diff --git a/scripts/check_journal_io_access.py b/scripts/check_journal_io_access.py index a3982573b..936f25215 100644 --- a/scripts/check_journal_io_access.py +++ b/scripts/check_journal_io_access.py @@ -133,6 +133,7 @@ OWNER_FILES: frozenset[str] = frozenset( "solstone/observe/transfer.py", "solstone/think/importers/cli.py", "solstone/think/importers/documents.py", + "solstone/think/importers/images.py", "solstone/think/importers/shared.py", "solstone/convey/chat_stream.py", # imports/** bundle + sync-cursor writers (local/CLI import flows). diff --git a/solstone/apps/import/ingest.py b/solstone/apps/import/ingest.py index 5e1b56a7d..65d8b8b2b 100644 --- a/solstone/apps/import/ingest.py +++ b/solstone/apps/import/ingest.py @@ -258,7 +258,11 @@ def register_ingest_routes(bp) -> None: } for name in expected_names ] - state_record: dict[str, Any] = {"files": file_records} + state_record: dict[str, Any] = { + "files": file_records, + "imported_via": "peer_link", + "link_id": sender_fingerprint, + } if sender_fingerprint is not None: state_record["sender_fingerprint"] = sender_fingerprint if sender_instance_id is not None: @@ -276,6 +280,8 @@ def register_ingest_routes(bp) -> None: "reason": reason, "files": expected_names, } + entry["imported_via"] = "peer_link" + entry["link_id"] = sender_fingerprint if action == "deconflicted": entry["original_key"] = original_segment_key if sender_fingerprint is not None: diff --git a/solstone/apps/import/routes.py b/solstone/apps/import/routes.py index ccd4bdf4c..764227a19 100644 --- a/solstone/apps/import/routes.py +++ b/solstone/apps/import/routes.py @@ -185,6 +185,17 @@ SOURCE_METADATA = [ "has_guide": False, "accept": ".pdf", }, + { + "name": "image", + "display_name": "Image", + "emoji": "🖼️", + "icon": "image", + "description": "Add a photo or screenshot and let sol describe what's in it", + "input_type": "file", + "upload_prompt": "Upload an image (PNG, JPEG, WebP, GIF, TIFF)", + "has_guide": False, + "accept": ".png,.jpg,.jpeg,.webp,.gif,.tiff", + }, { "name": "quick", "display_name": "Quick Import", @@ -199,6 +210,14 @@ SOURCE_METADATA = [ ] +def _link_id_from_identity() -> str | None: + return ( + g.identity.fingerprint + if g.identity.mode in {"pl-direct", "pl-via-spl"} + else None + ) + + @import_bp.route("/api/save", methods=["POST"]) def import_save() -> Any: from datetime import datetime @@ -306,6 +325,9 @@ def import_save() -> Any: "facet": facet, # Include selected facet "setting": setting, "file_path": str(file_path), # Store the actual file path + "imported_via": request.form.get("imported_via", "").strip() or "web_dashboard", + "link_id": _link_id_from_identity(), + "observer_handle": request.form.get("observer_handle", "").strip() or None, } # Write metadata using utility function @@ -379,6 +401,9 @@ def import_save_path() -> Any: "facet": facet, "setting": setting, "is_local_path": True, + "imported_via": data.get("imported_via", "").strip() or "web_dashboard", + "link_id": _link_id_from_identity(), + "observer_handle": data.get("observer_handle", "").strip() or None, } write_import_metadata( diff --git a/solstone/think/importers/cli.py b/solstone/think/importers/cli.py index a186f82d9..b96b96e52 100644 --- a/solstone/think/importers/cli.py +++ b/solstone/think/importers/cli.py @@ -401,6 +401,8 @@ def _import_one_from_args(args: argparse.Namespace) -> dict[str, Any] | None: global _stage_start_time, _stages_run, _status_thread, _status_running args.media = os.path.expanduser(args.media) + if args.source == "quick": + args.source = None _file_importer = None import_source = None @@ -949,7 +951,9 @@ def _import_one_from_args(args: argparse.Namespace) -> dict[str, Any] | None: ) # Write import manifest for dedup tracking - from solstone.think.importers.shared import write_manifest + from solstone.think.importers.shared import read_provenance, write_manifest + + _prov = read_provenance(journal_root, args.timestamp) write_manifest( journal_root, @@ -958,6 +962,9 @@ def _import_one_from_args(args: argparse.Namespace) -> dict[str, Any] | None: source_hash=_source_hash, entry_count=result.entries_written, files_created=result.files_created, + imported_via=_prov["imported_via"], + link_id=_prov["link_id"], + observer_handle=_prov["observer_handle"], ) if args.json: @@ -1159,7 +1166,9 @@ def _import_one_from_args(args: argparse.Namespace) -> dict[str, Any] | None: # theirs in the file-importer branch above (line ~887); guard prevents a # double write since all branches fall through this common tail. if _file_importer is None: - from solstone.think.importers.shared import write_manifest + from solstone.think.importers.shared import read_provenance, write_manifest + + _prov = read_provenance(journal_root, args.timestamp) write_manifest( journal_root, @@ -1169,6 +1178,9 @@ def _import_one_from_args(args: argparse.Namespace) -> dict[str, Any] | None: entry_count=len(all_created_files), files_created=all_created_files, days_affected=[day], + imported_via=_prov["imported_via"], + link_id=_prov["link_id"], + observer_handle=_prov["observer_handle"], ) imported_path = import_dir / "imported.json" diff --git a/solstone/think/importers/file_importer.py b/solstone/think/importers/file_importer.py index 9e5314615..f6d293c1f 100644 --- a/solstone/think/importers/file_importer.py +++ b/solstone/think/importers/file_importer.py @@ -69,6 +69,7 @@ FILE_IMPORTER_REGISTRY: dict[str, str] = { "kindle": "solstone.think.importers.kindle", "gemini": "solstone.think.importers.gemini", "document": "solstone.think.importers.documents", + "image": "solstone.think.importers.images", "journal_archive": "solstone.think.importers.journal_archive", } diff --git a/solstone/think/importers/images.py b/solstone/think/importers/images.py new file mode 100644 index 000000000..021b9b1cb --- /dev/null +++ b/solstone/think/importers/images.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Image importer with vision description.""" + +from __future__ import annotations + +import datetime as dt +from pathlib import Path +from typing import Callable + +from PIL import Image, UnidentifiedImageError + +from solstone.think.importers.file_importer import ImportPreview, ImportResult +from solstone.think.importers.shared import install_source_file, write_content_manifest +from solstone.think.journal_io import write_text +from solstone.think.models import generate +from solstone.think.utils import day_path + +_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".tiff"} +_VISION_PROMPT = ( + "Describe what is in this image faithfully and concisely. " + "Transcribe any legible text verbatim. Return clean markdown." +) + + +def _describe_image(image: Image.Image) -> str: + """Describe an image using the vision model.""" + description = generate( + contents=[_VISION_PROMPT, image], context="import.image.vision" + ).strip() + if not description: + raise RuntimeError("Vision produced no description for image") + return description + + +def _render_image_markdown(title: str, description: str, meta: dict) -> str: + """Render image description as markdown.""" + lines = [f"# {title}", "", "**Type:** Image"] + if meta.get("format"): + lines.append(f"**Format:** {meta['format']}") + if meta.get("width") is not None and meta.get("height") is not None: + lines.append(f"**Dimensions:** {meta['width']}×{meta['height']}") + if meta.get("date"): + lines.append(f"**Date:** {meta['date']}") + lines.extend(["", "---", "", description.strip()]) + return "\n".join(lines).rstrip() + "\n" + + +class ImageImporter: + name = "image" + display_name = "Image" + file_patterns = ["*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif", "*.tiff"] + description = "Import a single image and describe its contents with vision" + + def detect(self, path: Path) -> bool: + return path.is_file() and path.suffix.lower() in _IMAGE_EXTS + + def preview(self, path: Path) -> ImportPreview: + try: + with Image.open(path) as image: + fmt = image.format or path.suffix.lower().lstrip(".").upper() + width, height = image.size + ts = path.stat().st_mtime + except Exception: + return ImportPreview( + date_range=("", ""), + item_count=0, + entity_count=0, + summary="No readable image found", + ) + + day = dt.datetime.fromtimestamp(ts).strftime("%Y%m%d") + return ImportPreview( + date_range=(day, day), + item_count=1, + entity_count=0, + summary=f"1 image ({fmt}, {width}×{height})", + ) + + def process( + self, + path: Path, + journal_root: Path, + *, + facet: str | None = None, + import_id: str | None = None, + progress_callback: Callable[..., None] | None = None, + dry_run: bool = False, + ) -> ImportResult: + import_id = import_id or dt.datetime.now().strftime("%Y%m%d_%H%M%S") + + try: + image = Image.open(path) + image.load() + except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as exc: + raise ValueError(f"Cannot decode image {path.name}: {exc}") from exc + + try: + ts = path.stat().st_mtime + except OSError: + ts = dt.datetime.now().timestamp() + + seg_dt = dt.datetime.fromtimestamp(ts) + day = seg_dt.strftime("%Y%m%d") + seg_key = f"{seg_dt.strftime('%H%M%S')}_0" + title = path.stem + fmt = image.format or path.suffix.lower().lstrip(".").upper() + width = image.width + height = image.height + date_str = seg_dt.strftime("%Y-%m-%d") + + description = _describe_image(image) + + segment_dir = day_path(day) / "import.image" / seg_key + segment_dir.mkdir(parents=True, exist_ok=True) + + install_source_file(path, segment_dir / f"original{path.suffix.lower()}") + md_path = segment_dir / "image_transcript.md" + write_text( + md_path, + _render_image_markdown( + title, + description, + {"format": fmt, "width": width, "height": height, "date": date_str}, + ), + ) + + entry = { + "id": "image-0", + "title": title, + "date": day, + "type": "image", + "preview": description[:200], + "meta": {"format": fmt, "width": width, "height": height}, + "segments": [{"day": day, "key": seg_key}], + } + write_content_manifest(import_id, [entry]) + + if progress_callback: + progress_callback( + 1, + 1, + earliest_date=day, + latest_date=day, + entities_found=0, + ) + + return ImportResult( + entries_written=1, + entities_seeded=0, + files_created=[str(md_path)], + errors=[], + summary="Imported 1 image into 1 segment", + segments=[(day, seg_key)], + date_range=(day, day), + ) + + +importer = ImageImporter() diff --git a/solstone/think/importers/shared.py b/solstone/think/importers/shared.py index 8264e42e6..eb3b7dd87 100644 --- a/solstone/think/importers/shared.py +++ b/solstone/think/importers/shared.py @@ -13,7 +13,11 @@ import tempfile from pathlib import Path from typing import Any, Callable -from solstone.think.importers.utils import save_import_file, write_import_metadata +from solstone.think.importers.utils import ( + read_import_metadata, + save_import_file, + write_import_metadata, +) from solstone.think.journal_io import atomic_replace, install_file, write_text from solstone.think.media import MIME_TYPES from solstone.think.utils import day_path, get_journal, now_ms @@ -30,6 +34,22 @@ def _get_relative_path(path: str) -> str: return path +def read_provenance(journal_root: Path, import_id: str) -> dict[str, str | None]: + """Read origin-provenance fields from an import's import.json. + + Defaults to a CLI-direct import (cli/None/None) when no import.json exists. + """ + try: + meta = read_import_metadata(journal_root, import_id) + except FileNotFoundError: + return {"imported_via": "cli", "link_id": None, "observer_handle": None} + return { + "imported_via": meta.get("imported_via") or "cli", + "link_id": meta.get("link_id"), + "observer_handle": meta.get("observer_handle"), + } + + def _write_import_jsonl( file_path: str, entries: list[dict], @@ -419,6 +439,9 @@ def _setup_import( "facet": facet, "setting": setting, "file_path": str(new_path), + "imported_via": "cli", + "link_id": None, + "observer_handle": None, } write_import_metadata( @@ -572,6 +595,9 @@ def write_manifest( entry_count: int, files_created: list[str], days_affected: list[str] | None = None, + imported_via: str = "cli", + link_id: str | None = None, + observer_handle: str | None = None, ) -> Path: """Write an import manifest for deduplication tracking. @@ -593,6 +619,9 @@ def write_manifest( "days_affected": days_affected, "files_created": files_created, "imported_at": dt.datetime.now().isoformat(), + "imported_via": imported_via, + "link_id": link_id, + "observer_handle": observer_handle, } manifest_dir = journal_root / "imports" / import_id manifest_dir.mkdir(parents=True, exist_ok=True) diff --git a/solstone/think/importers/utils.py b/solstone/think/importers/utils.py index 0028048c7..51faed999 100644 --- a/solstone/think/importers/utils.py +++ b/solstone/think/importers/utils.py @@ -352,6 +352,9 @@ def build_import_info( import_data["facet"] = import_meta.get("facet") import_data["setting"] = import_meta.get("setting") import_data["user_timestamp"] = import_meta.get("user_timestamp") + import_data["imported_via"] = import_meta.get("imported_via") + import_data["link_id"] = import_meta.get("link_id") + import_data["observer_handle"] = import_meta.get("observer_handle") task_id = import_meta.get("task_id") import_data["task_id"] = task_id # Use upload_timestamp if available for better sorting diff --git a/tests/baselines/api/import/list.json b/tests/baselines/api/import/list.json index d42aff2e8..c9cdde123 100644 --- a/tests/baselines/api/import/list.json +++ b/tests/baselines/api/import/list.json @@ -13,7 +13,10 @@ "facet": "work", "file_size": 67890, "imported_at": 1767276000.0, + "imported_via": null, + "link_id": null, "mime_type": "application/json", + "observer_handle": null, "original_filename": "claude-conversations.json", "processed": true, "setting": "ai_conversation", @@ -39,7 +42,10 @@ "facet": "work", "file_size": 123456, "imported_at": 1767261600.0, + "imported_via": null, + "link_id": null, "mime_type": "application/zip", + "observer_handle": null, "original_filename": "chatgpt-export.zip", "processed": true, "setting": "ai_conversation", @@ -65,7 +71,10 @@ "facet": "work", "file_size": 45678, "imported_at": 1767258000.0, + "imported_via": null, + "link_id": null, "mime_type": "application/zip", + "observer_handle": null, "original_filename": "calendar-export.zip", "processed": true, "setting": "calendar", @@ -91,7 +100,10 @@ "facet": "work", "file_size": 52340, "imported_at": 1767265200.0, + "imported_via": null, + "link_id": null, "mime_type": "application/zip", + "observer_handle": null, "original_filename": "My Activity.zip", "processed": true, "setting": "ai_conversation", @@ -117,7 +129,10 @@ "facet": "reading", "file_size": 34521, "imported_at": 1767297600.0, + "imported_via": null, + "link_id": null, "mime_type": "text/plain", + "observer_handle": null, "original_filename": "My Clippings.txt", "processed": true, "setting": "kindle", @@ -143,7 +158,10 @@ "facet": "work", "file_size": 89012, "imported_at": 1767283200.0, + "imported_via": null, + "link_id": null, "mime_type": "application/zip", + "observer_handle": null, "original_filename": "vault-export.zip", "processed": true, "setting": "obsidian", diff --git a/tests/test_importer.py b/tests/test_importer.py index d2f7c9f33..9579d7d9f 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -9,11 +9,16 @@ import os import subprocess import time import zipfile +from io import BytesIO from pathlib import Path from unittest.mock import ANY, MagicMock, patch import pytest +from flask import Flask, g +from PIL import Image +import solstone.think.utils as think_utils +from solstone.convey.secure_listener import ConveyIdentity from solstone.think.importers.file_importer import ImportPreview, ImportResult from solstone.think.importers.shared import install_source_file from solstone.think.utils import day_path @@ -84,6 +89,144 @@ def _read_action_entries(journal_root: Path) -> list[dict]: ] +def _import_route_client( + tmp_path: Path, + monkeypatch, + identity: ConveyIdentity | None = None, +): + import_routes = importlib.import_module("solstone.apps.import.routes") + monkeypatch.setattr( + import_routes.state, "journal_root", str(tmp_path), raising=False + ) + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + think_utils._journal_path_cache = None + monkeypatch.setattr(import_routes, "detect_created", lambda *args, **kwargs: None) + monkeypatch.setattr(import_routes, "now_ms", lambda: 1_765_000_000_000) + + stamped = identity or ConveyIdentity( + mode="dl", + fingerprint=None, + device_label=None, + paired_at=None, + session_id=None, + ) + app = Flask(__name__) + app.config["TESTING"] = True + + @app.before_request + def stamp_identity(): + g.identity = stamped + + app.register_blueprint(import_routes.import_bp) + return app.test_client() + + +def _post_import_save(client, data: dict): + payload = { + "file": (BytesIO(b"hello"), "note.txt"), + **data, + } + return client.post( + "/app/import/api/save", + data=payload, + content_type="multipart/form-data", + ) + + +def test_import_save_stamps_web_dashboard_provenance(tmp_path, monkeypatch): + client = _import_route_client(tmp_path, monkeypatch) + + response = _post_import_save(client, {"observer_handle": "phone-share"}) + + assert response.status_code == 200 + timestamp = response.get_json()["timestamp"] + metadata = json.loads( + (tmp_path / "imports" / timestamp / "import.json").read_text(encoding="utf-8") + ) + assert metadata["imported_via"] == "web_dashboard" + assert metadata["link_id"] is None + assert metadata["observer_handle"] == "phone-share" + + +def test_import_save_persists_imported_via_override(tmp_path, monkeypatch): + client = _import_route_client(tmp_path, monkeypatch) + + response = _post_import_save(client, {"imported_via": "mobile_share"}) + + assert response.status_code == 200 + timestamp = response.get_json()["timestamp"] + metadata = json.loads( + (tmp_path / "imports" / timestamp / "import.json").read_text(encoding="utf-8") + ) + assert metadata["imported_via"] == "mobile_share" + + +def test_import_save_stamps_pl_link_id(tmp_path, monkeypatch): + fingerprint = "sha256:" + ("a" * 64) + client = _import_route_client( + tmp_path, + monkeypatch, + ConveyIdentity( + mode="pl-direct", + fingerprint=fingerprint, + device_label="peer", + paired_at="2026-05-20T00:00:00Z", + session_id="session-1", + ), + ) + + response = _post_import_save(client, {}) + + assert response.status_code == 200 + timestamp = response.get_json()["timestamp"] + metadata = json.loads( + (tmp_path / "imports" / timestamp / "import.json").read_text(encoding="utf-8") + ) + assert metadata["link_id"] == fingerprint + + +def test_cli_import_provenance_defaults(tmp_path, monkeypatch): + shared = importlib.import_module("solstone.think.importers.shared") + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + think_utils._journal_path_cache = None + + assert shared.read_provenance(tmp_path, "missing") == { + "imported_via": "cli", + "link_id": None, + "observer_handle": None, + } + + media = tmp_path / "note.txt" + media.write_text("hello", encoding="utf-8") + shared._setup_import(str(media), "20260115_120000", None, None, None) + metadata = json.loads( + (tmp_path / "imports" / "20260115_120000" / "import.json").read_text( + encoding="utf-8" + ) + ) + assert metadata["imported_via"] == "cli" + assert metadata["link_id"] is None + assert metadata["observer_handle"] is None + + +def test_build_import_info_old_metadata_defaults_provenance_none(tmp_path): + from solstone.think.importers.utils import build_import_info + + timestamp = "20260115_120000" + import_dir = tmp_path / "imports" / timestamp + import_dir.mkdir(parents=True) + (import_dir / "import.json").write_text( + json.dumps({"original_filename": "old.txt"}), + encoding="utf-8", + ) + + info = build_import_info(tmp_path, timestamp) + + assert info["imported_via"] is None + assert info["link_id"] is None + assert info["observer_handle"] is None + + def test_slice_audio_segment(tmp_path): """Test slice_audio_segment extracts audio with stream copy.""" mod = importlib.import_module("solstone.think.importers.audio") @@ -1310,6 +1453,44 @@ def test_file_importer_with_timestamp(tmp_path, monkeypatch): assert (tmp_path / "imports" / "20260303_120000" / "manifest.json").exists() +@pytest.mark.parametrize("source_args", [[], ["--source", "quick"]]) +def test_image_dispatch_uses_file_importer_not_audio( + tmp_path, monkeypatch, source_args +): + """Image imports auto-detect the image file importer, including Quick Import.""" + mod = importlib.import_module("solstone.think.importers.cli") + images = importlib.import_module("solstone.think.importers.images") + + image_file = tmp_path / "shot.png" + Image.new("RGB", (8, 8), "red").save(image_file) + ts = dt.datetime(2026, 3, 3, 12, 0, 0).timestamp() + os.utime(image_file, (ts, ts)) + + callosum = MagicMock() + mock_prepare_audio_segments = MagicMock( + side_effect=AssertionError("audio path should not be used for image import") + ) + + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + monkeypatch.setattr( + "sys.argv", + ["sol import", str(image_file), *source_args, "--timestamp", "20260303_120000"], + ) + monkeypatch.setattr(images, "_describe_image", lambda image: "A red square.") + monkeypatch.setattr(mod, "CallosumConnection", lambda **kwargs: callosum) + monkeypatch.setattr(mod, "get_rev", lambda: "test-rev") + monkeypatch.setattr(mod, "_status_emitter", lambda: None) + monkeypatch.setattr(mod, "prepare_audio_segments", mock_prepare_audio_segments) + monkeypatch.setattr(mod, "index_file", lambda *args, **kwargs: None) + + mod.main() + + mock_prepare_audio_segments.assert_not_called() + segments = list((tmp_path / "chronicle").glob("*/import.image/*")) + assert len(segments) == 1 + assert (segments[0] / "image_transcript.md").exists() + + def test_file_importer_observed_events_are_batch_and_drain_distinct_days( tmp_path, monkeypatch ): diff --git a/tests/test_importer_images.py b/tests/test_importer_images.py new file mode 100644 index 000000000..7639fe731 --- /dev/null +++ b/tests/test_importer_images.py @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import datetime as dt +import os +from pathlib import Path + +import pytest +from PIL import Image + +import solstone.think.utils as think_utils +from solstone.think.importers.file_importer import ( + FILE_IMPORTER_REGISTRY, + get_file_importer, +) + + +def _configure_journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + think_utils._journal_path_cache = None + + +def _write_png(path: Path) -> None: + Image.new("RGB", (8, 8), "red").save(path) + + +def test_detect_image_files(tmp_path): + mod = __import__("solstone.think.importers.images", fromlist=["importer"]) + + for suffix in [".png", ".jpg", ".jpeg", ".webp", ".gif", ".tiff"]: + path = tmp_path / f"image{suffix}" + path.write_bytes(b"placeholder") + assert mod.importer.detect(path) is True + + (tmp_path / "note.txt").write_text("hello", encoding="utf-8") + (tmp_path / "doc.pdf").write_bytes(b"%PDF-1.4") + assert mod.importer.detect(tmp_path / "note.txt") is False + assert mod.importer.detect(tmp_path / "doc.pdf") is False + assert mod.importer.detect(tmp_path) is False + assert mod.importer.detect(tmp_path / "missing.png") is False + + +def test_preview_readable_png(tmp_path): + mod = __import__("solstone.think.importers.images", fromlist=["importer"]) + image_path = tmp_path / "shot.png" + _write_png(image_path) + + preview = mod.importer.preview(image_path) + + assert preview.item_count == 1 + assert preview.entity_count == 0 + assert preview.summary == "1 image (PNG, 8×8)" + + +def test_process_image_success(tmp_path, monkeypatch): + mod = __import__("solstone.think.importers.images", fromlist=["importer"]) + _configure_journal(tmp_path, monkeypatch) + image_path = tmp_path / "shot.png" + _write_png(image_path) + ts = dt.datetime(2026, 1, 15, 12, 0, 0).timestamp() + os.utime(image_path, (ts, ts)) + monkeypatch.setattr( + mod, + "_describe_image", + lambda image: "A red square.\n\nVisible text: none.", + ) + + result = mod.importer.process(image_path, tmp_path, import_id="20260115_120000") + + day = dt.datetime.fromtimestamp(ts).strftime("%Y%m%d") + segment_root = tmp_path / "chronicle" / day / "import.image" + segment_dirs = [path for path in segment_root.iterdir() if path.is_dir()] + assert len(segment_dirs) == 1 + segment_dir = segment_dirs[0] + md_path = segment_dir / "image_transcript.md" + assert md_path.exists() + assert "A red square." in md_path.read_text(encoding="utf-8") + assert (segment_dir / "original.png").exists() + assert result.entries_written == 1 + assert result.entities_seeded == 0 + assert result.files_created == [str(md_path)] + assert result.segments == [(day, "120000_0")] + + +def test_process_undecodable_image_raises_before_segment(tmp_path, monkeypatch): + mod = __import__("solstone.think.importers.images", fromlist=["importer"]) + _configure_journal(tmp_path, monkeypatch) + image_path = tmp_path / "bad.png" + image_path.write_bytes(b"not an image") + + with pytest.raises(ValueError, match="Cannot decode image bad.png"): + mod.importer.process(image_path, tmp_path, import_id="20260115_120000") + + assert not list((tmp_path / "chronicle").glob("**/import.image")) + + +def test_process_vision_failure_propagates_before_success_entry(tmp_path, monkeypatch): + mod = __import__("solstone.think.importers.images", fromlist=["importer"]) + _configure_journal(tmp_path, monkeypatch) + image_path = tmp_path / "shot.png" + _write_png(image_path) + + def fail_description(image): + raise RuntimeError("vision failed") + + monkeypatch.setattr(mod, "_describe_image", fail_description) + + with pytest.raises(RuntimeError, match="vision failed"): + mod.importer.process(image_path, tmp_path, import_id="20260115_120000") + + assert not list((tmp_path / "chronicle").glob("**/import.image")) + + +def test_registry_entry(): + assert FILE_IMPORTER_REGISTRY["image"] == "solstone.think.importers.images" + importer = get_file_importer("image") + assert importer is not None + assert importer.name == "image" diff --git a/tests/test_segment_ingest.py b/tests/test_segment_ingest.py index 9083fa84a..2dded047c 100644 --- a/tests/test_segment_ingest.py +++ b/tests/test_segment_ingest.py @@ -248,8 +248,15 @@ def test_ingest_new_segments(ingest_env): assert log_entries[0]["item_id"] == "20260413/laptop/143022_300" assert log_entries[0]["item_type"] == "segment" assert log_entries[0]["reason"] == "new segment" + assert log_entries[0]["imported_via"] == "peer_link" + assert log_entries[0]["link_id"] is None assert "sender_fingerprint" not in log_entries[0] assert "sender_instance_id" not in log_entries[0] + assert all( + record["imported_via"] == "peer_link" + for record in state_data["20260413"].values() + ) + assert all(record["link_id"] is None for record in state_data["20260413"].values()) assert all( "sender_fingerprint" not in record for record in state_data["20260413"].values() ) @@ -368,10 +375,14 @@ def test_pl_ingest_stamps_sender_fingerprint(pl_ingest_env): assert response.status_code == 200 state_data = _read_state(env["key_prefix"]) state_record = state_data["20260413"]["laptop/143022_300"] + assert state_record["imported_via"] == "peer_link" + assert state_record["link_id"] == env["fingerprint"] assert state_record["sender_fingerprint"] == env["fingerprint"] assert "sender_instance_id" not in state_record log_entries = _read_log(env["key_prefix"]) + assert log_entries[0]["imported_via"] == "peer_link" + assert log_entries[0]["link_id"] == env["fingerprint"] assert log_entries[0]["sender_fingerprint"] == env["fingerprint"] assert "sender_instance_id" not in log_entries[0] @@ -749,7 +760,9 @@ def test_ingest_state_json_manifest_sync(ingest_env): "sha256": compute_bytes_sha256(b'{"text":"one"}\n'), "size": len(b'{"text":"one"}\n'), }, - ] + ], + "imported_via": "peer_link", + "link_id": None, } } }