unknownfriend00007 commited on
Commit
52255dd
·
verified ·
1 Parent(s): befb1c3

Upload 52 files

Browse files
Files changed (33) hide show
  1. docs/superpowers/plans/2026-07-21-feel-scorer.md +1672 -0
  2. docs/superpowers/specs/2026-07-20-feel-scorer-design.md +192 -0
  3. video_library/__main__.py +48 -1
  4. video_library/__pycache__/__init__.cpython-312.pyc +0 -0
  5. video_library/__pycache__/__main__.cpython-312.pyc +0 -0
  6. video_library/__pycache__/app.cpython-312.pyc +0 -0
  7. video_library/__pycache__/config.cpython-312.pyc +0 -0
  8. video_library/__pycache__/curation_app.cpython-312.pyc +0 -0
  9. video_library/__pycache__/downloader.cpython-312.pyc +0 -0
  10. video_library/__pycache__/feel_score_app.cpython-312.pyc +0 -0
  11. video_library/__pycache__/feel_score_runner.cpython-312.pyc +0 -0
  12. video_library/__pycache__/feel_scorer.cpython-312.pyc +0 -0
  13. video_library/__pycache__/library_builder.cpython-312.pyc +0 -0
  14. video_library/__pycache__/mistral_backend.cpython-312.pyc +0 -0
  15. video_library/__pycache__/models.cpython-312.pyc +0 -0
  16. video_library/__pycache__/mood_rules.cpython-312.pyc +0 -0
  17. video_library/__pycache__/mood_tagger.cpython-312.pyc +0 -0
  18. video_library/__pycache__/reject_rules.cpython-312.pyc +0 -0
  19. video_library/__pycache__/stock_search.cpython-312.pyc +0 -0
  20. video_library/__pycache__/tag_app.cpython-312.pyc +0 -0
  21. video_library/__pycache__/tag_runner.cpython-312.pyc +0 -0
  22. video_library/__pycache__/taxonomy.cpython-312.pyc +0 -0
  23. video_library/__pycache__/vision_describe.cpython-312.pyc +0 -0
  24. video_library/app.py +57 -0
  25. video_library/catalog_store.py +248 -15
  26. video_library/feel_score_app.py +223 -0
  27. video_library/feel_score_runner.py +88 -0
  28. video_library/feel_scorer.py +413 -0
  29. video_library/seed_app.py +34 -9
  30. video_library/seed_reason_app.py +72 -0
  31. video_library/seed_reason_runner.py +114 -0
  32. video_library/seed_reasoner.py +188 -0
  33. video_library/vision_describe.py +9 -3
