Archie0099 commited on
Commit
0992cee
·
verified ·
1 Parent(s): 1f46341

Add knowledge graph search over extracted text

Browse files
Files changed (6) hide show
  1. app.py +180 -0
  2. pipeline/jobs.py +10 -0
  3. pipeline/kg.py +802 -0
  4. static/app.js +423 -0
  5. static/index.html +36 -0
  6. static/styles.css +60 -0
app.py CHANGED
@@ -106,6 +106,10 @@ async def capabilities():
106
  # True when this deployment provides a shared Gemini key (a Space secret),
107
  # so the front-end can offer online OCR without the visitor's own key.
108
  "online_demo": bool(os.environ.get("DEMO_GEMINI_KEY", "").strip()),
 
 
 
 
109
  }
110
 
111
 
@@ -348,3 +352,179 @@ async def page_image(job_id: str, n: int, dpi: int = 150):
348
  # 500 — the frontend <img> onerror handles a non-image response.
349
  raise HTTPException(status_code=404, detail="could not render page")
350
  return StreamingResponse(io.BytesIO(png), media_type="image/png")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  # True when this deployment provides a shared Gemini key (a Space secret),
107
  # so the front-end can offer online OCR without the visitor's own key.
108
  "online_demo": bool(os.environ.get("DEMO_GEMINI_KEY", "").strip()),
109
+ # Knowledge-graph search is always supported by the backend (stdlib +
110
+ # numpy, no torch/networkx). It needs a Gemini key at build time — the
111
+ # user's own key or, where present, the demo key (online_demo above).
112
+ "knowledge_graph": True,
113
  }
114
 
115
 
 
352
  # 500 — the frontend <img> onerror handles a non-image response.
353
  raise HTTPException(status_code=404, detail="could not render page")
