Initial commit — kb-app RAG server
This commit is contained in:
+139
@@ -0,0 +1,139 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from .config import CONFIG
|
||||
from .parsers import parse_file, PARSERS
|
||||
from .chunk import chunk_text, count_tokens
|
||||
from .embed import embed_batch
|
||||
from . import db
|
||||
|
||||
|
||||
def file_hash(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def category_from_path(path):
|
||||
try:
|
||||
rel = path.relative_to(CONFIG.corpus_root)
|
||||
except ValueError:
|
||||
return "unknown"
|
||||
parts = rel.parts[:-1]
|
||||
return "/".join(parts) if parts else "uncategorized"
|
||||
|
||||
|
||||
def build_chunk_prefix(title, category, last_modified, doc_metadata):
|
||||
"""Construct a metadata breadcrumb to prepend to each chunk."""
|
||||
parts = []
|
||||
if last_modified:
|
||||
parts.append(last_modified.strftime("%Y-%m-%d"))
|
||||
if category:
|
||||
parts.append(f"Category: {category}")
|
||||
if title:
|
||||
parts.append(f"Title: {title}")
|
||||
fmt = doc_metadata.get("format")
|
||||
if fmt:
|
||||
parts.append(f"Format: {fmt}")
|
||||
return f"[{' | '.join(parts)}]\n\n" if parts else ""
|
||||
|
||||
|
||||
def ingest_file(path):
|
||||
path = Path(path)
|
||||
if path.suffix.lower() not in PARSERS:
|
||||
return {"path": str(path), "status": "skipped", "reason": "unsupported type"}
|
||||
|
||||
fhash = file_hash(path)
|
||||
category = category_from_path(path)
|
||||
|
||||
try:
|
||||
title, text, metadata = parse_file(path)
|
||||
except Exception as e:
|
||||
return {"path": str(path), "status": "error", "reason": f"parse failed: {e}"}
|
||||
|
||||
last_modified = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
||||
doc_id, changed = db.upsert_document(
|
||||
source_path=str(path),
|
||||
file_hash=fhash,
|
||||
file_type=path.suffix.lower(),
|
||||
category=category,
|
||||
title=title,
|
||||
last_modified=last_modified,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
if not changed:
|
||||
return {"path": str(path), "status": "unchanged", "doc_id": doc_id}
|
||||
|
||||
chunks = chunk_text(text)
|
||||
if not chunks:
|
||||
return {"path": str(path), "status": "empty", "doc_id": doc_id}
|
||||
|
||||
# Build per-chunk topic labels using the LLM
|
||||
from .topic_extractor import extract_topic
|
||||
|
||||
base_prefix = build_chunk_prefix(title, category, last_modified, metadata)
|
||||
|
||||
chunk_records = []
|
||||
embeddings_to_compute = []
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
# Generate per-chunk topic label
|
||||
topic = extract_topic(chunk, doc_title=title, doc_category=category)
|
||||
|
||||
if topic:
|
||||
chunk_with_context = f"{base_prefix}Topic: {topic}\n\n{chunk}"
|
||||
else:
|
||||
chunk_with_context = base_prefix + chunk
|
||||
|
||||
embeddings_to_compute.append(chunk_with_context)
|
||||
chunk_records.append({
|
||||
"chunk_index": i,
|
||||
"content_with_context": chunk_with_context,
|
||||
"raw_chunk": chunk,
|
||||
"topic": topic,
|
||||
})
|
||||
|
||||
# Batch embed
|
||||
embeddings = []
|
||||
for i in range(0, len(embeddings_to_compute), CONFIG.embed_batch_size):
|
||||
batch = embeddings_to_compute[i:i + CONFIG.embed_batch_size]
|
||||
embeddings.extend(embed_batch(batch))
|
||||
|
||||
# Build final records for DB
|
||||
final_records = [
|
||||
{
|
||||
"chunk_index": rec["chunk_index"],
|
||||
"content": rec["content_with_context"],
|
||||
"embedding": emb,
|
||||
"token_count": count_tokens(rec["content_with_context"]),
|
||||
"metadata": {
|
||||
"raw_chunk": rec["raw_chunk"],
|
||||
"topic": rec["topic"],
|
||||
},
|
||||
}
|
||||
for rec, emb in zip(chunk_records, embeddings)
|
||||
]
|
||||
db.insert_chunks(doc_id, final_records)
|
||||
|
||||
return {
|
||||
"path": str(path),
|
||||
"status": "indexed",
|
||||
"doc_id": doc_id,
|
||||
"chunks": len(chunks),
|
||||
"category": category,
|
||||
}
|
||||
|
||||
|
||||
def ingest_directory(root=None):
|
||||
root = Path(root) if root else Path(CONFIG.corpus_root)
|
||||
results = []
|
||||
for path in root.rglob("*"):
|
||||
if path.is_file() and path.suffix.lower() in PARSERS:
|
||||
print(f"Processing: {path}", flush=True)
|
||||
result = ingest_file(path)
|
||||
results.append(result)
|
||||
print(f" → {result['status']}", flush=True)
|
||||
return results
|
||||
Reference in New Issue
Block a user