feat: Complete AI grading platform with multilingual support
Browse filesFeatures 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 +33 -0
- .gitignore +98 -0
- API.md +434 -0
- README.md +72 -1
- agents.yaml +83 -0
- backend/.env.example +5 -0
- backend/.gitignore +57 -0
- backend/Dockerfile +16 -0
- backend/agents.yaml +83 -0
- backend/app/config/agents.py +104 -0
- backend/app/config/settings.py +31 -0
- backend/app/models/grading.py +57 -0
- backend/app/prompts/grading_prompt.py +178 -0
- backend/app/routers/grading.py +104 -0
- backend/app/services/assistant_setup.py +89 -0
- backend/app/services/openai_service.py +382 -0
- backend/main.py +65 -0
- backend/requirements.txt +10 -0
- docker-compose.yml +34 -0
- frontend/.gitignore +24 -0
- frontend/Dockerfile +31 -0
- frontend/README.md +69 -0
- frontend/eslint.config.js +23 -0
- frontend/index.html +13 -0
- frontend/nginx.conf +21 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +30 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.css +661 -0
- frontend/src/App.tsx +495 -0
- frontend/src/api/grading.ts +23 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/index.css +68 -0
- frontend/src/main.tsx +10 -0
- frontend/src/types/index.ts +61 -0
- frontend/src/vite-env.d.ts +1 -0
- frontend/tsconfig.app.json +27 -0
- frontend/tsconfig.json +7 -0
- frontend/tsconfig.node.json +25 -0
- frontend/vite.config.ts +7 -0
|
@@ -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*
|
|
@@ -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
|
|
@@ -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
|
|
@@ -7,4 +7,75 @@ sdk: docker
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 容器化部署
|
|
@@ -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"
|
|
@@ -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
|
|
@@ -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/
|
|
@@ -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"]
|
|
@@ -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"
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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]
|
|
@@ -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 |
+
)
|
|
@@ -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"}
|
|
@@ -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")
|
|
@@ -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
|
|
@@ -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 |
+
)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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?
|
|
@@ -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;"]
|
|
@@ -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 |
+
```
|
|
@@ -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 |
+
])
|
|
@@ -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>
|
|
@@ -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 |
+
}
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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 |
+
}
|
|
|
|
@@ -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 |
+
}
|
|
@@ -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;
|
|
@@ -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 |
+
};
|
|
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
}
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
|
@@ -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 |
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
})
|