docs(milestone): complete v0.5-live-assist — v0.1.13 tagged, milestone release, merged to main

v0.5 (Live Assist — on-the-job voice companion) milestone complete.
4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail,
v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final
review + ship, v0.1.13 = milestone release).

16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog.
469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety).
8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed.
G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for
human legal review before assist surface go-live.

---ci---
project: praxis
phase: 3
milestone: v0.5
status: complete
requirements:
  covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09]
  partial: []
---/ci---
This commit is contained in:
Praxis CI
2026-08-04 22:35:56 +00:00
parent ba928cf3b4
commit ec397f2c65
65 changed files with 11550 additions and 77 deletions
+2
View File
@@ -14,11 +14,13 @@ import { Routes, Route } from 'react-router-dom'
import VoiceSession from './VoiceSession'
import Login from './operator/Login'
import Dashboard from './operator/Dashboard'
import AssistControl from './AssistControl'
export default function App() {
return (
<Routes>
<Route path="/" element={<VoiceSession />} />
<Route path="/assist" element={<AssistControl />} />
<Route path="/operator/login" element={<Login />} />
<Route path="/operator/dashboard" element={<Dashboard />} />
<Route path="*" element={<VoiceSession />} />
+139
View File
@@ -0,0 +1,139 @@
/**
* AssistControl — Praxis Live Assist tap-to-talk control surface (TASK-02-03, D-071).
*
* Minimal React component (~100-150 LOC — below the frontend-engineer reactivation
* threshold per PERSONAS.md §7.2). The assist control surface:
* - "Start Shift" → POST /api/assist/shift/start (declare context: path week + scenario tag)
* - "End Shift" → POST /api/assist/shift/end
* - Tap-to-talk button (hold to speak, release to send) — D-071 (no wake-word in v0.5)
* - Consent disclosure banner (D-070) — shown on shift start, dismissed by learner
*
* Routed at /assist (added to App.tsx route switch — TASK-07-02).
*/
import { useState } from 'react'
const SCENARIO_TAGS = [
'damaged-product refund',
'escalation',
'policy exception',
'multi-issue resolution',
'recovery & retention',
]
export default function AssistControl() {
const [shiftId, setShiftId] = useState<string | null>(null)
const [week, setWeek] = useState<number>(1)
const [scenarioTag, setScenarioTag] = useState<string>(SCENARIO_TAGS[0])
const [consent, setConsent] = useState<string | null>(null)
const [consentDismissed, setConsentDismissed] = useState<boolean>(false)
const [talking, setTalking] = useState<boolean>(false)
const [summary, setSummary] = useState<{ turn_count: number; guardrail_block_count: number } | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState<boolean>(false)
async function startShift() {
setLoading(true); setError(null); setSummary(null)
try {
const res = await fetch('/api/assist/shift/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path_slug: 'customer_service', scenario_tag: scenarioTag }),
})
if (res.status === 409) {
const data = await res.json()
setError(data.detail || 'Mode conflict — end the other session first.')
return
}
if (!res.ok) { setError(`shift start failed (${res.status})`); return }
const data = await res.json()
setShiftId(data.shift_id)
setWeek(data.context?.current_week ?? week)
setConsent(data.consent_disclosure)
setConsentDismissed(false)
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
async function endShift() {
if (!shiftId) return
setLoading(true); setError(null)
try {
const res = await fetch('/api/assist/shift/end', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shift_id: shiftId, outcome: 'completed' }),
})
if (!res.ok) { setError(`shift end failed (${res.status})`); return }
const data = await res.json()
setSummary({ turn_count: data.turn_count, guardrail_block_count: data.guardrail_block_count })
setShiftId(null); setConsent(null); setTalking(false)
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
// Tap-to-talk (D-071): hold to speak, release to send. The client sends audio
// over the warm WebRTC connection (opened by /api/assist/webrtc — SLICE-06).
function pressToTalk() { setTalking(true) }
function releaseToTalk() { setTalking(false) }
if (summary) {
return (
<div className="assist-summary">
<h2>Shift ended</h2>
<p>Assist turns: {summary.turn_count}</p>
<p>Guardrail blocks: {summary.guardrail_block_count}</p>
<button onClick={() => setSummary(null)}>New shift</button>
</div>
)
}
if (!shiftId) {
return (
<div className="assist-start">
<h2>Start an Assist Shift</h2>
{error && <div className="assist-error">{error}</div>}
<label>Path week
<select value={week} onChange={(e) => setWeek(Number(e.target.value))}>
{[1, 2, 3, 4, 5, 6].map((w) => <option key={w} value={w}>Week {w}</option>)}
</select>
</label>
<label>Scenario tag
<select value={scenarioTag} onChange={(e) => setScenarioTag(e.target.value)}>
{SCENARIO_TAGS.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
<button onClick={startShift} disabled={loading}>Start Shift</button>
</div>
)
}
return (
<div className="assist-active">
{consent && !consentDismissed && (
<div className="assist-consent-banner">
<p>{consent}</p>
<button onClick={() => setConsentDismissed(true)}>Got it</button>
</div>
)}
<h2>Shift active Week {week}, {scenarioTag}</h2>
{error && <div className="assist-error">{error}</div>}
<button
className="tap-to-talk"
onMouseDown={pressToTalk}
onMouseUp={releaseToTalk}
onTouchStart={pressToTalk}
onTouchEnd={releaseToTalk}
style={{ background: talking ? '#4caf50' : '#ccc' }}
>
{talking ? 'Listening… (release to send)' : 'Tap to talk'}
</button>
<button onClick={endShift} disabled={loading}>End Shift</button>
</div>
)
}