Archie0099's picture
Fix edge-case bugs found in a thorough code review
74fe989 verified
Raw
History Blame Contribute Delete
44.4 kB
"""Optional knowledge-graph + embedding search over EXTRACTED text (Gemini).
After a PDF is extracted, this module turns its per-page text into a small
**knowledge graph** — typed entities (nodes) joined by ``(subject, predicate,
object)`` triples (edges) — embeds every node into a vector space, and answers a
query by combining **semantic vector search** (conceptually-related entities)
with **explicit graph traversal** (following the facts). The answer is
*explainable*: every result carries the supporting triples and the page each
came from.
DESIGN / IDENTITY
- This is an OPT-IN, online feature. Like ``online_ocr.py`` it sends text to
Google's Gemini API, so it is OFF by default and only runs when the caller
passes a Gemini API key. The page text leaves this machine.
- ZERO new pip dependencies: the REST calls use the Python standard library
only (``urllib.request`` + ``json``); the graph is a plain in-memory
adjacency structure; ``numpy`` (already a core dep) holds the node vectors.
This keeps the feature working on the lean hosted build too (no torch /
sentence-transformers / networkx required).
- The security-critical key sanitization and the HTTP error mapping are REUSED
from ``online_ocr`` so the "never echo the key, only raise a single-line
RuntimeError" contract holds here as well.
Importing this module performs NO network call and has no import-time side
effects.
Public API
----------
``extract_triples(text, *, api_key, model=None, timeout=...) -> dict``
Pull ``{"entities": [...], "triples": [...]}`` out of one page of text.
``embed_texts(texts, *, api_key, model=None, task_type=..., timeout=...) -> np.ndarray``
Embed a list of strings to an ``(n, dim)`` float32 matrix.
``build_graph(pages, *, api_key, ...) -> KnowledgeGraph``
Build a per-document graph from ``[(page_number, text), ...]``.
``KnowledgeGraph``
Holds the graph + node vectors; ``.search(query, ...)`` does hybrid
retrieval and returns ranked, explainable results.
"""
import hashlib
import http.client
import json
import re
import time
import urllib.error
import urllib.request
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
# Reuse the verified, security-tested helpers from the OCR client so the key is
# sanitized identically and HTTP errors never leak it.
from pipeline.online_ocr import (
_API_BASE,
_API_KEY_HEADER,
_clean_key,
_friendly_http_error,
is_configured,
)
# ---------------------------------------------------------------------------
# Gemini REST endpoints (same base/host as online_ocr; different methods).
# ---------------------------------------------------------------------------
_GENERATE_URL = _API_BASE + "/{model}:generateContent"
_BATCH_EMBED_URL = _API_BASE + "/{model}:batchEmbedContents"
_EMBED_URL = _API_BASE + "/{model}:embedContent" # single-item fallback
# Triple extraction must default to a FREE-TIER model (Flash). Pro/preview
# models return HTTP 429 limit:0 on the free tier — see online_ocr notes.
DEFAULT_TRIPLE_MODEL = "gemini-2.5-flash"
# Embeddings: the wire format wants the "models/" prefix in the per-request
# "model" field. The embedding model id has CHANGED over time — Google retired
# `text-embedding-004` on v1beta (it now 404s) in favour of `gemini-embedding-001`.
# So we DON'T trust a single hard-coded id: build_graph DISCOVERS the model the
# key can actually use via ModelService.ListModels (`_pick_embedding_model`),
# trying this preference order and falling back to the default only if discovery
# fails. Order newest→oldest so a key that still has an old model also works.
DEFAULT_EMBED_MODEL = "gemini-embedding-001"
EMBED_MODEL_PREFERENCE = (
"gemini-embedding-001", # current GA model (default ~3072-dim; we L2-normalize)
"text-embedding-004", # legacy 768-dim (retired on v1beta for many keys)
"embedding-001", # older fallback
)
# Gemini caps batchEmbedContents at 100 requests per call; chunk to stay under.
_EMBED_BATCH = 100
# Defensive cap on per-page text sent for triple extraction. A single PDF page
# is tiny next to Flash's context window, but a pathological page (e.g. a giant
# embedded text dump) shouldn't balloon the request.
_MAX_PAGE_CHARS = 100_000
# Transient-failure backoff. Free-tier embedding/generate limits are real, so a
# 429/503 is retried a few times with exponential backoff before giving up.
_RETRY_STATUSES = frozenset({429, 503})
_RETRY_ATTEMPTS = 4
_RETRY_BASE_DELAY = 1.5 # seconds; doubled each attempt
# Build scalability: per-page triple extraction is one (I/O-bound) Gemini call
# each, so they're fanned across this SHARED, bounded pool instead of running
# strictly sequentially — a multi-page build is ~Nx faster on a key with real
# throughput. The pool is module-level on purpose: being shared, it also caps the
# TOTAL number of concurrent Gemini calls across simultaneous builds/users, so one
# big document can't blow the rate limit for everyone. Overshoot of the provider's
# rate limit is absorbed by the 429/503 backoff in _post_json. Graph mutation stays
# single-threaded (results are collected, then applied in deterministic page order).
_BUILD_CONCURRENCY = 5
_EXTRACT_POOL = ThreadPoolExecutor(
max_workers=_BUILD_CONCURRENCY, thread_name_prefix="kg-extract"
)
# Instruction for triple extraction. We ask for typed entities AND triples and
# force a JSON response via responseSchema for deterministic parsing.
_TRIPLE_PROMPT = (
"You are building a knowledge graph from a document. From the TEXT below, "
"extract the key entities and the factual relationships between them.\n"
"- entities: the important named things (people, organizations, places, "
"dates, IDs, amounts, concepts). Give each a short canonical name and a "
"TYPE (e.g. PERSON, ORG, PLACE, DATE, ID, AMOUNT, CONCEPT, OTHER).\n"
"- triples: factual (subject, predicate, object) statements stated or "
"clearly implied by the text. Use concise predicates (e.g. 'works at', "
"'located in', 'has id', 'dated'). Subject and object should be entity "
"names. Do NOT invent facts that are not supported by the text.\n"
"Return only what the text supports; if the text is empty or has no "
"extractable facts, return empty lists.\n\nTEXT:\n"
)
# Response schema for generateContent — keeps output a strict JSON object.
_TRIPLE_SCHEMA = {
"type": "OBJECT",
"properties": {
"entities": {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"name": {"type": "STRING"},
"type": {"type": "STRING"},
},
"required": ["name"],
},
},
"triples": {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"subject": {"type": "STRING"},
"predicate": {"type": "STRING"},
"object": {"type": "STRING"},
},
"required": ["subject", "predicate", "object"],
},
},
},
"required": ["triples"],
}
# ---------------------------------------------------------------------------
# Low-level HTTP (stdlib) — mirrors online_ocr's request/error handling.
# ---------------------------------------------------------------------------
class GeminiHTTPError(RuntimeError):
"""A friendly, KEY-FREE Gemini HTTP error that also carries the status code.
Subclasses RuntimeError so every existing ``except RuntimeError`` still
catches it; the ``.code`` lets callers branch (e.g. fall back from
batchEmbedContents to embedContent on a 404 method-not-found).
"""
def __init__(self, message, code=None):
super().__init__(message)
self.code = code
def _post_json(url: str, body: dict, api_key: str, *, timeout: float) -> dict:
"""POST a JSON body to Gemini and return the parsed JSON response.
``api_key`` must already be sanitized via ``_clean_key``. Retries 429/503
with exponential backoff, then maps any HTTP/network/non-JSON failure to a
single-line RuntimeError (never echoing the key — ``_friendly_http_error``
reads only the API's own error body).
"""
data = json.dumps(body).encode("utf-8")
request = urllib.request.Request(
url,
data=data,
method="POST",
headers={
"Content-Type": "application/json",
_API_KEY_HEADER: api_key,
},
)
last_http_err: Optional[urllib.error.HTTPError] = None
for attempt in range(_RETRY_ATTEMPTS):
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read()
break
except urllib.error.HTTPError as err:
last_http_err = err
if err.code in _RETRY_STATUSES and attempt < _RETRY_ATTEMPTS - 1:
time.sleep(_RETRY_BASE_DELAY * (2 ** attempt))
continue
raise GeminiHTTPError(str(_friendly_http_error(err)), code=err.code) from None
except urllib.error.URLError as err:
raise RuntimeError(
"Could not reach the Gemini API ({}). Check your internet "
"connection.".format(err.reason)
) from None
except TimeoutError:
raise RuntimeError(
"The Gemini request timed out after {}s.".format(timeout)
) from None
except (OSError, http.client.HTTPException) as err:
# The connection dropped mid-body: urlopen() returned headers but
# response.read() failed (e.g. ConnectionResetError — an OSError not
# wrapped in URLError — or http.client.IncompleteRead, which isn't
# even an OSError). This clause MUST sit after URLError/TimeoutError
# (both OSError subclasses). Mapping it to a single-line RuntimeError
# keeps the "_post_json only ever raises RuntimeError" contract that
# build_graph's per-page isolation and non-fatal embedding rely on.
raise RuntimeError(
"The Gemini connection dropped while reading the response ({}); "
"retry shortly.".format(err)
) from None
else: # pragma: no cover - loop always breaks or raises
raise GeminiHTTPError(
str(_friendly_http_error(last_http_err)),
code=getattr(last_http_err, "code", None),
)
try:
payload = json.loads(raw.decode("utf-8", "replace"))
except (ValueError, AttributeError) as err:
raise RuntimeError(
"Gemini returned a response that was not valid JSON ({}). The "
"service may be having problems; retry shortly.".format(err)
) from None
if not isinstance(payload, dict):
raise RuntimeError(
"Gemini returned an unexpected response (not a JSON object); "
"retry shortly."
)
return payload
def _resolve_model(model, default: str) -> str:
"""Return a clean bare model id (no ``models/`` prefix), defaulting when unset."""
name = (str(model).strip() if model else "") or default
if name.startswith("models/"):
name = name[len("models/"):]
return name or default
# Process-level cache of the discovered embedding model, keyed by a HASH of the
# API key (never the key itself). Different keys/projects can have access to
# different models — on the multi-user demo Space the first visitor's key must
# NOT pin the model for everyone. Only a SUCCESSFUL discovery is cached, so a
# transient failure that fell back to the default is retried next time.
_EMBED_MODEL_RESOLVED: dict = {}
def _list_embedding_models(api_key, *, timeout=30) -> list:
"""Return bare ids of models the key can use for embedding.
Calls ``GET /v1beta/models`` and keeps those whose
``supportedGenerationMethods`` include ``batchEmbedContents`` (what we call)
or ``embedContent``. Used to pick a model that actually exists, instead of
hard-coding an id that Google may retire (the cause of the text-embedding-004
404). Mirrors ``online_ocr.list_models`` but filters for embedding support.
"""
api_key = _clean_key(api_key)
request = urllib.request.Request(
_API_BASE, method="GET", headers={_API_KEY_HEADER: api_key}
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read()
except urllib.error.HTTPError as err:
raise _friendly_http_error(err) from None
except urllib.error.URLError as err:
raise RuntimeError(
"Could not reach the Gemini API ({}). Check your internet "
"connection.".format(err.reason)
) from None
except TimeoutError:
raise RuntimeError(
"The Gemini request timed out after {}s.".format(timeout)
) from None
except (OSError, http.client.HTTPException) as err:
# Connection dropped while reading the ListModels body (see _post_json).
# Keep it a single-line RuntimeError so _pick_embedding_model's
# `except RuntimeError` catches it and falls back to the default model.
raise RuntimeError(
"The Gemini connection dropped while reading the response ({}); "
"retry shortly.".format(err)
) from None
try:
payload = json.loads(raw.decode("utf-8", "replace"))
except (ValueError, AttributeError):
return []
out = []
for entry in (payload.get("models") or []):
if not isinstance(entry, dict):
continue
methods = entry.get("supportedGenerationMethods") or []
if "batchEmbedContents" in methods or "embedContent" in methods:
name = str(entry.get("name") or "")
if name.startswith("models/"):
name = name[len("models/"):]
if name:
out.append(name)
return out
def _pick_embedding_model(api_key, requested=None, *, timeout=30) -> str:
"""Choose an embedding model id the key can actually use.
An explicit ``requested`` model wins. Otherwise discover the available
embedding models and pick by :data:`EMBED_MODEL_PREFERENCE`; on a transient
discovery failure fall back to :data:`DEFAULT_EMBED_MODEL` (without caching,
so it's retried). A successful discovery is cached for the process.
"""
if requested:
return _resolve_model(requested, DEFAULT_EMBED_MODEL)
key_hash = hashlib.sha256(_clean_key(api_key).encode("utf-8")).hexdigest()
cached = _EMBED_MODEL_RESOLVED.get(key_hash)
if cached:
return cached
try:
available = set(_list_embedding_models(api_key, timeout=timeout))
except RuntimeError:
available = set()
chosen = next((m for m in EMBED_MODEL_PREFERENCE if m in available), None)
if not chosen and available:
# An unfamiliar but embedding-capable model — prefer one that looks like
# an embedding model, else just take the first deterministically.
chosen = next(
(m for m in sorted(available) if "embedding" in m.lower()),
sorted(available)[0],
)
if chosen:
_EMBED_MODEL_RESOLVED[key_hash] = chosen # cache only a real discovery
return chosen
return DEFAULT_EMBED_MODEL
# ---------------------------------------------------------------------------
# Response parsers (pure functions — unit-tested with canned dicts, no network).
# ---------------------------------------------------------------------------
def _response_text(payload: dict) -> str:
"""Concatenate the text parts of a generateContent response.
Handles a prompt-level block or a non-STOP finishReason by raising an
actionable RuntimeError, and tolerates an explicit ``{"text": null}`` part.
"""
feedback = payload.get("promptFeedback") or {}
block_reason = feedback.get("blockReason")
candidates = payload.get("candidates") or []
if not candidates:
if block_reason:
raise RuntimeError(
"Gemini blocked the request (blockReason={}). The text tripped "
"a safety filter; knowledge-graph extraction is unavailable for "
"this page.".format(block_reason)
)
raise RuntimeError(
"Gemini returned no candidates for knowledge-graph extraction."
)
candidate = candidates[0] or {}
content = candidate.get("content") or {}
parts = content.get("parts") or []
return "".join(
str(part.get("text") or "") for part in parts if isinstance(part, dict)
)
def _parse_triple_payload(payload: dict) -> dict:
"""Parse a generateContent response into ``{"entities": [...], "triples": [...]}``.
The model is asked to return a JSON object (responseMimeType=application/json),
so the candidate text is itself a JSON string. We parse it and coerce every
field to clean strings, dropping malformed/empty rows. Never raises on a
merely-empty or slightly-malformed result — returns empty lists instead, so
one odd page can't fail the whole build.
"""
text = _response_text(payload).strip()
if not text:
return {"entities": [], "triples": []}
# Strip a ```json ... ``` fence if the model wrapped its JSON in one — but
# only the fence markers, so backticks legitimately inside the content
# (e.g. a value with a code span) are preserved.
if text.startswith("```"):
text = text[3:]
if text[:4].lower() == "json":
text = text[4:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
obj = json.loads(text)
except (ValueError, TypeError):
return {"entities": [], "triples": []}
if not isinstance(obj, dict):
# Some models return a bare array of triples.
obj = {"triples": obj} if isinstance(obj, list) else {}
entities = []
for e in obj.get("entities") or []:
if not isinstance(e, dict):
continue
name = str(e.get("name") or "").strip()
if not name:
continue
etype = str(e.get("type") or "").strip().upper() or "OTHER"
entities.append({"name": name, "type": etype})
triples = []
for t in obj.get("triples") or []:
if not isinstance(t, dict):
continue
subj = str(t.get("subject") or "").strip()
pred = str(t.get("predicate") or "").strip()
obj_ = str(t.get("object") or "").strip()
if subj and pred and obj_:
triples.append({"subject": subj, "predicate": pred, "object": obj_})
return {"entities": entities, "triples": triples}
def _parse_embed_payload(payload: dict, expected: int) -> list:
"""Parse a batchEmbedContents response into a list of float vectors.
Response shape: ``{"embeddings": [{"values": [...]}, ...]}`` in request
order. Raises if the count doesn't match what we sent (a silent mismatch
would misalign vectors with nodes).
"""
embeddings = payload.get("embeddings")
if not isinstance(embeddings, list):
raise RuntimeError(
"Gemini embedding response had no 'embeddings' list; cannot build "
"the vector index."
)
if len(embeddings) != expected:
raise RuntimeError(
"Gemini returned {} embeddings for {} inputs (count mismatch).".format(
len(embeddings), expected
)
)
out = []
for item in embeddings:
values = (item or {}).get("values") if isinstance(item, dict) else None
if not isinstance(values, list) or not values:
raise RuntimeError("Gemini returned an empty embedding vector.")
out.append([float(v) for v in values])
return out
def _parse_single_embed_payload(payload: dict) -> list:
"""Parse a single-item embedContent response: ``{"embedding": {"values": [...]}}``."""
emb = payload.get("embedding") if isinstance(payload, dict) else None
values = emb.get("values") if isinstance(emb, dict) else None
if not isinstance(values, list) or not values:
raise RuntimeError("Gemini returned an empty embedding vector.")
return [float(v) for v in values]
def _embed_item(wire_model: str, text: str, task_type: str) -> dict:
"""One embedding request item (same shape for batch list and single body)."""
return {
"model": wire_model,
"content": {"parts": [{"text": text}]},
"taskType": task_type,
}
# ---------------------------------------------------------------------------
# Public extraction / embedding calls.
# ---------------------------------------------------------------------------
def extract_triples(text: str, *, api_key, model=None, timeout: float = 120) -> dict:
"""Extract typed entities + ``(subject, predicate, object)`` triples from text.
Returns ``{"entities": [{"name","type"}], "triples":
[{"subject","predicate","object"}]}``. Provenance (the source page) is NOT
returned by the model — the caller attaches it.
"""
if not is_configured(api_key):
raise RuntimeError(
"Knowledge-graph extraction needs a Gemini API key but none was "
"provided. Get a free key from https://aistudio.google.com/."
)
clean = str(text or "").strip()
if not clean:
return {"entities": [], "triples": []}
if len(clean) > _MAX_PAGE_CHARS:
clean = clean[:_MAX_PAGE_CHARS]
api_key = _clean_key(api_key)
model_id = _resolve_model(model, DEFAULT_TRIPLE_MODEL)
body = {
"contents": [{"parts": [{"text": _TRIPLE_PROMPT + clean}]}],
"generationConfig": {
"temperature": 0,
"responseMimeType": "application/json",
"responseSchema": _TRIPLE_SCHEMA,
},
}
payload = _post_json(
_GENERATE_URL.format(model=model_id), body, api_key, timeout=timeout
)
return _parse_triple_payload(payload)
def embed_texts(
texts,
*,
api_key,
model=None,
task_type: str = "RETRIEVAL_DOCUMENT",
timeout: float = 120,
) -> np.ndarray:
"""Embed a list of strings into an ``(n, dim)`` float32 matrix via Gemini.
``task_type`` should be ``RETRIEVAL_DOCUMENT`` for the graph's nodes and
``RETRIEVAL_QUERY`` for the search query — the embedding model uses it to
tune the vectors for retrieval, a real accuracy win. Batches at 100 inputs
per request (Gemini's cap) and concatenates in order. Pass an explicit
``model`` (typically resolved by :func:`_pick_embedding_model`); the default
is only a last resort since Google retires embedding-model ids over time.
"""
if not is_configured(api_key):
raise RuntimeError(
"Knowledge-graph search needs a Gemini API key but none was provided."
)
items = [str(t or "") for t in texts]
if not items:
return np.zeros((0, 0), dtype=np.float32)
api_key = _clean_key(api_key)
model_id = _resolve_model(model, DEFAULT_EMBED_MODEL)
wire_model = "models/" + model_id
batch_url = _BATCH_EMBED_URL.format(model=model_id)
single_url = _EMBED_URL.format(model=model_id)
vectors: list = []
use_batch = True
for start in range(0, len(items), _EMBED_BATCH):
chunk = items[start:start + _EMBED_BATCH]
if use_batch:
body = {"requests": [_embed_item(wire_model, t, task_type) for t in chunk]}
try:
payload = _post_json(batch_url, body, api_key, timeout=timeout)
vectors.extend(_parse_embed_payload(payload, len(chunk)))
continue
except GeminiHTTPError as exc:
# 404 = this model/version doesn't expose batchEmbedContents.
# Degrade to per-item embedContent instead of failing the build.
if exc.code != 404:
raise
use_batch = False
for t in chunk:
payload = _post_json(single_url, _embed_item(wire_model, t, task_type),
api_key, timeout=timeout)
vectors.append(_parse_single_embed_payload(payload))
return np.asarray(vectors, dtype=np.float32)
# ---------------------------------------------------------------------------
# Graph data structures + builder.
# ---------------------------------------------------------------------------
def _norm_key(name: str) -> str:
"""Canonical key for entity de-duplication (case/space-insensitive)."""
return re.sub(r"\s+", " ", str(name or "").strip()).casefold()
_WORD_RE = re.compile(r"[^\W\d_]+|\d+", re.UNICODE)
def _tokens(text: str) -> set:
return {t for t in _WORD_RE.findall(str(text or "").casefold()) if len(t) > 1}
@dataclass
class KnowledgeGraph:
"""A per-document knowledge graph + node embeddings, held in RAM.
Nodes are entities; edges are ``(subject, predicate, object)`` triples, each
carrying the page it came from (provenance). Node vectors live in a parallel
float32 matrix (L2-normalized for cosine). Everything is plain Python +
numpy — no networkx, no external store — so it evicts with its ``Job`` and
serializes only via the explicit ``to_*`` methods (never leaked by accident).
"""
# Parallel arrays indexed by node id (0..n-1).
nodes: list = field(default_factory=list) # [{name, type, pages, degree}]
triples: list = field(default_factory=list) # [{subject_id, predicate, object_id, subject, object, page}]
adjacency: dict = field(default_factory=dict) # node_id -> [(neighbor_id, triple_index)]
_index: dict = field(default_factory=dict, repr=False) # norm_key -> node_id
vectors: Optional[np.ndarray] = field(default=None, repr=False) # (n, dim) L2-normalized
embed_model: Optional[str] = None
# Build telemetry (surfaced so partial builds / degraded search aren't silent).
pages_built: int = 0 # pages whose triple extraction succeeded
pages_failed: int = 0 # pages skipped due to a per-page Gemini failure
embed_error: Optional[str] = None # set if embedding failed -> lexical-only
# --- construction helpers ------------------------------------------------
def _add_node(self, name: str, etype: str = "OTHER", page: Optional[int] = None) -> int:
key = _norm_key(name)
if not key:
return -1
nid = self._index.get(key)
if nid is None:
nid = len(self.nodes)
self._index[key] = nid
self.nodes.append(
{"name": str(name).strip(), "type": (etype or "OTHER"), "pages": [], "degree": 0}
)
self.adjacency[nid] = []
node = self.nodes[nid]
# Prefer a known type over OTHER if a later mention supplies one.
if (not node["type"] or node["type"] == "OTHER") and etype and etype != "OTHER":
node["type"] = etype
if page is not None and page not in node["pages"]:
node["pages"].append(page)
return nid
def _add_triple(self, subject: str, predicate: str, obj: str, page: Optional[int]) -> None:
s_id = self._add_node(subject, page=page)
o_id = self._add_node(obj, page=page)
if s_id < 0 or o_id < 0 or s_id == o_id:
return
tindex = len(self.triples)
self.triples.append(
{
"subject_id": s_id,
"object_id": o_id,
"predicate": str(predicate).strip(),
"subject": self.nodes[s_id]["name"],
"object": self.nodes[o_id]["name"],
"page": page,
}
)
# Undirected adjacency for traversal; direction is kept in the triple.
self.adjacency[s_id].append((o_id, tindex))
self.adjacency[o_id].append((s_id, tindex))
self.nodes[s_id]["degree"] += 1
self.nodes[o_id]["degree"] += 1
def _node_embed_text(self, nid: int) -> str:
"""Text used to embed a node: its name/type + a little fact context."""
node = self.nodes[nid]
bits = ["{} ({})".format(node["name"], node["type"])]
# Up to a few connected facts give the embedding semantic context.
ctx = [
"{} {} {}".format(t["subject"], t["predicate"], t["object"])
for _, tindex in self.adjacency[nid][:3]
for t in (self.triples[tindex],)
]
if ctx:
bits.append(". ".join(ctx))
return ". ".join(bits)
def set_vectors(self, vectors: Optional[np.ndarray], model: Optional[str] = None) -> None:
"""Attach (and L2-normalize) the node-vector matrix."""
if vectors is None or getattr(vectors, "size", 0) == 0:
self.vectors = None
return
v = np.asarray(vectors, dtype=np.float32)
norms = np.linalg.norm(v, axis=1, keepdims=True)
norms[norms == 0] = 1.0
self.vectors = v / norms
self.embed_model = model
# --- public properties ---------------------------------------------------
@property
def num_nodes(self) -> int:
return len(self.nodes)
@property
def num_edges(self) -> int:
return len(self.triples)
@property
def has_vectors(self) -> bool:
return self.vectors is not None and self.vectors.shape[0] == len(self.nodes)
# --- retrieval -----------------------------------------------------------
def _semantic_scores(self, query: str, query_vec: Optional[np.ndarray]):
"""Per-node relevance to the query in [0,1].
Returns ``(scores, used_vectors)``. Uses cosine similarity when vectors +
a usable query vector are available; otherwise falls back to lexical
token overlap (``used_vectors=False``) so the caller can report the mode
accurately even when it silently fell back (e.g. a dim mismatch).
"""
n = len(self.nodes)
if n == 0:
return np.zeros((0,), dtype=np.float32), False
if self.has_vectors and query_vec is not None and getattr(query_vec, "size", 0):
q = np.asarray(query_vec, dtype=np.float32).reshape(-1)
qn = np.linalg.norm(q)
if qn > 0 and q.shape[0] == self.vectors.shape[1]:
sims = self.vectors @ (q / qn) # cosine; both are L2-normalized
return ((sims + 1.0) / 2.0).astype(np.float32), True # [-1,1]->[0,1]
# Lexical fallback.
q_tokens = _tokens(query)
scores = np.zeros((n,), dtype=np.float32)
if not q_tokens:
return scores, False
for i, node in enumerate(self.nodes):
nt = _tokens(node["name"])
if nt:
scores[i] = len(q_tokens & nt) / float(len(q_tokens | nt))
return scores, False
def _multi_source_paths(self, seeds: list, hops: int) -> dict:
"""Multi-source BFS from ``seeds`` up to ``hops``.
Returns ``{node_id: (distance, source_seed_id, [triple_index,...])}``
where the triple list is the chain of edges from the nearest seed to the
node (empty for the seeds themselves).
"""
result: dict = {}
prev: dict = {}
queue: deque = deque()
for s in seeds:
if s not in result:
result[s] = (0, s, [])
queue.append(s)
while queue:
u = queue.popleft()
dist, src, _ = result[u]
if dist >= hops:
continue
for nb_id, tindex in self.adjacency.get(u, []):
if nb_id not in result:
prev[nb_id] = (u, tindex)
result[nb_id] = (dist + 1, src, [])
queue.append(nb_id)
# Reconstruct triple chains via predecessors.
for nid in list(result.keys()):
dist, src, _ = result[nid]
chain = []
cur = nid
while cur in prev:
u, tindex = prev[cur]
chain.append(tindex)
cur = u
chain.reverse()
result[nid] = (dist, src, chain)
return result
def search(
self,
query: str,
*,
api_key=None,
query_vec: Optional[np.ndarray] = None,
embed_model=None,
top_k: int = 6,
hops: int = 2,
max_results: int = 10,
timeout: float = 60,
) -> dict:
"""Hybrid retrieval: semantic seeds + graph traversal -> explainable answers.
1. Score every node's semantic relevance to ``query`` (cosine over
embeddings, else lexical).
2. Take the ``top_k`` strongest as SEED nodes.
3. Traverse up to ``hops`` from the seeds to gather connected entities +
the linking triples.
4. Rank candidates by ``semantic + graph-proximity`` and return each
answer with its supporting path (chain of triples) and page refs.
If ``query_vec`` is given it is used directly; otherwise, when an
``api_key`` is supplied and the graph has vectors, the query is embedded
via Gemini (RETRIEVAL_QUERY). With neither, search degrades to lexical.
"""
query = str(query or "").strip()
out = {
"query": query,
"answers": [],
"seeds": [],
"triples": [],
"mode": "lexical",
}
if not query or not self.nodes:
return out
# Obtain a query vector if we can (don't fail search if embedding fails).
if query_vec is None and self.has_vectors and is_configured(api_key):
try:
mat = embed_texts(
[query],
api_key=api_key,
model=embed_model or self.embed_model,
task_type="RETRIEVAL_QUERY",
timeout=timeout,
)
if mat.size:
query_vec = mat[0]
except RuntimeError:
query_vec = None
sem, used_vectors = self._semantic_scores(query, query_vec)
out["mode"] = "semantic" if used_vectors else "lexical"
if sem.size == 0 or float(sem.max()) <= 0:
return out
# Seeds = strongest semantic matches.
order = np.argsort(-sem)
seeds = [int(i) for i in order[:max(1, top_k)] if sem[int(i)] > 0]
out["seeds"] = [self.nodes[i]["name"] for i in seeds]
reach = self._multi_source_paths(seeds, hops=max(0, hops))
# Rank reachable candidates.
W_SEM, W_GRAPH, DECAY = 0.6, 0.4, 0.55
scored = []
for nid, (dist, src, chain) in reach.items():
sim = float(sem[nid])
graph_term = float(sem[src]) * (DECAY ** dist)
score = W_SEM * sim + W_GRAPH * graph_term
scored.append((score, dist, nid, src, chain))
scored.sort(key=lambda x: (-x[0], x[1]))
used_triples: dict = {}
for score, dist, nid, src, chain in scored[:max(1, max_results)]:
node = self.nodes[nid]
path_triples = [self._triple_view(t) for t in chain]
path_pages = sorted({t["page"] for t in path_triples if t["page"] is not None})
# The entity's own incident facts make even a seed (empty path)
# explainable; cap a few of the highest-information ones.
fact_idx = [tindex for _, tindex in self.adjacency.get(nid, [])][:6]
facts = [self._triple_view(t) for t in fact_idx]
for t in list(chain) + fact_idx:
used_triples[t] = self._triple_view(t)
out["answers"].append(
{
"entity": node["name"],
"type": node["type"],
"score": round(float(score), 4),
"similarity": round(float(sem[nid]), 4),
"hops": int(dist),
"pages": sorted(node["pages"]),
"path": path_triples,
"path_pages": path_pages,
"facts": facts,
}
)
# Flat, de-duplicated list of every supporting triple referenced above.
out["triples"] = list(used_triples.values())
return out
def _triple_view(self, tindex: int) -> dict:
t = self.triples[tindex]
return {
"subject": t["subject"],
"predicate": t["predicate"],
"object": t["object"],
"page": t["page"],
}
# --- serialization (explicit; the graph is NEVER auto-serialized) --------
def to_viz_dict(self, max_nodes: int = 120) -> dict:
"""Compact node/edge lists for the vanilla-canvas visualization.
Caps to the highest-degree ``max_nodes`` so a huge graph stays drawable
(and the dropped count is reported, never silently truncated).
"""
order = sorted(
range(len(self.nodes)), key=lambda i: self.nodes[i]["degree"], reverse=True
)
keep = set(order[:max_nodes])
nodes = [
{
"id": i,
"name": self.nodes[i]["name"],
"type": self.nodes[i]["type"],
"pages": sorted(self.nodes[i]["pages"]),
"degree": self.nodes[i]["degree"],
}
for i in sorted(keep)
]
edges = [
{
"source": t["subject_id"],
"target": t["object_id"],
"predicate": t["predicate"],
"page": t["page"],
}
for t in self.triples
if t["subject_id"] in keep and t["object_id"] in keep
]
return {
"nodes": nodes,
"edges": edges,
"total_nodes": len(self.nodes),
"total_edges": len(self.triples),
"truncated": len(self.nodes) > len(keep),
}
def to_export_dict(self) -> dict:
"""Full graph for the .kg.json export (entities + triples + provenance)."""
return {
"entities": [
{"name": n["name"], "type": n["type"], "pages": sorted(n["pages"])}
for n in self.nodes
],
"triples": [self._triple_view(i) for i in range(len(self.triples))],
"node_count": len(self.nodes),
"triple_count": len(self.triples),
"embed_model": self.embed_model,
}
def build_graph(
pages,
*,
api_key,
triple_model=None,
embed_model=None,
max_pages: Optional[int] = None,
embed: bool = True,
timeout: float = 120,
) -> KnowledgeGraph:
"""Build a per-document :class:`KnowledgeGraph` from extracted page text.
Parameters
----------
pages : list[tuple[int, str]]
``(page_number, text)`` pairs — typically
``[(p.page, p.text) for p in job.pages if p.status == "done"]``.
api_key : str
A Gemini API key (required; this is the opt-in online feature).
triple_model, embed_model : str, optional
Model overrides; default to Flash + a discovered embedding model
(``_pick_embedding_model``, prefers gemini-embedding-001).
max_pages : int, optional
Cap the number of non-empty pages processed (cost control). None = all.
embed : bool
If True (default) also embed the nodes so semantic search works; if
False the graph supports lexical search only (cheaper).
Per-page triple extraction is fanned across a shared bounded pool
(``_EXTRACT_POOL``), so a multi-page build is much faster on a key with real
throughput; the shared pool also caps total concurrent Gemini calls. Each
page is isolated — a single failed page is skipped and counted
(``pages_failed``); only an all-pages failure raises. Embedding failure is
non-fatal (``embed_error`` set, lexical search retained).
Returns a graph (possibly empty if the document yields no facts). Raises a
single-line RuntimeError only for a missing key or a hard Gemini failure.
"""
if not is_configured(api_key):
raise RuntimeError(
"Building a knowledge graph needs a Gemini API key. Enter your free "
"key from https://aistudio.google.com/, or use the demo key."
)
usable = [(int(pn), str(txt or "")) for pn, txt in pages if str(txt or "").strip()]
if max_pages is not None and max_pages > 0:
usable = usable[:max_pages]
graph = KnowledgeGraph()
last_error: Optional[Exception] = None
# Fan the per-page Gemini calls across the shared pool (concurrency), then
# build the graph single-threaded below in PAGE ORDER so node ids stay
# deterministic and the adjacency dicts are never mutated from two threads.
def _extract_one(text):
return extract_triples(text, api_key=api_key, model=triple_model, timeout=timeout)
futures = [(page_no, _EXTRACT_POOL.submit(_extract_one, text)) for page_no, text in usable]
results: dict = {}
for page_no, fut in futures:
# Isolate each page: one rate-limited / safety-blocked page (common on the
# free tier) must NOT discard every other page (and its API spend).
try:
results[page_no] = fut.result()
except RuntimeError as exc:
results[page_no] = exc
last_error = exc
for page_no, _text in usable:
data = results.get(page_no)
if not isinstance(data, dict): # a skipped page (exception) or missing
graph.pages_failed += 1
continue
graph.pages_built += 1
# Register typed entities first so types are known, then triples.
for e in data.get("entities", []):
graph._add_node(e["name"], e.get("type", "OTHER"), page=page_no)
for t in data.get("triples", []):
graph._add_triple(t["subject"], t["predicate"], t["object"], page=page_no)
# If EVERY page failed, this is a systemic failure (bad key / quota / network),
# not "a document with no facts" — surface it instead of returning an empty graph.
if usable and graph.pages_failed == len(usable):
raise last_error or RuntimeError(
"Knowledge-graph extraction failed for every page."
)
if embed and graph.num_nodes:
# Resolve the embedding model the key can actually use (Google retires
# ids over time), so search uses the SAME model the nodes were built with.
# Embeddings are the SEMANTIC half: if they fail (quota / retired model),
# keep the graph for LEXICAL search rather than throwing away the (costly)
# triple extraction. The degradation is surfaced via has_vectors/embed_error.
try:
resolved = _pick_embedding_model(api_key, embed_model, timeout=min(timeout, 30))
node_texts = [graph._node_embed_text(i) for i in range(graph.num_nodes)]
vectors = embed_texts(
node_texts,
api_key=api_key,
model=resolved,
task_type="RETRIEVAL_DOCUMENT",
timeout=timeout,
)
graph.set_vectors(vectors, model=resolved)
except RuntimeError as exc:
graph.embed_error = str(exc)
# If the DISCOVERED model 404'd (Google retired the id mid-process),
# drop its per-key cache entry so the NEXT build re-discovers a
# working model instead of pinning the dead one for the whole process
# lifetime. Only for discovery (no explicit embed_model) and only on a
# model-not-found 404 — a transient 429/quota must NOT evict a good id.
if (
isinstance(exc, GeminiHTTPError)
and exc.code == 404
and not embed_model
):
key_hash = hashlib.sha256(
_clean_key(api_key).encode("utf-8")
).hexdigest()
_EMBED_MODEL_RESOLVED.pop(key_hash, None)
return graph