Fade0510 commited on
Commit
0aa8e87
·
1 Parent(s): b95582f

Score improvement changes

Browse files
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
- [ IntakeAgent ]
28
  |
29
  v
30
- [ Router ]
31
- / \
32
- (If Audio) / \ (If Text)
33
- v \
34
- [ TranscriptionAgent ] |
35
- \ /
36
- \ /
37
- v v
38
- [ ModerationAgent ]
39
- |
40
- v
41
- [ SummarizationAgent ]
42
- |
43
- v
44
- [ QualityScoringAgent ]
45
- |
46
- v
47
- (END)
 
48
  ```
49
 
 
 
 
 
 
50
  ### Agent Classes
51
 
52
- 1. **`IntakeAgent` (`src/agents/intake_agent.py`)**
53
  - *Entry Point*.
54
- - Reads the uploaded file, validates the file format, extracts basic metadata, and runs a first-pass profanity scrub (using an LLM).
 
55
 
56
- 2. **`Router` (`src/agents/router.py`)**
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 directly to the `SummarizationAgent`.
 
61
 
62
- 3. **`TranscriptionAgent` (`src/agents/transcription_agent.py`)**
 
 
 
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/summarization_agent.py`)**
71
  - Takes the redacted text from the `ModerationAgent`.
72
- - Generates a concise summary and extracts key points using OpenAI (`gpt-3.5-turbo`).
73
 
74
- 6. **`QualityScoringAgent` (`src/agents/quality_scoring_agent.py`)**
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
- python -m venv .venv
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 app.py
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-3.5-turbo", temperature=0)
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
- if "transcript" in df.columns:
21
- content = " ".join(df["transcript"].astype(str).tolist())
22
- else:
23
- content = df.to_string()
24
- state["content"] = content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  except Exception as e:
26
- state["content"] = f"Error reading CSV: {e}"
 
 
 
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-3.5-turbo", temperature=0)
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-3.5-turbo", temperature=0)
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 as JSON string with keys 'tone', 'professionalism', 'structured_resolution', and 'notes'.\n\n"
20
  "Transcript:\n{text}"
21
  )
22
-
23
- chain = prompt | self.llm
24
- result_text = chain.invoke({"text": clean_text}).content
25
-
26
- try:
27
- result_json = json.loads(result_text)
28
- profanity_count = clean_text.count("***")
29
- if profanity_count > 0:
30
- result_json["profanity"] = profanity_count
31
- for key in ["tone", "professionalism", "structured_resolution"]:
32
- if key in result_json and isinstance(result_json[key], (int, float)):
33
- result_json[key] = max(0, result_json[key] - 3)
34
-
35
- state["quality_scores"] = result_json
36
- except json.JSONDecodeError:
37
- state["quality_scores"] = {"raw_evaluation": result_text}
 
 
 
 
 
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-3.5-turbo", temperature=0)
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 as a JSON string with 'summary' and 'key_points' keys.\n\n"
19
  "Transcript:\n{text}"
20
  )
21
 
22
- chain = prompt | self.llm
23
- result_text = chain.invoke({"text": clean_text}).content
24
-
25
- try:
26
- result_json = json.loads(result_text)
27
- state["summary"] = result_json.get("summary", "")
28
- state["key_points"] = result_json.get("key_points", "")
29
- except json.JSONDecodeError:
30
- state["summary"] = result_text
31
- state["key_points"] = "Failed to parse JSON for key points."
 
 
 
 
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-3.5-turbo", temperature=0)
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, use_container_width=True)
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=["json", "mp3", "wav", "csv"],
141
  on_change=set_upload_mode
142
  )
143
 
@@ -191,15 +247,21 @@ def main():
191
  "file_type": file_extension
192
  }
193
 
194
- final_state = workflow.invoke(initial_state)
 
 
 
195
 
196
- # Cache the results
197
- with open(cached_json_path, 'w') as f:
198
- json.dump(final_state, f)
 
 
 
 
199
 
200
- st.rerun()
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
- final_state = workflow.invoke(initial_state)
239
- with open(cached_json_path, 'w') as f:
240
- json.dump(final_state, f)
241
- st.rerun()
242
- display_results(final_state)
 
 
 
 
 
 
 
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
- workflow.add_node("summarize", summarization_agent)
28
- workflow.add_node("score", quality_scoring_agent)
 
 
 
 
 
 
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.add_edge("summarize", "score")
 
 
 
 
47
  workflow.add_edge("score", END)
48
-
49
- return workflow.compile()
 
 
 
 
 
 
 
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()