Spaces:
Runtime error
Runtime error
| #!/usr/bin/env python3 | |
| """ | |
| AI Knowledge Graph Chat Application | |
| ==================================== | |
| A production-quality single-file AI chat application that builds a knowledge | |
| graph from uploaded documents and answers questions using LangGraph orchestration. | |
| Features: | |
| β’ Multi-provider LLM support (Groq, Gemini, Cohere, Cerebras) | |
| β’ Document upload & processing (PDF, DOCX, TXT, CSV, XLSX, JSON, MD, HTML) | |
| β’ LLM-driven knowledge-graph extraction (entities + relationships β triples) | |
| β’ Neo4j storage with automatic in-memory fallback | |
| β’ SHA-256-based triple deduplication and incremental updates | |
| β’ File deletion with surgical graph cleanup | |
| β’ Intent detection and intelligent routing via LangGraph | |
| β’ Streaming responses with stop/clear controls | |
| β’ Modern Gradio UI + REST API on a single port | |
| Deployment: | |
| python app.py | |
| """ | |
| # ============================================================ | |
| # SECTION 1: IMPORTS | |
| # ============================================================ | |
| import os | |
| import re | |
| import json | |
| import time | |
| import uuid | |
| import asyncio | |
| import hashlib | |
| import logging | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import ( | |
| Any, AsyncGenerator, Dict, List, Optional, Tuple, Union, Sequence | |
| ) | |
| # --- Third-party core --- | |
| import pandas as pd | |
| from pydantic import BaseModel, Field | |
| # --- FastAPI --- | |
| from fastapi import FastAPI, UploadFile, File, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| import uvicorn | |
| # --- Gradio --- | |
| import gradio as gr | |
| # --- LangChain core --- | |
| from langchain_core.messages import ( | |
| HumanMessage, AIMessage, SystemMessage, BaseMessage | |
| ) | |
| from langchain_core.language_models.chat_models import BaseChatModel | |
| from langchain_core.outputs import ChatResult, ChatGeneration | |
| # --- LangGraph --- | |
| from langgraph.graph import StateGraph, END | |
| from typing_extensions import TypedDict | |
| # --- Optional LLM provider imports (gracefully degrade) --- | |
| try: | |
| from langchain_groq import ChatGroq | |
| _GROQ_OK = True | |
| except Exception: | |
| _GROQ_OK = False | |
| try: | |
| from langchain_google_genai import ChatGoogleGenerativeAI | |
| _GEMINI_OK = True | |
| except Exception: | |
| _GEMINI_OK = False | |
| try: | |
| from langchain_cohere import ChatCohere | |
| _COHERE_OK = True | |
| except Exception: | |
| _COHERE_OK = False | |
| try: | |
| from langchain_cerebras import ChatCerebras | |
| _CEREBRAS_OK = True | |
| except Exception: | |
| _CEREBRAS_OK = False | |
| # --- Optional file-processing imports --- | |
| try: | |
| import fitz # PyMuPDF | |
| _PYMUPDF_OK = True | |
| except Exception: | |
| _PYMUPDF_OK = False | |
| try: | |
| from docx import Document as DocxDocument | |
| _DOCX_OK = True | |
| except Exception: | |
| _DOCX_OK = False | |
| try: | |
| from bs4 import BeautifulSoup | |
| _BS4_OK = True | |
| except Exception: | |
| _BS4_OK = False | |
| # --- Neo4j --- | |
| try: | |
| from neo4j import GraphDatabase | |
| from neo4j.exceptions import ServiceUnavailable, AuthError, CypherSyntaxError | |
| _NEO4J_OK = True | |
| except Exception: | |
| _NEO4J_OK = False | |
| # ============================================================ | |
| # SECTION 2: CONFIGURATION & CONSTANTS | |
| # ============================================================ | |
| class Config: | |
| """Central configuration. Override via environment variables.""" | |
| # Server | |
| HOST: str = os.getenv("HOST", "0.0.0.0") | |
| PORT: int = int(os.getenv("PORT", "7860")) | |
| # Upload limits | |
| MAX_UPLOAD_SIZE_MB: int = int(os.getenv("MAX_UPLOAD_SIZE_MB", "50")) | |
| UPLOAD_DIR: Path = Path(os.getenv("UPLOAD_DIR", "./data/uploads")) | |
| UPLOAD_DIR.mkdir(parents=True, exist_ok=True) | |
| # Neo4j | |
| NEO4J_URI: str = os.getenv("NEO4J_URI", "") | |
| NEO4J_USERNAME: str = os.getenv("NEO4J_USERNAME", "neo4j") | |
| NEO4J_PASSWORD: str = os.getenv("NEO4J_PASSWORD", "") | |
| # LLM defaults | |
| DEFAULT_PROVIDER: str = os.getenv("DEFAULT_PROVIDER", "groq") | |
| DEFAULT_MODELS: Dict[str, str] = { | |
| "groq": os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile"), | |
| "gemini": os.getenv("GEMINI_MODEL", "gemini-1.5-flash"), | |
| "cohere": os.getenv("COHERE_MODEL", "command-r-plus"), | |
| "cerebras": os.getenv("CEREBRAS_MODEL", "llama-3.3-70b"), | |
| } | |
| # Extraction | |
| CHUNK_SIZE: int = int(os.getenv("CHUNK_SIZE", "4000")) | |
| MAX_TRIPLES_PER_CHUNK: int = int(os.getenv("MAX_TRIPLES_PER_CHUNK", "50")) | |
| # Supported file types | |
| SUPPORTED_EXTENSIONS: Tuple[str, ...] = ( | |
| ".pdf", ".docx", ".txt", ".csv", ".xlsx", ".json", ".md", ".html" | |
| ) | |
| # Provider display metadata | |
| PROVIDERS: Dict[str, Dict[str, Any]] = { | |
| "groq": {"label": "Groq", "available": _GROQ_OK, "env_key": "GROQ_API_KEY"}, | |
| "gemini": {"label": "Gemini", "available": _GEMINI_OK, "env_key": "GOOGLE_API_KEY"}, | |
| "cohere": {"label": "Cohere", "available": _COHERE_OK, "env_key": "COHERE_API_KEY"}, | |
| "cerebras": {"label": "Cerebras", "available": _CEREBRAS_OK, "env_key": "CEREBRAS_API_KEY"}, | |
| } | |
| # Intent constants | |
| INTENT_GENERAL_CHAT = "GENERAL_CHAT" | |
| INTENT_KG_QUERY = "KNOWLEDGE_GRAPH_QUERY" | |
| INTENT_DOC_SEARCH = "DOCUMENT_SEARCH" | |
| INTENT_GREETING = "GREETING" | |
| INTENT_PROGRAMMING = "PROGRAMMING" | |
| INTENT_EXPLANATION = "EXPLANATION" | |
| ALL_INTENTS = [ | |
| INTENT_GENERAL_CHAT, INTENT_KG_QUERY, INTENT_DOC_SEARCH, | |
| INTENT_GREETING, INTENT_PROGRAMMING, INTENT_EXPLANATION, | |
| ] | |
| # ============================================================ | |
| # SECTION 3: PYDANTIC MODELS | |
| # ============================================================ | |
| class ChatRequest(BaseModel): | |
| """Request body for /chat endpoint.""" | |
| message: str = Field(..., min_length=1, max_length=10000) | |
| provider: str = Field(default=Config.DEFAULT_PROVIDER) | |
| history: List[Dict[str, str]] = Field(default_factory=list) | |
| class ChatResponse(BaseModel): | |
| """Response body for /chat endpoint.""" | |
| reply: str | |
| intent: str | |
| execution_path: List[str] | |
| sources: List[str] | |
| class UploadResponse(BaseModel): | |
| """Response body for /upload endpoint.""" | |
| filename: str | |
| triples_inserted: int | |
| triples_skipped: int | |
| entities: int | |
| relationships: int | |
| processing_time: float | |
| message: str = "" | |
| class FileMetadata(BaseModel): | |
| """Metadata for an uploaded file.""" | |
| filename: str | |
| upload_date: str | |
| file_hash: str | |
| size_bytes: int | |
| triples: int | |
| class GraphStats(BaseModel): | |
| """Knowledge-graph statistics.""" | |
| documents: int | |
| entities: int | |
| relationships: int | |
| class ProviderRequest(BaseModel): | |
| """Request body for /provider endpoint.""" | |
| provider: str | |
| class Triple(BaseModel): | |
| """A knowledge-graph triple.""" | |
| subject: str | |
| relation: str | |
| object: str | |
| confidence: float = 0.8 | |
| # ============================================================ | |
| # SECTION 4: LOGGING SETUP | |
| # ============================================================ | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", | |
| datefmt="%Y-%m-%d %H:%M:%S", | |
| ) | |
| logger = logging.getLogger("kg_app") | |
| # ============================================================ | |
| # SECTION 5: LLM PROVIDER MANAGER | |
| # ============================================================ | |
| class MockChatModel(BaseChatModel): | |
| """Fallback chat model used when no API key is configured.""" | |
| def _generate(self, messages, stop=None, run_manager=None, **kwargs): | |
| last = messages[-1].content if messages else "" | |
| text = ( | |
| f"[Mock LLM] No API key configured for the selected provider.\n\n" | |
| f"Your message was: {last[:200]}\n\n" | |
| f"Set the appropriate environment variable (e.g. GROQ_API_KEY) " | |
| f"to enable real LLM responses." | |
| ) | |
| return ChatResult(generations=[ChatGeneration(message=AIMessage(content=text))]) | |
| def _llm_type(self) -> str: | |
| return "mock" | |
| class LLMProviderManager: | |
| """ | |
| Single abstraction over all supported LLM providers. | |
| Caches client instances so repeated calls reuse the same client. | |
| Switching providers is transparent to the rest of the application. | |
| """ | |
| def __init__(self): | |
| self._cache: Dict[str, BaseChatModel] = {} | |
| def _normalise(self, provider: str) -> str: | |
| return provider.lower().strip() | |
| def is_available(self, provider: str) -> bool: | |
| """Check whether *provider* is installed AND has an API key.""" | |
| p = self._normalise(provider) | |
| meta = PROVIDERS.get(p) | |
| if not meta or not meta["available"]: | |
| return False | |
| return bool(os.getenv(meta["env_key"])) | |
| def get_llm(self, provider: str, **kwargs) -> BaseChatModel: | |
| """ | |
| Return a (possibly cached) chat model for *provider*. | |
| Falls back to MockChatModel when the real provider is unavailable. | |
| """ | |
| p = self._normalise(provider) | |
| model_name = kwargs.get("model", Config.DEFAULT_MODELS.get(p, "")) | |
| cache_key = f"{p}::{model_name}" | |
| if cache_key in self._cache: | |
| return self._cache[cache_key] | |
| llm = self._create(p, model_name=model_name, **kwargs) | |
| self._cache[cache_key] = llm | |
| logger.info("LLM provider initialised: %s (model=%s, type=%s)", | |
| p, model_name, type(llm).__name__) | |
| return llm | |
| def _create(self, provider: str, *, model_name: str, **kwargs) -> BaseChatModel: | |
| temperature = kwargs.get("temperature", 0.7) | |
| if provider == "groq" and _GROQ_OK: | |
| key = os.getenv("GROQ_API_KEY") | |
| if key: | |
| return ChatGroq(model=model_name, temperature=temperature, api_key=key) | |
| if provider == "gemini" and _GEMINI_OK: | |
| key = os.getenv("GOOGLE_API_KEY") | |
| if key: | |
| return ChatGoogleGenerativeAI( | |
| model=model_name, temperature=temperature, google_api_key=key | |
| ) | |
| if provider == "cohere" and _COHERE_OK: | |
| key = os.getenv("COHERE_API_KEY") | |
| if key: | |
| return ChatCohere(model=model_name, temperature=temperature, cohere_api_key=key) | |
| if provider == "cerebras" and _CEREBRAS_OK: | |
| key = os.getenv("CEREBRAS_API_KEY") | |
| if key: | |
| return ChatCerebras(model=model_name, temperature=temperature, api_key=key) | |
| logger.warning("Provider '%s' unavailable β using MockChatModel", provider) | |
| return MockChatModel() | |
| def list_providers(self) -> List[Dict[str, Any]]: | |
| """Return provider info for UI rendering.""" | |
| result = [] | |
| for key, meta in PROVIDERS.items(): | |
| result.append({ | |
| "key": key, | |
| "label": meta["label"], | |
| "available": self.is_available(key), | |
| }) | |
| return result | |
| # Singleton | |
| provider_manager = LLMProviderManager() | |
| # ============================================================ | |
| # SECTION 6: FILE PROCESSORS | |
| # ============================================================ | |
| class FileProcessor: | |
| """Detect file type and extract clean text.""" | |
| def detect_type(filepath: str) -> str: | |
| ext = Path(filepath).suffix.lower() | |
| if ext not in Config.SUPPORTED_EXTENSIONS: | |
| raise ValueError(f"Unsupported file type: {ext}") | |
| return ext | |
| def extract_text(filepath: str) -> str: | |
| """Dispatch to the correct extractor based on file extension.""" | |
| ext = FileProcessor.detect_type(filepath) | |
| extractors = { | |
| ".pdf": FileProcessor._extract_pdf, | |
| ".docx": FileProcessor._extract_docx, | |
| ".txt": FileProcessor._extract_text, | |
| ".csv": FileProcessor._extract_csv, | |
| ".xlsx": FileProcessor._extract_xlsx, | |
| ".json": FileProcessor._extract_json, | |
| ".md": FileProcessor._extract_text, | |
| ".html": FileProcessor._extract_html, | |
| } | |
| extractor = extractors.get(ext, FileProcessor._extract_text) | |
| text = extractor(filepath) | |
| # Clean whitespace | |
| text = re.sub(r"[ \t]+", " ", text) | |
| text = re.sub(r"\n{3,}", "\n\n", text).strip() | |
| if not text: | |
| raise ValueError("No readable text found in file.") | |
| return text | |
| def _extract_pdf(filepath: str) -> str: | |
| if not _PYMUPDF_OK: | |
| raise RuntimeError("PyMuPDF not installed β cannot process PDF.") | |
| doc = fitz.open(filepath) | |
| pages = [page.get_text("text") for page in doc] | |
| doc.close() | |
| return "\n\n".join(pages) | |
| def _extract_docx(filepath: str) -> str: | |
| if not _DOCX_OK: | |
| raise RuntimeError("python-docx not installed β cannot process DOCX.") | |
| doc = DocxDocument(filepath) | |
| return "\n".join(p.text for p in doc.paragraphs if p.text.strip()) | |
| def _extract_text(filepath: str) -> str: | |
| with open(filepath, "r", encoding="utf-8", errors="replace") as f: | |
| return f.read() | |
| def _extract_csv(filepath: str) -> str: | |
| df = pd.read_csv(filepath) | |
| return df.to_string(index=False) | |
| def _extract_xlsx(filepath: str) -> str: | |
| xl = pd.ExcelFile(filepath, engine="openpyxl") | |
| parts = [] | |
| for sheet in xl.sheet_names: | |
| df = xl.parse(sheet) | |
| parts.append(f"## Sheet: {sheet}\n{df.to_string(index=False)}") | |
| return "\n\n".join(parts) | |
| def _extract_json(filepath: str) -> str: | |
| with open(filepath, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| return json.dumps(data, indent=2, ensure_ascii=False) | |
| def _extract_html(filepath: str) -> str: | |
| with open(filepath, "r", encoding="utf-8", errors="replace") as f: | |
| raw = f.read() | |
| if _BS4_OK: | |
| soup = BeautifulSoup(raw, "html.parser") | |
| # Remove script/style | |
| for tag in soup(["script", "style"]): | |
| tag.decompose() | |
| return soup.get_text(separator="\n") | |
| # Crude fallback | |
| return re.sub(r"<[^>]+>", " ", raw) | |
| # ============================================================ | |
| # SECTION 7: KNOWLEDGE GRAPH EXTRACTOR | |
| # ============================================================ | |
| EXTRACTION_SYSTEM_PROMPT = """\ | |
| You are a knowledge-graph extraction engine. | |
| Extract entities and relationships from the user-provided text. | |
| Return STRICT JSON with this schema: | |
| {{ | |
| "entities": [ | |
| {{"name": "canonical entity name", "type": "Person|Organization|Technology|Location|Event|Concept|Date|Other"}} | |
| ], | |
| "relationships": [ | |
| {{"subject": "entity name", "relation": "lowercase_snake_case", "object": "entity name", "confidence": 0.0-1.0}} | |
| ] | |
| }} | |
| Rules: | |
| β’ Entity names MUST be normalised (canonical form, Title Case where appropriate). | |
| β’ Relations MUST be lowercase snake_case verbs or short phrases (e.g. "works_for", "located_in"). | |
| β’ Confidence is a float between 0 and 1. | |
| β’ Extract ONLY clear, factual relationships stated in the text. | |
| β’ Do NOT invent information. | |
| β’ Return at most {max_triples} relationships. | |
| β’ If no knowledge can be extracted, return {{"entities": [], "relationships": []}}. | |
| """ | |
| CYPHER_SYSTEM_PROMPT = """\ | |
| You are a Cypher query generator for a Neo4j knowledge graph. | |
| Graph schema: | |
| β’ Nodes: (:Entity {{name: string, type: string}}) | |
| β’ Relationships: (:Entity)-[:RELATES_TO {{relation: string}}]->(:Entity) | |
| Instructions: | |
| 1. Generate a Cypher query that retrieves information relevant to the user's question. | |
| 2. Use fuzzy matching where helpful: `toLower(n.name) CONTAINS toLower(keyword)`. | |
| 3. Limit results to 20 rows. | |
| 4. Return ONLY the Cypher query β no markdown, no explanation. | |
| Example: | |
| Question: "Who works at Acme?" | |
| Query: MATCH (s:Entity)-[r:RELATES_TO]->(o:Entity) | |
| WHERE toLower(r.relation) CONTAINS 'work' AND toLower(o.name) CONTAINS 'acme' | |
| RETURN s.name, r.relation, o.name LIMIT 20 | |
| """ | |
| class KnowledgeExtractor: | |
| """Use an LLM to extract structured knowledge from raw text.""" | |
| def __init__(self, provider_manager: LLMProviderManager): | |
| self._pm = provider_manager | |
| def _chunk_text(self, text: str, chunk_size: int = Config.CHUNK_SIZE) -> List[str]: | |
| """Split text into character chunks (NOT for RAG retrieval β for extraction).""" | |
| return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] | |
| def _parse_json_response(self, content: str) -> Dict[str, Any]: | |
| """Robustly extract JSON from an LLM response that may contain markdown fences.""" | |
| # Strip markdown code fences | |
| content = re.sub(r"```(?:json)?\s*", "", content) | |
| content = content.strip().rstrip("`") | |
| # Try direct parse | |
| try: | |
| return json.loads(content) | |
| except json.JSONDecodeError: | |
| pass | |
| # Try to find first { ... } block | |
| match = re.search(r"\{.*\}", content, re.DOTALL) | |
| if match: | |
| try: | |
| return json.loads(match.group()) | |
| except json.JSONDecodeError: | |
| pass | |
| logger.warning("Failed to parse LLM JSON response. Returning empty.") | |
| return {"entities": [], "relationships": []} | |
| def extract(self, text: str, provider: str) -> Tuple[List[Dict], List[Dict]]: | |
| """ | |
| Extract entities and relationships from *text*. | |
| Returns (entities, relationships) where each relationship is a triple dict. | |
| """ | |
| llm = self._pm.get_llm(provider, temperature=0.1) | |
| chunks = self._chunk_text(text) | |
| all_entities: List[Dict] = [] | |
| all_rels: List[Dict] = [] | |
| for idx, chunk in enumerate(chunks): | |
| logger.info("Extracting knowledge from chunk %d/%d (%d chars)", | |
| idx + 1, len(chunks), len(chunk)) | |
| messages = [ | |
| SystemMessage(content=EXTRACTION_SYSTEM_PROMPT.format( | |
| max_triples=Config.MAX_TRIPLES_PER_CHUNK | |
| )), | |
| HumanMessage(content=f"Extract knowledge from this text:\n\n{chunk}"), | |
| ] | |
| try: | |
| response = llm.invoke(messages) | |
| data = self._parse_json_response(response.content) | |
| all_entities.extend(data.get("entities", [])) | |
| all_rels.extend(data.get("relationships", [])) | |
| except Exception as e: | |
| logger.error("Extraction failed on chunk %d: %s", idx + 1, e) | |
| # Deduplicate entities by name (case-insensitive) | |
| seen_names: set = set() | |
| unique_entities: List[Dict] = [] | |
| for ent in all_entities: | |
| key = ent["name"].lower().strip() | |
| if key and key not in seen_names: | |
| seen_names.add(key) | |
| unique_entities.append(ent) | |
| # Normalise relationships | |
| normalised_rels: List[Dict] = [] | |
| for rel in all_rels: | |
| s = str(rel.get("subject", "")).strip() | |
| r = str(rel.get("relation", "")).strip().lower().replace(" ", "_") | |
| o = str(rel.get("object", "")).strip() | |
| conf = float(rel.get("confidence", 0.8)) | |
| if s and r and o: | |
| normalised_rels.append({ | |
| "subject": s, "relation": r, "object": o, | |
| "confidence": min(max(conf, 0.0), 1.0), | |
| }) | |
| return unique_entities, normalised_rels | |
| # ============================================================ | |
| # SECTION 8: GRAPH STORE (Neo4j with in-memory fallback) | |
| # ============================================================ | |
| class GraphStoreBase: | |
| """Abstract interface for graph storage backends.""" | |
| def register_document(self, file_id: str, filename: str, | |
| file_hash: str, upload_time: str) -> None: ... | |
| def add_triple(self, subject: str, relation: str, object_: str, | |
| file_id: str, filename: str, upload_time: str, | |
| version: int, confidence: float) -> bool: ... | |
| def delete_by_file(self, file_id: str) -> int: ... | |
| def get_stats(self) -> GraphStats: ... | |
| def search(self, query: str, limit: int = 20) -> List[Dict]: ... | |
| def get_file_triple_count(self, file_id: str) -> int: ... | |
| def close(self) -> None: ... | |
| # --- In-memory backend ---------------------------------------------------- | |
| class InMemoryGraphStore(GraphStoreBase): | |
| """ | |
| Pure-Python graph store used when Neo4j is not configured. | |
| Implements the same interface as the Neo4j backend. | |
| """ | |
| def __init__(self): | |
| self._documents: Dict[str, Dict] = {} # file_id -> doc meta | |
| self._nodes: Dict[str, Dict] = {} # normalised name -> node | |
| self._rels: Dict[str, Dict] = {} # triple_hash -> relationship | |
| def _triple_hash(subject: str, relation: str, object_: str) -> str: | |
| raw = f"{subject.lower().strip()}|{relation.lower().strip()}|{object_.lower().strip()}" | |
| return hashlib.sha256(raw.encode("utf-8")).hexdigest() | |
| def register_document(self, file_id, filename, file_hash, upload_time): | |
| self._documents[file_id] = { | |
| "filename": filename, | |
| "file_hash": file_hash, | |
| "upload_time": upload_time, | |
| } | |
| def add_triple(self, subject, relation, object_, file_id, filename, | |
| upload_time, version, confidence): | |
| h = self._triple_hash(subject, relation, object_) | |
| if h in self._rels: | |
| # Triple exists β just add source if not already recorded | |
| rel = self._rels[h] | |
| if file_id not in rel["source_files"]: | |
| rel["source_files"].append(file_id) | |
| rel["filenames"].append(filename) | |
| rel["upload_times"].append(upload_time) | |
| rel["versions"].append(version) | |
| rel["confidences"].append(confidence) | |
| return False # skipped (already existed) | |
| # Merge nodes | |
| for name in (subject, object_): | |
| key = name.lower().strip() | |
| if key not in self._nodes: | |
| self._nodes[key] = { | |
| "name": name, | |
| "source_files": [], | |
| "filenames": [], | |
| "upload_times": [], | |
| "versions": [], | |
| "confidences": [], | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| } | |
| node = self._nodes[key] | |
| if file_id not in node["source_files"]: | |
| node["source_files"].append(file_id) | |
| node["filenames"].append(filename) | |
| node["upload_times"].append(upload_time) | |
| node["versions"].append(version) | |
| node["confidences"].append(confidence) | |
| self._rels[h] = { | |
| "subject": subject, | |
| "relation": relation, | |
| "object": object_, | |
| "hash": h, | |
| "source_files": [file_id], | |
| "filenames": [filename], | |
| "upload_times": [upload_time], | |
| "versions": [version], | |
| "confidences": [confidence], | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| } | |
| return True # inserted | |
| def delete_by_file(self, file_id): | |
| deleted = 0 | |
| # Delete relationships | |
| to_del_rels = [] | |
| for h, rel in self._rels.items(): | |
| if file_id in rel["source_files"]: | |
| idx = rel["source_files"].index(file_id) | |
| rel["source_files"].pop(idx) | |
| rel["filenames"].pop(idx) | |
| rel["upload_times"].pop(idx) | |
| rel["versions"].pop(idx) | |
| rel["confidences"].pop(idx) | |
| if not rel["source_files"]: | |
| to_del_rels.append(h) | |
| deleted += 1 | |
| for h in to_del_rels: | |
| del self._rels[h] | |
| # Delete nodes whose source_files no longer include any file | |
| to_del_nodes = [] | |
| for key, node in self._nodes.items(): | |
| if file_id in node["source_files"]: | |
| idx = node["source_files"].index(file_id) | |
| node["source_files"].pop(idx) | |
| node["filenames"].pop(idx) | |
| node["upload_times"].pop(idx) | |
| node["versions"].pop(idx) | |
| node["confidences"].pop(idx) | |
| if not node["source_files"]: | |
| to_del_nodes.append(key) | |
| for key in to_del_nodes: | |
| del self._nodes[key] | |
| # Remove document registration | |
| self._documents.pop(file_id, None) | |
| return deleted | |
| def get_stats(self): | |
| return GraphStats( | |
| documents=len(self._documents), | |
| entities=len(self._nodes), | |
| relationships=len(self._rels), | |
| ) | |
| def search(self, query, limit=20): | |
| """Keyword-based search over triples.""" | |
| keywords = [w.lower().strip() for w in re.split(r"\s+", query) if len(w) > 2] | |
| results = [] | |
| for rel in self._rels.values(): | |
| text = f"{rel['subject']} {rel['relation']} {rel['object']}".lower() | |
| score = sum(1 for kw in keywords if kw in text) | |
| if score > 0: | |
| results.append({ | |
| "subject": rel["subject"], | |
| "relation": rel["relation"], | |
| "object": rel["object"], | |
| "sources": rel["filenames"], | |
| "score": score, | |
| }) | |
| results.sort(key=lambda x: x["score"], reverse=True) | |
| return results[:limit] | |
| def get_file_triple_count(self, file_id): | |
| return sum( | |
| 1 for rel in self._rels.values() | |
| if file_id in rel["source_files"] | |
| ) | |
| def close(self): | |
| pass | |
| # --- Neo4j backend -------------------------------------------------------- | |
| class Neo4jGraphStore(GraphStoreBase): | |
| """Neo4j-backed graph store. Falls back gracefully on connection errors.""" | |
| def __init__(self, uri: str, username: str, password: str): | |
| self._driver = GraphDatabase.driver(uri, auth=(username, password)) | |
| # Verify connectivity | |
| self._driver.verify_connectivity() | |
| logger.info("Connected to Neo4j at %s", uri) | |
| self._init_constraints() | |
| def _init_constraints(self): | |
| """Create uniqueness constraint on Entity.name if not exists.""" | |
| with self._driver.session() as session: | |
| try: | |
| session.run( | |
| "CREATE CONSTRAINT entity_name_unique IF NOT EXISTS " | |
| "FOR (n:Entity) REQUIRE n.name IS UNIQUE" | |
| ) | |
| except Exception as e: | |
| logger.warning("Could not create constraint: %s", e) | |
| def _triple_hash(subject, relation, object_): | |
| raw = f"{subject.lower().strip()}|{relation.lower().strip()}|{object_.lower().strip()}" | |
| return hashlib.sha256(raw.encode("utf-8")).hexdigest() | |
| def register_document(self, file_id, filename, file_hash, upload_time): | |
| with self._driver.session() as session: | |
| session.run( | |
| "MERGE (d:Document {file_id: $fid}) " | |
| "SET d.filename = $fn, d.file_hash = $fh, d.upload_time = $ut", | |
| fid=file_id, fn=filename, fh=file_hash, ut=upload_time, | |
| ) | |
| def add_triple(self, subject, relation, object_, file_id, filename, | |
| upload_time, version, confidence): | |
| h = self._triple_hash(subject, relation, object_) | |
| with self._driver.session() as session: | |
| # Check if triple already exists | |
| result = session.run( | |
| "MATCH ()-[r:RELATES_TO {hash: $h}]->() " | |
| "RETURN r.source_files AS sfs", | |
| h=h, | |
| ) | |
| record = result.single() | |
| if record is not None: | |
| # Triple exists β add source file if not present | |
| sfs = record["sfs"] or [] | |
| if file_id not in sfs: | |
| session.run( | |
| "MATCH ()-[r:RELATES_TO {hash: $h}]->() " | |
| "SET r.source_files = coalesce(r.source_files, []) + $fid, " | |
| " r.filenames = coalesce(r.filenames, []) + $fn, " | |
| " r.upload_times = coalesce(r.upload_times, []) + $ut, " | |
| " r.versions = coalesce(r.versions, []) + $ver, " | |
| " r.confidences = coalesce(r.confidences, []) + $conf", | |
| h=h, fid=file_id, fn=filename, ut=upload_time, | |
| ver=version, conf=confidence, | |
| ) | |
| return False # skipped | |
| # Create new triple | |
| session.run( | |
| """ | |
| MERGE (s:Entity {name: $subject}) | |
| MERGE (o:Entity {name: $object}) | |
| CREATE (s)-[r:RELATES_TO {hash: $h}]->(o) | |
| SET r.relation = $rel, | |
| r.source_files = [$fid], | |
| r.filenames = [$fn], | |
| r.upload_times = [$ut], | |
| r.versions = [$ver], | |
| r.confidences = [$conf], | |
| r.created_at = $now | |
| WITH s, o | |
| WHERE NOT $fid IN coalesce(s.source_files, []) | |
| SET s.source_files = coalesce(s.source_files, []) + $fid, | |
| s.filenames = coalesce(s.filenames, []) + $fn, | |
| s.upload_times = coalesce(s.upload_times, []) + $ut, | |
| s.versions = coalesce(s.versions, []) + $ver, | |
| s.confidences = coalesce(s.confidences, []) + $conf, | |
| s.created_at = coalesce(s.created_at, $now) | |
| WITH o | |
| WHERE NOT $fid IN coalesce(o.source_files, []) | |
| SET o.source_files = coalesce(o.source_files, []) + $fid, | |
| o.filenames = coalesce(o.filenames, []) + $fn, | |
| o.upload_times = coalesce(o.upload_times, []) + $ut, | |
| o.versions = coalesce(o.versions, []) + $ver, | |
| o.confidences = coalesce(o.confidences, []) + $conf, | |
| o.created_at = coalesce(o.created_at, $now) | |
| """, | |
| subject=subject, object_=object_, h=h, rel=relation, | |
| fid=file_id, fn=filename, ut=upload_time, | |
| ver=version, conf=confidence, | |
| now=datetime.now(timezone.utc).isoformat(), | |
| ) | |
| return True # inserted | |
| def delete_by_file(self, file_id): | |
| deleted = 0 | |
| with self._driver.session() as session: | |
| # Count and remove file from relationships, delete orphaned rels | |
| result = session.run( | |
| """ | |
| MATCH ()-[r:RELATES_TO]-() | |
| WHERE $fid IN r.source_files | |
| WITH r, r.source_files AS sfs | |
| SET r.source_files = [x IN sfs WHERE x <> $fid] | |
| WITH r WHERE size(r.source_files) = 0 | |
| DELETE r | |
| RETURN count(*) AS cnt | |
| """, | |
| fid=file_id, | |
| ) | |
| rec = result.single() | |
| deleted = rec["cnt"] if rec else 0 | |
| # Remove file from nodes, delete orphaned nodes | |
| session.run( | |
| """ | |
| MATCH (n:Entity) | |
| WHERE $fid IN n.source_files | |
| SET n.source_files = [x IN n.source_files WHERE x <> $fid] | |
| WITH n WHERE size(n.source_files) = 0 | |
| DETACH DELETE n | |
| """, | |
| fid=file_id, | |
| ) | |
| # Remove document node | |
| session.run("MATCH (d:Document {file_id: $fid}) DELETE d", fid=file_id) | |
| return deleted | |
| def get_stats(self): | |
| with self._driver.session() as session: | |
| docs = session.run("MATCH (d:Document) RETURN count(d) AS c").single()["c"] | |
| ents = session.run("MATCH (n:Entity) RETURN count(n) AS c").single()["c"] | |
| rels = session.run("MATCH ()-[r:RELATES_TO]->() RETURN count(r) AS c").single()["c"] | |
| return GraphStats(documents=docs, entities=ents, relationships=rels) | |
| def search(self, query, limit=20): | |
| """Keyword search over triples.""" | |
| keywords = [w.lower().strip() for w in re.split(r"\s+", query) if len(w) > 2] | |
| if not keywords: | |
| return [] | |
| conditions = " OR ".join( | |
| [f"toLower(s.name) CONTAINS '{kw}' OR toLower(r.relation) CONTAINS '{kw}' OR toLower(o.name) CONTAINS '{kw}'" | |
| for kw in keywords] | |
| ) | |
| cypher = ( | |
| f"MATCH (s:Entity)-[r:RELATES_TO]->(o:Entity) " | |
| f"WHERE {conditions} " | |
| f"RETURN s.name AS subject, r.relation AS relation, o.name AS object, " | |
| f"r.filenames AS sources " | |
| f"LIMIT {limit}" | |
| ) | |
| with self._driver.session() as session: | |
| result = session.run(cypher) | |
| return [dict(r) for r in result] | |
| def execute_cypher(self, cypher: str, limit: int = 20) -> List[Dict]: | |
| """Execute a raw Cypher query (used by LLM-generated queries).""" | |
| # Safety: only allow READ queries | |
| stripped = cypher.strip().upper() | |
| if not stripped.startswith("MATCH") and not stripped.startswith("RETURN"): | |
| raise ValueError("Only MATCH/RETURN queries are allowed.") | |
| if "DELETE" in stripped or "REMOVE" in stripped or "DROP" in stripped: | |
| raise ValueError("Destructive queries are not allowed.") | |
| with self._driver.session() as session: | |
| result = session.run(cypher) | |
| return [dict(r) for r in result] | |
| def get_file_triple_count(self, file_id): | |
| with self._driver.session() as session: | |
| result = session.run( | |
| "MATCH ()-[r:RELATES_TO]-() WHERE $fid IN r.source_files " | |
| "RETURN count(r) AS c", | |
| fid=file_id, | |
| ) | |
| return result.single()["c"] | |
| def close(self): | |
| self._driver.close() | |
| # --- Factory --------------------------------------------------------------- | |
| def create_graph_store() -> GraphStoreBase: | |
| """Create the best available graph store.""" | |
| if _NEO4J_OK and Config.NEO4J_URI: | |
| try: | |
| return Neo4jGraphStore( | |
| Config.NEO4J_URI, Config.NEO4J_USERNAME, Config.NEO4J_PASSWORD | |
| ) | |
| except (ServiceUnavailable, AuthError, Exception) as e: | |
| logger.warning("Neo4j connection failed (%s) β falling back to in-memory.", e) | |
| logger.info("Using in-memory graph store.") | |
| return InMemoryGraphStore() | |
| # ============================================================ | |
| # SECTION 9: CONVERSATION MEMORY | |
| # ============================================================ | |
| class ConversationMemory: | |
| """ | |
| Maintains per-session conversation history. | |
| This is SEPARATE from the knowledge graph β it is never persisted to Neo4j. | |
| """ | |
| def __init__(self, max_messages: int = 50): | |
| self._sessions: Dict[str, List[Dict[str, str]]] = {} | |
| self._max = max_messages | |
| def get_history(self, session_id: str) -> List[Dict[str, str]]: | |
| return self._sessions.get(session_id, []) | |
| def add_message(self, session_id: str, role: str, content: str): | |
| hist = self._sessions.setdefault(session_id, []) | |
| hist.append({"role": role, "content": content}) | |
| if len(hist) > self._max: | |
| self._sessions[session_id] = hist[-self._max:] | |
| def clear(self, session_id: str): | |
| self._sessions.pop(session_id, None) | |
| def to_langchain_messages(self, session_id: str) -> List[BaseMessage]: | |
| """Convert stored history to LangChain message objects.""" | |
| msgs: List[BaseMessage] = [] | |
| for m in self.get_history(session_id): | |
| if m["role"] == "user": | |
| msgs.append(HumanMessage(content=m["content"])) | |
| elif m["role"] == "assistant": | |
| msgs.append(AIMessage(content=m["content"])) | |
| return msgs | |
| # ============================================================ | |
| # SECTION 10: INTENT DETECTION | |
| # ============================================================ | |
| INTENT_SYSTEM_PROMPT = """\ | |
| You are an intent classifier for an AI knowledge-graph assistant. | |
| Classify the user's message into EXACTLY one of these intents: | |
| β’ GENERAL_CHAT β casual conversation, opinions, general questions | |
| β’ KNOWLEDGE_GRAPH_QUERY β questions that require information stored in the knowledge graph | |
| β’ DOCUMENT_SEARCH β questions about uploaded documents | |
| β’ GREETING β hello, hi, greetings | |
| β’ PROGRAMMING β code, programming, technical implementation | |
| β’ EXPLANATION β explain a concept, how something works | |
| Return ONLY the intent name (one of the above), nothing else. | |
| """ | |
| class IntentDetector: | |
| """Lightweight LLM-based intent classifier.""" | |
| def __init__(self, provider_manager: LLMProviderManager): | |
| self._pm = provider_manager | |
| def detect(self, message: str, provider: str) -> str: | |
| """Return one of the ALL_INTENTS constants.""" | |
| llm = self._pm.get_llm(provider, temperature=0.0) | |
| try: | |
| response = llm.invoke([ | |
| SystemMessage(content=INTENT_SYSTEM_PROMPT), | |
| HumanMessage(content=message), | |
| ]) | |
| intent = response.content.strip().upper() | |
| # Validate | |
| for valid in ALL_INTENTS: | |
| if valid in intent: | |
| return valid | |
| except Exception as e: | |
| logger.error("Intent detection failed: %s", e) | |
| return INTENT_GENERAL_CHAT # safe fallback | |
| # ============================================================ | |
| # SECTION 11: LANGGRAPH WORKFLOW | |
| # ============================================================ | |
| class AgentState(TypedDict, total=False): | |
| """State object passed through the LangGraph workflow.""" | |
| user_input: str | |
| provider: str | |
| intent: str | |
| context: str | |
| sources: List[str] | |
| execution_path: List[str] | |
| class KnowledgeGraphWorkflow: | |
| """ | |
| LangGraph-based orchestration: | |
| START β detect_intent β (route) β general_chat | kg_search β END | |
| """ | |
| def __init__( | |
| self, | |
| intent_detector: IntentDetector, | |
| graph_store: GraphStoreBase, | |
| provider_manager: LLMProviderManager, | |
| ): | |
| self._intent = intent_detector | |
| self._store = graph_store | |
| self._pm = provider_manager | |
| self._graph = self._build() | |
| def _build(self): | |
| workflow = StateGraph(AgentState) | |
| workflow.add_node("detect_intent", self._detect_intent_node) | |
| workflow.add_node("general_chat", self._general_chat_node) | |
| workflow.add_node("kg_search", self._kg_search_node) | |
| workflow.set_entry_point("detect_intent") | |
| workflow.add_conditional_edges( | |
| "detect_intent", | |
| self._route, | |
| { | |
| "general_chat": "general_chat", | |
| "kg_search": "kg_search", | |
| }, | |
| ) | |
| workflow.add_edge("general_chat", END) | |
| workflow.add_edge("kg_search", END) | |
| return workflow.compile() | |
| # --- Nodes --- | |
| def _detect_intent_node(self, state: AgentState) -> AgentState: | |
| intent = self._intent.detect(state["user_input"], state["provider"]) | |
| state["intent"] = intent | |
| state["execution_path"] = state.get("execution_path", []) + ["intent_detection"] | |
| logger.info("Intent detected: %s", intent) | |
| return state | |
| def _general_chat_node(self, state: AgentState) -> AgentState: | |
| state["context"] = "" | |
| state["sources"] = [] | |
| state["execution_path"] = state.get("execution_path", []) + ["general_chat"] | |
| return state | |
| def _kg_search_node(self, state: AgentState) -> AgentState: | |
| """Search the knowledge graph and assemble context for the LLM.""" | |
| state["execution_path"] = state.get("execution_path", []) + ["kg_search"] | |
| results = self._store.search(state["user_input"], limit=20) | |
| if not results: | |
| state["context"] = "No relevant information found in the knowledge graph." | |
| state["sources"] = [] | |
| return state | |
| # Build context text | |
| lines = [] | |
| sources_set = set() | |
| for r in results: | |
| lines.append(f"β’ {r['subject']} β[{r['relation']}]-> {r['object']}") | |
| for s in r.get("sources", []): | |
| sources_set.add(s) | |
| state["context"] = "\n".join(lines) | |
| state["sources"] = sorted(sources_set) | |
| return state | |
| # --- Routing --- | |
| def _route(self, state: AgentState) -> str: | |
| intent = state.get("intent", INTENT_GENERAL_CHAT) | |
| if intent in (INTENT_KG_QUERY, INTENT_DOC_SEARCH): | |
| return "kg_search" | |
| return "general_chat" | |
| # --- Public API --- | |
| def run(self, user_input: str, provider: str) -> AgentState: | |
| """Execute the workflow and return the final state.""" | |
| initial: AgentState = { | |
| "user_input": user_input, | |
| "provider": provider, | |
| "intent": "", | |
| "context": "", | |
| "sources": [], | |
| "execution_path": [], | |
| } | |
| return self._graph.invoke(initial) | |
| # ============================================================ | |
| # SECTION 12: APPLICATION ORCHESTRATOR | |
| # ============================================================ | |
| class Application: | |
| """ | |
| Central orchestrator that ties together all subsystems: | |
| file processing, knowledge extraction, graph storage, | |
| conversation memory, and the LangGraph workflow. | |
| """ | |
| def __init__(self): | |
| self.graph_store: GraphStoreBase = create_graph_store() | |
| self.provider_mgr: LLMProviderManager = provider_manager | |
| self.extractor: KnowledgeExtractor = KnowledgeExtractor(self.provider_mgr) | |
| self.intent_detector: IntentDetector = IntentDetector(self.provider_mgr) | |
| self.workflow: KnowledgeGraphWorkflow = KnowledgeGraphWorkflow( | |
| self.intent_detector, self.graph_store, self.provider_mgr | |
| ) | |
| self.memory: ConversationMemory = ConversationMemory() | |
| self.file_registry: Dict[str, Dict] = {} # file_id -> metadata | |
| self._stop_flags: Dict[str, bool] = {} | |
| # --- File helpers --- | |
| def _file_hash(filepath: str) -> str: | |
| h = hashlib.sha256() | |
| with open(filepath, "rb") as f: | |
| for chunk in iter(lambda: f.read(8192), b""): | |
| h.update(chunk) | |
| return h.hexdigest() | |
| def _find_existing_file(self, filename: str, file_hash: str) -> Optional[str]: | |
| """Check if a file with same name AND hash is already indexed.""" | |
| for fid, meta in self.file_registry.items(): | |
| if meta["filename"] == filename and meta["file_hash"] == file_hash: | |
| return fid | |
| return None | |
| def upload_file(self, filepath: str, original_name: str, | |
| provider: str) -> UploadResponse: | |
| """Process an uploaded file: extract text β extract knowledge β store triples.""" | |
| start = time.time() | |
| # Validate | |
| ext = Path(original_name).suffix.lower() | |
| if ext not in Config.SUPPORTED_EXTENSIONS: | |
| raise ValueError(f"Unsupported file type: {ext}") | |
| size = os.path.getsize(filepath) | |
| if size > Config.MAX_UPLOAD_SIZE_MB * 1024 * 1024: | |
| raise ValueError( | |
| f"File too large ({size / 1024 / 1024:.1f} MB). " | |
| f"Max: {Config.MAX_UPLOAD_SIZE_MB} MB." | |
| ) | |
| file_hash = self._file_hash(filepath) | |
| # Duplicate detection: same filename + same hash | |
| existing = self._find_existing_file(original_name, file_hash) | |
| if existing: | |
| return UploadResponse( | |
| filename=original_name, | |
| triples_inserted=0, | |
| triples_skipped=0, | |
| entities=0, | |
| relationships=0, | |
| processing_time=0.0, | |
| message="File already indexed.", | |
| ) | |
| # Determine version (if same filename but different hash β new version) | |
| version = 1 | |
| for fid, meta in self.file_registry.items(): | |
| if meta["filename"] == original_name: | |
| version = max(version, meta["version"] + 1) | |
| # Extract text | |
| try: | |
| text = FileProcessor.extract_text(filepath) | |
| except Exception as e: | |
| logger.error("Text extraction failed for %s: %s", original_name, e) | |
| raise RuntimeError(f"Text extraction failed: {e}") | |
| # Extract knowledge via LLM | |
| try: | |
| entities, relationships = self.extractor.extract(text, provider) | |
| except Exception as e: | |
| logger.error("Knowledge extraction failed for %s: %s", original_name, e) | |
| raise RuntimeError(f"Knowledge extraction failed: {e}") | |
| # Register document in graph | |
| file_id = str(uuid.uuid4()) | |
| upload_time = datetime.now(timezone.utc).isoformat() | |
| self.graph_store.register_document(file_id, original_name, file_hash, upload_time) | |
| # Insert triples (with deduplication) | |
| inserted = 0 | |
| skipped = 0 | |
| for rel in relationships: | |
| try: | |
| was_inserted = self.graph_store.add_triple( | |
| subject=rel["subject"], | |
| relation=rel["relation"], | |
| object_=rel["object"], | |
| file_id=file_id, | |
| filename=original_name, | |
| upload_time=upload_time, | |
| version=version, | |
| confidence=rel["confidence"], | |
| ) | |
| if was_inserted: | |
| inserted += 1 | |
| else: | |
| skipped += 1 | |
| except Exception as e: | |
| logger.error("Triple insert failed: %s", e) | |
| skipped += 1 | |
| # Register in local registry | |
| self.file_registry[file_id] = { | |
| "filename": original_name, | |
| "file_hash": file_hash, | |
| "upload_time": upload_time, | |
| "size_bytes": size, | |
| "version": version, | |
| "file_id": file_id, | |
| } | |
| elapsed = time.time() - start | |
| stats = self.graph_store.get_stats() | |
| logger.info( | |
| "Upload complete: %s | inserted=%d skipped=%d entities=%d rels=%d time=%.2fs", | |
| original_name, inserted, skipped, stats.entities, stats.relationships, elapsed | |
| ) | |
| return UploadResponse( | |
| filename=original_name, | |
| triples_inserted=inserted, | |
| triples_skipped=skipped, | |
| entities=stats.entities, | |
| relationships=stats.relationships, | |
| processing_time=round(elapsed, 2), | |
| message=f"Successfully processed '{original_name}' (v{version}).", | |
| ) | |
| def delete_file(self, filename: str) -> Dict[str, Any]: | |
| """Delete a file and all graph elements that originated ONLY from it.""" | |
| # Find file by filename (use latest version if multiple) | |
| target_id = None | |
| for fid, meta in self.file_registry.items(): | |
| if meta["filename"] == filename: | |
| target_id = fid # keep searching to get latest | |
| if not target_id: | |
| return {"success": False, "message": f"File '{filename}' not found."} | |
| deleted_triples = self.graph_store.delete_by_file(target_id) | |
| del self.file_registry[target_id] | |
| # Optionally remove the physical file | |
| # (not strictly necessary since we use temp paths) | |
| stats = self.graph_store.get_stats() | |
| logger.info("Deleted %s: %d triples removed", filename, deleted_triples) | |
| return { | |
| "success": True, | |
| "message": f"Deleted '{filename}'. {deleted_triples} triples removed.", | |
| "stats": stats.model_dump(), | |
| } | |
| def get_file_list(self) -> List[Dict]: | |
| """Return metadata for all uploaded files.""" | |
| result = [] | |
| for meta in self.file_registry.values(): | |
| triple_count = self.graph_store.get_file_triple_count(meta["file_id"]) | |
| result.append({ | |
| "filename": meta["filename"], | |
| "upload_date": meta["upload_time"][:19].replace("T", " "), | |
| "size_bytes": meta["size_bytes"], | |
| "triples": triple_count, | |
| "version": meta["version"], | |
| }) | |
| return result | |
| def get_stats(self) -> GraphStats: | |
| return self.graph_store.get_stats() | |
| # --- Chat --- | |
| def request_stop(self, session_id: str): | |
| self._stop_flags[session_id] = True | |
| def _should_stop(self, session_id: str) -> bool: | |
| return self._stop_flags.get(session_id, False) | |
| async def chat_stream( | |
| self, | |
| message: str, | |
| history: List[Dict[str, str]], | |
| provider: str, | |
| session_id: str = "default", | |
| ) -> AsyncGenerator[Tuple[List[Dict], str, str], None]: | |
| """ | |
| Stream a chat response. | |
| Yields tuples of (updated_history, intent_label, execution_info). | |
| The caller updates the Gradio chatbot with updated_history. | |
| """ | |
| self._stop_flags[session_id] = False | |
| # 1. Run LangGraph workflow to determine intent and gather context | |
| state = self.workflow.run(message, provider) | |
| intent = state.get("intent", INTENT_GENERAL_CHAT) | |
| exec_path = " β ".join(state.get("execution_path", [])) | |
| context = state.get("context", "") | |
| sources = state.get("sources", []) | |
| logger.info("Chat: intent=%s path=%s", intent, exec_path) | |
| # 2. Build system prompt | |
| if intent in (INTENT_KG_QUERY, INTENT_DOC_SEARCH) and context: | |
| system_content = ( | |
| "You are an AI assistant with access to a knowledge graph.\n" | |
| "Use the following retrieved knowledge to answer the user's question.\n" | |
| "If the knowledge is insufficient, say so clearly.\n\n" | |
| f"## Knowledge Graph Context\n{context}\n" | |
| ) | |
| if sources: | |
| system_content += f"\n## Sources: {', '.join(sources)}\n" | |
| elif intent == INTENT_PROGRAMMING: | |
| system_content = ( | |
| "You are an expert programmer. Provide clear, well-structured " | |
| "code with explanations. Use markdown code blocks." | |
| ) | |
| elif intent == INTENT_EXPLANATION: | |
| system_content = ( | |
| "You are an expert educator. Explain concepts clearly with examples." | |
| ) | |
| elif intent == INTENT_GREETING: | |
| system_content = "You are a friendly AI assistant. Greet the user warmly." | |
| else: | |
| system_content = "You are a helpful AI assistant." | |
| # 3. Build LangChain messages | |
| lc_messages: List[BaseMessage] = [SystemMessage(content=system_content)] | |
| for msg in history[-10:]: # last 10 messages for context | |
| if msg["role"] == "user": | |
| lc_messages.append(HumanMessage(content=msg["content"])) | |
| elif msg["role"] == "assistant": | |
| lc_messages.append(AIMessage(content=msg["content"])) | |
| lc_messages.append(HumanMessage(content=message)) | |
| # 4. Update history with user message | |
| updated = list(history) + [{"role": "user", "content": message}] | |
| updated.append({"role": "assistant", "content": ""}) | |
| # 5. Stream LLM response | |
| llm = self.provider_mgr.get_llm(provider) | |
| response_text = "" | |
| try: | |
| async for chunk in llm.astream(lc_messages): | |
| if self._should_stop(session_id): | |
| break | |
| token = chunk.content if hasattr(chunk, "content") else str(chunk) | |
| response_text += token | |
| updated[-1]["content"] = response_text | |
| yield updated, intent, exec_path | |
| except Exception as e: | |
| response_text = f"β οΈ Error generating response: {e}" | |
| updated[-1]["content"] = response_text | |
| yield updated, intent, exec_path | |
| # 6. Persist to conversation memory | |
| self.memory.add_message(session_id, "user", message) | |
| self.memory.add_message(session_id, "assistant", response_text) | |
| # --- Non-streaming chat (for REST API) --- | |
| def chat(self, message: str, provider: str, | |
| history: List[Dict[str, str]]) -> ChatResponse: | |
| """Non-streaming chat for the REST API.""" | |
| state = self.workflow.run(message, provider) | |
| intent = state.get("intent", INTENT_GENERAL_CHAT) | |
| context = state.get("context", "") | |
| sources = state.get("sources", []) | |
| if intent in (INTENT_KG_QUERY, INTENT_DOC_SEARCH) and context: | |
| system_content = ( | |
| "You are an AI assistant with access to a knowledge graph.\n" | |
| "Use the following retrieved knowledge to answer.\n\n" | |
| f"## Knowledge Graph Context\n{context}\n" | |
| ) | |
| else: | |
| system_content = "You are a helpful AI assistant." | |
| lc_messages: List[BaseMessage] = [SystemMessage(content=system_content)] | |
| for msg in history[-10:]: | |
| if msg["role"] == "user": | |
| lc_messages.append(HumanMessage(content=msg["content"])) | |
| elif msg["role"] == "assistant": | |
| lc_messages.append(AIMessage(content=msg["content"])) | |
| lc_messages.append(HumanMessage(content=message)) | |
| llm = self.provider_mgr.get_llm(provider) | |
| try: | |
| response = llm.invoke(lc_messages) | |
| reply = response.content | |
| except Exception as e: | |
| reply = f"β οΈ Error: {e}" | |
| return ChatResponse( | |
| reply=reply, | |
| intent=intent, | |
| execution_path=state.get("execution_path", []), | |
| sources=sources, | |
| ) | |
| # Create the global application instance | |
| app_core = Application() | |
| # ============================================================ | |
| # SECTION 13: FASTAPI ENDPOINTS | |
| # ============================================================ | |
| api = FastAPI(title="AI Knowledge Graph Chat API", version="1.0.0") | |
| api.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| async def health(): | |
| """Health check endpoint.""" | |
| return { | |
| "status": "healthy", | |
| "neo4j": isinstance(app_core.graph_store, Neo4jGraphStore), | |
| "providers": app_core.provider_mgr.list_providers(), | |
| } | |
| async def chat_endpoint(req: ChatRequest): | |
| """Chat endpoint β returns full response (non-streaming).""" | |
| try: | |
| result = app_core.chat(req.message, req.provider, req.history) | |
| return result | |
| except Exception as e: | |
| logger.error("Chat error: %s", e) | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def upload_endpoint(file: UploadFile = File(...), | |
| provider: str = Config.DEFAULT_PROVIDER): | |
| """Upload and process a document.""" | |
| # Validate extension | |
| ext = Path(file.filename or "").suffix.lower() | |
| if ext not in Config.SUPPORTED_EXTENSIONS: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Unsupported file type: {ext}. Supported: {Config.SUPPORTED_EXTENSIONS}" | |
| ) | |
| # Save to temp file | |
| tmp_path = Config.UPLOAD_DIR / f"{uuid.uuid4().hex}_{file.filename}" | |
| try: | |
| content = await file.read() | |
| if not content: | |
| raise HTTPException(status_code=400, detail="Empty file.") | |
| if len(content) > Config.MAX_UPLOAD_SIZE_MB * 1024 * 1024: | |
| raise HTTPException( | |
| status_code=413, | |
| detail=f"File too large. Max: {Config.MAX_UPLOAD_SIZE_MB} MB." | |
| ) | |
| tmp_path.write_bytes(content) | |
| result = app_core.upload_file(str(tmp_path), file.filename, provider) | |
| return result | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error("Upload error: %s", e) | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| finally: | |
| if tmp_path.exists(): | |
| tmp_path.unlink(missing_ok=True) | |
| async def delete_file_endpoint(filename: str): | |
| """Delete a file and its graph data.""" | |
| result = app_core.delete_file(filename) | |
| if not result["success"]: | |
| raise HTTPException(status_code=404, detail=result["message"]) | |
| return result | |
| async def list_files_endpoint(): | |
| """List all uploaded files.""" | |
| return app_core.get_file_list() | |
| async def graph_stats_endpoint(): | |
| """Return knowledge-graph statistics.""" | |
| return app_core.get_stats() | |
| async def set_provider_endpoint(req: ProviderRequest): | |
| """Validate a provider choice.""" | |
| if req.provider.lower() not in PROVIDERS: | |
| raise HTTPException(status_code=400, detail="Unknown provider.") | |
| available = app_core.provider_mgr.is_available(req.provider) | |
| return { | |
| "provider": req.provider, | |
| "available": available, | |
| "message": "Provider is available." if available | |
| else "Provider library installed but no API key set.", | |
| } | |
| # ============================================================ | |
| # SECTION 14: GRADIO UI | |
| # ============================================================ | |
| # Track the current Gradio click event for cancellation | |
| _current_click_event = None | |
| def _stats_markdown() -> str: | |
| """Render graph statistics as markdown.""" | |
| stats = app_core.get_stats() | |
| return ( | |
| f"### π Knowledge Graph Stats\n" | |
| f"| Metric | Count |\n|---|---|\n" | |
| f"| π Documents | **{stats.documents}** |\n" | |
| f"| π΅ Entities | **{stats.entities}** |\n" | |
| f"| π Relationships | **{stats.relationships}** |\n" | |
| ) | |
| def _file_list_df(): | |
| """Return file list as a pandas DataFrame for gr.Dataframe.""" | |
| files = app_core.get_file_list() | |
| if not files: | |
| return pd.DataFrame(columns=["Filename", "Upload Date", "Size (KB)", "Triples", "Version"]) | |
| return pd.DataFrame([ | |
| { | |
| "Filename": f["filename"], | |
| "Upload Date": f["upload_date"], | |
| "Size (KB)": round(f["size_bytes"] / 1024, 1), | |
| "Triples": f["triples"], | |
| "Version": f["version"], | |
| } | |
| for f in files | |
| ]) | |
| def _file_choices(): | |
| """Return list of filenames for the delete dropdown.""" | |
| return [f["filename"] for f in app_core.get_file_list()] | |
| def _provider_choices(): | |
| """Return provider choices with availability indicators.""" | |
| return [ | |
| f"{PROVIDERS[k]['label']}{' β ' if app_core.provider_mgr.is_available(k) else ' β οΈ'}" | |
| for k in PROVIDERS | |
| ] | |
| def _provider_value_to_key(label: str) -> str: | |
| """Convert a display label back to a provider key.""" | |
| for k, v in PROVIDERS.items(): | |
| if v["label"] in label: | |
| return k | |
| return Config.DEFAULT_PROVIDER | |
| async def _stream_response(message, history, provider_label, session_id): | |
| """Async generator that streams chat responses to Gradio.""" | |
| provider = _provider_value_to_key(provider_label) | |
| intent_label = "" | |
| exec_info = "" | |
| async for updated_history, intent, exec_path in app_core.chat_stream( | |
| message, history, provider, session_id | |
| ): | |
| intent_label = intent | |
| exec_info = exec_path | |
| yield updated_history, intent_label, exec_info, _stats_markdown() | |
| def _send_handler(message, history, provider_label): | |
| """Wrapper for the send button (sync generator for Gradio).""" | |
| session_id = "gradio_session" | |
| loop = asyncio.new_event_loop() | |
| asyncio.set_event_loop(loop) | |
| async def gen(): | |
| async for item in _stream_response(message, history, provider_label, session_id): | |
| yield item | |
| async_gen = gen() | |
| # Convert async generator to sync | |
| try: | |
| while True: | |
| try: | |
| item = loop.run_until_complete(async_gen.__anext__()) | |
| yield item | |
| except StopAsyncIteration: | |
| break | |
| finally: | |
| loop.close() | |
| def _upload_handler(files, provider_label): | |
| """Handle file uploads.""" | |
| if not files: | |
| return "No files selected.", _file_list_df(), _stats_markdown(), gr.update(choices=_file_choices()) | |
| provider = _provider_value_to_key(provider_label) | |
| results = [] | |
| for f in files: | |
| try: | |
| result = app_core.upload_file(f.name, os.path.basename(f.name), provider) | |
| results.append( | |
| f"β **{result.filename}**: {result.triples_inserted} triples inserted, " | |
| f"{result.triples_skipped} skipped ({result.processing_time}s) β {result.message}" | |
| ) | |
| except Exception as e: | |
| results.append(f"β **{os.path.basename(f.name)}**: {e}") | |
| return "\n".join(results), _file_list_df(), _stats_markdown(), gr.update(choices=_file_choices()) | |
| def _delete_handler(filename): | |
| """Handle file deletion.""" | |
| if not filename: | |
| return "No file selected.", _file_list_df(), _stats_markdown(), gr.update(choices=_file_choices()) | |
| result = app_core.delete_file(filename) | |
| return result["message"], _file_list_df(), _stats_markdown(), gr.update(choices=_file_choices()) | |
| def _clear_handler(): | |
| """Clear the chat.""" | |
| app_core.memory.clear("gradio_session") | |
| return [], "", "" | |
| def _stop_handler(): | |
| """Stop generation.""" | |
| app_core.request_stop("gradio_session") | |
| return "Generation stopped." | |
| def build_ui() -> gr.Blocks: | |
| """Build the Gradio interface.""" | |
| with gr.Blocks( | |
| title="AI Knowledge Graph Chat", | |
| theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="blue"), | |
| css=""" | |
| .main { max-width: 1400px; margin: auto; } | |
| .stats-box { background: #f0f4ff; padding: 12px; border-radius: 8px; } | |
| """ | |
| ) as demo: | |
| gr.Markdown("# π§ AI Knowledge Graph Chat") | |
| gr.Markdown( | |
| "Upload documents to build a knowledge graph, then ask questions. " | |
| "The AI extracts entities and relationships, stores them in Neo4j, " | |
| "and routes your questions intelligently." | |
| ) | |
| # --- Top bar: provider + stats --- | |
| with gr.Row(): | |
| provider_dd = gr.Dropdown( | |
| choices=_provider_choices(), | |
| value=_provider_choices()[0] if _provider_choices() else "Groq", | |
| label="LLM Provider", | |
| scale=1, | |
| interactive=True, | |
| ) | |
| stats_md = gr.Markdown(_stats_markdown(), elem_classes=["stats-box"], scale=2) | |
| refresh_btn = gr.Button("π Refresh", scale=0) | |
| # --- Main area: chat + sidebar --- | |
| with gr.Row(): | |
| # Chat column | |
| with gr.Column(scale=3): | |
| chatbot = gr.Chatbot( | |
| label="Conversation", | |
| height=480, | |
| show_copy_button=True, | |
| type="messages", | |
| render_markdown=True, | |
| avatar_images=("π€", "π€"), | |
| ) | |
| intent_md = gr.Markdown("", label="Intent") | |
| exec_md = gr.Markdown("", label="Execution Path") | |
| with gr.Row(): | |
| msg_input = gr.Textbox( | |
| placeholder="Type your message... (Enter to send)", | |
| show_label=False, | |
| scale=4, | |
| lines=2, | |
| ) | |
| send_btn = gr.Button("π€ Send", variant="primary", scale=1) | |
| stop_btn = gr.Button("βΉοΈ Stop", variant="stop", scale=1) | |
| clear_btn = gr.Button("ποΈ Clear", scale=1) | |
| # Sidebar: file management | |
| with gr.Column(scale=2): | |
| gr.Markdown("### π Upload Documents") | |
| file_upload = gr.File( | |
| label="Drop files here", | |
| file_count="multiple", | |
| file_types=[ext.lstrip(".") for ext in Config.SUPPORTED_EXTENSIONS], | |
| ) | |
| upload_status = gr.Markdown("") | |
| upload_btn = gr.Button("Process Files", variant="primary") | |
| gr.Markdown("---") | |
| gr.Markdown("### π Uploaded Files") | |
| file_df = gr.Dataframe( | |
| value=_file_list_df(), | |
| headers=["Filename", "Upload Date", "Size (KB)", "Triples", "Version"], | |
| datatype=["str", "str", "str", "number", "number"], | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| with gr.Row(): | |
| delete_dd = gr.Dropdown( | |
| choices=_file_choices(), | |
| label="Select file to delete", | |
| scale=3, | |
| ) | |
| delete_btn = gr.Button("ποΈ Delete", variant="stop", scale=1) | |
| # --- Event wiring --- | |
| # Send message (streaming) | |
| click_event = send_btn.click( | |
| fn=_send_handler, | |
| inputs=[msg_input, chatbot, provider_dd], | |
| outputs=[chatbot, intent_md, exec_md, stats_md], | |
| ).then( | |
| fn=lambda: "", | |
| outputs=msg_input, | |
| ) | |
| # Enter key sends | |
| msg_input.submit( | |
| fn=_send_handler, | |
| inputs=[msg_input, chatbot, provider_dd], | |
| outputs=[chatbot, intent_md, exec_md, stats_md], | |
| ).then( | |
| fn=lambda: "", | |
| outputs=msg_input, | |
| ) | |
| # Stop generation | |
| stop_btn.click( | |
| fn=_stop_handler, | |
| outputs=upload_status, | |
| cancels=[click_event], | |
| ) | |
| # Clear conversation | |
| clear_btn.click( | |
| fn=_clear_handler, | |
| outputs=[chatbot, intent_md, exec_md], | |
| ) | |
| # Upload files | |
| upload_btn.click( | |
| fn=_upload_handler, | |
| inputs=[file_upload, provider_dd], | |
| outputs=[upload_status, file_df, stats_md, delete_dd], | |
| ) | |
| # Delete file | |
| delete_btn.click( | |
| fn=_delete_handler, | |
| inputs=[delete_dd], | |
| outputs=[upload_status, file_df, stats_md, delete_dd], | |
| ) | |
| # Refresh stats | |
| refresh_btn.click( | |
| fn=lambda: (_stats_markdown(), _file_list_df(), gr.update(choices=_file_choices())), | |
| outputs=[stats_md, file_df, delete_dd], | |
| ) | |
| return demo | |
| # ============================================================ | |
| # SECTION 15: MAIN ENTRYPOINT | |
| # ============================================================ | |
| def main(): | |
| """ | |
| Entry point: mount Gradio on FastAPI and run with uvicorn. | |
| This serves both the UI and REST API from a single port. | |
| """ | |
| os.system("") # ensure terminal output on Windows | |
| logger.info("=" * 60) | |
| logger.info("AI Knowledge Graph Chat Application") | |
| logger.info("=" * 60) | |
| logger.info("Neo4j available: %s", _NEO4J_OK) | |
| logger.info("Neo4j configured: %s", bool(Config.NEO4J_URI)) | |
| logger.info("Graph store: %s", type(app_core.graph_store).__name__) | |
| for key, meta in PROVIDERS.items(): | |
| avail = app_core.provider_mgr.is_available(key) | |
| logger.info("Provider %s: installed=%s, api_key=%s", | |
| key, meta["available"], avail) | |
| # Build Gradio UI | |
| demo = build_ui() | |
| # Mount Gradio on FastAPI at root | |
| gr.mount_gradio_app(api, demo, path="/") | |
| logger.info("Starting server on %s:%d", Config.HOST, Config.PORT) | |
| uvicorn.run(api, host=Config.HOST, port=Config.PORT, log_level="info") | |
| if __name__ == "__main__": | |
| main() |