feat: revamp README.md for enhanced project clarity and user engagement. Introduce a new project description, features list, and supported exams section. Update environment variable examples in .env.example for better developer onboarding. Refactor GeneratePage and job state management to improve user experience with job dismissal functionality. Enhance AI question generation with new language rules and regeneration capabilities, ensuring compliance with exam standards.
Browse files- README.md +238 -56
- apps/server/.env.example +18 -4
- apps/web/src/components/routes/GeneratePage.tsx +2 -2
- apps/web/src/hooks/use-generation-jobs.ts +2 -0
- apps/web/src/hooks/use-job-state.ts +58 -25
- apps/web/src/hooks/use-polling-transport.ts +13 -7
- packages/ai/src/__tests__/language-rules.test.ts +66 -0
- packages/ai/src/__tests__/repair.test.ts +41 -8
- packages/ai/src/agentic.ts +197 -158
- packages/ai/src/index.ts +2 -1
- packages/ai/src/language-rules.ts +124 -0
- packages/ai/src/pipeline.ts +46 -10
- packages/ai/src/prompts.ts +10 -4
- packages/ai/src/regenerate-questions.ts +100 -0
- packages/ai/src/repair.ts +88 -31
- packages/api/src/queue.ts +145 -43
README.md
CHANGED
|
@@ -1,101 +1,283 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
## Features
|
| 6 |
|
| 7 |
-
- **
|
| 8 |
-
- **
|
| 9 |
-
- **
|
| 10 |
-
- **
|
| 11 |
-
- **
|
| 12 |
-
- **
|
| 13 |
-
- **
|
| 14 |
-
- **
|
| 15 |
-
- **
|
| 16 |
-
- **
|
| 17 |
-
- **
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
## Getting Started
|
| 21 |
|
| 22 |
-
|
| 23 |
|
| 24 |
```bash
|
|
|
|
|
|
|
| 25 |
bun install
|
| 26 |
```
|
| 27 |
|
| 28 |
-
##
|
| 29 |
-
|
| 30 |
-
This project uses PostgreSQL with Drizzle ORM.
|
| 31 |
-
|
| 32 |
-
1. Make sure you have a PostgreSQL database set up.
|
| 33 |
-
2. Update your `apps/server/.env` file with your PostgreSQL connection details.
|
| 34 |
|
| 35 |
-
|
| 36 |
|
| 37 |
```bash
|
| 38 |
-
bun run db:
|
| 39 |
```
|
| 40 |
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
```bash
|
| 44 |
-
|
|
|
|
| 45 |
```
|
| 46 |
|
| 47 |
-
|
| 48 |
-
The API is running at [http://localhost:3000](http://localhost:3000).
|
| 49 |
|
| 50 |
-
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
-
|
| 56 |
-
|
|
|
|
| 57 |
|
| 58 |
-
#
|
|
|
|
| 59 |
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
```bash
|
| 63 |
-
|
| 64 |
```
|
| 65 |
|
| 66 |
-
|
| 67 |
|
| 68 |
-
```
|
| 69 |
-
|
| 70 |
```
|
| 71 |
|
| 72 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
## Project Structure
|
| 77 |
|
| 78 |
```
|
| 79 |
labas/
|
| 80 |
├── apps/
|
| 81 |
-
│ ├── web/
|
| 82 |
-
│ └── server/
|
| 83 |
├── packages/
|
| 84 |
-
│ ├──
|
| 85 |
-
│ ├── api/
|
| 86 |
-
│ ├── auth/
|
| 87 |
-
│
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
```
|
| 89 |
|
|
|
|
|
|
|
| 90 |
## Available Scripts
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<p align="center">
|
| 2 |
+
<img src="./apps/web/public/logo.png" alt="Labas" width="200" />
|
| 3 |
+
</p>
|
| 4 |
|
| 5 |
+
<h1 align="center">Labas</h1>
|
| 6 |
+
|
| 7 |
+
<p align="center">
|
| 8 |
+
<strong>AI-powered multi-language test practice platform</strong><br />
|
| 9 |
+
Generate authentic exam questions, run mock tests, and track your progress — all in one place.
|
| 10 |
+
</p>
|
| 11 |
+
|
| 12 |
+
<p align="center">
|
| 13 |
+
<a href="#getting-started">Getting Started</a> ·
|
| 14 |
+
<a href="#features">Features</a> ·
|
| 15 |
+
<a href="#supported-exams">Exams</a> ·
|
| 16 |
+
<a href="#project-structure">Structure</a> ·
|
| 17 |
+
<a href="#license">License</a>
|
| 18 |
+
</p>
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## What is Labas?
|
| 23 |
+
|
| 24 |
+
**Labas** is an open-source platform for practicing foreign-language proficiency exams. Users can generate reading (and writing) questions with AI, save them into question packages, take timed mock tests, and review results with explanations in **Bahasa Indonesia** (foreign script such as kanji, hanzi, hangul, or Arabic may appear when relevant).
|
| 25 |
+
|
| 26 |
+
The project is a **Turborepo monorepo** managed with **Bun**. The web app talks to a **Hono + tRPC** backend backed by **PostgreSQL**, **Redis** (job queue), and **Better Auth**.
|
| 27 |
|
| 28 |
## Features
|
| 29 |
|
| 30 |
+
- **AI question generation** — Quick mode (single-pass) and Agentic mode (multi-step pipeline with passage validation and quality checks)
|
| 31 |
+
- **8 exam types** — IELTS, TOEFL, JLPT, HSK, Goethe, TOPIK, TOAFL, DELE
|
| 32 |
+
- **20+ question formats** — Multiple choice, true/false/not given, fill-in-blank, kanji reading, matching, and more
|
| 33 |
+
- **Question bank & packages** — Organize generated questions into shareable or private packages
|
| 34 |
+
- **Mock tests & attempts** — Timed practice sessions with scoring and review
|
| 35 |
+
- **Analytics & leaderboard** — Track performance over time
|
| 36 |
+
- **User-managed AI keys** — Bring your own OpenAI-compatible API key via Settings (never hardcoded in server env)
|
| 37 |
+
- **Credit system** — Token-based usage tracking for AI generation
|
| 38 |
+
- **Admin panel** — User management, moderation, jobs, credits, and featured content
|
| 39 |
+
- **PWA** — Installable progressive web app
|
| 40 |
+
- **Accessible UI** — Shared shadcn/ui components with skip links, proper ARIA, and focus management
|
| 41 |
+
|
| 42 |
+
## Supported Exams
|
| 43 |
+
|
| 44 |
+
| Exam | Language | Notes |
|
| 45 |
+
|------|----------|-------|
|
| 46 |
+
| IELTS Academic | English | Reading & Writing sections |
|
| 47 |
+
| TOEFL iBT | English | Reading & Writing sections |
|
| 48 |
+
| JLPT | Japanese | Kanji annotations supported (`漢字(かんじ)`) |
|
| 49 |
+
| HSK | Chinese | |
|
| 50 |
+
| Goethe-Zertifikat | German | |
|
| 51 |
+
| TOPIK | Korean | Particles, honorifics, speech levels |
|
| 52 |
+
| TOAFL | Arabic | RTL text support |
|
| 53 |
+
| DELE | Spanish | Verb conjugation focus |
|
| 54 |
+
|
| 55 |
+
## Tech Stack
|
| 56 |
+
|
| 57 |
+
| Layer | Technology |
|
| 58 |
+
|-------|------------|
|
| 59 |
+
| Runtime & package manager | [Bun](https://bun.sh) 1.3+ |
|
| 60 |
+
| Frontend | React 19, Vite, [TanStack Router](https://tanstack.com/router) |
|
| 61 |
+
| Backend | [Hono](https://hono.dev), [tRPC](https://trpc.io) |
|
| 62 |
+
| Database | PostgreSQL + [Drizzle ORM](https://orm.drizzle.team) |
|
| 63 |
+
| Queue | Redis + BullMQ (AI generation jobs) |
|
| 64 |
+
| Auth | [Better Auth](https://www.better-auth.com) (email/password) |
|
| 65 |
+
| UI | [shadcn/ui](https://ui.shadcn.com) in `packages/ui` |
|
| 66 |
+
| AI | OpenAI-compatible API (`packages/ai`) |
|
| 67 |
+
| Build | Turborepo + tsdown |
|
| 68 |
+
|
| 69 |
+
## Prerequisites
|
| 70 |
+
|
| 71 |
+
- **[Bun](https://bun.sh)** `1.3.11` or later (see `packageManager` in root `package.json`)
|
| 72 |
+
- **PostgreSQL** 15+
|
| 73 |
+
- **Redis** 7+ (required for background AI generation jobs)
|
| 74 |
+
- **SMTP server** (required for email verification and password reset)
|
| 75 |
+
|
| 76 |
+
> Use `bun` for all install and script commands. Do not use `pnpm`, `npm`, or `yarn` unless a global tool explicitly requires it.
|
| 77 |
|
| 78 |
## Getting Started
|
| 79 |
|
| 80 |
+
### 1. Clone and install
|
| 81 |
|
| 82 |
```bash
|
| 83 |
+
git clone https://github.com/<your-org>/labas.git
|
| 84 |
+
cd labas
|
| 85 |
bun install
|
| 86 |
```
|
| 87 |
|
| 88 |
+
### 2. Start PostgreSQL and Redis
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
+
The repo includes a Docker Compose file for local development:
|
| 91 |
|
| 92 |
```bash
|
| 93 |
+
bun run db:start
|
| 94 |
```
|
| 95 |
|
| 96 |
+
This starts PostgreSQL on port `5432` and Redis on port `6379`. See [`packages/db/docker-compose.yml`](./packages/db/docker-compose.yml) for defaults.
|
| 97 |
+
|
| 98 |
+
Alternatively, point `DATABASE_URL` and `REDIS_URL` at your own instances.
|
| 99 |
+
|
| 100 |
+
### 3. Configure environment
|
| 101 |
+
|
| 102 |
+
Copy the example env files:
|
| 103 |
|
| 104 |
```bash
|
| 105 |
+
cp apps/server/.env.example apps/server/.env
|
| 106 |
+
cp apps/web/.env.example apps/web/.env
|
| 107 |
```
|
| 108 |
|
| 109 |
+
Edit **`apps/server/.env`**:
|
|
|
|
| 110 |
|
| 111 |
+
```env
|
| 112 |
+
# PostgreSQL (matches docker-compose defaults)
|
| 113 |
+
DATABASE_URL=postgresql://postgres:password@localhost:5432/labas
|
| 114 |
|
| 115 |
+
# Redis (defaults to redis://localhost:6379 if omitted)
|
| 116 |
+
REDIS_URL=redis://localhost:6379
|
| 117 |
|
| 118 |
+
# Auth — generate random strings ≥ 32 characters
|
| 119 |
+
BETTER_AUTH_SECRET=your-random-secret-at-least-32-chars
|
| 120 |
+
BETTER_AUTH_URL=http://localhost:3000
|
| 121 |
+
CORS_ORIGIN=http://localhost:3001
|
| 122 |
|
| 123 |
+
# Encrypts user AI API keys at rest — ≥ 32 characters
|
| 124 |
+
API_KEY_ENCRYPTION_KEY=your-encryption-key-at-least-32-chars
|
| 125 |
|
| 126 |
+
# SMTP (required for sign-up verification & password reset)
|
| 127 |
+
SMTP_HOST=smtp.example.com
|
| 128 |
+
SMTP_PORT=587
|
| 129 |
+
SMTP_USER=your-smtp-user
|
| 130 |
+
SMTP_PASS=your-smtp-password
|
| 131 |
+
SMTP_FROM=noreply@example.com
|
| 132 |
+
|
| 133 |
+
# Optional: platform-wide AI fallback (users normally set keys in Settings UI)
|
| 134 |
+
# PLATFORM_AI_API_KEY=
|
| 135 |
+
# PLATFORM_AI_BASE_URL=
|
| 136 |
+
# PLATFORM_AI_MODEL=
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
Edit **`apps/web/.env`**:
|
| 140 |
+
|
| 141 |
+
```env
|
| 142 |
+
VITE_SERVER_URL=http://localhost:3000
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
### 4. Push database schema
|
| 146 |
|
| 147 |
```bash
|
| 148 |
+
bun run db:push
|
| 149 |
```
|
| 150 |
|
| 151 |
+
Optionally seed reference data (exam types, sections):
|
| 152 |
|
| 153 |
+
```bash
|
| 154 |
+
cd packages/db && bun run db:seed
|
| 155 |
```
|
| 156 |
|
| 157 |
+
### 5. Run the dev servers
|
| 158 |
+
|
| 159 |
+
```bash
|
| 160 |
+
bun run dev
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
| Service | URL |
|
| 164 |
+
|---------|-----|
|
| 165 |
+
| Web app | [http://localhost:3001](http://localhost:3001) |
|
| 166 |
+
| API server | [http://localhost:3000](http://localhost:3000) |
|
| 167 |
+
|
| 168 |
+
Sign up, verify your email, then open **Settings** to add your OpenAI-compatible API key before generating questions.
|
| 169 |
+
|
| 170 |
+
## AI Configuration
|
| 171 |
+
|
| 172 |
+
AI provider keys are **not** stored in server `.env` by default. Each user configures their own key in the **Settings** page. The server encrypts keys with `API_KEY_ENCRYPTION_KEY`.
|
| 173 |
+
|
| 174 |
+
Generation supports any **OpenAI-compatible** endpoint (OpenAI, OpenRouter, local LM Studio, etc.).
|
| 175 |
|
| 176 |
+
Two generation modes are available:
|
| 177 |
+
|
| 178 |
+
| Mode | Description |
|
| 179 |
+
|------|-------------|
|
| 180 |
+
| **Quick** | Single LLM call with schema validation and repair |
|
| 181 |
+
| **Agentic** | Multi-step pipeline: plan → shared passage → parallel question shards → validation |
|
| 182 |
+
|
| 183 |
+
AI logic lives in [`packages/ai/`](./packages/ai/).
|
| 184 |
|
| 185 |
## Project Structure
|
| 186 |
|
| 187 |
```
|
| 188 |
labas/
|
| 189 |
├── apps/
|
| 190 |
+
│ ├── web/ # Frontend (React + TanStack Router) — port 3001
|
| 191 |
+
│ └── server/ # Backend entry (Hono + tRPC) — port 3000
|
| 192 |
├── packages/
|
| 193 |
+
│ ├── ai/ # Prompts, schemas, quick & agentic pipelines
|
| 194 |
+
│ ├── api/ # tRPC routers, queue workers, business logic
|
| 195 |
+
│ ├── auth/ # Better Auth configuration
|
| 196 |
+
│ ├── config/ # Shared TypeScript configs
|
| 197 |
+
│ ├── db/ # Drizzle schema, migrations, docker-compose
|
| 198 |
+
│ ├── env/ # Environment validation (Zod)
|
| 199 |
+
│ └── ui/ # Shared shadcn/ui components & design tokens
|
| 200 |
+
├── AGENTS.md # Contributor guide for AI agents & developers
|
| 201 |
+
└── turbo.json
|
| 202 |
```
|
| 203 |
|
| 204 |
+
Internal imports use the `@labas/*` workspace namespace. Apps should not import from each other directly — share code through `packages/*`.
|
| 205 |
+
|
| 206 |
## Available Scripts
|
| 207 |
|
| 208 |
+
Run from the **repository root**:
|
| 209 |
+
|
| 210 |
+
| Command | Description |
|
| 211 |
+
|---------|-------------|
|
| 212 |
+
| `bun run dev` | Start web + server in development |
|
| 213 |
+
| `bun run dev:web` | Start web app only |
|
| 214 |
+
| `bun run dev:server` | Start server only |
|
| 215 |
+
| `bun run build` | Build all packages |
|
| 216 |
+
| `bun run check-types` | TypeScript check across the monorepo |
|
| 217 |
+
| `bun test` | Run all tests via Turborepo |
|
| 218 |
+
| `bun run db:push` | Push Drizzle schema to PostgreSQL |
|
| 219 |
+
| `bun run db:generate` | Generate migration files |
|
| 220 |
+
| `bun run db:migrate` | Run migrations |
|
| 221 |
+
| `bun run db:studio` | Open Drizzle Studio |
|
| 222 |
+
| `bun run db:start` | Start PostgreSQL + Redis (Docker Compose) |
|
| 223 |
+
| `bun run db:stop` | Stop Docker Compose services |
|
| 224 |
+
|
| 225 |
+
## Development
|
| 226 |
+
|
| 227 |
+
### Type checking
|
| 228 |
+
|
| 229 |
+
```bash
|
| 230 |
+
bun run check-types
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
### Testing
|
| 234 |
+
|
| 235 |
+
```bash
|
| 236 |
+
bun test
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
Tests use **Bun's built-in test runner**. Unit tests live in `src/__tests__/` within each package. Integration tests in `packages/api` use PGlite (in-memory PostgreSQL).
|
| 240 |
+
|
| 241 |
+
### Adding shadcn/ui components
|
| 242 |
+
|
| 243 |
+
From the project root:
|
| 244 |
+
|
| 245 |
+
```bash
|
| 246 |
+
npx shadcn@latest add accordion dialog -c packages/ui
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
Import shared components:
|
| 250 |
+
|
| 251 |
+
```tsx
|
| 252 |
+
import { Button } from "@labas/ui/components/button";
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
Design tokens and global styles: [`packages/ui/src/styles/globals.css`](./packages/ui/src/styles/globals.css).
|
| 256 |
+
|
| 257 |
+
### PWA assets
|
| 258 |
+
|
| 259 |
+
```bash
|
| 260 |
+
cd apps/web && bun run generate-pwa-assets
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
## Contributing
|
| 264 |
+
|
| 265 |
+
Contributions are welcome! Before opening a PR:
|
| 266 |
+
|
| 267 |
+
1. Read [`AGENTS.md`](./AGENTS.md) for architecture, conventions, and common pitfalls.
|
| 268 |
+
2. Run `bun run check-types` and `bun test`.
|
| 269 |
+
3. Keep changes focused and match existing code style.
|
| 270 |
+
|
| 271 |
+
For AI-related changes, see the **AI Generation Context** section in `AGENTS.md` — adding a new question format touches schemas, prompts, attempt normalization, and frontend constants.
|
| 272 |
+
|
| 273 |
+
## License
|
| 274 |
+
|
| 275 |
+
This project is licensed under the **[GNU Affero General Public License v3.0 (AGPL-3.0)](./LICENSE)**.
|
| 276 |
+
|
| 277 |
+
If you run a modified version as a network service, you must make the corresponding source code available to users of that service.
|
| 278 |
+
|
| 279 |
+
---
|
| 280 |
+
|
| 281 |
+
<p align="center">
|
| 282 |
+
Built with Bun, React, Hono, and tRPC.
|
| 283 |
+
</p>
|
apps/server/.env.example
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
BETTER_AUTH_SECRET=
|
| 2 |
BETTER_AUTH_URL=http://localhost:3000
|
| 3 |
CORS_ORIGIN=http://localhost:3001
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
API_KEY_ENCRYPTION_KEY=
|
| 8 |
|
|
|
|
| 9 |
SMTP_HOST=
|
| 10 |
-
SMTP_PORT=
|
| 11 |
SMTP_USER=
|
| 12 |
SMTP_PASS=
|
| 13 |
-
SMTP_FROM=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PostgreSQL connection string
|
| 2 |
+
# Local dev (docker-compose): postgresql://postgres:password@localhost:5432/labas
|
| 3 |
+
DATABASE_URL=
|
| 4 |
+
|
| 5 |
+
# Redis — required for AI generation job queue
|
| 6 |
+
# Local dev (docker-compose): redis://localhost:6379
|
| 7 |
+
REDIS_URL=redis://localhost:6379
|
| 8 |
+
|
| 9 |
+
# Auth — random strings, minimum 32 characters
|
| 10 |
BETTER_AUTH_SECRET=
|
| 11 |
BETTER_AUTH_URL=http://localhost:3000
|
| 12 |
CORS_ORIGIN=http://localhost:3001
|
| 13 |
|
| 14 |
+
# Encrypts user AI API keys at rest — minimum 32 characters
|
|
|
|
| 15 |
API_KEY_ENCRYPTION_KEY=
|
| 16 |
|
| 17 |
+
# SMTP — required for email verification and password reset
|
| 18 |
SMTP_HOST=
|
| 19 |
+
SMTP_PORT=587
|
| 20 |
SMTP_USER=
|
| 21 |
SMTP_PASS=
|
| 22 |
+
SMTP_FROM=
|
| 23 |
+
|
| 24 |
+
# Optional: platform-wide AI fallback (users normally configure keys in Settings UI)
|
| 25 |
+
# PLATFORM_AI_API_KEY=
|
| 26 |
+
# PLATFORM_AI_BASE_URL=
|
| 27 |
+
# PLATFORM_AI_MODEL=
|
apps/web/src/components/routes/GeneratePage.tsx
CHANGED
|
@@ -60,8 +60,8 @@ export function RouteComponent() {
|
|
| 60 |
isGenerating,
|
| 61 |
error,
|
| 62 |
addJob,
|
| 63 |
-
removeJob,
|
| 64 |
resetAll,
|
|
|
|
| 65 |
setError,
|
| 66 |
} = useGenerationJobs();
|
| 67 |
|
|
@@ -708,7 +708,7 @@ export function RouteComponent() {
|
|
| 708 |
<span
|
| 709 |
onClick={(e) => {
|
| 710 |
e.stopPropagation();
|
| 711 |
-
|
| 712 |
}}
|
| 713 |
role="button"
|
| 714 |
aria-label={`Tutup tab ${res.mode}`}
|
|
|
|
| 60 |
isGenerating,
|
| 61 |
error,
|
| 62 |
addJob,
|
|
|
|
| 63 |
resetAll,
|
| 64 |
+
dismissResult,
|
| 65 |
setError,
|
| 66 |
} = useGenerationJobs();
|
| 67 |
|
|
|
|
| 708 |
<span
|
| 709 |
onClick={(e) => {
|
| 710 |
e.stopPropagation();
|
| 711 |
+
dismissResult(res.jobId);
|
| 712 |
}}
|
| 713 |
role="button"
|
| 714 |
aria-label={`Tutup tab ${res.mode}`}
|
apps/web/src/hooks/use-generation-jobs.ts
CHANGED
|
@@ -26,6 +26,7 @@ export function useGenerationJobs() {
|
|
| 26 |
trackStatus,
|
| 27 |
setResult,
|
| 28 |
clearResult,
|
|
|
|
| 29 |
} = useJobState();
|
| 30 |
|
| 31 |
/* Fallback: discover active jobs from myJobs endpoint */
|
|
@@ -157,6 +158,7 @@ export function useGenerationJobs() {
|
|
| 157 |
addJob,
|
| 158 |
removeJob: wrappedRemoveJob,
|
| 159 |
resetAll,
|
|
|
|
| 160 |
setError,
|
| 161 |
myJobsQuery,
|
| 162 |
};
|
|
|
|
| 26 |
trackStatus,
|
| 27 |
setResult,
|
| 28 |
clearResult,
|
| 29 |
+
dismissResult,
|
| 30 |
} = useJobState();
|
| 31 |
|
| 32 |
/* Fallback: discover active jobs from myJobs endpoint */
|
|
|
|
| 158 |
addJob,
|
| 159 |
removeJob: wrappedRemoveJob,
|
| 160 |
resetAll,
|
| 161 |
+
dismissResult,
|
| 162 |
setError,
|
| 163 |
myJobsQuery,
|
| 164 |
};
|
apps/web/src/hooks/use-job-state.ts
CHANGED
|
@@ -56,6 +56,22 @@ function broadcastJobs(ids: string[]) {
|
|
| 56 |
);
|
| 57 |
}
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
/* ── State shape ── */
|
| 60 |
|
| 61 |
interface State {
|
|
@@ -72,6 +88,7 @@ type Action =
|
|
| 72 |
| { type: "removeJob"; id: string }
|
| 73 |
| { type: "reset" }
|
| 74 |
| { type: "syncIds"; ids: string[] }
|
|
|
|
| 75 |
| { type: "trackStatus"; jobId: string; status: string }
|
| 76 |
| { type: "setResult"; result: CompletedResult }
|
| 77 |
| { type: "clearResult"; jobId: string }
|
|
@@ -99,18 +116,15 @@ function reducer(state: State, action: Action): State {
|
|
| 99 |
|
| 100 |
case "removeJob": {
|
| 101 |
const nextIds = state.jobIds.filter((j) => j !== action.id);
|
| 102 |
-
const nextResults = state.completedResults.filter((r) => r.jobId !== action.id);
|
| 103 |
const nextRemoved = new Set(state.removedIds).add(action.id);
|
| 104 |
|
| 105 |
writeStoredIds(nextIds);
|
| 106 |
-
writeStoredResults(nextResults);
|
| 107 |
const cleared = readClearedJobIds();
|
| 108 |
if (!cleared.includes(action.id)) writeClearedJobIds([...cleared, action.id]);
|
| 109 |
|
| 110 |
return {
|
| 111 |
...state,
|
| 112 |
jobIds: nextIds,
|
| 113 |
-
completedResults: nextResults,
|
| 114 |
removedIds: nextRemoved,
|
| 115 |
};
|
| 116 |
}
|
|
@@ -125,6 +139,7 @@ function reducer(state: State, action: Action): State {
|
|
| 125 |
const nextRemoved = new Set(state.removedIds);
|
| 126 |
for (const id of current) nextRemoved.add(id);
|
| 127 |
|
|
|
|
| 128 |
return {
|
| 129 |
...state,
|
| 130 |
jobIds: [],
|
|
@@ -139,6 +154,10 @@ function reducer(state: State, action: Action): State {
|
|
| 139 |
return { ...state, jobIds: action.ids };
|
| 140 |
}
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
case "trackStatus": {
|
| 143 |
return {
|
| 144 |
...state,
|
|
@@ -148,18 +167,25 @@ function reducer(state: State, action: Action): State {
|
|
| 148 |
|
| 149 |
case "setResult": {
|
| 150 |
const idx = state.completedResults.findIndex((r) => r.jobId === action.result.jobId);
|
|
|
|
| 151 |
if (idx >= 0) {
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
| 155 |
}
|
| 156 |
-
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
|
| 159 |
case "clearResult": {
|
|
|
|
|
|
|
|
|
|
| 160 |
return {
|
| 161 |
...state,
|
| 162 |
-
completedResults:
|
| 163 |
};
|
| 164 |
}
|
| 165 |
|
|
@@ -175,31 +201,22 @@ function reducer(state: State, action: Action): State {
|
|
| 175 |
/* ── Hook ── */
|
| 176 |
|
| 177 |
export function useJobState() {
|
| 178 |
-
const [state, dispatch] = useReducer(reducer,
|
| 179 |
-
|
| 180 |
-
completedResults: [],
|
| 181 |
-
error: null,
|
| 182 |
-
processedStates: {},
|
| 183 |
-
removedIds: new Set<string>(),
|
| 184 |
-
});
|
| 185 |
|
| 186 |
const stateRef = useRef(state);
|
| 187 |
stateRef.current = state;
|
| 188 |
|
| 189 |
-
/*
|
| 190 |
-
useEffect(() => {
|
| 191 |
-
const saved = readStoredIds();
|
| 192 |
-
const savedResults = readStoredResults();
|
| 193 |
-
const clearedIds = readClearedJobIds();
|
| 194 |
-
dispatch({ type: "init", jobIds: saved, results: savedResults, clearedIds });
|
| 195 |
-
}, []);
|
| 196 |
-
|
| 197 |
-
/* Persist completedResults */
|
| 198 |
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
writeStoredResults(state.completedResults);
|
| 200 |
}, [state.completedResults]);
|
| 201 |
|
| 202 |
-
/* Sync across hook instances
|
| 203 |
useEffect(() => {
|
| 204 |
const handler = (e: Event) => {
|
| 205 |
const detail = (e as CustomEvent).detail as { ids: string[] };
|
|
@@ -209,6 +226,17 @@ export function useJobState() {
|
|
| 209 |
return () => window.removeEventListener("labas:jobs-change", handler);
|
| 210 |
}, []);
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
const addJob = useCallback((id: string) => dispatch({ type: "addJob", id }), []);
|
| 213 |
const removeJob = useCallback((id: string) => dispatch({ type: "removeJob", id }), []);
|
| 214 |
const resetAll = useCallback(() => dispatch({ type: "reset" }), []);
|
|
@@ -225,6 +253,10 @@ export function useJobState() {
|
|
| 225 |
(jobId: string) => dispatch({ type: "clearResult", jobId }),
|
| 226 |
[],
|
| 227 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
return {
|
| 230 |
jobIds: state.jobIds,
|
|
@@ -239,5 +271,6 @@ export function useJobState() {
|
|
| 239 |
trackStatus,
|
| 240 |
setResult,
|
| 241 |
clearResult,
|
|
|
|
| 242 |
};
|
| 243 |
}
|
|
|
|
| 56 |
);
|
| 57 |
}
|
| 58 |
|
| 59 |
+
function broadcastResults(results: CompletedResult[]) {
|
| 60 |
+
window.dispatchEvent(
|
| 61 |
+
new CustomEvent("labas:results-change", { detail: { results } }),
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function createInitialState(): State {
|
| 66 |
+
return {
|
| 67 |
+
jobIds: readStoredIds(),
|
| 68 |
+
completedResults: readStoredResults(),
|
| 69 |
+
error: null,
|
| 70 |
+
processedStates: {},
|
| 71 |
+
removedIds: new Set(readClearedJobIds()),
|
| 72 |
+
};
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
/* ── State shape ── */
|
| 76 |
|
| 77 |
interface State {
|
|
|
|
| 88 |
| { type: "removeJob"; id: string }
|
| 89 |
| { type: "reset" }
|
| 90 |
| { type: "syncIds"; ids: string[] }
|
| 91 |
+
| { type: "syncResults"; results: CompletedResult[] }
|
| 92 |
| { type: "trackStatus"; jobId: string; status: string }
|
| 93 |
| { type: "setResult"; result: CompletedResult }
|
| 94 |
| { type: "clearResult"; jobId: string }
|
|
|
|
| 116 |
|
| 117 |
case "removeJob": {
|
| 118 |
const nextIds = state.jobIds.filter((j) => j !== action.id);
|
|
|
|
| 119 |
const nextRemoved = new Set(state.removedIds).add(action.id);
|
| 120 |
|
| 121 |
writeStoredIds(nextIds);
|
|
|
|
| 122 |
const cleared = readClearedJobIds();
|
| 123 |
if (!cleared.includes(action.id)) writeClearedJobIds([...cleared, action.id]);
|
| 124 |
|
| 125 |
return {
|
| 126 |
...state,
|
| 127 |
jobIds: nextIds,
|
|
|
|
| 128 |
removedIds: nextRemoved,
|
| 129 |
};
|
| 130 |
}
|
|
|
|
| 139 |
const nextRemoved = new Set(state.removedIds);
|
| 140 |
for (const id of current) nextRemoved.add(id);
|
| 141 |
|
| 142 |
+
broadcastResults([]);
|
| 143 |
return {
|
| 144 |
...state,
|
| 145 |
jobIds: [],
|
|
|
|
| 154 |
return { ...state, jobIds: action.ids };
|
| 155 |
}
|
| 156 |
|
| 157 |
+
case "syncResults": {
|
| 158 |
+
return { ...state, completedResults: action.results };
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
case "trackStatus": {
|
| 162 |
return {
|
| 163 |
...state,
|
|
|
|
| 167 |
|
| 168 |
case "setResult": {
|
| 169 |
const idx = state.completedResults.findIndex((r) => r.jobId === action.result.jobId);
|
| 170 |
+
let nextResults: CompletedResult[];
|
| 171 |
if (idx >= 0) {
|
| 172 |
+
nextResults = [...state.completedResults];
|
| 173 |
+
nextResults[idx] = action.result;
|
| 174 |
+
} else {
|
| 175 |
+
nextResults = [...state.completedResults, action.result];
|
| 176 |
}
|
| 177 |
+
writeStoredResults(nextResults);
|
| 178 |
+
broadcastResults(nextResults);
|
| 179 |
+
return { ...state, completedResults: nextResults };
|
| 180 |
}
|
| 181 |
|
| 182 |
case "clearResult": {
|
| 183 |
+
const nextResults = state.completedResults.filter((r) => r.jobId !== action.jobId);
|
| 184 |
+
writeStoredResults(nextResults);
|
| 185 |
+
broadcastResults(nextResults);
|
| 186 |
return {
|
| 187 |
...state,
|
| 188 |
+
completedResults: nextResults,
|
| 189 |
};
|
| 190 |
}
|
| 191 |
|
|
|
|
| 201 |
/* ── Hook ── */
|
| 202 |
|
| 203 |
export function useJobState() {
|
| 204 |
+
const [state, dispatch] = useReducer(reducer, undefined, createInitialState);
|
| 205 |
+
const skipPersistRef = useRef(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
const stateRef = useRef(state);
|
| 208 |
stateRef.current = state;
|
| 209 |
|
| 210 |
+
/* Persist completedResults — skip first run (lazy init already loaded from storage) */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
useEffect(() => {
|
| 212 |
+
if (skipPersistRef.current) {
|
| 213 |
+
skipPersistRef.current = false;
|
| 214 |
+
return;
|
| 215 |
+
}
|
| 216 |
writeStoredResults(state.completedResults);
|
| 217 |
}, [state.completedResults]);
|
| 218 |
|
| 219 |
+
/* Sync job IDs across hook instances */
|
| 220 |
useEffect(() => {
|
| 221 |
const handler = (e: Event) => {
|
| 222 |
const detail = (e as CustomEvent).detail as { ids: string[] };
|
|
|
|
| 226 |
return () => window.removeEventListener("labas:jobs-change", handler);
|
| 227 |
}, []);
|
| 228 |
|
| 229 |
+
/* Sync completed results across hook instances (GeneratePage + GlobalProgress) */
|
| 230 |
+
useEffect(() => {
|
| 231 |
+
const handler = (e: Event) => {
|
| 232 |
+
const detail = (e as CustomEvent).detail as { results: CompletedResult[] };
|
| 233 |
+
skipPersistRef.current = true;
|
| 234 |
+
dispatch({ type: "syncResults", results: detail.results });
|
| 235 |
+
};
|
| 236 |
+
window.addEventListener("labas:results-change", handler);
|
| 237 |
+
return () => window.removeEventListener("labas:results-change", handler);
|
| 238 |
+
}, []);
|
| 239 |
+
|
| 240 |
const addJob = useCallback((id: string) => dispatch({ type: "addJob", id }), []);
|
| 241 |
const removeJob = useCallback((id: string) => dispatch({ type: "removeJob", id }), []);
|
| 242 |
const resetAll = useCallback(() => dispatch({ type: "reset" }), []);
|
|
|
|
| 253 |
(jobId: string) => dispatch({ type: "clearResult", jobId }),
|
| 254 |
[],
|
| 255 |
);
|
| 256 |
+
const dismissResult = useCallback((jobId: string) => {
|
| 257 |
+
dispatch({ type: "clearResult", jobId });
|
| 258 |
+
dispatch({ type: "removeJob", id: jobId });
|
| 259 |
+
}, []);
|
| 260 |
|
| 261 |
return {
|
| 262 |
jobIds: state.jobIds,
|
|
|
|
| 271 |
trackStatus,
|
| 272 |
setResult,
|
| 273 |
clearResult,
|
| 274 |
+
dismissResult,
|
| 275 |
};
|
| 276 |
}
|
apps/web/src/hooks/use-polling-transport.ts
CHANGED
|
@@ -11,9 +11,15 @@ function jobStatusRefetchInterval(query: unknown): number | false {
|
|
| 11 |
return 1000;
|
| 12 |
}
|
| 13 |
|
| 14 |
-
/** Track
|
| 15 |
-
function
|
| 16 |
-
return jobs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
/** Polling-based implementation of JobTransport.
|
|
@@ -30,7 +36,7 @@ export function usePollingTransport({
|
|
| 30 |
const onUpdateRef = useRef<(event: JobTransportEvent) => void>(undefined);
|
| 31 |
const onErrorRef = useRef<(error: Error) => void>(undefined);
|
| 32 |
|
| 33 |
-
const
|
| 34 |
const prevStatusesRef = useRef<Record<string, string>>({});
|
| 35 |
|
| 36 |
const jobQueries = useQueries({
|
|
@@ -46,9 +52,9 @@ export function usePollingTransport({
|
|
| 46 |
.filter((q) => q.data && !isTerminal((q.data as { status: string }).status))
|
| 47 |
.map((q) => q.data as unknown as ActiveJob);
|
| 48 |
|
| 49 |
-
const nextIds =
|
| 50 |
-
if (nextIds !==
|
| 51 |
-
|
| 52 |
setActiveJobs(nextActive);
|
| 53 |
}
|
| 54 |
|
|
|
|
| 11 |
return 1000;
|
| 12 |
}
|
| 13 |
|
| 14 |
+
/** Track job snapshots so progress/message/log updates propagate without ID churn. */
|
| 15 |
+
function serializeJobSnapshot(jobs: ActiveJob[]): string {
|
| 16 |
+
return jobs
|
| 17 |
+
.map((j) => {
|
| 18 |
+
const logsLen = Array.isArray(j.logs) ? j.logs.length : 0;
|
| 19 |
+
return [j.id, j.status, j.progress ?? 0, j.progressMessage ?? "", logsLen].join(":");
|
| 20 |
+
})
|
| 21 |
+
.sort()
|
| 22 |
+
.join("|");
|
| 23 |
}
|
| 24 |
|
| 25 |
/** Polling-based implementation of JobTransport.
|
|
|
|
| 36 |
const onUpdateRef = useRef<(event: JobTransportEvent) => void>(undefined);
|
| 37 |
const onErrorRef = useRef<(error: Error) => void>(undefined);
|
| 38 |
|
| 39 |
+
const prevSnapshotRef = useRef("");
|
| 40 |
const prevStatusesRef = useRef<Record<string, string>>({});
|
| 41 |
|
| 42 |
const jobQueries = useQueries({
|
|
|
|
| 52 |
.filter((q) => q.data && !isTerminal((q.data as { status: string }).status))
|
| 53 |
.map((q) => q.data as unknown as ActiveJob);
|
| 54 |
|
| 55 |
+
const nextIds = serializeJobSnapshot(nextActive);
|
| 56 |
+
if (nextIds !== prevSnapshotRef.current) {
|
| 57 |
+
prevSnapshotRef.current = nextIds;
|
| 58 |
setActiveJobs(nextActive);
|
| 59 |
}
|
| 60 |
|
packages/ai/src/__tests__/language-rules.test.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "bun:test";
|
| 2 |
+
import {
|
| 3 |
+
getQuestionLanguageErrors,
|
| 4 |
+
isLikelyIndonesianContent,
|
| 5 |
+
getTargetLanguage,
|
| 6 |
+
getExplanationLanguageErrors,
|
| 7 |
+
} from "../language-rules";
|
| 8 |
+
|
| 9 |
+
describe("language-rules", () => {
|
| 10 |
+
it("detects Indonesian question text for JLPT", () => {
|
| 11 |
+
const errors = getQuestionLanguageErrors(
|
| 12 |
+
{
|
| 13 |
+
questionText: "Menurut paragraf tersebut, apakah pernyataan berikut benar?",
|
| 14 |
+
options: [
|
| 15 |
+
{ key: "A", text: "はい、正しいです" },
|
| 16 |
+
{ key: "B", text: "いいえ、違います" },
|
| 17 |
+
],
|
| 18 |
+
},
|
| 19 |
+
"JLPT",
|
| 20 |
+
);
|
| 21 |
+
expect(errors.some((e) => e.includes("questionText"))).toBe(true);
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
it("accepts Japanese question text for JLPT", () => {
|
| 25 |
+
const errors = getQuestionLanguageErrors(
|
| 26 |
+
{
|
| 27 |
+
questionText: "この文章によると、筆者の主張として正しいものはどれか。",
|
| 28 |
+
options: [
|
| 29 |
+
{ key: "A", text: "環境問題は深刻化している" },
|
| 30 |
+
{ key: "B", text: "経済成長だけが重要だ" },
|
| 31 |
+
],
|
| 32 |
+
},
|
| 33 |
+
"JLPT",
|
| 34 |
+
);
|
| 35 |
+
expect(errors).toHaveLength(0);
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it("flags Indonesian options", () => {
|
| 39 |
+
expect(isLikelyIndonesianContent("Pilihan yang benar menurut teks")).toBe(true);
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
it("returns Japanese for JLPT", () => {
|
| 43 |
+
expect(getTargetLanguage("JLPT")).toBe("Japanese");
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
it("accepts mixed Indonesian + Japanese in explanation", () => {
|
| 47 |
+
const errors = getExplanationLanguageErrors(
|
| 48 |
+
"Jawaban A benar karena kata 環境(かんきょう) di paragraf kedua merujuk pada lingkungan hidup.",
|
| 49 |
+
);
|
| 50 |
+
expect(errors).toHaveLength(0);
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it("rejects explanation with only Japanese and no Indonesian prose", () => {
|
| 54 |
+
const errors = getExplanationLanguageErrors(
|
| 55 |
+
"この文章によると、正しい答えはAです。",
|
| 56 |
+
);
|
| 57 |
+
expect(errors.length).toBeGreaterThan(0);
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
it("rejects English-only explanation", () => {
|
| 61 |
+
const errors = getExplanationLanguageErrors(
|
| 62 |
+
"The correct answer is A because the passage clearly states the main idea.",
|
| 63 |
+
);
|
| 64 |
+
expect(errors.some((e) => e.includes("Bahasa Indonesia"))).toBe(true);
|
| 65 |
+
});
|
| 66 |
+
});
|
packages/ai/src/__tests__/repair.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
| 3 |
repairQuestion,
|
| 4 |
repairAndParseQuestions,
|
| 5 |
tryParseQuestion,
|
|
|
|
| 6 |
} from "../repair";
|
| 7 |
|
| 8 |
const fullPassage = "A".repeat(200);
|
|
@@ -18,7 +19,7 @@ const baseRaw = {
|
|
| 18 |
{ key: "D", text: "Fourth option" },
|
| 19 |
],
|
| 20 |
correctAnswer: "A",
|
| 21 |
-
explanation: "
|
| 22 |
difficulty: 3,
|
| 23 |
skillTags: ["comprehension"],
|
| 24 |
};
|
|
@@ -143,16 +144,48 @@ describe("coerceCorrectAnswer — multiple_choice with invalid key", () => {
|
|
| 143 |
});
|
| 144 |
});
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
describe("ensureOptions", () => {
|
| 147 |
-
it("
|
| 148 |
const raw = {
|
| 149 |
...baseRaw,
|
| 150 |
format: "multiple_choice",
|
| 151 |
options: [],
|
| 152 |
};
|
| 153 |
-
const
|
| 154 |
-
expect(
|
| 155 |
-
expect(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
});
|
| 157 |
|
| 158 |
it("deduplicates options by key", () => {
|
|
@@ -177,14 +210,14 @@ describe("repairAndParseQuestions", () => {
|
|
| 177 |
expect(result.invalid).toHaveLength(0);
|
| 178 |
});
|
| 179 |
|
| 180 |
-
it("repairs and
|
| 181 |
const raw = [
|
| 182 |
baseRaw,
|
| 183 |
{ format: "multiple_choice", passageText: "", questionText: "", correctAnswer: "", explanation: "", difficulty: 99, skillTags: [] },
|
| 184 |
];
|
| 185 |
const result = repairAndParseQuestions(raw, fullPassage);
|
| 186 |
-
expect(result.valid).toHaveLength(
|
| 187 |
-
expect(result.invalid).toHaveLength(
|
| 188 |
expect(result.repairLog.length).toBeGreaterThan(0);
|
| 189 |
});
|
| 190 |
|
|
|
|
| 3 |
repairQuestion,
|
| 4 |
repairAndParseQuestions,
|
| 5 |
tryParseQuestion,
|
| 6 |
+
isGenericOptionText,
|
| 7 |
} from "../repair";
|
| 8 |
|
| 9 |
const fullPassage = "A".repeat(200);
|
|
|
|
| 19 |
{ key: "D", text: "Fourth option" },
|
| 20 |
],
|
| 21 |
correctAnswer: "A",
|
| 22 |
+
explanation: "Jawaban A benar karena pilihan ini sesuai dengan isi teks.",
|
| 23 |
difficulty: 3,
|
| 24 |
skillTags: ["comprehension"],
|
| 25 |
};
|
|
|
|
| 144 |
});
|
| 145 |
});
|
| 146 |
|
| 147 |
+
describe("isGenericOptionText", () => {
|
| 148 |
+
it("detects common placeholder patterns", () => {
|
| 149 |
+
expect(isGenericOptionText("Option A")).toBe(true);
|
| 150 |
+
expect(isGenericOptionText("option b")).toBe(true);
|
| 151 |
+
expect(isGenericOptionText("Pilihan C")).toBe(true);
|
| 152 |
+
expect(isGenericOptionText("Placeholder option")).toBe(true);
|
| 153 |
+
expect(isGenericOptionText("")).toBe(true);
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
it("accepts real answer text", () => {
|
| 157 |
+
expect(isGenericOptionText("The author argues for renewable energy")).toBe(false);
|
| 158 |
+
expect(isGenericOptionText("Pada paragraf kedua")).toBe(false);
|
| 159 |
+
});
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
describe("ensureOptions", () => {
|
| 163 |
+
it("rejects missing options via repairAndParseQuestions instead of injecting placeholders", () => {
|
| 164 |
const raw = {
|
| 165 |
...baseRaw,
|
| 166 |
format: "multiple_choice",
|
| 167 |
options: [],
|
| 168 |
};
|
| 169 |
+
const result = repairAndParseQuestions([raw], fullPassage);
|
| 170 |
+
expect(result.valid).toHaveLength(0);
|
| 171 |
+
expect(result.invalid).toHaveLength(1);
|
| 172 |
+
expect(result.invalid[0]!.errors.some((e) => e.includes("options missing"))).toBe(true);
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
it("rejects model-provided generic option text", () => {
|
| 176 |
+
const raw = {
|
| 177 |
+
...baseRaw,
|
| 178 |
+
options: [
|
| 179 |
+
{ key: "A", text: "Option A" },
|
| 180 |
+
{ key: "B", text: "Option B" },
|
| 181 |
+
{ key: "C", text: "Option C" },
|
| 182 |
+
{ key: "D", text: "Option D" },
|
| 183 |
+
],
|
| 184 |
+
};
|
| 185 |
+
const result = repairAndParseQuestions([raw], fullPassage);
|
| 186 |
+
expect(result.valid).toHaveLength(0);
|
| 187 |
+
expect(result.invalid).toHaveLength(1);
|
| 188 |
+
expect(result.invalid[0]!.errors.some((e) => e.includes("generic placeholder"))).toBe(true);
|
| 189 |
});
|
| 190 |
|
| 191 |
it("deduplicates options by key", () => {
|
|
|
|
| 210 |
expect(result.invalid).toHaveLength(0);
|
| 211 |
});
|
| 212 |
|
| 213 |
+
it("repairs valid questions and rejects structurally incomplete ones", () => {
|
| 214 |
const raw = [
|
| 215 |
baseRaw,
|
| 216 |
{ format: "multiple_choice", passageText: "", questionText: "", correctAnswer: "", explanation: "", difficulty: 99, skillTags: [] },
|
| 217 |
];
|
| 218 |
const result = repairAndParseQuestions(raw, fullPassage);
|
| 219 |
+
expect(result.valid).toHaveLength(1);
|
| 220 |
+
expect(result.invalid).toHaveLength(1);
|
| 221 |
expect(result.repairLog.length).toBeGreaterThan(0);
|
| 222 |
});
|
| 223 |
|
packages/ai/src/agentic.ts
CHANGED
|
@@ -9,6 +9,13 @@ import {
|
|
| 9 |
getGenericQuestionJsonSchemaDescription,
|
| 10 |
repairAndParseQuestions,
|
| 11 |
} from "./repair";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
import { type GenerationInput, type GenerationResult } from "./schemas";
|
| 13 |
|
| 14 |
interface AgenticStep {
|
|
@@ -34,15 +41,7 @@ function getSystemPrompt(): string {
|
|
| 34 |
return "You are a precise exam question generator. You always return valid JSON. You never include markdown formatting around the JSON.";
|
| 35 |
}
|
| 36 |
|
| 37 |
-
|
| 38 |
-
if (examType === "JLPT") return "Japanese";
|
| 39 |
-
if (examType === "HSK") return "Chinese";
|
| 40 |
-
if (examType === "GOETHE") return "German";
|
| 41 |
-
if (examType === "TOPIK") return "Korean";
|
| 42 |
-
if (examType === "TOAFL") return "Arabic";
|
| 43 |
-
if (examType === "DELE") return "Spanish";
|
| 44 |
-
return "English";
|
| 45 |
-
}
|
| 46 |
|
| 47 |
/** Estimate max tokens needed per step to avoid truncation. */
|
| 48 |
function calculateMaxTokens(
|
|
@@ -196,7 +195,9 @@ Rules:
|
|
| 196 |
- Questions should test real comprehension, not surface recall
|
| 197 |
- For multiple choice: always provide 4 options (A, B, C, D) with one clearly correct answer
|
| 198 |
- Options must be plausible distractors
|
| 199 |
-
|
|
|
|
|
|
|
| 200 |
- For true_false_not_given: correctAnswer must be exactly TRUE, FALSE, or NOT_GIVEN (uppercase)
|
| 201 |
- For author_view: correctAnswer must be exactly YES, NO, or NOT_GIVEN (uppercase)
|
| 202 |
- For matching_pairs: options are {key, text} pairs. correctAnswer is serialized mapping like "A:1,B:2".
|
|
@@ -232,15 +233,32 @@ Return ONLY valid JSON conforming to this schema.`;
|
|
| 232 |
};
|
| 233 |
}
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
async function step4SelfValidate(
|
| 236 |
client: OpenAICompatibleClient,
|
| 237 |
input: GenerationInput,
|
| 238 |
passage: string,
|
| 239 |
questions: Array<Record<string, unknown>>,
|
| 240 |
onToken?: (token: string) => void,
|
| 241 |
-
): Promise<{ confidence: number; issues:
|
| 242 |
const qaPairs = questions
|
| 243 |
-
.map((q, i) =>
|
| 244 |
.join("\n\n");
|
| 245 |
|
| 246 |
const schema = getSelfValidationJsonSchemaDescription();
|
|
@@ -258,6 +276,7 @@ For each question, verify:
|
|
| 258 |
1. Is the claimed answer truly correct based on the passage?
|
| 259 |
2. Are there any ambiguous questions?
|
| 260 |
3. Are distractors plausible but clearly wrong?
|
|
|
|
| 261 |
|
| 262 |
Return ONLY valid JSON conforming to this schema:
|
| 263 |
${schema}`;
|
|
@@ -283,69 +302,21 @@ ${schema}`;
|
|
| 283 |
return { confidence, issues, tokensUsed: result.usage?.total_tokens ?? 0 };
|
| 284 |
}
|
| 285 |
|
| 286 |
-
async function
|
| 287 |
-
client: OpenAICompatibleClient,
|
| 288 |
input: GenerationInput,
|
| 289 |
-
passage: string,
|
| 290 |
-
count: number,
|
| 291 |
-
context: string,
|
| 292 |
onToken?: (token: string) => void,
|
| 293 |
-
): Promise<{
|
| 294 |
-
const
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
Passage:
|
| 299 |
-
"""
|
| 300 |
-
${passage}
|
| 301 |
-
"""
|
| 302 |
-
|
| 303 |
-
Generate ${count} new reading comprehension questions for ${input.examType} exam.
|
| 304 |
-
Formats: ${input.formats.join(", ")}
|
| 305 |
-
Difficulty: ${input.difficulty}/5
|
| 306 |
-
|
| 307 |
-
Rules:
|
| 308 |
-
- Each question must be directly answerable from the passage
|
| 309 |
-
- Use "passageText" field with relevant excerpt (or full passage)
|
| 310 |
-
- For multiple choice: provide 4 options (A, B, C, D)
|
| 311 |
-
- explanation — WAJIB ditulis dalam Bahasa Indonesia. DILARANG menggunakan bahasa asing.
|
| 312 |
-
- For true_false_not_given: correctAnswer must be TRUE, FALSE, or NOT_GIVEN (uppercase)
|
| 313 |
-
- For author_view: correctAnswer must be YES, NO, or NOT_GIVEN (uppercase)
|
| 314 |
-
- For matching_pairs: options are {key, text} pairs. correctAnswer is serialized mapping.
|
| 315 |
-
- For error_recognition: options are error segments. correctAnswer is key of segment with error.
|
| 316 |
-
- For text_insertion: options are position markers. correctAnswer is best position key.
|
| 317 |
-
|
| 318 |
-
Question schema:
|
| 319 |
-
${schema}
|
| 320 |
-
|
| 321 |
-
Return ONLY valid JSON conforming to this schema.`;
|
| 322 |
-
|
| 323 |
-
const result = await client.chatCompletion(
|
| 324 |
-
{
|
| 325 |
-
model: input.apiKeyConfig.model,
|
| 326 |
-
messages: [
|
| 327 |
-
{ role: "system", content: getSystemPrompt() },
|
| 328 |
-
{ role: "user", content: prompt },
|
| 329 |
-
],
|
| 330 |
-
temperature: 0.7,
|
| 331 |
-
max_tokens: calculateMaxTokens(input.apiKeyConfig.maxTokens, "regenerate", count),
|
| 332 |
-
response_format: { type: "json_object" },
|
| 333 |
-
},
|
| 334 |
-
onToken ? { onToken } : undefined,
|
| 335 |
);
|
| 336 |
-
|
| 337 |
-
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 338 |
-
if (!Array.isArray(parsed.questions)) {
|
| 339 |
-
throw new Error("Missing questions array in regeneration response");
|
| 340 |
-
}
|
| 341 |
-
return {
|
| 342 |
-
questions: parsed.questions as Array<Record<string, unknown>>,
|
| 343 |
-
tokensUsed: result.usage?.total_tokens ?? 0,
|
| 344 |
-
};
|
| 345 |
}
|
| 346 |
|
| 347 |
-
|
|
|
|
| 348 |
input: GenerationInput,
|
|
|
|
| 349 |
onProgress?: (progress: AgenticProgress) => void,
|
| 350 |
onToken?: (token: string) => void,
|
| 351 |
options?: AgenticGenerationOptions,
|
|
@@ -359,9 +330,9 @@ export async function generateQuestionsAgentic(
|
|
| 359 |
);
|
| 360 |
|
| 361 |
const steps: [AgenticStep, AgenticStep, AgenticStep, AgenticStep] = [
|
| 362 |
-
{ step: "generate_passage", status: "
|
| 363 |
-
{ step: "validate_passage", status: "
|
| 364 |
-
{ step: "generate_questions", status: "
|
| 365 |
{ step: "self_validate", status: "pending" as any },
|
| 366 |
];
|
| 367 |
|
|
@@ -375,64 +346,63 @@ export async function generateQuestionsAgentic(
|
|
| 375 |
};
|
| 376 |
|
| 377 |
let accumulatedTokens = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
steps[1].message = s2.feedback;
|
| 406 |
-
steps[1].output = JSON.stringify({ isValid: s2.isValid, feedback: s2.feedback }, null, 2);
|
| 407 |
-
} catch (err: any) {
|
| 408 |
-
steps[1].status = "error";
|
| 409 |
-
steps[1].message = err.message ?? "Passage validation failed";
|
| 410 |
-
throw new GenerationError(`Step 2 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
|
| 411 |
-
}
|
| 412 |
-
} else {
|
| 413 |
-
steps[1].status = "done";
|
| 414 |
-
steps[1].message = "Skipped in lean strategy";
|
| 415 |
-
report(1, steps[1].message);
|
| 416 |
-
}
|
| 417 |
-
|
| 418 |
-
// ── Step 3: Generate questions ──────────────────────────
|
| 419 |
-
steps[2].status = "running";
|
| 420 |
report(2, `Generating ${input.questionCount} questions...`);
|
| 421 |
let rawQuestions: Array<Record<string, unknown>>;
|
| 422 |
try {
|
| 423 |
const s3 = await step3GenerateQuestions(client, input, passage, input.questionCount, onToken);
|
| 424 |
rawQuestions = s3.questions;
|
| 425 |
accumulatedTokens += s3.tokensUsed;
|
| 426 |
-
steps[2].
|
| 427 |
-
steps[2].
|
|
|
|
|
|
|
| 428 |
} catch (err: any) {
|
| 429 |
-
steps[2].status = "error";
|
| 430 |
-
steps[2].message = err.message ?? "Question generation failed";
|
| 431 |
throw new GenerationError(`Step 3 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
|
| 432 |
}
|
| 433 |
|
| 434 |
-
|
| 435 |
-
steps[3].status = "running";
|
| 436 |
report(3, "Validating & repairing questions...");
|
| 437 |
|
| 438 |
let validQuestions: any[] = [];
|
|
@@ -446,98 +416,80 @@ export async function generateQuestionsAgentic(
|
|
| 446 |
selfValidationConfidence = s4.confidence;
|
| 447 |
}
|
| 448 |
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
const filteredValid: typeof valid = [];
|
| 456 |
-
for (let vi = 0; vi < valid.length; vi++) {
|
| 457 |
-
const q = valid[vi];
|
| 458 |
-
if (!q) continue;
|
| 459 |
-
if (q.explanation && hasCJK(q.explanation)) {
|
| 460 |
-
cjkInvalid.push({
|
| 461 |
-
index: vi,
|
| 462 |
-
raw: q,
|
| 463 |
-
errors: ["explanation contains CJK characters (should be Bahasa Indonesia)"],
|
| 464 |
-
});
|
| 465 |
-
repairLog.push(`Q${vi + 1}: explanation contains CJK characters, moved to regeneration queue`);
|
| 466 |
-
} else {
|
| 467 |
-
filteredValid.push(q);
|
| 468 |
-
}
|
| 469 |
-
}
|
| 470 |
-
validQuestions = filteredValid;
|
| 471 |
-
invalid = [...invalid, ...cjkInvalid];
|
| 472 |
allRepairLogs.push(...repairLog);
|
| 473 |
|
| 474 |
-
// Regenerate structural-invalid questions with bounded attempts
|
| 475 |
let regenerationAttempts = 0;
|
| 476 |
|
| 477 |
while (invalid.length > 0 && regenerationAttempts < maxRegenAttempts && validQuestions.length < input.questionCount) {
|
| 478 |
regenerationAttempts++;
|
| 479 |
const regenCount = Math.min(invalid.length, input.questionCount - validQuestions.length);
|
| 480 |
-
const context =
|
| 481 |
|
| 482 |
report(3, `Regenerating ${regenCount} invalid question(s) (attempt ${regenerationAttempts}/${maxRegenAttempts})...`);
|
| 483 |
|
| 484 |
-
const regen = await
|
| 485 |
accumulatedTokens += regen.tokensUsed;
|
| 486 |
|
| 487 |
-
const regenResult = repairAndParseQuestions(regen.questions, passage
|
|
|
|
|
|
|
| 488 |
validQuestions.push(...regenResult.valid);
|
| 489 |
allRepairLogs.push(...regenResult.repairLog.map((l) => `[Regen ${regenerationAttempts}] ${l}`));
|
| 490 |
|
| 491 |
-
// If regeneration produced valid questions, remove corresponding invalid entries
|
| 492 |
if (regenResult.valid.length > 0) {
|
| 493 |
invalid = invalid.slice(regenResult.valid.length);
|
| 494 |
} else {
|
| 495 |
-
// No progress — break to avoid infinite loop
|
| 496 |
break;
|
| 497 |
}
|
| 498 |
}
|
| 499 |
|
| 500 |
-
// If still below target, generate additional questions
|
| 501 |
if (validQuestions.length < input.questionCount) {
|
| 502 |
const needMore = input.questionCount - validQuestions.length;
|
| 503 |
report(3, `Generating ${needMore} additional question(s)...`);
|
| 504 |
-
const extra = await
|
| 505 |
client, input, passage, needMore,
|
| 506 |
`Need ${needMore} more valid questions to reach target of ${input.questionCount}.`,
|
| 507 |
onToken,
|
| 508 |
);
|
| 509 |
accumulatedTokens += extra.tokensUsed;
|
| 510 |
-
const extraResult = repairAndParseQuestions(extra.questions, passage
|
|
|
|
|
|
|
| 511 |
validQuestions.push(...extraResult.valid);
|
| 512 |
allRepairLogs.push(...extraResult.repairLog.map((l) => `[Extra] ${l}`));
|
| 513 |
}
|
| 514 |
|
| 515 |
-
steps[3].status = "done";
|
| 516 |
-
steps[3].message =
|
| 517 |
strategy === "full"
|
| 518 |
? `Validated ${validQuestions.length}/${input.questionCount} questions. Confidence: ${selfValidationConfidence}%`
|
| 519 |
: `Lean validation completed: ${validQuestions.length}/${input.questionCount} valid`;
|
| 520 |
-
steps[3].output = [
|
| 521 |
`Strategy: ${strategy}`,
|
| 522 |
...(strategy === "full" ? [`Overall Confidence: ${selfValidationConfidence}%`] : []),
|
| 523 |
`Valid Questions: ${validQuestions.length}/${input.questionCount}`,
|
| 524 |
`Repair Log:`,
|
| 525 |
...allRepairLogs,
|
| 526 |
].join("\n");
|
|
|
|
| 527 |
} catch (err: any) {
|
| 528 |
-
steps[3].status = "error";
|
| 529 |
-
steps[3].message = err.message ?? "Self-validation failed";
|
| 530 |
throw new GenerationError(`Step 4 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
|
| 531 |
}
|
| 532 |
|
| 533 |
-
// ── Final check ─────────────────────────────────────────
|
| 534 |
if (validQuestions.length === 0) {
|
| 535 |
throw new GenerationError("No valid questions generated after agentic validation", {
|
| 536 |
tokensUsed: accumulatedTokens,
|
| 537 |
});
|
| 538 |
}
|
| 539 |
|
| 540 |
-
// Trim to requested count (if we overshot)
|
| 541 |
const finalQuestions = validQuestions.slice(0, input.questionCount);
|
| 542 |
|
| 543 |
return {
|
|
@@ -550,3 +502,90 @@ export async function generateQuestionsAgentic(
|
|
| 550 |
},
|
| 551 |
};
|
| 552 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
getGenericQuestionJsonSchemaDescription,
|
| 10 |
repairAndParseQuestions,
|
| 11 |
} from "./repair";
|
| 12 |
+
import { regenerateQuestions, buildRegenerationContext } from "./regenerate-questions";
|
| 13 |
+
import { OPTION_QUALITY_RULES } from "./prompts";
|
| 14 |
+
import {
|
| 15 |
+
getTargetLanguage,
|
| 16 |
+
buildContentLanguageRules,
|
| 17 |
+
buildExplanationLanguageRule,
|
| 18 |
+
} from "./language-rules";
|
| 19 |
import { type GenerationInput, type GenerationResult } from "./schemas";
|
| 20 |
|
| 21 |
interface AgenticStep {
|
|
|
|
| 41 |
return "You are a precise exam question generator. You always return valid JSON. You never include markdown formatting around the JSON.";
|
| 42 |
}
|
| 43 |
|
| 44 |
+
export { getTargetLanguage };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
/** Estimate max tokens needed per step to avoid truncation. */
|
| 47 |
function calculateMaxTokens(
|
|
|
|
| 195 |
- Questions should test real comprehension, not surface recall
|
| 196 |
- For multiple choice: always provide 4 options (A, B, C, D) with one clearly correct answer
|
| 197 |
- Options must be plausible distractors
|
| 198 |
+
${OPTION_QUALITY_RULES}
|
| 199 |
+
${buildContentLanguageRules(input.examType)}
|
| 200 |
+
${buildExplanationLanguageRule(input.examType)}
|
| 201 |
- For true_false_not_given: correctAnswer must be exactly TRUE, FALSE, or NOT_GIVEN (uppercase)
|
| 202 |
- For author_view: correctAnswer must be exactly YES, NO, or NOT_GIVEN (uppercase)
|
| 203 |
- For matching_pairs: options are {key, text} pairs. correctAnswer is serialized mapping like "A:1,B:2".
|
|
|
|
| 233 |
};
|
| 234 |
}
|
| 235 |
|
| 236 |
+
function formatQuestionForValidation(q: Record<string, unknown>, index: number): string {
|
| 237 |
+
const lines = [`Q${index + 1}: ${q.questionText}`];
|
| 238 |
+
if (Array.isArray(q.options) && q.options.length > 0) {
|
| 239 |
+
const optionLines = q.options
|
| 240 |
+
.map((o) => {
|
| 241 |
+
const opt = o as Record<string, unknown>;
|
| 242 |
+
const key = opt.key ?? opt.left ?? "?";
|
| 243 |
+
const text = opt.text ?? opt.right ?? "";
|
| 244 |
+
return ` ${key}: ${text}`;
|
| 245 |
+
})
|
| 246 |
+
.join("\n");
|
| 247 |
+
lines.push(`Options:\n${optionLines}`);
|
| 248 |
+
}
|
| 249 |
+
lines.push(`Claimed answer: ${q.correctAnswer}`);
|
| 250 |
+
return lines.join("\n");
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
async function step4SelfValidate(
|
| 254 |
client: OpenAICompatibleClient,
|
| 255 |
input: GenerationInput,
|
| 256 |
passage: string,
|
| 257 |
questions: Array<Record<string, unknown>>,
|
| 258 |
onToken?: (token: string) => void,
|
| 259 |
+
): Promise<{ confidence: number; issues: unknown[]; tokensUsed: number }> {
|
| 260 |
const qaPairs = questions
|
| 261 |
+
.map((q, i) => formatQuestionForValidation(q, i))
|
| 262 |
.join("\n\n");
|
| 263 |
|
| 264 |
const schema = getSelfValidationJsonSchemaDescription();
|
|
|
|
| 276 |
1. Is the claimed answer truly correct based on the passage?
|
| 277 |
2. Are there any ambiguous questions?
|
| 278 |
3. Are distractors plausible but clearly wrong?
|
| 279 |
+
4. Are all option texts meaningful (not generic placeholders like "Option A")?
|
| 280 |
|
| 281 |
Return ONLY valid JSON conforming to this schema:
|
| 282 |
${schema}`;
|
|
|
|
| 302 |
return { confidence, issues, tokensUsed: result.usage?.total_tokens ?? 0 };
|
| 303 |
}
|
| 304 |
|
| 305 |
+
export async function generatePassageForInput(
|
|
|
|
| 306 |
input: GenerationInput,
|
|
|
|
|
|
|
|
|
|
| 307 |
onToken?: (token: string) => void,
|
| 308 |
+
): Promise<{ passage: string; title: string; tokensUsed: number }> {
|
| 309 |
+
const client = new OpenAICompatibleClient(
|
| 310 |
+
input.apiKeyConfig.baseUrl,
|
| 311 |
+
input.apiKeyConfig.apiKey,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
);
|
| 313 |
+
return step1GeneratePassage(client, input, onToken);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
}
|
| 315 |
|
| 316 |
+
/** Generate + validate questions from an existing passage (skips passage generation). */
|
| 317 |
+
export async function generateQuestionsAgenticFromPassage(
|
| 318 |
input: GenerationInput,
|
| 319 |
+
passage: string,
|
| 320 |
onProgress?: (progress: AgenticProgress) => void,
|
| 321 |
onToken?: (token: string) => void,
|
| 322 |
options?: AgenticGenerationOptions,
|
|
|
|
| 330 |
);
|
| 331 |
|
| 332 |
const steps: [AgenticStep, AgenticStep, AgenticStep, AgenticStep] = [
|
| 333 |
+
{ step: "generate_passage", status: "done", message: "Using shared passage" },
|
| 334 |
+
{ step: "validate_passage", status: "done", message: "Skipped (shared passage)" },
|
| 335 |
+
{ step: "generate_questions", status: "running" },
|
| 336 |
{ step: "self_validate", status: "pending" as any },
|
| 337 |
];
|
| 338 |
|
|
|
|
| 346 |
};
|
| 347 |
|
| 348 |
let accumulatedTokens = 0;
|
| 349 |
+
return runAgenticQuestionPipeline({
|
| 350 |
+
client,
|
| 351 |
+
input,
|
| 352 |
+
passage,
|
| 353 |
+
steps,
|
| 354 |
+
report,
|
| 355 |
+
strategy,
|
| 356 |
+
maxRegenAttempts,
|
| 357 |
+
onToken,
|
| 358 |
+
start,
|
| 359 |
+
accumulatedTokens,
|
| 360 |
+
});
|
| 361 |
+
}
|
| 362 |
|
| 363 |
+
async function runAgenticQuestionPipeline(ctx: {
|
| 364 |
+
client: OpenAICompatibleClient;
|
| 365 |
+
input: GenerationInput;
|
| 366 |
+
passage: string;
|
| 367 |
+
steps: [AgenticStep, AgenticStep, AgenticStep, AgenticStep];
|
| 368 |
+
report: (current: number, extraMsg?: string) => void;
|
| 369 |
+
strategy: AgenticGenerationStrategy;
|
| 370 |
+
maxRegenAttempts: number;
|
| 371 |
+
onToken?: (token: string) => void;
|
| 372 |
+
start: number;
|
| 373 |
+
accumulatedTokens: number;
|
| 374 |
+
}): Promise<GenerationResult> {
|
| 375 |
+
const {
|
| 376 |
+
client,
|
| 377 |
+
input,
|
| 378 |
+
passage,
|
| 379 |
+
steps,
|
| 380 |
+
report,
|
| 381 |
+
strategy,
|
| 382 |
+
maxRegenAttempts,
|
| 383 |
+
onToken,
|
| 384 |
+
start,
|
| 385 |
+
} = ctx;
|
| 386 |
+
let accumulatedTokens = ctx.accumulatedTokens;
|
| 387 |
+
|
| 388 |
+
steps[2]!.status = "running";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
report(2, `Generating ${input.questionCount} questions...`);
|
| 390 |
let rawQuestions: Array<Record<string, unknown>>;
|
| 391 |
try {
|
| 392 |
const s3 = await step3GenerateQuestions(client, input, passage, input.questionCount, onToken);
|
| 393 |
rawQuestions = s3.questions;
|
| 394 |
accumulatedTokens += s3.tokensUsed;
|
| 395 |
+
steps[2]!.status = "done";
|
| 396 |
+
steps[2]!.message = `Generated ${rawQuestions.length} questions`;
|
| 397 |
+
steps[2]!.output = rawQuestions.map((q, i) => `${i + 1}. [${q.format}] ${q.questionText}`).join("\n");
|
| 398 |
+
report(2);
|
| 399 |
} catch (err: any) {
|
| 400 |
+
steps[2]!.status = "error";
|
| 401 |
+
steps[2]!.message = err.message ?? "Question generation failed";
|
| 402 |
throw new GenerationError(`Step 3 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
|
| 403 |
}
|
| 404 |
|
| 405 |
+
steps[3]!.status = "running";
|
|
|
|
| 406 |
report(3, "Validating & repairing questions...");
|
| 407 |
|
| 408 |
let validQuestions: any[] = [];
|
|
|
|
| 416 |
selfValidationConfidence = s4.confidence;
|
| 417 |
}
|
| 418 |
|
| 419 |
+
let { valid, invalid, repairLog } = repairAndParseQuestions(rawQuestions, passage, {
|
| 420 |
+
examType: input.examType,
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
validQuestions = valid;
|
| 424 |
+
invalid = [...invalid];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
allRepairLogs.push(...repairLog);
|
| 426 |
|
|
|
|
| 427 |
let regenerationAttempts = 0;
|
| 428 |
|
| 429 |
while (invalid.length > 0 && regenerationAttempts < maxRegenAttempts && validQuestions.length < input.questionCount) {
|
| 430 |
regenerationAttempts++;
|
| 431 |
const regenCount = Math.min(invalid.length, input.questionCount - validQuestions.length);
|
| 432 |
+
const context = buildRegenerationContext(invalid.slice(0, regenCount));
|
| 433 |
|
| 434 |
report(3, `Regenerating ${regenCount} invalid question(s) (attempt ${regenerationAttempts}/${maxRegenAttempts})...`);
|
| 435 |
|
| 436 |
+
const regen = await regenerateQuestions(client, input, passage, regenCount, context, onToken);
|
| 437 |
accumulatedTokens += regen.tokensUsed;
|
| 438 |
|
| 439 |
+
const regenResult = repairAndParseQuestions(regen.questions, passage, {
|
| 440 |
+
examType: input.examType,
|
| 441 |
+
});
|
| 442 |
validQuestions.push(...regenResult.valid);
|
| 443 |
allRepairLogs.push(...regenResult.repairLog.map((l) => `[Regen ${regenerationAttempts}] ${l}`));
|
| 444 |
|
|
|
|
| 445 |
if (regenResult.valid.length > 0) {
|
| 446 |
invalid = invalid.slice(regenResult.valid.length);
|
| 447 |
} else {
|
|
|
|
| 448 |
break;
|
| 449 |
}
|
| 450 |
}
|
| 451 |
|
|
|
|
| 452 |
if (validQuestions.length < input.questionCount) {
|
| 453 |
const needMore = input.questionCount - validQuestions.length;
|
| 454 |
report(3, `Generating ${needMore} additional question(s)...`);
|
| 455 |
+
const extra = await regenerateQuestions(
|
| 456 |
client, input, passage, needMore,
|
| 457 |
`Need ${needMore} more valid questions to reach target of ${input.questionCount}.`,
|
| 458 |
onToken,
|
| 459 |
);
|
| 460 |
accumulatedTokens += extra.tokensUsed;
|
| 461 |
+
const extraResult = repairAndParseQuestions(extra.questions, passage, {
|
| 462 |
+
examType: input.examType,
|
| 463 |
+
});
|
| 464 |
validQuestions.push(...extraResult.valid);
|
| 465 |
allRepairLogs.push(...extraResult.repairLog.map((l) => `[Extra] ${l}`));
|
| 466 |
}
|
| 467 |
|
| 468 |
+
steps[3]!.status = "done";
|
| 469 |
+
steps[3]!.message =
|
| 470 |
strategy === "full"
|
| 471 |
? `Validated ${validQuestions.length}/${input.questionCount} questions. Confidence: ${selfValidationConfidence}%`
|
| 472 |
: `Lean validation completed: ${validQuestions.length}/${input.questionCount} valid`;
|
| 473 |
+
steps[3]!.output = [
|
| 474 |
`Strategy: ${strategy}`,
|
| 475 |
...(strategy === "full" ? [`Overall Confidence: ${selfValidationConfidence}%`] : []),
|
| 476 |
`Valid Questions: ${validQuestions.length}/${input.questionCount}`,
|
| 477 |
`Repair Log:`,
|
| 478 |
...allRepairLogs,
|
| 479 |
].join("\n");
|
| 480 |
+
report(3);
|
| 481 |
} catch (err: any) {
|
| 482 |
+
steps[3]!.status = "error";
|
| 483 |
+
steps[3]!.message = err.message ?? "Self-validation failed";
|
| 484 |
throw new GenerationError(`Step 4 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
|
| 485 |
}
|
| 486 |
|
|
|
|
| 487 |
if (validQuestions.length === 0) {
|
| 488 |
throw new GenerationError("No valid questions generated after agentic validation", {
|
| 489 |
tokensUsed: accumulatedTokens,
|
| 490 |
});
|
| 491 |
}
|
| 492 |
|
|
|
|
| 493 |
const finalQuestions = validQuestions.slice(0, input.questionCount);
|
| 494 |
|
| 495 |
return {
|
|
|
|
| 502 |
},
|
| 503 |
};
|
| 504 |
}
|
| 505 |
+
|
| 506 |
+
export async function generateQuestionsAgentic(
|
| 507 |
+
input: GenerationInput,
|
| 508 |
+
onProgress?: (progress: AgenticProgress) => void,
|
| 509 |
+
onToken?: (token: string) => void,
|
| 510 |
+
options?: AgenticGenerationOptions,
|
| 511 |
+
): Promise<GenerationResult> {
|
| 512 |
+
const start = Date.now();
|
| 513 |
+
const strategy = options?.strategy ?? "full";
|
| 514 |
+
const maxRegenAttempts = Math.max(0, options?.maxRegenerateAttempts ?? (strategy === "lean" ? 1 : 2));
|
| 515 |
+
const client = new OpenAICompatibleClient(
|
| 516 |
+
input.apiKeyConfig.baseUrl,
|
| 517 |
+
input.apiKeyConfig.apiKey,
|
| 518 |
+
);
|
| 519 |
+
|
| 520 |
+
const steps: [AgenticStep, AgenticStep, AgenticStep, AgenticStep] = [
|
| 521 |
+
{ step: "generate_passage", status: "running" },
|
| 522 |
+
{ step: "validate_passage", status: "pending" as any },
|
| 523 |
+
{ step: "generate_questions", status: "pending" as any },
|
| 524 |
+
{ step: "self_validate", status: "pending" as any },
|
| 525 |
+
];
|
| 526 |
+
|
| 527 |
+
const report = (current: number, extraMsg?: string) => {
|
| 528 |
+
if (extraMsg && steps[current]) {
|
| 529 |
+
steps[current]!.message = extraMsg;
|
| 530 |
+
}
|
| 531 |
+
if (onProgress) {
|
| 532 |
+
onProgress({ steps, currentStep: current });
|
| 533 |
+
}
|
| 534 |
+
};
|
| 535 |
+
|
| 536 |
+
let accumulatedTokens = 0;
|
| 537 |
+
|
| 538 |
+
// ── Step 1: Generate passage ────────────────────────────
|
| 539 |
+
report(0);
|
| 540 |
+
let passage: string;
|
| 541 |
+
let title: string;
|
| 542 |
+
try {
|
| 543 |
+
const s1 = await step1GeneratePassage(client, input, onToken);
|
| 544 |
+
passage = s1.passage;
|
| 545 |
+
title = s1.title;
|
| 546 |
+
accumulatedTokens += s1.tokensUsed;
|
| 547 |
+
steps[0].status = "done";
|
| 548 |
+
steps[0].message = `Generated: ${title}`;
|
| 549 |
+
steps[0].output = passage;
|
| 550 |
+
report(0);
|
| 551 |
+
} catch (err: any) {
|
| 552 |
+
steps[0].status = "error";
|
| 553 |
+
steps[0].message = err.message ?? "Passage generation failed";
|
| 554 |
+
throw new GenerationError(`Step 1 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
// ── Step 2: Validate passage ────────────────────────────
|
| 558 |
+
if (strategy === "full") {
|
| 559 |
+
steps[1].status = "running";
|
| 560 |
+
report(1);
|
| 561 |
+
try {
|
| 562 |
+
const s2 = await step2ValidatePassage(client, input, passage, onToken);
|
| 563 |
+
accumulatedTokens += s2.tokensUsed;
|
| 564 |
+
steps[1].status = s2.isValid ? "done" : "error";
|
| 565 |
+
steps[1].message = s2.feedback;
|
| 566 |
+
steps[1].output = JSON.stringify({ isValid: s2.isValid, feedback: s2.feedback }, null, 2);
|
| 567 |
+
report(1);
|
| 568 |
+
} catch (err: any) {
|
| 569 |
+
steps[1].status = "error";
|
| 570 |
+
steps[1].message = err.message ?? "Passage validation failed";
|
| 571 |
+
throw new GenerationError(`Step 2 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
|
| 572 |
+
}
|
| 573 |
+
} else {
|
| 574 |
+
steps[1].status = "done";
|
| 575 |
+
steps[1].message = "Skipped in lean strategy";
|
| 576 |
+
report(1, steps[1].message);
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
return runAgenticQuestionPipeline({
|
| 580 |
+
client,
|
| 581 |
+
input,
|
| 582 |
+
passage,
|
| 583 |
+
steps,
|
| 584 |
+
report,
|
| 585 |
+
strategy,
|
| 586 |
+
maxRegenAttempts,
|
| 587 |
+
onToken,
|
| 588 |
+
start,
|
| 589 |
+
accumulatedTokens,
|
| 590 |
+
});
|
| 591 |
+
}
|
packages/ai/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
export { OpenAICompatibleClient } from "./client";
|
| 2 |
export { generateQuestionsQuick } from "./pipeline";
|
| 3 |
-
export { generateQuestionsAgentic } from "./agentic";
|
|
|
|
| 4 |
export type {
|
| 5 |
AgenticProgress,
|
| 6 |
AgenticGenerationOptions,
|
|
|
|
| 1 |
export { OpenAICompatibleClient } from "./client";
|
| 2 |
export { generateQuestionsQuick } from "./pipeline";
|
| 3 |
+
export { generateQuestionsAgentic, generatePassageForInput, generateQuestionsAgenticFromPassage } from "./agentic";
|
| 4 |
+
export { getTargetLanguage, buildContentLanguageRules } from "./language-rules";
|
| 5 |
export type {
|
| 6 |
AgenticProgress,
|
| 7 |
AgenticGenerationOptions,
|
packages/ai/src/language-rules.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface QuestionLanguageFields {
|
| 2 |
+
questionText: string;
|
| 3 |
+
options?: Array<{ key: string; text: string }>;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
/** Target language for exam content (passage, question, options). */
|
| 7 |
+
export function getTargetLanguage(examType: string): string {
|
| 8 |
+
if (examType === "JLPT") return "Japanese";
|
| 9 |
+
if (examType === "HSK") return "Chinese";
|
| 10 |
+
if (examType === "GOETHE") return "German";
|
| 11 |
+
if (examType === "TOPIK") return "Korean";
|
| 12 |
+
if (examType === "TOAFL") return "Arabic";
|
| 13 |
+
if (examType === "DELE") return "Spanish";
|
| 14 |
+
return "English";
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const EXAMS_REQUIRING_NON_LATIN_SCRIPT = new Set(["JLPT", "HSK", "TOPIK"]);
|
| 18 |
+
|
| 19 |
+
const INDONESIAN_PROSE_MARKERS =
|
| 20 |
+
/\b(yang|dari|pada|Apakah|Menurut|paragraf|bacaan|teks|judul|penulis|artikel|soal|pilihan|jawaban|berikut|manakah|bagian|kalimat|isinya|topik|gagasan|benar|karena|merujuk|sesuai|artinya|makna|kata|terdapat|disebutkan|dijelaskan|adalah|jadi|oleh|opsi|pilihan|jawabannya|penjelasan)\b/i;
|
| 21 |
+
|
| 22 |
+
/** Strip exam-script characters to analyze Indonesian prose separately. */
|
| 23 |
+
export function stripExamScriptForAnalysis(text: string): string {
|
| 24 |
+
return text
|
| 25 |
+
.replace(/[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF\u0600-\u06FF]+/g, " ")
|
| 26 |
+
.replace(/\s+/g, " ")
|
| 27 |
+
.trim();
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** Latin prose looks like Bahasa Indonesia (not English-only or empty). */
|
| 31 |
+
export function hasIndonesianProse(text: string): boolean {
|
| 32 |
+
const t = text.trim();
|
| 33 |
+
if (!t) return false;
|
| 34 |
+
if (INDONESIAN_PROSE_MARKERS.test(t)) return true;
|
| 35 |
+
if (/^(The|This|That|According|Because|Therefore|Option|Answer|It is|In the)\b/i.test(t)) {
|
| 36 |
+
return false;
|
| 37 |
+
}
|
| 38 |
+
return t.length >= 15;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function hasCJKScript(text: string): boolean {
|
| 42 |
+
return /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/.test(text);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export function hasArabicScript(text: string): boolean {
|
| 46 |
+
return /[\u0600-\u06FF]/.test(text);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function hasTargetScript(text: string, examType: string): boolean {
|
| 50 |
+
if (examType === "TOAFL") return hasArabicScript(text);
|
| 51 |
+
if (EXAMS_REQUIRING_NON_LATIN_SCRIPT.has(examType)) return hasCJKScript(text);
|
| 52 |
+
return true;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/** Heuristic: Latin text that looks like Indonesian exam instructions, not English. */
|
| 56 |
+
export function isLikelyIndonesianContent(text: string): boolean {
|
| 57 |
+
const t = text.trim();
|
| 58 |
+
if (!t) return false;
|
| 59 |
+
if (hasCJKScript(t) || hasArabicScript(t)) return false;
|
| 60 |
+
return INDONESIAN_PROSE_MARKERS.test(t);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export function buildContentLanguageRules(examType: string): string {
|
| 64 |
+
const target = getTargetLanguage(examType);
|
| 65 |
+
return `- LANGUAGE RULE (critical):
|
| 66 |
+
* passageText, questionText, and every option "text" MUST be written in ${target} — the authentic exam language.
|
| 67 |
+
* NEVER write questionText or options in Bahasa Indonesia for ${examType}.
|
| 68 |
+
* explanation: tulis dalam Bahasa Indonesia; boleh sisipkan istilah/kanji/kata ${target} bila perlu (mis. 「環境」 atau kutipan singkat dari teks).`;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export function buildExplanationLanguageRule(examType?: string): string {
|
| 72 |
+
const target = examType ? getTargetLanguage(examType) : "bahasa ujian";
|
| 73 |
+
return `- explanation — WAJIB ditulis dalam Bahasa Indonesia sebagai penjelasan utama. Boleh menyertakan istilah, kanji, atau kutipan singkat dalam ${target} jika relevan (mis. arti kanji, padanan kata). Jangan tulis seluruh explanation hanya dalam ${target} atau Inggris.`;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export function getQuestionLanguageErrors(
|
| 77 |
+
q: QuestionLanguageFields,
|
| 78 |
+
examType: string,
|
| 79 |
+
): string[] {
|
| 80 |
+
const errors: string[] = [];
|
| 81 |
+
const target = getTargetLanguage(examType);
|
| 82 |
+
|
| 83 |
+
if (isLikelyIndonesianContent(q.questionText)) {
|
| 84 |
+
errors.push(`questionText must be ${target}, not Bahasa Indonesia`);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
if (EXAMS_REQUIRING_NON_LATIN_SCRIPT.has(examType) && !hasTargetScript(q.questionText, examType)) {
|
| 88 |
+
errors.push(`questionText must be written in ${target}`);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (examType === "IELTS" || examType === "TOEFL") {
|
| 92 |
+
if (isLikelyIndonesianContent(q.questionText)) {
|
| 93 |
+
errors.push("questionText must be English for IELTS/TOEFL");
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
for (const opt of q.options ?? []) {
|
| 98 |
+
if (isLikelyIndonesianContent(opt.text)) {
|
| 99 |
+
errors.push(`option ${opt.key} must be ${target}, not Bahasa Indonesia`);
|
| 100 |
+
}
|
| 101 |
+
if (EXAMS_REQUIRING_NON_LATIN_SCRIPT.has(examType) && !hasTargetScript(opt.text, examType)) {
|
| 102 |
+
errors.push(`option ${opt.key} must be written in ${target}`);
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
return errors;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export function getExplanationLanguageErrors(explanation: string): string[] {
|
| 110 |
+
const t = explanation.trim();
|
| 111 |
+
if (!t) return [];
|
| 112 |
+
|
| 113 |
+
const latinPart = stripExamScriptForAnalysis(t);
|
| 114 |
+
|
| 115 |
+
if (latinPart.length === 0) {
|
| 116 |
+
return ["explanation must include Bahasa Indonesia prose, not only foreign script"];
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
if (!hasIndonesianProse(latinPart)) {
|
| 120 |
+
return ["explanation prose must be in Bahasa Indonesia"];
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
return [];
|
| 124 |
+
}
|
packages/ai/src/pipeline.ts
CHANGED
|
@@ -2,11 +2,14 @@ import { OpenAICompatibleClient } from "./client";
|
|
| 2 |
import { GenerationError } from "./errors";
|
| 3 |
import { buildQuickModePrompt } from "./prompts";
|
| 4 |
import { repairAndParseQuestions } from "./repair";
|
|
|
|
| 5 |
import {
|
| 6 |
type GenerationInput,
|
| 7 |
type GenerationResult,
|
| 8 |
} from "./schemas";
|
| 9 |
|
|
|
|
|
|
|
| 10 |
export interface QuickModeCallbacks {
|
| 11 |
onToken?: (token: string) => void;
|
| 12 |
}
|
|
@@ -115,36 +118,69 @@ export async function generateQuestionsQuick(
|
|
| 115 |
) as any;
|
| 116 |
const fallbackPassage = firstWithPassage?.passageText ?? "No passage available.";
|
| 117 |
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
if (repairLog.length > 0) {
|
| 121 |
log("warn", "Repairs applied", { count: repairLog.length, details: repairLog });
|
| 122 |
}
|
| 123 |
-
if (
|
| 124 |
-
log("warn", "Invalid questions after repair", {
|
| 125 |
-
count:
|
| 126 |
-
details:
|
| 127 |
});
|
| 128 |
}
|
| 129 |
|
| 130 |
-
if (
|
| 131 |
log("error", "No valid questions after repair", { rawCount: raw.questions.length });
|
| 132 |
-
throw new GenerationError("No valid questions generated", { tokensUsed });
|
| 133 |
}
|
| 134 |
|
| 135 |
log("info", "Quick mode generation completed", {
|
| 136 |
-
validCount:
|
| 137 |
rawCount: raw.questions.length,
|
| 138 |
durationMs: Date.now() - start,
|
| 139 |
});
|
| 140 |
|
| 141 |
-
const questions =
|
| 142 |
|
| 143 |
return {
|
| 144 |
questions,
|
| 145 |
meta: {
|
| 146 |
model: input.apiKeyConfig.model,
|
| 147 |
-
tokensUsed,
|
| 148 |
durationMs: Date.now() - start,
|
| 149 |
mode: "quick",
|
| 150 |
},
|
|
|
|
| 2 |
import { GenerationError } from "./errors";
|
| 3 |
import { buildQuickModePrompt } from "./prompts";
|
| 4 |
import { repairAndParseQuestions } from "./repair";
|
| 5 |
+
import { regenerateQuestions, buildRegenerationContext } from "./regenerate-questions";
|
| 6 |
import {
|
| 7 |
type GenerationInput,
|
| 8 |
type GenerationResult,
|
| 9 |
} from "./schemas";
|
| 10 |
|
| 11 |
+
const MAX_QUICK_REGEN_ATTEMPTS = 2;
|
| 12 |
+
|
| 13 |
export interface QuickModeCallbacks {
|
| 14 |
onToken?: (token: string) => void;
|
| 15 |
}
|
|
|
|
| 118 |
) as any;
|
| 119 |
const fallbackPassage = firstWithPassage?.passageText ?? "No passage available.";
|
| 120 |
|
| 121 |
+
let { valid: validQuestions, invalid: pendingInvalid, repairLog } = repairAndParseQuestions(
|
| 122 |
+
raw.questions,
|
| 123 |
+
fallbackPassage,
|
| 124 |
+
{ examType: input.examType },
|
| 125 |
+
);
|
| 126 |
+
|
| 127 |
+
let accumulatedTokens = tokensUsed ?? 0;
|
| 128 |
+
|
| 129 |
+
for (let attempt = 0; attempt < MAX_QUICK_REGEN_ATTEMPTS && pendingInvalid.length > 0; attempt++) {
|
| 130 |
+
log("warn", "Regenerating invalid questions in quick mode", {
|
| 131 |
+
attempt: attempt + 1,
|
| 132 |
+
count: pendingInvalid.length,
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
const context = buildRegenerationContext(pendingInvalid);
|
| 136 |
+
const regen = await regenerateQuestions(
|
| 137 |
+
client,
|
| 138 |
+
input,
|
| 139 |
+
fallbackPassage,
|
| 140 |
+
pendingInvalid.length,
|
| 141 |
+
context,
|
| 142 |
+
callbacks?.onToken,
|
| 143 |
+
);
|
| 144 |
+
accumulatedTokens += regen.tokensUsed ?? 0;
|
| 145 |
+
|
| 146 |
+
const regenResult = repairAndParseQuestions(regen.questions, fallbackPassage, {
|
| 147 |
+
examType: input.examType,
|
| 148 |
+
});
|
| 149 |
+
validQuestions = [...validQuestions, ...regenResult.valid];
|
| 150 |
+
pendingInvalid = regenResult.invalid;
|
| 151 |
+
if (regenResult.repairLog.length > 0) {
|
| 152 |
+
repairLog = [...repairLog, ...regenResult.repairLog.map((l) => `[Regen ${attempt + 1}] ${l}`)];
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
|
| 156 |
if (repairLog.length > 0) {
|
| 157 |
log("warn", "Repairs applied", { count: repairLog.length, details: repairLog });
|
| 158 |
}
|
| 159 |
+
if (pendingInvalid.length > 0) {
|
| 160 |
+
log("warn", "Invalid questions after repair and regeneration", {
|
| 161 |
+
count: pendingInvalid.length,
|
| 162 |
+
details: pendingInvalid.map((i) => ({ index: i.index, errors: i.errors })),
|
| 163 |
});
|
| 164 |
}
|
| 165 |
|
| 166 |
+
if (validQuestions.length === 0) {
|
| 167 |
log("error", "No valid questions after repair", { rawCount: raw.questions.length });
|
| 168 |
+
throw new GenerationError("No valid questions generated", { tokensUsed: accumulatedTokens });
|
| 169 |
}
|
| 170 |
|
| 171 |
log("info", "Quick mode generation completed", {
|
| 172 |
+
validCount: validQuestions.length,
|
| 173 |
rawCount: raw.questions.length,
|
| 174 |
durationMs: Date.now() - start,
|
| 175 |
});
|
| 176 |
|
| 177 |
+
const questions = validQuestions;
|
| 178 |
|
| 179 |
return {
|
| 180 |
questions,
|
| 181 |
meta: {
|
| 182 |
model: input.apiKeyConfig.model,
|
| 183 |
+
tokensUsed: accumulatedTokens,
|
| 184 |
durationMs: Date.now() - start,
|
| 185 |
mode: "quick",
|
| 186 |
},
|
packages/ai/src/prompts.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
import type { GenerationInput } from "./schemas";
|
| 2 |
import { getQuestionJsonSchemaDescription } from "./schema-to-prompt";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export function buildQuickModePrompt(input: GenerationInput): string {
|
| 5 |
const { examType, section, formats, difficulty, topics, questionCount } = input;
|
|
@@ -17,22 +21,24 @@ TOPICS: ${topics.join(", ")}
|
|
| 17 |
FORMATS TO GENERATE: ${formats.join(", ")}
|
| 18 |
|
| 19 |
INSTRUCTIONS:
|
| 20 |
-
|
|
|
|
| 21 |
- For Korean (TOPIK): Focus on particles, honorifics (speech levels), and functional grammar.
|
| 22 |
- For Arabic (TOAFL): Support RTL text. Focus on I'rab (case endings/vowel changes) and grammar.
|
| 23 |
- For Spanish (DELE): Focus on verb conjugation by subject and agreement.
|
| 24 |
-
- For JLPT/TOPIK kanji/hanja: Include reading annotations in format: 漢字(かんじ) for words that have readings.
|
| 25 |
- Passage length should be appropriate for the exam type and difficulty.
|
| 26 |
- Each question must have:
|
| 27 |
* a reading passage (passageText)
|
| 28 |
-
* a clear question prompt (questionText)
|
| 29 |
* a correct answer (correctAnswer)
|
| 30 |
-
* an explanation (explanation) —
|
| 31 |
* difficulty level (${difficulty})
|
| 32 |
* relevant skill tags (skillTags)
|
|
|
|
| 33 |
- Questions should test real comprehension, not just surface-level recall.
|
| 34 |
- For multiple choice: always provide 4 options labeled A, B, C, D.
|
| 35 |
- Options must be plausible distractors — one clearly correct answer.
|
|
|
|
| 36 |
- For matching_pairs: Provide options as an array of {key, text} where key is the left item identifier and text is the left item. correctAnswer should be a serialized mapping like "A:1,B:2,C:3" matching each left key to its right pair.
|
| 37 |
- For error_recognition: options are error segments (A, B, C, D) and correctAnswer is the key of the segment containing an error.
|
| 38 |
- For text_insertion: options are position markers (A, B, C, D) within the passage where a sentence could be inserted. correctAnswer is the best position key.
|
|
|
|
| 1 |
import type { GenerationInput } from "./schemas";
|
| 2 |
import { getQuestionJsonSchemaDescription } from "./schema-to-prompt";
|
| 3 |
+
import { buildContentLanguageRules, buildExplanationLanguageRule } from "./language-rules";
|
| 4 |
+
|
| 5 |
+
export const OPTION_QUALITY_RULES = `- Each option "text" must be meaningful content derived from the passage or question — NEVER use generic labels like "Option A", "Option B", "Pilihan A", "Choice B", or "Placeholder".
|
| 6 |
+
- For multiple choice: always provide at least 4 real answer choices labeled A, B, C, D with plausible distractors.`;
|
| 7 |
|
| 8 |
export function buildQuickModePrompt(input: GenerationInput): string {
|
| 9 |
const { examType, section, formats, difficulty, topics, questionCount } = input;
|
|
|
|
| 21 |
FORMATS TO GENERATE: ${formats.join(", ")}
|
| 22 |
|
| 23 |
INSTRUCTIONS:
|
| 24 |
+
${buildContentLanguageRules(examType)}
|
| 25 |
+
${buildExplanationLanguageRule(examType)}
|
| 26 |
- For Korean (TOPIK): Focus on particles, honorifics (speech levels), and functional grammar.
|
| 27 |
- For Arabic (TOAFL): Support RTL text. Focus on I'rab (case endings/vowel changes) and grammar.
|
| 28 |
- For Spanish (DELE): Focus on verb conjugation by subject and agreement.
|
|
|
|
| 29 |
- Passage length should be appropriate for the exam type and difficulty.
|
| 30 |
- Each question must have:
|
| 31 |
* a reading passage (passageText)
|
| 32 |
+
* a clear question prompt (questionText) — in exam language, NOT Bahasa Indonesia
|
| 33 |
* a correct answer (correctAnswer)
|
| 34 |
+
* an explanation (explanation) — Bahasa Indonesia, boleh sisipkan istilah/kanji/kata ujian bila perlu
|
| 35 |
* difficulty level (${difficulty})
|
| 36 |
* relevant skill tags (skillTags)
|
| 37 |
+
- For JLPT/TOPIK kanji/hanja: Include reading annotations in format: 漢字(かんじ) for words that have readings.
|
| 38 |
- Questions should test real comprehension, not just surface-level recall.
|
| 39 |
- For multiple choice: always provide 4 options labeled A, B, C, D.
|
| 40 |
- Options must be plausible distractors — one clearly correct answer.
|
| 41 |
+
${OPTION_QUALITY_RULES}
|
| 42 |
- For matching_pairs: Provide options as an array of {key, text} where key is the left item identifier and text is the left item. correctAnswer should be a serialized mapping like "A:1,B:2,C:3" matching each left key to its right pair.
|
| 43 |
- For error_recognition: options are error segments (A, B, C, D) and correctAnswer is the key of the segment containing an error.
|
| 44 |
- For text_insertion: options are position markers (A, B, C, D) within the passage where a sentence could be inserted. correctAnswer is the best position key.
|
packages/ai/src/regenerate-questions.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { OpenAICompatibleClient } from "./client";
|
| 2 |
+
import { getGenericQuestionJsonSchemaDescription } from "./repair";
|
| 3 |
+
import { OPTION_QUALITY_RULES } from "./prompts";
|
| 4 |
+
import { buildContentLanguageRules, buildExplanationLanguageRule } from "./language-rules";
|
| 5 |
+
import type { GenerationInput } from "./schemas";
|
| 6 |
+
|
| 7 |
+
function parseJsonResponse(content: string): unknown {
|
| 8 |
+
if (!content) throw new Error("Empty response from AI");
|
| 9 |
+
try {
|
| 10 |
+
return JSON.parse(content);
|
| 11 |
+
} catch {
|
| 12 |
+
const cleaned = content
|
| 13 |
+
.replace(/^```json\s*/, "")
|
| 14 |
+
.replace(/```\s*$/, "")
|
| 15 |
+
.trim();
|
| 16 |
+
return JSON.parse(cleaned);
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function calculateRegenerateMaxTokens(userMax: number, count: number): number {
|
| 21 |
+
const base = userMax > 0 ? userMax : 16_384;
|
| 22 |
+
return Math.min(Math.max(base, 2_000 + count * 600), 64_000);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const SYSTEM_PROMPT =
|
| 26 |
+
"You are a precise exam question generator. You always return valid JSON. You never include markdown formatting around the JSON.";
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Regenerate questions that failed structural or semantic validation.
|
| 30 |
+
*/
|
| 31 |
+
export async function regenerateQuestions(
|
| 32 |
+
client: OpenAICompatibleClient,
|
| 33 |
+
input: GenerationInput,
|
| 34 |
+
passage: string,
|
| 35 |
+
count: number,
|
| 36 |
+
context: string,
|
| 37 |
+
onToken?: (token: string) => void,
|
| 38 |
+
): Promise<{ questions: Array<Record<string, unknown>>; tokensUsed: number }> {
|
| 39 |
+
const schema = getGenericQuestionJsonSchemaDescription();
|
| 40 |
+
|
| 41 |
+
const prompt = `You are an expert exam question writer. ${context}
|
| 42 |
+
|
| 43 |
+
Passage:
|
| 44 |
+
"""
|
| 45 |
+
${passage}
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
Generate ${count} new reading comprehension questions for ${input.examType} exam.
|
| 49 |
+
Formats: ${input.formats.join(", ")}
|
| 50 |
+
Difficulty: ${input.difficulty}/5
|
| 51 |
+
|
| 52 |
+
Rules:
|
| 53 |
+
- Each question must be directly answerable from the passage
|
| 54 |
+
- Use "passageText" field with relevant excerpt (or full passage)
|
| 55 |
+
${OPTION_QUALITY_RULES}
|
| 56 |
+
${buildContentLanguageRules(input.examType)}
|
| 57 |
+
${buildExplanationLanguageRule(input.examType)}
|
| 58 |
+
- For true_false_not_given: correctAnswer must be TRUE, FALSE, or NOT_GIVEN (uppercase)
|
| 59 |
+
- For author_view: correctAnswer must be YES, NO, or NOT_GIVEN (uppercase)
|
| 60 |
+
- For matching_pairs: options are {key, text} pairs. correctAnswer is serialized mapping.
|
| 61 |
+
- For error_recognition: options are error segments. correctAnswer is key of segment with error.
|
| 62 |
+
- For text_insertion: options are position markers. correctAnswer is best position key.
|
| 63 |
+
|
| 64 |
+
Question schema:
|
| 65 |
+
${schema}
|
| 66 |
+
|
| 67 |
+
Return ONLY valid JSON conforming to this schema.`;
|
| 68 |
+
|
| 69 |
+
const result = await client.chatCompletion(
|
| 70 |
+
{
|
| 71 |
+
model: input.apiKeyConfig.model,
|
| 72 |
+
messages: [
|
| 73 |
+
{ role: "system", content: SYSTEM_PROMPT },
|
| 74 |
+
{ role: "user", content: prompt },
|
| 75 |
+
],
|
| 76 |
+
temperature: 0.7,
|
| 77 |
+
max_tokens: calculateRegenerateMaxTokens(input.apiKeyConfig.maxTokens, count),
|
| 78 |
+
response_format: { type: "json_object" },
|
| 79 |
+
},
|
| 80 |
+
onToken ? { onToken } : undefined,
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 84 |
+
if (!Array.isArray(parsed.questions)) {
|
| 85 |
+
throw new Error("Missing questions array in regeneration response");
|
| 86 |
+
}
|
| 87 |
+
return {
|
| 88 |
+
questions: parsed.questions as Array<Record<string, unknown>>,
|
| 89 |
+
tokensUsed: result.usage?.total_tokens ?? 0,
|
| 90 |
+
};
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export function buildRegenerationContext(
|
| 94 |
+
invalid: Array<{ index: number; errors: string[] }>,
|
| 95 |
+
): string {
|
| 96 |
+
const details = invalid
|
| 97 |
+
.map((item) => `Q${item.index + 1}: ${item.errors.join(", ")}`)
|
| 98 |
+
.join("; ");
|
| 99 |
+
return `The previous ${invalid.length} question(s) failed validation: ${details}.`;
|
| 100 |
+
}
|
packages/ai/src/repair.ts
CHANGED
|
@@ -5,6 +5,10 @@ import {
|
|
| 5 |
questionSchema,
|
| 6 |
type Question,
|
| 7 |
} from "./schemas";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
// ── Generic Question Schema (for AI prompt) ─────────────────
|
| 10 |
// Simplified schema that AI can understand easily.
|
|
@@ -43,6 +47,58 @@ const FORMATS_WITH_OPTIONS = new Set([
|
|
| 43 |
"sentence_arrangement",
|
| 44 |
]);
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
function normalizeOptionKey(key: string): string {
|
| 47 |
return key.trim().toUpperCase();
|
| 48 |
}
|
|
@@ -58,26 +114,10 @@ function ensureOptions(
|
|
| 58 |
|
| 59 |
let opts = q.options;
|
| 60 |
if (!Array.isArray(opts) || opts.length === 0) {
|
| 61 |
-
|
| 62 |
-
// We'll mark them for later regeneration if needed
|
| 63 |
-
if (q.format === "multiple_choice" || q.format === "synonym" || q.format === "grammar_in_context" ||
|
| 64 |
-
q.format === "sentence_completion" || q.format === "reference" || q.format === "kanji_reading" ||
|
| 65 |
-
q.format === "particle_choice" || q.format === "article_case" || q.format === "character_reading" ||
|
| 66 |
-
q.format === "sentence_arrangement" || q.format === "error_recognition" || q.format === "text_insertion") {
|
| 67 |
-
return [
|
| 68 |
-
{ key: "A", text: "Option A" },
|
| 69 |
-
{ key: "B", text: "Option B" },
|
| 70 |
-
{ key: "C", text: "Option C" },
|
| 71 |
-
{ key: "D", text: "Option D" },
|
| 72 |
-
];
|
| 73 |
-
}
|
| 74 |
-
if (q.format === "matching_headings" || q.format === "matching_information" ||
|
| 75 |
-
q.format === "summary_completion" || q.format === "cloze") {
|
| 76 |
-
return [{ key: "1", text: "Placeholder option" }];
|
| 77 |
-
}
|
| 78 |
}
|
| 79 |
|
| 80 |
-
opts = opts
|
| 81 |
key: normalizeOptionKey(o.key),
|
| 82 |
text: normalizeOptionText(o.text),
|
| 83 |
}));
|
|
@@ -158,10 +198,13 @@ function ensureExplanation(q: GenericQuestion): string {
|
|
| 158 |
return "Penjelasan tidak tersedia.";
|
| 159 |
}
|
| 160 |
|
| 161 |
-
function ensureQuestionText(q: GenericQuestion): string {
|
| 162 |
const text = q.questionText?.trim();
|
| 163 |
if (text && text.length >= 10) return text;
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
| 165 |
}
|
| 166 |
|
| 167 |
function ensureDifficulty(q: GenericQuestion): number {
|
|
@@ -176,6 +219,7 @@ function ensureDifficulty(q: GenericQuestion): number {
|
|
| 176 |
export function repairQuestion(
|
| 177 |
raw: unknown,
|
| 178 |
fullPassage: string,
|
|
|
|
| 179 |
): { question: GenericQuestion; wasRepaired: boolean; repairNotes: string[] } {
|
| 180 |
const notes: string[] = [];
|
| 181 |
let wasRepaired = false;
|
|
@@ -190,7 +234,7 @@ export function repairQuestion(
|
|
| 190 |
const q: GenericQuestion = {
|
| 191 |
format: String(r.format || "multiple_choice").trim().toLowerCase() as any,
|
| 192 |
passageText: ensurePassageText(r as GenericQuestion, fullPassage),
|
| 193 |
-
questionText: ensureQuestionText(r as GenericQuestion),
|
| 194 |
options: Array.isArray(r.options)
|
| 195 |
? r.options
|
| 196 |
.filter((o: any) => o && typeof o === "object")
|
|
@@ -215,8 +259,8 @@ export function repairQuestion(
|
|
| 215 |
if (explanationText.trim().length === 0) {
|
| 216 |
notes.push("explanation missing, used fallback");
|
| 217 |
wasRepaired = true;
|
| 218 |
-
} else if (
|
| 219 |
-
notes.push("explanation
|
| 220 |
wasRepaired = true;
|
| 221 |
}
|
| 222 |
if (!Array.isArray(r.skillTags) || r.skillTags.length === 0) {
|
|
@@ -232,14 +276,12 @@ export function repairQuestion(
|
|
| 232 |
wasRepaired = true;
|
| 233 |
}
|
| 234 |
|
| 235 |
-
//
|
| 236 |
const repairedOptions = ensureOptions(q);
|
| 237 |
if (repairedOptions !== undefined) {
|
| 238 |
-
if (!q.options || q.options.length === 0) {
|
| 239 |
-
notes.push("options missing, injected placeholders");
|
| 240 |
-
wasRepaired = true;
|
| 241 |
-
}
|
| 242 |
q.options = repairedOptions;
|
|
|
|
|
|
|
| 243 |
}
|
| 244 |
|
| 245 |
return { question: q, wasRepaired, repairNotes: notes };
|
|
@@ -263,6 +305,7 @@ export function tryParseQuestion(generic: GenericQuestion): Question | null {
|
|
| 263 |
export function repairAndParseQuestions(
|
| 264 |
rawQuestions: unknown[],
|
| 265 |
fullPassage: string,
|
|
|
|
| 266 |
): {
|
| 267 |
valid: Question[];
|
| 268 |
invalid: { index: number; raw: unknown; errors: string[] }[];
|
|
@@ -272,13 +315,27 @@ export function repairAndParseQuestions(
|
|
| 272 |
const invalid: { index: number; raw: unknown; errors: string[] }[] = [];
|
| 273 |
const repairLog: string[] = [];
|
| 274 |
|
|
|
|
|
|
|
| 275 |
for (let i = 0; i < rawQuestions.length; i++) {
|
| 276 |
const raw = rawQuestions[i];
|
| 277 |
try {
|
| 278 |
-
const { question: repaired, wasRepaired, repairNotes } = repairQuestion(raw, fullPassage);
|
| 279 |
if (wasRepaired) {
|
| 280 |
repairLog.push(`Q${i + 1}: ${repairNotes.join("; ")}`);
|
| 281 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
const parsed = tryParseQuestion(repaired);
|
| 283 |
if (parsed) {
|
| 284 |
valid.push(parsed);
|
|
@@ -349,7 +406,7 @@ export function getGenericQuestionJsonSchemaDescription(): string {
|
|
| 349 |
type: "object",
|
| 350 |
properties: {
|
| 351 |
key: { type: "string", description: "Option identifier (e.g. A, B, C, D)" },
|
| 352 |
-
text: { type: "string", description: "
|
| 353 |
},
|
| 354 |
required: ["key", "text"],
|
| 355 |
},
|
|
@@ -358,7 +415,7 @@ export function getGenericQuestionJsonSchemaDescription(): string {
|
|
| 358 |
type: "string",
|
| 359 |
description: "For true_false_not_given use TRUE/FALSE/NOT_GIVEN. For author_view use YES/NO/NOT_GIVEN. For multiple choice use the option key (e.g. A).",
|
| 360 |
},
|
| 361 |
-
explanation: { type: "string", description: "Explanation in
|
| 362 |
difficulty: { type: "integer", minimum: 1, maximum: 5 },
|
| 363 |
skillTags: { type: "array", items: { type: "string" } },
|
| 364 |
},
|
|
|
|
| 5 |
questionSchema,
|
| 6 |
type Question,
|
| 7 |
} from "./schemas";
|
| 8 |
+
import {
|
| 9 |
+
getQuestionLanguageErrors,
|
| 10 |
+
getExplanationLanguageErrors,
|
| 11 |
+
} from "./language-rules";
|
| 12 |
|
| 13 |
// ── Generic Question Schema (for AI prompt) ─────────────────
|
| 14 |
// Simplified schema that AI can understand easily.
|
|
|
|
| 47 |
"sentence_arrangement",
|
| 48 |
]);
|
| 49 |
|
| 50 |
+
const FORMATS_MIN_TWO_OPTIONS = new Set([
|
| 51 |
+
"multiple_choice",
|
| 52 |
+
"synonym",
|
| 53 |
+
"grammar_in_context",
|
| 54 |
+
"sentence_completion",
|
| 55 |
+
"reference",
|
| 56 |
+
"kanji_reading",
|
| 57 |
+
"particle_choice",
|
| 58 |
+
"article_case",
|
| 59 |
+
"character_reading",
|
| 60 |
+
"sentence_arrangement",
|
| 61 |
+
"error_recognition",
|
| 62 |
+
"text_insertion",
|
| 63 |
+
"summary_completion",
|
| 64 |
+
"cloze",
|
| 65 |
+
"matching_headings",
|
| 66 |
+
"matching_information",
|
| 67 |
+
]);
|
| 68 |
+
|
| 69 |
+
/** Detect generic placeholder option text from lazy AI output or repair fallbacks. */
|
| 70 |
+
export function isGenericOptionText(text: string): boolean {
|
| 71 |
+
const t = text.trim();
|
| 72 |
+
if (!t) return true;
|
| 73 |
+
if (/^placeholder/i.test(t)) return true;
|
| 74 |
+
if (/^(option|pilihan|choice|opsi)(\s+[a-d0-9]+)?\.?$/i.test(t)) return true;
|
| 75 |
+
return false;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/** Semantic quality checks beyond Zod structural validation. */
|
| 79 |
+
export function getQuestionSemanticErrors(q: GenericQuestion): string[] {
|
| 80 |
+
if (!FORMATS_WITH_OPTIONS.has(q.format)) return [];
|
| 81 |
+
|
| 82 |
+
const errors: string[] = [];
|
| 83 |
+
if (!Array.isArray(q.options) || q.options.length === 0) {
|
| 84 |
+
errors.push("options missing or empty");
|
| 85 |
+
return errors;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const minOptions = FORMATS_MIN_TWO_OPTIONS.has(q.format) ? 2 : 1;
|
| 89 |
+
if (q.options.length < minOptions) {
|
| 90 |
+
errors.push(`options must have at least ${minOptions} item(s)`);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
for (const opt of q.options) {
|
| 94 |
+
if (isGenericOptionText(opt.text)) {
|
| 95 |
+
errors.push(`generic placeholder option text: "${opt.text}"`);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return errors;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
function normalizeOptionKey(key: string): string {
|
| 103 |
return key.trim().toUpperCase();
|
| 104 |
}
|
|
|
|
| 114 |
|
| 115 |
let opts = q.options;
|
| 116 |
if (!Array.isArray(opts) || opts.length === 0) {
|
| 117 |
+
return undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
+
opts = opts.map((o) => ({
|
| 121 |
key: normalizeOptionKey(o.key),
|
| 122 |
text: normalizeOptionText(o.text),
|
| 123 |
}));
|
|
|
|
| 198 |
return "Penjelasan tidak tersedia.";
|
| 199 |
}
|
| 200 |
|
| 201 |
+
function ensureQuestionText(q: GenericQuestion, examType?: string): string {
|
| 202 |
const text = q.questionText?.trim();
|
| 203 |
if (text && text.length >= 10) return text;
|
| 204 |
+
if (examType === "JLPT") return "この文章の内容について正しいものはどれですか。";
|
| 205 |
+
if (examType === "HSK") return "根据短文,下列哪项正确?";
|
| 206 |
+
if (examType === "TOPIK") return "글의 내용과 일치하는 것은 무엇입니까?";
|
| 207 |
+
return text || "What is the correct answer based on the passage?";
|
| 208 |
}
|
| 209 |
|
| 210 |
function ensureDifficulty(q: GenericQuestion): number {
|
|
|
|
| 219 |
export function repairQuestion(
|
| 220 |
raw: unknown,
|
| 221 |
fullPassage: string,
|
| 222 |
+
examType?: string,
|
| 223 |
): { question: GenericQuestion; wasRepaired: boolean; repairNotes: string[] } {
|
| 224 |
const notes: string[] = [];
|
| 225 |
let wasRepaired = false;
|
|
|
|
| 234 |
const q: GenericQuestion = {
|
| 235 |
format: String(r.format || "multiple_choice").trim().toLowerCase() as any,
|
| 236 |
passageText: ensurePassageText(r as GenericQuestion, fullPassage),
|
| 237 |
+
questionText: ensureQuestionText(r as GenericQuestion, examType),
|
| 238 |
options: Array.isArray(r.options)
|
| 239 |
? r.options
|
| 240 |
.filter((o: any) => o && typeof o === "object")
|
|
|
|
| 259 |
if (explanationText.trim().length === 0) {
|
| 260 |
notes.push("explanation missing, used fallback");
|
| 261 |
wasRepaired = true;
|
| 262 |
+
} else if (getExplanationLanguageErrors(explanationText).length > 0) {
|
| 263 |
+
notes.push("explanation not in Bahasa Indonesia, marked for regeneration");
|
| 264 |
wasRepaired = true;
|
| 265 |
}
|
| 266 |
if (!Array.isArray(r.skillTags) || r.skillTags.length === 0) {
|
|
|
|
| 276 |
wasRepaired = true;
|
| 277 |
}
|
| 278 |
|
| 279 |
+
// Normalize options (no placeholder injection — missing options fail semantic validation)
|
| 280 |
const repairedOptions = ensureOptions(q);
|
| 281 |
if (repairedOptions !== undefined) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
q.options = repairedOptions;
|
| 283 |
+
} else if (FORMATS_WITH_OPTIONS.has(q.format)) {
|
| 284 |
+
q.options = undefined;
|
| 285 |
}
|
| 286 |
|
| 287 |
return { question: q, wasRepaired, repairNotes: notes };
|
|
|
|
| 305 |
export function repairAndParseQuestions(
|
| 306 |
rawQuestions: unknown[],
|
| 307 |
fullPassage: string,
|
| 308 |
+
options?: { examType?: string },
|
| 309 |
): {
|
| 310 |
valid: Question[];
|
| 311 |
invalid: { index: number; raw: unknown; errors: string[] }[];
|
|
|
|
| 315 |
const invalid: { index: number; raw: unknown; errors: string[] }[] = [];
|
| 316 |
const repairLog: string[] = [];
|
| 317 |
|
| 318 |
+
const examType = options?.examType;
|
| 319 |
+
|
| 320 |
for (let i = 0; i < rawQuestions.length; i++) {
|
| 321 |
const raw = rawQuestions[i];
|
| 322 |
try {
|
| 323 |
+
const { question: repaired, wasRepaired, repairNotes } = repairQuestion(raw, fullPassage, examType);
|
| 324 |
if (wasRepaired) {
|
| 325 |
repairLog.push(`Q${i + 1}: ${repairNotes.join("; ")}`);
|
| 326 |
}
|
| 327 |
+
|
| 328 |
+
const semanticErrors = [
|
| 329 |
+
...getQuestionSemanticErrors(repaired),
|
| 330 |
+
...(examType ? getQuestionLanguageErrors(repaired, examType) : []),
|
| 331 |
+
...getExplanationLanguageErrors(repaired.explanation),
|
| 332 |
+
];
|
| 333 |
+
if (semanticErrors.length > 0) {
|
| 334 |
+
invalid.push({ index: i, raw, errors: semanticErrors });
|
| 335 |
+
repairLog.push(`Q${i + 1}: semantic quality failure — ${semanticErrors.join(", ")}`);
|
| 336 |
+
continue;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
const parsed = tryParseQuestion(repaired);
|
| 340 |
if (parsed) {
|
| 341 |
valid.push(parsed);
|
|
|
|
| 406 |
type: "object",
|
| 407 |
properties: {
|
| 408 |
key: { type: "string", description: "Option identifier (e.g. A, B, C, D)" },
|
| 409 |
+
text: { type: "string", description: "Meaningful answer text from the passage — never generic labels like 'Option A'" },
|
| 410 |
},
|
| 411 |
required: ["key", "text"],
|
| 412 |
},
|
|
|
|
| 415 |
type: "string",
|
| 416 |
description: "For true_false_not_given use TRUE/FALSE/NOT_GIVEN. For author_view use YES/NO/NOT_GIVEN. For multiple choice use the option key (e.g. A).",
|
| 417 |
},
|
| 418 |
+
explanation: { type: "string", description: "Explanation in Bahasa Indonesia; may include exam-language terms/kanji when relevant" },
|
| 419 |
difficulty: { type: "integer", minimum: 1, maximum: 5 },
|
| 420 |
skillTags: { type: "array", items: { type: "string" } },
|
| 421 |
},
|
packages/api/src/queue.ts
CHANGED
|
@@ -9,7 +9,10 @@ async function log(level: "debug" | "warn", message: string, meta?: Record<strin
|
|
| 9 |
import {
|
| 10 |
generateQuestionsQuick,
|
| 11 |
generateQuestionsAgentic,
|
|
|
|
|
|
|
| 12 |
GenerationError,
|
|
|
|
| 13 |
type GenerationInput,
|
| 14 |
type GenerationResult,
|
| 15 |
} from "@labas/ai";
|
|
@@ -83,6 +86,74 @@ export class GenerationJobCancelledError extends Error {
|
|
| 83 |
}
|
| 84 |
}
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
export function computeSectionSplit(
|
| 87 |
selectedSections: string[],
|
| 88 |
count: number,
|
|
@@ -532,6 +603,7 @@ export const generationWorker = new Worker<FastJobData>(
|
|
| 532 |
.where(eq(generationJob.id, jobId));
|
| 533 |
};
|
| 534 |
|
|
|
|
| 535 |
const updateProgress = async (
|
| 536 |
progress: number,
|
| 537 |
progressMessage: string,
|
|
@@ -539,11 +611,13 @@ export const generationWorker = new Worker<FastJobData>(
|
|
| 539 |
resultJson?: unknown,
|
| 540 |
) => {
|
| 541 |
cancelPoll.check();
|
| 542 |
-
|
|
|
|
|
|
|
| 543 |
const [updated] = await db
|
| 544 |
.update(generationJob)
|
| 545 |
.set({
|
| 546 |
-
progress,
|
| 547 |
progressMessage,
|
| 548 |
...(status ? { status } : {}),
|
| 549 |
...(resultJson !== undefined ? { resultJson: resultJson as any } : {}),
|
|
@@ -590,6 +664,29 @@ export const generationWorker = new Worker<FastJobData>(
|
|
| 590 |
let completedShards = 0;
|
| 591 |
let partialPublished = false;
|
| 592 |
let timeToFirstValidQuestionMs: number | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
const shardResults: Array<{
|
| 594 |
shard: ShardPlan;
|
| 595 |
questions: PersistableQuestion[];
|
|
@@ -610,14 +707,30 @@ export const generationWorker = new Worker<FastJobData>(
|
|
| 610 |
};
|
| 611 |
|
| 612 |
let sectionResult: GenerationResult;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
try {
|
| 614 |
if (selectedMode === "agentic") {
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
} else {
|
| 622 |
sectionResult = await generateQuestionsQuick(subInput, {
|
| 623 |
onToken: tokenCounter,
|
|
@@ -637,9 +750,9 @@ export const generationWorker = new Worker<FastJobData>(
|
|
| 637 |
|
| 638 |
sectionResult = await generateQuestionsAgentic(
|
| 639 |
{ ...subInput, mode: "agentic" },
|
| 640 |
-
|
| 641 |
tokenCounter,
|
| 642 |
-
{ strategy: "
|
| 643 |
);
|
| 644 |
}
|
| 645 |
|
|
@@ -780,43 +893,24 @@ export const generationWorker = new Worker<FastJobData>(
|
|
| 780 |
};
|
| 781 |
|
| 782 |
if (selectedMode === "agentic") {
|
| 783 |
-
await
|
| 784 |
-
|
|
|
|
|
|
|
|
|
|
| 785 |
sectionSplits,
|
| 786 |
-
|
| 787 |
-
|
|
|
|
| 788 |
metrics: {
|
| 789 |
timeToFirstValidQuestionMs: timeToFirstValidQuestionMs ?? Date.now() - start,
|
| 790 |
shardCount: shards.length,
|
| 791 |
shardRetryBudget: MAX_SHARD_RETRIES,
|
|
|
|
|
|
|
| 792 |
},
|
| 793 |
});
|
| 794 |
-
await pushLog(
|
| 795 |
-
"quality_queue",
|
| 796 |
-
"Partial result ready, lanjut quality upgrade di background",
|
| 797 |
-
"done",
|
| 798 |
-
);
|
| 799 |
-
await generationQualityQueue.add(
|
| 800 |
-
"quality-upgrade",
|
| 801 |
-
{
|
| 802 |
-
input,
|
| 803 |
-
jobId,
|
| 804 |
-
sectionSplits,
|
| 805 |
-
fastQuestions: allQuestions,
|
| 806 |
-
fastMeta: {
|
| 807 |
-
tokensUsed: totalTokens,
|
| 808 |
-
durationMs: totalDurationMs || Date.now() - start,
|
| 809 |
-
approxTokens,
|
| 810 |
-
},
|
| 811 |
-
},
|
| 812 |
-
{
|
| 813 |
-
jobId,
|
| 814 |
-
removeOnComplete: { count: 100 },
|
| 815 |
-
removeOnFail: { count: 100 },
|
| 816 |
-
attempts: 2,
|
| 817 |
-
backoff: { type: "exponential", delay: 4000 },
|
| 818 |
-
},
|
| 819 |
-
);
|
| 820 |
return;
|
| 821 |
}
|
| 822 |
|
|
@@ -902,6 +996,7 @@ export const generationQualityWorker = new Worker<QualityJobData>(
|
|
| 902 |
|
| 903 |
const cancelPoll = createCancellationPoller(jobId);
|
| 904 |
let approxTokens = 0;
|
|
|
|
| 905 |
const tokenCounter = (token: string) => {
|
| 906 |
cancelPoll.check();
|
| 907 |
approxTokens += Math.ceil(token.length / 4);
|
|
@@ -909,12 +1004,14 @@ export const generationQualityWorker = new Worker<QualityJobData>(
|
|
| 909 |
|
| 910 |
const updateProgress = async (progress: number, progressMessage: string) => {
|
| 911 |
cancelPoll.check();
|
| 912 |
-
|
|
|
|
|
|
|
| 913 |
const [updated] = await db
|
| 914 |
.update(generationJob)
|
| 915 |
.set({
|
| 916 |
status: "running_quality",
|
| 917 |
-
progress,
|
| 918 |
progressMessage,
|
| 919 |
})
|
| 920 |
.where(
|
|
@@ -961,9 +1058,14 @@ export const generationQualityWorker = new Worker<QualityJobData>(
|
|
| 961 |
questionCount: split.count,
|
| 962 |
mode: "agentic",
|
| 963 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 964 |
const result = await generateQuestionsAgentic(
|
| 965 |
sectionInput,
|
| 966 |
-
|
| 967 |
tokenCounter,
|
| 968 |
{ strategy: "full", maxRegenerateAttempts: 2 },
|
| 969 |
);
|
|
|
|
| 9 |
import {
|
| 10 |
generateQuestionsQuick,
|
| 11 |
generateQuestionsAgentic,
|
| 12 |
+
generatePassageForInput,
|
| 13 |
+
generateQuestionsAgenticFromPassage,
|
| 14 |
GenerationError,
|
| 15 |
+
type AgenticProgress,
|
| 16 |
type GenerationInput,
|
| 17 |
type GenerationResult,
|
| 18 |
} from "@labas/ai";
|
|
|
|
| 86 |
}
|
| 87 |
}
|
| 88 |
|
| 89 |
+
const AGENTIC_STEP_FRACTIONS = [0.12, 0.28, 0.62, 0.92] as const;
|
| 90 |
+
|
| 91 |
+
const AGENTIC_STEP_LABELS: Record<string, string> = {
|
| 92 |
+
generate_passage: "Menulis bacaan...",
|
| 93 |
+
validate_passage: "Memvalidasi bacaan...",
|
| 94 |
+
generate_questions: "Membuat soal...",
|
| 95 |
+
self_validate: "Validasi kualitas...",
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
function mapAgenticProgressInShard(
|
| 99 |
+
agentic: AgenticProgress,
|
| 100 |
+
completedShards: number,
|
| 101 |
+
totalShards: number,
|
| 102 |
+
): { progress: number; message: string } {
|
| 103 |
+
const shardSpan = 65 / Math.max(totalShards, 1);
|
| 104 |
+
const shardBase = 15 + completedShards * shardSpan;
|
| 105 |
+
const stepFrac = AGENTIC_STEP_FRACTIONS[agentic.currentStep] ?? 0.5;
|
| 106 |
+
const progress = Math.min(Math.round(shardBase + shardSpan * stepFrac), 84);
|
| 107 |
+
const step = agentic.steps[agentic.currentStep];
|
| 108 |
+
const message =
|
| 109 |
+
step?.message ??
|
| 110 |
+
AGENTIC_STEP_LABELS[step?.step ?? ""] ??
|
| 111 |
+
"Agentic generation...";
|
| 112 |
+
return { progress, message };
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function mapAgenticProgressInQuality(agentic: AgenticProgress): { progress: number; message: string } {
|
| 116 |
+
const stepFrac = AGENTIC_STEP_FRACTIONS[agentic.currentStep] ?? 0.5;
|
| 117 |
+
const progress = Math.min(86 + Math.round(stepFrac * 10), 96);
|
| 118 |
+
const step = agentic.steps[agentic.currentStep];
|
| 119 |
+
const message =
|
| 120 |
+
step?.message ??
|
| 121 |
+
AGENTIC_STEP_LABELS[step?.step ?? ""] ??
|
| 122 |
+
"Quality validation...";
|
| 123 |
+
return { progress, message };
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function createAgenticProgressHandler(
|
| 127 |
+
updateProgress: (progress: number, progressMessage: string) => Promise<void>,
|
| 128 |
+
pushLog: (
|
| 129 |
+
step: string,
|
| 130 |
+
message: string,
|
| 131 |
+
status: "running" | "done" | "error",
|
| 132 |
+
details?: string,
|
| 133 |
+
) => Promise<void>,
|
| 134 |
+
mapProgress: (agentic: AgenticProgress) => { progress: number; message: string },
|
| 135 |
+
) {
|
| 136 |
+
const seenSteps = new Set<string>();
|
| 137 |
+
|
| 138 |
+
return (agentic: AgenticProgress) => {
|
| 139 |
+
const { progress, message } = mapProgress(agentic);
|
| 140 |
+
void updateProgress(progress, message).catch(() => {});
|
| 141 |
+
|
| 142 |
+
for (const step of agentic.steps) {
|
| 143 |
+
if (!step || step.status === "pending") continue;
|
| 144 |
+
const key = `${step.step}:${step.status}`;
|
| 145 |
+
if (seenSteps.has(key)) continue;
|
| 146 |
+
seenSteps.add(key);
|
| 147 |
+
void pushLog(
|
| 148 |
+
step.step,
|
| 149 |
+
step.message ?? AGENTIC_STEP_LABELS[step.step] ?? step.step,
|
| 150 |
+
step.status as "running" | "done" | "error",
|
| 151 |
+
step.output,
|
| 152 |
+
).catch(() => {});
|
| 153 |
+
}
|
| 154 |
+
};
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
export function computeSectionSplit(
|
| 158 |
selectedSections: string[],
|
| 159 |
count: number,
|
|
|
|
| 603 |
.where(eq(generationJob.id, jobId));
|
| 604 |
};
|
| 605 |
|
| 606 |
+
let maxProgress = 0;
|
| 607 |
const updateProgress = async (
|
| 608 |
progress: number,
|
| 609 |
progressMessage: string,
|
|
|
|
| 611 |
resultJson?: unknown,
|
| 612 |
) => {
|
| 613 |
cancelPoll.check();
|
| 614 |
+
const next = Math.max(maxProgress, progress);
|
| 615 |
+
maxProgress = next;
|
| 616 |
+
await job.updateProgress(next);
|
| 617 |
const [updated] = await db
|
| 618 |
.update(generationJob)
|
| 619 |
.set({
|
| 620 |
+
progress: next,
|
| 621 |
progressMessage,
|
| 622 |
...(status ? { status } : {}),
|
| 623 |
...(resultJson !== undefined ? { resultJson: resultJson as any } : {}),
|
|
|
|
| 664 |
let completedShards = 0;
|
| 665 |
let partialPublished = false;
|
| 666 |
let timeToFirstValidQuestionMs: number | null = null;
|
| 667 |
+
const sectionPassages = new Map<string, string>();
|
| 668 |
+
|
| 669 |
+
if (selectedMode === "agentic") {
|
| 670 |
+
for (const split of sectionSplits) {
|
| 671 |
+
if (sectionPassages.has(split.section)) continue;
|
| 672 |
+
await updateProgress(8, `Menulis bacaan ${split.section}...`);
|
| 673 |
+
const passageInput: GenerationInput = {
|
| 674 |
+
...input,
|
| 675 |
+
section: split.section as GenerationInput["section"],
|
| 676 |
+
questionCount: split.count,
|
| 677 |
+
};
|
| 678 |
+
const passageResult = await generatePassageForInput(passageInput, tokenCounter);
|
| 679 |
+
totalTokens += passageResult.tokensUsed ?? 0;
|
| 680 |
+
sectionPassages.set(split.section, passageResult.passage);
|
| 681 |
+
await pushLog(
|
| 682 |
+
"generate_passage",
|
| 683 |
+
`Bacaan siap: ${passageResult.title}`,
|
| 684 |
+
"done",
|
| 685 |
+
passageResult.passage.slice(0, 400),
|
| 686 |
+
);
|
| 687 |
+
}
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
const shardResults: Array<{
|
| 691 |
shard: ShardPlan;
|
| 692 |
questions: PersistableQuestion[];
|
|
|
|
| 707 |
};
|
| 708 |
|
| 709 |
let sectionResult: GenerationResult;
|
| 710 |
+
const onAgenticProgress = createAgenticProgressHandler(
|
| 711 |
+
(progress, progressMessage) => updateProgress(progress, progressMessage),
|
| 712 |
+
pushLog,
|
| 713 |
+
(agentic) => mapAgenticProgressInShard(agentic, completedShards, shards.length),
|
| 714 |
+
);
|
| 715 |
try {
|
| 716 |
if (selectedMode === "agentic") {
|
| 717 |
+
const sharedPassage = sectionPassages.get(shard.section);
|
| 718 |
+
if (sharedPassage) {
|
| 719 |
+
sectionResult = await generateQuestionsAgenticFromPassage(
|
| 720 |
+
subInput,
|
| 721 |
+
sharedPassage,
|
| 722 |
+
onAgenticProgress,
|
| 723 |
+
tokenCounter,
|
| 724 |
+
{ strategy: "full", maxRegenerateAttempts: 1 },
|
| 725 |
+
);
|
| 726 |
+
} else {
|
| 727 |
+
sectionResult = await generateQuestionsAgentic(
|
| 728 |
+
subInput,
|
| 729 |
+
onAgenticProgress,
|
| 730 |
+
tokenCounter,
|
| 731 |
+
{ strategy: "full", maxRegenerateAttempts: 1 },
|
| 732 |
+
);
|
| 733 |
+
}
|
| 734 |
} else {
|
| 735 |
sectionResult = await generateQuestionsQuick(subInput, {
|
| 736 |
onToken: tokenCounter,
|
|
|
|
| 750 |
|
| 751 |
sectionResult = await generateQuestionsAgentic(
|
| 752 |
{ ...subInput, mode: "agentic" },
|
| 753 |
+
onAgenticProgress,
|
| 754 |
tokenCounter,
|
| 755 |
+
{ strategy: "full", maxRegenerateAttempts: 1 },
|
| 756 |
);
|
| 757 |
}
|
| 758 |
|
|
|
|
| 893 |
};
|
| 894 |
|
| 895 |
if (selectedMode === "agentic") {
|
| 896 |
+
await pushLog("save", "Saving agentic result...", "running");
|
| 897 |
+
await completeJobWithResult({
|
| 898 |
+
jobId,
|
| 899 |
+
input,
|
| 900 |
+
allQuestions,
|
| 901 |
sectionSplits,
|
| 902 |
+
totalTokens: totalTokens || approxTokens,
|
| 903 |
+
durationMs: Date.now() - start,
|
| 904 |
+
statusMessage: "Completed",
|
| 905 |
metrics: {
|
| 906 |
timeToFirstValidQuestionMs: timeToFirstValidQuestionMs ?? Date.now() - start,
|
| 907 |
shardCount: shards.length,
|
| 908 |
shardRetryBudget: MAX_SHARD_RETRIES,
|
| 909 |
+
shardFailures: failedShards.length,
|
| 910 |
+
singlePassAgentic: true,
|
| 911 |
},
|
| 912 |
});
|
| 913 |
+
await pushLog("save", `Saved ${allQuestions.length} questions`, "done");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 914 |
return;
|
| 915 |
}
|
| 916 |
|
|
|
|
| 996 |
|
| 997 |
const cancelPoll = createCancellationPoller(jobId);
|
| 998 |
let approxTokens = 0;
|
| 999 |
+
let maxProgress = 0;
|
| 1000 |
const tokenCounter = (token: string) => {
|
| 1001 |
cancelPoll.check();
|
| 1002 |
approxTokens += Math.ceil(token.length / 4);
|
|
|
|
| 1004 |
|
| 1005 |
const updateProgress = async (progress: number, progressMessage: string) => {
|
| 1006 |
cancelPoll.check();
|
| 1007 |
+
const next = Math.max(maxProgress, progress);
|
| 1008 |
+
maxProgress = next;
|
| 1009 |
+
await job.updateProgress(next);
|
| 1010 |
const [updated] = await db
|
| 1011 |
.update(generationJob)
|
| 1012 |
.set({
|
| 1013 |
status: "running_quality",
|
| 1014 |
+
progress: next,
|
| 1015 |
progressMessage,
|
| 1016 |
})
|
| 1017 |
.where(
|
|
|
|
| 1058 |
questionCount: split.count,
|
| 1059 |
mode: "agentic",
|
| 1060 |
};
|
| 1061 |
+
const onQualityProgress = createAgenticProgressHandler(
|
| 1062 |
+
updateProgress,
|
| 1063 |
+
pushLog,
|
| 1064 |
+
mapAgenticProgressInQuality,
|
| 1065 |
+
);
|
| 1066 |
const result = await generateQuestionsAgentic(
|
| 1067 |
sectionInput,
|
| 1068 |
+
onQualityProgress,
|
| 1069 |
tokenCounter,
|
| 1070 |
{ strategy: "full", maxRegenerateAttempts: 2 },
|
| 1071 |
);
|