Spaces:
Sleeping
Sleeping
skating-coach-deploy commited on
Commit ·
43bbe2b
1
Parent(s): 51da90a
deploy: sync web demo ff7cc29
Browse files- Dockerfile +5 -3
- server/app/api/sessions.py +30 -1
- server/app/legal/privacy.html +104 -0
- server/app/legal/support.html +55 -0
- server/app/main.py +18 -0
- server/app/storage/corpus.py +123 -0
- server/app/storage/sessions.py +19 -1
Dockerfile
CHANGED
|
@@ -39,9 +39,11 @@ WORKDIR /app/server
|
|
| 39 |
# Resolve + install runtime deps into an in-project .venv.
|
| 40 |
RUN uv sync --no-dev
|
| 41 |
|
| 42 |
-
# HF Spaces injects $PORT (default 7860).
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
| 45 |
EXPOSE 7860
|
| 46 |
|
| 47 |
CMD ["sh", "-c", "uv run --no-dev uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
|
|
|
| 39 |
# Resolve + install runtime deps into an in-project .venv.
|
| 40 |
RUN uv sync --no-dev
|
| 41 |
|
| 42 |
+
# HF Spaces injects $PORT (default 7860). Data dir is auto-resolved in
|
| 43 |
+
# storage/sessions.py: HF persistent storage at /data when mounted (durable —
|
| 44 |
+
# survives Space restarts, for the F1 corpus + events), else an ephemeral /tmp
|
| 45 |
+
# dir. Set SKATING_COACH_DATA_DIR to override.
|
| 46 |
+
ENV PORT=7860
|
| 47 |
EXPOSE 7860
|
| 48 |
|
| 49 |
CMD ["sh", "-c", "uv run --no-dev uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
server/app/api/sessions.py
CHANGED
|
@@ -19,7 +19,7 @@ from fastapi.responses import FileResponse
|
|
| 19 |
from pydantic import BaseModel, Field
|
| 20 |
|
| 21 |
from app.pipeline import PipelineError, run_pipeline
|
| 22 |
-
from app.storage import Session, SessionStatus, get_store
|
| 23 |
|
| 24 |
log = logging.getLogger(__name__)
|
| 25 |
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
|
@@ -186,6 +186,35 @@ async def get_session(session_id: str) -> dict:
|
|
| 186 |
return _serialize_session(session)
|
| 187 |
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
@router.get("/{session_id}/annotated.mp4")
|
| 190 |
async def get_annotated_video(session_id: str) -> FileResponse:
|
| 191 |
session = get_store().get(session_id)
|
|
|
|
| 19 |
from pydantic import BaseModel, Field
|
| 20 |
|
| 21 |
from app.pipeline import PipelineError, run_pipeline
|
| 22 |
+
from app.storage import Session, SessionStatus, corpus, get_store
|
| 23 |
|
| 24 |
log = logging.getLogger(__name__)
|
| 25 |
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
|
|
|
| 186 |
return _serialize_session(session)
|
| 187 |
|
| 188 |
|
| 189 |
+
class FeedbackIn(BaseModel):
|
| 190 |
+
"""F1 trust-flywheel: the skater's one-tap verdict on a review."""
|
| 191 |
+
|
| 192 |
+
install_id: str = Field(min_length=8, max_length=64)
|
| 193 |
+
verdict: str = Field(pattern=r"^(right|wrong)$")
|
| 194 |
+
# What it actually was, when the skater corrects us ("wrong"). Free text.
|
| 195 |
+
said: str | None = Field(default=None, max_length=120)
|
| 196 |
+
# Explicit opt-in to keep this clip for the eval corpus (internal-only).
|
| 197 |
+
retain_clip: bool = False
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@router.post("/{session_id}/feedback")
|
| 201 |
+
async def submit_feedback(session_id: str, fb: FeedbackIn) -> dict[str, bool]:
|
| 202 |
+
"""Record a review verdict + optional correction, and (on opt-in) retain the
|
| 203 |
+
clip for the eval corpus. Tolerant of an expired session — the correction is
|
| 204 |
+
always logged; only the clip copy needs the session to still hold a file, so
|
| 205 |
+
this never 404s (the anonymous verdict already rode the M1 event stream)."""
|
| 206 |
+
session = get_store().get(session_id)
|
| 207 |
+
retained = corpus.record_feedback(
|
| 208 |
+
install_id=fb.install_id,
|
| 209 |
+
session_id=session_id,
|
| 210 |
+
verdict=fb.verdict,
|
| 211 |
+
said=fb.said,
|
| 212 |
+
retain_clip=fb.retain_clip,
|
| 213 |
+
session=session,
|
| 214 |
+
)
|
| 215 |
+
return {"recorded": True, "retained": retained}
|
| 216 |
+
|
| 217 |
+
|
| 218 |
@router.get("/{session_id}/annotated.mp4")
|
| 219 |
async def get_annotated_video(session_id: str) -> FileResponse:
|
| 220 |
session = get_store().get(session_id)
|
server/app/legal/privacy.html
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html><head><meta charset='utf-8'><title>privacy_policy</title><style>
|
| 2 |
+
body{font:15px/1.6 -apple-system,Helvetica,sans-serif;color:#1b2330;max-width:960px;margin:40px auto;padding:0 24px;background:#faf7f2}
|
| 3 |
+
h1{color:#1b2330;border-bottom:3px solid #e8604c;padding-bottom:8px}
|
| 4 |
+
h2{color:#1b2330;margin-top:2em} h3{color:#e8604c;margin-top:1.8em}
|
| 5 |
+
table{border-collapse:collapse;width:100%;font-size:13.5px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
| 6 |
+
th,td{border:1px solid #e3ded6;padding:7px 10px;text-align:left;vertical-align:top}
|
| 7 |
+
th{background:#1b2330;color:#fff} tr:nth-child(even){background:#f6f2ea}
|
| 8 |
+
code{background:#eee9e0;padding:1px 5px;border-radius:4px;font-size:12.5px}
|
| 9 |
+
strong{color:#b3402f} li{margin:3px 0}
|
| 10 |
+
blockquote{border-left:3px solid #e8604c;margin-left:0;padding-left:14px;color:#555}
|
| 11 |
+
</style></head><body><h1>Glide — Privacy Policy</h1>
|
| 12 |
+
<p><em>Last updated: 18 June 2026</em></p>
|
| 13 |
+
<p>Glide ("Glide", "we", "us") is a figure-skating coaching app that analyzes a
|
| 14 |
+
short video of your skating and shows you technique feedback. This policy
|
| 15 |
+
explains exactly what data the app handles, why, and the choices you have. It is
|
| 16 |
+
written to describe what the app <strong>actually does today</strong> — no more, no less.</p>
|
| 17 |
+
<h2>The short version</h2>
|
| 18 |
+
<ul>
|
| 19 |
+
<li>We do <strong>not</strong> ask you to create an account. There is no name, email, or login.</li>
|
| 20 |
+
<li>When you ask for a review, your <strong>video clip is uploaded to our server</strong> to be
|
| 21 |
+
analyzed, then the coaching result is sent back to your phone.</li>
|
| 22 |
+
<li>Your past reviews are stored <strong>on your device</strong>, not on our servers.</li>
|
| 23 |
+
<li>The only identifier we use is a <strong>random, anonymous install ID</strong> so we can
|
| 24 |
+
count app usage. It is not tied to you, your device, or any other service.</li>
|
| 25 |
+
<li>You can <strong>delete all your reviews from inside the app</strong> at any time.</li>
|
| 26 |
+
<li>We do <strong>not</strong> sell data, run third-party ad/analytics SDKs, or track you across
|
| 27 |
+
other apps or websites.</li>
|
| 28 |
+
</ul>
|
| 29 |
+
<h2>What we collect and why</h2>
|
| 30 |
+
<h3>1. The video you submit for a review</h3>
|
| 31 |
+
<p>When you record or import a clip and ask for a review, the app uploads that clip
|
| 32 |
+
to our coaching server (hosted on Hugging Face), which runs pose analysis and an
|
| 33 |
+
AI critique and returns the result.</p>
|
| 34 |
+
<ul>
|
| 35 |
+
<li><strong>What it may contain:</strong> you, and anyone else who happens to be in frame
|
| 36 |
+
(rinks are public — bystanders, other skaters, and <strong>possibly minors</strong> may
|
| 37 |
+
appear). Please only film where you are permitted to, and avoid submitting
|
| 38 |
+
clips that focus on other people, especially children, without their (or a
|
| 39 |
+
guardian's) permission.</li>
|
| 40 |
+
<li><strong>Retention:</strong> the uploaded source clip is processed and held only on the
|
| 41 |
+
server's temporary working storage. It is <strong>not</strong> placed in a durable
|
| 42 |
+
database, and it is cleared when the server restarts (which happens routinely).
|
| 43 |
+
Clips submitted via a pasted link are deleted from the server immediately after
|
| 44 |
+
processing. We are not building a permanent library of your clips.</li>
|
| 45 |
+
<li><strong>On your device:</strong> the finished review (the clip plus the coaching) is saved
|
| 46 |
+
locally on your phone so you can reopen it. This local copy never leaves your
|
| 47 |
+
device unless you explicitly share it.</li>
|
| 48 |
+
</ul>
|
| 49 |
+
<h3>2. Anonymous usage events</h3>
|
| 50 |
+
<p>To understand whether the app is useful (e.g. how long a review takes, how often
|
| 51 |
+
people come back), the app sends small, anonymous events to our server — for
|
| 52 |
+
example "app opened", "review ready (took N seconds)". These carry <strong>no personal
|
| 53 |
+
information</strong>. The only identifier is a random UUID generated once on your device
|
| 54 |
+
("install ID"); it is not linked to your identity, Apple ID, advertising ID, or
|
| 55 |
+
any other app.</p>
|
| 56 |
+
<h3>3. "Help improve the coach" (opt-in only)</h3>
|
| 57 |
+
<p>If a review gets your move wrong, you can tell us — and you may <strong>optionally</strong>
|
| 58 |
+
choose to share that one clip so we can use it to improve our analysis. This
|
| 59 |
+
sharing is <strong>off by default</strong> and happens only when you tick the box. Shared
|
| 60 |
+
clips are used internally to test and improve the coaching, are reviewed by a
|
| 61 |
+
person, and are <strong>never</strong> used to train a public model or sold to anyone.</p>
|
| 62 |
+
<h3>4. Profile details you enter (optional, stays on your device)</h3>
|
| 63 |
+
<p>If you enter your height (to convert measurements into real-world units), it is
|
| 64 |
+
stored <strong>only on your device</strong> and is never uploaded.</p>
|
| 65 |
+
<h2>What we do NOT do</h2>
|
| 66 |
+
<ul>
|
| 67 |
+
<li>No accounts, no passwords, no email collection.</li>
|
| 68 |
+
<li>No third-party advertising or analytics SDKs.</li>
|
| 69 |
+
<li>No cross-app or cross-site tracking; we do not use the Advertising Identifier.</li>
|
| 70 |
+
<li>No selling or renting of any data.</li>
|
| 71 |
+
</ul>
|
| 72 |
+
<h2>Camera, microphone, and photo access</h2>
|
| 73 |
+
<ul>
|
| 74 |
+
<li><strong>Camera / microphone:</strong> used only while you are recording a clip to review.</li>
|
| 75 |
+
<li><strong>Photo library:</strong> used only when you pick an existing video to review.</li>
|
| 76 |
+
</ul>
|
| 77 |
+
<p>Audio is captured alongside video when you record, as part of the clip; it is
|
| 78 |
+
not analyzed separately or used for any other purpose.</p>
|
| 79 |
+
<h2>Your choices and controls</h2>
|
| 80 |
+
<ul>
|
| 81 |
+
<li><strong>Delete your data:</strong> open the <strong>Me</strong> tab → <strong>Clear all reviews</strong> to delete
|
| 82 |
+
every saved review (clip, thumbnail, and result) from your device. You can also
|
| 83 |
+
delete an individual review from its screen.</li>
|
| 84 |
+
<li><strong>Stop sharing clips:</strong> the "help improve the coach" option is per-review and
|
| 85 |
+
opt-in; simply leave it unticked.</li>
|
| 86 |
+
<li><strong>Uninstalling</strong> the app removes all locally stored reviews and your install ID.</li>
|
| 87 |
+
</ul>
|
| 88 |
+
<p>To request deletion of a clip you previously chose to share, contact us (below)
|
| 89 |
+
with the approximate date you submitted it.</p>
|
| 90 |
+
<h2>Children</h2>
|
| 91 |
+
<p>Glide is designed for <strong>adult</strong> recreational figure skaters and is not directed
|
| 92 |
+
at children. We do not knowingly collect personal information from children. If
|
| 93 |
+
you believe a clip you submitted contains a child and you would like it removed,
|
| 94 |
+
contact us and we will delete any retained copy.</p>
|
| 95 |
+
<h2>Data security & location</h2>
|
| 96 |
+
<p>Uploads use HTTPS. Coaching analysis runs on Hugging Face and GPU compute on
|
| 97 |
+
Modal; these providers process clips transiently on our behalf to produce your
|
| 98 |
+
review. We do not grant them any other rights to your data.</p>
|
| 99 |
+
<h2>Changes to this policy</h2>
|
| 100 |
+
<p>If this policy changes materially, we will update the "Last updated" date and the
|
| 101 |
+
in-app link. Because the app currently has no account or contact list, we cannot
|
| 102 |
+
notify you individually — please check this page.</p>
|
| 103 |
+
<h2>Contact</h2>
|
| 104 |
+
<p>Questions, or a deletion request: <strong>emmanuel@mexkoy.com</strong></p></body></html>
|
server/app/legal/support.html
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html><head><meta charset='utf-8'><title>support</title><style>
|
| 2 |
+
body{font:15px/1.6 -apple-system,Helvetica,sans-serif;color:#1b2330;max-width:960px;margin:40px auto;padding:0 24px;background:#faf7f2}
|
| 3 |
+
h1{color:#1b2330;border-bottom:3px solid #e8604c;padding-bottom:8px}
|
| 4 |
+
h2{color:#1b2330;margin-top:2em} h3{color:#e8604c;margin-top:1.8em}
|
| 5 |
+
table{border-collapse:collapse;width:100%;font-size:13.5px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
| 6 |
+
th,td{border:1px solid #e3ded6;padding:7px 10px;text-align:left;vertical-align:top}
|
| 7 |
+
th{background:#1b2330;color:#fff} tr:nth-child(even){background:#f6f2ea}
|
| 8 |
+
code{background:#eee9e0;padding:1px 5px;border-radius:4px;font-size:12.5px}
|
| 9 |
+
strong{color:#b3402f} li{margin:3px 0}
|
| 10 |
+
blockquote{border-left:3px solid #e8604c;margin-left:0;padding-left:14px;color:#555}
|
| 11 |
+
</style></head><body><h1>Glide — Support</h1>
|
| 12 |
+
<p><em>Glide is an AI figure-skating coach: film a field move, get the one fix drawn
|
| 13 |
+
on your body in seconds.</em></p>
|
| 14 |
+
<h2>Contact</h2>
|
| 15 |
+
<ul>
|
| 16 |
+
<li><strong>Email:</strong> emmanuel@mexkoy.com</li>
|
| 17 |
+
<li>We're a tiny team building this with early skaters — real replies, usually
|
| 18 |
+
within a couple of days.</li>
|
| 19 |
+
</ul>
|
| 20 |
+
<h2>What Glide does</h2>
|
| 21 |
+
<p>Record or import a short clip of a <strong>field move</strong> — a spiral, edge, turn, spin,
|
| 22 |
+
stroking, a glide hold — tap the skater that's you, and Glide analyzes your body
|
| 23 |
+
mechanics and shows you what to work on, traced onto your own video.</p>
|
| 24 |
+
<p>It coaches the things one phone camera can honestly see: extension, posture,
|
| 25 |
+
timing, steadiness, hold duration. When it can't be sure what a move was, it
|
| 26 |
+
shows you the <strong>measurements</strong> instead of guessing a name.</p>
|
| 27 |
+
<h2>What Glide does NOT do (yet)</h2>
|
| 28 |
+
<ul>
|
| 29 |
+
<li><strong>Jumps.</strong> A single phone angle can't reliably judge airborne rotation, so
|
| 30 |
+
Glide stays out of jumps on purpose rather than guess.</li>
|
| 31 |
+
<li><strong>Edge depth / blade tracing.</strong> Sub-blade detail isn't visible from a phone;
|
| 32 |
+
we coach the body, not the ice.</li>
|
| 33 |
+
</ul>
|
| 34 |
+
<h2>Tips for the best review</h2>
|
| 35 |
+
<ul>
|
| 36 |
+
<li>Film from roughly a <strong>45° angle</strong> to your path (not flat side-on or head-on),
|
| 37 |
+
with your <strong>whole body in frame</strong>.</li>
|
| 38 |
+
<li>Keep the clip short (a single element).</li>
|
| 39 |
+
<li>Good light helps the tracker follow you.</li>
|
| 40 |
+
</ul>
|
| 41 |
+
<h2>Common questions</h2>
|
| 42 |
+
<p><strong>Do I need an account?</strong> No. There's no sign-up — just record and review.</p>
|
| 43 |
+
<p><strong>Where do my videos go?</strong> Your finished reviews are stored on your phone. The
|
| 44 |
+
clip is briefly uploaded to our server to be analyzed and is not kept in a
|
| 45 |
+
permanent database. See the <a href="/privacy">Privacy Policy</a>.</p>
|
| 46 |
+
<p><strong>How do I delete my reviews?</strong> Me tab → <strong>Clear all reviews</strong>, or delete a
|
| 47 |
+
single review from its screen.</p>
|
| 48 |
+
<p><strong>It got my move wrong.</strong> Tap <strong>"Did we get your move right?" → No</strong> on the
|
| 49 |
+
review and tell us what it was — that's the most useful thing you can send us,
|
| 50 |
+
and it directly improves the coach. You can optionally share that clip to help.</p>
|
| 51 |
+
<p><strong>The review failed or said my session expired.</strong> Tap <strong>Try again</strong> — it
|
| 52 |
+
re-sends your clip. If it keeps failing, check your connection or try a shorter
|
| 53 |
+
clip, and email us if it persists.</p>
|
| 54 |
+
<h2>Privacy</h2>
|
| 55 |
+
<p>See the full <a href="/privacy">Privacy Policy</a>.</p></body></html>
|
server/app/main.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
|
| 3 |
Routes:
|
| 4 |
/ — single-page demo frontend
|
|
|
|
|
|
|
| 5 |
/api/sessions — upload / poll / fetch annotated video
|
| 6 |
/api/events — anonymous client instrumentation (batched, JSONL per day)
|
| 7 |
/api/warmup — fire-and-forget pre-warm of the Modal GPU pose backend
|
|
@@ -32,6 +34,7 @@ app.include_router(sessions_router)
|
|
| 32 |
app.include_router(events_router)
|
| 33 |
|
| 34 |
WEB_DIR = Path(__file__).parent / "web"
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
@app.get("/healthz")
|
|
@@ -39,6 +42,21 @@ def healthz() -> dict[str, str]:
|
|
| 39 |
return {"status": "ok"}
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# /api/warmup — pre-warm the Modal T4 pose backend. Cold start is ~30-90s vs
|
| 43 |
# ~4s warm inference, so the iOS client fires this the moment the user starts
|
| 44 |
# recording / picking a clip; by upload time the container is already up.
|
|
|
|
| 2 |
|
| 3 |
Routes:
|
| 4 |
/ — single-page demo frontend
|
| 5 |
+
/privacy — privacy policy (also the App Store / TestFlight URL)
|
| 6 |
+
/support — support page
|
| 7 |
/api/sessions — upload / poll / fetch annotated video
|
| 8 |
/api/events — anonymous client instrumentation (batched, JSONL per day)
|
| 9 |
/api/warmup — fire-and-forget pre-warm of the Modal GPU pose backend
|
|
|
|
| 34 |
app.include_router(events_router)
|
| 35 |
|
| 36 |
WEB_DIR = Path(__file__).parent / "web"
|
| 37 |
+
LEGAL_DIR = Path(__file__).parent / "legal"
|
| 38 |
|
| 39 |
|
| 40 |
@app.get("/healthz")
|
|
|
|
| 42 |
return {"status": "ok"}
|
| 43 |
|
| 44 |
|
| 45 |
+
# Legal pages — the privacy URL is required by App Store review + external
|
| 46 |
+
# TestFlight metadata once any data is collected. Served from the app server so
|
| 47 |
+
# the URL is stable HTTPS (the iOS GlideLinks point here). Source of truth is
|
| 48 |
+
# docs/legal/*.md; server/app/legal/*.html are the rendered, self-contained
|
| 49 |
+
# (inline-CSS, no external refs) copies.
|
| 50 |
+
@app.get("/privacy")
|
| 51 |
+
def privacy() -> FileResponse:
|
| 52 |
+
return FileResponse(LEGAL_DIR / "privacy.html", media_type="text/html")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.get("/support")
|
| 56 |
+
def support() -> FileResponse:
|
| 57 |
+
return FileResponse(LEGAL_DIR / "support.html", media_type="text/html")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
# /api/warmup — pre-warm the Modal T4 pose backend. Cold start is ~30-90s vs
|
| 61 |
# ~4s warm inference, so the iOS client fires this the moment the user starts
|
| 62 |
# recording / picking a clip; by upload time the container is already up.
|
server/app/storage/corpus.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Opt-in corpus inbox for the F1 trust flywheel.
|
| 2 |
+
|
| 3 |
+
When a beta skater says "you got my move wrong" and opts in to share the clip,
|
| 4 |
+
we record their correction and (best-effort) copy the session's source clip into
|
| 5 |
+
`DATA_DIR/corpus_inbox/` so it can be hand-verified and folded into the eval
|
| 6 |
+
corpus — the standing protocol (bug report -> eval clip -> fix -> rerun) gains a
|
| 7 |
+
UI handle instead of relying on chat messages.
|
| 8 |
+
|
| 9 |
+
Privacy: clip retention happens ONLY on explicit per-review opt-in, is
|
| 10 |
+
internal-only, and never trains a public model. The verdict itself (right/wrong
|
| 11 |
+
+ what it actually was) also rides the anonymous M1 event stream — this module
|
| 12 |
+
is purely the clip-and-correction sidecar that M1 can't carry.
|
| 13 |
+
|
| 14 |
+
No database — JSONL append + a file copy, like the events log. Durable storage
|
| 15 |
+
lands with the rest of session persistence in U9.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import logging
|
| 22 |
+
import shutil
|
| 23 |
+
import threading
|
| 24 |
+
from datetime import UTC, datetime
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import TYPE_CHECKING, Any
|
| 27 |
+
|
| 28 |
+
from app.storage import sessions
|
| 29 |
+
|
| 30 |
+
if TYPE_CHECKING:
|
| 31 |
+
from app.storage.sessions import Session
|
| 32 |
+
|
| 33 |
+
log = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
# Line-level interleave guard for concurrent appends (mirrors events.py).
|
| 36 |
+
_append_lock = threading.Lock()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def corpus_dir() -> Path:
|
| 40 |
+
"""`DATA_DIR/corpus_inbox` — resolved at call time so tests can repoint."""
|
| 41 |
+
d = sessions.DATA_DIR / "corpus_inbox"
|
| 42 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 43 |
+
return d
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _source_clip(session: Session | None) -> Path | None:
|
| 47 |
+
"""The best clip to retain for re-eval: the original the skater filmed,
|
| 48 |
+
falling back to the normalized then the zoom crop. None if the session is
|
| 49 |
+
gone (non-durable store) or no file survives on disk."""
|
| 50 |
+
if session is None:
|
| 51 |
+
return None
|
| 52 |
+
for path in (session.source_path, session.normalized_path, session.zoom_path):
|
| 53 |
+
if path is not None and Path(path).exists():
|
| 54 |
+
return Path(path)
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def record_feedback(
|
| 59 |
+
*,
|
| 60 |
+
install_id: str,
|
| 61 |
+
session_id: str,
|
| 62 |
+
verdict: str,
|
| 63 |
+
said: str | None,
|
| 64 |
+
retain_clip: bool,
|
| 65 |
+
session: Session | None,
|
| 66 |
+
) -> bool:
|
| 67 |
+
"""Append the correction to `corpus_inbox/feedback.jsonl` (always) and, when
|
| 68 |
+
`retain_clip` is set and a source clip survives on disk, copy it into
|
| 69 |
+
`corpus_inbox/clips/`. Returns whether a clip was actually retained.
|
| 70 |
+
|
| 71 |
+
The feedback line is written even when the session has expired so the
|
| 72 |
+
correction is never lost — only the clip copy depends on the session still
|
| 73 |
+
holding a file.
|
| 74 |
+
"""
|
| 75 |
+
root = corpus_dir()
|
| 76 |
+
retained = False
|
| 77 |
+
clip_name: str | None = None
|
| 78 |
+
|
| 79 |
+
if retain_clip:
|
| 80 |
+
src = _source_clip(session)
|
| 81 |
+
if src is not None:
|
| 82 |
+
clips = root / "clips"
|
| 83 |
+
clips.mkdir(parents=True, exist_ok=True)
|
| 84 |
+
clip_name = f"{session_id}{src.suffix}"
|
| 85 |
+
try:
|
| 86 |
+
shutil.copy2(src, clips / clip_name)
|
| 87 |
+
retained = True
|
| 88 |
+
except OSError:
|
| 89 |
+
# Best-effort; the correction line still lands. Log it so a
|
| 90 |
+
# systemic failure (disk full) isn't silent in the corpus pipe.
|
| 91 |
+
log.warning("corpus clip retain failed for %s", session_id, exc_info=True)
|
| 92 |
+
retained = False
|
| 93 |
+
clip_name = None
|
| 94 |
+
|
| 95 |
+
record: dict[str, Any] = {
|
| 96 |
+
"ts": datetime.now(tz=UTC).isoformat(),
|
| 97 |
+
"install_id": install_id,
|
| 98 |
+
"session_id": session_id,
|
| 99 |
+
"verdict": verdict,
|
| 100 |
+
"said": said,
|
| 101 |
+
"declared_move": getattr(session, "declared_move", None),
|
| 102 |
+
"detected_move": _detected_move(session),
|
| 103 |
+
"clip": clip_name,
|
| 104 |
+
}
|
| 105 |
+
line = json.dumps(record, separators=(",", ":"))
|
| 106 |
+
with _append_lock:
|
| 107 |
+
with (root / "feedback.jsonl").open("a", encoding="utf-8") as f:
|
| 108 |
+
f.write(line + "\n")
|
| 109 |
+
return retained
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _detected_move(session: Session | None) -> str | None:
|
| 113 |
+
"""The most specific move the pipeline named for this session, for the
|
| 114 |
+
sidecar label — best-effort, tolerant of partial/older scorecards."""
|
| 115 |
+
if session is None:
|
| 116 |
+
return None
|
| 117 |
+
moves = session.predicted_moves or []
|
| 118 |
+
for m in moves:
|
| 119 |
+
if isinstance(m, dict):
|
| 120 |
+
name = m.get("move") or m.get("name") or m.get("label")
|
| 121 |
+
if name:
|
| 122 |
+
return str(name)
|
| 123 |
+
return None
|
server/app/storage/sessions.py
CHANGED
|
@@ -10,7 +10,25 @@ from enum import StrEnum
|
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""Local directory for uploaded + annotated video files."""
|
| 15 |
|
| 16 |
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
+
def _resolve_data_dir(persistent_root: Path = Path("/data")) -> Path:
|
| 14 |
+
"""Where uploaded clips, events, and the F1 corpus live.
|
| 15 |
+
|
| 16 |
+
Priority: an explicit ``SKATING_COACH_DATA_DIR`` override → HF persistent
|
| 17 |
+
storage (``persistent_root``, ``/data``) when it's actually mounted +
|
| 18 |
+
writable (durable across Space restarts, so the F1 corpus + events survive —
|
| 19 |
+
enable it in the Space settings) → an ephemeral ``/tmp`` dir (lost on
|
| 20 |
+
restart; fine pre-scale). ``persistent_root`` is a parameter only so tests
|
| 21 |
+
can stand in for ``/data``.
|
| 22 |
+
"""
|
| 23 |
+
override = os.environ.get("SKATING_COACH_DATA_DIR")
|
| 24 |
+
if override:
|
| 25 |
+
return Path(override)
|
| 26 |
+
if persistent_root.is_dir() and os.access(persistent_root, os.W_OK):
|
| 27 |
+
return persistent_root / "skating-coach"
|
| 28 |
+
return Path("/tmp/skating-coach-data")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
DATA_DIR = _resolve_data_dir()
|
| 32 |
"""Local directory for uploaded + annotated video files."""
|
| 33 |
|
| 34 |
|