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,832 @@
|
|
|
1
|
+
# v2.4.0 - 2026-04-12 - Add learning, inbox_item, audit doc_types to search grouping
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import struct
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal, cast
|
|
13
|
+
|
|
14
|
+
import aiosqlite
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
DIMENSIONS = 512
|
|
19
|
+
GRANITE_NATIVE_DIMENSIONS = 384
|
|
20
|
+
DEFAULT_SEARCH_RRF_K = 60
|
|
21
|
+
DEFAULT_SEARCH_SALIENCE_WEIGHT = 0.3
|
|
22
|
+
DEFAULT_SEARCH_SCORE_THRESHOLD = 0.6
|
|
23
|
+
BM25_FETCH_LIMIT = 50
|
|
24
|
+
EmbeddingMode = Literal["voyage", "granite_local", "granite_remote", "dual"]
|
|
25
|
+
_VALID_EMBEDDING_MODES = {"voyage", "granite_local", "granite_remote", "dual"}
|
|
26
|
+
|
|
27
|
+
# Clients are initialized in lifespan where possible. Granite remains lazy and
|
|
28
|
+
# loads the onnxruntime session + tokenizer (torch-free) on first use.
|
|
29
|
+
_voyage_client = None
|
|
30
|
+
_granite_client = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class _EmbeddingHit:
|
|
35
|
+
doc_id: int
|
|
36
|
+
distance: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class _Bm25Hit:
|
|
41
|
+
doc_id: int
|
|
42
|
+
score: float
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class _HybridRankScore:
|
|
47
|
+
doc_id: int
|
|
48
|
+
score: float
|
|
49
|
+
rrf_score: float
|
|
50
|
+
rank_embedding: int | None
|
|
51
|
+
rank_bm25: int | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def init_voyage_client() -> None:
|
|
55
|
+
"""Called from lifespan. Keeps the legacy name for router compatibility."""
|
|
56
|
+
mode = _embedding_mode()
|
|
57
|
+
if mode in {"voyage", "dual"}:
|
|
58
|
+
_init_voyage_client()
|
|
59
|
+
if mode in {"granite_local", "dual"}:
|
|
60
|
+
client = _get_granite_client()
|
|
61
|
+
logger.info(
|
|
62
|
+
"Granite local embedding client configured (lazy load, model=%s, device=%s)",
|
|
63
|
+
client.active_model_name,
|
|
64
|
+
client.device,
|
|
65
|
+
)
|
|
66
|
+
if mode == "granite_remote":
|
|
67
|
+
logger.info("Granite remote embedding mode selected; Phase 2 sidecar pending")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _init_voyage_client() -> None:
|
|
71
|
+
"""Initialize Voyage. Fails gracefully if VOYAGE_API_KEY is not set."""
|
|
72
|
+
global _voyage_client
|
|
73
|
+
try:
|
|
74
|
+
import voyageai
|
|
75
|
+
|
|
76
|
+
_voyage_client = voyageai.Client()
|
|
77
|
+
logger.info(
|
|
78
|
+
"Voyage AI client initialized (model=voyage-4-lite, dims=%d)",
|
|
79
|
+
DIMENSIONS,
|
|
80
|
+
)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.warning("Voyage AI client not available: %s", e)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_available() -> bool:
|
|
86
|
+
mode = _embedding_mode()
|
|
87
|
+
if mode == "voyage":
|
|
88
|
+
return _voyage_client is not None
|
|
89
|
+
if mode == "dual":
|
|
90
|
+
return _voyage_client is not None
|
|
91
|
+
if mode == "granite_local":
|
|
92
|
+
return _get_granite_client().can_attempt_load
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def embed_texts(
|
|
97
|
+
texts: list[str],
|
|
98
|
+
input_type: str = "document",
|
|
99
|
+
batch_size: int = 16,
|
|
100
|
+
) -> list[list[float]]:
|
|
101
|
+
"""Embed texts using the configured backend.
|
|
102
|
+
|
|
103
|
+
Public callers keep receiving 512-dimensional vectors because sqlite-vec's
|
|
104
|
+
vec_documents table is fixed at float[512].
|
|
105
|
+
"""
|
|
106
|
+
mode = _embedding_mode()
|
|
107
|
+
if mode == "voyage":
|
|
108
|
+
return await _embed_voyage_texts(texts, input_type=input_type)
|
|
109
|
+
if mode == "granite_local":
|
|
110
|
+
return await _embed_granite_texts(
|
|
111
|
+
texts,
|
|
112
|
+
input_type=input_type,
|
|
113
|
+
batch_size=batch_size,
|
|
114
|
+
)
|
|
115
|
+
if mode == "dual":
|
|
116
|
+
voyage_embeddings = await _embed_voyage_texts(texts, input_type=input_type)
|
|
117
|
+
try:
|
|
118
|
+
await _embed_granite_texts(
|
|
119
|
+
texts,
|
|
120
|
+
input_type=input_type,
|
|
121
|
+
batch_size=batch_size,
|
|
122
|
+
)
|
|
123
|
+
except Exception:
|
|
124
|
+
logger.exception("Granite dual-mode validation embedding failed")
|
|
125
|
+
return voyage_embeddings
|
|
126
|
+
raise NotImplementedError("Phase 2 sidecar Docker mode")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _embed_voyage_texts(
|
|
130
|
+
texts: list[str],
|
|
131
|
+
input_type: str = "document",
|
|
132
|
+
) -> list[list[float]]:
|
|
133
|
+
"""Embed texts using Voyage-4-lite. Uses asyncio.to_thread (SDK is sync)."""
|
|
134
|
+
if not _voyage_client:
|
|
135
|
+
raise RuntimeError("Voyage AI client not initialized")
|
|
136
|
+
result = await asyncio.to_thread(
|
|
137
|
+
_voyage_client.embed,
|
|
138
|
+
texts=texts,
|
|
139
|
+
model="voyage-4-lite",
|
|
140
|
+
input_type=input_type,
|
|
141
|
+
output_dimension=DIMENSIONS,
|
|
142
|
+
)
|
|
143
|
+
return result.embeddings
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _embed_granite_texts(
|
|
147
|
+
texts: list[str],
|
|
148
|
+
input_type: str,
|
|
149
|
+
batch_size: int,
|
|
150
|
+
) -> list[list[float]]:
|
|
151
|
+
client = _get_granite_client()
|
|
152
|
+
embeddings = await asyncio.to_thread(
|
|
153
|
+
client.embed_texts,
|
|
154
|
+
texts,
|
|
155
|
+
input_type=input_type,
|
|
156
|
+
batch_size=batch_size,
|
|
157
|
+
)
|
|
158
|
+
return [_coerce_dimensions_for_vec_documents(embedding) for embedding in embeddings]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _embedding_mode() -> EmbeddingMode:
|
|
162
|
+
raw = os.environ.get("EMBEDDING_MODE")
|
|
163
|
+
if raw is None or not raw.strip():
|
|
164
|
+
# No explicit mode: resolve on Voyage key PRESENCE, never on deploy_mode.
|
|
165
|
+
# OSS users have no VOYAGE_API_KEY -> granite_local (local engine).
|
|
166
|
+
# Prod has the key (deploy_mode=core but key present) -> voyage, unchanged.
|
|
167
|
+
return "voyage" if os.environ.get("VOYAGE_API_KEY", "").strip() else "granite_local"
|
|
168
|
+
mode = raw.strip().lower()
|
|
169
|
+
if mode not in _VALID_EMBEDDING_MODES:
|
|
170
|
+
logger.warning(
|
|
171
|
+
"Unknown EMBEDDING_MODE=%r; falling back to granite_local", mode
|
|
172
|
+
)
|
|
173
|
+
return "granite_local"
|
|
174
|
+
return cast(EmbeddingMode, mode)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _search_rrf_k() -> int:
|
|
178
|
+
return max(_int_from_env("SEARCH_RRF_K", DEFAULT_SEARCH_RRF_K), 1)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _search_salience_weight() -> float:
|
|
182
|
+
return max(_float_from_env("SEARCH_SALIENCE_WEIGHT", DEFAULT_SEARCH_SALIENCE_WEIGHT), 0.0)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _search_score_threshold() -> float:
|
|
186
|
+
return _float_from_env("SEARCH_SCORE_THRESHOLD", DEFAULT_SEARCH_SCORE_THRESHOLD)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _search_bm25_enabled() -> bool:
|
|
190
|
+
raw = os.environ.get("SEARCH_BM25_ENABLED", "true").strip().lower()
|
|
191
|
+
return raw not in {"0", "false", "no", "off"}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _int_from_env(name: str, default: int) -> int:
|
|
195
|
+
raw = os.environ.get(name)
|
|
196
|
+
if not raw:
|
|
197
|
+
return default
|
|
198
|
+
try:
|
|
199
|
+
return int(raw)
|
|
200
|
+
except ValueError:
|
|
201
|
+
logger.warning("Invalid %s=%r; using %d", name, raw, default)
|
|
202
|
+
return default
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _float_from_env(name: str, default: float) -> float:
|
|
206
|
+
raw = os.environ.get(name)
|
|
207
|
+
if not raw:
|
|
208
|
+
return default
|
|
209
|
+
try:
|
|
210
|
+
return float(raw)
|
|
211
|
+
except ValueError:
|
|
212
|
+
logger.warning("Invalid %s=%r; using %.3f", name, raw, default)
|
|
213
|
+
return default
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _get_granite_client():
|
|
217
|
+
global _granite_client
|
|
218
|
+
if _granite_client is None:
|
|
219
|
+
from core.api.services.embedding_internal import GraniteEmbeddingClient
|
|
220
|
+
|
|
221
|
+
_granite_client = GraniteEmbeddingClient(
|
|
222
|
+
device=os.environ.get("EMBEDDING_DEVICE", "cpu"),
|
|
223
|
+
)
|
|
224
|
+
return _granite_client
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _coerce_dimensions_for_vec_documents(vec: list[float]) -> list[float]:
|
|
228
|
+
if len(vec) == DIMENSIONS:
|
|
229
|
+
return vec
|
|
230
|
+
if len(vec) > DIMENSIONS:
|
|
231
|
+
return vec[:DIMENSIONS]
|
|
232
|
+
|
|
233
|
+
# W1 pragmatism: vec_documents is hardcoded as float[512]. Granite-97m emits
|
|
234
|
+
# native 384-dimensional, L2-normalized vectors; zero-padding preserves their
|
|
235
|
+
# geometry without a migration. Phase 2 can introduce a
|
|
236
|
+
# vec_documents_granite float[384] shadow table and route per backend.
|
|
237
|
+
return [*vec, *([0.0] * (DIMENSIONS - len(vec)))]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _empty_grouped_results() -> dict[str, list[dict]]:
|
|
241
|
+
return {
|
|
242
|
+
"task": [],
|
|
243
|
+
"project": [],
|
|
244
|
+
"file": [],
|
|
245
|
+
"handoff": [],
|
|
246
|
+
"learning": [],
|
|
247
|
+
"inbox_item": [],
|
|
248
|
+
"audit": [],
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _normalize_salience(value: object) -> float:
|
|
253
|
+
try:
|
|
254
|
+
salience = float(value) if value is not None else 0.5
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
salience = 0.5
|
|
257
|
+
return min(max(salience, 0.0), 1.0)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _reciprocal_rank_score(
|
|
261
|
+
rank_embedding: int | None,
|
|
262
|
+
rank_bm25: int | None,
|
|
263
|
+
*,
|
|
264
|
+
rrf_k: int,
|
|
265
|
+
) -> float:
|
|
266
|
+
score = 0.0
|
|
267
|
+
if rank_embedding is not None:
|
|
268
|
+
score += 1.0 / (rrf_k + rank_embedding)
|
|
269
|
+
if rank_bm25 is not None:
|
|
270
|
+
score += 1.0 / (rrf_k + rank_bm25)
|
|
271
|
+
return score
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _public_hybrid_score(
|
|
275
|
+
rrf_score: float,
|
|
276
|
+
salience: float,
|
|
277
|
+
*,
|
|
278
|
+
salience_weight: float,
|
|
279
|
+
max_rrf_score: float,
|
|
280
|
+
) -> float:
|
|
281
|
+
"""Map tiny RRF values onto the public 0-ish relevance scale before boost."""
|
|
282
|
+
normalized_rrf = rrf_score / max_rrf_score if max_rrf_score > 0 else 0.0
|
|
283
|
+
return normalized_rrf + salience_weight * _normalize_salience(salience)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _embedding_only_score(
|
|
287
|
+
distance: float,
|
|
288
|
+
salience: float,
|
|
289
|
+
*,
|
|
290
|
+
salience_weight: float,
|
|
291
|
+
) -> float:
|
|
292
|
+
similarity = 1.0 - (distance / 2.0)
|
|
293
|
+
similarity = min(max(similarity, 0.0), 1.0)
|
|
294
|
+
semantic_weight = max(1.0 - salience_weight, 0.0)
|
|
295
|
+
return similarity * semantic_weight + _normalize_salience(salience) * salience_weight
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _fuse_ranked_documents(
|
|
299
|
+
embedding_hits: Sequence[_EmbeddingHit],
|
|
300
|
+
bm25_hits: Sequence[_Bm25Hit],
|
|
301
|
+
salience_by_doc_id: Mapping[int, float],
|
|
302
|
+
*,
|
|
303
|
+
rrf_k: int,
|
|
304
|
+
salience_weight: float,
|
|
305
|
+
threshold: float,
|
|
306
|
+
allowed_doc_ids: set[int] | None = None,
|
|
307
|
+
) -> list[_HybridRankScore]:
|
|
308
|
+
embedding_ranks = {hit.doc_id: rank for rank, hit in enumerate(embedding_hits, start=1)}
|
|
309
|
+
bm25_ranks = {hit.doc_id: rank for rank, hit in enumerate(bm25_hits, start=1)}
|
|
310
|
+
ordered_doc_ids = list(dict.fromkeys([*embedding_ranks.keys(), *bm25_ranks.keys()]))
|
|
311
|
+
if allowed_doc_ids is not None:
|
|
312
|
+
ordered_doc_ids = [doc_id for doc_id in ordered_doc_ids if doc_id in allowed_doc_ids]
|
|
313
|
+
|
|
314
|
+
source_count = 1 + (1 if bm25_hits else 0)
|
|
315
|
+
max_rrf_score = source_count * (1.0 / (rrf_k + 1))
|
|
316
|
+
|
|
317
|
+
fused: list[_HybridRankScore] = []
|
|
318
|
+
for doc_id in ordered_doc_ids:
|
|
319
|
+
rank_embedding = embedding_ranks.get(doc_id)
|
|
320
|
+
rank_bm25 = bm25_ranks.get(doc_id)
|
|
321
|
+
rrf_score = _reciprocal_rank_score(
|
|
322
|
+
rank_embedding,
|
|
323
|
+
rank_bm25,
|
|
324
|
+
rrf_k=rrf_k,
|
|
325
|
+
)
|
|
326
|
+
score = _public_hybrid_score(
|
|
327
|
+
rrf_score,
|
|
328
|
+
salience_by_doc_id.get(doc_id, 0.5),
|
|
329
|
+
salience_weight=salience_weight,
|
|
330
|
+
max_rrf_score=max_rrf_score,
|
|
331
|
+
)
|
|
332
|
+
if score < threshold:
|
|
333
|
+
continue
|
|
334
|
+
fused.append(
|
|
335
|
+
_HybridRankScore(
|
|
336
|
+
doc_id=doc_id,
|
|
337
|
+
score=score,
|
|
338
|
+
rrf_score=rrf_score,
|
|
339
|
+
rank_embedding=rank_embedding,
|
|
340
|
+
rank_bm25=rank_bm25,
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def _best_rank(item: _HybridRankScore) -> int:
|
|
345
|
+
ranks = [r for r in (item.rank_embedding, item.rank_bm25) if r is not None]
|
|
346
|
+
return min(ranks) if ranks else 10**9
|
|
347
|
+
|
|
348
|
+
fused.sort(key=lambda item: (item.score, item.rrf_score, -_best_rank(item)), reverse=True)
|
|
349
|
+
return fused
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def serialize_f32(vec: list[float]) -> bytes:
|
|
353
|
+
"""Serialize float vector to binary format for sqlite-vec."""
|
|
354
|
+
vec = _coerce_dimensions_for_vec_documents(vec)
|
|
355
|
+
return struct.pack(f"{len(vec)}f", *vec)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def content_hash(text: str) -> str:
|
|
359
|
+
"""SHA-256 hash of content for change detection."""
|
|
360
|
+
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def upsert_document(
|
|
364
|
+
*,
|
|
365
|
+
file_path: str,
|
|
366
|
+
project: str,
|
|
367
|
+
workspace_id: str,
|
|
368
|
+
doc_type: str,
|
|
369
|
+
doc_title: str,
|
|
370
|
+
content: str,
|
|
371
|
+
db: aiosqlite.Connection,
|
|
372
|
+
vec_db: aiosqlite.Connection,
|
|
373
|
+
) -> bool:
|
|
374
|
+
"""Embed and upsert a single document. Returns True if embedding was updated."""
|
|
375
|
+
h = content_hash(content)
|
|
376
|
+
cur = await db.execute(
|
|
377
|
+
"SELECT id, content_hash FROM documents WHERE file_path = ?", [file_path]
|
|
378
|
+
)
|
|
379
|
+
row = await cur.fetchone()
|
|
380
|
+
if row and row["content_hash"] == h:
|
|
381
|
+
return False # unchanged
|
|
382
|
+
|
|
383
|
+
embeddings = await embed_texts([content], input_type="document")
|
|
384
|
+
vec_bytes = serialize_f32(embeddings[0])
|
|
385
|
+
|
|
386
|
+
await db.execute(
|
|
387
|
+
"""INSERT INTO documents (file_path, project, workspace_id, doc_type, doc_title, content_hash)
|
|
388
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
389
|
+
ON CONFLICT(file_path) DO UPDATE SET
|
|
390
|
+
content_hash = excluded.content_hash,
|
|
391
|
+
project = excluded.project,
|
|
392
|
+
workspace_id = excluded.workspace_id,
|
|
393
|
+
doc_type = excluded.doc_type,
|
|
394
|
+
doc_title = excluded.doc_title""",
|
|
395
|
+
[file_path, project, workspace_id, doc_type, doc_title, h],
|
|
396
|
+
)
|
|
397
|
+
cur2 = await db.execute("SELECT id FROM documents WHERE file_path = ?", [file_path])
|
|
398
|
+
doc_row = await cur2.fetchone()
|
|
399
|
+
doc_id = doc_row["id"]
|
|
400
|
+
|
|
401
|
+
# vec0 virtual table does not support ON CONFLICT/UPSERT — DELETE + INSERT
|
|
402
|
+
await vec_db.execute("DELETE FROM vec_documents WHERE doc_id = ?", [doc_id])
|
|
403
|
+
await vec_db.execute(
|
|
404
|
+
"INSERT INTO vec_documents (doc_id, embedding) VALUES (?, ?)",
|
|
405
|
+
[doc_id, vec_bytes],
|
|
406
|
+
)
|
|
407
|
+
await db.commit()
|
|
408
|
+
await vec_db.commit()
|
|
409
|
+
return True
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
async def embed_task_document(
|
|
413
|
+
*,
|
|
414
|
+
task_id: str,
|
|
415
|
+
title: str,
|
|
416
|
+
project: str,
|
|
417
|
+
status: str,
|
|
418
|
+
workspace_id: str,
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Embed a task into documents + vec_documents on the single-writer pool.
|
|
421
|
+
|
|
422
|
+
The ONE fastapi-free implementation of the task auto-embed body. Both the HTTP
|
|
423
|
+
surface (``routers.tasks._schedule_embed_task``) and the MCP surface
|
|
424
|
+
(``mcp._adapter.mcp_schedule_embed``) call this — no fork. Callers schedule it
|
|
425
|
+
fire-and-forget; this function owns the embed-then-write sequencing:
|
|
426
|
+
|
|
427
|
+
1. GATHER (slow, ~10-15s for Voyage / model inference) the embedding OUTSIDE
|
|
428
|
+
the write lock — building the content string and calling ``embed_texts``.
|
|
429
|
+
2. WRITE (fast, <10ms) the documents row + vec_documents vector INSIDE one
|
|
430
|
+
``write_db`` acquisition. The single-writer ``asyncio.Lock`` is NOT
|
|
431
|
+
reentrant, so we never hold it across the embed call (learning f83f5209).
|
|
432
|
+
|
|
433
|
+
Writes go through ``write_db`` (the writer pool); the read pool is
|
|
434
|
+
``query_only=ON`` and would fail any INSERT (learning 6130bc49). No-ops if the
|
|
435
|
+
embedder is unavailable — the caller is expected to gate on ``is_available()``
|
|
436
|
+
too, but this guard keeps the helper safe to call directly.
|
|
437
|
+
"""
|
|
438
|
+
if not is_available():
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
content = f"{title}\nStatus: {status}\nProject: {project}"
|
|
442
|
+
# GATHER: embedding (HTTP to Voyage / local model inference) OUTSIDE the write lock.
|
|
443
|
+
h = content_hash(content)
|
|
444
|
+
embeddings = await embed_texts([content], input_type="document")
|
|
445
|
+
vec_bytes = serialize_f32(embeddings[0])
|
|
446
|
+
|
|
447
|
+
# WRITE: DB operations INSIDE the write lock (fast batch).
|
|
448
|
+
from core.api.db import ensure_vec_documents, write_db
|
|
449
|
+
|
|
450
|
+
async with write_db(label="tasks.auto_embed") as db:
|
|
451
|
+
if not await ensure_vec_documents(db):
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
cur = await db.execute(
|
|
455
|
+
"SELECT id, content_hash FROM documents WHERE file_path = ?",
|
|
456
|
+
[f"task:{task_id}"],
|
|
457
|
+
)
|
|
458
|
+
row = await cur.fetchone()
|
|
459
|
+
if row and row["content_hash"] == h:
|
|
460
|
+
return # unchanged
|
|
461
|
+
|
|
462
|
+
await db.execute(
|
|
463
|
+
"""INSERT INTO documents (file_path, project, workspace_id, doc_type, doc_title, content_hash)
|
|
464
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
465
|
+
ON CONFLICT(file_path) DO UPDATE SET
|
|
466
|
+
content_hash = excluded.content_hash, project = excluded.project,
|
|
467
|
+
workspace_id = excluded.workspace_id, doc_type = excluded.doc_type,
|
|
468
|
+
doc_title = excluded.doc_title""",
|
|
469
|
+
[f"task:{task_id}", project, workspace_id, "task", title, h],
|
|
470
|
+
)
|
|
471
|
+
cur2 = await db.execute(
|
|
472
|
+
"SELECT id FROM documents WHERE file_path = ?",
|
|
473
|
+
[f"task:{task_id}"],
|
|
474
|
+
)
|
|
475
|
+
doc_row = await cur2.fetchone()
|
|
476
|
+
doc_id = doc_row["id"]
|
|
477
|
+
await db.execute("DELETE FROM vec_documents WHERE doc_id = ?", [doc_id])
|
|
478
|
+
await db.execute(
|
|
479
|
+
"INSERT INTO vec_documents (doc_id, embedding) VALUES (?, ?)",
|
|
480
|
+
[doc_id, vec_bytes],
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
async def _ensure_vec_documents(db: aiosqlite.Connection, vec0_path: str) -> None:
|
|
485
|
+
vec_path = str(Path(vec0_path))
|
|
486
|
+
await db._execute(db._conn.enable_load_extension, True)
|
|
487
|
+
await db.execute("SELECT load_extension(?)", [vec_path])
|
|
488
|
+
await db.execute("""
|
|
489
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_documents USING vec0(
|
|
490
|
+
doc_id INTEGER PRIMARY KEY,
|
|
491
|
+
embedding float[512]
|
|
492
|
+
)
|
|
493
|
+
""")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
async def _embedding_knn_search(
|
|
497
|
+
db: aiosqlite.Connection,
|
|
498
|
+
vec_bytes: bytes,
|
|
499
|
+
limit: int,
|
|
500
|
+
) -> list[_EmbeddingHit]:
|
|
501
|
+
cur = await db.execute(
|
|
502
|
+
"""SELECT v.doc_id, v.distance
|
|
503
|
+
FROM vec_documents v
|
|
504
|
+
WHERE v.embedding MATCH ? AND k = ?
|
|
505
|
+
ORDER BY v.distance""",
|
|
506
|
+
[vec_bytes, limit],
|
|
507
|
+
)
|
|
508
|
+
rows = await cur.fetchall()
|
|
509
|
+
return [
|
|
510
|
+
_EmbeddingHit(doc_id=int(row["doc_id"]), distance=float(row["distance"]))
|
|
511
|
+
for row in rows
|
|
512
|
+
]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
async def _bm25_documents_search(
|
|
516
|
+
db: aiosqlite.Connection,
|
|
517
|
+
query: str,
|
|
518
|
+
*,
|
|
519
|
+
limit: int,
|
|
520
|
+
) -> list[_Bm25Hit]:
|
|
521
|
+
if not query.strip():
|
|
522
|
+
return []
|
|
523
|
+
try:
|
|
524
|
+
cur = await db.execute(
|
|
525
|
+
"""SELECT rowid AS doc_id, bm25(documents_fts) AS score
|
|
526
|
+
FROM documents_fts
|
|
527
|
+
WHERE documents_fts MATCH ?
|
|
528
|
+
ORDER BY score
|
|
529
|
+
LIMIT ?""",
|
|
530
|
+
[query, limit],
|
|
531
|
+
)
|
|
532
|
+
rows = await cur.fetchall()
|
|
533
|
+
except aiosqlite.OperationalError as exc:
|
|
534
|
+
msg = str(exc).lower()
|
|
535
|
+
if "no such table" in msg or "syntax" in msg or "malformed match" in msg:
|
|
536
|
+
return []
|
|
537
|
+
raise
|
|
538
|
+
return [
|
|
539
|
+
_Bm25Hit(doc_id=int(row["doc_id"]), score=float(row["score"] or 0.0))
|
|
540
|
+
for row in rows
|
|
541
|
+
]
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
async def _document_columns(db: aiosqlite.Connection) -> set[str]:
|
|
545
|
+
cur = await db.execute("PRAGMA table_info(documents)")
|
|
546
|
+
return {str(row["name"]) for row in await cur.fetchall()}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
async def _fetch_document_rows(
|
|
550
|
+
db: aiosqlite.Connection,
|
|
551
|
+
doc_ids: Sequence[int],
|
|
552
|
+
workspace_id: str,
|
|
553
|
+
) -> dict[int, dict]:
|
|
554
|
+
if not doc_ids:
|
|
555
|
+
return {}
|
|
556
|
+
|
|
557
|
+
columns = await _document_columns(db)
|
|
558
|
+
doc_type_expr = "doc_type" if "doc_type" in columns else "'file' AS doc_type"
|
|
559
|
+
title_expr = "doc_title" if "doc_title" in columns else "file_path AS doc_title"
|
|
560
|
+
salience_expr = "salience" if "salience" in columns else "0.5 AS salience"
|
|
561
|
+
workspace_filter = ""
|
|
562
|
+
params: list[object] = list(doc_ids)
|
|
563
|
+
if "workspace_id" in columns:
|
|
564
|
+
workspace_filter = "AND COALESCE(workspace_id, 'ws_default') = ?"
|
|
565
|
+
params.append(workspace_id)
|
|
566
|
+
archived_filter = ""
|
|
567
|
+
if "archived" in columns:
|
|
568
|
+
archived_filter = "AND COALESCE(archived, 0) = 0"
|
|
569
|
+
|
|
570
|
+
placeholders = ",".join("?" * len(doc_ids))
|
|
571
|
+
cur = await db.execute(
|
|
572
|
+
f"""SELECT id, file_path, project, {doc_type_expr}, {title_expr}, {salience_expr}
|
|
573
|
+
FROM documents
|
|
574
|
+
WHERE id IN ({placeholders})
|
|
575
|
+
{workspace_filter}
|
|
576
|
+
{archived_filter}""",
|
|
577
|
+
params,
|
|
578
|
+
)
|
|
579
|
+
rows = await cur.fetchall()
|
|
580
|
+
return {int(row["id"]): dict(row) for row in rows}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _doc_entity_id(file_path: str) -> str:
|
|
584
|
+
return file_path.split(":", 1)[1] if ":" in file_path else file_path
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _base_result_item(row: Mapping[str, object]) -> dict:
|
|
588
|
+
file_path = str(row["file_path"])
|
|
589
|
+
salience = _normalize_salience(row.get("salience"))
|
|
590
|
+
return {
|
|
591
|
+
"doc_id": _doc_entity_id(file_path),
|
|
592
|
+
"title": row.get("doc_title") or file_path,
|
|
593
|
+
"project": row.get("project") or "",
|
|
594
|
+
"path": file_path,
|
|
595
|
+
"salience": salience,
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _group_ranked_rows(
|
|
600
|
+
ranked_scores: Sequence[_HybridRankScore],
|
|
601
|
+
rows_by_doc_id: Mapping[int, Mapping[str, object]],
|
|
602
|
+
*,
|
|
603
|
+
top_k: int,
|
|
604
|
+
) -> dict[str, list[dict]]:
|
|
605
|
+
results = _empty_grouped_results()
|
|
606
|
+
for ranked in ranked_scores:
|
|
607
|
+
row = rows_by_doc_id.get(ranked.doc_id)
|
|
608
|
+
if row is None:
|
|
609
|
+
continue
|
|
610
|
+
doc_type = str(row.get("doc_type") or "file")
|
|
611
|
+
if doc_type not in results:
|
|
612
|
+
continue
|
|
613
|
+
item = _base_result_item(row)
|
|
614
|
+
item["score"] = round(ranked.score, 4)
|
|
615
|
+
item["rrf_score"] = round(ranked.rrf_score, 6)
|
|
616
|
+
item["rank_embedding"] = ranked.rank_embedding
|
|
617
|
+
item["rank_bm25"] = ranked.rank_bm25
|
|
618
|
+
results[doc_type].append(item)
|
|
619
|
+
|
|
620
|
+
for doc_type in results:
|
|
621
|
+
results[doc_type].sort(key=lambda item: item["score"], reverse=True)
|
|
622
|
+
results[doc_type] = results[doc_type][:top_k]
|
|
623
|
+
return results
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _group_embedding_only_rows(
|
|
627
|
+
embedding_hits: Sequence[_EmbeddingHit],
|
|
628
|
+
rows_by_doc_id: Mapping[int, Mapping[str, object]],
|
|
629
|
+
*,
|
|
630
|
+
top_k: int,
|
|
631
|
+
salience_weight: float,
|
|
632
|
+
threshold: float,
|
|
633
|
+
) -> dict[str, list[dict]]:
|
|
634
|
+
results = _empty_grouped_results()
|
|
635
|
+
for hit in embedding_hits:
|
|
636
|
+
row = rows_by_doc_id.get(hit.doc_id)
|
|
637
|
+
if row is None:
|
|
638
|
+
continue
|
|
639
|
+
doc_type = str(row.get("doc_type") or "file")
|
|
640
|
+
if doc_type not in results:
|
|
641
|
+
continue
|
|
642
|
+
salience = _normalize_salience(row.get("salience"))
|
|
643
|
+
score = _embedding_only_score(
|
|
644
|
+
hit.distance,
|
|
645
|
+
salience,
|
|
646
|
+
salience_weight=salience_weight,
|
|
647
|
+
)
|
|
648
|
+
if score < threshold:
|
|
649
|
+
continue
|
|
650
|
+
item = _base_result_item(row)
|
|
651
|
+
item["score"] = round(score, 4)
|
|
652
|
+
results[doc_type].append(item)
|
|
653
|
+
|
|
654
|
+
for doc_type in results:
|
|
655
|
+
results[doc_type].sort(key=lambda item: item["score"], reverse=True)
|
|
656
|
+
results[doc_type] = results[doc_type][:top_k]
|
|
657
|
+
return results
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
async def search_by_type(
|
|
661
|
+
query: str,
|
|
662
|
+
workspace_id: str,
|
|
663
|
+
db_path: str,
|
|
664
|
+
vec0_path: str,
|
|
665
|
+
top_k: int = 5,
|
|
666
|
+
) -> dict[str, list[dict]]:
|
|
667
|
+
"""Hybrid RRF search across all doc_types. Returns grouped results.
|
|
668
|
+
|
|
669
|
+
Uses separate db + vec_db connections (NOT the request-scoped pooled connections)
|
|
670
|
+
because aiosqlite uses ThreadPoolExecutor(max_workers=1) per connection, so queries
|
|
671
|
+
on the same connection are serialized. Two connections = true parallel I/O.
|
|
672
|
+
|
|
673
|
+
Default behavior fuses sqlite-vec semantic KNN with documents_fts BM25
|
|
674
|
+
using Reciprocal Rank Fusion, then applies the salience boost. Set
|
|
675
|
+
SEARCH_BM25_ENABLED=false to use the legacy embedding-only scorer.
|
|
676
|
+
"""
|
|
677
|
+
if not is_available():
|
|
678
|
+
raise RuntimeError("Embedding client not initialized")
|
|
679
|
+
|
|
680
|
+
embeddings = await embed_texts([query], input_type="query")
|
|
681
|
+
vec_bytes = serialize_f32(embeddings[0])
|
|
682
|
+
|
|
683
|
+
# Open dedicated connections — never share request-scoped connections
|
|
684
|
+
db = await aiosqlite.connect(db_path)
|
|
685
|
+
db.row_factory = aiosqlite.Row
|
|
686
|
+
await db.execute("PRAGMA journal_mode=WAL")
|
|
687
|
+
await db.commit()
|
|
688
|
+
|
|
689
|
+
vec_db = await aiosqlite.connect(db_path)
|
|
690
|
+
vec_db.row_factory = aiosqlite.Row
|
|
691
|
+
await vec_db.execute("PRAGMA journal_mode=WAL")
|
|
692
|
+
await vec_db.commit()
|
|
693
|
+
|
|
694
|
+
try:
|
|
695
|
+
await _ensure_vec_documents(vec_db, vec0_path)
|
|
696
|
+
except Exception as e:
|
|
697
|
+
await db.close()
|
|
698
|
+
await vec_db.close()
|
|
699
|
+
raise RuntimeError(f"sqlite-vec not available: {e}") from e
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
# Single KNN with large k, then group by doc_type. Previous approach
|
|
703
|
+
# ran the same KNN 4x with k=20, so files/handoffs were drowned out by
|
|
704
|
+
# task-heavy top-20 results.
|
|
705
|
+
fetch_k = max(BM25_FETCH_LIMIT, top_k * 20)
|
|
706
|
+
embedding_hits = await _embedding_knn_search(vec_db, vec_bytes, fetch_k)
|
|
707
|
+
if not embedding_hits:
|
|
708
|
+
return _empty_grouped_results()
|
|
709
|
+
|
|
710
|
+
bm25_enabled = _search_bm25_enabled()
|
|
711
|
+
bm25_hits: list[_Bm25Hit] = []
|
|
712
|
+
if bm25_enabled:
|
|
713
|
+
bm25_hits = await _bm25_documents_search(
|
|
714
|
+
db,
|
|
715
|
+
query,
|
|
716
|
+
limit=BM25_FETCH_LIMIT,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
candidate_doc_ids = list(
|
|
720
|
+
dict.fromkeys([hit.doc_id for hit in embedding_hits] + [hit.doc_id for hit in bm25_hits])
|
|
721
|
+
)
|
|
722
|
+
doc_rows = await _fetch_document_rows(db, candidate_doc_ids, workspace_id)
|
|
723
|
+
if not doc_rows:
|
|
724
|
+
return _empty_grouped_results()
|
|
725
|
+
|
|
726
|
+
if bm25_enabled:
|
|
727
|
+
salience_by_doc_id = {
|
|
728
|
+
doc_id: _normalize_salience(row["salience"])
|
|
729
|
+
for doc_id, row in doc_rows.items()
|
|
730
|
+
}
|
|
731
|
+
ranked_scores = _fuse_ranked_documents(
|
|
732
|
+
embedding_hits,
|
|
733
|
+
bm25_hits,
|
|
734
|
+
salience_by_doc_id,
|
|
735
|
+
rrf_k=_search_rrf_k(),
|
|
736
|
+
salience_weight=_search_salience_weight(),
|
|
737
|
+
threshold=_search_score_threshold(),
|
|
738
|
+
allowed_doc_ids=set(doc_rows),
|
|
739
|
+
)
|
|
740
|
+
return _group_ranked_rows(ranked_scores, doc_rows, top_k=top_k)
|
|
741
|
+
|
|
742
|
+
return _group_embedding_only_rows(
|
|
743
|
+
embedding_hits,
|
|
744
|
+
doc_rows,
|
|
745
|
+
top_k=top_k,
|
|
746
|
+
salience_weight=_search_salience_weight(),
|
|
747
|
+
threshold=_search_score_threshold(),
|
|
748
|
+
)
|
|
749
|
+
finally:
|
|
750
|
+
await db.close()
|
|
751
|
+
await vec_db.close()
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
async def reindex_project(project: str, db: aiosqlite.Connection) -> dict:
|
|
755
|
+
"""Reindex all handoff files for a project. Single connection for atomicity."""
|
|
756
|
+
if not is_available():
|
|
757
|
+
raise RuntimeError("Embedding client not initialized")
|
|
758
|
+
|
|
759
|
+
# Find handoff directory
|
|
760
|
+
import core.api.routers.projects as _projects_mod
|
|
761
|
+
project_path = _projects_mod._find_project_path(project)
|
|
762
|
+
if not project_path:
|
|
763
|
+
return {"error": "Project not found", "indexed": 0}
|
|
764
|
+
|
|
765
|
+
memory_dir = project_path / "memory"
|
|
766
|
+
if not memory_dir.is_dir():
|
|
767
|
+
return {"error": "No memory directory", "indexed": 0}
|
|
768
|
+
|
|
769
|
+
# Cleanup orphans
|
|
770
|
+
await db.execute("DELETE FROM vec_documents WHERE doc_id NOT IN (SELECT id FROM documents)")
|
|
771
|
+
|
|
772
|
+
# Load existing hashes
|
|
773
|
+
cursor = await db.execute(
|
|
774
|
+
"SELECT file_path, content_hash FROM documents WHERE project = ?",
|
|
775
|
+
[project],
|
|
776
|
+
)
|
|
777
|
+
existing = {row["file_path"]: row["content_hash"] for row in await cursor.fetchall()}
|
|
778
|
+
|
|
779
|
+
# Scan files and find changed ones
|
|
780
|
+
to_embed: list[tuple[str, str]] = [] # (file_path, content)
|
|
781
|
+
for f in sorted(memory_dir.glob("handoff-*.md")):
|
|
782
|
+
if f.is_symlink():
|
|
783
|
+
continue
|
|
784
|
+
try:
|
|
785
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
786
|
+
except OSError:
|
|
787
|
+
continue
|
|
788
|
+
fpath = str(f)
|
|
789
|
+
h = content_hash(text)
|
|
790
|
+
if existing.get(fpath) == h:
|
|
791
|
+
continue
|
|
792
|
+
to_embed.append((fpath, text))
|
|
793
|
+
|
|
794
|
+
if not to_embed:
|
|
795
|
+
return {"indexed": 0, "skipped": len(existing), "message": "All up to date"}
|
|
796
|
+
|
|
797
|
+
# Batch embed (Voyage max 128 per call)
|
|
798
|
+
indexed = 0
|
|
799
|
+
batch_size = 64
|
|
800
|
+
for i in range(0, len(to_embed), batch_size):
|
|
801
|
+
batch = to_embed[i:i + batch_size]
|
|
802
|
+
texts = [content for _, content in batch]
|
|
803
|
+
embeddings = await embed_texts(texts, input_type="document")
|
|
804
|
+
|
|
805
|
+
for (fpath, text), embedding in zip(batch, embeddings):
|
|
806
|
+
h = content_hash(text)
|
|
807
|
+
# Upsert document
|
|
808
|
+
await db.execute(
|
|
809
|
+
"INSERT INTO documents (file_path, project, content_hash) VALUES (?, ?, ?) "
|
|
810
|
+
"ON CONFLICT(file_path) DO UPDATE SET content_hash = excluded.content_hash",
|
|
811
|
+
[fpath, project, h],
|
|
812
|
+
)
|
|
813
|
+
# Get doc_id
|
|
814
|
+
cur = await db.execute("SELECT id FROM documents WHERE file_path = ?", [fpath])
|
|
815
|
+
doc_row = await cur.fetchone()
|
|
816
|
+
doc_id = doc_row["id"]
|
|
817
|
+
|
|
818
|
+
# vec0 virtual table does not support ON CONFLICT — DELETE + INSERT
|
|
819
|
+
await db.execute("DELETE FROM vec_documents WHERE doc_id = ?", [doc_id])
|
|
820
|
+
await db.execute(
|
|
821
|
+
"INSERT INTO vec_documents (doc_id, embedding) VALUES (?, ?)",
|
|
822
|
+
[doc_id, serialize_f32(embedding)],
|
|
823
|
+
)
|
|
824
|
+
indexed += 1
|
|
825
|
+
|
|
826
|
+
await db.commit()
|
|
827
|
+
|
|
828
|
+
# Rate limit: 1s between batches
|
|
829
|
+
if i + batch_size < len(to_embed):
|
|
830
|
+
await asyncio.sleep(1)
|
|
831
|
+
|
|
832
|
+
return {"indexed": indexed, "skipped": len(existing) - indexed, "total_files": len(to_embed) + len(existing)}
|