1MR's picture
Update app.py
55c97da verified
Raw
History Blame Contribute Delete
68.3 kB
#!/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))])
@property
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."""
@staticmethod
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
@staticmethod
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
@staticmethod
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)
@staticmethod
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())
@staticmethod
def _extract_text(filepath: str) -> str:
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
return f.read()
@staticmethod
def _extract_csv(filepath: str) -> str:
df = pd.read_csv(filepath)
return df.to_string(index=False)
@staticmethod
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)
@staticmethod
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)
@staticmethod
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
@staticmethod
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)
@staticmethod
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 ---
@staticmethod
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=["*"],
)
@api.get("/health")
async def health():
"""Health check endpoint."""
return {
"status": "healthy",
"neo4j": isinstance(app_core.graph_store, Neo4jGraphStore),
"providers": app_core.provider_mgr.list_providers(),
}
@api.post("/chat", response_model=ChatResponse)
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))
@api.post("/upload", response_model=UploadResponse)
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)
@api.delete("/file/{filename}")
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
@api.get("/files")
async def list_files_endpoint():
"""List all uploaded files."""
return app_core.get_file_list()
@api.get("/graph/stats", response_model=GraphStats)
async def graph_stats_endpoint():
"""Return knowledge-graph statistics."""
return app_core.get_stats()
@api.post("/provider")
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()