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,272 @@
1
+ """Embedding routing for Universal Ingestion E2.1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ import aiosqlite
13
+
14
+ from core.api.config import settings
15
+ from core.api.db import acquire_write_db
16
+ from core.api.services import embedding_service
17
+ from core.api.services.pii_redactor import redact
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ EmbedStatus = Literal["voyage", "nomic-local", "skipped"]
22
+ ProjectType = Literal["work", "code", "system"]
23
+ NOMIC_MODEL_PATH = Path(
24
+ os.environ.get("NOMIC_MODEL_PATH", "/data/pir/models/nomic-embed-text-v1.5")
25
+ )
26
+ _LOCAL_MODEL_MISSING_LOGGED = False
27
+
28
+
29
+ async def embed_and_index(
30
+ *,
31
+ ingest_id: str,
32
+ slug: str,
33
+ target_path: Path,
34
+ extracted_text: str | None,
35
+ document_type: str = "file",
36
+ title: str | None = None,
37
+ project_type: ProjectType = "work",
38
+ ) -> EmbedStatus:
39
+ """Redact, embed, and index one ingested file.
40
+
41
+ Work projects skip embeddings unless external embedding is explicitly
42
+ enabled. Code/system projects use local Nomic by default and route to Voyage
43
+ only through migration 095's project opt-in table.
44
+ """
45
+ if not extracted_text or len(extracted_text.strip()) < 20:
46
+ logger.info(
47
+ "embedding backend: skipped ingest_id=%s reason=text-too-short",
48
+ ingest_id,
49
+ )
50
+ return "skipped"
51
+
52
+ allow_external = await _project_allows_external_embedding(slug)
53
+ if project_type == "work" and not allow_external:
54
+ logger.info(
55
+ "embedding backend: skipped ingest_id=%s reason=work-no-opt-in",
56
+ ingest_id,
57
+ )
58
+ return "skipped"
59
+
60
+ redacted_text = redact(extracted_text)
61
+ backend: Literal["voyage", "nomic-local"]
62
+ try:
63
+ if allow_external:
64
+ vector = await _embed_voyage_text(redacted_text)
65
+ backend = "voyage"
66
+ else:
67
+ if not _local_embedding_model_available():
68
+ _log_local_embedding_missing_once("local-model-missing")
69
+ logger.info(
70
+ "embedding backend: skipped ingest_id=%s reason=local-model-missing",
71
+ ingest_id,
72
+ )
73
+ return "skipped"
74
+ vector = await asyncio.to_thread(_embed_nomic_text_sync, redacted_text)
75
+ backend = "nomic-local"
76
+ except Exception:
77
+ if allow_external:
78
+ logger.warning(
79
+ "Voyage embedding failed; falling back to nomic-local", exc_info=True
80
+ )
81
+ if not _local_embedding_model_available():
82
+ _log_local_embedding_missing_once("local-fallback-model-missing")
83
+ logger.info(
84
+ "embedding backend: skipped ingest_id=%s reason=local-fallback-model-missing",
85
+ ingest_id,
86
+ )
87
+ return "skipped"
88
+ try:
89
+ vector = await asyncio.to_thread(_embed_nomic_text_sync, redacted_text)
90
+ backend = "nomic-local"
91
+ except Exception:
92
+ logger.warning(
93
+ "Local embedding fallback failed; skipping index", exc_info=True
94
+ )
95
+ return "skipped"
96
+ else:
97
+ logger.warning("Local embedding unavailable; skipping index", exc_info=True)
98
+ return "skipped"
99
+
100
+ await _persist_ingest_embedding(
101
+ slug=slug,
102
+ target_path=target_path,
103
+ document_type=document_type,
104
+ title=title or target_path.stem,
105
+ content=extracted_text,
106
+ vector=_coerce_dimensions(vector),
107
+ )
108
+ logger.info(
109
+ "embedding backend: %s ingest_id=%s project=%s", backend, ingest_id, slug
110
+ )
111
+ return backend
112
+
113
+
114
+ async def _project_allows_external_embedding(slug: str) -> bool:
115
+ try:
116
+ async with acquire_write_db() as db:
117
+ async with db.execute(
118
+ """
119
+ SELECT allow_external_embed
120
+ FROM project_external_embedding_policy
121
+ WHERE project_slug = ?
122
+ """,
123
+ (slug,),
124
+ ) as cursor:
125
+ row = await cursor.fetchone()
126
+ except Exception:
127
+ logger.warning(
128
+ "external embedding policy unavailable; defaulting to local", exc_info=True
129
+ )
130
+ return False
131
+ return bool(row and row["allow_external_embed"])
132
+
133
+
134
+ async def _embed_voyage_text(text: str) -> list[float]:
135
+ if not embedding_service.is_available():
136
+ embedding_service.init_voyage_client()
137
+ if not embedding_service.is_available():
138
+ raise RuntimeError("Voyage AI client not initialized")
139
+
140
+ last_error: Exception | None = None
141
+ for attempt, delay in enumerate((0.0, 0.4, 1.5, 4.0), start=1):
142
+ if delay:
143
+ await asyncio.sleep(delay)
144
+ try:
145
+ vectors = await embedding_service.embed_texts([text], input_type="document")
146
+ return vectors[0]
147
+ except Exception as exc:
148
+ last_error = exc
149
+ message = str(exc)
150
+ if any(code in message for code in ("400", "401", "403", "422")):
151
+ raise
152
+ logger.warning("Voyage attempt %d failed: %s", attempt, exc)
153
+ assert last_error is not None
154
+ raise last_error
155
+
156
+
157
+ def _embed_nomic_text_sync(text: str) -> list[float]:
158
+ model = _nomic_model()
159
+ try:
160
+ vector = model.encode(
161
+ text,
162
+ normalize_embeddings=True,
163
+ truncate_dim=embedding_service.DIMENSIONS,
164
+ )
165
+ except TypeError:
166
+ vector = model.encode(text, normalize_embeddings=True)
167
+ return vector.tolist()
168
+
169
+
170
+ def _local_embedding_model_available() -> bool:
171
+ return NOMIC_MODEL_PATH.exists()
172
+
173
+
174
+ def _log_local_embedding_missing_once(reason: str) -> None:
175
+ global _LOCAL_MODEL_MISSING_LOGGED
176
+ if _LOCAL_MODEL_MISSING_LOGGED:
177
+ return
178
+ _LOCAL_MODEL_MISSING_LOGGED = True
179
+ logger.warning(
180
+ "embedding backend: local embedding disabled reason=%s path=%s",
181
+ reason,
182
+ NOMIC_MODEL_PATH,
183
+ )
184
+
185
+
186
+ @lru_cache(maxsize=1)
187
+ def _nomic_model():
188
+ from sentence_transformers import SentenceTransformer
189
+
190
+ return SentenceTransformer(str(NOMIC_MODEL_PATH), trust_remote_code=True)
191
+
192
+
193
+ def _coerce_dimensions(vector: list[float]) -> list[float]:
194
+ if len(vector) == embedding_service.DIMENSIONS:
195
+ return vector
196
+ if len(vector) > embedding_service.DIMENSIONS:
197
+ return vector[: embedding_service.DIMENSIONS]
198
+ padding = [0.0] * (embedding_service.DIMENSIONS - len(vector))
199
+ return [*vector, *padding]
200
+
201
+
202
+ async def _persist_ingest_embedding(
203
+ *,
204
+ slug: str,
205
+ target_path: Path,
206
+ document_type: str,
207
+ title: str,
208
+ content: str,
209
+ vector: list[float],
210
+ ) -> None:
211
+ file_path = str(target_path)
212
+ async with acquire_write_db() as db:
213
+ await db.execute(
214
+ """INSERT INTO documents (file_path, project, workspace_id, doc_type, doc_title, content_hash)
215
+ VALUES (?, ?, 'ws_default', ?, ?, ?)
216
+ ON CONFLICT(file_path) DO UPDATE SET
217
+ content_hash = excluded.content_hash,
218
+ project = excluded.project,
219
+ workspace_id = excluded.workspace_id,
220
+ doc_type = excluded.doc_type,
221
+ doc_title = excluded.doc_title""",
222
+ [
223
+ file_path,
224
+ slug,
225
+ document_type,
226
+ title,
227
+ embedding_service.content_hash(content),
228
+ ],
229
+ )
230
+ async with db.execute(
231
+ "SELECT id FROM documents WHERE file_path = ?", [file_path]
232
+ ) as cursor:
233
+ row = await cursor.fetchone()
234
+ if row is None:
235
+ raise RuntimeError(f"document upsert failed for {file_path}")
236
+
237
+ if await _ensure_vec_documents(db):
238
+ await db.execute("DELETE FROM vec_documents WHERE doc_id = ?", [row["id"]])
239
+ await db.execute(
240
+ "INSERT INTO vec_documents (doc_id, embedding) VALUES (?, ?)",
241
+ [row["id"], embedding_service.serialize_f32(vector)],
242
+ )
243
+ await db.commit()
244
+
245
+
246
+ async def _ensure_vec_documents(db: aiosqlite.Connection) -> bool:
247
+ vec_path = Path(settings.vec0_path)
248
+ vec_so = vec_path.with_suffix(".so") if not vec_path.suffix else vec_path
249
+ if not vec_so.exists():
250
+ logger.warning(
251
+ "sqlite-vec extension missing at %s; document metadata stored without vector",
252
+ vec_so,
253
+ )
254
+ return False
255
+ try:
256
+ await db._execute(db._conn.enable_load_extension, True)
257
+ await db.execute("SELECT load_extension(?)", [str(vec_path)])
258
+ await db.execute(
259
+ """
260
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_documents USING vec0(
261
+ doc_id INTEGER PRIMARY KEY,
262
+ embedding float[512]
263
+ )
264
+ """
265
+ )
266
+ return True
267
+ except Exception:
268
+ logger.warning(
269
+ "sqlite-vec setup failed; document metadata stored without vector",
270
+ exc_info=True,
271
+ )
272
+ return False
@@ -0,0 +1,33 @@
1
+ """Best-effort websocket notifications for ingest state changes."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ async def broadcast_ingest_changed(
11
+ event: str,
12
+ *,
13
+ ingest_id: str | None = None,
14
+ project_slug: str | None = None,
15
+ status: str | None = None,
16
+ extra: dict[str, Any] | None = None,
17
+ ) -> None:
18
+ payload: dict[str, Any] = {"type": "ingest_changed", "event": event}
19
+ if ingest_id is not None:
20
+ payload["ingest_id"] = ingest_id
21
+ if project_slug is not None:
22
+ payload["project_slug"] = project_slug
23
+ if status is not None:
24
+ payload["status"] = status
25
+ if extra:
26
+ payload.update(extra)
27
+
28
+ try:
29
+ from core.api.terminal import session_manager
30
+
31
+ await session_manager.broadcast_control_message(payload)
32
+ except Exception:
33
+ logger.exception("failed to broadcast ingest_changed event")
@@ -0,0 +1,79 @@
1
+ """Shared ignore rules for container ingest inputs."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import PurePosixPath
5
+
6
+ MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024
7
+
8
+ IGNORED_DIRECTORY_NAMES = {
9
+ ".git",
10
+ ".hg",
11
+ ".svn",
12
+ ".venv",
13
+ "__pycache__",
14
+ "build",
15
+ "coverage",
16
+ "dist",
17
+ "node_modules",
18
+ "tmp",
19
+ }
20
+
21
+ IGNORED_FILE_NAMES = {
22
+ ".DS_Store",
23
+ "Thumbs.db",
24
+ "desktop.ini",
25
+ }
26
+
27
+ IGNORED_SUFFIXES = {
28
+ ".app",
29
+ ".bat",
30
+ ".bin",
31
+ ".cmd",
32
+ ".com",
33
+ ".dll",
34
+ ".dmg",
35
+ ".exe",
36
+ ".iso",
37
+ ".lnk",
38
+ ".msi",
39
+ ".o",
40
+ ".out",
41
+ ".part",
42
+ ".pyc",
43
+ ".so",
44
+ ".swp",
45
+ ".tmp",
46
+ }
47
+
48
+
49
+ def normalize_relative_path(path: str | PurePosixPath) -> PurePosixPath:
50
+ normalized = str(path).replace("\\", "/")
51
+ return PurePosixPath(normalized)
52
+
53
+
54
+ def is_ignored_directory_name(name: str) -> bool:
55
+ return name in IGNORED_DIRECTORY_NAMES
56
+
57
+
58
+ def should_ignore(
59
+ relative_path: str | PurePosixPath,
60
+ *,
61
+ file_size_bytes: int | None = None,
62
+ ) -> str | None:
63
+ path = normalize_relative_path(relative_path)
64
+ parts = [part for part in path.parts if part not in {"", "."}]
65
+ if not parts:
66
+ return "empty-path"
67
+ if any(part == ".." for part in parts):
68
+ return "path-traversal"
69
+ if any(is_ignored_directory_name(part) for part in parts[:-1]):
70
+ return "ignored-directory"
71
+
72
+ filename = parts[-1]
73
+ if filename in IGNORED_FILE_NAMES or filename.startswith("."):
74
+ return "ignored-file"
75
+ if PurePosixPath(filename).suffix.lower() in IGNORED_SUFFIXES:
76
+ return "ignored-suffix"
77
+ if file_size_bytes is not None and file_size_bytes > MAX_FILE_SIZE_BYTES:
78
+ return "file-too-large"
79
+ return None
@@ -0,0 +1,218 @@
1
+ """Lightweight image evidence for ingest routing decisions."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from statistics import mean, pstdev
6
+ from typing import Any
7
+
8
+ from PIL import Image
9
+
10
+ BILL_TERMS = {"bolletta", "fattura", "invoice", "pod", "pdr", "kwh", "energia", "gas"}
11
+ IDENTITY_TERMS = {
12
+ "carta-identita",
13
+ "carta_identita",
14
+ "identity",
15
+ "passport",
16
+ "passaporto",
17
+ "codice-fiscale",
18
+ }
19
+ DOCUMENT_TERMS = {"contratto", "contract", "report", "modulo", "form", "documento"}
20
+
21
+
22
+ def probe_image(path: Path) -> dict[str, Any]:
23
+ """Return cheap, auditable image signals without OCR or vision calls."""
24
+ try:
25
+ with Image.open(path) as image:
26
+ exif = image.getexif()
27
+ width = int(image.width)
28
+ height = int(image.height)
29
+ orientation = exif.get(0x0112) if exif else None
30
+ camera_hint = bool(exif.get(0x010F) or exif.get(0x0110)) if exif else False
31
+ stats = _pixel_stats(image)
32
+ aspect_ratio = round(width / height, 4) if height else None
33
+ suffix = path.suffix.lower()
34
+ name_score = _filename_document_score(path)
35
+ screenshot_hint = _screenshot_hint(
36
+ suffix=suffix,
37
+ width=width,
38
+ height=height,
39
+ camera_hint=camera_hint,
40
+ )
41
+ document_boundary_hint = _document_boundary_hint(width, height)
42
+ poor_capture_hint = _poor_capture_hint(width, height, orientation)
43
+
44
+ document_likelihood = _clamp(
45
+ 0.12
46
+ + (0.35 if document_boundary_hint else 0.0)
47
+ + (0.18 if stats["white_ratio"] >= 0.45 else 0.0)
48
+ + (0.16 if stats["edge_density"] >= 0.08 else 0.0)
49
+ + name_score
50
+ - (0.18 if camera_hint and stats["white_ratio"] < 0.35 else 0.0)
51
+ )
52
+ screenshot_likelihood = _clamp(
53
+ 0.10
54
+ + (0.45 if screenshot_hint else 0.0)
55
+ + (0.18 if width > height else 0.0)
56
+ + (0.14 if stats["edge_density"] >= 0.10 else 0.0)
57
+ - (0.28 if camera_hint else 0.0)
58
+ )
59
+ photo_likelihood = _clamp(
60
+ 0.18
61
+ + (0.42 if camera_hint else 0.0)
62
+ + (0.18 if stats["white_ratio"] < 0.25 else 0.0)
63
+ + (0.10 if stats["contrast"] > 55 else 0.0)
64
+ - (0.20 if screenshot_hint else 0.0)
65
+ )
66
+ text_likelihood = _clamp(
67
+ 0.20
68
+ + (0.35 if stats["edge_density"] >= 0.10 else 0.0)
69
+ + (0.18 if stats["contrast"] >= 35 else 0.0)
70
+ + (0.15 if document_boundary_hint else 0.0)
71
+ )
72
+
73
+ return {
74
+ "kind": "image",
75
+ "format": image.format,
76
+ "width": width,
77
+ "height": height,
78
+ "aspect_ratio": aspect_ratio,
79
+ "orientation": orientation,
80
+ "exif_camera_hint": camera_hint,
81
+ "exif_tag_count": len(exif or {}),
82
+ "screenshot_hint": screenshot_hint,
83
+ "document_boundary_hint": document_boundary_hint,
84
+ "poor_capture_hint": poor_capture_hint,
85
+ "white_background_ratio": stats["white_ratio"],
86
+ "edge_density": stats["edge_density"],
87
+ "brightness": stats["brightness"],
88
+ "contrast": stats["contrast"],
89
+ "document_likelihood": round(document_likelihood, 4),
90
+ "screenshot_likelihood": round(screenshot_likelihood, 4),
91
+ "photo_likelihood": round(photo_likelihood, 4),
92
+ "text_likelihood": round(text_likelihood, 4),
93
+ "image_kind": _image_kind(
94
+ document_likelihood=document_likelihood,
95
+ screenshot_likelihood=screenshot_likelihood,
96
+ photo_likelihood=photo_likelihood,
97
+ ),
98
+ "signals": _signals(
99
+ screenshot_hint=screenshot_hint,
100
+ document_boundary_hint=document_boundary_hint,
101
+ poor_capture_hint=poor_capture_hint,
102
+ camera_hint=camera_hint,
103
+ name_score=name_score,
104
+ stats=stats,
105
+ ),
106
+ }
107
+ except Exception:
108
+ return {"kind": "image", "preflight_error": "image_probe_failed"}
109
+
110
+
111
+ def _pixel_stats(image: Image.Image) -> dict[str, float]:
112
+ sample = image.convert("L")
113
+ sample.thumbnail((160, 160))
114
+ width, height = sample.size
115
+ pixels = list(sample.tobytes())
116
+ if not pixels:
117
+ return {"white_ratio": 0.0, "edge_density": 0.0, "brightness": 0.0, "contrast": 0.0}
118
+
119
+ white_ratio = sum(1 for value in pixels if value >= 235) / len(pixels)
120
+ brightness = mean(pixels)
121
+ contrast = pstdev(pixels) if len(pixels) > 1 else 0.0
122
+ edge_hits = 0
123
+ comparisons = 0
124
+ for y in range(height):
125
+ row = y * width
126
+ for x in range(width):
127
+ value = pixels[row + x]
128
+ if x + 1 < width:
129
+ comparisons += 1
130
+ edge_hits += abs(value - pixels[row + x + 1]) >= 32
131
+ if y + 1 < height:
132
+ comparisons += 1
133
+ edge_hits += abs(value - pixels[row + width + x]) >= 32
134
+ edge_density = edge_hits / comparisons if comparisons else 0.0
135
+ return {
136
+ "white_ratio": round(white_ratio, 4),
137
+ "edge_density": round(edge_density, 4),
138
+ "brightness": round(brightness, 2),
139
+ "contrast": round(contrast, 2),
140
+ }
141
+
142
+
143
+ def _filename_document_score(path: Path) -> float:
144
+ stem = path.stem.lower()
145
+ if any(term in stem for term in BILL_TERMS | IDENTITY_TERMS):
146
+ return 0.28
147
+ if any(term in stem for term in DOCUMENT_TERMS):
148
+ return 0.18
149
+ return 0.0
150
+
151
+
152
+ def _screenshot_hint(*, suffix: str, width: int, height: int, camera_hint: bool) -> bool:
153
+ if camera_hint:
154
+ return False
155
+ if suffix != ".png":
156
+ return False
157
+ return width >= 800 and height >= 500
158
+
159
+
160
+ def _document_boundary_hint(width: int, height: int) -> bool:
161
+ if not width or not height:
162
+ return False
163
+ aspect = width / height
164
+ return 0.55 <= aspect <= 1.9
165
+
166
+
167
+ def _poor_capture_hint(width: int, height: int, orientation: Any) -> bool:
168
+ return width * height < 600_000 or orientation in {3, 6, 8}
169
+
170
+
171
+ def _image_kind(
172
+ *,
173
+ document_likelihood: float,
174
+ screenshot_likelihood: float,
175
+ photo_likelihood: float,
176
+ ) -> str:
177
+ ranked = sorted(
178
+ [
179
+ ("document", document_likelihood),
180
+ ("screenshot", screenshot_likelihood),
181
+ ("photo", photo_likelihood),
182
+ ],
183
+ key=lambda item: item[1],
184
+ reverse=True,
185
+ )
186
+ kind, score = ranked[0]
187
+ return kind if score >= 0.62 else "ambiguous"
188
+
189
+
190
+ def _signals(
191
+ *,
192
+ screenshot_hint: bool,
193
+ document_boundary_hint: bool,
194
+ poor_capture_hint: bool,
195
+ camera_hint: bool,
196
+ name_score: float,
197
+ stats: dict[str, float],
198
+ ) -> list[str]:
199
+ signals: list[str] = []
200
+ if screenshot_hint:
201
+ signals.append("screenshot_hint")
202
+ if document_boundary_hint:
203
+ signals.append("document_boundary_hint")
204
+ if poor_capture_hint:
205
+ signals.append("poor_capture_hint")
206
+ if camera_hint:
207
+ signals.append("exif_camera_hint")
208
+ if name_score:
209
+ signals.append("filename_document_terms")
210
+ if stats["white_ratio"] >= 0.45:
211
+ signals.append("white_background")
212
+ if stats["edge_density"] >= 0.10:
213
+ signals.append("edge_dense")
214
+ return signals
215
+
216
+
217
+ def _clamp(value: float) -> float:
218
+ return max(0.0, min(1.0, value))