Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

981 lines
34 KiB
Python

"""Per-source knowledge-graph store co-located with the pgvector ``documents`` table.
GraphRAG is pgvector-only: the graph tables live in the same DB as the
pgvector store and are created on-demand (``CREATE TABLE IF NOT EXISTS`` +
``CREATE EXTENSION IF NOT EXISTS vector``), mirroring
``PGVectorStore._ensure_table_exists`` rather than going through app-DB Alembic.
That DB may be a separate cluster (e.g. Neon) from the app DB where ``sources``
lives, so ``source_id`` is a plain indexed UUID column with no cross-DB FK and
all ids are generated in Python.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any, Dict, List, Optional
from psycopg.types.json import Jsonb
from application.core.settings import settings
DEFAULT_NAME_EMBEDDING_DIM = 768
MAX_SUBGRAPH_NODES = 500
MAX_SUBGRAPH_EDGES = 2000
GRAPH_OVERVIEW_DEFAULT_LIMIT = 100
GRAPH_OVERVIEW_MAX_LIMIT = 250
PGVECTOR_SOURCE_COLUMN = "source_id"
def _safe_identifier(name: str) -> str:
"""Return ``name`` if it is a bare SQL identifier, else raise.
Guards the interpolated table/column names against injection; pgvector uses
plain identifiers, so anything outside ``[A-Za-z_][A-Za-z0-9_]*`` is rejected.
"""
if not isinstance(name, str) or not name.isidentifier():
raise ValueError(f"Unsafe SQL identifier: {name!r}")
return name
def _pgvector_identifiers() -> tuple[str, str, str, str]:
"""Resolve ``(table, text_col, metadata_col, source_col)`` from ``PGVectorStore``.
Reads the table and column defaults from ``PGVectorStore.__init__`` so the
graph store queries the same names a customized deployment configured.
"""
import inspect
from application.vectorstore.pgvector import PGVectorStore
params = inspect.signature(PGVectorStore.__init__).parameters
table = params["table_name"].default
text_col = params["text_column"].default
metadata_col = params["metadata_column"].default
return (
_safe_identifier(table),
_safe_identifier(text_col),
_safe_identifier(metadata_col),
_safe_identifier(PGVECTOR_SOURCE_COLUMN),
)
class GraphStore:
"""Stores and queries a per-source knowledge graph in the pgvector DB."""
def __init__(self, connection_string: Optional[str] = None):
self._connection_string = connection_string or getattr(
settings, "PGVECTOR_CONNECTION_STRING", None
)
if not self._connection_string and getattr(settings, "POSTGRES_URI", None):
from application.core.db_uri import normalize_pgvector_connection_string
self._connection_string = normalize_pgvector_connection_string(
settings.POSTGRES_URI
)
if not self._connection_string:
raise ValueError(
"PostgreSQL connection string is required. "
"Set PGVECTOR_CONNECTION_STRING or POSTGRES_URI in settings, "
"or pass connection_string parameter."
)
try:
import psycopg
from pgvector.psycopg import register_vector
except ImportError:
raise ImportError(
"Could not import required packages. "
"Please install with `pip install 'psycopg[binary,pool]' pgvector`."
)
self._psycopg = psycopg
self._register_vector = register_vector
self._connection = None
self._ensure_tables()
def _get_connection(self):
if self._connection is None or self._connection.closed:
self._connection = self._psycopg.connect(self._connection_string)
self._register_vector(self._connection)
return self._connection
def _embedding_dim(self) -> int:
"""Dimension of the configured embeddings model, matching ``PGVectorStore``.
Falls back to ``DEFAULT_NAME_EMBEDDING_DIM`` so the graph table and the
pgvector ``documents`` table always agree on the configured model.
"""
from application.vectorstore.base import EmbeddingsSingleton
embedding = EmbeddingsSingleton.get_instance(
settings.EMBEDDINGS_NAME, settings.EMBEDDINGS_KEY
)
return getattr(embedding, "dimension", DEFAULT_NAME_EMBEDDING_DIM)
def _ensure_tables(self):
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute("CREATE EXTENSION IF NOT EXISTS vector;")
embedding_dim = self._embedding_dim()
cursor.execute(
f"""
CREATE TABLE IF NOT EXISTS graph_nodes (
id UUID PRIMARY KEY,
source_id UUID NOT NULL,
name TEXT,
normalized_name TEXT,
type TEXT,
description TEXT,
degree INT DEFAULT 0,
doc_freq INT DEFAULT 0,
name_embedding vector({embedding_dim}),
UNIQUE (source_id, normalized_name)
);
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS graph_edges (
id UUID PRIMARY KEY,
source_id UUID NOT NULL,
src_node_id UUID,
dst_node_id UUID,
type TEXT,
description TEXT,
weight REAL DEFAULT 1.0,
source_chunk_ids JSONB
);
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS graph_node_chunks (
source_id UUID NOT NULL,
node_id UUID NOT NULL,
chunk_id TEXT NOT NULL,
PRIMARY KEY (source_id, node_id, chunk_id)
);
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS graph_ingest_progress (
source_id UUID NOT NULL,
chunk_id TEXT NOT NULL,
status TEXT,
PRIMARY KEY (source_id, chunk_id)
);
"""
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS graph_nodes_source_id_idx "
"ON graph_nodes (source_id);"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS graph_edges_source_id_idx "
"ON graph_edges (source_id);"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS graph_edges_src_node_id_idx "
"ON graph_edges (src_node_id);"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS graph_edges_dst_node_id_idx "
"ON graph_edges (dst_node_id);"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS graph_node_chunks_node_id_idx "
"ON graph_node_chunks (node_id);"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS graph_nodes_name_embedding_idx "
"ON graph_nodes USING ivfflat (name_embedding vector_cosine_ops) "
"WITH (lists = 100);"
)
conn.commit()
except Exception as e:
conn.rollback()
logging.error(f"Error creating graph tables: {e}")
raise
finally:
cursor.close()
def _upsert_node(
self,
cursor,
source_id: str,
name: str,
normalized_name: str,
type: Optional[str] = None,
description: Optional[str] = None,
name_embedding: Optional[List[float]] = None,
) -> str:
"""Upsert a node on an open cursor (no commit). Returns the node id.
On conflict the description is concatenated (de-duped), ``doc_freq`` is
incremented, the type is refreshed if previously empty, and the
embedding is refreshed when provided.
"""
node_id = str(uuid.uuid4())
cursor.execute(
"""
INSERT INTO graph_nodes
(id, source_id, name, normalized_name, type, description,
doc_freq, name_embedding)
VALUES (%s, %s, %s, %s, %s, %s, 1, %s)
ON CONFLICT (source_id, normalized_name) DO UPDATE SET
description = CASE
WHEN EXCLUDED.description IS NULL
OR EXCLUDED.description = '' THEN graph_nodes.description
WHEN graph_nodes.description IS NULL
OR graph_nodes.description = '' THEN EXCLUDED.description
WHEN position(EXCLUDED.description IN graph_nodes.description) > 0
THEN graph_nodes.description
ELSE graph_nodes.description || ' ' || EXCLUDED.description
END,
type = CASE
WHEN graph_nodes.type IS NULL
OR graph_nodes.type = '' THEN EXCLUDED.type
ELSE graph_nodes.type
END,
name = COALESCE(graph_nodes.name, EXCLUDED.name),
doc_freq = graph_nodes.doc_freq + 1,
name_embedding = COALESCE(
EXCLUDED.name_embedding, graph_nodes.name_embedding
)
RETURNING id;
""",
(
node_id,
source_id,
name,
normalized_name,
type,
description,
name_embedding,
),
)
return str(cursor.fetchone()[0])
def upsert_node(
self,
source_id: str,
name: str,
normalized_name: str,
type: Optional[str] = None,
description: Optional[str] = None,
name_embedding: Optional[List[float]] = None,
) -> str:
"""Insert a node or merge into the existing one for ``(source_id, normalized_name)``.
On conflict the description is concatenated (de-duped), ``doc_freq`` is
incremented, the type is refreshed if previously empty, and the
embedding is refreshed when provided. Returns the node id either way.
"""
conn = self._get_connection()
cursor = conn.cursor()
try:
returned_id = self._upsert_node(
cursor, source_id, name, normalized_name, type, description,
name_embedding,
)
conn.commit()
return returned_id
except Exception as e:
conn.rollback()
logging.error(f"Error upserting node: {e}")
raise
finally:
cursor.close()
def _add_edge(
self,
cursor,
source_id: str,
src_node_id: str,
dst_node_id: str,
type: Optional[str] = None,
description: Optional[str] = None,
weight: float = 1.0,
source_chunk_ids: Optional[List[str]] = None,
) -> str:
"""Insert an edge on an open cursor (no commit, no degree bump).
Callers that batch many edges run ``set_node_degrees`` once afterwards
instead of bumping degree per edge.
"""
edge_id = str(uuid.uuid4())
cursor.execute(
"""
INSERT INTO graph_edges
(id, source_id, src_node_id, dst_node_id, type, description,
weight, source_chunk_ids)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s);
""",
(
edge_id,
source_id,
src_node_id,
dst_node_id,
type,
description,
weight,
Jsonb(source_chunk_ids or []),
),
)
return edge_id
def add_edge(
self,
source_id: str,
src_node_id: str,
dst_node_id: str,
type: Optional[str] = None,
description: Optional[str] = None,
weight: float = 1.0,
source_chunk_ids: Optional[List[str]] = None,
) -> str:
"""Insert an edge and bump the degree of both endpoints. Returns its id."""
conn = self._get_connection()
cursor = conn.cursor()
try:
edge_id = self._add_edge(
cursor, source_id, src_node_id, dst_node_id, type, description,
weight, source_chunk_ids,
)
cursor.execute(
"UPDATE graph_nodes SET degree = degree + 1 "
"WHERE source_id = %s AND id IN (%s, %s);",
(source_id, src_node_id, dst_node_id),
)
conn.commit()
return edge_id
except Exception as e:
conn.rollback()
logging.error(f"Error adding edge: {e}")
raise
finally:
cursor.close()
def _link_node_chunk(self, cursor, source_id: str, node_id: str, chunk_id: str):
"""Link a node to a chunk on an open cursor (no commit)."""
cursor.execute(
"""
INSERT INTO graph_node_chunks (source_id, node_id, chunk_id)
VALUES (%s, %s, %s)
ON CONFLICT (source_id, node_id, chunk_id) DO NOTHING;
""",
(source_id, node_id, str(chunk_id)),
)
def link_node_chunk(self, source_id: str, node_id: str, chunk_id: str):
conn = self._get_connection()
cursor = conn.cursor()
try:
self._link_node_chunk(cursor, source_id, node_id, chunk_id)
conn.commit()
except Exception as e:
conn.rollback()
logging.error(f"Error linking node chunk: {e}")
raise
finally:
cursor.close()
def apply_chunk(
self,
source_id: str,
chunk_id: str,
entities: List[Dict[str, Any]],
relationships: List[Dict[str, Any]],
name_embeddings: Dict[str, List[float]],
) -> tuple[int, int]:
"""Write one chunk's extracted entities and relationships in one transaction.
``entities`` are ``{name, normalized_name, type, description}`` dicts;
each is upserted and linked to ``chunk_id``. ``relationships`` are
``{source, target, type, description, weight}`` dicts keyed by entity
name; an endpoint not among the chunk's entities is upserted edge-only
(not linked to the chunk), mirroring the per-call path.
``name_embeddings`` maps ``normalized_name`` to its embedding. Degrees
are not bumped here — the caller runs ``set_node_degrees`` once at the
end. Returns ``(nodes_upserted, edges_added)``.
"""
conn = self._get_connection()
cursor = conn.cursor()
node_ids: Dict[str, str] = {}
edges_added = 0
try:
for entity in entities:
normalized_name = entity["normalized_name"]
node_id = self._upsert_node(
cursor,
source_id,
entity["name"],
normalized_name,
entity.get("type"),
entity.get("description"),
name_embeddings.get(normalized_name),
)
node_ids[normalized_name] = node_id
self._link_node_chunk(cursor, source_id, node_id, chunk_id)
for rel in relationships:
src_id = self._resolve_endpoint(
cursor, source_id, rel.get("source"), node_ids, name_embeddings
)
dst_id = self._resolve_endpoint(
cursor, source_id, rel.get("target"), node_ids, name_embeddings
)
if src_id is None or dst_id is None:
continue
self._add_edge(
cursor,
source_id,
src_id,
dst_id,
type=rel.get("type"),
description=rel.get("description"),
weight=float(rel.get("weight") or 1.0),
source_chunk_ids=[chunk_id],
)
edges_added += 1
conn.commit()
return len(entities), edges_added
except Exception:
conn.rollback()
raise
finally:
cursor.close()
def _resolve_endpoint(
self,
cursor,
source_id: str,
name: Any,
node_ids: Dict[str, str],
name_embeddings: Dict[str, List[float]],
) -> Optional[str]:
"""Resolve a relationship endpoint to a node id, upserting if unseen this chunk."""
if name is None:
return None
clean = str(name).strip()
if not clean:
return None
normalized_name = clean.lower()
if normalized_name in node_ids:
return node_ids[normalized_name]
node_id = self._upsert_node(
cursor,
source_id,
clean,
normalized_name,
name_embedding=name_embeddings.get(normalized_name),
)
node_ids[normalized_name] = node_id
return node_id
def get_node_by_normalized(
self, source_id: str, normalized_name: str
) -> Optional[Dict[str, Any]]:
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT id, name, normalized_name, type, description, degree, doc_freq
FROM graph_nodes
WHERE source_id = %s AND normalized_name = %s;
""",
(source_id, normalized_name),
)
row = cursor.fetchone()
if row is None:
return None
return {
"id": str(row[0]),
"name": row[1],
"normalized_name": row[2],
"type": row[3],
"description": row[4],
"degree": row[5],
"doc_freq": row[6],
}
except Exception as e:
logging.error(f"Error getting node by normalized name: {e}")
return None
finally:
cursor.close()
conn.rollback()
def count_nodes(self, source_id: str) -> int:
"""Number of nodes for a source. Zero drives the ClassicRAG fallback."""
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT count(*) FROM graph_nodes WHERE source_id = %s;",
(source_id,),
)
return int(cursor.fetchone()[0])
except Exception as e:
logging.error(f"Error counting nodes: {e}")
return 0
finally:
cursor.close()
conn.rollback()
def search_nodes_by_embedding(
self, source_id: str, query_embedding: List[float], k: int = 10
) -> List[Dict[str, Any]]:
"""Cosine NN over ``graph_nodes.name_embedding`` scoped to a source."""
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT id, name, description,
(name_embedding <=> %s::vector) AS distance
FROM graph_nodes
WHERE source_id = %s AND name_embedding IS NOT NULL
ORDER BY name_embedding <=> %s::vector
LIMIT %s;
""",
(query_embedding, source_id, query_embedding, k),
)
rows = cursor.fetchall()
return [
{
"id": str(row[0]),
"name": row[1],
"description": row[2],
"distance": row[3],
}
for row in rows
]
except Exception as e:
logging.error(f"Error searching nodes by embedding: {e}")
return []
finally:
cursor.close()
conn.rollback()
def get_subgraph(
self, source_id: str, node_ids: List[str], hops: int = 1
) -> Dict[str, List[Dict[str, Any]]]:
"""Bounded 1-2-hop neighborhood of ``node_ids`` via indexed joins.
Expands the seed set one hop at a time over edges (no recursive PageRank
in SQL), capping node and edge counts so a hub never explodes the fetch.
"""
if not node_ids:
return {"nodes": [], "edges": []}
conn = self._get_connection()
cursor = conn.cursor()
try:
frontier = set(str(n) for n in node_ids)
visited = set(frontier)
for _ in range(max(1, hops)):
if not frontier or len(visited) >= MAX_SUBGRAPH_NODES:
break
cursor.execute(
"""
SELECT src_node_id, dst_node_id
FROM graph_edges
WHERE source_id = %s
AND (src_node_id = ANY(%s) OR dst_node_id = ANY(%s))
LIMIT %s;
""",
(
source_id,
list(frontier),
list(frontier),
MAX_SUBGRAPH_EDGES,
),
)
next_frontier = set()
for src, dst in cursor.fetchall():
for neighbor in (str(src), str(dst)):
if neighbor not in visited:
next_frontier.add(neighbor)
if len(visited) + len(next_frontier) > MAX_SUBGRAPH_NODES:
allowed = MAX_SUBGRAPH_NODES - len(visited)
next_frontier = set(sorted(next_frontier)[:allowed])
visited |= next_frontier
frontier = next_frontier
node_id_list = list(visited)
cursor.execute(
"""
SELECT id, name, type, description, degree, doc_freq
FROM graph_nodes
WHERE source_id = %s AND id = ANY(%s);
""",
(source_id, node_id_list),
)
nodes = [
{
"id": str(row[0]),
"name": row[1],
"type": row[2],
"description": row[3],
"degree": row[4],
"doc_freq": row[5],
}
for row in cursor.fetchall()
]
cursor.execute(
"""
SELECT id, src_node_id, dst_node_id, type, weight
FROM graph_edges
WHERE source_id = %s
AND src_node_id = ANY(%s) AND dst_node_id = ANY(%s)
LIMIT %s;
""",
(source_id, node_id_list, node_id_list, MAX_SUBGRAPH_EDGES),
)
edges = [
{
"id": str(row[0]),
"src_node_id": str(row[1]),
"dst_node_id": str(row[2]),
"type": row[3],
"weight": row[4],
}
for row in cursor.fetchall()
]
return {"nodes": nodes, "edges": edges}
except Exception as e:
logging.error(f"Error getting subgraph: {e}")
return {"nodes": [], "edges": []}
finally:
cursor.close()
conn.rollback()
def get_graph_overview(
self, source_id: str, limit: int = GRAPH_OVERVIEW_DEFAULT_LIMIT
) -> Dict[str, List[Dict[str, Any]]]:
"""Top-``limit`` nodes by degree and the edges among them.
Bounds the visualization: the top nodes by degree are selected, then only
edges whose endpoints are both in that set are returned (edge ids
reference node ids). ``limit`` is clamped to ``GRAPH_OVERVIEW_MAX_LIMIT``.
"""
limit = max(1, min(int(limit), GRAPH_OVERVIEW_MAX_LIMIT))
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT id, name, type, description, degree
FROM graph_nodes
WHERE source_id = %s
ORDER BY degree DESC, id
LIMIT %s;
""",
(source_id, limit),
)
nodes = [
{
"id": str(row[0]),
"name": row[1],
"type": row[2],
"description": row[3],
"degree": row[4],
}
for row in cursor.fetchall()
]
if not nodes:
return {"nodes": [], "edges": []}
node_ids = [n["id"] for n in nodes]
cursor.execute(
"""
SELECT src_node_id, dst_node_id, type, weight
FROM graph_edges
WHERE source_id = %s
AND src_node_id = ANY(%s) AND dst_node_id = ANY(%s)
LIMIT %s;
""",
(source_id, node_ids, node_ids, MAX_SUBGRAPH_EDGES),
)
edges = [
{
"source": str(row[0]),
"target": str(row[1]),
"type": row[2],
"weight": row[3],
}
for row in cursor.fetchall()
]
return {"nodes": nodes, "edges": edges}
except Exception as e:
logging.error(f"Error getting graph overview: {e}")
return {"nodes": [], "edges": []}
finally:
cursor.close()
conn.rollback()
def get_chunk_ids_for_nodes(
self, source_id: str, node_ids: List[str]
) -> Dict[str, List[str]]:
if not node_ids:
return {}
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT node_id, chunk_id
FROM graph_node_chunks
WHERE source_id = %s AND node_id = ANY(%s);
""",
(source_id, [str(n) for n in node_ids]),
)
result: Dict[str, List[str]] = {}
for node_id, chunk_id in cursor.fetchall():
result.setdefault(str(node_id), []).append(chunk_id)
return result
except Exception as e:
logging.error(f"Error getting chunk ids for nodes: {e}")
return {}
finally:
cursor.close()
conn.rollback()
def get_chunk_texts(
self,
source_id: str,
chunk_ids: List[str],
) -> Dict[str, Dict[str, Any]]:
"""Map chunk ids to ``{"text": ..., "metadata": {...}}`` from the pgvector table.
Reads the co-located documents table, deriving its name and the text,
metadata and source-id column names from the same defaults
``PGVectorStore`` uses so a customized deployment still resolves. Chunk
ids are pgvector document ids (SERIAL) cast to text to match the
JSONB-sourced string ids without per-id round trips.
"""
if not chunk_ids:
return {}
table, text_col, metadata_col, source_col = _pgvector_identifiers()
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
f"""
SELECT id, {text_col}, {metadata_col} FROM {table}
WHERE {source_col} = %s AND id::text = ANY(%s);
""",
(source_id, [str(c) for c in chunk_ids]),
)
return {
str(row[0]): {"text": row[1], "metadata": row[2] or {}}
for row in cursor.fetchall()
}
except Exception as e:
logging.error(f"Error getting chunk texts: {e}")
return {}
finally:
cursor.close()
conn.rollback()
def get_node_detail(
self, source_id: str, node_id: str, max_chunks: int = 20
) -> Optional[Dict[str, Any]]:
"""A node's full record plus a bounded list of its linked chunks.
Returns ``None`` when the node does not belong to the source. Chunk texts
are read from the co-located pgvector table; at most ``max_chunks`` are
returned so a hub node never streams an unbounded payload.
"""
conn = self._get_connection()
try:
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT id, name, type, description, degree, doc_freq
FROM graph_nodes
WHERE source_id = %s AND id = %s;
""",
(source_id, node_id),
)
row = cursor.fetchone()
finally:
cursor.close()
if row is None:
return None
node = {
"id": str(row[0]),
"name": row[1],
"type": row[2],
"description": row[3],
"degree": row[4],
"doc_freq": row[5],
}
chunk_ids = self.get_chunk_ids_for_nodes(source_id, [node_id]).get(
str(node_id), []
)[: max(0, int(max_chunks))]
texts = (
self.get_chunk_texts(source_id, chunk_ids) if chunk_ids else {}
)
node["chunks"] = [
{
"chunk_id": cid,
"text": texts.get(cid, {}).get("text", ""),
"metadata": texts.get(cid, {}).get("metadata", {}),
}
for cid in chunk_ids
]
return node
except Exception as e:
logging.error(f"Error getting node detail: {e}")
return None
finally:
conn.rollback()
def set_node_degrees(self, source_id: str):
"""Recompute every node's degree from its incident edges for a source.
A self-loop counts once, matching ``add_edge``'s incremental update
(``WHERE id IN (src, dst)`` bumps the endpoint a single time when
``src == dst``). ``UNION`` deduplicates the two endpoints of each edge.
"""
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
UPDATE graph_nodes n
SET degree = COALESCE(d.deg, 0)
FROM (
SELECT node_id, count(*) AS deg
FROM (
SELECT id, src_node_id AS node_id FROM graph_edges
WHERE source_id = %s
UNION
SELECT id, dst_node_id AS node_id FROM graph_edges
WHERE source_id = %s
) incident
GROUP BY node_id
) d
WHERE n.source_id = %s AND n.id = d.node_id;
""",
(source_id, source_id, source_id),
)
conn.commit()
except Exception as e:
conn.rollback()
logging.error(f"Error setting node degrees: {e}")
raise
finally:
cursor.close()
def mark_chunk(self, source_id: str, chunk_id: str, status: str):
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
INSERT INTO graph_ingest_progress (source_id, chunk_id, status)
VALUES (%s, %s, %s)
ON CONFLICT (source_id, chunk_id) DO UPDATE SET status = EXCLUDED.status;
""",
(source_id, str(chunk_id), status),
)
conn.commit()
except Exception as e:
conn.rollback()
logging.error(f"Error marking chunk: {e}")
raise
finally:
cursor.close()
def pending_chunks(self, source_id: str, all_chunk_ids: List[str]) -> List[str]:
"""Chunk ids from ``all_chunk_ids`` not yet marked ``done`` for the source."""
if not all_chunk_ids:
return []
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT chunk_id FROM graph_ingest_progress
WHERE source_id = %s AND status = 'done';
""",
(source_id,),
)
done = {row[0] for row in cursor.fetchall()}
return [str(c) for c in all_chunk_ids if str(c) not in done]
except Exception as e:
logging.error(f"Error getting pending chunks: {e}")
return [str(c) for c in all_chunk_ids]
finally:
cursor.close()
conn.rollback()
def get_progress(self, source_id: str) -> Dict[str, str]:
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT chunk_id, status FROM graph_ingest_progress "
"WHERE source_id = %s;",
(source_id,),
)
return {row[0]: row[1] for row in cursor.fetchall()}
except Exception as e:
logging.error(f"Error getting progress: {e}")
return {}
finally:
cursor.close()
conn.rollback()
def delete_by_source(self, source_id: str):
"""Remove every graph row for a source (no FK cascade across clusters)."""
conn = self._get_connection()
cursor = conn.cursor()
try:
for table in (
"graph_node_chunks",
"graph_edges",
"graph_nodes",
"graph_ingest_progress",
):
cursor.execute(
f"DELETE FROM {table} WHERE source_id = %s;", (source_id,)
)
conn.commit()
except Exception as e:
conn.rollback()
logging.error(f"Error deleting graph by source: {e}")
raise
finally:
cursor.close()
def __del__(self):
if (
hasattr(self, "_connection")
and self._connection
and not self._connection.closed
):
self._connection.close()