docs/superpowers/plans/2026-07-21-feel-scorer.md ADDED
@@ -0,0 +1,1672 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Feel Scorer (Phase 4) Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Gemini re-watches every mechanically-passed clip, judges its feel against a taste rubric, and writes a score-ranked shortlist to a new `feel_scores` table with a UI to browse it.
6
+
7
+ **Architecture:** Reuse the Phase-1 vision pipeline (download → VisionRouter → store). Add a `prompt`/`parser` override to `GenAIBackend` so the same router can run `FEEL_PROMPT`. New backend module (`feel_scorer.py`), runner (`feel_score_runner.py`), store methods (`feel_scores` table), UI tab + glue (`feel_score_app.py`), CLI flag.
8
+
9
+ **Tech Stack:** Python 3, google-genai (Gemini/Gemma vision), Neon Postgres, Gradio 6.19, pytest, InMemoryCatalogStore for tests.
10
+
11
+ ## Global Constraints
12
+
13
+ - **Two trees, kept in sync:** every non-test file written to BOTH `video_library/<f>` AND `hf_space/video_library/<f>`, byte-identical. Tests live only in `video_library/tests/`.
14
+ - **Run tests from repo root** `C:\EXTRA PROJECTS\INPENIX\narration-shorts` with `python -m pytest video_library/tests/ -q`.
15
+ - **No network in tests** — inject fakes; use `InMemoryCatalogStore`.
16
+ - **Crash safety in UI:** video players `autoplay=False, loop=True`, page size 6, verdict/browse never triggers mass video reload, guarded render (catch → error header, no 500).
17
+ - **No bytes persist:** clips downloaded to temp, deleted after scoring.
18
+ - **`clips` table untouched.** `feel_scores` is a fresh, disposable table.
19
+ - **FEEL_PROMPT is verbatim** from the user's rubric (score /100, KEEP≥80 / BORDERLINE 60-79 / REJECT<60, JSON keys: decision, score, camera_motion, subject_motion, visual_complexity, human_presence, mood, reason).
20
+ - **No git repo** — skip commit steps; "commit" means "save both trees + run tests green".
21
+
22
+ ---
23
+
24
+ ### Task 1: `GenAIBackend` prompt/parser override (non-breaking)
25
+
26
+ Make the existing vision backend accept an alternate prompt + parser so the feel pass reuses `VisionRouter` unchanged. Phase-1 callers keep current behavior via defaults.
27
+
28
+ **Files:**
29
+ - Modify: `video_library/vision_describe.py` (+ hf_space mirror)
30
+ - Test: `video_library/tests/test_vision_describe.py`
31
+
32
+ **Interfaces:**
33
+ - Produces: `GenAIBackend(api_key, model, *, strip_audio=False, prompt=VISION_PROMPT, parser=parse_vision_json, ...)`. `describe()` uses `self._prompt` and `self._parser`.
34
+
35
+ - [ ] **Step 1: Write failing test** — append to `test_vision_describe.py`:
36
+
37
+ ```python
38
+ def test_backend_uses_injected_prompt_and_parser():
39
+ captured = {}
40
+
41
+ class _FakeResp:
42
+ text = '{"ok": true}'
43
+
44
+ class _FakeModels:
45
+ def generate_content(self, model, contents, config):
46
+ captured["prompt"] = contents[1]
47
+ return _FakeResp()
48
+
49
+ class _FakeFiles:
50
+ def upload(self, file):
51
+ return object()
52
+
53
+ class _FakeClient:
54
+ files = _FakeFiles()
55
+ models = _FakeModels()
56
+
57
+ sentinel = object()
58
+ backend = GenAIBackend("k", "gemini-x", client=_FakeClient(),
59
+ prompt="FEEL PROMPT HERE",
60
+ parser=lambda text: sentinel)
61
+ result = backend.describe("dummy.mp4")
62
+ assert result is sentinel
63
+ assert captured["prompt"] == "FEEL PROMPT HERE"
64
+ ```
65
+
66
+ - [ ] **Step 2: Run to verify it fails**
67
+
68
+ Run: `python -m pytest video_library/tests/test_vision_describe.py::test_backend_uses_injected_prompt_and_parser -q`
69
+ Expected: FAIL (`GenAIBackend` got unexpected keyword `prompt`).
70
+
71
+ - [ ] **Step 3: Implement** — in `GenAIBackend.__init__` add params and store them. Edit the signature:
72
+
73
+ ```python
74
+ def __init__(self, api_key: str, model: str, *, strip_audio: bool = False,
75
+ client=None, audio_stripper=strip_audio_track,
76
+ sleep=time.sleep, poll_interval: float = 5.0, max_poll: int = 60,
77
+ gen_config=None, prompt: str = VISION_PROMPT,
78
+ parser=parse_vision_json):
79
+ self._key = api_key
80
+ self._model = model
81
+ self._strip_audio = strip_audio
82
+ self._client = client
83
+ self._audio_stripper = audio_stripper
84
+ self._sleep = sleep
85
+ self._poll_interval = poll_interval
86
+ self._max_poll = max_poll
87
+ self._gen_config = gen_config
88
+ self._prompt = prompt
89
+ self._parser = parser
90
+ ```
91
+
92
+ In `describe()`, replace the two hardcoded references:
93
+
94
+ ```python
95
+ resp = client.models.generate_content(
96
+ model=self._model,
97
+ contents=[uploaded, self._prompt],
98
+ config=self._gen_config_obj(),
99
+ )
100
+ desc = self._parser(self._text_of(resp))
101
+ desc.described_by = self._model
102
+ return desc
103
+ ```
104
+
105
+ Note: `FeelResult` (Task 2) also carries a `described_by` attribute so this assignment stays valid for the feel parser.
106
+
107
+ - [ ] **Step 4: Run tests** — verify new test + existing vision tests pass:
108
+
109
+ Run: `python -m pytest video_library/tests/test_vision_describe.py -q`
110
+ Expected: PASS (all).
111
+
112
+ - [ ] **Step 5: Sync mirror** — copy `video_library/vision_describe.py` to `hf_space/video_library/vision_describe.py` (identical). Verify:
113
+
114
+ Run: `diff video_library/vision_describe.py hf_space/video_library/vision_describe.py && echo SYNC`
115
+ Expected: `SYNC`.
116
+
117
+ ---
118
+
119
+ ### Task 2: `feel_scorer.py` — FEEL_PROMPT, FeelResult, parse_feel_json
120
+
121
+ The vision judge's data layer: the verbatim prompt, the result dataclass, and a strict-JSON parser with validation/clamping.
122
+
123
+ **Files:**
124
+ - Create: `video_library/feel_scorer.py` (+ hf_space mirror)
125
+ - Test: `video_library/tests/test_feel_scorer.py`
126
+
127
+ **Interfaces:**
128
+ - Produces:
129
+ - `FEEL_PROMPT: str`
130
+ - `FeelResult` dataclass: `decision: str, score: int, camera_motion: str, subject_motion: str, visual_complexity: str, human_presence: str, mood: str, reason: str, described_by: str = ""`. Property `scored_by` returns `described_by`.
131
+ - `FeelParseError(Exception)`
132
+ - `parse_feel_json(text: str) -> FeelResult`
133
+ - `FEEL_DECISIONS = ("KEEP", "BORDERLINE", "REJECT")`
134
+
135
+ - [ ] **Step 1: Write failing tests** — create `video_library/tests/test_feel_scorer.py`:
136
+
137
+ ```python
138
+ import pytest
139
+ from video_library.feel_scorer import (
140
+ FEEL_PROMPT, FeelResult, parse_feel_json, FeelParseError, FEEL_DECISIONS,
141
+ )
142
+
143
+ GOOD = ('{"decision": "KEEP", "score": 88, "camera_motion": "slow drone glide", '
144
+ '"subject_motion": "still", "visual_complexity": "low", '
145
+ '"human_presence": "none", "mood": "peaceful", "reason": "calm open nature"}')
146
+
147
+
148
+ def test_parses_good_json():
149
+ r = parse_feel_json(GOOD)
150
+ assert r.decision == "KEEP"
151
+ assert r.score == 88
152
+ assert r.visual_complexity == "low"
153
+ assert r.human_presence == "none"
154
+ assert r.reason == "calm open nature"
155
+
156
+
157
+ def test_parses_fenced_json():
158
+ r = parse_feel_json("```json\n" + GOOD + "\n```")
159
+ assert r.decision == "KEEP"
160
+
161
+
162
+ def test_score_clamped_to_0_100():
163
+ hi = parse_feel_json('{"decision":"KEEP","score":150,"reason":"x"}')
164
+ lo = parse_feel_json('{"decision":"REJECT","score":-5,"reason":"x"}')
165
+ assert hi.score == 100
166
+ assert lo.score == 0
167
+
168
+
169
+ def test_score_coerced_from_string():
170
+ r = parse_feel_json('{"decision":"KEEP","score":"73","reason":"x"}')
171
+ assert r.score == 73
172
+
173
+
174
+ def test_missing_optional_keys_default_empty():
175
+ r = parse_feel_json('{"decision":"BORDERLINE","score":65}')
176
+ assert r.reason == ""
177
+ assert r.mood == ""
178
+ assert r.camera_motion == ""
179
+
180
+
181
+ def test_bad_decision_raises():
182
+ with pytest.raises(FeelParseError):
183
+ parse_feel_json('{"decision":"MAYBE","score":50}')
184
+
185
+
186
+ def test_no_json_raises():
187
+ with pytest.raises(FeelParseError):
188
+ parse_feel_json("not json at all")
189
+
190
+
191
+ def test_scored_by_mirrors_described_by():
192
+ r = FeelResult(decision="KEEP", score=90, described_by="gemini-x")
193
+ assert r.scored_by == "gemini-x"
194
+
195
+
196
+ def test_prompt_mentions_core_rubric():
197
+ p = FEEL_PROMPT.lower()
198
+ assert "narration" in p
199
+ assert "camera" in p and "subject" in p
200
+ assert "keep" in p and "reject" in p
201
+ ```
202
+
203
+ - [ ] **Step 2: Run to verify it fails**
204
+
205
+ Run: `python -m pytest video_library/tests/test_feel_scorer.py -q`
206
+ Expected: FAIL (module `feel_scorer` not found).
207
+
208
+ - [ ] **Step 3: Implement** — create `video_library/feel_scorer.py` exactly as below. `FEEL_PROMPT` is the user's rubric verbatim; keep it byte-for-byte:
209
+
210
+ ```python
211
+ """Phase-4 vision judge: watch a clip, score its FEEL for contemplative narration.
212
+
213
+ Unlike the Phase-1 VISION_PROMPT (which forbids feel-judgment on purpose), this
214
+ prompt asks the model to decide whether the clip can sit quietly *under* Osho-style
215
+ narration without competing for attention. Output is strict JSON we parse into a
216
+ FeelResult. The taste lives entirely in FEEL_PROMPT — no few-shot examples.
217
+ """
218
+ from __future__ import annotations
219
+ import json
220
+ import re
221
+ from dataclasses import dataclass
222
+
223
+ FEEL_DECISIONS = ("KEEP", "BORDERLINE", "REJECT")
224
+
225
+ FEEL_PROMPT = """# Role
226
+
227
+ You are an expert curator of stock footage for a YouTube Shorts channel featuring slow, contemplative Osho narration.
228
+
229
+ Your job is **not** to judge whether a clip is beautiful. Your job is to judge whether it quietly supports spoken wisdom without competing for the viewer's attention.
230
+
231
+ The narration is always the hero.
232
+
233
+ The footage exists only to create atmosphere.
234
+
235
+ A perfect clip should almost disappear behind the words while gently deepening their emotional impact.
236
+
237
+ ---
238
+
239
+ # Core Principle
240
+
241
+ **Ask yourself one question above all else:**
242
+
243
+ > **Would this clip make the viewer watch the visuals instead of listening to the narration?**
244
+
245
+ If **yes**, reject it.
246
+
247
+ If the visuals simply create a peaceful atmosphere while allowing the narration to remain the focus, keep it.
248
+
249
+ ---
250
+
251
+ # The Most Important Distinction
252
+
253
+ Evaluate **camera movement** and **subject movement** separately.
254
+
255
+ ### Good Camera Movement
256
+
257
+ The camera may move if it slowly reveals the scene.
258
+
259
+ Examples:
260
+
261
+ * slow drone glide
262
+ * slow dolly
263
+ * gentle pan
264
+ * slow tilt
265
+ * slow push-in
266
+ * smooth floating movement
267
+
268
+ This creates immersion without distraction.
269
+
270
+ ### Bad Camera Movement
271
+
272
+ Reject:
273
+
274
+ * handheld shake
275
+ * whip pans
276
+ * rapid zooms
277
+ * aggressive orbiting
278
+ * jerky stabilization
279
+ * fast tracking
280
+
281
+ ---
282
+
283
+ ### Good Subject Movement
284
+
285
+ Minimal natural movement is ideal.
286
+
287
+ Examples:
288
+
289
+ * clouds drifting
290
+ * mist moving
291
+ * gentle waves
292
+ * leaves lightly swaying
293
+ * rain
294
+ * smoke
295
+ * calm flowing river
296
+
297
+ The subject should feel almost still.
298
+
299
+ ### Bad Subject Movement
300
+
301
+ Reject if the subject itself becomes the attraction.
302
+
303
+ Examples:
304
+
305
+ * people walking prominently
306
+ * animals running
307
+ * birds dominating the frame
308
+ * vehicles
309
+ * traffic
310
+ * crowds
311
+ * dancing
312
+ * sports
313
+ * waterfalls with violent motion
314
+ * fireworks
315
+ * explosions
316
+ * anything energetic
317
+
318
+ **Camera motion is good. Subject motion is usually bad.**
319
+
320
+ ---
321
+
322
+ # Desired Visual Style
323
+
324
+ Prefer clips with:
325
+
326
+ * open landscapes
327
+ * mountains
328
+ * forests
329
+ * rivers
330
+ * lakes
331
+ * ocean horizons
332
+ * beaches
333
+ * deserts
334
+ * snow
335
+ * fog
336
+ * mist
337
+ * clouds
338
+ * sunrise
339
+ * sunset
340
+ * moonlight
341
+ * empty temples
342
+ * quiet interiors
343
+ * empty roads
344
+ * abandoned structures
345
+ * fields
346
+ * minimal architecture
347
+ * cinematic nature
348
+
349
+ The frame should evoke:
350
+
351
+ * silence
352
+ * spaciousness
353
+ * solitude
354
+ * timelessness
355
+ * contemplation
356
+ * stillness
357
+ * peace
358
+ * wonder
359
+ * presence
360
+
361
+ ---
362
+
363
+ # Visual Simplicity
364
+
365
+ The composition should be clean and easy to absorb.
366
+
367
+ Prefer:
368
+
369
+ * few dominant elements
370
+ * negative space
371
+ * uncluttered backgrounds
372
+ * natural lighting
373
+ * cinematic framing
374
+ * soft colors
375
+ * slow visual rhythm
376
+
377
+ Reject:
378
+
379
+ * clutter
380
+ * visual chaos
381
+ * many competing objects
382
+ * busy forests
383
+ * dense city scenes
384
+ * heavy visual texture
385
+ * flashing lights
386
+ * excessive detail that constantly pulls attention
387
+
388
+ ---
389
+
390
+ # Human Presence
391
+
392
+ People should rarely be the subject.
393
+
394
+ KEEP:
395
+
396
+ * tiny distant silhouette
397
+ * lone person occupying very little of the frame
398
+ * person integrated into the landscape
399
+ * person remaining still
400
+
401
+ REJECT:
402
+
403
+ * close-up faces
404
+ * portraits
405
+ * talking people
406
+ * emotional acting
407
+ * influencers
408
+ * vlog footage
409
+ * people performing actions
410
+ * crowds
411
+ * dancing
412
+ * weddings
413
+ * celebrations
414
+
415
+ If a viewer immediately notices the person before the environment, reject.
416
+
417
+ ---
418
+
419
+ # Commercial or Stock Feel
420
+
421
+ Reject clips that resemble:
422
+
423
+ * advertisements
424
+ * travel influencer footage
425
+ * luxury lifestyle
426
+ * product showcases
427
+ * fashion videos
428
+ * cinematic hero shots
429
+ * corporate footage
430
+ * generic motivational stock
431
+ * obvious AI-generated visuals
432
+ * overprocessed HDR
433
+ * unrealistic colors
434
+
435
+ The footage should feel authentic rather than promotional.
436
+
437
+ ---
438
+
439
+ # Technical Rejections
440
+
441
+ Automatically reject clips containing:
442
+
443
+ * text
444
+ * subtitles
445
+ * logos
446
+ * watermarks
447
+ * UI elements
448
+ * borders
449
+ * split screens
450
+ * multiple scenes
451
+ * fast cuts
452
+ * jump cuts
453
+ * transitions
454
+ * glitches
455
+ * poor stabilization
456
+ * distracting compression artifacts
457
+
458
+ One uninterrupted shot is strongly preferred.
459
+
460
+ ---
461
+
462
+ # Emotional Tone
463
+
464
+ Desired emotions:
465
+
466
+ * peaceful
467
+ * reflective
468
+ * meditative
469
+ * calm
470
+ * expansive
471
+ * spiritual
472
+ * quiet
473
+ * gentle
474
+ * grounded
475
+
476
+ Avoid clips that feel:
477
+
478
+ * exciting
479
+ * urgent
480
+ * dramatic
481
+ * suspenseful
482
+ * comedic
483
+ * romantic
484
+ * aggressive
485
+ * adventurous
486
+ * action-oriented
487
+ * emotionally manipulative
488
+
489
+ ---
490
+
491
+ # Scoring Rubric
492
+
493
+ Score each clip out of 100.
494
+
495
+ **Subject Motion (35 points)**
496
+
497
+ * Minimal movement = high score
498
+ * Active subjects = low score
499
+
500
+ **Camera Movement (20 points)**
501
+
502
+ * Smooth slow movement = high score
503
+ * Fast or shaky movement = low score
504
+
505
+ **Visual Simplicity (20 points)**
506
+
507
+ * Clean composition = high score
508
+ * Busy composition = low score
509
+
510
+ **Mood & Atmosphere (15 points)**
511
+
512
+ * Peaceful and contemplative = high score
513
+ * Exciting or dramatic = low score
514
+
515
+ **Human Presence (10 points)**
516
+
517
+ * None or tiny silhouette = high score
518
+ * Human as subject = low score
519
+
520
+ ---
521
+
522
+ # Decision Rules
523
+
524
+ **KEEP**
525
+
526
+ * Score **80-100**
527
+ * Strong contemplative background
528
+ * Supports narration effortlessly
529
+
530
+ **BORDERLINE**
531
+
532
+ * Score **60-79**
533
+ * Usable but contains mild distractions
534
+
535
+ **REJECT**
536
+
537
+ * Score **below 60**
538
+ * Competes with narration or breaks the contemplative atmosphere
539
+
540
+ ---
541
+
542
+ # Output Format
543
+
544
+ Return only valid JSON.
545
+
546
+ {
547
+ "decision": "KEEP | BORDERLINE | REJECT",
548
+ "score": 0,
549
+ "camera_motion": "",
550
+ "subject_motion": "",
551
+ "visual_complexity": "low | medium | high",
552
+ "human_presence": "none | distant | prominent",
553
+ "mood": "",
554
+ "reason": ""
555
+ }
556
+
557
+ Keep the "reason" concise (1-2 sentences), focusing on why the clip does or does not work as a quiet background for contemplative narration.
558
+
559
+ Never judge the philosophical content. Judge only the visual suitability for an Osho-style spoken wisdom video."""
560
+
561
+
562
+ class FeelParseError(Exception):
563
+ pass
564
+
565
+
566
+ @dataclass
567
+ class FeelResult:
568
+ decision: str
569
+ score: int
570
+ camera_motion: str = ""
571
+ subject_motion: str = ""
572
+ visual_complexity: str = ""
573
+ human_presence: str = ""
574
+ mood: str = ""
575
+ reason: str = ""
576
+ described_by: str = ""
577
+
578
+ @property
579
+ def scored_by(self) -> str:
580
+ return self.described_by
581
+
582
+
583
+ def _coerce_score(v) -> int:
584
+ try:
585
+ n = int(float(v))
586
+ except (TypeError, ValueError):
587
+ raise FeelParseError(f"bad score: {v!r}")
588
+ return max(0, min(100, n))
589
+
590
+
591
+ def _from_obj(obj: dict) -> FeelResult:
592
+ decision = str(obj.get("decision", "")).strip().upper()
593
+ if decision not in FEEL_DECISIONS:
594
+ raise FeelParseError(f"bad decision: {decision!r}")
595
+ return FeelResult(
596
+ decision=decision,
597
+ score=_coerce_score(obj.get("score", 0)),
598
+ camera_motion=str(obj.get("camera_motion", "") or ""),
599
+ subject_motion=str(obj.get("subject_motion", "") or ""),
600
+ visual_complexity=str(obj.get("visual_complexity", "") or ""),
601
+ human_presence=str(obj.get("human_presence", "") or ""),
602
+ mood=str(obj.get("mood", "") or ""),
603
+ reason=str(obj.get("reason", "") or ""),
604
+ )
605
+
606
+
607
+ def parse_feel_json(text: str) -> FeelResult:
608
+ """Extract the first valid JSON object from model text -> FeelResult."""
609
+ if not text:
610
+ raise FeelParseError("empty response")
611
+ candidates = []
612
+ fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
613
+ if fence:
614
+ candidates.append(fence.group(1))
615
+ brace = re.search(r"\{.*\}", text, re.DOTALL)
616
+ if brace:
617
+ candidates.append(brace.group(0))
618
+ for c in candidates:
619
+ try:
620
+ return _from_obj(json.loads(c))
621
+ except (json.JSONDecodeError, TypeError):
622
+ continue
623
+ raise FeelParseError("no valid JSON object in response")
624
+ ```
625
+
626
+ IMPORTANT: replace `<PASTE THE USER'S FULL RUBRIC PROMPT VERBATIM HERE>` with the exact rubric the user supplied (the `# Role ... Judge only the visual suitability` text). Do not paraphrase.
627
+
628
+ - [ ] **Step 4: Run tests**
629
+
630
+ Run: `python -m pytest video_library/tests/test_feel_scorer.py -q`
631
+ Expected: PASS (all 9).
632
+
633
+ - [ ] **Step 5: Sync mirror**
634
+
635
+ Run: `cp video_library/feel_scorer.py hf_space/video_library/feel_scorer.py && diff video_library/feel_scorer.py hf_space/video_library/feel_scorer.py && echo SYNC`
636
+ Expected: `SYNC`.
637
+
638
+ ---
639
+
640
+ ### Task 3: `feel_scores` table + store methods
641
+
642
+ Add the fresh table and its CRUD to both `InMemoryCatalogStore` and `NeonCatalogStore`.
643
+
644
+ **Files:**
645
+ - Modify: `video_library/catalog_store.py` (+ hf_space mirror)
646
+ - Test: `video_library/tests/test_catalog_store_feel.py`
647
+
648
+ **Interfaces:**
649
+ - Consumes: `FeelResult` from Task 2.
650
+ - Produces on both stores:
651
+ - `ensure_feel_schema() -> None`
652
+ - `load_for_feel_scoring(limit: int = 100) -> list[dict]` — mechanically-passed clip records NOT already in feel_scores.
653
+ - `set_feel_score(provider, provider_id, *, result: FeelResult, clip_url: str, now: str) -> None` — UPSERT.
654
+ - `load_feel_scores() -> list[dict]` — rows as dicts with keys: provider, provider_id, decision, score, camera_motion, subject_motion, visual_complexity, human_presence, mood, reason, clip_url, scored_at, scored_by.
655
+ - `feel_page(*, decision=None, min_score=0, limit=6, offset=0) -> list[dict]` — filtered, sorted score DESC.
656
+ - `feel_counts() -> dict` — `{"KEEP": n, "BORDERLINE": n, "REJECT": n, "total": n, "scored": n, "unscored": n}` where scored/unscored are over mechanically-passed clips.
657
+
658
+ - [ ] **Step 1: Write failing tests** — create `video_library/tests/test_catalog_store_feel.py`:
659
+
660
+ ```python
661
+ from video_library.catalog_store import InMemoryCatalogStore
662
+ from video_library.models import ClipRecord, Candidate, FileOption, Description, CheckResult
663
+ from video_library.feel_scorer import FeelResult
664
+
665
+
666
+ def _clip(pid, *, passed=True):
667
+ cand = Candidate(provider="pexels", provider_id=pid, page_url="p",
668
+ files=[FileOption("sd", 640, 360, 25.0, "http://x/" + pid)],
669
+ duration=10.0, width=640, height=360, fps=25.0,
670
+ creator_name="c", creator_url="u", license="pexels",
671
+ original_query="q")
672
+ return ClipRecord(candidate=cand,
673
+ description=Description(summary="s", environment="nature"),
674
+ mechanical_check=CheckResult(passed=passed),
675
+ analyzed_at="t" + pid).to_dict()
676
+
677
+
678
+ def _store(*clips):
679
+ s = InMemoryCatalogStore()
680
+ for c in clips:
681
+ s._records[(c["provider"], c["provider_id"])] = c
682
+ return s
683
+
684
+
685
+ def _res(decision="KEEP", score=88):
686
+ return FeelResult(decision=decision, score=score, reason="r",
687
+ camera_motion="slow", subject_motion="still",
688
+ visual_complexity="low", human_presence="none",
689
+ mood="calm", described_by="gemini-x")
690
+
691
+
692
+ def test_load_for_feel_scoring_only_passed_and_unscored():
693
+ s = _store(_clip("1"), _clip("2", passed=False))
694
+ got = s.load_for_feel_scoring()
695
+ assert [r["provider_id"] for r in got] == ["1"]
696
+
697
+
698
+ def test_set_and_load_feel_score_roundtrip():
699
+ s = _store(_clip("1"))
700
+ s.set_feel_score("pexels", "1", result=_res(), clip_url="http://c/1", now="t1")
701
+ rows = s.load_feel_scores()
702
+ assert len(rows) == 1
703
+ r = rows[0]
704
+ assert r["decision"] == "KEEP" and r["score"] == 88
705
+ assert r["scored_by"] == "gemini-x" and r["clip_url"] == "http://c/1"
706
+ assert r["human_presence"] == "none"
707
+
708
+
709
+ def test_scored_clip_excluded_from_next_load():
710
+ s = _store(_clip("1"), _clip("2"))
711
+ s.set_feel_score("pexels", "1", result=_res(), clip_url="u", now="t")
712
+ assert [r["provider_id"] for r in s.load_for_feel_scoring()] == ["2"]
713
+
714
+
715
+ def test_set_feel_score_upsert_overwrites():
716
+ s = _store(_clip("1"))
717
+ s.set_feel_score("pexels", "1", result=_res(score=50), clip_url="u", now="t1")
718
+ s.set_feel_score("pexels", "1", result=_res(score=95), clip_url="u", now="t2")
719
+ rows = s.load_feel_scores()
720
+ assert len(rows) == 1 and rows[0]["score"] == 95
721
+
722
+
723
+ def test_feel_page_sorts_desc_and_filters():
724
+ s = _store(_clip("1"), _clip("2"), _clip("3"))
725
+ s.set_feel_score("pexels", "1", result=_res("KEEP", 90), clip_url="u", now="t")
726
+ s.set_feel_score("pexels", "2", result=_res("REJECT", 30), clip_url="u", now="t")
727
+ s.set_feel_score("pexels", "3", result=_res("BORDERLINE", 70), clip_url="u", now="t")
728
+ ordered = [r["score"] for r in s.feel_page(limit=10)]
729
+ assert ordered == [90, 70, 30]
730
+ keeps = s.feel_page(decision="KEEP", limit=10)
731
+ assert [r["provider_id"] for r in keeps] == ["1"]
732
+ strong = [r["score"] for r in s.feel_page(min_score=60, limit=10)]
733
+ assert strong == [90, 70]
734
+
735
+
736
+ def test_feel_counts():
737
+ s = _store(_clip("1"), _clip("2"), _clip("3"))
738
+ s.set_feel_score("pexels", "1", result=_res("KEEP", 90), clip_url="u", now="t")
739
+ s.set_feel_score("pexels", "2", result=_res("REJECT", 30), clip_url="u", now="t")
740
+ c = s.feel_counts()
741
+ assert c["KEEP"] == 1 and c["REJECT"] == 1 and c["BORDERLINE"] == 0
742
+ assert c["total"] == 2 and c["scored"] == 2 and c["unscored"] == 1
743
+ ```
744
+
745
+ - [ ] **Step 2: Run to verify it fails**
746
+
747
+ Run: `python -m pytest video_library/tests/test_catalog_store_feel.py -q`
748
+ Expected: FAIL (`load_for_feel_scoring` missing).
749
+
750
+ - [ ] **Step 3a: Implement SQL constants** — in `catalog_store.py`, after the seed SQL block (near `SELECT_SEED`), add:
751
+
752
+ ```python
753
+ CREATE_FEEL_SCORES = """
754
+ CREATE TABLE IF NOT EXISTS feel_scores (
755
+ provider TEXT NOT NULL,
756
+ provider_id TEXT NOT NULL,
757
+ decision TEXT NOT NULL,
758
+ score INT NOT NULL DEFAULT 0,
759
+ camera_motion TEXT NOT NULL DEFAULT '',
760
+ subject_motion TEXT NOT NULL DEFAULT '',
761
+ visual_complexity TEXT NOT NULL DEFAULT '',
762
+ human_presence TEXT NOT NULL DEFAULT '',
763
+ mood TEXT NOT NULL DEFAULT '',
764
+ reason TEXT NOT NULL DEFAULT '',
765
+ clip_url TEXT NOT NULL DEFAULT '',
766
+ scored_at TEXT,
767
+ scored_by TEXT NOT NULL DEFAULT '',
768
+ PRIMARY KEY (provider, provider_id)
769
+ )
770
+ """
771
+
772
+ UPSERT_FEEL = """
773
+ INSERT INTO feel_scores (provider, provider_id, decision, score, camera_motion,
774
+ subject_motion, visual_complexity, human_presence, mood, reason, clip_url,
775
+ scored_at, scored_by)
776
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
777
+ ON CONFLICT (provider, provider_id) DO UPDATE SET
778
+ decision = EXCLUDED.decision, score = EXCLUDED.score,
779
+ camera_motion = EXCLUDED.camera_motion, subject_motion = EXCLUDED.subject_motion,
780
+ visual_complexity = EXCLUDED.visual_complexity,
781
+ human_presence = EXCLUDED.human_presence, mood = EXCLUDED.mood,
782
+ reason = EXCLUDED.reason, clip_url = EXCLUDED.clip_url,
783
+ scored_at = EXCLUDED.scored_at, scored_by = EXCLUDED.scored_by
784
+ """
785
+
786
+ SELECT_FEEL = """
787
+ SELECT provider, provider_id, decision, score, camera_motion, subject_motion,
788
+ visual_complexity, human_presence, mood, reason, clip_url, scored_at, scored_by
789
+ FROM feel_scores
790
+ ORDER BY score DESC, provider, provider_id
791
+ """
792
+
793
+ # Mechanically-passed clips with no row yet in feel_scores.
794
+ SELECT_FOR_FEEL = """
795
+ SELECT c.record FROM clips c
796
+ WHERE (c.record->'mechanical_check'->>'passed')::bool = true
797
+ AND NOT EXISTS (
798
+ SELECT 1 FROM feel_scores f
799
+ WHERE f.provider = c.provider AND f.provider_id = c.provider_id)
800
+ ORDER BY c.analyzed_at
801
+ LIMIT %s
802
+ """
803
+
804
+ COUNT_FEEL_PASSED = """
805
+ SELECT COUNT(*) FROM clips
806
+ WHERE (record->'mechanical_check'->>'passed')::bool = true
807
+ """
808
+ ```
809
+
810
+ - [ ] **Step 3b: Implement InMemory methods** — add to `InMemoryCatalogStore` (it already has `self._records`; add `self._feel = {}` in `__init__` alongside `self._seed`):
811
+
812
+ ```python
813
+ # --- Phase-4 feel scoring ---
814
+
815
+ def ensure_feel_schema(self) -> None:
816
+ if not hasattr(self, "_feel"):
817
+ self._feel = {}
818
+
819
+ def load_for_feel_scoring(self, limit: int = 100) -> list[dict]:
820
+ self.ensure_feel_schema()
821
+ out = []
822
+ for rec in self._records.values():
823
+ if not (rec.get("mechanical_check") or {}).get("passed"):
824
+ continue
825
+ if (rec.get("provider"), rec.get("provider_id")) in self._feel:
826
+ continue
827
+ out.append(rec)
828
+ if len(out) >= limit:
829
+ break
830
+ return out
831
+
832
+ def set_feel_score(self, provider, provider_id, *, result, clip_url, now) -> None:
833
+ self.ensure_feel_schema()
834
+ self._feel[(provider, provider_id)] = {
835
+ "provider": provider, "provider_id": provider_id,
836
+ "decision": result.decision, "score": int(result.score),
837
+ "camera_motion": result.camera_motion,
838
+ "subject_motion": result.subject_motion,
839
+ "visual_complexity": result.visual_complexity,
840
+ "human_presence": result.human_presence, "mood": result.mood,
841
+ "reason": result.reason, "clip_url": clip_url,
842
+ "scored_at": now, "scored_by": result.scored_by,
843
+ }
844
+
845
+ def load_feel_scores(self) -> list[dict]:
846
+ self.ensure_feel_schema()
847
+ rows = list(self._feel.values())
848
+ rows.sort(key=lambda r: (-int(r["score"]), r["provider"], r["provider_id"]))
849
+ return [dict(r) for r in rows]
850
+
851
+ def feel_page(self, *, decision=None, min_score=0, limit=6, offset=0) -> list[dict]:
852
+ rows = self.load_feel_scores()
853
+ if decision and decision != "all":
854
+ rows = [r for r in rows if r["decision"] == decision]
855
+ if min_score:
856
+ rows = [r for r in rows if int(r["score"]) >= int(min_score)]
857
+ return rows[offset:offset + limit]
858
+
859
+ def feel_counts(self) -> dict:
860
+ self.ensure_feel_schema()
861
+ c = {"KEEP": 0, "BORDERLINE": 0, "REJECT": 0, "total": 0,
862
+ "scored": 0, "unscored": 0}
863
+ for r in self._feel.values():
864
+ if r["decision"] in c:
865
+ c[r["decision"]] += 1
866
+ c["total"] += 1
867
+ passed = sum(1 for rec in self._records.values()
868
+ if (rec.get("mechanical_check") or {}).get("passed"))
869
+ c["scored"] = len(self._feel)
870
+ c["unscored"] = max(0, passed - len(self._feel))
871
+ return c
872
+ ```
873
+
874
+ Also add `self._feel = {}` to `InMemoryCatalogStore.__init__`.
875
+
876
+ - [ ] **Step 3c: Implement Neon methods + abstract stubs** — add abstract method declarations on `CatalogStore` (mirroring the seed section) for the six methods, then add to `NeonCatalogStore`:
877
+
878
+ ```python
879
+ # --- Phase-4 feel scoring ---
880
+
881
+ def ensure_feel_schema(self) -> None:
882
+ with self._connect(self._dsn) as conn:
883
+ with conn.cursor() as cur:
884
+ cur.execute(CREATE_FEEL_SCORES)
885
+ conn.commit()
886
+
887
+ def load_for_feel_scoring(self, limit: int = 100) -> list[dict]:
888
+ with self._connect(self._dsn) as conn:
889
+ with conn.cursor() as cur:
890
+ cur.execute(SELECT_FOR_FEEL, (limit,))
891
+ rows = cur.fetchall()
892
+ out = []
893
+ for row in rows:
894
+ rec = row[0]
895
+ if isinstance(rec, str):
896
+ rec = json.loads(rec)
897
+ out.append(rec)
898
+ return out
899
+
900
+ def set_feel_score(self, provider, provider_id, *, result, clip_url, now) -> None:
901
+ with self._connect(self._dsn) as conn:
902
+ with conn.cursor() as cur:
903
+ cur.execute(UPSERT_FEEL, (
904
+ provider, provider_id, result.decision, int(result.score),
905
+ result.camera_motion, result.subject_motion,
906
+ result.visual_complexity, result.human_presence, result.mood,
907
+ result.reason, clip_url, now, result.scored_by))
908
+ conn.commit()
909
+
910
+ def load_feel_scores(self) -> list[dict]:
911
+ cols = ("provider", "provider_id", "decision", "score", "camera_motion",
912
+ "subject_motion", "visual_complexity", "human_presence", "mood",
913
+ "reason", "clip_url", "scored_at", "scored_by")
914
+ with self._connect(self._dsn) as conn:
915
+ with conn.cursor() as cur:
916
+ cur.execute(SELECT_FEEL)
917
+ rows = cur.fetchall()
918
+ return [dict(zip(cols, row)) for row in rows]
919
+
920
+ def feel_page(self, *, decision=None, min_score=0, limit=6, offset=0) -> list[dict]:
921
+ rows = self.load_feel_scores()
922
+ if decision and decision != "all":
923
+ rows = [r for r in rows if r["decision"] == decision]
924
+ if min_score:
925
+ rows = [r for r in rows if int(r["score"]) >= int(min_score)]
926
+ return rows[offset:offset + limit]
927
+
928
+ def feel_counts(self) -> dict:
929
+ c = {"KEEP": 0, "BORDERLINE": 0, "REJECT": 0, "total": 0,
930
+ "scored": 0, "unscored": 0}
931
+ for r in self.load_feel_scores():
932
+ if r["decision"] in c:
933
+ c[r["decision"]] += 1
934
+ c["total"] += 1
935
+ with self._connect(self._dsn) as conn:
936
+ with conn.cursor() as cur:
937
+ cur.execute(COUNT_FEEL_PASSED)
938
+ passed = int((cur.fetchone() or [0])[0])
939
+ c["scored"] = c["total"]
940
+ c["unscored"] = max(0, passed - c["total"])
941
+ return c
942
+ ```
943
+
944
+ - [ ] **Step 4: Run tests**
945
+
946
+ Run: `python -m pytest video_library/tests/test_catalog_store_feel.py -q`
947
+ Expected: PASS (all 6).
948
+
949
+ - [ ] **Step 5: Sync mirror + full suite**
950
+
951
+ Run: `cp video_library/catalog_store.py hf_space/video_library/catalog_store.py && python -m pytest video_library/tests/ -q`
952
+ Expected: PASS (all).
953
+
954
+ ---
955
+
956
+ ### Task 4: `feel_score_runner.py` — the scoring loop
957
+
958
+ Orchestrate download → router.describe (with FEEL_PROMPT) → set_feel_score. Resumable, quota-aware, per-clip error skip-list.
959
+
960
+ **Files:**
961
+ - Create: `video_library/feel_score_runner.py` (+ hf_space mirror)
962
+ - Test: `video_library/tests/test_feel_score_runner.py`
963
+
964
+ **Interfaces:**
965
+ - Consumes: store methods (Task 3), `pick_stream_url` (curation_app), `download_to_temp`/`delete_quietly` (downloader), `NoModelAvailableError` (vision_describe).
966
+ - Produces: `run_feel_scoring(store, router, *, limit=10000, batch_size=100, tmp_dir, now=_now_iso, sleep=time.sleep, rpm_seconds=2.5, downloader=download_to_temp, deleter=delete_quietly, url_picker=pick_stream_url, quota_detector=is_quota_error) -> dict`. Summary keys: `processed, scored, skipped, errors, stopped_quota, by_decision`.
967
+
968
+ - [ ] **Step 1: Write failing tests** — create `video_library/tests/test_feel_score_runner.py`:
969
+
970
+ ```python
971
+ from video_library.catalog_store import InMemoryCatalogStore
972
+ from video_library.models import ClipRecord, Candidate, FileOption, Description, CheckResult
973
+ from video_library.feel_scorer import FeelResult
974
+ from video_library.feel_score_runner import run_feel_scoring
975
+ from video_library.vision_describe import NoModelAvailableError
976
+
977
+
978
+ def _clip(pid, *, passed=True):
979
+ cand = Candidate(provider="pexels", provider_id=pid, page_url="p",
980
+ files=[FileOption("sd", 640, 360, 25.0, "http://x/" + pid)],
981
+ duration=10.0, width=640, height=360, fps=25.0,
982
+ creator_name="c", creator_url="u", license="pexels",
983
+ original_query="q")
984
+ return ClipRecord(candidate=cand,
985
+ description=Description(summary="s", environment="nature"),
986
+ mechanical_check=CheckResult(passed=passed),
987
+ analyzed_at="t" + pid).to_dict()
988
+
989
+
990
+ def _store(*clips):
991
+ s = InMemoryCatalogStore()
992
+ for c in clips:
993
+ s._records[(c["provider"], c["provider_id"])] = c
994
+ return s
995
+
996
+
997
+ class _FakeRouter:
998
+ def __init__(self, *, by_url=None, raise_on=None):
999
+ self._by_url = by_url or {}
1000
+ self._raise_on = raise_on or {}
1001
+ self.calls = []
1002
+
1003
+ def describe(self, path):
1004
+ self.calls.append(path)
1005
+ if path in self._raise_on:
1006
+ raise self._raise_on[path]
1007
+ r = self._by_url.get(path)
1008
+ return r or FeelResult(decision="KEEP", score=80, described_by="fake")
1009
+
1010
+
1011
+ def _noop_dl(url, tmp, pid):
1012
+ return url # use the url itself as the "path" so router keys on it
1013
+
1014
+
1015
+ def _noop_del(path):
1016
+ pass
1017
+
1018
+
1019
+ def test_scores_all_passed_clips():
1020
+ s = _store(_clip("1"), _clip("2"), _clip("3", passed=False))
1021
+ router = _FakeRouter()
1022
+ summary = run_feel_scoring(s, router, tmp_dir="/tmp", sleep=lambda x: None,
1023
+ downloader=_noop_dl, deleter=_noop_del)
1024
+ assert summary["processed"] == 2
1025
+ assert summary["scored"] == 2
1026
+ assert summary["by_decision"]["KEEP"] == 2
1027
+ assert len(s.load_feel_scores()) == 2
1028
+
1029
+
1030
+ def test_skips_already_scored():
1031
+ s = _store(_clip("1"), _clip("2"))
1032
+ s.set_feel_score("pexels", "1",
1033
+ result=FeelResult(decision="KEEP", score=90), clip_url="u", now="t")
1034
+ router = _FakeRouter()
1035
+ summary = run_feel_scoring(s, router, tmp_dir="/tmp", sleep=lambda x: None,
1036
+ downloader=_noop_dl, deleter=_noop_del)
1037
+ assert summary["processed"] == 1
1038
+ assert [r["provider_id"] for r in s.load_feel_scores() if r["score"] == 90]
1039
+
1040
+
1041
+ def test_quota_stops_cleanly():
1042
+ s = _store(_clip("1"), _clip("2"))
1043
+ router = _FakeRouter(raise_on={"http://x/1": NoModelAvailableError("over cap")})
1044
+ summary = run_feel_scoring(s, router, tmp_dir="/tmp", sleep=lambda x: None,
1045
+ downloader=_noop_dl, deleter=_noop_del)
1046
+ assert summary["stopped_quota"] is True
1047
+ assert summary["scored"] == 0
1048
+
1049
+
1050
+ def test_per_clip_error_skips_and_continues():
1051
+ s = _store(_clip("1"), _clip("2"))
1052
+ router = _FakeRouter(raise_on={"http://x/1": RuntimeError("bad json")})
1053
+ summary = run_feel_scoring(s, router, tmp_dir="/tmp", sleep=lambda x: None,
1054
+ downloader=_noop_dl, deleter=_noop_del)
1055
+ assert summary["errors"] == 1
1056
+ assert summary["scored"] == 1
1057
+
1058
+
1059
+ def test_limit_caps_processing():
1060
+ s = _store(*[_clip(str(i)) for i in range(5)])
1061
+ router = _FakeRouter()
1062
+ summary = run_feel_scoring(s, router, tmp_dir="/tmp", limit=2,
1063
+ sleep=lambda x: None,
1064
+ downloader=_noop_dl, deleter=_noop_del)
1065
+ assert summary["processed"] == 2
1066
+ ```
1067
+
1068
+ - [ ] **Step 2: Run to verify it fails**
1069
+
1070
+ Run: `python -m pytest video_library/tests/test_feel_score_runner.py -q`
1071
+ Expected: FAIL (module not found).
1072
+
1073
+ - [ ] **Step 3: Implement** — create `video_library/feel_score_runner.py`:
1074
+
1075
+ ```python
1076
+ """Phase-4 orchestrator: score every mechanically-passed clip's FEEL.
1077
+
1078
+ Loop mirrors tag_runner / library_builder:
1079
+ 1. Load a batch of mechanically-passed, not-yet-scored clips.
1080
+ 2. Pick the smallest streamable file, download to temp.
1081
+ 3. router.describe(path) -> FeelResult (router carries FEEL_PROMPT).
1082
+ 4. store.set_feel_score(...); delete temp.
1083
+ 5. Sleep rpm_seconds.
1084
+ Resumable (each row written immediately) and quota-aware (NoModelAvailableError or
1085
+ a detected quota error stops the run cleanly; rerun to continue).
1086
+ """
1087
+ from __future__ import annotations
1088
+ import datetime as _dt
1089
+ import time
1090
+ from typing import Callable
1091
+
1092
+ from .downloader import download_to_temp, delete_quietly
1093
+ from .curation_app import pick_stream_url
1094
+ from .vision_describe import NoModelAvailableError, is_quota_error
1095
+
1096
+
1097
+ def _now_iso() -> str:
1098
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1099
+
1100
+
1101
+ def run_feel_scoring(store, router, *,
1102
+ limit: int = 10_000,
1103
+ batch_size: int = 100,
1104
+ tmp_dir,
1105
+ now: Callable[[], str] = _now_iso,
1106
+ sleep: Callable[[float], None] = time.sleep,
1107
+ rpm_seconds: float = 2.5,
1108
+ downloader=download_to_temp,
1109
+ deleter=delete_quietly,
1110
+ url_picker=pick_stream_url,
1111
+ quota_detector: Callable[[Exception], bool] = is_quota_error,
1112
+ ) -> dict:
1113
+ summary = {"processed": 0, "scored": 0, "skipped": 0, "errors": 0,
1114
+ "stopped_quota": False, "by_decision": {}}
1115
+ errored: set[tuple[str, str]] = set()
1116
+ remaining = limit
1117
+ while remaining > 0:
1118
+ batch = store.load_for_feel_scoring(limit=min(batch_size, remaining))
1119
+ batch = [r for r in batch
1120
+ if (r.get("provider"), r.get("provider_id")) not in errored]
1121
+ if not batch:
1122
+ break
1123
+ for rec in batch:
1124
+ if remaining <= 0:
1125
+ break
1126
+ provider = rec.get("provider")
1127
+ provider_id = rec.get("provider_id")
1128
+ summary["processed"] += 1
1129
+ remaining -= 1
1130
+
1131
+ url = url_picker(rec)
1132
+ if not url:
1133
+ summary["skipped"] += 1
1134
+ continue
1135
+
1136
+ temp_path = None
1137
+ try:
1138
+ temp_path = downloader(url, tmp_dir, provider_id)
1139
+ result = router.describe(str(temp_path))
1140
+ except NoModelAvailableError:
1141
+ summary["stopped_quota"] = True
1142
+ if temp_path is not None:
1143
+ deleter(temp_path)
1144
+ return summary
1145
+ except Exception as e:
1146
+ if temp_path is not None:
1147
+ deleter(temp_path)
1148
+ if quota_detector(e):
1149
+ summary["stopped_quota"] = True
1150
+ return summary
1151
+ summary["errors"] += 1
1152
+ errored.add((provider, provider_id))
1153
+ continue
1154
+ else:
1155
+ deleter(temp_path)
1156
+
1157
+ store.set_feel_score(provider, provider_id, result=result,
1158
+ clip_url=url, now=now())
1159
+ summary["scored"] += 1
1160
+ d = result.decision
1161
+ summary["by_decision"][d] = summary["by_decision"].get(d, 0) + 1
1162
+ sleep(rpm_seconds)
1163
+ return summary
1164
+ ```
1165
+
1166
+ Note: `_noop_dl` in the test returns the URL as the path, so the fake router keys its `raise_on`/`by_url` on `http://x/<id>`.
1167
+
1168
+ - [ ] **Step 4: Run tests**
1169
+
1170
+ Run: `python -m pytest video_library/tests/test_feel_score_runner.py -q`
1171
+ Expected: PASS (all 5).
1172
+
1173
+ - [ ] **Step 5: Sync mirror**
1174
+
1175
+ Run: `cp video_library/feel_score_runner.py hf_space/video_library/feel_score_runner.py && echo SYNC`
1176
+
1177
+ ---
1178
+
1179
+ ### Task 5: `feel_score_app.py` — UI card helpers + trigger glue
1180
+
1181
+ Split-out logic (testable without Gradio) + a background-thread trigger mirroring `seed_reason_app.py` + the Gradio results tab (built lazily).
1182
+
1183
+ **Files:**
1184
+ - Create: `video_library/feel_score_app.py` (+ hf_space mirror)
1185
+ - Test: `video_library/tests/test_feel_score_app.py`
1186
+
1187
+ **Interfaces:**
1188
+ - Consumes: store methods (Task 3), `run_feel_scoring` (Task 4).
1189
+ - Produces:
1190
+ - `feel_card(row: dict) -> dict` — `{video_url, score, decision, badge, caption}`.
1191
+ - `feel_caption(row: dict) -> str`.
1192
+ - `FEEL_SCORE_STATUS` dict + `run_feel_scorer(...)`, `format_feel_status(status)`, `start_feel_scorer_thread()`, `default_feel_components()`.
1193
+ - `build_feel_tab(store_factory, *, now)` — returns `{"init_fn", "init_inputs", "init_outputs"}`.
1194
+ - `FEEL_PAGE_SIZE = 6`, `DECISION_CHOICES = ["all", "KEEP", "BORDERLINE", "REJECT"]`.
1195
+
1196
+ - [ ] **Step 1: Write failing tests** — create `video_library/tests/test_feel_score_app.py`:
1197
+
1198
+ ```python
1199
+ from video_library.feel_score_app import (
1200
+ feel_card, feel_caption, format_feel_status, FEEL_PAGE_SIZE, DECISION_CHOICES,
1201
+ )
1202
+
1203
+
1204
+ def _row(**kw):
1205
+ base = {"provider": "pexels", "provider_id": "1", "decision": "KEEP",
1206
+ "score": 88, "camera_motion": "slow drone", "subject_motion": "still",
1207
+ "visual_complexity": "low", "human_presence": "none", "mood": "calm",
1208
+ "reason": "calm open nature", "clip_url": "http://c/1",
1209
+ "scored_at": "t", "scored_by": "gemini-x"}
1210
+ base.update(kw)
1211
+ return base
1212
+
1213
+
1214
+ def test_feel_card_shape():
1215
+ c = feel_card(_row())
1216
+ assert c["video_url"] == "http://c/1"
1217
+ assert c["score"] == 88
1218
+ assert c["decision"] == "KEEP"
1219
+ assert "88" in c["caption"]
1220
+
1221
+
1222
+ def test_feel_caption_includes_reason_and_decision():
1223
+ cap = feel_caption(_row(reason="busy motion", decision="REJECT", score=30))
1224
+ assert "busy motion" in cap
1225
+ assert "REJECT" in cap
1226
+ assert "30" in cap
1227
+
1228
+
1229
+ def test_page_size_is_crash_safe():
1230
+ assert FEEL_PAGE_SIZE <= 6
1231
+
1232
+
1233
+ def test_decision_choices():
1234
+ assert DECISION_CHOICES[0] == "all"
1235
+ assert "KEEP" in DECISION_CHOICES
1236
+
1237
+
1238
+ def test_format_feel_status_running():
1239
+ s = format_feel_status({"state": "running", "summary": None, "error": None})
1240
+ assert "running" in s
1241
+
1242
+
1243
+ def test_format_feel_status_summary():
1244
+ s = format_feel_status({"state": "done", "error": None,
1245
+ "summary": {"processed": 10, "scored": 8, "skipped": 1, "errors": 1,
1246
+ "stopped_quota": False, "by_decision": {"KEEP": 5}}})
1247
+ assert "scored 8" in s
1248
+ ```
1249
+
1250
+ - [ ] **Step 2: Run to verify it fails**
1251
+
1252
+ Run: `python -m pytest video_library/tests/test_feel_score_app.py -q`
1253
+ Expected: FAIL (module not found).
1254
+
1255
+ - [ ] **Step 3: Implement** — create `video_library/feel_score_app.py`. The pure helpers + trigger glue first (Gradio import is lazy inside `build_feel_tab`):
1256
+
1257
+ ```python
1258
+ """Phase-4 feel-scorer glue: pure card/status helpers, a background-thread trigger
1259
+ (mirrors seed_reason_app), and the Gradio 'Feel Score' results tab.
1260
+
1261
+ Crash safety carried from the curation tab: video players autoplay=False, loop=True,
1262
+ page size 6, browsing repaints a small page, guarded render (no 500 on cold DB).
1263
+ """
1264
+ from __future__ import annotations
1265
+ import threading
1266
+ import traceback
1267
+
1268
+ from . import config
1269
+ from .feel_score_runner import run_feel_scoring
1270
+ from .__main__ import _now_iso
1271
+
1272
+ FEEL_PAGE_SIZE = 6
1273
+ DECISION_CHOICES = ["all", "KEEP", "BORDERLINE", "REJECT"]
1274
+
1275
+ _BADGE = {"KEEP": "✅ KEEP", "BORDERLINE": "🟡 BORDERLINE", "REJECT": "❌ REJECT"}
1276
+
1277
+
1278
+ def feel_caption(row: dict) -> str:
1279
+ dec = row.get("decision", "")
1280
+ badge = _BADGE.get(dec, dec)
1281
+ parts = [f"**{badge} · {row.get('score', 0)}/100**"]
1282
+ meta = []
1283
+ if row.get("mood"):
1284
+ meta.append(row["mood"])
1285
+ if row.get("camera_motion"):
1286
+ meta.append("cam: " + row["camera_motion"])
1287
+ if row.get("subject_motion"):
1288
+ meta.append("subj: " + row["subject_motion"])
1289
+ if meta:
1290
+ parts.append(" · ".join(meta))
1291
+ if row.get("reason"):
1292
+ parts.append("_" + row["reason"] + "_")
1293
+ return " \n".join(parts)
1294
+
1295
+
1296
+ def feel_card(row: dict) -> dict:
1297
+ return {
1298
+ "video_url": row.get("clip_url", ""),
1299
+ "score": int(row.get("score", 0) or 0),
1300
+ "decision": row.get("decision", ""),
1301
+ "badge": _BADGE.get(row.get("decision", ""), row.get("decision", "")),
1302
+ "caption": feel_caption(row),
1303
+ }
1304
+
1305
+
1306
+ # --- background-thread trigger (mirrors seed_reason_app) ---
1307
+
1308
+ FEEL_SCORE_STATUS = {"state": "idle", "summary": None, "error": None}
1309
+
1310
+
1311
+ def default_feel_components():
1312
+ """Build the Neon store (feel schema ensured) + a FEEL_PROMPT vision router."""
1313
+ from .catalog_store import NeonCatalogStore
1314
+ from .vision_describe import GenAIBackend, VisionRouter
1315
+ from .feel_scorer import FEEL_PROMPT, parse_feel_json
1316
+ from .__main__ import _build_model_configs, _utc_date
1317
+
1318
+ store = NeonCatalogStore(config.NEON_DATABASE_URL)
1319
+ store.ensure_schema()
1320
+ store.ensure_feel_schema()
1321
+ router = VisionRouter(
1322
+ _build_model_configs(), store,
1323
+ backend_factory=lambda cfg: GenAIBackend(
1324
+ cfg.api_key or config.GEMINI_API_KEY, cfg.model_id,
1325
+ strip_audio=cfg.strip_audio,
1326
+ prompt=FEEL_PROMPT, parser=parse_feel_json),
1327
+ today=_utc_date,
1328
+ )
1329
+ return store, router
1330
+
1331
+
1332
+ def run_feel_scorer(*, components_factory=default_feel_components,
1333
+ runner=run_feel_scoring, limit=10000):
1334
+ if not config.NEON_DATABASE_URL:
1335
+ FEEL_SCORE_STATUS.update(state="missing-key", error="set NEON_DATABASE_URL")
1336
+ return FEEL_SCORE_STATUS
1337
+ if not config.GEMINI_API_KEY:
1338
+ FEEL_SCORE_STATUS.update(state="missing-key", error="set GEMINI_API_KEY")
1339
+ return FEEL_SCORE_STATUS
1340
+ FEEL_SCORE_STATUS.update(state="running", error=None, summary=None)
1341
+ try:
1342
+ store, router = components_factory()
1343
+ summary = runner(store, router, limit=limit, tmp_dir=config.TMP_DIR,
1344
+ now=_now_iso)
1345
+ FEEL_SCORE_STATUS.update(state="done", summary=summary)
1346
+ except Exception as e:
1347
+ FEEL_SCORE_STATUS.update(
1348
+ state="error", error=repr(e) + "\n" + traceback.format_exc())
1349
+ return FEEL_SCORE_STATUS
1350
+
1351
+
1352
+ def format_feel_status(status) -> str:
1353
+ lines = ["Feel scorer: " + str(status.get("state"))]
1354
+ summary = status.get("summary")
1355
+ if summary:
1356
+ lines.append(
1357
+ "processed {processed} | scored {scored} | "
1358
+ "skipped {skipped} | errors {errors}".format(**summary))
1359
+ if summary.get("by_decision"):
1360
+ lines.append("decisions: " + ", ".join(
1361
+ f"{k} {v}" for k, v in summary["by_decision"].items()))
1362
+ if summary.get("stopped_quota"):
1363
+ lines.append("Stopped: Gemini quota hit; resume later.")
1364
+ if status.get("error"):
1365
+ lines.append("Error: " + str(status["error"]).splitlines()[0])
1366
+ return "\n".join(lines)
1367
+
1368
+
1369
+ def start_feel_scorer_thread():
1370
+ t = threading.Thread(target=run_feel_scorer, name="feel-scorer-loop", daemon=True)
1371
+ t.start()
1372
+ return t
1373
+ ```
1374
+
1375
+ Then append the Gradio tab builder in the same file (mirrors curation_app's snapshot/paging, but read-only — no verdict buttons):
1376
+
1377
+ ```python
1378
+ def build_feel_tab(store_factory, *, now):
1379
+ """Construct the 'Feel Score' results tab. Imported lazily by app.py."""
1380
+ import gradio as gr
1381
+
1382
+ session = {"rows": None, "decision": "all", "min_score": 0, "page": 1}
1383
+
1384
+ gr.Markdown(
1385
+ "### Feel Score\nGemini watched every clip and scored how well it works as a "
1386
+ "quiet background for narration. Sorted best-first. Filter by decision or "
1387
+ "minimum score, then click a clip to play it.")
1388
+
1389
+ with gr.Row():
1390
+ dec_dd = gr.Dropdown(DECISION_CHOICES, value="all", label="Decision")
1391
+ min_slider = gr.Slider(0, 100, value=0, step=5, label="Min score")
1392
+ header = gr.Markdown("")
1393
+ page_label = gr.Markdown("")
1394
+
1395
+ slots = []
1396
+ with gr.Column():
1397
+ for _ in range(FEEL_PAGE_SIZE):
1398
+ with gr.Row(visible=False) as row:
1399
+ player = gr.Video(interactive=False, height=260, show_label=False,
1400
+ autoplay=False, loop=True)
1401
+ caption = gr.Markdown("")
1402
+ slots.append({"row": row, "player": player, "caption": caption})
1403
+
1404
+ with gr.Row():
1405
+ prev_btn = gr.Button("← Prev")
1406
+ next_btn = gr.Button("Next →")
1407
+
1408
+ def _load_rows():
1409
+ if session["rows"] is None:
1410
+ session["rows"] = store_factory().load_feel_scores()
1411
+ return session["rows"]
1412
+
1413
+ def _filtered():
1414
+ rows = _load_rows()
1415
+ dec, ms = session["decision"], session["min_score"]
1416
+ if dec and dec != "all":
1417
+ rows = [r for r in rows if r["decision"] == dec]
1418
+ if ms:
1419
+ rows = [r for r in rows if int(r["score"]) >= int(ms)]
1420
+ return rows
1421
+
1422
+ def _outputs():
1423
+ out = [header, page_label]
1424
+ for slot in slots:
1425
+ out += [slot["row"], slot["player"], slot["caption"]]
1426
+ return out
1427
+
1428
+ def _render():
1429
+ view = _filtered()
1430
+ pages = max(1, (len(view) + FEEL_PAGE_SIZE - 1) // FEEL_PAGE_SIZE)
1431
+ session["page"] = max(1, min(session["page"], pages))
1432
+ off = (session["page"] - 1) * FEEL_PAGE_SIZE
1433
+ clips = view[off:off + FEEL_PAGE_SIZE]
1434
+ counts = store_factory().feel_counts() if session["rows"] is None else None
1435
+ hdr = (f"{len(view)} clips shown · {len(_load_rows())} scored total")
1436
+ updates = [gr.update(value=hdr),
1437
+ gr.update(value=f"Page {session['page']} / {pages}")]
1438
+ for i, slot in enumerate(slots):
1439
+ if i < len(clips):
1440
+ card = feel_card(clips[i])
1441
+ updates += [gr.update(visible=True),
1442
+ gr.update(value=card["video_url"]),
1443
+ gr.update(value=card["caption"])]
1444
+ else:
1445
+ updates += [gr.update(visible=False), gr.update(value=None),
1446
+ gr.update(value="")]
1447
+ return updates
1448
+
1449
+ def _error_render(exc):
1450
+ updates = [gr.update(value=f"⚠️ Could not load scores: {exc}"),
1451
+ gr.update(value="")]
1452
+ for _ in slots:
1453
+ updates += [gr.update(visible=False), gr.update(value=None),
1454
+ gr.update(value="")]
1455
+ return updates
1456
+
1457
+ def _on_filter(dec, ms):
1458
+ session["decision"], session["min_score"], session["page"] = dec, ms, 1
1459
+ try:
1460
+ return _render()
1461
+ except Exception as exc:
1462
+ return _error_render(exc)
1463
+
1464
+ def _on_page(delta):
1465
+ session["page"] = max(1, session["page"] + delta)
1466
+ try:
1467
+ return _render()
1468
+ except Exception as exc:
1469
+ return _error_render(exc)
1470
+
1471
+ def _on_init():
1472
+ try:
1473
+ return _render()
1474
+ except Exception as exc:
1475
+ return _error_render(exc)
1476
+
1477
+ full_out = _outputs()
1478
+ dec_dd.change(_on_filter, [dec_dd, min_slider], full_out)
1479
+ min_slider.release(_on_filter, [dec_dd, min_slider], full_out)
1480
+ prev_btn.click(lambda: _on_page(-1), None, full_out)
1481
+ next_btn.click(lambda: _on_page(1), None, full_out)
1482
+
1483
+ return {"init_fn": _on_init, "init_inputs": None, "init_outputs": _outputs()}
1484
+ ```
1485
+
1486
+ Note: the unused `counts` line is dead — remove it; keep `hdr` simple as shown. (Self-review fix applied: drop the `counts = ...` line.)
1487
+
1488
+ - [ ] **Step 4: Run tests**
1489
+
1490
+ Run: `python -m pytest video_library/tests/test_feel_score_app.py -q`
1491
+ Expected: PASS (all 6).
1492
+
1493
+ - [ ] **Step 5: Sync mirror**
1494
+
1495
+ Run: `cp video_library/feel_score_app.py hf_space/video_library/feel_score_app.py && echo SYNC`
1496
+
1497
+ ---
1498
+
1499
+ ### Task 6: Wire the tab into `app.py` + CLI flag
1500
+
1501
+ Add the "Feel Score" tab (button + status + results grid) to the Gradio app, and a `--feel-score` CLI entry.
1502
+
1503
+ **Files:**
1504
+ - Modify: `video_library/app.py` (+ hf_space mirror)
1505
+ - Modify: `video_library/__main__.py` (+ hf_space mirror)
1506
+ - Test: `video_library/tests/test_cli.py` (append)
1507
+
1508
+ **Interfaces:**
1509
+ - Consumes: `build_feel_tab`, `FEEL_SCORE_STATUS`, `run_feel_scorer`, `format_feel_status`, `start_feel_scorer_thread` from Task 5.
1510
+
1511
+ - [ ] **Step 1: Write failing CLI test** — append to `video_library/tests/test_cli.py`:
1512
+
1513
+ ```python
1514
+ def test_feel_score_flag_dispatches(monkeypatch):
1515
+ import video_library.__main__ as m
1516
+ called = {}
1517
+
1518
+ def _fake_run(limit):
1519
+ called["limit"] = limit
1520
+ return 0
1521
+
1522
+ monkeypatch.setattr(m, "_run_feel_score", _fake_run)
1523
+ rc = m.main(["--feel-score", "--limit", "7"])
1524
+ assert rc == 0
1525
+ assert called["limit"] == 7
1526
+ ```
1527
+
1528
+ - [ ] **Step 2: Run to verify it fails**
1529
+
1530
+ Run: `python -m pytest video_library/tests/test_cli.py::test_feel_score_flag_dispatches -q`
1531
+ Expected: FAIL (`_run_feel_score` missing / flag unknown).
1532
+
1533
+ - [ ] **Step 3a: Implement CLI** — in `__main__.py` add the arg in `build_arg_parser`:
1534
+
1535
+ ```python
1536
+ p.add_argument("--feel-score", action="store_true",
1537
+ help="Phase-4: Gemini re-watches clips and scores their feel, "
1538
+ "then exit")
1539
+ ```
1540
+
1541
+ Add the entry function:
1542
+
1543
+ ```python
1544
+ def _run_feel_score(limit):
1545
+ """Phase-4 entry: score clip feel via Gemini. Returns exit code."""
1546
+ if not config.NEON_DATABASE_URL:
1547
+ print("ERROR: set NEON_DATABASE_URL", file=sys.stderr)
1548
+ return 2
1549
+ if not config.GEMINI_API_KEY:
1550
+ print("ERROR: set GEMINI_API_KEY", file=sys.stderr)
1551
+ return 2
1552
+ from .feel_score_app import default_feel_components
1553
+ from .feel_score_runner import run_feel_scoring
1554
+ store, router = default_feel_components()
1555
+ summary = run_feel_scoring(store, router, limit=limit, tmp_dir=config.TMP_DIR,
1556
+ now=_now_iso)
1557
+ print("Feel-scored:", summary)
1558
+ return 0
1559
+ ```
1560
+
1561
+ Wire dispatch in `main()` after the `--seed-reason` block:
1562
+
1563
+ ```python
1564
+ if args.feel_score:
1565
+ return _run_feel_score(args.limit)
1566
+ ```
1567
+
1568
+ - [ ] **Step 3b: Implement app.py tab** — add import near the other tab imports:
1569
+
1570
+ ```python
1571
+ from .feel_score_app import (
1572
+ FEEL_SCORE_STATUS, run_feel_scorer, format_feel_status,
1573
+ start_feel_scorer_thread, build_feel_tab,
1574
+ )
1575
+ ```
1576
+
1577
+ In `build_ui`, after the Seed Reasons tab block, add a new tab (reuse the cached curation store factory `_curation_store` / `_seed_store` pattern already in the file — add a `_feel_store` that ensures the feel schema once):
1578
+
1579
+ ```python
1580
+ with gr.Tab("Feel Score"):
1581
+ _feel_state = {"store": None}
1582
+
1583
+ def _feel_store():
1584
+ if _feel_state["store"] is None:
1585
+ store = _curation_store()
1586
+ store.ensure_feel_schema()
1587
+ _feel_state["store"] = store
1588
+ return _feel_state["store"]
1589
+
1590
+ feel_status_box = gr.Textbox(
1591
+ label="Gemini feel scorer",
1592
+ value=format_feel_status(FEEL_SCORE_STATUS),
1593
+ lines=4, interactive=False)
1594
+ with gr.Row():
1595
+ def _start_feel():
1596
+ if FEEL_SCORE_STATUS.get("state") != "running":
1597
+ start_feel_scorer_thread()
1598
+ return format_feel_status(FEEL_SCORE_STATUS)
1599
+
1600
+ gr.Button("Score clips (Gemini)").click(
1601
+ _start_feel, outputs=feel_status_box)
1602
+ gr.Button("Refresh scorer").click(
1603
+ lambda: format_feel_status(FEEL_SCORE_STATUS),
1604
+ outputs=feel_status_box)
1605
+
1606
+ feel_init = build_feel_tab(_feel_store, now=_now_iso)
1607
+ demo.load(feel_init["init_fn"], feel_init["init_inputs"],
1608
+ feel_init["init_outputs"])
1609
+ ```
1610
+
1611
+ If `_curation_store` isn't the exact name in the file, use whatever cached store factory the curation/seed tabs already share (verify by reading `app.py:build_ui` first).
1612
+
1613
+ - [ ] **Step 4: Run tests + import-sanity**
1614
+
1615
+ Run: `python -m pytest video_library/tests/test_cli.py -q`
1616
+ Expected: PASS.
1617
+
1618
+ Run: `python -c "import video_library.app"` (from repo root)
1619
+ Expected: no error (gradio import ok) — if gradio unavailable in dev, skip; the tab body is lazy.
1620
+
1621
+ - [ ] **Step 5: Sync mirrors + full suite**
1622
+
1623
+ Run: `cp video_library/__main__.py hf_space/video_library/__main__.py && cp video_library/app.py hf_space/video_library/app.py && python -m pytest video_library/tests/ -q`
1624
+ Expected: PASS (all).
1625
+
1626
+ ---
1627
+
1628
+ ### Task 7: End-to-end sanity + tree-sync verification
1629
+
1630
+ Final guard: confirm both trees are byte-identical for every touched file and the whole suite is green.
1631
+
1632
+ **Files:** none (verification only).
1633
+
1634
+ - [ ] **Step 1: Verify tree sync**
1635
+
1636
+ Run:
1637
+ ```bash
1638
+ for f in vision_describe.py feel_scorer.py catalog_store.py feel_score_runner.py feel_score_app.py __main__.py app.py; do
1639
+ diff -q "video_library/$f" "hf_space/video_library/$f" && echo "SYNC $f" || echo "DIFF $f";
1640
+ done
1641
+ ```
1642
+ Expected: `SYNC` for all 7.
1643
+
1644
+ - [ ] **Step 2: Full test suite**
1645
+
1646
+ Run: `python -m pytest video_library/tests/ -q`
1647
+ Expected: PASS (prior count + ~26 new tests).
1648
+
1649
+ - [ ] **Step 3: Confirm FEEL_PROMPT is verbatim**
1650
+
1651
+ Manually open `video_library/feel_scorer.py` and confirm `FEEL_PROMPT` is the user's exact rubric (role, core principle, camera-vs-subject distinction, desired style, scoring rubric out of 100, decision bands, JSON output format). No paraphrasing.
1652
+
1653
+ - [ ] **Step 4: Deploy note**
1654
+
1655
+ The HF Space runs `python -m video_library.app`. On next Space restart the "Feel Score" tab appears; clicking "Score clips (Gemini)" starts the background pass (resumable, quota-aware). `feel_scores` table is created on first tab load. User runs the actual scoring on the Space (fast internet, Gemini quota).
1656
+
1657
+ ---
1658
+
1659
+ ## Self-Review
1660
+
1661
+ **Spec coverage:**
1662
+ - Output score/100 + decision + full feel schema → Task 2 (`FeelResult`, `parse_feel_json`) + verbatim `FEEL_PROMPT`. ✓
1663
+ - Which clips = mechanically-passed, ignore hand-decisions → Task 3 `load_for_feel_scoring` (filters on `mechanical_check.passed`, not curation). ✓
1664
+ - Fresh `feel_scores` table, clips untouched, store all incl REJECT → Task 3. ✓
1665
+ - Resumable + quota-aware → Task 4 (`stopped_quota`, skip already-scored). ✓
1666
+ - UI: trigger + results grid, crash-safe → Tasks 5-6 (autoplay off, loop on, page 6, guarded render). ✓
1667
+ - No few-shot seed injection → confirmed; prompt is sole taste source (Task 2). ✓
1668
+ - Two-tree sync → every non-test task step 5 + Task 7. ✓
1669
+
1670
+ **Placeholder scan:** `FEEL_PROMPT` in Task 2 Step 3 now holds the user's full rubric verbatim (no placeholder). No TBDs remain.
1671
+
1672
+ **Type consistency:** `FeelResult` fields identical across Tasks 2/3/4/5. `run_feel_scoring` signature matches its test and the CLI/app callers. `set_feel_score(provider, provider_id, *, result, clip_url, now)` consistent Task 3 ↔ 4. `build_feel_tab(store_factory, *, now) -> {"init_fn","init_inputs","init_outputs"}` matches curation_app convention used in app.py wiring.
docs/superpowers/specs/2026-07-20-feel-scorer-design.md ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Feel Scorer (Phase 4) — Design
2
+
3
+ **Date:** 2026-07-20
4
+ **Status:** Approved for planning
5
+
6
+ ## Goal
7
+
8
+ Gemini re-watches every mechanically-passed clip and judges its *feel* — whether
9
+ it can sit quietly under contemplative Osho narration without competing for
10
+ attention — producing a fresh, ready-to-use, score-ranked shortlist of good clips.
11
+
12
+ This replaces hand-curating ~1200 clips. The problem it fixes: the Phase-1 vision
13
+ pass (`VISION_PROMPT`) deliberately forbids feel-judgment ("Do NOT guess a mood,
14
+ emotion, theme, or meaning"), so the catalog is *feel-blind*. This pass gives
15
+ Gemini eyes on the pixels plus a precise taste spec, and writes the verdict to a
16
+ new table.
17
+
18
+ ## Scope decisions (locked)
19
+
20
+ 1. **Output per clip:** score /100 + KEEP/BORDERLINE/REJECT + full feel schema
21
+ (`camera_motion`, `subject_motion`, `visual_complexity`, `human_presence`,
22
+ `mood`, `reason`). Exactly the user-supplied rubric prompt (verbatim, below).
23
+ 2. **Which clips:** all mechanically-passed clips (Phase-1 `mechanical_check.passed`
24
+ == true). Ignore whether the user hand-decided them in Phase 3 — Gemini's
25
+ judgment with this prompt supersedes the rushed hand-tags. Resumable + quota-aware.
26
+ 3. **Storage:** fresh dedicated `feel_scores` table. `clips` table untouched.
27
+ Store *every* scored clip (including REJECTs); filter at query time.
28
+ 4. **UI:** new "Feel Score" tab — trigger button with live progress + a results
29
+ grid sorted best-first. Carries all crash-safety guards from the curation tab.
30
+ 5. **No few-shot seed injection.** The taste lives entirely in the prompt. The
31
+ Phase-3.5 seed set is NOT used as calibration examples (hand-tags are noisy).
32
+
33
+ ## The taste prompt (authoritative — used verbatim)
34
+
35
+ The full rubric prompt supplied by the user is the single source of taste truth.
36
+ It is stored as `FEEL_PROMPT` in the new backend module. Key contract points the
37
+ rest of the design depends on:
38
+
39
+ - Judges *visual suitability as a quiet background*, never philosophical content.
40
+ - Camera motion good (slow reveal) vs subject motion bad (in-frame distraction) —
41
+ judged separately.
42
+ - Scoring rubric out of **100**: Subject Motion 35, Camera Movement 20, Visual
43
+ Simplicity 20, Mood & Atmosphere 15, Human Presence 10.
44
+ - Decision bands: **KEEP 80–100**, **BORDERLINE 60–79**, **REJECT <60**.
45
+ - Output is strict JSON:
46
+ ```json
47
+ {
48
+ "decision": "KEEP | BORDERLINE | REJECT",
49
+ "score": 0,
50
+ "camera_motion": "",
51
+ "subject_motion": "",
52
+ "visual_complexity": "low | medium | high",
53
+ "human_presence": "none | distant | prominent",
54
+ "mood": "",
55
+ "reason": ""
56
+ }
57
+ ```
58
+
59
+ The exact prompt text is pasted into the code as a module constant; this doc does
60
+ not re-transcribe it to avoid drift — the code is canonical.
61
+
62
+ ## Architecture
63
+
64
+ Mirrors the existing Phase-1 vision pipeline. Reuses, does not reinvent:
65
+
66
+ - **`downloader.download_to_temp` / `delete_quietly`** — stream clip to temp, delete
67
+ after. No bytes persist.
68
+ - **`VisionRouter`** — model selection, Gemini→Gemma fallback, RPD/RPM accounting,
69
+ quota exhaustion. **Reused as-is.**
70
+ - **Buffer/snapshot/paging patterns** from `curation_app.py` for the results grid.
71
+
72
+ New components:
73
+
74
+ ### 1. `feel_scorer.py` — the vision judge (backend)
75
+ - `FEEL_PROMPT` — the user's rubric, verbatim.
76
+ - `FeelResult` dataclass: `decision, score, camera_motion, subject_motion,
77
+ visual_complexity, human_presence, mood, reason, scored_by`.
78
+ - `parse_feel_json(text) -> FeelResult` — extract first valid JSON object (same
79
+ fence/brace strategy as `parse_vision_json`), validate `decision` ∈
80
+ {KEEP,BORDERLINE,REJECT} and coerce `score` to int 0–100.
81
+ - A thin describe-with-feel-prompt path. **Design choice:** rather than duplicate
82
+ `GenAIBackend`, add an optional `prompt` override so the router can be pointed at
83
+ `FEEL_PROMPT` instead of `VISION_PROMPT`. The router/backend stay generic; the
84
+ prompt is injected. (Backend already stamps `described_by` → reused as `scored_by`.)
85
+
86
+ Concretely: `GenAIBackend.__init__` gains `prompt: str = VISION_PROMPT` and
87
+ `parser=parse_vision_json`. Phase-1 callers keep the defaults (no behavior change);
88
+ the feel pass constructs backends with `prompt=FEEL_PROMPT, parser=parse_feel_json`.
89
+ This keeps `VisionRouter` untouched.
90
+
91
+ ### 2. `feel_score_runner.py` — the orchestrator loop
92
+ `run_feel_scoring(store, router, *, limit, tmp_dir, now, sleep, rpm_seconds,
93
+ downloader, file_picker, overwrite=False) -> summary`
94
+
95
+ Per clip (loop mirrors `tag_runner.run_tagging` + `library_builder.analyze_candidate`):
96
+ 1. Load a batch of mechanically-passed clips from the store via a new
97
+ `load_for_feel_scoring(limit)` — clips with `mechanical_check.passed == true`,
98
+ skipping those already in `feel_scores` (unless `overwrite`).
99
+ 2. Pick the smallest streamable file ≥360p via `pick_stream_url` (reused from
100
+ curation_app) — same clip URL the UI will show.
101
+ 3. `download_to_temp` → `router.describe(path)` (router now carries FEEL_PROMPT) →
102
+ `delete_quietly`.
103
+ 4. `store.set_feel_score(provider, provider_id, result=..., clip_url=..., now=...)`.
104
+ 5. Sleep `rpm_seconds`.
105
+ 6. On `NoModelAvailableError` / quota → stop gracefully, `stopped_quota=True`.
106
+ On other errors → count, skip clip, continue (per-run skip list, no infinite retry).
107
+
108
+ Summary: `{processed, scored, skipped, errors, stopped_quota, by_decision}`.
109
+
110
+ ### 3. `catalog_store.py` additions (both InMem + Neon)
111
+ - `CREATE_FEEL_SCORES` DDL: `feel_scores(provider TEXT, provider_id TEXT, decision
112
+ TEXT, score INT, camera_motion TEXT, subject_motion TEXT, visual_complexity TEXT,
113
+ human_presence TEXT, mood TEXT, reason TEXT, clip_url TEXT, scored_at TEXT,
114
+ scored_by TEXT, PRIMARY KEY (provider, provider_id))`.
115
+ - `ensure_feel_schema()` — create table if not exists (called once via cached store).
116
+ - `load_for_feel_scoring(limit)` — mechanically-passed clips not yet in feel_scores.
117
+ - `set_feel_score(provider, provider_id, *, result, clip_url, now)` — UPSERT one row.
118
+ - `feel_page(page, page_size, *, decision_filter, min_score)` — paged results
119
+ sorted by score DESC for the grid.
120
+ - `feel_counts()` — `{KEEP, BORDERLINE, REJECT, total}` for the header.
121
+
122
+ ### 4. `feel_score_app.py` — HF Space UI tab
123
+ Two parts in one tab, mirroring existing tabs:
124
+ - **Trigger:** "Score clips" button → background thread runs `run_feel_scoring`,
125
+ status line shows live progress (mirrors `tag_app.py` / `seed_reason_app.py`).
126
+ - **Results grid:** snapshot-backed, paged (PAGE_SIZE = 6). Each card: video player
127
+ (`autoplay=False`, `loop=True`, `height≈260`), score, decision, and reason.
128
+ Sorted best-first. Filter control: KEEP only (default) / KEEP+BORDERLINE / all.
129
+
130
+ **Crash-safety (carried from curation tab, non-negotiable):** autoplay OFF, small
131
+ page size, no violent re-render, snapshot loaded once into RAM, guarded `_render`
132
+ that catches exceptions and surfaces them in the header instead of 500-ing.
133
+
134
+ ### 5. `app.py` — register the tab
135
+ Add "Feel Score" (Phase 4) tab. Store cached via existing closure dict; call
136
+ `ensure_feel_schema()` once at build.
137
+
138
+ ### 6. `__main__.py` — CLI
139
+ Add `--feel-score` flag (+ `--overwrite` to re-score existing rows) mirroring
140
+ `--tag` / `--seed-reason`.
141
+
142
+ ## Data flow
143
+
144
+ ```
145
+ mechanically-passed clip
146
+ -> load_for_feel_scoring (skip already-scored)
147
+ -> pick_stream_url (smallest >=360p)
148
+ -> download_to_temp
149
+ -> VisionRouter.describe [FEEL_PROMPT, Gemini->Gemma fallback, RPD/RPM]
150
+ -> parse_feel_json -> FeelResult
151
+ -> set_feel_score [UPSERT into feel_scores]
152
+ -> delete_quietly
153
+ (sleep rpm_seconds; stop cleanly on quota; resume next run)
154
+
155
+ query time:
156
+ feel_page(decision_filter, min_score) sorted score DESC -> UI grid / shortlist
157
+ ```
158
+
159
+ ## Error handling
160
+
161
+ - **Quota exhaustion:** `NoModelAvailableError` bubbles up → runner stops with
162
+ `stopped_quota=True`; already-written rows persist; next run resumes (skips scored).
163
+ - **Per-clip failure** (download/parse/network): counted in `errors`, clip skipped,
164
+ added to per-run skip list so the loop never spins on it. Left unscored → retried
165
+ on a future run.
166
+ - **Malformed model JSON:** `parse_feel_json` raises → treated as a per-clip error.
167
+ - **UI cold-DB hiccup:** guarded `_render` catches, shows error in header, no 500.
168
+ - **Idempotent:** UPSERT keyed on (provider, provider_id); re-running is safe.
169
+
170
+ ## Testing
171
+
172
+ New test files (mirror existing suite; all against `InMemoryCatalogStore`, no network):
173
+ - `test_feel_scorer.py` — `parse_feel_json` (valid, fenced, braces, bad decision,
174
+ score coercion/clamp, missing keys); `FeelResult`; backend prompt-injection path
175
+ with a fake client.
176
+ - `test_feel_score_runner.py` — happy path, skip-already-scored, quota stop,
177
+ per-clip error skip-list, overwrite flag, summary counts.
178
+ - `test_catalog_store_feel.py` — schema, `set_feel_score` UPSERT, `load_for_
179
+ feel_scoring` filtering, `feel_page` sort/filter, `feel_counts`.
180
+ - `test_feel_score_app.py` — caption/card rendering, filter, guarded render,
181
+ trigger glue.
182
+
183
+ Run full suite (`pytest`) after each component; keep both `video_library/` and
184
+ `hf_space/video_library/` trees in sync (every new file written to both).
185
+
186
+ ## Explicitly out of scope (YAGNI)
187
+
188
+ - No re-scoring of mechanically-rejected clips.
189
+ - No seed/few-shot injection into the prompt.
190
+ - No changes to `clips` table shape, Phase-1/2/3/3.5 behavior.
191
+ - No auto-selection of clips into montages — this pass only *scores*; using the
192
+ shortlist is a later, separate concern.
video_library/__main__.py CHANGED
@@ -78,8 +78,17 @@ def build_arg_parser():
78
  help="print resolved (mood, query) pairs and exit")
79
  p.add_argument("--tag", action="store_true",
80
  help="Phase-2: tag untagged clips with moods, then exit")
 
 
 
 
 
 
 
 
 
81
  p.add_argument("--limit", type=int, default=10000,
82
- help="max clips to tag in this --tag run")
83
  return p
84
 
85
 
@@ -104,6 +113,40 @@ def _run_tag(limit):
104
  return 0
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def main(argv, *, runner=build_library, components_factory=default_components):
108
  args = build_arg_parser().parse_args(argv)
109
  if args.dry_run:
@@ -112,6 +155,10 @@ def main(argv, *, runner=build_library, components_factory=default_components):
112
  return 0
113
  if args.tag:
114
  return _run_tag(args.limit)
 
 
 
 
115
  missing = _missing_secrets()
116
  if missing:
117
  print("ERROR: set env vars: " + ", ".join(missing), file=sys.stderr)
 
78
  help="print resolved (mood, query) pairs and exit")
