fix(ui): browser-playable overlay video (H.264) + light all dropdown option lists

#1
by BladeSzaSza - opened
formscout/agents/report.py CHANGED
@@ -10,9 +10,7 @@ Gated: no.
10
  """
11
  from __future__ import annotations
12
 
13
- from formscout.types import (
14
- MovementResult, BiomechFeatures, ScoreResult, JudgeResult, ReportResult,
15
- )
16
  from formscout import config
17
 
18
  # Bilateral tests that need L/R scoring
@@ -50,7 +48,10 @@ class ReportAgent:
50
  else:
51
  unilateral.append(entry)
52
 
53
- # Process bilateral tests β€” take the lower score, emit asymmetry
 
 
 
54
  for test_name, entries in bilateral_groups.items():
55
  scores = []
56
  for entry in entries:
@@ -59,33 +60,34 @@ class ReportAgent:
59
  score = judge.score if judge.score is not None else None
60
  scores.append({"side": side, "score": score, "entry": entry})
61
 
62
- # Find best entry per side
63
  left = next((s for s in scores if s["side"] == "left"), None)
64
  right = next((s for s in scores if s["side"] == "right"), None)
65
-
66
  left_score = left["score"] if left else None
67
  right_score = right["score"] if right else None
68
 
69
- # Report lower
70
  if left_score is not None and right_score is not None:
 
71
  final_score = min(left_score, right_score)
72
- delta = abs(left_score - right_score)
73
  asymmetries.append({
74
  "test": test_name,
75
  "left_score": left_score,
76
  "right_score": right_score,
77
- "delta": delta,
78
  })
 
79
  elif left_score is not None:
80
- final_score = left_score
81
  elif right_score is not None:
82
- final_score = right_score
83
  else:
84
- final_score = None
85
-
86
- # Use the entry with the lower score for details
87
- primary = (left["entry"] if left and (right is None or (left_score or 4) <= (right_score or 4))
88
- else right["entry"] if right else entries[0])
 
 
 
89
 
90
  per_test.append({
91
  "test_name": test_name,
 
10
  """
11
  from __future__ import annotations
12
 
13
+ from formscout.types import ReportResult
 
 
14
  from formscout import config
15
 
16
  # Bilateral tests that need L/R scoring
 
48
  else:
49
  unilateral.append(entry)
50
 
51
+ # Process bilateral tests β€” report the lower side and emit an asymmetry
52
+ # when both sides exist; otherwise fall back to whatever was scored (e.g.
53
+ # a single clip submitted with side="na", the UI default) so the test
54
+ # still counts toward the composite instead of being dropped as unscored.
55
  for test_name, entries in bilateral_groups.items():
56
  scores = []
57
  for entry in entries:
 
60
  score = judge.score if judge.score is not None else None
61
  scores.append({"side": side, "score": score, "entry": entry})
62
 
 
63
  left = next((s for s in scores if s["side"] == "left"), None)
64
  right = next((s for s in scores if s["side"] == "right"), None)
 
65
  left_score = left["score"] if left else None
66
  right_score = right["score"] if right else None
67
 
 
68
  if left_score is not None and right_score is not None:
69
+ # Both sides present β€” report the lower and record the asymmetry.
70
  final_score = min(left_score, right_score)
 
71
  asymmetries.append({
72
  "test": test_name,
73
  "left_score": left_score,
74
  "right_score": right_score,
75
+ "delta": abs(left_score - right_score),
76
  })
77
+ primary = left["entry"] if left_score <= right_score else right["entry"]
78
  elif left_score is not None:
79
+ final_score, primary = left_score, left["entry"]
80
  elif right_score is not None:
81
+ final_score, primary = right_score, right["entry"]
82
  else:
83
+ # No explicit L/R side: use the lowest available score across the
84
+ # group (covers side="na" and needs_human/unscored entries).
85
+ scored = [s for s in scores if s["score"] is not None]
86
+ if scored:
87
+ best = min(scored, key=lambda s: s["score"])
88
+ final_score, primary = best["score"], best["entry"]
89
+ else:
90
+ final_score, primary = None, entries[0]
91
 
