Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions stacklets/core/bot-runner/voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ def transcribed_source(source: dict, transcript: str) -> dict:
provenance["url"] = url
if filename := (content.get("filename") or content.get("body")):
provenance["filename"] = filename
# The upload's own `body` beside its `filename`, as sent: when they
# differ, `body` is the sender's caption, which the transcript is
# about to replace.
if body := content.get("body"):
provenance["body"] = body
info = content.get("info")
if isinstance(info, dict):
if (duration := info.get("duration")) is not None:
Expand Down
73 changes: 67 additions & 6 deletions stacklets/docs/bot/archivist.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@

from capture_tags import CaptureTagCache
from extractors import TextExtractor, UrlExtractor
from git_mirror import GitMirror
from git_mirror import BOT_EMAIL, BOT_USERNAME, GitMirror
from git_mirror import REPO_NAME as MEMORY_REPO
from migration_photos_to_notes import is_picture_bytes, migrate_photos_to_notes
import voice
from microbot import CHECK, CROSS, EYES, MicroBot
from pdf_analysis import (
Expand Down Expand Up @@ -465,10 +467,43 @@ async def start(self) -> None:
# first-sync hook runs once per install, not once per start.
if self._diary_task is None:
self._diary_task = asyncio.create_task(self._diary.loop())
asyncio.create_task(self._migrate_photos_to_notes())
# The framework's start() owns the session loop and closes the
# http session (via _aclose) on shutdown.
await super().start()

async def _migrate_photos_to_notes(self) -> None:
"""Run `migration_photos_to_notes` once per instance; remove with it.

Waits a minute like the diary loop: an old entry is placed by
downloading its upload, which needs the first sync done. A marker
in the session directory keeps it from running again; a failure
leaves no marker, so the next start tries again.
"""
marker = Path(self._session_dir) / "migration-photos-to-notes.done"
mirror, root = self._services.mirror, self._services.vault.root()
if marker.exists() or mirror is None or root is None:
return
await asyncio.sleep(60)

async def is_picture(mxc: str) -> bool:
data = await self._download_media(mxc)
return bool(data) and is_picture_bytes(data)

try:
client = await mirror.vault_client()
if client is None:
return
await migrate_photos_to_notes(
client, mirror.repo_owner, MEMORY_REPO, root=root,
is_picture=is_picture,
author_name=BOT_USERNAME, author_email=BOT_EMAIL,
)
except Exception as e: # noqa: BLE001 - a migration never stops the bot
logger.warning("[archivist] moving photos to notes failed: {}", e)
return
marker.touch()

def _build_services(self) -> ArchivistServices:
"""Wire every service from the bot's config.

Expand Down Expand Up @@ -1268,6 +1303,8 @@ async def _handle_correction(
await self._handle_reply_capture_reprocess(
room_id, capture_path, hint, event.sender, reply_to,
initial_classification=initial,
capture_id=envelope.get("data", {}).get("capture_id"),
reply_text=_strip_reply_fallback(event.body or ""),
)
return True
return False
Expand Down Expand Up @@ -1428,19 +1465,34 @@ async def _handle_reply_capture_reprocess(
self, room_id: str, vault_path: str, user_hint: str,
sender_mxid: str, reply_to: str,
*, initial_classification: dict | None = None,
capture_id: str | None = None,
reply_text: str = "",
) -> None:
"""Re-classify a capture with the user's reply as a hint.

Parallels `_handle_reply_reprocess` for documents: dispatch to
the pipeline, render via the shared reprocessed presenter,
attach the fresh `capture.reclassified` envelope so the user
can chain another correction by replying to THIS message.

``capture_id`` is the event the capture was filed from. Its
caption is read off that event again, so a correction restores
a caption an earlier filing dropped. ``reply_text`` is the words
of this reply, which the entry keeps as written.
"""
upload = await self._fetch_event(room_id, capture_id)
caption = (
_attachment_caption(upload.source.get("content", {}))
if upload is not None else ""
)
outcome = await self._services.capture.reprocess(
vault_path=vault_path, user_hint=user_hint,
sender_mxid=sender_mxid,
initial_classification=initial_classification,
write_in=await self._write_in(room_id),
caption=caption,
reply_text=reply_text,
capture_id=capture_id,
)
# `_reply_for_capture` already knows how to render the
# reclassified branch + attach the envelope, so we just defer.
Expand Down Expand Up @@ -1616,6 +1668,7 @@ async def _on_file(self, room, event) -> None:
# prompt as user_hint -- "neue Personalausweise für Marge und
# Bart" steers the LLM the same way a reply-to-correct does,
# without the user having to wait for a bad classification first.
# A capture also keeps the caption on its entry, as written.
raw_filename = content.get("filename") or content.get("body") or "document"
caption = _attachment_caption(content)
display_name = _clean_filename(raw_filename, msgtype)
Expand Down Expand Up @@ -1650,9 +1703,9 @@ async def _on_file(self, room, event) -> None:

# Documents room → full archivist pipeline (Paperless + classify
# + entity reconciliation). Every other reacting room is a
# capture room: PDFs and images become visual bookmarks in the
# sender's bucket; Matrix already stores the binary, so we link
# to the mxc URL and don't re-archive the bytes.
# capture room: a PDF becomes a bookmark and a photo a note, in
# the room's topic or the sender's bucket, linked to the mxc URL
# and with a copy of the bytes in the media archive.
if self._is_documents_room(ctx):
await self._process_document(
room.room_id, raw_filename, display_name, file_data, reply_to,
Expand Down Expand Up @@ -1686,6 +1739,7 @@ async def _on_file(self, room, event) -> None:
default_person=not bot_attachment,
extra_seed_topics=extra_seed_topics,
captured_ts=getattr(event, "server_timestamp", 0) or 0,
caption=caption,
)

async def _on_text(self, room, event: RoomMessageText) -> None:
Expand Down Expand Up @@ -1912,6 +1966,9 @@ async def _on_text(self, room, event: RoomMessageText) -> None:
await self._handle_text_capture(
room.room_id, query, event.sender, reply_to,
capture_id=event.event_id, transcribed=transcribed,
caption=_attachment_caption(
event.source.get("content", {}).get(voice.TRANSCRIPT_KEY) or {}
),
)

else:
Expand Down Expand Up @@ -2341,7 +2398,7 @@ async def _handle_capture(
async def _handle_text_capture(
self, room_id: str, text: str, sender_mxid: str,
reply_to: str | None = None, *, capture_id: str | None = None,
transcribed: bool = False,
transcribed: bool = False, caption: str = "",
) -> None:
# 👀 before the work, like every other capture shape: a link gets
# it from the pipeline's notifier, an upload from the handler. A
Expand All @@ -2361,6 +2418,7 @@ async def _handle_text_capture(
bucket=binding.bucket if binding else None,
transcribed=transcribed,
write_in=await self._write_in(room_id),
caption=caption,
)
await self._reply_for_capture(room_id, outcome, reply_to)

Expand Down Expand Up @@ -2422,8 +2480,9 @@ async def _handle_binary_capture(
default_person: bool = True,
extra_seed_topics: list[str] | None = None,
captured_ts: int = 0,
caption: str = "",
) -> None:
"""Capture a PDF or image as a visual bookmark.
"""Capture a PDF as a bookmark, or a photo as a note.

Vision-driven by default (the classifier sees the rendered
page(s) + any extractable text layer). The entry points back at
Expand All @@ -2439,6 +2498,7 @@ async def _handle_binary_capture(
attachment): the sender is a bot, not the owner, so don't fall
it in as the person. ``extra_seed_topics`` prepend provenance
tags (Sender, ``email``) on top of any topic-room seeds.
``caption`` is what the sender wrote with the upload.
"""
room = self._room_by_id(room_id)
binding = await self._topic_binding(room, sender_mxid)
Expand Down Expand Up @@ -2468,6 +2528,7 @@ async def _handle_binary_capture(
sender_mxid=sender_mxid, ts_ms=captured_ts,
),
write_in=await self._write_in(room_id),
caption=caption,
)
await self._reply_for_capture(room_id, outcome, reply_to)

Expand Down
Loading
Loading