A local web app for generating English speaking questions from a candidate profile, recording the answer with the browser camera, and saving the video plus metadata on the server.
npm install
npm startOpen http://localhost:3199.
Copy .env.example to .env, then fill in the required credentials. Use
.env.local for local overrides and .env.prod for production overrides;
these three environment files are gitignored, while .env.example is safe to
commit.
The app reads the internal OpenAI-compatible model gateway settings from .env:
INTERNAL_LLM_API_KEY=...
INTERNAL_LLM_CHAT_COMPLETIONS_URL=https://api.example.com/v1/chat/completions
INTERNAL_LLM_TRANSCRIPTIONS_URL=https://api.example.com/v1/audio/transcriptions
INTERNAL_LLM_QUESTION_MODEL=glm
INTERNAL_LLM_TRANSCRIBE_MODEL=qwen-asr
INTERNAL_LLM_EVAL_MODEL=qwen
EVAL_MAX_FRAMES=24
EVAL_REQUEST_TIMEOUT_MS=100000
# OpenRouter fallback for question generation, scoring, comment moderation,
# and ASR transcription when the internal gateway is unavailable. The
# transcription fallback uses OpenRouter's POST /api/v1/audio/transcriptions
# endpoint with a Qwen3 ASR model (same language coverage as qwen-asr).
OPENROUTER_API_KEY=...
OPENROUTER_CHAT_COMPLETIONS_URL=https://openrouter.ai/api/v1/chat/completions
OPENROUTER_EVAL_MODEL=z-ai/glm-5.3-flash
OPENROUTER_TRANSCRIBE_MODEL=qwen/qwen3-asr-1.7bEveryone can generate questions, save recordings, and view their own history as a guest. DingTalk login is optional; configure it with:
DINGTALK_UNIFIED_APP_ID=...
DINGTALK_CORP_ID=...
DINGTALK_CLIENT_ID=...
DINGTALK_CLIENT_SECRET=...
APP_BASE_URL=http://localhost:3199
SESSION_SECRET=...
COOKIE_SECURE=false
PARTNER_API_KEY=replace-with-a-long-random-secretThe DingTalk app callback URL must match APP_BASE_URL plus /auth/dingtalk/callback.
DINGTALK_CORP_ID enables automatic H5 passwordless sign-in when the page is
opened inside DingTalk. The configured 端内免登地址 is an allowlist; the browser
must still call DingTalk's requestAuthCode JSAPI and the server must exchange
that one-time code. Outside DingTalk, the existing nonce-protected OAuth flow is
available as an optional sign-in instead.
Guest access uses a server-issued signed HttpOnly englisheval_guest cookie with
a renewable 180-day lifetime. Set SESSION_SECRET even without DingTalk configuration
(the existing DINGTALK_CLIENT_SECRET fallback remains supported). No IP hash or
localStorage credential is used. Cookie loss or expiry creates a new guest identity.
GET /api/me returns the effective user and identityType (guest or dingtalk).
Guest openId values are synthetic guest:<UUID> ownership keys. DingTalk sessions
take priority; signing out restores the browser's separate guest history. Records,
consent, and pending jobs are never merged. Guest videos are private and excluded
from partner exports; weekly scores use generated aliases. All learners share the
same queue limits. Clearing cookies can create another identity; global queue and
worker limits remain the resource protection.
Learner API mutations require X-Expected-Owner matching /api/me's user.openId.
Missing or mismatched values return 409 with code: IDENTITY_CHANGED before
upload processing. Reads also reject a mismatched header when provided. Refresh
the session and reset identity-bound UI before retrying; never replay a previous
owner's upload under the new identity. Admin routes still require DingTalk and
the admin token; partner routes still require their bearer token.
In production, APP_BASE_URL must use HTTPS and COOKIE_SECURE must be true.
Local HTTP development may set COOKIE_SECURE=false.
Recording protection and production maintenance use:
RECORDING_RETENTION_DAYS=0
BACKUP_RETENTION_DAYS=30
BACKUP_AGE_RECIPIENT=<public-age-recipient>
RECORDING_BACKUP_ENABLED=trueRECORDING_RETENTION_DAYS=0 disables live-recording expiration, so videos and
their extracted evaluation artifacts are preserved indefinitely. Encrypted
backup snapshots expire independently according to BACKUP_RETENTION_DAYS (30
days by default).
RECORDING_BACKUP_ENABLED=false turns off the encrypted backup and generates
the maintenance unit without its service stop/start steps: the nightly timer
then runs only recording retention and the orphan sweep with the application
and worker fully up, and deployment becomes the only source of restarts. The
age command and BACKUP_AGE_RECIPIENT are required only while the backup is
enabled; switching either way takes effect on the next ./deploy.sh, which
regenerates the unit.
The browser never receives API keys. Question generation goes through POST /api/generate-question.
This workflow is a core application contract and should be preserved when the authentication, question-generation, recording, or evaluation code changes.
- The browser checks
GET /api/me. Inside DingTalk it automatically requests a one-time H5 auth code and posts it toPOST /auth/dingtalk/in-app. In a regular browser, visitors continue with a signed guest cookie and may chooseGET /auth/dingtalkwith a safe local redirect path. In-app sign-in is bounded and falls back to guest access on failure. - The server creates a nonce-protected OAuth state and redirects to DingTalk.
At
/auth/dingtalk/callback, it exchanges the authorization code, obtains the user'sopenId, optionally enriches the session with organization fields (userId,jobNumber,email, andorgEmail), and sets a signed, HTTP-only session cookie. Authentication can continue if optional organization enrichment fails, but it cannot continue withoutopenId. - Before question generation, the effective user must accept both current
privacy acknowledgements. Consent is versioned and persisted by
openId. - The browser submits the candidate profile to
POST /api/generate-question. The server replaces the submitted name with the DingTalk name or generated guest alias, asks the configured internal chat model for a structured interview-style question, and persists the generated question with its owner and profile. If model generation fails, a persisted fallback question is returned with the error. - After a short preparation countdown, the browser records camera and
microphone together with
MediaRecorder. Recording stops when the user selects Finish and save, when the three-minute-thirty-second limit is reached, or when a required media device is interrupted. An interrupted incomplete recording is not uploaded. - The browser uploads the finalized recording and its
questionIdtoPOST /api/save-answer. The server rejects a missing question or a question not owned by the authenticated user'sopenId. Supported browser formats are validated and normalized to MP4 before durable storage. - Once the video is safely stored, the server runs the speech-evaluation workflow described below. Evaluation failure does not discard the answer: the recording and a failed evaluation status are still persisted.
- The recording metadata, evaluation, and owned question reference are
appended to
recordings/metadata.jsonl. History and video requests are always filtered by the effective user's ownership key.
In compact form:
Guest cookie or DingTalk OAuth -> effective owner -> versioned privacy consent
-> generate and persist owned question -> prepare -> record answer
-> verify owned question -> normalize and save video -> evaluate
-> persist result -> show owner-filtered history
/game always serves the current week: POST /api/game/question resolves
the week from the clock (currentChallenge) and the selected class group
(?group=g1|g2). The public leaderboard can display any of the last ten weeks,
so a past week is answerable only through /game/first-lesson — a private
make-up page pinned to group 2's lesson 1 (weekly-2026-09-28-g2, "Explaining
an experimental figure"). It is deliberately absent from every menu and serves
the app shell to the allow-listed session and to anonymous visitors. A visitor
without a session gets a blank page and the sign-in modal — the link is pasted
around directly, so a silent bounce to /leaderboard read as a broken link —
while a signed-in session that is not allow-listed is still redirected to
/leaderboard. The lesson itself paints only once /api/me reports
catchupAccess, and every API the page calls is gated on its own.
Access follows the teacher-console contract: CATCHUP_OPEN_IDS (comma-separated
openIds) is authoritative once non-empty, and until then the built-in names
张鳐 and 吴衍标 grant access while each login logs [catchup-enroll] with the
openId to enroll. The challengeId parameter on GET /api/game/challenge and
POST /api/game/question is rejected for every other session, and the learner's
answer is stored under the pinned challenge id, which is what puts his score on
the group 2 lesson 1 board.
Qwen ASR is the transcription component; it does not assign evaluation scores. The scoring model evaluates the transcript together with derived audio metrics, the question and rubric, and sampled video frames.
Recorded video
-> validate media; limit recorded answers to 3:30 and standalone uploads to 2:00
-> FFmpeg extracts mono 16 kHz audio
-> reject missing, silent, or extremely quiet audio
-> Qwen ASR transcribes English speech to text
(long audio is processed in ordered chunks)
-> FFmpeg derives duration, silence, pause, word-count, and speaking-rate metrics
-> FFmpeg samples a video frame about every five seconds, up to EVAL_MAX_FRAMES
-> GLM-5.3-Flash receives the transcript, audio metrics, question/profile, rubric, and frames
-> normalize and persist JSON scores, feedback, strengths, and improvements
The evaluator therefore uses different evidence for different dimensions:
- Grammar, vocabulary, coherence, and task relevance primarily use the transcript.
- Fluency and pacing use the transcript plus speaking-rate and pause metrics.
- Visual delivery uses sampled video frames. It is excluded and reweighted when usable audio exists but no video picture is available.
- Pronunciation/intelligibility is currently inferred from transcription reliability and intelligibility clues. The scoring model does not receive the original audio, so this is not a phoneme-level acoustic pronunciation test.
INTERNAL_LLM_API_KEY is required for both transcription and evaluation.
Evaluation uses INTERNAL_LLM_EVAL_MODEL (default qwen) through the internal
chat gateway; question generation remains on INTERNAL_LLM_QUESTION_MODEL
(default glm). Qwen ASR requests explicitly use English and retry transient failures. Audio longer than the configured chunk
size (30 seconds by default, capped at 40) is split and transcribed in order;
known Qwen input-format failures trigger WAV/MP3 format fallback.
If the internal gateway fails during question generation, evaluation scoring,
or comment moderation, the same request is retried once through OpenRouter
(OPENROUTER_API_KEY required; scoring uses OPENROUTER_EVAL_MODEL, default
z-ai/glm-5.3-flash, and question/moderation fall back to
OPENROUTER_QUESTION_MODEL/OPENROUTER_MODERATION_MODEL when set). The
evaluation result records whichever model actually produced it.
Node.js 22.16 or newer is required. npm run dev starts the web process and
evaluation worker; production uses separate englisheval and
englisheval-worker systemd services. npm start starts only the web process;
npm run worker starts only the worker.
The default capacity is 50 admitted sessions and 200 waiting-room positions,
with one outstanding session per effective owner openId. Recording happens in the
browser. Upload transfers are limited to four. Generic defaults are four processing
pipelines, CPU-derived FFmpeg concurrency, and two ASR/scoring calls each.
The six-CPU production profile uses eight pipelines, three FFmpeg processes,
three ASR slots and four scoring slots. Shared model budgets can reduce actual
request concurrency. Question generation stays at two slots and is reused within an admission.
These are single-server limits; do not start additional worker instances.
Clients accept privacy terms, POST /api/admission, heartbeat every 20 seconds,
and wait for state: admitted before generating a question. Idle pre-upload
reservations expire after three minutes. Obtain a grant from
POST /api/admission/upload-grant before uploading. Both upload endpoints now
require X-Submission-Id (UUID v4), X-Upload-Grant, and, for recorded answers,
X-Question-Id. They return HTTP 202 with a job ID and statusUrl; poll that URL
until completed, failed, or canceled. Reusing a submission ID returns its
existing job. Acknowledgement means the original upload and job are durable;
media validation and MP4 conversion occur asynchronously.
SQLite lives at recordings/queue.sqlite, with pending originals under
recordings/pending/. These files, WAL files, and artifacts are private persistent
data and are included in encrypted recording backups. Existing JSONL records
are preserved; only the web process projects worker results into them. The worker
checkpoints stages and request results, renews leases, and recovers after restart.
Discard creates a durable tombstone and removes media after the worker has stopped.
Never delete the queue database to clear a backlog. DATA_DIR selects isolated
storage for tests and staging; do not change it on the production service.
The browser retains unfinished uploads in owner-scoped IndexedDB. Successful
acknowledgement or explicit discard removes that local copy. Queued evaluations
survive closing the browser. History is paginated using limit (maximum 100)
and offset. Wait estimates use recent measured stage durations and current
pipeline occupancy; they are ranges, not completion guarantees. Cold starts show
"Calculating estimate" and service outages show a delay.
Admission pauses under worker outages, model cooldowns, sustained available RAM
below 512 MiB, or insufficient space for 512 MiB per reservation plus 5 GiB spare.
Already admitted users retain their upload allocation. Monitor /api/health and
the administrator's Evaluation capacity section. Administrators can pause new
admissions there; accepted jobs continue processing. Operational telemetry records
durations, retries, upstream status, and returned usage/cost fields without
transcripts, credentials, or identity fields.
Deployment initially holds admissions when creating a new queue. Verify health,
perform the isolated benchmark, and retain a tested queue-compatible release for
rollback before running node scripts/queue-control.js resume from the production
release with its original environment. pause, status, and checkpoint are
also available. Roll back web and worker together; never roll back to a release
without queue support once submissions have been accepted. Maintenance stops
both services before copying SQLite and resumes both afterward.
Run node scripts/load-benchmark.js --users=50 --arrivals=100 --duration=120 --restart
for isolated synthetic identities, real FFmpeg media processing, deterministic
model responses, and forced worker recovery. --users=10 and --users=30 cover
smaller bursts. --fixture=/absolute/video.mp4 --real-upstreams --users=1 measures
actual configured model services using the explicit fixture; it uses temporary
storage and never reads production recordings. --keep-data retains the isolated
test results. Do not load-test the live production application or user database.
The media worker uses FFprobe JSON to select streams. One FFmpeg invocation
produces the durable MP4, mono 16 kHz audio, volume/pause measurements and frames.
Compliant H.264 (8-bit 4:2:0, square pixels, up to 1280x720, known bitrate up to
2 Mbps, supported profile/level, no rotation or required trimming) is remuxed
without video re-encoding. Other video is normalized with H.264. Frames span the
whole answer, approximately every five seconds, subject to EVAL_MAX_FRAMES
(at most 24). Artifact failures fall back to saving the MP4; old checkpoints and
missing artifacts can resume from that recording.
FFMPEG_CONCURRENCY accepts 1-4. When unset, CPU affinity and Linux cgroup v2
quotas select 1-2 slots. WORKER_CONCURRENCY accepts 1-16 (default 4).
Model semaphores use MODEL_*_CONCURRENT, matching the durable model guard.
The queue estimator uses the worker's reported media, model and pipeline limits.
All decoder, filter and encoder outputs remain single-threaded.
The production web and worker units load the non-secret, versioned
ops/englisheval-capacity.env profile. On the six-CPU / 12 GB shared host the
worker gets CPUQuota=400%, MemoryHigh=3G, and MemoryMax=4G, leaving capacity
for the web process and other applications. The profile sets media/pipeline/ASR/
scoring limits to 3/8/3/4. RPM and TPM budgets remain unchanged; four scoring
slots do not guarantee four simultaneous provider requests. Existing .env.prod
values explicitly loaded by the app take precedence. Deployment and rollback
switch code and systemd units together without modifying shared credentials.
Admin capacity metrics show P50/P90, sample counts, duration class and pipeline
version. Stage samples measure active resource time; telemetry also records wall
time and resource waits. New and legacy pipeline samples are kept separate.
node scripts/stage-report.js --database=/absolute/queue.sqlite --hours=168
reads aggregate timings without opening user recordings or changing the database.
Only the latest 30 samples per stage/class/version are retained; report sample
size and workload when interpreting percentiles.
On the production host, run this from the validated code directory:
TEST_ROOT=/path/to/validated/code bash scripts/production-load-check.sh \
--users=50 --arrivals=100 --duration=120 \
--ffmpeg-concurrency=1 --restart --keep-dataThe wrapper defaults to constraining the test process group to one CPU and 1400 MiB, uses isolated
temporary data, and stops if real queued work arrives, health checks fail, or
memory/disk margins shrink. Use --webm --size=1280x720 for camera-like transcoding,
or --fixture=/absolute/synthetic-spoken.mp4 --real-upstreams for real model
measurements. Never use a user's recording as a test fixture. --keep-data
retains report.json with the synthetic test results.
For the six-CPU profile, add --capacity-profile and set
BENCHMARK_CPU_QUOTA=400% BENCHMARK_MEMORY_MIB=4096 when running the wrapper.
Its high-memory mode stops below 2 GiB available host memory. Benchmark reports
separate local semaphore peaks from sampled actual in-flight model requests.
--resume-data=/tmp/englisheval-load-... --keep-data resumes an interrupted
synthetic member benchmark. It verifies the temporary path and synthetic owners,
preserves existing recordings/checkpoints, and can revalidate a cached scoring
response after a parser fix. Resume reports are marked explicitly; their elapsed
time is the resumed portion, while stage measurements preserve already recorded
resource time. Never use this option with the production database.
Run node scripts/full-browser-check.js --full-duration with Playwright installed
for desktop/mobile recording, three-minute-thirty-second auto-stop, upload, scoring, playback,
share-image export, game, history, feedback and comments. Model responses are
deterministic in this browser test. The other browser scripts cover queue recovery,
guest/DingTalk identity changes and administrator monitoring.
The read-only partner API exposes DingTalk user identity fields and speaking-assessment scores without exposing recorded videos, video paths, filenames, extracted audio, sampled frames, transcripts, raw audio metrics, improved-answer text, or internal model metadata. Configure a separate integration secret as PARTNER_API_KEY; do not reuse the DingTalk app secret or session secret. Generate a strong key with openssl rand -hex 32, and expose the API only over HTTPS or a trusted private network.
Swagger UI is available at /api-docs, and the OpenAPI 3.0 specification is available at /openapi.yaml.
curl \
-H "Authorization: Bearer $PARTNER_API_KEY" \
"http://localhost:3199/api/v1/users?jobNumber=EMPLOYEE_NUMBER"
curl \
-H "Authorization: Bearer $PARTNER_API_KEY" \
"http://localhost:3199/api/v1/users/DINGTALK_USER_ID"
curl \
-H "Authorization: Bearer $PARTNER_API_KEY" \
"http://localhost:3199/api/v1/rubrics"GET /api/v1/users supports exact, case-insensitive filters for openId, userId, jobNumber (or its job_number alias), email, and orgEmail, plus limit (maximum 200) and offset. User responses include both jobNumber and job_number with the same value. Evaluations are sorted newest-first, users are sorted by their latest evaluation, and pagination.total is calculated before pagination. GET /api/v1/users/{userId} returns one exact, case-sensitive DingTalk organization user ID match. GET /api/v1/rubrics returns the active versioned scoring standard, formula, score bands, weights, evidence, and interpretation guidance. Each evaluation response includes rubricId and rubricVersion so consumers can join scores to the correct standard. Existing records created before organization enrichment may have empty identity fields, and standalone speech uploads return an empty questionId because they are not backed by a generated question.
The partner endpoints also expose 28 deterministic roster-backed users, each with one completed mock evaluation, for integration testing. Their jobNumber values come from the roster's 工号 field, their name values come from 姓名, and generated API identifiers start with mock_. Only those two roster fields are retained. The mocks are generated in memory and do not modify recording or question metadata.
Answer evaluation runs after Finish and save according to the audio,
transcript, and evaluation workflow above. The active rubric is:
| Dimension | Weight |
|---|---|
| Pronunciation / intelligibility | 20% |
| Fluency | 10% |
| Grammar | 20% |
| Vocabulary | 15% |
| Coherence / task relevance | 25% |
| Visual delivery | 10% |
Generated questions are appended to questions/metadata.jsonl with the DingTalk OAuth openId and an organization user snapshot containing userId, jobNumber, email, and orgEmail when the organization contact API returns them. These fields are stored both at the record's top level and in its nested user object. Answer attempts are appended to recordings/metadata.jsonl with the same user fields plus the owned questionId. Attempts without a video are retained with hasVideo: false and a skipped evaluation; uploaded answer videos are stored in recordings/ and use hasVideo: true.
The JSONL files and the video/artifact directories live on the server filesystem, so they survive application restarts. Privacy acknowledgements are stored separately in consents/metadata.jsonl by DingTalk openId and policy version. Experience ratings are stored in ratings/metadata.jsonl with only the DingTalk user snapshot, 1–5 score, selected reason tags, outcome, and timestamp; they do not contain the user's question, answer, transcript, recording, or evaluation. The current per-user leaderboard alias and actual-name/alias choice are stored in recordings/leaderboard-identities.jsonl, so changing either updates all leaderboard views without rewriting answer records. In production, these records live in raw persistent directories under /srv/englisheval/shared. If the app will run on multiple instances, migrate these records to a shared database/object store rather than relying on instance-local files.
History and private video endpoints always filter by the effective DingTalk or guest ownership key. Guest videos are excluded from the public standalone gallery and its video/poster endpoints. The recordings directory is not publicly served. Application startup never deletes or migrates persistent records; any future migration must be run explicitly with a verified backup.
Standalone uploads are private by default, including existing records without explicit
sharing consent. A DingTalk user can separately opt in to public video, title, score,
and feedback sharing for an individual upload. Only records with publiclyShared: true
can appear in the gallery or its media endpoints; guest uploads remain private.
Run npm test for API, media, queue, recovery, and security regression checks.
For browser integration checks, install Chromium with npx playwright install chromium
and run npm run test:browser. The browser checks start npm run dev on an isolated
port with temporary data, synthetic identities and local model responses. Use
node scripts/full-browser-check.js --full-duration to also wait for the real
three-minute-thirty-second browser recording cutoff. Additional access and draft-restoration checks
are available through node scripts/access-browser-check.js and
node scripts/guest-browser-check.js.
The production service applies a 0077 umask. Persistent directories are mode
0700, and data files are mode 0600, limiting access to the service account.
The daily recording-maintenance unit comes in two shapes, selected at deploy
time by RECORDING_BACKUP_ENABLED. With the backup enabled (the default) it
briefly stops the application, creates an encrypted backup, skips
live-recording deletion when retention is disabled, and starts the application
again. With the backup disabled it runs recording retention and the orphan
upload sweep only, without stopping anything.
openId remains the ownership field. Browser OAuth stores the application-scoped
openId returned directly by DingTalk. H5 in-app login returns the organization's
unionId and userId instead; the server reuses a matching historical OAuth
openId when one exists, otherwise it derives a deterministic, app-scoped opaque
inapp_... ownership value without exposing the raw organization identifiers.
At sign-in, the server queries DingTalk user details and normalizes DingTalk's
snake_case/camelCase response fields as userId, jobNumber, email, and
orgEmail. This enrichment requires the application's organization-contact
permission; if the lookup is unavailable, authentication continues with empty
organization fields and the server logs a warning.
Browser support for direct MP4 recording varies. The app asks for MP4 first and records WebM when the browser does not support MP4 through MediaRecorder. Recordings are finalized as a single browser blob because timed MP4 chunks are not reliably concatenable across implementations. The server validates and transcodes every accepted upload to MP4 before storage.
Extracted audio and sampled frames are stored under recordings/artifacts/.
Backups use age public-key encryption. Generate the key on a trusted administrator workstation, not on production:
age-keygen -o englisheval-backup-identity.txtKeep that identity file offline and outside this repository. Copy only the
printed public recipient into production's original
/srv/englisheval/shared/.env as BACKUP_AGE_RECIPIENT. Never copy the identity
file or a local .env to production.
Production must have the age command installed when the recording backup is
enabled. Deployment refuses to proceed without age, an explicit non-negative
retention setting, and a native age recipient; with
RECORDING_BACKUP_ENABLED=false only the retention setting is required. The
maintenance timer runs daily around 03:15 and, with the backup enabled, writes
encrypted snapshots to /srv/englisheval/backups. It never creates a
plaintext archive.
To test recovery, restore into a new empty staging directory rather than over the live persistent data:
./scripts/restore-recordings-backup.sh \
recordings-YYYYMMDDTHHMMSSZ.tar.gz.age \
/secure/offline/englisheval-backup-identity.txt \
/tmp/englisheval-restore-testVerify the restored metadata and videos before considering any manual recovery. The restore script deliberately refuses to write into a non-empty directory.
Live recordings are stored as raw files in
/srv/englisheval/shared/recordings. They are protected by service-account
ownership and restrictive filesystem permissions, but they are not encrypted
at rest. The encrypted backup archives remain the recovery mechanism for this
directory.
The included script deploys versioned releases over SSH, installs a systemd
service, and keeps .env, .env.prod, recordings/, questions/, comments/, consents/, ratings/, invitations/, analytics/, and user-figures/ in shared persistent
storage on the target host. Every directory that src/config.js creates must be
in that shared list and in the ReadWritePaths of each systemd unit that loads
it; otherwise ProtectSystem=strict makes the release tree read-only and the
service fails to start. Local-only working state (.workbuddy/, output/,
tmp/, downloads/, backups/, and the offline age/LUKS key material) is
excluded from the release. .env contains settings shared by development and
production, while .env.local and .env.prod override environment-specific
values. Deployment never copies .env.local.
For the initial deployment and data migration:
./deploy.sh --migrate-dataFor later code-only deployments:
./deploy.shThe deployment script (deploy.sh), server configuration (ops/), and
production validation scripts are private local files excluded from Git.
Obtain them from your deployment administrator before using the commands above.
Deployment destinations and paths are configured in those local files.
The production APP_BASE_URL is read from the existing shared .env.prod.
Plain HTTP deployments must set COOKIE_SECURE=false; HTTPS deployments should
set it to true. Override TARGET, REMOTE_ROOT, APP_PORT, or
SERVICE_NAME in the command environment. The migration option deliberately
refuses to overwrite non-empty remote data.
Production also runs englisheval-monitor.service, independently sampling disk,
memory, CPU, queue and service health every 30 seconds. The EnglishEval robot
sends in-app DING to the configured administrator on incidents, severity
escalations and recovery, without periodic reminders. Configure notifications
in shared monitor.env; retain application credentials in the original shared
environment. The admin dashboard includes monitor health and delivery status.
See resource alert operations and
deployment validation.