Fade0510 commited on
Commit
18ddfa2
·
1 Parent(s): 2a1f84b

Add Scoring Rubric

Browse files
README.md CHANGED
@@ -16,7 +16,7 @@ args: ["--server.enableCORS", "false", "--server.enableXsrfProtection", "false"]
16
  ---
17
  # Call Center Data Analysis Agent
18
 
19
- This project implements a multi-agent workflow using **LangGraph** to process, transcribe, summarize, and score call center data. It comes with a **Streamlit** user interface to easily upload `.csv`, `.mp3`, or `.wav` files and view the resulting insights.
20
 
21
  ## Workflow Flow & Agent Classes
22
 
@@ -57,8 +57,9 @@ Notes:
57
 
58
  1. **`IntakeAgent` (`src/agents/IntakeAgent.py`)**
59
  - *Entry Point*.
60
- - Reads the uploaded file, validates the file format and CSV schema, extracts basic metadata, and runs a first-pass clean-up (using an LLM).
61
  - **CSV requirement:** headers must include `id` and `transcript` (case-insensitive).
 
62
 
63
  2. **`Router` (`src/agents/Router.py`)**
64
  - *Conditional Routing Node*.
@@ -80,7 +81,7 @@ Notes:
80
 
81
  5. **`SummarizationAgent` (`src/agents/SummarizationAgent.py`)**
82
  - Takes the redacted text from the `ModerationAgent`.
83
- - Generates a concise **summary**, **key points**, **tags**, and **highlights** using OpenAI (`gpt-4o`) with Pydantic-structured output.
84
 
85
  6. **`QualityScoringAgent` (`src/agents/QualityScoringAgent.py`)**
86
  - Takes the clean text and evaluates it against a predefined rubric.
 
16
  ---
17
  # Call Center Data Analysis Agent
18
 
19
+ This project implements a multi-agent workflow using **LangGraph** to process, transcribe, summarize, and score call center data. It comes with a **Streamlit** user interface to easily upload `.csv`, `.json`, `.mp3`, or `.wav` files and view the resulting insights.
20
 
21
  ## Workflow Flow & Agent Classes
22
 
 
57
 
58
  1. **`IntakeAgent` (`src/agents/IntakeAgent.py`)**
59
  - *Entry Point*.
60
+ - Reads the uploaded file, validates the file format and schema, extracts basic metadata, and runs a first-pass clean-up (using an LLM).
61
  - **CSV requirement:** headers must include `id` and `transcript` (case-insensitive).
62
+ - **JSON supported shapes:** a list of `{id, transcript}` objects, a single `{id, transcript}` object, or a dict with `transcripts`/`calls` arrays containing `{id, transcript}` objects.
63
 
64
  2. **`Router` (`src/agents/Router.py`)**
65
  - *Conditional Routing Node*.
 
81
 
82
  5. **`SummarizationAgent` (`src/agents/SummarizationAgent.py`)**
83
  - Takes the redacted text from the `ModerationAgent`.
84
+ - Generates a concise **summary**, **key points**, **action items**, **tags**, and **highlights** using OpenAI (`gpt-4o`) with Pydantic-structured output.
85
 
86
  6. **`QualityScoringAgent` (`src/agents/QualityScoringAgent.py`)**
87
  - Takes the clean text and evaluates it against a predefined rubric.
sample_data.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "call-001",
4
+ "transcript": "Agent: Thanks for calling Acme Support. How can I help today?\nCustomer: My internet has been dropping every evening.\nAgent: I’m sorry about that. Let’s run a quick modem reset and check outages in your area.
5
+ \nCustomer: Okay.\nAgent: I see intermittent signal loss. I’ll schedule a technician for tomorrow between 2–4pm and apply a service credit request.\nCustomer: Thank you.\nAgent: Before we wrap up, can you confirm the best phone
6
+ number for the tech to call?\nCustomer: 555-0101.\nAgent: Great — you’ll get a confirmation text shortly."
7
+ },
8
+ {
9
+ "id": "call-002",
10
+ "transcript": "Agent: Hi! I can help with billing.\nCustomer: I was charged twice for last month.\nAgent: I understand. I’m reviewing the account now.\nCustomer: I’m frustrated because this happened before.\nAgent: I’m
11
+ sorry. I see two identical charges; I’ll void the duplicate and email you a confirmation within 10 minutes.\nCustomer: Please do.\nAgent: Also, I’ll add a note to prevent a recurring duplicate payment."
12
+ }
13
+ ]
src/__init__.py ADDED
File without changes
src/agents/CallState.py CHANGED
@@ -8,6 +8,7 @@ class CallState(TypedDict):
8
  metadata: Optional[Dict[str, Any]]
