Before doing any work, always run this to orient yourself:
pwd && which ruff >/dev/null 2>&1 && echo "venv OK" || echo "venv MISSING — see Worktree Setup below"The Claude Code shell sources venv/bin/activate on startup, so python, ruff, mypy, pytest, uvicorn, alembic, etc. are on PATH directly — no ./venv/bin/ prefix needed.
If running inside a git worktree (i.e. pwd shows a path like .git/worktrees/... or a sibling directory, and venv/ is missing), symlink the shared artifacts from the main project rather than reinstalling:
# Find the main project root (adjust path if needed)
MAIN=$(git worktree list | head -1 | awk '{print $1}')
ln -s "$MAIN/venv" ./venv
ln -s "$MAIN/node_modules" ./node_modulesAfter symlinking, verify: ruff --version && npx relay-compiler --version (you may need to start a new shell so the activate hook re-runs).
Full-stack media manager app: FastAPI + Strawberry GraphQL backend (Python), React + Relay + TypeScript frontend. The backend manages Plex/Radarr/Sonarr integrations and an agentic, AI-powered recommendation pipeline backed by Postgres + pgvector for semantic search.
indexer_utils/— Python backend (FastAPI app, GraphQL schema, async SQLAlchemy models, integrations)ai_recs.py— orchestrates the per-candidate recommendation flowai_tools/— the openai-agents-SDK recommendation Agent and its toolsprompts/— system prompts for the recommendation agent + discovery subagents (.md)vector_search.py— pgvector embedding + synopsis-similarity queriestaste_signal.py— builds thetaste_signalpayload block (neighbour×critic cohort cross-tab + per-attribute add-rates + whole-library cast cross-reference), the cohort cross-tab Redis-cached by era
src/— React/TypeScript frontend (Relay, MUI)alembic/— DB migrationstests/— Python unit tests (run against a real pgvector Postgres, see below)e2e/— Playwright end-to-end testsscripts/postgres-init/—create-test-db.sql, mounted as Postgresinitdb.dby the compose files to create the test DBbackfill_synopsis_vectors.py(repo root) — (re)embedsynopsis_vectoracross the catalog;--reindex-all,--check-vectors,--added-only, etc.
indexer_utils/mcp_server.py exposes a native MCP endpoint mounted at /mcp in main.py (via mcp.http_app(path="/") + combine_lifespans so FastMCP's session-manager lifespan runs alongside the scheduler's). It's a curated tool surface (read: open/decided candidates, scheduled jobs, recommend; write: add_item, ignore_item, retry_ai, set_recommendation_preference, recheck_visible) wrapping the same helpers the GraphQL resolvers use.
- Auth: Authelia acts as the OIDC provider. The connector gets a JWT access token from Authelia and presents it as a bearer token;
JWTVerifiervalidates it against Authelia's JWKS (issuer + audience). Config viaMCP_OIDC_ISSUER,MCP_OIDC_JWKS_URI,MCP_RESOURCE_URL,MCP_BASE_URL;MCP_AUTH_DISABLED=truebypasses auth for local/dev. - Discovery: we serve RFC 9728 protected-resource metadata ourselves from
main.py(/.well-known/oauth-protected-resource[/mcp]) because FastMCP mis-derivesresource/path under an ASGI sub-mount (upstream issue #1348).PROTECTED_RESOURCE_METADATA.resourceMUST equal theJWTVerifieraudience and the Authelia client's audience, or tokens validate to the wrongaudand silently fail. - nginx:
/mcpand/.well-known/oauth-protected-resourcemust be proxied to the app without Authelia forward-auth (auth_request) — the JWT is the gate, not the session cookie. These are server-side, gitignored configs.
Postgres 16 with the pgvector extension (pgvector/pgvector:pg16 image). The app talks to it through async SQLAlchemy 2.0 over psycopg 3 (postgresql+psycopg://…).
indexer_utils/session.py—db_session()returns anAsyncSession; alwaysasync with db_session() as session:. The engine/sessionmaker are cached module-level singletons. Model classmethods (IgnoreItem.create,.filter,MovieRecommendationRecord.recent_history, …) are allasync.indexer_utils/models.py—IgnoreItem(the catalog row;attributesis a PostgresJSONBblob,synopsis_vectoris a deferredVector(1536)column),MovieRecommendationRecord(recommendation history + LIKE/NOT_NOW/NEVER feedback),FilterRule.- Test DB isolation:
session.pycheckssys.argvforpytestand swapsDB_NAME→TEST_DB_NAME, so the same.envserves both the app and the suite without ever pointing tests at the real DB. Don't pass DB env vars on the command line. - Don't read/grep
.envto discover DB config — reads of it are permission-denied, and you don't need it:decouple/session.pyalready load it. Any script usingdb_session()connects to the real DB automatically (and the running container ismediamanager-db-1). - The MySQL + Weaviate → Postgres + pgvector swap is done (commit
253e198), and the one-shot migration tooling has been removed. Don't reintroduce either dependency.
Entry point: annotate_with_ai_async(item_type, uid, title, attrs) in indexer_utils/ai_recs.py, called during candidate ingest (vid_utils.py) and on GraphQL re-annotation (schema.py). Per candidate it:
- Hydrates metadata (TMDB cast/director/release-count via
tmdb.py). - Generates a short synopsis (plain OpenAI JSON call) and embeds
title + synopsisinto the pgvectorsynopsis_vectorcolumn (vector_search.upsert_item_vector). For brand-new candidates the row doesn't exist yet, so the vector is stashed inattrs["_synopsis_vector_tmp"]and attached after insert. - Builds a user payload including a pre-computed
library_profile(aggregate taste, seelibrary_profile.py) and ataste_signalblock (taste_signal.py): raw historical add counts over the candidate's decided ±2yr same-type cohort, broken out by the synopsis-neighbour × critic-presence cross-tab and per-attribute (network/language/genre), plus acast_xrefcounting how many added titles each of the candidate's cast appears in (whole-library, cross-era — not bounded to the cohort window). The model reads counts as rates itself; the cohort cross-tab is Redis-cached by(item_type, year). - Runs the recommendation Agent and writes a single consolidated
aiblock back ontoattrs(verdict, score, reason, synopsis, tool log, turn/tool-call counts, failure info).
The agent itself lives in indexer_utils/ai_tools/ and is built on the openai-agents SDK (openai-agents package):
agent.py—build_agent()wires per-item-type tools and a PydanticRecommendation(recommend: bool,score: 0–1,reason) as the structuredoutput_type.run_recommendation()drivesRunner.runwith tracing disabled and a per-runAsyncOpenAIclient (closed on exit to avoid leaking sockets acrossasyncio.runloops). Model failures (turn cap, tool-budget cap, transport error) are captured asresult.failure, not raised.- Tools (all
@safe_tool-wrapped so a tool exception comes back to the model as an error payload instead of killing the run):searches.py—search_similar_by_synopsis(pgvector cosine distance),search_by_genre,search_by_network. All query added library items only; rating filters are per-source (imdb_min,rt_min, …).inspections.py—get_item_details,get_user_history,check_added_history(fan out to DB / Plex / Radarr / Sonarr).discoveries.py—search_recent_releases(movies only),search_recent_tv(TV only),search_title_buzz. These are nested subagents with the hostedWebSearchTool; they return prose dossiers (no JSON schema — the consumer is another LLM) and cache results in Redis.
hooks.py—AuditHooksrecords per-call timing/outcome and enforces a cumulative tool-call budget (the SDK only caps turns).base.py—ToolContext(item_type + candidate) passed to every tool viaRunContextWrapper.
Relevant env (via python-decouple/.env): OPENAI_API_KEY, OPENAI_MODEL (default gpt-5.5), OPENAI_EMBEDDING_MODEL (default text-embedding-3-small), AI_AGENT_MAX_TURNS (6), AI_AGENT_MAX_TOOL_CALLS (16). Discovery subagents are pinned to gpt-5.4-mini in discoveries.py.
- Formatter/linter:
ruff(config inpyproject.toml) — replaces black + isort + flake8. Line length 88, Python 3.9 target. - Type checker:
mypyin strict mode (mypy.ini). Annotate all new functions; useOptional[X]/X | Nonefor nullables. - Auto-fix:
ruff check --fix .
- Formatter:
prettier(v2). Linter:eslint. Type checker:tsc --noEmit. relay-compilermust run beforetsc/eslintbecause it generates types insrc/__generated__/. Thenpm run lintscript handles this ordering.
ruff format . && ruff check .
npm run lint # relay-compiler + tsc + prettier + eslintCommit messages: short and concise — 12 words max. State the central point of the change in one line. No bullet lists, no feature breakdowns, no "and also" addenda.
PR descriptions: a little more room, but still restrained. Describe the problem being solved and why — let the code itself answer the "how". Skip the file-by-file walkthrough and the bulleted list of every change. Two or three sentences. A reviewer reading the diff shouldn't also need a prose narration of it.
🚫 NO Claude attribution. Ever. Do not append Co-Authored-By: Claude …, 🤖 Generated with [Claude Code], or any variant of those trailers/footers to commit messages or PR descriptions. This applies even if the default Claude Code commit/PR templates suggest them — strip them out before running git commit or gh pr create. The commit body ends at the last real line of the message; the PR body ends at the end of the human-written description. No exceptions.
- Relay-generated files in
src/__generated__/are auto-generated — never edit. Re-runnpx relay-compilerafter changing GraphQL queries/mutations or the Strawberry schema inindexer_utils/schema.py. - alembic:
alembic upgrade headto apply,alembic revision --autogenerate -m "…"to create. pgvector bits are hand-written, not autogenerated —add_pgvector_synopsis.pyop.executesCREATE EXTENSION vectorand the HNSW cosine index, and importsVectorfrompgvector.sqlalchemy. Mirror that pattern for vector changes. - Async DB: the whole DB layer is async —
db_session()yields anAsyncSessionand must be used withasync with/await. Don't reintroduce syncSessioncalls. - Missing tool: if a Python tool isn't found, the activate hook didn't fire — check
./venv/bin/directly (most likely a worktree missing the symlink).
bash dev_server.sh # backend (uvicorn, DEBUG=true, port 8000)
npm run dev # frontend (relay-compiler + webpack --watch)Unit tests run against a real pgvector Postgres (the cosine-distance / JSONB SQL paths need it), in Docker:
docker compose -f docker-compose.test.yml run --build --rm pytestRun this locally before committing test changes rather than push-and-watch-CI. pytest-asyncio is in asyncio_mode=auto (see pyproject.toml). The same compose file also wires the Playwright e2e stack (db_init → app → seeder → playwright).