Skip to content

Commit 604ebfa

Browse files
videscarclaude
andcommitted
feat(identifier-layer): step 3 — doc_identifier table + ingest extractor
New doc_identifier table (document_id, id_kind, id_key, raw, source) with a (id_kind, id_key) lookup index. api/identifiers.py extracts three classes at ingest — code (letter-prefixed slash expedient codes + \d{2}I\d{3,4} body codes), bdns (body), and norm (the doc's own numbered identity, subsuming norm-pin's title ILIKE). person stays lexical-only; ref stays on its column. Backfill script mirrors build_doc_references and is wired into ingest_pipeline best-effort. Full-corpus backfill (51,637 docs, ~15s): code 7,161/2,695, bdns 3,891/1,807, norm 1,424/665. Compact code regex tightened to the I-shape after the first pass caught budget-line codes (08R09/28E050) — not identifiers. Acid test: an exact (id_kind, id_key) lookup returns precisely each probe's gold docs (match-count <=2, the es/va pair). No retrieval path changed yet, so the probe set is unchanged at 8/12; the query lane consuming this table is step 4. Full suite 244 passed. Table/backfill are additive and unread by prod code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a09426e commit 604ebfa

6 files changed

Lines changed: 462 additions & 1 deletion

File tree

api/identifiers.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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

