PERI-AI-dev / src /kb_meta.py
Nelly-43's picture
Local updates
585c3ec
Raw History Blame Contribute Delete
20.2 kB
"""Knowledge Base meta-overview: LLM-generated summary of indexed content.
Generates a high-level overview of all documents and SQL tables in the
knowledge base using the LLM to understand content and connections.
This overview is:
1. Stored as a special chunk in ChromaDB so meta-questions can find it
via vector search (solving the "what data do you have?" problem).
2. Injected into the query understanding prompt so the LLM can route
and reformulate queries with full KB awareness.
3. Injected into the system prompt so the LLM always has the "general
picture" when generating answers.
"""
import json
import os
from pathlib import Path
META_CHUNK_ID = "kb_meta_overview_001"
META_SOURCE = "Knowledge Base Overview"
META_DATASET = "meta"
_META_GENERATION_PROMPT = """\
You are a knowledge base analyst. Given the following information about
documents and data tables in a research knowledge base, write a concise
overview (200-500 words) that covers:
1. **What is in the knowledge base**: List each document/dataset, its type,
and a 1-sentence description of what it contains (based on the sample text).
2. **Key topics and themes**: What subjects does this knowledge base cover?
3. **Connections**: How do the different documents/datasets relate to each
other? Do they share countries, time periods, variables, or methodologies?
4. **Structured datasets**: For each structured data table, describe:
- **Unit of observation**: What does each row represent? (e.g., a country-year,
a person, a transaction, an event)
- **Key columns**: What do the most important columns measure or contain?
Use the column descriptions and codebook information provided below.
- If codebook-derived descriptions are provided, use them. If only column
names and sample values are available, infer the meaning and note that
the interpretation is inferred.
Write in plain text, organized with clear headings. Be factual — only
describe what you can see in the provided samples. Do not speculate.
--- DOCUMENT INVENTORY ---
{inventory}
--- END INVENTORY ---
--- SAMPLE CONTENT (first chunk from each document) ---
{samples}
--- END SAMPLES ---
{sql_section}
Write the overview now:"""
def generate_kb_overview(
file_records: list[dict],
sample_chunks: dict[str, str],
sql_schema: dict | None = None,
) -> str:
"""Build a deterministic (no-LLM) overview as fallback.
Args:
file_records: List of dicts with keys: source, dataset, chunk_count, ext.
sample_chunks: Dict mapping source name -> first chunk text.
sql_schema: SQL schema dict from sql_schemas.json (optional).
Returns:
A plain-text overview string suitable for prompt injection.
"""
if not file_records and not sql_schema:
return ""
lines = ["=== KNOWLEDGE BASE OVERVIEW ===", ""]
# ── Group files by dataset ───────────────────────────────────────
datasets: dict[str, list[dict]] = {}
for rec in file_records:
ds = rec.get("dataset", "general")
datasets.setdefault(ds, []).append(rec)
if datasets:
total_files = sum(len(v) for v in datasets.values())
total_chunks = sum(r.get("chunk_count", 0) for r in file_records)
lines.append(
f"Documents: {total_files} files, {total_chunks} chunks "
f"across {len(datasets)} dataset(s)."
)
lines.append("")
for ds_name in sorted(datasets):
ds_files = datasets[ds_name]
lines.append(f" [{ds_name}]")
for rec in sorted(ds_files, key=lambda r: r.get("source", "")):
name = rec.get("source", "unknown")
ext = rec.get("ext", "")
chunks = rec.get("chunk_count", 0)
lines.append(f" - {name} ({ext}, {chunks} chunks)")
lines.append("")
# ── SQL tables ───────────────────────────────────────────────────
if sql_schema:
lines.append(f"Structured data: {len(sql_schema)} SQL table(s).")
lines.append("")
for table_name, info in sql_schema.items():
row_count = info.get("row_count", 0)
source = info.get("source_file", table_name)
table_desc = info.get("table_description", "")
lines.append(f" [{table_name}] from {source}")
header = f" {row_count} rows"
if table_desc:
header += f" | {table_desc}"
lines.append(header)
for c in info.get("columns", []):
col_name = c.get("name", "unknown")
col_type = c.get("type", "TEXT")
desc = c.get("description", "")
stats = c.get("stats", {})
samples_list = c.get("sample", [])
parts = [f" {col_name} ({col_type})"]
if stats.get("unique_count"):
parts.append(f"{stats['unique_count']} unique")
if stats.get("min") is not None:
parts.append(f"range {stats['min']}\u2013{stats['max']}")
if samples_list:
quoted = ", ".join(str(s) for s in samples_list[:3])
parts.append(f"e.g. {quoted}")
if desc:
parts.append(f"\u2014 {desc}")
lines.append(", ".join(parts) if len(parts) > 1 else parts[0])
lines.append("")
# ── Connection note ──────────────────────────────────────────────
if len(datasets) > 1:
ds_names = ", ".join(sorted(datasets))
lines.append(
f"All {len(datasets)} datasets ({ds_names}) are part of the same "
"knowledge base and may share related topics, countries, or time periods."
)
lines.append("")
lines.append("=== END OVERVIEW ===")
return "\n".join(lines)
def generate_kb_overview_with_llm(
file_records: list[dict],
sample_chunks: dict[str, str],
sql_schema: dict | None,
cfg: dict,
) -> str:
"""Use the LLM to generate a rich KB overview with topic analysis.
Falls back to the deterministic overview if LLM call fails.
Args:
file_records: List of dicts with keys: source, dataset, chunk_count, ext.
sample_chunks: Dict mapping source name -> first chunk text.
sql_schema: SQL schema dict from sql_schemas.json (optional).
cfg: App config for LLM access.
Returns:
LLM-generated overview text.
"""
from src.llm import generate
if not file_records and not sql_schema:
return ""
# Build inventory section
inv_lines = []
for rec in sorted(file_records, key=lambda r: (r.get("dataset", ""), r.get("source", ""))):
inv_lines.append(
f"- {rec['source']} | dataset: {rec.get('dataset', 'general')} | "
f"type: {rec.get('ext', '?')} | chunks: {rec.get('chunk_count', 0)}"
)
inventory = "\n".join(inv_lines) if inv_lines else "(no documents)"
# Build sample section (truncate each to 500 chars)
sample_lines = []
for source, text in sorted(sample_chunks.items()):
preview = text[:500] + "..." if len(text) > 500 else text
sample_lines.append(f"[{source}]\n{preview}\n")
samples = "\n".join(sample_lines) if sample_lines else "(no samples available)"
# Build SQL section with full column detail
sql_section = ""
if sql_schema:
sql_lines = ["--- STRUCTURED DATASETS (SQL TABLES) ---"]
for table_name, info in sql_schema.items():
row_count = info.get("row_count", 0)
source = info.get("source_file", "?")
table_desc = info.get("table_description", "")
sql_lines.append(f"\nTable: {table_name} ({row_count} rows, from {source})")
if table_desc:
sql_lines.append(f" Description: {table_desc}")
# Determine if descriptions came from a codebook or were inferred
has_descriptions = any(
c.get("description") for c in info.get("columns", [])
)
if has_descriptions:
sql_lines.append(" Column descriptions (from codebook or LLM analysis):")
else:
sql_lines.append(" Columns (no codebook found — infer from names and samples):")
for c in info.get("columns", []):
col_name = c.get("name", "unknown")
col_type = c.get("type", "TEXT")
desc = c.get("description", "")
samples_list = c.get("sample", [])
stats = c.get("stats", {})
parts = [f" - {col_name} [{col_type}]"]
if stats.get("unique_count"):
parts.append(f"({stats['unique_count']} unique)")
if stats.get("min") is not None:
parts.append(f"range: {stats['min']}\u2013{stats['max']}")
if samples_list:
quoted = ", ".join(str(s) for s in samples_list[:5])
parts.append(f"e.g. {quoted}")
if desc:
parts.append(f"\u2014 {desc}")
sql_lines.append(" ".join(parts))
sql_lines.append("\n--- END STRUCTURED DATASETS ---\n")
sql_section = "\n".join(sql_lines)
prompt = _META_GENERATION_PROMPT.format(
inventory=inventory,
samples=samples,
sql_section=sql_section,
)
try:
overview = generate(
"You are a knowledge base analyst. Be concise and factual.",
prompt,
cfg,
max_tokens=1024,
)
# Wrap in markers for consistent parsing
return f"=== KNOWLEDGE BASE OVERVIEW ===\n\n{overview}\n\n=== END OVERVIEW ==="
except Exception:
# Fall back to deterministic overview
return generate_kb_overview(file_records, sample_chunks, sql_schema)
def _collect_all_data(collection) -> tuple[list[str], list[dict]]:
"""Fetch all documents and metadatas from ChromaDB in a single call."""
if collection.count() == 0:
return [], []
batch_size = 5000
offset = 0
all_documents = []
all_metadatas = []
while True:
batch = collection.get(
limit=batch_size,
offset=offset,
include=["documents", "metadatas"]
)
# Break when no more records are returned
if not batch["ids"]:
break
# print(batch)
all_documents.extend(batch["documents"])
all_metadatas.extend(batch["metadatas"])
offset += batch_size
# all_data = collection.get(include=["documents", "metadatas"])
return all_documents, all_metadatas
def collect_file_records(collection, *, _cache: tuple = None) -> list[dict]:
"""Extract unique file records from ChromaDB metadata.
Args:
_cache: Optional (documents, metadatas) tuple to avoid redundant
collection.get() calls when used alongside collect_sample_chunks.
"""
if _cache is not None:
_, metadatas = _cache
else:
_, metadatas = _collect_all_data(collection)
source_info: dict[str, dict] = {}
for meta in metadatas:
source = meta.get("source", "unknown")
if source == META_SOURCE:
continue
if source not in source_info:
ext = Path(source).suffix
source_info[source] = {
"source": source,
"dataset": meta.get("dataset", "general"),
"ext": ext,
"chunk_count": 0,
}
source_info[source]["chunk_count"] += 1
return list(source_info.values())
def collect_sample_chunks(collection, *, _cache: tuple = None) -> dict[str, str]:
"""Get the first chunk from each unique source for LLM analysis.
Args:
_cache: Optional (documents, metadatas) tuple to avoid redundant
collection.get() calls when used alongside collect_file_records.
"""
if _cache is not None:
documents, metadatas = _cache
else:
documents, metadatas = _collect_all_data(collection)
samples: dict[str, str] = {}
for doc, meta in zip(documents, metadatas):
source = meta.get("source", "unknown")
if source == META_SOURCE:
continue
# Keep the first chunk seen for each source
if source not in samples:
samples[source] = doc
return samples
def load_sql_schema(cfg: dict) -> dict | None:
"""Load SQL schema from sql_schemas.json if it exists."""
sql_db_dir = cfg.get("paths", {}).get("sql_db", "sql_db")
if not os.path.isabs(sql_db_dir):
project_root = Path(__file__).resolve().parent.parent
sql_db_dir = os.path.join(str(project_root), sql_db_dir)
schema_path = os.path.join(sql_db_dir, "sql_schemas.json")
if not os.path.exists(schema_path):
return None
try:
with open(schema_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _resolve_db_path(cfg: dict) -> str:
"""Resolve the vector DB directory path from config."""
db_path = cfg.get("paths", {}).get("vector_db", "chroma_db")
if not os.path.isabs(db_path):
project_root = Path(__file__).resolve().parent.parent
db_path = os.path.join(str(project_root), db_path)
return db_path
def save_kb_meta(overview_text: str, cfg: dict) -> None:
"""Save the overview text to a file alongside the vector DB."""
db_path = _resolve_db_path(cfg)
os.makedirs(db_path, exist_ok=True)
meta_path = os.path.join(db_path, "kb_meta.txt")
with open(meta_path, "w", encoding="utf-8") as f:
f.write(overview_text)
def load_kb_meta(cfg: dict) -> str:
"""Load the KB overview text from file. Returns empty string if not found."""
db_path = _resolve_db_path(cfg)
meta_path = os.path.join(db_path, "kb_meta.txt")
if not os.path.exists(meta_path):
return ""
try:
with open(meta_path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
_BRIEF_OVERVIEW_PROMPT = """\
Given the following detailed knowledge base overview, write a brief
high-level summary (3-5 sentences). Cover:
- What types of documents and datasets are in the KB
- The main topics/themes
- Time periods and geographic scope if applicable
Do NOT list individual files, column names, or technical details.
Write in plain text, no headings or bullet points.
--- FULL OVERVIEW ---
{full_overview}
--- END ---
Write the brief summary now:"""
def generate_brief_overview(full_overview: str, cfg: dict) -> str:
"""Generate a brief high-level summary from the full KB overview.
Used in the system prompt where only general awareness is needed.
Falls back to first 3 sentences of the full overview if LLM fails.
"""
if not full_overview:
return ""
# Strip markers for clean input
clean = full_overview
for marker in ("=== KNOWLEDGE BASE OVERVIEW ===", "=== END OVERVIEW ==="):
clean = clean.replace(marker, "")
clean = clean.strip()
if not clean:
return ""
try:
from src.llm import generate
prompt = _BRIEF_OVERVIEW_PROMPT.format(full_overview=clean)
brief = generate(
"You are a concise summarizer. Be brief and factual.",
prompt,
cfg,
max_tokens=256,
)
if brief and brief.strip():
return brief.strip()
except Exception:
pass
# Fallback: extract first 3 sentences
import re
sentences = re.split(r'(?<=[.!?])\s+', clean)
fallback = " ".join(sentences[:3])
if len(fallback) > 400:
fallback = fallback[:400].rsplit(" ", 1)[0] + "..."
return fallback
def save_kb_meta_brief(brief_text: str, cfg: dict) -> None:
"""Save the brief overview to a file alongside the vector DB."""
db_path = _resolve_db_path(cfg)
os.makedirs(db_path, exist_ok=True)
meta_path = os.path.join(db_path, "kb_meta_brief.txt")
with open(meta_path, "w", encoding="utf-8") as f:
f.write(brief_text)
def load_kb_meta_brief(cfg: dict) -> str:
"""Load the brief KB overview. Falls back to full overview if not found."""
db_path = _resolve_db_path(cfg)
meta_path = os.path.join(db_path, "kb_meta_brief.txt")
if os.path.exists(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
return f.read()
except Exception:
pass
# Fallback: return full overview
return load_kb_meta(cfg)
def upsert_meta_chunk(collection, overview_text: str) -> None:
"""Insert or update the meta overview as a special chunk in ChromaDB."""
if not overview_text:
return
collection.upsert(
ids=[META_CHUNK_ID],
documents=[overview_text],
metadatas=[{
"source": META_SOURCE,
"dataset": META_DATASET,
"page": "1",
"chunk_index": 0,
}],
)
def build_and_store_overview(collection, cfg: dict) -> str:
"""Generate, store, and index the KB overview. Called after ingestion.
Uses the LLM to produce a rich summary. Falls back to deterministic
overview if LLM is unavailable.
Returns the overview text.
"""
cache = _collect_all_data(collection)
file_records = collect_file_records(collection, _cache=cache)
sample_chunks = collect_sample_chunks(collection, _cache=cache)
sql_schema = load_sql_schema(cfg)
overview = generate_kb_overview_with_llm(
file_records, sample_chunks, sql_schema, cfg,
)
if not overview:
# LLM failed and deterministic also empty — nothing to store
return ""
save_kb_meta(overview, cfg)
upsert_meta_chunk(collection, overview)
# Generate and save brief version for system prompt injection
brief = generate_brief_overview(overview, cfg)
if brief:
save_kb_meta_brief(brief, cfg)
return overview
_WELCOME_SUMMARY_PROMPT = """\
Given the following knowledge base overview, write a brief, friendly
welcome summary (3-6 sentences) for the chatbot's landing page.
Tell the user:
- What topics and datasets are available to ask about
- Roughly how much data there is (number of files, datasets, or data points)
- 2-3 example questions they could ask based on the actual content
Keep it conversational and concise. Do NOT use bullet points or headings.
Do NOT include technical details like column names, file extensions, or chunk counts.
--- KNOWLEDGE BASE OVERVIEW ---
{kb_overview}
--- END ---
Write the welcome summary now:"""
def summarize_kb_for_welcome(cfg: dict) -> str:
"""Generate a short, friendly welcome summary from the KB meta overview.
Uses the LLM to produce a conversational summary suitable for the
welcome page. Falls back to the raw overview text (stripped of markers)
if the LLM call fails.
Returns empty string if no KB meta exists.
"""
kb_overview = load_kb_meta(cfg)
if not kb_overview:
return ""
# Strip markers for clean input
clean = kb_overview
for marker in ("=== KNOWLEDGE BASE OVERVIEW ===", "=== END OVERVIEW ==="):
clean = clean.replace(marker, "")
clean = clean.strip()
if not clean:
return ""
try:
from src.llm import generate
prompt = _WELCOME_SUMMARY_PROMPT.format(kb_overview=clean)
summary = generate(
"You are a friendly research assistant. Be concise.",
prompt,
cfg,
max_tokens=512,
)
if summary and summary.strip():
return summary.strip()
except Exception:
pass
# Fallback: return truncated raw overview
if len(clean) > 600:
clean = clean[:600].rsplit("\n", 1)[0] + "\n..."
return clean