"""Knowledge Graph Builder — builds graph from ContentList. Inspired by RAG-Anything / LightRAG's multi-modal knowledge graph approach: 1. Structural entities — every content item becomes a graph node 2. LLM semantic extraction — entities + relationships from text chunks 3. Entity deduplication — merge same-named entities across chunks 4. Cross-modal relationship mapping 5. Hierarchical structure preservation ("belongs_to" chains) 6. Weighted relationship scoring """ import asyncio import hashlib import json as _json import re from typing import Callable, Optional from agentic_rag.services.knowledge.content_list import ContentItem, ContentList, ContentType from agentic_rag.services.knowledge.graph.index import Entity, KnowledgeGraph, Relation from agentic_rag.config.prompts import Prompts def _stable_id(name: str, prefix: str = "ent") -> str: """Generate a stable, deterministic entity ID from its name.""" h = hashlib.md5(name.strip().lower().encode("utf-8")).hexdigest()[:12] return f"{prefix}-{h}" def _normalize_name(name: str) -> str: """Normalize entity name for deduplication.""" return name.strip().strip("'\".,;:!?()[]{}<>《》").strip() class GraphBuilder: """Builds a knowledge graph from a ContentList. Two-layer construction: - **Structural layer**: every content item → entity, with belongs_to/nearby/describes edges - **Semantic layer** (optional, requires LLM): extracts fine-grained entities and semantic relationships from text chunks, deduplicates by name, and links to structural entities via "appears_in" edges. """ def __init__( self, llm_func: Optional[Callable] = None, extract_entities: bool = True, ): """ Args: llm_func: LLM function (text) -> str for entity/relation extraction. extract_entities: Whether to perform semantic entity extraction from text. """ self.llm_func = llm_func self.extract_entities = extract_entities # ═══════════════════════════════════════════════════════════ # Main entry point # ═══════════════════════════════════════════════════════════ async def build(self, content_list: ContentList, doc_id: str = "", existing_graph: KnowledgeGraph | None = None) -> KnowledgeGraph: """Build (or extend) a knowledge graph from a ContentList. Args: content_list: Parsed and processed document content. doc_id: Document identifier (used as namespace). existing_graph: If provided, extend this graph instead of creating new. Returns: Populated KnowledgeGraph. """ graph = existing_graph if existing_graph is not None else KnowledgeGraph() import sys # ── Layer 1: Structural entities ────────────────────── structural_eids = [] for i, item in enumerate(content_list.items): entity = await self._item_to_entity(item, i) eid = graph.add_entity(entity) structural_eids.append((eid, item)) # ── Document entity ─────────────────────────────────── doc_name = content_list.source or "Document" if hasattr(doc_name, "split"): doc_name = doc_name.split("/")[-1] # filename only doc_entity = Entity( entity_id=_stable_id(doc_name, "doc"), name=doc_name, entity_type="document", properties={"doc_id": doc_id}, ) doc_eid = graph.add_entity(doc_entity) for eid, _ in structural_eids: graph.add_relation(eid, doc_eid, "belongs_to", weight=1.0) # ── Layer 2: Page-level adjacency ───────────────────── page_entities: dict[int, list[str]] = {} for eid, item in structural_eids: page_entities.setdefault(item.page_idx, []).append(eid) for page, eids in page_entities.items(): for i in range(len(eids) - 1): graph.add_relation(eids[i], eids[i + 1], "nearby", weight=0.8) # ── Layer 3: Cross-modal references ─────────────────── for page, eids in page_entities.items(): page_ents = [graph.get_entity(eid) for eid in eids] page_ents = [e for e in page_ents if e is not None] text_ents = [e for e in page_ents if e.type == "text_chunk"] visual_ents = [e for e in page_ents if e.type in ("image", "table", "equation")] for text_e in text_ents: for visual_e in visual_ents: graph.add_relation( visual_e.id, text_e.id, "describes", weight=0.6, metadata={"page_idx": page}, ) # ── Layer 4: Semantic entity extraction (LLM) ──────── if self.extract_entities and self.llm_func: all_semantic_entities: dict[str, str] = {} # normalized_name -> eid text_chunks = [ (eid, item) for eid, item in structural_eids if item.type == ContentType.TEXT and item.text and len(item.text.strip()) > 30 ] for eid, item in text_chunks: try: chunk_entities, chunk_relations = await self._extract_from_chunk( item.text ) # Register semantic entities + "appears_in" links for ent in chunk_entities: norm = _normalize_name(ent["name"]) if norm in all_semantic_entities: sem_eid = all_semantic_entities[norm] else: sem_entity = Entity( entity_id=_stable_id(norm, "ent"), name=ent["name"], entity_type=ent.get("type", "CONCEPT").lower(), properties={ "description": ent.get("description", ""), "source_doc": doc_name, }, ) sem_eid = graph.add_entity(sem_entity) all_semantic_entities[norm] = sem_eid # Link semantic entity → structural chunk graph.add_relation( sem_eid, eid, "appears_in", weight=0.9, metadata={"chunk_type": "text"}, ) # Register semantic relationships for rel in chunk_relations: src_norm = _normalize_name(rel["source"]) tgt_norm = _normalize_name(rel["target"]) if src_norm in all_semantic_entities and tgt_norm in all_semantic_entities: src_eid = all_semantic_entities[src_norm] tgt_eid = all_semantic_entities[tgt_norm] if src_eid != tgt_eid: # skip self-loops graph.add_relation( src_eid, tgt_eid, rel.get("keywords", "related_to").split(",")[0].strip(), weight=0.85, metadata={ "keywords": rel.get("keywords", ""), "description": rel.get("description", ""), }, ) except Exception as e: print(f" [GraphBuilder] ⚠ Semantic extraction failed for chunk: {e}", flush=True) sys.stdout.flush() print(f" [GraphBuilder] Semantic: {len(all_semantic_entities)} unique entities " f"from {len(text_chunks)} chunks", flush=True) sys.stdout.flush() return graph # ═══════════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════════ async def _extract_from_chunk(self, text: str) -> tuple[list[dict], list[dict]]: """Extract semantic entities and relationships from a text chunk via LLM. Returns: (entities_list, relationships_list) — each entity dict has name/type/description; each relation has source/target/keywords/description. """ import sys prompt = Prompts.KG_ENTITY_EXTRACTION.format(text=text[:2000]) try: raw = await self.llm_func(prompt) # Extract result from callable (may return str or object) if hasattr(raw, "content"): raw = raw.content elif hasattr(raw, "choices") and raw.choices: raw = raw.choices[0].message.content raw = str(raw).strip() if raw else "" except Exception as e: print(f" [GraphBuilder] ⚠ LLM call failed: {e}", flush=True) sys.stdout.flush() return [], [] if not raw: print(f" [GraphBuilder] ⚠ LLM returned empty response", flush=True) sys.stdout.flush() return [], [] # Strip Qwen ... reasoning blocks (if present) raw = re.sub(r'[\s\S]*?', '', raw).strip() if not raw: print(f" [GraphBuilder] ⚠ LLM response was all block", flush=True) sys.stdout.flush() return [], [] # Parse JSON from LLM output. # Qwen reasoning models prepend a "thinking process" before the actual # JSON — we must skip that and find the real output block. json_str = raw # Strategy 1: Find ```json fenced block (most reliable) fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', raw) if fence_match: json_str = fence_match.group(1).strip() else: # Strategy 2: Find the outermost JSON object that contains # "entities" key. Reasoning models put thinking text BEFORE the # JSON, so we scan from each `{` and pick the first one that # produces a complete, parseable dict with the right keys. json_str = "" for m in re.finditer(r'\{', raw): start = m.start() candidate = raw[start:] depth = 0 end_pos = -1 for i, ch in enumerate(candidate): if ch == '{': depth += 1 elif ch == '}': depth -= 1 if depth == 0: end_pos = i + 1 break if end_pos > 0: candidate = candidate[:end_pos].strip() # Quick check: does it look like our target JSON? if '"entities"' in candidate or '"entity"' in candidate: try: _json.loads(candidate) json_str = candidate break # found it except _json.JSONDecodeError: continue # try next `{` # Strategy 3: Look for known JSON keys as a fallback if not json_str or '{' not in json_str: for marker in ('"entities"', '"entity"'): idx = raw.find(marker) if idx > 0: brace_idx = raw.rfind('{', 0, idx) if brace_idx >= 0: json_str = raw[brace_idx:] depth = 0 end_pos = -1 for i, ch in enumerate(json_str): if ch == '{': depth += 1 elif ch == '}': depth -= 1 if depth == 0: end_pos = i + 1 break if end_pos > 0: json_str = json_str[:end_pos].strip() break try: data = _json.loads(json_str) except _json.JSONDecodeError: # Attempt repair: try to fix common LLM JSON mistakes try: import json as _json_module data = _json.loads(json_str.replace("'", '"')) except Exception: print(f" [GraphBuilder] ⚠ JSON parse failed, raw={raw[:120]}", flush=True) sys.stdout.flush() return [], [] if not isinstance(data, dict): print(f" [GraphBuilder] ⚠ Unexpected LLM output type: {type(data).__name__}", flush=True) sys.stdout.flush() return [], [] entities = data.get("entities", []) or data.get("entity", []) or [] relationships = data.get("relationships", []) or data.get("relations", []) or data.get("relationship", []) or [] # Validate valid_entities = [] for e in entities: if isinstance(e, dict) and e.get("name", "").strip(): valid_entities.append({ "name": _normalize_name(e["name"]), "type": str(e.get("type", "CONCEPT")).upper(), "description": str(e.get("description", ""))[:200], }) valid_relations = [] for r in relationships: if isinstance(r, dict) and r.get("source", "").strip() and r.get("target", "").strip(): valid_relations.append({ "source": _normalize_name(r["source"]), "target": _normalize_name(r["target"]), "keywords": str(r.get("keywords", "related_to"))[:100], "description": str(r.get("description", ""))[:200], }) if not valid_entities: print(f" [GraphBuilder] ⚠ No valid entities extracted from chunk " f"(text={text[:60]}...)", flush=True) sys.stdout.flush() return valid_entities, valid_relations async def _item_to_entity(self, item: ContentItem, index: int) -> Entity: """Convert a ContentItem to a structural graph Entity.""" type_map = { ContentType.TEXT: "text_chunk", ContentType.IMAGE: "image", ContentType.TABLE: "table", ContentType.EQUATION: "equation", ContentType.VIDEO: "video_frame", ContentType.AUDIO: "audio_segment", ContentType.CODE: "code_block", } entity_type = type_map.get(item.type, "unknown") searchable = item.to_searchable_text() # Entity name: extract a meaningful title from the chunk text. # Strategy (in order of preference): # 1. Section header: 【...】 or \n...\n # 2. First complete sentence (ends with 。!?) # 3. First 50 chars, trimmed at word boundary if entity_type == "text_chunk" and searchable: text = searchable.strip() import re as _re # Try section header pattern: 【XXX】 or 第X页 sec = _re.search(r'【(.+?)】', text) if sec: name = sec.group(0) # e.g. "【药品名称】" else: # Try first line if it's a short heading first_line = text.split('\n')[0].strip() if len(first_line) <= 30 and not first_line.endswith(('。', '!', '?')): name = first_line else: # Find first complete sentence name = text[:80] for sep in ("。", "!", "?", ";"): idx = name.find(sep) if 15 < idx < 70: name = name[:idx + 1] break # If name is still too long, trim at 50 chars if len(name) > 50: name = name[:50] + "…" elif searchable: name = searchable[:50] else: name = f"{entity_type}_{index}" # Generate stable ID from content hash id_src = (item.text or item.img_path or item.video_path or item.audio_path or str(index)) eid = _stable_id(id_src[:200], entity_type[:6]) return Entity( entity_id=eid, name=name, entity_type=entity_type, content_item=item, properties={ "page_idx": item.page_idx, "content_type": item.type.value, "has_caption": bool(item.img_caption or item.table_caption or item.video_caption), "index": index, }, )