youngtsai commited on
Commit
35c394f
·
1 Parent(s): d21f0e7

exercise json

Browse files
backend/__pycache__/main.cpython-310.pyc CHANGED
Binary files a/backend/__pycache__/main.cpython-310.pyc and b/backend/__pycache__/main.cpython-310.pyc differ
 
backend/exercises/check_exercises.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from typing import Dict, Set
4
+
5
+ def load_knowledge_graphs() -> Dict[str, Set[str]]:
6
+ """加載所有知識圖譜的節點 ID"""
7
+ graphs = {}
8
+
9
+ # Python 基礎
10
+ from backend.knowledge_graphs.python_basics import knowledge_graph as python_basics
11
+ graphs['python_basics'] = set(python_basics['nodes'].keys())
12
+
13
+ # 資料結構
14
+ from backend.knowledge_graphs.data_structures import knowledge_graph as data_structures
15
+ graphs['data_structures'] = set(data_structures['nodes'].keys())
16
+
17
+ # 保險詐騙防範
18
+ from backend.knowledge_graphs.fraud_prevention import knowledge_graph as fraud_prevention
19
+ graphs['fraud_prevention'] = set(fraud_prevention['nodes'].keys())
20
+
21
+ return graphs
22
+
23
+ def check_exercises():
24
+ """檢查每個節點是否都有練習題"""
25
+ # 加載知識圖譜
26
+ graphs = load_knowledge_graphs()
27
+
28
+ # 加載練習題
29
+ current_dir = os.path.dirname(os.path.abspath(__file__))
30
+ json_path = os.path.join(current_dir, 'exercise_bank.json')
31
+
32
+ with open(json_path, 'r', encoding='utf-8') as f:
33
+ exercise_data = json.load(f)
34
+
35
+ # 收集所有練習題覆蓋的節點
36
+ covered_nodes = {graph_id: set() for graph_id in graphs.keys()}
37
+
38
+ # 檢查每個練習題
39
+ for topic, categories in exercise_data.items():
40
+ for category, exercises in categories.items():
41
+ for exercise in exercises:
42
+ for node_id in exercise['node_ids']:
43
+ if topic in covered_nodes:
44
+ covered_nodes[topic].add(node_id)
45
+
46
+ # 檢查結果
47
+ missing_nodes = {}
48
+ for graph_id, nodes in graphs.items():
49
+ missing = nodes - covered_nodes[graph_id]
50
+ if missing:
51
+ missing_nodes[graph_id] = missing
52
+
53
+ # 輸出結果
54
+ if missing_nodes:
55
+ print("以下節點缺少練習題:")
56
+ for graph_id, nodes in missing_nodes.items():
57
+ print(f"\n{graph_id}:")
58
+ for node_id in nodes:
59
+ print(f" - Node {node_id}")
60
+ return False
61
+ else:
62
+ print("所有節點都有對應的練習題!")
63
+ return True
64
+
65
+ if __name__ == "__main__":
66
+ check_exercises()
backend/exercises/exercise_bank.json ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "python_basics": {
3
+ "variables": [
4
+ {
5
+ "id": "py_var_1",
6
+ "type": "multiple_choice",
7
+ "question": "在 Python 中,哪個是正確的變數命名方式?",
8
+ "options": [
9
+ "1_variable",
10
+ "my-variable",
11
+ "my_variable",
12
+ "@variable"
13
+ ],
14
+ "answer": "2",
15
+ "hints": [
16
+ "變數名稱不能以數字開頭",
17
+ "Python 變數命名通常使用蛇形命名法(snake_case)",
18
+ "變數名稱只能包含字母、數字和底線"
19
+ ],
20
+ "node_ids": ["2"],
21
+ "explanation": "Python 變數命名規則:必須以字母或底線開頭,只能包含字母、數字和底線,通常使用蛇形命名法。"
22
+ }
23
+ ],
24
+ "data_types": [
25
+ {
26
+ "id": "py_type_1",
27
+ "type": "fill_in_blank",
28
+ "question": "請填寫適當的 Python 資料型別:\n x = _____(3.14)",
29
+ "answer": "float",
30
+ "hints": [
31
+ "這是一個帶小數點的數字",
32
+ "Python 中用於表示小數的資料型別",
33
+ "與 int 相對的浮點數型別"
34
+ ],
35
+ "node_ids": ["2"],
36
+ "explanation": "float() 用於創建或轉換浮點數,3.14 是一個浮點數值。"
37
+ }
38
+ ],
39
+ "control_flow": [
40
+ {
41
+ "id": "py_loop_1",
42
+ "type": "multiple_choice",
43
+ "question": "以下哪個迴圈會執行 5 次?",
44
+ "options": [
45
+ "for i in range(5, 1)",
46
+ "for i in range(1, 5)",
47
+ "for i in range(5)",
48
+ "for i in range(1, 6)"
49
+ ],
50
+ "answer": "2",
51
+ "hints": [
52
+ "range() 函數的起始值預設為 0",
53
+ "range(n) 會生成 0 到 n-1 的序列",
54
+ "數一數會執行幾次"
55
+ ],
56
+ "node_ids": ["3"],
57
+ "explanation": "range(5) 會生成序列 [0,1,2,3,4],正好執行 5 次。"
58
+ }
59
+ ],
60
+ "functions": []
61
+ },
62
+ "fraud_prevention": {
63
+ "basic_concepts": [
64
+ {
65
+ "id": "fraud_basic_1",
66
+ "type": "multiple_choice",
67
+ "question": "下列何者不是保險詐騙的常見手法?",
68
+ "options": [
69
+ "虛報醫療費用",
70
+ "故意製造意外事故",
71
+ "在投保前如實告知健康狀況",
72
+ "重複投保多家保險公司"
73
+ ],
74
+ "answer": "2",
75
+ "hints": [
76
+ "思考哪個選項是合法且正當的行為",
77
+ "誠實告知是投保人的義務",
78
+ "其他選項都是詐騙手法"
79
+ ],
80
+ "node_ids": ["1"],
81
+ "explanation": "在投保前如實告知健康狀況是投保人的法定義務,而非詐騙手法。其他選項都是常見的保險詐騙方式。"
82
+ },
83
+ {
84
+ "id": "fraud_basic_4",
85
+ "type": "multiple_choice",
86
+ "question": "保險詐騙對社會造成的影響不包括?",
87
+ "options": [
88
+ "增加保險公司營運成本",
89
+ "提高誠實投保人的保費負擔",
90
+ "降低保險理賠效率",
91
+ "增加保險商品種類"
92
+ ],
93
+ "answer": "3",
94
+ "hints": [
95
+ "思考詐騙的負面影響",
96
+ "哪個選項是正面的",
97
+ "其他選項都是負面影響"
98
+ ],
99
+ "node_ids": ["1"],
100
+ "explanation": "保險詐騙會增加營運成本、提高保費、降低效率,但不會增加商品種類。反而可能因為詐騙風險而減少商品種類。"
101
+ },
102
+ {
103
+ "id": "fraud_basic_5",
104
+ "type": "fill_in_blank",
105
+ "question": "保險詐騙的主要動機是_____,這導致部分人鋌而走險。",
106
+ "answer": "不當得利",
107
+ "hints": [
108
+ "考慮詐騙者的目的",
109
+ "違法獲取金錢利益",
110
+ "四個字的詞語"
111
+ ],
112
+ "node_ids": ["1"],
113
+ "explanation": "不當得利是保險詐騙的主要動機,詐騙者試圖透過非法手段獲取不應得的保險金。"
114
+ }
115
+ ],
116
+ "detection": [
117
+ {
118
+ "id": "fraud_detect_1",
119
+ "type": "multiple_choice",
120
+ "question": "下列哪個不是保險詐騙的警示指標?",
121
+ "options": [
122
+ "短期內多次理賠",
123
+ "理賠金額異常高",
124
+ "定期繳納保費",
125
+ "事故發生情況不明確"
126
+ ],
127
+ "answer": "2",
128
+ "hints": [
129
+ "區分正常與異常行為",
130
+ "正常的保險行為",
131
+ "其他選項都有可疑之處"
132
+ ],
133
+ "node_ids": ["2"],
134
+ "explanation": "定期繳納保費是正常的保險行為,而其他選項都是可能的詐騙警示指標。"
135
+ },
136
+ {
137
+ "id": "fraud_detect_3",
138
+ "type": "multiple_choice",
139
+ "question": "下列哪項不是有效的詐騙偵測方法?",
140
+ "options": [
141
+ "大數據分析",
142
+ "人工智慧模型",
143
+ "完全信任客戶",
144
+ "交叉比對資料"
145
+ ],
146
+ "answer": "2",
147
+ "hints": [
148
+ "考慮專業的偵測方法",
149
+ "哪個選項缺乏專業判斷",
150
+ "需要保持適度懷疑"
151
+ ],
152
+ "node_ids": ["2"],
153
+ "explanation": "完全信任客戶而不進行專業判斷是不恰當的,應該運用各種科技工具和專業方法進行偵測。"
154
+ },
155
+ {
156
+ "id": "fraud_detect_4",
157
+ "type": "fill_in_blank",
158
+ "question": "保險公司應建立_____系統,以即時發現異常的理賠申請。",
159
+ "answer": "風險評估",
160
+ "hints": [
161
+ "這是一個評價風險的系統",
162
+ "用於判斷案件的風險程度",
163
+ "四個字的專業詞彙"
164
+ ],
165
+ "node_ids": ["2"],
166
+ "explanation": "風險評估系統能幫助保險公司及早發現高風險的理賠申請,是重要的詐騙偵測工具。"
167
+ }
168
+ ],
169
+ "prevention": [
170
+ {
171
+ "id": "fraud_prevent_4",
172
+ "type": "multiple_choice",
173
+ "question": "以下哪項不是有效的防範措施?",
174
+ "options": [
175
+ "加強員工培訓",
176
+ "簡化理賠流程",
177
+ "建立資料庫",
178
+ "跨部門合作"
179
+ ],
180
+ "answer": "1",
181
+ "hints": [
182
+ "考慮哪個選項可能增加風險",
183
+ "流程簡化可能降低控管",
184
+ "其他選項都有助於防範"
185
+ ],
186
+ "node_ids": ["3"],
187
+ "explanation": "過度簡化理賠流程可能降低控管效果,增加詐騙風險。其他選項都是有效的防範措施。"
188
+ },
189
+ {
190
+ "id": "fraud_prevent_5",
191
+ "type": "fill_in_blank",
192
+ "question": "保險公司應定期進行_____,以提升防範措施的有效性。",
193
+ "answer": "成效評估",
194
+ "hints": [
195
+ "這是一個檢視措施效果的過程",
196
+ "用於評價防範措施的結果",
197
+ "四個字的專業詞彙"
198
+ ],
199
+ "node_ids": ["3"],
200
+ "explanation": "定期進行成效評估可以了解防範措施的實際效果,並適時調整改進。"
201
+ }
202
+ ],
203
+ "reporting_channels": [
204
+ {
205
+ "id": "fraud_report_1",
206
+ "type": "multiple_choice",
207
+ "question": "下列何者不是正確的保險詐騙通報管道?",
208
+ "options": [
209
+ "保險局檢舉專線",
210
+ "保險公司客服中心",
211
+ "社群媒體留言板",
212
+ "金融監督管理委員會"
213
+ ],
214
+ "answer": "2",
215
+ "hints": [
216
+ "考慮哪個管道是官方或正式的",
217
+ "社群媒體不是正式通報管道",
218
+ "應該透過正式管道通報"
219
+ ],
220
+ "node_ids": ["4"],
221
+ "explanation": "社群媒體留言板不是正式的通報管道,應該透過保險局檢舉專線、保險公司客服中心或金融監督管理委員會等正式管道進行通報。"
222
+ },
223
+ {
224
+ "id": "fraud_report_2",
225
+ "type": "fill_in_blank",
226
+ "question": "保險詐騙通報時,應提供_____等具體事證,以利後續調查。",
227
+ "answer": "時間地點",
228
+ "hints": [
229
+ "這些資訊是調查的基本要素",
230
+ "包含事件發生的基本資訊",
231
+ "四個字的答案"
232
+ ],
233
+ "node_ids": ["4"],
234
+ "explanation": "提供時間地點等具體事證可以幫助調查人員更有效地進行調查工作。"
235
+ },
236
+ {
237
+ "id": "fraud_report_3",
238
+ "type": "multiple_choice",
239
+ "question": "關於保險詐騙通報,下列敘述何者正確?",
240
+ "options": [
241
+ "只能由保險公司員工通報",
242
+ "通報者身分必須公開",
243
+ "可以匿名通報",
244
+ "必須等待犯罪完成才能通報"
245
+ ],
246
+ "answer": "2",
247
+ "hints": [
248
+ "考慮通報者的權益保護",
249
+ "通報機制應該要便利且安全",
250
+ "不需要等待犯罪完成"
251
+ ],
252
+ "node_ids": ["4"],
253
+ "explanation": "為了保護通報者,可以採取匿名通報的方式,且任何人都可以進行通報,不限於保險公司員工。"
254
+ }
255
+ ],
256
+ "case_studies": [
257
+ {
258
+ "id": "fraud_case_1",
259
+ "type": "multiple_choice",
260
+ "question": "在「假車禍真理賠」的案例中,最常見的作案手法是?",
261
+ "options": [
262
+ "偽造醫療單據",
263
+ "故意製造小擦撞",
264
+ "虛報車損金額",
265
+ "以上皆是"
266
+ ],
267
+ "answer": "3",
268
+ "hints": [
269
+ "這類案件通常涉及多種手法",
270
+ "考慮完整的詐騙流程",
271
+ "各種手法常常同時使用"
272
+ ],
273
+ "node_ids": ["5"],
274
+ "explanation": "假車禍案件通常會同時使用多種手法,包括故意製造擦撞、虛報損失金額,以及偽造相關單據等。"
275
+ },
276
+ {
277
+ "id": "fraud_case_2",
278
+ "type": "fill_in_blank",
279
+ "question": "在醫療保險詐騙案例中,常見的手法是偽造_____以詐取保險金。",
280
+ "answer": "診斷證明",
281
+ "hints": [
282
+ "這是醫療院所開立的重要文件",
283
+ "用於證明病情的文件",
284
+ "四個字的醫療文件"
285
+ ],
286
+ "node_ids": ["5"],
287
+ "explanation": "診斷證明是醫療保險理賠的重要文件,不肖分子常透過偽造診斷證明來進行詐騙。"
288
+ },
289
+ {
290
+ "id": "fraud_case_3",
291
+ "type": "multiple_choice",
292
+ "question": "分析真實詐騙案例的主要目的是?",
293
+ "options": [
294
+ "增加專業知識",
295
+ "提升偵測能力",
296
+ "預防類似案件",
297
+ "以上皆是"
298
+ ],
299
+ "answer": "3",
300
+ "hints": [
301
+ "案例分析有多重目的",
302
+ "既要學習也要預防",
303
+ "知識和實務的結合"
304
+ ],
305
+ "node_ids": ["5"],
306
+ "explanation": "分析真實案例可以同時達到增加專業知識、提升偵測能力和預防類似案件發生的多重目的。"
307
+ }
308
+ ]
309
+ }
310
+ }
backend/exercises/exercise_service.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from backend.exercises.models import Exercise, ExerciseType
4
+
5
+ def load_exercises():
6
+ """從 JSON 文件加載練習題"""
7
+ current_dir = os.path.dirname(os.path.abspath(__file__))
8
+ json_path = os.path.join(current_dir, 'exercise_bank.json')
9
+
10
+ with open(json_path, 'r', encoding='utf-8') as f:
11
+ data = json.load(f)
12
+
13
+ exercises = []
14
+ node_exercises = {}
15
+
16
+ # 遍歷所有主題和子類別
17
+ for topic, categories in data.items():
18
+ for category, exercises_list in categories.items():
19
+ for exercise_data in exercises_list:
20
+ # 創建練習題實例
21
+ exercise = Exercise(
22
+ id=exercise_data['id'],
23
+ type=ExerciseType(exercise_data['type']),
24
+ question=exercise_data['question'],
25
+ answer=exercise_data['answer'],
26
+ hints=exercise_data['hints'],
27
+ node_ids=exercise_data['node_ids'],
28
+ options=exercise_data.get('options'),
29
+ explanation=exercise_data.get('explanation')
30
+ )
31
+ exercises.append(exercise)
32
+
33
+ # 建立節點到練習題的映射
34
+ for node_id in exercise_data['node_ids']:
35
+ if node_id not in node_exercises:
36
+ node_exercises[node_id] = []
37
+ node_exercises[node_id].append(exercise)
38
+
39
+ return exercises, node_exercises
40
+
41
+ # 載入練習題
42
+ exercises, node_exercises = load_exercises()
backend/exercises/models.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+ from typing import List, Optional, Dict
3
+
4
+ class ExerciseType(str, Enum):
5
+ MULTIPLE_CHOICE = "multiple_choice"
6
+ FILL_IN_BLANK = "fill_in_blank"
7
+
8
+ class Exercise:
9
+ def __init__(
10
+ self,
11
+ id: str,
12
+ type: ExerciseType,
13
+ question: str,
14
+ answer: str,
15
+ hints: List[str],
16
+ node_ids: List[str],
17
+ options: Optional[List[str]] = None,
18
+ explanation: Optional[str] = None
19
+ ):
20
+ self.id = id
21
+ self.type = type
22
+ self.question = question
23
+ self.answer = answer
24
+ self.hints = hints
25
+ self.node_ids = node_ids # 關聯的知識點 ID 列表
26
+ self.options = options # 選擇題選項
27
+ self.explanation = explanation # 解答說明
backend/main.py CHANGED
@@ -6,6 +6,8 @@ from typing import Dict
6
  from .knowledge_graphs import python_basics, data_structures, fraud_prevention
