PocketAccountant: custom ledger UI + deterministic agent (engine, ledger, retrieval, classifier)
c55ab5e verified | """Local retriever over the regulation corpus — no cloud API, no downloads. | |
| A pure-Python TF-IDF cosine retriever. It is deterministic, offline, and good enough | |
| to ground tax answers in the right article. The ``Retriever`` interface leaves room | |
| for a heavier embedding backend later (sentence-transformers), but the default | |
| requires nothing beyond the standard library — which keeps the 🔌 Off-the-Grid badge | |
| honest. | |
| The cite-or-abstain rule lives here: if the best match scores below a threshold, the | |
| agent must NOT answer from the corpus — it abstains and recommends a CPA. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import re | |
| import unicodedata | |
| from collections import Counter | |
| from dataclasses import dataclass | |
| from typing import Dict, List, Optional | |
| from .corpus import Document, load_corpus | |
| # Small Spanish/English stopword set — enough to stop common words dominating. | |
| _STOPWORDS = { | |
| "de", "la", "el", "los", "las", "un", "una", "y", "o", "en", "que", "del", | |
| "se", "su", "por", "para", "con", "es", "al", "lo", "como", "mas", "si", | |
| "no", "the", "a", "an", "of", "to", "in", "is", "for", "on", "and", "or", | |
| "be", "as", "it", "this", "that", "are", "i", "my", "me", "do", "can", | |
| } | |
| def _deaccent(s: str) -> str: | |
| return "".join(c for c in unicodedata.normalize("NFD", s) | |
| if unicodedata.category(c) != "Mn") | |
| # Derivational suffixes, longest first, so 'deducción' / 'deducible' / 'deducir' | |
| # all reduce to the same stem 'deduc'. Conservative — only stripped when a stem of | |
| # length >= 3 remains. | |
| _SUFFIXES = ("aciones", "iciones", "acion", "icion", "ciones", "cion", "ibles", | |
| "ables", "ible", "able", "mente", "idades", "idad", "ir", "ar", "er") | |
| def _stem(tok: str) -> str: | |
| # plurals | |
| if len(tok) > 4 and tok.endswith("es"): | |
| tok = tok[:-2] | |
| elif len(tok) > 3 and tok.endswith("s"): | |
| tok = tok[:-1] | |
| # one derivational suffix | |
| for suf in _SUFFIXES: | |
| if tok.endswith(suf) and len(tok) - len(suf) >= 3: | |
| return tok[: -len(suf)] | |
| return tok | |
| def tokenize(text: str) -> List[str]: | |
| text = _deaccent(text.lower()) | |
| return [_stem(t) for t in re.findall(r"[a-z0-9]+", text) | |
| if len(t) >= 2 and t not in _STOPWORDS] | |
| class Passage: | |
| doc_id: str | |
| title: str | |
| source: str | |
| jurisdiction: str | |
| year: Optional[int] | |
| text: str | |
| score: float | |
| matched_terms: int = 0 # distinct query terms found in the passage | |
| class CiteResult: | |
| grounded: bool | |
| query: str | |
| passages: List[Passage] | |
| message: str | |
| # Below this cosine score, we treat the corpus as not supporting the query. | |
| DEFAULT_MIN_SCORE = 0.08 | |
| class Retriever: | |
| def __init__(self, documents: List[Document]): | |
| self.docs = documents | |
| self._idf: Dict[str, float] = {} | |
| self._vectors: List[Dict[str, float]] = [] | |
| self._build() | |
| def from_corpus_dir(cls, path) -> "Retriever": | |
| return cls(load_corpus(path)) | |
| def _build(self) -> None: | |
| tokenized = [tokenize(f"{d.title} {d.text}") for d in self.docs] | |
| n = len(self.docs) | |
| df: Counter = Counter() | |
| for toks in tokenized: | |
| df.update(set(toks)) | |
| self._idf = {term: math.log((n + 1) / (freq + 1)) + 1 | |
| for term, freq in df.items()} | |
| self._vectors = [self._vectorize(toks) for toks in tokenized] | |
| def _vectorize(self, tokens: List[str]) -> Dict[str, float]: | |
| tf = Counter(tokens) | |
| vec = {t: c * self._idf.get(t, 0.0) for t, c in tf.items()} | |
| norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0 | |
| return {t: v / norm for t, v in vec.items()} | |
| def _query_vector(self, query: str) -> Dict[str, float]: | |
| tf = Counter(tokenize(query)) | |
| vec = {t: c * self._idf.get(t, 0.0) for t, c in tf.items() if t in self._idf} | |
| norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0 | |
| return {t: v / norm for t, v in vec.items()} | |
| def search(self, query: str, k: int = 3, | |
| jurisdiction: Optional[str] = None) -> List[Passage]: | |
| qv = self._query_vector(query) | |
| q_terms = set(qv) | |
| scored: List[Passage] = [] | |
| for doc, dv in zip(self.docs, self._vectors): | |
| if jurisdiction and doc.jurisdiction != jurisdiction: | |
| continue | |
| score = sum(w * dv.get(t, 0.0) for t, w in qv.items()) | |
| if score <= 0: | |
| continue | |
| matched = len(q_terms & set(dv)) | |
| scored.append(Passage(doc.id, doc.title, doc.source, doc.jurisdiction, | |
| doc.year, doc.text, round(score, 4), matched)) | |
| scored.sort(key=lambda p: p.score, reverse=True) | |
| return scored[:k] | |
| def cite(self, query: str, k: int = 3, min_score: float = DEFAULT_MIN_SCORE, | |
| jurisdiction: Optional[str] = None) -> CiteResult: | |
| """Retrieve with the cite-or-abstain guardrail. | |
| A match must clear the score threshold AND share at least two distinct terms | |
| with the passage (one term, for single-word queries). The two-term rule kills | |
| false positives where the query overlaps a corpus doc only on one generic word | |
| (e.g. 'crédito' in 'crédito fiscal' matching 'tarjeta de crédito'). | |
| """ | |
| passages = self.search(query, k=k, jurisdiction=jurisdiction) | |
| min_terms = 2 if len(set(tokenize(query))) >= 2 else 1 | |
| top_ok = bool(passages) and ( | |
| passages[0].score >= min_score and passages[0].matched_terms >= min_terms | |
| ) | |
| if not top_ok: | |
| return CiteResult( | |
| grounded=False, query=query, passages=passages, | |
| message=("No tengo una fuente en mi base de regulación que respalde " | |
| "esto con confianza. Te recomiendo confirmarlo con un " | |
| "contador público / CPA."), | |
| ) | |
| cites = ", ".join(f"{p.source}" for p in passages) | |
| return CiteResult( | |
| grounded=True, query=query, passages=passages, | |
| message=f"Respaldado por: {cites}.", | |
| ) | |