Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
NODE_ENV="development"
DATABASE_URL="postgres://postgres:password@localhost:6311/postgres?sslmode=disable"
TEST_DATABASE_URL="postgres://postgres:password@localhost:6312/postgres?sslmode=disable"
SESSION_SECRET="xxx"
SESSION_SECRET="replace-with-at-least-32-random-characters"
VITE_GOOGLE_CLIENT_ID="xxx"
VITE_APP_URL="http://localhost:3000"
APP_URL="http://localhost:3000"
Expand Down Expand Up @@ -30,16 +30,6 @@ AI_GATEWAY_ID=""
AI_GATEWAY_TOKEN=""
OPENROUTER_API_KEY=""

# ElevenLabs Conversational AI (voice assessment)
# Create an agent in https://elevenlabs.io/app/agents and put its ID here.
# ELEVENLABS_WEBHOOK_SECRET is the HMAC secret configured on the post-call
# webhook in the ElevenLabs dashboard. Point that webhook at
# /api/voice-webhook. Leave the secret empty in dev to skip
# signature verification (the webhook is optional in local development).
ELEVENLABS_API_KEY=""
ELEVENLABS_AGENT_ID=""
ELEVENLABS_WEBHOOK_SECRET=""

# Platform admin (comma-separated allowlist; leave empty to disable /admin)
PLATFORM_ADMIN_EMAILS="you@example.com"

Expand Down
2 changes: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ DATABASE_URL=postgresql://postgres:password@localhost:6312/postgres?sslmode=disa
TEST_DATABASE_URL=postgresql://postgres:password@localhost:6312/postgres?sslmode=disable
APP_URL=http://localhost:3000
VITE_APP_URL=http://localhost:3000
SESSION_SECRET=test-secret
SESSION_SECRET=test-secret-with-at-least-32-characters
EMAIL_FROM=test@tryroundzero.com
POLAR_ACCESS_TOKEN=test-polar-token
POLAR_WEBHOOK_SECRET=test-polar-webhook-secret
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ jobs:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/roundzero_test?sslmode=disable
TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/roundzero_test?sslmode=disable

- name: Run Workers runtime tests
run: bun run test:workers

deploy:
needs: test
runs-on: ubuntu-latest
Expand Down Expand Up @@ -78,9 +81,6 @@ jobs:
POLAR_PRODUCT_ID_STARTER=${{ secrets.POLAR_PRODUCT_ID_STARTER }}
POLAR_PRODUCT_ID_GROWTH=${{ secrets.POLAR_PRODUCT_ID_GROWTH }}
POLAR_PRODUCT_ID_SCALE=${{ secrets.POLAR_PRODUCT_ID_SCALE }}
ELEVENLABS_API_KEY=${{ secrets.ELEVENLABS_API_KEY }}
ELEVENLABS_AGENT_ID=${{ secrets.ELEVENLABS_AGENT_ID }}
ELEVENLABS_WEBHOOK_SECRET=${{ secrets.ELEVENLABS_WEBHOOK_SECRET }}
PLATFORM_ADMIN_EMAILS=${{ secrets.PLATFORM_ADMIN_EMAILS }}
EOF

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ jobs:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/roundzero_test?sslmode=disable
TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/roundzero_test?sslmode=disable

- name: Run Workers runtime tests
run: bun run test:workers

deploy:
needs: test
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
Expand Down Expand Up @@ -93,9 +96,6 @@ jobs:
POLAR_PRODUCT_ID_STARTER=${{ secrets.POLAR_PRODUCT_ID_STARTER }}
POLAR_PRODUCT_ID_GROWTH=${{ secrets.POLAR_PRODUCT_ID_GROWTH }}
POLAR_PRODUCT_ID_SCALE=${{ secrets.POLAR_PRODUCT_ID_SCALE }}
ELEVENLABS_API_KEY=${{ secrets.ELEVENLABS_API_KEY }}
ELEVENLABS_AGENT_ID=${{ secrets.ELEVENLABS_AGENT_ID }}
ELEVENLABS_WEBHOOK_SECRET=${{ secrets.ELEVENLABS_WEBHOOK_SECRET }}
PLATFORM_ADMIN_EMAILS=${{ secrets.PLATFORM_ADMIN_EMAILS }}
EOF

