devcouncil 0.3.1 → 0.4.0
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.
- package/README.md +46 -30
- package/package.json +6 -2
- package/packages/codeintel-grammars/hatch_build.py +43 -0
- package/packages/codeintel-grammars/pyproject.toml +16 -0
- package/packages/codeintel-grammars/src/devcouncil_codeintel_grammars/__init__.py +93 -0
- package/pyproject.toml +99 -4
- package/src/devcouncil/app/config.py +512 -20
- package/src/devcouncil/app/events.py +4 -23
- package/src/devcouncil/app/orchestrator.py +5 -0
- package/src/devcouncil/app/run_context.py +3 -3
- package/src/devcouncil/assets/__init__.py +4 -1
- package/src/devcouncil/assets/vendor/force-graph.min.js +5 -0
- package/src/devcouncil/campaign/__init__.py +71 -0
- package/src/devcouncil/campaign/bloom.py +137 -0
- package/src/devcouncil/campaign/dashboard.py +123 -0
- package/src/devcouncil/campaign/mailbox.py +305 -0
- package/src/devcouncil/campaign/notify.py +91 -0
- package/src/devcouncil/campaign/orchestrator.py +592 -0
- package/src/devcouncil/campaign/prompts/coordinator.md +29 -0
- package/src/devcouncil/campaign/prompts/director.md +21 -0
- package/src/devcouncil/campaign/prompts/protocol.md +46 -0
- package/src/devcouncil/campaign/prompts/reviewer.md +24 -0
- package/src/devcouncil/campaign/prompts/worker.md +24 -0
- package/src/devcouncil/campaign/roles.py +202 -0
- package/src/devcouncil/campaign/watcher.py +153 -0
- package/src/devcouncil/cli/commands/agents.py +24 -17
- package/src/devcouncil/cli/commands/artifacts.py +36 -27
- package/src/devcouncil/cli/commands/ast.py +12 -3
- package/src/devcouncil/cli/commands/baseline.py +21 -12
- package/src/devcouncil/cli/commands/boot.py +218 -0
- package/src/devcouncil/cli/commands/campaign.py +302 -0
- package/src/devcouncil/cli/commands/check.py +225 -12
- package/src/devcouncil/cli/commands/config.py +221 -74
- package/src/devcouncil/cli/commands/cost.py +137 -28
- package/src/devcouncil/cli/commands/dashboard.py +12 -4
- package/src/devcouncil/cli/commands/debug_cmd.py +249 -0
- package/src/devcouncil/cli/commands/design.py +27 -17
- package/src/devcouncil/cli/commands/doctor.py +790 -8
- package/src/devcouncil/cli/commands/evidence.py +41 -20
- package/src/devcouncil/cli/commands/export.py +73 -0
- package/src/devcouncil/cli/commands/gaps.py +175 -0
- package/src/devcouncil/cli/commands/gated_write.py +76 -0
- package/src/devcouncil/cli/commands/go.py +220 -68
- package/src/devcouncil/cli/commands/graph_cmd.py +1192 -0
- package/src/devcouncil/cli/commands/handoff.py +45 -34
- package/src/devcouncil/cli/commands/hook.py +630 -85
- package/src/devcouncil/cli/commands/init.py +89 -30
- package/src/devcouncil/cli/commands/integrate.py +296 -1385
- package/src/devcouncil/cli/commands/lease.py +120 -0
- package/src/devcouncil/cli/commands/logs.py +12 -5
- package/src/devcouncil/cli/commands/lsp.py +40 -5
- package/src/devcouncil/cli/commands/map.py +317 -74
- package/src/devcouncil/cli/commands/mcp_server.py +12 -2
- package/src/devcouncil/cli/commands/okf.py +44 -6
- package/src/devcouncil/cli/commands/plan.py +184 -69
- package/src/devcouncil/cli/commands/prompt.py +26 -17
- package/src/devcouncil/cli/commands/provenance.py +79 -0
- package/src/devcouncil/cli/commands/repair.py +60 -49
- package/src/devcouncil/cli/commands/report.py +148 -40
- package/src/devcouncil/cli/commands/requirements.py +104 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +13 -4
- package/src/devcouncil/cli/commands/rollback.py +46 -35
- package/src/devcouncil/cli/commands/run.py +173 -8
- package/src/devcouncil/cli/commands/runs.py +298 -68
- package/src/devcouncil/cli/commands/scaffold.py +33 -12
- package/src/devcouncil/cli/commands/semantic.py +29 -14
- package/src/devcouncil/cli/commands/setup.py +103 -93
- package/src/devcouncil/cli/commands/shell.py +51 -42
- package/src/devcouncil/cli/commands/show.py +56 -42
- package/src/devcouncil/cli/commands/skills.py +29 -20
- package/src/devcouncil/cli/commands/status.py +80 -67
- package/src/devcouncil/cli/commands/task_gate.py +295 -0
- package/src/devcouncil/cli/commands/tasks.py +248 -19
- package/src/devcouncil/cli/commands/trace.py +14 -8
- package/src/devcouncil/cli/commands/verify.py +33 -8
- package/src/devcouncil/cli/commands/version.py +14 -6
- package/src/devcouncil/cli/commands/watch.py +49 -30
- package/src/devcouncil/cli/commands/watch_fs.py +30 -19
- package/src/devcouncil/cli/commands/wiki.py +278 -0
- package/src/devcouncil/cli/main.py +58 -1
- package/src/devcouncil/codeintel/__init__.py +16 -0
- package/src/devcouncil/codeintel/build_control.py +429 -0
- package/src/devcouncil/codeintel/build_worker.py +78 -0
- package/src/devcouncil/codeintel/debug/__init__.py +17 -0
- package/src/devcouncil/codeintel/debug/broker.py +114 -0
- package/src/devcouncil/codeintel/debug/broker_client.py +61 -0
- package/src/devcouncil/codeintel/debug/consent.py +36 -0
- package/src/devcouncil/codeintel/debug/discovery.py +132 -0
- package/src/devcouncil/codeintel/debug/fingerprint.py +85 -0
- package/src/devcouncil/codeintel/debug/protocol.py +259 -0
- package/src/devcouncil/codeintel/debug/python_trace_runner.py +81 -0
- package/src/devcouncil/codeintel/debug/session.py +238 -0
- package/src/devcouncil/codeintel/debug/tracing.py +201 -0
- package/src/devcouncil/codeintel/languages/__init__.py +17 -0
- package/src/devcouncil/codeintel/languages/generic_extractor.py +236 -0
- package/src/devcouncil/codeintel/languages/registry.py +149 -0
- package/src/devcouncil/codeintel/languages/workers.py +245 -0
- package/src/devcouncil/codeintel/query/__init__.py +5 -0
- package/src/devcouncil/codeintel/query/engine.py +289 -0
- package/src/devcouncil/codeintel/resolution/__init__.py +6 -0
- package/src/devcouncil/codeintel/resolution/abstract_state.py +301 -0
- package/src/devcouncil/codeintel/resolution/frameworks/__init__.py +33 -0
- package/src/devcouncil/codeintel/resolution/frameworks/base.py +46 -0
- package/src/devcouncil/codeintel/resolution/frameworks/di.py +56 -0
- package/src/devcouncil/codeintel/resolution/frameworks/events.py +45 -0
- package/src/devcouncil/codeintel/resolution/frameworks/routes.py +88 -0
- package/src/devcouncil/codeintel/resolution/semantic.py +887 -0
- package/src/devcouncil/codeintel/service.py +104 -0
- package/src/devcouncil/codeintel/store/__init__.py +15 -0
- package/src/devcouncil/codeintel/store/sqlite.py +1565 -0
- package/src/devcouncil/codeintel/sync/__init__.py +19 -0
- package/src/devcouncil/codeintel/sync/coordinator.py +430 -0
- package/src/devcouncil/codeintel/sync/incremental.py +484 -0
- package/src/devcouncil/codeintel/sync/lease.py +96 -0
- package/src/devcouncil/codeintel/sync/scope.py +98 -0
- package/src/devcouncil/council/__init__.py +4 -0
- package/src/devcouncil/council/prompts/__init__.py +4 -0
- package/src/devcouncil/domain/checkpoint_refs.py +17 -0
- package/src/devcouncil/domain/evidence.py +1 -0
- package/src/devcouncil/domain/gap.py +10 -0
- package/src/devcouncil/domain/requirement.py +5 -1
- package/src/devcouncil/domain/task.py +43 -2
- package/src/devcouncil/execution/checkpoints.py +25 -31
- package/src/devcouncil/execution/context_builder.py +15 -44
- package/src/devcouncil/execution/fs_watcher.py +64 -0
- package/src/devcouncil/execution/gated_write.py +203 -0
- package/src/devcouncil/execution/handoff.py +2 -1
- package/src/devcouncil/execution/hook_policy.py +19 -5
- package/src/devcouncil/execution/lease_ops.py +177 -0
- package/src/devcouncil/execution/lease_validation.py +71 -0
- package/src/devcouncil/execution/patch.py +3 -0
- package/src/devcouncil/execution/permissions.py +1 -0
- package/src/devcouncil/execution/policy_engine.py +205 -10
- package/src/devcouncil/execution/prompt_builder.py +278 -33
- package/src/devcouncil/execution/run_trace.py +356 -0
- package/src/devcouncil/execution/shell_session.py +46 -5
- package/src/devcouncil/execution/stop_gate.py +746 -0
- package/src/devcouncil/execution/stop_gate_history.py +113 -0
- package/src/devcouncil/execution/stop_gate_state.py +54 -0
- package/src/devcouncil/execution/stop_gate_verify_cache.py +69 -0
- package/src/devcouncil/execution/task_gate_ops.py +590 -0
- package/src/devcouncil/execution/task_runner.py +19 -0
- package/src/devcouncil/executors/advisor_tool.py +315 -0
- package/src/devcouncil/executors/agent_registry.py +125 -17
- package/src/devcouncil/executors/claude_sdk.py +376 -0
- package/src/devcouncil/executors/coding_cli.py +724 -25
- package/src/devcouncil/executors/mini_swe.py +50 -8
- package/src/devcouncil/executors/native/agent.py +224 -19
- package/src/devcouncil/executors/openhands.py +50 -8
- package/src/devcouncil/executors/transient_retry.py +99 -0
- package/src/devcouncil/gating/checks/clean_git.py +5 -2
- package/src/devcouncil/gating/checks/planned_files_check.py +38 -11
- package/src/devcouncil/gating/checks/secret_scan_check.py +2 -2
- package/src/devcouncil/gating/policy.py +46 -2
- package/src/devcouncil/indexing/ast_matcher.py +41 -4
- package/src/devcouncil/indexing/graph/__init__.py +78 -0
- package/src/devcouncil/indexing/graph/api_routes.py +522 -0
- package/src/devcouncil/indexing/graph/build.py +862 -0
- package/src/devcouncil/indexing/graph/cache.py +329 -0
- package/src/devcouncil/indexing/graph/communities.py +28 -0
- package/src/devcouncil/indexing/graph/cypher.py +107 -0
- package/src/devcouncil/indexing/graph/embeddings.py +194 -0
- package/src/devcouncil/indexing/graph/export.py +381 -0
- package/src/devcouncil/indexing/graph/export_links.py +81 -0
- package/src/devcouncil/indexing/graph/extract_python.py +307 -0
- package/src/devcouncil/indexing/graph/extract_ts.py +1205 -0
- package/src/devcouncil/indexing/graph/intel.py +668 -0
- package/src/devcouncil/indexing/graph/liveness.py +992 -0
- package/src/devcouncil/indexing/graph/okf_export.py +65 -0
- package/src/devcouncil/indexing/graph/pdg/__init__.py +67 -0
- package/src/devcouncil/indexing/graph/pdg/build.py +11 -0
- package/src/devcouncil/indexing/graph/pdg/cdg.py +41 -0
- package/src/devcouncil/indexing/graph/pdg/cfg.py +199 -0
- package/src/devcouncil/indexing/graph/pdg/query.py +21 -0
- package/src/devcouncil/indexing/graph/pdg/reaching_def.py +126 -0
- package/src/devcouncil/indexing/graph/pdg/schema.py +253 -0
- package/src/devcouncil/indexing/graph/pdg/taint.py +154 -0
- package/src/devcouncil/indexing/graph/query.py +302 -0
- package/src/devcouncil/indexing/graph/resolve.py +1020 -0
- package/src/devcouncil/indexing/graph/schema.py +103 -0
- package/src/devcouncil/indexing/graph_index.py +20 -29
- package/src/devcouncil/indexing/lsp.py +57 -25
- package/src/devcouncil/indexing/lsp_client.py +577 -0
- package/src/devcouncil/indexing/map_artifacts.py +355 -0
- package/src/devcouncil/indexing/map_refresh.py +141 -0
- package/src/devcouncil/indexing/repo_mapper.py +1509 -138
- package/src/devcouncil/indexing/semantic_index.py +12 -6
- package/src/devcouncil/indexing/subsystem_map.py +163 -0
- package/src/devcouncil/indexing/ts_imports.py +343 -0
- package/src/devcouncil/indexing/viz.py +960 -0
- package/src/devcouncil/indexing/walk.py +52 -0
- package/src/devcouncil/indexing/wiring.py +1776 -0
- package/src/devcouncil/integrations/actions.py +27 -4
- package/src/devcouncil/integrations/check.py +211 -16
- package/src/devcouncil/integrations/claude_assets.py +209 -12
- package/src/devcouncil/integrations/clients/__init__.py +1 -0
- package/src/devcouncil/integrations/clients/aider.py +52 -0
- package/src/devcouncil/integrations/clients/antigravity.py +87 -0
- package/src/devcouncil/integrations/clients/claude.py +339 -0
- package/src/devcouncil/integrations/clients/codex.py +39 -0
- package/src/devcouncil/integrations/clients/common.py +332 -0
- package/src/devcouncil/integrations/clients/cursor.py +164 -0
- package/src/devcouncil/integrations/clients/gemini.py +49 -0
- package/src/devcouncil/integrations/clients/grok.py +105 -0
- package/src/devcouncil/integrations/clients/hooks.py +500 -0
- package/src/devcouncil/integrations/clients/opencode.py +96 -0
- package/src/devcouncil/integrations/clients/warp.py +75 -0
- package/src/devcouncil/integrations/code_review_graph.py +2 -2
- package/src/devcouncil/integrations/github.py +73 -7
- package/src/devcouncil/integrations/integration_cli.py +197 -0
- package/src/devcouncil/integrations/mcp/handlers/__init__.py +1 -0
- package/src/devcouncil/integrations/mcp/handlers/ast_lsp.py +77 -0
- package/src/devcouncil/integrations/mcp/handlers/checkout.py +50 -0
- package/src/devcouncil/integrations/mcp/handlers/cli_gate.py +43 -0
- package/src/devcouncil/integrations/mcp/handlers/codeintel.py +182 -0
- package/src/devcouncil/integrations/mcp/handlers/debug.py +236 -0
- package/src/devcouncil/integrations/mcp/handlers/evidence.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/git.py +99 -0
- package/src/devcouncil/integrations/mcp/handlers/graph.py +36 -0
- package/src/devcouncil/integrations/mcp/handlers/handoff.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/knowledge.py +30 -0
- package/src/devcouncil/integrations/mcp/handlers/lease.py +70 -0
- package/src/devcouncil/integrations/mcp/handlers/live.py +108 -0
- package/src/devcouncil/integrations/mcp/handlers/map.py +676 -0
- package/src/devcouncil/integrations/mcp/handlers/next_task.py +35 -0
- package/src/devcouncil/integrations/mcp/handlers/policy.py +80 -0
- package/src/devcouncil/integrations/mcp/handlers/prompts.py +168 -0
- package/src/devcouncil/integrations/mcp/handlers/provenance.py +101 -0
- package/src/devcouncil/integrations/mcp/handlers/read.py +74 -0
- package/src/devcouncil/integrations/mcp/handlers/router_cache.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/run.py +53 -0
- package/src/devcouncil/integrations/mcp/handlers/runs.py +84 -0
- package/src/devcouncil/integrations/mcp/handlers/scope.py +56 -0
- package/src/devcouncil/integrations/mcp/handlers/status.py +114 -0
- package/src/devcouncil/integrations/mcp/handlers/task.py +100 -0
- package/src/devcouncil/integrations/mcp/handlers/tool_specs.py +904 -0
- package/src/devcouncil/integrations/mcp/handlers/trace.py +65 -0
- package/src/devcouncil/integrations/mcp/handlers/verify.py +45 -0
- package/src/devcouncil/integrations/mcp/handlers/wiki.py +60 -0
- package/src/devcouncil/integrations/mcp/handlers/write.py +69 -0
- package/src/devcouncil/integrations/mcp/server.py +265 -2391
- package/src/devcouncil/integrations/mcp/util.py +303 -0
- package/src/devcouncil/integrations/setup.py +152 -0
- package/src/devcouncil/knowledge/fetch.py +4 -0
- package/src/devcouncil/knowledge/knowledge_select.py +38 -0
- package/src/devcouncil/knowledge/okf.py +2 -1
- package/src/devcouncil/knowledge/resource_discovery.py +40 -0
- package/src/devcouncil/knowledge/wiki.py +643 -0
- package/src/devcouncil/knowledge/wiki_read.py +87 -0
- package/src/devcouncil/live/cards.py +7 -7
- package/src/devcouncil/live/models.py +4 -1
- package/src/devcouncil/live/reviewer.py +90 -11
- package/src/devcouncil/live/signals.py +4 -2
- package/src/devcouncil/live/tasks.py +12 -3
- package/src/devcouncil/live/transcripts.py +69 -2
- package/src/devcouncil/llm/cache.py +5 -6
- package/src/devcouncil/llm/model_defaults.yaml +10 -10
- package/src/devcouncil/llm/provider.py +647 -73
- package/src/devcouncil/llm/router.py +271 -46
- package/src/devcouncil/llm/semantic_bridge.py +614 -0
- package/src/devcouncil/optimization/gepa_agent.py +6 -4
- package/src/devcouncil/optimization/skillopt.py +9 -5
- package/src/devcouncil/planning/arbiter_service.py +12 -3
- package/src/devcouncil/planning/correction_manifest.py +107 -10
- package/src/devcouncil/planning/plan_difficulty.py +69 -0
- package/src/devcouncil/planning/plan_service.py +5 -2
- package/src/devcouncil/planning/planned_files_reconcile.py +191 -0
- package/src/devcouncil/planning/prompt_enhancer_service.py +6 -5
- package/src/devcouncil/planning/question_conversion.py +56 -0
- package/src/devcouncil/planning/spec_service.py +9 -3
- package/src/devcouncil/repo/ci_scaffold.py +197 -1
- package/src/devcouncil/repo/gitignore.py +1 -2
- package/src/devcouncil/reporting/evidence_export.py +124 -0
- package/src/devcouncil/reporting/evidence_html.py +210 -0
- package/src/devcouncil/reporting/json_report.py +16 -12
- package/src/devcouncil/reporting/markdown_report.py +38 -9
- package/src/devcouncil/reporting/mcp_resources.py +142 -0
- package/src/devcouncil/reporting/report_builder.py +40 -4
- package/src/devcouncil/reporting/task_provenance.py +42 -0
- package/src/devcouncil/reporting/verdict.py +75 -0
- package/src/devcouncil/skills/library/README.md +1 -0
- package/src/devcouncil/skills/library/devcouncil-hero-loop.md +109 -0
- package/src/devcouncil/skills/library/devcouncil-verification.md +109 -0
- package/src/devcouncil/skills/library/devcouncil.md +93 -0
- package/src/devcouncil/skills/registry.py +43 -12
- package/src/devcouncil/storage/db.py +57 -11
- package/src/devcouncil/storage/models.py +6 -0
- package/src/devcouncil/storage/native.py +5 -3
- package/src/devcouncil/storage/repositories.py +50 -18
- package/src/devcouncil/telemetry/context.py +28 -0
- package/src/devcouncil/telemetry/cost.py +4 -5
- package/src/devcouncil/telemetry/logging_setup.py +78 -11
- package/src/devcouncil/telemetry/model_pricing.yaml +7 -0
- package/src/devcouncil/telemetry/stages.py +27 -2
- package/src/devcouncil/telemetry/tracker.py +50 -13
- package/src/devcouncil/ui/dashboard.py +120 -8
- package/src/devcouncil/utils/fsio.py +58 -0
- package/src/devcouncil/utils/git_snapshot.py +112 -0
- package/src/devcouncil/utils/json_persist.py +53 -0
- package/src/devcouncil/utils/proc.py +89 -0
- package/src/devcouncil/verification/acceptance_compiler.py +36 -13
- package/src/devcouncil/verification/ad_hoc_check.py +95 -3
- package/src/devcouncil/verification/checks/__init__.py +41 -0
- package/src/devcouncil/verification/checks/acceptance.py +39 -0
- package/src/devcouncil/verification/checks/acceptance_corpus.py +194 -0
- package/src/devcouncil/verification/checks/acceptance_evidence.py +239 -0
- package/src/devcouncil/verification/checks/command_evidence.py +148 -0
- package/src/devcouncil/verification/checks/compiled_acceptance.py +179 -0
- package/src/devcouncil/verification/checks/corpus_stale.py +124 -0
- package/src/devcouncil/verification/checks/corpus_verification.py +9 -0
- package/src/devcouncil/verification/checks/dead_symbols.py +360 -0
- package/src/devcouncil/verification/checks/diff_coverage_gate.py +101 -0
- package/src/devcouncil/verification/checks/doc_code_ref.py +79 -0
- package/src/devcouncil/verification/checks/liveness_ratchet.py +336 -0
- package/src/devcouncil/verification/checks/orphan_diff.py +104 -0
- package/src/devcouncil/verification/checks/planned_files.py +98 -0
- package/src/devcouncil/verification/checks/semantic_diff.py +241 -0
- package/src/devcouncil/verification/checks/stale_map.py +80 -0
- package/src/devcouncil/verification/checks/stub_scan.py +71 -0
- package/src/devcouncil/verification/checks/subsystem_boundary.py +103 -0
- package/src/devcouncil/verification/checks/wiring.py +216 -0
- package/src/devcouncil/verification/claims/__init__.py +23 -0
- package/src/devcouncil/verification/claims/checks.py +395 -0
- package/src/devcouncil/verification/claims/mapper.py +168 -0
- package/src/devcouncil/verification/claims/models.py +39 -0
- package/src/devcouncil/verification/claims/transcript.py +92 -0
- package/src/devcouncil/verification/claims/verdict.py +88 -0
- package/src/devcouncil/verification/command_evidence.py +170 -0
- package/src/devcouncil/verification/command_malformation.py +147 -0
- package/src/devcouncil/verification/command_runner.py +164 -0
- package/src/devcouncil/verification/coverage_measurement.py +292 -0
- package/src/devcouncil/verification/diff_coverage.py +151 -0
- package/src/devcouncil/verification/difficulty.py +296 -0
- package/src/devcouncil/verification/effort_heuristics.py +178 -0
- package/src/devcouncil/verification/gap_ids.py +63 -0
- package/src/devcouncil/verification/gate_cache.py +194 -0
- package/src/devcouncil/verification/gate_selector.py +344 -0
- package/src/devcouncil/verification/git_diff_fallback.py +272 -0
- package/src/devcouncil/verification/implementation_reviewer.py +13 -0
- package/src/devcouncil/verification/incremental_check.py +241 -0
- package/src/devcouncil/verification/next_actions.py +60 -1
- package/src/devcouncil/verification/rigor_analytics.py +130 -0
- package/src/devcouncil/verification/sandbox.py +38 -11
- package/src/devcouncil/verification/stub_detector.py +369 -0
- package/src/devcouncil/verification/test_resolver.py +67 -1
- package/src/devcouncil/verification/verifier.py +137 -1666
- package/src/devcouncil/verification/verify_orchestration.py +610 -0
- package/src/devcouncil/verification/verify_setup.py +176 -0
- package/src/devcouncil/verification/wiki_refresh.py +208 -0
- package/src/semantic_layer/__init__.py +58 -0
- package/src/semantic_layer/benchmark.py +75 -0
- package/src/semantic_layer/cache.py +290 -0
- package/src/semantic_layer/compressor.py +137 -0
- package/src/semantic_layer/config.py +75 -0
- package/src/semantic_layer/embeddings.py +69 -0
- package/src/semantic_layer/llm_backends.py +99 -0
- package/src/semantic_layer/pipeline.py +111 -0
- package/src/semantic_layer/router.py +128 -0
- package/src/semantic_layer/tuner.py +72 -0
- package/uv.lock +973 -9
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/integrations/gitnexus.py +0 -70
- package/src/devcouncil/integrations/graphify.py +0 -34
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Semantic cache with FAISS backend, TTL, LRU eviction, and multi-gate validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
import faiss
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import NDArray
|
|
17
|
+
|
|
18
|
+
from .config import CacheConfig
|
|
19
|
+
from .embeddings import EmbeddingService, FloatVector
|
|
20
|
+
|
|
21
|
+
FloatMatrix = NDArray[np.float32]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EmbedderProtocol(Protocol):
|
|
25
|
+
def embed_one(self, text: str) -> FloatVector: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CacheEntry:
|
|
30
|
+
entry_id: str
|
|
31
|
+
query_text: str
|
|
32
|
+
query_hash: str
|
|
33
|
+
response_text: str
|
|
34
|
+
embedding: FloatVector
|
|
35
|
+
namespace: str
|
|
36
|
+
intent: str
|
|
37
|
+
created_at: float
|
|
38
|
+
last_accessed: float
|
|
39
|
+
access_count: int = 0
|
|
40
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
def is_expired(self, ttl_seconds: int, now: float | None = None) -> bool:
|
|
43
|
+
now = now or time.time()
|
|
44
|
+
return (now - self.created_at) > ttl_seconds
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class CacheLookupResult:
|
|
49
|
+
hit: bool
|
|
50
|
+
response: str | None = None
|
|
51
|
+
similarity: float = 0.0
|
|
52
|
+
entry_id: str | None = None
|
|
53
|
+
reason: str = "miss"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SemanticCache:
|
|
57
|
+
"""
|
|
58
|
+
FAISS IndexFlatIP + LRU metadata store.
|
|
59
|
+
IndexFlatIP with normalized vectors == cosine similarity.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
config: CacheConfig | None = None,
|
|
65
|
+
embedder: EmbedderProtocol | None = None,
|
|
66
|
+
dimension: int = 384,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.config = config or CacheConfig()
|
|
69
|
+
self.embedder = embedder or EmbeddingService.get_instance()
|
|
70
|
+
self.dimension = dimension
|
|
71
|
+
self._index: faiss.Index = faiss.IndexFlatIP(dimension)
|
|
72
|
+
self._entries: OrderedDict[str, CacheEntry] = OrderedDict()
|
|
73
|
+
self._id_to_faiss_row: dict[str, int] = {}
|
|
74
|
+
self._faiss_row_to_id: dict[int, str] = {}
|
|
75
|
+
self._lock = threading.RLock()
|
|
76
|
+
self._stats = {"hits": 0, "misses": 0, "evictions": 0, "false_positive_forced": 0}
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _normalize_query(text: str) -> str:
|
|
80
|
+
return " ".join(text.lower().split())
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _hash_query(text: str) -> str:
|
|
84
|
+
normalized = SemanticCache._normalize_query(text)
|
|
85
|
+
return hashlib.sha256(normalized.encode()).hexdigest()
|
|
86
|
+
|
|
87
|
+
def _evict_lru(self) -> None:
|
|
88
|
+
"""Remove oldest-accessed entry when at capacity."""
|
|
89
|
+
if len(self._entries) < self.config.max_entries:
|
|
90
|
+
return
|
|
91
|
+
self._entries.popitem(last=False)
|
|
92
|
+
self._stats["evictions"] += 1
|
|
93
|
+
# Note: FAISS IndexFlatIP does not support deletion;
|
|
94
|
+
# stale rows are ignored during search (production: use IndexIDMap2)
|
|
95
|
+
|
|
96
|
+
def _search_faiss(
|
|
97
|
+
self, query_vec: FloatVector, k: int = 5
|
|
98
|
+
) -> list[tuple[str, float]]:
|
|
99
|
+
if self._index.ntotal == 0:
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
q = query_vec.reshape(1, -1).astype(np.float32)
|
|
103
|
+
scores, indices = self._index.search(q, min(k, self._index.ntotal))
|
|
104
|
+
results: list[tuple[str, float]] = []
|
|
105
|
+
now = time.time()
|
|
106
|
+
|
|
107
|
+
for score, idx in zip(scores[0], indices[0]):
|
|
108
|
+
if idx < 0:
|
|
109
|
+
continue
|
|
110
|
+
entry_id = self._faiss_row_to_id.get(int(idx))
|
|
111
|
+
if entry_id is None:
|
|
112
|
+
continue
|
|
113
|
+
entry = self._entries.get(entry_id)
|
|
114
|
+
if entry is None:
|
|
115
|
+
continue
|
|
116
|
+
if entry.is_expired(self.config.ttl_seconds, now):
|
|
117
|
+
continue
|
|
118
|
+
if entry.namespace != self.config.namespace:
|
|
119
|
+
continue
|
|
120
|
+
results.append((entry_id, float(score)))
|
|
121
|
+
return results
|
|
122
|
+
|
|
123
|
+
def lookup(
|
|
124
|
+
self,
|
|
125
|
+
query: str,
|
|
126
|
+
query_embedding: FloatVector | None = None,
|
|
127
|
+
intent: str | None = None,
|
|
128
|
+
force_miss: bool = False,
|
|
129
|
+
) -> CacheLookupResult:
|
|
130
|
+
with self._lock:
|
|
131
|
+
query_hash = self._hash_query(query)
|
|
132
|
+
|
|
133
|
+
# Exact hash short-circuit
|
|
134
|
+
for entry in self._entries.values():
|
|
135
|
+
if (
|
|
136
|
+
entry.query_hash == query_hash
|
|
137
|
+
and entry.namespace == self.config.namespace
|
|
138
|
+
and not entry.is_expired(self.config.ttl_seconds)
|
|
139
|
+
):
|
|
140
|
+
if intent is not None and entry.intent != intent:
|
|
141
|
+
self._stats["misses"] += 1
|
|
142
|
+
return CacheLookupResult(hit=False, similarity=1.0, reason="intent_mismatch")
|
|
143
|
+
entry.last_accessed = time.time()
|
|
144
|
+
entry.access_count += 1
|
|
145
|
+
self._entries.move_to_end(entry.entry_id)
|
|
146
|
+
self._stats["hits"] += 1
|
|
147
|
+
return CacheLookupResult(
|
|
148
|
+
hit=True,
|
|
149
|
+
response=entry.response_text,
|
|
150
|
+
similarity=1.0,
|
|
151
|
+
entry_id=entry.entry_id,
|
|
152
|
+
reason="exact_hash",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if force_miss:
|
|
156
|
+
self._stats["misses"] += 1
|
|
157
|
+
return CacheLookupResult(hit=False, reason="forced_exploration")
|
|
158
|
+
|
|
159
|
+
vec = query_embedding if query_embedding is not None else self.embedder.embed_one(query)
|
|
160
|
+
candidates = self._search_faiss(vec, k=5)
|
|
161
|
+
|
|
162
|
+
if not candidates:
|
|
163
|
+
self._stats["misses"] += 1
|
|
164
|
+
return CacheLookupResult(hit=False, reason="empty_index")
|
|
165
|
+
|
|
166
|
+
best_id, best_sim = candidates[0]
|
|
167
|
+
|
|
168
|
+
# OOD gate
|
|
169
|
+
if best_sim < self.config.ood_threshold:
|
|
170
|
+
self._stats["misses"] += 1
|
|
171
|
+
return CacheLookupResult(hit=False, similarity=best_sim, reason="ood")
|
|
172
|
+
|
|
173
|
+
# Similarity gate
|
|
174
|
+
if best_sim < self.config.similarity_threshold:
|
|
175
|
+
self._stats["misses"] += 1
|
|
176
|
+
return CacheLookupResult(hit=False, similarity=best_sim, reason="below_threshold")
|
|
177
|
+
|
|
178
|
+
# Margin gate (ambiguous neighborhood)
|
|
179
|
+
if len(candidates) >= 2:
|
|
180
|
+
second_sim = candidates[1][1]
|
|
181
|
+
if (best_sim - second_sim) < self.config.margin_threshold:
|
|
182
|
+
self._stats["misses"] += 1
|
|
183
|
+
return CacheLookupResult(
|
|
184
|
+
hit=False,
|
|
185
|
+
similarity=best_sim,
|
|
186
|
+
reason="insufficient_margin",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
entry = self._entries[best_id]
|
|
190
|
+
|
|
191
|
+
# Intent consistency gate
|
|
192
|
+
if intent is not None and entry.intent != intent:
|
|
193
|
+
self._stats["misses"] += 1
|
|
194
|
+
return CacheLookupResult(hit=False, similarity=best_sim, reason="intent_mismatch")
|
|
195
|
+
|
|
196
|
+
entry.last_accessed = time.time()
|
|
197
|
+
entry.access_count += 1
|
|
198
|
+
self._entries.move_to_end(entry.entry_id)
|
|
199
|
+
self._stats["hits"] += 1
|
|
200
|
+
return CacheLookupResult(
|
|
201
|
+
hit=True,
|
|
202
|
+
response=entry.response_text,
|
|
203
|
+
similarity=best_sim,
|
|
204
|
+
entry_id=entry.entry_id,
|
|
205
|
+
reason="semantic_hit",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def put(
|
|
209
|
+
self,
|
|
210
|
+
query: str,
|
|
211
|
+
response: str,
|
|
212
|
+
query_embedding: FloatVector | None = None,
|
|
213
|
+
intent: str = "general",
|
|
214
|
+
metadata: dict[str, Any] | None = None,
|
|
215
|
+
) -> str:
|
|
216
|
+
with self._lock:
|
|
217
|
+
self._evict_lru()
|
|
218
|
+
vec = query_embedding if query_embedding is not None else self.embedder.embed_one(query)
|
|
219
|
+
entry_id = str(uuid.uuid4())
|
|
220
|
+
now = time.time()
|
|
221
|
+
entry = CacheEntry(
|
|
222
|
+
entry_id=entry_id,
|
|
223
|
+
query_text=query,
|
|
224
|
+
query_hash=self._hash_query(query),
|
|
225
|
+
response_text=response,
|
|
226
|
+
embedding=vec,
|
|
227
|
+
namespace=self.config.namespace,
|
|
228
|
+
intent=intent,
|
|
229
|
+
created_at=now,
|
|
230
|
+
last_accessed=now,
|
|
231
|
+
metadata=metadata or {},
|
|
232
|
+
)
|
|
233
|
+
row = self._index.ntotal
|
|
234
|
+
self._index.add(vec.reshape(1, -1).astype(np.float32))
|
|
235
|
+
self._id_to_faiss_row[entry_id] = row
|
|
236
|
+
self._faiss_row_to_id[row] = entry_id
|
|
237
|
+
self._entries[entry_id] = entry
|
|
238
|
+
return entry_id
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def hit_rate(self) -> float:
|
|
242
|
+
total = self._stats["hits"] + self._stats["misses"]
|
|
243
|
+
return self._stats["hits"] / total if total else 0.0
|
|
244
|
+
|
|
245
|
+
def stats(self) -> dict[str, Any]:
|
|
246
|
+
return {**self._stats, "hit_rate": self.hit_rate, "size": len(self._entries)}
|
|
247
|
+
|
|
248
|
+
def persist(self, path: str) -> None:
|
|
249
|
+
with self._lock:
|
|
250
|
+
faiss.write_index(self._index, f"{path}.faiss")
|
|
251
|
+
serializable = {
|
|
252
|
+
eid: {
|
|
253
|
+
"query_text": e.query_text,
|
|
254
|
+
"query_hash": e.query_hash,
|
|
255
|
+
"response_text": e.response_text,
|
|
256
|
+
"embedding": e.embedding.tolist(),
|
|
257
|
+
"namespace": e.namespace,
|
|
258
|
+
"intent": e.intent,
|
|
259
|
+
"created_at": e.created_at,
|
|
260
|
+
"last_accessed": e.last_accessed,
|
|
261
|
+
"access_count": e.access_count,
|
|
262
|
+
"metadata": e.metadata,
|
|
263
|
+
}
|
|
264
|
+
for eid, e in self._entries.items()
|
|
265
|
+
}
|
|
266
|
+
with open(f"{path}.json", "w", encoding="utf-8") as f:
|
|
267
|
+
json.dump({"entries": serializable, "row_map": self._faiss_row_to_id}, f)
|
|
268
|
+
|
|
269
|
+
def load(self, path: str) -> None:
|
|
270
|
+
with self._lock:
|
|
271
|
+
self._index = faiss.read_index(f"{path}.faiss")
|
|
272
|
+
with open(f"{path}.json", encoding="utf-8") as f:
|
|
273
|
+
data = json.load(f)
|
|
274
|
+
self._entries.clear()
|
|
275
|
+
self._faiss_row_to_id = {int(k): v for k, v in data["row_map"].items()}
|
|
276
|
+
self._id_to_faiss_row = {v: int(k) for k, v in self._faiss_row_to_id.items()}
|
|
277
|
+
for eid, raw in data["entries"].items():
|
|
278
|
+
self._entries[eid] = CacheEntry(
|
|
279
|
+
entry_id=eid,
|
|
280
|
+
query_text=raw["query_text"],
|
|
281
|
+
query_hash=raw["query_hash"],
|
|
282
|
+
response_text=raw["response_text"],
|
|
283
|
+
embedding=np.array(raw["embedding"], dtype=np.float32),
|
|
284
|
+
namespace=raw["namespace"],
|
|
285
|
+
intent=raw["intent"],
|
|
286
|
+
created_at=raw["created_at"],
|
|
287
|
+
last_accessed=raw["last_accessed"],
|
|
288
|
+
access_count=raw["access_count"],
|
|
289
|
+
metadata=raw.get("metadata", {}),
|
|
290
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Semantic RAG context compression with MMR diversification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
from .config import CompressorConfig
|
|
11
|
+
from .embeddings import EmbeddingService, FloatVector
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CompressedContext:
|
|
16
|
+
text: str
|
|
17
|
+
chunks_used: int
|
|
18
|
+
chunks_total: int
|
|
19
|
+
estimated_tokens: int
|
|
20
|
+
scores: list[float]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SemanticCompressor:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
config: CompressorConfig | None = None,
|
|
27
|
+
embedder: EmbeddingService | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.config = config or CompressorConfig()
|
|
30
|
+
self.embedder = embedder or EmbeddingService.get_instance()
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _estimate_tokens(text: str) -> int:
|
|
34
|
+
# Rough heuristic: ~4 chars per token for English
|
|
35
|
+
return max(1, len(text) // 4)
|
|
36
|
+
|
|
37
|
+
def chunk_document(self, document: str) -> list[str]:
|
|
38
|
+
"""Split document into overlapping word-based chunks."""
|
|
39
|
+
words = document.split()
|
|
40
|
+
chunk_words = self.config.chunk_token_size # treating as word proxy
|
|
41
|
+
overlap = self.config.chunk_overlap
|
|
42
|
+
if not words:
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
chunks: list[str] = []
|
|
46
|
+
start = 0
|
|
47
|
+
while start < len(words):
|
|
48
|
+
end = min(len(words), start + chunk_words)
|
|
49
|
+
chunks.append(" ".join(words[start:end]))
|
|
50
|
+
if end >= len(words):
|
|
51
|
+
break
|
|
52
|
+
start = max(start + 1, end - overlap)
|
|
53
|
+
return chunks
|
|
54
|
+
|
|
55
|
+
def _mmr_select(
|
|
56
|
+
self,
|
|
57
|
+
query_vec: FloatVector,
|
|
58
|
+
chunk_vecs: NDArray[np.float32],
|
|
59
|
+
chunk_texts: list[str],
|
|
60
|
+
token_budget: int,
|
|
61
|
+
) -> tuple[list[int], list[float]]:
|
|
62
|
+
"""Maximal Marginal Relevance selection under token budget."""
|
|
63
|
+
n = len(chunk_texts)
|
|
64
|
+
if n == 0:
|
|
65
|
+
return [], []
|
|
66
|
+
|
|
67
|
+
relevance = chunk_vecs @ query_vec
|
|
68
|
+
selected: list[int] = []
|
|
69
|
+
scores: list[float] = []
|
|
70
|
+
tokens_used = 0
|
|
71
|
+
lam = self.config.mmr_lambda
|
|
72
|
+
|
|
73
|
+
candidate_mask = relevance >= self.config.min_chunk_score
|
|
74
|
+
candidates = [i for i in range(n) if candidate_mask[i]]
|
|
75
|
+
|
|
76
|
+
while candidates and len(selected) < self.config.top_k:
|
|
77
|
+
best_idx = -1
|
|
78
|
+
best_mmr = -float("inf")
|
|
79
|
+
|
|
80
|
+
for i in candidates:
|
|
81
|
+
chunk_tokens = self._estimate_tokens(chunk_texts[i])
|
|
82
|
+
if tokens_used + chunk_tokens > token_budget and selected:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
rel = float(relevance[i])
|
|
86
|
+
redundancy = 0.0
|
|
87
|
+
if selected:
|
|
88
|
+
redundancy = max(
|
|
89
|
+
float(chunk_vecs[i] @ chunk_vecs[j]) for j in selected
|
|
90
|
+
)
|
|
91
|
+
mmr = lam * rel - (1.0 - lam) * redundancy
|
|
92
|
+
if mmr > best_mmr:
|
|
93
|
+
best_mmr = mmr
|
|
94
|
+
best_idx = i
|
|
95
|
+
|
|
96
|
+
if best_idx < 0:
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
selected.append(best_idx)
|
|
100
|
+
scores.append(float(relevance[best_idx]))
|
|
101
|
+
tokens_used += self._estimate_tokens(chunk_texts[best_idx])
|
|
102
|
+
candidates.remove(best_idx)
|
|
103
|
+
|
|
104
|
+
if tokens_used >= token_budget:
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
return selected, scores
|
|
108
|
+
|
|
109
|
+
def compress(
|
|
110
|
+
self,
|
|
111
|
+
query: str,
|
|
112
|
+
documents: list[str],
|
|
113
|
+
query_embedding: FloatVector | None = None,
|
|
114
|
+
token_budget: int | None = None,
|
|
115
|
+
) -> CompressedContext:
|
|
116
|
+
budget = token_budget or self.config.token_budget
|
|
117
|
+
all_chunks: list[str] = []
|
|
118
|
+
for doc in documents:
|
|
119
|
+
all_chunks.extend(self.chunk_document(doc))
|
|
120
|
+
|
|
121
|
+
if not all_chunks:
|
|
122
|
+
return CompressedContext(text="", chunks_used=0, chunks_total=0, estimated_tokens=0, scores=[])
|
|
123
|
+
|
|
124
|
+
q_vec = query_embedding if query_embedding is not None else self.embedder.embed_one(query)
|
|
125
|
+
chunk_vecs = self.embedder.embed(all_chunks)
|
|
126
|
+
|
|
127
|
+
selected_indices, scores = self._mmr_select(q_vec, chunk_vecs, all_chunks, budget)
|
|
128
|
+
selected_chunks = [all_chunks[i] for i in selected_indices]
|
|
129
|
+
combined = "\n\n---\n\n".join(selected_chunks)
|
|
130
|
+
|
|
131
|
+
return CompressedContext(
|
|
132
|
+
text=combined,
|
|
133
|
+
chunks_used=len(selected_chunks),
|
|
134
|
+
chunks_total=len(all_chunks),
|
|
135
|
+
estimated_tokens=self._estimate_tokens(combined),
|
|
136
|
+
scores=scores,
|
|
137
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Central configuration for the semantic layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ModelTier(str, Enum):
|
|
11
|
+
SMALL = "small" # 1B-3B parameters
|
|
12
|
+
LARGE = "large" # 8B-70B parameters
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class EmbeddingConfig:
|
|
17
|
+
model_name: str = "sentence-transformers/all-MiniLM-L6-v2"
|
|
18
|
+
dimension: int = 384
|
|
19
|
+
device: Literal["cpu", "cuda"] = "cpu" # CPU avoids VRAM contention with LLM
|
|
20
|
+
batch_size: int = 32
|
|
21
|
+
normalize: bool = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CacheConfig:
|
|
26
|
+
backend: Literal["faiss", "chroma"] = "faiss"
|
|
27
|
+
similarity_threshold: float = 0.92
|
|
28
|
+
ood_threshold: float = 0.75
|
|
29
|
+
margin_threshold: float = 0.03
|
|
30
|
+
ttl_seconds: int = 3600
|
|
31
|
+
max_entries: int = 10_000
|
|
32
|
+
namespace: str = "default"
|
|
33
|
+
exploration_rate: float = 0.02 # force miss for FPR calibration
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class RouterConfig:
|
|
38
|
+
complexity_threshold: float = 0.45
|
|
39
|
+
small_model: str = "qwen2.5:1.5b"
|
|
40
|
+
large_model: str = "llama3.1:8b"
|
|
41
|
+
weights: dict[str, float] = field(
|
|
42
|
+
default_factory=lambda: {
|
|
43
|
+
"length": 0.25,
|
|
44
|
+
"structure": 0.30,
|
|
45
|
+
"embed_disp": 0.25,
|
|
46
|
+
"domain": 0.20,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class CompressorConfig:
|
|
53
|
+
token_budget: int = 2048
|
|
54
|
+
top_k: int = 8
|
|
55
|
+
chunk_token_size: int = 256
|
|
56
|
+
chunk_overlap: int = 32
|
|
57
|
+
min_chunk_score: float = 0.35
|
|
58
|
+
mmr_lambda: float = 0.7
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class LLMConfig:
|
|
63
|
+
backend: Literal["ollama", "llama_cpp", "hf"] = "ollama"
|
|
64
|
+
base_url: str = "http://localhost:11434"
|
|
65
|
+
timeout_seconds: float = 120.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SemanticLayerConfig:
|
|
70
|
+
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
|
71
|
+
cache: CacheConfig = field(default_factory=CacheConfig)
|
|
72
|
+
router: RouterConfig = field(default_factory=RouterConfig)
|
|
73
|
+
compressor: CompressorConfig = field(default_factory=CompressorConfig)
|
|
74
|
+
llm: LLMConfig = field(default_factory=LLMConfig)
|
|
75
|
+
latency_budget_ms: float = 15.0
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Shared embedding service — singleton, low-latency, thread-safe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Sequence, cast
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
from .config import EmbeddingConfig
|
|
13
|
+
|
|
14
|
+
FloatVector = NDArray[np.float32]
|
|
15
|
+
FloatMatrix = NDArray[np.float32]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmbeddingService:
|
|
19
|
+
"""Lazy-loaded sentence-transformers embedder with L2 normalization."""
|
|
20
|
+
|
|
21
|
+
_instance: EmbeddingService | None = None
|
|
22
|
+
_lock = threading.Lock()
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: EmbeddingConfig | None = None) -> None:
|
|
25
|
+
self.config = config or EmbeddingConfig()
|
|
26
|
+
self._model = None
|
|
27
|
+
self._model_lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def get_instance(cls, config: EmbeddingConfig | None = None) -> EmbeddingService:
|
|
31
|
+
with cls._lock:
|
|
32
|
+
if cls._instance is None:
|
|
33
|
+
cls._instance = cls(config)
|
|
34
|
+
return cls._instance
|
|
35
|
+
|
|
36
|
+
def _load_model(self) -> None:
|
|
37
|
+
if self._model is not None:
|
|
38
|
+
return
|
|
39
|
+
with self._model_lock:
|
|
40
|
+
if self._model is not None:
|
|
41
|
+
return
|
|
42
|
+
from sentence_transformers import SentenceTransformer
|
|
43
|
+
|
|
44
|
+
self._model = SentenceTransformer(
|
|
45
|
+
self.config.model_name,
|
|
46
|
+
device=self.config.device,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def embed(self, texts: Sequence[str]) -> FloatMatrix:
|
|
50
|
+
"""Embed a batch of texts. Returns (N, dim) float32 array."""
|
|
51
|
+
self._load_model()
|
|
52
|
+
assert self._model is not None
|
|
53
|
+
|
|
54
|
+
t0 = time.perf_counter()
|
|
55
|
+
vectors: FloatMatrix = self._model.encode(
|
|
56
|
+
list(texts),
|
|
57
|
+
batch_size=self.config.batch_size,
|
|
58
|
+
convert_to_numpy=True,
|
|
59
|
+
normalize_embeddings=self.config.normalize,
|
|
60
|
+
show_progress_bar=False,
|
|
61
|
+
).astype(np.float32)
|
|
62
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
63
|
+
if elapsed_ms > 10:
|
|
64
|
+
# Log in production; kept silent here for brevity
|
|
65
|
+
pass
|
|
66
|
+
return vectors
|
|
67
|
+
|
|
68
|
+
def embed_one(self, text: str) -> FloatVector:
|
|
69
|
+
return cast(FloatVector, self.embed([text])[0])
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Unified adapter for Ollama, llama.cpp, and Hugging Face backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .config import LLMConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LLMBackend(abc.ABC):
|
|
14
|
+
@abc.abstractmethod
|
|
15
|
+
def generate(self, model: str, prompt: str, system: str | None = None) -> str:
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
def close(self) -> None:
|
|
19
|
+
"""Release backend resources if any."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OllamaBackend(LLMBackend):
|
|
23
|
+
def __init__(self, config: LLMConfig | None = None) -> None:
|
|
24
|
+
self.config = config or LLMConfig()
|
|
25
|
+
self._client = httpx.Client(base_url=self.config.base_url, timeout=self.config.timeout_seconds)
|
|
26
|
+
|
|
27
|
+
def generate(self, model: str, prompt: str, system: str | None = None) -> str:
|
|
28
|
+
payload: dict[str, Any] = {
|
|
29
|
+
"model": model,
|
|
30
|
+
"prompt": prompt,
|
|
31
|
+
"stream": False,
|
|
32
|
+
}
|
|
33
|
+
if system:
|
|
34
|
+
payload["system"] = system
|
|
35
|
+
resp = self._client.post("/api/generate", json=payload)
|
|
36
|
+
resp.raise_for_status()
|
|
37
|
+
return cast(str, resp.json()["response"])
|
|
38
|
+
|
|
39
|
+
def close(self) -> None:
|
|
40
|
+
self._client.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LlamaCppBackend(LLMBackend):
|
|
44
|
+
"""OpenAI-compatible llama.cpp server (/v1/chat/completions)."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: LLMConfig | None = None) -> None:
|
|
47
|
+
self.config = config or LLMConfig(base_url="http://localhost:8080")
|
|
48
|
+
self._client = httpx.Client(base_url=self.config.base_url, timeout=self.config.timeout_seconds)
|
|
49
|
+
|
|
50
|
+
def generate(self, model: str, prompt: str, system: str | None = None) -> str:
|
|
51
|
+
messages = []
|
|
52
|
+
if system:
|
|
53
|
+
messages.append({"role": "system", "content": system})
|
|
54
|
+
messages.append({"role": "user", "content": prompt})
|
|
55
|
+
resp = self._client.post(
|
|
56
|
+
"/v1/chat/completions",
|
|
57
|
+
json={"model": model, "messages": messages, "stream": False},
|
|
58
|
+
)
|
|
59
|
+
resp.raise_for_status()
|
|
60
|
+
return cast(str, resp.json()["choices"][0]["message"]["content"])
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
self._client.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class HuggingFaceBackend(LLMBackend):
|
|
67
|
+
"""Local HF pipeline — lazy load to avoid cold-start in semantic layer process."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, config: LLMConfig | None = None) -> None:
|
|
70
|
+
self.config = config or LLMConfig()
|
|
71
|
+
self._pipelines: dict[str, Any] = {}
|
|
72
|
+
|
|
73
|
+
def _get_pipeline(self, model: str) -> Any:
|
|
74
|
+
if model not in self._pipelines:
|
|
75
|
+
from transformers import pipeline
|
|
76
|
+
|
|
77
|
+
self._pipelines[model] = pipeline(
|
|
78
|
+
"text-generation",
|
|
79
|
+
model=model,
|
|
80
|
+
device_map="auto",
|
|
81
|
+
)
|
|
82
|
+
return self._pipelines[model]
|
|
83
|
+
|
|
84
|
+
def generate(self, model: str, prompt: str, system: str | None = None) -> str:
|
|
85
|
+
pipe = self._get_pipeline(model)
|
|
86
|
+
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
|
87
|
+
out = pipe(full_prompt, max_new_tokens=512, do_sample=False)
|
|
88
|
+
return cast(str, out[0]["generated_text"])[len(full_prompt):]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def create_backend(config: LLMConfig | None = None) -> LLMBackend:
|
|
92
|
+
cfg = config or LLMConfig()
|
|
93
|
+
if cfg.backend == "ollama":
|
|
94
|
+
return OllamaBackend(cfg)
|
|
95
|
+
if cfg.backend == "llama_cpp":
|
|
96
|
+
return LlamaCppBackend(cfg)
|
|
97
|
+
if cfg.backend == "hf":
|
|
98
|
+
return HuggingFaceBackend(cfg)
|
|
99
|
+
raise ValueError(f"Unknown backend: {cfg.backend}")
|