atakan
fix: Enforce parameter provenance, fix rendering/citations, add tools, harden agent loop
48ee375
Raw
History Blame Contribute Delete
11.3 kB
"""Local Offline Hybrid BM25 Index with metadata preservation and citation support."""
from __future__ import annotations
import json
import pickle
import re
from pathlib import Path
from typing import Any
try:
from rank_bm25 import BM25Okapi
except ImportError:
BM25Okapi = None
from controlai_rag.chunker import Chunk
INDEX_DIR = Path("data/rag_index")
def tokenize_corpus(text: str) -> list[str]:
# Lowercase and extract alphanumeric tokens + mathematical symbols
return re.findall(r"\b\w+\b|[+\-*/^_]", text.lower())
# Only chunk body text is tokenized into BM25, so a query naming a source
# ("what does Nise say about stability") cannot match the book it refers to.
# These constants drive a post-retrieval boost for chunks whose *filename*
# matches a distinctive query term.
SOURCE_MATCH_BOOST = 1.6
# A filename token is only distinctive enough to boost on if it appears in at
# most this fraction of the indexed files (floored at 2 files, so the rule
# still works on a small corpus). An author surname like "nise" or "ogata"
# appears in exactly one file; generic domain words appear in many.
SOURCE_TOKEN_MAX_FILE_FRACTION = 0.02
# Words that identify the subject rather than a specific source. Even if the
# corpus grows large enough for these to slip under the fraction cutoff, they
# must never trigger a source boost.
GENERIC_SOURCE_TOKENS = {
"control", "controls", "systems", "system", "engineering", "theory",
"lecture", "lectures", "notes", "chapter", "solutions", "solution",
"exercise", "exercises", "book", "textbook", "txtbk", "edition", "vol",
"part", "final", "exam", "slides", "course", "intro", "introduction",
}
class ControlRAGIndex:
"""Fast, local, offline search index over control engineering documents."""
def __init__(self, index_dir: Path = INDEX_DIR) -> None:
self.index_dir = index_dir
self.chunks: list[dict[str, Any]] = []
self.bm25: Any | None = None
self._distinctive_source_tokens: set[str] = set()
self._load_if_exists()
self._build_source_token_map()
def _build_source_token_map(self) -> None:
"""Index which filename tokens are distinctive enough to boost on.
Author surnames and title words unique to a few files ("nise", "ogata",
"kharitonov") identify a source; words common across the corpus do not.
"""
if not self.chunks:
return
files_per_token: dict[str, set[str]] = {}
all_files: set[str] = set()
for chunk in self.chunks:
fname = str(chunk.get("metadata", {}).get("filename", ""))
if not fname:
continue
all_files.add(fname)
for token in set(tokenize_corpus(fname)):
files_per_token.setdefault(token, set()).add(fname)
if not all_files:
return
cutoff = max(2, int(len(all_files) * SOURCE_TOKEN_MAX_FILE_FRACTION))
self._distinctive_source_tokens = {
token
for token, files in files_per_token.items()
if len(files) <= cutoff
and len(token) > 2
and not token.isdigit()
and token not in GENERIC_SOURCE_TOKENS
}
def build_from_chunks(self, chunks: list[Chunk]) -> None:
if BM25Okapi is None:
return
self.chunks = [c.to_dict() for c in chunks]
corpus = [tokenize_corpus(c.text) for c in chunks]
self.bm25 = BM25Okapi(corpus)
self.save()
def add_chunks(self, new_chunks: list[Chunk]) -> None:
"""Add new chunks to the existing index and rebuild BM25."""
new_dict_chunks = [c.to_dict() for c in new_chunks]
self.chunks.extend(new_dict_chunks)
corpus = [tokenize_corpus(c["text"]) for c in self.chunks]
self.bm25 = BM25Okapi(corpus)
# A newly uploaded document may introduce a new author/title, so the
# distinctive-source vocabulary has to be recomputed alongside BM25.
self._build_source_token_map()
self.save()
def save(self) -> None:
self.index_dir.mkdir(parents=True, exist_ok=True)
with (self.index_dir / "chunks.json").open("w", encoding="utf-8") as f:
json.dump(self.chunks, f, ensure_ascii=False, indent=2)
with (self.index_dir / "bm25.pkl").open("wb") as f:
pickle.dump(self.bm25, f)
def _load_if_exists(self) -> bool:
chunks_file = self.index_dir / "chunks.json"
bm25_file = self.index_dir / "bm25.pkl"
if chunks_file.exists() and bm25_file.exists():
try:
with chunks_file.open("r", encoding="utf-8") as f:
self.chunks = json.load(f)
with bm25_file.open("rb") as f:
self.bm25 = pickle.load(f)
return True
except Exception as exc:
print(f"Warning: Failed to load existing index: {exc}")
return False
def search(self, query: str, top_k: int = 5, source_filter: str | None = None) -> list[dict[str, Any]]:
if not self.bm25 or not self.chunks:
return []
tokens = tokenize_corpus(query)
if not tokens:
return []
scores = self.bm25.get_scores(tokens)
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
# Re-rank a widened candidate pool so that a chunk from an explicitly
# named source can be promoted above a body-text-only BM25 match.
query_source_tokens = set(tokens) & self._distinctive_source_tokens
pool = ranked[: max(top_k * 10, 60)]
rescored: list[tuple[float, int]] = []
for idx in pool:
base = float(scores[idx])
if base <= 0.0:
continue
score = base
if query_source_tokens:
fname_tokens = set(tokenize_corpus(str(self.chunks[idx].get("metadata", {}).get("filename", ""))))
if query_source_tokens & fname_tokens:
score *= SOURCE_MATCH_BOOST
rescored.append((score, idx))
rescored.sort(key=lambda pair: pair[0], reverse=True)
results = []
for score, idx in rescored:
chunk = self.chunks[idx]
if source_filter and source_filter.lower() not in chunk["source_path"].lower():
continue
fname = chunk["metadata"].get("filename", "unknown")
source_name, is_published = display_source_name(fname)
results.append({
"chunk_id": chunk["chunk_id"],
"score": round(score, 3),
"source": chunk["source_path"],
"filename": fname,
# User-facing label -- never show the raw filename in an answer.
"source_name": source_name,
"is_published_work": is_published,
"page": chunk["metadata"].get("page"),
"text": chunk["text"],
})
if len(results) >= top_k:
break
return results
# A single process-wide index instance. The agent, the retrieval tool, and the
# document-upload endpoint must all read and mutate the SAME in-memory object:
# with separate instances, a document uploaded through the web UI is written to
# disk but stays invisible to the running agent until the server restarts.
_shared_index: ControlRAGIndex | None = None
def get_shared_index(index_dir: Path = INDEX_DIR) -> ControlRAGIndex:
"""Return the process-wide shared RAG index, loading it on first use."""
global _shared_index
if _shared_index is None:
_shared_index = ControlRAGIndex(index_dir)
return _shared_index
# --- Human-readable source names -------------------------------------------
# Indexed filenames carry private organisational cruft -- owner initials
# ("JD_", "B&B_"), course codes ("AMC", "PLMMR"), and scan artefacts
# ("txtbk", "DEFINITIVO", a doubled ".pdf.pdf"). Those must never reach a user
# as a citation, so every hit also carries a cleaned display name plus whether
# it is a published work (citable by author/title) or personal course notes
# (referred to generically).
_PUBLISHED_SOURCES: dict[str, str] = {
"norman s. nise - control systems engineering": "Nise, *Control Systems Engineering*",
"ogata modern control engineering 5th txtbk": "Ogata, *Modern Control Engineering* (5th ed.)",
}
# Course code -> the subject it stands for, so a generic remainder such as
# "CAM Course Notes Part 2" still resolves to something meaningful.
_COURSE_CODES: dict[str, str] = {
"SAS": "Safety in Automation Systems",
"AMC": "Advanced and Multivariable Control",
"CIR": "Control of Industrial Robots",
"PLMMR": "Perception, Localization and Mapping for Mobile Robots",
"NC": "Networked Control",
"CAM": "Computer-Aided Manufacturing",
"PSC": "Production Systems Control",
"MIDA": "Model Identification and Data Analysis",
"MIDA1": "Model Identification and Data Analysis",
"ACEHV": "Autonomous and Connected Electric/Hybrid Vehicles",
"ACHEV": "Autonomous and Connected Electric/Hybrid Vehicles",
"ACAV": "Autonomous and Connected Vehicles",
"DDCSD": "Data-Driven Control System Design",
"SACI": "Industrial Automation and Communication Systems",
"ICT": "Information and Communication Technology",
}
_OWNER_PREFIX_RE = re.compile(r"^(?:B&B|BBB|JD|LP|EC|AC|FG|RB|XX)[_\-\s]+", re.IGNORECASE)
_NOISE_RE = re.compile(
r"\b(?:txtbk|definitivo|margini\s+larghi|theory\s+notes|practice\s+notes|final)\b|\(.*?\)",
re.IGNORECASE,
)
def display_source_name(filename: str) -> tuple[str, bool]:
"""Map an indexed filename to (display name, is_published_work)."""
if not filename:
return ("local reference", False)
stem = str(filename)
while True:
lowered = stem.lower()
for ext in (".pdf", ".md", ".txt", ".json", ".jsonl"):
if lowered.endswith(ext):
stem = stem[: -len(ext)]
break
else:
break
key = " ".join(stem.split()).lower()
if key in _PUBLISHED_SOURCES:
return (_PUBLISHED_SOURCES[key], True)
name = _OWNER_PREFIX_RE.sub("", stem)
# A leading all-caps token is a course code; swap it for its subject.
subject = ""
parts = name.replace("_", " ").split()
if parts:
head = parts[0].strip(":-").upper()
if head in _COURSE_CODES:
subject = _COURSE_CODES[head]
parts = parts[1:]
elif len(parts) > 1 and 2 <= len(head) <= 6 and head.isalpha() and parts[0].isupper():
parts = parts[1:]
remainder = _NOISE_RE.sub("", " ".join(parts))
remainder = " ".join(remainder.replace("_", " ").split()).strip(" -–—")
generic = remainder.lower() in {
"", "course notes", "lecture notes", "notes", "exercise sessions",
"summary", "course notes part 1", "course notes part 2", "lectures",
}
if subject and generic:
label = subject
elif subject and remainder:
label = subject if remainder.lower() in subject.lower() else f"{subject} - {remainder}"
else:
label = remainder or subject or "local reference"
return (f"{label} (course notes)", False)