GitHub Action commited on
Commit
c11a2f8
·
0 Parent(s):

Automated sync to Hugging Face

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +68 -0
  2. .gitattributes +3 -0
  3. .github/prompts/Solution_Architecture.prompt.md +169 -0
  4. .github/workflows/sync_to_hf.yml +33 -0
  5. .gitignore +91 -0
  6. ARCHITECTURE.md +297 -0
  7. QUICKSTART.md +265 -0
  8. README.md +456 -0
  9. data/uploads/.gitkeep +1 -0
  10. fix_datetime.py +21 -0
  11. fix_default_factory.py +12 -0
  12. fix_print_statements.py +21 -0
  13. fix_timezone_imports.py +33 -0
  14. frontend-react/.gitignore +24 -0
  15. frontend-react/README.md +73 -0
  16. frontend-react/eslint.config.js +23 -0
  17. frontend-react/index.html +13 -0
  18. frontend-react/package-lock.json +0 -0
  19. frontend-react/package.json +46 -0
  20. frontend-react/public/_redirects +1 -0
  21. frontend-react/public/favicon.svg +1 -0
  22. frontend-react/public/icons.svg +24 -0
  23. frontend-react/public/thumbnail.png +3 -0
  24. frontend-react/src/App.css +1 -0
  25. frontend-react/src/App.tsx +179 -0
  26. frontend-react/src/assets/hero.png +3 -0
  27. frontend-react/src/assets/react.svg +1 -0
  28. frontend-react/src/assets/vite.svg +1 -0
  29. frontend-react/src/components/GraphCanvas.tsx +570 -0
  30. frontend-react/src/context/AuthContext.tsx +64 -0
  31. frontend-react/src/index.css +494 -0
  32. frontend-react/src/main.tsx +10 -0
  33. frontend-react/src/types/api.ts +182 -0
  34. frontend-react/src/views/AdminDashboard.tsx +762 -0
  35. frontend-react/src/views/Home.tsx +692 -0
  36. frontend-react/src/views/InsightsView.tsx +872 -0
  37. frontend-react/src/views/InteractionView.tsx +1396 -0
  38. frontend-react/src/views/Login.tsx +644 -0
  39. frontend-react/src/views/Ontology.tsx +803 -0
  40. frontend-react/src/views/Process.tsx +763 -0
  41. frontend-react/src/views/SimulationRunView.tsx +690 -0
  42. frontend-react/tsconfig.app.json +28 -0
  43. frontend-react/tsconfig.json +7 -0
  44. frontend-react/tsconfig.node.json +26 -0
  45. frontend-react/vite.config.ts +14 -0
  46. main.py +9 -0
  47. package-lock.json +373 -0
  48. package.json +16 -0
  49. pyproject.toml +84 -0
  50. refactor_di.py +33 -0
.env.example ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Application
2
+ APP_NAME="Graph RAG Service"
3
+ DEBUG=false
4
+ ENVIRONMENT=development
5
+
6
+ # API Server
7
+ API_HOST=0.0.0.0
8
+ API_PORT=8000
9
+
10
+ # Security
11
+ # ⚠️ CRITICAL: Change SECRET_KEY before ANY deployment.
12
+ # Generate one with: python -c "import secrets; print(secrets.token_hex(32))"
13
+ SECRET_KEY=change-this-in-production-to-a-secure-random-key
14
+ ACCESS_TOKEN_EXPIRE_MINUTES=30
15
+
16
+ # CORS: comma-separated list of allowed origins.
17
+ # Default allows only the local Vite dev server.
18
+ # Example for production: CORS_ORIGINS=https://yourdomain.com
19
+ CORS_ORIGINS=http://localhost:3000,http://localhost:5173
20
+
21
+ # Neo4j
22
+ NEO4J_URI=bolt://localhost:7687
23
+ NEO4J_USER=neo4j
24
+ NEO4J_PASSWORD=password
25
+ NEO4J_DATABASE=neo4j
26
+
27
+ # Redis
28
+ REDIS_HOST=localhost
29
+ REDIS_PORT=6379
30
+ REDIS_DB=0
31
+
32
+ # Celery
33
+ CELERY_BROKER_URL=redis://localhost:6379/0
34
+ CELERY_RESULT_BACKEND=redis://localhost:6379/0
35
+
36
+ # LLMs
37
+ DEFAULT_LLM_PROVIDER=ollama
38
+
39
+ # OpenAI
40
+ OPENAI_API_KEY=
41
+
42
+ # Anthropic
43
+ ANTHROPIC_API_KEY=
44
+
45
+ # Google Gemini
46
+ GOOGLE_API_KEY=
47
+
48
+ # LlamaCloud (for LlamaParse)
49
+ LLAMA_CLOUD_API_KEY=
50
+ USE_LLAMA_PARSE=true
51
+
52
+ # Ollama
53
+ OLLAMA_BASE_URL=http://localhost:11434
54
+ OLLAMA_MODEL=deepseek-v3.1:671b-cloud
55
+ OLLAMA_EMBEDDING_MODEL=nomic-embed-text
56
+
57
+ # Embedding
58
+ EMBEDDING_PROVIDER=ollama
59
+ EMBEDDING_DIMENSION=768
60
+
61
+ # Agent Configuration
62
+ MAX_AGENT_ITERATIONS=5
63
+ AGENT_TIMEOUT_SECONDS=30
64
+
65
+ # Observability
66
+ ENABLE_TRACING=true
67
+ ENABLE_METRICS=true
68
+ LOG_LEVEL=INFO
.gitattributes ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.jpg filter=lfs diff=lfs merge=lfs -text
3
+ *.ico filter=lfs diff=lfs merge=lfs -text
.github/prompts/Solution_Architecture.prompt.md ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Solution Architecture: Agentic Graph RAG as a Service
2
+
3
+ This document outlines a detailed technical approach to building the Agentic Graph RAG platform. It focuses on modularity, scalability, and the specific requirements of the Lyzr Hackathon, with a strong emphasis on production-grade robustness.
4
+
5
+ ## 1. High-Level Architecture
6
+
7
+ The system is designed as a set of modular services centered around a shared Knowledge Graph and Vector Store, fortified with enterprise-grade security and observability layers.
8
+
9
+ ```mermaid
10
+ graph TD
11
+ User[User / Client] --> Auth[Auth & Access Control]
12
+ Auth --> API[Unified API Gateway]
13
+
14
+ subgraph "Observability Layer (OpenTelemetry)"
15
+ Logs[Structured Logging]
16
+ Traces[Agent Traces]
17
+ Metrics[Performance Metrics]
18
+ end
19
+
20
+ API -.-> Logs & Traces & Metrics
21
+
22
+ subgraph "Ingestion Pipeline (Async Workers)"
23
+ API --> Queue[Task Queue (Redis/Celery)]
24
+ Queue --> Ingest[Ingestion Worker]
25
+ Ingest --> Chunking[Text Chunking]
26
+ Chunking --> OntologyGen[LLM Ontology Gen (Versioned)]
27
+ OntologyGen --> Extract[Entity & Relation Extraction]
28
+ Extract --> Resolution[Entity Resolution / Dedup]
29
+ Resolution --> GraphDB[(Neo4j / Neptune)]
30
+ Resolution --> VectorDB[(Vector Store)]
31
+ end
32
+
33
+ subgraph "Retrieval Context"
34
+ API --> Agent[Agent Orchestrator]
35
+ Agent --> Decomp[Query Decomposer]
36
+ Decomp --> Router[Query Router / Planner]
37
+
38
+ Router --> |Semantic Query| VectorSearch[Vector Search]
39
+ Router --> |Deep Relation| GraphSearch[Graph Traversal / Cypher]
40
+ Router --> |Structured| FilterSearch[Metadata Filter]
41
+
42
+ VectorSearch & GraphSearch & FilterSearch --> Validator[Hallucination Guard / Schema Validator]
43
+ Validator --> Synthesizer[Response Synthesizer]
44
+ Synthesizer --> Agent
45
+ end
46
+ ```
47
+
48
+ ## 2. Technology Stack Selection
49
+
50
+ * **Language:** Python 3.12 (Standard for AI/ML engineering).
51
+ * **API Framework:** FastAPI (Async support, auto-documentation).
52
+ * **Orchestration:** LlamaIndex (Preferred for Graph RAG).
53
+ * **LLM:** multi-LLM support like ollama, open ai, gemini,claude (use lang-graph) (Reasoning & Extraction) & `BAAI bge-m3` this model is available on ollama so we will use from ollama (Embeddings).
54
+ * **Graph Database:** Neo4j (Primary) .
55
+ * **Vector Store:** Neo4j Vector Index (for unified storage) or Qdrant/Chroma.
56
+ * **Task Queue:** Celery with Redis (for async ingestion).
57
+ * **Monitoring:** OpenTelemetry + Prometheus/Grafana.
58
+ * **Frontend:** React vite + tailwind css (for Visual Ontology Editor).
59
+
60
+ ## 3. Production-Grade Components
61
+
62
+ ### A. Document-to-Graph Pipeline (Ingestion)
63
+
64
+ This pipeline converts unstructured text into a structured Knowledge Graph, robust to schema changes and duplicates.
65
+
66
+ 1. **Ontology Generation & Evolution:**
67
+ * *Initial:* Ask LLM to identify high-level concepts (nodes) and interactions (edges) from first $N$ chunks.
68
+ * *Visual Editor:* Human approval step to refine the JSON schema.
69
+ * **Drift Handling:** Incorporate an "Ontology Versioning" system. Every node/edge is tagged with `ontology_version: v1.0`. New documents causing schema changes trigger a "Migration Proposal" for approval.
70
+
71
+ 2. **Extraction & Embedding:**
72
+ * **Prompt Engineering:** "Given text + Ontology v1.0, extract entities/relationships."
73
+ * **Hybrid Nodes:** Create `(:Chunk)` nodes linked to `(:Entity)` nodes (`(:Chunk)-[:MENTIONS]->(:Entity)`). This preserves ground truth source text alongside abstract graph relationships.
74
+
75
+ 3. **Advanced Entity Resolution:**
76
+ * *Naive:* Exact string match.
77
+ * *Production:* Multi-stage blocking and merging.
78
+ 1. **Blocking:** Group entities by Label and similar name (e.g., phonetic match).
79
+ 2. **Semantic Check:** Compare embeddings of candidates.
80
+ 3. **Threshold:** If similarity > 0.95 -> Auto-merge. If 0.85-0.95 -> Flag for "Human Review Queue".
81
+
82
+ ### B. The Agentic Retrieval System (The Brain)
83
+
84
+ A state machine loop designed for accuracy and fail-safe operation.
85
+
86
+ **1. Query Decomposition & Routing**
87
+ Instead of a single step, the Agent breaks down complexity:
88
+ * *User Query:* "How is the CEO of Lyzr related to OpenAI?"
89
+ * *Decomposition:*
90
+ 1. "Identify Lyzr CEO" (Vector/Graph lookup) -> *Result: user_X*
91
+ 2. "Find path between user_X and OpenAI" (Graph traversal).
92
+ * *Router:* Dynamically selects tools for each sub-step.
93
+
94
+ **2. Tool Implementation with Guardrails:**
95
+ * **Vector Tool:** Top-k retrieval using embedding similarity.
96
+ * **Graph Tool (Text-to-Cypher):** Uses LLM to generate Cypher.
97
+ * **Hallucination Guard:** The tool injects the *strict* allowed schema into the prompt. Generated Cypher is parsed and validated against a "Relationship Whitelist" before execution to prevent schema injection or invalid edge types.
98
+ * **Filter Tool:** Converts natural language to structured DB filters (WHERE clauses).
99
+
100
+ **3. Latency & Performance Strategy:**
101
+ * **Timeouts:** Hard limit on agent reasoning steps (e.g., max 5 loops).
102
+ * **Fallback:** If Graph tool fails or times out, degrade gracefully to pure Vector Search for a "best effort" answer.
103
+
104
+ ### C. Parity & Extensibility Layer
105
+
106
+ We define abstract base class interfaces to ensure no vendor lock-in.
107
+
108
+ ```python
109
+ class GraphStore(ABC):
110
+ @abstractmethod
111
+ def execute_query(self, query: str, params: dict): pass
112
+
113
+ class VectorStore(ABC):
114
+ @abstractmethod
115
+ def search(self, query_vector: List[float], k: int): pass
116
+
117
+ class LLMProvider(ABC):
118
+ @abstractmethod
119
+ def complete(self, prompt: str): pass
120
+
121
+ # Implementations: Neo4jStore, NeptuneStore, QdrantStore, OpenAIProvider, etc.
122
+ ```
123
+
124
+ ## 4. Scalability, Security & Observability
125
+
126
+ To meet "Production-Grade" criteria, these non-functional requirements are critical:
127
+
128
+ 1. **Access Control (RBAC):**
129
+ * Pre-retrieval enforcement.
130
+ * All queries filter by `user.tenant_id` or `user.permissions` to ensure users only retrieve data they are authorized to see.
131
+
132
+ 2. **Observability:**
133
+ * **Tracing:** Log every step of the Agent's reasoning chain (Input -> Decomp -> Tool Call -> Result). This is vital for debugging "why did the bot say that?".
134
+ * **Metrics:** Track Token Usage, Latency p95, and Cache Hit Rates.
135
+
136
+ 3. **Async Ingestion:**
137
+ * Ingestion is decoupled from the user request loop.
138
+ * File Upload API -> Pushes ID to Redis Queue -> Background Worker picks up -> Runs Extraction -> Updates Graph.
139
+
140
+ 4. **Caching Strategy:**
141
+ * **Semantic Cache (Redis):** Before hitting the LLM, check if a semantically similar query has been answered recently. reduces cost and latency.
142
+ * **Embedding Cache:** Store computed embeddings to avoid re-calculation for identical text chunks.
143
+
144
+ ## 5. Implementation Plan
145
+
146
+ ### Phase 1: Foundation (Hours 1-4)
147
+ 1. Set up Repository, Python envf (Neo4j/Redis).
148
+ 2. Implement `GraphStore` & `VectorStore` abstractions.
149
+ 3. Create Basic Auth & Middleware logging.
150
+
151
+ ### Phase 2: Ingestion Engine (Hours 5-12)
152
+ 1. Implement PDF extractor & Async Worker skeleton.
153
+ 2. Build "Ontology Proposer" & "Graph Extractor" prompts.
154
+ 3. Implement Entity Resolution logic.
155
+
156
+ ### Phase 3: The Retrieval Agent (Hours 13-20)
157
+ 1. Set up Agent loop with Query Decomposition.
158
+ 2. Implement `Text2Cypher` with schema validation.
159
+ 3. Implement Latency Timeouts & Fallbacks.
160
+
161
+ ### Phase 4: Refinement & UI (Hours 21-24)
162
+ 1. Build Visual Editor (Streamlit).
163
+ 2. Add simple Evaluation Script (run known queries, check answers).
164
+ 3. Write `README.md` highlighting the "Production Thinking" (RBAC, Async, Observability).
165
+
166
+ ## 6. Key Innovations
167
+ 1. **Hybrid Chunk Nodes:** Storing source text explicitly in the graph for ground-truth verification.
168
+ 2. **Self-Correcting Cypher:** If Cypher execution fails, feed the error back to the LLM to fix syntax automatically.
169
+ 3. **Adaptive Retrieval:** The agent assigns a "confidence score" to each retrieval method. If Vector Search confidence is low (<0.7), it automatically triggers Graph Traversal to boost context.
.github/workflows/sync_to_hf.yml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face
2
+ on:
3
+ push:
4
+ branches: [master]
5
+
6
+ jobs:
7
+ sync-to-hub:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v3
11
+ - name: Push to hub
12
+ env:
13
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
14
+ run: |
15
+ # 1. Turn on Large File Storage (LFS)
16
+ git lfs install
17
+
18
+ # 2. Tell Hugging Face to properly handle images
19
+ echo "*.png filter=lfs diff=lfs merge=lfs -text" >> .gitattributes
20
+ echo "*.jpg filter=lfs diff=lfs merge=lfs -text" >> .gitattributes
21
+ echo "*.ico filter=lfs diff=lfs merge=lfs -text" >> .gitattributes
22
+
23
+ # 3. Wipe the hidden git history in the runner to clear past image errors
24
+ rm -rf .git
25
+ git config --global user.email "action@github.com"
26
+ git config --global user.name "GitHub Action"
27
+
28
+ # 4. Create a fresh, clean package and force push it
29
+ git init
30
+ git checkout -b main
31
+ git add .
32
+ git commit -m "Automated sync to Hugging Face"
33
+ git push --force https://anky2002:$HF_TOKEN@huggingface.co/spaces/anky2002/graph-rag main
.gitignore ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Python ───────────────────────────────────────────────────────────────────
2
+ __pycache__/
3
+ *.py[oc]
4
+ *.pyo
5
+ *.pyd
6
+ build/
7
+ dist/
8
+ wheels/
9
+ *.egg-info/
10
+ .eggs/
11
+ *.egg
12
+
13
+ # ── Virtual environments ──────────────────────────────────────────────────────
14
+ .venv/
15
+ venv/
16
+ ENV/
17
+ env/
18
+
19
+ # ── Environment / Secrets (NEVER commit real keys) ────────────────────────────
20
+ .env
21
+ .env.local
22
+ .env.*.local
23
+ *.secret
24
+
25
+ # ── Node.js / NPM ─────────────────────────────────────────────────────────────
26
+ node_modules/
27
+ npm-debug.log*
28
+ yarn-debug.log*
29
+ yarn-error.log*
30
+ pnpm-debug.log*
31
+ .npm/
32
+
33
+ # ── Uploaded user data ────────────────────────────────────────────────────────
34
+ data/uploads/*
35
+ !data/uploads/.gitkeep
36
+
37
+ # ── One-off debug / experiment test files ─────────────────────────────────────
38
+ test_combinations.py
39
+ test_document_embedding.py
40
+ test_embedding.py
41
+ test_exact_combination.py
42
+ test_narrow_down.py
43
+ test_nomic.py
44
+ test_resume_text.py
45
+
46
+ # ── IDE / OS ──────────────────────────────────────────────────────────────────
47
+ .idea/
48
+ .vscode/
49
+ *.swp
50
+ *.swo
51
+ .DS_Store
52
+ Thumbs.db
53
+
54
+ # ── Logs & temp ───────────────────────────────────────────────────────────────
55
+ *.log
56
+ *.tmp
57
+ *.temp
58
+ htmlcov/
59
+ .coverage
60
+ .pytest_cache/
61
+ .mypy_cache/
62
+ .ruff_cache/
63
+
64
+ # ── Docs that are project-internal notes (not needed in repo) ─────────────────
65
+ BGE_M3_ISSUE_ANALYSIS.md
66
+ CONFIGURATION_CHANGES.md
67
+ PROJECT_COMPLETION_CHECKLIST.md
68
+ LLAMAPARSE_SETUP.md
69
+
70
+ # ── Lyzr Hackathon source material (not part of the submitted codebase) ────────
71
+ Lyzr_Hackathon_Problem_Statement.md
72
+
73
+ # ── UV lock / python version pins (repo-specific, not portable) ─────────────────
74
+ # uv.lock # keep: ensures reproducible installs for reviewers
75
+ .python-version
76
+
77
+ # ── pyvis / notebook temp files ──────────────────────────────────────────────
78
+ *.html
79
+ !frontend-react/index.html
80
+ !frontend-react/*.html
81
+ !frontend/*.html
82
+
83
+ # ── Celery / task artefacts ───────────────────────────────────────────────────
84
+ celerybeat-schedule
85
+ celerybeat.pid
86
+
87
+ # ── Neo4j local data (if ever mounted) ───────────────────────────────────────
88
+ neo4j_data/
89
+
90
+ # ── Windows shortcuts / artifacts ────────────────────────────────────────────
91
+ *.lnk
ARCHITECTURE.md ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Graph RAG Service - Project Documentation
2
+
3
+ ## System Architecture
4
+
5
+ ### Overview
6
+ The Graph RAG Service is built as a modular, production-grade platform with the following key components:
7
+
8
+ 1. **API Gateway (FastAPI)**: Handles all HTTP requests, authentication, and routing
9
+ 2. **Ingestion Pipeline**: Processes documents and constructs knowledge graphs
10
+ 3. **Retrieval Agent (LangGraph)**: Intelligent query routing and response synthesis
11
+ 4. **Storage Layer**: Neo4j for graph + vector storage
12
+ 5. **Task Queue**: Celery + Redis for async processing
13
+ 6. **Observability**: OpenTelemetry for tracing and metrics
14
+
15
+ ### Design Principles
16
+
17
+ #### 1. No Vendor Lock-in
18
+ All core components are abstracted behind interfaces:
19
+ - `GraphStore`: Can swap Neo4j for AWS Neptune
20
+ - `VectorStore`: Supports multiple vector databases
21
+ - `LLMProvider`: Works with any LLM (OpenAI, Anthropic, Gemini, Ollama)
22
+
23
+ #### 2. Production-Ready
24
+ - **Async Processing**: Non-blocking I/O for all database operations
25
+ - **Background Jobs**: Celery workers handle heavy ingestion tasks
26
+ - **Authentication**: JWT-based with RBAC support
27
+ - **Error Handling**: Graceful degradation and fallback mechanisms
28
+ - **Observability**: Full tracing and metrics collection
29
+
30
+ #### 3. Intelligent Retrieval
31
+ The agentic system:
32
+ - Decomposes complex queries into sub-queries
33
+ - Dynamically selects retrieval methods (vector vs graph vs cypher)
34
+ - Validates outputs against schema (hallucination guard)
35
+ - Provides reasoning chains for transparency
36
+
37
+ ## Components Deep Dive
38
+
39
+ ### Core Abstractions (`src/graph_rag_service/core/`)
40
+
41
+ #### GraphStore Interface
42
+ ```python
43
+ class GraphStore(ABC):
44
+ @abstractmethod
45
+ async def create_node(entity: Entity) -> str
46
+ @abstractmethod
47
+ async def create_relationship(relationship: Relationship) -> str
48
+ @abstractmethod
49
+ async def execute_query(query: str, params: dict) -> List[dict]
50
+ @abstractmethod
51
+ async def find_path(source: str, target: str, max_depth: int) -> List[dict]
52
+ ```
53
+
54
+ Implementation: `Neo4jStore` provides unified graph + vector storage using Neo4j 5.x vector capabilities.
55
+
56
+ #### LLMProvider Interface
57
+ ```python
58
+ class LLMProvider(ABC):
59
+ @abstractmethod
60
+ async def complete(prompt: str, **kwargs) -> str
61
+ @abstractmethod
62
+ async def embed(text: str) -> List[float]
63
+ ```
64
+
65
+ Implementation: `UnifiedLLMProvider` wraps OpenAI, Anthropic, Gemini, and Ollama with a consistent interface.
66
+
67
+ #### Entity Resolution
68
+ Multi-stage resolution:
69
+ 1. **Blocking**: Group by entity type and name similarity (fast reject)
70
+ 2. **Semantic Check**: Compare embeddings for deep similarity
71
+ 3. **Threshold Matching**: Configurable thresholds (0.85 default)
72
+ 4. **Auto-merge**: High confidence merges (>0.95)
73
+ 5. **Human Review Queue**: Medium confidence flagged for review (0.85-0.95)
74
+
75
+ ### Ingestion Pipeline (`src/graph_rag_service/ingestion/`)
76
+
77
+ #### Flow
78
+ 1. **Document Processing**: Extract text from PDF/TXT/MD/DOCX
79
+ 2. **Chunking**: Split into overlapping chunks (1024 tokens, 200 overlap)
80
+ 3. **Ontology Generation**: LLM analyzes samples to propose entity/relationship types
81
+ 4. **Entity Extraction**: Extract entities and relationships per chunk
82
+ 5. **Entity Resolution**: Deduplicate and merge entities
83
+ 6. **Embedding Generation**: Create vector embeddings (BGE-M3)
84
+ 7. **Graph Construction**: Store in Neo4j with hybrid nodes
85
+
86
+ #### Hybrid Nodes
87
+ Each chunk is stored as both:
88
+ - A `(:Chunk)` node with text and embedding
89
+ - Connected to `(:Entity)` nodes via `[:MENTIONS]` relationships
90
+
91
+ This preserves source text for grounding while enabling abstract graph queries.
92
+
93
+ ### Retrieval System (`src/graph_rag_service/retrieval/`)
94
+
95
+ #### Tools
96
+ 1. **VectorSearchTool**: Semantic similarity using embeddings
97
+ 2. **GraphTraversalTool**: Relationship exploration and path finding
98
+ 3. **CypherGenerationTool**: Text-to-Cypher with validation
99
+ 4. **MetadataFilterTool**: Structured queries on attributes
100
+
101
+ #### Agent Workflow (LangGraph)
102
+ ```
103
+ [Query] → [Decompose] → [Route] → [Vector/Graph/Cypher] → [Synthesize] → [Response]
104
+ ↑ ↓
105
+ └─────────────────────────────────────┘
106
+ (Iterative refinement)
107
+ ```
108
+
109
+ #### Hallucination Guards
110
+ - **Schema Injection**: Prompt includes allowed entity/relationship types
111
+ - **Cypher Validation**: Parse and validate against whitelist
112
+ - **Self-Correction**: Feed errors back to LLM to fix syntax
113
+ - **Fallback**: If graph fails, degrade to vector search
114
+
115
+ ### API Layer (`src/graph_rag_service/api/`)
116
+
117
+ #### Endpoints
118
+ - `POST /api/auth/login`: Get JWT token
119
+ - `POST /api/documents/upload`: Upload document (returns task ID)
120
+ - `GET /api/documents/status/{task_id}`: Check ingestion progress
121
+ - `POST /api/query`: Execute agentic query
122
+ - `GET /api/ontology`: Get current ontology schema
123
+ - `PUT /api/ontology`: Update ontology (admin only)
124
+ - `GET /api/graph/visualization`: Get graph data for visualization
125
+ - `GET /api/system/health`: System health check
126
+ - `GET /api/system/stats`: System statistics
127
+
128
+ #### Authentication
129
+ - JWT tokens with configurable expiration (default: 30 min)
130
+ - RBAC with scopes: `read`, `write`, `admin`
131
+ - Dependency injection for protected endpoints
132
+
133
+ ### Workers (`src/graph_rag_service/workers/`)
134
+
135
+ #### Celery Tasks
136
+ - `ingest_document`: Process single document
137
+ - `ingest_documents_batch`: Process multiple documents
138
+ - `health_check`: Worker health verification
139
+
140
+ #### Configuration
141
+ - Broker: Redis
142
+ - Result Backend: Redis
143
+ - Serializer: JSON
144
+ - Task timeout: 1 hour (configurable)
145
+
146
+ ### Observability (`src/graph_rag_service/observability/`)
147
+
148
+ #### OpenTelemetry Integration
149
+ - **Traces**: Agent reasoning steps, tool calls, database queries
150
+ - **Metrics**:
151
+ - `documents_ingested`: Counter
152
+ - `queries_executed`: Counter
153
+ - `query_duration_seconds`: Histogram
154
+ - `entities_extracted`: Counter
155
+
156
+ #### Structured Logging
157
+ - Log level: INFO (configurable)
158
+ - Format: `%(asctime)s - %(name)s - %(levelname)s - %(message)s`
159
+ - All async operations logged with context
160
+
161
+ ## Configuration
162
+
163
+ ### Environment Variables
164
+ Key settings in `.env`:
165
+ - **Neo4j**: `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD`
166
+ - **Redis**: `REDIS_HOST`, `REDIS_PORT`
167
+ - **LLM Provider**: `DEFAULT_LLM_PROVIDER` (openai/anthropic/gemini/ollama)
168
+ - **API Keys**: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`
169
+ - **Ollama**: `OLLAMA_BASE_URL`, `OLLAMA_MODEL`, `OLLAMA_EMBEDDING_MODEL`
170
+ - **Security**: `SECRET_KEY`, `ACCESS_TOKEN_EXPIRE_MINUTES`
171
+
172
+ ### Tuning Parameters
173
+ - `CHUNK_SIZE`: 1024 (text chunk size)
174
+ - `CHUNK_OVERLAP`: 200 (overlap between chunks)
175
+ - `MAX_AGENT_ITERATIONS`: 5 (max reasoning steps)
176
+ - `AGENT_TIMEOUT_SECONDS`: 30 (query timeout)
177
+ - `ENTITY_RESOLUTION_THRESHOLD`: 0.85 (similarity threshold)
178
+ - `DEFAULT_TOP_K`: 5 (retrieval results)
179
+ - `GRAPH_MAX_DEPTH`: 3 (graph traversal depth)
180
+
181
+ ## Deployment
182
+
183
+ ### Local Development
184
+ ```bash
185
+ # 1. Ensure Neo4j and Redis are running
186
+ # 2. Configure .env with connection details
187
+
188
+ # 3. Start API server
189
+ ./start-server.sh # or start-server.bat on Windows
190
+
191
+ # 4. Start workers
192
+ ./start-worker.sh # or start-worker.bat on Windows
193
+ ```
194
+
195
+ ### Production Considerations
196
+ 1. **Database**: Use managed Neo4j (Aura) or self-hosted cluster
197
+ 2. **Redis**: Use managed Redis (AWS ElastiCache, Redis Cloud)
198
+ 3. **Worker Scaling**: Add more Celery workers based on ingestion load
199
+ 4. **API Scaling**: Run multiple API instances behind load balancer
200
+ 5. **Monitoring**: Integrate with Prometheus/Grafana for metrics
201
+ 6. **Secrets**: Use secret management (AWS Secrets Manager, HashiCorp Vault)
202
+
203
+ ## Extensibility
204
+
205
+ ### Adding New LLM Provider
206
+ 1. Implement `LLMProvider` interface
207
+ 2. Add to `LLMFactory.create()` method
208
+ 3. Update config with new provider settings
209
+
210
+ ### Adding New Graph Database
211
+ 1. Implement `GraphStore` interface
212
+ 2. Update `IngestionPipeline` to use new store
213
+ 3. Test with existing workflows
214
+
215
+ ### Custom Retrieval Tools
216
+ 1. Create new tool class with `run()` method
217
+ 2. Add to `AgentRetrievalSystem.tools`
218
+ 3. Update routing logic in `_route_query()`
219
+
220
+ ## Testing Strategy
221
+
222
+ ### Unit Tests
223
+ - Test each component independently
224
+ - Mock external dependencies (Neo4j, Redis, LLMs)
225
+ - Focus on business logic
226
+
227
+ ### Integration Tests
228
+ - Test component interactions
229
+ - Use test database instances
230
+ - Verify end-to-end flows
231
+
232
+ ### Performance Tests
233
+ - Benchmark ingestion throughput
234
+ - Measure query latencies
235
+ - Stress test with concurrent requests
236
+
237
+ ## Future Enhancements
238
+
239
+ ### Phase 1 (Current MVP)
240
+ - ✅ Core ingestion pipeline
241
+ - ✅ Agentic retrieval system
242
+ - ✅ Multi-LLM support
243
+ - ✅ Entity resolution
244
+ - ✅ Async workers
245
+
246
+ ### Phase 2 (Next Steps)
247
+ - [ ] React frontend with visual ontology editor
248
+ - [ ] Graph visualization (D3.js/Cytoscape)
249
+ - [ ] Advanced ontology evolution with migrations
250
+ - [ ] Semantic cache with Redis
251
+ - [ ] Batch ingestion optimization
252
+
253
+ ### Phase 3 (Advanced Features)
254
+ - [ ] Multi-tenant support with data isolation
255
+ - [ ] Fine-tuned entity extraction models
256
+ - [ ] Graph neural network embeddings
257
+ - [ ] Automated ontology quality metrics
258
+ - [ ] Export/import ontology schemas
259
+
260
+ ## Troubleshooting
261
+
262
+ ### Common Issues
263
+
264
+ #### Neo4j Connection Failed
265
+ - Verify Neo4j is running and accessible
266
+ - Verify credentials in `.env`
267
+ - Try connecting with cypher-shell: `cypher-shell -u neo4j -p password`
268
+
269
+ #### Celery Worker Not Processing
270
+ - Check Redis is running: `redis-cli ping`
271
+ - Verify broker URL in `.env`
272
+ - Check worker logs
273
+
274
+ #### Ollama Models Not Found
275
+ - Pull models: `ollama pull llama3.2 && ollama pull bge-m3`
276
+ - Verify Ollama is running: `curl http://localhost:11434/api/tags`
277
+
278
+ #### Query Returns No Results
279
+ - Verify documents are ingested: `GET /api/system/stats`
280
+ - Check ontology exists: `GET /api/ontology`
281
+ - Try simpler queries first
282
+
283
+ ## Support
284
+
285
+ For issues or questions:
286
+ 1. Check documentation and troubleshooting guide
287
+ 2. Search existing GitHub issues
288
+ 3. Open new issue with:
289
+ - Clear description
290
+ - Steps to reproduce
291
+ - Environment details
292
+ - Relevant logs
293
+
294
+ ---
295
+
296
+ **Last Updated**: February 2026
297
+ **Version**: 0.1.0
QUICKSTART.md ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Quick Start Guide
2
+
3
+ Get the Graph RAG Service up and running in 10 minutes!
4
+
5
+ ## Prerequisites
6
+
7
+ Before starting, make sure you have:
8
+ - Python 3.12 or higher
9
+ - Neo4j database (running locally or remotely)
10
+ - Redis server (running locally or remotely)
11
+ - UV package manager (will be installed if missing)
12
+
13
+ ## Step 1: Clone and Setup
14
+
15
+ ```bash
16
+ cd graph-RAG
17
+
18
+ # Install UV if you don't have it
19
+ curl -LsSf https://astral.sh/uv/install.sh | sh
20
+
21
+ # Install dependencies
22
+ uv sync
23
+ ```
24
+
25
+ ## Step 2: Configure Environment
26
+
27
+ ```bash
28
+ # Copy environment template
29
+ cp .env.example .env
30
+
31
+ # Edit .env with your settings
32
+ # Configure NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD
33
+ # Configure REDIS_URL
34
+ ```
35
+
36
+ ## Step 3: Ensure Backend Services Are Running
37
+
38
+ Make sure Neo4j and Redis are running and accessible:
39
+ - Neo4j should be available at the URI specified in your .env file (default: bolt://localhost:7687)
40
+ - Redis should be available at the URL specified in your .env file (default: redis://localhost:6379)
41
+
42
+ ## Step 4: Start the API Server
43
+
44
+ ### On Windows:
45
+ ```bash
46
+ start-server.bat
47
+ ```
48
+
49
+ ### On Mac/Linux:
50
+ ```bash
51
+ chmod +x start-server.sh
52
+ ./start-server.sh
53
+ ```
54
+
55
+ ### Or directly (any OS):
56
+ ```bash
57
+ uv run python main.py
58
+ ```
59
+
60
+ The API server will start on `http://localhost:8000`
61
+
62
+ ## Step 5: Start Celery Worker
63
+
64
+ For asynchronous document ingestion, start a worker in a **new terminal**:
65
+
66
+ ### On Windows:
67
+ ```bash
68
+ start-worker.bat
69
+ ```
70
+
71
+ ### On Mac/Linux:
72
+ ```bash
73
+ chmod +x start-worker.sh
74
+ ./start-worker.sh
75
+ ```
76
+
77
+ ### Or directly:
78
+ ```bash
79
+ uv run celery -A src.graph_rag_service.workers.celery_worker worker --loglevel=info
80
+ ```
81
+
82
+ ## Step 6: Start the React Frontend
83
+
84
+ In a **new terminal**:
85
+
86
+ ```bash
87
+ cd frontend-react
88
+
89
+ # Install Node dependencies (first time only)
90
+ npm install
91
+
92
+ # Start the dev server
93
+ npm run dev
94
+ ```
95
+
96
+ The React UI will open at `http://localhost:5173`
97
+
98
+ > **Note:** The project uses a **React/Vite** frontend (`frontend-react/`).
99
+ > There is no `streamlit run app.py` — any references to Streamlit in older docs are outdated.
100
+
101
+ Login with: **username** `admin` / **password** `admin`
102
+
103
+ ## Step 7: Test the API
104
+
105
+ ### Using cURL
106
+
107
+ 1. **Get Access Token**
108
+ ```bash
109
+ curl -X POST "http://localhost:8000/api/auth/login" \
110
+ -H "Content-Type: application/json" \
111
+ -d '{"username": "demo", "password": "demo"}'
112
+ ```
113
+
114
+ Save the `access_token` from the response.
115
+
116
+ 2. **Upload a Document**
117
+ ```bash
118
+ curl -X POST "http://localhost:8000/api/documents/upload" \
119
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
120
+ -F "file=@sample.pdf"
121
+ ```
122
+
123
+ 3. **Query the Knowledge Base**
124
+ ```bash
125
+ curl -X POST "http://localhost:8000/api/query" \
126
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
127
+ -H "Content-Type: application/json" \
128
+ -d '{
129
+ "query": "What is this document about?",
130
+ "top_k": 5
131
+ }'
132
+ ```
133
+
134
+ ### Using the Interactive Docs
135
+
136
+ Open your browser and go to:
137
+ - Swagger UI: `http://localhost:8000/docs`
138
+ - ReDoc: `http://localhost:8000/redoc`
139
+
140
+ Click "Authorize" and enter your access token to test endpoints interactively.
141
+
142
+ ## Step 8: Setup Ollama (Optional)
143
+
144
+ If you want to use local LLMs instead of OpenAI/Anthropic:
145
+
146
+ ```bash
147
+ # Install Ollama (https://ollama.ai)
148
+ # Then pull models:
149
+ ollama pull llama3.2
150
+ ollama pull bge-m3
151
+ ```
152
+
153
+ Update `.env`:
154
+ ```
155
+ DEFAULT_LLM_PROVIDER=ollama
156
+ OLLAMA_BASE_URL=http://localhost:11434
157
+ OLLAMA_MODEL=llama3.2
158
+ OLLAMA_EMBEDDING_MODEL=bge-m3
159
+ ```
160
+
161
+ ## Common Commands
162
+
163
+ ### Check System Health
164
+ ```bash
165
+ curl http://localhost:8000/api/system/health
166
+ ```
167
+
168
+ ### Get System Stats
169
+ ```bash
170
+ curl -H "Authorization: Bearer YOUR_TOKEN" \
171
+ http://localhost:8000/api/system/stats
172
+ ```
173
+
174
+ ### View Ontology
175
+ ```bash
176
+ curl -H "Authorization: Bearer YOUR_TOKEN" \
177
+ http://localhost:8000/api/ontology
178
+ ```
179
+
180
+ ### Visualize Graph
181
+ ```bash
182
+ curl -H "Authorization: Bearer YOUR_TOKEN" \
183
+ "http://localhost:8000/api/graph/visualization?limit=50"
184
+ ```
185
+
186
+ ## Troubleshooting
187
+
188
+ ### Neo4j Connection Error
189
+ ```bash
190
+ # Verify credentials in .env
191
+ # Check Neo4j is accessible
192
+
193
+ # Access Neo4j browser
194
+ open http://localhost:7474
195
+
196
+ # Try connecting with cypher-shell
197
+ cypher-shell -u neo4j -p password
198
+ ```
199
+
200
+ ### Redis Connection Error
201
+ ```bash
202
+ # Check Redis is running
203
+ redis-cli ping
204
+
205
+ # Test Redis connection
206
+ redis-cli -h localhost -p 6379 ping
207
+ ```
208
+
209
+ ### Worker Not Processing
210
+ ```bash
211
+ # Check worker logs
212
+ # If running locally:
213
+ celery -A src.graph_rag_service.workers.celery_worker inspect active
214
+ ```
215
+
216
+ ### API Server Won't Start
217
+ ```bash
218
+ # Check for port conflicts
219
+ lsof -i :8000 # On Mac/Linux
220
+ netstat -ano | findstr :8000 # On Windows
221
+
222
+ # View detailed logs
223
+ uv run python main.py
224
+ ```
225
+
226
+ ## Next Steps
227
+
228
+ - Read the [README.md](README.md) for comprehensive documentation
229
+ - Check [ARCHITECTURE.md](ARCHITECTURE.md) for system design details
230
+ - Explore the API docs at `http://localhost:8000/docs`
231
+
232
+ ## Using with Different LLM Providers
233
+
234
+ ### OpenAI
235
+ ```bash
236
+ # In .env:
237
+ DEFAULT_LLM_PROVIDER=openai
238
+ OPENAI_API_KEY=sk-your-key-here
239
+ ```
240
+
241
+ ### Anthropic
242
+ ```bash
243
+ # In .env:
244
+ DEFAULT_LLM_PROVIDER=anthropic
245
+ ANTHROPIC_API_KEY=sk-ant-your-key-here
246
+ ```
247
+
248
+ ### Google Gemini
249
+ ```bash
250
+ # In .env:
251
+ DEFAULT_LLM_PROVIDER=gemini
252
+ GOOGLE_API_KEY=your-key-here
253
+ ```
254
+
255
+ Services will be available at:
256
+ - API: `http://localhost:8000`
257
+ - Neo4j Browser: `http://localhost:7474`
258
+
259
+ ## Need Help?
260
+
261
+ - Check the troubleshooting section above
262
+ - Read the full [README.md](README.md)
263
+ - Open an issue on GitHub
264
+
265
+ Happy building! 🚀
README.md ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CORTEX — Agentic Graph RAG Platform
2
+
3
+ > **CORTEX** is a production-grade, agentic Knowledge Graph platform that transforms unstructured documents and web content into an intelligent, queryable knowledge graph — with a full-featured React UI, streaming AI chat, real-time graph visualization, simulation personas, and deep ontology governance.
4
+
5
+ ---
6
+
7
+ ## ✨ What's Been Built
8
+
9
+ ### 🖥️ Full-Stack Application
10
+
11
+ | Layer | Stack |
12
+ |---|---|
13
+ | **Backend API** | FastAPI (async) + Python 3.12 |
14
+ | **Task Queue** | Celery + Redis |
15
+ | **Graph + Vector DB** | Neo4j 5.x (unified) |
16
+ | **LLM Layer** | OpenAI, Anthropic, Google Gemini, Ollama |
17
+ | **Frontend** | React 18 + TypeScript + Vite |
18
+ | **Unified Start** | `npm run rag` (concurrently launches all 3 processes) |
19
+
20
+ ---
21
+
22
+ ## 🚀 Features
23
+
24
+ ### 📥 Document Ingestion Pipeline
25
+
26
+ - **Multi-format ingestion**: PDF, TXT, MD, DOCX, CSV, XLSX, PPTX, JSON
27
+ - **Web scraping**: Single-page scrape via `POST /api/documents/scrape`
28
+ - **Deep web crawling**: Multi-depth Playwright-powered crawler (`POST /api/documents/crawl`) via Crawl4AI
29
+ - **Async Celery workers**: Upload returns instantly with a `task_id`; background workers build the graph
30
+ - **Re-ingest**: Admin can trigger re-processing of any stored document
31
+ - **Document preview & download**: In-browser preview of text/Markdown; PDF download via API
32
+
33
+ ### 🔭 Ontology Management
34
+
35
+ - **Auto-generation**: LLM analyzes document chunks to propose entity types & relationship types
36
+ - **LLM-powered refinement**: `POST /api/ontology/refine` — refine schema with optional human feedback
37
+ - **Versioning**: Each schema change bumps the version (`v1.0` → `v1.1`, etc.)
38
+ - **Document-scoped stats**: `/api/ontology/stats?document_id=...` returns entity/relationship breakdowns for a specific document
39
+ - **Visual editor**: Ontology view in UI with editable entity types and relationship types
40
+ - **Ontology Drift Detection**: Automated drift detection compares live graph against new chunk samples; exposes pending/approved/rejected drift reports with admin approve/reject workflow
41
+
42
+ ### 🤖 Agentic Retrieval System
43
+
44
+ - **LangGraph orchestration**: State-machine ReACT agent with multi-step reasoning and fallback mechanisms
45
+ - **Tool routing**: Dynamically selects from Vector Search, Graph Traversal, Cypher Generation, Metadata Filtering, Community Search, and Temporal Queries
46
+ - **Streaming responses**: Server-Sent Events (SSE) with real-time reasoning steps surfaced in the UI
47
+ - **Multi-turn conversations**: Persistent conversation threads stored in Neo4j, per-user
48
+ - **Document-scoped queries**: Filter retrieval to a specific document via `document_id`
49
+ - **Graph of Thoughts (GoT)**: Optional GoT reasoning mode for complex multi-hop queries
50
+ - **LLM-as-a-Judge (inline)**: Optional per-response quality scoring with hallucination risk, grounded/ungrounded claims, and confidence reasoning displayed in chat
51
+ - **Confidence display**: Confidence score, hallucination risk, and judge reasoning shown directly in the chat bubble
52
+
53
+ ### 📊 RAGAS Evaluation & Quality Dashboard
54
+
55
+ - **`POST /api/eval/score`**: Run RAGAS-style evaluation on any Q&A pair (faithfulness, relevancy, context precision, hallucination detection)
56
+ - **`GET /api/eval/dashboard`**: Aggregate evaluation history — avg scores, hallucination rate, trend timeline
57
+ - Results persisted in Neo4j for longitudinal quality tracking
58
+
59
+ ### 🗺️ Graph Intelligence
60
+
61
+ - **D3 force-directed visualization**: Interactive knowledge graph with zoom, pan, node selection, and a details modal
62
+ - **Graph Export**: Export full or document-scoped graph as JSON, Cypher, or GraphML
63
+ - **Community Detection**: Weakly-connected-components (WCC) community assignment with `POST /api/graph/communities/assign`
64
+ - **Community listing**: `GET /api/graph/communities` — top communities by entity count
65
+ - **Temporal Queries**: `GET /api/entities/{entity_name}/at-time` — retrieve entity relationships at a historical point in time
66
+ - **Semantic Entity Deduplication**: Multi-stage entity resolution with configurable similarity thresholds (`POST /api/entities/deduplicate`)
67
+ - **Entity Enrichment**: LLM-synthesized profile summaries for every entity, stored as `e.summary` (`POST /api/entities/enrich`)
68
+ - **Entity Chat (scoped)**: `POST /api/entities/{entity_name}/chat` — multi-turn conversation scoped entirely to a single entity's graph neighborhood
69
+ - **Graph Memory Updater**: Push raw text directly into the live knowledge graph without re-ingesting a document (`POST /api/graph/update`)
70
+
71
+ ### 📝 Analytical Report Agent (ReACT)
72
+
73
+ - **`POST /api/report`**: ReACT multi-step report agent using InsightForge / PanoramaSearch / QuickSearch tools
74
+ - Decomposes topic into sub-questions → retrieves graph data → synthesizes sections → compiles structured markdown report
75
+ - Exposed in the **Insights** view (copy/download report as Markdown)
76
+
77
+ ### 🎭 Simulation & Persona Engine
78
+
79
+ - **Persona generation**: Celery task that generates personas from graph entities (`POST /api/v1/simulation/generate_personas`)
80
+ - **Simulation ticks**: Background tick loop (`POST /api/v1/simulation/tick`)
81
+ - **Live persona interview**: `POST /api/v1/simulation/interview` — roleplay chat with any graph entity injecting their Neo4j memory as system context
82
+ - **SimulationRunView**: Dedicated UI view for managing and interacting with simulation personas
83
+
84
+ ### 🛡️ Admin Dashboard
85
+
86
+ - **System statistics**: Node count, relationship count, LLM provider, environment
87
+ - **User management**: List users, update scopes/roles (RBAC)
88
+ - **Document vault**: View and delete all ingested documents
89
+ - **Graph CRUD**: Search, inspect, and delete graph nodes from the admin panel
90
+ - **Ontology governance**: Review and approve/reject pending ontology proposals
91
+ - **Celery task monitor**: View active and reserved tasks from the admin panel
92
+ - **Self-demotion guard**: Admins cannot demote their own account
93
+ - **Re-ingest button**: Re-queue any stored document from the document vault
94
+ - **User activity metrics**: Per-user conversation count, message count, last active timestamp
95
+
96
+ ### 🔐 Authentication & Security
97
+
98
+ - **JWT authentication**: Token-based auth with configurable expiry
99
+ - **RBAC scopes**: `read`, `write`, `admin` scopes enforced per endpoint
100
+ - **User registration**: `POST /api/auth/register`
101
+ - **Pydantic validation**: All API inputs validated at the model layer
102
+ - **Cypher injection prevention**: Schema validation and query whitelisting
103
+ - **File upload limits**: File size and MIME type enforcement
104
+
105
+ ### 🌐 Frontend (React/TypeScript)
106
+
107
+ Seven fully implemented views accessible from the `CORTEX` top navigation bar:
108
+
109
+ | Route | View | Description |
110
+ |---|---|---|
111
+ | `/` | **Home** | Animated stats dashboard — documents, entities, relationships, graph health |
112
+ | `/process` | **Process** | Upload files or scrape/crawl URLs; view ingestion queue and document list |
113
+ | `/ontology` | **Ontology** | View/edit the live ontology schema; run LLM refinement; inspect entity/relationship stats per doc |
114
+ | `/interact` | **Interact** | Streaming AI chat with reasoning steps, confidence, hallucination risk; conversation history |
115
+ | `/simulate` | **Simulate** | Simulation persona management and live interview interface |
116
+ | `/insights` | **Insights** | Topic-driven analytical report generation with copy/download |
117
+ | `/admin` | **Admin** _(admin-only)_ | Full admin panel for users, docs, tasks, ontology governance |
118
+
119
+ ### 🔭 Observability
120
+
121
+ - **OpenTelemetry**: Distributed tracing (silenced from console; configured for export)
122
+ - **Health check**: `GET /api/system/health` — Neo4j, Redis, Celery worker status
123
+ - **System stats**: `GET /api/system/stats` — document, entity, relationship, chunk counts
124
+ - **User stats**: `GET /api/system/my-stats` — per-user conversation and message activity
125
+
126
+ ---
127
+
128
+ ## 🏗️ Architecture
129
+
130
+ ```
131
+ ┌─────────────────────────────────────────────────────────────────────────────┐
132
+ │ React Frontend (CORTEX) │
133
+ │ Home │ Process │ Ontology │ Interact │ Simulate │ Insights │ Admin │
134
+ └─────────────────────────────┬───────────────────────────────────────────────┘
135
+ │ HTTP / SSE
136
+ ┌─────────────────────────────▼───────────────────────────────────────────────┐
137
+ │ FastAPI Gateway (port 8000) │
138
+ │ JWT Auth · RBAC Scopes · CORS · OpenTelemetry │
139
+ └──────┬──────────────────────┬──────────────────────┬────────────────────────┘
140
+ │ │ │
141
+ ┌──────▼──────┐ ┌───────────▼──────────┐ ┌───────▼────────────────────┐
142
+ │ Ingestion │ │ ReACT Agent System │ │ Report Agent (ReACT) │
143
+ │ Pipeline │ │ - Vector Search │ │ - InsightForge │
144
+ │ - Parser │ │ - Graph Traversal │ │ - PanoramaSearch │
145
+ │ - Ontology │ │ - Cypher Gen (GoT) │ │ - QuickSearch │
146
+ │ - Extractor│ │ - Community Search │ │ - Markdown output │
147
+ │ - Web │ │ - Temporal Queries │ └────────────────────────────┘
148
+ │ Crawler │ │ - LLM-as-a-Judge │
149
+ └──────┬──────┘ └─────────┬────────────┘
150
+ │ │
151
+ ┌──────▼────────────────────▼──────────────────┐
152
+ │ Neo4j 5.x Database │
153
+ │ Entities · Chunks · Relationships · │
154
+ │ Vector Index · Conversations · │
155
+ │ EvalResults · DriftReports · Users │
156
+ └───────────────────────────────────────────────┘
157
+
158
+ ┌──────▼──────────────────────┐
159
+ │ Celery Workers (Redis) │
160
+ │ - Async document ingestion │
161
+ │ - Persona generation │
162
+ │ - Simulation ticks │
163
+ └─────────────────────────────┘
164
+ ```
165
+
166
+ ---
167
+
168
+ ## 📦 Project Structure
169
+
170
+ ```
171
+ graph-RAG/
172
+ ├── src/graph_rag_service/
173
+ │ ├── api/
174
+ │ │ ├── server.py # Main FastAPI app + all API routes (1900 lines)
175
+ │ │ ├── auth.py # JWT auth + RBAC helpers
176
+ │ │ ├── admin.py # Admin sub-router
177
+ │ │ ├── simulation.py # Simulation / persona interview router
178
+ │ │ └── models.py # All Pydantic request/response models
179
+ │ ├── core/
180
+ │ │ ├── abstractions.py # Abstract base classes (GraphStore, VectorStore, LLMProvider)
181
+ │ │ ├── models.py # Domain data models
182
+ │ │ ├── neo4j_store.py # Full Neo4j implementation (graph + vector)
183
+ │ │ ├── llm_factory.py # Multi-LLM provider factory + UnifiedLLMProvider
184
+ │ │ ├── entity_resolver.py # Semantic entity deduplication
185
+ │ │ └── storage.py # File storage abstraction
186
+ │ ├── ingestion/
187
+ │ │ ├── pipeline.py # End-to-end ingestion orchestrator
188
+ │ │ ├── document_processor.py # Multi-format document parsing
189
+ │ │ ├── ontology_generator.py # LLM ontology generation + refinement
190
+ │ │ ├── extractor.py # Entity + relationship extraction
191
+ │ │ ├── web_crawler.py # Playwright-based deep web crawler (Crawl4AI)
192
+ │ │ └── persona_generator.py # Simulation persona generation
193
+ │ ├── retrieval/
194
+ │ │ ├── agent.py # LangGraph ReACT retrieval agent
195
+ │ │ ├── tools.py # Retrieval tools + RAGEvaluator (RAGAS)
196
+ │ │ └── report_agent.py # ReACT analytical report agent
197
+ │ ├── services/
198
+ │ │ ├── graph_memory_updater.py # Push raw text → live graph
199
+ │ │ ├── entity_enricher.py # LLM entity profile summaries
200
+ │ │ └── ontology_drift_detector.py # Automated schema drift detection
201
+ │ ├── workers/
202
+ │ │ └── celery_worker.py # Celery app + ingest_document_task
203
+ │ ├── observability/
204
+ │ │ └── tracing.py # OpenTelemetry setup (console suppressed)
205
+ │ ├── config.py # Pydantic settings (all env vars)
206
+ │ └── main.py # Uvicorn entry point
207
+ ├── frontend-react/
208
+ │ └── src/
209
+ │ ├── views/
210
+ │ │ ├── Home.tsx # Animated stats dashboard
211
+ │ │ ├── Process.tsx # Document upload + URL scrape/crawl
212
+ │ │ ├── Ontology.tsx # Schema editor + stats
213
+ │ │ ├── InteractionView.tsx # Streaming chat + conversation history
214
+ │ │ ├── SimulationRunView.tsx # Persona simulation UI
215
+ │ │ ├── InsightsView.tsx # Report generation + copy/download
216
+ │ │ ├── AdminDashboard.tsx # Full admin panel
217
+ │ │ └── Login.tsx # Login page
218
+ │ ├── components/
219
+ │ │ └── GraphCanvas.tsx # D3 force-directed graph + node modal
220
+ │ ├── context/
221
+ │ │ └── AuthContext.tsx # JWT auth context + hooks
222
+ │ └── App.tsx # Router + top-nav (CORTEX branding)
223
+ ├── tests/ # Test suite
224
+ ├── data/uploads/ # Uploaded documents (local storage)
225
+ ├── .env.example # All configurable environment variables
226
+ ├── pyproject.toml # Python project + uv dependencies
227
+ ├── package.json # Unified start scripts (npm run rag)
228
+ ├── ARCHITECTURE.md # Detailed architecture design doc
229
+ └── QUICKSTART.md # 5-minute quick start guide
230
+ ```
231
+
232
+ ---
233
+
234
+ ## ⚡ Quick Start
235
+
236
+ ### Prerequisites
237
+
238
+ - Python 3.12+
239
+ - Node.js 18+
240
+ - Neo4j 5.x (running) with **APOC** and **Graph Data Science (GDS)** plugins installed
241
+ - Redis (running)
242
+ - Ollama *(optional, for local LLMs)*
243
+
244
+ ### 1. Clone & Install
245
+
246
+ ```bash
247
+ git clone <repository-url>
248
+ cd graph-RAG
249
+
250
+ # Installs Python deps (uv), frontend (npm), and Playwright Chromium
251
+ npm install
252
+ ```
253
+
254
+ ### 2. Configure Environment
255
+
256
+ ```bash
257
+ cp .env.example .env
258
+ # Fill in NEO4J_URI, NEO4J_PASSWORD, and your LLM API keys
259
+ ```
260
+
261
+ ### 3. Start Neo4j
262
+
263
+ ```bash
264
+ docker run -d --name neo4j \
265
+ -p 7474:7474 -p 7687:7687 \
266
+ -e NEO4J_AUTH=neo4j/password \
267
+ neo4j:latest
268
+ ```
269
+
270
+ ### 4. Start Redis
271
+
272
+ ```bash
273
+ docker run -d --name redis -p 6379:6379 redis:alpine
274
+ ```
275
+
276
+ ### 5. Launch Everything
277
+
278
+ ```bash
279
+ npm run rag
280
+ ```
281
+
282
+ This starts three color-coded processes concurrently:
283
+
284
+ | Process | URL |
285
+ |---|---|
286
+ | **API Server** | `http://localhost:8000` |
287
+ | **API Docs** | `http://localhost:8000/docs` |
288
+ | **React Frontend** | `http://localhost:5173` |
289
+
290
+ > Default credentials: `admin` / `admin`
291
+
292
+ ---
293
+
294
+ ## 🔑 Environment Variables
295
+
296
+ Copy `.env.example` to `.env` and configure:
297
+
298
+ ```env
299
+ # Neo4j
300
+ NEO4J_URI=bolt://localhost:7687
301
+ NEO4J_USER=neo4j
302
+ NEO4J_PASSWORD=password
303
+
304
+ # Redis
305
+ REDIS_HOST=localhost
306
+ REDIS_PORT=6379
307
+
308
+ # LLM Provider (openai | anthropic | gemini | ollama)
309
+ DEFAULT_LLM_PROVIDER=gemini
310
+ GOOGLE_API_KEY=your-key-here
311
+
312
+ # Optional: OpenAI / Anthropic
313
+ OPENAI_API_KEY=sk-...
314
+ ANTHROPIC_API_KEY=sk-ant-...
315
+
316
+ # Optional: Ollama (local)
317
+ OLLAMA_BASE_URL=http://localhost:11434
318
+ OLLAMA_MODEL=deepseek-r1:7b
319
+ OLLAMA_EMBEDDING_MODEL=nomic-embed-text
320
+
321
+ # Feature flags
322
+ ENABLE_LLM_JUDGE=true
323
+
324
+ # Security
325
+ SECRET_KEY=change-this-in-production
326
+ ACCESS_TOKEN_EXPIRE_MINUTES=1440
327
+ ```
328
+
329
+ ---
330
+
331
+ ## 🌐 API Reference
332
+
333
+ ### Authentication
334
+ | Method | Endpoint | Description |
335
+ |---|---|---|
336
+ | `POST` | `/api/auth/register` | Register new user |
337
+ | `POST` | `/api/auth/login` | Login → JWT token |
338
+ | `GET` | `/api/auth/me` | Get current user info |
339
+
340
+ ### Documents
341
+ | Method | Endpoint | Description |
342
+ |---|---|---|
343
+ | `POST` | `/api/documents/upload` | Upload file (PDF, DOCX, TXT, MD, CSV, XLSX, PPTX, JSON) |
344
+ | `POST` | `/api/documents/scrape` | Scrape single URL → ingest |
345
+ | `POST` | `/api/documents/crawl` | Deep multi-page Playwright crawl → ingest *(API Only)* |
346
+ | `GET` | `/api/documents` | List all ingested documents |
347
+ | `DELETE` | `/api/documents/{id}` | Delete document + graph chunks |
348
+ | `GET` | `/api/documents/{id}/download` | Download source file |
349
+ | `GET` | `/api/documents/{id}/preview` | Preview text content |
350
+ | `GET` | `/api/documents/status/{task_id}` | Ingestion task status |
351
+
352
+ ### Query & Chat
353
+ | Method | Endpoint | Description |
354
+ |---|---|---|
355
+ | `POST` | `/api/query` | Agentic query (streaming or JSON); supports `document_id`, `use_got` |
356
+ | `GET` | `/api/conversations` | List conversation threads |
357
+ | `GET` | `/api/conversations/{id}` | Get conversation + messages |
358
+ | `DELETE` | `/api/conversations/{id}` | Delete conversation |
359
+
360
+ ### Ontology
361
+ | Method | Endpoint | Description |
362
+ |---|---|---|
363
+ | `GET` | `/api/ontology` | Get current ontology |
364
+ | `PUT` | `/api/ontology` | Update ontology (admin) |
365
+ | `POST` | `/api/ontology/refine` | LLM-powered ontology refinement |
366
+ | `GET` | `/api/ontology/stats` | Entity/relationship counts (optional doc filter) |
367
+ | `POST` | `/api/ontology/drift/detect` | Trigger drift detection |
368
+ | `GET` | `/api/ontology/drift` | List drift reports |
369
+ | `POST` | `/api/ontology/drift/{id}/approve` | Approve drift → merge into ontology |
370
+ | `POST` | `/api/ontology/drift/{id}/reject` | Reject drift report |
371
+
372
+ ### Graph
373
+ | Method | Endpoint | Description |
374
+ |---|---|---|
375
+ | `GET` | `/api/graph/visualization` | Graph nodes + edges for D3 rendering |
376
+ | `GET` | `/api/graph/export` | Export graph (json \| cypher \| graphml) |
377
+ | `POST` | `/api/graph/update` | Push raw text → merge into live graph |
378
+ | `POST` | `/api/graph/communities/assign` | Run WCC community detection |
379
+ | `GET` | `/api/graph/communities` | List top communities |
380
+
381
+ ### Entities
382
+ | Method | Endpoint | Description |
383
+ |---|---|---|
384
+ | `POST` | `/api/entities/deduplicate` | Semantic entity resolution + merge |
385
+ | `POST` | `/api/entities/enrich` | Generate LLM summaries for all entities |
386
+ | `GET` | `/api/entities/{name}/summary` | Get enriched entity profile |
387
+ | `POST` | `/api/entities/{name}/chat` | Multi-turn entity-scoped chat |
388
+ | `GET` | `/api/entities/{name}/at-time` | Temporal query (ISO 8601 date) |
389
+
390
+ ### Reports & Evaluation
391
+ | Method | Endpoint | Description |
392
+ |---|---|---|
393
+ | `POST` | `/api/report` | Generate ReACT analytical report (markdown) |
394
+ | `POST` | `/api/eval/score` | RAGAS evaluation of a Q&A pair |
395
+ | `GET` | `/api/eval/dashboard` | Evaluation history dashboard |
396
+
397
+ ### Simulation
398
+ | Method | Endpoint | Description |
399
+ |---|---|---|
400
+ | `POST` | `/api/v1/simulation/interview` | Live persona interview (in-character LLM) |
401
+ | `GET` | `/api/v1/simulation/report` | Sandbox analytical report *(API Only)* |
402
+ | `POST` | `/api/v1/simulation/generate_personas` | Queue persona generation task *(API Only)* |
403
+ | `POST` | `/api/v1/simulation/tick` | Advance simulation tick *(API Only)* |
404
+
405
+ ### System & Admin
406
+ | Method | Endpoint | Description |
407
+ |---|---|---|
408
+ | `GET` | `/api/system/health` | Neo4j + Redis + Celery health |
409
+ | `GET` | `/api/system/stats` | Document, entity, relationship counts |
410
+ | `GET` | `/api/system/my-stats` | Current user's activity stats |
411
+ | `GET` | `/api/system/formats` | Supported ingestion file formats |
412
+ | `GET` | `/api/admin/stats` | Admin-only system stats |
413
+ | `GET` | `/api/admin/users` | List all users |
414
+ | `PUT` | `/api/admin/users/{username}/role` | Update user scopes |
415
+ | `GET` | `/api/admin/tasks` | View Celery tasks |
416
+ | `GET` | `/api/admin/documents` | Admin document vault |
417
+ | `POST` | `/api/admin/documents/{id}/reingest` | Re-queue document for ingestion |
418
+ | `GET` | `/api/admin/graph/nodes` | Search graph nodes |
419
+ | `DELETE` | `/api/admin/graph/nodes/{id}` | Delete a graph node |
420
+
421
+ ---
422
+
423
+ ## 🧪 Testing
424
+
425
+ ```bash
426
+ # Run tests
427
+ uv run pytest
428
+
429
+ # With coverage
430
+ uv run pytest --cov=src/graph_rag_service
431
+ ```
432
+
433
+ ---
434
+
435
+ ## 🚀 Production Deployment
436
+
437
+ | Process | Command |
438
+ |---|---|
439
+ | **API Server** | `uv run python main.py` |
440
+ | **Celery Worker** | `uv run celery -A src.graph_rag_service.workers.celery_worker worker --loglevel=info --concurrency=4 --pool=threads` |
441
+ | **React Build** | `cd frontend-react && npm run build` |
442
+
443
+ The built React assets can be served directly by FastAPI (static file mount), or deployed to a CDN separately. Neo4j and Redis can be run via Docker, managed cloud services (AuraDB, Redis Cloud), or self-hosted.
444
+
445
+ ---
446
+
447
+ ## 📄 Additional Documentation
448
+
449
+ - **[ARCHITECTURE.md](./ARCHITECTURE.md)** — Deep dive into the system design, data flow, and component interactions
450
+ - **[QUICKSTART.md](./QUICKSTART.md)** — 5-minute environment setup guide
451
+ - **`/docs`** — Interactive Swagger UI (auto-generated from FastAPI)
452
+
453
+ ---
454
+
455
+ **Project Status**: Production-grade MVP · Actively developed
456
+ **License**: Proprietary — all rights reserved
data/uploads/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # This file ensures the data/uploads directory is created in git
fix_datetime.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ target_dir = r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service"
4
+
5
+ for root, dirs, files in os.walk(target_dir):
6
+ for f in files:
7
+ if f.endswith(".py"):
8
+ p = os.path.join(root, f)
9
+ with open(p, "r", encoding="utf-8") as file:
10
+ content = file.read()
11
+
12
+ if "datetime.utcnow()" in content:
13
+ content = content.replace("datetime.utcnow()", "datetime.now(timezone.utc).replace(tzinfo=None)")
14
+ if "from datetime import datetime" in content and "timezone" not in content:
15
+ content = content.replace("from datetime import datetime", "from datetime import datetime, timezone")
16
+ elif "import datetime" in content and "timezone" not in content:
17
+ content = "from datetime import timezone\n" + content
18
+
19
+ with open(p, "w", encoding="utf-8") as file:
20
+ file.write(content)
21
+ print(f"Updated {p}")
fix_default_factory.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import glob
3
+
4
+ for f in glob.glob(r'D:\Desktop_March_26\LYZR\graph-RAG\src\**\*.py', recursive=True):
5
+ with open(f, 'r', encoding='utf-8') as file:
6
+ content = file.read()
7
+
8
+ if 'default_factory=datetime.utcnow' in content:
9
+ new_content = content.replace('default_factory=datetime.utcnow', 'default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None)')
10
+ with open(f, 'w', encoding='utf-8') as file:
11
+ file.write(new_content)
12
+ print(f"Fixed {f}")
fix_print_statements.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import glob
3
+ import re
4
+
5
+ for f in glob.glob(r'D:\Desktop_March_26\LYZR\graph-RAG\src\**\*.py', recursive=True):
6
+ with open(f, 'r', encoding='utf-8') as file:
7
+ content = file.read()
8
+
9
+ if 'print(' in content:
10
+ # Add import logging and logger if not exists
11
+ if 'import logging' not in content:
12
+ content = "import logging\nlogger = logging.getLogger(__name__)\n" + content
13
+ elif 'logger = ' not in content:
14
+ content = content.replace('import logging\n', 'import logging\nlogger = logging.getLogger(__name__)\n', 1)
15
+
16
+ # Replace print( with logger.info(
17
+ content = re.sub(r'\bprint\(', 'logger.info(', content)
18
+
19
+ with open(f, 'w', encoding='utf-8') as file:
20
+ file.write(content)
21
+ print(f"Fixed {f}")
fix_timezone_imports.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ files = [
4
+ r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\api\auth.py",
5
+ r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\api\routers\ontology.py",
6
+ r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\api\routers\system.py",
7
+ r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\ingestion\document_processor.py",
8
+ r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\ingestion\ontology_generator.py",
9
+ r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\retrieval\report_agent.py",
10
+ r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\services\graph_memory_updater.py",
11
+ r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\services\ontology_drift_detector.py",
12
+ r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\workers\simulation_runner.py"
13
+ ]
14
+
15
+ for f in files:
16
+ with open(f, 'r', encoding='utf-8') as file:
17
+ content = file.read()
18
+
19
+ # Check if 'from datetime import datetime' exists
20
+ if "from datetime import datetime\n" in content:
21
+ content = content.replace("from datetime import datetime\n", "from datetime import datetime, timezone\n")
22
+ elif "from datetime import datetime," in content:
23
+ if "timezone" not in content:
24
+ content = content.replace("from datetime import datetime,", "from datetime import datetime, timezone,")
25
+ elif "import datetime\n" in content:
26
+ content = content.replace("import datetime\n", "import datetime\nfrom datetime import timezone\n")
27
+ else:
28
+ # Just put it at the top
29
+ content = "from datetime import timezone\n" + content
30
+
31
+ with open(f, 'w', encoding='utf-8') as file:
32
+ file.write(content)
33
+ print(f"Fixed {f}")
frontend-react/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend-react/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
+
18
+ ```js
19
+ export default defineConfig([
20
+ globalIgnores(['dist']),
21
+ {
22
+ files: ['**/*.{ts,tsx}'],
23
+ extends: [
24
+ // Other configs...
25
+
26
+ // Remove tseslint.configs.recommended and replace with this
27
+ tseslint.configs.recommendedTypeChecked,
28
+ // Alternatively, use this for stricter rules
29
+ tseslint.configs.strictTypeChecked,
30
+ // Optionally, add this for stylistic rules
31
+ tseslint.configs.stylisticTypeChecked,
32
+
33
+ // Other configs...
34
+ ],
35
+ languageOptions: {
36
+ parserOptions: {
37
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
+ tsconfigRootDir: import.meta.dirname,
39
+ },
40
+ // other options...
41
+ },
42
+ },
43
+ ])
44
+ ```
45
+
46
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
+
48
+ ```js
49
+ // eslint.config.js
50
+ import reactX from 'eslint-plugin-react-x'
51
+ import reactDom from 'eslint-plugin-react-dom'
52
+
53
+ export default defineConfig([
54
+ globalIgnores(['dist']),
55
+ {
56
+ files: ['**/*.{ts,tsx}'],
57
+ extends: [
58
+ // Other configs...
59
+ // Enable lint rules for React
60
+ reactX.configs['recommended-typescript'],
61
+ // Enable lint rules for React DOM
62
+ reactDom.configs.recommended,
63
+ ],
64
+ languageOptions: {
65
+ parserOptions: {
66
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
+ tsconfigRootDir: import.meta.dirname,
68
+ },
69
+ // other options...
70
+ },
71
+ },
72
+ ])
73
+ ```
frontend-react/eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
frontend-react/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend-react</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
frontend-react/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend-react/package.json ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend-react",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview",
11
+ "server": "cd ../ && .\\venv\\Scripts\\python.exe -m uvicorn src.graph_rag_service.api.server:app --reload",
12
+ "worker": "cd ../ && .\\venv\\Scripts\\celery.exe -A src.graph_rag_service.workers.celery_worker worker --loglevel=info --pool=solo",
13
+ "backend": "concurrently \"npm run server\" \"npm run worker\""
14
+ },
15
+ "dependencies": {
16
+ "@heroicons/react": "^2.2.0",
17
+ "@types/d3": "^7.4.3",
18
+ "d3": "^7.9.0",
19
+ "lucide-react": "^1.7.0",
20
+ "react": "^19.2.4",
21
+ "react-dom": "^19.2.4",
22
+ "react-markdown": "^10.1.0",
23
+ "react-router-dom": "^7.13.2",
24
+ "remark-gfm": "^4.0.1"
25
+ },
26
+ "devDependencies": {
27
+ "@eslint/js": "^9.39.4",
28
+ "@tailwindcss/vite": "^4.2.4",
29
+ "@types/node": "^24.12.0",
30
+ "@types/react": "^19.2.14",
31
+ "@types/react-dom": "^19.2.3",
32
+ "@vitejs/plugin-react": "^6.0.1",
33
+ "autoprefixer": "^10.5.0",
34
+ "concurrently": "^9.2.1",
35
+ "eslint": "^9.39.4",
36
+ "eslint-plugin-react-hooks": "^7.0.1",
37
+ "eslint-plugin-react-refresh": "^0.5.2",
38
+ "globals": "^17.4.0",
39
+ "openapi-typescript-codegen": "^0.30.0",
40
+ "postcss": "^8.5.10",
41
+ "tailwindcss": "^4.2.4",
42
+ "typescript": "~5.9.3",
43
+ "typescript-eslint": "^8.57.0",
44
+ "vite": "^8.0.1"
45
+ }
46
+ }
frontend-react/public/_redirects ADDED
@@ -0,0 +1 @@
 
 
1
+ /* /index.html 200
frontend-react/public/favicon.svg ADDED
frontend-react/public/icons.svg ADDED
frontend-react/public/thumbnail.png ADDED

Git LFS Details

  • SHA256: 9c0fd3b4ca20ef442918b0cd3504825b0a966e94d681732c036298395d5a773a
  • Pointer size: 131 Bytes
  • Size of remote file: 545 kB
frontend-react/src/App.css ADDED
@@ -0,0 +1 @@
 
 
1
+ /* Empty file to override default vite styles */
frontend-react/src/App.tsx ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { BrowserRouter, Routes, Route, Navigate, NavLink } from 'react-router-dom';
3
+ import { AuthProvider, useAuth } from './context/AuthContext';
4
+
5
+ import Login from './views/Login';
6
+ import Home from './views/Home';
7
+ import Process from './views/Process';
8
+ import InteractionView from './views/InteractionView';
9
+ import SimulationRunView from './views/SimulationRunView';
10
+ import Ontology from './views/Ontology';
11
+ import InsightsView from './views/InsightsView';
12
+ import AdminDashboard from './views/AdminDashboard';
13
+
14
+ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
15
+ const { isAuthenticated } = useAuth();
16
+ if (!isAuthenticated) return <Navigate to="/login" />;
17
+ return children;
18
+ };
19
+
20
+ const Navigation: React.FC = () => {
21
+ const { logout, user } = useAuth();
22
+ return (
23
+ <nav className="top-nav">
24
+ <div className="nav-brand">CORTEX</div>
25
+
26
+ <div className="nav-links">
27
+ <NavLink to="/" end className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>HOME</NavLink>
28
+ <NavLink to="/process" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>PROCESS</NavLink>
29
+ <NavLink to="/ontology" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>ONTOLOGY</NavLink>
30
+ <NavLink to="/interact" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>INTERACT</NavLink>
31
+ <NavLink to="/simulate" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>SIMULATE</NavLink>
32
+ <NavLink to="/insights" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>INSIGHTS</NavLink>
33
+ {user?.scopes?.includes('admin') && (
34
+ <NavLink to="/admin" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>ADMIN</NavLink>
35
+ )}
36
+ </div>
37
+
38
+ <div className="nav-right">
39
+ <span className="user-badge" title={`Logged in as ${user?.username}`}>
40
+ {user?.username}
41
+ </span>
42
+ <button onClick={logout} className="logout-btn">LOGOUT</button>
43
+ </div>
44
+
45
+ <style>{`
46
+ .top-nav {
47
+ position: sticky;
48
+ top: 0;
49
+ z-index: 1000;
50
+ padding: 0 2rem;
51
+ height: 60px;
52
+ border-bottom: 2px solid #000;
53
+ background: #fff;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: space-between;
57
+ gap: 1rem;
58
+ }
59
+
60
+ .nav-brand {
61
+ font-family: var(--font-mono);
62
+ font-weight: 700;
63
+ letter-spacing: 3px;
64
+ font-size: 1rem;
65
+ white-space: nowrap;
66
+ flex-shrink: 0;
67
+ }
68
+
69
+ .nav-links {
70
+ display: flex;
71
+ gap: 0;
72
+ align-items: stretch;
73
+ height: 100%;
74
+ }
75
+
76
+ .nav-link {
77
+ display: flex;
78
+ align-items: center;
79
+ padding: 0 1rem;
80
+ font-family: var(--font-mono);
81
+ font-size: 0.78rem;
82
+ font-weight: 600;
83
+ letter-spacing: 1px;
84
+ text-decoration: none;
85
+ color: #000;
86
+ border-left: 1px solid transparent;
87
+ border-right: 1px solid transparent;
88
+ transition: background 0.15s ease, color 0.15s ease;
89
+ white-space: nowrap;
90
+ }
91
+
92
+ .nav-link:hover {
93
+ background: #f0f0f0;
94
+ color: #000;
95
+ }
96
+
97
+ .nav-link.active {
98
+ background: #000;
99
+ color: #fff;
100
+ border-color: #000;
101
+ }
102
+
103
+ .nav-right {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 0.75rem;
107
+ flex-shrink: 0;
108
+ }
109
+
110
+ .user-badge {
111
+ font-family: var(--font-mono);
112
+ font-size: 0.75rem;
113
+ font-weight: 700;
114
+ background: #000;
115
+ color: #fff;
116
+ padding: 0.2rem 0.6rem;
117
+ letter-spacing: 0.5px;
118
+ max-width: 120px;
119
+ overflow: hidden;
120
+ text-overflow: ellipsis;
121
+ white-space: nowrap;
122
+ }
123
+
124
+ .logout-btn {
125
+ font-family: var(--font-mono);
126
+ font-size: 0.75rem;
127
+ font-weight: 700;
128
+ background: transparent;
129
+ color: #000;
130
+ border: 1.5px solid #000;
131
+ padding: 0.25rem 0.75rem;
132
+ cursor: pointer;
133
+ letter-spacing: 0.5px;
134
+ transition: all 0.15s ease;
135
+ white-space: nowrap;
136
+ }
137
+ .logout-btn:hover {
138
+ background: #000;
139
+ color: #fff;
140
+ }
141
+
142
+ @media (max-width: 768px) {
143
+ .top-nav { padding: 0 1rem; height: auto; flex-wrap: wrap; padding: 0.5rem 1rem; }
144
+ .nav-link { padding: 0.4rem 0.6rem; font-size: 0.7rem; }
145
+ .nav-brand { width: 100%; padding: 0.25rem 0; }
146
+ }
147
+ `}</style>
148
+ </nav>
149
+ );
150
+ };
151
+
152
+ const Layout = ({ children }: { children: React.ReactNode }) => (
153
+ <>
154
+ <Navigation />
155
+ {children}
156
+ </>
157
+ );
158
+
159
+ function App() {
160
+ return (
161
+ <AuthProvider>
162
+ <BrowserRouter>
163
+ <Routes>
164
+ <Route path="/login" element={<Login />} />
165
+
166
+ <Route path="/" element={<ProtectedRoute><Layout><Home /></Layout></ProtectedRoute>} />
167
+ <Route path="/process" element={<ProtectedRoute><Layout><Process /></Layout></ProtectedRoute>} />
168
+ <Route path="/ontology" element={<ProtectedRoute><Layout><Ontology /></Layout></ProtectedRoute>} />
169
+ <Route path="/interact" element={<ProtectedRoute><Layout><InteractionView /></Layout></ProtectedRoute>} />
170
+ <Route path="/simulate" element={<ProtectedRoute><Layout><SimulationRunView /></Layout></ProtectedRoute>} />
171
+ <Route path="/insights" element={<ProtectedRoute><Layout><InsightsView /></Layout></ProtectedRoute>} />
172
+ <Route path="/admin" element={<ProtectedRoute><Layout><AdminDashboard /></Layout></ProtectedRoute>} />
173
+ </Routes>
174
+ </BrowserRouter>
175
+ </AuthProvider>
176
+ );
177
+ }
178
+
179
+ export default App;
frontend-react/src/assets/hero.png ADDED

Git LFS Details

  • SHA256: 72a860570eddf1dd9988f26c7106c67be286bc9f2fd3303c465ce87edb1ae6cd
  • Pointer size: 130 Bytes
  • Size of remote file: 44.9 kB
frontend-react/src/assets/react.svg ADDED
frontend-react/src/assets/vite.svg ADDED
frontend-react/src/components/GraphCanvas.tsx ADDED
@@ -0,0 +1,570 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, forwardRef, useImperativeHandle, useState } from 'react';
2
+ import * as d3 from 'd3';
3
+ import { X, Tag, FileText, Database } from 'lucide-react';
4
+
5
+ // 12-color categorical palette for node types
6
+ const TYPE_COLORS = [
7
+ '#e63946', '#457b9d', '#2a9d8f', '#e9c46a', '#f4a261',
8
+ '#6a4c93', '#1982c4', '#8ac926', '#ff595e', '#6a994e',
9
+ '#bc4749', '#a8dadc'
10
+ ];
11
+
12
+ export interface GraphOptions {
13
+ colorByType: boolean;
14
+ showLabels: boolean;
15
+ showEdgeLabels: boolean;
16
+ nodeRadius: number;
17
+ linkDistance: number;
18
+ chargeStrength: number;
19
+ showCurvedEdges: boolean;
20
+ nodeSizeByDegree: boolean;
21
+ centerGravity: number; // 0 = no gravity, 0.1 = default
22
+ }
23
+
24
+ export const DEFAULT_OPTIONS: GraphOptions = {
25
+ colorByType: true,
26
+ showLabels: true,
27
+ showEdgeLabels: false,
28
+ nodeRadius: 16,
29
+ linkDistance: 120,
30
+ chargeStrength: -300,
31
+ showCurvedEdges: false,
32
+ nodeSizeByDegree: false,
33
+ centerGravity: 0.05,
34
+ };
35
+
36
+ interface GraphCanvasProps {
37
+ data: { nodes: any[]; edges: any[] };
38
+ onNodeUpdate?: (nodeId: string, newName: string) => void;
39
+ options?: GraphOptions;
40
+ highlightNodeIds?: Set<string>; // nodes to highlight (from search)
41
+ }
42
+
43
+ export interface GraphCanvasHandle {
44
+ exportPNG: () => void;
45
+ exportSVG: () => void;
46
+ fitView: () => void;
47
+ highlightNode: (id: string) => void;
48
+ }
49
+
50
+ const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
51
+ ({ data, onNodeUpdate, options = DEFAULT_OPTIONS, highlightNodeIds }, ref) => {
52
+ const [activeNode, setActiveNode] = useState<any>(null);
53
+ const containerRef = useRef<HTMLDivElement>(null);
54
+ const svgRef = useRef<SVGSVGElement>(null);
55
+ const zoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);
56
+ const gRef = useRef<d3.Selection<SVGGElement, unknown, null, undefined> | null>(null);
57
+ const simulationRef = useRef<d3.Simulation<any, any> | null>(null);
58
+ const typeColorMap = useRef<Map<string, string>>(new Map());
59
+
60
+ // ── Imperative API ─────────────────────────────────────────────────────
61
+ useImperativeHandle(ref, () => ({
62
+ exportPNG() {
63
+ if (!svgRef.current) return;
64
+ const svgEl = svgRef.current;
65
+ const serializer = new XMLSerializer();
66
+ const svgStr = serializer.serializeToString(svgEl);
67
+ const canvas = document.createElement('canvas');
68
+ canvas.width = svgEl.clientWidth * 2;
69
+ canvas.height = svgEl.clientHeight * 2;
70
+ const ctx = canvas.getContext('2d')!;
71
+ const img = new Image();
72
+ const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
73
+ const url = URL.createObjectURL(blob);
74
+ img.onload = () => {
75
+ ctx.fillStyle = '#fff';
76
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
77
+ ctx.scale(2, 2);
78
+ ctx.drawImage(img, 0, 0);
79
+ URL.revokeObjectURL(url);
80
+ const a = document.createElement('a');
81
+ a.download = 'graph.png';
82
+ a.href = canvas.toDataURL('image/png');
83
+ a.click();
84
+ };
85
+ img.src = url;
86
+ },
87
+ exportSVG() {
88
+ if (!svgRef.current) return;
89
+ const serializer = new XMLSerializer();
90
+ const svgStr = serializer.serializeToString(svgRef.current);
91
+ const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
92
+ const url = URL.createObjectURL(blob);
93
+ const a = document.createElement('a');
94
+ a.download = 'graph.svg';
95
+ a.href = url;
96
+ a.click();
97
+ URL.revokeObjectURL(url);
98
+ },
99
+ fitView() {
100
+ if (!svgRef.current || !zoomRef.current) return;
101
+ d3.select(svgRef.current).transition().duration(600).call(
102
+ zoomRef.current.transform, d3.zoomIdentity
103
+ );
104
+ },
105
+ highlightNode(id: string) {
106
+ if (!svgRef.current || !zoomRef.current) return;
107
+ const node = simulationRef.current?.nodes().find((n: any) => n.id === id);
108
+ if (!node || node.x === undefined) return;
109
+ const svg = d3.select(svgRef.current);
110
+ const w = svgRef.current.clientWidth;
111
+ const h = svgRef.current.clientHeight;
112
+ const t = d3.zoomIdentity.translate(w / 2 - node.x, h / 2 - node.y);
113
+ svg.transition().duration(700).call(zoomRef.current.transform, t);
114
+ }
115
+ }));
116
+
117
+ // ── Main D3 render effect ──────────────────────────────────────────────
118
+ useEffect(() => {
119
+ if (!data.nodes.length || !containerRef.current || !svgRef.current) return;
120
+
121
+ const width = containerRef.current.clientWidth;
122
+ const height = containerRef.current.clientHeight;
123
+
124
+ // Build type→color map (stable)
125
+ const types = [...new Set(data.nodes.map(n => n.type || 'Unknown'))];
126
+ types.forEach((t, i) => {
127
+ if (!typeColorMap.current.has(t)) {
128
+ typeColorMap.current.set(t, TYPE_COLORS[i % TYPE_COLORS.length]);
129
+ }
130
+ });
131
+
132
+ const svg = d3.select(svgRef.current);
133
+ svg.selectAll('*').remove();
134
+
135
+ const nodes: any[] = data.nodes.map(d => ({ ...d }));
136
+ const nodeIds = new Set(nodes.map(n => n.id));
137
+ const links: any[] = data.edges
138
+ .filter(d => nodeIds.has(d.source) && nodeIds.has(d.target))
139
+ .map(d => ({ ...d }));
140
+
141
+ // Degree map for node-size-by-degree
142
+ const degreeMap = new Map<string, number>();
143
+ nodes.forEach(n => degreeMap.set(n.id, 0));
144
+ links.forEach(l => {
145
+ const sid = typeof l.source === 'object' ? l.source.id : l.source;
146
+ const tid = typeof l.target === 'object' ? l.target.id : l.target;
147
+ degreeMap.set(sid, (degreeMap.get(sid) || 0) + 1);
148
+ degreeMap.set(tid, (degreeMap.get(tid) || 0) + 1);
149
+ });
150
+ const maxDegree = Math.max(1, ...degreeMap.values());
151
+
152
+ const nodeR = (d: any) => {
153
+ if (!options.nodeSizeByDegree) return options.nodeRadius;
154
+ const deg = degreeMap.get(d.id) || 0;
155
+ return Math.max(8, options.nodeRadius * (0.5 + 1.0 * (deg / maxDegree)));
156
+ };
157
+
158
+ // ── Defs: arrowhead markers ──────────────────────────────────────────
159
+ const defs = svg.append('defs');
160
+ if (options.colorByType) {
161
+ types.forEach(t => {
162
+ const color = typeColorMap.current.get(t) || '#000';
163
+ defs.append('marker')
164
+ .attr('id', `arrow-${t.replace(/\s+/g, '_')}`)
165
+ .attr('viewBox', '-0 -5 10 10').attr('refX', options.nodeRadius + 10)
166
+ .attr('refY', 0).attr('orient', 'auto')
167
+ .attr('markerWidth', 6).attr('markerHeight', 6)
168
+ .append('path').attr('d', 'M 0,-5 L 10,0 L 0,5').attr('fill', color);
169
+ });
170
+ } else {
171
+ defs.append('marker')
172
+ .attr('id', 'arrow-default')
173
+ .attr('viewBox', '-0 -5 10 10').attr('refX', options.nodeRadius + 10)
174
+ .attr('refY', 0).attr('orient', 'auto')
175
+ .attr('markerWidth', 6).attr('markerHeight', 6)
176
+ .append('path').attr('d', 'M 0,-5 L 10,0 L 0,5').attr('fill', '#666');
177
+ }
178
+
179
+ // ── Force simulation ──────────────────────────────────────────────────
180
+ const sim = d3.forceSimulation(nodes)
181
+ .force('link', d3.forceLink(links).id((d: any) => d.id).distance(options.linkDistance))
182
+ .force('charge', d3.forceManyBody().strength(options.chargeStrength))
183
+ .force('center', d3.forceCenter(width / 2, height / 2).strength(options.centerGravity))
184
+ .force('collide', d3.forceCollide().radius((d: any) => nodeR(d) + 14));
185
+
186
+ simulationRef.current = sim;
187
+
188
+ const g = svg.append('g').attr('class', 'graph-root');
189
+ gRef.current = g;
190
+
191
+ // ── Tooltip ───────────────────────────────────────────────────────────
192
+ const tooltip = d3.select(containerRef.current)
193
+ .selectAll('.graph-tooltip').data([null]).join('div')
194
+ .attr('class', 'graph-tooltip')
195
+ .style('position', 'absolute')
196
+ .style('pointer-events', 'none')
197
+ .style('background', '#000')
198
+ .style('color', '#fff')
199
+ .style('padding', '6px 12px')
200
+ .style('font-family', '"JetBrains Mono", monospace')
201
+ .style('font-size', '11px')
202
+ .style('line-height', '1.5')
203
+ .style('opacity', 0)
204
+ .style('border', '1px solid #333')
205
+ .style('z-index', '999')
206
+ .style('max-width', '220px')
207
+ .style('word-break', 'break-word');
208
+
209
+ // ── Links ────────────────────────────────────────────────────────────
210
+ const linkG = g.append('g').attr('class', 'links');
211
+
212
+ // Adjacency set for hover highlight
213
+ const adjacentIds = new Set<string>();
214
+
215
+ // Straight lines or curved paths
216
+ const linkEl = options.showCurvedEdges
217
+ ? linkG.selectAll('path').data(links).enter().append('path')
218
+ .attr('fill', 'none')
219
+ .attr('stroke', (d: any) => {
220
+ if (!options.colorByType) return '#aaa';
221
+ const srcNode = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
222
+ return srcNode ? (typeColorMap.current.get(srcNode.type) || '#aaa') : '#aaa';
223
+ })
224
+ .attr('stroke-width', 1.5)
225
+ .attr('stroke-opacity', 0.55)
226
+ .attr('marker-end', (d: any) => {
227
+ if (!options.colorByType) return 'url(#arrow-default)';
228
+ const srcNode = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
229
+ const t = srcNode?.type?.replace(/\s+/g, '_') || 'Unknown';
230
+ return `url(#arrow-${t})`;
231
+ })
232
+ : linkG.selectAll('line').data(links).enter().append('line')
233
+ .attr('stroke', (d: any) => {
234
+ if (!options.colorByType) return '#aaa';
235
+ const srcNode = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
236
+ return srcNode ? (typeColorMap.current.get(srcNode.type) || '#aaa') : '#aaa';
237
+ })
238
+ .attr('stroke-width', 1.5)
239
+ .attr('stroke-opacity', 0.55)
240
+ .attr('marker-end', (d: any) => {
241
+ if (!options.colorByType) return 'url(#arrow-default)';
242
+ const srcNode = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
243
+ const t = srcNode?.type?.replace(/\s+/g, '_') || 'Unknown';
244
+ return `url(#arrow-${t})`;
245
+ });
246
+
247
+ // ── Nodes ─────────────────────────────────────────────────────────────
248
+ const node = g.append('g').attr('class', 'nodes')
249
+ .selectAll<SVGGElement, any>('g').data(nodes).enter().append('g')
250
+ .call(d3.drag<SVGGElement, any>()
251
+ .on('start', (event, d) => { if (!event.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
252
+ .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
253
+ .on('end', (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
254
+ )
255
+ .on('mouseover', (_, d: any) => {
256
+ // Build adjacent set
257
+ adjacentIds.clear();
258
+ adjacentIds.add(d.id);
259
+ links.forEach(l => {
260
+ const sid = typeof l.source === 'object' ? l.source.id : l.source;
261
+ const tid = typeof l.target === 'object' ? l.target.id : l.target;
262
+ if (sid === d.id) adjacentIds.add(tid);
263
+ if (tid === d.id) adjacentIds.add(sid);
264
+ });
265
+ // Dim non-adjacent
266
+ node.select('circle')
267
+ .style('opacity', (n: any) => adjacentIds.has(n.id) ? 1 : 0.15)
268
+ .style('stroke-width', (n: any) => n.id === d.id ? 4 : 2);
269
+ (linkEl as any)
270
+ .style('stroke-opacity', (l: any) => {
271
+ const sid = typeof l.source === 'object' ? l.source.id : l.source;
272
+ const tid = typeof l.target === 'object' ? l.target.id : l.target;
273
+ return (sid === d.id || tid === d.id) ? 0.9 : 0.04;
274
+ });
275
+ tooltip
276
+ .style('opacity', 1)
277
+ .html(`<strong>${d.label}</strong><br/>ID: ${d.id}<br/>Type: ${d.type || '—'}<br/>Degree: ${degreeMap.get(d.id) || 0}`);
278
+ })
279
+ .on('mousemove', (event) => {
280
+ const rect = containerRef.current!.getBoundingClientRect();
281
+ tooltip
282
+ .style('left', (event.clientX - rect.left + 14) + 'px')
283
+ .style('top', (event.clientY - rect.top - 32) + 'px');
284
+ })
285
+ .on('mouseout', () => {
286
+ adjacentIds.clear();
287
+ node.select('circle')
288
+ .style('opacity', (n: any) => {
289
+ if (!highlightNodeIds || highlightNodeIds.size === 0) return 1;
290
+ return highlightNodeIds.has(n.id) ? 1 : 0.2;
291
+ })
292
+ .style('stroke-width', (n: any) => highlightNodeIds?.has(n.id) ? 4 : 2);
293
+ (linkEl as any).style('stroke-opacity', 0.55);
294
+ tooltip.style('opacity', 0);
295
+ })
296
+ .on('click', (event, d: any) => {
297
+ setActiveNode(d);
298
+ // Zoom to node on single click
299
+ if (!svgRef.current || !zoomRef.current) return;
300
+ const w = svgRef.current.clientWidth;
301
+ const h = svgRef.current.clientHeight;
302
+ const t = d3.zoomIdentity.translate(w / 2 - d.x, h / 2 - d.y).scale(1.4);
303
+ d3.select(svgRef.current).transition().duration(500).call(zoomRef.current.transform, t);
304
+ event.stopPropagation();
305
+ })
306
+ .on('dblclick', (event, d: any) => {
307
+ const newName = window.prompt('Update entity name:', d.label);
308
+ if (newName && newName.trim() && newName.trim() !== d.label) {
309
+ const updated = newName.trim();
310
+ d.label = updated;
311
+ d3.select(event.currentTarget).select('text.node-label').text(
312
+ updated.length > 18 ? updated.substring(0, 16) + '…' : updated
313
+ );
314
+ if (onNodeUpdate) onNodeUpdate(d.id, updated);
315
+ }
316
+ });
317
+
318
+ // Circle
319
+ node.append('circle')
320
+ .attr('r', (d: any) => nodeR(d))
321
+ .attr('fill', (d: any) => options.colorByType
322
+ ? (typeColorMap.current.get(d.type) || '#ccc')
323
+ : '#fff')
324
+ .attr('stroke', (d: any) => {
325
+ if (highlightNodeIds && highlightNodeIds.size > 0) {
326
+ return highlightNodeIds.has(d.id) ? '#ff0' : (options.colorByType
327
+ ? d3.color(typeColorMap.current.get(d.type) || '#ccc')!.darker(1).toString()
328
+ : '#000');
329
+ }
330
+ return options.colorByType
331
+ ? d3.color(typeColorMap.current.get(d.type) || '#ccc')!.darker(1).toString()
332
+ : '#000';
333
+ })
334
+ .attr('stroke-width', (d: any) => highlightNodeIds?.has(d.id) ? 4 : 2)
335
+ .style('opacity', (d: any) => {
336
+ if (!highlightNodeIds || highlightNodeIds.size === 0) return 1;
337
+ return highlightNodeIds.has(d.id) ? 1 : 0.2;
338
+ })
339
+ .style('filter', 'drop-shadow(1px 2px 3px rgba(0,0,0,0.15))')
340
+ .style('cursor', 'pointer');
341
+
342
+ // Type abbreviation inside circle
343
+ node.append('text')
344
+ .attr('class', 'node-type-badge')
345
+ .text((d: any) => (d.type || '?').substring(0, 2).toUpperCase())
346
+ .attr('text-anchor', 'middle').attr('dy', '0.35em')
347
+ .style('font-family', '"JetBrains Mono", monospace')
348
+ .style('font-size', (d: any) => `${Math.max(8, nodeR(d) - 6)}px`)
349
+ .style('font-weight', '700')
350
+ .style('fill', (d: any) => {
351
+ if (!options.colorByType) return '#000';
352
+ const c = d3.color(typeColorMap.current.get(d.type) || '#ccc');
353
+ if (!c) return '#000';
354
+ const { r, g: gv, b } = c.rgb();
355
+ return (r * 0.299 + gv * 0.587 + b * 0.114) > 150 ? '#111' : '#fff';
356
+ })
357
+ .style('pointer-events', 'none');
358
+
359
+ // Node name label below circle
360
+ if (options.showLabels) {
361
+ node.append('text')
362
+ .attr('class', 'node-label')
363
+ .text((d: any) => d.label && d.label.length > 18 ? d.label.substring(0, 16) + '…' : d.label)
364
+ .attr('text-anchor', 'middle')
365
+ .attr('dy', (d: any) => nodeR(d) + 14)
366
+ .style('font-family', '"JetBrains Mono", monospace')
367
+ .style('font-size', '10px')
368
+ .style('font-weight', '600')
369
+ .style('fill', '#222')
370
+ .style('pointer-events', 'none');
371
+ }
372
+
373
+ // Edge labels
374
+ let edgeLabel: d3.Selection<SVGTextElement, any, SVGGElement, unknown> | null = null;
375
+ if (options.showEdgeLabels) {
376
+ edgeLabel = g.append('g').attr('class', 'edge-labels')
377
+ .selectAll('text').data(links).enter().append('text')
378
+ .text((d: any) => d.type || '')
379
+ .style('font-family', '"JetBrains Mono", monospace')
380
+ .style('font-size', '9px')
381
+ .style('fill', '#777')
382
+ .style('text-anchor', 'middle')
383
+ .style('pointer-events', 'none')
384
+ .attr('dy', -5);
385
+ }
386
+
387
+ // ── Tick ──────────────────────────────────────────────────────────────
388
+ sim.on('tick', () => {
389
+ if (options.showCurvedEdges) {
390
+ (linkEl as d3.Selection<SVGPathElement, any, SVGGElement, unknown>)
391
+ .attr('d', (d: any) => {
392
+ const sx = d.source.x, sy = d.source.y;
393
+ const tx = d.target.x, ty = d.target.y;
394
+ const dx = tx - sx, dy = ty - sy;
395
+ const dr = Math.sqrt(dx * dx + dy * dy) * 0.8;
396
+ return `M${sx},${sy}A${dr},${dr} 0 0,1 ${tx},${ty}`;
397
+ });
398
+ } else {
399
+ (linkEl as d3.Selection<SVGLineElement, any, SVGGElement, unknown>)
400
+ .attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
401
+ .attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y);
402
+ }
403
+ node.attr('transform', (d: any) => `translate(${d.x},${d.y})`);
404
+ if (edgeLabel) {
405
+ edgeLabel
406
+ .attr('x', (d: any) => (d.source.x + d.target.x) / 2)
407
+ .attr('y', (d: any) => (d.source.y + d.target.y) / 2);
408
+ }
409
+ });
410
+
411
+ // ── Zoom / Pan ────────────────────────────────────────────────────────
412
+ const zoom = d3.zoom<SVGSVGElement, unknown>()
413
+ .scaleExtent([0.03, 8])
414
+ .on('zoom', (event) => g.attr('transform', event.transform));
415
+
416
+ svg.call(zoom);
417
+ // Click on SVG background resets highlighting and active node
418
+ svg.on('click', () => {
419
+ setActiveNode(null);
420
+ node.select('circle').style('opacity', 1).style('stroke-width', 2);
421
+ (linkEl as any).style('stroke-opacity', 0.55);
422
+ });
423
+
424
+ zoomRef.current = zoom;
425
+
426
+ return () => { sim.stop(); };
427
+ }, [data, options, highlightNodeIds]);
428
+
429
+ return (
430
+ <div ref={containerRef} style={{ width: '100%', height: '100%', position: 'relative', overflow: 'hidden' }}>
431
+ <svg ref={svgRef} width="100%" height="100%" />
432
+
433
+ {/* ── Node details modal ────────────────────────────────────────────── */}
434
+ {activeNode && (
435
+ <div className="gc-node-modal">
436
+ <div className="gc-node-modal-header">
437
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
438
+ <h3 className="mono-text" style={{ margin: 0, fontSize: '0.85rem' }}>NODE DETAILS</h3>
439
+ <span className="gc-node-badge" style={{ background: typeColorMap.current.get(activeNode.type) || '#000' }}>
440
+ {activeNode.type || 'Unknown'}
441
+ </span>
442
+ </div>
443
+ <button className="gc-node-close" onClick={() => setActiveNode(null)}>
444
+ <X size={16} />
445
+ </button>
446
+ </div>
447
+
448
+ <div className="gc-node-modal-body">
449
+ <div className="gc-node-row">
450
+ <span className="gc-node-key"><Tag size={12}/> Name:</span>
451
+ <span className="gc-node-val" style={{ fontWeight: 600 }}>{activeNode.label || '—'}</span>
452
+ </div>
453
+ <div className="gc-node-row">
454
+ <span className="gc-node-key"><Database size={12}/> UUID:</span>
455
+ <span className="gc-node-val" style={{ wordBreak: 'break-all', fontSize: '0.7em' }}>{activeNode.id}</span>
456
+ </div>
457
+
458
+ {activeNode.properties && Object.keys(activeNode.properties).length > 0 && (
459
+ <>
460
+ <div className="gc-node-divider" />
461
+ <div className="gc-node-section-title">PROPERTIES</div>
462
+ <div className="gc-node-props">
463
+ {Object.entries(activeNode.properties).map(([k, v]) => (
464
+ <div className="gc-node-prop-item" key={k}>
465
+ <span className="gc-node-prop-k">{k}:</span>
466
+ <span className="gc-node-prop-v">{String(v)}</span>
467
+ </div>
468
+ ))}
469
+ </div>
470
+ </>
471
+ )}
472
+
473
+ {activeNode.description && (
474
+ <>
475
+ <div className="gc-node-divider" />
476
+ <div className="gc-node-section-title"><FileText size={12}/> DESCRIPTION / SUMMARY</div>
477
+ <div className="gc-node-summary">
478
+ {activeNode.description}
479
+ </div>
480
+ </>
481
+ )}
482
+ </div>
483
+ </div>
484
+ )}
485
+
486
+ <style>{`
487
+ .gc-node-modal {
488
+ position: absolute;
489
+ top: 20px;
490
+ right: 20px;
491
+ width: 320px;
492
+ max-height: calc(100% - 40px);
493
+ background: #fff;
494
+ border: 3px solid #000;
495
+ box-shadow: 6px 6px 0 rgba(0,0,0,0.1);
496
+ display: flex;
497
+ flex-direction: column;
498
+ z-index: 1000;
499
+ animation: slideInR 0.15s ease-out;
500
+ }
501
+ @keyframes slideInR { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
502
+
503
+ .gc-node-modal-header {
504
+ padding: 0.75rem 1rem;
505
+ border-bottom: 3px solid #000;
506
+ background: #fafafa;
507
+ display: flex;
508
+ align-items: center;
509
+ justify-content: space-between;
510
+ }
511
+ .gc-node-close {
512
+ background: none; border: none; cursor: pointer; padding: 2px; display: flex; align-items: center; opacity: 0.5; transition: 0.12s;
513
+ }
514
+ .gc-node-close:hover { opacity: 1; color: #ef4444; }
515
+
516
+ .gc-node-badge {
517
+ color: #fff;
518
+ font-size: 0.6rem;
519
+ font-family: var(--font-mono);
520
+ font-weight: 700;
521
+ padding: 2px 6px;
522
+ border-radius: 20px;
523
+ text-transform: uppercase;
524
+ }
525
+
526
+ .gc-node-modal-body {
527
+ padding: 1rem;
528
+ overflow-y: auto;
529
+ flex: 1;
530
+ display: flex;
531
+ flex-direction: column;
532
+ gap: 0.6rem;
533
+ background: #fff;
534
+ }
535
+
536
+ .gc-node-row {
537
+ display: flex;
538
+ align-items: flex-start;
539
+ gap: 0.75rem;
540
+ font-family: var(--font-mono);
541
+ font-size: 0.8rem;
542
+ }
543
+ .gc-node-key { width: 60px; flex-shrink: 0; color: #777; display: flex; align-items: center; gap: 4px; }
544
+ .gc-node-val { flex: 1; color: #111; }
545
+
546
+ .gc-node-divider { height: 1px; border-bottom: 1px dashed #ccc; margin: 0.4rem 0; }
547
+ .gc-node-section-title { font-family: var(--font-mono); font-size: 0.7rem; font-weight: 700; color: #444; letter-spacing: 1px; margin-bottom: 0.2rem; display: flex; align-items: center; gap: 6px; }
548
+
549
+ .gc-node-props { display: flex; flex-direction: column; gap: 4px; background: #f8f8f8; border: 1px solid #ddd; padding: 0.5rem; }
550
+ .gc-node-prop-item { font-family: var(--font-mono); font-size: 0.7rem; display: flex; gap: 6px; align-items: flex-start; }
551
+ .gc-node-prop-k { color: #555; }
552
+ .gc-node-prop-v { color: #111; word-break: break-all; }
553
+
554
+ .gc-node-summary {
555
+ font-family: var(--font-mono);
556
+ font-size: 0.75rem;
557
+ line-height: 1.5;
558
+ color: #333;
559
+ background: #fff9c4;
560
+ border-left: 3px solid #fbc02d;
561
+ padding: 0.5rem 0.75rem;
562
+ }
563
+ `}</style>
564
+ </div>
565
+ );
566
+ }
567
+ );
568
+
569
+ GraphCanvas.displayName = 'GraphCanvas';
570
+ export default GraphCanvas;
frontend-react/src/context/AuthContext.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useEffect } from 'react';
2
+
3
+ // This acts as our authentication context for the React application
4
+
5
+ interface User {
6
+ username: string;
7
+ email?: string;
8
+ full_name?: string;
9
+ scopes: string[];
10
+ }
11
+
12
+ interface AuthContextType {
13
+ token: string | null;
14
+ user: User | null;
15
+ login: (token: string, user: User) => void;
16
+ logout: () => void;
17
+ isAuthenticated: boolean;
18
+ }
19
+
20
+ const AuthContext = createContext<AuthContextType | undefined>(undefined);
21
+
22
+ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
23
+ const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
24
+ const [user, setUser] = useState<User | null>(null);
25
+
26
+ useEffect(() => {
27
+ const storedUser = localStorage.getItem('user');
28
+ if (storedUser) {
29
+ try {
30
+ setUser(JSON.parse(storedUser));
31
+ } catch (e) {
32
+ console.error('Failed to parse stored user', e);
33
+ }
34
+ }
35
+ }, []);
36
+
37
+ const login = (newToken: string, newUser: User) => {
38
+ localStorage.setItem('token', newToken);
39
+ localStorage.setItem('user', JSON.stringify(newUser));
40
+ setToken(newToken);
41
+ setUser(newUser);
42
+ };
43
+
44
+ const logout = () => {
45
+ localStorage.removeItem('token');
46
+ localStorage.removeItem('user');
47
+ setToken(null);
48
+ setUser(null);
49
+ };
50
+
51
+ return (
52
+ <AuthContext.Provider value={{ token, user, login, logout, isAuthenticated: !!token }}>
53
+ {children}
54
+ </AuthContext.Provider>
55
+ );
56
+ };
57
+
58
+ export const useAuth = () => {
59
+ const context = useContext(AuthContext);
60
+ if (context === undefined) {
61
+ throw new Error('useAuth must be used within an AuthProvider');
62
+ }
63
+ return context;
64
+ };
frontend-react/src/index.css ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
2
+ @import "tailwindcss";
3
+
4
+ /* ── Design Tokens ─────────────────────────────────────────────────────── */
5
+ :root {
6
+ --bg-color: #ffffff;
7
+ --text-color: #000000;
8
+ --border-color: #000000;
9
+ --surface-color: #f5f5f5; /* subtle off-white for secondary surfaces */
10
+ --hover-bg: #f0f0f0;
11
+ --muted-color: #666666;
12
+ --success-color: #16a34a;
13
+ --error-color: #dc2626;
14
+ --warning-color: #d97706;
15
+
16
+ --font-sans: 'Inter', 'Noto Sans SC', sans-serif;
17
+ --font-display: 'Space Grotesk', 'Inter', sans-serif;
18
+ --font-mono: 'JetBrains Mono', monospace;
19
+
20
+ --transition-speed: 0.18s;
21
+ --nav-height: 60px;
22
+ }
23
+
24
+ /* ── Reset ─────────────────────────────────────────────────────────────── */
25
+ *, *::before, *::after {
26
+ box-sizing: border-box;
27
+ margin: 0;
28
+ padding: 0;
29
+ }
30
+
31
+ body {
32
+ font-family: var(--font-sans);
33
+ background-color: var(--bg-color);
34
+ color: var(--text-color);
35
+ line-height: 1.6;
36
+ -webkit-font-smoothing: antialiased;
37
+ -moz-osx-font-smoothing: grayscale;
38
+ min-height: 100vh;
39
+ }
40
+
41
+ h1, h2, h3, h4, h5, h6 {
42
+ font-family: var(--font-display);
43
+ font-weight: 600;
44
+ margin-bottom: 0.75rem;
45
+ line-height: 1.2;
46
+ }
47
+
48
+ code, pre, .technical { font-family: var(--font-mono); }
49
+
50
+ /* ── Links ─────────────────────────────────────────────────────────────── */
51
+ a {
52
+ color: var(--text-color);
53
+ text-decoration: underline;
54
+ text-underline-offset: 3px;
55
+ transition: background var(--transition-speed), color var(--transition-speed);
56
+ }
57
+ a:hover {
58
+ background-color: var(--text-color);
59
+ color: var(--bg-color);
60
+ }
61
+
62
+ /* ── Buttons ───────────────────────────────────────────────────────────── */
63
+ button, .app-btn {
64
+ font-family: var(--font-display);
65
+ background-color: var(--bg-color);
66
+ color: var(--text-color);
67
+ border: 2px solid var(--border-color);
68
+ padding: 0.5rem 1rem;
69
+ font-weight: 600;
70
+ cursor: pointer;
71
+ transition: background var(--transition-speed) ease, color var(--transition-speed) ease;
72
+ font-size: 0.9rem;
73
+ text-transform: uppercase;
74
+ letter-spacing: 0.8px;
75
+ display: inline-flex;
76
+ align-items: center;
77
+ gap: 0.4rem;
78
+ }
79
+ button:hover, .app-btn:hover {
80
+ background-color: var(--text-color);
81
+ color: var(--bg-color);
82
+ }
83
+ button:disabled, .app-btn:disabled {
84
+ opacity: 0.45;
85
+ cursor: not-allowed;
86
+ pointer-events: none;
87
+ }
88
+
89
+ /* Text-only button (no border, no background) */
90
+ .text-btn {
91
+ background: none !important;
92
+ border: none !important;
93
+ color: var(--text-color);
94
+ text-decoration: underline;
95
+ text-underline-offset: 3px;
96
+ cursor: pointer;
97
+ font-family: var(--font-mono);
98
+ font-size: 0.8rem;
99
+ font-weight: 600;
100
+ transition: background var(--transition-speed), color var(--transition-speed);
101
+ padding: 0.2rem 0.4rem;
102
+ }
103
+ .text-btn:hover {
104
+ background: var(--text-color) !important;
105
+ color: var(--bg-color) !important;
106
+ }
107
+
108
+ /* ── Toast dismiss button ──────────────────────────────────────────────── */
109
+ /* Prevents global button:hover from flipping the close button inside toasts */
110
+ .toast-dismiss-btn {
111
+ background: transparent !important;
112
+ border: none !important;
113
+ color: inherit !important;
114
+ cursor: pointer !important;
115
+ margin-left: auto !important;
116
+ padding: 0 0.3rem !important;
117
+ font-size: 1.2rem !important;
118
+ line-height: 1 !important;
119
+ opacity: 0.7;
120
+ flex-shrink: 0;
121
+ transition: opacity 0.12s ease !important;
122
+ }
123
+ .toast-dismiss-btn:hover {
124
+ background: transparent !important;
125
+ color: inherit !important;
126
+ opacity: 1 !important;
127
+ }
128
+
129
+ /* ── Status toast ──────────────────────────────────────────────────────── */
130
+ .status-toast {
131
+ position: fixed;
132
+ bottom: 2rem;
133
+ right: 2rem;
134
+ background: var(--text-color);
135
+ color: var(--bg-color);
136
+ padding: 0.9rem 1.2rem;
137
+ border-left: 5px solid #000;
138
+ box-shadow: 4px 4px 0 rgba(0,0,0,0.3);
139
+ display: flex;
140
+ align-items: center;
141
+ gap: 0.75rem;
142
+ z-index: 9999;
143
+ font-family: var(--font-mono);
144
+ font-weight: 700;
145
+ font-size: 0.85rem;
146
+ max-width: 420px;
147
+ animation: slideUpToast 0.28s ease-out both;
148
+ will-change: transform;
149
+ }
150
+ .status-toast.error { border-left-color: var(--error-color); }
151
+ .status-toast.success { border-left-color: var(--success-color); }
152
+
153
+ /* ── Form Controls ─────────────────────────────────────────────────────── */
154
+ input, textarea, select {
155
+ font-family: var(--font-sans);
156
+ background-color: var(--bg-color);
157
+ color: var(--text-color);
158
+ border: 2px solid var(--border-color);
159
+ padding: 0.5rem 0.75rem;
160
+ font-size: 0.95rem;
161
+ transition: box-shadow var(--transition-speed) ease;
162
+ }
163
+ input:focus, textarea:focus, select:focus {
164
+ outline: none;
165
+ box-shadow: 3px 3px 0 var(--border-color);
166
+ }
167
+
168
+ /* ── Scrollbars ────────────────────────────────────────────────────────── */
169
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
170
+ ::-webkit-scrollbar-track { background: #f9f9f9; border-left: 1px solid #e5e5e5; }
171
+ ::-webkit-scrollbar-thumb { background: #ccc; }
172
+ ::-webkit-scrollbar-thumb:hover { background: #999; }
173
+
174
+ /* ── Layout Utilities ──────────────────────────────────────────────────── */
175
+ .container {
176
+ max-width: 1280px;
177
+ margin: 0 auto;
178
+ padding: 2rem;
179
+ }
180
+
181
+ .card {
182
+ border: 2px solid var(--border-color);
183
+ padding: 1.5rem;
184
+ background-color: var(--bg-color);
185
+ transition: transform var(--transition-speed) ease, box-shadow var(--transition-speed) ease;
186
+ }
187
+ .card:hover {
188
+ transform: translateY(-2px);
189
+ box-shadow: 5px 5px 0 var(--border-color);
190
+ }
191
+
192
+ .mono-text { font-family: var(--font-mono); font-size: 0.88em; }
193
+
194
+ .flex-center {
195
+ display: flex;
196
+ align-items: center;
197
+ justify-content: center;
198
+ }
199
+
200
+ .flex-between {
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: space-between;
204
+ flex-wrap: wrap;
205
+ gap: 1rem;
206
+ }
207
+
208
+ .full-width { width: 100%; }
209
+
210
+ .page-header {
211
+ border-bottom: 3px solid var(--border-color);
212
+ padding-bottom: 1rem;
213
+ margin-bottom: 2rem;
214
+ }
215
+
216
+ /* ── Keyframes ─────────────────────────────────────────────────────────── */
217
+ @keyframes fadeIn {
218
+ from { opacity: 0; transform: translateY(8px); }
219
+ to { opacity: 1; transform: translateY(0); }
220
+ }
221
+
222
+ @keyframes slideUpToast {
223
+ from { transform: translateY(80px); opacity: 0; }
224
+ to { transform: translateY(0); opacity: 1; }
225
+ }
226
+
227
+ @keyframes slideUp {
228
+ from { transform: translateY(20px); opacity: 0; }
229
+ to { transform: translateY(0); opacity: 1; }
230
+ }
231
+
232
+ @keyframes spin {
233
+ 100% { transform: rotate(360deg); }
234
+ }
235
+
236
+ .fade-in { animation: fadeIn 0.4s ease both; }
237
+
238
+ /* ── Status indicators ─────────────────────────────────────────────────── */
239
+ .status-dot {
240
+ display: inline-block;
241
+ width: 8px;
242
+ height: 8px;
243
+ border-radius: 50%;
244
+ flex-shrink: 0;
245
+ }
246
+ .status-dot.online { background: var(--success-color); }
247
+ .status-dot.offline { background: var(--error-color); }
248
+
249
+ /* ── Responsive ────────────────────────────────────────────────────────── */
250
+ @media (max-width: 768px) {
251
+ .container { padding: 1rem; }
252
+ .card { padding: 1rem; }
253
+ }
254
+
255
+ /* ── Admin / shared aliases ─────────────────────────────────────────────── */
256
+ /* These classes are referenced in AdminDashboard and other views. */
257
+ /* They map onto the existing design system so everything is uniform. */
258
+
259
+ /* Base btn — same as global button but without uppercase transform */
260
+ .btn {
261
+ font-family: var(--font-display);
262
+ background-color: var(--bg-color);
263
+ color: var(--text-color);
264
+ border: 2px solid var(--border-color);
265
+ padding: 0.5rem 1rem;
266
+ font-weight: 600;
267
+ cursor: pointer;
268
+ font-size: 0.85rem;
269
+ letter-spacing: 0.5px;
270
+ display: inline-flex;
271
+ align-items: center;
272
+ gap: 0.4rem;
273
+ transition: background var(--transition-speed) ease, color var(--transition-speed) ease;
274
+ }
275
+ .btn:hover:not(:disabled) {
276
+ background-color: var(--text-color);
277
+ color: var(--bg-color);
278
+ }
279
+ .btn:disabled {
280
+ opacity: 0.45;
281
+ cursor: not-allowed;
282
+ pointer-events: none;
283
+ }
284
+
285
+ /* Primary button — filled black */
286
+ .btn-primary {
287
+ background-color: var(--text-color);
288
+ color: var(--bg-color);
289
+ border: 2px solid var(--text-color);
290
+ }
291
+ .btn-primary:hover:not(:disabled) {
292
+ background-color: #333;
293
+ border-color: #333;
294
+ }
295
+
296
+ /* Outline / ghost button */
297
+ .btn-outline {
298
+ background: transparent;
299
+ color: var(--text-color);
300
+ border: 2px solid var(--border-color);
301
+ }
302
+ .btn-outline:hover:not(:disabled) {
303
+ background: var(--text-color);
304
+ color: var(--bg-color);
305
+ }
306
+
307
+ /* Danger button */
308
+ .btn-danger {
309
+ color: var(--error-color);
310
+ border-color: var(--error-color);
311
+ }
312
+ .btn-danger:hover:not(:disabled) {
313
+ background: var(--error-color);
314
+ color: #fff;
315
+ }
316
+
317
+ /* Uniform search / select input (matches global input but explicit class) */
318
+ .search-input {
319
+ font-family: var(--font-sans);
320
+ background-color: var(--bg-color);
321
+ color: var(--text-color);
322
+ border: 2px solid var(--border-color);
323
+ padding: 0.5rem 0.75rem;
324
+ font-size: 0.9rem;
325
+ transition: box-shadow var(--transition-speed) ease;
326
+ width: 100%;
327
+ }
328
+ .search-input:focus {
329
+ outline: none;
330
+ box-shadow: 3px 3px 0 var(--border-color);
331
+ }
332
+
333
+ /* Status / metric cards */
334
+ .status-card {
335
+ border: 2px solid var(--border-color);
336
+ padding: 1.25rem 1.5rem;
337
+ background: var(--bg-color);
338
+ }
339
+
340
+ .metric-value {
341
+ font-family: var(--font-mono);
342
+ font-size: 2rem;
343
+ font-weight: 900;
344
+ line-height: 1;
345
+ margin-top: 0.25rem;
346
+ }
347
+
348
+ .status-label {
349
+ font-family: var(--font-mono);
350
+ font-size: 0.68rem;
351
+ font-weight: 700;
352
+ color: var(--muted-color);
353
+ letter-spacing: 1px;
354
+ text-transform: uppercase;
355
+ }
356
+
357
+ /* Online / offline indicator dots */
358
+ .indicator {
359
+ width: 10px;
360
+ height: 10px;
361
+ border-radius: 50%;
362
+ flex-shrink: 0;
363
+ }
364
+ .indicator.online { background: var(--success-color); }
365
+ .indicator.offline { background: var(--error-color); }
366
+ .indicator.pending { background: var(--warning-color); }
367
+
368
+ /* Typography */
369
+ .title-lg {
370
+ font-family: var(--font-display);
371
+ font-size: 1.8rem;
372
+ font-weight: 700;
373
+ line-height: 1.1;
374
+ }
375
+ .title-md {
376
+ font-family: var(--font-display);
377
+ font-size: 1.15rem;
378
+ font-weight: 700;
379
+ }
380
+ .title-sm {
381
+ font-family: var(--font-mono);
382
+ font-size: 0.72rem;
383
+ font-weight: 700;
384
+ letter-spacing: 1px;
385
+ text-transform: uppercase;
386
+ color: var(--muted-color);
387
+ }
388
+
389
+ /* Data table */
390
+ .data-table {
391
+ width: 100%;
392
+ border-collapse: collapse;
393
+ font-size: 0.9rem;
394
+ }
395
+ .data-table thead tr {
396
+ background: var(--surface-color);
397
+ border-bottom: 2px solid var(--border-color);
398
+ }
399
+ .data-table th,
400
+ .data-table td {
401
+ padding: 0.75rem 1rem;
402
+ text-align: left;
403
+ border-bottom: 1px solid #eaeaea;
404
+ }
405
+ .data-table th {
406
+ font-family: var(--font-mono);
407
+ font-size: 0.72rem;
408
+ font-weight: 700;
409
+ letter-spacing: 0.8px;
410
+ text-transform: uppercase;
411
+ color: var(--muted-color);
412
+ }
413
+ .data-table tbody tr:hover {
414
+ background: var(--surface-color);
415
+ }
416
+ .data-table tbody tr:last-child td {
417
+ border-bottom: none;
418
+ }
419
+
420
+ /* Tag / badge chips */
421
+ .chip {
422
+ display: inline-flex;
423
+ align-items: center;
424
+ gap: 4px;
425
+ border: 1.5px solid var(--border-color);
426
+ padding: 2px 8px;
427
+ font-family: var(--font-mono);
428
+ font-size: 0.72rem;
429
+ font-weight: 700;
430
+ }
431
+ .chip.success { border-color: var(--success-color); color: var(--success-color); }
432
+ .chip.error { border-color: var(--error-color); color: var(--error-color); }
433
+ .chip.warning { border-color: var(--warning-color); color: var(--warning-color); }
434
+ .chip.filled { background: var(--text-color); color: var(--bg-color); }
435
+
436
+ /* Empty state */
437
+ .empty-state {
438
+ display: flex;
439
+ flex-direction: column;
440
+ align-items: center;
441
+ justify-content: center;
442
+ padding: 4rem 2rem;
443
+ color: var(--muted-color);
444
+ gap: 0.75rem;
445
+ border: 2px dashed var(--border-color);
446
+ font-family: var(--font-mono);
447
+ font-size: 0.85rem;
448
+ letter-spacing: 0.5px;
449
+ text-align: center;
450
+ }
451
+
452
+ /* Help tooltip */
453
+ .help-tooltip {
454
+ position: relative;
455
+ display: inline-flex;
456
+ align-items: center;
457
+ }
458
+ .help-tooltip [data-tip] {
459
+ cursor: help;
460
+ color: var(--muted-color);
461
+ }
462
+ .help-tooltip [data-tip]:hover::after {
463
+ content: attr(data-tip);
464
+ position: absolute;
465
+ bottom: 125%;
466
+ left: 50%;
467
+ transform: translateX(-50%);
468
+ background: var(--text-color);
469
+ color: var(--bg-color);
470
+ padding: 0.4rem 0.75rem;
471
+ font-family: var(--font-mono);
472
+ font-size: 0.75rem;
473
+ white-space: nowrap;
474
+ z-index: 9999;
475
+ pointer-events: none;
476
+ max-width: 260px;
477
+ white-space: normal;
478
+ text-align: center;
479
+ }
480
+
481
+ /* Page info bar */
482
+ .page-info-bar {
483
+ background: var(--surface-color);
484
+ border-left: 4px solid var(--border-color);
485
+ padding: 0.75rem 1rem;
486
+ font-size: 0.82rem;
487
+ line-height: 1.6;
488
+ margin-bottom: 1.5rem;
489
+ display: flex;
490
+ align-items: flex-start;
491
+ gap: 0.75rem;
492
+ color: var(--muted-color);
493
+ }
494
+ .page-info-bar strong { color: var(--text-color); font-family: var(--font-mono); }
frontend-react/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend-react/src/types/api.ts ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface LoginRequest {
2
+ username: string;
3
+ password: string;
4
+ }
5
+
6
+ export interface RegisterRequest {
7
+ username: string;
8
+ password: string;
9
+ email?: string;
10
+ full_name?: string;
11
+ scopes?: string[];
12
+ tenant_id?: string;
13
+ }
14
+
15
+ export interface TokenResponse {
16
+ access_token: string;
17
+ token_type: string;
18
+ }
19
+
20
+ export interface DocumentUploadResponse {
21
+ document_id: string;
22
+ filename: string;
23
+ size_bytes: number;
24
+ task_id?: string;
25
+ message: string;
26
+ }
27
+
28
+ export interface ScrapeRequest {
29
+ url: string;
30
+ }
31
+
32
+ export interface CrawlRequest {
33
+ url: string;
34
+ max_depth?: number;
35
+ max_pages?: number;
36
+ }
37
+
38
+ export interface IngestionStatusResponse {
39
+ task_id: string;
40
+ status: string;
41
+ progress?: Record<string, any>;
42
+ result?: Record<string, any>;
43
+ }
44
+
45
+ export interface DocumentInfo {
46
+ id: string;
47
+ filename: string;
48
+ file_type: string;
49
+ size_bytes: number;
50
+ upload_date: string;
51
+ }
52
+
53
+ export interface DocumentListResponse {
54
+ documents: DocumentInfo[];
55
+ total: number;
56
+ }
57
+
58
+ export interface QueryRequest {
59
+ query: string;
60
+ top_k?: number;
61
+ streaming?: boolean;
62
+ document_id?: string;
63
+ conversation_id?: string;
64
+ use_got?: boolean;
65
+ at_time?: string;
66
+ }
67
+
68
+ export interface ConfidenceJudgmentResponse {
69
+ score: number;
70
+ reasoning: string;
71
+ grounded_claims: number;
72
+ ungrounded_claims: number;
73
+ hallucination_risk: 'low' | 'medium' | 'high';
74
+ }
75
+
76
+ export interface QueryResponse {
77
+ answer: string;
78
+ sources: Array<Record<string, any>>;
79
+ reasoning_chain: string[];
80
+ confidence: number;
81
+ confidence_judgment?: ConfidenceJudgmentResponse;
82
+ retrieval_method: string;
83
+ processing_time_seconds: number;
84
+ conversation_id?: string;
85
+ drift_expanded?: boolean;
86
+ total_sub_queries?: number;
87
+ }
88
+
89
+ export interface EvalResultData {
90
+ overall_score?: number;
91
+ faithfulness: number;
92
+ answer_relevancy?: number;
93
+ relevancy?: number;
94
+ context_precision?: number;
95
+ precision?: number;
96
+ }
97
+
98
+ export interface Message {
99
+ id: string;
100
+ role: string;
101
+ content: string;
102
+ reasoning?: string[];
103
+ sources?: Array<Record<string, any>>;
104
+ confidence?: number;
105
+ hallucination_risk?: string;
106
+ confidence_reasoning?: string;
107
+ created_at: string;
108
+ eval_result?: EvalResultData;
109
+ evaluating?: boolean;
110
+ }
111
+
112
+ export interface Conversation {
113
+ id: string;
114
+ title: string;
115
+ created_at: string;
116
+ updated_at: string;
117
+ messages?: Message[];
118
+ }
119
+
120
+ export interface ConversationListResponse {
121
+ conversations: Conversation[];
122
+ }
123
+
124
+ export interface OntologyResponse {
125
+ version: string;
126
+ entity_types: string[];
127
+ relationship_types: string[];
128
+ properties: Record<string, string[]>;
129
+ created_at: string;
130
+ approved: boolean;
131
+ }
132
+
133
+ export interface OntologyUpdateRequest {
134
+ entity_types?: string[];
135
+ relationship_types?: string[];
136
+ properties?: Record<string, string[]>;
137
+ approved?: boolean;
138
+ }
139
+
140
+ export interface GraphNode {
141
+ id: string;
142
+ label: string;
143
+ type: string;
144
+ description?: string;
145
+ properties: Record<string, any>;
146
+ community_id?: number;
147
+ valid_from?: string;
148
+ valid_until?: string;
149
+ }
150
+
151
+ export interface GraphEdge {
152
+ source: string;
153
+ target: string;
154
+ type: string;
155
+ properties: Record<string, any>;
156
+ valid_from?: string;
157
+ confidence?: number;
158
+ }
159
+
160
+ export interface GraphVisualizationResponse {
161
+ nodes: GraphNode[];
162
+ edges: GraphEdge[];
163
+ }
164
+
165
+ export interface SystemHealthResponse {
166
+ status: string;
167
+ version: string;
168
+ neo4j_connected: boolean;
169
+ redis_connected: boolean;
170
+ workers_active: number;
171
+ timestamp: string;
172
+ }
173
+
174
+ export interface SystemStatsResponse {
175
+ documents_count: number;
176
+ entities_count: number;
177
+ relationships_count: number;
178
+ chunks_count: number;
179
+ ontology_version: string;
180
+ }
181
+
182
+ export interface DriftReport { id: string; detected_at: string; new_entity_types: string[]; new_relationship_types: string[]; removed_entity_types: string[]; removed_relationship_types: string[]; sample_size: number; drift_score: number; status: string; approved_by?: string; approved_at?: string; }
frontend-react/src/views/AdminDashboard.tsx ADDED
@@ -0,0 +1,762 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { useAuth } from '../context/AuthContext';
3
+ import {
4
+ BarChart2, Cpu, Users, Database, Settings,
5
+ Trash2, Check, X, Play, RefreshCw, Shield,
6
+ AlertTriangle, Zap, GitBranch, Info
7
+ } from 'lucide-react';
8
+
9
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
10
+
11
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
12
+ const Spinner = () => (
13
+ <span className="inline-block w-[14px] h-[14px] border-2 border-[#ccc] border-t-black rounded-full animate-spin"/>
14
+ );
15
+
16
+ // ─── Tab Components ──────────────────────────────────────────────────────────
17
+
18
+ const OverviewTab = ({ stats, health, onRefresh }: { stats: Partial<import('../types/api').SystemStatsResponse & {graph: any, total_nodes: number, total_relationships: number, documents: any, total_documents: number, costs: any}>; health: import('../types/api').SystemHealthResponse | any; onRefresh: () => void }) => (
19
+ <div>
20
+ {/* KPI Grid */}
21
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4 mb-8">
22
+ {[
23
+ { label:'Graph Nodes', value: stats?.graph?.nodes ?? stats?.total_nodes ?? '—', icon:<Database size={16}/> },
24
+ { label:'Relationships', value: stats?.graph?.relationships ?? stats?.total_relationships ?? '—', icon:<GitBranch size={16}/> },
25
+ { label:'Documents', value: stats?.documents?.total ?? stats?.total_documents ?? '—', icon:<Database size={16}/> },
26
+ { label:'Est. LLM Cost', value: `$${(stats?.costs?.total_estimated_usd ?? 0).toFixed(4)}`, icon:<BarChart2 size={16}/> },
27
+ ].map(c => (
28
+ <div key={c.label} className="status-card">
29
+ <div className="flex justify-between items-center mb-2">
30
+ <div className="status-label">{c.label}</div>
31
+ {c.icon}
32
+ </div>
33
+ <div className="metric-value">{c.value}</div>
34
+ </div>
35
+ ))}
36
+ </div>
37
+
38
+ {/* System health */}
39
+ <div className="card mb-6">
40
+ <div className="flex justify-between items-center mb-4">
41
+ <h2 className="title-md">System Health</h2>
42
+ <button className="btn btn-outline py-1 px-3 text-xs" onClick={onRefresh}>
43
+ <RefreshCw size={13}/> Refresh
44
+ </button>
45
+ </div>
46
+ {health ? (
47
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
48
+ {Object.entries(health).map(([k, v]: [string, any]) => {
49
+ const isOk = v === true || v === 'ok' || v === 'connected' || v === 'healthy';
50
+ const isErr = v === false || v === 'error' || v === 'disconnected';
51
+ return (
52
+ <div key={k} className="border-[1.5px] border-[#e5e5e5] py-2.5 px-3.5 flex items-center gap-2">
53
+ <span className={`indicator ${isOk ? 'online' : isErr ? 'offline' : 'pending'}`}/>
54
+ <div>
55
+ <div className="status-label">{k.toUpperCase()}</div>
56
+ <div className="font-mono text-[0.8rem] font-bold">
57
+ {typeof v === 'object' ? JSON.stringify(v) : String(v)}
58
+ </div>
59
+ </div>
60
+ </div>
61
+ );
62
+ })}
63
+ </div>
64
+ ) : (
65
+ <div className="text-center p-6 text-[var(--muted-color)] font-mono text-[0.85rem]">
66
+ Loading health data…
67
+ </div>
68
+ )}
69
+ </div>
70
+
71
+ {/* Provider info */}
72
+ {stats?.system && (
73
+ <div className="card">
74
+ <h2 className="title-md mb-4">LLM Provider</h2>
75
+ <div className="flex gap-4 flex-wrap">
76
+ {Object.entries(stats.system).map(([k, v]: any) => (
77
+ <div key={k} className="chip">{k}: <strong>{String(v)}</strong></div>
78
+ ))}
79
+ </div>
80
+ </div>
81
+ )}
82
+ </div>
83
+ );
84
+
85
+ const UsersTab = ({ token }: { token: string | null }) => {
86
+ const [users, setUsers] = useState<any[]>([]);
87
+ const [loading, setLoading] = useState(true);
88
+ const [msg, setMsg] = useState('');
89
+
90
+ const fetchUsers = useCallback(async () => {
91
+ setLoading(true);
92
+ try {
93
+ const res = await fetch(`${API_BASE}/admin/users`, { headers: { Authorization: `Bearer ${token}` } });
94
+ if (res.ok) {
95
+ const json = await res.json();
96
+ setUsers(json.users || []);
97
+ }
98
+ } finally { setLoading(false); }
99
+ }, [token]);
100
+
101
+ useEffect(() => { fetchUsers(); }, [fetchUsers]);
102
+
103
+ const updateRole = async (username: string, scopes: string) => {
104
+ const res = await fetch(`${API_BASE}/admin/users/${username}/role`, {
105
+ method: 'PUT',
106
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ scopes: [scopes] })
108
+ });
109
+ if (res.ok) {
110
+ setUsers(u => u.map(usr => usr.username === username ? { ...usr, scopes: [scopes] } : usr));
111
+ setMsg(`Role updated for ${username}`);
112
+ setTimeout(() => setMsg(''), 3000);
113
+ } else {
114
+ setMsg(`Failed to update role for ${username}`);
115
+ setTimeout(() => setMsg(''), 3000);
116
+ }
117
+ };
118
+
119
+ return (
120
+ <div className="card">
121
+ <div className="flex justify-between items-center mb-4">
122
+ <h2 className="title-md">Registered Users</h2>
123
+ <button className="btn btn-outline py-1 px-3 text-xs" onClick={fetchUsers}>
124
+ <RefreshCw size={13}/> Refresh
125
+ </button>
126
+ </div>
127
+ {msg && <div className="bg-[#dcfce7] text-[#166534] py-2 px-3 mb-4 font-mono text-[0.8rem]">{msg}</div>}
128
+ {loading ? (
129
+ <div className="text-center p-8"><Spinner/></div>
130
+ ) : (
131
+ <div className="overflow-x-auto">
132
+ <table className="data-table">
133
+ <thead><tr><th>Username</th><th>Scope</th><th>Change Role</th></tr></thead>
134
+ <tbody>
135
+ {users.map(u => {
136
+ const isAdminUser = u.username === 'admin';
137
+ const currentScope = u.scopes?.includes('admin') ? 'admin' : (u.scopes?.includes('write') ? 'write' : 'read');
138
+ return (
139
+ <tr key={u.username}>
140
+ <td className="font-mono font-semibold">
141
+ {u.username}
142
+ {isAdminUser && <span className="chip filled ml-1.5 text-[0.62rem]">PROTECTED</span>}
143
+ </td>
144
+ <td><span className="chip">{u.scopes?.join(', ') || 'none'}</span></td>
145
+ <td>
146
+ {isAdminUser ? (
147
+ <span className="font-mono text-xs text-[var(--muted-color)]">—</span>
148
+ ) : (
149
+ <select
150
+ className="search-input w-auto py-1 px-2 text-[0.82rem]"
151
+ value={currentScope}
152
+ onChange={e => updateRole(u.username, e.target.value)}
153
+ >
154
+ <option value="read">Read Only</option>
155
+ <option value="write">Read / Write</option>
156
+ <option value="admin">Admin</option>
157
+ </select>
158
+ )}
159
+ </td>
160
+ </tr>
161
+ );
162
+ })}
163
+ {users.length === 0 && (
164
+ <tr><td colSpan={3} className="text-center p-8 text-[var(--muted-color)]">No users found.</td></tr>
165
+ )}
166
+ </tbody>
167
+ </table>
168
+ </div>
169
+ )}
170
+ </div>
171
+ );
172
+ };
173
+
174
+ const DocumentsTab = ({ token }: { token: string | null }) => {
175
+ const [docs, setDocs] = useState<any[]>([]);
176
+ const [loading, setLoading] = useState(true);
177
+ const [msg, setMsg] = useState('');
178
+
179
+ const fetchDocs = useCallback(async () => {
180
+ setLoading(true);
181
+ try {
182
+ const res = await fetch(`${API_BASE}/admin/documents`, { headers: { Authorization: `Bearer ${token}` } });
183
+ if (res.ok) {
184
+ const json = await res.json();
185
+ setDocs(json.documents || []);
186
+ }
187
+ } finally { setLoading(false); }
188
+ }, [token]);
189
+
190
+ useEffect(() => { fetchDocs(); }, [fetchDocs]);
191
+
192
+ const deleteDoc = async (id: string, filename: string) => {
193
+ if (!window.confirm(`Delete "${filename}" and all its graph data? This cannot be undone.`)) return;
194
+ const res = await fetch(`${API_BASE}/admin/documents/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
195
+ if (res.ok) {
196
+ setDocs(d => d.filter(doc => doc.id !== id));
197
+ setMsg('Document deleted.');
198
+ setTimeout(() => setMsg(''), 3000);
199
+ }
200
+ };
201
+
202
+ const reIngestDoc = async (id: string, filename: string) => {
203
+ setMsg(`Re-ingesting "${filename}"...`);
204
+ try {
205
+ const res = await fetch(`${API_BASE}/admin/documents/${id}/reingest`, {
206
+ method: 'POST',
207
+ headers: { Authorization: `Bearer ${token}` }
208
+ });
209
+ if (res.ok) {
210
+ const data = await res.json();
211
+ setMsg(`Re-ingestion queued for "${filename}". Task: ${data.task_id?.slice(0,8)}…`);
212
+ setTimeout(() => { setMsg(''); fetchDocs(); }, 5000);
213
+ } else {
214
+ setMsg(`Failed to re-ingest "${filename}"`);
215
+ setTimeout(() => setMsg(''), 3000);
216
+ }
217
+ } catch {
218
+ setMsg('Network error during re-ingest.');
219
+ setTimeout(() => setMsg(''), 3000);
220
+ }
221
+ };
222
+
223
+ return (
224
+ <div className="card">
225
+ <div className="flex justify-between items-center mb-4">
226
+ <h2 className="title-md">Document Vault</h2>
227
+ <span className="font-mono text-xs text-[var(--muted-color)]">{docs.length} documents</span>
228
+ </div>
229
+ {msg && <div className={`py-2 px-3 mb-4 font-mono text-[0.8rem] ${(msg.includes('Failed') || msg.includes('error')) ? 'bg-[#fef2f2] text-[#dc2626]' : 'bg-[#dcfce7] text-[#166534]'}`}>{msg}</div>}
230
+ {loading ? (
231
+ <div className="text-center p-8"><Spinner/></div>
232
+ ) : (
233
+ <div className="overflow-x-auto">
234
+ <table className="data-table">
235
+ <thead><tr><th>ID</th><th>Filename</th><th>Status</th><th>Actions</th></tr></thead>
236
+ <tbody>
237
+ {docs.map(d => (
238
+ <tr key={d.id}>
239
+ <td className="font-mono text-[0.78rem] text-[var(--muted-color)]">{d.id?.substring(0,12)}…</td>
240
+ <td className="font-mono font-semibold">{d.filename}</td>
241
+ <td>
242
+ <span className={`chip ${d.status === 'completed' ? 'success' : d.status === 'failed' ? 'error' : 'warning'}`}>
243
+ {d.status || 'unknown'}
244
+ </span>
245
+ </td>
246
+ <td className="flex gap-1.5 flex-wrap">
247
+ {(d.status === 'failed' || d.status === 'pending') && (
248
+ <button className="btn text-[0.72rem] py-1 px-2 border-[#2563eb] text-[#2563eb] bg-[#eff6ff]"
249
+ onClick={() => reIngestDoc(d.id, d.filename)}>
250
+ <Play size={11}/> Re-Ingest
251
+ </button>
252
+ )}
253
+ <button className="btn btn-danger text-xs py-1 px-2.5" onClick={() => deleteDoc(d.id, d.filename)}>
254
+ <Trash2 size={12}/> Delete
255
+ </button>
256
+ </td>
257
+ </tr>
258
+ ))}
259
+ {docs.length === 0 && (
260
+ <tr><td colSpan={4} className="text-center p-8 text-[var(--muted-color)]">No documents uploaded yet.</td></tr>
261
+ )}
262
+ </tbody>
263
+ </table>
264
+ </div>
265
+ )}
266
+ </div>
267
+ );
268
+ };
269
+
270
+ const GraphCRUDTab = ({ token }: { token: string | null }) => {
271
+ const [nodes, setNodes] = useState<any[]>([]);
272
+ const [query, setQuery] = useState('');
273
+ const [loading, setLoading] = useState(false);
274
+ const [msg, setMsg] = useState('');
275
+
276
+ const search = async (e?: React.FormEvent) => {
277
+ e?.preventDefault();
278
+ setLoading(true);
279
+ try {
280
+ const res = await fetch(`${API_BASE}/admin/graph/nodes?query=${encodeURIComponent(query)}&limit=100`, {
281
+ headers: { Authorization: `Bearer ${token}` }
282
+ });
283
+ if (res.ok) setNodes((await res.json()).nodes || []);
284
+ } finally { setLoading(false); }
285
+ };
286
+
287
+ const deleteNode = async (id: string) => {
288
+ if (!window.confirm('Detach and delete this node?')) return;
289
+ const res = await fetch(`${API_BASE}/admin/graph/nodes/${id}`, {
290
+ method: 'DELETE', headers: { Authorization: `Bearer ${token}` }
291
+ });
292
+ if (res.ok) {
293
+ setNodes(n => n.filter(nd => nd.id !== id));
294
+ setMsg('Node deleted.');
295
+ setTimeout(() => setMsg(''), 3000);
296
+ }
297
+ };
298
+
299
+ return (
300
+ <div className="card">
301
+ <h2 className="title-md mb-4">Graph Node Browser</h2>
302
+ <div className="page-info-bar">
303
+ <Info size={14}/>
304
+ <span>Search and inspect nodes directly in Neo4j. Use label names or property values. <strong>DELETE</strong> detaches all relationships before removing the node.</span>
305
+ </div>
306
+ {msg && <div className="bg-[#dcfce7] text-[#166534] py-2 px-3 mb-4 font-mono text-[0.8rem]">{msg}</div>}
307
+ <form onSubmit={search} className="flex gap-3 mb-6">
308
+ <input type="text" value={query} onChange={e => setQuery(e.target.value)}
309
+ placeholder="Search node labels or properties…" className="search-input flex-1"/>
310
+ <button type="submit" className="btn btn-primary" disabled={loading}>
311
+ {loading ? <Spinner/> : null} Search
312
+ </button>
313
+ </form>
314
+ <div className="overflow-x-auto max-h-[420px] overflow-y-auto">
315
+ <table className="data-table">
316
+ <thead><tr><th>ID</th><th>Labels</th><th>Properties</th><th>Action</th></tr></thead>
317
+ <tbody>
318
+ {nodes.map((n, i) => (
319
+ <tr key={i}>
320
+ <td className="font-mono text-[0.78rem] text-[var(--muted-color)]">{n.id}</td>
321
+ <td className="font-mono text-[#2563eb]">{n.labels?.join(', ')}</td>
322
+ <td className="font-mono text-[0.78rem] text-[var(--muted-color)] max-w-[260px] whitespace-nowrap overflow-hidden text-ellipsis">
323
+ {JSON.stringify(n.properties)}
324
+ </td>
325
+ <td>
326
+ <button className="btn btn-danger text-xs py-1 px-2.5" onClick={() => deleteNode(n.id)}>
327
+ <Trash2 size={12}/> Delete
328
+ </button>
329
+ </td>
330
+ </tr>
331
+ ))}
332
+ {nodes.length === 0 && (
333
+ <tr><td colSpan={4} className="text-center p-8 text-[var(--muted-color)]">
334
+ {loading ? 'Searching…' : 'Enter a search term above to browse nodes.'}
335
+ </td></tr>
336
+ )}
337
+ </tbody>
338
+ </table>
339
+ </div>
340
+ </div>
341
+ );
342
+ };
343
+
344
+ const OntologyGovernanceTab = ({ token }: { token: string | null }) => {
345
+ const [proposals, setProposals] = useState<any[]>([]);
346
+ const [driftReports, setDriftReports] = useState<any[]>([]);
347
+ const [loading, setLoading] = useState(true);
348
+ const [detectLoading, setDetectLoading] = useState(false);
349
+ const [msg, setMsg] = useState('');
350
+
351
+ const fetchData = useCallback(async () => {
352
+ setLoading(true);
353
+ try {
354
+ const [propRes, driftRes] = await Promise.all([
355
+ fetch(`${API_BASE}/admin/ontology/pending`, { headers: { Authorization: `Bearer ${token}` } }),
356
+ fetch(`${API_BASE}/ontology/drift`, { headers: { Authorization: `Bearer ${token}` } }),
357
+ ]);
358
+ if (propRes.ok) setProposals((await propRes.json()).proposals || []);
359
+ if (driftRes.ok) setDriftReports((await driftRes.json()).reports || []);
360
+ } finally { setLoading(false); }
361
+ }, [token]);
362
+
363
+ useEffect(() => { fetchData(); }, [fetchData]);
364
+
365
+ const handleProposal = async (id: string, action: 'approve' | 'reject') => {
366
+ const res = await fetch(`${API_BASE}/admin/ontology/${action}/${id}`, {
367
+ method: 'POST', headers: { Authorization: `Bearer ${token}` }
368
+ });
369
+ if (res.ok) {
370
+ setProposals(p => p.filter(o => o.id !== id));
371
+ setMsg(`Proposal ${action}d.`);
372
+ setTimeout(() => setMsg(''), 3000);
373
+ }
374
+ };
375
+
376
+ const handleDrift = async (id: string, action: 'approve' | 'reject') => {
377
+ const res = await fetch(`${API_BASE}/ontology/drift/${id}/${action}`, {
378
+ method: 'POST', headers: { Authorization: `Bearer ${token}` }
379
+ });
380
+ if (res.ok) {
381
+ setDriftReports(d => d.filter(r => r.id !== id));
382
+ setMsg(`Drift report ${action}d.`);
383
+ setTimeout(() => setMsg(''), 3000);
384
+ }
385
+ };
386
+
387
+ const detectDrift = async () => {
388
+ setDetectLoading(true);
389
+ try {
390
+ const res = await fetch(`${API_BASE}/ontology/drift/detect`, {
391
+ method: 'POST', headers: { Authorization: `Bearer ${token}` }
392
+ });
393
+ if (res.ok) {
394
+ setMsg('Drift detection complete. Refreshing…');
395
+ await fetchData();
396
+ }
397
+ } finally {
398
+ setDetectLoading(false);
399
+ setTimeout(() => setMsg(''), 4000);
400
+ }
401
+ };
402
+
403
+ return (
404
+ <div>
405
+ {msg && <div className="bg-[#dcfce7] text-[#166534] py-2.5 px-3.5 mb-4 font-mono text-[0.8rem] border border-[#bbf7d0]">{msg}</div>}
406
+
407
+ {/* Drift detection */}
408
+ <div className="card mb-6">
409
+ <div className="flex justify-between items-center mb-3">
410
+ <h2 className="title-md">Ontology Drift Reports</h2>
411
+ <button className="btn btn-primary text-[0.8rem]" onClick={detectDrift} disabled={detectLoading}>
412
+ {detectLoading ? <Spinner/> : <Zap size={13}/>} Run Drift Detection
413
+ </button>
414
+ </div>
415
+ <p className="text-[var(--muted-color)] text-[0.85rem] mb-5 font-sans">
416
+ Drift detection samples recent data and suggests additions or changes to the graph schema.
417
+ Review and approve or reject proposals below.
418
+ </p>
419
+ {loading ? <div className="text-center p-6"><Spinner/></div> : (
420
+ <div className="grid gap-3">
421
+ {driftReports.map(r => (
422
+ <div key={r.id} className="border-2 border-black p-4 flex justify-between items-start gap-4">
423
+ <div className="flex-1">
424
+ <div className="flex gap-2 mb-1.5 flex-wrap">
425
+ <span className="chip">{r.status || 'pending'}</span>
426
+ <span className="chip">{r.new_entity_types?.length || 0} new types</span>
427
+ </div>
428
+ <p className="font-mono text-[0.78rem] text-[#555] m-0">
429
+ {r.summary || 'Drift report — review suggested schema changes.'}
430
+ </p>
431
+ </div>
432
+ <div className="flex gap-2 shrink-0">
433
+ <button className="btn bg-[#f0fdf4] text-[#16a34a] border-[#16a34a] py-1 px-3 text-[0.78rem]"
434
+ onClick={() => handleDrift(r.id, 'approve')}><Check size={13}/> Apply</button>
435
+ <button className="btn bg-[#fef2f2] text-[#dc2626] border-[#dc2626] py-1 px-3 text-[0.78rem]"
436
+ onClick={() => handleDrift(r.id, 'reject')}><X size={13}/> Reject</button>
437
+ </div>
438
+ </div>
439
+ ))}
440
+ {driftReports.length === 0 && (
441
+ <div className="empty-state p-8">No pending drift reports. Run drift detection above.</div>
442
+ )}
443
+ </div>
444
+ )}
445
+ </div>
446
+
447
+ {/* Manual proposals */}
448
+ <div className="card">
449
+ <h2 className="title-md mb-3">Manual Schema Proposals</h2>
450
+ <div className="grid gap-3">
451
+ {proposals.map(o => (
452
+ <div key={o.id} className="border-[1.5px] border-[#e5e5e5] p-3.5 flex justify-between items-center gap-4">
453
+ <div className="flex items-center gap-3">
454
+ <span className="chip">{o.type}</span>
455
+ <span className="font-mono text-[0.85rem]">{o.name}</span>
456
+ </div>
457
+ <div className="flex gap-2">
458
+ <button className="btn bg-[#f0fdf4] text-[#16a34a] border-[#16a34a] py-1 px-3 text-[0.78rem]"
459
+ onClick={() => handleProposal(o.id, 'approve')}><Check size={13}/> Approve</button>
460
+ <button className="btn bg-[#fef2f2] text-[#dc2626] border-[#dc2626] py-1 px-3 text-[0.78rem]"
461
+ onClick={() => handleProposal(o.id, 'reject')}><X size={13}/> Reject</button>
462
+ </div>
463
+ </div>
464
+ ))}
465
+ {proposals.length === 0 && (
466
+ <div className="empty-state p-6">No pending manual proposals.</div>
467
+ )}
468
+ </div>
469
+ </div>
470
+ </div>
471
+ );
472
+ };
473
+
474
+ const WorkersTab = ({ token }: { token: string | null }) => {
475
+ const [tasks, setTasks] = useState<any>(null);
476
+ const [health, setHealth] = useState<any>(null);
477
+ const [loading, setLoading] = useState(true);
478
+
479
+ const fetchAll = useCallback(async () => {
480
+ setLoading(true);
481
+ try {
482
+ const [taskRes, healthRes] = await Promise.all([
483
+ fetch(`${API_BASE}/admin/tasks`, { headers: { Authorization: `Bearer ${token}` } }),
484
+ fetch(`${API_BASE}/system/health`, { headers: { Authorization: `Bearer ${token}` } }),
485
+ ]);
486
+ if (taskRes.ok) setTasks(await taskRes.json());
487
+ if (healthRes.ok) setHealth(await healthRes.json());
488
+ } finally { setLoading(false); }
489
+ }, [token]);
490
+
491
+ useEffect(() => { fetchAll(); }, [fetchAll]);
492
+
493
+ return (
494
+ <div>
495
+ <div className="card mb-5">
496
+ <div className="flex justify-between items-center mb-4">
497
+ <h2 className="title-md">Celery Worker Status</h2>
498
+ <button className="btn btn-outline text-xs py-1 px-3" onClick={fetchAll}>
499
+ <RefreshCw size={13}/> Refresh
500
+ </button>
501
+ </div>
502
+ {loading ? <div className="text-center p-6"><Spinner/></div> : (
503
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
504
+ {[
505
+ { label:'Active Tasks', value: tasks?.active_tasks ?? tasks?.active ?? 0 },
506
+ { label:'Queued Tasks', value: tasks?.queued_tasks ?? tasks?.reserved ?? 0 },
507
+ { label:'Completed', value: tasks?.completed_tasks ?? tasks?.total ?? 0 },
508
+ { label:'Failed', value: tasks?.failed_tasks ?? 0 },
509
+ ].map(m => (
510
+ <div key={m.label} className="status-card">
511
+ <div className="status-label">{m.label}</div>
512
+ <div className="metric-value text-2xl">{m.value}</div>
513
+ </div>
514
+ ))}
515
+ </div>
516
+ )}
517
+ </div>
518
+ {health && (
519
+ <div className="card">
520
+ <h2 className="title-md mb-3">Service Health</h2>
521
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-2.5">
522
+ {Object.entries(health).map(([k, v]: any) => {
523
+ const ok = v === true || v === 'ok' || v === 'connected' || v === 'healthy';
524
+ return (
525
+ <div key={k} className="border-[1.5px] border-[#e5e5e5] py-2 px-3 flex items-center gap-2">
526
+ <span className={`indicator ${ok ? 'online' : 'offline'}`}/>
527
+ <div>
528
+ <div className="status-label">{k}</div>
529
+ <div className="font-mono text-[0.78rem] font-bold">{String(v)}</div>
530
+ </div>
531
+ </div>
532
+ );
533
+ })}
534
+ </div>
535
+ </div>
536
+ )}
537
+ </div>
538
+ );
539
+ };
540
+
541
+ const EnrichmentTab = ({ token }: { token: string | null }) => {
542
+ const [loading, setLoading] = useState(false);
543
+ const [result, setResult] = useState<any>(null);
544
+ const [batchSize, setBatchSize] = useState(20);
545
+ const [minConnections, setMinConnections] = useState(1);
546
+ const [driftLoading, setDriftLoading] = useState(false);
547
+ const [driftResult, setDriftResult] = useState<any>(null);
548
+
549
+ const runEnrichment = async () => {
550
+ setLoading(true); setResult(null);
551
+ try {
552
+ const res = await fetch(`${API_BASE}/entities/enrich`, {
553
+ method:'POST',
554
+ headers:{ Authorization:`Bearer ${token}`, 'Content-Type':'application/json' },
555
+ body: JSON.stringify({ batch_size: batchSize, min_connections: minConnections })
556
+ });
557
+ if (res.ok) setResult(await res.json());
558
+ } finally { setLoading(false); }
559
+ };
560
+
561
+ const runDrift = async () => {
562
+ setDriftLoading(true); setDriftResult(null);
563
+ try {
564
+ const res = await fetch(`${API_BASE}/ontology/drift/detect`, {
565
+ method:'POST', headers:{ Authorization:`Bearer ${token}` }
566
+ });
567
+ if (res.ok) setDriftResult(await res.json());
568
+ } finally { setDriftLoading(false); }
569
+ };
570
+
571
+ return (
572
+ <div>
573
+ {/* Entity Enrichment */}
574
+ <div className="card mb-6">
575
+ <h2 className="title-md mb-2">Entity Enrichment</h2>
576
+ <p className="text-[var(--muted-color)] text-[0.85rem] mb-5">
577
+ Synthesize rich LLM-generated profiles for all eligible entities by scanning their neighborhood context in the graph.
578
+ </p>
579
+ <div className="grid grid-cols-2 gap-4 mb-4 max-w-[360px]">
580
+ <div>
581
+ <label className="block font-mono text-[0.7rem] font-bold text-[#888] mb-1">BATCH SIZE</label>
582
+ <input type="number" min={1} max={100} value={batchSize} onChange={e => setBatchSize(Number(e.target.value))}
583
+ className="search-input w-full"/>
584
+ </div>
585
+ <div>
586
+ <label className="block font-mono text-[0.7rem] font-bold text-[#888] mb-1">MIN CONNECTIONS</label>
587
+ <input type="number" min={0} max={20} value={minConnections} onChange={e => setMinConnections(Number(e.target.value))}
588
+ className="search-input w-full"/>
589
+ </div>
590
+ </div>
591
+ <button className="btn btn-primary flex gap-2 items-center" onClick={runEnrichment} disabled={loading}>
592
+ {loading ? <Spinner/> : <Zap size={14}/>}
593
+ {loading ? 'Enriching…' : 'Run Entity Enrichment'}
594
+ </button>
595
+ {result && (
596
+ <div className="mt-4 bg-[#f0fdf4] border border-[#bbf7d0] p-3 font-mono text-[0.82rem] text-[#166534]">
597
+ ✓ {result.message || `Enriched ${result.enriched_count ?? '?'} entities`}
598
+ </div>
599
+ )}
600
+ </div>
601
+
602
+ {/* Drift Detection */}
603
+ <div className="card">
604
+ <h2 className="title-md mb-2">Ontology Drift Detection</h2>
605
+ <p className="text-[var(--muted-color)] text-[0.85rem] mb-5">
606
+ Analyse recent data samples to detect schema evolution and generate a drift report for admin review.
607
+ </p>
608
+ <button className="btn btn-primary flex gap-2 items-center" onClick={runDrift} disabled={driftLoading}>
609
+ {driftLoading ? <Spinner/> : <GitBranch size={14}/>}
610
+ {driftLoading ? 'Detecting…' : 'Run Drift Detection'}
611
+ </button>
612
+ {driftResult && (
613
+ <div className="mt-4 bg-[#eff6ff] border border-[#bfdbfe] p-3 font-mono text-[0.82rem] text-[#1d4ed8]">
614
+ ✓ Drift report created. ID: {driftResult.report_id || driftResult.id || '—'} → Review in Ontology Governance tab.
615
+ </div>
616
+ )}
617
+ </div>
618
+ </div>
619
+ );
620
+ };
621
+
622
+ const SandboxTab = ({ token }: { token: string | null }) => {
623
+ const [loading, setLoading] = useState(false);
624
+ const [msg, setMsg] = useState('');
625
+
626
+ const trigger = async (endpoint: string, label: string) => {
627
+ setLoading(true); setMsg('');
628
+ try {
629
+ const res = await fetch(`${API_BASE}${endpoint}`, {
630
+ method:'POST', headers:{ Authorization:`Bearer ${token}` }
631
+ });
632
+ setMsg(res.ok ? `✓ ${label} dispatched to Celery worker.` : `✗ Failed to trigger ${label}.`);
633
+ } catch {
634
+ setMsg(`✗ Network error.`);
635
+ } finally { setLoading(false); }
636
+ };
637
+
638
+ return (
639
+ <div className="card">
640
+ <h2 className="title-md mb-2">MiroFish God-Mode Sandbox</h2>
641
+ <p className="text-[var(--muted-color)] text-[0.85rem] mb-6">
642
+ Control the simulation loops that connect Knowledge Graph entities into living agents.
643
+ </p>
644
+ {msg && (
645
+ <div className={`py-2.5 px-3.5 mb-4 font-mono text-[0.82rem] border ${msg.startsWith('✓') ? 'bg-[#dcfce7] text-[#166534] border-[#bbf7d0]' : 'bg-[#fef2f2] text-[#dc2626] border-[#fecaca]'}`}>
646
+ {msg}
647
+ </div>
648
+ )}
649
+ <div className="flex flex-col gap-4 max-w-[420px]">
650
+ {[
651
+ { endpoint:'/v1/simulation/generate_personas', label:'Generate Agent Personas', icon:<Users size={14}/>,
652
+ desc:'Converts raw graph nodes into living psychological profiles for agent simulation.' },
653
+ { endpoint:'/v1/simulation/tick', label:'Force Simulation Tick', icon:<Play size={14}/>,
654
+ desc:'Forces agents to read their local graph memory and output a new interaction edge.' },
655
+ ].map(item => (
656
+ <div key={item.endpoint} className="border-2 border-black p-4">
657
+ <button className="btn btn-primary flex gap-2 items-center w-full justify-center mb-2" onClick={() => trigger(item.endpoint, item.label)} disabled={loading}>
658
+ {loading ? <Spinner/> : item.icon} {item.label}
659
+ </button>
660
+ <p className="m-0 text-[0.78rem] text-[var(--muted-color)] font-sans">{item.desc}</p>
661
+ </div>
662
+ ))}
663
+ </div>
664
+ </div>
665
+ );
666
+ };
667
+
668
+ // ─── Main Dashboard ───────────────────────────────────────────────────────────
669
+ export default function AdminDashboard() {
670
+ const { token, user } = useAuth();
671
+ const [activeTab, setActiveTab] = useState('overview');
672
+ const [stats, setStats] = useState<any>(null);
673
+ const [health, setHealth] = useState<any>(null);
674
+ const [error, setError] = useState<string | null>(null);
675
+
676
+ const fetchOverview = useCallback(async () => {
677
+ if (!token) return;
678
+ try {
679
+ const [statsRes, healthRes] = await Promise.all([
680
+ fetch(`${API_BASE}/admin/stats`, { headers: { Authorization: `Bearer ${token}` } }),
681
+ fetch(`${API_BASE}/system/health`, { headers: { Authorization: `Bearer ${token}` } }),
682
+ ]);
683
+ if (statsRes.ok) setStats(await statsRes.json());
684
+ if (healthRes.ok) setHealth(await healthRes.json());
685
+ } catch (err: any) {
686
+ setError(err.message);
687
+ }
688
+ }, [token]);
689
+
690
+ useEffect(() => { fetchOverview(); }, [fetchOverview]);
691
+
692
+ if (user && user.username !== 'admin' && !user.scopes?.includes('admin')) {
693
+ return (
694
+ <div className="container flex-center min-h-[60vh] flex-col gap-4">
695
+ <Shield size={48} className="opacity-30"/>
696
+ <h2 className="title-md">Access Denied</h2>
697
+ <p className="text-[var(--muted-color)]">You need administrative privileges to view this page.</p>
698
+ </div>
699
+ );
700
+ }
701
+
702
+ const TABS = [
703
+ { id:'overview', label:'Overview', icon:<BarChart2 size={14}/> },
704
+ { id:'users', label:'Users', icon:<Users size={14}/> },
705
+ { id:'documents', label:'Documents', icon:<Database size={14}/> },
706
+ { id:'graph', label:'Graph CRUD', icon:<GitBranch size={14}/> },
707
+ { id:'ontology', label:'Ontology', icon:<Settings size={14}/> },
708
+ { id:'workers', label:'Workers', icon:<Cpu size={14}/> },
709
+ { id:'enrichment', label:'Enrichment / Drift', icon:<Zap size={14}/> },
710
+ { id:'sandbox', label:'God-Mode Sandbox', icon:<Play size={14}/> },
711
+ ];
712
+
713
+ return (
714
+ <div className="container fade-in py-8 px-10 max-w-[1400px]">
715
+ {/* Header */}
716
+ <div className="flex justify-between items-start mb-6">
717
+ <div>
718
+ <h1 className="text-3xl font-bold font-display tracking-tight">Admin Control Center</h1>
719
+ <p className="text-[#666] mt-2 text-[0.95rem]">Manage graph data, workers, users, and platform configuration</p>
720
+ </div>
721
+ {error && (
722
+ <div className="bg-[#fef2f2] text-[#dc2626] py-2 px-4 border border-[#fecaca] font-mono text-[0.85rem] flex items-center gap-2 rounded shadow-sm">
723
+ <AlertTriangle size={15}/> {error}
724
+ </div>
725
+ )}
726
+ </div>
727
+
728
+ <div className="grid grid-cols-[240px_1fr] gap-8 mt-2">
729
+ {/* Sidebar nav */}
730
+ <div className="bg-white border-2 border-black p-3">
731
+ <nav className="flex flex-col gap-1.5">
732
+ {TABS.map(tab => (
733
+ <button
734
+ key={tab.id}
735
+ className={`flex items-center w-full justify-start gap-2.5 py-2 px-3 text-[0.85rem] font-medium border-[1.5px] transition-colors ${activeTab === tab.id ? 'bg-black text-white border-black' : 'bg-transparent text-black border-transparent hover:bg-gray-100'}`}
736
+ onClick={() => setActiveTab(tab.id)}
737
+ >
738
+ {tab.icon} {tab.label}
739
+ </button>
740
+ ))}
741
+ </nav>
742
+ </div>
743
+
744
+ {/* Main content */}
745
+ <div>
746
+ {activeTab === 'overview' && <OverviewTab stats={stats} health={health} onRefresh={fetchOverview}/>}
747
+ {activeTab === 'users' && <UsersTab token={token}/>}
748
+ {activeTab === 'documents' && <DocumentsTab token={token}/>}
749
+ {activeTab === 'graph' && <GraphCRUDTab token={token}/>}
750
+ {activeTab === 'ontology' && <OntologyGovernanceTab token={token}/>}
751
+ {activeTab === 'workers' && <WorkersTab token={token}/>}
752
+ {activeTab === 'enrichment' && <EnrichmentTab token={token}/>}
753
+ {activeTab === 'sandbox' && <SandboxTab token={token}/>}
754
+ </div>
755
+ </div>
756
+
757
+ <style>{`
758
+ @keyframes spin { 100% { transform: rotate(360deg); } }
759
+ `}</style>
760
+ </div>
761
+ );
762
+ }
frontend-react/src/views/Home.tsx ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useRef } from 'react';
2
+ import { useAuth } from '../context/AuthContext';
3
+ import { Network, Server, Cpu, Database, Activity, ArrowRight, Zap, GitBranch, MessageSquare, TrendingUp, RefreshCw } from 'lucide-react';
4
+
5
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
6
+
7
+ // Animated counter hook
8
+ function useCountUp(target: number, duration = 1200) {
9
+ const [val, setVal] = useState(0);
10
+ const prev = useRef(0);
11
+ useEffect(() => {
12
+ if (target === 0) { setVal(0); return; }
13
+ const start = prev.current;
14
+ const diff = target - start;
15
+ const startTime = performance.now();
16
+ const tick = (now: number) => {
17
+ const t = Math.min((now - startTime) / duration, 1);
18
+ const ease = 1 - Math.pow(1 - t, 3);
19
+ setVal(Math.round(start + diff * ease));
20
+ if (t < 1) requestAnimationFrame(tick);
21
+ else prev.current = target;
22
+ };
23
+ requestAnimationFrame(tick);
24
+ }, [target]);
25
+ return val;
26
+ }
27
+
28
+ const StatCounter: React.FC<{ value: number | string; label: string; suffix?: string }> = ({ value, label, suffix = '' }) => {
29
+ const numVal = typeof value === 'number' ? value : parseInt(String(value)) || 0;
30
+ const animated = useCountUp(numVal);
31
+ return (
32
+ <div className="hm-stat-block">
33
+ <div className="hm-stat-value">{typeof value === 'number' ? animated : value}{suffix}</div>
34
+ <div className="hm-stat-key">{label}</div>
35
+ </div>
36
+ );
37
+ };
38
+
39
+ const Home: React.FC = () => {
40
+ const { token, logout, user } = useAuth();
41
+ const [health, setHealth] = useState<any>(null);
42
+ const [stats, setStats] = useState<any>(null);
43
+ const [myStats, setMyStats] = useState<any>(null);
44
+ const [loading, setLoading] = useState(true);
45
+ const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
46
+
47
+ const fetchData = async () => {
48
+ setLoading(true);
49
+ try {
50
+ const [healthRes, statsRes, myStatsRes] = await Promise.all([
51
+ fetch(`${API_BASE}/system/health`),
52
+ fetch(`${API_BASE}/system/stats`, { headers: { Authorization: `Bearer ${token}` } }),
53
+ fetch(`${API_BASE}/system/my-stats`, { headers: { Authorization: `Bearer ${token}` } }).catch(() => null),
54
+ ]);
55
+ if (statsRes.status === 401) { logout(); return; }
56
+ if (healthRes.ok) setHealth(await healthRes.json());
57
+ if (statsRes.ok) setStats(await statsRes.json());
58
+ if (myStatsRes?.ok) setMyStats(await myStatsRes.json());
59
+ } catch (err) {
60
+ console.error('Failed to fetch system data', err);
61
+ } finally {
62
+ setLoading(false);
63
+ setLastRefresh(new Date());
64
+ }
65
+ };
66
+
67
+ useEffect(() => {
68
+ fetchData();
69
+ const interval = setInterval(fetchData, 30000);
70
+ return () => clearInterval(interval);
71
+ }, [token]);
72
+
73
+ const isOnline = (v: boolean | undefined) => v === true;
74
+
75
+ const systemOk = health ? (isOnline(health.neo4j_connected) && isOnline(health.redis_connected)) : false;
76
+
77
+ return (
78
+ <div className="container fade-in max-w-[1300px] pb-12">
79
+
80
+ {/* ── Hero ─────────────────────────────────────── */}
81
+ <div className="hm-hero">
82
+ <div className="hm-hero-left">
83
+ <div className="hm-hero-label">CORTEX PLATFORM</div>
84
+ <h1 className="hm-hero-title">
85
+ Agentic Knowledge<br />
86
+ <span className="hm-hero-accent">Intelligence</span>
87
+ </h1>
88
+ <p className="hm-hero-sub">
89
+ Production-grade knowledge graph · Real-time extraction · Multi-hop reasoning
90
+ </p>
91
+ <div className="hm-hero-ctas">
92
+ <a href="/process" className="hm-cta-primary">
93
+ <Database size={16} /> Ingest Documents <ArrowRight size={14} />
94
+ </a>
95
+ <a href="/interact" className="hm-cta-secondary">
96
+ <Cpu size={16} /> Query Graph <ArrowRight size={14} />
97
+ </a>
98
+ </div>
99
+ </div>
100
+
101
+ <div className="hm-hero-right">
102
+ <div className={`hm-system-badge ${systemOk ? 'ok' : 'warn'}`}>
103
+ <span className={`hm-pulse-dot ${systemOk ? 'green' : 'yellow'}`} />
104
+ <span className="hm-badge-label">
105
+ {loading ? 'CHECKING...' : systemOk ? 'SYSTEM OPERATIONAL' : 'SYSTEM DEGRADED'}
106
+ </span>
107
+ <button className="hm-refresh-btn" onClick={fetchData} title="Refresh">
108
+ <RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
109
+ </button>
110
+ </div>
111
+
112
+ <div className="hm-infra-stack">
113
+ {[
114
+ { label: 'NEO4J', ok: isOnline(health?.neo4j_connected) },
115
+ { label: 'REDIS', ok: isOnline(health?.redis_connected) },
116
+ { label: `${health?.workers_active ?? 0} WORKERS`, ok: true, neutral: true },
117
+ { label: 'API', ok: true },
118
+ ].map(s => (
119
+ <div key={s.label} className="hm-infra-row">
120
+ <span className={`hm-dot ${s.neutral ? 'neutral' : s.ok ? 'online' : 'offline'}`} />
121
+ <span className="hm-infra-name">{s.label}</span>
122
+ <span className={`hm-infra-badge ${s.neutral ? 'neutral' : s.ok ? 'ok' : 'fail'}`}>
123
+ {s.neutral ? 'ACTIVE' : s.ok ? 'CONNECTED' : 'OFFLINE'}
124
+ </span>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ </div>
129
+ </div>
130
+
131
+ {/* ── Platform Metrics ─────────────────────────── */}
132
+ <div className="hm-section">
133
+ <div className="hm-section-label">PLATFORM METRICS</div>
134
+ <div className="hm-metrics-row">
135
+ <StatCounter value={stats?.documents_count ?? 0} label="DOCUMENTS" />
136
+ <div className="hm-metric-divider" />
137
+ <StatCounter value={stats?.entities_count ?? 0} label="ENTITIES" />
138
+ <div className="hm-metric-divider" />
139
+ <StatCounter value={stats?.relationships_count ?? 0} label="RELATIONSHIPS" />
140
+ <div className="hm-metric-divider" />
141
+ <StatCounter value={stats?.chunks_count ?? 0} label="CHUNKS" />
142
+ <div className="hm-metric-divider" />
143
+ <div className="hm-stat-block">
144
+ <div className="hm-stat-value text-2xl">
145
+ {stats?.ontology_version ?? '—'}
146
+ </div>
147
+ <div className="hm-stat-key">ONTOLOGY VER</div>
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ {/* ── Main Grid ────────────────────────────────── */}
153
+ <div className="hm-main-grid">
154
+
155
+ {/* Quick Actions */}
156
+ <div className="hm-card hm-actions-card">
157
+ <div className="hm-card-head">
158
+ <Activity size={16} /> QUICK ACTIONS
159
+ </div>
160
+ <div className="hm-action-list">
161
+ {[
162
+ { href: '/process', icon: <Database size={18}/>, label: 'INGEST DOCUMENTS', desc: 'Upload PDFs, text, or crawl URLs' },
163
+ { href: '/interact', icon: <Cpu size={18}/>, label: 'QUERY KNOWLEDGE', desc: 'Ask questions across the graph' },
164
+ { href: '/simulate', icon: <Network size={18}/>, label: 'EXPLORE NODES', desc: 'Interactive D3 force visualization' },
165
+ { href: '/ontology', icon: <Server size={18}/>, label: 'MANAGE ONTOLOGY', desc: 'Edit schema & run AI refinement' },
166
+ { href: '/insights', icon: <TrendingUp size={18}/>,label: 'INSIGHTS', desc: 'Quality metrics & AI reports' },
167
+ ].map(a => (
168
+ <a key={a.href} href={a.href} className="hm-action-item">
169
+ <span className="hm-action-icon">{a.icon}</span>
170
+ <div className="hm-action-text">
171
+ <div className="hm-action-label">{a.label}</div>
172
+ <div className="hm-action-desc">{a.desc}</div>
173
+ </div>
174
+ <ArrowRight size={14} className="hm-action-arrow" />
175
+ </a>
176
+ ))}
177
+ </div>
178
+ </div>
179
+
180
+ {/* Right column: My Activity + Feature cards */}
181
+ <div className="flex flex-col gap-6">
182
+
183
+ {/* User Activity */}
184
+ <div className="hm-card">
185
+ <div className="hm-card-head">
186
+ <MessageSquare size={16} /> MY ACTIVITY
187
+ {user && <span className="ml-auto font-mono text-[0.7rem] text-[#666666]">@{user.username}</span>}
188
+ </div>
189
+ <div className="hm-activity-grid">
190
+ <div className="hm-activity-chip">
191
+ <div className="hm-activity-val">{myStats?.conversation_count ?? '—'}</div>
192
+ <div className="hm-activity-key">CONVERSATIONS</div>
193
+ </div>
194
+ <div className="hm-activity-chip">
195
+ <div className="hm-activity-val">{myStats?.message_count ?? '—'}</div>
196
+ <div className="hm-activity-key">QUERIES SENT</div>
197
+ </div>
198
+ <div className="hm-activity-chip">
199
+ <div className="hm-activity-val">{myStats?.last_active ? new Date(myStats.last_active).toLocaleDateString() : '—'}</div>
200
+ <div className="hm-activity-key">LAST ACTIVE</div>
201
+ </div>
202
+ </div>
203
+ {!myStats && (
204
+ <div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.78rem', color: 'var(--muted-color)', marginTop: '0.75rem' }}>
205
+ Start querying the graph to build your activity history.
206
+ </div>
207
+ )}
208
+ </div>
209
+
210
+ {/* Graph intelligence card */}
211
+ <div className="hm-card hm-graph-card">
212
+ <div className="hm-card-head">
213
+ <GitBranch size={16} /> KNOWLEDGE GRAPH
214
+ </div>
215
+ <div style={{ fontSize: '0.85rem', lineHeight: 1.7, color: 'var(--muted-color)', marginBottom: '1rem' }}>
216
+ Neo4j-powered semantic knowledge graph. Multi-hop reasoning, entity enrichment, and community detection built in.
217
+ </div>
218
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
219
+ {['Entities', 'Relationships', 'Communities', 'Graph Export'].map(tag => (
220
+ <span key={tag} className="hm-tag">{tag}</span>
221
+ ))}
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+
227
+ {/* ── Feature Showcase ─────────────────────────── */}
228
+ <div className="hm-features-section">
229
+ <div className="hm-section-label" style={{ marginBottom: '1.5rem' }}>PLATFORM CAPABILITIES</div>
230
+ <div className="hm-features-grid">
231
+ {[
232
+ {
233
+ icon: <Database size={24}/>,
234
+ title: 'DOCUMENT INGESTION',
235
+ desc: 'Ingest PDFs, text files, Markdown, and web URLs. Celery workers extract entities and relationships into the knowledge graph automatically via LLM pipelines.',
236
+ color: '#2563eb',
237
+ },
238
+ {
239
+ icon: <Network size={24}/>,
240
+ title: 'GRAPH INTELLIGENCE',
241
+ desc: 'Neo4j-powered knowledge graph with rich entity relationships. Query across documents globally or per-source with full ontology control.',
242
+ color: '#7c3aed',
243
+ },
244
+ {
245
+ icon: <Cpu size={24}/>,
246
+ title: 'AGENTIC LOGIC',
247
+ desc: 'Multi-step ReACT reasoning agent that searches the graph, retrieves relevant chunks, and streams answers with confidence scoring in real time.',
248
+ color: '#059669',
249
+ },
250
+ {
251
+ icon: <Zap size={24}/>,
252
+ title: 'LLM-AS-JUDGE',
253
+ desc: 'Inline faithfulness evaluation using heuristic scoring. Detects hallucination risk, context precision, and answer quality on every response.',
254
+ color: '#d97706',
255
+ },
256
+ {
257
+ icon: <Activity size={24}/>,
258
+ title: 'LIVE SIMULATION',
259
+ desc: 'Interactive D3 force graph with color-coded entity types, physics controls, fullscreen mode, PNG export, and node detail modals.',
260
+ color: '#dc2626',
261
+ },
262
+ {
263
+ icon: <TrendingUp size={24}/>,
264
+ title: 'ONTOLOGY DRIFT',
265
+ desc: 'Automated schema drift detection that spots when new data no longer fits the current ontology. Propose and approve schema expansions.',
266
+ color: '#0891b2',
267
+ },
268
+ ].map(f => (
269
+ <div key={f.title} className="hm-feature-card" style={{ '--feature-color': f.color } as any}>
270
+ <div className="hm-feature-icon" style={{ color: f.color }}>{f.icon}</div>
271
+ <div className="hm-feature-title">{f.title}</div>
272
+ <div className="hm-feature-desc">{f.desc}</div>
273
+ </div>
274
+ ))}
275
+ </div>
276
+ </div>
277
+
278
+ {/* Footer bar */}
279
+ <div className="hm-footer-bar">
280
+ <span className="hm-footer-brand">CORTEX_PLATFORM</span>
281
+ <span style={{ color: 'var(--muted-color)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}>
282
+ Last refreshed: {lastRefresh.toLocaleTimeString()}
283
+ </span>
284
+ <span style={{ color: 'var(--muted-color)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}>
285
+ v{health ? '1.0' : '—'} · Neo4j + Redis + Celery
286
+ </span>
287
+ </div>
288
+
289
+ <style>{`
290
+ /* ── Hero ── */
291
+ .hm-hero {
292
+ display: grid;
293
+ grid-template-columns: 1fr 320px;
294
+ gap: 2rem;
295
+ padding: 2.5rem 0 2rem;
296
+ border-bottom: 3px solid #000;
297
+ margin-bottom: 2.5rem;
298
+ align-items: center;
299
+ }
300
+ .hm-hero-label {
301
+ font-family: var(--font-mono);
302
+ font-size: 0.72rem;
303
+ font-weight: 700;
304
+ letter-spacing: 3px;
305
+ color: var(--muted-color);
306
+ margin-bottom: 0.75rem;
307
+ }
308
+ .hm-hero-title {
309
+ font-family: var(--font-display);
310
+ font-size: clamp(2rem, 4vw, 3rem);
311
+ font-weight: 800;
312
+ line-height: 1.1;
313
+ margin-bottom: 0.75rem;
314
+ letter-spacing: -0.5px;
315
+ }
316
+ .hm-hero-accent {
317
+ position: relative;
318
+ display: inline-block;
319
+ }
320
+ .hm-hero-accent::after {
321
+ content: '';
322
+ position: absolute;
323
+ left: 0; bottom: 2px;
324
+ width: 100%; height: 4px;
325
+ background: #000;
326
+ }
327
+ .hm-hero-sub {
328
+ color: var(--muted-color);
329
+ font-size: 0.95rem;
330
+ line-height: 1.6;
331
+ margin-bottom: 1.5rem;
332
+ max-width: 480px;
333
+ }
334
+ .hm-hero-ctas {
335
+ display: flex;
336
+ gap: 0.75rem;
337
+ flex-wrap: wrap;
338
+ }
339
+ .hm-cta-primary {
340
+ display: inline-flex;
341
+ align-items: center;
342
+ gap: 0.5rem;
343
+ background: #000;
344
+ color: #fff;
345
+ padding: 0.65rem 1.25rem;
346
+ font-family: var(--font-mono);
347
+ font-size: 0.8rem;
348
+ font-weight: 700;
349
+ letter-spacing: 0.5px;
350
+ text-decoration: none;
351
+ transition: background 0.15s, transform 0.15s;
352
+ border: 2px solid #000;
353
+ }
354
+ .hm-cta-primary:hover {
355
+ background: #333;
356
+ color: #fff;
357
+ transform: translateY(-1px);
358
+ box-shadow: 3px 3px 0 rgba(0,0,0,0.2);
359
+ }
360
+ .hm-cta-secondary {
361
+ display: inline-flex;
362
+ align-items: center;
363
+ gap: 0.5rem;
364
+ background: transparent;
365
+ color: #000;
366
+ padding: 0.65rem 1.25rem;
367
+ font-family: var(--font-mono);
368
+ font-size: 0.8rem;
369
+ font-weight: 700;
370
+ letter-spacing: 0.5px;
371
+ text-decoration: none;
372
+ border: 2px solid #000;
373
+ transition: background 0.15s, transform 0.15s;
374
+ }
375
+ .hm-cta-secondary:hover {
376
+ background: #000;
377
+ color: #fff;
378
+ transform: translateY(-1px);
379
+ }
380
+
381
+ /* System badge */
382
+ .hm-system-badge {
383
+ display: flex;
384
+ align-items: center;
385
+ gap: 0.6rem;
386
+ border: 2px solid #000;
387
+ padding: 0.5rem 0.85rem;
388
+ margin-bottom: 1rem;
389
+ font-family: var(--font-mono);
390
+ font-size: 0.72rem;
391
+ font-weight: 700;
392
+ letter-spacing: 1px;
393
+ }
394
+ .hm-system-badge.warn { border-color: #d97706; color: #d97706; }
395
+ .hm-pulse-dot {
396
+ width: 8px; height: 8px;
397
+ border-radius: 50%;
398
+ flex-shrink: 0;
399
+ animation: pulseGlow 2s ease-in-out infinite;
400
+ }
401
+ .hm-pulse-dot.green { background: #16a34a; box-shadow: 0 0 0 0 rgba(22,163,74,0.4); }
402
+ .hm-pulse-dot.yellow { background: #d97706; box-shadow: 0 0 0 0 rgba(217,119,6,0.4); }
403
+ @keyframes pulseGlow {
404
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(22,163,74,0.4); }
405
+ 50% { box-shadow: 0 0 0 5px rgba(22,163,74,0); }
406
+ }
407
+ .hm-badge-label { flex: 1; }
408
+ .hm-refresh-btn {
409
+ background: none;
410
+ border: none;
411
+ cursor: pointer;
412
+ padding: 0;
413
+ color: var(--muted-color);
414
+ display: flex;
415
+ align-items: center;
416
+ transition: color 0.15s;
417
+ }
418
+ .hm-refresh-btn:hover { color: #000; background: none; }
419
+
420
+ /* Infra stack */
421
+ .hm-infra-stack { display: flex; flex-direction: column; gap: 0.5rem; }
422
+ .hm-infra-row {
423
+ display: flex;
424
+ align-items: center;
425
+ gap: 0.5rem;
426
+ padding: 0.4rem 0.6rem;
427
+ border: 1.5px solid #e5e5e5;
428
+ font-family: var(--font-mono);
429
+ font-size: 0.78rem;
430
+ }
431
+ .hm-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
432
+ .hm-dot.online { background: #16a34a; }
433
+ .hm-dot.offline { background: #dc2626; }
434
+ .hm-dot.neutral { background: #9ca3af; }
435
+ .hm-infra-name { flex: 1; font-weight: 600; color: var(--muted-color); }
436
+ .hm-infra-badge {
437
+ font-size: 0.65rem;
438
+ font-weight: 700;
439
+ padding: 1px 6px;
440
+ letter-spacing: 0.5px;
441
+ }
442
+ .hm-infra-badge.ok { background: #dcfce7; color: #16a34a; }
443
+ .hm-infra-badge.fail { background: #fee2e2; color: #dc2626; }
444
+ .hm-infra-badge.neutral { background: #f3f4f6; color: #6b7280; }
445
+
446
+ /* ── Section label ── */
447
+ .hm-section { margin-bottom: 2.5rem; }
448
+ .hm-section-label {
449
+ font-family: var(--font-mono);
450
+ font-size: 0.68rem;
451
+ font-weight: 700;
452
+ letter-spacing: 3px;
453
+ color: var(--muted-color);
454
+ margin-bottom: 1rem;
455
+ }
456
+
457
+ /* ── Metrics row ── */
458
+ .hm-metrics-row {
459
+ display: flex;
460
+ align-items: center;
461
+ border: 3px solid #000;
462
+ overflow: hidden;
463
+ }
464
+ .hm-stat-block {
465
+ flex: 1;
466
+ min-width: 0;
467
+ padding: 1.25rem 1.5rem;
468
+ text-align: center;
469
+ }
470
+ .hm-stat-value {
471
+ font-family: var(--font-mono);
472
+ font-size: 2.2rem;
473
+ font-weight: 900;
474
+ line-height: 1;
475
+ margin-bottom: 0.3rem;
476
+ }
477
+ .hm-stat-key {
478
+ font-family: var(--font-mono);
479
+ font-size: 0.6rem;
480
+ color: var(--muted-color);
481
+ letter-spacing: 1.5px;
482
+ font-weight: 700;
483
+ }
484
+ .hm-metric-divider {
485
+ width: 1px;
486
+ height: 60px;
487
+ background: #000;
488
+ flex-shrink: 0;
489
+ }
490
+
491
+ /* ── Main grid ── */
492
+ .hm-main-grid {
493
+ display: grid;
494
+ grid-template-columns: 1fr 360px;
495
+ gap: 1.5rem;
496
+ margin-bottom: 3rem;
497
+ }
498
+
499
+ /* ── Card base ── */
500
+ .hm-card {
501
+ border: 2px solid #000;
502
+ padding: 1.5rem;
503
+ background: #fff;
504
+ transition: transform 0.15s, box-shadow 0.15s;
505
+ }
506
+ .hm-card:hover {
507
+ transform: translateY(-2px);
508
+ box-shadow: 4px 4px 0 #000;
509
+ }
510
+ .hm-card-head {
511
+ display: flex;
512
+ align-items: center;
513
+ gap: 0.5rem;
514
+ font-family: var(--font-mono);
515
+ font-size: 0.72rem;
516
+ font-weight: 700;
517
+ letter-spacing: 1.5px;
518
+ margin-bottom: 1.25rem;
519
+ padding-bottom: 0.75rem;
520
+ border-bottom: 2px solid #000;
521
+ }
522
+
523
+ /* ── Actions ── */
524
+ .hm-actions-card {
525
+ background: #fafafa;
526
+ }
527
+ .hm-action-list { display: flex; flex-direction: column; gap: 0.4rem; }
528
+ .hm-action-item {
529
+ display: flex;
530
+ align-items: center;
531
+ gap: 0.75rem;
532
+ padding: 0.7rem 0.75rem;
533
+ border: 1.5px solid #e5e5e5;
534
+ text-decoration: none;
535
+ color: #000;
536
+ transition: all 0.15s;
537
+ background: #fff;
538
+ }
539
+ .hm-action-item:hover {
540
+ background: #000;
541
+ color: #fff;
542
+ border-color: #000;
543
+ transform: translateX(3px);
544
+ box-shadow: -3px 3px 0 rgba(0,0,0,0.1);
545
+ }
546
+ .hm-action-icon {
547
+ width: 36px; height: 36px;
548
+ display: flex; align-items: center; justify-content: center;
549
+ background: #000;
550
+ color: #fff;
551
+ flex-shrink: 0;
552
+ transition: background 0.15s;
553
+ }
554
+ .hm-action-item:hover .hm-action-icon { background: #fff; color: #000; }
555
+ .hm-action-text { flex: 1; min-width: 0; }
556
+ .hm-action-label {
557
+ font-family: var(--font-mono);
558
+ font-size: 0.8rem;
559
+ font-weight: 700;
560
+ letter-spacing: 0.5px;
561
+ }
562
+ .hm-action-desc {
563
+ font-size: 0.75rem;
564
+ color: var(--muted-color);
565
+ margin-top: 0.1rem;
566
+ }
567
+ .hm-action-item:hover .hm-action-desc { color: rgba(255,255,255,0.7); }
568
+ .hm-action-arrow { flex-shrink: 0; transition: transform 0.15s; }
569
+ .hm-action-item:hover .hm-action-arrow { transform: translateX(3px); }
570
+
571
+ /* ── Activity ── */
572
+ .hm-activity-grid {
573
+ display: grid;
574
+ grid-template-columns: repeat(3, 1fr);
575
+ gap: 0.75rem;
576
+ }
577
+ .hm-activity-chip {
578
+ border: 1.5px solid #e5e5e5;
579
+ padding: 0.75rem 0.5rem;
580
+ text-align: center;
581
+ }
582
+ .hm-activity-val {
583
+ font-family: var(--font-mono);
584
+ font-size: 1.3rem;
585
+ font-weight: 900;
586
+ line-height: 1;
587
+ margin-bottom: 0.25rem;
588
+ }
589
+ .hm-activity-key {
590
+ font-family: var(--font-mono);
591
+ font-size: 0.58rem;
592
+ color: var(--muted-color);
593
+ letter-spacing: 1px;
594
+ font-weight: 700;
595
+ }
596
+
597
+ /* ── Graph card ── */
598
+ .hm-graph-card {}
599
+ .hm-tag {
600
+ display: inline-block;
601
+ background: #f3f4f6;
602
+ border: 1.5px solid #e5e5e5;
603
+ font-family: var(--font-mono);
604
+ font-size: 0.68rem;
605
+ font-weight: 700;
606
+ padding: 2px 8px;
607
+ letter-spacing: 0.5px;
608
+ }
609
+
610
+ /* ── Features ── */
611
+ .hm-features-section {
612
+ border-top: 3px solid #000;
613
+ padding-top: 2.5rem;
614
+ margin-bottom: 2rem;
615
+ }
616
+ .hm-features-grid {
617
+ display: grid;
618
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
619
+ gap: 1rem;
620
+ }
621
+ .hm-feature-card {
622
+ border: 2px solid #e5e5e5;
623
+ padding: 1.5rem;
624
+ transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
625
+ position: relative;
626
+ overflow: hidden;
627
+ }
628
+ .hm-feature-card::before {
629
+ content: '';
630
+ position: absolute;
631
+ left: 0; top: 0; bottom: 0;
632
+ width: 3px;
633
+ background: var(--feature-color, #000);
634
+ opacity: 0;
635
+ transition: opacity 0.2s;
636
+ }
637
+ .hm-feature-card:hover {
638
+ border-color: #000;
639
+ box-shadow: 4px 4px 0 #000;
640
+ transform: translateY(-2px);
641
+ }
642
+ .hm-feature-card:hover::before { opacity: 1; }
643
+ .hm-feature-icon {
644
+ margin-bottom: 0.85rem;
645
+ transition: transform 0.2s;
646
+ }
647
+ .hm-feature-card:hover .hm-feature-icon { transform: scale(1.1); }
648
+ .hm-feature-title {
649
+ font-family: var(--font-mono);
650
+ font-size: 0.72rem;
651
+ font-weight: 700;
652
+ letter-spacing: 1px;
653
+ margin-bottom: 0.5rem;
654
+ }
655
+ .hm-feature-desc {
656
+ font-size: 0.82rem;
657
+ color: var(--muted-color);
658
+ line-height: 1.65;
659
+ }
660
+
661
+ /* ── Footer ── */
662
+ .hm-footer-bar {
663
+ display: flex;
664
+ align-items: center;
665
+ gap: 1.5rem;
666
+ border-top: 1px solid #e5e5e5;
667
+ padding-top: 1.25rem;
668
+ margin-top: 1rem;
669
+ }
670
+ .hm-footer-brand {
671
+ font-family: var(--font-mono);
672
+ font-size: 0.72rem;
673
+ font-weight: 700;
674
+ letter-spacing: 2px;
675
+ margin-right: auto;
676
+ }
677
+
678
+ /* ── Responsive ── */
679
+ @media (max-width: 900px) {
680
+ .hm-hero { grid-template-columns: 1fr; }
681
+ .hm-hero-right { order: -1; }
682
+ .hm-main-grid { grid-template-columns: 1fr; }
683
+ .hm-metrics-row { flex-wrap: wrap; }
684
+ .hm-metric-divider { display: none; }
685
+ .hm-stat-block { flex: 0 0 50%; border-bottom: 1px solid #000; }
686
+ }
687
+ `}</style>
688
+ </div>
689
+ );
690
+ };
691
+
692
+ export default Home;
frontend-react/src/views/InsightsView.tsx ADDED
@@ -0,0 +1,872 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { useAuth } from '../context/AuthContext';
3
+ import { BarChart2, TrendingUp, AlertTriangle, CheckCircle, RefreshCw, Zap, FileText, GitCommit, MessageSquare } from 'lucide-react';
4
+
5
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
6
+
7
+ interface EvalDashboard {
8
+ total_evaluations: number;
9
+ avg_overall_score: number;
10
+ avg_faithfulness: number;
11
+ avg_relevancy: number;
12
+ hallucination_rate: number;
13
+ trend_data: TrendPoint[];
14
+ }
15
+
16
+ interface TrendPoint {
17
+ timestamp: string;
18
+ overall_score: number;
19
+ faithfulness: number;
20
+ answer_relevancy: number;
21
+ hallucination_detected: boolean;
22
+ document_id?: string;
23
+ }
24
+
25
+ interface EvalForm {
26
+ question: string;
27
+ answer: string;
28
+ contexts: string;
29
+ }
30
+
31
+ // Helper: build markdown from report result
32
+ function buildReportMarkdown(result: any, topic: string): string {
33
+ const lines: string[] = [];
34
+ lines.push(`# ${result.topic || topic}`);
35
+ lines.push('');
36
+ if (result.confidence !== undefined) {
37
+ lines.push(`**Confidence:** ${(result.confidence * 100).toFixed(1)}%`);
38
+ }
39
+ if (result.tool_calls_made !== undefined) {
40
+ lines.push(`**Tool calls:** ${result.tool_calls_made}`);
41
+ }
42
+ lines.push(`**Generated:** ${new Date().toLocaleString()}`);
43
+ lines.push('');
44
+
45
+ if (result.executive_summary) {
46
+ lines.push('## Executive Summary');
47
+ lines.push('');
48
+ lines.push(result.executive_summary);
49
+ lines.push('');
50
+ }
51
+
52
+ if (result.sections && typeof result.sections === 'object' && !Array.isArray(result.sections)) {
53
+ Object.entries(result.sections).forEach(([title, content], i) => {
54
+ lines.push(`## ${i + 1}. ${title}`);
55
+ lines.push('');
56
+ lines.push(String(content));
57
+ lines.push('');
58
+ });
59
+ } else if (Array.isArray(result.sections)) {
60
+ result.sections.forEach((s: any, i: number) => {
61
+ lines.push(`## ${i + 1}. ${s.title || ''}`);
62
+ lines.push('');
63
+ lines.push(s.content || '');
64
+ lines.push('');
65
+ });
66
+ } else if (!result.sections) {
67
+ lines.push(result.report || result.content || result.markdown || JSON.stringify(result, null, 2));
68
+ lines.push('');
69
+ }
70
+
71
+ if (result.key_entities && result.key_entities.length > 0) {
72
+ lines.push('## Key Entities');
73
+ lines.push('');
74
+ lines.push(result.key_entities.join(', '));
75
+ }
76
+
77
+ return lines.join('\n');
78
+ }
79
+
80
+ const InsightsView: React.FC = () => {
81
+ const { token } = useAuth();
82
+ const [dashboard, setDashboard] = useState<EvalDashboard | null>(null);
83
+ const [loading, setLoading] = useState(false);
84
+ const [evalForm, setEvalForm] = useState<EvalForm>({ question: '', answer: '', contexts: '' });
85
+ const [evalResult, setEvalResult] = useState<any>(null);
86
+ const [evalLoading, setEvalLoading] = useState(false);
87
+ const [communities, setCommunities] = useState<any[]>([]);
88
+ const [communityLoading, setCommunityLoading] = useState(false);
89
+ const [activeTab, setActiveTab] = useState<'metrics' | 'evaluate' | 'communities' | 'export' | 'report' | 'graph-update' | 'entity-chat'>('metrics');
90
+
91
+ // Report Agent state
92
+ const [reportTopic, setReportTopic] = useState('');
93
+ const [reportDepth, setReportDepth] = useState(3);
94
+ const [reportResult, setReportResult] = useState<any>(null);
95
+ const [reportLoading, setReportLoading] = useState(false);
96
+ const [reportTimestamp, setReportTimestamp] = useState<Date | null>(null);
97
+ const [copyDone, setCopyDone] = useState(false);
98
+
99
+ // Graph Update state
100
+ const [updateText, setUpdateText] = useState('');
101
+ const [updateResult, setUpdateResult] = useState<any>(null);
102
+ const [updateLoading, setUpdateLoading] = useState(false);
103
+
104
+ // Entity Chat state
105
+ const [entities, setEntities] = useState<any[]>([]);
106
+ const [selectedEntity, setSelectedEntity] = useState('');
107
+ const [entityContext, setEntityContext] = useState('');
108
+ const [chatMsg, setChatMsg] = useState('');
109
+ const [chatHistory, setChatHistory] = useState<{role: string; content: string}[]>([]);
110
+ const [chatLoading, setChatLoading] = useState(false);
111
+
112
+ const fetchDashboard = useCallback(async () => {
113
+ setLoading(true);
114
+ try {
115
+ const res = await fetch(`${API_BASE}/eval/dashboard?limit=200`, {
116
+ headers: { Authorization: `Bearer ${token}` }
117
+ });
118
+ if (res.ok) setDashboard(await res.json());
119
+ } catch (e) { console.error(e); }
120
+ setLoading(false);
121
+ }, [token]);
122
+
123
+ const fetchCommunities = useCallback(async () => {
124
+ setCommunityLoading(true);
125
+ try {
126
+ const res = await fetch(`${API_BASE}/graph/communities?limit=30`, {
127
+ headers: { Authorization: `Bearer ${token}` }
128
+ });
129
+ if (res.ok) {
130
+ const data = await res.json();
131
+ setCommunities(data.communities || []);
132
+ }
133
+ } catch (e) { console.error(e); }
134
+ setCommunityLoading(false);
135
+ }, [token]);
136
+
137
+ const fetchEntities = useCallback(async () => {
138
+ try {
139
+ const res = await fetch(`${API_BASE}/admin/graph/nodes?query=&limit=200`, {
140
+ headers: { Authorization: `Bearer ${token}` }
141
+ });
142
+ if (res.ok) {
143
+ const data = await res.json();
144
+ setEntities((data.nodes || []).slice(0, 100));
145
+ }
146
+ } catch {}
147
+ }, [token]);
148
+
149
+ useEffect(() => {
150
+ fetchDashboard();
151
+ fetchCommunities();
152
+ fetchEntities();
153
+ }, [fetchDashboard, fetchCommunities, fetchEntities]);
154
+
155
+ const runEval = async () => {
156
+ if (!evalForm.question || !evalForm.answer || !evalForm.contexts) return;
157
+ setEvalLoading(true);
158
+ setEvalResult(null);
159
+ try {
160
+ const res = await fetch(`${API_BASE}/eval/score`, {
161
+ method: 'POST',
162
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
163
+ body: JSON.stringify({
164
+ question: evalForm.question,
165
+ answer: evalForm.answer,
166
+ contexts: evalForm.contexts.split('\n---\n').map(c => c.trim()).filter(Boolean)
167
+ })
168
+ });
169
+ if (res.ok) {
170
+ setEvalResult(await res.json());
171
+ fetchDashboard(); // Refresh metrics
172
+ }
173
+ } catch (e) { console.error(e); }
174
+ setEvalLoading(false);
175
+ };
176
+
177
+ const runReport = async () => {
178
+ if (!reportTopic.trim()) return;
179
+ setReportLoading(true); setReportResult(null);
180
+ try {
181
+ const res = await fetch(`${API_BASE}/report`, {
182
+ method: 'POST',
183
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
184
+ body: JSON.stringify({ topic: reportTopic, depth: reportDepth })
185
+ });
186
+ if (res.ok) {
187
+ setReportResult(await res.json());
188
+ setReportTimestamp(new Date());
189
+ }
190
+ } catch (e) { console.error(e); }
191
+ setReportLoading(false);
192
+ };
193
+
194
+ const runGraphUpdate = async () => {
195
+ if (!updateText.trim()) return;
196
+ setUpdateLoading(true); setUpdateResult(null);
197
+ try {
198
+ const res = await fetch(`${API_BASE}/graph/update`, {
199
+ method: 'POST',
200
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
201
+ body: JSON.stringify({ text: updateText })
202
+ });
203
+ if (res.ok) setUpdateResult(await res.json());
204
+ } catch (e) { console.error(e); }
205
+ setUpdateLoading(false);
206
+ };
207
+
208
+ const sendEntityChat = async () => {
209
+ if (!selectedEntity || !chatMsg.trim()) return;
210
+ const userMsg = { role: 'user', content: chatMsg };
211
+ setChatHistory(h => [...h, userMsg]);
212
+ setChatMsg('');
213
+ setChatLoading(true);
214
+ try {
215
+ const res = await fetch(`${API_BASE}/entities/${encodeURIComponent(selectedEntity)}/chat`, {
216
+ method: 'POST',
217
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
218
+ body: JSON.stringify({ message: userMsg.content })
219
+ });
220
+ if (res.ok) {
221
+ const data = await res.json();
222
+ setChatHistory(h => [...h, { role: 'assistant', content: data.response || data.answer || JSON.stringify(data) }]);
223
+ }
224
+ } catch (e) { console.error(e); }
225
+ setChatLoading(false);
226
+ };
227
+
228
+ const assignCommunities = async () => {
229
+ setCommunityLoading(true);
230
+ try {
231
+ const res = await fetch(`${API_BASE}/graph/communities/assign`, {
232
+ method: 'POST',
233
+ headers: { Authorization: `Bearer ${token}` }
234
+ });
235
+ if (res.ok) {
236
+ const data = await res.json();
237
+ alert(`✓ ${data.message}`);
238
+ fetchCommunities();
239
+ }
240
+ } catch (e) { console.error(e); }
241
+ setCommunityLoading(false);
242
+ };
243
+
244
+ const exportGraph = async (fmt: string) => {
245
+ const res = await fetch(`${API_BASE}/graph/export?format=${fmt}`, {
246
+ headers: { Authorization: `Bearer ${token}` }
247
+ });
248
+ if (!res.ok) return;
249
+ const blob = await res.blob();
250
+ const ext = fmt === 'json' ? 'json' : fmt === 'cypher' ? 'cypher' : 'graphml';
251
+ const url = URL.createObjectURL(blob);
252
+ const a = document.createElement('a');
253
+ a.href = url; a.download = `graph_export.${ext}`; a.click();
254
+ URL.revokeObjectURL(url);
255
+ };
256
+
257
+ const handleCopyReport = () => {
258
+ if (!reportResult) return;
259
+ const text = buildReportMarkdown(reportResult, reportTopic);
260
+ navigator.clipboard.writeText(text).then(() => {
261
+ setCopyDone(true);
262
+ setTimeout(() => setCopyDone(false), 2000);
263
+ });
264
+ };
265
+
266
+ const handleDownloadReport = () => {
267
+ if (!reportResult) return;
268
+ const text = buildReportMarkdown(reportResult, reportTopic);
269
+ const blob = new Blob([text], { type: 'text/markdown' });
270
+ const url = URL.createObjectURL(blob);
271
+ const a = document.createElement('a');
272
+ a.href = url;
273
+ a.download = `report_${reportTopic.slice(0, 30).replace(/\s+/g, '_')}.md`;
274
+ a.click();
275
+ URL.revokeObjectURL(url);
276
+ };
277
+
278
+ const score2color = (s: number) => {
279
+ if (s >= 0.8) return '#16a34a';
280
+ if (s >= 0.6) return '#d97706';
281
+ return '#dc2626';
282
+ };
283
+ const score2label = (s: number) => s >= 0.8 ? 'HIGH' : s >= 0.6 ? 'MEDIUM' : 'LOW';
284
+
285
+ const ScoreBar: React.FC<{ label: string; value: number }> = ({ label, value }) => (
286
+ <div style={{ marginBottom: '1rem' }}>
287
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
288
+ <span>{label}</span>
289
+ <span style={{ color: score2color(value), fontWeight: 700 }}>{(value * 100).toFixed(1)}%</span>
290
+ </div>
291
+ <div style={{ height: '8px', background: '#e5e7eb', position: 'relative' }}>
292
+ <div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: `${value * 100}%`, background: score2color(value), transition: 'width 0.6s ease' }} />
293
+ </div>
294
+ </div>
295
+ );
296
+
297
+ const confidenceBadgeColor = (c: number) =>
298
+ c >= 0.8 ? '#16a34a' : c >= 0.6 ? '#d97706' : '#dc2626';
299
+
300
+ return (
301
+ <div className="container" style={{ maxWidth: '1100px', paddingBottom: '3rem' }}>
302
+ <div className="page-header flex-between" style={{ marginBottom: '2rem' }}>
303
+ <div>
304
+ <h1>INSIGHTS HQ</h1>
305
+ <p className="mono-text">QUALITY METRICS // EVAL DASHBOARD // GRAPH INTELLIGENCE</p>
306
+ </div>
307
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
308
+ <button className="app-btn mono-text" onClick={fetchDashboard} disabled={loading} style={{ padding: '0.5rem 1rem', fontSize: '0.8rem' }}>
309
+ <RefreshCw size={14} style={{ display: 'inline', marginRight: '0.4rem' }} />
310
+ REFRESH
311
+ </button>
312
+ </div>
313
+ </div>
314
+
315
+ {/* Tab Nav */}
316
+ <div style={{ display: 'flex', borderBottom: '3px solid #000', marginBottom: '2rem', gap: 0, flexWrap: 'wrap' }}>
317
+ {([
318
+ { id: 'metrics', label: 'METRICS' },
319
+ { id: 'evaluate', label: 'EVALUATE' },
320
+ { id: 'communities', label: 'COMMUNITIES' },
321
+ { id: 'export', label: 'EXPORT' },
322
+ { id: 'report', label: '⚡ REPORT' },
323
+ { id: 'graph-update', label: '↑ LIVE UPDATE' },
324
+ { id: 'entity-chat', label: '💬 ENTITY CHAT' },
325
+ ] as const).map(t => (
326
+ <button
327
+ key={t.id}
328
+ onClick={() => setActiveTab(t.id as any)}
329
+ className="mono-text"
330
+ style={{
331
+ padding: '0.65rem 1.2rem', fontWeight: 700, fontSize: '0.78rem', letterSpacing: '0.8px',
332
+ border: 'none', borderBottom: activeTab === t.id ? '3px solid #000' : 'none',
333
+ background: activeTab === t.id ? '#000' : 'transparent',
334
+ color: activeTab === t.id ? '#fff' : '#000', cursor: 'pointer',
335
+ marginBottom: '-3px', textTransform: 'uppercase', whiteSpace: 'nowrap'
336
+ }}
337
+ >
338
+ {t.label}
339
+ </button>
340
+ ))}
341
+ </div>
342
+
343
+ {/* ── METRICS TAB ── */}
344
+ {activeTab === 'metrics' && (
345
+ <div>
346
+ {loading ? (
347
+ <div className="mono-text" style={{ textAlign: 'center', padding: '3rem', color: '#aaa' }}>LOADING METRICS...</div>
348
+ ) : !dashboard || dashboard.total_evaluations === 0 ? (
349
+ <div style={{ border: '3px solid #000', padding: '3rem', textAlign: 'center' }}>
350
+ <BarChart2 size={48} style={{ marginBottom: '1rem', opacity: 0.3 }} />
351
+ <p className="mono-text" style={{ color: '#777' }}>NO EVALUATION DATA YET</p>
352
+ <p className="mono-text" style={{ color: '#999', fontSize: '0.85rem', marginTop: '0.5rem' }}>
353
+ Use the EVALUATE tab to score Q&A pairs and build your quality history.
354
+ </p>
355
+ </div>
356
+ ) : (
357
+ <>
358
+ {/* KPI Cards */}
359
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
360
+ {[
361
+ { label: 'TOTAL EVALS', value: dashboard.total_evaluations, raw: true, icon: <BarChart2 size={20} /> },
362
+ { label: 'AVG QUALITY', value: dashboard.avg_overall_score, icon: <TrendingUp size={20} /> },
363
+ { label: 'FAITHFULNESS', value: dashboard.avg_faithfulness, icon: <CheckCircle size={20} /> },
364
+ { label: 'HALLUCINATION RATE', value: dashboard.hallucination_rate, invert: true, icon: <AlertTriangle size={20} /> },
365
+ ].map(card => (
366
+ <div key={card.label} style={{ border: '3px solid #000', padding: '1.5rem', background: (card.invert ? dashboard.hallucination_rate > 0.3 : false) ? '#fff5f5' : '#fff' }}>
367
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
368
+ <span className="mono-text" style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '1px', color: '#666' }}>{card.label}</span>
369
+ {card.icon}
370
+ </div>
371
+ <div className="mono-text" style={{
372
+ fontSize: '2rem', fontWeight: 900,
373
+ color: card.raw ? '#000' : score2color(card.invert ? 1 - (card.value as number) : (card.value as number))
374
+ }}>
375
+ {card.raw ? card.value : `${((card.value as number) * 100).toFixed(1)}%`}
376
+ </div>
377
+ </div>
378
+ ))}
379
+ </div>
380
+
381
+ {/* Score Breakdown */}
382
+ <div style={{ border: '3px solid #000', padding: '1.5rem', marginBottom: '2rem' }}>
383
+ <h3 className="mono-text" style={{ marginBottom: '1.5rem', borderBottom: '1px dotted #000', paddingBottom: '0.5rem' }}>METRIC BREAKDOWN</h3>
384
+ <ScoreBar label="Overall Quality Score" value={dashboard.avg_overall_score} />
385
+ <ScoreBar label="Faithfulness (grounding)" value={dashboard.avg_faithfulness} />
386
+ <ScoreBar label="Answer Relevancy" value={dashboard.avg_relevancy} />
387
+ <ScoreBar label="Non-hallucination Rate" value={1 - dashboard.hallucination_rate} />
388
+ </div>
389
+
390
+ {/* Trend Table */}
391
+ {dashboard.trend_data.length > 0 && (
392
+ <div style={{ border: '3px solid #000', padding: '1.5rem' }}>
393
+ <h3 className="mono-text" style={{ marginBottom: '1.5rem', borderBottom: '1px dotted #000', paddingBottom: '0.5rem' }}>EVALUATION HISTORY (LATEST {Math.min(dashboard.trend_data.length, 20)})</h3>
394
+ <div style={{ overflowX: 'auto' }}>
395
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
396
+ <thead>
397
+ <tr style={{ borderBottom: '2px solid #000' }}>
398
+ {['TIMESTAMP', 'QUALITY', 'FAITHFULNESS', 'RELEVANCY', 'HALLUCINATION'].map(h => (
399
+ <th key={h} style={{ padding: '0.5rem', textAlign: 'left', fontWeight: 700 }}>{h}</th>
400
+ ))}
401
+ </tr>
402
+ </thead>
403
+ <tbody>
404
+ {dashboard.trend_data.slice(0, 20).map((p, i) => (
405
+ <tr key={i} style={{ borderBottom: '1px dotted #ddd', background: i % 2 === 0 ? '#fafafa' : '#fff' }}>
406
+ <td style={{ padding: '0.5rem' }}>{p.timestamp}</td>
407
+ <td style={{ padding: '0.5rem', color: score2color(p.overall_score), fontWeight: 700 }}>{(p.overall_score * 100).toFixed(1)}%</td>
408
+ <td style={{ padding: '0.5rem', color: score2color(p.faithfulness) }}>{(p.faithfulness * 100).toFixed(1)}%</td>
409
+ <td style={{ padding: '0.5rem', color: score2color(p.answer_relevancy) }}>{(p.answer_relevancy * 100).toFixed(1)}%</td>
410
+ <td style={{ padding: '0.5rem' }}>
411
+ <span style={{ background: p.hallucination_detected ? '#dc2626' : '#16a34a', color: '#fff', padding: '0.1rem 0.5rem', fontSize: '0.7rem', fontWeight: 700 }}>
412
+ {p.hallucination_detected ? 'YES' : 'NO'}
413
+ </span>
414
+ </td>
415
+ </tr>
416
+ ))}
417
+ </tbody>
418
+ </table>
419
+ </div>
420
+ </div>
421
+ )}
422
+ </>
423
+ )}
424
+ </div>
425
+ )}
426
+
427
+ {/* ── EVALUATE TAB ── */}
428
+ {activeTab === 'evaluate' && (
429
+ <div>
430
+ <div style={{ border: '3px solid #000', padding: '2rem', marginBottom: '2rem' }}>
431
+ <h3 className="mono-text" style={{ marginBottom: '1.5rem' }}>
432
+ <Zap size={16} style={{ display: 'inline', marginRight: '0.5rem' }} />
433
+ SCORE A Q&A PAIR
434
+ </h3>
435
+ <p className="mono-text" style={{ fontSize: '0.8rem', color: '#666', marginBottom: '1.5rem' }}>
436
+ Paste a question, its generated answer, and the retrieved context chunks (separate chunks with "---").
437
+ </p>
438
+
439
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
440
+ <div>
441
+ <label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>QUESTION</label>
442
+ <textarea value={evalForm.question} onChange={e => setEvalForm(f => ({ ...f, question: e.target.value }))}
443
+ rows={2} style={{ width: '100%', border: '2px solid #000', padding: '0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />
444
+ </div>
445
+ <div>
446
+ <label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>GENERATED ANSWER</label>
447
+ <textarea value={evalForm.answer} onChange={e => setEvalForm(f => ({ ...f, answer: e.target.value }))}
448
+ rows={4} style={{ width: '100%', border: '2px solid #000', padding: '0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />
449
+ </div>
450
+ <div>
451
+ <label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>CONTEXT CHUNKS (separate with "---" on its own line)</label>
452
+ <textarea value={evalForm.contexts} onChange={e => setEvalForm(f => ({ ...f, contexts: e.target.value }))}
453
+ rows={6} placeholder={'Context chunk 1 text...\n---\nContext chunk 2 text...'} style={{ width: '100%', border: '2px solid #000', padding: '0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.85rem', resize: 'vertical', boxSizing: 'border-box' }} />
454
+ </div>
455
+ <button className="app-btn mono-text" onClick={runEval} disabled={evalLoading || !evalForm.question || !evalForm.answer || !evalForm.contexts}
456
+ style={{ alignSelf: 'flex-start', padding: '0.75rem 2rem' }}>
457
+ {evalLoading ? 'EVALUATING...' : 'RUN EVALUATION'}
458
+ </button>
459
+ </div>
460
+ </div>
461
+
462
+ {evalResult && (
463
+ <div style={{ border: '3px solid #000', padding: '2rem', background: evalResult.hallucination_detected ? '#fff5f5' : '#f0fdf4' }}>
464
+ <h3 className="mono-text" style={{ marginBottom: '1.5rem' }}>
465
+ {evalResult.hallucination_detected
466
+ ? <><AlertTriangle size={16} style={{ display: 'inline', marginRight: '0.5rem', color: '#dc2626' }} />HALLUCINATION RISK DETECTED</>
467
+ : <><CheckCircle size={16} style={{ display: 'inline', marginRight: '0.5rem', color: '#16a34a' }} />EVALUATION RESULTS</>
468
+ }
469
+ </h3>
470
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '1rem', marginBottom: '1.5rem' }}>
471
+ {[
472
+ { label: 'OVERALL', value: evalResult.overall_score },
473
+ { label: 'FAITHFULNESS', value: evalResult.faithfulness },
474
+ { label: 'RELEVANCY', value: evalResult.answer_relevancy },
475
+ { label: 'PRECISION', value: evalResult.context_precision },
476
+ ].map(m => (
477
+ <div key={m.label} style={{ border: `2px solid ${score2color(m.value)}`, padding: '1rem', textAlign: 'center' }}>
478
+ <div className="mono-text" style={{ fontSize: '0.7rem', fontWeight: 700, color: '#666', marginBottom: '0.5rem' }}>{m.label}</div>
479
+ <div className="mono-text" style={{ fontSize: '1.8rem', fontWeight: 900, color: score2color(m.value) }}>
480
+ {(m.value * 100).toFixed(0)}%
481
+ </div>
482
+ <div className="mono-text" style={{ fontSize: '0.65rem', color: score2color(m.value), marginTop: '0.25rem' }}>{score2label(m.value)}</div>
483
+ </div>
484
+ ))}
485
+ </div>
486
+ <div className="mono-text" style={{ fontSize: '0.75rem', color: '#777', borderTop: '1px dotted #ccc', paddingTop: '0.75rem' }}>
487
+ Saved to Neo4j for trending. Eval ID: {evalResult.eval_id || 'N/A'}
488
+ </div>
489
+ </div>
490
+ )}
491
+ </div>
492
+ )}
493
+
494
+ {/* ── COMMUNITIES TAB ── */}
495
+ {activeTab === 'communities' && (
496
+ <div>
497
+ <div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', alignItems: 'center' }}>
498
+ <button className="app-btn mono-text" onClick={assignCommunities} disabled={communityLoading} style={{ padding: '0.75rem 1.5rem' }}>
499
+ {communityLoading ? 'DETECTING...' : '⚡ DETECT COMMUNITIES'}
500
+ </button>
501
+ <span className="mono-text" style={{ fontSize: '0.8rem', color: '#666' }}>
502
+ Run after ingesting documents to cluster entities into related groups (enables community search).
503
+ </span>
504
+ </div>
505
+
506
+ {communities.length === 0 ? (
507
+ <div style={{ border: '3px solid #000', padding: '3rem', textAlign: 'center' }}>
508
+ <p className="mono-text" style={{ color: '#777' }}>NO COMMUNITIES DETECTED YET</p>
509
+ <p className="mono-text" style={{ color: '#999', fontSize: '0.85rem', marginTop: '0.5rem' }}>Click "Detect Communities" above to cluster your knowledge graph entities.</p>
510
+ </div>
511
+ ) : (
512
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1rem' }}>
513
+ {communities.map((c, i) => (
514
+ <div key={i} style={{ border: '2px solid #000', padding: '1.25rem' }}>
515
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
516
+ <span className="mono-text" style={{ fontWeight: 700, fontSize: '0.85rem' }}>CLUSTER #{c.community_id}</span>
517
+ <span className="mono-text" style={{ fontSize: '0.75rem', background: '#000', color: '#fff', padding: '0.15rem 0.5rem' }}>
518
+ {c.entity_count} entities
519
+ </span>
520
+ </div>
521
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
522
+ {(c.sample_entities || []).map((e: string, ei: number) => (
523
+ <span key={ei} style={{ background: '#f3f4f6', border: '1px solid #d1d5db', padding: '0.15rem 0.5rem', fontSize: '0.75rem', fontFamily: 'var(--font-mono)' }}>{e}</span>
524
+ ))}
525
+ {c.entity_count > (c.sample_entities || []).length && (
526
+ <span style={{ color: '#666', fontSize: '0.75rem', fontFamily: 'var(--font-mono)', padding: '0.15rem 0' }}>
527
+ +{c.entity_count - (c.sample_entities || []).length} more
528
+ </span>
529
+ )}
530
+ </div>
531
+ </div>
532
+ ))}
533
+ </div>
534
+ )}
535
+ </div>
536
+ )}
537
+
538
+ {/* ── EXPORT TAB ── */}
539
+ {activeTab === 'export' && (
540
+ <div>
541
+ <div style={{ border: '3px solid #000', padding: '2rem', marginBottom: '2rem' }}>
542
+ <h3 className="mono-text" style={{ marginBottom: '1.5rem' }}>EXPORT KNOWLEDGE GRAPH</h3>
543
+ <p className="mono-text" style={{ fontSize: '0.85rem', color: '#555', marginBottom: '2rem', lineHeight: '1.6' }}>
544
+ Export your knowledge graph for use in external tools, backups, or further analysis.
545
+ </p>
546
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1rem' }}>
547
+ {[
548
+ { fmt: 'json', label: 'JSON', desc: 'Nodes & edges as JSON. For custom processing or visualization.' },
549
+ { fmt: 'cypher', label: 'CYPHER', desc: 'Cypher CREATE statements. Re-import to any Neo4j instance.' },
550
+ { fmt: 'graphml', label: 'GRAPHML', desc: 'GraphML XML. Compatible with Gephi, yEd, and most graph tools.' },
551
+ ].map(e => (
552
+ <div key={e.fmt} style={{ border: '2px solid #000', padding: '1.5rem' }}>
553
+ <div className="mono-text" style={{ fontWeight: 900, fontSize: '1.1rem', marginBottom: '0.5rem' }}>.{e.label}</div>
554
+ <p style={{ fontSize: '0.82rem', color: '#555', marginBottom: '1.25rem', fontFamily: 'var(--font-sans)', lineHeight: '1.5' }}>{e.desc}</p>
555
+ <button className="app-btn mono-text" onClick={() => exportGraph(e.fmt)} style={{ width: '100%', textAlign: 'center' }}>
556
+ DOWNLOAD .{e.label}
557
+ </button>
558
+ </div>
559
+ ))}
560
+ </div>
561
+ </div>
562
+ </div>
563
+ )}
564
+
565
+ {/* ── REPORT AGENT TAB ── */}
566
+ {activeTab === 'report' && (
567
+ <div>
568
+ <div className="page-info-bar">
569
+ <FileText size={14}/>
570
+ <span><strong>REPORT AGENT</strong> — Enter any topic or question. The ReACT agent autonomously queries the knowledge graph using multiple retrieval strategies and synthesizes a deep multi-section report. More <strong>depth</strong> = more reasoning steps.</span>
571
+ </div>
572
+
573
+ {/* Input form */}
574
+ <div style={{ border: '3px solid #000', padding: '2rem', marginBottom: '2rem' }}>
575
+ <h3 className="mono-text" style={{ marginBottom: '1.5rem' }}>
576
+ <Zap size={16} style={{ display: 'inline', marginRight: '0.5rem' }}/>GENERATE ANALYTICAL REPORT
577
+ </h3>
578
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
579
+ <div>
580
+ <label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>TOPIC OR QUESTION</label>
581
+ <textarea value={reportTopic} onChange={e => setReportTopic(e.target.value)}
582
+ rows={3} placeholder="e.g. 'Summarize all relationships between Company X and its investors'"
583
+ style={{ width: '100%', border: '2px solid #000', padding: '0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }}/>
584
+ </div>
585
+ <div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
586
+ <div>
587
+ <label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>DEPTH (REASONING STEPS)</label>
588
+ <select value={reportDepth} onChange={e => setReportDepth(Number(e.target.value))}
589
+ style={{ border: '2px solid #000', padding: '0.5rem 0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.9rem' }}>
590
+ {[1, 2, 3, 4, 5].map(n => <option key={n} value={n}>{n} {n === 1 ? '(fast)' : n === 5 ? '(deep)' : ''}</option>)}
591
+ </select>
592
+ </div>
593
+ <button className="app-btn mono-text" onClick={runReport} disabled={reportLoading || !reportTopic.trim()}
594
+ style={{ alignSelf: 'flex-end', padding: '0.75rem 2rem' }}>
595
+ {reportLoading ? 'GENERATING...' : 'GENERATE REPORT'}
596
+ </button>
597
+ </div>
598
+ </div>
599
+ </div>
600
+
601
+ {/* Empty state */}
602
+ {!reportResult && !reportLoading && (
603
+ <div style={{ border: '3px dashed #e5e5e5', padding: '4rem 2rem', textAlign: 'center' }}>
604
+ <FileText size={44} style={{ opacity: 0.18, marginBottom: '1rem' }} />
605
+ <p className="mono-text" style={{ color: '#aaa', fontSize: '0.85rem', letterSpacing: '1px' }}>NO REPORT GENERATED YET</p>
606
+ <p style={{ color: '#bbb', fontSize: '0.8rem', marginTop: '0.5rem', fontFamily: 'var(--font-sans)' }}>
607
+ Enter a topic above and click Generate to produce an AI-powered analytical report from the knowledge graph.
608
+ </p>
609
+ </div>
610
+ )}
611
+
612
+ {/* Loading state */}
613
+ {reportLoading && (
614
+ <div style={{ border: '3px solid #000', padding: '2.5rem', textAlign: 'center' }}>
615
+ <div style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: '0.9rem', marginBottom: '0.75rem' }}>
616
+ ⚡ AGENT REASONING…
617
+ </div>
618
+ <div style={{ fontFamily: 'var(--font-sans)', fontSize: '0.82rem', color: 'var(--muted-color)' }}>
619
+ Querying knowledge graph with depth {reportDepth}. This may take 20–60 seconds.
620
+ </div>
621
+ </div>
622
+ )}
623
+
624
+ {/* Report output */}
625
+ {reportResult && (
626
+ <div style={{ border: '3px solid #000' }}>
627
+ {/* Report header bar */}
628
+ <div style={{ background: '#000', color: '#fff', padding: '0.85rem 1.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
629
+ <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: '0.82rem', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
630
+ 📄 {(reportResult.topic || reportTopic).toUpperCase()}
631
+ </span>
632
+ {/* Confidence badge */}
633
+ {reportResult.confidence !== undefined && (
634
+ <span style={{
635
+ background: confidenceBadgeColor(reportResult.confidence),
636
+ color: '#fff',
637
+ padding: '2px 10px',
638
+ fontFamily: 'var(--font-mono)',
639
+ fontSize: '0.72rem',
640
+ fontWeight: 700,
641
+ letterSpacing: '0.5px',
642
+ flexShrink: 0,
643
+ }}>
644
+ CONF: {(reportResult.confidence * 100).toFixed(0)}%
645
+ </span>
646
+ )}
647
+ {reportResult.tool_calls_made !== undefined && (
648
+ <span style={{ background: '#333', color: '#fff', padding: '2px 10px', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', fontWeight: 700, flexShrink: 0 }}>
649
+ {reportResult.tool_calls_made} CALLS
650
+ </span>
651
+ )}
652
+ {/* Actions */}
653
+ <button
654
+ onClick={handleCopyReport}
655
+ style={{ background: copyDone ? '#16a34a' : 'transparent', color: '#fff', border: '1px solid #555', padding: '3px 10px', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', cursor: 'pointer', flexShrink: 0 }}
656
+ >
657
+ {copyDone ? '✓ COPIED' : '📋 COPY'}
658
+ </button>
659
+ <button
660
+ onClick={handleDownloadReport}
661
+ style={{ background: 'transparent', color: '#fff', border: '1px solid #555', padding: '3px 10px', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', cursor: 'pointer', flexShrink: 0 }}
662
+ >
663
+ ⬇ .MD
664
+ </button>
665
+ </div>
666
+
667
+ <div style={{ padding: '2rem' }}>
668
+ {/* Meta info row */}
669
+ <div style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap', marginBottom: '2rem', paddingBottom: '1rem', borderBottom: '1px dotted #ddd', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: '#888' }}>
670
+ {reportTimestamp && <div>Generated: {reportTimestamp.toLocaleString()}</div>}
671
+ {reportResult.tool_calls_made !== undefined && (
672
+ <div>Depth: {reportDepth} · Tool calls: {reportResult.tool_calls_made}</div>
673
+ )}
674
+ {reportResult.confidence !== undefined && (
675
+ <div style={{ color: confidenceBadgeColor(reportResult.confidence), fontWeight: 700 }}>
676
+ Confidence: {(reportResult.confidence * 100).toFixed(1)}%
677
+ </div>
678
+ )}
679
+ </div>
680
+
681
+ {/* Executive summary */}
682
+ {reportResult.executive_summary && (
683
+ <div style={{ marginBottom: '2.5rem', borderLeft: '4px solid #f59e0b', paddingLeft: '1.25rem' }}>
684
+ <h4 className="mono-text" style={{ fontSize: '0.72rem', marginBottom: '0.75rem', color: '#d97706', letterSpacing: '1.5px' }}>
685
+ EXECUTIVE SUMMARY
686
+ </h4>
687
+ <p style={{ fontFamily: 'var(--font-sans)', lineHeight: 1.9, fontSize: '1rem', fontWeight: 500, whiteSpace: 'pre-wrap', color: '#111' }}>
688
+ {reportResult.executive_summary}
689
+ </p>
690
+ </div>
691
+ )}
692
+
693
+ {/* Sections */}
694
+ {reportResult.sections && typeof reportResult.sections === 'object' && !Array.isArray(reportResult.sections) ? (
695
+ Object.entries(reportResult.sections).map(([title, content], i) => (
696
+ <div key={i} style={{ marginBottom: '2rem', paddingBottom: '1.5rem', borderBottom: '1px dotted #e5e5e5' }}>
697
+ <h4 className="mono-text" style={{ fontSize: '0.8rem', marginBottom: '1rem', color: '#000', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
698
+ <span style={{ background: '#000', color: '#fff', padding: '2px 8px', fontSize: '0.65rem', flexShrink: 0 }}>{i + 1}</span>
699
+ {title.toUpperCase()}
700
+ </h4>
701
+ <p style={{ fontFamily: 'var(--font-sans)', lineHeight: 1.85, fontSize: '0.92rem', whiteSpace: 'pre-wrap', color: '#333' }}>
702
+ {String(content)}
703
+ </p>
704
+ </div>
705
+ ))
706
+ ) : (
707
+ reportResult.sections?.map?.((s: any, i: number) => (
708
+ <div key={i} style={{ marginBottom: '2rem', paddingBottom: '1.5rem', borderBottom: '1px dotted #e5e5e5' }}>
709
+ <h4 className="mono-text" style={{ fontSize: '0.8rem', marginBottom: '1rem', color: '#000', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
710
+ <span style={{ background: '#000', color: '#fff', padding: '2px 8px', fontSize: '0.65rem', flexShrink: 0 }}>{i + 1}</span>
711
+ {s.title?.toUpperCase()}
712
+ </h4>
713
+ <p style={{ fontFamily: 'var(--font-sans)', lineHeight: 1.85, fontSize: '0.92rem', whiteSpace: 'pre-wrap', color: '#333' }}>
714
+ {s.content}
715
+ </p>
716
+ </div>
717
+ ))
718
+ )}
719
+
720
+ {!reportResult.sections && (
721
+ <p style={{ fontFamily: 'var(--font-sans)', lineHeight: 1.85, whiteSpace: 'pre-wrap', color: '#333' }}>
722
+ {reportResult.report || reportResult.content || reportResult.markdown || JSON.stringify(reportResult, null, 2)}
723
+ </p>
724
+ )}
725
+
726
+ {/* Key entities */}
727
+ {reportResult.key_entities && reportResult.key_entities.length > 0 && (
728
+ <div style={{ marginTop: '2rem', paddingTop: '1rem', borderTop: '1px dotted #ccc' }}>
729
+ <h4 className="mono-text" style={{ fontSize: '0.68rem', color: '#888', marginBottom: '0.5rem', letterSpacing: '1.5px' }}>
730
+ KEY ENTITIES REFERENCED
731
+ </h4>
732
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
733
+ {reportResult.key_entities.map((e: string, i: number) => (
734
+ <span key={i} style={{ background: '#f3f4f6', border: '1.5px solid #e5e5e5', padding: '2px 8px', fontSize: '0.75rem', fontFamily: 'var(--font-mono)', fontWeight: 600 }}>
735
+ {e}
736
+ </span>
737
+ ))}
738
+ </div>
739
+ </div>
740
+ )}
741
+ </div>
742
+ </div>
743
+ )}
744
+ </div>
745
+ )}
746
+
747
+ {/* ── LIVE GRAPH UPDATE TAB ── */}
748
+ {activeTab === 'graph-update' && (
749
+ <div>
750
+ <div className="page-info-bar">
751
+ <GitCommit size={14}/>
752
+ <span><strong>LIVE GRAPH UPDATE</strong> — Paste any text (news article, note, policy). The system extracts entities and relationships and merges them into the live knowledge graph without re-running ingestion. Uses <strong>MERGE</strong> to prevent duplicates.</span>
753
+ </div>
754
+ <div style={{ border: '3px solid #000', padding: '2rem', marginBottom: '2rem' }}>
755
+ <h3 className="mono-text" style={{ marginBottom: '1.5rem' }}><GitCommit size={16} style={{ display: 'inline', marginRight: '0.5rem' }}/>INJECT KNOWLEDGE INTO GRAPH</h3>
756
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
757
+ <div>
758
+ <label className="mono-text" style={{ fontSize: '0.8rem', fontWeight: 700, display: 'block', marginBottom: '0.5rem' }}>TEXT TO INJECT</label>
759
+ <textarea value={updateText} onChange={e => setUpdateText(e.target.value)}
760
+ rows={8} placeholder="Paste any text — news, reports, notes, or raw facts..."
761
+ style={{ width: '100%', border: '2px solid #000', padding: '0.75rem', fontFamily: 'var(--font-mono)', fontSize: '0.88rem', resize: 'vertical', boxSizing: 'border-box' }}/>
762
+ </div>
763
+ <button className="app-btn mono-text" onClick={runGraphUpdate} disabled={updateLoading || !updateText.trim()}
764
+ style={{ alignSelf: 'flex-start', padding: '0.75rem 2rem' }}>
765
+ {updateLoading ? 'INJECTING...' : 'INJECT INTO GRAPH'}
766
+ </button>
767
+ </div>
768
+ </div>
769
+ {updateResult && (
770
+ <div style={{ border: '3px solid #000', padding: '1.5rem', background: '#f0fdf4' }}>
771
+ <h3 className="mono-text" style={{ marginBottom: '1rem', color: '#166534' }}>✓ GRAPH UPDATED</h3>
772
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px,1fr))', gap: '0.75rem', marginBottom: '1rem' }}>
773
+ {[
774
+ ['Entities Added', updateResult.entities_added ?? updateResult.nodes_created],
775
+ ['Relationships Added', updateResult.relationships_added ?? updateResult.edges_created],
776
+ ['Merged Existing', updateResult.entities_merged ?? '—'],
777
+ ].map(([label, val]) => (
778
+ <div key={label as string} style={{ border: '2px solid #000', padding: '1rem', textAlign: 'center' }}>
779
+ <div className="mono-text" style={{ fontSize: '0.68rem', color: '#888', marginBottom: '0.35rem' }}>{label}</div>
780
+ <div className="mono-text" style={{ fontSize: '1.6rem', fontWeight: 900 }}>{val ?? '—'}</div>
781
+ </div>
782
+ ))}
783
+ </div>
784
+ {updateResult.message && (
785
+ <p className="mono-text" style={{ fontSize: '0.82rem', color: '#555' }}>{updateResult.message}</p>
786
+ )}
787
+ </div>
788
+ )}
789
+ </div>
790
+ )}
791
+
792
+ {/* ── ENTITY CHAT TAB ── */}
793
+ {activeTab === 'entity-chat' && (
794
+ <div>
795
+ <div className="page-info-bar">
796
+ <MessageSquare size={14}/>
797
+ <span><strong>ENTITY CHAT</strong> — Select an entity from the graph and interview it. The agent synthesizes answers from its neighborhood context, relationships, and LLM-generated profile.</span>
798
+ </div>
799
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '1.5rem' }}>
800
+ {/* Config panel */}
801
+ <div style={{ border: '3px solid #000', padding: '1.5rem' }}>
802
+ <h3 className="mono-text" style={{ marginBottom: '1.25rem', fontSize: '0.9rem' }}>SELECT ENTITY</h3>
803
+ <div style={{ marginBottom: '1rem' }}>
804
+ <label className="mono-text" style={{ fontSize: '0.75rem', fontWeight: 700, color: '#888', display: 'block', marginBottom: '0.4rem' }}>ENTITY NAME</label>
805
+ <select value={selectedEntity} onChange={e => { setSelectedEntity(e.target.value); setChatHistory([]); }}
806
+ style={{ width: '100%', border: '2px solid #000', padding: '0.5rem', fontFamily: 'var(--font-mono)', fontSize: '0.85rem' }}>
807
+ <option value="">— Select entity —</option>
808
+ {entities.map((n: any) => (
809
+ <option key={n.id} value={n.properties?.name || n.properties?.label || n.id}>
810
+ {n.labels?.[0]} · {n.properties?.name || n.properties?.label || n.id}
811
+ </option>
812
+ ))}
813
+ </select>
814
+ </div>
815
+ <div style={{ marginBottom: '1rem' }}>
816
+ <label className="mono-text" style={{ fontSize: '0.75rem', fontWeight: 700, color: '#888', display: 'block', marginBottom: '0.4rem' }}>EXTRA CONTEXT (OPTIONAL)</label>
817
+ <textarea value={entityContext} onChange={e => setEntityContext(e.target.value)}
818
+ rows={3} placeholder="Add any additional context or constraints..."
819
+ style={{ width: '100%', border: '2px solid #000', padding: '0.5rem', fontFamily: 'var(--font-mono)', fontSize: '0.82rem', resize: 'vertical' }}/>
820
+ </div>
821
+ <button className="app-btn mono-text" style={{ width: '100%', justifyContent: 'center' }}
822
+ onClick={() => setChatHistory([])}>CLEAR CHAT</button>
823
+ </div>
824
+ {/* Chat window */}
825
+ <div style={{ border: '3px solid #000', display: 'flex', flexDirection: 'column', minHeight: 400 }}>
826
+ <div style={{ background: '#000', color: '#fff', padding: '0.5rem 1rem', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', fontWeight: 700 }}>
827
+ {selectedEntity ? `CHATTING WITH: ${selectedEntity.toUpperCase()}` : 'SELECT AN ENTITY TO BEGIN'}
828
+ </div>
829
+ <div style={{ flex: 1, overflowY: 'auto', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', minHeight: 280, maxHeight: 400 }}>
830
+ {chatHistory.length === 0 && (
831
+ <div style={{ textAlign: 'center', padding: '2rem', color: '#ccc', fontFamily: 'var(--font-mono)', fontSize: '0.82rem' }}>
832
+ {selectedEntity ? 'Ask this entity anything…' : 'Select an entity to start chatting'}
833
+ </div>
834
+ )}
835
+ {chatHistory.map((m, i) => (
836
+ <div key={i} style={{
837
+ alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
838
+ background: m.role === 'user' ? '#000' : '#f3f4f6',
839
+ color: m.role === 'user' ? '#fff' : '#000',
840
+ padding: '0.6rem 1rem',
841
+ maxWidth: '85%',
842
+ fontFamily: 'var(--font-sans)', fontSize: '0.88rem', lineHeight: 1.6
843
+ }}>
844
+ {m.content}
845
+ </div>
846
+ ))}
847
+ {chatLoading && (
848
+ <div style={{ alignSelf: 'flex-start', background: '#f3f4f6', padding: '0.6rem 1rem', fontFamily: 'var(--font-mono)', fontSize: '0.82rem', color: '#888' }}>
849
+ Thinking…
850
+ </div>
851
+ )}
852
+ </div>
853
+ <div style={{ borderTop: '2px solid #000', display: 'flex', gap: 0 }}>
854
+ <input type="text" value={chatMsg} onChange={e => setChatMsg(e.target.value)}
855
+ onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendEntityChat()}
856
+ placeholder={selectedEntity ? 'Ask a question…' : 'Select an entity first'}
857
+ disabled={!selectedEntity || chatLoading}
858
+ style={{ flex: 1, border: 'none', padding: '0.75rem 1rem', fontFamily: 'var(--font-mono)', fontSize: '0.88rem', outline: 'none' }}/>
859
+ <button className="app-btn" onClick={sendEntityChat} disabled={!selectedEntity || !chatMsg.trim() || chatLoading}
860
+ style={{ border: 'none', borderLeft: '2px solid #000', borderRadius: 0, minWidth: 80 }}>
861
+ SEND
862
+ </button>
863
+ </div>
864
+ </div>
865
+ </div>
866
+ </div>
867
+ )}
868
+ </div>
869
+ );
870
+ };
871
+
872
+ export default InsightsView;
frontend-react/src/views/InteractionView.tsx ADDED
@@ -0,0 +1,1396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { useAuth } from '../context/AuthContext';
3
+ import { Message, Conversation, DocumentInfo, GraphNode } from '../types/api';
4
+ import {
5
+ MessageSquare, Send, Bot, User as UserIcon, Zap,
6
+ Menu, Info, X, ChevronDown, FileText, Plus
7
+ } from 'lucide-react';
8
+ import ReactMarkdown from 'react-markdown';
9
+ import remarkGfm from 'remark-gfm';
10
+
11
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
12
+
13
+ /* ── confidence colour helper ─────────────────────────────────────────────── */
14
+ const confColor = (c: number) => c >= 0.75 ? '#16a34a' : c >= 0.5 ? '#d97706' : '#dc2626';
15
+ const riskColor = (r: string) =>
16
+ r.toLowerCase() === 'high' ? '#dc2626' : r.toLowerCase() === 'medium' ? '#d97706' : '#16a34a';
17
+
18
+ /* ─────────────────────────────────────────────────────────────────────────── */
19
+
20
+ const InteractionView: React.FC = () => {
21
+ const { token, logout } = useAuth();
22
+
23
+ // ── Core chat state ──────────────────────────────────────────────────────
24
+ const [query, setQuery] = useState('');
25
+ const [conversation, setConversation] = useState<Message[]>([]);
26
+ const [loading, setLoading] = useState(false);
27
+
28
+ // ── Document / mode state ────────────────────────────────────────────────
29
+ const [documents, setDocuments] = useState<DocumentInfo[]>([]);
30
+ const [selectedDocId, setSelectedDocId] = useState('');
31
+ const [useGot, setUseGot] = useState(false);
32
+ const [mode, setMode] = useState<'rag' | 'simulation'>('rag');
33
+ const [agentId, setAgentId] = useState('');
34
+ const [agentNodes, setAgentNodes] = useState<GraphNode[]>([]);
35
+
36
+ // ── Thread history ────────────────────────────────────────────────────────
37
+ const [pastConversations, setPastConversations] = useState<Conversation[]>([]);
38
+ const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
39
+
40
+ // ── UI state ──────────────────────────────────────────────────────────────
41
+ const [sidebarOpen, setSidebarOpen] = useState(true);
42
+ const [drawerSource, setDrawerSource] = useState<any | null>(null);
43
+
44
+ const endOfChatRef = useRef<HTMLDivElement>(null);
45
+ const inputRef = useRef<HTMLInputElement>(null);
46
+
47
+ /* ── auto-scroll ─────────────────────────────────────────────────────── */
48
+ useEffect(() => {
49
+ endOfChatRef.current?.scrollIntoView({ behavior: 'smooth' });
50
+ }, [conversation]);
51
+
52
+ /* ── initial data fetch ──────────────────────────────────────────────── */
53
+ useEffect(() => {
54
+ const fetchDocs = async () => {
55
+ try {
56
+ const res = await fetch(`${API_BASE}/documents`, {
57
+ headers: { Authorization: `Bearer ${token}` }
58
+ });
59
+ if (res.ok) setDocuments((await res.json()).documents);
60
+ } catch {}
61
+ };
62
+
63
+ const fetchConvs = async () => {
64
+ try {
65
+ const res = await fetch(`${API_BASE}/conversations`, {
66
+ headers: { Authorization: `Bearer ${token}` }
67
+ });
68
+ if (res.ok) setPastConversations((await res.json()).conversations);
69
+ } catch {}
70
+ };
71
+
72
+ const fetchAgents = async () => {
73
+ try {
74
+ const res = await fetch(`${API_BASE}/graph/visualization?limit=500`, {
75
+ headers: { Authorization: `Bearer ${token}` }
76
+ });
77
+ if (res.ok) setAgentNodes((await res.json()).nodes);
78
+ } catch {}
79
+ };
80
+
81
+ fetchDocs();
82
+ fetchConvs();
83
+ fetchAgents();
84
+ }, [token]);
85
+
86
+ /* ── load an archived thread ─────────────────────────────────────────── */
87
+ const loadConversation = useCallback(async (convId: string) => {
88
+ try {
89
+ const res = await fetch(`${API_BASE}/conversations/${convId}`, {
90
+ headers: { Authorization: `Bearer ${token}` }
91
+ });
92
+ if (res.ok) {
93
+ const data = await res.json();
94
+ setCurrentConversationId(data.id);
95
+ setConversation(
96
+ data.messages.map((m: any) => ({
97
+ role: m.role,
98
+ content: m.content,
99
+ reasoning: m.reasoning || [],
100
+ sources: m.sources || []
101
+ }))
102
+ );
103
+ // On mobile: close sidebar after selecting
104
+ if (window.innerWidth < 768) setSidebarOpen(false);
105
+ }
106
+ } catch {}
107
+ }, [token]);
108
+
109
+ const startNewConversation = useCallback(() => {
110
+ setCurrentConversationId(null);
111
+ setConversation([]);
112
+ if (window.innerWidth < 768) setSidebarOpen(false);
113
+ setTimeout(() => inputRef.current?.focus(), 100);
114
+ }, []);
115
+
116
+ /* ── submit query ────────────────────────────────────────────────────── */
117
+ const handleSubmit = async (e: React.FormEvent) => {
118
+ e.preventDefault();
119
+ if (!query.trim() || loading) return;
120
+
121
+ const userMessage = { role: 'user', content: query };
122
+ const assistantPlaceholder = {
123
+ role: 'assistant', content: '', sources: [], reasoning: [],
124
+ confidence: null, drift_expanded: false
125
+ };
126
+
127
+ setConversation(prev => [...prev, userMessage, assistantPlaceholder]);
128
+ setQuery('');
129
+ setLoading(true);
130
+
131
+ try {
132
+ /* ── Simulation mode ──────────────────────────────────────────── */
133
+ if (mode === 'simulation') {
134
+ const res = await fetch(`${API_BASE}/v1/simulation/interview`, {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
137
+ body: JSON.stringify({ agent_id: agentId, user_query: userMessage.content })
138
+ });
139
+ if (!res.ok) throw new Error('Simulation endpoint failed.');
140
+ const data = await res.json();
141
+ setConversation(prev => {
142
+ const next = [...prev];
143
+ next[next.length - 1] = {
144
+ role: 'assistant',
145
+ content: data.response,
146
+ sources: [],
147
+ reasoning: [`Simulated persona response for agent: ${data.agent_name || agentId}`],
148
+ confidence: null
149
+ };
150
+ return next;
151
+ });
152
+ setLoading(false);
153
+ return;
154
+ }
155
+
156
+ /* ── RAG streaming mode ─────────────────────────────────────────── */
157
+ const reqBody: any = {
158
+ query: userMessage.content,
159
+ streaming: true,
160
+ top_k: 5,
161
+ use_got: useGot
162
+ };
163
+ if (selectedDocId) reqBody.document_id = selectedDocId;
164
+ if (currentConversationId) reqBody.conversation_id = currentConversationId;
165
+
166
+ const res = await fetch(`${API_BASE}/query`, {
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
169
+ body: JSON.stringify(reqBody)
170
+ });
171
+
172
+ if (res.status === 401) { logout(); return; }
173
+ if (!res.body) throw new Error('ReadableStream not supported.');
174
+
175
+ const reader = res.body.getReader();
176
+ const decoder = new TextDecoder('utf-8');
177
+
178
+ while (true) {
179
+ const { done, value } = await reader.read();
180
+ if (done) break;
181
+ const raw = decoder.decode(value);
182
+ const chunks = raw.split('\n\n');
183
+
184
+ for (const chunk of chunks) {
185
+ if (chunk.trim() === 'data: [DONE]') { setLoading(false); break; }
186
+ if (!chunk.startsWith('data: ')) continue;
187
+ try {
188
+ const data = JSON.parse(chunk.replace('data: ', ''));
189
+ if (data.type === 'meta') {
190
+ setCurrentConversationId(data.conversation_id);
191
+ // Refresh thread list so it shows up in sidebar
192
+ const convRes = await fetch(`${API_BASE}/conversations`, {
193
+ headers: { Authorization: `Bearer ${token}` }
194
+ });
195
+ if (convRes.ok) setPastConversations((await convRes.json()).conversations);
196
+ continue;
197
+ }
198
+ setConversation(prev => {
199
+ const next = [...prev];
200
+ const last = next.length - 1;
201
+ if (data.type === 'step') {
202
+ next[last] = { ...next[last], reasoning: [...(next[last].reasoning || []), data.content] };
203
+ } else if (data.type === 'answer') {
204
+ next[last] = {
205
+ ...next[last],
206
+ content: data.answer,
207
+ sources: data.sources,
208
+ confidence: data.confidence,
209
+ drift_expanded: data.drift_expanded || false,
210
+ hallucination_risk: data.hallucination_risk,
211
+ confidence_reasoning: data.confidence_reasoning
212
+ };
213
+ }
214
+ return next;
215
+ });
216
+ } catch {}
217
+ }
218
+ }
219
+ } catch (err) {
220
+ console.error('Query error:', err);
221
+ // Show error in the placeholder message
222
+ setConversation(prev => {
223
+ const next = [...prev];
224
+ next[next.length - 1] = {
225
+ ...next[next.length - 1],
226
+ content: '⚠ An error occurred. Please check your connection or try again.'
227
+ };
228
+ return next;
229
+ });
230
+ } finally {
231
+ setLoading(false);
232
+ }
233
+ };
234
+
235
+ /* ── inline eval ─────────────────────────────────────────────────────── */
236
+ const runInlineEval = async (msgIndex: number) => {
237
+ const astMsg = conversation[msgIndex];
238
+ if (astMsg.role !== 'assistant') return;
239
+
240
+ let question = 'Contextual Query';
241
+ for (let i = msgIndex - 1; i >= 0; i--) {
242
+ if (conversation[i].role === 'user') { question = conversation[i].content; break; }
243
+ }
244
+
245
+ setConversation(prev => {
246
+ const next = [...prev];
247
+ next[msgIndex] = { ...next[msgIndex], evaluating: true };
248
+ return next;
249
+ });
250
+
251
+ try {
252
+ const contexts = (astMsg.sources || []).map((s: any) => s.text || JSON.stringify(s));
253
+ const res = await fetch(`${API_BASE}/eval/score`, {
254
+ method: 'POST',
255
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
256
+ body: JSON.stringify({ question, answer: astMsg.content, contexts })
257
+ });
258
+ const evalData = res.ok ? await res.json() : null;
259
+ setConversation(prev => {
260
+ const next = [...prev];
261
+ next[msgIndex] = { ...next[msgIndex], evaluating: false, ...(evalData ? { eval_result: evalData } : {}) };
262
+ return next;
263
+ });
264
+ } catch {
265
+ setConversation(prev => {
266
+ const next = [...prev]; next[msgIndex] = { ...next[msgIndex], evaluating: false }; return next;
267
+ });
268
+ }
269
+ };
270
+
271
+ /* ── keyboard shortcut: Ctrl+/ or Cmd+/ to focus input ─────────────── */
272
+ useEffect(() => {
273
+ const handler = (e: KeyboardEvent) => {
274
+ if ((e.ctrlKey || e.metaKey) && e.key === '/') {
275
+ e.preventDefault();
276
+ inputRef.current?.focus();
277
+ }
278
+ if (e.key === 'Escape' && drawerSource) setDrawerSource(null);
279
+ };
280
+ window.addEventListener('keydown', handler);
281
+ return () => window.removeEventListener('keydown', handler);
282
+ }, [drawerSource]);
283
+
284
+ /* ────────────────────────────────────────────────────────────────────── */
285
+ return (
286
+ <div className="iv-root">
287
+
288
+ {/* ── Top header bar ──────────────────────────────────────────────── */}
289
+ <div className="iv-header">
290
+ {/* left: title + breadcrumb */}
291
+ <div className="iv-header-left">
292
+ <button
293
+ className="iv-sidebar-toggle"
294
+ onClick={() => setSidebarOpen(o => !o)}
295
+ title={sidebarOpen ? 'Hide threads' : 'Show threads'}
296
+ >
297
+ <Menu size={18} />
298
+ </button>
299
+ <div>
300
+ <h1 className="iv-title">AGENTIC INTERACTION</h1>
301
+ <p className="mono-text iv-breadcrumb">TERMINAL // LOGIC QUERY INTERFACE</p>
302
+ </div>
303
+ </div>
304
+
305
+ {/* right: controls */}
306
+ <div className="iv-header-right">
307
+ {/* Document filter */}
308
+ <div className="iv-ctrl-group">
309
+ <label className="iv-ctrl-label">SCOPE</label>
310
+ <div className="iv-select-wrap">
311
+ <select
312
+ className="iv-select"
313
+ value={selectedDocId}
314
+ onChange={e => setSelectedDocId(e.target.value)}
315
+ >
316
+ <option value="">🌐 ALL DOCUMENTS</option>
317
+ {documents.length === 0 ? (
318
+ <option disabled>No documents uploaded</option>
319
+ ) : (
320
+ documents.map(doc => (
321
+ <option key={doc.id} value={doc.id}>
322
+ 📄 {doc.filename.length > 32 ? doc.filename.substring(0, 30) + '…' : doc.filename}
323
+ </option>
324
+ ))
325
+ )}
326
+ </select>
327
+ <ChevronDown size={13} className="iv-select-chevron" />
328
+ </div>
329
+ </div>
330
+
331
+ {/* GoT toggle */}
332
+ <button
333
+ className={`iv-got-btn ${useGot ? 'active' : ''}`}
334
+ onClick={() => setUseGot(g => !g)}
335
+ title="Graph-of-Thought: runs all retrieval strategies in parallel for higher quality answers"
336
+ >
337
+ <Zap size={13} />
338
+ GoT {useGot ? 'ON' : 'OFF'}
339
+ </button>
340
+ </div>
341
+ </div>
342
+
343
+ {/* ── Info bar ────────────────────────────────────────────────────── */}
344
+ <div className="iv-info-bar">
345
+ <Info size={13} />
346
+ <span>
347
+ <strong>Standard (Graph Logic)</strong>: multi-hop retrieval over the knowledge graph. &nbsp;
348
+ <strong>GoT</strong>: runs all search strategies in parallel, best for complex questions. &nbsp;
349
+ <strong>God-Mode</strong>: interviews a simulated AI persona by agent ID. &nbsp;
350
+ Press <kbd>Ctrl+/</kbd> to focus input.
351
+ </span>
352
+ </div>
353
+
354
+ {/* ── Main layout ─────────────────────────────────────────────────── */}
355
+ <div className="iv-body">
356
+
357
+ {/* ── Sidebar ────────────────────────────────────────────────────── */}
358
+ <div className={`iv-sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
359
+ <button className="iv-new-thread" onClick={startNewConversation} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
360
+ <Plus size={16} /> NEW THREAD
361
+ </button>
362
+
363
+ <div className="iv-thread-list">
364
+ <div className="iv-thread-header">ARCHIVED THREADS</div>
365
+ {pastConversations.length === 0 ? (
366
+ <div className="iv-empty-threads">No prior sequences</div>
367
+ ) : (
368
+ pastConversations.map(conv => (
369
+ <div
370
+ key={conv.id}
371
+ className={`iv-thread-item ${currentConversationId === conv.id ? 'active' : ''}`}
372
+ onClick={() => loadConversation(conv.id)}
373
+ >
374
+ <div className="iv-thread-title">
375
+ <MessageSquare size={12} style={{ display: 'inline', marginRight: '6px', verticalAlign: '-1px' }} />
376
+ {conv.title || 'Untitled thread'}
377
+ </div>
378
+ <div className="iv-thread-date">
379
+ {new Date(conv.created_at).toLocaleDateString()}
380
+ </div>
381
+ </div>
382
+ ))
383
+ )}
384
+ </div>
385
+ </div>
386
+
387
+ {/* ── Chat panel ──────────────────────────────────────────────────── */}
388
+ <div className="iv-chat-panel">
389
+
390
+ {/* messages */}
391
+ <div className="iv-messages">
392
+ {conversation.length === 0 ? (
393
+ <div className="iv-empty-chat">
394
+ <Bot size={40} style={{ opacity: 0.15 }} />
395
+ <span>INITIALIZE QUERY SEQUENCE TO BEGIN GRAPH ANALYSIS…</span>
396
+ <div className="iv-empty-hints">
397
+ <span>Try: "Summarize the main entities in this document"</span>
398
+ <span>Try: "What relationships exist between X and Y?"</span>
399
+ <span>Try: "Find all mentions of [topic] and their context"</span>
400
+ </div>
401
+ </div>
402
+ ) : (
403
+ conversation.map((msg, idx) => (
404
+ <div key={idx} className={`iv-msg-row ${msg.role}`}>
405
+ <div className="iv-msg-avatar">
406
+ {msg.role === 'user' ? <UserIcon size={18} /> : <Bot size={18} />}
407
+ </div>
408
+
409
+ <div className="iv-msg-card">
410
+ {/* message header */}
411
+ <div className="iv-msg-header">
412
+ <span className="iv-msg-role">
413
+ {msg.role === 'user' ? 'YOU' : 'GRAPH REASONING SYSTEM'}
414
+ </span>
415
+
416
+ {msg.role === 'assistant' && msg.confidence != null && (
417
+ <div className="iv-msg-badges">
418
+ {msg.drift_expanded && (
419
+ <span className="iv-badge" style={{ background: '#3b82f6' }}>
420
+ DRIFT EXPANDED
421
+ </span>
422
+ )}
423
+ <span
424
+ className="iv-badge"
425
+ style={{ background: confColor(msg.confidence) }}
426
+ title={`Confidence: ${(msg.confidence * 100).toFixed(1)}%`}
427
+ >
428
+ {(msg.confidence * 100).toFixed(0)}% CONF
429
+ </span>
430
+ {msg.hallucination_risk && (
431
+ <span
432
+ className="iv-badge-outline"
433
+ style={{ color: riskColor(msg.hallucination_risk), borderColor: riskColor(msg.hallucination_risk) }}
434
+ title={msg.confidence_reasoning}
435
+ >
436
+ RISK: {msg.hallucination_risk.toUpperCase()}
437
+ </span>
438
+ )}
439
+ </div>
440
+ )}
441
+ </div>
442
+
443
+ {/* reasoning steps */}
444
+ {msg.role === 'assistant' && msg.reasoning?.length > 0 && (
445
+ <div className="iv-reasoning">
446
+ {msg.reasoning.map((step: string, si: number) => (
447
+ <div key={si} className="iv-reasoning-step">
448
+ <span className="iv-step-idx">{si + 1}</span>
449
+ <span>{step}</span>
450
+ </div>
451
+ ))}
452
+ </div>
453
+ )}
454
+
455
+ {/* content */}
456
+ <div className="iv-msg-content">
457
+ {msg.role === 'assistant' && msg.content === '' && loading && idx === conversation.length - 1 ? (
458
+ <span className="iv-cursor">██</span>
459
+ ) : (
460
+ <div className="iv-markdown">
461
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
462
+ </div>
463
+ )}
464
+ </div>
465
+
466
+ {/* sources */}
467
+ {msg.sources?.length > 0 && (
468
+ <div className="iv-sources">
469
+ <div className="iv-sources-top">
470
+ <div className="iv-sources-label">
471
+ SOURCES:
472
+ <div className="iv-source-chips">
473
+ {msg.sources
474
+ .filter((v: any, i: number, a: any[]) =>
475
+ a.findIndex(t => (t.metadata?.file_name || t.document_id) === (v.metadata?.file_name || v.document_id)) === i
476
+ )
477
+ .map((s: any, si: number) => (
478
+ <button
479
+ key={si}
480
+ className="iv-source-chip"
481
+ onClick={() => setDrawerSource(s)}
482
+ title="Click to view source text"
483
+ >
484
+ {s.metadata?.file_name || s.document_id}
485
+ </button>
486
+ ))}
487
+ </div>
488
+ </div>
489
+
490
+ {msg.role === 'assistant' && !msg.eval_result && (
491
+ <button
492
+ className="iv-eval-btn"
493
+ onClick={() => runInlineEval(idx)}
494
+ disabled={msg.evaluating}
495
+ >
496
+ {msg.evaluating ? 'EVALUATING…' : 'EVALUATE QUALITY'}
497
+ </button>
498
+ )}
499
+ </div>
500
+
501
+ {/* eval results */}
502
+ {msg.eval_result && (
503
+ <div className="iv-eval-result">
504
+ <div className="iv-eval-title">EVALUATION RESULTS</div>
505
+ <div className="iv-eval-grid">
506
+ {[
507
+ { label: 'OVERALL', value: msg.eval_result.overall_score ?? (msg.eval_result.faithfulness * 0.5 + (msg.eval_result.answer_relevancy || msg.eval_result.relevancy || 0) * 0.3 + (msg.eval_result.context_precision || msg.eval_result.precision || 0) * 0.2) },
508
+ { label: 'FAITHFULNESS', value: msg.eval_result.faithfulness },
509
+ { label: 'RELEVANCY', value: msg.eval_result.answer_relevancy ?? msg.eval_result.relevancy },
510
+ { label: 'PRECISION', value: msg.eval_result.context_precision ?? msg.eval_result.precision }
511
+ ].map((m, mi) => {
512
+ const val = typeof m.value === 'number' ? m.value : 0;
513
+ const pct = Math.round(val * 100);
514
+ return (
515
+ <div key={mi} className="iv-eval-metric">
516
+ <div className="iv-eval-label">{m.label}</div>
517
+ <div className="iv-eval-bar-wrap">
518
+ <div className="iv-eval-bar" style={{ width: `${pct}%`, background: confColor(val) }} />
519
+ </div>
520
+ <div className="iv-eval-pct" style={{ color: confColor(val) }}>{pct}%</div>
521
+ </div>
522
+ );
523
+ })}
524
+ </div>
525
+ </div>
526
+ )}
527
+ </div>
528
+ )}
529
+ </div>
530
+ </div>
531
+ ))
532
+ )}
533
+ <div ref={endOfChatRef} />
534
+ </div>
535
+
536
+ {/* ── Input area ─────────────────────────────────────────────── */}
537
+ <form className="iv-input-area" onSubmit={handleSubmit}>
538
+ {/* Mode + agent ID row */}
539
+ <div className="iv-input-controls">
540
+ <div className="iv-ctrl-group">
541
+ <label className="iv-ctrl-label">INTERACTION MODE</label>
542
+ <div className="iv-select-wrap">
543
+ <select
544
+ className="iv-select iv-select-dark"
545
+ value={mode}
546
+ onChange={e => setMode(e.target.value as 'rag' | 'simulation')}
547
+ >
548
+ <option value="rag">STANDARD (GRAPH LOGIC)</option>
549
+ <option value="simulation">GOD-MODE (PERSONA INTERVIEW)</option>
550
+ </select>
551
+ <ChevronDown size={13} className="iv-select-chevron" />
552
+ </div>
553
+ </div>
554
+
555
+ {mode === 'simulation' && (
556
+ <div className="iv-ctrl-group">
557
+ <label className="iv-ctrl-label" style={{ color: '#f59e0b' }}>TARGET PERSONA (GOD MODE)</label>
558
+ <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
559
+ <div className="iv-select-wrap">
560
+ <select
561
+ className="iv-select iv-select-dark"
562
+ style={{ borderColor: '#f59e0b', width: '200px' }}
563
+ value={agentNodes.some(n => n.id === agentId) ? agentId : ""}
564
+ onChange={e => setAgentId(e.target.value)}
565
+ >
566
+ <option value="" disabled>Select from graph...</option>
567
+ {agentNodes.map(n => (
568
+ <option key={n.id} value={n.id}>
569
+ [{n.type}] {n.label.length > 20 ? n.label.substring(0, 18) + '…' : n.label}
570
+ </option>
571
+ ))}
572
+ </select>
573
+ <ChevronDown size={13} className="iv-select-chevron" />
574
+ </div>
575
+ <span style={{ color: '#666', fontSize: '0.65rem', fontFamily: 'var(--font-mono)' }}>OR</span>
576
+ <input
577
+ type="text"
578
+ value={agentId}
579
+ onChange={e => setAgentId(e.target.value)}
580
+ placeholder="Paste UUID..."
581
+ className="iv-agent-input"
582
+ style={{ width: '120px' }}
583
+ />
584
+ </div>
585
+ </div>
586
+ )}
587
+
588
+ {/* Document scope chip (shows when doc selected) */}
589
+ {selectedDocId && (
590
+ <div className="iv-scope-chip">
591
+ <FileText size={12} />
592
+ <span>{documents.find(d => d.id === selectedDocId)?.filename?.substring(0, 24) || 'Filtered'}</span>
593
+ <button type="button" onClick={() => setSelectedDocId('')} title="Clear filter">
594
+ <X size={11} />
595
+ </button>
596
+ </div>
597
+ )}
598
+ </div>
599
+
600
+ {/* Text input row */}
601
+ <div className="iv-input-row">
602
+ <span className="iv-prompt-marker">&gt;</span>
603
+ <input
604
+ ref={inputRef}
605
+ type="text"
606
+ value={query}
607
+ onChange={e => setQuery(e.target.value)}
608
+ disabled={loading || (mode === 'simulation' && !agentId)}
609
+ placeholder={
610
+ mode === 'simulation'
611
+ ? agentId ? 'INTERVIEW AGENT…' : 'ENTER AGENT ID ABOVE FIRST…'
612
+ : 'ENTER QUERY DIRECTIVE…'
613
+ }
614
+ className="iv-text-input"
615
+ autoComplete="off"
616
+ />
617
+ <button
618
+ type="submit"
619
+ className="iv-send-btn"
620
+ disabled={!query.trim() || loading || (mode === 'simulation' && !agentId)}
621
+ title="Send (Enter)"
622
+ >
623
+ {loading ? (
624
+ <span className="iv-spinner" />
625
+ ) : (
626
+ <Send size={18} />
627
+ )}
628
+ </button>
629
+ </div>
630
+ </form>
631
+ </div>
632
+ </div>
633
+
634
+ {/* ── Source detail drawer ─────────────────────────────────────────── */}
635
+ {drawerSource && (
636
+ <div className="iv-drawer-overlay" onClick={() => setDrawerSource(null)}>
637
+ <div className="iv-drawer" onClick={e => e.stopPropagation()}>
638
+ <div className="iv-drawer-header">
639
+ <h3 className="mono-text">SOURCE DETAIL</h3>
640
+ <button className="iv-drawer-close" onClick={() => setDrawerSource(null)}>
641
+ <X size={16} />
642
+ </button>
643
+ </div>
644
+ <div className="iv-drawer-body">
645
+ <div className="iv-drawer-meta">
646
+ <div className="iv-meta-row">
647
+ <span className="iv-meta-key">DOCUMENT</span>
648
+ <span>{drawerSource.metadata?.file_name || drawerSource.document_id || '—'}</span>
649
+ </div>
650
+ <div className="iv-meta-row">
651
+ <span className="iv-meta-key">RELEVANCE</span>
652
+ <span>{drawerSource.score != null ? (drawerSource.score * 100).toFixed(1) + '%' : 'N/A'}</span>
653
+ </div>
654
+ <div className="iv-meta-row">
655
+ <span className="iv-meta-key">CHUNK ID</span>
656
+ <span style={{ wordBreak: 'break-all' }}>{drawerSource.id || '—'}</span>
657
+ </div>
658
+ </div>
659
+ <hr className="iv-drawer-divider" />
660
+ <div className="iv-drawer-text">{drawerSource.text || 'No text available.'}</div>
661
+ </div>
662
+ </div>
663
+ </div>
664
+ )}
665
+
666
+ {/* ── Scoped styles ────────────────────────────────────────────────── */}
667
+ <style>{`
668
+ /* ── Root layout ───────────────────────────────────────────── */
669
+ .iv-root {
670
+ height: calc(100vh - 62px);
671
+ display: flex;
672
+ flex-direction: column;
673
+ background: var(--bg-color);
674
+ overflow: hidden;
675
+ }
676
+
677
+ /* ── Header ───────────────────────────────────────────────── */
678
+ .iv-header {
679
+ display: flex;
680
+ align-items: center;
681
+ justify-content: space-between;
682
+ gap: 1rem;
683
+ padding: 0.65rem 1.5rem;
684
+ border-bottom: 3px solid var(--border-color);
685
+ flex-shrink: 0;
686
+ flex-wrap: wrap;
687
+ }
688
+
689
+ .iv-header-left {
690
+ display: flex;
691
+ align-items: center;
692
+ gap: 0.75rem;
693
+ min-width: 0;
694
+ }
695
+
696
+ .iv-title {
697
+ font-size: 1rem;
698
+ letter-spacing: 2px;
699
+ margin: 0;
700
+ line-height: 1.1;
701
+ }
702
+
703
+ .iv-breadcrumb {
704
+ font-size: 0.7rem;
705
+ color: var(--muted-color);
706
+ margin: 0;
707
+ }
708
+
709
+ .iv-sidebar-toggle {
710
+ border: 2px solid var(--border-color);
711
+ background: var(--bg-color);
712
+ color: var(--text-color);
713
+ width: 34px;
714
+ height: 34px;
715
+ display: flex;
716
+ align-items: center;
717
+ justify-content: center;
718
+ cursor: pointer;
719
+ flex-shrink: 0;
720
+ transition: all 0.13s;
721
+ }
722
+ .iv-sidebar-toggle:hover { background: var(--text-color); color: var(--bg-color); }
723
+
724
+ .iv-header-right {
725
+ display: flex;
726
+ align-items: flex-end;
727
+ gap: 0.75rem;
728
+ flex-wrap: wrap;
729
+ }
730
+
731
+ /* ── Control group ─────────────────────────────────────────── */
732
+ .iv-ctrl-group {
733
+ display: flex;
734
+ flex-direction: column;
735
+ gap: 2px;
736
+ }
737
+ .iv-ctrl-label {
738
+ font-family: var(--font-mono);
739
+ font-size: 0.6rem;
740
+ font-weight: 700;
741
+ color: var(--muted-color);
742
+ letter-spacing: 1px;
743
+ text-transform: uppercase;
744
+ }
745
+
746
+ /* ── Uniform select ────────────────────────────────────────── */
747
+ .iv-select-wrap {
748
+ position: relative;
749
+ display: inline-flex;
750
+ align-items: center;
751
+ }
752
+ .iv-select-chevron {
753
+ position: absolute;
754
+ right: 8px;
755
+ pointer-events: none;
756
+ color: var(--muted-color);
757
+ }
758
+ .iv-select {
759
+ font-family: var(--font-mono);
760
+ font-size: 0.82rem;
761
+ font-weight: 700;
762
+ border: 2px solid var(--border-color);
763
+ background: var(--bg-color);
764
+ color: var(--text-color);
765
+ padding: 0.32rem 2rem 0.32rem 0.65rem;
766
+ cursor: pointer;
767
+ appearance: none;
768
+ -webkit-appearance: none;
769
+ outline: none;
770
+ max-width: 260px;
771
+ }
772
+ .iv-select:focus { box-shadow: 2px 2px 0 var(--border-color); }
773
+
774
+ /* Dark variant (input area) */
775
+ .iv-select-dark {
776
+ background: #111;
777
+ color: #e5e7eb;
778
+ border-color: #333;
779
+ }
780
+ .iv-select-dark option { background: #111; color: #e5e7eb; }
781
+
782
+ /* ── GoT button ────────────────────────────────────────────── */
783
+ .iv-got-btn {
784
+ display: inline-flex;
785
+ align-items: center;
786
+ gap: 5px;
787
+ border: 2px solid var(--border-color);
788
+ background: var(--bg-color);
789
+ color: var(--text-color);
790
+ font-family: var(--font-mono);
791
+ font-size: 0.75rem;
792
+ font-weight: 700;
793
+ padding: 0.32rem 0.75rem;
794
+ cursor: pointer;
795
+ transition: all 0.13s;
796
+ white-space: nowrap;
797
+ }
798
+ .iv-got-btn.active { background: #000; color: #fff; border-color: #000; }
799
+ .iv-got-btn:hover:not(.active) { background: #f3f4f6; }
800
+
801
+ /* ── Info bar ──────────────────────────────────────────────── */
802
+ .iv-info-bar {
803
+ display: flex;
804
+ align-items: flex-start;
805
+ gap: 0.5rem;
806
+ padding: 0.45rem 1.5rem;
807
+ background: var(--surface-color);
808
+ border-bottom: 1px solid #e5e5e5;
809
+ font-size: 0.75rem;
810
+ color: var(--muted-color);
811
+ line-height: 1.5;
812
+ flex-shrink: 0;
813
+ }
814
+ .iv-info-bar strong { color: var(--text-color); }
815
+ .iv-info-bar kbd {
816
+ background: #e5e7eb; border: 1px solid #d1d5db;
817
+ border-radius: 3px; padding: 0 4px;
818
+ font-family: var(--font-mono); font-size: 0.7rem;
819
+ }
820
+
821
+ /* ── Body: sidebar + chat ──────────────────────────────────── */
822
+ .iv-body {
823
+ display: flex;
824
+ flex: 1;
825
+ min-height: 0;
826
+ overflow: hidden;
827
+ }
828
+
829
+ /* ── Sidebar ───────────────────────────────────────────────── */
830
+ .iv-sidebar {
831
+ display: flex;
832
+ flex-direction: column;
833
+ border-right: 3px solid var(--border-color);
834
+ background: var(--bg-color);
835
+ flex-shrink: 0;
836
+ overflow: hidden;
837
+ transition: width 0.22s ease;
838
+ }
839
+ .iv-sidebar.open { width: 220px; }
840
+ .iv-sidebar.closed { width: 0; border-right: none; }
841
+
842
+ .iv-new-thread {
843
+ width: 100%;
844
+ border: none;
845
+ border-bottom: 3px solid var(--border-color);
846
+ padding: 0.9rem 1rem;
847
+ background: #000;
848
+ color: #fff;
849
+ font-family: var(--font-mono);
850
+ font-size: 0.82rem;
851
+ font-weight: 700;
852
+ letter-spacing: 1px;
853
+ cursor: pointer;
854
+ flex-shrink: 0;
855
+ text-align: left;
856
+ transition: background 0.13s;
857
+ white-space: nowrap;
858
+ }
859
+ .iv-new-thread:hover { background: #222; }
860
+
861
+ .iv-thread-list {
862
+ flex: 1;
863
+ overflow-y: auto;
864
+ padding: 0.75rem;
865
+ }
866
+
867
+ .iv-thread-header {
868
+ font-family: var(--font-mono);
869
+ font-size: 0.6rem;
870
+ font-weight: 700;
871
+ color: var(--muted-color);
872
+ letter-spacing: 1px;
873
+ border-bottom: 1px dotted var(--border-color);
874
+ padding-bottom: 0.4rem;
875
+ margin-bottom: 0.6rem;
876
+ white-space: nowrap;
877
+ }
878
+
879
+ .iv-thread-item {
880
+ padding: 0.6rem 0.7rem;
881
+ border: 1.5px solid var(--border-color);
882
+ cursor: pointer;
883
+ margin-bottom: 0.4rem;
884
+ transition: background 0.13s;
885
+ }
886
+ .iv-thread-item:hover, .iv-thread-item.active {
887
+ background: var(--text-color);
888
+ color: var(--bg-color);
889
+ border-color: var(--text-color);
890
+ }
891
+ .iv-thread-title {
892
+ font-family: var(--font-mono);
893
+ font-size: 0.78rem;
894
+ font-weight: 600;
895
+ white-space: nowrap;
896
+ overflow: hidden;
897
+ text-overflow: ellipsis;
898
+ }
899
+ .iv-thread-date {
900
+ font-family: var(--font-mono);
901
+ font-size: 0.65rem;
902
+ opacity: 0.65;
903
+ margin-top: 3px;
904
+ }
905
+ .iv-empty-threads {
906
+ font-family: var(--font-mono);
907
+ color: #bbb;
908
+ font-size: 0.78rem;
909
+ text-align: center;
910
+ padding: 1.5rem 0;
911
+ }
912
+
913
+ /* ── Chat panel ────────────────────────────────────────────── */
914
+ .iv-chat-panel {
915
+ flex: 1;
916
+ min-width: 0;
917
+ display: flex;
918
+ flex-direction: column;
919
+ overflow: hidden;
920
+ }
921
+
922
+ /* ── Messages ──────────────────────────────────────────────── */
923
+ .iv-messages {
924
+ flex: 1;
925
+ overflow-y: auto;
926
+ padding: 1.5rem;
927
+ display: flex;
928
+ flex-direction: column;
929
+ gap: 1.5rem;
930
+ scroll-behavior: smooth;
931
+ }
932
+
933
+ .iv-empty-chat {
934
+ flex: 1;
935
+ display: flex;
936
+ flex-direction: column;
937
+ align-items: center;
938
+ justify-content: center;
939
+ gap: 1rem;
940
+ color: var(--muted-color);
941
+ font-family: var(--font-mono);
942
+ font-size: 0.88rem;
943
+ letter-spacing: 1.5px;
944
+ text-align: center;
945
+ padding: 3rem;
946
+ }
947
+ .iv-empty-hints {
948
+ display: flex;
949
+ flex-direction: column;
950
+ gap: 0.4rem;
951
+ margin-top: 0.75rem;
952
+ font-size: 0.72rem;
953
+ opacity: 0.6;
954
+ }
955
+
956
+ /* ── Message row ─────────────────────────────────────────── */
957
+ .iv-msg-row {
958
+ display: flex;
959
+ gap: 1rem;
960
+ align-items: flex-start;
961
+ max-width: 90%;
962
+ }
963
+ .iv-msg-row.user {
964
+ align-self: flex-end;
965
+ flex-direction: row-reverse;
966
+ max-width: 72%;
967
+ }
968
+ .iv-msg-row.assistant { align-self: flex-start; max-width: 90%; }
969
+
970
+ .iv-msg-avatar {
971
+ width: 36px;
972
+ height: 36px;
973
+ border: 2px solid var(--border-color);
974
+ display: flex;
975
+ align-items: center;
976
+ justify-content: center;
977
+ flex-shrink: 0;
978
+ background: var(--bg-color);
979
+ }
980
+ .iv-msg-row.assistant .iv-msg-avatar {
981
+ background: #000;
982
+ color: #fff;
983
+ border-color: #000;
984
+ }
985
+
986
+ .iv-msg-card {
987
+ flex: 1;
988
+ border: 2px solid var(--border-color);
989
+ background: var(--bg-color);
990
+ min-width: 0;
991
+ box-shadow: 4px 4px 0 rgba(0,0,0,0.05);
992
+ }
993
+ .iv-msg-row.assistant .iv-msg-card {
994
+ border-left: 4px solid #000;
995
+ }
996
+ .iv-msg-row.user .iv-msg-card {
997
+ border-color: #000;
998
+ background: #f8f8f8;
999
+ border-right: 4px solid #000;
1000
+ }
1001
+
1002
+ .iv-msg-header {
1003
+ display: flex;
1004
+ align-items: center;
1005
+ justify-content: space-between;
1006
+ flex-wrap: wrap;
1007
+ gap: 0.5rem;
1008
+ padding: 0.5rem 0.85rem;
1009
+ border-bottom: 1px dotted var(--border-color);
1010
+ background: var(--surface-color);
1011
+ }
1012
+ .iv-msg-role {
1013
+ font-family: var(--font-mono);
1014
+ font-size: 0.68rem;
1015
+ font-weight: 700;
1016
+ letter-spacing: 1px;
1017
+ color: var(--muted-color);
1018
+ }
1019
+
1020
+ .iv-msg-badges {
1021
+ display: flex;
1022
+ align-items: center;
1023
+ gap: 0.4rem;
1024
+ flex-wrap: wrap;
1025
+ }
1026
+ .iv-badge {
1027
+ font-family: var(--font-mono);
1028
+ font-size: 0.65rem;
1029
+ font-weight: 700;
1030
+ color: #fff;
1031
+ padding: 1px 7px;
1032
+ letter-spacing: 0.3px;
1033
+ }
1034
+ .iv-badge-outline {
1035
+ font-family: var(--font-mono);
1036
+ font-size: 0.65rem;
1037
+ font-weight: 700;
1038
+ padding: 1px 6px;
1039
+ border: 1.5px solid;
1040
+ cursor: help;
1041
+ }
1042
+
1043
+ /* ── Reasoning chain ─────────────────────────────────────── */
1044
+ .iv-reasoning {
1045
+ padding: 0.75rem 0.85rem;
1046
+ background: #fafafa;
1047
+ border-bottom: 1px dotted var(--border-color);
1048
+ display: flex;
1049
+ flex-direction: column;
1050
+ gap: 3px;
1051
+ }
1052
+ .iv-reasoning-step {
1053
+ display: flex;
1054
+ gap: 0.5rem;
1055
+ font-family: var(--font-mono);
1056
+ font-size: 0.75rem;
1057
+ color: #555;
1058
+ line-height: 1.5;
1059
+ }
1060
+ .iv-step-idx {
1061
+ background: #000;
1062
+ color: #fff;
1063
+ font-size: 0.6rem;
1064
+ font-weight: 700;
1065
+ width: 16px;
1066
+ height: 16px;
1067
+ display: inline-flex;
1068
+ align-items: center;
1069
+ justify-content: center;
1070
+ flex-shrink: 0;
1071
+ margin-top: 2px;
1072
+ }
1073
+
1074
+ /* ── Message content ─────────────────────────────────────── */
1075
+ .iv-msg-content {
1076
+ padding: 0.85rem;
1077
+ }
1078
+
1079
+ .iv-markdown { font-family: var(--font-sans); font-size: 0.97rem; line-height: 1.8; }
1080
+ .iv-markdown p { margin-bottom: 0.75rem; }
1081
+ .iv-markdown p:last-child { margin-bottom: 0; }
1082
+ .iv-markdown ul, .iv-markdown ol { margin-bottom: 0.75rem; padding-left: 1.5rem; }
1083
+ .iv-markdown li { margin-bottom: 0.2rem; }
1084
+ .iv-markdown h1, .iv-markdown h2, .iv-markdown h3 { font-family: var(--font-display); margin: 1rem 0 0.5rem; }
1085
+ .iv-markdown code { font-family: var(--font-mono); background: #f3f4f6; padding: 1px 5px; font-size: 0.85em; }
1086
+ .iv-markdown pre { background: #1e293b; color: #e2e8f0; padding: 1rem; overflow-x: auto; margin-bottom: 0.75rem; }
1087
+ .iv-markdown pre code { background: transparent; color: inherit; padding: 0; }
1088
+ .iv-markdown table { border-collapse: collapse; width: 100%; margin-bottom: 0.75rem; font-size: 0.9rem; }
1089
+ .iv-markdown th, .iv-markdown td { border: 1px solid var(--border-color); padding: 0.4rem 0.6rem; }
1090
+ .iv-markdown th { background: var(--surface-color); font-family: var(--font-mono); font-size: 0.75rem; }
1091
+ .iv-markdown blockquote { border-left: 3px solid #000; margin: 0 0 0.75rem; padding: 0.5rem 0.75rem; color: #555; background: #fafafa; }
1092
+
1093
+ .iv-cursor { animation: blink 0.9s step-end infinite; font-size: 1.1rem; }
1094
+ @keyframes blink { 50% { opacity: 0; } }
1095
+
1096
+ /* ── Sources ──────────────────────────────────────────────── */
1097
+ .iv-sources {
1098
+ padding: 0.65rem 0.85rem;
1099
+ border-top: 1px dashed var(--border-color);
1100
+ background: #fafafa;
1101
+ }
1102
+ .iv-sources-top {
1103
+ display: flex;
1104
+ align-items: flex-start;
1105
+ justify-content: space-between;
1106
+ gap: 0.75rem;
1107
+ flex-wrap: wrap;
1108
+ }
1109
+ .iv-sources-label {
1110
+ font-family: var(--font-mono);
1111
+ font-size: 0.7rem;
1112
+ font-weight: 700;
1113
+ color: var(--muted-color);
1114
+ display: flex;
1115
+ align-items: center;
1116
+ gap: 0.5rem;
1117
+ flex-wrap: wrap;
1118
+ }
1119
+ .iv-source-chips { display: flex; flex-wrap: wrap; gap: 4px; }
1120
+ .iv-source-chip {
1121
+ background: var(--bg-color);
1122
+ border: 1px solid var(--border-color);
1123
+ padding: 2px 8px;
1124
+ cursor: pointer;
1125
+ font-family: var(--font-mono);
1126
+ font-size: 0.7rem;
1127
+ transition: all 0.12s;
1128
+ }
1129
+ .iv-source-chip:hover { background: #000; color: #fff; border-color: #000; }
1130
+
1131
+ .iv-eval-btn {
1132
+ font-family: var(--font-mono);
1133
+ font-size: 0.68rem;
1134
+ font-weight: 700;
1135
+ border: 2px solid #000;
1136
+ background: var(--bg-color);
1137
+ color: var(--text-color);
1138
+ padding: 2px 10px;
1139
+ cursor: pointer;
1140
+ letter-spacing: 0.5px;
1141
+ white-space: nowrap;
1142
+ transition: all 0.12s;
1143
+ flex-shrink: 0;
1144
+ }
1145
+ .iv-eval-btn:hover:not(:disabled) { background: #000; color: #fff; }
1146
+ .iv-eval-btn:disabled { opacity: 0.4; cursor: not-allowed; }
1147
+
1148
+ /* ── Eval results ─────────────────────────────────────────── */
1149
+ .iv-eval-result {
1150
+ margin-top: 0.65rem;
1151
+ padding: 0.75rem;
1152
+ border: 1px solid var(--border-color);
1153
+ background: var(--bg-color);
1154
+ }
1155
+ .iv-eval-title {
1156
+ font-family: var(--font-mono);
1157
+ font-size: 0.65rem;
1158
+ font-weight: 700;
1159
+ color: var(--muted-color);
1160
+ letter-spacing: 1px;
1161
+ margin-bottom: 0.6rem;
1162
+ }
1163
+ .iv-eval-grid { display: flex; flex-direction: column; gap: 0.4rem; }
1164
+ .iv-eval-metric { display: flex; align-items: center; gap: 0.6rem; }
1165
+ .iv-eval-label { font-family: var(--font-mono); font-size: 0.65rem; color: #666; width: 80px; flex-shrink: 0; }
1166
+ .iv-eval-bar-wrap { flex: 1; height: 5px; background: #e5e7eb; }
1167
+ .iv-eval-bar { height: 100%; transition: width 0.4s ease; }
1168
+ .iv-eval-pct { font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700; width: 36px; text-align: right; }
1169
+
1170
+ /* ── Input area ───────────────────────────────────────────── */
1171
+ .iv-input-area {
1172
+ border-top: 3px solid var(--border-color);
1173
+ background: #000;
1174
+ flex-shrink: 0;
1175
+ display: flex;
1176
+ flex-direction: column;
1177
+ }
1178
+
1179
+ .iv-input-controls {
1180
+ display: flex;
1181
+ align-items: flex-end;
1182
+ gap: 1rem;
1183
+ padding: 0.55rem 1rem 0.45rem;
1184
+ border-bottom: 1px dotted #333;
1185
+ flex-wrap: wrap;
1186
+ }
1187
+ .iv-input-controls .iv-ctrl-label { color: #888; }
1188
+ .iv-input-controls .iv-select-chevron { color: #888; }
1189
+
1190
+ .iv-agent-input {
1191
+ font-family: var(--font-mono);
1192
+ font-size: 0.82rem;
1193
+ font-weight: 600;
1194
+ border: 2px solid #444;
1195
+ background: #111;
1196
+ color: #fff;
1197
+ padding: 0.32rem 0.65rem;
1198
+ width: 160px;
1199
+ outline: none;
1200
+ }
1201
+ .iv-agent-input:focus { border-color: #f59e0b; box-shadow: 2px 2px 0 #f59e0b; }
1202
+ .iv-agent-input::placeholder { color: #888; }
1203
+
1204
+ .iv-scope-chip {
1205
+ display: inline-flex;
1206
+ align-items: center;
1207
+ gap: 5px;
1208
+ border: 1.5px solid #444;
1209
+ background: #111;
1210
+ color: #aaa;
1211
+ font-family: var(--font-mono);
1212
+ font-size: 0.7rem;
1213
+ padding: 3px 8px;
1214
+ align-self: flex-end;
1215
+ margin-bottom: 2px;
1216
+ }
1217
+ .iv-scope-chip button {
1218
+ background: none;
1219
+ border: none;
1220
+ color: #aaa;
1221
+ cursor: pointer;
1222
+ padding: 0;
1223
+ display: flex;
1224
+ align-items: center;
1225
+ }
1226
+ .iv-scope-chip button:hover { color: #ef4444; }
1227
+
1228
+ /* ── Text input row ───────────────────────────────────────── */
1229
+ .iv-input-row {
1230
+ display: flex;
1231
+ align-items: center;
1232
+ padding: 0 0.5rem 0 1rem;
1233
+ gap: 0.5rem;
1234
+ min-height: 56px;
1235
+ }
1236
+
1237
+ .iv-prompt-marker {
1238
+ font-family: var(--font-mono);
1239
+ font-size: 1.3rem;
1240
+ font-weight: 700;
1241
+ color: #fff;
1242
+ flex-shrink: 0;
1243
+ user-select: none;
1244
+ }
1245
+
1246
+ .iv-text-input {
1247
+ flex: 1;
1248
+ background: transparent;
1249
+ border: none;
1250
+ outline: none;
1251
+ font-family: var(--font-mono);
1252
+ font-size: 1rem;
1253
+ color: #fff;
1254
+ padding: 0.75rem 0.5rem;
1255
+ caret-color: #fff;
1256
+ }
1257
+ .iv-text-input::placeholder { color: #666; }
1258
+ .iv-text-input:disabled { opacity: 0.4; cursor: not-allowed; }
1259
+
1260
+ .iv-send-btn {
1261
+ width: 44px;
1262
+ height: 44px;
1263
+ border: 2px solid #444;
1264
+ background: #111;
1265
+ color: #888;
1266
+ display: flex;
1267
+ align-items: center;
1268
+ justify-content: center;
1269
+ cursor: pointer;
1270
+ flex-shrink: 0;
1271
+ transition: all 0.13s;
1272
+ }
1273
+ .iv-send-btn:hover:not(:disabled) { background: #fff; color: #000; border-color: #fff; }
1274
+ .iv-send-btn:disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; }
1275
+
1276
+ .iv-spinner {
1277
+ width: 18px;
1278
+ height: 18px;
1279
+ border: 2px solid #444;
1280
+ border-top-color: #fff;
1281
+ border-radius: 50%;
1282
+ animation: spin 0.8s linear infinite;
1283
+ }
1284
+ @keyframes spin { 100% { transform: rotate(360deg); } }
1285
+
1286
+ /* ── Source drawer (modal style) ─────────────────────────── */
1287
+ .iv-drawer-overlay {
1288
+ position: fixed;
1289
+ inset: 0;
1290
+ background: rgba(0,0,0,0.4);
1291
+ z-index: 999;
1292
+ display: flex;
1293
+ align-items: stretch;
1294
+ justify-content: flex-end;
1295
+ }
1296
+
1297
+ .iv-drawer {
1298
+ width: min(420px, 95vw);
1299
+ background: var(--bg-color);
1300
+ border-left: 3px solid var(--border-color);
1301
+ display: flex;
1302
+ flex-direction: column;
1303
+ animation: slideIn 0.22s ease-out;
1304
+ box-shadow: -8px 0 32px rgba(0,0,0,0.15);
1305
+ }
1306
+ @keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
1307
+
1308
+ .iv-drawer-header {
1309
+ padding: 1rem 1.25rem;
1310
+ border-bottom: 3px solid var(--border-color);
1311
+ background: var(--surface-color);
1312
+ display: flex;
1313
+ align-items: center;
1314
+ justify-content: space-between;
1315
+ flex-shrink: 0;
1316
+ }
1317
+ .iv-drawer-header h3 { margin: 0; font-size: 0.85rem; }
1318
+
1319
+ .iv-drawer-close {
1320
+ width: 32px;
1321
+ height: 32px;
1322
+ border: 2px solid var(--border-color);
1323
+ background: var(--bg-color);
1324
+ color: var(--text-color);
1325
+ display: flex;
1326
+ align-items: center;
1327
+ justify-content: center;
1328
+ cursor: pointer;
1329
+ transition: all 0.12s;
1330
+ }
1331
+ .iv-drawer-close:hover { background: #000; color: #fff; border-color: #000; }
1332
+
1333
+ .iv-drawer-body {
1334
+ flex: 1;
1335
+ overflow-y: auto;
1336
+ padding: 1.25rem;
1337
+ }
1338
+
1339
+ .iv-drawer-meta { margin-bottom: 1rem; }
1340
+ .iv-meta-row {
1341
+ display: flex;
1342
+ gap: 0.75rem;
1343
+ margin-bottom: 0.5rem;
1344
+ font-family: var(--font-mono);
1345
+ font-size: 0.8rem;
1346
+ align-items: flex-start;
1347
+ }
1348
+ .iv-meta-key { font-weight: 700; color: var(--muted-color); min-width: 80px; flex-shrink: 0; }
1349
+
1350
+ .iv-drawer-divider {
1351
+ border: none;
1352
+ border-top: 1px dashed var(--border-color);
1353
+ margin: 0.75rem 0 1rem;
1354
+ }
1355
+
1356
+ .iv-drawer-text {
1357
+ font-family: var(--font-mono);
1358
+ font-size: 0.82rem;
1359
+ line-height: 1.7;
1360
+ white-space: pre-wrap;
1361
+ color: #444;
1362
+ }
1363
+
1364
+ /* ── Responsive ──────────────────────────────────────────── */
1365
+ @media (max-width: 768px) {
1366
+ .iv-header { padding: 0.5rem 0.75rem; }
1367
+ .iv-title { font-size: 0.88rem; letter-spacing: 1px; }
1368
+ .iv-info-bar { display: none; }
1369
+ .iv-sidebar.open { width: 200px; }
1370
+ .iv-messages { padding: 1rem 0.75rem; }
1371
+ .iv-msg-row { max-width: 100% !important; }
1372
+ .iv-msg-avatar { width: 28px; height: 28px; }
1373
+ .iv-msg-card { font-size: 0.9rem; }
1374
+ .iv-msg-content { padding: 0.6rem; }
1375
+ .iv-input-controls { padding: 0.4rem 0.75rem; gap: 0.5rem; }
1376
+ .iv-input-row { min-height: 46px; }
1377
+ .iv-text-input { font-size: 0.9rem; }
1378
+ .iv-select { max-width: 200px; }
1379
+ }
1380
+
1381
+ @media (max-width: 480px) {
1382
+ .iv-sidebar.open {
1383
+ position: absolute;
1384
+ left: 0; top: 0; bottom: 0;
1385
+ z-index: 50;
1386
+ border-right: 3px solid #000;
1387
+ box-shadow: 4px 0 20px rgba(0,0,0,0.2);
1388
+ }
1389
+ .iv-msg-row.user { max-width: 90% !important; }
1390
+ }
1391
+ `}</style>
1392
+ </div>
1393
+ );
1394
+ };
1395
+
1396
+ export default InteractionView;
frontend-react/src/views/Login.tsx ADDED
@@ -0,0 +1,644 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { useAuth } from '../context/AuthContext';
4
+ import { LogIn, UserPlus, Eye, EyeOff } from 'lucide-react';
5
+
6
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
7
+
8
+ /* ─── Typewriter ─── */
9
+ const useTypewriter = (words: string[], speed = 80, pause = 2200) => {
10
+ const [index, setIndex] = useState(0);
11
+ const [sub, setSub] = useState(0);
12
+ const [deleting, setDeleting] = useState(false);
13
+ const [text, setText] = useState('');
14
+ useEffect(() => {
15
+ const word = words[index % words.length];
16
+ const timer = setTimeout(() => {
17
+ if (!deleting) {
18
+ setText(word.slice(0, sub + 1));
19
+ setSub(s => s + 1);
20
+ if (sub + 1 === word.length) setTimeout(() => setDeleting(true), pause);
21
+ } else {
22
+ setText(word.slice(0, sub - 1));
23
+ setSub(s => s - 1);
24
+ if (sub - 1 === 0) { setDeleting(false); setIndex(i => i + 1); }
25
+ }
26
+ }, deleting ? speed / 2 : speed);
27
+ return () => clearTimeout(timer);
28
+ }, [sub, deleting, index]);
29
+ return text;
30
+ };
31
+
32
+ const Login: React.FC = () => {
33
+ const [isRegistering, setIsRegistering] = useState(false);
34
+ const [username, setUsername] = useState('');
35
+ const [password, setPassword] = useState('');
36
+ const [error, setError] = useState('');
37
+ const [loading, setLoading] = useState(false);
38
+ const [showPassword, setShowPassword] = useState(false);
39
+
40
+ const { login } = useAuth();
41
+ const navigate = useNavigate();
42
+
43
+ const rotatingWords = ['Knowledge Graphs.', 'Logic Engines.', 'LLM Reasoning.', 'Entity Networks.', 'Semantic Search.'];
44
+ const rotating = useTypewriter(rotatingWords);
45
+
46
+ const handleSubmit = async (e: React.FormEvent) => {
47
+ e.preventDefault();
48
+ setError('');
49
+ setLoading(true);
50
+ try {
51
+ if (isRegistering) {
52
+ const regRes = await fetch(`${API_BASE}/auth/register`, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({ username, password, scopes: ['read', 'write', 'admin'] }),
56
+ });
57
+ if (!regRes.ok) {
58
+ const errData = await regRes.json();
59
+ throw new Error(errData.detail || 'Registration failed');
60
+ }
61
+ }
62
+ const loginRes = await fetch(`${API_BASE}/auth/login`, {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json' },
65
+ body: JSON.stringify({ username, password }),
66
+ });
67
+ if (!loginRes.ok) {
68
+ const errData = await loginRes.json();
69
+ throw new Error(errData.detail || 'Login failed');
70
+ }
71
+ const { access_token } = await loginRes.json();
72
+ const userRes = await fetch(`${API_BASE}/auth/me`, {
73
+ headers: { Authorization: `Bearer ${access_token}` },
74
+ });
75
+ if (!userRes.ok) throw new Error('Failed to fetch user profile');
76
+ const user = await userRes.json();
77
+ login(access_token, user);
78
+ navigate('/');
79
+ } catch (err: any) {
80
+ setError(err.message);
81
+ } finally {
82
+ setLoading(false);
83
+ }
84
+ };
85
+
86
+ return (
87
+ <div className="lp-root">
88
+
89
+ {/* ── Brand mark top-left ── */}
90
+ <div className="lp-brand">
91
+ <span className="lp-brand-dot" />
92
+ CORTEX
93
+ </div>
94
+
95
+ {/* ── Version badge top-right ── */}
96
+ <div className="lp-version">v1.0 · PRODUCTION</div>
97
+
98
+ {/* ── Giant background wordmark ── */}
99
+ <div className="lp-bg-word" aria-hidden>COR</div>
100
+ <div className="lp-bg-word lp-bg-word-2" aria-hidden>TEX</div>
101
+
102
+ {/* ── Hero headline, top-center area ── */}
103
+ <div className="lp-headline">
104
+ <div className="lp-headline-super">AGENTIC KNOWLEDGE PLATFORM</div>
105
+ <h1 className="lp-headline-h1">
106
+ Enterprise-grade<br/>
107
+ <span className="lp-headline-rotating">
108
+ {rotating}<span className="lp-cursor">|</span>
109
+ </span>
110
+ </h1>
111
+ </div>
112
+
113
+ {/* ── Floating stat chips ── */}
114
+ <div className="lp-stat lp-stat-1">
115
+ <div className="lp-stat-num">Neo4j</div>
116
+ <div className="lp-stat-key">GRAPH ENGINE</div>
117
+ </div>
118
+ <div className="lp-stat lp-stat-2">
119
+ <div className="lp-stat-num">ReACT</div>
120
+ <div className="lp-stat-key">AGENT LOOP</div>
121
+ </div>
122
+ <div className="lp-stat lp-stat-3">
123
+ <div className="lp-stat-num">EVAL</div>
124
+ <div className="lp-stat-key">SCORING</div>
125
+ </div>
126
+ <div className="lp-stat lp-stat-4">
127
+ <div className="lp-stat-num">Multi-hop</div>
128
+ <div className="lp-stat-key">REASONING</div>
129
+ </div>
130
+
131
+ {/* ── Vertical feature list bottom-left ── */}
132
+ <div className="lp-features">
133
+ <div className="lp-features-label">CAPABILITIES</div>
134
+ {[
135
+ 'Document ingestion → Knowledge graph',
136
+ 'LLM entity & relationship extraction',
137
+ 'Agentic multi-step query reasoning',
138
+ 'Hallucination risk scoring',
139
+ 'Ontology drift detection & governance',
140
+ 'Entity enrichment & deduplication',
141
+ 'Interactive D3 force visualization',
142
+ 'Export: JSON · Cypher · GraphML',
143
+ ].map((f, i) => (
144
+ <div key={i} className="lp-feature-row">
145
+ <span className="lp-feature-tick">→</span>
146
+ <span>{f}</span>
147
+ </div>
148
+ ))}
149
+ </div>
150
+
151
+ {/* ── Horizontal tech stack bottom-center ── */}
152
+ <div className="lp-stack">
153
+ {['FastAPI', 'Neo4j', 'Redis', 'Celery', 'Gemini', 'LangChain'].map(t => (
154
+ <span key={t} className="lp-stack-tag">{t}</span>
155
+ ))}
156
+ </div>
157
+
158
+ {/* ── Bottom-right quote ── */}
159
+ <div className="lp-quote">
160
+ "Knowledge is only useful<br/>when it can be reasoned over."
161
+ </div>
162
+
163
+ {/* ── Decorative rule lines ── */}
164
+ <div className="lp-rule lp-rule-h1" aria-hidden />
165
+ <div className="lp-rule lp-rule-h2" aria-hidden />
166
+ <div className="lp-rule lp-rule-v1" aria-hidden />
167
+
168
+ {/* ── LOGIN CARD (right side, vertically centered) ── */}
169
+ <div className="lp-card-wrap">
170
+ <div className="login-card">
171
+ <h2 className="login-title">
172
+ {isRegistering ? 'INITIALIZE ACCESS' : 'SYSTEM AUTH'}
173
+ </h2>
174
+
175
+ {error && <div className="error-banner">{error}</div>}
176
+
177
+ <form onSubmit={handleSubmit} className="login-form">
178
+ <div className="input-group">
179
+ <label>IDENTIFIER [USER]</label>
180
+ <input
181
+ type="text"
182
+ value={username}
183
+ onChange={(e) => setUsername(e.target.value)}
184
+ required
185
+ className="mono-text"
186
+ autoComplete="username"
187
+ />
188
+ </div>
189
+
190
+ <div className="input-group">
191
+ <label>KEY [PASS]</label>
192
+ <div className="password-wrapper">
193
+ <input
194
+ type={showPassword ? 'text' : 'password'}
195
+ value={password}
196
+ onChange={(e) => setPassword(e.target.value)}
197
+ required
198
+ className="mono-text"
199
+ autoComplete="current-password"
200
+ />
201
+ <button
202
+ type="button"
203
+ className="eye-btn"
204
+ onClick={() => setShowPassword(!showPassword)}
205
+ tabIndex={-1}
206
+ title="Toggle visibility"
207
+ >
208
+ {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
209
+ </button>
210
+ </div>
211
+ </div>
212
+
213
+ <button type="submit" className="app-btn auth-btn" disabled={loading}>
214
+ {loading
215
+ ? 'PROCESSING...'
216
+ : isRegistering
217
+ ? <><UserPlus size={16} /> REGISTER</>
218
+ : <><LogIn size={16} /> AUTHENTICATE →</>
219
+ }
220
+ </button>
221
+ </form>
222
+
223
+ <p className="toggle-mode">
224
+ <button type="button" className="text-btn" onClick={() => setIsRegistering(!isRegistering)}>
225
+ {isRegistering ? 'RETURN TO LOGIN' : 'REQUEST ACCESS (REGISTER)'}
226
+ </button>
227
+ </p>
228
+
229
+ <div className="lp-card-footer">
230
+ <span>CORTEX PLATFORM</span>
231
+ <span>SECURED · ENCRYPTED</span>
232
+ </div>
233
+ </div>
234
+ </div>
235
+
236
+ <style>{`
237
+ /* ── Root ── */
238
+ .lp-root {
239
+ position: fixed;
240
+ inset: 0;
241
+ background: #fff;
242
+ overflow: hidden;
243
+ font-family: var(--font-sans);
244
+ }
245
+
246
+ /* ── Brand top-left ── */
247
+ .lp-brand {
248
+ position: absolute;
249
+ top: 2rem;
250
+ left: 2.5rem;
251
+ font-family: var(--font-mono);
252
+ font-size: 0.75rem;
253
+ font-weight: 900;
254
+ letter-spacing: 3px;
255
+ display: flex;
256
+ align-items: center;
257
+ gap: 0.5rem;
258
+ z-index: 10;
259
+ }
260
+ .lp-brand-dot {
261
+ width: 8px; height: 8px;
262
+ background: #000;
263
+ border-radius: 50%;
264
+ animation: brandPulse 2.5s ease-in-out infinite;
265
+ }
266
+ @keyframes brandPulse {
267
+ 0%, 100% { transform: scale(1); }
268
+ 50% { transform: scale(1.4); }
269
+ }
270
+
271
+ /* ── Version badge ── */
272
+ .lp-version {
273
+ position: absolute;
274
+ top: 2rem;
275
+ right: 420px;
276
+ font-family: var(--font-mono);
277
+ font-size: 0.65rem;
278
+ font-weight: 700;
279
+ letter-spacing: 2px;
280
+ color: #aaa;
281
+ z-index: 10;
282
+ }
283
+
284
+ /* ── Giant background wordmarks ── */
285
+ .lp-bg-word {
286
+ position: absolute;
287
+ top: -0.15em;
288
+ left: -0.05em;
289
+ font-family: var(--font-display);
290
+ font-size: clamp(200px, 28vw, 380px);
291
+ font-weight: 900;
292
+ line-height: 1;
293
+ color: transparent;
294
+ -webkit-text-stroke: 1.5px #ebebeb;
295
+ letter-spacing: -0.04em;
296
+ pointer-events: none;
297
+ user-select: none;
298
+ z-index: 0;
299
+ }
300
+ .lp-bg-word-2 {
301
+ top: auto;
302
+ bottom: -0.1em;
303
+ left: auto;
304
+ right: 360px;
305
+ -webkit-text-stroke: 1.5px #f0f0f0;
306
+ font-size: clamp(160px, 22vw, 300px);
307
+ }
308
+
309
+ /* ── Hero headline ── */
310
+ .lp-headline {
311
+ position: absolute;
312
+ top: 5rem;
313
+ left: 2.5rem;
314
+ right: 420px;
315
+ z-index: 5;
316
+ }
317
+ .lp-headline-super {
318
+ font-family: var(--font-mono);
319
+ font-size: 0.65rem;
320
+ font-weight: 700;
321
+ letter-spacing: 4px;
322
+ color: #999;
323
+ margin-bottom: 0.75rem;
324
+ }
325
+ .lp-headline-h1 {
326
+ font-family: var(--font-display);
327
+ font-size: clamp(2rem, 3.8vw, 3.4rem);
328
+ font-weight: 800;
329
+ line-height: 1.15;
330
+ letter-spacing: -0.5px;
331
+ }
332
+ .lp-headline-rotating {
333
+ display: inline-block;
334
+ border-bottom: 4px solid #000;
335
+ min-width: 2ch;
336
+ }
337
+ .lp-cursor {
338
+ display: inline-block;
339
+ width: 2px;
340
+ animation: blink 0.9s step-end infinite;
341
+ font-weight: 300;
342
+ margin-left: 1px;
343
+ }
344
+ @keyframes blink { 50% { opacity: 0; } }
345
+
346
+ /* ── Floating stat chips ── */
347
+ .lp-stat {
348
+ position: absolute;
349
+ border: 2px solid #000;
350
+ padding: 0.6rem 0.9rem;
351
+ z-index: 5;
352
+ background: #fff;
353
+ transition: transform 0.2s, box-shadow 0.2s;
354
+ }
355
+ .lp-stat:hover {
356
+ transform: translateY(-3px);
357
+ box-shadow: 4px 4px 0 #000;
358
+ }
359
+ .lp-stat-num {
360
+ font-family: var(--font-mono);
361
+ font-size: 0.88rem;
362
+ font-weight: 900;
363
+ line-height: 1;
364
+ margin-bottom: 0.2rem;
365
+ }
366
+ .lp-stat-key {
367
+ font-family: var(--font-mono);
368
+ font-size: 0.58rem;
369
+ color: #888;
370
+ letter-spacing: 1px;
371
+ font-weight: 700;
372
+ }
373
+ .lp-stat-1 { top: 36%; left: 2.5rem; }
374
+ .lp-stat-2 { top: 37%; left: 9rem; }
375
+ .lp-stat-3 { top: 44%; left: 2.5rem; }
376
+ .lp-stat-4 { top: 44%; left: 9rem; }
377
+
378
+ /* ── Feature list ── */
379
+ .lp-features {
380
+ position: absolute;
381
+ bottom: 5rem;
382
+ left: 2.5rem;
383
+ width: 340px;
384
+ z-index: 5;
385
+ }
386
+ .lp-features-label {
387
+ font-family: var(--font-mono);
388
+ font-size: 0.6rem;
389
+ font-weight: 700;
390
+ letter-spacing: 3px;
391
+ color: #bbb;
392
+ margin-bottom: 0.6rem;
393
+ }
394
+ .lp-feature-row {
395
+ display: flex;
396
+ gap: 0.5rem;
397
+ font-family: var(--font-sans);
398
+ font-size: 0.78rem;
399
+ color: #333;
400
+ padding: 0.22rem 0;
401
+ border-bottom: 1px solid #f0f0f0;
402
+ line-height: 1.5;
403
+ }
404
+ .lp-feature-tick {
405
+ font-family: var(--font-mono);
406
+ font-size: 0.72rem;
407
+ font-weight: 700;
408
+ color: #000;
409
+ flex-shrink: 0;
410
+ margin-top: 1px;
411
+ }
412
+
413
+ /* ── Tech stack ── */
414
+ .lp-stack {
415
+ position: absolute;
416
+ bottom: 1.75rem;
417
+ left: 2.5rem;
418
+ right: 420px;
419
+ display: flex;
420
+ gap: 0.5rem;
421
+ flex-wrap: wrap;
422
+ z-index: 5;
423
+ }
424
+ .lp-stack-tag {
425
+ font-family: var(--font-mono);
426
+ font-size: 0.65rem;
427
+ font-weight: 700;
428
+ letter-spacing: 0.5px;
429
+ border: 1.5px solid #000;
430
+ padding: 2px 8px;
431
+ background: #fff;
432
+ }
433
+
434
+ /* ── Bottom-right quote ── */
435
+ .lp-quote {
436
+ position: absolute;
437
+ bottom: 4rem;
438
+ right: 420px;
439
+ width: 240px;
440
+ font-family: var(--font-display);
441
+ font-size: 0.82rem;
442
+ font-style: italic;
443
+ color: #bbb;
444
+ text-align: right;
445
+ line-height: 1.6;
446
+ z-index: 5;
447
+ }
448
+
449
+ /* ── Decorative rules ── */
450
+ .lp-rule {
451
+ position: absolute;
452
+ background: #e8e8e8;
453
+ z-index: 4;
454
+ pointer-events: none;
455
+ }
456
+ .lp-rule-h1 {
457
+ top: 4rem;
458
+ left: 0; right: 0;
459
+ height: 1px;
460
+ }
461
+ .lp-rule-h2 {
462
+ bottom: 3.5rem;
463
+ left: 0; right: 0;
464
+ height: 1px;
465
+ }
466
+ .lp-rule-v1 {
467
+ top: 0; bottom: 0;
468
+ right: 400px;
469
+ width: 2px;
470
+ background: #000;
471
+ }
472
+
473
+ /* ── Login card ── */
474
+ .lp-card-wrap {
475
+ position: absolute;
476
+ top: 0; bottom: 0;
477
+ right: 0;
478
+ width: 400px;
479
+ display: flex;
480
+ align-items: center;
481
+ justify-content: center;
482
+ background: #fff;
483
+ z-index: 20;
484
+ padding: 2rem 2.5rem;
485
+ }
486
+
487
+ .login-card {
488
+ width: 100%;
489
+ animation: slideUp 0.45s ease both;
490
+ }
491
+ @keyframes slideUp {
492
+ from { transform: translateY(16px); opacity: 0; }
493
+ to { transform: translateY(0); opacity: 1; }
494
+ }
495
+
496
+ .login-title {
497
+ font-family: var(--font-display);
498
+ font-size: 1.15rem;
499
+ font-weight: 800;
500
+ letter-spacing: 2px;
501
+ margin-bottom: 2rem;
502
+ text-transform: uppercase;
503
+ border-bottom: 3px solid #000;
504
+ padding-bottom: 1rem;
505
+ }
506
+
507
+ .input-group {
508
+ margin-bottom: 1.25rem;
509
+ }
510
+ .input-group label {
511
+ display: block;
512
+ font-family: var(--font-mono);
513
+ font-size: 0.72rem;
514
+ font-weight: 700;
515
+ letter-spacing: 1px;
516
+ margin-bottom: 0.4rem;
517
+ color: #555;
518
+ }
519
+ .input-group input {
520
+ width: 100%;
521
+ border: 2px solid #000;
522
+ padding: 0.75rem;
523
+ font-size: 0.95rem;
524
+ background: #fafafa;
525
+ transition: box-shadow 0.15s;
526
+ }
527
+ .input-group input:focus {
528
+ outline: none;
529
+ box-shadow: 3px 3px 0 #000;
530
+ background: #fff;
531
+ }
532
+ .password-wrapper {
533
+ position: relative;
534
+ display: flex;
535
+ align-items: center;
536
+ }
537
+ .password-wrapper input { padding-right: 2.5rem; }
538
+ .eye-btn {
539
+ position: absolute;
540
+ right: 0.5rem;
541
+ background: none !important;
542
+ border: none !important;
543
+ cursor: pointer;
544
+ color: #888;
545
+ display: flex;
546
+ align-items: center;
547
+ transition: color 0.15s !important;
548
+ }
549
+ .eye-btn:hover { background: none !important; color: #000 !important; }
550
+
551
+ .auth-btn {
552
+ width: 100%;
553
+ padding: 0.9rem;
554
+ display: flex;
555
+ align-items: center;
556
+ justify-content: center;
557
+ gap: 0.5rem;
558
+ margin-top: 0.5rem;
559
+ font-size: 0.85rem;
560
+ letter-spacing: 1px;
561
+ background: #000;
562
+ color: #fff;
563
+ border: 2px solid #000;
564
+ transition: background 0.15s, transform 0.15s;
565
+ }
566
+ .auth-btn:hover:not(:disabled) {
567
+ background: #222;
568
+ transform: translateY(-1px);
569
+ box-shadow: 3px 3px 0 rgba(0,0,0,0.2);
570
+ }
571
+ .auth-btn:disabled { opacity: 0.5; }
572
+
573
+ .error-banner {
574
+ background: #000;
575
+ color: #fff;
576
+ padding: 0.6rem 0.75rem;
577
+ margin-bottom: 1.25rem;
578
+ font-family: var(--font-mono);
579
+ font-size: 0.78rem;
580
+ border-left: 4px solid #dc2626;
581
+ }
582
+
583
+ .toggle-mode {
584
+ text-align: center;
585
+ margin-top: 1.25rem;
586
+ }
587
+ .text-btn {
588
+ background: none;
589
+ border: none;
590
+ color: #555;
591
+ text-decoration: underline;
592
+ text-underline-offset: 4px;
593
+ cursor: pointer;
594
+ font-family: var(--font-mono);
595
+ font-size: 0.72rem;
596
+ letter-spacing: 0.5px;
597
+ transition: color 0.15s;
598
+ }
599
+ .text-btn:hover { color: #000; background: none; }
600
+
601
+ .lp-card-footer {
602
+ display: flex;
603
+ justify-content: space-between;
604
+ margin-top: 2rem;
605
+ padding-top: 1rem;
606
+ border-top: 1px solid #e5e5e5;
607
+ font-family: var(--font-mono);
608
+ font-size: 0.6rem;
609
+ color: #bbb;
610
+ letter-spacing: 0.5px;
611
+ }
612
+
613
+ /* ── Responsive ── */
614
+ @media (max-width: 800px) {
615
+ .lp-headline { right: 2rem; }
616
+ .lp-version { right: 2rem; }
617
+ .lp-rule-v1 { display: none; }
618
+ .lp-card-wrap {
619
+ position: fixed;
620
+ inset: 0;
621
+ width: 100%;
622
+ background: rgba(255,255,255,0.95);
623
+ backdrop-filter: blur(8px);
624
+ }
625
+ .lp-bg-word, .lp-bg-word-2, .lp-features, .lp-stack, .lp-quote,
626
+ .lp-stat-1, .lp-stat-2, .lp-stat-3, .lp-stat-4 { display: none; }
627
+ }
628
+
629
+ @media (max-height: 750px) {
630
+ .lp-headline { top: 4rem; }
631
+ .lp-headline-h1 { font-size: clamp(1.8rem, 3.5vw, 2.8rem); }
632
+ .lp-stat-1 { top: 32%; }
633
+ .lp-stat-2 { top: 33%; }
634
+ .lp-stat-3 { top: 41%; }
635
+ .lp-stat-4 { top: 41%; }
636
+ .lp-features { bottom: 4rem; transform: scale(0.9); transform-origin: left bottom; }
637
+ .lp-stack { display: none; }
638
+ }
639
+ `}</style>
640
+ </div>
641
+ );
642
+ };
643
+
644
+ export default Login;
frontend-react/src/views/Ontology.tsx ADDED
@@ -0,0 +1,803 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useAuth } from '../context/AuthContext';
3
+ import { DriftReport, DocumentInfo } from '../types/api';
4
+ import { Database, GitMerge, Settings, Sparkles, Save, Info, Zap, AlertTriangle, Check, X, FileText } from 'lucide-react';
5
+
6
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
7
+
8
+ const Ontology: React.FC = () => {
9
+ const { token, logout } = useAuth();
10
+
11
+ const [loading, setLoading] = useState(true);
12
+ const [saving, setSaving] = useState(false);
13
+ const [refining, setRefining] = useState(false);
14
+ const [deduping, setDeduping] = useState(false);
15
+ const [enriching, setEnriching] = useState(false);
16
+ const [detectingDrift, setDetectingDrift] = useState(false);
17
+ const [driftReports, setDriftReports] = useState<DriftReport[]>([]);
18
+
19
+ const [version, setVersion] = useState('');
20
+ const [entityTypes, setEntityTypes] = useState('');
21
+ const [relationshipTypes, setRelationshipTypes] = useState('');
22
+ const [properties, setProperties] = useState('');
23
+ const [feedback, setFeedback] = useState('');
24
+
25
+ // "Global" baseline so we can restore when switching back to global mode
26
+ const [globalEntityTypes, setGlobalEntityTypes] = useState('');
27
+ const [globalRelationshipTypes, setGlobalRelationshipTypes] = useState('');
28
+ const [globalProperties, setGlobalProperties] = useState('');
29
+
30
+ const [documents, setDocuments] = useState<DocumentInfo[]>([]);
31
+ const [selectedDocId, setSelectedDocId] = useState<string>('');
32
+ const [stats, setStats] = useState<any>(null);
33
+ const [docSchemaLoading, setDocSchemaLoading] = useState(false);
34
+
35
+ const [message, setMessage] = useState('');
36
+
37
+ /* ── Fetch global ontology ─────────────────────────────────────── */
38
+ const fetchOntology = async () => {
39
+ setLoading(true);
40
+ try {
41
+ const res = await fetch(`${API_BASE}/ontology`, {
42
+ headers: { Authorization: `Bearer ${token}` }
43
+ });
44
+ if (res.status === 401) { logout(); return; }
45
+ if (res.ok) {
46
+ const data = await res.json();
47
+ setVersion(data.version || '1.0');
48
+ const ent = data.entity_types?.join(', ') || '';
49
+ const rel = data.relationship_types?.join(', ') || '';
50
+ const props = JSON.stringify(data.properties || {}, null, 2);
51
+ setEntityTypes(ent);
52
+ setRelationshipTypes(rel);
53
+ setProperties(props);
54
+ // Save as global baseline
55
+ setGlobalEntityTypes(ent);
56
+ setGlobalRelationshipTypes(rel);
57
+ setGlobalProperties(props);
58
+ } else {
59
+ setMessage('No active ontology found. Please upload documents first.');
60
+ }
61
+ } catch (err) {
62
+ console.error(err);
63
+ setMessage('FAILED TO LOAD ONTOLOGY API');
64
+ } finally {
65
+ setLoading(false);
66
+ }
67
+ };
68
+
69
+ const fetchDocuments = async () => {
70
+ try {
71
+ const res = await fetch(`${API_BASE}/documents`, {
72
+ headers: { Authorization: `Bearer ${token}` }
73
+ });
74
+ if (res.ok) {
75
+ const data = await res.json();
76
+ setDocuments(data.documents);
77
+ }
78
+ } catch (err) {
79
+ console.error('Failed to fetch docs for dropdown', err);
80
+ }
81
+ };
82
+
83
+ const fetchStats = async (docId: string) => {
84
+ try {
85
+ const url = new URL(`${API_BASE}/ontology/stats`);
86
+ if (docId) url.searchParams.append('document_id', docId);
87
+ const res = await fetch(url.toString(), {
88
+ headers: { Authorization: `Bearer ${token}` }
89
+ });
90
+ if (res.ok) setStats(await res.json());
91
+ else setStats(null);
92
+ } catch { setStats(null); }
93
+ };
94
+
95
+ /* ── Fetch document-specific schema ─────────────────────────────── */
96
+ const fetchDocSchema = async (docId: string) => {
97
+ if (!docId) {
98
+ // Restore global schema
99
+ setEntityTypes(globalEntityTypes);
100
+ setRelationshipTypes(globalRelationshipTypes);
101
+ setProperties(globalProperties);
102
+ return;
103
+ }
104
+ setDocSchemaLoading(true);
105
+ try {
106
+ const url = new URL(`${API_BASE}/ontology/stats`);
107
+ url.searchParams.append('document_id', docId);
108
+ const res = await fetch(url.toString(), {
109
+ headers: { Authorization: `Bearer ${token}` }
110
+ });
111
+ if (res.ok) {
112
+ const data = await res.json();
113
+ // Populate editor with document-specific entity types and relationships
114
+ const docEntityTypes = (data.entity_stats || [])
115
+ .map((s: any) => s.type)
116
+ .filter(Boolean)
117
+ .join(', ');
118
+ const docRelTypes = (data.relationship_stats || [])
119
+ .map((s: any) => s.type)
120
+ .filter(Boolean)
121
+ .join(', ');
122
+
123
+ setEntityTypes(docEntityTypes || globalEntityTypes);
124
+ setRelationshipTypes(docRelTypes || globalRelationshipTypes);
125
+ // Properties: keep global properties since per-doc isn't tracked separately
126
+ setProperties(globalProperties);
127
+ }
128
+ } catch (err) {
129
+ console.error('Failed to fetch doc schema', err);
130
+ } finally {
131
+ setDocSchemaLoading(false);
132
+ }
133
+ };
134
+
135
+ useEffect(() => {
136
+ fetchOntology();
137
+ fetchDocuments();
138
+ fetchStats('');
139
+ }, [token]);
140
+
141
+ useEffect(() => {
142
+ fetchStats(selectedDocId);
143
+ fetchDocSchema(selectedDocId);
144
+ }, [selectedDocId]);
145
+
146
+ const handleSave = async (e: React.FormEvent) => {
147
+ e.preventDefault();
148
+ setSaving(true);
149
+ setMessage('');
150
+
151
+ let parsedProps = {};
152
+ try {
153
+ parsedProps = JSON.parse(properties);
154
+ } catch (e) {
155
+ setMessage('ERROR: PROPERTIES MUST BE VALID JSON');
156
+ setSaving(false);
157
+ return;
158
+ }
159
+
160
+ try {
161
+ const res = await fetch(`${API_BASE}/ontology`, {
162
+ method: 'PUT',
163
+ headers: {
164
+ 'Content-Type': 'application/json',
165
+ Authorization: `Bearer ${token}`
166
+ },
167
+ body: JSON.stringify({
168
+ entity_types: entityTypes.split(',').map(s => s.trim()).filter(Boolean),
169
+ relationship_types: relationshipTypes.split(',').map(s => s.trim()).filter(Boolean),
170
+ properties: parsedProps,
171
+ approved: true
172
+ })
173
+ });
174
+
175
+ if (res.status === 401) { logout(); return; }
176
+
177
+ if (res.ok) {
178
+ setMessage('ONTOLOGY SCHEMA UPDATED');
179
+ fetchOntology();
180
+ setSelectedDocId(''); // reset to global after save
181
+ } else {
182
+ setMessage('FAILED TO SAVE SCHEMA');
183
+ }
184
+ } catch (err) {
185
+ console.error(err);
186
+ setMessage('API ERROR DURING SAVE');
187
+ } finally {
188
+ setSaving(false);
189
+ }
190
+ };
191
+
192
+ const handleRefine = async () => {
193
+ setRefining(true);
194
+ setMessage('ANALYZING GRAPH FOR UPGRADES... (THIS MAY TAKE 30s+)');
195
+ try {
196
+ const res = await fetch(`${API_BASE}/ontology/refine`, {
197
+ method: 'POST',
198
+ headers: {
199
+ 'Content-Type': 'application/json',
200
+ Authorization: `Bearer ${token}`
201
+ },
202
+ body: JSON.stringify({
203
+ feedback: feedback || undefined,
204
+ document_id: selectedDocId || undefined
205
+ })
206
+ });
207
+
208
+ if (res.status === 401) { logout(); return; }
209
+
210
+ if (res.ok) {
211
+ const data = await res.json();
212
+ setMessage(`SUCCESS: ${data.changes}`);
213
+ fetchOntology();
214
+ } else {
215
+ setMessage('FAILED TO REFINE SCHEMA');
216
+ }
217
+ } catch (err) {
218
+ console.error(err);
219
+ setMessage('API ERROR DURING REFINE');
220
+ } finally {
221
+ setRefining(false);
222
+ }
223
+ };
224
+
225
+ const handleDeduplicate = async () => {
226
+ if (!window.confirm("Run semantic merging? This cannot be undone.")) return;
227
+ setDeduping(true);
228
+ setMessage('SCANNING GRAPH FOR DUPLICATE ENTITIES... (THIS MAY TAKE AWHILE)');
229
+ try {
230
+ const res = await fetch(`${API_BASE}/entities/deduplicate`, {
231
+ method: 'POST',
232
+ headers: { Authorization: `Bearer ${token}` }
233
+ });
234
+ if (res.status === 401) { logout(); return; }
235
+ if (res.ok) {
236
+ const data = await res.json();
237
+ setMessage(`DEDUPLICATION COMPLETE: Merged ${data.merged_count} entities.`);
238
+ } else {
239
+ setMessage('FAILED TO DEDUPLICATE ENTITIES');
240
+ }
241
+ } catch (err) {
242
+ console.error(err);
243
+ setMessage('API ERROR DURING DEDUPLICATION');
244
+ } finally {
245
+ setDeduping(false);
246
+ }
247
+ };
248
+
249
+ const handleEnrichEntities = async () => {
250
+ setEnriching(true);
251
+ setMessage('GENERATING ENTITY PROFILES FROM GRAPH NEIGHBORHOODS...');
252
+ try {
253
+ const res = await fetch(`${API_BASE}/entities/enrich`, {
254
+ method: 'POST',
255
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
256
+ body: JSON.stringify({ batch_size: 20, min_connections: 1 })
257
+ });
258
+ if (res.status === 401) { logout(); return; }
259
+ if (res.ok) {
260
+ const data = await res.json();
261
+ setMessage(`ENRICHMENT COMPLETE: ${data.message || `${data.enriched_count ?? '?'} entities profiled.`}`);
262
+ } else {
263
+ setMessage('FAILED TO ENRICH ENTITIES');
264
+ }
265
+ } catch {
266
+ setMessage('API ERROR DURING ENRICHMENT');
267
+ } finally {
268
+ setEnriching(false);
269
+ }
270
+ };
271
+
272
+ const handleDetectDrift = async () => {
273
+ setDetectingDrift(true);
274
+ setMessage('ANALYZING GRAPH DATA FOR SCHEMA DRIFT...');
275
+ try {
276
+ const res = await fetch(`${API_BASE}/ontology/drift/detect`, {
277
+ method: 'POST',
278
+ headers: { Authorization: `Bearer ${token}` }
279
+ });
280
+ if (res.status === 401) { logout(); return; }
281
+ if (res.ok) {
282
+ const data = await res.json();
283
+ setMessage(`DRIFT REPORT CREATED: ID ${data.report_id || data.id || '—'}`);
284
+ fetchDriftReports();
285
+ } else {
286
+ setMessage('FAILED TO DETECT DRIFT');
287
+ }
288
+ } catch {
289
+ setMessage('API ERROR DURING DRIFT DETECTION');
290
+ } finally {
291
+ setDetectingDrift(false);
292
+ }
293
+ };
294
+
295
+ const fetchDriftReports = async () => {
296
+ try {
297
+ const res = await fetch(`${API_BASE}/ontology/drift`, {
298
+ headers: { Authorization: `Bearer ${token}` }
299
+ });
300
+ if (res.ok) {
301
+ const data = await res.json();
302
+ setDriftReports(data.reports || []);
303
+ }
304
+ } catch {}
305
+ };
306
+
307
+ const handleDriftAction = async (id: string, action: 'approve' | 'reject') => {
308
+ const res = await fetch(`${API_BASE}/ontology/drift/${id}/${action}`, {
309
+ method: 'POST', headers: { Authorization: `Bearer ${token}` }
310
+ });
311
+ if (res.ok) {
312
+ setDriftReports(d => d.filter(r => r.id !== id));
313
+ setMessage(`Drift report ${action}d.`);
314
+ }
315
+ };
316
+
317
+ const selectedDoc = documents.find(d => d.id === selectedDocId);
318
+
319
+ return (
320
+ <div className="container" style={{ animation: 'fadeIn 0.5s ease' }}>
321
+ <div className="page-header flex-between">
322
+ <div>
323
+ <h1>ONTOLOGY MANAGEMENT</h1>
324
+ <p className="mono-text">SCHEMA CONTROL &amp; GRAPH REFINEMENT</p>
325
+ </div>
326
+ <Database size={32} />
327
+ </div>
328
+
329
+ {/* Help info bar */}
330
+ <div className="page-info-bar">
331
+ <Info size={14}/>
332
+ <span>
333
+ <strong>ENTITY TYPES</strong> define what kinds of nodes exist in your graph.
334
+ <strong> RELATIONSHIP TYPES</strong> define how they connect.
335
+ Use <strong>LLM REFINEMENT</strong> to auto-suggest schema improvements from your data.
336
+ Use <strong>DRIFT DETECTION</strong> to detect when new data doesn't fit the current schema.
337
+ Use <strong>ENTITY ENRICHMENT</strong> to synthesize rich profiles for all graph nodes.
338
+ </span>
339
+ </div>
340
+
341
+ <div className="ontology-layout">
342
+ {/* Schema Editor */}
343
+ <div className="card editor-card">
344
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
345
+ <h2 className="mono-text flex-center" style={{ gap: '0.5rem', margin: 0 }}>
346
+ <Settings size={20}/> EDIT SCHEMA {version ? `v${version}` : ''}
347
+ </h2>
348
+ {selectedDocId && (
349
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', background: '#000', color: '#fff', padding: '3px 10px', fontFamily: 'var(--font-mono)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.5px' }}>
350
+ <FileText size={11} />
351
+ DOC SCOPE: {selectedDoc?.filename?.slice(0, 22) ?? selectedDocId.slice(0, 12)}…
352
+ </div>
353
+ )}
354
+ </div>
355
+
356
+ {/* Document selector in editor header */}
357
+ <div style={{ marginBottom: '1.25rem', padding: '0.75rem', background: '#f5f5f5', border: '1.5px solid #e5e5e5' }}>
358
+ <label className="control-label" style={{ display: 'block', marginBottom: '0.4rem' }}>
359
+ POPULATE FROM DOCUMENT
360
+ </label>
361
+ <select
362
+ className="mono-text doc-dropdown"
363
+ value={selectedDocId}
364
+ onChange={(e) => setSelectedDocId(e.target.value)}
365
+ style={{ width: '100%' }}
366
+ >
367
+ <option value="">🌐 GLOBAL — Full Ontology Schema</option>
368
+ {documents.map(doc => (
369
+ <option key={doc.id} value={doc.id}>📄 {doc.filename}</option>
370
+ ))}
371
+ </select>
372
+ {docSchemaLoading && (
373
+ <div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted-color)', marginTop: '0.4rem' }}>
374
+ ↻ Loading document schema…
375
+ </div>
376
+ )}
377
+ {selectedDocId && !docSchemaLoading && (
378
+ <div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: '#16a34a', marginTop: '0.4rem', display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
379
+ <Check size={11}/> Schema populated from "{selectedDoc?.filename ?? selectedDocId}"
380
+ </div>
381
+ )}
382
+ </div>
383
+
384
+ {loading ? (
385
+ <div className="mono-text" style={{ padding: '2rem', textAlign: 'center' }}>LOADING SCHEMA...</div>
386
+ ) : (
387
+ <form onSubmit={handleSave} className="schema-form">
388
+ <div className="form-group">
389
+ <label className="mono-text">ENTITY TYPES (COMMA-SEPARATED)</label>
390
+ <textarea
391
+ value={entityTypes}
392
+ onChange={(e) => setEntityTypes(e.target.value)}
393
+ className="mono-text"
394
+ rows={3}
395
+ placeholder="Person, Organization, Location, Event…"
396
+ />
397
+ </div>
398
+
399
+ <div className="form-group">
400
+ <label className="mono-text">RELATIONSHIP TYPES (COMMA-SEPARATED)</label>
401
+ <textarea
402
+ value={relationshipTypes}
403
+ onChange={(e) => setRelationshipTypes(e.target.value)}
404
+ className="mono-text"
405
+ rows={3}
406
+ placeholder="WORKS_FOR, LOCATED_IN, RELATED_TO…"
407
+ />
408
+ </div>
409
+
410
+ <div className="form-group">
411
+ <label className="mono-text">PROPERTIES BINDING (JSON FORMAT)</label>
412
+ <textarea
413
+ value={properties}
414
+ onChange={(e) => setProperties(e.target.value)}
415
+ className="mono-text dict-editor"
416
+ rows={8}
417
+ />
418
+ </div>
419
+
420
+ <button
421
+ type="submit"
422
+ className="app-btn full-width"
423
+ disabled={saving}
424
+ style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
425
+ >
426
+ <Save size={18} /> {saving ? 'SAVING...' : 'COMMIT SCHEMA CHANGES'}
427
+ </button>
428
+ </form>
429
+ )}
430
+
431
+ </div>
432
+
433
+ {/* AI Tools */}
434
+ <div className="tools-card-container">
435
+ <div className="card tools-card">
436
+ <h2 className="mono-text flex-center" style={{ gap: '0.5rem' }}>
437
+ <Sparkles size={20}/> LLM REFINEMENT
438
+ </h2>
439
+ <p style={{ marginTop: '1rem', marginBottom: '0.75rem', fontSize: '0.88rem', lineHeight: 1.6 }}>
440
+ Use the LLM Agent to scan existing document chunks and automatically suggest expansions or restructuring to the current ontology schema.
441
+ </p>
442
+
443
+ <div className="refine-info-box">
444
+ <Info size={13} />
445
+ <span>
446
+ <strong>Global mode:</strong> samples random chunks from all documents.<br/>
447
+ <strong>Targeted mode:</strong> only scans chunks from the selected document — useful for domain-specific refinement.
448
+ </span>
449
+ </div>
450
+
451
+ <label className="control-label" style={{ display: 'block', marginBottom: '0.4rem', marginTop: '1rem' }}>REFINEMENT SCOPE</label>
452
+ <select
453
+ className="mono-text doc-dropdown"
454
+ value={selectedDocId}
455
+ onChange={(e) => setSelectedDocId(e.target.value)}
456
+ style={{ width: '100%', marginBottom: '1rem' }}
457
+ >
458
+ <option value="">🌐 GLOBAL — ALL DOCUMENTS (RANDOM CHUNKS)</option>
459
+ {documents.map(doc => (
460
+ <option key={doc.id} value={doc.id}>📄 TARGET: {doc.filename}</option>
461
+ ))}
462
+ </select>
463
+ <label className="control-label" style={{ display: 'block', marginBottom: '0.4rem' }}>OPTIONAL CRITERIA</label>
464
+ <textarea
465
+ placeholder="e.g., 'Focus heavily on extracting medical symptoms and treatment names'"
466
+ value={feedback}
467
+ onChange={(e) => setFeedback(e.target.value)}
468
+ className="mono-text"
469
+ rows={3}
470
+ style={{ width: '100%', marginBottom: '1rem' }}
471
+ />
472
+ <button
473
+ onClick={handleRefine}
474
+ className="app-btn outline-btn full-width"
475
+ disabled={refining || loading}
476
+ style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
477
+ >
478
+ <Sparkles size={18} /> {refining ? 'ANALYZING GRAPH...' : 'REFINE SCHEMA'}
479
+ </button>
480
+ </div>
481
+
482
+ <div className="card tools-card" style={{ marginTop: '2rem' }}>
483
+ <h2 className="mono-text flex-center" style={{ gap: '0.5rem' }}>
484
+ <GitMerge size={20}/> IDENTITY RESOLUTION
485
+ </h2>
486
+ <p style={{ marginTop: '1rem', marginBottom: '1rem', fontSize: '0.9rem', lineHeight: 1.5 }}>
487
+ Scan the entire Knowledge Graph and use Semantic Embedding comparisons to detect and permanently merge duplicated entities.
488
+ </p>
489
+ <button
490
+ onClick={handleDeduplicate}
491
+ className="app-btn outline-btn full-width"
492
+ disabled={deduping || loading}
493
+ style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
494
+ >
495
+ <GitMerge size={18} /> {deduping ? 'MERGING...' : 'DEDUPLICATE ENTITIES'}
496
+ </button>
497
+ </div>
498
+
499
+ {/* Entity Enrichment */}
500
+ <div className="card tools-card" style={{ marginTop: '2rem' }}>
501
+ <h2 className="mono-text flex-center" style={{ gap: '0.5rem' }}>
502
+ <Zap size={20}/> ENTITY ENRICHMENT
503
+ </h2>
504
+ <p style={{ marginTop: '1rem', marginBottom: '1rem', fontSize: '0.9rem', lineHeight: 1.5 }}>
505
+ Generate rich LLM-synthesized profiles for all eligible entities using their graph neighborhood context. Profiles power the Entity Chat feature.
506
+ </p>
507
+ <button
508
+ onClick={handleEnrichEntities}
509
+ className="app-btn outline-btn full-width"
510
+ disabled={enriching || loading}
511
+ style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
512
+ >
513
+ <Zap size={18} /> {enriching ? 'ENRICHING...' : 'ENRICH ALL ENTITIES'}
514
+ </button>
515
+ </div>
516
+
517
+ {/* Drift Detection */}
518
+ <div className="card tools-card" style={{ marginTop: '2rem' }}>
519
+ <h2 className="mono-text flex-center" style={{ gap: '0.5rem' }}>
520
+ <AlertTriangle size={20}/> ONTOLOGY DRIFT
521
+ </h2>
522
+ <p style={{ marginTop: '1rem', marginBottom: '1rem', fontSize: '0.9rem', lineHeight: 1.5 }}>
523
+ Detect when new incoming data no longer fits the current schema. The drift detector proposes additions for review.
524
+ </p>
525
+ <button
526
+ onClick={handleDetectDrift}
527
+ className="app-btn outline-btn full-width"
528
+ disabled={detectingDrift || loading}
529
+ style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginBottom: driftReports.length > 0 ? '1rem' : 0 }}
530
+ >
531
+ <AlertTriangle size={18} /> {detectingDrift ? 'DETECTING...' : 'DETECT DRIFT'}
532
+ </button>
533
+ {driftReports.length > 0 && (
534
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.5rem' }}>
535
+ <div className="control-label" style={{ marginBottom: '0.25rem' }}>PENDING DRIFT REPORTS</div>
536
+ {driftReports.slice(0, 3).map(r => (
537
+ <div key={r.id} style={{ border: '1px solid #e5e5e5', padding: '0.6rem 0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
538
+ <span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
539
+ {r.summary || r.id}
540
+ </span>
541
+ <div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
542
+ <button onClick={() => handleDriftAction(r.id, 'approve')}
543
+ style={{ background: '#f0fdf4', color: '#16a34a', border: '1px solid #16a34a', padding: '2px 8px', cursor: 'pointer', fontSize: '0.72rem' }}>
544
+ <Check size={10}/>
545
+ </button>
546
+ <button onClick={() => handleDriftAction(r.id, 'reject')}
547
+ style={{ background: '#fef2f2', color: '#dc2626', border: '1px solid #dc2626', padding: '2px 8px', cursor: 'pointer', fontSize: '0.72rem' }}>
548
+ <X size={10}/>
549
+ </button>
550
+ </div>
551
+ </div>
552
+ ))}
553
+ </div>
554
+ )}
555
+ </div>
556
+
557
+ {/* ── Live Stats Panel ── */}
558
+ {stats && (
559
+ <div className="card tools-card stats-panel" style={{ marginTop: '2rem' }}>
560
+ <h2 className="mono-text flex-center" style={{ gap: '0.5rem', marginBottom: '1rem' }}>
561
+ <Database size={20}/> GRAPH STATISTICS
562
+ {selectedDocId && <span className="scope-badge">DOC FILTERED</span>}
563
+ </h2>
564
+
565
+ <div className="stats-summary">
566
+ <div className="sum-chip">
567
+ <div className="sum-val">{stats.total_entities}</div>
568
+ <div className="sum-key">TOTAL ENTITIES</div>
569
+ </div>
570
+ <div className="sum-chip">
571
+ <div className="sum-val">{stats.total_relationships}</div>
572
+ <div className="sum-key">RELATIONSHIPS</div>
573
+ </div>
574
+ </div>
575
+
576
+ {stats.entity_stats?.length > 0 && (
577
+ <>
578
+ <div className="stat-section-lbl">ENTITY TYPES</div>
579
+ {stats.entity_stats.slice(0, 8).map((s: any) => (
580
+ <div key={s.type} className="stat-bar-row">
581
+ <span className="stat-bar-label">{s.type}</span>
582
+ <div className="stat-bar-track">
583
+ <div
584
+ className="stat-bar-fill"
585
+ style={{ width: `${Math.min(100, (s.count / stats.total_entities) * 100)}%` }}
586
+ />
587
+ </div>
588
+ <span className="stat-bar-count">{s.count}</span>
589
+ </div>
590
+ ))}
591
+ </>
592
+ )}
593
+ </div>
594
+ )}
595
+ </div>
596
+ </div>
597
+
598
+ {message && (
599
+ <div className={`status-toast ${message.includes('ERROR') || message.includes('FAILED') ? 'error' : ''}`}>
600
+ <Info size={20} /> {message}
601
+ <button
602
+ onClick={() => setMessage('')}
603
+ className="toast-dismiss-btn"
604
+ >
605
+ &times;
606
+ </button>
607
+ </div>
608
+ )}
609
+
610
+ <style>{`
611
+ .doc-dropdown {
612
+ background: var(--bg-color);
613
+ color: var(--text-color);
614
+ border: 2px solid var(--border-color);
615
+ padding: 0.5rem;
616
+ font-size: 0.9rem;
617
+ font-weight: bold;
618
+ outline: none;
619
+ max-width: 100%;
620
+ text-overflow: ellipsis;
621
+ }
622
+
623
+ .ontology-layout {
624
+ display: grid;
625
+ grid-template-columns: 3fr 2fr;
626
+ gap: 2rem;
627
+ margin-top: 1rem;
628
+ }
629
+
630
+ @media (max-width: 768px) {
631
+ .ontology-layout {
632
+ grid-template-columns: 1fr;
633
+ }
634
+ }
635
+
636
+ .form-group {
637
+ margin-bottom: 1.5rem;
638
+ }
639
+
640
+ .form-group label {
641
+ display: block;
642
+ margin-bottom: 0.5rem;
643
+ font-weight: bold;
644
+ font-size: 0.85rem;
645
+ color: #555;
646
+ }
647
+
648
+ .form-group textarea {
649
+ width: 100%;
650
+ padding: 1rem;
651
+ border: 2px solid var(--border-color);
652
+ background: var(--bg-color);
653
+ color: var(--text-color);
654
+ resize: vertical;
655
+ font-size: 0.9rem;
656
+ }
657
+
658
+ .form-group textarea:focus {
659
+ outline: none;
660
+ border-color: #555;
661
+ }
662
+
663
+ .dict-editor {
664
+ font-family: var(--font-mono);
665
+ white-space: pre;
666
+ background-color: #fafafa !important;
667
+ }
668
+
669
+ .status-toast {
670
+ position: fixed;
671
+ bottom: 2rem;
672
+ right: 2rem;
673
+ background: var(--text-color);
674
+ color: var(--bg-color);
675
+ padding: 1rem 1.5rem;
676
+ border-left: 6px solid var(--text-color);
677
+ box-shadow: 4px 4px 0 var(--border-color);
678
+ display: flex;
679
+ align-items: center;
680
+ gap: 1rem;
681
+ z-index: 9999;
682
+ font-family: var(--font-mono);
683
+ font-weight: bold;
684
+ font-size: 0.9rem;
685
+ animation: slideUp 0.3s ease-out;
686
+ max-width: 400px;
687
+ }
688
+
689
+ .status-toast.error {
690
+ border-left-color: #ff0000;
691
+ }
692
+
693
+ .refine-info-box {
694
+ background: #f5f5f5;
695
+ border-left: 3px solid #000;
696
+ padding: 0.5rem 0.75rem;
697
+ font-size: 0.78rem;
698
+ line-height: 1.6;
699
+ display: flex;
700
+ gap: 0.5rem;
701
+ align-items: flex-start;
702
+ }
703
+
704
+ .control-label {
705
+ font-family: var(--font-mono);
706
+ font-size: 0.7rem;
707
+ font-weight: 700;
708
+ color: #666;
709
+ letter-spacing: 1px;
710
+ text-transform: uppercase;
711
+ }
712
+
713
+ .outline-btn {
714
+ background: transparent !important;
715
+ color: var(--text-color) !important;
716
+ border: 2px solid var(--text-color) !important;
717
+ }
718
+ .outline-btn:hover:not(:disabled) {
719
+ background: var(--text-color) !important;
720
+ color: var(--bg-color) !important;
721
+ }
722
+
723
+ /* ── Stats panel ── */
724
+ .stats-summary {
725
+ display: grid;
726
+ grid-template-columns: 1fr 1fr;
727
+ gap: 0.75rem;
728
+ margin-bottom: 1rem;
729
+ }
730
+ .sum-chip {
731
+ border: 1.5px solid #e5e5e5;
732
+ padding: 0.6rem 0.75rem;
733
+ text-align: center;
734
+ }
735
+ .sum-val {
736
+ font-family: var(--font-mono);
737
+ font-size: 1.4rem;
738
+ font-weight: 700;
739
+ line-height: 1;
740
+ }
741
+ .sum-key {
742
+ font-family: var(--font-mono);
743
+ font-size: 0.62rem;
744
+ color: #888;
745
+ letter-spacing: 1px;
746
+ margin-top: 0.2rem;
747
+ }
748
+ .stat-section-lbl {
749
+ font-family: var(--font-mono);
750
+ font-size: 0.65rem;
751
+ font-weight: 700;
752
+ color: #888;
753
+ letter-spacing: 1px;
754
+ margin-bottom: 0.5rem;
755
+ }
756
+ .stat-bar-row {
757
+ display: flex;
758
+ align-items: center;
759
+ gap: 0.5rem;
760
+ margin-bottom: 0.35rem;
761
+ font-family: var(--font-mono);
762
+ font-size: 0.75rem;
763
+ }
764
+ .stat-bar-label {
765
+ width: 90px;
766
+ flex-shrink: 0;
767
+ white-space: nowrap;
768
+ overflow: hidden;
769
+ text-overflow: ellipsis;
770
+ color: #444;
771
+ }
772
+ .stat-bar-track {
773
+ flex: 1;
774
+ height: 6px;
775
+ background: #e5e5e5;
776
+ border-radius: 2px;
777
+ overflow: hidden;
778
+ }
779
+ .stat-bar-fill {
780
+ height: 100%;
781
+ background: #000;
782
+ transition: width 0.4s ease;
783
+ }
784
+ .stat-bar-count {
785
+ width: 28px;
786
+ text-align: right;
787
+ color: #666;
788
+ font-size: 0.72rem;
789
+ }
790
+ .scope-badge {
791
+ font-size: 0.6rem;
792
+ background: #000;
793
+ color: #fff;
794
+ padding: 2px 6px;
795
+ letter-spacing: 0.5px;
796
+ margin-left: 0.4rem;
797
+ }
798
+ `}</style>
799
+ </div>
800
+ );
801
+ };
802
+
803
+ export default Ontology;
frontend-react/src/views/Process.tsx ADDED
@@ -0,0 +1,763 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useAuth } from '../context/AuthContext';
3
+ import { DocumentInfo } from '../types/api';
4
+ import { Upload, FilePlus, Activity, Trash2, Eye, Globe, X, FileText, Link, Hash, BookOpen } from 'lucide-react';
5
+ import ReactMarkdown from 'react-markdown';
6
+ import remarkGfm from 'remark-gfm';
7
+
8
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
9
+
10
+ interface PreviewData {
11
+ filename: string;
12
+ file_type: string;
13
+ word_count: number;
14
+ char_count: number;
15
+ content: string;
16
+ }
17
+
18
+ const Process: React.FC = () => {
19
+ const { token, logout } = useAuth();
20
+ const [documents, setDocuments] = useState<DocumentInfo[]>([]);
21
+ const [loadingDocs, setLoadingDocs] = useState(true);
22
+ const [uploading, setUploading] = useState(false);
23
+ const [file, setFile] = useState<File | null>(null);
24
+ const [url, setUrl] = useState('');
25
+ const [message, setMessage] = useState('');
26
+
27
+ // Preview modal state
28
+ const [previewOpen, setPreviewOpen] = useState(false);
29
+ const [previewData, setPreviewData] = useState<PreviewData | null>(null);
30
+ const [previewLoading, setPreviewLoading] = useState(false);
31
+
32
+ interface TaskState {
33
+ status: string;
34
+ progress?: {
35
+ current_chunk?: number;
36
+ total_chunks?: number;
37
+ file?: string;
38
+ };
39
+ }
40
+
41
+ const [tasks, setTasks] = useState<Record<string, TaskState>>({});
42
+
43
+ const fetchDocuments = async () => {
44
+ setLoadingDocs(true);
45
+ try {
46
+ const res = await fetch(`${API_BASE}/documents`, {
47
+ headers: { Authorization: `Bearer ${token}` }
48
+ });
49
+ if (res.status === 401) { logout(); return; }
50
+ if (res.ok) {
51
+ const data = await res.json();
52
+ setDocuments(data.documents);
53
+ }
54
+ } catch (err) {
55
+ console.error('Failed to fetch docs', err);
56
+ } finally {
57
+ setLoadingDocs(false);
58
+ }
59
+ };
60
+
61
+ useEffect(() => {
62
+ fetchDocuments();
63
+ const interval = setInterval(fetchDocuments, 10000);
64
+ return () => clearInterval(interval);
65
+ }, [token]);
66
+
67
+ useEffect(() => {
68
+ const activeTasks = Object.keys(tasks).filter(
69
+ tid => tasks[tid].status !== 'completed' && tasks[tid].status !== 'failure'
70
+ );
71
+ if (activeTasks.length === 0) return;
72
+
73
+ const interval = setInterval(() => {
74
+ activeTasks.forEach(async (tid) => {
75
+ try {
76
+ const res = await fetch(`${API_BASE}/documents/status/${tid}`, {
77
+ headers: { Authorization: `Bearer ${token}` }
78
+ });
79
+ if (res.status === 401) { logout(); return; }
80
+ if (res.ok) {
81
+ const data = await res.json();
82
+ setTasks(prev => ({ ...prev, [tid]: { status: data.status, progress: data.progress } }));
83
+ if (data.status === 'completed' || data.status === 'failure') fetchDocuments();
84
+ }
85
+ } catch (e) {}
86
+ });
87
+ }, 2000);
88
+
89
+ return () => clearInterval(interval);
90
+ }, [tasks, token]);
91
+
92
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
93
+ if (e.target.files && e.target.files.length > 0) setFile(e.target.files[0]);
94
+ };
95
+
96
+ const handleUpload = async (e: React.FormEvent) => {
97
+ e.preventDefault();
98
+ if (!file) return;
99
+ setUploading(true);
100
+ setMessage('');
101
+ const formData = new FormData();
102
+ formData.append('file', file);
103
+ try {
104
+ const res = await fetch(`${API_BASE}/documents/upload`, {
105
+ method: 'POST',
106
+ headers: { Authorization: `Bearer ${token}` },
107
+ body: formData
108
+ });
109
+ if (res.status === 401) { logout(); return; }
110
+ const data = await res.json();
111
+ if (!res.ok) {
112
+ setMessage(`UPLOAD FAILED: ${data.detail}`);
113
+ } else {
114
+ setMessage('FILE UPLOADED. INGESTION TASK QUEUED.');
115
+ if (data.task_id) setTasks(prev => ({ ...prev, [data.task_id]: { status: 'pending' } }));
116
+ setFile(null);
117
+ fetchDocuments();
118
+ }
119
+ } catch (err: any) {
120
+ setMessage(`ERROR: ${err.message}`);
121
+ } finally {
122
+ setUploading(false);
123
+ }
124
+ };
125
+
126
+ const handleUrlScrape = async (e: React.FormEvent) => {
127
+ e.preventDefault();
128
+ if (!url.trim()) return;
129
+ setUploading(true);
130
+ setMessage('');
131
+ try {
132
+ const res = await fetch(`${API_BASE}/documents/scrape`, {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
135
+ body: JSON.stringify({ url: url.trim() })
136
+ });
137
+ if (res.status === 401) { logout(); return; }
138
+ const data = await res.json();
139
+ if (!res.ok) {
140
+ setMessage(`SCRAPE FAILED: ${data.detail}`);
141
+ } else {
142
+ setMessage('URL SCRAPED. INGESTION TASK QUEUED.');
143
+ if (data.task_id) setTasks(prev => ({ ...prev, [data.task_id]: { status: 'pending' } }));
144
+ setUrl('');
145
+ fetchDocuments();
146
+ }
147
+ } catch (err: any) {
148
+ setMessage(`ERROR: ${err.message}`);
149
+ } finally {
150
+ setUploading(false);
151
+ }
152
+ };
153
+
154
+ const handleDelete = async (docId: string) => {
155
+ if (!window.confirm('WARNING: Delete this document and all its graph data?')) return;
156
+ try {
157
+ const res = await fetch(`${API_BASE}/documents/${docId}`, {
158
+ method: 'DELETE',
159
+ headers: { Authorization: `Bearer ${token}` }
160
+ });
161
+ if (res.status === 401) { logout(); return; }
162
+ fetchDocuments();
163
+ } catch (err) {
164
+ console.error(err);
165
+ }
166
+ };
167
+
168
+ const handleView = async (doc: any) => {
169
+ const fileType = (doc.file_type || '').toLowerCase();
170
+
171
+ // Text/scraped files → use inline preview modal
172
+ if (fileType === '.txt' || fileType === '.md' || fileType === '') {
173
+ setPreviewOpen(true);
174
+ setPreviewLoading(true);
175
+ setPreviewData(null);
176
+ try {
177
+ const res = await fetch(`${API_BASE}/documents/${doc.id}/preview`, {
178
+ headers: { Authorization: `Bearer ${token}` }
179
+ });
180
+ if (res.status === 401) { logout(); return; }
181
+ if (res.ok) {
182
+ const data = await res.json();
183
+ setPreviewData(data);
184
+ } else {
185
+ const err = await res.json();
186
+ setMessage(`PREVIEW ERROR: ${err.detail}`);
187
+ setPreviewOpen(false);
188
+ }
189
+ } catch (err) {
190
+ setMessage('ERROR: Failed to load preview.');
191
+ setPreviewOpen(false);
192
+ } finally {
193
+ setPreviewLoading(false);
194
+ }
195
+ return;
196
+ }
197
+
198
+ // Binary files (PDF) → open in new tab as before
199
+ const newTab = window.open('', '_blank');
200
+ if (!newTab) { setMessage('ERROR: Please allow popups to view PDFs.'); return; }
201
+ newTab.document.write('<div style="font-family:monospace;padding:20px;">Fetching secure document...</div>');
202
+ try {
203
+ const res = await fetch(`${API_BASE}/documents/${doc.id}/download`, {
204
+ headers: { Authorization: `Bearer ${token}` }
205
+ });
206
+ if (res.status === 401) { newTab.close(); logout(); return; }
207
+ if (res.ok) {
208
+ const contentType = res.headers.get('content-type') || 'application/pdf';
209
+ const blob = await res.blob();
210
+ const fileBlob = new Blob([blob], { type: contentType });
211
+ const blobUrl = window.URL.createObjectURL(fileBlob);
212
+ newTab.location.href = blobUrl;
213
+ } else {
214
+ newTab.close();
215
+ setMessage('ERROR: Document file not found on server.');
216
+ }
217
+ } catch (err) {
218
+ newTab.close();
219
+ setMessage('ERROR: Failed to open document.');
220
+ }
221
+ };
222
+
223
+ const isTxtDoc = (doc: any) => {
224
+ const ft = (doc.file_type || '').toLowerCase();
225
+ return ft === '.txt' || ft === '.md' || ft === '';
226
+ };
227
+
228
+ return (
229
+ <div className="container" style={{ animation: 'fadeIn 0.5s ease' }}>
230
+ <div className="page-header flex-between">
231
+ <div>
232
+ <h1>INGESTION PIPELINE</h1>
233
+ <p className="mono-text">DATA PROCESSING &amp; GRAPH CONSTRUCTION</p>
234
+ </div>
235
+ <Activity size={32} />
236
+ </div>
237
+
238
+ <div className="process-layout">
239
+ {/* Upload / Scrape Section */}
240
+ <div className="card upload-card">
241
+ <h2 className="mono-text flex-center"><FilePlus style={{ marginRight: '0.5rem' }}/> ADD DOCUMENT</h2>
242
+
243
+ <form onSubmit={handleUpload} className="upload-form">
244
+ <div className="file-input-wrapper">
245
+ <input type="file" id="file-upload" onChange={handleFileChange} accept=".pdf,.txt,.md" />
246
+ <label htmlFor="file-upload" className="file-label">
247
+ {file ? file.name : 'SELECT FILE FOR INGESTION'}
248
+ </label>
249
+ </div>
250
+ <button
251
+ type="submit"
252
+ className="app-btn full-width"
253
+ disabled={!file || uploading}
254
+ style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '8px' }}
255
+ >
256
+ {uploading && file ? 'UPLOADING...' : <><Upload size={18} /> INITIALIZE FILE INGESTION</>}
257
+ </button>
258
+ </form>
259
+
260
+ <hr style={{ margin: '2rem 0', borderColor: 'var(--border-color)', borderStyle: 'dotted' }} />
261
+
262
+ <h2 className="mono-text flex-center"><Globe style={{ marginRight: '0.5rem' }}/> SCRAPE URL</h2>
263
+ <p style={{ fontSize: '0.85rem', color: '#666', marginBottom: '1rem', lineHeight: 1.5 }}>
264
+ Paste any public URL — articles, docs, Wikipedia pages — to scrape and ingest the content into your knowledge graph.
265
+ </p>
266
+ <form onSubmit={handleUrlScrape} className="upload-form">
267
+ <input
268
+ type="url"
269
+ className="query-input mono-text"
270
+ placeholder="https://example.com/article"
271
+ value={url}
272
+ onChange={(e) => setUrl(e.target.value)}
273
+ required
274
+ style={{ width: '100%', marginBottom: '1rem' }}
275
+ />
276
+ <button
277
+ type="submit"
278
+ className="app-btn full-width"
279
+ disabled={!url || uploading}
280
+ style={{ display: 'flex', justifyContent: 'center', gap: '8px' }}
281
+ >
282
+ {uploading && url ? 'SCRAPING...' : <><Activity size={18} /> INGEST FROM URL</>}
283
+ </button>
284
+ </form>
285
+
286
+ {Object.keys(tasks).length > 0 && (
287
+ <div className="task-tracker" style={{ marginTop: '1rem' }}>
288
+ <h4 className="mono-text">ACTIVE TASKS</h4>
289
+ {Object.entries(tasks).map(([tid, taskObj]) => {
290
+ const pct = (taskObj.progress?.current_chunk && taskObj.progress?.total_chunks)
291
+ ? Math.round((taskObj.progress.current_chunk / taskObj.progress.total_chunks) * 100)
292
+ : 0;
293
+ return (
294
+ <div key={tid} className="task-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '8px' }}>
295
+ <div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
296
+ <span title={tid}>{tid.substring(0,8)}...</span>
297
+ <span className={`status-badge ${taskObj.status}`}>
298
+ {taskObj.status.toUpperCase()}{taskObj.status === 'processing' && pct > 0 ? ` ${pct}%` : ''}
299
+ </span>
300
+ </div>
301
+ {taskObj.status === 'processing' && taskObj.progress?.total_chunks && (
302
+ <div style={{ width: '100%', height: '4px', background: '#e0e0e0', borderRadius: '2px', overflow: 'hidden' }}>
303
+ <div style={{ width: `${pct}%`, height: '100%', background: '#000', transition: 'width 0.3s ease' }}></div>
304
+ </div>
305
+ )}
306
+ </div>
307
+ );
308
+ })}
309
+ </div>
310
+ )}
311
+ </div>
312
+
313
+ {/* Corpus Table */}
314
+ <div className="card documents-card">
315
+ <h2 className="mono-text">CORPUS INDEX</h2>
316
+ <div className="table-wrapper">
317
+ <table className="docs-table">
318
+ <thead>
319
+ <tr>
320
+ <th>DOCUMENT NAME</th>
321
+ <th>TYPE</th>
322
+ <th>SIZE</th>
323
+ <th>INGESTED</th>
324
+ <th>ACTIONS</th>
325
+ </tr>
326
+ </thead>
327
+ <tbody>
328
+ {documents.length === 0 && !loadingDocs ? (
329
+ <tr><td colSpan={5} style={{ textAlign: 'center', padding: '3rem' }} className="mono-text">
330
+ <p style={{ marginBottom: '1rem' }}>NO DOCUMENTS INDEXED IN GRAPH DATABASE</p>
331
+ <p style={{ color: '#666', fontSize: '0.85rem' }}>Upload a file or scrape a URL above to begin.</p>
332
+ </td></tr>
333
+ ) : loadingDocs ? (
334
+ <tr><td colSpan={5} style={{ textAlign: 'center', padding: '3rem' }} className="mono-text">
335
+ FETCHING CORPUS DATA...
336
+ </td></tr>
337
+ ) : (
338
+ documents.map((doc: any) => (
339
+ <tr key={doc.id}>
340
+ <td className="mono-text" style={{ maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
341
+ <span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
342
+ {isTxtDoc(doc) ? <Link size={14} /> : <FileText size={14} />}
343
+ {doc.filename}
344
+ </span>
345
+ </td>
346
+ <td>
347
+ <span className={`type-badge ${isTxtDoc(doc) ? 'scraped' : 'pdf'}`}>
348
+ {isTxtDoc(doc) ? 'WEB / TXT' : (doc.file_type || 'PDF').toUpperCase()}
349
+ </span>
350
+ </td>
351
+ <td className="mono-text">{(doc.size_bytes / 1024).toFixed(1)} KB</td>
352
+ <td className="mono-text" style={{ fontSize: '0.8rem' }}>{doc.upload_date?.substring(0,19) || '—'}</td>
353
+ <td style={{ whiteSpace: 'nowrap' }}>
354
+ <button
355
+ className="icon-btn"
356
+ onClick={() => handleView(doc)}
357
+ title={isTxtDoc(doc) ? 'Preview Content' : 'Open PDF'}
358
+ style={{ marginRight: '10px' }}
359
+ >
360
+ <Eye size={16} />
361
+ </button>
362
+ <button className="icon-btn" onClick={() => handleDelete(doc.id)} title="Delete Document">
363
+ <Trash2 size={16} />
364
+ </button>
365
+ </td>
366
+ </tr>
367
+ ))
368
+ )}
369
+ </tbody>
370
+ </table>
371
+ </div>
372
+ </div>
373
+ </div>
374
+
375
+ {/* Document Preview Modal */}
376
+ {previewOpen && (
377
+ <div className="preview-overlay" onClick={() => setPreviewOpen(false)}>
378
+ <div className="preview-modal" onClick={(e) => e.stopPropagation()}>
379
+ {/* Modal Header */}
380
+ <div className="preview-header">
381
+ <div className="preview-header-left">
382
+ <BookOpen size={20} />
383
+ <div>
384
+ <div className="preview-title">{previewData?.filename || 'Loading...'}</div>
385
+ {previewData && (
386
+ <div className="preview-meta-row">
387
+ <span className="preview-meta-chip"><Hash size={12} /> {previewData.word_count.toLocaleString()} words</span>
388
+ <span className="preview-meta-chip"><FileText size={12} /> {(previewData.char_count / 1000).toFixed(1)}K chars</span>
389
+ <span className="type-badge scraped">WEB SCRAPE</span>
390
+ </div>
391
+ )}
392
+ </div>
393
+ </div>
394
+ <button className="icon-btn" onClick={() => setPreviewOpen(false)} style={{ fontSize: '1.5rem' }}>
395
+ <X size={22} />
396
+ </button>
397
+ </div>
398
+
399
+ {/* Modal Body */}
400
+ <div className="preview-body">
401
+ {previewLoading ? (
402
+ <div className="loading-spinner"><Activity size={32} style={{ animation: 'pulse 1.5s infinite' }} /> Fetching Content...</div>
403
+ ) : previewData ? (
404
+ <div className="markdown-content formatted">
405
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{previewData.content}</ReactMarkdown>
406
+ </div>
407
+ ) : (
408
+ <div className="error-text">Failed to load content.</div>
409
+ )}
410
+ </div>
411
+ </div>
412
+ </div>
413
+ )}
414
+
415
+ {message && (
416
+ <div className={`status-toast ${message.includes('ERROR') || message.includes('FAILED') ? 'error' : ''}`}>
417
+ <Activity size={20} /> {message}
418
+ <button
419
+ onClick={() => setMessage('')}
420
+ className="toast-dismiss-btn"
421
+ >
422
+ &times;
423
+ </button>
424
+ </div>
425
+ )}
426
+
427
+ <style>{`
428
+ .process-layout {
429
+ display: grid;
430
+ grid-template-columns: 1fr 2fr;
431
+ gap: 2rem;
432
+ }
433
+
434
+ @media (max-width: 900px) {
435
+ .process-layout { grid-template-columns: 1fr; }
436
+ }
437
+
438
+ .file-input-wrapper {
439
+ position: relative;
440
+ overflow: hidden;
441
+ display: inline-block;
442
+ width: 100%;
443
+ border: 2px dashed var(--border-color);
444
+ text-align: center;
445
+ background: var(--hover-bg);
446
+ transition: var(--transition-speed);
447
+ }
448
+
449
+ .file-input-wrapper:hover { background: #e0e0e0; }
450
+
451
+ .file-input-wrapper input[type=file] {
452
+ font-size: 100px;
453
+ position: absolute;
454
+ left: 0; top: 0;
455
+ opacity: 0;
456
+ cursor: pointer;
457
+ }
458
+
459
+ .file-label {
460
+ display: block;
461
+ padding: 2rem;
462
+ font-family: var(--font-mono);
463
+ font-weight: 600;
464
+ cursor: pointer;
465
+ }
466
+
467
+ .task-tracker {
468
+ margin-top: 2rem;
469
+ border-top: 2px solid var(--border-color);
470
+ padding-top: 1rem;
471
+ }
472
+
473
+ .task-row {
474
+ display: flex;
475
+ justify-content: space-between;
476
+ padding: 0.5rem 0;
477
+ font-family: var(--font-mono);
478
+ font-size: 0.85rem;
479
+ border-bottom: 1px dotted var(--border-color);
480
+ }
481
+
482
+ .status-badge {
483
+ padding: 0.1rem 0.5rem;
484
+ background: var(--text-color);
485
+ color: var(--bg-color);
486
+ font-weight: 700;
487
+ font-size: 0.75rem;
488
+ font-family: var(--font-mono);
489
+ }
490
+ .status-badge.processing { background: #666; }
491
+ .status-badge.completed { background: #000; }
492
+ .status-badge.failure { background: #990000; color: white; }
493
+
494
+ .type-badge {
495
+ display: inline-block;
496
+ padding: 0.15rem 0.5rem;
497
+ font-size: 0.7rem;
498
+ font-family: var(--font-mono);
499
+ font-weight: 700;
500
+ letter-spacing: 0.5px;
501
+ border: 1.5px solid var(--border-color);
502
+ }
503
+ .type-badge.scraped { background: #000; color: #fff; border-color: #000; }
504
+ .type-badge.pdf { background: transparent; color: #444; }
505
+
506
+ .table-wrapper {
507
+ overflow-x: auto;
508
+ margin-top: 1rem;
509
+ width: 100%;
510
+ -webkit-overflow-scrolling: touch;
511
+ }
512
+
513
+ .docs-table {
514
+ width: 100%;
515
+ border-collapse: collapse;
516
+ border: 2px solid var(--border-color);
517
+ }
518
+
519
+ .docs-table th, .docs-table td {
520
+ border: 1px solid var(--border-color);
521
+ padding: 0.75rem;
522
+ text-align: left;
523
+ font-size: 0.9rem;
524
+ }
525
+
526
+ .docs-table th {
527
+ background-color: var(--text-color);
528
+ color: var(--bg-color);
529
+ font-family: var(--font-display);
530
+ letter-spacing: 1px;
531
+ white-space: nowrap;
532
+ }
533
+
534
+ .docs-table tr:hover { background-color: var(--hover-bg); }
535
+
536
+ .icon-btn {
537
+ background: none;
538
+ border: none;
539
+ cursor: pointer;
540
+ color: var(--text-color);
541
+ padding: 4px;
542
+ }
543
+ .icon-btn:hover { color: #ff0000; }
544
+
545
+ /* Preview Modal */
546
+ .preview-overlay {
547
+ position: fixed;
548
+ inset: 0;
549
+ background: rgba(0, 0, 0, 0.65);
550
+ z-index: 5000;
551
+ display: flex;
552
+ align-items: center;
553
+ justify-content: center;
554
+ padding: 2rem;
555
+ animation: fadeIn 0.2s ease;
556
+ }
557
+
558
+ .preview-modal {
559
+ background: var(--bg-color);
560
+ border: 3px solid var(--border-color);
561
+ box-shadow: 8px 8px 0 var(--border-color);
562
+ width: 100%;
563
+ max-width: 860px;
564
+ max-height: 85vh;
565
+ display: flex;
566
+ flex-direction: column;
567
+ animation: scaleIn 0.2s ease;
568
+ }
569
+
570
+ @keyframes scaleIn {
571
+ from { transform: scale(0.95); opacity: 0; }
572
+ to { transform: scale(1); opacity: 1; }
573
+ }
574
+
575
+ .preview-header {
576
+ display: flex;
577
+ align-items: flex-start;
578
+ justify-content: space-between;
579
+ padding: 1.25rem 1.5rem;
580
+ border-bottom: 2px solid var(--border-color);
581
+ background: var(--text-color);
582
+ color: var(--bg-color);
583
+ gap: 1rem;
584
+ }
585
+
586
+ .preview-header-left {
587
+ display: flex;
588
+ align-items: flex-start;
589
+ gap: 1rem;
590
+ flex: 1;
591
+ min-width: 0;
592
+ }
593
+
594
+ .preview-title {
595
+ font-family: var(--font-mono);
596
+ font-weight: 700;
597
+ font-size: 0.95rem;
598
+ word-break: break-all;
599
+ margin-bottom: 0.4rem;
600
+ }
601
+
602
+ .preview-meta-row {
603
+ display: flex;
604
+ flex-wrap: wrap;
605
+ gap: 0.5rem;
606
+ align-items: center;
607
+ }
608
+
609
+ .preview-meta-chip {
610
+ display: inline-flex;
611
+ align-items: center;
612
+ gap: 4px;
613
+ background: rgba(255,255,255,0.15);
614
+ padding: 0.15rem 0.5rem;
615
+ font-size: 0.75rem;
616
+ font-family: var(--font-mono);
617
+ border-radius: 2px;
618
+ }
619
+
620
+ .preview-header .icon-btn { color: var(--bg-color); }
621
+ .preview-header .icon-btn:hover { color: #ffaaaa; }
622
+
623
+ .preview-body {
624
+ flex: 1;
625
+ overflow-y: auto;
626
+ padding: 0;
627
+ }
628
+
629
+ .markdown-content.formatted {
630
+ padding: 2rem;
631
+ font-family: var(--font-sans);
632
+ line-height: 1.6;
633
+ color: #333;
634
+ font-size: 1rem;
635
+ }
636
+
637
+ .markdown-content.formatted h1,
638
+ .markdown-content.formatted h2,
639
+ .markdown-content.formatted h3 {
640
+ font-family: var(--font-mono);
641
+ color: #000;
642
+ margin-top: 1.5em;
643
+ margin-bottom: 0.5em;
644
+ border-bottom: 2px solid #eaeaea;
645
+ padding-bottom: 0.3em;
646
+ }
647
+
648
+ .markdown-content.formatted p {
649
+ margin-bottom: 1em;
650
+ }
651
+
652
+ .markdown-content.formatted ul,
653
+ .markdown-content.formatted ol {
654
+ margin-bottom: 1em;
655
+ padding-left: 2em;
656
+ }
657
+
658
+ .markdown-content.formatted a {
659
+ color: #0056b3;
660
+ text-decoration: underline;
661
+ }
662
+
663
+ .markdown-content.formatted code {
664
+ font-family: var(--font-mono);
665
+ background: #f4f4f4;
666
+ padding: 0.1em 0.3em;
667
+ border-radius: 3px;
668
+ font-size: 0.9em;
669
+ }
670
+
671
+ .markdown-content.formatted pre {
672
+ background: #111;
673
+ color: #fff;
674
+ padding: 1em;
675
+ overflow-x: auto;
676
+ border-radius: 4px;
677
+ }
678
+
679
+ .markdown-content.formatted pre code {
680
+ background: transparent;
681
+ color: inherit;
682
+ padding: 0;
683
+ }
684
+
685
+ .markdown-content.formatted blockquote {
686
+ border-left: 4px solid #ccc;
687
+ margin: 0;
688
+ padding-left: 1em;
689
+ color: #666;
690
+ font-style: italic;
691
+ }
692
+
693
+ .preview-loading {
694
+ display: flex;
695
+ flex-direction: column;
696
+ align-items: center;
697
+ justify-content: center;
698
+ gap: 1rem;
699
+ height: 300px;
700
+ font-family: var(--font-mono);
701
+ font-weight: bold;
702
+ letter-spacing: 2px;
703
+ color: #666;
704
+ }
705
+
706
+ .preview-content {
707
+ font-family: var(--font-mono);
708
+ font-size: 0.82rem;
709
+ line-height: 1.7;
710
+ white-space: pre-wrap;
711
+ word-break: break-word;
712
+ padding: 2rem;
713
+ margin: 0;
714
+ color: #111;
715
+ border: none;
716
+ background: #fafafa;
717
+ }
718
+
719
+ .spin-slow {
720
+ animation: spin 1.5s linear infinite;
721
+ }
722
+
723
+ @keyframes spin {
724
+ 100% { transform: rotate(360deg); }
725
+ }
726
+
727
+ .status-toast {
728
+ position: fixed;
729
+ bottom: 2rem;
730
+ right: 2rem;
731
+ background: var(--text-color);
732
+ color: var(--bg-color);
733
+ padding: 1rem 1.5rem;
734
+ border-left: 6px solid var(--text-color);
735
+ box-shadow: 4px 4px 0 var(--border-color);
736
+ display: flex;
737
+ align-items: center;
738
+ gap: 1rem;
739
+ z-index: 9999;
740
+ font-family: var(--font-mono);
741
+ font-weight: bold;
742
+ font-size: 0.9rem;
743
+ animation: slideUp 0.3s ease-out;
744
+ max-width: 420px;
745
+ }
746
+
747
+ .status-toast.error { border-left-color: #ff0000; }
748
+
749
+ @keyframes slideUp {
750
+ from { transform: translateY(100px); opacity: 0; }
751
+ to { transform: translateY(0); opacity: 1; }
752
+ }
753
+
754
+ @keyframes fadeIn {
755
+ from { opacity: 0; }
756
+ to { opacity: 1; }
757
+ }
758
+ `}</style>
759
+ </div>
760
+ );
761
+ };
762
+
763
+ export default Process;
frontend-react/src/views/SimulationRunView.tsx ADDED
@@ -0,0 +1,690 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useCallback, useRef } from 'react';
2
+ import { useAuth } from '../context/AuthContext';
3
+ import { GraphNode, GraphEdge, DocumentInfo } from '../types/api';
4
+ import GraphCanvas, { DEFAULT_OPTIONS } from '../components/GraphCanvas';
5
+ import type { GraphOptions, GraphCanvasHandle } from '../components/GraphCanvas';
6
+ import {
7
+ RefreshCw, Play, Database, Info, Maximize2, Minimize2,
8
+ Download, SlidersHorizontal, X, Layers, Tag, Search,
9
+ Image, GitBranch, HelpCircle
10
+ } from 'lucide-react';
11
+
12
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api';
13
+
14
+ const TYPE_COLORS = [
15
+ '#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261',
16
+ '#6a4c93','#1982c4','#8ac926','#ff595e','#6a994e',
17
+ '#bc4749','#a8dadc'
18
+ ];
19
+
20
+ const SimulationRunView: React.FC = () => {
21
+ const { token, logout } = useAuth();
22
+ const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [] });
23
+ const [loading, setLoading] = useState(false);
24
+ const [limit, setLimit] = useState(100);
25
+ const [documents, setDocuments] = useState<DocumentInfo[]>([]);
26
+ const [selectedDocId, setSelectedDocId] = useState('');
27
+ const [nodeCount, setNodeCount] = useState(0);
28
+ const [edgeCount, setEdgeCount] = useState(0);
29
+
30
+ // UI state
31
+ const [isFullscreen, setIsFullscreen] = useState(false);
32
+ const [showPanel, setShowPanel] = useState(false);
33
+ const [showHelp, setShowHelp] = useState(false);
34
+ const [showSearch, setShowSearch] = useState(false);
35
+ const [searchQuery, setSearchQuery] = useState('');
36
+ const [highlightNodeIds, setHighlightNodeIds] = useState<Set<string>>(new Set());
37
+
38
+ // Graph options
39
+ const [options, setOptions] = useState<GraphOptions>({ ...DEFAULT_OPTIONS });
40
+
41
+ // Canvas handle
42
+ const canvasRef = useRef<GraphCanvasHandle>(null);
43
+ const containerRef = useRef<HTMLDivElement>(null);
44
+
45
+ // Derived legend
46
+ const typeLegend = React.useMemo(() => {
47
+ const types = [...new Set(graphData.nodes.map(n => n.type || 'Unknown'))];
48
+ return types.map((t, i) => ({ type: t, color: TYPE_COLORS[i % TYPE_COLORS.length] }));
49
+ }, [graphData.nodes]);
50
+
51
+ // Graph stats
52
+ const degreeStats = React.useMemo(() => {
53
+ if (!graphData.nodes.length) return null;
54
+ const degMap = new Map<string, number>();
55
+ graphData.nodes.forEach(n => degMap.set(n.id, 0));
56
+ graphData.edges.forEach(e => {
57
+ degMap.set(e.source, (degMap.get(e.source) || 0) + 1);
58
+ degMap.set(e.target, (degMap.get(e.target) || 0) + 1);
59
+ });
60
+ const degrees = [...degMap.values()];
61
+ const avg = degrees.reduce((a, b) => a + b, 0) / degrees.length;
62
+ const max = Math.max(...degrees);
63
+ const hubs = graphData.nodes.filter(n => (degMap.get(n.id) || 0) >= avg * 2);
64
+ return { avg: avg.toFixed(1), max, hubs: hubs.slice(0, 5) };
65
+ }, [graphData]);
66
+
67
+ // ── Data fetching ──────────────────────────────────────────────────────
68
+ const fetchDocuments = useCallback(async () => {
69
+ try {
70
+ const res = await fetch(`${API_BASE}/documents`, { headers: { Authorization: `Bearer ${token}` } });
71
+ if (res.ok) setDocuments((await res.json()).documents);
72
+ } catch {}
73
+ }, [token]);
74
+
75
+ const fetchGraph = useCallback(async (docId: string, nodeLimit: number) => {
76
+ setLoading(true);
77
+ try {
78
+ const url = new URL(`${API_BASE}/graph/visualization`);
79
+ url.searchParams.append('limit', nodeLimit.toString());
80
+ if (docId) url.searchParams.append('document_id', docId);
81
+ const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}` } });
82
+ if (res.status === 401) { logout(); return; }
83
+ if (res.ok) {
84
+ const data = await res.json();
85
+ setGraphData(data);
86
+ setNodeCount(data.nodes?.length ?? 0);
87
+ setEdgeCount(data.edges?.length ?? 0);
88
+ setHighlightNodeIds(new Set());
89
+ setSearchQuery('');
90
+ }
91
+ } catch (err) {
92
+ console.error('Graph fetch error:', err);
93
+ } finally {
94
+ setLoading(false);
95
+ }
96
+ }, [token, logout]);
97
+
98
+ const handleNodeUpdate = useCallback(async (nodeId: string, newName: string) => {
99
+ try {
100
+ const res = await fetch(`${API_BASE}/entities/${nodeId}`, {
101
+ method: 'PUT',
102
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
103
+ body: JSON.stringify({ name: newName })
104
+ });
105
+ if (res.status === 401) { logout(); return; }
106
+ if (res.ok) {
107
+ setGraphData(prev => ({
108
+ ...prev,
109
+ nodes: prev.nodes.map(n => n.id === nodeId ? { ...n, label: newName } : n)
110
+ }));
111
+ }
112
+ } catch {}
113
+ }, [token, logout]);
114
+
115
+ useEffect(() => { fetchDocuments(); }, [fetchDocuments]);
116
+ useEffect(() => { fetchGraph(selectedDocId, limit); }, [fetchGraph, selectedDocId, limit]);
117
+
118
+ // Search handler
119
+ const handleSearch = useCallback((q: string) => {
120
+ setSearchQuery(q);
121
+ if (!q.trim()) {
122
+ setHighlightNodeIds(new Set());
123
+ return;
124
+ }
125
+ const lower = q.toLowerCase();
126
+ const matched = new Set(
127
+ graphData.nodes
128
+ .filter(n =>
129
+ n.label?.toLowerCase().includes(lower) ||
130
+ n.type?.toLowerCase().includes(lower)
131
+ )
132
+ .map(n => n.id)
133
+ );
134
+ setHighlightNodeIds(matched);
135
+ // Zoom to first match
136
+ if (matched.size > 0) {
137
+ const firstId = [...matched][0];
138
+ canvasRef.current?.highlightNode(firstId);
139
+ }
140
+ }, [graphData.nodes]);
141
+
142
+ // ── Fullscreen ─────────────────────────────────────────────────────────
143
+ const toggleFullscreen = useCallback(() => {
144
+ if (!isFullscreen) {
145
+ containerRef.current?.requestFullscreen?.().catch(() => setIsFullscreen(true));
146
+ } else {
147
+ document.exitFullscreen?.().catch(() => setIsFullscreen(false));
148
+ }
149
+ setIsFullscreen(f => !f);
150
+ }, [isFullscreen]);
151
+
152
+ useEffect(() => {
153
+ const handler = () => setIsFullscreen(!!document.fullscreenElement);
154
+ document.addEventListener('fullscreenchange', handler);
155
+ return () => document.removeEventListener('fullscreenchange', handler);
156
+ }, []);
157
+
158
+ // ── Option helpers ─────────────────────────────────────────────────────
159
+ const setOpt = <K extends keyof GraphOptions>(key: K, val: GraphOptions[K]) =>
160
+ setOptions(prev => ({ ...prev, [key]: val }));
161
+
162
+ const selectedDocName = documents.find(d => d.id === selectedDocId)?.filename || '';
163
+
164
+ return (
165
+ <div ref={containerRef} className={`simulation-root${isFullscreen ? ' fullscreen' : ''}`}>
166
+
167
+ {/* ── Top bar ──────────────────────────────────────────────────────── */}
168
+ <div className="sim-topbar">
169
+ <div className="sim-title-group">
170
+ <h2>GRAPH VISUALIZATION</h2>
171
+ <div className="sim-chips">
172
+ <span className="s-chip"><Database size={11}/> {nodeCount} nodes</span>
173
+ <span className="s-chip">{edgeCount} edges</span>
174
+ {degreeStats && <span className="s-chip" title="Average connections per node">avg° {degreeStats.avg}</span>}
175
+ {selectedDocId && (
176
+ <span className="s-chip active" title={selectedDocName}>
177
+ {selectedDocName.length > 22 ? selectedDocName.substring(0,20)+'…' : selectedDocName}
178
+ </span>
179
+ )}
180
+ </div>
181
+ </div>
182
+
183
+ <div className="sim-controls">
184
+ {/* Doc filter */}
185
+ <div className="ctrl-grp">
186
+ <label className="ctrl-lbl">DOCUMENT</label>
187
+ <select className="sim-sel" value={selectedDocId} onChange={e => setSelectedDocId(e.target.value)}>
188
+ <option value="">🌐 ALL</option>
189
+ {documents.map(doc => (
190
+ <option key={doc.id} value={doc.id}>📄 {doc.filename.length > 28 ? doc.filename.substring(0,26)+'…' : doc.filename}</option>
191
+ ))}
192
+ </select>
193
+ </div>
194
+
195
+ {/* Limit */}
196
+ <div className="ctrl-grp">
197
+ <label className="ctrl-lbl">NODES</label>
198
+ <select className="sim-sel" value={limit} onChange={e => setLimit(Number(e.target.value))}>
199
+ {[50, 100, 200, 500, 1000].map(v => <option key={v} value={v}>{v}</option>)}
200
+ </select>
201
+ </div>
202
+
203
+ {/* Action buttons */}
204
+ <div className="sim-actions">
205
+ <button className="sim-icon-btn" title="Refresh graph" onClick={() => fetchGraph(selectedDocId, limit)} disabled={loading}>
206
+ {loading ? <RefreshCw className="spin" size={15}/> : <Play size={15}/>}<span>REFRESH</span>
207
+ </button>
208
+
209
+ <button className="sim-icon-btn" title="Fit to view" onClick={() => canvasRef.current?.fitView()}>
210
+ <Layers size={15}/><span>FIT</span>
211
+ </button>
212
+
213
+ {/* Edge labels quick toggle */}
214
+ <button
215
+ className={`sim-icon-btn${options.showEdgeLabels ? ' active' : ''}`}
216
+ title={options.showEdgeLabels ? 'Hide edge labels' : 'Show edge labels'}
217
+ onClick={() => setOpt('showEdgeLabels', !options.showEdgeLabels)}
218
+ >
219
+ <Tag size={15}/><span>LABELS</span>
220
+ </button>
221
+
222
+ {/* Search */}
223
+ <button
224
+ className={`sim-icon-btn${showSearch ? ' active' : ''}`}
225
+ title="Search nodes"
226
+ onClick={() => { setShowSearch(s => !s); if (showSearch) { setSearchQuery(''); setHighlightNodeIds(new Set()); } }}
227
+ >
228
+ <Search size={15}/><span>SEARCH</span>
229
+ </button>
230
+
231
+ {/* Export PNG */}
232
+ <button className="sim-icon-btn" title="Export as PNG" onClick={() => canvasRef.current?.exportPNG()}>
233
+ <Image size={15}/><span>PNG</span>
234
+ </button>
235
+
236
+ {/* Export SVG */}
237
+ <button className="sim-icon-btn" title="Export as SVG" onClick={() => canvasRef.current?.exportSVG()}>
238
+ <Download size={15}/><span>SVG</span>
239
+ </button>
240
+
241
+ {/* Options panel */}
242
+ <button className={`sim-icon-btn${showPanel ? ' active' : ''}`} title="Advanced options" onClick={() => setShowPanel(p => !p)}>
243
+ <SlidersHorizontal size={15}/><span>OPTIONS</span>
244
+ </button>
245
+
246
+ {/* Fullscreen */}
247
+ <button className="sim-icon-btn" title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'} onClick={toggleFullscreen}>
248
+ {isFullscreen ? <Minimize2 size={15}/> : <Maximize2 size={15}/>}
249
+ <span>{isFullscreen ? 'EXIT' : 'FULL'}</span>
250
+ </button>
251
+
252
+ {/* Help */}
253
+ <button className={`sim-icon-btn${showHelp ? ' active' : ''}`} title="Keyboard shortcuts & help" onClick={() => setShowHelp(h => !h)}>
254
+ <HelpCircle size={15}/><span>?</span>
255
+ </button>
256
+ </div>
257
+ </div>
258
+ </div>
259
+
260
+ {/* ── Node Search bar ──────────────────────────────────────────────── */}
261
+ {showSearch && (
262
+ <div className="search-bar-row">
263
+ <Search size={14}/>
264
+ <input
265
+ autoFocus
266
+ type="text"
267
+ className="sim-search-input"
268
+ placeholder="Search by node name or type…"
269
+ value={searchQuery}
270
+ onChange={e => handleSearch(e.target.value)}
271
+ />
272
+ {highlightNodeIds.size > 0 && (
273
+ <span className="search-result-badge">{highlightNodeIds.size} match{highlightNodeIds.size !== 1 ? 'es' : ''}</span>
274
+ )}
275
+ <button className="sim-icon-btn" style={{ padding: '0.2rem 0.5rem' }} onClick={() => { setSearchQuery(''); setHighlightNodeIds(new Set()); }}>
276
+ <X size={13}/>
277
+ </button>
278
+ </div>
279
+ )}
280
+
281
+ {/* ── Help modal ───────────────────────────────────────────────────── */}
282
+ {showHelp && (
283
+ <div className="advanced-panel">
284
+ <div className="panel-header">
285
+ <span>CONTROLS REFERENCE</span>
286
+ <button className="toast-dismiss-btn" onClick={() => setShowHelp(false)}><X size={15}/></button>
287
+ </div>
288
+ <div className="panel-body" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
289
+ <div>
290
+ <div className="opt-label">MOUSE / TOUCH</div>
291
+ <table className="help-table">
292
+ <tbody>
293
+ {[
294
+ ['Single click', 'Zoom to node'],
295
+ ['Double click', 'Edit node name'],
296
+ ['Drag node', 'Pin node position'],
297
+ ['Hover', 'Highlight neighbors'],
298
+ ['Scroll', 'Zoom in / out'],
299
+ ['Drag canvas', 'Pan view'],
300
+ ['Click canvas bg', 'Reset highlight'],
301
+ ].map(([k, v]) => (
302
+ <tr key={k}><td className="help-key">{k}</td><td className="help-val">{v}</td></tr>
303
+ ))}
304
+ </tbody>
305
+ </table>
306
+ </div>
307
+ <div>
308
+ <div className="opt-label">TOOLBAR BUTTONS</div>
309
+ <table className="help-table">
310
+ <tbody>
311
+ {[
312
+ ['REFRESH', 'Reload graph from Neo4j'],
313
+ ['FIT', 'Reset zoom/pan to center'],
314
+ ['LABELS', 'Toggle edge relation text'],
315
+ ['SEARCH', 'Find & highlight nodes'],
316
+ ['PNG/SVG', 'Export graph image'],
317
+ ['OPTIONS', 'Physics & display settings'],
318
+ ['FULL', 'Toggle fullscreen mode'],
319
+ ].map(([k, v]) => (
320
+ <tr key={k}><td className="help-key">{k}</td><td className="help-val">{v}</td></tr>
321
+ ))}
322
+ </tbody>
323
+ </table>
324
+ </div>
325
+ <div>
326
+ <div className="opt-label">TIPS</div>
327
+ <ul className="help-tips">
328
+ <li>Set <strong>NODES</strong> to 50 for better performance on large graphs</li>
329
+ <li>Enable <strong>Curved edges</strong> to reduce visual overlap</li>
330
+ <li><strong>Node size by degree</strong> makes hubs visually prominent</li>
331
+ <li>Use <strong>SEARCH</strong> to find entities by name or type</li>
332
+ <li>Increase <strong>Charge Strength</strong> to spread out dense clusters</li>
333
+ </ul>
334
+ </div>
335
+ </div>
336
+ </div>
337
+ )}
338
+
339
+ {/* ── Advanced options panel ────────────────────────────────────────── */}
340
+ {showPanel && (
341
+ <div className="advanced-panel">
342
+ <div className="panel-header">
343
+ <span>ADVANCED GRAPH OPTIONS</span>
344
+ <button className="toast-dismiss-btn" onClick={() => setShowPanel(false)}><X size={15}/></button>
345
+ </div>
346
+ <div className="panel-body">
347
+ {/* Display toggles */}
348
+ <div className="opt-section">
349
+ <div className="opt-label">DISPLAY</div>
350
+ <div className="opt-row">
351
+ {([
352
+ ['colorByType', 'Color nodes by entity type'],
353
+ ['showLabels', 'Show node name labels'],
354
+ ['showEdgeLabels', 'Show edge relation labels'],
355
+ ['showCurvedEdges', 'Curved edges (arc routing)'],
356
+ ['nodeSizeByDegree', 'Node size by connection count'],
357
+ ] as [keyof GraphOptions, string][]).map(([key, label]) => (
358
+ <label key={key} className="opt-toggle">
359
+ <input type="checkbox"
360
+ checked={options[key] as boolean}
361
+ onChange={e => setOpt(key, e.target.checked)}
362
+ />
363
+ <span>{label}</span>
364
+ </label>
365
+ ))}
366
+ </div>
367
+ </div>
368
+
369
+ {/* Physics sliders */}
370
+ <div className="opt-section">
371
+ <div className="opt-label">PHYSICS</div>
372
+ <div className="opt-sliders">
373
+ {([
374
+ ['nodeRadius', 'Node Radius', 8, 40, 1],
375
+ ['linkDistance', 'Link Distance', 40, 500, 10],
376
+ ['chargeStrength', 'Repulsion', -1200, -30, 10],
377
+ ['centerGravity', 'Center Gravity', 0, 0.5, 0.01],
378
+ ] as [keyof GraphOptions, string, number, number, number][]).map(([key, label, min, max, step]) => (
379
+ <div key={key} className="slider-row">
380
+ <label>{label}: <strong>{typeof options[key] === 'number' ? (options[key] as number).toFixed(key === 'centerGravity' ? 2 : 0) : options[key]}</strong></label>
381
+ <input type="range" min={min} max={max} step={step}
382
+ value={options[key] as number}
383
+ onChange={e => setOpt(key, Number(e.target.value))}
384
+ />
385
+ </div>
386
+ ))}
387
+ </div>
388
+ </div>
389
+
390
+ <button className="reset-btn" onClick={() => setOptions({ ...DEFAULT_OPTIONS })}>
391
+ ↺ RESET TO DEFAULTS
392
+ </button>
393
+ </div>
394
+ </div>
395
+ )}
396
+
397
+ {/* ── Canvas + Legend + Stats ───────────────────────────────────────── */}
398
+ <div className="canvas-area">
399
+ <div className="canvas-wrapper">
400
+ {loading && graphData.nodes.length === 0 ? (
401
+ <div className="loading-overlay">
402
+ <RefreshCw className="spin" size={28}/>
403
+ <span>INITIALIZING PHYSICS ENGINE...</span>
404
+ </div>
405
+ ) : graphData.nodes.length === 0 ? (
406
+ <div className="loading-overlay empty">
407
+ <Database size={40}/>
408
+ <span>
409
+ {selectedDocId
410
+ ? 'No entities found for this document.\nTry a different document or re-ingest.'
411
+ : 'No entity data in graph.\nIngest documents via the PROCESS tab first.'}
412
+ </span>
413
+ </div>
414
+ ) : (
415
+ <GraphCanvas
416
+ ref={canvasRef}
417
+ data={graphData}
418
+ onNodeUpdate={handleNodeUpdate}
419
+ options={options}
420
+ highlightNodeIds={highlightNodeIds}
421
+ />
422
+ )}
423
+
424
+ {loading && graphData.nodes.length > 0 && (
425
+ <div className="refresh-pill">
426
+ <RefreshCw className="spin" size={13}/> REFRESHING
427
+ </div>
428
+ )}
429
+ </div>
430
+
431
+ {/* ── Sidebar: Legend + Stats ─────────────────────────────────────── */}
432
+ <div className="sidebar-panels">
433
+ {/* Type Legend */}
434
+ {options.colorByType && typeLegend.length > 0 && (
435
+ <div className="legend-panel">
436
+ <div className="legend-title">ENTITY TYPES</div>
437
+ {typeLegend.map(({ type, color }) => (
438
+ <div key={type} className="legend-row">
439
+ <span className="legend-dot" style={{ background: color }}/>
440
+ <span className="legend-type">{type}</span>
441
+ </div>
442
+ ))}
443
+ </div>
444
+ )}
445
+
446
+ {/* Graph stats */}
447
+ {degreeStats && (
448
+ <div className="stats-sidebar">
449
+ <div className="legend-title">NETWORK STATS</div>
450
+ <div className="stat-row"><span>Avg Degree</span><strong>{degreeStats.avg}</strong></div>
451
+ <div className="stat-row"><span>Max Degree</span><strong>{degreeStats.max}</strong></div>
452
+ {degreeStats.hubs.length > 0 && (
453
+ <>
454
+ <div className="legend-title" style={{ marginTop: '0.6rem' }}>HUB NODES</div>
455
+ {degreeStats.hubs.map((n: any) => (
456
+ <div
457
+ key={n.id}
458
+ className="hub-node-row"
459
+ title="Click to zoom to this node"
460
+ onClick={() => canvasRef.current?.highlightNode(n.id)}
461
+ >
462
+ <GitBranch size={10}/>
463
+ <span>{n.label?.length > 14 ? n.label.substring(0,12)+'…' : n.label}</span>
464
+ </div>
465
+ ))}
466
+ </>
467
+ )}
468
+ </div>
469
+ )}
470
+ </div>
471
+ </div>
472
+
473
+ {/* ── Hint bar ──────────────────────────────────────────────────────── */}
474
+ <div className="sim-hint">
475
+ <Info size={11}/>
476
+ <span>
477
+ <strong>Click</strong> node to zoom · <strong>Double-click</strong> to rename · <strong>Hover</strong> to highlight neighbors ·
478
+ <strong> Drag</strong> to pin · <strong>Scroll</strong> to zoom
479
+ </span>
480
+ </div>
481
+
482
+ <style>{`
483
+ .simulation-root {
484
+ height: calc(100vh - 62px);
485
+ display: flex;
486
+ flex-direction: column;
487
+ padding: 0.75rem 1.25rem 0.5rem;
488
+ background: var(--bg-color);
489
+ overflow: hidden;
490
+ gap: 0;
491
+ }
492
+ .simulation-root.fullscreen {
493
+ height: 100vh;
494
+ position: fixed;
495
+ inset: 0;
496
+ z-index: 9999;
497
+ background: #fff;
498
+ padding: 0.75rem 1.25rem 0.5rem;
499
+ }
500
+
501
+ /* Top bar */
502
+ .sim-topbar {
503
+ display: flex;
504
+ align-items: flex-start;
505
+ justify-content: space-between;
506
+ flex-wrap: wrap;
507
+ gap: 0.5rem;
508
+ border-bottom: 2px solid #000;
509
+ padding-bottom: 0.6rem;
510
+ margin-bottom: 0.5rem;
511
+ }
512
+ .sim-title-group h2 { margin: 0 0 0.2rem; font-size: 1rem; letter-spacing: 2px; }
513
+ .sim-chips { display: flex; flex-wrap: wrap; gap: 5px; }
514
+ .s-chip {
515
+ display: inline-flex; align-items: center; gap: 3px;
516
+ border: 1.5px solid #000; padding: 1px 7px;
517
+ font-family: var(--font-mono); font-size: 0.7rem; font-weight: 700;
518
+ }
519
+ .s-chip.active { background: #000; color: #fff; }
520
+
521
+ .sim-controls { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 0.6rem; }
522
+ .ctrl-grp { display: flex; flex-direction: column; gap: 2px; }
523
+ .ctrl-lbl {
524
+ font-family: var(--font-mono); font-size: 0.62rem;
525
+ font-weight: 700; color: #888; letter-spacing: 1px;
526
+ }
527
+ .sim-sel {
528
+ font-family: var(--font-mono); font-size: 0.8rem;
529
+ border: 2px solid #000; background: #fff; color: #000;
530
+ padding: 0.28rem 0.55rem; cursor: pointer; min-width: 120px;
531
+ max-width: 180px;
532
+ }
533
+ .sim-sel:focus { outline: none; box-shadow: 2px 2px 0 #000; }
534
+
535
+ .sim-actions { display: flex; flex-wrap: wrap; gap: 5px; align-items: flex-end; }
536
+ .sim-icon-btn {
537
+ display: inline-flex; align-items: center; gap: 4px;
538
+ border: 2px solid #000; background: #fff; color: #000;
539
+ padding: 0.28rem 0.65rem; font-family: var(--font-mono);
540
+ font-size: 0.7rem; font-weight: 700; cursor: pointer;
541
+ letter-spacing: 0.5px; transition: all 0.13s ease;
542
+ white-space: nowrap;
543
+ }
544
+ .sim-icon-btn:hover, .sim-icon-btn.active { background: #000; color: #fff; }
545
+ .sim-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
546
+
547
+ /* Search bar */
548
+ .search-bar-row {
549
+ display: flex; align-items: center; gap: 0.5rem;
550
+ border: 2px solid #000; padding: 0.3rem 0.75rem;
551
+ margin-bottom: 0.5rem; background: #fafafa;
552
+ }
553
+ .sim-search-input {
554
+ flex: 1; border: none; background: transparent;
555
+ font-family: var(--font-mono); font-size: 0.85rem;
556
+ color: #000; outline: none;
557
+ }
558
+ .search-result-badge {
559
+ font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700;
560
+ background: #000; color: #fff; padding: 1px 8px;
561
+ }
562
+
563
+ /* Advanced panel */
564
+ .advanced-panel {
565
+ border: 2px solid #000; background: #fff;
566
+ margin-bottom: 0.5rem; box-shadow: 3px 3px 0 #000;
567
+ }
568
+ .panel-header {
569
+ display: flex; justify-content: space-between; align-items: center;
570
+ padding: 0.4rem 0.9rem;
571
+ background: #000; color: #fff;
572
+ font-family: var(--font-mono); font-size: 0.75rem; font-weight: 700;
573
+ letter-spacing: 1px;
574
+ }
575
+ .panel-body {
576
+ display: flex; flex-wrap: wrap; gap: 1.25rem; padding: 0.9rem 1rem;
577
+ }
578
+ .opt-section { flex: 1; min-width: 160px; }
579
+ .opt-label {
580
+ font-family: var(--font-mono); font-size: 0.62rem; font-weight: 700;
581
+ color: #888; letter-spacing: 1px; margin-bottom: 0.45rem;
582
+ }
583
+ .opt-row { display: flex; flex-direction: column; gap: 5px; }
584
+ .opt-toggle {
585
+ display: flex; align-items: center; gap: 7px;
586
+ font-family: var(--font-mono); font-size: 0.77rem; cursor: pointer;
587
+ }
588
+ .opt-toggle input { cursor: pointer; }
589
+ .opt-sliders { display: flex; flex-direction: column; gap: 0.55rem; }
590
+ .slider-row {
591
+ display: flex; flex-direction: column; gap: 3px;
592
+ font-family: var(--font-mono); font-size: 0.75rem;
593
+ }
594
+ .slider-row input[type=range] { width: 100%; cursor: pointer; }
595
+ .reset-btn {
596
+ align-self: flex-end;
597
+ border: 2px solid #000; background: #fff; color: #000;
598
+ font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700;
599
+ padding: 0.3rem 0.9rem; cursor: pointer; letter-spacing: 0.5px;
600
+ transition: all 0.13s ease;
601
+ }
602
+ .reset-btn:hover { background: #000; color: #fff; }
603
+
604
+ /* Help table */
605
+ .help-table { width: 100%; border-collapse: collapse; }
606
+ .help-table td { vertical-align: top; padding: 2px 0; font-family: var(--font-mono); font-size: 0.74rem; }
607
+ .help-key { color: #000; font-weight: 700; white-space: nowrap; padding-right: 0.75rem; }
608
+ .help-val { color: #555; }
609
+ .help-tips { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
610
+ .help-tips li { font-family: var(--font-mono); font-size: 0.74rem; color: #555; line-height: 1.4; }
611
+ .help-tips li::before { content: '→ '; color: #000; font-weight: 700; }
612
+ .help-tips strong { color: #000; }
613
+
614
+ /* Canvas area */
615
+ .canvas-area {
616
+ flex: 1; display: flex; gap: 0.6rem; min-height: 0; overflow: hidden;
617
+ }
618
+ .canvas-wrapper {
619
+ flex: 1; border: 2px solid #000;
620
+ background: #fafafa; position: relative; overflow: hidden; min-height: 0;
621
+ }
622
+ .loading-overlay {
623
+ position: absolute; inset: 0;
624
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
625
+ gap: 1rem; background: rgba(255,255,255,0.92);
626
+ font-family: var(--font-mono); font-size: 0.88rem;
627
+ letter-spacing: 1.2px; color: #555;
628
+ white-space: pre-line; text-align: center; padding: 2rem; z-index: 10;
629
+ }
630
+ .loading-overlay.empty { color: #bbb; }
631
+ .refresh-pill {
632
+ position: absolute; top: 8px; right: 8px;
633
+ display: flex; align-items: center; gap: 5px;
634
+ background: rgba(0,0,0,0.85); color: #fff;
635
+ font-family: var(--font-mono); font-size: 0.7rem;
636
+ padding: 2px 9px; z-index: 20;
637
+ }
638
+
639
+ /* Sidebar panels */
640
+ .sidebar-panels {
641
+ display: flex; flex-direction: column; gap: 0.5rem;
642
+ width: 150px; flex-shrink: 0; overflow-y: auto;
643
+ }
644
+ .legend-panel, .stats-sidebar {
645
+ border: 2px solid #000; background: #fff; padding: 0.65rem 0.75rem; flex-shrink: 0;
646
+ }
647
+ .legend-title {
648
+ font-family: var(--font-mono); font-size: 0.6rem; font-weight: 700;
649
+ color: #888; letter-spacing: 1px; margin-bottom: 0.5rem; text-transform: uppercase;
650
+ }
651
+ .legend-row { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
652
+ .legend-dot {
653
+ width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0;
654
+ border: 1px solid rgba(0,0,0,0.15);
655
+ }
656
+ .legend-type {
657
+ font-family: var(--font-mono); font-size: 0.72rem; font-weight: 600;
658
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
659
+ }
660
+ .stat-row {
661
+ display: flex; justify-content: space-between; align-items: center;
662
+ font-family: var(--font-mono); font-size: 0.72rem;
663
+ color: #555; margin-bottom: 3px;
664
+ }
665
+ .stat-row strong { color: #000; font-weight: 700; }
666
+ .hub-node-row {
667
+ display: flex; align-items: center; gap: 5px;
668
+ font-family: var(--font-mono); font-size: 0.69rem;
669
+ color: #333; margin-bottom: 3px; cursor: pointer;
670
+ padding: 2px 4px; transition: background 0.12s;
671
+ }
672
+ .hub-node-row:hover { background: #f0f0f0; }
673
+
674
+ /* Hint bar */
675
+ .sim-hint {
676
+ display: flex; align-items: center; gap: 5px;
677
+ font-size: 0.69rem; color: #999; font-family: var(--font-mono);
678
+ padding: 0.25rem 0; border-top: 1px dashed #ccc; margin-top: 0.25rem;
679
+ flex-shrink: 0;
680
+ }
681
+ .sim-hint strong { color: #555; }
682
+
683
+ .spin { animation: spin 1.2s linear infinite; }
684
+ @keyframes spin { 100% { transform: rotate(360deg); } }
685
+ `}</style>
686
+ </div>
687
+ );
688
+ };
689
+
690
+ export default SimulationRunView;
frontend-react/tsconfig.app.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
frontend-react/tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
frontend-react/tsconfig.node.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
frontend-react/vite.config.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+
5
+ // https://vite.dev/config/
6
+ export default defineConfig({
7
+ plugins: [
8
+ react(),
9
+ tailwindcss(),
10
+ ],
11
+ server: {
12
+ host: true, // Listen on all network interfaces to allow LAN access
13
+ }
14
+ })
main.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Entry point for the application
3
+ You can run the API server using: uv run python main.py
4
+ """
5
+
6
+ from src.graph_rag_service.main import main
7
+
8
+ if __name__ == "__main__":
9
+ main()
package-lock.json ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "graph-rag",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "graph-rag",
9
+ "version": "1.0.0",
10
+ "hasInstallScript": true,
11
+ "devDependencies": {
12
+ "concurrently": "^8.2.2"
13
+ }
14
+ },
15
+ "node_modules/@babel/runtime": {
16
+ "version": "7.29.2",
17
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
18
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
19
+ "dev": true,
20
+ "license": "MIT",
21
+ "engines": {
22
+ "node": ">=6.9.0"
23
+ }
24
+ },
25
+ "node_modules/ansi-regex": {
26
+ "version": "5.0.1",
27
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
28
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
29
+ "dev": true,
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=8"
33
+ }
34
+ },
35
+ "node_modules/ansi-styles": {
36
+ "version": "4.3.0",
37
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
38
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
39
+ "dev": true,
40
+ "license": "MIT",
41
+ "dependencies": {
42
+ "color-convert": "^2.0.1"
43
+ },
44
+ "engines": {
45
+ "node": ">=8"
46
+ },
47
+ "funding": {
48
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
49
+ }
50
+ },
51
+ "node_modules/chalk": {
52
+ "version": "4.1.2",
53
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
54
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
55
+ "dev": true,
56
+ "license": "MIT",
57
+ "dependencies": {
58
+ "ansi-styles": "^4.1.0",
59
+ "supports-color": "^7.1.0"
60
+ },
61
+ "engines": {
62
+ "node": ">=10"
63
+ },
64
+ "funding": {
65
+ "url": "https://github.com/chalk/chalk?sponsor=1"
66
+ }
67
+ },
68
+ "node_modules/chalk/node_modules/supports-color": {
69
+ "version": "7.2.0",
70
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
71
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
72
+ "dev": true,
73
+ "license": "MIT",
74
+ "dependencies": {
75
+ "has-flag": "^4.0.0"
76
+ },
77
+ "engines": {
78
+ "node": ">=8"
79
+ }
80
+ },
81
+ "node_modules/cliui": {
82
+ "version": "8.0.1",
83
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
84
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
85
+ "dev": true,
86
+ "license": "ISC",
87
+ "dependencies": {
88
+ "string-width": "^4.2.0",
89
+ "strip-ansi": "^6.0.1",
90
+ "wrap-ansi": "^7.0.0"
91
+ },
92
+ "engines": {
93
+ "node": ">=12"
94
+ }
95
+ },
96
+ "node_modules/color-convert": {
97
+ "version": "2.0.1",
98
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
99
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
100
+ "dev": true,
101
+ "license": "MIT",
102
+ "dependencies": {
103
+ "color-name": "~1.1.4"
104
+ },
105
+ "engines": {
106
+ "node": ">=7.0.0"
107
+ }
108
+ },
109
+ "node_modules/color-name": {
110
+ "version": "1.1.4",
111
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
112
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
113
+ "dev": true,
114
+ "license": "MIT"
115
+ },
116
+ "node_modules/concurrently": {
117
+ "version": "8.2.2",
118
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
119
+ "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
120
+ "dev": true,
121
+ "license": "MIT",
122
+ "dependencies": {
123
+ "chalk": "^4.1.2",
124
+ "date-fns": "^2.30.0",
125
+ "lodash": "^4.17.21",
126
+ "rxjs": "^7.8.1",
127
+ "shell-quote": "^1.8.1",
128
+ "spawn-command": "0.0.2",
129
+ "supports-color": "^8.1.1",
130
+ "tree-kill": "^1.2.2",
131
+ "yargs": "^17.7.2"
132
+ },
133
+ "bin": {
134
+ "conc": "dist/bin/concurrently.js",
135
+ "concurrently": "dist/bin/concurrently.js"
136
+ },
137
+ "engines": {
138
+ "node": "^14.13.0 || >=16.0.0"
139
+ },
140
+ "funding": {
141
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
142
+ }
143
+ },
144
+ "node_modules/date-fns": {
145
+ "version": "2.30.0",
146
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
147
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
148
+ "dev": true,
149
+ "license": "MIT",
150
+ "dependencies": {
151
+ "@babel/runtime": "^7.21.0"
152
+ },
153
+ "engines": {
154
+ "node": ">=0.11"
155
+ },
156
+ "funding": {
157
+ "type": "opencollective",
158
+ "url": "https://opencollective.com/date-fns"
159
+ }
160
+ },
161
+ "node_modules/emoji-regex": {
162
+ "version": "8.0.0",
163
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
164
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
165
+ "dev": true,
166
+ "license": "MIT"
167
+ },
168
+ "node_modules/escalade": {
169
+ "version": "3.2.0",
170
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
171
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
172
+ "dev": true,
173
+ "license": "MIT",
174
+ "engines": {
175
+ "node": ">=6"
176
+ }
177
+ },
178
+ "node_modules/get-caller-file": {
179
+ "version": "2.0.5",
180
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
181
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
182
+ "dev": true,
183
+ "license": "ISC",
184
+ "engines": {
185
+ "node": "6.* || 8.* || >= 10.*"
186
+ }
187
+ },
188
+ "node_modules/has-flag": {
189
+ "version": "4.0.0",
190
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
191
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
192
+ "dev": true,
193
+ "license": "MIT",
194
+ "engines": {
195
+ "node": ">=8"
196
+ }
197
+ },
198
+ "node_modules/is-fullwidth-code-point": {
199
+ "version": "3.0.0",
200
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
201
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
202
+ "dev": true,
203
+ "license": "MIT",
204
+ "engines": {
205
+ "node": ">=8"
206
+ }
207
+ },
208
+ "node_modules/lodash": {
209
+ "version": "4.18.1",
210
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
211
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
212
+ "dev": true,
213
+ "license": "MIT"
214
+ },
215
+ "node_modules/require-directory": {
216
+ "version": "2.1.1",
217
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
218
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
219
+ "dev": true,
220
+ "license": "MIT",
221
+ "engines": {
222
+ "node": ">=0.10.0"
223
+ }
224
+ },
225
+ "node_modules/rxjs": {
226
+ "version": "7.8.2",
227
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
228
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
229
+ "dev": true,
230
+ "license": "Apache-2.0",
231
+ "dependencies": {
232
+ "tslib": "^2.1.0"
233
+ }
234
+ },
235
+ "node_modules/shell-quote": {
236
+ "version": "1.8.3",
237
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
238
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
239
+ "dev": true,
240
+ "license": "MIT",
241
+ "engines": {
242
+ "node": ">= 0.4"
243
+ },
244
+ "funding": {
245
+ "url": "https://github.com/sponsors/ljharb"
246
+ }
247
+ },
248
+ "node_modules/spawn-command": {
249
+ "version": "0.0.2",
250
+ "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
251
+ "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
252
+ "dev": true
253
+ },
254
+ "node_modules/string-width": {
255
+ "version": "4.2.3",
256
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
257
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
258
+ "dev": true,
259
+ "license": "MIT",
260
+ "dependencies": {
261
+ "emoji-regex": "^8.0.0",
262
+ "is-fullwidth-code-point": "^3.0.0",
263
+ "strip-ansi": "^6.0.1"
264
+ },
265
+ "engines": {
266
+ "node": ">=8"
267
+ }
268
+ },
269
+ "node_modules/strip-ansi": {
270
+ "version": "6.0.1",
271
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
272
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
273
+ "dev": true,
274
+ "license": "MIT",
275
+ "dependencies": {
276
+ "ansi-regex": "^5.0.1"
277
+ },
278
+ "engines": {
279
+ "node": ">=8"
280
+ }
281
+ },
282
+ "node_modules/supports-color": {
283
+ "version": "8.1.1",
284
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
285
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
286
+ "dev": true,
287
+ "license": "MIT",
288
+ "dependencies": {
289
+ "has-flag": "^4.0.0"
290
+ },
291
+ "engines": {
292
+ "node": ">=10"
293
+ },
294
+ "funding": {
295
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
296
+ }
297
+ },
298
+ "node_modules/tree-kill": {
299
+ "version": "1.2.2",
300
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
301
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
302
+ "dev": true,
303
+ "license": "MIT",
304
+ "bin": {
305
+ "tree-kill": "cli.js"
306
+ }
307
+ },
308
+ "node_modules/tslib": {
309
+ "version": "2.8.1",
310
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
311
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
312
+ "dev": true,
313
+ "license": "0BSD"
314
+ },
315
+ "node_modules/wrap-ansi": {
316
+ "version": "7.0.0",
317
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
318
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
319
+ "dev": true,
320
+ "license": "MIT",
321
+ "dependencies": {
322
+ "ansi-styles": "^4.0.0",
323
+ "string-width": "^4.1.0",
324
+ "strip-ansi": "^6.0.0"
325
+ },
326
+ "engines": {
327
+ "node": ">=10"
328
+ },
329
+ "funding": {
330
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
331
+ }
332
+ },
333
+ "node_modules/y18n": {
334
+ "version": "5.0.8",
335
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
336
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
337
+ "dev": true,
338
+ "license": "ISC",
339
+ "engines": {
340
+ "node": ">=10"
341
+ }
342
+ },
343
+ "node_modules/yargs": {
344
+ "version": "17.7.2",
345
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
346
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
347
+ "dev": true,
348
+ "license": "MIT",
349
+ "dependencies": {
350
+ "cliui": "^8.0.1",
351
+ "escalade": "^3.1.1",
352
+ "get-caller-file": "^2.0.5",
353
+ "require-directory": "^2.1.1",
354
+ "string-width": "^4.2.3",
355
+ "y18n": "^5.0.5",
356
+ "yargs-parser": "^21.1.1"
357
+ },
358
+ "engines": {
359
+ "node": ">=12"
360
+ }
361
+ },
362
+ "node_modules/yargs-parser": {
363
+ "version": "21.1.1",
364
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
365
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
366
+ "dev": true,
367
+ "license": "ISC",
368
+ "engines": {
369
+ "node": ">=12"
370
+ }
371
+ }
372
+ }
373
+ }
package.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "graph-rag",
3
+ "version": "1.0.0",
4
+ "description": "Agentic Graph RAG Application",
5
+ "private": true,
6
+ "scripts": {
7
+ "rag": "concurrently -k -p \"[{name}]\" -n \"SERVER,WORKER,FRONTEND\" -c \"magenta,blue,cyan\" \"npm run run:server\" \"npm run run:worker\" \"npm run run:frontend\"",
8
+ "run:server": "uv run python main.py",
9
+ "run:worker": "uv run celery -A src.graph_rag_service.workers.celery_worker worker --loglevel=info --concurrency=4 --pool=threads",
10
+ "run:frontend": "npm --prefix frontend-react run dev",
11
+ "postinstall": "npm --prefix frontend-react install && uv sync && uv run playwright install chromium"
12
+ },
13
+ "devDependencies": {
14
+ "concurrently": "^8.2.2"
15
+ }
16
+ }
pyproject.toml ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "graph-rag"
3
+ version = "0.1.0"
4
+ description = "Agentic Graph RAG as a Service - Production-grade knowledge graph platform"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [
8
+ {name = "Your Name", email = "your.email@example.com"}
9
+ ]
10
+ keywords = ["graph", "rag", "llm", "knowledge-graph", "neo4j", "langgraph", "agents"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Topic :: Software Development :: Libraries :: Python Modules",
15
+ "Programming Language :: Python :: 3.12",
16
+ ]
17
+
18
+ dependencies = [
19
+ "fastapi[standard]>=0.129.0",
20
+ "uvicorn[standard]>=0.41.0",
21
+ "llama-index-core>=0.14.14",
22
+ "langgraph>=1.0.8",
23
+ "langchain-core>=1.2.13",
24
+ "neo4j>=5.28.0,<6.0.0",
25
+ "redis>=7.2.0",
26
+ "celery>=5.6.2",
27
+ "pydantic>=2.12.5",
28
+ "pydantic-settings>=2.13.0",
29
+ "python-multipart>=0.0.22",
30
+ "httpx>=0.28.1",
31
+ "llama-index-llms-openai>=0.6.18",
32
+ "llama-index-llms-anthropic>=0.10.8",
33
+ "llama-index-llms-gemini>=0.6.2",
34
+ "llama-index-embeddings-gemini>=0.1.0",
35
+ "llama-index-llms-ollama>=0.9.1",
36
+ "llama-index-embeddings-ollama>=0.8.6",
37
+ "llama-index-graph-stores-neo4j>=0.5.1",
38
+ "llama-parse>=0.6.10",
39
+ "opentelemetry-api>=1.39.1",
40
+ "opentelemetry-sdk>=1.39.1",
41
+ "opentelemetry-instrumentation-fastapi>=0.60b1",
42
+ "python-jose[cryptography]>=3.5.0",
43
+ "passlib[bcrypt]>=1.7.4",
44
+ "aiofiles>=25.1.0",
45
+ "pypdf>=6.7.1",
46
+ "python-magic-bin>=0.4.14",
47
+ "openpyxl>=3.1.2",
48
+ "python-pptx>=0.6.23",
49
+ "beautifulsoup4>=4.12.3",
50
+ "crawl4ai>=0.4.0",
51
+ "markdownify>=1.2.2",
52
+ "flower>=2.0.1",
53
+ "bcrypt>=4.0.0", # used directly in auth.py (not via passlib)
54
+ ]
55
+
56
+ [project.optional-dependencies]
57
+ dev = [
58
+ "pytest>=9.0.2",
59
+ "pytest-asyncio>=1.3.0",
60
+ "black>=24.0.0",
61
+ "ruff>=0.1.0",
62
+ ]
63
+
64
+ [project.scripts]
65
+ graph-rag = "graph_rag_service.main:main"
66
+
67
+ [build-system]
68
+ requires = ["hatchling"]
69
+ build-backend = "hatchling.build"
70
+
71
+ [tool.hatch.build.targets.wheel]
72
+ packages = ["src/graph_rag_service"]
73
+
74
+ [tool.pytest.ini_options]
75
+ asyncio_mode = "auto"
76
+ testpaths = ["tests"]
77
+
78
+ [tool.black]
79
+ line-length = 100
80
+ target-version = ['py312']
81
+
82
+ [tool.ruff]
83
+ line-length = 100
84
+ target-version = "py312"
refactor_di.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+
4
+ target_dir = r"D:\Desktop_March_26\LYZR\graph-RAG\src\graph_rag_service\api\routers"
5
+
6
+ import_statement = "from ..dependencies import get_graph_store, get_retrieval_agent, get_ingestion_pipeline, get_redis_client\n"
7
+
8
+ for f in os.listdir(target_dir):
9
+ if not f.endswith(".py"): continue
10
+ p = os.path.join(target_dir, f)
11
+ with open(p, "r", encoding="utf-8") as file:
12
+ content = file.read()
13
+
14
+ # Remove all definitions
15
+ content = re.sub(r'def get_graph_store\(.*?\).*?return.*?\n+', '', content, flags=re.DOTALL)
16
+ content = re.sub(r'def get_retrieval_agent\(.*?\).*?return.*?\n+', '', content, flags=re.DOTALL)
17
+ content = re.sub(r'def get_ingestion_pipeline\(.*?\).*?return.*?\n+', '', content, flags=re.DOTALL)
18
+ content = re.sub(r'def get_redis_client\(.*?\).*?return.*?\n+', '', content, flags=re.DOTALL)
19
+ content = re.sub(r'# Dependency injection for global state\n+', '', content)
20
+
21
+ # Add import near the top if not present
22
+ if "from ..dependencies import" not in content and ("Depends(" in content or "Request" in content):
23
+ # find the last import
24
+ last_import = max((content.find("\nimport "), content.find("\nfrom ")))
25
+ if last_import != -1:
26
+ end_of_line = content.find("\n", last_import + 1)
27
+ content = content[:end_of_line+1] + import_statement + content[end_of_line+1:]
28
+ else:
29
+ content = import_statement + content
30
+
31
+ with open(p, "w", encoding="utf-8") as file:
32
+ file.write(content)
33
+ print(f"Refactored {f}")