brainiac-cli 0.16.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain/__init__.py +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/ingest/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""ING-01/ING-02 — document ingestion pipeline (ADR-0003 Ruling 1).
|
|
2
|
+
|
|
3
|
+
Drop a file into ``<vault>/inbox/``; the HOST-only ``brain ingest`` verb (also
|
|
4
|
+
folded into every host ``brain sync``, per the s01 cadence amendment) extracts
|
|
5
|
+
it to Markdown, archives the untouched original immutably under
|
|
6
|
+
``raw/originals/``, and commits the extracted source through the existing
|
|
7
|
+
audited ``write_note`` path. Unhandled/failed files quarantine to
|
|
8
|
+
``inbox/_quarantine/<reason>/`` — never silently dropped.
|
|
9
|
+
|
|
10
|
+
Public surface: :func:`run_ingest` (orchestrator) and :func:`capability_report`
|
|
11
|
+
(which handlers are usable given installed deps) — both re-exported from
|
|
12
|
+
``.pipeline`` and ``.handlers``.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .handlers import ExtractResult, Handler, capability_report
|
|
17
|
+
from .pipeline import inbox_dir, run_ingest
|
|
18
|
+
|
|
19
|
+
__all__ = ["ExtractResult", "Handler", "capability_report", "inbox_dir", "run_ingest"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Extension -> Handler registry (ING-01)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .base import ExtractResult, Handler, density_gate, strip_control_chars
|
|
5
|
+
from .docx import DocxHandler
|
|
6
|
+
from .email import EmailHandler
|
|
7
|
+
from .html import HtmlHandler
|
|
8
|
+
from .image import ImageHandler
|
|
9
|
+
from .pdf import PdfHandler
|
|
10
|
+
from .pptx import PptxHandler
|
|
11
|
+
from .text import TextHandler
|
|
12
|
+
from .xlsx import XlsxHandler
|
|
13
|
+
from .zip import ZipHandler
|
|
14
|
+
|
|
15
|
+
ALL_HANDLERS: tuple[type[Handler], ...] = (
|
|
16
|
+
PdfHandler, DocxHandler, PptxHandler, XlsxHandler, TextHandler,
|
|
17
|
+
ImageHandler, EmailHandler, HtmlHandler, ZipHandler,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
REGISTRY: dict[str, type[Handler]] = {
|
|
21
|
+
ext: handler for handler in ALL_HANDLERS for ext in handler.extensions
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def handler_for(path) -> type[Handler] | None:
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
return REGISTRY.get(Path(path).suffix.lower())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def capability_report() -> dict[str, dict]:
|
|
32
|
+
"""Which extension handlers are available right now (ING-01 dep probe)."""
|
|
33
|
+
return {
|
|
34
|
+
ext: {"handler": handler.__name__, "dependency": handler.dependency_name,
|
|
35
|
+
"available": handler.available()}
|
|
36
|
+
for ext, handler in REGISTRY.items()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"ExtractResult", "Handler", "density_gate", "strip_control_chars",
|
|
42
|
+
"ALL_HANDLERS", "REGISTRY", "handler_for", "capability_report",
|
|
43
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Handler contract (ING-01/ING-02, ADR-0003 Ruling 1).
|
|
2
|
+
|
|
3
|
+
Every format handler implements one function contract: given a Path, return an
|
|
4
|
+
``ExtractResult``. Handlers never touch the vault, the index, or the audit
|
|
5
|
+
chain — that is the orchestrator's job (``brain.ingest.run_ingest``). This
|
|
6
|
+
keeps a handler pure and trivially testable: bytes in, Markdown (or a
|
|
7
|
+
quarantine reason) out.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# Below this many non-whitespace characters, a "successfully extracted"
|
|
18
|
+
# document is treated as empty/near-empty content — the OHRBench finding that
|
|
19
|
+
# upstream extraction failure (not the write path) is the dominant corpus-
|
|
20
|
+
# corruption vector. Quarantine, never sign a near-empty source.
|
|
21
|
+
MIN_CONTENT_CHARS = 40
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ExtractResult:
|
|
26
|
+
"""Outcome of one handler's extraction attempt.
|
|
27
|
+
|
|
28
|
+
``quarantine_reason`` is ``None`` on success. ``markdown``/``warnings``/
|
|
29
|
+
``metadata`` are always populated (possibly empty) so callers never branch
|
|
30
|
+
on attribute presence.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
markdown: str = ""
|
|
34
|
+
warnings: list[str] = field(default_factory=list)
|
|
35
|
+
quarantine_reason: str | None = None
|
|
36
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def ok(self) -> bool:
|
|
40
|
+
return self.quarantine_reason is None
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def quarantine(reason: str, *, warnings: list[str] | None = None) -> "ExtractResult":
|
|
44
|
+
return ExtractResult(quarantine_reason=reason, warnings=list(warnings or []))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def density_gate(markdown: str, *, min_chars: int = MIN_CONTENT_CHARS) -> str | None:
|
|
48
|
+
"""Generic extraction-quality gate (HARDENED:grill): empty-text / low text
|
|
49
|
+
density detector shared by every handler. Strips table-unparsed fences and
|
|
50
|
+
page markers before counting so a document that is ENTIRELY scanned pages
|
|
51
|
+
or an unparsed table dump does not slip past on marker text alone.
|
|
52
|
+
Returns a quarantine reason, or ``None`` if the content passes."""
|
|
53
|
+
import re
|
|
54
|
+
|
|
55
|
+
stripped = re.sub(r"^#{1,3}\s.*$", "", markdown, flags=re.MULTILINE)
|
|
56
|
+
stripped = stripped.replace("```", "")
|
|
57
|
+
content_chars = len(stripped.strip())
|
|
58
|
+
if content_chars < min_chars:
|
|
59
|
+
return "empty_or_low_text_density"
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def strip_control_chars(name: str) -> str:
|
|
67
|
+
"""Strip control chars (incl. embedded newlines) from an untrusted name
|
|
68
|
+
(zip member, email attachment/header, HTML title, ...) before it flows
|
|
69
|
+
into generated Markdown body text or a report entry (S06 HARDENED — the
|
|
70
|
+
S05 lesson was frontmatter; a control char in body text can still forge a
|
|
71
|
+
fake heading/table row in the rendered note or a report line)."""
|
|
72
|
+
if not name:
|
|
73
|
+
return name
|
|
74
|
+
return _CONTROL_CHARS.sub("", name)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Handler(ABC):
|
|
78
|
+
"""One handler per file extension family."""
|
|
79
|
+
|
|
80
|
+
#: lower-cased extensions this handler claims, e.g. (".pdf",)
|
|
81
|
+
extensions: tuple[str, ...] = ()
|
|
82
|
+
#: human label for capability-probe reporting, e.g. "pypdf"
|
|
83
|
+
dependency_name: str = ""
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def available(cls) -> bool:
|
|
88
|
+
"""True iff this handler's extraction dependency import-succeeds."""
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def extract(cls, path: Path) -> ExtractResult:
|
|
93
|
+
"""Extract ``path`` to Markdown. MUST NOT raise on malformed input —
|
|
94
|
+
catch and return a quarantine ``ExtractResult`` instead (a crash here
|
|
95
|
+
would abort the whole drain, not just this one file)."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""DOCX handler — python-docx. Paragraphs + tables (Markdown tables, headers
|
|
2
|
+
retained per HARDENED:grill — see .tables.rows_to_markdown)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .base import ExtractResult, Handler, density_gate
|
|
8
|
+
from .tables import rows_to_markdown
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import docx # python-docx
|
|
12
|
+
_HAS_DOCX = True
|
|
13
|
+
except ImportError: # pragma: no cover
|
|
14
|
+
_HAS_DOCX = False
|
|
15
|
+
|
|
16
|
+
MAX_DOCX_BYTES = 100 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DocxHandler(Handler):
|
|
20
|
+
extensions = (".docx",)
|
|
21
|
+
dependency_name = "python-docx"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def available(cls) -> bool:
|
|
25
|
+
return _HAS_DOCX
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def extract(cls, path: Path) -> ExtractResult:
|
|
29
|
+
if not _HAS_DOCX:
|
|
30
|
+
return ExtractResult.quarantine("missing_dependency:python-docx")
|
|
31
|
+
try:
|
|
32
|
+
size = path.stat().st_size
|
|
33
|
+
except OSError:
|
|
34
|
+
size = 0
|
|
35
|
+
if size > MAX_DOCX_BYTES:
|
|
36
|
+
return ExtractResult.quarantine("file_too_large")
|
|
37
|
+
try:
|
|
38
|
+
document = docx.Document(str(path))
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
return ExtractResult.quarantine(
|
|
41
|
+
"docx_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
parts: list[str] = []
|
|
45
|
+
try:
|
|
46
|
+
# Walk body children in document order so tables land inline with
|
|
47
|
+
# the surrounding prose rather than all at the end.
|
|
48
|
+
body = document.element.body
|
|
49
|
+
table_by_elem = {t._tbl: t for t in document.tables}
|
|
50
|
+
# C10: re-scanning document.paragraphs per body child was O(n^2).
|
|
51
|
+
# Build the element->paragraph lookup once, like table_by_elem.
|
|
52
|
+
para_by_elem = {p._p: p for p in document.paragraphs}
|
|
53
|
+
for child in body.iterchildren():
|
|
54
|
+
tag = child.tag.rsplit("}", 1)[-1]
|
|
55
|
+
if tag == "p":
|
|
56
|
+
p = para_by_elem.get(child)
|
|
57
|
+
if p is not None:
|
|
58
|
+
text = p.text.strip()
|
|
59
|
+
if text:
|
|
60
|
+
if p.style and p.style.name and p.style.name.startswith("Heading"):
|
|
61
|
+
parts.append(f"## {text}\n")
|
|
62
|
+
else:
|
|
63
|
+
parts.append(f"{text}\n")
|
|
64
|
+
elif tag == "tbl":
|
|
65
|
+
tbl = table_by_elem.get(child)
|
|
66
|
+
if tbl is not None:
|
|
67
|
+
rows = [[c.text for c in row.cells] for row in tbl.rows]
|
|
68
|
+
parts.append(rows_to_markdown(rows))
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
return ExtractResult.quarantine(
|
|
71
|
+
"docx_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
body_md = "\n".join(p for p in parts if p.strip())
|
|
75
|
+
reason = density_gate(body_md)
|
|
76
|
+
if reason:
|
|
77
|
+
return ExtractResult.quarantine(reason)
|
|
78
|
+
return ExtractResult(markdown=body_md, metadata={"tables": len(document.tables)})
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Email (.eml, RFC 5322) handler — stdlib only (``email`` + ``html.parser``,
|
|
2
|
+
mirrors the sha256-verified reference-vault reference, ADR-0003 Appendix B).
|
|
3
|
+
Produces headers + body + an attachment manifest; each attachment's bytes are
|
|
4
|
+
returned via ``metadata["nested"]`` so the orchestrator (pipeline.py
|
|
5
|
+
``_process_nested``) re-enters the dispatcher for each one — bounded by
|
|
6
|
+
``MAX_ATTACHMENTS``/``MAX_ATTACHMENT_TOTAL_BYTES`` here, and by depth +
|
|
7
|
+
a shared byte/count budget at the pipeline layer (defense in depth against a
|
|
8
|
+
crafted attachment-of-attachment chain)."""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import email
|
|
12
|
+
import email.policy
|
|
13
|
+
import email.utils
|
|
14
|
+
import html.parser
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from .base import ExtractResult, Handler, density_gate, strip_control_chars
|
|
20
|
+
|
|
21
|
+
MAX_EML_BYTES = 50 * 1024 * 1024
|
|
22
|
+
MAX_ATTACHMENTS = 50
|
|
23
|
+
MAX_ATTACHMENT_TOTAL_BYTES = 200 * 1024 * 1024 # matches pipeline.MAX_INGEST_BYTES
|
|
24
|
+
|
|
25
|
+
_EM_DASH = "—"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _HtmlStripper(html.parser.HTMLParser):
|
|
29
|
+
"""Minimal stdlib HTML->text fallback for a text/html-only body (no
|
|
30
|
+
BeautifulSoup dependency needed for this narrow use)."""
|
|
31
|
+
|
|
32
|
+
_BLOCK = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
33
|
+
"blockquote", "pre", "hr"}
|
|
34
|
+
_SKIP = {"script", "style", "head", "meta", "link"}
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
super().__init__(convert_charrefs=True)
|
|
38
|
+
self._chunks: list[str] = []
|
|
39
|
+
self._skip = 0
|
|
40
|
+
|
|
41
|
+
def handle_starttag(self, tag: str, attrs: list) -> None:
|
|
42
|
+
if tag in self._SKIP:
|
|
43
|
+
self._skip += 1
|
|
44
|
+
elif tag in self._BLOCK:
|
|
45
|
+
self._chunks.append("\n")
|
|
46
|
+
|
|
47
|
+
def handle_endtag(self, tag: str) -> None:
|
|
48
|
+
if tag in self._SKIP and self._skip > 0:
|
|
49
|
+
self._skip -= 1
|
|
50
|
+
elif tag in self._BLOCK:
|
|
51
|
+
self._chunks.append("\n")
|
|
52
|
+
|
|
53
|
+
def handle_data(self, data: str) -> None:
|
|
54
|
+
if not self._skip:
|
|
55
|
+
self._chunks.append(data)
|
|
56
|
+
|
|
57
|
+
def get_text(self) -> str:
|
|
58
|
+
joined = "".join(self._chunks)
|
|
59
|
+
joined = re.sub(r"\n{3,}", "\n\n", joined)
|
|
60
|
+
return "\n".join(line.rstrip() for line in joined.split("\n")).strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _strip_html(raw: str) -> str:
|
|
64
|
+
stripper = _HtmlStripper()
|
|
65
|
+
try:
|
|
66
|
+
stripper.feed(raw)
|
|
67
|
+
stripper.close()
|
|
68
|
+
except Exception:
|
|
69
|
+
return ""
|
|
70
|
+
return stripper.get_text()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _decode_header(raw: object) -> str:
|
|
74
|
+
return str(raw).strip() if raw else ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _addr_list(raw: str) -> list[str]:
|
|
78
|
+
if not raw:
|
|
79
|
+
return []
|
|
80
|
+
out: list[str] = []
|
|
81
|
+
for name, addr in email.utils.getaddresses([raw]):
|
|
82
|
+
name, addr = name.strip(), addr.strip()
|
|
83
|
+
if name and addr:
|
|
84
|
+
out.append(f"{name} <{addr}>")
|
|
85
|
+
elif addr:
|
|
86
|
+
out.append(addr)
|
|
87
|
+
elif name:
|
|
88
|
+
out.append(name)
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _sent_date_iso(raw: str) -> Optional[str]:
|
|
93
|
+
if not raw:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
dt = email.utils.parsedate_to_datetime(raw)
|
|
97
|
+
return dt.isoformat() if dt is not None else None
|
|
98
|
+
except Exception:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_body(msg: "email.message.Message") -> tuple[str, list[str]]:
|
|
103
|
+
warnings: list[str] = []
|
|
104
|
+
text_part = html_part = None
|
|
105
|
+
for part in msg.walk():
|
|
106
|
+
if part.is_multipart() or part.get_content_disposition() == "attachment":
|
|
107
|
+
continue
|
|
108
|
+
ctype = part.get_content_type()
|
|
109
|
+
if ctype == "text/plain" and text_part is None:
|
|
110
|
+
text_part = part
|
|
111
|
+
elif ctype == "text/html" and html_part is None:
|
|
112
|
+
html_part = part
|
|
113
|
+
|
|
114
|
+
if text_part is not None:
|
|
115
|
+
try:
|
|
116
|
+
return text_part.get_content().strip(), warnings
|
|
117
|
+
except Exception:
|
|
118
|
+
payload = text_part.get_payload(decode=True) or b""
|
|
119
|
+
charset = text_part.get_content_charset() or "utf-8"
|
|
120
|
+
return payload.decode(charset, errors="replace").strip(), warnings
|
|
121
|
+
|
|
122
|
+
if html_part is not None:
|
|
123
|
+
try:
|
|
124
|
+
raw_html = html_part.get_content()
|
|
125
|
+
except Exception:
|
|
126
|
+
payload = html_part.get_payload(decode=True) or b""
|
|
127
|
+
charset = html_part.get_content_charset() or "utf-8"
|
|
128
|
+
raw_html = payload.decode(charset, errors="replace")
|
|
129
|
+
warnings.append("html_only_fallback: no text/plain part, stripped HTML")
|
|
130
|
+
return _strip_html(raw_html), warnings
|
|
131
|
+
|
|
132
|
+
warnings.append("no_body_part: neither text/plain nor text/html present")
|
|
133
|
+
return "", warnings
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class EmailHandler(Handler):
|
|
137
|
+
extensions = (".eml",)
|
|
138
|
+
dependency_name = "stdlib"
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def available(cls) -> bool:
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def extract(cls, path: Path) -> ExtractResult:
|
|
146
|
+
try:
|
|
147
|
+
size = path.stat().st_size
|
|
148
|
+
except OSError:
|
|
149
|
+
size = 0
|
|
150
|
+
if size > MAX_EML_BYTES:
|
|
151
|
+
return ExtractResult.quarantine(
|
|
152
|
+
"file_too_large", warnings=[f"{size} bytes exceeds cap {MAX_EML_BYTES}"]
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
raw = path.read_bytes()
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
return ExtractResult.quarantine("eml_read_error", warnings=[f"{type(exc).__name__}: {exc}"])
|
|
158
|
+
try:
|
|
159
|
+
msg = email.message_from_bytes(raw, policy=email.policy.default)
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
return ExtractResult.quarantine("eml_parse_error", warnings=[f"{type(exc).__name__}: {exc}"])
|
|
162
|
+
|
|
163
|
+
subject = strip_control_chars(_decode_header(msg.get("Subject")))
|
|
164
|
+
from_addrs = _addr_list(_decode_header(msg.get("From")))
|
|
165
|
+
to_addrs = _addr_list(_decode_header(msg.get("To")))
|
|
166
|
+
cc_addrs = _addr_list(_decode_header(msg.get("Cc")))
|
|
167
|
+
sent_raw = _decode_header(msg.get("Date"))
|
|
168
|
+
sent_iso = _sent_date_iso(sent_raw)
|
|
169
|
+
body_text, warnings = _extract_body(msg)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
attachments = list(msg.iter_attachments())
|
|
173
|
+
except Exception:
|
|
174
|
+
attachments = []
|
|
175
|
+
if len(attachments) > MAX_ATTACHMENTS:
|
|
176
|
+
warnings.append(f"attachments_truncated: {len(attachments)} found, cap {MAX_ATTACHMENTS}")
|
|
177
|
+
attachments = attachments[:MAX_ATTACHMENTS]
|
|
178
|
+
|
|
179
|
+
nested: list[dict] = []
|
|
180
|
+
attach_meta: list[tuple[str, str, int]] = []
|
|
181
|
+
total_bytes = 0
|
|
182
|
+
for idx, part in enumerate(attachments, start=1):
|
|
183
|
+
name = strip_control_chars(part.get_filename() or f"attachment_{idx}.bin")
|
|
184
|
+
try:
|
|
185
|
+
data = part.get_payload(decode=True) or b""
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
warnings.append(f"attachment_decode_failed:{name}:{type(exc).__name__}")
|
|
188
|
+
continue
|
|
189
|
+
if total_bytes + len(data) > MAX_ATTACHMENT_TOTAL_BYTES:
|
|
190
|
+
warnings.append(f"attachment_byte_cap_reached: stopped before {name}")
|
|
191
|
+
break
|
|
192
|
+
total_bytes += len(data)
|
|
193
|
+
attach_meta.append((name, part.get_content_type(), len(data)))
|
|
194
|
+
nested.append({"name": name, "data": data})
|
|
195
|
+
|
|
196
|
+
from_disp = "; ".join(from_addrs) if from_addrs else _EM_DASH
|
|
197
|
+
to_disp = "; ".join(to_addrs) if to_addrs else _EM_DASH
|
|
198
|
+
|
|
199
|
+
lines = [
|
|
200
|
+
"## Email metadata", "",
|
|
201
|
+
f"- **Subject:** {subject or '(no subject)'}",
|
|
202
|
+
f"- **From:** {from_disp}",
|
|
203
|
+
f"- **To:** {to_disp}",
|
|
204
|
+
]
|
|
205
|
+
if cc_addrs:
|
|
206
|
+
lines.append(f"- **Cc:** {'; '.join(cc_addrs)}")
|
|
207
|
+
if sent_iso:
|
|
208
|
+
lines.append(f"- **Sent:** {sent_iso} (raw: {sent_raw})")
|
|
209
|
+
elif sent_raw:
|
|
210
|
+
lines.append(f"- **Sent:** {sent_raw}")
|
|
211
|
+
if attach_meta:
|
|
212
|
+
lines.append(f"- **Attachments:** {len(attach_meta)}")
|
|
213
|
+
lines += ["", "## Body", "", body_text or "*(empty body)*", ""]
|
|
214
|
+
if attach_meta:
|
|
215
|
+
lines.append("## Attachments")
|
|
216
|
+
lines.append("")
|
|
217
|
+
for name, ctype, nbytes in attach_meta:
|
|
218
|
+
lines.append(f"- `{name}` — {ctype} ({nbytes / 1024:.1f} KB)")
|
|
219
|
+
lines.append("")
|
|
220
|
+
body_md = "\n".join(lines)
|
|
221
|
+
|
|
222
|
+
reason = density_gate(body_md)
|
|
223
|
+
if reason:
|
|
224
|
+
return ExtractResult.quarantine(reason, warnings=warnings)
|
|
225
|
+
return ExtractResult(
|
|
226
|
+
markdown=body_md, warnings=warnings,
|
|
227
|
+
metadata={"nested": nested, "attachment_count": len(attach_meta), "subject": subject},
|
|
228
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""HTML handler — stdlib ``html.parser`` readable-text conversion (no new
|
|
2
|
+
required dependency); ``lxml`` is used as an optional faster/more-robust path
|
|
3
|
+
when already installed, mirroring the sha256-verified reference-vault reference
|
|
4
|
+
(ADR-0003 Appendix B) which proves the stdlib fallback alone is production-
|
|
5
|
+
adequate."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import html as _html_stdlib
|
|
9
|
+
import html.parser
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .base import ExtractResult, Handler, density_gate
|
|
14
|
+
|
|
15
|
+
MAX_HTML_BYTES = 50 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
_SKIP_TAGS = frozenset({"script", "style", "noscript", "svg", "canvas", "iframe", "object", "embed"})
|
|
18
|
+
_BLOCK_TAGS = frozenset({
|
|
19
|
+
"div", "p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
20
|
+
"li", "dt", "dd", "tr", "td", "th", "section", "article", "header",
|
|
21
|
+
"footer", "nav", "main", "aside", "pre", "blockquote", "table",
|
|
22
|
+
"thead", "tbody", "tfoot", "ul", "ol", "dl", "figure", "figcaption",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _TextExtractor(html.parser.HTMLParser):
|
|
27
|
+
"""Minimal HTML -> plain-text via stdlib. Void elements (br, hr, meta,
|
|
28
|
+
link, ...) fire ``handle_starttag`` but never ``handle_endtag`` — they are
|
|
29
|
+
deliberately absent from ``_SKIP_TAGS`` so ``_skip`` can never be left
|
|
30
|
+
incremented forever by one."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
super().__init__(convert_charrefs=True)
|
|
34
|
+
self._chunks: list[str] = []
|
|
35
|
+
self._skip = 0
|
|
36
|
+
|
|
37
|
+
def handle_starttag(self, tag: str, attrs: list) -> None:
|
|
38
|
+
tag = tag.lower()
|
|
39
|
+
if tag in _SKIP_TAGS:
|
|
40
|
+
self._skip += 1
|
|
41
|
+
if tag in _BLOCK_TAGS:
|
|
42
|
+
self._chunks.append("\n")
|
|
43
|
+
|
|
44
|
+
def handle_endtag(self, tag: str) -> None:
|
|
45
|
+
tag = tag.lower()
|
|
46
|
+
if tag in _SKIP_TAGS:
|
|
47
|
+
self._skip = max(0, self._skip - 1)
|
|
48
|
+
if tag in _BLOCK_TAGS:
|
|
49
|
+
self._chunks.append("\n")
|
|
50
|
+
|
|
51
|
+
def handle_data(self, data: str) -> None:
|
|
52
|
+
if not self._skip:
|
|
53
|
+
self._chunks.append(data)
|
|
54
|
+
|
|
55
|
+
def get_text(self) -> str:
|
|
56
|
+
raw = "".join(self._chunks)
|
|
57
|
+
lines = [re.sub(r"[ \t]+", " ", ln).strip() for ln in raw.splitlines()]
|
|
58
|
+
text = "\n".join(lines)
|
|
59
|
+
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_title(raw_html: str) -> str | None:
|
|
63
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", raw_html, re.IGNORECASE | re.DOTALL)
|
|
64
|
+
if not m:
|
|
65
|
+
return None
|
|
66
|
+
title = _html_stdlib.unescape(m.group(1))
|
|
67
|
+
title = re.sub(r"\s+", " ", title).strip()
|
|
68
|
+
return title or None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _extract_text(raw_html: str) -> tuple[str, list[str]]:
|
|
72
|
+
warnings: list[str] = []
|
|
73
|
+
try:
|
|
74
|
+
from lxml.html import fromstring as _fromstring
|
|
75
|
+
|
|
76
|
+
doc = _fromstring(raw_html)
|
|
77
|
+
for bad in doc.xpath("//script|//style|//noscript"):
|
|
78
|
+
bad.drop_tree()
|
|
79
|
+
text = doc.text_content()
|
|
80
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
81
|
+
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
|
82
|
+
if text:
|
|
83
|
+
return text, warnings
|
|
84
|
+
# fall through to stdlib if lxml produced nothing
|
|
85
|
+
except ImportError:
|
|
86
|
+
pass # lxml not installed — stdlib fallback below
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
warnings.append(f"lxml_parse_warning: {type(exc).__name__}: {exc}")
|
|
89
|
+
|
|
90
|
+
extractor = _TextExtractor()
|
|
91
|
+
try:
|
|
92
|
+
extractor.feed(raw_html)
|
|
93
|
+
return extractor.get_text(), warnings
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
warnings.append(f"html_parse_warning: {type(exc).__name__}: {exc}")
|
|
96
|
+
text = re.sub(r"<[^>]+>", " ", raw_html)
|
|
97
|
+
return re.sub(r"\s+", " ", text).strip(), warnings
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class HtmlHandler(Handler):
|
|
101
|
+
extensions = (".html", ".htm")
|
|
102
|
+
dependency_name = "stdlib"
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def available(cls) -> bool:
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def extract(cls, path: Path) -> ExtractResult:
|
|
110
|
+
try:
|
|
111
|
+
size = path.stat().st_size
|
|
112
|
+
except OSError:
|
|
113
|
+
size = 0
|
|
114
|
+
if size > MAX_HTML_BYTES:
|
|
115
|
+
return ExtractResult.quarantine(
|
|
116
|
+
"file_too_large", warnings=[f"{size} bytes exceeds cap {MAX_HTML_BYTES}"]
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
raw = path.read_bytes()
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
return ExtractResult.quarantine("html_read_error", warnings=[f"{type(exc).__name__}: {exc}"])
|
|
122
|
+
|
|
123
|
+
text_raw = None
|
|
124
|
+
for enc in ("utf-8", "latin-1"):
|
|
125
|
+
try:
|
|
126
|
+
text_raw = raw.decode(enc)
|
|
127
|
+
break
|
|
128
|
+
except UnicodeDecodeError:
|
|
129
|
+
continue
|
|
130
|
+
if text_raw is None:
|
|
131
|
+
return ExtractResult.quarantine("html_decode_error")
|
|
132
|
+
|
|
133
|
+
title = _extract_title(text_raw)
|
|
134
|
+
body, warnings = _extract_text(text_raw)
|
|
135
|
+
if not body:
|
|
136
|
+
return ExtractResult.quarantine("empty_or_low_text_density", warnings=warnings)
|
|
137
|
+
|
|
138
|
+
markdown = f"# {title}\n\n{body}\n" if title else f"{body}\n"
|
|
139
|
+
reason = density_gate(markdown)
|
|
140
|
+
if reason:
|
|
141
|
+
return ExtractResult.quarantine(reason, warnings=warnings)
|
|
142
|
+
return ExtractResult(markdown=markdown, warnings=warnings, metadata={"title": title})
|