RemiFabre commited on
Commit
278e874
·
1 Parent(s): 15d0622

Added review mode

Browse files
README.md CHANGED
@@ -36,3 +36,7 @@ Behaviors carry a tiny colored crescent to hint how long the move lasts:
36
  - Seafoam (`#73E0A9`) — short (≤ 4 s)
37
  - Amber (`#FFC857`) — medium (between 4 s and 8 s)
38
  - Rose (`#FF6B6B`) — long (> 8 s)
 
 
 
 
 
36
  - Seafoam (`#73E0A9`) — short (≤ 4 s)
37
  - Amber (`#FFC857`) — medium (between 4 s and 8 s)
38
  - Rose (`#FF6B6B`) — long (> 8 s)
39
+
40
+ ## Review mode (developer only)
41
+
42
+ Set the environment variable `EMOTIONS_REVIEW_MODE=1` before launching the app to unlock a hidden review panel in the GUI (Shift + R or double-click the status chip). Each move can then be tagged with a quality (`Excellent`, `OK`, `Bad`) and precision (`Clear`, `Ambiguous`). Ratings are stored back inside `ressources/emotions.yml`.
emotions/main.py CHANGED
@@ -6,8 +6,9 @@ import time
6
  from collections import defaultdict
7
  from dataclasses import dataclass, field
8
  from datetime import datetime
 
9
  from pathlib import Path
10
- from typing import Any
11
 
12
  from fastapi import HTTPException
13
  from pydantic import BaseModel, Field
@@ -24,6 +25,11 @@ _RESOURCE_CANDIDATES = [
24
  ]
25
  RESOURCE_ROOT = next((path for path in _RESOURCE_CANDIDATES if path.exists()), _RESOURCE_CANDIDATES[0])
26
  EMOTIONS_YAML_PATH = RESOURCE_ROOT / "emotions.yml"
 
 
 
 
 
27
  COHERENCE_DURATION_TOLERANCE = 0.25
28
  YAML_LOADER = YAML()
29
  YAML_LOADER.preserve_quotes = True
@@ -87,6 +93,8 @@ class WheelMove:
87
  recorded_duration: float | None
88
  order: int
89
  layout: dict[str, float] | None = None
 
 
90
 
91
  def to_dict(self) -> dict[str, Any]:
92
  return {
@@ -105,6 +113,8 @@ class WheelMove:
105
  "recorded_duration": self.recorded_duration,
106
  "order": self.order,
107
  "layout": self.layout,
 
 
108
  }
109
 
110
 
@@ -173,6 +183,15 @@ class SaveLayoutPayload(BaseModel):
173
  positions: dict[str, MovePositionPayload]
174
 
175
 
 
 
 
 
 
 
 
 
 
176
  def slugify(value: str) -> str:
177
  slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
178
  return slug or "sector"
@@ -387,6 +406,16 @@ def build_catalog(spec: dict[str, Any], recorded_emotions: RecordedMoves) -> Emo
387
  layout = None
388
 
389
  sector_type_map[wheel_sector].add(move_type)
 
 
 
 
 
 
 
 
 
 
390
  move = WheelMove(
391
  move_id=move_id,
392
  clean_name=clean_name,
@@ -403,6 +432,8 @@ def build_catalog(spec: dict[str, Any], recorded_emotions: RecordedMoves) -> Emo
403
  recorded_duration=recorded_duration,
404
  order=order,
405
  layout=layout,
 
 
406
  )
407
  moves_by_type[move_type].append(move)
408
  move_lookup[move_id] = move
@@ -562,6 +593,48 @@ class Emotions(ReachyMiniApp):
562
  dump_emotions_spec_tree(tree)
563
  self._refresh_catalog(tree)
564
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  def _register_routes(self) -> None:
566
  @self.settings_app.get("/api/emotions")
567
  def list_emotions() -> dict[str, Any]:
@@ -625,6 +698,20 @@ class Emotions(ReachyMiniApp):
625
  self._reset_positions()
626
  return {"status": "ok"}
627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
629
  while not stop_event.is_set():
630
  move_to_play: str | None = None
 
6
  from collections import defaultdict
7
  from dataclasses import dataclass, field
8
  from datetime import datetime
9
+ import os
10
  from pathlib import Path
11
+ from typing import Any, Literal
12
 
13
  from fastapi import HTTPException
14
  from pydantic import BaseModel, Field
 
25
  ]
26
  RESOURCE_ROOT = next((path for path in _RESOURCE_CANDIDATES if path.exists()), _RESOURCE_CANDIDATES[0])
27
  EMOTIONS_YAML_PATH = RESOURCE_ROOT / "emotions.yml"
28
+ REVIEW_MODE_ENABLED = os.getenv("EMOTIONS_REVIEW_MODE", "").lower() not in {"0", "false", "no"}
29
+ QUALITY_SCALE: tuple[str, ...] = ("excellent", "ok", "bad")
30
+ PRECISION_SCALE: tuple[str, ...] = ("clear", "ambiguous")
31
+ QualityLiteral = Literal["excellent", "ok", "bad"]
32
+ PrecisionLiteral = Literal["clear", "ambiguous"]
33
  COHERENCE_DURATION_TOLERANCE = 0.25
34
  YAML_LOADER = YAML()
35
  YAML_LOADER.preserve_quotes = True
 
93
  recorded_duration: float | None
94
  order: int
95
  layout: dict[str, float] | None = None
96
+ quality: str | None = None
97
+ precision: str | None = None
98
 
99
  def to_dict(self) -> dict[str, Any]:
100
  return {
 
113
  "recorded_duration": self.recorded_duration,
114
  "order": self.order,
115
  "layout": self.layout,
116
+ "quality": self.quality,
117
+ "precision": self.precision,
118
  }
119
 
120
 
 
183
  positions: dict[str, MovePositionPayload]
184
 
185
 
186
+ class ReviewPayload(BaseModel):
187
+ quality: QualityLiteral | None = Field(
188
+ default=None, description="Quality rating for the move"
189
+ )
190
+ precision: PrecisionLiteral | None = Field(
191
+ default=None, description="Precision rating for the move"
192
+ )
193
+
194
+
195
  def slugify(value: str) -> str:
