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,1242 @@
|
|
|
1
|
+
# Brain v1 cycle math + persistence (sub-01 D3 + portions of D1/D2).
|
|
2
|
+
# Cycle math is owned by Brain — NEVER import from inbox_digest_jobs (parent §10 anti-pattern).
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import uuid
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from datetime import datetime, time, timedelta, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import aiosqlite
|
|
14
|
+
|
|
15
|
+
from core.api.services.brain.models import EventDraft, SourceFailure
|
|
16
|
+
from core.api.services.brain.scope import resolve_program
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_DECISION_MARKERS: frozenset[str] = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"merged",
|
|
24
|
+
"deployed",
|
|
25
|
+
"approved",
|
|
26
|
+
"rejected",
|
|
27
|
+
"closed_won",
|
|
28
|
+
"closed_lost",
|
|
29
|
+
"created_with_severity_critical",
|
|
30
|
+
"decision",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_DECISION_EVENT_TYPES: frozenset[str] = frozenset(
|
|
35
|
+
{"pr_changed", "task_changed", "learning_changed", "handoff_changed"}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ----------------------------------------------------------------------
|
|
40
|
+
# Cycle math (own this — do not import from inbox_digest_jobs)
|
|
41
|
+
# ----------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def current_brain_cycle_key(now: datetime, freeze_hour_utc: int) -> str:
|
|
45
|
+
"""Return YYYY-MM-DD (UTC) for the current cycle.
|
|
46
|
+
|
|
47
|
+
If wall clock is before freeze_hour_utc, the cycle still belongs to the
|
|
48
|
+
previous day — same rule as inbox_digest_jobs._current_cycle_key. We copy
|
|
49
|
+
the formula rather than import (cross-domain coupling guard).
|
|
50
|
+
"""
|
|
51
|
+
if now.hour < freeze_hour_utc:
|
|
52
|
+
return (now.date() - timedelta(days=1)).isoformat()
|
|
53
|
+
return now.date().isoformat()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cycle_cutoff_at(now: datetime, cutoff_hour_utc: int) -> datetime:
|
|
57
|
+
"""UTC ISO timestamp marking the substrate cutoff for this cycle."""
|
|
58
|
+
cycle_date = now.date()
|
|
59
|
+
if now.hour < cutoff_hour_utc:
|
|
60
|
+
cycle_date = cycle_date - timedelta(days=1)
|
|
61
|
+
return datetime.combine(
|
|
62
|
+
cycle_date,
|
|
63
|
+
time(hour=cutoff_hour_utc, tzinfo=timezone.utc),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ----------------------------------------------------------------------
|
|
68
|
+
# Stable event id derivation (BLAKE2b 16-byte, hex 32 chars)
|
|
69
|
+
# ----------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def canonical_evidence(evidence: dict[str, Any]) -> str:
|
|
73
|
+
"""Canonical JSON for hash stability. sort_keys + no whitespace."""
|
|
74
|
+
return json.dumps(evidence, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def evidence_hash(evidence: dict[str, Any]) -> str:
|
|
78
|
+
return hashlib.sha256(canonical_evidence(evidence).encode("utf-8")).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def make_event_id(
|
|
82
|
+
*, cycle_key: str, event_type: str, source_ref: str, evidence_hash_hex: str
|
|
83
|
+
) -> str:
|
|
84
|
+
"""Deterministic BLAKE2b stable id, 16 bytes → 32 hex chars."""
|
|
85
|
+
payload = f"{cycle_key}|{event_type}|{source_ref}|{evidence_hash_hex}".encode("utf-8")
|
|
86
|
+
return hashlib.blake2b(payload, digest_size=16).hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ----------------------------------------------------------------------
|
|
90
|
+
# brain_runs CRUD
|
|
91
|
+
# ----------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _utc_iso(dt: datetime) -> str:
|
|
95
|
+
return dt.astimezone(timezone.utc).isoformat()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def insert_brain_run(
|
|
99
|
+
db: aiosqlite.Connection,
|
|
100
|
+
*,
|
|
101
|
+
cycle_key: str,
|
|
102
|
+
workspace_id: str,
|
|
103
|
+
cycle_window_start_utc: datetime,
|
|
104
|
+
cycle_window_end_utc: datetime,
|
|
105
|
+
cutoff_hour_utc_at_run: int,
|
|
106
|
+
trigger: str,
|
|
107
|
+
triggered_by: str | None,
|
|
108
|
+
now: datetime,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Insert a brain_runs row with status='running' and return run_id."""
|
|
111
|
+
run_id = uuid.uuid4().hex
|
|
112
|
+
await db.execute(
|
|
113
|
+
"INSERT INTO brain_runs ("
|
|
114
|
+
" run_id, workspace_id, cycle_key,"
|
|
115
|
+
" cycle_window_start_utc, cycle_window_end_utc, cutoff_hour_utc_at_run,"
|
|
116
|
+
" scope_type, scope_key, trigger, triggered_by, started_at, status"
|
|
117
|
+
") VALUES (?, ?, ?, ?, ?, ?, 'company', '__company__', ?, ?, ?, 'running')",
|
|
118
|
+
(
|
|
119
|
+
run_id,
|
|
120
|
+
workspace_id,
|
|
121
|
+
cycle_key,
|
|
122
|
+
_utc_iso(cycle_window_start_utc),
|
|
123
|
+
_utc_iso(cycle_window_end_utc),
|
|
124
|
+
cutoff_hour_utc_at_run,
|
|
125
|
+
trigger,
|
|
126
|
+
triggered_by,
|
|
127
|
+
_utc_iso(now),
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
return run_id
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def update_run_status(
|
|
134
|
+
db: aiosqlite.Connection,
|
|
135
|
+
*,
|
|
136
|
+
run_id: str,
|
|
137
|
+
status: str,
|
|
138
|
+
event_count: int = 0,
|
|
139
|
+
partial_failures: list[SourceFailure] | None = None,
|
|
140
|
+
duration_ms: int | None = None,
|
|
141
|
+
error_summary: str | None = None,
|
|
142
|
+
finished_at: datetime | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Update terminal state on brain_runs."""
|
|
145
|
+
failures_json = json.dumps(
|
|
146
|
+
[
|
|
147
|
+
{"source_system": f.source_system, "error": f.error, "traceback": f.traceback}
|
|
148
|
+
for f in (partial_failures or [])
|
|
149
|
+
]
|
|
150
|
+
)
|
|
151
|
+
await db.execute(
|
|
152
|
+
"UPDATE brain_runs SET "
|
|
153
|
+
" status = ?,"
|
|
154
|
+
" event_count = ?,"
|
|
155
|
+
" partial_failures_json = ?,"
|
|
156
|
+
" duration_ms = ?,"
|
|
157
|
+
" error_summary = ?,"
|
|
158
|
+
" finished_at = ?"
|
|
159
|
+
" WHERE run_id = ?",
|
|
160
|
+
(
|
|
161
|
+
status,
|
|
162
|
+
event_count,
|
|
163
|
+
failures_json,
|
|
164
|
+
duration_ms,
|
|
165
|
+
error_summary,
|
|
166
|
+
_utc_iso(finished_at) if finished_at else None,
|
|
167
|
+
run_id,
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def supersede_active_runs(
|
|
173
|
+
db: aiosqlite.Connection,
|
|
174
|
+
*,
|
|
175
|
+
workspace_id: str,
|
|
176
|
+
cycle_key: str,
|
|
177
|
+
new_run_id: str,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Mark prior active runs for the same cycle as superseded.
|
|
180
|
+
|
|
181
|
+
Must be called BEFORE inserting the new run, otherwise the partial unique
|
|
182
|
+
index uniq_brain_runs_active_cycle will fire.
|
|
183
|
+
|
|
184
|
+
Wave 3.1 fix (Emilio 2026-05-19): `partial` runs are also superseded —
|
|
185
|
+
senza questo i journal entries di un run partial restavano "canonical"
|
|
186
|
+
nel reader filter (`r.status IN ('succeeded', 'partial') AND
|
|
187
|
+
superseded_by_run_id IS NULL`), affiancando l'entry empty del run
|
|
188
|
+
succeeded successivo e confondendo la UI.
|
|
189
|
+
"""
|
|
190
|
+
await db.execute(
|
|
191
|
+
"UPDATE brain_runs SET status = 'superseded', superseded_by_run_id = ? "
|
|
192
|
+
"WHERE workspace_id = ? AND cycle_key = ? "
|
|
193
|
+
"AND status IN ('running', 'succeeded', 'partial') "
|
|
194
|
+
"AND superseded_by_run_id IS NULL",
|
|
195
|
+
(new_run_id, workspace_id, cycle_key),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def supersede_orphan_partials(
|
|
200
|
+
db: aiosqlite.Connection,
|
|
201
|
+
*,
|
|
202
|
+
workspace_id: str = "ws_default",
|
|
203
|
+
) -> int:
|
|
204
|
+
"""One-shot backfill: mark partial runs orphan (no `superseded_by_run_id`)
|
|
205
|
+
as superseded by the latest succeeded run for the same `(workspace_id,
|
|
206
|
+
cycle_key)`.
|
|
207
|
+
|
|
208
|
+
Wave 3.1 (Emilio 2026-05-19): used to clean up historical partial runs
|
|
209
|
+
that landed in DB before the supersede_active_runs fix included
|
|
210
|
+
`'partial'` in its WHERE clause. Idempotent — partials without a later
|
|
211
|
+
succeeded run stay untouched.
|
|
212
|
+
|
|
213
|
+
Returns the count of rows updated.
|
|
214
|
+
"""
|
|
215
|
+
db.row_factory = aiosqlite.Row
|
|
216
|
+
cur = await db.execute(
|
|
217
|
+
"SELECT p.run_id AS partial_id,"
|
|
218
|
+
" (SELECT s.run_id FROM brain_runs s"
|
|
219
|
+
" WHERE s.workspace_id = p.workspace_id"
|
|
220
|
+
" AND s.cycle_key = p.cycle_key"
|
|
221
|
+
" AND s.status = 'succeeded'"
|
|
222
|
+
" AND s.superseded_by_run_id IS NULL"
|
|
223
|
+
" AND s.started_at > p.started_at"
|
|
224
|
+
" ORDER BY s.started_at DESC LIMIT 1) AS succ_id"
|
|
225
|
+
" FROM brain_runs p"
|
|
226
|
+
" WHERE p.workspace_id = ?"
|
|
227
|
+
" AND p.status = 'partial'"
|
|
228
|
+
" AND p.superseded_by_run_id IS NULL",
|
|
229
|
+
(workspace_id,),
|
|
230
|
+
)
|
|
231
|
+
rows = await cur.fetchall()
|
|
232
|
+
await cur.close()
|
|
233
|
+
pairs = [(r["partial_id"], r["succ_id"]) for r in rows if r["succ_id"]]
|
|
234
|
+
if not pairs:
|
|
235
|
+
return 0
|
|
236
|
+
updated = 0
|
|
237
|
+
for partial_id, succ_id in pairs:
|
|
238
|
+
result = await db.execute(
|
|
239
|
+
"UPDATE brain_runs SET status = 'superseded',"
|
|
240
|
+
" superseded_by_run_id = ?"
|
|
241
|
+
" WHERE run_id = ?"
|
|
242
|
+
" AND status = 'partial'"
|
|
243
|
+
" AND superseded_by_run_id IS NULL",
|
|
244
|
+
(succ_id, partial_id),
|
|
245
|
+
)
|
|
246
|
+
updated += result.rowcount if result.rowcount is not None else 0
|
|
247
|
+
return updated
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ----------------------------------------------------------------------
|
|
251
|
+
# Event persistence
|
|
252
|
+
# ----------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def derive_event_id(
|
|
256
|
+
*, cycle_key: str, event_type: str, source_ref: str, evidence: dict[str, Any]
|
|
257
|
+
) -> tuple[str, str]:
|
|
258
|
+
"""Return (event_id, evidence_hash) for the given canonicalized evidence."""
|
|
259
|
+
ev_hash = evidence_hash(evidence)
|
|
260
|
+
return (
|
|
261
|
+
make_event_id(
|
|
262
|
+
cycle_key=cycle_key,
|
|
263
|
+
event_type=event_type,
|
|
264
|
+
source_ref=source_ref,
|
|
265
|
+
evidence_hash_hex=ev_hash,
|
|
266
|
+
),
|
|
267
|
+
ev_hash,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def persist_event(
|
|
272
|
+
db: aiosqlite.Connection,
|
|
273
|
+
*,
|
|
274
|
+
run_id: str,
|
|
275
|
+
cycle_key: str,
|
|
276
|
+
draft: EventDraft,
|
|
277
|
+
) -> str:
|
|
278
|
+
"""Insert one event with deterministic id + INSERT OR IGNORE idempotency."""
|
|
279
|
+
evidence = dict(draft.evidence or {})
|
|
280
|
+
# Reject non-JSON-serializable evidence eagerly (test scenario).
|
|
281
|
+
canonical_evidence(evidence)
|
|
282
|
+
ev_hash = evidence_hash(evidence)
|
|
283
|
+
event_id = make_event_id(
|
|
284
|
+
cycle_key=cycle_key,
|
|
285
|
+
event_type=draft.event_type,
|
|
286
|
+
source_ref=draft.source_ref,
|
|
287
|
+
evidence_hash_hex=ev_hash,
|
|
288
|
+
)
|
|
289
|
+
program_key = draft.program_key or resolve_program(
|
|
290
|
+
draft.source_project or draft.target_project
|
|
291
|
+
)
|
|
292
|
+
await db.execute(
|
|
293
|
+
"INSERT OR IGNORE INTO brain_digest_events ("
|
|
294
|
+
" event_id, run_id, cycle_key,"
|
|
295
|
+
" observed_at, derived_from_state_at,"
|
|
296
|
+
" event_type, schema_version, source_system,"
|
|
297
|
+
" source_project, target_project, program_key,"
|
|
298
|
+
" source_ref, title, summary, evidence_json, evidence_hash"
|
|
299
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
300
|
+
(
|
|
301
|
+
event_id,
|
|
302
|
+
run_id,
|
|
303
|
+
cycle_key,
|
|
304
|
+
_utc_iso(draft.observed_at),
|
|
305
|
+
_utc_iso(draft.derived_from_state_at),
|
|
306
|
+
draft.event_type,
|
|
307
|
+
draft.schema_version,
|
|
308
|
+
draft.source_system,
|
|
309
|
+
draft.source_project,
|
|
310
|
+
draft.target_project,
|
|
311
|
+
program_key,
|
|
312
|
+
draft.source_ref,
|
|
313
|
+
draft.title,
|
|
314
|
+
draft.summary,
|
|
315
|
+
json.dumps(evidence, sort_keys=True, ensure_ascii=False),
|
|
316
|
+
ev_hash,
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
return event_id
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ----------------------------------------------------------------------
|
|
323
|
+
# Journal aggregation
|
|
324
|
+
# ----------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _classify_decision(event_type: str, evidence: dict[str, Any]) -> bool:
|
|
328
|
+
if event_type not in _DECISION_EVENT_TYPES:
|
|
329
|
+
return False
|
|
330
|
+
marker = evidence.get("decision_marker")
|
|
331
|
+
return isinstance(marker, str) and marker in _DECISION_MARKERS
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _build_body_for_events(events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
335
|
+
what_changed: list[dict[str, Any]] = []
|
|
336
|
+
decisions: list[str] = []
|
|
337
|
+
sources: list[str] = []
|
|
338
|
+
notable: list[dict[str, Any]] = []
|
|
339
|
+
domain_groups: dict[str, list[str]] = defaultdict(list)
|
|
340
|
+
|
|
341
|
+
for ev in events:
|
|
342
|
+
ev_id = ev["event_id"]
|
|
343
|
+
sources.append(ev_id)
|
|
344
|
+
domain_key = ev.get("event_type", "unknown").split("_", 1)[0]
|
|
345
|
+
domain_groups[domain_key].append(ev_id)
|
|
346
|
+
try:
|
|
347
|
+
evidence_obj = json.loads(ev.get("evidence_json") or "{}")
|
|
348
|
+
except (TypeError, json.JSONDecodeError):
|
|
349
|
+
evidence_obj = {}
|
|
350
|
+
if _classify_decision(ev["event_type"], evidence_obj):
|
|
351
|
+
decisions.append(ev_id)
|
|
352
|
+
salience = evidence_obj.get("salience")
|
|
353
|
+
if isinstance(salience, (int, float)) and salience >= 0.7:
|
|
354
|
+
notable.append({"event_id": ev_id, "title": ev.get("title", "")})
|
|
355
|
+
|
|
356
|
+
for domain, ids in sorted(domain_groups.items()):
|
|
357
|
+
what_changed.append({"domain": domain, "event_ids": ids})
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
"what_changed": what_changed,
|
|
361
|
+
"decisions_observed": decisions,
|
|
362
|
+
"open_loops": [],
|
|
363
|
+
"notable_context": notable,
|
|
364
|
+
"sources": sources,
|
|
365
|
+
"tomorrow_watch": [],
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _empty_body() -> dict[str, Any]:
|
|
370
|
+
return {
|
|
371
|
+
"what_changed": [],
|
|
372
|
+
"decisions_observed": [],
|
|
373
|
+
"open_loops": [],
|
|
374
|
+
"notable_context": [],
|
|
375
|
+
"sources": [],
|
|
376
|
+
"tomorrow_watch": [],
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _hydrate_event_rows(rows: list[Any]) -> list[dict[str, Any]]:
|
|
381
|
+
"""Internal: row → dict mapping shared by run-scoped and cycle-scoped reads."""
|
|
382
|
+
return [
|
|
383
|
+
{
|
|
384
|
+
"event_id": r[0] if not hasattr(r, "keys") else r["event_id"],
|
|
385
|
+
"event_type": r[1] if not hasattr(r, "keys") else r["event_type"],
|
|
386
|
+
"source_project": r[2] if not hasattr(r, "keys") else r["source_project"],
|
|
387
|
+
"target_project": r[3] if not hasattr(r, "keys") else r["target_project"],
|
|
388
|
+
"program_key": r[4] if not hasattr(r, "keys") else r["program_key"],
|
|
389
|
+
"title": r[5] if not hasattr(r, "keys") else r["title"],
|
|
390
|
+
"evidence_json": r[6] if not hasattr(r, "keys") else r["evidence_json"],
|
|
391
|
+
}
|
|
392
|
+
for r in rows
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
async def fetch_events_for_run(
|
|
397
|
+
db: aiosqlite.Connection, *, run_id: str
|
|
398
|
+
) -> list[dict[str, Any]]:
|
|
399
|
+
"""Read all events for a run. Caller decides on memory bounds."""
|
|
400
|
+
rows = await (
|
|
401
|
+
await db.execute(
|
|
402
|
+
"SELECT event_id, event_type, source_project, target_project, program_key, "
|
|
403
|
+
" title, evidence_json "
|
|
404
|
+
"FROM brain_digest_events WHERE run_id = ?",
|
|
405
|
+
(run_id,),
|
|
406
|
+
)
|
|
407
|
+
).fetchall()
|
|
408
|
+
return _hydrate_event_rows(rows)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def fetch_events_for_cycle(
|
|
412
|
+
db: aiosqlite.Connection,
|
|
413
|
+
*,
|
|
414
|
+
cycle_key: str,
|
|
415
|
+
workspace_id: str,
|
|
416
|
+
) -> list[dict[str, Any]]:
|
|
417
|
+
"""Read events for a `(cycle_key, workspace_id)` deduped by event_id.
|
|
418
|
+
|
|
419
|
+
Wave 3.1 fix (Emilio 2026-05-19): `persist_event` ha `INSERT OR IGNORE`
|
|
420
|
+
su event_id deterministico — quando un recompute force=true ri-collecta
|
|
421
|
+
gli stessi events, le INSERT duplicate vengono droppate e il nuovo run
|
|
422
|
+
vede 0 events nella sua `SELECT WHERE run_id`. Risultato: la canonical
|
|
423
|
+
succeeded run di un cycle ricomputato emetteva solo `is_empty=1` company
|
|
424
|
+
entry.
|
|
425
|
+
|
|
426
|
+
Soluzione: aggregare cross-run per cycle_key (event_id è già unique-key
|
|
427
|
+
deterministico, quindi nessun dedup esplicito necessario), così
|
|
428
|
+
`publish_run_journals` può popolare il body anche quando gli events
|
|
429
|
+
raw appartengono fisicamente a precedenti run superseded.
|
|
430
|
+
"""
|
|
431
|
+
# brain_digest_events ha cycle_key ma non workspace_id — uso JOIN
|
|
432
|
+
# su brain_runs per filtrare. Eseguo DISTINCT su event_id per dedupare
|
|
433
|
+
# se la stessa evidence è arrivata sotto run_id diversi nel cycle
|
|
434
|
+
# (raro ma possibile con recompute force=true + INSERT OR IGNORE).
|
|
435
|
+
rows = await (
|
|
436
|
+
await db.execute(
|
|
437
|
+
"SELECT DISTINCT e.event_id, e.event_type, e.source_project,"
|
|
438
|
+
" e.target_project, e.program_key, e.title, e.evidence_json"
|
|
439
|
+
" FROM brain_digest_events e"
|
|
440
|
+
" JOIN brain_runs r ON r.run_id = e.run_id"
|
|
441
|
+
" WHERE e.cycle_key = ? AND r.workspace_id = ?",
|
|
442
|
+
(cycle_key, workspace_id),
|
|
443
|
+
)
|
|
444
|
+
).fetchall()
|
|
445
|
+
return _hydrate_event_rows(rows)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
async def publish_run_journals(
|
|
449
|
+
db: aiosqlite.Connection,
|
|
450
|
+
*,
|
|
451
|
+
run_id: str,
|
|
452
|
+
cycle_key: str,
|
|
453
|
+
workspace_id: str,
|
|
454
|
+
now: datetime,
|
|
455
|
+
max_events: int = 10_000,
|
|
456
|
+
) -> int:
|
|
457
|
+
"""Aggregate events for a run into company/program/project journal entries.
|
|
458
|
+
|
|
459
|
+
Returns count of entries written (always >= 1 because empty cycles still
|
|
460
|
+
emit an `is_empty=1` company entry).
|
|
461
|
+
|
|
462
|
+
Wave 3.1 (Emilio 2026-05-19): legge events cycle-wide invece di filtrare
|
|
463
|
+
solo per run_id, perché `persist_event INSERT OR IGNORE` deduplica gli
|
|
464
|
+
event_id deterministici tra run successive del cycle. Senza questo, un
|
|
465
|
+
recompute force=true emetteva sempre company empty (events già nel DB
|
|
466
|
+
sotto run precedenti, nessuna nuova INSERT visibile a questo run).
|
|
467
|
+
"""
|
|
468
|
+
events = await fetch_events_for_cycle(
|
|
469
|
+
db, cycle_key=cycle_key, workspace_id=workspace_id
|
|
470
|
+
)
|
|
471
|
+
if len(events) > max_events:
|
|
472
|
+
raise RuntimeError(
|
|
473
|
+
f"cycle_too_large: {len(events)} events > {max_events} cap"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
company_body = _build_body_for_events(events)
|
|
477
|
+
is_empty_company = 1 if not events else 0
|
|
478
|
+
|
|
479
|
+
entries_written = 0
|
|
480
|
+
|
|
481
|
+
await _upsert_journal_entry(
|
|
482
|
+
db,
|
|
483
|
+
run_id=run_id,
|
|
484
|
+
workspace_id=workspace_id,
|
|
485
|
+
cycle_key=cycle_key,
|
|
486
|
+
scope_type="company",
|
|
487
|
+
scope_key="__company__",
|
|
488
|
+
program_key=None,
|
|
489
|
+
body=company_body,
|
|
490
|
+
is_empty=is_empty_company,
|
|
491
|
+
now=now,
|
|
492
|
+
)
|
|
493
|
+
entries_written += 1
|
|
494
|
+
|
|
495
|
+
project_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
496
|
+
program_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
497
|
+
project_program: dict[str, str | None] = {}
|
|
498
|
+
|
|
499
|
+
for ev in events:
|
|
500
|
+
scopes: set[str] = set()
|
|
501
|
+
if ev.get("source_project"):
|
|
502
|
+
scopes.add(ev["source_project"])
|
|
503
|
+
if ev.get("target_project"):
|
|
504
|
+
scopes.add(ev["target_project"])
|
|
505
|
+
for slug in scopes:
|
|
506
|
+
project_buckets[slug].append(ev)
|
|
507
|
+
program = ev.get("program_key") or resolve_program(slug)
|
|
508
|
+
project_program.setdefault(slug, program)
|
|
509
|
+
program_key = ev.get("program_key")
|
|
510
|
+
if program_key:
|
|
511
|
+
program_buckets[program_key].append(ev)
|
|
512
|
+
|
|
513
|
+
for slug, slug_events in project_buckets.items():
|
|
514
|
+
await _upsert_journal_entry(
|
|
515
|
+
db,
|
|
516
|
+
run_id=run_id,
|
|
517
|
+
workspace_id=workspace_id,
|
|
518
|
+
cycle_key=cycle_key,
|
|
519
|
+
scope_type="project",
|
|
520
|
+
scope_key=slug,
|
|
521
|
+
program_key=project_program.get(slug),
|
|
522
|
+
body=_build_body_for_events(slug_events),
|
|
523
|
+
is_empty=0,
|
|
524
|
+
now=now,
|
|
525
|
+
)
|
|
526
|
+
entries_written += 1
|
|
527
|
+
|
|
528
|
+
for prog_key, prog_events in program_buckets.items():
|
|
529
|
+
await _upsert_journal_entry(
|
|
530
|
+
db,
|
|
531
|
+
run_id=run_id,
|
|
532
|
+
workspace_id=workspace_id,
|
|
533
|
+
cycle_key=cycle_key,
|
|
534
|
+
scope_type="program",
|
|
535
|
+
scope_key=prog_key,
|
|
536
|
+
program_key=prog_key,
|
|
537
|
+
body=_build_body_for_events(prog_events),
|
|
538
|
+
is_empty=0,
|
|
539
|
+
now=now,
|
|
540
|
+
)
|
|
541
|
+
entries_written += 1
|
|
542
|
+
|
|
543
|
+
return entries_written
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
async def _upsert_journal_entry(
|
|
547
|
+
db: aiosqlite.Connection,
|
|
548
|
+
*,
|
|
549
|
+
run_id: str,
|
|
550
|
+
workspace_id: str,
|
|
551
|
+
cycle_key: str,
|
|
552
|
+
scope_type: str,
|
|
553
|
+
scope_key: str,
|
|
554
|
+
program_key: str | None,
|
|
555
|
+
body: dict[str, Any],
|
|
556
|
+
is_empty: int,
|
|
557
|
+
now: datetime,
|
|
558
|
+
) -> None:
|
|
559
|
+
body_json = json.dumps(body, sort_keys=True, ensure_ascii=False)
|
|
560
|
+
entry_id = uuid.uuid4().hex
|
|
561
|
+
await db.execute(
|
|
562
|
+
"INSERT INTO brain_journal_entries ("
|
|
563
|
+
" entry_id, run_id, workspace_id, cycle_key,"
|
|
564
|
+
" scope_type, scope_key, program_key,"
|
|
565
|
+
" body_json, is_empty, published_at"
|
|
566
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
567
|
+
"ON CONFLICT(run_id, scope_type, scope_key) DO UPDATE SET "
|
|
568
|
+
" body_json = excluded.body_json,"
|
|
569
|
+
" is_empty = excluded.is_empty,"
|
|
570
|
+
" program_key = excluded.program_key,"
|
|
571
|
+
" published_at = excluded.published_at",
|
|
572
|
+
(
|
|
573
|
+
entry_id,
|
|
574
|
+
run_id,
|
|
575
|
+
workspace_id,
|
|
576
|
+
cycle_key,
|
|
577
|
+
scope_type,
|
|
578
|
+
scope_key,
|
|
579
|
+
program_key,
|
|
580
|
+
body_json,
|
|
581
|
+
is_empty,
|
|
582
|
+
_utc_iso(now),
|
|
583
|
+
),
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# ----------------------------------------------------------------------
|
|
588
|
+
# Wave 3.1 gap 2 — persistent journal narrative polish at cycle time.
|
|
589
|
+
# ----------------------------------------------------------------------
|
|
590
|
+
#
|
|
591
|
+
# Unlike the read-time polish in llm/router_glue.py (TTL cache, transient),
|
|
592
|
+
# this helper writes narrative_polished + at + model directly to the row
|
|
593
|
+
# so historical reads (Console "Giornale ultimi 30 giorni") never miss a
|
|
594
|
+
# polish. Best-effort: failures keep the deterministic body_json visible.
|
|
595
|
+
|
|
596
|
+
_POLISH_KEEP_SECTIONS = ("what_changed", "decisions_observed", "open_loops", "notable_context")
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _polish_entries_for_prompt(body: dict[str, Any]) -> list[dict[str, Any]]:
|
|
600
|
+
flat: list[dict[str, Any]] = []
|
|
601
|
+
for label in _POLISH_KEEP_SECTIONS:
|
|
602
|
+
items = body.get(label) or []
|
|
603
|
+
if not items:
|
|
604
|
+
continue
|
|
605
|
+
flat.append({"section": label, "items": items})
|
|
606
|
+
return flat
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _polish_allowed_refs(body: dict[str, Any]) -> list[str]:
|
|
610
|
+
refs: list[str] = []
|
|
611
|
+
for ref in body.get("sources") or []:
|
|
612
|
+
if isinstance(ref, str) and ref:
|
|
613
|
+
refs.append(ref)
|
|
614
|
+
for label in _POLISH_KEEP_SECTIONS + ("tomorrow_watch",):
|
|
615
|
+
for item in body.get(label) or []:
|
|
616
|
+
if isinstance(item, dict):
|
|
617
|
+
ref = item.get("ref") or item.get("evidence_ref")
|
|
618
|
+
if isinstance(ref, str) and ref:
|
|
619
|
+
refs.append(ref)
|
|
620
|
+
seen: set[str] = set()
|
|
621
|
+
out: list[str] = []
|
|
622
|
+
for ref in refs:
|
|
623
|
+
if ref not in seen:
|
|
624
|
+
seen.add(ref)
|
|
625
|
+
out.append(ref)
|
|
626
|
+
return out
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _polish_deterministic_narrative(body: dict[str, Any]) -> str:
|
|
630
|
+
decisions = body.get("decisions_observed") or []
|
|
631
|
+
if decisions:
|
|
632
|
+
return " ".join(str(d) for d in decisions[:3])
|
|
633
|
+
open_loops = body.get("open_loops") or []
|
|
634
|
+
if open_loops:
|
|
635
|
+
first = open_loops[0]
|
|
636
|
+
if isinstance(first, dict):
|
|
637
|
+
title = first.get("title") or first.get("summary")
|
|
638
|
+
if isinstance(title, str) and title:
|
|
639
|
+
return title
|
|
640
|
+
return ""
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# ----------------------------------------------------------------------
|
|
644
|
+
# Wave 3.1 smart polish payload (Emilio 2026-05-19 19:48 — decisione regole).
|
|
645
|
+
# ----------------------------------------------------------------------
|
|
646
|
+
#
|
|
647
|
+
# Il body_json delle entries scope=company aggrega fino a 1100+ event_ids
|
|
648
|
+
# (es. 2026-05-19 company __company__ ha 1121 sources, 80KB di JSON). Il
|
|
649
|
+
# polish gateway tier-write rifiuta HTTP 422 quando il prompt supera il
|
|
650
|
+
# context window di Gemma 3 12B QAT.
|
|
651
|
+
#
|
|
652
|
+
# Regole concordate per ridurre il payload mantenendo significato:
|
|
653
|
+
# - PR mergiati (`pr_changed` con marker decisional): tutti, titolo + project
|
|
654
|
+
# - Task completati (`task_changed` con marker decisional): tutti
|
|
655
|
+
# - Learning registrati (`learning_changed`): tutti
|
|
656
|
+
# - Handoff scritti (`handoff_changed`): max 3 per progetto, recency DESC
|
|
657
|
+
# - Commit (`commit_changed` / `file_changed`): aggregato per progetto, no list
|
|
658
|
+
# - KG (`kg_changed`): top-5 nodi più toccati nel cycle + count totale
|
|
659
|
+
# - Altri (`ingest_changed`, `doc_changed`, `regression_signal`,
|
|
660
|
+
# `external_update_seen`): aggregato per event_type, no list
|
|
661
|
+
#
|
|
662
|
+
# Output target: ~3KB JSON invece di 80KB → Gemma context safe (~1.5K tokens).
|
|
663
|
+
|
|
664
|
+
_DECISION_MARKERS_LOWER = frozenset(m.lower() for m in _DECISION_MARKERS)
|
|
665
|
+
_HANDOFFS_PER_PROJECT_CAP = 2
|
|
666
|
+
_KG_HOTSPOTS_CAP = 5
|
|
667
|
+
_DECISIONS_TOTAL_CAP = 15 # Mac Gateway 4KB prompt budget gate (Gemma 12B prefill latency)
|
|
668
|
+
_ALLOWED_REFS_CAP = 30
|
|
669
|
+
_TITLE_MAX_CHARS = 90
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _is_decisional(event_type: str, evidence_json: str, title: str, summary: str) -> bool:
|
|
673
|
+
"""An event qualifies as 'decision' when it's a state-change marker
|
|
674
|
+
or its evidence carries one of the canonical markers.
|
|
675
|
+
|
|
676
|
+
Robust to bodies where evidence is sparse — we also scan title/summary.
|
|
677
|
+
"""
|
|
678
|
+
if event_type not in _DECISION_EVENT_TYPES:
|
|
679
|
+
return False
|
|
680
|
+
haystack = " ".join((evidence_json or "", title or "", summary or "")).lower()
|
|
681
|
+
return any(marker in haystack for marker in _DECISION_MARKERS_LOWER)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
async def _fetch_run_events(
|
|
685
|
+
db: aiosqlite.Connection,
|
|
686
|
+
*,
|
|
687
|
+
run_id: str,
|
|
688
|
+
scope_type: str,
|
|
689
|
+
scope_key: str,
|
|
690
|
+
cycle_key: str | None = None,
|
|
691
|
+
workspace_id: str = "ws_default",
|
|
692
|
+
) -> list[dict[str, Any]]:
|
|
693
|
+
"""Read events for the smart polish payload.
|
|
694
|
+
|
|
695
|
+
Wave 3.1 cycle-wide hop (Emilio 2026-05-19): se `cycle_key` è fornito,
|
|
696
|
+
legge cross-run per `(cycle_key, workspace_id)` filtrato per scope.
|
|
697
|
+
Necessario perché `persist_event INSERT OR IGNORE` deduplica event_id
|
|
698
|
+
deterministici tra recompute successivi — il canonical run finisce con
|
|
699
|
+
0 events sotto la sua run_id, e la SELECT WHERE run_id ritorna [] →
|
|
700
|
+
polish skip `no_refs`.
|
|
701
|
+
|
|
702
|
+
Senza `cycle_key` (fallback) usa il filter `run_id` legacy.
|
|
703
|
+
|
|
704
|
+
company → tutti gli events
|
|
705
|
+
project → events con source_project = scope_key OR target_project = scope_key
|
|
706
|
+
program → events con program_key = scope_key
|
|
707
|
+
"""
|
|
708
|
+
if cycle_key is not None:
|
|
709
|
+
where = ["e.cycle_key = ?", "r.workspace_id = ?"]
|
|
710
|
+
params: list[Any] = [cycle_key, workspace_id]
|
|
711
|
+
if scope_type == "project":
|
|
712
|
+
where.append("(e.source_project = ? OR e.target_project = ?)")
|
|
713
|
+
params.extend([scope_key, scope_key])
|
|
714
|
+
elif scope_type == "program":
|
|
715
|
+
where.append("e.program_key = ?")
|
|
716
|
+
params.append(scope_key)
|
|
717
|
+
db.row_factory = aiosqlite.Row
|
|
718
|
+
cur = await db.execute(
|
|
719
|
+
"SELECT DISTINCT e.event_id, e.event_type, e.source_system,"
|
|
720
|
+
" e.source_project, e.target_project, e.program_key, e.source_ref,"
|
|
721
|
+
" e.title, e.summary, e.evidence_json, e.observed_at"
|
|
722
|
+
" FROM brain_digest_events e"
|
|
723
|
+
" JOIN brain_runs r ON r.run_id = e.run_id"
|
|
724
|
+
f" WHERE {' AND '.join(where)}"
|
|
725
|
+
" ORDER BY e.observed_at DESC",
|
|
726
|
+
params,
|
|
727
|
+
)
|
|
728
|
+
rows = await cur.fetchall()
|
|
729
|
+
await cur.close()
|
|
730
|
+
return [dict(r) for r in rows]
|
|
731
|
+
|
|
732
|
+
where = ["run_id = ?"]
|
|
733
|
+
params = [run_id]
|
|
734
|
+
if scope_type == "project":
|
|
735
|
+
where.append("(source_project = ? OR target_project = ?)")
|
|
736
|
+
params.extend([scope_key, scope_key])
|
|
737
|
+
elif scope_type == "program":
|
|
738
|
+
where.append("program_key = ?")
|
|
739
|
+
params.append(scope_key)
|
|
740
|
+
|
|
741
|
+
db.row_factory = aiosqlite.Row
|
|
742
|
+
cur = await db.execute(
|
|
743
|
+
"SELECT event_id, event_type, source_system, source_project, target_project,"
|
|
744
|
+
" program_key, source_ref, title, summary, evidence_json, observed_at"
|
|
745
|
+
" FROM brain_digest_events"
|
|
746
|
+
f" WHERE {' AND '.join(where)}"
|
|
747
|
+
" ORDER BY observed_at DESC",
|
|
748
|
+
params,
|
|
749
|
+
)
|
|
750
|
+
rows = await cur.fetchall()
|
|
751
|
+
await cur.close()
|
|
752
|
+
return [dict(r) for r in rows]
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _build_smart_polish_payload(
|
|
756
|
+
*,
|
|
757
|
+
events: list[dict[str, Any]],
|
|
758
|
+
scope_type: str,
|
|
759
|
+
scope_key: str,
|
|
760
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
761
|
+
"""Apply Emilio's reduction rules. Returns `(entries, allowed_refs)`.
|
|
762
|
+
|
|
763
|
+
`entries` is a single-element list with `section='cycle_summary'` so
|
|
764
|
+
`polish_journal_entry` keeps its current call shape. The payload inside
|
|
765
|
+
is a structured dict with `decisions`, `handoffs`, `commits_by_project`,
|
|
766
|
+
`kg_hotspots`, `other_aggregates`.
|
|
767
|
+
"""
|
|
768
|
+
decisions: list[dict[str, Any]] = []
|
|
769
|
+
handoffs_by_project: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
770
|
+
commits_by_project: dict[str, int] = defaultdict(int)
|
|
771
|
+
kg_by_ref: dict[str, dict[str, Any]] = {}
|
|
772
|
+
other_by_type: dict[str, int] = defaultdict(int)
|
|
773
|
+
allowed_refs: list[str] = []
|
|
774
|
+
|
|
775
|
+
for ev in events:
|
|
776
|
+
et = ev.get("event_type") or ""
|
|
777
|
+
title = ev.get("title") or ""
|
|
778
|
+
summary = ev.get("summary") or ""
|
|
779
|
+
evidence_json = ev.get("evidence_json") or ""
|
|
780
|
+
proj = ev.get("source_project") or ev.get("target_project") or "unknown"
|
|
781
|
+
source_ref = ev.get("source_ref") or ""
|
|
782
|
+
|
|
783
|
+
# PR / task / learning / handoff
|
|
784
|
+
if et == "pr_changed" and _is_decisional(et, evidence_json, title, summary):
|
|
785
|
+
decisions.append({
|
|
786
|
+
"type": "pr_merged",
|
|
787
|
+
"project": proj,
|
|
788
|
+
"title": title[:_TITLE_MAX_CHARS],
|
|
789
|
+
"ref": source_ref,
|
|
790
|
+
})
|
|
791
|
+
allowed_refs.append(source_ref)
|
|
792
|
+
continue
|
|
793
|
+
|
|
794
|
+
if et == "task_changed" and _is_decisional(et, evidence_json, title, summary):
|
|
795
|
+
decisions.append({
|
|
796
|
+
"type": "task_completed",
|
|
797
|
+
"project": proj,
|
|
798
|
+
"title": title[:_TITLE_MAX_CHARS],
|
|
799
|
+
"ref": source_ref,
|
|
800
|
+
})
|
|
801
|
+
allowed_refs.append(source_ref)
|
|
802
|
+
continue
|
|
803
|
+
|
|
804
|
+
if et == "learning_changed":
|
|
805
|
+
decisions.append({
|
|
806
|
+
"type": "learning_registered",
|
|
807
|
+
"project": proj,
|
|
808
|
+
"title": title[:_TITLE_MAX_CHARS],
|
|
809
|
+
"ref": source_ref,
|
|
810
|
+
})
|
|
811
|
+
allowed_refs.append(source_ref)
|
|
812
|
+
continue
|
|
813
|
+
|
|
814
|
+
if et == "handoff_changed":
|
|
815
|
+
bucket = handoffs_by_project[proj]
|
|
816
|
+
if len(bucket) < _HANDOFFS_PER_PROJECT_CAP:
|
|
817
|
+
bucket.append({
|
|
818
|
+
"project": proj,
|
|
819
|
+
"title": title[:_TITLE_MAX_CHARS],
|
|
820
|
+
"ref": source_ref,
|
|
821
|
+
})
|
|
822
|
+
allowed_refs.append(source_ref)
|
|
823
|
+
continue
|
|
824
|
+
|
|
825
|
+
if et in ("commit_changed", "file_changed"):
|
|
826
|
+
commits_by_project[proj] += 1
|
|
827
|
+
continue
|
|
828
|
+
|
|
829
|
+
if et == "kg_changed":
|
|
830
|
+
slot = kg_by_ref.get(source_ref)
|
|
831
|
+
if slot is None:
|
|
832
|
+
kg_by_ref[source_ref] = {
|
|
833
|
+
"ref": source_ref,
|
|
834
|
+
"project": proj,
|
|
835
|
+
"title": title[:160],
|
|
836
|
+
"count": 1,
|
|
837
|
+
}
|
|
838
|
+
else:
|
|
839
|
+
slot["count"] += 1
|
|
840
|
+
continue
|
|
841
|
+
|
|
842
|
+
# Catch-all
|
|
843
|
+
other_by_type[et] += 1
|
|
844
|
+
|
|
845
|
+
# Flatten handoffs preserving project order (alphabetical for stability).
|
|
846
|
+
handoffs: list[dict[str, Any]] = []
|
|
847
|
+
for proj in sorted(handoffs_by_project.keys()):
|
|
848
|
+
handoffs.extend(handoffs_by_project[proj])
|
|
849
|
+
|
|
850
|
+
# Decisions cap — keep most recent (events arrive observed_at DESC).
|
|
851
|
+
if len(decisions) > _DECISIONS_TOTAL_CAP:
|
|
852
|
+
decisions = decisions[:_DECISIONS_TOTAL_CAP]
|
|
853
|
+
|
|
854
|
+
# KG hotspots — top by touch count.
|
|
855
|
+
kg_hotspots = sorted(
|
|
856
|
+
kg_by_ref.values(), key=lambda h: (-int(h.get("count", 0)), h.get("ref", ""))
|
|
857
|
+
)[:_KG_HOTSPOTS_CAP]
|
|
858
|
+
for hot in kg_hotspots:
|
|
859
|
+
ref = hot.get("ref")
|
|
860
|
+
if isinstance(ref, str) and ref:
|
|
861
|
+
allowed_refs.append(ref)
|
|
862
|
+
|
|
863
|
+
kg_total = sum(int(h.get("count", 0)) for h in kg_by_ref.values())
|
|
864
|
+
|
|
865
|
+
payload = {
|
|
866
|
+
"scope_type": scope_type,
|
|
867
|
+
"scope_key": scope_key,
|
|
868
|
+
"decisions": decisions,
|
|
869
|
+
"handoffs": handoffs,
|
|
870
|
+
"kg_hotspots": kg_hotspots,
|
|
871
|
+
"aggregates": {
|
|
872
|
+
"commits_by_project": dict(
|
|
873
|
+
sorted(commits_by_project.items(), key=lambda kv: -kv[1])
|
|
874
|
+
),
|
|
875
|
+
"kg_changes_total": kg_total,
|
|
876
|
+
"other_events_by_type": dict(other_by_type),
|
|
877
|
+
"events_total": len(events),
|
|
878
|
+
},
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
seen: set[str] = set()
|
|
882
|
+
dedup_refs: list[str] = []
|
|
883
|
+
for ref in allowed_refs:
|
|
884
|
+
if ref and ref not in seen:
|
|
885
|
+
seen.add(ref)
|
|
886
|
+
dedup_refs.append(ref)
|
|
887
|
+
if len(dedup_refs) > _ALLOWED_REFS_CAP:
|
|
888
|
+
dedup_refs = dedup_refs[:_ALLOWED_REFS_CAP]
|
|
889
|
+
|
|
890
|
+
return ([{"section": "cycle_summary", "items": [payload]}], dedup_refs)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
async def polish_run_journals(
|
|
894
|
+
*,
|
|
895
|
+
run_id: str,
|
|
896
|
+
workspace_id: str = "ws_default",
|
|
897
|
+
now: datetime | None = None,
|
|
898
|
+
) -> int:
|
|
899
|
+
"""Polish every non-empty journal entry of a run and persist the result.
|
|
900
|
+
|
|
901
|
+
Behaviour:
|
|
902
|
+
* No-op when `settings.brain_llm_polish_enabled` is false.
|
|
903
|
+
* Skips rows with `is_empty=1` — there is nothing narrative-worthy.
|
|
904
|
+
* Skips rows that already have `narrative_polished IS NOT NULL` (idempotent).
|
|
905
|
+
* Skips rows with zero allowed_evidence_refs (grounding floor).
|
|
906
|
+
* Failures swallowed and logged; deterministic body_json stays visible.
|
|
907
|
+
|
|
908
|
+
Returns the count of rows updated.
|
|
909
|
+
"""
|
|
910
|
+
# Lazy import to keep cycle.py importable without the LLM stack in tests
|
|
911
|
+
# that monkey-patch the brain_llm subsystem.
|
|
912
|
+
from core.api.config import settings as _settings
|
|
913
|
+
from core.api.db import write_db
|
|
914
|
+
from core.api.services.brain.llm.factory import ( # type: ignore
|
|
915
|
+
BrainLLMConfigError,
|
|
916
|
+
get_brain_llm_service,
|
|
917
|
+
)
|
|
918
|
+
from core.api.services.brain.llm.journal_polish import polish_journal_entry
|
|
919
|
+
|
|
920
|
+
if not getattr(_settings, "brain_llm_polish_enabled", False):
|
|
921
|
+
logger.info(
|
|
922
|
+
"polish_run_journals skip reason=disabled run_id=%s", run_id
|
|
923
|
+
)
|
|
924
|
+
return 0
|
|
925
|
+
|
|
926
|
+
try:
|
|
927
|
+
service = get_brain_llm_service()
|
|
928
|
+
except BrainLLMConfigError as exc:
|
|
929
|
+
logger.warning(
|
|
930
|
+
"polish_run_journals skip reason=misconfig run_id=%s detail=%s",
|
|
931
|
+
run_id, exc,
|
|
932
|
+
)
|
|
933
|
+
return 0
|
|
934
|
+
except Exception: # noqa: BLE001 — never break the cycle
|
|
935
|
+
logger.warning(
|
|
936
|
+
"polish_run_journals skip reason=service_unavailable run_id=%s",
|
|
937
|
+
run_id, exc_info=True,
|
|
938
|
+
)
|
|
939
|
+
return 0
|
|
940
|
+
|
|
941
|
+
# Wave 3.1 polish UX fix (Emilio 2026-05-19): grounding_strict obbligava
|
|
942
|
+
# il LLM a citare ≥1 evidence_ref dal whitelist e il check rifiutava
|
|
943
|
+
# come `not_success` ~50% delle entries (~72% per scope=company aggregato).
|
|
944
|
+
# Il body_json passato al LLM è già ground truth (eventi deterministici);
|
|
945
|
+
# un secondo gate citation non previene hallucination reale ma blocca
|
|
946
|
+
# narrative valide. Disabilitiamo by-default — `brain_llm_grounding_strict`
|
|
947
|
+
# setting resta come escape hatch operatore via .env, ma default False.
|
|
948
|
+
grounding_strict = bool(
|
|
949
|
+
getattr(_settings, "brain_llm_grounding_strict", False)
|
|
950
|
+
)
|
|
951
|
+
now_iso = _utc_iso(now or datetime.now(timezone.utc))
|
|
952
|
+
|
|
953
|
+
rows: list[tuple[str, str, str, str, str]] = []
|
|
954
|
+
async with write_db() as db:
|
|
955
|
+
db.row_factory = aiosqlite.Row
|
|
956
|
+
cur = await db.execute(
|
|
957
|
+
"SELECT entry_id, scope_type, scope_key, body_json, cycle_key"
|
|
958
|
+
" FROM brain_journal_entries"
|
|
959
|
+
" WHERE run_id = ? AND workspace_id = ? AND is_empty = 0"
|
|
960
|
+
" AND narrative_polished IS NULL",
|
|
961
|
+
(run_id, workspace_id),
|
|
962
|
+
)
|
|
963
|
+
async for row in cur:
|
|
964
|
+
rows.append(
|
|
965
|
+
(
|
|
966
|
+
row["entry_id"],
|
|
967
|
+
row["scope_type"],
|
|
968
|
+
row["scope_key"],
|
|
969
|
+
row["body_json"] or "{}",
|
|
970
|
+
row["cycle_key"],
|
|
971
|
+
)
|
|
972
|
+
)
|
|
973
|
+
await cur.close()
|
|
974
|
+
|
|
975
|
+
# Wave 3.1 silent-skip-v3 prevention (learning 46d8d1d4): emit INFO log at
|
|
976
|
+
# start so journalctl always shows whether polish actually ran. Counts
|
|
977
|
+
# below per skip reason close the diagnostic loop without re-deploying.
|
|
978
|
+
logger.info(
|
|
979
|
+
"polish_run_journals start run_id=%s rows=%d grounding_strict=%s",
|
|
980
|
+
run_id, len(rows), grounding_strict,
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
updated = 0
|
|
984
|
+
skipped_invalid_json = 0
|
|
985
|
+
skipped_no_refs = 0
|
|
986
|
+
skipped_polish_exception = 0
|
|
987
|
+
skipped_polish_not_success = 0
|
|
988
|
+
skipped_polish_empty = 0
|
|
989
|
+
for entry_id, scope_type, scope_key, body_json, cycle_key in rows:
|
|
990
|
+
try:
|
|
991
|
+
body = json.loads(body_json)
|
|
992
|
+
except json.JSONDecodeError:
|
|
993
|
+
skipped_invalid_json += 1
|
|
994
|
+
continue
|
|
995
|
+
# Wave 3.1 smart payload (Emilio 2026-05-19): query brain_digest_events
|
|
996
|
+
# for this run + scope, apply reduction rules (decisions all / handoffs
|
|
997
|
+
# cap 3 per project / commits aggregated / kg hotspots top 5 / others
|
|
998
|
+
# aggregated). Output ~3KB JSON instead of the up-to-80KB body sources.
|
|
999
|
+
async with write_db() as ev_db:
|
|
1000
|
+
events_for_polish = await _fetch_run_events(
|
|
1001
|
+
ev_db, run_id=run_id, scope_type=scope_type, scope_key=scope_key,
|
|
1002
|
+
cycle_key=cycle_key, workspace_id=workspace_id,
|
|
1003
|
+
)
|
|
1004
|
+
entries_payload, allowed_refs = _build_smart_polish_payload(
|
|
1005
|
+
events=events_for_polish, scope_type=scope_type, scope_key=scope_key
|
|
1006
|
+
)
|
|
1007
|
+
if not allowed_refs:
|
|
1008
|
+
skipped_no_refs += 1
|
|
1009
|
+
continue
|
|
1010
|
+
try:
|
|
1011
|
+
result = await polish_journal_entry(
|
|
1012
|
+
service=service,
|
|
1013
|
+
grounding_strict=grounding_strict,
|
|
1014
|
+
run_id=run_id,
|
|
1015
|
+
entry_id=entry_id,
|
|
1016
|
+
scope_type=scope_type,
|
|
1017
|
+
scope_key=scope_key,
|
|
1018
|
+
cycle_key=cycle_key,
|
|
1019
|
+
entries=entries_payload,
|
|
1020
|
+
allowed_evidence_refs=allowed_refs,
|
|
1021
|
+
deterministic_narrative=_polish_deterministic_narrative(body),
|
|
1022
|
+
)
|
|
1023
|
+
except Exception: # noqa: BLE001 — never break the cycle
|
|
1024
|
+
logger.warning(
|
|
1025
|
+
"polish_run_journals_polish_failed entry_id=%s scope=%s/%s",
|
|
1026
|
+
entry_id,
|
|
1027
|
+
scope_type,
|
|
1028
|
+
scope_key,
|
|
1029
|
+
exc_info=True,
|
|
1030
|
+
)
|
|
1031
|
+
skipped_polish_exception += 1
|
|
1032
|
+
continue
|
|
1033
|
+
if not result.success:
|
|
1034
|
+
logger.info(
|
|
1035
|
+
"polish skip reason=not_success entry_id=%s scope=%s/%s "
|
|
1036
|
+
"purpose=%s fallback_reason=%s",
|
|
1037
|
+
entry_id, scope_type, scope_key,
|
|
1038
|
+
getattr(result, "purpose", "?"),
|
|
1039
|
+
getattr(result, "reason", "?"),
|
|
1040
|
+
)
|
|
1041
|
+
skipped_polish_not_success += 1
|
|
1042
|
+
continue
|
|
1043
|
+
polished_text = (result.polished or {}).get("narrative_polished") or None
|
|
1044
|
+
if not polished_text:
|
|
1045
|
+
skipped_polish_empty += 1
|
|
1046
|
+
continue
|
|
1047
|
+
async with write_db() as db:
|
|
1048
|
+
await db.execute(
|
|
1049
|
+
"UPDATE brain_journal_entries SET"
|
|
1050
|
+
" narrative_polished = ?,"
|
|
1051
|
+
" narrative_polished_at = ?,"
|
|
1052
|
+
" narrative_polished_model = ?"
|
|
1053
|
+
" WHERE entry_id = ?",
|
|
1054
|
+
(polished_text, now_iso, result.model or "", entry_id),
|
|
1055
|
+
)
|
|
1056
|
+
updated += 1
|
|
1057
|
+
logger.info(
|
|
1058
|
+
"polish_run_journals done run_id=%s updated=%d "
|
|
1059
|
+
"skip{invalid_json=%d, no_refs=%d, polish_exception=%d, "
|
|
1060
|
+
"not_success=%d, empty=%d}",
|
|
1061
|
+
run_id, updated,
|
|
1062
|
+
skipped_invalid_json, skipped_no_refs,
|
|
1063
|
+
skipped_polish_exception, skipped_polish_not_success,
|
|
1064
|
+
skipped_polish_empty,
|
|
1065
|
+
)
|
|
1066
|
+
return updated
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
async def polish_pending_journals(
|
|
1070
|
+
*,
|
|
1071
|
+
workspace_id: str = "ws_default",
|
|
1072
|
+
limit: int = 100,
|
|
1073
|
+
now: datetime | None = None,
|
|
1074
|
+
) -> dict[str, Any]:
|
|
1075
|
+
"""Backfill polish for historic non-empty entries with `narrative_polished IS NULL`.
|
|
1076
|
+
|
|
1077
|
+
Unlike `polish_run_journals` (which filters by `run_id` and runs inline
|
|
1078
|
+
after `publish_run_journals`), this helper scans ALL entries across all
|
|
1079
|
+
runs that still need polish. Built to recover the cohort that landed in
|
|
1080
|
+
DB before the X-Sync header fix (silent-skip v3).
|
|
1081
|
+
|
|
1082
|
+
Cap `limit` per call so callers can paginate from cron / curl without
|
|
1083
|
+
holding the writer lock for minutes. Returns a structured envelope:
|
|
1084
|
+
`{updated, skip_*, remaining}` so the operator knows when to stop calling.
|
|
1085
|
+
"""
|
|
1086
|
+
from core.api.config import settings as _settings
|
|
1087
|
+
from core.api.db import write_db
|
|
1088
|
+
from core.api.services.brain.llm.factory import ( # type: ignore
|
|
1089
|
+
BrainLLMConfigError,
|
|
1090
|
+
get_brain_llm_service,
|
|
1091
|
+
)
|
|
1092
|
+
from core.api.services.brain.llm.journal_polish import polish_journal_entry
|
|
1093
|
+
|
|
1094
|
+
if not getattr(_settings, "brain_llm_polish_enabled", False):
|
|
1095
|
+
logger.info("polish_pending_journals skip reason=disabled")
|
|
1096
|
+
return {"updated": 0, "remaining": 0, "skipped_reason": "disabled"}
|
|
1097
|
+
|
|
1098
|
+
try:
|
|
1099
|
+
service = get_brain_llm_service()
|
|
1100
|
+
except BrainLLMConfigError as exc:
|
|
1101
|
+
logger.warning("polish_pending_journals skip reason=misconfig detail=%s", exc)
|
|
1102
|
+
return {"updated": 0, "remaining": 0, "skipped_reason": f"misconfig:{exc}"}
|
|
1103
|
+
except Exception: # noqa: BLE001 — never break the cycle
|
|
1104
|
+
logger.warning("polish_pending_journals skip reason=service_unavailable", exc_info=True)
|
|
1105
|
+
return {"updated": 0, "remaining": 0, "skipped_reason": "service_unavailable"}
|
|
1106
|
+
|
|
1107
|
+
# Wave 3.1 polish UX fix (Emilio 2026-05-19): grounding_strict default
|
|
1108
|
+
# disabilitato — vedi nota in `polish_run_journals` sopra.
|
|
1109
|
+
grounding_strict = bool(getattr(_settings, "brain_llm_grounding_strict", False))
|
|
1110
|
+
now_iso = _utc_iso(now or datetime.now(timezone.utc))
|
|
1111
|
+
|
|
1112
|
+
# Fetch a bounded slice of pending entries — newest first so the operator
|
|
1113
|
+
# sees recent cycles polished before deep history.
|
|
1114
|
+
rows: list[tuple[str, str, str, str, str, str]] = []
|
|
1115
|
+
async with write_db() as db:
|
|
1116
|
+
db.row_factory = aiosqlite.Row
|
|
1117
|
+
cur = await db.execute(
|
|
1118
|
+
"SELECT entry_id, run_id, scope_type, scope_key, body_json, cycle_key"
|
|
1119
|
+
" FROM brain_journal_entries"
|
|
1120
|
+
" WHERE workspace_id = ? AND is_empty = 0"
|
|
1121
|
+
" AND narrative_polished IS NULL"
|
|
1122
|
+
" ORDER BY cycle_key DESC, scope_type ASC, scope_key ASC"
|
|
1123
|
+
" LIMIT ?",
|
|
1124
|
+
(workspace_id, int(limit)),
|
|
1125
|
+
)
|
|
1126
|
+
async for row in cur:
|
|
1127
|
+
rows.append(
|
|
1128
|
+
(
|
|
1129
|
+
row["entry_id"],
|
|
1130
|
+
row["run_id"],
|
|
1131
|
+
row["scope_type"],
|
|
1132
|
+
row["scope_key"],
|
|
1133
|
+
row["body_json"] or "{}",
|
|
1134
|
+
row["cycle_key"],
|
|
1135
|
+
)
|
|
1136
|
+
)
|
|
1137
|
+
await cur.close()
|
|
1138
|
+
remaining_cur = await db.execute(
|
|
1139
|
+
"SELECT count(*) FROM brain_journal_entries"
|
|
1140
|
+
" WHERE workspace_id = ? AND is_empty = 0"
|
|
1141
|
+
" AND narrative_polished IS NULL",
|
|
1142
|
+
(workspace_id,),
|
|
1143
|
+
)
|
|
1144
|
+
remaining_row = await remaining_cur.fetchone()
|
|
1145
|
+
await remaining_cur.close()
|
|
1146
|
+
total_pending = int(remaining_row[0] if remaining_row else 0)
|
|
1147
|
+
|
|
1148
|
+
logger.info(
|
|
1149
|
+
"polish_pending_journals start picked=%d remaining=%d limit=%d grounding_strict=%s",
|
|
1150
|
+
len(rows), total_pending, limit, grounding_strict,
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
updated = 0
|
|
1154
|
+
skipped_invalid_json = 0
|
|
1155
|
+
skipped_no_refs = 0
|
|
1156
|
+
skipped_polish_exception = 0
|
|
1157
|
+
skipped_polish_not_success = 0
|
|
1158
|
+
skipped_polish_empty = 0
|
|
1159
|
+
for entry_id, run_id, scope_type, scope_key, body_json, cycle_key in rows:
|
|
1160
|
+
try:
|
|
1161
|
+
body = json.loads(body_json)
|
|
1162
|
+
except json.JSONDecodeError:
|
|
1163
|
+
skipped_invalid_json += 1
|
|
1164
|
+
continue
|
|
1165
|
+
# Wave 3.1 smart payload (Emilio 2026-05-19): see polish_run_journals.
|
|
1166
|
+
async with write_db() as ev_db:
|
|
1167
|
+
events_for_polish = await _fetch_run_events(
|
|
1168
|
+
ev_db, run_id=run_id, scope_type=scope_type, scope_key=scope_key,
|
|
1169
|
+
cycle_key=cycle_key, workspace_id=workspace_id,
|
|
1170
|
+
)
|
|
1171
|
+
entries_payload, allowed_refs = _build_smart_polish_payload(
|
|
1172
|
+
events=events_for_polish, scope_type=scope_type, scope_key=scope_key
|
|
1173
|
+
)
|
|
1174
|
+
if not allowed_refs:
|
|
1175
|
+
skipped_no_refs += 1
|
|
1176
|
+
continue
|
|
1177
|
+
try:
|
|
1178
|
+
result = await polish_journal_entry(
|
|
1179
|
+
service=service,
|
|
1180
|
+
grounding_strict=grounding_strict,
|
|
1181
|
+
run_id=run_id,
|
|
1182
|
+
entry_id=entry_id,
|
|
1183
|
+
scope_type=scope_type,
|
|
1184
|
+
scope_key=scope_key,
|
|
1185
|
+
cycle_key=cycle_key,
|
|
1186
|
+
entries=entries_payload,
|
|
1187
|
+
allowed_evidence_refs=allowed_refs,
|
|
1188
|
+
deterministic_narrative=_polish_deterministic_narrative(body),
|
|
1189
|
+
)
|
|
1190
|
+
except Exception: # noqa: BLE001 — never break the cycle
|
|
1191
|
+
logger.warning(
|
|
1192
|
+
"polish_pending_journals polish_failed entry_id=%s scope=%s/%s",
|
|
1193
|
+
entry_id, scope_type, scope_key,
|
|
1194
|
+
exc_info=True,
|
|
1195
|
+
)
|
|
1196
|
+
skipped_polish_exception += 1
|
|
1197
|
+
continue
|
|
1198
|
+
if not result.success:
|
|
1199
|
+
logger.info(
|
|
1200
|
+
"polish_pending skip reason=not_success entry_id=%s scope=%s/%s "
|
|
1201
|
+
"purpose=%s fallback_reason=%s",
|
|
1202
|
+
entry_id, scope_type, scope_key,
|
|
1203
|
+
getattr(result, "purpose", "?"),
|
|
1204
|
+
getattr(result, "reason", "?"),
|
|
1205
|
+
)
|
|
1206
|
+
skipped_polish_not_success += 1
|
|
1207
|
+
continue
|
|
1208
|
+
polished_text = (result.polished or {}).get("narrative_polished") or None
|
|
1209
|
+
if not polished_text:
|
|
1210
|
+
skipped_polish_empty += 1
|
|
1211
|
+
continue
|
|
1212
|
+
async with write_db() as db:
|
|
1213
|
+
await db.execute(
|
|
1214
|
+
"UPDATE brain_journal_entries SET"
|
|
1215
|
+
" narrative_polished = ?,"
|
|
1216
|
+
" narrative_polished_at = ?,"
|
|
1217
|
+
" narrative_polished_model = ?"
|
|
1218
|
+
" WHERE entry_id = ?",
|
|
1219
|
+
(polished_text, now_iso, result.model or "", entry_id),
|
|
1220
|
+
)
|
|
1221
|
+
updated += 1
|
|
1222
|
+
remaining_after = max(0, total_pending - updated)
|
|
1223
|
+
logger.info(
|
|
1224
|
+
"polish_pending_journals done updated=%d remaining=%d "
|
|
1225
|
+
"skip{invalid_json=%d, no_refs=%d, polish_exception=%d, "
|
|
1226
|
+
"not_success=%d, empty=%d}",
|
|
1227
|
+
updated, remaining_after,
|
|
1228
|
+
skipped_invalid_json, skipped_no_refs,
|
|
1229
|
+
skipped_polish_exception, skipped_polish_not_success,
|
|
1230
|
+
skipped_polish_empty,
|
|
1231
|
+
)
|
|
1232
|
+
return {
|
|
1233
|
+
"updated": updated,
|
|
1234
|
+
"remaining": remaining_after,
|
|
1235
|
+
"skipped": {
|
|
1236
|
+
"invalid_json": skipped_invalid_json,
|
|
1237
|
+
"no_refs": skipped_no_refs,
|
|
1238
|
+
"polish_exception": skipped_polish_exception,
|
|
1239
|
+
"not_success": skipped_polish_not_success,
|
|
1240
|
+
"empty": skipped_polish_empty,
|
|
1241
|
+
},
|
|
1242
|
+
}
|