Vasanth6 commited on
Commit
25d4f70
·
0 Parent(s):

local: phase 5 arena and text highlighter

Browse files
.geminirules ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Agent Behavioral Rules
2
+
3
+ 1. NEVER write, edit, or modify any backend code files (Python, FastAPI, databases, engines, schemas, etc.) directly on your own.
4
+ 2. The user is in charge of the backend implementation.
5
+ 3. Instead of editing backend files, explain the concepts, algorithms, and provide code blocks in your responses so the user can review and implement them.
6
+ 4. DO NOT provide full copy-pasteable files of backend code to the user. Instead, provide architectural guidance, conceptual breakdowns, schema designs, key functions, and partial snippets/pseudocode not actual python code so that the user writes and completes the actual code, reinforcing their hands-on learning.
7
+ 5. Only edit frontend files (HTML, CSS, JS) if explicitly requested, but NEVER touch backend files.
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .venv
2
+ store/
3
+ __pycache__
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.11
README.md ADDED
File without changes
backend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Backend package for RAG Visualizer
backend/constants.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ system_instructions = """
2
+ # ROLE
3
+ You are a strict, unbiased RAG evaluation judge. Your sole task is to determine which of two retrieved text chunks better answers a given search query. You have no preference for either chunk.
4
+
5
+ # BLINDING RULE (critical)
6
+ The chunks are labelled "Chunk A" and "Chunk B". You must ignore any model names or metadata shown — evaluate content only.
7
+
8
+ # SCORING CRITERIA (score each 1–10)
9
+ Score each dimension independently. Do not let one dimension influence another.
10
+
11
+ 1. Query Relevance
12
+ Does the chunk directly address the specific question asked?
13
+ 10 = precisely on-topic, 1 = entirely off-topic.
14
+ Penalise chunks that are topically adjacent but don't answer the actual query.
15
+
16
+ 2. Answer Completeness
17
+ Is the answer self-contained within the chunk, or does it trail off / require external context?
18
+ 10 = standalone complete answer, 1 = fragment with no usable answer.
19
+ Do NOT reward length. A concise, complete answer scores higher than a verbose partial one.
20
+
21
+ 3. Factual Plausibility
22
+ Based on your knowledge, does the chunk contain accurate, internally consistent information?
23
+ 10 = no detectable errors, 1 = clearly wrong or contradictory.
24
+ If you cannot verify a claim, score conservatively (5–6) rather than assuming correctness.
25
+ Do not penalise a chunk for information you simply don't recognise.
26
+
27
+ 4. Clarity & Parsability
28
+ Is the chunk clean, readable, and free from noise (broken formatting, encoding artefacts, truncation mid-sentence)?
29
+ 10 = polished and easy to parse, 1 = heavily noisy or unreadable.
30
+
31
+ # OVERALL SCORE
32
+ overall = (relevance + completeness + plausibility + clarity) / 4
33
+ Round to two decimal places.
34
+
35
+ # WINNER DECLARATION
36
+ - Declare "chunk_a" or "chunk_b" based on overall score.
37
+ - Declare "tie" ONLY if overall scores are within 0.5 of each other AND no single dimension differs by more than 2 points. Ties should be rare.
38
+ - If you declare a tie, the winner_reason must explicitly state why the gap is insufficient to prefer either chunk.
39
+
40
+ # CONFIDENCE
41
+ 0.9–1.0: One chunk is clearly superior across most dimensions.
42
+ 0.7–0.89: One chunk wins, but with a notable weakness.
43
+ 0.5–0.69: Close call; winner has only a marginal edge.
44
+ Below 0.5: Reserve for genuine ties.
45
+
46
+ ---
47
+
48
+ Search Query: {search_query}
49
+
50
+ --- Chunk A ---
51
+ {chunk_a}
52
+
53
+ --- Chunk B ---
54
+ {chunk_b}
55
+
56
+ ---
57
+
58
+ # OUTPUT RULE
59
+ OUTPUT: respond with this exact JSON structure and nothing else:
60
+ {{
61
+ "winner": "chunk_a" | "chunk_b" | "tie",
62
+ "confidence": float,
63
+ "chunk_a_score": {{
64
+ "query_relevance": int,
65
+ "answer_completeness": int,
66
+ "factual_plausibility": int,
67
+ "clarity": int,
68
+ "overall": float
69
+ }},
70
+ "chunk_b_score": {{
71
+ "query_relevance": int,
72
+ "answer_completeness": int,
73
+ "factual_plausibility": int,
74
+ "clarity": int,
75
+ "overall": float
76
+ }},
77
+ "winner_reason": "2-3 sentences",
78
+ "deciding_dimension": "e.g. query_relevance",
79
+ "chunk_a_strengths": ["..."],
80
+ "chunk_a_weaknesses": ["..."],
81
+ "chunk_b_strengths": ["..."],
82
+ "chunk_b_weaknesses": ["..."]
83
+ }}
84
+ """
85
+
86
+
87
+ hyde_prompt = """Generate a concise, factual passage that directly answers the query below.
88
+ Write as if excerpted from a authoritative document or textbook — no intro, no filler, no meta-commentary.
89
+ Match the tone and vocabulary a subject-matter expert would use when writing about this topic.
90
+
91
+ Query: {search_text}
92
+
93
+ Passage:"""
backend/engines/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Engines package — ChunkingEngine, EmbeddingEngine, etc.
backend/engines/chunking.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import tiktoken
3
+ from typing import List
4
+ from nltk.tokenize import sent_tokenize
5
+ import numpy as np
6
+
7
+ from backend.engines.embedding import EmbeddingEngine
8
+
9
+ from langchain_text_splitters import (
10
+ RecursiveCharacterTextSplitter,
11
+ NLTKTextSplitter,
12
+ CharacterTextSplitter,
13
+ )
14
+
15
+ from backend.models.schemas import ChunkConfig, ChunkNode
16
+
17
+
18
+ def count_token(text: str, tokenizer) -> int:
19
+ _encoder = tiktoken.get_encoding(tokenizer)
20
+ return len(_encoder.encode(text))
21
+
22
+
23
+ def fixed_size_strategy(text, config: ChunkConfig) -> List[ChunkNode]:
24
+ splitter = CharacterTextSplitter.from_tiktoken_encoder(
25
+ encoding_name=config.tokenizer,
26
+ chunk_size=config.chunk_size,
27
+ chunk_overlap=config.chunk_overlap,
28
+ )
29
+ result = splitter.split_text(text)
30
+ result = construct_chunk_node(text, result, config.tokenizer)
31
+ return result
32
+
33
+
34
+ def sentence_strategy(text, config: ChunkConfig) -> List[ChunkNode]:
35
+ text_splitter = NLTKTextSplitter.from_tiktoken_encoder(
36
+ encoding_name=config.tokenizer,
37
+ chunk_size=config.chunk_size,
38
+ chunk_overlap=config.chunk_overlap,
39
+ )
40
+ result = text_splitter.split_text(text)
41
+ result = construct_chunk_node(text, result, config.tokenizer)
42
+ return result
43
+
44
+
45
+ def recursive_strategy(text, config: ChunkConfig) -> List[ChunkNode]:
46
+ splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
47
+ encoding_name=config.tokenizer,
48
+ chunk_size=config.chunk_size,
49
+ chunk_overlap=config.chunk_overlap,
50
+ separators=config.separators,
51
+ )
52
+ result = splitter.split_text(text)
53
+ result = construct_chunk_node(text, result, config.tokenizer)
54
+ return result
55
+
56
+
57
+ def parent_child_strategy(text, config) -> List[ChunkNode]:
58
+ parent_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
59
+ encoding_name=config.tokenizer,
60
+ chunk_size=config.parent_chunk_size,
61
+ chunk_overlap=config.parent_chunk_overlap,
62
+ separators=config.separators,
63
+ )
64
+ parent_chunks = parent_splitter.split_text(text)
65
+ result = construct_parent_child_nodes(text, parent_chunks, config)
66
+ return result
67
+
68
+
69
+ def construct_chunk_node(text, chunks, tokenizer):
70
+ nodes = []
71
+ current_position = 0
72
+ for i, chunk in enumerate(chunks):
73
+ # 1. Try exact find first
74
+ start = text.find(chunk, current_position)
75
+
76
+ # 2. If exact find fails, try a clean stripped version
77
+ if start == -1:
78
+ clean_anchor = chunk.strip()[:40]
79
+ if clean_anchor:
80
+ start = text.find(clean_anchor, current_position)
81
+
82
+ # 3. If it still fails, park it at current_position
83
+ if start == -1:
84
+ start = current_position
85
+
86
+ end = start + len(chunk)
87
+ node = ChunkNode(
88
+ id=f"chunk_{i}",
89
+ order=i,
90
+ text=chunk,
91
+ token_count=count_token(chunk, tokenizer),
92
+ start_char=start,
93
+ end_char=end,
94
+ )
95
+ nodes.append(node)
96
+
97
+ # Safely advance position but allow overlaps
98
+ current_position = max(current_position, start + 1)
99
+
100
+ return nodes
101
+
102
+
103
+ def construct_parent_child_nodes(text, parent_chunks, config):
104
+ all_nodes = []
105
+ current_position = 0
106
+ for parent_index, parent_chunk in enumerate(parent_chunks):
107
+ parent_start = text.find(parent_chunk, current_position)
108
+ parent_end = parent_start + len(parent_chunk)
109
+
110
+ parent_id = f"parent_{parent_index}"
111
+
112
+ parent_node = ChunkNode(
113
+ id=parent_id,
114
+ order=parent_index,
115
+ text=parent_chunk,
116
+ token_count=count_token(parent_chunk, config.tokenizer),
117
+ start_char=parent_start,
118
+ end_char=parent_end,
119
+ level=0,
120
+ child_ids=[],
121
+ )
122
+ all_nodes.append(parent_node)
123
+
124
+ child_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
125
+ encoding_name=config.tokenizer,
126
+ chunk_size=config.child_chunk_size,
127
+ chunk_overlap=config.child_chunk_overlap,
128
+ separators=config.separators,
129
+ )
130
+ child_chunks = child_splitter.split_text(parent_chunk)
131
+
132
+ child_position = parent_start
133
+
134
+ for child_index, child_chunk in enumerate(child_chunks):
135
+
136
+ child_start = text.find(child_chunk, child_position)
137
+ child_end = child_start + len(child_chunk)
138
+
139
+ child_id = f"{parent_id}_child_{child_index}"
140
+
141
+ child_node = ChunkNode(
142
+ id=child_id,
143
+ order=child_index,
144
+ text=child_chunk,
145
+ token_count=count_token(child_chunk, config.tokenizer),
146
+ start_char=child_start,
147
+ end_char=child_end,
148
+ level=1,
149
+ parent_id=parent_id,
150
+ )
151
+
152
+ parent_node.child_ids.append(child_id)
153
+
154
+ all_nodes.append(child_node)
155
+
156
+ child_position = child_start + 1
157
+
158
+ current_position = parent_start + 1
159
+
160
+ return all_nodes
161
+
162
+
163
+ def cosine_similarity(v1, v2):
164
+ dot_product = sum(x * y for x, y in zip(v1, v2))
165
+ norm_v1 = math.sqrt(sum(x * x for x in v1))
166
+ norm_v2 = math.sqrt(sum(x * x for x in v2))
167
+ if not norm_v1 or not norm_v2:
168
+ return 0.0
169
+ return dot_product / (norm_v1 * norm_v2)
170
+
171
+
172
+ async def semantic_strategy(text, config: ChunkConfig, embedding_model):
173
+ sentences = sent_tokenize(text)
174
+ embedding_engine = EmbeddingEngine(embedding_model)
175
+
176
+ if not sentences:
177
+ return []
178
+
179
+ temp_nodes = [
180
+ ChunkNode(
181
+ id=f"temp_{i}", order=i, text=s, token_count=0, start_char=0, end_char=0
182
+ )
183
+ for i, s in enumerate(sentences)
184
+ ]
185
+
186
+ sentence_embeddings = await embedding_engine.generate_embeddings(temp_nodes)
187
+
188
+ similarities = []
189
+
190
+ for i in range(len(sentence_embeddings) - 1):
191
+ sim = cosine_similarity(sentence_embeddings[i], sentence_embeddings[i + 1])
192
+ similarities.append(sim)
193
+
194
+ distances = [1 - s for s in similarities]
195
+
196
+ if not distances:
197
+ return construct_chunk_node(text, sentences, config.tokenizer)
198
+
199
+ mean_distance = np.mean(distances)
200
+ std_deviation = np.std(distances)
201
+
202
+ z_score_multiplier = 2.5 - (config.semantic_threshold * 3.0)
203
+ dynamic_threshold = mean_distance + (z_score_multiplier * std_deviation)
204
+
205
+ chunks = []
206
+ current_chunks = [sentences[0]]
207
+
208
+ for i in range(len(distances)):
209
+ current_dist = distances[i]
210
+
211
+ if current_dist > dynamic_threshold:
212
+ is_greater_than_prev = (i == 0) or (current_dist > distances[i - 1])
213
+ is_greater_than_or_equal_next = (i == len(distances) - 1) or (
214
+ current_dist >= distances[i + 1]
215
+ )
216
+
217
+ if is_greater_than_prev and is_greater_than_or_equal_next:
218
+ chunks.append(" ".join(current_chunks))
219
+ current_chunks = [sentences[i + 1]]
220
+ else:
221
+ current_chunks.append(sentences[i + 1])
222
+ else:
223
+ current_chunks.append(sentences[i + 1])
224
+
225
+ if current_chunks:
226
+ chunks.append(" ".join(current_chunks))
227
+
228
+ return construct_chunk_node(text, chunks, config.tokenizer)
229
+
230
+
231
+ class ChunkingEngine:
232
+
233
+ async def chunk(self, text, strategy, config, embedding_model) -> List[ChunkNode]:
234
+ strategy_func = self.available_strategy(strategy)
235
+ if not strategy_func:
236
+ raise ValueError(f"Unknown strategy: {strategy}")
237
+
238
+ if strategy == "semantic":
239
+ return await strategy_func(text, config, embedding_model)
240
+
241
+ return strategy_func(text, config)
242
+
243
+ def available_strategy(self, strategy):
244
+ STRATEGY = {
245
+ "fixed_size": fixed_size_strategy,
246
+ "sentence": sentence_strategy,
247
+ "recursive": recursive_strategy,
248
+ "parent_child": parent_child_strategy,
249
+ "semantic": semantic_strategy,
250
+ }
251
+ return STRATEGY.get(strategy)
backend/engines/embedding.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Sequence
2
+ import httpx
3
+
4
+ from backend.models.schemas import ChunkNode, EmbeddingModel
5
+
6
+
7
+ class EmbeddingEngine:
8
+ def __init__(self, embedding_model):
9
+ self.embedding_model = embedding_model
10
+ self.client = httpx.AsyncClient(timeout=60)
11
+
12
+ async def generate_embeddings(self, chunks: Sequence[ChunkNode | str]):
13
+ texts = [c if isinstance(c, str) else c.text for c in chunks]
14
+ res = await self.client.post(
15
+ "http://localhost:11434/api/embed",
16
+ json={
17
+ "model": self.embedding_model,
18
+ "input": texts,
19
+ },
20
+ )
21
+
22
+ res_json = res.json()
23
+
24
+ if "embeddings" not in res_json or not res_json["embeddings"]:
25
+ raise ValueError(
26
+ f"Ollama failed to generate embeddings for {self.embedding_model}. Response: {res_json}"
27
+ )
28
+
29
+ return res_json["embeddings"]
backend/engines/llm_client.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ import ollama
3
+
4
+
5
+ class OllamaClient:
6
+ def __init__(self):
7
+ self.model = "gemma4:e2b"
8
+ self.client = ollama.AsyncClient()
9
+
10
+ async def generate(self, prompt: str, response_format: Optional[str] = "json"):
11
+ args = {"model": self.model, "prompt": prompt}
12
+
13
+ if response_format:
14
+ args["format"] = response_format
15
+
16
+ response = await self.client.generate(**args) # type: ignore
17
+ return response["response"]
backend/engines/reducer.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from numpy.typing import NDArray
3
+ from typing import List
4
+ import umap
5
+
6
+
7
+ class ReducerEngine:
8
+ _last_fitted_reducer = None
9
+
10
+ def __init__(self, n_neighbors, min_dist):
11
+ self.n_neighbors = n_neighbors
12
+ self.min_dist = min_dist
13
+
14
+ def reduce(self, embeddings: List[List[float]]) -> List[List[float]]:
15
+ n_samples = len(embeddings)
16
+
17
+ if n_samples == 0:
18
+ return []
19
+
20
+ if n_samples == 1:
21
+ return [[0.0, 0.1]]
22
+
23
+ if n_samples < 5:
24
+ return [[0.0, i * 0.1] for i in range(n_samples)]
25
+
26
+ data = np.array(embeddings)
27
+
28
+ safe_n_neighbors = min(self.n_neighbors, n_samples - 1)
29
+ safe_n_neighbors = max(2, safe_n_neighbors)
30
+
31
+ reducer = umap.UMAP(
32
+ n_neighbors=safe_n_neighbors,
33
+ min_dist=self.min_dist,
34
+ metric="cosine",
35
+ random_state=42,
36
+ n_components=2,
37
+ )
38
+
39
+ coords_2d: NDArray[np.float32] = np.asarray(reducer.fit_transform(data))
40
+
41
+ ReducerEngine._last_fitted_reducer = reducer
42
+
43
+ return coords_2d.tolist()
backend/main.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import nltk
3
+ from pathlib import Path
4
+ from fastapi import FastAPI
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.staticfiles import StaticFiles
7
+ from fastapi.responses import FileResponse
8
+
9
+
10
+ def initialize_nltk():
11
+ nltk_data_dir = os.path.expanduser("~/nltk_data")
12
+ os.makedirs(nltk_data_dir, exist_ok=True)
13
+
14
+ if nltk_data_dir not in nltk.data.path:
15
+ nltk.data.path.append(nltk_data_dir)
16
+
17
+ resources = {
18
+ "tokenizers/punkt": "punkt",
19
+ "tokenizers/punkt_tab": "punkt_tab",
20
+ "corpora/stopwords": "stopwords",
21
+ }
22
+
23
+ for path, package in resources.items():
24
+ try:
25
+ nltk.data.find(path)
26
+ print(f"Found NLTK resource: {package}")
27
+ except LookupError:
28
+ print(f"Downloading missing NLTK resource: {package} to {nltk_data_dir}...")
29
+ nltk.download(package, download_dir=nltk_data_dir)
30
+
31
+
32
+ initialize_nltk()
33
+
34
+ from backend.routers.retrieval_router import router as retrieval_router
35
+ from backend.routers.chunk_router import router as chunk_router
36
+
37
+ app = FastAPI(
38
+ title="RAG Visualizer",
39
+ description="An X-Ray machine for RAG pipelines",
40
+ version="0.1.0",
41
+ )
42
+
43
+ app.add_middleware(
44
+ CORSMiddleware,
45
+ allow_origins=["*"],
46
+ allow_credentials=True,
47
+ allow_methods=["*"],
48
+ allow_headers=["*"],
49
+ )
50
+
51
+ app.include_router(chunk_router)
52
+ app.include_router(retrieval_router)
53
+
54
+ FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
55
+ app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
56
+
57
+
58
+ @app.get("/")
59
+ def serve_frontend():
60
+ return FileResponse(str(FRONTEND_DIR / "index.html"))
backend/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Models package — Pydantic schemas (API contracts)
backend/models/schemas.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+ from typing import Any, Dict, List, Literal, Optional
3
+ from pydantic import BaseModel, Field, model_validator
4
+
5
+
6
+ # --- Chunk-related schemas ---
7
+
8
+
9
+ class Strategy(str, Enum):
10
+ FIXED_SIZE = "fixed_size"
11
+ SENTENCE = "sentence"
12
+ RECURSIVE = "recursive"
13
+ PARENT_CHILD = "parent_child"
14
+ SEMANTIC = "semantic"
15
+
16
+
17
+ class EmbeddingModel(str, Enum):
18
+ NOMIC_EMBED_TEXT = "nomic-embed-text"
19
+ EMBEDDING_GEMMA = "EmbeddingGemma"
20
+ QWEN_EMBEDDING = "qwen3-embedding:0.6b"
21
+
22
+
23
+ class RetrievalMode(str, Enum):
24
+ DENSE = "dense"
25
+ SPARSE = "sparse"
26
+ HYBRID = "hybrid"
27
+
28
+
29
+ class ChunkConfig(BaseModel):
30
+ chunk_size: int = 500
31
+ chunk_overlap: int = 20
32
+ semantic_threshold: float = 0.5
33
+ separators: Optional[List[str]] = None
34
+ tokenizer: str = "cl100k_base"
35
+ parent_chunk_size: Optional[int] = None
36
+ parent_chunk_overlap: Optional[int] = None
37
+ child_chunk_size: Optional[int] = None
38
+ child_chunk_overlap: Optional[int] = None
39
+
40
+
41
+ class ChunkNode(BaseModel):
42
+ id: str
43
+ order: int
44
+ text: str
45
+ token_count: int
46
+ start_char: int
47
+ end_char: int
48
+ level: int = 0
49
+ parent_id: Optional[str] = None
50
+ child_ids: List[str] = []
51
+ metadata: Dict[str, Any] = {}
52
+ embeddings: Optional[List[float]] = None
53
+ coords_2d: Optional[List[float]] = None
54
+
55
+
56
+ class StrategyRun(BaseModel):
57
+ strategy: Strategy
58
+ config: ChunkConfig
59
+
60
+
61
+ class StrategyResult(BaseModel):
62
+ strategy: Strategy
63
+ chunks: List[ChunkNode]
64
+ total_chunks: int
65
+ avg_token_count: int
66
+ total_tokens: int
67
+
68
+
69
+ class ChunkRequest(BaseModel):
70
+ text: str
71
+ runs: List[StrategyRun]
72
+ embedding_model: EmbeddingModel = EmbeddingModel.NOMIC_EMBED_TEXT
73
+ n_neighbors: int = 15
74
+ min_dist: float = 0.1
75
+
76
+
77
+ class ChunkResponse(BaseModel):
78
+ results: List[StrategyResult]
79
+
80
+
81
+ class QueryRequest(BaseModel):
82
+ search_text: str
83
+ embedding_model: EmbeddingModel
84
+ strategy: Strategy
85
+ top_k: int = 3
86
+ retrieval_mode: RetrievalMode = RetrievalMode.DENSE
87
+ use_hyde: bool = False
88
+ use_reranking: bool = False
89
+
90
+
91
+ class RetrievedChunk(BaseModel):
92
+ id: str
93
+ text: str
94
+ score: float
95
+ start_char: int
96
+ end_char: int
97
+ parent_id: Optional[str] = None
98
+ level: int = 0
99
+ text_highlighted: Optional[str] = None
100
+
101
+
102
+ class QueryResponse(BaseModel):
103
+ query_text: str
104
+ query_coords: List[float] = [0.0, 0.0]
105
+ results: List[RetrievedChunk]
106
+ hypothetical_answer: Optional[str] = None
107
+
108
+
109
+ class CompareRequest(BaseModel):
110
+ search_text: str
111
+ top_k: int = 3
112
+ model_a: EmbeddingModel
113
+ strategy_a: Strategy
114
+ model_b: EmbeddingModel
115
+ strategy_b: Strategy
116
+
117
+ retrieval_mode: RetrievalMode = RetrievalMode.DENSE
118
+ use_hyde: bool = False
119
+ use_reranking: bool = False
120
+
121
+
122
+ class CompareResponse(BaseModel):
123
+ search_text: str
124
+ results_a: List[RetrievedChunk]
125
+ results_b: List[RetrievedChunk]
126
+ hypothetical_answer: Optional[str] = None
127
+
128
+
129
+ class JudgeRequest(BaseModel):
130
+ search_query: str
131
+ chunk_a: str
132
+ chunk_b: str
133
+
134
+
135
+ class ChunkScore(BaseModel):
136
+ query_relevance: int = Field(ge=1, le=10)
137
+ answer_completeness: int = Field(ge=1, le=10)
138
+ factual_plausibility: int = Field(ge=1, le=10)
139
+ clarity: int = Field(ge=1, le=10)
140
+ overall: float
141
+
142
+ @model_validator(mode="after")
143
+ def check_overall(self) -> "ChunkScore":
144
+ expected = round(
145
+ (
146
+ self.query_relevance
147
+ + self.answer_completeness
148
+ + self.factual_plausibility
149
+ + self.clarity
150
+ )
151
+ / 4,
152
+ 2,
153
+ )
154
+ self.overall = expected
155
+ return self
156
+
157
+
158
+ class JudgeResponse(BaseModel):
159
+ winner: Literal["chunk_a", "chunk_b", "tie"]
160
+ confidence: float = Field(ge=0, le=1)
161
+
162
+ chunk_a_score: ChunkScore
163
+ chunk_b_score: ChunkScore
164
+
165
+ winner_reason: str
166
+ chunk_a_strengths: List[str]
167
+ chunk_b_strengths: List[str]
168
+ chunk_a_weaknesses: List[str]
169
+ chunk_b_weaknesses: List[str]
170
+ deciding_dimension: str
171
+
172
+ @model_validator(mode="after")
173
+ def check_winner_consistency(self) -> "JudgeResponse":
174
+ a = self.chunk_a_score.overall
175
+ b = self.chunk_b_score.overall
176
+ gap = abs(a - b)
177
+
178
+ if gap <= 0.5:
179
+ self.winner = "tie"
180
+ elif a > b:
181
+ self.winner = "chunk_a"
182
+ else:
183
+ self.winner = "chunk_b"
184
+ return self
backend/routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Routers package — API endpoint definitions
backend/routers/chunk_router.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from fastapi import APIRouter, responses
3
+ from pydantic import config
4
+
5
+ from backend.engines.chunking import ChunkingEngine
6
+ from backend.engines.embedding import EmbeddingEngine
7
+ from backend.engines.reducer import ReducerEngine
8
+ from backend.models.schemas import (
9
+ ChunkNode,
10
+ ChunkRequest,
11
+ ChunkResponse,
12
+ Strategy,
13
+ StrategyResult,
14
+ )
15
+ from backend.storage.vector_store import VectorStore
16
+
17
+ # TODO: import your schemas and engine
18
+
19
+ router = APIRouter(prefix="/api", tags=["chunking"])
20
+
21
+
22
+ # TODO: GET /strategies endpoint
23
+ @router.get("/strategies", response_model=List[Strategy])
24
+ def get_strategies():
25
+ return list(Strategy)
26
+
27
+
28
+ # TODO: POST /chunk endpoint
29
+ @router.post("/chunk", response_model=ChunkResponse)
30
+ async def create_chunk(request: ChunkRequest):
31
+ chunk_engine = ChunkingEngine()
32
+ embedding_engine = EmbeddingEngine(request.embedding_model.value)
33
+ reducer_engine = ReducerEngine(request.n_neighbors, request.min_dist)
34
+ vector_store = VectorStore()
35
+ response = []
36
+ all_chunks = []
37
+ strategy_results_map = {}
38
+
39
+ for run in request.runs:
40
+ strategy = run.strategy
41
+ config = run.config
42
+ chunks: List[ChunkNode] = await chunk_engine.chunk(
43
+ text=request.text,
44
+ strategy=strategy,
45
+ config=config,
46
+ embedding_model=request.embedding_model.value,
47
+ )
48
+ strategy_results_map[strategy] = chunks
49
+ all_chunks.extend(chunks)
50
+
51
+ embeddings = await embedding_engine.generate_embeddings(all_chunks)
52
+
53
+ ids = [c.id for c in all_chunks]
54
+ documents = [c.text for c in all_chunks]
55
+
56
+ chunk_metadatas = [
57
+ {
58
+ "start_char": c.start_char,
59
+ "end_char": c.end_char,
60
+ "token_count": c.token_count,
61
+ "level": c.level,
62
+ "parent_id": c.parent_id or "",
63
+ }
64
+ for c in all_chunks
65
+ ]
66
+
67
+ await vector_store.upsert(
68
+ collection_name=f"{request.embedding_model.value}_{request.runs[0].strategy.value}".replace(":", "-"),
69
+ ids=ids,
70
+ documents=documents,
71
+ embeddings=embeddings,
72
+ metadatas=chunk_metadatas,
73
+ )
74
+
75
+ coords_2d = reducer_engine.reduce(embeddings)
76
+
77
+ for chunk, coords in zip(all_chunks, coords_2d):
78
+ chunk.coords_2d = coords
79
+
80
+ for strategy, chunks in strategy_results_map.items():
81
+ total_chunks = len(chunks)
82
+ total_tokens = sum(node.token_count for node in chunks)
83
+ avg_tokens = total_tokens / total_chunks if total_chunks > 0 else 0
84
+
85
+ response.append(
86
+ StrategyResult(
87
+ strategy=strategy,
88
+ chunks=chunks,
89
+ total_chunks=total_chunks,
90
+ total_tokens=total_tokens,
91
+ avg_token_count=int(avg_tokens),
92
+ )
93
+ )
94
+
95
+ return ChunkResponse(results=response)
backend/routers/retrieval_router.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from backend.constants import hyde_prompt
2
+ from backend.engines import llm_client
3
+ from backend.engines.llm_client import OllamaClient
4
+ from backend.constants import system_instructions
5
+ import asyncio
6
+ from typing import Any, Optional
7
+ from fastapi import APIRouter
8
+ import json
9
+ import re
10
+ from backend.engines.embedding import EmbeddingEngine
11
+ from backend.engines.reducer import ReducerEngine
12
+ from backend.models.schemas import (
13
+ CompareRequest,
14
+ CompareResponse,
15
+ JudgeResponse,
16
+ JudgeRequest,
17
+ QueryRequest,
18
+ QueryResponse,
19
+ RetrievedChunk,
20
+ )
21
+ from backend.storage.vector_store import VectorStore
22
+
23
+ from nltk.tokenize import RegexpTokenizer
24
+ from nltk.corpus import stopwords
25
+
26
+ router = APIRouter(prefix="/api", tags=["retrieval"])
27
+
28
+
29
+ tokenizer = RegexpTokenizer(r"\w+")
30
+ stop_words = set(stopwords.words("english"))
31
+
32
+
33
+ @router.post("/retrieve", response_model=QueryResponse)
34
+ async def retrieve(request: QueryRequest):
35
+
36
+ search_text = (
37
+ await get_hyde_text(request.search_text)
38
+ if request.use_hyde
39
+ else request.search_text
40
+ )
41
+
42
+ if request.use_reranking == True:
43
+ "" ""
44
+
45
+ retrieved_chunks, embeddings = await process_retrieval(
46
+ request.embedding_model,
47
+ search_text,
48
+ request.strategy,
49
+ request.top_k,
50
+ request.search_text,
51
+ )
52
+
53
+ query_coords = [0.0, 0.0]
54
+ if ReducerEngine._last_fitted_reducer is not None:
55
+ projected: Any = ReducerEngine._last_fitted_reducer.transform(embeddings)
56
+ query_coords = projected[0].tolist()
57
+
58
+ response_kwargs = {
59
+ "query_text": request.search_text,
60
+ "query_coords": query_coords,
61
+ "results": retrieved_chunks,
62
+ }
63
+
64
+ if request.use_hyde:
65
+ response_kwargs["hypothetical_answer"] = search_text
66
+
67
+ return QueryResponse(**response_kwargs)
68
+
69
+
70
+ @router.post("/compare", response_model=CompareResponse)
71
+ async def compare(request: CompareRequest):
72
+ retrieval_text = (
73
+ await get_hyde_text(request.search_text)
74
+ if request.use_hyde
75
+ else request.search_text
76
+ )
77
+ (
78
+ (retrieved_chunks_a, _),
79
+ (retrieved_chunks_b, _),
80
+ ) = await asyncio.gather(
81
+ process_retrieval(
82
+ request.model_a,
83
+ retrieval_text,
84
+ request.strategy_a,
85
+ request.top_k,
86
+ request.search_text,
87
+ ),
88
+ process_retrieval(
89
+ request.model_b,
90
+ retrieval_text,
91
+ request.strategy_b,
92
+ request.top_k,
93
+ request.search_text,
94
+ ),
95
+ )
96
+
97
+ response_kwargs = {
98
+ "search_text": request.search_text,
99
+ "results_a": retrieved_chunks_a,
100
+ "results_b": retrieved_chunks_b,
101
+ }
102
+ if request.use_hyde:
103
+ response_kwargs["hypothetical_answer"] = retrieval_text
104
+
105
+ return CompareResponse(**response_kwargs)
106
+
107
+
108
+ @router.post("/judge", response_model=JudgeResponse)
109
+ async def judge(request: JudgeRequest):
110
+ prompt = system_instructions.format(
111
+ search_query=request.search_query,
112
+ chunk_a=request.chunk_a,
113
+ chunk_b=request.chunk_b,
114
+ )
115
+
116
+ llm_client = OllamaClient()
117
+ result = await llm_client.generate(prompt=prompt)
118
+
119
+ print(f"res:res: {result}")
120
+
121
+ result_dict = json.loads(result)
122
+
123
+ return JudgeResponse(**result_dict)
124
+
125
+
126
+ async def process_retrieval(
127
+ model, search_text, strategy, top_k, original_query: Optional[str] = None
128
+ ):
129
+ vector_store = VectorStore()
130
+ embedding_engine = EmbeddingEngine(model.value)
131
+ embeddings = await embedding_engine.generate_embeddings([search_text])
132
+ result: Any = await vector_store.retrieve(
133
+ collection_name=f"{model.value}_{strategy.value}".replace(":", "-"),
134
+ embeddings=embeddings,
135
+ n_results=top_k,
136
+ )
137
+
138
+ retrieved_chunks = []
139
+
140
+ highlight_text = original_query if original_query is not None else search_text
141
+
142
+ # Check if result contains valid data
143
+ if result and "ids" in result and result["ids"]:
144
+ ids = result["ids"][0]
145
+ documents = result["documents"][0]
146
+ distances = result["distances"][0]
147
+ metadatas = result["metadatas"][0]
148
+
149
+ for i in range(len(ids)):
150
+ text_highlighted = get_text_highlights(documents[i], highlight_text)
151
+ meta = metadatas[i] or {}
152
+ retrieved_chunks.append(
153
+ RetrievedChunk(
154
+ id=ids[i],
155
+ text=documents[i],
156
+ score=distances[i],
157
+ start_char=meta.get("start_char", 0),
158
+ end_char=meta.get("end_char", 0),
159
+ parent_id=meta.get("parent_id") or None,
160
+ level=meta.get("level", 0),
161
+ text_highlighted=text_highlighted,
162
+ )
163
+ )
164
+
165
+ return retrieved_chunks, embeddings
166
+
167
+
168
+ def get_text_highlights(original_text, search_text):
169
+ query_keywords = get_keywords(search_text)
170
+ if not query_keywords:
171
+ return original_text
172
+
173
+ query_keywords.sort(key=len, reverse=True)
174
+
175
+ safe_keywords = [f"{re.escape(w)}(?:'s)?" for w in query_keywords]
176
+
177
+ pattern_string = r"\b(" + "|".join(safe_keywords) + r")\b"
178
+ pattern = re.compile(pattern_string, re.IGNORECASE)
179
+
180
+ return pattern.sub(r"<mark>\1</mark>", original_text)
181
+
182
+
183
+ def get_keywords(text: str):
184
+ token = tokenizer.tokenize(text.lower())
185
+ return [word for word in token if word not in stop_words]
186
+
187
+
188
+ async def get_hyde_text(search_text):
189
+ llm_client = OllamaClient()
190
+ hyde_prompt.format(search_text=search_text)
191
+ return await llm_client.generate(hyde_prompt, response_format=None)
backend/storage/vector_store.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from chromadb import PersistentClient
3
+ import asyncio
4
+
5
+
6
+ class VectorStore:
7
+ def __init__(self):
8
+ base_dir = Path(__file__).resolve().parent.parent.parent
9
+ store_path = base_dir / "store"
10
+ self.client = PersistentClient(path=str(store_path))
11
+
12
+ def get_collection(self, name: str):
13
+ return self.client.get_or_create_collection(
14
+ name=name, metadata={"hnsw:space": "cosine"}
15
+ )
16
+
17
+ async def upsert(self, collection_name, ids, documents, embeddings, metadatas=None):
18
+ collection = self.get_collection(collection_name)
19
+
20
+ await asyncio.to_thread(
21
+ collection.upsert,
22
+ ids=ids,
23
+ documents=documents,
24
+ embeddings=embeddings,
25
+ metadatas=metadatas,
26
+ )
27
+
28
+ async def retrieve(self, collection_name, embeddings, n_results=3):
29
+ collection = self.get_collection(collection_name)
30
+
31
+ return await asyncio.to_thread(
32
+ collection.query, query_embeddings=embeddings, n_results=n_results
33
+ )
dev.bat ADDED
@@ -0,0 +1 @@
 
 
1
+ uv run uvicorn backend.main:app --reload --port 8080
frontend/app.js ADDED
@@ -0,0 +1,1652 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RAG Visualizer — Chunking Lab
3
+ * Frontend logic: API calls, chunk highlighting, and interactive inspection.
4
+ */
5
+
6
+ // ============================================================
7
+ // State
8
+ // ============================================================
9
+ const state = {
10
+ text: "",
11
+ strategy: "fixed_size",
12
+ config: {
13
+ chunk_size: 500,
14
+ chunk_overlap: 20,
15
+ tokenizer: "cl100k_base",
16
+ separators: null,
17
+ parent_chunk_size: 1000,
18
+ parent_chunk_overlap: 100,
19
+ child_chunk_size: 200,
20
+ child_chunk_overlap: 20,
21
+ embedding_model: "nomic-embed-text",
22
+ n_neighbors: 15,
23
+ min_dist: 0.1,
24
+ semantic_threshold: 0.5,
25
+ },
26
+ activeTab: "xray-tab",
27
+ results: null, // StrategyResult from API
28
+ activeChunkId: null, // Currently highlighted chunk
29
+ isLoading: false,
30
+ };
31
+
32
+ // ============================================================
33
+ // DOM References
34
+ // ============================================================
35
+ const $ = (sel) => document.querySelector(sel);
36
+ const $$ = (sel) => document.querySelectorAll(sel);
37
+
38
+ const dom = {
39
+ textInput: $("#text-input"),
40
+ charCount: $("#char-count"),
41
+ strategyGrid: $("#strategy-grid"),
42
+ btnRun: $("#btn-run"),
43
+ xrayEmpty: $("#xray-empty"),
44
+ xrayText: $("#xray-text"),
45
+ chunkList: $("#chunk-list"),
46
+ statTotal: $("#stat-total"),
47
+ statAvgTokens: $("#stat-avg-tokens"),
48
+ statTotalTokens: $("#stat-total-tokens"),
49
+ statStrategy: $("#stat-strategy"),
50
+ standardConfig: $("#standard-config"),
51
+ parentChildConfig: $("#parent-child-config"),
52
+ semanticConfig: $("#semantic-config"),
53
+ separatorsSection: $("#separators-section"),
54
+ separatorTags: $("#separator-tags"),
55
+ separatorInput: $("#separator-input"),
56
+
57
+ // Vector & UMAP config elements
58
+ embeddingModel: $("#embedding-model"),
59
+ nNeighbors: $("#n-neighbors"),
60
+ minDist: $("#min-dist"),
61
+ nNeighborsValue: $("#n-neighbors-value"),
62
+ minDistValue: $("#min-dist-value"),
63
+
64
+ // Tab elements
65
+ tabButtons: $$(".tab-btn"),
66
+ tabContents: $$(".tab-content"),
67
+
68
+ // Canvas elements
69
+ vectorCanvas: $("#vector-canvas"),
70
+ vectorTooltip: $("#vector-tooltip"),
71
+
72
+ // Query Simulator elements
73
+ queryInput: $("#query-input"),
74
+ btnQuery: $("#btn-query"),
75
+ queryResultsDrawer: $("#query-results-drawer"),
76
+ queryResultsList: $("#query-results-list"),
77
+ closeDrawer: $("#close-drawer"),
78
+ };
79
+
80
+ // ============================================================
81
+ // Slider & Selection Bindings
82
+ // ============================================================
83
+ const sliders = [
84
+ { id: "chunk-size", stateKey: "chunk_size", valueId: "chunk-size-value" },
85
+ { id: "overlap", stateKey: "chunk_overlap", valueId: "overlap-value" },
86
+ {
87
+ id: "parent-size",
88
+ stateKey: "parent_chunk_size",
89
+ valueId: "parent-size-value",
90
+ },
91
+ {
92
+ id: "parent-overlap",
93
+ stateKey: "parent_chunk_overlap",
94
+ valueId: "parent-overlap-value",
95
+ },
96
+ {
97
+ id: "child-size",
98
+ stateKey: "child_chunk_size",
99
+ valueId: "child-size-value",
100
+ },
101
+ {
102
+ id: "child-overlap",
103
+ stateKey: "child_chunk_overlap",
104
+ valueId: "child-overlap-value",
105
+ },
106
+ { id: "n-neighbors", stateKey: "n_neighbors", valueId: "n-neighbors-value" },
107
+ {
108
+ id: "min-dist",
109
+ stateKey: "min_dist",
110
+ valueId: "min-dist-value",
111
+ isFloat: true,
112
+ },
113
+ {
114
+ id: "semantic-threshold",
115
+ stateKey: "semantic_threshold",
116
+ valueId: "semantic-threshold-value",
117
+ isFloat: true,
118
+ },
119
+ ];
120
+
121
+ sliders.forEach(({ id, stateKey, valueId, isFloat }) => {
122
+ const slider = $(`#${id}`);
123
+ const badge = $(`#${valueId}`);
124
+ if (!slider || !badge) return;
125
+
126
+ slider.addEventListener("input", () => {
127
+ const val = isFloat ? parseFloat(slider.value) : parseInt(slider.value, 10);
128
+ state.config[stateKey] = val;
129
+ badge.textContent = isFloat ? val.toFixed(2) : val;
130
+ });
131
+ });
132
+
133
+ if (dom.embeddingModel) {
134
+ dom.embeddingModel.addEventListener("change", () => {
135
+ state.config.embedding_model = dom.embeddingModel.value;
136
+ });
137
+ }
138
+
139
+ // ============================================================
140
+ // Interactive Tab Switching
141
+ // ============================================================
142
+ dom.tabButtons.forEach((btn) => {
143
+ btn.addEventListener("click", () => {
144
+ const targetTab = btn.dataset.tab;
145
+ state.activeTab = targetTab;
146
+
147
+ // Toggle active classes on buttons
148
+ dom.tabButtons.forEach((b) => b.classList.toggle("active", b === btn));
149
+
150
+ // Toggle active content panels
151
+ dom.tabContents.forEach((content) => {
152
+ const isTarget = content.id === targetTab;
153
+ content.classList.toggle("active", isTarget);
154
+ content.style.display = isTarget ? "flex" : "none";
155
+ });
156
+
157
+ // If switching to vector tab, let the canvas redraw itself
158
+ if (
159
+ targetTab === "vector-tab" &&
160
+ typeof window.drawVectorSpace === "function"
161
+ ) {
162
+ window.drawVectorSpace();
163
+ }
164
+ });
165
+ });
166
+
167
+ // ============================================================
168
+ // Text Input
169
+ // ============================================================
170
+ dom.textInput.addEventListener("input", () => {
171
+ state.text = dom.textInput.value;
172
+ dom.charCount.textContent = `${state.text.length} chars`;
173
+ });
174
+
175
+ // ============================================================
176
+ // Strategy Selector
177
+ // ============================================================
178
+ dom.strategyGrid.addEventListener("click", (e) => {
179
+ const card = e.target.closest(".strategy-card");
180
+ if (!card) return;
181
+
182
+ $$(".strategy-card").forEach((c) => c.classList.remove("active"));
183
+ card.classList.add("active");
184
+ card.querySelector('input[type="radio"]').checked = true;
185
+
186
+ state.strategy = card.dataset.strategy;
187
+ updateConfigVisibility();
188
+ });
189
+
190
+ function updateConfigVisibility() {
191
+ const isParentChild = state.strategy === "parent_child";
192
+ const isRecursive = state.strategy === "recursive";
193
+ const isSemantic = state.strategy === "semantic";
194
+
195
+ // Show/hide standard config (hide for parent_child and semantic)
196
+ dom.standardConfig.style.display =
197
+ isParentChild || isSemantic ? "none" : "block";
198
+
199
+ // Show/hide parent-child config
200
+ dom.parentChildConfig.classList.toggle("visible", isParentChild);
201
+
202
+ // Show/hide semantic config
203
+ if (dom.semanticConfig) {
204
+ dom.semanticConfig.style.display = isSemantic ? "block" : "none";
205
+ }
206
+
207
+ // Show/hide separators (for recursive and parent_child)
208
+ dom.separatorsSection.style.display =
209
+ isRecursive || isParentChild ? "block" : "none";
210
+ }
211
+
212
+ // ============================================================
213
+ // Separator Tags
214
+ // ============================================================
215
+ function getSeparators() {
216
+ const tags = dom.separatorTags.querySelectorAll(".separator-tag");
217
+ if (tags.length === 0) return null;
218
+ return Array.from(tags).map((tag) => {
219
+ const raw = tag.dataset.sep;
220
+ // Convert escaped sequences back to real characters
221
+ return raw.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
222
+ });
223
+ }
224
+
225
+ // Remove separator tag
226
+ dom.separatorTags.addEventListener("click", (e) => {
227
+ if (e.target.classList.contains("remove")) {
228
+ e.target.closest(".separator-tag").remove();
229
+ }
230
+ });
231
+
232
+ // Add separator tag
233
+ dom.separatorInput.addEventListener("keydown", (e) => {
234
+ if (e.key === "Enter" && dom.separatorInput.value.trim()) {
235
+ e.preventDefault();
236
+ const val = dom.separatorInput.value;
237
+ const displayVal = val
238
+ .replace(/\n/g, "\\n")
239
+ .replace(/\t/g, "\\t")
240
+ .replace(/ /g, "⎵");
241
+ const tag = document.createElement("span");
242
+ tag.className = "separator-tag";
243
+ tag.dataset.sep = val;
244
+ tag.innerHTML = `<code>${escapeHtml(displayVal)}</code><span class="remove">&times;</span>`;
245
+ dom.separatorTags.insertBefore(tag, dom.separatorInput);
246
+ dom.separatorInput.value = "";
247
+ }
248
+ });
249
+
250
+ // ============================================================
251
+ // API Call
252
+ // ============================================================
253
+ async function runChunking() {
254
+ if (state.isLoading || !state.text.trim()) return;
255
+
256
+ // Reset visualizer and search highlights
257
+ state.results = [];
258
+ state.activeChunkId = null;
259
+ unhighlightChunk();
260
+ dom.xrayText.classList.remove("search-active");
261
+
262
+ state.isLoading = true;
263
+ dom.btnRun.classList.add("loading");
264
+ dom.btnRun.classList.remove("ready");
265
+ dom.btnRun.disabled = true;
266
+
267
+ // Build request payload
268
+ const runConfig = { tokenizer: state.config.tokenizer };
269
+
270
+ if (state.strategy === "parent_child") {
271
+ runConfig.parent_chunk_size = state.config.parent_chunk_size;
272
+ runConfig.parent_chunk_overlap = state.config.parent_chunk_overlap;
273
+ runConfig.child_chunk_size = state.config.child_chunk_size;
274
+ runConfig.child_chunk_overlap = state.config.child_chunk_overlap;
275
+ } else if (state.strategy === "semantic") {
276
+ runConfig.semantic_threshold = state.config.semantic_threshold;
277
+ } else {
278
+ runConfig.chunk_size = state.config.chunk_size;
279
+ runConfig.chunk_overlap = state.config.chunk_overlap;
280
+ }
281
+
282
+ // Add separators if applicable
283
+ const separators = getSeparators();
284
+ if (
285
+ separators &&
286
+ (state.strategy === "recursive" || state.strategy === "parent_child")
287
+ ) {
288
+ runConfig.separators = separators;
289
+ }
290
+
291
+ const payload = {
292
+ text: state.text,
293
+ runs: [
294
+ {
295
+ strategy: state.strategy,
296
+ config: runConfig,
297
+ },
298
+ ],
299
+ embedding_model: state.config.embedding_model,
300
+ n_neighbors: state.config.n_neighbors,
301
+ min_dist: state.config.min_dist,
302
+ };
303
+
304
+ try {
305
+ const res = await fetch("/api/chunk", {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/json" },
308
+ body: JSON.stringify(payload),
309
+ });
310
+
311
+ if (!res.ok) {
312
+ const err = await res.json();
313
+ console.error("API Error:", err);
314
+ alert(`API Error: ${JSON.stringify(err.detail || err)}`);
315
+ return;
316
+ }
317
+
318
+ const data = await res.json();
319
+ state.results = data.results[0]; // Single strategy for now
320
+ state.activeChunkId = null;
321
+
322
+ renderResults();
323
+ } catch (err) {
324
+ console.error("Network error:", err);
325
+ alert("Failed to connect to the API. Is the server running?");
326
+ } finally {
327
+ state.isLoading = false;
328
+ dom.btnRun.classList.remove("loading");
329
+ dom.btnRun.disabled = false;
330
+ dom.btnRun.classList.add("ready");
331
+ }
332
+ }
333
+
334
+ dom.btnRun.addEventListener("click", runChunking);
335
+
336
+ // ============================================================
337
+ // Rendering
338
+ // ============================================================
339
+ function renderResults() {
340
+ if (!state.results) return;
341
+
342
+ const { chunks, total_chunks, avg_token_count, total_tokens, strategy } =
343
+ state.results;
344
+
345
+ // Compute stats if backend returned 0s
346
+ const computedTotal = total_chunks || chunks.length;
347
+ const computedTotalTokens =
348
+ total_tokens || chunks.reduce((sum, c) => sum + c.token_count, 0);
349
+ const computedAvg =
350
+ avg_token_count ||
351
+ (computedTotal > 0 ? Math.round(computedTotalTokens / computedTotal) : 0);
352
+
353
+ // Update stats
354
+ dom.statTotal.textContent = computedTotal;
355
+ dom.statAvgTokens.textContent = computedAvg;
356
+ dom.statTotalTokens.textContent = computedTotalTokens;
357
+ dom.statStrategy.textContent = strategy;
358
+
359
+ // Render X-Ray viewer
360
+ renderXrayText(chunks);
361
+
362
+ // Render chunk list
363
+ renderChunkList(chunks);
364
+
365
+ // Initialize and draw Vector Space (Phase 2)
366
+ if (typeof window.initVectorSpace === "function") {
367
+ window.initVectorSpace(chunks);
368
+ }
369
+ }
370
+
371
+ // ============================================================
372
+ // X-Ray Text Viewer — Highlight chunks in the original text
373
+ // ============================================================
374
+ function renderXrayText(chunks) {
375
+ dom.xrayEmpty.style.display = "none";
376
+ dom.xrayText.style.display = "block";
377
+
378
+ const text = state.text;
379
+ if (!text) return;
380
+
381
+ // 1. Create an array of "markers" for every character in the text
382
+ // Each marker will store which chunks "own" this character
383
+ const markers = Array.from({ length: text.length }, () => []);
384
+
385
+ chunks.forEach((chunk) => {
386
+ for (let i = chunk.start_char; i < chunk.end_char; i++) {
387
+ if (i >= 0 && i < markers.length) {
388
+ markers[i].push(chunk.id);
389
+ }
390
+ }
391
+ });
392
+
393
+ // 2. Build the HTML by grouping characters with the same "owners"
394
+ let html = "";
395
+ let currentOwnersId = null;
396
+ let currentBuffer = "";
397
+
398
+ for (let i = 0; i <= text.length; i++) {
399
+ const owners = markers[i] || [];
400
+ const ownersId = owners.join(",");
401
+
402
+ if (ownersId !== currentOwnersId) {
403
+ if (currentBuffer) {
404
+ if (!currentOwnersId) {
405
+ html += escapeHtml(currentBuffer);
406
+ } else {
407
+ const ownerList = currentOwnersId.split(",");
408
+ const isOverlap = ownerList.length > 1;
409
+ const primaryOwner = ownerList[0];
410
+ const colorIdx = (ownerList.length % 4) + 1;
411
+
412
+ html +=
413
+ `<span class="chunk-highlight${isOverlap ? " overlap-region" : ""}" ` +
414
+ `data-chunk-id="${primaryOwner}" ` +
415
+ `data-all-chunks="${currentOwnersId}" ` +
416
+ `data-color="${colorIdx}">` +
417
+ escapeHtml(currentBuffer) +
418
+ `</span>`;
419
+ }
420
+ }
421
+ currentOwnersId = ownersId;
422
+ currentBuffer = "";
423
+ }
424
+
425
+ if (i < text.length) {
426
+ currentBuffer += text[i];
427
+ }
428
+ }
429
+
430
+ dom.xrayText.innerHTML = html;
431
+
432
+ // Add hover/click listeners to highlights
433
+ dom.xrayText.querySelectorAll(".chunk-highlight").forEach((el) => {
434
+ el.addEventListener("mouseenter", () => highlightChunk(el.dataset.chunkId));
435
+ el.addEventListener("mouseleave", () => unhighlightChunk());
436
+ el.addEventListener("click", () => selectChunk(el.dataset.chunkId));
437
+ });
438
+ }
439
+
440
+ // ============================================================
441
+ // Chunk List (Right Panel)
442
+ // ============================================================
443
+ function renderChunkList(chunks) {
444
+ let html = "";
445
+ let currentParentId = null;
446
+
447
+ chunks.forEach((chunk, index) => {
448
+ // Add parent label for parent-child strategy
449
+ if (chunk.level === 0 && state.strategy === "parent_child") {
450
+ html += `<div class="parent-child-label">📦 Parent</div>`;
451
+ currentParentId = chunk.id;
452
+ } else if (chunk.level === 1 && chunk.parent_id !== currentParentId) {
453
+ currentParentId = chunk.parent_id;
454
+ }
455
+
456
+ const preview =
457
+ chunk.text.length > 120 ? chunk.text.slice(0, 120) + "…" : chunk.text;
458
+
459
+ html += `
460
+ <div class="chunk-item" data-chunk-id="${chunk.id}" data-level="${chunk.level}" data-index="${index + 1}">
461
+ <div class="chunk-item-header">
462
+ <span class="chunk-item-id">${chunk.id}</span>
463
+ <div class="chunk-item-badges">
464
+ <span class="chunk-badge tokens">${chunk.token_count} tok</span>
465
+ <span class="chunk-badge">${chunk.end_char - chunk.start_char} chars</span>
466
+ </div>
467
+ </div>
468
+ <div class="chunk-item-text">${escapeHtml(preview)}</div>
469
+ <div class="chunk-item-meta">
470
+ <span>start: ${chunk.start_char}</span>
471
+ <span>end: ${chunk.end_char}</span>
472
+ ${chunk.parent_id ? `<span>parent: ${chunk.parent_id}</span>` : ""}
473
+ </div>
474
+ </div>
475
+ `;
476
+ });
477
+
478
+ dom.chunkList.innerHTML = html;
479
+
480
+ // Add click/hover listeners
481
+ dom.chunkList.querySelectorAll(".chunk-item").forEach((el) => {
482
+ el.addEventListener("mouseenter", () => highlightChunk(el.dataset.chunkId));
483
+ el.addEventListener("mouseleave", () => unhighlightChunk());
484
+ el.addEventListener("click", () => selectChunk(el.dataset.chunkId));
485
+ });
486
+ }
487
+
488
+ // ============================================================
489
+ // Highlight / Selection Logic
490
+ // ============================================================
491
+ function highlightChunk(chunkId) {
492
+ // Highlight in X-ray viewer
493
+ dom.xrayText.querySelectorAll(".chunk-highlight").forEach((el) => {
494
+ el.classList.toggle("active", el.dataset.chunkId === chunkId);
495
+ });
496
+
497
+ // Highlight in chunk list
498
+ dom.chunkList.querySelectorAll(".chunk-item").forEach((el) => {
499
+ el.classList.toggle("active", el.dataset.chunkId === chunkId);
500
+ });
501
+ }
502
+
503
+ function unhighlightChunk() {
504
+ if (state.activeChunkId) {
505
+ // If a chunk is "selected" (clicked), keep it highlighted
506
+ highlightChunk(state.activeChunkId);
507
+ return;
508
+ }
509
+
510
+ dom.xrayText.querySelectorAll(".chunk-highlight.active").forEach((el) => {
511
+ el.classList.remove("active");
512
+ });
513
+ dom.chunkList.querySelectorAll(".chunk-item.active").forEach((el) => {
514
+ el.classList.remove("active");
515
+ });
516
+ }
517
+
518
+ window.selectChunk = function selectChunk(chunkId) {
519
+ // Toggle selection
520
+ state.activeChunkId = state.activeChunkId === chunkId ? null : chunkId;
521
+ if (state.activeChunkId) {
522
+ highlightChunk(state.activeChunkId);
523
+
524
+ // Scroll the chunk into view in the X-ray panel
525
+ const xrayEl = dom.xrayText.querySelector(`[data-chunk-id="${chunkId}"]`);
526
+ if (xrayEl) {
527
+ xrayEl.scrollIntoView({ behavior: "smooth", block: "center" });
528
+ }
529
+
530
+ // Scroll the chunk into view in the list
531
+ const listEl = dom.chunkList.querySelector(`[data-chunk-id="${chunkId}"]`);
532
+ if (listEl) {
533
+ listEl.scrollIntoView({ behavior: "smooth", block: "center" });
534
+ }
535
+ } else {
536
+ unhighlightChunk();
537
+ }
538
+ };
539
+
540
+ function escapeHtml(str) {
541
+ const div = document.createElement("div");
542
+ div.textContent = str;
543
+ return div.innerHTML;
544
+ }
545
+
546
+ document.addEventListener("keydown", (e) => {
547
+ // Ctrl/Cmd + Enter to run
548
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
549
+ e.preventDefault();
550
+ runChunking();
551
+ }
552
+ });
553
+
554
+ updateConfigVisibility();
555
+
556
+ // Local visual state for canvas physics and scaling
557
+ const vectorState = {
558
+ chunks: [], // Holds our active chunks
559
+ zoom: 1.0, // Scroll scale
560
+ pan: { x: 0, y: 0 }, // Drag offset
561
+ hoveredChunk: null, // Chunk under mouse cursor
562
+ selectedChunk: null, // Active clicked chunk
563
+ isDragging: false, // Drag state flag
564
+ dragStart: { x: 0, y: 0 },
565
+ activeQuery: null, // Active search query or sonar probe click
566
+
567
+ // High/low UMAP coordinates (for scaling math)
568
+ bounds: {
569
+ minX: 0,
570
+ maxX: 0,
571
+ minY: 0,
572
+ maxY: 0,
573
+ },
574
+ };
575
+
576
+ // Stubs for you to implement the magical vector space rendering:
577
+ window.initVectorSpace = function (chunks) {
578
+ console.log("Initializing Vector Space with chunks:", chunks);
579
+ vectorState.chunks = chunks;
580
+
581
+ if (chunks.length > 0) {
582
+ const xs = chunks.map((c) => (c.coords_2d ? c.coords_2d[0] : 0));
583
+ const ys = chunks.map((c) => (c.coords_2d ? c.coords_2d[1] : 0));
584
+
585
+ vectorState.bounds.minX = Math.min(...xs);
586
+ vectorState.bounds.maxX = Math.max(...xs);
587
+ vectorState.bounds.minY = Math.min(...ys);
588
+ vectorState.bounds.maxY = Math.max(...ys);
589
+ }
590
+ const canvas = dom.vectorCanvas;
591
+ if (!canvas) return;
592
+
593
+ // Set logical dimensions matching the bounding box
594
+ const rect = canvas.parentNode.getBoundingClientRect();
595
+ canvas.width = rect.width * window.devicePixelRatio;
596
+ canvas.height = rect.height * window.devicePixelRatio;
597
+ canvas.style.width = "100%";
598
+ canvas.style.height = "100%";
599
+
600
+ // Center alignment resetting
601
+ vectorState.pan = { x: 0, y: 0 };
602
+ vectorState.zoom = 1.0;
603
+ vectorState.hoveredChunk = null;
604
+ vectorState.selectedChunk = null;
605
+ vectorState.activeQuery = null;
606
+
607
+ // Bind custom interactive event handlers
608
+ if (!canvas.dataset.listenersAttached) {
609
+ setupCanvasListeners(canvas);
610
+ canvas.dataset.listenersAttached = "true";
611
+ }
612
+
613
+ // Active smooth render animation loop
614
+ if (!window.animationLoopActive) {
615
+ startAnimationLoop();
616
+ }
617
+ };
618
+
619
+ window.animationLoopActive = false;
620
+ function startAnimationLoop() {
621
+ window.animationLoopActive = true;
622
+ function tick() {
623
+ window.drawVectorSpace();
624
+ requestAnimationFrame(tick);
625
+ }
626
+ requestAnimationFrame(tick);
627
+ }
628
+
629
+ // Maps UMAP coordinates [x, y] to Canvas pixel coordinates [x, y]
630
+ function mapToCanvas(umapX, umapY, width, height) {
631
+ const padding = 50; // Keeps dots from clipping the canvas edges
632
+ const { minX, maxX, minY, maxY } = vectorState.bounds;
633
+
634
+ // Avoid division by zero if all coordinates are identical
635
+ const rangeX = maxX - minX || 1;
636
+ const rangeY = maxY - minY || 1;
637
+
638
+ // 1. Normalize UMAP value to a percentage (0.0 to 1.0)
639
+ const pctX = (umapX - minX) / rangeX;
640
+ const pctY = (umapY - minY) / rangeY;
641
+
642
+ // 2. Map percentage to canvas size (with padding)
643
+ const canvasX = padding + pctX * (width - padding * 2);
644
+ const canvasY = padding + pctY * (height - padding * 2);
645
+
646
+ return { x: canvasX, y: canvasY };
647
+ }
648
+
649
+ window.drawVectorSpace = function () {
650
+ const canvas = dom.vectorCanvas;
651
+ if (!canvas) return;
652
+ const ctx = canvas.getContext("2d");
653
+
654
+ // Self-healing resize guard (handles hidden container init and window resizing)
655
+ const rect = canvas.parentNode.getBoundingClientRect();
656
+ const targetWidth = Math.floor(rect.width * window.devicePixelRatio);
657
+ const targetHeight = Math.floor(rect.height * window.devicePixelRatio);
658
+
659
+ if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
660
+ if (rect.width > 0 && rect.height > 0) {
661
+ canvas.width = targetWidth;
662
+ canvas.height = targetHeight;
663
+ ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
664
+ }
665
+ }
666
+
667
+ const width = canvas.width / window.devicePixelRatio;
668
+ const height = canvas.height / window.devicePixelRatio;
669
+
670
+ // Clear canvas
671
+ ctx.clearRect(0, 0, width, height);
672
+
673
+ // Apply Panning and Zooming transformations
674
+ ctx.save();
675
+ ctx.translate(vectorState.pan.x, vectorState.pan.y);
676
+ ctx.scale(vectorState.zoom, vectorState.zoom);
677
+
678
+ // A. DRAW GRID IN TRANSFORMED SPACE
679
+ ctx.strokeStyle = "rgba(15, 23, 42, 0.05)";
680
+ ctx.lineWidth = 1 / vectorState.zoom;
681
+ const gridSize = 40;
682
+
683
+ // Calculate bounding box in transformed space to draw grid infinite-scrolling
684
+ const startX = -vectorState.pan.x / vectorState.zoom;
685
+ const endX = (width - vectorState.pan.x) / vectorState.zoom;
686
+ const startY = -vectorState.pan.y / vectorState.zoom;
687
+ const endY = (height - vectorState.pan.y) / vectorState.zoom;
688
+
689
+ for (
690
+ let x = Math.floor(startX / gridSize) * gridSize;
691
+ x < endX;
692
+ x += gridSize
693
+ ) {
694
+ ctx.beginPath();
695
+ ctx.moveTo(x, startY);
696
+ ctx.lineTo(x, endY);
697
+ ctx.stroke();
698
+ }
699
+ for (
700
+ let y = Math.floor(startY / gridSize) * gridSize;
701
+ y < endY;
702
+ y += gridSize
703
+ ) {
704
+ ctx.beginPath();
705
+ ctx.moveTo(startX, y);
706
+ ctx.lineTo(endX, y);
707
+ ctx.stroke();
708
+ }
709
+
710
+ // B. DRAW PARENT-CHILD CONNECTION LINES
711
+ if (state.strategy === "parent_child") {
712
+ vectorState.chunks.forEach((chunk) => {
713
+ if (chunk.level === 1 && chunk.parent_id) {
714
+ const parent = vectorState.chunks.find((c) => c.id === chunk.parent_id);
715
+ if (parent && chunk.coords_2d && parent.coords_2d) {
716
+ const cpos = mapToCanvas(
717
+ chunk.coords_2d[0],
718
+ chunk.coords_2d[1],
719
+ width,
720
+ height,
721
+ );
722
+ const ppos = mapToCanvas(
723
+ parent.coords_2d[0],
724
+ parent.coords_2d[1],
725
+ width,
726
+ height,
727
+ );
728
+
729
+ ctx.beginPath();
730
+ ctx.strokeStyle = "rgba(245, 158, 11, 0.2)";
731
+ ctx.lineWidth = 1.5 / vectorState.zoom;
732
+ ctx.moveTo(ppos.x, ppos.y);
733
+ ctx.lineTo(cpos.x, cpos.y);
734
+ ctx.stroke();
735
+ }
736
+ }
737
+ });
738
+ } // C. DRAW ACTIVE RETRIEVAL LINES AND ANIMATIONS
739
+ if (vectorState.activeQuery) {
740
+ // Increment pulse radius for animation
741
+ vectorState.activeQuery.pulseRadius += 0.8;
742
+ if (vectorState.activeQuery.pulseRadius > 50) {
743
+ vectorState.activeQuery.pulseRadius = 0;
744
+ }
745
+
746
+ const qpos = mapToCanvas(
747
+ vectorState.activeQuery.x,
748
+ vectorState.activeQuery.y,
749
+ width,
750
+ height,
751
+ );
752
+
753
+ if (
754
+ vectorState.activeQuery.state === "fetching" ||
755
+ vectorState.activeQuery.state === "loaded"
756
+ ) {
757
+ // Flowing lines or loaded lines to topK
758
+ const topK = vectorState.activeQuery.topK || [];
759
+ if (vectorState.activeQuery.state === "fetching") {
760
+ vectorState.activeQuery.flowOffset =
761
+ (vectorState.activeQuery.flowOffset || 0) - 2;
762
+ }
763
+
764
+ topK.forEach((item, index) => {
765
+ if (item.chunk.coords_2d) {
766
+ const cpos = mapToCanvas(
767
+ item.chunk.coords_2d[0],
768
+ item.chunk.coords_2d[1],
769
+ width,
770
+ height,
771
+ );
772
+ ctx.beginPath();
773
+ ctx.strokeStyle = "rgba(245, 158, 11, 0.6)"; // Stronger gold
774
+ ctx.lineWidth = 2 / vectorState.zoom;
775
+ ctx.setLineDash([5, 5]);
776
+ ctx.lineDashOffset =
777
+ vectorState.activeQuery.state === "fetching"
778
+ ? vectorState.activeQuery.flowOffset
779
+ : 0;
780
+ ctx.moveTo(qpos.x, qpos.y);
781
+ ctx.lineTo(cpos.x, cpos.y);
782
+ ctx.stroke();
783
+ ctx.setLineDash([]);
784
+ }
785
+ });
786
+ }
787
+ }
788
+ // D. DRAW CHUNK PARTICLES
789
+ vectorState.chunks.forEach((chunk) => {
790
+ if (!chunk.coords_2d) return;
791
+
792
+ const cpos = mapToCanvas(
793
+ chunk.coords_2d[0],
794
+ chunk.coords_2d[1],
795
+ width,
796
+ height,
797
+ );
798
+
799
+ // Style according to strategy and highlights
800
+ let radius = 6;
801
+ let color = "#2563eb"; // Superman Blue default
802
+ let glowColor = "rgba(37, 99, 235, 0.15)";
803
+
804
+ const isHovered =
805
+ vectorState.hoveredChunk && vectorState.hoveredChunk.id === chunk.id;
806
+ const isSelected = state.activeChunkId === chunk.id;
807
+
808
+ if (state.strategy === "parent_child") {
809
+ if (chunk.level === 0) {
810
+ radius = 8;
811
+ color = "#f59e0b"; // Superman Gold Parent
812
+ glowColor = "rgba(245, 158, 11, 0.2)";
813
+ } else {
814
+ radius = 4.5;
815
+ color = "#2563eb"; // Superman Blue Child
816
+ glowColor = "rgba(37, 99, 235, 0.15)";
817
+ }
818
+ } else {
819
+ // Standard styles
820
+ radius = 5.5;
821
+ color = "#2563eb";
822
+ glowColor = "rgba(37, 99, 235, 0.15)";
823
+ }
824
+
825
+ // Dynamic scale-up on hover or selection (glowing Superman Red)
826
+ if (isHovered || isSelected) {
827
+ radius += 3;
828
+ color = "#ef4444"; // Superman Red
829
+ glowColor = isSelected
830
+ ? "rgba(239, 68, 68, 0.45)"
831
+ : "rgba(239, 68, 68, 0.25)";
832
+ }
833
+
834
+ // 1. Draw glowing background shadow ring
835
+ ctx.beginPath();
836
+ ctx.arc(cpos.x, cpos.y, radius + 4, 0, Math.PI * 2);
837
+ ctx.fillStyle = glowColor;
838
+ ctx.fill();
839
+
840
+ // 2. Draw solid particle
841
+ ctx.beginPath();
842
+ ctx.arc(cpos.x, cpos.y, radius, 0, Math.PI * 2);
843
+ ctx.fillStyle = color;
844
+ ctx.strokeStyle = "#ffffff"; // Clean white border outline for Light Mode
845
+ ctx.lineWidth = 1.5;
846
+ ctx.fill();
847
+ ctx.stroke();
848
+
849
+ // 3. Draw highlighted white core on selection
850
+ if (isSelected) {
851
+ ctx.beginPath();
852
+ ctx.arc(cpos.x, cpos.y, 2, 0, Math.PI * 2);
853
+ ctx.fillStyle = "#ffffff";
854
+ ctx.fill();
855
+ }
856
+
857
+ // 4. Rank 1 Sun Glow if loaded
858
+ if (
859
+ vectorState.activeQuery &&
860
+ vectorState.activeQuery.state === "loaded" &&
861
+ vectorState.activeQuery.topK &&
862
+ vectorState.activeQuery.topK.length > 0
863
+ ) {
864
+ if (chunk.id === vectorState.activeQuery.topK[0].chunk.id) {
865
+ // Pulsing sun glow radius
866
+ const sunRadius = 15 + Math.sin(Date.now() / 300) * 5;
867
+ ctx.beginPath();
868
+ ctx.arc(cpos.x, cpos.y, sunRadius, 0, Math.PI * 2);
869
+ ctx.fillStyle = "rgba(245, 158, 11, 0.3)";
870
+ ctx.fill();
871
+ ctx.beginPath();
872
+ ctx.arc(cpos.x, cpos.y, radius + 2, 0, Math.PI * 2);
873
+ ctx.fillStyle = "#f59e0b"; // Solid gold core
874
+ ctx.fill();
875
+ }
876
+ }
877
+ });
878
+
879
+ // E. DRAW ACTIVE QUERY & SONAR PING SHOCKWAVES
880
+ if (vectorState.activeQuery) {
881
+ const qpos = mapToCanvas(
882
+ vectorState.activeQuery.x,
883
+ vectorState.activeQuery.y,
884
+ width,
885
+ height,
886
+ );
887
+
888
+ // 1. Draw Sonar ripple pulse rings (Superman Red wave)
889
+ ctx.beginPath();
890
+ ctx.arc(
891
+ qpos.x,
892
+ qpos.y,
893
+ vectorState.activeQuery.pulseRadius,
894
+ 0,
895
+ Math.PI * 2,
896
+ );
897
+ ctx.strokeStyle = `rgba(239, 68, 68, ${1 - vectorState.activeQuery.pulseRadius / 50})`;
898
+ ctx.lineWidth = 1.5 / vectorState.zoom;
899
+ ctx.stroke();
900
+
901
+ // 2. Draw outer glowing ring (Superman Red base)
902
+ ctx.beginPath();
903
+ ctx.arc(qpos.x, qpos.y, 8, 0, Math.PI * 2);
904
+ ctx.fillStyle = "rgba(239, 68, 68, 0.15)";
905
+ ctx.strokeStyle = "#ef4444";
906
+ ctx.lineWidth = 1.5 / vectorState.zoom;
907
+ ctx.fill();
908
+ ctx.stroke();
909
+
910
+ // 3. Draw crosshair target center
911
+ ctx.beginPath();
912
+ ctx.arc(qpos.x, qpos.y, 2, 0, Math.PI * 2);
913
+ ctx.fillStyle = "#ef4444";
914
+ ctx.fill();
915
+
916
+ // 4. Draw a small floating text label for the query
917
+ ctx.fillStyle = "#475569"; // Dark muted slate text for excellent light mode readability
918
+ ctx.font = `${Math.max(8, 10 / vectorState.zoom)}px var(--font-sans)`;
919
+ ctx.textAlign = "center";
920
+ ctx.fillText(
921
+ vectorState.activeQuery.label || "Sonar Query",
922
+ qpos.x,
923
+ qpos.y - 12,
924
+ );
925
+ }
926
+
927
+ ctx.restore();
928
+ };
929
+
930
+ function setupCanvasListeners(canvas) {
931
+ const getMousePos = (e) => {
932
+ const rect = canvas.getBoundingClientRect();
933
+ return {
934
+ x: e.clientX - rect.left,
935
+ y: e.clientY - rect.top,
936
+ };
937
+ };
938
+
939
+ canvas.addEventListener("mousedown", (e) => {
940
+ const pos = getMousePos(e);
941
+ vectorState.isDragging = true;
942
+ vectorState.dragStart = {
943
+ x: pos.x - vectorState.pan.x,
944
+ y: pos.y - vectorState.pan.y,
945
+ };
946
+ });
947
+
948
+ canvas.addEventListener("mousemove", (e) => {
949
+ const pos = getMousePos(e);
950
+ const width = canvas.width / window.devicePixelRatio;
951
+ const height = canvas.height / window.devicePixelRatio;
952
+
953
+ if (vectorState.isDragging) {
954
+ // Pan canvas
955
+ vectorState.pan.x = pos.x - vectorState.dragStart.x;
956
+ vectorState.pan.y = pos.y - vectorState.dragStart.y;
957
+ } else {
958
+ // Hover detection
959
+ const tx = (pos.x - vectorState.pan.x) / vectorState.zoom;
960
+ const ty = (pos.y - vectorState.pan.y) / vectorState.zoom;
961
+
962
+ let found = null;
963
+ let minDistance = Infinity;
964
+
965
+ vectorState.chunks.forEach((chunk) => {
966
+ if (!chunk.coords_2d) return;
967
+ const cpos = mapToCanvas(
968
+ chunk.coords_2d[0],
969
+ chunk.coords_2d[1],
970
+ width,
971
+ height,
972
+ );
973
+ const dist = Math.hypot(cpos.x - tx, cpos.y - ty);
974
+
975
+ if (dist < 12 && dist < minDistance) {
976
+ minDistance = dist;
977
+ found = chunk;
978
+ }
979
+ });
980
+
981
+ if (found !== vectorState.hoveredChunk) {
982
+ vectorState.hoveredChunk = found;
983
+ if (found) {
984
+ highlightChunk(found.id);
985
+ showTooltip(found, pos.x, pos.y);
986
+ } else {
987
+ unhighlightChunk();
988
+ hideTooltip();
989
+ }
990
+ }
991
+ }
992
+ });
993
+
994
+ canvas.addEventListener("mouseup", () => {
995
+ vectorState.isDragging = false;
996
+ });
997
+
998
+ canvas.addEventListener("mouseleave", () => {
999
+ vectorState.isDragging = false;
1000
+ vectorState.hoveredChunk = null;
1001
+ unhighlightChunk();
1002
+ hideTooltip();
1003
+ });
1004
+
1005
+ canvas.addEventListener(
1006
+ "wheel",
1007
+ (e) => {
1008
+ e.preventDefault();
1009
+ const pos = getMousePos(e);
1010
+
1011
+ // Decreased sensitivity with a smooth exponential multiplier
1012
+ const zoomFactor = 1 - e.deltaY * 0.0006;
1013
+ const prevZoom = vectorState.zoom;
1014
+
1015
+ // Smoothly clamp zoom scale between 0.15 and 8.0
1016
+ vectorState.zoom = Math.min(
1017
+ Math.max(vectorState.zoom * zoomFactor, 0.15),
1018
+ 8.0,
1019
+ );
1020
+
1021
+ // Zoom centering physics
1022
+ vectorState.pan.x =
1023
+ pos.x - (pos.x - vectorState.pan.x) * (vectorState.zoom / prevZoom);
1024
+ vectorState.pan.y =
1025
+ pos.y - (pos.y - vectorState.pan.y) * (vectorState.zoom / prevZoom);
1026
+ },
1027
+ { passive: false },
1028
+ );
1029
+
1030
+ canvas.addEventListener("click", (e) => {
1031
+ if (vectorState.isDragging) return;
1032
+ const pos = getMousePos(e);
1033
+ const width = canvas.width / window.devicePixelRatio;
1034
+ const height = canvas.height / window.devicePixelRatio;
1035
+
1036
+ const tx = (pos.x - vectorState.pan.x) / vectorState.zoom;
1037
+ const ty = (pos.y - vectorState.pan.y) / vectorState.zoom;
1038
+
1039
+ let clickedNode = null;
1040
+ let minDistance = Infinity;
1041
+
1042
+ vectorState.chunks.forEach((chunk) => {
1043
+ if (!chunk.coords_2d) return;
1044
+ const cpos = mapToCanvas(
1045
+ chunk.coords_2d[0],
1046
+ chunk.coords_2d[1],
1047
+ width,
1048
+ height,
1049
+ );
1050
+ const dist = Math.hypot(cpos.x - tx, cpos.y - ty);
1051
+ if (dist < 12 && dist < minDistance) {
1052
+ minDistance = dist;
1053
+ clickedNode = chunk;
1054
+ }
1055
+ });
1056
+
1057
+ if (clickedNode) {
1058
+ selectChunk(clickedNode.id);
1059
+ } else {
1060
+ // Probe click to trigger Sonar RAG retrieval!
1061
+ triggerSonarProbe(tx, ty);
1062
+ }
1063
+ });
1064
+ }
1065
+
1066
+ function showTooltip(chunk, x, y) {
1067
+ const tooltip = dom.vectorTooltip;
1068
+ if (!tooltip) return;
1069
+
1070
+ const snippet =
1071
+ chunk.text.length > 80 ? chunk.text.slice(0, 80) + "..." : chunk.text;
1072
+ const coordsStr = `[${chunk.coords_2d[0].toFixed(2)}, ${chunk.coords_2d[1].toFixed(2)}]`;
1073
+
1074
+ tooltip.style.display = "block";
1075
+ tooltip.style.left = `${x + 15}px`;
1076
+ tooltip.style.top = `${y + 15}px`;
1077
+ tooltip.innerHTML = `
1078
+ <div class="vector-tooltip-title">
1079
+ <span>${chunk.id}</span>
1080
+ <span class="vector-tooltip-coords">${coordsStr}</span>
1081
+ </div>
1082
+ <div style="font-size: 0.65rem; color: var(--text-tertiary); margin-bottom: var(--space-xs);">
1083
+ ${chunk.token_count} tokens | ${chunk.text.length} chars
1084
+ </div>
1085
+ <div class="vector-tooltip-text">${escapeHtml(snippet)}</div>
1086
+ `;
1087
+ }
1088
+
1089
+ function hideTooltip() {
1090
+ const tooltip = dom.vectorTooltip;
1091
+ if (tooltip) tooltip.style.display = "none";
1092
+ }
1093
+
1094
+ function triggerSonarProbe(tx, ty) {
1095
+ const canvas = dom.vectorCanvas;
1096
+ const width = canvas.width / window.devicePixelRatio;
1097
+ const height = canvas.height / window.devicePixelRatio;
1098
+
1099
+ // Convert canvas position [tx, ty] back to UMAP [x, y] coordinates
1100
+ const padding = 50;
1101
+ const { minX, maxX, minY, maxY } = vectorState.bounds;
1102
+ const rangeX = maxX - minX || 1;
1103
+ const rangeY = maxY - minY || 1;
1104
+
1105
+ const pctX = (tx - padding) / (width - padding * 2);
1106
+ const pctY = (ty - padding) / (height - padding * 2);
1107
+
1108
+ const umapX = minX + pctX * rangeX;
1109
+ const umapY = minY + pctY * rangeY;
1110
+
1111
+ // Perform similarity distance in 2D space to prepare topK
1112
+ const scoredChunks = vectorState.chunks
1113
+ .map((chunk) => {
1114
+ if (!chunk.coords_2d) return { chunk, dist: Infinity };
1115
+ const dist = Math.hypot(
1116
+ chunk.coords_2d[0] - umapX,
1117
+ chunk.coords_2d[1] - umapY,
1118
+ );
1119
+ return { chunk, dist };
1120
+ })
1121
+ .sort((a, b) => a.dist - b.dist);
1122
+
1123
+ const topK = scoredChunks.slice(0, 3);
1124
+
1125
+ // Set active query crosshair
1126
+ vectorState.activeQuery = {
1127
+ x: umapX,
1128
+ y: umapY,
1129
+ tx: tx,
1130
+ ty: ty,
1131
+ pulseRadius: 0,
1132
+ label: "Sonar Probe",
1133
+ state: "loaded",
1134
+ topK: topK,
1135
+ };
1136
+
1137
+ // Render RAG results drawer
1138
+ dom.queryResultsDrawer.style.display = "block";
1139
+ dom.queryResultsList.innerHTML = topK
1140
+ .map((item, index) => {
1141
+ const similarity = Math.max(0, 1 - item.dist / 1.5).toFixed(2); // Normalised distance
1142
+ const snippet =
1143
+ item.chunk.text.length > 100
1144
+ ? item.chunk.text.slice(0, 100) + "..."
1145
+ : item.chunk.text;
1146
+ const rankClass = index === 0 ? "rank-1" : "";
1147
+ return `
1148
+ <div class="retrieved-chunk-card ${rankClass}" onclick="selectChunk('${item.chunk.id}')">
1149
+ <div class="retrieved-chunk-meta">
1150
+ <span class="retrieved-chunk-rank">Rank ${index + 1} (${item.chunk.id})</span>
1151
+ <span class="retrieved-chunk-score">Match Score: ${similarity}</span>
1152
+ </div>
1153
+ <div class="retrieved-chunk-text">${escapeHtml(snippet)}</div>
1154
+ </div>
1155
+ `;
1156
+ })
1157
+ .join("");
1158
+ }
1159
+
1160
+ async function runQuerySimulator() {
1161
+ const queryText = dom.queryInput.value.trim();
1162
+ if (!queryText || !state.results || vectorState.chunks.length === 0) return;
1163
+
1164
+ dom.btnQuery.disabled = true;
1165
+ dom.btnQuery.textContent = "⚡ Search...";
1166
+
1167
+ try {
1168
+ console.log("Running Query against ChromaDB:", queryText);
1169
+
1170
+ // Reset camera view so the user can see the entire map
1171
+ vectorState.zoom = 1.0;
1172
+ vectorState.pan = { x: 0, y: 0 };
1173
+
1174
+ // Setup active query skeleton state (fetching)
1175
+ // We start at [0,0] and will update the actual coordinates once the server responds
1176
+ vectorState.activeQuery = {
1177
+ x: 0,
1178
+ y: 0,
1179
+ tx: 0,
1180
+ ty: 0,
1181
+ pulseRadius: 0,
1182
+ label: `Query: "${queryText.substring(0, 15)}"`,
1183
+ state: "fetching",
1184
+ flowOffset: 0,
1185
+ topK: [],
1186
+ };
1187
+
1188
+ dom.queryResultsDrawer.style.display = "block";
1189
+ dom.queryResultsList.innerHTML = `
1190
+ <div class="skeleton-loader"></div>
1191
+ <div class="skeleton-loader"></div>
1192
+ <div class="skeleton-loader"></div>
1193
+ `;
1194
+
1195
+ // Make the actual API call to the backend
1196
+ const res = await fetch("/api/retrieve", {
1197
+ method: "POST", // Needs to be POST to send a JSON body!
1198
+ headers: { "Content-Type": "application/json" },
1199
+ body: JSON.stringify({
1200
+ search_text: queryText,
1201
+ embedding_model: state.config.embedding_model,
1202
+ strategy: state.strategy,
1203
+ top_k: 3,
1204
+ }),
1205
+ });
1206
+
1207
+ if (!res.ok) {
1208
+ throw new Error(`HTTP error! status: ${res.status}`);
1209
+ }
1210
+
1211
+ const data = await res.json();
1212
+
1213
+ // Parse the query coordinates returned by UMAP
1214
+ const targetX = data.query_coords[0];
1215
+ const targetY = data.query_coords[1];
1216
+
1217
+ const canvas = dom.vectorCanvas;
1218
+ const width = canvas.width / window.devicePixelRatio;
1219
+ const height = canvas.height / window.devicePixelRatio;
1220
+
1221
+ // Dynamically expand bounds to fit the query dot if it lands far away
1222
+ if (vectorState.chunks.length > 0) {
1223
+ vectorState.bounds.minX = Math.min(vectorState.bounds.minX, targetX);
1224
+ vectorState.bounds.maxX = Math.max(vectorState.bounds.maxX, targetX);
1225
+ vectorState.bounds.minY = Math.min(vectorState.bounds.minY, targetY);
1226
+ vectorState.bounds.maxY = Math.max(vectorState.bounds.maxY, targetY);
1227
+ }
1228
+
1229
+ const screenPos = mapToCanvas(targetX, targetY, width, height);
1230
+
1231
+ // Link the retrieved results back to the frontend chunks for drawing lines
1232
+ const topK = data.results.map((result) => {
1233
+ // Find the corresponding chunk in the frontend's loaded chunks
1234
+ const chunk = vectorState.chunks.find((c) => c.id === result.id) || {
1235
+ id: result.id,
1236
+ text: result.text,
1237
+ coords_2d: null, // Fallback if chunk somehow isn't loaded
1238
+ };
1239
+ return {
1240
+ chunk: chunk,
1241
+ dist: result.score,
1242
+ text_highlighted: result.text_highlighted,
1243
+ };
1244
+ });
1245
+
1246
+ // Update the active query with the final data
1247
+ vectorState.activeQuery = {
1248
+ x: targetX,
1249
+ y: targetY,
1250
+ tx: screenPos.x,
1251
+ ty: screenPos.y,
1252
+ pulseRadius: 0,
1253
+ label: `Query: "${queryText.substring(0, 15)}"`,
1254
+ state: "loaded",
1255
+ flowOffset: 0,
1256
+ topK: topK,
1257
+ };
1258
+
1259
+ // Render results in the drawer
1260
+ dom.queryResultsList.innerHTML = topK
1261
+ .map((item, index) => {
1262
+ return `
1263
+ <div class="query-result-item" style="border-left-color: ${index === 0 ? "var(--warning-color)" : "var(--info-color)"}">
1264
+ <div class="query-result-header">
1265
+ <span class="rank-badge" style="color: ${index === 0 ? "var(--warning-color)" : "var(--info-color)"}">Rank ${index + 1} (${item.chunk.id})</span>
1266
+ <span class="dist-badge">Dist: ${item.dist.toFixed(3)}</span>
1267
+ </div>
1268
+ <div class="query-result-text">${item.text_highlighted || escapeHtml(item.chunk.text.substring(0, 100)) + "..."}</div>
1269
+ </div>
1270
+ `;
1271
+ })
1272
+ .join("");
1273
+
1274
+ // --- TASK 3.4: X-RAY DOCUMENT HIGHLIGHTING ---
1275
+ // 1. Activate the search mode on the X-Ray text to dim non-retrieved chunks
1276
+ dom.xrayText.classList.add("search-active");
1277
+
1278
+ // 2. Clear any previous search rankings
1279
+ dom.xrayText.querySelectorAll(".chunk-highlight").forEach((el) => {
1280
+ el.classList.remove(
1281
+ "retrieved-rank-1",
1282
+ "retrieved-rank-2",
1283
+ "retrieved-rank-3",
1284
+ );
1285
+ });
1286
+
1287
+ // 3. Highlight the new Top-K chunks in the Document Viewer
1288
+ topK.forEach((item, index) => {
1289
+ const rank = index + 1;
1290
+ const chunkId = item.chunk.id;
1291
+ const span = dom.xrayText.querySelector(`[data-chunk-id="${chunkId}"]`);
1292
+
1293
+ if (span) {
1294
+ span.classList.add(`retrieved-rank-${rank}`);
1295
+ // If it's the #1 hit, scroll the Document Viewer straight to it!
1296
+ if (rank === 1) {
1297
+ span.scrollIntoView({ behavior: "smooth", block: "center" });
1298
+ }
1299
+ }
1300
+ });
1301
+ // ----------------------------------------------
1302
+ } catch (err) {
1303
+ console.error("Query failed:", err);
1304
+ dom.queryResultsList.innerHTML = `<div style="color: #ef4444; padding: 1rem;">Failed to fetch results. Check console.</div>`;
1305
+ } finally {
1306
+ dom.btnQuery.disabled = false;
1307
+ dom.btnQuery.innerHTML = "🔍 Query";
1308
+ }
1309
+ }
1310
+
1311
+ // Bind Query events
1312
+ if (dom.btnQuery) {
1313
+ dom.btnQuery.addEventListener("click", runQuerySimulator);
1314
+ }
1315
+ if (dom.queryInput) {
1316
+ dom.queryInput.addEventListener("keydown", (e) => {
1317
+ if (e.key === "Enter") {
1318
+ runQuerySimulator();
1319
+ }
1320
+ });
1321
+ }
1322
+ if (dom.closeDrawer) {
1323
+ dom.closeDrawer.addEventListener("click", () => {
1324
+ dom.queryResultsDrawer.style.display = "none";
1325
+ });
1326
+ }
1327
+
1328
+ // ============================================================
1329
+ // TASK 3.5: ARENA COMPARISON LOGIC
1330
+ // ============================================================
1331
+ const domArena = {
1332
+ modal: document.getElementById("arena-modal"),
1333
+ btnOpen: document.getElementById("btn-open-arena"),
1334
+ btnClose: document.getElementById("btn-close-arena"),
1335
+ btnFight: document.getElementById("btn-arena-fight"),
1336
+ queryInput: document.getElementById("arena-query-input"),
1337
+ modelA: document.getElementById("arena-model-a"),
1338
+ strategyA: document.getElementById("arena-strategy-a"),
1339
+ resultsA: document.getElementById("arena-results-a"),
1340
+ modelB: document.getElementById("arena-model-b"),
1341
+ strategyB: document.getElementById("arena-strategy-b"),
1342
+ resultsB: document.getElementById("arena-results-b"),
1343
+ };
1344
+
1345
+ if (domArena.btnOpen) {
1346
+ domArena.btnOpen.addEventListener("click", () => {
1347
+ domArena.modal.style.display = "flex";
1348
+ if (dom.queryInput && dom.queryInput.value) {
1349
+ domArena.queryInput.value = dom.queryInput.value;
1350
+ }
1351
+ });
1352
+ }
1353
+
1354
+ if (domArena.btnClose) {
1355
+ domArena.btnClose.addEventListener("click", () => {
1356
+ domArena.modal.style.display = "none";
1357
+ });
1358
+ }
1359
+
1360
+ if (domArena.btnFight) {
1361
+ domArena.btnFight.addEventListener("click", async () => {
1362
+ const query = domArena.queryInput.value.trim();
1363
+ if (!query) return;
1364
+
1365
+ domArena.btnFight.disabled = true;
1366
+ domArena.btnFight.textContent = "FIGHTING...";
1367
+
1368
+ // Reset AI Referee if active
1369
+ if (typeof domReferee !== "undefined" && domReferee.panel) {
1370
+ domReferee.panel.style.display = "none";
1371
+ domReferee.verdictBox.style.display = "none";
1372
+ domReferee.loadingBox.style.display = "none";
1373
+ domReferee.triggerBox.style.display = "block";
1374
+ }
1375
+
1376
+ domArena.resultsA.innerHTML =
1377
+ '<div class="skeleton-loader"></div><div class="skeleton-loader"></div>';
1378
+ domArena.resultsB.innerHTML =
1379
+ '<div class="skeleton-loader"></div><div class="skeleton-loader"></div>';
1380
+
1381
+ try {
1382
+ const res = await fetch("/api/compare", {
1383
+ method: "POST",
1384
+ headers: { "Content-Type": "application/json" },
1385
+ body: JSON.stringify({
1386
+ search_text: query,
1387
+ top_k: 3,
1388
+ model_a: domArena.modelA.value,
1389
+ strategy_a: domArena.strategyA.value,
1390
+ model_b: domArena.modelB.value,
1391
+ strategy_b: domArena.strategyB.value,
1392
+ }),
1393
+ });
1394
+
1395
+ if (!res.ok)
1396
+ throw new Error(
1397
+ "Comparison failed. Check if server is running and /api/compare exists.",
1398
+ );
1399
+
1400
+ const data = await res.json();
1401
+
1402
+ // Render Column A
1403
+ domArena.resultsA.innerHTML = data.results_a
1404
+ .map(
1405
+ (chunk, i) => `
1406
+ <div class="query-result-item" style="border-left-color: var(--warning-color)">
1407
+ <div class="query-result-header">
1408
+ <span class="rank-badge" style="color: var(--warning-color)">Rank ${i + 1}</span>
1409
+ <span class="dist-badge">Dist: ${chunk.score.toFixed(3)}</span>
1410
+ </div>
1411
+ <div class="query-result-text">${chunk.text_highlighted || escapeHtml(chunk.text)}</div>
1412
+ </div>
1413
+ `,
1414
+ )
1415
+ .join("");
1416
+
1417
+ // Render Column B
1418
+ domArena.resultsB.innerHTML = data.results_b
1419
+ .map(
1420
+ (chunk, i) => `
1421
+ <div class="query-result-item" style="border-left-color: var(--info-color)">
1422
+ <div class="query-result-header">
1423
+ <span class="rank-badge" style="color: var(--info-color)">Rank ${i + 1}</span>
1424
+ <span class="dist-badge">Dist: ${chunk.score.toFixed(3)}</span>
1425
+ </div>
1426
+ <div class="query-result-text">${chunk.text_highlighted || escapeHtml(chunk.text)}</div>
1427
+ </div>
1428
+ `,
1429
+ )
1430
+ .join("");
1431
+
1432
+ // Activate AI Referee if both sides retrieved chunks
1433
+ if (
1434
+ typeof domReferee !== "undefined" &&
1435
+ domReferee.panel &&
1436
+ data.results_a.length > 0 &&
1437
+ data.results_b.length > 0
1438
+ ) {
1439
+ domReferee.panel.style.display = "block";
1440
+ domReferee.btnCall.dataset.query = query;
1441
+ domReferee.btnCall.dataset.chunkA = data.results_a[0].text;
1442
+ domReferee.btnCall.dataset.chunkB = data.results_b[0].text;
1443
+ }
1444
+ } catch (err) {
1445
+ console.error(err);
1446
+ domArena.resultsA.innerHTML = `<div style="color: var(--superman-red); padding: 1rem;">Error: ${err.message}</div>`;
1447
+ domArena.resultsB.innerHTML = `<div style="color: var(--superman-red); padding: 1rem;">Error: ${err.message}</div>`;
1448
+ } finally {
1449
+ domArena.btnFight.disabled = false;
1450
+ domArena.btnFight.textContent = "🔥 FIGHT!";
1451
+ }
1452
+ });
1453
+ }
1454
+
1455
+ // ============================================================
1456
+ // TASK 5.1: AI REFEREE / LLM-AS-A-JUDGE LOGIC
1457
+ // ============================================================
1458
+ const domReferee = {
1459
+ panel: document.getElementById("arena-referee-panel"),
1460
+ triggerBox: document.getElementById("referee-trigger-box"),
1461
+ btnCall: document.getElementById("btn-call-referee"),
1462
+ loadingBox: document.getElementById("referee-loading-box"),
1463
+ statusText: document.getElementById("referee-status-text"),
1464
+ verdictBox: document.getElementById("referee-verdict-box"),
1465
+ };
1466
+
1467
+ if (domReferee.btnCall) {
1468
+ domReferee.btnCall.addEventListener("click", async () => {
1469
+ const query = domReferee.btnCall.dataset.query;
1470
+ const chunkA = domReferee.btnCall.dataset.chunkA;
1471
+ const chunkB = domReferee.btnCall.dataset.chunkB;
1472
+ if (!query || !chunkA || !chunkB) return;
1473
+
1474
+ // Capture user-selected model names for clean revealed scorecard display
1475
+ const labelA = domArena.modelA.options[domArena.modelA.selectedIndex].text;
1476
+ const labelB = domArena.modelB.options[domArena.modelB.selectedIndex].text;
1477
+
1478
+ domReferee.triggerBox.style.display = "none";
1479
+ domReferee.loadingBox.style.display = "block";
1480
+ domReferee.verdictBox.style.display = "none";
1481
+
1482
+ const statuses = [
1483
+ "⚖️ Summoning Gemma evaluation referee...",
1484
+ "🔍 Blinding model names and metadata...",
1485
+ "🧠 Auditing Corner A relevance and clarity...",
1486
+ "🧠 Auditing Corner B completeness and truthfulness...",
1487
+ "📊 Aggregating overall dimensional scores...",
1488
+ "✍️ Draft verdict and writing final card...",
1489
+ ];
1490
+ let statusIdx = 0;
1491
+ domReferee.statusText.textContent = statuses[0];
1492
+ const statusInterval = setInterval(() => {
1493
+ statusIdx = (statusIdx + 1) % statuses.length;
1494
+ domReferee.statusText.textContent = statuses[statusIdx];
1495
+ }, 1500);
1496
+
1497
+ try {
1498
+ const res = await fetch("/api/judge", {
1499
+ method: "POST",
1500
+ headers: { "Content-Type": "application/json" },
1501
+ body: JSON.stringify({
1502
+ search_query: query,
1503
+ chunk_a: chunkA,
1504
+ chunk_b: chunkB,
1505
+ }),
1506
+ });
1507
+
1508
+ if (!res.ok)
1509
+ throw new Error(
1510
+ "Gemma evaluation failed. Check if local Ollama server is running.",
1511
+ );
1512
+
1513
+ const data = await res.json();
1514
+ clearInterval(statusInterval);
1515
+
1516
+ renderRefereeScorecard(data, labelA, labelB);
1517
+ } catch (err) {
1518
+ clearInterval(statusInterval);
1519
+ console.error(err);
1520
+ domReferee.verdictBox.innerHTML = `
1521
+ <div style="color: var(--superman-red); text-align: center; padding: 1.5rem; border: 1px solid var(--superman-red-glow); border-radius: 6px; background-color: var(--superman-red-muted);">
1522
+ <strong>Referee Error:</strong> ${err.message}
1523
+ </div>
1524
+ `;
1525
+ domReferee.verdictBox.style.display = "block";
1526
+ } finally {
1527
+ domReferee.loadingBox.style.display = "none";
1528
+ }
1529
+ });
1530
+ }
1531
+
1532
+ function renderRefereeScorecard(data, labelA, labelB) {
1533
+ const isA = data.winner === "chunk_a";
1534
+ const isB = data.winner === "chunk_b";
1535
+ const isTie = data.winner === "tie";
1536
+
1537
+ let winnerColor = "var(--superman-yellow)";
1538
+ let winnerBanner = "⚖️ IT'S A TIE";
1539
+ if (isA) {
1540
+ winnerColor = "var(--superman-yellow)";
1541
+ winnerBanner = `🏆 ${labelA.toUpperCase()} WINS`;
1542
+ } else if (isB) {
1543
+ winnerColor = "var(--superman-blue)";
1544
+ winnerBanner = `🏆 ${labelB.toUpperCase()} WINS`;
1545
+ }
1546
+
1547
+ // Dynamically replace blinded chunk labels with actual bolded model names
1548
+ const boldLabelA = `<strong>${labelA}</strong>`;
1549
+ const boldLabelB = `<strong>${labelB}</strong>`;
1550
+
1551
+ let cleanedReason = data.winner_reason.trim();
1552
+ // Strip leading/trailing quote characters
1553
+ cleanedReason = cleanedReason
1554
+ .replace(/^["']/, "")
1555
+ .replace(/["']$/, "")
1556
+ .trim();
1557
+ // Collapse any newlines or multiple whitespace characters to a single space for single-line display
1558
+ cleanedReason = cleanedReason.replace(/\r?\n|\r/g, " ").replace(/\s+/g, " ");
1559
+
1560
+ cleanedReason = cleanedReason
1561
+ .replace(/\b(Chunk A|chunk_a|chunk A)\b/g, boldLabelA)
1562
+ .replace(/\b(Chunk B|chunk_b|chunk B)\b/g, boldLabelB);
1563
+
1564
+ const scoreCardHtml = `
1565
+ <div class="verdict-banner" style="background-color: ${winnerColor}-muted; border-color: ${winnerColor};">
1566
+ <div class="verdict-title" style="color: ${winnerColor}">${winnerBanner}</div>
1567
+ <div class="verdict-confidence">Confidence: ${(data.confidence * 100).toFixed(0)}%</div>
1568
+ </div>
1569
+
1570
+ <div class="verdict-reason">
1571
+ <strong>Verdict Reason:</strong>
1572
+ <p>${cleanedReason}</p>
1573
+ <div class="deciding-factor">Deciding Factor: <code>${data.deciding_dimension}</code></div>
1574
+ </div>
1575
+
1576
+ <!-- Comparative Table of dimensions -->
1577
+ <div class="referee-scores-table">
1578
+ <div class="scores-table-header">
1579
+ <span class="score-col-label">Criterion</span>
1580
+ <span class="score-col-val" style="font-size: 0.65rem; color: var(--superman-yellow);">${labelA}</span>
1581
+ <span class="score-col-bar">Comparison</span>
1582
+ <span class="score-col-val" style="font-size: 0.65rem; color: var(--superman-blue);">${labelB}</span>
1583
+ </div>
1584
+
1585
+ ${renderScoreRow("Query Relevance", data.chunk_a_score.query_relevance, data.chunk_b_score.query_relevance)}
1586
+ ${renderScoreRow("Completeness", data.chunk_a_score.answer_completeness, data.chunk_b_score.answer_completeness)}
1587
+ ${renderScoreRow("Plausibility", data.chunk_a_score.factual_plausibility, data.chunk_b_score.factual_plausibility)}
1588
+ ${renderScoreRow("Clarity & Style", data.chunk_a_score.clarity, data.chunk_b_score.clarity)}
1589
+
1590
+ <div class="scores-table-row overall-row">
1591
+ <span class="score-col-label"><strong>Overall Average</strong></span>
1592
+ <span class="score-col-val" style="color: var(--superman-yellow)"><strong>${data.chunk_a_score.overall.toFixed(2)}</strong></span>
1593
+ <span class="score-col-bar">
1594
+ <div class="overall-progress-bar">
1595
+ <div class="overall-val-a" style="width: ${data.chunk_a_score.overall * 10}%"></div>
1596
+ <div class="overall-val-b" style="width: ${data.chunk_b_score.overall * 10}%"></div>
1597
+ </div>
1598
+ </span>
1599
+ <span class="score-col-val" style="color: var(--superman-blue)"><strong>${data.chunk_b_score.overall.toFixed(2)}</strong></span>
1600
+ </div>
1601
+ </div>
1602
+
1603
+ <!-- Strengths & Weaknesses Column -->
1604
+ <div class="verdict-details-grid">
1605
+ <div class="verdict-details-col">
1606
+ <h4>${labelA} Analysis</h4>
1607
+ <div class="verdict-list-title strength-list-title">🟢 Strengths</div>
1608
+ <ul>
1609
+ ${data.chunk_a_strengths.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}
1610
+ </ul>
1611
+ <div class="verdict-list-title weakness-list-title">🔴 Weaknesses</div>
1612
+ <ul>
1613
+ ${data.chunk_a_weaknesses.map((w) => `<li>${escapeHtml(w)}</li>`).join("")}
1614
+ </ul>
1615
+ </div>
1616
+
1617
+ <div class="verdict-details-col">
1618
+ <h4>${labelB} Analysis</h4>
1619
+ <div class="verdict-list-title strength-list-title">🟢 Strengths</div>
1620
+ <ul>
1621
+ ${data.chunk_b_strengths.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}
1622
+ </ul>
1623
+ <div class="verdict-list-title weakness-list-title">🔴 Weaknesses</div>
1624
+ <ul>
1625
+ ${data.chunk_b_weaknesses.map((w) => `<li>${escapeHtml(w)}</li>`).join("")}
1626
+ </ul>
1627
+ </div>
1628
+ </div>
1629
+ `;
1630
+
1631
+ domReferee.verdictBox.innerHTML = scoreCardHtml;
1632
+ domReferee.verdictBox.style.display = "block";
1633
+ }
1634
+
1635
+ function renderScoreRow(label, valA, valB) {
1636
+ const pctA = valA * 10;
1637
+ const pctB = valB * 10;
1638
+
1639
+ return `
1640
+ <div class="scores-table-row">
1641
+ <span class="score-col-label">${label}</span>
1642
+ <span class="score-col-val">${valA}/10</span>
1643
+ <span class="score-col-bar">
1644
+ <div class="score-progress-bar">
1645
+ <div class="score-val-a" style="width: ${pctA}%"></div>
1646
+ <div class="score-val-b" style="width: ${pctB}%"></div>
1647
+ </div>
1648
+ </span>
1649
+ <span class="score-col-val">${valB}/10</span>
1650
+ </div>
1651
+ `;
1652
+ }
frontend/index.html ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="RAG Visualizer — An X-Ray machine for RAG pipelines. Visualize and compare chunking strategies." />
7
+ <title>RAG Visualizer — Chunking Lab</title>
8
+ <link rel="stylesheet" href="/static/styles.css" />
9
+ </head>
10
+ <body>
11
+ <div class="app-wrapper">
12
+
13
+ <!-- ========== HEADER ========== -->
14
+ <header class="app-header">
15
+ <div class="app-logo">
16
+ <div class="app-logo-icon">🔬</div>
17
+ <div class="app-logo-text">RAG <span>Visualizer</span></div>
18
+ </div>
19
+ </header>
20
+
21
+ <!-- ========== MAIN 3-COLUMN LAYOUT ========== -->
22
+ <main class="app-main">
23
+
24
+ <!-- ===== LEFT PANEL: Configuration ===== -->
25
+ <aside class="panel" id="config-panel">
26
+ <div class="panel-header">
27
+ <h2><span class="icon">⚙️</span> Configuration</h2>
28
+ </div>
29
+ <div class="panel-body">
30
+
31
+ <!-- Text Input -->
32
+ <div class="config-section">
33
+ <div class="config-section-title">Input Text</div>
34
+ <div class="text-input-wrapper">
35
+ <textarea
36
+ id="text-input"
37
+ class="text-input"
38
+ placeholder="Paste your text here to visualize chunking strategies..."
39
+ spellcheck="false"
40
+ ></textarea>
41
+ <span class="char-count" id="char-count">0 chars</span>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Strategy Selector -->
46
+ <div class="config-section">
47
+ <div class="config-section-title">Chunking Strategy</div>
48
+ <div class="strategy-grid" id="strategy-grid">
49
+ <label class="strategy-card active" data-strategy="fixed_size">
50
+ <input type="radio" name="strategy" value="fixed_size" checked />
51
+ <div class="strategy-card-icon">📏</div>
52
+ <div class="strategy-card-name">Fixed Size</div>
53
+ <div class="strategy-card-desc">Cut every N tokens</div>
54
+ </label>
55
+ <label class="strategy-card" data-strategy="sentence">
56
+ <input type="radio" name="strategy" value="sentence" />
57
+ <div class="strategy-card-icon">💬</div>
58
+ <div class="strategy-card-name">Sentence</div>
59
+ <div class="strategy-card-desc">Split on boundaries</div>
60
+ </label>
61
+ <label class="strategy-card" data-strategy="recursive">
62
+ <input type="radio" name="strategy" value="recursive" />
63
+ <div class="strategy-card-icon">🔄</div>
64
+ <div class="strategy-card-name">Recursive</div>
65
+ <div class="strategy-card-desc">Hierarchy of separators</div>
66
+ </label>
67
+ <label class="strategy-card" data-strategy="parent_child">
68
+ <input type="radio" name="strategy" value="parent_child" />
69
+ <div class="strategy-card-icon">🌳</div>
70
+ <div class="strategy-card-name">Parent-Child</div>
71
+ <div class="strategy-card-desc">Nested chunking</div>
72
+ </label>
73
+ <label class="strategy-card" data-strategy="semantic">
74
+ <input type="radio" name="strategy" value="semantic" />
75
+ <div class="strategy-card-icon">🧠</div>
76
+ <div class="strategy-card-name">Semantic</div>
77
+ <div class="strategy-card-desc">Split on topic shifts</div>
78
+ </label>
79
+ </div>
80
+ </div>
81
+
82
+ <!-- Standard Config Sliders -->
83
+ <div class="config-section" id="standard-config">
84
+ <div class="config-section-title">Parameters</div>
85
+ <div class="config-field">
86
+ <label>
87
+ Chunk Size (tokens)
88
+ <span class="value-badge" id="chunk-size-value">500</span>
89
+ </label>
90
+ <input type="range" id="chunk-size" min="50" max="2000" step="50" value="500" />
91
+ </div>
92
+ <div class="config-field">
93
+ <label>
94
+ Overlap (tokens)
95
+ <span class="value-badge" id="overlap-value">20</span>
96
+ </label>
97
+ <input type="range" id="overlap" min="0" max="500" step="10" value="20" />
98
+ </div>
99
+ </div>
100
+
101
+ <!-- Parent-Child Config (Hidden by default) -->
102
+ <div class="parent-child-config" id="parent-child-config">
103
+ <div class="pc-section-label parent">🔵 Parent Config</div>
104
+ <div class="config-field">
105
+ <label>
106
+ Parent Chunk Size
107
+ <span class="value-badge" id="parent-size-value">1000</span>
108
+ </label>
109
+ <input type="range" id="parent-size" min="100" max="4000" step="100" value="1000" />
110
+ </div>
111
+ <div class="config-field">
112
+ <label>
113
+ Parent Overlap
114
+ <span class="value-badge" id="parent-overlap-value">100</span>
115
+ </label>
116
+ <input type="range" id="parent-overlap" min="0" max="500" step="10" value="100" />
117
+ </div>
118
+ <div class="pc-section-label child">🟢 Child Config</div>
119
+ <div class="config-field">
120
+ <label>
121
+ Child Chunk Size
122
+ <span class="value-badge" id="child-size-value">200</span>
123
+ </label>
124
+ <input type="range" id="child-size" min="50" max="1000" step="50" value="200" />
125
+ </div>
126
+ <div class="config-field">
127
+ <label>
128
+ Child Overlap
129
+ <span class="value-badge" id="child-overlap-value">20</span>
130
+ </label>
131
+ <input type="range" id="child-overlap" min="0" max="200" step="10" value="20" />
132
+ </div>
133
+ </div>
134
+
135
+ <!-- Semantic Config (Hidden by default) -->
136
+ <div class="config-section" id="semantic-config" style="display: none;">
137
+ <div class="config-section-title">Semantic Settings</div>
138
+ <div class="config-field">
139
+ <label>
140
+ Similarity Threshold
141
+ <span class="value-badge" id="semantic-threshold-value">0.50</span>
142
+ </label>
143
+ <input type="range" id="semantic-threshold" min="0.1" max="1.0" step="0.05" value="0.5" />
144
+ </div>
145
+ </div>
146
+
147
+ <!-- Separators (for recursive) -->
148
+ <div class="config-section" id="separators-section" style="display: none;">
149
+ <div class="config-section-title">Separators</div>
150
+ <div class="separator-tags" id="separator-tags">
151
+ <span class="separator-tag" data-sep="\n\n"><code>\\n\\n</code><span class="remove">&times;</span></span>
152
+ <span class="separator-tag" data-sep="\n"><code>\\n</code><span class="remove">&times;</span></span>
153
+ <span class="separator-tag" data-sep=". "><code>. </code><span class="remove">&times;</span></span>
154
+ <span class="separator-tag" data-sep=" "><code>⎵</code><span class="remove">&times;</span></span>
155
+ <input type="text" class="separator-input" id="separator-input" placeholder="Add..." />
156
+ </div>
157
+ </div>
158
+
159
+ <!-- Embedding & Vector Space Config -->
160
+ <div class="config-section">
161
+ <div class="config-section-title">Vector Space Settings</div>
162
+
163
+ <div class="config-field">
164
+ <label for="embedding-model">Embedding Model</label>
165
+ <select id="embedding-model" class="select-input">
166
+ <option value="nomic-embed-text" selected>Nomic Embed Text</option>
167
+ <option value="EmbeddingGemma">Embedding Gemma</option>
168
+ <option value="qwen3-embedding:0.6b">Qwen3 Embedding</option>
169
+ </select>
170
+ </div>
171
+
172
+ <div class="config-field">
173
+ <label>
174
+ UMAP n_neighbors
175
+ <span class="value-badge" id="n-neighbors-value">15</span>
176
+ </label>
177
+ <input type="range" id="n-neighbors" min="2" max="100" step="1" value="15" />
178
+ </div>
179
+
180
+ <div class="config-field">
181
+ <label>
182
+ UMAP min_dist
183
+ <span class="value-badge" id="min-dist-value">0.10</span>
184
+ </label>
185
+ <input type="range" id="min-dist" min="0.01" max="1.0" step="0.05" value="0.1" />
186
+ </div>
187
+ </div>
188
+
189
+ <!-- Run Button -->
190
+ <button class="btn-run ready" id="btn-run">
191
+ <span class="spinner"></span>
192
+ <span class="btn-text">⚡ Run Chunking</span>
193
+ </button>
194
+
195
+ </div>
196
+ </aside>
197
+
198
+ <!-- ===== CENTER PANEL: X-Ray Text Viewer & Vector Space ===== -->
199
+ <section class="panel xray-viewer" id="xray-panel">
200
+ <div class="panel-header tabs-header">
201
+ <div class="tab-buttons">
202
+ <button class="tab-btn active" data-tab="xray-tab">
203
+ <span class="icon">🔍</span> Document Viewer
204
+ </button>
205
+ <button class="tab-btn" data-tab="vector-tab">
206
+ <span class="icon">🌌</span> Vector Space 2D
207
+ </button>
208
+ </div>
209
+ </div>
210
+
211
+ <!-- Tab Content 1: X-Ray Text Viewer -->
212
+ <div class="panel-body tab-content active" id="xray-tab">
213
+ <div class="empty-state" id="xray-empty">
214
+ <div class="empty-state-icon">🔬</div>
215
+ <div class="empty-state-title">Ready to Analyze</div>
216
+ <div class="empty-state-desc">
217
+ Paste your text, choose a chunking strategy, and hit <strong>Run</strong> to see how your text gets sliced.
218
+ </div>
219
+ </div>
220
+ <div class="xray-text-container" id="xray-text" style="display: none;"></div>
221
+ </div>
222
+
223
+ <!-- Tab Content 2: Vector Space 2D -->
224
+ <div class="panel-body tab-content" id="vector-tab" style="display: none;">
225
+ <div class="vector-space-container">
226
+ <div class="canvas-wrapper">
227
+ <canvas id="vector-canvas"></canvas>
228
+ <!-- Floating Tooltip inside canvas -->
229
+ <div id="vector-tooltip" class="vector-tooltip" style="display: none;"></div>
230
+
231
+ <!-- Help overlay for canvas controls -->
232
+ <div class="canvas-help-overlay">
233
+ <span>🖱️ Drag to Pan</span>
234
+ <span>☸️ Scroll to Zoom</span>
235
+ <span>✨ Click node to select</span>
236
+ </div>
237
+ </div>
238
+
239
+ <!-- Sonar Query Simulator Section -->
240
+ <div class="query-simulator-section">
241
+ <div class="query-section-title" style="display: flex; justify-content: space-between; align-items: center;">
242
+ <span>🔮 Sonar Query Simulator (RAG Retrieval)</span>
243
+ <button id="btn-open-arena" class="btn-sm" style="background: var(--superman-red); color: white; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-weight: bold; font-family: var(--font-sans);">⚔️ Arena Comparison</button>
244
+ </div>
245
+ <div class="query-input-wrapper">
246
+ <input type="text" id="query-input" placeholder="Type a concept query to fetch semantically similar chunks (e.g. 'linear regression')..." />
247
+ <button id="btn-query" class="btn-query">🔍 Query</button>
248
+ </div>
249
+ <div class="query-results-drawer" id="query-results-drawer" style="display: none;">
250
+ <div class="drawer-header">
251
+ <h3>Retrieved Context</h3>
252
+ <span class="close-drawer" id="close-drawer">&times;</span>
253
+ </div>
254
+ <div class="query-results-list" id="query-results-list">
255
+ <!-- Retrieved chunks will go here -->
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </div>
260
+ </div>
261
+ </section>
262
+
263
+ <!-- ===== RIGHT PANEL: Chunk Inspector ===== -->
264
+ <aside class="panel" id="inspector-panel">
265
+ <div class="panel-header">
266
+ <h2><span class="icon">📊</span> Chunk Inspector</h2>
267
+ </div>
268
+ <div class="panel-body">
269
+ <!-- Stats -->
270
+ <div class="stats-grid" id="stats-grid">
271
+ <div class="stat-card">
272
+ <div class="stat-value" id="stat-total">—</div>
273
+ <div class="stat-label">Total Chunks</div>
274
+ </div>
275
+ <div class="stat-card">
276
+ <div class="stat-value" id="stat-avg-tokens">—</div>
277
+ <div class="stat-label">Avg Tokens</div>
278
+ </div>
279
+ <div class="stat-card">
280
+ <div class="stat-value" id="stat-total-tokens">—</div>
281
+ <div class="stat-label">Total Tokens</div>
282
+ </div>
283
+ <div class="stat-card">
284
+ <div class="stat-value" id="stat-strategy">—</div>
285
+ <div class="stat-label">Strategy</div>
286
+ </div>
287
+ </div>
288
+
289
+ <!-- Chunk List -->
290
+ <div class="config-section-title">Chunks</div>
291
+ <div class="chunk-list" id="chunk-list">
292
+ <div class="empty-state" style="min-height: 200px;">
293
+ <div class="empty-state-desc">Run chunking to see results here</div>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ </aside>
298
+
299
+ <!-- ===== THE ARENA MODAL ===== -->
300
+ <div id="arena-modal" class="arena-modal-overlay" style="display: none;">
301
+ <div class="arena-modal-content">
302
+ <div class="arena-header">
303
+ <h2>⚔️ The Grand Comparison Arena</h2>
304
+ <button id="btn-close-arena" class="btn-close-arena">&times;</button>
305
+ </div>
306
+
307
+ <div class="arena-query-bar">
308
+ <input type="text" id="arena-query-input" placeholder="Type a query to compare configurations (e.g., 'What is Clark Kent's weakness?')..." />
309
+ <button class="btn-run" id="btn-arena-fight" style="width: 150px; flex-shrink: 0;">🔥 FIGHT!</button>
310
+ </div>
311
+
312
+ <div class="arena-body">
313
+ <div class="arena-columns">
314
+ <!-- COLUMN A -->
315
+ <div class="arena-col">
316
+ <div class="arena-col-header">
317
+ <h3>Corner A</h3>
318
+ <div class="arena-config-row">
319
+ <select id="arena-model-a" class="select-input">
320
+ <option value="nomic-embed-text">Nomic Embed</option>
321
+ <option value="EmbeddingGemma">Gemma Embed</option>
322
+ <option value="qwen3-embedding:0.6b">Qwen3 Embed</option>
323
+ </select>
324
+ <select id="arena-strategy-a" class="select-input">
325
+ <option value="fixed_size">Fixed Size</option>
326
+ <option value="sentence">Sentence</option>
327
+ <option value="recursive">Recursive</option>
328
+ <option value="semantic">Semantic</option>
329
+ </select>
330
+ </div>
331
+ </div>
332
+ <div class="arena-results-list" id="arena-results-a">
333
+ <div style="text-align: center; color: var(--text-tertiary); padding: 2rem;">Awaiting challengers...</div>
334
+ </div>
335
+ </div>
336
+
337
+ <!-- COLUMN B -->
338
+ <div class="arena-col">
339
+ <div class="arena-col-header" style="border-bottom-color: var(--info-color);">
340
+ <h3>Corner B</h3>
341
+ <div class="arena-config-row">
342
+ <select id="arena-model-b" class="select-input">
343
+ <option value="nomic-embed-text">Nomic Embed</option>
344
+ <option value="EmbeddingGemma">Gemma Embed</option>
345
+ <option value="qwen3-embedding:0.6b">Qwen3 Embed</option>
346
+ </select>
347
+ <select id="arena-strategy-b" class="select-input">
348
+ <option value="fixed_size">Fixed Size</option>
349
+ <option value="sentence">Sentence</option>
350
+ <option value="recursive">Recursive</option>
351
+ <option value="semantic">Semantic</option>
352
+ </select>
353
+ </div>
354
+ </div>
355
+ <div class="arena-results-list" id="arena-results-b">
356
+ <div style="text-align: center; color: var(--text-tertiary); padding: 2rem;">Awaiting challengers...</div>
357
+ </div>
358
+ </div>
359
+ </div>
360
+
361
+ <!-- THE AI REFEREE PANEL (TASK 5.1) -->
362
+ <div class="arena-referee-panel" id="arena-referee-panel" style="display: none;">
363
+ <div class="referee-divider">
364
+ <span class="referee-badge">⚖️ THE AI REFEREE</span>
365
+ </div>
366
+
367
+ <div class="referee-trigger-box" id="referee-trigger-box">
368
+ <p class="referee-prompt-text">Call upon the local Gemma model to analyze, score, and judge both contexts!</p>
369
+ <button class="btn-run" id="btn-call-referee" style="margin: 0 auto; display: block; max-width: 250px;">⚖️ CALL THE REFEREE</button>
370
+ </div>
371
+
372
+ <!-- Loading scanner status -->
373
+ <div class="referee-loading-box" id="referee-loading-box" style="display: none;">
374
+ <div class="skeleton-loader" style="max-width: 400px; margin: 0 auto 1rem; border-radius: 6px;"></div>
375
+ <div class="referee-status-text" id="referee-status-text">Summoning Gemma to review the case...</div>
376
+ </div>
377
+
378
+ <!-- Scorecard Verdict Display -->
379
+ <div class="referee-verdict-box" id="referee-verdict-box" style="display: none;"></div>
380
+ </div>
381
+ </div>
382
+
383
+ </div>
384
+ </div>
385
+
386
+ </main>
387
+ </div>
388
+
389
+ <script src="/static/app.js"></script>
390
+ </body>
391
+ </html>
frontend/styles.css ADDED
@@ -0,0 +1,1899 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ RAG Visualizer — Chunking Lab Design System
3
+ Theme: Superman High-Contrast Color-Blocked Light Mode
4
+ Palette: Blue #2563eb | Red #ef4444 | Gold #f59e0b
5
+ ============================================================ */
6
+
7
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Roboto+Condensed:wght@400;700&display=swap');
8
+
9
+ /* --- Design Tokens --- */
10
+ :root {
11
+ /* Surface Colors */
12
+ --bg-root: #f8fafc;
13
+ --bg-surface: #ffffff;
14
+ --bg-surface-raised: #f1f5f9;
15
+ --bg-surface-overlay: #e2e8f0;
16
+ --bg-input: #f8fafc;
17
+
18
+ /* Borders */
19
+ --border-subtle: rgba(15, 23, 42, 0.05);
20
+ --border-default: rgba(15, 23, 42, 0.09);
21
+ --border-active: rgba(37, 99, 235, 0.35);
22
+
23
+ /* Text Colors */
24
+ --text-primary: #0f172a;
25
+ --text-secondary: #475569;
26
+ --text-tertiary: #64748b;
27
+ --text-inverse: #ffffff;
28
+
29
+ /* Superman Blue — Structural (borders, headers, outlines, secondary data) */
30
+ --superman-blue: #2563eb;
31
+ --superman-blue-hover: #1d4ed8;
32
+ --superman-blue-muted: rgba(37, 99, 235, 0.08);
33
+ --superman-blue-glow: rgba(37, 99, 235, 0.2);
34
+
35
+ /* Superman Red — Action/Attention (CTA buttons, active states, query nodes) */
36
+ --superman-red: #ef4444;
37
+ --superman-red-hover: #dc2626;
38
+ --superman-red-muted: rgba(239, 68, 68, 0.08);
39
+ --superman-red-glow: rgba(239, 68, 68, 0.2);
40
+
41
+ /* Superman Gold — Interactive/Highlight (slider thumbs, Rank 1, connecting lines) */
42
+ --superman-yellow: #f59e0b;
43
+ --superman-yellow-hover: #d97706;
44
+ --superman-yellow-muted: rgba(245, 158, 11, 0.08);
45
+ --superman-yellow-glow: rgba(245, 158, 11, 0.2);
46
+
47
+ /* Functional Accent — Default to Blue for structural context */
48
+ --accent: var(--superman-blue);
49
+ --accent-hover: var(--superman-blue-hover);
50
+ --accent-muted: var(--superman-blue-muted);
51
+ --accent-glow: var(--superman-blue-glow);
52
+
53
+ /* Chunk Highlight Colors */
54
+ --chunk-color-1: var(--superman-blue);
55
+ --chunk-color-2: var(--superman-red);
56
+ --chunk-color-3: var(--superman-yellow);
57
+ --chunk-color-4: #818cf8;
58
+
59
+ /* Semantic */
60
+ --success: #16a34a;
61
+ --warning: #d97706;
62
+ --error: #dc2626;
63
+ --info: #2563eb;
64
+
65
+ /* Spacing */
66
+ --space-xs: 4px;
67
+ --space-sm: 8px;
68
+ --space-md: 16px;
69
+ --space-lg: 24px;
70
+ --space-xl: 32px;
71
+ --space-2xl: 48px;
72
+
73
+ /* Radius */
74
+ --radius-sm: 6px;
75
+ --radius-md: 10px;
76
+ --radius-lg: 16px;
77
+ --radius-xl: 20px;
78
+
79
+ /* Shadows */
80
+ --shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.05);
81
+ --shadow-md: 0 4px 16px rgba(15, 23, 42, 0.06);
82
+ --shadow-lg: 0 10px 36px rgba(15, 23, 42, 0.08);
83
+ --shadow-glow-red: 0 0 20px var(--superman-red-glow);
84
+ --shadow-glow-blue: 0 0 20px var(--superman-blue-glow);
85
+ --shadow-glow-gold: 0 0 20px var(--superman-yellow-glow);
86
+
87
+ /* Transitions */
88
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
89
+ --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
90
+ --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
91
+
92
+ /* Typography */
93
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
94
+ --font-heading: 'Roboto Condensed', 'Inter', sans-serif;
95
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
96
+ }
97
+
98
+ /* --- Kryptonite Protocol (Error States) --- */
99
+ .text-kryptonite {
100
+ color: #22c55e !important;
101
+ }
102
+ .bg-kryptonite-wash {
103
+ background-color: rgba(34, 197, 94, 0.1) !important;
104
+ border: 1px solid #22c55e !important;
105
+ }
106
+
107
+ /* --- Reset & Base --- */
108
+ *,
109
+ *::before,
110
+ *::after {
111
+ margin: 0;
112
+ padding: 0;
113
+ box-sizing: border-box;
114
+ }
115
+
116
+ html {
117
+ font-size: 14px;
118
+ -webkit-font-smoothing: antialiased;
119
+ -moz-osx-font-smoothing: grayscale;
120
+ }
121
+
122
+ body {
123
+ font-family: var(--font-sans);
124
+ background: var(--bg-root);
125
+ color: var(--text-primary);
126
+ line-height: 1.6;
127
+ min-height: 100vh;
128
+ overflow-x: hidden;
129
+ }
130
+
131
+ /* Subtle dot grid texture */
132
+ body::before {
133
+ content: '';
134
+ position: fixed;
135
+ inset: 0;
136
+ background-image:
137
+ radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.03) 1px, transparent 0);
138
+ background-size: 32px 32px;
139
+ pointer-events: none;
140
+ z-index: 0;
141
+ }
142
+
143
+ /* --- Scrollbar --- */
144
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
145
+ ::-webkit-scrollbar-track { background: transparent; }
146
+ ::-webkit-scrollbar-thumb { background: var(--text-tertiary); border-radius: 99px; }
147
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
148
+
149
+ /* --- Layout --- */
150
+ .app-wrapper {
151
+ position: relative;
152
+ z-index: 1;
153
+ display: flex;
154
+ flex-direction: column;
155
+ min-height: 100vh;
156
+ }
157
+
158
+ /* ============================================================
159
+ 1. HEADER — Blue-to-Red gradient logo, frosted glass bar
160
+ ============================================================ */
161
+ .app-header {
162
+ display: flex;
163
+ align-items: center;
164
+ justify-content: space-between;
165
+ padding: var(--space-md) var(--space-xl);
166
+ border-bottom: 1px solid var(--border-default);
167
+ background: rgba(255, 255, 255, 0.85);
168
+ backdrop-filter: blur(16px);
169
+ -webkit-backdrop-filter: blur(16px);
170
+ position: sticky;
171
+ top: 0;
172
+ z-index: 100;
173
+ }
174
+
175
+ .app-logo {
176
+ display: flex;
177
+ align-items: center;
178
+ gap: var(--space-sm);
179
+ }
180
+
181
+ /* Logo icon: Blue-to-Red gradient */
182
+ .app-logo-icon {
183
+ width: 32px;
184
+ height: 32px;
185
+ background: linear-gradient(90deg, #2563eb 0%, #ef4444 100%);
186
+ border-radius: var(--radius-sm);
187
+ display: grid;
188
+ place-items: center;
189
+ font-size: 16px;
190
+ }
191
+
192
+ .app-logo-text {
193
+ font-weight: 700;
194
+ font-size: 1.15rem;
195
+ letter-spacing: -0.02em;
196
+ }
197
+
198
+ /* "Visualizer" span in Blue */
199
+ .app-logo-text span {
200
+ color: var(--superman-blue);
201
+ }
202
+
203
+ .app-header-badge {
204
+ font-size: 0.75rem;
205
+ font-weight: 500;
206
+ color: var(--superman-blue);
207
+ background: var(--superman-blue-muted);
208
+ padding: 2px 10px;
209
+ border-radius: 99px;
210
+ border: 1px solid rgba(37, 99, 235, 0.2);
211
+ }
212
+
213
+ /* ============================================================
214
+ 2. MAIN CONTENT — 3 Column Grid
215
+ ============================================================ */
216
+ .app-main {
217
+ display: grid;
218
+ grid-template-columns: 300px 1fr 340px;
219
+ gap: 0;
220
+ flex: 1;
221
+ overflow: hidden;
222
+ }
223
+
224
+ /* --- Panels --- */
225
+ .panel {
226
+ display: flex;
227
+ flex-direction: column;
228
+ border-right: 1px solid var(--border-default);
229
+ overflow-y: auto;
230
+ height: calc(100vh - 57px);
231
+ }
232
+
233
+ .panel:last-child {
234
+ border-right: none;
235
+ }
236
+
237
+ .panel-header {
238
+ padding: var(--space-md) var(--space-lg);
239
+ border-bottom: 1px solid var(--border-subtle);
240
+ background: var(--bg-surface);
241
+ position: sticky;
242
+ top: 0;
243
+ z-index: 10;
244
+ }
245
+
246
+ .panel-header h2 {
247
+ font-family: var(--font-heading);
248
+ font-size: 1rem;
249
+ font-weight: 700;
250
+ text-transform: uppercase;
251
+ letter-spacing: 0.08em;
252
+ color: var(--text-secondary);
253
+ display: flex;
254
+ align-items: center;
255
+ gap: var(--space-sm);
256
+ }
257
+
258
+ .panel-header h2 .icon {
259
+ font-size: 1rem;
260
+ }
261
+
262
+ /* Sidebar Panel Header Overrides */
263
+ #config-panel .panel-header,
264
+ #inspector-panel .panel-header {
265
+ border-bottom: none;
266
+ background: transparent;
267
+ padding-bottom: var(--space-xs);
268
+ }
269
+
270
+ #config-panel .panel-header h2,
271
+ #inspector-panel .panel-header h2 {
272
+ font-family: var(--font-heading);
273
+ font-size: 1.15rem;
274
+ font-weight: 700;
275
+ text-transform: none;
276
+ letter-spacing: normal;
277
+ color: var(--text-primary);
278
+ }
279
+
280
+ .panel-body {
281
+ padding: var(--space-lg);
282
+ flex: 1;
283
+ overflow-y: auto;
284
+ }
285
+
286
+ /* ============================================================
287
+ 3. CONFIG PANEL (Left Sidebar)
288
+ ============================================================ */
289
+ .config-section {
290
+ margin-bottom: var(--space-xl);
291
+ }
292
+
293
+ .config-section-title {
294
+ font-family: var(--font-heading);
295
+ font-size: 0.8rem;
296
+ font-weight: 700;
297
+ text-transform: uppercase;
298
+ letter-spacing: 0.1em;
299
+ color: var(--text-tertiary);
300
+ margin-bottom: var(--space-md);
301
+ }
302
+
303
+ /* --- Strategy Selector Cards --- */
304
+ .strategy-grid {
305
+ display: grid;
306
+ grid-template-columns: 1fr 1fr;
307
+ gap: var(--space-sm);
308
+ }
309
+
310
+ .strategy-card {
311
+ position: relative;
312
+ padding: var(--space-md);
313
+ background-color: #ffffff;
314
+ border: 1px solid #e5e7eb;
315
+ border-top: 4px solid var(--superman-blue);
316
+ border-radius: 10px;
317
+ cursor: pointer;
318
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
319
+ text-align: center;
320
+ overflow: hidden;
321
+ }
322
+
323
+ .strategy-card:hover {
324
+ transform: translateY(-2px);
325
+ box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.15);
326
+ border-color: var(--superman-blue);
327
+ }
328
+
329
+ /* Active strategy card: White background, thicker blue border, corner badge */
330
+ .strategy-card.active {
331
+ border: 2px solid var(--superman-blue);
332
+ border-top: 4px solid var(--superman-blue);
333
+ background-color: #ffffff;
334
+ box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.25), var(--shadow-glow-blue);
335
+ }
336
+
337
+ /* Corner badge for active strategy */
338
+ .strategy-card.active::after {
339
+ content: '✓';
340
+ position: absolute;
341
+ top: 0;
342
+ right: 0;
343
+ width: 24px;
344
+ height: 24px;
345
+ background-color: var(--superman-red);
346
+ color: #ffffff;
347
+ font-size: 12px;
348
+ font-weight: bold;
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: center;
352
+ border-bottom-left-radius: 4px;
353
+ }
354
+
355
+ .strategy-card input[type="radio"] {
356
+ display: none;
357
+ }
358
+
359
+ .strategy-card-icon {
360
+ font-size: 1.4rem;
361
+ margin-bottom: var(--space-xs);
362
+ }
363
+
364
+ .strategy-card-name {
365
+ font-size: 0.78rem;
366
+ font-weight: 600;
367
+ color: var(--text-primary);
368
+ }
369
+
370
+ .strategy-card-desc {
371
+ font-size: 0.65rem;
372
+ color: var(--text-tertiary);
373
+ margin-top: 2px;
374
+ }
375
+
376
+ /* --- Sliders (Blue track, Gold thumb) --- */
377
+ .config-field {
378
+ margin-bottom: var(--space-lg);
379
+ }
380
+
381
+ .config-field label {
382
+ display: flex;
383
+ justify-content: space-between;
384
+ align-items: center;
385
+ font-family: var(--font-heading);
386
+ font-size: 0.85rem;
387
+ font-weight: 700;
388
+ color: var(--text-secondary);
389
+ margin-bottom: var(--space-sm);
390
+ }
391
+
392
+ /* Value badges in Blue */
393
+ .config-field .value-badge {
394
+ font-family: var(--font-mono);
395
+ font-size: 0.72rem;
396
+ font-weight: 500;
397
+ color: var(--superman-blue);
398
+ background: var(--superman-blue-muted);
399
+ padding: 1px 8px;
400
+ border-radius: 99px;
401
+ }
402
+
403
+ /* Slider track: thin Blue */
404
+ input[type="range"] {
405
+ -webkit-appearance: none;
406
+ appearance: none;
407
+ width: 100%;
408
+ height: 4px;
409
+ background: linear-gradient(90deg, var(--superman-blue-muted) 0%, rgba(37, 99, 235, 0.15) 100%);
410
+ border-radius: 99px;
411
+ outline: none;
412
+ cursor: pointer;
413
+ }
414
+
415
+ /* Slider thumb: Superman Gold */
416
+ input[type="range"]::-webkit-slider-thumb {
417
+ -webkit-appearance: none;
418
+ appearance: none;
419
+ width: 16px;
420
+ height: 16px;
421
+ border-radius: 50%;
422
+ background: var(--superman-yellow);
423
+ border: 2px solid var(--bg-surface);
424
+ box-shadow: 0 0 8px var(--superman-yellow-glow);
425
+ cursor: pointer;
426
+ transition: transform var(--transition-fast);
427
+ }
428
+
429
+ input[type="range"]::-webkit-slider-thumb:hover {
430
+ transform: scale(1.2);
431
+ box-shadow: 0 0 12px var(--superman-yellow-glow);
432
+ }
433
+
434
+ input[type="range"]::-moz-range-thumb {
435
+ width: 16px;
436
+ height: 16px;
437
+ border-radius: 50%;
438
+ background: var(--superman-yellow);
439
+ border: 2px solid var(--bg-surface);
440
+ box-shadow: 0 0 8px var(--superman-yellow-glow);
441
+ cursor: pointer;
442
+ }
443
+
444
+ /* --- Text Input --- */
445
+ .text-input-wrapper {
446
+ position: relative;
447
+ }
448
+
449
+ .text-input {
450
+ width: 100%;
451
+ min-height: 120px;
452
+ padding: var(--space-md);
453
+ background: var(--bg-input);
454
+ border: 1px solid var(--border-default);
455
+ border-radius: var(--radius-md);
456
+ color: var(--text-primary);
457
+ font-family: var(--font-mono);
458
+ font-size: 0.78rem;
459
+ line-height: 1.7;
460
+ resize: vertical;
461
+ outline: none;
462
+ transition: border-color var(--transition-base), box-shadow var(--transition-base);
463
+ }
464
+
465
+ .text-input:focus {
466
+ border-color: var(--superman-blue);
467
+ box-shadow: 0 0 0 3px var(--superman-blue-muted);
468
+ }
469
+
470
+ .text-input::placeholder {
471
+ color: var(--text-tertiary);
472
+ }
473
+
474
+ .char-count {
475
+ position: absolute;
476
+ bottom: var(--space-sm);
477
+ right: var(--space-md);
478
+ font-size: 0.65rem;
479
+ font-family: var(--font-mono);
480
+ color: var(--text-tertiary);
481
+ }
482
+
483
+ /* --- Separators Input --- */
484
+ .separator-tags {
485
+ display: flex;
486
+ flex-wrap: wrap;
487
+ gap: var(--space-xs);
488
+ padding: var(--space-sm);
489
+ background: var(--bg-input);
490
+ border: 1px solid var(--border-default);
491
+ border-radius: var(--radius-md);
492
+ min-height: 36px;
493
+ cursor: text;
494
+ }
495
+
496
+ .separator-tag {
497
+ display: inline-flex;
498
+ align-items: center;
499
+ gap: 4px;
500
+ padding: 2px 8px;
501
+ background: var(--bg-surface-overlay);
502
+ border: 1px solid var(--border-default);
503
+ border-radius: var(--radius-sm);
504
+ font-family: var(--font-mono);
505
+ font-size: 0.7rem;
506
+ color: var(--text-secondary);
507
+ }
508
+
509
+ .separator-tag .remove {
510
+ cursor: pointer;
511
+ color: var(--text-tertiary);
512
+ font-size: 0.85rem;
513
+ line-height: 1;
514
+ transition: color var(--transition-fast);
515
+ }
516
+
517
+ .separator-tag .remove:hover {
518
+ color: var(--superman-red);
519
+ }
520
+
521
+ .separator-input {
522
+ border: none;
523
+ outline: none;
524
+ background: transparent;
525
+ color: var(--text-primary);
526
+ font-family: var(--font-mono);
527
+ font-size: 0.72rem;
528
+ flex: 1;
529
+ min-width: 60px;
530
+ }
531
+
532
+ /* ============================================================
533
+ 4. RUN BUTTON — Superman Red CTA (primary action)
534
+ ============================================================ */
535
+ .btn-run {
536
+ width: 100%;
537
+ padding: var(--space-md) var(--space-lg);
538
+ background: linear-gradient(135deg, var(--superman-red), var(--superman-red-hover));
539
+ color: var(--text-inverse);
540
+ font-family: var(--font-sans);
541
+ font-size: 0.85rem;
542
+ font-weight: 700;
543
+ border: none;
544
+ border-radius: var(--radius-md);
545
+ cursor: pointer;
546
+ transition: all var(--transition-base);
547
+ display: flex;
548
+ align-items: center;
549
+ justify-content: center;
550
+ gap: var(--space-sm);
551
+ text-transform: uppercase;
552
+ letter-spacing: 0.05em;
553
+ position: relative;
554
+ overflow: hidden;
555
+ }
556
+
557
+ .btn-run::before {
558
+ content: '';
559
+ position: absolute;
560
+ inset: 0;
561
+ background: linear-gradient(135deg, transparent, rgba(255, 255, 255, 0.15));
562
+ opacity: 0;
563
+ transition: opacity var(--transition-base);
564
+ }
565
+
566
+ .btn-run:hover::before {
567
+ opacity: 1;
568
+ }
569
+
570
+ .btn-run:hover {
571
+ transform: translateY(-1px);
572
+ box-shadow: var(--shadow-glow-red), var(--shadow-md);
573
+ }
574
+
575
+ .btn-run:active {
576
+ transform: translateY(0);
577
+ }
578
+
579
+ .btn-run:disabled {
580
+ opacity: 0.5;
581
+ cursor: not-allowed;
582
+ transform: none;
583
+ }
584
+
585
+ .btn-run:disabled:hover::before {
586
+ opacity: 0;
587
+ }
588
+
589
+ .btn-run .spinner {
590
+ width: 16px;
591
+ height: 16px;
592
+ border: 2px solid rgba(255, 255, 255, 0.3);
593
+ border-top-color: var(--text-inverse);
594
+ border-radius: 50%;
595
+ animation: spin 0.6s linear infinite;
596
+ display: none;
597
+ }
598
+
599
+ .btn-run.loading .spinner {
600
+ display: block;
601
+ }
602
+
603
+ .btn-run.loading .btn-text {
604
+ display: none;
605
+ }
606
+
607
+ @keyframes spin {
608
+ to { transform: rotate(360deg); }
609
+ }
610
+
611
+ /* Pulse animation for Run button — Red glow */
612
+ @keyframes pulse-glow-red {
613
+ 0%, 100% { box-shadow: 0 0 8px var(--superman-red-glow); }
614
+ 50% { box-shadow: 0 0 20px var(--superman-red-glow), 0 0 40px rgba(239, 68, 68, 0.1); }
615
+ }
616
+
617
+ .btn-run.ready {
618
+ animation: pulse-glow-red 2s ease-in-out infinite;
619
+ }
620
+
621
+ /* ============================================================
622
+ 5. CENTER PANEL — Tabs (Document Viewer / Vector Space 2D)
623
+ ============================================================ */
624
+ .xray-viewer {
625
+ position: relative;
626
+ }
627
+
628
+ /* --- Interactive Tabs Navigation --- */
629
+ .tabs-header {
630
+ padding: 0 !important;
631
+ }
632
+ .tab-buttons {
633
+ display: flex;
634
+ height: 56px;
635
+ width: 100%;
636
+ }
637
+
638
+ /* Tab button: Blue text, no background highlight */
639
+ .tab-btn {
640
+ flex: 1;
641
+ background: transparent;
642
+ border: none;
643
+ border-bottom: 3px solid transparent;
644
+ color: var(--text-secondary);
645
+ font-family: var(--font-sans);
646
+ font-size: 0.85rem;
647
+ font-weight: 600;
648
+ cursor: pointer;
649
+ display: flex;
650
+ align-items: center;
651
+ justify-content: center;
652
+ gap: var(--space-sm);
653
+ transition: all var(--transition-base);
654
+ }
655
+
656
+ .tab-btn:hover {
657
+ color: var(--text-primary);
658
+ background: transparent;
659
+ }
660
+
661
+ /* Active tab: 3px solid Red bottom border, Red text, NO background tint */
662
+ .tab-btn.active {
663
+ color: var(--superman-red);
664
+ border-bottom-color: var(--superman-red);
665
+ background: transparent;
666
+ }
667
+
668
+ /* --- Tab Content Visibility --- */
669
+ .tab-content {
670
+ display: none !important;
671
+ }
672
+ .tab-content.active {
673
+ display: flex !important;
674
+ flex-direction: column;
675
+ }
676
+
677
+ /* --- X-Ray Text Container --- */
678
+ .xray-text-container {
679
+ font-family: var(--font-mono);
680
+ font-size: 0.8rem;
681
+ line-height: 2;
682
+ white-space: pre-wrap;
683
+ word-break: break-word;
684
+ padding: var(--space-lg);
685
+ position: relative;
686
+ }
687
+
688
+ /* Chunk highlights in the text */
689
+ .chunk-highlight {
690
+ position: relative;
691
+ border-radius: 3px;
692
+ padding: 1px 0;
693
+ border-left: 3px solid var(--superman-red); /* "Cape" Accent */
694
+ transition: all var(--transition-fast);
695
+ cursor: pointer;
696
+ }
697
+
698
+ /* ============================================================
699
+ RETRIEVAL X-RAY HIGHLIGHTS (TASK 3.4)
700
+ ============================================================ */
701
+ .xray-content.search-active .chunk-highlight {
702
+ opacity: 0.3;
703
+ transition: all 0.3s ease;
704
+ }
705
+
706
+ .xray-content.search-active .chunk-highlight.retrieved-rank-1 {
707
+ opacity: 1.0;
708
+ background-color: rgba(255, 165, 0, 0.4);
709
+ box-shadow: 0 0 12px rgba(255, 165, 0, 0.6);
710
+ border-top: 2px solid var(--warning-color);
711
+ border-bottom: 2px solid var(--warning-color);
712
+ z-index: 10;
713
+ position: relative;
714
+ }
715
+
716
+ .xray-content.search-active .chunk-highlight.retrieved-rank-2 {
717
+ opacity: 0.9;
718
+ background-color: rgba(255, 165, 0, 0.25);
719
+ border-top: 2px dashed rgba(255, 165, 0, 0.6);
720
+ border-bottom: 2px dashed rgba(255, 165, 0, 0.6);
721
+ z-index: 9;
722
+ position: relative;
723
+ }
724
+
725
+ .xray-content.search-active .chunk-highlight.retrieved-rank-3 {
726
+ opacity: 0.8;
727
+ background-color: rgba(255, 165, 0, 0.15);
728
+ border-top: 2px dotted rgba(255, 165, 0, 0.4);
729
+ border-bottom: 2px dotted rgba(255, 165, 0, 0.4);
730
+ z-index: 8;
731
+ position: relative;
732
+ }
733
+
734
+ /* Lexical Heatmap Highlights */
735
+ .query-result-text mark,
736
+ .retrieved-chunk-text mark {
737
+ background-color: rgba(245, 158, 11, 0.22); /* Superman Gold Muted */
738
+ color: #b45309; /* Highly readable deep amber */
739
+ border-bottom: 2px solid var(--superman-yellow);
740
+ font-weight: 600;
741
+ padding: 1px 3px;
742
+ border-radius: 3px;
743
+ transition: all 0.2s ease;
744
+ }
745
+
746
+ .query-result-text mark:hover,
747
+ .retrieved-chunk-text mark:hover {
748
+ background-color: rgba(245, 158, 11, 0.35);
749
+ color: #78350f;
750
+ cursor: help;
751
+ }
752
+
753
+
754
+ /* ============================================================
755
+ THE GRAND COMPARISON ARENA (TASK 3.5)
756
+ ============================================================ */
757
+ .arena-modal-overlay {
758
+ position: fixed;
759
+ inset: 0;
760
+ background: rgba(15, 23, 42, 0.9);
761
+ backdrop-filter: blur(8px);
762
+ z-index: 9999;
763
+ display: flex;
764
+ align-items: center;
765
+ justify-content: center;
766
+ }
767
+
768
+ .arena-modal-content {
769
+ background: var(--bg-surface);
770
+ width: 90vw;
771
+ height: 90vh;
772
+ border-radius: var(--radius-lg);
773
+ box-shadow: var(--shadow-xl);
774
+ display: flex;
775
+ flex-direction: column;
776
+ overflow: hidden;
777
+ border: 1px solid var(--border-default);
778
+ }
779
+
780
+ .arena-header {
781
+ display: flex;
782
+ justify-content: space-between;
783
+ align-items: center;
784
+ padding: var(--space-md) var(--space-xl);
785
+ background: var(--bg-surface-elevated);
786
+ border-bottom: 1px solid var(--border-default);
787
+ }
788
+
789
+ .arena-header h2 {
790
+ margin: 0;
791
+ font-size: 1.25rem;
792
+ color: var(--superman-red);
793
+ }
794
+
795
+ .btn-close-arena {
796
+ background: none;
797
+ border: none;
798
+ color: var(--text-secondary);
799
+ font-size: 1.5rem;
800
+ cursor: pointer;
801
+ }
802
+
803
+ .btn-close-arena:hover {
804
+ color: var(--superman-red);
805
+ }
806
+
807
+ .arena-query-bar {
808
+ display: flex;
809
+ padding: var(--space-lg) var(--space-xl);
810
+ gap: var(--space-md);
811
+ background: var(--bg-surface-elevated);
812
+ border-bottom: 1px solid var(--border-default);
813
+ }
814
+
815
+ .arena-query-bar input {
816
+ flex: 1;
817
+ padding: var(--space-md) var(--space-lg);
818
+ border-radius: var(--radius-md);
819
+ border: 1px solid var(--border-default);
820
+ background: var(--bg-surface);
821
+ color: var(--text-primary);
822
+ font-size: 1rem;
823
+ }
824
+
825
+ .arena-columns {
826
+ display: flex;
827
+ height: 385px;
828
+ flex-shrink: 0;
829
+ border-bottom: 1px solid var(--border-default);
830
+ }
831
+
832
+ .arena-body {
833
+ flex: 1;
834
+ overflow-y: auto;
835
+ padding: 0 0 var(--space-xl) 0;
836
+ display: flex;
837
+ flex-direction: column;
838
+ }
839
+
840
+ .arena-col {
841
+ flex: 1;
842
+ display: flex;
843
+ flex-direction: column;
844
+ border-right: 1px solid var(--border-default);
845
+ }
846
+ .arena-col:last-child {
847
+ border-right: none;
848
+ }
849
+
850
+ .arena-col-header {
851
+ padding: var(--space-md) var(--space-xl);
852
+ border-bottom: 2px solid var(--warning-color);
853
+ background: rgba(255, 255, 255, 0.02);
854
+ }
855
+
856
+ .arena-col-header h3 {
857
+ margin: 0 0 var(--space-sm) 0;
858
+ font-size: 1.1rem;
859
+ font-weight: 700;
860
+ color: var(--text-primary);
861
+ }
862
+
863
+ .arena-config-row {
864
+ display: flex;
865
+ gap: var(--space-sm);
866
+ }
867
+
868
+ .arena-config-row select {
869
+ flex: 1;
870
+ }
871
+
872
+ .arena-results-list {
873
+ flex: 1;
874
+ overflow-y: auto;
875
+ padding: var(--space-xl);
876
+ display: flex;
877
+ flex-direction: column;
878
+ gap: var(--space-md);
879
+ }
880
+
881
+ /* Low opacity wash of primary colors, no underlines */
882
+ .chunk-highlight[data-color="1"] { background: rgba(37, 99, 235, 0.1); }
883
+ .chunk-highlight[data-color="2"] { background: rgba(239, 68, 68, 0.1); }
884
+ .chunk-highlight[data-color="3"] { background: rgba(245, 158, 11, 0.1); }
885
+ .chunk-highlight[data-color="4"] { background: rgba(129, 140, 248, 0.1); }
886
+
887
+ .chunk-highlight.active[data-color="1"] { background: rgba(37, 99, 235, 0.25); box-shadow: 0 0 12px rgba(37, 99, 235, 0.15); }
888
+ .chunk-highlight.active[data-color="2"] { background: rgba(239, 68, 68, 0.25); box-shadow: 0 0 12px rgba(239, 68, 68, 0.15); }
889
+ .chunk-highlight.active[data-color="3"] { background: rgba(245, 158, 11, 0.25); box-shadow: 0 0 12px rgba(245, 158, 11, 0.15); }
890
+ .chunk-highlight.active[data-color="4"] { background: rgba(129, 140, 248, 0.25); box-shadow: 0 0 12px rgba(129, 140, 248, 0.15); }
891
+
892
+ /* Chunk boundary marker */
893
+ .chunk-boundary {
894
+ display: inline-block;
895
+ width: 0;
896
+ height: 1em;
897
+ border-left: 2px dashed var(--text-tertiary);
898
+ margin: 0 2px;
899
+ vertical-align: middle;
900
+ opacity: 0.5;
901
+ }
902
+
903
+ /* Overlap region */
904
+ .overlap-region {
905
+ background: repeating-linear-gradient(
906
+ -45deg,
907
+ transparent,
908
+ transparent 2px,
909
+ rgba(251, 146, 60, 0.15) 2px,
910
+ rgba(251, 146, 60, 0.15) 4px
911
+ ) !important;
912
+ border-bottom: 2px dashed var(--warning) !important;
913
+ }
914
+
915
+ /* Empty state */
916
+ .empty-state {
917
+ display: flex;
918
+ flex-direction: column;
919
+ align-items: center;
920
+ justify-content: center;
921
+ height: 100%;
922
+ min-height: 400px;
923
+ color: var(--text-tertiary);
924
+ text-align: center;
925
+ gap: var(--space-md);
926
+ }
927
+
928
+ .empty-state-icon {
929
+ font-size: 3rem;
930
+ opacity: 0.8;
931
+ color: #22c55e; /* Kryptonite Green */
932
+ }
933
+
934
+ .empty-state-title {
935
+ font-size: 1rem;
936
+ font-weight: 600;
937
+ color: var(--text-secondary);
938
+ }
939
+
940
+ .empty-state-desc {
941
+ font-size: 0.78rem;
942
+ max-width: 280px;
943
+ line-height: 1.6;
944
+ }
945
+
946
+ /* ============================================================
947
+ 6. VECTOR SPACE TAB (Phase 2)
948
+ ============================================================ */
949
+ .vector-space-container {
950
+ display: flex;
951
+ flex-direction: column;
952
+ height: 100%;
953
+ position: relative;
954
+ gap: var(--space-md);
955
+ padding: var(--space-lg);
956
+ }
957
+
958
+ /* Canvas wrapper: Fortress of Solitude Grid */
959
+ .canvas-wrapper {
960
+ flex: 1;
961
+ position: relative;
962
+ border: 1px solid var(--superman-blue);
963
+ border-radius: var(--radius-lg);
964
+ background-color: #fafafa;
965
+ background-image:
966
+ linear-gradient(rgba(37, 99, 235, 0.05) 1px, transparent 1px),
967
+ linear-gradient(90deg, rgba(37, 99, 235, 0.05) 1px, transparent 1px);
968
+ background-size: 24px 24px;
969
+ overflow: hidden;
970
+ box-shadow: inset 0 2px 8px rgba(15, 23, 42, 0.04);
971
+ min-height: 380px;
972
+ }
973
+
974
+ #vector-canvas {
975
+ display: block;
976
+ width: 100%;
977
+ height: 100%;
978
+ cursor: grab;
979
+ }
980
+ #vector-canvas:active {
981
+ cursor: grabbing;
982
+ }
983
+
984
+ /* Canvas Help Overlay */
985
+ .canvas-help-overlay {
986
+ position: absolute;
987
+ top: var(--space-md);
988
+ right: var(--space-md);
989
+ display: flex;
990
+ flex-direction: column;
991
+ gap: var(--space-xs);
992
+ background: rgba(255, 255, 255, 0.85);
993
+ backdrop-filter: blur(4px);
994
+ padding: 6px 12px;
995
+ border-radius: var(--radius-md);
996
+ border: 1px solid var(--border-default);
997
+ pointer-events: none;
998
+ }
999
+ .canvas-help-overlay span {
1000
+ font-size: 0.68rem;
1001
+ color: var(--text-tertiary);
1002
+ font-family: var(--font-mono);
1003
+ }
1004
+
1005
+ /* Floating Tooltip inside Canvas — Blue left accent bar */
1006
+ .vector-tooltip {
1007
+ position: absolute;
1008
+ pointer-events: none;
1009
+ background: rgba(255, 255, 255, 0.95);
1010
+ backdrop-filter: blur(12px);
1011
+ -webkit-backdrop-filter: blur(12px);
1012
+ border: 1px solid var(--border-default);
1013
+ border-left: 3px solid var(--superman-blue);
1014
+ border-radius: var(--radius-md);
1015
+ padding: var(--space-md);
1016
+ color: var(--text-primary);
1017
+ max-width: 280px;
1018
+ font-size: 0.75rem;
1019
+ box-shadow: var(--shadow-lg);
1020
+ z-index: 50;
1021
+ transition: opacity 100ms ease;
1022
+ }
1023
+ .vector-tooltip-title {
1024
+ font-family: var(--font-mono);
1025
+ font-weight: 700;
1026
+ color: var(--superman-blue);
1027
+ margin-bottom: var(--space-xs);
1028
+ font-size: 0.68rem;
1029
+ text-transform: uppercase;
1030
+ letter-spacing: 0.05em;
1031
+ display: flex;
1032
+ justify-content: space-between;
1033
+ }
1034
+ .vector-tooltip-coords {
1035
+ color: var(--text-tertiary);
1036
+ font-family: var(--font-mono);
1037
+ font-size: 0.65rem;
1038
+ }
1039
+ .vector-tooltip-text {
1040
+ font-family: var(--font-mono);
1041
+ color: var(--text-secondary);
1042
+ line-height: 1.5;
1043
+ margin-top: var(--space-xs);
1044
+ border-top: 1px solid var(--border-subtle);
1045
+ padding-top: var(--space-xs);
1046
+ }
1047
+
1048
+ /* ============================================================
1049
+ 7. SONAR QUERY SIMULATOR
1050
+ ============================================================ */
1051
+ .query-simulator-section {
1052
+ background: var(--bg-surface);
1053
+ border: 1px solid var(--border-default);
1054
+ border-radius: var(--radius-lg);
1055
+ padding: var(--space-md);
1056
+ display: flex;
1057
+ flex-direction: column;
1058
+ gap: var(--space-sm);
1059
+ box-shadow: var(--shadow-md);
1060
+ }
1061
+
1062
+ .query-section-title {
1063
+ font-family: var(--font-heading);
1064
+ font-size: 0.9rem;
1065
+ font-weight: 700;
1066
+ text-transform: uppercase;
1067
+ letter-spacing: 0.08em;
1068
+ color: var(--superman-blue);
1069
+ display: flex;
1070
+ align-items: center;
1071
+ gap: 6px;
1072
+ }
1073
+
1074
+ .query-section-title .radar-icon {
1075
+ color: var(--superman-yellow);
1076
+ }
1077
+
1078
+ .query-input-wrapper {
1079
+ display: flex;
1080
+ gap: var(--space-sm);
1081
+ }
1082
+
1083
+ /* Query input: Blue focus ring */
1084
+ #query-input {
1085
+ flex: 1;
1086
+ padding: 10px 14px;
1087
+ background: var(--bg-input);
1088
+ border: 1px solid var(--border-default);
1089
+ border-radius: var(--radius-md);
1090
+ color: var(--text-primary);
1091
+ font-size: 0.8rem;
1092
+ outline: none;
1093
+ transition: all var(--transition-base);
1094
+ }
1095
+ #query-input:focus {
1096
+ border-color: var(--superman-blue);
1097
+ box-shadow: 0 0 0 2px var(--superman-blue-muted);
1098
+ }
1099
+
1100
+ /* Query button: Red CTA style */
1101
+ .btn-query {
1102
+ padding: 10px var(--space-lg);
1103
+ background: var(--superman-red);
1104
+ border: 1px solid var(--superman-red);
1105
+ color: var(--text-inverse);
1106
+ font-family: var(--font-heading);
1107
+ font-weight: 700;
1108
+ font-size: 0.9rem;
1109
+ border-radius: var(--radius-md);
1110
+ cursor: pointer;
1111
+ transition: all var(--transition-base);
1112
+ }
1113
+ .btn-query:hover {
1114
+ background: var(--superman-red-hover);
1115
+ box-shadow: var(--shadow-glow-red);
1116
+ }
1117
+
1118
+ /* Retrieved Results Drawer */
1119
+ .query-results-drawer {
1120
+ border-top: 1px solid var(--border-subtle);
1121
+ padding-top: var(--space-sm);
1122
+ display: flex;
1123
+ flex-direction: column;
1124
+ gap: var(--space-sm);
1125
+ max-height: 200px;
1126
+ overflow-y: auto;
1127
+ }
1128
+
1129
+ .drawer-header {
1130
+ display: flex;
1131
+ justify-content: space-between;
1132
+ align-items: center;
1133
+ }
1134
+ .drawer-header h3 {
1135
+ font-size: 0.72rem;
1136
+ font-weight: 600;
1137
+ text-transform: uppercase;
1138
+ letter-spacing: 0.05em;
1139
+ color: var(--text-secondary);
1140
+ }
1141
+ .close-drawer {
1142
+ cursor: pointer;
1143
+ font-size: 1.2rem;
1144
+ color: var(--text-tertiary);
1145
+ line-height: 1;
1146
+ transition: color var(--transition-fast);
1147
+ }
1148
+ .close-drawer:hover {
1149
+ color: var(--superman-red);
1150
+ }
1151
+
1152
+ .query-results-list {
1153
+ display: flex;
1154
+ flex-direction: column;
1155
+ gap: var(--space-xs);
1156
+ }
1157
+
1158
+ .retrieved-chunk-card {
1159
+ display: flex;
1160
+ flex-direction: column;
1161
+ padding: var(--space-sm) var(--space-md);
1162
+ background-color: #ffffff;
1163
+ border: 1px solid var(--border-subtle);
1164
+ border-left: 4px solid var(--superman-blue);
1165
+ border-radius: var(--radius-md);
1166
+ gap: 2px;
1167
+ cursor: pointer;
1168
+ transition: all var(--transition-fast);
1169
+ }
1170
+ .retrieved-chunk-card:hover {
1171
+ border-color: var(--superman-blue);
1172
+ background: var(--superman-blue-muted);
1173
+ }
1174
+
1175
+ /* Rank 1 card: Gold left accent border */
1176
+ .retrieved-chunk-card.rank-1 {
1177
+ border-left: 4px solid var(--superman-yellow);
1178
+ background: #ffffff;
1179
+ }
1180
+
1181
+ .retrieved-chunk-card.rank-1:hover {
1182
+ background: var(--superman-yellow-muted);
1183
+ }
1184
+
1185
+ .retrieved-chunk-meta {
1186
+ display: flex;
1187
+ justify-content: space-between;
1188
+ font-size: 0.65rem;
1189
+ font-family: var(--font-mono);
1190
+ }
1191
+
1192
+ /* Rank label: Gold for Rank 1, Blue for others */
1193
+ .retrieved-chunk-rank {
1194
+ font-weight: 700;
1195
+ color: var(--superman-blue);
1196
+ }
1197
+ .retrieved-chunk-card.rank-1 .retrieved-chunk-rank {
1198
+ color: var(--superman-yellow);
1199
+ }
1200
+
1201
+ /* Score badge: Red pill */
1202
+ .retrieved-chunk-score {
1203
+ color: var(--text-inverse);
1204
+ background-color: var(--superman-red);
1205
+ font-weight: bold;
1206
+ padding: 1px 8px;
1207
+ border-radius: 99px;
1208
+ }
1209
+
1210
+ .retrieved-chunk-text {
1211
+ font-family: var(--font-mono);
1212
+ font-size: 0.7rem;
1213
+ color: var(--text-secondary);
1214
+ line-height: 1.4;
1215
+ white-space: nowrap;
1216
+ overflow: hidden;
1217
+ text-overflow: ellipsis;
1218
+ }
1219
+
1220
+ /* ============================================================
1221
+ 8. CHUNK INSPECTOR (Right Panel)
1222
+ ============================================================ */
1223
+
1224
+ /* --- Stat Cards: Frosted glass with Blue top accent border --- */
1225
+ .stats-grid {
1226
+ display: grid;
1227
+ grid-template-columns: 1fr 1fr;
1228
+ gap: var(--space-sm);
1229
+ margin-bottom: var(--space-lg);
1230
+ }
1231
+
1232
+ .stat-card {
1233
+ padding: var(--space-md);
1234
+ background: rgba(255, 255, 255, 0.7);
1235
+ backdrop-filter: blur(8px);
1236
+ -webkit-backdrop-filter: blur(8px);
1237
+ border: 1px solid var(--border-default);
1238
+ border-top: 3px solid var(--superman-blue);
1239
+ border-radius: var(--radius-md);
1240
+ text-align: center;
1241
+ box-shadow: var(--shadow-sm);
1242
+ transition: all var(--transition-base);
1243
+ }
1244
+
1245
+ .stat-card:hover {
1246
+ box-shadow: var(--shadow-md);
1247
+ transform: translateY(-1px);
1248
+ }
1249
+
1250
+ /* Stat values: Red/Gold monospace */
1251
+ .stat-card .stat-value {
1252
+ font-family: var(--font-heading);
1253
+ font-size: 1.8rem;
1254
+ font-weight: 700;
1255
+ color: var(--superman-red);
1256
+ }
1257
+
1258
+ .stat-card:nth-child(even) .stat-value {
1259
+ color: var(--superman-yellow);
1260
+ }
1261
+
1262
+ .stat-card .stat-label {
1263
+ font-family: var(--font-heading);
1264
+ font-size: 0.75rem;
1265
+ text-transform: uppercase;
1266
+ letter-spacing: 0.08em;
1267
+ color: var(--text-tertiary);
1268
+ margin-top: 2px;
1269
+ }
1270
+
1271
+ /* --- Chunk List --- */
1272
+ .chunk-list {
1273
+ display: flex;
1274
+ flex-direction: column;
1275
+ gap: var(--space-sm);
1276
+ }
1277
+
1278
+ .chunk-item {
1279
+ padding: var(--space-md);
1280
+ padding-left: calc(var(--space-md) + 28px); /* Room for the index circle */
1281
+ background-color: #ffffff;
1282
+ border: 1px solid #e5e7eb;
1283
+ border-left: 4px solid var(--superman-blue); /* Default alternating later */
1284
+ border-radius: 4px;
1285
+ cursor: pointer;
1286
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
1287
+ position: relative;
1288
+ overflow: hidden;
1289
+ }
1290
+
1291
+ /* Alternate Left Borders */
1292
+ .chunk-item:nth-child(3n+1) { border-left-color: var(--superman-blue); }
1293
+ .chunk-item:nth-child(3n+2) { border-left-color: var(--superman-red); }
1294
+ .chunk-item:nth-child(3n+3) { border-left-color: var(--superman-yellow); }
1295
+
1296
+ .chunk-item:hover {
1297
+ transform: translateY(-2px);
1298
+ box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.15);
1299
+ }
1300
+
1301
+ /* Active chunk */
1302
+ .chunk-item.active {
1303
+ background: rgba(37, 99, 235, 0.05);
1304
+ }
1305
+
1306
+ .chunk-item.active::before {
1307
+ background: var(--superman-red);
1308
+ }
1309
+
1310
+ /* Parent chunk styling */
1311
+ .chunk-item[data-level="0"] {
1312
+ border-left: 3px solid var(--superman-blue);
1313
+ }
1314
+
1315
+ .chunk-item[data-level="0"]::before {
1316
+ background: var(--superman-blue);
1317
+ }
1318
+
1319
+ /* Child chunk styling */
1320
+ .chunk-item[data-level="1"] {
1321
+ margin-left: var(--space-lg);
1322
+ border-left: 3px solid var(--superman-yellow);
1323
+ opacity: 0.85;
1324
+ }
1325
+
1326
+ .chunk-item[data-level="1"]::before {
1327
+ background: var(--superman-yellow);
1328
+ }
1329
+
1330
+ .chunk-item-header {
1331
+ display: flex;
1332
+ align-items: center;
1333
+ justify-content: space-between;
1334
+ margin-bottom: var(--space-sm);
1335
+ }
1336
+
1337
+ /* Chunk ID: Blue mono label */
1338
+ .chunk-item-id {
1339
+ font-family: var(--font-mono);
1340
+ font-size: 0.7rem;
1341
+ font-weight: 600;
1342
+ color: var(--superman-blue);
1343
+ }
1344
+
1345
+ .chunk-item.active .chunk-item-id {
1346
+ color: var(--superman-red);
1347
+ }
1348
+
1349
+ .chunk-item-badges {
1350
+ display: flex;
1351
+ gap: var(--space-xs);
1352
+ }
1353
+
1354
+ .chunk-badge {
1355
+ font-family: var(--font-mono);
1356
+ font-size: 0.6rem;
1357
+ padding: 1px 6px;
1358
+ border-radius: 99px;
1359
+ background: var(--bg-surface-overlay);
1360
+ color: var(--text-tertiary);
1361
+ border: 1px solid var(--border-subtle);
1362
+ }
1363
+
1364
+ /* Token badge: Blue tinted */
1365
+ .chunk-badge.tokens {
1366
+ color: var(--superman-blue);
1367
+ background: var(--superman-blue-muted);
1368
+ border-color: rgba(37, 99, 235, 0.2);
1369
+ }
1370
+
1371
+ .chunk-item-text {
1372
+ font-family: var(--font-mono);
1373
+ font-size: 0.72rem;
1374
+ color: var(--text-secondary);
1375
+ line-height: 1.6;
1376
+ max-height: 60px;
1377
+ overflow: hidden;
1378
+ position: relative;
1379
+ }
1380
+
1381
+ .chunk-item-text::after {
1382
+ content: '';
1383
+ position: absolute;
1384
+ bottom: 0;
1385
+ left: 0;
1386
+ right: 0;
1387
+ height: 24px;
1388
+ background: linear-gradient(transparent, var(--bg-surface));
1389
+ pointer-events: none;
1390
+ }
1391
+
1392
+ .chunk-item.active .chunk-item-text::after {
1393
+ background: linear-gradient(transparent, rgba(239, 68, 68, 0.06));
1394
+ }
1395
+
1396
+ .chunk-item-meta {
1397
+ display: flex;
1398
+ gap: var(--space-md);
1399
+ margin-top: var(--space-sm);
1400
+ font-size: 0.65rem;
1401
+ color: var(--text-tertiary);
1402
+ font-family: var(--font-mono);
1403
+ }
1404
+
1405
+ /* Parent-child connector label */
1406
+ .parent-child-label {
1407
+ font-size: 0.6rem;
1408
+ font-weight: 600;
1409
+ text-transform: uppercase;
1410
+ letter-spacing: 0.1em;
1411
+ color: var(--superman-blue);
1412
+ padding: var(--space-xs) 0;
1413
+ margin-top: var(--space-sm);
1414
+ }
1415
+
1416
+ /* --- Config: Parent-Child Fields --- */
1417
+ .parent-child-config {
1418
+ display: none;
1419
+ flex-direction: column;
1420
+ gap: var(--space-md);
1421
+ padding: var(--space-md);
1422
+ background: var(--bg-surface);
1423
+ border: 1px solid var(--border-default);
1424
+ border-radius: var(--radius-md);
1425
+ margin-top: var(--space-sm);
1426
+ }
1427
+
1428
+ .parent-child-config.visible {
1429
+ display: flex;
1430
+ }
1431
+
1432
+ .pc-section-label {
1433
+ font-size: 0.65rem;
1434
+ font-weight: 700;
1435
+ text-transform: uppercase;
1436
+ letter-spacing: 0.1em;
1437
+ padding-bottom: var(--space-xs);
1438
+ border-bottom: 1px solid var(--border-subtle);
1439
+ }
1440
+
1441
+ .pc-section-label.parent { color: var(--superman-blue); }
1442
+ .pc-section-label.child { color: var(--superman-yellow); }
1443
+
1444
+ /* ============================================================
1445
+ 9. SELECT INPUT
1446
+ ============================================================ */
1447
+ .select-input {
1448
+ width: 100%;
1449
+ padding: 8px 12px;
1450
+ background: var(--bg-input);
1451
+ border: 1px solid var(--border-default);
1452
+ border-radius: var(--radius-md);
1453
+ color: var(--text-primary);
1454
+ outline: none;
1455
+ font-family: var(--font-sans);
1456
+ font-size: 0.85rem;
1457
+ cursor: pointer;
1458
+ transition: border-color var(--transition-base);
1459
+ }
1460
+ .select-input:focus {
1461
+ border-color: var(--superman-blue);
1462
+ }
1463
+
1464
+ /* ============================================================
1465
+ 10. ANIMATIONS & SKELETONS
1466
+ ============================================================ */
1467
+ @keyframes fadeInUp {
1468
+ from {
1469
+ opacity: 0;
1470
+ transform: translateY(8px);
1471
+ }
1472
+ to {
1473
+ opacity: 1;
1474
+ transform: translateY(0);
1475
+ }
1476
+ }
1477
+
1478
+ .chunk-item {
1479
+ animation: fadeInUp var(--transition-slow) both;
1480
+ }
1481
+
1482
+ .chunk-item:nth-child(1) { animation-delay: 0ms; }
1483
+ .chunk-item:nth-child(2) { animation-delay: 40ms; }
1484
+ .chunk-item:nth-child(3) { animation-delay: 80ms; }
1485
+ .chunk-item:nth-child(4) { animation-delay: 120ms; }
1486
+ .chunk-item:nth-child(5) { animation-delay: 160ms; }
1487
+ .chunk-item:nth-child(6) { animation-delay: 200ms; }
1488
+ .chunk-item:nth-child(7) { animation-delay: 240ms; }
1489
+ .chunk-item:nth-child(8) { animation-delay: 280ms; }
1490
+
1491
+ /* 1. Heat Vision Pulse (Applied to the Sonar Probe node in Canvas via JS, but CSS provided if DOM used) */
1492
+ @keyframes radar-pulse {
1493
+ 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
1494
+ 70% { transform: scale(1); box-shadow: 0 0 0 20px rgba(239, 68, 68, 0); }
1495
+ 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
1496
+ }
1497
+ .sonar-probe {
1498
+ background-color: #ef4444;
1499
+ border-radius: 50%;
1500
+ animation: radar-pulse 2s infinite;
1501
+ }
1502
+
1503
+ /* 2. Super Speed Data Flow (Applied to the SVG connecting lines if DOM used) */
1504
+ @keyframes data-flow {
1505
+ to { stroke-dashoffset: -20; }
1506
+ }
1507
+ .connection-line {
1508
+ stroke: #f59e0b;
1509
+ stroke-width: 2;
1510
+ stroke-dasharray: 5, 5;
1511
+ animation: data-flow 0.8s linear infinite;
1512
+ }
1513
+
1514
+ /* 3. Yellow Sun Glow (Applied to the Rank 1 retrieved node if DOM used) */
1515
+ @keyframes sun-glow {
1516
+ 0%, 100% { box-shadow: 0 0 5px rgba(245, 158, 11, 0.6); }
1517
+ 50% { box-shadow: 0 0 15px #f59e0b, 0 0 25px rgba(245, 158, 11, 0.4); }
1518
+ }
1519
+ .rank-1-node {
1520
+ background-color: #f59e0b;
1521
+ border-radius: 50%;
1522
+ animation: sun-glow 3s ease-in-out infinite;
1523
+ }
1524
+
1525
+ /* Heat Vision Skeleton Animation */
1526
+ @keyframes heat-sweep {
1527
+ 0% { background-position: -200% 0; }
1528
+ 100% { background-position: 200% 0; }
1529
+ }
1530
+
1531
+ .skeleton-loader {
1532
+ height: 60px; /* Adjust based on your text line height */
1533
+ border-radius: 4px;
1534
+ /* Sweeps a soft red gradient over a standard grey skeleton base */
1535
+ background: linear-gradient(90deg, #f3f4f6 25%, rgba(239, 68, 68, 0.15) 50%, #f3f4f6 75%);
1536
+ background-size: 200% 100%;
1537
+ animation: heat-sweep 1.5s infinite linear;
1538
+ }
1539
+
1540
+ /* ============================================================
1541
+ 11. RESPONSIVE
1542
+ ============================================================ */
1543
+ @media (max-width: 1200px) {
1544
+ .app-main {
1545
+ grid-template-columns: 260px 1fr 300px;
1546
+ }
1547
+ }
1548
+
1549
+ @media (max-width: 900px) {
1550
+ .app-main {
1551
+ grid-template-columns: 1fr;
1552
+ grid-template-rows: auto 1fr auto;
1553
+ }
1554
+
1555
+ .panel {
1556
+ height: auto;
1557
+ max-height: 50vh;
1558
+ border-right: none;
1559
+ border-bottom: 1px solid var(--border-default);
1560
+ }
1561
+ }
1562
+
1563
+ /* ============================================================
1564
+ 12. AI REFEREE (TASK 5.1)
1565
+ ============================================================ */
1566
+ .arena-referee-panel {
1567
+ margin-top: 2rem;
1568
+ padding: 1.5rem;
1569
+ border-radius: 8px;
1570
+ background-color: var(--bg-surface-raised);
1571
+ border: 1px solid var(--border-default);
1572
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.02);
1573
+ }
1574
+
1575
+ .referee-divider {
1576
+ display: flex;
1577
+ align-items: center;
1578
+ text-align: center;
1579
+ margin-bottom: 1.5rem;
1580
+ }
1581
+
1582
+ .referee-divider::before,
1583
+ .referee-divider::after {
1584
+ content: '';
1585
+ flex: 1;
1586
+ border-bottom: 1px dashed var(--border-default);
1587
+ }
1588
+
1589
+ .referee-badge {
1590
+ padding: 0.25rem 0.75rem;
1591
+ font-family: 'Roboto Condensed', sans-serif;
1592
+ font-weight: 700;
1593
+ font-size: 0.85rem;
1594
+ color: var(--superman-blue);
1595
+ background-color: var(--superman-blue-muted);
1596
+ border-radius: 50px;
1597
+ border: 1px solid rgba(37, 99, 235, 0.2);
1598
+ letter-spacing: 0.05em;
1599
+ margin: 0 1rem;
1600
+ }
1601
+
1602
+ .referee-prompt-text {
1603
+ text-align: center;
1604
+ color: var(--text-secondary);
1605
+ font-size: 0.9rem;
1606
+ margin-bottom: 1rem;
1607
+ }
1608
+
1609
+ .referee-status-text {
1610
+ text-align: center;
1611
+ font-weight: 500;
1612
+ color: var(--superman-blue);
1613
+ font-size: 0.9rem;
1614
+ animation: pulse 1.5s infinite ease-in-out;
1615
+ }
1616
+
1617
+ .referee-verdict-box {
1618
+ animation: fadeIn 0.4s ease-out;
1619
+ }
1620
+
1621
+ /* Verdict banner */
1622
+ .verdict-banner {
1623
+ display: flex;
1624
+ align-items: center;
1625
+ justify-content: space-between;
1626
+ padding: 1rem 1.5rem;
1627
+ border-radius: 6px;
1628
+ border: 1.5px solid;
1629
+ margin-bottom: 1.5rem;
1630
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
1631
+ }
1632
+
1633
+ .verdict-title {
1634
+ font-family: 'Roboto Condensed', sans-serif;
1635
+ font-weight: 700;
1636
+ font-size: 1.2rem;
1637
+ letter-spacing: 0.02em;
1638
+ }
1639
+
1640
+ .verdict-confidence {
1641
+ font-size: 0.85rem;
1642
+ font-weight: 600;
1643
+ background-color: rgba(255, 255, 255, 0.7);
1644
+ padding: 0.2rem 0.5rem;
1645
+ border-radius: 4px;
1646
+ border: 1px solid rgba(0, 0, 0, 0.05);
1647
+ }
1648
+
1649
+ /* Verdict reason */
1650
+ .verdict-reason {
1651
+ background-color: var(--bg-surface);
1652
+ padding: 1.25rem;
1653
+ border-radius: 6px;
1654
+ border: 1px solid var(--border-default);
1655
+ margin-bottom: 1.5rem;
1656
+ }
1657
+
1658
+ .verdict-reason > strong {
1659
+ display: block;
1660
+ font-size: 0.85rem;
1661
+ color: var(--text-tertiary);
1662
+ text-transform: uppercase;
1663
+ letter-spacing: 0.05em;
1664
+ margin-bottom: 0.5rem;
1665
+ }
1666
+
1667
+ .verdict-reason p {
1668
+ font-size: 0.95rem;
1669
+ color: var(--text-primary);
1670
+ line-height: 1.5;
1671
+ margin-bottom: 0.75rem;
1672
+ font-style: italic;
1673
+ }
1674
+
1675
+ .deciding-factor {
1676
+ font-size: 0.8rem;
1677
+ color: var(--text-tertiary);
1678
+ display: flex;
1679
+ align-items: center;
1680
+ gap: 0.5rem;
1681
+ }
1682
+
1683
+ .deciding-factor code {
1684
+ background-color: var(--bg-surface-raised);
1685
+ padding: 0.1rem 0.4rem;
1686
+ border-radius: 4px;
1687
+ font-family: 'JetBrains Mono', monospace;
1688
+ color: var(--superman-red);
1689
+ font-weight: 600;
1690
+ }
1691
+
1692
+ /* Scorecard Table */
1693
+ .referee-scores-table {
1694
+ background-color: var(--bg-surface);
1695
+ border: 1px solid var(--border-default);
1696
+ border-radius: 6px;
1697
+ overflow: hidden;
1698
+ margin-bottom: 1.5rem;
1699
+ }
1700
+
1701
+ .scores-table-header {
1702
+ display: grid;
1703
+ grid-template-columns: 150px 70px 1fr 70px;
1704
+ padding: 0.75rem 1rem;
1705
+ background-color: var(--bg-surface-raised);
1706
+ border-bottom: 1px solid var(--border-default);
1707
+ font-weight: 600;
1708
+ font-size: 0.8rem;
1709
+ color: var(--text-tertiary);
1710
+ text-transform: uppercase;
1711
+ letter-spacing: 0.05em;
1712
+ }
1713
+
1714
+ .scores-table-row {
1715
+ display: grid;
1716
+ grid-template-columns: 150px 70px 1fr 70px;
1717
+ align-items: center;
1718
+ padding: 0.75rem 1rem;
1719
+ border-bottom: 1px solid var(--border-default);
1720
+ font-size: 0.9rem;
1721
+ }
1722
+
1723
+ .scores-table-row:last-child {
1724
+ border-bottom: none;
1725
+ }
1726
+
1727
+ .overall-row {
1728
+ background-color: rgba(37, 99, 235, 0.02);
1729
+ border-top: 1.5px solid var(--border-default);
1730
+ }
1731
+
1732
+ .score-col-label {
1733
+ font-weight: 500;
1734
+ color: var(--text-primary);
1735
+ }
1736
+
1737
+ .score-col-val {
1738
+ text-align: center;
1739
+ font-weight: 600;
1740
+ }
1741
+
1742
+ .score-col-bar {
1743
+ padding: 0 1rem;
1744
+ }
1745
+
1746
+ .score-progress-bar {
1747
+ height: 6px;
1748
+ background-color: #e2e8f0;
1749
+ border-radius: 10px;
1750
+ overflow: hidden;
1751
+ display: grid;
1752
+ grid-template-columns: 1fr 1fr;
1753
+ gap: 2px;
1754
+ }
1755
+
1756
+ .score-val-a {
1757
+ background-color: var(--superman-yellow);
1758
+ height: 100%;
1759
+ border-radius: 10px 0 0 10px;
1760
+ justify-self: end;
1761
+ }
1762
+
1763
+ .score-val-b {
1764
+ background-color: var(--superman-blue);
1765
+ height: 100%;
1766
+ border-radius: 0 10px 10px 0;
1767
+ justify-self: start;
1768
+ }
1769
+
1770
+ .overall-progress-bar {
1771
+ height: 10px;
1772
+ background-color: #e2e8f0;
1773
+ border-radius: 10px;
1774
+ overflow: hidden;
1775
+ display: grid;
1776
+ grid-template-columns: 1fr 1fr;
1777
+ gap: 2px;
1778
+ }
1779
+
1780
+ .overall-val-a {
1781
+ background-color: var(--superman-yellow);
1782
+ height: 100%;
1783
+ border-radius: 10px 0 0 10px;
1784
+ justify-self: end;
1785
+ }
1786
+
1787
+ .overall-val-b {
1788
+ background-color: var(--superman-blue);
1789
+ height: 100%;
1790
+ border-radius: 0 10px 10px 0;
1791
+ justify-self: start;
1792
+ }
1793
+
1794
+ /* Strengths & Weaknesses Columns */
1795
+ .verdict-details-grid {
1796
+ display: grid;
1797
+ grid-template-columns: 1fr 1fr;
1798
+ gap: 1.5rem;
1799
+ }
1800
+
1801
+ .verdict-details-col {
1802
+ background-color: var(--bg-surface);
1803
+ border: 1px solid var(--border-default);
1804
+ padding: 1.25rem;
1805
+ border-radius: 6px;
1806
+ }
1807
+
1808
+ .verdict-details-col h4 {
1809
+ font-family: 'Roboto Condensed', sans-serif;
1810
+ font-weight: 700;
1811
+ margin-bottom: 1rem;
1812
+ font-size: 1rem;
1813
+ border-bottom: 1.5px solid var(--border-default);
1814
+ padding-bottom: 0.5rem;
1815
+ }
1816
+
1817
+ .verdict-details-col:first-child h4 {
1818
+ color: var(--warning-color);
1819
+ border-bottom-color: rgba(245, 158, 11, 0.2);
1820
+ }
1821
+
1822
+ .verdict-details-col:last-child h4 {
1823
+ color: var(--info-color);
1824
+ border-bottom-color: rgba(59, 130, 246, 0.2);
1825
+ }
1826
+
1827
+ .verdict-list-title {
1828
+ font-size: 0.75rem;
1829
+ font-weight: 700;
1830
+ text-transform: uppercase;
1831
+ letter-spacing: 0.05em;
1832
+ margin-bottom: 0.5rem;
1833
+ }
1834
+
1835
+ .strength-list-title {
1836
+ color: #16a34a;
1837
+ }
1838
+
1839
+ .weakness-list-title {
1840
+ color: #dc2626;
1841
+ margin-top: 1rem;
1842
+ }
1843
+
1844
+ .verdict-details-col ul {
1845
+ padding-left: 1.25rem;
1846
+ margin: 0;
1847
+ }
1848
+
1849
+ .verdict-details-col li {
1850
+ font-size: 0.85rem;
1851
+ color: var(--text-secondary);
1852
+ line-height: 1.4;
1853
+ margin-bottom: 0.4rem;
1854
+ }
1855
+
1856
+ @keyframes fadeIn {
1857
+ from { opacity: 0; transform: translateY(10px); }
1858
+ to { opacity: 1; transform: translateY(0); }
1859
+ }
1860
+
1861
+
1862
+ /* ============================================================
1863
+ RETRIEVAL X-RAY HIGHLIGHTS (TASK 3.4)
1864
+ ============================================================ */
1865
+ .xray-content.search-active .chunk-highlight {
1866
+ opacity: 0.3;
1867
+ transition: all 0.3s ease;
1868
+ }
1869
+
1870
+ .xray-content.search-active .chunk-highlight.retrieved-rank-1 {
1871
+ opacity: 1.0;
1872
+ background-color: rgba(255, 165, 0, 0.4);
1873
+ box-shadow: 0 0 12px rgba(255, 165, 0, 0.6);
1874
+ border-top: 2px solid var(--warning-color);
1875
+ border-bottom: 2px solid var(--warning-color);
1876
+ z-index: 10;
1877
+ position: relative;
1878
+ }
1879
+
1880
+ .xray-content.search-active .chunk-highlight.retrieved-rank-2 {
1881
+ opacity: 0.9;
1882
+ background-color: rgba(255, 165, 0, 0.25);
1883
+ border-top: 2px dashed rgba(255, 165, 0, 0.6);
1884
+ border-bottom: 2px dashed rgba(255, 165, 0, 0.6);
1885
+ z-index: 9;
1886
+ position: relative;
1887
+ }
1888
+
1889
+ .xray-content.search-active .chunk-highlight.retrieved-rank-3 {
1890
+ opacity: 0.8;
1891
+ background-color: rgba(255, 165, 0, 0.15);
1892
+ border-top: 2px dotted rgba(255, 165, 0, 0.4);
1893
+ border-bottom: 2px dotted rgba(255, 165, 0, 0.4);
1894
+ z-index: 8;
1895
+ position: relative;
1896
+ }
1897
+
1898
+
1899
+
pyproject.toml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "rag-visualizer"
3
+ version = "0.1.0"
4
+ description = "An X-Ray machine for RAG — visualize and compare chunking, embeddings, and retrieval across local models."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastapi>=0.115.0",
9
+ "uvicorn[standard]>=0.34.0",
10
+ "pydantic>=2.0",
11
+ "chromadb>=1.0.0",
12
+ "tiktoken>=0.9.0",
13
+ "httpx>=0.28.0",
14
+ "langchain-text-splitters>=1.1.2",
15
+ "nltk>=3.9.4",
16
+ "umap-learn>=0.5.12",
17
+ "numpy>=2.4.4",
18
+ "ollama>=0.6.2",
19
+ ]
uv.lock ADDED
The diff for this file is too large to render. See raw diff