Archie0099 commited on
Commit
97ff337
·
verified ·
1 Parent(s): 97c3931

Harden knowledge graph build (isolation, status reset, embed fallback)

Browse files
Files changed (3) hide show
  1. app.py +14 -2
  2. pipeline/kg.py +126 -52
  3. 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 _friendly_http_error(last_http_err)
 
 
 
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. Only a SUCCESSFUL
217
- # discovery is cached (so a transient failure that fell back to the default is
218
- # retried next time). Not keyed by the API key the available models are a
219
- # property of the deployment, not a secret, and we never store the key itself.
220
- _EMBED_MODEL_RESOLVED = None
 
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
- if _EMBED_MODEL_RESOLVED:
280
- return _EMBED_MODEL_RESOLVED
 
 
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 an accidental ```json ... ``` fence if the model added one.
 
 
342
  if text.startswith("```"):
343
- text = text.strip("`")
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
- url = _BATCH_EMBED_URL.format(model=model_id)
 
471
 
472
  vectors: list = []
 
473
  for start in range(0, len(items), _EMBED_BATCH):
474
  chunk = items[start:start + _EMBED_BATCH]
475
- body = {
476
- "requests": [
477
- {
478
- "model": wire_model,
479
- "content": {"parts": [{"text": t}]},
480
- "taskType": task_type,
481
- }
482
- for t in chunk
483
- ]
484
- }
485
- payload = _post_json(url, body, api_key, timeout=timeout)
486
- vectors.extend(_parse_embed_payload(payload, len(chunk)))
 
 
 
 
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]) -> np.ndarray:
608
  """Per-node relevance to the query in [0,1].
609
 
610
- Uses cosine similarity when vectors + a query vector are available;
611
- otherwise falls back to lexical token overlap so the feature still works
612
- offline / when embeddings are unavailable.
 
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.size:
618
  q = np.asarray(query_vec, dtype=np.float32).reshape(-1)
619
  qn = np.linalg.norm(q)
620
- if qn > 0:
621
- q = q / qn
622
- if q.shape[0] == self.vectors.shape[1]:
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 (self.has_vectors and query_vec is not None) else "lexical"
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
- data = extract_triples(text, api_key=api_key, model=triple_model, timeout=timeout)
 
 
 
 
 
 
 
 
 
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
- resolved = _pick_embedding_model(api_key, embed_model, timeout=min(timeout, 30))
891
- node_texts = [graph._node_embed_text(i) for i in range(graph.num_nodes)]
892
- vectors = embed_texts(
893
- node_texts,
894
- api_key=api_key,
895
- model=resolved,
896
- task_type="RETRIEVAL_DOCUMENT",
897
- timeout=timeout,
898
- )
899
- graph.set_vectors(vectors, model=resolved)
 
 
 
 
 
 
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
- el.kgBuildNote.textContent =
1187
- data.has_vectors
1188
- ? "Graph ready — ask a question below (semantic + graph search)."
1189
- : "Graph ready — ask a question below (graph search).";
 
 
 
 
 
 
 
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");