Expand Down
17 changes: 10 additions & 7 deletions AI-LAYER.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,20 +170,23 @@ Only for selected candidates who completed text + voice.
1. Idempotency check
2. Load interview context + transcript
3. `assess_answer_authenticity` (Haiku in prod)
4. `load_voice_assessment` — LLM analysis over stored ElevenLabs transcript (`generateText` + `Output.object`)
4. `load_voice_assessment` — LLM analysis over the stored voice transcript (`generateText` + `Output.object`)
5. Generate structured report (`post_eval` chain)
6. Deterministic refine + `post_eval_audit` LLM pass
7. Blend voice into communication score
8. Persist report, move application to `evaluated_held`
9. Batch orchestration holds until release

## Voice assessment (ElevenLabs)
## Voice assessment (Cloudflare)

- ElevenLabs handles STT/LLM/TTS
- `getMyVoiceToken` fetches signed URL via ElevenLabs REST API (`fetch` to `api.elevenlabs.io`)
- Client: `@elevenlabs/client` + TanStack `useRealtimeChat` adapter
- On end: transcript persisted (`analysis: null` initially); interview → `completed`; `startPostEvaluation` triggered
- Voice dimension scoring runs in post-eval, not on the hot path
- One `VoiceAssessmentAgent` Durable Object per interview runs the live session through `@cloudflare/voice` and the Agents SDK.
- Speech-to-text uses Workers AI Deepgram Flux, conversation turns use Workers AI `@cf/openai/gpt-oss-20b`, and text-to-speech uses Workers AI Deepgram Aura.
- The browser connects with `@cloudflare/voice/react` through an authenticated, same-origin WebSocket route. A short-lived HttpOnly capability cookie is scoped to the candidate and interview; the route also rechecks current Postgres ownership and lifecycle state.
- The source-controlled system prompt and first message live in `app/features/interviews/shared/voice-runtime.ts`; candidate background is explicitly marked as untrusted evidence.
- Durable Object SQLite holds reconnectable live history for at most seven days. The browser never submits the authoritative transcript.
- Intentional completion asks the Agent RPC to finalize committed server history. It persists the transcript with `analysis: null`, moves the interview to `completed`, and triggers `startPostEvaluation` idempotently.
- The initial Cloudflare implementation does not record or store call audio.
- Voice dimension scoring runs in post-eval, not on the hot path.

## Communication score blend

Expand Down
35 changes: 18 additions & 17 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Implemented in the repo today:
- in-app notifications with Cloudflare Email Service delivery
- Cloudflare Workflows for pre-evaluation, post-evaluation, batch orchestration, pool-check, eval-retry, and account-cleanup
- TanStack AI + OpenRouter text interviews (SSE via `/api/interview-chat`)
- ElevenLabs Conversational AI for voice assessment
- Cloudflare Voice Agents + Workers AI for voice assessment
- AI pre-evaluation, interviews, reports, and ranked batch release flow
- billing plan config, Polar checkout/webhooks, and plan-gated entitlements
- multi-tenant company auth (membership, email invitations, team management)
Expand All @@ -35,6 +35,7 @@ Important current constraints:
| Framework | TanStack Start, React 19, Vite 8 |
| Runtime | Cloudflare Worker |
| AI Runtime | Cloudflare Workflows |
| Voice Runtime | Cloudflare Agents, Voice, Durable Objects, Workers AI |
| Database | Postgres |
| Local DB | Docker Postgres containers (`rz_pg_dev`, `rz_pg_test`) |
| Deployed DB Access | Hyperdrive binding in `wrangler.jsonc` |
Expand Down Expand Up @@ -646,18 +647,21 @@ Hybrid TanStack AI + OpenRouter:

### Voice Assessment

The voice assessment uses ElevenLabs' Conversational AI:

