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 CHANGED
@@ -36,7 +36,7 @@ Authorization: Bearer YOUR_API_TOKEN
36
  {
37
  "key": "zh_full_edit",
38
  "name": "中文全文批改",
39
- "language": "zh-Hant",
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
- 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"
 
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
- 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
 
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
- 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():
@@ -45,16 +46,11 @@ class JobStatus(BaseModel):
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]
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
- 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"
 
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, 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
  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
- @router.get("/agents", response_model=AgentsListResponse)
20
- async def get_agents():
21
- """Get list of available agents"""
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  try:
23
  config = get_agents_config()
24
- agents_list = []
25
-
26
- for key, agent_data in config.list_agents().items():
27
- agents_list.append(AgentInfo(
28
- key=key,
29
- name=agent_data["name"],
30
- language=agent_data["language"],
31
- ui=agent_data.get("ui"),
32
- rubric=agent_data.get("rubric")
33
- ))
34
-
35
- return AgentsListResponse(
36
- version=config.version,
37
- agents=agents_list
38
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  except Exception as e:
40
- logger.error(f"Error fetching agents: {str(e)}")
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
- """Grade text using specified agent"""
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  try:
47
- # Get agent configuration
 
48
  config = get_agents_config()
49
- agent = config.get_agent(request.agent_key)
50
-
51
- if not agent:
52
- return GradeResponse(
53
- success=False,
54
- error={
55
- "code": "INVALID_AGENT",
56
- "message": f"Agent '{request.agent_key}' not found",
57
- "available_agents": list(config.agents.keys())
58
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  )
60
-
61
- # Add category, age_group, and language to options if provided
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  options = request.options or {}
63
- if request.category:
64
- options['category'] = request.category
65
- if request.age_group:
66
- options['age_group'] = request.age_group
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=agent,
74
  text=request.text,
75
  options=options
76
  )
77
-
78
  return GradeResponse(
79
  success=True,
80
  data={
81
- "agent_key": request.agent_key,
 
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": "An error occurred while processing your request"
 
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
- return {"status": "healthy", "service": "AI Grading Platform"}
 
 
 
 
 
 
 
 
 
 
 
 
 
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-Hant":
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 { Agent, GradeResult } from './types/index';
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
- const [agents, setAgents] = useState<Agent[]>([]);
12
- const [selectedAgent, setSelectedAgent] = useState<string>('');
13
- const [selectedCategory, setSelectedCategory] = useState<string>('1');
14
- const [selectedAgeGroup, setSelectedAgeGroup] = useState<string>('國小');
15
- const [selectedLanguage, setSelectedLanguage] = useState<string>('zh');
 
 
 
 
 
 
 
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
- const categories = [
22
- { value: '1', label: '文學小說' },
23
- { value: '2', label: '詩集' },
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.zh;
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
- loadAgents();
116
  }
117
  }, []);
118
 
119
  const handlePasswordCorrect = () => {
120
  setIsPasswordVerified(true);
121
- loadAgents();
122
  };
123
 
124
- const loadAgents = async () => {
125
  try {
126
- const agentsList = await gradingApi.getAgents();
127
- setAgents(agentsList);
128
- if (agentsList.length > 0) {
129
- setSelectedAgent(agentsList[0].key);
 
 
 
 
 
 
 
 
 
 
 
130
  }
131
  } catch (err) {
132
- console.error('Failed to load agents:', err);
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.value} value={age.value}>
247
  {age.label}
248
  </option>
249
  ))}
@@ -259,7 +283,7 @@ function App() {
259
  disabled={loading}
260
  >
261
  {categories.map((category) => (
262
- <option key={category.value} value={category.value}>
263
  {category.label}
264
  </option>
265
  ))}
@@ -267,35 +291,19 @@ function App() {
267
  </div>
268
 
269
  <div className="header-selector">
270
- <label htmlFor="agent">{currentLabels.gradingMode}</label>
271
  <select
272
- id="agent"
273
- value={selectedAgent}
274
- onChange={(e) => setSelectedAgent(e.target.value)}
275
  disabled={loading}
276
  >
277
- {agents.map((agent) => (
278
- <option key={agent.key} value={agent.key}>
279
- {agent.name}
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
- <button
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if (selectedLanguage === 'en') {
452
- if (key === 'theme_content' || key === '主題與內容') return 'Theme & Content';
453
- if (key === 'word_sentence' || key === '遣詞造句') return 'Word Choice';
454
- if (key === 'paragraph_structure' || key === '段落結構') return 'Structure';
455
- if (key === 'typo_check' || key === '錯別字檢查') return 'Spelling & Grammar';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  return key;
457
  }
458
- // Chinese
459
- if (key === 'theme_content' || key === '主題與內容') return '主題與內容';
460
- if (key === 'word_sentence' || key === '遣詞造句') return '遣詞造句';
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 { Agent, AgentsResponse, GradeRequest, GradeResponse } from '../types/index';
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
- async getAgents(): Promise<Agent[]> {
24
- const response = await api.get<AgentsResponse>('/agents');
25
- return response.data.agents;
 
 
 
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
- 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;
@@ -46,16 +57,13 @@ export type GradeResult = {
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
  }
 
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
  }