196
  slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
197
  return slug or "sector"
 
406
  layout = None
407
 
408
  sector_type_map[wheel_sector].add(move_type)
409
+ quality = move_data.get("quality")
410
+ if isinstance(quality, str) and quality.lower() in QUALITY_SCALE:
411
+ quality = quality.lower()
412
+ else:
413
+ quality = None
414
+ precision = move_data.get("precision")
415
+ if isinstance(precision, str) and precision.lower() in PRECISION_SCALE:
416
+ precision = precision.lower()
417
+ else:
418
+ precision = None
419
  move = WheelMove(
420
  move_id=move_id,
421
  clean_name=clean_name,
 
432
  recorded_duration=recorded_duration,
433
  order=order,
434
  layout=layout,
435
+ quality=quality,
436
+ precision=precision,
437
  )
438
  moves_by_type[move_type].append(move)
439
  move_lookup[move_id] = move
 
593
  dump_emotions_spec_tree(tree)
594
  self._refresh_catalog(tree)
595
 
596
+ def _find_move_entry(self, tree: Any, move_id: str) -> dict[str, Any] | None:
597
+ moves_section = tree.get("moves", [])
598
+ for entry in moves_section:
599
+ if isinstance(entry, dict) and entry.get("id") == move_id:
600
+ return entry
601
+ return None
602
+
603
+ def _save_review(self, move_id: str, payload: ReviewPayload) -> dict[str, Any]:
604
+ if not payload.model_fields_set:
605
+ raise HTTPException(status_code=400, detail="No review fields provided.")
606
+
607
+ with self._config_lock:
608
+ tree = load_emotions_spec_tree()
609
+ target = self._find_move_entry(tree, move_id)
610
+ if target is None:
611
+ raise HTTPException(status_code=404, detail=f"Move {move_id} not found in spec.")
612
+
613
+ def assign(field: str, value: str | None, allowed: tuple[str, ...]) -> None:
614
+ if value is None:
615
+ target.pop(field, None)
616
+ return
617
+ normalized = value.lower()
618
+ if normalized not in allowed:
619
+ raise HTTPException(status_code=400, detail=f"Invalid {field} value: {value}")
620
+ target[field] = normalized
621
+
622
+ if "quality" in payload.model_fields_set:
623
+ assign("quality", payload.quality, QUALITY_SCALE)
624
+
625
+ if "precision" in payload.model_fields_set:
626
+ assign("precision", payload.precision, PRECISION_SCALE)
627
+
628
+ dump_emotions_spec_tree(tree)
629
+ self._refresh_catalog(tree)
630
+
631
+ updated_move = self._catalog.move_lookup.get(move_id)
632
+ return {
633
+ "status": "ok",
634
+ "quality": updated_move.quality if updated_move else None,
635
+ "precision": updated_move.precision if updated_move else None,
636
+ }
637
+
638
  def _register_routes(self) -> None:
639
  @self.settings_app.get("/api/emotions")
640
  def list_emotions() -> dict[str, Any]:
 
698
  self._reset_positions()
699
  return {"status": "ok"}
700
 
701
+ @self.settings_app.get("/api/review/config")
702
+ def review_config() -> dict[str, Any]:
703
+ return {
704
+ "enabled": REVIEW_MODE_ENABLED,
705
+ "quality_options": QUALITY_SCALE,
706
+ "precision_options": PRECISION_SCALE,
707
+ }
708
+
709
+ @self.settings_app.post("/api/review/{move_id}")
710
+ def save_review(move_id: str, payload: ReviewPayload) -> dict[str, Any]:
711
+ if not REVIEW_MODE_ENABLED:
712
+ raise HTTPException(status_code=404, detail="Review mode is disabled.")
713
+ return self._save_review(move_id, payload)
714
+
715
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
716
  while not stop_event.is_set():
717
  move_to_play: str | None = None
emotions/static/index.html CHANGED
@@ -31,6 +31,47 @@
31
  <button id="layoutModeBtn" class="control-btn">Manual layout</button>
32
  <button id="layoutSaveBtn" class="control-btn" disabled>Save layout</button>
33
  <button id="layoutResetBtn" class="control-btn secondary">Reset layout</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  </section>
35
 
36
  <section class="wheel-stage">
 
31
  <button id="layoutModeBtn" class="control-btn">Manual layout</button>
32
  <button id="layoutSaveBtn" class="control-btn" disabled>Save layout</button>
33
  <button id="layoutResetBtn" class="control-btn secondary">Reset layout</button>
34
+ <button id="reviewToggleBtn" class="control-btn tertiary" hidden type="button">Review mode</button>
35
+ </section>
36
+
37
+ <section class="review-panel" id="reviewPanel" hidden>
38
+ <header>
39
+ <div>
40
+ <strong>Review mode</strong>
41
+ <details class="review-instructions">
42
+ <summary>How to tag moves</summary>
43
+ <ul>
44
+ <li>Click a move (or press the play button) to focus it.</li>
45
+ <li>Use 1/2/3 to set quality (Excellent / OK / Bad).</li>
46
+ <li>Use Q/W for precision (Clear / Ambiguous).</li>
47
+ <li>Press N to jump to the next unrated move.</li>
48
+ <li>Ratings are saved straight into <code>ressources/emotions.yml</code>.</li>
49
+ </ul>
50
+ </details>
51
+ </div>
52
+ <div class="review-progress" id="reviewProgress">0 / 0</div>
53
+ <button id="reviewExitBtn" class="chip soft" type="button">Exit</button>
54
+ </header>
55
+ <div class="review-current">
56
+ <div>
57
+ <p id="reviewMoveLabel">Select a move to begin.</p>
58
+ <small id="reviewMoveId" class="muted"></small>
59
+ </div>
60
+ <div class="review-current-actions">
61
+ <button id="reviewNextBtn" class="control-btn secondary" type="button">Next unrated</button>
62
+ <button id="reviewClearBtn" class="control-btn secondary" type="button">Clear rating</button>
63
+ </div>
64
+ </div>
65
+ <div class="review-controls">
66
+ <article>
67
+ <h3>Quality</h3>
68
+ <div class="review-options" id="reviewQualityOptions"></div>
69
+ </article>
70
+ <article>
71
+ <h3>Precision</h3>
72
+ <div class="review-options" id="reviewPrecisionOptions"></div>
73
+ </article>
74
+ </div>
75
  </section>
