Compare commits
4 Commits
2e59c0ef5b
...
e95c678271
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e95c678271 | ||
|
|
b9f06f6c21 | ||
|
|
1136294c1f | ||
|
|
9238cba06a |
3
tools/import-normalizer/.gitignore
vendored
3
tools/import-normalizer/.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.venv/
|
||||
out/
|
||||
out/*
|
||||
!out/canonical-persons-tree.json
|
||||
!out/*.xlsx
|
||||
review/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -66,6 +66,7 @@ class ParsedDate:
|
||||
iso: str | None
|
||||
precision: Precision
|
||||
raw: str
|
||||
end: str | None = None # RANGE end day; None for every non-RANGE precision
|
||||
|
||||
|
||||
_LEADING_MARKERS = re.compile(
|
||||
@@ -210,21 +211,23 @@ def _match_year_only(s):
|
||||
def _match_range(s):
|
||||
m = _RANGE_YY_RE.fullmatch(s)
|
||||
if m:
|
||||
return datetime.date(int(m.group(1)), 1, 1).isoformat(), Precision.RANGE
|
||||
return datetime.date(int(m.group(1)), 1, 1).isoformat(), Precision.RANGE, None
|
||||
m = _RANGE_DAY_RE.fullmatch(s)
|
||||
if m:
|
||||
first = f"{m.group(1)}.{m.group(3)}" # "7." + "Sept.1923" -> "7.Sept.1923"
|
||||
for matcher in (_match_numeric, _match_monthname_a):
|
||||
r = matcher(first)
|
||||
if r:
|
||||
return r[0], Precision.RANGE
|
||||
day_start, day_end, rest = m.group(1), m.group(2), m.group(3)
|
||||
# "10." + "1.1917" -> "10.1.1917"; resolve start and end day against the shared month/year
|
||||
for matcher in (_match_numeric, _match_roman, _match_monthname_a):
|
||||
start = matcher(f"{day_start}.{rest}")
|
||||
if start:
|
||||
end = matcher(f"{day_end}.{rest}")
|
||||
return start[0], Precision.RANGE, (end[0] if end else None)
|
||||
m = _RANGE_HYPHEN_RE.fullmatch(s)
|
||||
if m:
|
||||
start = m.group(1).strip()
|
||||
for matcher in (_match_numeric, _match_roman, _match_monthname_a, _match_year_only):
|
||||
r = matcher(start)
|
||||
if r:
|
||||
return r[0], Precision.RANGE
|
||||
return r[0], Precision.RANGE, None
|
||||
return None
|
||||
|
||||
|
||||
@@ -253,10 +256,11 @@ def parse_date(raw: str, date_overrides: dict | None = None) -> ParsedDate:
|
||||
for matcher in _MATCHERS:
|
||||
result = matcher(cleaned)
|
||||
if result:
|
||||
iso, precision = result
|
||||
iso, precision = result[0], result[1]
|
||||
end = result[2] if len(result) > 2 else None
|
||||
if approx:
|
||||
precision = Precision.APPROX
|
||||
return ParsedDate(iso, precision, raw)
|
||||
return ParsedDate(iso, precision, raw, end)
|
||||
return ParsedDate(None, Precision.UNKNOWN, raw)
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class RawRow:
|
||||
@dataclass
|
||||
class CanonicalDocument:
|
||||
index: str
|
||||
file: str = ""
|
||||
box: str = ""
|
||||
folder: str = ""
|
||||
sender_person_id: str = ""
|
||||
@@ -40,6 +41,7 @@ class CanonicalDocument:
|
||||
date_iso: str = ""
|
||||
date_raw: str = ""
|
||||
date_precision: str = ""
|
||||
date_end: str = ""
|
||||
location: str = ""
|
||||
tags: list = field(default_factory=list)
|
||||
summary: str = ""
|
||||
@@ -109,11 +111,12 @@ def to_canonical(raw, ctx, date_overrides: dict, approved_themes: frozenset = fr
|
||||
flags.append("index_file_mismatch")
|
||||
|
||||
return CanonicalDocument(
|
||||
index=raw.index, box=raw.box, folder=raw.folder,
|
||||
index=raw.index, file=raw.file, box=raw.box, folder=raw.folder,
|
||||
sender_person_id=sender_id, sender_name=sender_name,
|
||||
receiver_person_ids=[r[0] for r in receivers],
|
||||
receiver_names=[r[1] for r in receivers],
|
||||
date_iso=pd.iso or "", date_raw=raw.date, date_precision=str(pd.precision),
|
||||
date_end=pd.end or "",
|
||||
location=raw.location, tags=_tags.generate_tags(raw.tags, raw.summary, approved_themes), summary=raw.summary,
|
||||
source_row=raw.source_row, needs_review=flags,
|
||||
)
|
||||
|
||||
BIN
tools/import-normalizer/out/canonical-documents.xlsx
Normal file
BIN
tools/import-normalizer/out/canonical-documents.xlsx
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
tools/import-normalizer/out/canonical-persons.xlsx
Normal file
BIN
tools/import-normalizer/out/canonical-persons.xlsx
Normal file
Binary file not shown.
BIN
tools/import-normalizer/out/canonical-tag-tree.xlsx
Normal file
BIN
tools/import-normalizer/out/canonical-tag-tree.xlsx
Normal file
Binary file not shown.
@@ -8,9 +8,14 @@ from pathlib import Path
|
||||
|
||||
import config
|
||||
import dates
|
||||
import persons as _persons
|
||||
from persons import _strip_accents
|
||||
|
||||
|
||||
# Pinned so the committed tree JSON is reproducible and does not churn on every run
|
||||
# (NFR-IDEM-01) — mirrors writers._FIXED_TS for the xlsx exports.
|
||||
_GENERATED_AT = "2020-01-01T00:00:00"
|
||||
|
||||
_MIN_YEAR = 1700
|
||||
_MAX_YEAR = 2100
|
||||
# Threshold: if parse_date parses a pure-digit string as a year outside [_MIN_YEAR, _MAX_YEAR],
|
||||
@@ -175,6 +180,23 @@ def _parse_row(row_num: int, fields: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _attach_person_ids(tree_persons: list[dict], raw_dicts: list[dict]) -> None:
|
||||
"""Attach the register's verbatim person_id to each tree person, in place.
|
||||
|
||||
The register (persons.parse_register) is the sole authority for person_id; it
|
||||
slugifies and suffixes colliding ids exactly once. We propagate that id rather
|
||||
than re-slugify in the tree, because re-slugifying would not reproduce the
|
||||
register's collision suffixes and so would not reconcile 1:1 with the register
|
||||
(#670, Gap 3).
|
||||
|
||||
tree_persons and raw_dicts must be the same length and in the same row order —
|
||||
parse_register and _parse_row both keep exactly the rows that have a last name.
|
||||
"""
|
||||
register = _persons.parse_register(raw_dicts)
|
||||
for tree_person, register_person in zip(tree_persons, register):
|
||||
tree_person["personId"] = register_person.person_id
|
||||
|
||||
|
||||
def _deduplicate(persons: list[dict]) -> tuple[list[dict], list[str]]:
|
||||
"""Remove duplicate rows. Two-stage:
|
||||
|
||||
@@ -339,11 +361,17 @@ def main() -> None:
|
||||
|
||||
# --- Pass 1: parse rows ---
|
||||
persons_raw: list[dict] = []
|
||||
raw_dicts: list[dict] = []
|
||||
for row_num, row in enumerate(rows[1:], start=2):
|
||||
field_dict = {field: (row[col] if col < len(row) else "") for field, col in fields_map.items()}
|
||||
if not field_dict.get("last_name", "").strip():
|
||||
continue
|
||||
persons_raw.append(_parse_row(row_num, field_dict))
|
||||
raw_dicts.append(field_dict)
|
||||
|
||||
# Propagate the register's verbatim person_id before dedup so the tree reconciles 1:1
|
||||
# with canonical-persons.xlsx (#670, Gap 3).
|
||||
_attach_person_ids(persons_raw, raw_dicts)
|
||||
|
||||
persons, skipped_msgs = _deduplicate(persons_raw)
|
||||
for msg in skipped_msgs:
|
||||
@@ -387,7 +415,7 @@ def main() -> None:
|
||||
return
|
||||
|
||||
output = {
|
||||
"generated_at": datetime.datetime.now().isoformat(),
|
||||
"generated_at": _GENERATED_AT,
|
||||
"source": Path(args.input).name,
|
||||
"stats": {
|
||||
"persons": len(persons),
|
||||
|
||||
@@ -115,10 +115,29 @@ def test_parse_invalid_calendar_date_is_unknown():
|
||||
assert dates.parse_date("31.4.1916").precision == Precision.UNKNOWN
|
||||
|
||||
def test_parse_intra_month_day_range():
|
||||
# "7./8. Sept.1923" -> start day, RANGE. Must NOT be confused with slash-date "17/6. 1916".
|
||||
assert dates.parse_date("7./8. Sept.1923") == dates.ParsedDate("1923-09-07", Precision.RANGE, "7./8. Sept.1923")
|
||||
# "7./8. Sept.1923" -> start day, RANGE, end day 8th. Must NOT be confused with slash-date "17/6. 1916".
|
||||
assert dates.parse_date("7./8. Sept.1923") == dates.ParsedDate("1923-09-07", Precision.RANGE, "7./8. Sept.1923", "1923-09-08")
|
||||
assert dates.parse_date("17/6. 1916") == dates.ParsedDate("1916-06-17", Precision.DAY, "17/6. 1916")
|
||||
|
||||
def test_parse_intra_month_day_range_carries_end_day():
|
||||
# the intra-month day range surfaces the END day so Phase 4 can render meta_date_end
|
||||
r = dates.parse_date("10./11.1.1917")
|
||||
assert r.iso == "1917-01-10"
|
||||
assert r.precision == Precision.RANGE
|
||||
assert r.end == "1917-01-11"
|
||||
|
||||
def test_parse_roman_month_day_range():
|
||||
# "10./11.I.1917" — Roman-numeral-month range; previously fell through to UNKNOWN
|
||||
r = dates.parse_date("10./11.I.1917")
|
||||
assert r.iso == "1917-01-10"
|
||||
assert r.precision == Precision.RANGE
|
||||
assert r.end == "1917-01-11"
|
||||
|
||||
def test_parse_non_range_has_no_end():
|
||||
assert dates.parse_date("15.2.1888").end is None
|
||||
assert dates.parse_date("Mai 1895").end is None
|
||||
assert dates.parse_date("").end is None
|
||||
|
||||
def test_parse_trailing_note_stripped_but_raw_preserved():
|
||||
r = dates.parse_date("17.Nov 1887, 2. Brief") # REQ-DATE-04
|
||||
assert r.iso == "1887-11-17"
|
||||
|
||||
@@ -52,8 +52,36 @@ def test_to_canonical_resolves_and_flags():
|
||||
assert doc.receiver_person_ids == ["de-gruyter-eugenie"] # matched via maiden alias
|
||||
assert doc.date_iso == "1888-02-15" and doc.date_precision == "DAY"
|
||||
assert doc.tags == ["Themen/Brautbriefe"]
|
||||
assert doc.file == r"..\__scan\W-0001.pdf" # file name carried through for the importer
|
||||
assert doc.needs_review == []
|
||||
|
||||
|
||||
def test_to_canonical_carries_file_name():
|
||||
ctx = _ctx()
|
||||
raw = documents.RawRow(source_row=4, index="H-0730", sender="", receivers="",
|
||||
file="H-0730.pdf")
|
||||
doc = documents.to_canonical(raw, ctx, date_overrides={})
|
||||
assert doc.file == "H-0730.pdf"
|
||||
|
||||
|
||||
def test_to_canonical_range_carries_date_end():
|
||||
ctx = _ctx()
|
||||
raw = documents.RawRow(source_row=4, index="H-0730", sender="", receivers="",
|
||||
date="10./11.1.1917")
|
||||
doc = documents.to_canonical(raw, ctx, date_overrides={})
|
||||
assert doc.date_iso == "1917-01-10"
|
||||
assert doc.date_precision == "RANGE"
|
||||
assert doc.date_end == "1917-01-11"
|
||||
|
||||
|
||||
def test_to_canonical_non_range_has_empty_date_end():
|
||||
ctx = _ctx()
|
||||
raw = documents.RawRow(source_row=4, index="H-0730", sender="", receivers="",
|
||||
date="15.2.1888")
|
||||
doc = documents.to_canonical(raw, ctx, date_overrides={})
|
||||
assert doc.date_precision == "DAY"
|
||||
assert doc.date_end == ""
|
||||
|
||||
def test_to_canonical_unmatched_and_unparsed():
|
||||
ctx = _ctx()
|
||||
raw = documents.RawRow(source_row=9, index="C-0001",
|
||||
|
||||
@@ -433,6 +433,44 @@ def test_parse_bemerkung_sohn_with_trailing_remark():
|
||||
assert notes == "nach Mexiko emigriert"
|
||||
|
||||
|
||||
def test_generated_at_is_fixed_for_reproducibility():
|
||||
# NFR-IDEM-01: a pinned timestamp so the committed tree JSON doesn't churn on every run
|
||||
assert persons_tree._GENERATED_AT == "2020-01-01T00:00:00"
|
||||
|
||||
|
||||
def test_attach_person_ids_propagates_register_slug():
|
||||
# the tree person must carry the register's verbatim person_id (slug), not a recomputed one
|
||||
raw_dicts = [
|
||||
{"generation": "G 1", "last_name": "de Gruyter", "first_name": "Walter",
|
||||
"maiden_name": "", "birth_date": "", "birth_place": "",
|
||||
"death_date": "", "death_place": "", "spouse": "", "notes": ""},
|
||||
{"generation": "G 1", "last_name": "de Gruyter", "first_name": "Eugenie",
|
||||
"maiden_name": "Müller", "birth_date": "", "birth_place": "",
|
||||
"death_date": "", "death_place": "", "spouse": "", "notes": ""},
|
||||
]
|
||||
tree_persons = [persons_tree._parse_row(n, d) for n, d in enumerate(raw_dicts, start=2)]
|
||||
persons_tree._attach_person_ids(tree_persons, raw_dicts)
|
||||
assert tree_persons[0]["personId"] == "de-gruyter-walter"
|
||||
assert tree_persons[1]["personId"] == "de-gruyter-eugenie"
|
||||
|
||||
|
||||
def test_attach_person_ids_carries_register_collision_suffix():
|
||||
# when two register rows slug-collide, the register suffixes the ids (-1, -2);
|
||||
# those exact suffixed ids must reach the tree persons, never a recomputed bare slug
|
||||
raw_dicts = [
|
||||
{"generation": "G 2", "last_name": "Cram", "first_name": "Hans",
|
||||
"maiden_name": "", "birth_date": "1890", "birth_place": "",
|
||||
"death_date": "", "death_place": "", "spouse": "", "notes": ""},
|
||||
{"generation": "G 3", "last_name": "Cram", "first_name": "Hans",
|
||||
"maiden_name": "", "birth_date": "1925", "birth_place": "",
|
||||
"death_date": "", "death_place": "", "spouse": "", "notes": ""},
|
||||
]
|
||||
tree_persons = [persons_tree._parse_row(n, d) for n, d in enumerate(raw_dicts, start=2)]
|
||||
persons_tree._attach_person_ids(tree_persons, raw_dicts)
|
||||
assert tree_persons[0]["personId"] == "cram-hans-1"
|
||||
assert tree_persons[1]["personId"] == "cram-hans-2"
|
||||
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,21 @@ def test_write_documents_xlsx_joins_lists(tmp_path):
|
||||
assert row["receiver_person_ids"] == "a|b"
|
||||
assert row["needs_review"] == "unparsed_date"
|
||||
|
||||
|
||||
def test_write_documents_xlsx_carries_file_and_date_end(tmp_path):
|
||||
doc = documents.CanonicalDocument(
|
||||
index="H-0730", file="H-0730.pdf", date_iso="1917-01-10",
|
||||
date_precision="RANGE", date_end="1917-01-11")
|
||||
out = tmp_path / "docs.xlsx"
|
||||
writers.write_documents_xlsx([doc], out)
|
||||
wb = openpyxl.load_workbook(out)
|
||||
ws = wb.active
|
||||
header = [c.value for c in ws[1]]
|
||||
assert "file" in header and "date_end" in header
|
||||
row = {h: c.value for h, c in zip(header, ws[2])}
|
||||
assert row["file"] == "H-0730.pdf"
|
||||
assert row["date_end"] == "1917-01-11"
|
||||
|
||||
def test_write_documents_xlsx_pins_timestamp(tmp_path):
|
||||
# determinism (NFR-IDEM-01): workbook created/modified are pinned, not the current time
|
||||
doc = documents.CanonicalDocument(index="W-0001")
|
||||
|
||||
@@ -22,9 +22,10 @@ def _csv_safe(value):
|
||||
return "'" + s if s[:1] in ("=", "+", "-", "@", "\t", "\r", "\n") else s
|
||||
|
||||
|
||||
DOC_COLUMNS = ["index", "box", "folder", "sender_person_id", "sender_name",
|
||||
DOC_COLUMNS = ["index", "file", "box", "folder", "sender_person_id", "sender_name",
|
||||
"receiver_person_ids", "receiver_names", "date_iso", "date_raw",
|
||||
"date_precision", "location", "tags", "summary", "source_row", "needs_review"]
|
||||
"date_precision", "date_end", "location", "tags", "summary",
|
||||
"source_row", "needs_review"]
|
||||
|
||||
PERSON_COLUMNS = ["person_id", "last_name", "first_name", "maiden_name", "title", "nickname",
|
||||
"birth_date", "birth_date_raw", "birth_place", "death_date", "death_date_raw",
|
||||
|
||||
Reference in New Issue
Block a user