Skip to main content

Overview

The Service Classification Agent analyzes emergency call transcripts to determine which service(s) to dispatch. It uses sophisticated keyword detection with negation handling, ASR error correction, and multi-service detection.

Source Code

Location: app/agents/service_classify.py (1301 lines)

Service Categories

ServiceCategory = Literal["EMS", "FIRE", "POLICE", "OTHER"]
Every call is classified into one of these categories:
  • EMS - Medical emergencies (cardiac arrest, trauma, overdose, etc.)
  • FIRE - Fire, hazmat, gas leaks, explosions, rescue
  • POLICE - Crime, violence, threats, suspicious activity
  • OTHER - Non-emergency or unclear situations

Public API

classify_service_and_tags()

def classify_service_and_tags(
    transcript: Optional[str],
    distress: float,
) -> Dict[str, Any]:
    """
    Heuristic classifier for:
      - service category: EMS / FIRE / POLICE / OTHER
      - semantic tags: TRAUMA, ACTIVE_SHOOTER, OVERDOSE, etc.
      - category_confidence: 0.0 - 1.0
    
    Uses keyword rules + distress level with improved robustness:
    - Negation detection
    - Phonetic/spelling variations
    - Multi-service emergency handling
    - ASR error tolerance
    
    Args:
        transcript: Text from speech-to-text (can be None)
        distress: Audio distress score (0.0-1.0)
        
    Returns:
        {
            "category": ServiceCategory,
            "confidence": float,  # 0.0-1.0
            "tags": list[str]     # Semantic tags like "TRAUMA", "FIRE"
        }
    """
See source code at line 8-23.

Usage Examples

Basic Classification

from app.agents.service_classify import classify_service_and_tags

result = classify_service_and_tags(
    transcript="Someone's having a heart attack!",
    distress=0.8
)

print(result)
# {
#   "category": "EMS",
#   "confidence": 0.95,
#   "tags": ["CARDIAC_EVENT", "TRAUMA"]
# }

Fire Emergency

result = classify_service_and_tags(
    transcript="There's a fire in the kitchen, smoke everywhere",
    distress=0.9
)

print(result)
# {
#   "category": "FIRE",
#   "confidence": 1.0,
#   "tags": ["FIRE", "SMOKE"]
# }

Multi-Service Emergency

result = classify_service_and_tags(
    transcript="Active shooter, multiple people shot and not breathing",
    distress=1.0
)

print(result)
# {
#   "category": "EMS",  # Primary category
#   "confidence": 0.85,
#   "tags": ["ACTIVE_SHOOTER", "NOT_BREATHING", "TRAUMA", "VIOLENCE"]
# }
# Note: ACTIVE_SHOOTER tag indicates POLICE also needed

Semantic Tags

The classifier generates detailed tags beyond the primary category:

Medical Tags

  • TRAUMA - Physical injury
  • ACTIVE_SHOOTER / STABBING - Violent trauma
  • MAJOR_BLEEDING / BLEEDING - Blood loss
  • BROKEN_BONE / FALL - Fractures
  • VEHICLE_ACCIDENT - Car crash
  • UNCONSCIOUS / FAINTING - Loss of consciousness
  • NOT_BREATHING / BREATHING_DIFFICULTY - Respiratory
  • CARDIAC_ARREST / CARDIAC_EVENT - Heart problems
  • STROKE - Neurological emergency
  • SEIZURE - Convulsions
  • OVERDOSE - Drug/medication poisoning
  • ALLERGIC_REACTION - Anaphylaxis
  • BURNS - Thermal/chemical burns
  • CHILDBIRTH / PREGNANCY_EMERGENCY - Obstetric
  • SEVERE_PAIN - Pain emergency
  • DIABETIC_EMERGENCY - Blood sugar crisis

Fire Tags

  • FIRE - Active fire
  • SMOKE - Smoke detected
  • EXPLOSION - Blast/detonation
  • GAS_LEAK - Natural gas/propane
  • HAZMAT - Hazardous materials

