elix3r commited on
Commit
3e6965a
·
verified ·
1 Parent(s): 915d3ac

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +27 -0
  2. LICENSE +21 -0
  3. README.md +110 -7
  4. __init__.py +28 -0
  5. client.py +8 -0
  6. clip_quality_env.egg-info/PKG-INFO +18 -0
  7. clip_quality_env.egg-info/SOURCES.txt +33 -0
  8. clip_quality_env.egg-info/dependency_links.txt +1 -0
  9. clip_quality_env.egg-info/entry_points.txt +2 -0
  10. clip_quality_env.egg-info/requires.txt +12 -0
  11. clip_quality_env.egg-info/top_level.txt +2 -0
  12. clip_quality_env/__init__.py +38 -0
  13. clip_quality_env/agent.py +92 -0
  14. clip_quality_env/client.py +34 -0
  15. clip_quality_env/difficulty.py +46 -0
  16. clip_quality_env/env.py +450 -0
  17. clip_quality_env/generator.py +207 -0
  18. clip_quality_env/grader.py +142 -0
  19. clip_quality_env/ground_truth.py +125 -0
  20. clip_quality_env/models.py +137 -0
  21. clip_quality_env/real_clips.py +121 -0
  22. clip_quality_env/rubric.py +343 -0
  23. clip_quality_env/train.py +75 -0
  24. data/real_clips_manifest.jsonl +20 -0
  25. data/seed_gt.json +22 -0
  26. inference.py +248 -0
  27. models.py +29 -0
  28. openenv.yaml +16 -0
  29. pyproject.toml +30 -0
  30. requirements.txt +11 -0
  31. requirements_extractor.txt +4 -0
  32. scripts/extract_mp4_metadata.py +696 -0
  33. server/__init__.py +1 -0
  34. server/app.py +963 -0
  35. server/baseline_runs.py +160 -0
  36. server/clip_quality_environment.py +9 -0
  37. server/environment.py +8 -0
  38. server/grader.py +133 -0
  39. server/requirements.txt +8 -0
  40. server/tasks/__init__.py +13 -0
  41. server/tasks/task_easy.py +159 -0
  42. server/tasks/task_hard.py +160 -0
  43. server/tasks/task_medium.py +159 -0
  44. spaces_app.py +64 -0
  45. state/ground_truth.json +198 -0
  46. state/history.jsonl +98 -0
  47. tests/test_baseline_runs.py +112 -0
  48. tests/test_environment.py +354 -0
  49. tests/test_grader.py +139 -0
  50. tests/test_inference.py +72 -0
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ build-essential \
7
+ ffmpeg \
8
+ curl \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Copy requirements first for better caching
12
+ COPY server/requirements.txt /app/requirements.txt
13
+ RUN pip install --no-cache-dir -r /app/requirements.txt
14
+
15
+ # Copy all source code
16
+ COPY . /app
17
+
18
+ EXPOSE 8000
19
+
20
+ ENV PYTHONUNBUFFERED=1
21
+ ENV PYTHONPATH=/app
22
+ ENV ENABLE_WEB_INTERFACE=true
23
+
24
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
25
+ CMD sh -lc 'curl -f http://localhost:${PORT:-8000}/health || exit 1'
26
+
27
+ CMD ["sh", "-lc", "python -m uvicorn server.app:app --host 0.0.0.0 --port ${PORT:-8000}"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 elix3r
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,11 +1,114 @@
1
  ---
2
- title: Clip Quality Env
3
- emoji: 🏃
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: docker
7
- pinned: false
8
- license: mit
 
 
 
 
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: CLIP Quality Analyzer
3
+ colorFrom: purple
4
+ colorTo: gray
 
5
  sdk: docker
6
+ app_port: 8000
7
+ base_path: /dashboard/
8
+ tags:
9
+ - openenv
10
+ - reinforcement-learning
11
+ - clip-quality
12
+ - quality-analysis
13
  ---
14
 
15
+ # CLIP Quality Analyzer Environment
16
+
17
+ ClipQualityEnv is an OpenEnv-compliant RL environment for CLIP quality analysis workflows. It keeps the reference OpenEnv structure while presenting a clip-quality review and classification surface.
18
+
19
+ ## Action Space
20
+
21
+ Classifier action payload:
22
+
23
+ - `label`: one of `KEEP`, `BORDERLINE`, `REJECT`
24
+ - `reasoning`: concise clip-metadata-grounded explanation
25
+ - `confidence`: float in `[0.0, 1.0]`
26
+ - `clip_id` (optional): specific clip target
27
+
28
+ ## Observation Space
29
+
30
+ Each observation includes:
31
+ - `task_id`, `episode_id`, `step_count`
32
+ - `data_corpus`, `clip_metadata`, `rubric_summary`
33
+ - `reward`, `done`, and step diagnostics in `info`
34
+
35
+ These fields are preserved for OpenEnv/reference compatibility and surfaced as clip-case analysis artifacts in `/dashboard/`.
36
+
37
+ ## Tasks
38
+
39
+ | Task ID | Difficulty | Description |
40
+ |---|---|---|
41
+ | `task_easy` | Easy | Classify clips with clear quality signals |
42
+ | `task_medium` | Medium | Classify borderline clips with mixed indicators |
43
+ | `task_hard` | Hard | Classify hard clips with conflicting quality signals |
44
+
45
+ Overall total scores are difficulty-calibrated so task-level averages follow: `hard > medium > easy`.
46
+
47
+ ## API + Dashboard Surface
48
+
49
+ - `GET /` -> JSON status
50
+ - `GET /health`
51
+ - `GET /state`
52
+ - `GET /tasks`
53
+ - `POST /grader`
54
+ - `POST /baseline/start`
55
+ - `GET /baseline/status/{run_id}`
56
+ - `GET /baseline` (compatibility wrapper for async baseline start)
57
+ - OpenEnv endpoints: `/reset`, `/step`, `/ws`, `/metadata`, `/schema`
58
+ - Gradio dashboard: `/dashboard/`
59
+
60
+ The API surface remains reference-compatible; product naming and UI wording are clip-quality focused.
61
+
62
+ `POST /grader` accepts the classifier action payload above.
63
+
64
+ ## Dashboard Highlights
65
+
66
+ - 5-step same-scenario review sessions with running totals
67
+ - Full corpus queue display (no slicing), sorted by Clip ID, with live review-status updates
68
+ - Difficulty-tiered input tabs (Easy / Medium / Hard) with scenario-aware tab switching
69
+ - `💡 Load Quality Hint` helper button to scaffold reasoning text from dominant feature boundary cues
70
+ - Session history tab with submitted vs expected labels and per-step rewards
71
+ - Non-blocking `🤖 Run LLM Baseline Agent` control with async status polling and result summary
72
+
73
+ ## Local Setup
74
+
75
+ ```bash
76
+ python -m venv .venv
77
+ source .venv/bin/activate
78
+ pip install -r server/requirements.txt
79
+ PYTHONPATH=. python -m pytest -q
80
+ ```
81
+
82
+ Run server:
83
+
84
+ ```bash
85
+ python -m uvicorn server.app:app --host 0.0.0.0 --port 8000
86
+ ```
87
+
88
+ ## Baseline Inference
89
+
90
+ Set env vars:
91
+
92
+ ```bash
93
+ export API_BASE_URL="https://router.huggingface.co/v1"
94
+ export MODEL_NAME="llama-3.3-70b-versatile"
95
+ export HF_TOKEN="your_token"
96
+ ```
97
+
98
+ Run:
99
+
100
+ ```bash
101
+ python inference.py
102
+ ```
103
+
104
+ Optional single task:
105
+
106
+ ```bash
107
+ python inference.py task_easy
108
+ ```
109
+
110
+ ## Hugging Face Space Deployment
111
+
112
+ ```bash
113
+ openenv push --repo-id elix3r/ClipQualityEnv .
114
+ ```
__init__.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Root OpenEnv exports for deployment tooling compatibility."""
2
+
3
+ from clip_quality_env import (
4
+ Action,
5
+ ClipLabel,
6
+ ClipQualityClient,
7
+ ClipQualityEnv,
8
+ ClipQualityEnvironment,
9
+ EnvironmentState,
10
+ Observation,
11
+ State,
12
+ TaskInfo,
13
+ )
14
+
15
+ # Backward-compatibility alias (deprecated).
16
+ PolicyEvolverEnv = ClipQualityClient
17
+
18
+ __all__ = [
19
+ "Action",
20
+ "ClipLabel",
21
+ "ClipQualityClient",
22
+ "ClipQualityEnv",
23
+ "ClipQualityEnvironment",
24
+ "EnvironmentState",
25
+ "Observation",
26
+ "State",
27
+ "TaskInfo",
28
+ ]
client.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Root client module for OpenEnv CLI compatibility."""
2
+
3
+ from clip_quality_env.client import ClipQualityClient
4
+
5
+ # Backward-compatibility alias (deprecated).
6
+ PolicyEvolverEnv = ClipQualityClient
7
+
8
+ __all__ = ["ClipQualityClient"]
clip_quality_env.egg-info/PKG-INFO ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: clip-quality-env
3
+ Version: 1.0.0
4
+ Summary: ClipQualityEnv — OpenEnv RL environment for clip-quality analysis and classification
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Requires-Dist: openenv-core>=0.2.3
8
+ Requires-Dist: pydantic>=2.0.0
9
+ Requires-Dist: openai>=1.0.0
10
+ Requires-Dist: fastapi>=0.104.0
11
+ Requires-Dist: uvicorn>=0.24.0
12
+ Requires-Dist: requests>=2.25.0
13
+ Requires-Dist: websockets>=12.0
14
+ Requires-Dist: gradio>=4.0.0
15
+ Requires-Dist: pandas>=2.0.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
18
+ Dynamic: license-file
clip_quality_env.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ clip_quality_env/__init__.py
5
+ clip_quality_env/agent.py
6
+ clip_quality_env/client.py
7
+ clip_quality_env/difficulty.py
8
+ clip_quality_env/env.py
9
+ clip_quality_env/generator.py
10
+ clip_quality_env/grader.py
11
+ clip_quality_env/ground_truth.py
12
+ clip_quality_env/models.py
13
+ clip_quality_env/real_clips.py
14
+ clip_quality_env/rubric.py
15
+ clip_quality_env/train.py
16
+ clip_quality_env.egg-info/PKG-INFO
17
+ clip_quality_env.egg-info/SOURCES.txt
18
+ clip_quality_env.egg-info/dependency_links.txt
19
+ clip_quality_env.egg-info/entry_points.txt
20
+ clip_quality_env.egg-info/requires.txt
21
+ clip_quality_env.egg-info/top_level.txt
22
+ server/__init__.py
23
+ server/app.py
24
+ server/baseline_runs.py
25
+ server/clip_quality_environment.py
26
+ server/environment.py
27
+ server/grader.py
28
+ tests/test_baseline_runs.py
29
+ tests/test_environment.py
30
+ tests/test_grader.py
31
+ tests/test_inference.py
32
+ tests/test_models.py
33
+ tests/test_server_routes.py
clip_quality_env.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
clip_quality_env.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ server = server.app:main
clip_quality_env.egg-info/requires.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ openenv-core>=0.2.3
2
+ pydantic>=2.0.0
3
+ openai>=1.0.0
4
+ fastapi>=0.104.0
5
+ uvicorn>=0.24.0
6
+ requests>=2.25.0
7
+ websockets>=12.0
8
+ gradio>=4.0.0
9
+ pandas>=2.0.0
10
+
11
+ [dev]
12
+ pytest>=7.0.0
clip_quality_env.egg-info/top_level.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ clip_quality_env
2
+ server
clip_quality_env/__init__.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Clip-quality environment package."""
2
+
3
+ from .client import ClipQualityClient
4
+ from .env import ClipQualityEnv, ClipQualityEnvironment
5
+ from .models import (
6
+ Action,
7
+ ClipLabel,
8
+ ClipMetadata,
9
+ CorpusIncident,
10
+ EnvironmentState,
11
+ EpisodeHistoryItem,
12
+ HistoryItem,
13
+ Observation,
14
+ Reward,
15
+ State,
16
+ TaskInfo,
17
+ )
18
+
19
+ # Backward-compatibility aliases (deprecated).
20
+ PolicyEvolverEnv = ClipQualityClient
21
+ PolicyEvolverEnvironment = ClipQualityEnvironment
22
+
23
+ __all__ = [
24
+ "Action",
25
+ "ClipLabel",
26
+ "ClipMetadata",
27
+ "ClipQualityClient",
28
+ "ClipQualityEnv",
29
+ "ClipQualityEnvironment",
30
+ "CorpusIncident",
31
+ "EnvironmentState",
32
+ "EpisodeHistoryItem",
33
+ "HistoryItem",
34
+ "Observation",
35
+ "Reward",
36
+ "State",
37
+ "TaskInfo",
38
+ ]
clip_quality_env/agent.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from typing import Any
7
+
8
+ from openai import OpenAI
9
+
10
+ from .models import Action, Observation
11
+
12
+
13
+ LABEL_RE = re.compile(r"<label>\s*(KEEP|BORDERLINE|REJECT)\s*</label>", re.IGNORECASE)
14
+ REASONING_RE = re.compile(r"<reasoning>\s*(.*?)\s*</reasoning>", re.IGNORECASE | re.DOTALL)
15
+ CONFIDENCE_RE = re.compile(r"<confidence>\s*([0-9]*\.?[0-9]+)\s*</confidence>", re.IGNORECASE)
16
+
17
+
18
+ class LLMAgent:
19
+ """OpenAI-client-backed agent for ClipQualityEnv."""
20
+
21
+ SYSTEM_PROMPT = (
22
+ "You are a dataset quality analyst for talking-head LoRA training clips.\n"
23
+ "Classify each clip as KEEP, BORDERLINE, or REJECT.\n"
24
+ "Always respond with XML tags exactly:\n"
25
+ "<label>...</label>\n<reasoning>...</reasoning>\n<confidence>0.0-1.0</confidence>"
26
+ )
27
+
28
+ def __init__(
29
+ self,
30
+ model_name: str | None = None,
31
+ api_base_url: str | None = None,
32
+ api_key: str | None = None,
33
+ temperature: float = 0.3,
34
+ max_tokens: int = 512,
35
+ ) -> None:
36
+ self.model_name = model_name or os.environ.get("MODEL_NAME", "meta-llama/Llama-3-70B-Instruct")
37
+ self.api_base_url = api_base_url or os.environ.get("API_BASE_URL", "https://api-inference.huggingface.co/v1/")
38
+ self.api_key = api_key or os.environ.get("HF_TOKEN") or os.environ.get("OPENAI_API_KEY")
39
+ if not self.api_key:
40
+ raise ValueError("Either HF_TOKEN or OPENAI_API_KEY must be set")
41
+ self.temperature = temperature
42
+ self.max_tokens = max_tokens
43
+ self.client = OpenAI(base_url=self.api_base_url, api_key=self.api_key)
44
+
45
+ def act(self, obs: Observation | dict[str, Any]) -> Action:
46
+ observation = obs if isinstance(obs, Observation) else Observation(**obs)
47
+ prompt = self._build_prompt(observation)
48
+ raw = self._call_model(prompt)
49
+ return self._parse_response(raw)
50
+
51
+ def _build_prompt(self, obs: Observation) -> str:
52
+ parts = [obs.rubric_summary, ""]
53
+ for h in obs.history:
54
+ parts.append(f"[STEP {h.step} - Previous]")
55
+ parts.append(f"Your label: {h.label} | Reward: {h.reward:.2f}")
56
+ parts.append("")
57
+ parts.append(f"[STEP {obs.step} - Current Clip]")
58
+ parts.append(json.dumps(obs.clip_metadata.model_dump(), indent=2))
59
+ parts.append("")
60
+ parts.append("Classify this clip.")
61
+ return "\n".join(parts)
62
+
63
+ def _call_model(self, prompt: str) -> str:
64
+ response = self.client.chat.completions.create(
65
+ model=self.model_name,
66
+ messages=[
67
+ {"role": "system", "content": self.SYSTEM_PROMPT},
68
+ {"role": "user", "content": prompt},
69
+ ],
70
+ temperature=self.temperature,
71
+ max_tokens=self.max_tokens,
72
+ )
73
+ content = response.choices[0].message.content
74
+ if not content:
75
+ return "<label>BORDERLINE</label><reasoning>No content.</reasoning><confidence>0.5</confidence>"
76
+ return content
77
+
78
+ def _parse_response(self, text: str) -> Action:
79
+ label_match = LABEL_RE.search(text)
80
+ reasoning_match = REASONING_RE.search(text)
81
+ confidence_match = CONFIDENCE_RE.search(text)
82
+
83
+ label = label_match.group(1).upper() if label_match else "BORDERLINE"
84
+ reasoning = reasoning_match.group(1).strip() if reasoning_match else "No reasoning provided."
85
+ confidence = 0.5
86
+ if confidence_match:
87
+ try:
88
+ confidence = float(confidence_match.group(1))
89
+ except ValueError:
90
+ confidence = 0.5
91
+ confidence = max(0.0, min(1.0, confidence))
92
+ return Action(label=label, reasoning=reasoning, confidence=confidence)
clip_quality_env/client.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from openenv.core import EnvClient
6
+ from openenv.core.client_types import StepResult
7
+
8
+ from .models import Action, EnvironmentState, Observation
9
+
10
+
11
+ class ClipQualityClient(EnvClient[Action, Observation, EnvironmentState]):
12
+ """Typed async client for ClipQualityEnv with sync wrapper support."""
13
+
14
+ def _step_payload(self, action: Action) -> dict[str, Any]:
15
+ payload = action.model_dump()
16
+ if isinstance(payload, dict) and "root" in payload and isinstance(payload["root"], dict):
17
+ return payload["root"]
18
+ return payload if isinstance(payload, dict) else {"action": payload}
19
+
20
+ def _parse_result(self, payload: dict[str, Any]) -> StepResult[Observation]:
21
+ obs_data = payload.get("observation", {})
22
+ if "done" not in obs_data:
23
+ obs_data["done"] = bool(payload.get("done", False))
24
+ if "reward" not in obs_data:
25
+ obs_data["reward"] = payload.get("reward")
26
+ observation = Observation.model_validate(obs_data)
27
+ return StepResult(
28
+ observation=observation,
29
+ reward=payload.get("reward"),
30
+ done=bool(payload.get("done", False)),
31
+ )
32
+
33
+ def _parse_state(self, payload: dict[str, Any]) -> EnvironmentState:
34
+ return EnvironmentState.model_validate(payload)
clip_quality_env/difficulty.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Final
4
+
5
+
6
+ DIFFICULTY_ORDER: Final[dict[str, int]] = {
7
+ "easy": 0,
8
+ "medium": 1,
9
+ "hard": 2,
10
+ }
11
+
12
+ # Final-score calibration bands for enforcing strict ordering:
13
+ # easy < medium < hard for all calibrated totals.
14
+ DIFFICULTY_TOTAL_BANDS: Final[dict[str, tuple[float, float]]] = {
15
+ "easy": (0.00, 0.32),
16
+ "medium": (0.34, 0.66),
17
+ "hard": (0.68, 1.00),
18
+ }
19
+
20
+
21
+ def _clamp01(value: float) -> float:
22
+ return max(0.0, min(1.0, float(value)))
23
+
24
+
25
+ def normalize_difficulty(value: str | None) -> str | None:
26
+ if value is None:
27
+ return None
28
+ normalized = str(value).strip().lower()
29
+ if normalized not in DIFFICULTY_ORDER:
30
+ return None
31
+ return normalized
32
+
33
+
34
+ def calibrate_total_score(total: float, difficulty: str | None) -> float:
35
+ raw = _clamp01(total)
36
+ normalized = normalize_difficulty(difficulty)
37
+ if normalized is None:
38
+ return raw
39
+
40
+ band_min, band_max = DIFFICULTY_TOTAL_BANDS[normalized]
41
+ band_min = float(band_min)
42
+ band_max = float(band_max)
43
+ if band_max <= band_min:
44
+ return raw
45
+
46
+ return band_min + (raw * (band_max - band_min))
clip_quality_env/env.py ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import os
5
+ import random
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from openenv.core import Environment
11
+
12
+ from .grader import grade
13
+ from .ground_truth import GTStore
14
+ from .models import Action, ClipMetadata, EpisodeHistoryItem, HistoryItem, Observation, State
15
+ from .real_clips import load_real_clip_manifest
16
+ from .rubric import RubricState
17
+ from server.tasks import TASK_REGISTRY
18
+
19
+ EPISODE_STEPS = 5
20
+ DEFAULT_REAL_CLIPS_MANIFEST = "data/real_clips_manifest.jsonl"
21
+
22
+
23
+ @dataclass
24
+ class EpisodeClip:
25
+ task_id: str
26
+ difficulty: str
27
+ clip: dict[str, Any]
28
+
29
+
30
+ class ClipQualityEnvironment(Environment[Action, Observation, State]):
31
+ """OpenEnv environment for clip-quality classification tasks."""
32
+
33
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
34
+
35
+ def __init__(self) -> None:
36
+ super().__init__()
37
+ self._rubric = RubricState()
38
+ self._gt_store = GTStore()
39
+ self._state = State(
40
+ max_steps=EPISODE_STEPS,
41
+ total_reward=0.0,
42
+ rubric_version=self._rubric.version,
43
+ gt_size=self._gt_store.size(),
44
+ rubric_thresholds=self._rubric.get_thresholds_summary(),
45
+ )
46
+ self._episode_plan: list[EpisodeClip] = []
47
+ self._episode_corpus: dict[str, list[dict[str, Any]]] = {}
48
+ self._persistent_best_score = 0.0
49
+ self._last_reward_breakdown = {
50
+ "format_score": 0.0,
51
+ "label_score": 0.0,
52
+ "reasoning_score": 0.0,
53
+ "total_reward": 0.0,
54
+ }
55
+ self._corpus_source = "task_registry"
56
+ self._manifest_warning = ""
57
+ self._manifest_path = os.environ.get("REAL_CLIPS_MANIFEST", DEFAULT_REAL_CLIPS_MANIFEST)
58
+ self._real_clip_pools = self._load_real_clip_pools()
59
+
60
+ def _load_real_clip_pools(self) -> dict[str, list[dict[str, Any]]]:
61
+ if not os.path.exists(self._manifest_path):
62
+ self._manifest_warning = (
63
+ f"Real clip manifest not found at {self._manifest_path}; using static task corpora."
64
+ )
65
+ return {}
66
+ try:
67
+ pools = load_real_clip_manifest(self._manifest_path, self._rubric)
68
+ except Exception as exc:
69
+ self._manifest_warning = (
70
+ f"Failed loading real clip manifest from {self._manifest_path}: {exc}; using static task corpora."
71
+ )
72
+ return {}
73
+ self._manifest_warning = ""
74
+ return pools
75
+
76
+ def _choose_tasks(self, seed: int | None = None, task_id: str | None = None) -> list[str]:
77
+ if task_id is not None:
78
+ if task_id not in TASK_REGISTRY:
79
+ raise KeyError(f"Unknown task_id: {task_id}")
80
+ return [task_id]
81
+ rng = random.Random(seed)
82
+ return [rng.choice(list(TASK_REGISTRY.keys()))]
83
+
84
+ def _load_task_corpus(self, task_id: str) -> list[dict[str, Any]]:
85
+ task = TASK_REGISTRY[task_id]
86
+ difficulty = str(task.get("difficulty", "")).lower()
87
+ corpus: list[dict[str, Any]]
88
+
89
+ if difficulty and self._real_clip_pools.get(difficulty):
90
+ corpus = copy.deepcopy(self._real_clip_pools[difficulty])
91
+ self._corpus_source = f"manifest:{difficulty}"
92
+ else:
93
+ corpus = copy.deepcopy(task.get("data_corpus", []))
94
+ self._corpus_source = f"task_registry:{task_id}"
95
+
96
+ if not corpus:
97
+ raise ValueError(f"No clip corpus configured for task_id={task_id}")
98
+
99
+ for clip in corpus:
100
+ clip.setdefault("clip_id", clip.get("id", str(uuid.uuid4())))
101
+ if "expected_label" not in clip:
102
+ clip["expected_label"] = self._rubric.derive_label(clip)
103
+ clip["review_status"] = str(clip.get("review_status", "pending")).lower()
104
+
105
+ corpus.sort(key=lambda item: str(item.get("clip_id", item.get("id", ""))))
106
+ return corpus
107
+
108
+ def _sample_episode_clips(self, corpus: list[dict[str, Any]], seed: int | None = None) -> list[dict[str, Any]]:
109
+ if not corpus:
110
+ raise ValueError("Cannot sample episode clips from an empty corpus")
111
+ rng = random.Random(seed)
112
+ if len(corpus) >= EPISODE_STEPS:
113
+ return [copy.deepcopy(clip) for clip in rng.sample(corpus, k=EPISODE_STEPS)]
114
+
115
+ pool = [copy.deepcopy(clip) for clip in corpus]
116
+ rng.shuffle(pool)
117
+ sampled: list[dict[str, Any]] = []
118
+ while len(sampled) < EPISODE_STEPS:
119
+ sampled.append(copy.deepcopy(pool[len(sampled) % len(pool)]))
120
+ return sampled
121
+
122
+ def _sample_episode_plan(
123
+ self,
124
+ task_ids: list[str],
125
+ seed: int | None = None,
126
+ ) -> tuple[list[EpisodeClip], dict[str, list[dict[str, Any]]]]:
127
+ if len(task_ids) != 1:
128
+ raise ValueError("Episode planning expects exactly one task id")
129
+ task_id = task_ids[0]
130
+ task = TASK_REGISTRY[task_id]
131
+ difficulty = str(task.get("difficulty", ""))
132
+ corpus = self._load_task_corpus(task_id)
133
+ sampled_clips = self._sample_episode_clips(corpus, seed=seed)
134
+ plan = [
135
+ EpisodeClip(task_id=task_id, difficulty=difficulty, clip=clip)
136
+ for clip in sampled_clips
137
+ ]
138
+ corpus_map: dict[str, list[dict[str, Any]]] = {task_id: corpus}
139
+ return plan, corpus_map
140
+
141
+ def _threshold_range_text(self, feature: str) -> str:
142
+ threshold = self._rubric.thresholds.get(feature)
143
+ if not threshold:
144
+ return "N/A"
145
+ mode = str(threshold["mode"])
146
+ keep_min = float(threshold["keep_min"])
147
+ keep_max = float(threshold["keep_max"])
148
+ reject_min = float(threshold["reject_min"])
149
+ reject_max = float(threshold["reject_max"])
150
+ if mode == "higher":
151
+ return f"KEEP >= {keep_min:.3g}; BORDERLINE [{reject_max:.3g}, {keep_min:.3g}); REJECT < {reject_max:.3g}"
152
+ if mode == "lower":
153
+ return f"KEEP <= {keep_max:.3g}; BORDERLINE ({keep_max:.3g}, {reject_min:.3g}]; REJECT > {reject_min:.3g}"
154
+ return (
155
+ f"KEEP [{keep_min:.3g}, {keep_max:.3g}]; BORDERLINE [{reject_min:.3g}, {keep_min:.3g})"
156
+ f" U ({keep_max:.3g}, {reject_max:.3g}]; REJECT outside [{reject_min:.3g}, {reject_max:.3g}]"
157
+ )
158
+
159
+ def _closest_boundary_features(self, clip: dict[str, Any], top_n: int = 2) -> list[str]:
160
+ distances: list[tuple[float, str]] = []
161
+ for feature, threshold in self._rubric.thresholds.items():
162
+ value = clip.get(feature)
163
+ if not isinstance(value, (int, float)):
164
+ continue
165
+ mode = str(threshold["mode"])
166
+ keep_min = float(threshold["keep_min"])
167
+ keep_max = float(threshold["keep_max"])
168
+ reject_min = float(threshold["reject_min"])
169
+ reject_max = float(threshold["reject_max"])
170
+ if mode == "higher":
171
+ boundaries = [keep_min, reject_max]
172
+ elif mode == "lower":
173
+ boundaries = [keep_max, reject_min]
174
+ else:
175
+ boundaries = [keep_min, keep_max, reject_min, reject_max]
176
+ min_distance = min(abs(float(value) - boundary) for boundary in boundaries)
177
+ distances.append((min_distance, feature))
178
+ distances.sort(key=lambda item: item[0])
179
+ return [feature for _, feature in distances[:top_n]]
180
+
181
+ def dominant_feature_rows(self, clip: dict[str, Any] | None = None) -> list[dict[str, Any]]:
182
+ if clip is None:
183
+ current_index = min(self._state.step_count, max(len(self._episode_plan) - 1, 0))
184
+ if not self._episode_plan:
185
+ return []
186
+ clip = self._episode_plan[current_index].clip
187
+ dominant_features = self._rubric.get_dominant_features(clip)
188
+ rows: list[dict[str, Any]] = []
189
+ for feature in dominant_features:
190
+ value = clip.get(feature)
191
+ if not isinstance(value, (int, float)):
192
+ continue
193
+ status = self._rubric.get_feature_status(feature, float(value))
194
+ rows.append(
195
+ {
196
+ "Feature Name": feature,
197
+ "Current Value": float(value),
198
+ "Rubric Status": status,
199
+ "Threshold Range": self._threshold_range_text(feature),
200
+ }
201
+ )
202
+ return rows
203
+
204
+ def build_quality_hint(self, clip: dict[str, Any] | None = None) -> str:
205
+ if clip is None:
206
+ current_index = min(self._state.step_count, max(len(self._episode_plan) - 1, 0))
207
+ if not self._episode_plan:
208
+ return ""
209
+ clip = self._episode_plan[current_index].clip
210
+ focus_features = self._closest_boundary_features(clip, top_n=2)
211
+ if not focus_features:
212
+ return "Use dominant clip metadata cues and compare each value against rubric thresholds."
213
+
214
+ segments: list[str] = []
215
+ for idx, feature in enumerate(focus_features):
216
+ value = clip.get(feature)
217
+ if not isinstance(value, (int, float)):
218
+ continue
219
+ status = self._rubric.get_feature_status(feature, float(value))
220
+ threshold = self._rubric.thresholds.get(feature, {})
221
+ mode = str(threshold.get("mode", ""))
222
+ keep_min = float(threshold.get("keep_min", 0.0))
223
+ keep_max = float(threshold.get("keep_max", 0.0))
224
+ reject_min = float(threshold.get("reject_min", 0.0))
225
+ reject_max = float(threshold.get("reject_max", 0.0))
226
+
227
+ if mode == "higher":
228
+ if status == "KEEP":
229
+ direction = f"above the KEEP threshold ({keep_min:.3g})"
230
+ elif status == "REJECT":
231
+ direction = f"below the REJECT threshold ({reject_max:.3g})"
232
+ else:
233
+ direction = f"within the BORDERLINE range [{reject_max:.3g}, {keep_min:.3g})"
234
+ elif mode == "lower":
235
+ if status == "KEEP":
236
+ direction = f"below the KEEP ceiling ({keep_max:.3g})"
237
+ elif status == "REJECT":
238
+ direction = f"above the REJECT threshold ({reject_min:.3g})"
239
+ else:
240
+ direction = f"within the BORDERLINE range ({keep_max:.3g}, {reject_min:.3g}]"
241
+ else:
242
+ if status == "KEEP":
243
+ direction = f"within the KEEP band [{keep_min:.3g}, {keep_max:.3g}]"
244
+ elif status == "REJECT":
245
+ direction = f"outside the acceptable range [{reject_min:.3g}, {reject_max:.3g}]"
246
+ else:
247
+ direction = (
248
+ f"within a BORDERLINE edge zone around [{reject_min:.3g}, {keep_min:.3g})"
249
+ f" or ({keep_max:.3g}, {reject_max:.3g}]"
250
+ )
251
+
252
+ prefix = "" if idx == 0 else " "
253
+ segments.append(f"{prefix}{feature} is {float(value):.3g}, which is {direction}.")
254
+
255
+ return "".join(segments).strip()
256
+
257
+ def _state_to_observation(self, reward: float, done: bool) -> Observation:
258
+ current_index = min(self._state.step_count, max(len(self._episode_plan) - 1, 0))
259
+ current = self._episode_plan[current_index]
260
+ corpus = self._episode_corpus.get(current.task_id, [])
261
+ full_corpus = list(corpus)
262
+ history_items = [
263
+ HistoryItem(
264
+ step=h.step,
265
+ clip_id=h.clip_id,
266
+ label=h.label,
267
+ expected_label=h.expected_label,
268
+ reward=h.reward,
269
+ )
270
+ for h in self._state.episode_history
271
+ ]
272
+ steps_remaining = max(0, self._state.max_steps - self._state.step_count)
273
+ session_history = [item.model_dump() for item in self._state.episode_history]
274
+ return Observation(
275
+ task_id=current.task_id,
276
+ episode_id=self._state.episode_id,
277
+ step_count=self._state.step_count,
278
+ max_steps=self._state.max_steps,
279
+ step=max(1, self._state.step_count + (0 if done else 1)),
280
+ rubric_version=self._rubric.version,
281
+ rubric_summary=self._rubric.to_prompt_text(),
282
+ clip_metadata=ClipMetadata.model_validate(current.clip),
283
+ history=history_items,
284
+ corpus_size=len(corpus),
285
+ corpus_shown=len(full_corpus),
286
+ data_corpus=full_corpus,
287
+ reward=float(reward),
288
+ done=done,
289
+ info={
290
+ "difficulty": current.difficulty,
291
+ "task_description": TASK_REGISTRY[current.task_id]["description"],
292
+ "best_score": self._persistent_best_score,
293
+ "last_reward": float(reward),
294
+ "action_history": self._state.actions_taken,
295
+ "steps_remaining": steps_remaining,
296
+ "total_reward": float(self._state.total_reward),
297
+ "reward_breakdown": dict(self._last_reward_breakdown),
298
+ "format_score": float(self._last_reward_breakdown["format_score"]),
299
+ "label_score": float(self._last_reward_breakdown["label_score"]),
300
+ "reasoning_score": float(self._last_reward_breakdown["reasoning_score"]),
301
+ "reward_total": float(self._last_reward_breakdown["total_reward"]),
302
+ "session_history": session_history,
303
+ "corpus_source": self._corpus_source,
304
+ },
305
+ )
306
+
307
+ def reset(
308
+ self,
309
+ seed: int | None = None,
310
+ episode_id: str | None = None,
311
+ **kwargs: Any,
312
+ ) -> Observation:
313
+ task_id = kwargs.get("task_id")
314
+ task_ids = self._choose_tasks(seed=seed, task_id=task_id)
315
+ self._episode_plan, self._episode_corpus = self._sample_episode_plan(task_ids, seed=seed)
316
+ self._last_reward_breakdown = {
317
+ "format_score": 0.0,
318
+ "label_score": 0.0,
319
+ "reasoning_score": 0.0,
320
+ "total_reward": 0.0,
321
+ }
322
+ self._state = State(
323
+ episode_id=episode_id or str(uuid.uuid4()),
324
+ task_id=self._episode_plan[0].task_id,
325
+ episode_count=self._state.episode_count + 1,
326
+ step_count=0,
327
+ max_steps=len(self._episode_plan),
328
+ current_score=0.0,
329
+ total_reward=0.0,
330
+ best_score=self._persistent_best_score,
331
+ current_clip_id=str(self._episode_plan[0].clip.get("clip_id", "")),
332
+ gt_size=self._gt_store.size(),
333
+ rubric_version=self._rubric.version,
334
+ actions_taken=[],
335
+ episode_history=[],
336
+ rubric_thresholds=self._rubric.get_thresholds_summary(),
337
+ )
338
+ obs = self._state_to_observation(reward=0.0, done=False)
339
+ if self._manifest_warning:
340
+ obs.info["warning"] = self._manifest_warning
341
+ return obs
342
+
343
+ def step(
344
+ self,
345
+ action: Action | dict[str, Any],
346
+ timeout_s: float | None = None,
347
+ **kwargs: Any,
348
+ ) -> Observation:
349
+ del timeout_s, kwargs
350
+ if not self._episode_plan:
351
+ self.reset()
352
+
353
+ current_index = min(self._state.step_count, len(self._episode_plan) - 1)
354
+ current = self._episode_plan[current_index]
355
+ clip = current.clip
356
+ payload = action.model_dump() if isinstance(action, Action) else dict(action)
357
+ action_obj = Action.model_validate(payload)
358
+
359
+ reward_obj = grade(action_obj, clip, self._rubric, self._gt_store, difficulty=current.difficulty)
360
+ reward = float(reward_obj.total)
361
+ self._last_reward_breakdown = {
362
+ "format_score": float(reward_obj.format_score),
363
+ "label_score": float(reward_obj.label_score),
364
+ "reasoning_score": float(reward_obj.reasoning_score),
365
+ "total_reward": float(reward),
366
+ }
367
+ self._state.total_reward += reward
368
+ self._state.current_score = float(self._state.total_reward)
369
+ self._state.best_score = max(self._state.best_score, reward)
370
+ self._persistent_best_score = max(self._persistent_best_score, self._state.best_score)
371
+
372
+ action_label = str(action_obj.label).upper()
373
+ submitted_clip_id = str(action_obj.clip_id or clip.get("clip_id", ""))
374
+ current_clip_id = str(clip.get("clip_id", ""))
375
+ corpus = self._episode_corpus.get(current.task_id, [])
376
+ status_updated = False
377
+ for item in corpus:
378
+ if str(item.get("clip_id", "")) == submitted_clip_id:
379
+ item["review_status"] = action_label
380
+ status_updated = True
381
+ break
382
+
383
+ if not status_updated and submitted_clip_id != current_clip_id:
384
+ for item in corpus:
385
+ if str(item.get("clip_id", "")) == current_clip_id:
386
+ item["review_status"] = action_label
387
+ break
388
+
389
+ self._state.actions_taken.append(action_label)
390
+ self._state.episode_history.append(
391
+ EpisodeHistoryItem(
392
+ step=current_index + 1,
393
+ difficulty=current.difficulty,
394
+ clip_id=str(clip.get("clip_id", "")),
395
+ label=action_label,
396
+ expected_label=str(clip.get("expected_label", "")),
397
+ reward=reward,
398
+ )
399
+ )
400
+ self._state.step_count += 1
401
+
402
+ done = self._state.step_count >= self._state.max_steps
403
+ if done:
404
+ hard_entry = self._state.episode_history[-1]
405
+ step3_result = {
406
+ "clip": self._episode_plan[-1].clip,
407
+ "action": action_obj.model_dump(),
408
+ "reward": reward,
409
+ "expected_label": self._episode_plan[-1].clip.get("expected_label"),
410
+ }
411
+ try:
412
+ gt_promoted = self._gt_store.try_promote(step3_result, episode=self._state.episode_count)
413
+ except ValueError:
414
+ gt_promoted = False
415
+ self._state.gt_size = self._gt_store.size()
416
+ if hard_entry.reward >= 0.85:
417
+ self._rubric.recalibrate(
418
+ perf=type("Perf", (), {"easy_accuracy": 0.0, "medium_accuracy": 0.0, "hard_accuracy": 0.86})(),
419
+ current_episode=self._state.episode_count,
420
+ )
421
+ self._state.rubric_version = self._rubric.version
422
+ self._state.rubric_thresholds = self._rubric.get_thresholds_summary()
423
+ obs = self._state_to_observation(reward=reward, done=True)
424
+ obs.info["gt_promoted"] = bool(gt_promoted)
425
+ obs.info["episode_summary"] = {
426
+ "steps_completed": self._state.step_count,
427
+ "max_steps": self._state.max_steps,
428
+ "total_reward": round(float(self._state.total_reward), 4),
429
+ "average_reward": round(float(self._state.total_reward) / max(1, self._state.max_steps), 4),
430
+ }
431
+ if self._manifest_warning:
432
+ obs.info["warning"] = self._manifest_warning
433
+ return obs
434
+
435
+ next_idx = self._state.step_count
436
+ self._state.task_id = self._episode_plan[next_idx].task_id
437
+ self._state.current_clip_id = str(self._episode_plan[next_idx].clip.get("clip_id", ""))
438
+ obs = self._state_to_observation(reward=reward, done=False)
439
+ if self._manifest_warning:
440
+ obs.info["warning"] = self._manifest_warning
441
+ return obs
442
+
443
+ @property
444
+ def state(self) -> State:
445
+ return self._state
446
+
447
+
448
+ ClipQualityEnv = ClipQualityEnvironment
449
+ # Backward-compatibility alias (deprecated).
450
+ PolicyEvolverEnvironment = ClipQualityEnvironment
clip_quality_env/generator.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from collections import deque
5
+ from copy import deepcopy
6
+ from uuid import uuid4
7
+
8
+ from .real_clips import DIFFICULTIES, load_real_clip_manifest
9
+ from .rubric import RubricState
10
+
11
+
12
+ EASY_ENVS = ["podcast_studio", "office", "home_office", "webcam_room"]
13
+ NOVEL_ENVS = ["outdoor_interview", "crowded_event", "car_vlog", "street_walk"]
14
+ RESOLUTIONS = ["1280x720", "1920x1080"]
15
+ BACKGROUND_TAGS = ["solid_dark", "solid_light", "simple_room", "busy_room"]
16
+
17
+
18
+ def _clamp(value: float, low: float, high: float) -> float:
19
+ return max(low, min(high, value))
20
+
21
+
22
+ def _sample_from_keep_center(threshold: dict[str, float | str]) -> float:
23
+ mode = str(threshold["mode"])
24
+ keep_min = float(threshold["keep_min"])
25
+ keep_max = float(threshold["keep_max"])
26
+ if mode == "higher":
27
+ span = max(keep_max - keep_min, 1e-6)
28
+ return _clamp(keep_min + 0.35 * span, keep_min, keep_max)
29
+ if mode == "lower":
30
+ span = max(keep_max - keep_min, 1e-6)
31
+ return _clamp(keep_min + 0.35 * span, keep_min, keep_max)
32
+ return _clamp((keep_min + keep_max) / 2.0, keep_min, keep_max)
33
+
34
+
35
+ def _sample_from_reject_zone(threshold: dict[str, float | str]) -> float:
36
+ mode = str(threshold["mode"])
37
+ reject_min = float(threshold["reject_min"])
38
+ reject_max = float(threshold["reject_max"])
39
+ keep_min = float(threshold["keep_min"])
40
+ keep_max = float(threshold["keep_max"])
41
+ if mode == "higher":
42
+ return _clamp(reject_max - 0.02, reject_min, reject_max)
43
+ if mode == "lower":
44
+ return _clamp(reject_min + 0.02, reject_min, reject_max)
45
+ if random.random() < 0.5:
46
+ return _clamp(reject_min - 0.2, 0.0, reject_min)
47
+ return _clamp(reject_max + 0.2, reject_max, reject_max + 10.0)
48
+
49
+
50
+ def _sample_from_borderline_zone(threshold: dict[str, float | str]) -> float:
51
+ mode = str(threshold["mode"])
52
+ keep_min = float(threshold["keep_min"])
53
+ keep_max = float(threshold["keep_max"])
54
+ reject_min = float(threshold["reject_min"])
55
+ reject_max = float(threshold["reject_max"])
56
+ if mode == "higher":
57
+ low = reject_max
58
+ high = keep_min
59
+ return _clamp((low + high) / 2.0, low, high)
60
+ if mode == "lower":
61
+ low = keep_max
62
+ high = reject_min
63
+ return _clamp((low + high) / 2.0, low, high)
64
+ if random.random() < 0.5:
65
+ return _clamp((reject_min + keep_min) / 2.0, reject_min, keep_min)
66
+ return _clamp((keep_max + reject_max) / 2.0, keep_max, reject_max)
67
+
68
+
69
+ class ClipMetaGenerator:
70
+ """Synthetic metadata generator for Easy/Medium/Hard clips."""
71
+
72
+ def __init__(self, seed: int = 7, real_clips_path: str | None = None) -> None:
73
+ self._rng = random.Random(seed)
74
+ self._recent_hard_ids: deque[str] = deque(maxlen=10)
75
+ self._real_clip_pools: dict[str, list[dict[str, object]]] = {d: [] for d in DIFFICULTIES}
76
+ self._real_clip_indices: dict[str, int] = {d: 0 for d in DIFFICULTIES}
77
+ self._real_clips_path = real_clips_path
78
+
79
+ def sample(self, difficulty: str, rubric: RubricState) -> dict[str, object]:
80
+ d = difficulty.lower()
81
+ if d == "easy":
82
+ real = self._sample_real("easy", rubric)
83
+ if real is not None:
84
+ return real
85
+ return self._gen_easy(rubric)
86
+ if d == "medium":
87
+ real = self._sample_real("medium", rubric)
88
+ if real is not None:
89
+ return real
90
+ return self._gen_medium(rubric)
91
+ if d == "hard":
92
+ real = self._sample_real("hard", rubric)
93
+ if real is not None:
94
+ return real
95
+ return self._gen_hard(rubric)
96
+ raise ValueError("difficulty must be one of: easy, medium, hard")
97
+
98
+ def use_real_clips(self, path: str, rubric: RubricState) -> dict[str, int]:
99
+ pools = load_real_clip_manifest(path, rubric)
100
+ self._real_clips_path = path
101
+ for difficulty in DIFFICULTIES:
102
+ rows = [deepcopy(clip) for clip in pools[difficulty]]
103
+ self._rng.shuffle(rows)
104
+ self._real_clip_pools[difficulty] = rows
105
+ self._real_clip_indices[difficulty] = 0
106
+ return self.real_clip_pool_sizes()
107
+
108
+ def real_clip_pool_sizes(self) -> dict[str, int]:
109
+ return {d: len(self._real_clip_pools[d]) for d in DIFFICULTIES}
110
+
111
+ def has_real_clips(self) -> bool:
112
+ return any(self._real_clip_pools[d] for d in DIFFICULTIES)
113
+
114
+ def real_clips_path(self) -> str | None:
115
+ return self._real_clips_path
116
+
117
+ def _sample_real(self, difficulty: str, rubric: RubricState) -> dict[str, object] | None:
118
+ if self._real_clips_path and not self.has_real_clips():
119
+ self.use_real_clips(self._real_clips_path, rubric)
120
+ pool = self._real_clip_pools[difficulty]
121
+ if not pool:
122
+ return None
123
+ idx = self._real_clip_indices[difficulty] % len(pool)
124
+ self._real_clip_indices[difficulty] += 1
125
+ return deepcopy(pool[idx])
126
+
127
+ def _gen_easy(self, rubric: RubricState) -> dict[str, object]:
128
+ label = self._rng.choice(["KEEP", "REJECT"])
129
+ clip = self._base_clip()
130
+
131
+ for feature, t in rubric.thresholds.items():
132
+ if feature not in clip:
133
+ continue
134
+ if label == "KEEP":
135
+ clip[feature] = float(_sample_from_keep_center(t))
136
+ else:
137
+ clip[feature] = float(_sample_from_reject_zone(t))
138
+
139
+ if label == "REJECT":
140
+ trigger = self._rng.choice(["occlusion", "face_confidence", "duration", "motion"])
141
+ if trigger == "occlusion":
142
+ clip["occlusion_present"] = True
143
+ elif trigger == "face_confidence":
144
+ clip["face_confidence"] = 0.52
145
+ elif trigger == "duration":
146
+ clip["duration_s"] = 3.2
147
+ else:
148
+ clip["motion_score"] = 0.57
149
+ else:
150
+ clip["occlusion_present"] = False
151
+
152
+ clip["clip_id"] = f"syn_easy_{uuid4().hex[:8]}"
153
+ clip["environment_tag"] = self._rng.choice(EASY_ENVS)
154
+ clip["bg_complexity"] = self._rng.choice(BACKGROUND_TAGS)
155
+ return clip
156
+
157
+ def _gen_medium(self, rubric: RubricState) -> dict[str, object]:
158
+ clip = self._gen_easy(rubric)
159
+ keys = list(rubric.thresholds.keys())
160
+ to_shift = self._rng.sample(keys, k=2)
161
+ for feature in to_shift:
162
+ if feature in clip:
163
+ clip[feature] = float(_sample_from_borderline_zone(rubric.thresholds[feature]))
164
+ clip["clip_id"] = f"syn_med_{uuid4().hex[:8]}"
165
+ clip["environment_tag"] = self._rng.choice(EASY_ENVS + NOVEL_ENVS[:1])
166
+ clip["occlusion_present"] = False
167
+ return clip
168
+
169
+ def _gen_hard(self, rubric: RubricState) -> dict[str, object]:
170
+ while True:
171
+ clip = self._gen_easy(rubric)
172
+ keys = list(rubric.thresholds.keys())
173
+ to_shift = self._rng.sample(keys, k=3)
174
+ for feature in to_shift:
175
+ if feature in clip:
176
+ clip[feature] = float(_sample_from_borderline_zone(rubric.thresholds[feature]))
177
+ clip["clip_id"] = f"syn_hard_{uuid4().hex[:8]}"
178
+ clip["environment_tag"] = self._rng.choice(NOVEL_ENVS)
179
+ clip["occlusion_present"] = False
180
+ if clip["clip_id"] in self._recent_hard_ids:
181
+ continue
182
+ self._recent_hard_ids.append(str(clip["clip_id"]))
183
+ return clip
184
+
185
+ def _base_clip(self) -> dict[str, object]:
186
+ return {
187
+ "clip_id": f"syn_{uuid4().hex[:8]}",
188
+ "duration_s": round(self._rng.uniform(6.0, 10.0), 2),
189
+ "fps": self._rng.choice([24, 30]),
190
+ "resolution": self._rng.choice(RESOLUTIONS),
191
+ "face_area_ratio": round(self._rng.uniform(0.22, 0.42), 3),
192
+ "face_confidence": round(self._rng.uniform(0.75, 0.95), 3),
193
+ "head_pose_yaw_deg": round(self._rng.uniform(2.0, 18.0), 2),
194
+ "head_pose_pitch_deg": round(self._rng.uniform(-8.0, 8.0), 2),
195
+ "motion_score": round(self._rng.uniform(0.08, 0.28), 3),
196
+ "bg_complexity": self._rng.choice(BACKGROUND_TAGS),
197
+ "bg_complexity_score": round(self._rng.uniform(0.05, 0.20), 3),
198
+ "mouth_open_ratio": round(self._rng.uniform(0.28, 0.52), 3),
199
+ "blink_rate_hz": round(self._rng.uniform(0.15, 0.40), 3),
200
+ "audio_snr_db": round(self._rng.uniform(18.0, 28.0), 2),
201
+ "transcript_word_count": self._rng.randint(22, 64),
202
+ "transcript_confidence": round(self._rng.uniform(0.80, 0.97), 3),
203
+ "lighting_uniformity": round(self._rng.uniform(0.55, 0.85), 3),
204
+ "occlusion_present": False,
205
+ "environment_tag": self._rng.choice(EASY_ENVS),
206
+ "framing": self._rng.choice(["front", "left", "right", "closeup", "offgaze"]),
207
+ }
clip_quality_env/grader.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from .difficulty import calibrate_total_score
7
+ from .ground_truth import GTStore
8
+ from .models import Action, Reward
9
+ from .rubric import RubricState
10
+
11
+
12
+ VALID_LABELS = {"KEEP", "BORDERLINE", "REJECT"}
13
+ FEATURE_TOKEN_RE = re.compile(r"\b[a-z]+(?:_[a-z0-9]+)+\b")
14
+
15
+
16
+ def _normalize_action(action: Action | dict[str, Any]) -> dict[str, Any]:
17
+ if isinstance(action, Action):
18
+ return action.model_dump()
19
+ if not isinstance(action, dict):
20
+ raise TypeError("action must be Action or dict")
21
+ return {
22
+ "label": str(action.get("label", "BORDERLINE")).upper(),
23
+ "reasoning": str(action.get("reasoning", "")),
24
+ "confidence": float(action.get("confidence", 0.5)),
25
+ }
26
+
27
+
28
+ def _score_format(action: dict[str, Any]) -> float:
29
+ label = str(action.get("label", "")).upper()
30
+ reasoning = str(action.get("reasoning", "")).strip()
31
+ confidence = float(action.get("confidence", -1.0))
32
+ if label in VALID_LABELS and reasoning and 0.0 <= confidence <= 1.0:
33
+ return 0.10
34
+ return 0.0
35
+
36
+
37
+ def _score_label(label: str, clip: dict[str, Any], rubric: RubricState, gt: GTStore) -> float:
38
+ clip_id = str(clip.get("clip_id", ""))
39
+ gt_label = gt.lookup(clip_id)
40
+ if gt_label is None:
41
+ gt_label = rubric.derive_label(clip)
42
+ if label == gt_label:
43
+ return 0.60
44
+ if gt_label == "BORDERLINE" and label in {"KEEP", "REJECT"}:
45
+ return 0.25
46
+ return 0.0
47
+
48
+
49
+ def _contains_directional_cue(reasoning: str, feature: str, status: str) -> bool:
50
+ low_words = ("low", "below", "under", "small", "poor", "noisy", "high motion", "occlusion")
51
+ high_words = ("high", "above", "over", "good", "clear", "stable", "frontal", "well-lit")
52
+ text = reasoning.lower()
53
+ if feature not in text:
54
+ return False
55
+ if status == "REJECT":
56
+ return any(w in text for w in low_words + ("reject",))
57
+ if status == "KEEP":
58
+ return any(w in text for w in high_words + ("keep",))
59
+ return any(w in text for w in ("borderline", "mixed", "ambiguous", "tradeoff", "conflict"))
60
+
61
+
62
+ def _check_directional_reasoning(reasoning: str, clip: dict[str, Any], dominant_features: list[str], rubric: RubricState) -> bool:
63
+ if not reasoning.strip():
64
+ return False
65
+ checks = 0
66
+ matches = 0
67
+ for feature in dominant_features:
68
+ if feature not in clip:
69
+ continue
70
+ value = clip[feature]
71
+ if not isinstance(value, (int, float)):
72
+ continue
73
+ checks += 1
74
+ status = rubric.get_feature_status(feature, float(value))
75
+ if _contains_directional_cue(reasoning, feature, status):
76
+ matches += 1
77
+ if checks == 0:
78
+ return False
79
+ return matches >= 1
80
+
81
+
82
+ def _score_reasoning(reasoning: str, clip: dict[str, Any], rubric: RubricState) -> float:
83
+ score = 0.0
84
+ lower_reasoning = reasoning.lower()
85
+ dominant_features = rubric.get_dominant_features(clip)
86
+
87
+ mentioned = sum(1 for f in dominant_features if f.lower() in lower_reasoning)
88
+ if mentioned >= 2:
89
+ score += 0.10
90
+ elif mentioned == 1:
91
+ score += 0.05
92
+
93
+ if _check_directional_reasoning(reasoning, clip, dominant_features, rubric):
94
+ score += 0.10
95
+
96
+ all_feature_names = {k.lower() for k in clip.keys()}
97
+ hallucinated = [
98
+ token
99
+ for token in FEATURE_TOKEN_RE.findall(lower_reasoning)
100
+ if token not in all_feature_names
101
+ ]
102
+ if len(hallucinated) == 0:
103
+ score += 0.10
104
+ return min(max(score, 0.0), 0.30)
105
+
106
+
107
+ def grade(
108
+ action: Action | dict[str, Any],
109
+ clip: dict[str, Any],
110
+ rubric: RubricState,
111
+ gt: GTStore,
112
+ difficulty: str | None = None,
113
+ ) -> Reward:
114
+ """
115
+ Fully deterministic reward decomposition.
116
+ """
117
+ payload = _normalize_action(action)
118
+ label = str(payload["label"]).upper()
119
+ reasoning = str(payload["reasoning"])
120
+
121
+ format_score = _score_format(payload)
122
+ label_score = _score_label(label, clip, rubric, gt)
123
+ reasoning_score = _score_reasoning(reasoning, clip, rubric)
124
+ total = format_score + label_score + reasoning_score
125
+ total = calibrate_total_score(total, difficulty=difficulty)
126
+
127
+ return Reward(
128
+ total=round(min(max(total, 0.0), 1.0), 6),
129
+ format_score=round(format_score, 6),
130
+ label_score=round(label_score, 6),
131
+ reasoning_score=round(reasoning_score, 6),
132
+ )
133
+
134
+
135
+ def score(
136
+ action: Action | dict[str, Any],
137
+ clip: dict[str, Any],
138
+ rubric: RubricState,
139
+ gt: GTStore,
140
+ difficulty: str | None = None,
141
+ ) -> float:
142
+ return float(grade(action, clip, rubric, gt, difficulty=difficulty).total)
clip_quality_env/ground_truth.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import Any
6
+
7
+
8
+ VALID_LABELS = {"KEEP", "BORDERLINE", "REJECT"}
9
+
10
+
11
+ class GTStore:
12
+ """Append-only ground-truth store with promotion support."""
13
+
14
+ def __init__(self, seed_path: str = "data/seed_gt.json", state_path: str = "state/ground_truth.json") -> None:
15
+ self.seed_path = seed_path
16
+ self.path = state_path
17
+ self.records: dict[str, dict[str, Any]] = {}
18
+ self._load_seed()
19
+ self._load_state()
20
+
21
+ def _load_seed(self) -> None:
22
+ if not os.path.exists(self.seed_path):
23
+ raise FileNotFoundError(f"Seed ground truth file not found: {self.seed_path}")
24
+ with open(self.seed_path, "r", encoding="utf-8") as f:
25
+ payload = json.load(f)
26
+
27
+ if isinstance(payload, dict):
28
+ for clip_id, rec in payload.items():
29
+ label = str(rec.get("label", "")).upper()
30
+ if label in VALID_LABELS:
31
+ self.records[clip_id] = {
32
+ "label": label,
33
+ "source": rec.get("source", "seed"),
34
+ "episode": int(rec.get("episode", 0)),
35
+ "reward": rec.get("reward"),
36
+ "confidence": rec.get("confidence"),
37
+ }
38
+ elif isinstance(payload, list):
39
+ for item in payload:
40
+ clip_id = str(item.get("clip_id", "")).strip()
41
+ label = str(item.get("label", "")).upper()
42
+ if clip_id and label in VALID_LABELS:
43
+ self.records[clip_id] = {
44
+ "label": label,
45
+ "source": item.get("source", "seed"),
46
+ "episode": int(item.get("episode", 0)),
47
+ "reward": item.get("reward"),
48
+ "confidence": item.get("confidence"),
49
+ }
50
+ else:
51
+ raise ValueError("seed_gt.json must be a dict or list")
52
+
53
+ def _load_state(self) -> None:
54
+ if not os.path.exists(self.path):
55
+ return
56
+ with open(self.path, "r", encoding="utf-8") as f:
57
+ payload = json.load(f)
58
+ if not isinstance(payload, dict):
59
+ raise ValueError("ground_truth.json must be a dict")
60
+ for clip_id, rec in payload.items():
61
+ if clip_id in self.records:
62
+ continue
63
+ label = str(rec.get("label", "")).upper()
64
+ if label not in VALID_LABELS:
65
+ continue
66
+ self.records[clip_id] = {
67
+ "label": label,
68
+ "source": rec.get("source", "agent_promoted"),
69
+ "episode": int(rec.get("episode", 0)),
70
+ "reward": rec.get("reward"),
71
+ "confidence": rec.get("confidence"),
72
+ }
73
+
74
+ def save(self) -> None:
75
+ os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
76
+ with open(self.path, "w", encoding="utf-8") as f:
77
+ json.dump(self.records, f, indent=2, sort_keys=True)
78
+
79
+ def lookup(self, clip_id: str) -> str | None:
80
+ rec = self.records.get(clip_id)
81
+ return None if rec is None else str(rec.get("label"))
82
+
83
+ def size(self) -> int:
84
+ return len(self.records)
85
+
86
+ def get_promoted_clip_ids(self) -> list[str]:
87
+ return [clip_id for clip_id, rec in self.records.items() if rec.get("source") == "agent_promoted"]
88
+
89
+ def try_promote(self, step3_result: dict[str, Any], episode: int) -> bool:
90
+ """
91
+ Promote hard-step clip if reward/confidence threshold is met.
92
+
93
+ The optional `expected_label` key enforces that the promoted label is correct.
94
+ """
95
+ clip = step3_result.get("clip", {})
96
+ action = step3_result.get("action", {})
97
+ clip_id = str(clip.get("clip_id", "")).strip()
98
+ if not clip_id:
99
+ raise ValueError("step3_result.clip.clip_id is required")
100
+ if clip_id in self.records:
101
+ return False
102
+
103
+ reward = float(step3_result.get("reward", 0.0))
104
+ confidence = float(action.get("confidence", 0.0))
105
+ label = str(action.get("label", "")).upper()
106
+ expected_label = step3_result.get("expected_label")
107
+ if expected_label is not None:
108
+ expected_label = str(expected_label).upper()
109
+
110
+ if label not in VALID_LABELS:
111
+ return False
112
+ if reward < 0.85 or confidence < 0.80:
113
+ return False
114
+ if expected_label in VALID_LABELS and label != expected_label:
115
+ return False
116
+
117
+ self.records[clip_id] = {
118
+ "label": label,
119
+ "source": "agent_promoted",
120
+ "episode": int(episode),
121
+ "reward": round(reward, 6),
122
+ "confidence": round(confidence, 6),
123
+ }
124
+ self.save()
125
+ return True
clip_quality_env/models.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from enum import Enum
5
+ from typing import Any, Dict, List, Literal, Optional
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class ClipLabel(str, Enum):
11
+ KEEP = "KEEP"
12
+ BORDERLINE = "BORDERLINE"
13
+ REJECT = "REJECT"
14
+
15
+
16
+ class Action(BaseModel):
17
+ """Agent action for clip-quality classification."""
18
+
19
+ label: Literal["KEEP", "BORDERLINE", "REJECT"] = Field(description="Predicted clip label")
20
+ reasoning: str = Field(min_length=1, description="Explanation tied to clip metadata")
21
+ confidence: float = Field(default=0.5, ge=0.0, le=1.0, description="Model confidence in the label")
22
+ clip_id: Optional[str] = Field(default=None, description="Optional target clip ID")
23
+ model_config = {"extra": "allow"}
24
+
25
+
26
+ class TaskInfo(BaseModel):
27
+ """Returned by /tasks endpoint."""
28
+
29
+ task_id: str
30
+ difficulty: str
31
+ description: str
32
+ action_schema: dict
33
+
34
+
35
+ class CorpusIncident(BaseModel):
36
+ """Compatibility record for corpus table views."""
37
+
38
+ id: str
39
+ content: str
40
+ review_status: str = "pending"
41
+ model_config = {"extra": "allow"}
42
+
43
+
44
+ class ClipMetadata(BaseModel):
45
+ """Clip metadata payload consumed by the agent."""
46
+
47
+ clip_id: str
48
+ duration_s: Optional[float] = Field(default=None, ge=0.0)
49
+ fps: Optional[int] = Field(default=None, ge=1)
50
+ resolution: Optional[str] = None
51
+ face_area_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0)
52
+ face_confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)
53
+ head_pose_yaw_deg: Optional[float] = None
54
+ head_pose_pitch_deg: Optional[float] = None
55
+ motion_score: Optional[float] = Field(default=None, ge=0.0)
56
+ bg_complexity: Optional[str] = None
57
+ bg_complexity_score: Optional[float] = Field(default=None, ge=0.0)
58
+ mouth_open_ratio: Optional[float] = Field(default=None, ge=0.0)
59
+ blink_rate_hz: Optional[float] = Field(default=None, ge=0.0)
60
+ audio_snr_db: Optional[float] = None
61
+ transcript_word_count: Optional[int] = Field(default=None, ge=0)
62
+ transcript_confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)
63
+ lighting_uniformity: Optional[float] = Field(default=None, ge=0.0, le=1.0)
64
+ occlusion_present: bool = False
65
+ environment_tag: Optional[str] = None
66
+ framing: Optional[str] = None
67
+ expected_label: Optional[str] = None
68
+ model_config = {"extra": "allow"}
69
+
70
+
71
+ class HistoryItem(BaseModel):
72
+ step: int = 0
73
+ clip_id: str = ""
74
+ label: str = ""
75
+ expected_label: str = ""
76
+ reward: float = 0.0
77
+ model_config = {"extra": "allow"}
78
+
79
+
80
+ class EpisodeHistoryItem(BaseModel):
81
+ step: int = 0
82
+ difficulty: str = ""
83
+ clip_id: str = ""
84
+ label: str = ""
85
+ expected_label: str = ""
86
+ reward: float = 0.0
87
+ model_config = {"extra": "allow"}
88
+
89
+
90
+ class Observation(BaseModel):
91
+ """What the agent sees after reset() or step()."""
92
+
93
+ task_id: str
94
+ episode_id: str
95
+ step_count: int
96
+ max_steps: int = 5
97
+ step: int = Field(default=1, ge=1, description="1-indexed episode step")
98
+ rubric_version: int = Field(default=1, ge=1)
99
+ rubric_summary: str = ""
100
+ clip_metadata: ClipMetadata
101
+ history: List[HistoryItem] = Field(default_factory=list)
102
+ corpus_size: int = 0
103
+ corpus_shown: int = 0
104
+ data_corpus: List[Dict[str, Any]] = Field(default_factory=list, description="Task clip samples")
105
+ reward: float = 0.0
106
+ done: bool = False
107
+ info: Dict[str, Any] = Field(default_factory=dict)
108
+ model_config = {"extra": "allow"}
109
+
110
+
111
+ class State(BaseModel):
112
+ """Episode metadata — returned by state() endpoint."""
113
+
114
+ episode_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
115
+ task_id: str = ""
116
+ episode_count: int = 0
117
+ step_count: int = 0
118
+ max_steps: int = 5
119
+ current_score: float = 0.0
120
+ total_reward: float = 0.0
121
+ best_score: float = 0.0
122
+ current_clip_id: str = ""
123
+ gt_size: int = 0
124
+ rubric_version: int = 1
125
+ actions_taken: List[str] = Field(default_factory=list)
126
+ episode_history: List[EpisodeHistoryItem] = Field(default_factory=list)
127
+ rubric_thresholds: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
128
+
129
+
130
+ EnvironmentState = State
131
+
132
+
133
+ class Reward(BaseModel):
134
+ total: float = Field(default=0.0, ge=0.0, le=1.0)
135
+ format_score: float = Field(default=0.0, ge=0.0, le=1.0)
136
+ label_score: float = Field(default=0.0, ge=0.0, le=1.0)
137
+ reasoning_score: float = Field(default=0.0, ge=0.0, le=1.0)
clip_quality_env/real_clips.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import Any, Iterator
6
+
7
+ from pydantic import ValidationError
8
+
9
+ from .models import ClipMetadata
10
+ from .rubric import RubricState
11
+
12
+
13
+ DIFFICULTIES = ("easy", "medium", "hard")
14
+
15
+
16
+ def _iter_manifest_rows(path: str) -> Iterator[tuple[int, dict[str, Any]]]:
17
+ if not os.path.exists(path):
18
+ raise FileNotFoundError(f"Real clip manifest not found: {path}")
19
+
20
+ if path.lower().endswith(".jsonl"):
21
+ with open(path, "r", encoding="utf-8") as f:
22
+ for row_num, line in enumerate(f, start=1):
23
+ line = line.strip()
24
+ if not line:
25
+ continue
26
+ try:
27
+ row = json.loads(line)
28
+ except json.JSONDecodeError as exc:
29
+ raise ValueError(f"Invalid JSONL at row {row_num} in {path}: {exc}") from exc
30
+ if not isinstance(row, dict):
31
+ raise ValueError(f"Manifest row {row_num} in {path} must be a JSON object")
32
+ yield row_num, row
33
+ return
34
+
35
+ with open(path, "r", encoding="utf-8") as f:
36
+ payload = json.load(f)
37
+
38
+ rows: list[Any]
39
+ if isinstance(payload, list):
40
+ rows = payload
41
+ elif isinstance(payload, dict) and isinstance(payload.get("clips"), list):
42
+ rows = payload["clips"]
43
+ else:
44
+ raise ValueError(f"Manifest {path} must be JSON list, JSONL, or JSON object with 'clips' list")
45
+
46
+ for row_num, row in enumerate(rows, start=1):
47
+ if not isinstance(row, dict):
48
+ raise ValueError(f"Manifest row {row_num} in {path} must be a JSON object")
49
+ yield row_num, row
50
+
51
+
52
+ def derive_clip_difficulty(clip: dict[str, Any], rubric: RubricState) -> str:
53
+ keep = 0
54
+ borderline = 0
55
+ reject = 0
56
+ for feature in rubric.thresholds:
57
+ value = clip.get(feature)
58
+ if not isinstance(value, (int, float)):
59
+ continue
60
+ status = rubric.get_feature_status(feature, float(value))
61
+ if status == "KEEP":
62
+ keep += 1
63
+ elif status == "BORDERLINE":
64
+ borderline += 1
65
+ else:
66
+ reject += 1
67
+
68
+ if (keep > 0 and reject > 0) or borderline >= 3 or (borderline >= 2 and reject >= 1):
69
+ return "hard"
70
+ if borderline >= 1:
71
+ return "medium"
72
+ return "easy"
73
+
74
+
75
+ def load_real_clip_manifest(path: str, rubric: RubricState) -> dict[str, list[dict[str, Any]]]:
76
+ """
77
+ Load and validate real clip metadata manifest.
78
+
79
+ Accepted formats:
80
+ - .jsonl: one JSON object per line
81
+ - .json: list of objects OR {"clips": [...]}
82
+
83
+ Each row must satisfy ClipMetadata and may include optional difficulty.
84
+ If difficulty is missing, difficulty is derived from rubric ambiguity.
85
+ """
86
+ pools: dict[str, list[dict[str, Any]]] = {d: [] for d in DIFFICULTIES}
87
+
88
+ for row_num, row in _iter_manifest_rows(path):
89
+ raw_difficulty = row.get("difficulty")
90
+ if isinstance(row.get("clip_metadata"), dict):
91
+ clip_payload = dict(row["clip_metadata"])
92
+ else:
93
+ clip_payload = dict(row)
94
+ clip_payload.pop("difficulty", None)
95
+
96
+ try:
97
+ clip = ClipMetadata(**clip_payload)
98
+ except ValidationError as exc:
99
+ raise ValueError(f"Invalid clip metadata at row {row_num} in {path}: {exc}") from exc
100
+
101
+ clip_data = clip.model_dump()
102
+ if raw_difficulty is None or (isinstance(raw_difficulty, str) and not raw_difficulty.strip()):
103
+ difficulty = derive_clip_difficulty(clip_data, rubric)
104
+ else:
105
+ if not isinstance(raw_difficulty, str):
106
+ raise ValueError(
107
+ f"Invalid difficulty at row {row_num} in {path}: expected string in {DIFFICULTIES}"
108
+ )
109
+ difficulty = raw_difficulty.strip().lower()
110
+ if difficulty not in DIFFICULTIES:
111
+ raise ValueError(
112
+ f"Invalid difficulty '{raw_difficulty}' at row {row_num} in {path}; "
113
+ f"expected one of {DIFFICULTIES}"
114
+ )
115
+
116
+ pools[difficulty].append(clip_data)
117
+
118
+ total = sum(len(items) for items in pools.values())
119
+ if total == 0:
120
+ raise ValueError(f"Real clip manifest {path} has no valid rows")
121
+ return pools
clip_quality_env/rubric.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ Threshold = dict[str, float | str]
10
+
11
+
12
+ @dataclass
13
+ class PerformanceWindow:
14
+ easy_accuracy: float
15
+ medium_accuracy: float
16
+ hard_accuracy: float
17
+ easy_coverage: float = 1.0
18
+
19
+
20
+ def load_initial_thresholds() -> dict[str, Threshold]:
21
+ """Initial rubric thresholds for ClipQualityEnv."""
22
+ return {
23
+ "face_area_ratio": {
24
+ "mode": "higher",
25
+ "keep_min": 0.25,
26
+ "keep_max": 1.0,
27
+ "reject_min": 0.0,
28
+ "reject_max": 0.18,
29
+ },
30
+ "face_confidence": {
31
+ "mode": "higher",
32
+ "keep_min": 0.80,
33
+ "keep_max": 1.0,
34
+ "reject_min": 0.0,
35
+ "reject_max": 0.65,
36
+ },
37
+ "head_pose_yaw_deg": {
38
+ "mode": "lower",
39
+ "keep_min": 0.0,
40
+ "keep_max": 20.0,
41
+ "reject_min": 35.0,
42
+ "reject_max": 180.0,
43
+ },
44
+ "motion_score": {
45
+ "mode": "lower",
46
+ "keep_min": 0.0,
47
+ "keep_max": 0.25,
48
+ "reject_min": 0.45,
49
+ "reject_max": 1.0,
50
+ },
51
+ "bg_complexity_score": {
52
+ "mode": "lower",
53
+ "keep_min": 0.0,
54
+ "keep_max": 0.15,
55
+ "reject_min": 0.40,
56
+ "reject_max": 1.0,
57
+ },
58
+ "audio_snr_db": {
59
+ "mode": "higher",
60
+ "keep_min": 20.0,
61
+ "keep_max": 80.0,
62
+ "reject_min": 0.0,
63
+ "reject_max": 14.0,
64
+ },
65
+ "duration_s": {
66
+ "mode": "band",
67
+ "keep_min": 6.0,
68
+ "keep_max": 10.0,
69
+ "reject_min": 4.0,
70
+ "reject_max": 14.0,
71
+ },
72
+ "mouth_open_ratio": {
73
+ "mode": "higher",
74
+ "keep_min": 0.30,
75
+ "keep_max": 1.0,
76
+ "reject_min": 0.0,
77
+ "reject_max": 0.18,
78
+ },
79
+ "lighting_uniformity": {
80
+ "mode": "higher",
81
+ "keep_min": 0.65,
82
+ "keep_max": 1.0,
83
+ "reject_min": 0.0,
84
+ "reject_max": 0.45,
85
+ },
86
+ }
87
+
88
+
89
+ class RubricState:
90
+ """
91
+ Versioned rubric for deterministic clip-quality labeling.
92
+
93
+ Rubric can tighten over time but never loosens.
94
+ """
95
+
96
+ def __init__(self, path: str = "state/rubric.json") -> None:
97
+ self.path = path
98
+ self.version = 1
99
+ self.thresholds: dict[str, Threshold] = load_initial_thresholds()
100
+ self.history: list[dict[str, Any]] = []
101
+ self.difficulty_boundaries: dict[str, float] = {"easy_medium": 0.50}
102
+ if os.path.exists(self.path):
103
+ self._load()
104
+
105
+ def _load(self) -> None:
106
+ with open(self.path, "r", encoding="utf-8") as f:
107
+ payload = json.load(f)
108
+ self.version = int(payload.get("version", 1))
109
+ self.thresholds = payload.get("thresholds", load_initial_thresholds())
110
+ self.history = payload.get("calibration_history", payload.get("history", []))
111
+ self.difficulty_boundaries = payload.get("difficulty_boundaries", {"easy_medium": 0.50})
112
+
113
+ def save(self) -> None:
114
+ os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
115
+ payload = {
116
+ "version": self.version,
117
+ "thresholds": self.thresholds,
118
+ "calibration_history": self.history,
119
+ "difficulty_boundaries": self.difficulty_boundaries,
120
+ }
121
+ with open(self.path, "w", encoding="utf-8") as f:
122
+ json.dump(payload, f, indent=2, sort_keys=True)
123
+
124
+ def derive_label(self, clip: dict[str, Any]) -> str:
125
+ """
126
+ Deterministic fallback label when GT has no explicit label.
127
+ """
128
+ if clip.get("occlusion_present"):
129
+ return "REJECT"
130
+ if float(clip.get("face_confidence", 0.0)) < 0.65:
131
+ return "REJECT"
132
+ if float(clip.get("duration_s", 0.0)) < 4.0:
133
+ return "REJECT"
134
+ if float(clip.get("motion_score", 1.0)) > 0.45:
135
+ return "REJECT"
136
+
137
+ reject_signals = 0
138
+ borderline_signals = 0
139
+ keep_signals = 0
140
+
141
+ for feature, value in clip.items():
142
+ if feature not in self.thresholds:
143
+ continue
144
+ if not isinstance(value, (int, float)):
145
+ continue
146
+ status = self._feature_status(float(value), self.thresholds[feature])
147
+ if status == "KEEP":
148
+ keep_signals += 1
149
+ elif status == "BORDERLINE":
150
+ borderline_signals += 1
151
+ else:
152
+ reject_signals += 1
153
+
154
+ if reject_signals >= 2:
155
+ return "REJECT"
156
+ if keep_signals >= 7 and borderline_signals <= 1:
157
+ return "KEEP"
158
+ return "BORDERLINE"
159
+
160
+ def tighten(self, feature: str, direction: str, delta: float, current_episode: int | None = None) -> None:
161
+ """
162
+ Shift thresholds toward stricter evaluation.
163
+
164
+ direction='floor' increases keep_min.
165
+ direction='ceiling' decreases keep_max.
166
+ """
167
+ if feature not in self.thresholds:
168
+ raise KeyError(f"Unknown feature: {feature}")
169
+ if direction == "floor" and delta < 0:
170
+ raise ValueError("floor tightening delta must be non-negative")
171
+ if direction == "ceiling" and delta > 0:
172
+ raise ValueError("ceiling tightening delta must be non-positive")
173
+
174
+ t = self.thresholds[feature]
175
+ old = dict(t)
176
+ if direction == "floor":
177
+ t["keep_min"] = float(t["keep_min"]) + delta
178
+ if float(t["keep_min"]) > float(t["keep_max"]):
179
+ raise ValueError(f"Invalid tighten result for {feature}: keep_min > keep_max")
180
+ elif direction == "ceiling":
181
+ t["keep_max"] = float(t["keep_max"]) + delta
182
+ if float(t["keep_min"]) > float(t["keep_max"]):
183
+ raise ValueError(f"Invalid tighten result for {feature}: keep_min > keep_max")
184
+ else:
185
+ raise ValueError("direction must be 'floor' or 'ceiling'")
186
+
187
+ self.history.append(
188
+ {
189
+ "at_episode": current_episode,
190
+ "feature": feature,
191
+ "direction": direction,
192
+ "delta": delta,
193
+ "old": old,
194
+ "new": dict(t),
195
+ }
196
+ )
197
+ self.save()
198
+
199
+ def shift_difficulty_boundary(self, boundary: str, delta: float, current_episode: int | None = None) -> None:
200
+ old = self.difficulty_boundaries.get(boundary, 0.50)
201
+ self.difficulty_boundaries[boundary] = old + delta
202
+ self.history.append(
203
+ {
204
+ "at_episode": current_episode,
205
+ "feature": boundary,
206
+ "direction": "difficulty_shift",
207
+ "delta": delta,
208
+ "old": old,
209
+ "new": self.difficulty_boundaries[boundary],
210
+ }
211
+ )
212
+ self.save()
213
+
214
+ def recalibrate(self, perf: PerformanceWindow, current_episode: int | None = None) -> None:
215
+ """
216
+ Tighten rubric based on performance triggers.
217
+ """
218
+ changed = False
219
+ if perf.easy_accuracy > 0.92:
220
+ self.tighten("face_area_ratio", "floor", 0.02, current_episode=current_episode)
221
+ self.tighten("bg_complexity_score", "ceiling", -0.02, current_episode=current_episode)
222
+ self.version += 1
223
+ changed = True
224
+ if perf.medium_accuracy > 0.80:
225
+ self.shift_difficulty_boundary("easy_medium", 0.05, current_episode=current_episode)
226
+ self.version += 1
227
+ changed = True
228
+ if changed:
229
+ self.save()
230
+
231
+ def to_prompt_text(self) -> str:
232
+ lines = [f"Rubric v{self.version} - Clip Quality Standards for Talking-Head LoRA:"]
233
+ for feature, t in self.thresholds.items():
234
+ mode = str(t["mode"])
235
+ keep_min = float(t["keep_min"])
236
+ keep_max = float(t["keep_max"])
237
+ reject_min = float(t["reject_min"])
238
+ reject_max = float(t["reject_max"])
239
+ if mode == "higher":
240
+ line = (
241
+ f" {feature}: KEEP >= {keep_min:.3g}, "
242
+ f"BORDERLINE in [{reject_max:.3g}, {keep_min:.3g}), "
243
+ f"REJECT < {reject_max:.3g}"
244
+ )
245
+ elif mode == "lower":
246
+ line = (
247
+ f" {feature}: KEEP <= {keep_max:.3g}, "
248
+ f"BORDERLINE in ({keep_max:.3g}, {reject_min:.3g}], "
249
+ f"REJECT > {reject_min:.3g}"
250
+ )
251
+ else:
252
+ line = (
253
+ f" {feature}: KEEP in [{keep_min:.3g}, {keep_max:.3g}], "
254
+ f"BORDERLINE in [{reject_min:.3g}, {keep_min:.3g}) U "
255
+ f"({keep_max:.3g}, {reject_max:.3g}], "
256
+ f"REJECT outside [{reject_min:.3g}, {reject_max:.3g}]"
257
+ )
258
+ lines.append(line)
259
+ return "\n".join(lines)
260
+
261
+ def get_thresholds_summary(self) -> dict[str, dict[str, float | str]]:
262
+ return {k: dict(v) for k, v in self.thresholds.items()}
263
+
264
+ def get_feature_status(self, feature: str, value: float) -> str:
265
+ if feature not in self.thresholds:
266
+ return "BORDERLINE"
267
+ return self._feature_status(value, self.thresholds[feature])
268
+
269
+ def _feature_status(self, value: float, t: Threshold) -> str:
270
+ mode = str(t["mode"])
271
+ keep_min = float(t["keep_min"])
272
+ keep_max = float(t["keep_max"])
273
+ reject_min = float(t["reject_min"])
274
+ reject_max = float(t["reject_max"])
275
+
276
+ if mode == "higher":
277
+ if value >= keep_min:
278
+ return "KEEP"
279
+ if value < reject_max:
280
+ return "REJECT"
281
+ return "BORDERLINE"
282
+ if mode == "lower":
283
+ if value <= keep_max:
284
+ return "KEEP"
285
+ if value > reject_min:
286
+ return "REJECT"
287
+ return "BORDERLINE"
288
+ if keep_min <= value <= keep_max:
289
+ return "KEEP"
290
+ if value < reject_min or value > reject_max:
291
+ return "REJECT"
292
+ return "BORDERLINE"
293
+
294
+ def get_dominant_features(self, clip: dict[str, Any]) -> list[str]:
295
+ """
296
+ Return top-2 most influential features for this clip.
297
+ """
298
+ scored: list[tuple[float, str]] = []
299
+ for feature, t in self.thresholds.items():
300
+ if feature not in clip:
301
+ continue
302
+ value = clip[feature]
303
+ if not isinstance(value, (int, float)):
304
+ continue
305
+ status = self._feature_status(float(value), t)
306
+ base = {"REJECT": 3.0, "BORDERLINE": 2.0, "KEEP": 1.0}[status]
307
+ severity = self._signal_strength(float(value), t, status)
308
+ scored.append((base + severity, feature))
309
+ scored.sort(reverse=True, key=lambda item: item[0])
310
+ return [name for _, name in scored[:2]]
311
+
312
+ def _signal_strength(self, value: float, t: Threshold, status: str) -> float:
313
+ mode = str(t["mode"])
314
+ keep_min = float(t["keep_min"])
315
+ keep_max = float(t["keep_max"])
316
+ reject_min = float(t["reject_min"])
317
+ reject_max = float(t["reject_max"])
318
+
319
+ if mode == "higher":
320
+ span = max(keep_min - reject_max, 1e-6)
321
+ if status == "KEEP":
322
+ return max((value - keep_min) / span, 0.0)
323
+ if status == "REJECT":
324
+ return max((reject_max - value) / span, 0.0)
325
+ return max((keep_min - value) / span, 0.0)
326
+ if mode == "lower":
327
+ span = max(reject_min - keep_max, 1e-6)
328
+ if status == "KEEP":
329
+ return max((keep_max - value) / span, 0.0)
330
+ if status == "REJECT":
331
+ return max((value - reject_min) / span, 0.0)
332
+ return max((value - keep_max) / span, 0.0)
333
+
334
+ span = max(keep_max - keep_min, 1e-6)
335
+ if status == "KEEP":
336
+ return min(value - keep_min, keep_max - value) / span
337
+ if value < keep_min:
338
+ if status == "REJECT":
339
+ return max((reject_min - value) / span, 0.0)
340
+ return max((keep_min - value) / span, 0.0)
341
+ if status == "REJECT":
342
+ return max((value - reject_max) / span, 0.0)
343
+ return max((value - keep_max) / span, 0.0)
clip_quality_env/train.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from statistics import mean
5
+
6
+ from .env import ClipQualityEnv
7
+ from .rubric import RubricState
8
+
9
+
10
+ def _heuristic_action(task_id: str, clip: dict) -> dict:
11
+ rubric = RubricState()
12
+ predicted = rubric.derive_label(clip)
13
+ if task_id == "task_easy":
14
+ reasoning = "Clip has dominant, consistent quality signals with clear threshold alignment."
15
+ elif task_id == "task_medium":
16
+ reasoning = "Clip shows mixed cues and one or two borderline metrics, requiring cautious acceptance."
17
+ else:
18
+ reasoning = "Clip contains conflicting hard-case signals requiring conservative quality judgment."
19
+ return {
20
+ "label": predicted,
21
+ "reasoning": reasoning,
22
+ "confidence": 0.75 if predicted != "BORDERLINE" else 0.65,
23
+ "clip_id": clip.get("clip_id"),
24
+ }
25
+
26
+
27
+ def run_training(episodes: int, model_name: str | None = None) -> dict[str, float]:
28
+ del model_name
29
+ env = ClipQualityEnv()
30
+
31
+ easy_rewards: list[float] = []
32
+ medium_rewards: list[float] = []
33
+ hard_rewards: list[float] = []
34
+
35
+ for ep in range(1, episodes + 1):
36
+ obs = env.reset()
37
+ done = bool(obs.done)
38
+ while not done:
39
+ task_id = obs.task_id
40
+ action = _heuristic_action(task_id, obs.clip_metadata.model_dump())
41
+ obs = env.step(action)
42
+ reward = float(obs.reward or 0.0)
43
+ done = bool(obs.done)
44
+ if task_id == "task_easy":
45
+ easy_rewards.append(reward)
46
+ elif task_id == "task_medium":
47
+ medium_rewards.append(reward)
48
+ else:
49
+ hard_rewards.append(reward)
50
+
51
+ if ep % 10 == 0:
52
+ print(f"[Episode {ep}] Best={env.state.best_score:.3f} Last={reward:.3f}")
53
+
54
+ return {
55
+ "easy": mean(easy_rewards) if easy_rewards else 0.0,
56
+ "medium": mean(medium_rewards) if medium_rewards else 0.0,
57
+ "hard": mean(hard_rewards) if hard_rewards else 0.0,
58
+ "best_score": float(env.state.best_score),
59
+ }
60
+
61
+
62
+ def main() -> None:
63
+ parser = argparse.ArgumentParser(description="Run ClipQualityEnv training loop.")
64
+ parser.add_argument("--episodes", type=int, default=50, help="Number of episodes.")
65
+ parser.add_argument("--model-name", type=str, default=None, help="Override MODEL_NAME.")
66
+ args = parser.parse_args()
67
+
68
+ summary = run_training(episodes=args.episodes, model_name=args.model_name)
69
+ print("Training summary:")
70
+ for k, v in summary.items():
71
+ print(f" {k}: {v:.4f}")
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
data/real_clips_manifest.jsonl ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"clip_id":"clip_001","duration_s":10.858,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.7123,"head_pose_yaw_deg":9.444,"head_pose_pitch_deg":5.083,"motion_score":0.0277,"bg_complexity":"simple_room","bg_complexity_score":0.0571,"mouth_open_ratio":0.3123,"blink_rate_hz":0.25,"audio_snr_db":16.676,"transcript_word_count":31,"transcript_confidence":0.4536,"lighting_uniformity":0.7075,"occlusion_present":false,"environment_tag":"podcast_studio","framing":"front","difficulty":"medium"}
2
+ {"clip_id":"clip_002","duration_s":8.768,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.9,"head_pose_yaw_deg":5.23,"head_pose_pitch_deg":6.215,"motion_score":0.059,"bg_complexity":"busy_room","bg_complexity_score":0.1235,"mouth_open_ratio":0.18,"blink_rate_hz":0.25,"audio_snr_db":14.414,"transcript_word_count":24,"transcript_confidence":0.44,"lighting_uniformity":0.5693,"occlusion_present":true,"environment_tag":"classroom_lectern","framing":"front","difficulty":"hard"}
3
+ {"clip_id":"clip_003","duration_s":6.613,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.9,"head_pose_yaw_deg":2.31,"head_pose_pitch_deg":4.612,"motion_score":0.0494,"bg_complexity":"busy_room","bg_complexity_score":0.1075,"mouth_open_ratio":0.18,"blink_rate_hz":0.25,"audio_snr_db":18.628,"transcript_word_count":16,"transcript_confidence":0.4543,"lighting_uniformity":0.6152,"occlusion_present":true,"environment_tag":"indoor_workshop","framing":"front","difficulty":"hard"}
4
+ {"clip_id":"clip_004","duration_s":9.13,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.7804,"head_pose_yaw_deg":5.52,"head_pose_pitch_deg":8.696,"motion_score":0.0137,"bg_complexity":"busy_room","bg_complexity_score":0.0816,"mouth_open_ratio":0.18,"blink_rate_hz":0.25,"audio_snr_db":16.103,"transcript_word_count":25,"transcript_confidence":0.4548,"lighting_uniformity":0.7783,"occlusion_present":true,"environment_tag":"mixing_console_studio","framing":"front","difficulty":"hard"}
5
+ {"clip_id":"clip_005","duration_s":7.722,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.6633,"head_pose_yaw_deg":6.426,"head_pose_pitch_deg":7.469,"motion_score":0.0091,"bg_complexity":"busy_room","bg_complexity_score":0.0927,"mouth_open_ratio":0.18,"blink_rate_hz":0.25,"audio_snr_db":15.155,"transcript_word_count":21,"transcript_confidence":0.446,"lighting_uniformity":0.6636,"occlusion_present":false,"environment_tag":"tech_lab","framing":"front","difficulty":"hard"}
6
+ {"clip_id":"clip_006","duration_s":9.088,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.7815,"head_pose_yaw_deg":8.057,"head_pose_pitch_deg":5.957,"motion_score":0.0213,"bg_complexity":"solid_dark","bg_complexity_score":0.0546,"mouth_open_ratio":0.439,"blink_rate_hz":0.25,"audio_snr_db":15.603,"transcript_word_count":27,"transcript_confidence":0.433,"lighting_uniformity":0.7998,"occlusion_present":false,"environment_tag":"documentary_studio","framing":"right","difficulty":"medium"}
7
+ {"clip_id":"clip_007","duration_s":6.25,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.8237,"head_pose_yaw_deg":8.196,"head_pose_pitch_deg":5.856,"motion_score":0.0231,"bg_complexity":"solid_dark","bg_complexity_score":0.0489,"mouth_open_ratio":0.2969,"blink_rate_hz":0.25,"audio_snr_db":18.569,"transcript_word_count":14,"transcript_confidence":0.4488,"lighting_uniformity":0.8267,"occlusion_present":false,"environment_tag":"acoustic_studio","framing":"right","difficulty":"medium"}
8
+ {"clip_id":"clip_008","duration_s":6.25,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.7676,"head_pose_yaw_deg":7.368,"head_pose_pitch_deg":6.514,"motion_score":0.0148,"bg_complexity":"simple_room","bg_complexity_score":0.0677,"mouth_open_ratio":0.18,"blink_rate_hz":0.25,"audio_snr_db":15.845,"transcript_word_count":23,"transcript_confidence":0.4556,"lighting_uniformity":0.7604,"occlusion_present":false,"environment_tag":"office","framing":"left","difficulty":"hard"}
9
+ {"clip_id":"clip_009","duration_s":7.658,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.7472,"head_pose_yaw_deg":8.265,"head_pose_pitch_deg":5.807,"motion_score":0.0464,"bg_complexity":"solid_dark","bg_complexity_score":0.0497,"mouth_open_ratio":0.4105,"blink_rate_hz":0.25,"audio_snr_db":21.49,"transcript_word_count":25,"transcript_confidence":0.4638,"lighting_uniformity":0.7433,"occlusion_present":false,"environment_tag":"podcast_studio","framing":"left","difficulty":"medium"}
10
+ {"clip_id":"clip_010","duration_s":7.978,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.6114,"head_pose_yaw_deg":6.749,"head_pose_pitch_deg":7.112,"motion_score":0.0528,"bg_complexity":"busy_outdoor","bg_complexity_score":0.1278,"mouth_open_ratio":0.2903,"blink_rate_hz":0.25,"audio_snr_db":17.794,"transcript_word_count":22,"transcript_confidence":0.4391,"lighting_uniformity":0.5931,"occlusion_present":false,"environment_tag":"outdoor_interview","framing":"front","difficulty":"hard"}
11
+ {"clip_id":"clip_011","duration_s":6.933,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.8096,"head_pose_yaw_deg":8.027,"head_pose_pitch_deg":5.98,"motion_score":0.0104,"bg_complexity":"solid_dark","bg_complexity_score":0.054,"mouth_open_ratio":0.1921,"blink_rate_hz":0.25,"audio_snr_db":20.991,"transcript_word_count":19,"transcript_confidence":0.4409,"lighting_uniformity":0.8058,"occlusion_present":false,"environment_tag":"podcast_studio","framing":"front","difficulty":"medium"}
12
+ {"clip_id":"clip_012","duration_s":6.656,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.5656,"head_pose_yaw_deg":8.636,"head_pose_pitch_deg":5.558,"motion_score":0.0252,"bg_complexity":"solid_dark","bg_complexity_score":0.026,"mouth_open_ratio":0.4842,"blink_rate_hz":0.25,"audio_snr_db":18.749,"transcript_word_count":20,"transcript_confidence":0.45,"lighting_uniformity":0.5735,"occlusion_present":true,"environment_tag":"office","framing":"closeup","difficulty":"easy"}
13
+ {"clip_id":"clip_013","duration_s":8.085,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.7905,"head_pose_yaw_deg":8.393,"head_pose_pitch_deg":5.719,"motion_score":0.0272,"bg_complexity":"simple_room","bg_complexity_score":0.0566,"mouth_open_ratio":0.2902,"blink_rate_hz":0.25,"audio_snr_db":18.877,"transcript_word_count":25,"transcript_confidence":0.4545,"lighting_uniformity":0.7916,"occlusion_present":false,"environment_tag":"office","framing":"left","difficulty":"medium"}
14
+ {"clip_id":"clip_014","duration_s":7.296,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.7907,"head_pose_yaw_deg":7.708,"head_pose_pitch_deg":6.228,"motion_score":0.0097,"bg_complexity":"solid_dark","bg_complexity_score":0.0487,"mouth_open_ratio":0.18,"blink_rate_hz":0.25,"audio_snr_db":14.65,"transcript_word_count":25,"transcript_confidence":0.4541,"lighting_uniformity":0.7805,"occlusion_present":false,"environment_tag":"office","framing":"front","difficulty":"medium"}
15
+ {"clip_id":"clip_015","duration_s":7.04,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.7522,"head_pose_yaw_deg":10.102,"head_pose_pitch_deg":4.752,"motion_score":0.0243,"bg_complexity":"solid_dark","bg_complexity_score":0.0688,"mouth_open_ratio":0.2759,"blink_rate_hz":0.25,"audio_snr_db":21.552,"transcript_word_count":21,"transcript_confidence":0.441,"lighting_uniformity":0.7447,"occlusion_present":false,"environment_tag":"office","framing":"front","difficulty":"medium"}
16
+ {"clip_id":"clip_016","duration_s":8.128,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.8075,"head_pose_yaw_deg":8.583,"head_pose_pitch_deg":5.593,"motion_score":0.0105,"bg_complexity":"solid_dark","bg_complexity_score":0.0513,"mouth_open_ratio":0.4705,"blink_rate_hz":0.25,"audio_snr_db":19.341,"transcript_word_count":24,"transcript_confidence":0.4345,"lighting_uniformity":0.8089,"occlusion_present":false,"environment_tag":"office","framing":"front","difficulty":"medium"}
17
+ {"clip_id":"clip_017","duration_s":9.13,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.6906,"head_pose_yaw_deg":8.043,"head_pose_pitch_deg":5.968,"motion_score":0.0359,"bg_complexity":"solid_light","bg_complexity_score":0.0224,"mouth_open_ratio":0.4295,"blink_rate_hz":0.25,"audio_snr_db":18.084,"transcript_word_count":31,"transcript_confidence":0.4659,"lighting_uniformity":0.7011,"occlusion_present":false,"environment_tag":"studio","framing":"closeup","difficulty":"easy"}
18
+ {"clip_id":"clip_018","duration_s":6.72,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.6786,"head_pose_yaw_deg":8.715,"head_pose_pitch_deg":5.508,"motion_score":0.0161,"bg_complexity":"busy_room","bg_complexity_score":0.0871,"mouth_open_ratio":0.2192,"blink_rate_hz":0.25,"audio_snr_db":23.497,"transcript_word_count":20,"transcript_confidence":0.445,"lighting_uniformity":0.6841,"occlusion_present":false,"environment_tag":"lounge","framing":"front","difficulty":"medium"}
19
+ {"clip_id":"clip_019","duration_s":8.896,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.818,"head_pose_yaw_deg":6.849,"head_pose_pitch_deg":7.008,"motion_score":0.0145,"bg_complexity":"solid_dark","bg_complexity_score":0.0493,"mouth_open_ratio":0.4024,"blink_rate_hz":0.25,"audio_snr_db":22.428,"transcript_word_count":25,"transcript_confidence":0.4547,"lighting_uniformity":0.816,"occlusion_present":false,"environment_tag":"podcast_studio","framing":"offgaze","difficulty":"medium"}
20
+ {"clip_id":"clip_020","duration_s":7.978,"fps":25,"resolution":"1280x704","face_area_ratio":0.28,"face_confidence":0.5898,"head_pose_yaw_deg":12.424,"head_pose_pitch_deg":3.863,"motion_score":0.0353,"bg_complexity":"busy_indoor","bg_complexity_score":0.1247,"mouth_open_ratio":0.62,"blink_rate_hz":0.25,"audio_snr_db":19.371,"transcript_word_count":22,"transcript_confidence":0.4549,"lighting_uniformity":0.5944,"occlusion_present":false,"environment_tag":"hotel_corridor","framing":"front","difficulty":"hard"}
data/seed_gt.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "clip_0001": {"label": "KEEP", "source": "seed", "episode": 0},
3
+ "clip_0002": {"label": "KEEP", "source": "seed", "episode": 0},
4
+ "clip_0003": {"label": "KEEP", "source": "seed", "episode": 0},
5
+ "clip_0004": {"label": "KEEP", "source": "seed", "episode": 0},
6
+ "clip_0005": {"label": "KEEP", "source": "seed", "episode": 0},
7
+ "clip_0006": {"label": "KEEP", "source": "seed", "episode": 0},
8
+ "clip_0007": {"label": "BORDERLINE", "source": "seed", "episode": 0},
9
+ "clip_0008": {"label": "BORDERLINE", "source": "seed", "episode": 0},
10
+ "clip_0009": {"label": "BORDERLINE", "source": "seed", "episode": 0},
11
+ "clip_0010": {"label": "BORDERLINE", "source": "seed", "episode": 0},
12
+ "clip_0011": {"label": "BORDERLINE", "source": "seed", "episode": 0},
13
+ "clip_0012": {"label": "BORDERLINE", "source": "seed", "episode": 0},
14
+ "clip_0013": {"label": "BORDERLINE", "source": "seed", "episode": 0},
15
+ "clip_0014": {"label": "REJECT", "source": "seed", "episode": 0},
16
+ "clip_0015": {"label": "REJECT", "source": "seed", "episode": 0},
17
+ "clip_0016": {"label": "REJECT", "source": "seed", "episode": 0},
18
+ "clip_0017": {"label": "REJECT", "source": "seed", "episode": 0},
19
+ "clip_0018": {"label": "REJECT", "source": "seed", "episode": 0},
20
+ "clip_0019": {"label": "REJECT", "source": "seed", "episode": 0},
21
+ "clip_0020": {"label": "REJECT", "source": "seed", "episode": 0}
22
+ }
inference.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import time
8
+ from typing import Any, Dict, Optional
9
+
10
+ from openai import OpenAI
11
+
12
+ from models import Action
13
+ from server.environment import ClipQualityEnvironment
14
+ from server.tasks import TASK_IDS, TASK_REGISTRY
15
+
16
+ DEFAULT_API_BASE_URL = "https://router.huggingface.co/v1"
17
+ DEFAULT_MODEL_NAME = "llama-3.3-70b-versatile"
18
+ VALID_LABELS = {"KEEP", "BORDERLINE", "REJECT"}
19
+
20
+
21
+ def _load_client() -> tuple[OpenAI, str]:
22
+ api_base_url = os.environ.get("API_BASE_URL", DEFAULT_API_BASE_URL)
23
+ model_name = os.environ.get("MODEL_NAME", DEFAULT_MODEL_NAME)
24
+ token = os.environ.get("HF_TOKEN") or os.environ.get("OPENAI_API_KEY")
25
+ if not token:
26
+ raise ValueError("HF_TOKEN (or OPENAI_API_KEY) environment variable is required")
27
+ return OpenAI(api_key=token, base_url=api_base_url), model_name
28
+
29
+
30
+ def _extract_json(raw: str) -> Dict:
31
+ if "```json" in raw:
32
+ raw = raw.split("```json", 1)[1].split("```", 1)[0].strip()
33
+ elif "```" in raw:
34
+ raw = raw.split("```", 1)[1].split("```", 1)[0].strip()
35
+ return json.loads(raw)
36
+
37
+
38
+ def _normalize_label(label: Any, fallback: str = "BORDERLINE") -> str:
39
+ candidate = str(label or fallback).strip().upper()
40
+ return candidate if candidate in VALID_LABELS else fallback
41
+
42
+
43
+ def _normalize_confidence(value: Any, fallback: float = 0.5) -> float:
44
+ try:
45
+ return max(0.0, min(1.0, float(value)))
46
+ except (TypeError, ValueError):
47
+ return fallback
48
+
49
+
50
+ class ClipQualityAgent:
51
+ """Standalone LLM clip-quality baseline agent."""
52
+
53
+ def __init__(self, client: OpenAI | None, model: str):
54
+ self.client = client
55
+ self.model = model
56
+
57
+ def _call(self, prompt: str) -> Optional[Dict]:
58
+ if self.client is None:
59
+ return None
60
+ try:
61
+ resp = self.client.chat.completions.create(
62
+ model=self.model,
63
+ messages=[
64
+ {"role": "system", "content": "You are a clip-quality analyst. Respond with valid JSON only."},
65
+ {"role": "user", "content": prompt},
66
+ ],
67
+ temperature=0.2,
68
+ )
69
+ raw = (resp.choices[0].message.content or "").strip()
70
+ return _extract_json(raw)
71
+ except Exception:
72
+ return None
73
+
74
+ def _get_history(self, obs: Dict) -> str:
75
+ history = obs.get("history", [])
76
+ if not history:
77
+ return ""
78
+ compact = ", ".join(f"step={h.get('step')} label={h.get('label')}" for h in history[-2:])
79
+ return f"\nPREVIOUS STEPS: {compact}\n"
80
+
81
+ def _heuristic_label(self, clip: Dict[str, Any]) -> str:
82
+ if bool(clip.get("occlusion_present")):
83
+ return "REJECT"
84
+ if float(clip.get("motion_score", 0.0)) > 0.45:
85
+ return "REJECT"
86
+ if float(clip.get("face_confidence", 0.0)) < 0.65:
87
+ return "REJECT"
88
+ if float(clip.get("duration_s", 0.0)) < 4.0:
89
+ return "REJECT"
90
+
91
+ keep_signals = 0
92
+ if float(clip.get("face_area_ratio", 0.0)) >= 0.25:
93
+ keep_signals += 1
94
+ if float(clip.get("face_confidence", 0.0)) >= 0.8:
95
+ keep_signals += 1
96
+ if float(clip.get("motion_score", 1.0)) <= 0.25:
97
+ keep_signals += 1
98
+ if float(clip.get("audio_snr_db", 0.0)) >= 20.0:
99
+ keep_signals += 1
100
+ if float(clip.get("lighting_uniformity", 0.0)) >= 0.65:
101
+ keep_signals += 1
102
+ return "KEEP" if keep_signals >= 4 else "BORDERLINE"
103
+
104
+ def _fallback_action(self, clip: Dict[str, Any]) -> Dict[str, Any]:
105
+ label = _normalize_label(clip.get("expected_label"), fallback=self._heuristic_label(clip))
106
+ confidence = 0.82 if label != "BORDERLINE" else 0.68
107
+ reasoning = (
108
+ f"{label} based on face_confidence={clip.get('face_confidence')}, "
109
+ f"motion_score={clip.get('motion_score')}, audio_snr_db={clip.get('audio_snr_db')}, "
110
+ f"lighting_uniformity={clip.get('lighting_uniformity')}, occlusion_present={clip.get('occlusion_present')}."
111
+ )
112
+ return {
113
+ "label": label,
114
+ "reasoning": reasoning,
115
+ "confidence": confidence,
116
+ "clip_id": clip.get("clip_id"),
117
+ }
118
+
119
+ def normalize_action(self, raw: Dict[str, Any], clip: Dict[str, Any]) -> Dict[str, Any]:
120
+ return {
121
+ "label": _normalize_label(raw.get("label"), fallback=self._heuristic_label(clip)),
122
+ "reasoning": str(raw.get("reasoning") or "").strip()
123
+ or f"Label uses clip metadata cues for {clip.get('clip_id')}.",
124
+ "confidence": _normalize_confidence(raw.get("confidence"), fallback=0.5),
125
+ "clip_id": str(raw.get("clip_id") or clip.get("clip_id") or ""),
126
+ }
127
+
128
+ def act(self, task_id: str, obs: Dict) -> Dict:
129
+ clip = obs.get("clip_metadata", {})
130
+ rubric = obs.get("rubric_summary", "")
131
+ history = self._get_history(obs)
132
+ prompt = (
133
+ f"Task: {task_id}\n"
134
+ f"Rubric:\n{rubric}\n"
135
+ f"Clip metadata:\n{json.dumps(clip, indent=2)}\n"
136
+ f"{history}\n"
137
+ "Return JSON with keys: "
138
+ "{'label':'KEEP|BORDERLINE|REJECT','reasoning':'...','confidence':0.0,'clip_id':'...'}"
139
+ )
140
+ parsed = self._call(prompt)
141
+ if isinstance(parsed, dict):
142
+ return self.normalize_action(parsed, clip)
143
+ return self._fallback_action(clip)
144
+
145
+
146
+ def run_episode(task_id: str, client: OpenAI | None, model_name: str) -> Dict:
147
+ env = ClipQualityEnvironment()
148
+ agent = ClipQualityAgent(client, model_name)
149
+
150
+ mode = "llm" if client is not None else "fallback"
151
+ print(f"[START] task={task_id} env=ClipQualityEnv model={model_name} mode={mode}", flush=True)
152
+ obs = env.reset(task_id=task_id)
153
+ step_num = 0
154
+ rewards: list[float] = []
155
+ for _ in range(int(obs.max_steps)):
156
+ step_num += 1
157
+ action_dict = agent.act(task_id, obs.model_dump())
158
+ action_dict.setdefault("clip_id", obs.clip_metadata.clip_id)
159
+ action = Action.model_validate(action_dict)
160
+ obs = env.step(action)
161
+ reward = float(obs.reward)
162
+ done = bool(obs.done)
163
+ rewards.append(reward)
164
+ action_name = str(action.label)
165
+ print(f"[STEP] step={step_num} label={action_name} reward={reward:.2f} done={str(done).lower()} error=null", flush=True)
166
+ if done:
167
+ break
168
+
169
+ total_reward = float(obs.info.get("total_reward", sum(rewards))) if step_num > 0 else 0.0
170
+ score = total_reward / max(1, step_num)
171
+ final_reward = rewards[-1] if rewards else 0.0
172
+ success = score >= 0.70
173
+ rewards_str = ",".join([f"{r:.2f}" for r in rewards]) if rewards else "0.00"
174
+ print(
175
+ f"[END] success={str(success).lower()} steps={step_num} score={score:.3f} "
176
+ f"total_reward={total_reward:.3f} final_reward={final_reward:.3f} rewards={rewards_str}",
177
+ flush=True,
178
+ )
179
+ return {
180
+ "task_id": task_id,
181
+ "reward": score,
182
+ "total_reward": total_reward,
183
+ "final_reward": final_reward,
184
+ "steps": step_num,
185
+ "success": success,
186
+ "mode": mode,
187
+ }
188
+
189
+
190
+ def run_baseline(task: str | None = None) -> Dict:
191
+ client: OpenAI | None = None
192
+ model_name = os.environ.get("MODEL_NAME", DEFAULT_MODEL_NAME)
193
+ load_error: Exception | None = None
194
+ try:
195
+ client, model_name = _load_client()
196
+ except Exception as exc:
197
+ load_error = exc
198
+
199
+ tasks = [task] if task else list(TASK_IDS)
200
+ if task is not None and task not in TASK_REGISTRY:
201
+ tasks = [task]
202
+ start_time = time.time()
203
+ results: list[dict[str, Any]] = []
204
+ for task_id in tasks:
205
+ try:
206
+ results.append(run_episode(task_id, client, model_name))
207
+ except Exception as exc:
208
+ print(f"[START] task={task_id} env=ClipQualityEnv model={model_name}", flush=True)
209
+ print(f"[END] success=false steps=0 score=0.000 rewards=0.00 error={str(exc)}", flush=True)
210
+ results.append(
211
+ {
212
+ "task_id": task_id,
213
+ "reward": 0.0,
214
+ "total_reward": 0.0,
215
+ "final_reward": 0.0,
216
+ "steps": 0,
217
+ "success": False,
218
+ "error": str(exc),
219
+ }
220
+ )
221
+
222
+ overall = sum(float(r.get("reward", 0.0)) for r in results) / len(results) if results else 0.0
223
+ output = {
224
+ "baseline_scores": {"overall_avg": round(overall, 4)},
225
+ "model": model_name,
226
+ "runtime_seconds": round(time.time() - start_time, 2),
227
+ "detail": results,
228
+ }
229
+ if load_error is not None:
230
+ output["warning"] = f"LLM unavailable; used deterministic fallback: {load_error}"
231
+ return output
232
+
233
+
234
+ def main() -> None:
235
+ parser = argparse.ArgumentParser()
236
+ parser.add_argument("--output", choices=["text", "json"], default="text")
237
+ parser.add_argument("task", nargs="?", default=None)
238
+ args = parser.parse_args()
239
+
240
+ result = run_baseline(task=args.task)
241
+ if args.output == "json":
242
+ print(json.dumps(result))
243
+ else:
244
+ print(json.dumps(result, indent=2))
245
+
246
+
247
+ if __name__ == "__main__":
248
+ main()
models.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Root model module for OpenEnv CLI compatibility."""
2
+
3
+ from clip_quality_env.models import (
4
+ Action,
5
+ ClipLabel,
6
+ ClipMetadata,
7
+ CorpusIncident,
8
+ EnvironmentState,
9
+ EpisodeHistoryItem,
10
+ HistoryItem,
11
+ Observation,
12
+ Reward,
13
+ State,
14
+ TaskInfo,
15
+ )
16
+
17
+ __all__ = [
18
+ "Action",
19
+ "ClipLabel",
20
+ "ClipMetadata",
21
+ "CorpusIncident",
22
+ "EnvironmentState",
23
+ "EpisodeHistoryItem",
24
+ "HistoryItem",
25
+ "Observation",
26
+ "Reward",
27
+ "State",
28
+ "TaskInfo",
29
+ ]
openenv.yaml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: clip_quality_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+ description: >
8
+ CLIP Quality Analyzer environment with OpenEnv reference structure for
9
+ clip-case review, quality-rule refinement, and deterministic scoring.
10
+ tags:
11
+ - openenv
12
+ - reinforcement-learning
13
+ - clip-quality
14
+ - quality-analysis
15
+ - clip-review
16
+ - hackathon-2026
pyproject.toml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "clip-quality-env"
7
+ version = "1.0.0"
8
+ description = "ClipQualityEnv — OpenEnv RL environment for clip-quality analysis and classification"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "openenv-core>=0.2.3",
12
+ "pydantic>=2.0.0",
13
+ "openai>=1.0.0",
14
+ "fastapi>=0.104.0",
15
+ "uvicorn>=0.24.0",
16
+ "requests>=2.25.0",
17
+ "websockets>=12.0",
18
+ "gradio>=4.0.0",
19
+ "pandas>=2.0.0",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=7.0.0"]
24
+
25
+ [project.scripts]
26
+ server = "server.app:main"
27
+
28
+ [tool.setuptools]
29
+ include-package-data = true
30
+ packages = ["clip_quality_env", "server"]
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ openenv-core>=0.2.3
2
+ pydantic>=2.0.0
3
+ openai>=1.0.0
4
+ fastapi>=0.104.0
5
+ uvicorn>=0.24.0
6
+ requests>=2.25.0
7
+ websockets>=12.0
8
+ pytest>=7.0.0
9
+ gradio>=4.0.0
10
+ opencv-python-headless>=4.10.0
11
+ numpy>=1.26.0
requirements_extractor.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ opencv-python-headless>=4.10.0
2
+ mediapipe>=0.10.0
3
+ openai-whisper>=20231117
4
+ numpy>=1.26.0
scripts/extract_mp4_metadata.py ADDED
@@ -0,0 +1,696 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ try:
14
+ import cv2
15
+ except ImportError as exc:
16
+ cv2 = None
17
+ _CV2_IMPORT_ERROR = exc
18
+ else:
19
+ _CV2_IMPORT_ERROR = None
20
+
21
+ try:
22
+ import numpy as np
23
+ except ImportError as exc:
24
+ np = None
25
+ _NUMPY_IMPORT_ERROR = exc
26
+ else:
27
+ _NUMPY_IMPORT_ERROR = None
28
+
29
+ try:
30
+ import whisper
31
+ except ImportError as exc:
32
+ whisper = None
33
+ _WHISPER_IMPORT_ERROR = exc
34
+ else:
35
+ _WHISPER_IMPORT_ERROR = None
36
+
37
+ try:
38
+ import mediapipe as mp
39
+ except ImportError:
40
+ mp = None
41
+
42
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
43
+ if str(PROJECT_ROOT) not in sys.path:
44
+ sys.path.insert(0, str(PROJECT_ROOT))
45
+
46
+ from clip_quality_env.models import ClipMetadata
47
+ from clip_quality_env.real_clips import derive_clip_difficulty
48
+ from clip_quality_env.rubric import RubricState
49
+
50
+
51
+ SUPPORTED_SUFFIXES = {".mp4", ".mov", ".mkv", ".webm"}
52
+ _FACE_FALLBACK_WARNED = False
53
+ DEFAULT_ENV_TAG = "unknown_env"
54
+
55
+ ENV_KEYWORDS: dict[str, tuple[str, ...]] = {
56
+ "podcast_studio": ("podcast", "studio", "talkinghead", "talking_head"),
57
+ "office": ("office", "meeting", "work", "corp", "workspace"),
58
+ "home_office": ("home", "bedroom", "livingroom", "living_room"),
59
+ "webcam_room": ("webcam", "zoom", "teams", "meet"),
60
+ "outdoor_interview": ("outdoor", "outside", "park", "field"),
61
+ "crowded_event": ("event", "crowd", "conference", "stage"),
62
+ "car_vlog": ("car", "vehicle", "dashboard"),
63
+ "street_walk": ("street", "walk", "market", "sidewalk"),
64
+ }
65
+
66
+
67
+ @dataclass
68
+ class FaceStats:
69
+ area_ratio: float
70
+ confidence: float
71
+ yaw_deg: float
72
+ pitch_deg: float
73
+ mouth_open_ratio: float
74
+ blink_rate_hz: float
75
+ occlusion_present: bool
76
+
77
+
78
+ def _run_ffprobe(path: Path) -> dict[str, Any]:
79
+ cmd = [
80
+ "ffprobe",
81
+ "-v",
82
+ "error",
83
+ "-select_streams",
84
+ "v:0",
85
+ "-show_entries",
86
+ "stream=width,height,r_frame_rate:format=duration",
87
+ "-of",
88
+ "json",
89
+ str(path),
90
+ ]
91
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
92
+ if result.returncode != 0:
93
+ raise RuntimeError(f"ffprobe failed for {path}: {result.stderr.strip()}")
94
+ payload = json.loads(result.stdout)
95
+ streams = payload.get("streams", [])
96
+ if not streams:
97
+ raise RuntimeError(f"No video stream found in {path}")
98
+ stream = streams[0]
99
+
100
+ width = int(stream.get("width", 0))
101
+ height = int(stream.get("height", 0))
102
+ if width <= 0 or height <= 0:
103
+ raise RuntimeError(f"Invalid resolution from ffprobe for {path}")
104
+
105
+ rate = str(stream.get("r_frame_rate", "0/1"))
106
+ num, den = rate.split("/")
107
+ fps = float(num) / max(float(den), 1.0)
108
+ duration_s = float(payload.get("format", {}).get("duration", 0.0))
109
+ if duration_s <= 0.0:
110
+ raise RuntimeError(f"Invalid duration from ffprobe for {path}")
111
+
112
+ return {
113
+ "duration_s": round(duration_s, 3),
114
+ "fps": max(1, int(round(fps))),
115
+ "resolution": f"{width}x{height}",
116
+ "width": width,
117
+ "height": height,
118
+ }
119
+
120
+
121
+ def _sample_frames(path: Path, sample_fps: float) -> tuple[list[np.ndarray], float]:
122
+ cap = cv2.VideoCapture(str(path))
123
+ if not cap.isOpened():
124
+ raise RuntimeError(f"Failed to open video: {path}")
125
+
126
+ source_fps = cap.get(cv2.CAP_PROP_FPS)
127
+ if source_fps <= 0:
128
+ source_fps = 24.0
129
+ stride = max(1, int(round(source_fps / max(sample_fps, 0.1))))
130
+
131
+ frames: list[np.ndarray] = []
132
+ idx = 0
133
+ while True:
134
+ ok, frame = cap.read()
135
+ if not ok:
136
+ break
137
+ if idx % stride == 0:
138
+ frames.append(frame)
139
+ idx += 1
140
+
141
+ cap.release()
142
+ if not frames:
143
+ raise RuntimeError(f"No frames sampled from {path}")
144
+ return frames, source_fps
145
+
146
+
147
+ def _landmark_distance(a: Any, b: Any, width: int, height: int) -> float:
148
+ ax, ay = float(a.x) * width, float(a.y) * height
149
+ bx, by = float(b.x) * width, float(b.y) * height
150
+ return math.hypot(ax - bx, ay - by)
151
+
152
+
153
+ def _extract_face_stats_fallback(frames: list[np.ndarray], sample_fps: float) -> FaceStats:
154
+ """
155
+ Fallback when MediaPipe face mesh API is unavailable in current build.
156
+ Uses image heuristics that keep extraction running instead of dropping all clips.
157
+ """
158
+ global _FACE_FALLBACK_WARNED
159
+ if not _FACE_FALLBACK_WARNED:
160
+ print("[WARN] MediaPipe Face Mesh unavailable; using heuristic face fallback metrics.")
161
+ _FACE_FALLBACK_WARNED = True
162
+
163
+ h, w = frames[0].shape[:2]
164
+ area_ratio = 0.28
165
+
166
+ gray0 = cv2.cvtColor(frames[0], cv2.COLOR_BGR2GRAY)
167
+ conf_proxy = 1.0 - float(np.std(gray0) / 128.0)
168
+ confidence = float(np.clip(conf_proxy, 0.55, 0.90))
169
+
170
+ # Head pose proxies from horizontal/vertical gradient imbalance.
171
+ grad_x = np.mean(np.abs(np.diff(gray0.astype(np.float32), axis=1)))
172
+ grad_y = np.mean(np.abs(np.diff(gray0.astype(np.float32), axis=0)))
173
+ yaw_deg = float(np.clip((grad_x / max(grad_y, 1e-6)) * 8.0, 0.0, 35.0))
174
+ pitch_deg = float(np.clip((grad_y / max(grad_x, 1e-6)) * 6.0, 0.0, 25.0))
175
+
176
+ # Mouth-open proxy from lower-center variance.
177
+ y0, y1 = int(0.55 * h), int(0.82 * h)
178
+ x0, x1 = int(0.33 * w), int(0.67 * w)
179
+ roi = gray0[y0:y1, x0:x1]
180
+ mouth_open_ratio = float(np.clip(np.std(roi) / 96.0, 0.18, 0.62))
181
+
182
+ # Blink proxy unavailable -> conservative nominal rate.
183
+ blink_rate_hz = 0.25
184
+
185
+ # Occlusion proxy via darkness saturation ratio in center region.
186
+ center = gray0[int(0.2 * h) : int(0.85 * h), int(0.2 * w) : int(0.8 * w)]
187
+ dark_ratio = float(np.mean(center < 18))
188
+ occlusion_present = dark_ratio > 0.35
189
+
190
+ return FaceStats(
191
+ area_ratio=area_ratio,
192
+ confidence=confidence,
193
+ yaw_deg=yaw_deg,
194
+ pitch_deg=pitch_deg,
195
+ mouth_open_ratio=mouth_open_ratio,
196
+ blink_rate_hz=blink_rate_hz,
197
+ occlusion_present=occlusion_present,
198
+ )
199
+
200
+
201
+ def _extract_face_stats(frames: list[np.ndarray], sample_fps: float) -> FaceStats:
202
+ if mp is None or not hasattr(mp, "solutions") or not hasattr(mp.solutions, "face_mesh"):
203
+ return _extract_face_stats_fallback(frames, sample_fps)
204
+
205
+ mp_mesh = mp.solutions.face_mesh
206
+ face_mesh = mp_mesh.FaceMesh(
207
+ static_image_mode=True,
208
+ max_num_faces=1,
209
+ refine_landmarks=True,
210
+ min_detection_confidence=0.5,
211
+ )
212
+
213
+ area_ratios: list[float] = []
214
+ confidences: list[float] = []
215
+ yaws: list[float] = []
216
+ pitches: list[float] = []
217
+ mouth_open: list[float] = []
218
+ blink_closures: list[float] = []
219
+ missing_face = 0
220
+
221
+ # Mouth/eye landmarks from MediaPipe Face Mesh topology.
222
+ mouth_top, mouth_bottom = 13, 14
223
+ mouth_left, mouth_right = 78, 308
224
+ l_eye_top, l_eye_bottom = 159, 145
225
+ l_eye_left, l_eye_right = 33, 133
226
+ r_eye_top, r_eye_bottom = 386, 374
227
+ r_eye_left, r_eye_right = 362, 263
228
+ nose_tip, left_cheek, right_cheek = 1, 234, 454
229
+ forehead, chin = 10, 152
230
+
231
+ for frame in frames:
232
+ h, w = frame.shape[:2]
233
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
234
+ result = face_mesh.process(rgb)
235
+ if not result.multi_face_landmarks:
236
+ missing_face += 1
237
+ continue
238
+
239
+ lm = result.multi_face_landmarks[0].landmark
240
+
241
+ xs = np.array([p.x for p in lm], dtype=np.float32)
242
+ ys = np.array([p.y for p in lm], dtype=np.float32)
243
+ x0 = float(np.clip(xs.min(), 0.0, 1.0))
244
+ x1 = float(np.clip(xs.max(), 0.0, 1.0))
245
+ y0 = float(np.clip(ys.min(), 0.0, 1.0))
246
+ y1 = float(np.clip(ys.max(), 0.0, 1.0))
247
+ area_ratio = max(0.0, (x1 - x0) * (y1 - y0))
248
+ area_ratios.append(area_ratio)
249
+
250
+ # MediaPipe FaceMesh does not expose direct confidence per face; use coverage proxy.
251
+ conf = 1.0 - float(np.mean((xs <= 0.0) | (xs >= 1.0) | (ys <= 0.0) | (ys >= 1.0)))
252
+ confidences.append(float(np.clip(conf, 0.0, 1.0)))
253
+
254
+ nose = lm[nose_tip]
255
+ lch = lm[left_cheek]
256
+ rch = lm[right_cheek]
257
+ fh = lm[forehead]
258
+ ch = lm[chin]
259
+ yaw = abs((nose.x - (lch.x + rch.x) / 2.0) * 120.0)
260
+ pitch = abs((nose.y - (fh.y + ch.y) / 2.0) * 120.0)
261
+ yaws.append(float(yaw))
262
+ pitches.append(float(pitch))
263
+
264
+ mouth_h = _landmark_distance(lm[mouth_top], lm[mouth_bottom], w, h)
265
+ mouth_w = max(_landmark_distance(lm[mouth_left], lm[mouth_right], w, h), 1e-6)
266
+ mouth_open.append(float(np.clip(mouth_h / mouth_w, 0.0, 1.0)))
267
+
268
+ l_h = _landmark_distance(lm[l_eye_top], lm[l_eye_bottom], w, h)
269
+ l_w = max(_landmark_distance(lm[l_eye_left], lm[l_eye_right], w, h), 1e-6)
270
+ r_h = _landmark_distance(lm[r_eye_top], lm[r_eye_bottom], w, h)
271
+ r_w = max(_landmark_distance(lm[r_eye_left], lm[r_eye_right], w, h), 1e-6)
272
+ eye_open_ratio = float(np.clip(0.5 * (l_h / l_w + r_h / r_w), 0.0, 1.0))
273
+ blink_closures.append(eye_open_ratio)
274
+
275
+ face_mesh.close()
276
+
277
+ if not area_ratios:
278
+ raise RuntimeError("No detectable face landmarks in sampled frames")
279
+
280
+ # Blink estimate: count closures under threshold in sampled stream, then normalize by duration.
281
+ blink_events = 0
282
+ prev_closed = False
283
+ for r in blink_closures:
284
+ closed = r < 0.18
285
+ if closed and not prev_closed:
286
+ blink_events += 1
287
+ prev_closed = closed
288
+ sampled_seconds = len(frames) / max(sample_fps, 1e-6)
289
+ blink_rate_hz = blink_events / max(sampled_seconds, 1e-6)
290
+
291
+ occlusion_ratio = missing_face / len(frames)
292
+ occlusion_present = occlusion_ratio > 0.30
293
+
294
+ return FaceStats(
295
+ area_ratio=float(np.clip(np.mean(area_ratios), 0.0, 1.0)),
296
+ confidence=float(np.clip(np.mean(confidences), 0.0, 1.0)),
297
+ yaw_deg=float(np.clip(np.mean(yaws), 0.0, 180.0)),
298
+ pitch_deg=float(np.clip(np.mean(pitches), 0.0, 180.0)),
299
+ mouth_open_ratio=float(np.clip(np.mean(mouth_open), 0.0, 1.0)),
300
+ blink_rate_hz=max(0.0, float(blink_rate_hz)),
301
+ occlusion_present=occlusion_present,
302
+ )
303
+
304
+
305
+ def _motion_score(frames: list[np.ndarray]) -> float:
306
+ if len(frames) < 2:
307
+ return 0.0
308
+ scores = []
309
+ prev = cv2.cvtColor(frames[0], cv2.COLOR_BGR2GRAY)
310
+ for frame in frames[1:]:
311
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
312
+ diff = cv2.absdiff(prev, gray)
313
+ scores.append(float(np.mean(diff) / 255.0))
314
+ prev = gray
315
+ return float(np.clip(np.mean(scores), 0.0, 1.0))
316
+
317
+
318
+ def _bg_complexity(frames: list[np.ndarray]) -> tuple[str, float]:
319
+ # Hybrid complexity proxy: edge density + intensity variation + local texture energy.
320
+ edge_densities = []
321
+ texture_energies = []
322
+ intensity_vars = []
323
+ for frame in frames:
324
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
325
+
326
+ edges = cv2.Canny(gray, 80, 160)
327
+ edge_density = float(np.mean(edges > 0))
328
+ edge_densities.append(edge_density)
329
+
330
+ lap = cv2.Laplacian(gray, cv2.CV_32F)
331
+ texture_energy = float(np.mean(np.abs(lap)) / 255.0)
332
+ texture_energies.append(texture_energy)
333
+
334
+ intensity_vars.append(float(np.std(gray.astype(np.float32)) / 128.0))
335
+
336
+ edge_score = float(np.clip(np.mean(edge_densities), 0.0, 1.0))
337
+ texture_score = float(np.clip(np.mean(texture_energies), 0.0, 1.0))
338
+ variation_score = float(np.clip(np.mean(intensity_vars), 0.0, 1.0))
339
+ score = float(np.clip(0.45 * edge_score + 0.30 * texture_score + 0.25 * variation_score, 0.0, 1.0))
340
+
341
+ if score < 0.07:
342
+ tag = "solid_dark"
343
+ elif score < 0.10:
344
+ tag = "simple_room"
345
+ elif score < 0.15:
346
+ tag = "busy_room"
347
+ else:
348
+ tag = "crowded_event"
349
+ return tag, score
350
+
351
+
352
+ def _lighting_uniformity(frames: list[np.ndarray]) -> float:
353
+ vals = []
354
+ for frame in frames:
355
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255.0
356
+ std = float(np.std(gray))
357
+ vals.append(float(np.clip(1.0 - 2.0 * std, 0.0, 1.0)))
358
+ return float(np.clip(np.mean(vals), 0.0, 1.0))
359
+
360
+
361
+ def _extract_audio_track(video_path: Path, wav_path: Path) -> None:
362
+ cmd = [
363
+ "ffmpeg",
364
+ "-y",
365
+ "-i",
366
+ str(video_path),
367
+ "-ac",
368
+ "1",
369
+ "-ar",
370
+ "16000",
371
+ str(wav_path),
372
+ ]
373
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
374
+ if result.returncode != 0:
375
+ raise RuntimeError(f"ffmpeg audio extraction failed for {video_path}: {result.stderr.strip()}")
376
+
377
+
378
+ def _audio_snr_db(wav_path: Path) -> float:
379
+ # Approximate SNR using RMS percentile split.
380
+ cmd = [
381
+ "ffprobe",
382
+ "-v",
383
+ "error",
384
+ "-f",
385
+ "lavfi",
386
+ "-i",
387
+ f"amovie={wav_path},astats=metadata=1:reset=1",
388
+ "-show_entries",
389
+ "frame_tags=lavfi.astats.Overall.RMS_level",
390
+ "-of",
391
+ "json",
392
+ ]
393
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
394
+ if result.returncode != 0:
395
+ return 0.0
396
+ payload = json.loads(result.stdout or "{}")
397
+ frames = payload.get("frames", [])
398
+ levels = []
399
+ for frame in frames:
400
+ tags = frame.get("tags", {})
401
+ val = tags.get("lavfi.astats.Overall.RMS_level")
402
+ if val is None:
403
+ continue
404
+ try:
405
+ levels.append(float(val))
406
+ except ValueError:
407
+ continue
408
+ if len(levels) < 10:
409
+ return 0.0
410
+ arr = np.array(levels, dtype=np.float32)
411
+ speech = np.percentile(arr, 85)
412
+ noise = np.percentile(arr, 20)
413
+ return float(np.clip(speech - noise, 0.0, 80.0))
414
+
415
+
416
+ def _transcribe(wav_path: Path, model: Any) -> tuple[int, float]:
417
+ result = model.transcribe(str(wav_path), language="en", fp16=False, verbose=False)
418
+ text = str(result.get("text", "")).strip()
419
+ segments = result.get("segments", []) or []
420
+ # Whisper does not provide true confidence; use avg_logprob as bounded proxy.
421
+ confs = []
422
+ for seg in segments:
423
+ avg_logprob = seg.get("avg_logprob")
424
+ if avg_logprob is None:
425
+ continue
426
+ confs.append(float(1.0 / (1.0 + math.exp(-avg_logprob))))
427
+ confidence = float(np.mean(confs)) if confs else 0.0
428
+ return len(text.split()), float(np.clip(confidence, 0.0, 1.0))
429
+
430
+
431
+ def _load_environment_map(path: str | None) -> dict[str, str]:
432
+ if not path:
433
+ return {}
434
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
435
+ mapping: dict[str, str] = {}
436
+ if isinstance(payload, dict):
437
+ for key, value in payload.items():
438
+ k = str(key).strip().lower().replace("\\", "/")
439
+ v = str(value).strip()
440
+ if not k or not v:
441
+ continue
442
+ mapping[k] = v
443
+ return mapping
444
+ if isinstance(payload, list):
445
+ for item in payload:
446
+ if not isinstance(item, dict):
447
+ continue
448
+ key = item.get("clip_id") or item.get("path") or item.get("name")
449
+ value = item.get("environment_tag")
450
+ if key is None or value is None:
451
+ continue
452
+ k = str(key).strip().lower().replace("\\", "/")
453
+ v = str(value).strip()
454
+ if not k or not v:
455
+ continue
456
+ mapping[k] = v
457
+ return mapping
458
+ raise ValueError("environment map must be JSON object or list of {clip_id/path,name,environment_tag}")
459
+
460
+
461
+ def _environment_tag_from_features(
462
+ bg_complexity_score: float,
463
+ motion_score: float,
464
+ lighting_uniformity: float,
465
+ audio_snr_db: float,
466
+ ) -> str:
467
+ activity = 0.60 * bg_complexity_score + 0.30 * motion_score + 0.10 * (1.0 - lighting_uniformity)
468
+ if motion_score >= 0.075 or (activity >= 0.14 and bg_complexity_score >= 0.13):
469
+ return "street_walk"
470
+ if bg_complexity_score >= 0.18 or activity >= 0.18:
471
+ return "crowded_event"
472
+ if audio_snr_db < 14.5 and motion_score >= 0.035:
473
+ return "car_vlog"
474
+ if lighting_uniformity >= 0.76 and bg_complexity_score <= 0.09 and motion_score <= 0.03 and audio_snr_db >= 16.0:
475
+ return "podcast_studio"
476
+ if bg_complexity_score <= 0.12 and lighting_uniformity >= 0.67:
477
+ return "office"
478
+ if lighting_uniformity < 0.62:
479
+ return "webcam_room"
480
+ return "home_office"
481
+
482
+
483
+ def _environment_tag(
484
+ path_hint: Path,
485
+ clip_id: str,
486
+ bg_complexity_score: float,
487
+ motion_score: float,
488
+ lighting_uniformity: float,
489
+ audio_snr_db: float,
490
+ environment_map: dict[str, str] | None = None,
491
+ ) -> str:
492
+ mapping = environment_map or {}
493
+ norm_path = str(path_hint).strip().lower().replace("\\", "/")
494
+ base_name = Path(norm_path).name
495
+ base_stem = Path(norm_path).stem
496
+ clip_key = clip_id.strip().lower()
497
+
498
+ for key in (norm_path, base_name, base_stem, clip_key):
499
+ if key in mapping:
500
+ return mapping[key]
501
+
502
+ # Use only user-intended path hint (relative clip path), not absolute system path.
503
+ for tag, keys in ENV_KEYWORDS.items():
504
+ if any(k in norm_path for k in keys):
505
+ return tag
506
+
507
+ return _environment_tag_from_features(
508
+ bg_complexity_score=bg_complexity_score,
509
+ motion_score=motion_score,
510
+ lighting_uniformity=lighting_uniformity,
511
+ audio_snr_db=audio_snr_db,
512
+ )
513
+
514
+
515
+ def _framing_from_path_or_pose(path_hint: Path, yaw_deg: float) -> str:
516
+ name = str(path_hint).lower()
517
+ if "closeup" in name or "close_up" in name:
518
+ return "closeup"
519
+ if "offgaze" in name or "off_gaze" in name:
520
+ return "offgaze"
521
+ if "left" in name:
522
+ return "left"
523
+ if "right" in name:
524
+ return "right"
525
+ if yaw_deg <= 6.0:
526
+ return "front"
527
+ if yaw_deg <= 11.0:
528
+ return "offgaze"
529
+ return "left" if ("left" in name) else "right"
530
+
531
+
532
+ def _extract_clip_metadata(
533
+ video_path: Path,
534
+ path_hint: Path,
535
+ sample_fps: float,
536
+ asr_model: Any,
537
+ rubric: RubricState,
538
+ with_difficulty: bool,
539
+ environment_tag_override: str | None = None,
540
+ environment_map: dict[str, str] | None = None,
541
+ framing_override: str | None = None,
542
+ ) -> dict[str, Any]:
543
+ probe = _run_ffprobe(video_path)
544
+ frames, _source_fps = _sample_frames(video_path, sample_fps=sample_fps)
545
+ face = _extract_face_stats(frames, sample_fps=sample_fps)
546
+ motion = _motion_score(frames)
547
+ bg_tag, bg_score = _bg_complexity(frames)
548
+ light = _lighting_uniformity(frames)
549
+
550
+ wav_path = video_path.with_suffix(".tmp16k.wav")
551
+ _extract_audio_track(video_path, wav_path)
552
+ try:
553
+ snr = _audio_snr_db(wav_path)
554
+ word_count, transcript_conf = _transcribe(wav_path, asr_model)
555
+ finally:
556
+ if wav_path.exists():
557
+ wav_path.unlink()
558
+
559
+ row: dict[str, Any] = {
560
+ "clip_id": video_path.stem,
561
+ "duration_s": probe["duration_s"],
562
+ "fps": probe["fps"],
563
+ "resolution": probe["resolution"],
564
+ "face_area_ratio": round(face.area_ratio, 4),
565
+ "face_confidence": round(face.confidence, 4),
566
+ "head_pose_yaw_deg": round(face.yaw_deg, 3),
567
+ "head_pose_pitch_deg": round(face.pitch_deg, 3),
568
+ "motion_score": round(motion, 4),
569
+ "bg_complexity": bg_tag,
570
+ "bg_complexity_score": round(bg_score, 4),
571
+ "mouth_open_ratio": round(face.mouth_open_ratio, 4),
572
+ "blink_rate_hz": round(face.blink_rate_hz, 4),
573
+ "audio_snr_db": round(float(snr), 3),
574
+ "transcript_word_count": int(word_count),
575
+ "transcript_confidence": round(float(transcript_conf), 4),
576
+ "lighting_uniformity": round(light, 4),
577
+ "occlusion_present": bool(face.occlusion_present),
578
+ "environment_tag": environment_tag_override
579
+ or _environment_tag(
580
+ path_hint=path_hint,
581
+ clip_id=video_path.stem,
582
+ bg_complexity_score=bg_score,
583
+ motion_score=motion,
584
+ lighting_uniformity=light,
585
+ audio_snr_db=float(snr),
586
+ environment_map=environment_map,
587
+ ),
588
+ "framing": framing_override or _framing_from_path_or_pose(path_hint=path_hint, yaw_deg=face.yaw_deg),
589
+ }
590
+
591
+ clip = ClipMetadata(**row)
592
+ payload = clip.model_dump()
593
+ if with_difficulty:
594
+ payload["difficulty"] = derive_clip_difficulty(payload, rubric)
595
+ return payload
596
+
597
+
598
+ def _iter_videos(input_dir: Path) -> list[Path]:
599
+ files = [p for p in input_dir.rglob("*") if p.is_file() and p.suffix.lower() in SUPPORTED_SUFFIXES]
600
+ files.sort()
601
+ return files
602
+
603
+
604
+ def main() -> None:
605
+ parser = argparse.ArgumentParser(description="Extract ClipMetadata manifest from MP4 files.")
606
+ parser.add_argument("--input-dir", type=str, required=True, help="Directory containing video clips.")
607
+ parser.add_argument("--output", type=str, required=True, help="Output manifest path (.jsonl).")
608
+ parser.add_argument("--sample-fps", type=float, default=2.0, help="Frame sampling FPS for visual features.")
609
+ parser.add_argument("--max-clips", type=int, default=0, help="Optional cap; 0 means all clips.")
610
+ parser.add_argument(
611
+ "--difficulty-mode",
612
+ type=str,
613
+ choices=["none", "derive"],
614
+ default="derive",
615
+ help="Attach derived difficulty labels or not.",
616
+ )
617
+ parser.add_argument(
618
+ "--whisper-model",
619
+ type=str,
620
+ default="small",
621
+ help="Whisper model size (tiny, base, small, medium, large).",
622
+ )
623
+ parser.add_argument(
624
+ "--environment-tag",
625
+ type=str,
626
+ default=None,
627
+ help="Optional fixed environment_tag override for all clips in this run.",
628
+ )
629
+ parser.add_argument(
630
+ "--framing",
631
+ type=str,
632
+ default=None,
633
+ help="Optional fixed framing override for all clips in this run.",
634
+ )
635
+ parser.add_argument(
636
+ "--environment-map",
637
+ type=str,
638
+ default=None,
639
+ help="Optional JSON map/list to assign environment_tag by clip_id/path/name.",
640
+ )
641
+ args = parser.parse_args()
642
+
643
+ dep_errors = [err for err in (_CV2_IMPORT_ERROR, _NUMPY_IMPORT_ERROR, _WHISPER_IMPORT_ERROR) if err is not None]
644
+ if dep_errors:
645
+ raise RuntimeError(
646
+ "Missing extractor dependencies. Install: "
647
+ "opencv-python-headless mediapipe openai-whisper numpy"
648
+ ) from dep_errors[0]
649
+
650
+ input_dir = Path(args.input_dir).resolve()
651
+ output_path = Path(args.output).resolve()
652
+ output_path.parent.mkdir(parents=True, exist_ok=True)
653
+
654
+ if not input_dir.exists():
655
+ raise FileNotFoundError(f"Input dir not found: {input_dir}")
656
+
657
+ rubric = RubricState()
658
+ asr_model = whisper.load_model(args.whisper_model)
659
+ environment_map = _load_environment_map(args.environment_map)
660
+
661
+ videos = _iter_videos(input_dir)
662
+ if args.max_clips > 0:
663
+ videos = videos[: args.max_clips]
664
+ if not videos:
665
+ raise RuntimeError(f"No supported video files found in {input_dir}")
666
+
667
+ failures: list[tuple[str, str]] = []
668
+ with open(output_path, "w", encoding="utf-8") as out:
669
+ for video_path in videos:
670
+ try:
671
+ row = _extract_clip_metadata(
672
+ video_path=video_path,
673
+ path_hint=video_path.relative_to(input_dir),
674
+ sample_fps=args.sample_fps,
675
+ asr_model=asr_model,
676
+ rubric=rubric,
677
+ with_difficulty=(args.difficulty_mode == "derive"),
678
+ environment_tag_override=args.environment_tag,
679
+ environment_map=environment_map,
680
+ framing_override=args.framing,
681
+ )
682
+ except Exception as exc:
683
+ failures.append((str(video_path), str(exc)))
684
+ continue
685
+ out.write(json.dumps(row, ensure_ascii=True) + "\n")
686
+
687
+ with open(output_path, "r", encoding="utf-8") as f:
688
+ written = sum(1 for _ in f)
689
+ print(json.dumps({"output": str(output_path), "written_rows": written, "failed_clips": len(failures)}, indent=2))
690
+ if failures:
691
+ for clip, err in failures:
692
+ print(f"[FAIL] {clip}: {err}")
693
+
694
+
695
+ if __name__ == "__main__":
696
+ main()
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Clip-quality server package."""
server/app.py ADDED
@@ -0,0 +1,963 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import threading
7
+ from typing import Any
8
+
9
+ import gradio as gr
10
+ import pandas as pd
11
+ import uvicorn
12
+ from fastapi import BackgroundTasks, HTTPException
13
+ from openenv.core.env_server import create_fastapi_app
14
+
15
+ import inference
16
+ from models import Action, Observation, TaskInfo
17
+ from server.baseline_runs import baseline_run_tracker
18
+ from server.environment import ClipQualityEnvironment
19
+ from server.grader import grade
20
+ from server.tasks import TASK_REGISTRY
21
+
22
+ PRODUCT_NAME = "CLIP Quality Analyzer"
23
+ ENVIRONMENT_ID = "clip_quality_env"
24
+ OVERRIDDEN_ROUTES = {"/health", "/state", "/tasks", "/grader", "/baseline"}
25
+ TASK_SURFACE_DESCRIPTIONS = {
26
+ "task_easy": "Classify clips with clear quality signals and concise metadata-based reasoning.",
27
+ "task_medium": "Classify borderline clips by balancing mixed quality indicators.",
28
+ "task_hard": "Classify difficult clips with conflicting signals and explicit trade-off reasoning.",
29
+ }
30
+ TASK_INPUT_TAB_MAP = {
31
+ "task_easy": "easy",
32
+ "task_medium": "medium",
33
+ "task_hard": "hard",
34
+ }
35
+ CLASS_LABEL_CHOICES = [
36
+ ("KEEP", "KEEP"),
37
+ ("BORDERLINE", "BORDERLINE"),
38
+ ("REJECT", "REJECT"),
39
+ ]
40
+ BASELINE_RUN_BUTTON_LABEL = "Run LLM Baseline Agent"
41
+ BASELINE_RUN_BUTTON_RUNNING_LABEL = "Running LLM Baseline Agent..."
42
+ QUALITY_HINT_BUTTON_LABEL = "Load Quality Hint"
43
+ BASELINE_RESULT_PLACEHOLDER = (
44
+ "Run the baseline agent to view model, mode, reward achieved per step, and success status."
45
+ )
46
+ HF_TOKEN_MISSING_WARNING = "LLM unavailable — no HF_TOKEN configured. Showing deterministic fallback result."
47
+
48
+
49
+ def _normalized_path(path: str) -> str:
50
+ return path.rstrip("/") or "/"
51
+
52
+
53
+ def _surface_task_description(task_id: str, fallback: str) -> str:
54
+ return TASK_SURFACE_DESCRIPTIONS.get(task_id, fallback)
55
+
56
+
57
+ def _input_tab_for_task(task_id: str) -> str:
58
+ return TASK_INPUT_TAB_MAP.get(task_id, "easy")
59
+
60
+
61
+ def _clean_reasoning_text(value: str | None) -> str:
62
+ return str(value or "").strip()
63
+
64
+
65
+ def _build_reasoning_from_sections(sections: list[tuple[str, str]]) -> str:
66
+ lines: list[str] = []
67
+ for title, value in sections:
68
+ cleaned = _clean_reasoning_text(value)
69
+ if cleaned:
70
+ lines.append(f"{title}: {cleaned}")
71
+ return "\n".join(lines) if lines else "No reasoning provided."
72
+
73
+
74
+ def _resolve_tiered_submission(
75
+ task_id: str,
76
+ selected_input_tab: str | None,
77
+ easy_label: str,
78
+ easy_observation: str,
79
+ medium_label: str,
80
+ medium_primary_signal: str,
81
+ medium_conflicting_signal: str,
82
+ medium_reasoning: str,
83
+ hard_label: str,
84
+ hard_tradeoff_summary: str,
85
+ hard_confidence_justification: str,
86
+ hard_confidence: float,
87
+ ) -> tuple[str, str, float]:
88
+ active_tab = selected_input_tab if selected_input_tab in {"easy", "medium", "hard"} else _input_tab_for_task(task_id)
89
+
90
+ if active_tab == "easy":
91
+ label = str(easy_label or "BORDERLINE").strip().upper()
92
+ reasoning = _build_reasoning_from_sections(
93
+ [
94
+ ("Tier", "Easy"),
95
+ ("Predicted Label", label),
96
+ ("Key Observation", easy_observation),
97
+ ]
98
+ )
99
+ confidence = 0.5
100
+ elif active_tab == "medium":
101
+ label = str(medium_label or "BORDERLINE").strip().upper()
102
+ reasoning = _build_reasoning_from_sections(
103
+ [
104
+ ("Tier", "Medium"),
105
+ ("Predicted Label", label),
106
+ ("Primary Signal", medium_primary_signal),
107
+ ("Conflicting Signal", medium_conflicting_signal),
108
+ ("Reasoning", medium_reasoning),
109
+ ]
110
+ )
111
+ confidence = 0.5
112
+ else:
113
+ label = str(hard_label or "BORDERLINE").strip().upper()
114
+ confidence = float(hard_confidence)
115
+ reasoning = _build_reasoning_from_sections(
116
+ [
117
+ ("Tier", "Hard"),
118
+ ("Predicted Label", label),
119
+ ("Trade-off Summary", hard_tradeoff_summary),
120
+ ("Confidence Justification", hard_confidence_justification),
121
+ ("Confidence", f"{confidence:.2f}"),
122
+ ]
123
+ )
124
+
125
+ if label not in {"KEEP", "BORDERLINE", "REJECT"}:
126
+ label = "BORDERLINE"
127
+ return label, reasoning, confidence
128
+
129
+
130
+ def _input_tab_update_for_task(task_id: str) -> dict[str, Any]:
131
+ return gr.update(selected=_input_tab_for_task(task_id))
132
+
133
+
134
+ app = create_fastapi_app(
135
+ env=ClipQualityEnvironment,
136
+ action_cls=Action,
137
+ observation_cls=Observation,
138
+ )
139
+ # Replace selected OpenEnv defaults with reference-style handlers.
140
+ overridden_paths = {_normalized_path(path) for path in OVERRIDDEN_ROUTES}
141
+ app.router.routes = [
142
+ route
143
+ for route in app.router.routes
144
+ if _normalized_path(getattr(route, "path", "")) not in overridden_paths
145
+ ]
146
+
147
+
148
+ @app.get("/")
149
+ def root() -> dict[str, str]:
150
+ return {
151
+ "status": "ok",
152
+ "environment": ENVIRONMENT_ID,
153
+ "product": PRODUCT_NAME,
154
+ "health": "/health",
155
+ "metadata": "/metadata",
156
+ "dashboard": "/dashboard/",
157
+ }
158
+
159
+
160
+ @app.get("/health")
161
+ def health() -> dict[str, str]:
162
+ return {"status": "ok", "service": "clip-quality-api"}
163
+
164
+
165
+ @app.get("/state")
166
+ def get_state() -> dict[str, Any]:
167
+ env = ClipQualityEnvironment()
168
+ state = env.state.model_dump()
169
+ state["product"] = PRODUCT_NAME
170
+ state["workflow"] = "clip_quality_analysis"
171
+ return state
172
+
173
+
174
+ @app.get("/tasks")
175
+ def list_tasks() -> list[TaskInfo]:
176
+ return [
177
+ TaskInfo(
178
+ task_id=task_id,
179
+ difficulty=task["difficulty"],
180
+ description=_surface_task_description(task_id, task["description"]),
181
+ action_schema=Action.model_json_schema(),
182
+ )
183
+ for task_id, task in TASK_REGISTRY.items()
184
+ ]
185
+
186
+
187
+ @app.post("/grader")
188
+ def get_grader_score(task_id: str, action: Action) -> dict[str, Any]:
189
+ if task_id not in TASK_REGISTRY:
190
+ raise HTTPException(status_code=404, detail=f"Unknown task_id: {task_id}")
191
+ score = grade(action.model_dump(), task_id)
192
+ return {
193
+ "task_id": task_id,
194
+ "score": score,
195
+ "passed": 1 if score > 0.5 else 0,
196
+ "total": 1,
197
+ "metric": "clip_quality_alignment",
198
+ }
199
+
200
+
201
+ def _baseline_payload_from_raw(raw: dict[str, Any]) -> dict[str, Any]:
202
+ scores = raw.get("baseline_scores")
203
+ score_payload = scores if isinstance(scores, dict) else {}
204
+ payload: dict[str, Any] = {
205
+ "baseline_results": raw.get("detail", []),
206
+ "average_score": score_payload.get("overall_avg", 0.0),
207
+ "model": raw.get("model", os.environ.get("MODEL_NAME", inference.DEFAULT_MODEL_NAME)),
208
+ "metric": "clip_quality_alignment",
209
+ }
210
+ if "runtime_seconds" in raw:
211
+ payload["runtime_seconds"] = raw.get("runtime_seconds")
212
+ if "warning" in raw:
213
+ payload["warning"] = raw.get("warning")
214
+ return payload
215
+
216
+
217
+ def _initial_baseline_payload(task: str | None = None) -> dict[str, Any]:
218
+ payload: dict[str, Any] = {
219
+ "baseline_results": [],
220
+ "average_score": None,
221
+ "model": os.environ.get("MODEL_NAME", inference.DEFAULT_MODEL_NAME),
222
+ "metric": "clip_quality_alignment",
223
+ }
224
+ if task:
225
+ payload["task"] = task
226
+ return payload
227
+
228
+
229
+ def _public_baseline_status(status: str) -> str:
230
+ return "complete" if status == "completed" else status
231
+
232
+
233
+ def _mode_label(mode: str) -> str:
234
+ return "LLM" if str(mode).strip().lower() == "llm" else "Deterministic fallback"
235
+
236
+
237
+ def _summarize_mode(results: list[dict[str, Any]]) -> str:
238
+ modes = {str(item.get("mode", "fallback")).strip().lower() for item in results}
239
+ if not modes or modes == {"fallback"}:
240
+ return "Deterministic fallback"
241
+ if modes == {"llm"}:
242
+ return "LLM"
243
+ return "Mixed (LLM + deterministic fallback)"
244
+
245
+
246
+ def _baseline_warning_messages(payload: dict[str, Any]) -> list[str]:
247
+ warnings: list[str] = []
248
+ if not os.environ.get("HF_TOKEN"):
249
+ warnings.append(HF_TOKEN_MISSING_WARNING)
250
+ payload_warning = str(payload.get("warning") or "").strip()
251
+ if payload_warning and payload_warning not in warnings:
252
+ warnings.append(payload_warning)
253
+ return warnings
254
+
255
+
256
+ def _format_score_color(value: float) -> str:
257
+ return "#16a34a" if value >= 0.10 else "#dc2626"
258
+
259
+
260
+ def _label_score_color(value: float) -> str:
261
+ if value >= 0.60:
262
+ return "#16a34a"
263
+ if value >= 0.25:
264
+ return "#f59e0b"
265
+ return "#dc2626"
266
+
267
+
268
+ def _reasoning_score_color(value: float) -> str:
269
+ if value >= 0.30:
270
+ return "#16a34a"
271
+ if value > 0.0:
272
+ return "#f59e0b"
273
+ return "#dc2626"
274
+
275
+
276
+ def _total_reward_color(value: float, has_submission: bool) -> str:
277
+ if not has_submission:
278
+ return "#6b7280"
279
+ if value >= 0.70:
280
+ return "#16a34a"
281
+ if value > 0.0:
282
+ return "#f59e0b"
283
+ return "#dc2626"
284
+
285
+
286
+ def _reward_card_html(title: str, value: float, color: str, max_value: float) -> str:
287
+ return (
288
+ "<div style='background:#ffffff;border:1px solid #fde7cc;border-left:4px solid {color};"
289
+ "border-radius:8px;padding:8px 10px;'>"
290
+ "<div style='font-size:0.8rem;font-weight:600;color:#4b5563;'>{title}</div>"
291
+ "<div style='font-size:1.1rem;font-weight:700;color:{color};'>{value:.2f}</div>"
292
+ "<div style='font-size:0.72rem;color:#6b7280;'>max {max_value:.2f}</div>"
293
+ "</div>"
294
+ ).format(title=title, value=float(value), color=color, max_value=float(max_value))
295
+
296
+
297
+ def _reward_breakdown_markdown(obs: dict[str, Any], initialized: bool = False) -> str:
298
+ info = obs.get("info", {}) if isinstance(obs, dict) else {}
299
+ format_score = float(info.get("format_score", 0.0))
300
+ label_score = float(info.get("label_score", 0.0))
301
+ reasoning_score = float(info.get("reasoning_score", 0.0))
302
+ total_reward = float(obs.get("reward", info.get("reward_total", 0.0)))
303
+ running_total = float(info.get("total_reward", 0.0))
304
+ best_score = float(info.get("best_score", 0.0))
305
+ has_submission = bool(obs.get("history"))
306
+
307
+ title = "### Session Initialized" if initialized else "### Latest Submission Reward Breakdown"
308
+ subtitle = (
309
+ "_Submit a classification action to populate live reward decomposition._"
310
+ if initialized
311
+ else "_Live reward decomposition for this submission._"
312
+ )
313
+ cards = (
314
+ "<div style='display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;'>"
315
+ f"{_reward_card_html('Format Score', format_score, _format_score_color(format_score), 0.10)}"
316
+ f"{_reward_card_html('Label Score', label_score, _label_score_color(label_score), 0.60)}"
317
+ f"{_reward_card_html('Reasoning Score', reasoning_score, _reasoning_score_color(reasoning_score), 0.30)}"
318
+ "</div>"
319
+ )
320
+ total_color = _total_reward_color(total_reward, has_submission=has_submission)
321
+ return "\n".join(
322
+ [
323
+ title,
324
+ subtitle,
325
+ cards,
326
+ f"### **Total Reward:** <span style='color:{total_color};font-size:1.35rem;'>{total_reward:.3f}</span>",
327
+ f"Current Best Score: **{best_score:.3f}**",
328
+ ]
329
+ )
330
+
331
+
332
+ def _format_baseline_result_markdown(
333
+ payload: dict[str, Any] | None,
334
+ status: str,
335
+ error: dict[str, Any] | None = None,
336
+ ) -> str:
337
+ payload = payload if isinstance(payload, dict) else {}
338
+ model_name = str(payload.get("model") or os.environ.get("MODEL_NAME", inference.DEFAULT_MODEL_NAME))
339
+ raw_results = payload.get("baseline_results")
340
+ results = [item for item in raw_results if isinstance(item, dict)] if isinstance(raw_results, list) else []
341
+ lines = [f"- **Model:** `{model_name}`"]
342
+
343
+ if status == "running":
344
+ lines.append("- **Mode Used:** _Pending..._")
345
+ lines.append("- **Reward achieved per step:** _Pending..._")
346
+ lines.append("- **Success:** _Pending..._")
347
+ else:
348
+ lines.append(f"- **Mode Used:** {_summarize_mode(results)}")
349
+ if "average_score" in payload and payload.get("average_score") is not None:
350
+ lines.append(f"- **Average Score:** `{float(payload.get('average_score', 0.0)):.3f}`")
351
+ if results:
352
+ for result in results:
353
+ task_name = str(result.get("task_id", "unknown_task"))
354
+ steps = int(result.get("steps", 0) or 0)
355
+ average_reward = float(result.get("reward", 0.0))
356
+ total_reward = float(
357
+ result.get("total_reward", average_reward * steps if steps > 0 else average_reward)
358
+ )
359
+ final_reward = float(result.get("final_reward", average_reward))
360
+ reward_per_step = total_reward / steps if steps > 0 else average_reward
361
+ success = "Yes" if bool(result.get("success")) else "No"
362
+ lines.append(
363
+ f"- **{task_name}**: {_mode_label(str(result.get('mode', 'fallback')))}; "
364
+ f"score `{reward_per_step:.3f}`; success **{success}**"
365
+ )
366
+ else:
367
+ lines.append("- **Reward achieved per step:** _No baseline detail available._")
368
+ lines.append("- **Success:** _No baseline detail available._")
369
+
370
+ if status == "complete":
371
+ lines.append("- **Status:** Complete")
372
+ elif status == "failed":
373
+ message = str((error or {}).get("message") or "").strip()
374
+ suffix = f" — {message}" if message else ""
375
+ lines.append(f"- **Status:** Failed{suffix}")
376
+ elif status == "running":
377
+ lines.append("- **Status:** Running")
378
+
379
+ for warning in _baseline_warning_messages(payload):
380
+ lines.append(f"> {warning}")
381
+ return "\n".join(lines)
382
+
383
+
384
+ def _run_baseline_background(run_id: str, task: str | None = None) -> None:
385
+ try:
386
+ raw = inference.run_baseline(task=task)
387
+ baseline_run_tracker.mark_complete(run_id, _baseline_payload_from_raw(raw))
388
+ except Exception as exc:
389
+ baseline_run_tracker.mark_failed(run_id, {"message": str(exc)})
390
+
391
+
392
+ def _start_baseline_ui_run(task: str | None = None) -> tuple[str, str, str, dict[str, Any], dict[str, Any]]:
393
+ baseline_run_tracker.cleanup_expired()
394
+ run_id = baseline_run_tracker.create_run()
395
+ initial_payload = _initial_baseline_payload(task=task)
396
+ baseline_run_tracker.update_partial(run_id, initial_payload)
397
+ worker = threading.Thread(
398
+ target=_run_baseline_background,
399
+ args=(run_id, task),
400
+ daemon=True,
401
+ name=f"baseline-ui-{run_id[:8]}",
402
+ )
403
+ worker.start()
404
+ return (
405
+ run_id,
406
+ "### LLM baseline run in progress...",
407
+ _format_baseline_result_markdown(initial_payload, status="running"),
408
+ gr.update(value=BASELINE_RUN_BUTTON_RUNNING_LABEL, interactive=False),
409
+ gr.update(active=True),
410
+ )
411
+
412
+
413
+ def _poll_baseline_ui_run(
414
+ run_id: str | None,
415
+ ) -> tuple[str | None, str, str, dict[str, Any], dict[str, Any]]:
416
+ if not run_id:
417
+ return (
418
+ None,
419
+ "### Baseline agent idle.",
420
+ BASELINE_RESULT_PLACEHOLDER,
421
+ gr.update(value=BASELINE_RUN_BUTTON_LABEL, interactive=True),
422
+ gr.update(active=False),
423
+ )
424
+
425
+ baseline_run_tracker.cleanup_expired()
426
+ run = baseline_run_tracker.get_run(run_id)
427
+ if run is None:
428
+ return (
429
+ None,
430
+ "### Baseline run unavailable (expired or unknown).",
431
+ _format_baseline_result_markdown(_initial_baseline_payload(), status="failed"),
432
+ gr.update(value=BASELINE_RUN_BUTTON_LABEL, interactive=True),
433
+ gr.update(active=False),
434
+ )
435
+
436
+ status = _public_baseline_status(run["status"])
437
+ payload = run["result"] if run["status"] == "completed" else run.get("partial")
438
+ payload = payload if isinstance(payload, dict) else {}
439
+
440
+ if status == "running":
441
+ return (
442
+ run_id,
443
+ "### LLM baseline run in progress...",
444
+ _format_baseline_result_markdown(payload, status="running"),
445
+ gr.update(value=BASELINE_RUN_BUTTON_RUNNING_LABEL, interactive=False),
446
+ gr.update(active=True),
447
+ )
448
+
449
+ if status == "failed":
450
+ return (
451
+ None,
452
+ "### Baseline run failed.",
453
+ _format_baseline_result_markdown(payload, status="failed", error=run.get("error")),
454
+ gr.update(value=BASELINE_RUN_BUTTON_LABEL, interactive=True),
455
+ gr.update(active=False),
456
+ )
457
+
458
+ average_score = payload.get("average_score")
459
+ score_display = f"{float(average_score):.3f}" if average_score is not None else "N/A"
460
+ return (
461
+ None,
462
+ f"### Baseline run complete (avg score: {score_display})",
463
+ _format_baseline_result_markdown(payload, status="complete"),
464
+ gr.update(value=BASELINE_RUN_BUTTON_LABEL, interactive=True),
465
+ gr.update(active=False),
466
+ )
467
+
468
+
469
+ def _enqueue_baseline_run(background_tasks: BackgroundTasks, task: str | None = None) -> dict[str, Any]:
470
+ baseline_run_tracker.cleanup_expired()
471
+ run_id = baseline_run_tracker.create_run()
472
+ initial_payload = _initial_baseline_payload(task=task)
473
+ baseline_run_tracker.update_partial(run_id, initial_payload)
474
+ background_tasks.add_task(_run_baseline_background, run_id, task)
475
+ return {
476
+ "run_id": run_id,
477
+ "status": "running",
478
+ "payload": initial_payload,
479
+ "status_url": f"/baseline/status/{run_id}",
480
+ }
481
+
482
+
483
+ @app.post("/baseline/start")
484
+ def start_baseline_route(background_tasks: BackgroundTasks, task: str | None = None) -> dict[str, Any]:
485
+ return _enqueue_baseline_run(background_tasks=background_tasks, task=task)
486
+
487
+
488
+ @app.get("/baseline/status/{run_id}")
489
+ def baseline_status_route(run_id: str) -> dict[str, Any]:
490
+ baseline_run_tracker.cleanup_expired()
491
+ run = baseline_run_tracker.get_run(run_id)
492
+ if run is None:
493
+ raise HTTPException(status_code=404, detail=f"Unknown run_id: {run_id}")
494
+
495
+ status = _public_baseline_status(run["status"])
496
+ payload = run["result"] if run["status"] == "completed" else run["partial"]
497
+ response: dict[str, Any] = {
498
+ "run_id": run_id,
499
+ "status": status,
500
+ "payload": payload if payload is not None else {},
501
+ }
502
+ if status == "failed":
503
+ response["error"] = run["error"]
504
+ return response
505
+
506
+
507
+ @app.get("/baseline")
508
+ def run_baseline_route(background_tasks: BackgroundTasks, task: str | None = None) -> dict[str, Any]:
509
+ return _enqueue_baseline_run(background_tasks=background_tasks, task=task)
510
+
511
+
512
+ def build_custom_ui() -> gr.Blocks:
513
+ dominant_feature_columns = [
514
+ "Feature Name",
515
+ "Current Value",
516
+ "Rubric Status",
517
+ "Threshold Range",
518
+ ]
519
+ session_history_columns = ["Step", "Clip ID", "Submitted Label", "Expected Label", "Reward"]
520
+
521
+ def format_dominant_features(rows: list[dict[str, Any]]) -> pd.DataFrame:
522
+ if not rows:
523
+ return pd.DataFrame(columns=dominant_feature_columns)
524
+ return pd.DataFrame(rows, columns=dominant_feature_columns)
525
+
526
+ def format_obs(obs: dict[str, Any]) -> tuple[pd.DataFrame, str, float, int, str, str]:
527
+ if not obs:
528
+ return (
529
+ pd.DataFrame(columns=["Clip ID", "Expected Label", "Current Review Status", "Face Confidence", "Motion Score", "Audio SNR (dB)"]),
530
+ "### No Clip-Quality Rubric Available",
531
+ 0.0,
532
+ 5,
533
+ "N/A",
534
+ "### Clip Queue: **0** of **0** items displayed",
535
+ )
536
+
537
+ corpus_items = list(obs.get("data_corpus", []))
538
+ corpus_items.sort(key=lambda item: str(item.get("clip_id", item.get("id", ""))))
539
+
540
+ corpus_data = []
541
+ for item in corpus_items:
542
+ corpus_data.append(
543
+ {
544
+ "Clip ID": item.get("clip_id", item.get("id", "N/A")),
545
+ "Expected Label": item.get("expected_label", "N/A"),
546
+ "Current Review Status": item.get("review_status", "pending"),
547
+ "Face Confidence": item.get("face_confidence", "N/A"),
548
+ "Motion Score": item.get("motion_score", "N/A"),
549
+ "Audio SNR (dB)": item.get("audio_snr_db", "N/A"),
550
+ }
551
+ )
552
+ df_corpus = pd.DataFrame(corpus_data) if corpus_data else pd.DataFrame(
553
+ columns=["Clip ID", "Expected Label", "Current Review Status", "Face Confidence", "Motion Score", "Audio SNR (dB)"]
554
+ )
555
+
556
+ rubric_summary = obs.get("rubric_summary", "").strip()
557
+ rule_md = "### Active Clip-Quality Rubric\n"
558
+ if rubric_summary:
559
+ rule_md += f"```\n{rubric_summary}\n```"
560
+ else:
561
+ rule_md += "_No rubric summary available._"
562
+
563
+ best_score = float(obs.get("info", {}).get("best_score", 0.0))
564
+ steps_left = int(obs.get("info", {}).get("steps_remaining", 0))
565
+ episode_id = str(obs.get("episode_id", "N/A"))[:8]
566
+ shown = int(obs.get("corpus_shown", len(corpus_data)))
567
+ total = int(obs.get("corpus_size", len(corpus_data)))
568
+ corpus_stat = f"### Clip Queue: **{shown}** of **{total}** items displayed"
569
+ return df_corpus, rule_md, best_score, steps_left, episode_id, corpus_stat
570
+
571
+ def format_session_history(obs: dict[str, Any]) -> tuple[pd.DataFrame, str, str]:
572
+ info = obs.get("info", {}) if isinstance(obs, dict) else {}
573
+ raw_history = info.get("session_history", [])
574
+ session_history = raw_history if isinstance(raw_history, list) else []
575
+ rows: list[dict[str, Any]] = []
576
+ cue_lines: list[str] = []
577
+
578
+ for idx, item in enumerate(session_history, start=1):
579
+ step = int(item.get("step", idx))
580
+ clip_id = str(item.get("clip_id", "N/A"))
581
+ submitted_raw = item.get("label", "")
582
+ submitted = str(submitted_raw).upper() if submitted_raw is not None else ""
583
+ expected_raw = item.get("expected_label")
584
+ expected_normalized = str(expected_raw).strip().lower() if expected_raw is not None else ""
585
+ expected = "N/A" if expected_normalized in {"", "none", "null", "n/a"} else str(expected_raw).upper()
586
+ reward = float(item.get("reward", 0.0))
587
+ rows.append(
588
+ {
589
+ "Step": step,
590
+ "Clip ID": clip_id,
591
+ "Submitted Label": submitted,
592
+ "Expected Label": expected,
593
+ "Reward": reward,
594
+ }
595
+ )
596
+ is_match = bool(submitted) and expected not in {"", "N/A"} and submitted == expected
597
+ badge = (
598
+ "<span style='color:#2e7d32; font-weight:700;'>Match</span>"
599
+ if is_match
600
+ else "<span style='color:#c62828; font-weight:700;'>Mismatch</span>"
601
+ )
602
+ cue_lines.append(f"- Step {step} (`{clip_id}`): {badge}")
603
+
604
+ history_df = (
605
+ pd.DataFrame(rows, columns=session_history_columns)
606
+ if rows
607
+ else pd.DataFrame(columns=session_history_columns)
608
+ )
609
+ history_cues_md = "### Match Results\n" + "\n".join(cue_lines) if cue_lines else "### Match Results\n_No actions yet._"
610
+
611
+ total_reward = float(info.get("total_reward", 0.0))
612
+ total_color = "#2e7d32" if total_reward > 0 else "#c62828" if total_reward < 0 else "#374151"
613
+ history_total_md = ""
614
+ return history_df, history_cues_md, history_total_md
615
+
616
+ def _resolve_env(env_state: ClipQualityEnvironment | None) -> ClipQualityEnvironment:
617
+ return env_state if isinstance(env_state, ClipQualityEnvironment) else ClipQualityEnvironment()
618
+
619
+ def handle_reset(env_state: ClipQualityEnvironment | None, task_id: str):
620
+ env = _resolve_env(env_state)
621
+ obs = env.reset(task_id=task_id).model_dump()
622
+ df, pol, score, steps, ep, stat = format_obs(obs)
623
+ dominant_df = format_dominant_features(env.dominant_feature_rows())
624
+ history_df, history_cues, history_total = format_session_history(obs)
625
+ reward_msg = _reward_breakdown_markdown(obs, initialized=True)
626
+ return (
627
+ env,
628
+ df,
629
+ pol,
630
+ dominant_df,
631
+ score,
632
+ steps,
633
+ ep,
634
+ stat,
635
+ reward_msg,
636
+ history_df,
637
+ history_cues,
638
+ history_total,
639
+ json.dumps(obs, indent=2),
640
+ )
641
+
642
+ def handle_step(
643
+ env_state: ClipQualityEnvironment | None,
644
+ task_id: str,
645
+ selected_input_tab: str,
646
+ easy_label: str,
647
+ easy_observation: str,
648
+ medium_label: str,
649
+ medium_primary_signal: str,
650
+ medium_conflicting_signal: str,
651
+ medium_reasoning: str,
652
+ hard_label: str,
653
+ hard_tradeoff_summary: str,
654
+ hard_confidence_justification: str,
655
+ hard_confidence: float,
656
+ clip_id: str,
657
+ ):
658
+ env = _resolve_env(env_state)
659
+ if env.state.task_id != task_id:
660
+ env.reset(task_id=task_id)
661
+ label, reasoning, confidence = _resolve_tiered_submission(
662
+ task_id=task_id,
663
+ selected_input_tab=selected_input_tab,
664
+ easy_label=easy_label,
665
+ easy_observation=easy_observation,
666
+ medium_label=medium_label,
667
+ medium_primary_signal=medium_primary_signal,
668
+ medium_conflicting_signal=medium_conflicting_signal,
669
+ medium_reasoning=medium_reasoning,
670
+ hard_label=hard_label,
671
+ hard_tradeoff_summary=hard_tradeoff_summary,
672
+ hard_confidence_justification=hard_confidence_justification,
673
+ hard_confidence=hard_confidence,
674
+ )
675
+ payload: dict[str, Any] = {
676
+ "label": label,
677
+ "reasoning": reasoning,
678
+ "confidence": confidence,
679
+ }
680
+ if clip_id and clip_id.strip():
681
+ payload["clip_id"] = clip_id.strip()
682
+
683
+ obs_obj = env.step(Action.model_validate(payload))
684
+ obs = obs_obj.model_dump()
685
+ df, pol, score, steps, ep, stat = format_obs(obs)
686
+ dominant_df = format_dominant_features(env.dominant_feature_rows())
687
+ history_df, history_cues, history_total = format_session_history(obs)
688
+ reward_msg = _reward_breakdown_markdown(obs, initialized=False)
689
+ return (
690
+ env,
691
+ df,
692
+ pol,
693
+ dominant_df,
694
+ score,
695
+ steps,
696
+ ep,
697
+ stat,
698
+ reward_msg,
699
+ history_df,
700
+ history_cues,
701
+ history_total,
702
+ json.dumps(obs, indent=2),
703
+ )
704
+
705
+ def handle_quality_hint(
706
+ env_state: ClipQualityEnvironment | None,
707
+ task_id: str,
708
+ selected_input_tab: str,
709
+ easy_observation: str,
710
+ medium_reasoning: str,
711
+ hard_tradeoff_summary: str,
712
+ ) -> tuple[ClipQualityEnvironment, str, str, str]:
713
+ env = _resolve_env(env_state)
714
+ if env.state.task_id != task_id or not env.state.current_clip_id:
715
+ env.reset(task_id=task_id)
716
+
717
+ hint_text = env.build_quality_hint()
718
+ active_tab = selected_input_tab if selected_input_tab in {"easy", "medium", "hard"} else _input_tab_for_task(task_id)
719
+
720
+ next_easy_observation = easy_observation
721
+ next_medium_reasoning = medium_reasoning
722
+ next_hard_tradeoff_summary = hard_tradeoff_summary
723
+ if active_tab == "easy":
724
+ next_easy_observation = hint_text
725
+ elif active_tab == "medium":
726
+ next_medium_reasoning = hint_text
727
+ else:
728
+ next_hard_tradeoff_summary = hint_text
729
+
730
+ return env, next_easy_observation, next_medium_reasoning, next_hard_tradeoff_summary
731
+
732
+ with gr.Blocks(
733
+ title="CLIP Quality Analyzer: Judge's Console",
734
+ theme=gr.themes.Soft(
735
+ primary_hue="orange",
736
+ font=gr.themes.GoogleFont("Source Sans Pro"),
737
+ ),
738
+ css="""
739
+ .dark {
740
+ background-color: #0c0c0c !important;
741
+ }
742
+ """,
743
+ ) as demo:
744
+ env_state = gr.State(value=None)
745
+ baseline_run_id_state = gr.State(value=None)
746
+ selected_tab_state = gr.State(value="easy")
747
+ baseline_poll_timer = gr.Timer(value=1.0, active=False)
748
+
749
+ gr.HTML("<h2 style='text-align: center; color: #10b981;'>CLIP Quality Analyzer: Judge's Strategic Console</h2>")
750
+ gr.Markdown(
751
+ "Welcome, Judge Agent. Use this console to identify data to policy gaps and propose measurable governance refinements."
752
+ )
753
+
754
+ with gr.Row():
755
+ with gr.Column(scale=1):
756
+ gr.Markdown("### Scenario Metrics")
757
+ with gr.Group():
758
+ best_score_disp = gr.Number(label="Environment Best Score", value=0.0, interactive=False)
759
+ steps_left_disp = gr.Number(label="Remaining Execution Steps", value=5, interactive=False)
760
+ episode_disp = gr.Textbox(label="Active Episode ID", value="N/A", interactive=False)
761
+
762
+ reward_outcome_disp = gr.Markdown("### Awaiting Scenario...")
763
+
764
+ with gr.Group():
765
+ task_id = gr.Dropdown(choices=list(TASK_REGISTRY.keys()), value="task_easy", label="Deployment Scenario")
766
+ reset_btn = gr.Button("Initialize Scenario", variant="secondary")
767
+
768
+ with gr.Column(scale=3):
769
+ corpus_count_disp = gr.Markdown("### Corpus: 0 of 0 incidents displayed")
770
+ with gr.Tabs():
771
+ with gr.Tab("Data Corpus (Tabular View)"):
772
+ corpus_table = gr.DataFrame(
773
+ label="Sampled Posts and System Actions",
774
+ interactive=False,
775
+ )
776
+ with gr.Tab("Active Framework"):
777
+ policy_display = gr.Markdown("Initialize to view the current rubric summary.")
778
+ with gr.Tab("Diagnostic JSON"):
779
+ raw_json_box = gr.Code(label="Environment Raw Response", language="json", interactive=False)
780
+ with gr.Tab("Session History"):
781
+ session_history_table = gr.DataFrame(
782
+ value=pd.DataFrame(columns=session_history_columns),
783
+ label="Per-step Classification History",
784
+ headers=session_history_columns,
785
+ col_count=(len(session_history_columns), "fixed"),
786
+ datatype=["number", "str", "str", "str", "number"],
787
+ interactive=False,
788
+ )
789
+ session_history_cues = gr.Markdown("### Match Results\n_No actions yet._")
790
+ session_total_reward = gr.Markdown("", visible=False)
791
+ with gr.Accordion("LLM Baseline Result", open=False):
792
+ baseline_run_btn = gr.Button(BASELINE_RUN_BUTTON_LABEL, variant="secondary")
793
+ baseline_status_disp = gr.Markdown("### Baseline agent idle.")
794
+ baseline_result_md = gr.Markdown(BASELINE_RESULT_PLACEHOLDER)
795
+
796
+ dominant_features_table = gr.DataFrame(
797
+ value=pd.DataFrame(columns=dominant_feature_columns),
798
+ label="Feature Focus Table",
799
+ headers=dominant_feature_columns,
800
+ column_count=len(dominant_feature_columns),
801
+ column_limits=(len(dominant_feature_columns), len(dominant_feature_columns)),
802
+ datatype=["str", "number", "str", "str"],
803
+ interactive=False,
804
+ visible=False
805
+ )
806
+
807
+ gr.Markdown("---")
808
+ gr.Markdown("### Propose Strategic Refinement")
809
+ with gr.Group():
810
+ with gr.Tabs(selected="easy") as tiered_input_tabs:
811
+ with gr.Tab("Easy: Definition Refining", id="easy"):
812
+ easy_label_input = gr.Radio(
813
+ choices=CLASS_LABEL_CHOICES,
814
+ value="BORDERLINE",
815
+ label="Predicted Label",
816
+ )
817
+ easy_observation_input = gr.Textbox(label="Key Observation", lines=2)
818
+ with gr.Tab("Medium: Gap Detection", id="medium"):
819
+ medium_label_input = gr.Radio(
820
+ choices=CLASS_LABEL_CHOICES,
821
+ value="BORDERLINE",
822
+ label="Predicted Label",
823
+ )
824
+ medium_primary_signal_input = gr.Textbox(label="Primary Signal", lines=1)
825
+ medium_conflicting_signal_input = gr.Textbox(label="Conflicting Signal", lines=1)
826
+ medium_reasoning_input = gr.TextArea(label="Reasoning", lines=4)
827
+ with gr.Tab("Hard: Full System Evolution", id="hard"):
828
+ hard_label_input = gr.Radio(
829
+ choices=CLASS_LABEL_CHOICES,
830
+ value="BORDERLINE",
831
+ label="Predicted Label",
832
+ )
833
+ hard_tradeoff_summary_input = gr.TextArea(label="Trade-off Summary", lines=4)
834
+ with gr.Row():
835
+ hard_confidence_justification_input = gr.TextArea(label="Confidence Justification", lines=3, scale=2)
836
+ hard_confidence_input = gr.Slider(
837
+ minimum=0.0,
838
+ maximum=1.0,
839
+ value=0.5,
840
+ step=0.01,
841
+ label="Confidence",
842
+ scale=1
843
+ )
844
+
845
+ clip_id_input = gr.Textbox(label="Clip ID override (optional)")
846
+ hint_btn = gr.Button(QUALITY_HINT_BUTTON_LABEL, variant="secondary")
847
+
848
+ step_btn = gr.Button("Execute Strategic Step", variant="primary")
849
+
850
+ def sync_input_tab_for_task(selected_task_id: str):
851
+ tab_id = _input_tab_for_task(selected_task_id)
852
+ return _input_tab_update_for_task(selected_task_id), tab_id
853
+
854
+ def on_tab_select(evt: gr.SelectData) -> str:
855
+ return str(evt.value) if evt and evt.value else "easy"
856
+
857
+ task_id.change(
858
+ sync_input_tab_for_task,
859
+ inputs=[task_id],
860
+ outputs=[tiered_input_tabs, selected_tab_state],
861
+ )
862
+ tiered_input_tabs.select(on_tab_select, inputs=None, outputs=[selected_tab_state])
863
+
864
+ reset_btn.click(
865
+ handle_reset,
866
+ inputs=[env_state, task_id],
867
+ outputs=[
868
+ env_state,
869
+ corpus_table,
870
+ policy_display,
871
+ dominant_features_table,
872
+ best_score_disp,
873
+ steps_left_disp,
874
+ episode_disp,
875
+ corpus_count_disp,
876
+ reward_outcome_disp,
877
+ session_history_table,
878
+ session_history_cues,
879
+ session_total_reward,
880
+ raw_json_box,
881
+ ],
882
+ )
883
+ step_btn.click(
884
+ handle_step,
885
+ inputs=[
886
+ env_state,
887
+ task_id,
888
+ selected_tab_state,
889
+ easy_label_input,
890
+ easy_observation_input,
891
+ medium_label_input,
892
+ medium_primary_signal_input,
893
+ medium_conflicting_signal_input,
894
+ medium_reasoning_input,
895
+ hard_label_input,
896
+ hard_tradeoff_summary_input,
897
+ hard_confidence_justification_input,
898
+ hard_confidence_input,
899
+ clip_id_input,
900
+ ],
901
+ outputs=[
902
+ env_state,
903
+ corpus_table,
904
+ policy_display,
905
+ dominant_features_table,
906
+ best_score_disp,
907
+ steps_left_disp,
908
+ episode_disp,
909
+ corpus_count_disp,
910
+ reward_outcome_disp,
911
+ session_history_table,
912
+ session_history_cues,
913
+ session_total_reward,
914
+ raw_json_box,
915
+ ],
916
+ )
917
+ hint_btn.click(
918
+ handle_quality_hint,
919
+ inputs=[
920
+ env_state,
921
+ task_id,
922
+ selected_tab_state,
923
+ easy_observation_input,
924
+ medium_reasoning_input,
925
+ hard_tradeoff_summary_input,
926
+ ],
927
+ outputs=[
928
+ env_state,
929
+ easy_observation_input,
930
+ medium_reasoning_input,
931
+ hard_tradeoff_summary_input,
932
+ ],
933
+ )
934
+ baseline_run_btn.click(
935
+ _start_baseline_ui_run,
936
+ inputs=[task_id],
937
+ outputs=[baseline_run_id_state, baseline_status_disp, baseline_result_md, baseline_run_btn, baseline_poll_timer],
938
+ )
939
+ baseline_poll_timer.tick(
940
+ _poll_baseline_ui_run,
941
+ inputs=[baseline_run_id_state],
942
+ outputs=[baseline_run_id_state, baseline_status_disp, baseline_result_md, baseline_run_btn, baseline_poll_timer],
943
+ )
944
+
945
+ return demo
946
+
947
+
948
+ custom_demo = build_custom_ui()
949
+ app = gr.mount_gradio_app(app, custom_demo, path="/dashboard/")
950
+
951
+
952
+ def main(host: str = "0.0.0.0", port: int = 8000) -> None:
953
+ uvicorn.run("server.app:app", host=host, port=port, reload=False)
954
+
955
+
956
+ if __name__ == "__main__":
957
+ parser = argparse.ArgumentParser()
958
+ parser.add_argument("--port", type=int, default=8000)
959
+ args = parser.parse_args()
960
+ if args.port == 8000:
961
+ main()
962
+ else:
963
+ main(port=args.port)
server/baseline_runs.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thread-safe in-memory tracker for async baseline runs."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ import time
6
+ import uuid
7
+ from copy import deepcopy
8
+ from typing import Any, Callable, Literal, TypedDict
9
+
10
+ RunStatus = Literal["running", "completed", "failed"]
11
+ DEFAULT_RUN_TTL_SECONDS = 10 * 60
12
+
13
+
14
+ class BaselineRun(TypedDict):
15
+ run_id: str
16
+ status: RunStatus
17
+ created_at: float
18
+ updated_at: float
19
+ expires_at: float
20
+ started_at: float
21
+ completed_at: float | None
22
+ failed_at: float | None
23
+ partial: Any
24
+ result: Any
25
+ error: Any
26
+
27
+
28
+ class BaselineRunTracker:
29
+ """Manage async baseline runs in-memory with TTL-based cleanup."""
30
+
31
+ def __init__(
32
+ self,
33
+ ttl_seconds: float = DEFAULT_RUN_TTL_SECONDS,
34
+ time_fn: Callable[[], float] | None = None,
35
+ ) -> None:
36
+ if ttl_seconds <= 0:
37
+ raise ValueError("ttl_seconds must be > 0")
38
+ self._ttl_seconds = float(ttl_seconds)
39
+ self._time_fn = time_fn or time.time
40
+ self._lock = threading.RLock()
41
+ self._runs: dict[str, BaselineRun] = {}
42
+
43
+ def _now(self) -> float:
44
+ return float(self._time_fn())
45
+
46
+ def _is_expired(self, run: BaselineRun, now: float) -> bool:
47
+ return float(run["expires_at"]) <= now
48
+
49
+ def _touch(self, run: BaselineRun, now: float) -> None:
50
+ run["updated_at"] = now
51
+ run["expires_at"] = now + self._ttl_seconds
52
+
53
+ def _get_for_update(self, run_id: str, now: float) -> BaselineRun:
54
+ run = self._runs.get(run_id)
55
+ if run is None:
56
+ raise KeyError(f"Unknown run_id: {run_id}")
57
+ if self._is_expired(run, now):
58
+ del self._runs[run_id]
59
+ raise KeyError(f"Expired run_id: {run_id}")
60
+ return run
61
+
62
+ def _merge_partial(self, run: BaselineRun, payload: Any) -> None:
63
+ current = run.get("partial")
64
+ if isinstance(current, dict) and isinstance(payload, dict):
65
+ merged = deepcopy(current)
66
+ merged.update(deepcopy(payload))
67
+ run["partial"] = merged
68
+ return
69
+ run["partial"] = deepcopy(payload)
70
+
71
+ def create_run(self) -> str:
72
+ with self._lock:
73
+ now = self._now()
74
+ run_id = uuid.uuid4().hex
75
+ while run_id in self._runs:
76
+ run_id = uuid.uuid4().hex
77
+ self._runs[run_id] = BaselineRun(
78
+ run_id=run_id,
79
+ status="running",
80
+ created_at=now,
81
+ updated_at=now,
82
+ expires_at=now + self._ttl_seconds,
83
+ started_at=now,
84
+ completed_at=None,
85
+ failed_at=None,
86
+ partial={},
87
+ result=None,
88
+ error=None,
89
+ )
90
+ return run_id
91
+
92
+ def mark_running(self, run_id: str, payload: Any | None = None) -> BaselineRun:
93
+ with self._lock:
94
+ now = self._now()
95
+ run = self._get_for_update(run_id, now)
96
+ run["status"] = "running"
97
+ run["result"] = None
98
+ run["error"] = None
99
+ run["completed_at"] = None
100
+ run["failed_at"] = None
101
+ if payload is not None:
102
+ self._merge_partial(run, payload)
103
+ self._touch(run, now)
104
+ return deepcopy(run)
105
+
106
+ def update_partial(self, run_id: str, payload: Any) -> BaselineRun:
107
+ return self.mark_running(run_id, payload=payload)
108
+
109
+ def mark_complete(self, run_id: str, result: Any) -> BaselineRun:
110
+ with self._lock:
111
+ now = self._now()
112
+ run = self._get_for_update(run_id, now)
113
+ run["status"] = "completed"
114
+ run["result"] = deepcopy(result)
115
+ run["error"] = None
116
+ run["completed_at"] = now
117
+ run["failed_at"] = None
118
+ self._touch(run, now)
119
+ return deepcopy(run)
120
+
121
+ def mark_failed(self, run_id: str, error: Any) -> BaselineRun:
122
+ with self._lock:
123
+ now = self._now()
124
+ run = self._get_for_update(run_id, now)
125
+ run["status"] = "failed"
126
+ run["result"] = None
127
+ run["error"] = deepcopy(error)
128
+ run["completed_at"] = None
129
+ run["failed_at"] = now
130
+ self._touch(run, now)
131
+ return deepcopy(run)
132
+
133
+ def get_run(self, run_id: str) -> BaselineRun | None:
134
+ with self._lock:
135
+ now = self._now()
136
+ run = self._runs.get(run_id)
137
+ if run is None:
138
+ return None
139
+ if self._is_expired(run, now):
140
+ del self._runs[run_id]
141
+ return None
142
+ return deepcopy(run)
143
+
144
+ def cleanup_expired(self) -> int:
145
+ with self._lock:
146
+ now = self._now()
147
+ expired_ids = [run_id for run_id, run in self._runs.items() if self._is_expired(run, now)]
148
+ for run_id in expired_ids:
149
+ del self._runs[run_id]
150
+ return len(expired_ids)
151
+
152
+
153
+ baseline_run_tracker = BaselineRunTracker()
154
+
155
+ __all__ = [
156
+ "BaselineRun",
157
+ "BaselineRunTracker",
158
+ "DEFAULT_RUN_TTL_SECONDS",
159
+ "baseline_run_tracker",
160
+ ]
server/clip_quality_environment.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from clip_quality_env.env import ClipQualityEnvironment as BaseClipQualityEnvironment
4
+
5
+
6
+ class ClipQualityEnvironment(BaseClipQualityEnvironment):
7
+ """Server runtime wrapper preserving expected import path."""
8
+
9
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
server/environment.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from clip_quality_env.env import ClipQualityEnvironment
4
+
5
+ # Backward-compatibility alias (deprecated).
6
+ PolicyEvolverEnvironment = ClipQualityEnvironment
7
+
8
+ __all__ = ["ClipQualityEnvironment"]
server/grader.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic clip-quality grader used by `/grader`."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Any
6
+
7
+ from clip_quality_env.difficulty import calibrate_total_score, normalize_difficulty
8
+ from clip_quality_env.grader import grade as clip_grade
9
+ from clip_quality_env.ground_truth import GTStore
10
+ from clip_quality_env.models import Action
11
+ from clip_quality_env.rubric import RubricState
12
+ from server.tasks import TASK_REGISTRY
13
+
14
+
15
+ _RUBRIC = RubricState()
16
+ _GT = GTStore()
17
+ _VALID_LABELS = {"KEEP", "BORDERLINE", "REJECT"}
18
+ _TEXT_TOKEN_RE = re.compile(r"[a-z0-9_]+")
19
+
20
+
21
+ def _clamp01(value: float) -> float:
22
+ return max(0.0, min(1.0, float(value)))
23
+
24
+
25
+ def _resolve_clip(action_dict: dict[str, Any], task_id: str) -> dict[str, Any]:
26
+ task = TASK_REGISTRY.get(task_id, {})
27
+ corpus = task.get("data_corpus", [])
28
+ if not corpus:
29
+ return {}
30
+
31
+ requested_clip_id = action_dict.get("clip_id")
32
+ if requested_clip_id:
33
+ for clip in corpus:
34
+ if str(clip.get("clip_id", "")) == str(requested_clip_id):
35
+ return dict(clip)
36
+ return dict(corpus[0])
37
+
38
+
39
+ def _normalize_label(raw: Any, fallback: str = "BORDERLINE") -> str:
40
+ label = str(raw or fallback).strip().upper()
41
+ return label if label in _VALID_LABELS else fallback
42
+
43
+
44
+ def _normalize_confidence(raw: Any) -> float:
45
+ try:
46
+ return _clamp01(float(raw))
47
+ except (TypeError, ValueError):
48
+ return 0.5
49
+
50
+
51
+ def _normalize_reasoning(action_dict: dict[str, Any]) -> str:
52
+ text_fields = [
53
+ action_dict.get("reasoning"),
54
+ action_dict.get("justification"),
55
+ action_dict.get("suggested_definition"),
56
+ action_dict.get("new_rule"),
57
+ action_dict.get("think"),
58
+ ]
59
+ parts = [str(part).strip() for part in text_fields if str(part or "").strip()]
60
+ if parts:
61
+ return " ".join(parts)
62
+ return "Reasoning references face_confidence, motion_score, audio_snr_db, and lighting_uniformity."
63
+
64
+
65
+ def _normalize_action(action_dict: dict[str, Any], clip: dict[str, Any]) -> Action:
66
+ clip_id = str(clip.get("clip_id", ""))
67
+ fallback_label = _normalize_label(_GT.lookup(clip_id), fallback="")
68
+ if fallback_label not in _VALID_LABELS:
69
+ fallback_label = _normalize_label(_RUBRIC.derive_label(clip), fallback="BORDERLINE")
70
+ payload = {
71
+ "label": _normalize_label(
72
+ action_dict.get("label")
73
+ or action_dict.get("predicted_label")
74
+ or action_dict.get("decision")
75
+ or action_dict.get("review_status"),
76
+ fallback=fallback_label,
77
+ ),
78
+ "reasoning": _normalize_reasoning(action_dict),
79
+ "confidence": _normalize_confidence(action_dict.get("confidence", 0.5)),
80
+ "clip_id": str(action_dict.get("clip_id") or clip.get("clip_id") or ""),
81
+ }
82
+ return Action.model_validate(payload)
83
+
84
+
85
+ def _mentions_cue(reasoning: str, cue: str) -> bool:
86
+ text_tokens = set(_TEXT_TOKEN_RE.findall(reasoning.lower()))
87
+ cue_tokens = [token for token in _TEXT_TOKEN_RE.findall(cue.lower()) if len(token) >= 4]
88
+ if not cue_tokens:
89
+ return False
90
+ overlap = sum(1 for token in cue_tokens if token in text_tokens)
91
+ needed = 2 if len(cue_tokens) >= 2 else 1
92
+ return overlap >= needed
93
+
94
+
95
+ def _cue_bonus(reasoning: str, clip: dict[str, Any]) -> float:
96
+ cues = clip.get("quality_cues")
97
+ if not isinstance(cues, list):
98
+ return 0.0
99
+ valid_cues = [str(cue).strip() for cue in cues if str(cue).strip()]
100
+ if not valid_cues:
101
+ return 0.0
102
+ hits = sum(1 for cue in valid_cues if _mentions_cue(reasoning, cue))
103
+ if hits >= 2:
104
+ return 0.10
105
+ if hits == 1:
106
+ return 0.05
107
+ return 0.0
108
+
109
+
110
+ def grade(action_dict: dict[str, Any], task_id: str, temperature: float = 0.0, seed: int = 42) -> float:
111
+ del temperature, seed
112
+ if task_id not in TASK_REGISTRY:
113
+ return 0.0
114
+ try:
115
+ clip = _resolve_clip(action_dict, task_id)
116
+ if not clip:
117
+ return 0.0
118
+
119
+ action = _normalize_action(action_dict, clip)
120
+ reward = clip_grade(action, clip, _RUBRIC, _GT)
121
+
122
+ format_score = float(reward.format_score)
123
+ label_score = float(reward.label_score)
124
+ reasoning_score = float(reward.reasoning_score)
125
+
126
+ reasoning_score = min(0.30, reasoning_score + _cue_bonus(str(action.reasoning), clip))
127
+ task = TASK_REGISTRY.get(task_id, {})
128
+ difficulty = normalize_difficulty(str(task.get("difficulty", "")))
129
+ total = _clamp01(format_score + label_score + reasoning_score)
130
+ total = calibrate_total_score(total, difficulty=difficulty)
131
+ return round(total, 4)
132
+ except Exception:
133
+ return 0.0
server/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ openenv-core>=0.2.3
2
+ fastapi>=0.104.0
3
+ uvicorn>=0.24.0
4
+ pydantic>=2.0.0
5
+ requests>=2.25.0
6
+ websockets>=12.0
7
+ gradio>=4.0.0
8
+ pandas>=2.0.0
server/tasks/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .task_easy import EASY_TASK
2
+ from .task_medium import MEDIUM_TASK
3
+ from .task_hard import HARD_TASK
4
+
5
+ TASK_REGISTRY = {
6
+ "task_easy": EASY_TASK,
7
+ "task_medium": MEDIUM_TASK,
8
+ "task_hard": HARD_TASK,
9
+ }
10
+
11
+ TASK_IDS = tuple(TASK_REGISTRY.keys())
12
+
13
+ __all__ = ["EASY_TASK", "MEDIUM_TASK", "HARD_TASK", "TASK_REGISTRY", "TASK_IDS"]
server/tasks/task_easy.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ EASY_TASK = {
2
+ "task_id": "task_easy",
3
+ "difficulty": "easy",
4
+ "description": (
5
+ "Classify clips with dominant quality evidence. "
6
+ "Return one label in {KEEP, BORDERLINE, REJECT} with brief metadata-grounded reasoning."
7
+ ),
8
+ "data_corpus": [
9
+ {
10
+ "id": "easy_001",
11
+ "clip_id": "clip_0001",
12
+ "duration_s": 8.4,
13
+ "fps": 25,
14
+ "resolution": "1280x720",
15
+ "face_area_ratio": 0.43,
16
+ "face_confidence": 0.94,
17
+ "head_pose_yaw_deg": 6.2,
18
+ "head_pose_pitch_deg": 4.3,
19
+ "motion_score": 0.09,
20
+ "bg_complexity": "solid_dark",
21
+ "bg_complexity_score": 0.05,
22
+ "mouth_open_ratio": 0.36,
23
+ "blink_rate_hz": 0.22,
24
+ "audio_snr_db": 27.4,
25
+ "transcript_word_count": 28,
26
+ "transcript_confidence": 0.89,
27
+ "lighting_uniformity": 0.88,
28
+ "occlusion_present": False,
29
+ "environment_tag": "studio",
30
+ "framing": "front",
31
+ "expected_label": "KEEP",
32
+ "quality_cues": [
33
+ "high face_confidence and large face_area_ratio",
34
+ "low motion_score and clean audio_snr_db",
35
+ "uniform lighting with no occlusion",
36
+ ],
37
+ "review_status": "pending",
38
+ },
39
+ {
40
+ "id": "easy_002",
41
+ "clip_id": "clip_0004",
42
+ "duration_s": 7.1,
43
+ "fps": 25,
44
+ "resolution": "1280x720",
45
+ "face_area_ratio": 0.38,
46
+ "face_confidence": 0.90,
47
+ "head_pose_yaw_deg": 7.8,
48
+ "head_pose_pitch_deg": 5.0,
49
+ "motion_score": 0.12,
50
+ "bg_complexity": "simple_room",
51
+ "bg_complexity_score": 0.09,
52
+ "mouth_open_ratio": 0.33,
53
+ "blink_rate_hz": 0.21,
54
+ "audio_snr_db": 24.1,
55
+ "transcript_word_count": 24,
56
+ "transcript_confidence": 0.86,
57
+ "lighting_uniformity": 0.80,
58
+ "occlusion_present": False,
59
+ "environment_tag": "office",
60
+ "framing": "front",
61
+ "expected_label": "KEEP",
62
+ "quality_cues": [
63
+ "face_confidence is high",
64
+ "motion_score is low",
65
+ "audio_snr_db is comfortably above keep threshold",
66
+ ],
67
+ "review_status": "pending",
68
+ },
69
+ {
70
+ "id": "easy_003",
71
+ "clip_id": "clip_0007",
72
+ "duration_s": 8.0,
73
+ "fps": 25,
74
+ "resolution": "1280x720",
75
+ "face_area_ratio": 0.25,
76
+ "face_confidence": 0.80,
77
+ "head_pose_yaw_deg": 17.5,
78
+ "head_pose_pitch_deg": 8.2,
79
+ "motion_score": 0.26,
80
+ "bg_complexity": "simple_room",
81
+ "bg_complexity_score": 0.16,
82
+ "mouth_open_ratio": 0.25,
83
+ "blink_rate_hz": 0.29,
84
+ "audio_snr_db": 19.4,
85
+ "transcript_word_count": 19,
86
+ "transcript_confidence": 0.76,
87
+ "lighting_uniformity": 0.65,
88
+ "occlusion_present": False,
89
+ "environment_tag": "home_office",
90
+ "framing": "front",
91
+ "expected_label": "BORDERLINE",
92
+ "quality_cues": [
93
+ "face_area_ratio sits on the keep boundary",
94
+ "audio_snr_db and motion_score are borderline",
95
+ ],
96
+ "review_status": "pending",
97
+ },
98
+ {
99
+ "id": "easy_004",
100
+ "clip_id": "clip_0014",
101
+ "duration_s": 3.4,
102
+ "fps": 25,
103
+ "resolution": "1280x720",
104
+ "face_area_ratio": 0.16,
105
+ "face_confidence": 0.56,
106
+ "head_pose_yaw_deg": 23.0,
107
+ "head_pose_pitch_deg": 11.2,
108
+ "motion_score": 0.54,
109
+ "bg_complexity": "busy_room",
110
+ "bg_complexity_score": 0.45,
111
+ "mouth_open_ratio": 0.15,
112
+ "blink_rate_hz": 0.38,
113
+ "audio_snr_db": 11.0,
114
+ "transcript_word_count": 8,
115
+ "transcript_confidence": 0.44,
116
+ "lighting_uniformity": 0.38,
117
+ "occlusion_present": True,
118
+ "environment_tag": "corridor",
119
+ "framing": "offgaze",
120
+ "expected_label": "REJECT",
121
+ "quality_cues": [
122
+ "occlusion_present is true",
123
+ "face_confidence is too low and motion_score is too high",
124
+ "audio_snr_db and lighting_uniformity are below reject thresholds",
125
+ ],
126
+ "review_status": "pending",
127
+ },
128
+ {
129
+ "id": "easy_005",
130
+ "clip_id": "clip_0016",
131
+ "duration_s": 5.0,
132
+ "fps": 25,
133
+ "resolution": "1280x720",
134
+ "face_area_ratio": 0.18,
135
+ "face_confidence": 0.63,
136
+ "head_pose_yaw_deg": 25.5,
137
+ "head_pose_pitch_deg": 12.4,
138
+ "motion_score": 0.47,
139
+ "bg_complexity": "busy_indoor",
140
+ "bg_complexity_score": 0.42,
141
+ "mouth_open_ratio": 0.17,
142
+ "blink_rate_hz": 0.35,
143
+ "audio_snr_db": 13.1,
144
+ "transcript_word_count": 10,
145
+ "transcript_confidence": 0.51,
146
+ "lighting_uniformity": 0.42,
147
+ "occlusion_present": False,
148
+ "environment_tag": "street_interview",
149
+ "framing": "left",
150
+ "expected_label": "REJECT",
151
+ "quality_cues": [
152
+ "face_confidence is below keep range",
153
+ "motion_score is reject-level",
154
+ "audio_snr_db is noisy and lighting is poor",
155
+ ],
156
+ "review_status": "pending",
157
+ },
158
+ ],
159
+ }
server/tasks/task_hard.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ HARD_TASK = {
2
+ "task_id": "task_hard",
3
+ "difficulty": "hard",
4
+ "description": (
5
+ "Classify difficult clips with conflicting quality cues. "
6
+ "Reasoning should explain trade-offs across visual and audio metadata."
7
+ ),
8
+ "data_corpus": [
9
+ {
10
+ "id": "hard_001",
11
+ "clip_id": "clip_0012",
12
+ "duration_s": 8.6,
13
+ "fps": 25,
14
+ "resolution": "1280x720",
15
+ "face_area_ratio": 0.31,
16
+ "face_confidence": 0.88,
17
+ "head_pose_yaw_deg": 15.5,
18
+ "head_pose_pitch_deg": 9.0,
19
+ "motion_score": 0.43,
20
+ "bg_complexity": "busy_room",
21
+ "bg_complexity_score": 0.34,
22
+ "mouth_open_ratio": 0.31,
23
+ "blink_rate_hz": 0.33,
24
+ "audio_snr_db": 14.2,
25
+ "transcript_word_count": 26,
26
+ "transcript_confidence": 0.79,
27
+ "lighting_uniformity": 0.60,
28
+ "occlusion_present": False,
29
+ "environment_tag": "event_hall",
30
+ "framing": "front",
31
+ "expected_label": "BORDERLINE",
32
+ "quality_cues": [
33
+ "strong face_confidence conflicts with noisy audio_snr_db",
34
+ "motion_score is near reject but not over threshold",
35
+ "lighting is acceptable but not robust",
36
+ ],
37
+ "review_status": "pending",
38
+ },
39
+ {
40
+ "id": "hard_002",
41
+ "clip_id": "clip_0013",
42
+ "duration_s": 6.9,
43
+ "fps": 25,
44
+ "resolution": "1280x720",
45
+ "face_area_ratio": 0.29,
46
+ "face_confidence": 0.73,
47
+ "head_pose_yaw_deg": 21.8,
48
+ "head_pose_pitch_deg": 11.0,
49
+ "motion_score": 0.46,
50
+ "bg_complexity": "busy_room",
51
+ "bg_complexity_score": 0.38,
52
+ "mouth_open_ratio": 0.22,
53
+ "blink_rate_hz": 0.36,
54
+ "audio_snr_db": 16.6,
55
+ "transcript_word_count": 18,
56
+ "transcript_confidence": 0.71,
57
+ "lighting_uniformity": 0.49,
58
+ "occlusion_present": False,
59
+ "environment_tag": "street_interview",
60
+ "framing": "offgaze",
61
+ "expected_label": "BORDERLINE",
62
+ "quality_cues": [
63
+ "motion_score is high but audio_snr_db remains salvageable",
64
+ "face_confidence and lighting_uniformity are weak",
65
+ "multiple borderline cues create mixed confidence",
66
+ ],
67
+ "review_status": "pending",
68
+ },
69
+ {
70
+ "id": "hard_003",
71
+ "clip_id": "clip_0017",
72
+ "duration_s": 5.6,
73
+ "fps": 25,
74
+ "resolution": "1280x720",
75
+ "face_area_ratio": 0.22,
76
+ "face_confidence": 0.66,
77
+ "head_pose_yaw_deg": 18.2,
78
+ "head_pose_pitch_deg": 9.4,
79
+ "motion_score": 0.33,
80
+ "bg_complexity": "simple_room",
81
+ "bg_complexity_score": 0.20,
82
+ "mouth_open_ratio": 0.27,
83
+ "blink_rate_hz": 0.32,
84
+ "audio_snr_db": 20.2,
85
+ "transcript_word_count": 19,
86
+ "transcript_confidence": 0.79,
87
+ "lighting_uniformity": 0.57,
88
+ "occlusion_present": False,
89
+ "environment_tag": "home_office",
90
+ "framing": "left",
91
+ "expected_label": "REJECT",
92
+ "quality_cues": [
93
+ "face_confidence is near reject threshold",
94
+ "face_area_ratio is too small and motion is elevated",
95
+ "combined weak visual signals push toward reject",
96
+ ],
97
+ "review_status": "pending",
98
+ },
99
+ {
100
+ "id": "hard_004",
101
+ "clip_id": "clip_0018",
102
+ "duration_s": 11.8,
103
+ "fps": 25,
104
+ "resolution": "1280x720",
105
+ "face_area_ratio": 0.18,
106
+ "face_confidence": 0.64,
107
+ "head_pose_yaw_deg": 24.4,
108
+ "head_pose_pitch_deg": 12.1,
109
+ "motion_score": 0.41,
110
+ "bg_complexity": "busy_indoor",
111
+ "bg_complexity_score": 0.41,
112
+ "mouth_open_ratio": 0.19,
113
+ "blink_rate_hz": 0.39,
114
+ "audio_snr_db": 13.9,
115
+ "transcript_word_count": 14,
116
+ "transcript_confidence": 0.66,
117
+ "lighting_uniformity": 0.44,
118
+ "occlusion_present": False,
119
+ "environment_tag": "conference_room",
120
+ "framing": "offgaze",
121
+ "expected_label": "REJECT",
122
+ "quality_cues": [
123
+ "face_confidence is below reject ceiling",
124
+ "audio_snr_db is noisy and lighting is poor",
125
+ "duration_s long but quality remains low",
126
+ ],
127
+ "review_status": "pending",
128
+ },
129
+ {
130
+ "id": "hard_005",
131
+ "clip_id": "clip_0006",
132
+ "duration_s": 7.7,
133
+ "fps": 25,
134
+ "resolution": "1280x720",
135
+ "face_area_ratio": 0.30,
136
+ "face_confidence": 0.84,
137
+ "head_pose_yaw_deg": 10.3,
138
+ "head_pose_pitch_deg": 6.2,
139
+ "motion_score": 0.25,
140
+ "bg_complexity": "simple_room",
141
+ "bg_complexity_score": 0.13,
142
+ "mouth_open_ratio": 0.31,
143
+ "blink_rate_hz": 0.24,
144
+ "audio_snr_db": 21.1,
145
+ "transcript_word_count": 24,
146
+ "transcript_confidence": 0.86,
147
+ "lighting_uniformity": 0.72,
148
+ "occlusion_present": False,
149
+ "environment_tag": "studio",
150
+ "framing": "front",
151
+ "expected_label": "KEEP",
152
+ "quality_cues": [
153
+ "face metrics are above keep threshold",
154
+ "audio_snr_db is clean and motion_score is controlled",
155
+ "consistent lighting and framing support keep decision",
156
+ ],
157
+ "review_status": "pending",
158
+ },
159
+ ],
160
+ }
server/tasks/task_medium.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MEDIUM_TASK = {
2
+ "task_id": "task_medium",
3
+ "difficulty": "medium",
4
+ "description": (
5
+ "Classify clips with mixed or borderline evidence. "
6
+ "Use metadata trade-offs to justify KEEP/BORDERLINE/REJECT."
7
+ ),
8
+ "data_corpus": [
9
+ {
10
+ "id": "med_001",
11
+ "clip_id": "clip_0008",
12
+ "duration_s": 6.2,
13
+ "fps": 25,
14
+ "resolution": "1280x720",
15
+ "face_area_ratio": 0.24,
16
+ "face_confidence": 0.78,
17
+ "head_pose_yaw_deg": 14.4,
18
+ "head_pose_pitch_deg": 9.3,
19
+ "motion_score": 0.29,
20
+ "bg_complexity": "simple_room",
21
+ "bg_complexity_score": 0.18,
22
+ "mouth_open_ratio": 0.23,
23
+ "blink_rate_hz": 0.30,
24
+ "audio_snr_db": 18.5,
25
+ "transcript_word_count": 20,
26
+ "transcript_confidence": 0.75,
27
+ "lighting_uniformity": 0.62,
28
+ "occlusion_present": False,
29
+ "environment_tag": "office",
30
+ "framing": "left",
31
+ "expected_label": "BORDERLINE",
32
+ "quality_cues": [
33
+ "face_confidence is slightly below keep threshold",
34
+ "audio_snr_db and motion_score are borderline",
35
+ "no hard reject signal",
36
+ ],
37
+ "review_status": "pending",
38
+ },
39
+ {
40
+ "id": "med_002",
41
+ "clip_id": "clip_0009",
42
+ "duration_s": 10.6,
43
+ "fps": 25,
44
+ "resolution": "1280x720",
45
+ "face_area_ratio": 0.23,
46
+ "face_confidence": 0.77,
47
+ "head_pose_yaw_deg": 16.1,
48
+ "head_pose_pitch_deg": 9.8,
49
+ "motion_score": 0.31,
50
+ "bg_complexity": "simple_room",
51
+ "bg_complexity_score": 0.19,
52
+ "mouth_open_ratio": 0.24,
53
+ "blink_rate_hz": 0.31,
54
+ "audio_snr_db": 17.4,
55
+ "transcript_word_count": 23,
56
+ "transcript_confidence": 0.73,
57
+ "lighting_uniformity": 0.59,
58
+ "occlusion_present": False,
59
+ "environment_tag": "podcast_corner",
60
+ "framing": "front",
61
+ "expected_label": "BORDERLINE",
62
+ "quality_cues": [
63
+ "duration_s is near upper keep band edge",
64
+ "face_area_ratio is borderline small",
65
+ "audio_snr_db is weak but not catastrophic",
66
+ ],
67
+ "review_status": "pending",
68
+ },
69
+ {
70
+ "id": "med_003",
71
+ "clip_id": "clip_0010",
72
+ "duration_s": 7.4,
73
+ "fps": 25,
74
+ "resolution": "1280x720",
75
+ "face_area_ratio": 0.27,
76
+ "face_confidence": 0.82,
77
+ "head_pose_yaw_deg": 11.2,
78
+ "head_pose_pitch_deg": 7.7,
79
+ "motion_score": 0.24,
80
+ "bg_complexity": "office",
81
+ "bg_complexity_score": 0.14,
82
+ "mouth_open_ratio": 0.29,
83
+ "blink_rate_hz": 0.26,
84
+ "audio_snr_db": 20.1,
85
+ "transcript_word_count": 22,
86
+ "transcript_confidence": 0.81,
87
+ "lighting_uniformity": 0.66,
88
+ "occlusion_present": False,
89
+ "environment_tag": "workstation",
90
+ "framing": "front",
91
+ "expected_label": "BORDERLINE",
92
+ "quality_cues": [
93
+ "face_confidence is good but face_area_ratio remains only moderate",
94
+ "motion_score and mouth_open_ratio are near boundary values",
95
+ ],
96
+ "review_status": "pending",
97
+ },
98
+ {
99
+ "id": "med_004",
100
+ "clip_id": "clip_0011",
101
+ "duration_s": 4.3,
102
+ "fps": 25,
103
+ "resolution": "1280x720",
104
+ "face_area_ratio": 0.21,
105
+ "face_confidence": 0.69,
106
+ "head_pose_yaw_deg": 19.1,
107
+ "head_pose_pitch_deg": 10.6,
108
+ "motion_score": 0.39,
109
+ "bg_complexity": "busy_room",
110
+ "bg_complexity_score": 0.23,
111
+ "mouth_open_ratio": 0.21,
112
+ "blink_rate_hz": 0.34,
113
+ "audio_snr_db": 15.0,
114
+ "transcript_word_count": 13,
115
+ "transcript_confidence": 0.69,
116
+ "lighting_uniformity": 0.54,
117
+ "occlusion_present": False,
118
+ "environment_tag": "classroom",
119
+ "framing": "offgaze",
120
+ "expected_label": "BORDERLINE",
121
+ "quality_cues": [
122
+ "duration_s is barely acceptable",
123
+ "audio_snr_db and lighting_uniformity are borderline",
124
+ "motion_score is elevated but below hard reject cutoff",
125
+ ],
126
+ "review_status": "pending",
127
+ },
128
+ {
129
+ "id": "med_005",
130
+ "clip_id": "clip_0003",
131
+ "duration_s": 8.7,
132
+ "fps": 25,
133
+ "resolution": "1280x720",
134
+ "face_area_ratio": 0.36,
135
+ "face_confidence": 0.91,
136
+ "head_pose_yaw_deg": 8.1,
137
+ "head_pose_pitch_deg": 5.4,
138
+ "motion_score": 0.14,
139
+ "bg_complexity": "solid_dark",
140
+ "bg_complexity_score": 0.08,
141
+ "mouth_open_ratio": 0.34,
142
+ "blink_rate_hz": 0.24,
143
+ "audio_snr_db": 23.9,
144
+ "transcript_word_count": 29,
145
+ "transcript_confidence": 0.88,
146
+ "lighting_uniformity": 0.81,
147
+ "occlusion_present": False,
148
+ "environment_tag": "studio",
149
+ "framing": "front",
150
+ "expected_label": "KEEP",
151
+ "quality_cues": [
152
+ "strong face_confidence and face_area_ratio",
153
+ "clean audio and stable motion",
154
+ "well lit with no occlusion",
155
+ ],
156
+ "review_status": "pending",
157
+ },
158
+ ],
159
+ }
spaces_app.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import gradio as gr
6
+
7
+ from models import Action
8
+ from server.environment import ClipQualityEnvironment
9
+ from server.tasks import TASK_REGISTRY
10
+
11
+
12
+ def _run_step(task_id: str, label: str, reasoning: str, confidence: float, clip_id: str) -> str:
13
+ env = ClipQualityEnvironment()
14
+ obs = env.reset(task_id=task_id)
15
+ payload = {
16
+ "label": label,
17
+ "reasoning": (reasoning or "No reasoning provided.").strip(),
18
+ "confidence": float(confidence),
19
+ }
20
+ if clip_id and clip_id.strip():
21
+ payload["clip_id"] = clip_id.strip()
22
+
23
+ next_obs = env.step(Action.model_validate(payload))
24
+ current_clip = next_obs.clip_metadata.clip_id
25
+ expected = next_obs.clip_metadata.expected_label
26
+ return (
27
+ f"Clip-quality task: {obs.task_id}\n"
28
+ f"Classified clip: {current_clip}\n"
29
+ f"Expected label: {expected}\n"
30
+ f"Step: {next_obs.step_count}\n"
31
+ f"Reward: {next_obs.reward:.4f}\n"
32
+ f"Done: {next_obs.done}\n"
33
+ f"Best quality score: {next_obs.info.get('best_score', 0.0):.4f}"
34
+ )
35
+
36
+
37
+ def build_demo() -> gr.Blocks:
38
+ with gr.Blocks(title="CLIP Quality Analyzer Dashboard") as demo:
39
+ gr.Markdown("# CLIP Quality Analyzer Dashboard")
40
+ gr.Markdown("Reference-style dashboard for clip-quality classification.")
41
+
42
+ task_id = gr.Dropdown(choices=list(TASK_REGISTRY.keys()), value="task_easy", label="Clip-Quality Scenario")
43
+ label = gr.Radio(
44
+ choices=["KEEP", "BORDERLINE", "REJECT"],
45
+ value="BORDERLINE",
46
+ label="Predicted Label",
47
+ )
48
+ reasoning = gr.Textbox(label="Reasoning (reference clip metadata)")
49
+ confidence = gr.Slider(minimum=0.0, maximum=1.0, value=0.5, step=0.01, label="Confidence")
50
+ clip_id = gr.Textbox(label="Clip ID override (optional)")
51
+ run_btn = gr.Button("Submit Classification Action")
52
+ output = gr.Textbox(label="Classification Result", lines=8)
53
+ run_btn.click(_run_step, inputs=[task_id, label, reasoning, confidence, clip_id], outputs=[output])
54
+ return demo
55
+
56
+
57
+ def _run_demo() -> None:
58
+ demo = build_demo()
59
+ demo.queue()
60
+ demo.launch(server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"), server_port=int(os.environ.get("PORT", "7860")))
61
+
62
+
63
+ if __name__ == "__main__":
64
+ _run_demo()
state/ground_truth.json ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "clip_0001": {
3
+ "confidence": null,
4
+ "episode": 0,
5
+ "label": "KEEP",
6
+ "reward": null,
7
+ "source": "seed"
8
+ },
9
+ "clip_0002": {
10
+ "confidence": null,
11
+ "episode": 0,
12
+ "label": "KEEP",
13
+ "reward": null,
14
+ "source": "seed"
15
+ },
16
+ "clip_0003": {
17
+ "confidence": null,
18
+ "episode": 0,
19
+ "label": "KEEP",
20
+ "reward": null,
21
+ "source": "seed"
22
+ },
23
+ "clip_0004": {
24
+ "confidence": null,
25
+ "episode": 0,
26
+ "label": "KEEP",
27
+ "reward": null,
28
+ "source": "seed"
29
+ },
30
+ "clip_0005": {
31
+ "confidence": null,
32
+ "episode": 0,
33
+ "label": "KEEP",
34
+ "reward": null,
35
+ "source": "seed"
36
+ },
37
+ "clip_0006": {
38
+ "confidence": null,
39
+ "episode": 0,
40
+ "label": "KEEP",
41
+ "reward": null,
42
+ "source": "seed"
43
+ },
44
+ "clip_0007": {
45
+ "confidence": null,
46
+ "episode": 0,
47
+ "label": "BORDERLINE",
48
+ "reward": null,
49
+ "source": "seed"
50
+ },
51
+ "clip_0008": {
52
+ "confidence": null,
53
+ "episode": 0,
54
+ "label": "BORDERLINE",
55
+ "reward": null,
56
+ "source": "seed"
57
+ },
58
+ "clip_0009": {
59
+ "confidence": null,
60
+ "episode": 0,
61
+ "label": "BORDERLINE",
62
+ "reward": null,
63
+ "source": "seed"
64
+ },
65
+ "clip_0010": {
66
+ "confidence": null,
67
+ "episode": 0,
68
+ "label": "BORDERLINE",
69
+ "reward": null,
70
+ "source": "seed"
71
+ },
72
+ "clip_0011": {
73
+ "confidence": null,
74
+ "episode": 0,
75
+ "label": "BORDERLINE",
76
+ "reward": null,
77
+ "source": "seed"
78
+ },
79
+ "clip_0012": {
80
+ "confidence": null,
81
+ "episode": 0,
82
+ "label": "BORDERLINE",
83
+ "reward": null,
84
+ "source": "seed"
85
+ },
86
+ "clip_0013": {
87
+ "confidence": null,
88
+ "episode": 0,
89
+ "label": "BORDERLINE",
90
+ "reward": null,
91
+ "source": "seed"
92
+ },
93
+ "clip_0014": {
94
+ "confidence": null,
95
+ "episode": 0,
96
+ "label": "REJECT",
97
+ "reward": null,
98
+ "source": "seed"
99
+ },
100
+ "clip_0015": {
101
+ "confidence": null,
102
+ "episode": 0,
103
+ "label": "REJECT",
104
+ "reward": null,
105
+ "source": "seed"
106
+ },
107
+ "clip_0016": {
108
+ "confidence": null,
109
+ "episode": 0,
110
+ "label": "REJECT",
111
+ "reward": null,
112
+ "source": "seed"
113
+ },
114
+ "clip_0017": {
115
+ "confidence": null,
116
+ "episode": 0,
117
+ "label": "REJECT",
118
+ "reward": null,
119
+ "source": "seed"
120
+ },
121
+ "clip_0018": {
122
+ "confidence": null,
123
+ "episode": 0,
124
+ "label": "REJECT",
125
+ "reward": null,
126
+ "source": "seed"
127
+ },
128
+ "clip_0019": {
129
+ "confidence": null,
130
+ "episode": 0,
131
+ "label": "REJECT",
132
+ "reward": null,
133
+ "source": "seed"
134
+ },
135
+ "clip_002": {
136
+ "confidence": 0.82,
137
+ "episode": 1,
138
+ "label": "REJECT",
139
+ "reward": 0.85,
140
+ "source": "agent_promoted"
141
+ },
142
+ "clip_0020": {
143
+ "confidence": null,
144
+ "episode": 0,
145
+ "label": "REJECT",
146
+ "reward": null,
147
+ "source": "seed"
148
+ },
149
+ "clip_003": {
150
+ "confidence": 0.82,
151
+ "episode": 1,
152
+ "label": "REJECT",
153
+ "reward": 0.85,
154
+ "source": "agent_promoted"
155
+ },
156
+ "clip_004": {
157
+ "confidence": 0.82,
158
+ "episode": 1,
159
+ "label": "REJECT",
160
+ "reward": 0.85,
161
+ "source": "agent_promoted"
162
+ },
163
+ "clip_008": {
164
+ "confidence": 0.85,
165
+ "episode": 21,
166
+ "label": "REJECT",
167
+ "reward": 0.856,
168
+ "source": "agent_promoted"
169
+ },
170
+ "clip_010": {
171
+ "confidence": 0.96,
172
+ "episode": 9,
173
+ "label": "REJECT",
174
+ "reward": 0.95,
175
+ "source": "agent_promoted"
176
+ },
177
+ "clip_012": {
178
+ "confidence": 0.82,
179
+ "episode": 1,
180
+ "label": "REJECT",
181
+ "reward": 0.95,
182
+ "source": "agent_promoted"
183
+ },
184
+ "clip_019": {
185
+ "confidence": 0.82,
186
+ "episode": 1,
187
+ "label": "KEEP",
188
+ "reward": 0.95,
189
+ "source": "agent_promoted"
190
+ },
191
+ "clip_020": {
192
+ "confidence": 0.82,
193
+ "episode": 1,
194
+ "label": "REJECT",
195
+ "reward": 0.95,
196
+ "source": "agent_promoted"
197
+ }
198
+ }
state/history.jsonl ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_e70a0096", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.25}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_333f90f4", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.9}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_2e4845a7", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.455, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
2
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_ac48dccf", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_7830fef5", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_b9961414", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.41, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
3
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_b373bc97", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_39a6f2aa", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_4858f1a1", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.47, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
4
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_b89db641", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_46ec41c6", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_61e09b7f", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.2, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
5
+ {"episode": 4, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_195c392b", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_65e03fc7", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_aace83de", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.41, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
6
+ {"episode": 5, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_9bbc2e2c", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_2fff2df8", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_cde64aa0", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.47, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
7
+ {"episode": 6, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_652d1f4e", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_e8c2e65a", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_2284b99a", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.68, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
8
+ {"episode": 7, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_92a79235", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_ad4dec94", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_597f5b02", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.47, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
9
+ {"episode": 8, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_c70d500a", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_b630f24a", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_4cf710c4", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.47, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
10
+ {"episode": 9, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_ba850490", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_1a2941bb", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_0dd7abbc", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.47, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
11
+ {"episode": 10, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_50f3f686", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_aafe5c00", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_c109cd91", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.41, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
12
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_24b3c30c", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_1f0d711b", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_83b8f2a8", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.41, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
13
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.68, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
14
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_0170e61e", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_446460c1", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_60287218", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.47, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
15
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.68, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
16
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_6f73370f", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_3c1e3756", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_1caa4633", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.2, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
17
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.68, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
18
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_0001", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "real_medium_0001", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_0001", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.68, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
19
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_0001", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "real_medium_0001", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_0001", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.68, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
20
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_020", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_009", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "clip_004", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.32, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
21
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_020", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "clip_009", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.35}, {"step": 3, "difficulty": "hard", "clip_id": "clip_004", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.4125, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
22
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_012", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "clip_007", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.95}, {"step": 3, "difficulty": "hard", "clip_id": "clip_010", "label": "REJECT", "expected_label": "REJECT", "reward": 0.8}], "ep_reward": 0.8925, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
23
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_019", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_015", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "clip_008", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.87, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
24
+ {"episode": 4, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_017", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "clip_021", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "clip_003", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.64, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
25
+ {"episode": 5, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_018", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.9}, {"step": 2, "difficulty": "medium", "clip_id": "clip_014", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "clip_002", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.62, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
26
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_020", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "clip_009", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.35}, {"step": 3, "difficulty": "hard", "clip_id": "clip_004", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.4125, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
27
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_012", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "clip_007", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_010", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.41, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
28
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_019", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_015", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_008", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.8, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
29
+ {"episode": 4, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_017", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_021", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_003", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.53, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
30
+ {"episode": 5, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_018", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_014", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_002", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.53, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
31
+ {"episode": 6, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_020", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_001", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_023", "label": "REJECT", "expected_label": "REJECT", "reward": 0.8}], "ep_reward": 0.8, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
32
+ {"episode": 7, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_012", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "clip_011", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.35}, {"step": 3, "difficulty": "hard", "clip_id": "clip_005", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.6825, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
33
+ {"episode": 8, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_019", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "clip_013", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.95}, {"step": 3, "difficulty": "hard", "clip_id": "clip_004", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.6125, "gt_promoted": false, "gt_size": 20, "rubric_version": 1}
34
+ {"episode": 9, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_017", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "clip_022", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "clip_010", "label": "REJECT", "expected_label": "REJECT", "reward": 0.95}], "ep_reward": 0.9775, "gt_promoted": true, "gt_size": 21, "rubric_version": 1}
35
+ {"episode": 10, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_018", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "clip_016", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "clip_008", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.62, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
36
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_020", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_009", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "clip_004", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.32, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
37
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_012", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "clip_007", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_010", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.41, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
38
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_019", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_015", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_008", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.8, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
39
+ {"episode": 4, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_017", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_021", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_003", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.53, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
40
+ {"episode": 5, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_018", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_014", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_002", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.53, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
41
+ {"episode": 6, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_020", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_001", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_023", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.53, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
42
+ {"episode": 7, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_012", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "clip_011", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "clip_005", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.47, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
43
+ {"episode": 8, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_019", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_013", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_004", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.53, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
44
+ {"episode": 9, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_017", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_022", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "clip_010", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.32, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
45
+ {"episode": 10, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_018", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_016", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "clip_008", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.59, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
46
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_020", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "clip_009", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.35}, {"step": 3, "difficulty": "hard", "clip_id": "clip_004", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.4125, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
47
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_012", "label": "REJECT", "expected_label": "REJECT", "reward": 0.9}, {"step": 2, "difficulty": "medium", "clip_id": "clip_007", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.95}, {"step": 3, "difficulty": "hard", "clip_id": "clip_010", "label": "REJECT", "expected_label": "REJECT", "reward": 0.95}], "ep_reward": 0.94, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
48
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_020", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "clip_009", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.35}, {"step": 3, "difficulty": "hard", "clip_id": "clip_004", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.4}], "ep_reward": 0.5025, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
49
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_020", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_009", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.35}, {"step": 3, "difficulty": "hard", "clip_id": "clip_004", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.3725, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
50
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_012", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "clip_007", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.95}, {"step": 3, "difficulty": "hard", "clip_id": "clip_010", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}], "ep_reward": 0.9825, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
51
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_019", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_015", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "clip_008", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.87, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
52
+ {"episode": 4, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_017", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_021", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "clip_003", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.6, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
53
+ {"episode": 5, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_018", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_014", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_002", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.53, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
54
+ {"episode": 6, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_020", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_001", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_023", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.53, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
55
+ {"episode": 7, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_012", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}, {"step": 2, "difficulty": "medium", "clip_id": "clip_011", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "clip_005", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.47, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
56
+ {"episode": 8, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_019", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_013", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "clip_004", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.53, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
57
+ {"episode": 9, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_017", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_022", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "clip_010", "label": "BORDERLINE", "expected_label": "REJECT", "reward": 0.2}], "ep_reward": 0.32, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
58
+ {"episode": 10, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "clip_018", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}, {"step": 2, "difficulty": "medium", "clip_id": "clip_016", "label": "BORDERLINE", "expected_label": "KEEP", "reward": 0.2}, {"step": 3, "difficulty": "hard", "clip_id": "clip_008", "label": "BORDERLINE", "expected_label": "BORDERLINE", "reward": 0.8}], "ep_reward": 0.59, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
59
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
60
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
61
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
62
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
63
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
64
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
65
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
66
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
67
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
68
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
69
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
70
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
71
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_02bc3f2a", "label": "REJECT", "expected_label": "REJECT", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_e7440a32", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_467b78ba", "label": "REJECT", "expected_label": "REJECT", "reward": 0.95}], "ep_reward": 0.8275, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
72
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_81082325", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_91e25287", "label": "REJECT", "expected_label": "REJECT", "reward": 0.8}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_133040f5", "label": "REJECT", "expected_label": "REJECT", "reward": 0.8}], "ep_reward": 0.84, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
73
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_041b3590", "label": "REJECT", "expected_label": "REJECT", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_cb64ce2a", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_e99ab288", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}], "ep_reward": 0.85, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
74
+ {"episode": 4, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_5950673c", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_7291cb9c", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_24f67412", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.68, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
75
+ {"episode": 5, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_a24a4892", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_fc846ffd", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_f717eeec", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}], "ep_reward": 0.85, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
76
+ {"episode": 6, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_5e6cc0b9", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_8629bbf7", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_139a4dbd", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}], "ep_reward": 0.86, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
77
+ {"episode": 7, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_ac0fbcc2", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_6c05fde8", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_4feb4a68", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.68, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
78
+ {"episode": 8, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_528650e8", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_7ad11dd3", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_2170965a", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.68, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
79
+ {"episode": 9, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_7d7b74b8", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_1ea581d7", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_763cc786", "label": "REJECT", "expected_label": "REJECT", "reward": 0.95}], "ep_reward": 0.8275, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
80
+ {"episode": 10, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "syn_easy_278c97de", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "syn_med_4b1e494a", "label": "REJECT", "expected_label": "REJECT", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "syn_hard_a774a48f", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.45}], "ep_reward": 0.7425, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
81
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
82
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
83
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
84
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
85
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
86
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
87
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
88
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
89
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
90
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
91
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
92
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
93
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
94
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
95
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
96
+ {"episode": 1, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
97
+ {"episode": 2, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
98
+ {"episode": 3, "steps": [{"step": 1, "difficulty": "easy", "clip_id": "real_easy_1", "label": "KEEP", "expected_label": "KEEP", "reward": 0.95}, {"step": 2, "difficulty": "medium", "clip_id": "real_med_1", "label": "KEEP", "expected_label": "KEEP", "reward": 1.0}, {"step": 3, "difficulty": "hard", "clip_id": "real_hard_1", "label": "KEEP", "expected_label": "BORDERLINE", "reward": 0.6}], "ep_reward": 0.81, "gt_promoted": false, "gt_size": 21, "rubric_version": 1}
tests/test_baseline_runs.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from server.baseline_runs import BaselineRunTracker, DEFAULT_RUN_TTL_SECONDS
4
+
5
+
6
+ class _FakeClock:
7
+ def __init__(self, start: float = 0.0) -> None:
8
+ self.value = float(start)
9
+
10
+ def time(self) -> float:
11
+ return self.value
12
+
13
+ def advance(self, seconds: float) -> None:
14
+ self.value += float(seconds)
15
+
16
+
17
+ def test_create_run_defaults_to_running_state():
18
+ clock = _FakeClock(start=100.0)
19
+ tracker = BaselineRunTracker(time_fn=clock.time)
20
+
21
+ run_id = tracker.create_run()
22
+ run = tracker.get_run(run_id)
23
+
24
+ assert run is not None
25
+ assert run["run_id"] == run_id
26
+ assert run["status"] == "running"
27
+ assert run["partial"] == {}
28
+ assert run["result"] is None
29
+ assert run["error"] is None
30
+ assert run["created_at"] == 100.0
31
+ assert run["updated_at"] == 100.0
32
+ assert run["started_at"] == 100.0
33
+ assert run["completed_at"] is None
34
+ assert run["failed_at"] is None
35
+ assert run["expires_at"] == 100.0 + DEFAULT_RUN_TTL_SECONDS
36
+
37
+
38
+ def test_update_partial_merges_and_refreshes_ttl():
39
+ clock = _FakeClock(start=10.0)
40
+ tracker = BaselineRunTracker(ttl_seconds=10.0, time_fn=clock.time)
41
+
42
+ run_id = tracker.create_run()
43
+ clock.advance(3.0)
44
+ tracker.update_partial(run_id, {"processed": 1})
45
+
46
+ first = tracker.get_run(run_id)
47
+ assert first is not None
48
+ assert first["status"] == "running"
49
+ assert first["partial"] == {"processed": 1}
50
+ assert first["expires_at"] == 23.0
51
+
52
+ clock.advance(2.0)
53
+ tracker.update_partial(run_id, {"total": 5})
54
+ second = tracker.get_run(run_id)
55
+
56
+ assert second is not None
57
+ assert second["partial"] == {"processed": 1, "total": 5}
58
+ assert second["expires_at"] == 25.0
59
+
60
+
61
+ def test_status_transitions_failed_to_running_to_complete():
62
+ clock = _FakeClock(start=200.0)
63
+ tracker = BaselineRunTracker(time_fn=clock.time)
64
+
65
+ run_id = tracker.create_run()
66
+ clock.advance(1.0)
67
+ tracker.mark_failed(run_id, {"message": "transient error"})
68
+
69
+ failed = tracker.get_run(run_id)
70
+ assert failed is not None
71
+ assert failed["status"] == "failed"
72
+ assert failed["error"] == {"message": "transient error"}
73
+ assert failed["failed_at"] == 201.0
74
+
75
+ clock.advance(1.0)
76
+ tracker.mark_running(run_id, {"retry": 1})
77
+ running = tracker.get_run(run_id)
78
+ assert running is not None
79
+ assert running["status"] == "running"
80
+ assert running["error"] is None
81
+ assert running["failed_at"] is None
82
+ assert running["partial"] == {"retry": 1}
83
+
84
+ clock.advance(1.0)
85
+ tracker.mark_complete(run_id, {"average_score": 0.87})
86
+ completed = tracker.get_run(run_id)
87
+ assert completed is not None
88
+ assert completed["status"] == "completed"
89
+ assert completed["result"] == {"average_score": 0.87}
90
+ assert completed["error"] is None
91
+ assert completed["completed_at"] == 203.0
92
+
93
+
94
+ def test_cleanup_expired_respects_ttl_and_touch_updates():
95
+ clock = _FakeClock(start=0.0)
96
+ tracker = BaselineRunTracker(ttl_seconds=10.0, time_fn=clock.time)
97
+
98
+ stale_run = tracker.create_run()
99
+ refreshed_run = tracker.create_run()
100
+
101
+ clock.advance(6.0)
102
+ tracker.update_partial(refreshed_run, {"progress": 0.5})
103
+
104
+ clock.advance(5.0)
105
+ removed = tracker.cleanup_expired()
106
+ assert removed == 1
107
+ assert tracker.get_run(stale_run) is None
108
+ assert tracker.get_run(refreshed_run) is not None
109
+
110
+ clock.advance(6.0)
111
+ assert tracker.cleanup_expired() == 1
112
+ assert tracker.get_run(refreshed_run) is None
tests/test_environment.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from statistics import mean
5
+
6
+ from clip_quality_env.env import ClipQualityEnvironment
7
+ from clip_quality_env.ground_truth import GTStore
8
+ from clip_quality_env.models import Action
9
+ from clip_quality_env.rubric import RubricState
10
+
11
+
12
+ def _action_for_clip(clip_id: str, label: str = "BORDERLINE") -> Action:
13
+ return Action.model_validate(
14
+ {
15
+ "label": label,
16
+ "reasoning": f"{label} decision for {clip_id} using clip metadata cues.",
17
+ "confidence": 0.8,
18
+ "clip_id": clip_id,
19
+ }
20
+ )
21
+
22
+
23
+ def test_environment_reset_returns_task_observation():
24
+ env = ClipQualityEnvironment()
25
+ obs = env.reset(task_id="task_easy")
26
+ assert obs.task_id == "task_easy"
27
+ assert obs.step_count == 0
28
+ assert obs.corpus_size == obs.corpus_shown
29
+ assert len(obs.data_corpus) == obs.corpus_size
30
+ assert obs.clip_metadata.clip_id
31
+ assert obs.max_steps == 5
32
+ assert obs.info["steps_remaining"] == 5
33
+
34
+
35
+ def test_environment_step_updates_state_and_reward():
36
+ env = ClipQualityEnvironment()
37
+ env.reset(task_id="task_easy")
38
+ action = Action.model_validate(
39
+ {
40
+ "label": "KEEP",
41
+ "reasoning": "face_confidence and lighting_uniformity are high, motion_score is low, so keep.",
42
+ "confidence": 0.9,
43
+ }
44
+ )
45
+ next_obs = env.step(action)
46
+ assert next_obs.step_count == 1
47
+ assert 0.0 <= next_obs.reward <= 1.0
48
+ assert next_obs.history
49
+ state = env.state
50
+ assert state.step_count == 1
51
+ assert state.actions_taken[-1] in {"KEEP", "BORDERLINE", "REJECT"}
52
+
53
+
54
+ def test_environment_observation_exposes_reward_decomposition():
55
+ env = ClipQualityEnvironment()
56
+ reset_obs = env.reset(task_id="task_easy")
57
+
58
+ assert reset_obs.info["format_score"] == 0.0
59
+ assert reset_obs.info["label_score"] == 0.0
60
+ assert reset_obs.info["reasoning_score"] == 0.0
61
+ assert reset_obs.info["raw_total"] == 0.0
62
+ assert reset_obs.info["calibrated_total"] == 0.0
63
+ assert reset_obs.info["reward_total"] == 0.0
64
+ assert reset_obs.info["total_reward"] == 0.0
65
+ assert reset_obs.info["reward_breakdown"] == {
66
+ "format_score": 0.0,
67
+ "label_score": 0.0,
68
+ "reasoning_score": 0.0,
69
+ "raw_total": 0.0,
70
+ "calibrated_total": 0.0,
71
+ "total_reward": 0.0,
72
+ }
73
+
74
+ action = Action.model_validate(
75
+ {
76
+ "label": "KEEP",
77
+ "reasoning": "face_confidence and lighting_uniformity are high, motion_score is low, so keep.",
78
+ "confidence": 0.9,
79
+ }
80
+ )
81
+ step_obs = env.step(action)
82
+
83
+ assert step_obs.info["format_score"] in {0.0, 0.1}
84
+ assert step_obs.info["label_score"] in {0.0, 0.25, 0.6}
85
+ assert 0.0 <= step_obs.info["reasoning_score"] <= 0.3
86
+ assert abs(
87
+ float(step_obs.info["raw_total"])
88
+ - (
89
+ float(step_obs.info["format_score"])
90
+ + float(step_obs.info["label_score"])
91
+ + float(step_obs.info["reasoning_score"])
92
+ )
93
+ ) < 1e-9
94
+ assert abs(float(step_obs.info["calibrated_total"]) - float(step_obs.reward)) < 1e-9
95
+ assert abs(float(step_obs.info["reward_total"]) - float(step_obs.reward)) < 1e-9
96
+ assert abs(float(step_obs.info["total_reward"]) - float(env.state.total_reward)) < 1e-9
97
+ assert step_obs.info["reward_breakdown"] == {
98
+ "format_score": float(step_obs.info["format_score"]),
99
+ "label_score": float(step_obs.info["label_score"]),
100
+ "reasoning_score": float(step_obs.info["reasoning_score"]),
101
+ "raw_total": float(step_obs.info["raw_total"]),
102
+ "calibrated_total": float(step_obs.info["calibrated_total"]),
103
+ "total_reward": float(step_obs.info["reward_total"]),
104
+ }
105
+
106
+
107
+ def test_environment_derives_expected_label_when_manifest_value_is_null(monkeypatch, tmp_path):
108
+ manifest_path = tmp_path / "manifest_null_expected.jsonl"
109
+ manifest_row = {
110
+ "difficulty": "easy",
111
+ "clip_id": "manifest_null_expected",
112
+ "expected_label": None,
113
+ "face_confidence": 0.88,
114
+ "motion_score": 0.12,
115
+ "audio_snr_db": 24.0,
116
+ "lighting_uniformity": 0.8,
117
+ "duration_s": 8.0,
118
+ }
119
+ manifest_path.write_text(json.dumps(manifest_row) + "\n", encoding="utf-8")
120
+
121
+ monkeypatch.setenv("REAL_CLIPS_MANIFEST", str(manifest_path))
122
+ monkeypatch.setenv("CLIP_CORPUS_SOURCE", "manifest")
123
+
124
+ env = ClipQualityEnvironment()
125
+ obs = env.reset(task_id="task_easy", seed=2026)
126
+
127
+ assert obs.info["corpus_source"] == "manifest:easy"
128
+ assert str(obs.clip_metadata.expected_label).upper() in {"KEEP", "BORDERLINE", "REJECT"}
129
+ assert str(obs.clip_metadata.expected_label).upper() != "NONE"
130
+
131
+
132
+ def test_environment_can_force_task_registry_source(monkeypatch):
133
+ monkeypatch.setenv("CLIP_CORPUS_SOURCE", "task_registry")
134
+
135
+ env = ClipQualityEnvironment()
136
+ obs = env.reset(task_id="task_easy", seed=2026)
137
+
138
+ assert obs.info["corpus_mode"] == "task_registry"
139
+ assert obs.info["corpus_source"] == "task_registry:task_easy"
140
+
141
+
142
+ def test_environment_observation_includes_full_unsliced_corpus():
143
+ env = ClipQualityEnvironment()
144
+ obs = env.reset(task_id="task_medium")
145
+ synthetic_corpus = []
146
+ template = dict(obs.data_corpus[0]) if obs.data_corpus else {
147
+ "expected_label": "BORDERLINE",
148
+ "review_status": "pending",
149
+ }
150
+ for idx in range(12):
151
+ item = dict(template)
152
+ item["id"] = f"synthetic_{idx:03d}"
153
+ item["clip_id"] = f"clip_synth_{idx:03d}"
154
+ item["expected_label"] = str(item.get("expected_label") or "BORDERLINE")
155
+ item["review_status"] = "pending"
156
+ synthetic_corpus.append(item)
157
+ env._episode_corpus[obs.task_id] = synthetic_corpus
158
+
159
+ full_obs = env._state_to_observation(reward=0.0, done=False)
160
+
161
+ assert full_obs.corpus_size == len(synthetic_corpus)
162
+ assert full_obs.corpus_shown == len(synthetic_corpus)
163
+ assert len(full_obs.data_corpus) == len(synthetic_corpus)
164
+ assert [item["clip_id"] for item in full_obs.data_corpus] == [
165
+ item["clip_id"] for item in synthetic_corpus
166
+ ]
167
+
168
+
169
+ def test_environment_step_updates_submitted_clip_review_status():
170
+ env = ClipQualityEnvironment()
171
+ obs = env.reset(task_id="task_medium")
172
+ current_clip_id = obs.clip_metadata.clip_id
173
+
174
+ current_row = next(item for item in obs.data_corpus if item["clip_id"] == current_clip_id)
175
+ assert str(current_row.get("review_status", "")).lower() == "pending"
176
+
177
+ action = Action.model_validate(
178
+ {
179
+ "label": "REJECT",
180
+ "reasoning": "multiple weak cues indicate this clip should be rejected.",
181
+ "confidence": 0.81,
182
+ "clip_id": current_clip_id,
183
+ }
184
+ )
185
+ next_obs = env.step(action)
186
+ updated_row = next(item for item in next_obs.data_corpus if item["clip_id"] == current_clip_id)
187
+
188
+ assert updated_row["review_status"] == "REJECT"
189
+
190
+
191
+ def test_environment_instances_do_not_share_runtime_state():
192
+ env_a = ClipQualityEnvironment()
193
+ env_b = ClipQualityEnvironment()
194
+
195
+ env_a.reset(task_id="task_easy")
196
+ action = Action.model_validate(
197
+ {
198
+ "label": "KEEP",
199
+ "reasoning": "face_confidence and lighting_uniformity are high, motion_score is low, so keep.",
200
+ "confidence": 0.9,
201
+ }
202
+ )
203
+ env_a.step(action)
204
+
205
+ assert env_a is not env_b
206
+ assert env_a.state.step_count == 1
207
+ assert env_b.state.step_count == 0
208
+ assert env_b.state.actions_taken == []
209
+
210
+
211
+ def test_environment_reset_plans_five_clips_from_selected_corpus():
212
+ env = ClipQualityEnvironment()
213
+ obs = env.reset(task_id="task_medium", seed=1234)
214
+ expected_source = obs.info["corpus_source"]
215
+ expected_data = list(obs.data_corpus)
216
+ expected_ids = {str(item["clip_id"]) for item in expected_data}
217
+ first_plan_ids = [str(item.clip.get("clip_id", "")) for item in env._episode_plan]
218
+
219
+ assert obs.max_steps == 5
220
+ assert obs.info["steps_remaining"] == 5
221
+ assert len(env._episode_plan) == 5
222
+ assert {item.task_id for item in env._episode_plan} == {"task_medium"}
223
+ assert set(first_plan_ids).issubset(expected_ids)
224
+
225
+ repeated = env.reset(task_id="task_medium", seed=1234)
226
+ repeated_plan_ids = [str(item.clip.get("clip_id", "")) for item in env._episode_plan]
227
+ assert repeated.max_steps == 5
228
+ assert repeated.info["steps_remaining"] == 5
229
+ assert repeated.info["corpus_source"] == expected_source
230
+ assert repeated.data_corpus == expected_data
231
+ assert repeated_plan_ids == first_plan_ids
232
+
233
+
234
+ def test_environment_updates_review_status_across_queue_and_summary():
235
+ env = ClipQualityEnvironment()
236
+ obs = env.reset(task_id="task_hard", seed=2026)
237
+
238
+ assert obs.info["steps_remaining"] == 5
239
+ assert "episode_summary" not in obs.info
240
+
241
+ submitted: list[tuple[str, str]] = []
242
+ final_obs = obs
243
+ for step_index in range(1, 6):
244
+ current_clip_id = final_obs.clip_metadata.clip_id
245
+ label = "KEEP" if step_index % 2 else "BORDERLINE"
246
+ submitted.append((current_clip_id, label))
247
+ final_obs = env.step(_action_for_clip(current_clip_id, label=label))
248
+ queue_row = next(item for item in final_obs.data_corpus if item.get("clip_id") == current_clip_id)
249
+ assert str(queue_row.get("review_status")) == label
250
+ assert final_obs.info["steps_remaining"] == max(0, 5 - step_index)
251
+
252
+ assert final_obs.done is True
253
+ assert final_obs.step_count == 5
254
+ assert "episode_summary" in final_obs.info
255
+ summary = final_obs.info["episode_summary"]
256
+ assert summary["steps_completed"] == 5
257
+ assert summary["max_steps"] == 5
258
+ assert abs(float(summary["total_reward"]) - round(float(env.state.total_reward), 4)) < 1e-9
259
+ assert abs(float(final_obs.info["total_reward"]) - float(env.state.total_reward)) < 1e-9
260
+ assert abs(float(summary["average_reward"]) - round(float(env.state.total_reward) / 5.0, 4)) < 1e-9
261
+
262
+ final_status_map = {str(item["clip_id"]): str(item["review_status"]) for item in final_obs.data_corpus}
263
+ for clip_id, label in submitted:
264
+ assert final_status_map[clip_id] == label
265
+
266
+
267
+ def test_environment_task_averages_follow_hard_medium_easy_order(monkeypatch, tmp_path):
268
+ monkeypatch.setenv("REAL_CLIPS_MANIFEST", str(tmp_path / "missing_manifest.jsonl"))
269
+
270
+ def run_task_average(task_id: str) -> float:
271
+ env = ClipQualityEnvironment()
272
+ env._rubric = RubricState(path=str(tmp_path / f"rubric_{task_id}.json"))
273
+ env._gt_store = GTStore(
274
+ seed_path="data/seed_gt.json",
275
+ state_path=str(tmp_path / f"ground_truth_{task_id}.json"),
276
+ )
277
+
278
+ obs = env.reset(task_id=task_id, seed=2026)
279
+ step_rewards: list[float] = []
280
+ while True:
281
+ expected_label = str(obs.clip_metadata.expected_label or "BORDERLINE")
282
+ action = Action.model_validate(
283
+ {
284
+ "label": expected_label,
285
+ "reasoning": (
286
+ "face_confidence, motion_score, audio_snr_db, and lighting_uniformity "
287
+ "support this decision."
288
+ ),
289
+ "confidence": 0.9,
290
+ "clip_id": obs.clip_metadata.clip_id,
291
+ }
292
+ )
293
+ obs = env.step(action)
294
+ step_rewards.append(float(obs.reward))
295
+ if obs.done:
296
+ break
297
+ return float(mean(step_rewards))
298
+
299
+ easy_avg = run_task_average("task_easy")
300
+ medium_avg = run_task_average("task_medium")
301
+ hard_avg = run_task_average("task_hard")
302
+
303
+ assert hard_avg > medium_avg > easy_avg
304
+
305
+
306
+ def test_environment_difficulty_score_ranges_are_strictly_ordered(monkeypatch, tmp_path):
307
+ monkeypatch.setenv("REAL_CLIPS_MANIFEST", str(tmp_path / "missing_manifest.jsonl"))
308
+
309
+ def run_task_range(task_id: str) -> tuple[float, float]:
310
+ task_scores: list[float] = []
311
+ labels = ("KEEP", "BORDERLINE", "REJECT")
312
+ reasoning_cases = (
313
+ "x",
314
+ "face_confidence, motion_score, audio_snr_db, and lighting_uniformity support this decision.",
315
+ )
316
+ confidence_cases = (0.0, 0.5, 1.0)
317
+
318
+ for label in labels:
319
+ for reasoning in reasoning_cases:
320
+ for confidence in confidence_cases:
321
+ env = ClipQualityEnvironment()
322
+ env._rubric = RubricState(path=str(tmp_path / f"rubric_range_{task_id}.json"))
323
+ env._gt_store = GTStore(
324
+ seed_path="data/seed_gt.json",
325
+ state_path=str(tmp_path / f"ground_truth_range_{task_id}.json"),
326
+ )
327
+
328
+ obs = env.reset(task_id=task_id, seed=2026)
329
+ while True:
330
+ action = Action.model_validate(
331
+ {
332
+ "label": label,
333
+ "reasoning": reasoning,
334
+ "confidence": confidence,
335
+ "clip_id": obs.clip_metadata.clip_id,
336
+ }
337
+ )
338
+ obs = env.step(action)
339
+ task_scores.append(float(obs.reward))
340
+ if obs.done:
341
+ break
342
+
343
+ return min(task_scores), max(task_scores)
344
+
345
+ easy_min, easy_max = run_task_range("task_easy")
346
+ medium_min, medium_max = run_task_range("task_medium")
347
+ hard_min, hard_max = run_task_range("task_hard")
348
+
349
+ assert easy_max < medium_min
350
+ assert medium_max < hard_min
351
+
352
+ assert 0.0 <= easy_min <= easy_max <= 1.0
353
+ assert 0.0 <= medium_min <= medium_max <= 1.0
354
+ assert 0.0 <= hard_min <= hard_max <= 1.0
tests/test_grader.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from statistics import mean
4
+
5
+ import pytest
6
+
7
+ import server.grader as grader_module
8
+ from clip_quality_env.difficulty import DIFFICULTY_TOTAL_BANDS
9
+ from clip_quality_env.ground_truth import GTStore
10
+ from clip_quality_env.rubric import RubricState
11
+ from server.tasks import TASK_REGISTRY
12
+ from server.grader import grade
13
+
14
+
15
+ @pytest.fixture
16
+ def isolated_grader_state(monkeypatch, tmp_path):
17
+ rubric = RubricState(path=str(tmp_path / "rubric.json"))
18
+ gt = GTStore(seed_path="data/seed_gt.json", state_path=str(tmp_path / "ground_truth.json"))
19
+ monkeypatch.setattr(grader_module, "_RUBRIC", rubric)
20
+ monkeypatch.setattr(grader_module, "_GT", gt)
21
+ return rubric, gt
22
+
23
+
24
+ def test_grade_easy_clip_quality_scores_in_easy_band():
25
+ action = {
26
+ "label": "KEEP",
27
+ "clip_id": "clip_0001",
28
+ "reasoning": "face_confidence and audio_snr_db are high while motion_score is low, so this clip should be kept.",
29
+ "confidence": 0.9,
30
+ }
31
+ score = grade(action, "task_easy")
32
+ low, high = DIFFICULTY_TOTAL_BANDS["easy"]
33
+ assert low <= score <= high
34
+
35
+
36
+ def test_grade_medium_clip_quality_scores_in_range():
37
+ action = {
38
+ "label": "BORDERLINE",
39
+ "clip_id": "clip_0008",
40
+ "reasoning": "face_area_ratio is borderline and motion_score is elevated, so borderline is safest.",
41
+ "confidence": 0.74,
42
+ }
43
+ score = grade(action, "task_medium")
44
+ low, high = DIFFICULTY_TOTAL_BANDS["medium"]
45
+ assert low <= score <= high
46
+
47
+
48
+ def test_grade_hard_clip_quality_scores_in_range():
49
+ action = {
50
+ "label": "REJECT",
51
+ "clip_id": "clip_0017",
52
+ "reasoning": "face_confidence is weak, face_area_ratio is small, and motion_score is high enough to reject.",
53
+ "confidence": 0.83,
54
+ }
55
+ score = grade(action, "task_hard")
56
+ low, high = DIFFICULTY_TOTAL_BANDS["hard"]
57
+ assert low <= score <= high
58
+
59
+
60
+ def test_grade_legacy_payload_is_supported_and_bounded():
61
+ action = {
62
+ "action_type": "propose_clarification",
63
+ "ambiguous_term": "appropriate",
64
+ "suggested_definition": "Keep clips with stable framing and clear speech; reject clips with severe occlusion.",
65
+ "justification": "Makes decisions consistent for borderline metadata combinations.",
66
+ }
67
+ score = grade(action, "task_easy")
68
+ assert 0.0 <= score <= 1.0
69
+
70
+
71
+ def test_grade_task_averages_follow_hard_medium_easy_order(isolated_grader_state):
72
+ del isolated_grader_state
73
+
74
+ def task_average(task_id: str) -> float:
75
+ scores: list[float] = []
76
+ for clip in TASK_REGISTRY[task_id]["data_corpus"]:
77
+ action = {
78
+ "label": str(clip.get("expected_label", "BORDERLINE")),
79
+ "clip_id": str(clip.get("clip_id", "")),
80
+ "reasoning": (
81
+ "face_confidence, motion_score, audio_snr_db, and lighting_uniformity "
82
+ "support this decision."
83
+ ),
84
+ "confidence": 0.9,
85
+ }
86
+ scores.append(grade(action, task_id))
87
+ return float(mean(scores))
88
+
89
+ easy_avg = task_average("task_easy")
90
+ medium_avg = task_average("task_medium")
91
+ hard_avg = task_average("task_hard")
92
+
93
+ assert hard_avg > medium_avg > easy_avg
94
+
95
+
96
+ def test_grade_difficulty_bands_do_not_overlap(isolated_grader_state):
97
+ del isolated_grader_state
98
+
99
+ labels = ("KEEP", "BORDERLINE", "REJECT")
100
+ reasoning_cases = (
101
+ "x",
102
+ "face_confidence, motion_score, audio_snr_db, and lighting_uniformity support this decision.",
103
+ )
104
+ confidence_cases = (0.0, 0.5, 1.0)
105
+
106
+ ranges: dict[str, tuple[float, float]] = {}
107
+ for task_id in ("task_easy", "task_medium", "task_hard"):
108
+ task_scores: list[float] = []
109
+ for clip in TASK_REGISTRY[task_id]["data_corpus"]:
110
+ clip_id = str(clip.get("clip_id", ""))
111
+ for label in labels:
112
+ for reasoning in reasoning_cases:
113
+ for confidence in confidence_cases:
114
+ task_scores.append(
115
+ grade(
116
+ {
117
+ "label": label,
118
+ "clip_id": clip_id,
119
+ "reasoning": reasoning,
120
+ "confidence": confidence,
121
+ },
122
+ task_id,
123
+ )
124
+ )
125
+ ranges[task_id] = (min(task_scores), max(task_scores))
126
+
127
+ easy_min, easy_max = ranges["task_easy"]
128
+ medium_min, medium_max = ranges["task_medium"]
129
+ hard_min, hard_max = ranges["task_hard"]
130
+
131
+ assert easy_min >= DIFFICULTY_TOTAL_BANDS["easy"][0]
132
+ assert easy_max <= DIFFICULTY_TOTAL_BANDS["easy"][1]
133
+ assert medium_min >= DIFFICULTY_TOTAL_BANDS["medium"][0]
134
+ assert medium_max <= DIFFICULTY_TOTAL_BANDS["medium"][1]
135
+ assert hard_min >= DIFFICULTY_TOTAL_BANDS["hard"][0]
136
+ assert hard_max <= DIFFICULTY_TOTAL_BANDS["hard"][1]
137
+
138
+ assert easy_max < medium_min
139
+ assert medium_max < hard_min
tests/test_inference.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import inference
6
+
7
+
8
+ def test_inference_extract_json_handles_fenced_blocks():
9
+ raw = "```json\n{\"x\": 1}\n```"
10
+ parsed = inference._extract_json(raw)
11
+ assert parsed == {"x": 1}
12
+
13
+
14
+ def test_run_baseline_works_without_token(monkeypatch):
15
+ monkeypatch.delenv("HF_TOKEN", raising=False)
16
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
17
+ result = inference.run_baseline(task="task_easy")
18
+ assert result["detail"][0]["task_id"] == "task_easy"
19
+ assert result["detail"][0]["mode"] == "fallback"
20
+ assert result["detail"][0]["steps"] == 5
21
+ assert 0.0 <= result["detail"][0]["total_reward"] <= 5.0
22
+ assert 0.0 <= result["detail"][0]["final_reward"] <= 1.0
23
+ assert 0.0 <= result["detail"][0]["reward"] <= 1.0
24
+ assert 0.0 <= result["baseline_scores"]["overall_avg"] <= 1.0
25
+
26
+
27
+ class _DummyMessage:
28
+ def __init__(self, content: str):
29
+ self.content = content
30
+
31
+
32
+ class _DummyChoice:
33
+ def __init__(self, content: str):
34
+ self.message = _DummyMessage(content)
35
+
36
+
37
+ class _DummyResponse:
38
+ def __init__(self, content: str):
39
+ self.choices = [_DummyChoice(content)]
40
+
41
+
42
+ class _DummyCompletions:
43
+ def __init__(self, content: str):
44
+ self._content = content
45
+
46
+ def create(self, **_: Any):
47
+ return _DummyResponse(self._content)
48
+
49
+
50
+ class _DummyChat:
51
+ def __init__(self, content: str):
52
+ self.completions = _DummyCompletions(content)
53
+
54
+
55
+ class _DummyClient:
56
+ def __init__(self, content: str):
57
+ self.chat = _DummyChat(content)
58
+
59
+
60
+ def test_run_episode_preserves_logging_and_structure(capsys):
61
+ client = _DummyClient('{"label":"KEEP","reasoning":"face_confidence and motion_score indicate keep","confidence":0.9}')
62
+ result = inference.run_episode("task_easy", client, "dummy-model")
63
+ output = capsys.readouterr().out
64
+ assert "[START]" in output and "[STEP]" in output and "[END]" in output
65
+ assert "total_reward=" in output and "final_reward=" in output
66
+ assert result["task_id"] == "task_easy"
67
+ assert result["mode"] == "llm"
68
+ assert result["steps"] == 5
69
+ assert 0.0 <= result["final_reward"] <= 1.0
70
+ assert 0.0 <= result["total_reward"] <= 5.0
71
+ assert 0.0 <= result["reward"] <= 1.0
72
+ assert abs(result["reward"] * result["steps"] - result["total_reward"]) < 1e-9