-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add conversations command group (v1.1.0) #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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: | ||
| """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) | ||
| 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 [] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Improvement:
|
||
| 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 | ||
| 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 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: is
q_keywordsthe correct filter name forsearch_conversations? (It’s hard to infer from the CLI alone.)