Coverage for src/lilbee/data/title.py: 100%
35 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Document title and source-level metadata derivation for ingest."""
3from __future__ import annotations
5import re
6from pathlib import PurePath
7from typing import Protocol
9from lilbee.core.web_names import is_web_source, page_stem
10from lilbee.data.store import SourceMeta
12# Filename-stem separators flattened to spaces when no extracted title exists.
13_STEM_SEPARATOR_RE = re.compile(r"[_\-\s]+")
15# Stems with no searchable words: camera/scanner counters, generic names, bare
16# numbers/dates, hex ids. Indexing these gives the title arm noise at full weight.
17_COUNTER_STEM_RE = re.compile(
18 r"(?:img|image|dsc[nf]?|pxl|mvimg|vid|video|scan|screenshot|photo|pic|picture"
19 r"|untitled|unnamed|noname|new|document|doc|file|page)?(?:\s*\d+)*",
20 re.IGNORECASE,
21)
22_NUMERIC_STEM_RE = re.compile(r"[\d\s.]+")
23_HEX_ID_RE = re.compile(r"[0-9a-f]{8,}", re.IGNORECASE)
24# A crawled page named by one number or one hex string is titled with that name.
25_BARE_ID_RE = re.compile(r"\d+|[0-9a-f]{8,}", re.IGNORECASE)
27# Below this many characters a stem cannot form a searchable word.
28_MIN_TITLE_CHARS = 3
31def is_junk_stem(stem: str) -> bool:
32 """True when a filename stem carries no searchable title words."""
33 flat = _STEM_SEPARATOR_RE.sub(" ", stem).strip()
34 if len(flat) < _MIN_TITLE_CHARS:
35 return True
36 if _NUMERIC_STEM_RE.fullmatch(flat) or _COUNTER_STEM_RE.fullmatch(flat):
37 return True
38 return bool(_HEX_ID_RE.fullmatch(flat.replace(" ", "")))
41class ExtractionMetadata(Protocol):
42 """xberg metadata fields, typed ``object``: xberg annotates but does not enforce
43 them (a PDF /Author arrives as a bare str; a non-str title is accepted)."""
45 @property
46 def title(self) -> object: ...
48 @property
49 def authors(self) -> object: ...
51 @property
52 def created_at(self) -> object: ...
55def derive_title(source_name: str, metadata_title: object = None) -> str:
56 """Human-readable document title: the extracted title, else the cleaned filename stem.
58 The stem cleanup flattens underscore/hyphen separators to spaces so BM25
59 tokenizes ``survey_214.pdf`` into the same terms a query would use. Junk
60 stems (``IMG 1234``, bare numbers, hex ids) yield "" so no title is stored.
61 A crawled page's stem is its readable name, without the hash of its address;
62 a page named by a bare number or hex id takes that name as its title.
63 """
64 if isinstance(metadata_title, str) and metadata_title.strip():
65 return metadata_title.strip()
66 name = PurePath(source_name).name
67 stem = page_stem(name) if is_web_source(source_name) else PurePath(name).stem
68 if is_junk_stem(stem):
69 return stem if is_web_source(source_name) and _BARE_ID_RE.fullmatch(stem) else ""
70 return _STEM_SEPARATOR_RE.sub(" ", stem).strip()
73def source_meta_from_extraction(
74 metadata: ExtractionMetadata | None, source_name: str
75) -> SourceMeta:
76 """Fold xberg extraction metadata into a :class:`SourceMeta`.
78 The title falls back to the filename stem; authors and creation date stay
79 empty (persisted NULL) when the extractor reports none. xberg annotates these
80 fields but does not enforce them: a PDF ``/Author`` arrives as a bare ``str``
81 where ``list[str]`` is declared, so a string is treated as one author rather
82 than split into its characters, and non-string entries are coerced.
83 """
84 raw_authors = metadata.authors if metadata is not None else None
85 if isinstance(raw_authors, str):
86 authors: list[str] = [raw_authors]
87 elif isinstance(raw_authors, (list, tuple)):
88 authors = [str(a) for a in raw_authors if a]
89 else:
90 authors = []
91 return SourceMeta(
92 title=derive_title(source_name, metadata.title if metadata is not None else None),
93 authors=", ".join(a for a in authors if a),
94 created_at=str((metadata.created_at if metadata is not None else None) or ""),
95 )