Add Scoring Rubric
Browse files- README.md +4 -3
- sample_data.json +13 -0
- src/__init__.py +0 -0
- src/agents/CallState.py +1 -0
- src/agents/IntakeAgent.py +106 -0
- src/agents/QualityScoringAgent.py +19 -3
- src/agents/SummarizationAgent.py +8 -2
- src/agents/schemas.py +4 -0
- src/streamlit_app.py +52 -3
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
|
| 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 |
-
"
|
| 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
|
|
|
|
| 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("###
|
| 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
|
| 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 |
|