The AI pipeline
The pipeline is the heart of ContentsVision. It takes an uploaded file and produces inventory items. There are three pipelines, one per media type, all orchestrated from backend/app/services/pipeline.py and all sharing the same supporting services. This page traces each one stage by stage.
pipeline.py exposes four entry points: run_pipeline (video), run_grouped_photo_pipeline (photos, the live path), run_audio_pipeline (audio), and the older run_photo_pipeline (legacy batched photos). Each is launched as a FastAPI background task from the upload or process route.
The shared shape
Every pipeline follows the same rhythm: prepare the input, run AI analysis (in parallel where possible), then synthesize and save items. Each stage is wrapped in a stage_timer from observability so timings and token usage are recorded, and each stage updates the job's status, progress_pct, and current_stage so the UI can stream progress.
Tunable knobs
The pipeline's behavior is controlled by settings in backend/app/core/config.py (all environment-overridable). The defaults below are tuned together; the configuration reference lists every variable.
| Setting | Default | Effect |
|---|---|---|
frame_extraction_fps | 1.0 | Frames pulled from video per second. |
chunk_duration_seconds | 30.0 | Length of each time chunk analyzed by one AI call. |
frames_per_minute | 60 | Target frame density used when selecting frames per chunk. |
max_frames_per_call | 60 | Hard cap on frames sent to a single vision call. |
video_dedup_window_seconds | 3.0 | How close in time two same-named items must be to merge across a chunk boundary. |
max_photos_per_call | 8 | Photos per call in the legacy batched photo pipeline. |
MAX_PHOTOS_PER_ITEM | 20 | Hard cap on photos grouped into one item. |
openrouter_concurrency | 6 | Max simultaneous AI calls per job (one job's own semaphore). |
openrouter_global_concurrency | 16 | Max simultaneous AI calls across all in-flight jobs in the process. A shared limiter (in claude_analyzer) wrapping every OpenRouter call, so several concurrent jobs share provider capacity smoothly instead of each firing openrouter_concurrency and tripping 429s. |
openrouter_model | anthropic/claude-opus-4-5 | The vision and analysis model. |
openrouter_temperature | 0 | Sampling temperature for every AI call. Pinned to 0 for deterministic, repeatable results (AS-719). |
deepgram_model | nova-2 | The transcription model. |
Video pipeline
run_pipeline(job_id, db) runs five stages. Progress percentages are shown so support can map a stuck job to a stage.
Stage 1: Extract
FFmpeg probes the video for duration, fps, and resolution, extracts the audio track to a file, and extracts frames at frame_extraction_fps (1 per second by default). The duration is stored on the job and later drives billing.
Stage 2: Transcribe
The audio is sent to Deepgram Nova-2, which returns word-level timestamps. Nova-2 is used instead of Whisper because it is far more accurate on brand and product names, which is exactly what the analysis depends on.
The transcript is stored on the job (full text plus per-segment data with word timings). The pipeline also extracts room cues from the narration ("now we're in the master bedroom"), though in the current design each video upload is already scoped to one known room.
The running code transcribes with Deepgram Nova-2 (audio_processor.transcribe_audio), and the DEEPGRAM_API_KEY is what matters in practice. OpenAI and Whisper are not used anywhere in the pipeline; the legacy OPENAI_API_KEY and whisper_model config have been removed.
Stage 3: Chunk
The video timeline is divided into fixed-length chunks of chunk_duration_seconds (30s) by video_processor.compute_chunk_ranges (a pure, unit-tested function). Each extracted frame is assigned to the chunk whose time range contains it. Smaller chunks mean more, smaller AI calls that overlap and finish sooner.
Stage 4: Analyze (the parallel, expensive stage)
This is where items are actually found. All chunks are launched at once, bounded by a semaphore of size openrouter_concurrency (6), and results are collected as each finishes so progress streams live (55% to 85%).
For each chunk, the pipeline:
- Selects frames by density (
select_frames_by_density): rather than sending every frame, it picks the best frames up toframes_per_minute/max_frames_per_call, choosing the sharpest frame in each time bucket. - Slices the transcript to just this chunk's time range, so the model sees the narration that belongs to these frames.
- Calls Claude vision through OpenRouter (
claude_analyzer.analyze_room_segment) with the active system prompt for video.
After all chunks return, cross-chunk dedup merges near-boundary duplicates: if the same item name appears in two adjacent chunks within video_dedup_window_seconds (3s) of the boundary, it is treated as one physical object (a couch straddling the 30s line), not two. The merge is conservative and never collapses items that are far apart in time.
Stage 5: Synthesize
Items are sorted by video time so numbering matches recording order. For each item the pipeline crops the best frame to the item's bounding box (a close-up) plus a wider context image, writes them to the job's photos/ directory, and saves an InventoryItem with its ItemPhoto rows. Item numbers continue from the claim's current maximum (see numbering). Items with confidence below 0.6 are flagged.
Finally, auto-approval runs if enabled, billing is finalized (video minutes times the per-minute credit rate, recorded for the usage meter), and the job is marked complete. When prepaid billing is enabled (ENABLE_BILLING), the upload did not start the pipeline at all: the job parked in awaiting_confirmation until the user confirmed the quoted cost, and the credits were spent up front at that confirmation (the spend is the gate). So nothing more is charged here; if the job instead fails, that up-front charge is refunded in the error path. All no-ops otherwise. See billing.
Photo pipeline
Photos work differently from video, and the difference is deliberate. Photos are distinct images, not near-identical frames, and the old approach of letting the AI guess which photos showed the same item produced noisy, duplicate-heavy output. The live pipeline removes that guessing.
How it flows (run_grouped_photo_pipeline):
- Upload (
POST /api/upload-photos, thenPOST /api/upload-photos/{id}/appendfor the rest): every photo is normalized to JPEG and saved; oneStagedPhotorow per file, each starting in its own group. The job sits inawaiting_grouping. AI does not start automatically. The frontend splits a large selection into size-bounded batches to stay under the Cloudflare Tunnel's per-request body cap, but all batches land on one job (see batched photo upload). - Group: the user merges photos that show the same item (capped at
MAX_PHOTOS_PER_ITEM). The grouping is persisted via the staged-photo regroup endpoint. - Process (
POST /api/jobs/{id}/process): the user starts analysis. Soft-deleted photos are removed from disk first. - Analyze: one AI call per group runs in parallel. A multi-photo group uses the "merged" prompt and yields exactly one item; a single-photo group uses the "unmerged" prompt and may yield several items.
- Synthesize: items are saved with every source photo attached as
ItemPhotorows. No cross-batch "is this the same item?" guessing is needed.
Described items (AS-705): the user can name an item during grouping, and a named item skips the AI entirely. The analyze step keys off manual_name: if a group has one, the pipeline builds the item straight from the photos, name, and quantity (confidence=1.0, the whole photo as its image) with no AI call, so it costs no credit. This covers both a whole photo / merged group the user named in the Items panel and a named drawn segment. The user sets the name and quantity via the staged-photos/details endpoint.
Manual segmentation (AS-705): during grouping the user can also draw boxes around items inside one photo (the segment endpoint crops each box into a StagedPhoto with is_segment=True, replacing the original). A segment is always a known item: the pipeline never asks the AI to re-split it, and the user's quantity overrides whatever count the model returns. A named segment skips the AI (as above); a blank-named segment is identified by the AI via the "merged" prompt like any known item, with the user's quantity forced onto the result.
Billing for photos is one request per group that hits the AI, not per photo. Described (named) items make no request and are free. See billing.
The two photo flows use two different system prompts (photo_merged and photo_unmerged), each separately customizable by a team. See automation and prompts.
Audio pipeline
run_audio_pipeline handles voice memos and call recordings. No frames, no photos: just transcribe and read.
The analyzer records, for each item, the audio_start_seconds and audio_end_seconds of the part of the recording that mentions it, so Guided Review can play back just that slice. If the recording has no speech, the job completes cleanly with zero items and a friendly message rather than failing. Audio is billed per minute, like video.
The vision call
All analysis goes through claude_analyzer.py, which calls OpenRouter's OpenAI-compatible endpoint (a single routing layer with model fallback and rate-limit headers).
- Model:
openrouter_model, defaultanthropic/claude-opus-4-5. - Sampling: every call pins
temperaturetoopenrouter_temperature(default0) so the same video or photo set yields the same items and names run-to-run (ticket AS-719). With no temperature the model defaults to ~1.0 and samples differently each run, which is what made repeated uploads produce different item counts and names. Temperature 0 makes output near-deterministic; it is not a bit-identical guarantee (provider batching, routing, and floating point still vary, and Anthropic models do not honor aseed). Onlytemperatureis sent, nevertop_palongside it, since Claude 4.x rejects both at once. - Concurrency: a shared, connection-pooled
httpxclient; the pipeline limits in-flight calls with a semaphore. - Retries: up to 6 attempts per call. On HTTP 429 it honors
Retry-Afterorx-ratelimit-reset; on 5xx and timeouts it uses exponential backoff with jitter. - Inspection: every call's final outcome is recorded to the
api_request_logstable (request JSON with images redacted, full response, attempts, duration, tokens, real cost) and browsable on the admin Logs page's API Requests tab. - Structured output: every prompt ends with a locked
OUTPUT FORMAT:block defining the JSON contract the app parses. Custom prompt add-ons are spliced in before that block, so the contract is always preserved.
The built-in system prompts encode the domain rules: be thorough, name items like searchable retail product names, put brand in its own field, document damage, group identical items, include movable property plus cabinets and appliances, and exclude structural building parts entirely. The condition scale is fixed (New, Above Average, Average, Below Average), and confidence is scored by how clearly an item is seen and whether it was named in narration.
Age is only ever taken from something a person said. An estimated age is unreliable and drives item value, so the model may set age_years / age_months only when the age is stated aloud, and never from wear, styling, or technology generation. The video and audio prompts keep the age fields but gate them on a spoken statement (video additionally requires the stating words to appear in audio_evidence). The three photo prompts drop the fields entirely, since a photo set has no narration to quote. analyze_room_segment enforces this by mode: mode="photo" hardcodes both fields to 0 regardless of the response, so a team's custom prompt add-on or an off-format reply cannot seed a guessed age; mode="video" and _audio_item_from_json pass a stated age through. Anything unstated stays 0 for the reviewer to fill in.
Item numbering
Numbers are unique and sequential within a claim and continue across every job in that claim (numbering.py). AI items are numbered in video-time order. Manual inserts can append (max + 1) or insert between numbers, shifting later items up by one.
Error handling
Every pipeline wraps its work in a try/except. On any failure the job is set to status=error with the exception type in current_stage and the message in error_message, and the failure is logged to the job's observability buffer. Partial work already committed (for example, items from chunks that succeeded) is kept. The Adjust Square submission path has its own retry and per-item failure handling, covered in its own page.
Next
- Automation and prompts: auto-approval rules and custom prompt add-ons.
- Observability: how stage timings, tokens, and job logs are captured.
- Billing: how each pipeline's output is priced.