Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies = [
"waitress>=3.0",
"duckdb>=1.0",
"numpy>=1.26",
"pyyaml>=6.0",
]

[project.optional-dependencies]
Expand Down Expand Up @@ -59,6 +60,7 @@ packages = ["src/pitwall"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
python_files = ["test_*.py"]
addopts = "--cov=src/pitwall --cov-report=term-missing --cov-report=xml --tb=short"

Expand Down
14 changes: 14 additions & 0 deletions src/pitwall/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,20 @@ def log_llm_friction(rec: dict) -> None:

# ── Session helpers ────────────────────────────────────────────────────────────

def session_exists(sid: str) -> bool:
"""Check whether the session row exists, even if it has no telemetry yet."""
if not state.has_duckdb:
return False
try:
with db_conn() as conn:
row = conn.execute(
"SELECT 1 FROM sessions WHERE session_id = ? LIMIT 1", [sid],
).fetchone()
except DuckDbUnavailable:
return False
return row is not None


def session_has_telemetry(sid: str) -> bool:
"""Check if a session has any telemetry frames."""
if not state.has_duckdb:
Expand Down
31 changes: 30 additions & 1 deletion src/pitwall/features/coaching/bp_coaching.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,32 @@ def _score_insights(bursts):
for i,ins in enumerate(insights[:3],1): ins["rank"]=i
return insights[:3]


def _normalize_debrief_bundle(bundle: dict) -> dict:
"""Expose a stable debrief contract to the PWA.

`session_analyzer` historically returned `narrative_md` + `next_focus`,
while the PWA's coach store expects `narrative` + `focus`. Keep the
legacy keys intact, but always populate the frontend-facing aliases so
fallback/non-ADK paths render instead of silently disappearing.
"""
narrative = bundle.get("narrative")
if not isinstance(narrative, str) or not narrative.strip():
legacy_narrative = bundle.get("narrative_md")
narrative = legacy_narrative if isinstance(legacy_narrative, str) else ""

focus = bundle.get("focus")
if not isinstance(focus, list):
focus = bundle.get("next_focus")
if isinstance(focus, list):
focus = [str(item).strip() for item in focus if str(item).strip()][:3]
else:
focus = []

bundle["narrative"] = narrative
bundle["focus"] = focus
return bundle

@bp.route("/insights", methods=["GET"])
def get_insights():
"""Return top-3 prioritised driver insights from the current session bursts."""
Expand Down Expand Up @@ -115,6 +141,7 @@ def coach_debrief():
frames = load_session_frames(sid)
if not frames: return jsonify({"error":"no telemetry — push frames or pass vbo_path","session_id":sid}), 400
bundle = analyze_session(session_id=sid, frames=frames, coach=state.coach if state.has_coach else None, driver_level=getattr(state.coach,"driver_level","intermediate") if state.coach else "intermediate")
bundle = _normalize_debrief_bundle(bundle)
if state.has_adk:
try:
adk_prompt = f"Generate a post-session debrief for session '{sid}', driver '{driver_id}'. Query DuckDB for lap times, corner grades, coaching notes. Structure: 1 highlight sentence, then FOCUS list of 3 items."
Expand All @@ -132,7 +159,9 @@ def coach_debrief():

adk_narrative, _em_val = extract_emotion(adk_narrative)
if _em_val != "neutral": bundle["emotion"] = _em_val
bundle["narrative"]=adk_narrative; bundle["narrative_source"]="adk"
if adk_narrative.strip():
bundle["narrative"] = adk_narrative
bundle["narrative_source"] = "adk"
except (ConnectionError, TimeoutError, OSError, RuntimeError, json.JSONDecodeError) as _e:
log.warning("ADK debrief failed (%s: %s)", type(_e).__name__, _e)
with state.bundles_lock: state.session_bundles[sid] = bundle
Expand Down
9 changes: 7 additions & 2 deletions src/pitwall/features/session/bp_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from flask import Blueprint, request, jsonify

from pitwall.state import state, SIM_DIR
from pitwall.db import db_conn, DuckDbUnavailable, session_has_telemetry
from pitwall.db import db_conn, DuckDbUnavailable, session_exists, session_has_telemetry
from pitwall.features.session.laps import detect_laps, lap_sectors, quantile
from pitwall.features.track.track_json import load_track_json, corner_bounds_from_track

Expand All @@ -20,8 +20,13 @@ def _laps_or_400(sid: str):

Inlined from pitwall.helpers.laps_or_400 — Flask-coupled, used only here.
"""
if not session_has_telemetry(sid):
if not session_exists(sid):
return None, (jsonify({"error": "session not found", "session_id": sid}), 404)
if not session_has_telemetry(sid):
return None, (jsonify({
"error": "no telemetry recorded yet",
"session_id": sid,
}), 409)
laps = detect_laps(sid)
if not laps:
return None, (jsonify({
Expand Down
9 changes: 7 additions & 2 deletions src/pitwall/features/track/bp_track.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import datetime
from flask import Blueprint, request, jsonify
from pitwall.state import state, SIM_DIR
from pitwall.db import db_conn, DuckDbUnavailable, session_has_telemetry, iso
from pitwall.db import db_conn, DuckDbUnavailable, session_exists, session_has_telemetry, iso
from pitwall.features.session.laps import detect_laps, lap_sectors, quantile
from pitwall.features.session.driver_profile import compute_profile
from pitwall.features.track.track_json import load_track_json, corner_bounds_from_track
Expand All @@ -15,8 +15,13 @@ def _laps_or_400(sid: str):

Inlined from pitwall.helpers.laps_or_400 — Flask-coupled, used only here.
"""
if not session_has_telemetry(sid):
if not session_exists(sid):
return None, (jsonify({"error": "session not found", "session_id": sid}), 404)
if not session_has_telemetry(sid):
return None, (jsonify({
"error": "no telemetry recorded yet",
"session_id": sid,
}), 409)
laps = detect_laps(sid)
if not laps:
return None, (jsonify({
Expand Down
1 change: 1 addition & 0 deletions src/pwa/src/app/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const viewport = useViewport()
const isPortrait = computed(() => viewport.isPortrait)

const handleGlobalKey = (e: KeyboardEvent) => {
if ((e as KeyboardEvent & { __pitwallHintTap?: boolean }).__pitwallHintTap) return
if (isPortrait.value) return // Block input in portrait

// Allow toggling pause with Escape anywhere except Title screen
Expand Down
31 changes: 30 additions & 1 deletion src/pwa/src/entities/coach/model/coachStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ export interface DebriefResponse {
scorecard?: Record<string, any>
highlights?: any[]
narrative?: string
narrative_md?: string
narrative_source?: string
emotion?: string
focus?: string[]
next_focus?: string[]
[key: string]: any
}

Expand Down Expand Up @@ -63,6 +65,32 @@ export interface ConversationRow {
recorded_at: string | null
}

function normalizeFocusItems(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value
.map((item) => String(item).trim())
.filter(Boolean)
.slice(0, 3)
}

function normalizeDebriefResponse(payload: DebriefResponse): DebriefResponse {
const narrative =
typeof payload.narrative === 'string' && payload.narrative.trim()
? payload.narrative
: typeof payload.narrative_md === 'string' && payload.narrative_md.trim()
? payload.narrative_md
: payload.narrative

const focus = normalizeFocusItems(payload.focus)
const nextFocus = focus.length > 0 ? focus : normalizeFocusItems(payload.next_focus)

return {
...payload,
narrative,
focus: nextFocus,
}
}

// ── Store ────────────────────────────────────────────────────────────────────

export const useCoachStore = defineStore('coach', {
Expand Down Expand Up @@ -156,11 +184,12 @@ export const useCoachStore = defineStore('coach', {
loading.start('Analyzing Session...', 'adk')
this.debriefError = null
try {
this.debrief = await bridge.post<DebriefResponse>('/coach/debrief', {
const response = await bridge.post<DebriefResponse>('/coach/debrief', {
session_id: opts.sessionId,
driver_id: opts.driverId ?? '',
vbo_path: opts.vboPath,
})
this.debrief = normalizeDebriefResponse(response)
} catch (e: any) {
this.debriefError = e.message ?? String(e)
console.warn('[coachStore] fetchDebrief failed:', e)
Expand Down
46 changes: 41 additions & 5 deletions src/pwa/src/features/coach-interaction/model/cueStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ export interface Cue {
timestamp: number
}

function normalizeCue(payload: unknown): Cue | null {
if (!payload || typeof payload !== 'object') return null

const raw = payload as Record<string, unknown>
const text = typeof raw.text === 'string' ? raw.text : ''
if (!text.trim()) return null

const idSource = raw.id ?? raw.phrase_id ?? raw.burst_id ?? raw.ts
const timestamp =
typeof raw.timestamp === 'number'
? raw.timestamp
: typeof raw.ts === 'number'
? raw.ts
: Date.now()

return {
id: idSource == null ? String(timestamp) : String(idSource),
text,
emotion: typeof raw.emotion === 'string' && raw.emotion.trim()
? raw.emotion
: 'neutral',
timestamp,
}
}

export const useCueStore = defineStore('cue', {
state: () => ({
activeCue: null as Cue | null,
Expand Down Expand Up @@ -39,12 +64,22 @@ export const useCueStore = defineStore('cue', {

_connect(sid: string) {
this._es = new EventSource(`${API_BASE}/cues/stream?session_id=${sid}`)

this._es.onmessage = (e) => {
const cue = JSON.parse(e.data) as Cue
this.queue.push(cue)
this._retryCount = 0 // reset on successful message

const handleCue = (e: MessageEvent) => {
try {
const cue = normalizeCue(JSON.parse(e.data))
if (!cue) return
this.queue.push(cue)
this._retryCount = 0 // reset on successful message
} catch (err) {
console.error('Failed to parse cue', err)
}
}

// The bridge emits named `cue` SSE events. Keep `onmessage` as a
// compatibility fallback for older unnamed-message streams.
this._es.addEventListener('cue', handleCue as EventListener)
this._es.onmessage = handleCue

this._es.onerror = () => {
this.activeCue = null
Expand All @@ -67,6 +102,7 @@ export const useCueStore = defineStore('cue', {
this._es?.close()
this._es = null
this._retryCount = 0
this.clearQueue()
}
}
})
18 changes: 12 additions & 6 deletions src/pwa/src/pages/pre-brief/PreBrief.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ onMounted(async () => {
const cursorIndex = ref(0) // 0-3 for goals, 4 for confirm

useKeyboard((e: KeyboardEvent) => {
if (e.key === 'w' || e.key === 'W' || e.key === ' ') {
audio.playSfx('cursor_select')
router.push('/analysis/track')
return
}

if (e.key === 'Escape' || e.key === 'Backspace' || e.key === 'b') {
audio.playSfx('cancel')
router.push('/garage')
return
}

if (phase.value !== 'goals') return

if (e.key === 'ArrowDown') {
Expand All @@ -83,12 +95,6 @@ useKeyboard((e: KeyboardEvent) => {
}
} else if (e.key === 's' || e.key === 'S') {
confirmSelection()
} else if (e.key === 'w' || e.key === 'W' || e.key === ' ') {
audio.playSfx('cursor_select')
router.push('/analysis/track')
} else if (e.key === 'Escape' || e.key === 'Backspace' || e.key === 'b') {
audio.playSfx('cancel')
router.push('/garage')
}
})

Expand Down
Loading
Loading