Youngger9765 Claude commited on
Commit ·
c511d53
1
Parent(s): 4b54973
feat: Refactor assistant system with auto-matching and manual selection
Browse files- Refactor agents.yaml with 6 real OpenAI assistants configuration
- Change language code from zh-Hant to zh-TW throughout codebase
- Implement smart assistant matching system with scoring algorithm
- Add frontend assistant selector dropdown next to grade button
- Fix all assistant IDs to match actual OpenAI assistants
- Add check_assistants.py utility to verify assistant availability
- Support Chinese/English aliases for age groups, categories, and modes
- Filter to show only strictly matching assistants in dropdown
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- API.md +1 -1
- agents.yaml +202 -81
- backend/agents.yaml +0 -83
- backend/app/config/agents.py +219 -77
- backend/app/models/grading.py +11 -15
- backend/app/prompts/grading_prompt.py +6 -3
- backend/app/routers/grading.py +257 -53
- backend/app/services/openai_service.py +1 -1
- backend/check_assistants.py +91 -0
- frontend/src/App.tsx +169 -110
- frontend/src/api/grading.ts +34 -4
- frontend/src/types/index.ts +32 -24
API.md
CHANGED
|
@@ -36,7 +36,7 @@ Authorization: Bearer YOUR_API_TOKEN
|
|
| 36 |
{
|
| 37 |
"key": "zh_full_edit",
|
| 38 |
"name": "中文全文批改",
|
| 39 |
-
"language": "zh-
|
| 40 |
"ui": {
|
| 41 |
"badge": "ZH",
|
| 42 |
"color": "#1565C0"
|
|
|
|
| 36 |
{
|
| 37 |
"key": "zh_full_edit",
|
| 38 |
"name": "中文全文批改",
|
| 39 |
+
"language": "zh-TW",
|
| 40 |
"ui": {
|
| 41 |
"badge": "ZH",
|
| 42 |
"color": "#1565C0"
|
agents.yaml
CHANGED
|
@@ -1,83 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
version: 2
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
temperature: 0.2
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 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"
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# 📖 使用說明 - 如何新增批改機器人
|
| 3 |
+
# ============================================================
|
| 4 |
+
#
|
| 5 |
+
# 1️⃣ 複製下面的格式新增機器人:
|
| 6 |
+
#
|
| 7 |
+
# - name: "你的機器人名稱"
|
| 8 |
+
# assistant_id: "asst_xxxxxxxxxx" # 從 OpenAI 取得的 Assistant ID
|
| 9 |
+
# language: "zh-TW" # zh-TW=中文, en=英文
|
| 10 |
+
# age_groups: ["國小"] # 可用中文或英文 key
|
| 11 |
+
# categories: ["記敘文"] # 可多選:["記敘文", "議論文"]
|
| 12 |
+
# grading_modes: ["全文批改"] # 支援的批改模式
|
| 13 |
+
# temperature: 0.3 # 0.1-1.0,越小越穩定
|
| 14 |
+
# enabled: true # true=啟用, false=停用
|
| 15 |
+
#
|
| 16 |
+
# 2️⃣ 萬用字元(處理所有選項):
|
| 17 |
+
# age_groups: ["*"] ← 表示所有年齡層
|
| 18 |
+
#
|
| 19 |
+
# 3️⃣ 多選(處理多個選項):
|
| 20 |
+
# age_groups: ["國小", "國中"] ← 處理國小和國中
|
| 21 |
+
# 或混用中英文:["elementary", "國中"] ← 也可以!
|
| 22 |
+
#
|
| 23 |
+
# 4️⃣ 修改完存檔,重啟服務即生效!
|
| 24 |
+
#
|
| 25 |
+
# ============================================================
|
| 26 |
+
|
| 27 |
version: 2
|
| 28 |
+
|
| 29 |
+
# ============================================================
|
| 30 |
+
# 前端下拉選單選項定義
|
| 31 |
+
# ============================================================
|
| 32 |
+
options:
|
| 33 |
+
age_groups:
|
| 34 |
+
- key: "elementary"
|
| 35 |
+
aliases: ["國小", "小學"]
|
| 36 |
+
label: "國小"
|
| 37 |
+
|
| 38 |
+
- key: "junior_high"
|
| 39 |
+
aliases: ["國中", "初中"]
|
| 40 |
+
label: "國中"
|
| 41 |
+
|
| 42 |
+
- key: "senior_high"
|
| 43 |
+
aliases: ["高中"]
|
| 44 |
+
label: "高中"
|
| 45 |
+
|
| 46 |
+
- key: "university"
|
| 47 |
+
aliases: ["大學", "大學生"]
|
| 48 |
+
label: "大學"
|
| 49 |
+
|
| 50 |
+
- key: "working"
|
| 51 |
+
aliases: ["上班族", "職場"]
|
| 52 |
+
label: "上班族"
|
| 53 |
+
|
| 54 |
+
- key: "retired"
|
| 55 |
+
aliases: ["退休人士", "退休"]
|
| 56 |
+
label: "退休人士"
|
| 57 |
+
|
| 58 |
+
categories:
|
| 59 |
+
- key: "literature"
|
| 60 |
+
aliases: ["文學小說", "小說"]
|
| 61 |
+
label: "文學小說"
|
| 62 |
+
|
| 63 |
+
- key: "poetry"
|
| 64 |
+
aliases: ["詩集", "詩詞"]
|
| 65 |
+
label: "詩集"
|
| 66 |
+
|
| 67 |
+
- key: "psychology"
|
| 68 |
+
aliases: ["心理勵志", "勵志"]
|
| 69 |
+
label: "心理勵志"
|
| 70 |
+
|
| 71 |
+
- key: "art_design"
|
| 72 |
+
aliases: ["藝術設計", "設計"]
|
| 73 |
+
label: "藝術設計"
|
| 74 |
+
|
| 75 |
+
- key: "travel"
|
| 76 |
+
aliases: ["觀光旅遊", "旅遊"]
|
| 77 |
+
label: "觀光旅遊"
|
| 78 |
+
|
| 79 |
+
- key: "food"
|
| 80 |
+
aliases: ["美食饗宴", "美食"]
|
| 81 |
+
label: "美食饗宴"
|
| 82 |
+
|
| 83 |
+
- key: "marketing"
|
| 84 |
+
aliases: ["行銷文案", "文案"]
|
| 85 |
+
label: "行銷文案"
|
| 86 |
+
|
| 87 |
+
- key: "business"
|
| 88 |
+
aliases: ["商業理財", "理財"]
|
| 89 |
+
label: "商業理財"
|
| 90 |
+
|
| 91 |
+
- key: "humanities"
|
| 92 |
+
aliases: ["人文史地", "歷史"]
|
| 93 |
+
label: "人文史地"
|
| 94 |
+
|
| 95 |
+
- key: "science"
|
| 96 |
+
aliases: ["自然科普", "科普"]
|
| 97 |
+
label: "自然科普"
|
| 98 |
+
|
| 99 |
+
- key: "comics"
|
| 100 |
+
aliases: ["漫畫圖文", "漫畫"]
|
| 101 |
+
label: "漫畫圖文"
|
| 102 |
+
|
| 103 |
+
- key: "education"
|
| 104 |
+
aliases: ["教育學習", "教育"]
|
| 105 |
+
label: "教育學習"
|
| 106 |
+
|
| 107 |
+
- key: "lifestyle"
|
| 108 |
+
aliases: ["生活休閒", "休閒"]
|
| 109 |
+
label: "生活休閒"
|
| 110 |
+
|
| 111 |
+
- key: "creative"
|
| 112 |
+
aliases: ["創意想像", "創意"]
|
| 113 |
+
label: "創意想像"
|
| 114 |
+
|
| 115 |
+
- key: "technology"
|
| 116 |
+
aliases: ["資訊科技", "科技"]
|
| 117 |
+
label: "資訊科技"
|
| 118 |
+
|
| 119 |
+
- key: "social"
|
| 120 |
+
aliases: ["社會議題", "社會"]
|
| 121 |
+
label: "社會議題"
|
| 122 |
+
|
| 123 |
+
grading_modes:
|
| 124 |
+
- key: "full_edit"
|
| 125 |
+
aliases: ["全文批改", "完整批改"]
|
| 126 |
+
label: "全文批改"
|
| 127 |
+
|
| 128 |
+
- key: "structure_only"
|
| 129 |
+
aliases: ["結構建議", "架構建議"]
|
| 130 |
+
label: "結構建議"
|
| 131 |
+
|
| 132 |
+
- key: "grammar_only"
|
| 133 |
+
aliases: ["文法修正", "語法修正"]
|
| 134 |
+
label: "文法修正"
|
| 135 |
+
|
| 136 |
+
# ============================================================
|
| 137 |
+
# 批改機器人配置
|
| 138 |
+
# ============================================================
|
| 139 |
+
assistants:
|
| 140 |
+
# ============================================================
|
| 141 |
+
# 國小專用機器人
|
| 142 |
+
# ============================================================
|
| 143 |
+
- name: "小學作文"
|
| 144 |
+
assistant_id: "asst_uEstIWft6VV91tm9gLjpjr7n"
|
| 145 |
+
language: "zh-TW"
|
| 146 |
+
age_groups: ["國小"]
|
| 147 |
+
categories: ["*"]
|
| 148 |
+
grading_modes: ["全文批改"]
|
| 149 |
+
temperature: 0.3
|
| 150 |
+
enabled: true
|
| 151 |
+
|
| 152 |
+
# ============================================================
|
| 153 |
+
# 全文批改機器人
|
| 154 |
+
# ============================================================
|
| 155 |
+
- name: "作文練習整體回饋"
|
| 156 |
+
assistant_id: "asst_o2ikYoJqDKQHny24FjS7iyzN"
|
| 157 |
+
language: "zh-TW"
|
| 158 |
+
age_groups: ["國中", "高中", "大學", "上班族", "退休人士"]
|
| 159 |
+
categories: ["*"]
|
| 160 |
+
grading_modes: ["全文批改"]
|
| 161 |
+
temperature: 0.3
|
| 162 |
+
enabled: true
|
| 163 |
+
|
| 164 |
+
# ============================================================
|
| 165 |
+
# 結構建議機器人
|
| 166 |
+
# ============================================================
|
| 167 |
+
- name: "段落批改機器人"
|
| 168 |
+
assistant_id: "asst_7Z3PCVKqmXfruwOxnOprAMH2"
|
| 169 |
+
language: "zh-TW"
|
| 170 |
+
age_groups: ["*"]
|
| 171 |
+
categories: ["*"]
|
| 172 |
+
grading_modes: ["結構建議"]
|
| 173 |
+
temperature: 0.25
|
| 174 |
+
enabled: true
|
| 175 |
+
|
| 176 |
+
- name: "段落擴寫"
|
| 177 |
+
assistant_id: "asst_LrV6pEWdZXljI9d0VfyHKn4j"
|
| 178 |
+
language: "zh-TW"
|
| 179 |
+
age_groups: ["*"]
|
| 180 |
+
categories: ["*"]
|
| 181 |
+
grading_modes: ["結構建議"]
|
| 182 |
+
temperature: 0.3
|
| 183 |
+
enabled: false # 先停用,避免跟段落批改衝突
|
| 184 |
+
|
| 185 |
+
# ============================================================
|
| 186 |
+
# 文法修正機器人
|
| 187 |
+
# ============================================================
|
| 188 |
+
- name: "錯別字機器人"
|
| 189 |
+
assistant_id: "asst_5PNgAr7DQl8jAVFxIUvrhG5Y"
|
| 190 |
+
language: "zh-TW"
|
| 191 |
+
age_groups: ["*"]
|
| 192 |
+
categories: ["*"]
|
| 193 |
+
grading_modes: ["文法修正"]
|
| 194 |
temperature: 0.2
|
| 195 |
+
enabled: true
|
| 196 |
+
|
| 197 |
+
- name: "句型改寫"
|
| 198 |
+
assistant_id: "asst_byI8gkZPMZZGczX1UyDpV6vh"
|
| 199 |
+
language: "zh-TW"
|
| 200 |
+
age_groups: ["*"]
|
| 201 |
+
categories: ["*"]
|
| 202 |
+
grading_modes: ["文法修正"]
|
| 203 |
+
temperature: 0.25
|
| 204 |
+
enabled: false # 先停用,避免跟錯別字機器人衝突
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/agents.yaml
DELETED
|
@@ -1,83 +0,0 @@
|
|
| 1 |
-
version: 2
|
| 2 |
-
defaults:
|
| 3 |
-
model: "gpt-4"
|
| 4 |
-
temperature: 0.2
|
| 5 |
-
output_format: "markdown"
|
| 6 |
-
max_tokens: 2000
|
| 7 |
-
|
| 8 |
-
agents:
|
| 9 |
-
zh_full_edit:
|
| 10 |
-
name: "小學作文批改"
|
| 11 |
-
assistant_id: "asst_MEzXFAvPbkbn85Ca1gQ7vKa9"
|
| 12 |
-
language: "zh-Hant"
|
| 13 |
-
temperature: 0.2
|
| 14 |
-
rubric:
|
| 15 |
-
overall_weight: 100
|
| 16 |
-
criteria:
|
| 17 |
-
- key: "structure"
|
| 18 |
-
label: "結構與段落"
|
| 19 |
-
weight: 25
|
| 20 |
-
- key: "coherence"
|
| 21 |
-
label: "論證與連貫"
|
| 22 |
-
weight: 25
|
| 23 |
-
- key: "clarity"
|
| 24 |
-
label: "表達清晰度"
|
| 25 |
-
weight: 25
|
| 26 |
-
- key: "mechanics"
|
| 27 |
-
label: "字詞/標點/語法"
|
| 28 |
-
weight: 25
|
| 29 |
-
response_contract:
|
| 30 |
-
fields:
|
| 31 |
-
- key: "summary"
|
| 32 |
-
type: "text"
|
| 33 |
-
required: true
|
| 34 |
-
- key: "inline_edits"
|
| 35 |
-
type: "richtext"
|
| 36 |
-
required: true
|
| 37 |
-
- key: "scores"
|
| 38 |
-
type: "object"
|
| 39 |
-
required: false
|
| 40 |
-
- key: "suggestions"
|
| 41 |
-
type: "list"
|
| 42 |
-
required: false
|
| 43 |
-
ui:
|
| 44 |
-
badge: "ZH"
|
| 45 |
-
color: "#1565C0"
|
| 46 |
-
|
| 47 |
-
en_edit:
|
| 48 |
-
name: "英文批改"
|
| 49 |
-
assistant_id: "asst_xxxxxxxxx_en" # 請替換為實際的 Assistant ID
|
| 50 |
-
language: "en"
|
| 51 |
-
temperature: 0.1
|
| 52 |
-
rubric:
|
| 53 |
-
overall_weight: 100
|
| 54 |
-
criteria:
|
| 55 |
-
- key: "organization"
|
| 56 |
-
label: "Organization"
|
| 57 |
-
weight: 20
|
| 58 |
-
- key: "argumentation"
|
| 59 |
-
label: "Argumentation"
|
| 60 |
-
weight: 30
|
| 61 |
-
- key: "clarity"
|
| 62 |
-
label: "Clarity & Style"
|
| 63 |
-
weight: 25
|
| 64 |
-
- key: "mechanics"
|
| 65 |
-
label: "Grammar & Spelling"
|
| 66 |
-
weight: 25
|
| 67 |
-
response_contract:
|
| 68 |
-
fields:
|
| 69 |
-
- key: "summary"
|
| 70 |
-
type: "text"
|
| 71 |
-
required: true
|
| 72 |
-
- key: "inline_edits"
|
| 73 |
-
type: "richtext"
|
| 74 |
-
required: true
|
| 75 |
-
- key: "overall_score"
|
| 76 |
-
type: "number"
|
| 77 |
-
required: false
|
| 78 |
-
- key: "criteria_scores"
|
| 79 |
-
type: "object"
|
| 80 |
-
required: false
|
| 81 |
-
ui:
|
| 82 |
-
badge: "EN"
|
| 83 |
-
color: "#2E7D32"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/config/agents.py
CHANGED
|
@@ -2,103 +2,245 @@ import yaml
|
|
| 2 |
from typing import Dict, Any, List, Optional
|
| 3 |
from pathlib import Path
|
| 4 |
from pydantic import BaseModel
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
key: str
|
| 8 |
-
label: str
|
| 9 |
-
weight: int
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
|
| 15 |
-
class
|
| 16 |
key: str
|
| 17 |
-
|
| 18 |
-
|
| 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 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
class AgentsConfig:
|
| 37 |
-
def __init__(self, config_path: str = "agents.yaml"):
|
| 38 |
self.config_path = Path(config_path)
|
| 39 |
-
self.
|
| 40 |
-
self.
|
| 41 |
-
self.version: int =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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',
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def
|
| 82 |
-
"""
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
}
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
-
|
| 98 |
-
agents_config = None
|
| 99 |
|
| 100 |
def get_agents_config() -> AgentsConfig:
|
| 101 |
-
global
|
| 102 |
-
if
|
| 103 |
-
|
| 104 |
-
return
|
|
|
|
| 2 |
from typing import Dict, Any, List, Optional
|
| 3 |
from pathlib import Path
|
| 4 |
from pydantic import BaseModel
|
| 5 |
+
import logging
|
| 6 |
|
| 7 |
+
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
# ============================================================
|
| 10 |
+
# Pydantic Models
|
| 11 |
+
# ============================================================
|
| 12 |
|
| 13 |
+
class Option(BaseModel):
|
| 14 |
key: str
|
| 15 |
+
label: str
|
| 16 |
+
aliases: Optional[List[str]] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
class Agent(BaseModel):
|
| 19 |
+
"""Represents a grading assistant"""
|
| 20 |
name: str
|
| 21 |
assistant_id: str
|
| 22 |
language: str
|
| 23 |
+
age_groups: List[str] # Can include aliases or "*"
|
| 24 |
+
categories: List[str] # Can include aliases or "*"
|
| 25 |
+
grading_modes: List[str] # Can include aliases or "*"
|
| 26 |
+
temperature: Optional[float] = 0.2
|
| 27 |
+
enabled: Optional[bool] = True
|
| 28 |
+
|
| 29 |
+
# ============================================================
|
| 30 |
+
# Main Configuration Class
|
| 31 |
+
# ============================================================
|
| 32 |
|
| 33 |
class AgentsConfig:
|
| 34 |
+
def __init__(self, config_path: str = "../agents.yaml"):
|
| 35 |
self.config_path = Path(config_path)
|
| 36 |
+
self.assistants: List[Agent] = []
|
| 37 |
+
self.options: Dict[str, List[Option]] = {}
|
| 38 |
+
self.version: int = 2
|
| 39 |
+
|
| 40 |
+
# Alias mapping: {option_type: {alias: key}}
|
| 41 |
+
self._alias_map: Dict[str, Dict[str, str]] = {}
|
| 42 |
+
|
| 43 |
self._load_config()
|
| 44 |
+
|
| 45 |
def _load_config(self):
|
| 46 |
"""Load and parse agents.yaml configuration"""
|
| 47 |
if not self.config_path.exists():
|
| 48 |
raise FileNotFoundError(f"Configuration file {self.config_path} not found")
|
| 49 |
+
|
| 50 |
with open(self.config_path, 'r', encoding='utf-8') as f:
|
| 51 |
config = yaml.safe_load(f)
|
| 52 |
+
|
| 53 |
+
self.version = config.get('version', 2)
|
| 54 |
+
|
| 55 |
+
# Parse options and build alias map
|
| 56 |
+
self._parse_options(config.get('options', {}))
|
| 57 |
+
|
| 58 |
+
# Parse assistants
|
| 59 |
+
self._parse_assistants(config.get('assistants', []))
|
| 60 |
+
|
| 61 |
+
logger.info(f"Loaded {len(self.assistants)} assistants from config")
|
| 62 |
+
|
| 63 |
+
def _parse_options(self, options_data: Dict[str, List[Dict]]):
|
| 64 |
+
"""Parse options and build alias mapping"""
|
| 65 |
+
for option_type, options_list in options_data.items():
|
| 66 |
+
self.options[option_type] = []
|
| 67 |
+
self._alias_map[option_type] = {}
|
| 68 |
+
|
| 69 |
+
for opt_data in options_list:
|
| 70 |
+
option = Option(**opt_data)
|
| 71 |
+
self.options[option_type].append(option)
|
| 72 |
+
|
| 73 |
+
# Map key to itself
|
| 74 |
+
self._alias_map[option_type][option.key] = option.key
|
| 75 |
+
|
| 76 |
+
# Map all aliases to the key
|
| 77 |
+
for alias in option.aliases:
|
| 78 |
+
self._alias_map[option_type][alias] = option.key
|
| 79 |
+
|
| 80 |
+
logger.info(f"Built alias maps: {list(self._alias_map.keys())}")
|
| 81 |
+
|
| 82 |
+
def _parse_assistants(self, assistants_data: List[Dict]):
|
| 83 |
+
"""Parse assistants configuration"""
|
| 84 |
+
for assistant_data in assistants_data:
|
| 85 |
+
if not assistant_data.get('enabled', True):
|
| 86 |
+
logger.info(f"Skipping disabled assistant: {assistant_data.get('name')}")
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
# Normalize age_groups, categories, grading_modes to use standard keys
|
| 90 |
+
age_groups_normalized = self._normalize_list('age_groups', assistant_data.get('age_groups', ['*']))
|
| 91 |
+
categories_normalized = self._normalize_list('categories', assistant_data.get('categories', ['*']))
|
| 92 |
+
modes_normalized = self._normalize_list('grading_modes', assistant_data.get('grading_modes', ['*']))
|
| 93 |
+
|
| 94 |
+
assistant = Agent(
|
| 95 |
+
name=assistant_data['name'],
|
| 96 |
+
assistant_id=assistant_data['assistant_id'],
|
| 97 |
+
language=assistant_data.get('language', 'zh-TW'),
|
| 98 |
+
age_groups=age_groups_normalized,
|
| 99 |
+
categories=categories_normalized,
|
| 100 |
+
grading_modes=modes_normalized,
|
| 101 |
+
temperature=assistant_data.get('temperature', 0.2),
|
| 102 |
+
enabled=assistant_data.get('enabled', True)
|
| 103 |
+
)
|
| 104 |
+
self.assistants.append(assistant)
|
| 105 |
+
|
| 106 |
+
logger.debug(f"Loaded assistant: {assistant.name} | age_groups={assistant.age_groups} | categories={assistant.categories}")
|
| 107 |
+
|
| 108 |
+
def _normalize(self, option_type: str, value: str) -> Optional[str]:
|
| 109 |
+
"""
|
| 110 |
+
Normalize a value using alias map
|
| 111 |
+
Examples:
|
| 112 |
+
normalize('age_groups', '國小') -> 'elementary'
|
| 113 |
+
normalize('age_groups', 'elementary') -> 'elementary'
|
| 114 |
+
normalize('age_groups', '*') -> '*'
|
| 115 |
+
"""
|
| 116 |
+
if value == '*':
|
| 117 |
+
return '*'
|
| 118 |
+
|
| 119 |
+
return self._alias_map.get(option_type, {}).get(value, value)
|
| 120 |
+
|
| 121 |
+
def _normalize_list(self, option_type: str, values: List[str]) -> List[str]:
|
| 122 |
+
"""Normalize a list of values"""
|
| 123 |
+
return [self._normalize(option_type, v) for v in values]
|
| 124 |
+
|
| 125 |
+
def find_assistant(
|
| 126 |
+
self,
|
| 127 |
+
language: str = "zh-TW",
|
| 128 |
+
age_group: Optional[str] = None,
|
| 129 |
+
category: Optional[str] = None,
|
| 130 |
+
grading_mode: Optional[str] = None
|
| 131 |
+
) -> Optional[Agent]:
|
| 132 |
+
"""
|
| 133 |
+
Find matching assistant with fallback logic
|
| 134 |
+
|
| 135 |
+
Priority scoring:
|
| 136 |
+
- Exact match (100 points per field)
|
| 137 |
+
- Wildcard match (10 points per field)
|
| 138 |
+
- Language must match (or returns -1)
|
| 139 |
+
|
| 140 |
+
Examples:
|
| 141 |
+
find_assistant(language='zh-TW', age_group='國小', category='記敘文')
|
| 142 |
+
find_assistant(language='zh-TW', age_group='elementary', category='narrative')
|
| 143 |
+
"""
|
| 144 |
+
# Normalize input using aliases
|
| 145 |
+
age_group_key = self._normalize('age_groups', age_group) if age_group else None
|
| 146 |
+
category_key = self._normalize('categories', category) if category else None
|
| 147 |
+
mode_key = self._normalize('grading_modes', grading_mode) if grading_mode else None
|
| 148 |
+
|
| 149 |
+
logger.info(f"Finding assistant: lang={language}, age={age_group}->{age_group_key}, cat={category}->{category_key}, mode={grading_mode}->{mode_key}")
|
| 150 |
+
|
| 151 |
+
def match_score(assistant: Agent) -> int:
|
| 152 |
+
"""Calculate match score for an assistant"""
|
| 153 |
+
score = 0
|
| 154 |
+
|
| 155 |
+
# Language must match
|
| 156 |
+
if assistant.language != language:
|
| 157 |
+
return -1
|
| 158 |
+
|
| 159 |
+
# Age group matching
|
| 160 |
+
if age_group_key:
|
| 161 |
+
if age_group_key in assistant.age_groups:
|
| 162 |
+
score += 100
|
| 163 |
+
elif '*' in assistant.age_groups:
|
| 164 |
+
score += 10
|
| 165 |
+
else:
|
| 166 |
+
return -1 # No match
|
| 167 |
+
elif '*' in assistant.age_groups:
|
| 168 |
+
score += 10
|
| 169 |
+
|
| 170 |
+
# Category matching
|
| 171 |
+
if category_key:
|
| 172 |
+
if category_key in assistant.categories:
|
| 173 |
+
score += 50
|
| 174 |
+
elif '*' in assistant.categories:
|
| 175 |
+
score += 5
|
| 176 |
+
else:
|
| 177 |
+
return -1
|
| 178 |
+
elif '*' in assistant.categories:
|
| 179 |
+
score += 5
|
| 180 |
+
|
| 181 |
+
# Grading mode matching
|
| 182 |
+
if mode_key:
|
| 183 |
+
if mode_key in assistant.grading_modes:
|
| 184 |
+
score += 25
|
| 185 |
+
elif '*' in assistant.grading_modes:
|
| 186 |
+
score += 2
|
| 187 |
+
else:
|
| 188 |
+
return -1
|
| 189 |
+
elif '*' in assistant.grading_modes:
|
| 190 |
+
score += 2
|
| 191 |
+
|
| 192 |
+
return score
|
| 193 |
+
|
| 194 |
+
# Find all valid matches
|
| 195 |
+
matches = []
|
| 196 |
+
for assistant in self.assistants:
|
| 197 |
+
score = match_score(assistant)
|
| 198 |
+
if score >= 0:
|
| 199 |
+
matches.append((score, assistant))
|
| 200 |
+
logger.debug(f" Match: {assistant.name} (score={score})")
|
| 201 |
+
|
| 202 |
+
if not matches:
|
| 203 |
+
logger.warning(f"No assistant found for: {language}/{age_group_key}/{category_key}/{mode_key}")
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
# Sort by score (highest first) and return best match
|
| 207 |
+
matches.sort(reverse=True, key=lambda x: x[0])
|
| 208 |
+
best_match = matches[0][1]
|
| 209 |
+
|
| 210 |
+
logger.info(f"✓ Selected: {best_match.name} (score={matches[0][0]})")
|
| 211 |
+
return best_match
|
| 212 |
+
|
| 213 |
+
def get_options(self) -> Dict[str, List[Dict[str, Any]]]:
|
| 214 |
+
"""Get options for frontend dropdowns"""
|
| 215 |
+
return {
|
| 216 |
+
'age_groups': [{'key': opt.key, 'label': opt.label} for opt in self.options.get('age_groups', [])],
|
| 217 |
+
'categories': [{'key': opt.key, 'label': opt.label} for opt in self.options.get('categories', [])],
|
| 218 |
+
'grading_modes': [{'key': opt.key, 'label': opt.label} for opt in self.options.get('grading_modes', [])]
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
def list_all_assistants(self) -> List[Dict[str, Any]]:
|
| 222 |
+
"""List all assistants (for debugging)"""
|
| 223 |
+
return [
|
| 224 |
+
{
|
| 225 |
+
'name': a.name,
|
| 226 |
+
'language': a.language,
|
| 227 |
+
'age_groups': a.age_groups,
|
| 228 |
+
'categories': a.categories,
|
| 229 |
+
'grading_modes': a.grading_modes,
|
| 230 |
+
'assistant_id': a.assistant_id[:20] + '...' if len(a.assistant_id) > 20 else a.assistant_id,
|
| 231 |
+
'enabled': a.enabled
|
| 232 |
}
|
| 233 |
+
for a in self.assistants
|
| 234 |
+
]
|
| 235 |
+
|
| 236 |
+
# ============================================================
|
| 237 |
+
# Singleton Instance
|
| 238 |
+
# ============================================================
|
| 239 |
|
| 240 |
+
_config = None
|
|
|
|
| 241 |
|
| 242 |
def get_agents_config() -> AgentsConfig:
|
| 243 |
+
global _config
|
| 244 |
+
if _config is None:
|
| 245 |
+
_config = AgentsConfig()
|
| 246 |
+
return _config
|
backend/app/models/grading.py
CHANGED
|
@@ -3,13 +3,14 @@ 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 |
-
|
| 9 |
-
age_group: Optional[str] = Field(None, description="Age group
|
| 10 |
-
|
|
|
|
|
|
|
| 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():
|
|
@@ -45,16 +46,11 @@ class JobStatus(BaseModel):
|
|
| 45 |
message: Optional[str] = None
|
| 46 |
result: Optional[GradeResult] = None
|
| 47 |
|
| 48 |
-
class
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
rubric: Optional[Dict[str, Any]] = None
|
| 54 |
-
|
| 55 |
-
class AgentsListResponse(BaseModel):
|
| 56 |
-
version: int
|
| 57 |
-
agents: List[AgentInfo]
|
| 58 |
|
| 59 |
class PasswordVerifyRequest(BaseModel):
|
| 60 |
password: str = Field(..., description="Password to verify")
|
|
|
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
class GradeRequest(BaseModel):
|
|
|
|
| 6 |
text: str = Field(..., description="Text to be graded")
|
| 7 |
+
language: str = Field("zh-TW", description="Language (zh-TW/en)")
|
| 8 |
+
age_group: Optional[str] = Field(None, description="Age group (可用中文或英文 key)")
|
| 9 |
+
category: Optional[str] = Field(None, description="Article category (可用中文或英文 key)")
|
| 10 |
+
grading_mode: Optional[str] = Field(None, description="Grading mode (可用中文或英文 key)")
|
| 11 |
+
assistant_id: Optional[str] = Field(None, description="Specific assistant ID to use (optional, overrides auto-matching)")
|
| 12 |
options: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional options")
|
| 13 |
+
|
| 14 |
@validator('text')
|
| 15 |
def validate_text(cls, v):
|
| 16 |
if not v or not v.strip():
|
|
|
|
| 46 |
message: Optional[str] = None
|
| 47 |
result: Optional[GradeResult] = None
|
| 48 |
|
| 49 |
+
class OptionsResponse(BaseModel):
|
| 50 |
+
"""Response model for /api/options endpoint"""
|
| 51 |
+
age_groups: List[Dict[str, str]]
|
| 52 |
+
categories: List[Dict[str, str]]
|
| 53 |
+
grading_modes: List[Dict[str, str]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
class PasswordVerifyRequest(BaseModel):
|
| 56 |
password: str = Field(..., description="Password to verify")
|
backend/app/prompts/grading_prompt.py
CHANGED
|
@@ -142,10 +142,13 @@ Important Reminders:
|
|
| 142 |
|
| 143 |
def get_grading_prompt(text: str, category: str = "", age_group: str = "", language: str = "zh") -> str:
|
| 144 |
"""Get formatted grading prompt"""
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
| 147 |
# Build age group text
|
| 148 |
-
if
|
| 149 |
age_text = ""
|
| 150 |
if age_group:
|
| 151 |
age_text = f"作者年齡層:{age_group}\n請以「{age_group}」的程度標準來評分\n"
|
|
|
|
| 142 |
|
| 143 |
def get_grading_prompt(text: str, category: str = "", age_group: str = "", language: str = "zh") -> str:
|
| 144 |
"""Get formatted grading prompt"""
|
| 145 |
+
# Support both "zh" and "zh-TW" for Chinese
|
| 146 |
+
is_chinese = language.startswith("zh")
|
| 147 |
+
|
| 148 |
+
category_text = category if category else "一般文章" if is_chinese else "General Article"
|
| 149 |
+
|
| 150 |
# Build age group text
|
| 151 |
+
if is_chinese:
|
| 152 |
age_text = ""
|
| 153 |
if age_group:
|
| 154 |
age_text = f"作者年齡層:{age_group}\n請以「{age_group}」的程度標準來評分\n"
|
backend/app/routers/grading.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
-
from fastapi import APIRouter, HTTPException
|
| 2 |
from typing import Dict, Any
|
| 3 |
import logging
|
| 4 |
from app.models.grading import (
|
| 5 |
-
GradeRequest,
|
| 6 |
-
GradeResponse,
|
| 7 |
-
|
| 8 |
-
AgentInfo,
|
| 9 |
PasswordVerifyRequest,
|
| 10 |
PasswordVerifyResponse
|
| 11 |
)
|
|
@@ -16,74 +15,258 @@ from app.config.settings import get_settings
|
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
router = APIRouter(prefix="/api", tags=["grading"])
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
try:
|
| 23 |
config = get_agents_config()
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
except Exception as e:
|
| 40 |
-
logger.error(f"Error
|
| 41 |
raise HTTPException(status_code=500, detail=str(e))
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
@router.post("/grade", response_model=GradeResponse)
|
| 44 |
async def grade_text(request: GradeRequest):
|
| 45 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
try:
|
| 47 |
-
|
|
|
|
| 48 |
config = get_agents_config()
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
if
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
)
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
options = request.options or {}
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
if request.language:
|
| 68 |
-
options['language'] = request.language
|
| 69 |
-
|
| 70 |
# Call OpenAI service
|
| 71 |
openai_service = get_openai_service()
|
| 72 |
result = await openai_service.grade_text(
|
| 73 |
-
agent=
|
| 74 |
text=request.text,
|
| 75 |
options=options
|
| 76 |
)
|
| 77 |
-
|
| 78 |
return GradeResponse(
|
| 79 |
success=True,
|
| 80 |
data={
|
| 81 |
-
"
|
|
|
|
| 82 |
"result": result.model_dump()
|
| 83 |
}
|
| 84 |
)
|
| 85 |
-
|
| 86 |
except ValueError as e:
|
|
|
|
| 87 |
return GradeResponse(
|
| 88 |
success=False,
|
| 89 |
error={
|
|
@@ -92,22 +275,27 @@ async def grade_text(request: GradeRequest):
|
|
| 92 |
}
|
| 93 |
)
|
| 94 |
except Exception as e:
|
| 95 |
-
logger.error(f"Error grading text: {str(e)}")
|
| 96 |
return GradeResponse(
|
| 97 |
success=False,
|
| 98 |
error={
|
| 99 |
"code": "INTERNAL_ERROR",
|
| 100 |
-
"message": "
|
|
|
|
| 101 |
}
|
| 102 |
)
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
@router.post("/verify-password", response_model=PasswordVerifyResponse)
|
| 105 |
async def verify_password(request: PasswordVerifyRequest):
|
| 106 |
"""Verify password for platform access"""
|
| 107 |
try:
|
| 108 |
settings = get_settings()
|
| 109 |
correct_password = settings.app_password or "3030"
|
| 110 |
-
|
| 111 |
if request.password == correct_password:
|
| 112 |
return PasswordVerifyResponse(
|
| 113 |
success=True,
|
|
@@ -125,7 +313,23 @@ async def verify_password(request: PasswordVerifyRequest):
|
|
| 125 |
message="Password verification failed"
|
| 126 |
)
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
@router.get("/health")
|
| 129 |
async def health_check():
|
| 130 |
"""Health check endpoint"""
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
from typing import Dict, Any
|
| 3 |
import logging
|
| 4 |
from app.models.grading import (
|
| 5 |
+
GradeRequest,
|
| 6 |
+
GradeResponse,
|
| 7 |
+
OptionsResponse,
|
|
|
|
| 8 |
PasswordVerifyRequest,
|
| 9 |
PasswordVerifyResponse
|
| 10 |
)
|
|
|
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
router = APIRouter(prefix="/api", tags=["grading"])
|
| 17 |
|
| 18 |
+
# ============================================================
|
| 19 |
+
# Options API - 給前端取得下拉選單選項
|
| 20 |
+
# ============================================================
|
| 21 |
+
|
| 22 |
+
@router.get("/options", response_model=OptionsResponse)
|
| 23 |
+
async def get_options():
|
| 24 |
+
"""
|
| 25 |
+
Get grading options for frontend dropdowns
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
{
|
| 29 |
+
"age_groups": [{"key": "elementary", "label": "國小 (6-12歲)"}, ...],
|
| 30 |
+
"categories": [{"key": "narrative", "label": "記敘文"}, ...],
|
| 31 |
+
"grading_modes": [{"key": "full_edit", "label": "全文批改"}, ...]
|
| 32 |
+
}
|
| 33 |
+
"""
|
| 34 |
try:
|
| 35 |
config = get_agents_config()
|
| 36 |
+
return OptionsResponse(**config.get_options())
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.error(f"Error fetching options: {str(e)}", exc_info=True)
|
| 39 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 40 |
+
|
| 41 |
+
# ============================================================
|
| 42 |
+
# Assistants API - 除錯用,列出所有配置的助手
|
| 43 |
+
# ============================================================
|
| 44 |
+
|
| 45 |
+
@router.get("/assistants")
|
| 46 |
+
async def list_assistants():
|
| 47 |
+
"""
|
| 48 |
+
List all configured assistants (for debugging)
|
| 49 |
+
|
| 50 |
+
Returns list of assistants with their configuration
|
| 51 |
+
"""
|
| 52 |
+
try:
|
| 53 |
+
config = get_agents_config()
|
| 54 |
+
return {
|
| 55 |
+
"version": config.version,
|
| 56 |
+
"count": len(config.assistants),
|
| 57 |
+
"assistants": config.list_all_assistants()
|
| 58 |
+
}
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logger.error(f"Error listing assistants: {str(e)}", exc_info=True)
|
| 61 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 62 |
+
|
| 63 |
+
# ============================================================
|
| 64 |
+
# Find Matched Assistants API - 根據參數找出匹配的機器人
|
| 65 |
+
# ============================================================
|
| 66 |
+
|
| 67 |
+
@router.get("/find-assistants")
|
| 68 |
+
async def find_matched_assistants(
|
| 69 |
+
language: str = "zh-TW",
|
| 70 |
+
age_group: str = None,
|
| 71 |
+
category: str = None,
|
| 72 |
+
grading_mode: str = None
|
| 73 |
+
):
|
| 74 |
+
"""
|
| 75 |
+
Find all assistants that match the given criteria
|
| 76 |
+
|
| 77 |
+
Query params:
|
| 78 |
+
language: Language code (zh-TW, en)
|
| 79 |
+
age_group: Age group (可用中文或英文 key)
|
| 80 |
+
category: Article category (可用中文或英文 key)
|
| 81 |
+
grading_mode: Grading mode (可用中文或英文 key)
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
List of matched assistants with their scores
|
| 85 |
+
"""
|
| 86 |
+
try:
|
| 87 |
+
config = get_agents_config()
|
| 88 |
+
|
| 89 |
+
# Normalize inputs
|
| 90 |
+
age_group_key = config._normalize('age_groups', age_group) if age_group else None
|
| 91 |
+
category_key = config._normalize('categories', category) if category else None
|
| 92 |
+
mode_key = config._normalize('grading_modes', grading_mode) if grading_mode else None
|
| 93 |
+
|
| 94 |
+
# Get all assistants with scores (including non-matching ones)
|
| 95 |
+
all_assistants = []
|
| 96 |
+
for assistant in config.assistants:
|
| 97 |
+
# Calculate match score
|
| 98 |
+
score = 0
|
| 99 |
+
is_match = True
|
| 100 |
+
|
| 101 |
+
# Language must match
|
| 102 |
+
if assistant.language != language:
|
| 103 |
+
is_match = False
|
| 104 |
+
score = -1000 # Put non-matching language at bottom
|
| 105 |
+
else:
|
| 106 |
+
# Age group matching
|
| 107 |
+
if age_group_key:
|
| 108 |
+
if age_group_key in assistant.age_groups:
|
| 109 |
+
score += 100
|
| 110 |
+
elif '*' in assistant.age_groups:
|
| 111 |
+
score += 10
|
| 112 |
+
else:
|
| 113 |
+
is_match = False
|
| 114 |
+
score = -100 # Not matching, put at bottom
|
| 115 |
+
elif '*' in assistant.age_groups:
|
| 116 |
+
score += 10
|
| 117 |
+
|
| 118 |
+
# Category matching
|
| 119 |
+
if is_match: # Only check if still matching
|
| 120 |
+
if category_key:
|
| 121 |
+
if category_key in assistant.categories:
|
| 122 |
+
score += 50
|
| 123 |
+
elif '*' in assistant.categories:
|
| 124 |
+
score += 5
|
| 125 |
+
else:
|
| 126 |
+
is_match = False
|
| 127 |
+
score = -100
|
| 128 |
+
elif '*' in assistant.categories:
|
| 129 |
+
score += 5
|
| 130 |
+
|
| 131 |
+
# Grading mode matching
|
| 132 |
+
if is_match: # Only check if still matching
|
| 133 |
+
if mode_key:
|
| 134 |
+
if mode_key in assistant.grading_modes:
|
| 135 |
+
score += 25
|
| 136 |
+
elif '*' in assistant.grading_modes:
|
| 137 |
+
score += 2
|
| 138 |
+
else:
|
| 139 |
+
is_match = False
|
| 140 |
+
score = -100
|
| 141 |
+
elif '*' in assistant.grading_modes:
|
| 142 |
+
score += 2
|
| 143 |
+
|
| 144 |
+
all_assistants.append({
|
| 145 |
+
"name": assistant.name,
|
| 146 |
+
"assistant_id": assistant.assistant_id,
|
| 147 |
+
"score": score,
|
| 148 |
+
"is_recommended": False,
|
| 149 |
+
"is_match": is_match
|
| 150 |
+
})
|
| 151 |
+
|
| 152 |
+
# Filter to only matching assistants
|
| 153 |
+
matching_assistants = [a for a in all_assistants if a['is_match']]
|
| 154 |
+
|
| 155 |
+
# Sort by score (highest first)
|
| 156 |
+
matching_assistants.sort(reverse=True, key=lambda x: x['score'])
|
| 157 |
+
|
| 158 |
+
# Mark the best match as recommended
|
| 159 |
+
if matching_assistants:
|
| 160 |
+
matching_assistants[0]['is_recommended'] = True
|
| 161 |
+
|
| 162 |
+
return {
|
| 163 |
+
"success": True,
|
| 164 |
+
"matches": matching_assistants,
|
| 165 |
+
"count": len(matching_assistants)
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
except Exception as e:
|
| 169 |
+
logger.error(f"Error finding assistants: {str(e)}", exc_info=True)
|
| 170 |
raise HTTPException(status_code=500, detail=str(e))
|
| 171 |
|
| 172 |
+
# ============================================================
|
| 173 |
+
# Grade API - 主要批改端點
|
| 174 |
+
# ============================================================
|
| 175 |
+
|
| 176 |
@router.post("/grade", response_model=GradeResponse)
|
| 177 |
async def grade_text(request: GradeRequest):
|
| 178 |
+
"""
|
| 179 |
+
Grade text using matched assistant based on parameters
|
| 180 |
+
|
| 181 |
+
Request body:
|
| 182 |
+
{
|
| 183 |
+
"text": "文章內容...",
|
| 184 |
+
"language": "zh-TW", // 可選:zh-TW 或 en
|
| 185 |
+
"age_group": "國小", // 可用中文或英文 key
|
| 186 |
+
"category": "記敘文", // 可用中文或英文 key
|
| 187 |
+
"grading_mode": "全文批改" // 可用中文或英文 key
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
Returns grading result from matched assistant
|
| 191 |
+
"""
|
| 192 |
try:
|
| 193 |
+
logger.info(f"Grading request: language={request.language}, age_group={request.age_group}, category={request.category}, mode={request.grading_mode}, assistant_id={request.assistant_id}")
|
| 194 |
+
|
| 195 |
config = get_agents_config()
|
| 196 |
+
|
| 197 |
+
# If user specified assistant_id, use it directly
|
| 198 |
+
if request.assistant_id:
|
| 199 |
+
assistant = None
|
| 200 |
+
for asst in config.assistants:
|
| 201 |
+
if asst.assistant_id == request.assistant_id:
|
| 202 |
+
assistant = asst
|
| 203 |
+
break
|
| 204 |
+
|
| 205 |
+
if not assistant:
|
| 206 |
+
logger.warning(f"Specified assistant_id not found: {request.assistant_id}")
|
| 207 |
+
return GradeResponse(
|
| 208 |
+
success=False,
|
| 209 |
+
error={
|
| 210 |
+
"code": "ASSISTANT_NOT_FOUND",
|
| 211 |
+
"message": f"找不到指定的批改助手: {request.assistant_id}",
|
| 212 |
+
"details": {"assistant_id": request.assistant_id}
|
| 213 |
+
}
|
| 214 |
+
)
|
| 215 |
+
logger.info(f"Using user-selected assistant: {assistant.name} (id: {assistant.assistant_id})")
|
| 216 |
+
|
| 217 |
+
else:
|
| 218 |
+
# Auto-match assistant based on parameters
|
| 219 |
+
assistant = config.find_assistant(
|
| 220 |
+
language=request.language,
|
| 221 |
+
age_group=request.age_group,
|
| 222 |
+
category=request.category,
|
| 223 |
+
grading_mode=request.grading_mode
|
| 224 |
)
|
| 225 |
+
|
| 226 |
+
if not assistant:
|
| 227 |
+
logger.warning(f"No assistant found for request: {request.model_dump()}")
|
| 228 |
+
return GradeResponse(
|
| 229 |
+
success=False,
|
| 230 |
+
error={
|
| 231 |
+
"code": "ASSISTANT_NOT_FOUND",
|
| 232 |
+
"message": "找不到符合條件的批改助手",
|
| 233 |
+
"details": {
|
| 234 |
+
"language": request.language,
|
| 235 |
+
"age_group": request.age_group,
|
| 236 |
+
"category": request.category,
|
| 237 |
+
"grading_mode": request.grading_mode,
|
| 238 |
+
"hint": "請檢查 agents.yaml 配置或使用 /api/assistants 查看可用助手"
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
logger.info(f"Using auto-matched assistant: {assistant.name} (id: {assistant.assistant_id})")
|
| 244 |
+
|
| 245 |
+
# Add parameters to options for prompt building
|
| 246 |
options = request.options or {}
|
| 247 |
+
options['category'] = request.category
|
| 248 |
+
options['age_group'] = request.age_group
|
| 249 |
+
options['language'] = request.language
|
| 250 |
+
|
|
|
|
|
|
|
|
|
|
| 251 |
# Call OpenAI service
|
| 252 |
openai_service = get_openai_service()
|
| 253 |
result = await openai_service.grade_text(
|
| 254 |
+
agent=assistant,
|
| 255 |
text=request.text,
|
| 256 |
options=options
|
| 257 |
)
|
| 258 |
+
|
| 259 |
return GradeResponse(
|
| 260 |
success=True,
|
| 261 |
data={
|
| 262 |
+
"assistant_name": assistant.name,
|
| 263 |
+
"assistant_id": assistant.assistant_id,
|
| 264 |
"result": result.model_dump()
|
| 265 |
}
|
| 266 |
)
|
| 267 |
+
|
| 268 |
except ValueError as e:
|
| 269 |
+
logger.error(f"Validation error: {str(e)}")
|
| 270 |
return GradeResponse(
|
| 271 |
success=False,
|
| 272 |
error={
|
|
|
|
| 275 |
}
|
| 276 |
)
|
| 277 |
except Exception as e:
|
| 278 |
+
logger.error(f"Error grading text: {str(e)}", exc_info=True)
|
| 279 |
return GradeResponse(
|
| 280 |
success=False,
|
| 281 |
error={
|
| 282 |
"code": "INTERNAL_ERROR",
|
| 283 |
+
"message": "批改過程中發生錯誤",
|
| 284 |
+
"details": str(e) if get_settings().environment == "development" else None
|
| 285 |
}
|
| 286 |
)
|
| 287 |
|
| 288 |
+
# ============================================================
|
| 289 |
+
# Password Verification API
|
| 290 |
+
# ============================================================
|
| 291 |
+
|
| 292 |
@router.post("/verify-password", response_model=PasswordVerifyResponse)
|
| 293 |
async def verify_password(request: PasswordVerifyRequest):
|
| 294 |
"""Verify password for platform access"""
|
| 295 |
try:
|
| 296 |
settings = get_settings()
|
| 297 |
correct_password = settings.app_password or "3030"
|
| 298 |
+
|
| 299 |
if request.password == correct_password:
|
| 300 |
return PasswordVerifyResponse(
|
| 301 |
success=True,
|
|
|
|
| 313 |
message="Password verification failed"
|
| 314 |
)
|
| 315 |
|
| 316 |
+
# ============================================================
|
| 317 |
+
# Health Check API
|
| 318 |
+
# ============================================================
|
| 319 |
+
|
| 320 |
@router.get("/health")
|
| 321 |
async def health_check():
|
| 322 |
"""Health check endpoint"""
|
| 323 |
+
try:
|
| 324 |
+
config = get_agents_config()
|
| 325 |
+
return {
|
| 326 |
+
"status": "healthy",
|
| 327 |
+
"service": "AI Grading Platform",
|
| 328 |
+
"version": config.version,
|
| 329 |
+
"assistants_loaded": len(config.assistants)
|
| 330 |
+
}
|
| 331 |
+
except Exception as e:
|
| 332 |
+
return {
|
| 333 |
+
"status": "unhealthy",
|
| 334 |
+
"error": str(e)
|
| 335 |
+
}
|
backend/app/services/openai_service.py
CHANGED
|
@@ -118,7 +118,7 @@ class OpenAIService:
|
|
| 118 |
Build prompt based on agent configuration
|
| 119 |
"""
|
| 120 |
# Use the new structured grading prompt for Chinese agents
|
| 121 |
-
if agent.language == "zh-
|
| 122 |
category = options.get('category', '')
|
| 123 |
age_group = options.get('age_group', '')
|
| 124 |
language = options.get('language', 'zh')
|
|
|
|
| 118 |
Build prompt based on agent configuration
|
| 119 |
"""
|
| 120 |
# Use the new structured grading prompt for Chinese agents
|
| 121 |
+
if agent.language == "zh-TW":
|
| 122 |
category = options.get('category', '')
|
| 123 |
age_group = options.get('age_group', '')
|
| 124 |
language = options.get('language', 'zh')
|
backend/check_assistants.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""檢查 agents.yaml 中配置的所有機器人是否可用"""
|
| 3 |
+
|
| 4 |
+
import yaml
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
from openai import OpenAI
|
| 8 |
+
|
| 9 |
+
# 讀取 .env.local
|
| 10 |
+
env_file = '.env.local'
|
| 11 |
+
if os.path.exists(env_file):
|
| 12 |
+
with open(env_file) as f:
|
| 13 |
+
for line in f:
|
| 14 |
+
if line.strip() and not line.startswith('#'):
|
| 15 |
+
key, value = line.strip().split('=', 1)
|
| 16 |
+
os.environ[key] = value
|
| 17 |
+
|
| 18 |
+
# 讀取配置
|
| 19 |
+
with open('../agents.yaml', 'r', encoding='utf-8') as f:
|
| 20 |
+
config = yaml.safe_load(f)
|
| 21 |
+
|
| 22 |
+
# 獲取 API key
|
| 23 |
+
api_key = os.getenv('OPENAI_API_KEY')
|
| 24 |
+
|
| 25 |
+
if not api_key:
|
| 26 |
+
print("❌ 錯誤: 找不到 OPENAI_API_KEY")
|
| 27 |
+
print(" 請在 .env.local 文件中設置 OPENAI_API_KEY")
|
| 28 |
+
sys.exit(1)
|
| 29 |
+
|
| 30 |
+
client = OpenAI(api_key=api_key, default_headers={"OpenAI-Beta": "assistants=v2"})
|
| 31 |
+
|
| 32 |
+
print("\n" + "=" * 80)
|
| 33 |
+
print("檢查 agents.yaml 中的機器人")
|
| 34 |
+
print("=" * 80 + "\n")
|
| 35 |
+
|
| 36 |
+
valid_count = 0
|
| 37 |
+
invalid_count = 0
|
| 38 |
+
disabled_count = 0
|
| 39 |
+
invalid_assistants = []
|
| 40 |
+
|
| 41 |
+
for assistant in config['assistants']:
|
| 42 |
+
name = assistant['name']
|
| 43 |
+
assistant_id = assistant['assistant_id']
|
| 44 |
+
enabled = assistant.get('enabled', True)
|
| 45 |
+
|
| 46 |
+
if not enabled:
|
| 47 |
+
print(f"⏸️ {name}")
|
| 48 |
+
print(f" ID: {assistant_id}")
|
| 49 |
+
print(f" 狀態: 已停用(未檢查)\n")
|
| 50 |
+
disabled_count += 1
|
| 51 |
+
continue
|
| 52 |
+
|
| 53 |
+
# 檢查啟用的機器人
|
| 54 |
+
try:
|
| 55 |
+
asst = client.beta.assistants.retrieve(assistant_id)
|
| 56 |
+
print(f"✅ {name}")
|
| 57 |
+
print(f" ID: {assistant_id}")
|
| 58 |
+
print(f" OpenAI 名稱: {asst.name}")
|
| 59 |
+
print(f" Model: {asst.model}")
|
| 60 |
+
print(f" 狀態: ✅ 可用\n")
|
| 61 |
+
valid_count += 1
|
| 62 |
+
except Exception as e:
|
| 63 |
+
error_msg = str(e)
|
| 64 |
+
print(f"❌ {name}")
|
| 65 |
+
print(f" ID: {assistant_id}")
|
| 66 |
+
print(f" 狀態: ❌ 不可用")
|
| 67 |
+
print(f" 錯誤: {error_msg[:150]}\n")
|
| 68 |
+
invalid_count += 1
|
| 69 |
+
invalid_assistants.append({
|
| 70 |
+
'name': name,
|
| 71 |
+
'id': assistant_id,
|
| 72 |
+
'error': error_msg
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
print("=" * 80)
|
| 76 |
+
print("總結")
|
| 77 |
+
print("=" * 80)
|
| 78 |
+
print(f" ✅ 可用機器人: {valid_count}")
|
| 79 |
+
print(f" ❌ 不可用機器人: {invalid_count}")
|
| 80 |
+
print(f" ⏸️ 已停用機器人: {disabled_count}")
|
| 81 |
+
print(f" 📊 總計: {valid_count + invalid_count + disabled_count}")
|
| 82 |
+
|
| 83 |
+
if invalid_assistants:
|
| 84 |
+
print("\n⚠️ 需要修正的機器人:")
|
| 85 |
+
for asst in invalid_assistants:
|
| 86 |
+
print(f" - {asst['name']} ({asst['id']})")
|
| 87 |
+
print(f" 錯誤: {asst['error'][:100]}")
|
| 88 |
+
|
| 89 |
+
print("\n" + "=" * 80 + "\n")
|
| 90 |
+
|
| 91 |
+
sys.exit(0 if invalid_count == 0 else 1)
|
frontend/src/App.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
import './App.css';
|
| 3 |
-
import type {
|
| 4 |
import { gradingApi } from './api/grading';
|
| 5 |
import AssistantGuide from './AssistantGuide';
|
| 6 |
import PasswordProtection from './components/PasswordProtection';
|
|
@@ -8,52 +8,35 @@ import PasswordProtection from './components/PasswordProtection';
|
|
| 8 |
function App() {
|
| 9 |
const [isPasswordVerified, setIsPasswordVerified] = useState<boolean>(false);
|
| 10 |
const [currentView, setCurrentView] = useState<'grading' | 'guide'>('grading');
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
const [
|
| 14 |
-
const [
|
| 15 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
const [text, setText] = useState<string>('');
|
| 17 |
const [loading, setLoading] = useState<boolean>(false);
|
| 18 |
const [result, setResult] = useState<GradeResult | null>(null);
|
| 19 |
const [error, setError] = useState<string>('');
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
{ value: '3', label: '心理勵志' },
|
| 25 |
-
{ value: '4', label: '藝術設計' },
|
| 26 |
-
{ value: '5', label: '觀光旅遊' },
|
| 27 |
-
{ value: '6', label: '美食饗宴' },
|
| 28 |
-
{ value: '7', label: '行銷文案' },
|
| 29 |
-
{ value: '8', label: '商業理財' },
|
| 30 |
-
{ value: '9', label: '人文史地' },
|
| 31 |
-
{ value: '10', label: '自然科普' },
|
| 32 |
-
{ value: '11', label: '漫畫圖文' },
|
| 33 |
-
{ value: '12', label: '教育學習' },
|
| 34 |
-
{ value: '13', label: '生活休閒' },
|
| 35 |
-
{ value: '14', label: '創意想像' },
|
| 36 |
-
{ value: '15', label: '資訊科技' },
|
| 37 |
-
{ value: '16', label: '社會議題' },
|
| 38 |
-
];
|
| 39 |
-
|
| 40 |
-
const ageGroups = [
|
| 41 |
-
{ value: '國小', label: '國小' },
|
| 42 |
-
{ value: '國中', label: '國中' },
|
| 43 |
-
{ value: '高中', label: '高中' },
|
| 44 |
-
{ value: '大學', label: '大學' },
|
| 45 |
-
{ value: '上班族', label: '上班族' },
|
| 46 |
-
{ value: '退休人士', label: '退休人士' },
|
| 47 |
-
];
|
| 48 |
|
| 49 |
const languages = [
|
| 50 |
-
{ value: 'zh', label: '中文' },
|
| 51 |
{ value: 'en', label: 'English' },
|
| 52 |
];
|
| 53 |
|
| 54 |
// Translations
|
| 55 |
const labels = {
|
| 56 |
-
zh: {
|
| 57 |
language: '語言:',
|
| 58 |
ageGroup: '對象年齡:',
|
| 59 |
category: '文章分類:',
|
|
@@ -78,7 +61,7 @@ function App() {
|
|
| 78 |
revisedLabel: '修改:',
|
| 79 |
reasonLabel: '原因:',
|
| 80 |
},
|
| 81 |
-
en: {
|
| 82 |
language: 'Language:',
|
| 83 |
ageGroup: 'Age Group:',
|
| 84 |
category: 'Article Category:',
|
|
@@ -105,35 +88,79 @@ function App() {
|
|
| 105 |
}
|
| 106 |
};
|
| 107 |
|
| 108 |
-
const currentLabels = labels[selectedLanguage as 'zh' | 'en'] || labels
|
| 109 |
|
| 110 |
useEffect(() => {
|
| 111 |
// Check if password is already verified
|
| 112 |
const savedPassword = sessionStorage.getItem('app_password_verified');
|
| 113 |
if (savedPassword === 'true') {
|
| 114 |
setIsPasswordVerified(true);
|
| 115 |
-
|
| 116 |
}
|
| 117 |
}, []);
|
| 118 |
|
| 119 |
const handlePasswordCorrect = () => {
|
| 120 |
setIsPasswordVerified(true);
|
| 121 |
-
|
| 122 |
};
|
| 123 |
|
| 124 |
-
const
|
| 125 |
try {
|
| 126 |
-
const
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
}
|
| 131 |
} catch (err) {
|
| 132 |
-
console.error('Failed to load
|
| 133 |
-
setError('無法載入
|
| 134 |
}
|
| 135 |
};
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
const handleGrade = async () => {
|
| 138 |
if (!text.trim()) {
|
| 139 |
setError('請輸入要批改的文章');
|
|
@@ -145,19 +172,19 @@ function App() {
|
|
| 145 |
setResult(null);
|
| 146 |
|
| 147 |
try {
|
| 148 |
-
const categoryLabel = categories.find(c => c.value === selectedCategory)?.label || '一般文章';
|
| 149 |
const response = await gradingApi.gradeText({
|
| 150 |
-
agent_key: selectedAgent,
|
| 151 |
text: text,
|
| 152 |
-
category: categoryLabel,
|
| 153 |
-
age_group: selectedAgeGroup,
|
| 154 |
language: selectedLanguage,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
});
|
| 156 |
|
| 157 |
if (response.success && response.data) {
|
| 158 |
setResult(response.data.result);
|
| 159 |
} else if (response.error) {
|
| 160 |
-
setError(response.error.message);
|
| 161 |
}
|
| 162 |
} catch (err) {
|
| 163 |
console.error('Grading failed:', err);
|
|
@@ -173,9 +200,6 @@ function App() {
|
|
| 173 |
setError('');
|
| 174 |
};
|
| 175 |
|
| 176 |
-
// 取得當前選中的 agent
|
| 177 |
-
const currentAgent = agents.find(a => a.key === selectedAgent);
|
| 178 |
-
|
| 179 |
// Show password protection if not verified
|
| 180 |
if (!isPasswordVerified) {
|
| 181 |
return <PasswordProtection onPasswordCorrect={handlePasswordCorrect} />;
|
|
@@ -187,29 +211,29 @@ function App() {
|
|
| 187 |
<div className="header-row-1">
|
| 188 |
<h1>AI 批改平台</h1>
|
| 189 |
<nav className="header-nav">
|
| 190 |
-
<button
|
| 191 |
className={`nav-button ${currentView === 'grading' ? 'active' : ''}`}
|
| 192 |
onClick={() => setCurrentView('grading')}
|
| 193 |
>
|
| 194 |
批改平台
|
| 195 |
</button>
|
| 196 |
-
<button
|
| 197 |
className={`nav-button ${currentView === 'guide' ? 'active' : ''}`}
|
| 198 |
onClick={() => setCurrentView('guide')}
|
| 199 |
>
|
| 200 |
設定指南
|
| 201 |
</button>
|
| 202 |
-
<a
|
| 203 |
href={window.location.port === '5174' ? 'http://localhost:8001/docs' : '/docs'}
|
| 204 |
-
target="_blank"
|
| 205 |
rel="noopener noreferrer"
|
| 206 |
className="nav-link"
|
| 207 |
>
|
| 208 |
API 文檔
|
| 209 |
</a>
|
| 210 |
-
<a
|
| 211 |
-
href="https://drive.google.com/drive/folders/1jFNx3loIJtEo8CwkendtpGh6m8WMa_QH"
|
| 212 |
-
target="_blank"
|
| 213 |
rel="noopener noreferrer"
|
| 214 |
className="nav-link course-link"
|
| 215 |
>
|
|
@@ -243,7 +267,7 @@ function App() {
|
|
| 243 |
disabled={loading}
|
| 244 |
>
|
| 245 |
{ageGroups.map((age) => (
|
| 246 |
-
<option key={age.
|
| 247 |
{age.label}
|
| 248 |
</option>
|
| 249 |
))}
|
|
@@ -259,7 +283,7 @@ function App() {
|
|
| 259 |
disabled={loading}
|
| 260 |
>
|
| 261 |
{categories.map((category) => (
|
| 262 |
-
<option key={category.
|
| 263 |
{category.label}
|
| 264 |
</option>
|
| 265 |
))}
|
|
@@ -267,35 +291,19 @@ function App() {
|
|
| 267 |
</div>
|
| 268 |
|
| 269 |
<div className="header-selector">
|
| 270 |
-
<label htmlFor="
|
| 271 |
<select
|
| 272 |
-
id="
|
| 273 |
-
value={
|
| 274 |
-
onChange={(e) =>
|
| 275 |
disabled={loading}
|
| 276 |
>
|
| 277 |
-
{
|
| 278 |
-
<option key={
|
| 279 |
-
{
|
| 280 |
</option>
|
| 281 |
))}
|
| 282 |
</select>
|
| 283 |
-
{currentAgent && currentAgent.ui && (
|
| 284 |
-
<span
|
| 285 |
-
className="agent-badge"
|
| 286 |
-
style={{
|
| 287 |
-
backgroundColor: currentAgent.ui.color,
|
| 288 |
-
marginLeft: '10px',
|
| 289 |
-
padding: '4px 10px',
|
| 290 |
-
borderRadius: '15px',
|
| 291 |
-
color: 'white',
|
| 292 |
-
fontWeight: 'bold',
|
| 293 |
-
fontSize: '0.85rem'
|
| 294 |
-
}}
|
| 295 |
-
>
|
| 296 |
-
{currentAgent.ui.badge}
|
| 297 |
-
</span>
|
| 298 |
-
)}
|
| 299 |
</div>
|
| 300 |
</div>
|
| 301 |
</header>
|
|
@@ -317,14 +325,50 @@ function App() {
|
|
| 317 |
</div>
|
| 318 |
|
| 319 |
<div className="action-buttons">
|
| 320 |
-
<button
|
| 321 |
className="btn btn-primary"
|
| 322 |
onClick={handleGrade}
|
| 323 |
disabled={loading || !text.trim()}
|
| 324 |
>
|
| 325 |
{loading ? currentLabels.gradingButton : currentLabels.gradeButton}
|
| 326 |
</button>
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
className="btn btn-secondary"
|
| 329 |
onClick={handleClear}
|
| 330 |
disabled={loading}
|
|
@@ -335,7 +379,7 @@ function App() {
|
|
| 335 |
|
| 336 |
{error && (
|
| 337 |
<div className="error-message">
|
| 338 |
-
{error}
|
| 339 |
</div>
|
| 340 |
)}
|
| 341 |
</div>
|
|
@@ -347,11 +391,11 @@ function App() {
|
|
| 347 |
<p>{currentLabels.loadingMessage}</p>
|
| 348 |
</div>
|
| 349 |
)}
|
| 350 |
-
|
| 351 |
{!loading && result && (
|
| 352 |
<div className="result-container">
|
| 353 |
<h2>{currentLabels.resultTitle}</h2>
|
| 354 |
-
|
| 355 |
{result.summary && (
|
| 356 |
<div className="result-section">
|
| 357 |
<h3>{currentLabels.summaryTitle}</h3>
|
|
@@ -417,7 +461,7 @@ function App() {
|
|
| 417 |
{result.scores && (
|
| 418 |
<div className="result-section">
|
| 419 |
<h3>{currentLabels.scoresTitle}</h3>
|
| 420 |
-
|
| 421 |
{/* 評分表格 */}
|
| 422 |
<div className="score-table">
|
| 423 |
<table>
|
|
@@ -441,28 +485,43 @@ function App() {
|
|
| 441 |
if (score >= 63) return 'C';
|
| 442 |
return 'D';
|
| 443 |
};
|
| 444 |
-
|
| 445 |
const getExplanation = () => {
|
| 446 |
const suggestion = result.suggestions?.find(s => s.type === key);
|
| 447 |
return suggestion?.text || (selectedLanguage === 'en' ? 'Good performance' : '表現良好');
|
| 448 |
};
|
| 449 |
-
|
| 450 |
const getCriteriaName = () => {
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
return key;
|
| 457 |
}
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
if (key === 'paragraph_structure' || key === '段落結構') return '段落結構';
|
| 462 |
-
if (key === 'typo_check' || key === '錯別字檢查') return '錯別字檢查';
|
| 463 |
-
return key;
|
| 464 |
};
|
| 465 |
-
|
| 466 |
return (
|
| 467 |
<tr key={key}>
|
| 468 |
<td className="criteria-name">{getCriteriaName()}</td>
|
|
@@ -476,7 +535,7 @@ function App() {
|
|
| 476 |
<td className="criteria-name">{currentLabels.overallLabel}</td>
|
| 477 |
<td className="criteria-grade">
|
| 478 |
<span className="overall-emoji">
|
| 479 |
-
{result.scores.overall >= 80 ? '🟢' :
|
| 480 |
result.scores.overall >= 60 ? '🟡' : '🔴'}
|
| 481 |
</span>
|
| 482 |
<span className="overall-score-number">
|
|
@@ -503,7 +562,7 @@ function App() {
|
|
| 503 |
const original = lines.find(l => l.startsWith('原文:') || l.startsWith('Original:'))?.replace(/^(原文:|Original:)/, '') || '';
|
| 504 |
const corrected = lines.find(l => l.startsWith('修改:') || l.startsWith('Revised:'))?.replace(/^(修改:|Revised:)/, '') || '';
|
| 505 |
const reason = lines.find(l => l.startsWith('原因:') || l.startsWith('Reason:'))?.replace(/^(原因:|Reason:)/, '') || '';
|
| 506 |
-
|
| 507 |
return (
|
| 508 |
<div key={index} className="correction-item">
|
| 509 |
<div className="correction-header">
|
|
@@ -526,7 +585,7 @@ function App() {
|
|
| 526 |
</div>
|
| 527 |
);
|
| 528 |
})}
|
| 529 |
-
|
| 530 |
{result.suggestions.filter(s => s.type !== 'correction' && s.section !== 'evaluation').map((suggestion, index) => (
|
| 531 |
<div key={`other-${index}`} className="suggestion-item">
|
| 532 |
<strong>{suggestion.type}:</strong>
|
|
@@ -545,4 +604,4 @@ function App() {
|
|
| 545 |
);
|
| 546 |
}
|
| 547 |
|
| 548 |
-
export default App;
|
|
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
import './App.css';
|
| 3 |
+
import type { GradeResult, Option, OptionsResponse, MatchedAssistant } from './types/index';
|
| 4 |
import { gradingApi } from './api/grading';
|
| 5 |
import AssistantGuide from './AssistantGuide';
|
| 6 |
import PasswordProtection from './components/PasswordProtection';
|
|
|
|
| 8 |
function App() {
|
| 9 |
const [isPasswordVerified, setIsPasswordVerified] = useState<boolean>(false);
|
| 10 |
const [currentView, setCurrentView] = useState<'grading' | 'guide'>('grading');
|
| 11 |
+
|
| 12 |
+
// 從 API 載入的選項
|
| 13 |
+
const [ageGroups, setAgeGroups] = useState<Option[]>([]);
|
| 14 |
+
const [categories, setCategories] = useState<Option[]>([]);
|
| 15 |
+
const [gradingModes, setGradingModes] = useState<Option[]>([]);
|
| 16 |
+
|
| 17 |
+
// 使用者選擇
|
| 18 |
+
const [selectedAgeGroup, setSelectedAgeGroup] = useState<string>('');
|
| 19 |
+
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
| 20 |
+
const [selectedGradingMode, setSelectedGradingMode] = useState<string>('');
|
| 21 |
+
const [selectedLanguage, setSelectedLanguage] = useState<string>('zh-TW');
|
| 22 |
+
|
| 23 |
const [text, setText] = useState<string>('');
|
| 24 |
const [loading, setLoading] = useState<boolean>(false);
|
| 25 |
const [result, setResult] = useState<GradeResult | null>(null);
|
| 26 |
const [error, setError] = useState<string>('');
|
| 27 |
|
| 28 |
+
// 機器人選擇
|
| 29 |
+
const [matchedAssistants, setMatchedAssistants] = useState<MatchedAssistant[]>([]);
|
| 30 |
+
const [selectedAssistantId, setSelectedAssistantId] = useState<string>('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
const languages = [
|
| 33 |
+
{ value: 'zh-TW', label: '中文' },
|
| 34 |
{ value: 'en', label: 'English' },
|
| 35 |
];
|
| 36 |
|
| 37 |
// Translations
|
| 38 |
const labels = {
|
| 39 |
+
'zh-TW': {
|
| 40 |
language: '語言:',
|
| 41 |
ageGroup: '對象年齡:',
|
| 42 |
category: '文章分類:',
|
|
|
|
| 61 |
revisedLabel: '修改:',
|
| 62 |
reasonLabel: '原因:',
|
| 63 |
},
|
| 64 |
+
'en': {
|
| 65 |
language: 'Language:',
|
| 66 |
ageGroup: 'Age Group:',
|
| 67 |
category: 'Article Category:',
|
|
|
|
| 88 |
}
|
| 89 |
};
|
| 90 |
|
| 91 |
+
const currentLabels = labels[selectedLanguage as 'zh-TW' | 'en'] || labels['zh-TW'];
|
| 92 |
|
| 93 |
useEffect(() => {
|
| 94 |
// Check if password is already verified
|
| 95 |
const savedPassword = sessionStorage.getItem('app_password_verified');
|
| 96 |
if (savedPassword === 'true') {
|
| 97 |
setIsPasswordVerified(true);
|
| 98 |
+
loadOptions();
|
| 99 |
}
|
| 100 |
}, []);
|
| 101 |
|
| 102 |
const handlePasswordCorrect = () => {
|
| 103 |
setIsPasswordVerified(true);
|
| 104 |
+
loadOptions();
|
| 105 |
};
|
| 106 |
|
| 107 |
+
const loadOptions = async () => {
|
| 108 |
try {
|
| 109 |
+
const options: OptionsResponse = await gradingApi.getOptions();
|
| 110 |
+
|
| 111 |
+
setAgeGroups(options.age_groups || []);
|
| 112 |
+
setCategories(options.categories || []);
|
| 113 |
+
setGradingModes(options.grading_modes || []);
|
| 114 |
+
|
| 115 |
+
// 設定預設值
|
| 116 |
+
if (options.age_groups && options.age_groups.length > 0) {
|
| 117 |
+
setSelectedAgeGroup(options.age_groups[0].key);
|
| 118 |
+
}
|
| 119 |
+
if (options.categories && options.categories.length > 0) {
|
| 120 |
+
setSelectedCategory(options.categories[0].key);
|
| 121 |
+
}
|
| 122 |
+
if (options.grading_modes && options.grading_modes.length > 0) {
|
| 123 |
+
setSelectedGradingMode(options.grading_modes[0].key);
|
| 124 |
}
|
| 125 |
} catch (err) {
|
| 126 |
+
console.error('Failed to load options:', err);
|
| 127 |
+
setError('無法載入選項,請重新整理頁面');
|
| 128 |
}
|
| 129 |
};
|
| 130 |
|
| 131 |
+
// 當選項改變時,自動查找匹配的機器人
|
| 132 |
+
useEffect(() => {
|
| 133 |
+
const findAssistants = async () => {
|
| 134 |
+
try {
|
| 135 |
+
const response = await gradingApi.findAssistants({
|
| 136 |
+
language: selectedLanguage,
|
| 137 |
+
age_group: selectedAgeGroup,
|
| 138 |
+
category: selectedCategory,
|
| 139 |
+
grading_mode: selectedGradingMode,
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
if (response.success && response.matches) {
|
| 143 |
+
setMatchedAssistants(response.matches);
|
| 144 |
+
|
| 145 |
+
// 自動選擇推薦的機器人(score最高的)
|
| 146 |
+
const recommended = response.matches.find(a => a.is_recommended);
|
| 147 |
+
if (recommended) {
|
| 148 |
+
setSelectedAssistantId(recommended.assistant_id);
|
| 149 |
+
} else if (response.matches.length > 0) {
|
| 150 |
+
setSelectedAssistantId(response.matches[0].assistant_id);
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
} catch (err) {
|
| 154 |
+
console.error('Failed to find assistants:', err);
|
| 155 |
+
// 不顯示錯誤,因為這不是關鍵功能
|
| 156 |
+
}
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
if (selectedAgeGroup && selectedCategory && selectedGradingMode) {
|
| 160 |
+
findAssistants();
|
| 161 |
+
}
|
| 162 |
+
}, [selectedLanguage, selectedAgeGroup, selectedCategory, selectedGradingMode]);
|
| 163 |
+
|
| 164 |
const handleGrade = async () => {
|
| 165 |
if (!text.trim()) {
|
| 166 |
setError('請輸入要批改的文章');
|
|
|
|
| 172 |
setResult(null);
|
| 173 |
|
| 174 |
try {
|
|
|
|
| 175 |
const response = await gradingApi.gradeText({
|
|
|
|
| 176 |
text: text,
|
|
|
|
|
|
|
| 177 |
language: selectedLanguage,
|
| 178 |
+
age_group: selectedAgeGroup,
|
| 179 |
+
category: selectedCategory,
|
| 180 |
+
grading_mode: selectedGradingMode,
|
| 181 |
+
assistant_id: selectedAssistantId || undefined,
|
| 182 |
});
|
| 183 |
|
| 184 |
if (response.success && response.data) {
|
| 185 |
setResult(response.data.result);
|
| 186 |
} else if (response.error) {
|
| 187 |
+
setError(response.error.message + (response.error.details ? `\n${JSON.stringify(response.error.details, null, 2)}` : ''));
|
| 188 |
}
|
| 189 |
} catch (err) {
|
| 190 |
console.error('Grading failed:', err);
|
|
|
|
| 200 |
setError('');
|
| 201 |
};
|
| 202 |
|
|
|
|
|
|
|
|
|
|
| 203 |
// Show password protection if not verified
|
| 204 |
if (!isPasswordVerified) {
|
| 205 |
return <PasswordProtection onPasswordCorrect={handlePasswordCorrect} />;
|
|
|
|
| 211 |
<div className="header-row-1">
|
| 212 |
<h1>AI 批改平台</h1>
|
| 213 |
<nav className="header-nav">
|
| 214 |
+
<button
|
| 215 |
className={`nav-button ${currentView === 'grading' ? 'active' : ''}`}
|
| 216 |
onClick={() => setCurrentView('grading')}
|
| 217 |
>
|
| 218 |
批改平台
|
| 219 |
</button>
|
| 220 |
+
<button
|
| 221 |
className={`nav-button ${currentView === 'guide' ? 'active' : ''}`}
|
| 222 |
onClick={() => setCurrentView('guide')}
|
| 223 |
>
|
| 224 |
設定指南
|
| 225 |
</button>
|
| 226 |
+
<a
|
| 227 |
href={window.location.port === '5174' ? 'http://localhost:8001/docs' : '/docs'}
|
| 228 |
+
target="_blank"
|
| 229 |
rel="noopener noreferrer"
|
| 230 |
className="nav-link"
|
| 231 |
>
|
| 232 |
API 文檔
|
| 233 |
</a>
|
| 234 |
+
<a
|
| 235 |
+
href="https://drive.google.com/drive/folders/1jFNx3loIJtEo8CwkendtpGh6m8WMa_QH"
|
| 236 |
+
target="_blank"
|
| 237 |
rel="noopener noreferrer"
|
| 238 |
className="nav-link course-link"
|
| 239 |
>
|
|
|
|
| 267 |
disabled={loading}
|
| 268 |
>
|
| 269 |
{ageGroups.map((age) => (
|
| 270 |
+
<option key={age.key} value={age.key}>
|
| 271 |
{age.label}
|
| 272 |
</option>
|
| 273 |
))}
|
|
|
|
| 283 |
disabled={loading}
|
| 284 |
>
|
| 285 |
{categories.map((category) => (
|
| 286 |
+
<option key={category.key} value={category.key}>
|
| 287 |
{category.label}
|
| 288 |
</option>
|
| 289 |
))}
|
|
|
|
| 291 |
</div>
|
| 292 |
|
| 293 |
<div className="header-selector">
|
| 294 |
+
<label htmlFor="grading-mode">{currentLabels.gradingMode}</label>
|
| 295 |
<select
|
| 296 |
+
id="grading-mode"
|
| 297 |
+
value={selectedGradingMode}
|
| 298 |
+
onChange={(e) => setSelectedGradingMode(e.target.value)}
|
| 299 |
disabled={loading}
|
| 300 |
>
|
| 301 |
+
{gradingModes.map((mode) => (
|
| 302 |
+
<option key={mode.key} value={mode.key}>
|
| 303 |
+
{mode.label}
|
| 304 |
</option>
|
| 305 |
))}
|
| 306 |
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
</div>
|
| 308 |
</div>
|
| 309 |
</header>
|
|
|
|
| 325 |
</div>
|
| 326 |
|
| 327 |
<div className="action-buttons">
|
| 328 |
+
<button
|
| 329 |
className="btn btn-primary"
|
| 330 |
onClick={handleGrade}
|
| 331 |
disabled={loading || !text.trim()}
|
| 332 |
>
|
| 333 |
{loading ? currentLabels.gradingButton : currentLabels.gradeButton}
|
| 334 |
</button>
|
| 335 |
+
|
| 336 |
+
{/* 機器人選擇下拉選單 */}
|
| 337 |
+
{matchedAssistants.length > 0 && (
|
| 338 |
+
<select
|
| 339 |
+
className="assistant-selector"
|
| 340 |
+
value={selectedAssistantId}
|
| 341 |
+
onChange={(e) => setSelectedAssistantId(e.target.value)}
|
| 342 |
+
disabled={loading}
|
| 343 |
+
style={{
|
| 344 |
+
padding: '10px 15px',
|
| 345 |
+
borderRadius: '8px',
|
| 346 |
+
border: '2px solid #E0E0E0',
|
| 347 |
+
fontSize: '14px',
|
| 348 |
+
fontWeight: '500',
|
| 349 |
+
cursor: 'pointer',
|
| 350 |
+
backgroundColor: '#fff',
|
| 351 |
+
minWidth: '200px',
|
| 352 |
+
}}
|
| 353 |
+
>
|
| 354 |
+
{matchedAssistants.map((assistant) => (
|
| 355 |
+
<option
|
| 356 |
+
key={assistant.assistant_id}
|
| 357 |
+
value={assistant.assistant_id}
|
| 358 |
+
style={{
|
| 359 |
+
color: assistant.is_match ? '#000' : '#999',
|
| 360 |
+
fontStyle: assistant.is_match ? 'normal' : 'italic',
|
| 361 |
+
}}
|
| 362 |
+
>
|
| 363 |
+
{assistant.is_recommended ? '⭐ ' : ''}
|
| 364 |
+
{assistant.name}
|
| 365 |
+
{!assistant.is_match ? ' (不太適合)' : ''}
|
| 366 |
+
</option>
|
| 367 |
+
))}
|
| 368 |
+
</select>
|
| 369 |
+
)}
|
| 370 |
+
|
| 371 |
+
<button
|
| 372 |
className="btn btn-secondary"
|
| 373 |
onClick={handleClear}
|
| 374 |
disabled={loading}
|
|
|
|
| 379 |
|
| 380 |
{error && (
|
| 381 |
<div className="error-message">
|
| 382 |
+
<pre style={{whiteSpace: 'pre-wrap'}}>{error}</pre>
|
| 383 |
</div>
|
| 384 |
)}
|
| 385 |
</div>
|
|
|
|
| 391 |
<p>{currentLabels.loadingMessage}</p>
|
| 392 |
</div>
|
| 393 |
)}
|
| 394 |
+
|
| 395 |
{!loading && result && (
|
| 396 |
<div className="result-container">
|
| 397 |
<h2>{currentLabels.resultTitle}</h2>
|
| 398 |
+
|
| 399 |
{result.summary && (
|
| 400 |
<div className="result-section">
|
| 401 |
<h3>{currentLabels.summaryTitle}</h3>
|
|
|
|
| 461 |
{result.scores && (
|
| 462 |
<div className="result-section">
|
| 463 |
<h3>{currentLabels.scoresTitle}</h3>
|
| 464 |
+
|
| 465 |
{/* 評分表格 */}
|
| 466 |
<div className="score-table">
|
| 467 |
<table>
|
|
|
|
| 485 |
if (score >= 63) return 'C';
|
| 486 |
return 'D';
|
| 487 |
};
|
| 488 |
+
|
| 489 |
const getExplanation = () => {
|
| 490 |
const suggestion = result.suggestions?.find(s => s.type === key);
|
| 491 |
return suggestion?.text || (selectedLanguage === 'en' ? 'Good performance' : '表現良好');
|
| 492 |
};
|
| 493 |
+
|
| 494 |
const getCriteriaName = () => {
|
| 495 |
+
// 直接顯示 AI 回傳的 key(已經是中文 label)
|
| 496 |
+
// 如果 key 是英文,做簡單的翻譯
|
| 497 |
+
const translations: Record<string, string> = {
|
| 498 |
+
// 從 rubric_templates 來的 key
|
| 499 |
+
'content': '內容創意',
|
| 500 |
+
'structure': '文章結構',
|
| 501 |
+
'expression': '文字表達',
|
| 502 |
+
'mechanics': '文字運用',
|
| 503 |
+
'theme': '主題深度',
|
| 504 |
+
'argumentation': '論證邏輯',
|
| 505 |
+
'critical_thinking': '批判思考',
|
| 506 |
+
'clarity': '表達清晰',
|
| 507 |
+
'professionalism': '專業性',
|
| 508 |
+
|
| 509 |
+
// 舊格式(向後相容)
|
| 510 |
+
'theme_content': '主題與內容',
|
| 511 |
+
'word_sentence': '遣詞造句',
|
| 512 |
+
'paragraph_structure': '段落結構',
|
| 513 |
+
'typo_check': '錯別字檢查',
|
| 514 |
+
};
|
| 515 |
+
|
| 516 |
+
// 如果已經是中文,直接返回
|
| 517 |
+
if (/[\u4e00-\u9fa5]/.test(key)) {
|
| 518 |
return key;
|
| 519 |
}
|
| 520 |
+
|
| 521 |
+
// 否則從翻譯表查找
|
| 522 |
+
return translations[key] || key;
|
|
|
|
|
|
|
|
|
|
| 523 |
};
|
| 524 |
+
|
| 525 |
return (
|
| 526 |
<tr key={key}>
|
| 527 |
<td className="criteria-name">{getCriteriaName()}</td>
|
|
|
|
| 535 |
<td className="criteria-name">{currentLabels.overallLabel}</td>
|
| 536 |
<td className="criteria-grade">
|
| 537 |
<span className="overall-emoji">
|
| 538 |
+
{result.scores.overall >= 80 ? '🟢' :
|
| 539 |
result.scores.overall >= 60 ? '🟡' : '🔴'}
|
| 540 |
</span>
|
| 541 |
<span className="overall-score-number">
|
|
|
|
| 562 |
const original = lines.find(l => l.startsWith('原文:') || l.startsWith('Original:'))?.replace(/^(原文:|Original:)/, '') || '';
|
| 563 |
const corrected = lines.find(l => l.startsWith('修改:') || l.startsWith('Revised:'))?.replace(/^(修改:|Revised:)/, '') || '';
|
| 564 |
const reason = lines.find(l => l.startsWith('原因:') || l.startsWith('Reason:'))?.replace(/^(原因:|Reason:)/, '') || '';
|
| 565 |
+
|
| 566 |
return (
|
| 567 |
<div key={index} className="correction-item">
|
| 568 |
<div className="correction-header">
|
|
|
|
| 585 |
</div>
|
| 586 |
);
|
| 587 |
})}
|
| 588 |
+
|
| 589 |
{result.suggestions.filter(s => s.type !== 'correction' && s.section !== 'evaluation').map((suggestion, index) => (
|
| 590 |
<div key={`other-${index}`} className="suggestion-item">
|
| 591 |
<strong>{suggestion.type}:</strong>
|
|
|
|
| 604 |
);
|
| 605 |
}
|
| 606 |
|
| 607 |
+
export default App;
|
frontend/src/api/grading.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import axios from 'axios';
|
| 2 |
-
import type {
|
| 3 |
|
| 4 |
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001/api';
|
| 5 |
|
|
@@ -20,18 +20,48 @@ api.interceptors.request.use((config) => {
|
|
| 20 |
});
|
| 21 |
|
| 22 |
export const gradingApi = {
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
},
|
| 27 |
|
|
|
|
|
|
|
|
|
|
| 28 |
async gradeText(request: GradeRequest): Promise<GradeResponse> {
|
| 29 |
const response = await api.post<GradeResponse>('/grade', request);
|
| 30 |
return response.data;
|
| 31 |
},
|
| 32 |
|
|
|
|
|
|
|
|
|
|
| 33 |
async verifyPassword(password: string): Promise<{success: boolean, message?: string}> {
|
| 34 |
const response = await axios.post(`${API_BASE_URL}/verify-password`, { password });
|
| 35 |
return response.data;
|
| 36 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
};
|
|
|
|
| 1 |
import axios from 'axios';
|
| 2 |
+
import type { OptionsResponse, GradeRequest, GradeResponse, FindAssistantsResponse } from '../types/index';
|
| 3 |
|
| 4 |
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001/api';
|
| 5 |
|
|
|
|
| 20 |
});
|
| 21 |
|
| 22 |
export const gradingApi = {
|
| 23 |
+
/**
|
| 24 |
+
* 取得前端下拉選單選項
|
| 25 |
+
*/
|
| 26 |
+
async getOptions(): Promise<OptionsResponse> {
|
| 27 |
+
const response = await api.get<OptionsResponse>('/options');
|
| 28 |
+
return response.data;
|
| 29 |
},
|
| 30 |
|
| 31 |
+
/**
|
| 32 |
+
* 送出批改請求
|
| 33 |
+
*/
|
| 34 |
async gradeText(request: GradeRequest): Promise<GradeResponse> {
|
| 35 |
const response = await api.post<GradeResponse>('/grade', request);
|
| 36 |
return response.data;
|
| 37 |
},
|
| 38 |
|
| 39 |
+
/**
|
| 40 |
+
* 驗證密碼
|
| 41 |
+
*/
|
| 42 |
async verifyPassword(password: string): Promise<{success: boolean, message?: string}> {
|
| 43 |
const response = await axios.post(`${API_BASE_URL}/verify-password`, { password });
|
| 44 |
return response.data;
|
| 45 |
},
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* 除錯用:查看所有配置的助手
|
| 49 |
+
*/
|
| 50 |
+
async getAssistants(): Promise<any> {
|
| 51 |
+
const response = await api.get('/assistants');
|
| 52 |
+
return response.data;
|
| 53 |
+
},
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* 根據條件查找匹配的機器人
|
| 57 |
+
*/
|
| 58 |
+
async findAssistants(params: {
|
| 59 |
+
language?: string;
|
| 60 |
+
age_group?: string;
|
| 61 |
+
category?: string;
|
| 62 |
+
grading_mode?: string;
|
| 63 |
+
}): Promise<FindAssistantsResponse> {
|
| 64 |
+
const response = await api.get<FindAssistantsResponse>('/find-assistants', { params });
|
| 65 |
+
return response.data;
|
| 66 |
+
},
|
| 67 |
};
|
frontend/src/types/index.ts
CHANGED
|
@@ -1,30 +1,41 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
key: string;
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 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;
|
|
@@ -46,16 +57,13 @@ export type GradeResult = {
|
|
| 46 |
export type GradeResponse = {
|
| 47 |
success: boolean;
|
| 48 |
data?: {
|
| 49 |
-
|
|
|
|
| 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 |
}
|
|
|
|
| 1 |
+
// 選項定義
|
| 2 |
+
export type Option = {
|
| 3 |
key: string;
|
| 4 |
+
label: string;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export type OptionsResponse = {
|
| 8 |
+
age_groups: Option[];
|
| 9 |
+
categories: Option[];
|
| 10 |
+
grading_modes: Option[];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
+
// 批改請求
|
| 14 |
export type GradeRequest = {
|
|
|
|
| 15 |
text: string;
|
|
|
|
|
|
|
| 16 |
language?: string;
|
| 17 |
+
age_group?: string;
|
| 18 |
+
category?: string;
|
| 19 |
+
grading_mode?: string;
|
| 20 |
+
assistant_id?: string; // 可選:手動指定機器人ID
|
| 21 |
options?: Record<string, any>;
|
| 22 |
}
|
| 23 |
|
| 24 |
+
// 機器人匹配結果
|
| 25 |
+
export type MatchedAssistant = {
|
| 26 |
+
name: string;
|
| 27 |
+
assistant_id: string;
|
| 28 |
+
score: number;
|
| 29 |
+
is_recommended: boolean;
|
| 30 |
+
is_match: boolean;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export type FindAssistantsResponse = {
|
| 34 |
+
success: boolean;
|
| 35 |
+
matches: MatchedAssistant[];
|
| 36 |
+
count: number;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
export type Suggestion = {
|
| 40 |
type: string;
|
| 41 |
section: string;
|
|
|
|
| 57 |
export type GradeResponse = {
|
| 58 |
success: boolean;
|
| 59 |
data?: {
|
| 60 |
+
assistant_name: string;
|
| 61 |
+
assistant_id: string;
|
| 62 |
result: GradeResult;
|
| 63 |
};
|
| 64 |
error?: {
|
| 65 |
code: string;
|
| 66 |
message: string;
|
| 67 |
+
details?: any;
|
| 68 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|