Files
ECC/skills/taste-application/scripts/tasteforge/interview.py
T
928c1dea72 feat(tasteforge): package reusable workflows and preserve native edits (#3033)
* feat: bundle standalone taste distillation and application workflows

* docs: fix imported taste skill markdown lint

* docs: align Turkish agent catalog with taste skills

* refactor: make ECC the canonical reusable video engine

* fix: preserve video duration when applying image overlays

* fix: preserve background colors in image compositing

* fix: report best-effort duration targets and shortfalls

* feat: ship verified Fusion presets with compatibility provenance

* feat(tasteforge): preserve native edits in application bundles

* feat(tasteforge): compile local preservation without hosted input

* fix: update js-yaml to patched 4.3.2

* test: report bounded Stop wrapper failure diagnostics

* fix(tasteforge): fail closed on unsafe output names, missing overlays and cadence

- cli: default report and spec paths are derived from pack name and profile
  genre; require the manifest's name pattern before using either as a
  filename part so a traversal string cannot write outside cwd/out.
- apply_local: a pack without cadence.json, or with no measured shots and
  no explicit mean_shot, raises instead of silently planning 1.0s shots and
  reporting a measured cadence.
- legacy apply: a missing overlay aborts before any paid upload; forge()
  would have rejected it after every take was generated.
- requirements-live: pin fal-client>=0.13.0, the first release whose
  subscribe() accepts client_timeout.

Addresses the five P1 findings from the independent review of #3033.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fxHRsydPqEcYngGbqkgt1

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 15:31:36 +01:00

98 lines
3.2 KiB
Python

"""Deterministic taste interview: answers in, structured profile out.
The interview is the local, human half of distillation. It asks the same axes
the recovered implementation asks a vision model (palette, grain, lighting,
lens, motion, framing, grade, mood, avoid) plus the content brief, and keeps
look and content strictly separate - collapsing them is the standard failure
(style words leak into the scene; subject words get read as style).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from . import schema
__all__ = ["Question", "QUESTIONS", "conduct"]
_LOOK_QUESTIONS = (
("palette", "Name the dominant colors and how they are distributed."),
("grain", "Describe texture/noise character (e.g. fine 35mm grain)."),
("lighting", "Key/fill/practical sources and their quality?"),
("focal_length", "Apparent focal length and its perspective effect?"),
("camera_motion", "How does the camera move, or is it locked off?"),
("subject_framing", "How do subjects sit in frame (headroom, thirds, negative space)?"),
("grade_description", "The color grade, in colorist language?"),
("mood_adjectives", "Three adjectives for the mood, comma-separated."),
("avoid", "Failure modes to avoid, comma-separated."),
)
_CONTENT_QUESTIONS = (
("brief", "What should happen on screen (subject, action, place)?"),
)
@dataclass(frozen=True)
class Question:
id: str
prompt: str
axis: str # "look" or "content"
QUESTIONS: tuple[Question, ...] = (
*(Question(qid, prompt, "look") for qid, prompt in _LOOK_QUESTIONS),
*(Question(qid, prompt, "content") for qid, prompt in _CONTENT_QUESTIONS),
)
def _split_list(value: str) -> list[str]:
return [part.strip() for part in value.replace(";", ",").split(",") if part.strip()]
def _utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def conduct(answers: dict[str, str], genre: str = "untitled") -> dict[str, Any]:
"""Build a TasteProfile from free-text answers. Deterministic, offline.
Missing answers are recorded under ``unanswered`` - never invented.
"""
look: dict[str, Any] = {}
unanswered: list[str] = []
for qid, _ in _LOOK_QUESTIONS:
raw = (answers.get(qid) or "").strip()
if not raw:
unanswered.append(qid)
continue
if qid in ("mood_adjectives", "avoid"):
look[qid] = _split_list(raw)
else:
look[qid] = raw
# Schema floor: mood_adjectives and avoid must exist as lists.
look.setdefault("mood_adjectives", [])
look.setdefault("avoid", [])
brief = (answers.get("brief") or "").strip()
if not brief:
unanswered.append("brief")
profile = {
"schema_version": 1,
"genre": genre,
"created": _utc_now(),
"answers": {k: str(v).strip() for k, v in answers.items() if str(v).strip()},
"unanswered": unanswered,
"constraints": {
"look": look,
"content": {"brief": brief},
},
}
problems = schema.validate(profile, schema.TASTE_PROFILE_SCHEMA)
if problems:
raise ValueError(f"interview produced an invalid profile: {problems}")
return profile