Youngger9765 Claude commited on
Commit
1c7f2e1
·
1 Parent(s): 4f604c4

feat: Complete AI grading platform with multilingual support

Browse files

Features implemented:
- Full-stack AI grading platform using FastAPI + React
- OpenAI Assistant API integration for intelligent grading
- Multilingual support (Chinese/English) with dynamic UI translation
- Comprehensive selectors: language, age group, article category, grading mode
- Modern, responsive UI with optimized layout
- Secure configuration with proper .gitignore and environment variables
- Docker support for containerized deployment

Technical improvements:
- TypeScript-only imports for better type safety
- Optimized token usage with structured prompts
- Real-time grading with loading states
- Scrollable result panels with custom styling
- Header-based control layout for better UX

Security:
- API keys properly secured in .env.local
- Comprehensive .gitignore for sensitive files
- CORS configuration for API security

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

.dockerignore ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend
2
+ backend/__pycache__/
3
+ backend/*.pyc
4
+ backend/.env
5
+ backend/.env.local
6
+ backend/venv/
7
+ backend/.venv/
8
+
9
+ # Frontend
10
+ frontend/node_modules/
11
+ frontend/dist/
12
+ frontend/.env
13
+ frontend/.env.local
14
+
15
+ # Git
16
+ .git/
17
+ .gitignore
18
+
19
+ # IDE
20
+ .vscode/
21
+ .idea/
22
+ *.swp
23
+ *.swo
24
+
25
+ # OS
26
+ .DS_Store
27
+ Thumbs.db
28
+
29
+ # Logs
30
+ *.log
31
+ npm-debug.log*
32
+ yarn-debug.log*
33
+ yarn-error.log*
.gitignore ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables
2
+ .env
3
+ .env.local
4
+ .env.*.local
5
+ *.env
6
+
7
+ # API Keys and Secrets
8
+ *api_key*
9
+ *secret*
10
+ *password*
11
+ *token*
12
+ *.key
13
+ *.pem
14
+ *.crt
15
+
16
+ # Python
17
+ __pycache__/
18
+ *.py[cod]
19
+ *$py.class
20
+ *.so
21
+ .Python
22
+ build/
23
+ develop-eggs/
24
+ dist/
25
+ downloads/
26
+ eggs/
27
+ .eggs/
28
+ lib/
29
+ lib64/
30
+ parts/
31
+ sdist/
32
+ var/
33
+ wheels/
34
+ *.egg-info/
35
+ .installed.cfg
36
+ *.egg
37
+ MANIFEST
38
+
39
+ # Virtual Environment
40
+ venv/
41
+ ENV/
42
+ env/
43
+ .venv
44
+
45
+ # Node modules
46
+ node_modules/
47
+ dist/
48
+ dist-ssr/
49
+
50
+ # IDE
51
+ .vscode/
52
+ .idea/
53
+ *.swp
54
+ *.swo
55
+ *~
56
+ .DS_Store
57
+
58
+ # Logs
59
+ *.log
60
+ logs/
61
+ npm-debug.log*
62
+ yarn-debug.log*
63
+ yarn-error.log*
64
+ pnpm-debug.log*
65
+ lerna-debug.log*
66
+
67
+ # Testing
68
+ .pytest_cache/
69
+ .coverage
70
+ htmlcov/
71
+ .tox/
72
+ .coverage.*
73
+ .cache
74
+ nosetests.xml
75
+ coverage.xml
76
+ *.cover
77
+ .hypothesis/
78
+
79
+ # OS
80
+ .DS_Store
81
+ Thumbs.db
82
+
83
+ # Editor
84
+ *.suo
85
+ *.ntvs*
86
+ *.njsproj
87
+ *.sln
88
+ *.sw?
89
+
90
+ # Backup files
91
+ *.bak
92
+ *.backup
93
+ *~
94
+
95
+ # Database
96
+ *.db
97
+ *.sqlite
98
+ *.sqlite3
API.md ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI 批改平台 API 文件
2
+
3
+ ## 基本資訊
4
+
5
+ - **Base URL**: `https://api.example.com`
6
+ - **API Version**: `v1`
7
+ - **Content-Type**: `application/json`
8
+ - **Authentication**: Bearer Token (放在 Authorization header)
9
+
10
+ ---
11
+
12
+ ## API 端點
13
+
14
+ ### 1. 取得可用 Agents 列表
15
+
16
+ **GET** `/api/agents`
17
+
18
+ 取得所有可用的批改 agents 清單。
19
+
20
+ #### Request
21
+
22
+ ```http
23
+ GET /api/agents HTTP/1.1
24
+ Host: api.example.com
25
+ Authorization: Bearer YOUR_API_TOKEN
26
+ ```
27
+
28
+ #### Response
29
+
30
+ **Status Code**: `200 OK`
31
+
32
+ ```json
33
+ {
34
+ "version": 2,
35
+ "agents": [
36
+ {
37
+ "key": "zh_full_edit",
38
+ "name": "中文全文批改",
39
+ "language": "zh-Hant",
40
+ "ui": {
41
+ "badge": "ZH",
42
+ "color": "#1565C0"
43
+ },
44
+ "rubric": {
45
+ "overall_weight": 100,
46
+ "criteria": [
47
+ {
48
+ "key": "structure",
49
+ "label": "結構與段落",
50
+ "weight": 25
51
+ },
52
+ {
53
+ "key": "coherence",
54
+ "label": "論證與連貫",
55
+ "weight": 25
56
+ },
57
+ {
58
+ "key": "clarity",
59
+ "label": "表達清晰度",
60
+ "weight": 25
61
+ },
62
+ {
63
+ "key": "mechanics",
64
+ "label": "字詞/標點/語法",
65
+ "weight": 25
66
+ }
67
+ ]
68
+ }
69
+ },
70
+ {
71
+ "key": "en_edit",
72
+ "name": "英文批改",
73
+ "language": "en",
74
+ "ui": {
75
+ "badge": "EN",
76
+ "color": "#2E7D32"
77
+ },
78
+ "rubric": {
79
+ "overall_weight": 100,
80
+ "criteria": [
81
+ {
82
+ "key": "organization",
83
+ "label": "Organization",
84
+ "weight": 20
85
+ },
86
+ {
87
+ "key": "argumentation",
88
+ "label": "Argumentation",
89
+ "weight": 30
90
+ },
91
+ {
92
+ "key": "clarity",
93
+ "label": "Clarity & Style",
94
+ "weight": 25
95
+ },
96
+ {
97
+ "key": "mechanics",
98
+ "label": "Grammar & Spelling",
99
+ "weight": 25
100
+ }
101
+ ]
102
+ }
103
+ }
104
+ ]
105
+ }
106
+ ```
107
+
108
+ ---
109
+
110
+ ### 2. 送出批改請求
111
+
112
+ **POST** `/api/grade`
113
+
114
+ 送出文章進行 AI 批改。
115
+
116
+ #### Request
117
+
118
+ ```http
119
+ POST /api/grade HTTP/1.1
120
+ Host: api.example.com
121
+ Authorization: Bearer YOUR_API_TOKEN
122
+ Content-Type: application/json
123
+ ```
124
+
125
+ **Body Parameters**
126
+
127
+ | 參數 | 類型 | 必填 | 說明 |
128
+ |------|------|------|------|
129
+ | `agent_key` | string | ✓ | Agent 識別碼(如 `zh_full_edit`) |
130
+ | `text` | string | ✓ | 要批改的文章全文 |
131
+ | `options` | object | ✗ | 額外選項 |
132
+ | `options.output_format` | string | ✗ | 輸出格式:`markdown`(預設)、`html`、`diff`、`json` |
133
+ | `options.include_scores` | boolean | ✗ | 是否包含評分(預設:true) |
134
+ | `options.include_suggestions` | boolean | ✗ | 是否包含改進建議(預設:true) |
135
+
136
+ **Request Example**
137
+
138
+ ```json
139
+ {
140
+ "agent_key": "zh_full_edit",
141
+ "text": "這是一篇關於環保議題的文章。在現代社會中,環境保護已成為全球關注的焦點...",
142
+ "options": {
143
+ "output_format": "markdown",
144
+ "include_scores": true,
145
+ "include_suggestions": true
146
+ }
147
+ }
148
+ ```
149
+
150
+ #### Response
151
+
152
+ **Success Response**
153
+
154
+ **Status Code**: `200 OK`
155
+
156
+ ```json
157
+ {
158
+ "success": true,
159
+ "data": {
160
+ "agent_key": "zh_full_edit",
161
+ "timestamp": "2024-01-20T10:30:00Z",
162
+ "processing_time_ms": 3500,
163
+ "result": {
164
+ "summary": "這篇文章探討了環保議題,結構清晰但論述可以更深入...",
165
+ "inline_edits": "這是一篇關於環保議題的文章。在現代社會中,環境保護已成為全球~~關注的焦點~~**矚目的核心議題**...",
166
+ "scores": {
167
+ "overall": 75,
168
+ "criteria": {
169
+ "structure": 80,
170
+ "coherence": 70,
171
+ "clarity": 75,
172
+ "mechanics": 75
173
+ }
174
+ },
175
+ "suggestions": [
176
+ {
177
+ "type": "improvement",
178
+ "section": "introduction",
179
+ "text": "建議在開頭加入更具體的數據或案例,增強說服力"
180
+ },
181
+ {
182
+ "type": "correction",
183
+ "section": "paragraph_2",
184
+ "text": "第二段的轉折詞使用不當,建議改為『然而』"
185
+ }
186
+ ]
187
+ }
188
+ }
189
+ }
190
+ ```
191
+
192
+ **Processing Response (Long Polling)**
193
+
194
+ 如果處理時間較長,可能會先回傳處理中狀態:
195
+
196
+ **Status Code**: `202 Accepted`
197
+
198
+ ```json
199
+ {
200
+ "success": true,
201
+ "data": {
202
+ "job_id": "job_abc123xyz",
203
+ "status": "processing",
204
+ "message": "批改處理中,請稍候..."
205
+ }
206
+ }
207
+ ```
208
+
209
+ **Error Response**
210
+
211
+ **Status Code**: `400 Bad Request`
212
+
213
+ ```json
214
+ {
215
+ "success": false,
216
+ "error": {
217
+ "code": "INVALID_AGENT",
218
+ "message": "指定的 agent_key 不存在",
219
+ "details": {
220
+ "agent_key": "invalid_agent",
221
+ "available_agents": ["zh_full_edit", "en_edit"]
222
+ }
223
+ }
224
+ }
225
+ ```
226
+
227
+ ---
228
+
229
+ ### 3. 查詢批改狀態(選用)
230
+
231
+ **GET** `/api/grade/{job_id}`
232
+
233
+ ��詢長時間處理的批改任務狀態。
234
+
235
+ #### Request
236
+
237
+ ```http
238
+ GET /api/grade/job_abc123xyz HTTP/1.1
239
+ Host: api.example.com
240
+ Authorization: Bearer YOUR_API_TOKEN
241
+ ```
242
+
243
+ #### Response
244
+
245
+ **Processing**
246
+
247
+ ```json
248
+ {
249
+ "success": true,
250
+ "data": {
251
+ "job_id": "job_abc123xyz",
252
+ "status": "processing",
253
+ "progress": 65,
254
+ "message": "正在分析文章結構..."
255
+ }
256
+ }
257
+ ```
258
+
259
+ **Completed**
260
+
261
+ ```json
262
+ {
263
+ "success": true,
264
+ "data": {
265
+ "job_id": "job_abc123xyz",
266
+ "status": "completed",
267
+ "result": {
268
+ // 同上方批改結果格式
269
+ }
270
+ }
271
+ }
272
+ ```
273
+
274
+ ---
275
+
276
+ ## 錯誤碼
277
+
278
+ | HTTP 狀態碼 | 錯誤碼 | 說明 |
279
+ |------------|--------|------|
280
+ | 400 | `INVALID_AGENT` | 無效的 agent_key |
281
+ | 400 | `EMPTY_TEXT` | 文章內容為空 |
282
+ | 400 | `TEXT_TOO_LONG` | 文章超過長度限制(預設 10000 字) |
283
+ | 401 | `UNAUTHORIZED` | 未提供或無效的 API Token |
284
+ | 403 | `FORBIDDEN` | 無權限使用此 agent |
285
+ | 429 | `RATE_LIMIT` | 超過速率限制 |
286
+ | 500 | `INTERNAL_ERROR` | 伺服器內部錯誤 |
287
+ | 503 | `SERVICE_UNAVAILABLE` | OpenAI API 暫時無法使用 |
288
+
289
+ ---
290
+
291
+ ## 速率限制
292
+
293
+ - **預設限制**:每分鐘 10 次請求
294
+ - **Response Headers**:
295
+ - `X-RateLimit-Limit`: 速率限制上限
296
+ - `X-RateLimit-Remaining`: 剩餘請求次數
297
+ - `X-RateLimit-Reset`: 重置時間(Unix timestamp)
298
+
299
+ ---
300
+
301
+ ## WebSocket API(即時批改 - 選用)
302
+
303
+ **Endpoint**: `wss://api.example.com/ws/grade`
304
+
305
+ ### Connection
306
+
307
+ ```javascript
308
+ const ws = new WebSocket('wss://api.example.com/ws/grade');
309
+ ws.onopen = () => {
310
+ // 發送認證
311
+ ws.send(JSON.stringify({
312
+ type: 'auth',
313
+ token: 'YOUR_API_TOKEN'
314
+ }));
315
+ };
316
+ ```
317
+
318
+ ### 送出批改
319
+
320
+ ```javascript
321
+ ws.send(JSON.stringify({
322
+ type: 'grade',
323
+ data: {
324
+ agent_key: 'zh_full_edit',
325
+ text: '文章內容...'
326
+ }
327
+ }));
328
+ ```
329
+
330
+ ### 接收進度更新
331
+
332
+ ```javascript
333
+ ws.onmessage = (event) => {
334
+ const message = JSON.parse(event.data);
335
+
336
+ switch(message.type) {
337
+ case 'progress':
338
+ console.log(`進度: ${message.progress}%`);
339
+ break;
340
+ case 'partial':
341
+ console.log('部分結果:', message.data);
342
+ break;
343
+ case 'complete':
344
+ console.log('批改完成:', message.data);
345
+ break;
346
+ case 'error':
347
+ console.error('錯誤:', message.error);
348
+ break;
349
+ }
350
+ };
351
+ ```
352
+
353
+ ---
354
+
355
+ ## SDK 範例
356
+
357
+ ### JavaScript/TypeScript
358
+
359
+ ```typescript
360
+ import { GradingClient } from '@example/grading-sdk';
361
+
362
+ const client = new GradingClient({
363
+ apiKey: 'YOUR_API_TOKEN',
364
+ baseUrl: 'https://api.example.com'
365
+ });
366
+
367
+ // 取得 agents
368
+ const agents = await client.getAgents();
369
+
370
+ // 送出批改
371
+ const result = await client.grade({
372
+ agentKey: 'zh_full_edit',
373
+ text: '文章內容...',
374
+ options: {
375
+ outputFormat: 'markdown',
376
+ includeScores: true
377
+ }
378
+ });
379
+
380
+ console.log(result.summary);
381
+ console.log(result.scores);
382
+ ```
383
+
384
+ ### Python
385
+
386
+ ```python
387
+ from grading_sdk import GradingClient
388
+
389
+ client = GradingClient(
390
+ api_key="YOUR_API_TOKEN",
391
+ base_url="https://api.example.com"
392
+ )
393
+
394
+ # 取得 agents
395
+ agents = client.get_agents()
396
+
397
+ # 送出批改
398
+ result = client.grade(
399
+ agent_key="zh_full_edit",
400
+ text="文章內容...",
401
+ options={
402
+ "output_format": "markdown",
403
+ "include_scores": True
404
+ }
405
+ )
406
+
407
+ print(result["summary"])
408
+ print(result["scores"])
409
+ ```
410
+
411
+ ---
412
+
413
+ ## 測試環境
414
+
415
+ - **Staging URL**: `https://staging-api.example.com`
416
+ - **測試 Token**: 請聯繫管理員取得
417
+
418
+ ---
419
+
420
+ ## 更新紀錄
421
+
422
+ | 版本 | 日期 | 更新內容 |
423
+ |------|------|----------|
424
+ | v1.0.0 | 2024-01-20 | 初版發布 |
425
+ | v1.1.0 | 2024-02-01 | 新增 WebSocket 即時批改 |
426
+ | v1.2.0 | 2024-02-15 | 支援批次批改 |
427
+
428
+ ---
429
+
430
+ ## 聯絡資訊
431
+
432
+ - **技術支援**: support@example.com
433
+ - **API 狀態頁**: https://status.example.com
434
+ - **開發者論壇**: https://forum.example.com/api
README.md CHANGED
@@ -7,4 +7,75 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  ---
9
 
10
+ # AI 批改平台
11
+
12
+ 基於 FastAPI 和 React 的 AI 文章批改系統,支援中文和英文批改。
13
+
14
+ ## 專案結構
15
+
16
+ ```
17
+ .
18
+ ├── backend/ # FastAPI 後端
19
+ ├── frontend/ # React 前端
20
+ ├── agents.yaml # Agent 設定檔
21
+ ├── docker-compose.yml # Docker 配置
22
+ └── README.md
23
+ ```
24
+
25
+ ## 快速開始
26
+
27
+ ### 環境需求
28
+
29
+ - Python 3.9+
30
+ - Node.js 18+
31
+ - OpenAI API Key
32
+
33
+ ### 後端設定
34
+
35
+ 1. 安裝依賴:
36
+ ```bash
37
+ cd backend
38
+ pip install -r requirements.txt
39
+ ```
40
+
41
+ 2. 設定環境變數:
42
+ ```bash
43
+ cp .env.example .env
44
+ # 編輯 .env 填入你的 OpenAI API Key
45
+ ```
46
+
47
+ 3. 啟動服務:
48
+ ```bash
49
+ uvicorn main:app --reload --host 0.0.0.0 --port 8000
50
+ ```
51
+
52
+ ### 前端設定
53
+
54
+ 1. 安裝依賴:
55
+ ```bash
56
+ cd frontend
57
+ npm install
58
+ ```
59
+
60
+ 2. 啟動開發服務:
61
+ ```bash
62
+ npm run dev
63
+ ```
64
+
65
+ ### Docker 部署
66
+
67
+ ```bash
68
+ docker-compose up -d
69
+ ```
70
+
71
+ ## API 文件
72
+
73
+ 啟動後端後,訪問 http://localhost:8000/docs 查看 Swagger API 文件。
74
+
75
+ ## 功能特色
76
+
77
+ - 支援中文和英文文章批改
78
+ - 可配置的 Agent 系統
79
+ - 即時批改回饋
80
+ - 評分和建議功能
81
+ - Docker 容器化部署
agents.yaml ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+ defaults:
3
+ model: "gpt-4"
4
+ temperature: 0.2
5
+ output_format: "markdown"
6
+ max_tokens: 2000
7
+
8
+ agents:
9
+ zh_full_edit:
10
+ name: "小學作文批改"
11
+ assistant_id: "asst_MEzXFAvPbkbn85Ca1gQ7vKa9"
12
+ language: "zh-Hant"
13
+ temperature: 0.2
14
+ rubric:
15
+ overall_weight: 100
16
+ criteria:
17
+ - key: "structure"
18
+ label: "結構與段落"
19
+ weight: 25
20
+ - key: "coherence"
21
+ label: "論證與連貫"
22
+ weight: 25
23
+ - key: "clarity"
24
+ label: "表達清晰度"
25
+ weight: 25
26
+ - key: "mechanics"
27
+ label: "字詞/標點/語法"
28
+ weight: 25
29
+ response_contract:
30
+ fields:
31
+ - key: "summary"
32
+ type: "text"
33
+ required: true
34
+ - key: "inline_edits"
35
+ type: "richtext"
36
+ required: true
37
+ - key: "scores"
38
+ type: "object"
39
+ required: false
40
+ - key: "suggestions"
41
+ type: "list"
42
+ required: false
43
+ ui:
44
+ badge: "ZH"
45
+ color: "#1565C0"
46
+
47
+ en_edit:
48
+ name: "英文批改"
49
+ assistant_id: "asst_xxxxxxxxx_en" # 請替換為實際的 Assistant ID
50
+ language: "en"
51
+ temperature: 0.1
52
+ rubric:
53
+ overall_weight: 100
54
+ criteria:
55
+ - key: "organization"
56
+ label: "Organization"
57
+ weight: 20
58
+ - key: "argumentation"
59
+ label: "Argumentation"
60
+ weight: 30
61
+ - key: "clarity"
62
+ label: "Clarity & Style"
63
+ weight: 25
64
+ - key: "mechanics"
65
+ label: "Grammar & Spelling"
66
+ weight: 25
67
+ response_contract:
68
+ fields:
69
+ - key: "summary"
70
+ type: "text"
71
+ required: true
72
+ - key: "inline_edits"
73
+ type: "richtext"
74
+ required: true
75
+ - key: "overall_score"
76
+ type: "number"
77
+ required: false
78
+ - key: "criteria_scores"
79
+ type: "object"
80
+ required: false
81
+ ui:
82
+ badge: "EN"
83
+ color: "#2E7D32"
backend/.env.example ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ OPENAI_API_KEY=your_openai_api_key_here
2
+ ENVIRONMENT=development
3
+ CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173"]
4
+ API_VERSION=v1
5
+ LOG_LEVEL=INFO
backend/.gitignore ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables
2
+ .env
3
+ .env.local
4
+ .env.*.local
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ *.so
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # Virtual Environment
30
+ venv/
31
+ ENV/
32
+ env/
33
+ .venv
34
+
35
+ # IDE
36
+ .vscode/
37
+ .idea/
38
+ *.swp
39
+ *.swo
40
+ *~
41
+ .DS_Store
42
+
43
+ # Logs
44
+ *.log
45
+ logs/
46
+
47
+ # Testing
48
+ .pytest_cache/
49
+ .coverage
50
+ htmlcov/
51
+ .tox/
52
+ .coverage.*
53
+ .cache
54
+ nosetests.xml
55
+ coverage.xml
56
+ *.cover
57
+ .hypothesis/
backend/Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy application
10
+ COPY . .
11
+
12
+ # Expose port
13
+ EXPOSE 8000
14
+
15
+ # Run the application
16
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
backend/agents.yaml ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+ defaults:
3
+ model: "gpt-4"
4
+ temperature: 0.2
5
+ output_format: "markdown"
6
+ max_tokens: 2000
7
+
8
+ agents:
9
+ zh_full_edit:
10
+ name: "小學作文批改"
11
+ assistant_id: "asst_MEzXFAvPbkbn85Ca1gQ7vKa9"
12
+ language: "zh-Hant"
13
+ temperature: 0.2
14
+ rubric:
15
+ overall_weight: 100
16
+ criteria:
17
+ - key: "structure"
18
+ label: "結構與段落"
19
+ weight: 25
20
+ - key: "coherence"
21
+ label: "論證與連貫"
22
+ weight: 25
23
+ - key: "clarity"
24
+ label: "表達清晰度"
25
+ weight: 25
26
+ - key: "mechanics"
27
+ label: "字詞/標點/語法"
28
+ weight: 25
29
+ response_contract:
30
+ fields:
31
+ - key: "summary"
32
+ type: "text"
33
+ required: true
34
+ - key: "inline_edits"
35
+ type: "richtext"
36
+ required: true
37
+ - key: "scores"
38
+ type: "object"
39
+ required: false
40
+ - key: "suggestions"
41
+ type: "list"
42
+ required: false
43
+ ui:
44
+ badge: "ZH"
45
+ color: "#1565C0"
46
+
47
+ en_edit:
48
+ name: "英文批改"
49
+ assistant_id: "asst_xxxxxxxxx_en" # 請替換為實際的 Assistant ID
50
+ language: "en"
51
+ temperature: 0.1
52
+ rubric:
53
+ overall_weight: 100
54
+ criteria:
55
+ - key: "organization"
56
+ label: "Organization"
57
+ weight: 20
58
+ - key: "argumentation"
59
+ label: "Argumentation"
60
+ weight: 30
61
+ - key: "clarity"
62
+ label: "Clarity & Style"
63
+ weight: 25
64
+ - key: "mechanics"
65
+ label: "Grammar & Spelling"
66
+ weight: 25
67
+ response_contract:
68
+ fields:
69
+ - key: "summary"
70
+ type: "text"
71
+ required: true
72
+ - key: "inline_edits"
73
+ type: "richtext"
74
+ required: true
75
+ - key: "overall_score"
76
+ type: "number"
77
+ required: false
78
+ - key: "criteria_scores"
79
+ type: "object"
80
+ required: false
81
+ ui:
82
+ badge: "EN"
83
+ color: "#2E7D32"
backend/app/config/agents.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import yaml
2
+ from typing import Dict, Any, List, Optional
3
+ from pathlib import Path
4
+ from pydantic import BaseModel
5
+
6
+ class Criterion(BaseModel):
7
+ key: str
8
+ label: str
9
+ weight: int
10
+
11
+ class Rubric(BaseModel):
12
+ overall_weight: int
13
+ criteria: List[Criterion]
14
+
15
+ class ResponseField(BaseModel):
16
+ key: str
17
+ type: str
18
+ required: bool
19
+
20
+ class ResponseContract(BaseModel):
21
+ fields: List[ResponseField]
22
+
23
+ class UIConfig(BaseModel):
24
+ badge: str
25
+ color: str
26
+
27
+ class Agent(BaseModel):
28
+ name: str
29
+ assistant_id: str
30
+ language: str
31
+ temperature: Optional[float] = None
32
+ rubric: Optional[Rubric] = None
33
+ response_contract: Optional[ResponseContract] = None
34
+ ui: Optional[UIConfig] = None
35
+
36
+ class AgentsConfig:
37
+ def __init__(self, config_path: str = "agents.yaml"):
38
+ self.config_path = Path(config_path)
39
+ self.agents: Dict[str, Agent] = {}
40
+ self.defaults: Dict[str, Any] = {}
41
+ self.version: int = 1
42
+ self._load_config()
43
+
44
+ def _load_config(self):
45
+ """Load and parse agents.yaml configuration"""
46
+ if not self.config_path.exists():
47
+ raise FileNotFoundError(f"Configuration file {self.config_path} not found")
48
+
49
+ with open(self.config_path, 'r', encoding='utf-8') as f:
50
+ config = yaml.safe_load(f)
51
+
52
+ self.version = config.get('version', 1)
53
+ self.defaults = config.get('defaults', {})
54
+
55
+ # Parse agents
56
+ for agent_key, agent_data in config.get('agents', {}).items():
57
+ # Merge with defaults
58
+ merged_data = {**self.defaults, **agent_data}
59
+
60
+ # Parse rubric if exists
61
+ if 'rubric' in merged_data:
62
+ rubric_data = merged_data['rubric']
63
+ merged_data['rubric'] = Rubric(
64
+ overall_weight=rubric_data['overall_weight'],
65
+ criteria=[Criterion(**c) for c in rubric_data.get('criteria', [])]
66
+ )
67
+
68
+ # Parse response_contract if exists
69
+ if 'response_contract' in merged_data:
70
+ contract_data = merged_data['response_contract']
71
+ merged_data['response_contract'] = ResponseContract(
72
+ fields=[ResponseField(**f) for f in contract_data.get('fields', [])]
73
+ )
74
+
75
+ # Parse UI config if exists
76
+ if 'ui' in merged_data:
77
+ merged_data['ui'] = UIConfig(**merged_data['ui'])
78
+
79
+ self.agents[agent_key] = Agent(**merged_data)
80
+
81
+ def get_agent(self, agent_key: str) -> Optional[Agent]:
82
+ """Get agent by key"""
83
+ return self.agents.get(agent_key)
84
+
85
+ def list_agents(self) -> Dict[str, Dict[str, Any]]:
86
+ """List all available agents"""
87
+ result = {}
88
+ for key, agent in self.agents.items():
89
+ result[key] = {
90
+ "name": agent.name,
91
+ "language": agent.language,
92
+ "ui": agent.ui.model_dump() if agent.ui else None,
93
+ "rubric": agent.rubric.model_dump() if agent.rubric else None
94
+ }
95
+ return result
96
+
97
+ # Global instance
98
+ agents_config = None
99
+
100
+ def get_agents_config() -> AgentsConfig:
101
+ global agents_config
102
+ if agents_config is None:
103
+ agents_config = AgentsConfig()
104
+ return agents_config
backend/app/config/settings.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from typing import List
3
+ import os
4
+
5
+ class Settings(BaseSettings):
6
+ # OpenAI
7
+ openai_api_key: str = ""
8
+
9
+ # API Configuration
10
+ api_version: str = "v1"
11
+ api_title: str = "AI Grading Platform API"
12
+ api_description: str = "API for AI-powered essay grading"
13
+
14
+ # CORS
15
+ cors_origins: List[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:5174", "file://"]
16
+
17
+ # Environment
18
+ environment: str = "development"
19
+ log_level: str = "INFO"
20
+
21
+ # Rate Limiting
22
+ rate_limit_per_minute: int = 10
23
+
24
+ # Text Limits
25
+ max_text_length: int = 10000
26
+
27
+ class Config:
28
+ env_file = ".env"
29
+ case_sensitive = False
30
+
31
+ settings = Settings()
backend/app/models/grading.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field, validator
2
+ from typing import Optional, Dict, List, Any
3
+ from datetime import datetime
4
+
5
+ class GradeRequest(BaseModel):
6
+ agent_key: str = Field(..., description="Agent identifier")
7
+ text: str = Field(..., description="Text to be graded")
8
+ category: Optional[str] = Field(None, description="Article category")
9
+ age_group: Optional[str] = Field(None, description="Age group of author")
10
+ language: Optional[str] = Field("zh", description="Output language (zh/en)")
11
+ options: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional options")
12
+
13
+ @validator('text')
14
+ def validate_text(cls, v):
15
+ if not v or not v.strip():
16
+ raise ValueError("Text cannot be empty")
17
+ if len(v) > 10000:
18
+ raise ValueError("Text exceeds maximum length of 10000 characters")
19
+ return v
20
+
21
+ class Suggestion(BaseModel):
22
+ type: str = Field(..., description="Type of suggestion (improvement, correction, etc.)")
23
+ section: str = Field(..., description="Section of the text")
24
+ text: str = Field(..., description="Suggestion text")
25
+
26
+ class Scores(BaseModel):
27
+ overall: Optional[float] = None
28
+ criteria: Optional[Dict[str, float]] = None
29
+
30
+ class GradeResult(BaseModel):
31
+ summary: str = Field(..., description="Summary of the grading")
32
+ inline_edits: str = Field(..., description="Text with inline edits/corrections")
33
+ scores: Optional[Scores] = None
34
+ suggestions: Optional[List[Suggestion]] = None
35
+
36
+ class GradeResponse(BaseModel):
37
+ success: bool = Field(..., description="Whether the request was successful")
38
+ data: Optional[Dict[str, Any]] = None
39
+ error: Optional[Dict[str, Any]] = None
40
+
41
+ class JobStatus(BaseModel):
42
+ job_id: str
43
+ status: str # processing, completed, failed
44
+ progress: Optional[int] = None
45
+ message: Optional[str] = None
46
+ result: Optional[GradeResult] = None
47
+
48
+ class AgentInfo(BaseModel):
49
+ key: str
50
+ name: str
51
+ language: str
52
+ ui: Optional[Dict[str, Any]] = None
53
+ rubric: Optional[Dict[str, Any]] = None
54
+
55
+ class AgentsListResponse(BaseModel):
56
+ version: int
57
+ agents: List[AgentInfo]
backend/app/prompts/grading_prompt.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Grading prompt for structured evaluation
3
+ """
4
+
5
+ GRADING_PROMPT_ZH = """
6
+ 批改以下文章,回傳JSON格式。
7
+
8
+ 文章分類:{category}
9
+ {age_text}
10
+ 輸出語言:中文
11
+
12
+ 評分標準(請依據「{category}」類型文章的特性來評分):
13
+ - 主題內容(30%)
14
+ - 遣詞造句(30%)
15
+ - 段落結構(25%)
16
+ - 錯別字(15%)
17
+
18
+ 等級:A/A-/B+/B/B-/C+/C/D
19
+ 分數:80+🟢, 60-79🟡, <60🔴
20
+
21
+ JSON格式:
22
+ {{
23
+ "overall_score": {{
24
+ "emoji": "🟢/🟡/🔴", // 80分以上用🟢, 60-79用🟡, 60以下用🔴
25
+ "score": 85, // 0-100的數字
26
+ "grade": "A-" // 整體等級
27
+ }},
28
+ "overall_feedback": "綜合回饋內容...",
29
+ "criteria": {{
30
+ "theme_content": {{
31
+ "grade": "A/A-/B+/B/B-/C+/C/D",
32
+ "explanation": "你的文章主題明確..."
33
+ }},
34
+ "word_sentence": {{
35
+ "grade": "A/A-/B+/B/B-/C+/C/D",
36
+ "explanation": "文章中使用的詞語和句型..."
37
+ }},
38
+ "paragraph_structure": {{
39
+ "grade": "A/A-/B+/B/B-/C+/C/D",
40
+ "explanation": "文章結構..."
41
+ }},
42
+ "typo_check": {{
43
+ "grade": "A+/A/A-/B+/B/B-/C+/C/D",
44
+ "explanation": "這篇文章中沒有發現錯別字..."
45
+ }}
46
+ }},
47
+ "corrections": [
48
+ {{
49
+ "original": "原文句子",
50
+ "corrected": "修改後句子",
51
+ "reason": "修改原因"
52
+ }}
53
+ ],
54
+ "detailed_feedback": {{
55
+ "theme_content": "詳細的主題與內容評語...",
56
+ "word_sentence": "詳細的遣詞造句評語...",
57
+ "paragraph_structure": "詳細的段落結構評語...",
58
+ "typo_check": "詳細的錯別字檢查評語..."
59
+ }}
60
+ }}
61
+
62
+ 文章內容:
63
+ {text}
64
+
65
+ 重要提醒:
66
+ 1. 請只回傳上述 JSON 格式,不要包含任何額外的文字說明
67
+ 2. 確保 JSON 格式正確,可以被解析
68
+ 3. 每個欄位都必須填寫,不可留空
69
+ 4. overall_score.score 必須是 0-100 的數字
70
+ 5. 所有 grade 欄位必須是 A/A-/B+/B/B-/C+/C/D 其中之一
71
+ 6. 所有回饋內容必須使用中文
72
+ """
73
+
74
+ GRADING_PROMPT_EN = """
75
+ Please grade the following article and return in JSON format.
76
+
77
+ Article Category: {category}
78
+ {age_text}
79
+ Output Language: English
80
+
81
+ Grading Criteria (Please grade based on the characteristics of "{category}" type articles):
82
+ - Theme & Content (30%)
83
+ - Word Choice & Sentence Structure (30%)
84
+ - Paragraph Structure (25%)
85
+ - Spelling & Grammar (15%)
86
+
87
+ Grades: A/A-/B+/B/B-/C+/C/D
88
+ Scores: 80+🟢, 60-79🟡, <60🔴
89
+
90
+ JSON Format:
91
+ {{
92
+ "overall_score": {{
93
+ "emoji": "🟢/🟡/🔴", // Use 🟢 for 80+, 🟡 for 60-79, 🔴 for <60
94
+ "score": 85, // Number from 0-100
95
+ "grade": "A-" // Overall grade
96
+ }},
97
+ "overall_feedback": "Overall feedback content...",
98
+ "criteria": {{
99
+ "theme_content": {{
100
+ "grade": "A/A-/B+/B/B-/C+/C/D",
101
+ "explanation": "Your article has a clear theme..."
102
+ }},
103
+ "word_sentence": {{
104
+ "grade": "A/A-/B+/B/B-/C+/C/D",
105
+ "explanation": "The vocabulary and sentence structures used..."
106
+ }},
107
+ "paragraph_structure": {{
108
+ "grade": "A/A-/B+/B/B-/C+/C/D",
109
+ "explanation": "The article structure..."
110
+ }},
111
+ "typo_check": {{
112
+ "grade": "A+/A/A-/B+/B/B-/C+/C/D",
113
+ "explanation": "No spelling or grammar errors found..."
114
+ }}
115
+ }},
116
+ "corrections": [
117
+ {{
118
+ "original": "Original sentence",
119
+ "corrected": "Corrected sentence",
120
+ "reason": "Reason for correction"
121
+ }}
122
+ ],
123
+ "detailed_feedback": {{
124
+ "theme_content": "Detailed feedback on theme and content...",
125
+ "word_sentence": "Detailed feedback on word choice and sentences...",
126
+ "paragraph_structure": "Detailed feedback on paragraph structure...",
127
+ "typo_check": "Detailed feedback on spelling and grammar..."
128
+ }}
129
+ }}
130
+
131
+ Article Content:
132
+ {text}
133
+
134
+ Important Reminders:
135
+ 1. Return only the JSON format above, without any additional text
136
+ 2. Ensure the JSON format is correct and parseable
137
+ 3. All fields must be filled, no empty values
138
+ 4. overall_score.score must be a number between 0-100
139
+ 5. All grade fields must be one of A/A-/B+/B/B-/C+/C/D
140
+ 6. All feedback content must be in English
141
+ """
142
+
143
+ def get_grading_prompt(text: str, category: str = "", age_group: str = "", language: str = "zh") -> str:
144
+ """Get formatted grading prompt"""
145
+ category_text = category if category else "一般文章" if language == "zh" else "General Article"
146
+
147
+ # Build age group text
148
+ if language == "zh":
149
+ age_text = ""
150
+ if age_group:
151
+ age_text = f"作者年齡層:{age_group}\n請以「{age_group}」的程度標準來評分\n"
152
+
153
+ return GRADING_PROMPT_ZH.format(
154
+ text=text,
155
+ category=category_text,
156
+ age_text=age_text
157
+ )
158
+ else:
159
+ # Map Chinese age groups to English
160
+ age_group_map = {
161
+ "國小": "Elementary School",
162
+ "國中": "Middle School",
163
+ "高中": "High School",
164
+ "大學": "University",
165
+ "上班族": "Working Professional",
166
+ "退休人士": "Retiree"
167
+ }
168
+
169
+ age_text = ""
170
+ if age_group:
171
+ age_group_en = age_group_map.get(age_group, age_group)
172
+ age_text = f"Author Age Group: {age_group_en}\nPlease grade according to the standards for \"{age_group_en}\"\n"
173
+
174
+ return GRADING_PROMPT_EN.format(
175
+ text=text,
176
+ category=category_text,
177
+ age_text=age_text
178
+ )
backend/app/routers/grading.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends
2
+ from typing import Dict, Any
3
+ import logging
4
+ from app.models.grading import (
5
+ GradeRequest,
6
+ GradeResponse,
7
+ AgentsListResponse,
8
+ AgentInfo
9
+ )
10
+ from app.config.agents import get_agents_config
11
+ from app.services.openai_service import get_openai_service
12
+
13
+ logger = logging.getLogger(__name__)
14
+ router = APIRouter(prefix="/api", tags=["grading"])
15
+
16
+ @router.get("/agents", response_model=AgentsListResponse)
17
+ async def get_agents():
18
+ """Get list of available agents"""
19
+ try:
20
+ config = get_agents_config()
21
+ agents_list = []
22
+
23
+ for key, agent_data in config.list_agents().items():
24
+ agents_list.append(AgentInfo(
25
+ key=key,
26
+ name=agent_data["name"],
27
+ language=agent_data["language"],
28
+ ui=agent_data.get("ui"),
29
+ rubric=agent_data.get("rubric")
30
+ ))
31
+
32
+ return AgentsListResponse(
33
+ version=config.version,
34
+ agents=agents_list
35
+ )
36
+ except Exception as e:
37
+ logger.error(f"Error fetching agents: {str(e)}")
38
+ raise HTTPException(status_code=500, detail=str(e))
39
+
40
+ @router.post("/grade", response_model=GradeResponse)
41
+ async def grade_text(request: GradeRequest):
42
+ """Grade text using specified agent"""
43
+ try:
44
+ # Get agent configuration
45
+ config = get_agents_config()
46
+ agent = config.get_agent(request.agent_key)
47
+
48
+ if not agent:
49
+ return GradeResponse(
50
+ success=False,
51
+ error={
52
+ "code": "INVALID_AGENT",
53
+ "message": f"Agent '{request.agent_key}' not found",
54
+ "available_agents": list(config.agents.keys())
55
+ }
56
+ )
57
+
58
+ # Add category, age_group, and language to options if provided
59
+ options = request.options or {}
60
+ if request.category:
61
+ options['category'] = request.category
62
+ if request.age_group:
63
+ options['age_group'] = request.age_group
64
+ if request.language:
65
+ options['language'] = request.language
66
+
67
+ # Call OpenAI service
68
+ openai_service = get_openai_service()
69
+ result = await openai_service.grade_text(
70
+ agent=agent,
71
+ text=request.text,
72
+ options=options
73
+ )
74
+
75
+ return GradeResponse(
76
+ success=True,
77
+ data={
78
+ "agent_key": request.agent_key,
79
+ "result": result.model_dump()
80
+ }
81
+ )
82
+
83
+ except ValueError as e:
84
+ return GradeResponse(
85
+ success=False,
86
+ error={
87
+ "code": "VALIDATION_ERROR",
88
+ "message": str(e)
89
+ }
90
+ )
91
+ except Exception as e:
92
+ logger.error(f"Error grading text: {str(e)}")
93
+ return GradeResponse(
94
+ success=False,
95
+ error={
96
+ "code": "INTERNAL_ERROR",
97
+ "message": "An error occurred while processing your request"
98
+ }
99
+ )
100
+
101
+ @router.get("/health")
102
+ async def health_check():
103
+ """Health check endpoint"""
104
+ return {"status": "healthy", "service": "AI Grading Platform"}
backend/app/services/assistant_setup.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Setup and configure OpenAI Assistant with proper settings
3
+ """
4
+ from openai import OpenAI
5
+ from app.config.settings import settings
6
+ import json
7
+
8
+ def setup_assistant():
9
+ """
10
+ Configure or update the OpenAI Assistant with JSON response format and file search
11
+ """
12
+ client = OpenAI(api_key=settings.openai_api_key)
13
+
14
+ assistant_id = "asst_MEzXFAvPbkbn85Ca1gQ7vKa9"
15
+
16
+ try:
17
+ # Update existing assistant
18
+ assistant = client.beta.assistants.update(
19
+ assistant_id,
20
+ instructions="""你是一位專業的中文作文批改老師。請根據提供的評分標準,對學生的作文進行詳細批改。
21
+
22
+ 重要指示:
23
+ 1. 必須以 JSON 格式回應
24
+ 2. 使用精簡的語言,避免冗長的說明
25
+ 3. 專注於具體、可操作的建議
26
+ 4. 評分要公正且具建設性
27
+
28
+ 評分標準:
29
+ - 80分以上:優秀(🟢)
30
+ - 60-79分:中等(🟡)
31
+ - 60分以下:需改進(🔴)
32
+
33
+ 請嚴格按照指定的 JSON 格式回應,不要包含任何額外文字。""",
34
+ model="gpt-4-turbo-preview",
35
+ tools=[
36
+ {"type": "file_search"}, # Enable file search
37
+ {"type": "code_interpreter"} # Optional: for code analysis
38
+ ],
39
+ response_format={"type": "json_object"}, # Force JSON response
40
+ temperature=0.7, # Balanced creativity
41
+ top_p=0.9
42
+ )
43
+
44
+ print(f"Assistant updated successfully: {assistant.id}")
45
+ print(f"Response format: {assistant.response_format}")
46
+ print(f"Tools enabled: {[tool.type for tool in assistant.tools]}")
47
+
48
+ return assistant
49
+
50
+ except Exception as e:
51
+ print(f"Error updating assistant: {e}")
52
+ # Try to retrieve current settings
53
+ try:
54
+ assistant = client.beta.assistants.retrieve(assistant_id)
55
+ print(f"Current assistant settings:")
56
+ print(f"- Model: {assistant.model}")
57
+ print(f"- Response format: {getattr(assistant, 'response_format', 'Not set')}")
58
+ print(f"- Tools: {[tool.type for tool in assistant.tools] if assistant.tools else 'None'}")
59
+ except:
60
+ pass
61
+ return None
62
+
63
+ def create_vector_store(client: OpenAI, name: str = "作文批改知識庫"):
64
+ """
65
+ Create a vector store for file search
66
+ """
67
+ try:
68
+ vector_store = client.beta.vector_stores.create(
69
+ name=name,
70
+ expires_after={
71
+ "anchor": "last_active_at",
72
+ "days": 7
73
+ }
74
+ )
75
+ print(f"Vector store created: {vector_store.id}")
76
+ return vector_store
77
+ except Exception as e:
78
+ print(f"Error creating vector store: {e}")
79
+ return None
80
+
81
+ if __name__ == "__main__":
82
+ # Run this script to update assistant settings
83
+ assistant = setup_assistant()
84
+ if assistant:
85
+ print("\nAssistant configuration complete!")
86
+ print("\nTo minimize token usage:")
87
+ print("1. JSON response format is enabled")
88
+ print("2. Instructions are concise")
89
+ print("3. Temperature is balanced at 0.7")
backend/app/services/openai_service.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from openai import OpenAI
2
+ from typing import Dict, Any, Optional
3
+ import json
4
+ import logging
5
+ from app.config.settings import settings
6
+ from app.config.agents import Agent
7
+ from app.models.grading import GradeResult, Scores, Suggestion
8
+ from app.prompts.grading_prompt import get_grading_prompt
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class OpenAIService:
13
+ def __init__(self):
14
+ self.client = OpenAI(api_key=settings.openai_api_key)
15
+
16
+ async def grade_text(self, agent: Agent, text: str, options: Dict[str, Any] = {}) -> GradeResult:
17
+ """
18
+ Grade text using OpenAI Assistant with optimized token usage
19
+ """
20
+ try:
21
+ logger.info(f"Starting grading with assistant_id: {agent.assistant_id}")
22
+
23
+ # Create thread with metadata for tracking
24
+ thread = self.client.beta.threads.create(
25
+ metadata={"purpose": "grading", "language": agent.language}
26
+ )
27
+ logger.info(f"Created thread: {thread.id}")
28
+
29
+ # Build prompt based on agent configuration
30
+ prompt = self._build_prompt(agent, text, options)
31
+ logger.info(f"Built prompt (first 200 chars): {prompt[:200]}...")
32
+
33
+ # Add message to thread
34
+ message = self.client.beta.threads.messages.create(
35
+ thread_id=thread.id,
36
+ role="user",
37
+ content=prompt
38
+ )
39
+ logger.info(f"Added message to thread")
40
+
41
+ # Run assistant with optimized settings
42
+ run = self.client.beta.threads.runs.create(
43
+ thread_id=thread.id,
44
+ assistant_id=agent.assistant_id,
45
+ additional_instructions="必須以 JSON 格式回應。"
46
+ )
47
+ logger.info(f"Created run: {run.id} with status: {run.status}")
48
+
49
+ # Wait for completion with timeout
50
+ import time
51
+ timeout = 120 # 120 seconds timeout (increased from 60)
52
+ start_time = time.time()
53
+
54
+ while run.status not in ["completed", "failed", "cancelled", "expired"]:
55
+ if time.time() - start_time > timeout:
56
+ raise Exception("Assistant run timed out")
57
+
58
+ time.sleep(0.5) # Wait before checking again
59
+ run = self.client.beta.threads.runs.retrieve(
60
+ thread_id=thread.id,
61
+ run_id=run.id
62
+ )
63
+ logger.debug(f"Run status: {run.status}")
64
+
65
+ logger.info(f"Run completed with status: {run.status}")
66
+
67
+ if run.status != "completed":
68
+ # Get more details about the failure
69
+ if hasattr(run, 'last_error') and run.last_error:
70
+ error_msg = f"Assistant run failed: {run.last_error.message}"
71
+ else:
72
+ error_msg = f"Assistant run failed with status: {run.status}"
73
+ raise Exception(error_msg)
74
+
75
+ # Get messages
76
+ messages = self.client.beta.threads.messages.list(thread_id=thread.id)
77
+ logger.info(f"Retrieved {len(messages.data)} messages from thread")
78
+
79
+ # Parse the latest assistant message
80
+ assistant_message = None
81
+ for msg in messages.data:
82
+ if msg.role == "assistant":
83
+ assistant_message = msg
84
+ break
85
+
86
+ if not assistant_message:
87
+ raise Exception("No assistant response received")
88
+
89
+ # Extract content
90
+ content = assistant_message.content[0].text.value if assistant_message.content else ""
91
+
92
+ # Log the raw response for debugging
93
+ logger.info(f"Raw assistant response (first 1000 chars): {content[:1000]}")
94
+
95
+ # Parse response based on agent's response contract
96
+ return self._parse_response(content, agent)
97
+
98
+ except Exception as e:
99
+ logger.error(f"Error grading text: {str(e)}", exc_info=True)
100
+ # Return a default error response
101
+ return GradeResult(
102
+ summary=f"批改過程中發生錯誤: {str(e)}",
103
+ inline_edits=text,
104
+ scores=None,
105
+ suggestions=None
106
+ )
107
+
108
+ def _build_prompt(self, agent: Agent, text: str, options: Dict[str, Any]) -> str:
109
+ """
110
+ Build prompt based on agent configuration
111
+ """
112
+ # Use the new structured grading prompt for Chinese agents
113
+ if agent.language == "zh-Hant":
114
+ category = options.get('category', '')
115
+ age_group = options.get('age_group', '')
116
+ language = options.get('language', 'zh')
117
+ return get_grading_prompt(text, category, age_group, language)
118
+
119
+ # Keep original prompt for other languages
120
+ prompt_parts = []
121
+ prompt_parts.append("Please grade the following text and provide detailed feedback:\n")
122
+ prompt_parts.append(f"\n{text}\n")
123
+
124
+ if agent.rubric:
125
+ prompt_parts.append("\nPlease grade according to these criteria:")
126
+ for criterion in agent.rubric.criteria:
127
+ prompt_parts.append(f"\n- {criterion.label} ({criterion.weight}%)")
128
+
129
+ return "".join(prompt_parts)
130
+
131
+ def _parse_new_format(self, data: dict) -> GradeResult:
132
+ """
133
+ Parse the new structured grading format
134
+ """
135
+ # Extract overall score info
136
+ overall_info = data.get('overall_score', {})
137
+ overall_score = overall_info.get('score', 0)
138
+ overall_emoji = overall_info.get('emoji', '🟡')
139
+ overall_grade = overall_info.get('grade', 'B')
140
+
141
+ # Ensure emoji matches score (3 levels only)
142
+ if overall_score >= 80:
143
+ overall_emoji = '🟢'
144
+ elif overall_score >= 60:
145
+ overall_emoji = '🟡'
146
+ else:
147
+ overall_emoji = '🔴'
148
+
149
+ # Extract criteria scores
150
+ criteria = data.get('criteria', {})
151
+ scores_dict = {}
152
+ suggestions = []
153
+
154
+ criteria_mapping = {
155
+ 'theme_content': '主題與內容',
156
+ 'word_sentence': '遣詞造句',
157
+ 'paragraph_structure': '段落結構',
158
+ 'typo_check': '錯別字檢查'
159
+ }
160
+
161
+ for key, chinese_name in criteria_mapping.items():
162
+ if key in criteria:
163
+ grade = criteria[key].get('grade', 'B')
164
+ explanation = criteria[key].get('explanation', '')
165
+
166
+ # Convert grade to score
167
+ grade_to_score = {
168
+ 'A': 95, 'A-': 88, 'B+': 83, 'B': 78,
169
+ 'B-': 73, 'C+': 68, 'C': 63, 'D': 50
170
+ }
171
+ score = grade_to_score.get(grade, 75)
172
+ scores_dict[chinese_name] = score
173
+
174
+ # Add to suggestions if not excellent
175
+ if score < 90:
176
+ suggestions.append(Suggestion(
177
+ type=chinese_name,
178
+ section="evaluation",
179
+ text=explanation
180
+ ))
181
+
182
+ # Add corrections as suggestions
183
+ corrections = data.get('corrections', [])
184
+ for correction in corrections:
185
+ suggestions.append(Suggestion(
186
+ type="correction",
187
+ section="edit",
188
+ text=f"原文:{correction.get('original', '')}\n修改:{correction.get('corrected', '')}\n原因:{correction.get('reason', '')}"
189
+ ))
190
+
191
+ # Build summary with emoji and overall feedback
192
+ summary = f"綜合評分:{overall_emoji} {overall_grade}\n\n"
193
+ summary += data.get('overall_feedback', '')
194
+
195
+ # Add detailed feedback to inline_edits for display
196
+ detailed = data.get('detailed_feedback', {})
197
+ inline_edits = json.dumps(detailed, ensure_ascii=False, indent=2)
198
+
199
+ return GradeResult(
200
+ summary=summary,
201
+ inline_edits=inline_edits,
202
+ scores=Scores(
203
+ overall=overall_score,
204
+ criteria=scores_dict
205
+ ),
206
+ suggestions=suggestions if suggestions else None
207
+ )
208
+
209
+ def _parse_structured_evaluation(self, data: dict) -> GradeResult:
210
+ """
211
+ Parse structured evaluation format into GradeResult
212
+ """
213
+ suggestions = []
214
+ scores_dict = {}
215
+ summary_parts = []
216
+
217
+ # Process each evaluation category
218
+ categories = {
219
+ 'content': '內容',
220
+ 'organization': '組織',
221
+ 'grammar_and_usage': '文法和用法',
222
+ 'vocabulary': '詞彙',
223
+ 'coherence_and_cohesion': '連貫性和連接詞'
224
+ }
225
+
226
+ for key, chinese_name in categories.items():
227
+ if key in data and data[key]:
228
+ level = data[key].get('level', '')
229
+ explanation = data[key].get('explanation', '')
230
+
231
+ # Extract score from level
232
+ if 'Excellent' in level or 'advanced' in level:
233
+ score = 90
234
+ elif 'Good' in level or 'intermediate' in level:
235
+ score = 75
236
+ elif 'Fair' in level or 'beginner' in level:
237
+ score = 60
238
+ else:
239
+ score = 40
240
+
241
+ scores_dict[chinese_name] = score
242
+
243
+ # Add to summary
244
+ summary_parts.append(f"【{chinese_name}】{level}\n{explanation}")
245
+
246
+ # Create suggestion if needed
247
+ if score < 90:
248
+ suggestions.append(Suggestion(
249
+ type=chinese_name,
250
+ section="general",
251
+ text=explanation
252
+ ))
253
+
254
+ # Calculate overall score
255
+ overall_score = sum(scores_dict.values()) / len(scores_dict) if scores_dict else 0
256
+
257
+ return GradeResult(
258
+ summary="\n\n".join(summary_parts),
259
+ inline_edits=json.dumps(data, ensure_ascii=False, indent=2),
260
+ scores=Scores(
261
+ overall=round(overall_score, 1),
262
+ criteria=scores_dict
263
+ ),
264
+ suggestions=suggestions if suggestions else None
265
+ )
266
+
267
+ def _parse_response(self, content: str, agent: Agent) -> GradeResult:
268
+ """
269
+ Parse assistant response into structured format
270
+ """
271
+ try:
272
+ # Log the raw content for debugging
273
+ logger.info(f"Raw AI response: {content[:500]}...")
274
+
275
+ # Try to parse as JSON first
276
+ json_content = content.strip()
277
+
278
+ # Extract JSON from markdown code blocks if present
279
+ if '```json' in content.lower():
280
+ json_start = content.find('{')
281
+ json_end = content.rfind('}') + 1
282
+ if json_start >= 0 and json_end > json_start:
283
+ json_content = content[json_start:json_end]
284
+ elif '```' in content:
285
+ # Remove any code blocks
286
+ json_content = content.replace('```', '').strip()
287
+
288
+ # Try to find JSON object in the content
289
+ if '{' in json_content:
290
+ json_start = json_content.find('{')
291
+ json_end = json_content.rfind('}') + 1
292
+ if json_start >= 0 and json_end > json_start:
293
+ json_content = json_content[json_start:json_end]
294
+
295
+ # Clean up common issues
296
+ json_content = json_content.strip()
297
+
298
+ # Parse JSON
299
+ data = json.loads(json_content)
300
+
301
+ # Check if it's the new format with overall_score
302
+ if 'overall_score' in data and 'criteria' in data:
303
+ return self._parse_new_format(data)
304
+
305
+ # Check if it's the old structured evaluation format
306
+ if all(key in data for key in ['content', 'organization', 'grammar_and_usage', 'vocabulary']):
307
+ return self._parse_structured_evaluation(data)
308
+
309
+ # Otherwise parse as before
310
+ return GradeResult(
311
+ summary=data.get('summary', ''),
312
+ inline_edits=data.get('inline_edits', ''),
313
+ scores=Scores(**data.get('scores', {})) if data.get('scores') else None,
314
+ suggestions=[Suggestion(**s) for s in data.get('suggestions', [])]
315
+ )
316
+ except json.JSONDecodeError as e:
317
+ logger.error(f"JSON parsing error: {str(e)}")
318
+ logger.error(f"Content that failed to parse: {content[:1000]}...")
319
+ except Exception as e:
320
+ logger.error(f"Unexpected error parsing response: {str(e)}")
321
+
322
+ # Fallback to text parsing
323
+ lines = content.split('\n')
324
+ summary = ""
325
+ inline_edits = ""
326
+ scores = None
327
+ suggestions = []
328
+
329
+ current_section = None
330
+
331
+ for line in lines:
332
+ line = line.strip()
333
+ if not line:
334
+ continue
335
+
336
+ # Detect sections
337
+ if any(keyword in line.lower() for keyword in ['summary', '摘要', '總評']):
338
+ current_section = 'summary'
339
+ continue
340
+ elif any(keyword in line.lower() for keyword in ['edit', '修訂', '修改', 'correction']):
341
+ current_section = 'edits'
342
+ continue
343
+ elif any(keyword in line.lower() for keyword in ['score', '評分', '分數']):
344
+ current_section = 'scores'
345
+ continue
346
+ elif any(keyword in line.lower() for keyword in ['suggestion', '建議', 'recommendation']):
347
+ current_section = 'suggestions'
348
+ continue
349
+
350
+ # Add content to sections
351
+ if current_section == 'summary':
352
+ summary += line + " "
353
+ elif current_section == 'edits':
354
+ inline_edits += line + "\n"
355
+ elif current_section == 'suggestions':
356
+ if line.startswith('-') or line.startswith('•'):
357
+ suggestions.append(Suggestion(
358
+ type="improvement",
359
+ section="general",
360
+ text=line[1:].strip()
361
+ ))
362
+
363
+ # If no structured parsing worked, use the entire content as summary
364
+ if not summary and not inline_edits:
365
+ summary = content
366
+ inline_edits = content
367
+
368
+ return GradeResult(
369
+ summary=summary.strip() or "已完成批改",
370
+ inline_edits=inline_edits.strip() or content,
371
+ scores=scores,
372
+ suggestions=suggestions if suggestions else None
373
+ )
374
+
375
+ # Singleton instance
376
+ _openai_service = None
377
+
378
+ def get_openai_service() -> OpenAIService:
379
+ global _openai_service
380
+ if _openai_service is None:
381
+ _openai_service = OpenAIService()
382
+ return _openai_service
backend/main.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from contextlib import asynccontextmanager
4
+ import logging
5
+ from dotenv import load_dotenv
6
+ import os
7
+
8
+ # Load environment variables
9
+ load_dotenv(".env.local")
10
+ load_dotenv(".env")
11
+
12
+ from app.config.settings import settings
13
+ from app.routers import grading
14
+
15
+ # Configure logging
16
+ logging.basicConfig(
17
+ level=getattr(logging, settings.log_level),
18
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
19
+ )
20
+ logger = logging.getLogger(__name__)
21
+
22
+ @asynccontextmanager
23
+ async def lifespan(app: FastAPI):
24
+ # Startup
25
+ logger.info("Starting AI Grading Platform API")
26
+ yield
27
+ # Shutdown
28
+ logger.info("Shutting down AI Grading Platform API")
29
+
30
+ # Create FastAPI app
31
+ app = FastAPI(
32
+ title=settings.api_title,
33
+ description=settings.api_description,
34
+ version=settings.api_version,
35
+ lifespan=lifespan
36
+ )
37
+
38
+ # Configure CORS
39
+ app.add_middleware(
40
+ CORSMiddleware,
41
+ allow_origins=["*"], # Allow all origins in development
42
+ allow_credentials=True,
43
+ allow_methods=["*"],
44
+ allow_headers=["*"],
45
+ )
46
+
47
+ # Include routers
48
+ app.include_router(grading.router)
49
+
50
+ @app.get("/")
51
+ async def root():
52
+ return {
53
+ "message": "AI Grading Platform API",
54
+ "version": settings.api_version,
55
+ "docs": "/docs"
56
+ }
57
+
58
+ if __name__ == "__main__":
59
+ import uvicorn
60
+ uvicorn.run(
61
+ "main:app",
62
+ host="0.0.0.0",
63
+ port=8000,
64
+ reload=settings.environment == "development"
65
+ )
backend/requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.109.0
2
+ uvicorn[standard]==0.27.0
3
+ python-dotenv==1.0.0
4
+ pydantic==2.5.3
5
+ pydantic-settings==2.1.0
6
+ openai==1.10.0
7
+ pyyaml==6.0.1
8
+ httpx==0.26.0
9
+ python-multipart==0.0.6
10
+ cors==1.0.1
docker-compose.yml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ backend:
5
+ build:
6
+ context: ./backend
7
+ dockerfile: Dockerfile
8
+ ports:
9
+ - "8000:8000"
10
+ environment:
11
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
12
+ - ENVIRONMENT=production
13
+ - CORS_ORIGINS=["http://localhost:3000", "http://localhost:80"]
14
+ volumes:
15
+ - ./agents.yaml:/app/agents.yaml:ro
16
+ networks:
17
+ - app-network
18
+
19
+ frontend:
20
+ build:
21
+ context: ./frontend
22
+ dockerfile: Dockerfile
23
+ ports:
24
+ - "80:80"
25
+ depends_on:
26
+ - backend
27
+ environment:
28
+ - VITE_API_URL=http://localhost:8000/api
29
+ networks:
30
+ - app-network
31
+
32
+ networks:
33
+ app-network:
34
+ driver: bridge
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build stage
2
+ FROM node:18-alpine as build
3
+
4
+ WORKDIR /app
5
+
6
+ # Copy package files
7
+ COPY package*.json ./
8
+
9
+ # Install dependencies
10
+ RUN npm ci
11
+
12
+ # Copy source code
13
+ COPY . .
14
+
15
+ # Build the application
16
+ RUN npm run build
17
+
18
+ # Production stage
19
+ FROM nginx:alpine
20
+
21
+ # Copy built files from build stage
22
+ COPY --from=build /app/dist /usr/share/nginx/html
23
+
24
+ # Copy nginx configuration
25
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
26
+
27
+ # Expose port
28
+ EXPOSE 80
29
+
30
+ # Start nginx
31
+ CMD ["nginx", "-g", "daemon off;"]
frontend/README.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## Expanding the ESLint configuration
11
+
12
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13
+
14
+ ```js
15
+ export default tseslint.config([
16
+ globalIgnores(['dist']),
17
+ {
18
+ files: ['**/*.{ts,tsx}'],
19
+ extends: [
20
+ // Other configs...
21
+
22
+ // Remove tseslint.configs.recommended and replace with this
23
+ ...tseslint.configs.recommendedTypeChecked,
24
+ // Alternatively, use this for stricter rules
25
+ ...tseslint.configs.strictTypeChecked,
26
+ // Optionally, add this for stylistic rules
27
+ ...tseslint.configs.stylisticTypeChecked,
28
+
29
+ // Other configs...
30
+ ],
31
+ languageOptions: {
32
+ parserOptions: {
33
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
34
+ tsconfigRootDir: import.meta.dirname,
35
+ },
36
+ // other options...
37
+ },
38
+ },
39
+ ])
40
+ ```
41
+
42
+ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
43
+
44
+ ```js
45
+ // eslint.config.js
46
+ import reactX from 'eslint-plugin-react-x'
47
+ import reactDom from 'eslint-plugin-react-dom'
48
+
49
+ export default tseslint.config([
50
+ globalIgnores(['dist']),
51
+ {
52
+ files: ['**/*.{ts,tsx}'],
53
+ extends: [
54
+ // Other configs...
55
+ // Enable lint rules for React
56
+ reactX.configs['recommended-typescript'],
57
+ // Enable lint rules for React DOM
58
+ reactDom.configs.recommended,
59
+ ],
60
+ languageOptions: {
61
+ parserOptions: {
62
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
63
+ tsconfigRootDir: import.meta.dirname,
64
+ },
65
+ // other options...
66
+ },
67
+ },
68
+ ])
69
+ ```
frontend/eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { globalIgnores } from 'eslint/config'
7
+
8
+ export default tseslint.config([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs['recommended-latest'],
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Vite + React + TS</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
frontend/nginx.conf ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server {
2
+ listen 80;
3
+ server_name localhost;
4
+
5
+ root /usr/share/nginx/html;
6
+ index index.html;
7
+
8
+ # Enable gzip compression
9
+ gzip on;
10
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
11
+
12
+ location / {
13
+ try_files $uri $uri/ /index.html;
14
+ }
15
+
16
+ # Cache static assets
17
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
18
+ expires 1y;
19
+ add_header Cache-Control "public, immutable";
20
+ }
21
+ }
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "axios": "^1.12.0",
14
+ "react": "^19.1.1",
15
+ "react-dom": "^19.1.1"
16
+ },
17
+ "devDependencies": {
18
+ "@eslint/js": "^9.33.0",
19
+ "@types/react": "^19.1.10",
20
+ "@types/react-dom": "^19.1.7",
21
+ "@vitejs/plugin-react": "^5.0.0",
22
+ "eslint": "^9.33.0",
23
+ "eslint-plugin-react-hooks": "^5.2.0",
24
+ "eslint-plugin-react-refresh": "^0.4.20",
25
+ "globals": "^16.3.0",
26
+ "typescript": "~5.8.3",
27
+ "typescript-eslint": "^8.39.1",
28
+ "vite": "^7.1.2"
29
+ }
30
+ }
frontend/public/vite.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
9
+ background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
10
+ min-height: 100vh;
11
+ display: flex;
12
+ justify-content: center;
13
+ align-items: center;
14
+ }
15
+
16
+ #root {
17
+ width: 100%;
18
+ display: flex;
19
+ justify-content: center;
20
+ align-items: center;
21
+ min-height: 100vh;
22
+ }
23
+
24
+ .App {
25
+ width: 100%;
26
+ max-width: 1400px;
27
+ margin: 0 auto;
28
+ padding: 10px 20px;
29
+ display: flex;
30
+ flex-direction: column;
31
+ align-items: center;
32
+ }
33
+
34
+ .app-header {
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: space-between;
38
+ margin-bottom: 20px;
39
+ width: 100%;
40
+ max-width: 1400px;
41
+ gap: 30px;
42
+ }
43
+
44
+ .header-title {
45
+ flex-shrink: 0;
46
+ }
47
+
48
+ .header-controls {
49
+ display: flex;
50
+ gap: 15px;
51
+ flex-wrap: wrap;
52
+ align-items: center;
53
+ flex-grow: 1;
54
+ justify-content: flex-end;
55
+ }
56
+
57
+ .header-selector {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 8px;
61
+ }
62
+
63
+ .header-selector label {
64
+ font-weight: 600;
65
+ color: #374151;
66
+ font-size: 0.9rem;
67
+ white-space: nowrap;
68
+ }
69
+
70
+ .header-selector select {
71
+ padding: 8px 12px;
72
+ border: 2px solid #e5e7eb;
73
+ border-radius: 8px;
74
+ font-size: 0.95rem;
75
+ background: white;
76
+ transition: all 0.2s;
77
+ color: #374151;
78
+ font-weight: 500;
79
+ min-width: 120px;
80
+ }
81
+
82
+ .header-selector select:hover {
83
+ border-color: #9ca3af;
84
+ }
85
+
86
+ .header-selector select:focus {
87
+ outline: none;
88
+ border-color: #667eea;
89
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
90
+ }
91
+
92
+ .app-header h1 {
93
+ font-size: 2.2rem;
94
+ margin-bottom: 0;
95
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
96
+ -webkit-background-clip: text;
97
+ -webkit-text-fill-color: transparent;
98
+ background-clip: text;
99
+ font-weight: 700;
100
+ }
101
+
102
+ .app-header p {
103
+ font-size: 1.1rem;
104
+ color: #64748b;
105
+ font-weight: 400;
106
+ }
107
+
108
+ .main-container {
109
+ display: grid;
110
+ grid-template-columns: 1fr 1fr;
111
+ gap: 24px;
112
+ width: 100%;
113
+ max-width: 1400px;
114
+ }
115
+
116
+ @media (max-width: 968px) {
117
+ .main-container {
118
+ grid-template-columns: 1fr;
119
+ max-width: 600px;
120
+ }
121
+ }
122
+
123
+ .input-panel, .result-panel {
124
+ background: white;
125
+ border-radius: 16px;
126
+ padding: 28px;
127
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
128
+ height: 550px;
129
+ display: flex;
130
+ flex-direction: column;
131
+ }
132
+
133
+ .result-panel {
134
+ overflow-y: auto;
135
+ overflow-x: hidden;
136
+ }
137
+
138
+ /* Custom scrollbar for result panel */
139
+ .result-panel::-webkit-scrollbar {
140
+ width: 8px;
141
+ }
142
+
143
+ .result-panel::-webkit-scrollbar-track {
144
+ background: #f1f1f1;
145
+ border-radius: 4px;
146
+ }
147
+
148
+ .result-panel::-webkit-scrollbar-thumb {
149
+ background: #888;
150
+ border-radius: 4px;
151
+ }
152
+
153
+ .result-panel::-webkit-scrollbar-thumb:hover {
154
+ background: #555;
155
+ }
156
+
157
+ /* Category Selector Styles */
158
+ .category-selector {
159
+ margin-bottom: 20px;
160
+ }
161
+
162
+ .category-selector label {
163
+ display: block;
164
+ margin-bottom: 8px;
165
+ font-weight: 600;
166
+ color: #374151;
167
+ font-size: 0.95rem;
168
+ text-transform: uppercase;
169
+ letter-spacing: 0.025em;
170
+ }
171
+
172
+ .category-select {
173
+ width: 100%;
174
+ padding: 10px 14px;
175
+ border: 2px solid #e5e7eb;
176
+ border-radius: 8px;
177
+ font-size: 1rem;
178
+ background-color: white;
179
+ transition: all 0.2s;
180
+ color: #374151;
181
+ font-weight: 500;
182
+ }
183
+
184
+ .category-select:hover {
185
+ border-color: #9ca3af;
186
+ }
187
+
188
+ .category-select:focus {
189
+ outline: none;
190
+ border-color: #667eea;
191
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
192
+ }
193
+
194
+ .category-select:disabled {
195
+ opacity: 0.6;
196
+ cursor: not-allowed;
197
+ background: #f9fafb;
198
+ }
199
+
200
+ /* Agent Selector Styles */
201
+ .agent-selector {
202
+ margin-bottom: 24px;
203
+ display: flex;
204
+ align-items: center;
205
+ gap: 12px;
206
+ }
207
+
208
+ .agent-selector label {
209
+ font-weight: 600;
210
+ color: #374151;
211
+ font-size: 0.95rem;
212
+ min-width: 100px;
213
+ text-transform: uppercase;
214
+ letter-spacing: 0.025em;
215
+ }
216
+
217
+ .agent-select {
218
+ flex: 1;
219
+ max-width: 300px;
220
+ padding: 10px 14px;
221
+ border: 2px solid #e5e7eb;
222
+ border-radius: 8px;
223
+ font-size: 1rem;
224
+ background: white;
225
+ transition: all 0.2s;
226
+ color: #374151;
227
+ font-weight: 500;
228
+ }
229
+
230
+ .agent-select:hover:not(:disabled) {
231
+ border-color: #9ca3af;
232
+ }
233
+
234
+ .agent-select:focus {
235
+ outline: none;
236
+ border-color: #667eea;
237
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
238
+ }
239
+
240
+ .agent-select:disabled {
241
+ opacity: 0.6;
242
+ cursor: not-allowed;
243
+ background: #f9fafb;
244
+ }
245
+
246
+ .agent-badge {
247
+ padding: 4px 10px;
248
+ border-radius: 12px;
249
+ color: white;
250
+ font-weight: bold;
251
+ font-size: 0.8rem;
252
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
253
+ }
254
+
255
+ .text-input {
256
+ margin-bottom: 24px;
257
+ }
258
+
259
+ .text-input textarea {
260
+ width: 100%;
261
+ padding: 14px;
262
+ border: 2px solid #e5e7eb;
263
+ border-radius: 8px;
264
+ font-size: 1rem;
265
+ line-height: 1.6;
266
+ resize: vertical;
267
+ min-height: 350px;
268
+ max-height: 400px;
269
+ transition: all 0.2s;
270
+ font-family: inherit;
271
+ color: #1f2937; /* Darker text color for better visibility */
272
+ background-color: white;
273
+ }
274
+
275
+ .text-input textarea::placeholder {
276
+ color: #9ca3af;
277
+ }
278
+
279
+ .text-input textarea:focus {
280
+ outline: none;
281
+ border-color: #667eea;
282
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
283
+ }
284
+
285
+ .text-input textarea:disabled {
286
+ background: #f9fafb;
287
+ cursor: not-allowed;
288
+ }
289
+
290
+ .action-buttons {
291
+ display: flex;
292
+ gap: 12px;
293
+ justify-content: center;
294
+ margin-bottom: 20px;
295
+ }
296
+
297
+ .btn {
298
+ padding: 10px 24px;
299
+ border: none;
300
+ border-radius: 8px;
301
+ font-size: 1rem;
302
+ font-weight: 600;
303
+ cursor: pointer;
304
+ transition: all 0.2s;
305
+ min-width: 100px;
306
+ }
307
+
308
+ .btn-primary {
309
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
310
+ color: white;
311
+ box-shadow: 0 4px 6px -1px rgba(102, 126, 234, 0.25);
312
+ }
313
+
314
+ .btn-primary:hover:not(:disabled) {
315
+ transform: translateY(-1px);
316
+ box-shadow: 0 10px 15px -3px rgba(102, 126, 234, 0.3);
317
+ }
318
+
319
+ .btn-primary:active:not(:disabled) {
320
+ transform: translateY(0);
321
+ }
322
+
323
+ .btn-primary:disabled {
324
+ opacity: 0.5;
325
+ cursor: not-allowed;
326
+ transform: none;
327
+ }
328
+
329
+ .btn-secondary {
330
+ background: #f3f4f6;
331
+ color: #4b5563;
332
+ border: 1px solid #e5e7eb;
333
+ }
334
+
335
+ .btn-secondary:hover:not(:disabled) {
336
+ background: #e5e7eb;
337
+ }
338
+
339
+ .btn-secondary:disabled {
340
+ opacity: 0.5;
341
+ cursor: not-allowed;
342
+ }
343
+
344
+ .error-message {
345
+ padding: 12px 16px;
346
+ background: #fef2f2;
347
+ color: #dc2626;
348
+ border-radius: 8px;
349
+ border-left: 4px solid #dc2626;
350
+ font-weight: 500;
351
+ margin-top: 12px;
352
+ font-size: 0.95rem;
353
+ }
354
+
355
+ .loading {
356
+ display: flex;
357
+ flex-direction: column;
358
+ align-items: center;
359
+ justify-content: center;
360
+ padding: 60px;
361
+ height: 100%;
362
+ }
363
+
364
+ .spinner {
365
+ width: 48px;
366
+ height: 48px;
367
+ border: 3px solid #e5e7eb;
368
+ border-top: 3px solid #667eea;
369
+ border-radius: 50%;
370
+ animation: spin 1s linear infinite;
371
+ margin-bottom: 20px;
372
+ }
373
+
374
+ @keyframes spin {
375
+ 0% { transform: rotate(0deg); }
376
+ 100% { transform: rotate(360deg); }
377
+ }
378
+
379
+ .loading p {
380
+ color: #6b7280;
381
+ font-size: 1rem;
382
+ }
383
+
384
+ .result-container {
385
+ height: 100%;
386
+ }
387
+
388
+ .result-container h2 {
389
+ color: #1f2937;
390
+ margin-bottom: 24px;
391
+ font-size: 1.5rem;
392
+ border-bottom: 2px solid #e5e7eb;
393
+ padding-bottom: 12px;
394
+ }
395
+
396
+ .result-section {
397
+ margin-bottom: 28px;
398
+ }
399
+
400
+ .result-section h3 {
401
+ color: #374151;
402
+ margin-bottom: 16px;
403
+ font-size: 1.2rem;
404
+ font-weight: 600;
405
+ }
406
+
407
+ .result-section p {
408
+ line-height: 1.8;
409
+ color: #4b5563;
410
+ font-size: 1rem;
411
+ }
412
+
413
+ .summary-content {
414
+ margin-top: 12px;
415
+ }
416
+
417
+ .evaluation-category {
418
+ margin-bottom: 20px;
419
+ padding: 16px;
420
+ background: #f9fafb;
421
+ border-radius: 8px;
422
+ border-left: 3px solid #667eea;
423
+ }
424
+
425
+ .category-title {
426
+ color: #667eea;
427
+ font-size: 1.05rem;
428
+ font-weight: 600;
429
+ margin-bottom: 8px;
430
+ }
431
+
432
+ .category-content {
433
+ color: #4b5563;
434
+ line-height: 1.7;
435
+ white-space: pre-wrap;
436
+ }
437
+
438
+ .inline-edits {
439
+ padding: 16px;
440
+ background: #f9fafb;
441
+ border-radius: 8px;
442
+ line-height: 1.7;
443
+ color: #374151;
444
+ border: 1px solid #e5e7eb;
445
+ }
446
+
447
+ .inline-edits pre {
448
+ margin: 0;
449
+ font-size: 0.95rem;
450
+ line-height: 1.7;
451
+ color: #374151;
452
+ }
453
+
454
+ /* Detailed feedback styles */
455
+ .detailed-feedback {
456
+ display: flex;
457
+ flex-direction: column;
458
+ gap: 16px;
459
+ }
460
+
461
+ .feedback-item {
462
+ padding: 14px;
463
+ background: #f9fafb;
464
+ border-radius: 8px;
465
+ border-left: 3px solid #667eea;
466
+ }
467
+
468
+ .feedback-title {
469
+ color: #667eea;
470
+ font-size: 1.05rem;
471
+ font-weight: 600;
472
+ margin-bottom: 8px;
473
+ }
474
+
475
+ .feedback-content {
476
+ color: #4b5563;
477
+ line-height: 1.7;
478
+ margin: 0;
479
+ }
480
+
481
+ /* Correction item styles */
482
+ .suggestions-container {
483
+ display: flex;
484
+ flex-direction: column;
485
+ gap: 16px;
486
+ }
487
+
488
+ .correction-item {
489
+ background: white;
490
+ border: 1px solid #e5e7eb;
491
+ border-radius: 8px;
492
+ overflow: hidden;
493
+ transition: all 0.2s;
494
+ }
495
+
496
+ .correction-item:hover {
497
+ border-color: #9ca3af;
498
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
499
+ }
500
+
501
+ .correction-header {
502
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
503
+ padding: 8px 14px;
504
+ }
505
+
506
+ .correction-label {
507
+ color: white;
508
+ font-weight: 600;
509
+ font-size: 0.85rem;
510
+ text-transform: uppercase;
511
+ letter-spacing: 0.025em;
512
+ }
513
+
514
+ .correction-content {
515
+ padding: 16px;
516
+ display: flex;
517
+ flex-direction: column;
518
+ gap: 12px;
519
+ }
520
+
521
+ .correction-original,
522
+ .correction-revised,
523
+ .correction-reason {
524
+ display: flex;
525
+ flex-direction: column;
526
+ gap: 6px;
527
+ }
528
+
529
+ .correction-original strong,
530
+ .correction-revised strong,
531
+ .correction-reason strong {
532
+ color: #1f2937;
533
+ font-weight: 600;
534
+ font-size: 0.85rem;
535
+ text-transform: uppercase;
536
+ letter-spacing: 0.025em;
537
+ }
538
+
539
+ .correction-original span {
540
+ color: #6b7280;
541
+ line-height: 1.6;
542
+ padding-left: 16px;
543
+ }
544
+
545
+ .correction-revised span {
546
+ line-height: 1.6;
547
+ padding-left: 16px;
548
+ }
549
+
550
+ .corrected-text {
551
+ color: #059669;
552
+ font-weight: 500;
553
+ background: #ecfdf5;
554
+ padding: 6px 10px;
555
+ border-radius: 4px;
556
+ display: inline-block;
557
+ }
558
+
559
+ .correction-reason span {
560
+ color: #6b7280;
561
+ font-style: italic;
562
+ line-height: 1.6;
563
+ padding-left: 16px;
564
+ }
565
+
566
+ .suggestion-item {
567
+ padding: 14px;
568
+ background: #fefce8;
569
+ border-radius: 8px;
570
+ border-left: 3px solid #eab308;
571
+ }
572
+
573
+ .suggestion-item strong {
574
+ color: #a16207;
575
+ margin-right: 8px;
576
+ }
577
+
578
+ /* Score table styles */
579
+ .score-table {
580
+ margin-top: 20px;
581
+ overflow-x: auto;
582
+ }
583
+
584
+ .score-table table {
585
+ width: 100%;
586
+ border-collapse: separate;
587
+ border-spacing: 0;
588
+ background: white;
589
+ border-radius: 8px;
590
+ overflow: hidden;
591
+ border: 1px solid #e5e7eb;
592
+ }
593
+
594
+ .score-table th {
595
+ background: #f9fafb;
596
+ color: #374151;
597
+ padding: 12px 16px;
598
+ text-align: left;
599
+ font-weight: 600;
600
+ font-size: 0.95rem;
601
+ text-transform: uppercase;
602
+ letter-spacing: 0.025em;
603
+ border-bottom: 2px solid #e5e7eb;
604
+ }
605
+
606
+ .score-table td {
607
+ padding: 14px 16px;
608
+ border-bottom: 1px solid #f3f4f6;
609
+ color: #374151;
610
+ font-size: 0.95rem;
611
+ }
612
+
613
+ .score-table tr:last-child td {
614
+ border-bottom: none;
615
+ }
616
+
617
+ .score-table tr:hover:not(.overall-row) {
618
+ background: #fafbfc;
619
+ }
620
+
621
+ .criteria-name {
622
+ font-weight: 600;
623
+ color: #4b5563;
624
+ min-width: 120px;
625
+ }
626
+
627
+ .criteria-grade {
628
+ font-weight: bold;
629
+ font-size: 1.2rem;
630
+ color: #667eea;
631
+ text-align: center;
632
+ min-width: 80px;
633
+ }
634
+
635
+ .criteria-explanation {
636
+ color: #6b7280;
637
+ line-height: 1.6;
638
+ }
639
+
640
+ .overall-row {
641
+ background: #f9fafb;
642
+ font-weight: bold;
643
+ }
644
+
645
+ .overall-row td {
646
+ padding: 16px;
647
+ border-top: 2px solid #667eea;
648
+ }
649
+
650
+ .overall-emoji {
651
+ font-size: 1.5rem;
652
+ margin-right: 8px;
653
+ vertical-align: middle;
654
+ }
655
+
656
+ .overall-score-number {
657
+ font-size: 1.1rem;
658
+ color: #1f2937;
659
+ font-weight: bold;
660
+ vertical-align: middle;
661
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import './App.css';
3
+ import type { Agent, GradeResult } from './types/index';
4
+ import { gradingApi } from './api/grading';
5
+
6
+ function App() {
7
+ const [agents, setAgents] = useState<Agent[]>([]);
8
+ const [selectedAgent, setSelectedAgent] = useState<string>('');
9
+ const [selectedCategory, setSelectedCategory] = useState<string>('1');
10
+ const [selectedAgeGroup, setSelectedAgeGroup] = useState<string>('國小');
11
+ const [selectedLanguage, setSelectedLanguage] = useState<string>('zh');
12
+ const [text, setText] = useState<string>('');
13
+ const [loading, setLoading] = useState<boolean>(false);
14
+ const [result, setResult] = useState<GradeResult | null>(null);
15
+ const [error, setError] = useState<string>('');
16
+
17
+ const categories = [
18
+ { value: '1', label: '文學小說' },
19
+ { value: '2', label: '詩集' },
20
+ { value: '3', label: '心理勵志' },
21
+ { value: '4', label: '藝術設計' },
22
+ { value: '5', label: '觀光旅遊' },
23
+ { value: '6', label: '美食饗宴' },
24
+ { value: '7', label: '行銷文案' },
25
+ { value: '8', label: '商業理財' },
26
+ { value: '9', label: '人文史地' },
27
+ { value: '10', label: '自然科普' },
28
+ { value: '11', label: '漫畫圖文' },
29
+ { value: '12', label: '教育學習' },
30
+ { value: '13', label: '生活休閒' },
31
+ { value: '14', label: '創意想像' },
32
+ { value: '15', label: '資訊科技' },
33
+ { value: '16', label: '社會議題' },
34
+ ];
35
+
36
+ const ageGroups = [
37
+ { value: '國小', label: '國小' },
38
+ { value: '國中', label: '國中' },
39
+ { value: '高中', label: '高中' },
40
+ { value: '大學', label: '大學' },
41
+ { value: '上班族', label: '上班族' },
42
+ { value: '退休人士', label: '退休人士' },
43
+ ];
44
+
45
+ const languages = [
46
+ { value: 'zh', label: '中文' },
47
+ { value: 'en', label: 'English' },
48
+ ];
49
+
50
+ // Translations
51
+ const labels = {
52
+ zh: {
53
+ language: '語言:',
54
+ ageGroup: '對象年齡:',
55
+ category: '文章分類:',
56
+ gradingMode: '批改模式:',
57
+ placeholder: '請在這裡貼上要批改的全文...',
58
+ gradeButton: '批改',
59
+ gradingButton: '批改中...',
60
+ clearButton: '清空',
61
+ loadingMessage: 'AI 正在批改您的文章,請稍候...',
62
+ resultTitle: '批改結果',
63
+ summaryTitle: '總評',
64
+ editsTitle: '修訂內容',
65
+ scoresTitle: '評分結果',
66
+ structureLabel: '架構',
67
+ gradeLabel: '評分',
68
+ explanationLabel: '解釋',
69
+ overallLabel: '綜合評分',
70
+ overallExplanation: '綜合各項表現的總體評價',
71
+ suggestionsTitle: '改進建議',
72
+ correctionLabel: '修改建議',
73
+ originalLabel: '原文:',
74
+ revisedLabel: '修改:',
75
+ reasonLabel: '原因:',
76
+ },
77
+ en: {
78
+ language: 'Language:',
79
+ ageGroup: 'Age Group:',
80
+ category: 'Article Category:',
81
+ gradingMode: 'Grading Mode:',
82
+ placeholder: 'Please paste your article here...',
83
+ gradeButton: 'Grade',
84
+ gradingButton: 'Grading...',
85
+ clearButton: 'Clear',
86
+ loadingMessage: 'AI is grading your article, please wait...',
87
+ resultTitle: 'Grading Results',
88
+ summaryTitle: 'Overall Feedback',
89
+ editsTitle: 'Revisions',
90
+ scoresTitle: 'Scores',
91
+ structureLabel: 'Criteria',
92
+ gradeLabel: 'Grade',
93
+ explanationLabel: 'Explanation',
94
+ overallLabel: 'Overall Score',
95
+ overallExplanation: 'Overall evaluation based on all criteria',
96
+ suggestionsTitle: 'Suggestions',
97
+ correctionLabel: 'Correction',
98
+ originalLabel: 'Original:',
99
+ revisedLabel: 'Revised:',
100
+ reasonLabel: 'Reason:',
101
+ }
102
+ };
103
+
104
+ const currentLabels = labels[selectedLanguage as 'zh' | 'en'] || labels.zh;
105
+
106
+ useEffect(() => {
107
+ loadAgents();
108
+ }, []);
109
+
110
+ const loadAgents = async () => {
111
+ try {
112
+ const agentsList = await gradingApi.getAgents();
113
+ setAgents(agentsList);
114
+ if (agentsList.length > 0) {
115
+ setSelectedAgent(agentsList[0].key);
116
+ }
117
+ } catch (err) {
118
+ console.error('Failed to load agents:', err);
119
+ setError('無法載入批改模式');
120
+ }
121
+ };
122
+
123
+ const handleGrade = async () => {
124
+ if (!text.trim()) {
125
+ setError('請輸入要批改的文章');
126
+ return;
127
+ }
128
+
129
+ setLoading(true);
130
+ setError('');
131
+ setResult(null);
132
+
133
+ try {
134
+ const categoryLabel = categories.find(c => c.value === selectedCategory)?.label || '一般文章';
135
+ const response = await gradingApi.gradeText({
136
+ agent_key: selectedAgent,
137
+ text: text,
138
+ category: categoryLabel,
139
+ age_group: selectedAgeGroup,
140
+ language: selectedLanguage,
141
+ });
142
+
143
+ if (response.success && response.data) {
144
+ setResult(response.data.result);
145
+ } else if (response.error) {
146
+ setError(response.error.message);
147
+ }
148
+ } catch (err) {
149
+ console.error('Grading failed:', err);
150
+ setError('批改失敗,請稍後再試');
151
+ } finally {
152
+ setLoading(false);
153
+ }
154
+ };
155
+
156
+ const handleClear = () => {
157
+ setText('');
158
+ setResult(null);
159
+ setError('');
160
+ };
161
+
162
+ // 取得當前選中的 agent
163
+ const currentAgent = agents.find(a => a.key === selectedAgent);
164
+
165
+ return (
166
+ <div className="App">
167
+ <header className="app-header">
168
+ <div className="header-title">
169
+ <h1>AI 批改平台</h1>
170
+ </div>
171
+ <div className="header-controls">
172
+ <div className="header-selector">
173
+ <label htmlFor="language">{currentLabels.language}</label>
174
+ <select
175
+ id="language"
176
+ value={selectedLanguage}
177
+ onChange={(e) => setSelectedLanguage(e.target.value)}
178
+ disabled={loading}
179
+ >
180
+ {languages.map((lang) => (
181
+ <option key={lang.value} value={lang.value}>
182
+ {lang.label}
183
+ </option>
184
+ ))}
185
+ </select>
186
+ </div>
187
+
188
+ <div className="header-selector">
189
+ <label htmlFor="age-group">{currentLabels.ageGroup}</label>
190
+ <select
191
+ id="age-group"
192
+ value={selectedAgeGroup}
193
+ onChange={(e) => setSelectedAgeGroup(e.target.value)}
194
+ disabled={loading}
195
+ >
196
+ {ageGroups.map((age) => (
197
+ <option key={age.value} value={age.value}>
198
+ {age.label}
199
+ </option>
200
+ ))}
201
+ </select>
202
+ </div>
203
+
204
+ <div className="header-selector">
205
+ <label htmlFor="category">{currentLabels.category}</label>
206
+ <select
207
+ id="category"
208
+ value={selectedCategory}
209
+ onChange={(e) => setSelectedCategory(e.target.value)}
210
+ disabled={loading}
211
+ >
212
+ {categories.map((category) => (
213
+ <option key={category.value} value={category.value}>
214
+ {category.label}
215
+ </option>
216
+ ))}
217
+ </select>
218
+ </div>
219
+
220
+ <div className="header-selector">
221
+ <label htmlFor="agent">{currentLabels.gradingMode}</label>
222
+ <select
223
+ id="agent"
224
+ value={selectedAgent}
225
+ onChange={(e) => setSelectedAgent(e.target.value)}
226
+ disabled={loading}
227
+ >
228
+ {agents.map((agent) => (
229
+ <option key={agent.key} value={agent.key}>
230
+ {agent.name}
231
+ </option>
232
+ ))}
233
+ </select>
234
+ {currentAgent && currentAgent.ui && (
235
+ <span
236
+ className="agent-badge"
237
+ style={{
238
+ backgroundColor: currentAgent.ui.color,
239
+ marginLeft: '10px',
240
+ padding: '4px 10px',
241
+ borderRadius: '15px',
242
+ color: 'white',
243
+ fontWeight: 'bold',
244
+ fontSize: '0.85rem'
245
+ }}
246
+ >
247
+ {currentAgent.ui.badge}
248
+ </span>
249
+ )}
250
+ </div>
251
+ </div>
252
+ </header>
253
+
254
+ <div className="main-container">
255
+ <div className="input-panel">
256
+
257
+ <div className="text-input">
258
+ <textarea
259
+ value={text}
260
+ onChange={(e) => setText(e.target.value)}
261
+ placeholder={currentLabels.placeholder}
262
+ disabled={loading}
263
+ rows={15}
264
+ />
265
+ </div>
266
+
267
+ <div className="action-buttons">
268
+ <button
269
+ className="btn btn-primary"
270
+ onClick={handleGrade}
271
+ disabled={loading || !text.trim()}
272
+ >
273
+ {loading ? currentLabels.gradingButton : currentLabels.gradeButton}
274
+ </button>
275
+ <button
276
+ className="btn btn-secondary"
277
+ onClick={handleClear}
278
+ disabled={loading}
279
+ >
280
+ {currentLabels.clearButton}
281
+ </button>
282
+ </div>
283
+
284
+ {error && (
285
+ <div className="error-message">
286
+ {error}
287
+ </div>
288
+ )}
289
+ </div>
290
+
291
+ <div className="result-panel">
292
+ {loading && (
293
+ <div className="loading">
294
+ <div className="spinner"></div>
295
+ <p>{currentLabels.loadingMessage}</p>
296
+ </div>
297
+ )}
298
+
299
+ {!loading && result && (
300
+ <div className="result-container">
301
+ <h2>{currentLabels.resultTitle}</h2>
302
+
303
+ {result.summary && (
304
+ <div className="result-section">
305
+ <h3>{currentLabels.summaryTitle}</h3>
306
+ <div className="summary-content">
307
+ {result.summary.split('\n\n').map((paragraph, index) => {
308
+ // Handle both Chinese and English format
309
+ if (paragraph.startsWith('【') || paragraph.startsWith('Overall Score:')) {
310
+ const [title, ...content] = paragraph.split('\n');
311
+ // Translate title if needed
312
+ let displayTitle = title;
313
+ if (selectedLanguage === 'en' && title.includes('綜合評分')) {
314
+ displayTitle = title.replace('綜合評分:', 'Overall Score: ');
315
+ }
316
+ return (
317
+ <div key={index} className="evaluation-category">
318
+ <h4 className="category-title">{displayTitle}</h4>
319
+ <p className="category-content">{content.join('\n')}</p>
320
+ </div>
321
+ );
322
+ }
323
+ return <p key={index}>{paragraph}</p>;
324
+ })}
325
+ </div>
326
+ </div>
327
+ )}
328
+
329
+ {result.inline_edits && (
330
+ <div className="result-section">
331
+ <h3>{currentLabels.editsTitle}</h3>
332
+ <div className="inline-edits">
333
+ {(() => {
334
+ try {
335
+ const data = JSON.parse(result.inline_edits);
336
+ return (
337
+ <div className="detailed-feedback">
338
+ {Object.entries(data)
339
+ .filter(([key]) => !result.suggestions?.some(s => s.type === key))
340
+ .map(([key, value]) => (
341
+ <div key={key} className="feedback-item">
342
+ <h4 className="feedback-title">
343
+ {key === 'theme_content' ? (selectedLanguage === 'en' ? 'Theme & Content' : '主題與內容') :
344
+ key === 'word_sentence' ? (selectedLanguage === 'en' ? 'Word Choice & Sentences' : '遣詞造句') :
345
+ key === 'paragraph_structure' ? (selectedLanguage === 'en' ? 'Paragraph Structure' : '段落結構') :
346
+ key === 'typo_check' ? (selectedLanguage === 'en' ? 'Spelling & Grammar' : '錯別字檢查') : key}
347
+ </h4>
348
+ <p className="feedback-content">{String(value)}</p>
349
+ </div>
350
+ ))}
351
+ </div>
352
+ );
353
+ } catch {
354
+ return (
355
+ <pre style={{whiteSpace: 'pre-wrap', fontFamily: 'inherit'}}>
356
+ {result.inline_edits}
357
+ </pre>
358
+ );
359
+ }
360
+ })()}
361
+ </div>
362
+ </div>
363
+ )}
364
+
365
+ {result.scores && (
366
+ <div className="result-section">
367
+ <h3>{currentLabels.scoresTitle}</h3>
368
+
369
+ {/* 評分表格 */}
370
+ <div className="score-table">
371
+ <table>
372
+ <thead>
373
+ <tr>
374
+ <th>{currentLabels.structureLabel}</th>
375
+ <th>{currentLabels.gradeLabel}</th>
376
+ <th>{currentLabels.explanationLabel}</th>
377
+ </tr>
378
+ </thead>
379
+ <tbody>
380
+ {result.scores.criteria && Object.entries(result.scores.criteria).map(([key, value]) => {
381
+ // Map score to grade based on the grading scale
382
+ const getGrade = (score: number) => {
383
+ if (score >= 95) return 'A';
384
+ if (score >= 88) return 'A-';
385
+ if (score >= 83) return 'B+';
386
+ if (score >= 78) return 'B';
387
+ if (score >= 73) return 'B-';
388
+ if (score >= 68) return 'C+';
389
+ if (score >= 63) return 'C';
390
+ return 'D';
391
+ };
392
+
393
+ const getExplanation = () => {
394
+ const suggestion = result.suggestions?.find(s => s.type === key);
395
+ return suggestion?.text || (selectedLanguage === 'en' ? 'Good performance' : '表現良好');
396
+ };
397
+
398
+ const getCriteriaName = () => {
399
+ if (selectedLanguage === 'en') {
400
+ if (key === 'theme_content' || key === '主題與內容') return 'Theme & Content';
401
+ if (key === 'word_sentence' || key === '遣詞造句') return 'Word Choice';
402
+ if (key === 'paragraph_structure' || key === '段落結構') return 'Structure';
403
+ if (key === 'typo_check' || key === '錯別字檢查') return 'Spelling & Grammar';
404
+ return key;
405
+ }
406
+ // Chinese
407
+ if (key === 'theme_content' || key === '主題與內容') return '主題與內容';
408
+ if (key === 'word_sentence' || key === '遣詞造句') return '遣詞造句';
409
+ if (key === 'paragraph_structure' || key === '段落結構') return '段落結構';
410
+ if (key === 'typo_check' || key === '錯別字檢查') return '錯別字檢查';
411
+ return key;
412
+ };
413
+
414
+ return (
415
+ <tr key={key}>
416
+ <td className="criteria-name">{getCriteriaName()}</td>
417
+ <td className="criteria-grade">{getGrade(value as number)}</td>
418
+ <td className="criteria-explanation">{getExplanation()}</td>
419
+ </tr>
420
+ );
421
+ })}
422
+ {result.scores.overall && (
423
+ <tr className="overall-row">
424
+ <td className="criteria-name">{currentLabels.overallLabel}</td>
425
+ <td className="criteria-grade">
426
+ <span className="overall-emoji">
427
+ {result.scores.overall >= 80 ? '🟢' :
428
+ result.scores.overall >= 60 ? '🟡' : '🔴'}
429
+ </span>
430
+ <span className="overall-score-number">
431
+ {result.scores.overall}{selectedLanguage === 'en' ? ' points' : '分'}
432
+ </span>
433
+ </td>
434
+ <td className="criteria-explanation">
435
+ {currentLabels.overallExplanation}
436
+ </td>
437
+ </tr>
438
+ )}
439
+ </tbody>
440
+ </table>
441
+ </div>
442
+ </div>
443
+ )}
444
+
445
+ {result.suggestions && result.suggestions.length > 0 && (
446
+ <div className="result-section">
447
+ <h3>{currentLabels.suggestionsTitle}</h3>
448
+ <div className="suggestions-container">
449
+ {result.suggestions.filter(s => s.type === 'correction').map((suggestion, index) => {
450
+ const lines = suggestion.text.split('\n');
451
+ const original = lines.find(l => l.startsWith('原文:') || l.startsWith('Original:'))?.replace(/^(原文:|Original:)/, '') || '';
452
+ const corrected = lines.find(l => l.startsWith('修改:') || l.startsWith('Revised:'))?.replace(/^(修改:|Revised:)/, '') || '';
453
+ const reason = lines.find(l => l.startsWith('原因:') || l.startsWith('Reason:'))?.replace(/^(原因:|Reason:)/, '') || '';
454
+
455
+ return (
456
+ <div key={index} className="correction-item">
457
+ <div className="correction-header">
458
+ <span className="correction-label">{currentLabels.correctionLabel} {index + 1}</span>
459
+ </div>
460
+ <div className="correction-content">
461
+ <div className="correction-original">
462
+ <strong>{currentLabels.originalLabel}</strong>
463
+ <span>{original}</span>
464
+ </div>
465
+ <div className="correction-revised">
466
+ <strong>{currentLabels.revisedLabel}</strong>
467
+ <span className="corrected-text">{corrected}</span>
468
+ </div>
469
+ <div className="correction-reason">
470
+ <strong>{currentLabels.reasonLabel}</strong>
471
+ <span>{reason}</span>
472
+ </div>
473
+ </div>
474
+ </div>
475
+ );
476
+ })}
477
+
478
+ {result.suggestions.filter(s => s.type !== 'correction' && s.section !== 'evaluation').map((suggestion, index) => (
479
+ <div key={`other-${index}`} className="suggestion-item">
480
+ <strong>{suggestion.type}:</strong>
481
+ <span>{suggestion.text}</span>
482
+ </div>
483
+ ))}
484
+ </div>
485
+ </div>
486
+ )}
487
+ </div>
488
+ )}
489
+ </div>
490
+ </div>
491
+ </div>
492
+ );
493
+ }
494
+
495
+ export default App;
frontend/src/api/grading.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+ import type { Agent, AgentsResponse, GradeRequest, GradeResponse } from '../types/index';
3
+
4
+ const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001/api';
5
+
6
+ const api = axios.create({
7
+ baseURL: API_BASE_URL,
8
+ headers: {
9
+ 'Content-Type': 'application/json',
10
+ },
11
+ });
12
+
13
+ export const gradingApi = {
14
+ async getAgents(): Promise<Agent[]> {
15
+ const response = await api.get<AgentsResponse>('/agents');
16
+ return response.data.agents;
17
+ },
18
+
19
+ async gradeText(request: GradeRequest): Promise<GradeResponse> {
20
+ const response = await api.post<GradeResponse>('/grade', request);
21
+ return response.data;
22
+ },
23
+ };
frontend/src/assets/react.svg ADDED
frontend/src/index.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3
+ line-height: 1.5;
4
+ font-weight: 400;
5
+
6
+ color-scheme: light dark;
7
+ color: rgba(255, 255, 255, 0.87);
8
+ background-color: #242424;
9
+
10
+ font-synthesis: none;
11
+ text-rendering: optimizeLegibility;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
15
+
16
+ a {
17
+ font-weight: 500;
18
+ color: #646cff;
19
+ text-decoration: inherit;
20
+ }
21
+ a:hover {
22
+ color: #535bf2;
23
+ }
24
+
25
+ body {
26
+ margin: 0;
27
+ display: flex;
28
+ place-items: center;
29
+ min-width: 320px;
30
+ min-height: 100vh;
31
+ }
32
+
33
+ h1 {
34
+ font-size: 3.2em;
35
+ line-height: 1.1;
36
+ }
37
+
38
+ button {
39
+ border-radius: 8px;
40
+ border: 1px solid transparent;
41
+ padding: 0.6em 1.2em;
42
+ font-size: 1em;
43
+ font-weight: 500;
44
+ font-family: inherit;
45
+ background-color: #1a1a1a;
46
+ cursor: pointer;
47
+ transition: border-color 0.25s;
48
+ }
49
+ button:hover {
50
+ border-color: #646cff;
51
+ }
52
+ button:focus,
53
+ button:focus-visible {
54
+ outline: 4px auto -webkit-focus-ring-color;
55
+ }
56
+
57
+ @media (prefers-color-scheme: light) {
58
+ :root {
59
+ color: #213547;
60
+ background-color: #ffffff;
61
+ }
62
+ a:hover {
63
+ color: #747bff;
64
+ }
65
+ button {
66
+ background-color: #f9f9f9;
67
+ }
68
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/src/types/index.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type Agent = {
2
+ key: string;
3
+ name: string;
4
+ language: string;
5
+ ui?: {
6
+ badge: string;
7
+ color: string;
8
+ };
9
+ rubric?: {
10
+ overall_weight: number;
11
+ criteria: Array<{
12
+ key: string;
13
+ label: string;
14
+ weight: number;
15
+ }>;
16
+ };
17
+ }
18
+
19
+ export type GradeRequest = {
20
+ agent_key: string;
21
+ text: string;
22
+ category?: string;
23
+ age_group?: string;
24
+ language?: string;
25
+ options?: Record<string, any>;
26
+ }
27
+
28
+ export type Suggestion = {
29
+ type: string;
30
+ section: string;
31
+ text: string;
32
+ }
33
+
34
+ export type Scores = {
35
+ overall?: number;
36
+ criteria?: Record<string, number>;
37
+ }
38
+
39
+ export type GradeResult = {
40
+ summary: string;
41
+ inline_edits: string;
42
+ scores?: Scores;
43
+ suggestions?: Suggestion[];
44
+ }
45
+
46
+ export type GradeResponse = {
47
+ success: boolean;
48
+ data?: {
49
+ agent_key: string;
50
+ result: GradeResult;
51
+ };
52
+ error?: {
53
+ code: string;
54
+ message: string;
55
+ };
56
+ }
57
+
58
+ export type AgentsResponse = {
59
+ version: number;
60
+ agents: Agent[];
61
+ }
frontend/src/vite-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />
frontend/tsconfig.app.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+ "jsx": "react-jsx",
17
+
18
+ /* Linting */
19
+ "strict": true,
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true,
22
+ "erasableSyntaxOnly": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "noUncheckedSideEffectImports": true
25
+ },
26
+ "include": ["src"]
27
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+
16
+ /* Linting */
17
+ "strict": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "erasableSyntaxOnly": true,
21
+ "noFallthroughCasesInSwitch": true,
22
+ "noUncheckedSideEffectImports": true
23
+ },
24
+ "include": ["vite.config.ts"]
25
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })