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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ That's it. Run `assembly onboard` for a guided tour, or see [Installation](#-ins
| `assembly agent` | Full-duplex spoken conversation with a voice agent, right in your terminal |
| `assembly agent-cascade` | Same live conversation, but wired client-side from Streaming STT + the LLM Gateway + streaming TTS, like the `agent-cascade` starter (sandbox-only) |
| `assembly speak` | Synthesize text to speech over the streaming-TTS WebSocket (sandbox-only) |
| `assembly llm` | Prompt the LLM Gateway over a transcript, stdin, or a live stream |
| `assembly llm` | Prompt the LLM Gateway over a transcript, files, stdin, or a live stream |
| `assembly clip` | Cut audio/video with ffmpeg by diarized speaker, text match, LLM pick, or time range (`--video` keeps the picture for URL sources) — clip boundaries snap into nearby silence |
| `assembly dub` | Re-voice an audio/video file or URL in another language: transcription, LLM translation, per-speaker TTS, ffmpeg track-swap (sandbox-only) |
| `assembly caption` | Burn always-visible captions into a video: transcribe (or reuse a transcript), fetch SRT, ffmpeg burns it in — audio untouched |
Expand Down Expand Up @@ -300,6 +300,12 @@ 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):

```sh
assembly llm "answer using only these notes: who owns the deploy?" notes/*.md
```

## 📚 Documentation

### In the terminal
Expand Down
12 changes: 12 additions & 0 deletions aai_cli/commands/llm/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from pathlib import Path

import typer

