Spaces:
Sleeping
Sleeping
Upload 52 files
Browse files- docs/superpowers/plans/2026-07-21-feel-scorer.md +1672 -0
- docs/superpowers/specs/2026-07-20-feel-scorer-design.md +192 -0
- video_library/__main__.py +48 -1
- video_library/__pycache__/__init__.cpython-312.pyc +0 -0
- video_library/__pycache__/__main__.cpython-312.pyc +0 -0
- video_library/__pycache__/app.cpython-312.pyc +0 -0
- video_library/__pycache__/config.cpython-312.pyc +0 -0
- video_library/__pycache__/curation_app.cpython-312.pyc +0 -0
- video_library/__pycache__/downloader.cpython-312.pyc +0 -0
- video_library/__pycache__/feel_score_app.cpython-312.pyc +0 -0
- video_library/__pycache__/feel_score_runner.cpython-312.pyc +0 -0
- video_library/__pycache__/feel_scorer.cpython-312.pyc +0 -0
- video_library/__pycache__/library_builder.cpython-312.pyc +0 -0
- video_library/__pycache__/mistral_backend.cpython-312.pyc +0 -0
- video_library/__pycache__/models.cpython-312.pyc +0 -0
- video_library/__pycache__/mood_rules.cpython-312.pyc +0 -0
- video_library/__pycache__/mood_tagger.cpython-312.pyc +0 -0
- video_library/__pycache__/reject_rules.cpython-312.pyc +0 -0
- video_library/__pycache__/stock_search.cpython-312.pyc +0 -0
- video_library/__pycache__/tag_app.cpython-312.pyc +0 -0
- video_library/__pycache__/tag_runner.cpython-312.pyc +0 -0
- video_library/__pycache__/taxonomy.cpython-312.pyc +0 -0
- video_library/__pycache__/vision_describe.cpython-312.pyc +0 -0
- video_library/app.py +57 -0
- video_library/catalog_store.py +248 -15
- video_library/feel_score_app.py +223 -0
- video_library/feel_score_runner.py +88 -0
- video_library/feel_scorer.py +413 -0
- video_library/seed_app.py +34 -9
- video_library/seed_reason_app.py +72 -0
- video_library/seed_reason_runner.py +114 -0
- video_library/seed_reasoner.py +188 -0
- 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
|
| 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
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 168 |
config=self._gen_config_obj(),
|
| 169 |
)
|
| 170 |
-
desc =
|
| 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:
|