Police Tags

  • WEAPON_INVOLVED - Guns, knives, etc.
  • FIGHT / ASSAULT - Physical violence
  • DOMESTIC_VIOLENCE - Intimate partner violence
  • ROBBERY - Armed robbery/mugging
  • BURGLARY - Break-in/home invasion
  • THREATS - Verbal threats
  • SEXUAL_ASSAULT - Sexual violence
  • KIDNAPPING - Abduction
  • MISSING_PERSON - Missing child/person
  • SUSPICIOUS_PERSON - Stalking/lurking
  • NOISE_COMPLAINT - Disturbance
  • DISTURBANCE - Public disruption
  • DUI - Drunk driving

Mental Health Tags

  • SUICIDAL - Suicide threat/ideation
  • SELF_HARM - Self-injury
  • PANIC_ATTACK - Acute anxiety
  • MENTAL_HEALTH_CRISIS - Psychosis/breakdown
  • EMOTIONAL_DISTRESS - Grief/sadness

Rescue Tags

  • TRAPPED - Person stuck/pinned
  • FLOOD - Water emergency
  • COLLAPSE - Building collapse
  • ELECTRICAL_HAZARD - Power lines/shock

Advanced Features

Negation Detection

Prevents false positives from negated phrases:
def is_negated(phrase: str) -> bool:
    """
    Check if a phrase is negated (e.g., 'not bleeding', 'no gun')
    """
    match = re.search(re.escape(phrase), text, re.IGNORECASE)
    if not match:
        return False
    
    # Check 20 chars before for negation words
    start = max(0, match.start() - 20)
    context = text[start : match.start()]
    
    negations = [
        r"\bno\b", r"\bnot\b", r"\bnever\b",
        r"\bwithout\b", r"\bwon'?t\b", r"\bcan'?t\b",
        r"\bdidn'?t\b", r"\bisn'?t\b", r"\baren'?t\b"
    ]
    
    return any(re.search(neg, context) for neg in negations)
See line 45-71. Example:
# "no gun" → is_negated("gun") returns True
# "has a gun" → is_negated("gun") returns False

ASR Error Normalization

Corrects common speech-to-text mistakes:
def _normalize_transcript(text: str) -> str:
    """
    Normalize common ASR errors and variations in emergency transcripts.
    """
    # Common phonetic substitutions
    text = re.sub(r"\bcant\b", "can't", text)
    text = re.sub(r"\bgonna\b", "going to", text)
    
    # Emergency-specific ASR errors
    text = re.sub(r"\bam balance\b", "ambulance", text)
    text = re.sub(r"\bambulence\b", "ambulance", text)
    text = re.sub(r"\bkeep myself\b", "kill myself", text)  # Critical!
    
    return text
See line 1195-1235. Common ASR Errors:
  • “am balance” → “ambulance”
  • “keep myself” → “kill myself” (critical for suicide detection)
  • “cant breathe” → “can’t breathe”
  • “gonna” → “going to”

Context-Aware Detection

Avoids false positives using contextual clues:
# "shot" can mean basketball or gunshot
if has_word_any(gunshot_keywords) and not is_negated("shot"):
    # Context: if "shoot" appears with basketball/sports, reduce confidence
    if not has_any(["basketball", "hoops", "game", "sport", "court"]):
        add_tag("ACTIVE_SHOOTER")
        bump("EMS", 0.9, urgent=True)
        bump("POLICE", 0.9, urgent=True)
See line 130-139. More examples:
  • “cutting” + “kitchen” → Not a stabbing
  • “burning pain” without “fire” → Not a fire emergency
  • “fire department” → Not an active fire

Multi-Service Detection

Some emergencies require multiple services:
# Track if multiple services clearly needed
service_needed: Set[str] = set()

def bump(cat: str, amount: float, urgent: bool = False):
    """Add score and track if service is clearly needed"""
    cat_scores[cat] += amount
    if urgent or amount >= 0.7:
        service_needed.add(cat)

# Active shooter needs both POLICE and EMS
if "ACTIVE_SHOOTER" in tags:
    service_needed.update(["POLICE", "EMS"])

