import json import hashlib import logging import os from typing import Dict, List, Tuple from langchain_community.vectorstores import Chroma from langchain_openai import OpenAIEmbeddings from src.data.chroma_config import COLLECTION_NAME, PERSIST_DIRECTORY, ensure_persist_dir # CONFIG PERSIST_DIR = PERSIST_DIRECTORY BATCH_SIZE = 100 logger = logging.getLogger(__name__) class UpdateKBStore: def __init__(self, persist_dir: str = PERSIST_DIR, batch_size: int = BATCH_SIZE): self.persist_dir = persist_dir self.batch_size = batch_size self.embedding = OpenAIEmbeddings(model="text-embedding-3-large") ensure_persist_dir(self.persist_dir) def build_text(self, doc: Dict) -> str: return f""" Domain: {doc.get('domain')} Category: {doc.get('category')} Persona: {doc.get('persona')} Market Context: {doc.get('market_context')} Source: {doc.get('source')} KeyWords: {", ".join(doc.get('keywords', []))} Q: {doc.get('question')} A: {doc.get('answer')} """ def clean_metadata(self, doc): return { "id": str(doc.get("id", "")), "domain": str(doc.get("domain", "")), "category": str(doc.get("category", "")), "topic": str(doc.get("topic", "")), "persona": str(doc.get("persona", "")), "market_context": str(doc.get("market_context", "")), "source": str(doc.get("source", "")), "regulatory_scope": ",".join(doc.get("regulatory_scope", [])) if doc.get("regulatory_scope") else "", "tax_year": int(doc["tax_year"]) if doc.get("tax_year") is not None else 0, "difficulty": str(doc.get("difficulty", "")) } def load_json(self, file_path: str): with open(file_path, "r") as f: return json.load(f) def _doc_id(self, doc: Dict) -> str: """ Stable ID for de-duplication inside the single shared Chroma collection. Prefers the explicit `id` field; otherwise hashes the Q/A text. """ raw_id = str(doc.get("id") or "").strip() if raw_id: return raw_id text = ( (str(doc.get("question") or "") + "\n" + str(doc.get("answer") or "")) .strip() ) h = hashlib.sha256() h.update(text.encode("utf-8")) return h.hexdigest() def ingest_file(self, file_path: str, collection_name: str) -> Dict: data = self.load_json(file_path) # This project uses a single shared Chroma collection. `collection_name` is # treated as a logical label only (kept in metadata for traceability). logical_collection = collection_name print( f"Ingesting {len(data)} records into {COLLECTION_NAME} (logical={logical_collection}), " f"file name = {file_path}" ) db = Chroma( collection_name=COLLECTION_NAME, embedding_function=self.embedding, persist_directory=self.persist_dir ) texts: List[str] = [] metadatas: List[Dict] = [] ids: List[str] = [] inserted = 0 def flush_batch(batch: List[Tuple[str, Dict, str]]) -> int: if not batch: return 0 batch_texts = [t for t, _, _ in batch] batch_metas = [m for _, m, _ in batch] batch_ids = [i for _, _, i in batch] try: existing = set(db.get(ids=batch_ids).get("ids", [])) except Exception: existing = set() to_add = [ (t, m, i) for t, m, i in zip(batch_texts, batch_metas, batch_ids) if i not in existing ] if not to_add: return 0 db.add_texts( texts=[t for t, _, _ in to_add], metadatas=[m for _, m, _ in to_add], ids=[i for _, _, i in to_add], ) return len(to_add) for doc in data: text = self.build_text(doc) metadata = self.clean_metadata(doc) doc_id = self._doc_id(doc) metadata = { **metadata, "namespace": "kb", "logical_collection": str(logical_collection), "source_file": os.path.basename(file_path), } texts.append(text) metadatas.append(metadata) ids.append(doc_id) if len(texts) >= self.batch_size: inserted += flush_batch(list(zip(texts, metadatas, ids))) texts, metadatas = [], [] ids = [] if texts and metadatas and ids: inserted += flush_batch(list(zip(texts, metadatas, ids))) # Persist best-effort (older wrappers require explicit persist). try: db.persist() except Exception: logger.debug("Chroma persist() unavailable or failed", exc_info=True) return { "collection": COLLECTION_NAME, "logical_collection": logical_collection, "records_inserted": inserted } def ingest_all_shards(self) -> dict: """ Auto-ingest all JSON shard files under finance_kb_shards directory. Returns: summary dict for UI """ DATA_DIR = "src/data/finance_kb_shards" if not os.path.exists(DATA_DIR): return { "status": "error", "message": f"Directory not found: {DATA_DIR}" } results = [] total_records = 0 files_processed = 0 for file in os.listdir(DATA_DIR): if not file.endswith(".json"): continue file_path = os.path.join(DATA_DIR, file) collection_name = "kb_shards" try: result = self.ingest_file(file_path, collection_name) total_records += result["records_inserted"] results.append(result) files_processed += 1 except Exception as e: results.append({ "collection": collection_name, "error": str(e) }) return { "status": "success", "files_processed": files_processed, "collections": results, "total_records": total_records }