Kentlo commited on
Commit
08348fb
·
1 Parent(s): a4720d3

중복답변 가능 버전

Browse files
app.py CHANGED
@@ -1,5 +1,5 @@
1
  # ==============================================
2
- # app.py — CBT + REVIEW(⭐) + WRONG + 통합 노트 + OCR Parser (확정 안정 전체 버전)
3
  # ==============================================
4
 
5
  import os
@@ -15,13 +15,15 @@ from ingest import ingest_questions
15
 
16
  try:
17
  from pdf_parser_adaptive import parse_pdf as _parse_pdf
18
- except:
19
  _parse_pdf = None
20
 
21
  load_dotenv()
22
  app = Flask(__name__)
23
- init_db()
24
 
 
 
 
25
  BASE_DIR = os.path.abspath(os.path.dirname(__file__))
26
  DATA_DIR = os.path.join(BASE_DIR, "data")
27
  UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
@@ -31,17 +33,53 @@ for d in [DATA_DIR, UPLOAD_DIR, PARSED_DIR]:
31
 
32
  DEFAULT_USER = "default"
33
 
 
 
 
 
 
 
 
 
34
 
 
 
 
35
  def normalize_options(raw):
 
 
 
 
 
 
36
  if not raw:
37
  return []
38
- try:
39
- if isinstance(raw, dict):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  return [f"{k}. {v}" for k, v in sorted(raw.items(), key=lambda kv: kv[0])]
41
- if isinstance(raw, list):
42
- return raw
43
- except:
44
- pass
 
 
 
45
  return []
46
 
47
 
@@ -54,6 +92,127 @@ def parse_pdf_wrapper(pdf_path):
54
  return out_json
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  @app.route("/")
58
  def home():
59
  return render_template("index.html")
@@ -79,6 +238,9 @@ def healthz():
79
  return jsonify({"ok": True})
80
 
81
 
 
 
 
82
  @app.route("/admin/upload", methods=["GET", "POST"])
83
  def upload_pdf():
84
  if request.method == "GET":
@@ -100,29 +262,34 @@ def upload_pdf():
100
  return jsonify({"ok": False, "error": str(e)}), 500
101
 
102
 
 
 
 
 
103
  @app.route("/api/question", methods=["GET"])
104
  def api_question():
105
- qid = request.args.get("id")
106
  db = SessionLocal()
107
  try:
108
- q = db.query(Question).filter(Question.id == int(qid)).first() if qid \
109
- else db.query(Question).order_by(Question.id.asc()).first()
 
 
 
 
 
110
  if not q:
111
  return jsonify({"error": "문제 없음"}), 404
112
 
113
- total = db.query(Question).count()
114
- return jsonify({
115
- "id": q.id,
116
- "question": q.stem,
117
- "options": normalize_options(q.get_options()),
118
- "answer": q.answer,
119
- "explanation": q.explanation,
120
- "total": total
121
- })
122
  finally:
123
  db.close()
124
 
125
 
 
 
 
 
126
  @app.route("/api/answer", methods=["POST"])
127
  def api_answer():
128
  data = request.get_json(force=True) or {}
@@ -130,18 +297,23 @@ def api_answer():
130
  chosen = data.get("chosen")
131
  user_id = str(data.get("user_id", DEFAULT_USER))
132
 
133
- if not qid or not chosen:
134
  return jsonify({"error": "question_id 또는 chosen 누락"}), 400
135
 
136
  db = SessionLocal()
137
  try:
138
  q = db.query(Question).filter(Question.id == int(qid)).first()
139
- correct = (str(chosen).upper() == str(q.answer).upper())
 
 
 
 
 
140
 
141
  db.add(Attempt(
142
  user_id=user_id,
143
  question_id=q.id,
144
- chosen=str(chosen),
145
  correct=bool(correct),
146
  note_type="wrong" if not correct else None
147
  ))
@@ -152,11 +324,22 @@ def api_answer():
152
  db.close()
153
 
154
 
 
 
 
 
 
155
  @app.route("/api/next", methods=["GET"])
156
  def api_next():
157
- current_id = int(request.args.get("current_id", 1))
158
  db = SessionLocal()
159
  try:
 
 
 
 
 
 
160
  nxt = (
161
  db.query(Question)
162
  .filter(Question.id > current_id)
@@ -166,19 +349,14 @@ def api_next():
166
  if not nxt:
167
  return jsonify({"end": True})
168
 
169
- total = db.query(Question).count()
170
- return jsonify({
171
- "id": nxt.id,
172
- "question": nxt.stem,
173
- "options": normalize_options(nxt.get_options()),
174
- "answer": nxt.answer,
175
- "explanation": nxt.explanation,
176
- "total": total
177
- })
178
  finally:
179
  db.close()
180
 
181
 
 
 
 
182
  @app.route("/api/review_add", methods=["POST"])
183
  def review_add():
184
  data = request.get_json(force=True) or {}
@@ -211,7 +389,7 @@ def review_remove():
211
  db.close()
212
 
213
 
214
- # ✅ 오답 + 복습 통합 조회
215
  @app.route("/api/wrong_review", methods=["GET"])
216
  def wrong_review():
217
  user_id = request.args.get("user_id", DEFAULT_USER)
@@ -245,7 +423,7 @@ def wrong_review():
245
  db.close()
246
 
247
 
248
- # ✅ 오답에서 제거 (복습에서도 제거)
249
  @app.route("/api/wrong_remove", methods=["POST"])
250
  def wrong_remove():
251
  data = request.get_json(force=True) or {}
@@ -265,5 +443,6 @@ def wrong_remove():
265
 
266
 
267
  if __name__ == "__main__":
268
- print("[INFO] http://127.0.0.1:5000 실행 중")
269
- app.run(host="0.0.0.0", port=5000, debug=True)
 
 
1
  # ==============================================
2
+ # app.py — CBT + REVIEW(⭐) + WRONG + 통합 노트 + OCR Parser (최적화 + 자동 DB화 완성본)
3
  # ==============================================
4
 
5
  import os
 
15
 
16
  try:
17
  from pdf_parser_adaptive import parse_pdf as _parse_pdf
18
+ except Exception:
19
  _parse_pdf = None
20
 
21
  load_dotenv()
22
  app = Flask(__name__)
 
23
 
24
+ # -----------------------------
25
+ # Paths
26
+ # -----------------------------
27
  BASE_DIR = os.path.abspath(os.path.dirname(__file__))
28
  DATA_DIR = os.path.join(BASE_DIR, "data")
29
  UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
 
33
 
34
  DEFAULT_USER = "default"
35
 
36
+ # -----------------------------
37
+ # DB init (패치/구버전 모두 호환)
38
+ # -----------------------------
39
+ try:
40
+ init_db(apply_light_migrations=True)
41
+ except TypeError:
42
+ init_db()
43
+
44
 
45
+ # -----------------------------
46
+ # Utils
47
+ # -----------------------------
48
  def normalize_options(raw):
49
+ """
50
+ 어떤 형태로 저장되어 있어도 "표시용 문자열 리스트"로 변환.
51
+ - 표준: [{"key":"A","text":"..."}] -> ["A. ...", ...]
52
+ - 레거시: {"A":"..."} -> ["A. ...", ...]
53
+ - 레거시: ["...","..."] -> 그대로
54
+ """
55
  if not raw:
56
  return []
57
+
58
+ # 표준: list[dict]
59
+ if isinstance(raw, list) and raw and isinstance(raw[0], dict):
60
+ out = []
61
+ for o in raw:
62
+ k = str(o.get("key", "")).strip()
63
+ t = str(o.get("text", "")).strip()
64
+ if k and t:
65
+ out.append(f"{k}. {t}")
66
+ elif t:
67
+ out.append(t)
68
+ elif k:
69
+ out.append(k)
70
+ return out
71
+
72
+ # 레거시: dict
73
+ if isinstance(raw, dict):
74
+ try:
75
  return [f"{k}. {v}" for k, v in sorted(raw.items(), key=lambda kv: kv[0])]
76
+ except Exception:
77
+ return [str(raw)]
78
+
79
+ # 레거시: list[str]
80
+ if isinstance(raw, list):
81
+ return [str(x) for x in raw]
82
+
83
  return []
84
 
85
 
 
92
  return out_json
93
 
94
 
95
+ def first_question(db):
96
+ """
97
+ 첫 문제 선택:
98
+ - page_no/q_no_on_page/global_no 컬럼이 있으면 PDF 순서로
99
+ - 없으면 id로
100
+ """
101
+ q = db.query(Question)
102
+ if hasattr(Question, "page_no") and hasattr(Question, "q_no_on_page"):
103
+ return q.order_by(
104
+ Question.page_no.asc().nulls_last(),
105
+ Question.q_no_on_page.asc().nulls_last(),
106
+ Question.id.asc()
107
+ ).first()
108
+ if hasattr(Question, "global_no"):
109
+ return q.order_by(
110
+ Question.global_no.asc().nulls_last(),
111
+ Question.id.asc()
112
+ ).first()
113
+ return q.order_by(Question.id.asc()).first()
114
+
115
+
116
+ def api_payload(db, q: Question):
117
+ """
118
+ 기존 프론트 호환 필드 유지 + 신규 필드(있으면)만 추가
119
+ """
120
+ total = db.query(Question).count()
121
+ payload = {
122
+ "id": q.id,
123
+ "question": q.stem,
124
+ "options": normalize_options(q.get_options()),
125
+ "answer": q.answer, # 레거시 유지
126
+ "explanation": q.explanation,
127
+ "total": total,
128
+ }
129
+
130
+ # 신규(모델이 지원할 때만)
131
+ if hasattr(q, "get_answer_keys"):
132
+ payload["answer_keys"] = q.get_answer_keys()
133
+ if hasattr(q, "get_answer_steps"):
134
+ payload["answer_steps"] = q.get_answer_steps()
135
+ if hasattr(q, "get_images"):
136
+ payload["images"] = q.get_images()
137
+
138
+ # 순서 메타(있을 때만)
139
+ if hasattr(q, "page_no"):
140
+ payload["page_no"] = q.page_no
141
+ if hasattr(q, "q_no_on_page"):
142
+ payload["q_no_on_page"] = q.q_no_on_page
143
+ if hasattr(q, "global_no"):
144
+ payload["global_no"] = q.global_no
145
+
146
+ return payload
147
+
148
+
149
+ def is_correct(q: Question, chosen):
150
+ """
151
+ 최소 변경으로 정답 판정 개선:
152
+ - models.py에 get_answer_keys/get_answer_steps 있으면 그걸 우선 사용
153
+ - 없으면 레거시 answer(string)로 비교
154
+ """
155
+ # chosen 표준화 (string/list 모두 허용)
156
+ if isinstance(chosen, list):
157
+ chosen_list = [str(x).strip().upper() for x in chosen if str(x).strip()]
158
+ else:
159
+ s = str(chosen).strip()
160
+ if "," in s:
161
+ chosen_list = [x.strip().upper() for x in s.split(",") if x.strip()]
162
+ else:
163
+ chosen_list = [s.upper()] if s else []
164
+
165
+ # Steps 우선
166
+ if hasattr(q, "get_answer_steps"):
167
+ ans_steps = [str(x).strip().upper() for x in (q.get_answer_steps() or [])]
168
+ if ans_steps:
169
+ return chosen_list == ans_steps # 순서/중복까지 동일
170
+
171
+ # 복수정답(중복 포함) 우선
172
+ if hasattr(q, "get_answer_keys"):
173
+ ans_keys = [str(x).strip().upper() for x in (q.get_answer_keys() or [])]
174
+ if ans_keys:
175
+ from collections import Counter
176
+ return Counter(chosen_list) == Counter(ans_keys)
177
+
178
+ # 레거시 단일 비교
179
+ return str(chosen).strip().upper() == str(q.answer or "").strip().upper()
180
+
181
+
182
+ def auto_ingest_json_if_empty(json_path="data/questions.json", source_name="az104_dump"):
183
+ """
184
+ ✅ 서버 시작 시 자동 DB화:
185
+ - DB에 문제가 0개면 json_path를 자동 ingest
186
+ - DB 삭제 후 재실행해도 자동으로 다시 쌓임
187
+ """
188
+ # JSON 존재 확인
189
+ abs_json = json_path if os.path.isabs(json_path) else os.path.join(BASE_DIR, json_path)
190
+ if not os.path.exists(abs_json):
191
+ print(f"[WARN] JSON not found: {abs_json}")
192
+ return
193
+
194
+ db = SessionLocal()
195
+ try:
196
+ n = db.query(Question).count()
197
+ finally:
198
+ db.close()
199
+
200
+ if n > 0:
201
+ print(f"[INFO] DB already has {n} questions. skip auto ingest.")
202
+ return
203
+
204
+ print(f"[INFO] AUTO INGEST start: {abs_json}")
205
+ inserted = ingest_questions(abs_json, source_name=source_name)
206
+ print(f"[INFO] AUTO INGEST done: inserted={inserted}")
207
+
208
+
209
+ # ✅ 여기서 자동 ingest 실행 (원하는 경로: data/questions.json)
210
+ auto_ingest_json_if_empty("data/questions.json", "az104_dump")
211
+
212
+
213
+ # -----------------------------
214
+ # Pages
215
+ # -----------------------------
216
  @app.route("/")
217
  def home():
218
  return render_template("index.html")
 
238
  return jsonify({"ok": True})
239
 
240
 
241
+ # -----------------------------
242
+ # Admin: upload pdf -> parse -> ingest
243
+ # -----------------------------
244
  @app.route("/admin/upload", methods=["GET", "POST"])
245
  def upload_pdf():
246
  if request.method == "GET":
 
262
  return jsonify({"ok": False, "error": str(e)}), 500
263
 
264
 
265
+ # -----------------------------
266
+ # API: question
267
+ # - ✅ id 없거나/없는 id면 첫 문제 fallback (DB 삭제 후 404 방지)
268
+ # -----------------------------
269
  @app.route("/api/question", methods=["GET"])
270
  def api_question():
271
+ qid = request.args.get("id", type=int)
272
  db = SessionLocal()
273
  try:
274
+ q = None
275
+ if qid:
276
+ q = db.query(Question).filter(Question.id == qid).first()
277
+
278
+ if not q:
279
+ q = first_question(db)
280
+
281
  if not q:
282
  return jsonify({"error": "문제 없음"}), 404
283
 
284
+ return jsonify(api_payload(db, q))
 
 
 
 
 
 
 
 
285
  finally:
286
  db.close()
287
 
288
 
289
+ # -----------------------------
290
+ # API: answer
291
+ # - ✅ chosen list/string 둘 다 허용 + (가능하면) 복수/순서 정답 판정
292
+ # -----------------------------
293
  @app.route("/api/answer", methods=["POST"])
294
  def api_answer():
295
  data = request.get_json(force=True) or {}
 
297
  chosen = data.get("chosen")
298
  user_id = str(data.get("user_id", DEFAULT_USER))
299
 
300
+ if not qid or chosen is None:
301
  return jsonify({"error": "question_id 또는 chosen 누락"}), 400
302
 
303
  db = SessionLocal()
304
  try:
305
  q = db.query(Question).filter(Question.id == int(qid)).first()
306
+ if not q:
307
+ return jsonify({"error": "해당 문제 없음"}), 404
308
+
309
+ correct = is_correct(q, chosen)
310
+
311
+ chosen_store = json.dumps(chosen, ensure_ascii=False) if isinstance(chosen, list) else str(chosen)
312
 
313
  db.add(Attempt(
314
  user_id=user_id,
315
  question_id=q.id,
316
+ chosen=chosen_store,
317
  correct=bool(correct),
318
  note_type="wrong" if not correct else None
319
  ))
 
324
  db.close()
325
 
326
 
327
+ # -----------------------------
328
+ # API: next
329
+ # - ✅ 최소 수정: current_id 없으면 첫 문제
330
+ # - (PDF 순서 next까지 완전 구현은 코드가 길어져서, 여기선 레거시 id-next 유지)
331
+ # -----------------------------
332
  @app.route("/api/next", methods=["GET"])
333
  def api_next():
334
+ current_id = request.args.get("current_id", type=int, default=None)
335
  db = SessionLocal()
336
  try:
337
+ if not current_id:
338
+ q = first_question(db)
339
+ if not q:
340
+ return jsonify({"end": True})
341
+ return jsonify(api_payload(db, q))
342
+
343
  nxt = (
344
  db.query(Question)
345
  .filter(Question.id > current_id)
 
349
  if not nxt:
350
  return jsonify({"end": True})
351
 
352
+ return jsonify(api_payload(db, nxt))
 
 
 
 
 
 
 
 
353
  finally:
354
  db.close()
355
 
356
 
357
+ # -----------------------------
358
+ # Review add/remove
359
+ # -----------------------------
360
  @app.route("/api/review_add", methods=["POST"])
361
  def review_add():
362
  data = request.get_json(force=True) or {}
 
389
  db.close()
390
 
391
 
392
+ # ✅ 오답 + 복습 통합 조회 (기존 그대로 유지)
393
  @app.route("/api/wrong_review", methods=["GET"])
394
  def wrong_review():
395
  user_id = request.args.get("user_id", DEFAULT_USER)
 
423
  db.close()
424
 
425
 
426
+ # ✅ 오답에서 제거 (기존 그대로 유지)
427
  @app.route("/api/wrong_remove", methods=["POST"])
428
  def wrong_remove():
429
  data = request.get_json(force=True) or {}
 
443
 
444
 
445
  if __name__ == "__main__":
446
+ port = int(os.getenv("PORT", 7860))
447
+ print(f"[INFO] 서버 시작: 0.0.0.0:{port} (HF/로컬 공통)")
448
+ app.run(host="0.0.0.0", port=port, debug=False)
db.py CHANGED
@@ -1,5 +1,5 @@
1
  # ==============================================
2
- # db.py (v2025-final)
3
  # ==============================================
4
 
5
  import os
@@ -8,25 +8,28 @@ from sqlalchemy import create_engine, inspect
8
  from sqlalchemy.orm import sessionmaker, declarative_base
9
 
10
  # ----------------------------------------------
11
- # 경로 설정
12
  # ----------------------------------------------
13
- DATA_DIR = Path("data")
 
14
  DATA_DIR.mkdir(parents=True, exist_ok=True)
 
15
  DB_PATH = DATA_DIR / "questions.db"
16
  DATABASE_URL = f"sqlite:///{DB_PATH.as_posix()}"
17
 
18
  # ----------------------------------------------
19
- # SQLAlchemy Engine
20
  # ----------------------------------------------
 
21
  engine = create_engine(
22
  DATABASE_URL,
23
  connect_args={"check_same_thread": False},
24
- echo=False, # 필요하면 .env True/False 변경 가능
25
  future=True
26
  )
27
 
28
  # ----------------------------------------------
29
- # Session
30
  # ----------------------------------------------
31
  SessionLocal = sessionmaker(
32
  autocommit=False,
@@ -35,34 +38,45 @@ SessionLocal = sessionmaker(
35
  )
36
 
37
  # ----------------------------------------------
38
- # Base (ORM 공통)
39
  # ----------------------------------------------
40
  Base = declarative_base()
41
 
42
  # ----------------------------------------------
43
- # DB 초기화
44
  # ----------------------------------------------
45
  def init_db():
46
- from models import Question, Attempt # Base 로 묶인 모델 불러오기
 
 
 
 
47
  Base.metadata.create_all(bind=engine)
48
 
49
- print(f"\n[INFO] ✅ Database Initialized: {DB_PATH}")
50
  print("──────────────────────────────────────────────")
51
 
 
52
  inspector = inspect(engine)
53
- for table in inspector.get_table_names():
54
- print(f"\n📘 Table: {table}")
 
 
 
 
 
55
  for col in inspector.get_columns(table):
56
- print(f" • {col['name']:<18} {str(col['type'])}")
57
-
 
58
  print("──────────────────────────────────────────────\n")
59
 
60
  # ----------------------------------------------
61
- # 요청 단위 DB 세션
62
  # ----------------------------------------------
63
  def get_db():
64
  db = SessionLocal()
65
  try:
66
  yield db
67
  finally:
68
- db.close()
 
1
  # ==============================================
2
+ # db.py
3
  # ==============================================
4
 
5
  import os
 
8
  from sqlalchemy.orm import sessionmaker, declarative_base
9
 
10
  # ----------------------------------------------
11
+ # 1. 경로 및 DB 설정
12
  # ----------------------------------------------
13
+ BASE_DIR = Path(__file__).resolve().parent
14
+ DATA_DIR = BASE_DIR / "data"
15
  DATA_DIR.mkdir(parents=True, exist_ok=True)
16
+
17
  DB_PATH = DATA_DIR / "questions.db"
18
  DATABASE_URL = f"sqlite:///{DB_PATH.as_posix()}"
19
 
20
  # ----------------------------------------------
21
+ # 2. SQLAlchemy Engine 생성
22
  # ----------------------------------------------
23
+ # check_same_thread=False: SQLite를 멀티 스레드 환경(FastAPI 등)에서 쓸 때 필수
24
  engine = create_engine(
25
  DATABASE_URL,
26
  connect_args={"check_same_thread": False},
27
+ echo=False, # SQL 로그가 필요하면 True 변경
28
  future=True
29
  )
30
 
31
  # ----------------------------------------------
32
+ # 3. Session 설정
33
  # ----------------------------------------------
34
  SessionLocal = sessionmaker(
35
  autocommit=False,
 
38
  )
39
 
40
  # ----------------------------------------------
41
+ # 4. Base 모델 (ORM 공통 부모)
42
  # ----------------------------------------------
43
  Base = declarative_base()
44
 
45
  # ----------------------------------------------
46
+ # 5. DB 초기화 함수 (테이블 생성 및 확인)
47
  # ----------------------------------------------
48
  def init_db():
49
+ # 모델들을 여기서 import 해야 Base.metadata에 등록됨
50
+ # (순환 참조 방지를 위해 함수 내부 import 권장)
51
+ from models import Question, Attempt
52
+
53
+ # 테이블 생성 (이미 있으면 무시함)
54
  Base.metadata.create_all(bind=engine)
55
 
56
+ print(f"\n[INFO] ✅ Database Connected: {DB_PATH}")
57
  print("──────────────────────────────────────────────")
58
 
59
+ # 생성된 테이블 구조 확인 (디버깅용)
60
  inspector = inspect(engine)
61
+ table_names = inspector.get_table_names()
62
+
63
+ if not table_names:
64
+ print("⚠️ No tables found. Did you define classes in models.py?")
65
+
66
+ for table in table_names:
67
+ print(f"📘 Table: {table}")
68
  for col in inspector.get_columns(table):
69
+ # 컬럼명, 타입 출력
70
+ print(f" • {col['name']:<15} {str(col['type'])}")
71
+
72
  print("──────────────────────────────────────────────\n")
73
 
74
  # ----------------------------------------------
75
+ # 6. Dependency (FastAPI 등에서 사용)
76
  # ----------------------------------------------
77
  def get_db():
78
  db = SessionLocal()
79
  try:
80
  yield db
81
  finally:
82
+ db.close()
ingest.py CHANGED
@@ -1,119 +1,283 @@
1
  # ==============================================
2
- # ingest.py (v2025-UNIVERSAL)
3
- # OCR JSON + 수동 JSON + Case Study JSON 완전 호환
 
 
 
 
 
 
 
4
  # ==============================================
5
 
6
  import json
7
- from db import SessionLocal, init_db
 
 
 
8
  from models import Question
9
 
10
 
11
- def normalize(item: dict):
 
 
 
12
  """
13
- 서로 다른 JSON 스키마를 하나의 표준 Question 구조로 정규화한다.
 
 
 
 
 
14
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- # 1) 문제(Stem)
17
- stem = (
18
- item.get("stem")
19
- or item.get("question")
20
- or item.get("q_text")
21
- or ""
22
- ).strip()
23
-
24
- # 2) 해설
25
- explanation = item.get("explanation", "").strip()
26
-
27
- # 3) 정답
28
- answer_raw = item.get("answer", "")
29
- answer = (
30
- json.dumps(answer_raw, ensure_ascii=False)
31
- if isinstance(answer_raw, list)
32
- else str(answer_raw).strip()
33
- )
34
-
35
- # 4) 보기 (list → dict 자동 변환)
36
- opts = item.get("options", {})
37
- if isinstance(opts, list): # ["opt1","opt2",...]
38
- options = {chr(65 + i): opt for i, opt in enumerate(opts)}
39
- elif isinstance(opts, dict):
40
- options = opts
41
- else:
42
- options = {}
43
-
44
- # 5) Topic / Subtopic → category/subcategory 매핑
45
- category = item.get("category") or item.get("topic") or None
46
- subcategory = item.get("subcategory") or item.get("subtopic") or None
47
 
48
- # 6) OCR 기반 JSON 호환 필드
49
- page = item.get("page")
 
 
 
 
50
  qtype = item.get("question_type", "MCQ")
51
  code = item.get("code", "")
52
 
53
- # 7) 순서형 / 매칭형 문제도 그대로 pass (DB에서 JSON 형태로 저장)
54
- sequence = (
55
- json.dumps(item.get("sequence"), ensure_ascii=False)
56
- if isinstance(item.get("sequence"), list)
57
- else None
58
- )
59
- pairs = (
60
- json.dumps(item.get("pairs"), ensure_ascii=False)
61
- if isinstance(item.get("pairs"), dict)
62
- else None
63
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  return {
66
  "stem": stem,
67
  "explanation": explanation,
68
- "answer": answer,
69
- "options": options,
70
  "category": category,
71
  "subcategory": subcategory,
72
- "page": page,
73
- "question_type": qtype,
74
  "code": code,
75
- "sequence": sequence,
76
- "pairs": pairs,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  }
78
 
79
 
80
- def ingest_questions(json_path: str, source_name: str = "imported"):
 
 
 
81
  """
82
- 다양한 형태의 JSON 문제 파일을 DB에 넣는 통합 ingest 함수.
 
83
  """
84
- init_db()
85
- db = SessionLocal()
86
- count = 0
87
 
88
- try:
89
- with open(json_path, "r", encoding="utf-8") as f:
90
- data = json.load(f)
91
 
92
- # Case Study 형태: {"questions":[...]} 지원
93
- if isinstance(data, dict) and "questions" in data:
94
- data = data["questions"]
 
95
 
96
- for raw in data:
97
- if not isinstance(raw, dict):
98
- continue
99
 
100
- qn = normalize(raw)
 
101
 
102
  q = Question(
103
  page=qn["page"],
104
  stem=qn["stem"],
105
  explanation=qn["explanation"],
106
- answer=qn["answer"],
107
  question_type=qn["question_type"],
108
  category=qn["category"],
109
  subcategory=qn["subcategory"],
110
  source=source_name,
111
  code=qn["code"],
112
- sequence=qn["sequence"],
113
- pairs=qn["pairs"],
114
  )
115
 
116
- q.set_options(qn["options"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  db.add(q)
118
  count += 1
119
 
@@ -124,14 +288,16 @@ def ingest_questions(json_path: str, source_name: str = "imported"):
124
  except Exception as e:
125
  db.rollback()
126
  print(f"[ERROR] DB 적재 중 오류 발생 → {e}")
127
- raise e
128
 
129
  finally:
130
  db.close()
131
 
132
 
133
  if __name__ == "__main__":
134
- # 네가 실제로 경로에 맞게 수정
135
- # 예: data/json/questions.json 에 있으면
136
- path = "data/json/questions.json"
137
- ingest_questions(path, "az104_dump")
 
 
 
1
  # ==============================================
2
+ # ingest.py (v2025-UNIVERSAL-COMPAT)
3
+ # 현재 db.py/init_db() (인자 없음)와 100% 호환
4
+ # ✅ 기존 questions 테이블 스키마( options_json / answer / pairs / sequence ) 기준으로 안전 동작
5
+ # ✅ 확장 모델( set_answer_keys / set_answer_steps / set_images / page_no 등 )이 있으면 자동 활용
6
+ # ✅ options는 항상 [{"key","text"}] 형태로 저장 → 웹 표기 안정화
7
+ # ✅ Steps/복수/중복 정답:
8
+ # - 확장 모델이면 answer_json/answer_steps_json에 저장
9
+ # - 구버전이면 answer="A,B" / sequence=["E","B","C"] 로 fallback
10
+ # ✅ rebuild_db=True: DB 파일 삭제 후 재생성
11
  # ==============================================
12
 
13
  import json
14
+ import os
15
+ from typing import Any, Dict, List
16
+
17
+ from db import SessionLocal, init_db, DB_PATH
18
  from models import Question
19
 
20
 
21
+ # -----------------------------
22
+ # Helpers
23
+ # -----------------------------
24
+ def _to_list_answer_keys(v: Any) -> List[str]:
25
  """
26
+ 정답 입력을 key 리스트로 정규화.
27
+ - ["A","C"] -> ["A","C"]
28
+ - "A" -> ["A"]
29
+ - "A,C" -> ["A","C"]
30
+ - "BE" -> ["B","E"] (전부 대문자 알파벳일 때만)
31
+ - dict(steps 텍스트) -> [] (steps에서 처리)
32
  """
33
+ if v is None:
34
+ return []
35
+
36
+ if isinstance(v, list):
37
+ return [str(x).strip() for x in v if str(x).strip()]
38
+
39
+ if isinstance(v, dict):
40
+ return []
41
+
42
+ s = str(v).strip()
43
+ if not s:
44
+ return []
45
+
46
+ if "," in s:
47
+ return [x.strip() for x in s.split(",") if x.strip()]
48
+
49
+ if len(s) >= 2 and s.isalpha() and s.upper() == s:
50
+ return list(s)
51
+
52
+ return [s]
53
+
54
+
55
+ def _normalize_options(opts: Any) -> List[Dict[str, str]]:
56
+ """
57
+ options를 항상 표준 리스트 형태로:
58
+ [{"key":"A","text":"..."}, ...]
59
+ 지원:
60
+ - list[str]
61
+ - dict{key:text}
62
+ - list[dict] (이미 key/text)
63
+ """
64
+ if not opts:
65
+ return []
66
+
67
+ # list[dict]
68
+ if isinstance(opts, list) and opts and all(isinstance(x, dict) for x in opts):
69
+ out = []
70
+ for o in opts:
71
+ k = str(o.get("key", "")).strip()
72
+ t = str(o.get("text", "")).strip()
73
+ if k or t:
74
+ out.append({"key": k, "text": t})
75
+ return out
76
+
77
+ # list[str]
78
+ if isinstance(opts, list):
79
+ return [{"key": chr(65 + i), "text": str(opt).strip()} for i, opt in enumerate(opts)]
80
+
81
+ # dict{key:text}
82
+ if isinstance(opts, dict):
83
+ return [{"key": str(k).strip(), "text": str(v).strip()} for k, v in opts.items()]
84
+
85
+ return []
86
+
87
+
88
+ def _infer_steps_answer_keys(item: Dict[str, Any], options_std: List[Dict[str, str]]) -> List[str]:
89
+ """
90
+ Steps 정답을 key 리스트로 뽑는다.
91
+ 우선순위:
92
+ 1) answer_steps(list)
93
+ 2) sequence(list)
94
+ 3) answer가 {"1":"텍스트", "2":"텍스트"} 형태면 options text 매칭으로 key 추정
95
+ """
96
+ if isinstance(item.get("answer_steps"), list):
97
+ return [str(x).strip() for x in item["answer_steps"] if str(x).strip()]
98
+
99
+ if isinstance(item.get("sequence"), list):
100
+ return [str(x).strip() for x in item["sequence"] if str(x).strip()]
101
+
102
+ ans = item.get("answer")
103
+ if isinstance(ans, dict) and all(str(k).isdigit() for k in ans.keys()):
104
+ text_to_key = {}
105
+ for o in options_std:
106
+ t = (o.get("text") or "").strip()
107
+ if t and t not in text_to_key:
108
+ text_to_key[t] = (o.get("key") or "").strip()
109
+
110
+ keys = []
111
+ for i in sorted(int(x) for x in ans.keys()):
112
+ t = str(ans.get(str(i), "")).strip()
113
+ keys.append(text_to_key.get(t, "__UNKNOWN__"))
114
+ return keys
115
+
116
+ return []
117
+
118
+
119
+ def _load_json(json_path: str) -> List[Dict[str, Any]]:
120
+ with open(json_path, "r", encoding="utf-8") as f:
121
+ data = json.load(f)
122
+
123
+ # Case Study: {"questions":[...]}
124
+ if isinstance(data, dict) and "questions" in data:
125
+ data = data["questions"]
126
+
127
+ if not isinstance(data, list):
128
+ raise ValueError("JSON root must be a list (or dict with 'questions').")
129
+
130
+ # dict 아닌 것 제거
131
+ return [x for x in data if isinstance(x, dict)]
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
+ def _normalize_item(item: Dict[str, Any]) -> Dict[str, Any]:
135
+ # stem
136
+ stem = (item.get("stem") or item.get("question") or item.get("q_text") or "")
137
+ stem = str(stem).strip()
138
+
139
+ explanation = str(item.get("explanation") or "").strip()
140
  qtype = item.get("question_type", "MCQ")
141
  code = item.get("code", "")
142
 
143
+ category = item.get("category") or item.get("topic") or None
144
+ subcategory = item.get("subcategory") or item.get("subtopic") or None
145
+
146
+ # options 표준화
147
+ options_std = _normalize_options(item.get("options"))
148
+
149
+ # 정렬키 (있으면)
150
+ source_pages = item.get("source_pages")
151
+ page_no = source_pages[0] if isinstance(source_pages, list) and source_pages else None
152
+ page_legacy = item.get("page")
153
+
154
+ q_no_on_page = item.get("q_no_on_page")
155
+ global_no = item.get("global_no") or item.get("question_id")
156
+
157
+ # 이미지
158
+ images = item.get("images") or item.get("image_urls") or []
159
+ if not isinstance(images, list):
160
+ images = []
161
+
162
+ # steps 정답
163
+ answer_steps = _infer_steps_answer_keys(item, options_std)
164
+
165
+ # 일반 정답 key들
166
+ answer_keys = []
167
+ if not answer_steps:
168
+ if isinstance(item.get("answer_keys"), list):
169
+ answer_keys = [str(x).strip() for x in item["answer_keys"] if str(x).strip()]
170
+ else:
171
+ answer_keys = _to_list_answer_keys(item.get("answer"))
172
 
173
  return {
174
  "stem": stem,
175
  "explanation": explanation,
176
+ "question_type": qtype,
 
177
  "category": category,
178
  "subcategory": subcategory,
 
 
179
  "code": code,
180
+
181
+ "options_std": options_std,
182
+
183
+ "page": page_legacy,
184
+ "page_no": page_no,
185
+ "q_no_on_page": q_no_on_page,
186
+ "global_no": global_no,
187
+
188
+ "answer_keys": answer_keys,
189
+ "answer_steps": answer_steps,
190
+
191
+ # 레거시 유지
192
+ "pairs": item.get("pairs"),
193
+ "sequence": item.get("sequence"),
194
+
195
+ "images": images,
196
+ "raw_answer": item.get("answer"),
197
  }
198
 
199
 
200
+ # -----------------------------
201
+ # Ingest
202
+ # -----------------------------
203
+ def ingest_questions(json_path: str, source_name: str = "imported", rebuild_db: bool = False) -> int:
204
  """
