日语作文批改工具,三个入口共存,用一套 core/ 业务内核:
- 模式 A · 离线 Gradio(
demo/) —— 老牌单机批改:扫描data/input/下整班作文目录,UI 上点几下,结果落到data/records/,导出 Markdown。 - 模式 B · 批改任务中台(
backend/) —— FastAPI + SQLite + asyncio worker, 提供 REST API 和 webhook。前端(作业收集系统)通过POST /v1/grading-tasks提交任务,轮询或回调拿严格 JSON 评分结果。访问http://127.0.0.1:8000/admin还能看到只读运维后台(Gradio)。 - 模式 C · 老师端 Web(
web/) —— Next.js 15 + SQLite + NextAuth 的学生作业 管理系统:班级 / 学生 / 作业批次 / 题目 CRUD + 移动端拍照上传 + 「学生 × 题号」批改 大盘 + 一键批改 / 重批。本身不调 LLM,所有批改请求都通过 HTTP 接 backend 中台。 独立 Docker 镜像,与 backend 在同一台机上 docker-compose 跑,二者用 docker network 互联,容器互联流量不占公网 3 Mbps 带宽(详见docs/PLAN_CN_SINGLE_SCHOOL_2C2G.md§〇)。
三个入口共享业务核心(core/)的同时也明确了职责边界:
| 维度 | demo | backend | web |
|---|---|---|---|
| 入口 | python -m demo.app (Gradio :7860) |
uvicorn (:8000) | next start (:3000) |
| 数据库 | 文件夹 + JSON | data/grading.db |
web/data/web.db |
| LLM 调用 | 直接调 core | 直接调 core | 不调,POST 到 backend |
| 认证 | 无(局域网) | API Key | 邮箱密码(NextAuth) |
| 主要使用者 | 单老师本机 | 第三方系统 | 老师从手机/电脑上批改 |
本地运行时使用 uv 管理虚拟环境。Docker 部署见 下面 模式 B · Docker 一键部署,那条路径不需要本地装 uv / Python。
# 一次性装 uv(如果还没装)
curl -LsSf https://astral.sh/uv/install.sh | sh
# 装依赖(从 uv.lock 锁定版本)
uv sync
cp .env.example .env
# 编辑 .env 填入 DASHSCOPE_API_KEY(推荐)/ GEMINI_API_KEY / ANTHROPIC_API_KEY
# 也可在 UI 的「设置」Tab 填依赖单一来源是 pyproject.toml。requirements.txt 是 uv export 自动生成
的,只用于 Docker 镜像构建;改依赖请改 pyproject.toml 后跑:
uv lock # 重生成 uv.lock
uv export --frozen --no-hashes --no-dev -o requirements.txtuv run python -m demo.app
# 打开 http://127.0.0.1:7860- 按下面的「输入格式」整理好作文图片目录。
- 「设置」Tab 填 Key / 模型。
- 「任务」Tab 扫描 → 批量 OCR → 批量批改(也可一键全跑)。
- 「修改」Tab 逐个学生复核、编辑、重跑。
- 「结果」Tab 导出 Markdown。
# 先在 .env 里加上:
# API_KEYS=akapen:<32+ 字符随机字符串> ← 至少配一个,否则服务拒启动
# WEBHOOK_SECRET=<32+ 字符随机字符串> ← 客户端校验回调签名用
uv run python -m backend.app
# 默认监听 0.0.0.0:8000提交一条任务(JSON + 图片 URL):
curl -X POST http://localhost:8000/v1/grading-tasks \
-H "X-API-Key: <你的 API_KEYS secret>" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "abc-123",
"student_id": "2024001",
"student_name": "王伟",
"image_urls": ["https://your-cdn/page1.jpg", "https://your-cdn/page2.jpg"],
"callback_url": "https://your-frontend/webhooks/grading"
}'
# → 202 { "task_id": "...", "status": "queued", ... }或者 multipart 上传(文件直传):
curl -X POST http://localhost:8000/v1/grading-tasks \
-H "X-API-Key: <secret>" \
-F idempotency_key=abc-123 \
-F student_id=2024001 \
-F student_name=王伟 \
-F images=@page1.jpg \
-F images=@page2.jpg轮询:
curl -H "X-API-Key: <secret>" http://localhost:8000/v1/grading-tasks/<task_id>完整 API 形态见 docs/PLAN_CN_SINGLE_SCHOOL_2C2G.md 的 §三、《API 形态》。
运维后台(task 列表 / 详情 / 重试):http://localhost:8000/admin。
监控指标:http://localhost:8000/v1/metrics(Prometheus 文本格式)。
仓库自带 Dockerfile + docker-compose.yml,一条命令起服务,SQLite / 上传图
片 / 日志全部映射到宿主机 ./data/,停容器重启容器都不丢任务。
# 1) 准备配置
cp .env.example .env
# 编辑 .env,至少填:
# DASHSCOPE_API_KEY=sk-...
# API_KEYS=akapen:<32+ 位随机串> ← 没配会拒启动
# WEBHOOK_SECRET=<32+ 位随机串> ← 客户端校验回调用
# USE_VPC_ENDPOINT=true ← 仅当机器和 DashScope 同 region
# 2) 准备宿主机数据目录(容器以 uid 1000 跑)
mkdir -p data
# 自建 Linux 宿主上若你的 uid 不是 1000:sudo chown -R 1000:1000 data
# 或者 build 时对齐:USER_UID=$(id -u) USER_GID=$(id -g) docker compose build
# 3) 起服务
docker compose up -d --build
# 4) 验活
curl http://127.0.0.1:8000/v1/livez # → 200 OK
curl http://127.0.0.1:8000/v1/readyz # → 200 OK(启动完成后)
docker compose logs -f backend # 看实时日志宿主机持久化目录长这样:
./data/
├── grading.db # SQLite 任务库(含 grading_tasks / schema_versions)
├── grading.db-wal # WAL 模式辅助文件
├── uploads/ # multipart 收到的原图 + 标准化后图片
└── logs/ # app.log(滚动 5MB × 3 备份)
常用维护命令:
docker compose ps # 查容器健康状态
docker compose restart backend # 改 .env 后重启(不重新打镜像)
docker compose down && docker compose up -d # 升级镜像后干净重起(数据保留)
docker compose down -v # ⚠ 不要轻易跑:会删 named volume(虽然这里只用 bind mount)
docker compose exec backend python -m backend.app --help # 进容器排查
# 备份(一次性手动跑)
./scripts/backup.sh
# cron 定时跑 + 上传 OSS 见 §「自动备份」升级中台代码:
git pull
docker compose up -d --build # 仅 backend 服务,重启时间一般 < 10s
# 启动时会自动 reclaim 上次没跑完的任务(详见 §六《startup reclaim》),不丢请求跨架构构建(开发机 macOS arm64 → 服务器 linux/amd64):
docker buildx build --platform linux/amd64 -t akapen-backend:latest --load .
# 然后 docker save | ssh server docker load完整启动(含 backend):
# 1) 准备 backend 配置(同模式 B)
cp .env.example .env
# 编辑:DASHSCOPE_API_KEY / API_KEYS / WEBHOOK_SECRET(必填)
# 2) 准备 web 配置:
cp web/.env.example web/.env
# 编辑 web/.env:AUTH_SECRET / IMAGE_URL_SECRET(各 32+ 字符随机串)
# 关键:WEBHOOK_SECRET 必须与 .env 里那个一模一样
# 关键:AKAPEN_API_KEY 是 .env 里 API_KEYS=akapen:<secret> 中的 <secret>
# 3) 起两个服务(backend + web 一起拉)
docker compose up -d --build
# 4) 创建初始老师账号(学生不登录,老师代为录入)
docker compose exec web node scripts/create-user.cjs \
--email teacher@example.com --password 'mypassword' --name '王老师'
# 注意:tsx 是 dev 依赖、不在 production 镜像;
# Dockerfile 单独把 scripts/create-user.cjs + bcryptjs 拷进了运行时镜像。
# 5) 浏览器打开 http://<host>:3000,邮箱+密码登录典型使用流程:
- 「班级 / 学生」新建班级 → 批量粘贴学号+姓名(每行一名)
- 「作业批次」新建批次 → 添加题目(题干 =
question_context送 LLM) - 手机扫码访问
/batches/<id>/upload→ 选学生 → 逐题拍照 - 桌面端进「批改大盘」(
/grade/<id>) → 多选单元格 → 「一键批改」 - 3 秒一次自动刷新;点单元格弹详情抽屉看分数 / 错误 / 重批
仅启 web(接已有 akapen-backend):
如果你想把 web 部署到一台独立机器上,对接远程 akapen-backend,把
docker-compose.yml 里的 backend service 注掉,单独 docker compose up web -d,
再把 web/.env 里 AKAPEN_BASE_URL 改成公网 / 内网 URL。注意 hairpin 陷阱
(详见 .cursor/plans/homework-frontend_*.plan.md §八),跨机部署务必把
WEB_PUBLIC_BASE_URL 也改成 backend 容器能解析到的地址。
中台默认配置已经为「单校单机 + 公网 3 Mbps」做了带宽优化(详见
docs/PLAN_CN_SINGLE_SCHOOL_2C2G.md §〇):
Settings.enable_single_shot=True—— 一次 vision 调用同时完成 OCR + 评分, 比两步模式省一半带宽Settings.grading_with_image=False—— 两步模式时批改阶段不再发图(仅文本)Semaphore(8) + TokenBucket(2400 kbps)—— 并发不超载、上行不超额
如果服务器是阿里云 ECS 且与 DashScope 同 region,加 USE_VPC_ENDPOINT=true
切到内网 endpoint,完全不占公网带宽:
# .env
USE_VPC_ENDPOINT=true
MAX_CONCURRENCY=20 # 内网了,并发可以拉高
BANDWIDTH_KBPS=200000 # 形同关闭桶默认走 阿里云百炼(DashScope)的 Qwen3.6,原因:
- 国内网络稳,延迟比 Gemini 低很多。
- Qwen 3.5+ 起主线 plus / flash 已经是多模态(图+视频+文本输入),1M 上下文。
- 日语手写实测好,对涂改 / 插字 / 划线推理够用。
- 价格便宜,
qwen3.6-plus比gemini-3.1-pro-preview便宜 ~5×;用 batch 还能再 5 折。
⚠ 阿里云官方明确「
qwen3-vl系列已不作为首选推荐,新项目建议使用qwen3.6/qwen3.5系列」。本项目的预设已对齐这一点;旧qwen3-vl-*留作 兼容选项。
| Provider | 推荐(多模态 + 1M 上下文) | 兼容 / 旧版 | 备注 |
|---|---|---|---|
qwen(默认) |
qwen3.6-plus / qwen3.6-flash / qwen3.5-plus / qwen3.5-flash |
qwen3-vl-plus / qwen3-vl-flash |
阿里云百炼 OpenAI 兼容协议;3.5+ 主线 plus/flash 全多模态 |
gemini |
gemini-3.1-pro-preview / gemini-2.5-pro / gemini-2.5-flash / gemini-2.5-flash-lite |
— | 海外,需科学上网;注意 3.1 真实 API name 带 -preview |
claude |
claude-sonnet-4-5 / claude-opus-4-5 / claude-haiku-4-5 |
— | 仅批改可选 |
百炼上
qwen3.6-plus-2026-04-02(版本快照)、qvq-72b-preview(视觉推理)、qwen-vl-ocr-latest这些不在默认下拉里,但每个 dropdown 都开了allow_custom_value,申请到之后直接粘贴 ID 即可。⚠ 旧 Qwen3-VL 系列没有 max 这一档;UI 上看到的「Max」一般是旧 Qwen2-VL 时代的
qwen-vl-max-latest,跟当前主线qwen3.6-plus/qwen3.5-plus无关。
申请百炼 Key:https://bailian.console.aliyun.com/ → API-KEY 管理 → 创建。
Key 形如 sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx。
每位学生一个子文件夹,文件夹名 学号_姓名,里面放页码命名的图片:
data/input/
├── 2024001_王伟/
│ ├── 1.jpg # 第 1 页
│ ├── 2.jpg # 第 2 页
│ └── 3.jpg # 第 3 页
├── 2024002_李娜/
│ └── 1.jpg
└── 2024003_张敏/
├── 1.jpg
└── 2.png
规则:
- 文件夹名第一个
_之前 = 学号,之后 = 姓名(学号支持字母数字混合,姓名支持中文/日文)。 - 图片支持
.jpg / .jpeg / .png / .webp。 - 同一学生的多页会按文件名里的数字排序,一次性合并送 Gemini 多模态 OCR 转写为一篇连续作文。
- 没有任何正则需要配置 —— 只要遵守命名约定即可。
.
├── core/ # 共享业务核心(模式 A 和 B 都依赖这里,自身不依赖任何一边)
│ ├── config.py # Settings + UI 模型 catalog + 双档画质预设
│ ├── schemas.py # 域模型:GradingResult / SingleShotResult(pydantic)
│ ├── providers/ # LLM provider 抽象层(详见 AGENTS.md)
│ │ ├── base.py # Provider ABC + ProviderError
│ │ ├── qwen.py # QwenProvider(阿里云百炼,OpenAI 兼容协议)
│ │ ├── gemini.py # GeminiProvider
│ │ ├── claude.py # ClaudeProvider
│ │ └── __init__.py # make_provider(name, settings) 工厂
│ ├── ocr.py # OCR 业务逻辑(provider-agnostic)
│ ├── grader.py # 批改业务:grade(markdown)/ grade_json / single_shot
│ ├── imageproc.py # 图片标准化(path / bytes 两个入口)
│ └── logger.py # 日志 + task_id contextvar
├── demo/ # 模式 A · 离线 Gradio Demo(uv run python -m demo.app)
│ ├── app.py # Gradio UI 主入口
│ ├── filenames.py # 学号_姓名/页码.jpg 文件夹扫描
│ └── storage.py # 每位学生一份 record JSON 持久化
├── backend/ # 模式 B · 批改任务中台(FastAPI + SQLite + asyncio worker)
│ ├── app.py # create_app() + lifespan + uvicorn 入口
│ ├── config.py # BackendSettings:core.Settings + API key / 并发 / 带宽
│ ├── db.py # aiosqlite 连接 + WAL + schema 迁移
│ ├── schemas.py # API 边界 pydantic(请求 / 响应 / webhook payload)
│ ├── repo.py # 任务 CRUD + 状态机 + reclaim
│ ├── auth.py # X-API-Key 鉴权 dependency
│ ├── rate_limit.py # slowapi 按 api_key_id 限流
│ ├── routes/ # FastAPI 路由
│ │ ├── tasks.py # POST/GET/list/retry/cancel
│ │ └── health.py # /livez /readyz /healthz /metrics
│ ├── worker.py # asyncio worker:Semaphore(8) + token bucket + grader 调用
│ ├── fetcher.py # URL→bytes 异步拉图(httpx + 退避 + size/mime 校验)
│ ├── webhook.py # HMAC-SHA256 回调(独立队列 + 指数退避 + 死信箱)
│ ├── token_bucket.py # 全局上行带宽令牌桶
│ ├── metrics.py # Prometheus 指标定义
│ ├── admin_ui.py # 只读 Gradio 后台(挂在 /admin)
│ └── prompts/ # ⚠ 模式 B 的 fallback prompt(web 没传 override 时才用)
│ ├── ocr.md
│ ├── grading.md # 批改 prompt(含 {ocr_review_block},不含 {rubric})
│ └── single_shot.md # Single-shot prompt(一次 vision 同时出转写+评分)
├── docs/
│ └── PLAN_CN_SINGLE_SCHOOL_2C2G.md # 中台架构说明 / 容量预算(保留作历史参考)
├── scripts/
│ └── smoke_api.py # 5 路烟测脚本(uv run python -m scripts.smoke_api)
├── data/ # ⚠ gitignore;本地 + Docker 容器挂载点
│ ├── input/ # 学生作文输入目录(模式 A)
│ ├── records/ # 每位学生一份 record JSON(模式 A)
│ ├── exports/ # 导出的 Markdown(模式 A)
│ ├── grading.db # 中台任务库(模式 B;首次启动自动创建)
│ ├── uploads/ # 中台 multipart 上传 + 标准化后图片(模式 B)
│ └── logs/ # 持久化运行日志(两种模式共享)
├── web/ # 模式 C · 老师端 Next.js 应用(next dev / docker)
│ ├── app/ # App Router:(auth)/login + (app)/{classes,batches,grade,settings} + api/*
│ ├── components/ui/ # shadcn/ui(手写):Button / Card / Dialog / Sheet / Table / Checkbox / ...
│ ├── lib/
│ │ ├── auth.ts / auth.config.ts # NextAuth v5 Credentials + Prisma user 表
│ │ ├── db.ts # Prisma client singleton
│ │ ├── akapen.ts # akapen 中台 HTTP 客户端(创建任务 / 重试 / 退避)
│ │ ├── hmac.ts # 图片签名 URL + webhook 验签(HMAC-SHA256)
│ │ ├── uploads.ts # 上传配置 + magic-byte 格式检测
│ │ ├── grade-data.ts # 批改大盘数据装载
│ │ └── actions/{classes,batches,grade}.ts # server actions
│ ├── prisma/
│ │ ├── schema.prisma # User / Class / Student / HomeworkBatch / Question / Submission / GradingTask
│ │ └── migrations/ # prisma migrate 产物,docker entrypoint 跑 migrate deploy
│ ├── data/ # ⚠ gitignore;docker bind-mount 挂回宿主:./web/data → /app/data
│ ├── scripts/create-user.ts # 命令行加老师账号
│ ├── Dockerfile # 三阶段(deps/build/runtime),node:22-alpine + non-root
│ ├── docker-entrypoint.sh # prisma migrate deploy → node server.js
│ └── .env.example # AUTH_SECRET / IMAGE_URL_SECRET / AKAPEN_BASE_URL ...
├── pyproject.toml # python 依赖 single source of truth(uv 用,模式 A/B)
├── uv.lock # uv 锁文件(committed,复现性靠它)
├── requirements.txt # uv export 自动生成,仅供 backend Docker 构建用
├── Dockerfile # python:3.12-slim + non-root + healthcheck(模式 B)
├── docker-compose.yml # 一键部署 backend + web:3000/8000 端口 + ./data + ./web/data 挂载
├── .dockerignore # 排除 venv / git / data / dataset 等
└── AGENTS.md # 给 AI 改这个仓库时看的架构指南
新增一个 LLM provider(例如 OpenAI / 火山引擎 / 本地 vLLM)的步骤详见 AGENTS.md。
- OCR:
qwen3.6-plus(百炼 Qwen3.6 旗舰,多模态 + 1M 上下文,~5–10 秒/页)。 非流式 chat 默认不开思考,对应 Gemini 的thinking_budget=0:让 OCR 保持 「快而傻」—— 原文转写、不主动纠错、看不清的字打[?]。这能避免模型偷偷把 学生写错的地方"修正"成正确的,导致批改时漏扣分。 - 批改:默认同样
qwen3.6-plus+ single-shot——一次 vision 调用同时 返回转写 + 评分,省一半带宽 + 一半延迟。 想省钱可换qwen3.6-flash/qwen3.5-flash(同样多模态,但更便宜更快); 想要更强推理可切到gemini-3.1-pro-preview/claude-sonnet-4-5,或在 Qwen 3.6 上把grading_thinking打开(plus / flash 都支持运行时开思考)。
core/imageproc.py 在每次送 API 前会对每张图:
- 按 EXIF 信息纠正方向;
- 模式统一成 RGB;
- 长边缩放到 1600 px(保留清晰度的前提下给真实大图省带宽);
- 重新编码 JPEG quality=85 + progressive。
学生手机直拍的 4000+ 像素大图能压到几百 KB,单次请求传输/计费都明显降低。
设置 Tab 里有:
- OCR 并发数 / 批改并发数:默认 8 / 6,按你的速率限额调。
- 单次超时 (秒):避开「Processing 半天没反应」的情况(注意 httpx timeout 是 per-IO,不是 wall-clock)。
- 最多重试次数:429/5xx 自动退避重试。
「任务」Tab 底部的实时日志框每 2 秒自动刷新一次内容来自 data/logs/app.log,刷新页面也不会丢,方便定位卡住的请求。
「修改」Tab 里编辑 OCR 文本后,点「📝 用当前转写重新批改」会以你修改后的文本送批改,是处理 OCR 错字 / 疑难笔迹的主要手段。多页学生会以 Gallery 形式展示原图。
容器无状态,所有 stateful 数据都在宿主机的两个目录,删容器不丢数据:
| 路径 | 内容 | 丢了的代价 |
|---|---|---|
data/grading.db (+ -wal/-shm) |
backend 任务队列 / worker 状态(SQLite WAL) | 重启重新跑就行 |
data/uploads/ |
backend 拉图后的标准化缓存 | 能重新拉 |
data/records/ |
每条任务的 prompt + LLM 原始输出 | 复盘用 |
data/exports/ |
模式 A 导出的 Markdown | 重新导即可 |
data/logs/app.log |
滚动日志(5MB × 3 备份) | 排障用 |
data/settings.json |
模式 A(Gradio)UI 里保存的设置 | 重填即可 |
web/data/web.db |
老师账号 / 班级 / 学生 / 题目 / 批改结果 | 🔥 全没 |
web/data/uploads/<batch>/<student>/<q>/... |
学生作业原图 | 🔥 全没 |
挂载契约(在 docker-compose.yml 里):
./data:/app/data(backend)./web/data:/app/data(web)
容器内进程 uid=1000;ECS 上的部署用户也得是 1000,否则容器写不进去
(sudo chown -R 1000:1000 data web/data 一次解决)。
scripts/backup.sh 一键备份核心数据:
- 用宿主
python3stdlib 自带 sqlite3 模块对*.db做在线.backup快照 (绕开容器没装 sqlite3 cli + 避免 WAL 漏写) - tar 打包
data/+web/data/,自动排除*-wal/*-shm/老 logs/临时上传 - 可选
BACKUP_OSS_BUCKET=...自动 ossutil 上传 OSS(异地容灾) - 自动清理 N 天前(默认 3 天)的本机 tar,避免无限堆积
ssh aliyun # 登 ECS
crontab -e在末尾加一行(每天 04:00 跑,凌晨服务器空闲):
0 4 * * * cd ~/docker/akapen && ./scripts/backup.sh >> data/logs/backup.log 2>&1保存退出后立刻生效。验证:
crontab -l # 看刚加的那行有没有
# 第二天看日志
tail -50 ~/docker/akapen/data/logs/backup.log只在本机存备份,盘炸的时候一起完蛋。先在 ECS 上装 ossutil:
curl -L https://gosspublic.alicdn.com/ossutil/v2/2.0.4/ossutil-2.0.4-linux-amd64.zip -o /tmp/o.zip
unzip /tmp/o.zip -d /tmp/
sudo mv /tmp/ossutil-*-linux-amd64/ossutil /usr/local/bin/
# 配 access key(阿里云 RAM 控制台建子账号,只授对应 bucket 的 OSS 写权限)
ossutil config -e oss-cn-shenzhen.aliyuncs.com \
-i <AccessKeyId> -k <AccessKeySecret>
# 验通
echo hello > /tmp/test.txt
ossutil cp /tmp/test.txt oss://your-bucket/akapen/test.txt然后改 cron,把 BACKUP_OSS_BUCKET 加上:
0 4 * * * cd ~/docker/akapen && BACKUP_OSS_BUCKET=oss://your-bucket/akapen ./scripts/backup.sh >> data/logs/backup.log 2>&1OSS 控制台再开个生命周期规则:自动删 30 天前对象,免无限堆积。
⚠ 绝对不要用主账号 access key —— ECS 一旦被攻陷 key 泄露,OSS 全没。 RAM 子账号只授
AliyunOSSFullAccess(或更紧的:只对你那个 bucket 的写权限)。
ssh aliyun && cd ~/docker/akapen
docker compose down
mkdir restore && cd restore
tar -xzf /path/to/akapen_20260501_040000.tgz
mv data/grading.db.bak data/grading.db
mv web/data/web.db.bak web/data/web.db
rm -f data/*.db-wal data/*.db-shm web/data/*.db-wal web/data/*.db-shm
cd ..
rsync -av --delete restore/data/ data/
rsync -av --delete restore/web/data/ web/data/
docker compose up -d详细恢复 / 反模式见 AGENTS.md §十三。