Run model: long-running scheduler container (APScheduler) replacing the
systemd timer in Docker deployments. Every Gemini-analysed file is also
persisted to a Postgres `tagging_events` table (run_id, prompt, raw
response, validated metadata, Box-write outcomes, status, error, timing)
for search and audit. Box is still updated exactly as before and remains
the source of truth for "already tagged" — `db.log_event` swallows DB
failures so an outage can't stop a tagging pass.
Backend:
- `db.py` + `schema.sql` — append-only `tagging_events` with indexes on
run_id, file_id, created_at.
- `scheduler.py` — APScheduler BlockingScheduler with `SCHEDULE_CRON`
(default daily 02:00), `RUN_AT_STARTUP`, SIGTERM handling.
- `api.py` (FastAPI) — `/api/health`, `/api/me`, `/api/events?q=…`
(single-input search across file_name, folder_path, description,
status, file_id, validated_metadata::text, raw_response::text,
scenes::text), `POST /api/runs` (fire-and-forget pass in a background
thread), `/api/runs`, `/api/runs/{id}/events`. Every event response
carries a synthesised `box_url`.
- `auth.py` — Azure AD bearer-token validation against the tenant JWKS
(signature + aud + iss). `DEV_AUTH_BYPASS=true` short-circuits to a
configurable dev user, mirrored on the frontend by
`VITE_DEV_AUTH_BYPASS`.
Frontend (Vite + React + TS):
- `frontend/` SPA, Montserrat + black/white/#FFC407 palette.
- @azure/msal-react with the bypass switch (auto-signin when bypass off).
- Search bar across all logged fields, results list with metadata tags,
status pills, and "Open in Box ↗" links.
- "Run now" button kicks off a tagging pass via `POST /api/runs` and
polls `/api/runs/{id}/events` every 2 s for live progress.
Docker / compose:
- `docker-compose.yml` pins `name: marriott-tagging`. Three services:
`db` (postgres:16, named volume, bound to 127.0.0.1 only), `tagger`
(scheduler.py), `api` (uvicorn). Same image, different `command`.
- `Dockerfile` — python:3.12-slim, non-root user.
Deploy (optical-dev.oliver.solutions):
- `deploy/deploy.sh` — idempotent. Auto-picks free host ports
(POSTGRES_HOST_PORT 5435-5499, MARRIOTT_API_PORT 8003-8099), renders
`apache-marriott-tagging.conf` from the .tmpl, builds the SPA in a
one-shot node:20-alpine container, rsyncs `dist/` to
`/var/www/html/marriott-tagging/`, polls `/api/health`, and prints the
shared-vhost Include line.
- `apache-marriott-tagging.conf.tmpl` — proxy `/marriott-tagging/api/`
to the API container, alias `/marriott-tagging` to the SPA web-root,
SPA fallback to `index.html`.
systemd unit files left in place for the existing Ubuntu deployment
path; do not run both on the same host (would double-fire the tagger).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
Marriott Box Asset Tagger
Batch-processes images and videos in a Box folder, analyzes them with Gemini AI, and writes structured metadata back to Box using the marriottUsa metadata template. Videos use Box's 480p MP4 proxy representations to keep bandwidth and Gemini token usage manageable.
Every Gemini-analysed file is also written to a Postgres tagging_events table for search/audit, and there's a small React SPA on top (search across all logged fields, click through to the original Box file, trigger an on-demand tagging pass).
Components
scheduler.py(tagger container) — APScheduler that firesmain.main()on a cron (default daily 02:00).api.py(api container, FastAPI) — search, list runs, kick off ad-hoc runs in a background thread.db.py+schema.sql— Postgres logging layer.frontend/(Vite + React + TS) — single-page UI, served by Apache from/var/www/html/marriott-tagging/in prod. Auth via@azure/msal-reactwith aVITE_DEV_AUTH_BYPASSswitch.
Setup
1. Clone and create virtual environment
cd Marriott_Box_Asset_Tagging
python3 -m venv env
source env/bin/activate
pip install -r requirements.txt
2. Box JWT credentials
Download your Box app's JWT config from the Box Developer Console and save it as box_config.json in the project root.
The service account must have:
- Access to folder
370595013246 - Permission to read/write metadata using the
marriottUsatemplate
3. Gemini API key
Add your key to .env:
GEMINI_API_KEY=your_key_here
Get a key at Google AI Studio.
Usage
source env/bin/activate
python main.py
The script will:
- Authenticate with Box and Gemini
- Fetch the
marriottUsatemplate schema (fields, types, allowed values) - Build a dynamic Gemini prompt from the schema
- Recursively list all image and video files in the target folder
- For each image: download, resize, analyze with Gemini, validate metadata, write metadata + description to Box
- For each video: fetch the 480p MP4 proxy from Box, analyze with Gemini, write metadata + description + a scene-breakdown comment to Box
- Print a summary of results
Run with Docker
Brings up Postgres, the scheduler (tagger), and the FastAPI backend (api). The frontend is built separately by deploy/deploy.sh (or npm run dev locally) and consumed by the API.
1. Prereqs
- Docker Desktop (or Docker Engine + Compose v2)
box_config.jsonin the project root- A
.envcopied from.env.example, filled in
cp .env.example .env
# minimum: GEMINI_API_KEY, POSTGRES_PASSWORD
# leave DEV_AUTH_BYPASS=true for now if you don't have Azure IDs ready
2. Build and start the backend services
docker compose up --build -d
This brings up three services:
db— Postgres 16, named volumepgdata, port bound to127.0.0.1:${POSTGRES_HOST_PORT:-5432}.tagger— runsscheduler.py(cron-driven Gemini passes).api— runsuvicorn api:appon container port 8000, published to127.0.0.1:${MARRIOTT_API_PORT:-8004}.
3. Run the frontend (local dev)
cd frontend
npm install
npm run dev # http://localhost:5173
Vite proxies /api/* to the FastAPI host port (default 8004). With VITE_DEV_AUTH_BYPASS=true you'll be auto-signed-in as the dev user.
4. Fire a tagging pass
Three ways:
- UI — open the SPA and click Run now. Polls live until done.
- API —
curl -X POST http://127.0.0.1:8004/api/runs(DEV_AUTH_BYPASS=true) or with a Bearer token in prod. - Container —
docker compose exec tagger python main.py(bypasses the API entirely).
5. Inspect the DB
docker compose exec db psql -U marriott marriott_tagging -c '\d tagging_events'
docker compose exec db psql -U marriott marriott_tagging -c \
"SELECT status, count(*) FROM tagging_events GROUP BY status;"
Auth: enabling Azure AD SSO
- Register (or reuse) an Azure AD app. Redirect URI:
- Local dev:
http://localhost:5173 - Prod:
https://optical-dev.oliver.solutions/marriott-tagging/
- Local dev:
- Expose an API with scope
access_as_userwhose audience is the same client ID. - Fill
.env:DEV_AUTH_BYPASS=false AZURE_TENANT_ID=... AZURE_CLIENT_ID=... VITE_DEV_AUTH_BYPASS=false VITE_AZURE_TENANT_ID=... VITE_AZURE_CLIENT_ID=... docker compose up -d --force-recreate apiand rebuild the SPA (deploy.shdoes this on the server; locallycd frontend && npm run build).
Backend validates JWT signature against the tenant's JWKS, checks aud == AZURE_CLIENT_ID and iss matches one of the tenant URLs. With bypass=true, every request is logged as the DEV_AUTH_EMAIL user.
Stop / tear down
docker compose down # stops containers, keeps the DB volume
docker compose down -v # also deletes the DB volume (destroys data)
Notes
- Postgres failures never stop the tagger —
db.log_eventswallows errors. Box is the source of truth for "already tagged". - The
marriott-tagger.service/.timerfiles below remain for the older systemd deployment path; the Docker path is the recommended one. Don't run both on the same host.
Server Deployment (Docker — optical-dev.oliver.solutions)
This is the recommended path on the shared optical-dev.oliver.solutions dev server. Apps live under /opt/<slug>/ with an idempotent deploy/deploy.sh. Mirrors the OSOP / adeo split-build pattern: backend in Docker, SPA built and served by Apache from /var/www/html/marriott-tagging/.
Public URL: https://optical-dev.oliver.solutions/marriott-tagging/
First-time setup
# 1. Clone into /opt
sudo git clone git@bitbucket.org:zlalani/marriott-box-image-video-tagging.git \
/opt/marriott-box-image-video-tagging
sudo chown -R "$USER:$USER" /opt/marriott-box-image-video-tagging
cd /opt/marriott-box-image-video-tagging
# 2. Drop credentials in place (NOT in git)
cp .env.example .env
$EDITOR .env # GEMINI_API_KEY, POSTGRES_PASSWORD,
# Azure IDs (or DEV_AUTH_BYPASS=true)
$EDITOR box_config.json # paste the Box JWT config
# 3. Deploy
./deploy/deploy.sh
The script will:
- Sanity-check
.env,box_config.json, docker, git, compose v2. - Pick free host ports — Postgres (default 5435, range 5435-5499) and API (default 8004, range 8003-8099) — persisted to
.env. - Render
deploy/apache-marriott-tagging.conffrom.tmplwith the picked API port. git pull --ff-only,docker compose build,docker compose up -d(db + tagger + api).- Build the Vite SPA in a one-shot
node:20container; rsyncfrontend/dist/to/var/www/html/marriott-tagging/. - Poll
/api/healthuntil ready and verify the tagger container is running. - Print the Apache
Includeline you need to add to the shared vhost.
One-time vhost step (manual):
Edit /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf and add inside </VirtualHost>:
Include /opt/marriott-box-image-video-tagging/deploy/apache-marriott-tagging.conf
Then:
sudo apachectl configtest && sudo systemctl reload apache2
Re-deploying
cd /opt/marriott-box-image-video-tagging
./deploy/deploy.sh
Flags:
--no-pullskipgit pull--no-buildskipdocker compose build--no-frontendskip Vite build + SPA sync--run-nowalso fire a tagging pass via/api/runs(works with DEV_AUTH_BYPASS=true)--logstail scheduler logs after deploy
Verifying it ran
# Scheduler logs (next cron-fired pass is at SCHEDULE_CRON; default 02:00 UTC)
docker compose logs -f tagger
# API logs
docker compose logs -f api
# Postgres request log
docker compose exec db psql -U marriott marriott_tagging -c \
"SELECT status, count(*) FROM tagging_events GROUP BY status;"
Postgres is bound to 127.0.0.1 only — not reachable from outside the server. To inspect from your laptop, tunnel: ssh -L 55432:127.0.0.1:<POSTGRES_HOST_PORT> user@optical-dev.oliver.solutions, then psql postgresql://marriott:***@127.0.0.1:55432/marriott_tagging.
Notes
- The Docker deploy and the
systemddeploy below target the same/opt/marriott-box-image-video-tagging/directory. Pick one on any given server — don't run both, they'll both fire the tagger and double-write to Box. - The SPA build bakes
VITE_AZURE_*andVITE_DEV_AUTH_BYPASSinto the bundle. Flipping the bypass requires a re-build (./deploy/deploy.shdoes this).
Server Deployment (systemd, Ubuntu)
The repo includes marriott-tagger.service and marriott-tagger.timer for running the tagger as a scheduled service. These steps are written for Ubuntu 22.04 / 24.04 but should work on any systemd-based distribution with minor path tweaks (e.g. /sbin/nologin instead of /usr/sbin/nologin on Red Hat-family).
0. Prerequisites
sudo apt update
sudo apt install -y git python3 python3-venv python3-pip
python3-venv is a separate apt package on Ubuntu — python3 -m venv will fail without it.
1. Clone the repo on the server
sudo mkdir -p /opt/marriott-box-image-video-tagging
sudo chown $USER:$USER /opt/marriott-box-image-video-tagging
git clone git@bitbucket.org:zlalani/marriott-box-image-video-tagging.git /opt/marriott-box-image-video-tagging
cd /opt/marriott-box-image-video-tagging
2. Create the service user
sudo useradd --system --shell /usr/sbin/nologin --home-dir /opt/marriott-box-image-video-tagging marriott-tagger
sudo chown -R marriott-tagger:marriott-tagger /opt/marriott-box-image-video-tagging
3. Drop credentials in place (NOT in git)
sudo -u marriott-tagger tee /opt/marriott-box-image-video-tagging/box_config.json > /dev/null < /path/to/local/box_config.json
sudo -u marriott-tagger tee /opt/marriott-box-image-video-tagging/.env > /dev/null <<'EOF'
GEMINI_API_KEY=your_key_here
EOF
sudo chmod 600 /opt/marriott-box-image-video-tagging/box_config.json /opt/marriott-box-image-video-tagging/.env
4. Set up the virtualenv
sudo -u marriott-tagger python3 -m venv /opt/marriott-box-image-video-tagging/env
sudo -u marriott-tagger /opt/marriott-box-image-video-tagging/env/bin/pip install -r /opt/marriott-box-image-video-tagging/requirements.txt
5. Install the systemd unit files
sudo cp /opt/marriott-box-image-video-tagging/marriott-tagger.service /etc/systemd/system/
sudo cp /opt/marriott-box-image-video-tagging/marriott-tagger.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now marriott-tagger.timer
6. Verify
# Show the next scheduled run
systemctl list-timers marriott-tagger.timer
# Trigger a one-off run immediately (timer will still run on schedule)
sudo systemctl start marriott-tagger.service
# Tail the logs (live)
sudo journalctl -u marriott-tagger -f
# Inspect the most recent run's full output
sudo journalctl -u marriott-tagger --since "1 day ago"
Updating the service
cd /opt/marriott-box-image-video-tagging
sudo -u marriott-tagger git pull
# If unit files changed:
sudo cp marriott-tagger.service marriott-tagger.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl restart marriott-tagger.timer
Configuration
Edit the constants at the top of main.py:
| Setting | Default | Description |
|---|---|---|
BOX_FOLDER_ID |
(varies) | Box folder to process |
METADATA_TEMPLATE_KEY |
marriottUsa |
Box metadata template key |
GEMINI_MODEL |
gemini-2.5-flash |
Gemini model for analysis |
EXCLUDED_FOLDER_PREFIXES |
("z_", "zz_", "zzz_") |
Subfolder name prefixes to skip |
GEMINI_DELAY |
7 |
Seconds between Gemini image calls |
GEMINI_VIDEO_DELAY |
10 |
Seconds between Gemini video calls |
SKIP_ALREADY_TAGGED |
True |
Skip files with existing metadata |
MAX_IMAGE_SIZE |
1000 |
Max pixel dimension for image resize |
VIDEO_SIZE_LIMIT_INLINE |
20 MB |
Below this, send video inline; above, use Gemini File API |
VIDEO_SOURCE_SIZE_LIMIT |
5 GB |
Skip videos whose source file exceeds this |
VIDEO_PROXY_SIZE_LIMIT |
400 MB |
Skip videos whose 480p proxy exceeds this (~60 min runtime) |
MAX_FILES_PER_RUN |
200 |
Hard cap on newly-tagged files per run |
MAX_RUN_DURATION |
4h |
Hard wall-clock cap per run |
DESCRIPTION_MAX_LENGTH |
255 |
Box description field char limit |
How It Works
- Dynamic prompt: The Gemini prompt is built at runtime from the actual Box template definition. If Marriott adds/changes fields or options in Box Admin, the script adapts automatically.
- Metadata + description: Each file gets structured metadata (for filtered search) and a short description (visible in Box list views, also indexed by Box search).
- Search-keyword tail: Each description is formatted as
<summary sentence>. <comma-separated keywords>.— the keyword tail covers synonyms and broader category terms (e.g. food/dining/eating/meal/restaurant) so a search for "Food" hits assets tagged with the enum valueDining, etc. - Video scene breakdown: Videos additionally get a timestamped scene breakdown written as a comment on the Box file — a high-level chapter map for finding moments inside long videos.
- Resumable: Files with existing metadata are skipped by default, so the script can be re-run after interruptions or when new files are added.
- Validation: Gemini output is validated against the template schema — invalid enum values are dropped, multiSelect arrays are filtered to allowed options only.
- Large-video gating: Videos exceeding the source or proxy size limits are skipped cleanly rather than wasting time / API budget on content beyond Gemini's context window. Skips are reported in the summary as
skipped, noterrored. - Per-run limiter: A daily run will tag at most
MAX_FILES_PER_RUNnewly-tagged files inMAX_RUN_DURATIONof wall clock. Whichever cap hits first, the run exits cleanly with a summary line; the next scheduled run picks up the remaining untagged files. This keeps a sudden 1000-file upload from blowing through your Gemini budget in one night.