from aai_cli import command_registry, help_panels, options
Expand Down Expand Up @@ -41,6 +43,10 @@ def _list_models(output_field: choices.TextOrJson | None, json_mode: bool) -> No
'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',
),
(
"Pick a model and add a system prompt",
'assembly llm "draft a follow-up email" --model claude-opus-4-7 --system "Be concise."',
Expand All @@ -52,6 +58,11 @@ def _list_models(output_field: choices.TextOrJson | None, json_mode: bool) -> No
def llm(
ctx: typer.Context,
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)",
),
# Note: text piped on stdin is injected into the prompt (e.g. `cat notes | assembly llm "summarize"`).
model: str = typer.Option(
gateway.DEFAULT_MODEL,
Expand Down Expand Up @@ -103,6 +114,7 @@ def llm(

opts = llm_exec.LlmOptions(
prompt=prompt,
files=tuple(files or ()),
model=model,
transcript_id=transcript_id,
system=system,
Expand Down
94 changes: 74 additions & 20 deletions aai_cli/commands/llm/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

from rich.markup import escape

Expand Down Expand Up @@ -44,10 +45,15 @@ class LlmOptions:
max_tokens: int
# Raw --config KEY=VALUE pairs; parsed (and validated) once in run_llm.
config_kv: tuple[str, ...] = ()
# Input files read as the prompt's context (header-prefixed, concatenated).
files: tuple[Path, ...] = ()


def _validate_follow_args(
prompt: str | None, output_field: str | None, transcript_id: str | None
prompt: str | None,
output_field: str | None,
transcript_id: str | None,
files: tuple[Path, ...],
) -> str:
"""Reject flag combinations that don't apply to --follow's live-panel mode.

Expand All @@ -65,36 +71,84 @@ def _validate_follow_args(
"--follow runs over live transcript text piped on stdin; it can't be "
"combined with --transcript-id."
)
if files:
raise UsageError(
"--follow runs over live transcript text piped on stdin; it can't be "
"combined with file arguments."
)
if not stdio.stdin_is_piped():
raise UsageError(_FOLLOW_STDIN_MESSAGE)
return prompt


def _stdin_transcript_text(
state: AppState, transcript_id: str | None, *, json_mode: bool
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.
"""
sections: list[str] = []
for path in files:
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise UsageError(
f"Couldn't read {path}: {exc.strerror or exc}.",
suggestion="Check the path points at a readable file.",
) from exc
sections.append(f"===== {path.stem} =====\n{text}")
return "\n\n".join(sections)


def _input_text(
state: AppState, transcript_id: str | None, files: tuple[Path, ...], *, json_mode: bool
) -> str | None:
"""Resolve the inline transcript text for one-shot mode.
"""Resolve the inline text the prompt operates on for one-shot mode.

Text piped on stdin becomes the content the prompt operates on, unless an
explicit --transcript-id is given — that injects server-side and takes
priority, so piped text is ignored with a visible warning (suppressed by
--quiet, structured under --json).
Three possible sources, in priority order: an explicit --transcript-id (injected
server-side, so this returns None), one or more file arguments (read and
concatenated), or text piped on stdin. A higher-priority source present alongside
a lower one ignores the lower with a visible warning (suppressed by --quiet,
structured under --json).
"""
if transcript_id is None:
return stdio.piped_stdin_text()
# Same cheap local id check as `transcripts get`, before auth or network.
client.validate_transcript_id(transcript_id)
if stdio.stdin_is_piped() and not state.quiet:
output.emit_warning(
"Ignoring piped stdin; --transcript-id takes priority.", json_mode=json_mode
)
return None
if transcript_id is not None:
# Same cheap local id check as `transcripts get`, before auth or network.
client.validate_transcript_id(transcript_id)
ignored = _ignored_sources(files, stdio.stdin_is_piped())
if ignored and not state.quiet:
output.emit_warning(
f"Ignoring {ignored}; --transcript-id takes priority.", json_mode=json_mode
)
return None
if files:
if stdio.stdin_is_piped() and not state.quiet:
output.emit_warning(
"Ignoring piped stdin; file arguments take priority.", json_mode=json_mode
)
return _read_files(files)
return stdio.piped_stdin_text()


def _ignored_sources(files: tuple[Path, ...], stdin_piped: bool) -> str | None:
"""Name the lower-priority input sources present alongside --transcript-id, for the
warning — or None when there's nothing to ignore."""
sources: list[str] = []
if files:
sources.append("file arguments")
if stdin_piped:
sources.append("piped stdin")
return " and ".join(sources) or None


def _run_follow(
opts: LlmOptions, state: AppState, extra: dict[str, object], *, json_mode: bool
) -> None:
prompt_text = _validate_follow_args(opts.prompt, opts.output_field, opts.transcript_id)
prompt_text = _validate_follow_args(
opts.prompt, opts.output_field, opts.transcript_id, opts.files
)
api_key = state.resolve_api_key()

def ask(transcript_text: str) -> str:
Expand Down Expand Up @@ -131,13 +185,13 @@ def _run_oneshot(
suggestion="Or pass --list-models to see available models.",
)
prompt_text = opts.prompt
stdin_text = _stdin_transcript_text(state, opts.transcript_id, json_mode=json_mode)
input_text = _input_text(state, opts.transcript_id, opts.files, json_mode=json_mode)
api_key = state.resolve_api_key()
messages = gateway.build_messages(
prompt_text,
system=opts.system,
transcript_id=opts.transcript_id,
transcript_text=stdin_text,
transcript_text=input_text,
)
response = gateway.complete(
api_key,
Expand Down
10 changes: 8 additions & 2 deletions tests/__snapshots__/test_snapshots_help_run.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@
# name: test_command_help_matches_snapshot[llm]
'''

Usage: assembly llm [OPTIONS] [PROMPT]
Usage: assembly llm [OPTIONS] [PROMPT] [FILES]...

Send a prompt to AssemblyAI's LLM Gateway and print the reply

Expand All @@ -579,7 +579,10 @@
--transcript-id ID).

╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ prompt [PROMPT] The prompt to send to the model │
│ 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) │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --model TEXT LLM Gateway model │
Expand Down Expand Up @@ -619,6 +622,9 @@
$ 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
Pick a model and add a system prompt
$ assembly llm "draft a follow-up email" --model claude-opus-4-7 --system "Be
concise."
Expand Down
Loading
Loading