diff --git a/README.md b/README.md index 7b51f88b..2bd2bd52 100644 --- a/README.md +++ b/README.md @@ -321,10 +321,11 @@ ffmpeg -i talk.mp4 -f wav - | assembly transcribe - git log --oneline -30 | assembly llm "write release notes grouped by feature/fix" ``` -Pass files straight to `llm` instead of building the pipeline yourself — each is read, prefixed with a `===== name =====` header, and concatenated as the prompt's context (so the answer can cite which note it came from): +Pass files — or a whole directory — straight to `llm` instead of building the pipeline yourself — each is read, prefixed with a `===== name =====` header, and concatenated as the prompt's context (so the answer can cite which note it came from). A directory argument recurses for its `.md`/`.txt` files: ```sh assembly llm "answer using only these notes: who owns the deploy?" notes/*.md +assembly llm "summarize the key decisions" transcripts/ ``` ## 📚 Documentation diff --git a/aai_cli/commands/llm/__init__.py b/aai_cli/commands/llm/__init__.py index 7652119f..84f25265 100644 --- a/aai_cli/commands/llm/__init__.py +++ b/aai_cli/commands/llm/__init__.py @@ -44,8 +44,8 @@ def _list_models(output_field: choices.TextOrJson | None, json_mode: bool) -> No ), ("Pipe any text in", 'echo "meeting notes" | assembly llm "turn into action items"'), ( - "Read one or more files as context", - 'assembly llm "answer using only these notes: who owns the deploy?" notes/*.md', + "Read files or a whole directory as context", + 'assembly llm "summarize the key decisions" transcripts/', ), ( "Pick a model and add a system prompt", @@ -60,8 +60,9 @@ def llm( prompt: str | None = typer.Argument(None, help="The prompt to send to the model"), files: list[Path] | None = typer.Argument( None, - help="Optional input files to read as the prompt's context (each is header-prefixed " - "with its name and concatenated; takes priority over piped stdin)", + help="Optional input files or directories to read as the prompt's context " + "(a directory recurses for .md/.txt files; each is header-prefixed with its " + "name and concatenated; takes priority over piped stdin)", ), # Note: text piped on stdin is injected into the prompt (e.g. `cat notes | assembly llm "summarize"`). model: str = typer.Option( diff --git a/aai_cli/commands/llm/_exec.py b/aai_cli/commands/llm/_exec.py index cb067cf5..9a2aa680 100644 --- a/aai_cli/commands/llm/_exec.py +++ b/aai_cli/commands/llm/_exec.py @@ -27,6 +27,9 @@ '`assembly stream -o text | assembly llm -f "summarize action items as I talk"`.' ) +# Suffixes a directory argument recurses for (matched case-insensitively). +_DIR_SUFFIXES = (".md", ".txt") + @dataclass(frozen=True) class LlmOptions: @@ -82,17 +85,46 @@ def _validate_follow_args( return prompt +def _expand_paths(files: tuple[Path, ...]) -> tuple[Path, ...]: + """Expand any directory argument into the ``.md``/``.txt`` files it holds. + + A directory recurses (depth-first via ``rglob``, path-sorted so the header + order is deterministic) for files whose suffix is in ``_DIR_SUFFIXES``; a plain + file passes through unchanged. A directory holding no matching files is a usage + error — the empty-check the justfile's ``transcripts/**/*.md(.N)`` glob used to + carry, now that the CLI can. This lets `assembly llm "…" transcripts/` stand in + for the glob-and-guard the caller would otherwise hand-roll. + """ + expanded: list[Path] = [] + for path in files: + if not path.is_dir(): + expanded.append(path) + continue + matches = sorted( + p for p in path.rglob("*") if p.is_file() and p.suffix.lower() in _DIR_SUFFIXES + ) + if not matches: + raise UsageError( + f"No {' or '.join(_DIR_SUFFIXES)} files found in {path}.", + suggestion="Point at a directory containing .md or .txt notes, " + "or pass files directly.", + ) + expanded.extend(matches) + return tuple(expanded) + + def _read_files(files: tuple[Path, ...]) -> str: """Read each file and join them, each prefixed with a ``===== name =====`` header. - The header names each source (the file's stem) so a multi-file prompt can cite - which note an answer came from; it's applied uniformly, even for a single file, - so the format the model sees is predictable. A missing or unreadable path is a - usage error raised before any auth or network — the same fail-fast ordering as - the --transcript-id check. + A directory argument is first expanded into its ``.md``/``.txt`` files + (see ``_expand_paths``). The header names each source (the file's stem) so a + multi-file prompt can cite which note an answer came from; it's applied + uniformly, even for a single file, so the format the model sees is predictable. + A missing or unreadable path is a usage error raised before any auth or + network — the same fail-fast ordering as the --transcript-id check. """ sections: list[str] = [] - for path in files: + for path in _expand_paths(files): try: text = path.read_text(encoding="utf-8") except OSError as exc: diff --git a/tests/__snapshots__/test_snapshots_help_run.ambr b/tests/__snapshots__/test_snapshots_help_run.ambr index 84a1ddf0..002dde60 100644 --- a/tests/__snapshots__/test_snapshots_help_run.ambr +++ b/tests/__snapshots__/test_snapshots_help_run.ambr @@ -592,9 +592,11 @@ ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ prompt [PROMPT] The prompt to send to the model │ - │ files [FILES]... Optional input files to read as the prompt's │ - │ context (each is header-prefixed with its name and │ - │ concatenated; takes priority over piped stdin) │ + │ files [FILES]... Optional input files or directories to read as the │ + │ prompt's context (a directory recurses for │ + │ .md/.txt files; each is header-prefixed with its │ + │ name and concatenated; takes priority over piped │ + │ stdin) │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --model TEXT LLM Gateway model │ @@ -634,9 +636,8 @@ $ assembly llm "summarize the key decisions" --transcript-id 5551234-abcd Pipe any text in $ echo "meeting notes" | assembly llm "turn into action items" - Read one or more files as context - $ assembly llm "answer using only these notes: who owns the deploy?" - notes/*.md + Read files or a whole directory as context + $ assembly llm "summarize the key decisions" transcripts/ Pick a model and add a system prompt $ assembly llm "draft a follow-up email" --model claude-opus-4-7 --system "Be concise." diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index fdfea7ba..c9f0032a 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -128,130 +128,6 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, e assert seen["transcript_id"] is None -def test_llm_reads_file_argument_as_context(monkeypatch, tmp_path): - _auth() - seen = {} - - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): - seen["content"] = messages[0]["content"] - seen["transcript_id"] = transcript_id - return _payload("done") - - monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) - note = tmp_path / "alpha.md" - note.write_text("bob owns the deploy") - result = runner.invoke(app, ["llm", "who owns the deploy?", str(note), "--json"]) - assert result.exit_code == 0 - # The file content is injected, under a header naming the file's stem. - assert "who owns the deploy?" in seen["content"] - assert "bob owns the deploy" in seen["content"] - assert "===== alpha =====" in seen["content"] - assert seen["transcript_id"] is None - - -def test_llm_concatenates_multiple_files_with_headers_in_order(monkeypatch, tmp_path): - _auth() - seen = {} - - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): - seen["content"] = messages[0]["content"] - return _payload("done") - - monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) - first = tmp_path / "first.md" - first.write_text("ship friday") - second = tmp_path / "second.md" - second.write_text("freeze monday") - result = runner.invoke(app, ["llm", "summarize", str(first), str(second), "--json"]) - assert result.exit_code == 0 - content = seen["content"] - assert "===== first =====" in content - assert "===== second =====" in content - assert "ship friday" in content - assert "freeze monday" in content - # Both note bodies appear under their own header, in the order passed. - assert content.index("===== first =====") < content.index("===== second =====") - assert content.index("ship friday") < content.index("freeze monday") - - -def test_llm_files_take_priority_over_stdin(monkeypatch, tmp_path): - _auth() - seen = {} - - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): - seen["content"] = messages[0]["content"] - return _payload("done") - - monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) - note = tmp_path / "note.md" - note.write_text("from the file") - result = runner.invoke( - app, ["llm", "summarize", str(note)], input="from stdin, should be ignored" - ) - assert result.exit_code == 0 - assert "from the file" in seen["content"] - assert "from stdin, should be ignored" not in seen["content"] - assert "Ignoring piped stdin; file arguments take priority." in result.output - - -def test_llm_missing_file_exits_2_without_network(monkeypatch, tmp_path): - # A bad path (e.g. an unmatched shell glob passed through literally) is a usage - # error raised before auth or the gateway, not a crash. - _auth() - monkeypatch.setattr( - "aai_cli.commands.llm.gateway.complete", - lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not call the gateway")), - ) - missing = tmp_path / "nope.md" - result = runner.invoke(app, ["llm", "summarize", str(missing)]) - assert result.exit_code == 2 - assert "Couldn't read" in result.output - # The clean OS reason (errno's strerror) is shown, not the raw exception repr — - # so no "[Errno N] …: '/path'" bracket leaks into the message. - assert "[Errno" not in result.output - - -def test_llm_files_with_terminal_stdin_emits_no_warning(monkeypatch, tmp_path): - # With files given and stdin a terminal (not piped), there's nothing being - # ignored, so the "Ignoring piped stdin" warning must not fire. - _auth() - monkeypatch.setattr("aai_cli.commands.llm._exec.stdio.stdin_is_piped", lambda: False) - seen = {} - - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): - seen["content"] = messages[0]["content"] - return _payload("done") - - monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) - note = tmp_path / "note.md" - note.write_text("only the file") - result = runner.invoke(app, ["llm", "summarize", str(note)]) - assert result.exit_code == 0 - assert "only the file" in seen["content"] - assert "Ignoring piped stdin" not in result.output - - -def test_llm_transcript_id_takes_priority_over_files(monkeypatch, tmp_path): - _auth() - seen = {} - # Pin stdin to a terminal so only the file argument is the ignored source. - monkeypatch.setattr("aai_cli.commands.llm._exec.stdio.stdin_is_piped", lambda: False) - - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): - seen["content"] = messages[0]["content"] - seen["transcript_id"] = transcript_id - return _payload("s") - - monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) - note = tmp_path / "note.md" - note.write_text("file content here") - result = runner.invoke(app, ["llm", "summarize", str(note), "--transcript-id", "t_9"]) - assert result.exit_code == 0 - assert seen["transcript_id"] == "t_9" - assert "file content here" not in seen["content"] - assert "Ignoring file arguments; --transcript-id takes priority." in result.output - - def test_llm_transcript_id_takes_priority_over_stdin(monkeypatch): _auth() seen = {} diff --git a/tests/test_llm_files.py b/tests/test_llm_files.py new file mode 100644 index 00000000..0dc572b3 --- /dev/null +++ b/tests/test_llm_files.py @@ -0,0 +1,238 @@ +"""`assembly llm` file- and directory-context tests. + +Split out of ``test_llm_command.py`` (which would otherwise exceed the 500-line +gate) — the prompt's file arguments, directory recursion, and the input-source +priority warnings all live here. +""" + +import types + +from typer.testing import CliRunner + +from aai_cli.core import config +from aai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_api_key("default", "sk_live") + + +def _payload(content="four"): + # Mimics the OpenAI SDK response object the command reads via content_of/usage_of. + message = types.SimpleNamespace(role="assistant", content=content) + choice = types.SimpleNamespace(message=message, finish_reason="stop") + usage = types.SimpleNamespace(model_dump=lambda: {"total_tokens": 3}) + return types.SimpleNamespace(choices=[choice], usage=usage) + + +def test_llm_reads_file_argument_as_context(monkeypatch, tmp_path): + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): + seen["content"] = messages[0]["content"] + seen["transcript_id"] = transcript_id + return _payload("done") + + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) + note = tmp_path / "alpha.md" + note.write_text("bob owns the deploy") + result = runner.invoke(app, ["llm", "who owns the deploy?", str(note), "--json"]) + assert result.exit_code == 0 + # The file content is injected, under a header naming the file's stem. + assert "who owns the deploy?" in seen["content"] + assert "bob owns the deploy" in seen["content"] + assert "===== alpha =====" in seen["content"] + assert seen["transcript_id"] is None + + +def test_llm_concatenates_multiple_files_with_headers_in_order(monkeypatch, tmp_path): + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): + seen["content"] = messages[0]["content"] + return _payload("done") + + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) + first = tmp_path / "first.md" + first.write_text("ship friday") + second = tmp_path / "second.md" + second.write_text("freeze monday") + result = runner.invoke(app, ["llm", "summarize", str(first), str(second), "--json"]) + assert result.exit_code == 0 + content = seen["content"] + assert "===== first =====" in content + assert "===== second =====" in content + assert "ship friday" in content + assert "freeze monday" in content + # Both note bodies appear under their own header, in the order passed. + assert content.index("===== first =====") < content.index("===== second =====") + assert content.index("ship friday") < content.index("freeze monday") + + +def test_llm_files_take_priority_over_stdin(monkeypatch, tmp_path): + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): + seen["content"] = messages[0]["content"] + return _payload("done") + + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) + note = tmp_path / "note.md" + note.write_text("from the file") + result = runner.invoke( + app, ["llm", "summarize", str(note)], input="from stdin, should be ignored" + ) + assert result.exit_code == 0 + assert "from the file" in seen["content"] + assert "from stdin, should be ignored" not in seen["content"] + assert "Ignoring piped stdin; file arguments take priority." in result.output + + +def test_llm_missing_file_exits_2_without_network(monkeypatch, tmp_path): + # A bad path (e.g. an unmatched shell glob passed through literally) is a usage + # error raised before auth or the gateway, not a crash. + _auth() + monkeypatch.setattr( + "aai_cli.commands.llm.gateway.complete", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not call the gateway")), + ) + missing = tmp_path / "nope.md" + result = runner.invoke(app, ["llm", "summarize", str(missing)]) + assert result.exit_code == 2 + assert "Couldn't read" in result.output + # The clean OS reason (errno's strerror) is shown, not the raw exception repr — + # so no "[Errno N] …: '/path'" bracket leaks into the message. + assert "[Errno" not in result.output + + +def test_llm_directory_argument_recurses_for_md_and_txt(monkeypatch, tmp_path): + # A directory recurses for .md/.txt files (including nested ones), reads each + # under its own header in a deterministic (path-sorted) order, and skips + # non-matching extensions. This is the glob-and-guard the justfile hand-rolled. + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): + seen["content"] = messages[0]["content"] + return _payload("done") + + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) + (tmp_path / "aaa.md").write_text("ship friday") + (tmp_path / "bbb.txt").write_text("freeze monday") + (tmp_path / "ignore.json").write_text("not included") + nested = tmp_path / "sub" + nested.mkdir() + (nested / "ccc.md").write_text("nested note") + + result = runner.invoke(app, ["llm", "summarize", str(tmp_path), "--json"]) + assert result.exit_code == 0 + content = seen["content"] + # Both top-level matches plus the nested one are present... + assert "ship friday" in content + assert "freeze monday" in content + assert "nested note" in content + assert "===== aaa =====" in content + assert "===== bbb =====" in content + assert "===== ccc =====" in content + # ...the .json file is not... + assert "not included" not in content + assert "ignore" not in content + # ...and the order is path-sorted, not filesystem-arbitrary. + assert content.index("ship friday") < content.index("freeze monday") + assert content.index("freeze monday") < content.index("nested note") + + +def test_llm_directory_match_is_case_insensitive(monkeypatch, tmp_path): + # An uppercase .MD/.TXT suffix still recurses (the membership test lowercases). + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): + seen["content"] = messages[0]["content"] + return _payload("done") + + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) + (tmp_path / "shout.MD").write_text("loud note") + result = runner.invoke(app, ["llm", "summarize", str(tmp_path), "--json"]) + assert result.exit_code == 0 + assert "loud note" in seen["content"] + + +def test_llm_directory_skips_subdir_named_like_a_match(monkeypatch, tmp_path): + # rglob also yields directories; a folder literally named notes.md must not be + # read as a file (the is_file guard), and a real match alongside it still loads. + _auth() + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): + seen["content"] = messages[0]["content"] + return _payload("done") + + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) + (tmp_path / "trap.md").mkdir() # a directory whose name ends in .md + (tmp_path / "real.md").write_text("real content") + result = runner.invoke(app, ["llm", "summarize", str(tmp_path), "--json"]) + assert result.exit_code == 0 + assert "real content" in seen["content"] + + +def test_llm_empty_directory_exits_2_without_network(monkeypatch, tmp_path): + # A directory with no .md/.txt files is a usage error before auth/network — + # the empty-guard the justfile used to carry for the CLI. + _auth() + monkeypatch.setattr( + "aai_cli.commands.llm.gateway.complete", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not call the gateway")), + ) + empty = tmp_path / "notes" + empty.mkdir() + (empty / "data.json").write_text("not a note") # present but non-matching + result = runner.invoke(app, ["llm", "summarize", str(empty)]) + assert result.exit_code == 2 + assert "No .md or .txt files found" in result.output + + +def test_llm_files_with_terminal_stdin_emits_no_warning(monkeypatch, tmp_path): + # With files given and stdin a terminal (not piped), there's nothing being + # ignored, so the "Ignoring piped stdin" warning must not fire. + _auth() + monkeypatch.setattr("aai_cli.commands.llm._exec.stdio.stdin_is_piped", lambda: False) + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): + seen["content"] = messages[0]["content"] + return _payload("done") + + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) + note = tmp_path / "note.md" + note.write_text("only the file") + result = runner.invoke(app, ["llm", "summarize", str(note)]) + assert result.exit_code == 0 + assert "only the file" in seen["content"] + assert "Ignoring piped stdin" not in result.output + + +def test_llm_transcript_id_takes_priority_over_files(monkeypatch, tmp_path): + _auth() + seen = {} + # Pin stdin to a terminal so only the file argument is the ignored source. + monkeypatch.setattr("aai_cli.commands.llm._exec.stdio.stdin_is_piped", lambda: False) + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): + seen["content"] = messages[0]["content"] + seen["transcript_id"] = transcript_id + return _payload("s") + + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) + note = tmp_path / "note.md" + note.write_text("file content here") + result = runner.invoke(app, ["llm", "summarize", str(note), "--transcript-id", "t_9"]) + assert result.exit_code == 0 + assert seen["transcript_id"] == "t_9" + assert "file content here" not in seen["content"] + assert "Ignoring file arguments; --transcript-id takes priority." in result.output