79
  p.add_argument("--tag", action="store_true",
80
  help="Phase-2: tag untagged clips with moods, then exit")
81
+ p.add_argument("--seed-reason", action="store_true",
82
+ help="Phase-3.5: draft AI calibration reasons for decided clips, "
83
+ "then exit")
84
+ p.add_argument("--overwrite-ai", action="store_true",
85
+ help="with --seed-reason, redraft clips that already have an AI "
86
+ "draft (never touches human-confirmed reasons)")
87
+ p.add_argument("--feel-score", action="store_true",
88
+ help="Phase-4: Gemini re-watches clips and scores their feel, "
89
+ "then exit")
90
  p.add_argument("--limit", type=int, default=10000,
91
+ help="max clips to process in this --tag / --seed-reason run")
92
  return p
93
 
94
 
 
113
  return 0
114
 
115
 
116
+ def _run_seed_reason(limit, overwrite_ai):
117
+ """Phase-3.5 entry: draft AI reasons for decided clips. Returns exit code."""
118
+ if not config.MISTRAL_API_KEY:
119
+ print("ERROR: set MISTRAL_API_KEY", file=sys.stderr)
120
+ return 2
121
+ if not config.NEON_DATABASE_URL:
122
+ print("ERROR: set NEON_DATABASE_URL", file=sys.stderr)
123
+ return 2
124
+ from .seed_reason_app import default_seed_reason_components
125
+ from .seed_reason_runner import run_seed_reasoning
126
+ store, reasoner = default_seed_reason_components()
127
+ summary = run_seed_reasoning(store, reasoner, limit=limit, now=_now_iso,
128
+ overwrite_ai=overwrite_ai)
129
+ print("Drafted:", summary)
130
+ return 0
131
+
132
+
133
+ def _run_feel_score(limit):
134
+ """Phase-4 entry: score clip feel via Gemini. Returns exit code."""
135
+ if not config.NEON_DATABASE_URL:
136
+ print("ERROR: set NEON_DATABASE_URL", file=sys.stderr)
137
+ return 2
138
+ if not config.GEMINI_API_KEY:
139
+ print("ERROR: set GEMINI_API_KEY", file=sys.stderr)
140
+ return 2
141
+ from .feel_score_app import default_feel_components
142
+ from .feel_score_runner import run_feel_scoring
143
+ store, router = default_feel_components()
144
+ summary = run_feel_scoring(store, router, limit=limit, tmp_dir=config.TMP_DIR,
145
+ now=_now_iso)
146
+ print("Feel-scored:", summary)
147
+ return 0
148
+
149
+
150
  def main(argv, *, runner=build_library, components_factory=default_components):