- ElevenLabs handles all voice processing (STT, LLM, TTS) as a managed service
- `getMyVoiceToken` is available only while `interviews.status === 'awaiting_voice'`
- `getMyVoiceToken` generates a signed URL via `fetch` to the ElevenLabs REST API for client-side session initiation
- Candidate/job context is injected via ElevenLabs `dynamicVariables` (`candidate_name`, `job_title`, `company_name`, `candidate_summary`)
- The client connects via `@elevenlabs/client` `Conversation.startSession()` using the signed URL
- `finalizeVoiceAssessmentFromTranscript` (ElevenLabs webhook or browser fallback) persists transcript with `analysis: null`, moves interview to `completed`, calls `startPostEvaluation`
- Voice dimension analysis (`analyzeVoiceTranscript`) runs inside post-evaluation `load_voice_assessment`, not on the hot path
- Communication score blend is evidence-weighted in `applyVoiceAssessmentToReport()` (not fixed 60/40)

The agent's system prompt and voice personality are configured in the ElevenLabs dashboard.
The voice assessment is a Cloudflare Voice Agent hosted in a Durable Object:

- `VoiceAssessmentAgent` uses `@cloudflare/voice` over the Agents SDK, with one Durable Object named by interview UUID.
- Workers AI runs Deepgram Flux speech-to-text, `@cf/openai/gpt-oss-20b` conversation turns, and Deepgram Aura text-to-speech.
- The browser uses `@cloudflare/voice/react` directly. `prepareMyVoiceConnection` issues a 30-minute, interview-scoped HttpOnly capability cookie only while the interview is eligible.
- The Worker intercepts `/agents/voice-assessment-agent/:interviewId` before TanStack routing and accepts only same-origin WebSocket upgrades with a valid capability. It then rechecks the candidate role, interview ownership, application state, interview state, and communication-assessment state in Postgres before routing the original request to the Agent.
- The source-controlled prompt and first message are built from server-owned job/company/candidate context in `voice-runtime.ts`. Candidate background is delimited as untrusted evidence.
- A call creates the assessment if absent and changes it to `in_progress`. The Agent enforces one active speaker per interview and a 10-minute conversation limit, after which no new turn reaches the LLM and the candidate is asked to finish.
- Durable Object SQLite is the authority for in-call and reconnect history. The browser is display-only and never submits transcript content to the server.
- Intentional completion waits for a pending candidate utterance to commit, ends the live session, and calls an authenticated Agent RPC to finalize committed history. Finalization persists the Postgres transcript with `analysis: null`, moves the interview to `completed`, and starts post-evaluation idempotently.
- Agent-selected completion speaks its farewell once, then asks the candidate to finish after playback; this avoids clipping the farewell because the Beta SDK has no browser playback-drained callback.
- Durable Object history is scheduled for deletion no later than seven days after its first creation. Account erasure deletes it immediately in addition to redacting the final Postgres transcript.
- The initial Cloudflare implementation does not record or store call audio. The nullable legacy `audio_key` column remains for compatibility and erasure coverage.
- Voice dimension analysis (`analyzeVoiceTranscript`) runs inside post-evaluation `load_voice_assessment`, not on the hot path.
- Communication score blend is evidence-weighted in `applyVoiceAssessmentToReport()` (not fixed 60/40).

### Post-Evaluation Workflow

Expand Down Expand Up @@ -793,7 +797,7 @@ wrangler hyperdrive create roundzero-db-staging \
| Secret | Purpose |
| ---------------------------- | ------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Postgres connection string for dbmate migrations |
| `SESSION_SECRET` | Cookie signing key |
| `SESSION_SECRET` | Session and voice-capability signing key |
| `APP_URL` | Canonical app URL (`https://staging.tryroundzero.com`) |
| `EMAIL_FROM` | Transactional email from address (Cloudflare Email Service; domain must be onboarded) |
| `OPENROUTER_API_KEY` | LLM inference |
Expand All @@ -803,9 +807,6 @@ wrangler hyperdrive create roundzero-db-staging \
| `POLAR_PRODUCT_ID_STARTER` | Polar Starter plan product ID |
| `POLAR_PRODUCT_ID_GROWTH` | Polar Growth plan product ID |
| `POLAR_PRODUCT_ID_SCALE` | Polar Scale plan product ID |
| `ELEVENLABS_API_KEY` | Voice assessment |
| `ELEVENLABS_AGENT_ID` | Voice agent config |
| `ELEVENLABS_WEBHOOK_SECRET` | Voice webhook verification |
| `VITE_GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `VITE_APP_URL` | Client-side app URL |
| `VITE_PUBLIC_ASSET_BASE_URL` | R2 asset CDN domain (optional) |
Expand Down
3 changes: 1 addition & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

## Future

- send periodic job recommendations to candidates (matches) based on their profile and put it behind a paywall. (DONE but not being a paywall)
- add batch actions for companies (shortlist multiple candidates, and quick actions "shortlist top 3" reject "bottom ones")
- see if we can add a cold reach out feature for companies (paid) so find any candidate on the platform in natural lang. should give their profile with a nice summary irrespective of job postings or anything. useful if companies are looking for someone direct instead of posting a job. an alternate way of hiring basically.
- right now there's no feedback if there's something wrong with candidate's pdf. they can upload any pdf and the workflow will try to parse it. we need to detect if it's not a resume, and tell the user to add or something? think.
Expand Down Expand Up @@ -227,8 +228,6 @@

## Agent & AI Stuff

- send periodic job recommendations to candidates (matches) based on their profile and put it behind a paywall.

<!--- add guard rails during agent conv, detect short and uninterested answers, detect screenshots, detect ai responses, detect if text was copied, and detect if answers don't align with normal conv tone and more. basically ai beating ai. - this will be another step in post-evaluation.-->
<!--- show suggested skills based on job description.-->
<!--- ~~gatekeep ai features using paywall~~ Done — AI job creation gated to paid plans via `aiJobCreation` entitlement.-->
Expand Down
6 changes: 6 additions & 0 deletions app/features/accounts/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ JOIN applications a ON a.id = ca.application_id
WHERE a.candidate_id = $1
AND ca.audio_key IS NOT NULL;

-- name: listInterviewIdsForCandidate :many
SELECT i.id
FROM interviews i
JOIN applications a ON a.id = i.application_id
WHERE a.candidate_id = $1;

-- name: scrubCandidateProfileForUser :exec
UPDATE candidate_profiles
SET resume_key = NULL,
Expand Down
20 changes: 20 additions & 0 deletions app/features/accounts/queries/queries_sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,26 @@ export async function listVoiceAudioKeysForUser(sql: Sql, args: listVoiceAudioKe
}));
}

export const listInterviewIdsForCandidateQuery = `-- name: listInterviewIdsForCandidate :many
SELECT i.id
FROM interviews i
JOIN applications a ON a.id = i.application_id
WHERE a.candidate_id = $1`;

export interface listInterviewIdsForCandidateArgs {
candidateId: string;
}

export interface listInterviewIdsForCandidateRow {
id: string;
}

export async function listInterviewIdsForCandidate(sql: Sql, args: listInterviewIdsForCandidateArgs): Promise<listInterviewIdsForCandidateRow[]> {
return (await sql.unsafe(listInterviewIdsForCandidateQuery, [args.candidateId]).values()).map(row => ({
id: row[0]
}));
}

export const scrubCandidateProfileForUserQuery = `-- name: scrubCandidateProfileForUser :exec
UPDATE candidate_profiles
SET resume_key = NULL,
Expand Down
48 changes: 41 additions & 7 deletions app/features/accounts/server/__tests__/cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ function makeFakeR2Bucket(deleteImplementation?: (key: string) => Promise<void>)
};
}

function makeExternalStores(eraseVoiceHistory?: (interviewId: string) => Promise<void>) {
return {
eraseVoiceHistory: vi.fn(eraseVoiceHistory ?? (async () => undefined)),
};
}

