Score improvement changes
Browse files- README.md +42 -32
- requirements.txt +2 -1
- src/agents/CallState.py +4 -2
- src/agents/IntakeAgent.py +24 -7
- src/agents/ModerationAgent.py +1 -1
- src/agents/QualityScoringAgent.py +53 -19
- src/agents/Router.py +15 -0
- src/agents/SummarizationAgent.py +66 -13
- src/agents/TranscriptionAgent.py +1 -1
- src/streamlit_app.py +84 -15
- src/workflow.py +26 -6
README.md
CHANGED
|
@@ -24,42 +24,53 @@ The core analysis logic is driven by a LangGraph StateGraph defined in `src/work
|
|
| 24 |
|
| 25 |
### Workflow Architecture
|
| 26 |
```text
|
| 27 |
-
|
| 28 |
|
|
| 29 |
v
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
```
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
### Agent Classes
|
| 51 |
|
| 52 |
-
1. **`IntakeAgent` (`src/agents/
|
| 53 |
- *Entry Point*.
|
| 54 |
-
- Reads the uploaded file, validates the file format, extracts basic metadata, and runs a first-pass
|
|
|
|
| 55 |
|
| 56 |
-
2. **`Router` (`src/agents/
|
| 57 |
- *Conditional Routing Node*.
|
| 58 |
-
- Determines the next step based on the file type.
|
| 59 |
- **If Audio (`.mp3`, `.wav`)**: Routes to the `TranscriptionAgent`.
|
| 60 |
-
- **If Text (`.csv`)**: Routes
|
|
|
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
| 63 |
- Utilizes `openai-whisper` to convert audio files into text.
|
| 64 |
- Also scrubs the resulting transcript of profanity before passing it down the pipeline.
|
| 65 |
|
|
@@ -67,13 +78,13 @@ The core analysis logic is driven by a LangGraph StateGraph defined in `src/work
|
|
| 67 |
- Receives text either directly from the `Router` (if text upload) or from the `TranscriptionAgent` (if audio upload).
|
| 68 |
- Identifies any obscene words or profanity using an LLM and replaces them entirely with a `***` mask to safely prepare the text for downstream analysis.
|
| 69 |
|
| 70 |
-
5. **`SummarizationAgent` (`src/agents/
|
| 71 |
- Takes the redacted text from the `ModerationAgent`.
|
| 72 |
-
- Generates a concise summary
|
| 73 |
|
| 74 |
-
6. **`QualityScoringAgent` (`src/agents/
|
| 75 |
- Takes the clean text and evaluates it against a predefined rubric.
|
| 76 |
-
- Scores the transcript based on Tone, Professionalism, and Structured Resolution. Automatically applies a 3-point penalty to each score and logs a count of policy violations if the `ModerationAgent` detected and masked any profanity (`***`).
|
| 77 |
|
| 78 |
## Prerequisites
|
| 79 |
|
|
@@ -87,7 +98,7 @@ The core analysis logic is driven by a LangGraph StateGraph defined in `src/work
|
|
| 87 |
|
| 88 |
1. Create a virtual environment and activate it (if you haven't already):
|
| 89 |
```bash
|
| 90 |
-
|
| 91 |
source .venv/bin/activate
|
| 92 |
```
|
| 93 |
2. Install the required dependencies:
|
|
@@ -104,9 +115,8 @@ The core analysis logic is driven by a LangGraph StateGraph defined in `src/work
|
|
| 104 |
|
| 105 |
2. Start the Streamlit application:
|
| 106 |
```bash
|
| 107 |
-
streamlit run
|
| 108 |
```
|
| 109 |
|
| 110 |
3. Open your browser to the local URL provided by Streamlit (usually `http://localhost:8501`).
|
| 111 |
4. Use the sidebar to upload a `.csv`, `.mp3`, or `.wav` file and watch the agents analyze your data!
|
| 112 |
-
|
|
|
|
| 24 |
|
| 25 |
### Workflow Architecture
|
| 26 |
```text
|
| 27 |
+
[ IntakeAgent ]
|
| 28 |
|
|
| 29 |
v
|
| 30 |
+
[ Router (file type) ]
|
| 31 |
+
/ | \
|
| 32 |
+
(CSV invalid)/ (If Audio) (If Text)
|
| 33 |
+
v v v
|
| 34 |
+
(END) [ TranscriptionAgent ] |
|
| 35 |
+
| |
|
| 36 |
+
v |
|
| 37 |
+
[ ModerationAgent ] <-----/
|
| 38 |
+
|
|
| 39 |
+
v
|
| 40 |
+
[ SummarizationAgent ]
|
| 41 |
+
|
|
| 42 |
+
v
|
| 43 |
+
[ PostSummarizeRouter (output) ]
|
| 44 |
+
| |
|
| 45 |
+
(END) [ QualityScoringAgent ]
|
| 46 |
+
|
|
| 47 |
+
v
|
| 48 |
+
(END)
|
| 49 |
```
|
| 50 |
|
| 51 |
+
Notes:
|
| 52 |
+
- The workflow uses a LangGraph memory checkpointer (`MemorySaver`) and fallbacks on critical nodes (summarization/scoring) to avoid UI breakage on transient API/parse errors.
|
| 53 |
+
- If CSV headers are invalid, the workflow terminates immediately and the UI shows a validation error.
|
| 54 |
+
- After summarization, the workflow can short-circuit to `END` if the transcript is too short or the model output is missing/empty.
|
| 55 |
+
|
| 56 |
### Agent Classes
|
| 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*.
|
| 65 |
+
- Determines the next step based on the file type and intake validation state.
|
| 66 |
- **If Audio (`.mp3`, `.wav`)**: Routes to the `TranscriptionAgent`.
|
| 67 |
+
- **If Text (`.csv`)**: Routes to the `ModerationAgent` then summarization/scoring.
|
| 68 |
+
- **If CSV invalid**: Routes to `END` (the UI displays `metadata.intake_error`).
|
| 69 |
|
| 70 |
+
**`PostSummarizeRouter` (`src/agents/Router.py`)**
|
| 71 |
+
- Routes based on model output quality (e.g., short transcript or missing summary can skip scoring).
|
| 72 |
+
|
| 73 |
+
3. **`TranscriptionAgent` (`src/agents/TranscriptionAgent.py`)**
|
| 74 |
- Utilizes `openai-whisper` to convert audio files into text.
|
| 75 |
- Also scrubs the resulting transcript of profanity before passing it down the pipeline.
|
| 76 |
|
|
|
|
| 78 |
- Receives text either directly from the `Router` (if text upload) or from the `TranscriptionAgent` (if audio upload).
|
| 79 |
- Identifies any obscene words or profanity using an LLM and replaces them entirely with a `***` mask to safely prepare the text for downstream analysis.
|
| 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.
|
| 87 |
+
- Scores the transcript based on Tone, Professionalism, and Structured Resolution using Pydantic structured output (function calling when supported). Automatically applies a 3-point penalty to each score and logs a count of policy violations if the `ModerationAgent` detected and masked any profanity (`***`).
|
| 88 |
|
| 89 |
## Prerequisites
|
| 90 |
|
|
|
|
| 98 |
|
| 99 |
1. Create a virtual environment and activate it (if you haven't already):
|
| 100 |
```bash
|
| 101 |
+
python3 -m venv .venv
|
| 102 |
source .venv/bin/activate
|
| 103 |
```
|
| 104 |
2. Install the required dependencies:
|
|
|
|
| 115 |
|
| 116 |
2. Start the Streamlit application:
|
| 117 |
```bash
|
| 118 |
+
streamlit run src/streamlit_app.py
|
| 119 |
```
|
| 120 |
|
| 121 |
3. Open your browser to the local URL provided by Streamlit (usually `http://localhost:8501`).
|
| 122 |
4. Use the sidebar to upload a `.csv`, `.mp3`, or `.wav` file and watch the agents analyze your data!
|
|
|
requirements.txt
CHANGED
|
@@ -7,5 +7,6 @@ langchain
|
|
| 7 |
langchain-openai
|
| 8 |
openai
|
| 9 |
openai-whisper
|
|
|
|
| 10 |
python-dotenv
|
| 11 |
-
ffmpeg-python
|
|
|
|
| 7 |
langchain-openai
|
| 8 |
openai
|
| 9 |
openai-whisper
|
| 10 |
+
pydantic
|
| 11 |
python-dotenv
|
| 12 |
+
ffmpeg-python
|
src/agents/CallState.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from typing import TypedDict, Optional, Dict, Any
|
| 2 |
|
| 3 |
class CallState(TypedDict):
|
| 4 |
file_path: str
|
|
@@ -7,5 +7,7 @@ class CallState(TypedDict):
|
|
| 7 |
clean_content: Optional[str]
|
| 8 |
metadata: Optional[Dict[str, Any]]
|
| 9 |
summary: Optional[str]
|
| 10 |
-
key_points: Optional[str]
|
|
|
|
|
|
|
| 11 |
quality_scores: Optional[Dict[str, Any]]
|
|
|
|
| 1 |
+
from typing import TypedDict, Optional, Dict, Any, List
|
| 2 |
|
| 3 |
class CallState(TypedDict):
|
| 4 |
file_path: str
|
|
|
|
| 7 |
clean_content: Optional[str]
|
| 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]]
|
src/agents/IntakeAgent.py
CHANGED
|
@@ -5,7 +5,7 @@ from src.agents.CallState import CallState
|
|
| 5 |
|
| 6 |
class IntakeAgent:
|
| 7 |
def __init__(self):
|
| 8 |
-
self.llm = ChatOpenAI(model="gpt-
|
| 9 |
|
| 10 |
def __call__(self, state: CallState) -> CallState:
|
| 11 |
"""Validates input formats, cleans up profanity, extracts meta."""
|
|
@@ -17,13 +17,30 @@ class IntakeAgent:
|
|
| 17 |
if file_type == "csv":
|
| 18 |
try:
|
| 19 |
df = pd.read_csv(file_path)
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
except Exception as e:
|
| 26 |
-
state["
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
if state.get("content"):
|
| 29 |
prompt = PromptTemplate.from_template(
|
|
|
|
| 5 |
|
| 6 |
class IntakeAgent:
|
| 7 |
def __init__(self):
|
| 8 |
+
self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
|
| 9 |
|
| 10 |
def __call__(self, state: CallState) -> CallState:
|
| 11 |
"""Validates input formats, cleans up profanity, extracts meta."""
|
|
|
|
| 17 |
if file_type == "csv":
|
| 18 |
try:
|
| 19 |
df = pd.read_csv(file_path)
|
| 20 |
+
normalized = {str(c).strip().lower(): c for c in df.columns}
|
| 21 |
+
required = {"id", "transcript"}
|
| 22 |
+
missing = sorted(required - set(normalized.keys()))
|
| 23 |
+
if missing:
|
| 24 |
+
state["metadata"]["intake_error"] = (
|
| 25 |
+
f"CSV is missing required headers: {', '.join(missing)}"
|
| 26 |
+
)
|
| 27 |
+
state["content"] = ""
|
| 28 |
+
state["clean_content"] = ""
|
| 29 |
+
return state
|
| 30 |
+
|
| 31 |
+
id_col = normalized["id"]
|
| 32 |
+
transcript_col = normalized["transcript"]
|
| 33 |
+
transcripts = df[transcript_col].fillna("").astype(str).tolist()
|
| 34 |
+
state["content"] = " ".join(transcripts).strip()
|
| 35 |
+
state["metadata"]["row_count"] = int(len(df))
|
| 36 |
+
state["metadata"]["ids_preview"] = (
|
| 37 |
+
df[id_col].fillna("").astype(str).head(20).tolist()
|
| 38 |
+
)
|
| 39 |
except Exception as e:
|
| 40 |
+
state["metadata"]["intake_error"] = str(e)
|
| 41 |
+
state["content"] = ""
|
| 42 |
+
state["clean_content"] = ""
|
| 43 |
+
return state
|
| 44 |
|
| 45 |
if state.get("content"):
|
| 46 |
prompt = PromptTemplate.from_template(
|
src/agents/ModerationAgent.py
CHANGED
|
@@ -9,7 +9,7 @@ class ModerationAgent:
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
def __init__(self):
|
| 12 |
-
self.llm = ChatOpenAI(model="gpt-
|
| 13 |
|
| 14 |
def __call__(self, state: CallState) -> CallState:
|
| 15 |
text = state.get("clean_content") or state.get("content") or ""
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
def __init__(self):
|
| 12 |
+
self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
|
| 13 |
|
| 14 |
def __call__(self, state: CallState) -> CallState:
|
| 15 |
text = state.get("clean_content") or state.get("content") or ""
|
src/agents/QualityScoringAgent.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
-
import json
|
| 2 |
from langchain_openai import ChatOpenAI
|
| 3 |
from langchain_core.prompts import PromptTemplate
|
| 4 |
from src.agents.CallState import CallState
|
|
|
|
|
|
|
| 5 |
|
| 6 |
class QualityScoringAgent:
|
| 7 |
def __init__(self):
|
| 8 |
-
self.llm = ChatOpenAI(model="gpt-
|
| 9 |
|
| 10 |
def __call__(self, state: CallState) -> CallState:
|
| 11 |
"""Evaluates tone, professionalism, and structured resolution with rubric."""
|
|
@@ -16,24 +17,57 @@ class QualityScoringAgent:
|
|
| 16 |
prompt = PromptTemplate.from_template(
|
| 17 |
"Evaluate the following call transcript for tone, professionalism, and structured resolution. "
|
| 18 |
"Score each out of 10 based on a strict rubric.\n"
|
| 19 |
-
"Return
|
| 20 |
"Transcript:\n{text}"
|
| 21 |
)
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from langchain_openai import ChatOpenAI
|
| 2 |
from langchain_core.prompts import PromptTemplate
|
| 3 |
from src.agents.CallState import CallState
|
| 4 |
+
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 |
|
| 11 |
def __call__(self, state: CallState) -> CallState:
|
| 12 |
"""Evaluates tone, professionalism, and structured resolution with rubric."""
|
|
|
|
| 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"):
|
| 30 |
+
result_dict = result.model_dump()
|
| 31 |
+
else:
|
| 32 |
+
result_dict = result.dict()
|
| 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
|
| 39 |
+
for key in ["tone", "professionalism", "structured_resolution"]:
|
| 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
|
| 46 |
+
|
| 47 |
+
def _structured_llm(self):
|
| 48 |
+
# Prefer OpenAI function calling enforcement when supported by the installed LangChain version.
|
| 49 |
+
sig = None
|
| 50 |
+
try:
|
| 51 |
+
sig = inspect.signature(self.llm.with_structured_output)
|
| 52 |
+
except Exception:
|
| 53 |
+
sig = None
|
| 54 |
+
|
| 55 |
+
if sig and "method" in sig.parameters:
|
| 56 |
+
try:
|
| 57 |
+
return self.llm.with_structured_output(
|
| 58 |
+
QualityScores, method="function_calling"
|
| 59 |
+
)
|
| 60 |
+
except Exception:
|
| 61 |
+
pass
|
| 62 |
+
|
| 63 |
+
return self.llm.with_structured_output(QualityScores)
|
| 64 |
+
|
| 65 |
+
@staticmethod
|
| 66 |
+
def fallback(state: CallState) -> CallState:
|
| 67 |
+
state["quality_scores"] = {
|
| 68 |
+
"tone": None,
|
| 69 |
+
"professionalism": None,
|
| 70 |
+
"structured_resolution": None,
|
| 71 |
+
"notes": "Scoring failed (rate limit or parse error). Showing placeholders.",
|
| 72 |
+
}
|
| 73 |
+
return state
|
src/agents/Router.py
CHANGED
|
@@ -3,7 +3,22 @@ from src.agents.CallState import CallState
|
|
| 3 |
class Router:
|
| 4 |
def __call__(self, state: CallState) -> str:
|
| 5 |
"""Invokes above agents accordingly."""
|
|
|
|
|
|
|
| 6 |
file_type = state["file_type"]
|
| 7 |
if file_type in ["mp3", "wav"] and not state.get("content"):
|
| 8 |
return "transcribe"
|
| 9 |
return "summarize"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
class Router:
|
| 4 |
def __call__(self, state: CallState) -> str:
|
| 5 |
"""Invokes above agents accordingly."""
|
| 6 |
+
if state.get("metadata", {}).get("intake_error"):
|
| 7 |
+
return "end"
|
| 8 |
file_type = state["file_type"]
|
| 9 |
if file_type in ["mp3", "wav"] and not state.get("content"):
|
| 10 |
return "transcribe"
|
| 11 |
return "summarize"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class PostSummarizeRouter:
|
| 15 |
+
def __call__(self, state: CallState) -> str:
|
| 16 |
+
"""Routes based on model output quality."""
|
| 17 |
+
if state.get("metadata", {}).get("intake_error"):
|
| 18 |
+
return "end"
|
| 19 |
+
text = state.get("clean_content") or state.get("content") or ""
|
| 20 |
+
if len(text.strip()) < 40:
|
| 21 |
+
return "end"
|
| 22 |
+
if not (state.get("summary") or "").strip():
|
| 23 |
+
return "end"
|
| 24 |
+
return "score"
|
src/agents/SummarizationAgent.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
import json
|
| 2 |
from langchain_openai import ChatOpenAI
|
| 3 |
from langchain_core.prompts import PromptTemplate
|
| 4 |
from src.agents.CallState import CallState
|
|
|
|
| 5 |
|
| 6 |
class SummarizationAgent:
|
| 7 |
def __init__(self):
|
| 8 |
-
self.llm = ChatOpenAI(model="gpt-
|
| 9 |
|
| 10 |
def __call__(self, state: CallState) -> CallState:
|
| 11 |
"""Generates summaries and key points."""
|
|
@@ -15,19 +15,72 @@ class SummarizationAgent:
|
|
| 15 |
|
| 16 |
prompt = PromptTemplate.from_template(
|
| 17 |
"Summarize the following call transcript and extract key points.\n"
|
| 18 |
-
"Return
|
| 19 |
"Transcript:\n{text}"
|
| 20 |
)
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
state["summary"] =
|
| 28 |
-
state["key_points"] =
|
| 29 |
-
|
| 30 |
-
state["
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from langchain_openai import ChatOpenAI
|
| 2 |
from langchain_core.prompts import PromptTemplate
|
| 3 |
from src.agents.CallState import CallState
|
| 4 |
+
from src.agents.schemas import CallSummary
|
| 5 |
|
| 6 |
class SummarizationAgent:
|
| 7 |
def __init__(self):
|
| 8 |
+
self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
|
| 9 |
|
| 10 |
def __call__(self, state: CallState) -> CallState:
|
| 11 |
"""Generates summaries and key points."""
|
|
|
|
| 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 |
|
| 22 |
+
structured_llm = self._structured_llm()
|
| 23 |
+
chain = prompt | structured_llm
|
| 24 |
+
result = chain.invoke({"text": clean_text})
|
| 25 |
+
|
| 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 |
|
| 37 |
return state
|
| 38 |
+
|
| 39 |
+
def _structured_llm(self):
|
| 40 |
+
# Some LangChain versions support different output enforcement methods.
|
| 41 |
+
for kwargs in ({"method": "function_calling"}, {"method": "json_mode"}, {}):
|
| 42 |
+
try:
|
| 43 |
+
return self.llm.with_structured_output(CallSummary, **kwargs)
|
| 44 |
+
except TypeError:
|
| 45 |
+
continue
|
| 46 |
+
return self.llm.with_structured_output(CallSummary)
|
| 47 |
+
|
| 48 |
+
@staticmethod
|
| 49 |
+
def fallback(state: CallState) -> CallState:
|
| 50 |
+
text = state.get("clean_content") or state.get("content") or ""
|
| 51 |
+
if not text:
|
| 52 |
+
return state
|
| 53 |
+
|
| 54 |
+
# Best-effort retry using structured output with alternate enforcement methods.
|
| 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 |
+
|
| 62 |
+
for kwargs in ({"method": "function_calling"}, {"method": "json_mode"}, {}):
|
| 63 |
+
try:
|
| 64 |
+
structured_llm = llm.with_structured_output(CallSummary, **kwargs)
|
| 65 |
+
result = (prompt | structured_llm).invoke({"text": text})
|
| 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
|
| 77 |
+
except Exception:
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
# Last resort heuristic fallback that avoids breaking the UI.
|
| 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
|
src/agents/TranscriptionAgent.py
CHANGED
|
@@ -5,7 +5,7 @@ from src.agents.CallState import CallState
|
|
| 5 |
|
| 6 |
class TranscriptionAgent:
|
| 7 |
def __init__(self):
|
| 8 |
-
self.llm = ChatOpenAI(model="gpt-
|
| 9 |
|
| 10 |
def __call__(self, state: CallState) -> CallState:
|
| 11 |
"""Converts audio to text using whisper."""
|
|
|
|
| 5 |
|
| 6 |
class TranscriptionAgent:
|
| 7 |
def __init__(self):
|
| 8 |
+
self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
|
| 9 |
|
| 10 |
def __call__(self, state: CallState) -> CallState:
|
| 11 |
"""Converts audio to text using whisper."""
|
src/streamlit_app.py
CHANGED
|
@@ -45,6 +45,32 @@ def apply_material_css():
|
|
| 45 |
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
| 46 |
}
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
</style>
|
| 49 |
""", unsafe_allow_html=True)
|
| 50 |
|
|
@@ -52,6 +78,23 @@ def apply_material_css():
|
|
| 52 |
def display_results(final_state):
|
| 53 |
st.subheader("Workflow Results")
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
col1, col2 = st.columns(2)
|
| 56 |
with col1:
|
| 57 |
st.markdown("### Summary")
|
|
@@ -65,6 +108,20 @@ def display_results(final_state):
|
|
| 65 |
else:
|
| 66 |
st.write(key_points)
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
with col2:
|
| 69 |
st.markdown("### Quality Scores")
|
| 70 |
quality_scores = final_state.get("quality_scores", {})
|
|
@@ -98,7 +155,7 @@ def display_results(final_state):
|
|
| 98 |
plot_bgcolor='#1E1E1E',
|
| 99 |
font={'color': '#FFFFFF'}
|
| 100 |
)
|
| 101 |
-
st.plotly_chart(fig,
|
| 102 |
except (ValueError, TypeError):
|
| 103 |
st.write(f"**{metric.replace('_', ' ').title()}**: {quality_scores[metric]}")
|
| 104 |
|
|
@@ -108,7 +165,6 @@ def display_results(final_state):
|
|
| 108 |
st.write("No specific scores generated.", quality_scores)
|
| 109 |
|
| 110 |
st.markdown("### Metadata")
|
| 111 |
-
metadata = final_state.get("metadata", {})
|
| 112 |
if isinstance(metadata, dict) and metadata:
|
| 113 |
for k, v in metadata.items():
|
| 114 |
st.markdown(f"- **{k.replace('_', ' ').title()}**: {v}")
|
|
@@ -137,7 +193,7 @@ def main():
|
|
| 137 |
st.sidebar.header("Upload New File")
|
| 138 |
uploaded_file = st.sidebar.file_uploader(
|
| 139 |
"Upload a file",
|
| 140 |
-
type=["
|
| 141 |
on_change=set_upload_mode
|
| 142 |
)
|
| 143 |
|
|
@@ -191,15 +247,21 @@ def main():
|
|
| 191 |
"file_type": file_extension
|
| 192 |
}
|
| 193 |
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
display_results(final_state)
|
| 203 |
|
| 204 |
elif (
|
| 205 |
st.session_state.view_mode == "dropdown" or st.session_state.view_mode == "none") and selected_file is not None:
|
|
@@ -235,11 +297,18 @@ def main():
|
|
| 235 |
"file_path": file_path,
|
| 236 |
"file_type": file_extension
|
| 237 |
}
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
else:
|
| 245 |
st.info("Please upload a file or select a previously processed file.")
|
|
|
|
| 45 |
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
| 46 |
}
|
| 47 |
|
| 48 |
+
/* Expander (Transcript) - keep dark theme even on hover/focus */
|
| 49 |
+
div[data-testid="stExpander"] details,
|
| 50 |
+
div[data-testid="stExpander"] summary,
|
| 51 |
+
div[data-testid="stExpander"] summary:hover,
|
| 52 |
+
div[data-testid="stExpander"] summary:focus,
|
| 53 |
+
div[data-testid="stExpander"] summary:active,
|
| 54 |
+
div[data-testid="stExpander"] summary:focus-visible {
|
| 55 |
+
background-color: #1E1E1E !important;
|
| 56 |
+
color: #FFFFFF !important;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
div[data-testid="stExpander"] details {
|
| 60 |
+
border: 1px solid #333333 !important;
|
| 61 |
+
border-radius: 8px !important;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* Transcript text area */
|
| 65 |
+
div[data-testid="stExpander"] textarea,
|
| 66 |
+
div[data-testid="stExpander"] textarea:hover,
|
| 67 |
+
div[data-testid="stExpander"] textarea:focus,
|
| 68 |
+
div[data-testid="stExpander"] textarea:active {
|
| 69 |
+
background-color: #121212 !important;
|
| 70 |
+
color: #FFFFFF !important;
|
| 71 |
+
border-color: #333333 !important;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
</style>
|
| 75 |
""", unsafe_allow_html=True)
|
| 76 |
|
|
|
|
| 78 |
def display_results(final_state):
|
| 79 |
st.subheader("Workflow Results")
|
| 80 |
|
| 81 |
+
metadata = final_state.get("metadata", {})
|
| 82 |
+
if isinstance(metadata, dict) and metadata.get("intake_error"):
|
| 83 |
+
st.error(f"CSV validation failed: {metadata['intake_error']}")
|
| 84 |
+
st.info("This CSV was not accepted for processing. Please fix the headers and re-upload.")
|
| 85 |
+
st.markdown("### Metadata")
|
| 86 |
+
for k, v in metadata.items():
|
| 87 |
+
st.markdown(f"- **{k.replace('_', ' ').title()}**: {v}")
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
st.markdown("### Transcript")
|
| 91 |
+
transcript_text = final_state.get("clean_content") or final_state.get("content") or ""
|
| 92 |
+
if transcript_text:
|
| 93 |
+
with st.expander("View transcript", expanded=False):
|
| 94 |
+
st.text_area("Transcript", transcript_text, height=220)
|
| 95 |
+
else:
|
| 96 |
+
st.write("No transcript available.")
|
| 97 |
+
|
| 98 |
col1, col2 = st.columns(2)
|
| 99 |
with col1:
|
| 100 |
st.markdown("### Summary")
|
|
|
|
| 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 []
|
| 114 |
+
if tags:
|
| 115 |
+
st.write("**Tags:** " + ", ".join([str(t) for t in tags]))
|
| 116 |
+
else:
|
| 117 |
+
st.write("**Tags:** None")
|
| 118 |
+
if highlights:
|
| 119 |
+
st.write("**Highlights:**")
|
| 120 |
+
for h in highlights:
|
| 121 |
+
st.markdown(f"- {h}")
|
| 122 |
+
else:
|
| 123 |
+
st.write("**Highlights:** None")
|
| 124 |
+
|
| 125 |
with col2:
|
| 126 |
st.markdown("### Quality Scores")
|
| 127 |
quality_scores = final_state.get("quality_scores", {})
|
|
|
|
| 155 |
plot_bgcolor='#1E1E1E',
|
| 156 |
font={'color': '#FFFFFF'}
|
| 157 |
)
|
| 158 |
+
st.plotly_chart(fig, width="stretch")
|
| 159 |
except (ValueError, TypeError):
|
| 160 |
st.write(f"**{metric.replace('_', ' ').title()}**: {quality_scores[metric]}")
|
| 161 |
|
|
|
|
| 165 |
st.write("No specific scores generated.", quality_scores)
|
| 166 |
|
| 167 |
st.markdown("### Metadata")
|
|
|
|
| 168 |
if isinstance(metadata, dict) and metadata:
|
| 169 |
for k, v in metadata.items():
|
| 170 |
st.markdown(f"- **{k.replace('_', ' ').title()}**: {v}")
|
|
|
|
| 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 |
|
|
|
|
| 247 |
"file_type": file_extension
|
| 248 |
}
|
| 249 |
|
| 250 |
+
thread_id = Path(file_path).name
|
| 251 |
+
final_state = workflow.invoke(
|
| 252 |
+
initial_state, config={"configurable": {"thread_id": thread_id}}
|
| 253 |
+
)
|
| 254 |
|
| 255 |
+
if final_state.get("metadata", {}).get("intake_error"):
|
| 256 |
+
display_results(final_state)
|
| 257 |
+
else:
|
| 258 |
+
# Cache the results
|
| 259 |
+
with open(cached_json_path, 'w') as f:
|
| 260 |
+
json.dump(final_state, f)
|
| 261 |
+
st.rerun()
|
| 262 |
|
| 263 |
+
if not final_state.get("metadata", {}).get("intake_error"):
|
| 264 |
+
display_results(final_state)
|
|
|
|
| 265 |
|
| 266 |
elif (
|
| 267 |
st.session_state.view_mode == "dropdown" or st.session_state.view_mode == "none") and selected_file is not None:
|
|
|
|
| 297 |
"file_path": file_path,
|
| 298 |
"file_type": file_extension
|
| 299 |
}
|
| 300 |
+
thread_id = Path(file_path).name
|
| 301 |
+
final_state = workflow.invoke(
|
| 302 |
+
initial_state, config={"configurable": {"thread_id": thread_id}}
|
| 303 |
+
)
|
| 304 |
+
if final_state.get("metadata", {}).get("intake_error"):
|
| 305 |
+
display_results(final_state)
|
| 306 |
+
else:
|
| 307 |
+
with open(cached_json_path, 'w') as f:
|
| 308 |
+
json.dump(final_state, f)
|
| 309 |
+
st.rerun()
|
| 310 |
+
if not final_state.get("metadata", {}).get("intake_error"):
|
| 311 |
+
display_results(final_state)
|
| 312 |
|
| 313 |
else:
|
| 314 |
st.info("Please upload a file or select a previously processed file.")
|
src/workflow.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
from langgraph.graph import StateGraph, END
|
|
|
|
| 2 |
from src.agents import (
|
| 3 |
CallState,
|
| 4 |
IntakeAgent,
|
|
@@ -8,6 +9,7 @@ from src.agents import (
|
|
| 8 |
ModerationAgent,
|
| 9 |
Router
|
| 10 |
)
|
|
|
|
| 11 |
|
| 12 |
def build_workflow():
|
| 13 |
workflow = StateGraph(CallState)
|
|
@@ -19,13 +21,20 @@ def build_workflow():
|
|
| 19 |
summarization_agent = SummarizationAgent()
|
| 20 |
quality_scoring_agent = QualityScoringAgent()
|
| 21 |
router = Router()
|
|
|
|
| 22 |
|
| 23 |
# Add nodes
|
| 24 |
workflow.add_node("intake", intake_agent)
|
| 25 |
workflow.add_node("transcribe", transcription_agent)
|
| 26 |
workflow.add_node("moderate", moderation_agent)
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
# Define entry point
|
| 31 |
workflow.set_entry_point("intake")
|
|
@@ -36,14 +45,25 @@ def build_workflow():
|
|
| 36 |
router,
|
| 37 |
{
|
| 38 |
"transcribe": "transcribe",
|
| 39 |
-
"summarize": "moderate"
|
|
|
|
| 40 |
}
|
| 41 |
)
|
| 42 |
|
| 43 |
# Add standard edges
|
| 44 |
workflow.add_edge("transcribe", "moderate")
|
| 45 |
workflow.add_edge("moderate", "summarize")
|
| 46 |
-
workflow.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
workflow.add_edge("score", END)
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from langgraph.graph import StateGraph, END
|
| 2 |
+
from langchain_core.runnables import RunnableLambda
|
| 3 |
from src.agents import (
|
| 4 |
CallState,
|
| 5 |
IntakeAgent,
|
|
|
|
| 9 |
ModerationAgent,
|
| 10 |
Router
|
| 11 |
)
|
| 12 |
+
from src.agents.Router import PostSummarizeRouter
|
| 13 |
|
| 14 |
def build_workflow():
|
| 15 |
workflow = StateGraph(CallState)
|
|
|
|
| 21 |
summarization_agent = SummarizationAgent()
|
| 22 |
quality_scoring_agent = QualityScoringAgent()
|
| 23 |
router = Router()
|
| 24 |
+
post_summarize_router = PostSummarizeRouter()
|
| 25 |
|
| 26 |
# Add nodes
|
| 27 |
workflow.add_node("intake", intake_agent)
|
| 28 |
workflow.add_node("transcribe", transcription_agent)
|
| 29 |
workflow.add_node("moderate", moderation_agent)
|
| 30 |
+
summarize_node = RunnableLambda(summarization_agent).with_fallbacks(
|
| 31 |
+
[RunnableLambda(SummarizationAgent.fallback)]
|
| 32 |
+
)
|
| 33 |
+
score_node = RunnableLambda(quality_scoring_agent).with_fallbacks(
|
| 34 |
+
[RunnableLambda(QualityScoringAgent.fallback)]
|
| 35 |
+
)
|
| 36 |
+
workflow.add_node("summarize", summarize_node)
|
| 37 |
+
workflow.add_node("score", score_node)
|
| 38 |
|
| 39 |
# Define entry point
|
| 40 |
workflow.set_entry_point("intake")
|
|
|
|
| 45 |
router,
|
| 46 |
{
|
| 47 |
"transcribe": "transcribe",
|
| 48 |
+
"summarize": "moderate",
|
| 49 |
+
"end": END
|
| 50 |
}
|
| 51 |
)
|
| 52 |
|
| 53 |
# Add standard edges
|
| 54 |
workflow.add_edge("transcribe", "moderate")
|
| 55 |
workflow.add_edge("moderate", "summarize")
|
| 56 |
+
workflow.add_conditional_edges(
|
| 57 |
+
"summarize",
|
| 58 |
+
post_summarize_router,
|
| 59 |
+
{"score": "score", "end": END},
|
| 60 |
+
)
|
| 61 |
workflow.add_edge("score", END)
|
| 62 |
+
|
| 63 |
+
# Compile with a MemorySaver checkpointer when available.
|
| 64 |
+
try:
|
| 65 |
+
from langgraph.checkpoint.memory import MemorySaver
|
| 66 |
+
|
| 67 |
+
return workflow.compile(checkpointer=MemorySaver())
|
| 68 |
+
except Exception:
|
| 69 |
+
return workflow.compile()
|