Spaces:
Paused
Paused
Download src/kb_meta.py from CGIAR/PERI-AI-dev: direct link, hf CLI and curl.
- Browser
- Download file 20.2 kB
-
https://huggingface.co/spaces/CGIAR/PERI-AI-dev/resolve/main/src/kb_meta.py
- Command line
-
hf download hf://spaces/CGIAR/PERI-AI-dev/src/kb_meta.py
-
curl -L -o kb_meta.py https://huggingface.co/spaces/CGIAR/PERI-AI-dev/resolve/main/src/kb_meta.py
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 | |