[{"data":1,"prerenderedAt":795},["ShallowReactive",2],{"repo-tree":3,"repo-\u002Flearning-api-preview-vm-plan":283},[4,7,10,13,16,19,22,25,28,31,34,37,40,43,46,49,52,55,58,61,64,67,69,72,75,78,81,84,86,88,90,93,96,99,102,105,108,111,114,117,120,123,125,127,129,131,133,135,138,141,143,146,149,152,155,158,161,164,167,169,172,175,178,180,183,186,189,192,195,198,201,203,206,209,212,215,218,221,224,227,230,233,236,239,242,245,248,251,254,257,260,263,266,269,272,275,278,281],{"path":5,"title":6},"\u002Fagents\u002Fbackend-code-style","Backend Conventions",{"path":8,"title":9},"\u002Fagents\u002Fdatabase","Database",{"path":11,"title":12},"\u002Fagents\u002Fportal-code-style","Portal Conventions",{"path":14,"title":15},"\u002Fagents\u002Ftranslation","Translation",{"path":17,"title":18},"\u002Fconventions\u002Fbackend-coding","Backend coding conventions",{"path":20,"title":21},"\u002Fconventions\u002Ffrontend-coding","Frontend coding conventions",{"path":23,"title":24},"\u002Fdevelopment-process","Development process",{"path":26,"title":27},"\u002Flearning-api-preview-hetzner-setup","Learning API Preview on Hetzner + Cloudflare",{"path":29,"title":30},"\u002Flearning-api-preview-vm-plan","Learning API Preview VM Plan",{"path":32,"title":33},"\u002Fmonorepo-structure","Monorepo structure",{"path":35,"title":36},"\u002Foperations","Operations — bugs and support",{"path":38,"title":39},"\u002Fpostmortems\u002F2026-03-16_onboarding-currency-regression","Onboarding Zod transform silently broken — web signups assigned wrong checkout currency",{"path":41,"title":42},"\u002Fpostmortems\u002Freadme","Postmortems",{"path":44,"title":45},"\u002Fpostmortems\u002F_template","TEMPLATE",{"path":47,"title":48},"\u002Fpostmortems\u002Fposthog-comparison","Postmortem practice — comparison with PostHog",{"path":50,"title":51},"\u002Fpreview-environment-plan","Preview Environment Plan",{"path":53,"title":54},"\u002Fprinciples","Engineering principles",{"path":56,"title":57},"\u002Fworking-with-ai","Working with AI",{"path":59,"title":60},"\u002F.claude\u002Fskills\u002Feval-playground\u002Fskill","Eval Playground — Co-development Skill",{"path":62,"title":63},"\u002F.claude\u002Fskills\u002Ffigma-diff-section\u002Fskill","Figma Diff Section Pipeline",{"path":65,"title":66},"\u002Fagents","AGENTS.md",{"path":68,"title":66},"\u002Fclaude",{"path":70,"title":71},"\u002Freadme","Studyflash",{"path":73,"title":74},"\u002Fapps\u002Fcore-api\u002Fagents","Core API (apps\u002Fcore-api)",{"path":76,"title":77},"\u002Fapps\u002Fcore-api\u002Freadme","README",{"path":79,"title":80},"\u002Fapps\u002Femail-previews\u002Fagents","Email Previews (apps\u002Femail-previews)",{"path":82,"title":83},"\u002Fapps\u002Flanding-page\u002Fagents","Landing Page (apps\u002Flanding-page)",{"path":85,"title":83},"\u002Fapps\u002Flanding-page\u002Fclaude",{"path":87,"title":66},"\u002Fapps\u002Flearning-api\u002Fagents",{"path":89,"title":77},"\u002Fapps\u002Flearning-api\u002Freadme",{"path":91,"title":92},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Feval_metrics_design","Surface-Specific Eval Metrics Design",{"path":94,"title":95},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Ftest_set","Quiz Eval Test Set",{"path":97,"title":98},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Ffrontend\u002Freadme","React + TypeScript + Vite",{"path":100,"title":101},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Fknown-issues\u002Fcontent-pillar-shallow-coverage\u002Freadme","Content pillar misses subtopics in dense documents",{"path":103,"title":104},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Fknown-issues\u002Fdocling-empty-section-headers\u002Freadme","Empty section headers dropped by docling chunker",{"path":106,"title":107},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Fknown-issues\u002Fdocling-table-reading-order\u002Freadme","Table\u002Fbox layout causes wrong reading order",{"path":109,"title":110},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Fmetrics\u002Freadme","Quiz eval metrics — canonical rubrics",{"path":112,"title":113},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Freports\u002F2026-04-12-quiz-summary-feedback-current-state","Quiz and Summary Feedback Current State",{"path":115,"title":116},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Freports\u002F2026-04-24-quiz-eval-metrics","Quiz Evaluation Metrics",{"path":118,"title":119},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Freports\u002F2026-05-01-quiz-eval-current-state","Quiz Eval Current State",{"path":121,"title":122},"\u002Fapps\u002Flearning-api\u002Fmonitoring\u002Freadme","Monitoring Stack",{"path":124,"title":77},"\u002Fapps\u002Flearning-api\u002Fshared\u002Freadme",{"path":126,"title":77},"\u002Fapps\u002Flearning-api\u002Fworkers\u002Flearning_agents\u002Fflashcard_agent\u002Freadme",{"path":128,"title":77},"\u002Fapps\u002Flearning-api\u002Fworkers\u002Flearning_agents\u002Fingestion_agent\u002Freadme",{"path":130,"title":77},"\u002Fapps\u002Flearning-api\u002Fworkers\u002Flearning_agents\u002Fquiz_agent\u002Freadme",{"path":132,"title":77},"\u002Fapps\u002Flearning-api\u002Fworkers\u002Flearning_agents\u002Fsummary_agent\u002Freadme",{"path":134,"title":77},"\u002Fapps\u002Flearning-api\u002Fworkers\u002Fparser\u002Freadme",{"path":136,"title":137},"\u002Fapps\u002Fmarketing-emails-preview\u002Fagents","Marketing Emails Preview (apps\u002Fmarketing-emails-preview)",{"path":139,"title":140},"\u002Fapps\u002Fmobile-app\u002Fagents","StudyFlash Mobile App - Claude Code Configuration",{"path":142,"title":140},"\u002Fapps\u002Fmobile-app\u002Fclaude",{"path":144,"title":145},"\u002Fapps\u002Fmountain-max\u002Fagents","Mountain Max (apps\u002Fmountain-max)",{"path":147,"title":148},"\u002Fapps\u002Fmountain-max\u002Fgame\u002Freadme","Mountain Max Game",{"path":150,"title":151},"\u002Fapps\u002Fportal\u002Fagents","Portal (apps\u002Fportal)",{"path":153,"title":154},"\u002Fapps\u002Fportal\u002Freadme","Nuxt Minimal Starter",{"path":156,"title":157},"\u002Fapps\u002Fportal\u002Fapp\u002Fcomposables\u002Ffiles\u002Freadme","File Upload Composables",{"path":159,"title":160},"\u002Fapps\u002Fportal\u002Fdocs\u002Flibrary-routing","Library Routing Documentation",{"path":162,"title":163},"\u002Fapps\u002Fsupabase\u002Fagents","Supabase (apps\u002Fsupabase)",{"path":165,"title":166},"\u002Fapps\u002Fwrapped\u002Fagents","Wrapped (apps\u002Fwrapped)",{"path":168,"title":98},"\u002Fapps\u002Fwrapped\u002Freadme",{"path":170,"title":171},"\u002Finfra\u002Freadme","infra\u002F",{"path":173,"title":174},"\u002Finfra\u002Fdns\u002Freadme","DNS Infrastructure",{"path":176,"title":177},"\u002Finfra\u002Fdokploy\u002Freadme","studyflash-dokploy",{"path":179,"title":77},"\u002Finfra\u002Fdokploy\u002Fsdk\u002Fnodejs\u002Freadme",{"path":181,"title":182},"\u002Finfra\u002Finfisical\u002Freadme","Infisical Infrastructure",{"path":184,"title":185},"\u002Finfra\u002Flearning-api\u002Freadme","Pulumi GCP TypeScript Template",{"path":187,"title":188},"\u002Finfra\u002Fopenreplay\u002Freadme","OpenReplay on Hetzner",{"path":190,"title":191},"\u002Finfra\u002Fscripts\u002Freadme","infra\u002Fscripts\u002F",{"path":193,"title":194},"\u002Finfra\u002Fturborepo-cache\u002Freadme","Turborepo Remote Cache Infrastructure",{"path":196,"title":197},"\u002Finternal\u002Fchatwoot\u002Freadme","Chatwoot Infrastructure",{"path":199,"title":200},"\u002Finternal\u002Fchatwoot\u002Fprovider\u002Freadme","studyflash-chatwoot-provider",{"path":202,"title":77},"\u002Finternal\u002Fchatwoot\u002Fprovider\u002Fsdk\u002Fnodejs\u002Freadme",{"path":204,"title":205},"\u002Finternal\u002Fdocs\u002Freadme","internal\u002Fdocs",{"path":207,"title":208},"\u002Finternal\u002Fsupport-bot\u002Fclaude","Support Bot (Maximilian)",{"path":210,"title":211},"\u002Finternal\u002Fsupport-bot\u002Freadme","Studyflash Customer Support Bot (Maximilian)",{"path":213,"title":214},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Faccount_issues","Account Issues",{"path":216,"title":217},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fbilling_invoice","Billing Invoice",{"path":219,"title":220},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fcontent_upload","Content Upload",{"path":222,"title":223},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fdata_loss","Data Loss",{"path":225,"title":226},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fflashcard_issues","Flashcard Issues",{"path":228,"title":229},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fgarbage","Garbage",{"path":231,"title":232},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fgeneral_how_to","General How To",{"path":234,"title":235},"\u002Finternal\u002Fsupport-bot\u002Fkb","Knowledge Base Index",{"path":237,"title":238},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Flanguage_issues","Language Issues",{"path":240,"title":241},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fmindmap_issues","Mindmap Issues",{"path":243,"title":244},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fmisunderstanding","Misunderstanding",{"path":246,"title":247},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fmock_exam_issues","Mock Exam Issues",{"path":249,"title":250},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fpodcast_issues","Podcast Issues",{"path":252,"title":253},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fquiz_issues","Quiz Issues",{"path":255,"title":256},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Frefund_request","Refund Request",{"path":258,"title":259},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fsubscription_cancellation","Subscription Cancellation",{"path":261,"title":262},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fsubscription_info","Subscription Info",{"path":264,"title":265},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fsummary_issues","Summary Issues",{"path":267,"title":268},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Ftechnical_errors","Technical Errors",{"path":270,"title":271},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fvideo_issues","Video Issues",{"path":273,"title":274},"\u002Fpackages\u002Fcommon\u002Fdocs\u002Fearly-access-features","Declarative Early Access Features",{"path":276,"title":277},"\u002Fpackages\u002Fcommon\u002Fscripts\u002Freadme","Common Package Scripts",{"path":279,"title":280},"\u002Fpackages\u002Fdevtools\u002Ffigma-plugins\u002Freadme","Figma plugins",{"path":282,"title":77},"\u002Fpackages\u002Fpulumi-infisical\u002Freadme",{"id":284,"title":30,"body":285,"description":771,"extension":789,"lastReviewed":790,"meta":791,"navigation":792,"owner":790,"path":29,"seo":793,"status":790,"stem":291,"tags":790,"__hash__":794},"handbook\u002Flearning-api-preview-vm-plan.md",{"type":286,"value":287,"toc":770},"minimark",[288,292,297,306,310,341,345,356,360,363,384,387,416,419,439,442,459,462,471,475,478,485,488,496,500,505,598,602,623,627,648,652,681,685,688,696,699,707,711,755,759],[289,290,30],"h1",{"id":291},"learning-api-preview-vm-plan",[293,294,296],"h2",{"id":295},"goal","Goal",[298,299,300,301,305],"p",{},"Run per-PR Learning API preview environments on one provisioned VM, with clean integration into existing ",[302,303,304],"code",{},"core-api"," preview environments.",[293,307,309],{"id":308},"scope","Scope",[311,312,313,325,328,338],"ul",{},[314,315,316,317,320,321,324],"li",{},"Host ",[302,318,319],{},"learning-api"," preview stacks (",[302,322,323],{},"api + worker + redis",") per PR.",[314,326,327],{},"Expose each PR stack behind Cloudflare (Tunnel + DNS).",[314,329,330,331,333,334,337],{},"Wire each PR stack URL into matching ",[302,332,304],{}," preview (",[302,335,336],{},"LEARNING_API_BASE_URL",").",[314,339,340],{},"Create\u002Fupdate on PR open\u002Fsync, destroy on PR close.",[293,342,344],{"id":343},"assumptions","Assumptions",[311,346,347,350,353],{},[314,348,349],{},"Parser service is disabled for default preview stacks.",[314,351,352],{},"No GPU requirement in default previews.",[314,354,355],{},"Supabase preview branch lifecycle remains managed by Supabase GitHub integration.",[293,357,359],{"id":358},"resource-model","Resource Model",[298,361,362],{},"Current defaults in repo are heavy for previews:",[311,364,365,375],{},[314,366,367,368,371,372,337],{},"API Dockerfile uses ",[302,369,370],{},"gunicorn -w 8"," (",[302,373,374],{},"apps\u002Flearning-api\u002Fapi\u002FDockerfile",[314,376,377,378,371,381,337],{},"Worker Dockerfile uses Celery ",[302,379,380],{},"--concurrency=16",[302,382,383],{},"apps\u002Flearning-api\u002Fworkers\u002Flearning_agents\u002FDockerfile",[298,385,386],{},"Preview-safe per-PR budget (after tuning):",[311,388,389,400,409],{},[314,390,391,392,395,396,399],{},"CPU: ",[302,393,394],{},"~1.5 vCPU"," reserved, ",[302,397,398],{},"~2.5 vCPU"," peak",[314,401,402,403,395,406,399],{},"Memory: ",[302,404,405],{},"~2.0 GB",[302,407,408],{},"~2.8 GB",[314,410,411,412,415],{},"Disk: ",[302,413,414],{},"~2-4 GB"," writable\u002Flog\u002Ftemp",[298,417,418],{},"Single-VM sizing recommendations:",[311,420,421,427,433],{},[314,422,423,424],{},"3 active PRs: ",[302,425,426],{},"8 vCPU \u002F 16 GB RAM \u002F 150 GB NVMe",[314,428,429,430],{},"6 active PRs: ",[302,431,432],{},"16 vCPU \u002F 32 GB RAM \u002F 250 GB NVMe",[314,434,435,436],{},"10 active PRs: ",[302,437,438],{},"24 vCPU \u002F 64 GB RAM \u002F 400 GB NVMe",[298,440,441],{},"Planning formulas:",[311,443,444,449,454],{},[314,445,446],{},[302,447,448],{},"vCPU ≈ 2 + 1.5 * active_previews",[314,450,451],{},[302,452,453],{},"RAM_GB ≈ 6 + 2.5 * active_previews",[314,455,456],{},[302,457,458],{},"Disk_GB ≈ 120 + 8 * active_previews",[298,460,461],{},"Recommended starting point:",[311,463,464,468],{},[314,465,466],{},[302,467,432],{},[314,469,470],{},"Cap active previews at 6.",[293,472,474],{"id":473},"url-and-routing-contract","URL and Routing Contract",[298,476,477],{},"Per-PR URL pattern:",[311,479,480],{},[314,481,482],{},[302,483,484],{},"https:\u002F\u002F\u003Cpreview-base-host>\u002Fpr-\u003CPR_NUMBER>",[298,486,487],{},"Core API integration:",[311,489,490],{},[314,491,492,493,495],{},"Set ",[302,494,336],{}," in that PR’s core-api preview secrets to the URL above.",[293,497,499],{"id":498},"lifecycle-workflow","Lifecycle Workflow",[501,502,504],"h3",{"id":503},"pr-open-reopen","PR Open \u002F Reopen",[506,507,508,511,565,575,585,595],"ol",{},[314,509,510],{},"Resolve PR number + branch slug.",[314,512,513,514,517,518],{},"Render stack env file (",[302,515,516],{},".env.pr-\u003Cn>",") with:\n",[311,519,520,530,533,540],{},[314,521,522,523,526,527],{},"base secrets from Infisical ",[302,524,525],{},"\u002Flearning-api\u002F"," in ",[302,528,529],{},"staging",[314,531,532],{},"Supabase preview URL\u002Fservice role key overrides",[314,534,535,536,539],{},"API key aligned with core-api preview (",[302,537,538],{},"LEARNING_API_KEY",")",[314,541,542,543],{},"internal wiring overrides:\n",[311,544,545,550,555,560],{},[314,546,547],{},[302,548,549],{},"API_URL=http:\u002F\u002Fapi:8000",[314,551,552],{},[302,553,554],{},"REDIS_URL=redis:\u002F\u002Fredis:6379\u002F0",[314,556,557],{},[302,558,559],{},"CELERY_BROKER_URL=redis:\u002F\u002Fredis:6379\u002F0",[314,561,562],{},[302,563,564],{},"CELERY_RESULT_BACKEND=redis:\u002F\u002Fredis:6379\u002F0",[314,566,567,568],{},"Start stack with compose project namespace:\n",[311,569,570],{},[314,571,572],{},[302,573,574],{},"docker compose -p pr-\u003Cn> up -d",[314,576,577,578],{},"Traefik auto-discovers the stack route from Docker labels:\n",[311,579,580],{},[314,581,582],{},[302,583,584],{},"Host(\u003Cpreview-host>) && PathPrefix(\u002Fpr-\u003Cn>)",[314,586,587,588],{},"Update core-api preview secrets:\n",[311,589,590],{},[314,591,592],{},[302,593,594],{},"LEARNING_API_BASE_URL=https:\u002F\u002F\u003Cpreview-base-host>\u002Fpr-\u003Cn>",[314,596,597],{},"Comment preview URL on PR.",[501,599,601],{"id":600},"pr-synchronize","PR Synchronize",[506,603,604,607,617,620],{},[314,605,606],{},"Pull latest images \u002F build if needed.",[314,608,609,610],{},"Recreate only that PR namespace:\n",[311,611,612],{},[314,613,614],{},[302,615,616],{},"docker compose -p pr-\u003Cn> up -d --force-recreate",[314,618,619],{},"Keep same URL and route.",[314,621,622],{},"Refresh core-api preview secret only if URL changes (normally no change).",[501,624,626],{"id":625},"pr-close","PR Close",[506,628,629,639,642,645],{},[314,630,631,632],{},"Stop and remove PR namespace:\n",[311,633,634],{},[314,635,636],{},[302,637,638],{},"docker compose -p pr-\u003Cn> down -v --remove-orphans",[314,640,641],{},"Route automatically disappears when container is removed.",[314,643,644],{},"Remove any local env\u002Fartifact files for that PR.",[314,646,647],{},"Keep nightly janitor job to clean orphaned stacks\u002Froutes.",[293,649,651],{"id":650},"preview-runtime-guardrails","Preview Runtime Guardrails",[311,653,654,657,660,667,678],{},[314,655,656],{},"Cap API workers and Celery concurrency for preview to avoid VM starvation.",[314,658,659],{},"Apply per-container memory limits and restart policies.",[314,661,662,663,666],{},"Use project name isolation (",[302,664,665],{},"pr-\u003Cn>",") for deterministic cleanup.",[314,668,669,670],{},"Log retention:\n",[311,671,672,675],{},[314,673,674],{},"rotate container logs",[314,676,677],{},"TTL old logs\u002Fartifacts",[314,679,680],{},"Enforce max concurrent preview stacks; fail fast with clear message when full.",[293,682,684],{"id":683},"parser-strategy","Parser Strategy",[298,686,687],{},"Default preview:",[311,689,690,693],{},[314,691,692],{},"Disable parser service.",[314,694,695],{},"Use fallback parsing path in preview mode.",[298,697,698],{},"Optional parser preview:",[311,700,701,704],{},[314,702,703],{},"Enable only on-demand (label or manual trigger), not for every PR.",[314,705,706],{},"Plan separate capacity if parser previews become common.",[293,708,710],{"id":709},"rollout-plan","Rollout Plan",[506,712,713,716,738,741,744],{},[314,714,715],{},"Add VM bootstrap scripts (Docker, compose plugin, cloudflared, systemd units).",[314,717,718,719],{},"Add GitHub Actions jobs:\n",[311,720,721,730],{},[314,722,723,726,727],{},[302,724,725],{},"deploy-learning-api-preview"," on ",[302,728,729],{},"opened\u002Freopened\u002Fsynchronize",[314,731,732,726,735],{},[302,733,734],{},"cleanup-learning-api-preview",[302,736,737],{},"closed",[314,739,740],{},"Add URL wiring step into existing core-api preview job.",[314,742,743],{},"Add nightly cleanup workflow for stale PR stacks.",[314,745,746,747],{},"Add basic observability:\n",[311,748,749,752],{},[314,750,751],{},"health check endpoint polling",[314,753,754],{},"stack count + resource usage report in workflow summary.",[293,756,758],{"id":757},"out-of-scope","Out of Scope",[311,760,761,764,767],{},[314,762,763],{},"Marketing\u002Femail preview changes.",[314,765,766],{},"Parser-on-every-PR rollout.",[314,768,769],{},"Migrating learning-api runtime to Cloudflare-native compute.",{"title":771,"searchDepth":772,"depth":772,"links":773},"",2,[774,775,776,777,778,779,785,786,787,788],{"id":295,"depth":772,"text":296},{"id":308,"depth":772,"text":309},{"id":343,"depth":772,"text":344},{"id":358,"depth":772,"text":359},{"id":473,"depth":772,"text":474},{"id":498,"depth":772,"text":499,"children":780},[781,783,784],{"id":503,"depth":782,"text":504},3,{"id":600,"depth":782,"text":601},{"id":625,"depth":782,"text":626},{"id":650,"depth":772,"text":651},{"id":683,"depth":772,"text":684},{"id":709,"depth":772,"text":710},{"id":757,"depth":772,"text":758},"md",null,{},true,{"title":30,"description":771},"cmC3uMV5odx0dl1z7yoWF80V9AkU_pQGza6m16OxmcE",1779007962943]