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,899 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import aiosqlite
|
|
12
|
+
|
|
13
|
+
from core.api.models import (
|
|
14
|
+
InboxItemDetail,
|
|
15
|
+
InboxItemSummary,
|
|
16
|
+
InboxStatsResponse,
|
|
17
|
+
InboxStatusUpdateRequest,
|
|
18
|
+
InboxTaxonomyUpdateRequest,
|
|
19
|
+
InboxTaxonomyUpdateResponse,
|
|
20
|
+
InboxTriageDecisionRequest,
|
|
21
|
+
InboxTriageDecisionResponse,
|
|
22
|
+
UserInfo,
|
|
23
|
+
)
|
|
24
|
+
from core.api.use_cases._errors import (
|
|
25
|
+
ConflictError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
ServiceUnavailableError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Differentiated scoring weights per status (PR A of inbox redesign, 2026-04-11).
|
|
33
|
+
# Immutable via MappingProxyType to prevent accidental runtime mutation.
|
|
34
|
+
# - newsletter +3: "gold public" signal (Emilio sends it to subscribers)
|
|
35
|
+
# - preferred +2: "gold private" signal (Emilio wants to see more like this)
|
|
36
|
+
# - saved/idea +1: "useful" signal
|
|
37
|
+
# - read +0.1: "attention" signal (weak but non-zero: reading is information)
|
|
38
|
+
# - auto_ignored 0: LLM auto-decision, zero weight (no write)
|
|
39
|
+
# - ignored -1: "noise" signal
|
|
40
|
+
SCORE_WEIGHTS: Mapping[str, float] = MappingProxyType(
|
|
41
|
+
{
|
|
42
|
+
"newsletter": 3.0,
|
|
43
|
+
"preferred": 2.0,
|
|
44
|
+
"saved": 1.0,
|
|
45
|
+
"idea": 1.0,
|
|
46
|
+
"read": 0.1,
|
|
47
|
+
"auto_ignored": 0.0,
|
|
48
|
+
"ignored": -1.0,
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _pick(mapping: dict[str, Any], *names: str) -> Any:
|
|
54
|
+
for name in names:
|
|
55
|
+
if name in mapping and mapping[name] is not None:
|
|
56
|
+
return mapping[name]
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _get_table_columns(db: aiosqlite.Connection, table_name: str) -> set[str]:
|
|
61
|
+
rows = await (await db.execute(f"PRAGMA table_info({table_name})")).fetchall()
|
|
62
|
+
return {
|
|
63
|
+
row[1] if not isinstance(row, aiosqlite.Row) else row["name"] for row in rows
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def ensure_inbox_core_available(db: aiosqlite.Connection) -> set[str]:
|
|
68
|
+
columns = await _get_table_columns(db, "inbox_items")
|
|
69
|
+
if not columns:
|
|
70
|
+
raise ServiceUnavailableError(
|
|
71
|
+
code="inbox_core_unavailable",
|
|
72
|
+
message=(
|
|
73
|
+
"Inbox core table 'inbox_items' not found. "
|
|
74
|
+
"This contract assumes the parallel inbox core branch provides it."
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
return columns
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_payload(raw_value: Any) -> Any:
|
|
81
|
+
if raw_value is None:
|
|
82
|
+
return None
|
|
83
|
+
if isinstance(raw_value, (dict, list)):
|
|
84
|
+
return raw_value
|
|
85
|
+
if isinstance(raw_value, str):
|
|
86
|
+
text = raw_value.strip()
|
|
87
|
+
if not text:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return json.loads(text)
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
return raw_value
|
|
93
|
+
return raw_value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _decision_from_row(row: aiosqlite.Row | None) -> InboxTriageDecisionResponse | None:
|
|
97
|
+
if row is None or row["decision"] is None:
|
|
98
|
+
return None
|
|
99
|
+
tags_raw = row["triage_tags_json"] or "[]"
|
|
100
|
+
try:
|
|
101
|
+
tags = json.loads(tags_raw)
|
|
102
|
+
except (json.JSONDecodeError, TypeError):
|
|
103
|
+
tags = []
|
|
104
|
+
# Use triage_decided_by alias to avoid clash with inbox_items.decided_by
|
|
105
|
+
triage_decided_by = None
|
|
106
|
+
mapping = dict(row)
|
|
107
|
+
if "triage_decided_by" in mapping:
|
|
108
|
+
triage_decided_by = mapping["triage_decided_by"]
|
|
109
|
+
elif "decided_by" in mapping:
|
|
110
|
+
triage_decided_by = mapping["decided_by"]
|
|
111
|
+
return InboxTriageDecisionResponse(
|
|
112
|
+
inbox_item_id=row["id"],
|
|
113
|
+
decision=row["decision"],
|
|
114
|
+
confidence=row["confidence"],
|
|
115
|
+
reason=row["reason"],
|
|
116
|
+
target_program=row["target_program"],
|
|
117
|
+
target_project=row["target_project"],
|
|
118
|
+
task_kind=row["task_kind"],
|
|
119
|
+
task_title=row["task_title"],
|
|
120
|
+
task_description=row["task_description"],
|
|
121
|
+
linked_task_id=row["linked_task_id"],
|
|
122
|
+
tags=tags,
|
|
123
|
+
decided_by=triage_decided_by or "",
|
|
124
|
+
created_at=row["decision_created_at"],
|
|
125
|
+
updated_at=row["decision_updated_at"],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _coerce_sender_str(sender: Any) -> str | None:
|
|
130
|
+
"""Normalize a sender value to a string.
|
|
131
|
+
|
|
132
|
+
Email ingestion can store the `From` header as a structured object
|
|
133
|
+
(e.g. {"name": "a16z", "address": "...", "html": "..."}). InboxItemDetail.sender
|
|
134
|
+
is typed `str | None`, so a dict reaches Pydantic and raises a 500 on item
|
|
135
|
+
fetch. Prefer the display name, fall back to address/email, else None.
|
|
136
|
+
"""
|
|
137
|
+
if sender is None or isinstance(sender, str):
|
|
138
|
+
return sender
|
|
139
|
+
if isinstance(sender, dict):
|
|
140
|
+
return (
|
|
141
|
+
sender.get("name")
|
|
142
|
+
or sender.get("address")
|
|
143
|
+
or sender.get("email")
|
|
144
|
+
or None
|
|
145
|
+
)
|
|
146
|
+
if isinstance(sender, (list, tuple)) and sender:
|
|
147
|
+
return _coerce_sender_str(sender[0])
|
|
148
|
+
return str(sender)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _normalize_item(
|
|
152
|
+
row: aiosqlite.Row, include_raw: bool = False
|
|
153
|
+
) -> InboxItemSummary | InboxItemDetail:
|
|
154
|
+
mapping = dict(row)
|
|
155
|
+
payload = _normalize_payload(
|
|
156
|
+
_pick(
|
|
157
|
+
mapping,
|
|
158
|
+
"payload_json",
|
|
159
|
+
"metadata_json",
|
|
160
|
+
"raw_payload",
|
|
161
|
+
"payload",
|
|
162
|
+
"raw_content",
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
program = _pick(
|
|
166
|
+
mapping, "program", "program_slug", "program_hint", "default_program"
|
|
167
|
+
)
|
|
168
|
+
if program is None:
|
|
169
|
+
candidate_programs_raw = _pick(mapping, "candidate_programs")
|
|
170
|
+
try:
|
|
171
|
+
candidate_programs = json.loads(candidate_programs_raw or "[]")
|
|
172
|
+
except (TypeError, json.JSONDecodeError):
|
|
173
|
+
candidate_programs = []
|
|
174
|
+
if candidate_programs:
|
|
175
|
+
program = candidate_programs[0]
|
|
176
|
+
content = _pick(mapping, "content_text", "body_text", "plain_text", "content")
|
|
177
|
+
triage = _decision_from_row(row)
|
|
178
|
+
source_label = None
|
|
179
|
+
if isinstance(payload, dict):
|
|
180
|
+
source_label = payload.get("feedName") or payload.get("sourceLabel")
|
|
181
|
+
if source_label is None:
|
|
182
|
+
source_label = _pick(
|
|
183
|
+
mapping, "source_label", "feed_title", "mailbox", "source_name", "source"
|
|
184
|
+
)
|
|
185
|
+
sender = _pick(mapping, "sender", "from_email", "author", "source_author")
|
|
186
|
+
if sender is None and isinstance(payload, dict):
|
|
187
|
+
sender = payload.get("sender") or payload.get("author")
|
|
188
|
+
sender = _coerce_sender_str(sender)
|
|
189
|
+
item_status = _pick(mapping, "status") or "unread"
|
|
190
|
+
ignore_reason = _pick(mapping, "ignore_reason")
|
|
191
|
+
base = dict(
|
|
192
|
+
id=str(mapping["id"]),
|
|
193
|
+
source_type=_pick(mapping, "source_type", "source_kind", "channel", "source"),
|
|
194
|
+
source_label=source_label,
|
|
195
|
+
external_id=_pick(
|
|
196
|
+
mapping, "external_id", "message_id", "source_item_id", "item_key"
|
|
197
|
+
),
|
|
198
|
+
title=_pick(mapping, "title", "subject", "name"),
|
|
199
|
+
snippet=_pick(mapping, "summary", "snippet", "body_preview", "excerpt")
|
|
200
|
+
or (content[:160] if isinstance(content, str) else None),
|
|
201
|
+
sender=sender,
|
|
202
|
+
url=_pick(mapping, "url", "source_url", "link"),
|
|
203
|
+
program=program,
|
|
204
|
+
project=_pick(mapping, "project", "project_slug", "project_hint"),
|
|
205
|
+
topic=_pick(mapping, "topic") or "general",
|
|
206
|
+
treatment=_pick(mapping, "treatment") or "read",
|
|
207
|
+
status=item_status,
|
|
208
|
+
ignore_reason=ignore_reason,
|
|
209
|
+
received_at=_pick(
|
|
210
|
+
mapping, "received_at", "created_at", "ingested_at", "updated_at"
|
|
211
|
+
),
|
|
212
|
+
needs_triage=item_status == "unread",
|
|
213
|
+
triage=triage,
|
|
214
|
+
)
|
|
215
|
+
if include_raw:
|
|
216
|
+
return InboxItemDetail(
|
|
217
|
+
**base,
|
|
218
|
+
content=content,
|
|
219
|
+
raw_payload=payload,
|
|
220
|
+
tldr=_pick(mapping, "tldr"),
|
|
221
|
+
deep_research=_pick(mapping, "deep_research"),
|
|
222
|
+
)
|
|
223
|
+
return InboxItemSummary(**base)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def list_inbox_items(
|
|
227
|
+
db: aiosqlite.Connection,
|
|
228
|
+
user: UserInfo,
|
|
229
|
+
*,
|
|
230
|
+
limit: int,
|
|
231
|
+
needs_triage: bool,
|
|
232
|
+
classified: bool | None = None,
|
|
233
|
+
source: str | None = None,
|
|
234
|
+
program: str | None = None,
|
|
235
|
+
topic: str | None = None,
|
|
236
|
+
treatment: str | None = None,
|
|
237
|
+
status: str | None = None,
|
|
238
|
+
) -> list[InboxItemSummary]:
|
|
239
|
+
columns = await ensure_inbox_core_available(db)
|
|
240
|
+
order_col = next(
|
|
241
|
+
(
|
|
242
|
+
name
|
|
243
|
+
for name in ("received_at", "created_at", "ingested_at", "updated_at", "id")
|
|
244
|
+
if name in columns
|
|
245
|
+
),
|
|
246
|
+
"id",
|
|
247
|
+
)
|
|
248
|
+
workspace_id = user.workspace_id or "ws_default"
|
|
249
|
+
params: list[Any] = [workspace_id]
|
|
250
|
+
where: list[str] = []
|
|
251
|
+
if "workspace_id" in columns:
|
|
252
|
+
where.append("COALESCE(i.workspace_id, 'ws_default') = ?")
|
|
253
|
+
else:
|
|
254
|
+
params = []
|
|
255
|
+
if needs_triage and not status:
|
|
256
|
+
where.append("i.status = 'unread'")
|
|
257
|
+
if status:
|
|
258
|
+
where.append("i.status = ?")
|
|
259
|
+
params.append(status)
|
|
260
|
+
if source:
|
|
261
|
+
where.append("i.source = ?")
|
|
262
|
+
params.append(source)
|
|
263
|
+
if program:
|
|
264
|
+
where.append("i.default_program = ?")
|
|
265
|
+
params.append(program)
|
|
266
|
+
if topic:
|
|
267
|
+
where.append("i.topic = ?")
|
|
268
|
+
params.append(topic)
|
|
269
|
+
if treatment:
|
|
270
|
+
where.append("i.treatment = ?")
|
|
271
|
+
params.append(treatment)
|
|
272
|
+
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
|
273
|
+
query = (
|
|
274
|
+
"SELECT i.*, "
|
|
275
|
+
"d.decision, d.confidence, d.reason, d.target_program, d.target_project, d.task_kind, "
|
|
276
|
+
"d.task_title, d.task_description, d.linked_task_id, d.tags_json AS triage_tags_json, "
|
|
277
|
+
"d.decided_by AS triage_decided_by, d.created_at AS decision_created_at, d.updated_at AS decision_updated_at "
|
|
278
|
+
"FROM inbox_items i "
|
|
279
|
+
"LEFT JOIN inbox_triage_decisions d "
|
|
280
|
+
"ON d.inbox_item_id = i.id AND COALESCE(d.workspace_id, 'ws_default') = ? "
|
|
281
|
+
f"{where_sql} ORDER BY i.{order_col} DESC LIMIT ?"
|
|
282
|
+
)
|
|
283
|
+
params = (
|
|
284
|
+
[workspace_id, *params, limit]
|
|
285
|
+
if "workspace_id" in columns
|
|
286
|
+
else [workspace_id, limit]
|
|
287
|
+
)
|
|
288
|
+
rows = await (await db.execute(query, params)).fetchall()
|
|
289
|
+
items: list[InboxItemSummary] = []
|
|
290
|
+
for row in rows:
|
|
291
|
+
payload = _normalize_payload(row["metadata_json"]) or {}
|
|
292
|
+
is_classified = isinstance(payload, dict) and bool(payload.get("classifiedAt"))
|
|
293
|
+
if classified is not None and is_classified != classified:
|
|
294
|
+
continue
|
|
295
|
+
items.append(_normalize_item(row))
|
|
296
|
+
return items
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def update_inbox_taxonomy(
|
|
300
|
+
db: aiosqlite.Connection,
|
|
301
|
+
user: UserInfo,
|
|
302
|
+
inbox_item_id: str,
|
|
303
|
+
body: InboxTaxonomyUpdateRequest,
|
|
304
|
+
) -> InboxTaxonomyUpdateResponse:
|
|
305
|
+
columns = await ensure_inbox_core_available(db)
|
|
306
|
+
workspace_id = user.workspace_id or "ws_default"
|
|
307
|
+
where = ["id = ?"]
|
|
308
|
+
params: list[Any] = [inbox_item_id]
|
|
309
|
+
if "workspace_id" in columns:
|
|
310
|
+
where.append("COALESCE(workspace_id, 'ws_default') = ?")
|
|
311
|
+
params.append(workspace_id)
|
|
312
|
+
row = await (
|
|
313
|
+
await db.execute(
|
|
314
|
+
f"SELECT id, metadata_json FROM inbox_items WHERE {' AND '.join(where)}",
|
|
315
|
+
params,
|
|
316
|
+
)
|
|
317
|
+
).fetchone()
|
|
318
|
+
if row is None:
|
|
319
|
+
raise NotFoundError(code="inbox_item_not_found", message="Inbox item not found")
|
|
320
|
+
|
|
321
|
+
metadata = _normalize_payload(row["metadata_json"]) or {}
|
|
322
|
+
if not isinstance(metadata, dict):
|
|
323
|
+
metadata = {}
|
|
324
|
+
if body.confidence is not None:
|
|
325
|
+
metadata["confidence"] = body.confidence
|
|
326
|
+
if body.note:
|
|
327
|
+
metadata["note"] = body.note
|
|
328
|
+
metadata["classifiedAt"] = datetime.now(timezone.utc).isoformat()
|
|
329
|
+
|
|
330
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
331
|
+
await db.execute(
|
|
332
|
+
"UPDATE inbox_items SET topic = ?, treatment = ?, metadata_json = ?, updated_at = ? WHERE id = ?"
|
|
333
|
+
+ (
|
|
334
|
+
" AND COALESCE(workspace_id, 'ws_default') = ?"
|
|
335
|
+
if "workspace_id" in columns
|
|
336
|
+
else ""
|
|
337
|
+
),
|
|
338
|
+
[
|
|
339
|
+
body.topic,
|
|
340
|
+
body.treatment,
|
|
341
|
+
json.dumps(metadata, ensure_ascii=False, sort_keys=True),
|
|
342
|
+
now,
|
|
343
|
+
inbox_item_id,
|
|
344
|
+
*([workspace_id] if "workspace_id" in columns else []),
|
|
345
|
+
],
|
|
346
|
+
)
|
|
347
|
+
await db.commit()
|
|
348
|
+
return InboxTaxonomyUpdateResponse(
|
|
349
|
+
inbox_item_id=inbox_item_id,
|
|
350
|
+
topic=body.topic,
|
|
351
|
+
treatment=body.treatment,
|
|
352
|
+
updated_at=now,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
async def get_inbox_stats(
|
|
357
|
+
db: aiosqlite.Connection,
|
|
358
|
+
user: UserInfo,
|
|
359
|
+
*,
|
|
360
|
+
needs_triage: bool,
|
|
361
|
+
classified: bool | None = None,
|
|
362
|
+
source: str | None = None,
|
|
363
|
+
program: str | None = None,
|
|
364
|
+
topic: str | None = None,
|
|
365
|
+
treatment: str | None = None,
|
|
366
|
+
status: str | None = None,
|
|
367
|
+
) -> InboxStatsResponse:
|
|
368
|
+
columns = await ensure_inbox_core_available(db)
|
|
369
|
+
workspace_id = user.workspace_id or "ws_default"
|
|
370
|
+
where: list[str] = []
|
|
371
|
+
params: list[Any] = [workspace_id]
|
|
372
|
+
if "workspace_id" in columns:
|
|
373
|
+
where.append("COALESCE(i.workspace_id, 'ws_default') = ?")
|
|
374
|
+
else:
|
|
375
|
+
params = []
|
|
376
|
+
if needs_triage and not status:
|
|
377
|
+
where.append("i.status = 'unread'")
|
|
378
|
+
if status:
|
|
379
|
+
where.append("i.status = ?")
|
|
380
|
+
params.append(status)
|
|
381
|
+
if source:
|
|
382
|
+
where.append("i.source = ?")
|
|
383
|
+
params.append(source)
|
|
384
|
+
if program:
|
|
385
|
+
where.append("i.default_program = ?")
|
|
386
|
+
params.append(program)
|
|
387
|
+
if topic:
|
|
388
|
+
where.append("i.topic = ?")
|
|
389
|
+
params.append(topic)
|
|
390
|
+
if treatment:
|
|
391
|
+
where.append("i.treatment = ?")
|
|
392
|
+
params.append(treatment)
|
|
393
|
+
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
|
394
|
+
query = (
|
|
395
|
+
"SELECT "
|
|
396
|
+
"COUNT(*) AS total, "
|
|
397
|
+
"SUM(CASE WHEN d.task_kind = 'idea' THEN 1 ELSE 0 END) AS ideas, "
|
|
398
|
+
"SUM(CASE WHEN d.task_kind = 'normal' THEN 1 ELSE 0 END) AS tasks, "
|
|
399
|
+
"SUM(CASE WHEN d.decision = 'needs_human_review' THEN 1 ELSE 0 END) AS review, "
|
|
400
|
+
"SUM(CASE WHEN i.status = 'unread' THEN 1 ELSE 0 END) AS unread, "
|
|
401
|
+
"SUM(CASE WHEN i.status = 'read' THEN 1 ELSE 0 END) AS status_read, "
|
|
402
|
+
"SUM(CASE WHEN i.status = 'saved' THEN 1 ELSE 0 END) AS status_saved, "
|
|
403
|
+
"SUM(CASE WHEN i.status IN ('ignored', 'auto_ignored') THEN 1 ELSE 0 END) AS status_ignored "
|
|
404
|
+
"FROM inbox_items i "
|
|
405
|
+
"LEFT JOIN inbox_triage_decisions d "
|
|
406
|
+
"ON d.inbox_item_id = i.id AND COALESCE(d.workspace_id, 'ws_default') = ? "
|
|
407
|
+
f"{where_sql}"
|
|
408
|
+
)
|
|
409
|
+
final_params = (
|
|
410
|
+
[workspace_id, *params] if "workspace_id" in columns else [workspace_id]
|
|
411
|
+
)
|
|
412
|
+
row = await (await db.execute(query, final_params)).fetchone()
|
|
413
|
+
stats = InboxStatsResponse(
|
|
414
|
+
total=int(row["total"] or 0),
|
|
415
|
+
ideas=int(row["ideas"] or 0),
|
|
416
|
+
tasks=int(row["tasks"] or 0),
|
|
417
|
+
review=int(row["review"] or 0),
|
|
418
|
+
unread=int(row["unread"] or 0),
|
|
419
|
+
read=int(row["status_read"] or 0),
|
|
420
|
+
saved=int(row["status_saved"] or 0),
|
|
421
|
+
ignored=int(row["status_ignored"] or 0),
|
|
422
|
+
)
|
|
423
|
+
if classified is None:
|
|
424
|
+
return stats
|
|
425
|
+
|
|
426
|
+
classified_items = await list_inbox_items(
|
|
427
|
+
db,
|
|
428
|
+
user,
|
|
429
|
+
limit=500,
|
|
430
|
+
needs_triage=needs_triage,
|
|
431
|
+
classified=classified,
|
|
432
|
+
source=source,
|
|
433
|
+
program=program,
|
|
434
|
+
topic=topic,
|
|
435
|
+
treatment=treatment,
|
|
436
|
+
status=status,
|
|
437
|
+
)
|
|
438
|
+
return InboxStatsResponse(
|
|
439
|
+
total=len(classified_items),
|
|
440
|
+
ideas=stats.ideas if not needs_triage else 0,
|
|
441
|
+
tasks=stats.tasks if not needs_triage else 0,
|
|
442
|
+
review=stats.review if not needs_triage else 0,
|
|
443
|
+
unread=stats.unread,
|
|
444
|
+
read=stats.read,
|
|
445
|
+
saved=stats.saved,
|
|
446
|
+
ignored=stats.ignored,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def get_inbox_item_detail(
|
|
451
|
+
db: aiosqlite.Connection, user: UserInfo, inbox_item_id: str
|
|
452
|
+
) -> InboxItemDetail:
|
|
453
|
+
columns = await ensure_inbox_core_available(db)
|
|
454
|
+
workspace_id = user.workspace_id or "ws_default"
|
|
455
|
+
where = ["i.id = ?"]
|
|
456
|
+
params: list[Any] = [workspace_id, inbox_item_id]
|
|
457
|
+
if "workspace_id" in columns:
|
|
458
|
+
where.append("COALESCE(i.workspace_id, 'ws_default') = ?")
|
|
459
|
+
params.append(workspace_id)
|
|
460
|
+
query = (
|
|
461
|
+
"SELECT i.*, "
|
|
462
|
+
"d.decision, d.confidence, d.reason, d.target_program, d.target_project, d.task_kind, "
|
|
463
|
+
"d.task_title, d.task_description, d.linked_task_id, d.tags_json AS triage_tags_json, "
|
|
464
|
+
"d.decided_by AS triage_decided_by, d.created_at AS decision_created_at, d.updated_at AS decision_updated_at "
|
|
465
|
+
"FROM inbox_items i "
|
|
466
|
+
"LEFT JOIN inbox_triage_decisions d "
|
|
467
|
+
"ON d.inbox_item_id = i.id AND COALESCE(d.workspace_id, 'ws_default') = ? "
|
|
468
|
+
f"WHERE {' AND '.join(where)}"
|
|
469
|
+
)
|
|
470
|
+
row = await (await db.execute(query, params)).fetchone()
|
|
471
|
+
if row is None:
|
|
472
|
+
raise NotFoundError(code="inbox_item_not_found", message="Inbox item not found")
|
|
473
|
+
return _normalize_item(row, include_raw=True)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
async def _tasks_has_kind_column(db: aiosqlite.Connection) -> bool:
|
|
477
|
+
return "kind" in await _get_table_columns(db, "tasks")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
async def _task_exists(
|
|
481
|
+
db: aiosqlite.Connection, task_id: str, workspace_id: str
|
|
482
|
+
) -> bool:
|
|
483
|
+
row = await (
|
|
484
|
+
await db.execute(
|
|
485
|
+
"SELECT id FROM tasks WHERE id = ? AND deleted_at IS NULL AND COALESCE(workspace_id, 'ws_default') = ?",
|
|
486
|
+
(task_id, workspace_id),
|
|
487
|
+
)
|
|
488
|
+
).fetchone()
|
|
489
|
+
return row is not None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
async def _create_triage_task(
|
|
493
|
+
db: aiosqlite.Connection,
|
|
494
|
+
user: UserInfo,
|
|
495
|
+
inbox_item_id: str,
|
|
496
|
+
body: InboxTriageDecisionRequest,
|
|
497
|
+
) -> str:
|
|
498
|
+
task_id = str(uuid.uuid4())
|
|
499
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
500
|
+
tags = ["inboxx"]
|
|
501
|
+
tags.extend(tag for tag in body.tags if tag not in tags)
|
|
502
|
+
tags_json = json.dumps(tags)
|
|
503
|
+
has_kind = await _tasks_has_kind_column(db)
|
|
504
|
+
task_kind = body.task_kind or (
|
|
505
|
+
"idea" if body.decision == "create_idea" else "normal"
|
|
506
|
+
)
|
|
507
|
+
columns = [
|
|
508
|
+
"id",
|
|
509
|
+
"title",
|
|
510
|
+
"description",
|
|
511
|
+
"status",
|
|
512
|
+
"project",
|
|
513
|
+
"priority",
|
|
514
|
+
"created_by",
|
|
515
|
+
"owner_id",
|
|
516
|
+
"source",
|
|
517
|
+
"source_ref",
|
|
518
|
+
"tags",
|
|
519
|
+
"workspace_id",
|
|
520
|
+
"created_at",
|
|
521
|
+
"updated_at",
|
|
522
|
+
]
|
|
523
|
+
values = [
|
|
524
|
+
task_id,
|
|
525
|
+
body.task_title,
|
|
526
|
+
body.task_description or body.reason,
|
|
527
|
+
"pending",
|
|
528
|
+
body.target_project,
|
|
529
|
+
"medium",
|
|
530
|
+
user.username,
|
|
531
|
+
None,
|
|
532
|
+
"console",
|
|
533
|
+
f"inbox:{inbox_item_id}",
|
|
534
|
+
tags_json,
|
|
535
|
+
user.workspace_id or "ws_default",
|
|
536
|
+
now,
|
|
537
|
+
now,
|
|
538
|
+
]
|
|
539
|
+
if has_kind:
|
|
540
|
+
columns.insert(5, "kind")
|
|
541
|
+
values.insert(5, task_kind)
|
|
542
|
+
placeholders = ", ".join("?" for _ in columns)
|
|
543
|
+
await db.execute(
|
|
544
|
+
f"INSERT INTO tasks ({', '.join(columns)}) VALUES ({placeholders})",
|
|
545
|
+
values,
|
|
546
|
+
)
|
|
547
|
+
return task_id
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
async def apply_triage_decision(
|
|
551
|
+
db: aiosqlite.Connection,
|
|
552
|
+
user: UserInfo,
|
|
553
|
+
inbox_item_id: str,
|
|
554
|
+
body: InboxTriageDecisionRequest,
|
|
555
|
+
) -> InboxTriageDecisionResponse:
|
|
556
|
+
columns = await ensure_inbox_core_available(db)
|
|
557
|
+
workspace_id = user.workspace_id or "ws_default"
|
|
558
|
+
item_query = "SELECT id FROM inbox_items WHERE id = ?"
|
|
559
|
+
item_params: list[Any] = [inbox_item_id]
|
|
560
|
+
if "workspace_id" in columns:
|
|
561
|
+
item_query += " AND COALESCE(workspace_id, 'ws_default') = ?"
|
|
562
|
+
item_params.append(workspace_id)
|
|
563
|
+
row = await (await db.execute(item_query, item_params)).fetchone()
|
|
564
|
+
if row is None:
|
|
565
|
+
raise NotFoundError(code="inbox_item_not_found", message="Inbox item not found")
|
|
566
|
+
|
|
567
|
+
existing = await (
|
|
568
|
+
await db.execute(
|
|
569
|
+
"SELECT linked_task_id, decision FROM inbox_triage_decisions WHERE inbox_item_id = ? AND COALESCE(workspace_id, 'ws_default') = ?",
|
|
570
|
+
(inbox_item_id, workspace_id),
|
|
571
|
+
)
|
|
572
|
+
).fetchone()
|
|
573
|
+
|
|
574
|
+
linked_task_id = body.linked_task_id
|
|
575
|
+
if body.decision in {"create_idea", "create_task"}:
|
|
576
|
+
if (
|
|
577
|
+
existing
|
|
578
|
+
and existing["linked_task_id"]
|
|
579
|
+
and existing["linked_task_id"] != linked_task_id
|
|
580
|
+
):
|
|
581
|
+
raise ConflictError(
|
|
582
|
+
code="inbox_item_already_linked",
|
|
583
|
+
message=(
|
|
584
|
+
"This inbox item is already linked to a task. "
|
|
585
|
+
"Reuse linked_task_id instead of creating a duplicate."
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
if linked_task_id:
|
|
589
|
+
if not await _task_exists(db, linked_task_id, workspace_id):
|
|
590
|
+
raise NotFoundError(
|
|
591
|
+
code="linked_task_not_found", message="Linked task not found"
|
|
592
|
+
)
|
|
593
|
+
else:
|
|
594
|
+
linked_task_id = await _create_triage_task(db, user, inbox_item_id, body)
|
|
595
|
+
|
|
596
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
597
|
+
task_kind = body.task_kind or (
|
|
598
|
+
"idea"
|
|
599
|
+
if body.decision == "create_idea"
|
|
600
|
+
else "normal"
|
|
601
|
+
if body.decision == "create_task"
|
|
602
|
+
else None
|
|
603
|
+
)
|
|
604
|
+
tags_json = json.dumps(body.tags)
|
|
605
|
+
decision_id = str(uuid.uuid4())
|
|
606
|
+
await db.execute(
|
|
607
|
+
"INSERT INTO inbox_triage_decisions (id, inbox_item_id, decision, reason, confidence, target_program, "
|
|
608
|
+
"target_project, task_kind, task_title, task_description, linked_task_id, tags_json, decided_by, workspace_id, created_at, updated_at) "
|
|
609
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
610
|
+
"ON CONFLICT(inbox_item_id, workspace_id) DO UPDATE SET "
|
|
611
|
+
"decision=excluded.decision, reason=excluded.reason, confidence=excluded.confidence, "
|
|
612
|
+
"target_program=excluded.target_program, target_project=excluded.target_project, task_kind=excluded.task_kind, "
|
|
613
|
+
"task_title=excluded.task_title, task_description=excluded.task_description, linked_task_id=excluded.linked_task_id, "
|
|
614
|
+
"tags_json=excluded.tags_json, decided_by=excluded.decided_by, updated_at=excluded.updated_at",
|
|
615
|
+
(
|
|
616
|
+
decision_id,
|
|
617
|
+
inbox_item_id,
|
|
618
|
+
body.decision,
|
|
619
|
+
body.reason,
|
|
620
|
+
body.confidence,
|
|
621
|
+
body.target_program,
|
|
622
|
+
body.target_project,
|
|
623
|
+
task_kind,
|
|
624
|
+
body.task_title,
|
|
625
|
+
body.task_description,
|
|
626
|
+
linked_task_id,
|
|
627
|
+
tags_json,
|
|
628
|
+
user.username,
|
|
629
|
+
workspace_id,
|
|
630
|
+
now,
|
|
631
|
+
now,
|
|
632
|
+
),
|
|
633
|
+
)
|
|
634
|
+
await db.commit()
|
|
635
|
+
|
|
636
|
+
saved = await (
|
|
637
|
+
await db.execute(
|
|
638
|
+
"SELECT ? AS inbox_item_id, decision, confidence, reason, target_program, target_project, task_kind, task_title, "
|
|
639
|
+
"task_description, linked_task_id, tags_json, decided_by, created_at, updated_at "
|
|
640
|
+
"FROM inbox_triage_decisions WHERE inbox_item_id = ? AND COALESCE(workspace_id, 'ws_default') = ?",
|
|
641
|
+
(inbox_item_id, inbox_item_id, workspace_id),
|
|
642
|
+
)
|
|
643
|
+
).fetchone()
|
|
644
|
+
return InboxTriageDecisionResponse(
|
|
645
|
+
inbox_item_id=inbox_item_id,
|
|
646
|
+
decision=saved["decision"],
|
|
647
|
+
confidence=saved["confidence"],
|
|
648
|
+
reason=saved["reason"],
|
|
649
|
+
target_program=saved["target_program"],
|
|
650
|
+
target_project=saved["target_project"],
|
|
651
|
+
task_kind=saved["task_kind"],
|
|
652
|
+
task_title=saved["task_title"],
|
|
653
|
+
task_description=saved["task_description"],
|
|
654
|
+
linked_task_id=saved["linked_task_id"],
|
|
655
|
+
tags=json.loads(saved["tags_json"] or "[]"),
|
|
656
|
+
decided_by=saved["decided_by"],
|
|
657
|
+
created_at=saved["created_at"],
|
|
658
|
+
updated_at=saved["updated_at"],
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
async def _update_source_score(
|
|
663
|
+
db: aiosqlite.Connection,
|
|
664
|
+
inbox_item_id: str,
|
|
665
|
+
workspace_id: str,
|
|
666
|
+
new_status: str,
|
|
667
|
+
) -> None:
|
|
668
|
+
"""Update source_scores with differentiated weights.
|
|
669
|
+
|
|
670
|
+
Uses the SCORE_WEIGHTS table (see module-level constant). Weights:
|
|
671
|
+
newsletter=+3, preferred=+2, saved=+1, idea=+1, read=+0.1,
|
|
672
|
+
auto_ignored=0 (no write), ignored=-1.
|
|
673
|
+
|
|
674
|
+
Differentiated counters are also tracked:
|
|
675
|
+
- upvotes: any "positive" signal (saved, newsletter, idea, preferred)
|
|
676
|
+
- downvotes: ignored only
|
|
677
|
+
- reads: read only (the new counter from migration 061)
|
|
678
|
+
"""
|
|
679
|
+
if new_status not in SCORE_WEIGHTS:
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
score_delta = SCORE_WEIGHTS[new_status]
|
|
683
|
+
if score_delta == 0:
|
|
684
|
+
# auto_ignored (or any future neutral status): no-op, skip the write
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
# Get source_key from the item's URL domain (most useful for scoring)
|
|
688
|
+
row = await (
|
|
689
|
+
await db.execute(
|
|
690
|
+
"SELECT source, url, domain_key, metadata_json FROM inbox_items WHERE id = ?",
|
|
691
|
+
(inbox_item_id,),
|
|
692
|
+
)
|
|
693
|
+
).fetchone()
|
|
694
|
+
if row is None:
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
source_key = row["domain_key"] or row["source"]
|
|
698
|
+
# Prefer URL domain as source_key (e.g. "notboring.co", "simonwillison.net").
|
|
699
|
+
# Normalization MUST match _migration_062_backfill_from_urls so the JOIN
|
|
700
|
+
# between source_scores and inbox_sources actually matches rows.
|
|
701
|
+
url = row["url"]
|
|
702
|
+
if url:
|
|
703
|
+
try:
|
|
704
|
+
netloc = urlparse(url).netloc
|
|
705
|
+
if netloc:
|
|
706
|
+
source_key = netloc.removeprefix("www.").lower()
|
|
707
|
+
except Exception: # noqa: BLE001 - defensive; source_key stays as fallback
|
|
708
|
+
pass
|
|
709
|
+
|
|
710
|
+
if not source_key:
|
|
711
|
+
return
|
|
712
|
+
|
|
713
|
+
is_read = new_status == "read"
|
|
714
|
+
is_upvote = new_status in ("saved", "newsletter", "idea", "preferred")
|
|
715
|
+
is_downvote = new_status == "ignored"
|
|
716
|
+
|
|
717
|
+
upvote_delta = 1 if is_upvote else 0
|
|
718
|
+
downvote_delta = 1 if is_downvote else 0
|
|
719
|
+
reads_delta = 1 if is_read else 0
|
|
720
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
721
|
+
|
|
722
|
+
await db.execute(
|
|
723
|
+
"INSERT INTO source_scores "
|
|
724
|
+
"(id, source_key, score, upvotes, downvotes, reads, workspace_id, created_at, updated_at) "
|
|
725
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
726
|
+
"ON CONFLICT(workspace_id, source_key) DO UPDATE SET "
|
|
727
|
+
"score = source_scores.score + ?, "
|
|
728
|
+
"upvotes = source_scores.upvotes + ?, "
|
|
729
|
+
"downvotes = source_scores.downvotes + ?, "
|
|
730
|
+
"reads = source_scores.reads + ?, "
|
|
731
|
+
"updated_at = ?",
|
|
732
|
+
(
|
|
733
|
+
str(uuid.uuid4()),
|
|
734
|
+
source_key,
|
|
735
|
+
score_delta,
|
|
736
|
+
upvote_delta,
|
|
737
|
+
downvote_delta,
|
|
738
|
+
reads_delta,
|
|
739
|
+
workspace_id,
|
|
740
|
+
now,
|
|
741
|
+
now,
|
|
742
|
+
score_delta,
|
|
743
|
+
upvote_delta,
|
|
744
|
+
downvote_delta,
|
|
745
|
+
reads_delta,
|
|
746
|
+
now,
|
|
747
|
+
),
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
async def update_inbox_status(
|
|
752
|
+
db: aiosqlite.Connection,
|
|
753
|
+
user: UserInfo,
|
|
754
|
+
inbox_item_id: str,
|
|
755
|
+
body: InboxStatusUpdateRequest,
|
|
756
|
+
) -> dict[str, Any]:
|
|
757
|
+
"""Update the status of an inbox item. decided_by is set server-side."""
|
|
758
|
+
columns = await ensure_inbox_core_available(db)
|
|
759
|
+
workspace_id = user.workspace_id or "ws_default"
|
|
760
|
+
where = ["id = ?"]
|
|
761
|
+
params: list[Any] = [inbox_item_id]
|
|
762
|
+
if "workspace_id" in columns:
|
|
763
|
+
where.append("COALESCE(workspace_id, 'ws_default') = ?")
|
|
764
|
+
params.append(workspace_id)
|
|
765
|
+
row = await (
|
|
766
|
+
await db.execute(
|
|
767
|
+
f"SELECT id FROM inbox_items WHERE {' AND '.join(where)}",
|
|
768
|
+
params,
|
|
769
|
+
)
|
|
770
|
+
).fetchone()
|
|
771
|
+
if row is None:
|
|
772
|
+
raise NotFoundError(code="inbox_item_not_found", message="Inbox item not found")
|
|
773
|
+
|
|
774
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
775
|
+
update_fields = [
|
|
776
|
+
"status = ?",
|
|
777
|
+
"decided_by = ?",
|
|
778
|
+
"decided_at = ?",
|
|
779
|
+
"updated_at = ?",
|
|
780
|
+
]
|
|
781
|
+
update_params: list[Any] = [body.status, user.username, now, now]
|
|
782
|
+
|
|
783
|
+
if body.ignore_reason:
|
|
784
|
+
update_fields.append("ignore_reason = ?")
|
|
785
|
+
update_params.append(body.ignore_reason)
|
|
786
|
+
else:
|
|
787
|
+
update_fields.append("ignore_reason = NULL")
|
|
788
|
+
|
|
789
|
+
where_clause = ["id = ?"]
|
|
790
|
+
update_params.append(inbox_item_id)
|
|
791
|
+
if "workspace_id" in columns:
|
|
792
|
+
where_clause.append("COALESCE(workspace_id, 'ws_default') = ?")
|
|
793
|
+
update_params.append(workspace_id)
|
|
794
|
+
|
|
795
|
+
await db.execute(
|
|
796
|
+
f"UPDATE inbox_items SET {', '.join(update_fields)} WHERE {' AND '.join(where_clause)}",
|
|
797
|
+
update_params,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
if body.status != "unread":
|
|
801
|
+
try:
|
|
802
|
+
from core.api.services.inbox_digest import remove_item_from_digest_selection
|
|
803
|
+
|
|
804
|
+
await remove_item_from_digest_selection(
|
|
805
|
+
db,
|
|
806
|
+
workspace_id=workspace_id,
|
|
807
|
+
inbox_item_id=inbox_item_id,
|
|
808
|
+
)
|
|
809
|
+
except Exception:
|
|
810
|
+
# Non-critical: do not block manual state changes on digest cleanup.
|
|
811
|
+
pass
|
|
812
|
+
|
|
813
|
+
# Update source score based on status decision
|
|
814
|
+
try:
|
|
815
|
+
await _update_source_score(db, inbox_item_id, workspace_id, body.status)
|
|
816
|
+
except Exception:
|
|
817
|
+
# Non-critical: don't fail the status update if score tracking fails
|
|
818
|
+
pass
|
|
819
|
+
|
|
820
|
+
await db.commit()
|
|
821
|
+
|
|
822
|
+
# Auto-generate deep research for newsletter items (non-blocking)
|
|
823
|
+
if body.status == "newsletter":
|
|
824
|
+
import asyncio
|
|
825
|
+
|
|
826
|
+
asyncio.create_task(_auto_generate_deep_research(inbox_item_id, workspace_id))
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
"inbox_item_id": inbox_item_id,
|
|
830
|
+
"status": body.status,
|
|
831
|
+
"ignore_reason": body.ignore_reason,
|
|
832
|
+
"decided_by": user.username,
|
|
833
|
+
"decided_at": now,
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
async def _auto_generate_deep_research(
|
|
838
|
+
inbox_item_id: str,
|
|
839
|
+
workspace_id: str,
|
|
840
|
+
) -> None:
|
|
841
|
+
"""Auto-generate deep research for newsletter items in background."""
|
|
842
|
+
try:
|
|
843
|
+
from core.api.services.inbox_tldr import get_or_generate_deep_research
|
|
844
|
+
|
|
845
|
+
await get_or_generate_deep_research(inbox_item_id, workspace_id)
|
|
846
|
+
logger.info(
|
|
847
|
+
"Auto-generated deep research for newsletter item %s", inbox_item_id
|
|
848
|
+
)
|
|
849
|
+
except Exception as exc:
|
|
850
|
+
logger.warning("Auto deep research failed for %s: %s", inbox_item_id, exc)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
async def get_source_scores(
|
|
854
|
+
db: aiosqlite.Connection,
|
|
855
|
+
workspace_id: str,
|
|
856
|
+
) -> list[dict[str, Any]]:
|
|
857
|
+
"""Return all source scores for the workspace."""
|
|
858
|
+
rows = await (
|
|
859
|
+
await db.execute(
|
|
860
|
+
"SELECT source_key, score, upvotes, downvotes "
|
|
861
|
+
"FROM source_scores WHERE workspace_id = ? ORDER BY score DESC",
|
|
862
|
+
(workspace_id,),
|
|
863
|
+
)
|
|
864
|
+
).fetchall()
|
|
865
|
+
return [
|
|
866
|
+
{
|
|
867
|
+
"source_key": row["source_key"],
|
|
868
|
+
"score": row["score"],
|
|
869
|
+
"upvotes": row["upvotes"],
|
|
870
|
+
"downvotes": row["downvotes"],
|
|
871
|
+
}
|
|
872
|
+
for row in rows
|
|
873
|
+
]
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
async def get_unread_count(
|
|
877
|
+
db: aiosqlite.Connection,
|
|
878
|
+
user: UserInfo,
|
|
879
|
+
) -> int:
|
|
880
|
+
"""Lightweight unread count query for badge polling."""
|
|
881
|
+
columns = await ensure_inbox_core_available(db)
|
|
882
|
+
workspace_id = user.workspace_id or "ws_default"
|
|
883
|
+
|
|
884
|
+
if "workspace_id" in columns:
|
|
885
|
+
row = await (
|
|
886
|
+
await db.execute(
|
|
887
|
+
"SELECT COUNT(*) AS cnt FROM inbox_items "
|
|
888
|
+
"WHERE status = 'unread' AND COALESCE(workspace_id, 'ws_default') = ?",
|
|
889
|
+
(workspace_id,),
|
|
890
|
+
)
|
|
891
|
+
).fetchone()
|
|
892
|
+
else:
|
|
893
|
+
row = await (
|
|
894
|
+
await db.execute(
|
|
895
|
+
"SELECT COUNT(*) AS cnt FROM inbox_items WHERE status = 'unread'"
|
|
896
|
+
)
|
|
897
|
+
).fetchone()
|
|
898
|
+
|
|
899
|
+
return int(row["cnt"] or 0)
|