diff --git a/observe/grab.py b/observe/grab.py index aca48870d..8d3efe370 100644 --- a/observe/grab.py +++ b/observe/grab.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse import json +import logging from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path @@ -30,6 +31,30 @@ TABLE_LEVELS = { "2": ("segments", ["segment", "start", "end", "screens", "frames_analyzed"]), "3": ("screens", ["screen", "position", "connector", "frames_analyzed", "status"]), } +NEXT_FOOTERS = { + "0": "Next: sol grab ", + "1": "Next: sol grab ", + "2": "Next: sol grab ", + "3": "Next: sol grab ", +} +LEVEL_4_FOOTER = ( + "Inspect: sol grab \n" + "Save one: sol grab --out PATH\n" + "Save many: sol grab " + ",,... --out PATH\n" + "\n" + "How extraction works:\n" + " Decoding walks the video linearly from frame 0 — seeking is unsafe at the\n" + " 1 Hz capture rate. Cost is dominated by the highest requested frame_id, not\n" + " the count. Asking for ids 7,12,23 costs the same as asking for 23 alone.\n" + " Prefer batch mode when you want more than one frame from the same screen." +) +LEVEL_4_PURGED_FOOTER = ( + "Save mode unavailable: raw video has been purged by retention.\n" + "Frame metadata above is still readable.\n" + "\n" + "Inspect: sol grab " +) @dataclass @@ -40,6 +65,7 @@ class ScreenBundle: frame_records: list[dict[str, Any]] frame_index: dict[int, dict[str, Any]] legacy_schema: bool + header_only: bool status: str segment_start: datetime @@ -117,26 +143,98 @@ def _print_table(columns: list[str], rows: list[dict[str, Any]]) -> None: print(" ".join(str(row.get(col, "")).ljust(widths[col]) for col in columns)) +def _format_alternatives(header: str, items: list[str]) -> str: + lines = ["", "", header] + lines.extend(f" {item}" for item in items) + return "\n".join(lines) + + +def _available_days() -> list[str]: + chronicle_dir = Path(get_journal()) / "chronicle" + if not chronicle_dir.is_dir(): + return [] + return sorted( + day_dir.name + for day_dir in chronicle_dir.iterdir() + if day_dir.is_dir() and len(day_dir.name) == 8 and day_dir.name.isdigit() + ) + + +def _closest_days(day: str, days: list[str]) -> list[str]: + if not day.isdigit(): + return days[:5] + target = int(day) + closest = sorted(days, key=lambda value: (abs(int(value) - target), int(value)))[:5] + return sorted(closest) + + +def _available_segments(stream_dir: Path) -> list[str]: + segments = [] + for segment_dir in sorted(path for path in stream_dir.iterdir() if path.is_dir()): + start_time, end_time = segment_parse(segment_dir.name) + if start_time is not None and end_time is not None: + segments.append(segment_dir.name) + return segments + + +def _truncate_segments(segments: list[str]) -> list[str]: + if len(segments) <= 20: + return segments + return [*segments[:10], "...", *segments[-10:]] + + +def _available_screen_tokens(segment_dir: Path) -> list[str]: + tokens: set[str] = set() + for entry in segment_dir.iterdir(): + if not entry.is_file(): + continue + suffix = entry.suffix.lower() + if suffix == ".jsonl" and _is_screen_token(entry.stem): + tokens.add(entry.stem) + if suffix in VIDEO_EXTENSIONS and _is_screen_token(entry.stem): + tokens.add(entry.stem) + return sorted(_normalize_screen_token(token) for token in tokens) + + def _require_day(day: str) -> Path: day_dir = Path(get_journal()) / "chronicle" / day if not day_dir.is_dir(): - raise FileNotFoundError(f"day {day} not found") + alternatives = _format_alternatives( + "Available days (closest 5):", + _closest_days(day, _available_days()), + ) + raise FileNotFoundError(f"day {day} not found{alternatives}") return day_dir def _require_stream(day: str, stream: str) -> Path: - _require_day(day) + day_dir = _require_day(day) stream_dir = Path(get_journal()) / "chronicle" / day / stream if not stream_dir.is_dir(): - raise FileNotFoundError(f"stream {stream} not found in {day}") + streams = sorted( + path.name + for path in day_dir.iterdir() + if path.is_dir() and path.name != "health" + ) + alternatives = _format_alternatives( + f"Available streams in {day}:", + streams, + ) + raise FileNotFoundError(f"stream {stream} not found in {day}{alternatives}") return stream_dir def _require_segment(day: str, stream: str, segment: str) -> Path: - _require_stream(day, stream) + stream_dir = _require_stream(day, stream) seg_dir = segment_path(day, segment, stream, create=False) if not seg_dir.is_dir(): - raise FileNotFoundError(f"segment {segment} not found in {day}/{stream}") + alternatives = _format_alternatives( + f"Available segments in {day}/{stream}:", + _truncate_segments(_available_segments(stream_dir)), + ) + raise FileNotFoundError( + f"segment {segment} not found in {day}/{stream}{alternatives}" + ) return seg_dir @@ -186,19 +284,34 @@ def load_screen_bundle( video_path = candidate break + frame_records = [record for record in records if "frame_id" in record] + frame_index = {int(record["frame_id"]): record for record in frame_records} + if header: + non_header_records = records[1:] + else: + non_header_records = records + header_only = ( + jsonl_path is not None and not frame_records and not non_header_records + ) + legacy_schema = ( + jsonl_path is not None and not frame_records and bool(non_header_records) + ) + if jsonl_path is None and video_path is not None: status = "captured but not analyzed" + elif jsonl_path is not None and video_path is None: + status = "analyzed; raw media purged by retention" elif jsonl_path is not None: status = "analyzed" else: + alternatives = _format_alternatives( + f"Available screens in {day}/{stream}/{segment}:", + _available_screen_tokens(segment_dir), + ) raise FileNotFoundError( - f"screen {screen_token} not found in {day}/{stream}/{segment}" + f"screen {screen_token} not found in {day}/{stream}/{segment}{alternatives}" ) - frame_records = [record for record in records if "frame_id" in record] - frame_index = {int(record["frame_id"]): record for record in frame_records} - legacy_schema = jsonl_path is not None and not frame_records - segment_start, _segment_end = _segment_bounds(day, segment) journal = Path(get_journal()) @@ -209,6 +322,7 @@ def load_screen_bundle( frame_records=sorted(frame_records, key=lambda record: int(record["frame_id"])), frame_index=frame_index, legacy_schema=legacy_schema, + header_only=header_only, status=status, segment_start=segment_start, ) @@ -216,15 +330,7 @@ def load_screen_bundle( def list_segment_screens(day: str, stream: str, segment: str) -> dict[str, Any]: segment_dir = _require_segment(day, stream, segment) - tokens: set[str] = set() - for entry in segment_dir.iterdir(): - if not entry.is_file(): - continue - suffix = entry.suffix.lower() - if suffix == ".jsonl" and _is_screen_token(entry.stem): - tokens.add(entry.stem) - if suffix in VIDEO_EXTENSIONS and _is_screen_token(entry.stem): - tokens.add(entry.stem) + tokens = [_screen_stem(token) for token in _available_screen_tokens(segment_dir)] screens = [] for token in sorted(tokens): @@ -335,14 +441,14 @@ def list_screen_frames( ) frames = ( [] - if bundle.legacy_schema + if bundle.legacy_schema or bundle.header_only else [ _frame_view(bundle.segment_start, frame) for frame in bundle.frame_records ] ) error_frames = ( 0 - if bundle.legacy_schema + if bundle.legacy_schema or bundle.header_only else sum(1 for frame in bundle.frame_records if "error" in frame) ) @@ -354,6 +460,7 @@ def list_screen_frames( "frames_analyzed": len(bundle.frame_records), "error_frames": error_frames, "legacy_schema": bundle.legacy_schema, + "video_present": bundle.video_path is not None, }, "frames": frames, }, @@ -432,6 +539,15 @@ def save_frame_images( ) -> dict[str, Any]: bundle = _load_analyzed_bundle(day, stream, segment, screen_token) if bundle.video_path is None: + if bundle.jsonl_rel is not None: + frame_id_token = ",".join(str(frame_id) for frame_id in frame_ids) + command = ( + f"sol grab {day} {stream} {segment} {screen_token} {frame_id_token}" + ) + raise FileNotFoundError( + "raw video has been purged by retention; metadata-only access " + f"remains via: {command}" + ) raise FileNotFoundError( f"raw video not found for screen {screen_token} in {segment}" ) @@ -502,15 +618,25 @@ def emit_output(payload: dict[str, Any], *, as_json: bool) -> None: if level in TABLE_LEVELS: key, columns = TABLE_LEVELS[level] _print_table(columns, data[key]) + print() + print(NEXT_FOOTERS[level]) return if level == "4": - if data["summary"]["legacy_schema"]: + summary = data["summary"] + if summary["legacy_schema"]: print("0 frames analyzed: file uses pre-frame_id schema") + elif summary["frames_analyzed"] == 0 and not data["frames"]: + print("No qualified frames in this screen's analysis.") else: _print_table( ["frame_id", "timestamp", "abs_time", "primary", "notes"], data["frames"], ) + print() + if summary["video_present"]: + print(LEVEL_4_FOOTER) + else: + print(LEVEL_4_PURGED_FOOTER) return if level == "5a": scope = payload["scope"] @@ -527,6 +653,12 @@ def emit_output(payload: dict[str, Any], *, as_json: bool) -> None: print(f"Notes: {computed['notes']}") print() print(json.dumps(data["frame"], indent=2)) + print() + print("Save: sol grab --out PATH") + print( + "Batch: sol grab " + ",,... --out PATH" + ) return if level in {"5b", "5c"}: for item in data["saved"]: @@ -561,55 +693,69 @@ def main() -> None: help="Emit JSON instead of table or plain output.", ) args = setup_cli(parser) - require_solstone() + observe_utils_logger = logging.getLogger("observe.utils") + previous_level = observe_utils_logger.level + should_quiet = ( + observe_utils_logger.getEffectiveLevel() == logging.WARNING + and not args.verbose + and not args.debug + ) + if should_quiet: + observe_utils_logger.setLevel(logging.ERROR) - tokens = list(args.args) - if len(tokens) > 5: - parser.error( - "grab accepts at most 5 positional tokens: day stream segment screen frame-id" - ) - if args.force and not args.out: - parser.error("--force requires --out") - if args.out and len(tokens) != 5: - parser.error("--out requires day stream segment screen and frame-id") - if args.out: - try: - resolve_output_paths(args.out, [1]) - except ValueError as exc: - parser.error(str(exc)) - - list_handlers = { - 0: lambda: list_available_days(), - 1: lambda: list_day_streams(tokens[0]), - 2: lambda: list_stream_segments(tokens[0], tokens[1]), - 3: lambda: list_segment_screens(tokens[0], tokens[1], tokens[2]), - 4: lambda: list_screen_frames(tokens[0], tokens[1], tokens[2], tokens[3]), - } try: - if len(tokens) < 5: - payload = list_handlers[len(tokens)]() - else: - frame_ids = parse_frame_id_token(tokens[4]) - if len(frame_ids) > 1 and not args.out: - parser.error("multiple frame ids require --out") - if args.out: - payload = save_frame_images( - tokens[0], - tokens[1], - tokens[2], - tokens[3], - frame_ids, - args.out, - args.force, - ) - else: - payload = show_frame_metadata( - tokens[0], tokens[1], tokens[2], tokens[3], frame_ids[0] - ) - except (FileNotFoundError, FileExistsError, RuntimeError, ValueError) as exc: - raise SystemExit(str(exc)) from exc + require_solstone() - emit_output(payload, as_json=bool(args.json)) + tokens = list(args.args) + if len(tokens) > 5: + parser.error( + "grab accepts at most 5 positional tokens: day stream segment screen frame-id" + ) + if args.force and not args.out: + parser.error("--force requires --out") + if args.out and len(tokens) != 5: + parser.error("--out requires day stream segment screen and frame-id") + if args.out: + try: + resolve_output_paths(args.out, [1]) + except ValueError as exc: + parser.error(str(exc)) + + list_handlers = { + 0: lambda: list_available_days(), + 1: lambda: list_day_streams(tokens[0]), + 2: lambda: list_stream_segments(tokens[0], tokens[1]), + 3: lambda: list_segment_screens(tokens[0], tokens[1], tokens[2]), + 4: lambda: list_screen_frames(tokens[0], tokens[1], tokens[2], tokens[3]), + } + try: + if len(tokens) < 5: + payload = list_handlers[len(tokens)]() + else: + frame_ids = parse_frame_id_token(tokens[4]) + if len(frame_ids) > 1 and not args.out: + parser.error("multiple frame ids require --out") + if args.out: + payload = save_frame_images( + tokens[0], + tokens[1], + tokens[2], + tokens[3], + frame_ids, + args.out, + args.force, + ) + else: + payload = show_frame_metadata( + tokens[0], tokens[1], tokens[2], tokens[3], frame_ids[0] + ) + except (FileNotFoundError, FileExistsError, RuntimeError, ValueError) as exc: + raise SystemExit(str(exc)) from exc + + emit_output(payload, as_json=bool(args.json)) + finally: + if should_quiet: + observe_utils_logger.setLevel(previous_level) if __name__ == "__main__": diff --git a/tests/fixtures/sol_grab/journal/chronicle/20240104/default/120000_300/screen.jsonl b/tests/fixtures/sol_grab/journal/chronicle/20240104/default/120000_300/screen.jsonl new file mode 100644 index 000000000..ca3027fa5 --- /dev/null +++ b/tests/fixtures/sol_grab/journal/chronicle/20240104/default/120000_300/screen.jsonl @@ -0,0 +1,2 @@ +{"raw": "screen.webm"} +{"frame_id": 7, "timestamp": 1.0, "requests": [{"type": "describe", "model": "gemini-2.5-flash-lite", "duration": 0.4}], "analysis": {"visual_description": "Archived planning board metadata.", "primary": "productivity", "secondary": "none", "overlap": true}} diff --git a/tests/fixtures/sol_grab/journal/chronicle/20240104/default/120000_300/stream.json b/tests/fixtures/sol_grab/journal/chronicle/20240104/default/120000_300/stream.json new file mode 100644 index 000000000..385dfd389 --- /dev/null +++ b/tests/fixtures/sol_grab/journal/chronicle/20240104/default/120000_300/stream.json @@ -0,0 +1 @@ +{"stream": "default", "prev_day": "20240103", "prev_segment": "110000_300", "seq": 4} diff --git a/tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/screen.jsonl b/tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/screen.jsonl new file mode 100644 index 000000000..db9cc82d6 --- /dev/null +++ b/tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/screen.jsonl @@ -0,0 +1 @@ +{"raw": "screen.webm"} diff --git a/tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/screen.webm b/tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/screen.webm new file mode 100644 index 0000000000000000000000000000000000000000..6c0f86268b18dc8d1a03c89e3f7855e5514d70ec GIT binary patch literal 1311 zcmb1gy}x+AQ(GgW)AvS3r%#P6PM;e&oSIt7Q5W*7+S(hEWrtzBZ?w;V)EkYrO(}UMEF-k(XGQqpEgV!{% z+!5Bkz23-Je6%Cu?b^d>cNqW7X=G5^I;XibFQucAp{+G6J}khpxsg%%adQjjd&N}` z+BP&g>}oUs`S^Nq@%-ci4e2Llx-M>a^>g-jaSd{5N3pcHcvJF$?qr1V=_eMtF79v% z4RQ?e^!IaVpKM@Y1wtlz1~9-N@4T(lIqrEQ!{SAa3=9oTp3P1S%paJ(F<7X`NO&n2AXVIgP*pHfjQ{^t1qafS$)G?=bpXXK_YSBkgp>XY`!TT8|I4cX|1SpY9-erx z8bo?D{{N5Df`MoMzh4ZX5aeY=4d~j%=l_5C{r{=>x7*!Edj%L4&u!#ggJ#6yzv~+q z<^TWW0H=7qV5l@aJ%3~XMf-0B`~SxpKz8u`gsOn2!vD7vKnecalmGueg)xANsEND# UH#ahD@A}@z__2wx^g|;v0MNYTcmMzZ literal 0 HcmV?d00001 diff --git a/tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/stream.json b/tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/stream.json new file mode 100644 index 000000000..db0a0441e --- /dev/null +++ b/tests/fixtures/sol_grab/journal/chronicle/20240105/default/130000_300/stream.json @@ -0,0 +1 @@ +{"stream": "default", "prev_day": "20240104", "prev_segment": "120000_300", "seq": 5} diff --git a/tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/screen.jsonl b/tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/screen.jsonl new file mode 100644 index 000000000..40eedb73b --- /dev/null +++ b/tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/screen.jsonl @@ -0,0 +1,4 @@ +{"raw": "screen.webm"} +{"frame_id": 1, "timestamp": 0.0, "requests": [{"type": "describe", "model": "gemini-2.5-flash-lite", "duration": 0.3}], "analysis": {"visual_description": "Diagnostics page with status rows.", "primary": "reading", "secondary": "none", "overlap": true}} +{"frame_id": +{"frame_id": 2, "timestamp": 1.0, "requests": [{"type": "describe", "model": "gemini-2.5-flash-lite", "duration": 0.3}], "analysis": {"visual_description": "Terminal with fixture output.", "primary": "terminal", "secondary": "none", "overlap": true}} diff --git a/tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/screen.webm b/tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/screen.webm new file mode 100644 index 0000000000000000000000000000000000000000..6c0f86268b18dc8d1a03c89e3f7855e5514d70ec GIT binary patch literal 1311 zcmb1gy}x+AQ(GgW)AvS3r%#P6PM;e&oSIt7Q5W*7+S(hEWrtzBZ?w;V)EkYrO(}UMEF-k(XGQqpEgV!{% z+!5Bkz23-Je6%Cu?b^d>cNqW7X=G5^I;XibFQucAp{+G6J}khpxsg%%adQjjd&N}` z+BP&g>}oUs`S^Nq@%-ci4e2Llx-M>a^>g-jaSd{5N3pcHcvJF$?qr1V=_eMtF79v% z4RQ?e^!IaVpKM@Y1wtlz1~9-N@4T(lIqrEQ!{SAa3=9oTp3P1S%paJ(F<7X`NO&n2AXVIgP*pHfjQ{^t1qafS$)G?=bpXXK_YSBkgp>XY`!TT8|I4cX|1SpY9-erx z8bo?D{{N5Df`MoMzh4ZX5aeY=4d~j%=l_5C{r{=>x7*!Edj%L4&u!#ggJ#6yzv~+q z<^TWW0H=7qV5l@aJ%3~XMf-0B`~SxpKz8u`gsOn2!vD7vKnecalmGueg)xANsEND# UH#ahD@A}@z__2wx^g|;v0MNYTcmMzZ literal 0 HcmV?d00001 diff --git a/tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/stream.json b/tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/stream.json new file mode 100644 index 000000000..470c3a3b0 --- /dev/null +++ b/tests/fixtures/sol_grab/journal/chronicle/20240106/default/140000_300/stream.json @@ -0,0 +1 @@ +{"stream": "default", "prev_day": "20240105", "prev_segment": "130000_300", "seq": 6} diff --git a/tests/fixtures/sol_grab/level_0.json b/tests/fixtures/sol_grab/level_0.json index 19149ab45..0905f3f0b 100644 --- a/tests/fixtures/sol_grab/level_0.json +++ b/tests/fixtures/sol_grab/level_0.json @@ -23,6 +23,27 @@ "segments": 2, "screens": 3, "frames_analyzed": 6 + }, + { + "day": "20240104", + "streams": 1, + "segments": 1, + "screens": 1, + "frames_analyzed": 1 + }, + { + "day": "20240105", + "streams": 1, + "segments": 1, + "screens": 1, + "frames_analyzed": 0 + }, + { + "day": "20240106", + "streams": 1, + "segments": 1, + "screens": 1, + "frames_analyzed": 2 } ] } diff --git a/tests/fixtures/sol_grab/level_3_purged.json b/tests/fixtures/sol_grab/level_3_purged.json new file mode 100644 index 000000000..a39defd8a --- /dev/null +++ b/tests/fixtures/sol_grab/level_3_purged.json @@ -0,0 +1,21 @@ +{ + "level": "3", + "scope": { + "day": "20240104", + "stream": "default", + "segment": "120000_300" + }, + "data": { + "screens": [ + { + "screen": "screen", + "position": "unknown", + "connector": "unknown", + "frames_analyzed": 1, + "jsonl": "20240104/default/120000_300/screen.jsonl", + "video": null, + "status": "analyzed; raw media purged by retention" + } + ] + } +} diff --git a/tests/fixtures/sol_grab/level_4.json b/tests/fixtures/sol_grab/level_4.json index 21459a833..033141443 100644 --- a/tests/fixtures/sol_grab/level_4.json +++ b/tests/fixtures/sol_grab/level_4.json @@ -10,7 +10,8 @@ "summary": { "frames_analyzed": 9, "error_frames": 1, - "legacy_schema": false + "legacy_schema": false, + "video_present": true }, "frames": [ { diff --git a/tests/fixtures/sol_grab/level_4_header_only.json b/tests/fixtures/sol_grab/level_4_header_only.json new file mode 100644 index 000000000..9a8e04eed --- /dev/null +++ b/tests/fixtures/sol_grab/level_4_header_only.json @@ -0,0 +1,18 @@ +{ + "level": "4", + "scope": { + "day": "20240105", + "stream": "default", + "segment": "130000_300", + "screen": "screen" + }, + "data": { + "summary": { + "frames_analyzed": 0, + "error_frames": 0, + "legacy_schema": false, + "video_present": true + }, + "frames": [] + } +} diff --git a/tests/fixtures/sol_grab/level_4_purged.json b/tests/fixtures/sol_grab/level_4_purged.json new file mode 100644 index 000000000..fc311515e --- /dev/null +++ b/tests/fixtures/sol_grab/level_4_purged.json @@ -0,0 +1,26 @@ +{ + "level": "4", + "scope": { + "day": "20240104", + "stream": "default", + "segment": "120000_300", + "screen": "screen" + }, + "data": { + "summary": { + "frames_analyzed": 1, + "error_frames": 0, + "legacy_schema": false, + "video_present": false + }, + "frames": [ + { + "frame_id": 7, + "timestamp": 1.0, + "abs_time": "2024-01-04T12:00:01", + "primary": "productivity", + "notes": "" + } + ] + } +} diff --git a/tests/test_grab.py b/tests/test_grab.py index 9c663d5c5..470097865 100644 --- a/tests/test_grab.py +++ b/tests/test_grab.py @@ -70,6 +70,14 @@ def test_grab_level_0_human_lists_days_with_counts(monkeypatch, capsys): assert "20240103" in out +def test_grab_level_0_human_ends_with_next_footer(monkeypatch, capsys): + code, message, out, err = _invoke_grab(monkeypatch, capsys) + assert code == 0 + assert message == "" + assert err == "" + assert out.rstrip().endswith("Next: sol grab ") + + def test_grab_level_1_json_matches_fixture(monkeypatch, capsys): code, message, out, err = _invoke_grab(monkeypatch, capsys, "--json", "20240102") assert code == 0 @@ -78,12 +86,21 @@ def test_grab_level_1_json_matches_fixture(monkeypatch, capsys): assert json.loads(out) == _expected("level_1.json") +def test_grab_level_1_human_ends_with_next_footer(monkeypatch, capsys): + code, message, out, err = _invoke_grab(monkeypatch, capsys, "20240102") + assert code == 0 + assert message == "" + assert err == "" + assert out.rstrip().endswith("Next: sol grab ") + + def test_grab_missing_day_errors(monkeypatch, capsys): code, message, out, err = _invoke_grab(monkeypatch, capsys, "20990101") assert code == 1 assert out == "" assert err == "" - assert message == "day 20990101 not found" + assert message.startswith("day 20990101 not found\n\n") + assert "Available days (closest 5):\n 20240102" in message def test_grab_level_2_json_matches_fixture(monkeypatch, capsys): @@ -96,12 +113,23 @@ def test_grab_level_2_json_matches_fixture(monkeypatch, capsys): assert json.loads(out) == _expected("level_2.json") +def test_grab_level_2_human_ends_with_next_footer(monkeypatch, capsys): + code, message, out, err = _invoke_grab(monkeypatch, capsys, "20240103", "default") + assert code == 0 + assert message == "" + assert err == "" + assert out.rstrip().endswith("Next: sol grab ") + + def test_grab_missing_stream_errors(monkeypatch, capsys): code, message, out, err = _invoke_grab(monkeypatch, capsys, "20240102", "missing") assert code == 1 assert out == "" assert err == "" - assert message == "stream missing not found in 20240102" + assert ( + message + == "stream missing not found in 20240102\n\nAvailable streams in 20240102:\n default" + ) def test_grab_level_3_json_matches_fixture(monkeypatch, capsys): @@ -114,6 +142,37 @@ def test_grab_level_3_json_matches_fixture(monkeypatch, capsys): assert json.loads(out) == _expected("level_3.json") +def test_grab_level_3_purged_json_matches_fixture(monkeypatch, capsys): + code, message, out, err = _invoke_grab( + monkeypatch, capsys, "--json", "20240104", "default", "120000_300" + ) + assert code == 0 + assert message == "" + assert err == "" + assert json.loads(out) == _expected("level_3_purged.json") + + +def test_grab_level_3_pins_all_status_strings(monkeypatch, capsys): + statuses = set() + for args in ( + ("20240102", "default", "233000_300"), + ("20240103", "default", "110000_300"), + ("20240104", "default", "120000_300"), + ): + code, message, out, err = _invoke_grab(monkeypatch, capsys, "--json", *args) + assert code == 0 + assert message == "" + assert err == "" + payload = json.loads(out) + statuses.update(screen["status"] for screen in payload["data"]["screens"]) + + assert statuses == { + "analyzed", + "analyzed; raw media purged by retention", + "captured but not analyzed", + } + + def test_grab_level_3_lists_named_monitors(monkeypatch, capsys): code, message, out, err = _invoke_grab( monkeypatch, capsys, "--json", "20240103", "default", "100000_300" @@ -130,6 +189,16 @@ def test_grab_level_3_lists_named_monitors(monkeypatch, capsys): assert payload["data"]["screens"][1]["connector"] == "HDMI-1" +def test_grab_level_3_human_ends_with_next_footer(monkeypatch, capsys): + code, message, out, err = _invoke_grab( + monkeypatch, capsys, "20240103", "default", "100000_300" + ) + assert code == 0 + assert message == "" + assert err == "" + assert out.rstrip().endswith("Next: sol grab ") + + def test_grab_missing_segment_errors(monkeypatch, capsys): code, message, out, err = _invoke_grab( monkeypatch, capsys, "20240103", "default", "999999_300" @@ -137,7 +206,12 @@ def test_grab_missing_segment_errors(monkeypatch, capsys): assert code == 1 assert out == "" assert err == "" - assert message == "segment 999999_300 not found in 20240103/default" + assert ( + message == "segment 999999_300 not found in 20240103/default\n\n" + "Available segments in 20240103/default:\n" + " 100000_300\n" + " 110000_300" + ) def test_grab_level_4_json_matches_fixture(monkeypatch, capsys): @@ -150,6 +224,26 @@ def test_grab_level_4_json_matches_fixture(monkeypatch, capsys): assert json.loads(out) == _expected("level_4.json") +def test_grab_level_4_purged_json_matches_fixture(monkeypatch, capsys): + code, message, out, err = _invoke_grab( + monkeypatch, capsys, "--json", "20240104", "default", "120000_300", "screen" + ) + assert code == 0 + assert message == "" + assert err == "" + assert json.loads(out) == _expected("level_4_purged.json") + + +def test_grab_level_4_header_only_json_matches_fixture(monkeypatch, capsys): + code, message, out, err = _invoke_grab( + monkeypatch, capsys, "--json", "20240105", "default", "130000_300", "screen" + ) + assert code == 0 + assert message == "" + assert err == "" + assert json.loads(out) == _expected("level_4_header_only.json") + + def test_grab_level_4_human_includes_error_notes(monkeypatch, capsys): code, message, out, err = _invoke_grab( monkeypatch, capsys, "20240102", "default", "233000_300", "screen" @@ -161,6 +255,27 @@ def test_grab_level_4_human_includes_error_notes(monkeypatch, capsys): assert "error: Vision request timed out while describing frame 18." in out +def test_grab_level_4_human_includes_extraction_footer(monkeypatch, capsys): + code, message, out, err = _invoke_grab( + monkeypatch, capsys, "20240102", "default", "233000_300", "screen" + ) + assert code == 0 + assert message == "" + assert err == "" + assert out.rstrip().endswith( + "Inspect: sol grab \n" + "Save one: sol grab --out PATH\n" + "Save many: sol grab " + ",,... --out PATH\n" + "\n" + "How extraction works:\n" + " Decoding walks the video linearly from frame 0 — seeking is unsafe at the\n" + " 1 Hz capture rate. Cost is dominated by the highest requested frame_id, not\n" + " the count. Asking for ids 7,12,23 costs the same as asking for 23 alone.\n" + " Prefer batch mode when you want more than one frame from the same screen." + ) + + def test_grab_level_4_legacy_schema_reports_zero_frames(monkeypatch, capsys): code, message, out, err = _invoke_grab( monkeypatch, capsys, "20240101", "default", "123456_300", "screen" @@ -171,6 +286,53 @@ def test_grab_level_4_legacy_schema_reports_zero_frames(monkeypatch, capsys): assert out.strip() == "0 frames analyzed: file uses pre-frame_id schema" +def test_grab_level_4_header_only_reports_no_qualified_frames(monkeypatch, capsys): + code, message, out, err = _invoke_grab( + monkeypatch, capsys, "20240105", "default", "130000_300", "screen" + ) + assert code == 0 + assert message == "" + assert err == "" + assert out == "No qualified frames in this screen's analysis.\n" + + +def test_grab_level_4_legacy_and_header_only_are_distinct(monkeypatch, capsys): + code, message, out, err = _invoke_grab( + monkeypatch, capsys, "--json", "20240101", "default", "123456_300", "screen" + ) + assert code == 0 + assert message == "" + assert err == "" + assert json.loads(out)["data"]["summary"]["legacy_schema"] is True + + code, message, out, err = _invoke_grab( + monkeypatch, capsys, "--json", "20240105", "default", "130000_300", "screen" + ) + assert code == 0 + assert message == "" + assert err == "" + payload = json.loads(out) + assert payload["data"]["summary"]["frames_analyzed"] == 0 + assert payload["data"]["frames"] == [] + assert payload["data"]["summary"]["legacy_schema"] is False + + +def test_grab_level_4_purged_human_uses_metadata_only_footer(monkeypatch, capsys): + code, message, out, err = _invoke_grab( + monkeypatch, capsys, "20240104", "default", "120000_300", "screen" + ) + assert code == 0 + assert message == "" + assert err == "" + assert "--out PATH" not in out + assert out.rstrip().endswith( + "Save mode unavailable: raw video has been purged by retention.\n" + "Frame metadata above is still readable.\n" + "\n" + "Inspect: sol grab " + ) + + def test_grab_level_4_captured_but_not_analyzed_errors(monkeypatch, capsys): code, message, out, err = _invoke_grab( monkeypatch, capsys, "20240103", "default", "110000_300", "screen" @@ -188,7 +350,11 @@ def test_grab_missing_screen_errors(monkeypatch, capsys): assert code == 1 assert out == "" assert err == "" - assert message == "screen missing_screen not found in 20240102/default/233000_300" + assert ( + message == "screen missing_screen not found in 20240102/default/233000_300\n\n" + "Available screens in 20240102/default/233000_300:\n" + " screen" + ) def test_grab_level_5a_json_matches_fixture(monkeypatch, capsys): @@ -219,6 +385,30 @@ def test_grab_level_5a_human_shows_frame_metadata(monkeypatch, capsys): assert '"frame_id": 7' in out +def test_grab_level_5a_human_shows_save_and_batch_footer(monkeypatch, capsys): + code, message, out, err = _invoke_grab( + monkeypatch, capsys, "20240102", "default", "233000_300", "screen", "7" + ) + assert code == 0 + assert message == "" + assert err == "" + assert out.rstrip().endswith( + "Save: sol grab --out PATH\n" + "Batch: sol grab " + ",,... --out PATH" + ) + + +def test_grab_json_outputs_do_not_include_footer_prose(monkeypatch, capsys): + code, message, out, err = _invoke_grab(monkeypatch, capsys, "--json") + assert code == 0 + assert message == "" + assert err == "" + assert "Next:" not in out + assert "Save:" not in out + assert "How extraction works:" not in out + + def test_grab_level_5a_legacy_schema_errors(monkeypatch, capsys): code, message, out, err = _invoke_grab( monkeypatch, capsys, "20240101", "default", "123456_300", "screen", "1" @@ -242,6 +432,27 @@ def test_grab_missing_frame_id_errors(monkeypatch, capsys): assert message == "frame id 999 not found in screen for 233000_300" +def test_grab_save_purged_video_reports_retention_message( + monkeypatch, capsys, tmp_path +): + code, message, out, err = _invoke_grab( + monkeypatch, + capsys, + "--out", + str(tmp_path / "frame.png"), + "20240104", + "default", + "120000_300", + "screen", + "7", + ) + assert code == 1 + assert out == "" + assert err == "" + assert "purged by retention" in message + assert "sol grab 20240104 default 120000_300 screen 7" in message + + def test_grab_level_5b_json_matches_fixture_and_writes_png( monkeypatch, capsys, tmp_path ): @@ -445,6 +656,23 @@ def test_grab_out_requires_level_5(monkeypatch, capsys, tmp_path): assert "--out requires day stream segment screen and frame-id" in err +def test_grab_malformed_jsonl_quiet_by_default(monkeypatch, capsys, caplog): + code, message, out, err = _invoke_grab(monkeypatch, capsys) + assert code == 0 + assert message == "" + assert "20240106" in out + assert "WARNING:observe.utils:" not in err + assert "Invalid JSON" not in caplog.text + + +def test_grab_malformed_jsonl_warns_with_verbose(monkeypatch, capsys, caplog): + code, message, out, err = _invoke_grab(monkeypatch, capsys, "-v") + assert code == 0 + assert message == "" + assert "20240106" in out + assert "Invalid JSON" in caplog.text + + @pytest.mark.parametrize("token", ["0", "-1", "abc", "7,7", "1,,2"]) def test_grab_frame_id_token_rejects_invalid_values(token): from observe.grab import parse_frame_id_token