205
+ 현재 db.py/init_db()와 호환되는 통합 ingest
206
+ - rebuild_db=True: DB 파일 삭제 후 init_db()로 새로 생성
207
  """
208
+ json_path = str(json_path)
 
 
209
 
210
+ if rebuild_db and DB_PATH.exists():
211
+ DB_PATH.unlink()
212
+ print(f"[INFO] 🧹 Deleted DB: {DB_PATH}")
213
 
214
+ # 현재 db.py는 인자 없는 init_db()만 지원
215
+ init_db()
216
+
217
+ rows = _load_json(json_path)
218
 
219
+ db = SessionLocal()
220
+ try:
221
+ count = 0
222
 
223
+ for raw in rows:
224
+ qn = _normalize_item(raw)
225
 
226
  q = Question(
227
  page=qn["page"],
228
  stem=qn["stem"],
229
  explanation=qn["explanation"],
 
230
  question_type=qn["question_type"],
231
  category=qn["category"],
232
  subcategory=qn["subcategory"],
233
  source=source_name,
234
  code=qn["code"],
 
 
235
  )
236
 
237
+ # (확장 모델이면) 정렬키 저장
238
+ if hasattr(q, "page_no"):
239
+ q.page_no = qn["page_no"]
240
+ if hasattr(q, "q_no_on_page"):
241
+ q.q_no_on_page = qn["q_no_on_page"]
242
+ if hasattr(q, "global_no"):
243
+ q.global_no = qn["global_no"]
244
+
245
+ # ✅ options는 표준 list[dict]로 저장 (web 표시 안정화)
246
+ q.set_options(qn["options_std"])
247
+
248
+ # ✅ Steps 정답 처리
249
+ if qn["answer_steps"]:
250
+ if hasattr(q, "set_answer_steps"):
251
+ q.set_answer_steps(qn["answer_steps"])
252
+ q.answer = "" # 확장 컬럼 쓰는 경우 레거시 비워도 OK
253
+ else:
254
+ # 구버전 fallback: sequence에 steps key 리스트 저장
255
+ q.sequence = json.dumps(qn["answer_steps"], ensure_ascii=False)
256
+ q.answer = "" # steps는 answer 문자열 비교가 의미 없음
257
+
258
+ else:
259
+ # ✅ 일반 정답(복수/중복 포함)
260
+ if qn["answer_keys"]:
261
+ if hasattr(q, "set_answer_keys"):
262
+ q.set_answer_keys(qn["answer_keys"])
263
+ q.answer = ""
264
+ else:
265
+ # 구버전 fallback: answer="A,B,C" (중복도 그대로)
266
+ q.answer = ",".join(qn["answer_keys"])
267
+ else:
268
+ # 정답이 애매하면 원본 유지
269
+ q.answer = str(qn["raw_answer"] or "").strip()
270
+
271
+ # 레거시 pairs/sequence 유지(있으면)
272
+ if qn["sequence"] is not None:
273
+ q.sequence = json.dumps(qn["sequence"], ensure_ascii=False) if isinstance(qn["sequence"], list) else qn["sequence"]
274
+ if qn["pairs"] is not None:
275
+ q.pairs = json.dumps(qn["pairs"], ensure_ascii=False) if isinstance(qn["pairs"], (dict, list)) else qn["pairs"]
276
+
277
+ # ✅ images 저장(확장 모델이면)
278
+ if hasattr(q, "set_images"):
279
+ q.set_images(qn["images"])
280
+
281
  db.add(q)
282
  count += 1
283
 
 
288
  except Exception as e:
289
  db.rollback()
290
  print(f"[ERROR] DB 적재 중 오류 발생 → {e}")
291
+ raise
292
 
293
  finally:
294
  db.close()
295
 
296
 
297
  if __name__ == "__main__":
298
+ # 너가 말한 실제 경로: data/questions.json
299
+ path = os.getenv("QUESTIONS_JSON", "data/questions.json")
300
+ source = os.getenv("SOURCE_NAME", "az104_dump")
301
+ rebuild = os.getenv("REBUILD_DB", "0") == "1"
302
+
303
+ ingest_questions(path, source_name=source, rebuild_db=rebuild)
models.py CHANGED
@@ -6,50 +6,80 @@ from sqlalchemy import Column, Integer, String, Text, Boolean, ForeignKey
6
  from sqlalchemy.orm import relationship
7
  import json
8
 
9
- from db import Base # ✅ Base 는 db.py Base 사용
 
10
 
11
  # ----------------------------------------------
12
- # Question
13
  # ----------------------------------------------
14
  class Question(Base):
15
  __tablename__ = "questions"
16
 
17
  id = Column(Integer, primary_key=True, autoincrement=True)
18
 
 
19
  stem = Column(Text, nullable=False)
 
 
20
  answer = Column(String(255))
 
 
21
  explanation = Column(Text)
 
 
22
  question_type = Column(String(50), default="MCQ")
23
 
 
24
  page = Column(Integer)
25
  category = Column(String(100))
26
  subcategory = Column(String(100))
27
  code = Column(Text)
28
  source = Column(String(255))
29
 
30
- # JSON 문자열 저장
31
- options_json = Column(Text)
32
- pairs = Column(Text)
33
- sequence = Column(Text)
 
 
 
 
 
 
 
 
34
 
35
- # 보기 저장
36
  def set_options(self, options):
 
 
 
 
37
  try:
38
- self.options_json = (
39
- json.dumps(options, ensure_ascii=False)
40
- if isinstance(options, (list, dict))
41
- else options
42
- )
43
- except Exception:
 
 
 
 
44
  self.options_json = "[]"
45
 
46
- # 보기 가져오기
47
  def get_options(self):
 
 
 
 
48
  try:
49
- return json.loads(self.options_json) if self.options_json else []
50
- except:
 
 
51
  return []
52
 
 
53
  def get_pairs(self):
54
  try:
55
  return json.loads(self.pairs) if self.pairs else {}
@@ -63,22 +93,34 @@ class Question(Base):
63
  return []
64
 
65
  def __repr__(self):
66
- return f"<Question id={self.id} type={self.question_type}>"
 
67
 
68
  # ----------------------------------------------
69
- # Attempt
70
  # ----------------------------------------------
71
  class Attempt(Base):
72
  __tablename__ = "attempts"
73
 
74
  id = Column(Integer, primary_key=True, autoincrement=True)
 
 
75
  user_id = Column(String(100), nullable=False, default="guest")
 
 
76
  question_id = Column(Integer, ForeignKey("questions.id"))
 
 
77
  chosen = Column(String(10))
 
 
78
  correct = Column(Boolean, default=False)
 
 
79
  note_type = Column(String(20), default="wrong")
80
 
 
81
  question = relationship("Question", backref="attempts")
82
 
83
  def __repr__(self):
84
- return f"<Attempt q={self.question_id}, user={self.user_id}, correct={self.correct}>"
 
6
  from sqlalchemy.orm import relationship
7
  import json
8
 
9
+ # db.py에서 생성한 Base 객체를 가져옵니다.
10
+ from db import Base
11
 
12
  # ----------------------------------------------
13
+ # Question 모델
14
  # ----------------------------------------------
15
  class Question(Base):
16
  __tablename__ = "questions"
17
 
18
  id = Column(Integer, primary_key=True, autoincrement=True)
19
 
20
+ # 문제 질문 (기존 이름 유지)
21
  stem = Column(Text, nullable=False)
22
+
23
+ # 정답 (단순 리스트로 변경되어도 "A", "B" 또는 "1", "2" 같은 인덱스 키 저장)
24
  answer = Column(String(255))
25
+
26
+ # 해설
27
  explanation = Column(Text)
28
+
29
+ # 문제 유형 (MCQ: 객관식)
30
  question_type = Column(String(50), default="MCQ")
31
 
32
+ # 메타 데이터
33
  page = Column(Integer)
34
  category = Column(String(100))
35
  subcategory = Column(String(100))
36
  code = Column(Text)
37
  source = Column(String(255))
38
 
39
+ # -------------------------------------------------------
40
+ # [데이터 구조 변경 핵심]
41
+ # 기존: [{"key": "A", "text": "내용"}]
42
+ # 변경: ["내용1", "내용2", "내용3"] (단순 문자열 리스트)
43
+ # -------------------------------------------------------
44
+ options_json = Column(Text, default="[]")
45
+ pairs = Column(Text, default="{}")
46
+ sequence = Column(Text, default="[]")
47
+
48
+ # -------------------------------------------------------
49
+ # Helper Methods (JSON 직렬화/역직렬화)
50
+ # -------------------------------------------------------
51
 
 
52
  def set_options(self, options):
53
+ """
54
+ 리스트 데이터를 받아 JSON 문자열로 변환하여 저장합니다.
55
+ Input 예시: ["EC2", "S3", "Lambda"]
56
+ """
57
  try:
58
+ if options is None:
59
+ self.options_json = "[]"
60
+ elif isinstance(options, (list, dict)):
61
+ # ensure_ascii=False: 한글 깨짐 방지
62
+ self.options_json = json.dumps(options, ensure_ascii=False)
63
+ else:
64
+ # 문자열로 들어오면 그대로 저장 시도 혹은 리스트로 감싸기
65
+ self.options_json = json.dumps([str(options)], ensure_ascii=False)
66
+ except Exception as e:
67
+ print(f"Error setting options: {e}")
68
  self.options_json = "[]"
69
 
 
70
  def get_options(self):
71
+ """
72
+ 저장된 JSON 문자열을 파이썬 리스트로 반환합니다.
73
+ Return 예시: ["EC2", "S3", "Lambda"]
74
+ """
75
  try:
76
+ if not self.options_json:
77
+ return []
78
+ return json.loads(self.options_json)
79
+ except Exception:
80
  return []
81
 
82
+ # (추가) pairs, sequence 처리도 동일한 방식으로 안정성 확보
83
  def get_pairs(self):
84
  try:
85
  return json.loads(self.pairs) if self.pairs else {}
 
93
  return []
94
 
95
  def __repr__(self):
96
+ return f"<Question id={self.id} type={self.question_type} stem={self.stem[:20]}...>"
97
+
98
 
99
  # ----------------------------------------------
100
+ # Attempt 모델 (사용자 풀이 기록)
101
  # ----------------------------------------------
102
  class Attempt(Base):
103
  __tablename__ = "attempts"
104
 
105
  id = Column(Integer, primary_key=True, autoincrement=True)
106
+
107
+ # 사용자 ID (로그인 기능이 없다면 guest 혹은 브라우저 지문 등 사용)
108
  user_id = Column(String(100), nullable=False, default="guest")
109
+
110
+ # 문제 ID (FK)
111
  question_id = Column(Integer, ForeignKey("questions.id"))
112
+
113
+ # 사용자가 선택한 답 (프론트엔드에서 생성한 Key: "A", "B", "1" 등)
114
  chosen = Column(String(10))
115
+
116
+ # 정답 여부
117
  correct = Column(Boolean, default=False)
118
+
119
+ # 오답 노트 유형 (wrong: 틀림, bookmark: 중요 표시 등)
120
  note_type = Column(String(20), default="wrong")
121
 
122
+ # 관계 설정 (Attempt.question 으로 문제 정보 접근 가능)
123
  question = relationship("Question", backref="attempts")
124
 
125
  def __repr__(self):
126
+ return f"<Attempt q={self.question_id}, user={self.user_id}, correct={self.correct}>"
templates/quiz.html CHANGED
@@ -14,6 +14,7 @@
14
  --bad: #d12929;
15
  --card: #fff;
16
  --muted: #6b7280;
 
17
  }
18
  body {
19
  margin: 0;
@@ -22,20 +23,23 @@
22
  padding: 24px;
23
  display: flex;
24
  justify-content: center;
 
25
  }
26
  .wrap { width: 100%; max-width: 900px; }
 
 
27
  .topbar {
28
  display: flex; align-items: center; justify-content: space-between;
29
  margin-bottom: 16px; gap: 12px; flex-wrap: wrap;
30
  }
31
- .progress { color: var(--muted); font-size: 14px; }
32
- .jump-box {
33
- display: flex; align-items: center; gap: 8px;
34
- }
35
  .jump-box input {
36
- width: 70px; padding: 6px 10px; border: 1px solid #e5e7eb;
37
  border-radius: 8px; font-size: 14px; text-align: center;
 
38
  }
 
39
  .jump-box button {
40
  padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 8px;
41
  background: #fff; cursor: pointer; font-size: 13px;
@@ -45,388 +49,653 @@
45
  background: var(--brand); color: #fff; border-color: var(--brand);
46
  }
47
  .pill {
48
- font-size: 12px; padding: 6px 10px; border-radius: 999px;
49
- background: #f3f4f6; color: #374151;
50
  }
 
 
51
  .card {
52
- background: var(--card); border-radius: 14px; padding: 22px;
53
- box-shadow: 0 8px 24px rgba(0,0,0,.08);
54
- min-height: 260px;
 
 
 
 
 
 
 
55
  }
56
- .qid { font-size: 13px; color: var(--muted); margin-bottom: 6px; }
57
- .qtext { font-size: 18px; line-height: 1.5; margin: 0 0 14px; }
58
- .options { display: grid; gap: 10px; margin-top: 14px; }
 
 
 
59
  .opt {
60
- display: flex; align-items: center; gap: 10px; padding: 12px 14px;
61
- border: 1px solid #e5e7eb; border-radius: 10px; cursor: pointer; background: #fff;
62
- transition: background .15s, border-color .15s;
63
- }
64
- .opt:hover { background: #f0f6ff; border-color: #d1e3ff; }
65
- .opt.selected { background: #f0f6ff; border-color: var(--brand); }
66
- .opt.correct { border-color: #b8e3c1; background: #effaf2; }
67
- .opt.wrong { border-color: #f1b7b7; background: #fff1f1; }
68
- .opt.disabled { cursor: not-allowed; opacity: 0.7; }
69
- .opt input { accent-color: var(--brand); }
 
 
 
 
 
 
 
 
70
  .exp {
71
- display: none; margin-top: 14px; padding: 12px 14px; border-left: 5px solid var(--brand);
72
- background: #eef6ff; border-radius: 8px;
 
 
73
  }
74
- .exp.show { display: block; }
75
- .exp .title { margin: 0 0 6px; font-weight: 700; }
76
  .exp .ok { color: var(--ok); }
77
  .exp .bad { color: var(--bad); }
 
 
78
  .nav {
79
- margin-top: 18px; display: flex; align-items: center; gap: 14px;
80
- background: #fff; border-radius: 14px; box-shadow: 0 8px 24px rgba(0,0,0,.08);
81
- padding: 16px 18px;
82
- }
83
- .nav-group {
84
- display: flex; gap: 8px;
85
  }
 
86
  .spacer { flex: 1; }
 
87
  button.btn {
88
- border: 0; padding: 10px 18px; border-radius: 10px; color: #fff; background: var(--brand);
89
- cursor: pointer; font-weight: 600;
90
- transition: background .15s;
91
  }
92
- button.btn:hover { background: var(--brand-dark); }
 
93
  button.outline {
94
- background: #fff; color: #111827; border: 1px solid #e5e7eb;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  }
96
- button.bookmark { background: var(--warn); color: #1f2937; }
97
- button[disabled] { opacity: .5; cursor: not-allowed; }
98
- .loading {
99
- text-align: center;
100
- padding: 40px;
 
101
  color: var(--muted);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  }
103
  </style>
104
  </head>
105
  <body>
106
  <div class="wrap">
107
  <div class="topbar">
108
- <div class="pill">CBT • 단일문제 모드</div>
 
 
109
  <div class="jump-box">
110
- <span style="font-size: 13px; color: var(--muted);">문제 이동:</span>
111
- <input type="number" id="jumpInput" placeholder="번호" min="1" />
112
  <button id="jumpBtn">이동</button>
113
  </div>
114
- <div class="progress" id="progress">문제 0 / 0</div>
115
  </div>
116
 
117
  <div class="card">
118
- <div id="loading" class="loading">문제를 불러오는 중...</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  <div id="qContainer" style="display: none;">
120
  <div class="qid" id="qid"></div>
121
  <h2 class="qtext" id="qtext"></h2>
 
 
 
 
122
  <div class="options" id="options"></div>
123
  <div class="exp" id="exp"></div>
124
  </div>
125
  </div>
126
 
127
  <div class="nav">
128
- <button class="btn outline" id="homeBtn">🏠 홈</button>
129
  <div class="nav-group">
130
  <button class="btn outline" id="prevBtn">← 이전</button>
131
- <button class="btn outline" id="skipBtn">다음 →</button>
132
  </div>
133
  <div class="spacer"></div>
134
- <button class="btn" id="submitBtn" disabled>정답 확인</button>
135
- <button class="btn" id="nextBtn" style="display: none;">정답 다음 →</button>
136
- <button class="btn bookmark" id="bmBtn">⭐ 복습</button>
 
 
137
  </div>
138
  </div>
139
 
 
 
140
  <script>
 
141
  let currentQuestion = null;
142
- let selectedOption = null;
143
  let answered = false;
144
 
145
- const qidEl = document.getElementById("qid");
146
- const qtextEl = document.getElementById("qtext");
147
- const optsEl = document.getElementById("options");
148
- const expEl = document.getElementById("exp");
149
- const progressEl = document.getElementById("progress");
150
- const submitBtn = document.getElementById("submitBtn");
151
- const nextBtn = document.getElementById("nextBtn");
152
- const skipBtn = document.getElementById("skipBtn");
153
- const prevBtn = document.getElementById("prevBtn");
154
- const homeBtn = document.getElementById("homeBtn");
155
- const bmBtn = document.getElementById("bmBtn");
156
- const jumpInput = document.getElementById("jumpInput");
157
- const jumpBtn = document.getElementById("jumpBtn");
158
- const loadingEl = document.getElementById("loading");
159
- const qContainer = document.getElementById("qContainer");
160
-
161
- // localStorage에서 마지막 문제 번호 가져오기
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  function getLastQuestionId() {
163
  return parseInt(localStorage.getItem("lastQuestionId")) || null;
164
  }
165
 
166
- // localStorage에 현재 문제 번호 저장
167
  function saveLastQuestionId(id) {
168
  localStorage.setItem("lastQuestionId", id);
169
  }
170
 
171
- // 문제 불러오기
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  async function loadQuestion(questionId = null) {
173
- loadingEl.style.display = "block";
174
- qContainer.style.display = "none";
175
-
176
  try {
177
- const url = questionId ? `/api/question?id=${questionId}` : '/api/question';
178
  const res = await fetch(url);
 
 
179
  const data = await res.json();
180
-
181
  if (data.error) {
182
- loadingEl.textContent = "❌ " + data.error;
183
  return;
184
  }
185
-
186
  currentQuestion = data;
187
  render(data);
188
- saveLastQuestionId(data.id); // 문제 번호 저장
189
-
190
- loadingEl.style.display = "none";
191
- qContainer.style.display = "block";
192
  } catch (err) {
193
- loadingEl.textContent = " 문제를 불러오지 못했습니다.";
194
  console.error(err);
195
  }
196
  }
197
 
198
- // 문제 렌더링
199
  function render(q) {
200
- progressEl.textContent = `문제 ${q.id} / ${q.total}`;
201
- qidEl.textContent = `ID ${q.id}`;
202
- qtextEl.textContent = q.question;
203
- optsEl.innerHTML = "";
204
- expEl.classList.remove("show");
205
- expEl.innerHTML = "";
206
-
207
- // 입력창 최대값 설정
208
- jumpInput.max = q.total;
209
- jumpInput.placeholder = `1-${q.total}`;
210
-
211
- // 초기화
212
- selectedOption = null;
213
- answered = false;
214
- submitBtn.disabled = true;
215
- submitBtn.style.display = "block";
216
- nextBtn.style.display = "none";
217
- skipBtn.style.display = "block";
 
 
 
 
 
 
 
218
 
219
- (q.options || []).forEach(opt => {
220
  const label = document.createElement("label");
221
  label.className = "opt";
222
-
 
 
223
  const input = document.createElement("input");
224
- input.type = "radio";
225
  input.name = `q${q.id}`;
226
- input.value = opt;
227
- input.addEventListener("change", () => onSelect(opt, label));
228
-
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  label.appendChild(input);
230
- label.append(opt);
231
- optsEl.appendChild(label);
 
 
 
 
 
 
 
 
232
  });
233
  }
234
 
235
- // 선택
236
- function onSelect(chosen, labelEl) {
237
  if (answered) return;
238
-
239
- // 모든 선택 해제
240
- document.querySelectorAll('.opt').forEach(opt => {
241
- opt.classList.remove("selected");
242
- });
243
-
244
- // 새 선택
245
- labelEl.classList.add("selected");
246
- selectedOption = chosen.charAt(0); // "A. Berlin" -> "A"
247
- submitBtn.disabled = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  }
249
 
250
- // 정답 확인
251
- submitBtn.addEventListener("click", async () => {
252
- if (!selectedOption || answered) return;
253
-
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  try {
255
  const res = await fetch("/api/answer", {
256
  method: "POST",
257
  headers: { "Content-Type": "application/json" },
258
  body: JSON.stringify({
259
  question_id: currentQuestion.id,
260
- chosen: selectedOption,
261
  user_id: "default"
262
  })
263
  });
264
-
265
  const result = await res.json();
266
  answered = true;
267
-
268
- // 정답/오답 표시
269
- document.querySelectorAll('.opt').forEach(opt => {
270
- opt.classList.add("disabled");
271
- const optLetter = opt.textContent.charAt(0);
272
- if (optLetter === result.answer) {
273
- opt.classList.add("correct");
274
- } else if (optLetter === selectedOption && !result.correct) {
275
- opt.classList.add("wrong");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  }
277
  });
278
-
279
- // 해설 표시
280
- showExplanation(result);
281
-
282
- submitBtn.style.display = "none";
283
- nextBtn.style.display = "block";
284
- skipBtn.style.display = "none";
 
285
  } catch (err) {
286
  console.error(err);
287
- alert("채점 중 오류가 발생했습니다.");
 
288
  }
289
  });
290
 
291
- // 해설 표시
292
- function showExplanation(result) {
293
- expEl.classList.add("show");
294
- const isCorrect = result.correct;
295
- expEl.innerHTML = `
 
 
 
 
 
 
 
296
  <div class="title ${isCorrect ? 'ok':'bad'}">
297
- ${isCorrect ? "✅ 정답입니다!" : "❌ 오답입니다. 정답: " + result.answer}
298
  </div>
299
- <div>${result.explanation || "해설이 없습니다."}</div>
 
300
  `;
301
  }
302
 
303
- // 다음 문제
304
- nextBtn.addEventListener("click", async () => {
305
- try {
306
- const res = await fetch(`/api/next?current_id=${currentQuestion.id}`);
307
- const data = await res.json();
308
-
309
- if (data.end) {
310
- alert("마지막 문제입니다!");
311
- return;
312
- }
313
-
314
- currentQuestion = data;
315
- render(data);
316
- saveLastQuestionId(data.id); // 문제 번호 저장
317
- } catch (err) {
318
- console.error(err);
319
- alert("다음 문제를 불러오는데 실패했습니다.");
320
- }
321
- });
322
 
323
- // 이전 문제
324
- prevBtn.addEventListener("click", async () => {
325
- if (!currentQuestion || currentQuestion.id <= 1) {
326
- alert("첫 번째 문제입니다!");
327
  return;
328
  }
329
-
330
- try {
331
- const prevId = currentQuestion.id - 1;
332
- await loadQuestion(prevId);
333
- } catch (err) {
334
- console.error(err);
335
- alert("이전 문제를 불러오는데 실패했습니다.");
336
- }
337
- });
338
 
339
- // 홈으로 이동
340
- homeBtn.addEventListener("click", () => {
341
- window.location.href = "/";
342
- });
343
-
344
- // 건너뛰기 (답 확인 없이 다음 문제)
345
- skipBtn.addEventListener("click", async () => {
346
  try {
347
  const res = await fetch(`/api/next?current_id=${currentQuestion.id}`);
348
  const data = await res.json();
349
-
350
- if (data.end) {
351
- alert("마지막 문제입니다!");
352
- return;
353
- }
354
-
355
  currentQuestion = data;
356
  render(data);
357
  saveLastQuestionId(data.id);
358
  } catch (err) {
359
- console.error(err);
360
- alert("다음 문제를 불러오는데 실패했습니다.");
361
- }
362
- });
363
-
364
- // 문제 번호로 이동
365
- jumpBtn.addEventListener("click", async () => {
366
- const targetId = parseInt(jumpInput.value);
367
- if (!targetId || targetId < 1) {
368
- alert("올바른 문제 번호를 입력하세요.");
369
- return;
370
- }
371
-
372
- if (currentQuestion && targetId > currentQuestion.total) {
373
- alert(`문제 번호는 1부터 ${currentQuestion.total}까지입니다.`);
374
- return;
375
  }
376
-
377
- await loadQuestion(targetId);
378
- jumpInput.value = "";
379
- });
380
 
381
- // Enter 키로 문제 이동
382
- jumpInput.addEventListener("keypress", (e) => {
383
- if (e.key === "Enter") {
384
- jumpBtn.click();
385
- }
386
- });
387
 
388
- // 복습 추가
389
- bmBtn.addEventListener("click", async () => {
390
  if (!currentQuestion) return;
391
-
392
  try {
393
  const res = await fetch("/api/review_add", {
394
  method: "POST",
395
  headers: { "Content-Type": "application/json" },
396
- body: JSON.stringify({
397
- question_id: currentQuestion.id,
398
- user_id: "default"
399
- })
400
  });
401
-
402
  const data = await res.json();
403
- alert(data.message);
404
  } catch (err) {
405
- console.error(err);
406
- alert("복습노트 추가 중 오류가 발생했습니다.");
407
  }
408
  });
409
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  // 키보드 단축키
411
  window.addEventListener("keydown", e => {
412
- if (e.key === "Enter" && !submitBtn.disabled && submitBtn.style.display !== "none") {
413
- submitBtn.click();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  }
415
- if (e.key === "ArrowRight" && nextBtn.style.display !== "none") {
416
- nextBtn.click();
 
 
417
  }
418
  if (e.key === "ArrowLeft") {
419
- prevBtn.click();
420
  }
421
  });
422
 
423
- // 페이지 로드시 마지막 문제 또는 첫 문제
424
  const lastId = getLastQuestionId();
425
- if (lastId) {
426
- loadQuestion(lastId);
427
- } else {
428
- loadQuestion();
429
- }
430
  </script>
431
  </body>
432
- </html>
 
14
  --bad: #d12929;
15
  --card: #fff;
16
  --muted: #6b7280;
17
+ --text: #1f2937;
18
  }
19
  body {
20
  margin: 0;
 
23
  padding: 24px;
24
  display: flex;
25
  justify-content: center;
26
+ color: var(--text);
27
  }
28
  .wrap { width: 100%; max-width: 900px; }
29
+
30
+ /* Topbar */
31
  .topbar {
32
  display: flex; align-items: center; justify-content: space-between;
33
  margin-bottom: 16px; gap: 12px; flex-wrap: wrap;
34
  }
35
+ .progress { color: var(--muted); font-size: 14px; font-weight: 500; }
36
+ .jump-box { display: flex; align-items: center; gap: 8px; }
 
 
37
  .jump-box input {
38
+ width: 60px; padding: 6px 10px; border: 1px solid #e5e7eb;
39
  border-radius: 8px; font-size: 14px; text-align: center;
40
+ outline: none;
41
  }
42
+ .jump-box input:focus { border-color: var(--brand); }
43
  .jump-box button {
44
  padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 8px;
45
  background: #fff; cursor: pointer; font-size: 13px;
 
49
  background: var(--brand); color: #fff; border-color: var(--brand);
50
  }
51
  .pill {
52
+ font-size: 12px; padding: 6px 12px; border-radius: 999px;
53
+ background: #e0e7ff; color: var(--brand-dark); font-weight: 600;
54
  }
55
+
56
+ /* Card */
57
  .card {
58
+ background: var(--card); border-radius: 16px; padding: 28px;
59
+ box-shadow: 0 10px 30px rgba(0,0,0,.06);
60
+ min-height: 300px; position: relative;
61
+ }
62
+
63
+ /* Fade Animation */
64
+ .fade-in { animation: fadeIn 0.3s ease-out; }
65
+ @keyframes fadeIn {
66
+ from { opacity: 0; transform: translateY(5px); }
67
+ to { opacity: 1; transform: translateY(0); }
68
  }
69
+
70
+ .qid { font-size: 13px; color: var(--muted); margin-bottom: 8px; font-weight: 600; }
71
+ .qtext { font-size: 19px; line-height: 1.55; margin: 0 0 20px; font-weight: 700; word-break: keep-all; }
72
+
73
+ /* Options */
74
+ .options { display: grid; gap: 12px; margin-top: 16px; }
75
  .opt {
76
+ display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px;
77
+ border: 2px solid #e5e7eb; border-radius: 12px; cursor: pointer; background: #fff;
78
+ transition: all .2s; position: relative;
79
+ }
80
+ .opt:hover:not(.disabled) { background: #f8fafc; border-color: #cbd5e1; }
81
+ .opt.selected { background: #eff6ff; border-color: var(--brand); box-shadow: 0 0 0 1px var(--brand); }
82
+ .opt.correct { border-color: var(--ok); background: #f0fdf4; }
83
+ .opt.wrong { border-color: var(--bad); background: #fef2f2; }
84
+ .opt.disabled { cursor: default; opacity: 0.8; pointer-events: none; }
85
+
86
+ /* Input Custom Style */
87
+ .opt input { margin-top: 4px; accent-color: var(--brand); cursor: pointer; }
88
+ .opt-key {
89
+ font-size: 12px; font-weight: 700; color: var(--muted);
90
+ min-width: 20px; margin-top: 3px;
91
+ }
92
+
93
+ /* Explanation */
94
  .exp {
95
+ display: none; margin-top: 20px; padding: 16px;
96
+ border-left: 5px solid var(--brand);
97
+ background: #f0f9ff; border-radius: 8px;
98
+ line-height: 1.6;
99
  }
100
+ .exp.show { display: block; animation: fadeIn 0.3s; }
101
+ .exp .title { margin: 0 0 8px; font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 6px; }
102
  .exp .ok { color: var(--ok); }
103
  .exp .bad { color: var(--bad); }
104
+
105
+ /* Navigation */
106
  .nav {
107
+ margin-top: 20px; display: flex; align-items: center; gap: 12px;
108
+ background: #fff; border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,.06);
109
+ padding: 16px 20px; flex-wrap: wrap;
 
 
 
110
  }
111
+ .nav-group { display: flex; gap: 8px; }
112
  .spacer { flex: 1; }
113
+
114
  button.btn {
115
+ border: 0; padding: 12px 20px; border-radius: 10px; color: #fff; background: var(--brand);
116
+ cursor: pointer; font-weight: 600; font-size: 15px;
117
+ transition: background .15s, transform .05s;
118
  }
119
+ button.btn:active { transform: scale(0.98); }
120
+ button.btn:hover:not([disabled]) { background: var(--brand-dark); }
121
  button.outline {
122
+ background: #fff; color: #374151; border: 1px solid #d1d5db;
123
+ }
124
+ button.outline:hover { background: #f9fafb; border-color: #9ca3af; }
125
+ button.bookmark { background: #fffbeb; color: #92400e; border: 1px solid #fcd34d; }
126
+ button.bookmark:hover { background: #fef3c7; }
127
+ button[disabled] { opacity: .5; cursor: not-allowed; background: #9ca3af; }
128
+
129
+ .loading { text-align: center; padding: 60px 0; color: var(--muted); font-size: 15px; }
130
+
131
+ /* Toast Notification */
132
+ #toast-container {
133
+ position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
134
+ z-index: 1000; display: flex; flex-direction: column; gap: 10px; pointer-events: none;
135
+ }
136
+ .toast {
137
+ background: #1f2937; color: #fff; padding: 12px 24px; border-radius: 50px;
138
+ font-size: 14px; opacity: 0; transform: translateY(20px);
139
+ transition: all 0.3s; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
140
  }
141
+ .toast.show { opacity: 1; transform: translateY(0); }
142
+
143
+ /* Multi-answer hint */
144
+ .multi-hint {
145
+ margin-top: 10px;
146
+ font-size: 13px;
147
  color: var(--muted);
148
+ display: none;
149
+ }
150
+ .multi-hint.show { display: block; }
151
+
152
+ /* Mobile Responsive */
153
+ @media (max-width: 640px) {
154
+ body { padding: 12px; padding-bottom: 80px; }
155
+ .topbar { flex-direction: column; align-items: stretch; gap: 10px; }
156
+ .jump-box { justify-content: space-between; }
157
+ .jump-box input { width: 100%; flex:1; }
158
+ .card { padding: 20px; }
159
+ .qtext { font-size: 17px; }
160
+ .nav {
161
+ flex-direction: column; gap: 10px; padding: 12px 16px;
162
+ position: fixed; bottom: 0; left: 0; right: 0;
163
+ border-radius: 20px 20px 0 0; box-shadow: 0 -4px 20px rgba(0,0,0,0.08);
164
+ z-index: 50;
165
+ }
166
+ .nav-group { width: 100%; display: grid; grid-template-columns: 1fr 1fr; }
167
+ button.btn { width: 100%; padding: 12px; }
168
+ .spacer { display: none; }
169
+ #homeBtn, #bmBtn { display: none; }
170
  }
171
  </style>
172
  </head>
173
  <body>
174
  <div class="wrap">
175
  <div class="topbar">
176
+ <div class="pill">AZ-104 CBT 연습</div>
177
+ <div class="spacer" style="flex:1"></div>
178
+ <div class="progress" id="progress">문제 - / -</div>
179
  <div class="jump-box">
180
+ <input type="number" id="jumpInput" placeholder="No." min="1" />
 
181
  <button id="jumpBtn">이동</button>
182
  </div>
 
183
  </div>
184
 
185
  <div class="card">
186
+ <div id="loading" class="loading">
187
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="animation: spin 1s linear infinite; margin-bottom: 10px; display:block; margin: 0 auto 10px;">
188
+ <path d="M12 2V6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
189
+ <path d="M12 18V22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
190
+ <path d="M4.93 4.93L7.76 7.76" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
191
+ <path d="M16.24 16.24L19.07 19.07" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
192
+ <path d="M2 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
193
+ <path d="M18 12H22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
194
+ <path d="M4.93 19.07L7.76 16.24" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
195
+ <path d="M16.24 7.76L19.07 4.93" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
196
+ </svg>
197
+ <style>@keyframes spin { 100% { transform: rotate(360deg); } }</style>
198
+ 문제를 불러오는 중...
199
+ </div>
200
+
201
  <div id="qContainer" style="display: none;">
202
  <div class="qid" id="qid"></div>
203
  <h2 class="qtext" id="qtext"></h2>
204
+
205
+ <!-- ✅ 멀티/스텝 힌트 -->
206
+ <div id="multiHint" class="multi-hint"></div>
207
+
208
  <div class="options" id="options"></div>
209
  <div class="exp" id="exp"></div>
210
  </div>
211
  </div>
212
 
213
  <div class="nav">
214
+ <button class="btn outline" id="homeBtn" title="홈으로">🏠</button>
215
  <div class="nav-group">
216
  <button class="btn outline" id="prevBtn">← 이전</button>
217
+ <button class="btn outline" id="skipBtn">스킵 →</button>
218
  </div>
219
  <div class="spacer"></div>
220
+
221
+ <button class="btn" id="submitBtn" disabled>정답 확인 (Enter)</button>
222
+ <button class="btn" id="nextBtn" style="display: none;">다음 문제 →</button>
223
+
224
+ <button class="btn bookmark" id="bmBtn">⭐ 복습 저장</button>
225
  </div>
226
  </div>
227
 
228
+ <div id="toast-container"></div>
229
+
230
  <script>
231
+ /* --- Global State --- */
232
  let currentQuestion = null;
 
233
  let answered = false;
234
 
235
+ // 단일/복수/스텝에 따라 UI/채점 모드
236
+ // mode: "single" | "multi" | "steps"
237
+ let answerMode = "single";
238
+
239
+ // 선택값
240
+ // single: string("A")
241
+ // multi/steps: array(["A","C"]) / array(["E","B","C"])
242
+ let selected = null;
243
+
244
+ /* --- Elements --- */
245
+ const els = {
246
+ qid: document.getElementById("qid"),
247
+ qtext: document.getElementById("qtext"),
248
+ opts: document.getElementById("options"),
249
+ exp: document.getElementById("exp"),
250
+ progress: document.getElementById("progress"),
251
+ submitBtn: document.getElementById("submitBtn"),
252
+ nextBtn: document.getElementById("nextBtn"),
253
+ skipBtn: document.getElementById("skipBtn"),
254
+ prevBtn: document.getElementById("prevBtn"),
255
+ homeBtn: document.getElementById("homeBtn"),
256
+ bmBtn: document.getElementById("bmBtn"),
257
+ jumpInput: document.getElementById("jumpInput"),
258
+ jumpBtn: document.getElementById("jumpBtn"),
259
+ loading: document.getElementById("loading"),
260
+ qContainer: document.getElementById("qContainer"),
261
+ toastContainer: document.getElementById("toast-container"),
262
+ multiHint: document.getElementById("multiHint"),
263
+ };
264
+
265
+ /* --- Utils --- */
266
+ function showToast(message) {
267
+ const toast = document.createElement("div");
268
+ toast.className = "toast";
269
+ toast.textContent = message;
270
+ els.toastContainer.appendChild(toast);
271
+
272
+ void toast.offsetWidth;
273
+ toast.classList.add("show");
274
+
275
+ setTimeout(() => {
276
+ toast.classList.remove("show");
277
+ setTimeout(() => toast.remove(), 300);
278
+ }, 2500);
279
+ }
280
+
281
  function getLastQuestionId() {
282
  return parseInt(localStorage.getItem("lastQuestionId")) || null;
283
  }
284
 
 
285
  function saveLastQuestionId(id) {
286
  localStorage.setItem("lastQuestionId", id);
287
  }
288
 
289
+ function setLoading(isLoading) {
290
+ els.loading.style.display = isLoading ? "block" : "none";
291
+ els.qContainer.style.display = isLoading ? "none" : "block";
292
+ }
293
+
294
+ function toKey(opt) {
295
+ // opt: "A. text" / "2-D. text" 형태
296
+ const s = String(opt || "").trim();
297
+ if (!s) return "";
298
+ // 첫 토큰을 key로: "A." / "2-D." / "B)" 등
299
+ const first = s.split(/\s+/)[0];
300
+ return first.replace(/[.)]$/, "").trim();
301
+ }
302
+
303
+ function inferModeFromQuestion(q) {
304
+ // ✅ Steps 우선 (백엔드가 answer_steps 내려주면 확정)
305
+ if (Array.isArray(q.answer_steps) && q.answer_steps.length) return "steps";
306
+
307
+ // ✅ 멀티: answer_keys가 2개 이상이거나 answer 문자열에 쉼표가 있으면
308
+ if (Array.isArray(q.answer_keys) && q.answer_keys.length >= 2) return "multi";
309
+ if (typeof q.answer === "string" && q.answer.includes(",")) return "multi";
310
+
311
+ return "single";
312
+ }
313
+
314
+ function setHint(mode) {
315
+ els.multiHint.classList.remove("show");
316
+ els.multiHint.textContent = "";
317
+
318
+ if (mode === "multi") {
319
+ els.multiHint.textContent = "💡 복수 정답 문제입니다. 정답을 여러 개 선택한 뒤 제출하세요.";
320
+ els.multiHint.classList.add("show");
321
+ } else if (mode === "steps") {
322
+ els.multiHint.textContent = "💡 순서(단계) 문제입니다. 정답을 순서대로 클릭하세요. (다시 클릭하면 마지막 선택이 취소됩니다)";
323
+ els.multiHint.classList.add("show");
324
+ }
325
+ }
326
+
327
+ function resetState() {
328
+ selected = null;
329
+ answered = false;
330
+ els.submitBtn.disabled = true;
331
+ els.submitBtn.style.display = "block";
332
+ els.nextBtn.style.display = "none";
333
+ els.skipBtn.style.display = "block";
334
+ els.exp.classList.remove("show");
335
+ els.exp.innerHTML = "";
336
+ els.opts.innerHTML = "";
337
+ }
338
+
339
+ function updateSubmitEnabled() {
340
+ if (answerMode === "single") {
341
+ els.submitBtn.disabled = !selected;
342
+ } else {
343
+ els.submitBtn.disabled = !(Array.isArray(selected) && selected.length > 0);
344
+ }
345
+ }
346
+
347
+ // steps 모드: 선택 순서 표시(옵션 우측 상단 배지 대신, 간단히 selected 클래스로만 유지)
348
+ function toggleSelectedClass(labelEl, on) {
349
+ if (on) labelEl.classList.add("selected");
350
+ else labelEl.classList.remove("selected");
351
+ }
352
+
353
+ function clearAllSelectedClass() {
354
+ document.querySelectorAll(".opt").forEach(el => el.classList.remove("selected"));
355
+ }
356
+
357
+ function getSelectedArray() {
358
+ if (!Array.isArray(selected)) return [];
359
+ return selected.slice();
360
+ }
361
+
362
+ /* --- Core Logic --- */
363
+
364
  async function loadQuestion(questionId = null) {
365
+ setLoading(true);
 
 
366
  try {
367
+ const url = questionId ? `/api/question?id=${questionId}` : "/api/question";
368
  const res = await fetch(url);
369
+ if (!res.ok) throw new Error("네트워크 응답 오류");
370
+
371
  const data = await res.json();
 
372
  if (data.error) {
373
+ els.loading.innerHTML = `<span style="color:var(--bad)">⚠️ ${data.error}</span>`;
374
  return;
375
  }
376
+
377
  currentQuestion = data;
378
  render(data);
379
+ saveLastQuestionId(data.id);
380
+ setLoading(false);
 
 
381
  } catch (err) {
382
+ els.loading.innerHTML = `<span style="color:var(--bad)">⚠️ 문제를 불러오지 못했습니다.<br><small>${err.message}</small></span>`;
383
  console.error(err);
384
  }
385
  }
386
 
 
387
  function render(q) {
388
+ els.qContainer.classList.remove("fade-in");
389
+ void els.qContainer.offsetWidth;
390
+ els.qContainer.classList.add("fade-in");
391
+
392
+ els.progress.textContent = `문제 ${q.id} / ${q.total}`;
393
+ els.qid.textContent = `Question ID: ${q.id}`;
394
+ els.qtext.textContent = q.question;
395
+
396
+ els.jumpInput.max = q.total;
397
+ els.jumpInput.placeholder = `Total ${q.total}`;
398
+
399
+ resetState();
400
+
401
+ // 모드 판별 + 힌트
402
+ answerMode = inferModeFromQuestion(q);
403
+ setHint(answerMode);
404
+
405
+ // 옵션 렌더
406
+ (q.options || []).forEach((opt, index) => {
407
+ const line = (typeof opt === "string") ? opt : `${opt.key}. ${opt.text}`;
408
+
409
+ const key = toKey(line);
410
+ let text = line.replace(/^[^\s]+[\s]*/, ""); // 첫 토큰 제거 후 나머지
411
+ // "A. xxx" 같은 경우 앞에 "A." 제거가 덜 될 수 있어 보정
412
+ text = text.replace(/^[A-Z]\s*[\.\)]\s*/, "");
413
 
 
414
  const label = document.createElement("label");
415
  label.className = "opt";
416
+ label.dataset.key = key;
417
+ label.dataset.idx = index + 1;
418
+
419
  const input = document.createElement("input");
 
420
  input.name = `q${q.id}`;
421
+
422
+ // ✅ single=radio, multi/steps=checkbox (steps도 체크박스를 쓰되, 클릭 순서로 배열 관리)
423
+ input.type = (answerMode === "single") ? "radio" : "checkbox";
424
+ input.value = key;
425
+
426
+ // 클릭/변경 이벤트 통합 처리
427
+ input.addEventListener("change", (e) => onSelect(key, label, e.target.checked));
428
+
429
+ const keySpan = document.createElement("div");
430
+ keySpan.className = "opt-key";
431
+ keySpan.textContent = key + ".";
432
+
433
+ const textSpan = document.createElement("div");
434
+ textSpan.style.flex = "1";
435
+ textSpan.textContent = text;
436
+
437
  label.appendChild(input);
438
+ label.appendChild(keySpan);
439
+ label.appendChild(textSpan);
440
+ els.opts.appendChild(label);
441
+
442
+ // label 클릭해도 토글되게(모바일 체감 좋음)
443
+ label.addEventListener("click", (e) => {
444
+ if (answered) return;
445
+ // input 자체 클릭은 기본 동작에 맡기고, label 영역 클릭만 보정
446
+ if (e.target.tagName !== "INPUT") input.click();
447
+ });
448
  });
449
  }
450
 
451
+ function onSelect(key, labelEl, checked) {
 
452
  if (answered) return;
453
+
454
+ if (answerMode === "single") {
455
+ // 기존 동작 유지
456
+ document.querySelectorAll(".opt").forEach(opt => opt.classList.remove("selected"));
457
+ labelEl.classList.add("selected");
458
+ selected = key;
459
+ updateSubmitEnabled();
460
+ return;
461
+ }
462
+
463
+ // multi / steps: 배열로 관리
464
+ if (!Array.isArray(selected)) selected = [];
465
+
466
+ if (answerMode === "multi") {
467
+ // checkbox 그대로 반영
468
+ if (checked) {
469
+ if (!selected.includes(key)) selected.push(key);
470
+ toggleSelectedClass(labelEl, true);
471
+ } else {
472
+ selected = selected.filter(x => x !== key);
473
+ toggleSelectedClass(labelEl, false);
474
+ }
475
+ updateSubmitEnabled();
476
+ return;
477
+ }
478
+
479
+ // steps: 클릭 순서가 중요
480
+ // 규칙: 체크되면 push, 해제되면 "마지막 요소"일 때만 pop 허용(사용자 실수 방지)
481
+ if (checked) {
482
+ if (!selected.includes(key)) selected.push(key);
483
+ toggleSelectedClass(labelEl, true);
484
+ } else {
485
+ // 마지막만 취소 허용
486
+ const last = selected[selected.length - 1];
487
+ if (last === key) {
488
+ selected.pop();
489
+ toggleSelectedClass(labelEl, false);
490
+ } else {
491
+ // 되돌리고 안내
492
+ const input = labelEl.querySelector("input");
493
+ if (input) input.checked = true;
494
+ showToast("순서 문제는 마지막 선택만 취소할 수 있어요.");
495
+ }
496
+ }
497
+ updateSubmitEnabled();
498
  }
499
 
500
+ // 정답 제출
501
+ els.submitBtn.addEventListener("click", async () => {
502
+ if (answered) return;
503
+
504
+ // 선택 값 만들기
505
+ let chosenPayload = null;
506
+ if (answerMode === "single") {
507
+ if (!selected) return;
508
+ chosenPayload = selected;
509
+ } else {
510
+ const arr = getSelectedArray();
511
+ if (!arr.length) return;
512
+ chosenPayload = arr.length === 1 ? arr[0] : arr; // 백엔드 호환(1개면 string)
513
+ }
514
+
515
+ els.submitBtn.disabled = true;
516
+
517
  try {
518
  const res = await fetch("/api/answer", {
519
  method: "POST",
520
  headers: { "Content-Type": "application/json" },
521
  body: JSON.stringify({
522
  question_id: currentQuestion.id,
523
+ chosen: chosenPayload,
524
  user_id: "default"
525
  })
526
  });
527
+
528
  const result = await res.json();
529
  answered = true;
530
+
531
+ // 정답키/steps키를 받을 수 있으면 우선 사용
532
+ // - 최적화 app.py: answer_keys / answer_steps를 내려줄 수 있음
533
+ const answerKeys = Array.isArray(currentQuestion.answer_keys) ? currentQuestion.answer_keys.map(String) : [];
534
+ const answerSteps = Array.isArray(currentQuestion.answer_steps) ? currentQuestion.answer_steps.map(String) : [];
535
+
536
+ // 서버가 answer를 "A,B"로 주는 구버전이면 파싱
537
+ const legacyKeys = (typeof result.answer === "string" && result.answer.includes(","))
538
+ ? result.answer.split(",").map(x => x.trim()).filter(Boolean)
539
+ : [];
540
+
541
+ // 채점용 정답 리스트 결정
542
+ let correctList = [];
543
+ if (answerMode === "steps" && answerSteps.length) correctList = answerSteps;
544
+ else if (answerMode !== "single" && answerKeys.length) correctList = answerKeys;
545
+ else if (legacyKeys.length) correctList = legacyKeys;
546
+ else if (typeof result.answer === "string" && result.answer) correctList = [result.answer];
547
+
548
+ // 스타일 적용
549
+ document.querySelectorAll(".opt").forEach(optEl => {
550
+ optEl.classList.add("disabled");
551
+ const optKey = optEl.dataset.key;
552
+
553
+ // correctList에 있으면 correct 표기 (멀티면 여러개)
554
+ if (correctList.includes(optKey)) {
555
+ optEl.classList.add("correct");
556
+ }
557
+
558
+ // 오답 표기: 선택했는데 correctList에 없으면 wrong
559
+ if (answerMode === "single") {
560
+ if (optKey === selected && !result.correct && !correctList.includes(optKey)) {
561
+ optEl.classList.add("wrong");
562
+ }
563
+ } else {
564
+ const selArr = Array.isArray(selected) ? selected : [];
565
+ if (selArr.includes(optKey) && !result.correct && !correctList.includes(optKey)) {
566
+ optEl.classList.add("wrong");
567
+ }
568
  }
569
  });
570
+
571
+ showExplanation(result, correctList);
572
+
573
+ els.submitBtn.style.display = "none";
574
+ els.nextBtn.style.display = "block";
575
+ els.skipBtn.style.display = "none";
576
+ els.nextBtn.focus();
577
+
578
  } catch (err) {
579
  console.error(err);
580
+ showToast("채점 중 오류가 발생했습니다.");
581
+ els.submitBtn.disabled = false;
582
  }
583
  });
584
 
585
+ function showExplanation(result, correctList) {
586
+ els.exp.classList.add("show");
587
+
588
+ const isCorrect = !!result.correct;
589
+ const correctText = correctList.length ? correctList.join(", ") : (result.answer || "(정답 정보 없음)");
590
+
591
+ const extra =
592
+ (answerMode === "multi") ? `<div style="margin-top:6px;color:var(--muted);font-size:13px">선택: ${Array.isArray(selected) ? selected.join(", ") : selected}</div>` :
593
+ (answerMode === "steps") ? `<div style="margin-top:6px;color:var(--muted);font-size:13px">선택(순서): ${Array.isArray(selected) ? selected.join(" → ") : selected}</div>` :
594
+ "";
595
+
596
+ els.exp.innerHTML = `
597
  <div class="title ${isCorrect ? 'ok':'bad'}">
598
+ ${isCorrect ? "✅ 정답입니다!" : "❌ 오답! 정답은 " + correctText + " 입니다."}
599
  </div>
600
+ ${extra}
601
+ <div>${result.explanation || "해설 정보가 없습니다."}</div>
602
  `;
603
  }
604
 
605
+ // 다음/이전/스킵
606
+ async function moveQuestion(direction) {
607
+ if (!currentQuestion) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
 
609
+ if (direction === "prev") {
610
+ if (currentQuestion.id <= 1) { showToast(" 번째 문제입니다."); return; }
611
+ await loadQuestion(currentQuestion.id - 1);
 
612
  return;
613
  }
 
 
 
 
 
 
 
 
 
614
 
 
 
 
 
 
 
 
615
  try {
616
  const res = await fetch(`/api/next?current_id=${currentQuestion.id}`);
617
  const data = await res.json();
618
+ if (data.end) { showToast("🎉 마지막 문제입니다!"); return; }
 
 
 
 
 
619
  currentQuestion = data;
620
  render(data);
621
  saveLastQuestionId(data.id);
622
  } catch (err) {
623
+ showToast("❌ 이동 실패");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  }
625
+ }
 
 
 
626
 
627
+ els.nextBtn.addEventListener("click", () => moveQuestion("next"));
628
+ els.skipBtn.addEventListener("click", () => moveQuestion("next"));
629
+ els.prevBtn.addEventListener("click", () => moveQuestion("prev"));
 
 
 
630
 
631
+ // 북마크
632
+ els.bmBtn.addEventListener("click", async () => {
633
  if (!currentQuestion) return;
 
634
  try {
635
  const res = await fetch("/api/review_add", {
636
  method: "POST",
637
  headers: { "Content-Type": "application/json" },
638
+ body: JSON.stringify({ question_id: currentQuestion.id, user_id: "default" })
 
 
 
639
  });
 
640
  const data = await res.json();
641
+ showToast("⭐ " + (data.message || "복습 노트에 저장되었습니다."));
642
  } catch (err) {
643
+ showToast("❌ 저장 실패");
 
644
  }
645
  });
646
 
647
+ // 점프
648
+ function jumpTo() {
649
+ const targetId = parseInt(els.jumpInput.value);
650
+ if (!targetId || targetId < 1) { showToast("⚠️ 올바른 번호를 입력하세요."); return; }
651
+ if (currentQuestion && targetId > currentQuestion.total) { showToast(`⚠️ 1 ~ ${currentQuestion.total} 사이만 가능합니다.`); return; }
652
+ loadQuestion(targetId);
653
+ els.jumpInput.value = "";
654
+ }
655
+ els.jumpBtn.addEventListener("click", jumpTo);
656
+ els.jumpInput.addEventListener("keypress", (e) => { if (e.key === "Enter") jumpTo(); });
657
+ els.homeBtn.addEventListener("click", () => window.location.href = "/");
658
+
659
  // 키보드 단축키
660
  window.addEventListener("keydown", e => {
661
+ if (document.activeElement.tagName === "INPUT") return;
662
+
663
+ const key = e.key.toUpperCase();
664
+
665
+ // 선택지 선택(1~4 / A~D) — 멀티/스텝에서도 "토글"로 동작
666
+ if (!answered) {
667
+ const keyMap = { "1":"A", "2":"B", "3":"C", "4":"D", "A":"A", "B":"B", "C":"C", "D":"D" };
668
+ if (keyMap[key]) {
669
+ const targetKey = keyMap[key];
670
+ const targetLabel = document.querySelector(`.opt[data-key="${targetKey}"]`);
671
+ if (targetLabel) {
672
+ const input = targetLabel.querySelector("input");
673
+ if (input) input.click();
674
+ }
675
+ }
676
+ }
677
+
678
+ // 제출/다음 (Enter)
679
+ if (e.key === "Enter") {
680
+ if (!els.submitBtn.disabled && els.submitBtn.style.display !== "none") {
681
+ els.submitBtn.click();
682
+ } else if (els.nextBtn.style.display !== "none") {
683
+ els.nextBtn.click();
684
+ }
685
  }
686
+
687
+ // 이동 (좌우)
688
+ if (e.key === "ArrowRight") {
689
+ if (els.nextBtn.style.display !== "none") els.nextBtn.click();
690
  }
691
  if (e.key === "ArrowLeft") {
692
+ els.prevBtn.click();
693
  }
694
  });
695
 
696
+ // 초기 실행
697
  const lastId = getLastQuestionId();
698
+ loadQuestion(lastId || undefined);
 
 
 
 
699
  </script>
700
  </body>
701
+ </html>
adaptive_parser_monitor.py → 삭제용/adaptive_parser_monitor.py RENAMED
File without changes
adative_parser_watchdog.py → 삭제용/adative_parser_watchdog.py RENAMED
File without changes
analyze_parser_log_ai.py → 삭제용/analyze_parser_log_ai.py RENAMED
File without changes
append_questions.py → 삭제용/append_questions.py RENAMED
File without changes
check_db.py → 삭제용/check_db.py RENAMED
File without changes
classify.py → 삭제용/classify.py RENAMED
File without changes
deploy_to_spaces.py → 삭제용/deploy_to_spaces.py RENAMED
File without changes
llm_extract.py → 삭제용/llm_extract.py RENAMED
File without changes
llm_parse.py → 삭제용/llm_parse.py RENAMED
File without changes
ocr_test_light.py → 삭제용/ocr_test_light.py RENAMED
File without changes
parse_pipeline.py → 삭제용/parse_pipeline.py RENAMED
File without changes
pdf_parser.py → 삭제용/pdf_parser.py RENAMED
File without changes
pdf_parser_adaptive.py → 삭제용/pdf_parser_adaptive.py RENAMED
File without changes
schemas.py → 삭제용/schemas.py RENAMED
File without changes
seed.py → 삭제용/seed.py RENAMED
File without changes
similarity.py → 삭제용/similarity.py RENAMED
File without changes