354
  return StreamingResponse(io.BytesIO(png), media_type="image/png")
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Knowledge graph (opt-in, online) — build a per-document graph from the
359
+ # already-extracted text, then answer queries with hybrid semantic + graph-
360
+ # traversal retrieval. These endpoints touch NO fitz/PaddleOCR, so they run on
361
+ # the DEFAULT thread pool (run_in_executor(None, ...)) — putting them on the
362
+ # single OCR worker would needlessly serialize them behind live extraction.
363
+ # ---------------------------------------------------------------------------
364
+ def _resolve_kg_key(explicit: str, job) -> str:
365
+ """Pick the Gemini key for KG: explicit (browser) -> the job's own key -> demo."""
366
+ return (
367
+ (explicit or "").strip()
368
+ or (getattr(job, "online_key", "") or "").strip()
369
+ or os.environ.get("DEMO_GEMINI_KEY", "").strip()
370
+ )
371
+
372
+
373
+ @app.post("/api/graph/build")
374
+ async def graph_build(
375
+ job_id: str = Form(...),
376
+ online_key: str = Form(""),
377
+ online_model: str = Form(""),
378
+ embed: str = Form("true"),
379
+ max_pages: str = Form(""),
380
+ ):
381
+ """Build a knowledge graph from a finished job's extracted text."""
382
+ job = manager.get(job_id)
383
+ if job is None:
384
+ raise HTTPException(status_code=404, detail="job not found")
385
+ if job.status not in ("done", "error"):
386
+ raise HTTPException(
387
+ status_code=409,
388
+ detail="Extraction is still running; wait for it to finish first.",
389
+ )
390
+ if job.kg_status == "building":
391
+ raise HTTPException(
392
+ status_code=409,
393
+ detail="A knowledge graph is already being built for this document.",
394
+ )
395
+
396
+ okey = _resolve_kg_key(online_key, job)
397
+ if not okey:
398
+ raise HTTPException(
399
+ status_code=400,
400
+ detail=(
401
+ "Building a knowledge graph needs a Gemini API key. Enter your "
402
+ "free key from aistudio.google.com (the same key online OCR uses)."
403
+ ),
404
+ )
405
+
406
+ pages = [
407
+ (p.page, p.text)
408
+ for p in job.pages
409
+ if p.status == "done" and (p.text or "").strip()
410
+ ]
411
+ if not pages:
412
+ raise HTTPException(
413
+ status_code=400, detail="No extracted text to build a graph from."
414
+ )
415
+
416
+ mp = None
417
+ if (max_pages or "").strip():
418
+ try:
419
+ mp = max(0, int(max_pages))
420
+ except ValueError:
421
+ mp = None
422
+ do_embed = _parse_bool(embed)
423
+
424
+ from pipeline import kg as kgmod
425
+
426
+ job.kg_status = "building"
427
+ job.kg_error = None
428
+ loop = asyncio.get_running_loop()
429
+ try:
430
+ graph = await loop.run_in_executor(
431
+ None,
432
+ lambda: kgmod.build_graph(
433
+ pages,
434
+ api_key=okey,
435
+ triple_model=(online_model or None),
436
+ max_pages=mp,
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"
443
+ job.kg_error = str(exc)
444
+ raise HTTPException(status_code=400, detail=str(exc))
445
+ except Exception:
446
+ job.kg_status = "error"
447
+ job.kg_error = "Knowledge-graph build failed unexpectedly."
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
+
463
+ @app.post("/api/graph/query")
464
+ async def graph_query(
465
+ job_id: str = Form(...),
466
+ query: str = Form(...),
467
+ online_key: str = Form(""),
468
+ top_k: int = Form(6),
469
+ hops: int = Form(2),
470
+ ):
471
+ """Answer a query against a built graph: ranked entities + supporting paths."""
472
+ job = manager.get(job_id)
473
+ if job is None:
474
+ raise HTTPException(status_code=404, detail="job not found")
475
+ graph = job.knowledge_graph
476
+ if graph is None:
477
+ raise HTTPException(
478
+ status_code=409, detail="No knowledge graph has been built for this document yet."
479
+ )
480
+ q = (query or "").strip()
481
+ if not q:
482
+ raise HTTPException(status_code=400, detail="Enter a question to search the graph.")
483
+
484
+ okey = _resolve_kg_key(online_key, job) # optional: enables semantic search
485
+ loop = asyncio.get_running_loop()
486
+ try:
487
+ result = await loop.run_in_executor(
488
+ None,
489
+ lambda: graph.search(
490
+ q,
491
+ api_key=(okey or None),
492
+ top_k=max(1, min(20, int(top_k))),
493
+ hops=max(0, min(3, int(hops))),
494
+ ),
495
+ )
496
+ except RuntimeError as exc:
497
+ raise HTTPException(status_code=400, detail=str(exc))
498
+ except Exception:
499
+ raise HTTPException(status_code=502, detail="Graph query failed unexpectedly.")
500
+ return result
501
+
502
+
503
+ @app.get("/api/graph/{job_id}")
504
+ async def graph_get(job_id: str, full: bool = False):
505
+ """Return the built graph.
506
+
507
+ Default: compact node/edge lists for the canvas visualization (capped).
508
+ ``?full=1``: the complete entities + triples + provenance for .json export.
509
+ """
510
+ job = manager.get(job_id)
511
+ if job is None:
512
+ raise HTTPException(status_code=404, detail="job not found")
513
+ graph = job.knowledge_graph
514
+ if graph is None:
515
+ return {
516
+ "status": job.kg_status,
517
+ "error": job.kg_error,
518
+ "nodes": [],
519
+ "edges": [],
520
+ "total_nodes": 0,
521
+ "total_edges": 0,
522
+ "truncated": False,
523
+ }
524
+ if full:
525
+ data = graph.to_export_dict()
526
+ data["status"] = "ready"
527
+ return data
528
+ data = graph.to_viz_dict()
529
+ data["status"] = "ready"
530
+ return data
pipeline/jobs.py CHANGED
@@ -85,6 +85,16 @@ class Job:
85
  processed_pages: int = 0
86
  error: Optional[str] = None
87
 
 
 
 
 
 
 
 
 
 
 
88
  # Internals (not serialized). One asyncio.Queue per live SSE subscriber so
89
  # progress is broadcast (fan-out), not consumed once — reconnects are safe.
90
  subscribers: list = field(default_factory=list, repr=False)
 
85
  processed_pages: int = 0
86
  error: Optional[str] = None
87
 
88
+ # Optional knowledge graph (opt-in, online). Built on demand from this job's
89
+ # extracted pages and attached here, so it is reclaimed with the job by the
90
+ # TTL/LRU eviction below and — because to_dict() is an explicit allow-list —
91
+ # is NEVER serialized to the client or broadcast over SSE (same protection
92
+ # the API key gets). Held loosely as ``object`` to keep jobs.py free of the
93
+ # kg / numpy import at module load.
94
+ knowledge_graph: object = field(default=None, repr=False)
95
+ kg_status: str = field(default="none", repr=False) # none|building|ready|error
96
+ kg_error: Optional[str] = field(default=None, repr=False)
97
+
98
  # Internals (not serialized). One asyncio.Queue per live SSE subscriber so
99
  # progress is broadcast (fan-out), not consumed once — reconnects are safe.
100
  subscribers: list = field(default_factory=list, repr=False)
pipeline/kg.py ADDED
@@ -0,0 +1,802 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional knowledge-graph + embedding search over EXTRACTED text (Gemini).
2
+
3
+ After a PDF is extracted, this module turns its per-page text into a small
4
+ **knowledge graph** — typed entities (nodes) joined by ``(subject, predicate,
5
+ object)`` triples (edges) — embeds every node into a vector space, and answers a
6
+ query by combining **semantic vector search** (conceptually-related entities)
7
+ with **explicit graph traversal** (following the facts). The answer is
8
+ *explainable*: every result carries the supporting triples and the page each
9
+ came from.
10
+
11
+ DESIGN / IDENTITY
12
+ - This is an OPT-IN, online feature. Like ``online_ocr.py`` it sends text to
13
+ Google's Gemini API, so it is OFF by default and only runs when the caller
14
+ passes a Gemini API key. The page text leaves this machine.
15
+ - ZERO new pip dependencies: the REST calls use the Python standard library
16
+ only (``urllib.request`` + ``json``); the graph is a plain in-memory
17
+ adjacency structure; ``numpy`` (already a core dep) holds the node vectors.
18
+ This keeps the feature working on the lean hosted build too (no torch /
19
+ sentence-transformers / networkx required).
20
+ - The security-critical key sanitization and the HTTP error mapping are REUSED
21
+ from ``online_ocr`` so the "never echo the key, only raise a single-line
22
+ RuntimeError" contract holds here as well.
23
+
24
+ Importing this module performs NO network call and has no import-time side
25
+ effects.
26
+
27
+ Public API
28
+ ----------
29
+ ``extract_triples(text, *, api_key, model=None, timeout=...) -> dict``
30
+ Pull ``{"entities": [...], "triples": [...]}`` out of one page of text.
31
+ ``embed_texts(texts, *, api_key, model=None, task_type=..., timeout=...) -> np.ndarray``
32
+ Embed a list of strings to an ``(n, dim)`` float32 matrix.
33
+ ``build_graph(pages, *, api_key, ...) -> KnowledgeGraph``
34
+ Build a per-document graph from ``[(page_number, text), ...]``.
35
+ ``KnowledgeGraph``
36
+ Holds the graph + node vectors; ``.search(query, ...)`` does hybrid
37
+ retrieval and returns ranked, explainable results.
38
+ """
39
+
40
+ import json
41
+ import re
42
+ import time
43
+ import urllib.error
44
+ import urllib.request
45
+ from collections import deque
46
+ from dataclasses import dataclass, field
47
+ from typing import Optional
48
+
49
+ import numpy as np
50
+
51
+ # Reuse the verified, security-tested helpers from the OCR client so the key is
52
+ # sanitized identically and HTTP errors never leak it.
53
+ from pipeline.online_ocr import (
54
+ _API_BASE,
55
+ _API_KEY_HEADER,
56
+ _clean_key,
57
+ _friendly_http_error,
58
+ is_configured,
59
+ )
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Gemini REST endpoints (same base/host as online_ocr; different methods).
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.
69
+ DEFAULT_TRIPLE_MODEL = "gemini-2.5-flash"
70
+ # Embeddings: text-embedding-004 is the stable, free, 768-dim model. The wire
71
+ # format wants the "models/" prefix in the per-request "model" field.
72
+ DEFAULT_EMBED_MODEL = "text-embedding-004"
73
+
74
+ # Gemini caps batchEmbedContents at 100 requests per call; chunk to stay under.
75
+ _EMBED_BATCH = 100
76
+
77
+ # Defensive cap on per-page text sent for triple extraction. A single PDF page
78
+ # is tiny next to Flash's context window, but a pathological page (e.g. a giant
79
+ # embedded text dump) shouldn't balloon the request.
80
+ _MAX_PAGE_CHARS = 100_000
81
+
82
+ # Transient-failure backoff. Free-tier embedding/generate limits are real, so a
83
+ # 429/503 is retried a few times with exponential backoff before giving up.
84
+ _RETRY_STATUSES = frozenset({429, 503})
85
+ _RETRY_ATTEMPTS = 4
86
+ _RETRY_BASE_DELAY = 1.5 # seconds; doubled each attempt
87
+
88
+ # Instruction for triple extraction. We ask for typed entities AND triples and
89
+ # force a JSON response via responseSchema for deterministic parsing.
90
+ _TRIPLE_PROMPT = (
91
+ "You are building a knowledge graph from a document. From the TEXT below, "
92
+ "extract the key entities and the factual relationships between them.\n"
93
+ "- entities: the important named things (people, organizations, places, "
94
+ "dates, IDs, amounts, concepts). Give each a short canonical name and a "
95
+ "TYPE (e.g. PERSON, ORG, PLACE, DATE, ID, AMOUNT, CONCEPT, OTHER).\n"
96
+ "- triples: factual (subject, predicate, object) statements stated or "
97
+ "clearly implied by the text. Use concise predicates (e.g. 'works at', "
98
+ "'located in', 'has id', 'dated'). Subject and object should be entity "
99
+ "names. Do NOT invent facts that are not supported by the text.\n"
100
+ "Return only what the text supports; if the text is empty or has no "
101
+ "extractable facts, return empty lists.\n\nTEXT:\n"
102
+ )
103
+
104
+ # Response schema for generateContent — keeps output a strict JSON object.
105
+ _TRIPLE_SCHEMA = {
106
+ "type": "OBJECT",
107
+ "properties": {
108
+ "entities": {
109
+ "type": "ARRAY",
110
+ "items": {
111
+ "type": "OBJECT",
112
+ "properties": {
113
+ "name": {"type": "STRING"},
114
+ "type": {"type": "STRING"},
115
+ },
116
+ "required": ["name"],
117
+ },
118
+ },
119
+ "triples": {
120
+ "type": "ARRAY",
121
+ "items": {
122
+ "type": "OBJECT",
123
+ "properties": {
124
+ "subject": {"type": "STRING"},
125
+ "predicate": {"type": "STRING"},
126
+ "object": {"type": "STRING"},
127
+ },
128
+ "required": ["subject", "predicate", "object"],
129
+ },
130
+ },
131
+ },
132
+ "required": ["triples"],
133
+ }
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Low-level HTTP (stdlib) — mirrors online_ocr's request/error handling.
138
+ # ---------------------------------------------------------------------------
139
+ def _post_json(url: str, body: dict, api_key: str, *, timeout: float) -> dict:
140
+ """POST a JSON body to Gemini and return the parsed JSON response.
141
+
142
+ ``api_key`` must already be sanitized via ``_clean_key``. Retries 429/503
143
+ with exponential backoff, then maps any HTTP/network/non-JSON failure to a
144
+ single-line RuntimeError (never echoing the key — ``_friendly_http_error``
145
+ reads only the API's own error body).
146
+ """
147
+ data = json.dumps(body).encode("utf-8")
148
+ request = urllib.request.Request(
149
+ url,
150
+ data=data,
151
+ method="POST",
152
+ headers={
153
+ "Content-Type": "application/json",
154
+ _API_KEY_HEADER: api_key,
155
+ },
156
+ )
157
+
158
+ last_http_err: Optional[urllib.error.HTTPError] = None
159
+ for attempt in range(_RETRY_ATTEMPTS):
160
+ try:
161
+ with urllib.request.urlopen(request, timeout=timeout) as response:
162
+ raw = response.read()
163
+ break
164
+ except urllib.error.HTTPError as err:
165
+ last_http_err = err
166
+ if err.code in _RETRY_STATUSES and attempt < _RETRY_ATTEMPTS - 1:
167
+ time.sleep(_RETRY_BASE_DELAY * (2 ** attempt))
168
+ continue
169
+ raise _friendly_http_error(err) from None
170
+ except urllib.error.URLError as err:
171
+ raise RuntimeError(
172
+ "Could not reach the Gemini API ({}). Check your internet "
173
+ "connection.".format(err.reason)
174
+ ) from None
175
+ except TimeoutError:
176
+ raise RuntimeError(
177
+ "The Gemini request timed out after {}s.".format(timeout)
178
+ ) from None
179
+ else: # pragma: no cover - loop always breaks or raises
180
+ raise _friendly_http_error(last_http_err)
181
+
182
+ try:
183
+ payload = json.loads(raw.decode("utf-8", "replace"))
184
+ except (ValueError, AttributeError) as err:
185
+ raise RuntimeError(
186
+ "Gemini returned a response that was not valid JSON ({}). The "
187
+ "service may be having problems; retry shortly.".format(err)
188
+ ) from None
189
+ if not isinstance(payload, dict):
190
+ raise RuntimeError(
191
+ "Gemini returned an unexpected response (not a JSON object); "
192
+ "retry shortly."
193
+ )
194
+ return payload
195
+
196
+
197
+ def _resolve_model(model, default: str) -> str:
198
+ """Return a clean bare model id (no ``models/`` prefix), defaulting when unset."""
199
+ name = (str(model).strip() if model else "") or default
200
+ if name.startswith("models/"):
201
+ name = name[len("models/"):]
202
+ return name or default
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Response parsers (pure functions — unit-tested with canned dicts, no network).
207
+ # ---------------------------------------------------------------------------
208
+ def _response_text(payload: dict) -> str:
209
+ """Concatenate the text parts of a generateContent response.
210
+
211
+ Handles a prompt-level block or a non-STOP finishReason by raising an
212
+ actionable RuntimeError, and tolerates an explicit ``{"text": null}`` part.
213
+ """
214
+ feedback = payload.get("promptFeedback") or {}
215
+ block_reason = feedback.get("blockReason")
216
+ candidates = payload.get("candidates") or []
217
+ if not candidates:
218
+ if block_reason:
219
+ raise RuntimeError(
220
+ "Gemini blocked the request (blockReason={}). The text tripped "
221
+ "a safety filter; knowledge-graph extraction is unavailable for "
222
+ "this page.".format(block_reason)
223
+ )
224
+ raise RuntimeError(
225
+ "Gemini returned no candidates for knowledge-graph extraction."
226
+ )
227
+ candidate = candidates[0] or {}
228
+ content = candidate.get("content") or {}
229
+ parts = content.get("parts") or []
230
+ return "".join(
231
+ str(part.get("text") or "") for part in parts if isinstance(part, dict)
232
+ )
233
+
234
+
235
+ def _parse_triple_payload(payload: dict) -> dict:
236
+ """Parse a generateContent response into ``{"entities": [...], "triples": [...]}``.
237
+
238
+ The model is asked to return a JSON object (responseMimeType=application/json),
239
+ so the candidate text is itself a JSON string. We parse it and coerce every
240
+ field to clean strings, dropping malformed/empty rows. Never raises on a
241
+ merely-empty or slightly-malformed result — returns empty lists instead, so
242
+ one odd page can't fail the whole build.
243
+ """
244
+ text = _response_text(payload).strip()
245
+ if not text:
246
+ return {"entities": [], "triples": []}
247
+ # Strip an accidental ```json ... ``` fence if the model added one.
248
+ if text.startswith("```"):
249
+ text = text.strip("`")
250
+ if text[:4].lower() == "json":
251
+ text = text[4:]
252
+ text = text.strip()
253
+ try:
254
+ obj = json.loads(text)
255
+ except (ValueError, TypeError):
256
+ return {"entities": [], "triples": []}
257
+ if not isinstance(obj, dict):
258
+ # Some models return a bare array of triples.
259
+ obj = {"triples": obj} if isinstance(obj, list) else {}
260
+
261
+ entities = []
262
+ for e in obj.get("entities") or []:
263
+ if not isinstance(e, dict):
264
+ continue
265
+ name = str(e.get("name") or "").strip()
266
+ if not name:
267
+ continue
268
+ etype = str(e.get("type") or "").strip().upper() or "OTHER"
269
+ entities.append({"name": name, "type": etype})
270
+
271
+ triples = []
272
+ for t in obj.get("triples") or []:
273
+ if not isinstance(t, dict):
274
+ continue
275
+ subj = str(t.get("subject") or "").strip()
276
+ pred = str(t.get("predicate") or "").strip()
277
+ obj_ = str(t.get("object") or "").strip()
278
+ if subj and pred and obj_:
279
+ triples.append({"subject": subj, "predicate": pred, "object": obj_})
280
+ return {"entities": entities, "triples": triples}
281
+
282
+
283
+ def _parse_embed_payload(payload: dict, expected: int) -> list:
284
+ """Parse a batchEmbedContents response into a list of float vectors.
285
+
286
+ Response shape: ``{"embeddings": [{"values": [...]}, ...]}`` in request
287
+ order. Raises if the count doesn't match what we sent (a silent mismatch
288
+ would misalign vectors with nodes).
289
+ """
290
+ embeddings = payload.get("embeddings")
291
+ if not isinstance(embeddings, list):
292
+ raise RuntimeError(
293
+ "Gemini embedding response had no 'embeddings' list; cannot build "
294
+ "the vector index."
295
+ )
296
+ if len(embeddings) != expected:
297
+ raise RuntimeError(
298
+ "Gemini returned {} embeddings for {} inputs (count mismatch).".format(
299
+ len(embeddings), expected
300
+ )
301
+ )
302
+ out = []
303
+ for item in embeddings:
304
+ values = (item or {}).get("values") if isinstance(item, dict) else None
305
+ if not isinstance(values, list) or not values:
306
+ raise RuntimeError("Gemini returned an empty embedding vector.")
307
+ out.append([float(v) for v in values])
308
+ return out
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Public extraction / embedding calls.
313
+ # ---------------------------------------------------------------------------
314
+ def extract_triples(text: str, *, api_key, model=None, timeout: float = 120) -> dict:
315
+ """Extract typed entities + ``(subject, predicate, object)`` triples from text.
316
+
317
+ Returns ``{"entities": [{"name","type"}], "triples":
318
+ [{"subject","predicate","object"}]}``. Provenance (the source page) is NOT
319
+ returned by the model — the caller attaches it.
320
+ """
321
+ if not is_configured(api_key):
322
+ raise RuntimeError(
323
+ "Knowledge-graph extraction needs a Gemini API key but none was "
324
+ "provided. Get a free key from https://aistudio.google.com/."
325
+ )
326
+ clean = str(text or "").strip()
327
+ if not clean:
328
+ return {"entities": [], "triples": []}
329
+ if len(clean) > _MAX_PAGE_CHARS:
330
+ clean = clean[:_MAX_PAGE_CHARS]
331
+
332
+ api_key = _clean_key(api_key)
333
+ model_id = _resolve_model(model, DEFAULT_TRIPLE_MODEL)
334
+ body = {
335
+ "contents": [{"parts": [{"text": _TRIPLE_PROMPT + clean}]}],
336
+ "generationConfig": {
337
+ "temperature": 0,
338
+ "responseMimeType": "application/json",
339
+ "responseSchema": _TRIPLE_SCHEMA,
340
+ },
341
+ }
342
+ payload = _post_json(
343
+ _GENERATE_URL.format(model=model_id), body, api_key, timeout=timeout
344
+ )
345
+ return _parse_triple_payload(payload)
346
+
347
+
348
+ def embed_texts(
349
+ texts,
350
+ *,
351
+ api_key,
352
+ model=None,
353
+ task_type: str = "RETRIEVAL_DOCUMENT",
354
+ timeout: float = 120,
355
+ ) -> np.ndarray:
356
+ """Embed a list of strings into an ``(n, dim)`` float32 matrix via Gemini.
357
+
358
+ ``task_type`` should be ``RETRIEVAL_DOCUMENT`` for the graph's nodes and
359
+ ``RETRIEVAL_QUERY`` for the search query — text-embedding-004 uses it to
360
+ tune the vectors for retrieval, a real accuracy win. Batches at 100 inputs
361
+ per request (Gemini's cap) and concatenates in order.
362
+ """
363
+ if not is_configured(api_key):
364
+ raise RuntimeError(
365
+ "Knowledge-graph search needs a Gemini API key but none was provided."
366
+ )
367
+ items = [str(t or "") for t in texts]
368
+ if not items:
369
+ return np.zeros((0, 0), dtype=np.float32)
370
+
371
+ api_key = _clean_key(api_key)
372
+ model_id = _resolve_model(model, DEFAULT_EMBED_MODEL)
373
+ wire_model = "models/" + model_id
374
+ url = _BATCH_EMBED_URL.format(model=model_id)
375
+
376
+ vectors: list = []
377
+ for start in range(0, len(items), _EMBED_BATCH):
378
+ chunk = items[start:start + _EMBED_BATCH]
379
+ body = {
380
+ "requests": [
381
+ {
382
+ "model": wire_model,
383
+ "content": {"parts": [{"text": t}]},
384
+ "taskType": task_type,
385
+ }
386
+ for t in chunk
387
+ ]
388
+ }
389
+ payload = _post_json(url, body, api_key, timeout=timeout)
390
+ vectors.extend(_parse_embed_payload(payload, len(chunk)))
391
+
392
+ return np.asarray(vectors, dtype=np.float32)
393
+
394
+
395
+ # ---------------------------------------------------------------------------
396
+ # Graph data structures + builder.
397
+ # ---------------------------------------------------------------------------
398
+ def _norm_key(name: str) -> str:
399
+ """Canonical key for entity de-duplication (case/space-insensitive)."""
400
+ return re.sub(r"\s+", " ", str(name or "").strip()).casefold()
401
+
402
+
403
+ _WORD_RE = re.compile(r"[^\W\d_]+|\d+", re.UNICODE)
404
+
405
+
406
+ def _tokens(text: str) -> set:
407
+ return {t for t in _WORD_RE.findall(str(text or "").casefold()) if len(t) > 1}
408
+
409
+
410
+ @dataclass
411
+ class KnowledgeGraph:
412
+ """A per-document knowledge graph + node embeddings, held in RAM.
413
+
414
+ Nodes are entities; edges are ``(subject, predicate, object)`` triples, each
415
+ carrying the page it came from (provenance). Node vectors live in a parallel
416
+ float32 matrix (L2-normalized for cosine). Everything is plain Python +
417
+ numpy — no networkx, no external store — so it evicts with its ``Job`` and
418
+ serializes only via the explicit ``to_*`` methods (never leaked by accident).
419
+ """
420
+
421
+ # Parallel arrays indexed by node id (0..n-1).
422
+ nodes: list = field(default_factory=list) # [{name, type, pages, degree}]
423
+ triples: list = field(default_factory=list) # [{subject_id, predicate, object_id, subject, object, page}]
424
+ adjacency: dict = field(default_factory=dict) # node_id -> [(neighbor_id, triple_index)]
425
+ _index: dict = field(default_factory=dict, repr=False) # norm_key -> node_id
426
+ vectors: Optional[np.ndarray] = field(default=None, repr=False) # (n, dim) L2-normalized
427
+ embed_model: Optional[str] = None
428
+
429
+ # --- construction helpers ------------------------------------------------
430
+ def _add_node(self, name: str, etype: str = "OTHER", page: Optional[int] = None) -> int:
431
+ key = _norm_key(name)
432
+ if not key:
433
+ return -1
434
+ nid = self._index.get(key)
435
+ if nid is None:
436
+ nid = len(self.nodes)
437
+ self._index[key] = nid
438
+ self.nodes.append(
439
+ {"name": str(name).strip(), "type": (etype or "OTHER"), "pages": [], "degree": 0}
440
+ )
441
+ self.adjacency[nid] = []
442
+ node = self.nodes[nid]
443
+ # Prefer a known type over OTHER if a later mention supplies one.
444
+ if (not node["type"] or node["type"] == "OTHER") and etype and etype != "OTHER":
445
+ node["type"] = etype
446
+ if page is not None and page not in node["pages"]:
447
+ node["pages"].append(page)
448
+ return nid
449
+
450
+ def _add_triple(self, subject: str, predicate: str, obj: str, page: Optional[int]) -> None:
451
+ s_id = self._add_node(subject, page=page)
452
+ o_id = self._add_node(obj, page=page)
453
+ if s_id < 0 or o_id < 0 or s_id == o_id:
454
+ return
455
+ tindex = len(self.triples)
456
+ self.triples.append(
457
+ {
458
+ "subject_id": s_id,
459
+ "object_id": o_id,
460
+ "predicate": str(predicate).strip(),
461
+ "subject": self.nodes[s_id]["name"],
462
+ "object": self.nodes[o_id]["name"],
463
+ "page": page,
464
+ }
465
+ )
466
+ # Undirected adjacency for traversal; direction is kept in the triple.
467
+ self.adjacency[s_id].append((o_id, tindex))
468
+ self.adjacency[o_id].append((s_id, tindex))
469
+ self.nodes[s_id]["degree"] += 1
470
+ self.nodes[o_id]["degree"] += 1
471
+
472
+ def _node_embed_text(self, nid: int) -> str:
473
+ """Text used to embed a node: its name/type + a little fact context."""
474
+ node = self.nodes[nid]
475
+ bits = ["{} ({})".format(node["name"], node["type"])]
476
+ # Up to a few connected facts give the embedding semantic context.
477
+ ctx = [
478
+ "{} {} {}".format(t["subject"], t["predicate"], t["object"])
479
+ for _, tindex in self.adjacency[nid][:3]
480
+ for t in (self.triples[tindex],)
481
+ ]
482
+ if ctx:
483
+ bits.append(". ".join(ctx))
484
+ return ". ".join(bits)
485
+
486
+ def set_vectors(self, vectors: Optional[np.ndarray], model: Optional[str] = None) -> None:
487
+ """Attach (and L2-normalize) the node-vector matrix."""
488
+ if vectors is None or getattr(vectors, "size", 0) == 0:
489
+ self.vectors = None
490
+ return
491
+ v = np.asarray(vectors, dtype=np.float32)
492
+ norms = np.linalg.norm(v, axis=1, keepdims=True)
493
+ norms[norms == 0] = 1.0
494
+ self.vectors = v / norms
495
+ self.embed_model = model
496
+
497
+ # --- public properties ---------------------------------------------------
498
+ @property
499
+ def num_nodes(self) -> int:
500
+ return len(self.nodes)
501
+
502
+ @property
503
+ def num_edges(self) -> int:
504
+ return len(self.triples)
505
+
506
+ @property
507
+ def has_vectors(self) -> bool:
508
+ return self.vectors is not None and self.vectors.shape[0] == len(self.nodes)
509
+
510
+ # --- retrieval -----------------------------------------------------------
511
+ def _semantic_scores(self, query: str, query_vec: Optional[np.ndarray]) -> np.ndarray:
512
+ """Per-node relevance to the query in [0,1].
513
+
514
+ Uses cosine similarity when vectors + a query vector are available;
515
+ otherwise falls back to lexical token overlap so the feature still works
516
+ offline / when embeddings are unavailable.
517
+ """
518
+ n = len(self.nodes)
519
+ if n == 0:
520
+ return np.zeros((0,), dtype=np.float32)
521
+ if self.has_vectors and query_vec is not None and query_vec.size:
522
+ q = np.asarray(query_vec, dtype=np.float32).reshape(-1)
523
+ qn = np.linalg.norm(q)
524
+ if qn > 0:
525
+ q = q / qn
526
+ if q.shape[0] == self.vectors.shape[1]:
527
+ sims = self.vectors @ q # cosine; both are L2-normalized
528
+ return ((sims + 1.0) / 2.0).astype(np.float32) # map [-1,1]->[0,1]
529
+ # Lexical fallback.
530
+ q_tokens = _tokens(query)
531
+ scores = np.zeros((n,), dtype=np.float32)
532
+ if not q_tokens:
533
+ return scores
534
+ for i, node in enumerate(self.nodes):
535
+ nt = _tokens(node["name"])
536
+ if nt:
537
+ scores[i] = len(q_tokens & nt) / float(len(q_tokens | nt))
538
+ return scores
539
+
540
+ def _multi_source_paths(self, seeds: list, hops: int) -> dict:
541
+ """Multi-source BFS from ``seeds`` up to ``hops``.
542
+
543
+ Returns ``{node_id: (distance, source_seed_id, [triple_index,...])}``
544
+ where the triple list is the chain of edges from the nearest seed to the
545
+ node (empty for the seeds themselves).
546
+ """
547
+ result: dict = {}
548
+ prev: dict = {}
549
+ queue: deque = deque()
550
+ for s in seeds:
551
+ if s not in result:
552
+ result[s] = (0, s, [])
553
+ queue.append(s)
554
+ while queue:
555
+ u = queue.popleft()
556
+ dist, src, _ = result[u]
557
+ if dist >= hops:
558
+ continue
559
+ for nb_id, tindex in self.adjacency.get(u, []):
560
+ if nb_id not in result:
561
+ prev[nb_id] = (u, tindex)
562
+ result[nb_id] = (dist + 1, src, [])
563
+ queue.append(nb_id)
564
+ # Reconstruct triple chains via predecessors.
565
+ for nid in list(result.keys()):
566
+ dist, src, _ = result[nid]
567
+ chain = []
568
+ cur = nid
569
+ while cur in prev:
570
+ u, tindex = prev[cur]
571
+ chain.append(tindex)
572
+ cur = u
573
+ chain.reverse()
574
+ result[nid] = (dist, src, chain)
575
+ return result
576
+
577
+ def search(
578
+ self,
579
+ query: str,
580
+ *,
581
+ api_key=None,
582
+ query_vec: Optional[np.ndarray] = None,
583
+ embed_model=None,
584
+ top_k: int = 6,
585
+ hops: int = 2,
586
+ max_results: int = 10,
587
+ timeout: float = 60,
588
+ ) -> dict:
589
+ """Hybrid retrieval: semantic seeds + graph traversal -> explainable answers.
590
+
591
+ 1. Score every node's semantic relevance to ``query`` (cosine over
592
+ embeddings, else lexical).
593
+ 2. Take the ``top_k`` strongest as SEED nodes.
594
+ 3. Traverse up to ``hops`` from the seeds to gather connected entities +
595
+ the linking triples.
596
+ 4. Rank candidates by ``semantic + graph-proximity`` and return each
597
+ answer with its supporting path (chain of triples) and page refs.
598
+
599
+ If ``query_vec`` is given it is used directly; otherwise, when an
600
+ ``api_key`` is supplied and the graph has vectors, the query is embedded
601
+ via Gemini (RETRIEVAL_QUERY). With neither, search degrades to lexical.
602
+ """
603
+ query = str(query or "").strip()
604
+ out = {
605
+ "query": query,
606
+ "answers": [],
607
+ "seeds": [],
608
+ "triples": [],
609
+ "mode": "lexical",
610
+ }
611
+ if not query or not self.nodes:
612
+ return out
613
+
614
+ # Obtain a query vector if we can (don't fail search if embedding fails).
615
+ if query_vec is None and self.has_vectors and is_configured(api_key):
616
+ try:
617
+ mat = embed_texts(
618
+ [query],
619
+ api_key=api_key,
620
+ model=embed_model or self.embed_model,
621
+ task_type="RETRIEVAL_QUERY",
622
+ timeout=timeout,
623
+ )
624
+ if mat.size:
625
+ query_vec = mat[0]
626
+ except RuntimeError:
627
+ query_vec = None
628
+
629
+ sem = self._semantic_scores(query, query_vec)
630
+ out["mode"] = "semantic" if (self.has_vectors and query_vec is not None) else "lexical"
631
+ if sem.size == 0 or float(sem.max()) <= 0:
632
+ return out
633
+
634
+ # Seeds = strongest semantic matches.
635
+ order = np.argsort(-sem)
636
+ seeds = [int(i) for i in order[:max(1, top_k)] if sem[int(i)] > 0]
637
+ out["seeds"] = [self.nodes[i]["name"] for i in seeds]
638
+
639
+ reach = self._multi_source_paths(seeds, hops=max(0, hops))
640
+
641
+ # Rank reachable candidates.
642
+ W_SEM, W_GRAPH, DECAY = 0.6, 0.4, 0.55
643
+ scored = []
644
+ for nid, (dist, src, chain) in reach.items():
645
+ sim = float(sem[nid])
646
+ graph_term = float(sem[src]) * (DECAY ** dist)
647
+ score = W_SEM * sim + W_GRAPH * graph_term
648
+ scored.append((score, dist, nid, src, chain))
649
+ scored.sort(key=lambda x: (-x[0], x[1]))
650
+
651
+ used_triples: dict = {}
652
+ for score, dist, nid, src, chain in scored[:max(1, max_results)]:
653
+ node = self.nodes[nid]
654
+ path_triples = [self._triple_view(t) for t in chain]
655
+ path_pages = sorted({t["page"] for t in path_triples if t["page"] is not None})
656
+ # The entity's own incident facts make even a seed (empty path)
657
+ # explainable; cap a few of the highest-information ones.
658
+ fact_idx = [tindex for _, tindex in self.adjacency.get(nid, [])][:6]
659
+ facts = [self._triple_view(t) for t in fact_idx]
660
+ for t in list(chain) + fact_idx:
661
+ used_triples[t] = self._triple_view(t)
662
+ out["answers"].append(
663
+ {
664
+ "entity": node["name"],
665
+ "type": node["type"],
666
+ "score": round(float(score), 4),
667
+ "similarity": round(float(sem[nid]), 4),
668
+ "hops": int(dist),
669
+ "pages": sorted(node["pages"]),
670
+ "path": path_triples,
671
+ "path_pages": path_pages,
672
+ "facts": facts,
673
+ }
674
+ )
675
+
676
+ # Flat, de-duplicated list of every supporting triple referenced above.
677
+ out["triples"] = list(used_triples.values())
678
+ return out
679
+
680
+ def _triple_view(self, tindex: int) -> dict:
681
+ t = self.triples[tindex]
682
+ return {
683
+ "subject": t["subject"],
684
+ "predicate": t["predicate"],
685
+ "object": t["object"],
686
+ "page": t["page"],
687
+ }
688
+
689
+ # --- serialization (explicit; the graph is NEVER auto-serialized) --------
690
+ def to_viz_dict(self, max_nodes: int = 120) -> dict:
691
+ """Compact node/edge lists for the vanilla-canvas visualization.
692
+
693
+ Caps to the highest-degree ``max_nodes`` so a huge graph stays drawable
694
+ (and the dropped count is reported, never silently truncated).
695
+ """
696
+ order = sorted(
697
+ range(len(self.nodes)), key=lambda i: self.nodes[i]["degree"], reverse=True
698
+ )
699
+ keep = set(order[:max_nodes])
700
+ nodes = [
701
+ {
702
+ "id": i,
703
+ "name": self.nodes[i]["name"],
704
+ "type": self.nodes[i]["type"],
705
+ "pages": sorted(self.nodes[i]["pages"]),
706
+ "degree": self.nodes[i]["degree"],
707
+ }
708
+ for i in sorted(keep)
709
+ ]
710
+ edges = [
711
+ {
712
+ "source": t["subject_id"],
713
+ "target": t["object_id"],
714
+ "predicate": t["predicate"],
715
+ "page": t["page"],
716
+ }
717
+ for t in self.triples
718
+ if t["subject_id"] in keep and t["object_id"] in keep
719
+ ]
720
+ return {
721
+ "nodes": nodes,
722
+ "edges": edges,
723
+ "total_nodes": len(self.nodes),
724
+ "total_edges": len(self.triples),
725
+ "truncated": len(self.nodes) > len(keep),
726
+ }
727
+
728
+ def to_export_dict(self) -> dict:
729
+ """Full graph for the .kg.json export (entities + triples + provenance)."""
730
+ return {
731
+ "entities": [
732
+ {"name": n["name"], "type": n["type"], "pages": sorted(n["pages"])}
733
+ for n in self.nodes
734
+ ],
735
+ "triples": [self._triple_view(i) for i in range(len(self.triples))],
736
+ "node_count": len(self.nodes),
737
+ "triple_count": len(self.triples),
738
+ "embed_model": self.embed_model,
739
+ }
740
+
741
+
742
+ def build_graph(
743
+ pages,
744
+ *,
745
+ api_key,
746
+ triple_model=None,
747
+ embed_model=None,
748
+ max_pages: Optional[int] = None,
749
+ embed: bool = True,
750
+ timeout: float = 120,
751
+ ) -> KnowledgeGraph:
752
+ """Build a per-document :class:`KnowledgeGraph` from extracted page text.
753
+
754
+ Parameters
755
+ ----------
756
+ pages : list[tuple[int, str]]
757
+ ``(page_number, text)`` pairs — typically
758
+ ``[(p.page, p.text) for p in job.pages if p.status == "done"]``.
759
+ api_key : str
760
+ A Gemini API key (required; this is the opt-in online feature).
761
+ triple_model, embed_model : str, optional
762
+ Model overrides; default to Flash + text-embedding-004.
763
+ max_pages : int, optional
764
+ Cap the number of non-empty pages processed (cost control). None = all.
765
+ embed : bool
766
+ If True (default) also embed the nodes so semantic search works; if
767
+ False the graph supports lexical search only (cheaper).
768
+
769
+ Returns a graph (possibly empty if the document yields no facts). Raises a
770
+ single-line RuntimeError only for a missing key or a hard Gemini failure.
771
+ """
772
+ if not is_configured(api_key):
773
+ raise RuntimeError(
774
+ "Building a knowledge graph needs a Gemini API key. Enter your free "
775
+ "key from https://aistudio.google.com/, or use the demo key."
776
+ )
777
+
778
+ usable = [(int(pn), str(txt or "")) for pn, txt in pages if str(txt or "").strip()]
779
+ if max_pages is not None and max_pages > 0:
780
+ usable = usable[:max_pages]
781
+
782
+ graph = KnowledgeGraph()
783
+ for page_no, text in usable:
784
+ data = extract_triples(text, api_key=api_key, model=triple_model, timeout=timeout)
785
+ # Register typed entities first so types are known, then triples.
786
+ for e in data.get("entities", []):
787
+ graph._add_node(e["name"], e.get("type", "OTHER"), page=page_no)
788
+ for t in data.get("triples", []):
789
+ graph._add_triple(t["subject"], t["predicate"], t["object"], page=page_no)
790
+
791
+ if embed and graph.num_nodes:
792
+ node_texts = [graph._node_embed_text(i) for i in range(graph.num_nodes)]
793
+ vectors = embed_texts(
794
+ node_texts,
795
+ api_key=api_key,
796
+ model=embed_model,
797
+ task_type="RETRIEVAL_DOCUMENT",
798
+ timeout=timeout,
799
+ )
800
+ graph.set_vectors(vectors, model=_resolve_model(embed_model, DEFAULT_EMBED_MODEL))
801
+
802
+ return graph
static/app.js CHANGED
@@ -724,6 +724,22 @@
724
  progressLabel: frag.querySelector('[data-role="progressLabel"]'),
725
  fileError: frag.querySelector('[data-role="fileError"]'),
726
  pages: frag.querySelector('[data-role="pages"]'),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
  };
728
  el.fileName.textContent = meta.filename;
729
 
@@ -739,6 +755,7 @@
739
 
740
  el.cancelBtn.addEventListener("click", () => cancelJob(job));
741
  if (el.removeBtn) el.removeBtn.addEventListener("click", () => removeJob(job));
 
742
 
743
  jobs.set(job.jobId, job);
744
  jobOrder.push(job.jobId);
@@ -778,6 +795,11 @@
778
  if (status === "done" || status === "error" || status === "cancelled") {
779
  job.el.cancelBtn.disabled = true;
780
  }
 
 
 
 
 
781
  }
782
 
783
  function updateProgress(job, processed, total) {
@@ -1084,6 +1106,407 @@
1084
  // Export/copy always available once there is at least one job; nothing to gate.
1085
  }
1086
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1087
  // ---------- Misc ----------
1088
  function humanizeError(detail) {
1089
  const d = String(detail).toLowerCase();
 
724
  progressLabel: frag.querySelector('[data-role="progressLabel"]'),
725
  fileError: frag.querySelector('[data-role="fileError"]'),
726
  pages: frag.querySelector('[data-role="pages"]'),
727
+ // Knowledge graph hooks (all optional; present only in newer markup).
728
+ kgPanel: frag.querySelector('[data-role="kgPanel"]'),
729
+ kgStatus: frag.querySelector('[data-role="kgStatus"]'),
730
+ kgBuildBtn: frag.querySelector('[data-role="kgBuildBtn"]'),
731
+ kgBuildNote: frag.querySelector('[data-role="kgBuildNote"]'),
732
+ kgQueryWrap: frag.querySelector('[data-role="kgQueryWrap"]'),
733
+ kgQueryInput: frag.querySelector('[data-role="kgQueryInput"]'),
734
+ kgAskBtn: frag.querySelector('[data-role="kgAskBtn"]'),
735
+ kgResults: frag.querySelector('[data-role="kgResults"]'),
736
+ kgTools: frag.querySelector('[data-role="kgVizToggle"]'),
737
+ kgVizToggle: frag.querySelector('[data-role="kgVizToggle"]'),
738
+ kgExportBtn: frag.querySelector('[data-role="kgExportBtn"]'),
739
+ kgRebuildBtn: frag.querySelector('[data-role="kgRebuildBtn"]'),
740
+ kgVizWrap: frag.querySelector('[data-role="kgVizWrap"]'),
741
+ kgCanvas: frag.querySelector('[data-role="kgCanvas"]'),
742
+ kgVizNote: frag.querySelector('[data-role="kgVizNote"]'),
743
  };
744
  el.fileName.textContent = meta.filename;
745
 
 
755
 
756
  el.cancelBtn.addEventListener("click", () => cancelJob(job));
757
  if (el.removeBtn) el.removeBtn.addEventListener("click", () => removeJob(job));
758
+ setupKnowledgeGraph(job);
759
 
760
  jobs.set(job.jobId, job);
761
  jobOrder.push(job.jobId);
 
795
  if (status === "done" || status === "error" || status === "cancelled") {
796
  job.el.cancelBtn.disabled = true;
797
  }
798
+ // Reveal the knowledge-graph panel once extraction has finished (there is
799
+ // now text to build from). Only on a successful run.
800
+ if (status === "done" && job.el && job.el.kgPanel) {
801
+ job.el.kgPanel.hidden = false;
802
+ }
803
  }
804
 
805
  function updateProgress(job, processed, total) {
 
1106
  // Export/copy always available once there is at least one job; nothing to gate.
1107
  }
1108
 
1109
+ // ---------- Knowledge graph (opt-in, online) ----------
1110
+ // Colors per entity type; chosen to read on both light & dark themes. Used
1111
+ // for the result-card dots/chips AND the canvas nodes so the two views match.
1112
+ const KG_TYPE_COLORS = {
1113
+ PERSON: "#7c3aed", ORG: "#0ea5b7", PLACE: "#0e9f6e", DATE: "#c2790f",
1114
+ ID: "#e0455c", AMOUNT: "#2563eb", CONCEPT: "#9333ea", EVENT: "#db2777",
1115
+ OTHER: "#8a8a99",
1116
+ };
1117
+ function kgTypeColor(t) {
1118
+ return KG_TYPE_COLORS[String(t || "OTHER").toUpperCase()] || KG_TYPE_COLORS.OTHER;
1119
+ }
1120
+
1121
+ function setupKnowledgeGraph(job) {
1122
+ const el = job.el;
1123
+ if (!el || !el.kgPanel) return; // older markup without the KG panel
1124
+ job.kgBuilt = false;
1125
+ job.kgViz = null;
1126
+ job.kgLaidOut = false;
1127
+
1128
+ if (el.kgBuildBtn) el.kgBuildBtn.addEventListener("click", () => buildKG(job));
1129
+ if (el.kgRebuildBtn) el.kgRebuildBtn.addEventListener("click", () => buildKG(job));
1130
+ if (el.kgAskBtn) el.kgAskBtn.addEventListener("click", () => askKG(job));
1131
+ if (el.kgQueryInput) {
1132
+ el.kgQueryInput.addEventListener("keydown", (e) => {
1133
+ if (e.key === "Enter") { e.preventDefault(); askKG(job); }
1134
+ });
1135
+ }
1136
+ if (el.kgExportBtn) el.kgExportBtn.addEventListener("click", () => exportKG(job));
1137
+ if (el.kgVizToggle) el.kgVizToggle.addEventListener("click", () => toggleKGViz(job));
1138
+ }
1139
+
1140
+ function setKgStatus(job, msg, kind) {
1141
+ const s = job.el.kgStatus;
1142
+ if (!s) return;
1143
+ s.textContent = msg || "";
1144
+ s.className = "kg-summary-status" + (kind ? " " + kind : "");
1145
+ }
1146
+
1147
+ async function buildKG(job) {
1148
+ const el = job.el;
1149
+ el.kgPanel.open = true;
1150
+ if (el.kgBuildBtn) el.kgBuildBtn.disabled = true;
1151
+ if (el.kgRebuildBtn) el.kgRebuildBtn.disabled = true;
1152
+ setKgStatus(job, "Building…", "busy");
1153
+ el.kgBuildNote.textContent = "Reading entities & facts with Gemini — this can take a moment on a long document…";
1154
+
1155
+ const form = new FormData();
1156
+ form.append("job_id", job.jobId);
1157
+ form.append("online_key", settings.online_key || "");
1158
+ form.append("online_model", settings.online_model || "");
1159
+ try {
1160
+ const res = await fetch("/api/graph/build", { method: "POST", body: form });
1161
+ const data = await res.json().catch(() => ({}));
1162
+ if (!res.ok) {
1163
+ if (el.kgBuildBtn) { el.kgBuildBtn.disabled = false; el.kgBuildBtn.hidden = false; }
1164
+ if (el.kgRebuildBtn) el.kgRebuildBtn.disabled = false;
1165
+ setKgStatus(job, "Failed", "bad");
1166
+ el.kgBuildNote.textContent = data.detail ? humanizeError(data.detail) : "Build failed.";
1167
+ return;
1168
+ }
1169
+ job.kgBuilt = true;
1170
+ job.kgViz = null;
1171
+ job.kgLaidOut = false;
1172
+ if (el.kgRebuildBtn) el.kgRebuildBtn.disabled = false;
1173
+ // Hide any previously open viz so it re-fetches the new graph next open.
1174
+ if (el.kgVizWrap) el.kgVizWrap.hidden = true;
1175
+ if (el.kgVizToggle) el.kgVizToggle.textContent = "Show graph view";
1176
+
1177
+ if (!data.nodes) {
1178
+ setKgStatus(job, "No entities found", "");
1179
+ el.kgBuildNote.textContent =
1180
+ "Gemini found no extractable entities/facts in this document's text.";
1181
+ if (el.kgBuildBtn) { el.kgBuildBtn.disabled = false; el.kgBuildBtn.hidden = false; el.kgBuildBtn.textContent = "Try again"; }
1182
+ el.kgQueryWrap.hidden = true;
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");
1193
+ if (el.kgQueryInput) el.kgQueryInput.focus();
1194
+ } catch (e) {
1195
+ if (el.kgBuildBtn) { el.kgBuildBtn.disabled = false; el.kgBuildBtn.hidden = false; }
1196
+ if (el.kgRebuildBtn) el.kgRebuildBtn.disabled = false;
1197
+ setKgStatus(job, "Failed", "bad");
1198
+ el.kgBuildNote.textContent = "Network error during build.";
1199
+ }
1200
+ }
1201
+
1202
+ async function askKG(job) {
1203
+ const el = job.el;
1204
+ const q = (el.kgQueryInput.value || "").trim();
1205
+ if (!q) return;
1206
+ el.kgAskBtn.disabled = true;
1207
+ el.kgResults.innerHTML = "";
1208
+ const loading = document.createElement("p");
1209
+ loading.className = "kg-empty";
1210
+ loading.textContent = "Searching…";
1211
+ el.kgResults.appendChild(loading);
1212
+
1213
+ const form = new FormData();
1214
+ form.append("job_id", job.jobId);
1215
+ form.append("query", q);
1216
+ form.append("online_key", settings.online_key || "");
1217
+ try {
1218
+ const res = await fetch("/api/graph/query", { method: "POST", body: form });
1219
+ const data = await res.json().catch(() => ({}));
1220
+ if (!res.ok) {
1221
+ renderKgMessage(job, data.detail ? humanizeError(data.detail) : "Query failed.");
1222
+ return;
1223
+ }
1224
+ renderKGResults(job, data);
1225
+ } catch (e) {
1226
+ renderKgMessage(job, "Network error during search.");
1227
+ } finally {
1228
+ el.kgAskBtn.disabled = false;
1229
+ }
1230
+ }
1231
+
1232
+ function renderKgMessage(job, msg) {
1233
+ const c = job.el.kgResults;
1234
+ c.innerHTML = "";
1235
+ const p = document.createElement("p");
1236
+ p.className = "kg-empty";
1237
+ p.textContent = msg;
1238
+ c.appendChild(p);
1239
+ }
1240
+
1241
+ // Build a "p.1, 2" provenance chip element.
1242
+ function kgPageTag(pages) {
1243
+ const tag = document.createElement("span");
1244
+ tag.className = "kg-pages";
1245
+ const list = (pages || []).filter((p) => p != null);
1246
+ tag.textContent = list.length
1247
+ ? (list.length === 1 ? "p." + list[0] : "p." + list.join(", "))
1248
+ : "";
1249
+ if (!list.length) tag.hidden = true;
1250
+ return tag;
1251
+ }
1252
+
1253
+ // Render one triple as "subject → predicate → object" with a page tag.
1254
+ function kgTripleRow(t) {
1255
+ const row = document.createElement("div");
1256
+ row.className = "kg-triple";
1257
+ const s = document.createElement("span"); s.className = "kg-subj"; s.textContent = t.subject;
1258
+ const p = document.createElement("span"); p.className = "kg-pred"; p.textContent = t.predicate;
1259
+ const o = document.createElement("span"); o.className = "kg-obj"; o.textContent = t.object;
1260
+ const a1 = document.createElement("span"); a1.className = "kg-arrow"; a1.textContent = "→";
1261
+ const a2 = document.createElement("span"); a2.className = "kg-arrow"; a2.textContent = "→";
1262
+ row.appendChild(s); row.appendChild(a1); row.appendChild(p); row.appendChild(a2); row.appendChild(o);
1263
+ if (t.page != null) {
1264
+ const pg = document.createElement("span");
1265
+ pg.className = "kg-pages kg-triple-page";
1266
+ pg.textContent = "p." + t.page;
1267
+ row.appendChild(pg);
1268
+ }
1269
+ return row;
1270
+ }
1271
+
1272
+ function renderKGResults(job, data) {
1273
+ const c = job.el.kgResults;
1274
+ c.innerHTML = "";
1275
+
1276
+ const meta = document.createElement("div");
1277
+ meta.className = "kg-resultmeta";
1278
+ const modeTxt = data.mode === "semantic" ? "semantic + graph" : "graph (lexical)";
1279
+ meta.textContent = (data.answers || []).length
1280
+ ? "Top matches · " + modeTxt + " search"
1281
+ : "";
1282
+ if (meta.textContent) c.appendChild(meta);
1283
+
1284
+ if (!data.answers || !data.answers.length) {
1285
+ renderKgMessage(job, "No matching entities found. Try different words.");
1286
+ // keep the meta line removed for a clean empty state
1287
+ return;
1288
+ }
1289
+
1290
+ data.answers.forEach((a) => {
1291
+ const card = document.createElement("div");
1292
+ card.className = "kg-answer";
1293
+
1294
+ const head = document.createElement("div");
1295
+ head.className = "kg-answer-head";
1296
+ const dot = document.createElement("span");
1297
+ dot.className = "kg-type-dot";
1298
+ dot.style.background = kgTypeColor(a.type);
1299
+ const name = document.createElement("span");
1300
+ name.className = "kg-entity";
1301
+ name.textContent = a.entity;
1302
+ const chip = document.createElement("span");
1303
+ chip.className = "kg-type-chip";
1304
+ chip.textContent = (a.type || "OTHER").toLowerCase();
1305
+ chip.style.color = kgTypeColor(a.type);
1306
+ head.appendChild(dot);
1307
+ head.appendChild(name);
1308
+ head.appendChild(chip);
1309
+ head.appendChild(kgPageTag(a.pages));
1310
+ card.appendChild(head);
1311
+
1312
+ // Supporting facts (the entity's own triples) — the explainable evidence.
1313
+ const facts = (a.facts && a.facts.length) ? a.facts : a.path;
1314
+ if (facts && facts.length) {
1315
+ const fwrap = document.createElement("div");
1316
+ fwrap.className = "kg-facts";
1317
+ facts.slice(0, 6).forEach((t) => fwrap.appendChild(kgTripleRow(t)));
1318
+ card.appendChild(fwrap);
1319
+ }
1320
+
1321
+ // If reached via traversal, show the connecting chain from the query seed.
1322
+ if (a.hops > 0 && a.path && a.path.length) {
1323
+ const link = document.createElement("div");
1324
+ link.className = "kg-linkpath";
1325
+ const lbl = document.createElement("span");
1326
+ lbl.className = "kg-link-label";
1327
+ lbl.textContent = "linked to your query via:";
1328
+ link.appendChild(lbl);
1329
+ a.path.forEach((t) => link.appendChild(kgTripleRow(t)));
1330
+ card.appendChild(link);
1331
+ }
1332
+
1333
+ c.appendChild(card);
1334
+ });
1335
+ }
1336
+
1337
+ // ----- Graph visualization (vanilla canvas; no external libs) -----
1338
+ async function toggleKGViz(job) {
1339
+ const el = job.el;
1340
+ if (!el.kgVizWrap) return;
1341
+ if (!el.kgVizWrap.hidden) {
1342
+ el.kgVizWrap.hidden = true;
1343
+ el.kgVizToggle.textContent = "Show graph view";
1344
+ return;
1345
+ }
1346
+ el.kgVizWrap.hidden = false;
1347
+ el.kgVizToggle.textContent = "Hide graph view";
1348
+ if (!job.kgViz) {
1349
+ el.kgVizNote.textContent = "Loading graph…";
1350
+ try {
1351
+ const res = await fetch("/api/graph/" + encodeURIComponent(job.jobId));
1352
+ const data = await res.json();
1353
+ if (!res.ok || !data.nodes) { el.kgVizNote.textContent = "No graph to show."; return; }
1354
+ job.kgViz = data;
1355
+ job.kgLaidOut = false;
1356
+ } catch (e) {
1357
+ el.kgVizNote.textContent = "Could not load the graph.";
1358
+ return;
1359
+ }
1360
+ }
1361
+ drawKGViz(job);
1362
+ }
1363
+
1364
+ function layoutKG(viz) {
1365
+ // Force-directed layout (Fruchterman–Reingold-ish) in a virtual unit box,
1366
+ // deterministic init so the picture is stable across redraws.
1367
+ const nodes = viz.nodes.map((n, i) => {
1368
+ const ang = (2 * Math.PI * i) / Math.max(1, viz.nodes.length);
1369
+ return { id: n.id, x: 0.5 + 0.35 * Math.cos(ang), y: 0.5 + 0.35 * Math.sin(ang),
1370
+ vx: 0, vy: 0, deg: n.degree || 0, name: n.name, type: n.type };
1371
+ });
1372
+ const index = new Map(nodes.map((n) => [n.id, n]));
1373
+ const edges = viz.edges
1374
+ .map((e) => [index.get(e.source), index.get(e.target)])
1375
+ .filter((p) => p[0] && p[1]);
1376
+
1377
+ const n = nodes.length || 1;
1378
+ const k = 0.9 / Math.sqrt(n); // ideal edge length
1379
+ let temp = 0.10;
1380
+ const ITER = 220;
1381
+ for (let it = 0; it < ITER; it++) {
1382
+ // repulsion (O(n^2); fine for the capped node count)
1383
+ for (let i = 0; i < nodes.length; i++) {
1384
+ let fx = 0, fy = 0;
1385
+ const a = nodes[i];
1386
+ for (let j = 0; j < nodes.length; j++) {
1387
+ if (i === j) continue;
1388
+ const b = nodes[j];
1389
+ let dx = a.x - b.x, dy = a.y - b.y;
1390
+ let d2 = dx * dx + dy * dy;
1391
+ if (d2 < 1e-6) { dx = (i - j) * 1e-3; dy = 1e-3; d2 = 1e-6; }
1392
+ const f = (k * k) / d2;
1393
+ fx += dx * f; fy += dy * f;
1394
+ }
1395
+ // gravity toward center keeps disconnected nodes on-canvas
1396
+ fx += (0.5 - a.x) * 0.02;
1397
+ fy += (0.5 - a.y) * 0.02;
1398
+ a.vx = fx; a.vy = fy;
1399
+ }
1400
+ // attraction along edges
1401
+ edges.forEach(([a, b]) => {
1402
+ let dx = a.x - b.x, dy = a.y - b.y;
1403
+ const d = Math.sqrt(dx * dx + dy * dy) || 1e-4;
1404
+ const f = (d * d) / k;
1405
+ const ux = (dx / d) * f, uy = (dy / d) * f;
1406
+ a.vx -= ux; a.vy -= uy;
1407
+ b.vx += ux; b.vy += uy;
1408
+ });
1409
+ // integrate with cooling + clamp into the box
1410
+ nodes.forEach((a) => {
1411
+ const sp = Math.sqrt(a.vx * a.vx + a.vy * a.vy) || 1e-6;
1412
+ const step = Math.min(sp, temp) / sp;
1413
+ a.x = Math.max(0.04, Math.min(0.96, a.x + a.vx * step));
1414
+ a.y = Math.max(0.05, Math.min(0.95, a.y + a.vy * step));
1415
+ });
1416
+ temp = Math.max(0.008, temp * 0.985);
1417
+ }
1418
+ return { nodes, edges };
1419
+ }
1420
+
1421
+ function drawKGViz(job) {
1422
+ const el = job.el;
1423
+ const canvas = el.kgCanvas;
1424
+ if (!canvas) return;
1425
+ if (!job.kgLaidOut) {
1426
+ job.kgLayout = layoutKG(job.kgViz);
1427
+ job.kgLaidOut = true;
1428
+ }
1429
+ const layout = job.kgLayout;
1430
+ const cssW = Math.max(280, canvas.clientWidth || canvas.parentElement.clientWidth || 640);
1431
+ const cssH = 400;
1432
+ const dpr = window.devicePixelRatio || 1;
1433
+ canvas.width = Math.round(cssW * dpr);
1434
+ canvas.height = Math.round(cssH * dpr);
1435
+ canvas.style.height = cssH + "px";
1436
+ const ctx = canvas.getContext("2d");
1437
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1438
+ ctx.clearRect(0, 0, cssW, cssH);
1439
+
1440
+ const cs = getComputedStyle(canvas);
1441
+ const textColor = cs.getPropertyValue("--text").trim() || "#222";
1442
+ const edgeColor = cs.getPropertyValue("--border-strong").trim() || "#ccc";
1443
+ const pad = 26;
1444
+ const X = (nx) => pad + nx * (cssW - 2 * pad);
1445
+ const Y = (ny) => pad + ny * (cssH - 2 * pad);
1446
+
1447
+ // edges
1448
+ ctx.lineWidth = 1;
1449
+ ctx.strokeStyle = edgeColor;
1450
+ ctx.globalAlpha = 0.55;
1451
+ layout.edges.forEach(([a, b]) => {
1452
+ ctx.beginPath();
1453
+ ctx.moveTo(X(a.x), Y(a.y));
1454
+ ctx.lineTo(X(b.x), Y(b.y));
1455
+ ctx.stroke();
1456
+ });
1457
+ ctx.globalAlpha = 1;
1458
+
1459
+ // label only the most-connected nodes to avoid clutter
1460
+ const labelled = new Set(
1461
+ layout.nodes.slice().sort((a, b) => b.deg - a.deg).slice(0, 26).map((n) => n.id)
1462
+ );
1463
+ ctx.font = "11px " + (cs.getPropertyValue("--sans").trim() || "sans-serif");
1464
+ ctx.textAlign = "center";
1465
+ ctx.textBaseline = "top";
1466
+ layout.nodes.forEach((nd) => {
1467
+ const r = 4 + Math.min(7, nd.deg * 1.4);
1468
+ ctx.beginPath();
1469
+ ctx.arc(X(nd.x), Y(nd.y), r, 0, 2 * Math.PI);
1470
+ ctx.fillStyle = kgTypeColor(nd.type);
1471
+ ctx.fill();
1472
+ ctx.lineWidth = 1.5;
1473
+ ctx.strokeStyle = "rgba(255,255,255,0.55)";
1474
+ ctx.stroke();
1475
+ if (labelled.has(nd.id)) {
1476
+ const label = nd.name.length > 22 ? nd.name.slice(0, 21) + "…" : nd.name;
1477
+ ctx.fillStyle = textColor;
1478
+ ctx.fillText(label, X(nd.x), Y(nd.y) + r + 2);
1479
+ }
1480
+ });
1481
+
1482
+ const more = job.kgViz.truncated
1483
+ ? " · showing top " + job.kgViz.nodes.length + " of " + job.kgViz.total_nodes
1484
+ : "";
1485
+ el.kgVizNote.textContent =
1486
+ job.kgViz.total_nodes + " entities · " + job.kgViz.total_edges + " facts" + more +
1487
+ " · labels on the most-connected entities";
1488
+ }
1489
+
1490
+ async function exportKG(job) {
1491
+ try {
1492
+ const res = await fetch("/api/graph/" + encodeURIComponent(job.jobId) + "?full=1");
1493
+ if (!res.ok) { showToast("Could not export the graph."); return; }
1494
+ const data = await res.json();
1495
+ const payload = {
1496
+ filename: job.filename,
1497
+ node_count: data.node_count,
1498
+ triple_count: data.triple_count,
1499
+ embed_model: data.embed_model,
1500
+ entities: data.entities,
1501
+ triples: data.triples,
1502
+ };
1503
+ download(baseName([job]) + ".kg.json", JSON.stringify(payload, null, 2), "application/json");
1504
+ showToast("Knowledge graph exported");
1505
+ } catch (e) {
1506
+ showToast("Could not export the graph.");
1507
+ }
1508
+ }
1509
+
1510
  // ---------- Misc ----------
1511
  function humanizeError(detail) {
1512
  const d = String(detail).toLowerCase();
static/index.html CHANGED
@@ -301,6 +301,42 @@
301
  <span class="progress-label" data-role="progressLabel">0 / 0</span>
302
  </div>
303
  <div class="file-error" data-role="fileError" hidden></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  <div class="pages" data-role="pages"></div>
305
  </article>
306
  </template>
 
301
  <span class="progress-label" data-role="progressLabel">0 / 0</span>
302
  </div>
303
  <div class="file-error" data-role="fileError" hidden></div>
304
+
305
+ <details class="kg-panel" data-role="kgPanel" hidden>
306
+ <summary class="kg-summary">
307
+ <svg class="kg-summary-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
308
+ <circle cx="5" cy="6" r="2.4"></circle><circle cx="19" cy="6" r="2.4"></circle><circle cx="12" cy="18" r="2.4"></circle>
309
+ <line x1="6.9" y1="7.4" x2="10.4" y2="16.2"></line><line x1="17.1" y1="7.4" x2="13.6" y2="16.2"></line><line x1="7" y1="6" x2="17" y2="6"></line>
310
+ </svg>
311
+ <span>Knowledge graph &amp; search</span>
312
+ <span class="kg-summary-status" data-role="kgStatus"></span>
313
+ <svg class="accordion-chevron kg-chevron" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"></path></svg>
314
+ </summary>
315
+ <div class="kg-body">
316
+ <p class="hint kg-intro">Turn this document into a searchable graph of its entities &amp; facts, then ask questions and get answers traced back to the exact page. Uses <strong>Google Gemini</strong> (online &mdash; same key as Online OCR).</p>
317
+ <div class="kg-build-row">
318
+ <button type="button" class="btn btn-primary btn-sm" data-role="kgBuildBtn">Build knowledge graph</button>
319
+ <span class="kg-build-note hint" data-role="kgBuildNote"></span>
320
+ </div>
321
+ <div class="kg-query-wrap" data-role="kgQueryWrap" hidden>
322
+ <div class="kg-ask">
323
+ <input type="search" class="kg-query-input" data-role="kgQueryInput" placeholder="Ask about this document…" autocomplete="off" />
324
+ <button type="button" class="btn btn-primary btn-sm" data-role="kgAskBtn">Ask</button>
325
+ </div>
326
+ <div class="kg-results" data-role="kgResults"></div>
327
+ <div class="kg-tools">
328
+ <button type="button" class="btn btn-ghost btn-sm" data-role="kgVizToggle">Show graph view</button>
329
+ <button type="button" class="btn btn-ghost btn-sm" data-role="kgExportBtn">Export graph (.json)</button>
330
+ <button type="button" class="btn btn-ghost btn-sm" data-role="kgRebuildBtn">Rebuild</button>
331
+ </div>
332
+ <div class="kg-viz-wrap" data-role="kgVizWrap" hidden>
333
+ <canvas class="kg-canvas" data-role="kgCanvas" width="640" height="400"></canvas>
334
+ <p class="hint kg-viz-note" data-role="kgVizNote"></p>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </details>
339
+
340
  <div class="pages" data-role="pages"></div>
341
  </article>
342
  </template>
static/styles.css CHANGED
@@ -531,6 +531,66 @@ html[data-theme="light"] .theme-toggle .icon-moon { display: block; }
531
  .btn-remove { color: var(--text-dim); font-size: 18px; line-height: 1; padding: 3px 9px; font-weight: 700; box-shadow: none; border-color: transparent; background: transparent; }
532
  .btn-remove:hover { color: var(--red); background: var(--red-soft); transform: none; }
533
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
  @media (prefers-reduced-motion: reduce) {
535
  * { transition: none !important; animation: none !important; }
536
  }
 
531
  .btn-remove { color: var(--text-dim); font-size: 18px; line-height: 1; padding: 3px 9px; font-weight: 700; box-shadow: none; border-color: transparent; background: transparent; }
532
  .btn-remove:hover { color: var(--red); background: var(--red-soft); transform: none; }
533
 
534
+ /* ---------------- Knowledge graph panel (per file card) ---------------- */
535
+ .kg-panel { margin: 0 18px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-3); overflow: hidden; }
536
+ .kg-summary {
537
+ list-style: none; cursor: pointer; user-select: none;
538
+ display: flex; align-items: center; gap: 9px;
539
+ padding: 12px 14px; font-size: 13px; font-weight: 650; color: var(--text);
540
+ }
541
+ .kg-summary::-webkit-details-marker { display: none; }
542
+ .kg-summary:hover { color: var(--accent); }
543
+ .kg-summary-icon { color: var(--accent); flex: none; }
544
+ .kg-chevron { color: var(--text-faint); margin-left: 2px; transition: transform var(--t); }
545
+ .kg-panel[open] > .kg-summary .kg-chevron { transform: rotate(180deg); }
546
+ .kg-summary-status { margin-left: auto; font-size: 11.5px; font-weight: 600; color: var(--text-faint); }
547
+ .kg-summary-status.good { color: var(--green); }
548
+ .kg-summary-status.bad { color: var(--red); }
549
+ .kg-summary-status.busy { color: var(--accent); }
550
+
551
+ .kg-body { padding: 4px 14px 14px; border-top: 1px solid var(--border-soft); }
552
+ .kg-intro { margin: 10px 0 12px; }
553
+ .kg-intro strong { color: var(--accent); }
554
+ .kg-build-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
555
+ .kg-build-note { margin: 0; flex: 1; min-width: 180px; }
556
+
557
+ .kg-query-wrap { margin-top: 12px; }
558
+ .kg-ask { display: flex; gap: 8px; }
559
+ .kg-query-input {
560
+ flex: 1; background: var(--surface); border: 1px solid var(--border); color: var(--text);
561
+ font: inherit; font-size: 14px; padding: 9px 12px; border-radius: var(--radius-sm);
562
+ transition: border-color var(--t-fast), box-shadow var(--t-fast);
563
+ }
564
+ .kg-query-input::placeholder { color: var(--text-faint); }
565
+ .kg-query-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
566
+
567
+ .kg-results { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
568
+ .kg-empty { margin: 4px 0; font-size: 12.5px; color: var(--text-faint); }
569
+ .kg-resultmeta { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); font-weight: 700; }
570
+
571
+ .kg-answer { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); padding: 11px 13px; box-shadow: var(--shadow-sm); }
572
+ .kg-answer-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
573
+ .kg-type-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
574
+ .kg-entity { font-size: 14px; font-weight: 700; color: var(--text); }
575
+ .kg-type-chip { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; border: 1px solid currentColor; border-radius: 999px; padding: 1px 7px; opacity: 0.9; }
576
+ .kg-pages { font-size: 11px; font-weight: 650; color: var(--text-dim); background: var(--surface-3); border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; font-variant-numeric: tabular-nums; }
577
+
578
+ .kg-facts { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
579
+ .kg-triple { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12.5px; line-height: 1.5; }
580
+ .kg-subj { font-weight: 650; color: var(--text); }
581
+ .kg-pred { color: var(--accent); font-style: italic; }
582
+ .kg-obj { font-weight: 650; color: var(--text); }
583
+ .kg-arrow { color: var(--text-faint); }
584
+ .kg-triple-page { margin-left: 2px; }
585
+
586
+ .kg-linkpath { margin-top: 9px; padding-top: 8px; border-top: 1px dashed var(--border); display: flex; flex-direction: column; gap: 4px; }
587
+ .kg-link-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-faint); font-weight: 700; }
588
+
589
+ .kg-tools { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
590
+ .kg-viz-wrap { margin-top: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); padding: 8px; }
591
+ .kg-canvas { width: 100%; display: block; border-radius: var(--radius-sm); background: var(--surface-3); }
592
+ .kg-viz-note { margin: 8px 2px 2px; text-align: center; }
593
+
594
  @media (prefers-reduced-motion: reduce) {
595
  * { transition: none !important; animation: none !important; }
596
  }