Spaces:
Sleeping
Sleeping
exercise json
Browse files- backend/__pycache__/main.cpython-310.pyc +0 -0
- backend/exercises/check_exercises.py +66 -0
- backend/exercises/exercise_bank.json +310 -0
- backend/exercises/exercise_service.py +42 -0
- backend/exercises/models.py +27 -0
- backend/main.py +42 -2
- src/App.jsx +96 -57
- src/components/Exercise/Exercise.jsx +200 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 107 |
-
setSelectedNode(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: '
|
| 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%' : '
|
| 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 |
-
{
|
| 468 |
-
<
|
| 469 |
-
{
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 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;
|