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,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import parse_qs, urlsplit
|
|
8
|
+
|
|
9
|
+
HTTP_URL_RE = re.compile(r"https?://[^\s<>'\")]+")
|
|
10
|
+
GMAIL_HOSTS = {"mail.google.com", "gmail.com"}
|
|
11
|
+
LOW_SIGNAL_SOURCE_KEYS = {"substack.com", "mail.google.com", "gmail.com"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_domain_key(raw: str | None) -> str | None:
|
|
15
|
+
if not raw:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
parsed = urlsplit(raw)
|
|
19
|
+
if parsed.netloc:
|
|
20
|
+
raw = parsed.netloc
|
|
21
|
+
except Exception: # noqa: BLE001
|
|
22
|
+
pass
|
|
23
|
+
key = raw.strip().lower()
|
|
24
|
+
if key.startswith("www."):
|
|
25
|
+
key = key.removeprefix("www.")
|
|
26
|
+
return key or None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_gmail_hosted_url(url: str | None) -> bool:
|
|
30
|
+
if not url:
|
|
31
|
+
return False
|
|
32
|
+
try:
|
|
33
|
+
parsed = urlsplit(url)
|
|
34
|
+
except Exception: # noqa: BLE001
|
|
35
|
+
return False
|
|
36
|
+
return normalize_domain_key(parsed.netloc) in GMAIL_HOSTS
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_low_signal_source_key(source_key: str | None) -> bool:
|
|
40
|
+
normalized = normalize_domain_key(source_key)
|
|
41
|
+
return bool(
|
|
42
|
+
normalized
|
|
43
|
+
and (
|
|
44
|
+
normalized in LOW_SIGNAL_SOURCE_KEYS
|
|
45
|
+
or normalized.endswith("@substack.com")
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def unwrap_tracking_url(url: str | None) -> str | None:
|
|
51
|
+
if not isinstance(url, str) or not url.strip():
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
raw_url = url.strip()
|
|
55
|
+
try:
|
|
56
|
+
parsed = urlsplit(raw_url)
|
|
57
|
+
except Exception: # noqa: BLE001
|
|
58
|
+
return raw_url
|
|
59
|
+
|
|
60
|
+
if normalize_domain_key(parsed.netloc) != "substack.com":
|
|
61
|
+
return raw_url
|
|
62
|
+
|
|
63
|
+
path_parts = [part for part in parsed.path.split("/") if part]
|
|
64
|
+
if len(path_parts) < 3 or path_parts[0] != "redirect":
|
|
65
|
+
return raw_url
|
|
66
|
+
|
|
67
|
+
payload = _decode_substack_redirect_payload(path_parts[-1])
|
|
68
|
+
if not isinstance(payload, dict):
|
|
69
|
+
return raw_url
|
|
70
|
+
|
|
71
|
+
event_url = payload.get("e")
|
|
72
|
+
if not isinstance(event_url, str):
|
|
73
|
+
return raw_url
|
|
74
|
+
|
|
75
|
+
next_url = _extract_query_url(event_url, "next")
|
|
76
|
+
if next_url:
|
|
77
|
+
return next_url
|
|
78
|
+
if _is_http_url(event_url):
|
|
79
|
+
return event_url
|
|
80
|
+
return raw_url
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _decode_substack_redirect_payload(encoded_segment: str) -> dict[str, Any] | None:
|
|
84
|
+
encoded_payload = encoded_segment.split(".", 1)[0]
|
|
85
|
+
if not encoded_payload:
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
padded_payload = encoded_payload + "=" * (-len(encoded_payload) % 4)
|
|
89
|
+
decoded = base64.urlsafe_b64decode(padded_payload.encode("ascii")).decode(
|
|
90
|
+
"utf-8"
|
|
91
|
+
)
|
|
92
|
+
payload = json.loads(decoded)
|
|
93
|
+
except Exception: # noqa: BLE001
|
|
94
|
+
return None
|
|
95
|
+
return payload if isinstance(payload, dict) else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract_query_url(url: str, key: str) -> str | None:
|
|
99
|
+
try:
|
|
100
|
+
values = parse_qs(urlsplit(url).query).get(key, [])
|
|
101
|
+
except Exception: # noqa: BLE001
|
|
102
|
+
return None
|
|
103
|
+
for value in values:
|
|
104
|
+
if _is_http_url(value):
|
|
105
|
+
return value
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_http_url(url: str | None) -> bool:
|
|
110
|
+
if not isinstance(url, str) or not url.strip():
|
|
111
|
+
return False
|
|
112
|
+
try:
|
|
113
|
+
parsed = urlsplit(url.strip())
|
|
114
|
+
except Exception: # noqa: BLE001
|
|
115
|
+
return False
|
|
116
|
+
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# v1.0.0 - 2026-04-11 - PR B: sources CRUD + metrics + SSRF guard
|
|
2
|
+
"""Service for inbox sources catalog (CRUD + metrics).
|
|
3
|
+
|
|
4
|
+
Tables used: inbox_sources (from migration 061), inbox_items, source_scores.
|
|
5
|
+
|
|
6
|
+
The service returns plain dicts so that FastAPI can serialize them directly
|
|
7
|
+
without an extra Pydantic layer. Keep the shape stable: the frontend (PR D)
|
|
8
|
+
consumes these fields verbatim.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ipaddress
|
|
13
|
+
import logging
|
|
14
|
+
import socket
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
from typing import Literal
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
import aiosqlite
|
|
21
|
+
from fastapi import HTTPException
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
MetricRange = Literal["24h", "7d", "30d", "total"]
|
|
26
|
+
_VALID_RANGES: frozenset[str] = frozenset({"24h", "7d", "30d", "total"})
|
|
27
|
+
_VALID_SOURCE_TYPES: frozenset[str] = frozenset({"rss", "email", "manual", "api", "legacy"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# SSRF prevention
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_public_url(url: str) -> None:
|
|
36
|
+
"""Raise HTTPException 422 if URL points to a private/loopback/internal address.
|
|
37
|
+
|
|
38
|
+
Blocks: non-http(s) schemes, missing hostname, unresolvable hostnames, and
|
|
39
|
+
hostnames that resolve to private / loopback / link-local / reserved /
|
|
40
|
+
multicast IP ranges (including the AWS metadata 169.254.169.254).
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
parsed = urlparse(url)
|
|
44
|
+
except Exception as exc: # noqa: BLE001
|
|
45
|
+
raise HTTPException(status_code=422, detail="Invalid URL") from exc
|
|
46
|
+
if parsed.scheme not in ("http", "https"):
|
|
47
|
+
raise HTTPException(status_code=422, detail="Only http(s) URLs allowed")
|
|
48
|
+
hostname = parsed.hostname
|
|
49
|
+
if not hostname:
|
|
50
|
+
raise HTTPException(status_code=422, detail="Missing hostname")
|
|
51
|
+
try:
|
|
52
|
+
infos = socket.getaddrinfo(hostname, None)
|
|
53
|
+
except socket.gaierror as exc:
|
|
54
|
+
raise HTTPException(status_code=422, detail="Cannot resolve hostname") from exc
|
|
55
|
+
for info in infos:
|
|
56
|
+
ip_str = info[4][0]
|
|
57
|
+
try:
|
|
58
|
+
ip = ipaddress.ip_address(ip_str)
|
|
59
|
+
except ValueError:
|
|
60
|
+
continue
|
|
61
|
+
if (
|
|
62
|
+
ip.is_private
|
|
63
|
+
or ip.is_loopback
|
|
64
|
+
or ip.is_link_local
|
|
65
|
+
or ip.is_reserved
|
|
66
|
+
or ip.is_multicast
|
|
67
|
+
or ip.is_unspecified
|
|
68
|
+
):
|
|
69
|
+
raise HTTPException(
|
|
70
|
+
status_code=422,
|
|
71
|
+
detail=f"URL resolves to non-public address: {ip_str}",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Helpers
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_source_key(raw: str) -> str:
|
|
81
|
+
"""Normalize source_key the same way inbox_triage._update_source_score does.
|
|
82
|
+
|
|
83
|
+
Strip whitespace, lowercase, drop 'www.' prefix. This ensures joins with
|
|
84
|
+
source_scores work across sources that were registered with/without 'www.'.
|
|
85
|
+
"""
|
|
86
|
+
key = (raw or "").strip().lower()
|
|
87
|
+
# If it looks like a URL, extract the netloc
|
|
88
|
+
try:
|
|
89
|
+
parsed = urlparse(key)
|
|
90
|
+
if parsed.netloc:
|
|
91
|
+
key = parsed.netloc
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
if key.startswith("www."):
|
|
95
|
+
key = key.removeprefix("www.")
|
|
96
|
+
return key
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _row_to_dict(row: aiosqlite.Row) -> dict:
|
|
100
|
+
"""Convert aiosqlite.Row (or any Mapping) to a plain dict."""
|
|
101
|
+
return {k: row[k] for k in row.keys()}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _range_to_cutoff(range_value: str) -> str | None:
|
|
105
|
+
"""Return an ISO timestamp cutoff for a given range, or None for 'total'."""
|
|
106
|
+
if range_value == "total":
|
|
107
|
+
return None
|
|
108
|
+
now = datetime.now(timezone.utc)
|
|
109
|
+
if range_value == "24h":
|
|
110
|
+
cutoff = now - timedelta(hours=24)
|
|
111
|
+
elif range_value == "7d":
|
|
112
|
+
cutoff = now - timedelta(days=7)
|
|
113
|
+
elif range_value == "30d":
|
|
114
|
+
cutoff = now - timedelta(days=30)
|
|
115
|
+
else:
|
|
116
|
+
return None
|
|
117
|
+
return cutoff.isoformat()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# CRUD
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def list_sources(
|
|
126
|
+
db: aiosqlite.Connection,
|
|
127
|
+
workspace_id: str,
|
|
128
|
+
*,
|
|
129
|
+
active_only: bool = False,
|
|
130
|
+
) -> list[dict]:
|
|
131
|
+
"""Return all sources with inline aggregate metrics.
|
|
132
|
+
|
|
133
|
+
One SQL query with LEFT JOINs to avoid N+1. Each row contains:
|
|
134
|
+
- base source columns
|
|
135
|
+
- total_items, unread_count, auto_ignored_count (from inbox_items)
|
|
136
|
+
- score, upvotes, downvotes, reads (from source_scores)
|
|
137
|
+
"""
|
|
138
|
+
where_active = "AND s.active = 1" if active_only else ""
|
|
139
|
+
|
|
140
|
+
query = f"""
|
|
141
|
+
SELECT
|
|
142
|
+
s.id,
|
|
143
|
+
s.name,
|
|
144
|
+
s.source_key,
|
|
145
|
+
s.feed_url,
|
|
146
|
+
s.source_type,
|
|
147
|
+
s.active,
|
|
148
|
+
s.last_fetch_at,
|
|
149
|
+
s.last_fetch_error,
|
|
150
|
+
s.workspace_id,
|
|
151
|
+
s.created_at,
|
|
152
|
+
s.updated_at,
|
|
153
|
+
COALESCE(items.total_items, 0) AS total_items,
|
|
154
|
+
COALESCE(items.unread_count, 0) AS unread_count,
|
|
155
|
+
COALESCE(items.auto_ignored_count, 0) AS auto_ignored_count,
|
|
156
|
+
COALESCE(scores.score, 0) AS score,
|
|
157
|
+
COALESCE(scores.upvotes, 0) AS upvotes,
|
|
158
|
+
COALESCE(scores.downvotes, 0) AS downvotes,
|
|
159
|
+
COALESCE(scores.reads, 0) AS reads
|
|
160
|
+
FROM inbox_sources s
|
|
161
|
+
LEFT JOIN (
|
|
162
|
+
SELECT
|
|
163
|
+
REPLACE(
|
|
164
|
+
LOWER(
|
|
165
|
+
SUBSTR(
|
|
166
|
+
REPLACE(REPLACE(url, 'https://', ''), 'http://', ''),
|
|
167
|
+
1,
|
|
168
|
+
CASE
|
|
169
|
+
WHEN INSTR(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''), '/') > 0
|
|
170
|
+
THEN INSTR(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''), '/') - 1
|
|
171
|
+
ELSE LENGTH(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''))
|
|
172
|
+
END
|
|
173
|
+
)
|
|
174
|
+
),
|
|
175
|
+
'www.', ''
|
|
176
|
+
) AS domain_key,
|
|
177
|
+
COUNT(*) AS total_items,
|
|
178
|
+
SUM(CASE WHEN status = 'unread' THEN 1 ELSE 0 END) AS unread_count,
|
|
179
|
+
SUM(CASE WHEN status = 'auto_ignored' THEN 1 ELSE 0 END) AS auto_ignored_count,
|
|
180
|
+
workspace_id AS ws
|
|
181
|
+
FROM inbox_items
|
|
182
|
+
WHERE url IS NOT NULL AND url != ''
|
|
183
|
+
GROUP BY domain_key, workspace_id
|
|
184
|
+
) items ON items.domain_key = s.source_key AND items.ws = s.workspace_id
|
|
185
|
+
LEFT JOIN source_scores scores
|
|
186
|
+
ON scores.source_key = s.source_key
|
|
187
|
+
AND scores.workspace_id = s.workspace_id
|
|
188
|
+
WHERE s.workspace_id = ?
|
|
189
|
+
{where_active}
|
|
190
|
+
ORDER BY s.name ASC
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
cursor = await db.execute(query, (workspace_id,))
|
|
194
|
+
rows = await cursor.fetchall()
|
|
195
|
+
return [_row_to_dict(r) for r in rows]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def get_source(
|
|
199
|
+
db: aiosqlite.Connection,
|
|
200
|
+
workspace_id: str,
|
|
201
|
+
source_id: str,
|
|
202
|
+
) -> dict | None:
|
|
203
|
+
"""Return a single source row with the same aggregate shape as list_sources."""
|
|
204
|
+
query = """
|
|
205
|
+
SELECT
|
|
206
|
+
s.id, s.name, s.source_key, s.feed_url, s.source_type,
|
|
207
|
+
s.active, s.last_fetch_at, s.last_fetch_error,
|
|
208
|
+
s.workspace_id, s.created_at, s.updated_at,
|
|
209
|
+
COALESCE(items.total_items, 0) AS total_items,
|
|
210
|
+
COALESCE(items.unread_count, 0) AS unread_count,
|
|
211
|
+
COALESCE(items.auto_ignored_count, 0) AS auto_ignored_count,
|
|
212
|
+
COALESCE(scores.score, 0) AS score,
|
|
213
|
+
COALESCE(scores.upvotes, 0) AS upvotes,
|
|
214
|
+
COALESCE(scores.downvotes, 0) AS downvotes,
|
|
215
|
+
COALESCE(scores.reads, 0) AS reads
|
|
216
|
+
FROM inbox_sources s
|
|
217
|
+
LEFT JOIN (
|
|
218
|
+
SELECT
|
|
219
|
+
REPLACE(
|
|
220
|
+
LOWER(
|
|
221
|
+
SUBSTR(
|
|
222
|
+
REPLACE(REPLACE(url, 'https://', ''), 'http://', ''),
|
|
223
|
+
1,
|
|
224
|
+
CASE
|
|
225
|
+
WHEN INSTR(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''), '/') > 0
|
|
226
|
+
THEN INSTR(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''), '/') - 1
|
|
227
|
+
ELSE LENGTH(REPLACE(REPLACE(url, 'https://', ''), 'http://', ''))
|
|
228
|
+
END
|
|
229
|
+
)
|
|
230
|
+
),
|
|
231
|
+
'www.', ''
|
|
232
|
+
) AS domain_key,
|
|
233
|
+
COUNT(*) AS total_items,
|
|
234
|
+
SUM(CASE WHEN status = 'unread' THEN 1 ELSE 0 END) AS unread_count,
|
|
235
|
+
SUM(CASE WHEN status = 'auto_ignored' THEN 1 ELSE 0 END) AS auto_ignored_count,
|
|
236
|
+
workspace_id AS ws
|
|
237
|
+
FROM inbox_items
|
|
238
|
+
WHERE url IS NOT NULL AND url != ''
|
|
239
|
+
GROUP BY domain_key, workspace_id
|
|
240
|
+
) items ON items.domain_key = s.source_key AND items.ws = s.workspace_id
|
|
241
|
+
LEFT JOIN source_scores scores
|
|
242
|
+
ON scores.source_key = s.source_key
|
|
243
|
+
AND scores.workspace_id = s.workspace_id
|
|
244
|
+
WHERE s.id = ? AND s.workspace_id = ?
|
|
245
|
+
"""
|
|
246
|
+
cursor = await db.execute(query, (source_id, workspace_id))
|
|
247
|
+
row = await cursor.fetchone()
|
|
248
|
+
return _row_to_dict(row) if row else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def create_source(
|
|
252
|
+
db: aiosqlite.Connection,
|
|
253
|
+
workspace_id: str,
|
|
254
|
+
*,
|
|
255
|
+
name: str,
|
|
256
|
+
source_key: str,
|
|
257
|
+
feed_url: str | None = None,
|
|
258
|
+
source_type: str = "rss",
|
|
259
|
+
) -> dict:
|
|
260
|
+
"""Insert a new source. 409 on duplicate (workspace_id, source_key)."""
|
|
261
|
+
if source_type not in _VALID_SOURCE_TYPES:
|
|
262
|
+
raise HTTPException(
|
|
263
|
+
status_code=422,
|
|
264
|
+
detail=f"Invalid source_type: {source_type}",
|
|
265
|
+
)
|
|
266
|
+
normalized_key = _normalize_source_key(source_key)
|
|
267
|
+
if not normalized_key:
|
|
268
|
+
raise HTTPException(status_code=422, detail="source_key cannot be empty")
|
|
269
|
+
|
|
270
|
+
source_id = f"src_{uuid.uuid4().hex[:24]}"
|
|
271
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
await db.execute(
|
|
275
|
+
"INSERT INTO inbox_sources "
|
|
276
|
+
"(id, name, source_key, feed_url, source_type, active, "
|
|
277
|
+
"workspace_id, created_at, updated_at) "
|
|
278
|
+
"VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)",
|
|
279
|
+
(
|
|
280
|
+
source_id,
|
|
281
|
+
name.strip(),
|
|
282
|
+
normalized_key,
|
|
283
|
+
feed_url,
|
|
284
|
+
source_type,
|
|
285
|
+
workspace_id,
|
|
286
|
+
now,
|
|
287
|
+
now,
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
await db.commit()
|
|
291
|
+
except aiosqlite.IntegrityError as exc:
|
|
292
|
+
raise HTTPException(
|
|
293
|
+
status_code=409,
|
|
294
|
+
detail=f"Source already exists with source_key={normalized_key!r}",
|
|
295
|
+
) from exc
|
|
296
|
+
|
|
297
|
+
result = await get_source(db, workspace_id, source_id)
|
|
298
|
+
if result is None:
|
|
299
|
+
raise HTTPException(status_code=500, detail="Source created but not found")
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def update_source(
|
|
304
|
+
db: aiosqlite.Connection,
|
|
305
|
+
workspace_id: str,
|
|
306
|
+
source_id: str,
|
|
307
|
+
*,
|
|
308
|
+
name: str | None = None,
|
|
309
|
+
feed_url: str | None = None,
|
|
310
|
+
active: bool | None = None,
|
|
311
|
+
) -> dict:
|
|
312
|
+
"""Selectively update a source. Returns the updated row."""
|
|
313
|
+
existing = await get_source(db, workspace_id, source_id)
|
|
314
|
+
if existing is None:
|
|
315
|
+
raise HTTPException(status_code=404, detail="Source not found")
|
|
316
|
+
|
|
317
|
+
updates: list[str] = []
|
|
318
|
+
params: list[object] = []
|
|
319
|
+
if name is not None:
|
|
320
|
+
updates.append("name = ?")
|
|
321
|
+
params.append(name.strip())
|
|
322
|
+
if feed_url is not None:
|
|
323
|
+
updates.append("feed_url = ?")
|
|
324
|
+
params.append(feed_url)
|
|
325
|
+
if active is not None:
|
|
326
|
+
updates.append("active = ?")
|
|
327
|
+
params.append(1 if active else 0)
|
|
328
|
+
|
|
329
|
+
if not updates:
|
|
330
|
+
return existing
|
|
331
|
+
|
|
332
|
+
updates.append("updated_at = ?")
|
|
333
|
+
params.append(datetime.now(timezone.utc).isoformat())
|
|
334
|
+
params.append(source_id)
|
|
335
|
+
params.append(workspace_id)
|
|
336
|
+
|
|
337
|
+
await db.execute(
|
|
338
|
+
f"UPDATE inbox_sources SET {', '.join(updates)} "
|
|
339
|
+
"WHERE id = ? AND workspace_id = ?",
|
|
340
|
+
tuple(params),
|
|
341
|
+
)
|
|
342
|
+
await db.commit()
|
|
343
|
+
|
|
344
|
+
result = await get_source(db, workspace_id, source_id)
|
|
345
|
+
if result is None: # pragma: no cover - defensive
|
|
346
|
+
raise HTTPException(status_code=500, detail="Source disappeared after update")
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def delete_source(
|
|
351
|
+
db: aiosqlite.Connection,
|
|
352
|
+
workspace_id: str,
|
|
353
|
+
source_id: str,
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Soft delete: set active = 0. Keeps historical data intact."""
|
|
356
|
+
existing = await get_source(db, workspace_id, source_id)
|
|
357
|
+
if existing is None:
|
|
358
|
+
raise HTTPException(status_code=404, detail="Source not found")
|
|
359
|
+
|
|
360
|
+
await db.execute(
|
|
361
|
+
"UPDATE inbox_sources SET active = 0, updated_at = ? "
|
|
362
|
+
"WHERE id = ? AND workspace_id = ?",
|
|
363
|
+
(datetime.now(timezone.utc).isoformat(), source_id, workspace_id),
|
|
364
|
+
)
|
|
365
|
+
await db.commit()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Metrics
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def get_source_metrics(
|
|
374
|
+
db: aiosqlite.Connection,
|
|
375
|
+
workspace_id: str,
|
|
376
|
+
source_id: str,
|
|
377
|
+
*,
|
|
378
|
+
range: MetricRange = "total", # noqa: A002 - deliberate shadow of builtin for API ergonomics
|
|
379
|
+
) -> dict:
|
|
380
|
+
"""Return detailed metrics for a source, filtered by a time range.
|
|
381
|
+
|
|
382
|
+
Range options:
|
|
383
|
+
- "24h" -> items created in last 24h
|
|
384
|
+
- "7d" -> items created in last 7d
|
|
385
|
+
- "30d" -> items created in last 30d
|
|
386
|
+
- "total"-> all-time (default)
|
|
387
|
+
"""
|
|
388
|
+
if range not in _VALID_RANGES:
|
|
389
|
+
raise HTTPException(status_code=422, detail=f"Invalid range: {range}")
|
|
390
|
+
|
|
391
|
+
source_row = await (
|
|
392
|
+
await db.execute(
|
|
393
|
+
"SELECT source_key FROM inbox_sources "
|
|
394
|
+
"WHERE id = ? AND workspace_id = ?",
|
|
395
|
+
(source_id, workspace_id),
|
|
396
|
+
)
|
|
397
|
+
).fetchone()
|
|
398
|
+
if source_row is None:
|
|
399
|
+
raise HTTPException(status_code=404, detail="Source not found")
|
|
400
|
+
source_key = source_row["source_key"]
|
|
401
|
+
|
|
402
|
+
cutoff = _range_to_cutoff(range)
|
|
403
|
+
where_items = "source = ? AND workspace_id = ?"
|
|
404
|
+
params: list[object] = [source_key, workspace_id]
|
|
405
|
+
if cutoff is not None:
|
|
406
|
+
where_items += " AND created_at >= ?"
|
|
407
|
+
params.append(cutoff)
|
|
408
|
+
|
|
409
|
+
row = await (
|
|
410
|
+
await db.execute(
|
|
411
|
+
f"""
|
|
412
|
+
SELECT
|
|
413
|
+
COUNT(*) AS total,
|
|
414
|
+
SUM(CASE WHEN status = 'unread' THEN 1 ELSE 0 END) AS unread,
|
|
415
|
+
SUM(CASE WHEN status = 'read' THEN 1 ELSE 0 END) AS read_items,
|
|
416
|
+
SUM(CASE WHEN status = 'saved' THEN 1 ELSE 0 END) AS saved,
|
|
417
|
+
SUM(CASE WHEN status = 'newsletter' THEN 1 ELSE 0 END) AS newsletter,
|
|
418
|
+
SUM(CASE WHEN status = 'preferred' THEN 1 ELSE 0 END) AS preferred,
|
|
419
|
+
SUM(CASE WHEN status = 'idea' THEN 1 ELSE 0 END) AS idea,
|
|
420
|
+
SUM(CASE WHEN status = 'ignored' THEN 1 ELSE 0 END) AS ignored,
|
|
421
|
+
SUM(CASE WHEN status = 'auto_ignored' THEN 1 ELSE 0 END) AS auto_ignored
|
|
422
|
+
FROM inbox_items
|
|
423
|
+
WHERE {where_items}
|
|
424
|
+
""",
|
|
425
|
+
tuple(params),
|
|
426
|
+
)
|
|
427
|
+
).fetchone()
|
|
428
|
+
|
|
429
|
+
# Pull the score row (cumulative, range-independent — score is an aggregate)
|
|
430
|
+
score_row = await (
|
|
431
|
+
await db.execute(
|
|
432
|
+
"SELECT score, upvotes, downvotes, reads FROM source_scores "
|
|
433
|
+
"WHERE workspace_id = ? AND source_key = ?",
|
|
434
|
+
(workspace_id, source_key),
|
|
435
|
+
)
|
|
436
|
+
).fetchone()
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
"source_id": source_id,
|
|
440
|
+
"source_key": source_key,
|
|
441
|
+
"range": range,
|
|
442
|
+
"cutoff": cutoff,
|
|
443
|
+
"total": int(row["total"] or 0),
|
|
444
|
+
"unread": int(row["unread"] or 0),
|
|
445
|
+
"read": int(row["read_items"] or 0),
|
|
446
|
+
"saved": int(row["saved"] or 0),
|
|
447
|
+
"newsletter": int(row["newsletter"] or 0),
|
|
448
|
+
"preferred": int(row["preferred"] or 0),
|
|
449
|
+
"idea": int(row["idea"] or 0),
|
|
450
|
+
"ignored": int(row["ignored"] or 0),
|
|
451
|
+
"auto_ignored": int(row["auto_ignored"] or 0),
|
|
452
|
+
"score": float(score_row["score"]) if score_row else 0.0,
|
|
453
|
+
"upvotes": int(score_row["upvotes"]) if score_row else 0,
|
|
454
|
+
"downvotes": int(score_row["downvotes"]) if score_row else 0,
|
|
455
|
+
"reads": int(score_row["reads"]) if score_row else 0,
|
|
456
|
+
}
|