Spaces:
Runtime error
Runtime error
Harden knowledge graph build (isolation, status reset, embed fallback)
Browse files- app.py +14 -2
- pipeline/kg.py +126 -52
- static/app.js +11 -4
app.py
CHANGED
|
@@ -437,6 +437,9 @@ async def graph_build(
|
|
| 437 |
embed=do_embed,
|
| 438 |
),
|
| 439 |
)
|
|
|
|
|
|
|
|
|
|
| 440 |
except RuntimeError as exc:
|
| 441 |
# A Gemini/key/quota/network failure — actionable, single-line message.
|
| 442 |
job.kg_status = "error"
|
|
@@ -448,15 +451,24 @@ async def graph_build(
|
|
| 448 |
raise HTTPException(
|
| 449 |
status_code=502, detail="Knowledge-graph build failed unexpectedly."
|
| 450 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
-
job.knowledge_graph = graph
|
| 453 |
-
job.kg_status = "ready"
|
| 454 |
return {
|
| 455 |
"job_id": job_id,
|
| 456 |
"status": "ready",
|
| 457 |
"nodes": graph.num_nodes,
|
| 458 |
"edges": graph.num_edges,
|
| 459 |
"has_vectors": graph.has_vectors,
|
|
|
|
|
|
|
|
|
|
| 460 |
}
|
| 461 |
|
| 462 |
|
|
|
|
| 437 |
embed=do_embed,
|
| 438 |
),
|
| 439 |
)
|
| 440 |
+
# Set the terminal state INSIDE the try so the finally below sees it.
|
| 441 |
+
job.knowledge_graph = graph
|
| 442 |
+
job.kg_status = "ready"
|
| 443 |
except RuntimeError as exc:
|
| 444 |
# A Gemini/key/quota/network failure — actionable, single-line message.
|
| 445 |
job.kg_status = "error"
|
|
|
|
| 451 |
raise HTTPException(
|
| 452 |
status_code=502, detail="Knowledge-graph build failed unexpectedly."
|
| 453 |
)
|
| 454 |
+
finally:
|
| 455 |
+
# A client disconnect raises asyncio.CancelledError — a BaseException
|
| 456 |
+
# that bypasses the except clauses above (they catch only Exception).
|
| 457 |
+
# Without this, kg_status would stay stuck on "building" and 409-lock
|
| 458 |
+
# every future rebuild of this document until it's evicted. Reset it.
|
| 459 |
+
if job.kg_status == "building":
|
| 460 |
+
job.kg_status = "error"
|
| 461 |
+
job.kg_error = "Build was interrupted before it finished; try again."
|
| 462 |
|
|
|
|
|
|
|
| 463 |
return {
|
| 464 |
"job_id": job_id,
|
| 465 |
"status": "ready",
|
| 466 |
"nodes": graph.num_nodes,
|
| 467 |
"edges": graph.num_edges,
|
| 468 |
"has_vectors": graph.has_vectors,
|
| 469 |
+
"pages_built": graph.pages_built,
|
| 470 |
+
"pages_failed": graph.pages_failed,
|
| 471 |
+
"embed_error": graph.embed_error,
|
| 472 |
}
|
| 473 |
|
| 474 |
|
pipeline/kg.py
CHANGED
|
@@ -37,6 +37,7 @@ Public API
|
|
| 37 |
retrieval and returns ranked, explainable results.
|
| 38 |
"""
|
| 39 |
|
|
|
|
| 40 |
import json
|
| 41 |
import re
|
| 42 |
import time
|
|
@@ -63,6 +64,7 @@ from pipeline.online_ocr import (
|
|
| 63 |
# ---------------------------------------------------------------------------
|
| 64 |
_GENERATE_URL = _API_BASE + "/{model}:generateContent"
|
| 65 |
_BATCH_EMBED_URL = _API_BASE + "/{model}:batchEmbedContents"
|
|
|
|
| 66 |
|
| 67 |
# Triple extraction must default to a FREE-TIER model (Flash). Pro/preview
|
| 68 |
# models return HTTP 429 limit:0 on the free tier — see online_ocr notes.
|
|
@@ -147,6 +149,19 @@ _TRIPLE_SCHEMA = {
|
|
| 147 |
# ---------------------------------------------------------------------------
|
| 148 |
# Low-level HTTP (stdlib) — mirrors online_ocr's request/error handling.
|
| 149 |
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
def _post_json(url: str, body: dict, api_key: str, *, timeout: float) -> dict:
|
| 151 |
"""POST a JSON body to Gemini and return the parsed JSON response.
|
| 152 |
|
|
@@ -177,7 +192,7 @@ def _post_json(url: str, body: dict, api_key: str, *, timeout: float) -> dict:
|
|
| 177 |
if err.code in _RETRY_STATUSES and attempt < _RETRY_ATTEMPTS - 1:
|
| 178 |
time.sleep(_RETRY_BASE_DELAY * (2 ** attempt))
|
| 179 |
continue
|
| 180 |
-
raise _friendly_http_error(err) from None
|
| 181 |
except urllib.error.URLError as err:
|
| 182 |
raise RuntimeError(
|
| 183 |
"Could not reach the Gemini API ({}). Check your internet "
|
|
@@ -188,7 +203,10 @@ def _post_json(url: str, body: dict, api_key: str, *, timeout: float) -> dict:
|
|
| 188 |
"The Gemini request timed out after {}s.".format(timeout)
|
| 189 |
) from None
|
| 190 |
else: # pragma: no cover - loop always breaks or raises
|
| 191 |
-
raise
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
try:
|
| 194 |
payload = json.loads(raw.decode("utf-8", "replace"))
|
|
@@ -213,11 +231,12 @@ def _resolve_model(model, default: str) -> str:
|
|
| 213 |
return name or default
|
| 214 |
|
| 215 |
|
| 216 |
-
# Process-level cache of the discovered embedding model
|
| 217 |
-
#
|
| 218 |
-
#
|
| 219 |
-
#
|
| 220 |
-
|
|
|
|
| 221 |
|
| 222 |
|
| 223 |
def _list_embedding_models(api_key, *, timeout=30) -> list:
|
|
@@ -273,11 +292,12 @@ def _pick_embedding_model(api_key, requested=None, *, timeout=30) -> str:
|
|
| 273 |
discovery failure fall back to :data:`DEFAULT_EMBED_MODEL` (without caching,
|
| 274 |
so it's retried). A successful discovery is cached for the process.
|
| 275 |
"""
|
| 276 |
-
global _EMBED_MODEL_RESOLVED
|
| 277 |
if requested:
|
| 278 |
return _resolve_model(requested, DEFAULT_EMBED_MODEL)
|
| 279 |
-
|
| 280 |
-
|
|
|
|
|
|
|
| 281 |
try:
|
| 282 |
available = set(_list_embedding_models(api_key, timeout=timeout))
|
| 283 |
except RuntimeError:
|
|
@@ -291,7 +311,7 @@ def _pick_embedding_model(api_key, requested=None, *, timeout=30) -> str:
|
|
| 291 |
sorted(available)[0],
|
| 292 |
)
|
| 293 |
if chosen:
|
| 294 |
-
_EMBED_MODEL_RESOLVED = chosen # cache only a real discovery
|
| 295 |
return chosen
|
| 296 |
return DEFAULT_EMBED_MODEL
|
| 297 |
|
|
@@ -338,11 +358,15 @@ def _parse_triple_payload(payload: dict) -> dict:
|
|
| 338 |
text = _response_text(payload).strip()
|
| 339 |
if not text:
|
| 340 |
return {"entities": [], "triples": []}
|
| 341 |
-
# Strip
|
|
|
|
|
|
|
| 342 |
if text.startswith("```"):
|
| 343 |
-
text = text
|
| 344 |
if text[:4].lower() == "json":
|
| 345 |
text = text[4:]
|
|
|
|
|
|
|
| 346 |
text = text.strip()
|
| 347 |
try:
|
| 348 |
obj = json.loads(text)
|
|
@@ -402,6 +426,24 @@ def _parse_embed_payload(payload: dict, expected: int) -> list:
|
|
| 402 |
return out
|
| 403 |
|
| 404 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
# ---------------------------------------------------------------------------
|
| 406 |
# Public extraction / embedding calls.
|
| 407 |
# ---------------------------------------------------------------------------
|
|
@@ -467,23 +509,29 @@ def embed_texts(
|
|
| 467 |
api_key = _clean_key(api_key)
|
| 468 |
model_id = _resolve_model(model, DEFAULT_EMBED_MODEL)
|
| 469 |
wire_model = "models/" + model_id
|
| 470 |
-
|
|
|
|
| 471 |
|
| 472 |
vectors: list = []
|
|
|
|
| 473 |
for start in range(0, len(items), _EMBED_BATCH):
|
| 474 |
chunk = items[start:start + _EMBED_BATCH]
|
| 475 |
-
|
| 476 |
-
"requests": [
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
|
| 488 |
return np.asarray(vectors, dtype=np.float32)
|
| 489 |
|
|
@@ -521,6 +569,10 @@ class KnowledgeGraph:
|
|
| 521 |
_index: dict = field(default_factory=dict, repr=False) # norm_key -> node_id
|
| 522 |
vectors: Optional[np.ndarray] = field(default=None, repr=False) # (n, dim) L2-normalized
|
| 523 |
embed_model: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
# --- construction helpers ------------------------------------------------
|
| 526 |
def _add_node(self, name: str, etype: str = "OTHER", page: Optional[int] = None) -> int:
|
|
@@ -604,34 +656,33 @@ class KnowledgeGraph:
|
|
| 604 |
return self.vectors is not None and self.vectors.shape[0] == len(self.nodes)
|
| 605 |
|
| 606 |
# --- retrieval -----------------------------------------------------------
|
| 607 |
-
def _semantic_scores(self, query: str, query_vec: Optional[np.ndarray])
|
| 608 |
"""Per-node relevance to the query in [0,1].
|
| 609 |
|
| 610 |
-
Uses cosine similarity when vectors +
|
| 611 |
-
|
| 612 |
-
|
|
|
|
| 613 |
"""
|
| 614 |
n = len(self.nodes)
|
| 615 |
if n == 0:
|
| 616 |
-
return np.zeros((0,), dtype=np.float32)
|
| 617 |
-
if self.has_vectors and query_vec is not None and query_vec
|
| 618 |
q = np.asarray(query_vec, dtype=np.float32).reshape(-1)
|
| 619 |
qn = np.linalg.norm(q)
|
| 620 |
-
if qn > 0:
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
sims = self.vectors @ q # cosine; both are L2-normalized
|
| 624 |
-
return ((sims + 1.0) / 2.0).astype(np.float32) # map [-1,1]->[0,1]
|
| 625 |
# Lexical fallback.
|
| 626 |
q_tokens = _tokens(query)
|
| 627 |
scores = np.zeros((n,), dtype=np.float32)
|
| 628 |
if not q_tokens:
|
| 629 |
-
return scores
|
| 630 |
for i, node in enumerate(self.nodes):
|
| 631 |
nt = _tokens(node["name"])
|
| 632 |
if nt:
|
| 633 |
scores[i] = len(q_tokens & nt) / float(len(q_tokens | nt))
|
| 634 |
-
return scores
|
| 635 |
|
| 636 |
def _multi_source_paths(self, seeds: list, hops: int) -> dict:
|
| 637 |
"""Multi-source BFS from ``seeds`` up to ``hops``.
|
|
@@ -722,8 +773,8 @@ class KnowledgeGraph:
|
|
| 722 |
except RuntimeError:
|
| 723 |
query_vec = None
|
| 724 |
|
| 725 |
-
sem = self._semantic_scores(query, query_vec)
|
| 726 |
-
out["mode"] = "semantic" if
|
| 727 |
if sem.size == 0 or float(sem.max()) <= 0:
|
| 728 |
return out
|
| 729 |
|
|
@@ -876,26 +927,49 @@ def build_graph(
|
|
| 876 |
usable = usable[:max_pages]
|
| 877 |
|
| 878 |
graph = KnowledgeGraph()
|
|
|
|
| 879 |
for page_no, text in usable:
|
| 880 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 881 |
# Register typed entities first so types are known, then triples.
|
| 882 |
for e in data.get("entities", []):
|
| 883 |
graph._add_node(e["name"], e.get("type", "OTHER"), page=page_no)
|
| 884 |
for t in data.get("triples", []):
|
| 885 |
graph._add_triple(t["subject"], t["predicate"], t["object"], page=page_no)
|
| 886 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 887 |
if embed and graph.num_nodes:
|
| 888 |
# Resolve the embedding model the key can actually use (Google retires
|
| 889 |
# ids over time), so search uses the SAME model the nodes were built with.
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 900 |
|
| 901 |
return graph
|
|
|
|
| 37 |
retrieval and returns ranked, explainable results.
|
| 38 |
"""
|
| 39 |
|
| 40 |
+
import hashlib
|
| 41 |
import json
|
| 42 |
import re
|
| 43 |
import time
|
|
|
|
| 64 |
# ---------------------------------------------------------------------------
|
| 65 |
_GENERATE_URL = _API_BASE + "/{model}:generateContent"
|
| 66 |
_BATCH_EMBED_URL = _API_BASE + "/{model}:batchEmbedContents"
|
| 67 |
+
_EMBED_URL = _API_BASE + "/{model}:embedContent" # single-item fallback
|
| 68 |
|
| 69 |
# Triple extraction must default to a FREE-TIER model (Flash). Pro/preview
|
| 70 |
# models return HTTP 429 limit:0 on the free tier — see online_ocr notes.
|
|
|
|
| 149 |
# ---------------------------------------------------------------------------
|
| 150 |
# Low-level HTTP (stdlib) — mirrors online_ocr's request/error handling.
|
| 151 |
# ---------------------------------------------------------------------------
|
| 152 |
+
class GeminiHTTPError(RuntimeError):
|
| 153 |
+
"""A friendly, KEY-FREE Gemini HTTP error that also carries the status code.
|
| 154 |
+
|
| 155 |
+
Subclasses RuntimeError so every existing ``except RuntimeError`` still
|
| 156 |
+
catches it; the ``.code`` lets callers branch (e.g. fall back from
|
| 157 |
+
batchEmbedContents to embedContent on a 404 method-not-found).
|
| 158 |
+
"""
|
| 159 |
+
|
| 160 |
+
def __init__(self, message, code=None):
|
| 161 |
+
super().__init__(message)
|
| 162 |
+
self.code = code
|
| 163 |
+
|
| 164 |
+
|
| 165 |
def _post_json(url: str, body: dict, api_key: str, *, timeout: float) -> dict:
|
| 166 |
"""POST a JSON body to Gemini and return the parsed JSON response.
|
| 167 |
|
|
|
|
| 192 |
if err.code in _RETRY_STATUSES and attempt < _RETRY_ATTEMPTS - 1:
|
| 193 |
time.sleep(_RETRY_BASE_DELAY * (2 ** attempt))
|
| 194 |
continue
|
| 195 |
+
raise GeminiHTTPError(str(_friendly_http_error(err)), code=err.code) from None
|
| 196 |
except urllib.error.URLError as err:
|
| 197 |
raise RuntimeError(
|
| 198 |
"Could not reach the Gemini API ({}). Check your internet "
|
|
|
|
| 203 |
"The Gemini request timed out after {}s.".format(timeout)
|
| 204 |
) from None
|
| 205 |
else: # pragma: no cover - loop always breaks or raises
|
| 206 |
+
raise GeminiHTTPError(
|
| 207 |
+
str(_friendly_http_error(last_http_err)),
|
| 208 |
+
code=getattr(last_http_err, "code", None),
|
| 209 |
+
)
|
| 210 |
|
| 211 |
try:
|
| 212 |
payload = json.loads(raw.decode("utf-8", "replace"))
|
|
|
|
| 231 |
return name or default
|
| 232 |
|
| 233 |
|
| 234 |
+
# Process-level cache of the discovered embedding model, keyed by a HASH of the
|
| 235 |
+
# API key (never the key itself). Different keys/projects can have access to
|
| 236 |
+
# different models — on the multi-user demo Space the first visitor's key must
|
| 237 |
+
# NOT pin the model for everyone. Only a SUCCESSFUL discovery is cached, so a
|
| 238 |
+
# transient failure that fell back to the default is retried next time.
|
| 239 |
+
_EMBED_MODEL_RESOLVED: dict = {}
|
| 240 |
|
| 241 |
|
| 242 |
def _list_embedding_models(api_key, *, timeout=30) -> list:
|
|
|
|
| 292 |
discovery failure fall back to :data:`DEFAULT_EMBED_MODEL` (without caching,
|
| 293 |
so it's retried). A successful discovery is cached for the process.
|
| 294 |
"""
|
|
|
|
| 295 |
if requested:
|
| 296 |
return _resolve_model(requested, DEFAULT_EMBED_MODEL)
|
| 297 |
+
key_hash = hashlib.sha256(_clean_key(api_key).encode("utf-8")).hexdigest()
|
| 298 |
+
cached = _EMBED_MODEL_RESOLVED.get(key_hash)
|
| 299 |
+
if cached:
|
| 300 |
+
return cached
|
| 301 |
try:
|
| 302 |
available = set(_list_embedding_models(api_key, timeout=timeout))
|
| 303 |
except RuntimeError:
|
|
|
|
| 311 |
sorted(available)[0],
|
| 312 |
)
|
| 313 |
if chosen:
|
| 314 |
+
_EMBED_MODEL_RESOLVED[key_hash] = chosen # cache only a real discovery
|
| 315 |
return chosen
|
| 316 |
return DEFAULT_EMBED_MODEL
|
| 317 |
|
|
|
|
| 358 |
text = _response_text(payload).strip()
|
| 359 |
if not text:
|
| 360 |
return {"entities": [], "triples": []}
|
| 361 |
+
# Strip a ```json ... ``` fence if the model wrapped its JSON in one — but
|
| 362 |
+
# only the fence markers, so backticks legitimately inside the content
|
| 363 |
+
# (e.g. a value with a code span) are preserved.
|
| 364 |
if text.startswith("```"):
|
| 365 |
+
text = text[3:]
|
| 366 |
if text[:4].lower() == "json":
|
| 367 |
text = text[4:]
|
| 368 |
+
if text.endswith("```"):
|
| 369 |
+
text = text[:-3]
|
| 370 |
text = text.strip()
|
| 371 |
try:
|
| 372 |
obj = json.loads(text)
|
|
|
|
| 426 |
return out
|
| 427 |
|
| 428 |
|
| 429 |
+
def _parse_single_embed_payload(payload: dict) -> list:
|
| 430 |
+
"""Parse a single-item embedContent response: ``{"embedding": {"values": [...]}}``."""
|
| 431 |
+
emb = payload.get("embedding") if isinstance(payload, dict) else None
|
| 432 |
+
values = emb.get("values") if isinstance(emb, dict) else None
|
| 433 |
+
if not isinstance(values, list) or not values:
|
| 434 |
+
raise RuntimeError("Gemini returned an empty embedding vector.")
|
| 435 |
+
return [float(v) for v in values]
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def _embed_item(wire_model: str, text: str, task_type: str) -> dict:
|
| 439 |
+
"""One embedding request item (same shape for batch list and single body)."""
|
| 440 |
+
return {
|
| 441 |
+
"model": wire_model,
|
| 442 |
+
"content": {"parts": [{"text": text}]},
|
| 443 |
+
"taskType": task_type,
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
|
| 447 |
# ---------------------------------------------------------------------------
|
| 448 |
# Public extraction / embedding calls.
|
| 449 |
# ---------------------------------------------------------------------------
|
|
|
|
| 509 |
api_key = _clean_key(api_key)
|
| 510 |
model_id = _resolve_model(model, DEFAULT_EMBED_MODEL)
|
| 511 |
wire_model = "models/" + model_id
|
| 512 |
+
batch_url = _BATCH_EMBED_URL.format(model=model_id)
|
| 513 |
+
single_url = _EMBED_URL.format(model=model_id)
|
| 514 |
|
| 515 |
vectors: list = []
|
| 516 |
+
use_batch = True
|
| 517 |
for start in range(0, len(items), _EMBED_BATCH):
|
| 518 |
chunk = items[start:start + _EMBED_BATCH]
|
| 519 |
+
if use_batch:
|
| 520 |
+
body = {"requests": [_embed_item(wire_model, t, task_type) for t in chunk]}
|
| 521 |
+
try:
|
| 522 |
+
payload = _post_json(batch_url, body, api_key, timeout=timeout)
|
| 523 |
+
vectors.extend(_parse_embed_payload(payload, len(chunk)))
|
| 524 |
+
continue
|
| 525 |
+
except GeminiHTTPError as exc:
|
| 526 |
+
# 404 = this model/version doesn't expose batchEmbedContents.
|
| 527 |
+
# Degrade to per-item embedContent instead of failing the build.
|
| 528 |
+
if exc.code != 404:
|
| 529 |
+
raise
|
| 530 |
+
use_batch = False
|
| 531 |
+
for t in chunk:
|
| 532 |
+
payload = _post_json(single_url, _embed_item(wire_model, t, task_type),
|
| 533 |
+
api_key, timeout=timeout)
|
| 534 |
+
vectors.append(_parse_single_embed_payload(payload))
|
| 535 |
|
| 536 |
return np.asarray(vectors, dtype=np.float32)
|
| 537 |
|
|
|
|
| 569 |
_index: dict = field(default_factory=dict, repr=False) # norm_key -> node_id
|
| 570 |
vectors: Optional[np.ndarray] = field(default=None, repr=False) # (n, dim) L2-normalized
|
| 571 |
embed_model: Optional[str] = None
|
| 572 |
+
# Build telemetry (surfaced so partial builds / degraded search aren't silent).
|
| 573 |
+
pages_built: int = 0 # pages whose triple extraction succeeded
|
| 574 |
+
pages_failed: int = 0 # pages skipped due to a per-page Gemini failure
|
| 575 |
+
embed_error: Optional[str] = None # set if embedding failed -> lexical-only
|
| 576 |
|
| 577 |
# --- construction helpers ------------------------------------------------
|
| 578 |
def _add_node(self, name: str, etype: str = "OTHER", page: Optional[int] = None) -> int:
|
|
|
|
| 656 |
return self.vectors is not None and self.vectors.shape[0] == len(self.nodes)
|
| 657 |
|
| 658 |
# --- retrieval -----------------------------------------------------------
|
| 659 |
+
def _semantic_scores(self, query: str, query_vec: Optional[np.ndarray]):
|
| 660 |
"""Per-node relevance to the query in [0,1].
|
| 661 |
|
| 662 |
+
Returns ``(scores, used_vectors)``. Uses cosine similarity when vectors +
|
| 663 |
+
a usable query vector are available; otherwise falls back to lexical
|
| 664 |
+
token overlap (``used_vectors=False``) so the caller can report the mode
|
| 665 |
+
accurately even when it silently fell back (e.g. a dim mismatch).
|
| 666 |
"""
|
| 667 |
n = len(self.nodes)
|
| 668 |
if n == 0:
|
| 669 |
+
return np.zeros((0,), dtype=np.float32), False
|
| 670 |
+
if self.has_vectors and query_vec is not None and getattr(query_vec, "size", 0):
|
| 671 |
q = np.asarray(query_vec, dtype=np.float32).reshape(-1)
|
| 672 |
qn = np.linalg.norm(q)
|
| 673 |
+
if qn > 0 and q.shape[0] == self.vectors.shape[1]:
|
| 674 |
+
sims = self.vectors @ (q / qn) # cosine; both are L2-normalized
|
| 675 |
+
return ((sims + 1.0) / 2.0).astype(np.float32), True # [-1,1]->[0,1]
|
|
|
|
|
|
|
| 676 |
# Lexical fallback.
|
| 677 |
q_tokens = _tokens(query)
|
| 678 |
scores = np.zeros((n,), dtype=np.float32)
|
| 679 |
if not q_tokens:
|
| 680 |
+
return scores, False
|
| 681 |
for i, node in enumerate(self.nodes):
|
| 682 |
nt = _tokens(node["name"])
|
| 683 |
if nt:
|
| 684 |
scores[i] = len(q_tokens & nt) / float(len(q_tokens | nt))
|
| 685 |
+
return scores, False
|
| 686 |
|
| 687 |
def _multi_source_paths(self, seeds: list, hops: int) -> dict:
|
| 688 |
"""Multi-source BFS from ``seeds`` up to ``hops``.
|
|
|
|
| 773 |
except RuntimeError:
|
| 774 |
query_vec = None
|
| 775 |
|
| 776 |
+
sem, used_vectors = self._semantic_scores(query, query_vec)
|
| 777 |
+
out["mode"] = "semantic" if used_vectors else "lexical"
|
| 778 |
if sem.size == 0 or float(sem.max()) <= 0:
|
| 779 |
return out
|
| 780 |
|
|
|
|
| 927 |
usable = usable[:max_pages]
|
| 928 |
|
| 929 |
graph = KnowledgeGraph()
|
| 930 |
+
last_error: Optional[Exception] = None
|
| 931 |
for page_no, text in usable:
|
| 932 |
+
# Isolate each page: a single rate-limited / safety-blocked page (common
|
| 933 |
+
# on the free tier) must NOT discard every page already processed (and
|
| 934 |
+
# its API spend). Skip it and keep going.
|
| 935 |
+
try:
|
| 936 |
+
data = extract_triples(text, api_key=api_key, model=triple_model, timeout=timeout)
|
| 937 |
+
except RuntimeError as exc:
|
| 938 |
+
graph.pages_failed += 1
|
| 939 |
+
last_error = exc
|
| 940 |
+
continue
|
| 941 |
+
graph.pages_built += 1
|
| 942 |
# Register typed entities first so types are known, then triples.
|
| 943 |
for e in data.get("entities", []):
|
| 944 |
graph._add_node(e["name"], e.get("type", "OTHER"), page=page_no)
|
| 945 |
for t in data.get("triples", []):
|
| 946 |
graph._add_triple(t["subject"], t["predicate"], t["object"], page=page_no)
|
| 947 |
|
| 948 |
+
# If EVERY page failed, this is a systemic failure (bad key / quota / network),
|
| 949 |
+
# not "a document with no facts" — surface it instead of returning an empty graph.
|
| 950 |
+
if usable and graph.pages_failed == len(usable):
|
| 951 |
+
raise last_error or RuntimeError(
|
| 952 |
+
"Knowledge-graph extraction failed for every page."
|
| 953 |
+
)
|
| 954 |
+
|
| 955 |
if embed and graph.num_nodes:
|
| 956 |
# Resolve the embedding model the key can actually use (Google retires
|
| 957 |
# ids over time), so search uses the SAME model the nodes were built with.
|
| 958 |
+
# Embeddings are the SEMANTIC half: if they fail (quota / retired model),
|
| 959 |
+
# keep the graph for LEXICAL search rather than throwing away the (costly)
|
| 960 |
+
# triple extraction. The degradation is surfaced via has_vectors/embed_error.
|
| 961 |
+
try:
|
| 962 |
+
resolved = _pick_embedding_model(api_key, embed_model, timeout=min(timeout, 30))
|
| 963 |
+
node_texts = [graph._node_embed_text(i) for i in range(graph.num_nodes)]
|
| 964 |
+
vectors = embed_texts(
|
| 965 |
+
node_texts,
|
| 966 |
+
api_key=api_key,
|
| 967 |
+
model=resolved,
|
| 968 |
+
task_type="RETRIEVAL_DOCUMENT",
|
| 969 |
+
timeout=timeout,
|
| 970 |
+
)
|
| 971 |
+
graph.set_vectors(vectors, model=resolved)
|
| 972 |
+
except RuntimeError as exc:
|
| 973 |
+
graph.embed_error = str(exc)
|
| 974 |
|
| 975 |
return graph
|
static/app.js
CHANGED
|
@@ -1183,10 +1183,17 @@
|
|
| 1183 |
return;
|
| 1184 |
}
|
| 1185 |
setKgStatus(job, data.nodes + " entities · " + data.edges + " facts", "good");
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1190 |
if (el.kgBuildBtn) el.kgBuildBtn.hidden = true;
|
| 1191 |
el.kgQueryWrap.hidden = false;
|
| 1192 |
showToast("Knowledge graph: " + data.nodes + " entities, " + data.edges + " facts");
|
|
|
|
| 1183 |
return;
|
| 1184 |
}
|
| 1185 |
setKgStatus(job, data.nodes + " entities · " + data.edges + " facts", "good");
|
| 1186 |
+
let note = data.has_vectors
|
| 1187 |
+
? "Graph ready — ask a question below (semantic + graph search)."
|
| 1188 |
+
: "Graph ready — ask a question below (keyword graph search).";
|
| 1189 |
+
if (data.pages_failed) {
|
| 1190 |
+
note += " " + data.pages_failed + " page(s) were skipped (rate limit or block).";
|
| 1191 |
+
}
|
| 1192 |
+
if (data.embed_error) {
|
| 1193 |
+
note += " Semantic search unavailable (" + shortErr(data.embed_error) +
|
| 1194 |
+
") — using keyword search.";
|
| 1195 |
+
}
|
| 1196 |
+
el.kgBuildNote.textContent = note;
|
| 1197 |
if (el.kgBuildBtn) el.kgBuildBtn.hidden = true;
|
| 1198 |
el.kgQueryWrap.hidden = false;
|
| 1199 |
showToast("Knowledge graph: " + data.nodes + " entities, " + data.edges + " facts");
|