76
 
77
  <section class="wheel-stage">
emotions/static/main.js CHANGED
@@ -10,6 +10,16 @@ const coherencePanel = document.getElementById("coherencePanel");
10
  const layoutModeBtn = document.getElementById("layoutModeBtn");
11
  const layoutSaveBtn = document.getElementById("layoutSaveBtn");
12
  const layoutResetBtn = document.getElementById("layoutResetBtn");
 
 
 
 
 
 
 
 
 
 
13
 
14
  const WHEEL_PADDING = 60;
15
  const MAX_LANES = 9;
@@ -18,6 +28,10 @@ const WHEEL_SETTINGS = {
18
  emotion: { nodeSize: 96, baseInnerRatio: 0.2, movesPerLane: 5 },
19
  behavior: { nodeSize: 96, baseInnerRatio: 0.2, movesPerLane: 5 },
20
  };
 
 
 
 
21
 
22
  const appState = {
23
  wheels: {},
@@ -33,6 +47,14 @@ const appState = {
33
  manualMode: false,
34
  positionsDirty: false,
35
  layoutOverrides: new Map(),
 
 
 
 
 
 
 
 
36
  };
37
  const manualState = {
38
  drag: null,
@@ -70,6 +92,12 @@ function clamp(value, min, max) {
70
  return Math.min(max, Math.max(min, value));
71
  }
72
 
 
 
 
 
 
 
73
  function hexToRgba(hex, alpha) {
74
  if (!hex) {
75
  return `rgba(255, 255, 255, ${alpha})`;
@@ -85,6 +113,19 @@ function hexToRgba(hex, alpha) {
85
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
86
  }
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  function applySectorColor(element, color, { backgroundAlpha = 0.9, borderAlpha = 0.95 } = {}) {
89
  const base = hexToRgba(color, backgroundAlpha);
90
  const fade = hexToRgba(color, 0.45);
@@ -93,6 +134,35 @@ function applySectorColor(element, color, { backgroundAlpha = 0.9, borderAlpha =
93
  element.style.boxShadow = `0 15px 35px ${hexToRgba(color, 0.35)}`;
94
  }
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  function showToast(message) {
97
  toast.textContent = message;
98
  toast.classList.add("visible");
@@ -135,14 +205,21 @@ function buildStructures(data) {
135
  appState.positions = new Map();
136
  appState.positionsDirty = false;
137
  appState.layoutOverrides = new Map();
 
138
 
139
  Object.entries(appState.wheels).forEach(([, wheel]) => {
140
  (wheel.sectors || []).forEach((sector) => {
141
  (sector.moves || []).forEach((move) => {
 
 
 
142
  appState.moveIndex.set(move.id, move);
143
  });
144
  });
145
  });
 
 
 
146
  }
147
 
148
  function getWheelSetting(key) {
@@ -199,7 +276,9 @@ function createNodeElement(move, wheelKey) {
199
  node.innerHTML = `
200
  <span class="node-title">${move.label}</span>
201
  <span class="node-meta">${move.duration_bucket.toUpperCase()}</span>
 
202
  `;
 
203
 
204
  if (!move.available) {
205
  node.classList.add("unavailable");
@@ -326,6 +405,9 @@ function renderAllWheels() {
326
  renderWheel("emotion");
327
  renderWheel("behavior");
328
  updateWheelStageSpacing();
 
 
 
329
  }
330
 
331
  function updateWheelStageSpacing() {
@@ -556,10 +638,19 @@ function showTooltip(move) {
556
  hasRecorded && Math.abs(move.recorded_duration - move.duration_seconds) > 0.01
557
  ? `<br><small>Recorded duration: ${move.recorded_duration.toFixed(2)}s</small>`
558
  : "";
 
 
 
 
 
 
 
 
559
  tooltip.innerHTML = `
560
  <strong>${move.label}</strong>
561
  <small class="muted">dataset: ${datasetId}</small>
562
  <span>${move.wheel_sector} • ${duration} • ${move.duration_bucket.toUpperCase()} ${availability}</span>
 
563
  <p>${move.description || "No description yet."}</p>
564
  <small>Intensity ${move.intensity.toFixed(1)}${recorded}</small>
565
  `;
@@ -576,6 +667,12 @@ function highlightSector(sector, enabled) {
576
  });
577
  }
578
 
 
 
 
 
 
 
579
  function summarizeList(items, formatter) {
580
  if (!items?.length) {
581
  return "";
@@ -707,6 +804,245 @@ function updateCoherencePanel(coherence) {
707
  `;
708
  }
709
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
  function updateManualControls() {
711
  document.body.classList.toggle("manual-mode", appState.manualMode);
712
  if (layoutModeBtn) {
@@ -776,9 +1112,47 @@ async function loadAndRender() {
776
  setPositionsDirty(false);
777
  updateManualControls();
778
  setBusyState(appState.busy, appState.currentMove);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
779
  }
780
 
781
  async function triggerPlay(moveId) {
 
 
 
782
  if (appState.manualMode) {
783
  showToast("Exit layout mode to play moves.");
784
  return;
@@ -824,6 +1198,7 @@ async function pollState() {
824
 
825
  async function init() {
826
  try {
 
827
  await loadAndRender();
828
  setBusyState(false, null);
829
  pollState();
@@ -853,3 +1228,40 @@ layoutSaveBtn?.addEventListener("click", () => {
853
  saveLayout();
854
  });
855
  layoutResetBtn?.addEventListener("click", () => resetLayout());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  const layoutModeBtn = document.getElementById("layoutModeBtn");
11
  const layoutSaveBtn = document.getElementById("layoutSaveBtn");
12
  const layoutResetBtn = document.getElementById("layoutResetBtn");
13
+ const reviewPanel = document.getElementById("reviewPanel");
14
+ const reviewToggleBtn = document.getElementById("reviewToggleBtn");
15
+ const reviewMoveLabel = document.getElementById("reviewMoveLabel");
16
+ const reviewMoveId = document.getElementById("reviewMoveId");
17
+ const reviewProgressEl = document.getElementById("reviewProgress");
18
+ const reviewExitBtn = document.getElementById("reviewExitBtn");
19
+ const reviewNextBtn = document.getElementById("reviewNextBtn");
20
+ const reviewClearBtn = document.getElementById("reviewClearBtn");
21
+ const reviewQualityContainer = document.getElementById("reviewQualityOptions");
22
+ const reviewPrecisionContainer = document.getElementById("reviewPrecisionOptions");
23
 
24
  const WHEEL_PADDING = 60;
25
  const MAX_LANES = 9;
 
28
  emotion: { nodeSize: 96, baseInnerRatio: 0.2, movesPerLane: 5 },
29
  behavior: { nodeSize: 96, baseInnerRatio: 0.2, movesPerLane: 5 },
30
  };
31
+ const REVIEW_KEYMAP = {
32
+ quality: ["1", "2", "3"],
33
+ precision: ["q", "w"],
34
+ };
35
 
36
  const appState = {
37
  wheels: {},
 
47
  manualMode: false,
48
  positionsDirty: false,
49
  layoutOverrides: new Map(),
50
+ moveOrder: [],
51
+ review: {
52
+ enabled: false,
53
+ active: false,
54
+ currentMoveId: null,
55
+ options: { quality: [], precision: [] },
56
+ saving: false,
57
+ },
58
  };
59
  const manualState = {
60
  drag: null,
 
92
  return Math.min(max, Math.max(min, value));
93
  }
94
 
95
+ function isTypingTarget(element) {
96
+ if (!element) return false;
97
+ const tag = element.tagName;
98
+ return tag === "INPUT" || tag === "TEXTAREA" || element.isContentEditable;
99
+ }
100
+
101
  function hexToRgba(hex, alpha) {
102
  if (!hex) {
103
  return `rgba(255, 255, 255, ${alpha})`;
 
113
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
114
  }
115
 
116
+ function capitalize(value) {
117
+ if (!value) return "";
118
+ return value.charAt(0).toUpperCase() + value.slice(1);
119
+ }
120
+
121
+ function isMoveReviewed(move) {
122
+ return Boolean(move?.quality && move?.precision);
123
+ }
124
+
125
+ function needsReview(move) {
126
+ return Boolean(move) && (!move.quality || !move.precision);
127
+ }
128
+
129
  function applySectorColor(element, color, { backgroundAlpha = 0.9, borderAlpha = 0.95 } = {}) {
130
  const base = hexToRgba(color, backgroundAlpha);
131
  const fade = hexToRgba(color, 0.45);
 
134
  element.style.boxShadow = `0 15px 35px ${hexToRgba(color, 0.35)}`;
135
  }
136
 
137
+ function updateNodeReviewBadge(node, move) {
138
+ const badge = node.querySelector(".node-review");
139
+ if (!badge) return;
140
+ const chunks = [];
141
+ if (move.quality) {
142
+ chunks.push(capitalize(move.quality));
143
+ }
144
+ if (move.precision) {
145
+ chunks.push(capitalize(move.precision));
146
+ }
147
+ if (!chunks.length) {
148
+ badge.textContent = "";
149
+ badge.classList.add("hidden");
150
+ } else {
151
+ badge.textContent = chunks.join(" · ");
152
+ badge.classList.remove("hidden");
153
+ }
154
+ node.classList.toggle("review-complete", isMoveReviewed(move));
155
+ }
156
+
157
+ function refreshMoveIndicators(moveId) {
158
+ if (!moveId) return;
159
+ const move = appState.moveIndex.get(moveId);
160
+ if (!move) return;
161
+ document.querySelectorAll(`.wheel-node[data-move="${moveId}"]`).forEach((node) => {
162
+ updateNodeReviewBadge(node, move);
163
+ });
164
+ }
165
+
166
  function showToast(message) {
167
  toast.textContent = message;
168
  toast.classList.add("visible");
 
205
  appState.positions = new Map();
206
  appState.positionsDirty = false;
207
  appState.layoutOverrides = new Map();
208
+ appState.moveOrder = [];
209
 
210
  Object.entries(appState.wheels).forEach(([, wheel]) => {
211
  (wheel.sectors || []).forEach((sector) => {
212
  (sector.moves || []).forEach((move) => {
213
+ if (!appState.moveIndex.has(move.id)) {
214
+ appState.moveOrder.push(move.id);
215
+ }
216
  appState.moveIndex.set(move.id, move);
217
  });
218
  });
219
  });
220
+ if (appState.review.currentMoveId && !appState.moveIndex.has(appState.review.currentMoveId)) {
221
+ appState.review.currentMoveId = null;
222
+ }
223
  }
224
 
225
  function getWheelSetting(key) {
 
276
  node.innerHTML = `
277
  <span class="node-title">${move.label}</span>
278
  <span class="node-meta">${move.duration_bucket.toUpperCase()}</span>
279
+ <span class="node-review${move.quality || move.precision ? "" : " hidden"}"></span>
280
  `;
281
+ updateNodeReviewBadge(node, move);
282
 
283
  if (!move.available) {
284
  node.classList.add("unavailable");
 
405
  renderWheel("emotion");
406
  renderWheel("behavior");
407
  updateWheelStageSpacing();
408
+ if (appState.review.active) {
409
+ highlightReviewTarget(appState.review.currentMoveId);
410
+ }
411
  }
412
 
413
  function updateWheelStageSpacing() {
 
638
  hasRecorded && Math.abs(move.recorded_duration - move.duration_seconds) > 0.01
639
  ? `<br><small>Recorded duration: ${move.recorded_duration.toFixed(2)}s</small>`
640
  : "";
641
+ const reviewBits = [];
642
+ if (move.quality) {
643
+ reviewBits.push(`Quality: ${capitalize(move.quality)}`);
644
+ }
645
+ if (move.precision) {
646
+ reviewBits.push(`Precision: ${capitalize(move.precision)}`);
647
+ }
648
+ const reviewLine = reviewBits.length ? `<small class="muted">Review · ${reviewBits.join(" · ")}</small>` : "";
649
  tooltip.innerHTML = `
650
  <strong>${move.label}</strong>
651
  <small class="muted">dataset: ${datasetId}</small>
652
  <span>${move.wheel_sector} • ${duration} • ${move.duration_bucket.toUpperCase()} ${availability}</span>
653
+ ${reviewLine}
654
  <p>${move.description || "No description yet."}</p>
655
  <small>Intensity ${move.intensity.toFixed(1)}${recorded}</small>
656
  `;
 
667
  });
668
  }
669
 
670
+ function highlightReviewTarget(moveId) {
671
+ document.querySelectorAll(".wheel-node").forEach((node) => {
672
+ node.classList.toggle("review-target", node.dataset.move === moveId);
673
+ });
674
+ }
675
+
676
  function summarizeList(items, formatter) {
677
  if (!items?.length) {
678
  return "";
 
804
  `;
805
  }
806
 
807
+ function reviewEnabled() {
808
+ return Boolean(appState.review.enabled);
809
+ }
810
+
811
+ function buildReviewOptionButtons() {
812
+ if (!reviewEnabled()) return;
813
+ if (reviewQualityContainer) {
814
+ reviewQualityContainer.innerHTML = "";
815
+ appState.review.options.quality.forEach((value, index) => {
816
+ const btn = document.createElement("button");
817
+ btn.type = "button";
818
+ btn.className = "review-option";
819
+ btn.dataset.type = "quality";
820
+ btn.dataset.value = value;
821
+ const shortcut = REVIEW_KEYMAP.quality[index];
822
+ btn.textContent = capitalize(value);
823
+ if (shortcut) {
824
+ btn.title = `Shortcut ${shortcut}`;
825
+ btn.dataset.shortcut = shortcut;
826
+ }
827
+ btn.addEventListener("click", () => handleReviewSelection("quality", value));
828
+ reviewQualityContainer.appendChild(btn);
829
+ });
830
+ }
831
+ if (reviewPrecisionContainer) {
832
+ reviewPrecisionContainer.innerHTML = "";
833
+ appState.review.options.precision.forEach((value, index) => {
834
+ const btn = document.createElement("button");
835
+ btn.type = "button";
836
+ btn.className = "review-option";
837
+ btn.dataset.type = "precision";
838
+ btn.dataset.value = value;
839
+ const shortcut = REVIEW_KEYMAP.precision[index];
840
+ btn.textContent = capitalize(value);
841
+ if (shortcut) {
842
+ btn.title = `Shortcut ${shortcut.toUpperCase()}`;
843
+ btn.dataset.shortcut = shortcut;
844
+ }
845
+ btn.addEventListener("click", () => handleReviewSelection("precision", value));
846
+ reviewPrecisionContainer.appendChild(btn);
847
+ });
848
+ }
849
+ }
850
+
851
+ function updateReviewButtonsState(move) {
852
+ if (!reviewPanel) return;
853
+ const buttons = reviewPanel.querySelectorAll(".review-option");
854
+ buttons.forEach((btn) => {
855
+ const type = btn.dataset.type;
856
+ const value = btn.dataset.value;
857
+ let isActive = false;
858
+ if (type === "quality") {
859
+ isActive = move?.quality === value;
860
+ } else if (type === "precision") {
861
+ isActive = move?.precision === value;
862
+ }
863
+ btn.classList.toggle("active", isActive);
864
+ btn.disabled = !appState.review.active;
865
+ });
866
+ }
867
+
868
+ function updateReviewProgress() {
869
+ if (!reviewProgressEl) return;
870
+ const total = appState.moveIndex.size;
871
+ if (!total) {
872
+ reviewProgressEl.textContent = "0 / 0";
873
+ return;
874
+ }
875
+ let reviewed = 0;
876
+ appState.moveIndex.forEach((move) => {
877
+ if (isMoveReviewed(move)) {
878
+ reviewed += 1;
879
+ }
880
+ });
881
+ reviewProgressEl.textContent = `${reviewed} / ${total}`;
882
+ }
883
+
884
+ function setReviewMode(active) {
885
+ if (!reviewEnabled()) return;
886
+ const nextState = Boolean(active);
887
+ if (nextState && appState.manualMode) {
888
+ toggleManualMode(false);
889
+ }
890
+ appState.review.active = nextState;
891
+ document.body.classList.toggle("review-mode", nextState);
892
+ if (reviewPanel) {
893
+ reviewPanel.hidden = !nextState;
894
+ }
895
+ if (!nextState) {
896
+ highlightReviewTarget(null);
897
+ } else if (!appState.review.currentMoveId) {
898
+ const next = findNextReviewTarget(true);
899
+ if (next) {
900
+ setActiveReviewMove(next);
901
+ }
902
+ }
903
+ updateReviewPanel();
904
+ if (reviewToggleBtn) {
905
+ reviewToggleBtn.textContent = nextState ? "Exit review mode" : "Review mode";
906
+ }
907
+ }
908
+
909
+ function setActiveReviewMove(moveId) {
910
+ if (!reviewEnabled()) return;
911
+ if (moveId && !appState.moveIndex.has(moveId)) {
912
+ return;
913
+ }
914
+ appState.review.currentMoveId = moveId || null;
915
+ updateReviewPanel();
916
+ }
917
+
918
+ function updateReviewPanel() {
919
+ if (!reviewPanel) return;
920
+ if (!appState.review.active || !reviewEnabled()) {
921
+ reviewPanel.hidden = true;
922
+ highlightReviewTarget(null);
923
+ updateReviewProgress();
924
+ return;
925
+ }
926
+ reviewPanel.hidden = false;
927
+
928
+ const moveId = appState.review.currentMoveId;
929
+ const move = moveId ? appState.moveIndex.get(moveId) : null;
930
+
931
+ if (reviewMoveLabel) {
932
+ reviewMoveLabel.textContent = move ? move.label : "Select a move to begin.";
933
+ }
934
+ if (reviewMoveId) {
935
+ reviewMoveId.textContent = move ? move.id : "";
936
+ }
937
+ if (reviewNextBtn) {
938
+ reviewNextBtn.disabled = !Boolean(findNextReviewTarget(true));
939
+ }
940
+ if (reviewClearBtn) {
941
+ reviewClearBtn.disabled = !move;
942
+ }
943
+
944
+ updateReviewButtonsState(move);
945
+ highlightReviewTarget(moveId);
946
+ updateReviewProgress();
947
+ }
948
+
949
+ function findNextReviewTarget(requireUnreviewed = false) {
950
+ if (!appState.moveOrder.length) return null;
951
+ const currentId = appState.review.currentMoveId;
952
+ let startIndex = currentId ? appState.moveOrder.indexOf(currentId) : -1;
953
+ if (startIndex === -1) {
954
+ startIndex = -1;
955
+ }
956
+ const total = appState.moveOrder.length;
957
+ for (let offset = 1; offset <= total; offset += 1) {
958
+ const idx = (startIndex + offset) % total;
959
+ const candidateId = appState.moveOrder[idx];
960
+ const move = appState.moveIndex.get(candidateId);
961
+ if (!move) continue;
962
+ if (!requireUnreviewed || needsReview(move)) {
963
+ return candidateId;
964
+ }
965
+ }
966
+ return null;
967
+ }
968
+
969
+ async function goToNextUnreviewed(autoPlay = false) {
970
+ const next = findNextReviewTarget(true);
971
+ if (!next) {
972
+ showToast("All moves have review notes.");
973
+ return;
974
+ }
975
+ setActiveReviewMove(next);
976
+ if (autoPlay && !appState.manualMode && !appState.busy) {
977
+ triggerPlay(next);
978
+ }
979
+ }
980
+
981
+ async function persistReviewMove(moveId, ratings) {
982
+ if (!reviewEnabled() || !moveId) return;
983
+ try {
984
+ appState.review.saving = true;
985
+ reviewPanel?.classList.add("saving");
986
+ const body = {
987
+ quality: ratings.quality ?? null,
988
+ precision: ratings.precision ?? null,
989
+ };
990
+ await fetchJson(`/api/review/${encodeURIComponent(moveId)}`, {
991
+ method: "POST",
992
+ body,
993
+ });
994
+ const move = appState.moveIndex.get(moveId);
995
+ if (move) {
996
+ move.quality = body.quality;
997
+ move.precision = body.precision;
998
+ refreshMoveIndicators(moveId);
999
+ }
1000
+ updateReviewPanel();
1001
+ } catch (error) {
1002
+ console.error(error);
1003
+ showToast("Failed to save review.");
1004
+ } finally {
1005
+ appState.review.saving = false;
1006
+ reviewPanel?.classList.remove("saving");
1007
+ }
1008
+ }
1009
+
1010
+ function handleReviewSelection(type, value) {
1011
+ if (!appState.review.active) {
1012
+ showToast("Enable review mode first.");
1013
+ return;
1014
+ }
1015
+ const moveId = appState.review.currentMoveId;
1016
+ if (!moveId) {
1017
+ showToast("Select a move to review.");
1018
+ return;
1019
+ }
1020
+ const move = appState.moveIndex.get(moveId);
1021
+ if (!move) {
1022
+ showToast("Move unavailable.");
1023
+ return;
1024
+ }
1025
+ const nextRatings = {
1026
+ quality: move.quality ?? null,
1027
+ precision: move.precision ?? null,
1028
+ };
1029
+ if (type === "quality") {
1030
+ nextRatings.quality = move.quality === value ? null : value;
1031
+ } else if (type === "precision") {
1032
+ nextRatings.precision = move.precision === value ? null : value;
1033
+ }
1034
+ persistReviewMove(moveId, nextRatings);
1035
+ }
1036
+
1037
+ function clearReviewForMove() {
1038
+ const moveId = appState.review.currentMoveId;
1039
+ if (!moveId) {
1040
+ showToast("Select a move first.");
1041
+ return;
1042
+ }
1043
+ persistReviewMove(moveId, { quality: null, precision: null });
1044
+ }
1045
+
1046
  function updateManualControls() {
1047
  document.body.classList.toggle("manual-mode", appState.manualMode);
1048
  if (layoutModeBtn) {
 
1112
  setPositionsDirty(false);
1113
  updateManualControls();
1114
  setBusyState(appState.busy, appState.currentMove);
1115
+ updateReviewPanel();
1116
+ }
1117
+
1118
+ async function initReviewConfig() {
1119
+ if (!reviewPanel) return;
1120
+ try {
1121
+ const config = await fetchJson("/api/review/config");
1122
+ appState.review.enabled = Boolean(config.enabled);
1123
+ appState.review.options = {
1124
+ quality: config.quality_options || [],
1125
+ precision: config.precision_options || [],
1126
+ };
1127
+ if (appState.review.enabled) {
1128
+ buildReviewOptionButtons();
1129
+ reviewPanel.hidden = true;
1130
+ if (reviewToggleBtn) {
1131
+ reviewToggleBtn.hidden = false;
1132
+ reviewToggleBtn.textContent = "Review mode";
1133
+ }
1134
+ } else {
1135
+ reviewPanel.hidden = true;
1136
+ if (reviewToggleBtn) {
1137
+ reviewToggleBtn.hidden = true;
1138
+ }
1139
+ }
1140
+ } catch (error) {
1141
+ console.info("Review mode unavailable", error);
1142
+ appState.review.enabled = false;
1143
+ if (reviewPanel) {
1144
+ reviewPanel.hidden = true;
1145
+ }
1146
+ if (reviewToggleBtn) {
1147
+ reviewToggleBtn.hidden = true;
1148
+ }
1149
+ }
1150
  }
1151
 
1152
  async function triggerPlay(moveId) {
1153
+ if (appState.review.active) {
1154
+ setActiveReviewMove(moveId);
1155
+ }
1156
  if (appState.manualMode) {
1157
  showToast("Exit layout mode to play moves.");
1158
  return;
 
1198
 
1199
  async function init() {
1200
  try {
1201
+ await initReviewConfig();
1202
  await loadAndRender();
1203
  setBusyState(false, null);
1204
  pollState();
 
1228
  saveLayout();
1229
  });
1230
  layoutResetBtn?.addEventListener("click", () => resetLayout());
1231
+ reviewToggleBtn?.addEventListener("click", () => {
1232
+ if (!reviewEnabled()) return;
1233
+ setReviewMode(!appState.review.active);
1234
+ });
1235
+ reviewExitBtn?.addEventListener("click", () => setReviewMode(false));
1236
+ reviewNextBtn?.addEventListener("click", () => goToNextUnreviewed(true));
1237
+ reviewClearBtn?.addEventListener("click", () => clearReviewForMove());
1238
+ window.addEventListener("keydown", (event) => {
1239
+ if (!reviewEnabled()) return;
1240
+ const key = event.key?.toLowerCase();
1241
+ const rawKey = event.key;
1242
+ if (!appState.review.active || isTypingTarget(event.target)) {
1243
+ return;
1244
+ }
1245
+ if (REVIEW_KEYMAP.quality.includes(rawKey)) {
1246
+ event.preventDefault();
1247
+ const idx = REVIEW_KEYMAP.quality.indexOf(rawKey);
1248
+ const option = appState.review.options.quality[idx];
1249
+ if (option) {
1250
+ handleReviewSelection("quality", option);
1251
+ }
1252
+ return;
1253
+ }
1254
+ if (REVIEW_KEYMAP.precision.includes(key)) {
1255
+ event.preventDefault();
1256
+ const idx = REVIEW_KEYMAP.precision.indexOf(key);
1257
+ const option = appState.review.options.precision[idx];
1258
+ if (option) {
1259
+ handleReviewSelection("precision", option);
1260
+ }
1261
+ return;
1262
+ }
1263
+ if (key === "n") {
1264
+ event.preventDefault();
1265
+ goToNextUnreviewed(true);
1266
+ }
1267
+ });
emotions/static/style.css CHANGED
@@ -119,6 +119,11 @@ body {
119
  color: var(--text-muted);
120
  }
121
 
 
 
 
 
 
122
  .control-btn:disabled {
123
  opacity: 0.4;
124
  cursor: not-allowed;
@@ -337,6 +342,42 @@ body {
337
  display: none;
338
  }
339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  .coherence-panel {
341
  margin: 180px auto 0;
342
  padding: 16px 0;
@@ -399,6 +440,151 @@ body {
399
  font-style: italic;
400
  }
401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  .credits-panel {
403
  margin: 48px auto 0;
404
  padding: 18px 0 0;
 
119
  color: var(--text-muted);
120
  }
121
 
122
+ .control-btn.tertiary {
123
+ border-style: dashed;
124
+ color: var(--text-muted);
125
+ }
126
+
127
  .control-btn:disabled {
128
  opacity: 0.4;
129
  cursor: not-allowed;
 
342
  display: none;
343
  }
344
 
345
+ .node-review {
346
+ display: none;
347
+ font-size: 0.65rem;
348
+ text-transform: uppercase;
349
+ letter-spacing: 0.08em;
350
+ color: rgba(245, 247, 255, 0.55);
351
+ margin-top: 4px;
352
+ }
353
+
354
+ .review-mode .wheel-node .node-review {
355
+ display: block;
356
+ }
357
+
358
+ .node-review.hidden {
359
+ display: none;
360
+ }
361
+
362
+ .review-mode .wheel-node.review-complete::after {
363
+ content: "";
364
+ position: absolute;
365
+ inset: -6px;
366
+ border-radius: 50%;
367
+ border: 2px solid rgba(123, 211, 137, 0.8);
368
+ box-shadow: 0 0 18px rgba(123, 211, 137, 0.45);
369
+ pointer-events: none;
370
+ }
371
+
372
+ .review-mode .wheel-node.review-target::before {
373
+ content: "";
374
+ position: absolute;
375
+ inset: -10px;
376
+ border-radius: 50%;
377
+ border: 2px dashed rgba(100, 214, 255, 0.9);
378
+ pointer-events: none;
379
+ }
380
+
381
  .coherence-panel {
382
  margin: 180px auto 0;
383
  padding: 16px 0;
 
440
  font-style: italic;
441
  }
442
 
443
+ .review-panel {
444
+ margin: 40px auto 20px;
445
+ max-width: 1100px;
446
+ border-radius: 24px;
447
+ border: 1px solid rgba(255, 255, 255, 0.08);
448
+ padding: 24px 28px;
449
+ color: var(--text-soft);
450
+ display: flex;
451
+ flex-direction: column;
452
+ gap: 18px;
453
+ background: rgba(7, 11, 22, 0.6);
454
+ box-shadow: 0 25px 70px rgba(0, 0, 0, 0.55);
455
+ }
456
+
457
+ .review-panel.saving {
458
+ opacity: 0.75;
459
+ pointer-events: none;
460
+ }
461
+
462
+ .review-panel[hidden] {
463
+ display: none;
464
+ }
465
+
466
+ .review-panel header {
467
+ display: flex;
468
+ justify-content: space-between;
469
+ flex-wrap: wrap;
470
+ gap: 12px;
471
+ align-items: center;
472
+ }
473
+
474
+ .review-panel header strong {
475
+ text-transform: uppercase;
476
+ letter-spacing: 0.3em;
477
+ font-size: 0.85rem;
478
+ }
479
+
480
+ .review-instructions {
481
+ margin: 6px 0 0;
482
+ font-size: 0.85rem;
483
+ color: var(--text-muted);
484
+ }
485
+
486
+ .review-instructions summary {
487
+ cursor: pointer;
488
+ letter-spacing: 0.15em;
489
+ text-transform: uppercase;
490
+ }
491
+
492
+ .review-instructions ul {
493
+ margin: 8px 0 0 16px;
494
+ padding: 0;
495
+ list-style: disc;
496
+ color: var(--text-soft);
497
+ font-size: 0.9rem;
498
+ }
499
+
500
+ .review-instructions code {
501
+ background: rgba(255, 255, 255, 0.08);
502
+ padding: 2px 6px;
503
+ border-radius: 6px;
504
+ font-size: 0.8rem;
505
+ }
506
+
507
+ .review-progress {
508
+ font-variant-numeric: tabular-nums;
509
+ letter-spacing: 0.12em;
510
+ font-size: 0.85rem;
511
+ color: var(--text-muted);
512
+ }
513
+
514
+ .review-current {
515
+ display: flex;
516
+ justify-content: space-between;
517
+ gap: 24px;
518
+ flex-wrap: wrap;
519
+ align-items: center;
520
+ padding: 12px 0 0;
521
+ }
522
+
523
+ .review-current p {
524
+ margin: 0;
525
+ font-size: 1rem;
526
+ }
527
+
528
+ .review-current .muted {
529
+ color: var(--text-muted);
530
+ }
531
+
532
+ .review-current-actions {
533
+ display: flex;
534
+ gap: 10px;
535
+ flex-wrap: wrap;
536
+ }
537
+
538
+ .review-controls {
539
+ display: grid;
540
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
541
+ gap: 18px;
542
+ }
543
+
544
+ .review-controls article {
545
+ background: rgba(7, 11, 22, 0.65);
546
+ border: 1px solid rgba(255, 255, 255, 0.07);
547
+ border-radius: 14px;
548
+ padding: 18px;
549
+ }
550
+
551
+ .review-controls h3 {
552
+ margin: 0 0 10px;
553
+ text-transform: uppercase;
554
+ letter-spacing: 0.2em;
555
+ font-size: 0.8rem;
556
+ color: var(--text-muted);
557
+ }
558
+
559
+ .review-options {
560
+ display: flex;
561
+ flex-wrap: wrap;
562
+ gap: 12px;
563
+ }
564
+
565
+ .review-option {
566
+ border: 1px solid rgba(255, 255, 255, 0.15);
567
+ background: transparent;
568
+ color: var(--text-strong);
569
+ border-radius: 999px;
570
+ padding: 8px 18px;
571
+ text-transform: uppercase;
572
+ letter-spacing: 0.18em;
573
+ font-size: 0.78rem;
574
+ cursor: pointer;
575
+ transition: background 0.2s ease, border-color 0.2s ease;
576
+ }
577
+
578
+ .review-option.active {
579
+ background: rgba(255, 255, 255, 0.12);
580
+ border-color: rgba(255, 255, 255, 0.35);
581
+ }
582
+
583
+ .review-option:disabled {
584
+ opacity: 0.4;
585
+ cursor: not-allowed;
586
+ }
587
+
588
  .credits-panel {
589
  margin: 48px auto 0;
590
  padding: 18px 0 0;
ressources/emotions.yml CHANGED
@@ -203,6 +203,8 @@ moves:
203
  position:
204
  angle: 22.5
205
  radius: 1.272667
 
 
206
  - id: come1
207
  clean_name: Come Here
208
  type: behavior
@@ -308,6 +310,8 @@ moves:
308
  position:
309
  angle: 292.5
310
  radius: 0.747667
 
 
311
  - id: displeased2
312
  clean_name: Displeased
313
  type: behavior
@@ -415,6 +419,8 @@ moves:
415
  position:
416
  angle: 292.5
417
  radius: 1.825667
 
 
418
  - id: furious1
419
  clean_name: Furious
420
  type: emotion
@@ -427,6 +433,8 @@ moves:
427
  position:
428
  angle: 292.5
429
  radius: 2.917667
 
 
430
  - id: go_away1
431
  clean_name: Go Away
432
  type: behavior
@@ -568,6 +576,8 @@ moves:
568
  position:
569
  angle: 292.5
570
  radius: 1.272667
 
 
571
  - id: irritated2
572
  clean_name: Irritated
573
  type: emotion
@@ -580,6 +590,8 @@ moves:
580
  position:
581
  angle: 292.5
582
  radius: 2.378667
 
 
583
  - id: laughing1
584
  clean_name: Laughing
585
  type: behavior
@@ -744,6 +756,8 @@ moves:
744
  position:
745
  angle: 292.5
746
  radius: 3.442667
 
 
747
  - id: relief1
748
  clean_name: Relief
749
  type: emotion
 
203
  position:
204
  angle: 22.5
205
  radius: 1.272667
206
+ quality: bad
207
+ precision: clear
208
  - id: come1
209
  clean_name: Come Here
210
  type: behavior
 
310
  position:
311
  angle: 292.5
312
  radius: 0.747667
313
+ quality: ok
314
+ precision: clear
315
  - id: displeased2
316
  clean_name: Displeased
317
  type: behavior
 
419
  position:
420
  angle: 292.5
421
  radius: 1.825667
422
+ precision: ambiguous
423
+ quality: ok
424
  - id: furious1
425
  clean_name: Furious
426
  type: emotion
 
433
  position:
434
  angle: 292.5
435
  radius: 2.917667
436
+ quality: ok
437
+ precision: ambiguous
438
  - id: go_away1
439
  clean_name: Go Away
440
  type: behavior
 
576
  position:
577
  angle: 292.5
578
  radius: 1.272667
579
+ precision: clear
580
+ quality: ok
581
  - id: irritated2
582
  clean_name: Irritated
583
  type: emotion
 
590
  position:
591
  angle: 292.5
592
  radius: 2.378667
593
+ precision: clear
594
+ quality: ok
595
  - id: laughing1
596
  clean_name: Laughing
597
  type: behavior
 
756
  position:
757
  angle: 292.5
758
  radius: 3.442667
759
+ quality: excellent
760
+ precision: clear
761
  - id: relief1
762
  clean_name: Relief
763
  type: emotion