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: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/apollo_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -50,6 +51,7 @@
notes_app,
tasks_app,
calls_app,
conversations_app,
emails_app,
news_app,
jobs_app,
Expand Down
48 changes: 48 additions & 0 deletions src/apollo_cli/commands/conversations.py
Original file line number Diff line number Diff line change
@@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: is q_keywords the correct filter name for search_conversations? (It’s hard to infer from the CLI alone.)

If the API uses a different param (e.g. q_keyword / query), this will look like it “works” but never filters. A unit test that asserts the exact kwargs (you already check q_keywords) is good — just double-check it matches the client method signature.

"""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)
109 changes: 109 additions & 0 deletions src/apollo_cli/formatters/conversations.py
Original file line number Diff line number Diff line change
@@ -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 []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improvement: format_conversation_detail() (and _format_summary()) use getattr() for participants_info / deals / call_summary / transcript. If get_conversation() ever returns raw dicts (or nested dicts after model_dump elsewhere), these sections will silently render empty.

Fix: normalize data/nested items via a small helper that supports both dicts and objects (or convert to a dict once and then use .get(...) throughout) so the rich detail view is robust regardless of return type.

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
9 changes: 9 additions & 0 deletions src/apollo_cli/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
15 changes: 15 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
59 changes: 59 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
70 changes: 70 additions & 0 deletions tests/test_conversations_formatter.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading