eldinosaur's picture
PocketAccountant: custom ledger UI + deterministic agent (engine, ledger, retrieval, classifier)
c55ab5e verified
Raw
History Blame Contribute Delete
6.17 kB
"""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]
@dataclass
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
@dataclass
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()
@classmethod
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}.",
)