Spaces:
Paused
Paused
File size: 20,249 Bytes
8f35c1b 01b9f34 8f35c1b 9093d8f 8f35c1b 9093d8f 8f35c1b 9093d8f 8f35c1b 9093d8f 8f35c1b 864852c 8f35c1b 9093d8f 8f35c1b 9093d8f 8f35c1b 9093d8f 8f35c1b 9093d8f 8f35c1b 1330e5c 8f35c1b 1330e5c 8f35c1b 1330e5c 585c3ec 1330e5c 8f35c1b 1330e5c 8f35c1b fbd18c8 8f35c1b 1330e5c 8f35c1b 1330e5c 8f35c1b c23d859 8f35c1b c23d859 8f35c1b c23d859 8f35c1b c23d859 8f35c1b 1330e5c 8f35c1b c23d859 8f35c1b 7e9f52f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 | """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
|