| """ |
| Product Catalog |
| ================ |
| Single source of truth for "which pesticide products do we actually have labels |
| for?" Derived dynamically from the processed-documents database instead of a |
| hard-coded keyword list. |
| |
| Why this exists |
| --------------- |
| The old product-name extraction in ``cdms_label_tool.py`` matched the user's |
| question against a fixed list of six names |
| (``["roundup", "sevin", "2,4-d", "glyphosate", "carbaryl", "atrazine"]``). |
| Any product outside that list (Dauntless, Kozami, Megalodon, Acquit, ...) was not |
| recognised, so the pipeline fell through to fuzzy heuristics and ultimately ran a |
| global vector search. Because one product (Roundup in this snapshot, "Trust" in |
| the deployed data) owns the majority of the chunks, that global search returned |
| *that* product's chunks for almost any question -- the "it keeps talking about |
| Trust / it answers from a different herbicide" bug reported by ISA. |
| |
| This module resolves a query to a product that we can actually answer for, and |
| lets callers *abstain* when the requested product is not in the index. |
| """ |
|
|
| import re |
| from functools import lru_cache |
| from typing import Dict, List, Optional, Set, Tuple |
|
|
| from src.cdms.schema import DatabaseManager, Document, DocumentChunk |
| from src.config.paths import DB_PATH |
|
|
| try: |
| from rapidfuzz import fuzz |
| _RAPIDFUZZ = True |
| except ImportError: |
| _RAPIDFUZZ = False |
|
|
| |
| _HASH_SUFFIX = re.compile(r"_[0-9a-f]{8,}$", re.IGNORECASE) |
| |
| |
| _NON_PRODUCT_PREFIXES = ("is there any", "what ", "how ", "tell me") |
|
|
| |
| DEFAULT_MATCH_THRESHOLD = 82 |
|
|
|
|
| def normalize_filename(filename: str) -> str: |
| """Turn a stored PDF filename into a human product name. |
| |
| ``roundup_bdc94bbee383.pdf`` -> ``roundup`` |
| ``Brandt_Nema_Q.pdf`` -> ``brandt nema q`` |
| ``24-d_fa1e6bdacae6.pdf`` -> ``24-d`` |
| """ |
| stem = re.sub(r"\.pdf$", "", filename, flags=re.IGNORECASE) |
| stem = _HASH_SUFFIX.sub("", stem) |
| return stem.replace("_", " ").strip().lower() |
|
|
|
|
| class ProductCatalog: |
| """Read-only view of the products we have processed labels for.""" |
|
|
| def __init__(self, db_path: str = None): |
| self.db_path = db_path if db_path is not None else str(DB_PATH) |
|
|
| def _rows(self) -> List[Tuple[str, int]]: |
| """Return (filename, chunk_count) for every processed document.""" |
| db = DatabaseManager(db_path=self.db_path) |
| session = db.get_session() |
| try: |
| rows = [] |
| for doc in session.query(Document).all(): |
| n = ( |
| session.query(DocumentChunk) |
| .filter(DocumentChunk.document_id == doc.id) |
| .count() |
| ) |
| rows.append((doc.filename, n)) |
| return rows |
| finally: |
| session.close() |
|
|
| def catalog(self) -> Dict[str, int]: |
| """Map normalized product name -> total chunk count across its PDFs.""" |
| catalog: Dict[str, int] = {} |
| for filename, n_chunks in self._rows(): |
| product = normalize_filename(filename) |
| if not product or product.startswith(_NON_PRODUCT_PREFIXES): |
| continue |
| catalog[product] = catalog.get(product, 0) + n_chunks |
| return catalog |
|
|
| def known_products(self) -> Set[str]: |
| """Every product with a document row (even if text extraction failed).""" |
| return set(self.catalog().keys()) |
|
|
| def available_products(self) -> Set[str]: |
| """Products we can actually answer about (have at least one chunk).""" |
| return {p for p, n in self.catalog().items() if n > 0} |
|
|
| def is_available(self, product_name: str) -> bool: |
| return normalize_filename(product_name) in self.available_products() |
|
|
| def resolve( |
| self, |
| text: str, |
| threshold: int = DEFAULT_MATCH_THRESHOLD, |
| ) -> Optional[str]: |
| """Resolve free text (a user question) to a known product name. |
| |
| Returns the best-matching *available* product, or ``None`` when nothing |
| clears the threshold -- the caller should then abstain rather than run a |
| global search that would surface the dominant product. |
| """ |
| products = self.available_products() |
| if not products: |
| return None |
|
|
| text_lower = text.lower() |
|
|
| |
| |
| substring_hits = [p for p in products if p and p in text_lower] |
| if substring_hits: |
| return max(substring_hits, key=len) |
|
|
| |
| |
| def _alnum(s: str) -> str: |
| return re.sub(r"[^a-z0-9]", "", s.lower()) |
|
|
| text_alnum = _alnum(text) |
| alnum_hits = [p for p in products if _alnum(p) and _alnum(p) in text_alnum] |
| if alnum_hits: |
| return max(alnum_hits, key=len) |
|
|
| if not _RAPIDFUZZ: |
| return None |
|
|
| |
| best_product, best_score = None, 0.0 |
| for product in products: |
| score = fuzz.partial_ratio(product, text_lower) |
| if score > best_score: |
| best_product, best_score = product, score |
|
|
| return best_product if best_score >= threshold else None |
|
|
|
|
| @lru_cache(maxsize=1) |
| def get_catalog() -> ProductCatalog: |
| """Process-wide singleton (the DB is small and read-mostly).""" |
| return ProductCatalog() |
|
|
|
|
| def diversify_by_product( |
| results: List[dict], |
| limit: int, |
| max_per_product: int = 2, |
| ) -> List[dict]: |
| """Round-robin results across products so one product can't monopolise top-k. |
| |
| On a *general* query (no product filter) the vector search is dominated by |
| whichever product owns most of the index (Roundup = 71% of chunks here), so |
| all top-k hits come from it. This re-ranks by taking the best chunks from |
| each product in turn -- preserving score order within a product -- so the |
| answer draws on a variety of labels instead of a single dominant one. |
| |
| ``results`` must be dicts with ``source_file`` and (ideally) ``score``. |
| Assumes ``results`` is already sorted best-first. |
| """ |
| if not results: |
| return [] |
|
|
| |
| by_product: Dict[str, List[dict]] = {} |
| for r in results: |
| product = normalize_filename(r.get("source_file", "")) or "_unknown" |
| by_product.setdefault(product, []).append(r) |
|
|
| |
| queues = [items[:max_per_product] for items in by_product.values()] |
| out: List[dict] = [] |
| idx = 0 |
| while len(out) < limit and any(idx < len(q) for q in queues): |
| for q in queues: |
| if idx < len(q): |
| out.append(q[idx]) |
| if len(out) >= limit: |
| break |
| idx += 1 |
| return out[:limit] |
|
|
|
|
| def cross_product_abstention( |
| user_question: str, |
| chunk_source_files: List[str], |
| catalog: Optional[ProductCatalog] = None, |
| ) -> Optional[str]: |
| """Decide whether to refuse answering from a different product's chunks. |
| |
| Returns the requested product name (meaning: ABSTAIN, answer nothing) when |
| the user clearly asked about an indexed product but *none* of the retrieved |
| chunks come from it. Returns ``None`` when it is safe to proceed. |
| |
| This is the core guard against the ISA-reported bug where a question about |
| herbicide X was answered from the label of a different (dominant) product. |
| """ |
| catalog = catalog or get_catalog() |
| requested = catalog.resolve(user_question) |
| if not requested or requested not in catalog.available_products(): |
| return None |
| chunk_products = {normalize_filename(s) for s in chunk_source_files if s} |
| return requested if requested not in chunk_products else None |
|
|