7
  import os
8
  from .services.openai_service import assistant_manager
 
 
9
 
10
  app = FastAPI()
11
 
@@ -88,11 +90,49 @@ async def chat(thread_id: str, data: dict):
88
  if "message" not in data:
89
  raise HTTPException(status_code=400, detail="Message is required")
90
 
 
91
  context = f"主題:{data.get('topic', '')}\n"
92
  context += f"當前節點:{data.get('node_title', '')}\n"
93
- context += f"問題:{data.get('message')}"
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  return StreamingResponse(
96
  assistant_manager.get_response_stream(thread_id, context),
97
  media_type="text/event-stream"
98
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from .knowledge_graphs import python_basics, data_structures, fraud_prevention
7
  import os
8
  from .services.openai_service import assistant_manager
9
+ from .exercises.exercise_service import exercises, node_exercises
10
+ from .exercises.models import Exercise
11
 
12
  app = FastAPI()
13
 
 
90
  if "message" not in data:
91
  raise HTTPException(status_code=400, detail="Message is required")
92
 
93
+ # 構建更詳細的上下文
94
  context = f"主題:{data.get('topic', '')}\n"
95
  context += f"當前節點:{data.get('node_title', '')}\n"
96
+
97
+ # 添加練習題相關資訊
98
+ if exercise_context := data.get('exercise_context'):
99
+ current_exercise = exercise_context['current_exercise']
100
+ context += f"\n當前練習題狀態:\n"
101
+ context += f"- 正在進行第 {current_exercise['current_index']}/{current_exercise['total_exercises']} 題\n"
102
+ context += f"- 題目:{current_exercise['question']}\n"
103
+ context += f"- 題型:{current_exercise['type']}\n"
104
+
105
+ if current_exercise.get('last_answer') is not None:
106
+ context += f"- 上次提交答案:{current_exercise['last_answer']}\n"
107
+ context += f"- 答題結果:{'正確' if current_exercise.get('is_correct') else '錯誤'}\n"
108
+
109
+ context += f"\n問題:{data.get('message')}"
110
 
111
  return StreamingResponse(
112
  assistant_manager.get_response_stream(thread_id, context),
113
  media_type="text/event-stream"
114
+ )
115
+
116
+ @app.get("/api/exercises/{node_id}")
117
+ def get_node_exercises(node_id: str):
118
+ """獲取特定節點的練習題"""
119
+ return {
120
+ "exercises": node_exercises.get(node_id, [])
121
+ }
122
+
123
+ @app.post("/api/exercises/check/{exercise_id}")
124
+ def check_exercise(exercise_id: str, data: dict):
125
+ """檢查答案"""
126
+ user_answer = data.get("answer")
127
+ if not user_answer:
128
+ raise HTTPException(status_code=400, detail="Answer is required")
129
+
130
+ exercise = next((ex for ex in exercises if ex.id == exercise_id), None)
131
+ if not exercise:
132
+ raise HTTPException(status_code=404, detail="Exercise not found")
133
+
134
+ is_correct = user_answer.lower() == exercise.answer.lower()
135
+ return {
136
+ "correct": is_correct,
137
+ "explanation": exercise.explanation if is_correct or data.get("show_explanation") else None
138
+ }
src/App.jsx CHANGED
@@ -24,6 +24,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
24
  import ExpandLessIcon from '@mui/icons-material/ExpandLess';
25
  import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
26
  import ChevronRightIcon from '@mui/icons-material/ChevronRight';
 
27
 
28
  function App() {
29
  const [availableGraphs, setAvailableGraphs] = useState([])
@@ -46,6 +47,10 @@ function App() {
46
  content: true, // 中間上方內容
47
  exercise: true // 中間下方練習
48
  });
 
 
 
 
49
 
50
  const scrollToBottom = useCallback(() => {
51
  if (chatContainerRef.current) {
@@ -103,12 +108,21 @@ function App() {
103
 
104
  const handleNodeClick = async (nodeId) => {
105
  try {
106
- const response = await axios.get(`/api/graph/${selectedGraphId}/node/${nodeId}`)
107
- setSelectedNode(response.data)
 
 
 
 
 
 
 
 
 
108
  } catch (error) {
109
- console.error('Error fetching node:', error)
110
  }
111
- }
112
 
113
  const handleSendMessage = async () => {
114
  if (!chatMessage.trim() || !threadId) return
@@ -125,33 +139,49 @@ function App() {
125
  setChatHistory(prev => [...prev, newChatItem])
126
 
127
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  const response = await fetch(`/api/chat/${threadId}`, {
129
  method: 'POST',
130
  headers: {
131
  'Content-Type': 'application/json',
132
  },
133
- body: JSON.stringify({
134
- message: newQuestion,
135
- topic: selectedGraphId,
136
- node_title: selectedNode?.title
137
- })
138
  })
139
 
 
 
 
 
140
  const reader = response.body.getReader()
141
  const decoder = new TextDecoder()
142
  let responseText = ''
143
 
144
  while (true) {
145
  const { done, value } = await reader.read()
146
- if (done) {
147
- // 在完整回應結束後執行聚焦
148
- setTimeout(() => {
149
- if (inputRef.current) {
150
- inputRef.current.querySelector('textarea').focus();
151
- }
152
- }, 100);
153
- break;
154
- }
155
 
156
  const chunk = decoder.decode(value)
157
  responseText += chunk
@@ -176,13 +206,19 @@ function App() {
176
  if (lastIndex >= 0) {
177
  newHistory[lastIndex] = {
178
  ...newHistory[lastIndex],
179
- answer: 'Error: Failed to get response'
180
  }
181
  }
182
  return newHistory
183
  })
184
  } finally {
185
  setChatLoading(false)
 
 
 
 
 
 
186
  }
187
  }
188
 
@@ -200,6 +236,31 @@ function App() {
200
  return 12 - leftExpanded - rightExpanded;
201
  };
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  if (loading) return <div>Loading...</div>
204
  if (error) return <div>Error: {error}</div>
205
  if (!graphData) return <div>No data</div>
@@ -312,7 +373,7 @@ function App() {
312
  }}>
313
  {/* 中間上方:內容 */}
314
  <Box sx={{
315
- height: expandedPanels.exercise ? '50%' : 'calc(100% - 48px)', // 當練習收合時延伸高度
316
  transition: 'all 0.3s ease',
317
  borderBottom: '1px solid rgba(0, 0, 0, 0.12)',
318
  backgroundColor: '#fff'
@@ -407,7 +468,7 @@ function App() {
407
 
408
  {/* 中間下方:練習 */}
409
  <Box sx={{
410
- height: expandedPanels.exercise ? '50%' : '48px',
411
  transition: 'all 0.3s ease',
412
  backgroundColor: '#fff',
413
  display: 'flex',
@@ -437,7 +498,7 @@ function App() {
437
  zIndex: 2
438
  }}>
439
  <Typography variant="h6" sx={{ fontWeight: 600 }}>
440
- 練習
441
  </Typography>
442
  <IconButton
443
  onClick={() => togglePanel('exercise')}
@@ -464,41 +525,19 @@ function App() {
464
  visibility: expandedPanels.exercise ? 'visible' : 'hidden',
465
  p: 2
466
  }}>
467
- {selectedNode?.exercises ? (
468
- <Box>
469
- {selectedNode.exercises.map((exercise) => (
470
- <Box key={exercise.id} sx={{ mb: 3 }}>
471
- <Typography variant="body1" sx={{
472
- mb: 2,
473
- fontWeight: 500
474
- }}>
475
- {exercise.question}
476
- </Typography>
477
- {exercise.type === 'multiple_choice' && (
478
- <Box sx={{ pl: 2 }}>
479
- {exercise.options.map((option, index) => (
480
- <Box
481
- key={index}
482
- sx={{
483
- mb: 1,
484
- display: 'flex',
485
- alignItems: 'center',
486
- gap: 1
487
- }}
488
- >
489
- <input
490
- type="radio"
491
- name={exercise.id}
492
- value={index}
493
- />
494
- <Typography>{option}</Typography>
495
- </Box>
496
- ))}
497
- </Box>
498
- )}
499
- </Box>
500
- ))}
501
- </Box>
502
  ) : (
503
  <Typography variant="body1" sx={{ color: 'text.secondary' }}>
504
  請選擇一個節點查看相關練習
 
24
  import ExpandLessIcon from '@mui/icons-material/ExpandLess';
25
  import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
26
  import ChevronRightIcon from '@mui/icons-material/ChevronRight';
27
+ import Exercise from './components/Exercise/Exercise';
28
 
29
  function App() {
30
  const [availableGraphs, setAvailableGraphs] = useState([])
 
47
  content: true, // 中間上方內容
48
  exercise: true // 中間下方練習
49
  });
50
+ const [currentExerciseIndex, setCurrentExerciseIndex] = useState(0);
51
+ const [nodeExercises, setNodeExercises] = useState([]);
52
+ const [exerciseHistory, setExerciseHistory] = useState([]);
53
+ const [result, setResult] = useState(null); // 添加 result 狀態
54
 
55
  const scrollToBottom = useCallback(() => {
56
  if (chatContainerRef.current) {
 
108
 
109
  const handleNodeClick = async (nodeId) => {
110
  try {
111
+ const nodeResponse = await axios.get(`/api/graph/${selectedGraphId}/node/${nodeId}`);
112
+ setSelectedNode(nodeResponse.data);
113
+
114
+ const exercisesResponse = await axios.get(`/api/exercises/${nodeId}`);
115
+ const exercises = exercisesResponse.data.exercises;
116
+
117
+ const shuffled = [...exercises].sort(() => 0.5 - Math.random());
118
+ setNodeExercises(shuffled);
119
+ setCurrentExerciseIndex(0);
120
+ setExerciseHistory([]); // 重置練習題歷程
121
+
122
  } catch (error) {
123
+ console.error('Error fetching node or exercises:', error);
124
  }
125
+ };
126
 
127
  const handleSendMessage = async () => {
128
  if (!chatMessage.trim() || !threadId) return
 
139
  setChatHistory(prev => [...prev, newChatItem])
140
 
141
  try {
142
+ // 構建更豐富的上下文
143
+ const context = {
144
+ message: newQuestion,
145
+ topic: selectedGraphId,
146
+ node_title: selectedNode?.title,
147
+ node_content: selectedNode?.content,
148
+ current_location: {
149
+ graph_title: availableGraphs.find(g => g.id === selectedGraphId)?.title || '',
150
+ node_title: selectedNode?.title || '未選擇節點',
151
+ has_exercises: nodeExercises.length > 0
152
+ },
153
+ exercise_context: nodeExercises.length > 0 ? {
154
+ current_exercise: {
155
+ question: nodeExercises[currentExerciseIndex].question,
156
+ type: nodeExercises[currentExerciseIndex].type,
157
+ answer: nodeExercises[currentExerciseIndex].answer, // 總是提供正確答案
158
+ is_correct: result?.correct,
159
+ total_exercises: nodeExercises.length,
160
+ current_index: currentExerciseIndex + 1
161
+ },
162
+ exercise_history: exerciseHistory // 添加練習題歷程
163
+ } : null
164
+ }
165
+
166
  const response = await fetch(`/api/chat/${threadId}`, {
167
  method: 'POST',
168
  headers: {
169
  'Content-Type': 'application/json',
170
  },
171
+ body: JSON.stringify(context)
 
 
 
 
172
  })
173
 
174
+ if (!response.ok) {
175
+ throw new Error(`HTTP error! status: ${response.status}`)
176
+ }
177
+
178
  const reader = response.body.getReader()
179
  const decoder = new TextDecoder()
180
  let responseText = ''
181
 
182
  while (true) {
183
  const { done, value } = await reader.read()
184
+ if (done) break
 
 
 
 
 
 
 
 
185
 
186
  const chunk = decoder.decode(value)
187
  responseText += chunk
 
206
  if (lastIndex >= 0) {
207
  newHistory[lastIndex] = {
208
  ...newHistory[lastIndex],
209
+ answer: '抱歉,我遇到了一些問題。請稍後再試。'
210
  }
211
  }
212
  return newHistory
213
  })
214
  } finally {
215
  setChatLoading(false)
216
+ // 在完整回應結束後執行聚焦
217
+ setTimeout(() => {
218
+ if (inputRef.current) {
219
+ inputRef.current.querySelector('textarea').focus()
220
+ }
221
+ }, 100)
222
  }
223
  }
224
 
 
236
  return 12 - leftExpanded - rightExpanded;
237
  };
238
 
239
+ const handleExerciseSubmit = async (exerciseId, answer) => {
240
+ try {
241
+ const response = await axios.post(`/api/exercises/check/${exerciseId}`, {
242
+ answer: answer
243
+ });
244
+
245
+ // 記錄練習題歷程
246
+ const exerciseRecord = {
247
+ exercise_id: exerciseId,
248
+ question: nodeExercises[currentExerciseIndex].question,
249
+ user_answer: answer,
250
+ correct_answer: nodeExercises[currentExerciseIndex].answer,
251
+ is_correct: response.data.correct,
252
+ timestamp: new Date().toISOString()
253
+ };
254
+
255
+ setExerciseHistory(prev => [...prev, exerciseRecord]);
256
+ setResult(response.data); // 設置結果
257
+
258
+ } catch (error) {
259
+ console.error('Error checking answer:', error);
260
+ setResult(null); // 發生錯誤時重置結果
261
+ }
262
+ };
263
+
264
  if (loading) return <div>Loading...</div>
265
  if (error) return <div>Error: {error}</div>
266
  if (!graphData) return <div>No data</div>
 
373
  }}>
374
  {/* 中間上方:內容 */}
375
  <Box sx={{
376
+ height: expandedPanels.content ? (expandedPanels.exercise ? '50%' : '50%') : '48px',
377
  transition: 'all 0.3s ease',
378
  borderBottom: '1px solid rgba(0, 0, 0, 0.12)',
379
  backgroundColor: '#fff'
 
468
 
469
  {/* 中間下方:練習 */}
470
  <Box sx={{
471
+ height: expandedPanels.exercise ? (expandedPanels.content ? '50%' : 'calc(100% - 48px)') : '48px',
472
  transition: 'all 0.3s ease',
473
  backgroundColor: '#fff',
474
  display: 'flex',
 
498
  zIndex: 2
499
  }}>
500
  <Typography variant="h6" sx={{ fontWeight: 600 }}>
501
+ 練習 {nodeExercises.length > 0 && `(${nodeExercises.length}題)`}
502
  </Typography>
503
  <IconButton
504
  onClick={() => togglePanel('exercise')}
 
525
  visibility: expandedPanels.exercise ? 'visible' : 'hidden',
526
  p: 2
527
  }}>
528
+ {nodeExercises.length > 0 ? (
529
+ <Exercise
530
+ key={nodeExercises[currentExerciseIndex].id}
531
+ exercise={nodeExercises[currentExerciseIndex]}
532
+ onNextExercise={
533
+ currentExerciseIndex < nodeExercises.length - 1
534
+ ? () => {
535
+ setCurrentExerciseIndex(prev => prev + 1);
536
+ }
537
+ : undefined
538
+ }
539
+ onSubmit={handleExerciseSubmit}
540
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  ) : (
542
  <Typography variant="body1" sx={{ color: 'text.secondary' }}>
543
  請選擇一個節點查看相關練習
src/components/Exercise/Exercise.jsx ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import {
3
+ Box,
4
+ Typography,
5
+ Radio,
6
+ RadioGroup,
7
+ FormControlLabel,
8
+ TextField,
9
+ Button,
10
+ Alert,
11
+ Collapse,
12
+ Stack,
13
+ Paper,
14
+ Divider
15
+ } from '@mui/material';
16
+ import LightbulbIcon from '@mui/icons-material/Lightbulb';
17
+ import NavigateNextIcon from '@mui/icons-material/NavigateNext';
18
+ import axios from 'axios';
19
+
20
+ function Exercise({ exercise, onNextExercise }) {
21
+ const [selectedAnswer, setSelectedAnswer] = useState('');
22
+ const [showHints, setShowHints] = useState(false);
23
+ const [currentHintIndex, setCurrentHintIndex] = useState(0);
24
+ const [result, setResult] = useState(null);
25
+
26
+ const handleSubmit = async () => {
27
+ try {
28
+ const response = await axios.post(`/api/exercises/check/${exercise.id}`, {
29
+ answer: selectedAnswer
30
+ });
31
+ setResult(response.data);
32
+ } catch (error) {
33
+ console.error('Error checking answer:', error);
34
+ }
35
+ };
36
+
37
+ const handleNextExercise = () => {
38
+ setSelectedAnswer('');
39
+ setShowHints(false);
40
+ setCurrentHintIndex(0);
41
+ setResult(null);
42
+ onNextExercise();
43
+ };
44
+
45
+ const showNextHint = () => {
46
+ if (currentHintIndex < exercise.hints.length - 1) {
47
+ setCurrentHintIndex(prev => prev + 1);
48
+ }
49
+ setShowHints(true);
50
+ };
51
+
52
+ return (
53
+ <Box sx={{
54
+ mb: 4,
55
+ display: 'flex',
56
+ gap: 3,
57
+ minHeight: 200
58
+ }}>
59
+ {/* 左側:題目和作答區域 */}
60
+ <Paper sx={{
61
+ flex: 2, // 佔據更多空間
62
+ p: 2,
63
+ display: 'flex',
64
+ flexDirection: 'column',
65
+ backgroundColor: '#f8f9fa'
66
+ }}>
67
+ <Typography variant="h6" sx={{ mb: 2, color: 'text.primary' }}>
68
+ 題目
69
+ </Typography>
70
+ <Typography variant="body1" sx={{
71
+ mb: 3,
72
+ fontWeight: 500,
73
+ whiteSpace: 'pre-wrap'
74
+ }}>
75
+ {exercise.question}
76
+ </Typography>
77
+
78
+ {/* 作答區域整合到題目下方 */}
79
+ <Box sx={{ mb: 2 }}>
80
+ {exercise.type === 'multiple_choice' ? (
81
+ <RadioGroup
82
+ value={selectedAnswer}
83
+ onChange={(e) => setSelectedAnswer(e.target.value)}
84
+ >
85
+ {exercise.options.map((option, index) => (
86
+ <FormControlLabel
87
+ key={index}
88
+ value={index.toString()}
89
+ control={<Radio />}
90
+ label={`${String.fromCharCode(65 + index)}. ${option}`} // 直接顯示完整選項
91
+ sx={{ mb: 1 }}
92
+ />
93
+ ))}
94
+ </RadioGroup>
95
+ ) : (
96
+ <TextField
97
+ fullWidth
98
+ value={selectedAnswer}
99
+ onChange={(e) => setSelectedAnswer(e.target.value)}
100
+ placeholder="請輸入答案"
101
+ variant="outlined"
102
+ size="small"
103
+ />
104
+ )}
105
+ </Box>
106
+ </Paper>
107
+
108
+ {/* 右側:操作和反饋區域 */}
109
+ <Paper sx={{
110
+ flex: 1, // 佔據較少空間
111
+ p: 2,
112
+ display: 'flex',
113
+ flexDirection: 'column'
114
+ }}>
115
+ <Typography variant="h6" sx={{ mb: 2, color: 'text.primary' }}>
116
+ 操作區
117
+ </Typography>
118
+
119
+ {/* 按鈕組 */}
120
+ <Stack spacing={2} sx={{ mb: 2 }}>
121
+ <Button
122
+ variant="contained"
123
+ onClick={handleSubmit}
124
+ disabled={!selectedAnswer}
125
+ fullWidth
126
+ >
127
+ 提交答案
128
+ </Button>
129
+ <Button
130
+ variant="outlined"
131
+ startIcon={<LightbulbIcon />}
132
+ onClick={showNextHint}
133
+ disabled={showHints && currentHintIndex >= exercise.hints.length - 1}
134
+ fullWidth
135
+ >
136
+ {showHints ? '下一個提示' : '顯示提示'}
137
+ </Button>
138
+ {onNextExercise && (
139
+ <Button
140
+ variant="contained"
141
+ color="success"
142
+ endIcon={<NavigateNextIcon />}
143
+ onClick={handleNextExercise}
144
+ disabled={!result?.correct}
145
+ fullWidth
146
+ >
147
+ 下一題
148
+ </Button>
149
+ )}
150
+ </Stack>
151
+
152
+ {/* 提示和結果區域 */}
153
+ <Box sx={{ mt: 'auto' }}>
154
+ <Collapse in={showHints}>
155
+ <Box sx={{ mb: 2 }}>
156
+ {exercise.hints.slice(0, currentHintIndex + 1).map((hint, index) => (
157
+ <Alert key={index} severity="info" sx={{ mb: 1 }}>
158
+ 提示 {index + 1}: {hint}
159
+ </Alert>
160
+ ))}
161
+ </Box>
162
+ </Collapse>
163
+
164
+ {result && (
165
+ <Alert
166
+ severity={result.correct ? "success" : "error"}
167
+ sx={{
168
+ '& .MuiAlert-message': { width: '100%' }
169
+ }}
170
+ >
171
+ <Box sx={{
172
+ display: 'flex',
173
+ flexDirection: 'column',
174
+ gap: 1
175
+ }}>
176
+ <Typography>
177
+ {result.correct ? '答對了!' : '再試一次!'}
178
+ </Typography>
179
+ {result.explanation && (
180
+ <Typography
181
+ sx={{
182
+ mt: 1,
183
+ p: 1,
184
+ backgroundColor: 'rgba(0, 0, 0, 0.04)',
185
+ borderRadius: 1
186
+ }}
187
+ >
188
+ 解釋:{result.explanation}
189
+ </Typography>
190
+ )}
191
+ </Box>
192
+ </Alert>
193
+ )}
194
+ </Box>
195
+ </Paper>
196
+ </Box>
197
+ );
198
+ }
199
+
200
+ export default Exercise;