Files
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

991 lines
42 KiB
Python

"""format_miner.py — proposed for mempalace 3.3.6.
A third miner alongside ``miner.py`` (project files) and ``convo_miner.py``
(chat exports). This one handles **binary office-format documents**:
PDF, DOCX, PPTX, XLSX, RTF, EPUB.
Architecture matches the existing miner-per-content-type pattern:
mempalace mine <dir> → miner.py
mempalace mine <dir> --mode convos → convo_miner.py
mempalace mine <dir> --mode extract → format_miner.py (this file)
**Read-time conversion**, never modifies source files on disk. The user's
``~/research.pdf`` stays exactly as it was after a mine — the bytes are
read into memory, handed to Microsoft's MarkItDown library for conversion
to Markdown, and the resulting text flows into the normal chunker /
drawer-store pipeline. Conversion artifacts never touch disk.
MarkItDown is an **optional** runtime dependency (declared as an extra).
If the user runs ``mempalace mine --mode extract`` without it installed,
they get a clear ``pip install markitdown`` instruction, not a crash.
**Per-format transformer routing** (verified live on a local mixed-format
test directory, 2026-05-19): MarkItDown 0.1.5 does NOT convert .rtf —
it returns the raw RTF control-code source unchanged. So .rtf is routed
to the purpose-built ``striprtf`` library; all other formats stay on
MarkItDown.
.pdf, .docx, .pptx, .xlsx, .epub → MarkItDown
.rtf → striprtf
Both libraries are optional runtime dependencies. If a user tries to
extract a format whose transformer isn't installed, they get a clear
install message (SKIP_NO_MARKITDOWN or SKIP_NO_STRIPRTF), not a crash.
**13 fringe cases handled** (spec finalized 2026-05-19):
1. MarkItDown not installed → SKIP_NO_MARKITDOWN (clear install msg)
2. File too large (> max) → SKIP_TOO_LARGE
3. iCloud cloud-only file → SKIP_CLOUD_ONLY
4. Encrypted PDF → SKIP_ENCRYPTED
5. Empty file → SKIP_EMPTY
6. Permission denied → SKIP_PERMISSION
7. Broken symlink → SKIP_BROKEN_SYMLINK
8. Dirty encoding → recovered via decode_robust
9. Windows path semantics → pathlib throughout
10. MarkItDown internal crash → SKIP_EXTRACTION_ERROR
11. Network / sync timeout → SKIP_NETWORK_TIMEOUT
12. Unrecognized extension → SKIP_UNRECOGNIZED
13. striprtf not installed → SKIP_NO_STRIPRTF (added 2026-05-19
after live test
on local RTF files)
Deferred (out of scope): custom PDF parsers for specific document types,
OCR on scanned PDFs, DRM-locked files, pathological corrupt files.
These get reported and skipped.
"""
from __future__ import annotations
import enum
import hashlib
import logging
import os
import re
import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
from .palace import (
NORMALIZE_VERSION,
SKIP_DIRS,
_validate_palace_fts5_after_mine,
file_already_mined,
get_collection,
mine_lock,
)
# Module-level imports from .miner so tests can patch them via
# mempalace.format_miner.<name>. Lazy imports inside functions would not
# expose these as attributes of this module, breaking the test seams.
from .config import MempalaceConfig, normalize_wing_name
from .collision_scan import assert_no_collisions
from .ids import ID_RECIPE, make_drawer_id_from_chunk
from .miner import (
_compute_topic_tunnels_for_wing,
chunk_text,
detect_room,
load_config,
)
__all__ = [
"SUPPORTED_FORMATS",
"DEFAULT_MAX_FILE_SIZE",
"ExtractionStatus",
"decode_robust",
"is_icloud_dataless",
"extract_text",
"scan_formats",
"mine_formats",
]
# Same batch size as miner.py / convo_miner.py — bounds memory + Chroma payload.
DRAWER_UPSERT_BATCH_SIZE = 1000
# Minimum chunk size (chars) — drawers below this are dropped as not useful.
MIN_CHUNK_SIZE = 50
logger = logging.getLogger("mempalace_format_miner")
# Extensions MarkItDown can convert. Lowercase, leading dot. Case-insensitive
# match performed against ``Path.suffix.lower()``.
SUPPORTED_FORMATS = frozenset(
{
".pdf",
".docx",
".pptx",
".xlsx",
".rtf",
".epub",
}
)
# Same default cap as miner.py / convo_miner.py — guards against pathological
# binary files and runaway memory. The caller can override via
# ``extract_text(..., max_file_size=...)`` for legitimately large documents.
DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
# Filename patterns that are not user content even if their extension matches.
_SKIP_FILENAMES = {
".DS_Store",
"Thumbs.db",
"desktop.ini",
}
# Message patterns that identify an encrypted / password-protected PDF
# regardless of which underlying library raised the exception.
_ENCRYPTED_PATTERNS = re.compile(
r"(encrypt|decrypt|password|protected)",
re.IGNORECASE,
)
class ExtractionStatus(enum.Enum):
"""Outcome of an ``extract_text`` call.
Test-friendly enum (each case has its own name) so callers can assert
exactly which path was taken. ``OK`` means text came back; everything
else is a skip variant.
"""
OK = "ok"
SKIP_TOO_LARGE = "skip:too_large"
SKIP_CLOUD_ONLY = "skip:cloud_only"
SKIP_EMPTY = "skip:empty"
SKIP_NO_MARKITDOWN = "skip:no_markitdown"
SKIP_NO_STRIPRTF = "skip:no_striprtf"
SKIP_ENCRYPTED = "skip:encrypted"
SKIP_PERMISSION = "skip:permission"
SKIP_BROKEN_SYMLINK = "skip:broken_symlink"
SKIP_UNRECOGNIZED = "skip:unrecognized"
SKIP_EXTRACTION_ERROR = "skip:extraction_error"
SKIP_MISSING_FORMAT_DEPS = "skip:missing_format_deps"
SKIP_NETWORK_TIMEOUT = "skip:network_timeout"
SKIP_UNREADABLE = "skip:unreadable"
# Skip variants that represent TRANSIENT environmental issues — the optional
# transformer dep isn't installed yet, or the network blipped. Sentinel
# writes for these would permanently mark the file as "already mined" and
# defeat re-mining after the missing piece is installed / the network is
# back. The orchestrator (``mine_formats``) checks this set before calling
# ``_register_file``. Per PR #1555 review (Copilot #14).
_TRANSIENT_MISSING_DEP_STATUSES = frozenset(
{
ExtractionStatus.SKIP_NO_MARKITDOWN,
ExtractionStatus.SKIP_NO_STRIPRTF,
ExtractionStatus.SKIP_MISSING_FORMAT_DEPS,
ExtractionStatus.SKIP_NETWORK_TIMEOUT,
}
)
# ─────────────────────────────────────────────────────────────────────────────
# Encoding fallback — same pattern as the terminal-session normalizer
# ─────────────────────────────────────────────────────────────────────────────
def decode_robust(raw: bytes) -> str:
"""Decode bytes to text without raising on dirty encodings.
Strategy: UTF-8 first (the clean case). On failure, try CP1252 (handles
legacy smart-quote bytes 0x91-0x9F that surface in older Office docs).
Final fallback is UTF-8 with ``errors='replace'`` so no byte is ever
lost — only made visible as the replacement char.
"""
if not raw:
return ""
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
pass
try:
return raw.decode("cp1252")
except UnicodeDecodeError:
pass
return raw.decode("utf-8", errors="replace")
# ─────────────────────────────────────────────────────────────────────────────
# iCloud cloud-only file detection
# ─────────────────────────────────────────────────────────────────────────────
def is_icloud_dataless(path: Path) -> bool:
"""True if ``path`` is an iCloud cloud-only placeholder (not local).
Two indicators:
1. The literal ``.icloud`` suffix iCloud uses for offloaded files.
2. The macOS UF_COMPRESSED / dataless flag on the inode (``st_flags``).
Either signal means MarkItDown would block on file I/O waiting for
iCloud to materialize the bytes, which can hang for minutes. We skip
these and report.
"""
if path.suffix.lower() == ".icloud":
return True
# macOS-only: stat() exposes st_flags which carries the dataless flag.
# On other platforms this attribute is absent or always 0.
try:
flags = getattr(path.lstat(), "st_flags", 0)
except OSError:
return False
# SF_DATALESS = 0x40000000 on macOS. Avoid hardcoding magic numbers
# cross-platform; check the bit if it exists.
DATALESS_FLAG = 0x40000000
return bool(flags & DATALESS_FLAG)
# ─────────────────────────────────────────────────────────────────────────────
# Conversion via MarkItDown — isolated so tests can patch this single seam
# ─────────────────────────────────────────────────────────────────────────────
def _extract_via_markitdown(path: Path) -> Optional[str]:
"""Run MarkItDown on ``path`` and return the resulting Markdown text.
Used for .pdf, .docx, .pptx, .xlsx, .epub. NOT used for .rtf — MarkItDown
0.1.5 doesn't convert RTF (returns raw control-code source unchanged),
so .rtf is routed to ``_extract_via_striprtf`` instead.
Raises ``ImportError`` if the markitdown package is not installed
(caller's responsibility to translate this into ``SKIP_NO_MARKITDOWN``).
Raises any other exception MarkItDown raises — the caller classifies
them via message pattern (encrypted vs generic crash vs timeout).
Returns ``None`` if MarkItDown returns an empty result for the file.
"""
try:
from markitdown import MarkItDown # type: ignore[import-not-found]
except ImportError:
raise
converter = MarkItDown()
result = converter.convert(str(path))
text = getattr(result, "text_content", None) or getattr(result, "markdown", None)
if text is None:
return None
if not isinstance(text, str):
return None
return text
def _extract_via_striprtf(path: Path) -> Optional[str]:
"""Run striprtf on ``path`` and return plain text.
Used exclusively for .rtf (see ``_extract_via_markitdown`` docstring for
why). striprtf is pure-Python, MIT-licensed, ~150 lines, cross-platform
(works on Python 3.6+).
Raises ``ImportError`` if the striprtf package is not installed
(caller's responsibility to translate this into ``SKIP_NO_STRIPRTF``).
Returns ``None`` if striprtf strips the file to empty text.
"""
try:
from striprtf.striprtf import rtf_to_text # type: ignore[import-not-found]
except ImportError:
raise
raw = path.read_bytes()
source = decode_robust(raw)
text = rtf_to_text(source)
if not isinstance(text, str):
return None
if text == "":
return None
return text
# ─────────────────────────────────────────────────────────────────────────────
# extract_text — the dispatcher with all 12 fringe cases handled
# ─────────────────────────────────────────────────────────────────────────────
def extract_text(
path: Union[Path, str],
max_file_size: int = DEFAULT_MAX_FILE_SIZE,
) -> tuple[Optional[str], ExtractionStatus]:
"""Convert ``path`` to plain text via MarkItDown, with comprehensive fringe-case handling.
Returns ``(text, ExtractionStatus)`` — text is ``None`` for every skip
case, a non-empty string for ``OK``.
Pure function (no I/O outside the file at ``path``). Source file is
never modified.
"""
# Accept str or Path; pathlib handles Windows separators correctly.
# ``expanduser()`` resolves ``~/foo.pdf`` style paths so CLI inputs work
# without forcing the caller to pre-resolve. Per PR #1555 review (Copilot).
p = Path(path).expanduser()
# Fringe Case 7 — broken symlink.
# ``is_symlink()`` true + ``exists()`` false ⇒ link target is missing.
if p.is_symlink() and not p.exists():
logger.info("skip:broken_symlink %s", p)
return None, ExtractionStatus.SKIP_BROKEN_SYMLINK
# Fringe Case 3 — iCloud cloud-only file. Detect BEFORE stat() so we
# don't accidentally trigger an iCloud materialization fetch.
if is_icloud_dataless(p):
logger.info("skip:cloud_only %s", p)
return None, ExtractionStatus.SKIP_CLOUD_ONLY
# General readability check. Anything unreadable here (file gone,
# permission denied at stat-time, etc.) collapses to SKIP_UNREADABLE
# or SKIP_PERMISSION depending on the error class.
try:
stat = p.stat()
except PermissionError:
logger.info("skip:permission (stat) %s", p)
return None, ExtractionStatus.SKIP_PERMISSION
except FileNotFoundError:
# Distinguish "broken symlink" (target gone) from "file disappeared
# between scan and extract" (regular file, no longer exists). The
# is_symlink() check at the top of this function catches the symlink
# case BEFORE stat(), so by the time we land here the symlink path
# is rare — but a race between scan_formats and extract_text on a
# plain file is the common cause. Per PR #1555 review (Copilot #8).
if p.is_symlink():
logger.info("skip:broken_symlink (stat) %s", p)
return None, ExtractionStatus.SKIP_BROKEN_SYMLINK
logger.info("skip:unreadable (file gone after scan) %s", p)
return None, ExtractionStatus.SKIP_UNREADABLE
except OSError as exc:
logger.info("skip:unreadable %s%s", p, exc)
return None, ExtractionStatus.SKIP_UNREADABLE
# Fringe Case 5 — empty file. Skip silently.
if stat.st_size == 0:
logger.debug("skip:empty %s", p)
return None, ExtractionStatus.SKIP_EMPTY
# Fringe Case 2 — file exceeds the caller's cap. The default is 500 MB
# (same as the existing miners). Callers with legitimate large docs
# raise the cap explicitly.
if stat.st_size > max_file_size:
logger.info("skip:too_large %s (%d bytes > %d)", p, stat.st_size, max_file_size)
return None, ExtractionStatus.SKIP_TOO_LARGE
# Fringe Case 12 — unrecognized extension. Cheap to check, do it last
# so we still report size / cloud / symlink issues for the obvious cases.
if p.suffix.lower() not in SUPPORTED_FORMATS:
logger.debug("skip:unrecognized %s (suffix=%s)", p, p.suffix)
return None, ExtractionStatus.SKIP_UNRECOGNIZED
# Per-format transformer routing. .rtf goes to striprtf (MarkItDown
# 0.1.5 doesn't convert RTF — returns raw control-code source
# unchanged, verified live on a local RTF set 2026-05-19).
is_rtf = p.suffix.lower() == ".rtf"
try:
if is_rtf:
text = _extract_via_striprtf(p)
else:
text = _extract_via_markitdown(p)
except ImportError:
# Fringe Case 1 / 13 — transformer not installed in this env.
if is_rtf:
logger.warning(
"skip:no_striprtf %s — install with: pip install striprtf",
p,
)
return None, ExtractionStatus.SKIP_NO_STRIPRTF
logger.warning(
"skip:no_markitdown %s — install with: pip install markitdown",
p,
)
return None, ExtractionStatus.SKIP_NO_MARKITDOWN
except TimeoutError:
# Fringe Case 11 — network or sync-drive timeout.
logger.info("skip:network_timeout %s", p)
return None, ExtractionStatus.SKIP_NETWORK_TIMEOUT
except PermissionError:
# Fringe Case 6 — permission denied during file read.
logger.info("skip:permission %s", p)
return None, ExtractionStatus.SKIP_PERMISSION
except Exception as exc:
# Fringe Case 14 (PR #1555 review, Igor): MarkItDown raises
# ``MissingDependencyException`` when a per-format sub-extra
# (e.g. ``markitdown[pdf]``) isn't installed. Surface this as a
# distinct status so users see the actionable signal instead of
# the generic SKIP_EXTRACTION_ERROR. Match by type name (not
# isinstance) so the catch fires without requiring markitdown to
# be import-resolvable at module load — keeps the static-import
# surface unchanged.
if type(exc).__name__ == "MissingDependencyException":
logger.warning(
"skip:missing_format_deps %s%s: %s",
p,
type(exc).__name__,
str(exc)[:200],
)
return None, ExtractionStatus.SKIP_MISSING_FORMAT_DEPS
# Fringe Case 4 vs Case 10: encrypted vs generic crash, by message.
msg = str(exc)
if _ENCRYPTED_PATTERNS.search(msg):
logger.info("skip:encrypted %s%s", p, msg[:120])
return None, ExtractionStatus.SKIP_ENCRYPTED
logger.warning("skip:extraction_error %s%s: %s", p, type(exc).__name__, msg[:200])
return None, ExtractionStatus.SKIP_EXTRACTION_ERROR
# Either transformer can legitimately return None / empty (malformed
# docs that parse but extract nothing). Treat both as extraction error
# so the caller knows to skip rather than file an empty drawer.
if not text:
transformer = "striprtf" if is_rtf else "markitdown"
logger.info("skip:extraction_error %s%s returned None/empty", p, transformer)
return None, ExtractionStatus.SKIP_EXTRACTION_ERROR
return text, ExtractionStatus.OK
# ─────────────────────────────────────────────────────────────────────────────
# Directory walker
# ─────────────────────────────────────────────────────────────────────────────
def scan_formats(directory: Union[Path, str]) -> list[Path]:
"""Walk ``directory`` recursively and return supported files, sorted.
Skips:
- Hidden / build directories listed in ``palace.SKIP_DIRS``
- Filenames listed in ``_SKIP_FILENAMES`` (.DS_Store etc.)
- Symlinks (prevents circular links / processing the same file via
multiple paths; mirrors ``miner.py`` and ``convo_miner.py``)
- Files whose suffix isn't in ``SUPPORTED_FORMATS``
Returns a list of ``Path`` objects. The order is deterministic
(sorted by path) so a re-mine processes files in the same order each
time — useful for reproducing bug reports.
"""
# ``expanduser().resolve()`` normalizes ``~/docs`` and relative paths so
# CLI inputs like ``mempalace mine --mode extract ~/docs`` work without
# the caller pre-resolving. Per PR #1555 review (Copilot #9).
root = Path(directory).expanduser().resolve()
if not root.exists():
return []
found: list[Path] = []
for dirpath, dirnames, filenames in os.walk(root):
# Mutate dirnames in place to prune the walk. Uses the shared
# palace.SKIP_DIRS constant so this stays in sync with the other
# miners' skip set.
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for name in filenames:
if name in _SKIP_FILENAMES:
continue
p = Path(dirpath) / name
# Skip symlinks — prevents following links to /dev/urandom,
# circular links, or processing the same file twice via
# different paths. Mirrors miner.py:1068.
if p.is_symlink():
continue
if p.suffix.lower() not in SUPPORTED_FORMATS:
continue
found.append(p)
found.sort()
return found
# ─────────────────────────────────────────────────────────────────────────────
# mine_formats — orchestrator. Walks a directory, transforms each supported
# file via extract_text, chunks the resulting Markdown via miner.chunk_text,
# files drawers via the same lock + purge + upsert pattern convo_miner uses.
# Source files on disk are never modified.
# ─────────────────────────────────────────────────────────────────────────────
def _print_mine_summary(
files_seen: int,
files_with_text: int,
files_skipped: int,
files_errored: int,
total_drawers: int,
status_counts: dict,
) -> None:
"""Print the post-mine summary block.
Factored out of ``mine_formats`` to keep the orchestrator under the
project's mccabe complexity ceiling (max-complexity=25 in pyproject.toml).
No behavior change.
"""
print(f"\n{'=' * 55}")
print(" Summary")
print(f"{'-' * 55}")
print(f" Files seen: {files_seen}")
print(f" Files extracted: {files_with_text}")
print(f" Files skipped: {files_skipped}")
print(f" Files errored: {files_errored}")
print(f" Total drawers: {total_drawers}")
if status_counts:
print(" Extraction status:")
for name, count in sorted(status_counts.items(), key=lambda kv: -kv[1]):
print(f" {name:30} {count}")
print(f"{'=' * 55}\n")
def _register_skip_sentinel_if_appropriate(
collection, source_file: str, wing: str, agent: str, status: ExtractionStatus
) -> None:
"""Write the ``file_already_mined`` sentinel ONLY when the skip is durable.
Transient missing-dep statuses (``SKIP_NO_MARKITDOWN``, ``SKIP_NO_STRIPRTF``,
``SKIP_MISSING_FORMAT_DEPS``, ``SKIP_NETWORK_TIMEOUT``) intentionally do
NOT mark the file as mined — installing the missing extra later (or
reconnecting the network) should let the next mine pass pick it up.
Per PR #1555 review (Copilot #14).
"""
if status in _TRANSIENT_MISSING_DEP_STATUSES:
return
_register_file(collection, source_file, wing, agent)
def _register_file(collection, source_file: str, wing: str, agent: str) -> None:
"""Write a sentinel so file_already_mined() returns True for 0-chunk files.
Without this, files that extract to nothing (or hit a SKIP status) get
rescanned on every re-mine. The sentinel preserves the no-op outcome.
Mirrors the helper of the same name in convo_miner.py.
"""
sentinel_id = f"sentinel_{wing}_{hashlib.sha256(source_file.encode()).hexdigest()[:24]}"
try:
collection.upsert(
documents=["[empty]"],
ids=[sentinel_id],
metadatas=[
{
"wing": wing,
"room": "documents",
"source_file": source_file,
"chunk_index": -1,
"added_by": agent,
"filed_at": datetime.now().isoformat(),
"ingest_mode": "extract",
"extract_mode": "format",
"normalize_version": NORMALIZE_VERSION,
"is_sentinel": True,
}
],
)
except Exception:
logger.debug("Sentinel write failed for %s", source_file, exc_info=True)
def _file_chunks_locked(
collection,
source_file,
chunks,
wing,
room,
agent,
source_mtime: Optional[float] = None,
content: Optional[str] = None,
):
"""Lock the source file, purge stale drawers, and upsert fresh chunks.
Mirrors the canonical convo_miner pattern (locked purge + batched upsert)
but with ``ingest_mode="extract"`` so format-mined drawers are
distinguishable from project / convo drawers in the palace.
Each drawer's metadata includes:
- ``source_mtime`` — for mtime-based idempotency (file_already_mined
with check_mtime=True will re-mine when the source file is updated)
- ``hall`` — content-keyword routing (same detect_hall as miner.py)
- ``entities`` — extracted entity tags (same _extract_entities_for_metadata)
Returns ``(drawers_added, skipped)``.
"""
# Lazy imports to avoid a module-load cycle (miner.py imports from this
# module's package, so we defer these helpers until call time).
from .miner import _extract_content_date, _extract_entities_for_metadata, detect_hall
# Tier 6a content-date: extract once per file (not per chunk). Format-mined
# files often have date-rich content (RTF/PDF dates in body text, mtimes on
# the binary source). Caller may pass ``content`` (full extracted text) for
# the body-scan branch; if absent, the helper still uses filename + mtime.
file_content_date = _extract_content_date(source_file, content or "")
drawers_added = 0
with mine_lock(source_file):
# Re-check after lock — another agent may have just finished this file.
# Use check_mtime=True so an updated source file is re-mined even
# though a prior drawer set exists (matches miner.py semantics).
# palace.file_already_mined reads current mtime from disk and compares
# against the stored source_mtime metadata, so the caller just toggles
# the flag; no need to pass mtime explicitly.
# ``extract_mode="format"`` scopes the idempotency check to format-mode
# drawers only — drawers from convo_miner / project miner on the same
# source_file don't falsely indicate "already mined" to the format
# miner. Per PR #1555 review (Copilot #12).
if file_already_mined(collection, source_file, check_mtime=True, extract_mode="format"):
return 0, True
try:
collection.delete(where={"source_file": source_file})
except Exception:
logger.debug("Stale-drawer purge failed for %s", source_file, exc_info=True)
filed_at = datetime.now().isoformat()
for batch_start in range(0, len(chunks), DRAWER_UPSERT_BATCH_SIZE):
batch_docs: list = []
batch_ids: list = []
batch_metas: list = []
for chunk in chunks[batch_start : batch_start + DRAWER_UPSERT_BATCH_SIZE]:
drawer_id = make_drawer_id_from_chunk(wing, room, source_file, chunk["chunk_index"])
content = chunk["content"]
meta: dict = {
"wing": wing,
"room": room,
"source_file": source_file,
"chunk_index": chunk["chunk_index"],
"added_by": agent,
"filed_at": filed_at,
"ingest_mode": "extract",
"extract_mode": "format",
"normalize_version": NORMALIZE_VERSION,
"hall": detect_hall(content),
"id_recipe": ID_RECIPE,
}
if source_mtime is not None:
meta["source_mtime"] = source_mtime
# Tier 6a — propagate line range from chunk dict into drawer
# metadata so closet pointers can carry "where in source"
# info. Chunks emitted by older code paths without these
# keys produce drawers without the keys (graceful fallback).
if chunk.get("line_start") is not None:
meta["line_start"] = chunk["line_start"]
if chunk.get("line_end") is not None:
meta["line_end"] = chunk["line_end"]
# Tier 6a content-date: shared across all chunks of the file.
if file_content_date:
meta["content_date"] = file_content_date
entities = _extract_entities_for_metadata(content)
if entities:
meta["entities"] = entities
batch_docs.append(content)
batch_ids.append(drawer_id)
batch_metas.append(meta)
assert_no_collisions(list(zip(batch_ids, batch_metas)), collection)
try:
collection.upsert(
documents=batch_docs,
ids=batch_ids,
metadatas=batch_metas,
)
drawers_added += len(batch_docs)
except Exception as exc:
if "already exists" not in str(exc).lower():
raise
return drawers_added, False
def mine_formats(
format_dir: str,
palace_path: str,
wing: Optional[str] = None,
agent: str = "mempalace",
limit: int = 0,
dry_run: bool = False,
) -> None:
"""Mine a directory of binary office-format files into the palace.
Walks ``format_dir`` via ``scan_formats``, converts each supported file
to text via ``extract_text`` (which routes to MarkItDown or striprtf
per format), chunks the result with the same chunker miner.py uses, and
files the chunks as drawers under the given wing.
Source files on disk are never modified — conversion is in-memory only.
Parameters
----------
format_dir :
Directory to walk recursively. Hidden / build dirs are skipped
(see ``palace.SKIP_DIRS``). Symlinks are skipped (consistency with
``miner.py`` and ``convo_miner.py``). Only files matching
``SUPPORTED_FORMATS`` are processed.
palace_path :
Path to the ChromaDB palace (the destination of the drawers).
wing :
Wing name. Defaults to the basename of ``format_dir`` (normalized).
agent :
Identifier recorded in each drawer's ``added_by`` metadata.
limit :
If > 0, process at most this many files. Useful for sampling.
dry_run :
If True, walk + extract + chunk but do NOT open the collection or
upsert any drawers. Just prints what would have been filed.
Notes
-----
- Loads ``MempalaceConfig`` once at the start. The current chunker
(``miner.chunk_text``) uses module-level CHUNK_SIZE constants and
doesn't accept overrides, so the loaded config values aren't yet
threaded into chunking; the load is wired so future ``chunk_text``
refactors that accept per-call sizing pick this up automatically.
- Each per-file step is wrapped so one malformed file can't crash the
whole mine; the loop continues with the next file and logs the
offender.
- ``KeyboardInterrupt`` is caught at the orchestrator level so the
summary still prints on Ctrl-C; partial progress is safe to leave
in place because drawer IDs are deterministic (re-mining
upserts to the same rows).
"""
# MempalaceConfig + chunk_text are imported at module level (above) so
# tests can patch ``mempalace.format_miner.MempalaceConfig`` and
# ``mempalace.format_miner.chunk_text`` directly. Hoisted as part of the
# PR #1555 polish PR (Gemini #3).
# Load palace-wide config. Chunk parameters (chunk_size, chunk_overlap,
# min_chunk_size) are now threaded through chunk_text below, so users
# who customized their config see the effect in format-mode mining.
# Per PR #1555 review (Gemini #3).
palace_config = MempalaceConfig()
format_path = Path(format_dir).expanduser().resolve()
if not wing:
wing = normalize_wing_name(format_path.name)
# Load the project's mempalace.yaml (rooms list + wing override) so
# detect_room has real categories to route into. Mirrors miner.py:1154.
# Fall back to a single "documents" room if no config exists — keeps the
# detector well-defined without forcing a yaml on every format dir.
try:
project_config = load_config(format_dir)
rooms = project_config.get(
"rooms",
[
{
"name": "documents",
"description": "All format-mined files",
"keywords": ["documents"],
}
],
)
except Exception:
logger.debug("mine_formats: load_config fallback to default rooms", exc_info=True)
rooms = [
{
"name": "documents",
"description": "All format-mined files",
"keywords": ["documents"],
}
]
# Initialize loop-state up-front so the outer try/except handlers and the
# post-try summary print can run safely even if scan_formats or
# get_collection raises before the for loop starts. Per PR #1555 review
# (Gemini #5).
files: list = []
collection = None
total_drawers = 0
files_mined = 0
files_skipped = 0
files_with_text = 0
files_errored = 0
files_processed = 0
status_counts: dict = defaultdict(int)
try:
# Use the resolved ``format_path``, not the raw ``format_dir``, so that
# ``~/docs`` and relative inputs work consistently. Per PR #1555 review
# (Copilot #10).
files = scan_formats(format_path)
print(f"\n{'=' * 55}")
print(" MemPalace Mine — Format extraction")
print(f"{'=' * 55}")
print(f" Wing: {wing}")
print(f" Source: {format_path}")
limit_suffix = f" (limit: {limit} new)" if limit > 0 else ""
print(f" Files: {len(files)}{limit_suffix}")
print(f" Palace: {palace_path}")
if dry_run:
print(" DRY RUN — nothing will be filed")
print(f"{'-' * 55}\n")
collection = get_collection(palace_path) if not dry_run else None
for i, filepath in enumerate(files, 1):
files_processed = i
source_file = str(filepath)
# Per-file try/except so one bad file can't crash the whole mine.
# Mirrors miner.py's robustness pattern.
try:
# Cheap mtime read up-front — used as the idempotency key on
# subsequent re-mines (check_mtime=True compares stored vs.
# current mtime).
try:
source_mtime: Optional[float] = os.path.getmtime(source_file)
except OSError:
source_mtime = None
# Pass extract_mode="format" so format-mode idempotency is
# scoped to its own drawer set — drawers from convo_miner /
# project miner on the same source_file don't falsely
# indicate "already mined" to the format miner. Per PR #1555
# review (Copilot #11).
if not dry_run and file_already_mined(
collection, source_file, check_mtime=True, extract_mode="format"
):
files_skipped += 1
continue
text, status = extract_text(filepath)
status_counts[status.name] += 1
if status != ExtractionStatus.OK or not text:
if not dry_run:
_register_skip_sentinel_if_appropriate(
collection, source_file, wing, agent, status
)
print(f" - [{i:4}/{len(files)}] {filepath.name[:50]:50} {status.name}")
continue
# Thread the user's MempalaceConfig chunk parameters through
# so format-mode mining honors their tuning. Per PR #1555
# review (Gemini #3).
chunks = chunk_text(
text,
source_file,
chunk_size=palace_config.chunk_size,
chunk_overlap=palace_config.chunk_overlap,
min_chunk_size=palace_config.min_chunk_size,
)
if not chunks:
if not dry_run:
_register_file(collection, source_file, wing, agent)
print(f" - [{i:4}/{len(files)}] {filepath.name[:50]:50} EMPTY_AFTER_CHUNK")
continue
# Route this drawer to a room — same detect_room miner.py
# uses (folder match → filename match → content keyword
# scoring → fallback "general"). Mirrors miner.py:904.
room = detect_room(filepath, text, rooms, format_path)
files_with_text += 1
if dry_run:
print(f" [DRY RUN] {filepath.name}{len(chunks)} drawers")
total_drawers += len(chunks)
files_mined += 1
if limit > 0 and files_mined >= limit:
break
continue
drawers_added, skipped = _file_chunks_locked(
collection,
source_file,
chunks,
wing,
room,
agent,
source_mtime=source_mtime,
content=text,
)
if skipped:
files_skipped += 1
continue
total_drawers += drawers_added
files_mined += 1
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
if limit > 0 and files_mined >= limit:
break
except Exception as exc:
# Log and continue — one malformed file shouldn't kill the
# whole mine. Mirrors miner.py's per-file recovery.
files_errored += 1
logger.warning(
"mine_formats: error processing %s%s: %s",
source_file,
type(exc).__name__,
str(exc)[:200],
)
print(
f" ! [{i:4}/{len(files)}] {filepath.name[:50]:50} ERROR: {type(exc).__name__}"
)
continue
except KeyboardInterrupt:
# Partial progress is safe — deterministic drawer IDs mean a re-mine
# upserts to the same rows. Print a clean summary and exit.
print("\n Mine interrupted by user (Ctrl-C).")
except Exception as exc:
# Defense in depth — the per-file try/except above catches most
# realistic crashes, but a programming error in the outer-loop
# plumbing (file enumeration, scan_formats itself, etc.) would
# otherwise propagate as a bare traceback. Catch it here so the
# summary still prints and the PID-file finally-block still runs.
# Per PR #1555 review (Gemini #5). Mirrors miner.py's belt-and-
# suspenders pattern.
logger.warning(
"mine_formats: unexpected outer-loop exception — %s: %s",
type(exc).__name__,
str(exc)[:200],
)
print(
f"\n Mine aborted by exception ({type(exc).__name__}: {str(exc)[:120]})",
file=sys.stderr,
)
else:
# All files processed without interruption — compute cross-wing topic
# tunnels linking this wing to others that share confirmed topics.
# Mirrors the post-loop tunnel block in miner._mine_impl: tunnel-compute
# failures must never fail a mine, so any exception is logged and
# skipped quietly.
if not dry_run:
try:
tunnels_added = _compute_topic_tunnels_for_wing(wing)
if tunnels_added:
print(f"\n Topic tunnels: +{tunnels_added} cross-wing link(s)")
except Exception as exc:
logger.warning(
"mine_formats: topic tunnel computation skipped — %s: %s",
type(exc).__name__,
str(exc)[:200],
)
print(
f"\n WARNING: topic tunnel computation skipped — {exc}",
file=sys.stderr,
)
# End-of-mine FTS5 integrity check (#1537). Mirrors _mine_impl;
# raises MineValidationError to cmd_mine if PRAGMA quick_check
# finds malformed FTS5 rows so a corrupted palace cannot silently
# exit Done on the --mode extract path that bypasses _mine_impl.
# Sits outside the per-file try/except in the for-loop body, so
# caught per-file errors do not mask the integrity result.
_validate_palace_fts5_after_mine(palace_path)
finally:
# Hook-spawned mines write a PID file that miner.py's
# _cleanup_mine_pid_file() clears; we mirror that so format-mode
# mines kicked off the same way don't leave a stale PID behind.
try:
from .miner import _cleanup_mine_pid_file
except ImportError:
_cleanup_mine_pid_file = None
if _cleanup_mine_pid_file is not None:
try:
_cleanup_mine_pid_file()
except Exception:
logger.debug("mine_formats: _cleanup_mine_pid_file failed", exc_info=True)
_print_mine_summary(
files_seen=files_processed,
files_with_text=files_with_text,
files_skipped=files_skipped,
files_errored=files_errored,
total_drawers=total_drawers,
status_counts=status_counts,
)