marvisx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- core/api/__init__.py +0 -0
- core/api/agents/__init__.py +0 -0
- core/api/agents/session_health.py +59 -0
- core/api/agents/session_manager.py +206 -0
- core/api/bin/marvisx-state-hook.py +182 -0
- core/api/config.py +533 -0
- core/api/db.py +1516 -0
- core/api/dependencies/__init__.py +0 -0
- core/api/dependencies/tenant.py +34 -0
- core/api/main.py +1641 -0
- core/api/mcp/__init__.py +8 -0
- core/api/mcp/_adapter.py +184 -0
- core/api/mcp/server.py +58 -0
- core/api/mcp/tools/__init__.py +59 -0
- core/api/mcp/tools/brain.py +599 -0
- core/api/mcp/tools/graph.py +380 -0
- core/api/mcp/tools/handoffs.py +112 -0
- core/api/mcp/tools/ingest.py +326 -0
- core/api/mcp/tools/learnings.py +144 -0
- core/api/mcp/tools/projects.py +99 -0
- core/api/mcp/tools/pull_requests.py +173 -0
- core/api/mcp/tools/safety.py +111 -0
- core/api/mcp/tools/search.py +79 -0
- core/api/mcp/tools/tasks.py +258 -0
- core/api/middleware/__init__.py +0 -0
- core/api/middleware/tool_call_audit.py +111 -0
- core/api/models/__init__.py +346 -0
- core/api/models/auth.py +48 -0
- core/api/models/brain.py +1006 -0
- core/api/models/common.py +76 -0
- core/api/models/costs.py +91 -0
- core/api/models/graph.py +66 -0
- core/api/models/graph_cosmo.py +125 -0
- core/api/models/graph_pr_impact.py +257 -0
- core/api/models/graph_ux.py +141 -0
- core/api/models/inbox.py +230 -0
- core/api/models/ingest_keys.py +108 -0
- core/api/models/kg.py +41 -0
- core/api/models/llm_config.py +56 -0
- core/api/models/monitoring.py +234 -0
- core/api/models/projects.py +161 -0
- core/api/models/search.py +42 -0
- core/api/models/sessions.py +322 -0
- core/api/models/tasks.py +184 -0
- core/api/models/teams.py +63 -0
- core/api/models/users.py +184 -0
- core/api/observability/__init__.py +0 -0
- core/api/observability/tracing.py +92 -0
- core/api/paths.py +26 -0
- core/api/rate_limit.py +24 -0
- core/api/rbac.py +112 -0
- core/api/routers/__init__.py +0 -0
- core/api/routers/_adapter.py +24 -0
- core/api/routers/admin_pr_impact.py +230 -0
- core/api/routers/admin_settings.py +147 -0
- core/api/routers/agent.py +1079 -0
- core/api/routers/agent_tokens.py +276 -0
- core/api/routers/app_settings.py +112 -0
- core/api/routers/audit.py +89 -0
- core/api/routers/auth.py +586 -0
- core/api/routers/bench.py +161 -0
- core/api/routers/brain.py +881 -0
- core/api/routers/brain_directions.py +527 -0
- core/api/routers/ci_checks.py +140 -0
- core/api/routers/comments.py +273 -0
- core/api/routers/costs.py +148 -0
- core/api/routers/docs_coverage.py +217 -0
- core/api/routers/docs_governance.py +63 -0
- core/api/routers/documents.py +318 -0
- core/api/routers/files.py +163 -0
- core/api/routers/finder.py +987 -0
- core/api/routers/graph.py +836 -0
- core/api/routers/handoffs.py +156 -0
- core/api/routers/inbox.py +496 -0
- core/api/routers/ingest_api_keys.py +205 -0
- core/api/routers/ingest_triage.py +1227 -0
- core/api/routers/judge.py +306 -0
- core/api/routers/kg.py +336 -0
- core/api/routers/learnings.py +253 -0
- core/api/routers/llm_config.py +130 -0
- core/api/routers/monitoring.py +347 -0
- core/api/routers/notifications.py +125 -0
- core/api/routers/pr_impact.py +315 -0
- core/api/routers/projects.py +1061 -0
- core/api/routers/pull_requests.py +312 -0
- core/api/routers/push.py +67 -0
- core/api/routers/raci.py +228 -0
- core/api/routers/search.py +125 -0
- core/api/routers/sessions.py +3100 -0
- core/api/routers/settings.py +90 -0
- core/api/routers/share_repo.py +68 -0
- core/api/routers/status_updates.py +96 -0
- core/api/routers/tags.py +45 -0
- core/api/routers/tasks.py +526 -0
- core/api/routers/teams.py +425 -0
- core/api/routers/terminal.py +105 -0
- core/api/routers/users.py +331 -0
- core/api/routers/webhooks.py +330 -0
- core/api/runtime_settings.py +84 -0
- core/api/security.py +652 -0
- core/api/services/__init__.py +0 -0
- core/api/services/audit.py +58 -0
- core/api/services/auto_approval.py +82 -0
- core/api/services/brain/__init__.py +52 -0
- core/api/services/brain/baseline.py +230 -0
- core/api/services/brain/capabilities.py +75 -0
- core/api/services/brain/cascade_rollup.py +388 -0
- core/api/services/brain/compound_bridge.py +215 -0
- core/api/services/brain/cycle.py +1242 -0
- core/api/services/brain/cycle_snapshot.py +371 -0
- core/api/services/brain/digest_collector.py +147 -0
- core/api/services/brain/direction.py +421 -0
- core/api/services/brain/drift.py +356 -0
- core/api/services/brain/drift_router.py +409 -0
- core/api/services/brain/edge_metrics.py +79 -0
- core/api/services/brain/events_reader.py +222 -0
- core/api/services/brain/findings.py +1379 -0
- core/api/services/brain/findings_reader.py +1006 -0
- core/api/services/brain/jobs.py +733 -0
- core/api/services/brain/journal.py +206 -0
- core/api/services/brain/knowledge_forms.py +92 -0
- core/api/services/brain/llm/__init__.py +37 -0
- core/api/services/brain/llm/_runner.py +62 -0
- core/api/services/brain/llm/base.py +70 -0
- core/api/services/brain/llm/cache.py +99 -0
- core/api/services/brain/llm/constants.py +46 -0
- core/api/services/brain/llm/direction_alignment.py +289 -0
- core/api/services/brain/llm/factory.py +132 -0
- core/api/services/brain/llm/finding_reasoning.py +98 -0
- core/api/services/brain/llm/finding_summary.py +92 -0
- core/api/services/brain/llm/grounding.py +46 -0
- core/api/services/brain/llm/journal_polish.py +96 -0
- core/api/services/brain/llm/local_gateway.py +426 -0
- core/api/services/brain/llm/parsers.py +71 -0
- core/api/services/brain/llm/router_glue.py +422 -0
- core/api/services/brain/memory_ops.py +1677 -0
- core/api/services/brain/models.py +140 -0
- core/api/services/brain/owner_hint.py +211 -0
- core/api/services/brain/recap.py +307 -0
- core/api/services/brain/rules/__init__.py +65 -0
- core/api/services/brain/rules/_signals.py +205 -0
- core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
- core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
- core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
- core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
- core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
- core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
- core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
- core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
- core/api/services/brain/runs_reader.py +485 -0
- core/api/services/brain/scope.py +79 -0
- core/api/services/brain/sources/__init__.py +46 -0
- core/api/services/brain/sources/base.py +86 -0
- core/api/services/brain/sources/git_kg.py +393 -0
- core/api/services/brain/sources/handoffs.py +157 -0
- core/api/services/brain/sources/ingestor.py +130 -0
- core/api/services/brain/sources/learnings.py +121 -0
- core/api/services/brain/sources/pir_tasks.py +245 -0
- core/api/services/brain/watermarks.py +147 -0
- core/api/services/brain/ws_emitter.py +170 -0
- core/api/services/cc_tasks_reader.py +76 -0
- core/api/services/ci_service.py +263 -0
- core/api/services/claude_metrics.py +796 -0
- core/api/services/codex_metrics.py +364 -0
- core/api/services/conversation_reader.py +102 -0
- core/api/services/cost_service.py +243 -0
- core/api/services/crypto.py +147 -0
- core/api/services/docs_governance/__init__.py +1 -0
- core/api/services/docs_governance/confidence.py +230 -0
- core/api/services/docs_governance/config.py +87 -0
- core/api/services/docs_governance/enrichment.py +65 -0
- core/api/services/docs_governance/frontmatter_validator.py +83 -0
- core/api/services/docs_governance/hard_gates.py +221 -0
- core/api/services/docs_governance/triage_orchestrator.py +98 -0
- core/api/services/embedding_internal.py +395 -0
- core/api/services/embedding_service.py +832 -0
- core/api/services/event_dispatcher.py +167 -0
- core/api/services/events.py +70 -0
- core/api/services/git_ops.py +621 -0
- core/api/services/graph_cosmo_service.py +440 -0
- core/api/services/graph_ranker.py +306 -0
- core/api/services/graph_service.py +1589 -0
- core/api/services/inbox.py +800 -0
- core/api/services/inbox_digest.py +221 -0
- core/api/services/inbox_digest_deep_research.py +80 -0
- core/api/services/inbox_digest_jobs.py +595 -0
- core/api/services/inbox_gmail_sync.py +167 -0
- core/api/services/inbox_llm_classifier.py +906 -0
- core/api/services/inbox_source_identity.py +116 -0
- core/api/services/inbox_sources.py +456 -0
- core/api/services/inbox_taxonomy.py +195 -0
- core/api/services/inbox_tldr.py +1079 -0
- core/api/services/inbox_triage.py +899 -0
- core/api/services/ingest/__init__.py +13 -0
- core/api/services/ingest/api_key_auth.py +136 -0
- core/api/services/ingest/auto_approve.py +120 -0
- core/api/services/ingest/classifier.py +138 -0
- core/api/services/ingest/confidence.py +173 -0
- core/api/services/ingest/dispatch.py +88 -0
- core/api/services/ingest/embedding_router.py +272 -0
- core/api/services/ingest/events.py +33 -0
- core/api/services/ingest/ignore_patterns.py +79 -0
- core/api/services/ingest/image_probe.py +218 -0
- core/api/services/ingest/ingress.py +263 -0
- core/api/services/ingest/insert_saga.py +793 -0
- core/api/services/ingest/llm/__init__.py +13 -0
- core/api/services/ingest/llm/anthropic_haiku.py +23 -0
- core/api/services/ingest/llm/base.py +59 -0
- core/api/services/ingest/llm/byok_provider.py +130 -0
- core/api/services/ingest/llm/classification_context.py +301 -0
- core/api/services/ingest/llm/config_store.py +246 -0
- core/api/services/ingest/llm/factory.py +24 -0
- core/api/services/ingest/llm/kg_enricher.py +306 -0
- core/api/services/ingest/llm/local_gateway.py +821 -0
- core/api/services/ingest/llm/local_vllm.py +23 -0
- core/api/services/ingest/llm/openai_nano.py +349 -0
- core/api/services/ingest/lock_advisory.py +57 -0
- core/api/services/ingest/parser_router.py +1756 -0
- core/api/services/ingest/parsers/__init__.py +1 -0
- core/api/services/ingest/parsers/docling_parser.py +142 -0
- core/api/services/ingest/parsers/docparse_gateway.py +178 -0
- core/api/services/ingest/parsers/docx_parser.py +127 -0
- core/api/services/ingest/parsers/folder_unpacker.py +85 -0
- core/api/services/ingest/parsers/gateway_aux.py +147 -0
- core/api/services/ingest/parsers/image_parser.py +251 -0
- core/api/services/ingest/parsers/internal_markdown.py +89 -0
- core/api/services/ingest/parsers/ocr_gateway.py +117 -0
- core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
- core/api/services/ingest/parsers/pdf_types.py +13 -0
- core/api/services/ingest/parsers/transcript_parser.py +445 -0
- core/api/services/ingest/parsers/vision_gateway.py +186 -0
- core/api/services/ingest/parsers/xlsx_parser.py +91 -0
- core/api/services/ingest/parsers/zip_unpacker.py +126 -0
- core/api/services/ingest/preflight.py +393 -0
- core/api/services/ingest/retry_voyage.py +88 -0
- core/api/services/ingest/routing_policy.py +307 -0
- core/api/services/ingest/serializers/__init__.py +1 -0
- core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
- core/api/services/ingest/skip_log.py +74 -0
- core/api/services/ingest/watcher.py +637 -0
- core/api/services/kg/__init__.py +0 -0
- core/api/services/kg/audit.py +49 -0
- core/api/services/kg/hybrid_search.py +691 -0
- core/api/services/kg/lens.py +339 -0
- core/api/services/kg/pr_impact.py +770 -0
- core/api/services/kg/queries.py +152 -0
- core/api/services/kg/ranking.py +89 -0
- core/api/services/kg/rrf.py +143 -0
- core/api/services/kg_watcher_control.py +161 -0
- core/api/services/local_llm/__init__.py +19 -0
- core/api/services/local_llm/async_client.py +385 -0
- core/api/services/local_llm/client.py +173 -0
- core/api/services/local_llm/url_validator.py +44 -0
- core/api/services/metrics_collector.py +646 -0
- core/api/services/metrics_providers.py +65 -0
- core/api/services/model_registry.py +266 -0
- core/api/services/model_router.py +137 -0
- core/api/services/n8n_client.py +77 -0
- core/api/services/newsletter_llm_gateway.py +66 -0
- core/api/services/notification_service.py +134 -0
- core/api/services/openai_responses.py +55 -0
- core/api/services/opencode_metrics.py +375 -0
- core/api/services/opencode_sessions.py +173 -0
- core/api/services/pii_redactor.py +138 -0
- core/api/services/pr_impact_pipeline/__init__.py +21 -0
- core/api/services/pr_impact_pipeline/differ.py +421 -0
- core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
- core/api/services/pr_impact_pipeline/gc.py +93 -0
- core/api/services/pr_impact_pipeline/languages.py +192 -0
- core/api/services/pr_impact_pipeline/parser.py +178 -0
- core/api/services/pr_impact_pipeline/writer.py +394 -0
- core/api/services/pr_service.py +1393 -0
- core/api/services/project_paths.py +70 -0
- core/api/services/project_status_updates.py +265 -0
- core/api/services/providers.py +276 -0
- core/api/services/push_service.py +170 -0
- core/api/services/reminder_service.py +89 -0
- core/api/services/runas.py +41 -0
- core/api/services/salience_service.py +69 -0
- core/api/services/security_collector.py +281 -0
- core/api/services/session_catalog.py +385 -0
- core/api/services/session_metrics_service.py +301 -0
- core/api/services/session_ops.py +272 -0
- core/api/services/session_state.py +173 -0
- core/api/services/share_links.py +222 -0
- core/api/services/task_transitions.py +146 -0
- core/api/services/terminal_metrics.py +462 -0
- core/api/services/terminal_metrics_dump.py +203 -0
- core/api/services/tmux.py +1205 -0
- core/api/services/webhook_service.py +422 -0
- core/api/services/workspace_sync.py +164 -0
- core/api/templates/__init__.py +1 -0
- core/api/templates/markdown_share.py +164 -0
- core/api/terminal.py +1031 -0
- core/api/tests/__init__.py +0 -0
- core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
- core/api/tests/test_audit_permissions.py +133 -0
- core/api/tests/test_backfill_session_conversations.py +90 -0
- core/api/tests/test_backfill_working_seconds_msg.py +129 -0
- core/api/tests/test_claude_metrics.py +326 -0
- core/api/tests/test_codex_metrics.py +189 -0
- core/api/tests/test_finder_paths.py +74 -0
- core/api/tests/test_git_ops_merge.py +155 -0
- core/api/tests/test_learnings_check_search.py +81 -0
- core/api/tests/test_metrics_providers.py +133 -0
- core/api/tests/test_migration_087.py +164 -0
- core/api/tests/test_migration_088.py +94 -0
- core/api/tests/test_migration_089.py +116 -0
- core/api/tests/test_openai_responses.py +24 -0
- core/api/tests/test_opencode_metrics.py +740 -0
- core/api/tests/test_opencode_sessions.py +321 -0
- core/api/tests/test_pr_workflow_e2e.py +457 -0
- core/api/tests/test_projects_handoffs.py +31 -0
- core/api/tests/test_providers.py +138 -0
- core/api/tests/test_safety_bridge.py +347 -0
- core/api/tests/test_session_catalog.py +142 -0
- core/api/tests/test_session_conversations.py +512 -0
- core/api/tests/test_session_metrics_service.py +270 -0
- core/api/tests/test_session_resume_paths.py +548 -0
- core/api/tests/test_session_theme_mode_migration.py +56 -0
- core/api/tests/test_sessions_rbac.py +131 -0
- core/api/tests/test_share_edit.py +398 -0
- core/api/tests/test_share_repo.py +200 -0
- core/api/tests/test_terminal_session_manager.py +98 -0
- core/api/tests/test_terminal_upload.py +34 -0
- core/api/tests/test_tmux.py +272 -0
- core/api/tests/test_workspace_sync.py +186 -0
- core/api/tests/test_ws_ticket_in_memory.py +73 -0
- core/api/use_cases/__init__.py +11 -0
- core/api/use_cases/_context.py +89 -0
- core/api/use_cases/_errors.py +62 -0
- core/api/use_cases/_roles.py +16 -0
- core/api/use_cases/audit.py +171 -0
- core/api/use_cases/brain.py +1232 -0
- core/api/use_cases/costs.py +249 -0
- core/api/use_cases/graph.py +1153 -0
- core/api/use_cases/handoffs.py +506 -0
- core/api/use_cases/ingest_triage.py +1229 -0
- core/api/use_cases/learnings.py +538 -0
- core/api/use_cases/projects.py +705 -0
- core/api/use_cases/pull_requests.py +415 -0
- core/api/use_cases/search.py +926 -0
- core/api/use_cases/tasks.py +1495 -0
- core/api/visibility.py +141 -0
- core/cli/__init__.py +5 -0
- core/cli/_index_source.py +632 -0
- core/cli/_runtime_ctx.py +160 -0
- core/cli/_transmute.py +241 -0
- core/cli/marvis_doctor.py +704 -0
- core/cli/marvis_feedback.py +396 -0
- core/cli/marvis_governance.py +315 -0
- core/cli/marvis_hooks.py +515 -0
- core/cli/marvis_init.py +757 -0
- core/cli/marvis_mcp.py +401 -0
- core/cli/marvis_runtime.py +855 -0
- core/cli/marvis_telemetry.py +228 -0
- core/scripts/_drift_check.py +716 -0
- core/scripts/_frontmatter.py +66 -0
- core/scripts/_graph_writer.py +189 -0
- core/scripts/ast_parser.py +1553 -0
- core/scripts/install_hooks/__init__.py +1 -0
- core/scripts/install_hooks/_config.sh +109 -0
- core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
- core/scripts/install_hooks/block-db-direct-write.sh +23 -0
- core/scripts/install_hooks/block-push-no-task.sh +23 -0
- core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
- core/scripts/install_hooks/block-subtree-push.sh +23 -0
- core/scripts/install_hooks/config.json +53 -0
- core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
- core/scripts/install_hooks/enforce-worktree.sh +23 -0
- core/scripts/install_hooks/quality-gate.sh +170 -0
- core/scripts/install_hooks/safety_bridge.py +968 -0
- core/scripts/install_hooks/secret-scan.sh +23 -0
- core/scripts/migrate_spike_node_ids.py +122 -0
- core/scripts/populate_artifacts.py +2198 -0
- core/scripts/populate_cross_project.py +2457 -0
- core/scripts/populate_inbox_nodes.py +357 -0
- core/scripts/populate_pr_impact.py +267 -0
- core/scripts/populate_project_nodes.py +603 -0
- core/scripts/populate_touch_counter.py +337 -0
- core/scripts/reparse_failed.py +57 -0
- core/scripts/safety_bridge.py +968 -0
- core/telemetry/__init__.py +9 -0
- core/telemetry/client.py +405 -0
- core/telemetry/schema.py +122 -0
- core/wizard/__init__.py +65 -0
- core/wizard/byok_vault.py +147 -0
- core/wizard/defaults.py +58 -0
- core/wizard/state.py +117 -0
- core/wizard/steps.py +70 -0
- core/wizard/validation.py +136 -0
- marvisx_cli-0.1.0.dist-info/METADATA +201 -0
- marvisx_cli-0.1.0.dist-info/RECORD +587 -0
- marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
- marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
- marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
- migrations/001_initial.sql +33 -0
- migrations/002_tasks.sql +30 -0
- migrations/003_session_management.sql +7 -0
- migrations/004_projects_comments.sql +65 -0
- migrations/005_session_intelligence.sql +15 -0
- migrations/006_settings.sql +12 -0
- migrations/007_task_scoring.sql +12 -0
- migrations/008_cost_tracking.sql +31 -0
- migrations/009_session_card_metrics.sql +3 -0
- migrations/010_monitoring.sql +55 -0
- migrations/012_agent_api.sql +21 -0
- migrations/013_session_complete.sql +8 -0
- migrations/015_pull_requests.sql +43 -0
- migrations/015_pull_requests_down.sql +5 -0
- migrations/016_users_raci.sql +116 -0
- migrations/017_task_cost_entries.sql +87 -0
- migrations/018_agents.sql +73 -0
- migrations/018_agents_down.sql +13 -0
- migrations/019_review_feedback.sql +18 -0
- migrations/020_pr_commit_sha.sql +4 -0
- migrations/021_webhook_events.sql +18 -0
- migrations/022_devx_agent_managed.sql +11 -0
- migrations/022_devx_agent_managed_down.sql +6 -0
- migrations/023_devx_p1_gate.sql +7 -0
- migrations/023_devx_p1_gate_down.sql +3 -0
- migrations/024_chat_messages.sql +16 -0
- migrations/024_pr_conversation_id.sql +8 -0
- migrations/024_task_indexes.sql +21 -0
- migrations/024_task_indexes_down.sql +7 -0
- migrations/025_audit_log.sql +17 -0
- migrations/026_agent_tokens.sql +20 -0
- migrations/027_teams_auth_phase_b.sql +35 -0
- migrations/028_learnings.sql +23 -0
- migrations/029_team_roles.sql +14 -0
- migrations/030_finder_pins.sql +10 -0
- migrations/031_pr_deploy_status.sql +9 -0
- migrations/032_task_reminders.sql +7 -0
- migrations/033_events_retry_count.sql +6 -0
- migrations/033_session_owner.sql +9 -0
- migrations/034_notifications.sql +38 -0
- migrations/035_shared_links.sql +15 -0
- migrations/036_session_index_upgrade.sql +29 -0
- migrations/037_pr_approval.sql +15 -0
- migrations/038_pr_submitted_by.sql +6 -0
- migrations/039_push_subscriptions.sql +17 -0
- migrations/040_semantic_search.sql +16 -0
- migrations/041_workspaces.sql +63 -0
- migrations/042_oidc_providers.sql +24 -0
- migrations/043_ci_checks.sql +31 -0
- migrations/044_agent_metrics.sql +30 -0
- migrations/045_documents_doc_type.sql +5 -0
- migrations/046_salience.sql +13 -0
- migrations/047_seed_missing_agents.sql +6 -0
- migrations/048_fix_agent_paths_roles.sql +5 -0
- migrations/049_agent_role_and_learnings_schema.sql +3 -0
- migrations/050_session_provider.sql +2 -0
- migrations/051_session_launch_profile.sql +4 -0
- migrations/052_session_theme_mode.sql +2 -0
- migrations/052_task_kind.sql +4 -0
- migrations/053_inbox_items.sql +31 -0
- migrations/054_inbox_triage_contract.sql +30 -0
- migrations/055_inbox_topic_treatment.sql +12 -0
- migrations/056_inbox_treatment_read_save.sql +57 -0
- migrations/057_session_theme_mode_backfill.sql +4 -0
- migrations/058_inbox_item_status_lifecycle.sql +13 -0
- migrations/059_inbox_tldr_and_source_scores.sql +18 -0
- migrations/060_newsletter.sql +16 -0
- migrations/061_inbox_redesign.sql +69 -0
- migrations/062_fix_inbox_sources_backfill.sql +37 -0
- migrations/063_task_completion_mode.sql +23 -0
- migrations/064_judge_mode_setting.sql +4 -0
- migrations/065_knowledge_graph_spike.sql +40 -0
- migrations/066_digest_ranking_inputs.sql +10 -0
- migrations/066_kg_artifact_nodes.sql +129 -0
- migrations/067_inbox_digest_selections.sql +28 -0
- migrations/067_kg_temporal.sql +53 -0
- migrations/068_inbox_digest_app_settings.sql +9 -0
- migrations/068_kg_touch_counter.sql +52 -0
- migrations/069_kg_doc_types.sql +117 -0
- migrations/070_digest_ranking_inputs_recovery.sql +3 -0
- migrations/071_inbox_digest_selections_recovery.sql +3 -0
- migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
- migrations/073_kg_cross_project.sql +216 -0
- migrations/073_kg_cross_project_down.sql +77 -0
- migrations/074_kg_infra_types.sql +208 -0
- migrations/074_kg_infra_types_down.sql +80 -0
- migrations/075_kg_file_state_recovery.sql +35 -0
- migrations/075_kg_file_state_recovery_down.sql +5 -0
- migrations/076_kg_watcher_state.sql +33 -0
- migrations/076_kg_watcher_state_down.sql +3 -0
- migrations/077_kg_doc_types_extend.sql +226 -0
- migrations/077_kg_doc_types_extend_down.sql +80 -0
- migrations/078_kg_fts5.sql +102 -0
- migrations/078_kg_fts5_down.sql +14 -0
- migrations/079_kg_missing_indexes.sql +31 -0
- migrations/079_kg_missing_indexes_down.sql +10 -0
- migrations/080_kg_fts5_extended.sql +232 -0
- migrations/080_kg_fts5_extended_down.sql +25 -0
- migrations/081_kg_lens_indexes.sql +9 -0
- migrations/081_kg_lens_indexes_down.sql +3 -0
- migrations/082_kg_pins.sql +26 -0
- migrations/082_kg_pins_down.sql +14 -0
- migrations/083_kg_graph_nodes_degree.sql +20 -0
- migrations/083_kg_graph_nodes_degree_down.sql +15 -0
- migrations/084_drop_legacy_scheduler_tables.sql +58 -0
- migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
- migrations/085_kg_edge_resolves_to.sql +142 -0
- migrations/085_kg_edge_resolves_to_down.sql +66 -0
- migrations/086_project_status_updates_feed.sql +20 -0
- migrations/086_project_status_updates_feed_down.sql +36 -0
- migrations/087_session_metrics_dual.sql +50 -0
- migrations/087_session_metrics_dual_down.sql +21 -0
- migrations/088_rename_context_pct_legacy.sql +23 -0
- migrations/088_rename_context_pct_legacy_down.sql +8 -0
- migrations/089_session_metrics_equivalent_cost.sql +26 -0
- migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
- migrations/090_kg_inbox_node_type.sql +26 -0
- migrations/090_kg_inbox_node_type_down.sql +20 -0
- migrations/091_kg_inbox_node_type_check.sql +265 -0
- migrations/091_kg_inbox_node_type_check_down.sql +129 -0
- migrations/092_sessions_activity_state_ts.sql +29 -0
- migrations/092_sessions_activity_state_ts_down.sql +14 -0
- migrations/093_sessions_activity_state_column.sql +29 -0
- migrations/093_sessions_activity_state_column_down.sql +10 -0
- migrations/094_ingest_pending.sql +55 -0
- migrations/094_ingest_pending_down.sql +15 -0
- migrations/095_kg_intent_first.sql +77 -0
- migrations/095_kg_intent_first_down.sql +25 -0
- migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
- migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
- migrations/097_ingest_change_history.sql +37 -0
- migrations/097_ingest_change_history_down.sql +13 -0
- migrations/098_kg_node_type_business.sql +254 -0
- migrations/098_kg_node_type_business_down.sql +195 -0
- migrations/099_kg_edges_restore_weight.sql +58 -0
- migrations/099_kg_edges_restore_weight_down.sql +12 -0
- migrations/100_kg_enriched_at.sql +25 -0
- migrations/100_kg_enriched_at_down.sql +12 -0
- migrations/101_local_llm_shadow_comparisons.sql +66 -0
- migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
- migrations/102_promote_llm_costs.sql +69 -0
- migrations/102_promote_llm_costs_down.sql +19 -0
- migrations/103_ingest_skipped_log.sql +46 -0
- migrations/103_ingest_skipped_log_down.sql +15 -0
- migrations/120_docs_governance.sql +50 -0
- migrations/120_docs_governance_down.sql +11 -0
- migrations/121_notification_event_fk_cleanup.sql +21 -0
- migrations/121_notification_event_fk_cleanup_down.sql +10 -0
- migrations/122_docs_drift_history.sql +34 -0
- migrations/122_docs_drift_history_down.sql +15 -0
- migrations/123_ingest_parser_waiting_status.sql +69 -0
- migrations/123_ingest_parser_waiting_status_down.sql +69 -0
- migrations/124_heypocket_recordings.sql +63 -0
- migrations/124_heypocket_recordings_down.sql +13 -0
- migrations/125_kg_node_type_record.sql +219 -0
- migrations/125_kg_node_type_record_down.sql +205 -0
- migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
- migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
- migrations/127_brain_v1_substrate.sql +200 -0
- migrations/127_brain_v1_substrate_down.sql +32 -0
- migrations/128_brain_drift_signals.sql +157 -0
- migrations/128_brain_drift_signals_down.sql +23 -0
- migrations/129_brain_memory_operations.sql +232 -0
- migrations/129_brain_memory_operations_down.sql +27 -0
- migrations/130_brain_findings.sql +258 -0
- migrations/130_brain_findings_down.sql +29 -0
- migrations/132_kg_pr_modifies.sql +242 -0
- migrations/132_kg_pr_modifies_down.sql +99 -0
- migrations/133_brain_v1_2_direction_schema.sql +476 -0
- migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
- migrations/134_brain_journal_narrative_polished.sql +8 -0
- migrations/134_brain_journal_narrative_polished_down.sql +6 -0
- migrations/135_kg_edges_provider.sql +21 -0
- migrations/136_documents_fts.sql +56 -0
- migrations/137_promote_llm_costs.sql +59 -0
- migrations/137_promote_llm_costs_down.sql +19 -0
- migrations/138_ingest_api_keys.sql +39 -0
- migrations/138_ingest_api_keys_down.sql +8 -0
- migrations/139_ingest_pending_ingress.sql +91 -0
- migrations/139_ingest_pending_ingress_down.sql +73 -0
- migrations/140_ingest_idempotency_quota.sql +45 -0
- migrations/140_ingest_idempotency_quota_down.sql +9 -0
- migrations/141_ingest_pending_metadata.sql +16 -0
- migrations/141_ingest_pending_metadata_down.sql +7 -0
- migrations/142_llm_function_config.sql +36 -0
- migrations/142_llm_function_config_down.sql +8 -0
- migrations/143_kg_code_embeddings.sql +25 -0
- migrations/143_kg_code_embeddings_down.sql +5 -0
- migrations/__init__.py +4 -0
- projects/_template/project.yaml +46 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from email.utils import parseaddr, parsedate_to_datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
import aiosqlite
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from core.api.config import settings
|
|
18
|
+
from core.api.use_cases._errors import (
|
|
19
|
+
AuthorizationError,
|
|
20
|
+
ConflictError,
|
|
21
|
+
ServiceError,
|
|
22
|
+
ValidationError,
|
|
23
|
+
)
|
|
24
|
+
from core.api.models.auth import UserInfo
|
|
25
|
+
from core.api.models.inbox import (
|
|
26
|
+
InboxIngestBatchItemError,
|
|
27
|
+
InboxIngestBatchRequest,
|
|
28
|
+
InboxIngestBatchResponse,
|
|
29
|
+
InboxIngestRequest,
|
|
30
|
+
InboxIngestResponse,
|
|
31
|
+
)
|
|
32
|
+
from core.api.services.inbox_taxonomy import (
|
|
33
|
+
infer_topic_from_metadata,
|
|
34
|
+
infer_treatment,
|
|
35
|
+
normalize_inbox_topic,
|
|
36
|
+
normalize_inbox_treatment,
|
|
37
|
+
)
|
|
38
|
+
from core.api.services.inbox_source_identity import (
|
|
39
|
+
HTTP_URL_RE as _HTTP_URL_RE,
|
|
40
|
+
is_gmail_hosted_url as _shared_is_gmail_hosted_url,
|
|
41
|
+
is_low_signal_source_key,
|
|
42
|
+
normalize_domain_key,
|
|
43
|
+
unwrap_tracking_url,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
_PROGRAM_SLUG_RE = r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"
|
|
49
|
+
|
|
50
|
+
_PUBLISHED_METADATA_KEYS = (
|
|
51
|
+
"published_at",
|
|
52
|
+
"publishedAt",
|
|
53
|
+
"published",
|
|
54
|
+
"pub_date",
|
|
55
|
+
"pubDate",
|
|
56
|
+
"updated_at",
|
|
57
|
+
"updatedAt",
|
|
58
|
+
"date",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class InboxSourceConfig:
|
|
64
|
+
name: str
|
|
65
|
+
enabled: bool = True
|
|
66
|
+
candidate_programs: list[str] = field(default_factory=list)
|
|
67
|
+
default_program: str | None = None
|
|
68
|
+
allowed_roots: list[str] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sanitize_text(value: str | None, *, max_length: int) -> str | None:
|
|
72
|
+
if value is None:
|
|
73
|
+
return None
|
|
74
|
+
sanitized = value.replace("\x00", "").replace("\r\n", "\n").strip()
|
|
75
|
+
if not sanitized:
|
|
76
|
+
return None
|
|
77
|
+
if len(sanitized) > max_length:
|
|
78
|
+
sanitized = sanitized[:max_length].rstrip()
|
|
79
|
+
return sanitized or None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sanitize_metadata(value: Any) -> Any:
|
|
83
|
+
if isinstance(value, dict):
|
|
84
|
+
return {str(k): _sanitize_metadata(v) for k, v in value.items()}
|
|
85
|
+
if isinstance(value, list):
|
|
86
|
+
return [_sanitize_metadata(item) for item in value]
|
|
87
|
+
if isinstance(value, str):
|
|
88
|
+
return value.replace("\x00", "").strip()
|
|
89
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
90
|
+
return value
|
|
91
|
+
return str(value)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_source_configs() -> dict[str, InboxSourceConfig]:
|
|
95
|
+
raw = settings.inbox_sources_json.strip() or "{}"
|
|
96
|
+
try:
|
|
97
|
+
payload = json.loads(raw)
|
|
98
|
+
except json.JSONDecodeError as exc:
|
|
99
|
+
logger.error("Invalid inbox_sources_json: %s", exc)
|
|
100
|
+
return {}
|
|
101
|
+
|
|
102
|
+
if not isinstance(payload, dict):
|
|
103
|
+
return {}
|
|
104
|
+
|
|
105
|
+
configs: dict[str, InboxSourceConfig] = {}
|
|
106
|
+
for source_name, config in payload.items():
|
|
107
|
+
if not isinstance(config, dict):
|
|
108
|
+
continue
|
|
109
|
+
configs[source_name] = InboxSourceConfig(
|
|
110
|
+
name=source_name,
|
|
111
|
+
enabled=bool(config.get("enabled", True)),
|
|
112
|
+
candidate_programs=[
|
|
113
|
+
str(item) for item in config.get("candidate_programs", [])
|
|
114
|
+
],
|
|
115
|
+
default_program=str(config["default_program"])
|
|
116
|
+
if config.get("default_program")
|
|
117
|
+
else None,
|
|
118
|
+
allowed_roots=[str(item) for item in config.get("allowed_roots", [])],
|
|
119
|
+
)
|
|
120
|
+
return configs
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_source_config(source: str) -> InboxSourceConfig:
|
|
124
|
+
config = _load_source_configs().get(source)
|
|
125
|
+
if config is None or not config.enabled:
|
|
126
|
+
raise AuthorizationError(
|
|
127
|
+
code="inbox_source_disabled", message="Inbox source is not enabled"
|
|
128
|
+
)
|
|
129
|
+
return config
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _known_programs() -> set[str]:
|
|
133
|
+
yaml_path = Path.home() / "workspace" / "programs.yaml"
|
|
134
|
+
if not yaml_path.exists():
|
|
135
|
+
return set()
|
|
136
|
+
try:
|
|
137
|
+
programs = yaml.safe_load(yaml_path.read_text()) or {}
|
|
138
|
+
except Exception:
|
|
139
|
+
logger.exception("Failed to load programs.yaml for inbox ingest")
|
|
140
|
+
return set()
|
|
141
|
+
if not isinstance(programs, dict):
|
|
142
|
+
return set()
|
|
143
|
+
return {str(name) for name in programs.keys()}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _validate_program_slug(program: str) -> str:
|
|
147
|
+
import re
|
|
148
|
+
|
|
149
|
+
if not re.match(_PROGRAM_SLUG_RE, program):
|
|
150
|
+
raise ValidationError(
|
|
151
|
+
code="invalid_program_slug", message=f"Invalid program slug: {program}"
|
|
152
|
+
)
|
|
153
|
+
return program
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _merge_program_metadata(
|
|
157
|
+
body: InboxIngestRequest,
|
|
158
|
+
config: InboxSourceConfig,
|
|
159
|
+
) -> tuple[list[str], str | None]:
|
|
160
|
+
known = _known_programs()
|
|
161
|
+
requested = [_validate_program_slug(item) for item in body.candidate_programs]
|
|
162
|
+
configured = [_validate_program_slug(item) for item in config.candidate_programs]
|
|
163
|
+
|
|
164
|
+
for program in requested + configured:
|
|
165
|
+
if known and program not in known:
|
|
166
|
+
raise ValidationError(
|
|
167
|
+
code="unknown_program", message=f"Unknown program: {program}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
merged: list[str] = []
|
|
171
|
+
for program in requested + configured:
|
|
172
|
+
if program not in merged:
|
|
173
|
+
merged.append(program)
|
|
174
|
+
|
|
175
|
+
default_program = body.default_program or config.default_program
|
|
176
|
+
if default_program is not None:
|
|
177
|
+
default_program = _validate_program_slug(default_program)
|
|
178
|
+
if known and default_program not in known:
|
|
179
|
+
raise ValidationError(
|
|
180
|
+
code="unknown_program", message=f"Unknown program: {default_program}"
|
|
181
|
+
)
|
|
182
|
+
if default_program not in merged:
|
|
183
|
+
merged.insert(0, default_program)
|
|
184
|
+
elif merged:
|
|
185
|
+
default_program = merged[0]
|
|
186
|
+
|
|
187
|
+
return merged, default_program
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _normalize_url(url: str | None) -> str | None:
|
|
191
|
+
if url is None:
|
|
192
|
+
return None
|
|
193
|
+
parsed = urlsplit(url)
|
|
194
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
195
|
+
raise ValidationError(code="invalid_url", message="Invalid url")
|
|
196
|
+
return url
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _normalize_domain_key(raw: str | None) -> str | None:
|
|
200
|
+
return normalize_domain_key(raw)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _normalize_sender_identity(raw: str | None) -> str | None:
|
|
204
|
+
if not raw:
|
|
205
|
+
return None
|
|
206
|
+
_, email = parseaddr(raw)
|
|
207
|
+
candidate = (email or raw).strip().lower()
|
|
208
|
+
if not candidate or "@" not in candidate:
|
|
209
|
+
return None
|
|
210
|
+
local, domain = candidate.rsplit("@", 1)
|
|
211
|
+
local = local.strip()
|
|
212
|
+
domain = domain.strip()
|
|
213
|
+
if not local or not domain:
|
|
214
|
+
return None
|
|
215
|
+
return f"{local}@{domain}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _iter_sender_values(value: Any) -> list[str]:
|
|
219
|
+
if isinstance(value, str):
|
|
220
|
+
return [value] if value.strip() else []
|
|
221
|
+
if isinstance(value, list):
|
|
222
|
+
values: list[str] = []
|
|
223
|
+
for entry in value:
|
|
224
|
+
values.extend(_iter_sender_values(entry))
|
|
225
|
+
return values
|
|
226
|
+
if not isinstance(value, dict):
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
values = []
|
|
230
|
+
parsed_values = value.get("value")
|
|
231
|
+
if isinstance(parsed_values, list):
|
|
232
|
+
for entry in parsed_values:
|
|
233
|
+
if isinstance(entry, dict):
|
|
234
|
+
address = entry.get("address")
|
|
235
|
+
name = entry.get("name")
|
|
236
|
+
if isinstance(address, str) and address.strip():
|
|
237
|
+
if isinstance(name, str) and name.strip():
|
|
238
|
+
values.append(f"{name.strip()} <{address.strip()}>")
|
|
239
|
+
else:
|
|
240
|
+
values.append(address.strip())
|
|
241
|
+
elif isinstance(entry, str) and entry.strip():
|
|
242
|
+
values.append(entry.strip())
|
|
243
|
+
|
|
244
|
+
for key in ("text", "address", "email", "html"):
|
|
245
|
+
raw = value.get(key)
|
|
246
|
+
if isinstance(raw, str) and raw.strip():
|
|
247
|
+
values.append(raw.strip())
|
|
248
|
+
return values
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _extract_sender_domain(metadata: dict[str, Any]) -> str | None:
|
|
252
|
+
for key in ("sender", "from_email", "author", "source_author"):
|
|
253
|
+
for value in _iter_sender_values(metadata.get(key)):
|
|
254
|
+
_, email = parseaddr(value)
|
|
255
|
+
candidate = email or value.strip()
|
|
256
|
+
if "@" not in candidate:
|
|
257
|
+
continue
|
|
258
|
+
_, domain = candidate.rsplit("@", 1)
|
|
259
|
+
domain_key = _normalize_domain_key(domain)
|
|
260
|
+
if domain_key:
|
|
261
|
+
return domain_key
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _extract_sender_identity(metadata: dict[str, Any]) -> str | None:
|
|
266
|
+
for key in ("sender", "from_email", "author", "source_author"):
|
|
267
|
+
for value in _iter_sender_values(metadata.get(key)):
|
|
268
|
+
sender_key = _normalize_sender_identity(value)
|
|
269
|
+
if sender_key:
|
|
270
|
+
return sender_key
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _is_gmail_hosted_url(url: str | None) -> bool:
|
|
275
|
+
return _shared_is_gmail_hosted_url(url)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _safe_normalize_url(url: str | None) -> str | None:
|
|
279
|
+
if not isinstance(url, str) or not url.strip():
|
|
280
|
+
return None
|
|
281
|
+
try:
|
|
282
|
+
return _normalize_url(url.strip())
|
|
283
|
+
except ServiceError:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _normalize_article_url_candidate(raw_url: str | None) -> str | None:
|
|
288
|
+
normalized = _safe_normalize_url(raw_url)
|
|
289
|
+
if not normalized or _is_gmail_hosted_url(normalized):
|
|
290
|
+
return None
|
|
291
|
+
unwrapped = _safe_normalize_url(unwrap_tracking_url(normalized))
|
|
292
|
+
if unwrapped and not _is_gmail_hosted_url(unwrapped):
|
|
293
|
+
return unwrapped
|
|
294
|
+
return normalized
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _prefer_real_article_url(
|
|
298
|
+
current: str | None,
|
|
299
|
+
candidate: str | None,
|
|
300
|
+
) -> str | None:
|
|
301
|
+
if not candidate:
|
|
302
|
+
return current
|
|
303
|
+
if not is_low_signal_source_key(_normalize_domain_key(candidate)):
|
|
304
|
+
return candidate
|
|
305
|
+
return current or candidate
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _extract_url_from_text(text: str | None, current: str | None) -> str | None:
|
|
309
|
+
if not isinstance(text, str) or not text.strip():
|
|
310
|
+
return current
|
|
311
|
+
resolved = current
|
|
312
|
+
for match in _HTTP_URL_RE.finditer(text):
|
|
313
|
+
candidate = _normalize_article_url_candidate(match.group(0).rstrip(".,;:"))
|
|
314
|
+
resolved = _prefer_real_article_url(resolved, candidate)
|
|
315
|
+
if resolved and not is_low_signal_source_key(_normalize_domain_key(resolved)):
|
|
316
|
+
return resolved
|
|
317
|
+
return resolved
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _extract_article_url(metadata: dict[str, Any], content: str | None) -> str | None:
|
|
321
|
+
resolved: str | None = None
|
|
322
|
+
for key in (
|
|
323
|
+
"article_url",
|
|
324
|
+
"articleUrl",
|
|
325
|
+
"canonical_url",
|
|
326
|
+
"canonicalUrl",
|
|
327
|
+
"link",
|
|
328
|
+
"href",
|
|
329
|
+
"url",
|
|
330
|
+
):
|
|
331
|
+
value = metadata.get(key)
|
|
332
|
+
candidate = _normalize_article_url_candidate(
|
|
333
|
+
value if isinstance(value, str) else None
|
|
334
|
+
)
|
|
335
|
+
resolved = _prefer_real_article_url(resolved, candidate)
|
|
336
|
+
if resolved and not is_low_signal_source_key(_normalize_domain_key(resolved)):
|
|
337
|
+
return resolved
|
|
338
|
+
|
|
339
|
+
links = metadata.get("links")
|
|
340
|
+
if isinstance(links, list):
|
|
341
|
+
for entry in links:
|
|
342
|
+
if isinstance(entry, str):
|
|
343
|
+
candidate = _normalize_article_url_candidate(entry)
|
|
344
|
+
elif isinstance(entry, dict):
|
|
345
|
+
candidate = _normalize_article_url_candidate(
|
|
346
|
+
entry.get("href") or entry.get("url") or entry.get("link")
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
candidate = None
|
|
350
|
+
resolved = _prefer_real_article_url(resolved, candidate)
|
|
351
|
+
if resolved and not is_low_signal_source_key(
|
|
352
|
+
_normalize_domain_key(resolved)
|
|
353
|
+
):
|
|
354
|
+
return resolved
|
|
355
|
+
|
|
356
|
+
for key in ("snippet", "summary", "description", "text", "body", "plain"):
|
|
357
|
+
resolved = _extract_url_from_text(
|
|
358
|
+
metadata.get(key) if isinstance(metadata.get(key), str) else None,
|
|
359
|
+
resolved,
|
|
360
|
+
)
|
|
361
|
+
if resolved and not is_low_signal_source_key(_normalize_domain_key(resolved)):
|
|
362
|
+
return resolved
|
|
363
|
+
|
|
364
|
+
resolved = _extract_url_from_text(content, resolved)
|
|
365
|
+
return resolved
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _resolve_item_url(
|
|
369
|
+
source: str,
|
|
370
|
+
normalized_url: str | None,
|
|
371
|
+
metadata: dict[str, Any],
|
|
372
|
+
content: str | None,
|
|
373
|
+
) -> str | None:
|
|
374
|
+
if source != "gmail-marvisx":
|
|
375
|
+
return normalized_url
|
|
376
|
+
candidate = _normalize_article_url_candidate(normalized_url)
|
|
377
|
+
if candidate and not is_low_signal_source_key(_normalize_domain_key(candidate)):
|
|
378
|
+
return candidate
|
|
379
|
+
|
|
380
|
+
article_url = _extract_article_url(metadata, content)
|
|
381
|
+
if article_url and not is_low_signal_source_key(_normalize_domain_key(article_url)):
|
|
382
|
+
return article_url
|
|
383
|
+
return article_url or candidate
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _derive_domain_key(
|
|
387
|
+
source: str,
|
|
388
|
+
normalized_url: str | None,
|
|
389
|
+
metadata: dict[str, Any],
|
|
390
|
+
) -> str:
|
|
391
|
+
if source == "gmail-marvisx":
|
|
392
|
+
article_domain_key = _normalize_domain_key(normalized_url)
|
|
393
|
+
if article_domain_key and not is_low_signal_source_key(article_domain_key):
|
|
394
|
+
return article_domain_key
|
|
395
|
+
return (
|
|
396
|
+
_extract_sender_identity(metadata)
|
|
397
|
+
or _extract_sender_domain(metadata)
|
|
398
|
+
or article_domain_key
|
|
399
|
+
or _normalize_domain_key(source)
|
|
400
|
+
or source.strip().lower()
|
|
401
|
+
)
|
|
402
|
+
return (
|
|
403
|
+
_normalize_domain_key(normalized_url)
|
|
404
|
+
or _extract_sender_domain(metadata)
|
|
405
|
+
or _normalize_domain_key(source)
|
|
406
|
+
or source.strip().lower()
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _coerce_timestamp(value: str | None) -> str | None:
|
|
411
|
+
if not value or not value.strip():
|
|
412
|
+
return None
|
|
413
|
+
candidate = value.strip()
|
|
414
|
+
try:
|
|
415
|
+
dt = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
|
|
416
|
+
except ValueError:
|
|
417
|
+
try:
|
|
418
|
+
dt = parsedate_to_datetime(candidate)
|
|
419
|
+
except (TypeError, ValueError):
|
|
420
|
+
return None
|
|
421
|
+
if dt.tzinfo is None:
|
|
422
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
423
|
+
else:
|
|
424
|
+
dt = dt.astimezone(timezone.utc)
|
|
425
|
+
return dt.isoformat()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _derive_published_at(
|
|
429
|
+
explicit_published_at: str | None,
|
|
430
|
+
metadata: dict[str, Any],
|
|
431
|
+
) -> str | None:
|
|
432
|
+
published_at = _coerce_timestamp(explicit_published_at)
|
|
433
|
+
if published_at:
|
|
434
|
+
return published_at
|
|
435
|
+
for key in _PUBLISHED_METADATA_KEYS:
|
|
436
|
+
raw = metadata.get(key)
|
|
437
|
+
if not isinstance(raw, str):
|
|
438
|
+
continue
|
|
439
|
+
published_at = _coerce_timestamp(raw)
|
|
440
|
+
if published_at:
|
|
441
|
+
return published_at
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _resolve_source_path(raw_path: str | None, config: InboxSourceConfig) -> str | None:
|
|
446
|
+
if raw_path is None:
|
|
447
|
+
return None
|
|
448
|
+
if not config.allowed_roots:
|
|
449
|
+
raise ValidationError(
|
|
450
|
+
code="source_path_not_enabled",
|
|
451
|
+
message="Source paths are not enabled for this source",
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
candidate = Path(raw_path).expanduser()
|
|
455
|
+
if not candidate.is_absolute():
|
|
456
|
+
raise ValidationError(
|
|
457
|
+
code="source_path_not_absolute", message="source_path must be absolute"
|
|
458
|
+
)
|
|
459
|
+
if candidate.is_symlink():
|
|
460
|
+
raise AuthorizationError(
|
|
461
|
+
code="source_path_symlink", message="Symlink source_path is not allowed"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
resolved = candidate.resolve()
|
|
465
|
+
if not resolved.exists() or not resolved.is_file():
|
|
466
|
+
raise ValidationError(
|
|
467
|
+
code="source_path_not_a_file",
|
|
468
|
+
message="source_path must point to an existing file",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
for root_str in config.allowed_roots:
|
|
472
|
+
root = Path(root_str).expanduser().resolve()
|
|
473
|
+
if resolved == root or resolved.is_relative_to(root):
|
|
474
|
+
return str(resolved)
|
|
475
|
+
|
|
476
|
+
raise AuthorizationError(
|
|
477
|
+
code="source_path_outside_roots", message="source_path is outside allowed roots"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _build_dedup_key(
|
|
482
|
+
source: str,
|
|
483
|
+
source_item_id: str | None,
|
|
484
|
+
title: str | None,
|
|
485
|
+
content: str | None,
|
|
486
|
+
url: str | None,
|
|
487
|
+
source_path: str | None,
|
|
488
|
+
) -> str:
|
|
489
|
+
if source_item_id:
|
|
490
|
+
material = f"{source}|item|{source_item_id}"
|
|
491
|
+
else:
|
|
492
|
+
material = json.dumps(
|
|
493
|
+
{
|
|
494
|
+
"source": source,
|
|
495
|
+
"title": title,
|
|
496
|
+
"content": content,
|
|
497
|
+
"url": url,
|
|
498
|
+
"source_path": source_path,
|
|
499
|
+
},
|
|
500
|
+
sort_keys=True,
|
|
501
|
+
ensure_ascii=True,
|
|
502
|
+
)
|
|
503
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _row_to_response(row: aiosqlite.Row, *, deduplicated: bool) -> InboxIngestResponse:
|
|
507
|
+
candidate_programs = json.loads(row["candidate_programs"] or "[]")
|
|
508
|
+
return InboxIngestResponse(
|
|
509
|
+
id=row["id"],
|
|
510
|
+
source=row["source"],
|
|
511
|
+
deduplicated=deduplicated,
|
|
512
|
+
status=row["status"],
|
|
513
|
+
title=row["title"],
|
|
514
|
+
source_item_id=row["source_item_id"],
|
|
515
|
+
source_path=row["source_path"],
|
|
516
|
+
candidate_programs=candidate_programs,
|
|
517
|
+
default_program=row["default_program"],
|
|
518
|
+
topic=row["topic"],
|
|
519
|
+
treatment=row["treatment"],
|
|
520
|
+
created_at=row["created_at"],
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# Auto-status from treatment (module-level: shared by single + batch ingest).
|
|
525
|
+
_TREATMENT_TO_STATUS = {
|
|
526
|
+
"read": "unread",
|
|
527
|
+
"read_save": "unread",
|
|
528
|
+
"save": "saved",
|
|
529
|
+
"ignore": "auto_ignored",
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
_INSERT_INBOX_ITEM_SQL = (
|
|
534
|
+
"INSERT INTO inbox_items ("
|
|
535
|
+
"id, source, source_item_id, dedup_key, status, title, content, url, domain_key, published_at, freshness_at, source_path, "
|
|
536
|
+
"metadata_json, candidate_programs, default_program, topic, treatment, created_by, workspace_id, created_at, updated_at"
|
|
537
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _prepare_insert(
|
|
542
|
+
body: InboxIngestRequest, *, user: UserInfo
|
|
543
|
+
) -> tuple[str, tuple, str, str]:
|
|
544
|
+
"""Compute sanitized fields for an inbox INSERT.
|
|
545
|
+
|
|
546
|
+
Returns (item_id, insert_args, dedup_key, workspace_id). Raises
|
|
547
|
+
``ValidationError`` (422) on metadata oversize. Shared by `ingest_item` and
|
|
548
|
+
`ingest_items_batch`.
|
|
549
|
+
"""
|
|
550
|
+
config = get_source_config(body.source)
|
|
551
|
+
|
|
552
|
+
title = _sanitize_text(body.title, max_length=settings.inbox_max_title_chars)
|
|
553
|
+
content = _sanitize_text(body.content, max_length=settings.inbox_max_content_chars)
|
|
554
|
+
source_item_id = _sanitize_text(body.source_item_id, max_length=255)
|
|
555
|
+
normalized_url = _normalize_url(_sanitize_text(body.url, max_length=2000))
|
|
556
|
+
metadata = _sanitize_metadata(body.metadata)
|
|
557
|
+
|
|
558
|
+
candidate_programs, default_program = _merge_program_metadata(body, config)
|
|
559
|
+
source_path = _resolve_source_path(
|
|
560
|
+
_sanitize_text(body.source_path, max_length=2000), config
|
|
561
|
+
)
|
|
562
|
+
metadata_dict = dict(metadata) if isinstance(metadata, dict) else {}
|
|
563
|
+
if body.source == "gmail-marvisx" and _is_gmail_hosted_url(normalized_url):
|
|
564
|
+
metadata_dict.setdefault("gmail_thread_url", normalized_url)
|
|
565
|
+
canonical_url = _resolve_item_url(
|
|
566
|
+
body.source, normalized_url, metadata_dict, content
|
|
567
|
+
)
|
|
568
|
+
domain_key = _derive_domain_key(body.source, canonical_url, metadata_dict)
|
|
569
|
+
published_at = _derive_published_at(body.published_at, metadata_dict)
|
|
570
|
+
metadata_json = json.dumps(metadata_dict, sort_keys=True, ensure_ascii=True)
|
|
571
|
+
if len(metadata_json.encode("utf-8")) > settings.inbox_max_metadata_bytes:
|
|
572
|
+
raise ValidationError(
|
|
573
|
+
code="metadata_too_large", message="metadata is too large"
|
|
574
|
+
)
|
|
575
|
+
topic = (
|
|
576
|
+
normalize_inbox_topic(body.topic)
|
|
577
|
+
if body.topic
|
|
578
|
+
else infer_topic_from_metadata(metadata_dict, title=title, content=content)
|
|
579
|
+
)
|
|
580
|
+
treatment = (
|
|
581
|
+
normalize_inbox_treatment(body.treatment)
|
|
582
|
+
if body.treatment
|
|
583
|
+
else infer_treatment(topic, metadata_dict)
|
|
584
|
+
)
|
|
585
|
+
dedup_key = _build_dedup_key(
|
|
586
|
+
body.source,
|
|
587
|
+
source_item_id,
|
|
588
|
+
title,
|
|
589
|
+
content,
|
|
590
|
+
canonical_url,
|
|
591
|
+
source_path,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
595
|
+
freshness_at = published_at or now
|
|
596
|
+
item_id = f"inbox_{uuid.uuid4().hex[:24]}"
|
|
597
|
+
candidate_programs_json = json.dumps(candidate_programs, ensure_ascii=True)
|
|
598
|
+
auto_status = _TREATMENT_TO_STATUS.get(treatment, "unread")
|
|
599
|
+
workspace_id = user.workspace_id or "ws_default"
|
|
600
|
+
|
|
601
|
+
args = (
|
|
602
|
+
item_id,
|
|
603
|
+
body.source,
|
|
604
|
+
source_item_id,
|
|
605
|
+
dedup_key,
|
|
606
|
+
auto_status,
|
|
607
|
+
title,
|
|
608
|
+
content,
|
|
609
|
+
canonical_url,
|
|
610
|
+
domain_key,
|
|
611
|
+
published_at,
|
|
612
|
+
freshness_at,
|
|
613
|
+
source_path,
|
|
614
|
+
metadata_json,
|
|
615
|
+
candidate_programs_json,
|
|
616
|
+
default_program,
|
|
617
|
+
topic,
|
|
618
|
+
treatment,
|
|
619
|
+
user.username,
|
|
620
|
+
workspace_id,
|
|
621
|
+
now,
|
|
622
|
+
now,
|
|
623
|
+
)
|
|
624
|
+
return item_id, args, dedup_key, workspace_id
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
async def ingest_item(
|
|
628
|
+
body: InboxIngestRequest,
|
|
629
|
+
*,
|
|
630
|
+
user: UserInfo,
|
|
631
|
+
db: aiosqlite.Connection,
|
|
632
|
+
) -> InboxIngestResponse:
|
|
633
|
+
item_id, args, dedup_key, workspace_id = _prepare_insert(body, user=user)
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
await db.execute(_INSERT_INBOX_ITEM_SQL, args)
|
|
637
|
+
await db.commit()
|
|
638
|
+
except aiosqlite.IntegrityError:
|
|
639
|
+
cursor = await db.execute(
|
|
640
|
+
"SELECT * FROM inbox_items WHERE source = ? AND dedup_key = ? AND COALESCE(workspace_id, 'ws_default') = ?",
|
|
641
|
+
(body.source, dedup_key, workspace_id),
|
|
642
|
+
)
|
|
643
|
+
existing = await cursor.fetchone()
|
|
644
|
+
if existing is None:
|
|
645
|
+
raise ConflictError(
|
|
646
|
+
code="duplicate_inbox_item", message="Duplicate inbox item"
|
|
647
|
+
)
|
|
648
|
+
return _row_to_response(existing, deduplicated=True)
|
|
649
|
+
|
|
650
|
+
cursor = await db.execute("SELECT * FROM inbox_items WHERE id = ?", (item_id,))
|
|
651
|
+
row = await cursor.fetchone()
|
|
652
|
+
if row is None:
|
|
653
|
+
err = ServiceError(
|
|
654
|
+
code="inbox_item_lost",
|
|
655
|
+
message="Inbox item created but not found",
|
|
656
|
+
)
|
|
657
|
+
err.http_status = 500 # preserve the original 500 status (was a 500 raise)
|
|
658
|
+
raise err
|
|
659
|
+
|
|
660
|
+
# PR B (2026-04-11): schedule async classification as a write-ahead task.
|
|
661
|
+
# The item is already persisted with status='unread'. The background task
|
|
662
|
+
# will update status/metadata when the LLM responds (or fall back). This
|
|
663
|
+
# call MUST never raise to the caller — ingest stays non-blocking.
|
|
664
|
+
try:
|
|
665
|
+
from core.api.services.inbox_llm_classifier import schedule_classification
|
|
666
|
+
|
|
667
|
+
schedule_classification(
|
|
668
|
+
settings.db_path,
|
|
669
|
+
workspace_id,
|
|
670
|
+
item_id,
|
|
671
|
+
)
|
|
672
|
+
except Exception: # noqa: BLE001
|
|
673
|
+
logger.exception("Failed to schedule classifier for item %s", item_id)
|
|
674
|
+
|
|
675
|
+
return _row_to_response(row, deduplicated=False)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
async def ingest_items_batch(
|
|
679
|
+
body: InboxIngestBatchRequest,
|
|
680
|
+
*,
|
|
681
|
+
user: UserInfo,
|
|
682
|
+
db: aiosqlite.Connection,
|
|
683
|
+
) -> InboxIngestBatchResponse:
|
|
684
|
+
"""Batch-ingest N items in a single write lock + single commit.
|
|
685
|
+
|
|
686
|
+
Rationale: n8n RSS fan-out previously fired N separate POST /ingest requests
|
|
687
|
+
per poll, each acquiring the process-wide `_write_lock` + doing an fsync.
|
|
688
|
+
With N ~800 this saturated disk IO and stalled readers via busy_timeout
|
|
689
|
+
(see session 2026-04-24 handoff). This endpoint folds that burst into one
|
|
690
|
+
transaction: ONE lock acquisition, ONE fsync, per-item SAVEPOINT so a
|
|
691
|
+
single constraint violation doesn't poison the rest of the batch.
|
|
692
|
+
|
|
693
|
+
Per-item errors (validation, dedup collision) are collected into the
|
|
694
|
+
response rather than aborting the whole batch — the caller keeps forward
|
|
695
|
+
progress on good items.
|
|
696
|
+
"""
|
|
697
|
+
inserted: list[InboxIngestResponse] = []
|
|
698
|
+
deduplicated: list[InboxIngestResponse] = []
|
|
699
|
+
errors: list[InboxIngestBatchItemError] = []
|
|
700
|
+
fresh_item_ids: list[tuple[str, str]] = [] # (item_id, workspace_id)
|
|
701
|
+
|
|
702
|
+
for idx, item in enumerate(body.items):
|
|
703
|
+
try:
|
|
704
|
+
item_id, args, dedup_key, workspace_id = _prepare_insert(item, user=user)
|
|
705
|
+
except ServiceError as exc:
|
|
706
|
+
errors.append(
|
|
707
|
+
InboxIngestBatchItemError(
|
|
708
|
+
index=idx,
|
|
709
|
+
source=item.source,
|
|
710
|
+
source_item_id=item.source_item_id,
|
|
711
|
+
error=str(exc.message),
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
continue
|
|
715
|
+
except Exception as exc: # noqa: BLE001
|
|
716
|
+
logger.exception("Failed to prepare inbox batch item %d", idx)
|
|
717
|
+
errors.append(
|
|
718
|
+
InboxIngestBatchItemError(
|
|
719
|
+
index=idx,
|
|
720
|
+
source=item.source,
|
|
721
|
+
source_item_id=item.source_item_id,
|
|
722
|
+
error=f"prepare_error: {exc}",
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
continue
|
|
726
|
+
|
|
727
|
+
savepoint = f"sp_ingest_{idx}"
|
|
728
|
+
await db.execute(f"SAVEPOINT {savepoint}")
|
|
729
|
+
try:
|
|
730
|
+
await db.execute(_INSERT_INBOX_ITEM_SQL, args)
|
|
731
|
+
except aiosqlite.IntegrityError:
|
|
732
|
+
await db.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
|
|
733
|
+
await db.execute(f"RELEASE SAVEPOINT {savepoint}")
|
|
734
|
+
cursor = await db.execute(
|
|
735
|
+
"SELECT * FROM inbox_items WHERE source = ? AND dedup_key = ? AND COALESCE(workspace_id, 'ws_default') = ?",
|
|
736
|
+
(item.source, dedup_key, workspace_id),
|
|
737
|
+
)
|
|
738
|
+
existing = await cursor.fetchone()
|
|
739
|
+
if existing is None:
|
|
740
|
+
errors.append(
|
|
741
|
+
InboxIngestBatchItemError(
|
|
742
|
+
index=idx,
|
|
743
|
+
source=item.source,
|
|
744
|
+
source_item_id=item.source_item_id,
|
|
745
|
+
error="integrity_error_no_match",
|
|
746
|
+
)
|
|
747
|
+
)
|
|
748
|
+
continue
|
|
749
|
+
deduplicated.append(_row_to_response(existing, deduplicated=True))
|
|
750
|
+
continue
|
|
751
|
+
except Exception as exc: # noqa: BLE001
|
|
752
|
+
await db.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
|
|
753
|
+
await db.execute(f"RELEASE SAVEPOINT {savepoint}")
|
|
754
|
+
logger.exception("Failed to INSERT inbox batch item %d", idx)
|
|
755
|
+
errors.append(
|
|
756
|
+
InboxIngestBatchItemError(
|
|
757
|
+
index=idx,
|
|
758
|
+
source=item.source,
|
|
759
|
+
source_item_id=item.source_item_id,
|
|
760
|
+
error=f"insert_error: {exc}",
|
|
761
|
+
)
|
|
762
|
+
)
|
|
763
|
+
continue
|
|
764
|
+
|
|
765
|
+
await db.execute(f"RELEASE SAVEPOINT {savepoint}")
|
|
766
|
+
fresh_item_ids.append((item_id, workspace_id))
|
|
767
|
+
|
|
768
|
+
# Single commit for all successful inserts in the batch.
|
|
769
|
+
await db.commit()
|
|
770
|
+
|
|
771
|
+
# Re-read rows + schedule classification outside the insert loop to keep
|
|
772
|
+
# the write lock held for the shortest possible duration.
|
|
773
|
+
for item_id, workspace_id in fresh_item_ids:
|
|
774
|
+
cursor = await db.execute(
|
|
775
|
+
"SELECT * FROM inbox_items WHERE id = ?", (item_id,)
|
|
776
|
+
)
|
|
777
|
+
row = await cursor.fetchone()
|
|
778
|
+
if row is None:
|
|
779
|
+
logger.warning("Inbox batch item %s inserted but not found post-commit", item_id)
|
|
780
|
+
continue
|
|
781
|
+
inserted.append(_row_to_response(row, deduplicated=False))
|
|
782
|
+
|
|
783
|
+
try:
|
|
784
|
+
from core.api.services.inbox_llm_classifier import schedule_classification
|
|
785
|
+
|
|
786
|
+
schedule_classification(settings.db_path, workspace_id, item_id)
|
|
787
|
+
except Exception: # noqa: BLE001
|
|
788
|
+
logger.exception("Failed to schedule classifier for item %s", item_id)
|
|
789
|
+
|
|
790
|
+
return InboxIngestBatchResponse(
|
|
791
|
+
inserted=inserted,
|
|
792
|
+
deduplicated=deduplicated,
|
|
793
|
+
errors=errors,
|
|
794
|
+
counts={
|
|
795
|
+
"received": len(body.items),
|
|
796
|
+
"inserted": len(inserted),
|
|
797
|
+
"deduplicated": len(deduplicated),
|
|
798
|
+
"errors": len(errors),
|
|
799
|
+
},
|
|
800
|
+
)
|