Besjon Cifliku commited on
Commit ·
9c3ade2
1
Parent(s): e29b232
feat: implement anomaly detection to filter suspicious word relations
Browse files- Dockerfile +13 -2
- anomaly.py +401 -0
- background_model.py +93 -0
- docker-compose.yml +6 -1
- frontend/src/App.tsx +70 -156
- frontend/src/api.ts +17 -3
- frontend/src/components/AnomalyPanel.tsx +289 -0
- frontend/src/components/DatasetPanel.tsx +3 -1
- frontend/src/components/Word2VecPanel.tsx +5 -35
- frontend/src/types.ts +65 -0
- frontend/tsconfig.tsbuildinfo +1 -1
- server.py +92 -14
Dockerfile
CHANGED
|
@@ -39,14 +39,25 @@ COPY --chown=appuser *.py ./
|
|
| 39 |
# Copy pre-built frontend
|
| 40 |
COPY --chown=appuser --from=frontend-build /app/frontend/dist ./frontend/dist
|
| 41 |
|
| 42 |
-
# Data directories (HF cache, engine state, trained models)
|
| 43 |
-
RUN mkdir -p /data/huggingface /data/engine_state /data/w2v_state /data/trained_model \
|
| 44 |
&& chown -R appuser:appuser /app /data
|
| 45 |
|
| 46 |
ENV HF_HOME=/data/huggingface
|
| 47 |
ENV TRANSFORMERS_CACHE=/data/huggingface
|
| 48 |
ENV ENGINE_STATE_DIR=/data/engine_state
|
| 49 |
ENV W2V_STATE_DIR=/data/w2v_state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
# Switch to non-root user
|
| 52 |
USER appuser
|
|
|
|
| 39 |
# Copy pre-built frontend
|
| 40 |
COPY --chown=appuser --from=frontend-build /app/frontend/dist ./frontend/dist
|
| 41 |
|
| 42 |
+
# Data directories (HF cache, gensim/GloVe cache, engine state, trained models)
|
| 43 |
+
RUN mkdir -p /data/huggingface /data/gensim-data /data/engine_state /data/w2v_state /data/trained_model \
|
| 44 |
&& chown -R appuser:appuser /app /data
|
| 45 |
|
| 46 |
ENV HF_HOME=/data/huggingface
|
| 47 |
ENV TRANSFORMERS_CACHE=/data/huggingface
|
| 48 |
ENV ENGINE_STATE_DIR=/data/engine_state
|
| 49 |
ENV W2V_STATE_DIR=/data/w2v_state
|
| 50 |
+
# GloVe background model cache for anomaly detection — keep it under /data so it
|
| 51 |
+
# sits on persistent storage and isn't re-downloaded (~128MB) on every cold start.
|
| 52 |
+
ENV GENSIM_DATA_DIR=/data/gensim-data
|
| 53 |
+
# Override the background model (e.g. glove-wiki-gigaword-50) without code changes.
|
| 54 |
+
ENV BACKGROUND_MODEL=glove-wiki-gigaword-100
|
| 55 |
+
|
| 56 |
+
# Bake the background model into the image so anomaly detection works offline,
|
| 57 |
+
# with no runtime download or cold-start wait (adds ~128MB to the image).
|
| 58 |
+
# return_path=True just ensures the download/cache without loading it into RAM.
|
| 59 |
+
RUN uv run python -c "import os, gensim.downloader as d; d.load(os.environ['BACKGROUND_MODEL'], return_path=True)" \
|
| 60 |
+
&& chown -R appuser:appuser /data/gensim-data
|
| 61 |
|
| 62 |
# Switch to non-root user
|
| 63 |
USER appuser
|
anomaly.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Anomalous-relation detection — finding "code word" candidates.
|
| 3 |
+
|
| 4 |
+
A code word is a *common English word used uncommonly in this corpus*. We make
|
| 5 |
+
that precise by contrasting two distributional word spaces:
|
| 6 |
+
|
| 7 |
+
- the corpus Word2Vec (how words associate IN these documents), and
|
| 8 |
+
- a pretrained general-English model (how they associate NORMALLY).
|
| 9 |
+
|
| 10 |
+
The whole design avoids two traps:
|
| 11 |
+
1. Raw cosines from different embedding spaces are NOT comparable, so we never
|
| 12 |
+
subtract a corpus cosine from a background cosine. We compare neighbour
|
| 13 |
+
*sets* (scale-free) and, when we need per-neighbour scores, we standardise
|
| 14 |
+
within each space (z-scores relative to the same anchor word) before
|
| 15 |
+
combining — that subtraction *is* legitimate.
|
| 16 |
+
2. "Low similarity" is the default for almost all word pairs and is not a
|
| 17 |
+
signal. The signal is *surprise*: strong here, weak normally.
|
| 18 |
+
|
| 19 |
+
Three stages:
|
| 20 |
+
A. sweep — rank which words behave most differently here vs. normally
|
| 21 |
+
(neighbour-set divergence, z-scored across the vocabulary).
|
| 22 |
+
B. relations — for one flagged word, the specific neighbours that are
|
| 23 |
+
strong in-corpus but weak/absent in general English.
|
| 24 |
+
C. incongruence — uses the transformer to find the specific occurrences
|
| 25 |
+
(chunks/docs) where a keyword is used unlike its norm.
|
| 26 |
+
|
| 27 |
+
Stages A/B need the corpus Word2Vec + background model. Stage C needs the
|
| 28 |
+
transformer engine (contextual embeddings), which is the only one of the three
|
| 29 |
+
models that can judge a single *occurrence* in context.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import logging
|
| 33 |
+
import re
|
| 34 |
+
import threading
|
| 35 |
+
from typing import Optional
|
| 36 |
+
|
| 37 |
+
import numpy as np
|
| 38 |
+
|
| 39 |
+
from contextual_similarity import ContextualSimilarityEngine
|
| 40 |
+
from word2vec_baseline import Word2VecEngine
|
| 41 |
+
from background_model import BackgroundModel
|
| 42 |
+
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
# Reuse the engine's stopword list so the three tools agree on what to ignore.
|
| 46 |
+
_STOPWORDS = ContextualSimilarityEngine._STOPWORDS
|
| 47 |
+
_ALPHA = re.compile(r"^[a-z]+$")
|
| 48 |
+
|
| 49 |
+
# Capitalised spans (names/orgs) and dates, for the Stage-C investigation view.
|
| 50 |
+
_ENTITY_RE = re.compile(r"\b[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+){0,3}\b")
|
| 51 |
+
_DATE_RE = re.compile(
|
| 52 |
+
r"\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}"
|
| 53 |
+
r"|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2}"
|
| 54 |
+
r"|\d{4})\b"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _is_candidate_word(word: str) -> bool:
|
| 59 |
+
"""A gating word must be a plain lowercase English word, not a stopword/short token."""
|
| 60 |
+
return len(word) >= 3 and bool(_ALPHA.match(word)) and word not in _STOPWORDS
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _normalize_rows(mat: np.ndarray) -> np.ndarray:
|
| 64 |
+
norms = np.linalg.norm(mat, axis=1, keepdims=True)
|
| 65 |
+
norms[norms == 0] = 1e-12
|
| 66 |
+
return (mat / norms).astype(np.float32)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class SharedSpace:
|
| 70 |
+
"""
|
| 71 |
+
The shared vocabulary of words present in BOTH the corpus Word2Vec and the
|
| 72 |
+
background model, with their normalised vectors in each space.
|
| 73 |
+
|
| 74 |
+
Restricting to shared words keeps neighbour-set overlap fair (both spaces
|
| 75 |
+
rank the *same* candidate set) and excludes names/jargon (absent from the
|
| 76 |
+
background) — which are domain vocabulary, not code words.
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
def __init__(self, words: list[str], corpus_vecs: np.ndarray,
|
| 80 |
+
bg_vecs: np.ndarray, corpus_counts: np.ndarray):
|
| 81 |
+
self.words = words
|
| 82 |
+
self.index = {w: i for i, w in enumerate(words)}
|
| 83 |
+
self.C = _normalize_rows(corpus_vecs) # (n, d_corpus)
|
| 84 |
+
self.B = _normalize_rows(bg_vecs) # (n, d_bg)
|
| 85 |
+
self.counts = corpus_counts # corpus frequency per word
|
| 86 |
+
|
| 87 |
+
def __len__(self) -> int:
|
| 88 |
+
return len(self.words)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def build_shared_space(
|
| 92 |
+
w2v: Word2VecEngine,
|
| 93 |
+
background: BackgroundModel,
|
| 94 |
+
min_count: int = 5,
|
| 95 |
+
max_vocab: int = 3000,
|
| 96 |
+
) -> SharedSpace:
|
| 97 |
+
"""
|
| 98 |
+
Build the shared-vocabulary space.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
min_count: ignore corpus words rarer than this (their vectors are noise).
|
| 102 |
+
max_vocab: cap to the N most frequent shared words for tractability.
|
| 103 |
+
"""
|
| 104 |
+
wv = w2v.model.wv
|
| 105 |
+
# Collect candidate words: frequent enough in the corpus, common in English.
|
| 106 |
+
candidates: list[tuple[str, int]] = []
|
| 107 |
+
for word in wv.index_to_key:
|
| 108 |
+
if not _is_candidate_word(word):
|
| 109 |
+
continue
|
| 110 |
+
count = wv.get_vecattr(word, "count")
|
| 111 |
+
if count < min_count:
|
| 112 |
+
continue
|
| 113 |
+
if not background.has(word):
|
| 114 |
+
continue
|
| 115 |
+
candidates.append((word, count))
|
| 116 |
+
|
| 117 |
+
# Keep the most frequent ones (their corpus vectors are the most reliable).
|
| 118 |
+
candidates.sort(key=lambda x: -x[1])
|
| 119 |
+
if len(candidates) > max_vocab:
|
| 120 |
+
logger.info("Shared vocab capped: %d candidates -> top %d by corpus frequency",
|
| 121 |
+
len(candidates), max_vocab)
|
| 122 |
+
candidates = candidates[:max_vocab]
|
| 123 |
+
|
| 124 |
+
words = [w for w, _ in candidates]
|
| 125 |
+
counts = np.array([c for _, c in candidates], dtype=np.int64)
|
| 126 |
+
corpus_vecs = np.array([wv[w] for w in words], dtype=np.float32)
|
| 127 |
+
bg_vecs = np.array([background.kv[w] for w in words], dtype=np.float32)
|
| 128 |
+
logger.info("Shared space built: %d words (corpus∩background, count>=%d)", len(words), min_count)
|
| 129 |
+
return SharedSpace(words, corpus_vecs, bg_vecs, counts)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# Module-level cache so the (expensive) shared space is reused across requests
|
| 133 |
+
# until the underlying corpus Word2Vec changes.
|
| 134 |
+
_cache_lock = threading.Lock()
|
| 135 |
+
_cached: dict = {"key": None, "space": None}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def get_shared_space(w2v: Word2VecEngine, background: BackgroundModel,
|
| 139 |
+
min_count: int, max_vocab: int) -> SharedSpace:
|
| 140 |
+
key = (id(w2v.model), len(w2v.model.wv), background.model_name, min_count, max_vocab)
|
| 141 |
+
with _cache_lock:
|
| 142 |
+
if _cached["key"] == key and _cached["space"] is not None:
|
| 143 |
+
return _cached["space"]
|
| 144 |
+
space = build_shared_space(w2v, background, min_count, max_vocab)
|
| 145 |
+
_cached["key"] = key
|
| 146 |
+
_cached["space"] = space
|
| 147 |
+
return space
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _topk_neighbours(sims: np.ndarray, self_idx: int, k: int) -> list[int]:
|
| 151 |
+
"""Indices of the top-k most similar rows, excluding the anchor itself."""
|
| 152 |
+
sims = sims.copy()
|
| 153 |
+
sims[self_idx] = -np.inf
|
| 154 |
+
if k >= len(sims):
|
| 155 |
+
order = np.argsort(sims)[::-1]
|
| 156 |
+
else:
|
| 157 |
+
part = np.argpartition(sims, -k)[-k:]
|
| 158 |
+
order = part[np.argsort(sims[part])[::-1]]
|
| 159 |
+
return [int(i) for i in order]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ------------------------------------------------------------------ #
|
| 163 |
+
# Stage A — corpus-wide sweep: which words behave most anomalously?
|
| 164 |
+
# ------------------------------------------------------------------ #
|
| 165 |
+
|
| 166 |
+
def sweep_anomalous_words(
|
| 167 |
+
w2v: Word2VecEngine,
|
| 168 |
+
background: BackgroundModel,
|
| 169 |
+
min_count: int = 5,
|
| 170 |
+
max_vocab: int = 3000,
|
| 171 |
+
neighbours: int = 25,
|
| 172 |
+
top_n: int = 30,
|
| 173 |
+
preview: int = 6,
|
| 174 |
+
) -> dict:
|
| 175 |
+
"""
|
| 176 |
+
Rank words by how differently they associate here vs. in general English.
|
| 177 |
+
|
| 178 |
+
For each shared word W we compute its top-`neighbours` in each space and
|
| 179 |
+
score `shift = 1 - overlap@k` (Jaccard-style overlap of the two neighbour
|
| 180 |
+
sets). Overlap is scale-free — no cross-space cosine arithmetic. We then
|
| 181 |
+
z-score `shift` across the whole vocabulary so flagging adapts to the corpus
|
| 182 |
+
instead of using a magic threshold.
|
| 183 |
+
"""
|
| 184 |
+
space = get_shared_space(w2v, background, min_count, max_vocab)
|
| 185 |
+
n = len(space)
|
| 186 |
+
if n < neighbours + 2:
|
| 187 |
+
return {"ready": True, "vocab_size": n, "results": [],
|
| 188 |
+
"note": "Shared vocabulary too small to compute neighbourhoods."}
|
| 189 |
+
|
| 190 |
+
C, B = space.C, space.B
|
| 191 |
+
k = min(neighbours, n - 1)
|
| 192 |
+
|
| 193 |
+
shifts = np.zeros(n, dtype=np.float32)
|
| 194 |
+
corpus_nbrs: list[list[int]] = [None] * n
|
| 195 |
+
bg_nbrs: list[list[int]] = [None] * n
|
| 196 |
+
for i in range(n):
|
| 197 |
+
c_sims = C @ C[i]
|
| 198 |
+
b_sims = B @ B[i]
|
| 199 |
+
cn = _topk_neighbours(c_sims, i, k)
|
| 200 |
+
bn = _topk_neighbours(b_sims, i, k)
|
| 201 |
+
corpus_nbrs[i] = cn
|
| 202 |
+
bg_nbrs[i] = bn
|
| 203 |
+
overlap = len(set(cn) & set(bn)) / k
|
| 204 |
+
shifts[i] = 1.0 - overlap
|
| 205 |
+
|
| 206 |
+
mean, std = float(shifts.mean()), float(shifts.std())
|
| 207 |
+
std = std if std > 1e-9 else 1e-9
|
| 208 |
+
|
| 209 |
+
order = np.argsort(shifts)[::-1][:top_n]
|
| 210 |
+
results = []
|
| 211 |
+
for i in order:
|
| 212 |
+
i = int(i)
|
| 213 |
+
bg_set = set(bg_nbrs[i])
|
| 214 |
+
# Corpus neighbours that are NOT normal neighbours = the surprising ties.
|
| 215 |
+
surprising = [space.words[j] for j in corpus_nbrs[i] if j not in bg_set][:preview]
|
| 216 |
+
normal = [space.words[j] for j in bg_nbrs[i]][:preview]
|
| 217 |
+
results.append({
|
| 218 |
+
"word": space.words[i],
|
| 219 |
+
"corpus_frequency": int(space.counts[i]),
|
| 220 |
+
"shift": round(float(shifts[i]), 4),
|
| 221 |
+
"z_score": round((float(shifts[i]) - mean) / std, 3),
|
| 222 |
+
"surprising_neighbors": surprising, # strong here, absent normally
|
| 223 |
+
"normal_neighbors": normal, # what's normal in English
|
| 224 |
+
})
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
"ready": True,
|
| 228 |
+
"vocab_size": n,
|
| 229 |
+
"neighbours": k,
|
| 230 |
+
"shift_mean": round(mean, 4),
|
| 231 |
+
"shift_std": round(std, 4),
|
| 232 |
+
"results": results,
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ------------------------------------------------------------------ #
|
| 237 |
+
# Stage B — per-relation surprise for a single flagged word.
|
| 238 |
+
# ------------------------------------------------------------------ #
|
| 239 |
+
|
| 240 |
+
def relation_surprise(
|
| 241 |
+
word: str,
|
| 242 |
+
w2v: Word2VecEngine,
|
| 243 |
+
background: BackgroundModel,
|
| 244 |
+
min_count: int = 5,
|
| 245 |
+
max_vocab: int = 3000,
|
| 246 |
+
top_k: int = 15,
|
| 247 |
+
) -> dict:
|
| 248 |
+
"""
|
| 249 |
+
For a single word, the neighbours that are strong in-corpus but weak/absent
|
| 250 |
+
in general English — the concrete "pizza -> fitness" rows.
|
| 251 |
+
|
| 252 |
+
We standardise each space's similarities to the anchor word into z-scores
|
| 253 |
+
(dimensionless, relative to the same anchor), then `surprise = corpus_z -
|
| 254 |
+
background_z`. Subtracting two within-space z-scores IS valid, unlike
|
| 255 |
+
subtracting raw cross-space cosines.
|
| 256 |
+
"""
|
| 257 |
+
word = word.lower().strip()
|
| 258 |
+
space = get_shared_space(w2v, background, min_count, max_vocab)
|
| 259 |
+
|
| 260 |
+
if word not in space.index:
|
| 261 |
+
in_corpus = word in w2v.model.wv
|
| 262 |
+
in_bg = background.has(word)
|
| 263 |
+
if in_corpus and not in_bg:
|
| 264 |
+
reason = ("not a common English word (absent from the background model), "
|
| 265 |
+
"so it's treated as domain vocabulary — a name/jargon, not a code-word candidate")
|
| 266 |
+
elif not in_corpus:
|
| 267 |
+
reason = "not in the corpus vocabulary (or too rare)"
|
| 268 |
+
else:
|
| 269 |
+
reason = "below the minimum corpus frequency"
|
| 270 |
+
return {"word": word, "ready": True, "found": False, "reason": reason, "relations": []}
|
| 271 |
+
|
| 272 |
+
i = space.index[word]
|
| 273 |
+
c_sims = space.C @ space.C[i]
|
| 274 |
+
b_sims = space.B @ space.B[i]
|
| 275 |
+
|
| 276 |
+
def zscore(sims: np.ndarray) -> np.ndarray:
|
| 277 |
+
m, s = sims.mean(), sims.std()
|
| 278 |
+
return (sims - m) / (s if s > 1e-9 else 1e-9)
|
| 279 |
+
|
| 280 |
+
c_z, b_z = zscore(c_sims), zscore(b_sims)
|
| 281 |
+
surprise = c_z - b_z
|
| 282 |
+
surprise[i] = -np.inf # exclude self
|
| 283 |
+
|
| 284 |
+
order = np.argsort(surprise)[::-1][:top_k]
|
| 285 |
+
relations = [{
|
| 286 |
+
"neighbor": space.words[j],
|
| 287 |
+
"corpus_sim": round(float(c_sims[j]), 4), # raw cosines: display only
|
| 288 |
+
"background_sim": round(float(b_sims[j]), 4),
|
| 289 |
+
"corpus_z": round(float(c_z[j]), 3),
|
| 290 |
+
"background_z": round(float(b_z[j]), 3),
|
| 291 |
+
"surprise": round(float(surprise[j]), 3),
|
| 292 |
+
} for j in (int(x) for x in order)]
|
| 293 |
+
|
| 294 |
+
# Contrast: what this word's NORMAL neighbours are, in general English.
|
| 295 |
+
normal_order = _topk_neighbours(b_sims, i, min(top_k, len(space) - 1))
|
| 296 |
+
normal = [{"neighbor": space.words[j], "background_sim": round(float(b_sims[j]), 4)}
|
| 297 |
+
for j in normal_order]
|
| 298 |
+
|
| 299 |
+
return {
|
| 300 |
+
"word": word,
|
| 301 |
+
"ready": True,
|
| 302 |
+
"found": True,
|
| 303 |
+
"corpus_frequency": int(space.counts[i]),
|
| 304 |
+
"relations": relations,
|
| 305 |
+
"normal_neighbors": normal,
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# ------------------------------------------------------------------ #
|
| 310 |
+
# Stage C — contextual incongruence (the "zoom in"), via the transformer.
|
| 311 |
+
# ------------------------------------------------------------------ #
|
| 312 |
+
|
| 313 |
+
def _extract_entities(text: str, limit: int = 8) -> list[str]:
|
| 314 |
+
"""Capitalised name-like spans and dates, for the investigation view."""
|
| 315 |
+
found: list[str] = []
|
| 316 |
+
seen: set[str] = set()
|
| 317 |
+
for m in _ENTITY_RE.finditer(text):
|
| 318 |
+
token = m.group().strip()
|
| 319 |
+
# Skip single sentence-initial capitalised stopwords ("The", "And", ...)
|
| 320 |
+
if " " not in token and token.lower() in _STOPWORDS:
|
| 321 |
+
continue
|
| 322 |
+
if token.lower() not in seen:
|
| 323 |
+
seen.add(token.lower())
|
| 324 |
+
found.append(token)
|
| 325 |
+
for m in _DATE_RE.finditer(text):
|
| 326 |
+
d = m.group()
|
| 327 |
+
if d.lower() not in seen:
|
| 328 |
+
seen.add(d.lower())
|
| 329 |
+
found.append(d)
|
| 330 |
+
return found[:limit]
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def contextual_incongruence(
|
| 334 |
+
engine: ContextualSimilarityEngine,
|
| 335 |
+
keyword: str,
|
| 336 |
+
canonical_meaning: Optional[str] = None,
|
| 337 |
+
top_k: int = 10,
|
| 338 |
+
) -> dict:
|
| 339 |
+
"""
|
| 340 |
+
Find the occurrences where `keyword` is used most unlike its norm.
|
| 341 |
+
|
| 342 |
+
Reference meaning is either:
|
| 343 |
+
- `canonical_meaning` (a gloss you supply, e.g. "pizza, an Italian food"), or
|
| 344 |
+
- the centroid of all the keyword's occurrence embeddings (its typical
|
| 345 |
+
usage in THIS corpus) when no gloss is given.
|
| 346 |
+
|
| 347 |
+
Per occurrence: incongruence = 1 - cos(chunk_embedding, reference). The
|
| 348 |
+
highest-incongruence chunks are the candidate coded usages — returned with
|
| 349 |
+
doc/snippet and co-occurring entities so you can read them directly.
|
| 350 |
+
"""
|
| 351 |
+
engine._ensure_index()
|
| 352 |
+
contexts = engine.find_keyword_contexts(keyword)
|
| 353 |
+
if not contexts:
|
| 354 |
+
return {"keyword": keyword, "total_occurrences": 0, "occurrences": []}
|
| 355 |
+
|
| 356 |
+
chunk_indices = [engine.chunks.index(ctx.chunk) for ctx in contexts]
|
| 357 |
+
embeds = engine.embeddings[chunk_indices] # rows are L2-normalised (build_index)
|
| 358 |
+
|
| 359 |
+
if canonical_meaning and canonical_meaning.strip():
|
| 360 |
+
ref = engine.model.encode([canonical_meaning], normalize_embeddings=True,
|
| 361 |
+
convert_to_numpy=True)[0].astype(np.float32)
|
| 362 |
+
ref_label = canonical_meaning.strip()
|
| 363 |
+
ref_kind = "gloss"
|
| 364 |
+
else:
|
| 365 |
+
centroid = embeds.mean(axis=0)
|
| 366 |
+
norm = np.linalg.norm(centroid)
|
| 367 |
+
ref = (centroid / (norm if norm > 0 else 1e-12)).astype(np.float32)
|
| 368 |
+
ref_label = "corpus-typical usage (centroid of all occurrences)"
|
| 369 |
+
ref_kind = "centroid"
|
| 370 |
+
|
| 371 |
+
sims = embeds @ ref # cosine (both sides unit-norm)
|
| 372 |
+
incong = 1.0 - sims
|
| 373 |
+
median_incong = float(np.median(incong))
|
| 374 |
+
|
| 375 |
+
order = np.argsort(incong)[::-1][:top_k]
|
| 376 |
+
occurrences = []
|
| 377 |
+
for j in (int(x) for x in order):
|
| 378 |
+
ctx = contexts[j]
|
| 379 |
+
text = ctx.chunk.text
|
| 380 |
+
if ctx.highlight_positions:
|
| 381 |
+
start, end = ctx.highlight_positions[0]
|
| 382 |
+
s, e = max(0, start - 120), min(len(text), end + 120)
|
| 383 |
+
snippet = ("..." if s > 0 else "") + text[s:e].strip() + ("..." if e < len(text) else "")
|
| 384 |
+
else:
|
| 385 |
+
snippet = text[:240]
|
| 386 |
+
occurrences.append({
|
| 387 |
+
"doc_id": ctx.chunk.doc_id,
|
| 388 |
+
"chunk_index": ctx.chunk.chunk_index,
|
| 389 |
+
"incongruence": round(float(incong[j]), 4),
|
| 390 |
+
"snippet": snippet,
|
| 391 |
+
"entities": _extract_entities(text),
|
| 392 |
+
})
|
| 393 |
+
|
| 394 |
+
return {
|
| 395 |
+
"keyword": keyword,
|
| 396 |
+
"total_occurrences": len(contexts),
|
| 397 |
+
"reference": ref_label,
|
| 398 |
+
"reference_kind": ref_kind,
|
| 399 |
+
"median_incongruence": round(median_incong, 4),
|
| 400 |
+
"occurrences": occurrences,
|
| 401 |
+
}
|
background_model.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Background language model — pretrained general-English word vectors.
|
| 3 |
+
|
| 4 |
+
This is the "what's normal in English" reference used by anomaly detection.
|
| 5 |
+
It is deliberately a *distributional* word-embedding model (GloVe), the same
|
| 6 |
+
*kind* of object as the corpus Word2Vec: comparing a word's neighbourhood in
|
| 7 |
+
the corpus against its neighbourhood here is only meaningful when both spaces
|
| 8 |
+
are word-co-occurrence embeddings. A sentence-transformer (or an OpenAI text
|
| 9 |
+
embedding) is the wrong type for this role — it models text similarity, not
|
| 10 |
+
word co-occurrence, and lives in an unrelated vector space.
|
| 11 |
+
|
| 12 |
+
The model is loaded lazily on first use (gensim downloads & caches it to
|
| 13 |
+
~/gensim-data, or $GENSIM_DATA_DIR). A failed download degrades gracefully:
|
| 14 |
+
callers see `ready == False` instead of a crash.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import threading
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# glove-wiki-gigaword-100: ~128MB, 400k lowercase words, dim=100.
|
| 25 |
+
# Override with BACKGROUND_MODEL env var (e.g. glove-wiki-gigaword-50).
|
| 26 |
+
DEFAULT_MODEL = os.environ.get("BACKGROUND_MODEL", "glove-wiki-gigaword-100")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class BackgroundModel:
|
| 30 |
+
"""Lazily-loaded pretrained general-English word vectors (gensim KeyedVectors)."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, model_name: str = DEFAULT_MODEL):
|
| 33 |
+
self.model_name = model_name
|
| 34 |
+
self._kv = None # gensim KeyedVectors once loaded
|
| 35 |
+
self._lock = threading.Lock()
|
| 36 |
+
self._load_failed = False
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def ready(self) -> bool:
|
| 40 |
+
return self._kv is not None
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def kv(self):
|
| 44 |
+
"""The underlying gensim KeyedVectors, or None if not loaded."""
|
| 45 |
+
return self._kv
|
| 46 |
+
|
| 47 |
+
def load(self) -> bool:
|
| 48 |
+
"""
|
| 49 |
+
Load the model if needed. Returns True on success, False on failure.
|
| 50 |
+
|
| 51 |
+
Thread-safe and idempotent. The first call may download ~128MB; later
|
| 52 |
+
calls (and restarts, if the cache survives) are instant. On failure the
|
| 53 |
+
model is marked failed so we don't retry a doomed download every request.
|
| 54 |
+
"""
|
| 55 |
+
if self._kv is not None:
|
| 56 |
+
return True
|
| 57 |
+
with self._lock:
|
| 58 |
+
if self._kv is not None:
|
| 59 |
+
return True
|
| 60 |
+
if self._load_failed:
|
| 61 |
+
return False
|
| 62 |
+
try:
|
| 63 |
+
import gensim.downloader as gd
|
| 64 |
+
logger.info("Loading background model '%s' (first use may download ~100MB)...",
|
| 65 |
+
self.model_name)
|
| 66 |
+
self._kv = gd.load(self.model_name)
|
| 67 |
+
logger.info("Background model ready: %d words, dim=%d",
|
| 68 |
+
len(self._kv), self._kv.vector_size)
|
| 69 |
+
return True
|
| 70 |
+
except Exception:
|
| 71 |
+
logger.exception("Failed to load background model '%s' — "
|
| 72 |
+
"anomaly detection will be unavailable.", self.model_name)
|
| 73 |
+
self._load_failed = True
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
def has(self, word: str) -> bool:
|
| 77 |
+
"""True if the word exists in the background vocabulary (i.e. common English)."""
|
| 78 |
+
return self._kv is not None and word in self._kv
|
| 79 |
+
|
| 80 |
+
def similarity(self, a: str, b: str) -> Optional[float]:
|
| 81 |
+
"""Cosine similarity between two words in general English, or None if either is OOV."""
|
| 82 |
+
if self._kv is None or a not in self._kv or b not in self._kv:
|
| 83 |
+
return None
|
| 84 |
+
return float(self._kv.similarity(a, b))
|
| 85 |
+
|
| 86 |
+
def status(self) -> dict:
|
| 87 |
+
return {
|
| 88 |
+
"model_name": self.model_name,
|
| 89 |
+
"ready": self.ready,
|
| 90 |
+
"load_failed": self._load_failed,
|
| 91 |
+
"vocab_size": len(self._kv) if self._kv is not None else 0,
|
| 92 |
+
"vector_size": int(self._kv.vector_size) if self._kv is not None else 0,
|
| 93 |
+
}
|
docker-compose.yml
CHANGED
|
@@ -6,8 +6,11 @@ services:
|
|
| 6 |
volumes:
|
| 7 |
# Persist HuggingFace model cache between restarts
|
| 8 |
- hf-cache:/data/huggingface
|
| 9 |
-
# Persist
|
|
|
|
|
|
|
| 10 |
- engine-state:/data/engine_state
|
|
|
|
| 11 |
- ./trained_model:/data/trained_model
|
| 12 |
environment:
|
| 13 |
- HOST=0.0.0.0
|
|
@@ -15,4 +18,6 @@ services:
|
|
| 15 |
|
| 16 |
volumes:
|
| 17 |
hf-cache:
|
|
|
|
| 18 |
engine-state:
|
|
|
|
|
|
| 6 |
volumes:
|
| 7 |
# Persist HuggingFace model cache between restarts
|
| 8 |
- hf-cache:/data/huggingface
|
| 9 |
+
# Persist the GloVe background model (anomaly detection) so it's not re-downloaded
|
| 10 |
+
- gensim-cache:/data/gensim-data
|
| 11 |
+
# Persist engine state, Word2Vec state, and trained models
|
| 12 |
- engine-state:/data/engine_state
|
| 13 |
+
- w2v-state:/data/w2v_state
|
| 14 |
- ./trained_model:/data/trained_model
|
| 15 |
environment:
|
| 16 |
- HOST=0.0.0.0
|
|
|
|
| 18 |
|
| 19 |
volumes:
|
| 20 |
hf-cache:
|
| 21 |
+
gensim-cache:
|
| 22 |
engine-state:
|
| 23 |
+
w2v-state:
|
frontend/src/App.tsx
CHANGED
|
@@ -5,25 +5,21 @@ import TrainingPanel from "./components/TrainingPanel";
|
|
| 5 |
import EngineSetup from "./components/EngineSetup";
|
| 6 |
import SemanticSearch from "./components/SemanticSearch";
|
| 7 |
import TextCompare from "./components/TextCompare";
|
| 8 |
-
import KeywordAnalysis from "./components/KeywordAnalysis";
|
| 9 |
-
import KeywordMatcher from "./components/KeywordMatcher";
|
| 10 |
-
import BatchAnalysis from "./components/BatchAnalysis";
|
| 11 |
import SimilarWords from "./components/SimilarWords";
|
| 12 |
import ContextAnalysis from "./components/ContextAnalysis";
|
|
|
|
| 13 |
import Word2VecPanel from "./components/Word2VecPanel";
|
| 14 |
-
import Word2VecTools from "./components/Word2VecTools";
|
| 15 |
import DatasetPanel from "./components/DatasetPanel";
|
| 16 |
import MetricCard from "./components/MetricCard";
|
| 17 |
import "./styles.css";
|
| 18 |
|
| 19 |
-
type NavGroup = "data" | "training"
|
| 20 |
type TrainingTab = "model" | "w2v";
|
| 21 |
-
type AnalysisTab = "context" | "
|
| 22 |
|
| 23 |
-
const STEPS: { id: NavGroup; label: string
|
| 24 |
{ id: "data", label: "Data & Setup" },
|
| 25 |
{ id: "training", label: "Training" },
|
| 26 |
-
{ id: "analysis", label: "Analysis", needsIndex: true },
|
| 27 |
];
|
| 28 |
|
| 29 |
const TRAINING_TABS: { id: TrainingTab; label: string }[] = [
|
|
@@ -33,25 +29,18 @@ const TRAINING_TABS: { id: TrainingTab; label: string }[] = [
|
|
| 33 |
|
| 34 |
const ANALYSIS_TABS: { id: AnalysisTab; label: string }[] = [
|
| 35 |
{ id: "context", label: "Context" },
|
| 36 |
-
{ id: "
|
| 37 |
-
{ id: "search", label: "Search" },
|
| 38 |
-
{ id: "compare", label: "Compare" },
|
| 39 |
-
{ id: "keyword", label: "Keywords" },
|
| 40 |
-
{ id: "match", label: "Matcher" },
|
| 41 |
-
{ id: "batch", label: "Batch" },
|
| 42 |
];
|
| 43 |
|
| 44 |
export default function App() {
|
| 45 |
const [group, setGroup] = useState<NavGroup>("data");
|
| 46 |
-
const [trainingTab, setTrainingTab] = useState<TrainingTab>("
|
| 47 |
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("context");
|
| 48 |
const [stats, setStats] = useState<CorpusStats | null>(null);
|
| 49 |
const [showManualSetup, setShowManualSetup] = useState(false);
|
| 50 |
const [serverError, setServerError] = useState<string | null>(null);
|
| 51 |
const [w2vReady, setW2vReady] = useState(false);
|
| 52 |
const [w2vInfo, setW2vInfo] = useState<{ vocab_size: number; sentences: number; vector_size: number } | null>(null);
|
| 53 |
-
const [resetLoading, setResetLoading] = useState(false);
|
| 54 |
-
const ready = stats !== null && stats.index_built;
|
| 55 |
|
| 56 |
useEffect(() => {
|
| 57 |
checkConnection().then((err) => {
|
|
@@ -62,6 +51,7 @@ export default function App() {
|
|
| 62 |
if (res.ready) {
|
| 63 |
setW2vReady(true);
|
| 64 |
setW2vInfo({ vocab_size: res.vocab_size!, sentences: res.sentences!, vector_size: res.vector_size! });
|
|
|
|
| 65 |
}
|
| 66 |
}).catch(() => {});
|
| 67 |
}
|
|
@@ -77,94 +67,6 @@ export default function App() {
|
|
| 77 |
setW2vInfo(ready && info ? info : null);
|
| 78 |
}
|
| 79 |
|
| 80 |
-
async function handleReset() {
|
| 81 |
-
setResetLoading(true);
|
| 82 |
-
try {
|
| 83 |
-
await api.w2vReset();
|
| 84 |
-
setW2vReady(false);
|
| 85 |
-
setW2vInfo(null);
|
| 86 |
-
} catch {
|
| 87 |
-
// ignore
|
| 88 |
-
} finally {
|
| 89 |
-
setResetLoading(false);
|
| 90 |
-
}
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
function handleStepClick(id: NavGroup, needsIndex?: boolean) {
|
| 94 |
-
if (needsIndex && !ready) return;
|
| 95 |
-
setGroup(id);
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
// ── W2V trained: stats bar + analysis tabs, no stepper ──
|
| 99 |
-
if (w2vReady && w2vInfo) {
|
| 100 |
-
return (
|
| 101 |
-
<div className="app">
|
| 102 |
-
<header className="app-header">
|
| 103 |
-
<h1>Contextual Similarity Engine</h1>
|
| 104 |
-
{stats && (
|
| 105 |
-
<div className="header-stats">
|
| 106 |
-
<span className="badge">{stats.model_name}</span>
|
| 107 |
-
<span className="badge">{stats.total_documents} docs</span>
|
| 108 |
-
<span className="badge">{stats.total_chunks} chunks</span>
|
| 109 |
-
</div>
|
| 110 |
-
)}
|
| 111 |
-
</header>
|
| 112 |
-
|
| 113 |
-
{serverError && (
|
| 114 |
-
<div className="server-error-banner">
|
| 115 |
-
<strong>Server unavailable:</strong> {serverError}
|
| 116 |
-
</div>
|
| 117 |
-
)}
|
| 118 |
-
|
| 119 |
-
{/* W2V stats bar */}
|
| 120 |
-
<div className="content">
|
| 121 |
-
<div className="panel">
|
| 122 |
-
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
|
| 123 |
-
<h2 style={{ margin: 0 }}>Word2Vec Baseline</h2>
|
| 124 |
-
<button className="btn btn-secondary" onClick={handleReset} disabled={resetLoading}
|
| 125 |
-
style={{ fontSize: "0.85em" }}>
|
| 126 |
-
{resetLoading ? "Resetting..." : "Reset & Retrain"}
|
| 127 |
-
</button>
|
| 128 |
-
</div>
|
| 129 |
-
<div className="metric-grid" style={{ marginTop: 12 }}>
|
| 130 |
-
<MetricCard value={w2vInfo.vocab_size} label="Vocabulary" />
|
| 131 |
-
<MetricCard value={w2vInfo.sentences} label="Sentences" />
|
| 132 |
-
<MetricCard value={w2vInfo.vector_size} label="Dimensions" />
|
| 133 |
-
</div>
|
| 134 |
-
</div>
|
| 135 |
-
|
| 136 |
-
{/* W2V-specific tools: Similar Words, Compare, Semantic Search */}
|
| 137 |
-
<Word2VecTools />
|
| 138 |
-
</div>
|
| 139 |
-
|
| 140 |
-
{/* Transformer Analysis sub-tabs */}
|
| 141 |
-
<nav className="subtabs">
|
| 142 |
-
{ANALYSIS_TABS.map((t) => (
|
| 143 |
-
<button
|
| 144 |
-
key={t.id}
|
| 145 |
-
className={`subtab ${analysisTab === t.id ? "subtab-active" : ""}`}
|
| 146 |
-
onClick={() => setAnalysisTab(t.id)}
|
| 147 |
-
>
|
| 148 |
-
{t.label}
|
| 149 |
-
</button>
|
| 150 |
-
))}
|
| 151 |
-
</nav>
|
| 152 |
-
|
| 153 |
-
{/* Analysis content */}
|
| 154 |
-
<main className="content">
|
| 155 |
-
{analysisTab === "context" && <ContextAnalysis />}
|
| 156 |
-
{analysisTab === "words" && <SimilarWords />}
|
| 157 |
-
{analysisTab === "search" && <SemanticSearch />}
|
| 158 |
-
{analysisTab === "compare" && <TextCompare />}
|
| 159 |
-
{analysisTab === "keyword" && <KeywordAnalysis />}
|
| 160 |
-
{analysisTab === "match" && <KeywordMatcher />}
|
| 161 |
-
{analysisTab === "batch" && <BatchAnalysis />}
|
| 162 |
-
</main>
|
| 163 |
-
</div>
|
| 164 |
-
);
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
// ── Normal stepper flow ──
|
| 168 |
return (
|
| 169 |
<div className="app">
|
| 170 |
<header className="app-header">
|
|
@@ -174,9 +76,6 @@ export default function App() {
|
|
| 174 |
<span className="badge">{stats.model_name}</span>
|
| 175 |
<span className="badge">{stats.total_documents} docs</span>
|
| 176 |
<span className="badge">{stats.total_chunks} chunks</span>
|
| 177 |
-
<span className={`badge ${stats.index_built ? "badge-ok" : "badge-warn"}`}>
|
| 178 |
-
{stats.index_built ? "Index ready" : "Index not built"}
|
| 179 |
-
</span>
|
| 180 |
</div>
|
| 181 |
)}
|
| 182 |
</header>
|
|
@@ -187,24 +86,22 @@ export default function App() {
|
|
| 187 |
</div>
|
| 188 |
)}
|
| 189 |
|
| 190 |
-
{/* Progress Stepper */}
|
|
|
|
| 191 |
<nav className="stepper">
|
| 192 |
{STEPS.map((step, i) => {
|
| 193 |
-
const disabled = step.needsIndex && !ready;
|
| 194 |
const active = group === step.id;
|
| 195 |
-
const done = step.id === "data" &&
|
|
|
|
| 196 |
return (
|
| 197 |
<Fragment key={step.id}>
|
| 198 |
-
{i > 0 &&
|
| 199 |
-
<div className={`stepper-line ${!disabled ? "stepper-line-active" : ""}`} />
|
| 200 |
-
)}
|
| 201 |
<div className="stepper-item">
|
| 202 |
<button
|
| 203 |
className={`stepper-circle ${active ? "stepper-active" : ""} ${done && !active ? "stepper-done" : ""}`}
|
| 204 |
-
onClick={() =>
|
| 205 |
-
disabled={disabled}
|
| 206 |
>
|
| 207 |
-
{done && !active ? "
|
| 208 |
</button>
|
| 209 |
<span className={`stepper-label ${active ? "stepper-label-active" : ""}`}>
|
| 210 |
{step.label}
|
|
@@ -214,62 +111,79 @@ export default function App() {
|
|
| 214 |
);
|
| 215 |
})}
|
| 216 |
</nav>
|
| 217 |
-
|
| 218 |
-
{/* Sub-tabs */}
|
| 219 |
-
{group === "training" && (
|
| 220 |
-
<nav className="subtabs">
|
| 221 |
-
{TRAINING_TABS.map((t) => (
|
| 222 |
-
<button
|
| 223 |
-
key={t.id}
|
| 224 |
-
className={`subtab ${trainingTab === t.id ? "subtab-active" : ""}`}
|
| 225 |
-
onClick={() => setTrainingTab(t.id)}
|
| 226 |
-
>
|
| 227 |
-
{t.label}
|
| 228 |
-
</button>
|
| 229 |
-
))}
|
| 230 |
-
</nav>
|
| 231 |
-
)}
|
| 232 |
-
|
| 233 |
-
{group === "analysis" && (
|
| 234 |
-
<nav className="subtabs">
|
| 235 |
-
{ANALYSIS_TABS.map((t) => (
|
| 236 |
-
<button
|
| 237 |
-
key={t.id}
|
| 238 |
-
className={`subtab ${analysisTab === t.id ? "subtab-active" : ""}`}
|
| 239 |
-
onClick={() => setAnalysisTab(t.id)}
|
| 240 |
-
>
|
| 241 |
-
{t.label}
|
| 242 |
-
</button>
|
| 243 |
-
))}
|
| 244 |
-
</nav>
|
| 245 |
)}
|
| 246 |
|
| 247 |
{/* Content */}
|
| 248 |
<main className="content">
|
| 249 |
{group === "data" && (
|
| 250 |
<>
|
| 251 |
-
<DatasetPanel onStatsUpdate={setStats} />
|
| 252 |
<button
|
| 253 |
className="collapsible-toggle"
|
| 254 |
onClick={() => setShowManualSetup(!showManualSetup)}
|
| 255 |
>
|
| 256 |
-
<span className="collapsible-arrow">{showManualSetup ? "
|
| 257 |
Or add documents manually
|
| 258 |
</button>
|
| 259 |
{showManualSetup && <EngineSetup onStatsUpdate={setStats} />}
|
| 260 |
</>
|
| 261 |
)}
|
| 262 |
|
| 263 |
-
{group === "training" &&
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
</main>
|
| 274 |
</div>
|
| 275 |
);
|
|
|
|
| 5 |
import EngineSetup from "./components/EngineSetup";
|
| 6 |
import SemanticSearch from "./components/SemanticSearch";
|
| 7 |
import TextCompare from "./components/TextCompare";
|
|
|
|
|
|
|
|
|
|
| 8 |
import SimilarWords from "./components/SimilarWords";
|
| 9 |
import ContextAnalysis from "./components/ContextAnalysis";
|
| 10 |
+
import AnomalyPanel from "./components/AnomalyPanel";
|
| 11 |
import Word2VecPanel from "./components/Word2VecPanel";
|
|
|
|
| 12 |
import DatasetPanel from "./components/DatasetPanel";
|
| 13 |
import MetricCard from "./components/MetricCard";
|
| 14 |
import "./styles.css";
|
| 15 |
|
| 16 |
+
type NavGroup = "data" | "training";
|
| 17 |
type TrainingTab = "model" | "w2v";
|
| 18 |
+
type AnalysisTab = "context" | "anomalies";
|
| 19 |
|
| 20 |
+
const STEPS: { id: NavGroup; label: string }[] = [
|
| 21 |
{ id: "data", label: "Data & Setup" },
|
| 22 |
{ id: "training", label: "Training" },
|
|
|
|
| 23 |
];
|
| 24 |
|
| 25 |
const TRAINING_TABS: { id: TrainingTab; label: string }[] = [
|
|
|
|
| 29 |
|
| 30 |
const ANALYSIS_TABS: { id: AnalysisTab; label: string }[] = [
|
| 31 |
{ id: "context", label: "Context" },
|
| 32 |
+
{ id: "anomalies", label: "Anomalies" },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
];
|
| 34 |
|
| 35 |
export default function App() {
|
| 36 |
const [group, setGroup] = useState<NavGroup>("data");
|
| 37 |
+
const [trainingTab, setTrainingTab] = useState<TrainingTab>("w2v");
|
| 38 |
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("context");
|
| 39 |
const [stats, setStats] = useState<CorpusStats | null>(null);
|
| 40 |
const [showManualSetup, setShowManualSetup] = useState(false);
|
| 41 |
const [serverError, setServerError] = useState<string | null>(null);
|
| 42 |
const [w2vReady, setW2vReady] = useState(false);
|
| 43 |
const [w2vInfo, setW2vInfo] = useState<{ vocab_size: number; sentences: number; vector_size: number } | null>(null);
|
|
|
|
|
|
|
| 44 |
|
| 45 |
useEffect(() => {
|
| 46 |
checkConnection().then((err) => {
|
|
|
|
| 51 |
if (res.ready) {
|
| 52 |
setW2vReady(true);
|
| 53 |
setW2vInfo({ vocab_size: res.vocab_size!, sentences: res.sentences!, vector_size: res.vector_size! });
|
| 54 |
+
setGroup("training");
|
| 55 |
}
|
| 56 |
}).catch(() => {});
|
| 57 |
}
|
|
|
|
| 67 |
setW2vInfo(ready && info ? info : null);
|
| 68 |
}
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
return (
|
| 71 |
<div className="app">
|
| 72 |
<header className="app-header">
|
|
|
|
| 76 |
<span className="badge">{stats.model_name}</span>
|
| 77 |
<span className="badge">{stats.total_documents} docs</span>
|
| 78 |
<span className="badge">{stats.total_chunks} chunks</span>
|
|
|
|
|
|
|
|
|
|
| 79 |
</div>
|
| 80 |
)}
|
| 81 |
</header>
|
|
|
|
| 86 |
</div>
|
| 87 |
)}
|
| 88 |
|
| 89 |
+
{/* Progress Stepper — hidden once training is complete */}
|
| 90 |
+
{!w2vReady && (
|
| 91 |
<nav className="stepper">
|
| 92 |
{STEPS.map((step, i) => {
|
|
|
|
| 93 |
const active = group === step.id;
|
| 94 |
+
const done = (step.id === "data" && stats !== null && stats.index_built)
|
| 95 |
+
|| (step.id === "training" && w2vReady);
|
| 96 |
return (
|
| 97 |
<Fragment key={step.id}>
|
| 98 |
+
{i > 0 && <div className="stepper-line stepper-line-active" />}
|
|
|
|
|
|
|
| 99 |
<div className="stepper-item">
|
| 100 |
<button
|
| 101 |
className={`stepper-circle ${active ? "stepper-active" : ""} ${done && !active ? "stepper-done" : ""}`}
|
| 102 |
+
onClick={() => setGroup(step.id)}
|
|
|
|
| 103 |
>
|
| 104 |
+
{done && !active ? "✓" : i + 1}
|
| 105 |
</button>
|
| 106 |
<span className={`stepper-label ${active ? "stepper-label-active" : ""}`}>
|
| 107 |
{step.label}
|
|
|
|
| 111 |
);
|
| 112 |
})}
|
| 113 |
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
)}
|
| 115 |
|
| 116 |
{/* Content */}
|
| 117 |
<main className="content">
|
| 118 |
{group === "data" && (
|
| 119 |
<>
|
| 120 |
+
<DatasetPanel onStatsUpdate={setStats} onLoaded={() => setGroup("training")} />
|
| 121 |
<button
|
| 122 |
className="collapsible-toggle"
|
| 123 |
onClick={() => setShowManualSetup(!showManualSetup)}
|
| 124 |
>
|
| 125 |
+
<span className="collapsible-arrow">{showManualSetup ? "▾" : "▸"}</span>
|
| 126 |
Or add documents manually
|
| 127 |
</button>
|
| 128 |
{showManualSetup && <EngineSetup onStatsUpdate={setStats} />}
|
| 129 |
</>
|
| 130 |
)}
|
| 131 |
|
| 132 |
+
{group === "training" && !w2vReady && (
|
| 133 |
+
<>
|
| 134 |
+
<nav className="subtabs">
|
| 135 |
+
{TRAINING_TABS.map((t) => (
|
| 136 |
+
<button
|
| 137 |
+
key={t.id}
|
| 138 |
+
className={`subtab ${trainingTab === t.id ? "subtab-active" : ""}`}
|
| 139 |
+
onClick={() => setTrainingTab(t.id)}
|
| 140 |
+
>
|
| 141 |
+
{t.label}
|
| 142 |
+
</button>
|
| 143 |
+
))}
|
| 144 |
+
</nav>
|
| 145 |
+
{trainingTab === "model" && <TrainingPanel />}
|
| 146 |
+
{trainingTab === "w2v" && <Word2VecPanel onReady={handleW2vReady} />}
|
| 147 |
+
</>
|
| 148 |
+
)}
|
| 149 |
+
|
| 150 |
+
{group === "training" && w2vReady && w2vInfo && (
|
| 151 |
+
<>
|
| 152 |
+
<div className="panel">
|
| 153 |
+
<h2 style={{ marginTop: 0 }}>Trained Corpus</h2>
|
| 154 |
+
<p className="panel-desc">
|
| 155 |
+
Word2Vec model is trained and persisted. Use the tools below to explore similarity.
|
| 156 |
+
</p>
|
| 157 |
+
<div className="metric-grid">
|
| 158 |
+
<MetricCard value={w2vInfo.vocab_size} label="Vocabulary" />
|
| 159 |
+
<MetricCard value={w2vInfo.sentences} label="Sentences" />
|
| 160 |
+
<MetricCard value={w2vInfo.vector_size} label="Dimensions" />
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
|
| 164 |
+
<nav className="subtabs">
|
| 165 |
+
{ANALYSIS_TABS.map((t) => (
|
| 166 |
+
<button
|
| 167 |
+
key={t.id}
|
| 168 |
+
className={`subtab ${analysisTab === t.id ? "subtab-active" : ""}`}
|
| 169 |
+
onClick={() => setAnalysisTab(t.id)}
|
| 170 |
+
>
|
| 171 |
+
{t.label}
|
| 172 |
+
</button>
|
| 173 |
+
))}
|
| 174 |
+
</nav>
|
| 175 |
+
|
| 176 |
+
{analysisTab === "context" && (
|
| 177 |
+
<>
|
| 178 |
+
<SimilarWords />
|
| 179 |
+
<TextCompare />
|
| 180 |
+
<SemanticSearch />
|
| 181 |
+
<ContextAnalysis />
|
| 182 |
+
</>
|
| 183 |
+
)}
|
| 184 |
+
{analysisTab === "anomalies" && <AnomalyPanel />}
|
| 185 |
+
</>
|
| 186 |
+
)}
|
| 187 |
</main>
|
| 188 |
</div>
|
| 189 |
);
|
frontend/src/api.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
| 9 |
W2VInitResponse, W2VQueryResult, W2VSimilarWord,
|
| 10 |
DatasetInfo, DatasetLoadRequest, DatasetLoadResponse, DatasetPreviewResponse,
|
| 11 |
ContextAnalysisResponse,
|
|
|
|
| 12 |
} from "./types";
|
| 13 |
|
| 14 |
// HuggingFace Spaces proxy requires the __sign token on every request.
|
|
@@ -146,9 +147,6 @@ export const api = {
|
|
| 146 |
w2vStatus: () =>
|
| 147 |
client.get<{ ready: boolean; vocab_size?: number; sentences?: number; vector_size?: number; has_saved_state?: boolean }>("/w2v/status").then(r => r.data),
|
| 148 |
|
| 149 |
-
w2vReset: () =>
|
| 150 |
-
client.post<{ status: string; message: string }>("/w2v/reset").then(r => r.data),
|
| 151 |
-
|
| 152 |
w2vCompare: (data: { text_a: string; text_b: string }) =>
|
| 153 |
client.post<CompareResponse>("/w2v/compare", data).then(r => r.data),
|
| 154 |
|
|
@@ -158,6 +156,22 @@ export const api = {
|
|
| 158 |
w2vSimilarWords: (data: { word: string; top_k: number }) =>
|
| 159 |
client.post<{ word: string; similar: W2VSimilarWord[] }>("/w2v/similar-words", data).then(r => r.data),
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
// ---- Dataset (HuggingFace) ----
|
| 162 |
datasetInfo: () =>
|
| 163 |
client.get<DatasetInfo>("/dataset/info").then(r => r.data),
|
|
|
|
| 9 |
W2VInitResponse, W2VQueryResult, W2VSimilarWord,
|
| 10 |
DatasetInfo, DatasetLoadRequest, DatasetLoadResponse, DatasetPreviewResponse,
|
| 11 |
ContextAnalysisResponse,
|
| 12 |
+
BackgroundStatus, AnomalySweepResponse, AnomalyRelationResponse, IncongruenceResponse,
|
| 13 |
} from "./types";
|
| 14 |
|
| 15 |
// HuggingFace Spaces proxy requires the __sign token on every request.
|
|
|
|
| 147 |
w2vStatus: () =>
|
| 148 |
client.get<{ ready: boolean; vocab_size?: number; sentences?: number; vector_size?: number; has_saved_state?: boolean }>("/w2v/status").then(r => r.data),
|
| 149 |
|
|
|
|
|
|
|
|
|
|
| 150 |
w2vCompare: (data: { text_a: string; text_b: string }) =>
|
| 151 |
client.post<CompareResponse>("/w2v/compare", data).then(r => r.data),
|
| 152 |
|
|
|
|
| 156 |
w2vSimilarWords: (data: { word: string; top_k: number }) =>
|
| 157 |
client.post<{ word: string; similar: W2VSimilarWord[] }>("/w2v/similar-words", data).then(r => r.data),
|
| 158 |
|
| 159 |
+
// ---- Anomalous-relation detection ----
|
| 160 |
+
backgroundStatus: () =>
|
| 161 |
+
client.get<BackgroundStatus>("/background/status").then(r => r.data),
|
| 162 |
+
|
| 163 |
+
backgroundLoad: () =>
|
| 164 |
+
client.post<BackgroundStatus>("/background/load", null, long).then(r => r.data),
|
| 165 |
+
|
| 166 |
+
analyzeAnomalies: (data?: { min_count?: number; max_vocab?: number; neighbours?: number; top_n?: number }) =>
|
| 167 |
+
client.post<AnomalySweepResponse>("/analyze/anomalies", data ?? {}, long).then(r => r.data),
|
| 168 |
+
|
| 169 |
+
analyzeAnomalyRelations: (data: { word: string; top_k?: number }) =>
|
| 170 |
+
client.post<AnomalyRelationResponse>("/analyze/anomaly-relations", data).then(r => r.data),
|
| 171 |
+
|
| 172 |
+
analyzeIncongruence: (data: { keyword: string; canonical_meaning?: string; top_k?: number }) =>
|
| 173 |
+
client.post<IncongruenceResponse>("/analyze/incongruence", data).then(r => r.data),
|
| 174 |
+
|
| 175 |
// ---- Dataset (HuggingFace) ----
|
| 176 |
datasetInfo: () =>
|
| 177 |
client.get<DatasetInfo>("/dataset/info").then(r => r.data),
|
frontend/src/components/AnomalyPanel.tsx
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
| 2 |
+
import { api } from "../api";
|
| 3 |
+
import type {
|
| 4 |
+
BackgroundStatus, AnomalySweepResponse, AnomalyRelationResponse, IncongruenceResponse,
|
| 5 |
+
} from "../types";
|
| 6 |
+
import { useApiCall } from "../hooks/useApiCall";
|
| 7 |
+
import ScoreBar from "./ScoreBar";
|
| 8 |
+
import StatusMessage from "./StatusMessage";
|
| 9 |
+
|
| 10 |
+
export default function AnomalyPanel() {
|
| 11 |
+
const [bg, setBg] = useState<BackgroundStatus | null>(null);
|
| 12 |
+
const [bgLoading, setBgLoading] = useState(false);
|
| 13 |
+
const [bgError, setBgError] = useState("");
|
| 14 |
+
|
| 15 |
+
// Stage A — corpus sweep
|
| 16 |
+
const [showAdvanced, setShowAdvanced] = useState(false);
|
| 17 |
+
const [minCount, setMinCount] = useState(5);
|
| 18 |
+
const [neighbours, setNeighbours] = useState(25);
|
| 19 |
+
const [topN, setTopN] = useState(30);
|
| 20 |
+
const sweep = useApiCall<AnomalySweepResponse>();
|
| 21 |
+
|
| 22 |
+
// Stage B — per-word relations
|
| 23 |
+
const [selectedWord, setSelectedWord] = useState<string | null>(null);
|
| 24 |
+
const relations = useApiCall<AnomalyRelationResponse>();
|
| 25 |
+
|
| 26 |
+
// Stage C — contextual incongruence (zoom in)
|
| 27 |
+
const [keyword, setKeyword] = useState("");
|
| 28 |
+
const [canonical, setCanonical] = useState("");
|
| 29 |
+
const incong = useApiCall<IncongruenceResponse>();
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
api.backgroundStatus().then(setBg).catch(() => {});
|
| 33 |
+
}, []);
|
| 34 |
+
|
| 35 |
+
async function loadBackground() {
|
| 36 |
+
setBgLoading(true); setBgError("");
|
| 37 |
+
try {
|
| 38 |
+
setBg(await api.backgroundLoad());
|
| 39 |
+
} catch {
|
| 40 |
+
setBgError("Background model failed to load (network/disk). Anomaly detection needs it.");
|
| 41 |
+
} finally {
|
| 42 |
+
setBgLoading(false);
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
async function runSweep() {
|
| 47 |
+
setSelectedWord(null);
|
| 48 |
+
relations.clear();
|
| 49 |
+
const res = await sweep.run(() =>
|
| 50 |
+
api.analyzeAnomalies({ min_count: minCount, neighbours, top_n: topN }));
|
| 51 |
+
if (res && !bg?.ready) api.backgroundStatus().then(setBg).catch(() => {});
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
async function drillInto(word: string) {
|
| 55 |
+
setSelectedWord(word);
|
| 56 |
+
await relations.run(() => api.analyzeAnomalyRelations({ word, top_k: 15 }));
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async function zoomIn(word: string, gloss?: string) {
|
| 60 |
+
setKeyword(word);
|
| 61 |
+
if (gloss !== undefined) setCanonical(gloss);
|
| 62 |
+
await incong.run(() =>
|
| 63 |
+
api.analyzeIncongruence({ keyword: word, canonical_meaning: gloss || undefined, top_k: 10 }));
|
| 64 |
+
document.getElementById("zoom-section")?.scrollIntoView({ behavior: "smooth" });
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const bgReady = bg?.ready ?? false;
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
<div>
|
| 71 |
+
{/* Background model status */}
|
| 72 |
+
<div className="panel">
|
| 73 |
+
<h2>Anomalous Relations</h2>
|
| 74 |
+
<p className="panel-desc">
|
| 75 |
+
Find <strong>code-word candidates</strong>: common English words that behave uncommonly
|
| 76 |
+
in this corpus. We contrast each word's neighbours in the corpus Word2Vec against a
|
| 77 |
+
pretrained general-English model (GloVe). A relation is flagged when it is{" "}
|
| 78 |
+
<em>strong here but weak/absent in normal English</em> — not merely "low similarity".
|
| 79 |
+
</p>
|
| 80 |
+
{bg && (
|
| 81 |
+
<div className="flex-row" style={{ alignItems: "center", gap: 8 }}>
|
| 82 |
+
<span
|
| 83 |
+
className="badge"
|
| 84 |
+
style={{
|
| 85 |
+
background: `rgba(${bgReady ? "74, 222, 128" : "255, 170, 0"}, 0.15)`,
|
| 86 |
+
color: bgReady ? "var(--ok)" : "var(--accent)",
|
| 87 |
+
}}
|
| 88 |
+
>
|
| 89 |
+
{bg.model_name}: {bgReady ? `ready (${bg.vocab_size.toLocaleString()} words)` : "not loaded"}
|
| 90 |
+
</span>
|
| 91 |
+
{!bgReady && (
|
| 92 |
+
<button className="btn" onClick={loadBackground} disabled={bgLoading}>
|
| 93 |
+
{bgLoading ? <><span className="spinner" /> Downloading…</> : "Load background model"}
|
| 94 |
+
</button>
|
| 95 |
+
)}
|
| 96 |
+
</div>
|
| 97 |
+
)}
|
| 98 |
+
{bgError && <div className="mt-2"><StatusMessage type="err" message={bgError} /></div>}
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
{/* Stage A — corpus sweep */}
|
| 102 |
+
<div className="panel">
|
| 103 |
+
<h3 style={{ marginTop: 0 }}>1 · Scan corpus for anomalous words</h3>
|
| 104 |
+
<p className="panel-desc">
|
| 105 |
+
Ranks words by neighbour-set divergence (z-scored across the vocabulary). Higher z = the
|
| 106 |
+
word's corpus associations look more unlike general English.
|
| 107 |
+
</p>
|
| 108 |
+
|
| 109 |
+
<button className="advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced)}>
|
| 110 |
+
{showAdvanced ? "▾" : "▸"} Advanced Settings
|
| 111 |
+
</button>
|
| 112 |
+
{showAdvanced && (
|
| 113 |
+
<div className="advanced-section">
|
| 114 |
+
<div className="form-row">
|
| 115 |
+
<div className="form-group" style={{ maxWidth: 130 }}>
|
| 116 |
+
<label>Min corpus freq</label>
|
| 117 |
+
<input type="number" value={minCount} onChange={e => setMinCount(+e.target.value)} min={1} max={1000} />
|
| 118 |
+
</div>
|
| 119 |
+
<div className="form-group" style={{ maxWidth: 130 }}>
|
| 120 |
+
<label>Neighbours (k)</label>
|
| 121 |
+
<input type="number" value={neighbours} onChange={e => setNeighbours(+e.target.value)} min={5} max={100} />
|
| 122 |
+
</div>
|
| 123 |
+
<div className="form-group" style={{ maxWidth: 130 }}>
|
| 124 |
+
<label>Top N results</label>
|
| 125 |
+
<input type="number" value={topN} onChange={e => setTopN(+e.target.value)} min={1} max={200} />
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
|
| 131 |
+
<button className="btn btn-primary" onClick={runSweep} disabled={sweep.loading} style={{ marginTop: 8 }}>
|
| 132 |
+
{sweep.loading ? <><span className="spinner" /> Scanning…</> : "Scan corpus"}
|
| 133 |
+
</button>
|
| 134 |
+
|
| 135 |
+
{sweep.error && <div className="mt-2"><StatusMessage type="err" message={sweep.error} /></div>}
|
| 136 |
+
{sweep.data?.note && <div className="mt-2"><StatusMessage type="err" message={sweep.data.note} /></div>}
|
| 137 |
+
|
| 138 |
+
{sweep.data && sweep.data.results.length > 0 && (
|
| 139 |
+
<div className="mt-2">
|
| 140 |
+
<div className="section-label">
|
| 141 |
+
{sweep.data.results.length} flagged · shared vocab {sweep.data.vocab_size.toLocaleString()} ·
|
| 142 |
+
mean shift {sweep.data.shift_mean}
|
| 143 |
+
</div>
|
| 144 |
+
<table className="data-table">
|
| 145 |
+
<thead>
|
| 146 |
+
<tr>
|
| 147 |
+
<th>Word</th><th>Freq</th><th>z</th>
|
| 148 |
+
<th>Surprising neighbours (here, not normal)</th><th></th>
|
| 149 |
+
</tr>
|
| 150 |
+
</thead>
|
| 151 |
+
<tbody>
|
| 152 |
+
{sweep.data.results.map((r) => (
|
| 153 |
+
<tr
|
| 154 |
+
key={r.word}
|
| 155 |
+
onClick={() => drillInto(r.word)}
|
| 156 |
+
style={{ cursor: "pointer", background: selectedWord === r.word ? "rgba(108,140,255,0.08)" : undefined }}
|
| 157 |
+
>
|
| 158 |
+
<td style={{ fontWeight: 600 }}>{r.word}</td>
|
| 159 |
+
<td>{r.corpus_frequency}</td>
|
| 160 |
+
<td>
|
| 161 |
+
<span className="badge" style={{
|
| 162 |
+
background: `rgba(${r.z_score >= 2 ? "255,107,107" : "108,140,255"},0.15)`,
|
| 163 |
+
color: r.z_score >= 2 ? "var(--err)" : "var(--accent)",
|
| 164 |
+
}}>{r.z_score.toFixed(2)}</span>
|
| 165 |
+
</td>
|
| 166 |
+
<td style={{ fontSize: "0.85rem" }}>{r.surprising_neighbors.join(", ") || "—"}</td>
|
| 167 |
+
<td style={{ color: "var(--accent)", fontSize: "0.8rem" }}>inspect →</td>
|
| 168 |
+
</tr>
|
| 169 |
+
))}
|
| 170 |
+
</tbody>
|
| 171 |
+
</table>
|
| 172 |
+
</div>
|
| 173 |
+
)}
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{/* Stage B — per-word relations drilldown */}
|
| 177 |
+
{selectedWord && (
|
| 178 |
+
<div className="panel">
|
| 179 |
+
<h3 style={{ marginTop: 0 }}>2 · Relations for "{selectedWord}"</h3>
|
| 180 |
+
{relations.loading && <StatusMessage type="loading" message="Computing relations…" />}
|
| 181 |
+
{relations.error && <StatusMessage type="err" message={relations.error} />}
|
| 182 |
+
{relations.data && !relations.data.found && (
|
| 183 |
+
<StatusMessage type="err" message={`"${selectedWord}" — ${relations.data.reason}.`} />
|
| 184 |
+
)}
|
| 185 |
+
{relations.data?.found && (
|
| 186 |
+
<>
|
| 187 |
+
<p className="panel-desc">
|
| 188 |
+
Surprise = (how strongly tied here) − (how strongly tied in general English), each
|
| 189 |
+
standardised within its own space. High surprise = the suspicious pairing.
|
| 190 |
+
</p>
|
| 191 |
+
<table className="data-table">
|
| 192 |
+
<thead>
|
| 193 |
+
<tr><th>Neighbour</th><th>Surprise</th><th>Corpus sim</th><th>Normal-English sim</th></tr>
|
| 194 |
+
</thead>
|
| 195 |
+
<tbody>
|
| 196 |
+
{relations.data.relations.map((rel) => (
|
| 197 |
+
<tr key={rel.neighbor}>
|
| 198 |
+
<td style={{ fontWeight: 600 }}>{rel.neighbor}</td>
|
| 199 |
+
<td><ScoreBar score={rel.surprise} max={4} /></td>
|
| 200 |
+
<td>{rel.corpus_sim.toFixed(3)}</td>
|
| 201 |
+
<td>{rel.background_sim.toFixed(3)}</td>
|
| 202 |
+
</tr>
|
| 203 |
+
))}
|
| 204 |
+
</tbody>
|
| 205 |
+
</table>
|
| 206 |
+
{relations.data.normal_neighbors && (
|
| 207 |
+
<div className="mt-2">
|
| 208 |
+
<div className="section-label">For contrast — "{selectedWord}" normally relates to:</div>
|
| 209 |
+
<div style={{ fontSize: "0.85rem", color: "var(--muted)" }}>
|
| 210 |
+
{relations.data.normal_neighbors.map(n => n.neighbor).join(", ")}
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
)}
|
| 214 |
+
<button className="btn btn-primary mt-2" onClick={() => zoomIn(selectedWord, "")}>
|
| 215 |
+
Zoom in on occurrences →
|
| 216 |
+
</button>
|
| 217 |
+
</>
|
| 218 |
+
)}
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
|
| 222 |
+
{/* Stage C — contextual incongruence */}
|
| 223 |
+
<div className="panel" id="zoom-section">
|
| 224 |
+
<h3 style={{ marginTop: 0 }}>3 · Zoom in — incongruent occurrences</h3>
|
| 225 |
+
<p className="panel-desc">
|
| 226 |
+
Uses the transformer to rank each occurrence of a keyword by how unlike its norm it is.
|
| 227 |
+
Leave the meaning blank to compare against the keyword's <em>typical</em> usage in this
|
| 228 |
+
corpus, or supply a dictionary meaning (e.g. "pizza, an Italian food") to flag usages that
|
| 229 |
+
drift from it. Highest-incongruence chunks are the candidate coded usages.
|
| 230 |
+
</p>
|
| 231 |
+
<div className="form-row">
|
| 232 |
+
<div className="form-group">
|
| 233 |
+
<label>Keyword</label>
|
| 234 |
+
<input value={keyword} onChange={e => setKeyword(e.target.value)}
|
| 235 |
+
onKeyDown={e => e.key === "Enter" && keyword.trim() && zoomIn(keyword.trim(), canonical)}
|
| 236 |
+
placeholder="e.g. pizza" />
|
| 237 |
+
</div>
|
| 238 |
+
<div className="form-group" style={{ flex: 2 }}>
|
| 239 |
+
<label>Canonical meaning (optional)</label>
|
| 240 |
+
<input value={canonical} onChange={e => setCanonical(e.target.value)}
|
| 241 |
+
onKeyDown={e => e.key === "Enter" && keyword.trim() && zoomIn(keyword.trim(), canonical)}
|
| 242 |
+
placeholder="leave blank to use corpus-typical usage" />
|
| 243 |
+
</div>
|
| 244 |
+
<div className="form-group form-group-sm">
|
| 245 |
+
<label> </label>
|
| 246 |
+
<button className="btn btn-primary" disabled={incong.loading || !keyword.trim()}
|
| 247 |
+
onClick={() => zoomIn(keyword.trim(), canonical)}>
|
| 248 |
+
{incong.loading ? "…" : "Zoom"}
|
| 249 |
+
</button>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
{incong.error && <StatusMessage type="err" message={incong.error} />}
|
| 254 |
+
{incong.data && incong.data.total_occurrences === 0 && (
|
| 255 |
+
<StatusMessage type="err" message={`No occurrences of "${incong.data.keyword}" found.`} />
|
| 256 |
+
)}
|
| 257 |
+
{incong.data && incong.data.occurrences.length > 0 && (
|
| 258 |
+
<div className="mt-2">
|
| 259 |
+
<div className="section-label">
|
| 260 |
+
{incong.data.total_occurrences} occurrences · reference: {incong.data.reference} ·
|
| 261 |
+
median incongruence {incong.data.median_incongruence}
|
| 262 |
+
</div>
|
| 263 |
+
<div className="flex-col gap-3">
|
| 264 |
+
{incong.data.occurrences.map((occ, i) => (
|
| 265 |
+
<div key={i} className="result-card">
|
| 266 |
+
<div className="result-header">
|
| 267 |
+
<span className="context-snippet-source">{occ.doc_id} · chunk {occ.chunk_index}</span>
|
| 268 |
+
<span className="badge" style={{
|
| 269 |
+
background: "rgba(255,107,107,0.15)", color: "var(--err)",
|
| 270 |
+
}}>incongruence {occ.incongruence.toFixed(3)}</span>
|
| 271 |
+
</div>
|
| 272 |
+
<div className="context-snippet mt-2">{occ.snippet}</div>
|
| 273 |
+
{occ.entities.length > 0 && (
|
| 274 |
+
<div className="mt-2">
|
| 275 |
+
<span className="section-label">Co-occurring: </span>
|
| 276 |
+
{occ.entities.map((e, j) => (
|
| 277 |
+
<span key={j} className="badge" style={{ marginRight: 4 }}>{e}</span>
|
| 278 |
+
))}
|
| 279 |
+
</div>
|
| 280 |
+
)}
|
| 281 |
+
</div>
|
| 282 |
+
))}
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
)}
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
);
|
| 289 |
+
}
|
frontend/src/components/DatasetPanel.tsx
CHANGED
|
@@ -10,9 +10,10 @@ import LogViewer from "./LogViewer";
|
|
| 10 |
|
| 11 |
interface Props {
|
| 12 |
onStatsUpdate?: (stats: any) => void;
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
-
export default function DatasetPanel({ onStatsUpdate }: Props) {
|
| 16 |
const [info, setInfo] = useState<DatasetInfo | null>(null);
|
| 17 |
const [error, setError] = useState("");
|
| 18 |
|
|
@@ -65,6 +66,7 @@ export default function DatasetPanel({ onStatsUpdate }: Props) {
|
|
| 65 |
console.warn("Failed to refresh stats after load:", e);
|
| 66 |
}
|
| 67 |
}
|
|
|
|
| 68 |
} catch (err) {
|
| 69 |
setError(getErrorMessage(err));
|
| 70 |
} finally {
|
|
|
|
| 10 |
|
| 11 |
interface Props {
|
| 12 |
onStatsUpdate?: (stats: any) => void;
|
| 13 |
+
onLoaded?: () => void;
|
| 14 |
}
|
| 15 |
|
| 16 |
+
export default function DatasetPanel({ onStatsUpdate, onLoaded }: Props) {
|
| 17 |
const [info, setInfo] = useState<DatasetInfo | null>(null);
|
| 18 |
const [error, setError] = useState("");
|
| 19 |
|
|
|
|
| 66 |
console.warn("Failed to refresh stats after load:", e);
|
| 67 |
}
|
| 68 |
}
|
| 69 |
+
onLoaded?.(); // advance to the training step
|
| 70 |
} catch (err) {
|
| 71 |
setError(getErrorMessage(err));
|
| 72 |
} finally {
|
frontend/src/components/Word2VecPanel.tsx
CHANGED
|
@@ -1,9 +1,7 @@
|
|
| 1 |
import { useState, useEffect } from "react";
|
| 2 |
import { api, getErrorMessage } from "../api";
|
| 3 |
-
import type { W2VInitResponse } from "../types";
|
| 4 |
import StatusMessage from "./StatusMessage";
|
| 5 |
import LogViewer from "./LogViewer";
|
| 6 |
-
import MetricCard from "./MetricCard";
|
| 7 |
|
| 8 |
interface Props {
|
| 9 |
onReady: (ready: boolean, info?: { vocab_size: number; sentences: number; vector_size: number }) => void;
|
|
@@ -11,8 +9,6 @@ interface Props {
|
|
| 11 |
|
| 12 |
export default function Word2VecPanel({ onReady }: Props) {
|
| 13 |
const [statusChecked, setStatusChecked] = useState(false);
|
| 14 |
-
const [trainResult, setTrainResult] = useState<W2VInitResponse | null>(null);
|
| 15 |
-
|
| 16 |
const [vectorSize, setVectorSize] = useState(100);
|
| 17 |
const [windowSize, setWindowSize] = useState(5);
|
| 18 |
const [w2vEpochs, setW2vEpochs] = useState(50);
|
|
@@ -30,14 +26,14 @@ export default function Word2VecPanel({ onReady }: Props) {
|
|
| 30 |
}, []);
|
| 31 |
|
| 32 |
async function handleTrainFromEngine() {
|
| 33 |
-
setInitLoading(true); setError("");
|
| 34 |
try {
|
| 35 |
const res = await api.w2vInitFromEngine({
|
| 36 |
vector_size: vectorSize,
|
| 37 |
window: windowSize,
|
| 38 |
epochs: w2vEpochs,
|
| 39 |
});
|
| 40 |
-
|
| 41 |
} catch (err) {
|
| 42 |
setError(getErrorMessage(err));
|
| 43 |
} finally {
|
|
@@ -49,42 +45,16 @@ export default function Word2VecPanel({ onReady }: Props) {
|
|
| 49 |
return <div className="panel"><p>Checking Word2Vec status...</p></div>;
|
| 50 |
}
|
| 51 |
|
| 52 |
-
// Training complete — show results + continue button
|
| 53 |
-
if (trainResult) {
|
| 54 |
-
return (
|
| 55 |
-
<div>
|
| 56 |
-
<div className="panel">
|
| 57 |
-
<h2>Training Complete</h2>
|
| 58 |
-
<div className="metric-grid">
|
| 59 |
-
<MetricCard value={trainResult.vocab_size} label="Vocabulary" />
|
| 60 |
-
<MetricCard value={trainResult.sentences} label="Sentences" />
|
| 61 |
-
<MetricCard value={trainResult.vector_size} label="Dimensions" />
|
| 62 |
-
<MetricCard value={`${trainResult.seconds}s`} label="Train Time" />
|
| 63 |
-
</div>
|
| 64 |
-
<StatusMessage type="ok" message="Word2Vec model trained and saved. It will persist across restarts." />
|
| 65 |
-
<button className="btn btn-primary" style={{ marginTop: 12 }}
|
| 66 |
-
onClick={() => onReady(true, { vocab_size: trainResult.vocab_size, sentences: trainResult.sentences, vector_size: trainResult.vector_size })}>
|
| 67 |
-
Continue to Analysis
|
| 68 |
-
</button>
|
| 69 |
-
</div>
|
| 70 |
-
|
| 71 |
-
<LogViewer active={false} />
|
| 72 |
-
</div>
|
| 73 |
-
);
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
// Training form
|
| 77 |
return (
|
| 78 |
<div>
|
| 79 |
<div className="panel">
|
| 80 |
<h2>Word2Vec Baseline (gensim)</h2>
|
| 81 |
<p className="panel-desc">
|
| 82 |
-
Static embeddings
|
| 83 |
-
Train on all documents loaded in the engine to use as a baseline comparison.
|
| 84 |
</p>
|
| 85 |
|
| 86 |
<button className="advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced)}>
|
| 87 |
-
{showAdvanced ? "
|
| 88 |
</button>
|
| 89 |
|
| 90 |
{showAdvanced && (
|
|
@@ -100,7 +70,7 @@ export default function Word2VecPanel({ onReady }: Props) {
|
|
| 100 |
</div>
|
| 101 |
<div className="form-group" style={{ maxWidth: 120 }}>
|
| 102 |
<label>Epochs</label>
|
| 103 |
-
<input type="number" value={w2vEpochs} onChange={e => setW2vEpochs(+e.target.value)} min={5} max={
|
| 104 |
</div>
|
| 105 |
</div>
|
| 106 |
</div>
|
|
|
|
| 1 |
import { useState, useEffect } from "react";
|
| 2 |
import { api, getErrorMessage } from "../api";
|
|
|
|
| 3 |
import StatusMessage from "./StatusMessage";
|
| 4 |
import LogViewer from "./LogViewer";
|
|
|
|
| 5 |
|
| 6 |
interface Props {
|
| 7 |
onReady: (ready: boolean, info?: { vocab_size: number; sentences: number; vector_size: number }) => void;
|
|
|
|
| 9 |
|
| 10 |
export default function Word2VecPanel({ onReady }: Props) {
|
| 11 |
const [statusChecked, setStatusChecked] = useState(false);
|
|
|
|
|
|
|
| 12 |
const [vectorSize, setVectorSize] = useState(100);
|
| 13 |
const [windowSize, setWindowSize] = useState(5);
|
| 14 |
const [w2vEpochs, setW2vEpochs] = useState(50);
|
|
|
|
| 26 |
}, []);
|
| 27 |
|
| 28 |
async function handleTrainFromEngine() {
|
| 29 |
+
setInitLoading(true); setError("");
|
| 30 |
try {
|
| 31 |
const res = await api.w2vInitFromEngine({
|
| 32 |
vector_size: vectorSize,
|
| 33 |
window: windowSize,
|
| 34 |
epochs: w2vEpochs,
|
| 35 |
});
|
| 36 |
+
onReady(true, { vocab_size: res.vocab_size, sentences: res.sentences, vector_size: res.vector_size });
|
| 37 |
} catch (err) {
|
| 38 |
setError(getErrorMessage(err));
|
| 39 |
} finally {
|
|
|
|
| 45 |
return <div className="panel"><p>Checking Word2Vec status...</p></div>;
|
| 46 |
}
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
return (
|
| 49 |
<div>
|
| 50 |
<div className="panel">
|
| 51 |
<h2>Word2Vec Baseline (gensim)</h2>
|
| 52 |
<p className="panel-desc">
|
| 53 |
+
Static embeddings trained on all documents loaded in the engine. Training runs once and persists across restarts.
|
|
|
|
| 54 |
</p>
|
| 55 |
|
| 56 |
<button className="advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced)}>
|
| 57 |
+
{showAdvanced ? "▾" : "▸"} Advanced Settings
|
| 58 |
</button>
|
| 59 |
|
| 60 |
{showAdvanced && (
|
|
|
|
| 70 |
</div>
|
| 71 |
<div className="form-group" style={{ maxWidth: 120 }}>
|
| 72 |
<label>Epochs</label>
|
| 73 |
+
<input type="number" value={w2vEpochs} onChange={e => setW2vEpochs(+e.target.value)} min={5} max={1000} />
|
| 74 |
</div>
|
| 75 |
</div>
|
| 76 |
</div>
|
frontend/src/types.ts
CHANGED
|
@@ -297,6 +297,71 @@ export interface ContextAnalysisResponse {
|
|
| 297 |
meanings: ContextMeaning[];
|
| 298 |
}
|
| 299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
// ---- UI State ----
|
| 301 |
|
| 302 |
export type EvalSection = "distribution" | "disambiguation" | "retrieval";
|
|
|
|
| 297 |
meanings: ContextMeaning[];
|
| 298 |
}
|
| 299 |
|
| 300 |
+
// ---- Anomalous-relation detection ----
|
| 301 |
+
|
| 302 |
+
export interface BackgroundStatus {
|
| 303 |
+
model_name: string;
|
| 304 |
+
ready: boolean;
|
| 305 |
+
load_failed: boolean;
|
| 306 |
+
vocab_size: number;
|
| 307 |
+
vector_size: number;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
export interface AnomalyWord {
|
| 311 |
+
word: string;
|
| 312 |
+
corpus_frequency: number;
|
| 313 |
+
shift: number;
|
| 314 |
+
z_score: number;
|
| 315 |
+
surprising_neighbors: string[];
|
| 316 |
+
normal_neighbors: string[];
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
export interface AnomalySweepResponse {
|
| 320 |
+
ready: boolean;
|
| 321 |
+
vocab_size: number;
|
| 322 |
+
neighbours?: number;
|
| 323 |
+
shift_mean?: number;
|
| 324 |
+
shift_std?: number;
|
| 325 |
+
note?: string;
|
| 326 |
+
results: AnomalyWord[];
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
export interface AnomalyRelation {
|
| 330 |
+
neighbor: string;
|
| 331 |
+
corpus_sim: number;
|
| 332 |
+
background_sim: number;
|
| 333 |
+
corpus_z: number;
|
| 334 |
+
background_z: number;
|
| 335 |
+
surprise: number;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
export interface AnomalyRelationResponse {
|
| 339 |
+
word: string;
|
| 340 |
+
ready: boolean;
|
| 341 |
+
found: boolean;
|
| 342 |
+
reason?: string;
|
| 343 |
+
corpus_frequency?: number;
|
| 344 |
+
relations: AnomalyRelation[];
|
| 345 |
+
normal_neighbors?: { neighbor: string; background_sim: number }[];
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
export interface IncongruentOccurrence {
|
| 349 |
+
doc_id: string;
|
| 350 |
+
chunk_index: number;
|
| 351 |
+
incongruence: number;
|
| 352 |
+
snippet: string;
|
| 353 |
+
entities: string[];
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
export interface IncongruenceResponse {
|
| 357 |
+
keyword: string;
|
| 358 |
+
total_occurrences: number;
|
| 359 |
+
reference?: string;
|
| 360 |
+
reference_kind?: "gloss" | "centroid";
|
| 361 |
+
median_incongruence?: number;
|
| 362 |
+
occurrences: IncongruentOccurrence[];
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
// ---- UI State ----
|
| 366 |
|
| 367 |
export type EvalSection = "distribution" | "disambiguation" | "retrieval";
|
frontend/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/batchanalysis.tsx","./src/components/contextanalysis.tsx","./src/components/datasetpanel.tsx","./src/components/documentviewer.tsx","./src/components/enginesetup.tsx","./src/components/evaluationdashboard.tsx","./src/components/keywordanalysis.tsx","./src/components/keywordmatcher.tsx","./src/components/logviewer.tsx","./src/components/metriccard.tsx","./src/components/scorebar.tsx","./src/components/select.tsx","./src/components/semanticsearch.tsx","./src/components/similarwords.tsx","./src/components/statusmessage.tsx","./src/components/switch.tsx","./src/components/textcompare.tsx","./src/components/toggle.tsx","./src/components/trainingpanel.tsx","./src/components/word2vecpanel.tsx","./src/components/word2vectools.tsx","./src/hooks/useapicall.ts","./src/hooks/usecorpusloader.ts","./src/utils/colors.ts"],"version":"5.9.3"}
|
|
|
|
| 1 |
+
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/anomalypanel.tsx","./src/components/batchanalysis.tsx","./src/components/contextanalysis.tsx","./src/components/datasetpanel.tsx","./src/components/documentviewer.tsx","./src/components/enginesetup.tsx","./src/components/evaluationdashboard.tsx","./src/components/keywordanalysis.tsx","./src/components/keywordmatcher.tsx","./src/components/logviewer.tsx","./src/components/metriccard.tsx","./src/components/scorebar.tsx","./src/components/select.tsx","./src/components/semanticsearch.tsx","./src/components/similarwords.tsx","./src/components/statusmessage.tsx","./src/components/switch.tsx","./src/components/textcompare.tsx","./src/components/toggle.tsx","./src/components/trainingpanel.tsx","./src/components/word2vecpanel.tsx","./src/components/word2vectools.tsx","./src/hooks/useapicall.ts","./src/hooks/usecorpusloader.ts","./src/utils/colors.ts"],"version":"5.9.3"}
|
server.py
CHANGED
|
@@ -34,6 +34,8 @@ from contextual_similarity import ContextualSimilarityEngine
|
|
| 34 |
from evaluation import Evaluator, GroundTruthEntry
|
| 35 |
from training import CorpusTrainer
|
| 36 |
from word2vec_baseline import Word2VecEngine
|
|
|
|
|
|
|
| 37 |
from data_loader import load_raw_dataset, load_raw_to_engine, import_chromadb_to_engine, get_dataset_info
|
| 38 |
|
| 39 |
logging.basicConfig(level=logging.INFO)
|
|
@@ -145,6 +147,7 @@ app.add_middleware(
|
|
| 145 |
engine: Optional[ContextualSimilarityEngine] = None
|
| 146 |
evaluator: Optional[Evaluator] = None
|
| 147 |
w2v_engine: Optional[Word2VecEngine] = None
|
|
|
|
| 148 |
|
| 149 |
ENGINE_SAVE_DIR = Path(os.environ.get("ENGINE_STATE_DIR", str(BASE_DIR / "engine_state")))
|
| 150 |
W2V_SAVE_DIR = Path(os.environ.get("W2V_STATE_DIR", str(BASE_DIR / "w2v_state")))
|
|
@@ -280,7 +283,7 @@ class W2VInitRequest(BaseModel):
|
|
| 280 |
corpus_texts: list[str] = Field(max_length=10_000)
|
| 281 |
vector_size: int = Field(default=100, ge=50, le=500)
|
| 282 |
window: int = Field(default=5, ge=1, le=20)
|
| 283 |
-
epochs: int = Field(default=50, ge=1, le=
|
| 284 |
|
| 285 |
|
| 286 |
class W2VCompareRequest(BaseModel):
|
|
@@ -304,6 +307,26 @@ class ContextAnalysisRequest(BaseModel):
|
|
| 304 |
top_words: int = Field(default=8, ge=1, le=30)
|
| 305 |
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
class DatasetLoadRequest(BaseModel):
|
| 308 |
source: Literal["raw", "embeddings"] = "raw"
|
| 309 |
max_docs: int = Field(default=500, ge=1, le=100_000)
|
|
@@ -521,6 +544,69 @@ def match_keyword(req: KeywordMatchRequest):
|
|
| 521 |
]}
|
| 522 |
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
# ------------------------------------------------------------------ #
|
| 525 |
# Evaluation endpoints
|
| 526 |
# ------------------------------------------------------------------ #
|
|
@@ -656,7 +742,7 @@ def w2v_init(req: W2VInitRequest):
|
|
| 656 |
def w2v_init_from_engine(
|
| 657 |
vector_size: int = Query(default=100, ge=50, le=500),
|
| 658 |
window: int = Query(default=5, ge=1, le=20),
|
| 659 |
-
epochs: int = Query(default=50, ge=1, le=
|
| 660 |
):
|
| 661 |
"""Train Word2Vec directly from all documents already loaded in the engine.
|
| 662 |
|
|
@@ -729,18 +815,6 @@ def w2v_status():
|
|
| 729 |
return {"ready": False, "has_saved_state": has_saved}
|
| 730 |
|
| 731 |
|
| 732 |
-
@app.post("/api/w2v/reset")
|
| 733 |
-
def w2v_reset():
|
| 734 |
-
"""Delete saved Word2Vec state and clear the in-memory model."""
|
| 735 |
-
global w2v_engine
|
| 736 |
-
w2v_engine = None
|
| 737 |
-
import shutil
|
| 738 |
-
if W2V_SAVE_DIR.is_dir():
|
| 739 |
-
shutil.rmtree(str(W2V_SAVE_DIR))
|
| 740 |
-
logger.info("Word2Vec state deleted from %s", W2V_SAVE_DIR)
|
| 741 |
-
return {"status": "ok", "message": "Word2Vec state cleared. You can retrain now."}
|
| 742 |
-
|
| 743 |
-
|
| 744 |
# ------------------------------------------------------------------ #
|
| 745 |
# Dataset endpoints (HuggingFace Epstein Files)
|
| 746 |
# ------------------------------------------------------------------ #
|
|
@@ -843,6 +917,10 @@ def _ensure_w2v():
|
|
| 843 |
if w2v_engine is None:
|
| 844 |
raise HTTPException(400, "Word2Vec not initialized. POST /api/w2v/init first.")
|
| 845 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 846 |
def _serialize_analysis(analysis):
|
| 847 |
return {
|
| 848 |
"keyword": analysis.keyword,
|
|
|
|
| 34 |
from evaluation import Evaluator, GroundTruthEntry
|
| 35 |
from training import CorpusTrainer
|
| 36 |
from word2vec_baseline import Word2VecEngine
|
| 37 |
+
from background_model import BackgroundModel
|
| 38 |
+
from anomaly import sweep_anomalous_words, relation_surprise, contextual_incongruence
|
| 39 |
from data_loader import load_raw_dataset, load_raw_to_engine, import_chromadb_to_engine, get_dataset_info
|
| 40 |
|
| 41 |
logging.basicConfig(level=logging.INFO)
|
|
|
|
| 147 |
engine: Optional[ContextualSimilarityEngine] = None
|
| 148 |
evaluator: Optional[Evaluator] = None
|
| 149 |
w2v_engine: Optional[Word2VecEngine] = None
|
| 150 |
+
background_model = BackgroundModel() # general-English reference (lazy-loaded)
|
| 151 |
|
| 152 |
ENGINE_SAVE_DIR = Path(os.environ.get("ENGINE_STATE_DIR", str(BASE_DIR / "engine_state")))
|
| 153 |
W2V_SAVE_DIR = Path(os.environ.get("W2V_STATE_DIR", str(BASE_DIR / "w2v_state")))
|
|
|
|
| 283 |
corpus_texts: list[str] = Field(max_length=10_000)
|
| 284 |
vector_size: int = Field(default=100, ge=50, le=500)
|
| 285 |
window: int = Field(default=5, ge=1, le=20)
|
| 286 |
+
epochs: int = Field(default=50, ge=1, le=1000)
|
| 287 |
|
| 288 |
|
| 289 |
class W2VCompareRequest(BaseModel):
|
|
|
|
| 307 |
top_words: int = Field(default=8, ge=1, le=30)
|
| 308 |
|
| 309 |
|
| 310 |
+
class AnomalySweepRequest(BaseModel):
|
| 311 |
+
min_count: int = Field(default=5, ge=1, le=1000)
|
| 312 |
+
max_vocab: int = Field(default=3000, ge=100, le=20_000)
|
| 313 |
+
neighbours: int = Field(default=25, ge=5, le=100)
|
| 314 |
+
top_n: int = Field(default=30, ge=1, le=200)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
class AnomalyRelationRequest(BaseModel):
|
| 318 |
+
word: str = Field(max_length=200)
|
| 319 |
+
min_count: int = Field(default=5, ge=1, le=1000)
|
| 320 |
+
max_vocab: int = Field(default=3000, ge=100, le=20_000)
|
| 321 |
+
top_k: int = Field(default=15, ge=1, le=100)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
class IncongruenceRequest(BaseModel):
|
| 325 |
+
keyword: str = Field(max_length=200)
|
| 326 |
+
canonical_meaning: Optional[str] = Field(default=None, max_length=500)
|
| 327 |
+
top_k: int = Field(default=10, ge=1, le=100)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
class DatasetLoadRequest(BaseModel):
|
| 331 |
source: Literal["raw", "embeddings"] = "raw"
|
| 332 |
max_docs: int = Field(default=500, ge=1, le=100_000)
|
|
|
|
| 544 |
]}
|
| 545 |
|
| 546 |
|
| 547 |
+
# ------------------------------------------------------------------ #
|
| 548 |
+
# Anomalous-relation detection (code-word candidates)
|
| 549 |
+
# ------------------------------------------------------------------ #
|
| 550 |
+
|
| 551 |
+
@app.get("/api/background/status")
|
| 552 |
+
def background_status():
|
| 553 |
+
"""Status of the general-English background model used for anomaly detection."""
|
| 554 |
+
return background_model.status()
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
@app.post("/api/background/load")
|
| 558 |
+
def background_load():
|
| 559 |
+
"""Eagerly load the background model (otherwise it loads on first anomaly query)."""
|
| 560 |
+
ok = background_model.load()
|
| 561 |
+
if not ok:
|
| 562 |
+
raise HTTPException(503, "Background model failed to load. Check server logs (network/disk).")
|
| 563 |
+
return background_model.status()
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
@app.post("/api/analyze/anomalies")
|
| 567 |
+
def analyze_anomalies(req: AnomalySweepRequest):
|
| 568 |
+
"""Stage A: rank corpus words by how differently they associate here vs. normal English."""
|
| 569 |
+
_ensure_w2v()
|
| 570 |
+
_ensure_background()
|
| 571 |
+
logger.info("Anomaly sweep: min_count=%d, max_vocab=%d, neighbours=%d, top_n=%d",
|
| 572 |
+
req.min_count, req.max_vocab, req.neighbours, req.top_n)
|
| 573 |
+
t0 = time.time()
|
| 574 |
+
result = sweep_anomalous_words(
|
| 575 |
+
w2v_engine, background_model,
|
| 576 |
+
min_count=req.min_count, max_vocab=req.max_vocab,
|
| 577 |
+
neighbours=req.neighbours, top_n=req.top_n,
|
| 578 |
+
)
|
| 579 |
+
logger.info("Anomaly sweep complete: %d words ranked in %.1fs",
|
| 580 |
+
len(result.get("results", [])), time.time() - t0)
|
| 581 |
+
return _to_native(result)
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
@app.post("/api/analyze/anomaly-relations")
|
| 585 |
+
def analyze_anomaly_relations(req: AnomalyRelationRequest):
|
| 586 |
+
"""Stage B: for one word, the neighbours strong here but weak/absent in general English."""
|
| 587 |
+
_ensure_w2v()
|
| 588 |
+
_ensure_background()
|
| 589 |
+
logger.info("Anomaly relations: word='%s', top_k=%d", req.word, req.top_k)
|
| 590 |
+
result = relation_surprise(
|
| 591 |
+
req.word, w2v_engine, background_model,
|
| 592 |
+
min_count=req.min_count, max_vocab=req.max_vocab, top_k=req.top_k,
|
| 593 |
+
)
|
| 594 |
+
return _to_native(result)
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
@app.post("/api/analyze/incongruence")
|
| 598 |
+
def analyze_incongruence(req: IncongruenceRequest):
|
| 599 |
+
"""Stage C: occurrences where a keyword is used most unlike its norm (transformer-based)."""
|
| 600 |
+
_ensure_engine(); _ensure_index()
|
| 601 |
+
logger.info("Incongruence: keyword='%s', canonical=%s, top_k=%d",
|
| 602 |
+
req.keyword, bool(req.canonical_meaning), req.top_k)
|
| 603 |
+
result = contextual_incongruence(
|
| 604 |
+
engine, req.keyword,
|
| 605 |
+
canonical_meaning=req.canonical_meaning, top_k=req.top_k,
|
| 606 |
+
)
|
| 607 |
+
return _to_native(result)
|
| 608 |
+
|
| 609 |
+
|
| 610 |
# ------------------------------------------------------------------ #
|
| 611 |
# Evaluation endpoints
|
| 612 |
# ------------------------------------------------------------------ #
|
|
|
|
| 742 |
def w2v_init_from_engine(
|
| 743 |
vector_size: int = Query(default=100, ge=50, le=500),
|
| 744 |
window: int = Query(default=5, ge=1, le=20),
|
| 745 |
+
epochs: int = Query(default=50, ge=1, le=1000),
|
| 746 |
):
|
| 747 |
"""Train Word2Vec directly from all documents already loaded in the engine.
|
| 748 |
|
|
|
|
| 815 |
return {"ready": False, "has_saved_state": has_saved}
|
| 816 |
|
| 817 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
# ------------------------------------------------------------------ #
|
| 819 |
# Dataset endpoints (HuggingFace Epstein Files)
|
| 820 |
# ------------------------------------------------------------------ #
|
|
|
|
| 917 |
if w2v_engine is None:
|
| 918 |
raise HTTPException(400, "Word2Vec not initialized. POST /api/w2v/init first.")
|
| 919 |
|
| 920 |
+
def _ensure_background():
|
| 921 |
+
if not background_model.load():
|
| 922 |
+
raise HTTPException(503, "Background model unavailable (download/load failed). Check server logs.")
|
| 923 |
+
|
| 924 |
def _serialize_analysis(analysis):
|
| 925 |
return {
|
| 926 |
"keyword": analysis.keyword,
|