151
  args = build_arg_parser().parse_args(argv)
152
  if args.dry_run:
 
155
  return 0
156
  if args.tag:
157
  return _run_tag(args.limit)
158
+ if args.seed_reason:
159
+ return _run_seed_reason(args.limit, args.overwrite_ai)
160
+ if args.feel_score:
161
+ return _run_feel_score(args.limit)
162
  missing = _missing_secrets()
163
  if missing:
164
  print("ERROR: set env vars: " + ", ".join(missing), file=sys.stderr)
video_library/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (174 Bytes). View file
 
video_library/__pycache__/__main__.cpython-312.pyc ADDED
Binary file (9.69 kB). View file
 
video_library/__pycache__/app.cpython-312.pyc ADDED
Binary file (12.5 kB). View file
 
video_library/__pycache__/config.cpython-312.pyc ADDED
Binary file (3.23 kB). View file
 
video_library/__pycache__/curation_app.cpython-312.pyc ADDED
Binary file (22.3 kB). View file
 
video_library/__pycache__/downloader.cpython-312.pyc ADDED
Binary file (1.61 kB). View file
 
video_library/__pycache__/feel_score_app.cpython-312.pyc ADDED
Binary file (13.7 kB). View file
 
video_library/__pycache__/feel_score_runner.cpython-312.pyc ADDED
Binary file (3.94 kB). View file
 
