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.
Files changed (587) hide show
  1. core/api/__init__.py +0 -0
  2. core/api/agents/__init__.py +0 -0
  3. core/api/agents/session_health.py +59 -0
  4. core/api/agents/session_manager.py +206 -0
  5. core/api/bin/marvisx-state-hook.py +182 -0
  6. core/api/config.py +533 -0
  7. core/api/db.py +1516 -0
  8. core/api/dependencies/__init__.py +0 -0
  9. core/api/dependencies/tenant.py +34 -0
  10. core/api/main.py +1641 -0
  11. core/api/mcp/__init__.py +8 -0
  12. core/api/mcp/_adapter.py +184 -0
  13. core/api/mcp/server.py +58 -0
  14. core/api/mcp/tools/__init__.py +59 -0
  15. core/api/mcp/tools/brain.py +599 -0
  16. core/api/mcp/tools/graph.py +380 -0
  17. core/api/mcp/tools/handoffs.py +112 -0
  18. core/api/mcp/tools/ingest.py +326 -0
  19. core/api/mcp/tools/learnings.py +144 -0
  20. core/api/mcp/tools/projects.py +99 -0
  21. core/api/mcp/tools/pull_requests.py +173 -0
  22. core/api/mcp/tools/safety.py +111 -0
  23. core/api/mcp/tools/search.py +79 -0
  24. core/api/mcp/tools/tasks.py +258 -0
  25. core/api/middleware/__init__.py +0 -0
  26. core/api/middleware/tool_call_audit.py +111 -0
  27. core/api/models/__init__.py +346 -0
  28. core/api/models/auth.py +48 -0
  29. core/api/models/brain.py +1006 -0
  30. core/api/models/common.py +76 -0
  31. core/api/models/costs.py +91 -0
  32. core/api/models/graph.py +66 -0
  33. core/api/models/graph_cosmo.py +125 -0
  34. core/api/models/graph_pr_impact.py +257 -0
  35. core/api/models/graph_ux.py +141 -0
  36. core/api/models/inbox.py +230 -0
  37. core/api/models/ingest_keys.py +108 -0
  38. core/api/models/kg.py +41 -0
  39. core/api/models/llm_config.py +56 -0
  40. core/api/models/monitoring.py +234 -0
  41. core/api/models/projects.py +161 -0
  42. core/api/models/search.py +42 -0
  43. core/api/models/sessions.py +322 -0
  44. core/api/models/tasks.py +184 -0
  45. core/api/models/teams.py +63 -0
  46. core/api/models/users.py +184 -0
  47. core/api/observability/__init__.py +0 -0
  48. core/api/observability/tracing.py +92 -0
  49. core/api/paths.py +26 -0
  50. core/api/rate_limit.py +24 -0
  51. core/api/rbac.py +112 -0
  52. core/api/routers/__init__.py +0 -0
  53. core/api/routers/_adapter.py +24 -0
  54. core/api/routers/admin_pr_impact.py +230 -0
  55. core/api/routers/admin_settings.py +147 -0
  56. core/api/routers/agent.py +1079 -0
  57. core/api/routers/agent_tokens.py +276 -0
  58. core/api/routers/app_settings.py +112 -0
  59. core/api/routers/audit.py +89 -0
  60. core/api/routers/auth.py +586 -0
  61. core/api/routers/bench.py +161 -0
  62. core/api/routers/brain.py +881 -0
  63. core/api/routers/brain_directions.py +527 -0
  64. core/api/routers/ci_checks.py +140 -0
  65. core/api/routers/comments.py +273 -0
  66. core/api/routers/costs.py +148 -0
  67. core/api/routers/docs_coverage.py +217 -0
  68. core/api/routers/docs_governance.py +63 -0
  69. core/api/routers/documents.py +318 -0
  70. core/api/routers/files.py +163 -0
  71. core/api/routers/finder.py +987 -0
  72. core/api/routers/graph.py +836 -0
  73. core/api/routers/handoffs.py +156 -0
  74. core/api/routers/inbox.py +496 -0
  75. core/api/routers/ingest_api_keys.py +205 -0
  76. core/api/routers/ingest_triage.py +1227 -0
  77. core/api/routers/judge.py +306 -0
  78. core/api/routers/kg.py +336 -0
  79. core/api/routers/learnings.py +253 -0
  80. core/api/routers/llm_config.py +130 -0
  81. core/api/routers/monitoring.py +347 -0
  82. core/api/routers/notifications.py +125 -0
  83. core/api/routers/pr_impact.py +315 -0
  84. core/api/routers/projects.py +1061 -0
  85. core/api/routers/pull_requests.py +312 -0
  86. core/api/routers/push.py +67 -0
  87. core/api/routers/raci.py +228 -0
  88. core/api/routers/search.py +125 -0
  89. core/api/routers/sessions.py +3100 -0
  90. core/api/routers/settings.py +90 -0
  91. core/api/routers/share_repo.py +68 -0
  92. core/api/routers/status_updates.py +96 -0
  93. core/api/routers/tags.py +45 -0
  94. core/api/routers/tasks.py +526 -0
  95. core/api/routers/teams.py +425 -0
  96. core/api/routers/terminal.py +105 -0
  97. core/api/routers/users.py +331 -0
  98. core/api/routers/webhooks.py +330 -0
  99. core/api/runtime_settings.py +84 -0
  100. core/api/security.py +652 -0
  101. core/api/services/__init__.py +0 -0
  102. core/api/services/audit.py +58 -0
  103. core/api/services/auto_approval.py +82 -0
  104. core/api/services/brain/__init__.py +52 -0
  105. core/api/services/brain/baseline.py +230 -0
  106. core/api/services/brain/capabilities.py +75 -0
  107. core/api/services/brain/cascade_rollup.py +388 -0
  108. core/api/services/brain/compound_bridge.py +215 -0
  109. core/api/services/brain/cycle.py +1242 -0
  110. core/api/services/brain/cycle_snapshot.py +371 -0
  111. core/api/services/brain/digest_collector.py +147 -0
  112. core/api/services/brain/direction.py +421 -0
  113. core/api/services/brain/drift.py +356 -0
  114. core/api/services/brain/drift_router.py +409 -0
  115. core/api/services/brain/edge_metrics.py +79 -0
  116. core/api/services/brain/events_reader.py +222 -0
  117. core/api/services/brain/findings.py +1379 -0
  118. core/api/services/brain/findings_reader.py +1006 -0
  119. core/api/services/brain/jobs.py +733 -0
  120. core/api/services/brain/journal.py +206 -0
  121. core/api/services/brain/knowledge_forms.py +92 -0
  122. core/api/services/brain/llm/__init__.py +37 -0
  123. core/api/services/brain/llm/_runner.py +62 -0
  124. core/api/services/brain/llm/base.py +70 -0
  125. core/api/services/brain/llm/cache.py +99 -0
  126. core/api/services/brain/llm/constants.py +46 -0
  127. core/api/services/brain/llm/direction_alignment.py +289 -0
  128. core/api/services/brain/llm/factory.py +132 -0
  129. core/api/services/brain/llm/finding_reasoning.py +98 -0
  130. core/api/services/brain/llm/finding_summary.py +92 -0
  131. core/api/services/brain/llm/grounding.py +46 -0
  132. core/api/services/brain/llm/journal_polish.py +96 -0
  133. core/api/services/brain/llm/local_gateway.py +426 -0
  134. core/api/services/brain/llm/parsers.py +71 -0
  135. core/api/services/brain/llm/router_glue.py +422 -0
  136. core/api/services/brain/memory_ops.py +1677 -0
  137. core/api/services/brain/models.py +140 -0
  138. core/api/services/brain/owner_hint.py +211 -0
  139. core/api/services/brain/recap.py +307 -0
  140. core/api/services/brain/rules/__init__.py +65 -0
  141. core/api/services/brain/rules/_signals.py +205 -0
  142. core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
  143. core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
  144. core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
  145. core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
  146. core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
  147. core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
  148. core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
  149. core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
  150. core/api/services/brain/runs_reader.py +485 -0
  151. core/api/services/brain/scope.py +79 -0
  152. core/api/services/brain/sources/__init__.py +46 -0
  153. core/api/services/brain/sources/base.py +86 -0
  154. core/api/services/brain/sources/git_kg.py +393 -0
  155. core/api/services/brain/sources/handoffs.py +157 -0
  156. core/api/services/brain/sources/ingestor.py +130 -0
  157. core/api/services/brain/sources/learnings.py +121 -0
  158. core/api/services/brain/sources/pir_tasks.py +245 -0
  159. core/api/services/brain/watermarks.py +147 -0
  160. core/api/services/brain/ws_emitter.py +170 -0
  161. core/api/services/cc_tasks_reader.py +76 -0
  162. core/api/services/ci_service.py +263 -0
  163. core/api/services/claude_metrics.py +796 -0
  164. core/api/services/codex_metrics.py +364 -0
  165. core/api/services/conversation_reader.py +102 -0
  166. core/api/services/cost_service.py +243 -0
  167. core/api/services/crypto.py +147 -0
  168. core/api/services/docs_governance/__init__.py +1 -0
  169. core/api/services/docs_governance/confidence.py +230 -0
  170. core/api/services/docs_governance/config.py +87 -0
  171. core/api/services/docs_governance/enrichment.py +65 -0
  172. core/api/services/docs_governance/frontmatter_validator.py +83 -0
  173. core/api/services/docs_governance/hard_gates.py +221 -0
  174. core/api/services/docs_governance/triage_orchestrator.py +98 -0
  175. core/api/services/embedding_internal.py +395 -0
  176. core/api/services/embedding_service.py +832 -0
  177. core/api/services/event_dispatcher.py +167 -0
  178. core/api/services/events.py +70 -0
  179. core/api/services/git_ops.py +621 -0
  180. core/api/services/graph_cosmo_service.py +440 -0
  181. core/api/services/graph_ranker.py +306 -0
  182. core/api/services/graph_service.py +1589 -0
  183. core/api/services/inbox.py +800 -0
  184. core/api/services/inbox_digest.py +221 -0
  185. core/api/services/inbox_digest_deep_research.py +80 -0
  186. core/api/services/inbox_digest_jobs.py +595 -0
  187. core/api/services/inbox_gmail_sync.py +167 -0
  188. core/api/services/inbox_llm_classifier.py +906 -0
  189. core/api/services/inbox_source_identity.py +116 -0
  190. core/api/services/inbox_sources.py +456 -0
  191. core/api/services/inbox_taxonomy.py +195 -0
  192. core/api/services/inbox_tldr.py +1079 -0
  193. core/api/services/inbox_triage.py +899 -0
  194. core/api/services/ingest/__init__.py +13 -0
  195. core/api/services/ingest/api_key_auth.py +136 -0
  196. core/api/services/ingest/auto_approve.py +120 -0
  197. core/api/services/ingest/classifier.py +138 -0
  198. core/api/services/ingest/confidence.py +173 -0
  199. core/api/services/ingest/dispatch.py +88 -0
  200. core/api/services/ingest/embedding_router.py +272 -0
  201. core/api/services/ingest/events.py +33 -0
  202. core/api/services/ingest/ignore_patterns.py +79 -0
  203. core/api/services/ingest/image_probe.py +218 -0
  204. core/api/services/ingest/ingress.py +263 -0
  205. core/api/services/ingest/insert_saga.py +793 -0
  206. core/api/services/ingest/llm/__init__.py +13 -0
  207. core/api/services/ingest/llm/anthropic_haiku.py +23 -0
  208. core/api/services/ingest/llm/base.py +59 -0
  209. core/api/services/ingest/llm/byok_provider.py +130 -0
  210. core/api/services/ingest/llm/classification_context.py +301 -0
  211. core/api/services/ingest/llm/config_store.py +246 -0
  212. core/api/services/ingest/llm/factory.py +24 -0
  213. core/api/services/ingest/llm/kg_enricher.py +306 -0
  214. core/api/services/ingest/llm/local_gateway.py +821 -0
  215. core/api/services/ingest/llm/local_vllm.py +23 -0
  216. core/api/services/ingest/llm/openai_nano.py +349 -0
  217. core/api/services/ingest/lock_advisory.py +57 -0
  218. core/api/services/ingest/parser_router.py +1756 -0
  219. core/api/services/ingest/parsers/__init__.py +1 -0
  220. core/api/services/ingest/parsers/docling_parser.py +142 -0
  221. core/api/services/ingest/parsers/docparse_gateway.py +178 -0
  222. core/api/services/ingest/parsers/docx_parser.py +127 -0
  223. core/api/services/ingest/parsers/folder_unpacker.py +85 -0
  224. core/api/services/ingest/parsers/gateway_aux.py +147 -0
  225. core/api/services/ingest/parsers/image_parser.py +251 -0
  226. core/api/services/ingest/parsers/internal_markdown.py +89 -0
  227. core/api/services/ingest/parsers/ocr_gateway.py +117 -0
  228. core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
  229. core/api/services/ingest/parsers/pdf_types.py +13 -0
  230. core/api/services/ingest/parsers/transcript_parser.py +445 -0
  231. core/api/services/ingest/parsers/vision_gateway.py +186 -0
  232. core/api/services/ingest/parsers/xlsx_parser.py +91 -0
  233. core/api/services/ingest/parsers/zip_unpacker.py +126 -0
  234. core/api/services/ingest/preflight.py +393 -0
  235. core/api/services/ingest/retry_voyage.py +88 -0
  236. core/api/services/ingest/routing_policy.py +307 -0
  237. core/api/services/ingest/serializers/__init__.py +1 -0
  238. core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
  239. core/api/services/ingest/skip_log.py +74 -0
  240. core/api/services/ingest/watcher.py +637 -0
  241. core/api/services/kg/__init__.py +0 -0
  242. core/api/services/kg/audit.py +49 -0
  243. core/api/services/kg/hybrid_search.py +691 -0
  244. core/api/services/kg/lens.py +339 -0
  245. core/api/services/kg/pr_impact.py +770 -0
  246. core/api/services/kg/queries.py +152 -0
  247. core/api/services/kg/ranking.py +89 -0
  248. core/api/services/kg/rrf.py +143 -0
  249. core/api/services/kg_watcher_control.py +161 -0
  250. core/api/services/local_llm/__init__.py +19 -0
  251. core/api/services/local_llm/async_client.py +385 -0
  252. core/api/services/local_llm/client.py +173 -0
  253. core/api/services/local_llm/url_validator.py +44 -0
  254. core/api/services/metrics_collector.py +646 -0
  255. core/api/services/metrics_providers.py +65 -0
  256. core/api/services/model_registry.py +266 -0
  257. core/api/services/model_router.py +137 -0
  258. core/api/services/n8n_client.py +77 -0
  259. core/api/services/newsletter_llm_gateway.py +66 -0
  260. core/api/services/notification_service.py +134 -0
  261. core/api/services/openai_responses.py +55 -0
  262. core/api/services/opencode_metrics.py +375 -0
  263. core/api/services/opencode_sessions.py +173 -0
  264. core/api/services/pii_redactor.py +138 -0
  265. core/api/services/pr_impact_pipeline/__init__.py +21 -0
  266. core/api/services/pr_impact_pipeline/differ.py +421 -0
  267. core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
  268. core/api/services/pr_impact_pipeline/gc.py +93 -0
  269. core/api/services/pr_impact_pipeline/languages.py +192 -0
  270. core/api/services/pr_impact_pipeline/parser.py +178 -0
  271. core/api/services/pr_impact_pipeline/writer.py +394 -0
  272. core/api/services/pr_service.py +1393 -0
  273. core/api/services/project_paths.py +70 -0
  274. core/api/services/project_status_updates.py +265 -0
  275. core/api/services/providers.py +276 -0
  276. core/api/services/push_service.py +170 -0
  277. core/api/services/reminder_service.py +89 -0
  278. core/api/services/runas.py +41 -0
  279. core/api/services/salience_service.py +69 -0
  280. core/api/services/security_collector.py +281 -0
  281. core/api/services/session_catalog.py +385 -0
  282. core/api/services/session_metrics_service.py +301 -0
  283. core/api/services/session_ops.py +272 -0
  284. core/api/services/session_state.py +173 -0
  285. core/api/services/share_links.py +222 -0
  286. core/api/services/task_transitions.py +146 -0
  287. core/api/services/terminal_metrics.py +462 -0
  288. core/api/services/terminal_metrics_dump.py +203 -0
  289. core/api/services/tmux.py +1205 -0
  290. core/api/services/webhook_service.py +422 -0
  291. core/api/services/workspace_sync.py +164 -0
  292. core/api/templates/__init__.py +1 -0
  293. core/api/templates/markdown_share.py +164 -0
  294. core/api/terminal.py +1031 -0
  295. core/api/tests/__init__.py +0 -0
  296. core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
  297. core/api/tests/test_audit_permissions.py +133 -0
  298. core/api/tests/test_backfill_session_conversations.py +90 -0
  299. core/api/tests/test_backfill_working_seconds_msg.py +129 -0
  300. core/api/tests/test_claude_metrics.py +326 -0
  301. core/api/tests/test_codex_metrics.py +189 -0
  302. core/api/tests/test_finder_paths.py +74 -0
  303. core/api/tests/test_git_ops_merge.py +155 -0
  304. core/api/tests/test_learnings_check_search.py +81 -0
  305. core/api/tests/test_metrics_providers.py +133 -0
  306. core/api/tests/test_migration_087.py +164 -0
  307. core/api/tests/test_migration_088.py +94 -0
  308. core/api/tests/test_migration_089.py +116 -0
  309. core/api/tests/test_openai_responses.py +24 -0
  310. core/api/tests/test_opencode_metrics.py +740 -0
  311. core/api/tests/test_opencode_sessions.py +321 -0
  312. core/api/tests/test_pr_workflow_e2e.py +457 -0
  313. core/api/tests/test_projects_handoffs.py +31 -0
  314. core/api/tests/test_providers.py +138 -0
  315. core/api/tests/test_safety_bridge.py +347 -0
  316. core/api/tests/test_session_catalog.py +142 -0
  317. core/api/tests/test_session_conversations.py +512 -0
  318. core/api/tests/test_session_metrics_service.py +270 -0
  319. core/api/tests/test_session_resume_paths.py +548 -0
  320. core/api/tests/test_session_theme_mode_migration.py +56 -0
  321. core/api/tests/test_sessions_rbac.py +131 -0
  322. core/api/tests/test_share_edit.py +398 -0
  323. core/api/tests/test_share_repo.py +200 -0
  324. core/api/tests/test_terminal_session_manager.py +98 -0
  325. core/api/tests/test_terminal_upload.py +34 -0
  326. core/api/tests/test_tmux.py +272 -0
  327. core/api/tests/test_workspace_sync.py +186 -0
  328. core/api/tests/test_ws_ticket_in_memory.py +73 -0
  329. core/api/use_cases/__init__.py +11 -0
  330. core/api/use_cases/_context.py +89 -0
  331. core/api/use_cases/_errors.py +62 -0
  332. core/api/use_cases/_roles.py +16 -0
  333. core/api/use_cases/audit.py +171 -0
  334. core/api/use_cases/brain.py +1232 -0
  335. core/api/use_cases/costs.py +249 -0
  336. core/api/use_cases/graph.py +1153 -0
  337. core/api/use_cases/handoffs.py +506 -0
  338. core/api/use_cases/ingest_triage.py +1229 -0
  339. core/api/use_cases/learnings.py +538 -0
  340. core/api/use_cases/projects.py +705 -0
  341. core/api/use_cases/pull_requests.py +415 -0
  342. core/api/use_cases/search.py +926 -0
  343. core/api/use_cases/tasks.py +1495 -0
  344. core/api/visibility.py +141 -0
  345. core/cli/__init__.py +5 -0
  346. core/cli/_index_source.py +632 -0
  347. core/cli/_runtime_ctx.py +160 -0
  348. core/cli/_transmute.py +241 -0
  349. core/cli/marvis_doctor.py +704 -0
  350. core/cli/marvis_feedback.py +396 -0
  351. core/cli/marvis_governance.py +315 -0
  352. core/cli/marvis_hooks.py +515 -0
  353. core/cli/marvis_init.py +757 -0
  354. core/cli/marvis_mcp.py +401 -0
  355. core/cli/marvis_runtime.py +855 -0
  356. core/cli/marvis_telemetry.py +228 -0
  357. core/scripts/_drift_check.py +716 -0
  358. core/scripts/_frontmatter.py +66 -0
  359. core/scripts/_graph_writer.py +189 -0
  360. core/scripts/ast_parser.py +1553 -0
  361. core/scripts/install_hooks/__init__.py +1 -0
  362. core/scripts/install_hooks/_config.sh +109 -0
  363. core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
  364. core/scripts/install_hooks/block-db-direct-write.sh +23 -0
  365. core/scripts/install_hooks/block-push-no-task.sh +23 -0
  366. core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
  367. core/scripts/install_hooks/block-subtree-push.sh +23 -0
  368. core/scripts/install_hooks/config.json +53 -0
  369. core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
  370. core/scripts/install_hooks/enforce-worktree.sh +23 -0
  371. core/scripts/install_hooks/quality-gate.sh +170 -0
  372. core/scripts/install_hooks/safety_bridge.py +968 -0
  373. core/scripts/install_hooks/secret-scan.sh +23 -0
  374. core/scripts/migrate_spike_node_ids.py +122 -0
  375. core/scripts/populate_artifacts.py +2198 -0
  376. core/scripts/populate_cross_project.py +2457 -0
  377. core/scripts/populate_inbox_nodes.py +357 -0
  378. core/scripts/populate_pr_impact.py +267 -0
  379. core/scripts/populate_project_nodes.py +603 -0
  380. core/scripts/populate_touch_counter.py +337 -0
  381. core/scripts/reparse_failed.py +57 -0
  382. core/scripts/safety_bridge.py +968 -0
  383. core/telemetry/__init__.py +9 -0
  384. core/telemetry/client.py +405 -0
  385. core/telemetry/schema.py +122 -0
  386. core/wizard/__init__.py +65 -0
  387. core/wizard/byok_vault.py +147 -0
  388. core/wizard/defaults.py +58 -0
  389. core/wizard/state.py +117 -0
  390. core/wizard/steps.py +70 -0
  391. core/wizard/validation.py +136 -0
  392. marvisx_cli-0.1.0.dist-info/METADATA +201 -0
  393. marvisx_cli-0.1.0.dist-info/RECORD +587 -0
  394. marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
  395. marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
  396. marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
  397. marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
  398. migrations/001_initial.sql +33 -0
  399. migrations/002_tasks.sql +30 -0
  400. migrations/003_session_management.sql +7 -0
  401. migrations/004_projects_comments.sql +65 -0
  402. migrations/005_session_intelligence.sql +15 -0
  403. migrations/006_settings.sql +12 -0
  404. migrations/007_task_scoring.sql +12 -0
  405. migrations/008_cost_tracking.sql +31 -0
  406. migrations/009_session_card_metrics.sql +3 -0
  407. migrations/010_monitoring.sql +55 -0
  408. migrations/012_agent_api.sql +21 -0
  409. migrations/013_session_complete.sql +8 -0
  410. migrations/015_pull_requests.sql +43 -0
  411. migrations/015_pull_requests_down.sql +5 -0
  412. migrations/016_users_raci.sql +116 -0
  413. migrations/017_task_cost_entries.sql +87 -0
  414. migrations/018_agents.sql +73 -0
  415. migrations/018_agents_down.sql +13 -0
  416. migrations/019_review_feedback.sql +18 -0
  417. migrations/020_pr_commit_sha.sql +4 -0
  418. migrations/021_webhook_events.sql +18 -0
  419. migrations/022_devx_agent_managed.sql +11 -0
  420. migrations/022_devx_agent_managed_down.sql +6 -0
  421. migrations/023_devx_p1_gate.sql +7 -0
  422. migrations/023_devx_p1_gate_down.sql +3 -0
  423. migrations/024_chat_messages.sql +16 -0
  424. migrations/024_pr_conversation_id.sql +8 -0
  425. migrations/024_task_indexes.sql +21 -0
  426. migrations/024_task_indexes_down.sql +7 -0
  427. migrations/025_audit_log.sql +17 -0
  428. migrations/026_agent_tokens.sql +20 -0
  429. migrations/027_teams_auth_phase_b.sql +35 -0
  430. migrations/028_learnings.sql +23 -0
  431. migrations/029_team_roles.sql +14 -0
  432. migrations/030_finder_pins.sql +10 -0
  433. migrations/031_pr_deploy_status.sql +9 -0
  434. migrations/032_task_reminders.sql +7 -0
  435. migrations/033_events_retry_count.sql +6 -0
  436. migrations/033_session_owner.sql +9 -0
  437. migrations/034_notifications.sql +38 -0
  438. migrations/035_shared_links.sql +15 -0
  439. migrations/036_session_index_upgrade.sql +29 -0
  440. migrations/037_pr_approval.sql +15 -0
  441. migrations/038_pr_submitted_by.sql +6 -0
  442. migrations/039_push_subscriptions.sql +17 -0
  443. migrations/040_semantic_search.sql +16 -0
  444. migrations/041_workspaces.sql +63 -0
  445. migrations/042_oidc_providers.sql +24 -0
  446. migrations/043_ci_checks.sql +31 -0
  447. migrations/044_agent_metrics.sql +30 -0
  448. migrations/045_documents_doc_type.sql +5 -0
  449. migrations/046_salience.sql +13 -0
  450. migrations/047_seed_missing_agents.sql +6 -0
  451. migrations/048_fix_agent_paths_roles.sql +5 -0
  452. migrations/049_agent_role_and_learnings_schema.sql +3 -0
  453. migrations/050_session_provider.sql +2 -0
  454. migrations/051_session_launch_profile.sql +4 -0
  455. migrations/052_session_theme_mode.sql +2 -0
  456. migrations/052_task_kind.sql +4 -0
  457. migrations/053_inbox_items.sql +31 -0
  458. migrations/054_inbox_triage_contract.sql +30 -0
  459. migrations/055_inbox_topic_treatment.sql +12 -0
  460. migrations/056_inbox_treatment_read_save.sql +57 -0
  461. migrations/057_session_theme_mode_backfill.sql +4 -0
  462. migrations/058_inbox_item_status_lifecycle.sql +13 -0
  463. migrations/059_inbox_tldr_and_source_scores.sql +18 -0
  464. migrations/060_newsletter.sql +16 -0
  465. migrations/061_inbox_redesign.sql +69 -0
  466. migrations/062_fix_inbox_sources_backfill.sql +37 -0
  467. migrations/063_task_completion_mode.sql +23 -0
  468. migrations/064_judge_mode_setting.sql +4 -0
  469. migrations/065_knowledge_graph_spike.sql +40 -0
  470. migrations/066_digest_ranking_inputs.sql +10 -0
  471. migrations/066_kg_artifact_nodes.sql +129 -0
  472. migrations/067_inbox_digest_selections.sql +28 -0
  473. migrations/067_kg_temporal.sql +53 -0
  474. migrations/068_inbox_digest_app_settings.sql +9 -0
  475. migrations/068_kg_touch_counter.sql +52 -0
  476. migrations/069_kg_doc_types.sql +117 -0
  477. migrations/070_digest_ranking_inputs_recovery.sql +3 -0
  478. migrations/071_inbox_digest_selections_recovery.sql +3 -0
  479. migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
  480. migrations/073_kg_cross_project.sql +216 -0
  481. migrations/073_kg_cross_project_down.sql +77 -0
  482. migrations/074_kg_infra_types.sql +208 -0
  483. migrations/074_kg_infra_types_down.sql +80 -0
  484. migrations/075_kg_file_state_recovery.sql +35 -0
  485. migrations/075_kg_file_state_recovery_down.sql +5 -0
  486. migrations/076_kg_watcher_state.sql +33 -0
  487. migrations/076_kg_watcher_state_down.sql +3 -0
  488. migrations/077_kg_doc_types_extend.sql +226 -0
  489. migrations/077_kg_doc_types_extend_down.sql +80 -0
  490. migrations/078_kg_fts5.sql +102 -0
  491. migrations/078_kg_fts5_down.sql +14 -0
  492. migrations/079_kg_missing_indexes.sql +31 -0
  493. migrations/079_kg_missing_indexes_down.sql +10 -0
  494. migrations/080_kg_fts5_extended.sql +232 -0
  495. migrations/080_kg_fts5_extended_down.sql +25 -0
  496. migrations/081_kg_lens_indexes.sql +9 -0
  497. migrations/081_kg_lens_indexes_down.sql +3 -0
  498. migrations/082_kg_pins.sql +26 -0
  499. migrations/082_kg_pins_down.sql +14 -0
  500. migrations/083_kg_graph_nodes_degree.sql +20 -0
  501. migrations/083_kg_graph_nodes_degree_down.sql +15 -0
  502. migrations/084_drop_legacy_scheduler_tables.sql +58 -0
  503. migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
  504. migrations/085_kg_edge_resolves_to.sql +142 -0
  505. migrations/085_kg_edge_resolves_to_down.sql +66 -0
  506. migrations/086_project_status_updates_feed.sql +20 -0
  507. migrations/086_project_status_updates_feed_down.sql +36 -0
  508. migrations/087_session_metrics_dual.sql +50 -0
  509. migrations/087_session_metrics_dual_down.sql +21 -0
  510. migrations/088_rename_context_pct_legacy.sql +23 -0
  511. migrations/088_rename_context_pct_legacy_down.sql +8 -0
  512. migrations/089_session_metrics_equivalent_cost.sql +26 -0
  513. migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
  514. migrations/090_kg_inbox_node_type.sql +26 -0
  515. migrations/090_kg_inbox_node_type_down.sql +20 -0
  516. migrations/091_kg_inbox_node_type_check.sql +265 -0
  517. migrations/091_kg_inbox_node_type_check_down.sql +129 -0
  518. migrations/092_sessions_activity_state_ts.sql +29 -0
  519. migrations/092_sessions_activity_state_ts_down.sql +14 -0
  520. migrations/093_sessions_activity_state_column.sql +29 -0
  521. migrations/093_sessions_activity_state_column_down.sql +10 -0
  522. migrations/094_ingest_pending.sql +55 -0
  523. migrations/094_ingest_pending_down.sql +15 -0
  524. migrations/095_kg_intent_first.sql +77 -0
  525. migrations/095_kg_intent_first_down.sql +25 -0
  526. migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
  527. migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
  528. migrations/097_ingest_change_history.sql +37 -0
  529. migrations/097_ingest_change_history_down.sql +13 -0
  530. migrations/098_kg_node_type_business.sql +254 -0
  531. migrations/098_kg_node_type_business_down.sql +195 -0
  532. migrations/099_kg_edges_restore_weight.sql +58 -0
  533. migrations/099_kg_edges_restore_weight_down.sql +12 -0
  534. migrations/100_kg_enriched_at.sql +25 -0
  535. migrations/100_kg_enriched_at_down.sql +12 -0
  536. migrations/101_local_llm_shadow_comparisons.sql +66 -0
  537. migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
  538. migrations/102_promote_llm_costs.sql +69 -0
  539. migrations/102_promote_llm_costs_down.sql +19 -0
  540. migrations/103_ingest_skipped_log.sql +46 -0
  541. migrations/103_ingest_skipped_log_down.sql +15 -0
  542. migrations/120_docs_governance.sql +50 -0
  543. migrations/120_docs_governance_down.sql +11 -0
  544. migrations/121_notification_event_fk_cleanup.sql +21 -0
  545. migrations/121_notification_event_fk_cleanup_down.sql +10 -0
  546. migrations/122_docs_drift_history.sql +34 -0
  547. migrations/122_docs_drift_history_down.sql +15 -0
  548. migrations/123_ingest_parser_waiting_status.sql +69 -0
  549. migrations/123_ingest_parser_waiting_status_down.sql +69 -0
  550. migrations/124_heypocket_recordings.sql +63 -0
  551. migrations/124_heypocket_recordings_down.sql +13 -0
  552. migrations/125_kg_node_type_record.sql +219 -0
  553. migrations/125_kg_node_type_record_down.sql +205 -0
  554. migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
  555. migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
  556. migrations/127_brain_v1_substrate.sql +200 -0
  557. migrations/127_brain_v1_substrate_down.sql +32 -0
  558. migrations/128_brain_drift_signals.sql +157 -0
  559. migrations/128_brain_drift_signals_down.sql +23 -0
  560. migrations/129_brain_memory_operations.sql +232 -0
  561. migrations/129_brain_memory_operations_down.sql +27 -0
  562. migrations/130_brain_findings.sql +258 -0
  563. migrations/130_brain_findings_down.sql +29 -0
  564. migrations/132_kg_pr_modifies.sql +242 -0
  565. migrations/132_kg_pr_modifies_down.sql +99 -0
  566. migrations/133_brain_v1_2_direction_schema.sql +476 -0
  567. migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
  568. migrations/134_brain_journal_narrative_polished.sql +8 -0
  569. migrations/134_brain_journal_narrative_polished_down.sql +6 -0
  570. migrations/135_kg_edges_provider.sql +21 -0
  571. migrations/136_documents_fts.sql +56 -0
  572. migrations/137_promote_llm_costs.sql +59 -0
  573. migrations/137_promote_llm_costs_down.sql +19 -0
  574. migrations/138_ingest_api_keys.sql +39 -0
  575. migrations/138_ingest_api_keys_down.sql +8 -0
  576. migrations/139_ingest_pending_ingress.sql +91 -0
  577. migrations/139_ingest_pending_ingress_down.sql +73 -0
  578. migrations/140_ingest_idempotency_quota.sql +45 -0
  579. migrations/140_ingest_idempotency_quota_down.sql +9 -0
  580. migrations/141_ingest_pending_metadata.sql +16 -0
  581. migrations/141_ingest_pending_metadata_down.sql +7 -0
  582. migrations/142_llm_function_config.sql +36 -0
  583. migrations/142_llm_function_config_down.sql +8 -0
  584. migrations/143_kg_code_embeddings.sql +25 -0
  585. migrations/143_kg_code_embeddings_down.sql +5 -0
  586. migrations/__init__.py +4 -0
  587. 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
+ }