# Explosion needs FIRE, EMS, and POLICE
if "EXPLOSION" in tags:
    service_needed.update(["FIRE", "EMS", "POLICE"])
See line 79-89, 1154-1159.

Classification Logic

Scoring System

Each keyword detection adds to category scores:
cat_scores = {"EMS": 0.0, "FIRE": 0.0, "POLICE": 0.0, "OTHER": 0.0}

# Example: Gunshot detection
add_tag("ACTIVE_SHOOTER")
bump("EMS", 0.9, urgent=True)     # Medical attention needed
bump("POLICE", 0.9, urgent=True)  # Law enforcement needed

# Example: Heart attack
add_tag("CARDIAC_EVENT")
bump("EMS", 0.9, urgent=True)

# Example: Fire
add_tag("FIRE")
bump("FIRE", 1.0, urgent=True)
See line 76.

Urgency Levels

  • HIGH (1.0) - Immediately life-threatening
  • MEDIUM (0.6-0.8) - Serious but not immediately critical
  • LOW (0.2-0.4) - Non-urgent or information gathering

Distress Amplification

High audio distress boosts urgency:
if distress >= 0.7:
    # Boost any medical/trauma tags
    medical_tags = {"TRAUMA", "CARDIAC_ARREST", "NOT_BREATHING", ...}
    if tags & medical_tags:
        bump("EMS", 0.3)
    
    # Boost violence/weapon tags
    violence_tags = {"VIOLENCE", "ACTIVE_SHOOTER", "WEAPON_INVOLVED", ...}
    if tags & violence_tags:
        bump("POLICE", 0.2)
See line 1106-1135.

Category Selection

# Choose primary category with highest score
category = max(cat_scores.items(), key=lambda kv: kv[1])[0]
max_score = cat_scores[category]
See line 1174-1175.

Confidence Calculation

Multi-factor confidence scoring:
def _calculate_confidence(
    max_score: float,
    second_best_score: float,
    num_tags: int,
    distress: float,
    multi_service: bool,
) -> float:
    """
    Calculate confidence score based on multiple factors.
    
    Higher confidence when:
    - Clear separation between top categories
    - Multiple supporting tags found
    - Distress level aligns with detected emergency
    - Single clear service needed (not ambiguous multi-service)
    
    Returns: float between 0.0 and 1.0
    """
    base_confidence = min(1.0, max_score)
    
    # Separation bonus: if winner clearly ahead
    separation = max_score - second_best_score
    if separation >= 0.5:
        separation_bonus = 0.1
    elif separation >= 0.3:
        separation_bonus = 0.05
    else:
        separation_bonus = 0.0
    
    # Tag support: more tags = more confidence
    if num_tags >= 3:
        tag_bonus = 0.1
    elif num_tags >= 2:
        tag_bonus = 0.05
    else:
        tag_bonus = 0.0
    
    # Distress alignment
    if max_score >= 0.7 and distress >= 0.6:
        distress_bonus = 0.05
    elif max_score >= 0.7 and distress <= 0.3:
        distress_bonus = -0.1  # Suspicious mismatch
    else:
        distress_bonus = 0.0
    
    # Multi-service penalty: ambiguous situations
    multi_service_penalty = -0.05 if multi_service else 0.0
    
    confidence = (
        base_confidence + separation_bonus + tag_bonus + 
        distress_bonus + multi_service_penalty
    )
    
    return max(0.0, min(1.0, confidence))
See line 1238-1300.

Keyword Examples

EMS - Gunshot/Shooting

gunshot_keywords = [
    "shot", "shooting", "gunshot", "gun shot",
    "shoot", "shoots", "bullet", "bullets",
    "shot me", "shot him", "shot her",
    "fired a gun", "fired at"
]

shooting_phrases = [
    "active shooter", "shots fired", "gunfire",
    "someone shot", "got shot", "been shot",
    "heard shots", "heard gunshots"
]
See line 95-128.

EMS - Cardiac

