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

339 lines
14 KiB
Python

"""
diary_ingest.py — Ingest daily summary files into the palace.
Architecture:
- ONE drawer per (wing, day) — full verbatim content, upserted as the day grows.
- Closets pack topics up to CLOSET_CHAR_LIMIT, never split mid-topic.
- A re-ingest fully purges the prior day's closets before rebuilding so a
shorter day never leaves orphans behind.
- Only new entries are processed by default (tracks entry count in a state
file under ``~/.mempalace/state/`` — never inside the user's diary dir).
- Per-file ``mine_lock`` so concurrent ingest from two terminals can't race.
- Entities extracted and stamped on metadata for filterable search.
Usage:
python -m mempalace.diary_ingest --dir ~/daily_summaries --palace ~/.mempalace/palace
python -m mempalace.diary_ingest --dir ~/daily_summaries --palace ~/.mempalace/palace --force
"""
import hashlib
import json
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from .config import MempalaceConfig
from .miner import _extract_entities_for_metadata
from .palace import (
build_closet_lines,
get_closets_collection,
get_collection,
mine_lock,
purge_file_closets,
upsert_closet_lines,
)
logger = logging.getLogger(__name__)
DIARY_ENTRY_RE = re.compile(r"^## .+", re.MULTILINE)
def _state_file_for(palace_path: str, diary_dir: Path) -> Path:
"""Return the per-(palace, diary-dir) state-file path under ~/.mempalace/state.
Keyed by sha256 of (palace_path, diary_dir) so multiple diary folders
pointing at the same palace each get an independent state file. The
state file is *never* written inside the user's diary directory.
"""
state_root = Path(os.path.expanduser("~")) / ".mempalace" / "state"
state_root.mkdir(parents=True, exist_ok=True)
key = hashlib.sha256(f"{palace_path}|{diary_dir}".encode()).hexdigest()[:24]
return state_root / f"diary_ingest_{key}.json"
def _split_entries(text):
"""Split diary text into (header, body) pairs per ## entry."""
parts = DIARY_ENTRY_RE.split(text)
headers = DIARY_ENTRY_RE.findall(text)
entries = []
for i, header in enumerate(headers):
body = parts[i + 1] if i + 1 < len(parts) else ""
entries.append((header.strip(), body.strip()))
return entries
def _diary_drawer_id(wing: str, date_str: str) -> str:
"""Stable, wing-scoped legacy drawer ID (file-level).
Retained for backwards-compatible cleanup of palaces that ingested
diaries before #1539 — those palaces hold one ``drawer_diary_{...}``
per file. New drawers use ``_diary_drawer_id_entry`` so each ``##``
entry becomes its own drawer (with per-entry character chunking
when an entry exceeds ``chunk_size``).
"""
suffix = hashlib.sha256(f"{wing}|{date_str}".encode()).hexdigest()[:24]
return f"drawer_diary_{suffix}"
def _diary_drawer_id_entry(wing: str, date_str: str, entry_idx: int, entry_chunk_idx: int) -> str:
"""Per-entry, per-chunk drawer ID introduced in #1539.
The ``v2_`` prefix distinguishes new IDs from the legacy file-level
scheme (one drawer per file). Legacy drawers from pre-#1539 palaces
are auto-purged on any ``ingest_diaries`` call that triggers a full
rebuild (``force=True`` or detected content change). The per-source
``delete(where=...)`` step on full rebuild collects both legacy and
stale v2 drawers via the ``source_file`` metadata key, so the
schema migration runs as a side effect of normal use.
"""
suffix = hashlib.sha256(
f"{wing}|{date_str}|{entry_idx}|{entry_chunk_idx}".encode()
).hexdigest()[:24]
return f"drawer_diary_v2_{suffix}"
def _diary_closet_id_base(wing: str, date_str: str) -> str:
suffix = hashlib.sha256(f"{wing}|{date_str}".encode()).hexdigest()[:24]
return f"closet_diary_{suffix}"
def ingest_diaries(
diary_dir,
palace_path,
wing="diary",
force=False,
):
"""Ingest daily summary files into the palace.
Each date file gets ONE drawer keyed by ``(wing, date)`` and closets that
pack topics atomically up to ``CLOSET_CHAR_LIMIT``. ``force=True`` rebuilds
every entry's closets from scratch (purging stale ones); the default
incremental mode only processes entries appended since the last run.
"""
diary_dir = Path(diary_dir).expanduser().resolve()
if not diary_dir.exists():
print(f"Diary directory not found: {diary_dir}")
return {"days_updated": 0, "closets_created": 0}
diary_files = sorted(diary_dir.glob("*.md"))
if not diary_files:
print(f"No .md files in {diary_dir}")
return {"days_updated": 0, "closets_created": 0}
state_file = _state_file_for(str(palace_path), diary_dir)
if force or not state_file.exists():
state: dict = {}
else:
try:
state = json.loads(state_file.read_text())
except Exception:
state = {}
drawers_col = get_collection(palace_path)
closets_col = get_closets_collection(palace_path)
chunk_size = MempalaceConfig().chunk_size
days_updated = 0
closets_created = 0
for diary_path in diary_files:
text = diary_path.read_text(encoding="utf-8", errors="replace")
if len(text.strip()) < 50:
continue
date_match = re.match(r"(\d{4}-\d{2}-\d{2})", diary_path.stem)
if not date_match:
continue
date_str = date_match.group(1)
# Skip if content hasn't changed. Hash-based — size alone false-negatives
# on same-length edits (e.g. "teh" → "the"), silently dropping real edits.
state_key = f"{wing}|{diary_path.name}"
prev_entry = state.get(state_key, {})
prev_hash = prev_entry.get("content_hash")
prev_size = prev_entry.get("size", 0)
curr_size = len(text)
curr_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
if not force:
if prev_hash is not None:
if curr_hash == prev_hash:
continue
elif curr_size == prev_size and prev_size > 0:
# Legacy state without content_hash: keep size-based skip but
# backfill the hash so future runs use the strict check.
state[state_key] = {**prev_entry, "content_hash": curr_hash}
continue
# An in-place edit (same entry count, different content) means existing
# closets are stale. Force a full rebuild whenever the hash changes,
# not only on entry-count growth.
content_changed = prev_hash is not None and curr_hash != prev_hash
now_iso = datetime.now(timezone.utc).isoformat()
entities = _extract_entities_for_metadata(text)
source_file = str(diary_path)
# Serialize per source — two terminals running ingest at once must
# not interleave the upsert + closet-rebuild.
with mine_lock(source_file):
entries = _split_entries(text)
prev_entry_count = state.get(state_key, {}).get("entry_count", 0)
full_rebuild = force or content_changed
base_meta = {
"date": date_str,
"wing": wing,
"room": "daily",
"source_file": source_file,
"source_session": "daily_diary",
"filed_at": now_iso,
}
if entities:
base_meta["entities"] = entities
# On full rebuild, purge ALL prior drawers for this source_file
# before writing fresh entries. This covers three cases in one
# step: (a) legacy pre-#1539 file-level drawers under the old
# ``drawer_diary_`` prefix, (b) v2 drawers from a prior pass
# where the file had MORE entries than the current run (entry
# deletion would otherwise leave trailing orphan drawers), and
# (c) in-place edits that shift content across entry boundaries.
if full_rebuild:
try:
drawers_col.delete(where={"source_file": source_file})
except Exception as exc:
# ChromaDB ``delete(where=...)`` against an empty collection
# returns silently rather than raising, so this catch only
# fires on real backend errors (locked DB, schema mismatch,
# transient I/O). Log at debug to preserve diagnostics
# without aborting the rebuild: the upsert below is the
# load-bearing write, and if it also fails the state file
# is left unchanged so the next pass retries cleanly.
logger.debug(
"legacy purge skipped for %s: %s",
source_file,
exc,
exc_info=True,
)
# Per-entry drawers, with character chunking inside any entry
# whose serialized text exceeds chunk_size. ``chunk_index`` is
# a global counter across the file so the searcher's
# ``_expand_with_neighbors`` (which queries by source_file +
# chunk_index) can stitch sibling chunks back together
# regardless of entry boundary. ``entry_index`` and
# ``entry_chunk_index`` are preserved for entry-grouping
# consumers.
#
# Accumulate all drawers for the file into one batched upsert
# so the embedding pass either commits every chunk or none.
# A mid-loop embedding failure would otherwise leave a
# half-written day's drawers behind, which the next
# incremental pass would skip (state file already updated)
# and the searcher would silently return partial results.
global_chunk_index = 0
batch_ids: list[str] = []
batch_docs: list[str] = []
batch_metas: list[dict] = []
for entry_idx, (header, body) in enumerate(entries):
entry_text = f"{header}\n{body}" if body else header
if len(entry_text) <= chunk_size:
batch_ids.append(_diary_drawer_id_entry(wing, date_str, entry_idx, 0))
batch_docs.append(entry_text)
batch_metas.append(
{
**base_meta,
"chunk_index": global_chunk_index,
"entry_index": entry_idx,
"entry_chunk_index": 0,
"entry_header_preview": header[:120],
}
)
global_chunk_index += 1
else:
for entry_chunk_idx, start in enumerate(range(0, len(entry_text), chunk_size)):
batch_ids.append(
_diary_drawer_id_entry(wing, date_str, entry_idx, entry_chunk_idx)
)
batch_docs.append(entry_text[start : start + chunk_size])
batch_metas.append(
{
**base_meta,
"chunk_index": global_chunk_index,
"entry_index": entry_idx,
"entry_chunk_index": entry_chunk_idx,
"entry_header_preview": header[:120],
}
)
global_chunk_index += 1
if batch_ids:
drawers_col.upsert(
ids=batch_ids,
documents=batch_docs,
metadatas=batch_metas,
)
new_entries = entries if full_rebuild else entries[prev_entry_count:]
if new_entries:
all_lines = []
for offset, (header, body) in enumerate(new_entries):
entry_idx = offset if full_rebuild else prev_entry_count + offset
entry_text = f"{header}\n{body}" if body else header
# Closet references the canonical (entry_chunk_idx=0)
# drawer for the entry. Searcher._expand_with_neighbors
# stitches sibling chunks back via the
# (source_file, chunk_index) pair.
entry_drawer_id = _diary_drawer_id_entry(wing, date_str, entry_idx, 0)
entry_lines = build_closet_lines(
source_file, [entry_drawer_id], entry_text, wing, "daily"
)
all_lines.extend(entry_lines)
if all_lines:
closet_id_base = _diary_closet_id_base(wing, date_str)
closet_meta = {
"date": date_str,
"wing": wing,
"room": "daily",
"source_file": source_file,
"filed_at": now_iso,
}
if entities:
closet_meta["entities"] = entities
# On any full rebuild (force or detected content edit),
# wipe leftover closets from a prior run before re-writing.
if full_rebuild:
purge_file_closets(closets_col, source_file)
n = upsert_closet_lines(closets_col, closet_id_base, all_lines, closet_meta)
closets_created += n
state[state_key] = {
"size": curr_size,
"content_hash": curr_hash,
"entry_count": len(entries),
"ingested_at": now_iso,
}
days_updated += 1
state_file.write_text(json.dumps(state, indent=2))
if days_updated:
print(f"Diary: {days_updated} days updated, {closets_created} new closets")
return {"days_updated": days_updated, "closets_created": closets_created}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Ingest daily summaries into the palace")
parser.add_argument("--dir", required=True, help="Path to daily_summaries directory")
parser.add_argument("--palace", default=os.path.expanduser("~/.mempalace/palace"))
parser.add_argument("--wing", default="diary")
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
ingest_diaries(args.dir, args.palace, wing=args.wing, force=args.force)