api/ingest_pipeline.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from api.config import get_settings
99
from api.db import SessionLocal
1010
from scripts.build_chunks import build_chunks_for_range
11+
from scripts.build_doc_identifiers import build_identifiers_for_range
1112
from scripts.build_doc_references import build_references_for_range
1213
from scripts.classify_documents import classify_range
1314
from scripts.extract_documents import process_issue
@@ -96,5 +97,11 @@ def run_pipeline(
9697
build_references_for_range(db, start_date, end_date)
9798
except Exception:
9899
logger.exception("doc_reference.range_failed start=%s end=%s", start_date, end_date)
100+
101+
# Structured-identifier extraction (identifier layer): also best-effort.
102+
try:
103+
build_identifiers_for_range(db, start_date, end_date)
104+
except Exception:
105+
logger.exception("doc_identifier.range_failed start=%s end=%s", start_date, end_date)
99106
finally:
100107
db.close()

docs/identifier_layer_design.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,26 @@ year-trapped population, not merely preparatory.
182182

183183
1. Build the identifier-probe set + baseline it on current prod. *(done — 6/12, above)*
184184
2. Intent year-guard (smallest, independent win). *(done — 8/12, `api/intent.py` `_complex_code_spans`; not yet deployed to prod)*
185-
3. Ingest extractor + `doc_identifier` table.
185+
3. Ingest extractor + `doc_identifier` table. *(done — table + `api/identifiers.py` + backfill; see below)*
186186
4. Query lane + migrate norm-pin onto it.
187187
5. Fold in code / bdns / person classes (person stays lexical-only — already 3/3; not pinned).
188188

189+
### Step 3 result (dev DB backfill, 2026-07-14)
190+
191+
`doc_identifier` created (`sql/2026-07-doc-identifier.sql`) and backfilled over all
192+
51,637 docs in ~15 s (`scripts/build_doc_identifiers.py`, wired into `ingest_pipeline`
193+
best-effort like `doc_reference`). Classes: **code 7,161 rows / 2,695 keys** (2,542 slash
194+
expedient codes + 153 `I`-shape body codes), **bdns 3,891 / 1,807**, **norm 1,424 / 665**.
195+
Two calibrations from the backfill: (a) the compact body-code regex was tightened to the
196+
`\d{2}I\d{3,4}` shape — the broader `\d{2}[A-Za-z]\d+` was also catching budget-line codes
197+
(`08R09`, `28E050`) that are not document identifiers; (b) `norm` extracts the doc's *own*
198+
identity (1,424 ≈ the 1,450 titles that start with a numbered norm — the audit's 3,649
199+
counted norm-refs appearing anywhere, which is the wrong population for a pin). Acid test:
200+
an exact `(id_kind, id_key)` lookup returns precisely the gold docs for every probe
201+
(gacujima/2025/36→{90640,90686}, gacujima/2024/26→{11849,33750}, bdns 895054→{85608,88100},
202+
decreto/74/2026→{86851,89343}), all match-counts ≤2 (es/va pair). `ref 2026/4148` resolves
203+
via the existing `ref` column. No retrieval path changed yet (probe set stays 8/12).
204+
189205
## Open questions
190206

191207
- Person-name matching: exact-ish on title/body, but names are common — needs a uniqueness

scripts/build_doc_identifiers.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""
2+
Backfill the doc_identifier structured-identifier table from existing corpus
3+
titles/bodies (see sql/2026-07-doc-identifier.sql, api/identifiers.py).
4+
5+
Idempotent (ON CONFLICT DO NOTHING) and safe to re-run over the full corpus.
6+
7+
Usage:
8+
python scripts/build_doc_identifiers.py [--batch-size 500] [--limit N]
9+
"""
10+
11+
import argparse
12+
import logging
13+
import time
14+
15+
from sqlalchemy import text as sa_text
16+
from sqlalchemy.orm import load_only
17+
18+
try:
19+
from . import _path # type: ignore # ensures project root is on sys.path
20+
except ImportError:
21+
import _path # type: ignore # noqa: F401
22+
23+
from api.db import SessionLocal
24+
from api.identifiers import extract_doc_identifiers
25+
from api.models import DogvDocument, DogvIssue
26+
27+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
28+
logger = logging.getLogger("dogv.build_doc_identifiers")
29+
30+
_INSERT_SQL = sa_text(
31+
"""
32+
INSERT INTO doc_identifier (document_id, id_kind, id_key, raw, source)
33+
VALUES (:document_id, :id_kind, :id_key, :raw, :source)
34+
ON CONFLICT (document_id, id_kind, id_key) DO NOTHING
35+
"""
36+
)
37+
38+
39+
def build_identifiers_for_doc(db, doc: DogvDocument) -> int:
40+
"""Extract + insert identifiers for one document. Returns count extracted."""
41+
idents = extract_doc_identifiers(doc.title or "", doc.text)
42+
for ident in idents:
43+
db.execute(
44+
_INSERT_SQL,
45+
{
46+
"document_id": doc.id,
47+
"id_kind": ident.id_kind,
48+
"id_key": ident.id_key,
49+
"raw": ident.raw,
50+
"source": ident.source,
51+
},
52+
)
53+
return len(idents)
54+
55+
56+
def build_identifiers_for_range(db, start_date=None, end_date=None) -> int:
57+
"""Extract+insert identifiers for documents ingested in [start_date,
58+
end_date] (by issue date). Called from the ingest pipeline right after text
59+
extraction; failure-isolated per document so a bad extraction never fails
60+
ingest. Returns the number of documents processed."""
61+
query = db.query(DogvDocument).options(
62+
load_only(DogvDocument.id, DogvDocument.title, DogvDocument.text)
63+
)
64+
if start_date is not None or end_date is not None:
65+
query = query.join(DogvIssue, DogvDocument.issue_id == DogvIssue.id)
66+
if start_date is not None:
67+
query = query.filter(DogvIssue.date >= start_date)
68+
if end_date is not None:
69+
query = query.filter(DogvIssue.date <= end_date)
70+
71+
processed = 0
72+
for doc in query.all():
73+
try:
74+
build_identifiers_for_doc(db, doc)
75+
except Exception:
76+
logger.exception("doc_identifier.extract_failed document_id=%s", doc.id)
77+
db.rollback()
78+
continue
79+
processed += 1
80+
db.commit()
81+
logger.info("doc_identifier.range_processed docs=%d", processed)
82+
return processed
83+
84+
85+
def main():
86+
parser = argparse.ArgumentParser()
87+
parser.add_argument("--batch-size", type=int, default=500)
88+
parser.add_argument("--limit", type=int, default=None, help="cap docs processed (debug)")
89+
args = parser.parse_args()
90+
91+
db = SessionLocal()
92+
start = time.monotonic()
93+
docs_processed = 0
94+
total_extracted = 0
95+
96+
try:
97+
id_query = db.query(DogvDocument.id).order_by(DogvDocument.id)
98+
if args.limit:
99+
id_query = id_query.limit(args.limit)
100+
all_ids = [row[0] for row in id_query.all()]
101+
102+
for i in range(0, len(all_ids), args.batch_size):
103+
batch_ids = all_ids[i : i + args.batch_size]
104+
docs = (
105+
db.query(DogvDocument)
106+
.options(load_only(DogvDocument.id, DogvDocument.title, DogvDocument.text))
107+
.filter(DogvDocument.id.in_(batch_ids))
108+
.all()
109+
)
110+
for d in docs:
111+
total_extracted += build_identifiers_for_doc(db, d)
112+
docs_processed += 1
113+
db.commit()
114+
elapsed = time.monotonic() - start
115+
logger.info(
116+
"progress docs=%d identifiers=%d elapsed=%.1fs",
117+
docs_processed,
118+
total_extracted,
119+
elapsed,
120+
)
121+
122+
kind_rows = db.execute(
123+
sa_text("SELECT id_kind, count(*) FROM doc_identifier GROUP BY id_kind ORDER BY id_kind")
124+
).all()
125+
kind_counts = {row[0]: row[1] for row in kind_rows}
126+
distinct_keys = db.execute(
127+
sa_text("SELECT id_kind, count(DISTINCT id_key) FROM doc_identifier GROUP BY id_kind")
128+
).all()
129+
finally:
130+
db.close()
131+
132+
elapsed = time.monotonic() - start
133+
logger.info("=== build_doc_identifiers summary ===")
134+
logger.info("docs_processed=%d elapsed=%.1fs", docs_processed, elapsed)
135+
logger.info("identifiers_extracted_this_run=%d", total_extracted)
136+
logger.info("rows_by_id_kind=%s", kind_counts)
137+
logger.info("distinct_keys_by_id_kind=%s", dict(distinct_keys))
138+
139+
140+
if __name__ == "__main__":
141+
main()

sql/2026-07-doc-identifier.sql

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
-- Structured-identifier index for the identifier layer.
2+
--
3+
-- Lexical BM25 and semantic embeddings are both blind to structured identifiers
4+
-- (a code like GACUJIMA/2025/36 is one atomic tsvector token; embeddings cannot
5+
-- separate twin extracts). This table holds every identifier extracted from a
6+
-- document's title/body at ingest, so the query side can look one up EXACTLY and
7+
-- hard-pin the document (see api/identifiers.py, and the query lane).
8+
--
9+
-- id_kind:
10+
-- 'code' — letter-prefixed slash expedient/project codes (GACUJIMA/2025/36,
11+
-- ERESAR/2026/39R07/0008) and compact body codes (24I636)
12+
-- 'bdns' — BDNS subsidy database IDs (895054), body-only
13+
-- 'norm' — the document's own numbered norm identity (DECRETO 74/2026),
14+
-- read off the start of its title (subsumes norm-pin's title ILIKE)
15+
-- Deliberately excluded: 'person' (names stay lexical-only) and 'ref' (already a
16+
-- uniquely-indexed column on dogv_documents; the query lane reads it directly).
17+
--
18+
-- id_key is the normalized lookup key (lowercase, separators collapsed to '/'),
19+
-- produced by the same api/identifiers.normalize_* helpers on both the ingest
20+
-- and query sides so an exact match is possible.
21+
22+
CREATE TABLE IF NOT EXISTS doc_identifier (
23+
id BIGSERIAL PRIMARY KEY,
24+
document_id BIGINT NOT NULL,
25+
id_kind TEXT NOT NULL,
26+
id_key TEXT NOT NULL,
27+
raw TEXT,
28+
source TEXT NOT NULL, -- 'title' | 'body'
29+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
30+
31+
CONSTRAINT fk_doc_identifier_document
32+
FOREIGN KEY (document_id) REFERENCES dogv_documents(id) ON DELETE CASCADE,
33+
CONSTRAINT uq_doc_identifier
34+
UNIQUE (document_id, id_kind, id_key)
35+
);
36+
37+
-- The exact-match query lane: SELECT document_id WHERE id_kind = ? AND id_key = ?.
38+
CREATE INDEX IF NOT EXISTS idx_doc_identifier_key
39+
ON doc_identifier (id_kind, id_key);
40+
41+
CREATE INDEX IF NOT EXISTS idx_doc_identifier_document
42+
ON doc_identifier (document_id);

0 commit comments

Comments
 (0)