Spaces:
Configuration error
Configuration error
| """ | |
| Retrieval Memory (Priority 9) | |
| ================================ | |
| Logs every query + retrieval outcome to an append-only JSONL file. | |
| Provides a mining function that surfaces "if query mentions X, always retrieve Y" patterns | |
| so they can be fed back into the authority registry. | |
| Two components: | |
| 1. RetrievalLogger β appends one JSON line per query (zero latency impact; | |
| writes are fire-and-forget via a background thread). | |
| 2. mine_retrieval_patterns() β offline analysis script that reads the log | |
| and produces authority_registry ADDITIONS for topics that are consistently | |
| missing an authority that always appears when manually inspected. | |
| Usage in retriever.py: | |
| from app.retrieval.retrieval_memory import RetrievalLogger | |
| _mem_logger = RetrievalLogger() | |
| # After coverage check, inside search() or supplement_and_rerank(): | |
| _mem_logger.log( | |
| query = query, | |
| topic = _taxonomy.get("topics", []), | |
| retrieved = [c.get("rel_path", "") for c in final_chunks[:15]], | |
| coverage_pct = _coverage_result.get("coverage_pct", 0), | |
| missing = _coverage_result.get("missing", []), | |
| ) | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import os | |
| import threading | |
| from collections import defaultdict | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any | |
| logger = logging.getLogger(__name__) | |
| # ββ Log file location βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _DEFAULT_LOG = Path(__file__).resolve().parent.parent.parent / "data" / "retrieval_memory.jsonl" | |
| class RetrievalLogger: | |
| """ | |
| Append-only JSONL logger for retrieval outcomes. | |
| All writes happen on a daemon worker thread β zero latency impact on the | |
| main retrieval path. | |
| Log entry schema (one JSON object per line): | |
| { | |
| "ts": "ISO-8601 UTC timestamp", | |
| "query": "raw query string", | |
| "topics": ["cross_charge", ...], # taxonomy topic matches | |
| "retrieved": ["circulars/199.../...", ...], # top-15 rel_paths | |
| "coverage_pct": 85, # mandatory coverage % | |
| "missing": ["CGST_SEC_25", ...], # authorities NOT found | |
| "duration_ms": 142 # retrieval wall time (optional) | |
| } | |
| """ | |
| def __init__(self, log_path: Path | str | None = None): | |
| self._path = Path(log_path) if log_path else _DEFAULT_LOG | |
| self._path.parent.mkdir(parents=True, exist_ok=True) | |
| self._lock = threading.Lock() | |
| self._queue = [] | |
| self._worker = threading.Thread(target=self._flush_loop, daemon=True) | |
| self._worker.start() | |
| logger.info(f"RetrievalLogger: logging to {self._path}") | |
| def log( | |
| self, | |
| query: str, | |
| topics: list[str], | |
| retrieved: list[str], | |
| coverage_pct: int, | |
| missing: list[str], | |
| duration_ms: int = 0, | |
| ) -> None: | |
| """Enqueues a log entry (non-blocking).""" | |
| entry = { | |
| "ts": datetime.now(timezone.utc).isoformat(), | |
| "query": query, | |
| "topics": topics, | |
| "retrieved": retrieved[:20], # cap to avoid bloat | |
| "coverage_pct": coverage_pct, | |
| "missing": missing, | |
| "duration_ms": duration_ms, | |
| } | |
| with self._lock: | |
| self._queue.append(entry) | |
| def _flush_loop(self) -> None: | |
| """Worker: drains the queue every 5 seconds.""" | |
| import time | |
| while True: | |
| time.sleep(5) | |
| self._drain() | |
| def _drain(self) -> None: | |
| with self._lock: | |
| batch, self._queue = self._queue, [] | |
| if not batch: | |
| return | |
| try: | |
| with open(self._path, "a", encoding="utf-8") as fh: | |
| for entry in batch: | |
| fh.write(json.dumps(entry, ensure_ascii=False) + "\n") | |
| except Exception as exc: | |
| logger.warning(f"RetrievalLogger flush failed: {exc}") | |
| # ββ Offline pattern miner βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def mine_retrieval_patterns( | |
| log_path: Path | str | None = None, | |
| min_occurrences: int = 5, | |
| min_miss_rate: float = 0.3, | |
| output_path: Path | str | None = None, | |
| ) -> dict[str, Any]: | |
| """ | |
| Analyses the retrieval log to find patterns like: | |
| "For topic X, authority Y is missing in 40% of queries β add Y to registry" | |
| Args: | |
| log_path: Path to retrieval_memory.jsonl (default: data/retrieval_memory.jsonl) | |
| min_occurrences: Minimum times a topic must appear to be included in report | |
| min_miss_rate: Fraction of queries where an authority is missing to flag it | |
| output_path: If set, writes JSON report to this path | |
| Returns: | |
| { | |
| "topic_stats": { | |
| "cross_charge": { | |
| "count": 42, | |
| "avg_coverage_pct": 67, | |
| "frequent_missing": [ | |
| {"authority": "CGST_SEC_25", "miss_rate": 0.52, "count": 22} | |
| ] | |
| } | |
| }, | |
| "registry_additions": { | |
| "cross_charge": { | |
| "sections": ["CGST_SEC_25"], # should add to registry | |
| "circulars": ["CIRCULAR_199"], | |
| } | |
| } | |
| } | |
| After running this, review the `registry_additions` section and apply them | |
| to data/authority_registry.json. The legal team can also run this manually | |
| after observing patterns in production. | |
| """ | |
| _log_path = Path(log_path) if log_path else _DEFAULT_LOG | |
| if not _log_path.exists(): | |
| return {"error": f"Log file not found: {_log_path}", "topic_stats": {}, "registry_additions": {}} | |
| # Load log | |
| entries = [] | |
| with open(_log_path, "r", encoding="utf-8") as fh: | |
| for line in fh: | |
| line = line.strip() | |
| if line: | |
| try: | |
| entries.append(json.loads(line)) | |
| except json.JSONDecodeError: | |
| continue | |
| if not entries: | |
| return {"error": "Log is empty", "topic_stats": {}, "registry_additions": {}} | |
| # Aggregate per topic | |
| topic_counts: dict[str, int] = defaultdict(int) | |
| topic_cov_sum: dict[str, int] = defaultdict(int) | |
| topic_miss: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) | |
| for entry in entries: | |
| for topic in entry.get("topics", ["unknown"]): | |
| topic_counts[topic] += 1 | |
| topic_cov_sum[topic] += entry.get("coverage_pct", 100) | |
| for miss in entry.get("missing", []): | |
| topic_miss[topic][miss] += 1 | |
| # Build stats | |
| topic_stats: dict[str, Any] = {} | |
| registry_additions: dict[str, Any] = {} | |
| for topic, count in sorted(topic_counts.items(), key=lambda x: -x[1]): | |
| if count < min_occurrences: | |
| continue | |
| avg_cov = round(topic_cov_sum[topic] / count) | |
| miss_items = topic_miss.get(topic, {}) | |
| frequent_missing = [ | |
| { | |
| "authority": auth, | |
| "miss_rate": round(n / count, 2), | |
| "count": n, | |
| } | |
| for auth, n in sorted(miss_items.items(), key=lambda x: -x[1]) | |
| if n / count >= min_miss_rate | |
| ] | |
| topic_stats[topic] = { | |
| "count": count, | |
| "avg_coverage_pct": avg_cov, | |
| "frequent_missing": frequent_missing, | |
| } | |
| if frequent_missing: | |
| additions: dict[str, list[str]] = {"sections": [], "rules": [], "circulars": []} | |
| for item in frequent_missing: | |
| auth = item["authority"] | |
| if "_SEC_" in auth: | |
| additions["sections"].append(auth) | |
| elif "_RUL_" in auth: | |
| additions["rules"].append(auth) | |
| elif auth.startswith("CIRCULAR_"): | |
| additions["circulars"].append(auth) | |
| if any(additions.values()): | |
| registry_additions[topic] = additions | |
| result = { | |
| "analysed": len(entries), | |
| "first_entry": entries[0]["ts"] if entries else None, | |
| "last_entry": entries[-1]["ts"] if entries else None, | |
| "topic_stats": topic_stats, | |
| "registry_additions": registry_additions, | |
| } | |
| if output_path: | |
| with open(Path(output_path), "w", encoding="utf-8") as fh: | |
| json.dump(result, fh, indent=2, ensure_ascii=False) | |
| logger.info(f"Pattern mining report written to {output_path}") | |
| return result | |
| if __name__ == "__main__": | |
| """ | |
| Quick CLI: | |
| python -m app.retrieval.retrieval_memory | |
| """ | |
| import sys | |
| _out = sys.argv[1] if len(sys.argv) > 1 else None | |
| report = mine_retrieval_patterns(output_path=_out) | |
| print(json.dumps(report, indent=2, ensure_ascii=False)) | |