cardiac_arrest = [
    "cardiac arrest", "no pulse",
    "can't find a pulse", "no heartbeat",
    "heart stopped", "flatline"
]

heart_attack = [
    "heart attack", "chest pain", "chest pains",
    "pressure in chest", "squeezing chest",
    "crushing chest", "tight chest",
    "heart hurt", "heart hurts"
]
See line 362-391.

EMS - Breathing

breathing_critical = [
    "can't breathe", "cannot breathe",
    "not breathing", "stopped breathing",
    "no breath", "no air",
    "turning blue", "blue lips"
]

breathing_difficulty = [
    "hard to breathe", "difficulty breathing",
    "shortness of breath", "gasping",
    "can't catch my breath"
]
See line 312-350.

FIRE - Fire/Smoke

fire_phrases = [
    "on fire", "house fire", "building fire",
    "car fire", "flames", "burning building",
    "fire spreading"
]

smoke_phrases = [
    "smoke", "smoking", "smells like smoke",
    "smoke detector", "smoke alarm",
    "smell burning", "burning smell"
]
See line 586-620.

POLICE - Violence

weapon_phrases = [
    "has a gun", "with a gun",
    "holding a gun", "pulled a gun",
    "pointing a gun", "threatened with"
]

assault_phrases = [
    "beating me", "beat me up",
    "attacked me", "attacking me",
    "assaulted", "assault"
]
See line 686-718.

Mental Health - Suicide

suicide_phrases = [
    "kill myself",
    "keep myself",  # ASR error: commonly mishears "kill" as "keep"
    "want to die", "wanna die",
    "suicidal", "suicide",
    "end my life",
    "and my life",  # ASR error: "end" → "and"
    "can't go on", "better off dead"
]
See line 915-944.
The classifier includes ASR error variants like “keep myself” (mishears “kill myself”) - critical for suicide detection!

Testing

Unit Tests

import pytest
from app.agents.service_classify import classify_service_and_tags

def test_classify_medical():
    result = classify_service_and_tags(
        "Someone's having a heart attack",
        distress=0.8
    )
    assert result['category'] == 'EMS'
    assert 'CARDIAC_EVENT' in result['tags']
    assert result['confidence'] > 0.7

def test_classify_fire():
    result = classify_service_and_tags(
        "House is on fire, smoke everywhere",
        distress=0.9
    )
    assert result['category'] == 'FIRE'
    assert 'FIRE' in result['tags']
    assert 'SMOKE' in result['tags']

def test_negation():
    result = classify_service_and_tags(
        "No gun, just an argument",
        distress=0.3
    )
    # Should NOT detect weapon
    assert 'WEAPON_INVOLVED' not in result['tags']

def test_multi_service():
    result = classify_service_and_tags(
        "Active shooter, multiple people down not breathing",
        distress=1.0
    )
    # Should detect need for both POLICE and EMS
    assert result['category'] in ['EMS', 'POLICE']
    assert 'ACTIVE_SHOOTER' in result['tags']
    assert 'NOT_BREATHING' in result['tags']

Edge Cases

def test_empty_transcript():
    result = classify_service_and_tags("", distress=0.0)
    assert result['category'] == 'OTHER'
    assert result['confidence'] == 0.0
    assert result['tags'] == []

def test_context_false_positive():
    # "shot" in basketball context
    result = classify_service_and_tags(
        "He shot the ball at the hoop and missed",
        distress=0.1
    )
    assert 'ACTIVE_SHOOTER' not in result['tags']

Performance

Execution Time

  • Typical: 20-50ms
  • Complex (many keywords): 50-100ms
  • Max: < 200ms

Memory Usage

  • Minimal (pure Python, no ML models)
  • ~1-2 KB per classification
The heuristic classifier is extremely fast because it uses no ML models - just regex and string matching. Perfect for real-time streaming.

Next Steps

Emotion Analysis

Emotional state classification

Summary Generation

AI-generated summaries

NLP Track

Complete text processing pipeline

Build docs developers (and LLMs) love