|
| 1 | +"""Structured-identifier extraction for the identifier layer. |
| 2 | +
|
| 3 | +The retrieval pipeline matches through lexical BM25 and semantic embeddings, and |
| 4 | +both are structurally blind to a structured identifier (a code like |
| 5 | +``GACUJIMA/2025/36`` is one atomic tsvector token; embeddings cannot tell twin |
| 6 | +extracts apart). This module extracts those identifiers at ingest into the |
| 7 | +``doc_identifier`` table (see sql/2026-07-doc-identifier.sql) so the query side |
| 8 | +can look them up exactly (see the query lane, step 4). |
| 9 | +
|
| 10 | +Classes extracted here: |
| 11 | +- ``code``: letter-prefixed slash expedient/project codes (``GACUJIMA/2025/36``, |
| 12 | + ``ERESAR/2026/39R07/0008``) and compact body project codes (``24I636``). |
| 13 | +- ``bdns``: BDNS subsidy database IDs (``BDNS 895054``), body-only. |
| 14 | +- ``norm``: the document's OWN norm identity (``DECRETO 74/2026``), read off the |
| 15 | + start of its title — this is what norm-pin resolves today by title ILIKE; |
| 16 | + storing it lets step 4 migrate norm-pin onto an exact lookup. |
| 17 | +
|
| 18 | +Deliberately NOT extracted: |
| 19 | +- ``person`` — names are lexically matchable and already retrieve correctly |
| 20 | + (probes IP06/07/08 pass); pinning on common names carries the highest |
| 21 | + false-pin risk, so names stay lexical-only. |
| 22 | +- ``ref`` — the DOGV publication ref is already a first-class, uniquely-indexed |
| 23 | + column on ``dogv_documents``; the query lane reads that column directly rather |
| 24 | + than duplicating it here. |
| 25 | +
|
| 26 | +Normalization is centralized (``normalize_code`` / ``normalize_norm_key``) so the |
| 27 | +ingest side and the query-side detector produce identical keys — that identity |
| 28 | +is the whole point of the exact-match lane. |
| 29 | +""" |
| 30 | + |
| 31 | +from __future__ import annotations |
| 32 | + |
| 33 | +import re |
| 34 | +from dataclasses import dataclass |
| 35 | + |
| 36 | +from .doc_references import _self_identity |
| 37 | + |
| 38 | +# Title + the first slice of the body carry the high-precision identifiers; deep |
| 39 | +# body text is boilerplate/annex noise. Matches doc_references._BODY_SCAN_CHARS. |
| 40 | +_BODY_SCAN_CHARS = 3000 |
| 41 | + |
| 42 | +# Letter-prefixed slash code: a >=3-letter acronym followed by 1..4 "/group" |
| 43 | +# segments (GACUJIMA/2025/36, ERESAR/2026/39R07/0008). Precision guard applied |
| 44 | +# after the match: at least one segment must be a 4-digit year, which rejects |
| 45 | +# ordinary prose slashes ("y/o", "km/h", "and/or") that lack a year. |
| 46 | +_SLASH_CODE_RE = re.compile(r"\b([A-Za-z]{3,}(?:/[A-Za-z0-9]+){1,4})\b") |
| 47 | +_YEAR_SEGMENT_RE = re.compile(r"^(?:19|20)\d{2}$") |
| 48 | + |
| 49 | +# Compact body project ("I" = investigación) code: 2-digit year + 'I' + 3-4 |
| 50 | +# digits ("codi 25I656", "24I636"). Restricted to the 'I' letter on purpose: |
| 51 | +# the broader \d{2}[A-Za-z]\d+ shape also matches budget-line codes (08R09, |
| 52 | +# 28E050) which are not document identifiers and pollute the class. |
| 53 | +_COMPACT_CODE_RE = re.compile(r"\b(\d{2}[Ii]\d{3,4})\b") |
| 54 | + |
| 55 | +# BDNS subsidy database identifier ("BDNS 895054", "BDNS (Identif.): 895054"). |
| 56 | +_BDNS_RE = re.compile(r"\bBDNS\b[^0-9]{0,20}?(\d{5,7})\b", re.IGNORECASE) |
| 57 | + |
| 58 | + |
| 59 | +@dataclass(frozen=True) |
| 60 | +class ExtractedIdentifier: |
| 61 | + id_kind: str # 'code' | 'bdns' | 'norm' |
| 62 | + id_key: str # normalized lookup key |
| 63 | + raw: str # as it appeared in the text |
| 64 | + source: str # 'title' | 'body' |
| 65 | + |
| 66 | + @property |
| 67 | + def dedup_key(self) -> tuple[str, str]: |
| 68 | + return (self.id_kind, self.id_key) |
| 69 | + |
| 70 | + |
| 71 | +def normalize_code(raw: str) -> str: |
| 72 | + """Canonical key for a slash/compact code: lowercase, separators collapsed to |
| 73 | + a single '/'. The query side normalizes spaced/dashed renderings |
| 74 | + ('gacujima 2025 36', 'GACUJIMA-2025-36') to this same key.""" |
| 75 | + parts = [p for p in re.split(r"[\s/\-]+", raw.strip()) if p] |
| 76 | + return "/".join(parts).lower() |
| 77 | + |
| 78 | + |
| 79 | +def normalize_norm_key(tipo: str, numero: int, anyo: int) -> str: |
| 80 | + """Canonical key for a numbered norm-ref: '<tipo>/<numero>/<anyo>' lowercased. |
| 81 | + tipo is a canonical tipo key from dogv_resolver._TIPO_TITLE_PATTERNS.""" |
| 82 | + return f"{tipo}/{numero}/{anyo}".lower() |
| 83 | + |
| 84 | + |
| 85 | +def _slash_codes(text: str, source: str) -> list[ExtractedIdentifier]: |
| 86 | + out: list[ExtractedIdentifier] = [] |
| 87 | + for m in _SLASH_CODE_RE.finditer(text): |
| 88 | + token = m.group(1) |
| 89 | + segments = token.split("/") |
| 90 | + # Precision guard: a real expedient code carries a 4-digit year segment. |
| 91 | + if not any(_YEAR_SEGMENT_RE.match(seg) for seg in segments): |
| 92 | + continue |
| 93 | + # Reject a plain two-number norm-ref (39/2015) that happens to have a |
| 94 | + # letter word glued by the tokenizer — those are handled by the norm |
| 95 | + # class, not code. A code has a genuine letter-only lead segment. |
| 96 | + if not re.match(r"^[A-Za-z]{3,}$", segments[0]): |
| 97 | + continue |
| 98 | + out.append( |
| 99 | + ExtractedIdentifier( |
| 100 | + id_kind="code", id_key=normalize_code(token), raw=token, source=source |
| 101 | + ) |
| 102 | + ) |
| 103 | + return out |
| 104 | + |
| 105 | + |
| 106 | +def _compact_codes(text: str, source: str) -> list[ExtractedIdentifier]: |
| 107 | + out: list[ExtractedIdentifier] = [] |
| 108 | + for m in _COMPACT_CODE_RE.finditer(text): |
| 109 | + token = m.group(1) |
| 110 | + out.append( |
| 111 | + ExtractedIdentifier( |
| 112 | + id_kind="code", id_key=token.lower(), raw=token, source=source |
| 113 | + ) |
| 114 | + ) |
| 115 | + return out |
| 116 | + |
| 117 | + |
| 118 | +def _bdns_ids(text: str, source: str) -> list[ExtractedIdentifier]: |
| 119 | + out: list[ExtractedIdentifier] = [] |
| 120 | + for m in _BDNS_RE.finditer(text): |
| 121 | + digits = m.group(1) |
| 122 | + out.append( |
| 123 | + ExtractedIdentifier( |
| 124 | + id_kind="bdns", id_key=digits, raw=m.group(0).strip(), source=source |
| 125 | + ) |
| 126 | + ) |
| 127 | + return out |
| 128 | + |
| 129 | + |
| 130 | +def _norm_identity(title: str) -> list[ExtractedIdentifier]: |
| 131 | + """The document's own numbered norm identity (DECRETO 74/2026), read off the |
| 132 | + start of its title. Date-only Resoluciones (no numero) are not pinned here.""" |
| 133 | + self_id = _self_identity(title or "") |
| 134 | + if self_id.tipo is None or self_id.numero is None or self_id.anyo is None: |
| 135 | + return [] |
| 136 | + key = normalize_norm_key(self_id.tipo, self_id.numero, self_id.anyo) |
| 137 | + raw = f"{self_id.tipo} {self_id.numero}/{self_id.anyo}" |
| 138 | + return [ExtractedIdentifier(id_kind="norm", id_key=key, raw=raw, source="title")] |
| 139 | + |
| 140 | + |
| 141 | +def extract_doc_identifiers(title: str, body: str | None) -> list[ExtractedIdentifier]: |
| 142 | + """All identifiers found in ``title`` (highest precision) then the first |
| 143 | + ``_BODY_SCAN_CHARS`` of ``body``. Deduplicated on (id_kind, id_key), |
| 144 | + keeping the first (title-sourced) occurrence.""" |
| 145 | + title = title or "" |
| 146 | + ids: list[ExtractedIdentifier] = [] |
| 147 | + |
| 148 | + ids.extend(_slash_codes(title, "title")) |
| 149 | + ids.extend(_compact_codes(title, "title")) |
| 150 | + ids.extend(_norm_identity(title)) |
| 151 | + |
| 152 | + if body: |
| 153 | + snippet = body[:_BODY_SCAN_CHARS] |
| 154 | + ids.extend(_slash_codes(snippet, "body")) |
| 155 | + ids.extend(_compact_codes(snippet, "body")) |
| 156 | + ids.extend(_bdns_ids(snippet, "body")) |
| 157 | + |
| 158 | + seen: set[tuple[str, str]] = set() |
| 159 | + deduped: list[ExtractedIdentifier] = [] |
| 160 | + for ident in ids: |
| 161 | + if ident.dedup_key in seen: |
| 162 | + continue |
| 163 | + seen.add(ident.dedup_key) |
| 164 | + deduped.append(ident) |
| 165 | + return deduped |
0 commit comments