video_library/__pycache__/feel_scorer.cpython-312.pyc ADDED
Binary file (9.99 kB). View file
 
video_library/__pycache__/library_builder.cpython-312.pyc ADDED
Binary file (3.57 kB). View file
 
video_library/__pycache__/mistral_backend.cpython-312.pyc ADDED
Binary file (5.34 kB). View file
 
video_library/__pycache__/models.cpython-312.pyc ADDED
Binary file (9.69 kB). View file
 
video_library/__pycache__/mood_rules.cpython-312.pyc ADDED
Binary file (6.83 kB). View file
 
video_library/__pycache__/mood_tagger.cpython-312.pyc ADDED
Binary file (8.85 kB). View file
 
video_library/__pycache__/reject_rules.cpython-312.pyc ADDED
Binary file (1.51 kB). View file
 
video_library/__pycache__/stock_search.cpython-312.pyc ADDED
Binary file (5.23 kB). View file
 
video_library/__pycache__/tag_app.cpython-312.pyc ADDED
Binary file (4.42 kB). View file
 
video_library/__pycache__/tag_runner.cpython-312.pyc ADDED
Binary file (5.37 kB). View file
 
video_library/__pycache__/taxonomy.cpython-312.pyc ADDED
Binary file (5.52 kB). View file
 
video_library/__pycache__/vision_describe.cpython-312.pyc ADDED
Binary file (15 kB). View file
 
video_library/app.py CHANGED
@@ -146,6 +146,9 @@ def build_ui():
146
 
147
  with gr.Tab("Seed Reasons (Phase 3.5)"):
148
  from .seed_app import build_seed_tab
 
 
 
149
 
150
  # Reuse the curation store, but ensure the seed table exists too.
151
  _seed_state = {"store": None}
@@ -157,9 +160,63 @@ def build_ui():
157
  _seed_state["store"] = store
158
  return _seed_state["store"]
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  seed_init = build_seed_tab(_seed_store, now=_now_iso)
161
  demo.load(seed_init["init_fn"], seed_init["init_inputs"],
162
  seed_init["init_outputs"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  return demo
164
 
165
 
 
146
 
147
  with gr.Tab("Seed Reasons (Phase 3.5)"):
148
  from .seed_app import build_seed_tab
149
+ from .seed_reason_app import (
150
+ SEED_REASON_STATUS, format_seed_reason_status,
151
+ start_seed_reasoner_thread)
152
 
153
  # Reuse the curation store, but ensure the seed table exists too.
154
  _seed_state = {"store": None}
 
160
  _seed_state["store"] = store
161
  return _seed_state["store"]
162
 
163
+ # AI-draft batch controls. Drafting is manual so it doesn't compete
164
+ # with Phase-1/2 for the Mistral rate limit; drafts land as source='ai'
165
+ # and you confirm them below by editing.
166
+ reason_box = gr.Textbox(
167
+ label="AI reason drafter",
168
+ value=format_seed_reason_status(SEED_REASON_STATUS),
169
+ lines=4, interactive=False)
170
+ with gr.Row():
171
+ def _start_reasoner():
172
+ if SEED_REASON_STATUS.get("state") != "running":
173
+ start_seed_reasoner_thread()
174
+ return format_seed_reason_status(SEED_REASON_STATUS)
175
+
176
+ gr.Button("Draft reasons for decided clips").click(
177
+ _start_reasoner, outputs=reason_box)
178
+ gr.Button("Refresh drafter").click(
179
+ lambda: format_seed_reason_status(SEED_REASON_STATUS),
180
+ outputs=reason_box)
181
+
182
  seed_init = build_seed_tab(_seed_store, now=_now_iso)
183
  demo.load(seed_init["init_fn"], seed_init["init_inputs"],
184
  seed_init["init_outputs"])
185
+
186
+ with gr.Tab("Feel Score"):
187
+ from .feel_score_app import (
188
+ FEEL_SCORE_STATUS, format_feel_status,
189
+ start_feel_scorer_thread, build_feel_tab)
190
+
191
+ # Reuse the curation store, but ensure the feel table exists too.
192
+ _feel_state = {"store": None}
193
+
194
+ def _feel_store():
195
+ if _feel_state["store"] is None:
196
+ store = _curation_store()
197
+ store.ensure_feel_schema()
198
+ _feel_state["store"] = store
199
+ return _feel_state["store"]
200
+
201
+ feel_status_box = gr.Textbox(
202
+ label="Gemini feel scorer",
203
+ value=format_feel_status(FEEL_SCORE_STATUS),
204
+ lines=4, interactive=False)
205
+ with gr.Row():
206
+ def _start_feel():
207
+ if FEEL_SCORE_STATUS.get("state") != "running":
208
+ start_feel_scorer_thread()
209
+ return format_feel_status(FEEL_SCORE_STATUS)
210
+
211
+ gr.Button("Score clips (Gemini)").click(
212
+ _start_feel, outputs=feel_status_box)
213
+ gr.Button("Refresh scorer").click(
214
+ lambda: format_feel_status(FEEL_SCORE_STATUS),
215
+ outputs=feel_status_box)
216
+
217
+ feel_init = build_feel_tab(_feel_store, now=_now_iso)
218
+ demo.load(feel_init["init_fn"], feel_init["init_inputs"],
219
+ feel_init["init_outputs"])
220
  return demo
221
 
222
 
video_library/catalog_store.py CHANGED
@@ -124,25 +124,63 @@ class CatalogStore(abc.ABC):
124
 
125
  @abc.abstractmethod
126
  def set_seed(self, provider: str, provider_id: str, *, verdict: str,
127
- reason: str, moods: list[str], clip_url: str, now: str) -> None:
128
- """Upsert one seed row (verdict + written reason + context)."""
 
 
 
 
129
  ...
130
 
131
  def set_seed_bulk(self, rows: list[dict], *, now: str) -> None:
132
  """Upsert many seed rows at once. Default = per-row set_seed; stores with
133
  real transactions override to commit in one round-trip. Each row dict:
134
- {provider, provider_id, verdict, reason, moods, clip_url}."""
135
  for r in rows:
136
  self.set_seed(
137
  r["provider"], r["provider_id"], verdict=r["verdict"],
138
  reason=r.get("reason", ""), moods=r.get("moods") or [],
139
- clip_url=r.get("clip_url", ""), now=now)
 
140
 
141
  @abc.abstractmethod
142
  def load_seed(self) -> list[dict]:
143
  """All seed rows, for building the few-shot prompt later."""
144
  ...
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  class InMemoryCatalogStore(CatalogStore):
148
  """Non-persistent store for tests and local dry runs."""
@@ -151,6 +189,7 @@ class InMemoryCatalogStore(CatalogStore):
151
  self._records: dict[tuple[str, str], dict] = {}
152
  self._usage: dict[tuple[str, str], int] = {}
153
  self._seed: dict[tuple[str, str], dict] = {}
 
154
 
155
  def is_cached(self, provider: str, provider_id: str) -> bool:
156
  return (provider, provider_id) in self._records
@@ -278,15 +317,75 @@ class InMemoryCatalogStore(CatalogStore):
278
  if curation_verdict(rec) in ("kept", "rejected")]
279
 
280
  def set_seed(self, provider, provider_id, *, verdict, reason, moods,
281
- clip_url, now):
282
  self._seed[(provider, provider_id)] = {
283
  "provider": provider, "provider_id": provider_id, "verdict": verdict,
284
  "reason": reason, "moods": list(moods or []), "clip_url": clip_url,
285
- "at": now}
286
 
287
  def load_seed(self) -> list[dict]:
288
  return list(self._seed.values())
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
  def _default_connect(dsn: str):
292
  import psycopg
@@ -325,23 +424,32 @@ CREATE TABLE IF NOT EXISTS curation_seed (
325
  moods JSONB NOT NULL DEFAULT '[]'::jsonb,
326
  clip_url TEXT NOT NULL DEFAULT '',
327
  at TEXT,
 
328
  PRIMARY KEY (provider, provider_id)
329
  )
330
  """
331
 
 
 
 
 
 
 
 
332
  UPSERT_SEED = """
333
- INSERT INTO curation_seed (provider, provider_id, verdict, reason, moods, clip_url, at)
334
- VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
335
  ON CONFLICT (provider, provider_id)
336
  DO UPDATE SET verdict = EXCLUDED.verdict,
337
  reason = EXCLUDED.reason,
338
  moods = EXCLUDED.moods,
339
  clip_url = EXCLUDED.clip_url,
340
- at = EXCLUDED.at
 
341
  """
342
 
343
  SELECT_SEED = """
344
- SELECT provider, provider_id, verdict, reason, moods, clip_url, at
345
  FROM curation_seed
346
  ORDER BY verdict, provider, provider_id
347
  """
@@ -356,6 +464,64 @@ WHERE (record->'mechanical_check'->>'passed')::bool = true
356
  ORDER BY analyzed_at
357
  """
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  UPSERT_CLIP = """
360
  INSERT INTO clips (provider, provider_id, mood_bucket, record, analyzed_at)
361
  VALUES (%s, %s, %s, %s::jsonb, %s)
@@ -669,6 +835,7 @@ class NeonCatalogStore(CatalogStore):
669
  with self._connect(self._dsn) as conn:
670
  with conn.cursor() as cur:
671
  cur.execute(CREATE_CURATION_SEED)
 
672
  conn.commit()
673
 
674
  def load_decided_clips(self) -> list[dict]:
@@ -685,10 +852,11 @@ class NeonCatalogStore(CatalogStore):
685
  return out
686
 
687
  def set_seed(self, provider, provider_id, *, verdict, reason, moods,
688
- clip_url, now):
689
  self.set_seed_bulk([{
690
  "provider": provider, "provider_id": provider_id, "verdict": verdict,
691
- "reason": reason, "moods": moods, "clip_url": clip_url}], now=now)
 
692
 
693
  def set_seed_bulk(self, rows: list[dict], *, now: str) -> None:
694
  if not rows:
@@ -699,7 +867,7 @@ class NeonCatalogStore(CatalogStore):
699
  cur.execute(UPSERT_SEED, (
700
  r["provider"], r["provider_id"], r["verdict"],
701
  r.get("reason", ""), json.dumps(r.get("moods") or []),
702
- r.get("clip_url", ""), now))
703
  conn.commit()
704
 
705
  def load_seed(self) -> list[dict]:
@@ -708,10 +876,75 @@ class NeonCatalogStore(CatalogStore):
708
  cur.execute(SELECT_SEED)
709
  rows = cur.fetchall()
710
  out = []
711
- for provider, provider_id, verdict, reason, moods, clip_url, at in rows:
712
  if isinstance(moods, str):
713
  moods = json.loads(moods)
714
  out.append({"provider": provider, "provider_id": provider_id,
715
  "verdict": verdict, "reason": reason,
716
- "moods": moods or [], "clip_url": clip_url, "at": at})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
717
  return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  @abc.abstractmethod
126
  def set_seed(self, provider: str, provider_id: str, *, verdict: str,
127
+ reason: str, moods: list[str], clip_url: str, now: str,
128
+ source: str = "human") -> None:
129
+ """Upsert one seed row (verdict + written reason + context).
130
+
131
+ `source` is "human" for a person-confirmed reason or "ai_draft" for an
132
+ LLM-generated draft awaiting review."""
133
  ...
134
 
135
  def set_seed_bulk(self, rows: list[dict], *, now: str) -> None:
136
  """Upsert many seed rows at once. Default = per-row set_seed; stores with
137
  real transactions override to commit in one round-trip. Each row dict:
138
+ {provider, provider_id, verdict, reason, moods, clip_url, source?}."""
139
  for r in rows:
140
  self.set_seed(
141
  r["provider"], r["provider_id"], verdict=r["verdict"],
142
  reason=r.get("reason", ""), moods=r.get("moods") or [],
143
+ clip_url=r.get("clip_url", ""), now=now,
144
+ source=r.get("source", "human"))
145
 
146
  @abc.abstractmethod
147
  def load_seed(self) -> list[dict]:
148
  """All seed rows, for building the few-shot prompt later."""
149
  ...
150
 
151
+ # --- Phase-4 (feel scoring) ---
152
+
153
+ @abc.abstractmethod
154
+ def ensure_feel_schema(self) -> None:
155
+ """Create the feel_scores table if it doesn't exist."""
156
+ ...
157
+
158
+ @abc.abstractmethod
159
+ def load_for_feel_scoring(self, limit: int = 100) -> list[dict]:
160
+ """Mechanically-passed clip records with no feel_scores row yet."""
161
+ ...
162
+
163
+ @abc.abstractmethod
164
+ def set_feel_score(self, provider: str, provider_id: str, *, result,
165
+ clip_url: str, now: str) -> None:
166
+ """UPSERT one clip's feel verdict."""
167
+ ...
168
+
169
+ @abc.abstractmethod
170
+ def load_feel_scores(self) -> list[dict]:
171
+ """All feel rows as dicts, sorted score DESC."""
172
+ ...
173
+
174
+ @abc.abstractmethod
175
+ def feel_page(self, *, decision=None, min_score=0, limit=6, offset=0) -> list[dict]:
176
+ """A page of feel rows, filtered by decision/min_score, sorted score DESC."""
177
+ ...
178
+
179
+ @abc.abstractmethod
180
+ def feel_counts(self) -> dict:
181
+ """{KEEP, BORDERLINE, REJECT, total, scored, unscored}."""
182
+ ...
183
+
184
 
185
  class InMemoryCatalogStore(CatalogStore):
186
  """Non-persistent store for tests and local dry runs."""
 
189
  self._records: dict[tuple[str, str], dict] = {}
190
  self._usage: dict[tuple[str, str], int] = {}
191
  self._seed: dict[tuple[str, str], dict] = {}
192
+ self._feel: dict[tuple[str, str], dict] = {}
193
 
194
  def is_cached(self, provider: str, provider_id: str) -> bool:
195
  return (provider, provider_id) in self._records
 
317
  if curation_verdict(rec) in ("kept", "rejected")]
