diff --git a/.env.example b/.env.example index e1fcbd72..ccd6d8ac 100644 --- a/.env.example +++ b/.env.example @@ -76,31 +76,3 @@ SCIDK_CHAT_OPENAI_API_KEY= # Anthropic key for entity extraction (optional, falls back to pattern matching) SCIDK_ANTHROPIC_API_KEY= - -# ------------------------------ -# Chat Neo4j (for chat intelligence & history) -# ------------------------------ -# Separate Neo4j instance for chat metadata (not research data) -CHAT_NEO4J_URI=bolt://localhost:7688 -CHAT_NEO4J_AUTH=neo4j/your-password-here - -# ------------------------------ -# Concept Graph Neo4j (for meta-reasoning) -# ------------------------------ -# Separate Neo4j instance for concept graph (intents, tools, schema abstractions) -# This layer enables semantic intent classification and graph-based planning -SCIDK_CONCEPT_NEO4J_URI=bolt://localhost:7689 -SCIDK_CONCEPT_NEO4J_AUTH=neo4j/concept-graph-password -SCIDK_CONCEPT_GRAPH_ENABLED=1 - -# Chat Intelligence Settings -SCIDK_CHAT_CONTEXT_RETRIEVAL_TOP_K=3 # messages per strategy (semantic + node overlap) -SCIDK_CHAT_CONTEXT_MAX_TOTAL=6 # max total messages in context -SCIDK_CHAT_REACT_MAX_STEPS=4 # ReAct loop step limit -SCIDK_CHAT_REACT_TOKEN_BUDGET=3000 # tokens per step - -# Background Summarization (fast model for compression) -SCIDK_CHAT_SUMMARIZATION_MODEL=qwen2.5:7b - -# Embedding Model (for semantic retrieval) -SCIDK_CHAT_EMBEDDING_MODEL=nomic-embed-text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 891f0afe..19d2f50c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,9 @@ jobs: run: | python -m pip install --upgrade pip pip install -e .[dev] - - name: Run pytest with coverage (exclude E2E and integration tests) + - name: Run pytest with coverage (exclude E2E) run: | - python -m coverage run -m pytest -q -m "not e2e and not integration" + python -m coverage run -m pytest -q -m "not e2e" python -m coverage report python -m coverage xml - name: Upload coverage to Codecov @@ -30,9 +30,9 @@ jobs: with: file: ./coverage.xml fail_ci_if_error: false - - name: Check coverage threshold (48%) + - name: Check coverage threshold (50%) run: | - python -m coverage report --fail-under=48 + python -m coverage report --fail-under=50 # E2E tests temporarily disabled in CI (Feb 2026) # The test suite has stability issues (auth conflicts, timing, cleanup) that need dedicated attention. diff --git a/.gitignore b/.gitignore index 95a221d0..a544053a 100644 --- a/.gitignore +++ b/.gitignore @@ -75,13 +75,3 @@ backups/ dev/test-runs/tmp/ dev/code-imports/ docs/archive/ -gunicorn.log - -# Scanner and debug outputs -/ambig_paths.txt -/ambiguous_paths.txt -/big_dir_tree.txt -/magic_results.txt -/out.txt -/out*.txt -/out_*.txt diff --git a/ARCHITECTURE_HANDOFF.md b/ARCHITECTURE_HANDOFF.md deleted file mode 100644 index 5bd1ada7..00000000 --- a/ARCHITECTURE_HANDOFF.md +++ /dev/null @@ -1,122 +0,0 @@ -# SciDK — Architecture & Status Handoff - -_Snapshot date: 2026-06-22 · Branch: `production-mvp` @ `23ba225` (the de-facto trunk)_ - -> **Read this, not the root `README.md`.** The README has accreted ~2 years of per-cycle -> notes and is partly stale (e.g. it still claims "in-memory graph, Neo4j not wired" — false; -> Neo4j, a Concept Graph, and an MCP server are all wired in). This doc reflects the actual code. - ---- - -## 1. What SciDK is -A Flask web app for **scientific data management on top of a knowledge graph**. It scans -filesystems (local, mounted, or rclone remotes), interprets files, builds a property graph in -Neo4j, and exposes an LLM chat interface (ReAct + GraphRAG) over that graph, plus an MCP server -so external agents (Claude Desktop, etc.) can query it. - -## 2. Branch / repo topology (important) -- **`production-mvp`** is the real mainline — it contains all of `main` plus **320 commits** on top. -- **`main`** is a **stale snapshot frozen at 2026-03-02** (last PR `#52`). The PR→main review flow - (PRs #32–#52, per `dev/README-planning.md`) stopped there. All March work (AI/Chat stack, - scanner, Cytoscape fixes) was committed **directly to `production-mvp` and never PR'd into main**. -- **`dev/`** is a **git submodule** = the planning/docs repo (stories, phases, tasks, features; - see `dev/README-planning.md`). It is NOT app runtime code. - -## 3. Tech stack -- **Backend:** Python 3.12, Flask (app-factory + blueprints), Swagger/flasgger at `/api/docs`, - `ProxyFix` for reverse-proxy/subpath deploys, gunicorn in prod (`restart_gunicorn.sh`). -- **Graph DB:** Neo4j 5 (`docker-compose.neo4j.yml`; Bolt 7687 / HTTP 7474). In-memory graph - backend exists as a fallback (`SCIDK_STATE_BACKEND=memory`). -- **Relational/state:** SQLite with WAL — multiple DBs: `scidk.db`, `scidk_settings.db`, - `scidk_path_index.db`. Auto-migrations run on boot (`scidk/core/migrations*`), reported via `/api/health`. -- **LLM:** pluggable providers — Ollama / Anthropic / OpenAI (`scidk/ai/provider_factory.py`, - `llm_providers.py`). GraphRAG/embeddings stack is **optional** (`requirements-graphrag.txt`, - PyTorch+CUDA, gated by `SCIDK_GRAPHRAG_ENABLED`). -- **Frontend:** server-rendered Jinja templates + vanilla JS; **Cytoscape.js** for graph viz - (`scidk/ui/static/js/graph_utils.js`, `SciDKGraph`). -- **Tests:** pytest, 3 tiers via markers (`unit` / `integration` / `e2e`); Playwright for E2E. - -## 4. App initialization — `scidk/app.py::create_app()` -Single application factory. Wires, in order: -1. Logging, channel defaults, ProxyFix, Swagger. -2. SQLite auto-migration; state-backend toggle (`sqlite` default). -3. **Graph backend** (`core/neo4j_config.create_graph_backend` → Neo4j or in-memory). -4. **Concept Graph driver** (optional, `SCIDK_CONCEPT_GRAPH_ENABLED=1`; graceful fallback to - hard-coded intent classifier if unavailable). -5. Interpreter registry; FilesystemManager; FS providers (`local_fs`, `mounted_fs`, `rclone`). -6. Everything hung off `app.extensions['scidk']` (graph, concept_driver, registry, fs, providers, - in-session registries for scans/tasks/directories, neo4j_config/state, rclone_mounts, settings). -7. Hydrate persisted state from SQLite (last scan, rclone mounts/settings, Neo4j creds). -8. Register **26 blueprints** (`web/routes/register_blueprints`); init auth middleware (RBAC). -9. **Plugin system**: label-endpoint registry, plugin-template registry, plugin-instance manager, - plugin loader (discovers `plugins/`). -10. Backup scheduler (also runs Concept Graph weight-decay job). - -## 5. Package layout (`scidk/`) -- **`ai/`** — LLM layer. `react_loop.py` (ReAct agent), `chat_graph.py`, `summarization.py`, - `cypher_utils.py`, `mcp_tools.py`, `schema_context.py`, `provider_factory.py` / `llm_providers.py`. -- **`services/`** — business logic. Key ones: - - `concept_graph_service.py` (~1k lines) — Concept Graph: semantic schema layer with weighted - edges + decay, export/import, intent routing. - - `schema_intelligence.py` (~675 lines) — editable schema intelligence (labels/profiles, embeddings). - - `chat_neo4j_client.py`, `chat_service.py` — DB-persisted chat with permissions. - - `graphrag/` (incl. `intent_classifier.py`) — LOOKUP vs REASONING routing, text-to-Cypher. - - `neo4j_client.py`, `query_service.py`, `link_service.py` / `link_service_v2.py`, - `label_service.py`, `fs_index_service.py`, `scan_index_service.py`, `commit_service.py`, - `saved_maps_service.py`, `metrics.py`. -- **`web/routes/`** — 26 Flask blueprints (see §6). Pattern: `_get_ext()` to reach - `app.extensions['scidk']`; background work uses threads with captured `app.app_context()`. -- **`core/`** — infra: migrations, neo4j/channel/rclone config, plugin loader + registries, - backup manager/scheduler, alert manager, settings, logging. -- **`concept_graph/`** — `intents.yaml`, `schema.cypher` (Concept Graph seed/schema). -- **`interpreters/`, `labels/`, `schema/`, `scripts/`, `export/`** — file interpreters, label - definitions, schema generation, script registry, exporters. -- **`mcp_server.py`** — standalone MCP server (`python -m scidk.mcp_server`), 5 read-only tools: - `query_knowledge_graph`, `get_schema`, `summarize_dataset`, `get_label_profile`, `list_labels`. - -## 6. HTTP surface — 26 blueprints (`scidk/web/routes/`) -UI (`ui.py`: `/`, `/datasets`, `/map`, `/chat`, `/settings`, …) + API blueprints: -`api_files`, `api_graph`, `api_maps`, `api_scripts`, `api_tasks`, `api_chat`, `api_queries`, -`api_neo4j`, `api_admin`, `api_interpreters`, `api_providers`, `api_annotations`, `api_labels`, -`api_integrations`, `api_links` (legacy) + `api_links_v2` (LinkRegistry), `api_settings`, -`api_auth`, `api_users`, `api_audit`, `api_alerts`, `api_logs`, `api_plugins`, `api_system` -(chat self-awareness), `api_results`. -_(The `web/routes/README.md` count of "9 blueprints / 91 routes" is outdated — it's 26 now.)_ - -## 7. Major subsystems delivered (newest → older) -- **AI / Chat stack (Mar 2026, production-mvp only):** Concept Graph (Phases 1–3: weighted edges, - decay, MCP tools, export/import), Schema Intelligence editable UI, ReAct streaming chat with - reasoning-block visualization, MCP server, Neo4j full-text indexes. Seed scripts at repo root - (`seed_concept_graph*.py`, `seed_schema_embeddings.py`, `apply_concept_schema.py`). -- **Standalone filesystem scanner (Mar 2026):** `tools/scidk_scanner.py` + `_opt.py` — - magic-byte detection + parallel enumeration; spec in `tools/SCIDK_SCANNER_SPEC.md`. -- **Cross-database transfer V2 (#51):** provenance, progress tracking, cancellation. -- **Security (#40):** multi-user auth + RBAC + DB-persisted chat with permissions. -- **Config backup/restore (#41) + auto-lock on inactivity (#44).** -- **Plugins:** `plugins/{example_plugin, example_ilab, table_loader, ilab_table_loader}` — - UI-instantiable plugin templates + instances; the recent demo focused on the **iLab plugin + - concept-graph seeding** (see the `dev` submodule branch `feature/ilab-plugin-and-demo-seeding`). -- **Providers:** rclone is core (feature flag removed, #31); FUSE mount manager under `./data/mounts/`. - -## 8. How to run -```bash -pip install -e .[dev] # or requirements.txt (lite) / requirements-graphrag.txt (AI) -docker compose -f docker-compose.neo4j.yml up -d # Neo4j (default creds neo4j/neo4jiscool) -scidk-serve # or: python -m scidk.app → http://127.0.0.1:5000 -python -m scidk.mcp_server # MCP server for external agents -make unit | make integration | make e2e | make check # tests -``` -Key env: `SCIDK_STATE_BACKEND` (sqlite|memory), `SCIDK_PROVIDERS`, `NEO4J_URI`/`NEO4J_AUTH`, -`SCIDK_CONCEPT_GRAPH_ENABLED`, `SCIDK_GRAPHRAG_ENABLED`, `SCIDK_CHANNEL` (stable|dev), `SCIDK_BASE` -(subpath deploys). `.env` is auto-loaded via python-dotenv. - -## 9. Open items / gotchas for the next agent -1. **main is stale.** Decide whether to back-merge the March AI/Chat + scanner work from - `production-mvp` into `main`, or formally retire `main` in favor of `production-mvp`. -2. **`dev` submodule diverges** (47 local-only vs 8 pinned-only commits) on - `feature/ilab-plugin-and-demo-seeding`. Reconcile before committing the superproject's `dev` pin. -3. **Stale root `README.md`** — large sections describe an earlier MVP; trust this doc + code instead. -4. **GraphRAG/AI features are optional and heavy** (PyTorch/CUDA) and degrade gracefully when off. -5. Repo root holds loose scripts/artifacts (`seed_*.py`, `*.db`, `htmlcov/`, `pytest_fully_output.txt`) - that could use a cleanup pass. - diff --git a/README.md b/README.md index d8fc9bbf..96ba959f 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,9 @@ source scripts/init_env.fish --write-dotenv 3) Run the server: ``` scidk-serve -# or the equivalent module form: +# or python3 -m scidk.app ``` -> **Canonical launch command:** `scidk-serve` (entry point `scidk.app:main`). `python3 -m scidk.app` is exactly equivalent. Other docs reference this section rather than restating launch commands. 4) Open the UI in your browser: - http://127.0.0.1:5000/ @@ -205,8 +204,8 @@ Details: - Add tests alongside new features in future cycles; see dev/cycles.md for cycle protocol. ## Notes -- The graph backend is selectable via `SCIDK_GRAPH_BACKEND` (`memory` by default, `neo4j` to persist). Neo4j is fully wired: scans commit File/Folder/Scan nodes and relationships with post-commit verification (see `scidk/services/neo4j_client.py`). -- With the in-memory backend, graph data resets on restart; use `SCIDK_GRAPH_BACKEND=neo4j` for persistence. Neo4j deployment docs reside in dev/ops/deployment-neo4j.md. +- This MVP uses an in-memory graph; data resets on restart. +- Neo4j deployment docs reside in dev/ops/deployment-neo4j.md, but Neo4j is not yet wired in the MVP code. ## Documentation - Delivery cycles and planning protocol: dev/cycles.md @@ -215,19 +214,11 @@ Details: ## Architecture SciDK uses a modular Flask blueprint architecture for web routes: -- **~27 blueprints** organize 300+ routes by functional area (UI, files, tasks, graph, Neo4j, providers, chat, labels, links, admin, interpreters, and more) -- **Application factory**: `create_app()` lives in `scidk/app.py` (~314 lines); blueprints are registered via `register_blueprints()` in `scidk/web/routes/__init__.py` +- **9 blueprints** organize 91 routes by functional area (UI, files, tasks, graph, Neo4j, providers, chat, admin, interpreters) +- **Reduced footprint**: app.py reduced from 5,781 to 645 lines (89% reduction) - **Clean separation**: Each blueprint is self-contained with proper import scoping - See `scidk/web/routes/README.md` for detailed blueprint documentation -## MCP Server (Model Context Protocol) -SciDK exposes core functionality via an MCP server for external AI agents (Claude Desktop, etc.): -- **5 core tools**: `query_knowledge_graph`, `get_schema`, `summarize_dataset`, `get_label_profile`, `list_labels` -- **Read-only safety**: All queries are validated to block write operations -- **Stdio transport**: Uses standard MCP protocol over stdin/stdout -- **Setup guide**: See `docs/mcp-setup.md` for Claude Desktop configuration -- **Run**: `python3 -m scidk.mcp_server` - ## Scanning progress and background tasks (MVP) - Current options: - Synchronous: POST /api/scan runs immediately and returns when complete. @@ -244,11 +235,13 @@ SciDK exposes core functionality via an MCP server for external AI agents (Claud - Preview and download instances for File, Folder, and Scan labels as CSV (XLSX if openpyxl is installed). ## Neo4j integration -- Status: Neo4j is fully wired. The app ships with docker-compose.neo4j.yml to run a local Neo4j; set `SCIDK_GRAPH_BACKEND=neo4j` (plus `NEO4J_URI`/`NEO4J_AUTH`) to persist to it. With the default in-memory backend, graph data is not persisted across restarts. -- How it works: - - The graph backend is selected at startup in `create_app()` via `SCIDK_GRAPH_BACKEND` (`memory` default, `neo4j` to persist); invalid Neo4j params fall back to in-memory. - - Committing a scan writes `(:File)`, `(:Folder)`, `(:Scan)` nodes with `(Folder)-[:CONTAINS]->(File|Folder)`, `(File|Folder)-[:SCANNED_IN]->(Scan)`, and `INTERPRETED_AS` relationships, via `scidk/services/neo4j_client.py`. - - Schema triples for `/api/graph/schema` are computed with Cypher (APOC variants available at `/api/graph/schema.apoc`). +- Status: The app ships with docker-compose.neo4j.yml to run a local Neo4j, but the Flask app currently uses an in-memory graph. +- Next steps to enable Neo4j writes/reads: + 1) Add a GraphAdapter interface and a Neo4jAdapter implementing upsert_dataset, add_interpretation, commit_scan, schema_triples. + 2) Add config/feature flag (e.g., SCIDK_GRAPH_BACKEND=neo4j) to switch adapters. + 3) Map current in-memory structures to Neo4j schema: (:File), (:Folder), (:Scan) nodes and CONTAINS, INTERPRETED_AS, SCANNED_IN relationships. + 4) Use Cypher or APOC to compute schema triples for /api/graph/schema. +- Until then, data is not persisted to Neo4j. Use the CSV exports or the in-memory map for the demo. ## New in this cycle: Optional Neo4j schema endpoints and extra Instance exports @@ -450,17 +443,19 @@ Local commands: - make e2e → pytest -m e2e tests/e2e -q - make check → runs unit, integration, and e2e sequentially -CI is defined in `.github/workflows/ci.yml` and runs pytest with `-m "not e2e"`. **E2E tests are disabled in CI as of Feb 2026** (the E2E job is commented out) — run them locally with `npm run e2e` or `pytest -m e2e`. +See .github/workflows/tests.yml for the CI matrix that runs each tier. ## Verify CI and Record Demo Artifacts Follow these steps to verify the full test suite and automatically capture screenshots/JSON for the demo. 1) Verify CI on GitHub -- Navigate to GitHub → Actions → the CI workflow (defined in `.github/workflows/ci.yml`). -- Confirm the pytest job is green (runs `pytest -m "not e2e"`). -- E2E (Playwright) is not run in CI as of Feb 2026; verify E2E locally with `npm run e2e`. -- Click into the latest run to see logs if the job is red. +- Navigate to GitHub → Actions → "Tests" workflow (defined in `.github/workflows/tests.yml`). +- Confirm that all three matrix jobs are green: + - tier=unit + - tier=integration + - tier=e2e (installs Playwright browsers automatically) +- Click into the latest run to see logs if any job is red. 2) Run all tests locally (mirrors CI) ``` diff --git a/SciDK_NCI_Demo.pdf b/SciDK_NCI_Demo.pdf deleted file mode 100644 index 83c30959..00000000 Binary files a/SciDK_NCI_Demo.pdf and /dev/null differ diff --git a/apply_concept_schema.py b/apply_concept_schema.py deleted file mode 100644 index bfc8ab35..00000000 --- a/apply_concept_schema.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -"""Apply schema constraints to the Concept Graph.""" -from neo4j import GraphDatabase - -uri = "bolt://localhost:7689" -auth = ("neo4j", "concept-graph-password") - -driver = GraphDatabase.driver(uri, auth=auth) - -with open('scidk/concept_graph/schema.cypher', 'r') as f: - schema_cypher = f.read() - -# Split by semicolon and execute each statement -statements = [s.strip() for s in schema_cypher.split(';') if s.strip() and not s.strip().startswith('//')] - -with driver.session() as session: - for stmt in statements: - if stmt: - try: - print(f"Executing: {stmt[:60]}...") - session.run(stmt) - print(" ✓ Success") - except Exception as e: - print(f" ✗ Error: {e}") - -driver.close() -print("\n✓ Schema constraints applied to Concept Graph") diff --git a/backups/scidk-backup-20260210_031853-85217c23.zip b/backups/scidk-backup-20260210_031853-85217c23.zip new file mode 100644 index 00000000..159e4e05 Binary files /dev/null and b/backups/scidk-backup-20260210_031853-85217c23.zip differ diff --git a/backups/scidk-backup-20260210_070000-17137b43.zip b/backups/scidk-backup-20260210_070000-17137b43.zip new file mode 100644 index 00000000..0ed2742e Binary files /dev/null and b/backups/scidk-backup-20260210_070000-17137b43.zip differ diff --git a/backups/scidk-backup-20260210_070000-40a80893.zip b/backups/scidk-backup-20260210_070000-40a80893.zip new file mode 100644 index 00000000..1afa66a8 Binary files /dev/null and b/backups/scidk-backup-20260210_070000-40a80893.zip differ diff --git a/dev b/dev index 7e804faf..2ace2601 160000 --- a/dev +++ b/dev @@ -1 +1 @@ -Subproject commit 7e804fafb00eb03347703a17dec8a1b01df1965c +Subproject commit 2ace2601c0ac8590aa27e66a81976574df1a957a diff --git a/docker-compose.concept-graph.yml b/docker-compose.concept-graph.yml deleted file mode 100644 index 423b0cfa..00000000 --- a/docker-compose.concept-graph.yml +++ /dev/null @@ -1,38 +0,0 @@ -version: '3.8' - -services: - neo4j-concept: - image: neo4j:5.13.0 - container_name: scidk-concept-graph - ports: - - "7689:7687" # Bolt protocol - - "7476:7474" # HTTP (browser access) - environment: - - NEO4J_AUTH=neo4j/concept-graph-password - - NEO4J_server_memory_heap_initial__size=512m - - NEO4J_server_memory_heap_max__size=2G - - NEO4J_server_memory_pagecache_size=512m - - NEO4J_dbms_security_procedures_unrestricted=apoc.* - - NEO4J_dbms_security_procedures_allowlist=apoc.* - volumes: - - concept-graph-data:/data - - concept-graph-logs:/logs - networks: - - scidk-network - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider localhost:7474 || exit 1"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - -volumes: - concept-graph-data: - driver: local - concept-graph-logs: - driver: local - -networks: - scidk-network: - driver: bridge diff --git a/docker-compose.neo4j.yml b/docker-compose.neo4j.yml index 4f23fd1c..90c3962b 100644 --- a/docker-compose.neo4j.yml +++ b/docker-compose.neo4j.yml @@ -28,26 +28,3 @@ services: retries: 10 start_period: 20s - neo4j-chat: - image: neo4j:2025.10.1 - container_name: scidk-neo4j-chat - restart: unless-stopped - environment: - - NEO4J_AUTH=${CHAT_NEO4J_AUTH:-neo4j/changeme} - - NEO4J_server_memory_heap_initial__size=512m - - NEO4J_server_memory_heap_max__size=512m - - NEO4J_server_memory_pagecache_size=256m - - NEO4J_dbms_security_auth__enabled=true - ports: - - "7688:7687" # Bolt (chat Neo4j on different port) - - "7475:7474" # HTTP (chat Neo4j Browser) - volumes: - - ${CHAT_NEO4J_HOST_DATA_DIR:-./data/neo4j-chat/data}:/data - - ${CHAT_NEO4J_HOST_LOGS_DIR:-./data/neo4j-chat/logs}:/logs - healthcheck: - test: ["CMD-SHELL", "PASS=$${NEO4J_AUTH#neo4j/}; cypher-shell -u neo4j -p \"$${PASS}\" -a bolt://localhost:7687 \"RETURN 1;\""] - interval: 15s - timeout: 5s - retries: 10 - start_period: 20s - diff --git a/docs/API.md b/docs/API.md index 62c77069..3f37934f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -70,13 +70,10 @@ curl -H "Authorization: Bearer abc123..." \ ### No Authentication (Development) -Authentication is **off by default**. It is toggled at runtime via the settings API (there is no `SCIDK_AUTH_DISABLED` environment variable). To disable auth (e.g. for development or E2E tests): +For development or testing, authentication can be disabled (not recommended for production): ```bash -curl -X POST http://localhost:5000/api/settings/security/auth \ - -H "Content-Type: application/json" \ - -d '{"enabled": false}' +export SCIDK_AUTH_DISABLED=true ``` -Set `{"enabled": true}` to require login. Current status: `GET /api/settings/security/auth`. ## Common API Operations @@ -122,8 +119,7 @@ curl http://localhost:5000/api/health/graph }, "relationships": { "CONTAINS": 1334, - "SCANNED_IN": 1245, - "DERIVED_FROM": 27 + "SCANNED_IN": 1245 } } ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 57e6cd19..ab566f20 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -79,7 +79,7 @@ SciDK is a scientific data knowledge management system that bridges filesystem d - Not ideal for high-concurrency writes (mitigated with WAL mode) - No built-in graph queries (use Neo4j for this) -**Graph Database**: Neo4j 5.x (Optional but active in production) +**Graph Database**: Neo4j 5.x (Optional) - **Why Neo4j**: - Industry-leading graph database - Cypher query language @@ -119,43 +119,26 @@ SciDK is a scientific data knowledge management system that bridges filesystem d ### Web Layer -**Blueprint Structure** (~27 blueprints, 300+ routes). Blueprints are registered via `register_blueprints()` in `scidk/web/routes/__init__.py`: +**Blueprint Structure** (9 blueprints, 91+ routes): ```python scidk/web/routes/ -├── ui.py # User interface (HTML) routes -├── api_files.py # File, scan, and dataset operations -├── api_graph.py # Graph schema, instances, RO-Crate export -├── api_maps.py # Map/visualization data +├── ui.py # User interface routes +├── api_files.py # File and dataset operations +├── api_graph.py # Graph queries and visualization ├── api_labels.py # Schema/label management ├── api_links.py # Link definitions and execution -├── api_links_v2.py # Link registry (v2) ├── api_integrations.py # External API integrations -├── api_neo4j.py # Neo4j connection and operations -├── api_providers.py # Filesystem/rclone providers and mounts -├── api_tasks.py # Background task management -├── api_scripts.py # Analysis scripts -├── api_results.py # Analysis result panels -├── api_queries.py # Saved Cypher query library -├── api_chat.py # Chat / GraphRAG interface -├── api_annotations.py # Annotations -├── api_plugins.py # Plugin management and instances -├── api_interpreters.py # Interpreter configuration ├── api_settings.py # Settings and configuration ├── api_auth.py # Authentication endpoints -├── api_users.py # User management -├── api_audit.py # Audit log access -├── api_alerts.py # Alert configuration -├── api_logs.py # Application logs -├── api_admin.py # Health / metrics / admin -└── api_system.py # System info +└── api_chat.py # Chat interface ``` **Advantages**: - Clean separation of concerns - Easy to add new features - Improved testability -- Lean application factory: `create_app()` lives in `scidk/app.py` (~314 lines); route definitions live in the per-area blueprint modules above +- Reduced file size (app.py reduced from 5,781 to 645 lines) ### Core Services @@ -336,74 +319,68 @@ User Pushes to Neo4j ### SQLite Tables -**files** (see `scidk/core/path_index_sqlite.py`): +**files**: ```sql -CREATE TABLE IF NOT EXISTS files ( - path TEXT NOT NULL, - parent_path TEXT, - name TEXT NOT NULL, - depth INTEGER NOT NULL, - type TEXT NOT NULL, - size INTEGER NOT NULL, - modified_time REAL, - file_extension TEXT, - mime_type TEXT, - etag TEXT, - hash TEXT, - remote TEXT, +CREATE TABLE files ( + id TEXT PRIMARY KEY, scan_id TEXT, - extra_json TEXT + path TEXT NOT NULL, + name TEXT, + size INTEGER, + modified REAL, + extension TEXT, + provider_id TEXT, + checksum TEXT, + FOREIGN KEY (scan_id) REFERENCES scans(id) ); --- interpreted_as TEXT and interpretation_json TEXT are added via ALTER TABLE on init. --- Node identity is the composite (path, host); there is no surrogate `id` column. +CREATE INDEX idx_files_scan ON files(scan_id); +CREATE INDEX idx_files_path ON files(path); +CREATE INDEX idx_files_extension ON files(extension); ``` -**scans** (see `scidk/core/migrations.py`): +**scans**: ```sql -CREATE TABLE IF NOT EXISTS scans ( +CREATE TABLE scans ( id TEXT PRIMARY KEY, - root TEXT, - started REAL, - completed REAL, + path TEXT NOT NULL, + recursive INTEGER, + timestamp REAL, status TEXT, - extra_json TEXT + file_count INTEGER, + provider_id TEXT ); --- Per-scan detail (recursive flag, counts, provider, etc.) is stored in extra_json --- and in companion tables (scan_items, scan_progress). ``` -**auth_users** (see `scidk/core/auth.py`): +**users**: ```sql -CREATE TABLE IF NOT EXISTS auth_users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, +CREATE TABLE users ( + id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('admin', 'user')), - enabled INTEGER DEFAULT 1, - created_at REAL NOT NULL, - updated_at REAL NOT NULL, - created_by TEXT, + role TEXT NOT NULL, + created_at REAL, last_login REAL ); ``` -**settings** (see `scidk/core/migrations.py`): +**settings**: ```sql -CREATE TABLE IF NOT EXISTS settings ( +CREATE TABLE settings ( key TEXT PRIMARY KEY, - value TEXT + value TEXT, + updated_at TEXT ); ``` -**auth_audit_log** (see `scidk/core/auth.py`): +**audit_log**: ```sql -CREATE TABLE IF NOT EXISTS auth_audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, timestamp REAL NOT NULL, - username TEXT NOT NULL, - action TEXT NOT NULL, - details TEXT, - ip_address TEXT + event_type TEXT NOT NULL, + user TEXT, + ip_address TEXT, + details TEXT ); ``` @@ -415,13 +392,12 @@ CREATE TABLE IF NOT EXISTS auth_audit_log ( - **Scan**: Scan session metadata (timestamp, path, recursive) - **Custom Labels**: User-defined via Labels page -**Relationships** (see `scidk/services/neo4j_client.py`): +**Relationships**: - **(File)-[:SCANNED_IN]->(Scan)**: Files belong to scans - **(Folder)-[:SCANNED_IN]->(Scan)**: Folders belong to scans -- **(Folder)-[:CONTAINS]->(File)**: File hierarchy -- **(Folder)-[:CONTAINS]->(Folder)**: Folder hierarchy -- **(File)-[:INTERPRETED_AS]->(...)**: Interpretation results -- **Custom Relationships**: User-defined via Links page (e.g. `DERIVED_FROM`) +- **(File)-[:CONTAINED_IN]->(Folder)**: File hierarchy +- **(Folder)-[:CONTAINED_IN]->(Folder)**: Folder hierarchy +- **Custom Relationships**: User-defined via Links page ## Scalability Considerations @@ -588,7 +564,7 @@ app.register_blueprint(custom_bp) - High concurrent write load (>100 writes/sec) - Distributed deployment required -### Why Neo4j (Optional but active in production)? +### Why Neo4j (Optional)? **Advantages**: - Native graph queries (relationships are first-class) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md deleted file mode 100644 index 5472bb3d..00000000 --- a/docs/CONTRIBUTING.md +++ /dev/null @@ -1,85 +0,0 @@ -# Contributing to SciDK - -Conventions for common changes. For setup and how to run the app, see the README ("Run the server" — `scidk-serve` is canonical). - -## Add a new route - -Routes live in per-area blueprints under `scidk/web/routes/`. Reuse an existing module when one fits; otherwise add a new file following the same pattern: - -```python -# scidk/web/routes/api_widgets.py -from flask import Blueprint, jsonify -bp = Blueprint("api_widgets", __name__, url_prefix="/api") - -@bp.get("/widgets") -def list_widgets(): - return jsonify([]) -``` - -Then register it in `scidk/web/routes/__init__.py` inside `register_blueprints()`: - -```python -from . import api_widgets -app.register_blueprint(api_widgets.bp) -``` - -Add `data-testid` attributes to any new interactive UI elements. - -## Persist settings - -Use the helpers in `scidk/core/settings.py` — do not write the `settings` table directly: - -```python -from scidk.core.settings import get_setting, set_setting -set_setting("my_key", "value") -val = get_setting("my_key", default=None) -``` - -## Query Neo4j - -Always go through `scidk/services/neo4j_client.py`; never instantiate `neo4j.GraphDatabase` drivers directly: - -```python -from scidk.services.neo4j_client import get_neo4j_client -client = get_neo4j_client() -rows = client.execute_read("MATCH (n:File) RETURN count(n) AS c") -client.execute_write("MERGE (:Tag {name: $name})", {"name": "demo"}) -``` - -This centralizes connection params, auth modes, and profiles. - -## Add a migration - -Schema lives in `scidk/core/migrations.py` and auto-runs on boot. Append a new versioned block at the end of `migrate()` — never edit past blocks: - -```python -if version < 26: - cur.execute("CREATE TABLE IF NOT EXISTS my_table (...);") - conn.commit() - _set_version(conn, 26) - version = 26 -``` - -`files`/index schema lives in `scidk/core/path_index_sqlite.py`. - -## Logging - -Use a module-level logger; do not use `print()`: - -```python -import logging -logger = logging.getLogger(__name__) -logger.info("scan started: %s", path) -``` - -## Run tests - -```bash -pytest tests/ -q # unit + integration (what CI runs) -``` - -E2E (Playwright) is **local only** — disabled in CI as of Feb 2026: - -```bash -npm run e2e # or: pytest -m e2e -``` diff --git a/docs/DEMO_SETUP.md b/docs/DEMO_SETUP.md index 873bdbf1..13996b1b 100644 --- a/docs/DEMO_SETUP.md +++ b/docs/DEMO_SETUP.md @@ -119,8 +119,8 @@ Use this when setting up a new demo instance: # Clean everything and start fresh python scripts/seed_demo_data.py --reset --neo4j -# Start SciDK (canonical launch command — see README "Run the server") -scidk-serve +# Start SciDK +python start.sh # Login as admin / demo123 ``` @@ -326,10 +326,10 @@ Demo files follow a consistent structure: ## See Also -- [Security & Authentication](SECURITY.md) -- [Plugin System](plugins.md) +- [Authentication Documentation](AUTHENTICATION.md) +- [Plugin System](plugins/README.md) - [iLab Importer Plugin](plugins/ILAB_IMPORTER.md) -- [Architecture (graph & Neo4j integration)](ARCHITECTURE.md) +- [Neo4j Integration](GRAPH_INTEGRATION.md) ## Support diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index c0f9938b..ac047982 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -7,14 +7,14 @@ This guide covers production deployment of SciDK, including installation, config ### System Requirements - **OS**: Linux (Ubuntu 20.04+, RHEL 8+, or compatible), macOS 11+, or Windows 10+ with WSL2 -- **Python**: 3.12 or higher (required; see `pyproject.toml` `requires-python = ">=3.12"`) +- **Python**: 3.10 or higher - **Memory**: Minimum 2GB RAM, 4GB+ recommended for large datasets - **Disk**: 10GB+ free space for application and data storage - **Neo4j** (optional): 5.x or higher for graph database functionality ### Required Software -1. **Python 3.12+** with pip and venv +1. **Python 3.10+** with pip and venv 2. **Neo4j** (optional but recommended): For persistent graph storage 3. **rclone** (optional): For cloud storage provider integration 4. **ncdu or gdu** (optional): For faster filesystem scanning diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 561fc1ca..62bb18e5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,12 +2,6 @@ This guide covers the security architecture, best practices, compliance considerations, and incident response procedures for SciDK deployments. -> **⚠️ Status: implemented vs. recommended.** This guide documents both controls that are **implemented today** and controls that are **recommended / not yet implemented**. Treat unmarked best-practice snippets (nginx, OS, compliance, monitoring) as deployment *recommendations*, not descriptions of current behavior. For the current security posture of the running app — including what is safe for single-user development vs. multi-user production — see [SECURITY_HARDENING.md](SECURITY_HARDENING.md). -> -> **Implemented today:** session-based login with bcrypt password hashing (`scidk/core/auth.py`), role-based access control with `@require_role`/`@require_admin` (`scidk/web/decorators.py`), the `auth_users` / `auth_audit_log` tables, audit logging, and Fernet-encrypted credentials in AlertManager/ConfigManager/API-endpoint registry and plugin settings (`scidk/core/plugin_settings.py`). -> -> **Recommended / not yet implemented:** `SESSION_COOKIE_SECURE` / `SESSION_COOKIE_HTTPONLY` / `SESSION_COOKIE_SAMESITE` config, and CSRF protection (see inline notes below). - ## Security Architecture Overview SciDK implements defense-in-depth security with multiple layers of protection: @@ -32,11 +26,11 @@ SciDK supports session-based authentication with the following features: - Secure password reset mechanisms **Session Management**: -- Session-based authentication +- Session-based authentication using secure cookies - Configurable session timeout (default: 30 minutes) - Auto-lock after inactivity - Session invalidation on logout -- CSRF protection and secure-cookie flags (`SESSION_COOKIE_SECURE`/`HTTPONLY`/`SAMESITE`) ⚠️ *recommended / not yet implemented* +- CSRF protection enabled **Example: Enabling Authentication**: ```python @@ -293,7 +287,7 @@ chmod 600 .env ``` **Credential Storage**: -- SciDK stores encrypted credentials in SQLite. Fernet (symmetric) encryption is used for SMTP/alert credentials, config-manager secrets, API-endpoint auth tokens, and plugin settings (`scidk/core/plugin_settings.py`). +- SciDK stores encrypted credentials in SQLite - Encryption key should be stored separately - Consider using external secret managers (HashiCorp Vault, AWS Secrets Manager) @@ -323,9 +317,9 @@ SciDK implements input validation to prevent: ### Session Security -**Configuration** ⚠️ *Recommended / not yet implemented* — the app does not currently set these cookie flags or enable CSRF protection; the snippet below is the recommended hardening to apply before multi-user production use: +**Configuration**: ```python -# Flask session configuration (RECOMMENDED — not currently set in code) +# Flask session configuration app.config.update( SESSION_COOKIE_SECURE=True, # HTTPS only SESSION_COOKIE_HTTPONLY=True, # No JavaScript access diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 64fca27c..49a63305 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -612,7 +612,7 @@ htop hashed = bcrypt.hashpw(password, bcrypt.gensalt()) conn = sqlite3.connect('/path/to/files.db') - conn.execute("UPDATE auth_users SET password_hash=? WHERE username='admin'", (hashed,)) + conn.execute("UPDATE users SET password_hash=? WHERE username='admin'", (hashed,)) conn.commit() ``` diff --git a/docs/branching-and-ci.md b/docs/branching-and-ci.md index 38b1758e..c483c6e0 100644 --- a/docs/branching-and-ci.md +++ b/docs/branching-and-ci.md @@ -28,8 +28,8 @@ Goal: Keep the development flow simple and reliable by working on one active bra - [ ] Justification added if working on multiple branches concurrently. ## CI Expectations -- Unit/integration tests run on every PR via `.github/workflows/ci.yml` (`pytest -m "not e2e"`). -- **E2E is disabled in CI as of Feb 2026** (the E2E job is commented out). Run E2E locally with `npm run e2e` or `pytest -m e2e`; do not let E2E block PRs. +- Unit tests and smoke checks run on every PR. +- E2E smoke (where applicable) runs within a few minutes (<5s/spec target). - Required checks must be green before merge. - Dev submodule freshness: PRs to main must keep dev/ submodule at the latest commit of its configured branch (see .gitmodules). A CI check enforces this, and main auto-syncs dev/ after merge. diff --git a/docs/mcp-setup.md b/docs/mcp-setup.md deleted file mode 100644 index 80e3bc85..00000000 --- a/docs/mcp-setup.md +++ /dev/null @@ -1,272 +0,0 @@ -# SciDK MCP Server Setup - -The SciDK MCP (Model Context Protocol) server exposes core SciDK functionality to external AI agents like Claude Desktop. - -## Features - -The MCP server provides 5 core tools: - -1. **`query_knowledge_graph`** — Execute safe read-only Cypher queries against the Neo4j knowledge graph -2. **`get_schema`** — Retrieve the current database schema (labels, relationships, properties) -3. **`summarize_dataset`** — Generate statistical summaries of labels/relationships -4. **`get_label_profile`** — Get detailed Schema Intelligence profiles for specific labels -5. **`list_labels`** — List all labels with node counts - -All tools return structured JSON responses and enforce read-only safety (no CREATE/MERGE/DELETE operations). - -## Installation - -### 1. Install MCP SDK - -```bash -pip install mcp>=1.0.0 -``` - -This is already included in `requirements.txt`. - -### 2. Configure Claude Desktop - -Add the following to your Claude Desktop configuration file: - -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` -**Linux**: `~/.config/Claude/claude_desktop_config.json` - -```json -{ - "mcpServers": { - "scidk": { - "command": "python3", - "args": ["-m", "scidk.mcp_server"], - "cwd": "/path/to/scidk", - "env": { - "NEO4J_URI": "bolt://localhost:7687", - "NEO4J_USER": "neo4j", - "NEO4J_PASSWORD": "neo4jiscool", - "NEO4J_DATABASE": "neo4j" - } - } - } -} -``` - -**Important**: Replace `/path/to/scidk` with the absolute path to your SciDK installation. - -### 3. Restart Claude Desktop - -After updating the config, restart Claude Desktop completely. The MCP server will start automatically when you open a new conversation. - -## Testing - -### Manual Test - -You can test the MCP server directly from the command line: - -```bash -# Start the server (it uses stdio transport) -python3 -m scidk.mcp_server -``` - -The server will connect to Neo4j and wait for MCP protocol messages on stdin. - -### Test in Claude Desktop - -Once configured, open Claude Desktop and try these queries: - -1. **List available labels:** - ``` - Use the list_labels tool to show me all labels in the database - ``` - -2. **Get schema:** - ``` - Use get_schema to show me the database structure - ``` - -3. **Query the graph:** - ``` - Use query_knowledge_graph to run: MATCH (f:File) RETURN f.name LIMIT 5 - ``` - -4. **Get label profile:** - ``` - Use get_label_profile to analyze the File label - ``` - -## Tool Usage Examples - -### 1. query_knowledge_graph - -Execute a read-only Cypher query: - -```json -{ - "cypher": "MATCH (f:File) WHERE f.mime_type CONTAINS 'image' RETURN f.name, f.path LIMIT 10", - "limit": 10 -} -``` - -Returns: -```json -{ - "status": "success", - "rows": [ - {"f.name": "image1.png", "f.path": "/data/images/image1.png"}, - ... - ], - "row_count": 10, - "error": null -} -``` - -### 2. get_schema - -Retrieve database schema: - -```json -{ - "max_labels": 50, - "max_props_per_label": 5 -} -``` - -Returns: -```json -{ - "status": "success", - "schema": { - "labels": ["File", "Folder", "Scan", "Sample", "SampleType"], - "relationships": ["CONTAINS", "DERIVED_FROM", "SCANNED_IN"], - "properties": { - "File": ["name", "path", "mime_type", "size", "created"], - "Folder": ["name", "path", "host", "host_id"] - }, - "label_counts": { - "File": 1523, - "Folder": 342, - ... - } - }, - "error": null -} -``` - -### 3. list_labels - -Get all labels with counts: - -```json -{} -``` - -Returns: -```json -{ - "status": "success", - "labels": [ - {"name": "File", "count": 1523}, - {"name": "Folder", "count": 342}, - {"name": "Scan", "count": 89}, - {"name": "Sample", "count": 45}, - {"name": "SampleType", "count": 12} - ], - "error": null -} -``` - -### 4. get_label_profile - -Get detailed profile for a label: - -```json -{ - "label": "File" -} -``` - -Returns: -```json -{ - "status": "success", - "profile": { - "label": "File", - "node_count": 1523, - "properties": [ - {"name": "name", "frequency": 1523}, - {"name": "path", "frequency": 1523}, - {"name": "mime_type", "frequency": 1521}, - {"name": "size", "frequency": 1520}, - {"name": "created", "frequency": 1518} - ], - "relationships": [ - {"type": "CONTAINS", "target": "Folder", "frequency": 1523}, - {"type": "DERIVED_FROM", "target": "Sample", "frequency": 342} - ] - }, - "error": null -} -``` - -### 5. summarize_dataset - -Generate statistical summary: - -```json -{ - "label": "File" -} -``` - -Returns: -```json -{ - "status": "success", - "summary": "Dataset summary: {'node_count': 1523, 'label': 'File'}", - "error": null -} -``` - -## Safety Features - -The MCP server enforces strict safety rules: - -- **Read-only queries**: Blocks CREATE, MERGE, DELETE, SET, DROP, DETACH keywords -- **Automatic LIMIT**: Adds `LIMIT 50` to queries without explicit limits -- **Error handling**: Returns structured error messages instead of crashing -- **Connection pooling**: Uses Neo4j driver connection pooling for efficiency - -## Troubleshooting - -### MCP server not appearing in Claude Desktop - -1. Check the Claude Desktop config file path -2. Verify JSON syntax is valid (use a JSON validator) -3. Check that the `cwd` path is correct -4. Restart Claude Desktop completely (quit and reopen) - -### Connection errors - -1. Verify Neo4j is running: `docker ps | grep neo4j` -2. Test connection manually: `python3 -m scidk.mcp_server` -3. Check environment variables in config -4. Verify Neo4j credentials are correct - -### Tools not working - -1. Check the Claude Desktop logs (varies by platform) -2. Verify the MCP server can import scidk modules -3. Test tools directly via Python: - ```python - from scidk.ai import mcp_tools - from neo4j import GraphDatabase - - driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "neo4jiscool")) - result = mcp_tools.list_labels(driver, "neo4j") - print(result) - ``` - -## Next Steps - -Once the MCP server is working, these tools can be integrated into the Concept Graph Phase 3 as `:Concept_Tool` nodes. The Concept Graph will then route user intents to MCP tools alongside native SciDK tools, creating a unified planning layer. - -See the [SciDK Architecture Vision](SciDK_Architecture_Vision.md) for the full integration plan. diff --git a/docs/setup_rclone.md b/docs/setup_rclone.md deleted file mode 100644 index 49de94ac..00000000 --- a/docs/setup_rclone.md +++ /dev/null @@ -1,143 +0,0 @@ -# Rclone Setup - -Ops reference for configuring rclone remotes used by a SciDK instance. For UI/API usage of the rclone provider see [`docs/rclone/quickstart.md`](rclone/quickstart.md); for FUSE mounts see [`docs/rclone/mount-examples.md`](rclone/mount-examples.md). - -## Install rclone - -Install from the official package (do not vendor the binary). See . - -```bash -# Linux -curl https://rclone.org/install.sh | sudo bash -# macOS -brew install rclone - -rclone version -``` - -SciDK shells out to whatever `rclone` is on `PATH` (`shutil.which('rclone')`). It must be installed on the same host as the SciDK process — not just inside a container the app cannot reach. - -## How SciDK uses rclone - -`RcloneProvider` (`scidk/core/providers.py`) drives the `rclone` CLI directly: - -- **Discover remotes:** `rclone listremotes` → populates the provider roots (each line like `dropbox:`). -- **Browse / scan:** `rclone lsjson ` → JSON entries (`Name`, `Path`, `Size`, `IsDir`) consumed by the scan loop. -- **Fetch content:** `rclone cat ` via `provider.cat()` / `provider.open()`. - -Any remote you create with `rclone config` is therefore visible to SciDK automatically — there is no separate SciDK-side remote registry. Enable the provider with: - -```bash -export SCIDK_PROVIDERS=local_fs,mounted_fs,rclone -``` - -Verify a remote is reachable before scanning: - -```bash -rclone listremotes # SciDK sees exactly these -rclone lsjson : # what a scan will enumerate -``` - -## Configuring remotes - -Run `rclone config` and follow the interactive prompts. Below are the three remote types used in this deployment. OAuth backends (SharePoint, Dropbox) need a browser for the initial token grant — if configuring on a headless server, run `rclone authorize` on a machine with a browser and paste the token, or use `rclone config` over an SSH tunnel. - -### SharePoint (Microsoft OneDrive provider) - -SharePoint document libraries are accessed through rclone's `onedrive` backend. - -```bash -rclone config -# n) New remote -# name> sharepoint -# Storage> onedrive -# Leave client_id / client_secret blank to use rclone's defaults -# region> 1 (Microsoft Cloud Global) -# Use auto config? > y (opens browser for OAuth) -# Then choose the site type: -# - "SharePoint site" / "Search for a SharePoint site" -# - enter the site name or URL, pick the document library (drive) -``` - -Confirm: - -```bash -rclone lsd sharepoint: -``` - -### Dropbox - -```bash -rclone config -# n) New remote -# name> dropbox -# Storage> dropbox -# Leave client_id / client_secret blank -# Use auto config? > y (opens browser for OAuth) -``` - -Confirm: - -```bash -rclone lsd dropbox: -``` - -### SFTP or mounted path (e.g. `/mnt/server`) - -For a local mount (NFS/CIFS already mounted by the OS, like the BMC-Lab6 archive at `/mnt/server`), you do **not** need an rclone remote at all — use SciDK's `mounted_fs` provider and point a scan at the path directly. - -To reach a server over SSH instead, configure an SFTP remote: - -```bash -rclone config -# n) New remote -# name> server -# Storage> sftp -# host> server.example.mit.edu -# user> -# port> 22 -# Auth: key_file> /home/scidk/.ssh/id_ed25519 (preferred) -# or set a password -``` - -Confirm: - -```bash -rclone lsd server:/path/to/data -``` - -## Reconnecting an expired OAuth token - -OAuth backends (SharePoint, Dropbox) hold refresh tokens that can expire or be revoked. Scans then fail with auth errors. Re-grant access without recreating the remote: - -```bash -rclone config reconnect sharepoint: -rclone config reconnect dropbox: -``` - -This reopens the browser OAuth flow and rewrites the token in place. Verify afterward with `rclone lsd :`. - -## Troubleshooting - -Diagnose any failure by reproducing the exact call SciDK makes, with verbose logging: - -```bash -rclone lsjson : -vv -``` - -| Symptom | Likely cause | Fix | -|---|---|---| -| `rclone not installed or not on PATH` from SciDK | `rclone` not visible to the app's user/environment | Install rclone for the SciDK service user; confirm `which rclone` | -| Remote missing from SciDK provider roots | Not in `rclone listremotes`, or `rclone` is a different config/user | Check `rclone config file` location; run `rclone listremotes` as the SciDK user | -| `401`/`403` / `token expired` / `invalid_grant` | OAuth token expired or revoked | `rclone config reconnect :` | -| `directory not found` / empty `lsjson` | Wrong path, or backend has no folder placeholders | Verify with `rclone lsd :`; check casing and the document-library name | -| Hang or timeout on large dirs | Backend slow to enumerate recursively | Scan with a bounded depth, or try `--fast-list` (provider retries without it on failure) | -| `couldn't connect SSH` | SFTP host/key/firewall | Test `ssh user@host`; confirm `key_file` path and permissions (`600`) | - -Useful checks: - -```bash -rclone config file # which config is in effect -rclone config show # inspect a remote's settings (redacts secrets) -rclone about : # quota / connectivity sanity check -``` diff --git a/docs/testing.md b/docs/testing.md index ddf9ca39..a81e9dd9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -166,7 +166,7 @@ Many features integrate with tools/services such as rclone and Neo4j. The test s - `SCIDK_PROVIDERS`: Feature-flag providers set (e.g., `local_fs,mounted_fs,rclone`) - `NEO4J_URI` / `NEO4J_USER` / `NEO4J_PASSWORD` / `NEO4J_AUTH`: Used to steer code paths; tests often set `NEO4J_AUTH=none` with a fake neo4j module - `SCIDK_RCLONE_MOUNTS` or `SCIDK_FEATURE_RCLONE_MOUNTS`: Enables rclone mount manager endpoints (tests mock subprocess) -- `SCIDK_E2E`: Set to `1` to enable E2E tests in local runs (E2E is disabled in CI as of Feb 2026) +- `SCIDK_E2E`: Set to `1` to enable E2E tests in local runs (automatically set in CI) ## Running Subsets and Debugging @@ -228,16 +228,20 @@ A GitHub Actions workflow is provided at `.github/workflows/ci.yml` - Fast feedback on API/unit/contract tests **E2E smoke (Playwright):** -- ⚠️ **Disabled in CI as of Feb 2026.** The E2E job is commented out in `.github/workflows/ci.yml`; CI runs only `pytest -m "not e2e"`. Continue writing E2E specs and run them **locally** — don't let E2E block PRs. -- When re-enabled, the job sets up Python 3.12 + Node 18, installs Playwright browsers (`npx playwright install --with-deps`), runs `npm run e2e` with `SCIDK_PROVIDERS=local_fs`. +- Sets up Python 3.12 (for Flask app) +- Sets up Node 18 +- Installs deps and Playwright browsers (`npx playwright install --with-deps`) +- Runs `npm run e2e` +- Environment: `SCIDK_PROVIDERS=local_fs` to avoid external dependencies +- Strict (no continue-on-error) now that smoke and core flows are stable ### Running Locally (CI-equivalent) ```bash -# Python tests (this is what CI runs) +# Python tests python -m pytest -q -m "not e2e" -# E2E tests (local only — not run in CI) +# E2E tests npm install npx playwright install --with-deps npm run e2e @@ -320,10 +324,10 @@ npm run e2e:headed # optional, debug mode ### CI Integration -⚠️ **E2E tests are disabled in CI as of Feb 2026** — run them locally with `npm run e2e` or `pytest -m e2e`. The E2E job in `.github/workflows/ci.yml` is commented out; CI runs only `pytest -m "not e2e"`. When the job is re-enabled it will: +E2E tests run automatically in GitHub Actions on every push and PR. See `.github/workflows/ci.yml`: -- **Job: `e2e`**: Run Playwright tests with `SCIDK_PROVIDERS=local_fs` -- **On failure**: Upload Playwright report and traces as artifacts +- **Job: `e2e`**: Runs Playwright tests with `SCIDK_PROVIDERS=local_fs` +- **On failure**: Uploads Playwright report and traces as artifacts - **Access artifacts**: Go to Actions → failed run → download `playwright-report` To view traces locally: diff --git a/pyproject.toml b/pyproject.toml index 9d1132b0..01d99338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,9 +24,6 @@ dependencies = [ "bcrypt>=4.0", "APScheduler>=3.10", "flasgger>=0.9.7", - "watchdog>=3.0", - "mcp>=1.0.0", - "python-dotenv>=1.0.0", ] [project.optional-dependencies] diff --git a/pytest_fully_output.txt b/pytest_fully_output.txt deleted file mode 100644 index f0c4c306..00000000 --- a/pytest_fully_output.txt +++ /dev/null @@ -1,433 +0,0 @@ -============================= test session starts ============================== -platform linux -- Python 3.12.3, pytest-7.4.4, pluggy-1.6.0 -rootdir: /home/patch/PycharmProjects/scidk -configfile: pyproject.toml -plugins: cov-7.0.0, playwright-0.4.3, base-url-2.1.0 -collected 927 items - -tests/test_alert_manager.py .............. [ 1%] -tests/test_alerts_api.py ............ [ 2%] -tests/test_annotations_and_selections.py .. [ 3%] -tests/test_annotations_relationships_syncqueue.py . [ 3%] -tests/test_annotations_rest_endpoints.py ........... [ 4%] -tests/test_api_endpoint_registry.py ................... [ 6%] -tests/test_app_api.py .... [ 6%] -tests/test_arrows_import_export.py .......... [ 7%] -tests/test_auth.py ...................... [ 10%] -tests/test_auth_multiuser.py ........................... [ 13%] -tests/test_auto_lock.py ............ [ 14%] -tests/test_backup_automation.py ............. [ 15%] -tests/test_bioformats_converter.py ........... [ 17%] -tests/test_bioformats_interpreters.py ................ [ 18%] -tests/test_bruker_microct_dataset.py .... [ 19%] -tests/test_bruker_skyscan_interpreter.py .. [ 19%] -tests/test_chat_api.py ....................................... [ 23%] -tests/test_commit_index_chain.py . [ 23%] -tests/test_commit_neo4j_attempt_flag.py . [ 23%] -tests/test_commit_verbose_and_health.py .. [ 24%] -tests/test_config_export_import.py ................... [ 26%] -tests/test_cross_database_transfer.py ............... [ 27%] -tests/test_csv_interpreter.py .. [ 27%] -tests/test_directories_api.py . [ 28%] -tests/test_eda_interpreter.py ........... [ 29%] -tests/test_error_and_timeout.py ... [ 29%] -tests/test_files_page_e2e.py .s....Fsss. [ 30%] -tests/test_filesystem.py ... [ 31%] -tests/test_folder_config_precedence.py . [ 31%] -tests/test_folder_hierarchy.py .... [ 31%] -tests/test_fs_list_api.py . [ 31%] -tests/test_fuzzy_matching.py ................. [ 33%] -tests/test_graph.py .. [ 33%] -tests/test_graph_combined_schema_api.py ......... [ 34%] -tests/test_graph_query_api.py ..s..... [ 35%] -tests/test_graph_schema_api.py .. [ 35%] -tests/test_graph_subschema_api.py .. [ 36%] -tests/test_graphrag_endpoints.py ... [ 36%] -tests/test_graphrag_entity_extractor.py ..... [ 36%] -tests/test_graphrag_errors.py .. [ 37%] -tests/test_graphrag_feedback.py ............. [ 38%] -tests/test_graphrag_observability.py . [ 38%] -tests/test_graphrag_query_engine.py ...... [ 39%] -tests/test_graphrag_utilities.py .. [ 39%] -tests/test_health_comprehensive.py ............... [ 41%] -tests/test_health_sqlite.py . [ 41%] -tests/test_helpers_example.py .... [ 41%] -tests/test_ilab_plugin.py ........... [ 42%] -tests/test_instances_api.py .. [ 43%] -tests/test_interpreters_effective_and_scan_config.py .. [ 43%] -tests/test_interpreters_page.py . [ 43%] -tests/test_interpreters_registry_api.py ..... [ 43%] -tests/test_ipynb_interpreter.py ...... [ 44%] -tests/test_ipynb_ui_render.py . [ 44%] -tests/test_label_endpoint_registry.py ............ [ 45%] -tests/test_label_instance_count.py ..F [ 46%] -tests/test_labels_api.py ................................... [ 50%] -tests/test_link_execution_progress.py ... [ 50%] -tests/test_links_api.py .........................FF... [ 53%] -tests/test_links_integration.py .s.....s. [ 54%] -tests/test_links_page.py ..... [ 55%] -tests/test_links_triple_import.py .............. [ 56%] -tests/test_logs_api.py .......... [ 57%] -tests/test_logs_endpoint.py ..... [ 58%] -tests/test_map_route.py . [ 58%] -tests/test_maps_features.py ................... [ 60%] -tests/test_metrics_endpoint.py . [ 60%] -tests/test_neo4j_adapter.py .... [ 60%] -tests/test_neo4j_commit.py . [ 61%] -tests/test_neo4j_commit_flow.py . [ 61%] -tests/test_neo4j_commit_folders.py . [ 61%] -tests/test_neo4j_password_save.py .. [ 61%] -tests/test_neo4j_settings_api.py .. [ 61%] -tests/test_plugin_endpoint_integration.py ......... [ 62%] -tests/test_plugin_instance_manager.py .......... [ 63%] -tests/test_plugin_label_publishing.py ......... [ 64%] -tests/test_plugin_loader.py ........... [ 65%] -tests/test_plugin_settings.py .............. [ 67%] -tests/test_plugin_settings_api.py .......... [ 68%] -tests/test_plugin_template_registry.py ............ [ 69%] -tests/test_plugins_api.py ..... [ 70%] -tests/test_progress_indicators.py ... [ 70%] -tests/test_property_removal_and_overwrite.py ......... [ 71%] -tests/test_providers_api.py ......... [ 72%] -tests/test_python_interpreter.py .. [ 72%] -tests/test_rclone_provider.py ... [ 73%] -tests/test_rclone_recursive_hierarchy.py . [ 73%] -tests/test_rclone_scan.py . [ 73%] -tests/test_rclone_scan_ingest.py . [ 73%] -tests/test_registry.py .. [ 73%] -tests/test_rescan_idempotency.py . [ 73%] -tests/test_rocrate_export.py .. [ 74%] -tests/test_rocrate_referenced.py .. [ 74%] -tests/test_saved_maps_service.py .................. [ 76%] -tests/test_scan_browse_indexed.py . [ 76%] -tests/test_scan_commit_delete.py .. [ 76%] -tests/test_scan_dry_run.py . [ 76%] -tests/test_scan_fs_auto_enter_base.py . [ 76%] -tests/test_scan_nonrecursive_includes_folders.py . [ 76%] -tests/test_scan_sources.py ... [ 77%] -tests/test_scans_committed_flag.py . [ 77%] -tests/test_schema.py ............................. [ 80%] -tests/test_schema_file_folder.py . [ 80%] -tests/test_script_sandbox.py ............................... [ 83%] -tests/test_scripts.py ...................... [ 86%] -tests/test_search_api.py ... [ 86%] -tests/test_seed_demo_data.py .............. [ 88%] -tests/test_selective_scan_cache.py . [ 88%] -tests/test_sqlite_migrations.py . [ 88%] -tests/test_sqlite_path_index.py .. [ 88%] -tests/test_state_backend_toggle.py .... [ 88%] -tests/test_swagger_api_docs.py ........... [ 90%] -tests/test_table_format_registry.py .................... [ 92%] -tests/test_table_loader_plugin.py ................... [ 94%] -tests/test_tasks_limits_cancel.py .. [ 94%] -tests/test_tasks_scan.py . [ 94%] -tests/test_transparency_analysis_scripts.py ssssssss [ 95%] -tests/test_transparency_chat_tools.py ..... [ 96%] -tests/test_transparency_dependencies.py .............. [ 97%] -tests/test_transparency_file_interpretation.py ...... [ 98%] -tests/test_transparency_plugins_page.py ......... [ 99%] -tests/contracts/test_api_contracts.py ...... [ 99%] -tests/e2e/test_demo_recording.py s [ 99%] -tests/e2e/test_persistence.py s [100%] - -=================================== FAILURES =================================== -________________________ test_no_synchronous_scan_in_ui ________________________ - - def test_no_synchronous_scan_in_ui(): - """Verify that synchronous /api/scan is NOT used by the Files page UI.""" - from scidk.app import create_app - app = create_app() - app.config['TESTING'] = True - - with authenticate_test_client(app.test_client(), app) as client: - resp = client.get('/datasets') - html = resp.data.decode('utf-8') - - # Check that the JavaScript does NOT call /api/scan from provider panel - # (it should only use /api/tasks) -> assert "'/api/scan'" not in html or html.count("'/api/scan'") <= 1 -E assert ("'/api/scan'" not in '\n' -E "'/api/scan'" is contained here: -E ait fetch('/api/scan', { -E method: 'POST', -E headers: { 'Content-Type': 'application/json' }, -E body: JSON.stringify(payload) -E }); -E ... -E -E ...Full output truncated (839 lines hidden), use '-vv' to show or 3 <= 1) -E + where 3 = ("'/api/scan'") -E + where = '\n\n\n \n -SciDK-> Files\n diff --git a/scidk/ui/templates/dataset_detail.html b/scidk/ui/templates/dataset_detail.html index d2492321..7b938537 100644 --- a/scidk/ui/templates/dataset_detail.html +++ b/scidk/ui/templates/dataset_detail.html @@ -14,7 +14,7 @@

{{ dataset.filename }}

  • Mime: {{ dataset.mime_type }}
  • {% if dataset.extension in ['.xlsx', '.xlsm'] %} -

    Open Workbook Viewer

    +

    Open Workbook Viewer

    {% endif %}

    Interpretations

    {% if dataset.interpretations %} diff --git a/scidk/ui/templates/datasets.html b/scidk/ui/templates/datasets.html index 40d7256a..932bc38b 100644 --- a/scidk/ui/templates/datasets.html +++ b/scidk/ui/templates/datasets.html @@ -851,7 +851,7 @@
    Select Files and Folders
    let data; if (nodeType === 'server') { // Load roots (first level only) - const r = await fetch(window.SCIDK_BASE + `/api/provider_roots?provider_id=${encodeURIComponent(nodeId)}`); + const r = await fetch(`/api/provider_roots?provider_id=${encodeURIComponent(nodeId)}`); const roots = await r.json(); data = roots.map(root => ({ id: root.id, @@ -1126,7 +1126,7 @@
    Select Files and Folders
    // ===== Servers List (Tree) ===== async function loadServers() { try { - const r = await fetch(window.SCIDK_BASE + '/api/servers'); + const r = await fetch('/api/servers'); if (!r.ok) { throw new Error(`API returned ${r.status}`); } @@ -1226,7 +1226,7 @@
    Select Files and Folders
    async function loadRootsAndBrowse(providerId) { try { - const r = await fetch(window.SCIDK_BASE + '/api/provider_roots?provider_id=' + encodeURIComponent(providerId)); + const r = await fetch('/api/provider_roots?provider_id=' + encodeURIComponent(providerId)); const roots = await r.json(); if (!Array.isArray(roots) || roots.length === 0) { @@ -1316,7 +1316,7 @@
    Select Files and Folders
    path: folderId }); - const r = await fetch(window.SCIDK_BASE + '/api/browse?' + qs.toString()); + const r = await fetch('/api/browse?' + qs.toString()); const data = await r.json(); if (data.entries && Array.isArray(data.entries)) { @@ -1454,7 +1454,7 @@
    Select Files and Folders
    } try { - const r = await fetch(window.SCIDK_BASE + '/api/browse?' + qs.toString()); + const r = await fetch('/api/browse?' + qs.toString()); const j = await r.json(); currentPath = path; @@ -1714,7 +1714,7 @@
    Select Files and Folders
    collapseRight.click(); } - fetch(window.SCIDK_BASE + `/api/scans`).then(r => r.json()).then(scans => { + fetch(`/api/scans`).then(r => r.json()).then(scans => { const scan = scans.find(s => s.id == scanId); if (!scan) return; @@ -1753,7 +1753,7 @@
    Select Files and Folders
    window.deleteScanDirect = async (scanId) => { if (!confirm(`Delete scan #${scanId}?`)) return; try { - await fetch(window.SCIDK_BASE + `/api/scans/${scanId}`, { method: 'DELETE' }); + await fetch(`/api/scans/${scanId}`, { method: 'DELETE' }); showMessage('Scan deleted', 'success'); loadScans(); } catch (e) { @@ -1778,7 +1778,7 @@
    Select Files and Folders
    // ===== Load Snapshots as Server Entries (for Snapshots mode) ===== async function loadSnapshotsAsServers() { try { - const r = await fetch(window.SCIDK_BASE + '/api/scans'); + const r = await fetch('/api/scans'); if (!r.ok) { throw new Error(`API returned ${r.status}`); } @@ -1862,7 +1862,7 @@
    Select Files and Folders
    // ===== Scans List (Tree) ===== async function loadScans() { try { - const r = await fetch(window.SCIDK_BASE + '/api/scans'); + const r = await fetch('/api/scans'); const scans = await r.json(); // Format scan timestamp for "Snapshot from: [date]" display @@ -1917,7 +1917,7 @@
    Select Files and Folders
    if (path) qs.set('path', path); try { - const r = await fetch(window.SCIDK_BASE + `/api/scans/${encodeURIComponent(scanId)}/browse?` + qs.toString()); + const r = await fetch(`/api/scans/${encodeURIComponent(scanId)}/browse?` + qs.toString()); const j = await r.json(); if (!r.ok) { @@ -1994,7 +1994,7 @@
    Select Files and Folders
    if (!confirm(`Delete "${path}" from snapshot #${scanId}?`)) return; try { - const r = await fetch(window.SCIDK_BASE + `/api/scans/${scanId}/entries`, { + const r = await fetch(`/api/scans/${scanId}/entries`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: path }) @@ -2036,7 +2036,7 @@
    Select Files and Folders
    root_id: currentRoot }; - const r = await fetch(window.SCIDK_BASE + '/api/tasks', { + const r = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) @@ -2069,7 +2069,7 @@
    Select Files and Folders
    commitScanBtn.textContent = 'Committing...'; try { - const r = await fetch(window.SCIDK_BASE + '/api/tasks', { + const r = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'commit', scan_id: currentScan }) @@ -2140,7 +2140,7 @@
    Select Files and Folders
    btn.addEventListener('click', async () => { const id = btn.getAttribute('data-cancel'); try { - await fetch(window.SCIDK_BASE + '/api/tasks/' + encodeURIComponent(id) + '/cancel', { method: 'POST' }); + await fetch('/api/tasks/' + encodeURIComponent(id) + '/cancel', { method: 'POST' }); fetchTasks(); } catch (e) { alert('Cancel failed: ' + e.message); @@ -2151,7 +2151,7 @@
    Select Files and Folders
    async function fetchTasks() { try { - const r = await fetch(window.SCIDK_BASE + '/api/tasks'); + const r = await fetch('/api/tasks'); const tasks = await r.json(); renderTasks(Array.isArray(tasks) ? tasks : []); } catch (e) { @@ -2235,7 +2235,7 @@
    Select Files and Folders
    btn.textContent = 'Refreshing...'; try { - const r = await fetch(window.SCIDK_BASE + `/api/scans/${scanId}/rescan`, { + const r = await fetch(`/api/scans/${scanId}/rescan`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -2271,7 +2271,7 @@
    Select Files and Folders
    // ===== Neo4j Status ===== async function fetchNeo4jStatus() { try { - const r = await fetch(window.SCIDK_BASE + '/api/health/comprehensive'); + const r = await fetch('/api/health/comprehensive'); const j = await r.json(); const light = document.getElementById('neo4j-light'); const txt = document.getElementById('neo4j-status-text'); @@ -2399,7 +2399,7 @@
    Select Files and Folders
    try { // Only search committed snapshots (indexed in database) // This is much faster than traversing live filesystems - const scansResp = await fetch(window.SCIDK_BASE + '/api/scans'); + const scansResp = await fetch('/api/scans'); const scans = await scansResp.json(); if (!scans || scans.length === 0) { @@ -2411,7 +2411,7 @@
    Select Files and Folders
    // Search across all scans in parallel const searchPromises = scans.map(scan => - fetch(window.SCIDK_BASE + `/api/scans/${scan.id}/browse?page_size=1000`) + fetch(`/api/scans/${scan.id}/browse?page_size=1000`) .then(r => r.json()) .then(data => ({ scanId: scan.id, @@ -2469,7 +2469,7 @@
    Select Files and Folders
    if (!confirm(`Delete "${path}" from snapshot #${scanId}?`)) return; try { - const r = await fetch(window.SCIDK_BASE + `/api/scans/${scanId}/entries`, { + const r = await fetch(`/api/scans/${scanId}/entries`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: path }) @@ -2572,7 +2572,7 @@
    Select Files and Folders
    selection: selection }; - const r = await fetch(window.SCIDK_BASE + '/api/scan', { + const r = await fetch('/api/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) @@ -2668,7 +2668,7 @@
    Select Files and Folders
    populateModalServerSelect = async function() { try { - const r = await fetch(window.SCIDK_BASE + '/api/servers'); + const r = await fetch('/api/servers'); const servers = await r.json(); const serverSelect = document.getElementById('snapshot-server-select'); @@ -2693,11 +2693,11 @@
    Select Files and Folders
    loadScanConfigForReconfigure = async function(scanId) { try { // Fetch scan details - const scanResp = await fetch(window.SCIDK_BASE + `/api/scans/${scanId}`); + const scanResp = await fetch(`/api/scans/${scanId}`); const scan = await scanResp.json(); // Fetch saved config - const configResp = await fetch(window.SCIDK_BASE + `/api/scans/${scanId}/config`); + const configResp = await fetch(`/api/scans/${scanId}/config`); const config = await configResp.json(); const nameInput = document.getElementById('snapshot-name-input'); @@ -2741,7 +2741,7 @@
    Select Files and Folders
    path: path || rootId || '/' }); - const r = await fetch(window.SCIDK_BASE + '/api/browse?' + qs.toString()); + const r = await fetch('/api/browse?' + qs.toString()); const data = await r.json(); // Update breadcrumb @@ -2842,7 +2842,7 @@
    Select Files and Folders
    path: folderId }); - const r = await fetch(window.SCIDK_BASE + '/api/browse?' + qs.toString()); + const r = await fetch('/api/browse?' + qs.toString()); const data = await r.json(); if (data.entries && Array.isArray(data.entries)) { @@ -2941,7 +2941,7 @@
    Select Files and Folders
    try { if (modalMode === 'reconfigure') { // Update existing scan config - const configResp = await fetch(window.SCIDK_BASE + `/api/scans/${modalScanId}/config`, { + const configResp = await fetch(`/api/scans/${modalScanId}/config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(selection) @@ -2951,7 +2951,7 @@
    Select Files and Folders
    if (startScan) { // Trigger rescan - const rescanResp = await fetch(window.SCIDK_BASE + `/api/scans/${modalScanId}/rescan`, { + const rescanResp = await fetch(`/api/scans/${modalScanId}/rescan`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -2979,7 +2979,7 @@
    Select Files and Folders
    }; if (startScan) { - const scanResp = await fetch(window.SCIDK_BASE + '/api/scan', { + const scanResp = await fetch('/api/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) @@ -2998,7 +2998,7 @@
    Select Files and Folders
    // TODO: Need API endpoint for this - for now just show message showMessage('Config-only save not yet implemented - scanning instead', 'info'); - const scanResp = await fetch(window.SCIDK_BASE + '/api/scan', { + const scanResp = await fetch('/api/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) diff --git a/scidk/ui/templates/files/_interpreter_modal.html b/scidk/ui/templates/files/_interpreter_modal.html index 6587e9e8..ad72ffc3 100644 --- a/scidk/ui/templates/files/_interpreter_modal.html +++ b/scidk/ui/templates/files/_interpreter_modal.html @@ -469,7 +469,7 @@

    🔬 Interpret File

    // Load available interpreters async function loadInterpreters() { try { - const response = await fetch(window.SCIDK_BASE + '/api/scripts/active?category=interpreters'); + const response = await fetch('/api/scripts/active?category=interpreters'); const data = await response.json(); interpreterLoading.style.display = 'none'; @@ -536,7 +536,7 @@

    🔬 Interpret File

    interpreterPreview.style.display = 'none'; try { - const response = await fetch(window.SCIDK_BASE + '/api/files/interpret', { + const response = await fetch('/api/files/interpret', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -604,7 +604,7 @@

    🔬 Interpret File

    interpreterError.style.display = 'none'; try { - const response = await fetch(window.SCIDK_BASE + '/api/files/interpret/commit', { + const response = await fetch('/api/files/interpret/commit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/scidk/ui/templates/index.html b/scidk/ui/templates/index.html index 9c836422..611fec3a 100644 --- a/scidk/ui/templates/index.html +++ b/scidk/ui/templates/index.html @@ -144,10 +144,7 @@ @@ -160,7 +157,7 @@ @@ -171,7 +168,6 @@
    Overview Users - API Tokens Audit Log - - -
    -

    Chat Intelligence

    -
    -
    - - - - -
    - -
    - -
    - Used in chat context and embedding. Editing will trigger re-embedding. -
    -
    -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    - -
    -
    -
    -
    - - -
    -
    -
    -
    - - -
    - -
    -
    - - -
    - -
    - - - -
    -
    @@ -1409,7 +960,6 @@
    Transfer to Primary Database
    // Arrows.app Import/Export document.getElementById('btn-import-arrows')?.addEventListener('click', openImportModal); document.getElementById('btn-export-arrows')?.addEventListener('click', exportToArrows); - document.getElementById('btn-export-schema-layer')?.addEventListener('click', exportSchemaLayer); // Import type radio button handling document.querySelectorAll('input[name="import-type"]').forEach(radio => { @@ -1468,7 +1018,7 @@
    Transfer to Primary Database
    formData.append('file', file); try { - const resp = await fetch(window.SCIDK_BASE + '/api/labels/import/eda', { + const resp = await fetch('/api/labels/import/eda', { method: 'POST', body: formData }); @@ -1953,7 +1503,7 @@
    Transfer to Primary Database
    const container = document.getElementById('label-list'); container.innerHTML = '
    Loading labels...
    '; - fetch(window.SCIDK_BASE + '/api/labels') + fetch('/api/labels') .then(r => { console.log('loadLabels() got response, status:', r.status); if (!r.ok) { @@ -2048,7 +1598,7 @@
    Transfer to Primary Database
    function navigateToPluginInstance(instanceId) { // Navigate to Settings > Plugins - window.location.href = window.SCIDK_BASE + '/settings#plugins'; + window.location.href = '/settings#plugins'; // Store the instance ID to highlight after navigation sessionStorage.setItem('highlight_plugin_instance', instanceId); } @@ -2179,20 +1729,7 @@
    Transfer to Primary Database
    } function loadLabel(name) { - // Check for unsaved intelligence changes - if (intelligenceProfileDirty && currentLabel && currentLabel.name !== name) { - const warning = document.getElementById('intel-unsaved-warning'); - warning.style.display = 'flex'; - - // Store the target label name for later - warning.dataset.targetLabel = name; - - // Scroll to warning - document.getElementById('intelligence-section').scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - - fetch(window.SCIDK_BASE + `/api/labels/${name}`) + fetch(`/api/labels/${name}`) .then(r => r.json()) .then(data => { if (data.status === 'success') { @@ -2447,399 +1984,8 @@
    Transfer to Primary Database
    } }); }); - - // Load intelligence profile - loadIntelligenceProfile(label.name); -} - -// Intelligence profile state -let intelligenceProfileData = null; -let intelligenceProfileDirty = false; - -function loadIntelligenceProfile(labelName) { - fetch(window.SCIDK_BASE + `/api/chat/schema/label/${labelName}`) - .then(r => r.json()) - .then(data => { - intelligenceProfileData = data; - intelligenceProfileDirty = false; - - // Description - const descInput = document.getElementById('intelligence-description-input'); - descInput.value = data.description || ''; - - // Context mode - const modeSelect = document.getElementById('intelligence-mode-select'); - const nInput = document.getElementById('intelligence-n-input'); - const nWrapper = document.getElementById('intelligence-mode-n-wrapper'); - const excludeWarning = document.getElementById('intel-exclude-warning'); - - const mode = data.chat_context_mode || 'top_n'; - const n = data.chat_context_n || 5; - - modeSelect.value = mode; - nInput.value = n; - - if (mode === 'top_n') { - nWrapper.style.display = 'inline-flex'; - excludeWarning.style.display = 'none'; - } else if (mode === 'exclude') { - nWrapper.style.display = 'none'; - excludeWarning.style.display = 'block'; - } else { - nWrapper.style.display = 'none'; - excludeWarning.style.display = 'none'; - } - - // Always include chips - const alwaysChips = document.getElementById('always-include-chips'); - renderChips(alwaysChips, data.always_include || [], 'always'); - - // Never include chips - const neverChips = document.getElementById('never-include-chips'); - renderChips(neverChips, data.never_include || [], 'never'); - - // Property usage bars - const usageEl = document.getElementById('intelligence-property-usage'); - if (data.property_rankings && data.property_rankings.length > 0) { - const maxCount = Math.max(...data.property_rankings.map(r => r.query_count || 0)); - usageEl.innerHTML = data.property_rankings.slice(0, 10).map(ranking => { - const pct = maxCount > 0 ? (ranking.query_count / maxCount) * 100 : 0; - return ` -
    - ${ranking.property} -
    -
    -
    - ${ranking.query_count || 0} -
    - `; - }).join(''); - } else { - usageEl.innerHTML = '
    No usage data yet
    '; - } - - // Embedding status - const statusEl = document.getElementById('intelligence-embedding-status'); - const status = data.embedding_status || 'none'; - statusEl.className = `embedding-badge ${status}`; - if (status === 'embedded') { - statusEl.textContent = '● Embedded'; - } else if (status === 'pending') { - statusEl.textContent = '○ Not embedded'; - } else { - statusEl.textContent = '○ No embedding'; - } - - // Hide save bar - document.getElementById('intel-save-bar').style.display = 'none'; - document.getElementById('intel-unsaved-warning').style.display = 'none'; - }) - .catch(err => { - console.error('Failed to load intelligence profile:', err); - document.getElementById('intelligence-section').innerHTML = - '
    Failed to load intelligence data
    '; - }); -} - -function renderChips(container, properties, type) { - if (properties.length === 0) { - container.innerHTML = 'None'; - return; - } - - container.innerHTML = properties.map(prop => ` - - ${prop} - - - `).join(''); -} - -function removeChip(field, property) { - const chips = field === 'always_include' - ? getChips('always-include-chips') - : getChips('never-include-chips'); - - const filtered = chips.filter(p => p !== property); - - if (field === 'always_include') { - renderChips(document.getElementById('always-include-chips'), filtered, 'always'); - } else { - renderChips(document.getElementById('never-include-chips'), filtered, 'never'); - } - - markIntelligenceDirty(); -} - -function getChips(containerId) { - const container = document.getElementById(containerId); - const chipEls = container.querySelectorAll('.tag-chip'); - return Array.from(chipEls).map(chip => chip.textContent.replace('×', '').trim()); -} - -function openPropertyDropdown(field, event) { - event.stopPropagation(); - - const dropdownId = `property-dropdown-${field === 'always_include' ? 'always' : 'never'}`; - const dropdown = document.getElementById(dropdownId); - const listId = `prop-list-${field === 'always_include' ? 'always' : 'never'}`; - const list = document.getElementById(listId); - - // Close other dropdowns - document.querySelectorAll('.property-dropdown').forEach(d => { - if (d.id !== dropdownId) d.style.display = 'none'; - }); - - // Toggle this dropdown - if (dropdown.style.display === 'block') { - dropdown.style.display = 'none'; - return; - } - - // Get available properties - const availableProps = currentLabel ? currentLabel.properties.map(p => p.name) : []; - const alwaysInclude = getChips('always-include-chips'); - const neverInclude = getChips('never-include-chips'); - - // Populate list - list.innerHTML = availableProps.map(prop => { - const isAlways = alwaysInclude.includes(prop); - const isNever = neverInclude.includes(prop); - const disabled = (field === 'always_include' && isAlways) || (field === 'never_include' && isNever); - - return ` -
  • - ${prop} ${disabled ? '(already added)' : ''} -
  • - `; - }).join(''); - - dropdown.style.display = 'block'; - - // Clear filter input - dropdown.querySelector('.prop-filter-input').value = ''; -} - -function filterPropertyDropdown(field) { - const dropdownId = `property-dropdown-${field === 'always_include' ? 'always' : 'never'}`; - const dropdown = document.getElementById(dropdownId); - const filter = dropdown.querySelector('.prop-filter-input').value.toLowerCase(); - const items = dropdown.querySelectorAll('.prop-dropdown-list li'); - - items.forEach(item => { - const text = item.textContent.toLowerCase(); - item.style.display = text.includes(filter) ? 'block' : 'none'; - }); -} - -function addChip(field, property) { - const alwaysChips = getChips('always-include-chips'); - const neverChips = getChips('never-include-chips'); - - // Check if property is in the other list (mutual exclusivity) - if (field === 'always_include' && neverChips.includes(property)) { - // Remove from never, add to always - const filtered = neverChips.filter(p => p !== property); - renderChips(document.getElementById('never-include-chips'), filtered, 'never'); - renderChips(document.getElementById('always-include-chips'), [...alwaysChips, property], 'always'); - showPropertyMoveIndicator(property, 'never', 'always'); - } else if (field === 'never_include' && alwaysChips.includes(property)) { - // Remove from always, add to never - const filtered = alwaysChips.filter(p => p !== property); - renderChips(document.getElementById('always-include-chips'), filtered, 'always'); - renderChips(document.getElementById('never-include-chips'), [...neverChips, property], 'never'); - showPropertyMoveIndicator(property, 'always', 'never'); - } else { - // Add to the target list - if (field === 'always_include' && !alwaysChips.includes(property)) { - renderChips(document.getElementById('always-include-chips'), [...alwaysChips, property], 'always'); - } else if (field === 'never_include' && !neverChips.includes(property)) { - renderChips(document.getElementById('never-include-chips'), [...neverChips, property], 'never'); - } - } - - // Close dropdown - const dropdownId = `property-dropdown-${field === 'always_include' ? 'always' : 'never'}`; - document.getElementById(dropdownId).style.display = 'none'; - - markIntelligenceDirty(); -} - -function showPropertyMoveIndicator(property, from, to) { - // Brief visual indicator that property moved between lists - const hint = document.getElementById('intel-save-hint'); - hint.textContent = `Moved "${property}" from ${from} → ${to}`; - setTimeout(() => updateSaveHint(), 2000); -} - -function handleModeChange() { - const mode = document.getElementById('intelligence-mode-select').value; - const nWrapper = document.getElementById('intelligence-mode-n-wrapper'); - const excludeWarning = document.getElementById('intel-exclude-warning'); - - if (mode === 'top_n') { - nWrapper.style.display = 'inline-flex'; - excludeWarning.style.display = 'none'; - } else if (mode === 'exclude') { - nWrapper.style.display = 'none'; - excludeWarning.style.display = 'block'; - } else { - nWrapper.style.display = 'none'; - excludeWarning.style.display = 'none'; - } - - markIntelligenceDirty(); -} - -function markIntelligenceDirty() { - if (!intelligenceProfileData) return; - - intelligenceProfileDirty = true; - - // Show save bar - document.getElementById('intel-save-bar').style.display = 'flex'; - - updateSaveHint(); -} - -function updateSaveHint() { - if (!intelligenceProfileData) return; - - const descInput = document.getElementById('intelligence-description-input'); - const modeSelect = document.getElementById('intelligence-mode-select'); - const nInput = document.getElementById('intelligence-n-input'); - - const descChanged = descInput.value !== (intelligenceProfileData.description || ''); - const modeChanged = modeSelect.value !== (intelligenceProfileData.chat_context_mode || 'top_n'); - const alwaysChips = getChips('always-include-chips'); - const neverChips = getChips('never-include-chips'); - - const alwaysChanged = JSON.stringify(alwaysChips.sort()) !== - JSON.stringify((intelligenceProfileData.always_include || []).sort()); - const neverChanged = JSON.stringify(neverChips.sort()) !== - JSON.stringify((intelligenceProfileData.never_include || []).sort()); - - const hint = document.getElementById('intel-save-hint'); - - if (descChanged) { - hint.textContent = 'Will re-embed after save'; - } else if (modeChanged) { - hint.textContent = 'Chat context will update immediately'; - } else if (alwaysChanged || neverChanged) { - hint.textContent = 'Property filters updated'; - } else { - hint.textContent = ''; - } -} - -async function saveIntelligenceProfile() { - if (!currentLabel) return; - - const descInput = document.getElementById('intelligence-description-input'); - const modeSelect = document.getElementById('intelligence-mode-select'); - const nInput = document.getElementById('intelligence-n-input'); - - const payload = { - description: descInput.value, - chat_context_mode: modeSelect.value, - chat_context_n: parseInt(nInput.value) || 5, - always_include: getChips('always-include-chips'), - never_include: getChips('never-include-chips') - }; - - const btn = document.querySelector('.btn-intel-save'); - btn.textContent = 'Saving...'; - btn.disabled = true; - - try { - const resp = await fetch( - window.SCIDK_BASE + `/api/chat/schema/label/${encodeURIComponent(currentLabel.name)}`, - { - method: 'PUT', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(payload) - } - ); - const data = await resp.json(); - - if (data.success) { - intelligenceProfileDirty = false; - document.getElementById('intel-save-bar').style.display = 'none'; - document.getElementById('intel-unsaved-warning').style.display = 'none'; - - // Update embedding status if re-embedded - const statusEl = document.getElementById('intelligence-embedding-status'); - if (payload.description) { - statusEl.className = 'embedding-badge embedded'; - statusEl.textContent = '● Embedded'; - } - - showIntelSaveToast( - payload.description && (payload.description !== intelligenceProfileData.description) - ? '✓ Saved and re-embedded' - : '✓ Saved' - ); - - // Reload profile data - loadIntelligenceProfile(currentLabel.name); - } else { - btn.textContent = 'Save Changes'; - btn.disabled = false; - showIntelSaveToast('Save failed: ' + (data.error || 'Unknown error'), true); - } - } catch (e) { - console.error('Save error:', e); - btn.textContent = 'Save Changes'; - btn.disabled = false; - showIntelSaveToast('Save failed', true); - } -} - -function discardIntelligenceChanges() { - if (!currentLabel) return; - - intelligenceProfileDirty = false; - document.getElementById('intel-save-bar').style.display = 'none'; - document.getElementById('intel-unsaved-warning').style.display = 'none'; - - // Reload from saved data - loadIntelligenceProfile(currentLabel.name); } -function discardAndSwitch() { - const warning = document.getElementById('intel-unsaved-warning'); - const targetLabel = warning.dataset.targetLabel; - - intelligenceProfileDirty = false; - warning.style.display = 'none'; - document.getElementById('intel-save-bar').style.display = 'none'; - - if (targetLabel) { - loadLabel(targetLabel); - } -} - -function showIntelSaveToast(message, isError = false) { - const toast = document.createElement('div'); - toast.className = `intel-save-toast ${isError ? 'error' : ''}`; - toast.textContent = message; - document.body.appendChild(toast); - - setTimeout(() => { - toast.remove(); - }, 3000); -} - -// Close dropdowns when clicking outside -document.addEventListener('click', (e) => { - if (!e.target.closest('.tag-add-container')) { - document.querySelectorAll('.property-dropdown').forEach(d => { - d.style.display = 'none'; - }); - } -}); - function getNextNavGroup(currentGroup) { const groups = ['property', 'outgoing-rel', 'incoming-rel']; const currentIdx = groups.indexOf(currentGroup); @@ -2979,7 +2125,7 @@
    Transfer to Primary Database
    try { // Fetch the source label - const resp = await fetch(window.SCIDK_BASE + `/api/labels/${sourceLabel}`); + const resp = await fetch(`/api/labels/${sourceLabel}`); const data = await resp.json(); if (data.status !== 'success') { @@ -3010,7 +2156,7 @@
    Transfer to Primary Database
    ]; // Save the updated source label - const saveResp = await fetch(window.SCIDK_BASE + '/api/labels', { + const saveResp = await fetch('/api/labels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -3026,7 +2172,7 @@
    Transfer to Primary Database
    showToast(`Added relationship from ${sourceLabel}`, 'success'); // Reload current label to update incoming relationships display - const refreshResp = await fetch(window.SCIDK_BASE + `/api/labels/${currentLabel.name}`); + const refreshResp = await fetch(`/api/labels/${currentLabel.name}`); const refreshData = await refreshResp.json(); if (refreshData.status === 'success') { @@ -3174,7 +2320,7 @@
    Transfer to Primary Database
    try { // First save the current label - const resp = await fetch(window.SCIDK_BASE + '/api/labels', { + const resp = await fetch('/api/labels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) @@ -3195,7 +2341,7 @@
    Transfer to Primary Database
    await applyIncomingRelationshipChanges(name, incomingRelationships); // Refresh the label to get updated incoming relationships - const refreshResp = await fetch(window.SCIDK_BASE + `/api/labels/${name}`); + const refreshResp = await fetch(`/api/labels/${name}`); const refreshData = await refreshResp.json(); if (refreshData.status === 'success') { @@ -3239,7 +2385,7 @@
    Transfer to Primary Database
    // Remove relationships from source labels for (const rel of toRemove) { try { - const resp = await fetch(window.SCIDK_BASE + `/api/labels/${rel.source_label}`); + const resp = await fetch(`/api/labels/${rel.source_label}`); const data = await resp.json(); if (data.status === 'success') { @@ -3248,7 +2394,7 @@
    Transfer to Primary Database
    !(r.type === rel.type && r.target_label === targetLabel) ); - await fetch(window.SCIDK_BASE + '/api/labels', { + await fetch('/api/labels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -3268,7 +2414,7 @@
    Transfer to Primary Database
    // Add relationships to source labels for (const rel of toAdd) { try { - const resp = await fetch(window.SCIDK_BASE + `/api/labels/${rel.source_label}`); + const resp = await fetch(`/api/labels/${rel.source_label}`); const data = await resp.json(); if (data.status === 'success') { @@ -3289,7 +2435,7 @@
    Transfer to Primary Database
    } ]; - await fetch(window.SCIDK_BASE + '/api/labels', { + await fetch('/api/labels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -3313,7 +2459,7 @@
    Transfer to Primary Database
    if (!confirm(`Delete label "${currentLabel.name}"?`)) return; - fetch(window.SCIDK_BASE + `/api/labels/${currentLabel.name}`, { method: 'DELETE' }) + fetch(`/api/labels/${currentLabel.name}`, { method: 'DELETE' }) .then(r => r.json()) .then(data => { if (data.status === 'success') { @@ -3346,7 +2492,7 @@
    Transfer to Primary Database
    try { // Fetch the source label - const resp = await fetch(window.SCIDK_BASE + `/api/labels/${sourceLabel}`); + const resp = await fetch(`/api/labels/${sourceLabel}`); const data = await resp.json(); if (data.status !== 'success') { @@ -3362,7 +2508,7 @@
    Transfer to Primary Database
    }); // Save the updated source label - const saveResp = await fetch(window.SCIDK_BASE + '/api/labels', { + const saveResp = await fetch('/api/labels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -3378,7 +2524,7 @@
    Transfer to Primary Database
    showToast(`Removed relationship from ${sourceLabel}`, 'success'); // Reload current label to update incoming relationships display - const refreshResp = await fetch(window.SCIDK_BASE + `/api/labels/${targetLabel}`); + const refreshResp = await fetch(`/api/labels/${targetLabel}`); const refreshData = await refreshResp.json(); if (refreshData.status === 'success') { @@ -3474,7 +2620,7 @@
    Transfer to Primary Database
    // Cancel transfer if running if (currentLabel) { try { - await fetch(window.SCIDK_BASE + `/api/labels/${encodeURIComponent(currentLabel.name)}/transfer-cancel`, { + await fetch(`/api/labels/${encodeURIComponent(currentLabel.name)}/transfer-cancel`, { method: 'POST' }); } catch (error) { @@ -3490,7 +2636,7 @@
    Transfer to Primary Database
    // Check if transfer already running try { - const statusResponse = await fetch(window.SCIDK_BASE + `/api/labels/${encodeURIComponent(currentLabel.name)}/transfer-status`); + const statusResponse = await fetch(`/api/labels/${encodeURIComponent(currentLabel.name)}/transfer-status`); const statusData = await statusResponse.json(); if (statusData.transfer_active) { @@ -3532,7 +2678,7 @@
    Transfer to Primary Database
    const pollInterval = setInterval(async () => { try { - const statusResp = await fetch(window.SCIDK_BASE + `/api/labels/${encodeURIComponent(currentLabel.name)}/transfer-status`); + const statusResp = await fetch(`/api/labels/${encodeURIComponent(currentLabel.name)}/transfer-status`); const statusData = await statusResp.json(); if (statusData.transfer_active && statusData.progress) { @@ -3612,7 +2758,7 @@
    Transfer to Primary Database
    }, 1000); // Poll every second try { - const response = await fetch(window.SCIDK_BASE + `/api/labels/${encodeURIComponent(currentLabel.name)}/transfer-to-primary?batch_size=${batchSize}&mode=${mode}&create_missing_targets=${createPlaceholders}`, { + const response = await fetch(`/api/labels/${encodeURIComponent(currentLabel.name)}/transfer-to-primary?batch_size=${batchSize}&mode=${mode}&create_missing_targets=${createPlaceholders}`, { method: 'POST' }); @@ -3667,7 +2813,7 @@
    Transfer to Primary Database
    async function getInstanceCount(labelName) { try { - const response = await fetch(window.SCIDK_BASE + `/api/labels/${labelName}/instance-count`); + const response = await fetch(`/api/labels/${labelName}/instance-count`); const data = await response.json(); return data; } catch (error) { @@ -3688,7 +2834,7 @@
    Transfer to Primary Database
    ], () => { showToast('Pulling from Neo4j...', 'info'); - fetch(window.SCIDK_BASE + `/api/labels/${currentLabel.name}/pull`, { method: 'POST' }) + fetch(`/api/labels/${currentLabel.name}/pull`, { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.status === 'success') { @@ -3736,7 +2882,7 @@
    Transfer to Primary Database
    showToast('Pulling all labels from Neo4j...', 'info'); - fetch(window.SCIDK_BASE + '/api/labels/pull', { method: 'POST' }) + fetch('/api/labels/pull', { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.status === 'success') { @@ -3754,7 +2900,7 @@
    Transfer to Primary Database
    showToast(`Pulling labels from ${profileName}...`, 'info'); - fetch(window.SCIDK_BASE + `/api/labels/pull?connection=${encodeURIComponent(profileName)}`, { method: 'POST' }) + fetch(`/api/labels/pull?connection=${encodeURIComponent(profileName)}`, { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.status === 'success') { @@ -3773,7 +2919,7 @@
    Transfer to Primary Database
    try { // Load available Neo4j profiles - const r = await fetch(window.SCIDK_BASE + '/api/settings/neo4j/profiles'); + const r = await fetch('/api/settings/neo4j/profiles'); const j = await r.json(); const profiles = j.profiles || []; @@ -3844,7 +2990,7 @@
    Transfer to Primary Database
    const arrowsJson = JSON.parse(input); showToast('Importing schema...', 'info'); - const resp = await fetch(window.SCIDK_BASE + '/api/labels/import/arrows', { + const resp = await fetch('/api/labels/import/arrows', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({arrows_json: arrowsJson, mode: 'merge'}) @@ -3874,23 +3020,13 @@
    Transfer to Primary Database
    async function exportToArrows() { try { showToast('Exporting schema...', 'info'); - window.location = window.SCIDK_BASE + '/api/labels/export/arrows'; + window.location = '/api/labels/export/arrows'; setTimeout(() => showToast('Schema exported successfully', 'success'), 500); } catch (e) { showToast('Export error: ' + e.message, 'error'); } } -async function exportSchemaLayer() { - try { - showToast('Exporting schema intelligence layer...', 'info'); - window.location = window.SCIDK_BASE + '/api/chat/schema/export'; - setTimeout(() => showToast('Schema layer exported successfully', 'success'), 500); - } catch (e) { - showToast('Export error: ' + e.message, 'error'); - } -} - // Batch operations async function batchPull() { if (selectedLabels.size === 0) return; @@ -3901,7 +3037,7 @@
    Transfer to Primary Database
    showToast(`Pulling schema for ${labelNames.length} labels...`, 'info'); try { - const resp = await fetch(window.SCIDK_BASE + '/api/labels/batch/pull', { + const resp = await fetch('/api/labels/batch/pull', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ label_names: labelNames }) @@ -3933,7 +3069,7 @@
    Transfer to Primary Database
    showToast(`Deleting ${labelNames.length} labels...`, 'info'); try { - const resp = await fetch(window.SCIDK_BASE + '/api/labels/batch/delete', { + const resp = await fetch('/api/labels/batch/delete', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ label_names: labelNames }) @@ -4022,7 +3158,7 @@

    InstancesInstancesInstancesInstances-SciDK->

    - + diff --git a/scidk/ui/templates/settings/_ai_history.html b/scidk/ui/templates/settings/_ai_history.html index 9bc0561f..f7ff01d1 100644 --- a/scidk/ui/templates/settings/_ai_history.html +++ b/scidk/ui/templates/settings/_ai_history.html @@ -3,86 +3,18 @@

    Chat History

    Manage chat sessions with persistent database storage.

    -
    - -
    -
    -
    - Storage Backend: Neo4j Chat Graph -
    -
    - Container: bolt://localhost:7688 -
    -
    - Purpose: Stores chat sessions, messages, and query audit trails -
    -
    - Status: Checking... -
    -
    -
    - - -
    -
    -

    - Chat sessions are stored in both SQLite (messages) and Neo4j (audit graph) and can be: -

    -
      -
    • Saved from the Chat interface using the "Save Session" button
    • -
    • Loaded from the session selector dropdown
    • -
    • Managed (rename, export, delete) using the "📂" button
    • -
    • Exported as JSON files and imported from backups
    • -
    -

    - Open Chat Interface → -

    -
    -
    +
    +

    + Chat sessions are now stored in the database and can be: +

    +
      +
    • Saved from the Chat interface using the "Save Session" button
    • +
    • Loaded from the session selector dropdown
    • +
    • Managed (rename, export, delete) using the "📂" button
    • +
    • Exported as JSON files and imported from backups
    • +
    +

    + Open Chat Interface → +

    - -
    - About Chat History Storage -
    -

    SciDK uses a dual-storage architecture for chat history:

    -
      -
    • SQLite: Fast session storage for conversation context (messages, metadata)
    • -
    • Neo4j Chat Graph: Audit trail with query provenance, ReAct steps, and finding graphs
    • -
    -

    Environment variables:

    -
      -
    • SCIDK_CHAT_NEO4J_URI - Chat graph Neo4j URI (default: bolt://localhost:7688)
    • -
    • SCIDK_CHAT_NEO4J_AUTH - Auth in format user/password (default: neo4j/chat-password)
    • -
    -
    -
    - - diff --git a/scidk/ui/templates/settings/_ai_llm_provider.html b/scidk/ui/templates/settings/_ai_llm_provider.html index ba9d0737..2ef30bed 100644 --- a/scidk/ui/templates/settings/_ai_llm_provider.html +++ b/scidk/ui/templates/settings/_ai_llm_provider.html @@ -161,7 +161,7 @@

    Ollama Configuration

    saveLLMConfigBtn.textContent = 'Saving...'; if (statusSpan) statusSpan.textContent = ''; - const resp = await fetch(window.SCIDK_BASE + '/api/chat/providers', { + const resp = await fetch('/api/chat/providers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) @@ -188,7 +188,7 @@

    Ollama Configuration

    // Load current LLM configuration async function loadLLMConfig() { try { - const resp = await fetch(window.SCIDK_BASE + '/api/chat/providers'); + const resp = await fetch('/api/chat/providers'); if (resp.ok) { const data = await resp.json(); diff --git a/scidk/ui/templates/settings/_ai_schema.html b/scidk/ui/templates/settings/_ai_schema.html deleted file mode 100644 index e7580303..00000000 --- a/scidk/ui/templates/settings/_ai_schema.html +++ /dev/null @@ -1,247 +0,0 @@ - -
    -

    Schema Intelligence

    -

    Manage schema intelligence layer - usage tracking, property rankings, and semantic embeddings.

    - -
    - -
    -
    -
    - Status: Loading... -
    -
    - Labels profiled: -
    -
    - Embedded: -
    -
    - Usage events: -
    -
    - Embedding model: nomic-embed-text -
    -
    -
    - - -
    -
    - - - -
    -
    - - - -
    - - - -
    -

    Import Schema Layer

    -
    -
    -

    This will update profiles for 0 labels.

    -

    Existing profiles will be updated with new data. This operation is non-destructive.

    - -
    -
    - - -
    -
    - -
    - About Schema Intelligence -
    -

    The Schema Intelligence Layer tracks how properties and labels are used in chat queries, ranks them by relevance, and creates semantic embeddings for intelligent schema retrieval.

    -

    Features:

    -
      -
    • Usage tracking: Records which properties are queried
    • -
    • Property ranking: Orders properties by frequency
    • -
    • Label profiles: Descriptions and chat behavior settings
    • -
    • Semantic embeddings: Enable context-aware schema retrieval
    • -
    • Export/Import: Share intelligence between instances
    • -
    -

    Environment variables:

    -
      -
    • SCIDK_CHAT_OLLAMA_ENDPOINT - Ollama endpoint for embeddings (default: http://localhost:11434)
    • -
    • SCIDK_SCHEMA_TOP_K - Number of labels to retrieve (default: 5)
    • -
    -
    -
    -
    - - diff --git a/scidk/ui/templates/settings/_alerts.html b/scidk/ui/templates/settings/_alerts.html index 4c3646de..91c51221 100644 --- a/scidk/ui/templates/settings/_alerts.html +++ b/scidk/ui/templates/settings/_alerts.html @@ -88,7 +88,7 @@

    Alert Definitions

    // ====================== async function loadSMTPConfig() { try { - const resp = await fetch(window.SCIDK_BASE + '/api/settings/smtp'); + const resp = await fetch('/api/settings/smtp'); const data = await resp.json(); if (data.status === 'success' && data.smtp) { @@ -151,7 +151,7 @@

    Alert Definitions

    payload.password = password; } - const resp = await fetch(window.SCIDK_BASE + '/api/settings/smtp', { + const resp = await fetch('/api/settings/smtp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) @@ -179,7 +179,7 @@

    Alert Definitions

    btn.textContent = 'Sending...'; try { - const resp = await fetch(window.SCIDK_BASE + '/api/settings/smtp/test', { + const resp = await fetch('/api/settings/smtp/test', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -217,7 +217,7 @@

    Alert Definitions

    // ====================== async function loadAlerts() { try { - const resp = await fetch(window.SCIDK_BASE + '/api/settings/alerts'); + const resp = await fetch('/api/settings/alerts'); const data = await resp.json(); if (data.status === 'success') { @@ -326,7 +326,7 @@

    async function toggleAlert(alertId, enabled) { try { - const resp = await fetch(window.SCIDK_BASE + `/api/settings/alerts/${alertId}`, { + const resp = await fetch(`/api/settings/alerts/${alertId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }) @@ -370,7 +370,7 @@

    } try { - const resp = await fetch(window.SCIDK_BASE + `/api/settings/alerts/${alertId}`, { + const resp = await fetch(`/api/settings/alerts/${alertId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) @@ -400,7 +400,7 @@

    btn.textContent = 'Sending...'; try { - const resp = await fetch(window.SCIDK_BASE + `/api/settings/alerts/${alertId}/test`, { + const resp = await fetch(`/api/settings/alerts/${alertId}/test`, { method: 'POST' }); @@ -429,7 +429,7 @@

    // ====================== async function loadAlertHistory() { try { - const resp = await fetch(window.SCIDK_BASE + '/api/settings/alerts/history?limit=50'); + const resp = await fetch('/api/settings/alerts/history?limit=50'); const data = await resp.json(); if (data.status === 'success') { diff --git a/scidk/ui/templates/settings/_backups.html b/scidk/ui/templates/settings/_backups.html index 2ac71ceb..dcb6df94 100644 --- a/scidk/ui/templates/settings/_backups.html +++ b/scidk/ui/templates/settings/_backups.html @@ -310,7 +310,7 @@

    Confirm Restore

    let originalSettings = null; function loadSettings() { - fetch(window.SCIDK_BASE + '/api/backups/settings') + fetch('/api/backups/settings') .then(res => res.json()) .then(data => { // Store original settings @@ -341,7 +341,7 @@

    Confirm Restore

    verify_backups: document.getElementById('setting-verify-backups').checked }; - fetch(window.SCIDK_BASE + '/api/backups/settings', { + fetch('/api/backups/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) @@ -380,7 +380,7 @@

    Confirm Restore

    document.getElementById('backups-table').style.display = 'none'; document.getElementById('backups-empty').style.display = 'none'; - fetch(window.SCIDK_BASE + '/api/backups') + fetch('/api/backups') .then(res => { if (!res.ok) { throw new Error(`HTTP ${res.status}`); @@ -517,7 +517,7 @@

    Confirm Restore

    btn.disabled = true; btn.textContent = 'Creating...'; - fetch(window.SCIDK_BASE + '/api/backups', { + fetch('/api/backups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: 'manual', verify: true }) @@ -541,7 +541,7 @@

    Confirm Restore

    } function verifyBackup(backupId) { - fetch(window.SCIDK_BASE + `/api/backups/verify/${backupId}`, { method: 'POST' }) + fetch(`/api/backups/verify/${backupId}`, { method: 'POST' }) .then(res => res.json()) .then(data => { if (data.verified) { @@ -569,7 +569,7 @@

    Confirm Restore

    btn.disabled = true; btn.textContent = 'Restoring...'; - fetch(window.SCIDK_BASE + `/api/backups/${selectedBackupId}/restore`, { + fetch(`/api/backups/${selectedBackupId}/restore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ create_backup_first: true }) @@ -599,7 +599,7 @@

    Confirm Restore

    return; } - fetch(window.SCIDK_BASE + `/api/backups/${backupId}`, { method: 'DELETE' }) + fetch(`/api/backups/${backupId}`, { method: 'DELETE' }) .then(res => res.json()) .then(data => { if (data.success) { @@ -619,7 +619,7 @@

    Confirm Restore

    btn.disabled = true; btn.textContent = 'Cleaning up...'; - fetch(window.SCIDK_BASE + '/api/backups/cleanup', { method: 'POST' }) + fetch('/api/backups/cleanup', { method: 'POST' }) .then(res => res.json()) .then(data => { if (data.success) { diff --git a/scidk/ui/templates/settings/_connections_chat_history.html b/scidk/ui/templates/settings/_connections_chat_history.html deleted file mode 100644 index f76a3db0..00000000 --- a/scidk/ui/templates/settings/_connections_chat_history.html +++ /dev/null @@ -1,150 +0,0 @@ - -
    -

    Chat History

    -

    Neo4j database for chat sessions, messages, and query audit trails.

    - - -
    -
    -
    -
    -
    -
    - Chat History Database -
    -
    bolt://localhost:7688
    -
    -
    - -
    -
    - - -
    -
    -
    Sessions
    -
    -
    -
    -
    Messages
    -
    -
    -
    -
    Queries
    -
    -
    -
    -
    Status
    -
    -
    -
    -
    -
    - - -
    -
    -

    - Chat sessions are stored in both SQLite (messages) and Neo4j (audit graph) and can be: -

    -
      -
    • Saved from the Chat interface using the "Save Session" button
    • -
    • Loaded from the session selector dropdown
    • -
    • Managed (rename, export, delete) using the "📂" button
    • -
    • Exported as JSON files and imported from backups
    • -
    -

    - Open Chat Interface → -

    -
    -
    -
    - -
    - Configuration -
    -

    Environment variables:

    -
      -
    • SCIDK_CHAT_NEO4J_URI - Chat graph Neo4j URI (default: bolt://localhost:7688)
    • -
    • SCIDK_CHAT_NEO4J_AUTH - Auth in format user/password (default: neo4j/chat-password)
    • -
    -

    Storage architecture:

    -
      -
    • SQLite: Fast session storage for conversation context (messages, metadata)
    • -
    • Neo4j Chat Graph: Audit trail with query provenance, ReAct steps, and finding graphs
    • -
    -
    -
    -
    - - diff --git a/scidk/ui/templates/settings/_connections_concept_graph.html b/scidk/ui/templates/settings/_connections_concept_graph.html deleted file mode 100644 index b9fa90c0..00000000 --- a/scidk/ui/templates/settings/_connections_concept_graph.html +++ /dev/null @@ -1,384 +0,0 @@ - -
    -

    Concept Graph

    -

    Intent-based query planning with embeddings, tools, and feedback-driven weights.

    - - -
    -
    -
    -
    -
    -
    - Concept Graph Database -
    -
    bolt://localhost:7689
    -
    -
    - - -
    -
    - - -
    -
    -
    Intents
    -
    -
    -
    -
    Tools
    -
    -
    -
    -
    SATISFIES edges
    -
    -
    -
    -
    Embedding model
    -
    nomic-embed-text
    -
    -
    -
    -
    - - -
    -

    Intents

    -
    - - - - - - - - - - - - - - -
    IntentTop ToolWeightUsage
    Loading...
    -
    -
    - - -
    -

    Tools

    -
    - - - - - - - - - - - - - -
    ToolStatusRetrieves
    Loading...
    -
    -
    -
    - - - -
    -

    Edit Intent:

    -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - -
    - About Concept Graph -
    -

    The Concept Graph maps user intents to tools and labels, using embeddings and traversal planning for intelligent query routing.

    -

    Features:

    -
      -
    • Intent classification: Matches user queries to execution paths
    • -
    • Tool selection: Chooses the right tool based on intent and labels
    • -
    • Feedback loop: Adjusts weights based on success/failure
    • -
    • Label awareness: Understands which tools retrieve which labels
    • -
    • Semantic embeddings: Intent similarity scoring using nomic-embed-text
    • -
    • Weight decay: Exponential decay on SATISFIES edges (90-day half-life)
    • -
    -

    Environment variables:

    -
      -
    • SCIDK_CONCEPT_NEO4J_URI - Concept graph Neo4j URI (default: bolt://localhost:7689)
    • -
    • SCIDK_CONCEPT_NEO4J_AUTH - Auth in format user/password (default: neo4j/concept-graph-password)
    • -
    • SCIDK_CHAT_OLLAMA_ENDPOINT - Ollama endpoint for embeddings (default: http://localhost:11434)
    • -
    • SCIDK_CONCEPT_WEIGHT_HALFLIFE_DAYS - Weight decay half-life in days (default: 90)
    • -
    -
    -
    -
    - - diff --git a/scidk/ui/templates/settings/_connections_local_storage.html b/scidk/ui/templates/settings/_connections_local_storage.html index dd186adf..7717be67 100644 --- a/scidk/ui/templates/settings/_connections_local_storage.html +++ b/scidk/ui/templates/settings/_connections_local_storage.html @@ -59,7 +59,7 @@

    Table Format Registry

    const status = document.getElementById('local-files-status'); // Load current setting - fetch(window.SCIDK_BASE + '/api/providers') + fetch('/api/providers') .then(r => r.json()) .then(data => { if (data.local_files_base) { @@ -75,7 +75,7 @@

    Table Format Registry

    status.className = 'small text-muted'; try { - const r = await fetch(window.SCIDK_BASE + '/api/providers', { + const r = await fetch('/api/providers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/scidk/ui/templates/settings/_connections_schema_map.html b/scidk/ui/templates/settings/_connections_schema_map.html deleted file mode 100644 index 8629e0d8..00000000 --- a/scidk/ui/templates/settings/_connections_schema_map.html +++ /dev/null @@ -1,206 +0,0 @@ - -
    -

    Schema Map

    -

    Visual overview of all labels and relationships in the Research Graph.

    - - -
    -
    -
    -
    -
    -
    - Research Graph Schema -
    -
    - Loading... -
    -
    -
    - - -
    -
    - - -
    -
    -
    🔄
    -
    Loading schema...
    -
    -
    - - -
    -
    Legend:
    -
    -
    -
    - High count (>1000) -
    -
    -
    - Medium count (100-1000) -
    -
    -
    - Low count (<100) -
    -
    -
    - Relationships -
    -
    -
    -
    -
    - - -
    -
    -

    - Interactions: -

    -
      -
    • Click a label node to navigate to the Labels page for that label
    • -
    • Hover over nodes to see counts and descriptions
    • -
    • Drag nodes to reposition them
    • -
    • Scroll to zoom in/out
    • -
    • Click "Reset Layout" to re-run the auto-layout algorithm
    • -
    -
    -
    -
    -
    - - - - - diff --git a/scidk/ui/templates/settings/_health.html b/scidk/ui/templates/settings/_health.html index 6ae71feb..14bf3ac7 100644 --- a/scidk/ui/templates/settings/_health.html +++ b/scidk/ui/templates/settings/_health.html @@ -205,7 +205,7 @@

    Component Details

    let lastHealthData = null; function updateHealthDashboard() { - fetch(window.SCIDK_BASE + '/api/health/comprehensive') + fetch('/api/health/comprehensive') .then(res => { if (!res.ok) { throw new Error(`HTTP ${res.status}`); diff --git a/scidk/ui/templates/settings/_interpreters.html b/scidk/ui/templates/settings/_interpreters.html index 0dc444dd..170f9b28 100644 --- a/scidk/ui/templates/settings/_interpreters.html +++ b/scidk/ui/templates/settings/_interpreters.html @@ -42,7 +42,7 @@

    Interpreter toggles

    diff --git a/scidk/ui/templates/settings/_security_users.html b/scidk/ui/templates/settings/_security_users.html index edc1edf9..b7eba498 100644 --- a/scidk/ui/templates/settings/_security_users.html +++ b/scidk/ui/templates/settings/_security_users.html @@ -24,7 +24,7 @@

    User List

    // Load users list async function loadUsers() { try { - const response = await fetch(window.SCIDK_BASE + '/api/users?include_disabled=true'); + const response = await fetch('/api/users?include_disabled=true'); if (response.ok) { const data = await response.json(); renderUsersList(data.users || []); @@ -199,7 +199,7 @@

    Add New User

    async function createUser(username, password, role) { try { - const response = await fetch(window.SCIDK_BASE + '/api/users', { + const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, role }), @@ -224,7 +224,7 @@

    Add New User

    const action = confirm('Enable or disable this user?\n\nOK = Enable, Cancel = Disable'); try { - const response = await fetch(window.SCIDK_BASE + `/api/users/${userId}`, { + const response = await fetch(`/api/users/${userId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: action }), @@ -249,7 +249,7 @@

    Add New User

    } try { - const response = await fetch(window.SCIDK_BASE + `/api/users/${userId}`, { + const response = await fetch(`/api/users/${userId}`, { method: 'DELETE', }); diff --git a/scidk/ui/templates/settings/_system_configuration.html b/scidk/ui/templates/settings/_system_configuration.html index ae056fba..55fcc2dd 100644 --- a/scidk/ui/templates/settings/_system_configuration.html +++ b/scidk/ui/templates/settings/_system_configuration.html @@ -30,7 +30,7 @@

    Configuration Management

    btnExportConfig.disabled = true; btnExportConfig.textContent = 'Exporting...'; - const resp = await fetch(window.SCIDK_BASE + '/api/settings/export', { + const resp = await fetch('/api/settings/export', { credentials: 'same-origin' }); @@ -103,7 +103,7 @@

    Configuration Management

    const formData = new FormData(); formData.append('backup_file', file); - const importResp = await fetch(window.SCIDK_BASE + '/api/settings/import', { + const importResp = await fetch('/api/settings/import', { method: 'POST', body: formData, credentials: 'same-origin' @@ -145,7 +145,7 @@

    Configuration Management

    if (btnViewBackups) { btnViewBackups.addEventListener('click', async () => { try { - const resp = await fetch(window.SCIDK_BASE + '/api/settings/backups?limit=20', { + const resp = await fetch('/api/settings/backups?limit=20', { credentials: 'same-origin' }); @@ -275,13 +275,13 @@

    Configuration Backups

    btn.textContent = 'Restoring...'; // Fetch the backup file and restore it - const backupResp = await fetch(window.SCIDK_BASE + `/backups/${filename}`); + const backupResp = await fetch(`/backups/${filename}`); const backupBlob = await backupResp.blob(); const formData = new FormData(); formData.append('backup_file', backupBlob, filename); - const restoreResp = await fetch(window.SCIDK_BASE + '/api/settings/import', { + const restoreResp = await fetch('/api/settings/import', { method: 'POST', body: formData, credentials: 'same-origin' @@ -319,7 +319,7 @@

    Configuration Backups

    } try { - const resp = await fetch(window.SCIDK_BASE + `/api/settings/backups/${filename}`, { + const resp = await fetch(`/api/settings/backups/${filename}`, { method: 'DELETE', credentials: 'same-origin' }); diff --git a/scidk/ui/templates/settings/_validation_modal.html b/scidk/ui/templates/settings/_validation_modal.html index 242b8d69..c8230814 100644 --- a/scidk/ui/templates/settings/_validation_modal.html +++ b/scidk/ui/templates/settings/_validation_modal.html @@ -193,7 +193,7 @@

    Validatio */ async function fetchAndDisplayValidation(scriptId) { try { - const response = await fetch(window.SCIDK_BASE + `/api/scripts/${scriptId}/validation-result`); + const response = await fetch(`/api/scripts/${scriptId}/validation-result`); const data = await response.json(); if (data.status === 'ok') { @@ -411,7 +411,7 @@

    Errors

    // Poll every 2 seconds validationModalPollingInterval = setInterval(async () => { try { - const response = await fetch(window.SCIDK_BASE + `/api/scripts/${scriptId}/validation-result`); + const response = await fetch(`/api/scripts/${scriptId}/validation-result`); const data = await response.json(); if (data.status === 'ok') { diff --git a/scidk/ui/templates/workbook.html b/scidk/ui/templates/workbook.html index 85f4eae3..3f507bfc 100644 --- a/scidk/ui/templates/workbook.html +++ b/scidk/ui/templates/workbook.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block title %}-SciDK-> Workbook Viewer{% endblock %} {% block content %} -

    ← Back to Files

    +

    ← Back to Files

    {% if not dataset %}

    Workbook

    Dataset not found.

    diff --git a/scidk/web/auth_middleware.py b/scidk/web/auth_middleware.py index a3eef286..bd01fb73 100644 --- a/scidk/web/auth_middleware.py +++ b/scidk/web/auth_middleware.py @@ -88,12 +88,10 @@ def check_auth(): # Get session token from cookie or header token = request.cookies.get('scidk_session') - bearer_token = None - auth_header = request.headers.get('Authorization', '') - if auth_header.startswith('Bearer '): - bearer_token = auth_header[7:] if not token: - token = bearer_token + auth_header = request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + token = auth_header[7:] # Verify session (try multi-user first, fall back to legacy) user = auth.get_session_user(token) if token else None @@ -105,12 +103,6 @@ def check_auth(): # Legacy session - create minimal user dict user = {'username': username, 'role': 'admin'} - if not user and bearer_token: - # Not a session token — try it as a per-user API token. This lets - # non-browser clients (scripts, MATLAB, etc.) authenticate with a - # Bearer token and carry their existing role. - user = auth.verify_api_token(bearer_token) - if user: # Check if session is locked (only for non-lock-related routes) if not is_public_route(request.path): @@ -144,9 +136,7 @@ def check_auth(): return jsonify({'error': 'Authentication required'}), 401 else: # UI requests redirect to login - # Use request.script_root + request.path to get full path for subpath deployments - redirect_target = request.script_root + request.path - return redirect(url_for('ui.login', redirect=redirect_target)) + return redirect(url_for('ui.login', redirect=request.path)) def init_auth_middleware(app): diff --git a/scidk/web/decorators.py b/scidk/web/decorators.py index 86258a65..13f76855 100644 --- a/scidk/web/decorators.py +++ b/scidk/web/decorators.py @@ -5,39 +5,7 @@ """ from functools import wraps -from flask import g, jsonify, request - - -def _authenticate_bearer_token(): - """Authenticate a request via an ``Authorization: Bearer`` API token. - - This is the Bearer-first half of the auth check shared by the decorators - below. When a Bearer header carries a valid per-user API token, it - populates ``g`` with the token's user and role so the role check downstream - succeeds. It runs as a fallback to the auth middleware (which handles the - same tokens app-wide); it only ever authenticates on success and never - short-circuits, so a Bearer header carrying a session token — or an invalid - token already handled upstream — still falls through to the normal session - cookie check, leaving existing behavior unchanged. - """ - # Already authenticated upstream (middleware resolved a session or token). - if hasattr(g, 'scidk_user_role'): - return - - auth_header = request.headers.get('Authorization', '') - if not auth_header.startswith('Bearer '): - return - - token = auth_header[7:] - from flask import current_app - from ..core.auth import get_auth_manager - db_path = current_app.config.get('SCIDK_SETTINGS_DB', 'scidk_settings.db') - auth = get_auth_manager(db_path=db_path) - user = auth.verify_api_token(token) - if user: - g.scidk_user = user['username'] - g.scidk_user_role = user['role'] - g.scidk_user_id = user['id'] +from flask import g, jsonify def require_role(*allowed_roles): @@ -82,9 +50,6 @@ def decorated_function(*args, **kwargs): # Auth disabled in tests - allow the request return f(*args, **kwargs) - # Bearer API token first, then fall through to session cookie auth. - _authenticate_bearer_token() - # Check if user is authenticated if not hasattr(g, 'scidk_user_role'): return jsonify({'error': 'Authentication required'}), 401 @@ -106,8 +71,7 @@ def decorated_function(*args, **kwargs): def require_admin(f): """Decorator to require admin role for a route. - Shortcut for @require_role('admin'), with special handling for first-time setup. - When there are zero users, allows unauthenticated access for initial admin creation. + Shortcut for @require_role('admin'). Usage: @app.route('/admin/users') @@ -121,55 +85,4 @@ def admin_users(): Returns: Decorated function """ - @wraps(f) - def decorated_function(*args, **kwargs): - # In test mode with auth disabled, allow all requests - import os - import sys - from flask import current_app - is_testing = ( - current_app.config.get('TESTING', False) or - 'pytest' in sys.modules or - os.environ.get('SCIDK_E2E_TEST') - ) - if is_testing and not os.environ.get('PYTEST_TEST_AUTH'): - from ..core.auth import get_auth_manager - db_path = current_app.config.get('SCIDK_SETTINGS_DB', 'scidk_settings.db') - auth = get_auth_manager(db_path=db_path) - if not auth.is_enabled(): - return f(*args, **kwargs) - - # Bearer API token first, then fall through to session cookie auth. - _authenticate_bearer_token() - - # Check for first-time setup (zero users) - allow unauthenticated access - # ONLY for creating the first admin (a POST). Read/update/delete on a - # zero-user state must still require authentication (return 401), since - # the bootstrap path only needs to create the initial admin user. - if request.method == 'POST': - from ..core.auth import get_auth_manager - from flask import current_app - db_path = current_app.config.get('SCIDK_SETTINGS_DB', 'scidk_settings.db') - auth = get_auth_manager(db_path=db_path) - try: - user_count = len(auth.list_users(include_disabled=True)) - if user_count == 0: - # First-time setup - allow access without authentication - return f(*args, **kwargs) - except Exception: - pass - - # Normal admin role check - if not hasattr(g, 'scidk_user_role'): - return jsonify({'error': 'Authentication required'}), 401 - - user_role = g.scidk_user_role - if user_role != 'admin': - return jsonify({ - 'error': 'Insufficient permissions', - 'required_roles': ['admin'], - 'your_role': user_role - }), 403 - - return f(*args, **kwargs) - return decorated_function + return require_role('admin')(f) diff --git a/scidk/web/routes/__init__.py b/scidk/web/routes/__init__.py index 49fc0d49..d812bcde 100644 --- a/scidk/web/routes/__init__.py +++ b/scidk/web/routes/__init__.py @@ -41,7 +41,6 @@ def register_blueprints(app): from . import api_settings from . import api_auth from . import api_users - from . import api_tokens from . import api_audit from . import api_queries from . import api_alerts @@ -75,7 +74,6 @@ def register_blueprints(app): app.register_blueprint(api_settings.bp) app.register_blueprint(api_auth.bp) app.register_blueprint(api_users.bp) - app.register_blueprint(api_tokens.bp) app.register_blueprint(api_audit.bp) app.register_blueprint(api_alerts.bp) app.register_blueprint(api_logs.bp) diff --git a/scidk/web/routes/api_auth.py b/scidk/web/routes/api_auth.py index ea112011..2cda1f17 100644 --- a/scidk/web/routes/api_auth.py +++ b/scidk/web/routes/api_auth.py @@ -244,12 +244,6 @@ def api_auth_status(): auth = _get_auth_manager() auth_enabled = auth.is_enabled() - # Get user count for first-time setup detection - try: - user_count = len(auth.list_users(include_disabled=True)) - except Exception: - user_count = 0 - # If auth is disabled, everyone is authenticated if not auth_enabled: return jsonify({ @@ -258,7 +252,6 @@ def api_auth_status(): 'auth_enabled': False, 'token_valid': False, 'session_locked': False, - 'user_count': user_count, }), 200 # Check if user has valid session (try multi-user first) @@ -283,7 +276,6 @@ def api_auth_status(): 'auth_enabled': True, 'token_valid': True, 'session_locked': session_locked, - 'user_count': user_count, }), 200 else: return jsonify({ @@ -294,7 +286,6 @@ def api_auth_status(): 'auth_enabled': True, 'token_valid': False, 'session_locked': False, - 'user_count': user_count, }), 200 diff --git a/scidk/web/routes/api_chat.py b/scidk/web/routes/api_chat.py index 895f6a09..95f60504 100644 --- a/scidk/web/routes/api_chat.py +++ b/scidk/web/routes/api_chat.py @@ -1,25 +1,14 @@ """ Blueprint for Chat/LLM API routes. """ -from flask import Blueprint, jsonify, request, current_app, Response +from flask import Blueprint, jsonify, request, current_app from pathlib import Path import json import os import time -import threading -import logging - -logger = logging.getLogger(__name__) bp = Blueprint('chat', __name__, url_prefix='/api') -# ========== SSE Connection Limiter ========== -# Track active SSE connections to prevent worker pool exhaustion -# With 16 sync gunicorn workers, limit to 12 concurrent streams -_sse_connection_lock = threading.Lock() -_active_sse_connections = 0 -MAX_SSE_CONNECTIONS = 12 - def _get_ext(): """Get SciDK extensions from current Flask current_app.""" return current_app.extensions['scidk'] @@ -36,23 +25,6 @@ def _get_feedback_service(): db_path = current_app.config.get('SCIDK_SETTINGS_DB', 'scidk_settings.db') return get_graphrag_feedback_service(db_path=db_path) -def _map_concept_intent_to_legacy(intent_name: str): - """Map concept graph intent name back to legacy Intent enum for execution routing.""" - from ...services.graphrag.intent_classifier import Intent - - # Map concept intent names to legacy execution paths - INTENT_MAP = { - 'data_lookup': Intent.LOOKUP, - 'count_simple': Intent.LOOKUP, - 'count_filtered': Intent.REACT, - 'summarize_dataset': Intent.SUMMARIZE, - 'relationship_traversal': Intent.REACT, - 'property_exploration': Intent.REASONING, - 'reasoning_multi_step': Intent.REACT, - } - - return INTENT_MAP.get(intent_name, Intent.REASONING) # Default to REASONING for safety - @bp.post('/chat') def api_chat(): data = request.get_json(force=True, silent=True) or {} @@ -84,27 +56,6 @@ def api_chat_graphrag(): message = (data.get('message') or '').strip() if not message: return jsonify({"status": "error", "error": "message required"}), 400 - - # Fetch recent conversation context from SQLite for continuity - session_id = data.get('session_id', 'default') - - # DEBUG: Log session_id being used - print(f"DEBUG session_id from request: {session_id}") - print(f"DEBUG request data keys: {list(data.keys())}") - - chat_service = _get_chat_service() - conversation_context = chat_service.get_recent_turns(session_id, n=4) - - # DEBUG: Log conversation context - print(f"DEBUG context length: {len(conversation_context)}") - print(f"DEBUG conversation_context: {conversation_context}") - - # Prepend context to message for intent classification and execution - message_with_context = f"{conversation_context}\n{message}" if conversation_context else message - - # DEBUG: Log enriched message - print(f"DEBUG message_with_context: {message_with_context[:300]}") - # Reuse existing Neo4j connection params try: from ...services.neo4j_client import get_neo4j_params @@ -171,336 +122,50 @@ def complete(self, prompt: str) -> str: schema_cache['last_loaded_ts'] = now neo4j_schema = schema_cache.get('schema') or {"labels": [], "relationships": []} - # Classify intent for routing using Concept Graph or fallback to hard-coded classifier + # Classify intent for routing (LOOKUP vs REASONING) from ...services.graphrag.intent_classifier import classify, Intent - concept_driver = _get_ext().get('concept_driver') - traversal_log = None - - try: - if concept_driver is not None: - # Use Concept Graph for intent classification and planning - from ...services.concept_graph_service import ( - classify_intent, plan_execution, build_traversal_log, - ConceptGraphUnavailableError - ) - from ...services.schema_intelligence import get_relevant_schema_context - - # Get SQLite connection - chat_service_tmp = _get_chat_service() - sqlite_conn_tmp = chat_service_tmp._get_conn() - - try: - ollama_url = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - - # Get relevant schema context (for semantic retrieval) - schema_context = get_relevant_schema_context( - message_with_context, sqlite_conn_tmp, driver, - ollama_url, database=database or "neo4j" - ) - relevant_labels = schema_context.get('labels', []) - - # Classify intent using concept graph - # Use raw message (not context) for intent - context pollutes classification - intent_name, intent_confidence = classify_intent( - message, concept_driver, sqlite_conn_tmp, ollama_url - ) - print(f"DEBUG concept_graph: intent_name={intent_name}, confidence={intent_confidence}") - - # Plan execution - plan = plan_execution(intent_name, relevant_labels, concept_driver) - print(f"DEBUG concept_graph: plan={plan}") - - # Build traversal log - traversal_log = build_traversal_log( - query=message, - intent_matched=intent_name, - intent_confidence=intent_confidence, - plan=plan, - labels_considered=list(schema_context.get('labels', [])) - ) - - # Map concept intent to legacy Intent enum - intent = _map_concept_intent_to_legacy(intent_name) - print(f"DEBUG concept_graph: mapped to legacy intent={intent}, value={intent.value}") - - # Log traversal to SQLite - sqlite_conn_tmp.execute( - "INSERT INTO usage_event (event_type, label_name, session_id, " - "source, traversal_json) VALUES (?, ?, ?, ?, ?)", - ('concept_graph_plan', '', data.get('session_id', 'default'), - 'chat', json.dumps(traversal_log)) - ) - sqlite_conn_tmp.commit() - - finally: - sqlite_conn_tmp.close() - - else: - # Fallback to hard-coded classifier - intent = classify(message_with_context) - - except Exception as e: - # Concept graph error — fall back to hard-coded classifier - import logging - logging.warning(f"Concept graph classification failed: {e}") - intent = classify(message_with_context) - traversal_log = None - - # DEBUG: Log classified intent - print(f"DEBUG classified intent: {intent}") - print(f"DEBUG intent value: {intent.value}") + intent = classify(message) # Route based on intent if intent == Intent.LOOKUP: - # LOOKUP path: Direct Cypher generation using provider - # This path bypasses QueryEngine to avoid neo4j-graphrag LLM interface requirements - from ...ai.cypher_utils import build_cypher_system_prompt, extract_cypher - from ...ai.provider_factory import LLMProviderFactory - - # Build settings dict from environment/config - settings = { - 'chat_llm_provider': data.get('provider') or os.environ.get('SCIDK_CHAT_LLM_PROVIDER'), - 'chat_ollama_endpoint': os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT'), - 'chat_ollama_model': os.environ.get('SCIDK_CHAT_OLLAMA_MODEL'), - 'chat_claude_api_key': os.environ.get('SCIDK_CHAT_CLAUDE_API_KEY'), - 'chat_openai_api_key': os.environ.get('SCIDK_CHAT_OPENAI_API_KEY'), - } - - provider_obj = LLMProviderFactory.from_settings(settings) - start_time_lookup = time.time() - - # Step 1: Generate Cypher using specialized prompt - cypher_prompt = build_cypher_system_prompt(neo4j_schema) - - try: - cypher_response = provider_obj.complete( - user_message=message_with_context, - system_prompt=cypher_prompt, - schema_context=None # Don't inject schema - already in cypher_prompt - ) - - # Step 2: Extract Cypher from response - cypher_query = extract_cypher(cypher_response) - - if cypher_query is None: - # Fallback to REASONING path if no valid Cypher extracted - from ...ai.schema_context import get_schema_context - import os as os_mod - try: - from ...services.schema_intelligence import get_relevant_schema_context - chat_service_tmp = _get_chat_service() - sqlite_conn_tmp = chat_service_tmp._get_conn() - try: - schema_context = get_relevant_schema_context( - user_query=message_with_context, - sqlite_conn=sqlite_conn_tmp, - neo4j_driver=driver, - ollama_url=os_mod.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434'), - database=database or "neo4j" - ) - finally: - sqlite_conn_tmp.close() - except Exception as e: - import logging - logging.warning(f"Schema intelligence failed: {e}") - schema_context = get_schema_context(driver, database=database or "neo4j") - base_prompt = "You are a research data assistant for SciDK. Answer questions about the knowledge graph and scientific data." - - response_text = provider_obj.complete( - user_message=message_with_context, - system_prompt=base_prompt, - schema_context=schema_context - ) - - elapsed_ms = int((time.time() - start_time_lookup) * 1000) - response_data = { - "status": "ok", - "reply": response_text, - "engine": "reasoning_fallback", - "metadata": { - "note": "Could not generate valid Cypher, used reasoning instead", - "execution_time_ms": elapsed_ms - } - } - else: - # Step 3: Execute Cypher - with driver.session(database=database) if database else driver.session() as session: - try: - result = session.run(cypher_query) - records = [record.data() for record in result] - result_count = len(records) - - # Phase 1: Log query usage (never fails) - try: - chat_service = _get_chat_service() - sqlite_conn = chat_service._get_conn() - try: - from ...services.schema_intelligence import log_query_usage - log_query_usage(cypher_query, session_id, sqlite_conn, source='chat') - finally: - sqlite_conn.close() - except Exception: - pass # Logging must never fail a query - - except Exception as query_error: - # Cypher execution failed - return error with query for debugging - elapsed_ms = int((time.time() - start_time_lookup) * 1000) - return jsonify({ - "status": "error", - "error": f"Query execution failed: {str(query_error)}", - "cypher_query": cypher_query, - "metadata": { - "execution_time_ms": elapsed_ms - } - }), 500 - - # Step 4: Synthesize natural language answer from results - synthesis_prompt = f"""You are a research data assistant. A user asked a question and we ran a database query. - -Context and Question: -{message_with_context} - -Query Results: {records[:10]} # Limit to first 10 for context window -Result Count: {result_count} - -Provide a clear, concise natural language answer based on these results. -If there are many results, summarize the key findings. -If there are no results, say so clearly.""" - - answer = provider_obj.complete( - user_message="Synthesize the answer from the query results above.", - system_prompt=synthesis_prompt, - schema_context=None - ) - - elapsed_ms = int((time.time() - start_time_lookup) * 1000) - - response_data = { - "status": "ok", - "reply": answer, - "engine": "lookup", - "cypher_query": cypher_query, - "metadata": { - "result_count": result_count, - "execution_time_ms": elapsed_ms - } - } - - except Exception as e: - # Provider error - return error response - elapsed_ms = int((time.time() - start_time_lookup) * 1000) - return jsonify({ - "status": "error", - "error": f"LOOKUP path failed: {str(e)}", - "metadata": { - "execution_time_ms": elapsed_ms - } - }), 500 - - elif intent == Intent.SUMMARIZE: - # SUMMARIZE path: Run count queries and synthesize narrative overview - from ...ai.summarization import generate_summary - from ...ai.provider_factory import LLMProviderFactory - - # Build settings dict from environment/config - settings = { - 'chat_llm_provider': data.get('provider') or os.environ.get('SCIDK_CHAT_LLM_PROVIDER'), - 'chat_ollama_endpoint': os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT'), - 'chat_ollama_model': os.environ.get('SCIDK_CHAT_OLLAMA_MODEL'), - 'chat_claude_api_key': os.environ.get('SCIDK_CHAT_CLAUDE_API_KEY'), - 'chat_openai_api_key': os.environ.get('SCIDK_CHAT_OPENAI_API_KEY'), - } - - provider_obj = LLMProviderFactory.from_settings(settings) + # LOOKUP path: Fast Text2Cypher via QueryEngine + from ...services.graphrag.query_engine import QueryEngine + anthropic_key = os.environ.get('SCIDK_ANTHROPIC_API_KEY') + verbose = (os.environ.get('SCIDK_GRAPHRAG_VERBOSE') or '').strip().lower() in ('1','true','yes') + + query_engine = QueryEngine( + driver=driver, + neo4j_schema=neo4j_schema, + anthropic_api_key=anthropic_key, + database=database, + verbose=verbose + ) - # Generate summary with count queries - result = generate_summary(driver, database or "neo4j", provider_obj, neo4j_schema) + # Execute query + result = query_engine.query(message) if result.get('status') == 'error': return jsonify(result), 500 - response_data = result - - elif intent == Intent.REACT: - # REACT path: Multi-step reasoning loop with query execution - from ...ai.react_loop import run_react_loop - from ...ai.schema_context import get_schema_context - from ...ai.provider_factory import LLMProviderFactory - from ...ai.chat_graph import retrieve_relevant_context, format_context_for_prompt - from ...services.chat_neo4j_client import get_chat_neo4j_client - - # Get schema context with semantic retrieval - try: - from ...services.schema_intelligence import get_relevant_schema_context - chat_service_tmp = _get_chat_service() - sqlite_conn_tmp = chat_service_tmp._get_conn() - try: - schema_context = get_relevant_schema_context( - user_query=message_with_context, - sqlite_conn=sqlite_conn_tmp, - neo4j_driver=driver, - ollama_url=os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434'), - database=database or "neo4j" - ) - finally: - sqlite_conn_tmp.close() - except Exception as e: - logger.warning(f"Schema intelligence failed: {e}") - schema_context = get_schema_context(driver, database=database or "neo4j") + result_text = result.get('answer', 'No results found') - # Build settings dict - override model for REACT path - # REACT requires stronger reasoning to avoid hallucination - react_model = os.environ.get('SCIDK_REACT_MODEL', 'qwen2.5:72b') - - settings = { - 'chat_llm_provider': data.get('provider') or os.environ.get('SCIDK_CHAT_LLM_PROVIDER', 'ollama'), - 'chat_ollama_endpoint': os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT'), - 'chat_ollama_model': react_model, # Use REACT-specific model - 'chat_claude_api_key': os.environ.get('SCIDK_CHAT_CLAUDE_API_KEY'), - 'chat_openai_api_key': os.environ.get('SCIDK_CHAT_OPENAI_API_KEY'), + # Build response with engine type and cypher for UI + response_data = { + "status": "ok", + "reply": result_text, + "engine": result.get('engine', 'graph_query'), # For UI badge + "cypher_query": result.get('cypher_query'), # For citations panel } - provider_obj = LLMProviderFactory.from_settings(settings) - - # Get chat Neo4j client for context retrieval - chat_driver = get_chat_neo4j_client() - - # Retrieve relevant past context (if chat Neo4j available) - retrieved_history = "" - session_id = data.get('session_id', 'default') # Get from request or use default - - if chat_driver: - try: - relevant_messages = retrieve_relevant_context( - current_query=message, - session_id=session_id, - chat_driver=chat_driver, - research_driver=driver, - embedding_model=os.environ.get('SCIDK_CHAT_EMBEDDING_MODEL', 'nomic-embed-text'), - top_k=int(os.environ.get('SCIDK_CHAT_CONTEXT_RETRIEVAL_TOP_K', 3)) - ) - retrieved_history = format_context_for_prompt(relevant_messages) - except Exception as e: - # Context retrieval failure shouldn't block the query - import logging - logging.warning(f"Context retrieval failed: {e}") - - # Run ReAct loop - result = run_react_loop( - user_query=message_with_context, - session_id=session_id, - provider=provider_obj, - research_driver=driver, - chat_driver=chat_driver, - schema_context=schema_context, - retrieved_history=retrieved_history, - max_steps=int(os.environ.get('SCIDK_CHAT_REACT_MAX_STEPS', 4)) - ) - - if result.get('status') == 'error': - return jsonify(result), 500 + # Include metadata + response_data["metadata"] = { + "entities": result.get('entities', {}), + "execution_time_ms": result.get('execution_time_ms', 0), + "result_count": result.get('result_count', 0) + } - response_data = result + if verbose and 'results' in result: + response_data["metadata"]["results"] = result['results'] else: # REASONING path: Use existing /v2 provider architecture @@ -508,23 +173,7 @@ def complete(self, prompt: str) -> str: from ...ai.schema_context import get_schema_context from ...ai.provider_factory import LLMProviderFactory - try: - from ...services.schema_intelligence import get_relevant_schema_context - chat_service_tmp = _get_chat_service() - sqlite_conn_tmp = chat_service_tmp._get_conn() - try: - schema_context = get_relevant_schema_context( - user_query=message_with_context, - sqlite_conn=sqlite_conn_tmp, - neo4j_driver=driver, - ollama_url=os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434'), - database=database or "neo4j" - ) - finally: - sqlite_conn_tmp.close() - except Exception as e: - logger.warning(f"Schema intelligence failed: {e}") - schema_context = get_schema_context(driver, database=database or "neo4j") + schema_context = get_schema_context(driver, database=database or "neo4j") # Build settings dict from environment/config settings = { @@ -543,7 +192,7 @@ def complete(self, prompt: str) -> str: # Complete (non-streaming) start_time_reasoning = time.time() response_text = provider_obj.complete( - user_message=message_with_context, + user_message=message, system_prompt=base_prompt, schema_context=schema_context ) @@ -565,38 +214,6 @@ def complete(self, prompt: str) -> str: } } - # Save messages to SQLite for conversation context - try: - # Ensure session exists (create if needed using INSERT OR IGNORE) - existing_session = chat_service.get_session(session_id) - if not existing_session: - # Directly insert with the provided session_id - conn = chat_service._get_conn() - try: - import time as time_module - now = time_module.time() - conn.execute( - """ - INSERT OR IGNORE INTO chat_sessions (id, name, created_at, updated_at, message_count, metadata) - VALUES (?, ?, ?, ?, 0, NULL) - """, - (session_id, f"Chat {session_id[:8]}", now, now) - ) - conn.commit() - print(f"DEBUG: Created new session {session_id}") - finally: - conn.close() - - # Save user message and assistant response - chat_service.add_message(session_id, "user", message) - chat_service.add_message(session_id, "assistant", response_data.get('reply', '')) - print(f"DEBUG: Saved messages to SQLite for session {session_id}") - except Exception as e: - # Non-fatal - conversation context won't work but query still succeeds - print(f"DEBUG: Failed to save messages to SQLite: {e}") - import traceback - traceback.print_exc() - # Track history and minimal audit store = _get_ext().setdefault('chat', {"history": []}) store['history'].extend([{"role":"user","content":message},{"role":"assistant","content":response_data.get('reply','')}]) @@ -612,72 +229,6 @@ def complete(self, prompt: str) -> str: except Exception: pass - # Log to chat Neo4j (background, non-blocking) - # This logs the final answer + all ReAct steps for full audit trail - try: - from ...services.chat_neo4j_client import get_chat_neo4j_client - chat_driver = get_chat_neo4j_client() - - if chat_driver and intent in (Intent.REACT, Intent.LOOKUP, Intent.SUMMARIZE): - import threading - - def log_to_chat_neo4j(): - try: - from ...ai.chat_graph import log_chat_message - - # Generate a unique sqlite_id (in real impl, this would be from chat_service) - import uuid - sqlite_id = str(uuid.uuid4()) - session_id = data.get('session_id', 'default') - - # For REACT, log each step as a separate record - if intent == Intent.REACT and 'step_log' in response_data: - for step in response_data['step_log']: - step_sqlite_id = f"{sqlite_id}_step_{step.get('step_num')}" - log_chat_message( - chat_driver=chat_driver, - research_driver=driver, - sqlite_id=step_sqlite_id, - session_id=session_id, - role="assistant", - intent=intent.value, - content_summary=f"ReAct Step {step.get('step_num')}: {step.get('action_type')}", - finding_text=step.get('content', '')[:150], - finding_type="REACT_STEP", - cypher_used=step.get('content', '') if step.get('action_type') == 'QUERY' else None, - referenced_labels=[], # Could parse from Cypher - embedding=None - ) - - # Log final answer - log_chat_message( - chat_driver=chat_driver, - research_driver=driver, - sqlite_id=sqlite_id, - session_id=session_id, - role="assistant", - intent=intent.value, - content_summary=response_data.get('reply', '')[:200], - finding_text=response_data.get('reply', '')[:150], - finding_type="COUNT" if intent == Intent.SUMMARIZE else "RELATIONAL", - cypher_used=response_data.get('cypher_query'), - referenced_labels=[], # Could extract from schema/query - embedding=None - ) - - except Exception as e: - import logging - logging.error(f"Chat Neo4j logging failed: {e}") - - # Run in background thread to avoid blocking response - thread = threading.Thread(target=log_to_chat_neo4j) - thread.daemon = True - thread.start() - - except Exception as e: - # Logging failure shouldn't break the response - pass - # Add history to response response_data["history"] = store['history'] @@ -686,612 +237,6 @@ def log_to_chat_neo4j(): return jsonify({"status": "error", "error": str(e)}), 500 -@bp.post('/chat/graphrag/stream') -def api_chat_graphrag_stream(): - """ - GraphRAG with Server-Sent Events (SSE) streaming for live ReAct step updates. - - Critical: Uses POST + fetch() ReadableStream (not EventSource GET) to support - long messages with attached Cypher queries that exceed GET param limits. - - SSE Message Format: - data: {"type": "step", "step_num": 1, "action": "THINK", "content": "...", "observation": ""} - data: {"type": "step", "step_num": 2, "action": "QUERY", "content": "MATCH...", "observation": "..."} - data: {"type": "done", "reply": "...", "engine": "react", "metadata": {...}} - data: {"type": "error", "error": "..."} - - Connection Limiting: - Max 12 concurrent SSE connections to prevent worker pool exhaustion. - Returns 429 Too Many Requests if limit exceeded. - - Intent Routing: - - REACT: Stream each step as it happens - - LOOKUP/SUMMARIZE/REASONING: Buffer and send all at once, then close stream - """ - global _active_sse_connections - - # Check connection limit before processing - with _sse_connection_lock: - if _active_sse_connections >= MAX_SSE_CONNECTIONS: - return jsonify({ - "status": "error", - "error": "Too many active streaming connections. Please try again shortly.", - "code": "SSE_CAPACITY_EXCEEDED" - }), 429 - - # Reserve slot - _active_sse_connections += 1 - current_count = _active_sse_connections - - print(f"DEBUG: SSE connection opened. Active: {current_count}/{MAX_SSE_CONNECTIONS}") - - # GraphRAG enabled check - enabled = (os.environ.get('SCIDK_GRAPHRAG_ENABLED') or '').strip().lower() in ('1','true','yes','on','y') - if not enabled: - with _sse_connection_lock: - _active_sse_connections -= 1 - from ...services.graphrag_schema import normalize_error - return jsonify(normalize_error(status="disabled", error="GraphRAG disabled", code="GR_DISABLED", hint="Set SCIDK_GRAPHRAG_ENABLED=1")), 501 - - data = request.get_json(force=True, silent=True) or {} - message = (data.get('message') or '').strip() - if not message: - with _sse_connection_lock: - _active_sse_connections -= 1 - return jsonify({"status": "error", "error": "message required"}), 400 - - # Capture app for thread context - _app = current_app._get_current_object() - - def generate_stream(): - """SSE generator with connection cleanup.""" - global _active_sse_connections - - # Push app context for entire generator - needed for _get_chat_service() and chat_service methods - with _app.app_context(): - try: - # Get session context - chat_service needs app context - session_id = data.get('session_id', 'default') - print(f"DEBUG: Stream session_id: {session_id}") - - chat_service = _get_chat_service() - conversation_context = chat_service.get_recent_turns(session_id, n=4) - message_with_context = f"{conversation_context}\n{message}" if conversation_context else message - - # Get Neo4j connection - try: - from ...services.neo4j_client import get_neo4j_params - uri, user, pwd, database, auth_mode = get_neo4j_params(_app) - except Exception: - uri = user = pwd = database = auth_mode = None - - if not uri: - yield f"data: {json.dumps({'type': 'error', 'error': 'Neo4j not configured'})}\n\n" - return - - from neo4j import GraphDatabase - auth = None if (auth_mode or 'basic').lower() == 'none' else (user, pwd) - driver = GraphDatabase.driver(uri, auth=auth) - - # Get schema and classify intent - from ...services.graphrag_schema import parse_ttl, filter_schema - schema_cache = _get_ext().setdefault('graphrag_schema', {}) - last = schema_cache.get('last_loaded_ts') or 0 - ttl = 0 - ttl_env = os.environ.get('SCIDK_GRAPHRAG_SCHEMA_CACHE_TTL_SEC') or os.environ.get('SCIDK_GRAPHRAG_SCHEMA_CACHE_TTL') - if ttl_env: - ttl = parse_ttl(ttl_env) - now = int(time.time()) - - if (now - last) > max(0, ttl): - with driver.session(database=database) if database else driver.session() as s: - labels = [r[0] for r in s.run("CALL db.labels()").values()] - rels = [r[0] for r in s.run("CALL db.relationshipTypes()").values()] - raw_schema = {"labels": labels, "relationships": rels} - allow_labels = [x.strip() for x in (os.environ.get('SCIDK_GRAPHRAG_ALLOW_LABELS') or '').split(',') if x.strip()] - deny_labels = [x.strip() for x in (os.environ.get('SCIDK_GRAPHRAG_DENY_LABELS') or '').split(',') if x.strip()] - prop_excl = [x.strip() for x in (os.environ.get('SCIDK_GRAPHRAG_EXCLUDE_PROPERTIES') or '').split(',') if x.strip()] - filtered = filter_schema(raw_schema, allow_labels or None, deny_labels or None, prop_excl or None) - schema_cache['schema'] = filtered - schema_cache['last_loaded_ts'] = now - - neo4j_schema = schema_cache.get('schema') or {"labels": [], "relationships": []} - - # Classify intent using Concept Graph or fallback to hard-coded classifier - from ...services.graphrag.intent_classifier import classify, Intent - concept_driver = _get_ext().get('concept_driver') - traversal_log = None - - try: - if concept_driver is not None: - # Use Concept Graph for intent classification and planning - from ...services.concept_graph_service import ( - classify_intent, plan_execution, build_traversal_log, - ConceptGraphUnavailableError - ) - from ...services.schema_intelligence import get_relevant_schema_context - - # Get SQLite connection - chat_service_tmp = _get_chat_service() - sqlite_conn_tmp = chat_service_tmp._get_conn() - - try: - ollama_url = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - - # Get relevant schema context (for semantic retrieval) - schema_context = get_relevant_schema_context( - message_with_context, sqlite_conn_tmp, driver, - ollama_url, database=database or "neo4j" - ) - relevant_labels = schema_context.get('labels', []) - - # Classify intent using concept graph - # Use raw message (not context) for intent - context pollutes classification - intent_name, intent_confidence = classify_intent( - message, concept_driver, sqlite_conn_tmp, ollama_url - ) - print(f"DEBUG STREAM concept_graph: intent_name={intent_name}, confidence={intent_confidence}") - - # Plan execution - plan = plan_execution(intent_name, relevant_labels, concept_driver) - print(f"DEBUG STREAM concept_graph: plan={plan}") - - # Build traversal log - traversal_log = build_traversal_log( - query=message, - intent_matched=intent_name, - intent_confidence=intent_confidence, - plan=plan, - labels_considered=list(schema_context.get('labels', [])) - ) - - # Map concept intent to legacy Intent enum - intent = _map_concept_intent_to_legacy(intent_name) - print(f"DEBUG STREAM concept_graph: mapped to legacy intent={intent}, value={intent.value}") - - # Log traversal to SQLite - sqlite_conn_tmp.execute( - "INSERT INTO usage_event (event_type, label_name, session_id, " - "source, traversal_json) VALUES (?, ?, ?, ?, ?)", - ('concept_graph_plan', '', data.get('session_id', 'default'), - 'chat', json.dumps(traversal_log)) - ) - sqlite_conn_tmp.commit() - - finally: - sqlite_conn_tmp.close() - - else: - # Fallback to hard-coded classifier - intent = classify(message_with_context) - - except Exception as e: - # Concept graph error — fall back to hard-coded classifier - import logging - logging.warning(f"Concept graph classification failed (streaming): {e}") - intent = classify(message_with_context) - traversal_log = None - - print(f"DEBUG: Stream intent: {intent.value}") - - # Route based on intent - if intent == Intent.REACT: - # REACT path: Stream steps in real-time - from ...ai.react_loop import run_react_loop - from ...ai.schema_context import get_schema_context - from ...ai.provider_factory import LLMProviderFactory - from ...ai.chat_graph import retrieve_relevant_context, format_context_for_prompt - from ...services.chat_neo4j_client import get_chat_neo4j_client - - try: - from ...services.schema_intelligence import get_relevant_schema_context - chat_service_tmp = _get_chat_service() - sqlite_conn_tmp = chat_service_tmp._get_conn() - try: - schema_context = get_relevant_schema_context( - user_query=message, - sqlite_conn=sqlite_conn_tmp, - neo4j_driver=driver, - ollama_url=os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434'), - database=database or "neo4j" - ) - finally: - sqlite_conn_tmp.close() - except Exception as e: - import logging - logging.warning(f"Schema intelligence failed: {e}") - schema_context = get_schema_context(driver, database=database or "neo4j") - - react_model = os.environ.get('SCIDK_REACT_MODEL', 'qwen2.5:72b') - settings = { - 'chat_llm_provider': data.get('provider') or os.environ.get('SCIDK_CHAT_LLM_PROVIDER', 'ollama'), - 'chat_ollama_endpoint': os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT'), - 'chat_ollama_model': react_model, - 'chat_claude_api_key': os.environ.get('SCIDK_CHAT_CLAUDE_API_KEY'), - 'chat_openai_api_key': os.environ.get('SCIDK_CHAT_OPENAI_API_KEY'), - } - - provider_obj = LLMProviderFactory.from_settings(settings) - chat_driver = get_chat_neo4j_client() - - # Retrieve context - retrieved_history = "" - if chat_driver: - try: - relevant_messages = retrieve_relevant_context( - current_query=message, - session_id=session_id, - chat_driver=chat_driver, - research_driver=driver, - embedding_model=os.environ.get('SCIDK_CHAT_EMBEDDING_MODEL', 'nomic-embed-text'), - top_k=int(os.environ.get('SCIDK_CHAT_CONTEXT_RETRIEVAL_TOP_K', 3)) - ) - retrieved_history = format_context_for_prompt(relevant_messages) - except Exception as e: - import logging - logging.warning(f"Context retrieval failed: {e}") - - # Define step callback for streaming - def step_callback(step_dict): - """Stream step updates as SSE events.""" - sse_data = { - "type": "step", - "step_num": step_dict["step_num"], - "action": step_dict["action_type"], - "content": step_dict["content"], - "observation": step_dict.get("observation", "") - } - # Must use nonlocal or return value - can't yield from nested function - # Instead, we'll collect steps and check them in the main loop - # For now, print for debugging - print(f"DEBUG: Step callback fired: {sse_data['action']} step {sse_data['step_num']}") - - # We need to refactor this - can't yield from callback - # Solution: Use queues to communicate between callback and generator - import queue - step_queue = queue.Queue() - token_queue = queue.Queue() - - def streaming_step_callback(step_dict): - step_queue.put(step_dict) - - def token_callback(token, step_num): - """Called by provider.stream() - enqueue tokens for SSE transmission.""" - token_queue.put({"type": "token", "token": token, "step": step_num}) - - # Run ReAct in separate thread so we can yield steps as they arrive - result_container = {} - def run_react_thread(): - # Push app context for Flask operations - use captured app object - with _app.app_context(): - result = run_react_loop( - user_query=message_with_context, - session_id=session_id, - provider=provider_obj, - research_driver=driver, - chat_driver=chat_driver, - schema_context=schema_context, - retrieved_history=retrieved_history, - max_steps=int(os.environ.get('SCIDK_CHAT_REACT_MAX_STEPS', 4)), - on_step_callback=streaming_step_callback, - on_token_callback=token_callback - ) - result_container['result'] = result - step_queue.put(None) # Sentinel to signal completion - - react_thread = threading.Thread(target=run_react_thread) - react_thread.start() - - # Stream loop - drain BOTH queues (tokens + steps) - import time as time_module - while True: - # Drain token queue first (non-blocking) - while not token_queue.empty(): - try: - token_event = token_queue.get_nowait() - yield f"data: {json.dumps(token_event)}\n\n" - except queue.Empty: - break - - # Then check for step events (blocking with timeout) - try: - step_dict = step_queue.get(timeout=0.05) # 50ms poll - - if step_dict is None: - # Thread completed - drain remaining tokens - while not token_queue.empty(): - try: - token_event = token_queue.get_nowait() - yield f"data: {json.dumps(token_event)}\n\n" - except queue.Empty: - break - break - - # After receiving step, pause briefly and drain remaining tokens for this step - time_module.sleep(0.05) # let token_queue drain - while not token_queue.empty(): - try: - token_event = token_queue.get_nowait() - yield f"data: {json.dumps(token_event)}\n\n" - except queue.Empty: - break - - # Now yield the step event - sse_data = { - "type": "step", - "step_num": step_dict["step_num"], - "action": step_dict["action_type"], - "content": step_dict["content"], - "observation": step_dict.get("observation", "") - } - yield f"data: {json.dumps(sse_data)}\n\n" - - except queue.Empty: - # No step yet - continue draining tokens - continue - - react_thread.join() - result = result_container.get('result', {}) - - if result.get('status') == 'error': - yield f"data: {json.dumps({'type': 'error', 'error': result.get('reply', 'Unknown error')})}\n\n" - return - - # Send completion - done_data = { - "type": "done", - "reply": result.get('reply', ''), - "engine": result.get('engine', 'react'), - "metadata": result.get('metadata', {}), - "traversal_log": traversal_log - } - yield f"data: {json.dumps(done_data)}\n\n" - - # Save to SQLite - try: - existing_session = chat_service.get_session(session_id) - if not existing_session: - conn = chat_service._get_conn() - try: - import time as time_module - now = time_module.time() - conn.execute( - "INSERT OR IGNORE INTO chat_sessions (id, name, created_at, updated_at, message_count, metadata) VALUES (?, ?, ?, ?, 0, NULL)", - (session_id, f"Chat {session_id[:8]}", now, now) - ) - conn.commit() - finally: - conn.close() - - chat_service.add_message(session_id, "user", message) - chat_service.add_message(session_id, "assistant", result.get('reply', '')) - except Exception as e: - print(f"DEBUG: Failed to save messages: {e}") - - elif intent == Intent.LOOKUP: - # LOOKUP path: Generate Cypher, execute, synthesize answer with streaming - from ...ai.cypher_utils import build_cypher_system_prompt, extract_cypher - from ...ai.provider_factory import LLMProviderFactory - from ...ai.schema_context import get_schema_context - - settings = { - 'chat_llm_provider': data.get('provider') or os.environ.get('SCIDK_CHAT_LLM_PROVIDER'), - 'chat_ollama_endpoint': os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT'), - 'chat_ollama_model': os.environ.get('SCIDK_CHAT_OLLAMA_MODEL'), - 'chat_claude_api_key': os.environ.get('SCIDK_CHAT_CLAUDE_API_KEY'), - 'chat_openai_api_key': os.environ.get('SCIDK_CHAT_OPENAI_API_KEY'), - } - - provider_obj = LLMProviderFactory.from_settings(settings) - start_time = time.time() - - # Generate Cypher query - cypher_prompt = build_cypher_system_prompt(neo4j_schema) - cypher_response = provider_obj.complete( - user_message=message_with_context, - system_prompt=cypher_prompt, - schema_context=None - ) - cypher_query = extract_cypher(cypher_response) - - if cypher_query is None: - # Fallback to reasoning if no valid Cypher - try: - from ...services.schema_intelligence import get_relevant_schema_context - chat_service_tmp = _get_chat_service() - sqlite_conn_tmp = chat_service_tmp._get_conn() - try: - schema_context = get_relevant_schema_context( - user_query=message_with_context, - sqlite_conn=sqlite_conn_tmp, - neo4j_driver=driver, - ollama_url=os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434'), - database=database or "neo4j" - ) - finally: - sqlite_conn_tmp.close() - except Exception as e: - import logging - logging.warning(f"Schema intelligence failed: {e}") - schema_context = get_schema_context(driver, database=database or "neo4j") - base_prompt = "You are a research data assistant for SciDK." - - final_answer = '' - for token in provider_obj.stream( - user_message=message_with_context, - system_prompt=base_prompt, - schema_context=schema_context - ): - final_answer += token - yield f"data: {json.dumps({'type': 'token', 'token': token})}\n\n" - - elapsed_ms = int((time.time() - start_time) * 1000) - yield f"data: {json.dumps({'type': 'done', 'reply': final_answer, 'engine': 'reasoning_fallback', 'metadata': {'execution_time_ms': elapsed_ms, 'note': 'Could not generate valid Cypher'}, 'traversal_log': traversal_log})}\n\n" - else: - # Execute Cypher query - try: - with driver.session(database=database) if database else driver.session() as session: - result = session.run(cypher_query) - records = [record.data() for record in result] - result_count = len(records) - except Exception as query_error: - elapsed_ms = int((time.time() - start_time) * 1000) - yield f"data: {json.dumps({'type': 'error', 'error': f'Query execution failed: {str(query_error)}', 'cypher_query': cypher_query, 'metadata': {'execution_time_ms': elapsed_ms}})}\n\n" - return - - # Synthesize answer with streaming - synthesis_prompt = f"""You are a research data assistant. A user asked a question and we ran a database query. - -Context and Question: -{message_with_context} - -Query Results: {records[:10]} -Result Count: {result_count} - -Provide a clear, concise natural language answer based on these results.""" - - final_answer = '' - for token in provider_obj.stream( - user_message="Synthesize the answer from the query results above.", - system_prompt=synthesis_prompt, - schema_context=None - ): - final_answer += token - yield f"data: {json.dumps({'type': 'token', 'token': token})}\n\n" - - elapsed_ms = int((time.time() - start_time) * 1000) - result_metadata = { - 'cypher_query': cypher_query, - 'result_count': result_count, - 'execution_time_ms': elapsed_ms - } - yield f"data: {json.dumps({'type': 'done', 'reply': final_answer, 'engine': 'lookup', 'metadata': result_metadata, 'traversal_log': traversal_log})}\n\n" - - # Save messages - chat_service.add_message(session_id, "user", message) - chat_service.add_message(session_id, "assistant", final_answer) - - elif intent == Intent.SUMMARIZE: - # SUMMARIZE path: Native streaming from LLM - from ...ai.summarization import generate_summary - from ...ai.provider_factory import LLMProviderFactory - - settings = { - 'chat_llm_provider': data.get('provider') or os.environ.get('SCIDK_CHAT_LLM_PROVIDER'), - 'chat_ollama_endpoint': os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT'), - 'chat_ollama_model': os.environ.get('SCIDK_CHAT_OLLAMA_MODEL'), - 'chat_claude_api_key': os.environ.get('SCIDK_CHAT_CLAUDE_API_KEY'), - 'chat_openai_api_key': os.environ.get('SCIDK_CHAT_OPENAI_API_KEY'), - } - - provider_obj = LLMProviderFactory.from_settings(settings) - - # Stream summary with native LLM streaming - summary_text = '' - metadata = {} - - for event in generate_summary(driver, database or "neo4j", provider_obj, neo4j_schema): - event_type = event.get('type') - - if event_type == 'error': - yield f"data: {json.dumps({'type': 'error', 'error': event.get('error', 'Unknown error')})}\n\n" - break - - elif event_type == 'metadata': - # Store metadata for final done event - metadata = event - - elif event_type == 'token': - # Stream token to frontend - token = event.get('content', '') - summary_text += token - yield f"data: {json.dumps({'type': 'token', 'token': token})}\n\n" - - elif event_type == 'done': - # Final event with complete text - summary_text = event.get('reply', summary_text) - yield f"data: {json.dumps({'type': 'done', 'reply': summary_text, 'engine': 'summarize', 'metadata': metadata, 'traversal_log': traversal_log})}\n\n" - - # Save messages - chat_service.add_message(session_id, "user", message) - chat_service.add_message(session_id, "assistant", summary_text) - - else: - # REASONING path: Default streaming response with schema grounding - from ...ai.schema_context import get_schema_context - from ...ai.provider_factory import LLMProviderFactory - - try: - from ...services.schema_intelligence import get_relevant_schema_context - chat_service_tmp = _get_chat_service() - sqlite_conn_tmp = chat_service_tmp._get_conn() - try: - schema_context = get_relevant_schema_context( - user_query=message_with_context, - sqlite_conn=sqlite_conn_tmp, - neo4j_driver=driver, - ollama_url=os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434'), - database=database or "neo4j" - ) - finally: - sqlite_conn_tmp.close() - except Exception as e: - import logging - logging.warning(f"Schema intelligence failed: {e}") - schema_context = get_schema_context(driver, database=database or "neo4j") - - settings = { - 'chat_llm_provider': data.get('provider') or os.environ.get('SCIDK_CHAT_LLM_PROVIDER'), - 'chat_ollama_endpoint': os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT'), - 'chat_ollama_model': os.environ.get('SCIDK_CHAT_OLLAMA_MODEL'), - 'chat_claude_api_key': os.environ.get('SCIDK_CHAT_CLAUDE_API_KEY'), - 'chat_openai_api_key': os.environ.get('SCIDK_CHAT_OPENAI_API_KEY'), - } - - provider = LLMProviderFactory.from_settings(settings) - base_prompt = "You are a research data assistant for SciDK." - - start_time = time.time() - final_answer = '' - for token in provider.stream( - user_message=message_with_context, - system_prompt=base_prompt, - schema_context=schema_context - ): - final_answer += token - yield f"data: {json.dumps({'type': 'token', 'token': token})}\n\n" - - elapsed_ms = int((time.time() - start_time) * 1000) - provider_info = provider.health_check() - yield f"data: {json.dumps({'type': 'done', 'reply': final_answer, 'metadata': {'provider': provider_info.get('provider'), 'model': provider_info.get('model'), 'execution_time_ms': elapsed_ms}, 'engine': 'reasoning', 'traversal_log': traversal_log})}\n\n" - - # Save messages - chat_service.add_message(session_id, "user", message) - chat_service.add_message(session_id, "assistant", final_answer) - - except Exception as e: - import traceback - traceback.print_exc() - yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" - - finally: - # Always release connection slot - with _sse_connection_lock: - _active_sse_connections -= 1 - remaining = _active_sse_connections - print(f"DEBUG: SSE connection closed. Active: {remaining}/{MAX_SSE_CONNECTIONS}") - - return Response( - generate_stream(), - mimetype='text/event-stream', - headers={ - 'Cache-Control': 'no-cache', - 'X-Accel-Buffering': 'no', - 'X-Stream-Capacity': f'{_active_sse_connections}/{MAX_SSE_CONNECTIONS}' - } - ) - - @bp.get('/chat/history') def api_chat_history(): store = _get_ext().setdefault('chat', {"history": []}) @@ -2119,23 +1064,7 @@ def api_chat_graphrag_v2(): # Get schema context for grounding (provider integrates it) from ...ai.schema_context import get_schema_context - try: - from ...services.schema_intelligence import get_relevant_schema_context - chat_service_tmp = _get_chat_service() - sqlite_conn_tmp = chat_service_tmp._get_conn() - try: - schema_context = get_relevant_schema_context( - user_query=message, - sqlite_conn=sqlite_conn_tmp, - neo4j_driver=driver, - ollama_url=os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434'), - database=database or "neo4j" - ) - finally: - sqlite_conn_tmp.close() - except Exception as e: - logger.warning(f"Schema intelligence failed: {e}") - schema_context = get_schema_context(driver, database=database or "neo4j") + schema_context = get_schema_context(driver, database=database or "neo4j") # Get provider (allow override via request body) from ...ai.provider_factory import LLMProviderFactory @@ -2200,8 +1129,8 @@ def api_chat_graphrag_v2_stream(): Returns: 200: Server-Sent Events (SSE) stream - data: {"type": "token", "token": "..."} - data: {"type": "done", "reply": "...", "metadata": {...}} + data: {"type": "token", "content": "..."} + data: {"type": "done", "metadata": {...}} """ enabled = (os.environ.get('SCIDK_GRAPHRAG_ENABLED') or '').strip().lower() in ('1','true','yes','on','y') if not enabled: @@ -2233,27 +1162,7 @@ def generate_stream(): # Get schema context for grounding from ...ai.schema_context import get_schema_context - try: - from ...services.schema_intelligence import get_relevant_schema_context - from ...services.chat_service import get_chat_service - import os as os_mod - db_path = os_mod.environ.get('SCIDK_SETTINGS_DB', 'scidk_settings.db') - chat_service_tmp = get_chat_service(db_path=db_path) - sqlite_conn_tmp = chat_service_tmp._get_conn() - try: - schema_context = get_relevant_schema_context( - user_query=message, - sqlite_conn=sqlite_conn_tmp, - neo4j_driver=driver, - ollama_url=os_mod.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434'), - database=database or "neo4j" - ) - finally: - sqlite_conn_tmp.close() - except Exception as e: - import logging - logging.warning(f"Schema intelligence failed: {e}") - schema_context = get_schema_context(driver, database=database or "neo4j") + schema_context = get_schema_context(driver, database=database or "neo4j") # Get provider from ...ai.provider_factory import LLMProviderFactory @@ -2272,20 +1181,18 @@ def generate_stream(): # Stream tokens - schema grounding built into interface start_time = time.time() - final_answer = '' for token in provider.stream( user_message=message, system_prompt=base_prompt, schema_context=schema_context ): - final_answer += token - yield f"data: {json.dumps({'type': 'token', 'token': token})}\n\n" + yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n" elapsed_ms = int((time.time() - start_time) * 1000) # Send completion metadata with engine field for UI badge provider_info = provider.health_check() - yield f"data: {json.dumps({'type': 'done', 'reply': final_answer, 'metadata': {'provider': provider_info.get('provider'), 'model': provider_info.get('model'), 'execution_time_ms': elapsed_ms, 'engine': 'reasoning'}, 'traversal_log': None})}\n\n" + yield f"data: {json.dumps({'type': 'done', 'metadata': {'provider': provider_info.get('provider'), 'model': provider_info.get('model'), 'execution_time_ms': elapsed_ms, 'engine': 'reasoning'}})}\n\n" except Exception as e: yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" @@ -2300,337 +1207,6 @@ def generate_stream(): ) -# ============================================================================ -# Schema Intelligence Layer API (Phases 1-3 + 6) -# ============================================================================ - -@bp.post('/chat/schema/refresh-embeddings') -def api_chat_schema_refresh_embeddings(): - """ - Refresh schema embeddings (Phase 6). - - Re-embeds all labels and relationship types from live Neo4j schema. - Called manually from Settings, or automatically after imports/description edits. - - Returns: - 200: {embedded: int, failed: int} - 500: {status: error, error: str} - """ - enabled = (os.environ.get('SCIDK_GRAPHRAG_ENABLED') or '').strip().lower() in ('1','true','yes','on','y') - if not enabled: - return jsonify({ - "status": "disabled", - "error": "GraphRAG disabled", - "hint": "Set SCIDK_GRAPHRAG_ENABLED=1" - }), 501 - - try: - from ...services.neo4j_client import get_neo4j_params - from neo4j import GraphDatabase - uri, user, pwd, database, auth_mode = get_neo4j_params(current_app) - - if not uri: - return jsonify({ - "status": "error", - "error": "Neo4j not configured" - }), 500 - - auth = None if (auth_mode or 'basic').lower() == 'none' else (user, pwd) - driver = GraphDatabase.driver(uri, auth=auth) - - # Get SQLite connection - chat_service = _get_chat_service() - sqlite_conn = chat_service._get_conn() - - try: - from ...services.schema_intelligence import refresh_schema_embeddings - ollama_url = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - result = refresh_schema_embeddings( - neo4j_driver=driver, - sqlite_conn=sqlite_conn, - ollama_url=ollama_url, - database=database or "neo4j" - ) - return jsonify(result), 200 - finally: - sqlite_conn.close() - driver.close() - - except Exception as e: - logger.error(f"Schema embedding refresh failed: {e}", exc_info=True) - return jsonify({ - "status": "error", - "error": str(e) - }), 500 - - -@bp.get('/chat/schema/status') -def api_chat_schema_status(): - """ - Get schema intelligence layer status. - - Returns statistics about: - - Embedded labels/relationships - - Last embedding update timestamp - - Property ranking coverage - - Usage event counts - - Returns: - 200: {labels_embedded, relationships_embedded, last_embedding_update, ...} - """ - try: - chat_service = _get_chat_service() - sqlite_conn = chat_service._get_conn() - - try: - cursor = sqlite_conn.cursor() - - label_count = cursor.execute( - "SELECT COUNT(*) FROM label_profile WHERE embedding IS NOT NULL" - ).fetchone()[0] - - rel_count = cursor.execute( - "SELECT COUNT(*) FROM relationship_profile WHERE embedding IS NOT NULL" - ).fetchone()[0] - - last_updated = cursor.execute( - "SELECT MAX(embedded_at) FROM label_profile WHERE embedding IS NOT NULL" - ).fetchone()[0] - - ranking_count = cursor.execute( - "SELECT COUNT(DISTINCT label_name) FROM property_ranking" - ).fetchone()[0] - - event_count = cursor.execute( - "SELECT COUNT(*) FROM usage_event" - ).fetchone()[0] - - return jsonify({ - 'labels_embedded': label_count, - 'relationships_embedded': rel_count, - 'last_embedding_update': last_updated, - 'labels_with_rankings': ranking_count, - 'total_usage_events': event_count - }), 200 - finally: - sqlite_conn.close() - - except Exception as e: - logger.error(f"Schema status check failed: {e}", exc_info=True) - return jsonify({ - "status": "error", - "error": str(e) - }), 500 - - -@bp.get('/chat/schema/label/') -def api_chat_schema_label(label_name): - """ - Get intelligence profile for a specific label. - - Returns: - 200: { - description, chat_context_mode, chat_context_n, - always_include, never_include, - embedding_status: 'embedded' | 'pending' | 'none', - property_rankings: [{property, query_count, rank}, ...] - } - """ - try: - chat_service = _get_chat_service() - sqlite_conn = chat_service._get_conn() - - try: - from ...services.schema_intelligence import get_label_profile - profile = get_label_profile(label_name, sqlite_conn) - - # Check embedding status - cursor = sqlite_conn.cursor() - row = cursor.execute( - "SELECT embedding, embedded_at FROM label_profile WHERE label_name = ?", - (label_name,) - ).fetchone() - - embedding_status = 'none' - if row and row[0]: - embedding_status = 'embedded' - elif row: - embedding_status = 'pending' - - # Get property rankings - rankings = cursor.execute( - "SELECT property_name, query_count, rank " - "FROM property_ranking WHERE label_name = ? " - "ORDER BY rank DESC", - (label_name,) - ).fetchall() - - property_rankings = [ - {'property': r[0], 'query_count': r[1], 'rank': r[2]} - for r in rankings - ] - - return jsonify({ - **profile, - 'embedding_status': embedding_status, - 'property_rankings': property_rankings - }), 200 - finally: - sqlite_conn.close() - - except Exception as e: - logger.error(f"Failed to get label profile for {label_name}: {e}", exc_info=True) - return jsonify({ - "status": "error", - "error": str(e) - }), 500 - - -@bp.put('/chat/schema/label/') -def api_chat_schema_label_update(label_name): - """ - Update intelligence profile for a label. - - Request body: - { - description: str (optional), - chat_context_mode: 'top_n' | 'all' | 'exclude', - chat_context_n: int, - always_include: [str, ...], - never_include: [str, ...] - } - - Side effect: Re-embeds label if description changed. - - Returns: - 200: {success: true} - """ - try: - data = request.get_json(force=True, silent=True) or {} - chat_service = _get_chat_service() - sqlite_conn = chat_service._get_conn() - - try: - from ...services.schema_intelligence import get_label_profile, embed_text - cursor = sqlite_conn.cursor() - - # Get existing profile to check if description changed - old_profile = get_label_profile(label_name, sqlite_conn) - description = data.get('description', old_profile.get('description')) - description_changed = description != old_profile.get('description') - - # Update or insert profile - cursor.execute(""" - INSERT INTO label_profile - (label_name, description, chat_context_mode, chat_context_n, always_include, never_include) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(label_name) DO UPDATE SET - description = excluded.description, - chat_context_mode = excluded.chat_context_mode, - chat_context_n = excluded.chat_context_n, - always_include = excluded.always_include, - never_include = excluded.never_include - """, ( - label_name, - description, - data.get('chat_context_mode', old_profile.get('chat_context_mode', 'top_n')), - data.get('chat_context_n', old_profile.get('chat_context_n', 5)), - json.dumps(data.get('always_include', old_profile.get('always_include', []))), - json.dumps(data.get('never_include', old_profile.get('never_include', []))) - )) - sqlite_conn.commit() - - # Re-embed if description changed - if description_changed and description: - ollama_url = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - embedding = embed_text(description, ollama_url) - if embedding: - from ...services.schema_intelligence import _vector_to_blob - cursor.execute(""" - UPDATE label_profile - SET embedding = ?, embedded_at = ? - WHERE label_name = ? - """, (_vector_to_blob(embedding), datetime.utcnow(), label_name)) - sqlite_conn.commit() - - return jsonify({'success': True}), 200 - finally: - sqlite_conn.close() - - except Exception as e: - logger.error(f"Failed to update label profile for {label_name}: {e}", exc_info=True) - return jsonify({ - "status": "error", - "error": str(e) - }), 500 - - -@bp.get('/chat/schema/export') -def api_chat_schema_export(): - """ - Export schema intelligence layer as JSON. - - Returns: - 200: JSON download with label profiles and property rankings - """ - try: - chat_service = _get_chat_service() - sqlite_conn = chat_service._get_conn() - - try: - from ...services.schema_intelligence import export_schema_layer - layer = export_schema_layer(sqlite_conn) - - response = jsonify(layer) - timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S') - response.headers['Content-Disposition'] = f'attachment; filename=scidk_schema_layer_{timestamp}.json' - return response, 200 - finally: - sqlite_conn.close() - - except Exception as e: - logger.error(f"Schema export failed: {e}", exc_info=True) - return jsonify({ - "status": "error", - "error": str(e) - }), 500 - - -@bp.post('/chat/schema/import') -def api_chat_schema_import(): - """ - Import schema intelligence layer from JSON. - - Request body: {layer JSON} - - Returns: - 200: { - imported_labels: int, - updated_labels: int, - skipped: int, - embeddings_triggered: int - } - """ - try: - data = request.get_json(force=True, silent=True) or {} - chat_service = _get_chat_service() - sqlite_conn = chat_service._get_conn() - - try: - from ...services.schema_intelligence import import_schema_layer - result = import_schema_layer(data, sqlite_conn) - return jsonify(result), 200 - finally: - sqlite_conn.close() - - except Exception as e: - logger.error(f"Schema import failed: {e}", exc_info=True) - return jsonify({ - "status": "error", - "error": str(e) - }), 500 - - @bp.get('/chat/providers') def api_chat_providers(): """ @@ -2689,469 +1265,3 @@ def api_chat_schema_cache_stats(): stats = get_cache_stats() return jsonify(stats), 200 - - -@bp.post('/chat/concept-graph/feedback') -def api_concept_graph_feedback(): - """ - Receive feedback on concept graph classification outcomes. - - Called by frontend after query completion (fire-and-forget). - Updates SATISFIES edge weights in concept graph based on success/failure. - - Request body: - { - "intent": "data_lookup", - "tool": "run_safe_cypher", - "success": true, - "session_id": "abc123" - } - - Returns: - 200: {"status": "ok"} - 400: {"status": "error", "error": "..."} - 501: {"status": "disabled", "error": "Concept graph not available"} - """ - data = request.get_json(force=True, silent=True) or {} - - intent_name = data.get('intent') - tool_name = data.get('tool') - success = data.get('success') - - if not intent_name or not tool_name or success is None: - return jsonify({ - "status": "error", - "error": "Missing required fields: intent, tool, success" - }), 400 - - # Get concept driver - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "error": "Concept graph not available" - }), 501 - - # Update weights - from ...services.concept_graph_service import update_traversal_weights - result = update_traversal_weights(intent_name, tool_name, bool(success), concept_driver) - - if result: - return jsonify({"status": "ok"}), 200 - else: - return jsonify({ - "status": "error", - "error": "Failed to update weights" - }), 500 - - -@bp.post('/chat/concept-graph/decay') -def api_concept_graph_decay(): - """ - Manually trigger weight decay on all SATISFIES edges. - - Applies exponential decay toward neutral (0.5) weight based on edge age. - Half-life defaults to 90 days (SCIDK_CONCEPT_WEIGHT_HALFLIFE_DAYS env). - - Returns: - 200: { - "status": "ok", - "edges_updated": int, - "edges_skipped": int, - "half_life_days": int, - "errors": [] - } - 501: {"status": "disabled", "error": "Concept graph not available"} - 500: {"status": "error", "error": "..."} - """ - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "error": "Concept graph not available" - }), 501 - - try: - from ...services.concept_graph_service import apply_weight_decay - import os - - half_life = int(os.environ.get('SCIDK_CONCEPT_WEIGHT_HALFLIFE_DAYS', '90')) - result = apply_weight_decay(concept_driver, half_life) - - return jsonify({ - "status": "ok", - **result - }), 200 - - except Exception as e: - return jsonify({ - "status": "error", - "error": str(e) - }), 500 - - -@bp.post('/chat/concept-graph/seed-mcp-tools') -def api_concept_graph_seed_mcp_tools(): - """ - Seed MCP tool nodes into the Concept Graph. - - Embeds MCP tool descriptions and creates :Concept_Tool nodes with source='mcp'. - Creates SATISFIES edges from relevant intents to MCP tools. - - Returns: - 200: { - "status": "ok", - "seeded": int, - "failed": int, - "edges_created": int, - "errors": [] - } - 501: {"status": "disabled", "error": "Concept graph not available"} - 500: {"status": "error", "error": "..."} - """ - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "error": "Concept graph not available" - }), 501 - - try: - from ...services.concept_graph_service import seed_mcp_tools - import os - - ollama_endpoint = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - result = seed_mcp_tools(concept_driver, ollama_endpoint) - - return jsonify({ - "status": "ok", - **result - }), 200 - - except Exception as e: - return jsonify({ - "status": "error", - "error": str(e) - }), 500 - - -@bp.get('/chat/concept-graph/export') -def api_concept_graph_export(): - """ - Export complete Concept Graph state as JSON download. - - Returns portable snapshot without embedding BLOBs (regenerated on import). - """ - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "error": "Concept graph not available" - }), 501 - - try: - from ...services.concept_graph_service import export_concept_graph - from datetime import datetime - - data = export_concept_graph(concept_driver) - - # Generate filename with timestamp - timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S') - filename = f"scidk_concept_graph_{timestamp}.json" - - response = jsonify(data) - response.headers['Content-Disposition'] = f'attachment; filename={filename}' - response.headers['Content-Type'] = 'application/json' - - return response, 200 - - except Exception as e: - return jsonify({ - "status": "error", - "error": str(e) - }), 500 - - -@bp.post('/chat/concept-graph/import') -def api_concept_graph_import(): - """ - Import Concept Graph snapshot (non-destructive upsert). - - Re-embeds intents whose descriptions changed. - Preserves higher weights on conflicting SATISFIES edges. - - Request body: Concept Graph JSON export - - Returns: - 200: { - "status": "ok", - "intents_imported": int, - "tools_imported": int, - "edges_imported": int, - "re_embedded": int, - "errors": [] - } - """ - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "error": "Concept graph not available" - }), 501 - - try: - data = request.get_json(force=True, silent=True) - if not data: - return jsonify({ - "status": "error", - "error": "Invalid JSON" - }), 400 - - # Validate format - if data.get('scidk_concept_graph') != '1.0': - return jsonify({ - "status": "error", - "error": "Invalid concept graph format" - }), 400 - - from ...services.concept_graph_service import import_concept_graph - import os - - ollama_endpoint = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - result = import_concept_graph(concept_driver, data, ollama_endpoint) - - return jsonify({ - "status": "ok", - **result - }), 200 - - except Exception as e: - return jsonify({ - "status": "error", - "error": str(e) - }), 500 - - -# ========== Concept Graph Editor Endpoints ========== - - -@bp.get('/chat/concept-graph/intents') -def api_concept_graph_intents(): - """Get all intents with their top tool + edge weights.""" - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "error": "Concept graph not available" - }), 501 - - try: - with concept_driver.session() as session: - result = session.run(""" - MATCH (i:Concept_Intent)-[r:SATISFIES]->(t:Concept_Tool) - WITH i, t, r - ORDER BY r.weight DESC - WITH i, COLLECT({tool: t.name, weight: r.weight, usage_count: r.usage_count})[0] AS top_tool, - i.description AS description, - i.examples AS examples - RETURN i.name AS name, - description, - examples, - top_tool.tool AS top_tool, - top_tool.weight AS weight, - top_tool.usage_count AS usage_count - ORDER BY i.name - """).data() - - return jsonify({"status": "ok", "intents": result}), 200 - except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 500 - - -@bp.put('/chat/concept-graph/intent/') -def api_concept_graph_intent_update(intent_name): - """Update intent description/examples + re-embed.""" - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "error": "Concept graph not available" - }), 501 - - data = request.get_json(force=True, silent=True) or {} - description = data.get('description', '').strip() - examples = data.get('examples', []) - - if not description: - return jsonify({"status": "error", "error": "description required"}), 400 - if not isinstance(examples, list): - return jsonify({"status": "error", "error": "examples must be a list"}), 400 - - try: - # Update intent node - with concept_driver.session() as session: - session.run(""" - MATCH (i:Concept_Intent {name: $name}) - SET i.description = $description, - i.examples = $examples - """, name=intent_name, description=description, examples=examples) - - # Re-embed this intent - ollama_endpoint = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - from ...services.concept_graph_service import embed_text - import json - - # Build embedding text from description + examples - embed_input = f"{description}\n" + "\n".join(examples) - embedding = embed_text(embed_input, ollama_endpoint) - - if embedding: - with concept_driver.session() as session: - session.run(""" - MATCH (i:Concept_Intent {name: $name}) - SET i.embedding = $embedding - """, name=intent_name, embedding=embedding) - - return jsonify({"status": "ok", "re_embedded": embedding is not None}), 200 - except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 500 - - -@bp.get('/chat/concept-graph/tools') -def api_concept_graph_tools(): - """Get all tools with active status + retrieves labels.""" - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "error": "Concept graph not available" - }), 501 - - try: - with concept_driver.session() as session: - result = session.run(""" - MATCH (t:Concept_Tool) - OPTIONAL MATCH (t)-[:RETRIEVES]->(l:Concept_Label) - WITH t, COLLECT(l.name) AS retrieves - RETURN t.name AS name, - t.description AS description, - COALESCE(t.active, true) AS active, - t.source AS source, - retrieves - ORDER BY t.name - """).data() - - return jsonify({"status": "ok", "tools": result}), 200 - except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 500 - - -@bp.post('/chat/concept-graph/tool//toggle') -def api_concept_graph_tool_toggle(tool_name): - """Toggle tool active/inactive.""" - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "error": "Concept graph not available" - }), 501 - - try: - with concept_driver.session() as session: - result = session.run(""" - MATCH (t:Concept_Tool {name: $name}) - SET t.active = NOT COALESCE(t.active, true) - RETURN t.active AS active - """, name=tool_name).single() - - if result is None: - return jsonify({"status": "error", "error": "Tool not found"}), 404 - - return jsonify({"status": "ok", "name": tool_name, "active": result["active"]}), 200 - except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 500 - - -@bp.get('/chat/concept-graph/status') -def api_concept_graph_status(): - """Get concept graph status.""" - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "connected": False, - "intents": 0, - "tools": 0, - "satisfies_edges": 0 - }), 501 - - try: - with concept_driver.session() as session: - stats = session.run(""" - MATCH (i:Concept_Intent) - WITH COUNT(i) AS intents - MATCH (t:Concept_Tool) - WITH intents, COUNT(t) AS tools - MATCH ()-[r:SATISFIES]->() - RETURN intents, tools, COUNT(r) AS satisfies_edges - """).single() - - # Get last_seeded from a timestamp property if it exists - # For now, return None (could be added to concept graph metadata) - return jsonify({ - "status": "ok", - "connected": True, - "intents": stats["intents"], - "tools": stats["tools"], - "satisfies_edges": stats["satisfies_edges"], - "last_seeded": None # TODO: track seeding timestamp - }), 200 - except Exception as e: - return jsonify({ - "status": "error", - "connected": False, - "error": str(e) - }), 500 - - -@bp.post('/chat/concept-graph/reseed') -def api_concept_graph_reseed(): - """Re-seed full concept graph.""" - concept_driver = _get_ext().get('concept_driver') - if concept_driver is None: - return jsonify({ - "status": "disabled", - "error": "Concept graph not available" - }), 501 - - try: - from ...services.concept_graph_service import seed_intents_from_yaml, seed_tools_from_yaml, sync_labels_from_schema - - ollama_endpoint = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - intents_file = Path(__file__).parent.parent.parent / 'concept_graph' / 'intents.yaml' - - # Seed intents - intent_result = seed_intents_from_yaml(concept_driver, str(intents_file), ollama_endpoint) - - # Seed tools - tool_result = seed_tools_from_yaml(concept_driver, str(intents_file), ollama_endpoint) - - # Sync labels - research_driver = _get_ext().get('driver') - if research_driver: - sqlite_conn = _get_chat_service()._get_conn() - try: - label_result = sync_labels_from_schema(concept_driver, research_driver, sqlite_conn) - finally: - sqlite_conn.close() - else: - label_result = {"synced": 0} - - return jsonify({ - "status": "ok", - "intents_seeded": intent_result.get('embedded', 0), - "tools_seeded": tool_result.get('seeded', 0), - "labels_synced": label_result.get('synced', 0) - }), 200 - except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 500 diff --git a/scidk/web/routes/api_files.py b/scidk/web/routes/api_files.py index 42d64bf6..2a2f4671 100644 --- a/scidk/web/routes/api_files.py +++ b/scidk/web/routes/api_files.py @@ -955,15 +955,7 @@ def api_servers(): # For each root, check scan history for root in roots: - # Handle both DriveInfo dataclass and dict for backward compatibility - if isinstance(root, dict): - root_id = root.get('id', '/') - root_path = root.get('path', root_id) - else: - # DriveInfo dataclass - root_id = getattr(root, 'id', '/') - root_path = getattr(root, 'path', root_id) - + root_id = root.get('id', '/') key = f"{prov_id}:{root_id}" scan_info = scan_history.get(key, {}) @@ -971,7 +963,7 @@ def api_servers(): 'id': prov_id, 'display_name': display_name, 'root_id': root_id, - 'root_path': root_path, + 'root_path': root.get('path', root_id), 'connected': connected, 'scanned': scan_info.get('scanned', False), 'last_scanned': scan_info.get('last_scanned', None), diff --git a/scidk/web/routes/api_graph.py b/scidk/web/routes/api_graph.py index ffe1bbea..58b39bfc 100644 --- a/scidk/web/routes/api_graph.py +++ b/scidk/web/routes/api_graph.py @@ -1030,166 +1030,3 @@ def api_ro_crates_export(crate_id): except Exception as e: return jsonify({'error': str(e)}), 500 - -@bp.get('/schema/map') -def api_schema_map(): - """Get schema visualization data for Cytoscape rendering. - - Returns: - JSON with Cytoscape-compatible format: - { - nodes: [{data: {id, label, count, description, color}}], - edges: [{data: {id, source, target, label, count}}] - } - """ - try: - # Get primary Neo4j driver - driver = None - try: - from neo4j import GraphDatabase - uri, user, password, database, auth_mode = get_neo4j_params() - if uri: - driver = GraphDatabase.driver(uri, auth=None if auth_mode == 'none' else (user, password)) - except Exception as e: - return jsonify({"error": f"Failed to connect to Neo4j: {str(e)}"}), 500 - - if not driver: - return jsonify({"error": "Neo4j not configured"}), 400 - - nodes = [] - edges = [] - - try: - with driver.session(database=database) as session: - # Get all labels with counts - label_query = """ - CALL db.labels() YIELD label - CALL apoc.cypher.run('MATCH (n:`' + label + '`) RETURN count(n) as count', {}) - YIELD value - RETURN label, value.count as count - """ - try: - result = session.run(label_query) - for record in result: - label = record['label'] - count = record['count'] - - # Try to get description from label_profile - description = "" - color = "#3498db" # Default blue - try: - from ...services.label_service import LabelService - label_service = LabelService() - profile = label_service.get_label_profile(label) - if profile and 'description' in profile: - description = profile['description'] - # Color coding by count (simple heuristic) - if count > 1000: - color = "#e74c3c" # Red for high count - elif count > 100: - color = "#f39c12" # Orange for medium - else: - color = "#3498db" # Blue for low - except Exception: - pass - - nodes.append({ - 'data': { - 'id': label, - 'label': label, - 'count': count, - 'description': description, - 'color': color - } - }) - except Exception: - # Fallback without APOC - label_result = session.run("CALL db.labels() YIELD label RETURN label") - for record in label_result: - label = record['label'] - count_result = session.run(f"MATCH (n:`{label}`) RETURN count(n) as count") - count = count_result.single()['count'] - - nodes.append({ - 'data': { - 'id': label, - 'label': label, - 'count': count, - 'description': '', - 'color': '#3498db' - } - }) - - # Get all relationship types with source/target labels - rel_query = """ - CALL db.relationshipTypes() YIELD relationshipType - CALL apoc.cypher.run( - 'MATCH (a)-[r:`' + relationshipType + '`]->(b) - RETURN DISTINCT labels(a)[0] as source, labels(b)[0] as target, count(r) as count - LIMIT 1000', - {} - ) YIELD value - RETURN relationshipType, value.source as source, value.target as target, value.count as count - """ - try: - result = session.run(rel_query) - edge_id = 0 - for record in result: - rel_type = record['relationshipType'] - source = record['source'] - target = record['target'] - count = record['count'] - - if source and target: - edges.append({ - 'data': { - 'id': f'e{edge_id}', - 'source': source, - 'target': target, - 'label': rel_type, - 'count': count - } - }) - edge_id += 1 - except Exception: - # Fallback without APOC (slower but works) - rel_types_result = session.run("CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType") - edge_id = 0 - for record in rel_types_result: - rel_type = record['relationshipType'] - # Sample relationships to find source/target labels - sample_query = f""" - MATCH (a)-[r:`{rel_type}`]->(b) - RETURN DISTINCT labels(a)[0] as source, labels(b)[0] as target, count(r) as count - LIMIT 100 - """ - sample_result = session.run(sample_query) - for sample in sample_result: - source = sample['source'] - target = sample['target'] - count = sample['count'] - - if source and target: - edges.append({ - 'data': { - 'id': f'e{edge_id}', - 'source': source, - 'target': target, - 'label': rel_type, - 'count': count - } - }) - edge_id += 1 - finally: - driver.close() - - return jsonify({ - 'nodes': nodes, - 'edges': edges, - 'node_count': len(nodes), - 'edge_count': len(edges) - }), 200 - - except Exception as e: - return jsonify({"error": str(e)}), 500 - diff --git a/scidk/web/routes/api_neo4j.py b/scidk/web/routes/api_neo4j.py index a98f19f1..1e29932e 100644 --- a/scidk/web/routes/api_neo4j.py +++ b/scidk/web/routes/api_neo4j.py @@ -119,7 +119,6 @@ def api_scan_commit(scan_id): neo_attempted = False neo_written = 0 neo_error = None - dataset_result = None db_verified = None db_files = 0 db_folders = 0 @@ -171,31 +170,6 @@ def _prog(ev, payload): # Update state on success neo_state['connected'] = True neo_state['last_error'] = None - # Post-commit: create :Dataset nodes for matched directories. - # File/Folder nodes now exist (write_scan committed above), so - # (:Dataset)-[:CONTAINS]->(:File) links can resolve. Best-effort: - # a failure here must never break the commit response. - try: - from ...services.dataset_node_service import write_dataset_nodes - from ...services.neo4j_client import Neo4jClient - profile_registry = _get_ext().get('profile_registry') - ds_client = Neo4jClient(uri, user, pwd, database, auth_mode).connect() - try: - current_app.logger.info( - "Dataset node service: starting (scan_id=%s, host=%s)", - scan_id, s.get('host_id'), - ) - dataset_result = write_dataset_nodes( - scan_id=scan_id, - host=s.get('host_id'), - neo4j_client=ds_client, - profile_registry=profile_registry, - ) - current_app.logger.info(f"Dataset node service result: {dataset_result}") - finally: - ds_client.close() - except Exception as de: - current_app.logger.warning(f"Dataset node creation failed: {de}", exc_info=True) except Exception as ne: neo_error = str(ne) neo_state['connected'] = False @@ -228,11 +202,6 @@ def _prog(ev, payload): pass if neo_error: payload["neo4j_error"] = neo_error - if dataset_result is not None: - payload["datasets_created"] = int(dataset_result.get('created', 0)) - payload["datasets_updated"] = int(dataset_result.get('updated', 0)) - if dataset_result.get('errors'): - payload["dataset_errors"] = dataset_result['errors'] # Add user-facing warnings if total == 0: payload["warning"] = "This scan has 0 files; nothing was linked." diff --git a/scidk/web/routes/api_tasks.py b/scidk/web/routes/api_tasks.py index 0ced16b0..a2e05e17 100644 --- a/scidk/web/routes/api_tasks.py +++ b/scidk/web/routes/api_tasks.py @@ -601,34 +601,6 @@ def _on_prog(e, p): task['neo4j_db_folders'] = int(result.get('db_folders') or 0) if task['neo4j_attempted'] and not task['neo4j_db_verified'] and not task.get('neo4j_error'): task['neo4j_error'] = 'Post-commit verification found 0 SCANNED_IN edges for this scan. Check Neo4j credentials/database or permissions.' - # Post-commit: create :Dataset nodes for matched directories. This must - # mirror the synchronous commit path in api_neo4j.api_scan_commit — large - # scans commit through this background worker, so without this step they - # would never get Dataset nodes. Best-effort: never break the commit. - if task.get('neo4j_attempted') and not task.get('neo4j_error'): - try: - from ...services.dataset_node_service import write_dataset_nodes - from ...services.neo4j_client import Neo4jClient - profile_registry = current_app.extensions['scidk'].get('profile_registry') - ds_client = Neo4jClient(uri, user, pwd, database, auth_mode).connect() - try: - current_app.logger.info( - "Dataset node service: starting (scan_id=%s, host=%s)", - scan_id, s.get('host_id'), - ) - dataset_result = write_dataset_nodes( - scan_id=scan_id, - host=s.get('host_id'), - neo4j_client=ds_client, - profile_registry=profile_registry, - ) - current_app.logger.info(f"Dataset node service result: {dataset_result}") - task['datasets_created'] = int(dataset_result.get('created', 0)) - task['datasets_updated'] = int(dataset_result.get('updated', 0)) - finally: - ds_client.close() - except Exception as de: - current_app.logger.warning(f"Dataset node creation failed: {de}", exc_info=True) # Done # mark final step (Neo4j write) as processed so progress reaches 100% only at the end task['processed'] = task.get('total') or task.get('processed') diff --git a/scidk/web/routes/api_tokens.py b/scidk/web/routes/api_tokens.py deleted file mode 100644 index 0cbbe558..00000000 --- a/scidk/web/routes/api_tokens.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -Blueprint for API token management routes (admin-only). - -API tokens let non-browser clients (Python scripts, MATLAB, etc.) authenticate -with an ``Authorization: Bearer `` header instead of a browser session. -Each token is tied to a user and carries that user's existing role. - -Endpoints: -- POST /api/settings/tokens - Generate a new token (plaintext returned once) -- GET /api/settings/tokens - List all tokens (metadata only) -- DELETE /api/settings/tokens/ - Revoke a token by id -""" -import json -from flask import Blueprint, jsonify, request, current_app, g -from ...core.auth import get_auth_manager -from ..decorators import require_admin - -bp = Blueprint('tokens', __name__, url_prefix='/api/settings/tokens') - - -def _get_auth_manager(): - """Get AuthManager instance using settings DB path from config.""" - db_path = current_app.config.get('SCIDK_SETTINGS_DB', 'scidk_settings.db') - return get_auth_manager(db_path=db_path) - - -@bp.post('') -@require_admin -def api_tokens_create(): - """Generate a new API token for a user (admin only). - - Request body: - { - "user_id": 123, - "label": "Anderson MATLAB script" - } - - Returns: - 201: { - "id": "", - "token": "", - "message": "Store this token now - it will not be shown again." - } - 400: {"error": "Missing user_id or label"} - 404: {"error": "User not found"} - """ - auth = _get_auth_manager() - data = request.get_json(silent=True) or {} - - user_id = data.get('user_id') - label = (data.get('label') or '').strip() - - if user_id is None or not label: - return jsonify({'error': 'Missing user_id or label'}), 400 - - try: - user_id = int(user_id) - except (TypeError, ValueError): - return jsonify({'error': 'user_id must be an integer'}), 400 - - if auth.get_user(user_id) is None: - return jsonify({'error': 'User not found'}), 404 - - result = auth.create_api_token(user_id, label) - if not result: - return jsonify({'error': 'Failed to create token'}), 500 - - # Audit the issuance (never log the plaintext token itself) - created_by = g.scidk_user if hasattr(g, 'scidk_user') else 'system' - details = json.dumps({'token_id': result['id'], 'user_id': user_id, 'label': label}) - auth.log_audit(created_by, 'api_token_created', details, request.remote_addr) - - return jsonify({ - 'id': result['id'], - 'token': result['token'], - 'message': 'Store this token now - it will not be shown again.', - }), 201 - - -@bp.get('') -@require_admin -def api_tokens_list(): - """List all API tokens (admin only). - - Never returns token hashes or plaintext. - - Returns: - 200: {"tokens": [{"id", "user_id", "label", "created_at", "last_used_at"}]} - """ - auth = _get_auth_manager() - return jsonify({'tokens': auth.list_api_tokens()}), 200 - - -@bp.delete('/<token_id>') -@require_admin -def api_tokens_delete(token_id): - """Revoke (delete) an API token by id (admin only). - - Returns: - 200: {"success": true} - 404: {"error": "Token not found"} - """ - auth = _get_auth_manager() - deleted = auth.delete_api_token(token_id) - - if not deleted: - return jsonify({'error': 'Token not found'}), 404 - - created_by = g.scidk_user if hasattr(g, 'scidk_user') else 'system' - auth.log_audit(created_by, 'api_token_revoked', json.dumps({'token_id': token_id}), request.remote_addr) - - return jsonify({'success': True}), 200 diff --git a/scripts/migrations/add_fulltext_indexes.py b/scripts/migrations/add_fulltext_indexes.py deleted file mode 100644 index b5656619..00000000 --- a/scripts/migrations/add_fulltext_indexes.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Add Neo4j full-text indexes for key string properties. - -This script creates full-text indexes on text-based properties across all labels, -enabling fast keyword search without embedding overhead. - -Usage: - python scripts/migrations/add_fulltext_indexes.py - -Features: -- Auto-discovers labels and string properties -- Creates label-specific full-text indexes -- Skips existing indexes -- Safe to re-run (idempotent) -""" -import os -import sys -from pathlib import Path - -# Add project root to path -project_root = Path(__file__).parent.parent.parent -sys.path.insert(0, str(project_root)) - -from neo4j import GraphDatabase -from typing import List, Dict, Set - - -def get_neo4j_connection(): - """Get Neo4j connection from environment.""" - uri = os.getenv('NEO4J_URI', 'bolt://localhost:7687') - user = os.getenv('NEO4J_USER', 'neo4j') - password = os.getenv('NEO4J_PASSWORD', 'password') - - return GraphDatabase.driver(uri, auth=(user, password)) - - -def get_existing_fulltext_indexes(session) -> Set[str]: - """Get names of existing full-text indexes.""" - result = session.run("SHOW INDEXES YIELD name, type WHERE type = 'FULLTEXT' RETURN name") - return {record['name'] for record in result} - - -def get_text_properties_per_label(session, label: str) -> List[str]: - """ - Get string properties for a label that are good candidates for full-text search. - - Heuristics: - - String type - - Not IDs or timestamps - - Commonly used (present in >50% of nodes) - """ - # Simplified: just get all string properties, filter by heuristics - query = f""" - MATCH (n:`{label}`) - UNWIND keys(n) AS prop - WITH DISTINCT prop, n[prop] AS value - WHERE value IS NOT NULL - AND (value IS :: STRING) - AND NOT prop =~ '(?i).*(id|uuid|timestamp|created|updated|date).*' - RETURN DISTINCT prop - LIMIT 5 - """ - - try: - result = session.run(query) - return [record['prop'] for record in result] - except Exception as e: - print(f" ⚠️ Could not analyze properties for {label}: {e}") - return [] - - -def create_fulltext_index(session, label: str, properties: List[str], existing_indexes: Set[str]) -> bool: - """ - Create a full-text index for the given label and properties. - - Returns True if created, False if skipped. - """ - if not properties: - return False - - # Index name follows Neo4j convention - index_name = f"{label.lower()}_fulltext_idx" - - if index_name in existing_indexes: - print(f" ⏭️ Skipping {label} (index already exists)") - return False - - # Build property list for query - props_str = ', '.join([f'n.{prop}' for prop in properties]) - - query = f""" - CREATE FULLTEXT INDEX {index_name} - FOR (n:`{label}`) - ON EACH [{props_str}] - OPTIONS {{indexConfig: {{`fulltext.analyzer`: 'standard'}}}} - """ - - try: - session.run(query) - print(f" ✅ Created {index_name} on properties: {', '.join(properties)}") - return True - except Exception as e: - print(f" ❌ Failed to create {index_name}: {e}") - return False - - -def main(): - """Main migration logic.""" - print("🔍 Adding Neo4j full-text indexes...") - print() - - driver = get_neo4j_connection() - - try: - with driver.session() as session: - # Get all labels - result = session.run("CALL db.labels() YIELD label RETURN label") - labels = [record['label'] for record in result] - - print(f"Found {len(labels)} labels in database") - print() - - # Get existing indexes - existing_indexes = get_existing_fulltext_indexes(session) - if existing_indexes: - print(f"Existing full-text indexes: {', '.join(existing_indexes)}") - print() - - # Process each label - created_count = 0 - skipped_count = 0 - - for label in labels: - print(f"Processing {label}...") - - # Find text properties suitable for indexing - text_props = get_text_properties_per_label(session, label) - - if not text_props: - print(f" ⏭️ No suitable text properties found") - skipped_count += 1 - continue - - # Create index - if create_fulltext_index(session, label, text_props, existing_indexes): - created_count += 1 - else: - skipped_count += 1 - - print() - print(f"✨ Complete! Created {created_count} indexes, skipped {skipped_count}") - - # Show usage example - if created_count > 0: - print() - print("📖 Usage example:") - print(f" CALL db.index.fulltext.queryNodes(") - print(f" '{labels[0].lower()}_fulltext_idx',") - print(f" 'search terms'") - print(f" ) YIELD node, score") - print(f" RETURN node, score") - print(f" ORDER BY score DESC") - - finally: - driver.close() - - -if __name__ == '__main__': - main() diff --git a/seed_concept_graph.py b/seed_concept_graph.py deleted file mode 100755 index aba509f4..00000000 --- a/seed_concept_graph.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -""" -Seed Concept Graph with intents, tools, and schema labels. - -Runs within Flask app context to bypass auth middleware and access app configuration. -Follows the same pattern as seed_schema_embeddings.py. - -Usage: - python seed_concept_graph.py -""" -import os -import sys - -# Add scidk to path -sys.path.insert(0, os.path.dirname(__file__)) - -from scidk.app import create_app - - -def main(): - print("Initializing Flask app...") - app = create_app() - - with app.app_context(): - # Get concept graph driver - concept_driver = app.extensions['scidk'].get('concept_driver') - - if not concept_driver: - print("ERROR: Concept graph is not configured or unavailable") - print("Check these environment variables:") - print(" SCIDK_CONCEPT_NEO4J_URI (default: bolt://localhost:7689)") - print(" SCIDK_CONCEPT_NEO4J_AUTH (default: neo4j/concept-graph-password)") - print(" SCIDK_CONCEPT_GRAPH_ENABLED (default: 1)") - return 1 - - print(f"Connected to concept graph") - - # Get research graph driver for schema sync - from scidk.services.neo4j_client import get_neo4j_params - from neo4j import GraphDatabase - - uri, user, pwd, database, auth_mode = get_neo4j_params(app) - - if not uri: - print("ERROR: Research graph (Neo4j) is not configured") - print("Concept graph needs research graph schema for label sync") - return 1 - - auth = None if (auth_mode or 'basic').lower() == 'none' else (user, pwd) - research_driver = GraphDatabase.driver(uri, auth=auth) - print(f"Connected to research graph at {uri}") - - # Get SQLite connection - from scidk.services.chat_service import get_chat_service - db_path = app.config.get('SCIDK_SETTINGS_DB', 'scidk_settings.db') - print(f"Using SQLite database: {db_path}") - chat_service = get_chat_service(db_path=db_path) - sqlite_conn = chat_service._get_conn() - - try: - from scidk.services.concept_graph_service import ( - seed_intents_from_yaml, - seed_tools_from_yaml, - sync_labels_from_schema - ) - - ollama_url = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - yaml_path = os.path.join(os.path.dirname(__file__), 'scidk/concept_graph/intents.yaml') - - print(f"Using Ollama at: {ollama_url}") - print(f"Loading intents from: {yaml_path}") - - # Step 1: Seed intents - print("\n" + "="*60) - print("STEP 1: Seeding intents...") - print("="*60) - result = seed_intents_from_yaml(concept_driver, yaml_path, ollama_url) - print(f"✓ Intents embedded: {result['embedded']}") - print(f"✗ Intents failed: {result['failed']}") - - # Step 2: Seed tools - print("\n" + "="*60) - print("STEP 2: Seeding tools...") - print("="*60) - result = seed_tools_from_yaml(concept_driver, yaml_path, ollama_url) - print(f"✓ Tools embedded: {result['embedded']}") - print(f"✗ Tools failed: {result['failed']}") - - # Step 3: Sync labels from research schema - print("\n" + "="*60) - print("STEP 3: Syncing labels from research schema...") - print("="*60) - result = sync_labels_from_schema(concept_driver, research_driver, sqlite_conn) - print(f"✓ Labels synced: {result['labels']}") - print(f"✓ Relationships synced: {result['relationships']}") - print(f"✓ Edges created: {result['edges']}") - - # Verification - print("\n" + "="*60) - print("VERIFICATION") - print("="*60) - - with concept_driver.session() as session: - # Count nodes by label - result = session.run("MATCH (n) RETURN labels(n)[0] AS label, count(n) AS count ORDER BY label") - print("\nNode counts:") - for record in result: - print(f" {record['label']}: {record['count']}") - - # Count SATISFIES edges - result = session.run("MATCH ()-[r:SATISFIES]->() RETURN count(r) AS count") - satisfies_count = result.single()['count'] - print(f"\nSATISFIES edges: {satisfies_count}") - - # Count RETRIEVES edges - result = session.run("MATCH ()-[r:RETRIEVES]->() RETURN count(r) AS count") - retrieves_count = result.single()['count'] - print(f"RETRIEVES edges: {retrieves_count}") - - # Count REFERENCES_LABEL edges - result = session.run("MATCH ()-[r:REFERENCES_LABEL]->() RETURN count(r) AS count") - references_count = result.single()['count'] - print(f"REFERENCES_LABEL edges: {references_count}") - - print("\n" + "="*60) - print("✓ SUCCESS! Concept graph seeded successfully") - print("="*60) - print("\nNext steps:") - print(" 1. Restart gunicorn to load concept_driver:") - print(" pkill -f gunicorn && gunicorn -w 16 -b 127.0.0.1:5000 --timeout 300 \"scidk.app:create_app()\"") - print(" 2. Verify in Neo4j Browser: http://localhost:7476/browser/") - print(" Run: MATCH (n) RETURN labels(n), count(n)") - print(" 3. Test with a chat query") - - return 0 - - except Exception as e: - print(f"\n✗ ERROR: {e}") - import traceback - traceback.print_exc() - return 1 - finally: - sqlite_conn.close() - research_driver.close() - concept_driver.close() - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/seed_concept_graph_edges.py b/seed_concept_graph_edges.py deleted file mode 100644 index 49ec047d..00000000 --- a/seed_concept_graph_edges.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 -""" -Seed missing edges in the concept graph. - -Seeds RETRIEVES, REQUIRES, CONNECTED_VIA, and CONNECTS_TO edges -to eliminate planning query warnings and complete the concept graph schema. -""" -from neo4j import GraphDatabase -import os - -uri = os.environ.get('SCIDK_CONCEPT_NEO4J_URI', 'bolt://localhost:7689') -user, pwd = os.environ.get( - 'SCIDK_CONCEPT_NEO4J_AUTH', 'neo4j/concept-graph-password' -).split('/', 1) -d = GraphDatabase.driver(uri, auth=(user, pwd)) - -with d.session() as s: - print("Seeding RETRIEVES edges...") - # RETRIEVES edges: run_safe_cypher can retrieve any label - # (primary=False means general-purpose) - for label in ['Sample', 'File', 'Folder', 'Scan', 'SampleType']: - s.run(""" - MATCH (t:Concept_Tool {name: 'run_safe_cypher'}) - MATCH (l:Concept_Label {name: $label}) - MERGE (t)-[:RETRIEVES {primary: false}]->(l) - """, label=label) - - # generate_summary retrieves all labels (primary=false) - for label in ['Sample', 'File', 'Folder', 'Scan', 'SampleType']: - s.run(""" - MATCH (t:Concept_Tool {name: 'generate_summary'}) - MATCH (l:Concept_Label {name: $label}) - MERGE (t)-[:RETRIEVES {primary: false}]->(l) - """, label=label) - - print("Seeding REQUIRES edges...") - # REQUIRES edges: react_loop requires run_safe_cypher - s.run(""" - MATCH (t1:Concept_Tool {name: 'react_loop'}) - MATCH (t2:Concept_Tool {name: 'run_safe_cypher'}) - MERGE (t1)-[:REQUIRES {order: 1}]->(t2) - """) - - print("Seeding CONNECTED_VIA and CONNECTS_TO edges...") - # CONNECTED_VIA edges: mirror actual research graph relationships - rels = [ - ('File', 'SCANNED_IN', 'Scan'), - ('Folder', 'SCANNED_IN', 'Scan'), - ('Folder', 'CONTAINS', 'File'), - ('Sample', 'OF_TYPE', 'SampleType'), - ('Sample', 'DERIVED_FROM', 'Sample'), - ] - for source, rel_type, target in rels: - s.run(""" - MERGE (r:Concept_Relationship {type: $rel_type}) - WITH r - MATCH (src:Concept_Label {name: $source}) - MATCH (tgt:Concept_Label {name: $target}) - MERGE (src)-[:CONNECTED_VIA]->(r) - MERGE (r)-[:CONNECTS_TO]->(tgt) - """, rel_type=rel_type, source=source, target=target) - - print("\n✓ Edges seeded.") - print("\nEdge counts:") - result = s.run("MATCH ()-[r]->() RETURN type(r) as rel_type, count(r) as count ORDER BY count DESC").data() - for row in result: - print(f" {row['rel_type']}: {row['count']}") - -d.close() diff --git a/seed_schema_embeddings.py b/seed_schema_embeddings.py deleted file mode 100644 index 7bb23977..00000000 --- a/seed_schema_embeddings.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -""" -Seed schema embeddings for Schema Intelligence Layer. -Runs within Flask app context to bypass auth middleware. -""" -import os -import sys - -# Add scidk to path -sys.path.insert(0, os.path.dirname(__file__)) - -from scidk.app import create_app -from scidk.services.schema_intelligence import refresh_schema_embeddings -from scidk.services.chat_service import get_chat_service -from scidk.services.neo4j_client import get_neo4j_params -from neo4j import GraphDatabase - -def main(): - print("Initializing Flask app...") - app = create_app() - - with app.app_context(): - print("Getting Neo4j connection parameters...") - uri, user, pwd, database, auth_mode = get_neo4j_params(app) - - if not uri: - print("ERROR: Neo4j is not configured") - return 1 - - auth = None if (auth_mode or 'basic').lower() == 'none' else (user, pwd) - driver = GraphDatabase.driver(uri, auth=auth) - print(f"Connected to Neo4j at {uri}") - - # Get SQLite connection - db_path = app.config.get('SCIDK_SETTINGS_DB', 'scidk_settings.db') - print(f"Using SQLite database: {db_path}") - chat_service = get_chat_service(db_path=db_path) - sqlite_conn = chat_service._get_conn() - - try: - ollama_url = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - print(f"Using Ollama at: {ollama_url}") - print("\nRefreshing schema embeddings...") - - result = refresh_schema_embeddings( - neo4j_driver=driver, - sqlite_conn=sqlite_conn, - ollama_url=ollama_url, - database=database or "neo4j" - ) - - print(f"\n✓ Success!") - print(f" - Embedded: {result['embedded']}") - print(f" - Failed: {result['failed']}") - - # Check status - cursor = sqlite_conn.cursor() - label_count = cursor.execute( - "SELECT COUNT(*) FROM label_profile WHERE embedding IS NOT NULL" - ).fetchone()[0] - rel_count = cursor.execute( - "SELECT COUNT(*) FROM relationship_profile WHERE embedding IS NOT NULL" - ).fetchone()[0] - - print(f"\nCurrent status:") - print(f" - Labels with embeddings: {label_count}") - print(f" - Relationship types with embeddings: {rel_count}") - - return 0 - - except Exception as e: - print(f"\n✗ ERROR: {e}") - import traceback - traceback.print_exc() - return 1 - finally: - sqlite_conn.close() - driver.close() - -if __name__ == '__main__': - sys.exit(main()) diff --git a/tests/test_api_tokens.py b/tests/test_api_tokens.py deleted file mode 100644 index 05d78836..00000000 --- a/tests/test_api_tokens.py +++ /dev/null @@ -1,226 +0,0 @@ -""" -Tests for per-user API token (Bearer) authentication. - -Covers: -- AuthManager token CRUD and verification (table creation, generate, list, - revoke, bcrypt verify, last_used_at update, disabled-user handling). -- Token management endpoints (admin-only). -- Bearer API token auth flowing through the middleware + decorators. -""" -import os -import re -import tempfile -import time - -import pytest - -from scidk.core.auth import AuthManager, get_auth_manager -from scidk.app import create_app - - -HEX64 = re.compile(r'^[0-9a-f]{64}$') - - -class TestApiTokenManager: - """Unit tests for AuthManager API token methods.""" - - @pytest.fixture - def auth(self): - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: - db_path = f.name - auth_manager = AuthManager(db_path=db_path) - yield auth_manager - auth_manager.close() - if os.path.exists(db_path): - os.unlink(db_path) - - def test_api_tokens_table_exists(self, auth): - cur = auth.db.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'" - ) - assert cur.fetchone() is not None - - def test_create_api_token_returns_id_and_plaintext(self, auth): - user_id = auth.create_user('alice', 'pw', role='user') - result = auth.create_api_token(user_id, 'MATLAB script') - assert result is not None - assert 'id' in result and 'token' in result - # secrets.token_hex(32) -> 64 hex chars - assert HEX64.match(result['token']) - - def test_create_api_token_unknown_user(self, auth): - assert auth.create_api_token(99999, 'nope') is None - - def test_list_api_tokens_hides_secrets(self, auth): - user_id = auth.create_user('alice', 'pw', role='user') - created = auth.create_api_token(user_id, 'script A') - - tokens = auth.list_api_tokens() - assert len(tokens) == 1 - t = tokens[0] - assert t['id'] == created['id'] - assert t['user_id'] == user_id - assert t['label'] == 'script A' - assert 'token' not in t - assert 'token_hash' not in t - - def test_verify_api_token_valid(self, auth): - user_id = auth.create_user('alice', 'pw', role='admin') - created = auth.create_api_token(user_id, 'script') - - user = auth.verify_api_token(created['token']) - assert user is not None - assert user['id'] == user_id - assert user['username'] == 'alice' - assert user['role'] == 'admin' - - def test_verify_api_token_invalid(self, auth): - user_id = auth.create_user('alice', 'pw', role='user') - auth.create_api_token(user_id, 'script') - assert auth.verify_api_token('not-a-real-token') is None - assert auth.verify_api_token('') is None - assert auth.verify_api_token(None) is None - - def test_verify_api_token_updates_last_used(self, auth): - user_id = auth.create_user('alice', 'pw', role='user') - created = auth.create_api_token(user_id, 'script') - - assert auth.list_api_tokens()[0]['last_used_at'] is None - time.sleep(1) # CURRENT_TIMESTAMP has 1s resolution - auth.verify_api_token(created['token']) - assert auth.list_api_tokens()[0]['last_used_at'] is not None - - def test_verify_api_token_disabled_user(self, auth): - user_id = auth.create_user('alice', 'pw', role='user') - created = auth.create_api_token(user_id, 'script') - auth.update_user(user_id, enabled=False) - assert auth.verify_api_token(created['token']) is None - - def test_delete_api_token(self, auth): - user_id = auth.create_user('alice', 'pw', role='user') - created = auth.create_api_token(user_id, 'script') - - assert auth.delete_api_token(created['id']) is True - assert auth.list_api_tokens() == [] - # Deleting again / unknown id returns False - assert auth.delete_api_token(created['id']) is False - assert auth.delete_api_token('no-such-id') is False - - # A revoked token no longer authenticates - assert auth.verify_api_token(created['token']) is None - - -class TestApiTokenEndpoints: - """Endpoint + Bearer auth tests with authentication enforced.""" - - @pytest.fixture - def app(self): - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: - test_db_path = f.name - os.environ['PYTEST_TEST_AUTH'] = '1' - app = create_app() - app.config['TESTING'] = True - app.config['SCIDK_SETTINGS_DB'] = test_db_path - yield app - os.environ.pop('PYTEST_TEST_AUTH', None) - if os.path.exists(test_db_path): - os.unlink(test_db_path) - - @pytest.fixture - def auth(self, app): - return get_auth_manager(db_path=app.config['SCIDK_SETTINGS_DB']) - - @pytest.fixture - def client(self, app): - return app.test_client() - - def _admin(self, auth): - uid = auth.create_user('admin', 'password123', role='admin') - session = auth.create_user_session(uid, 'admin') - return uid, session - - def _user(self, auth): - uid = auth.create_user('bob', 'password123', role='user') - session = auth.create_user_session(uid, 'bob') - return uid, session - - def test_create_token_requires_admin(self, client, auth): - user_id, user_session = self._user(auth) - resp = client.post( - '/api/settings/tokens', - json={'user_id': user_id, 'label': 'x'}, - headers={'Authorization': f'Bearer {user_session}'}, - ) - assert resp.status_code == 403 - - def test_create_token_unauthenticated(self, client, auth): - # Auth is enabled (an admin exists) but no credentials provided. - self._admin(auth) - resp = client.post('/api/settings/tokens', json={'user_id': 1, 'label': 'x'}) - assert resp.status_code == 401 - - def test_create_list_revoke_flow(self, client, auth): - admin_id, admin_session = self._admin(auth) - hdr = {'Authorization': f'Bearer {admin_session}'} - - # Create - resp = client.post( - '/api/settings/tokens', - json={'user_id': admin_id, 'label': 'pipeline token'}, - headers=hdr, - ) - assert resp.status_code == 201 - body = resp.get_json() - assert HEX64.match(body['token']) - token_id = body['id'] - plaintext = body['token'] - - # List (metadata only) - resp = client.get('/api/settings/tokens', headers=hdr) - assert resp.status_code == 200 - tokens = resp.get_json()['tokens'] - assert any(t['id'] == token_id for t in tokens) - assert all('token' not in t and 'token_hash' not in t for t in tokens) - - # Revoke - resp = client.delete(f'/api/settings/tokens/{token_id}', headers=hdr) - assert resp.status_code == 200 - - # Revoke again -> 404 - resp = client.delete(f'/api/settings/tokens/{token_id}', headers=hdr) - assert resp.status_code == 404 - - # Revoked token no longer authenticates - resp = client.get('/api/users', headers={'Authorization': f'Bearer {plaintext}'}) - assert resp.status_code == 401 - - def test_create_token_validation(self, client, auth): - admin_id, admin_session = self._admin(auth) - hdr = {'Authorization': f'Bearer {admin_session}'} - - assert client.post('/api/settings/tokens', json={'label': 'x'}, headers=hdr).status_code == 400 - assert client.post('/api/settings/tokens', json={'user_id': admin_id}, headers=hdr).status_code == 400 - assert client.post('/api/settings/tokens', json={'user_id': 99999, 'label': 'x'}, headers=hdr).status_code == 404 - - def test_api_token_authenticates_as_admin(self, client, auth): - admin_id, admin_session = self._admin(auth) - created = auth.create_api_token(admin_id, 'admin token') - - # Use the API token (not a session) as Bearer on an admin-only route. - resp = client.get('/api/users', headers={'Authorization': f'Bearer {created["token"]}'}) - assert resp.status_code == 200 - - def test_api_token_carries_user_role(self, client, auth): - user_id, _ = self._user(auth) - # Need auth enabled with at least one admin so enforcement is on - self._admin(auth) - created = auth.create_api_token(user_id, 'user token') - - # A 'user'-role token is blocked from an admin-only route. - resp = client.get('/api/users', headers={'Authorization': f'Bearer {created["token"]}'}) - assert resp.status_code == 403 - - def test_invalid_bearer_token_rejected(self, client, auth): - self._admin(auth) # auth enabled - resp = client.get('/api/users', headers={'Authorization': 'Bearer totally-bogus'}) - assert resp.status_code == 401 diff --git a/tests/test_chat_fixes.py b/tests/test_chat_fixes.py deleted file mode 100644 index eba70ef3..00000000 --- a/tests/test_chat_fixes.py +++ /dev/null @@ -1,256 +0,0 @@ -""" -Tests for conversation context injection and REACT routing fixes. - -Tests Fix 1: Conversation context from SQLite -Tests Fix 2: REACT pattern matching for cross-service queries -""" -import pytest -from scidk.services.chat_service import ChatService -from scidk.services.graphrag.intent_classifier import classify, Intent - - -def test_get_recent_turns_empty_session(): - """Test get_recent_turns with no messages returns empty string.""" - import tempfile - import os - - # Use temp file instead of :memory: to ensure migrations run - fd, db_path = tempfile.mkstemp(suffix=".db") - os.close(fd) - - try: - chat_service = ChatService(db_path=db_path) - - # Create session with no messages - session = chat_service.create_session("Test Session") - - # Should return empty string - result = chat_service.get_recent_turns(session.id, n=4) - assert result == "" - finally: - os.unlink(db_path) - - -def test_get_recent_turns_formats_correctly(): - """Test get_recent_turns formats messages as User/Assistant pairs.""" - import tempfile - import os - - fd, db_path = tempfile.mkstemp(suffix=".db") - os.close(fd) - - try: - chat_service = ChatService(db_path=db_path) - - # Create session with messages - session = chat_service.create_session("Test Session") - chat_service.add_message(session.id, "user", "How many files do you have?") - chat_service.add_message(session.id, "assistant", "There are 1684 files.") - chat_service.add_message(session.id, "user", "What about folders?") - - # Get recent turns - result = chat_service.get_recent_turns(session.id, n=4) - - # Check formatting - assert "[Previous turns]" in result - assert "User: How many files do you have?" in result - assert "Assistant: There are 1684 files." in result - assert "User: What about folders?" in result - assert "---" in result - finally: - os.unlink(db_path) - - -def test_get_recent_turns_limits_to_n(): - """Test get_recent_turns respects n parameter.""" - import tempfile - import os - - fd, db_path = tempfile.mkstemp(suffix=".db") - os.close(fd) - - try: - chat_service = ChatService(db_path=db_path) - - # Create session with many messages - session = chat_service.create_session("Test Session") - for i in range(10): - chat_service.add_message(session.id, "user", f"Message {i}") - chat_service.add_message(session.id, "assistant", f"Response {i}") - - # Get only last 2 turns (4 messages) - result = chat_service.get_recent_turns(session.id, n=2) - - # Should only contain last 2 user messages and 2 assistant messages - assert "Message 8" in result - assert "Message 9" in result - assert "Message 7" not in result # Too old - finally: - os.unlink(db_path) - - -def test_get_recent_turns_truncates_long_messages(): - """Test get_recent_turns truncates messages longer than 150 chars.""" - import tempfile - import os - - fd, db_path = tempfile.mkstemp(suffix=".db") - os.close(fd) - - try: - chat_service = ChatService(db_path=db_path) - - # Create session with long message - session = chat_service.create_session("Test Session") - long_message = "A" * 200 - chat_service.add_message(session.id, "user", long_message) - - # Get recent turns - result = chat_service.get_recent_turns(session.id, n=4) - - # Should be truncated with ellipsis - assert "..." in result - assert len(result) < 300 # Much shorter than original 200 char message - finally: - os.unlink(db_path) - - -# ========== Intent Classifier Tests for REACT Patterns ========== - -def test_react_pattern_service_names(): - """Test REACT patterns match service names like Dropbox, SharePoint, Google.""" - queries = [ - "Find folders in Dropbox", - "Show me SharePoint files", - "List Google Drive items", - "CAC folders in dropbox", # lowercase - ] - - for query in queries: - intent = classify(query) - assert intent == Intent.REACT, f"Expected REACT for '{query}', got {intent}" - - -def test_react_pattern_across_services(): - """Test REACT patterns match 'across services/sources/platforms'.""" - queries = [ - "Find CAC folders across all services", - "Compare files across platforms", - "Check redundancy across sources", - "Show me data across 3 services", - ] - - for query in queries: - intent = classify(query) - assert intent == Intent.REACT, f"Expected REACT for '{query}', got {intent}" - - -def test_react_pattern_all_three(): - """Test REACT patterns match 'all three/2/3'.""" - queries = [ - "Check all three services", - "Find folders in all 3 platforms", - "Compare all 2 sources", - ] - - for query in queries: - intent = classify(query) - assert intent == Intent.REACT, f"Expected REACT for '{query}', got {intent}" - - -def test_react_pattern_each_service(): - """Test REACT patterns match 'each service/source/platform'.""" - queries = [ - "Count files in each service", - "Show folders from each source", - "Check each platform", - ] - - for query in queries: - intent = classify(query) - assert intent == Intent.REACT, f"Expected REACT for '{query}', got {intent}" - - -def test_react_pattern_redundancy(): - """Test REACT patterns match 'redundant/redundancy'.""" - queries = [ - "Are there redundant folders?", - "Check for redundancy", - "Find redundant files", - ] - - for query in queries: - intent = classify(query) - assert intent == Intent.REACT, f"Expected REACT for '{query}', got {intent}" - - -def test_react_pattern_compare_across(): - """Test REACT patterns match 'compare across/between'.""" - queries = [ - "Compare folders across services", - "Compare data between platforms", - "Compare files across all sources", - ] - - for query in queries: - intent = classify(query) - assert intent == Intent.REACT, f"Expected REACT for '{query}', got {intent}" - - -def test_react_pattern_multi_service(): - """Test REACT patterns match 'multi-service/multiple-service'.""" - queries = [ - "Run a multi-service query", - "Do a multiple-platform search", - "Multi-source analysis", - ] - - for query in queries: - intent = classify(query) - assert intent == Intent.REACT, f"Expected REACT for '{query}', got {intent}" - - -def test_react_pattern_check_all(): - """Test REACT patterns match 'check all/each/every' when combined with context.""" - queries = [ - "Check all folders for redundancy", # Triggers redundancy pattern - "Verify each service has CAC folders", # Triggers 'each service' pattern - "Check all three platforms", # Triggers 'all three' pattern - ] - - for query in queries: - intent = classify(query) - assert intent == Intent.REACT, f"Expected REACT for '{query}', got {intent}" - - -def test_react_pattern_complex_cross_service_query(): - """Test the exact query from the bug report routes to REACT.""" - query = "Find all base-level folders related to CAC in Dropbox, SharePoint, and Google Drive, and tell me if any are redundant" - - intent = classify(query) - assert intent == Intent.REACT, f"Expected REACT for complex cross-service query, got {intent}" - - -def test_react_pattern_does_not_override_summarize(): - """Test REACT patterns don't override higher-priority SUMMARIZE.""" - # SUMMARIZE patterns should have higher priority - query = "What do we have in the database?" # Explicitly triggers SUMMARIZE pattern - intent = classify(query) - assert intent == Intent.SUMMARIZE - - -def test_simple_lookup_still_routes_to_lookup(): - """Test simple lookups don't get caught by new REACT patterns.""" - queries = [ - "How many files?", - "List all folders", - "Show me samples", - ] - - for query in queries: - intent = classify(query) - assert intent == Intent.LOOKUP, f"Expected LOOKUP for '{query}', got {intent}" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_chat_intent_routing.py b/tests/test_chat_intent_routing.py deleted file mode 100644 index c97e62d3..00000000 --- a/tests/test_chat_intent_routing.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Test intent classification and ReAct loop routing.""" -import pytest -from scidk.services.graphrag.intent_classifier import classify, Intent -from scidk.ai.react_loop import ( - build_react_system_prompt, - extract_action, - is_safe_cypher, - format_step_history -) - - -class TestIntentClassification: - """Test intent routing for REACT path.""" - - def test_react_intent_exploratory(self): - """Test: Exploratory language routes to REACT.""" - queries = [ - "tell me about samples", - "explore the relationships between treatments and samples", - "investigate connections", - "I'm curious about the data", - "what can you tell me about this dataset" - ] - for query in queries: - intent = classify(query) - assert intent == Intent.REACT, f"'{query}' should route to REACT, got {intent}" - - def test_react_intent_conditional(self): - """Test: Conditional queries route to REACT.""" - queries = ["are there any samples that have multiple treatments?"] - for query in queries: - intent = classify(query) - assert intent == Intent.REACT, f"'{query}' should route to REACT, got {intent}" - - def test_lookup_still_works(self): - """Test: LOOKUP queries don't accidentally route to REACT.""" - queries = ["how many samples are there?", "list all treatments", "count the datasets"] - for query in queries: - intent = classify(query) - assert intent == Intent.LOOKUP, f"'{query}' should route to LOOKUP, got {intent}" - - def test_summarize_still_works(self): - """Test: SUMMARIZE queries don't route to REACT.""" - queries = ["summarize the data", "what do we have?", "give me an overview of the graph"] - for query in queries: - intent = classify(query) - assert intent == Intent.SUMMARIZE, f"'{query}' should route to SUMMARIZE, got {intent}" - - -class TestReActLoop: - """Test ReAct loop components.""" - - def test_extract_action_think(self): - """Test: THINK action is parsed correctly.""" - response = "THINK: I need to find out how many samples there are first" - action_type, content = extract_action(response) - assert action_type == "THINK" - assert "how many samples" in content - - def test_extract_action_query(self): - """Test: QUERY action is parsed correctly.""" - response = "QUERY: MATCH (s:Sample) RETURN count(s)" - action_type, content = extract_action(response) - assert action_type == "QUERY" - assert "MATCH" in content - - def test_is_safe_cypher_read_only(self): - """Test: Read-only Cypher is allowed.""" - queries = [ - "MATCH (s:Sample) RETURN s", - "MATCH (s:Sample)-[:HAS_TYPE]->(t:SampleType) RETURN t.name, count(s)", - ] - for query in queries: - is_safe, error = is_safe_cypher(query) - assert is_safe, f"Query should be safe: {query}, error: {error}" - - def test_is_safe_cypher_blocks_writes(self): - """Test: Write operations are blocked.""" - dangerous_queries = [ - "CREATE (n:Sample {id: 'test'})", - "MATCH (n:Sample) DELETE n", - "MATCH (n:Sample) SET n.value = 100", - ] - for query in dangerous_queries: - is_safe, error = is_safe_cypher(query) - assert not is_safe, f"Query should be blocked: {query}" - assert "Blocked" in error - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_chat_neo4j_setup.py b/tests/test_chat_neo4j_setup.py deleted file mode 100644 index a3d377df..00000000 --- a/tests/test_chat_neo4j_setup.py +++ /dev/null @@ -1,245 +0,0 @@ -""" -Test chat Neo4j setup and connectivity. - -Gate 2 verification tests: -- Chat Neo4j container is accessible -- Indexes can be created -- Messages can be written and retrieved -- Staleness detection works correctly -""" -import pytest -import time -import json - -pytestmark = pytest.mark.integration - -from scidk.services.chat_neo4j_client import ChatNeo4jClient, get_chat_neo4j_client -from scidk.ai.chat_graph import ( - get_label_snapshot, - check_staleness, - retrieve_relevant_context, - format_context_for_prompt, - log_chat_message -) - - -def test_chat_neo4j_connection(): - """Test: Can connect to chat Neo4j.""" - client = get_chat_neo4j_client() - assert client is not None, "Chat Neo4j client should be created" - - connected = client.verify_connection() - assert connected, "Chat Neo4j should be accessible" - - client.close() - - -def test_chat_neo4j_ensure_schema(): - """Test: Can create indexes in chat Neo4j.""" - client = get_chat_neo4j_client() - assert client is not None - - # Create schema (idempotent) - client.ensure_schema() - - # Verify indexes were created by querying them - with client._session() as session: - # Check if indexes exist (Neo4j 5.x syntax) - result = session.run("SHOW INDEXES") - indexes = [record["name"] for record in result] - - # Should have created these indexes - expected_indexes = [ - "chat_message_session", - "chat_message_timestamp", - "chat_message_finding_type", - "chat_session_id" - ] - - for expected in expected_indexes: - assert expected in indexes, f"Index {expected} should be created" - - client.close() - - -def test_write_and_retrieve_chat_message(): - """Test: Can write ChatMessage to chat Neo4j and retrieve it.""" - chat_client = get_chat_neo4j_client() - assert chat_client is not None - - # Create a mock research driver that returns empty snapshot - class MockResearchDriver: - def session(self): - class MockSession: - def run(self, query): - class MockResult: - def __iter__(self): - return iter([]) - def single(self): - return {"count": 0} - return MockResult() - def __enter__(self): - return self - def __exit__(self, *args): - pass - return MockSession() - - mock_research_driver = MockResearchDriver() - - # Log a test message - test_session_id = f"test_session_{int(time.time())}" - test_sqlite_id = f"test_msg_{int(time.time())}" - - log_chat_message( - chat_driver=chat_client, - research_driver=mock_research_driver, - sqlite_id=test_sqlite_id, - session_id=test_session_id, - role="user", - intent="LOOKUP", - content_summary="Test query about samples", - finding_text=None, - finding_type="NONE", - cypher_used=None, - referenced_labels=[], - embedding=None - ) - - # Retrieve the message - result = chat_client.execute_read( - """ - MATCH (m:ChatMessage {sqlite_id: $sqlite_id}) - RETURN m - """, - {"sqlite_id": test_sqlite_id} - ) - - assert len(result) == 1, "Should retrieve exactly one message" - msg = result[0]["m"] - assert msg["role"] == "user" - assert msg["intent"] == "LOOKUP" - assert msg["session_id"] == test_session_id - - # Cleanup - chat_client.execute_write( - "MATCH (m:ChatMessage {sqlite_id: $sqlite_id}) DETACH DELETE m", - {"sqlite_id": test_sqlite_id} - ) - chat_client.execute_write( - "MATCH (s:ChatSession {session_id: $session_id}) DETACH DELETE s", - {"session_id": test_session_id} - ) - - chat_client.close() - - -def test_staleness_detection(): - """Test: Staleness detection calculates correct percentage changes.""" - # Mock research driver that returns different counts - class MockResearchDriver: - def __init__(self, snapshot): - self.snapshot = snapshot - - def session(self): - class MockSession: - def __init__(self, snapshot): - self.snapshot = snapshot - - def run(self, query): - if "db.labels()" in query: - class LabelsResult: - def __init__(self, labels): - self.labels = labels - def __iter__(self): - return iter([{"label": label} for label in self.labels]) - return LabelsResult(list(self.snapshot.keys())) - else: - # Extract label from query - for label in self.snapshot.keys(): - if label in query: - class CountResult: - def __init__(self, count): - self.count = count - def single(self): - return {"count": self.count} - return CountResult(self.snapshot[label]) - class EmptyResult: - def single(self): - return {"count": 0} - return EmptyResult() - - def __enter__(self): - return self - - def __exit__(self, *args): - pass - - return MockSession(self.snapshot) - - # Test message with stored snapshot - message = { - "referenced_labels": ["Sample", "Treatment"], - "snapshot": {"Sample": 1000, "Treatment": 50}, - "finding_type": "COUNT" - } - - # Current snapshot shows significant changes - current_snapshot = {"Sample": 1080, "Treatment": 50} # 8% increase in Sample - mock_driver = MockResearchDriver(current_snapshot) - - signals = check_staleness(message, mock_driver) - - assert len(signals) == 2, "Should return signals for both labels" - - # Find Sample signal - sample_signal = next(s for s in signals if s["label"] == "Sample") - assert sample_signal["stored_count"] == 1000 - assert sample_signal["current_count"] == 1080 - assert sample_signal["delta"] == 80 - assert 0.07 < sample_signal["pct_change"] < 0.09 # ~8% - assert sample_signal["status"] == "moderate_change" - assert sample_signal["should_flag"] is True # COUNT finding with moderate change - - # Find Treatment signal - treatment_signal = next(s for s in signals if s["label"] == "Treatment") - assert treatment_signal["status"] == "unchanged" - assert treatment_signal["should_flag"] is False - - -def test_format_context_for_prompt(): - """Test: Context formatting stays under token budget.""" - messages = [ - { - "timestamp": time.time() - 86400, # 1 day ago - "intent": "LOOKUP", - "content_summary": "How many samples are in the database?", - "finding_text": "Found 9042 samples", - "cypher_used": "MATCH (s:Sample) RETURN count(s)", - "staleness_signals": [ - {"message": "Sample: 9847 now vs 9042 then (+805 nodes) ⚠️", "should_flag": True} - ] - }, - { - "timestamp": time.time() - 172800, # 2 days ago - "intent": "SUMMARIZE", - "content_summary": "What data do we have in the graph?", - "finding_text": "Database contains 3 node types with 10k total nodes", - "cypher_used": "", - "staleness_signals": [] - } - ] - - context = format_context_for_prompt(messages) - - assert len(context) > 0, "Context should be generated" - assert "[PAST CONTEXT" in context, "Should have context header" - assert "LOOKUP" in context, "Should include intent" - assert "Finding:" in context, "Should include findings" - assert "Staleness:" in context, "Should include staleness warnings" - - # Rough token check (~4 chars per token, target 800 tokens = 3200 chars) - assert len(context) < 3500, f"Context should stay under token budget (got {len(context)} chars)" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/test_dataset_node_service.py b/tests/test_dataset_node_service.py deleted file mode 100644 index c5dce1cd..00000000 --- a/tests/test_dataset_node_service.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Tests for post-commit :Dataset node creation. - -These exercise ``write_dataset_nodes`` end-to-end against a temporary SQLite path -index (populated like a real scan) and a fake Neo4j client that records the -Cypher it would run, so no live Neo4j is required. -""" -import pathlib - -import pytest - -from scidk.core import path_index_sqlite as pix -from scidk.core.profile_registry import ProfileRegistry -from scidk.services.dataset_node_service import write_dataset_nodes - -PROFILES_DIR = ( - pathlib.Path(__file__).resolve().parents[1] - / "scidk" - / "interpreters" - / "profiles" -) - -HOST = "host-1" -SCAN_ID = "scan-123" - - -class FakeNeo4jClient: - """Records execute_write calls; mimics a connected Neo4jClient.""" - - def __init__(self): - self._driver = object() # truthy -> _is_connected() returns True - self.calls = [] - - def execute_write(self, query, parameters=None): - self.calls.append((query, parameters or {})) - # Emulate the dataset MERGE returning a freshly-created node. - if "MERGE (d:Dataset" in query: - return [{"created": True}] - return [] - - def close(self): - pass - - -def _registry_from(*yaml_names): - """Build a ProfileRegistry loaded only with the named profile YAMLs.""" - return _registry_with_files( - [(name, (PROFILES_DIR / name).read_text()) for name in yaml_names] - ) - - -def _registry_with_files(name_content_pairs, tmp_path=None): - import tempfile - - reg = ProfileRegistry() - base = pathlib.Path(tempfile.mkdtemp()) - for name, content in name_content_pairs: - (base / name).write_text(content) - reg.load(base) - return reg - - -def _seed_files(rows): - conn = pix.connect() - pix.init_db(conn) - try: - cur = conn.cursor() - for r in rows: - cur.execute( - "INSERT INTO files(path, parent_path, name, depth, type, size, " - "modified_time, file_extension, mime_type, etag, hash, remote, " - "scan_id, extra_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ( - r["path"], r["parent_path"], r["name"], r.get("depth", 1), - r["type"], r.get("size", 10), None, r.get("ext"), None, - None, None, None, SCAN_ID, None, - ), - ) - conn.commit() - finally: - conn.close() - - -@pytest.fixture -def temp_db(tmp_path, monkeypatch): - monkeypatch.setenv("SCIDK_DB_PATH", str(tmp_path / "files.db")) - yield - - -def test_tiff_directory_writes_tiff_collection_dataset(temp_db): - # A directory of .tif files whose names are NOT a numbered sequence, so the - # most specific match is tiff_collection (TIFFCollection), not image_sequence. - dir_path = "remote:bucket/scan" - _seed_files([ - {"path": f"{dir_path}/alpha.tif", "parent_path": dir_path, "name": "alpha.tif", "type": "file", "ext": ".tif"}, - {"path": f"{dir_path}/beta.tif", "parent_path": dir_path, "name": "beta.tif", "type": "file", "ext": ".tif"}, - {"path": f"{dir_path}/notes.txt", "parent_path": dir_path, "name": "notes.txt", "type": "file", "ext": ".txt"}, - ]) - - reg = ProfileRegistry() - reg.load(PROFILES_DIR) # real registry incl. file_collection catch-all - client = FakeNeo4jClient() - - result = write_dataset_nodes(SCAN_ID, HOST, client, reg) - - assert result["created"] == 1 - assert result["updated"] == 0 - assert result["errors"] == [] - - # The Dataset MERGE must set type = "TIFFCollection". - merge_calls = [c for c in client.calls if "MERGE (d:Dataset" in c[0]] - assert len(merge_calls) == 1 - params = merge_calls[0][1] - assert params["type"] == "TIFFCollection" - assert params["profile_id"] == "tiff_collection" - assert params["dir_path"] == dir_path - assert params["host"] == HOST - - # Files are linked via (:Dataset)-[:CONTAINS]->(:File) matched on (path, host). - link_calls = [c for c in client.calls if "[:CONTAINS]" in c[0]] - assert len(link_calls) == 1 - link_params = link_calls[0][1] - assert set(link_params["file_paths"]) == {f"{dir_path}/alpha.tif", f"{dir_path}/beta.tif", f"{dir_path}/notes.txt"} - assert link_params["host"] == HOST - - -def test_no_matching_profile_writes_no_dataset(temp_db): - # Registry without the file_collection catch-all; a directory of .txt files - # matches nothing, so no Dataset node is written. - dir_path = "remote:bucket/docs" - _seed_files([ - {"path": f"{dir_path}/a.txt", "parent_path": dir_path, "name": "a.txt", "type": "file", "ext": ".txt"}, - {"path": f"{dir_path}/b.txt", "parent_path": dir_path, "name": "b.txt", "type": "file", "ext": ".txt"}, - ]) - - reg = _registry_from("tiff_collection.yaml") # no file_collection -> no catch-all - client = FakeNeo4jClient() - - result = write_dataset_nodes(SCAN_ID, HOST, client, reg) - - assert result["created"] == 0 - assert result["updated"] == 0 - assert [c for c in client.calls if "MERGE (d:Dataset" in c[0]] == [] - - -def test_disabled_profile_is_skipped(temp_db): - # scidk_dataset is disabled by default; a directory with a .scidk.yaml - # descriptor must not produce a Dataset node. - dir_path = "remote:bucket/userds" - _seed_files([ - {"path": f"{dir_path}/.scidk.yaml", "parent_path": dir_path, "name": ".scidk.yaml", "type": "file", "ext": ".yaml"}, - {"path": f"{dir_path}/data.bin", "parent_path": dir_path, "name": "data.bin", "type": "file", "ext": ".bin"}, - ]) - - reg = ProfileRegistry() - reg.load(PROFILES_DIR) # full registry: file_collection abstract, scidk_dataset disabled - client = FakeNeo4jClient() - - result = write_dataset_nodes(SCAN_ID, HOST, client, reg) - - assert result["created"] == 0 - assert [c for c in client.calls if "MERGE (d:Dataset" in c[0]] == [] - - -def test_abstract_profile_never_emits_node(temp_db): - # A directory that matches ONLY the abstract file_collection (no specific or - # enabled profile applies) produces no Dataset node. - dir_path = "remote:bucket/misc" - _seed_files([ - {"path": f"{dir_path}/data.bin", "parent_path": dir_path, "name": "data.bin", "type": "file", "ext": ".bin"}, - ]) - - reg = ProfileRegistry() - reg.load(PROFILES_DIR) - client = FakeNeo4jClient() - - result = write_dataset_nodes(SCAN_ID, HOST, client, reg) - - assert result["created"] == 0 - assert [c for c in client.calls if "MERGE (d:Dataset" in c[0]] == [] - - -def test_sqlite_setting_enables_scidk_dataset(temp_db, monkeypatch): - # The Settings-UI preference (profile_enabled_scidk_dataset=true in SQLite) - # overrides the YAML default and turns on the otherwise-disabled profile. - dir_path = "remote:bucket/userds" - _seed_files([ - {"path": f"{dir_path}/dataset.scidk.yaml", "parent_path": dir_path, "name": "dataset.scidk.yaml", "type": "file", "ext": ".yaml"}, - ]) - - # Override the settings lookup used inside _profile_enabled (imported lazily - # from scidk.core.settings) to simulate the Settings-UI preference. - from scidk.core import settings as settings_mod - monkeypatch.setattr( - settings_mod, - "get_setting", - lambda key, default=None: "true" if key == "profile_enabled_scidk_dataset" else default, - ) - - reg = ProfileRegistry() - reg.load(PROFILES_DIR) - client = FakeNeo4jClient() - - result = write_dataset_nodes(SCAN_ID, HOST, client, reg) - - assert result["created"] == 1 - merge_calls = [c for c in client.calls if "MERGE (d:Dataset" in c[0]] - assert len(merge_calls) == 1 - assert merge_calls[0][1]["type"] == "UserDefinedDataset" - assert merge_calls[0][1]["profile_id"] == "scidk_dataset" diff --git a/tests/test_folder_config_precedence.py b/tests/test_folder_config_precedence.py index 63b8bf08..c2614ea2 100644 --- a/tests/test_folder_config_precedence.py +++ b/tests/test_folder_config_precedence.py @@ -1,9 +1,6 @@ from pathlib import Path import json -import time -import pytest -@pytest.mark.skip(reason="Flaky in CI: config precedence with sibling folders has non-deterministic behavior (issue #TBD)") def test_folder_config_precedence_includes_excludes(client, tmp_path: Path): # Setup: two sibling folders with different .scidk.toml a = tmp_path / 'A' @@ -20,27 +17,16 @@ def test_folder_config_precedence_includes_excludes(client, tmp_path: Path): (b / 'c.txt').write_text('ok', encoding='utf-8') (b / 'd.md').write_text('no', encoding='utf-8') - # Ensure filesystem operations are complete - time.sleep(0.1) - # Scan tmp_path recursively r = client.post('/api/scan', json={'path': str(tmp_path), 'recursive': True}) assert r.status_code == 200 - scan_result = r.get_json() - - # Add diagnostic info for debugging CI failures - print(f"Scan result: {json.dumps(scan_result, indent=2)}") # List datasets and assert only selected files appear r2 = client.get('/api/datasets') assert r2.status_code == 200 items = r2.get_json() paths = {it.get('path') for it in items} - - # Add diagnostic info for debugging CI failures - print(f"Found {len(paths)} paths: {sorted(paths)}") - # Verify B's rules apply: include txt, exclude md - assert str(b / 'c.txt') in paths, f"Expected {b / 'c.txt'} in paths but got: {sorted(paths)}" + assert str(b / 'c.txt') in paths assert str(b / 'd.md') not in paths # A's precedence behavior is covered in follow-up tests; ensure no crash and API works. diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py deleted file mode 100644 index 619087c3..00000000 --- a/tests/test_mcp_tools.py +++ /dev/null @@ -1,154 +0,0 @@ -""" -Tests for MCP tools. - -These tests verify the core MCP tool implementations work correctly -with a live Neo4j database. -""" -import pytest -from neo4j import GraphDatabase -import os - -pytestmark = pytest.mark.integration - - -@pytest.fixture -def neo4j_driver(): - """Create a Neo4j driver for testing.""" - uri = os.getenv('NEO4J_URI', 'bolt://localhost:7687') - user = os.getenv('NEO4J_USER', 'neo4j') - password = os.getenv('NEO4J_PASSWORD', 'password') - - driver = GraphDatabase.driver(uri, auth=(user, password)) - yield driver - driver.close() - - -def test_list_labels(neo4j_driver): - """Test list_labels tool.""" - from scidk.ai import mcp_tools - - result = mcp_tools.list_labels(neo4j_driver, database="neo4j") - - assert result["status"] == "success" - assert "labels" in result - assert isinstance(result["labels"], list) - assert result["error"] is None - - # Should have at least one label - if len(result["labels"]) > 0: - assert "name" in result["labels"][0] - assert "count" in result["labels"][0] - - -def test_get_schema(neo4j_driver): - """Test get_schema tool.""" - from scidk.ai import mcp_tools - - result = mcp_tools.get_schema(neo4j_driver, database="neo4j") - - assert result["status"] == "success" - assert "schema" in result - assert "labels" in result["schema"] - assert "relationships" in result["schema"] - assert "properties" in result["schema"] - assert result["error"] is None - - -def test_query_knowledge_graph_safe(neo4j_driver): - """Test query_knowledge_graph with a safe query.""" - from scidk.ai import mcp_tools - - # Simple count query - result = mcp_tools.query_knowledge_graph( - neo4j_driver, - "MATCH (n) RETURN count(n) as total", - database="neo4j" - ) - - assert result["status"] == "success" - assert "rows" in result - assert isinstance(result["rows"], list) - assert result["error"] is None - - -def test_query_knowledge_graph_blocked_write(neo4j_driver): - """Test that write operations are blocked.""" - from scidk.ai import mcp_tools - - # Try a CREATE query (should be blocked) - result = mcp_tools.query_knowledge_graph( - neo4j_driver, - "CREATE (n:Test {name: 'test'}) RETURN n", - database="neo4j" - ) - - assert result["status"] == "error" - assert "Forbidden keyword" in result["error"] - assert "CREATE" in result["error"] - - -def test_query_knowledge_graph_auto_limit(neo4j_driver): - """Test that LIMIT is added automatically.""" - from scidk.ai import mcp_tools - - # Query without LIMIT should have one added - result = mcp_tools.query_knowledge_graph( - neo4j_driver, - "MATCH (n) RETURN n", - database="neo4j", - limit=5 - ) - - # Should succeed and return at most 5 rows - assert result["status"] == "success" - assert len(result["rows"]) <= 5 - - -def test_get_label_profile(neo4j_driver): - """Test get_label_profile tool.""" - from scidk.ai import mcp_tools - - # First, get a label name - labels_result = mcp_tools.list_labels(neo4j_driver, database="neo4j") - if len(labels_result["labels"]) == 0: - pytest.skip("No labels in database") - - label_name = labels_result["labels"][0]["name"] - - # Get profile for that label - result = mcp_tools.get_label_profile(neo4j_driver, label_name, database="neo4j") - - assert result["status"] == "success" - assert "profile" in result - assert result["profile"]["label"] == label_name - assert "node_count" in result["profile"] - assert "properties" in result["profile"] - assert "relationships" in result["profile"] - assert result["error"] is None - - -def test_summarize_dataset(neo4j_driver): - """Test summarize_dataset tool.""" - from scidk.ai import mcp_tools - - # Test overall summary - result = mcp_tools.summarize_dataset(neo4j_driver, database="neo4j") - - assert result["status"] == "success" - assert "summary" in result - assert result["error"] is None - - -def test_tool_definitions_complete(): - """Test that all tools have proper definitions.""" - from scidk.ai import mcp_tools - - assert len(mcp_tools.TOOL_DEFINITIONS) == 5 - - for tool_def in mcp_tools.TOOL_DEFINITIONS: - assert "name" in tool_def - assert "description" in tool_def - assert "inputSchema" in tool_def - assert "type" in tool_def["inputSchema"] - assert tool_def["inputSchema"]["type"] == "object" - assert "properties" in tool_def["inputSchema"] diff --git a/tests/test_profile_matcher.py b/tests/test_profile_matcher.py deleted file mode 100644 index 11363516..00000000 --- a/tests/test_profile_matcher.py +++ /dev/null @@ -1,80 +0,0 @@ -from scidk.core.profile_matcher import match - - -def _file(name, path=None, size=10): - return {"Name": name, "Path": path or name, "Size": size, "IsDir": False} - - -TIFF_PROFILE = { - "profile_id": "tiff_collection", - "trigger": { - "extensions": [".tif", ".tiff"], - "filename_pattern": r".*\.(tif|tiff)$", - }, - "siblings": [ - { - "group": "tiff_files", - "required": True, - "type": "file", - "min_count": 1, - "patterns": [r".*\.(tif|tiff)$"], - } - ], -} - -FILE_COLLECTION_PROFILE = { - "profile_id": "file_collection", - "trigger": {"extensions": [], "filename_pattern": ".*"}, -} - -IMAGE_SEQUENCE_PROFILE = { - "profile_id": "image_sequence", - "trigger": { - "extensions": [".tif", ".tiff"], - "filename_pattern": r".*\d{3,}\.(tif|tiff)$", - }, - "siblings": [ - { - "group": "numbered_images", - "required": True, - "type": "file", - "min_count": 3, - "patterns": [r".*\d{3,}\.(tif|tiff)$"], - } - ], -} - - -def test_trigger_match(): - entries = [_file("scan001.tif"), _file("notes.txt")] - res = match("/data/scan", entries, TIFF_PROFILE) - assert res["matched"] is True - assert res["trigger_file"] == "scan001.tif" - assert res["matched_groups"]["tiff_files"] == ["scan001.tif"] - - -def test_trigger_no_match(): - entries = [_file("notes.txt"), _file("data.csv")] - res = match("/data/scan", entries, TIFF_PROFILE) - assert res["matched"] is False - assert res["trigger_file"] is None - - -def test_required_sibling_present(): - entries = [_file("a001.tif"), _file("a002.tif"), _file("a003.tif")] - res = match("/data/seq", entries, IMAGE_SEQUENCE_PROFILE) - assert res["matched"] is True - assert len(res["matched_groups"]["numbered_images"]) == 3 - - -def test_required_sibling_absent(): - # Trigger matches (one numbered tiff) but min_count of 3 is not met. - entries = [_file("a001.tif"), _file("plain.tif")] - res = match("/data/seq", entries, IMAGE_SEQUENCE_PROFILE) - assert res["matched"] is False - - -def test_empty_trigger_extensions_matches_any_directory(): - entries = [_file("whatever.bin"), {"Name": "sub", "Path": "sub", "IsDir": True}] - res = match("/data/anything", entries, FILE_COLLECTION_PROFILE) - assert res["matched"] is True diff --git a/tests/test_profile_registry.py b/tests/test_profile_registry.py deleted file mode 100644 index 8709347f..00000000 --- a/tests/test_profile_registry.py +++ /dev/null @@ -1,55 +0,0 @@ -import pathlib - -from scidk.core.profile_registry import ProfileRegistry - -PROFILES_DIR = ( - pathlib.Path(__file__).resolve().parents[1] - / "scidk" - / "interpreters" - / "profiles" -) - - -def _loaded_registry(): - reg = ProfileRegistry() - reg.load(PROFILES_DIR) - return reg - - -def test_load_finds_all_profiles(): - reg = _loaded_registry() - for pid in ("file_collection", "tiff_collection", "csv_collection", "image_sequence"): - assert reg.get(pid) is not None, f"missing profile {pid}" - - -def test_get_returns_none_for_unknown(): - reg = _loaded_registry() - assert reg.get("does_not_exist") is None - - -def test_inheritance_depth(): - reg = _loaded_registry() - # file_collection has no parent -> depth 0 - assert reg.get("file_collection")["_depth"] == 0 - # tiff_collection inherits file_collection -> depth 1 - assert reg.get("tiff_collection")["_depth"] == 1 - # csv_collection inherits file_collection -> depth 1 - assert reg.get("csv_collection")["_depth"] == 1 - # image_sequence inherits tiff_collection -> depth 2 - assert reg.get("image_sequence")["_depth"] == 2 - - -def test_ordered_profiles_shallowest_first(): - reg = _loaded_registry() - depths = [p["_depth"] for p in reg.ordered_profiles()] - assert depths == sorted(depths), f"not shallowest-first: {depths}" - # base profile must come before any that inherit from it - order = [p["profile_id"] for p in reg.ordered_profiles()] - assert order.index("file_collection") < order.index("tiff_collection") - assert order.index("tiff_collection") < order.index("image_sequence") - - -def test_load_missing_dir_is_safe(tmp_path): - reg = ProfileRegistry() - reg.load(tmp_path / "nope") - assert reg.ordered_profiles() == [] diff --git a/tests/test_profile_yamls.py b/tests/test_profile_yamls.py deleted file mode 100644 index 9e54b26c..00000000 --- a/tests/test_profile_yamls.py +++ /dev/null @@ -1,75 +0,0 @@ -import pathlib -import re - -import pytest -import yaml - -PROFILES_DIR = pathlib.Path(__file__).resolve().parents[1] / "scidk" / "interpreters" / "profiles" - -PROFILE_FILES = [ - "file_collection.yaml", - "tiff_collection.yaml", - "csv_collection.yaml", - "image_sequence.yaml", - "scidk_dataset.yaml", -] - - -def _load(filename): - path = PROFILES_DIR / filename - assert path.exists(), f"Missing profile YAML: {path}" - with path.open() as f: - return yaml.safe_load(f) - - -@pytest.mark.parametrize("filename", PROFILE_FILES) -def test_profile_yaml_has_required_fields(filename): - data = _load(filename) - - assert isinstance(data, dict), f"{filename} did not parse to a mapping" - - # profile_id present and non-empty - assert data.get("profile_id"), f"{filename} missing profile_id" - - # graph.node_label present - graph = data.get("graph") - assert isinstance(graph, dict), f"{filename} missing graph section" - assert graph.get("node_label"), f"{filename} missing graph.node_label" - - # graph.properties present and a mapping - properties = graph.get("properties") - assert isinstance(properties, dict) and properties, ( - f"{filename} missing graph.properties" - ) - - -@pytest.mark.parametrize("filename", PROFILE_FILES) -def test_profile_yaml_enabled_is_bool(filename): - data = _load(filename) - assert "enabled" in data, f"{filename} missing enabled field" - assert isinstance(data["enabled"], bool), f"{filename} enabled must be a bool" - - -def test_specific_profiles_enabled_by_default(): - for filename in ("tiff_collection.yaml", "csv_collection.yaml", "image_sequence.yaml"): - assert _load(filename)["enabled"] is True, f"{filename} should be enabled by default" - - -def test_file_collection_is_abstract(): - data = _load("file_collection.yaml") - assert data.get("abstract") is True, "file_collection must be abstract" - - -def test_scidk_dataset_profile(): - data = _load("scidk_dataset.yaml") - assert data["profile_id"] == "scidk_dataset" - assert data["inherits"] == "file_collection" - # Disabled by default — user opts in via Settings UI. - assert data["enabled"] is False - assert data["graph"]["properties"]["type"] == "UserDefinedDataset" - assert data["graph"]["properties"]["profile"] == "scidk_dataset" - # Triggers on the SciDK descriptor filenames. - pattern = data["trigger"]["filename_pattern"] - assert re.search(pattern, ".scidk.yaml") - assert re.search(pattern, "dataset.scidk.yaml") - assert not re.search(pattern, "notes.yaml") diff --git a/tests/test_semantic_retrieval.py b/tests/test_semantic_retrieval.py deleted file mode 100644 index 27438fff..00000000 --- a/tests/test_semantic_retrieval.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -""" -Test semantic schema retrieval for Schema Intelligence Layer. -Verifies that relevant labels are retrieved for different query types. -""" -import pytest -import os -import sys - -pytestmark = pytest.mark.integration - -sys.path.insert(0, os.path.dirname(__file__)) - -from scidk.app import create_app -from scidk.services.schema_intelligence import get_relevant_schema_context -from scidk.services.chat_service import get_chat_service -from scidk.services.neo4j_client import get_neo4j_params -from neo4j import GraphDatabase - -def test_query(query_text: str, expected_labels: list, driver, sqlite_conn, ollama_url, database): - """Test a single query and print results.""" - print(f"\n{'='*80}") - print(f"Query: \"{query_text}\"") - print(f"Expected to retrieve: {', '.join(expected_labels)}") - print(f"{'='*80}") - - try: - result = get_relevant_schema_context( - user_query=query_text, - sqlite_conn=sqlite_conn, - neo4j_driver=driver, - ollama_url=ollama_url, - database=database, - top_k=5 - ) - - retrieved_labels = result.get('labels', []) - scores = result.get('scores', {}) - method = result.get('retrieval_method', 'unknown') - - print(f"\nRetrieval method: {method}") - print(f"\nRetrieved labels (top {len(retrieved_labels)}):") - for label in retrieved_labels: - score = scores.get(label, 0.0) - check = "✓" if label in expected_labels else "✗" - print(f" {check} {label:20s} (score: {score:.3f})") - - # Check if expected labels are in top results - matches = [label for label in expected_labels if label in retrieved_labels[:3]] - print(f"\nMatches in top 3: {len(matches)}/{len(expected_labels)}") - - if matches == expected_labels: - print("✓ TEST PASSED: All expected labels retrieved") - return True - else: - missing = [label for label in expected_labels if label not in retrieved_labels[:3]] - if missing: - print(f"✗ TEST FAILED: Missing expected labels in top 3: {', '.join(missing)}") - return False - - except Exception as e: - print(f"✗ ERROR: {e}") - import traceback - traceback.print_exc() - return False - - -def main(): - print("Initializing Flask app...") - app = create_app() - - with app.app_context(): - print("Connecting to Neo4j and SQLite...") - uri, user, pwd, database, auth_mode = get_neo4j_params(app) - - if not uri: - print("ERROR: Neo4j is not configured") - return 1 - - auth = None if (auth_mode or 'basic').lower() == 'none' else (user, pwd) - driver = GraphDatabase.driver(uri, auth=auth) - - db_path = app.config.get('SCIDK_SETTINGS_DB', 'scidk_settings.db') - chat_service = get_chat_service(db_path=db_path) - sqlite_conn = chat_service._get_conn() - - ollama_url = os.environ.get('SCIDK_CHAT_OLLAMA_ENDPOINT', 'http://localhost:11434') - - try: - print("\n" + "="*80) - print("SEMANTIC SCHEMA RETRIEVAL TEST SUITE") - print("="*80) - - results = [] - - # Test 1: File-related query - results.append(test_query( - query_text="What types of files are in the dataset?", - expected_labels=["File"], # Adjusted expectation - may not have Folder - driver=driver, - sqlite_conn=sqlite_conn, - ollama_url=ollama_url, - database=database or "neo4j" - )) - - # Test 2: Sample-related query - results.append(test_query( - query_text="What properties do Samples have?", - expected_labels=["Sample"], # Adjusted - may not have SampleType - driver=driver, - sqlite_conn=sqlite_conn, - ollama_url=ollama_url, - database=database or "neo4j" - )) - - # Test 3: Relationship query - results.append(test_query( - query_text="How are scans connected to samples?", - expected_labels=["Scan", "Sample"], - driver=driver, - sqlite_conn=sqlite_conn, - ollama_url=ollama_url, - database=database or "neo4j" - )) - - # Summary - print("\n" + "="*80) - print("TEST SUMMARY") - print("="*80) - passed = sum(results) - total = len(results) - print(f"Passed: {passed}/{total}") - - if passed == total: - print("\n✓ ALL TESTS PASSED - Semantic retrieval is working correctly!") - return 0 - else: - print(f"\n✗ {total - passed} TEST(S) FAILED - Review results above") - return 1 - - finally: - sqlite_conn.close() - driver.close() - -if __name__ == '__main__': - sys.exit(main()) diff --git a/tests/test_streaming_demo.html b/tests/test_streaming_demo.html deleted file mode 100644 index 96ead1a6..00000000 --- a/tests/test_streaming_demo.html +++ /dev/null @@ -1,288 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>ReAct Streaming Test</title> - <style> - body { - font-family: system-ui, -apple-system, sans-serif; - max-width: 1200px; - margin: 40px auto; - padding: 20px; - background: #f5f5f5; - } - h1 { - color: #333; - } - .controls { - background: white; - padding: 20px; - border-radius: 8px; - margin-bottom: 20px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - } - input[type="text"] { - width: 100%; - padding: 12px; - font-size: 16px; - border: 1px solid #ddd; - border-radius: 4px; - margin-bottom: 12px; - } - button { - padding: 12px 24px; - font-size: 16px; - background: #4a90e2; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - } - button:hover { - background: #357abd; - } - button:disabled { - background: #ccc; - cursor: not-allowed; - } - .stream-container { - background: white; - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - } - .step { - margin: 16px 0; - padding: 12px; - background: #f8f9fa; - border-left: 4px solid #4a90e2; - border-radius: 4px; - } - .step-header { - font-weight: bold; - margin-bottom: 8px; - color: #333; - } - .step-content { - color: #555; - white-space: pre-wrap; - } - .step code { - display: block; - background: white; - padding: 8px; - margin: 8px 0; - border-radius: 4px; - overflow-x: auto; - font-family: 'Monaco', 'Menlo', monospace; - font-size: 14px; - } - .final-answer { - margin-top: 24px; - padding: 20px; - background: #e8f5e9; - border-left: 4px solid #4caf50; - border-radius: 4px; - } - .metadata { - margin-top: 16px; - padding: 12px; - background: #fff3e0; - border-radius: 4px; - font-size: 14px; - color: #666; - } - .error { - padding: 12px; - background: #ffebee; - border-left: 4px solid #f44336; - border-radius: 4px; - color: #c62828; - } - .status { - margin: 12px 0; - padding: 8px; - background: #e3f2fd; - border-radius: 4px; - color: #1976d2; - font-size: 14px; - } - </style> -</head> -<body> - <h1>🧪 ReAct Streaming Test</h1> - - <div class="controls"> - <input type="text" id="queryInput" placeholder="Enter a multi-step query (e.g., 'Find all folders and count files in them')" value="How many files are in the database?"> - <button id="sendBtn" onclick="testStreaming()">Send Query (with Streaming)</button> - <button id="clearBtn" onclick="clearResults()">Clear Results</button> - </div> - - <div class="stream-container"> - <div id="status"></div> - <div id="results"></div> - </div> - - <script> - const BASE_URL = 'http://localhost:5000'; - let isStreaming = false; - - function showStatus(message, isError = false) { - const statusEl = document.getElementById('status'); - statusEl.className = isError ? 'error' : 'status'; - statusEl.textContent = message; - } - - function clearResults() { - document.getElementById('results').innerHTML = ''; - document.getElementById('status').innerHTML = ''; - } - - async function testStreaming() { - if (isStreaming) return; - - const query = document.getElementById('queryInput').value.trim(); - if (!query) { - alert('Please enter a query'); - return; - } - - isStreaming = true; - document.getElementById('sendBtn').disabled = true; - clearResults(); - - const resultsEl = document.getElementById('results'); - showStatus('🔄 Connecting to stream...'); - - try { - const response = await fetch(`${BASE_URL}/api/chat/graphrag/stream`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message: query, - session_id: 'streaming-test-demo', - verbose: true - }) - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: response.statusText })); - throw new Error(errorData.error || `HTTP ${response.status}`); - } - - showStatus('✅ Stream connected! Waiting for ReAct steps...'); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let stepCount = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - // Process SSE messages - const lines = buffer.split('\n\n'); - buffer = lines.pop(); // Keep incomplete message - - for (const line of lines) { - if (!line.trim() || !line.startsWith('data: ')) continue; - - const jsonStr = line.substring(6); - try { - const event = JSON.parse(jsonStr); - - if (event.type === 'step') { - stepCount++; - const stepEl = document.createElement('div'); - stepEl.className = 'step'; - - let content = ''; - if (event.action === 'THINK') { - content = ` - <div class="step-header">💭 Step ${event.step_num}: Thinking</div> - <div class="step-content">${escapeHtml(event.content)}</div> - `; - } else if (event.action === 'QUERY') { - content = ` - <div class="step-header">🔍 Step ${event.step_num}: Querying Database</div> - <code>${escapeHtml(event.content)}</code> - ${event.observation ? `<div class="step-content">→ ${escapeHtml(event.observation)}</div>` : ''} - `; - } else if (event.action === 'FINAL_ANSWER') { - content = ` - <div class="step-header">✅ Step ${event.step_num}: Generating Final Answer</div> - `; - } - - stepEl.innerHTML = content; - resultsEl.appendChild(stepEl); - window.scrollTo(0, document.body.scrollHeight); - - showStatus(`🔄 Processing step ${stepCount}...`); - - } else if (event.type === 'done') { - // Final answer - const answerEl = document.createElement('div'); - answerEl.className = 'final-answer'; - answerEl.innerHTML = ` - <div class="step-header">📦 Final Answer</div> - <div class="step-content">${escapeHtml(event.reply)}</div> - `; - resultsEl.appendChild(answerEl); - - // Metadata - const meta = event.metadata || {}; - const metaEl = document.createElement('div'); - metaEl.className = 'metadata'; - metaEl.innerHTML = ` - <strong>Engine:</strong> ${event.engine || 'unknown'} | - <strong>Steps:</strong> ${meta.steps_taken || 0} | - <strong>Queries:</strong> ${meta.queries_executed || 0} | - <strong>Time:</strong> ${meta.execution_time_ms || 0}ms - `; - resultsEl.appendChild(metaEl); - - showStatus(`✅ Streaming completed! Received ${stepCount} steps.`); - - } else if (event.type === 'error') { - const errorEl = document.createElement('div'); - errorEl.className = 'error'; - errorEl.textContent = `❌ Error: ${event.error}`; - resultsEl.appendChild(errorEl); - showStatus('❌ Stream encountered an error', true); - } - - } catch (parseErr) { - console.error('Failed to parse event:', parseErr, jsonStr); - } - } - } - - } catch (err) { - showStatus(`❌ Error: ${err.message}`, true); - console.error('Streaming error:', err); - } finally { - isStreaming = false; - document.getElementById('sendBtn').disabled = false; - } - } - - function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - // Allow Enter key to submit - document.getElementById('queryInput').addEventListener('keypress', (e) => { - if (e.key === 'Enter' && !isStreaming) { - testStreaming(); - } - }); - </script> -</body> -</html> diff --git a/tests/test_streaming_react.py b/tests/test_streaming_react.py deleted file mode 100755 index b44287cb..00000000 --- a/tests/test_streaming_react.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -""" -Test the new streaming ReAct endpoint. - -Sends a multi-step query that will trigger ReAct reasoning and displays -live step updates as they stream from the server. -""" -import pytest -import requests -import json -import time - -pytestmark = pytest.mark.integration - -BASE_URL = "http://localhost:5000" - -def test_streaming_react(): - """Test streaming endpoint with a ReAct query.""" - print("\n" + "="*70) - print("Testing Streaming ReAct Endpoint") - print("="*70) - - # This query should trigger ReAct (multi-step reasoning required) - message = "Find all folders named 'data' and tell me how many files are inside them" - - print(f"\nQuery: {message}") - print(f"\nSending POST request to /api/chat/graphrag/stream...") - print("Expecting SSE stream with live step updates...\n") - - payload = { - "message": message, - "session_id": "test-streaming-react", - "verbose": True - } - - start_time = time.time() - - # Use stream=True to get response as it arrives - with requests.post( - f"{BASE_URL}/api/chat/graphrag/stream", - json=payload, - stream=True, - timeout=180 - ) as response: - - if response.status_code != 200: - print(f"❌ Error: {response.status_code}") - print(response.text) - return - - print("✅ Stream connected! Reading events...\n") - print("-"*70) - - step_count = 0 - - # Read SSE events line by line - for line in response.iter_lines(): - if not line: - continue - - line = line.decode('utf-8') - - # SSE format: "data: {...json...}" - if line.startswith('data: '): - json_str = line[6:] # Remove "data: " prefix - - try: - event = json.loads(json_str) - event_type = event.get('type', 'unknown') - - if event_type == 'step': - step_count += 1 - step_num = event.get('step_num', '?') - action = event.get('action', 'UNKNOWN') - content = event.get('content', '') - observation = event.get('observation', '') - - # Display step - if action == 'THINK': - print(f"\n💭 Step {step_num}: THINKING") - print(f" {content[:200]}{'...' if len(content) > 200 else ''}") - - elif action == 'QUERY': - print(f"\n🔍 Step {step_num}: QUERYING") - print(f" Query: {content[:150]}{'...' if len(content) > 150 else ''}") - if observation: - print(f" Result: {observation[:150]}{'...' if len(observation) > 150 else ''}") - - elif action == 'FINAL_ANSWER': - print(f"\n✅ Step {step_num}: FINAL ANSWER") - - elif event_type == 'done': - elapsed = time.time() - start_time - print("\n" + "-"*70) - print(f"\n📦 FINAL RESPONSE:") - print(f" {event.get('reply', 'No reply')}") - - metadata = event.get('metadata', {}) - print(f"\n📊 Metadata:") - print(f" Engine: {event.get('engine', 'unknown')}") - print(f" Steps: {metadata.get('steps_taken', 0)}") - print(f" Queries: {metadata.get('queries_executed', 0)}") - print(f" Server Time: {metadata.get('execution_time_ms', 0)}ms") - print(f" Total Elapsed: {elapsed:.2f}s") - print(f" Steps Streamed: {step_count}") - - elif event_type == 'error': - print(f"\n❌ ERROR: {event.get('error', 'Unknown error')}") - - elif event_type == 'info': - print(f"\nℹ️ {event.get('message', '')}") - - except json.JSONDecodeError as e: - print(f"⚠️ Failed to parse event: {e}") - print(f" Raw: {json_str[:100]}") - - print("\n" + "="*70) - print("✅ Test completed successfully!") - print("="*70 + "\n") - - -if __name__ == "__main__": - try: - test_streaming_react() - except KeyboardInterrupt: - print("\n\n⚠️ Test interrupted by user") - except Exception as e: - print(f"\n\n❌ Test failed: {e}") - import traceback - traceback.print_exc() diff --git a/tools/SCIDK_SCANNER_SPEC.md b/tools/SCIDK_SCANNER_SPEC.md deleted file mode 100644 index 6b03c565..00000000 --- a/tools/SCIDK_SCANNER_SPEC.md +++ /dev/null @@ -1,308 +0,0 @@ -# SciDK Filesystem Scanner — Integration Spec -**File:** `scidk_scanner.py` -**Branch target:** `production-mvp` -**Status:** Standalone tool, ready for repo integration - ---- - -## Purpose - -`scidk_scanner.py` is a standalone Python script that walks a filesystem volume, -classifies every file it finds, and writes results into a SQLite database that is -schema-identical to SciDK's internal `files.db`. - -It is designed as a field tool for exploring unknown research volumes before or -independent of a full SciDK deployment. Because it writes the same schema, -any scan database can be read directly by SciDK without conversion. - ---- - -## What It Does - -1. **Walks** a target directory tree using `os.walk` -2. **Classifies** each file by extension against SciDK's known interpreter registry -3. **Samples magic bytes** (first 256 bytes) to identify files with missing or - misleading extensions — covers HDF5, NetCDF, DICOM, TIFF, BAM, VCF, FCS, - FASTQ, FITS, PDF, ZIP-based, and ~20 other formats -4. **Detects directory patterns** for instrument/pipeline output structures: - 10x Genomics MTX triplets, MaxQuant output, Bruker MRI, OME-TIFF, DICOM dirs, - BIDS datasets, TCGA exports -5. **Hashes files** (blake3 if available, blake2b otherwise) up to a configurable - size limit -6. **Persists everything** to SQLite with SciDK-compatible schema -7. **Prints a gap report** on completion — ranked by extension frequency, - with coverage percentage and magic-byte identifications for unknown files - ---- - -## Schema Compatibility - -The tool writes to these tables, using the **exact column names and types** -defined in `scidk/core/path_index_sqlite.py` and `scidk/core/migrations.py`: - -| Table | Source | Notes | -|---|---|---| -| `scans` | `migrations.py v2` | One row per scan run | -| `files` | `path_index_sqlite.py` | Per-file index, primary output | -| `scan_items` | `migrations.py v2` | Per-scan snapshot (parallel to files) | -| `scan_progress` | `migrations.py v2` | Progress metrics per scan | -| `file_history` | `path_index_sqlite.py` | Change tracking between scans | - -Two interpretation columns (`interpreted_as`, `interpretation_json`) are added -to `files` via the same migration-safe `ALTER TABLE` pattern already used in -`path_index_sqlite.py`. They will not break an existing SciDK database. - ---- - -## Integration Points - -### Option A — Zero-change import (recommended for MVP) - -The scanner writes to `scidk_scan.db` by default. To import into a running -SciDK instance: - -```bash -# On the research server -python3 scidk_scanner.py /data/lab --db scan_$(date +%Y%m%d).db - -# Copy to SciDK host -scp scan_20260310.db ki-ed3g:~/.scidk/db/ - -# SciDK reads it by setting SCIDK_DB_PATH or via the import endpoint -``` - -No code changes needed. SciDK already knows how to open a `files.db` at any path. - -### Option B — Add scanner as a SciDK route (suggested) - -**Suggested location:** `scidk/core/filesystem_scanner.py` - -Extract the core `walk_path()` function and expose it through: - -``` -POST /api/scanner/scan - body: { path, options } - → triggers walk_path() as background task - → writes to SciDK's primary files.db - → streams progress via scan_progress table - -GET /api/scanner/report/<scan_id> - → returns gap report as JSON - -GET /api/scanner/scans - → lists all scan_runs with status, root, file count - -POST /api/scanner/import - → accepts an external scidk_scan.db file - → merges into primary files.db (scan_id namespacing prevents collisions) -``` - -The background task pattern is already established in SciDK via -`background_tasks` table in `migrations.py`. The scanner should register a -task row and update it as the walk progresses. - -### Option C — Wire into existing FilesystemScanner class - -The existing `FilesystemScanner` class (in `scidk/core/filesystem_scanner.py`) -currently delegates to `ncdu`. The `walk_path()` function in this script can -replace or augment that — same output schema, with richer metadata. - -Suggested refactor: -```python -class FilesystemScanner: - def scan_directory(self, path, options): - # existing ncdu path remains as fallback - if self._ncdu_available(): - return self._scan_ncdu(path, options) - # new Python-native path - return walk_path(path, scan_id, self.conn, **options) -``` - ---- - -## Known Interpreter Registry - -The `KNOWN_INTERPRETERS` dict at the top of the script is the canonical -source of truth for coverage. It must be kept in sync with -`scidk/interpreters/` as new interpreters are registered. - -**Suggested integration:** Import from a shared location rather than -duplicating. Options: - -```python -# In scidk_scanner.py (standalone mode) -try: - from scidk.core.registry import get_extension_map - KNOWN_INTERPRETERS = get_extension_map() -except ImportError: - KNOWN_INTERPRETERS = _BUILTIN_FALLBACK # hardcoded dict for standalone use -``` - -This keeps the tool standalone-capable while staying in sync when running -inside SciDK. - ---- - -## Gap Report Query - -The gap report is also directly queryable from any SQLite client or -SciDK's chat interface: - -```sql --- Ranked gap extensions for a scan -SELECT file_extension, - COUNT(*) AS file_count, - SUM(size)/1e9 AS size_gb -FROM files -WHERE scan_id = '<scan_id>' - AND type = 'file' - AND interpreted_as IS NULL - AND file_extension IS NOT NULL -GROUP BY file_extension -ORDER BY file_count DESC; - --- Coverage summary -SELECT - COUNT(*) FILTER (WHERE interpreted_as IS NOT NULL) * 100.0 / COUNT(*) AS pct_covered, - COUNT(*) FILTER (WHERE interpreted_as IS NULL) AS gaps -FROM files -WHERE scan_id = '<scan_id>' AND type = 'file'; - --- Files identified by magic bytes but not extension -SELECT json_extract(interpretation_json, '$.magic_label') AS detected_format, - COUNT(*) AS n -FROM files -WHERE scan_id = '<scan_id>' - AND interpreted_as IS NULL - AND interpretation_json IS NOT NULL -GROUP BY detected_format ORDER BY n DESC; -``` - ---- - -## Suggested File Layout in Repo - -``` -scidk/ - core/ - filesystem_scanner.py ← existing (modify to call walk_path) - scanner_formats.py ← NEW: extract KNOWN_INTERPRETERS + MAGIC_SIGNATURES - shared between scanner and interpreter registry - tools/ - scidk_scanner.py ← this file, standalone entry point -tests/ - test_scanner.py ← NEW (see test plan below) -``` - ---- - -## Test Plan - -```python -# tests/test_scanner.py - -def test_schema_compatibility(): - """Scanner DB opens cleanly in SciDK's path_index_sqlite.connect()""" - -def test_gap_report_structure(): - """Gap report runs without error on a real scan result""" - -def test_magic_byte_detection(): - """Known-format files are identified by magic bytes regardless of extension""" - # rename sample.h5 → sample.dat, verify hdf5 is detected - -def test_directory_pattern_detection(): - """10x MTX triplet directory is flagged as 10x_genomics_mtx""" - -def test_scan_resume(): - """--resume flag continues an existing scan_id without creating a new one""" - -def test_known_interpreters_sync(): - """Every interpreter in scidk/interpreters/ has an entry in KNOWN_INTERPRETERS""" -``` - ---- - -## Dependencies - -**Stdlib only** for core functionality: -`os`, `sqlite3`, `pathlib`, `hashlib`, `mimetypes`, `json`, `uuid`, `time`, -`argparse`, `fnmatch` - -**Optional:** -- `blake3` — faster hashing (falls back to `blake2b` if absent) - -**No** pandas, numpy, or any scientific library required for the scanner itself. - ---- - -## Usage Examples - -```bash -# Basic scan -python3 scidk_scanner.py /data/lab1 - -# Fast scan (no hashing, no magic sampling on large files) -python3 scidk_scanner.py /data/lab1 --no-hash --magic-limit 50 - -# Scan with note, custom db location -python3 scidk_scanner.py /data/lab1 \ - --db ~/scans/lab1_2026-03-10.db \ - --note "Initial survey for Data Science Core onboarding" - -# Exclude scratch and temp dirs -python3 scidk_scanner.py /data/lab1 \ - --exclude '__pycache__' \ - --exclude '*.tmp' \ - --exclude '.git' - -# Re-open existing db and view gap report without rescanning -python3 scidk_scanner.py --db ~/scans/lab1.db --report - -# Report for a specific scan -python3 scidk_scanner.py --db ~/scans/lab1.db --report-id <scan_id> -``` - ---- - -## Output Example (terminal) - -``` -SciDK Filesystem Scanner - Root : /data/lab1 - Database: ./scidk_scan.db - Scan ID : a3f2b1c0-... - Hashing : files < 100 MB - - 142,831 files 4,219 dirs 2.31 GB 1,204 gaps (47s) - - Saved → ./scidk_scan.db - -══════════════════════════════════════════════════════════════ - SciDK Filesystem Scan Report - Root : /data/lab1 - Scan ID: a3f2b1c0-... -══════════════════════════════════════════════════════════════ - - Total : 142,831 files 4,219 dirs 2.31 GB - Coverage: 141,627 identified 1,204 gaps (99.2% covered) - - COVERED EXTENSIONS count size GB - ────────────────────────────────────────────────────────────── - .fastq.gz fastq_interpreter 42,310 1.21 - .bam bam_interpreter 8,104 0.88 - ... - - GAP EXTENSIONS (no interpreter) count size GB - ────────────────────────────────────────────────────────────── - .fcs 892 0.14 - .raw 201 0.06 - ... - - DETECTED INSTRUMENT / PIPELINE DIRECTORIES - ────────────────────────────────────────────────────────────── - 10x_genomics_mtx 14 directories - maxquant_output 3 directories - bids_root 1 directory -══════════════════════════════════════════════════════════════ -``` diff --git a/tools/scidk_scanner.py b/tools/scidk_scanner.py deleted file mode 100644 index 60513c5c..00000000 --- a/tools/scidk_scanner.py +++ /dev/null @@ -1,815 +0,0 @@ -#!/usr/bin/env python3 -""" -scidk_scanner.py — Standalone filesystem scanner for SciDK -============================================================ -Walks a target directory and writes file metadata into a SQLite database -that is schema-compatible with SciDK's path_index_sqlite / migrations schema. - -The same .db file can be: - - Opened directly by SciDK if run on the same host (SCIDK_DB_PATH) - - Copied (scp) to the SciDK host and read without any conversion - - Queried standalone with any SQLite client - -Usage: - python3 scidk_scanner.py /path/to/scan [options] - -Options: - --db PATH SQLite output path (default: ./scidk_scan.db) - --note TEXT Human note stored in scans.extra_json - --no-hash Skip content hashing (faster, no integrity data) - --hash-limit MB Only hash files smaller than this (default: 100) - --magic-limit MB Only sample magic bytes for files smaller than this (default: 500) - --depth INT Max directory depth (default: unlimited) - --exclude PATTERN Glob pattern to exclude (can repeat, e.g. --exclude '*.tmp') - --follow-symlinks Follow symbolic links (default: off) - --resume ID Resume a previous scan by scan_id - --report Print gap report after scan and exit - --quiet Suppress progress output - -Schema compatibility: - Writes to: scans, files, file_history, scan_items - Adds: interpreted_as, interpretation_json columns (migration-safe) - Compatible with: SciDK production-mvp branch, path_index_sqlite.py schema -""" - -import argparse -import fnmatch -import hashlib -import json -import mimetypes -import os -import sqlite3 -import sys -import time -import uuid -from datetime import datetime, timezone -from pathlib import Path -from typing import Dict, Iterator, List, Optional, Tuple - -# ───────────────────────────────────────────── -# Known interpreter coverage (SciDK built-ins) -# Update this list as new interpreters are added -# ───────────────────────────────────────────── -KNOWN_INTERPRETERS: Dict[str, str] = { - # extension (lowercase, with dot) → interpreter id - ".csv": "csv_interpreter", - ".tsv": "csv_interpreter", - ".xlsx": "xlsx_interpreter", - ".xls": "xlsx_interpreter", - ".json": "json_interpreter", - ".jsonl": "json_interpreter", - ".yaml": "yaml_interpreter", - ".yml": "yaml_interpreter", - ".ipynb": "ipynb_interpreter", - ".dcm": "dicom_interpreter", - ".dicom": "dicom_interpreter", - ".tif": "ome_tiff_interpreter", - ".tiff": "ome_tiff_interpreter", - ".h5": "hdf5_interpreter", - ".hdf5": "hdf5_interpreter", - ".nc": "netcdf_interpreter", - ".nc4": "netcdf_interpreter", - ".rdf": "rdf_interpreter", - ".ttl": "rdf_interpreter", - ".owl": "owl_interpreter", - ".py": "python_interpreter", - # Add entries here as new interpreters land in scidk/interpreters/ -} - -# ───────────────────────────────────────────── -# Magic byte signatures for format identification -# Used when extension is ambiguous or missing -# ───────────────────────────────────────────── -MAGIC_SIGNATURES: List[Tuple[bytes, str, str]] = [ - # (prefix_bytes, format_label, interpreter_hint) - (b"\x89HDF", "hdf5", "hdf5_interpreter"), - (b"CDF\x01", "netcdf3", "netcdf_interpreter"), - (b"CDF\x02", "netcdf3_64", "netcdf_interpreter"), - (b"\x89PNG", "png", None), - (b"\xff\xd8\xff", "jpeg", None), - (b"GIF8", "gif", None), - (b"II\x2a\x00", "tiff_le", "ome_tiff_interpreter"), - (b"MM\x00\x2a", "tiff_be", "ome_tiff_interpreter"), - (b"DICM", "dicom", "dicom_interpreter"), # offset 128 - (b"PK\x03\x04", "zip_based", None), # xlsx, docx, jar… - (b"%PDF", "pdf", None), - (b"{\n", "json_likely", "json_interpreter"), - (b"{\"", "json_likely", "json_interpreter"), - (b"[\n", "json_likely", "json_interpreter"), - (b"[{", "json_likely", "json_interpreter"), - (b"@HD\t", "sam", None), - (b"BAM\x01", "bam", None), - (b"##fileformat=VCF", "vcf", None), - (b"@SQUAWK", "fastq_likely",None), - (b"BZh", "bz2", None), - (b"\x1f\x8b", "gzip", None), - (b"FCS3.", "fcs", None), # flow cytometry - (b"FCS2.", "fcs", None), - (b"\x89\x48\x44\x46", "hdf5", "hdf5_interpreter"), - (b"SIMPLE =", "fits", None), # FITS astronomy/bio - (b"#\n# ", "r_data", None), -] - -# Directory structure patterns → instrument/pipeline recognition -DIRECTORY_PATTERNS: List[Tuple[List[str], str]] = [ - # (required_filenames_in_dir, pattern_label) - (["barcodes.tsv", "features.tsv", "matrix.mtx"], "10x_genomics_mtx"), - (["barcodes.tsv.gz", "features.tsv.gz", "matrix.mtx.gz"], "10x_genomics_mtx_gz"), - (["proteinGroups.txt", "peptides.txt"], "maxquant_output"), - (["summary.txt", "Parameters.txt"], "maxquant_run"), - (["acqp", "method", "fid"], "bruker_mri"), - (["acqp", "method", "ser"], "bruker_mri"), - (["2dseq"], "bruker_processed"), - (["OME", "metadata.xml"], "ome_tiff_dir"), - (["DICOMDIR"], "dicom_dir"), - (["subject", "ses-", "anat"], "bids_dataset"), # partial match - (["dataset_description.json", "participants.tsv"], "bids_root"), - (["Manifest.xml"], "tcga_manifest"), - (["clinical_data.txt", "mutations.txt"], "tcga_export"), -] - - -# ───────────────────────────────────────────────────────────────────────────── -# SQLite setup — schema-compatible with SciDK path_index_sqlite + migrations -# ───────────────────────────────────────────────────────────────────────────── - -def db_connect(db_path: str) -> sqlite3.Connection: - conn = sqlite3.connect(db_path) - conn.execute("PRAGMA journal_mode=WAL;") - conn.execute("PRAGMA synchronous=NORMAL;") - conn.execute("PRAGMA temp_store=MEMORY;") - conn.execute("PRAGMA cache_size=-80000;") - conn.row_factory = sqlite3.Row - return conn - - -def db_init(conn: sqlite3.Connection) -> None: - cur = conn.cursor() - - # scans — matches SciDK migrations.py v2 - cur.execute(""" - CREATE TABLE IF NOT EXISTS scans ( - id TEXT PRIMARY KEY, - root TEXT, - started REAL, - completed REAL, - status TEXT, - extra_json TEXT - ); - """) - - # files — matches SciDK path_index_sqlite.py (+ interpretation columns) - cur.execute(""" - CREATE TABLE IF NOT EXISTS files ( - path TEXT NOT NULL, - parent_path TEXT, - name TEXT NOT NULL, - depth INTEGER NOT NULL, - type TEXT NOT NULL, - size INTEGER NOT NULL, - modified_time REAL, - file_extension TEXT, - mime_type TEXT, - etag TEXT, - hash TEXT, - remote TEXT, - scan_id TEXT, - extra_json TEXT, - interpreted_as TEXT, - interpretation_json TEXT - ); - """) - cur.execute("CREATE INDEX IF NOT EXISTS idx_files_scan_ext ON files(scan_id, file_extension);") - cur.execute("CREATE INDEX IF NOT EXISTS idx_files_scan_type ON files(scan_id, type);") - cur.execute("CREATE INDEX IF NOT EXISTS idx_files_scan_parent ON files(scan_id, parent_path, name);") - cur.execute("CREATE INDEX IF NOT EXISTS idx_files_interp ON files(scan_id, interpreted_as);") - - # scan_items — per-scan snapshot, matches migrations.py v2 - cur.execute(""" - CREATE TABLE IF NOT EXISTS scan_items ( - scan_id TEXT NOT NULL, - path TEXT NOT NULL, - type TEXT, - size INTEGER, - modified_time REAL, - file_extension TEXT, - mime_type TEXT, - etag TEXT, - hash TEXT, - extra_json TEXT, - PRIMARY KEY (scan_id, path) - ); - """) - cur.execute("CREATE INDEX IF NOT EXISTS idx_scan_items_ext ON scan_items(scan_id, file_extension);") - cur.execute("CREATE INDEX IF NOT EXISTS idx_scan_items_type ON scan_items(scan_id, type);") - - # scan_progress — matches migrations.py v2 - cur.execute(""" - CREATE TABLE IF NOT EXISTS scan_progress ( - scan_id TEXT NOT NULL, - metric TEXT NOT NULL, - value REAL, - updated REAL, - PRIMARY KEY (scan_id, metric) - ); - """) - - # file_history — matches SciDK path_index_sqlite.py - cur.execute(""" - CREATE TABLE IF NOT EXISTS file_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - filesystem TEXT, - path TEXT NOT NULL, - size INTEGER, - modified_time REAL, - hash TEXT, - scan_id TEXT, - change_type TEXT, - previous_size INTEGER, - previous_modified_time REAL, - previous_path TEXT, - logical_key TEXT - ); - """) - cur.execute("CREATE INDEX IF NOT EXISTS idx_hist_path ON file_history(path);") - cur.execute("CREATE INDEX IF NOT EXISTS idx_hist_scan ON file_history(scan_id);") - - conn.commit() - - -# ───────────────────────────────────────────────────────────────────────────── -# Utility helpers -# ───────────────────────────────────────────────────────────────────────────── - -def _depth(path: str) -> int: - p = Path(path) - try: - return len(p.parts) - 1 - except Exception: - return 0 - - -def _compute_hash(file_path: str, limit_bytes: int) -> Optional[str]: - try: - if os.path.getsize(file_path) > limit_bytes: - return None - try: - import blake3 # type: ignore - h = blake3.blake3() - except ImportError: - h = hashlib.blake2b() - with open(file_path, "rb") as f: - while chunk := f.read(1024 * 1024): - h.update(chunk) - return h.hexdigest() - except Exception: - return None - - -def _sample_magic(file_path: str, limit_bytes: int) -> Tuple[Optional[str], Optional[str], str]: - """ - Returns (format_label, interpreter_hint, hex_prefix). - Reads first 256 bytes (and checks offset 128 for DICOM). - """ - try: - size = os.path.getsize(file_path) - if size > limit_bytes: - return None, None, "" - with open(file_path, "rb") as f: - header = f.read(256) - hex_prefix = header[:16].hex() - - # DICOM: magic at offset 128 - if len(header) >= 132 and header[128:132] == b"DICM": - return "dicom", "dicom_interpreter", hex_prefix - - for sig, label, hint in MAGIC_SIGNATURES: - if header[:len(sig)] == sig: - return label, hint, hex_prefix - - except Exception: - pass - return None, None, "" - - -def _detect_interpreter(ext: str, magic_label: Optional[str], - magic_hint: Optional[str]) -> Optional[str]: - """Return interpreter id or None (gap).""" - if ext and ext in KNOWN_INTERPRETERS: - return KNOWN_INTERPRETERS[ext] - if magic_hint: - return magic_hint - return None - - -def _detect_dir_pattern(children: List[str]) -> Optional[str]: - """Check if a directory's child names match any known instrument pattern.""" - child_set = set(children) - for required, label in DIRECTORY_PATTERNS: - # All required names must be present (case-insensitive) - lower_children = {c.lower() for c in child_set} - if all(r.lower() in lower_children for r in required): - return label - # Partial BIDS: check for prefix matches - if any(r.endswith("-") for r in required): - matches = all( - r.lower() in lower_children or - any(c.startswith(r.lower()) for c in lower_children) - for r in required - ) - if matches: - return label - return None - - -def _update_progress(conn: sqlite3.Connection, scan_id: str, - files: int, dirs: int, bytes_: int) -> None: - now = time.time() - conn.executemany( - "INSERT OR REPLACE INTO scan_progress(scan_id, metric, value, updated) VALUES(?,?,?,?)", - [ - (scan_id, "files_scanned", files, now), - (scan_id, "dirs_scanned", dirs, now), - (scan_id, "bytes_scanned", bytes_, now), - ], - ) - conn.commit() - - -# ───────────────────────────────────────────────────────────────────────────── -# Core scanner -# ───────────────────────────────────────────────────────────────────────────── - -def walk_path( - root: str, - scan_id: str, - conn: sqlite3.Connection, - *, - do_hash: bool = True, - hash_limit_bytes: int = 100 * 1024 * 1024, - magic_limit_bytes: int = 500 * 1024 * 1024, - max_depth: Optional[int] = None, - excludes: List[str] = [], - follow_symlinks: bool = False, - quiet: bool = False, -) -> Dict: - """Walk root, insert rows, return stats dict.""" - - mimetypes.init() - root_path = Path(root).resolve() - root_str = str(root_path) - - file_buf: List[Tuple] = [] - item_buf: List[Tuple] = [] - BATCH = 5000 - - stats = {"files": 0, "dirs": 0, "bytes": 0, - "gaps": 0, "identified": 0, "errors": 0} - last_print = time.time() - - def flush(force: bool = False) -> None: - if len(file_buf) >= BATCH or (force and file_buf): - conn.executemany( - """INSERT INTO files( - path, parent_path, name, depth, type, size, - modified_time, file_extension, mime_type, - etag, hash, remote, scan_id, extra_json, - interpreted_as, interpretation_json - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - file_buf, - ) - conn.executemany( - """INSERT OR IGNORE INTO scan_items( - scan_id, path, type, size, modified_time, - file_extension, mime_type, etag, hash, extra_json - ) VALUES (?,?,?,?,?,?,?,?,?,?)""", - item_buf, - ) - conn.commit() - file_buf.clear() - item_buf.clear() - - def print_progress() -> None: - nonlocal last_print - if quiet: - return - now = time.time() - if now - last_print >= 2.0: - print( - f"\r {stats['files']:>8,} files " - f"{stats['dirs']:>6,} dirs " - f"{stats['bytes'] / (1024**3):.2f} GB " - f"{stats['gaps']:>5} gaps", - end="", flush=True, - ) - last_print = now - - for dirpath, dirnames, filenames in os.walk( - root_str, followlinks=follow_symlinks, topdown=True - ): - current_depth = _depth(dirpath) - _depth(root_str) - - # Depth pruning - if max_depth is not None and current_depth >= max_depth: - dirnames.clear() - - # Exclusion pruning on directories - dirnames[:] = [ - d for d in dirnames - if not any(fnmatch.fnmatch(d, pat) for pat in excludes) - ] - - # Directory row - dir_path_str = str(Path(dirpath)) - dir_parent = str(Path(dirpath).parent) - dir_name = Path(dirpath).name or dir_path_str - dir_depth = _depth(dir_path_str) - - # Detect instrument directory patterns - all_children = dirnames + filenames - dir_pattern = _detect_dir_pattern(all_children) - dir_extra = json.dumps({"pattern": dir_pattern}) if dir_pattern else None - - try: - st = os.stat(dirpath) - dir_size = 0 - dir_mtime = st.st_mtime - except OSError: - dir_size = 0 - dir_mtime = None - - file_buf.append(( - dir_path_str, dir_parent, dir_name, dir_depth, - "folder", dir_size, dir_mtime, - None, None, None, None, None, scan_id, dir_extra, - None, None, - )) - item_buf.append(( - scan_id, dir_path_str, "folder", dir_size, - dir_mtime, None, None, None, None, dir_extra, - )) - stats["dirs"] += 1 - flush() - print_progress() - - # File rows - for fname in filenames: - # Exclusion check - if any(fnmatch.fnmatch(fname, pat) for pat in excludes): - continue - - fpath = os.path.join(dirpath, fname) - fpath_str = str(fpath) - - try: - st = os.stat(fpath, follow_symlinks=follow_symlinks) - except OSError as e: - stats["errors"] += 1 - if not quiet: - print(f"\n [warn] cannot stat {fpath}: {e}", file=sys.stderr) - continue - - fsize = st.st_size - fmtime = st.st_mtime - fext = Path(fname).suffix.lower() - fmime, _ = mimetypes.guess_type(fname) - fdepth = _depth(fpath_str) - fparent = dir_path_str - - # Magic byte sampling - magic_label, magic_hint, hex_prefix = _sample_magic( - fpath_str, magic_limit_bytes - ) - - # Interpreter matching - interp = _detect_interpreter(fext, magic_label, magic_hint) - if interp: - stats["identified"] += 1 - else: - stats["gaps"] += 1 - - # Interpretation metadata - interp_json = None - if magic_label or magic_hint or hex_prefix: - interp_json = json.dumps({ - "magic_label": magic_label, - "magic_hint": magic_hint, - "hex_prefix": hex_prefix or None, - }) - - # Content hash - fhash = None - if do_hash: - fhash = _compute_hash(fpath_str, hash_limit_bytes) - - file_buf.append(( - fpath_str, fparent, fname, fdepth, - "file", fsize, fmtime, - fext, fmime, None, fhash, None, scan_id, - None, # extra_json (file) - interp, interp_json, - )) - item_buf.append(( - scan_id, fpath_str, "file", fsize, fmtime, - fext, fmime, None, fhash, None, - )) - - stats["files"] += 1 - stats["bytes"] += fsize - flush() - print_progress() - - flush(force=True) - _update_progress(conn, scan_id, - stats["files"], stats["dirs"], stats["bytes"]) - return stats - - -# ───────────────────────────────────────────────────────────────────────────── -# Gap report -# ───────────────────────────────────────────────────────────────────────────── - -def print_gap_report(conn: sqlite3.Connection, scan_id: str) -> None: - cur = conn.cursor() - - # Pull scan root for context - cur.execute("SELECT root, started, completed FROM scans WHERE id=?", (scan_id,)) - row = cur.fetchone() - root = row["root"] if row else "?" - started = row["started"] if row else 0 - ended = row["completed"] if row else time.time() - - print(f"\n{'═'*62}") - print(f" SciDK Filesystem Scan Report") - print(f" Root : {root}") - print(f" Scan ID: {scan_id}") - print(f" Time : {datetime.fromtimestamp(started, tz=timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}" - f" ({int(ended - started)}s)") - print(f"{'═'*62}") - - # Totals - cur.execute(""" - SELECT - COUNT(*) FILTER (WHERE type='file') as n_files, - COUNT(*) FILTER (WHERE type='folder') as n_dirs, - SUM(size) FILTER (WHERE type='file') as total_bytes - FROM files WHERE scan_id=? - """, (scan_id,)) - t = cur.fetchone() - n_files = t["n_files"] or 0 - n_dirs = t["n_dirs"] or 0 - tb = (t["total_bytes"] or 0) / (1024**3) - print(f"\n Total : {n_files:,} files {n_dirs:,} dirs {tb:.2f} GB") - - # Coverage summary - cur.execute(""" - SELECT - COUNT(*) FILTER (WHERE interpreted_as IS NOT NULL) as covered, - COUNT(*) FILTER (WHERE interpreted_as IS NULL) as gaps - FROM files WHERE scan_id=? AND type='file' - """, (scan_id,)) - c = cur.fetchone() - covered = c["covered"] or 0 - gaps = c["gaps"] or 0 - pct = 100.0 * covered / n_files if n_files else 0 - print(f" Coverage: {covered:,} identified {gaps:,} gaps ({pct:.1f}% covered)") - - # ── Top covered extensions ────────────────────────────────────────────── - print(f"\n {'COVERED EXTENSIONS':42s} {'count':>7} {'size GB':>8}") - print(f" {'-'*62}") - cur.execute(""" - SELECT file_extension, interpreted_as, - COUNT(*) as n, SUM(size) as b - FROM files - WHERE scan_id=? AND type='file' AND interpreted_as IS NOT NULL - GROUP BY file_extension - ORDER BY n DESC LIMIT 20 - """, (scan_id,)) - for r in cur.fetchall(): - ext = r["file_extension"] or "(none)" - size = (r["b"] or 0) / (1024**3) - print(f" {ext:<20} {r['interpreted_as']:<22} {r['n']:>7,} {size:>8.2f}") - - # ── Gap extensions ────────────────────────────────────────────────────── - print(f"\n {'GAP EXTENSIONS (no interpreter)':42s} {'count':>7} {'size GB':>8}") - print(f" {'-'*62}") - cur.execute(""" - SELECT file_extension, - COUNT(*) as n, SUM(size) as b - FROM files - WHERE scan_id=? AND type='file' AND interpreted_as IS NULL - AND file_extension IS NOT NULL AND file_extension != '' - GROUP BY file_extension - ORDER BY n DESC LIMIT 30 - """, (scan_id,)) - for r in cur.fetchall(): - size = (r["b"] or 0) / (1024**3) - print(f" {r['file_extension']:<42} {r['n']:>7,} {size:>8.2f}") - - # ── Extension-less / unknown ──────────────────────────────────────────── - cur.execute(""" - SELECT COUNT(*) as n, SUM(size) as b - FROM files - WHERE scan_id=? AND type='file' AND interpreted_as IS NULL - AND (file_extension IS NULL OR file_extension = '') - """, (scan_id,)) - u = cur.fetchone() - if u["n"]: - size = (u["b"] or 0) / (1024**3) - print(f" {'(no extension)':42} {u['n']:>7,} {size:>8.2f}") - - # ── Detected directory patterns ───────────────────────────────────────── - print(f"\n DETECTED INSTRUMENT / PIPELINE DIRECTORIES") - print(f" {'-'*62}") - cur.execute(""" - SELECT json_extract(extra_json, '$.pattern') as pattern, - COUNT(*) as n, name - FROM files - WHERE scan_id=? AND type='folder' - AND extra_json IS NOT NULL - GROUP BY pattern ORDER BY n DESC - """, (scan_id,)) - rows = cur.fetchall() - if rows: - for r in rows: - if r["pattern"]: - print(f" {r['pattern']:<40} {r['n']:>4} director{'y' if r['n']==1 else 'ies'}") - else: - print(" (none detected)") - - # ── Magic byte findings for gaps ──────────────────────────────────────── - print(f"\n MAGIC BYTE IDENTIFICATIONS (unmatched extension)") - print(f" {'-'*62}") - cur.execute(""" - SELECT json_extract(interpretation_json, '$.magic_label') as ml, - COUNT(*) as n - FROM files - WHERE scan_id=? AND type='file' - AND interpreted_as IS NULL - AND interpretation_json IS NOT NULL - AND json_extract(interpretation_json, '$.magic_label') IS NOT NULL - GROUP BY ml ORDER BY n DESC LIMIT 15 - """, (scan_id,)) - rows = cur.fetchall() - if rows: - for r in rows: - print(f" {r['ml']:<42} {r['n']:>7,}") - else: - print(" (none — increase --magic-limit to sample more files)") - - print(f"\n{'═'*62}\n") - - -# ───────────────────────────────────────────────────────────────────────────── -# CLI entry point -# ───────────────────────────────────────────────────────────────────────────── - -def main() -> None: - parser = argparse.ArgumentParser( - description="SciDK-compatible standalone filesystem scanner", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__, - ) - parser.add_argument("root", nargs="?", - help="Directory to scan") - parser.add_argument("--db", default="./scidk_scan.db", - help="SQLite output path (default: ./scidk_scan.db)") - parser.add_argument("--note", default="", - help="Human note stored with the scan") - parser.add_argument("--no-hash", action="store_true", - help="Skip content hashing") - parser.add_argument("--hash-limit", type=int, default=100, - metavar="MB", - help="Only hash files smaller than this MB (default 100)") - parser.add_argument("--magic-limit", type=int, default=500, - metavar="MB", - help="Only sample magic bytes for files < this MB (default 500)") - parser.add_argument("--depth", type=int, default=None, - help="Max directory depth (default: unlimited)") - parser.add_argument("--exclude", action="append", default=[], - metavar="PATTERN", - help="Glob pattern to exclude (repeatable)") - parser.add_argument("--follow-symlinks", action="store_true") - parser.add_argument("--resume", - metavar="SCAN_ID", - help="Resume an existing scan by ID (skips re-walking)") - parser.add_argument("--report", action="store_true", - help="Print gap report for most recent scan and exit") - parser.add_argument("--report-id", - metavar="SCAN_ID", - help="Print gap report for a specific scan ID and exit") - parser.add_argument("--quiet", action="store_true") - - args = parser.parse_args() - - # ── Report-only mode ──────────────────────────────────────────────────── - if args.report or args.report_id: - conn = db_connect(args.db) - db_init(conn) - if args.report_id: - scan_id = args.report_id - else: - cur = conn.cursor() - cur.execute("SELECT id FROM scans ORDER BY started DESC LIMIT 1") - row = cur.fetchone() - if not row: - print("No scans found in database.", file=sys.stderr) - sys.exit(1) - scan_id = row["id"] - print_gap_report(conn, scan_id) - conn.close() - return - - if not args.root: - parser.print_help() - sys.exit(1) - - root = str(Path(args.root).resolve()) - if not os.path.isdir(root): - print(f"Error: '{root}' is not a directory.", file=sys.stderr) - sys.exit(1) - - # ── Open / init DB ────────────────────────────────────────────────────── - conn = db_connect(args.db) - db_init(conn) - - # ── Create or resume scan ─────────────────────────────────────────────── - if args.resume: - scan_id = args.resume - cur = conn.cursor() - cur.execute("SELECT id FROM scans WHERE id=?", (scan_id,)) - if not cur.fetchone(): - print(f"Scan ID '{scan_id}' not found in {args.db}", file=sys.stderr) - sys.exit(1) - if not args.quiet: - print(f"Resuming scan: {scan_id}") - else: - scan_id = str(uuid.uuid4()) - extra = json.dumps({ - "note": args.note, - "tool": "scidk_scanner.py", - "excludes": args.exclude, - }) - conn.execute( - "INSERT INTO scans(id, root, started, status, extra_json) VALUES(?,?,?,?,?)", - (scan_id, root, time.time(), "running", extra), - ) - conn.commit() - - if not args.quiet: - print(f"\nSciDK Filesystem Scanner") - print(f" Root : {root}") - print(f" Database: {args.db}") - print(f" Scan ID : {scan_id}") - print(f" Hashing : {'off' if args.no_hash else f'files < {args.hash_limit} MB'}") - print(f" Magic : files < {args.magic_limit} MB") - if args.exclude: - print(f" Excludes: {', '.join(args.exclude)}") - print() - - # ── Walk ──────────────────────────────────────────────────────────────── - t0 = time.time() - try: - stats = walk_path( - root, scan_id, conn, - do_hash=not args.no_hash, - hash_limit_bytes=args.hash_limit * 1024 * 1024, - magic_limit_bytes=args.magic_limit * 1024 * 1024, - max_depth=args.depth, - excludes=args.exclude, - follow_symlinks=args.follow_symlinks, - quiet=args.quiet, - ) - except KeyboardInterrupt: - if not args.quiet: - print("\n\n [interrupted — partial results saved]") - conn.execute( - "UPDATE scans SET status=?, completed=? WHERE id=?", - ("interrupted", time.time(), scan_id), - ) - conn.commit() - conn.close() - return - - elapsed = time.time() - t0 - conn.execute( - "UPDATE scans SET status=?, completed=? WHERE id=?", - ("complete", time.time(), scan_id), - ) - conn.commit() - - if not args.quiet: - gb = stats["bytes"] / (1024**3) - print(f"\r {stats['files']:>8,} files " - f"{stats['dirs']:>6,} dirs " - f"{gb:.2f} GB " - f"{stats['gaps']:>5} gaps " - f"({elapsed:.0f}s) ") - print(f"\n Saved → {args.db}") - - print_gap_report(conn, scan_id) - conn.close() - - -if __name__ == "__main__": - main() diff --git a/tools/scidk_scanner_opt.py b/tools/scidk_scanner_opt.py deleted file mode 100644 index babdc8be..00000000 --- a/tools/scidk_scanner_opt.py +++ /dev/null @@ -1,878 +0,0 @@ -#!/usr/bin/env python3 -""" -scidk_scanner.py — Standalone filesystem scanner for SciDK -============================================================ -Walks a target directory and writes file metadata into a SQLite database -that is schema-compatible with SciDK's path_index_sqlite / migrations schema. - -The same .db file can be: - - Opened directly by SciDK if run on the same host (SCIDK_DB_PATH) - - Copied (scp) to the SciDK host and read without any conversion - - Queried standalone with any SQLite client - -Usage: - python3 scidk_scanner.py /path/to/scan [options] - -Options: - --db PATH SQLite output path (default: ./scidk_scan.db) - --note TEXT Human note stored in scans.extra_json - --workers N Parallel I/O workers (default: 1) - Try 8-32 on network mounts with magic bytes on - Try 4-8 on local SSD - --no-hash Skip content hashing (faster, no integrity data) - --hash-limit MB Only hash files smaller than this (default: 100) - --magic-limit MB Sample magic bytes for files < this MB (default: 0 = OFF) - 0 = stat-only, ncdu-speed - 500 = rich format detection, slower on network mounts - --depth INT Max directory depth (default: unlimited) - --exclude PATTERN Glob pattern to exclude (repeatable) - --follow-symlinks Follow symbolic links (default: off) - --resume ID Resume a previous scan by scan_id - --report Print gap report for most recent scan and exit - --report-id ID Print gap report for a specific scan ID and exit - --quiet Suppress progress output - -Worker tuning guide: - Local SSD : --workers 4-8 - Network NFS/CIFS : --workers 16-32 (latency-bound, more threads help a lot) - HPC Lustre/GPFS : --workers 8-16 - Magic bytes off : 1 worker is plenty (bottleneck is stat, not I/O wait) - Magic bytes on : parallelism makes the biggest difference here - -Schema compatibility: - Writes to: scans, files, scan_items, scan_progress, file_history - Adds: interpreted_as, interpretation_json (migration-safe ALTER TABLE) - Compatible with: SciDK production-mvp branch, path_index_sqlite.py schema -""" - -import argparse -import fnmatch -import hashlib -import json -import mimetypes -import os -import queue -import sqlite3 -import sys -import threading -import time -import uuid -from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import datetime, timezone -from pathlib import Path -from typing import Dict, List, Optional, Tuple - -# ───────────────────────────────────────────── -# Known interpreter coverage (SciDK built-ins) -# Update this list as new interpreters are added -# ───────────────────────────────────────────── -KNOWN_INTERPRETERS: Dict[str, str] = { - ".csv": "csv_interpreter", - ".tsv": "csv_interpreter", - ".xlsx": "xlsx_interpreter", - ".xls": "xlsx_interpreter", - ".json": "json_interpreter", - ".jsonl": "json_interpreter", - ".yaml": "yaml_interpreter", - ".yml": "yaml_interpreter", - ".ipynb": "ipynb_interpreter", - ".dcm": "dicom_interpreter", - ".dicom": "dicom_interpreter", - ".tif": "ome_tiff_interpreter", - ".tiff": "ome_tiff_interpreter", - ".h5": "hdf5_interpreter", - ".hdf5": "hdf5_interpreter", - ".nc": "netcdf_interpreter", - ".nc4": "netcdf_interpreter", - ".rdf": "rdf_interpreter", - ".ttl": "rdf_interpreter", - ".owl": "owl_interpreter", - ".py": "python_interpreter", -} - -MAGIC_SIGNATURES: List[Tuple[bytes, str, Optional[str]]] = [ - (b"\x89HDF", "hdf5", "hdf5_interpreter"), - (b"CDF\x01", "netcdf3", "netcdf_interpreter"), - (b"CDF\x02", "netcdf3_64", "netcdf_interpreter"), - (b"\x89PNG", "png", None), - (b"\xff\xd8\xff", "jpeg", None), - (b"GIF8", "gif", None), - (b"II\x2a\x00", "tiff_le", "ome_tiff_interpreter"), - (b"MM\x00\x2a", "tiff_be", "ome_tiff_interpreter"), - (b"DICM", "dicom", "dicom_interpreter"), # at offset 128 - (b"PK\x03\x04", "zip_based", None), - (b"%PDF", "pdf", None), - (b"{\n", "json_likely", "json_interpreter"), - (b"{\"", "json_likely", "json_interpreter"), - (b"[\n", "json_likely", "json_interpreter"), - (b"[{", "json_likely", "json_interpreter"), - (b"@HD\t", "sam", None), - (b"BAM\x01", "bam", None), - (b"##fileformat=VCF", "vcf", None), - (b"BZh", "bz2", None), - (b"\x1f\x8b", "gzip", None), - (b"FCS3.", "fcs", None), - (b"FCS2.", "fcs", None), - (b"SIMPLE =", "fits", None), - (b"#\n# ", "r_data", None), -] - -DIRECTORY_PATTERNS: List[Tuple[List[str], str]] = [ - (["barcodes.tsv", "features.tsv", "matrix.mtx"], "10x_genomics_mtx"), - (["barcodes.tsv.gz", "features.tsv.gz", "matrix.mtx.gz"], "10x_genomics_mtx_gz"), - (["proteinGroups.txt", "peptides.txt"], "maxquant_output"), - (["summary.txt", "Parameters.txt"], "maxquant_run"), - (["acqp", "method", "fid"], "bruker_mri"), - (["acqp", "method", "ser"], "bruker_mri"), - (["2dseq"], "bruker_processed"), - (["OME", "metadata.xml"], "ome_tiff_dir"), - (["DICOMDIR"], "dicom_dir"), - (["dataset_description.json", "participants.tsv"], "bids_root"), - (["Manifest.xml"], "tcga_manifest"), - (["clinical_data.txt", "mutations.txt"], "tcga_export"), -] - -# Writer queue sentinel -_STOP = object() - - -# ───────────────────────────────────────────────────────────────────────────── -# SQLite — schema-compatible with SciDK path_index_sqlite + migrations -# ───────────────────────────────────────────────────────────────────────────── - -def db_connect(db_path: str) -> sqlite3.Connection: - conn = sqlite3.connect(db_path, check_same_thread=False) - conn.execute("PRAGMA journal_mode=WAL;") - conn.execute("PRAGMA synchronous=NORMAL;") - conn.execute("PRAGMA temp_store=MEMORY;") - conn.execute("PRAGMA cache_size=-80000;") - conn.row_factory = sqlite3.Row - return conn - - -def db_init(conn: sqlite3.Connection) -> None: - cur = conn.cursor() - cur.execute(""" - CREATE TABLE IF NOT EXISTS scans ( - id TEXT PRIMARY KEY, - root TEXT, - started REAL, - completed REAL, - status TEXT, - extra_json TEXT - )""") - cur.execute(""" - CREATE TABLE IF NOT EXISTS files ( - path TEXT NOT NULL, - parent_path TEXT, - name TEXT NOT NULL, - depth INTEGER NOT NULL, - type TEXT NOT NULL, - size INTEGER NOT NULL, - modified_time REAL, - file_extension TEXT, - mime_type TEXT, - etag TEXT, - hash TEXT, - remote TEXT, - scan_id TEXT, - extra_json TEXT, - interpreted_as TEXT, - interpretation_json TEXT - )""") - for idx_sql in [ - "CREATE INDEX IF NOT EXISTS idx_files_scan_ext ON files(scan_id, file_extension)", - "CREATE INDEX IF NOT EXISTS idx_files_scan_type ON files(scan_id, type)", - "CREATE INDEX IF NOT EXISTS idx_files_scan_parent ON files(scan_id, parent_path, name)", - "CREATE INDEX IF NOT EXISTS idx_files_interp ON files(scan_id, interpreted_as)", - ]: - cur.execute(idx_sql) - cur.execute(""" - CREATE TABLE IF NOT EXISTS scan_items ( - scan_id TEXT NOT NULL, - path TEXT NOT NULL, - type TEXT, - size INTEGER, - modified_time REAL, - file_extension TEXT, - mime_type TEXT, - etag TEXT, - hash TEXT, - extra_json TEXT, - PRIMARY KEY (scan_id, path) - )""") - cur.execute("CREATE INDEX IF NOT EXISTS idx_scan_items_ext ON scan_items(scan_id, file_extension)") - cur.execute("CREATE INDEX IF NOT EXISTS idx_scan_items_type ON scan_items(scan_id, type)") - cur.execute(""" - CREATE TABLE IF NOT EXISTS scan_progress ( - scan_id TEXT NOT NULL, - metric TEXT NOT NULL, - value REAL, - updated REAL, - PRIMARY KEY (scan_id, metric) - )""") - cur.execute(""" - CREATE TABLE IF NOT EXISTS file_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - filesystem TEXT, - path TEXT NOT NULL, - size INTEGER, - modified_time REAL, - hash TEXT, - scan_id TEXT, - change_type TEXT, - previous_size INTEGER, - previous_modified_time REAL, - previous_path TEXT, - logical_key TEXT - )""") - cur.execute("CREATE INDEX IF NOT EXISTS idx_hist_path ON file_history(path)") - cur.execute("CREATE INDEX IF NOT EXISTS idx_hist_scan ON file_history(scan_id)") - conn.commit() - - -# ───────────────────────────────────────────────────────────────────────────── -# Pure helpers — called freely from any worker thread -# ───────────────────────────────────────────────────────────────────────────── - -def _depth(path: str) -> int: - try: - return len(Path(path).parts) - 1 - except Exception: - return 0 - - -def _compute_hash(file_path: str, limit_bytes: int) -> Optional[str]: - try: - if os.path.getsize(file_path) > limit_bytes: - return None - try: - import blake3 # type: ignore - h = blake3.blake3() - except ImportError: - h = hashlib.blake2b() - with open(file_path, "rb") as f: - while chunk := f.read(1024 * 1024): - h.update(chunk) - return h.hexdigest() - except Exception: - return None - - -def _sample_magic(file_path: str, limit_bytes: int - ) -> Tuple[Optional[str], Optional[str], str]: - """Returns (format_label, interpreter_hint, hex_prefix). Reads ≤256 bytes.""" - if limit_bytes == 0: - return None, None, "" - try: - if os.path.getsize(file_path) > limit_bytes: - return None, None, "" - with open(file_path, "rb") as f: - header = f.read(256) - hex_prefix = header[:16].hex() - # DICOM preamble lives at offset 128 - if len(header) >= 132 and header[128:132] == b"DICM": - return "dicom", "dicom_interpreter", hex_prefix - for sig, label, hint in MAGIC_SIGNATURES: - if header[: len(sig)] == sig: - return label, hint, hex_prefix - except Exception: - pass - return None, None, "" - - -def _detect_interpreter(ext: str, magic_label: Optional[str], - magic_hint: Optional[str]) -> Optional[str]: - if ext and ext in KNOWN_INTERPRETERS: - return KNOWN_INTERPRETERS[ext] - if magic_hint: - return magic_hint - return None - - -def _detect_dir_pattern(children: List[str]) -> Optional[str]: - lower = {c.lower() for c in children} - for required, label in DIRECTORY_PATTERNS: - if all(r.lower() in lower for r in required): - return label - if any(r.endswith("-") for r in required): - if all( - r.lower() in lower or any(c.startswith(r.lower()) for c in lower) - for r in required - ): - return label - return None - - -def _make_file_rows( - fpath: str, dir_path: str, fname: str, - scan_id: str, - do_hash: bool, hash_limit_bytes: int, magic_limit_bytes: int, - follow_symlinks: bool, - stats: Dict, stats_lock: threading.Lock, -) -> Optional[Tuple[Tuple, Tuple]]: - """Stat + magic + hash one file. Returns (file_row, item_row) or None on error.""" - try: - st = os.stat(fpath, follow_symlinks=follow_symlinks) - except OSError: - with stats_lock: - stats["errors"] += 1 - return None - - fsize = st.st_size - fmtime = st.st_mtime - fext = Path(fname).suffix.lower() - fmime, _ = mimetypes.guess_type(fname) - fdepth = _depth(fpath) - - ml, mh, hx = _sample_magic(fpath, magic_limit_bytes) - interp = _detect_interpreter(fext, ml, mh) - ij = json.dumps({"magic_label": ml, "magic_hint": mh, - "hex_prefix": hx or None}) if (ml or mh or hx) else None - fhash = _compute_hash(fpath, hash_limit_bytes) if do_hash else None - - with stats_lock: - stats["files"] += 1 - stats["bytes"] += fsize - if interp: - stats["identified"] += 1 - else: - stats["gaps"] += 1 - - file_row = (fpath, dir_path, fname, fdepth, "file", fsize, fmtime, - fext, fmime, None, fhash, None, scan_id, None, interp, ij) - item_row = (scan_id, fpath, "file", fsize, fmtime, - fext, fmime, None, fhash, None) - return file_row, item_row - - -# ───────────────────────────────────────────────────────────────────────────── -# Writer thread — sole owner of the SQLite connection -# ───────────────────────────────────────────────────────────────────────────── - -def _writer_thread( - write_q: "queue.Queue", - conn: sqlite3.Connection, - scan_id: str, - stats: Dict, - stats_lock: threading.Lock, - quiet: bool, -) -> None: - FILE_SQL = """INSERT INTO files( - path, parent_path, name, depth, type, size, - modified_time, file_extension, mime_type, - etag, hash, remote, scan_id, extra_json, - interpreted_as, interpretation_json - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""" - ITEM_SQL = """INSERT OR IGNORE INTO scan_items( - scan_id, path, type, size, modified_time, - file_extension, mime_type, etag, hash, extra_json - ) VALUES (?,?,?,?,?,?,?,?,?,?)""" - - BATCH = 2000 - PROG_EVERY = 8.0 - PRINT_EVERY = 2.0 - - file_buf: List[Tuple] = [] - item_buf: List[Tuple] = [] - last_prog = time.time() - last_print = time.time() - - def flush() -> None: - if not file_buf: - return - conn.executemany(FILE_SQL, file_buf) - conn.executemany(ITEM_SQL, item_buf) - conn.commit() - file_buf.clear() - item_buf.clear() - - def write_progress() -> None: - nonlocal last_prog - now = time.time() - if now - last_prog < PROG_EVERY: - return - with stats_lock: - f, d, b = stats["files"], stats["dirs"], stats["bytes"] - conn.executemany( - "INSERT OR REPLACE INTO scan_progress(scan_id,metric,value,updated) VALUES(?,?,?,?)", - [(scan_id, "files_scanned", f, now), - (scan_id, "dirs_scanned", d, now), - (scan_id, "bytes_scanned", b, now)], - ) - conn.commit() - last_prog = now - - def print_progress() -> None: - nonlocal last_print - if quiet: - return - now = time.time() - if now - last_print < PRINT_EVERY: - return - with stats_lock: - f, d, b, g = stats["files"], stats["dirs"], stats["bytes"], stats["gaps"] - print(f"\r {f:>9,} files {d:>6,} dirs " - f"{b/(1024**3):.2f} GB {g:>6,} gaps", - end="", flush=True) - last_print = now - - while True: - try: - item = write_q.get(timeout=0.5) - except queue.Empty: - flush() - write_progress() - print_progress() - continue - - if item is _STOP: - flush() - now = time.time() - with stats_lock: - f, d, b = stats["files"], stats["dirs"], stats["bytes"] - conn.executemany( - "INSERT OR REPLACE INTO scan_progress(scan_id,metric,value,updated) VALUES(?,?,?,?)", - [(scan_id, "files_scanned", f, now), - (scan_id, "dirs_scanned", d, now), - (scan_id, "bytes_scanned", b, now)], - ) - conn.commit() - break - - file_row, item_row = item - file_buf.append(file_row) - item_buf.append(item_row) - if len(file_buf) >= BATCH: - flush() - write_progress() - print_progress() - - -# ───────────────────────────────────────────────────────────────────────────── -# Worker — walks one subtree, pushes rows onto write_q, never touches SQLite -# ───────────────────────────────────────────────────────────────────────────── - -def _walk_subtree( - root_str: str, - subdir: str, - scan_id: str, - write_q: "queue.Queue", - stats: Dict, - stats_lock: threading.Lock, - *, - do_hash: bool, - hash_limit_bytes: int, - magic_limit_bytes: int, - max_depth: Optional[int], - root_depth: int, - excludes: List[str], - follow_symlinks: bool, -) -> None: - for dirpath, dirnames, filenames in os.walk( - subdir, followlinks=follow_symlinks, topdown=True - ): - current_depth = _depth(dirpath) - root_depth - if max_depth is not None and current_depth >= max_depth: - dirnames.clear() - - dirnames[:] = [ - d for d in dirnames - if not any(fnmatch.fnmatch(d, p) for p in excludes) - ] - - # Directory row - dp = str(Path(dirpath)) - dparent = str(Path(dirpath).parent) - dname = Path(dirpath).name or dp - ddepth = _depth(dp) - pattern = _detect_dir_pattern(dirnames + filenames) - dextra = json.dumps({"pattern": pattern}) if pattern else None - try: - dmtime = os.stat(dirpath).st_mtime - except OSError: - dmtime = None - - write_q.put(( - (dp, dparent, dname, ddepth, "folder", 0, dmtime, - None, None, None, None, None, scan_id, dextra, None, None), - (scan_id, dp, "folder", 0, dmtime, None, None, None, None, dextra), - )) - with stats_lock: - stats["dirs"] += 1 - - # File rows - for fname in filenames: - if any(fnmatch.fnmatch(fname, p) for p in excludes): - continue - fpath = os.path.join(dirpath, fname) - rows = _make_file_rows( - fpath, dp, fname, scan_id, - do_hash, hash_limit_bytes, magic_limit_bytes, - follow_symlinks, stats, stats_lock, - ) - if rows: - write_q.put(rows) - - -# ───────────────────────────────────────────────────────────────────────────── -# Orchestrator — splits top-level dirs across workers, single writer thread -# ───────────────────────────────────────────────────────────────────────────── - -def walk_path( - root: str, - scan_id: str, - conn: sqlite3.Connection, - *, - workers: int = 1, - do_hash: bool = True, - hash_limit_bytes: int = 100 * 1024 * 1024, - magic_limit_bytes: int = 0, - max_depth: Optional[int] = None, - excludes: List[str] = [], - follow_symlinks: bool = False, - quiet: bool = False, -) -> Dict: - mimetypes.init() - root_path = Path(root).resolve() - root_str = str(root_path) - root_depth = _depth(root_str) - - stats: Dict = {"files": 0, "dirs": 0, "bytes": 0, - "gaps": 0, "identified": 0, "errors": 0} - stats_lock = threading.Lock() - - # Queue sized so workers can stay busy without blowing memory - write_q: "queue.Queue" = queue.Queue(maxsize=workers * 1000) - - writer = threading.Thread( - target=_writer_thread, - args=(write_q, conn, scan_id, stats, stats_lock, quiet), - daemon=True, - name="scidk-writer", - ) - writer.start() - - # Enumerate top-level entries - try: - top_entries = list(os.scandir(root_str)) - except PermissionError as e: - print(f"\n[error] Cannot scan root: {e}", file=sys.stderr) - write_q.put(_STOP) - writer.join() - return stats - - top_dirs = [e.path for e in top_entries - if e.is_dir(follow_symlinks=follow_symlinks) - and not any(fnmatch.fnmatch(e.name, p) for p in excludes)] - top_files = [e for e in top_entries - if e.is_file(follow_symlinks=follow_symlinks) - and not any(fnmatch.fnmatch(e.name, p) for p in excludes)] - - # Emit root directory row - try: - rmtime = os.stat(root_str).st_mtime - except OSError: - rmtime = None - root_pattern = _detect_dir_pattern([e.name for e in top_entries]) - root_extra = json.dumps({"pattern": root_pattern}) if root_pattern else None - write_q.put(( - (root_str, str(root_path.parent), root_path.name or root_str, - root_depth, "folder", 0, rmtime, - None, None, None, None, None, scan_id, root_extra, None, None), - (scan_id, root_str, "folder", 0, rmtime, - None, None, None, None, root_extra), - )) - with stats_lock: - stats["dirs"] += 1 - - # Emit files directly in root - for e in top_files: - rows = _make_file_rows( - e.path, root_str, e.name, scan_id, - do_hash, hash_limit_bytes, magic_limit_bytes, - follow_symlinks, stats, stats_lock, - ) - if rows: - write_q.put(rows) - - # Walk subdirs — parallel or serial - worker_kwargs = dict( - scan_id=scan_id, write_q=write_q, - stats=stats, stats_lock=stats_lock, - do_hash=do_hash, hash_limit_bytes=hash_limit_bytes, - magic_limit_bytes=magic_limit_bytes, - max_depth=max_depth, root_depth=root_depth, - excludes=excludes, follow_symlinks=follow_symlinks, - ) - n_workers = min(workers, max(1, len(top_dirs))) - - if n_workers <= 1: - for subdir in top_dirs: - _walk_subtree(root_str, subdir, **worker_kwargs) - else: - with ThreadPoolExecutor(max_workers=n_workers, - thread_name_prefix="scidk-worker") as pool: - futures = { - pool.submit(_walk_subtree, root_str, subdir, **worker_kwargs): subdir - for subdir in top_dirs - } - for fut in as_completed(futures): - exc = fut.exception() - if exc and not quiet: - print(f"\n[warn] worker error on {futures[fut]}: {exc}", - file=sys.stderr) - - write_q.put(_STOP) - writer.join() - return stats - - -# ───────────────────────────────────────────────────────────────────────────── -# Gap report -# ───────────────────────────────────────────────────────────────────────────── - -def print_gap_report(conn: sqlite3.Connection, scan_id: str) -> None: - cur = conn.cursor() - cur.execute("SELECT root, started, completed FROM scans WHERE id=?", (scan_id,)) - row = cur.fetchone() - root = row["root"] if row else "?" - started = row["started"] if row else 0 - ended = row["completed"] if row else time.time() - - W = 64 - print(f"\n{'═'*W}") - print(f" SciDK Filesystem Scan Report") - print(f" Root : {root}") - print(f" Scan ID: {scan_id}") - print(f" Time : " - f"{datetime.fromtimestamp(started, tz=timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}" - f" ({int(ended - started)}s)") - print(f"{'═'*W}") - - cur.execute(""" - SELECT COUNT(*) FILTER (WHERE type='file') AS n_files, - COUNT(*) FILTER (WHERE type='folder') AS n_dirs, - SUM(size) FILTER (WHERE type='file') AS total_bytes - FROM files WHERE scan_id=?""", (scan_id,)) - t = cur.fetchone() - n_files = t["n_files"] or 0 - n_dirs = t["n_dirs"] or 0 - tb = (t["total_bytes"] or 0) / (1024**3) - print(f"\n Total : {n_files:,} files {n_dirs:,} dirs {tb:.2f} GB") - - cur.execute(""" - SELECT COUNT(*) FILTER (WHERE interpreted_as IS NOT NULL) AS covered, - COUNT(*) FILTER (WHERE interpreted_as IS NULL) AS gaps - FROM files WHERE scan_id=? AND type='file'""", (scan_id,)) - c = cur.fetchone() - covered = c["covered"] or 0 - gaps = c["gaps"] or 0 - pct = 100.0 * covered / n_files if n_files else 0 - print(f" Coverage: {covered:,} identified {gaps:,} gaps ({pct:.1f}% covered)") - - print(f"\n {'COVERED EXTENSIONS':42s} {'count':>7} {'GB':>8}") - print(f" {'-'*W}") - cur.execute(""" - SELECT file_extension, interpreted_as, COUNT(*) AS n, SUM(size) AS b - FROM files WHERE scan_id=? AND type='file' AND interpreted_as IS NOT NULL - GROUP BY file_extension ORDER BY n DESC LIMIT 20""", (scan_id,)) - for r in cur.fetchall(): - ext = r["file_extension"] or "(none)" - size = (r["b"] or 0) / (1024**3) - print(f" {ext:<20} {r['interpreted_as']:<22} {r['n']:>7,} {size:>8.2f}") - - print(f"\n {'GAP EXTENSIONS (no interpreter)':42s} {'count':>7} {'GB':>8}") - print(f" {'-'*W}") - cur.execute(""" - SELECT file_extension, COUNT(*) AS n, SUM(size) AS b - FROM files WHERE scan_id=? AND type='file' AND interpreted_as IS NULL - AND file_extension IS NOT NULL AND file_extension != '' - GROUP BY file_extension ORDER BY n DESC LIMIT 30""", (scan_id,)) - for r in cur.fetchall(): - size = (r["b"] or 0) / (1024**3) - print(f" {r['file_extension']:<42} {r['n']:>7,} {size:>8.2f}") - - cur.execute(""" - SELECT COUNT(*) AS n, SUM(size) AS b FROM files - WHERE scan_id=? AND type='file' AND interpreted_as IS NULL - AND (file_extension IS NULL OR file_extension = '')""", (scan_id,)) - u = cur.fetchone() - if u["n"]: - print(f" {'(no extension)':42} {u['n']:>7,} {(u['b'] or 0)/(1024**3):>8.2f}") - - print(f"\n DETECTED INSTRUMENT / PIPELINE DIRECTORIES") - print(f" {'-'*W}") - cur.execute(""" - SELECT json_extract(extra_json, '$.pattern') AS pattern, COUNT(*) AS n - FROM files WHERE scan_id=? AND type='folder' AND extra_json IS NOT NULL - GROUP BY pattern ORDER BY n DESC""", (scan_id,)) - rows = [r for r in cur.fetchall() if r["pattern"]] - if rows: - for r in rows: - print(f" {r['pattern']:<44} {r['n']:>4} dir{'s' if r['n']!=1 else ''}") - else: - print(" (none detected)") - - print(f"\n MAGIC BYTE IDs (gap files where format was detected)") - print(f" {'-'*W}") - cur.execute(""" - SELECT json_extract(interpretation_json, '$.magic_label') AS ml, COUNT(*) AS n - FROM files WHERE scan_id=? AND type='file' - AND interpreted_as IS NULL AND interpretation_json IS NOT NULL - AND json_extract(interpretation_json, '$.magic_label') IS NOT NULL - GROUP BY ml ORDER BY n DESC LIMIT 15""", (scan_id,)) - rows = cur.fetchall() - if rows: - for r in rows: - print(f" {r['ml']:<44} {r['n']:>7,}") - else: - print(" (none — use --magic-limit N to enable format sampling)") - - print(f"\n{'═'*W}\n") - - -# ───────────────────────────────────────────────────────────────────────────── -# CLI -# ───────────────────────────────────────────────────────────────────────────── - -def main() -> None: - parser = argparse.ArgumentParser( - description="SciDK-compatible parallel filesystem scanner", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__, - ) - parser.add_argument("root", nargs="?", help="Directory to scan") - parser.add_argument("--db", default="./scidk_scan.db", - help="SQLite output path (default: ./scidk_scan.db)") - parser.add_argument("--note", default="", help="Human note stored with the scan") - parser.add_argument("--workers", type=int, default=1, metavar="N", - help="Parallel I/O workers (default: 1). " - "Try 8-32 on network mounts.") - parser.add_argument("--no-hash", action="store_true", help="Skip content hashing") - parser.add_argument("--hash-limit", type=int, default=100, metavar="MB", - help="Only hash files < this MB (default: 100)") - parser.add_argument("--magic-limit", type=int, default=0, metavar="MB", - help="Sample magic bytes for files < this MB. " - "0 = off (default, ncdu-speed). 500 = rich detection.") - parser.add_argument("--depth", type=int, default=None, - help="Max directory depth (default: unlimited)") - parser.add_argument("--exclude", action="append", default=[], metavar="PATTERN", - help="Glob pattern to exclude (repeatable)") - parser.add_argument("--follow-symlinks", action="store_true") - parser.add_argument("--resume", metavar="SCAN_ID", - help="Resume an existing scan by ID") - parser.add_argument("--report", action="store_true", - help="Print gap report for most recent scan and exit") - parser.add_argument("--report-id", metavar="SCAN_ID", - help="Print gap report for a specific scan ID and exit") - parser.add_argument("--quiet", action="store_true") - args = parser.parse_args() - - # Report-only mode - if args.report or args.report_id: - conn = db_connect(args.db) - db_init(conn) - if args.report_id: - scan_id = args.report_id - else: - cur = conn.cursor() - cur.execute("SELECT id FROM scans ORDER BY started DESC LIMIT 1") - row = cur.fetchone() - if not row: - print("No scans found in database.", file=sys.stderr) - sys.exit(1) - scan_id = row["id"] - print_gap_report(conn, scan_id) - conn.close() - return - - if not args.root: - parser.print_help() - sys.exit(1) - - root = str(Path(args.root).resolve()) - if not os.path.isdir(root): - print(f"Error: '{root}' is not a directory.", file=sys.stderr) - sys.exit(1) - - conn = db_connect(args.db) - db_init(conn) - - if args.resume: - scan_id = args.resume - cur = conn.cursor() - cur.execute("SELECT id FROM scans WHERE id=?", (scan_id,)) - if not cur.fetchone(): - print(f"Scan ID '{scan_id}' not found in {args.db}", file=sys.stderr) - sys.exit(1) - if not args.quiet: - print(f"Resuming scan: {scan_id}") - else: - scan_id = str(uuid.uuid4()) - conn.execute( - "INSERT INTO scans(id, root, started, status, extra_json) VALUES(?,?,?,?,?)", - (scan_id, root, time.time(), "running", json.dumps({ - "note": args.note, "tool": "scidk_scanner.py", - "workers": args.workers, "excludes": args.exclude, - })), - ) - conn.commit() - - magic_str = "off (stat-only)" if args.magic_limit == 0 \ - else f"files < {args.magic_limit} MB" - if not args.quiet: - print(f"\nSciDK Filesystem Scanner") - print(f" Root : {root}") - print(f" Database: {args.db}") - print(f" Scan ID : {scan_id}") - print(f" Workers : {args.workers}") - print(f" Hashing : {'off' if args.no_hash else f'files < {args.hash_limit} MB'}") - print(f" Magic : {magic_str}") - if args.exclude: - print(f" Excludes: {', '.join(args.exclude)}") - print() - - t0 = time.time() - try: - stats = walk_path( - root, scan_id, conn, - workers=args.workers, - do_hash=not args.no_hash, - hash_limit_bytes=args.hash_limit * 1024 * 1024, - magic_limit_bytes=args.magic_limit * 1024 * 1024, - max_depth=args.depth, - excludes=args.exclude, - follow_symlinks=args.follow_symlinks, - quiet=args.quiet, - ) - except KeyboardInterrupt: - if not args.quiet: - print("\n\n [interrupted — partial results saved]") - conn.execute( - "UPDATE scans SET status=?, completed=? WHERE id=?", - ("interrupted", time.time(), scan_id), - ) - conn.commit() - conn.close() - return - - elapsed = time.time() - t0 - conn.execute( - "UPDATE scans SET status=?, completed=? WHERE id=?", - ("complete", time.time(), scan_id), - ) - conn.commit() - - if not args.quiet: - gb = stats["bytes"] / (1024**3) - print(f"\r {stats['files']:>9,} files {stats['dirs']:>6,} dirs " - f"{gb:.2f} GB {stats['gaps']:>6,} gaps ({elapsed:.0f}s) ") - print(f"\n Saved → {args.db}") - - print_gap_report(conn, scan_id) - conn.close() - - -if __name__ == "__main__": - main()