diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e9a4923 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.env +.env.* +.venv +**/__pycache__ +**/.pytest_cache +.mypy_cache +.ruff_cache +.import_linter_cache +var +models +*.task diff --git a/deploy/video/Dockerfile b/deploy/video/Dockerfile new file mode 100644 index 0000000..7a4ab20 --- /dev/null +++ b/deploy/video/Dockerfile @@ -0,0 +1,34 @@ +FROM ghcr.io/astral-sh/uv:0.11.21 AS uv + +FROM python:3.12-slim-trixie AS runtime +COPY --from=uv /uv /usr/local/bin/uv +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/app/.venv/bin:$PATH" +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 repit \ + && useradd --uid 10001 --gid repit --no-create-home repit \ + && mkdir -p /srv/repit/video \ + && chown repit:repit /srv/repit/video +WORKDIR /app +COPY pyproject.toml uv.lock README.md ./ +COPY src ./src +COPY evaluation/behavior/prepare_release.py ./evaluation/behavior/prepare_release.py +COPY deploy/video/logging.json ./deploy/video/logging.json +RUN uv sync --frozen --no-dev +USER 10001:10001 +CMD ["uvicorn", "app.main.run:make_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--log-config", "/app/deploy/video/logging.json"] + +FROM runtime AS cpu +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends libgl1 libegl1 libgles2 libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* +RUN uv sync --frozen --no-dev --extra behavior \ + && python -c "import mediapipe; import av" \ + && repit-behavior-analyzer --help +USER 10001:10001 +CMD ["python", "-m", "app.main.video_worker", "--lane", "cpu"] diff --git a/deploy/video/README.md b/deploy/video/README.md index 687d236..068308a 100644 --- a/deploy/video/README.md +++ b/deploy/video/README.md @@ -5,6 +5,8 @@ API·io/cpu/callback 워커·정리는 같은 서비스 계정, 로컬 SQLite DB, artifact 볼륨, 보안 설정을 사용한다. SQLite WAL을 네트워크 파일시스템에 두거나 서로 다른 호스트에서 공유하지 않는다. +컨테이너로 CPU 전용 runtime을 분리하는 구성은 [containers.md](containers.md)를 따른다. + ## 준비 1. 서비스 계정 `repit`과 `/srv/repit/video`를 만들고 해당 계정 소유·0700으로 설정한다. diff --git a/deploy/video/compose.yaml b/deploy/video/compose.yaml new file mode 100644 index 0000000..701b07c --- /dev/null +++ b/deploy/video/compose.yaml @@ -0,0 +1,71 @@ +name: repit-video + +x-service: &service + image: ${VIDEO_IMAGE_PREFIX:-repit-video}:runtime + build: + context: ../.. + dockerfile: deploy/video/Dockerfile + target: runtime + env_file: + - ${VIDEO_ENV_FILE:-./video.env} + environment: &video-environment + VIDEO_DATABASE_PATH: /srv/repit/video/jobs.sqlite3 + VIDEO_ARTIFACT_ROOT: /srv/repit/video/artifacts + VIDEO_ANALYZER__BACKEND: unconfigured + volumes: + - video-data:/srv/repit/video + init: true + read_only: true + tmpfs: + - /tmp:mode=1777 + restart: unless-stopped + mem_limit: 1g + +services: + api: + <<: *service + ports: + - "127.0.0.1:${VIDEO_HTTP_PORT:-8000}:8000" + + io: + <<: *service + command: ["python", "-m", "app.main.video_worker", "--lane", "io"] + mem_limit: 3g + + cpu: + <<: *service + image: ${VIDEO_IMAGE_PREFIX:-repit-video}:cpu + build: + context: ../.. + dockerfile: deploy/video/Dockerfile + target: cpu + command: ["python", "-m", "app.main.video_worker", "--lane", "cpu"] + environment: + <<: *video-environment + VIDEO_ANALYZER__BACKEND: process + VIDEO_ANALYZER__EXECUTABLE: /app/.venv/bin/repit-behavior-analyzer + VIDEO_ANALYZER__MODEL_PATH: /models/mp-face-pose-v1/manifest.json + VIDEO_ANALYZER__MODEL_VERSION: mp-face-pose-v1 + VIDEO_ANALYZER__DATA_SCHEMA_VERSION: behavior-v1 + volumes: + - video-data:/srv/repit/video + - type: bind + source: ${VIDEO_MODEL_RELEASE_DIR:?Set an absolute path to the prepared model release} + target: /models/mp-face-pose-v1 + read_only: true + bind: + create_host_path: false + mem_limit: 3g + + callback: + <<: *service + command: ["python", "-m", "app.main.video_worker", "--lane", "callback"] + mem_limit: 3g + + cleanup: + <<: *service + command: ["python", "-m", "app.main.video_cleanup", "--interval", "3600", "--batch", "100", "--max-passes", "100"] + mem_limit: 256m + +volumes: + video-data: diff --git a/deploy/video/containers.md b/deploy/video/containers.md new file mode 100644 index 0000000..ba7db24 --- /dev/null +++ b/deploy/video/containers.md @@ -0,0 +1,65 @@ +# 영상 CPU runtime 분리 + +기존 systemd 구성과 선택해서 사용한다. 같은 SQLite/artifact 디렉터리를 두 구성에서 동시에 실행하지 않는다. +기본 API/io/callback/cleanup 이미지는 behavior extra와 음성 overlay를 설치하지 않는다. +CPU 이미지만 `uv sync --frozen --no-dev --extra behavior`와 native 추론 라이브러리를 설치한다. +음성 워커의 환경·설치 버전은 이 빌드에 포함하지 않는다. + +## 이미지 준비 + +저장소 루트에서 실행한다. Python 3.12 Debian Trixie 기반이며 이미지 digest·OS/architecture· +Python patch·ffmpeg/ffprobe·uv.lock SHA를 실제 배포 기록에 남긴다. +실제 호스트 architecture로 빌드·검사한다. Apple Silicon의 amd64 에뮬레이션은 AVX가 없어 추론 검증에 쓰지 않는다. 아래 명령은 운영 서비스 시작을 포함하지 않는다. + +```sh +docker build -f deploy/video/Dockerfile --target runtime -t repit-video:runtime . +docker build -f deploy/video/Dockerfile --target cpu -t repit-video:cpu . +docker run --rm repit-video:cpu python -c 'import mediapipe; import av; import numpy' +docker run --rm repit-video:cpu repit-behavior-analyzer --help +``` + +## 모델 release 준비 + +공식 두 파일과 SHA-256은 [행동 분석 문서](../../docs/behavior-analysis.md)의 값을 사용한다. +다운로드는 운영자가 준비 단계에 수행하며 서비스 시작·작업 중에는 다운로드하지 않는다. +`VIDEO_MODEL_RELEASE_DIR`는 호스트의 절대 release 디렉터리다. 모델을 담은 디렉터리를 준비한 뒤 실행한다. +컨테이너에서 디렉터리에 쓸 권한을 가진 준비 담당자의 UID/GID로 manifest를 생성한다. + +```sh +export VIDEO_MODEL_RELEASE_DIR=/absolute/models/mp-face-pose-v1 +docker run --rm --user "$(id -u):$(id -g)" --mount "type=bind,src=$VIDEO_MODEL_RELEASE_DIR,dst=/release" repit-video:cpu python evaluation/behavior/prepare_release.py /release +``` + +공식 hash와 다른 파일·symlink는 거부하며 기존 manifest는 덮어쓰지 않는다. +Python minor·MediaPipe/PyAV/NumPy 버전을 실제 추론 이미지로 기록한다. 다른 환경에서 만든 예제 manifest를 복사하지 않는다. +준비 후 UID 10001이 디렉터리를 탐색하고 모델·manifest를 읽을 수 있게 권한을 확인한다. +Compose는 모델을 `/models/mp-face-pose-v1`에 읽기 전용으로 연결한다. + +## 공통 환경과 기동 + +`video.env.example`을 저장소 밖의 보호된 파일로 복사해 실제 토큰·호스트를 채운다. +`VIDEO_ENV_FILE`에는 그 절대 경로를 지정한다. 이 파일을 셸로 source하지 않는다. +`APP_ENVIRONMENT=production`, `VIDEO_ENABLED=true`와 기존 인증·allowlist 설정이 필요하다. +API의 신규 분석 OFF와 Analysis 라우터 OFF는 다르며 기존 PENDING 복구 중에는 라우터를 유지한다. + +```sh +export VIDEO_ENV_FILE=/absolute/protected/video.env +docker compose -f deploy/video/compose.yaml config --quiet +# 배포 승인을 받은 환경에서만 실행 +docker compose -f deploy/video/compose.yaml up -d --build +``` + +환경 우선순위는 Compose `environment`가 `env_file`보다 높다. DB/artifact 절대 경로는 모든 서비스에서 고정하며, +CPU backend·실행 파일·manifest 경로·버전도 Compose에서 고정한다. 기본 서비스의 backend는 unconfigured다. +토큰·S3 source/callback allowlist·VIDEO_ENABLED·timeout·보관 설정은 보호된 env 파일에서 가져온다. +환경값이 출력되는 `config` 대신 `config --quiet`로 검사한다. + +모든 서비스는 UID/GID 10001로 한 호스트의 `video-data` named volume을 공유한다. +새 named volume은 이미지의 `/srv/repit/video` 소유권을 상속한다. 기존 volume의 소유권은 별도 확인한다. +SQLite를 다른 호스트·NFS에 공유하지 않는다. API에는 모델 mount가 없으며 CPU lane만 모델을 검증한다. +기본 CPU 동시성은 기존 1이다. 워커당 3GiB 컨테이너 제한과 analyzer RSS 2GiB 감시·900초 제한을 함께 적용한다. +수치는 실측 완료를 뜻하지 않으며 30/60분 영상·연속 유입·음성 동시 사용으로 확인한다. +API는 호스트 loopback에만 열고 내부 HTTPS gateway로 연결한다. cleanup은 이 구성의 상주 프로세스 하나만 사용한다. + +D5 원본 삭제 종결 정책은 미확정이다. 기존 cleanup의 기본 24시간을 보관 승인으로 해석하지 않는다. +실제 S3·인증 callback·GET 복구, 카메라 영상, 사람 주석·holdout 평가를 통과하기 전 운영 활성화 완료로 기록하지 않는다. diff --git a/docs/behavior-analysis.md b/docs/behavior-analysis.md index fff1a3c..7026795 100644 --- a/docs/behavior-analysis.md +++ b/docs/behavior-analysis.md @@ -126,3 +126,11 @@ BEHAVIOR_REAL_MODELS_REQUIRED=1이면 설정 누락을 성공/skip으로 처리 사람 주석 평가를 별도로 확인한다. 기본 분석 timeout은900초·RSS 감시는2GiB이며 자동으로 늘리지 않는다. 모델 교체는 API 신규 분석을 끄고 기존 작업을 모두 끝낸 다음 같은 release의 모든 워커를 교체한다. 모델별 job snapshot 라우팅·무중단 다중 모델은 지원하지 않는다. 과거 결과를 새 모델 결과로 덮어쓰지 않는다. + + +## 2026-10-21 연결 보완 + +모델 준비·기동 검증은 공식 SHA-256과 출처까지 고정 release와 대조한다. +임의 파일의 hash를 새 manifest에 적어 같은 release로 등록하는 것은 허용하지 않는다. +CPU 전용 이미지·Compose는 [실행 절차](../deploy/video/containers.md)를 따른다. +실제 모델·Chromium·전체 회귀 결과와 남은 인수 gate는 [검증 기록](behavior-verification-20261021.md)에 구분했다. diff --git a/docs/behavior-verification-20261021.md b/docs/behavior-verification-20261021.md new file mode 100644 index 0000000..4e4f68c --- /dev/null +++ b/docs/behavior-verification-20261021.md @@ -0,0 +1,88 @@ +# 2026-10-21 행동 분석 연결 수정·검증 기록 + +기준: fetch 후 main `8b3302783a25e262a49768802f832a8eda3714d3`. +브랜치: `feature/#58-action-analyze`. 코드·실행 구성 결과 commit: `d6cb8fe5a09b52dcc16e8e3de15d3f4eb60c2041`. +로컬 준비·검증 결과이며 실제 운영 배포·S3 연동·사람 평가 완료를 뜻하지 않는다. + +## 발견한 결함과 수정 + +- 기존 prepare_release는 임의 파일의 hash를 그대로 manifest에 넣었으며 load_manifest도 그 자기 선언만 검사했다. + 실제로 잘못된 두 파일이 mp-face-pose-v1로 통과하는 것을 수정 전에 재현했다. +- 준비와 워커 기동 모두 공식 두 SHA-256·출처를 고정 release와 대조한다. 파일과 manifest를 함께 바꿔도 거절한다. +- hash·경로·runtime metadata를 검사한 뒤에만 manifest를 생성한다. symlink·누락 파일을 거절하고 기존 release는 덮어쓰지 않는다. +- API/io/callback/cleanup과 CPU 이미지를 분리했다. CPU만 behavior extra와 native 추론 라이브러리를 설치한다. + 음성 overlay·음성 워커의 설치 버전은 변경하지 않았다. +- Compose는 한 호스트의 SQLite/artifact volume과 읽기 전용 모델을 연결한다. 경로·환경 우선순위는 + [컨테이너 절차](../deploy/video/containers.md)에 기록했다. +- Bookworm FFmpeg 5에서 기존 rotation/VFR 검사가 실패하여 FFmpeg 7을 제공하는 Trixie 기반으로 보완했다. + +## 고정 모델과 실행 환경 + +공식 모델 source URL과 SHA-256은 [기존 행동 분석 문서](behavior-analysis.md)의 두 파일과 일치한다. +별도 새 release 디렉터리에서 CPU 이미지로 prepare_release를 실행했으며 기존 release를 덮어쓰지 않았다. + +| 항목 | 실제 검증 환경 | +|---|---| +| Python | 3.12.15 | +| OS/architecture | Debian Trixie Linux / aarch64 | +| MediaPipe / PyAV / NumPy | 1.1.1 / 19.0.1 / 2.5.4 | +| ffmpeg / ffprobe | 7.1.5-0+deb13u1 | +| uv.lock SHA-256 | `942bc0d919882d86d213770bbd3486891bf443aa2cdb130250a5184fc4fdf359` | +| model / thresholds / data / outer schema | mp-face-pose-v1 / behavior-policy-v1 / behavior-v1 / 1 | + +실제로 빌드하고 검사한 로컬 이미지 ID: + +- runtime: `sha256:d2f44df8d14c918b3daf8169a6b896be13c633ed3972e230f7a1c6202e024c85` +- CPU: `sha256:bdde722291a5413f1b5f6cd79e8016d696b5b9712e29a29e86c5eca01cb402b3` + +Linux amd64 에뮬레이션은 이 Apple Silicon 호스트에서 AVX 미지원으로 실제 추론을 실행하지 못했다. +성공한 실제 모델 검증은 호스트와 같은 Linux ARM64에서 수행했다. 배포할 x86 호스트의 별도 검증은 남아 있다. + +## 실행한 검사 + +| 검사 | 결과 | +|---|---| +| frozen lock 별도 환경 설치 | 통과 | +| ruff check --no-fix src tests 및 prepare_release | 통과 | +| ruff format --check src tests 및 prepare_release | 통과 | +| mypy src tests | 384 source files 통과 | +| lint-imports | 2 contracts 통과 | +| macOS 전체 pytest 첫 검증 | 1351 통과 / 13 skip / 실패 0 | +| Linux 전체 pytest 최종 검증 | 1366 통과 / skip 0 / 실패 0 | +| UID 10001·읽기 전용 파일시스템 실제 모델 재검증 | 5 통과 / skip 0 | +| runtime/CPU 이미지 실제 build·native import·CLI help | 통과 | +| 기본 이미지의 MediaPipe/PyAV/NumPy/faster-whisper 미설치 | 확인 | +| io/cpu/callback --lane·--once 기동 | 공유 임시 volume·UID 10001·읽기 전용 root에서 통과 | +| Compose config --quiet | synthetic 토큰/호스트로 통과 | +| 공유 ready/partial/zero-face fixture | 전달 계약 JSON과 세 파일 동일 | + +최종 Linux 검사는 BEHAVIOR_MODEL_MANIFEST·BEHAVIOR_TEST_PORTRAIT·analyzer PATH와 +BEHAVIOR_REAL_MODELS_REQUIRED=1, VIDEO_REAL_MEDIA_REQUIRED=1을 명시했다. +검증 전용 이미지에만 개발 도구·Git·Chromium을 설치했다. 배포 이미지에는 추가하지 않았다. +macOS의 FFmpeg/브라우저 누락 skip은 최종 Linux에서 실제 도구·fixture를 준비해 모두 실행했다. + +실제 모델은 H.264/VP8/VP9 portrait clip의 얼굴 관찰·ready/partial 계약, +1초 영상의 unavailable/BEHAVIOR_NOT_MEASURABLE/retryable=false, +12초 검은 영상의 partial/얼굴 0%와 나머지 항목 null·사유를 확인했다. +Chromium 145 MediaRecorder가 만든 canvas VP8/VP9를 실제 파일 검사·전체 decode에 통과시켰다. +이 반복 이미지·canvas 영상은 실행 smoke이며 사람 주석·실제 카메라 품질 평가를 대신하지 않는다. + +SOLO/MULTI·기업 유무 조합에서 interviewStatus 누락/null/ABANDONED/IN_PROGRESS/알 수 없는 값/소문자/숫자/ +boolean/list/object는 422이며 job 생성·LLM·콜백은 0회다. COMPLETED의 기존 성공·실패 흐름도 회귀에 포함된다. +영상·음성 HTTP DTO에 내용 채점 자격 필드를 추가하거나 API DB 조회를 만들지 않았다. +기존 멱등 접수·콜백 재시도·GET·crash recovery·일반 직군·기업·음성 경로는 전체 테스트에 포함된다. + +## 미검증·미확정 인수 항목 + +- 실제 S3 presigned 다운로드 → 배포 API 인증 callback → GET 복구·202 응답 유실 복구. + 로컬 대역/회귀 결과를 실제 S3 성공으로 기록하지 않는다. +- Chrome/Edge/Safari의 실제 카메라, 저조도·다중 인물·작은 얼굴·어깨 잘림 등 촬영 조건. +- 6명/30클립/30분·독립 이중 주석/holdout, 지표별 F1·비율 오차·coverage 품질 gate. +- 30/60분 전체 추론의 단계별 지연·RSS·출력 크기, 연속 유입·음성 동시 처리량. + 기존 60분 입력 경계 회귀는 장시간 실제 추론 벤치마크가 아니다. +- D5 삭제 종결·실패/재시도 수명은 미확정이다. API 원본 삭제·재분석 금지와 Analysis 진행 작업/전사 삭제 연계는 + 정책 확정 후 별도 구현·실제 연동 검증이 필요하다. 기본 24시간 cleanup을 보관 승인으로 해석하지 않는다. +- 배포 환경 토큰 일치·실제 볼륨 권한·서버 자원·API/Web의 신규 활성화 gate. + +공동 운영 활성화 기준은 아직 충족했다고 선언하지 않는다. 운영 flag·서비스·데이터는 변경하지 않았다. +원본 영상·모델 파일·토큰·presigned URL은 Git에 추가하지 않았다. 임시 검증 volume은 검사 후 정리했다. diff --git a/evaluation/behavior/prepare_release.py b/evaluation/behavior/prepare_release.py index 838a54d..ec363e2 100644 --- a/evaluation/behavior/prepare_release.py +++ b/evaluation/behavior/prepare_release.py @@ -1,4 +1,4 @@ -"""Record hashes and locked runtime versions for explicitly provisioned model assets.""" +"""Verify pinned assets and record locked runtime versions without replacing a release.""" from __future__ import annotations @@ -6,21 +6,38 @@ import hashlib from pathlib import Path -from app.outbound.adapters.behavior.manifest import load_manifest, manifest_template +from app.outbound.adapters.behavior.manifest import CHUNK_BYTES, ModelManifest, load_manifest, manifest_template + + +def prepare_release(directory: Path) -> Path: + root = directory.resolve(strict=True) + path = root / "manifest.json" + hashes = [] + for name in ("face_landmarker.task", "pose_landmarker_lite.task"): + asset = root / name + if asset.is_symlink() or not asset.is_file(): + raise ValueError("model asset must be a regular release file") + checksum = hashlib.sha256() + with asset.open("rb") as source: + while block := source.read(CHUNK_BYTES): + checksum.update(block) + hashes.append(checksum.hexdigest()) + # Validate the pinned hashes, package metadata and paths before creating any release record. + body = manifest_template(*hashes) + manifest = ModelManifest.model_validate_json(body) + for model_asset in (manifest.face, manifest.pose): + manifest.asset_path(path, model_asset) + with path.open("x", encoding="utf-8") as output: + output.write(body) + load_manifest(path, "mp-face-pose-v1") + return path def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("directory", type=Path) args = parser.parse_args() - root = args.directory.resolve() - face = hashlib.sha256((root / "face_landmarker.task").read_bytes()).hexdigest() - pose = hashlib.sha256((root / "pose_landmarker_lite.task").read_bytes()).hexdigest() - path = root / "manifest.json" - # Do not silently replace an immutable release record. - with path.open("x", encoding="utf-8") as output: - output.write(manifest_template(face, pose)) - load_manifest(path, "mp-face-pose-v1") + prepare_release(args.directory) if __name__ == "__main__": diff --git a/src/app/outbound/adapters/behavior/manifest.py b/src/app/outbound/adapters/behavior/manifest.py index 6ef949d..9c7dc12 100644 --- a/src/app/outbound/adapters/behavior/manifest.py +++ b/src/app/outbound/adapters/behavior/manifest.py @@ -13,6 +13,17 @@ MANIFEST_BYTES = 64 * 1_024 CHUNK_BYTES = 1_024 * 1_024 +MODEL_BASE = "https://storage.googleapis.com/mediapipe-models" +RELEASE_ASSETS = { + "face": ( + "64184e229b263107bc2b804c6625db1341ff2bb731874b0bcc2fe6544e0bc9ff", + f"{MODEL_BASE}/face_landmarker/face_landmarker/float16/1/face_landmarker.task", + ), + "pose": ( + "59929e1d1ee95287735ddd833b19cf4ac46d29bc7afddbbf6753c459690d574a", + f"{MODEL_BASE}/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task", + ), +} class ModelAsset(BehaviorModel): @@ -60,7 +71,9 @@ def load_manifest(path: Path, expected_version: str, expected_schema: str = "beh for package in ("mediapipe", "av", "numpy"): if version(package) != getattr(manifest.runtime, package): raise ValueError("model package runtime mismatch") - for asset in (manifest.face, manifest.pose): + for name, asset in (("face", manifest.face), ("pose", manifest.pose)): + if (asset.sha256, asset.source_url) != RELEASE_ASSETS[name]: + raise ValueError("model asset does not match the pinned release") checksum = hashlib.sha256() with manifest.asset_path(path, asset).open("rb") as source: while block := source.read(CHUNK_BYTES): @@ -72,7 +85,8 @@ def load_manifest(path: Path, expected_version: str, expected_schema: str = "beh def manifest_template(face_hash: str, pose_hash: str) -> str: """Build a release record after explicit operator-controlled asset provisioning.""" - base = "https://storage.googleapis.com/mediapipe-models" + if face_hash != RELEASE_ASSETS["face"][0] or pose_hash != RELEASE_ASSETS["pose"][0]: + raise ValueError("model asset does not match the pinned release") return ( json.dumps( { @@ -87,12 +101,12 @@ def manifest_template(face_hash: str, pose_hash: str) -> str: "face": { "path": "face_landmarker.task", "sha256": face_hash, - "sourceUrl": f"{base}/face_landmarker/face_landmarker/float16/1/face_landmarker.task", + "sourceUrl": RELEASE_ASSETS["face"][1], }, "pose": { "path": "pose_landmarker_lite.task", "sha256": pose_hash, - "sourceUrl": f"{base}/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task", + "sourceUrl": RELEASE_ASSETS["pose"][1], }, }, indent=2, diff --git a/tests/behavior/test_manifest.py b/tests/behavior/test_manifest.py index 96b64dc..24bfd33 100644 --- a/tests/behavior/test_manifest.py +++ b/tests/behavior/test_manifest.py @@ -9,12 +9,23 @@ from app.main.video_config import AnalyzerSettings, VideoSettings from app.main.video_worker import WorkerBootError, configured_analyzer +from app.outbound.adapters.behavior import manifest as release_manifest from app.outbound.adapters.behavior.manifest import load_manifest, manifest_template if any(find_spec(package) is None for package in ("numpy", "mediapipe", "av")): pytest.skip("behavior extra is not installed", allow_module_level=True) +@pytest.fixture(autouse=True) +def synthetic_assets(monkeypatch: pytest.MonkeyPatch) -> None: + for name, content in (("face", b"face"), ("pose", b"pose")): + monkeypatch.setitem( + release_manifest.RELEASE_ASSETS, + name, + (hashlib.sha256(content).hexdigest(), release_manifest.RELEASE_ASSETS[name][1]), + ) + + def release(tmp_path: Path) -> Path: face, pose = tmp_path / "face_landmarker.task", tmp_path / "pose_landmarker_lite.task" face.write_bytes(b"face") @@ -32,7 +43,7 @@ def test_hash_runtime_and_release_binding(tmp_path: Path) -> None: load_manifest(path, "mp-face-pose-v1") -@pytest.mark.parametrize("mutation", ["runtime", "version", "traversal", "symlink"]) +@pytest.mark.parametrize("mutation", ["runtime", "version", "traversal", "symlink", "replacement", "source"]) def test_reject_invalid_release(tmp_path: Path, mutation: str) -> None: path = release(tmp_path) raw = json.loads(path.read_text()) @@ -42,6 +53,11 @@ def test_reject_invalid_release(tmp_path: Path, mutation: str) -> None: raw["dataSchemaVersion"] = "unknown" elif mutation == "traversal": raw["face"]["path"] = "../outside.task" + elif mutation == "replacement": + (tmp_path / "face_landmarker.task").write_bytes(b"substituted model") + raw["face"]["sha256"] = hashlib.sha256(b"substituted model").hexdigest() + elif mutation == "source": + raw["face"]["sourceUrl"] = "https://example.com/different.task" else: (tmp_path / "link.task").symlink_to(tmp_path / "face_landmarker.task") raw["face"]["path"] = "link.task" diff --git a/tests/behavior/test_prepare_release.py b/tests/behavior/test_prepare_release.py new file mode 100644 index 0000000..3c2d8a0 --- /dev/null +++ b/tests/behavior/test_prepare_release.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest +from evaluation.behavior.prepare_release import prepare_release + +from app.outbound.adapters.behavior import manifest + + +@pytest.fixture +def assets(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for name, filename in (("face", "face_landmarker.task"), ("pose", "pose_landmarker_lite.task")): + content = name.encode() + (tmp_path / filename).write_bytes(content) + monkeypatch.setitem( + manifest.RELEASE_ASSETS, name, (hashlib.sha256(content).hexdigest(), "https://example.com/model") + ) + monkeypatch.setattr(manifest, "version", lambda _: "test-runtime") + return tmp_path + + +def test_preparation_records_a_verified_release_and_never_overwrites(assets: Path) -> None: + path = prepare_release(assets) + original = path.read_bytes() + assert manifest.load_manifest(path, "mp-face-pose-v1").model_version == "mp-face-pose-v1" + with pytest.raises(FileExistsError): + prepare_release(assets) + assert path.read_bytes() == original + + +@pytest.mark.parametrize("filename", ["face_landmarker.task", "pose_landmarker_lite.task"]) +def test_modified_asset_never_creates_a_release(assets: Path, filename: str) -> None: + (assets / filename).write_bytes(b"wrong model") + with pytest.raises(ValueError, match="pinned release"): + prepare_release(assets) + assert not (assets / "manifest.json").exists() + + +def test_official_release_hashes_are_checked_before_package_metadata(tmp_path: Path) -> None: + (tmp_path / "face_landmarker.task").write_bytes(b"not the official face model") + (tmp_path / "pose_landmarker_lite.task").write_bytes(b"not the official pose model") + with pytest.raises(ValueError, match="pinned release"): + prepare_release(tmp_path) + assert not (tmp_path / "manifest.json").exists() + + +def test_symlink_asset_never_creates_a_release(assets: Path) -> None: + face = assets / "face_landmarker.task" + face.rename(assets / "original.task") + face.symlink_to(assets / "original.task") + with pytest.raises(ValueError, match="regular release file"): + prepare_release(assets) + assert not (assets / "manifest.json").exists() diff --git a/tests/behavior/test_real_models.py b/tests/behavior/test_real_models.py index b4fa431..c3b2530 100644 --- a/tests/behavior/test_real_models.py +++ b/tests/behavior/test_real_models.py @@ -75,3 +75,55 @@ async def test_actual_cli_models_and_codec_output( assert data.metrics["faceVisibleRatio"].value is not None assert data.metrics["faceVisibleRatio"].value > 0.8 assert "COMPONENT_FAILURE" not in data.warnings + + +@pytest.mark.parametrize(("frames", "expected"), [(5, "unavailable"), (60, "partial")]) +@pytest.mark.asyncio +async def test_actual_models_keep_short_video_and_zero_face_distinct( + tmp_path: Path, frames: int, expected: str +) -> None: + manifest, _, executable = real_inputs() + av = import_module("av") + np = import_module("numpy") + path = tmp_path / "black.mp4" + rgb = np.zeros((240, 320, 3), dtype=np.uint8) + with av.open(str(path), "w") as output: + stream = output.add_stream("libx264", rate=5) + stream.width, stream.height, stream.pix_fmt = 320, 240, "yuv420p" + for index in range(frames): + frame = av.VideoFrame.from_ndarray(rgb, format="rgb24") + frame.pts = index + for packet in stream.encode(frame): + output.mux(packet) + for packet in stream.encode(): + output.mux(packet) + contents = path.read_bytes() + prepared = PreparedVideo( + video_id="real-black", + path=str(path), + bytes=len(contents), + sha256=hashlib.sha256(contents).hexdigest(), + container="mp4", + codec="h264", + display_long_edge=320, + display_short_edge=240, + rotation_degrees=0, + frame_count=frames, + duration_ms=frames * 200, + average_fps=5, + ) + outcome = await ProcessVideoAnalyzer(executable, manifest, "mp-face-pose-v1", "behavior-v1").analyze( + prepared, VideoLimits() + ) + assert outcome.status == expected + if expected == "unavailable": + assert outcome.data is None + assert outcome.error is not None + assert outcome.error.code == "BEHAVIOR_NOT_MEASURABLE" + assert outcome.error.retryable is False + else: + assert outcome.error is None + data = BehaviorData.model_validate(outcome.data) + assert data.metrics["faceVisibleRatio"].value == 0 + assert data.metrics["headFacingRatio"].value is None + assert data.metrics["headFacingRatio"].unavailable_reason == "NO_SINGLE_FACE" diff --git a/tests/inbound/test_feedback_interview_status.py b/tests/inbound/test_feedback_interview_status.py index 7503489..56cc397 100644 --- a/tests/inbound/test_feedback_interview_status.py +++ b/tests/inbound/test_feedback_interview_status.py @@ -11,9 +11,11 @@ @pytest.mark.parametrize("mode", ["solo", "multi"]) @pytest.mark.parametrize("with_company", [False, True]) -@pytest.mark.parametrize("state", ["missing", None, "ABANDONED", "IN_PROGRESS", "UNKNOWN", "completed"]) +@pytest.mark.parametrize( + "state", ["missing", None, "ABANDONED", "IN_PROGRESS", "UNKNOWN", "completed", 1, True, [], {}] +) async def test_incomplete_feedback_is_rejected_without_side_effects( - mode: str, with_company: bool, state: str | None, monkeypatch: pytest.MonkeyPatch + mode: str, with_company: bool, state: object, monkeypatch: pytest.MonkeyPatch ) -> None: body = feedback_body() if with_company: