All projects

영어 스피킹 트레이너 Sayso

LIVE

A serverless speaking trainer: answers are recorded in the browser, transcribed by Whisper, graded by an LLM as structured JSON, and any attempt under the threshold is filed as a wrong note automatically.

브라우저에서 답변을 녹음하면 Whisper가 전사하고 LLM이 구조화 JSON으로 채점하며, 기준 미만 시도는 오답노트로 자동 수집되는 서버리스 스피킹 트레이너

Role · Solo builder — Next.js static front, Hono on Cloudflare Workers, self-hosted auth, Supabase schema (27 tables), R2 media, STT/LLM grading loop, LLM-generated courses, Cloudflare Pages deploy.

LLMServerlessSTTEdge

Key Metrics

144
API endpoints
27
DB tables
5 / 5
TOEIC parts covered

Why it matters

The product is one loop — record → transcribe → grade → collect mistakes — and every architectural choice exists to keep that loop instant and cheap: per-attempt grading instead of batches, JSON-only model output, a model tier per task, and a threshold constant that decides what becomes a wrong note.

제품은 '녹음 → 전사 → 채점 → 오답 수집' 루프 하나이고, 모든 구조 결정은 이 루프를 즉시·저렴하게 유지하는 데 맞춰져 있습니다. 배치 대신 시도 단위 채점, JSON 전용 모델 출력, 작업별 모델 티어, 그리고 오답 여부를 가르는 임계값 상수 하나.

Problem

Speaking is the skill language learners practice least, because feedback is slow: a human tutor grades later, or you replay your own recording and guess. The loop I wanted was measured in seconds. Record an answer in the browser, get a transcript of what you actually said, get a score with concrete corrections, and never think about what to review — the app keeps the misses. Doing that on a hobby budget meant no servers to keep warm and no per-user infrastructure: a static Next.js front on Cloudflare Pages, Hono on Cloudflare Workers, Supabase Postgres reached only through PostgREST, R2 for audio and images, Whisper for speech-to-text, and OpenRouter for the text LLM calls so the grading model can be swapped per feature without touching call sites.

말하기는 학습자가 가장 적게 연습하는 영역입니다. 피드백이 느리기 때문입니다. 튜터가 나중에 채점하거나, 자기 녹음을 다시 들으며 짐작해야 합니다. 원하는 루프는 초 단위였습니다. 브라우저에서 답변을 녹음하면 실제로 말한 내용의 전사본이 나오고, 점수와 구체적인 교정이 붙고, 무엇을 복습할지 고민하지 않아도 앱이 틀린 것을 모아둡니다. 이걸 취미 예산으로 하려면 켜둘 서버도, 사용자당 인프라도 없어야 했습니다. Cloudflare Pages의 정적 Next.js 프런트, Cloudflare Workers 위의 Hono, PostgREST로만 접근하는 Supabase Postgres, 오디오·이미지용 R2, STT는 Whisper, 텍스트 LLM은 OpenRouter를 경유해 기능별 채점 모델을 호출부 수정 없이 교체할 수 있게 했습니다.

What I built

Key Decisions (trade-offs)

1

Threshold constant, not a review UI

Wrong-note collection is a single rule: if attempts.ai_score falls below SCORE_THRESHOLD (60), the attempt is inserted into wrong_notes at grading time. There is no 'mark for review' button and no nightly job. Tuning the product's strictness is a one-line change, and the review queue can never drift from what was actually scored.

오답 수집은 규칙 하나입니다. attempts.ai_score가 SCORE_THRESHOLD(60) 미만이면 채점 시점에 wrong_notes에 바로 들어갑니다. '복습 표시' 버튼도, 야간 배치도 없습니다. 제품의 엄격함을 조정하는 건 한 줄 수정이고, 복습 큐가 실제 채점 결과와 어긋날 수 없습니다.

2

Per-attempt grading in JSON, never batched

Batching attempts would cut LLM calls, but the requirement is feedback while the answer is still in your head. Every attempt is graded on its own with response_format: json_object, so the score and corrections come back as fields rather than prose to parse. The system prompt carries only the rubric and the user prompt only question, reference answer and transcript — nothing extra, because the prompt is the per-call cost.

시도를 묶어 채점하면 LLM 호출은 줄지만, 요구사항은 답변이 머릿속에 남아 있을 때의 피드백입니다. 모든 시도를 개별로 response_format: json_object로 채점해 점수와 교정이 파싱할 산문이 아니라 필드로 돌아옵니다. 시스템 프롬프트는 채점 기준만, 유저 프롬프트는 문제·참고답안·전사본만 넣습니다. 프롬프트가 곧 호출당 비용이기 때문입니다.

3

A model tier per task, routed through OpenRouter

Grading needs judgment; vocabulary autocomplete needs a short, cheap answer. Each feature has a default model in one module (grading and course generation on Claude Haiku 4.5, vocab and question generation on gpt-4o-mini) with an env override per feature. All text calls go through OpenRouter so a model swap is a config change. Whisper is the one exception — OpenRouter has no audio transcription, so STT calls OpenAI directly.

채점은 판단이 필요하고, 단어 자동완성은 짧고 싼 답이면 됩니다. 기능별 기본 모델을 모듈 하나에 두고(채점·코스 생성은 Claude Haiku 4.5, 단어·문제 생성은 gpt-4o-mini) 기능별 env로 덮어쓸 수 있게 했습니다. 텍스트 호출은 전부 OpenRouter를 경유하므로 모델 교체가 설정 변경입니다. Whisper만 예외로, OpenRouter가 오디오 전사를 지원하지 않아 STT는 OpenAI를 직접 호출합니다.

4

PostgREST only, auth at the Worker

Workers have no persistent TCP, so instead of a Postgres driver or Hyperdrive the Worker talks to Supabase through its REST API behind a thin wrapper. That means the Worker holds the service_role key and bypasses row-level security, so the real access boundary is requireAuth / requireAdmin middleware on every route — an explicit rule rather than something the database enforces for free.

Workers에는 지속 TCP 연결이 없어, Postgres 드라이버나 Hyperdrive 대신 얇은 래퍼 뒤에서 Supabase REST API로만 통신합니다. 그 대가로 Worker가 service_role 키를 들고 RLS를 우회하므로, 실제 접근 경계는 모든 라우트의 requireAuth / requireAdmin 미들웨어입니다. DB가 공짜로 강제해주는 것이 아니라 명시적 규칙입니다.

5

Self-hosted auth that survives Safari and a static export

No OAuth by requirement: bcryptjs password hashing and an HS256 JWT signed with WebCrypto on the Worker. The access token lives in memory and the refresh token in an httpOnly cookie. Two consequences shaped the deploy: the API is proxied by a Pages Function on the same origin so the cookie stays first-party (Safari drops it otherwise and sessions die at the 15-minute expiry), and because the front is a static export with no middleware, the auth check is a client-side guard that silently refreshes on mount.

요구사항상 OAuth 없이 bcryptjs 해싱과 Worker에서 WebCrypto로 서명한 HS256 JWT를 씁니다. 액세스 토큰은 메모리, 리프레시 토큰은 httpOnly 쿠키입니다. 이게 배포 구조를 두 가지로 결정했습니다. API를 같은 오리진의 Pages Function으로 프록시해 쿠키를 1st-party로 유지하고(아니면 Safari가 버려 15분 만료 시 세션이 죽습니다), 프런트가 미들웨어 없는 정적 export라 인증 확인은 마운트 시 조용히 갱신하는 클라이언트 가드로 처리합니다.

6

Tablet frame on desktop, phone-native below it

The app is capped at 834px (iPad Air) on every viewport and behaves as a normal responsive layout below that, so it reads as one app surface everywhere and the design only has to work across 0–834px. The body is pinned and only <main> scrolls, which stops iOS Safari from rubber-banding the nav bars. The public landing page is the deliberate exception: it lives outside that shell as a full-width web page.

앱은 모든 뷰포트에서 834px(iPad Air)로 폭을 제한하고 그 아래에서는 일반 반응형으로 동작합니다. 어디서든 하나의 앱 화면으로 읽히고, 디자인은 0~834px 구간만 책임지면 됩니다. body를 고정하고 <main>만 스크롤해 iOS Safari에서 내비게이션 바가 튕기는 현상을 막았습니다. 공개 랜딩페이지만 의도된 예외로, 이 셸 밖에서 풀와이드 웹 페이지로 렌더됩니다.

Stack

Next.js 14 (static export) / TypeScriptTailwind CSS / ZustandHono on Cloudflare WorkersCloudflare Pages + Functions proxySupabase Postgres (PostgREST)Cloudflare R2OpenAI Whisper (STT)OpenRouter — Claude Haiku 4.5 / gpt-4o-minibcryptjs + WebCrypto JWT