Architecture
System overview
Six independently-deployable services, a deterministic scoring path, and app-level multi-tenancy.
Services
| Service | Stack | Responsibility |
|---|---|---|
tc-core | NestJS + Prisma + Postgres | Auth/OAuth/RBAC, the interview HITL workflow state machine, the A2A/Integration API gateway, webhooks, admission control |
tc-measurement | FastAPI | Plan Engine (interview plan generation) and the deterministic assessment/scoring pipeline. Ontology lives in Postgres reference.* tables |
tc-realtime | Python | Maya, the AI interviewer — WebRTC/LiveKit audio, STT/TTS, turn-taking, adaptive question flow |
tc-web | Next.js 15 (App Router) | Recruiter dashboard, candidate interview experience, OAuth consent screen, and this docs site |
tc-contracts | TypeScript + Zod | Shared wire-shape validators/types — every cross-service payload is defined once here |
tc-infra | Docker Compose | Postgres, Redis, Kafka, and the full application stack for local/dev |
Deterministic scoring — no LLM in the scoring path
An LLM extracts and types evidence from what the candidate said (mapping each response to a competency and a coverage signal), but every scoring decision after that point — dedup, aggregation, and the final coverage_summary — runs through fixed, auditable rules. The same transcript always produces the same assessment: no model non-determinism in the number (or absence of a number) that a candidate's outcome depends on.
This is why the output is a coverage_summary rather than a score: coverage against a fixed competency ontology is something a deterministic pipeline can defend and explain; a single blended "fit" number is not.
Multi-tenancy
Every tenant-scoped table carries a tenantId column, and every query path in tc-core filters by the resolved tenant from the authenticated principal — never from a client-supplied value in the body. Platform tenants (Tenant.isPlatform) can act on behalf of their own customer tenants via X-Tenant-ID, but that header is validated to name a tenant that actually belongs to the calling platform on every request, so one platform's key can never reach another platform's data.
Runs are addressed by an opaque runId (run_<epoch>_<hex>) resolved as { tenantId, runId } — a syntactically valid runId belonging to a different tenant 404s rather than exposing that it exists.