92
  per_test.append({
93
  "test_name": test_name,
scripts/hackathon_upload.sh ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Upload the FormScout source tree to the Build Small Hackathon Space, as a PR.
3
+ #
4
+ # Usage:
5
+ # ./scripts/hackathon_upload.sh # message from last git commit
6
+ # ./scripts/hackathon_upload.sh "feat: my change" # custom message
7
+ #
8
+ # Pushes (PR) to:
9
+ # spaces/build-small-hackathon/small-functional-movement-screening
10
+ #
11
+ # The Space already exists, so this script only uploads β€” it does not create the
12
+ # repo. Like hf_upload.sh, `hf upload` ignores .hfignore, so we parse it into
13
+ # --exclude globs ourselves. Above LARGE_THRESHOLD files it falls back to
14
+ # `hf upload-large-folder` (resumable, but commits to main directly β€” no PR).
15
+ set -euo pipefail
16
+
17
+ cd "$(dirname "$0")/.."
18
+
19
+ HACKATHON_OWNER="${FORMSCOUT_HF_HACKATHON_OWNER:-build-small-hackathon}"
20
+ REPO_NAME="small-functional-movement-screening"
21
+ SPACE_REPO="spaces/$HACKATHON_OWNER/$REPO_NAME"
22
+ MSG="${1:-$(git log -1 --pretty=%s)}"
23
+ LARGE_THRESHOLD="${FORMSCOUT_HF_LARGE_THRESHOLD:-500}"
24
+
25
+ # Belt-and-suspenders extras on top of .hfignore. `.cache/` is the resume
26
+ # state upload-large-folder writes into the folder being uploaded.
27
+ PATTERNS=(
28
+ "*.pdf"
29
+ "**/node_modules/**"
30
+ ".cache/**"
31
+ )
32
+
33
+ # Parse .hfignore into fnmatch-style globs. fnmatch's `*` crosses `/`, but a
34
+ # bare name like `.DS_Store` or `dir/` only matches at the root, so emit both
35
+ # the rooted and `**/`-prefixed forms.
36
+ while IFS= read -r line; do
37
+ line="${line%%#*}"
38
+ line="${line#"${line%%[![:space:]]*}"}"
39
+ line="${line%"${line##*[![:space:]]}"}"
40
+ [[ -z "$line" ]] && continue
41
+ if [[ "$line" == */ ]]; then
42
+ PATTERNS+=("${line}**" "**/${line}**")
43
+ else
44
+ PATTERNS+=("$line" "**/$line")
45
+ fi
46
+ done < .hfignore
47
+
48
+ EXCLUDES=()
49
+ for p in "${PATTERNS[@]}"; do
50
+ EXCLUDES+=(--exclude="$p")
51
+ done
52
+
53
+ # Count what would actually be uploaded, using the same filter the hub client
54
+ # applies, so the mode decision matches reality.
55
+ N_FILES=$(python3 - "${PATTERNS[@]}" <<'EOF'
56
+ import sys
57
+ from pathlib import Path
58
+ from huggingface_hub.utils import filter_repo_objects
59
+
60
+ patterns = sys.argv[1:]
61
+ files = (
62
+ str(p) for p in Path(".").rglob("*")
63
+ if p.is_file() and p.parts[0] != ".git"
64
+ )
65
+ print(len(list(filter_repo_objects(files, ignore_patterns=patterns))))
66
+ EOF
67
+ )
68
+ echo "── $N_FILES files to upload after .hfignore filtering"
69
+
70
+ if (( N_FILES == 0 )); then
71
+ echo "βœ— nothing to upload β€” check .hfignore" >&2
72
+ exit 1
73
+ fi
74
+
75
+ if (( N_FILES > LARGE_THRESHOLD )); then
76
+ echo "── $SPACE_REPO: $N_FILES files > $LARGE_THRESHOLD, using upload-large-folder"
77
+ echo " (resumable; commits directly to main β€” no PR, no custom message)"
78
+ hf upload-large-folder "$SPACE_REPO" . "${EXCLUDES[@]}"
79
+ else
80
+ echo "── uploading (PR) to: $SPACE_REPO"
81
+ hf upload "$SPACE_REPO" . . "${EXCLUDES[@]}" --create-pr --commit-message="$MSG"
82
+ fi
83
+
84
+ echo "βœ“ done β€” PR opened on $SPACE_REPO (review & merge in the HF UI)"
tests/test_phase2.py CHANGED
@@ -338,6 +338,26 @@ class TestReportAgent:
338
  assert len(result.asymmetries) == 1
339
  assert result.asymmetries[0]["delta"] == 1
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  def test_composite_none_when_unscored(self):
342
  agent = ReportAgent()
343
  entries = [{
 
338
  assert len(result.asymmetries) == 1
339
  assert result.asymmetries[0]["delta"] == 1
340
 
341
+ def test_bilateral_na_side_is_scored(self):
342
+ """A bilateral test run once with side='na' (the UI default) must still
343
+ count toward the composite, not be dropped as unscored."""
344
+ agent = ReportAgent()
345
+ entries = [{
346
+ "movement": MovementResult(test_name="hurdle_step", side="na", confidence=1.0),
347
+ "features": _make_features("hurdle_step", side="na"),
348
+ "rubric_score": ScoreResult(score=2, rationale="", confidence=0.9),
349
+ "judge": JudgeResult(
350
+ score=2, rationale="", compensation_tags=[], corrective_hint="",
351
+ confidence=0.9,
352
+ ),
353
+ "side": "na",
354
+ }]
355
+ result = agent.run(entries)
356
+ assert len(result.per_test) == 1
357
+ assert result.per_test[0]["score"] == 2
358
+ assert result.composite == 2
359
+ assert result.asymmetries == [] # no L/R pair β†’ no asymmetry
360
+
361
  def test_composite_none_when_unscored(self):
362
  agent = ReportAgent()
363
  entries = [{