presentercoach / apps /api /src /app /scoring.py
nathycodes's picture
Deploy Tech Presenter Coach
ec17af3
Raw History Blame Contribute Delete
6.53 kB
import re
from typing import List
FILLER_WORDS = ["um", "uh", "like", "you know", "actually", "basically", "literally"]
STRUCTURE_WORDS = ["first", "second", "third", "because", "therefore", "however", "so", "thus"]
def tokenize(text: str) -> List[str]:
return [w for w in re.sub(r"[^a-z0-9\s']", " ", text.lower()).split() if w]
def count_words(segments: List[dict]) -> int:
return sum(len(tokenize(seg["text"])) for seg in segments)
def count_fillers(segments: List[dict]) -> int:
text = " ".join(seg["text"].lower() for seg in segments)
total = 0
for filler in FILLER_WORDS:
escaped = re.escape(filler).replace(" ", r"\s+")
pattern = re.compile(rf"\b{escaped}\b")
total += len(pattern.findall(text))
return total
def compute_wpm(word_count: int, duration_ms: int) -> float:
if duration_ms <= 0:
return 0
return round((word_count / (duration_ms / 60000)) * 10) / 10
def score_wpm(wpm: float) -> int:
if 120 <= wpm <= 160:
return 18
if 100 <= wpm < 120 or 160 < wpm <= 180:
return 10
return 4
def score_fillers(fillers_per_minute: float) -> int:
if fillers_per_minute <= 2:
return 12
if fillers_per_minute <= 6:
return 8
return 3
def score_pauses(pause_events: List[int], duration_ms: int) -> int:
if not pause_events:
return 4
natural = len([p for p in pause_events if 600 <= p <= 1500])
long = len([p for p in pause_events if p > 1500])
minutes = max(duration_ms / 60000, 0.1)
rate = natural / minutes
if long > max(2, minutes):
return 4
if 2 <= rate <= 6:
return 10
if rate >= 1:
return 7
return 5
def score_eye_contact(eye_contact_pct: float) -> int:
if eye_contact_pct > 0.7:
return 15
if eye_contact_pct >= 0.4:
return 10
return 5
def score_smile(smile_avg: float) -> int:
return round(7 * max(0, min(1, smile_avg)))
def score_head_stability(yaw_var: float, pitch_var: float, roll_var: float) -> int:
total_var = yaw_var + pitch_var + roll_var
if total_var < 5:
return 8
if total_var < 12:
return 6
return 4
def score_structure(segments: List[dict]) -> int:
words = tokenize(" ".join(seg["text"] for seg in segments))
count = sum(1 for w in words if w in STRUCTURE_WORDS)
if count >= 4:
return 10
if count >= 2:
return 7
return 4
def score_repetition(segments: List[dict]) -> int:
words = tokenize(" ".join(seg["text"] for seg in segments))
freq = {}
for word in words:
freq[word] = freq.get(word, 0) + 1
repeated = len([c for c in freq.values() if c >= 6])
if repeated >= 3:
return -8
if repeated >= 1:
return -4
return 0
def score_clarity(segments: List[dict]) -> int:
sentences = [s.strip() for s in re.split(r"[.!?]+", " ".join(seg["text"] for seg in segments)) if s.strip()]
if not sentences:
return 0
lengths = [len(tokenize(s)) for s in sentences]
avg = sum(lengths) / len(lengths)
if 8 <= avg <= 20:
return 12
if 5 <= avg <= 25:
return 8
return 4
def score_keyword_coverage(segments: List[dict], keywords: List[str]) -> int:
if not keywords:
return 0
text = " ".join(seg["text"].lower() for seg in segments)
covered = sum(1 for k in keywords if k.lower() in text)
ratio = covered / len(keywords)
if ratio >= 0.8:
return 8
if ratio >= 0.5:
return 5
return 2
def avg(values: List[float]) -> float:
return sum(values) / len(values) if values else 0
def variance(values: List[float]) -> float:
if not values:
return 0
mean = avg(values)
return avg([(v - mean) ** 2 for v in values])
def compute_score(segments: List[dict], metrics: List[dict], duration_ms: int, keywords: List[str]):
word_count = count_words(segments)
wpm = compute_wpm(word_count, duration_ms)
filler_count = count_fillers(segments)
minutes = max(duration_ms / 60000, 0.1)
fillers_per_minute = filler_count / minutes
pause_events = [m.get("pause_ms", 0) for m in metrics if m.get("pause_ms", 0) > 0]
wpm_score = score_wpm(wpm)
filler_score = score_fillers(fillers_per_minute)
pause_score = score_pauses(pause_events, duration_ms)
speech = wpm_score + filler_score + pause_score
eye_contact_pct = avg([m.get("eye_contact", 0) for m in metrics])
smile_avg = avg([m.get("smile", 0) for m in metrics])
yaw_var = variance([m.get("yaw", 0) for m in metrics])
pitch_var = variance([m.get("pitch", 0) for m in metrics])
roll_var = variance([m.get("roll", 0) for m in metrics])
eye_score = score_eye_contact(eye_contact_pct)
smile_score = score_smile(smile_avg)
head_score = score_head_stability(yaw_var, pitch_var, roll_var)
delivery = eye_score + smile_score + head_score
structure_score = score_structure(segments)
repetition_score = score_repetition(segments)
clarity_score = score_clarity(segments)
keyword_score = score_keyword_coverage(segments, keywords)
content = max(0, structure_score + repetition_score + clarity_score + keyword_score)
total = max(0, min(100, speech + delivery + content))
fixes = []
if wpm_score <= 10:
fixes.append("Adjust pace to 120-160 WPM.")
if filler_score <= 8:
fixes.append("Reduce filler words with pauses.")
if eye_score <= 10:
fixes.append("Increase eye contact with the lens.")
if clarity_score <= 8:
fixes.append("Shorten sentences for clarity.")
if keyword_score <= 5 and keywords:
fixes.append("Cover target keywords.")
drill = (
"60-second pace drill"
if speech < delivery and speech < content
else "Eye contact mirror drill"
if delivery < content
else "Story arc outline drill"
)
return {
"total": total,
"speech": speech,
"delivery": delivery,
"content": content,
"details": {
"wpm": wpm_score,
"fillers": filler_score,
"pauses": pause_score,
"eyeContact": eye_score,
"smile": smile_score,
"headStability": head_score,
"structure": structure_score,
"repetition": repetition_score,
"clarity": clarity_score,
"keywordCoverage": keyword_score,
},
"topFixes": fixes[:3],
"recommendedDrill": drill,
}