318
 
319
  def set_seed(self, provider, provider_id, *, verdict, reason, moods,
320
+ clip_url, now, source="human"):
321
  self._seed[(provider, provider_id)] = {
322
  "provider": provider, "provider_id": provider_id, "verdict": verdict,
323
  "reason": reason, "moods": list(moods or []), "clip_url": clip_url,
324
+ "source": source, "at": now}
325
 
326
  def load_seed(self) -> list[dict]:
327
  return list(self._seed.values())
328
 
329
+ # --- Phase-4 feel scoring ---
330
+
331
+ def ensure_feel_schema(self) -> None:
332
+ if not hasattr(self, "_feel"):
333
+ self._feel = {}
334
+
335
+ def load_for_feel_scoring(self, limit: int = 100) -> list[dict]:
336
+ self.ensure_feel_schema()
337
+ out = []
338
+ for rec in self._records.values():
339
+ if not (rec.get("mechanical_check") or {}).get("passed"):
340
+ continue
341
+ if (rec.get("provider"), rec.get("provider_id")) in self._feel:
342
+ continue
343
+ out.append(rec)
344
+ if len(out) >= limit:
345
+ break
346
+ return out
347
+
348
+ def set_feel_score(self, provider, provider_id, *, result, clip_url, now) -> None:
349
+ self.ensure_feel_schema()
350
+ self._feel[(provider, provider_id)] = {
351
+ "provider": provider, "provider_id": provider_id,
352
+ "decision": result.decision, "score": int(result.score),
353
+ "camera_motion": result.camera_motion,
354
+ "subject_motion": result.subject_motion,
355
+ "visual_complexity": result.visual_complexity,
356
+ "human_presence": result.human_presence, "mood": result.mood,
357
+ "reason": result.reason, "clip_url": clip_url,
358
+ "scored_at": now, "scored_by": result.scored_by,
359
+ }
360
+
361
+ def load_feel_scores(self) -> list[dict]:
362
+ self.ensure_feel_schema()
363
+ rows = list(self._feel.values())
364
+ rows.sort(key=lambda r: (-int(r["score"]), r["provider"], r["provider_id"]))
365
+ return [dict(r) for r in rows]
366
+
367
+ def feel_page(self, *, decision=None, min_score=0, limit=6, offset=0) -> list[dict]:
368
+ rows = self.load_feel_scores()
369
+ if decision and decision != "all":
370
+ rows = [r for r in rows if r["decision"] == decision]
371
+ if min_score:
372
+ rows = [r for r in rows if int(r["score"]) >= int(min_score)]
373
+ return rows[offset:offset + limit]
374
+
375
+ def feel_counts(self) -> dict:
376
+ self.ensure_feel_schema()
377
+ c = {"KEEP": 0, "BORDERLINE": 0, "REJECT": 0, "total": 0,
378
+ "scored": 0, "unscored": 0}
379
+ for r in self._feel.values():
380
+ if r["decision"] in c:
381
+ c[r["decision"]] += 1
382
+ c["total"] += 1
383
+ passed = sum(1 for rec in self._records.values()
384
+ if (rec.get("mechanical_check") or {}).get("passed"))
385
+ c["scored"] = len(self._feel)
386
+ c["unscored"] = max(0, passed - len(self._feel))
387
+ return c
388
+
389
 
390
  def _default_connect(dsn: str):
391
  import psycopg
 
424
  moods JSONB NOT NULL DEFAULT '[]'::jsonb,
425
  clip_url TEXT NOT NULL DEFAULT '',
426
  at TEXT,
427
+ source TEXT NOT NULL DEFAULT 'human',
428
  PRIMARY KEY (provider, provider_id)
429
  )
430
  """
431
 
432
+ # Idempotent migration for tables created before `source` existed (the first ~23
433
+ # human-labelled rows). Adds the column defaulting to 'human' so old rows read
434
+ # correctly. Safe to run every boot.
435
+ MIGRATE_SEED_SOURCE = """
436
+ ALTER TABLE curation_seed ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'human'
437
+ """
438
+
439
  UPSERT_SEED = """
440
+ INSERT INTO curation_seed (provider, provider_id, verdict, reason, moods, clip_url, at, source)
441
+ VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s, %s)
442
  ON CONFLICT (provider, provider_id)
443
  DO UPDATE SET verdict = EXCLUDED.verdict,
444
  reason = EXCLUDED.reason,
445
  moods = EXCLUDED.moods,
446
  clip_url = EXCLUDED.clip_url,
447
+ at = EXCLUDED.at,
448
+ source = EXCLUDED.source
449
  """
450
 
451
  SELECT_SEED = """
452
+ SELECT provider, provider_id, verdict, reason, moods, clip_url, at, source
453
  FROM curation_seed
454
  ORDER BY verdict, provider, provider_id
455
  """
 
464
  ORDER BY analyzed_at
465
  """
466
 