9
  summary: Optional[str]
10
  key_points: Optional[List[str]]
 
11
  tags: Optional[List[str]]
12
  highlights: Optional[List[str]]
13
  quality_scores: Optional[Dict[str, Any]]
 
8
  metadata: Optional[Dict[str, Any]]
9
  summary: Optional[str]
10
  key_points: Optional[List[str]]
11
+ action_items: Optional[List[str]]
12
  tags: Optional[List[str]]
13
  highlights: Optional[List[str]]
14
  quality_scores: Optional[Dict[str, Any]]
src/agents/IntakeAgent.py CHANGED
@@ -1,4 +1,5 @@
1
  import pandas as pd
 
2
  from langchain_openai import ChatOpenAI
3
  from langchain_core.prompts import PromptTemplate
4
  from src.agents.CallState import CallState
@@ -42,6 +43,111 @@ class IntakeAgent:
42
  state["clean_content"] = ""
43
  return state
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  if state.get("content"):
46
  prompt = PromptTemplate.from_template(
47
  "Clean the following text of any profanity and fix basic grammatical errors. Return only the clean text:\n{text}"
 
1
  import pandas as pd
2
+ import json
3
  from langchain_openai import ChatOpenAI
4
  from langchain_core.prompts import PromptTemplate
5
  from src.agents.CallState import CallState
 
43
  state["clean_content"] = ""
44
  return state
45
 
46
+ if file_type == "json":
47
+ try:
48
+ with open(file_path, "r", encoding="utf-8") as f:
49
+ raw = f.read()
50
+
51
+ try:
52
+ payload = json.loads(raw)
53
+ except json.JSONDecodeError:
54
+ # Common failure mode: users paste multi-line transcripts with raw control characters
55
+ # (e.g., literal newlines) inside JSON strings. JSON requires these to be escaped.
56
+ def _sanitize_control_chars_in_strings(text: str) -> str:
57
+ out: list[str] = []
58
+ in_string = False
59
+ escape = False
60
+ for ch in text:
61
+ if not in_string:
62
+ out.append(ch)
63
+ if ch == '"':
64
+ in_string = True
65
+ continue
66
+
67
+ # Inside string
68
+ if escape:
69
+ out.append(ch)
70
+ escape = False
71
+ continue
72
+ if ch == "\\":
73
+ out.append(ch)
74
+ escape = True
75
+ continue
76
+ if ch == '"':
77
+ out.append(ch)
78
+ in_string = False
79
+ continue
80
+
81
+ code = ord(ch)
82
+ if ch == "\n":
83
+ out.append("\\n")
84
+ elif ch == "\r":
85
+ out.append("\\r")
86
+ elif ch == "\t":
87
+ out.append("\\t")
88
+ elif code < 0x20:
89
+ out.append(f"\\u{code:04x}")
90
+ else:
91
+ out.append(ch)
92
+ return "".join(out)
93
+
94
+ payload = json.loads(_sanitize_control_chars_in_strings(raw))
95
+
96
+ transcripts: list[str] = []
97
+ ids_preview: list[str] = []
98
+
99
+ def add_item(item: object) -> None:
100
+ if not isinstance(item, dict):
101
+ return
102
+ transcript = item.get("transcript")
103
+ if isinstance(transcript, str) and transcript.strip():
104
+ transcripts.append(transcript.strip())
105
+ item_id = item.get("id")
106
+ if item_id is not None and len(ids_preview) < 20:
107
+ ids_preview.append(str(item_id))
108
+
109
+ if isinstance(payload, list):
110
+ for item in payload:
111
+ add_item(item)
112
+ elif isinstance(payload, dict):
113
+ if isinstance(payload.get("transcript"), str):
114
+ add_item(payload)
115
+ elif isinstance(payload.get("transcripts"), list):
116
+ for item in payload["transcripts"]:
117
+ add_item(item)
118
+ elif isinstance(payload.get("calls"), list):
119
+ for item in payload["calls"]:
120
+ add_item(item)
121
+ else:
122
+ state["metadata"]["intake_error"] = (
123
+ "JSON must be either a list of {id, transcript} objects, "
124
+ "a single {id, transcript} object, or a dict with 'transcripts'/'calls' arrays."
125
+ )
126
+ state["content"] = ""
127
+ state["clean_content"] = ""
128
+ return state
129
+ else:
130
+ state["metadata"]["intake_error"] = "Unsupported JSON root type."
131
+ state["content"] = ""
132
+ state["clean_content"] = ""
133
+ return state
134
+
135
+ if not transcripts:
136
+ state["metadata"]["intake_error"] = "JSON contained no non-empty 'transcript' fields."
137
+ state["content"] = ""
138
+ state["clean_content"] = ""
139
+ return state
140
+
141
+ state["content"] = " ".join(transcripts).strip()
142
+ state["metadata"]["row_count"] = int(len(transcripts))
143
+ if ids_preview:
144
+ state["metadata"]["ids_preview"] = ids_preview
145
+ except Exception as e:
146
+ state["metadata"]["intake_error"] = str(e)
147
+ state["content"] = ""
148
+ state["clean_content"] = ""
149
+ return state
150
+
151
  if state.get("content"):
152
  prompt = PromptTemplate.from_template(
153
  "Clean the following text of any profanity and fix basic grammatical errors. Return only the clean text:\n{text}"
src/agents/QualityScoringAgent.py CHANGED
@@ -5,6 +5,15 @@ from src.agents.schemas import QualityScores
5
  import inspect
6
 
7
  class QualityScoringAgent:
 
 
 
 
 
 
 
 
 
8
  def __init__(self):
9
  self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
10
 
@@ -15,15 +24,15 @@ class QualityScoringAgent:
15
  return state
16
 
17
  prompt = PromptTemplate.from_template(
18
- "Evaluate the following call transcript for tone, professionalism, and structured resolution. "
19
- "Score each out of 10 based on a strict rubric.\n"
20
  "Return scores and brief notes.\n\n"
21
  "Transcript:\n{text}"
22
  )
23
 
24
  structured_llm = self._structured_llm()
25
  chain = prompt | structured_llm
26
- result = chain.invoke({"text": clean_text})
27
 
28
  if isinstance(result, QualityScores):
29
  if hasattr(result, "model_dump"):
@@ -33,6 +42,9 @@ class QualityScoringAgent:
33
  else:
34
  result_dict = dict(result or {})
35
 
 
 
 
36
  profanity_count = clean_text.count("***")
37
  if profanity_count > 0:
38
  result_dict["profanity"] = profanity_count
@@ -40,6 +52,10 @@ class QualityScoringAgent:
40
  if key in result_dict and isinstance(result_dict[key], (int, float)):
41
  result_dict[key] = max(0, result_dict[key] - 3)
42
 
 
 
 
 
43
  state["quality_scores"] = result_dict
44
 
45
  return state
 
5
  import inspect
6
 
7
  class QualityScoringAgent:
8
+ RUBRIC_VERSION = "v1"
9
+ RUBRIC_TEXT = (
10
+ "Scoring rubric (0–10 each):\n"
11
+ "- Tone: 0 hostile/arguing; 3 curt/tense; 5 neutral; 7 friendly/empathic; 10 consistently calm, respectful, and de-escalating.\n"
12
+ "- Professionalism: 0 rude/unprofessional; 3 unclear or dismissive; 5 acceptable; 7 clear, courteous, policy-aligned; 10 excellent clarity, appropriate boundaries, and ownership.\n"
13
+ "- Structured resolution: 0 no attempt; 3 vague/no next steps; 5 partial (some questions/steps); 7 clear diagnosis + next steps + confirmation; 10 fully structured (issue, actions, timelines, confirmation, and closure).\n"
14
+ "Notes must cite 1–3 specific behaviors from the transcript (no long quotes)."
15
+ )
16
+
17
  def __init__(self):
18
  self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
19
 
 
24
  return state
25
 
26
  prompt = PromptTemplate.from_template(
27
+ "Evaluate the following call transcript for tone, professionalism, and structured resolution.\n\n"
28
+ "{rubric}\n\n"
29
  "Return scores and brief notes.\n\n"
30
  "Transcript:\n{text}"
31
  )
32
 
33
  structured_llm = self._structured_llm()
34
  chain = prompt | structured_llm
35
+ result = chain.invoke({"text": clean_text, "rubric": self.RUBRIC_TEXT})
36
 
37
  if isinstance(result, QualityScores):
38
  if hasattr(result, "model_dump"):
 
42
  else:
43
  result_dict = dict(result or {})
44
 
45
+ result_dict["rubric_version"] = self.RUBRIC_VERSION
46
+ result_dict["rubric"] = self.RUBRIC_TEXT
47
+
48
  profanity_count = clean_text.count("***")
49
  if profanity_count > 0:
50
  result_dict["profanity"] = profanity_count
 
52
  if key in result_dict and isinstance(result_dict[key], (int, float)):
53
  result_dict[key] = max(0, result_dict[key] - 3)
54
 
55
+ if state.get("metadata") is None or not isinstance(state.get("metadata"), dict):
56
+ state["metadata"] = {}
57
+ state["metadata"]["qa_rubric_version"] = self.RUBRIC_VERSION
58
+
59
  state["quality_scores"] = result_dict
60
 
61
  return state
src/agents/SummarizationAgent.py CHANGED
@@ -15,7 +15,8 @@ class SummarizationAgent:
15
 
16
  prompt = PromptTemplate.from_template(
17
  "Summarize the following call transcript and extract key points.\n"
18
- "Return a concise summary, a short list of key points, 3-8 topic tags, and 2-6 highlights.\n\n"
 
19
  "Transcript:\n{text}"
20
  )
21
 
@@ -26,11 +27,13 @@ class SummarizationAgent:
26
  if isinstance(result, CallSummary):
27
  state["summary"] = result.summary
28
  state["key_points"] = result.key_points
 
29
  state["tags"] = result.tags
30
  state["highlights"] = result.highlights
31
  else:
32
  state["summary"] = (result or {}).get("summary", "")
33
  state["key_points"] = (result or {}).get("key_points", [])
 
34
  state["tags"] = (result or {}).get("tags", [])
35
  state["highlights"] = (result or {}).get("highlights", [])
36
 
@@ -55,7 +58,7 @@ class SummarizationAgent:
55
  llm = ChatOpenAI(model="gpt-4o", temperature=0)
56
  prompt = PromptTemplate.from_template(
57
  "Summarize the following call transcript.\n"
58
- "Return: summary, key_points, tags, highlights.\n\n"
59
  "Transcript:\n{text}"
60
  )
61
 
@@ -66,11 +69,13 @@ class SummarizationAgent:
66
  if isinstance(result, CallSummary):
67
  state["summary"] = result.summary
68
  state["key_points"] = result.key_points
 
69
  state["tags"] = result.tags
70
  state["highlights"] = result.highlights
71
  return state
72
  state["summary"] = (result or {}).get("summary", state.get("summary", ""))
73
  state["key_points"] = (result or {}).get("key_points", state.get("key_points") or [])
 
74
  state["tags"] = (result or {}).get("tags", state.get("tags") or [])
75
  state["highlights"] = (result or {}).get("highlights", state.get("highlights") or [])
76
  return state
@@ -81,6 +86,7 @@ class SummarizationAgent:
81
  summary = (text.strip()[:800] + ("…" if len(text.strip()) > 800 else "")).strip()
82
  state["summary"] = summary or state.get("summary", "")
83
  state["key_points"] = state.get("key_points") or []
 
84
  state["tags"] = state.get("tags") or []
85
  state["highlights"] = state.get("highlights") or []
86
  return state
 
15
 
16
  prompt = PromptTemplate.from_template(
17
  "Summarize the following call transcript and extract key points.\n"
18
+ "Return a concise summary, a short list of key points, 2-8 action items, 3-8 topic tags, and 2-6 highlights.\n"
19
+ "Action items must be concrete follow-ups; include the owner (Agent/Customer) when you can.\n\n"
20
  "Transcript:\n{text}"
21
  )
22
 
 
27
  if isinstance(result, CallSummary):
28
  state["summary"] = result.summary
29
  state["key_points"] = result.key_points
30
+ state["action_items"] = result.action_items
31
  state["tags"] = result.tags
32
  state["highlights"] = result.highlights
33
  else:
34
  state["summary"] = (result or {}).get("summary", "")
35
  state["key_points"] = (result or {}).get("key_points", [])
36
+ state["action_items"] = (result or {}).get("action_items", [])
37
  state["tags"] = (result or {}).get("tags", [])
38
  state["highlights"] = (result or {}).get("highlights", [])
39
 
 
58
  llm = ChatOpenAI(model="gpt-4o", temperature=0)
59
  prompt = PromptTemplate.from_template(
60
  "Summarize the following call transcript.\n"
61
+ "Return: summary, key_points, action_items, tags, highlights.\n\n"
62
  "Transcript:\n{text}"
63
  )
64
 
 
69
  if isinstance(result, CallSummary):
70
  state["summary"] = result.summary
71
  state["key_points"] = result.key_points
72
+ state["action_items"] = result.action_items
73
  state["tags"] = result.tags
74
  state["highlights"] = result.highlights
75
  return state
76
  state["summary"] = (result or {}).get("summary", state.get("summary", ""))
77
  state["key_points"] = (result or {}).get("key_points", state.get("key_points") or [])
78
+ state["action_items"] = (result or {}).get("action_items", state.get("action_items") or [])
79
  state["tags"] = (result or {}).get("tags", state.get("tags") or [])
80
  state["highlights"] = (result or {}).get("highlights", state.get("highlights") or [])
81
  return state
 
86
  summary = (text.strip()[:800] + ("…" if len(text.strip()) > 800 else "")).strip()
87
  state["summary"] = summary or state.get("summary", "")
88
  state["key_points"] = state.get("key_points") or []
89
+ state["action_items"] = state.get("action_items") or []
90
  state["tags"] = state.get("tags") or []
91
  state["highlights"] = state.get("highlights") or []
92
  return state
src/agents/schemas.py CHANGED
@@ -11,6 +11,10 @@ class CallSummary(BaseModel):
11
  default_factory=list,
12
  description="A short list of the most important takeaways from the call.",
13
  )
 
 
 
 
14
  tags: List[str] = Field(
15
  default_factory=list,
16
  description="Short topic tags (e.g., billing, outage, refund).",
 
11
  default_factory=list,
12
  description="A short list of the most important takeaways from the call.",
13
  )
14
+ action_items: List[str] = Field(
15
+ default_factory=list,
16
+ description="Concrete follow-up actions with an owner when possible.",
17
+ )
18
  tags: List[str] = Field(
19
  default_factory=list,
20
  description="Short topic tags (e.g., billing, outage, refund).",
src/streamlit_app.py CHANGED
@@ -108,6 +108,17 @@ def display_results(final_state):
108
  else:
109
  st.write(key_points)
110
 
 
 
 
 
 
 
 
 
 
 
 
111
  st.markdown("### Tags / Highlights")
112
  tags = final_state.get("tags") or []
113
  highlights = final_state.get("highlights") or []
@@ -123,7 +134,7 @@ def display_results(final_state):
123
  st.write("**Highlights:** None")
124
 
125
  with col2:
126
- st.markdown("### Quality Scores")
127
  quality_scores = final_state.get("quality_scores", {})
128
  if isinstance(quality_scores, dict) and quality_scores:
129
  import plotly.graph_objects as go
@@ -161,8 +172,46 @@ def display_results(final_state):
161
 
162
  if "notes" in quality_scores:
163
  st.write("**Notes:**", quality_scores["notes"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  else:
165
- st.write("No specific scores generated.", quality_scores)
166
 
167
  st.markdown("### Metadata")
168
  if isinstance(metadata, dict) and metadata:
@@ -193,7 +242,7 @@ def main():
193
  st.sidebar.header("Upload New File")
194
  uploaded_file = st.sidebar.file_uploader(
195
  "Upload a file",
196
- type=["mp3", "wav", "csv"],
197
  on_change=set_upload_mode
198
  )
199
 
 
108
  else:
109
  st.write(key_points)
110
 
111
+ st.markdown("### Action Items")
112
+ action_items = final_state.get("action_items", "No action items generated.")
113
+ if isinstance(action_items, list):
114
+ if action_items:
115
+ for item in action_items:
116
+ st.markdown(f"- {item}")
117
+ else:
118
+ st.write("No action items generated.")
119
+ else:
120
+ st.write(action_items)
121
+
122
  st.markdown("### Tags / Highlights")
123
  tags = final_state.get("tags") or []
124
  highlights = final_state.get("highlights") or []
 
134
  st.write("**Highlights:** None")
135
 
136
  with col2:
137
+ st.markdown("### Scoring Rubric")
138
  quality_scores = final_state.get("quality_scores", {})
139
  if isinstance(quality_scores, dict) and quality_scores:
140
  import plotly.graph_objects as go
 
172
 
173
  if "notes" in quality_scores:
174
  st.write("**Notes:**", quality_scores["notes"])
175
+
176
+ if "rubric" in quality_scores and quality_scores["rubric"]:
177
+ with st.expander("View scoring rubric", expanded=False):
178
+ rubric_rows = [
179
+ {
180
+ "Dimension": "Tone",
181
+ "0": "Hostile/arguing",
182
+ "3": "Curt/tense",
183
+ "5": "Neutral",
184
+ "7": "Friendly/empathic",
185
+ "10": "Consistently calm, respectful, de-escalating",
186
+ },
187
+ {
188
+ "Dimension": "Professionalism",
189
+ "0": "Rude/unprofessional",
190
+ "3": "Unclear or dismissive",
191
+ "5": "Acceptable",
192
+ "7": "Clear, courteous, policy-aligned",
193
+ "10": "Excellent clarity, appropriate boundaries, ownership",
194
+ },
195
+ {
196
+ "Dimension": "Structured resolution",
197
+ "0": "No attempt",
198
+ "3": "Vague / no next steps",
199
+ "5": "Partial (some questions/steps)",
200
+ "7": "Clear diagnosis + next steps + confirmation",
201
+ "10": "Fully structured (issue, actions, timelines, confirmation, closure)",
202
+ },
203
+ ]
204
+
205
+ st.dataframe(
206
+ rubric_rows,
207
+ hide_index=True,
208
+ use_container_width=True,
209
+ )
210
+ st.caption(
211
+ "Notes must cite 1–3 specific behaviors from the transcript (avoid long quotes)."
212
+ )
213
  else:
214
+ st.write("No scoring rubric results generated.", quality_scores)
215
 
216
  st.markdown("### Metadata")
217
  if isinstance(metadata, dict) and metadata:
 
242
  st.sidebar.header("Upload New File")
243
  uploaded_file = st.sidebar.file_uploader(
244
  "Upload a file",
245
+ type=["mp3", "wav", "csv", "json"],
246
  on_change=set_upload_mode
247
  )
248