Repository navigation
Expand file tree
/
Copy pathcross_call_insights.py
More file actions
219 lines (173 loc) · 6.6 KB
/
Copy pathcross_call_insights.py
File metadata and controls
219 lines (173 loc) · 6.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
"""
Cross-Call Insights — Aggregation & LLM Analysis
This module aggregates per-call analyses and generates strategic insights via LLM.
Philosophy:
- Aggregate all calls into high-level statistics (topic distribution, sentiment trends)
- Summarize key risks, features, and opportunities
- Single LLM call to synthesize into strategic insights (cheap, high value)
Example:
>>> from cross_call_insights import extract_cross_call_insights
>>> from llm_client import LLMClient
>>> insights = extract_cross_call_insights(all_transcripts, all_analysis, llm)
>>> print(insights["executive_summary"])
"""
import json
from collections import defaultdict
from llm_client import LLMClient
def extract_cross_call_insights(all_transcripts, all_analysis, llm):
"""
Aggregate per-call analyses and generate cross-call strategic insights via LLM.
Steps:
1. Aggregate topic counts, sentiment stats, risks, feature requests
2. Build call digest (summary of top issues)
3. Send structured JSON to LLM with explicit prompt
4. Parse LLM response (JSON with insights, risks, opportunities, actions)
Args:
all_transcripts (list): List of loaded transcripts (from transcript_loader)
all_analysis (list): List of per-call analyses (from orchestrator)
llm (LLMClient): LLM client instance (Claude or OpenAI)
Returns:
dict: Keys:
- executive_summary: High-level synthesis
- insights: List of dicts with keys:
- insight (str): The insight
- evidence (str): Supporting evidence from the data
- urgency (str): "high", "medium", or "low"
- risks: List of risk strings
- opportunities: List of opportunity strings
- stakeholder_actions: Dict with "Sales", "Product", "Support", "Engineering"
as keys, each mapping to list of action strings
- error (bool, optional): True if LLM call failed
- message (str, optional): Error message if present
"""
# Aggregate statistics
topic_counts = defaultdict(int)
sentiment_by_type = defaultdict(list)
risk_flags = []
feature_requests = []
for analysis in all_analysis:
# Skip analyses with errors
if "error" in analysis:
continue
# Count topics
topic = analysis.get("primary_topic", "Other")
topic_counts[topic] += 1
# Aggregate sentiment by call type (computed later)
sentiment_label = analysis.get("sentiment_label", "neutral")
sentiment_score = analysis.get("sentiment_score", 0)
sentiment_by_type[sentiment_label].append(sentiment_score)
# Collect risks
if analysis.get("risks"):
risk_flags.extend(analysis["risks"])
# Collect feature requests
if "feature request" in topic.lower():
feature_requests.extend(analysis.get("action_items", []))
# Compute average sentiment by type
avg_sentiment_by_type = {
s: round(sum(v) / len(v), 2) if v else 0
for s, v in sentiment_by_type.items()
}
# Build call digest (compact summary of each call)
call_digest = []
for i, (t, a) in enumerate(zip(all_transcripts, all_analysis)):
if "error" in a:
continue
call_digest.append({
"call_type": t["call_type"],
"primary_topic": a.get("primary_topic"),
"sentiment_label": a.get("sentiment_label"),
"summary": a.get("summary", "")[:200], # Truncate for brevity
"risks": a.get("risks", [])[:2], # Top 2 risks
})
# Build LLM prompt
prompt = f"""
You are a Senior Product Strategy Analyst for a B2B SaaS company.
The company provides cloud compliance and automation software.
The following data is aggregated from customer calls, support calls, sales conversations, and internal engineering meetings.
DATA:
Topic Distribution:
{json.dumps(dict(topic_counts), indent=2)}
Sentiment Distribution:
{json.dumps(avg_sentiment_by_type, indent=2)}
PER-CALL DIGEST:
{json.dumps(call_digest, indent=2)}
Top Risks Mentioned:
{', '.join(risk_flags[:10]) if risk_flags else 'None'}
Feature Requests:
{', '.join(feature_requests[:10]) if feature_requests else 'None'}
Total Calls Analyzed: {len(all_transcripts)}
Internal Calls: {sum(1 for t in all_transcripts if t['call_type'] == 'internal')}
External Calls: {sum(1 for t in all_transcripts if t['call_type'] == 'external')}
Customer Support Calls: {sum(1 for t in all_transcripts if t['call_type'] == 'support')}
Instructions:
- Base all conclusions ONLY on the data provided.
- Every insight must reference supporting evidence.
- Do not invent customers, revenue figures, or opportunities.
- If evidence is insufficient, return an empty list.
- Urgency levels:
high = churn, outage, escalation, security, major recurring issue
medium = recurring issue with moderate impact
low = informational trend
Return ONLY valid JSON (no markdown, no code blocks).
{{
"executive_summary": "...",
"insights": [
{{
"insight": "...",
"evidence": "...",
"urgency": "high|medium|low"
}}
],
"risks": [...],
"opportunities": [...],
"stakeholder_actions": {{
"Sales": [...],
"Product": [...],
"Support": [...],
"Engineering": [...]
}}
}}
"""
try:
# Call LLM with retries
resp = llm.complete_with_retry(prompt, max_tokens=1500)
# Parse response (flexible JSON extraction)
return _parse_json_response(resp)
except Exception as e:
# Return error structure on failure
return {
"error": True,
"message": str(e),
"insights": [],
"risks": [],
"opportunities": [],
}
def _parse_json_response(text):
"""
Parse JSON from LLM response, handling partial/malformed JSON.
Attempts:
1. Direct json.loads()
2. Extract {...} substring and parse
3. Return error dict if both fail
Args:
text (str): LLM response text
Returns:
dict: Parsed JSON, or error dict on failure
"""
try:
return json.loads(text)
except json.JSONDecodeError:
pass
try:
# Try to extract {...} substring
start, end = text.find("{"), text.rfind("}") + 1
if start >= 0 and end > start:
return json.loads(text[start:end])
except (json.JSONDecodeError, ValueError):
pass
# Return error structure
return {
"parse_error": True,
"raw_response": text[:500],
"error_message": "Failed to parse JSON from LLM response",
}