rogasper commited on
Commit
72e7de2
·
1 Parent(s): 46d8323

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 CHANGED
@@ -1,101 +1,283 @@
1
- # labas
 
 
2
 
3
- This project was created with [Better-T-Stack](https://github.com/AmanVarshney01/create-better-t-stack), a modern TypeScript stack that combines React, TanStack Router, Hono, TRPC, and more.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  ## Features
6
 
7
- - **TypeScript** - For type safety and improved developer experience
8
- - **TanStack Router** - File-based routing with full type safety
9
- - **TailwindCSS** - Utility-first CSS for rapid UI development
10
- - **Shared UI package** - shadcn/ui primitives live in `packages/ui`
11
- - **Hono** - Lightweight, performant server framework
12
- - **tRPC** - End-to-end type-safe APIs
13
- - **Bun** - Runtime environment
14
- - **Drizzle** - TypeScript-first ORM
15
- - **PostgreSQL** - Database engine
16
- - **Authentication** - Better-Auth
17
- - **PWA** - Progressive Web App support
18
- - **Turborepo** - Optimized monorepo build system
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  ## Getting Started
21
 
22
- First, install the dependencies:
23
 
24
  ```bash
 
 
25
  bun install
26
  ```
27
 
28
- ## Database Setup
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
- 3. Apply the schema to your database:
36
 
37
  ```bash
38
- bun run db:push
39
  ```
40
 
41
- Then, run the development server:
 
 
 
 
 
 
42
 
43
  ```bash
44
- bun run dev
 
45
  ```
46
 
47
- Open [http://localhost:5173](http://localhost:5173) in your browser to see the web application.
48
- The API is running at [http://localhost:3000](http://localhost:3000).
49
 
50
- ## UI Customization
 
 
51
 
52
- React web apps in this stack share shadcn/ui primitives through `packages/ui`.
 
53
 
54
- - Change design tokens and global styles in `packages/ui/src/styles/globals.css`
55
- - Update shared primitives in `packages/ui/src/components/*`
56
- - Adjust shadcn aliases or style config in `packages/ui/components.json` and `apps/web/components.json`
 
57
 
58
- ### Add more shared components
 
59
 
60
- Run this from the project root to add more primitives to the shared UI package:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
  ```bash
63
- npx shadcn@latest add accordion dialog popover sheet table -c packages/ui
64
  ```
65
 
66
- Import shared components like this:
67
 
68
- ```tsx
69
- import { Button } from "@labas/ui/components/button";
70
  ```
71
 
72
- ### Add app-specific blocks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- If you want to add app-specific blocks instead of shared primitives, run the shadcn CLI from `apps/web`.
 
 
 
 
 
 
 
75
 
76
  ## Project Structure
77
 
78
  ```
79
  labas/
80
  ├── apps/
81
- │ ├── web/ # Frontend application (React + TanStack Router)
82
- │ └── server/ # Backend API (Hono, TRPC)
83
  ├── packages/
84
- │ ├── ui/ # Shared shadcn/ui components and styles
85
- │ ├── api/ # API layer / business logic
86
- │ ├── auth/ # Authentication configuration & logic
87
- ── db/ # Database schema & queries
 
 
 
 
 
88
  ```
89
 
 
 
90
  ## Available Scripts
91
 
92
- - `bun run dev`: Start all applications in development mode
93
- - `bun run build`: Build all applications
94
- - `bun run dev:web`: Start only the web application
95
- - `bun run dev:server`: Start only the server
96
- - `bun run check-types`: Check TypeScript types across all apps
97
- - `bun run db:push`: Push schema changes to database
98
- - `bun run db:generate`: Generate database client/types
99
- - `bun run db:migrate`: Run database migrations
100
- - `bun run db:studio`: Open database studio UI
101
- - `cd apps/web && bun run generate-pwa-assets`: Generate PWA assets
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- DATABASE_URL=
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
- removeJob(res.jobId);
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
- const updated = [...state.completedResults];
153
- updated[idx] = action.result;
154
- return { ...state, completedResults: updated };
 
155
  }
156
- return { ...state, completedResults: [...state.completedResults, action.result] };
 
 
157
  }
158
 
159
  case "clearResult": {
 
 
 
160
  return {
161
  ...state,
162
- completedResults: state.completedResults.filter((r) => r.jobId !== action.jobId),
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
- jobIds: [],
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
- /* Recover from sessionStorage on mount */
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 via custom event */
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 previous active job IDs and per-job statuses to avoid redundant state updates. */
15
- function serializeJobIds(jobs: ActiveJob[]): string {
16
- return jobs.map((j) => j.id).sort().join(",");
 
 
 
 
 
 
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 prevActiveIdsRef = useRef("");
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 = serializeJobIds(nextActive);
50
- if (nextIds !== prevActiveIdsRef.current) {
51
- prevActiveIdsRef.current = nextIds;
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: "This is the correct answer because...",
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("injects placeholder options when missing for multiple_choice", () => {
148
  const raw = {
149
  ...baseRaw,
150
  format: "multiple_choice",
151
  options: [],
152
  };
153
- const { question, wasRepaired } = repairQuestion(raw, fullPassage);
154
- expect(question.options).toHaveLength(4);
155
- expect(wasRepaired).toBe(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 processes mixed valid/invalid questions", () => {
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(2);
187
- expect(result.invalid).toHaveLength(0);
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
- function getTargetLanguage(examType: string): string {
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
- - explanation — WAJIB ditulis dalam Bahasa Indonesia. DILARANG menggunakan bahasa asing.
 
 
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: any[]; tokensUsed: number }> {
242
  const qaPairs = questions
243
- .map((q, i) => `Q${i + 1}: ${q.questionText}\nA: ${q.correctAnswer}`)
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 stepRegenerateQuestions(
287
- client: OpenAICompatibleClient,
288
  input: GenerationInput,
289
- passage: string,
290
- count: number,
291
- context: string,
292
  onToken?: (token: string) => void,
293
- ): Promise<{ questions: Array<Record<string, unknown>>; tokensUsed: number }> {
294
- const schema = getGenericQuestionJsonSchemaDescription();
295
-
296
- const prompt = `You are an expert exam question writer. ${context}
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
- export async function generateQuestionsAgentic(
 
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: "running" },
363
- { step: "validate_passage", status: "pending" as any },
364
- { step: "generate_questions", status: "pending" as any },
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
- // ── Step 1: Generate passage ────────────────────────────
380
- report(0);
381
- let passage: string;
382
- let title: string;
383
- try {
384
- const s1 = await step1GeneratePassage(client, input, onToken);
385
- passage = s1.passage;
386
- title = s1.title;
387
- accumulatedTokens += s1.tokensUsed;
388
- steps[0].status = "done";
389
- steps[0].message = `Generated: ${title}`;
390
- steps[0].output = passage;
391
- } catch (err: any) {
392
- steps[0].status = "error";
393
- steps[0].message = err.message ?? "Passage generation failed";
394
- throw new GenerationError(`Step 1 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
395
- }
396
-
397
- // ── Step 2: Validate passage ────────────────────────────
398
- if (strategy === "full") {
399
- steps[1].status = "running";
400
- report(1);
401
- try {
402
- const s2 = await step2ValidatePassage(client, input, passage, onToken);
403
- accumulatedTokens += s2.tokensUsed;
404
- steps[1].status = s2.isValid ? "done" : "error";
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].message = `Generated ${rawQuestions.length} questions`;
427
- steps[2].output = rawQuestions.map((q, i) => `${i + 1}. [${q.format}] ${q.questionText}`).join("\n");
 
 
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
- // ── Step 4: Self-validate + Repair + Regenerate loop ─────
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
- // Repair & parse
450
- let { valid, invalid, repairLog } = repairAndParseQuestions(rawQuestions, passage);
451
-
452
- // Move questions with non-Indonesian (CJK) explanations to invalid for regeneration
453
- const hasCJK = (text: string) => /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/.test(text);
454
- const cjkInvalid: Array<{ index: number; raw: unknown; errors: string[] }> = [];
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 = `The previous ${regenCount} question(s) had structural errors: ${invalid.map((i) => `Q${i.index + 1}: ${i.errors.join(", ")}`).join("; ")}.`;
481
 
482
  report(3, `Regenerating ${regenCount} invalid question(s) (attempt ${regenerationAttempts}/${maxRegenAttempts})...`);
483
 
484
- const regen = await stepRegenerateQuestions(client, input, passage, regenCount, context, onToken);
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 stepRegenerateQuestions(
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
- const { valid, invalid, repairLog } = repairAndParseQuestions(raw.questions, fallbackPassage);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  if (repairLog.length > 0) {
121
  log("warn", "Repairs applied", { count: repairLog.length, details: repairLog });
122
  }
123
- if (invalid.length > 0) {
124
- log("warn", "Invalid questions after repair", {
125
- count: invalid.length,
126
- details: invalid.map((i) => ({ index: i.index, errors: i.errors })),
127
  });
128
  }
129
 
130
- if (valid.length === 0) {
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: valid.length,
137
  rawCount: raw.questions.length,
138
  durationMs: Date.now() - start,
139
  });
140
 
141
- const questions = valid;
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
- - The reading passage must be written in the target language of the exam (${examType === "JLPT" ? "Japanese" : examType === "HSK" ? "Chinese" : examType === "GOETHE" ? "German" : examType === "TOPIK" ? "Korean" : examType === "TOAFL" ? "Arabic" : examType === "DELE" ? "Spanish" : "English"}).
 
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) — WAJIB ditulis dalam Bahasa Indonesia. DILARANG menggunakan bahasa asing (China, Jepang, Jerman, Inggris) untuk 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
- // AI forgot options — inject plausible placeholders so parsing can succeed
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!.map((o) => ({
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
- return text || "Soal latihan membaca.";
 
 
 
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 (hasCJK(explanationText)) {
219
- notes.push("explanation contains CJK characters (should be Bahasa Indonesia), marked for regeneration");
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
- // Ensure options
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: "Option text" },
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 Indonesian" },
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
- await job.updateProgress(progress);
 
 
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
- sectionResult = await generateQuestionsAgentic(
616
- subInput,
617
- undefined,
618
- tokenCounter,
619
- { strategy: "lean", maxRegenerateAttempts: 1 },
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
- undefined,
641
  tokenCounter,
642
- { strategy: "lean", maxRegenerateAttempts: 1 },
643
  );
644
  }
645
 
@@ -780,43 +893,24 @@ export const generationWorker = new Worker<FastJobData>(
780
  };
781
 
782
  if (selectedMode === "agentic") {
783
- await updateProgress(85, "Fast phase done, enqueuing quality phase...", "running_quality", {
784
- ...fastResult,
 
 
 
785
  sectionSplits,
786
- qualityPhase: "fast",
787
- isPartial: true,
 
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
- await job.updateProgress(progress);
 
 
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
- undefined,
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
  );