diff --git a/CHANGELOG.md b/CHANGELOG.md index 88256dd..286df02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +## [1.1.0] - 2026-07-08 + +### Added + +- **conversations**: new command group exposing the recorded-conversation endpoints (Zoom/Teams/Meet). `conversations search [--query]` lists conversations; `conversations get ID` returns the full detail including transcript and AI call summary (outcome, pain points, objections, next steps). The underlying `qodev-apollo-api` client already supported `search_conversations`/`get_conversation` — only the CLI surface was missing. Note this is distinct from `calls`, which covers dialer/phone-call activity, not recorded meetings. + ## [1.0.0] - 2026-07-01 ### Added diff --git a/README.md b/README.md index a751d72..bc8bd5f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ $ qodev-apollo-cli usage | **tasks** | `search` | Search tasks (`--type`, `--status`) | | | `create` | Create a task (`--contact-ids`, `--note`, `--due-at`) | | **calls** | `search` | Search call activities | +| **conversations** | `search` | Search recorded conversations (`--query`) | +| | `get` | Get a conversation with transcript and AI summary | | **emails** | `search` | Search email activities | | **news** | `search` | Search news (`--categories`) | | **jobs** | `search` | Search job postings (`--job-titles`, `--company-domains`) | diff --git a/pyproject.toml b/pyproject.toml index b901f8f..d712cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "qodev-apollo-cli" -version = "1.0.0" +version = "1.1.0" description = "Agent-friendly CLI for the Apollo API" readme = "README.md" requires-python = ">=3.11" diff --git a/src/apollo_cli/app.py b/src/apollo_cli/app.py index d2a4ec1..6feaae1 100644 --- a/src/apollo_cli/app.py +++ b/src/apollo_cli/app.py @@ -26,6 +26,7 @@ from apollo_cli.commands.accounts import accounts_app # noqa: E402 from apollo_cli.commands.calls import calls_app # noqa: E402 from apollo_cli.commands.contacts import contacts_app # noqa: E402 +from apollo_cli.commands.conversations import conversations_app # noqa: E402 from apollo_cli.commands.deals import deals_app # noqa: E402 from apollo_cli.commands.emails import emails_app # noqa: E402 from apollo_cli.commands.enrich import enrich_app # noqa: E402 @@ -50,6 +51,7 @@ notes_app, tasks_app, calls_app, + conversations_app, emails_app, news_app, jobs_app, diff --git a/src/apollo_cli/commands/conversations.py b/src/apollo_cli/commands/conversations.py new file mode 100644 index 0000000..d1b14e9 --- /dev/null +++ b/src/apollo_cli/commands/conversations.py @@ -0,0 +1,48 @@ +"""Conversations command group.""" + +from __future__ import annotations + +from typing import Annotated + +from cyclopts import App, Parameter + +from apollo_cli.context import ctx +from apollo_cli.formatters.conversations import format_conversation_detail, format_conversation_list +from apollo_cli.output import output, output_list + +conversations_app = App(name="conversations", help="Recorded conversations (Zoom/Teams/Meet).") + + +@conversations_app.command +async def search( + *, + query: Annotated[str, Parameter(name=["--query", "-q"], help="Search keyword (topic/title)")] = "", +) -> None: + """Search recorded conversations.""" + filters: dict = {} + if query: + filters["q_keywords"] = query + + async with ctx.client() as client: + result = await client.search_conversations(page=ctx.page, limit=ctx.limit, **filters) + + output_list( + items=result.items, + total=result.total, + page=result.page, + limit=ctx.limit, + ctx=ctx, + format_fn=format_conversation_list, + resource_name="Conversations", + ) + + +@conversations_app.command +async def get( + id: Annotated[str, Parameter(help="Conversation ID")], +) -> None: + """Get conversation details by ID (includes transcript and AI summary).""" + async with ctx.client() as client: + conversation = await client.get_conversation(id) + + output(conversation, ctx=ctx, format_fn=format_conversation_detail) diff --git a/src/apollo_cli/formatters/conversations.py b/src/apollo_cli/formatters/conversations.py new file mode 100644 index 0000000..f0d00e7 --- /dev/null +++ b/src/apollo_cli/formatters/conversations.py @@ -0,0 +1,109 @@ +"""Conversation-specific formatters.""" + +from __future__ import annotations + +from typing import Any + +from apollo_cli.formatters.generic import detail_table, list_table + +CONVERSATION_LIST_COLUMNS = [ + ("ID", "id"), + ("Topic", "topic"), + ("Type", "conversation_type"), + ("Start", "start_time"), + ("Duration", "duration"), + ("Host", "host"), + ("State", "state"), +] + +CONVERSATION_DETAIL_FIELDS = [ + ("ID", "id"), + ("Topic", "topic"), + ("Type", "conversation_type"), + ("Start", "start_time"), + ("Duration (s)", "duration"), + ("Host", "host"), + ("Host ID", "host_id"), + ("State", "state"), + ("Internal", "is_internal"), + ("Private", "is_private"), + ("Comments", "comment_count"), + ("Participants", "participant_names"), + ("Accounts", "account_names"), + ("Recording", "video_recording.url"), + ("Pushed to CRM", "pushed_to_crm"), +] + + +def format_conversation_list(items: list[Any], *, total: int = 0, page: int = 1) -> str: + """Format a list of conversations as a markdown table.""" + return list_table(items, CONVERSATION_LIST_COLUMNS, title="Conversations", total=total, page=page) + + +def format_conversation_detail(data: Any) -> str: + """Format a single conversation (with transcript & summary) as a markdown detail view.""" + topic = getattr(data, "topic", None) or "Conversation" + md = detail_table(data, CONVERSATION_DETAIL_FIELDS, title=f"Conversation: {topic}") + + # Participants (richer than the participant_names list in the metadata table) + participants = getattr(data, "participants_info", None) or [] + if participants: + md += "\n\n## Participants\n" + for p in participants: + name = getattr(p, "name", None) or "Unknown" + title = getattr(p, "title", None) + account = getattr(p, "account_name", None) + internal = getattr(p, "is_internal_participant", None) + suffix = ", ".join(x for x in [title, account] if x) + tag = " (internal)" if internal else "" + md += f"\n- **{name}**{tag}" + (f" — {suffix}" if suffix else "") + + # Associated deals + deals = getattr(data, "deals", None) or [] + if deals: + md += "\n\n## Deals\n" + for d in deals: + name = getattr(d, "name", None) or getattr(d, "id", "Unknown") + account = getattr(d, "account_name", None) + md += f"\n- {name}" + (f" ({account})" if account else "") + + # AI-generated call summary (detail endpoint only) + summary = getattr(data, "call_summary", None) + if summary: + md += _format_summary(summary) + + # Transcript (detail endpoint only) + transcript = getattr(data, "transcript", None) or [] + if transcript: + md += "\n\n## Transcript\n" + for seg in transcript: + speaker = getattr(seg, "participant_name", None) or "Unknown" + sentence = getattr(seg, "spoken_sentence", None) or "" + md += f"\n- **{speaker}:** {sentence}" + + return md + + +def _format_summary(summary: Any) -> str: + """Render the AI call summary (outcome, pain points, objections, next steps).""" + md = "\n\n## Call Summary\n" + outcome = getattr(summary, "outcome", None) + if outcome: + md += f"\n**Outcome:** {outcome}\n" + pricing = getattr(summary, "pricing_discussion", None) + if pricing: + md += f"\n**Pricing discussion:** {pricing}\n" + + for label, attr, field in ( + ("Pain Points", "pain_points", "text"), + ("Objections", "objections", "text"), + ("Next Steps", "next_steps", "step"), + ): + items = getattr(summary, attr, None) or [] + if items: + md += f"\n### {label}\n" + for item in items: + text = getattr(item, field, None) or "" + who = getattr(item, "participant_name", None) + md += f"\n- {text}" + (f" — _{who}_" if who else "") + return md diff --git a/src/apollo_cli/skills/SKILL.md b/src/apollo_cli/skills/SKILL.md index 672a783..1d66290 100644 --- a/src/apollo_cli/skills/SKILL.md +++ b/src/apollo_cli/skills/SKILL.md @@ -98,6 +98,15 @@ Get your API key from [Apollo.io Settings → API](https://app.apollo.io/#/setti |---------|-------------| | `calls search` | Search call activities | +### conversations + +Recorded meetings (Zoom/Teams/Meet) with transcript and AI summary — distinct from `calls` (dialer activity). + +| Command | Description | +|---------|-------------| +| `conversations search [--query TEXT]` | Search recorded conversations | +| `conversations get ID` | Get a conversation with transcript and AI summary | + ### emails | Command | Description | diff --git a/tests/conftest.py b/tests/conftest.py index 088ea16..874979c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,6 +74,21 @@ def sample_deal(): } +@pytest.fixture +def sample_conversation(): + """Sample conversation dict for testing.""" + return { + "id": "test-conversation-321", + "topic": "Acme <> QoDev discovery call", + "conversation_type": "zoom", + "start_time": "2026-06-01T14:00:00Z", + "duration": 1800, + "host": "Jane Smith", + "state": "processed", + "participant_names": ["Jane Smith", "John Doe"], + } + + @pytest.fixture def sample_pipeline(): """Sample pipeline dict for testing.""" diff --git a/tests/test_commands.py b/tests/test_commands.py index bd737b0..b7f2b59 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -291,6 +291,65 @@ async def test_deals_get(self, sample_deal: dict, capsys) -> None: assert data["stage_name"] == "Negotiation" +class TestConversationsCommands: + @pytest.mark.asyncio + async def test_conversations_search_json(self, sample_conversation: dict, capsys) -> None: + """Test conversations search in JSON mode.""" + mock_client = MagicMock() + mock_client.search_conversations = AsyncMock( + return_value=MockSearchResult(items=[sample_conversation], total=1, page=1) + ) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.conversations import search + + await search() + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["items"][0]["id"] == "test-conversation-321" + assert data["items"][0]["conversation_type"] == "zoom" + assert data["total"] == 1 + + @pytest.mark.asyncio + async def test_conversations_search_with_query(self, sample_conversation: dict, capsys) -> None: + """Test conversations search forwards the keyword as q_keywords.""" + mock_client = MagicMock() + mock_client.search_conversations = AsyncMock( + return_value=MockSearchResult(items=[sample_conversation], total=1, page=1) + ) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.conversations import search + + await search(query="discovery") + + mock_client.search_conversations.assert_called_once() + assert mock_client.search_conversations.call_args.kwargs["q_keywords"] == "discovery" + + @pytest.mark.asyncio + async def test_conversations_get(self, sample_conversation: dict, capsys) -> None: + """Test conversations get command.""" + mock_client = MagicMock() + mock_client.get_conversation = AsyncMock(return_value=sample_conversation) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.conversations import get + + await get(id="test-conversation-321") + + mock_client.get_conversation.assert_called_once_with("test-conversation-321") + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["topic"] == "Acme <> QoDev discovery call" + + class TestUsageCommand: @pytest.mark.asyncio async def test_usage_json(self, sample_usage: dict, capsys) -> None: diff --git a/tests/test_conversations_formatter.py b/tests/test_conversations_formatter.py new file mode 100644 index 0000000..01a12a9 --- /dev/null +++ b/tests/test_conversations_formatter.py @@ -0,0 +1,70 @@ +"""Tests for the conversation detail/list formatters.""" + +from __future__ import annotations + +from qodev_apollo_api.models import Conversation, ConversationDetail + +from apollo_cli.formatters.conversations import format_conversation_detail, format_conversation_list + + +def test_list_formatter_renders_columns() -> None: + conv = Conversation.model_validate( + {"id": "c1", "topic": "Discovery", "conversation_type": "zoom", "duration": 1800} + ) + md = format_conversation_list([conv], total=1, page=1) + assert "Conversations" in md + assert "Discovery" in md + assert "c1" in md + + +def test_detail_formatter_renders_transcript_and_summary() -> None: + detail = ConversationDetail.model_validate( + { + "id": "c1", + "topic": "Acme discovery", + "conversation_type": "zoom", + "duration": 1800, + "participants_info": [ + {"id": "p1", "name": "Jane Smith", "title": "VP", "is_internal_participant": True}, + {"id": "p2", "name": "John Doe", "account_name": "Acme"}, + ], + "deals": [{"id": "d1", "name": "Enterprise Deal", "account_name": "Acme"}], + "call_summary": { + "outcome": "Positive — moving to POC", + "pain_points": [{"id": "pp1", "text": "Manual data entry", "participant_name": "John Doe"}], + "next_steps": [{"id": "ns1", "step": "Send proposal"}], + }, + "transcript": [ + {"id": "t1", "participant_name": "Jane Smith", "spoken_sentence": "Thanks for joining."}, + {"id": "t2", "participant_name": "John Doe", "spoken_sentence": "Happy to be here."}, + ], + "video_recording": {"url": "https://example.com/rec"}, + } + ) + md = format_conversation_detail(detail) + + # Metadata + assert "Conversation: Acme discovery" in md + assert "https://example.com/rec" in md + # Participants section + assert "## Participants" in md + assert "Jane Smith" in md and "(internal)" in md + # Deals section + assert "## Deals" in md and "Enterprise Deal" in md + # Summary section + assert "## Call Summary" in md + assert "Positive — moving to POC" in md + assert "Manual data entry" in md + assert "Send proposal" in md + # Transcript section + assert "## Transcript" in md + assert "**Jane Smith:** Thanks for joining." in md + + +def test_detail_formatter_minimal_conversation() -> None: + """A search-level Conversation (no transcript/summary) still renders cleanly.""" + conv = Conversation.model_validate({"id": "c1", "topic": "Quick sync"}) + md = format_conversation_detail(conv) + assert "Conversation: Quick sync" in md + assert "## Transcript" not in md + assert "## Call Summary" not in md diff --git a/uv.lock b/uv.lock index 444c185..becb682 100644 --- a/uv.lock +++ b/uv.lock @@ -494,7 +494,7 @@ provides-extras = ["dev"] [[package]] name = "qodev-apollo-cli" -version = "1.0.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "cyclopts" },