async function seedCandidatePipeline() {
const { company } = await seedCompany();
const { job } = await seedJob({ companyId: company.id, status: "open" });
Expand Down Expand Up @@ -187,7 +193,9 @@ describe("eraseDeletedAccount", () => {
throw deletionError;
});

await expect(eraseDeletedAccount(sql, bucket, candidate.id)).rejects.toThrow(deletionError);
await expect(
eraseDeletedAccount(sql, bucket, candidate.id, makeExternalStores()),
).rejects.toThrow(deletionError);
expect(deleteFn).toHaveBeenCalledTimes(1);

const [userRow] = await sql`
Expand Down Expand Up @@ -237,8 +245,10 @@ describe("eraseDeletedAccount", () => {
await softDeleteUser(candidate.id, daysAgo(31));

const { bucket, deleteFn } = makeFakeR2Bucket();
const first = await eraseDeletedAccount(sql, bucket, candidate.id);
const externalStores = makeExternalStores();
const first = await eraseDeletedAccount(sql, bucket, candidate.id, externalStores);
expect(first).toEqual({ erased: true });
expect(externalStores.eraseVoiceHistory).toHaveBeenCalledWith(interviewId);

const deletedKeys = deleteFn.mock.calls.map(([key]) => key).sort();
expect(deletedKeys).toEqual([appResumeKey, audioKey, profileResumeKey].sort());
Expand Down Expand Up @@ -340,12 +350,36 @@ describe("eraseDeletedAccount", () => {
expect(feedbackCount?.count).toBe(0);
});

it("does not anonymize the account when Durable Object voice-history erasure fails", async () => {
const { candidate, interviewId } = await seedCandidatePipeline();
await softDeleteUser(candidate.id, daysAgo(31));

const voiceError = new Error("Voice history unavailable");
const externalStores = makeExternalStores(async () => {
throw voiceError;
});
const { bucket } = makeFakeR2Bucket();

await expect(eraseDeletedAccount(sql, bucket, candidate.id, externalStores)).rejects.toThrow(
voiceError,
);
expect(externalStores.eraseVoiceHistory).toHaveBeenCalledWith(interviewId);

const [userRow] = await sql`
SELECT name, anonymized_at
FROM users
WHERE id = ${candidate.id}
`;
expect(userRow?.name).toBe(candidate.name);
expect(userRow?.anonymized_at).toBeNull();
});

it("is idempotent on a second run", async () => {
const { candidate } = await seedCandidatePipeline();
await softDeleteUser(candidate.id, daysAgo(31));

const { bucket } = makeFakeR2Bucket();
const first = await eraseDeletedAccount(sql, bucket, candidate.id);
const first = await eraseDeletedAccount(sql, bucket, candidate.id, makeExternalStores());
expect(first).toEqual({ erased: true });

const [afterFirst] = await sql`
Expand All @@ -354,7 +388,7 @@ describe("eraseDeletedAccount", () => {
WHERE id = ${candidate.id}
`;

const second = await eraseDeletedAccount(sql, bucket, candidate.id);
const second = await eraseDeletedAccount(sql, bucket, candidate.id, makeExternalStores());
expect(second).toEqual({ erased: false });

const [afterSecond] = await sql`
Expand All @@ -380,7 +414,7 @@ describe("eraseDeletedAccount", () => {
await softDeleteUser(candidate.id, daysAgo(10));

const { bucket, deleteFn } = makeFakeR2Bucket();
const result = await eraseDeletedAccount(sql, bucket, candidate.id);
const result = await eraseDeletedAccount(sql, bucket, candidate.id, makeExternalStores());
expect(result).toEqual({ erased: false });
expect(deleteFn).not.toHaveBeenCalled();

Expand All @@ -400,7 +434,7 @@ describe("eraseDeletedAccount", () => {
await softDeleteUser(owner.id, daysAgo(31));

const { bucket } = makeFakeR2Bucket();
const result = await eraseDeletedAccount(sql, bucket, owner.id);
const result = await eraseDeletedAccount(sql, bucket, owner.id, makeExternalStores());
expect(result).toEqual({ erased: true });

const [ownerRow] = await sql`
Expand Down Expand Up @@ -442,7 +476,7 @@ describe("eraseDeletedAccount", () => {
await softDeleteUser(owner.id, daysAgo(31));

const { bucket } = makeFakeR2Bucket();
const result = await eraseDeletedAccount(sql, bucket, owner.id);
const result = await eraseDeletedAccount(sql, bucket, owner.id, makeExternalStores());
expect(result).toEqual({ erased: true });

const [jobRow] = await sql`
Expand Down
Loading