467
+ # --- Phase-4 feel scoring: a fresh, disposable table of AI feel verdicts. ---
468
+
469
+ CREATE_FEEL_SCORES = """
470
+ CREATE TABLE IF NOT EXISTS feel_scores (
471
+ provider TEXT NOT NULL,
472
+ provider_id TEXT NOT NULL,
473
+ decision TEXT NOT NULL,
474
+ score INT NOT NULL DEFAULT 0,
475
+ camera_motion TEXT NOT NULL DEFAULT '',
476
+ subject_motion TEXT NOT NULL DEFAULT '',
477
+ visual_complexity TEXT NOT NULL DEFAULT '',
478
+ human_presence TEXT NOT NULL DEFAULT '',
479
+ mood TEXT NOT NULL DEFAULT '',
480
+ reason TEXT NOT NULL DEFAULT '',
481
+ clip_url TEXT NOT NULL DEFAULT '',
482
+ scored_at TEXT,
483
+ scored_by TEXT NOT NULL DEFAULT '',
484
+ PRIMARY KEY (provider, provider_id)
485
+ )
486
+ """
487
+
488
+ UPSERT_FEEL = """
489
+ INSERT INTO feel_scores (provider, provider_id, decision, score, camera_motion,
490
+ subject_motion, visual_complexity, human_presence, mood, reason, clip_url,
491
+ scored_at, scored_by)
492
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
493
+ ON CONFLICT (provider, provider_id) DO UPDATE SET
494
+ decision = EXCLUDED.decision, score = EXCLUDED.score,
495
+ camera_motion = EXCLUDED.camera_motion, subject_motion = EXCLUDED.subject_motion,
496
+ visual_complexity = EXCLUDED.visual_complexity,
497
+ human_presence = EXCLUDED.human_presence, mood = EXCLUDED.mood,
498
+ reason = EXCLUDED.reason, clip_url = EXCLUDED.clip_url,
499
+ scored_at = EXCLUDED.scored_at, scored_by = EXCLUDED.scored_by
500
+ """
501
+
502
+ SELECT_FEEL = """
503
+ SELECT provider, provider_id, decision, score, camera_motion, subject_motion,
504
+ visual_complexity, human_presence, mood, reason, clip_url, scored_at, scored_by
505
+ FROM feel_scores
506
+ ORDER BY score DESC, provider, provider_id
507
+ """
508
+
509
+ # Mechanically-passed clips with no row yet in feel_scores.
510
+ SELECT_FOR_FEEL = """
511
+ SELECT c.record FROM clips c
512
+ WHERE (c.record->'mechanical_check'->>'passed')::bool = true
513
+ AND NOT EXISTS (
514
+ SELECT 1 FROM feel_scores f
515
+ WHERE f.provider = c.provider AND f.provider_id = c.provider_id)
516
+ ORDER BY c.analyzed_at
517
+ LIMIT %s
518
+ """
519
+
520
+ COUNT_FEEL_PASSED = """
521
+ SELECT COUNT(*) FROM clips
522
+ WHERE (record->'mechanical_check'->>'passed')::bool = true
523
+ """
524
+
525
  UPSERT_CLIP = """
526
  INSERT INTO clips (provider, provider_id, mood_bucket, record, analyzed_at)
527
  VALUES (%s, %s, %s, %s::jsonb, %s)
 
835
  with self._connect(self._dsn) as conn:
836
  with conn.cursor() as cur:
837
  cur.execute(CREATE_CURATION_SEED)
838
+ cur.execute(MIGRATE_SEED_SOURCE)
839
  conn.commit()
840
 
841
  def load_decided_clips(self) -> list[dict]:
 
852
  return out
853
 
854
  def set_seed(self, provider, provider_id, *, verdict, reason, moods,
855
+ clip_url, now, source="human"):
856
  self.set_seed_bulk([{
857
  "provider": provider, "provider_id": provider_id, "verdict": verdict,
858
+ "reason": reason, "moods": moods, "clip_url": clip_url,
859
+ "source": source}], now=now)
860
 
861
  def set_seed_bulk(self, rows: list[dict], *, now: str) -> None:
862
  if not rows:
 
867
  cur.execute(UPSERT_SEED, (
868
  r["provider"], r["provider_id"], r["verdict"],
869
  r.get("reason", ""), json.dumps(r.get("moods") or []),
870
+ r.get("clip_url", ""), now, r.get("source", "human")))
871
  conn.commit()
872
 
873
  def load_seed(self) -> list[dict]:
 
876
  cur.execute(SELECT_SEED)
877
  rows = cur.fetchall()
878
  out = []
879
+ for provider, provider_id, verdict, reason, moods, clip_url, at, source in rows:
880
  if isinstance(moods, str):
881
  moods = json.loads(moods)
882
  out.append({"provider": provider, "provider_id": provider_id,
883
  "verdict": verdict, "reason": reason,
884
+ "moods": moods or [], "clip_url": clip_url, "at": at,
885
+ "source": source})
886
+ return out
887
+
888
+ # --- Phase-4 feel scoring ---
889
+
890
+ def ensure_feel_schema(self) -> None:
891
+ with self._connect(self._dsn) as conn:
892
+ with conn.cursor() as cur:
893
+ cur.execute(CREATE_FEEL_SCORES)
894
+ conn.commit()
895
+
896
+ def load_for_feel_scoring(self, limit: int = 100) -> list[dict]:
897
+ with self._connect(self._dsn) as conn:
898
+ with conn.cursor() as cur:
899
+ cur.execute(SELECT_FOR_FEEL, (limit,))
900
+ rows = cur.fetchall()
901
+ out = []
902
+ for row in rows:
903
+ rec = row[0]
904
+ if isinstance(rec, str):
905
+ rec = json.loads(rec)
906
+ out.append(rec)
907
  return out
908
+
909
+ def set_feel_score(self, provider, provider_id, *, result, clip_url, now) -> None:
910
+ with self._connect(self._dsn) as conn:
911
+ with conn.cursor() as cur:
912
+ cur.execute(UPSERT_FEEL, (
913
+ provider, provider_id, result.decision, int(result.score),
914
+ result.camera_motion, result.subject_motion,
915
+ result.visual_complexity, result.human_presence, result.mood,
916
+ result.reason, clip_url, now, result.scored_by))
917
+ conn.commit()
918
+
919
+ def load_feel_scores(self) -> list[dict]:
920
+ cols = ("provider", "provider_id", "decision", "score", "camera_motion",
921
+ "subject_motion", "visual_complexity", "human_presence", "mood",
922
+ "reason", "clip_url", "scored_at", "scored_by")
923
+ with self._connect(self._dsn) as conn:
924
+ with conn.cursor() as cur:
925
+ cur.execute(SELECT_FEEL)
926
+ rows = cur.fetchall()
927
+ return [dict(zip(cols, row)) for row in rows]
928
+
929
+ def feel_page(self, *, decision=None, min_score=0, limit=6, offset=0) -> list[dict]:
930
+ rows = self.load_feel_scores()
931
+ if decision and decision != "all":
932
+ rows = [r for r in rows if r["decision"] == decision]
933
+ if min_score:
934
+ rows = [r for r in rows if int(r["score"]) >= int(min_score)]
935
+ return rows[offset:offset + limit]
936
+
937
+ def feel_counts(self) -> dict:
938
+ c = {"KEEP": 0, "BORDERLINE": 0, "REJECT": 0, "total": 0,
939
+ "scored": 0, "unscored": 0}
940
+ for r in self.load_feel_scores():
941
+ if r["decision"] in c:
942
+ c[r["decision"]] += 1
943
+ c["total"] += 1
944
+ with self._connect(self._dsn) as conn:
945
+ with conn.cursor() as cur:
946
+ cur.execute(COUNT_FEEL_PASSED)
947
+ passed = int((cur.fetchone() or [0])[0])
948
+ c["scored"] = c["total"]
949
+ c["unscored"] = max(0, passed - c["total"])
950
+ return c
video_library/feel_score_app.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase-4 feel-scorer glue: pure card/status helpers, a background-thread trigger
2
+ (mirrors seed_reason_app), and the Gradio 'Feel Score' results tab.
3
+
4
+ Crash safety carried from the curation tab: video players autoplay=False, loop=True,
5
+ page size 6, browsing repaints a small page, guarded render (no 500 on cold DB).
6
+ """
7
+ from __future__ import annotations
8
+ import threading
9
+ import traceback
10
+
11
+ from . import config
12
+ from .feel_score_runner import run_feel_scoring
13
+ from .__main__ import _now_iso
14
+
15
+ FEEL_PAGE_SIZE = 6
16
+ DECISION_CHOICES = ["all", "KEEP", "BORDERLINE", "REJECT"]
17
+
18
+ _BADGE = {"KEEP": "✅ KEEP", "BORDERLINE": "\U0001f7e1 BORDERLINE",
19
+ "REJECT": "❌ REJECT"}
20
+
21
+
22
+ def feel_caption(row: dict) -> str:
23
+ dec = row.get("decision", "")
24
+ badge = _BADGE.get(dec, dec)
25
+ parts = [f"**{badge} · {row.get('score', 0)}/100**"]
26
+ meta = []
27
+ if row.get("mood"):
28
+ meta.append(row["mood"])
29
+ if row.get("camera_motion"):
30
+ meta.append("cam: " + row["camera_motion"])
31
+ if row.get("subject_motion"):
32
+ meta.append("subj: " + row["subject_motion"])
33
+ if meta:
34
+ parts.append(" · ".join(meta))
35
+ if row.get("reason"):
36
+ parts.append("_" + row["reason"] + "_")
37
+ return " \n".join(parts)
38
+
39
+
40
+ def feel_card(row: dict) -> dict:
41
+ return {
42
+ "video_url": row.get("clip_url", ""),
43
+ "score": int(row.get("score", 0) or 0),
44
+ "decision": row.get("decision", ""),
45
+ "badge": _BADGE.get(row.get("decision", ""), row.get("decision", "")),
46
+ "caption": feel_caption(row),
47
+ }
48
+
49
+
50
+ # --- background-thread trigger (mirrors seed_reason_app) ---
51
+
52
+ FEEL_SCORE_STATUS = {"state": "idle", "summary": None, "error": None}
53
+
54
+
55
+ def default_feel_components():
56
+ """Build the Neon store (feel schema ensured) + a FEEL_PROMPT vision router."""
57
+ from .catalog_store import NeonCatalogStore
58
+ from .vision_describe import GenAIBackend, VisionRouter
59
+ from .feel_scorer import FEEL_PROMPT, parse_feel_json
60
+ from .__main__ import _build_model_configs, _utc_date
61
+
62
+ store = NeonCatalogStore(config.NEON_DATABASE_URL)
63
+ store.ensure_schema()
64
+ store.ensure_feel_schema()
65
+ router = VisionRouter(
66
+ _build_model_configs(), store,
67
+ backend_factory=lambda cfg: GenAIBackend(
68
+ cfg.api_key or config.GEMINI_API_KEY, cfg.model_id,
69
+ strip_audio=cfg.strip_audio,
70
+ prompt=FEEL_PROMPT, parser=parse_feel_json),
71
+ today=_utc_date,
72
+ )
73
+ return store, router
74
+
75
+
76
+ def run_feel_scorer(*, components_factory=default_feel_components,
77
+ runner=run_feel_scoring, limit=10000):
78
+ if not config.NEON_DATABASE_URL:
79
+ FEEL_SCORE_STATUS.update(state="missing-key", error="set NEON_DATABASE_URL")
80
+ return FEEL_SCORE_STATUS
81
+ if not config.GEMINI_API_KEY:
82
+ FEEL_SCORE_STATUS.update(state="missing-key", error="set GEMINI_API_KEY")
83
+ return FEEL_SCORE_STATUS
84
+ FEEL_SCORE_STATUS.update(state="running", error=None, summary=None)
85
+ try:
86
+ store, router = components_factory()
87
+ summary = runner(store, router, limit=limit, tmp_dir=config.TMP_DIR,
88
+ now=_now_iso)
89
+ FEEL_SCORE_STATUS.update(state="done", summary=summary)
90
+ except Exception as e:
91
+ FEEL_SCORE_STATUS.update(
92
+ state="error", error=repr(e) + "\n" + traceback.format_exc())
93
+ return FEEL_SCORE_STATUS
94
+
95
+
96
+ def format_feel_status(status) -> str:
97
+ lines = ["Feel scorer: " + str(status.get("state"))]
98
+ summary = status.get("summary")
99
+ if summary:
100
+ lines.append(
101
+ "processed {processed} | scored {scored} | "
102
+ "skipped {skipped} | errors {errors}".format(**summary))
103
+ if summary.get("by_decision"):
104
+ lines.append("decisions: " + ", ".join(
105
+ f"{k} {v}" for k, v in summary["by_decision"].items()))
106
+ if summary.get("stopped_quota"):
107
+ lines.append("Stopped: Gemini quota hit; resume later.")
108
+ if status.get("error"):
109
+ lines.append("Error: " + str(status["error"]).splitlines()[0])
110
+ return "\n".join(lines)
111
+
112
+
113
+ def start_feel_scorer_thread():
114
+ t = threading.Thread(target=run_feel_scorer, name="feel-scorer-loop", daemon=True)
115
+ t.start()
116
+ return t
117
+
118
+
119
+ def build_feel_tab(store_factory, *, now):
120
+ """Construct the 'Feel Score' results tab. Imported lazily by app.py."""
121
+ import gradio as gr
122
+
123
+ session = {"rows": None, "decision": "all", "min_score": 0, "page": 1}
124
+
125
+ gr.Markdown(
126
+ "### Feel Score\nGemini watched every clip and scored how well it works as a "
127
+ "quiet background for narration. Sorted best-first. Filter by decision or "
128
+ "minimum score, then click a clip to play it.")
129
+
130
+ with gr.Row():
131
+ dec_dd = gr.Dropdown(DECISION_CHOICES, value="all", label="Decision")
132
+ min_slider = gr.Slider(0, 100, value=0, step=5, label="Min score")
133
+ header = gr.Markdown("")
134
+ page_label = gr.Markdown("")
135
+
136
+ slots = []
137
+ with gr.Column():
138
+ for _ in range(FEEL_PAGE_SIZE):
139
+ with gr.Row(visible=False) as row:
140
+ player = gr.Video(interactive=False, height=260, show_label=False,
141
+ autoplay=False, loop=True)
142
+ caption = gr.Markdown("")
143
+ slots.append({"row": row, "player": player, "caption": caption})
144
+
145
+ with gr.Row():
146
+ prev_btn = gr.Button("← Prev")
147
+ next_btn = gr.Button("Next →")
148
+
149
+ def _load_rows():
150
+ if session["rows"] is None:
151
+ session["rows"] = store_factory().load_feel_scores()
152
+ return session["rows"]
153
+
154
+ def _filtered():
155
+ rows = _load_rows()
156
+ dec, ms = session["decision"], session["min_score"]
157
+ if dec and dec != "all":
158
+ rows = [r for r in rows if r["decision"] == dec]
159
+ if ms:
160
+ rows = [r for r in rows if int(r["score"]) >= int(ms)]
161
+ return rows
162
+
163
+ def _outputs():
164
+ out = [header, page_label]
165
+ for slot in slots:
166
+ out += [slot["row"], slot["player"], slot["caption"]]
167
+ return out
168
+
169
+ def _render():
170
+ view = _filtered()
171
+ pages = max(1, (len(view) + FEEL_PAGE_SIZE - 1) // FEEL_PAGE_SIZE)
172
+ session["page"] = max(1, min(session["page"], pages))
173
+ off = (session["page"] - 1) * FEEL_PAGE_SIZE
174
+ clips = view[off:off + FEEL_PAGE_SIZE]
175
+ hdr = (f"{len(view)} clips shown · {len(_load_rows())} scored total")
176
+ updates = [gr.update(value=hdr),
177
+ gr.update(value=f"Page {session['page']} / {pages}")]
178
+ for i, slot in enumerate(slots):
179
+ if i < len(clips):
180
+ card = feel_card(clips[i])
181
+ updates += [gr.update(visible=True),
182
+ gr.update(value=card["video_url"]),
183
+ gr.update(value=card["caption"])]
184
+ else:
185
+ updates += [gr.update(visible=False), gr.update(value=None),
186
+ gr.update(value="")]
187
+ return updates
188
+
189
+ def _error_render(exc):
190
+ updates = [gr.update(value=f"⚠️ Could not load scores: {exc}"),
191
+ gr.update(value="")]
192
+ for _ in slots:
193
+ updates += [gr.update(visible=False), gr.update(value=None),
194
+ gr.update(value="")]
195
+ return updates
196
+
197
+ def _on_filter(dec, ms):
198
+ session["decision"], session["min_score"], session["page"] = dec, ms, 1
199
+ try:
200
+ return _render()
201
+ except Exception as exc:
202
+ return _error_render(exc)
203
+
204
+ def _on_page(delta):
205
+ session["page"] = max(1, session["page"] + delta)
206
+ try:
207
+ return _render()
208
+ except Exception as exc:
209
+ return _error_render(exc)
210
+
211
+ def _on_init():
212
+ try:
213
+ return _render()
214
+ except Exception as exc:
215
+ return _error_render(exc)
216
+
217
+ full_out = _outputs()
218
+ dec_dd.change(_on_filter, [dec_dd, min_slider], full_out)
219
+ min_slider.release(_on_filter, [dec_dd, min_slider], full_out)
220
+ prev_btn.click(lambda: _on_page(-1), None, full_out)
221
+ next_btn.click(lambda: _on_page(1), None, full_out)
222
+
223
+ return {"init_fn": _on_init, "init_inputs": None, "init_outputs": _outputs()}
video_library/feel_score_runner.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase-4 orchestrator: score every mechanically-passed clip's FEEL.
2
+
3
+ Loop mirrors tag_runner / library_builder:
4
+ 1. Load a batch of mechanically-passed, not-yet-scored clips.
5
+ 2. Pick the smallest streamable file, download to temp.
6
+ 3. router.describe(path) -> FeelResult (router carries FEEL_PROMPT).
7
+ 4. store.set_feel_score(...); delete temp.
8
+ 5. Sleep rpm_seconds.
9
+ Resumable (each row written immediately) and quota-aware (NoModelAvailableError or
10
+ a detected quota error stops the run cleanly; rerun to continue).
11
+ """
12
+ from __future__ import annotations
13
+ import datetime as _dt
14
+ import time
15
+ from typing import Callable
16
+
17
+ from .downloader import download_to_temp, delete_quietly
18
+ from .curation_app import pick_stream_url
19
+ from .vision_describe import NoModelAvailableError, is_quota_error
20
+
21
+
22
+ def _now_iso() -> str:
23
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
24
+
25
+
26
+ def run_feel_scoring(store, router, *,
27
+ limit: int = 10_000,
28
+ batch_size: int = 100,
29
+ tmp_dir,
30
+ now: Callable[[], str] = _now_iso,
31
+ sleep: Callable[[float], None] = time.sleep,
32
+ rpm_seconds: float = 2.5,
33
+ downloader=download_to_temp,
34
+ deleter=delete_quietly,
35
+ url_picker=pick_stream_url,
36
+ quota_detector: Callable[[Exception], bool] = is_quota_error,
37
+ ) -> dict:
38
+ summary = {"processed": 0, "scored": 0, "skipped": 0, "errors": 0,
39
+ "stopped_quota": False, "by_decision": {}}
40
+ errored: set[tuple[str, str]] = set()
41
+ remaining = limit
42
+ while remaining > 0:
43
+ batch = store.load_for_feel_scoring(limit=min(batch_size, remaining))
44
+ batch = [r for r in batch
45
+ if (r.get("provider"), r.get("provider_id")) not in errored]
46
+ if not batch:
47
+ break
48
+ for rec in batch:
49
+ if remaining <= 0:
50
+ break
51
+ provider = rec.get("provider")
52
+ provider_id = rec.get("provider_id")
53
+ summary["processed"] += 1
54
+ remaining -= 1
55
+
56
+ url = url_picker(rec)
57
+ if not url:
58
+ summary["skipped"] += 1
59
+ continue
60
+
61
+ temp_path = None
62
+ try:
63
+ temp_path = downloader(url, tmp_dir, provider_id)
64
+ result = router.describe(str(temp_path))
65
+ except NoModelAvailableError:
66
+ summary["stopped_quota"] = True
67
+ if temp_path is not None:
68
+ deleter(temp_path)
69
+ return summary
70
+ except Exception as e:
71
+ if temp_path is not None:
72
+ deleter(temp_path)
73
+ if quota_detector(e):
74
+ summary["stopped_quota"] = True
75
+ return summary
76
+ summary["errors"] += 1
77
+ errored.add((provider, provider_id))
78
+ continue
79
+ else:
80
+ deleter(temp_path)
81
+
82
+ store.set_feel_score(provider, provider_id, result=result,
83
+ clip_url=url, now=now())
84
+ summary["scored"] += 1
85
+ d = result.decision
86
+ summary["by_decision"][d] = summary["by_decision"].get(d, 0) + 1
87
+ sleep(rpm_seconds)
88
+ return summary
video_library/feel_scorer.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase-4 vision judge: watch a clip, score its FEEL for contemplative narration.
2
+
3
+ Unlike the Phase-1 VISION_PROMPT (which forbids feel-judgment on purpose), this
4
+ prompt asks the model to decide whether the clip can sit quietly *under* Osho-style
5
+ narration without competing for attention. Output is strict JSON we parse into a
6
+ FeelResult. The taste lives entirely in FEEL_PROMPT - no few-shot examples.
7
+ """
8
+ from __future__ import annotations
9
+ import json
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ FEEL_DECISIONS = ("KEEP", "BORDERLINE", "REJECT")
14
+
15
+ FEEL_PROMPT = """# Role
16
+
17
+ You are an expert curator of stock footage for a YouTube Shorts channel featuring slow, contemplative Osho narration.
18
+
19
+ Your job is **not** to judge whether a clip is beautiful. Your job is to judge whether it quietly supports spoken wisdom without competing for the viewer's attention.
20
+
21
+ The narration is always the hero.
22
+
23
+ The footage exists only to create atmosphere.
24
+
25
+ A perfect clip should almost disappear behind the words while gently deepening their emotional impact.
26
+
27
+ ---
28
+
29
+ # Core Principle
30
+
31
+ **Ask yourself one question above all else:**
32
+
33
+ > **Would this clip make the viewer watch the visuals instead of listening to the narration?**
34
+
35
+ If **yes**, reject it.
36
+
37
+ If the visuals simply create a peaceful atmosphere while allowing the narration to remain the focus, keep it.
38
+
39
+ ---
40
+
41
+ # The Most Important Distinction
42
+
43
+ Evaluate **camera movement** and **subject movement** separately.
44
+
45
+ ### Good Camera Movement
46
+
47
+ The camera may move if it slowly reveals the scene.
48
+
49
+ Examples:
50
+
51
+ * slow drone glide
52
+ * slow dolly
53
+ * gentle pan
54
+ * slow tilt
55
+ * slow push-in
56
+ * smooth floating movement
57
+
58
+ This creates immersion without distraction.
59
+
60
+ ### Bad Camera Movement
61
+
62
+ Reject:
63
+
64
+ * handheld shake
65
+ * whip pans
66
+ * rapid zooms
67
+ * aggressive orbiting
68
+ * jerky stabilization
69
+ * fast tracking
70
+
71
+ ---
72
+
73
+ ### Good Subject Movement
74
+
75
+ Minimal natural movement is ideal.
76
+
77
+ Examples:
78
+
79
+ * clouds drifting
80
+ * mist moving
81
+ * gentle waves
82
+ * leaves lightly swaying
83
+ * rain
84
+ * smoke
85
+ * calm flowing river
86
+
87
+ The subject should feel almost still.
88
+
89
+ ### Bad Subject Movement
90
+
91
+ Reject if the subject itself becomes the attraction.
92
+
93
+ Examples:
94
+
95
+ * people walking prominently
96
+ * animals running
97
+ * birds dominating the frame
98
+ * vehicles
99
+ * traffic
100
+ * crowds
101
+ * dancing
102
+ * sports
103
+ * waterfalls with violent motion
104
+ * fireworks
105
+ * explosions
106
+ * anything energetic
107
+
108
+ **Camera motion is good. Subject motion is usually bad.**
109
+
110
+ ---
111
+
112
+ # Desired Visual Style
113
+
114
+ Prefer clips with:
115
+
116
+ * open landscapes
117
+ * mountains
118
+ * forests
119
+ * rivers
120
+ * lakes
121
+ * ocean horizons
122
+ * beaches
123
+ * deserts
124
+ * snow
125
+ * fog
126
+ * mist
127
+ * clouds
128
+ * sunrise
129
+ * sunset
130
+ * moonlight
131
+ * empty temples
132
+ * quiet interiors
133
+ * empty roads
134
+ * abandoned structures
135
+ * fields
136
+ * minimal architecture
137
+ * cinematic nature
138
+
139
+ The frame should evoke:
140
+
141
+ * silence
142
+ * spaciousness
143
+ * solitude
144
+ * timelessness
145
+ * contemplation
146
+ * stillness
147
+ * peace
148
+ * wonder
149
+ * presence
150
+
151
+ ---
152
+
153
+ # Visual Simplicity
154
+
155
+ The composition should be clean and easy to absorb.
156
+
157
+ Prefer:
158
+
159
+ * few dominant elements
160
+ * negative space
161
+ * uncluttered backgrounds
162
+ * natural lighting
163
+ * cinematic framing
164
+ * soft colors
165
+ * slow visual rhythm
166
+
167
+ Reject:
168
+
169
+ * clutter
170
+ * visual chaos
171
+ * many competing objects
172
+ * busy forests
173
+ * dense city scenes
174
+ * heavy visual texture
175
+ * flashing lights
176
+ * excessive detail that constantly pulls attention
177
+
178
+ ---
179
+
180
+ # Human Presence
181
+
182
+ People should rarely be the subject.
183
+
184
+ KEEP:
185
+
186
+ * tiny distant silhouette
187
+ * lone person occupying very little of the frame
188
+ * person integrated into the landscape
189
+ * person remaining still
190
+
191
+ REJECT:
192
+
193
+ * close-up faces
194
+ * portraits
195
+ * talking people
196
+ * emotional acting
197
+ * influencers
198
+ * vlog footage
199
+ * people performing actions
200
+ * crowds
201
+ * dancing
202
+ * weddings
203
+ * celebrations
204
+
205
+ If a viewer immediately notices the person before the environment, reject.
206
+
207
+ ---
208
+
209
+ # Commercial or Stock Feel
210
+
211
+ Reject clips that resemble:
212
+
213
+ * advertisements
214
+ * travel influencer footage
215
+ * luxury lifestyle
216
+ * product showcases
217
+ * fashion videos
218
+ * cinematic hero shots
219
+ * corporate footage
220
+ * generic motivational stock
221
+ * obvious AI-generated visuals
222
+ * overprocessed HDR
223
+ * unrealistic colors
224
+
225
+ The footage should feel authentic rather than promotional.
226
+
227
+ ---
228
+
229
+ # Technical Rejections
230
+
231
+ Automatically reject clips containing:
232
+
233
+ * text
234
+ * subtitles
235
+ * logos
236
+ * watermarks
237
+ * UI elements
238
+ * borders
239
+ * split screens
240
+ * multiple scenes
241
+ * fast cuts
242
+ * jump cuts
243
+ * transitions
244
+ * glitches
245
+ * poor stabilization
246
+ * distracting compression artifacts
247
+
248
+ One uninterrupted shot is strongly preferred.
249
+
250
+ ---
251
+
252
+ # Emotional Tone
253
+
254
+ Desired emotions:
255
+
256
+ * peaceful
257
+ * reflective
258
+ * meditative
259
+ * calm
260
+ * expansive
261
+ * spiritual
262
+ * quiet
263
+ * gentle
264
+ * grounded
265
+
266
+ Avoid clips that feel:
267
+
268
+ * exciting
269
+ * urgent
270
+ * dramatic
271
+ * suspenseful
272
+ * comedic
273
+ * romantic
274
+ * aggressive
275
+ * adventurous
276
+ * action-oriented
277
+ * emotionally manipulative
278
+
279
+ ---
280
+
281
+ # Scoring Rubric
282
+
283
+ Score each clip out of 100.
284
+
285
+ **Subject Motion (35 points)**
286
+
287
+ * Minimal movement = high score
288
+ * Active subjects = low score
289
+
290
+ **Camera Movement (20 points)**
291
+
292
+ * Smooth slow movement = high score
293
+ * Fast or shaky movement = low score
294
+
295
+ **Visual Simplicity (20 points)**
296
+
297
+ * Clean composition = high score
298
+ * Busy composition = low score
299
+
300
+ **Mood & Atmosphere (15 points)**
301
+
302
+ * Peaceful and contemplative = high score
303
+ * Exciting or dramatic = low score
304
+
305
+ **Human Presence (10 points)**
306
+
307
+ * None or tiny silhouette = high score
308
+ * Human as subject = low score
309
+
310
+ ---
311
+
312
+ # Decision Rules
313
+
314
+ **KEEP**
315
+
316
+ * Score **80-100**
317
+ * Strong contemplative background
318
+ * Supports narration effortlessly
319
+
320
+ **BORDERLINE**
321
+
322
+ * Score **60-79**
323
+ * Usable but contains mild distractions
324
+
325
+ **REJECT**
326
+
327
+ * Score **below 60**
328
+ * Competes with narration or breaks the contemplative atmosphere
329
+
330
+ ---
331
+
332
+ # Output Format
333
+
334
+ Return only valid JSON.
335
+
336
+ {
337
+ "decision": "KEEP | BORDERLINE | REJECT",
338
+ "score": 0,
339
+ "camera_motion": "",
340
+ "subject_motion": "",
341
+ "visual_complexity": "low | medium | high",
342
+ "human_presence": "none | distant | prominent",
343
+ "mood": "",
344
+ "reason": ""
345
+ }
346
+
347
+ Keep the "reason" concise (1-2 sentences), focusing on why the clip does or does not work as a quiet background for contemplative narration.
348
+
349
+ Never judge the philosophical content. Judge only the visual suitability for an Osho-style spoken wisdom video."""
350
+
351
+
352
+ class FeelParseError(Exception):
353
+ pass
354
+
355
+
356
+ @dataclass
357
+ class FeelResult:
358
+ decision: str
359
+ score: int
360
+ camera_motion: str = ""
361
+ subject_motion: str = ""
362
+ visual_complexity: str = ""
363
+ human_presence: str = ""
364
+ mood: str = ""
365
+ reason: str = ""
366
+ described_by: str = ""
367
+
368
+ @property
369
+ def scored_by(self) -> str:
370
+ return self.described_by
371
+
372
+
373
+ def _coerce_score(v) -> int:
374
+ try:
375
+ n = int(float(v))
376
+ except (TypeError, ValueError):
377
+ raise FeelParseError(f"bad score: {v!r}")
378
+ return max(0, min(100, n))
379
+
380
+
381
+ def _from_obj(obj: dict) -> FeelResult:
382
+ decision = str(obj.get("decision", "")).strip().upper()
383
+ if decision not in FEEL_DECISIONS:
384
+ raise FeelParseError(f"bad decision: {decision!r}")
385
+ return FeelResult(
386
+ decision=decision,
387
+ score=_coerce_score(obj.get("score", 0)),
388
+ camera_motion=str(obj.get("camera_motion", "") or ""),
389
+ subject_motion=str(obj.get("subject_motion", "") or ""),
390
+ visual_complexity=str(obj.get("visual_complexity", "") or ""),
391
+ human_presence=str(obj.get("human_presence", "") or ""),
392
+ mood=str(obj.get("mood", "") or ""),
393
+ reason=str(obj.get("reason", "") or ""),
394
+ )
395
+
396
+
397
+ def parse_feel_json(text: str) -> FeelResult:
398
+ """Extract the first valid JSON object from model text -> FeelResult."""
399
+ if not text:
400
+ raise FeelParseError("empty response")
401
+ candidates = []
402
+ fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
403
+ if fence:
404
+ candidates.append(fence.group(1))
405
+ brace = re.search(r"\{.*\}", text, re.DOTALL)
406
+ if brace:
407
+ candidates.append(brace.group(0))
408
+ for c in candidates:
409
+ try:
410
+ return _from_obj(json.loads(c))
411
+ except (json.JSONDecodeError, TypeError):
412
+ continue
413
+ raise FeelParseError("no valid JSON object in response")
video_library/seed_app.py CHANGED
@@ -22,12 +22,25 @@ SEED_VERDICTS = ("kept", "rejected")
22
  SEED_PAGE_SIZE = 6
23
 
24
 
25
- def _seed_caption(card: dict) -> str:
26
- """Card caption showing subject/quality context (not editable, just reference)."""
27
- return (f"**{', '.join(card['moods']) or '—'}** · q{card['quality']} · "
 
 
 
 
28
  f"{card['motion'] or '—'}\n\n{card['description']}")
29
 
30
 
 
 
 
 
 
 
 
 
 
31
  class SeedSnapshot:
32
  """The decided-clip set (kept/rejected) loaded into RAM once.
33
 
@@ -114,10 +127,18 @@ def build_seed_tab(store_factory, *, now):
114
  snap = _ensure_snapshot()
115
  total = snap.count()
116
  # A reason is "done" only if it's non-empty; a bare verdict was already set
117
- # in Phase 3, so it doesn't count as newly annotated here.
118
- done = sum(1 for r in session["saved"].values() if (r.get("reason") or "").strip())
119
- return (f"**{done} / {total}** clips have a written reason · "
120
- f"edits save automatically when you click Next / Prev")
 
 
 
 
 
 
 
 
121
 
122
  def _flush():
123
  """Write buffered seed rows to Neon in one batch, then clear the buffer.
@@ -167,9 +188,11 @@ def build_seed_tab(store_factory, *, now):
167
  if i < len(page_records):
168
  card = card_for_record(page_records[i])
169
  v, r = _buffered(card)
 
 
170
  updates += [gr.update(visible=True),
171
  gr.update(value=card["video_url"]),
172
- gr.update(value=_seed_caption(card)),
173
  gr.update(value=v),
174
  gr.update(value=r)]
175
  else:
@@ -214,10 +237,12 @@ def build_seed_tab(store_factory, *, now):
214
  and (prev.get("reason") or "") == reason_val:
215
  continue # already saved with these exact values — nothing to do
216
 
 
 
217
  session["pending"][key] = {
218
  "provider": p, "provider_id": pid, "verdict": verdict_val,
219
  "reason": reason_val, "moods": card["moods"],
220
- "clip_url": card["video_url"]}
221
 
222
  def _on_page(delta, *vals):
223
  try:
 
22
  SEED_PAGE_SIZE = 6
23
 
24
 
25
+ def _seed_caption(card: dict, status: str = "") -> str:
26
+ """Card caption showing subject/quality context (not editable, just reference).
27
+
28
+ `status` is an optional marker line for the reason's state: an AI draft awaiting
29
+ review, or a human-confirmed reason."""
30
+ head = f"{status}\n\n" if status else ""
31
+ return (f"{head}**{', '.join(card['moods']) or '—'}** · q{card['quality']} · "
32
  f"{card['motion'] or '—'}\n\n{card['description']}")
33
 
34
 
35
+ def _reason_status(saved_row: dict | None) -> str:
36
+ """Marker for a card's reason state, from its saved seed row (or None)."""
37
+ if not saved_row or not (saved_row.get("reason") or "").strip():
38
+ return ""
39
+ if (saved_row.get("source") or "human") == "ai":
40
+ return "🤖 *AI draft — glance & edit to confirm*"
41
+ return "✅ *confirmed*"
42
+
43
+
44
  class SeedSnapshot:
45
  """The decided-clip set (kept/rejected) loaded into RAM once.
46
 
 
127
  snap = _ensure_snapshot()
128
  total = snap.count()
129
  # A reason is "done" only if it's non-empty; a bare verdict was already set
130
+ # in Phase 3, so it doesn't count as newly annotated here. Split into
131
+ # human-confirmed vs AI drafts still awaiting your glance.
132
+ confirmed = drafts = 0
133
+ for r in session["saved"].values():
134
+ if not (r.get("reason") or "").strip():
135
+ continue
136
+ if (r.get("source") or "human") == "ai":
137
+ drafts += 1
138
+ else:
139
+ confirmed += 1
140
+ return (f"**{confirmed} confirmed** · 🤖 **{drafts} AI drafts to review** · "
141
+ f"{total} total · edits save when you click Next / Prev")
142
 
143
  def _flush():
144
  """Write buffered seed rows to Neon in one batch, then clear the buffer.
 
188
  if i < len(page_records):
189
  card = card_for_record(page_records[i])
190
  v, r = _buffered(card)
191
+ key = (card["provider"], card["provider_id"])
192
+ status = _reason_status(session.get("saved", {}).get(key))
193
  updates += [gr.update(visible=True),
194
  gr.update(value=card["video_url"]),
195
+ gr.update(value=_seed_caption(card, status)),
196
  gr.update(value=v),
197
  gr.update(value=r)]
198
  else:
 
237
  and (prev.get("reason") or "") == reason_val:
238
  continue # already saved with these exact values — nothing to do
239
 
240
+ # A human touched this card, so it's a confirmed reason now — even if
241
+ # the text came from an AI draft they left as-is by editing the verdict.
242
  session["pending"][key] = {
243
  "provider": p, "provider_id": pid, "verdict": verdict_val,
244
  "reason": reason_val, "moods": card["moods"],
245
+ "clip_url": card["video_url"], "source": "human"}
246
 
247
  def _on_page(delta, *vals):
248
  try:
video_library/seed_reason_app.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase-3.5 seed-reasoner glue for the Gradio Space + CLI.
2
+
3
+ Mirrors tag_app.py: builds the Neon store + a SeedReasoner from config, runs one
4
+ batch pass in a background thread, and exposes a compact status string. Manual
5
+ (button / CLI flag), never auto-started, so it doesn't compete with Phase-1/2 for
6
+ the Mistral rate limit.
7
+ """
8
+ from __future__ import annotations
9
+ import threading
10
+ import traceback
11
+
12
+ from . import config
13
+ from .seed_reason_runner import run_seed_reasoning
14
+ from .__main__ import _now_iso
15
+
16
+
17
+ SEED_REASON_STATUS = {"state": "idle", "summary": None, "error": None}
18
+
19
+
20
+ def default_seed_reason_components():
21
+ """Build the Neon store (seed schema ensured) + a Mistral SeedReasoner."""
22
+ from .catalog_store import NeonCatalogStore
23
+ from .seed_reasoner import SeedReasoner
24
+
25
+ store = NeonCatalogStore(config.NEON_DATABASE_URL)
26
+ store.ensure_schema()
27
+ store.ensure_seed_schema()
28
+ reasoner = SeedReasoner(config.MISTRAL_API_KEY, config.MISTRAL_MODEL)
29
+ return store, reasoner
30
+
31
+
32
+ def run_seed_reasoner(*, components_factory=default_seed_reason_components,
33
+ runner=run_seed_reasoning, limit=10000, overwrite_ai=False):
34
+ """Run one full pass of the seed reasoner. Updates SEED_REASON_STATUS as it goes."""
35
+ if not config.MISTRAL_API_KEY:
36
+ SEED_REASON_STATUS.update(state="missing-key", error="set MISTRAL_API_KEY")
37
+ return SEED_REASON_STATUS
38
+ if not config.NEON_DATABASE_URL:
39
+ SEED_REASON_STATUS.update(state="missing-key", error="set NEON_DATABASE_URL")
40
+ return SEED_REASON_STATUS
41
+ SEED_REASON_STATUS.update(state="running", error=None, summary=None)
42
+ try:
43
+ store, reasoner = components_factory()
44
+ summary = runner(store, reasoner, limit=limit, now=_now_iso,
45
+ overwrite_ai=overwrite_ai)
46
+ SEED_REASON_STATUS.update(state="done", summary=summary)
47
+ except Exception as e:
48
+ SEED_REASON_STATUS.update(
49
+ state="error", error=repr(e) + "\n" + traceback.format_exc())
50
+ return SEED_REASON_STATUS
51
+
52
+
53
+ def format_seed_reason_status(status):
54
+ """Render SEED_REASON_STATUS as a compact human-readable string."""
55
+ lines = ["Seed reasoner: " + str(status.get("state"))]
56
+ summary = status.get("summary")
57
+ if summary:
58
+ lines.append(
59
+ "Processed {processed} | drafted {drafted} | "
60
+ "skipped {skipped} | errors {errors}".format(**summary))
61
+ if summary.get("stopped_quota"):
62
+ lines.append("Stopped: Mistral quota hit; resume later.")
63
+ if status.get("error"):
64
+ lines.append("Error: " + str(status["error"]).splitlines()[0])
65
+ return "\n".join(lines)
66
+
67
+
68
+ def start_seed_reasoner_thread(*, overwrite_ai=False):
69
+ t = threading.Thread(target=lambda: run_seed_reasoner(overwrite_ai=overwrite_ai),
70
+ name="seed-reasoner-loop", daemon=True)
71
+ t.start()
72
+ return t
video_library/seed_reason_runner.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase-3.5 orchestrator: draft a calibration reason for every decided clip.
2
+
3
+ For each clip the human already kept/rejected (Phase 3), ask a SeedReasoner to draft a
4
+ one-line principle-level reason, then upsert it into the curation_seed table as an AI
5
+ DRAFT (source='ai'). The human later eyeballs drafts in the seed tab and, on any edit,
6
+ the row is re-saved as source='human' (confirmed).
7
+
8
+ Design mirrors tag_runner.py:
9
+ * Resumable — every clip's reason is written immediately, so killing the process
10
+ loses at most one in-flight draft.
11
+ * Quota-aware — a 429 stops the run cleanly; rerun later to continue.
12
+ * Idempotent by default — clips that already have a reason are skipped, so a rerun
13
+ only fills the gaps. Pass overwrite_ai=True to redraft existing AI drafts (but never
14
+ clobber a human-confirmed reason).
15
+
16
+ The human's own rough note (if any already saved) is fed to the reasoner as signal.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import datetime as _dt
21
+ import time
22
+ from typing import Callable
23
+
24
+ from .seed_reasoner import is_quota_error
25
+ from .curation_app import pick_stream_url
26
+
27
+
28
+ def _now_iso() -> str:
29
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
30
+
31
+
32
+ def _clip_key(rec: dict) -> tuple[str, str]:
33
+ # Stored records are the flattened to_dict() shape: provider/provider_id are
34
+ # top-level, NOT nested under `candidate`.
35
+ return (rec.get("provider", ""), rec.get("provider_id", ""))
36
+
37
+
38
+ def _verdict_of(rec: dict) -> str:
39
+ return (rec.get("curation") or {}).get("verdict", "")
40
+
41
+
42
+ def run_seed_reasoning(store, reasoner, *,
43
+ limit: int = 10_000,
44
+ now: Callable[[], str] = _now_iso,
45
+ sleep: Callable[[float], None] = time.sleep,
46
+ rpm_seconds: float = 2.5,
47
+ overwrite_ai: bool = False,
48
+ quota_detector: Callable[[Exception], bool] = is_quota_error,
49
+ ) -> dict:
50
+ """Draft reasons for up to `limit` decided clips. Returns a summary dict.
51
+
52
+ Summary keys:
53
+ processed - decided clips inspected this run
54
+ drafted - reasons written this run
55
+ skipped - clips left as-is (already had a reason we won't touch)
56
+ errors - non-quota failures; clip left for a later retry
57
+ stopped_quota - True if a quota error halted the run
58
+ """
59
+ summary = {"processed": 0, "drafted": 0, "skipped": 0, "errors": 0,
60
+ "stopped_quota": False}
61
+
62
+ # What's already in curation_seed, so we can skip / respect human reasons.
63
+ existing = {(r["provider"], r["provider_id"]): r for r in store.load_seed()}
64
+
65
+ decided = store.load_decided_clips()
66
+ remaining = limit
67
+ for rec in decided:
68
+ if remaining <= 0:
69
+ break
70
+ provider, provider_id = _clip_key(rec)
71
+ if not provider_id:
72
+ continue
73
+ verdict = _verdict_of(rec)
74
+ if verdict not in ("kept", "rejected"):
75
+ continue
76
+
77
+ summary["processed"] += 1
78
+ remaining -= 1
79
+
80
+ prior = existing.get((provider, provider_id))
81
+ note = ""
82
+ if prior:
83
+ has_reason = bool((prior.get("reason") or "").strip())
84
+ source = prior.get("source") or "human"
85
+ # Never overwrite a human-confirmed reason. Skip existing AI drafts
86
+ # unless explicitly asked to redraft. A prior human note with no verdict
87
+ # change is still useful signal for (re)drafting.
88
+ if has_reason and source == "human":
89
+ summary["skipped"] += 1
90
+ continue
91
+ if has_reason and source == "ai" and not overwrite_ai:
92
+ summary["skipped"] += 1
93
+ continue
94
+ note = prior.get("reason") or ""
95
+
96
+ description = rec.get("description") or {}
97
+ moods = rec.get("moods") or []
98
+ clip_url = pick_stream_url(rec)
99
+
100
+ try:
101
+ reason = reasoner.draft(description, verdict, note)
102
+ except Exception as e:
103
+ if quota_detector(e):
104
+ summary["stopped_quota"] = True
105
+ return summary
106
+ summary["errors"] += 1
107
+ continue
108
+
109
+ store.set_seed(provider, provider_id, verdict=verdict, reason=reason,
110
+ moods=moods, clip_url=clip_url, now=now(), source="ai")
111
+ summary["drafted"] += 1
112
+ sleep(rpm_seconds)
113
+
114
+ return summary
video_library/seed_reasoner.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase-3.5 seed reasoner: draft a one-line calibration reason for a decided clip.
2
+
3
+ The human already gave the ground-truth signal in Phase 3 (kept / rejected). This
4
+ module turns that verdict — plus the clip's neutral description and any rough note the
5
+ human typed — into a clean, PRINCIPLE-LEVEL reason that a later AI visual scorer can
6
+ learn from without over-fixating on literal details.
7
+
8
+ Taste (the Osho / spiritual channel calibration) is encoded in REASONER_PROMPT:
9
+ WANT natural/nature footage, solitude/stillness/aesthetic calm, CAMERA movement that
10
+ reveals beauty, clean low-clutter composition.
11
+ AVOID in-frame subject motion & clutter, product/studio look, text/logos/faces, generic.
12
+ KEY camera movement = good; in-frame subject motion & clutter = bad (the subtle one).
13
+
14
+ We reuse the same plain-HTTP Mistral plumbing as mistral_backend.py (no SDK, injectable
15
+ session so tests never hit the network). The reason text comes back as strict JSON so we
16
+ can parse it deterministically.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ import time
23
+
24
+ MISTRAL_URL = "https://api.mistral.ai/v1/chat/completions"
25
+
26
+ # The description fields the reasoner is allowed to see. We deliberately hand it the
27
+ # neutral facts only — never the mood tags or search query — so the reason reflects the
28
+ # clip itself, matching how the future scorer will work.
29
+ _DESC_FIELDS = ("summary", "subjects", "environment", "lighting", "color_palette",
30
+ "motion", "camera", "people", "faces", "text_or_logo", "watermark",
31
+ "visual_quality", "raw_caption")
32
+
33
+ REASONER_PROMPT = """You calibrate a taste model for a spiritual (Osho) short-form video \
34
+ channel. The channel pairs slow, contemplative narration with a single beautiful moving \
35
+ image. You are given ONE clip's neutral description JSON and the human's verdict (kept or \
36
+ rejected). A rough human note may also appear — it is weak background signal only.
37
+
38
+ YOUR JOB: write ONE short reason (max ~14 words) that states, as a GENERAL PRINCIPLE, why \
39
+ a clip like this belongs (or doesn't) on the channel. The verdict is ground truth; explain \
40
+ the quality behind it — never argue with it.
41
+
42
+ THE TASTE (this, not the note, is the authority):
43
+ KEEP a clip when it delivers a single calm, unhurried image that lets narration breathe — \
44
+ open natural scenes (landscape, water, sky, forest, desert, still interiors), a feeling of \
45
+ solitude and spaciousness, and CAMERA motion that slowly unveils the scene (a drift, glide, \
46
+ drone, or slow pan). Clean composition, few elements, natural light.
47
+ REJECT a clip that fights the narration — busy in-frame subject motion, clutter or visual \
48
+ chaos, fast cuts; a product-shot / studio / commercial look; on-screen text, logos, or \
49
+ watermarks; prominent faces or people as the subject; anything generic, stocky, or \
50
+ algorithmic.
51
+ THE SUBTLE DISTINCTION most reasons hinge on: CAMERA movement is good (it reveals calm). \
52
+ SUBJECT movement inside the frame is bad (it distracts). Judge those two separately — a \
53
+ still subject under a gliding camera is ideal; a static camera on a fidgeting subject is not.
54
+
55
+ HOW TO PHRASE THE REASON:
56
+ - Name the QUALITY, never the literal contents. The scorer must generalize, so a reason \
57
+ tied to specific objects teaches it the wrong lesson.
58
+ - WRONG (too literal): "a bird flies over a lake at sunset"
59
+ RIGHT (principle): "calm open nature, camera glides to reveal it, uncluttered"
60
+ - WRONG (too literal): "person in a red jacket walks through a busy market"
61
+ RIGHT (principle): "busy subject motion and clutter compete with the narration"
62
+ - If a human note is literal, distill it to the underlying quality; do not repeat its words.
63
+
64
+ Return STRICT JSON and nothing else:
65
+ {"reason": "one short principle-level line"}
66
+ No markdown, no commentary outside the JSON."""
67
+
68
+
69
+ class SeedReasonParseError(Exception):
70
+ pass
71
+
72
+
73
+ class MistralQuotaError(Exception):
74
+ """Raised on an HTTP 429 so a runner can stop/fall through cleanly."""
75
+
76
+
77
+ def is_quota_error(exc: Exception) -> bool:
78
+ """True if an exception looks like a 429 / rate / quota from Mistral."""
79
+ s = str(exc).upper()
80
+ return ("429" in s) or ("RATE" in s and "LIMIT" in s) or ("QUOTA" in s) \
81
+ or ("RESOURCE_EXHAUSTED" in s) or ("TOO MANY REQUESTS" in s)
82
+
83
+
84
+ def _slim_description(description: dict) -> dict:
85
+ """Keep only the neutral fields; drop provenance and anything not decision-relevant."""
86
+ return {k: description[k] for k in _DESC_FIELDS if k in description}
87
+
88
+
89
+ def _user_message(description: dict, verdict: str, note: str) -> str:
90
+ payload = {
91
+ "verdict": verdict,
92
+ "description": _slim_description(description or {}),
93
+ # Weak background signal only — a rough human aside, often literal. The
94
+ # reason must reflect the taste spec and the description, not echo this.
95
+ "human_note_weak_signal": (note or "").strip(),
96
+ }
97
+ return ("Draft the principle-level reason for this clip. The verdict is ground "
98
+ "truth. Data follows:\n" + json.dumps(payload, indent=2))
99
+
100
+
101
+ def parse_reason_json(text: str) -> str:
102
+ """Extract the first valid JSON object from model text -> the reason string."""
103
+ if not text:
104
+ raise SeedReasonParseError("empty response")
105
+ candidates: list[str] = []
106
+ fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
107
+ if fence:
108
+ candidates.append(fence.group(1))
109
+ brace = re.search(r"\{.*\}", text, re.DOTALL)
110
+ if brace:
111
+ candidates.append(brace.group(0))
112
+ for c in candidates:
113
+ try:
114
+ obj = json.loads(c)
115
+ except json.JSONDecodeError:
116
+ continue
117
+ if not isinstance(obj, dict):
118
+ continue
119
+ reason = obj.get("reason")
120
+ if isinstance(reason, str) and reason.strip():
121
+ return reason.strip()
122
+ raise SeedReasonParseError("no valid reason in response")
123
+
124
+
125
+ class SeedReasoner:
126
+ """One call: (description, verdict, note) -> a principle-level reason string."""
127
+
128
+ def __init__(self, api_key, model="mistral-medium-2505", *,
129
+ session=None, sleep=time.sleep, max_retries=2, timeout=60):
130
+ self._key = api_key
131
+ self._model = model
132
+ self._session = session
133
+ self._sleep = sleep
134
+ self._max_retries = max_retries
135
+ self._timeout = timeout
136
+
137
+ def _ensure_session(self):
138
+ if self._session is None:
139
+ import requests
140
+ self._session = requests.Session()
141
+ return self._session
142
+
143
+ def _post(self, body):
144
+ sess = self._ensure_session()
145
+ headers = {"Authorization": "Bearer " + self._key,
146
+ "Content-Type": "application/json"}
147
+ resp = sess.post(MISTRAL_URL, headers=headers, json=body, timeout=self._timeout)
148
+ status = getattr(resp, "status_code", 200)
149
+ if status == 429:
150
+ raise MistralQuotaError("429 Too Many Requests")
151
+ if status >= 400:
152
+ text = getattr(resp, "text", "")
153
+ raise RuntimeError("Mistral HTTP " + str(status) + ": " + str(text)[:300])
154
+ return resp.json()
155
+
156
+ @staticmethod
157
+ def _text_of(data):
158
+ try:
159
+ return data["choices"][0]["message"]["content"] or ""
160
+ except (KeyError, IndexError, TypeError):
161
+ return ""
162
+
163
+ def draft(self, description: dict, verdict: str, note: str = "") -> str:
164
+ body = {
165
+ "model": self._model,
166
+ "messages": [
167
+ {"role": "system", "content": REASONER_PROMPT},
168
+ {"role": "user", "content": _user_message(description, verdict, note)},
169
+ ],
170
+ "response_format": {"type": "json_object"},
171
+ }
172
+ attempt = 0
173
+ last_exc = None
174
+ while attempt <= self._max_retries:
175
+ try:
176
+ data = self._post(body)
177
+ return parse_reason_json(self._text_of(data))
178
+ except MistralQuotaError:
179
+ raise
180
+ except Exception as e:
181
+ if is_quota_error(e):
182
+ raise
183
+ last_exc = e
184
+ attempt += 1
185
+ if attempt > self._max_retries:
186
+ break
187
+ self._sleep(2 ** attempt)
188
+ raise last_exc if last_exc else RuntimeError("SeedReasoner: exhausted retries")
video_library/vision_describe.py CHANGED
@@ -105,7 +105,8 @@ class GenAIBackend(VisionBackend):
105
  def __init__(self, api_key: str, model: str, *, strip_audio: bool = False,
106
  client=None, audio_stripper=strip_audio_track,
107
  sleep=time.sleep, poll_interval: float = 5.0, max_poll: int = 60,
108
- gen_config=None):
 
109
  self._key = api_key
110
  self._model = model
111
  self._strip_audio = strip_audio
@@ -115,6 +116,11 @@ class GenAIBackend(VisionBackend):
115
  self._poll_interval = poll_interval
116
  self._max_poll = max_poll
117
  self._gen_config = gen_config
 
 
 
 
 
118
 
119
  def _ensure_client(self):
120
  if self._client is None:
@@ -164,10 +170,10 @@ class GenAIBackend(VisionBackend):
164
  uploaded = self._wait_active(client, uploaded)
165
  resp = client.models.generate_content(
166
  model=self._model,
167
- contents=[uploaded, VISION_PROMPT],
168
  config=self._gen_config_obj(),
169
  )
170
- desc = parse_vision_json(self._text_of(resp))
171
  desc.described_by = self._model
172
  return desc
173
  finally:
 
105
  def __init__(self, api_key: str, model: str, *, strip_audio: bool = False,
106
  client=None, audio_stripper=strip_audio_track,
107
  sleep=time.sleep, poll_interval: float = 5.0, max_poll: int = 60,
108
+ gen_config=None, prompt: str = VISION_PROMPT,
109
+ parser=parse_vision_json):
110
  self._key = api_key
111
  self._model = model
112
  self._strip_audio = strip_audio
 
116
  self._poll_interval = poll_interval
117
  self._max_poll = max_poll
118
  self._gen_config = gen_config
119
+ # Prompt + parser are injectable so the same backend/router can run the
120
+ # Phase-4 feel judge (FEEL_PROMPT + parse_feel_json). Defaults preserve
121
+ # the neutral Phase-1 behavior for existing callers.
122
+ self._prompt = prompt
123
+ self._parser = parser
124
 
125
  def _ensure_client(self):
126
  if self._client is None:
 
170
  uploaded = self._wait_active(client, uploaded)
171
  resp = client.models.generate_content(
172
  model=self._model,
173
+ contents=[uploaded, self._prompt],
174
  config=self._gen_config_obj(),
175
  )
176
+ desc = self._parser(self._text_of(resp))
177
  desc.described_by = self._model
178
  return desc
179
  finally: