Kentlo commited on
Commit
87b5875
ยท
verified ยท
1 Parent(s): 56a2bc0

Sync from GitHub f8e98d3f03952efd3d7c1b217df60c77c3992aec

Browse files
.env.example CHANGED
@@ -1,12 +1,23 @@
1
- DATABASE_URL=postgresql+pg8000://cert_study:cert_study@localhost:5432/cert_study
2
- CERT_STUDY_DB_FALLBACK=0
 
3
 
4
- OLLAMA_BASE_URL=http://localhost:11434
 
 
5
  OLLAMA_MODEL=qwen2.5:14b
6
  OLLAMA_FAST_MODEL=qwen3.5:9b
7
  OLLAMA_VISUAL_MODEL=qwen3-vl:8b-instruct-q4_K_M
 
8
  EMBEDDING_MODEL=BAAI/bge-m3
9
 
10
- AIRFLOW_API_BASE_URL=http://localhost:8080
11
  AIRFLOW_API_USERNAME=admin
12
- AIRFLOW_API_PASSWORD=admin
 
 
 
 
 
 
 
 
1
+ POSTGRES_DB=cert_study
2
+ POSTGRES_USER=cert_study
3
+ POSTGRES_PASSWORD=change-me
4
 
5
+ DATABASE_URL=postgresql+pg8000://cert_study:change-me@postgres:5432/cert_study
6
+
7
+ CERT_STUDY_LLM_PROVIDER=ollama
8
  OLLAMA_MODEL=qwen2.5:14b
9
  OLLAMA_FAST_MODEL=qwen3.5:9b
10
  OLLAMA_VISUAL_MODEL=qwen3-vl:8b-instruct-q4_K_M
11
+ OLLAMA_BASE_URL=http://host.docker.internal:11434
12
  EMBEDDING_MODEL=BAAI/bge-m3
13
 
14
+ AIRFLOW_API_BASE_URL=http://host.docker.internal:8080
15
  AIRFLOW_API_USERNAME=admin
16
+ AIRFLOW_API_PASSWORD=change-me
17
+ AIRFLOW_ADMIN_USERNAME=admin
18
+ AIRFLOW_ADMIN_PASSWORD=change-me
19
+ AIRFLOW_ADMIN_EMAIL=admin@example.com
20
+
21
+ CERT_STUDY_SEED_DEMO=1
22
+ CERT_STUDY_REFRESH_SEED=1
23
+ CERT_STUDY_OVERWRITE_REVIEWED_SEED=0
CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## 2026-06-07
4
+
5
+ - Added deployable Hugging Face seed data and static question assets.
6
+ - Restored common passages and parent images for case-study questions.
7
+ - Improved seed refresh so deployed databases can receive updated questions.
8
+ - Added Yes/No matrix handling for true/false hotspot questions.
9
+ - Added grouped row selection for `1-A / 1-B / 2-A / 2-B` style questions.
10
+ - Added centralized answer normalization for single choice, multi-select, ordered answers, and Yes/No matrices.
11
+ - Added centralized question type normalization for aliases such as `Hotspot (True/False)` and `Hotspot (Drag and Drop)`.
12
+ - Reduced mobile home screen noise by prioritizing study actions and grouping upload/admin actions.
13
+ - Added lightweight tests for answer normalization, question type aliases, and seed export reports.
14
+ - Added GitHub Actions quality check workflow.
15
+ - Added `.env.example` and Docker Compose environment variable defaults.
16
+ - Updated README and docs for the current deployment and troubleshooting flow.
README.md CHANGED
@@ -26,6 +26,7 @@ pinned: false
26
  - ๋ฌธ์ œ, ์ •๋‹ต, ํ•ด์„ค, ํ’€์ด ๊ธฐ๋ก ์ €์žฅ
27
  - Streamlit ๊ธฐ๋ฐ˜ ๋ฌธ์ œํ’€์ด ํ™”๋ฉด
28
  - ๊ณตํ†ต ์ง€๋ฌธ, ์›๋ฌธ ์ด๋ฏธ์ง€, Yes/No ์ง„์ˆ ํ˜• ๋ฌธ์ œํ’€์ด UI
 
29
  - ์˜ค๋‹ต/๋ณต์Šต ํ™”๋ฉด
30
  - ๊ฐœ๋… ์ •๋ฆฌ ํ™”๋ฉด
31
  - Chroma ๊ธฐ๋ฐ˜ ์œ ์‚ฌ ๋ฌธ์ œ ๊ฒ€์ƒ‰ ๊ตฌ์กฐ
@@ -57,12 +58,14 @@ pinned: false
57
  8. [GitHub / Hugging Face ๋ฐฐํฌ ์ž๋™ํ™”](docs/08_publish_automation.md)
58
  9. [๋ชจ๋ฐ”์ผ ์•ฑ์ฒ˜๋Ÿผ ์‚ฌ์šฉํ•˜๊ธฐ](docs/09_mobile_pwa.md)
59
  10. [๊ฐœ๋ฐœ์ผ์ง€](docs/devlog/2026-06-07.md)
 
60
 
61
  ## ์‹คํ–‰ ๋ฐฉ๋ฒ•
62
 
63
  Docker๊ฐ€ ์„ค์น˜๋˜์–ด ์žˆ๋‹ค๋ฉด ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.
64
 
65
  ```bash
 
66
  docker compose up --build
67
  ```
68
 
@@ -83,7 +86,7 @@ DATABASE_URL=sqlite:////tmp/cert-study-local-check.db \
83
 
84
  Hugging Face Space์—๋Š” ๋กœ์ปฌ DB๋‚˜ PDF ์›๋ณธ์„ ์˜ฌ๋ฆฌ์ง€ ์•Š๊ณ , `cert_study_app/demo_data/questions_seed.json`๊ณผ `static/question_assets/`์˜ ์ฐธ์กฐ ์ด๋ฏธ์ง€๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
85
 
86
- ํ˜„์žฌ ๋ฐฐํฌ์šฉ seed๋Š” 321๋ฌธํ•ญ, ๊ณตํ†ต ์ง€๋ฌธ ํฌํ•จ ๋ฌธํ•ญ, ์›๋ฌธ ์ด๋ฏธ์ง€, Yes/No ์ง„์ˆ ํ˜• ๋ฌธ์ œ UI์— ํ•„์š”ํ•œ ๊ตฌ์กฐ๋ฅผ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. seed๋Š” ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ ๋‹ค์‹œ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
87
 
88
  ```bash
89
  python scripts/export_hf_seed.py
@@ -97,6 +100,28 @@ Git ์ปค๋ฐ‹์€ ๋‚ ์งœ์™€ ํ•จ๊ป˜ ๋‚จ๊ธฐ ๋•Œ๋ฌธ์— ์ผ์ž๋ณ„ ๋ณ€๊ฒฝ ๋‚ด์šฉ์„ ํ™•
97
  scripts/git_daily_changes.sh 2026-06-07
98
  ```
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  ## ํ™˜๊ฒฝ ๋ณ€์ˆ˜
101
 
102
  ๊ธฐ๋ณธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋Š” PostgreSQL์ž…๋‹ˆ๋‹ค.
@@ -123,4 +148,4 @@ CERT_STUDY_DB_FALLBACK=1
123
  - ๋ฌธ์ œ ์ค‘๋ณต ๊ฐ์ง€ ๋ฐ ์žฌ์—…๋กœ๋“œ ์ฒ˜๋ฆฌ
124
  - Oracle Cloud Ubuntu ์„œ๋ฒ„ ๋ฐฐํฌ
125
  - ๋ฐฐํฌ์šฉ ํ™˜๊ฒฝ ๋ณ€์ˆ˜์™€ ๋น„๋ฐ€๊ฐ’ ๊ด€๋ฆฌ ์ •๋ฆฌ
126
- - ํ…Œ์ŠคํŠธ์™€ CI/CD ์ถ”๊ฐ€
 
26
  - ๋ฌธ์ œ, ์ •๋‹ต, ํ•ด์„ค, ํ’€์ด ๊ธฐ๋ก ์ €์žฅ
27
  - Streamlit ๊ธฐ๋ฐ˜ ๋ฌธ์ œํ’€์ด ํ™”๋ฉด
28
  - ๊ณตํ†ต ์ง€๋ฌธ, ์›๋ฌธ ์ด๋ฏธ์ง€, Yes/No ์ง„์ˆ ํ˜• ๋ฌธ์ œํ’€์ด UI
29
+ - ๋ฌธ์ œ ์œ ํ˜• ํ‘œ์ค€ํ™” ๋ฐ ์ •๋‹ต ์ •๊ทœํ™” ๋กœ์ง
30
  - ์˜ค๋‹ต/๋ณต์Šต ํ™”๋ฉด
31
  - ๊ฐœ๋… ์ •๋ฆฌ ํ™”๋ฉด
32
  - Chroma ๊ธฐ๋ฐ˜ ์œ ์‚ฌ ๋ฌธ์ œ ๊ฒ€์ƒ‰ ๊ตฌ์กฐ
 
58
  8. [GitHub / Hugging Face ๋ฐฐํฌ ์ž๋™ํ™”](docs/08_publish_automation.md)
59
  9. [๋ชจ๋ฐ”์ผ ์•ฑ์ฒ˜๋Ÿผ ์‚ฌ์šฉํ•˜๊ธฐ](docs/09_mobile_pwa.md)
60
  10. [๊ฐœ๋ฐœ์ผ์ง€](docs/devlog/2026-06-07.md)
61
+ 11. [๋ณ€๊ฒฝ ์ด๋ ฅ](CHANGELOG.md)
62
 
63
  ## ์‹คํ–‰ ๋ฐฉ๋ฒ•
64
 
65
  Docker๊ฐ€ ์„ค์น˜๋˜์–ด ์žˆ๋‹ค๋ฉด ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.
66
 
67
  ```bash
68
+ cp .env.example .env
69
  docker compose up --build
70
  ```
71
 
 
86
 
87
  Hugging Face Space์—๋Š” ๋กœ์ปฌ DB๋‚˜ PDF ์›๋ณธ์„ ์˜ฌ๋ฆฌ์ง€ ์•Š๊ณ , `cert_study_app/demo_data/questions_seed.json`๊ณผ `static/question_assets/`์˜ ์ฐธ์กฐ ์ด๋ฏธ์ง€๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
88
 
89
+ ํ˜„์žฌ ๋ฐฐํฌ์šฉ seed๋Š” 321๋ฌธํ•ญ, ๊ณตํ†ต ์ง€๋ฌธ ํฌํ•จ 36๋ฌธํ•ญ, ์›๋ฌธ ์ด๋ฏธ์ง€, Yes/No ์ง„์ˆ ํ˜• ๋ฌธ์ œ UI์— ํ•„์š”ํ•œ ๊ตฌ์กฐ๋ฅผ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. seed๋Š” ์•„๋ž˜ ๋ช…๋ น์œผ๋กœ ๋‹ค์‹œ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
90
 
91
  ```bash
92
  python scripts/export_hf_seed.py
 
100
  scripts/git_daily_changes.sh 2026-06-07
101
  ```
102
 
103
+ ## ํ…Œ์ŠคํŠธ
104
+
105
+ ๋ฌธ์ œ ์œ ํ˜•์ด ๋‹ค์–‘ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ •๋‹ต ์ฒ˜๋ฆฌ ๋กœ์ง์€ ๋ณ„๋„ ํ…Œ์ŠคํŠธ๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
106
+
107
+ ```bash
108
+ python -m pip install -r requirements-dev.txt
109
+ python -m pytest tests
110
+ ```
111
+
112
+ ๋ฐฐํฌ ์ „์— ํ•ต์‹ฌ ํ’ˆ์งˆ ๊ฒ€์‚ฌ๋ฅผ ํ•œ ๋ฒˆ์— ์‹คํ–‰ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
113
+
114
+ ```bash
115
+ python scripts/check_quality.py
116
+ ```
117
+
118
+ ํ˜„์žฌ ์šฐ์„  ํ™•์ธํ•˜๋Š” ๋ฒ”์œ„:
119
+
120
+ - Yes/No ์ง„์ˆ ํ˜• ์ •๋‹ต ์ •๊ทœํ™”
121
+ - ๋ณต์ˆ˜ ์„ ํƒ ์ •๋‹ต ์ •๋ ฌ
122
+ - ์ˆœ์„œ๊ฐ€ ์ค‘์š”ํ•œ ๋‹ต์•ˆ ๋ณด์กด
123
+ - `Hotspot (True/False)`, `Hotspot (Drag and Drop)` ๊ฐ™์€ ๋ฌธ์ œ ์œ ํ˜• alias ํ‘œ์ค€ํ™”
124
+
125
  ## ํ™˜๊ฒฝ ๋ณ€์ˆ˜
126
 
127
  ๊ธฐ๋ณธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋Š” PostgreSQL์ž…๋‹ˆ๋‹ค.
 
148
  - ๋ฌธ์ œ ์ค‘๋ณต ๊ฐ์ง€ ๋ฐ ์žฌ์—…๋กœ๋“œ ์ฒ˜๋ฆฌ
149
  - Oracle Cloud Ubuntu ์„œ๋ฒ„ ๋ฐฐํฌ
150
  - ๋ฐฐํฌ์šฉ ํ™˜๊ฒฝ ๋ณ€์ˆ˜์™€ ๋น„๋ฐ€๊ฐ’ ๊ด€๋ฆฌ ์ •๋ฆฌ
151
+ - ํ…Œ์ŠคํŠธ ๋ฒ”์œ„ ํ™•๋Œ€์™€ CI/CD ์ถ”๊ฐ€
cert_study_app/demo_data/questions_seed.json CHANGED
The diff for this file is too large to render. See raw diff
 
cert_study_app/demo_data/questions_seed.report.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": 1,
3
+ "seed_path": "cert_study_app/demo_data/questions_seed.json",
4
+ "question_count": 321,
5
+ "first_question_number": 1,
6
+ "last_question_number": 321,
7
+ "common_passage_count": 36,
8
+ "type_counts": {
9
+ "Drag and Drop (Auth)": 1,
10
+ "Drag and Drop (Roles)": 1,
11
+ "Drag and Drop (Steps)": 9,
12
+ "Hotspot (Drag and Drop)": 88,
13
+ "Hotspot (Select Two)": 2,
14
+ "Hotspot (Select options in sequence)": 1,
15
+ "Hotspot (True/False)": 50,
16
+ "Multiple Choice": 121,
17
+ "Multiple Choice (Select Three)": 2,
18
+ "Multiple Choice (Select Two)": 15,
19
+ "True/False": 2,
20
+ "True/False (In-Context)": 29
21
+ },
22
+ "duplicate_numbers": [],
23
+ "missing_answers": [],
24
+ "missing_options": [],
25
+ "missing_images": [],
26
+ "ready": true
27
+ }
cert_study_app/services/answer_normalizer.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from cert_study_app.services.text_cleanup_service import clean_inline_text
9
+
10
+
11
+ YES_WORDS = {"yes", "y", "์˜ˆ", "true", "selected"}
12
+ NO_WORDS = {"no", "n", "์•„๋‹ˆ์˜ค", "์•„๋‹ˆ์š”", "false", "not selected"}
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AnswerEvaluation:
17
+ chosen: str
18
+ answer: str
19
+ normalized_chosen: str
20
+ normalized_answer: str
21
+ correct: bool
22
+ ordered: bool = False
23
+
24
+
25
+ def normalize_options(raw: Any) -> list[str]:
26
+ if not raw:
27
+ return []
28
+ try:
29
+ if isinstance(raw, str):
30
+ parsed = json.loads(raw)
31
+ if isinstance(parsed, (list, dict)):
32
+ raw = parsed
33
+ if isinstance(raw, dict):
34
+ keyed = []
35
+ for key, value in sorted(raw.items(), key=lambda kv: str(kv[0])):
36
+ text = clean_inline_text(value)
37
+ if re.match(r"^[A-Za-z1-9][\.\)]\s+", text):
38
+ keyed.append(text)
39
+ else:
40
+ keyed.append(f"{key}. {text}")
41
+ return split_embedded_options(keyed)
42
+ if isinstance(raw, list):
43
+ return split_embedded_options(raw)
44
+ except Exception:
45
+ return []
46
+ return []
47
+
48
+
49
+ def split_embedded_options(options: list[str]) -> list[str]:
50
+ split = []
51
+ expected = "A"
52
+ for option in options:
53
+ text = clean_inline_text(option)
54
+ if not text:
55
+ continue
56
+ parts = list(re.finditer(r"(?<![A-Za-z0-9])([A-Z])[\.\)]\s+", text))
57
+ if len(parts) <= 1:
58
+ split.append(text)
59
+ continue
60
+ for index, match in enumerate(parts):
61
+ end = parts[index + 1].start() if index + 1 < len(parts) else len(text)
62
+ part = text[match.start() : end].strip()
63
+ label = match.group(1).upper()
64
+ if label >= expected:
65
+ split.append(part)
66
+ expected = chr(ord(label) + 1)
67
+ return split
68
+
69
+
70
+ def extract_options_from_stem(stem: str) -> list[str]:
71
+ lines = [line.strip() for line in (stem or "").splitlines() if line.strip()]
72
+ options = []
73
+ current_key = None
74
+ current_parts = []
75
+ expected = "A"
76
+
77
+ def flush():
78
+ if current_key and current_parts:
79
+ text = " ".join(current_parts).strip()
80
+ if text:
81
+ options.append(f"{current_key}. {text}")
82
+
83
+ for line in lines:
84
+ if re.match(r"^(answer|์ •๋‹ต|explanation|reference)\b", line, re.I):
85
+ flush()
86
+ current_key = None
87
+ current_parts = []
88
+ break
89
+
90
+ marker = None
91
+ body = ""
92
+ exact = re.match(r"^([A-Z])[\.\)]?$", line, re.I)
93
+ inline = re.match(r"^([A-Z])[\.\)]?\s+(.+)$", line, re.I)
94
+ if exact:
95
+ marker = exact.group(1).upper()
96
+ elif inline:
97
+ marker = inline.group(1).upper()
98
+ body = inline.group(2).strip()
99
+
100
+ if marker and marker >= expected:
101
+ flush()
102
+ current_key = marker
103
+ current_parts = [body] if body else []
104
+ expected = chr(ord(marker) + 1)
105
+ elif current_key:
106
+ current_parts.append(line)
107
+
108
+ flush()
109
+ return options if len(options) >= 2 else []
110
+
111
+
112
+ def extract_answer_from_stem(stem: str) -> str:
113
+ match = re.search(r"(?:Answer|์ •๋‹ต)\s*:?\s*([A-Z1-9])", stem or "", re.I)
114
+ if not match:
115
+ return ""
116
+ value = match.group(1).upper()
117
+ if value.isdigit():
118
+ return chr(ord("A") + int(value) - 1)
119
+ return value
120
+
121
+
122
+ def option_label(value: Any) -> str:
123
+ text = str(value or "").strip().upper()
124
+ if text.isdigit():
125
+ return chr(ord("A") + int(text) - 1)
126
+ match = re.match(r"^([A-Z])(?:[\s\.,\)]|$)", text)
127
+ return match.group(1) if match else text
128
+
129
+
130
+ def choice_labels(value: Any) -> list[str]:
131
+ text = str(value or "").strip().upper()
132
+ if not text:
133
+ return []
134
+ if re.fullmatch(r"[A-Z]{2,26}", text):
135
+ return list(text)
136
+ tokens = re.findall(r"\b[A-Z]\b|\b[1-9]\b", text)
137
+ return [option_label(token) for token in tokens]
138
+
139
+
140
+ def yes_no_labels(value: Any) -> list[str]:
141
+ raw_value = _parse_jsonish(value)
142
+
143
+ if isinstance(raw_value, list):
144
+ values = [
145
+ item.get("value") or item.get("answer") or item.get("selected_answer")
146
+ for item in raw_value
147
+ if isinstance(item, dict)
148
+ ]
149
+ labels = yes_no_labels(",".join(str(item) for item in values if item))
150
+ if labels:
151
+ return labels
152
+ elif isinstance(raw_value, dict):
153
+ labels = yes_no_labels(",".join(str(item) for item in raw_value.values()))
154
+ if labels:
155
+ return labels
156
+
157
+ text = str(value or "").strip().lower()
158
+ if not text:
159
+ return []
160
+ if re.fullmatch(r"[yn](?:\s*,\s*[yn])+", text, re.I):
161
+ return [token.upper() for token in re.findall(r"[yn]", text, re.I)]
162
+
163
+ tokens = re.findall(
164
+ r"(?<![๊ฐ€-ํžฃ])์˜ˆ(?![๊ฐ€-ํžฃ])|์•„๋‹ˆ์˜ค|์•„๋‹ˆ์š”|\byes\b|\bno\b|\bselected\b|\bnot selected\b|\btrue\b|\bfalse\b",
165
+ text,
166
+ re.I,
167
+ )
168
+ labels = []
169
+ for token in tokens:
170
+ lowered = token.lower()
171
+ labels.append("Y" if token == "์˜ˆ" or lowered in YES_WORDS else "N")
172
+ return labels if len(labels) >= 2 else []
173
+
174
+
175
+ def structured_answer_values(value: Any) -> list[str]:
176
+ raw_value = _parse_jsonish(value)
177
+ if isinstance(raw_value, list):
178
+ values = [
179
+ item.get("value") or item.get("answer") or item.get("selected_answer")
180
+ for item in raw_value
181
+ if isinstance(item, dict)
182
+ ]
183
+ elif isinstance(raw_value, dict):
184
+ values = list(raw_value.values())
185
+ else:
186
+ return []
187
+ return [str(item).strip() for item in values if str(item).strip()]
188
+
189
+
190
+ def option_text(options: list[str], label: str) -> str:
191
+ label = option_label(label)
192
+ for index, option in enumerate(options, 1):
193
+ text = str(option).strip()
194
+ keys = {str(index), chr(ord("A") + index - 1)}
195
+ match = re.match(r"^([A-Z1-9])[\.\)]\s+(.+)$", text, re.I)
196
+ if match:
197
+ keys.add(option_label(match.group(1)))
198
+ if match.group(1).isdigit():
199
+ keys.add(match.group(1))
200
+ if label in keys:
201
+ return text
202
+ return ""
203
+
204
+
205
+ def option_labels_from_texts(options: list[str], values: Any, allow_duplicates: bool = False) -> list[str]:
206
+ if not values:
207
+ return []
208
+ if isinstance(values, str):
209
+ values = [values]
210
+ labels = []
211
+ for value in values:
212
+ target = re.sub(r"\s+", " ", str(value or "").strip()).lower()
213
+ if not target:
214
+ continue
215
+ for index, option in enumerate(options, 1):
216
+ body = re.sub(r"\s+", " ", _option_body(option)).lower()
217
+ if body and (target == body or target in body or body in target):
218
+ label = chr(ord("A") + index - 1)
219
+ if allow_duplicates or label not in labels:
220
+ labels.append(label)
221
+ break
222
+ return labels
223
+
224
+
225
+ def normalize_answer(value: Any, *, ordered: bool = False) -> str:
226
+ yn = yes_no_labels(value)
227
+ if yn:
228
+ return ",".join(yn)
229
+ labels = choice_labels(value)
230
+ if not labels:
231
+ return option_label(value)
232
+ if ordered:
233
+ return ",".join(labels)
234
+ return ",".join(sorted(set(labels)))
235
+
236
+
237
+ def evaluate_answer(chosen: Any, answer: Any, *, ordered: bool = False) -> AnswerEvaluation:
238
+ normalized_chosen = normalize_answer(chosen, ordered=ordered)
239
+ normalized_answer = normalize_answer(answer, ordered=ordered)
240
+ return AnswerEvaluation(
241
+ chosen=str(chosen or ""),
242
+ answer=str(answer or ""),
243
+ normalized_chosen=normalized_chosen,
244
+ normalized_answer=normalized_answer,
245
+ correct=normalized_chosen == normalized_answer,
246
+ ordered=ordered,
247
+ )
248
+
249
+
250
+ def _option_body(option: str) -> str:
251
+ text = str(option or "").strip()
252
+ match = re.match(r"^([A-Z1-9])[\.\)]\s+(.+)$", text, re.I)
253
+ return (match.group(2) if match and match.group(2) else text).strip()
254
+
255
+
256
+ def _parse_jsonish(value: Any) -> Any:
257
+ if isinstance(value, (list, dict)):
258
+ return value
259
+ try:
260
+ return json.loads(value) if isinstance(value, str) else None
261
+ except Exception:
262
+ return None
cert_study_app/services/demo_seed_service.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import json
4
  import os
 
5
  from pathlib import Path
6
 
7
  from sqlalchemy import func
@@ -13,6 +14,9 @@ DEMO_SOURCE = "Demo Certification Sample"
13
  DEMO_DATA_DIR = Path(__file__).resolve().parent.parent / "demo_data"
14
  FULL_SEED_DATA_PATH = DEMO_DATA_DIR / "questions_seed.json"
15
  DEMO_DATA_PATH = DEMO_DATA_DIR / "demo_questions.json"
 
 
 
16
 
17
 
18
  def _seed_data_path() -> Path:
@@ -30,7 +34,15 @@ def _json_text(value, fallback):
30
  return fallback
31
 
32
 
 
 
 
 
 
33
  def _apply_seed_row(question: Question, row: dict) -> None:
 
 
 
34
  question.stem = row["stem"]
35
  question.answer = _json_text(row.get("answer"), None)
36
  question.explanation = row.get("explanation")
@@ -45,14 +57,14 @@ def _apply_seed_row(question: Question, row: dict) -> None:
45
  question.source = row.get("source") or DEMO_SOURCE
46
  question.image_path = row.get("image_path")
47
  question.raw_text = row.get("raw_text") or row.get("stem")
48
- question.structured_data_json = json.dumps(row, ensure_ascii=False)
49
  question.parse_status = row.get("parse_status") or "approved"
50
  question.quality_score = row.get("quality_score") or 100
51
- question.quality_status = row.get("quality_status") or "seed"
52
  question.quality_issues = _json_text(row.get("quality_issues"), "[]")
53
  question.chunk_key = row.get("chunk_key")
54
  question.chunk_index = row.get("chunk_index")
55
- question.parser_version = row.get("parser_version") or "seed-v1"
56
  question.code = row.get("code")
57
  question.pairs = _json_text(row.get("pairs"), None)
58
  question.sequence = _json_text(row.get("sequence"), None)
@@ -64,21 +76,38 @@ def _refresh_seed_questions(db, rows: list[dict]) -> int:
64
  if os.getenv("CERT_STUDY_REFRESH_SEED", "1") == "0":
65
  return 0
66
 
 
67
  changed = 0
68
  for row in rows:
69
  question_number = row.get("question_number")
70
  if not question_number:
71
  continue
72
 
 
73
  question = (
74
  db.query(Question)
75
  .filter(Question.question_number == question_number)
 
76
  .order_by(Question.id.asc())
77
  .first()
78
  )
 
 
 
 
 
 
 
 
79
  if not question:
80
  question = Question()
81
  db.add(question)
 
 
 
 
 
 
82
 
83
  _apply_seed_row(question, row)
84
  changed += 1
 
2
 
3
  import json
4
  import os
5
+ import hashlib
6
  from pathlib import Path
7
 
8
  from sqlalchemy import func
 
14
  DEMO_DATA_DIR = Path(__file__).resolve().parent.parent / "demo_data"
15
  FULL_SEED_DATA_PATH = DEMO_DATA_DIR / "questions_seed.json"
16
  DEMO_DATA_PATH = DEMO_DATA_DIR / "demo_questions.json"
17
+ SEED_VERSION = "seed-v2"
18
+ SEED_QUALITY_STATUS = "seed"
19
+ SEED_SOURCES = {DEMO_SOURCE, "AZ-104 imported seed"}
20
 
21
 
22
  def _seed_data_path() -> Path:
 
34
  return fallback
35
 
36
 
37
+ def _seed_hash(row: dict) -> str:
38
+ payload = json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)
39
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
40
+
41
+
42
  def _apply_seed_row(question: Question, row: dict) -> None:
43
+ row_with_meta = dict(row)
44
+ row_with_meta["_seed_version"] = SEED_VERSION
45
+ row_with_meta["_seed_hash"] = _seed_hash(row)
46
  question.stem = row["stem"]
47
  question.answer = _json_text(row.get("answer"), None)
48
  question.explanation = row.get("explanation")
 
57
  question.source = row.get("source") or DEMO_SOURCE
58
  question.image_path = row.get("image_path")
59
  question.raw_text = row.get("raw_text") or row.get("stem")
60
+ question.structured_data_json = json.dumps(row_with_meta, ensure_ascii=False)
61
  question.parse_status = row.get("parse_status") or "approved"
62
  question.quality_score = row.get("quality_score") or 100
63
+ question.quality_status = row.get("quality_status") or SEED_QUALITY_STATUS
64
  question.quality_issues = _json_text(row.get("quality_issues"), "[]")
65
  question.chunk_key = row.get("chunk_key")
66
  question.chunk_index = row.get("chunk_index")
67
+ question.parser_version = row.get("parser_version") or SEED_VERSION
68
  question.code = row.get("code")
69
  question.pairs = _json_text(row.get("pairs"), None)
70
  question.sequence = _json_text(row.get("sequence"), None)
 
76
  if os.getenv("CERT_STUDY_REFRESH_SEED", "1") == "0":
77
  return 0
78
 
79
+ overwrite_reviewed = os.getenv("CERT_STUDY_OVERWRITE_REVIEWED_SEED", "0") == "1"
80
  changed = 0
81
  for row in rows:
82
  question_number = row.get("question_number")
83
  if not question_number:
84
  continue
85
 
86
+ source = row.get("source") or DEMO_SOURCE
87
  question = (
88
  db.query(Question)
89
  .filter(Question.question_number == question_number)
90
+ .filter(Question.source == source)
91
  .order_by(Question.id.asc())
92
  .first()
93
  )
94
+ if not question:
95
+ question = (
96
+ db.query(Question)
97
+ .filter(Question.question_number == question_number)
98
+ .filter(Question.quality_status == SEED_QUALITY_STATUS)
99
+ .order_by(Question.id.asc())
100
+ .first()
101
+ )
102
  if not question:
103
  question = Question()
104
  db.add(question)
105
+ elif (
106
+ not overwrite_reviewed
107
+ and question.quality_status != SEED_QUALITY_STATUS
108
+ and question.source not in SEED_SOURCES
109
+ ):
110
+ continue
111
 
112
  _apply_seed_row(question, row)
113
  changed += 1
cert_study_app/services/parse_quality_service.py CHANGED
@@ -7,8 +7,8 @@ from pathlib import Path
7
  from statistics import median
8
  from typing import Any
9
 
 
10
 
11
- VISUAL_TYPES = {"hotspot", "yes_no", "ordering", "table_choice", "matching"}
12
  ISSUE_WEIGHTS = {
13
  "missing_stem": 18,
14
  "short_stem": 8,
@@ -62,7 +62,7 @@ def build_parse_quality_report(
62
  stem = _text(item.get("stem") or item.get("question") or item.get("q_text"))
63
  answer = item.get("answer")
64
  options = _normal_options(item.get("options"))
65
- question_type = _text(item.get("question_type") or "mcq").lower()
66
  raw_fingerprints[_fingerprint(raw_text)].append(index)
67
  chunk_lengths.append(len(raw_text))
68
 
@@ -72,7 +72,7 @@ def build_parse_quality_report(
72
  _add_issue(issues_by_index, index, "short_stem", "๋ฌธ์ œ ๋ณธ๋ฌธ์ด ๋„ˆ๋ฌด ์งง์Šต๋‹ˆ๋‹ค.", length=len(stem))
73
  if not _has_answer(answer):
74
  _add_issue(issues_by_index, index, "missing_answer", "์ •๋‹ต์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.")
75
- if question_type not in VISUAL_TYPES and len(options) < 2:
76
  _add_issue(issues_by_index, index, "missing_options", "๊ฐ๊ด€์‹/์„ ํƒํ˜•์œผ๋กœ ๋ณด์ด์ง€๋งŒ ๋ณด๊ธฐ๊ฐ€ 2๊ฐœ ๋ฏธ๋งŒ์ž…๋‹ˆ๋‹ค.", option_count=len(options))
77
  if len(options) >= 2:
78
  expected_labels = [chr(ord("A") + offset) for offset in range(len(options))]
@@ -99,7 +99,7 @@ def build_parse_quality_report(
99
  _add_issue(issues_by_index, index, "answer_leaked_to_stem", "๋ณธ๋ฌธ์— ์ •๋‹ต ๋ผ์ธ์ด ์„ž์—ฌ ์žˆ์Šต๋‹ˆ๋‹ค.")
100
  if re.search(r"(?m)\n\s*\d{1,3}\s*[.)]\s+\S+", stem):
101
  _add_issue(issues_by_index, index, "embedded_next_question", "์ฒญํฌ ์•ˆ์— ๋‹ค์Œ ๋ฌธ์ œ ์‹œ์ž‘์ฒ˜๋Ÿผ ๋ณด์ด๋Š” ์ค„์ด ์žˆ์Šต๋‹ˆ๋‹ค.")
102
- if question_type in VISUAL_TYPES and item.get("image_path") and not Path(str(item.get("image_path"))).exists():
103
  _add_issue(issues_by_index, index, "image_missing", "์ด๋ฏธ์ง€ ๊ธฐ๋ฐ˜ ๋ฌธ์ œ์˜ ์›๋ฌธ ์ด๋ฏธ์ง€ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
104
 
105
  nonzero_numbers = [number for number in numbers if number is not None]
 
7
  from statistics import median
8
  from typing import Any
9
 
10
+ from cert_study_app.services.question_type_metadata_service import is_visual_question_type, normalize_question_type
11
 
 
12
  ISSUE_WEIGHTS = {
13
  "missing_stem": 18,
14
  "short_stem": 8,
 
62
  stem = _text(item.get("stem") or item.get("question") or item.get("q_text"))
63
  answer = item.get("answer")
64
  options = _normal_options(item.get("options"))
65
+ question_type = normalize_question_type(item.get("question_type") or "mcq")
66
  raw_fingerprints[_fingerprint(raw_text)].append(index)
67
  chunk_lengths.append(len(raw_text))
68
 
 
72
  _add_issue(issues_by_index, index, "short_stem", "๋ฌธ์ œ ๋ณธ๋ฌธ์ด ๋„ˆ๋ฌด ์งง์Šต๋‹ˆ๋‹ค.", length=len(stem))
73
  if not _has_answer(answer):
74
  _add_issue(issues_by_index, index, "missing_answer", "์ •๋‹ต์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.")
75
+ if not is_visual_question_type(question_type) and len(options) < 2:
76
  _add_issue(issues_by_index, index, "missing_options", "๊ฐ๊ด€์‹/์„ ํƒํ˜•์œผ๋กœ ๋ณด์ด์ง€๋งŒ ๋ณด๊ธฐ๊ฐ€ 2๊ฐœ ๋ฏธ๋งŒ์ž…๋‹ˆ๋‹ค.", option_count=len(options))
77
  if len(options) >= 2:
78
  expected_labels = [chr(ord("A") + offset) for offset in range(len(options))]
 
99
  _add_issue(issues_by_index, index, "answer_leaked_to_stem", "๋ณธ๋ฌธ์— ์ •๋‹ต ๋ผ์ธ์ด ์„ž์—ฌ ์žˆ์Šต๋‹ˆ๋‹ค.")
100
  if re.search(r"(?m)\n\s*\d{1,3}\s*[.)]\s+\S+", stem):
101
  _add_issue(issues_by_index, index, "embedded_next_question", "์ฒญํฌ ์•ˆ์— ๋‹ค์Œ ๋ฌธ์ œ ์‹œ์ž‘์ฒ˜๋Ÿผ ๋ณด์ด๋Š” ์ค„์ด ์žˆ์Šต๋‹ˆ๋‹ค.")
102
+ if is_visual_question_type(question_type) and item.get("image_path") and not Path(str(item.get("image_path"))).exists():
103
  _add_issue(issues_by_index, index, "image_missing", "์ด๋ฏธ์ง€ ๊ธฐ๋ฐ˜ ๋ฌธ์ œ์˜ ์›๋ฌธ ์ด๋ฏธ์ง€ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
104
 
105
  nonzero_numbers = [number for number in numbers if number is not None]
cert_study_app/services/question_review_service.py CHANGED
@@ -13,12 +13,15 @@ from cert_study_app.services.quiz_service import (
13
  yes_no_labels,
14
  )
15
  from cert_study_app.services.text_cleanup_service import clean_question_text
16
- from cert_study_app.services.question_type_metadata_service import type_metadata
 
 
 
 
17
  from cert_study_app.services.question_concept_service import apply_question_concept
18
 
19
 
20
  AUTO_APPROVE_THRESHOLD = 85
21
- VISUAL_TYPES = {"hotspot", "ordering", "matching", "table_choice"}
22
 
23
 
24
  def _answer_labels(answer: str) -> set[str]:
@@ -62,7 +65,7 @@ def _clean_options(options: list[str]) -> list[str]:
62
 
63
 
64
  def _looks_like_yes_no_hotspot(question_type: str, stem: str, answer: str, explanation: str, options: list[str]) -> bool:
65
- if (question_type or "").lower() not in {"hotspot", "table_choice"}:
66
  return False
67
  text = " ".join([stem or "", answer or "", explanation or "", " ".join(str(option) for option in options or [])])
68
  return bool(
@@ -81,13 +84,14 @@ def _visual_analysis(question: Question) -> dict:
81
 
82
 
83
  def _looks_like_box_dropdown(question_type: str, stem: str) -> bool:
84
- if (question_type or "").lower() not in {"hotspot", "table_choice"}:
85
  return False
86
  return bool(re.search(r"(๋‹ต๋ณ€\s*์˜์—ญ|๋“œ๋กญ๋‹ค์šด|์ ์ ˆํ•œ\s*์˜ต์…˜|๋‹ต๋ณ€ํ•˜๋ ค๋ฉด)", stem or ""))
87
 
88
 
89
  def _detect_answer_mode(question_type: str, stem: str, answer: str, explanation: str, options: list[str]) -> str:
90
- if _looks_like_yes_no_hotspot(question_type, stem, answer, explanation, options):
 
91
  return "yes_no_matrix"
92
  labels = choice_labels(answer or "")
93
  if len(labels) > 1:
@@ -101,7 +105,7 @@ def _detect_answer_mode(question_type: str, stem: str, answer: str, explanation:
101
  )
102
  if (
103
  not looks_like_standard_multi
104
- and (question_type or "").lower() in {"hotspot", "table_choice"}
105
  and re.search(r"(๊ฐ\s*๋ฆฌ์†Œ์Šค|๊ฐ\s*ํ•ญ๋ชฉ|๊ฐ\s*ํ–‰|๋‹ต๋ณ€\s*์˜์—ญ|๋“œ๋กญ๋‹ค์šด|์ ์ ˆํ•œ\s*์˜ต์…˜|(?:์ƒ์ž|Box)\s*1)", text, re.I)
106
  ):
107
  return "per_row_choice"
@@ -151,8 +155,8 @@ def analyze_question(question: Question) -> dict:
151
  else:
152
  issues.append("์ •๋‹ต์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.")
153
 
154
- question_type = (question.question_type or "").strip()
155
- if question_type.lower() in {"unparsed", "unknown", ""} and re.search(r"(๋“œ๋ž˜๊ทธ|๋Œ์–ด|drag|drop)", stem, re.I):
156
  question_type = "matching"
157
  answer_mode = _detect_answer_mode(question_type, stem, answer, question.explanation or "", options)
158
  yn_labels = yes_no_labels(answer) or yes_no_labels(question.explanation or "")
@@ -160,7 +164,7 @@ def analyze_question(question: Question) -> dict:
160
  has_answer_areas = bool(visual.get("answer_areas"))
161
  has_statements = bool(visual.get("statements"))
162
  needs_answer_areas = _looks_like_box_dropdown(question_type, stem)
163
- needs_statements = answer_mode == "yes_no_matrix" or (question_type or "").lower() == "yes_no"
164
  if has_answer_areas:
165
  answer_mode = "per_row_choice"
166
  if has_statements:
@@ -205,7 +209,7 @@ def analyze_question(question: Question) -> dict:
205
  if needs_answer_areas and not has_answer_areas:
206
  issues.append("์ƒ์ž/๋“œ๋กญ๋‹ค์šดํ˜• ๋ฌธ์ œ๋กœ ๋ณด์ด์ง€๋งŒ answer_areas ๊ตฌ์กฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. qwen ์ด๋ฏธ์ง€ ๋ถ„์„์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
207
 
208
- if question_type and question_type.lower() not in {"unparsed", "unknown"}:
209
  score += 5
210
  else:
211
  issues.append("๋ฌธ์ œ ์œ ํ˜•์„ ํ™•์ •ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.")
@@ -215,11 +219,11 @@ def analyze_question(question: Question) -> dict:
215
  or (answer_mode == "yes_no_matrix" and bool(yn_labels) and (has_statements or not needs_statements))
216
  or (needs_answer_areas and has_answer_areas)
217
  )
218
- if (question_type.lower() in VISUAL_TYPES or answer_mode == "yes_no_matrix") and image_ok and not playable_input_ready:
219
  issues.append("ํ’€์ด ํ™”๋ฉด์—์„œ ์‚ฌ์šฉํ•  ๋ณด๊ธฐ/์ƒ์ž/์ง„์ˆ  ๊ตฌ์กฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์ฃผ๊ด€์‹ ์ž…๋ ฅ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋ฉด ์•ˆ ๋ฉ๋‹ˆ๋‹ค.")
220
 
221
  visual_playable = (
222
- (question_type.lower() in VISUAL_TYPES or answer_mode == "yes_no_matrix")
223
  and image_ok
224
  and has_answer_text
225
  and len(stem) >= 20
@@ -232,7 +236,7 @@ def analyze_question(question: Question) -> dict:
232
  image_ok
233
  and len(stem) >= 20
234
  and (
235
- question_type.lower() in VISUAL_TYPES
236
  or answer_mode in {"yes_no_matrix", "per_row_choice"}
237
  )
238
  and not playable_input_ready
 
13
  yes_no_labels,
14
  )
15
  from cert_study_app.services.text_cleanup_service import clean_question_text
16
+ from cert_study_app.services.question_type_metadata_service import (
17
+ is_visual_question_type,
18
+ normalize_question_type,
19
+ type_metadata,
20
+ )
21
  from cert_study_app.services.question_concept_service import apply_question_concept
22
 
23
 
24
  AUTO_APPROVE_THRESHOLD = 85
 
25
 
26
 
27
  def _answer_labels(answer: str) -> set[str]:
 
65
 
66
 
67
  def _looks_like_yes_no_hotspot(question_type: str, stem: str, answer: str, explanation: str, options: list[str]) -> bool:
68
+ if normalize_question_type(question_type) not in {"hotspot", "table_choice", "yes_no"}:
69
  return False
70
  text = " ".join([stem or "", answer or "", explanation or "", " ".join(str(option) for option in options or [])])
71
  return bool(
 
84
 
85
 
86
  def _looks_like_box_dropdown(question_type: str, stem: str) -> bool:
87
+ if normalize_question_type(question_type) not in {"hotspot", "table_choice", "matching"}:
88
  return False
89
  return bool(re.search(r"(๋‹ต๋ณ€\s*์˜์—ญ|๋“œ๋กญ๋‹ค์šด|์ ์ ˆํ•œ\s*์˜ต์…˜|๋‹ต๋ณ€ํ•˜๋ ค๋ฉด)", stem or ""))
90
 
91
 
92
  def _detect_answer_mode(question_type: str, stem: str, answer: str, explanation: str, options: list[str]) -> str:
93
+ normalized_type = normalize_question_type(question_type)
94
+ if normalized_type == "yes_no" or _looks_like_yes_no_hotspot(question_type, stem, answer, explanation, options):
95
  return "yes_no_matrix"
96
  labels = choice_labels(answer or "")
97
  if len(labels) > 1:
 
105
  )
106
  if (
107
  not looks_like_standard_multi
108
+ and normalized_type in {"hotspot", "table_choice", "matching"}
109
  and re.search(r"(๊ฐ\s*๋ฆฌ์†Œ์Šค|๊ฐ\s*ํ•ญ๋ชฉ|๊ฐ\s*ํ–‰|๋‹ต๋ณ€\s*์˜์—ญ|๋“œ๋กญ๋‹ค์šด|์ ์ ˆํ•œ\s*์˜ต์…˜|(?:์ƒ์ž|Box)\s*1)", text, re.I)
110
  ):
111
  return "per_row_choice"
 
155
  else:
156
  issues.append("์ •๋‹ต์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.")
157
 
158
+ question_type = normalize_question_type(question.question_type)
159
+ if question_type in {"unparsed", "unknown", ""} and re.search(r"(๋“œ๋ž˜๊ทธ|๋Œ์–ด|drag|drop)", stem, re.I):
160
  question_type = "matching"
161
  answer_mode = _detect_answer_mode(question_type, stem, answer, question.explanation or "", options)
162
  yn_labels = yes_no_labels(answer) or yes_no_labels(question.explanation or "")
 
164
  has_answer_areas = bool(visual.get("answer_areas"))
165
  has_statements = bool(visual.get("statements"))
166
  needs_answer_areas = _looks_like_box_dropdown(question_type, stem)
167
+ needs_statements = answer_mode == "yes_no_matrix" or question_type == "yes_no"
168
  if has_answer_areas:
169
  answer_mode = "per_row_choice"
170
  if has_statements:
 
209
  if needs_answer_areas and not has_answer_areas:
210
  issues.append("์ƒ์ž/๋“œ๋กญ๋‹ค์šดํ˜• ๋ฌธ์ œ๋กœ ๋ณด์ด์ง€๋งŒ answer_areas ๊ตฌ์กฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. qwen ์ด๋ฏธ์ง€ ๋ถ„์„์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
211
 
212
+ if question_type and question_type not in {"unparsed", "unknown"}:
213
  score += 5
214
  else:
215
  issues.append("๋ฌธ์ œ ์œ ํ˜•์„ ํ™•์ •ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.")
 
219
  or (answer_mode == "yes_no_matrix" and bool(yn_labels) and (has_statements or not needs_statements))
220
  or (needs_answer_areas and has_answer_areas)
221
  )
222
+ if (is_visual_question_type(question_type) or answer_mode == "yes_no_matrix") and image_ok and not playable_input_ready:
223
  issues.append("ํ’€์ด ํ™”๋ฉด์—์„œ ์‚ฌ์šฉํ•  ๋ณด๊ธฐ/์ƒ์ž/์ง„์ˆ  ๊ตฌ์กฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์ฃผ๊ด€์‹ ์ž…๋ ฅ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋ฉด ์•ˆ ๋ฉ๋‹ˆ๋‹ค.")
224
 
225
  visual_playable = (
226
+ (is_visual_question_type(question_type) or answer_mode == "yes_no_matrix")
227
  and image_ok
228
  and has_answer_text
229
  and len(stem) >= 20
 
236
  image_ok
237
  and len(stem) >= 20
238
  and (
239
+ is_visual_question_type(question_type)
240
  or answer_mode in {"yes_no_matrix", "per_row_choice"}
241
  )
242
  and not playable_input_ready
cert_study_app/services/question_type_metadata_service.py CHANGED
@@ -8,6 +8,41 @@ from sqlalchemy import func
8
  from cert_study_app.models import Question
9
 
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  TYPE_METADATA = {
12
  "mcq": {
13
  "label": "๋‹จ์ผ ์„ ํƒ",
@@ -87,7 +122,7 @@ STATUS_LABELS = {
87
 
88
 
89
  def type_metadata(question_type: Optional[str], answer_mode: Optional[str] = None) -> dict:
90
- key = (question_type or "unparsed").lower()
91
  metadata = dict(TYPE_METADATA.get(key, TYPE_METADATA["unparsed"]))
92
  metadata["type"] = key
93
  metadata["answer_mode"] = answer_mode or "single_choice"
@@ -106,6 +141,43 @@ def type_metadata(question_type: Optional[str], answer_mode: Optional[str] = Non
106
  return metadata
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  def status_label(status: Optional[str]) -> str:
110
  return STATUS_LABELS.get(status or "none", status or "๋ฏธ์ •")
111
 
 
8
  from cert_study_app.models import Question
9
 
10
 
11
+ TYPE_ALIASES = {
12
+ "single_choice": "mcq",
13
+ "single select": "mcq",
14
+ "single-select": "mcq",
15
+ "multiple choice": "mcq",
16
+ "mcq": "mcq",
17
+ "multi": "multi_select",
18
+ "multiple_response": "multi_select",
19
+ "multiple-response": "multi_select",
20
+ "multiple select": "multi_select",
21
+ "multi_select": "multi_select",
22
+ "yes/no": "yes_no",
23
+ "yes no": "yes_no",
24
+ "yes_no": "yes_no",
25
+ "true_false": "yes_no",
26
+ "true/false": "yes_no",
27
+ "true/false (in-context)": "yes_no",
28
+ "hotspot (true/false)": "yes_no",
29
+ "hotspot true/false": "yes_no",
30
+ "hotspot": "hotspot",
31
+ "hotspot (drag and drop)": "matching",
32
+ "drag and drop": "matching",
33
+ "drag-and-drop": "matching",
34
+ "matching": "matching",
35
+ "ordering": "ordering",
36
+ "order": "ordering",
37
+ "sequence": "ordering",
38
+ "table": "table_choice",
39
+ "table_choice": "table_choice",
40
+ "table choice": "table_choice",
41
+ "case_study": "case_study",
42
+ "case study": "case_study",
43
+ }
44
+
45
+
46
  TYPE_METADATA = {
47
  "mcq": {
48
  "label": "๋‹จ์ผ ์„ ํƒ",
 
122
 
123
 
124
  def type_metadata(question_type: Optional[str], answer_mode: Optional[str] = None) -> dict:
125
+ key = normalize_question_type(question_type)
126
  metadata = dict(TYPE_METADATA.get(key, TYPE_METADATA["unparsed"]))
127
  metadata["type"] = key
128
  metadata["answer_mode"] = answer_mode or "single_choice"
 
141
  return metadata
142
 
143
 
144
+ def normalize_question_type(question_type: Optional[str]) -> str:
145
+ raw = str(question_type or "").strip().lower()
146
+ if not raw:
147
+ return "unparsed"
148
+ compact = raw.replace("-", " ").replace("_", " ")
149
+ compact = " ".join(compact.split())
150
+ if raw in TYPE_ALIASES:
151
+ return TYPE_ALIASES[raw]
152
+ if compact in TYPE_ALIASES:
153
+ return TYPE_ALIASES[compact]
154
+ if "true/false" in raw or "yes/no" in raw or "yes no" in compact:
155
+ return "yes_no"
156
+ if "drag" in raw or "matching" in raw:
157
+ return "matching"
158
+ if "hotspot" in raw:
159
+ return "hotspot"
160
+ if "table" in raw:
161
+ return "table_choice"
162
+ if "order" in raw or "sequence" in raw:
163
+ return "ordering"
164
+ if "multi" in raw:
165
+ return "multi_select"
166
+ if "case" in raw:
167
+ return "case_study"
168
+ if "mcq" in raw or "choice" in raw:
169
+ return "mcq"
170
+ return raw if raw in TYPE_METADATA else "unparsed"
171
+
172
+
173
+ def is_visual_question_type(question_type: Optional[str]) -> bool:
174
+ return normalize_question_type(question_type) in {"yes_no", "hotspot", "table_choice", "matching", "ordering"}
175
+
176
+
177
+ def is_ordered_answer_type(question_type: Optional[str]) -> bool:
178
+ return normalize_question_type(question_type) in {"ordering", "table_choice", "hotspot", "matching", "yes_no"}
179
+
180
+
181
  def status_label(status: Optional[str]) -> str:
182
  return STATUS_LABELS.get(status or "none", status or "๋ฏธ์ •")
183
 
cert_study_app/services/quiz_service.py CHANGED
@@ -1,236 +1,25 @@
1
  from cert_study_app.config import DEFAULT_USER
2
  from cert_study_app.models import Question
3
  from cert_study_app.repositories.question_repository import QuestionRepository
4
- from cert_study_app.services.text_cleanup_service import clean_inline_text
5
  from cert_study_app.services.question_concept_service import concept_label
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from typing import Optional
7
  import json
8
  import re
9
 
10
 
11
- def normalize_options(raw):
12
- if not raw:
13
- return []
14
- try:
15
- if isinstance(raw, dict):
16
- keyed = []
17
- for k, v in sorted(raw.items(), key=lambda kv: kv[0]):
18
- value = clean_inline_text(v)
19
- if re.match(r"^[A-Za-z1-9][\.\)]\s+", value):
20
- keyed.append(value)
21
- else:
22
- keyed.append(f"{k}. {value}")
23
- return split_embedded_options(keyed)
24
- if isinstance(raw, list):
25
- return split_embedded_options(raw)
26
- except Exception:
27
- pass
28
- return []
29
-
30
-
31
- def split_embedded_options(options: list[str]) -> list[str]:
32
- split = []
33
- expected = "A"
34
- for option in options:
35
- text = clean_inline_text(option)
36
- if not text:
37
- continue
38
- parts = list(re.finditer(r"(?<![A-Za-z0-9])([A-Z])[\.\)]\s+", text))
39
- if len(parts) <= 1:
40
- split.append(text)
41
- continue
42
- for index, match in enumerate(parts):
43
- end = parts[index + 1].start() if index + 1 < len(parts) else len(text)
44
- part = text[match.start() : end].strip()
45
- label = match.group(1).upper()
46
- if label >= expected:
47
- split.append(part)
48
- expected = chr(ord(label) + 1)
49
- return split
50
-
51
-
52
- def extract_options_from_stem(stem: str) -> list[str]:
53
- lines = [line.strip() for line in (stem or "").splitlines() if line.strip()]
54
- options = []
55
- current_key = None
56
- current_parts = []
57
- expected = "A"
58
-
59
- def flush():
60
- if current_key and current_parts:
61
- text = " ".join(current_parts).strip()
62
- if text:
63
- options.append(f"{current_key}. {text}")
64
-
65
- for line in lines:
66
- if re.match(r"^(answer|์ •๋‹ต|explanation|reference)\b", line, re.I):
67
- flush()
68
- current_key = None
69
- current_parts = []
70
- break
71
-
72
- marker = None
73
- body = ""
74
- exact = re.match(r"^([A-Z])[\.\)]?$", line, re.I)
75
- inline = re.match(r"^([A-Z])[\.\)]?\s+(.+)$", line, re.I)
76
- if exact:
77
- marker = exact.group(1).upper()
78
- elif inline:
79
- marker = inline.group(1).upper()
80
- body = inline.group(2).strip()
81
-
82
- if marker and marker >= expected:
83
- flush()
84
- current_key = marker
85
- current_parts = [body] if body else []
86
- expected = chr(ord(marker) + 1)
87
- elif current_key:
88
- current_parts.append(line)
89
-
90
- flush()
91
- return options if len(options) >= 2 else []
92
-
93
-
94
- def extract_answer_from_stem(stem: str) -> str:
95
- match = re.search(r"(?:Answer|์ •๋‹ต)\s*:?\s*([A-Z1-9])", stem or "", re.I)
96
- if not match:
97
- return ""
98
- value = match.group(1).upper()
99
- if value.isdigit():
100
- return chr(ord("A") + int(value) - 1)
101
- return value
102
-
103
-
104
- def option_label(value: str) -> str:
105
- value = str(value or "").strip().upper()
106
- if value.isdigit():
107
- return chr(ord("A") + int(value) - 1)
108
- match = re.match(r"^([A-Z])(?:[\s\.,\)]|$)", value)
109
- return match.group(1) if match else value
110
-
111
-
112
- def choice_labels(value: str) -> list[str]:
113
- text = str(value or "").strip().upper()
114
- if not text:
115
- return []
116
- if re.fullmatch(r"[A-Z]{2,26}", text):
117
- return list(text)
118
- tokens = re.findall(r"\b[A-Z]\b|\b[1-9]\b", text)
119
- return [option_label(token) for token in tokens]
120
-
121
-
122
- def normalize_choice_answer(value: str, ordered: bool = False) -> str:
123
- yn = yes_no_labels(value)
124
- if yn:
125
- return ",".join(yn)
126
- labels = choice_labels(value)
127
- if not labels:
128
- return option_label(value)
129
- if ordered:
130
- return ",".join(labels)
131
- return ",".join(sorted(set(labels)))
132
-
133
-
134
- def yes_no_labels(value: str) -> list[str]:
135
- if isinstance(value, (list, dict)):
136
- raw_value = value
137
- else:
138
- raw_value = None
139
- try:
140
- raw_value = json.loads(value) if isinstance(value, str) else None
141
- except Exception:
142
- raw_value = None
143
-
144
- if isinstance(raw_value, list):
145
- values = [
146
- item.get("value") or item.get("answer") or item.get("selected_answer")
147
- for item in raw_value
148
- if isinstance(item, dict)
149
- ]
150
- labels = yes_no_labels(",".join(str(item) for item in values if item))
151
- if labels:
152
- return labels
153
- elif isinstance(raw_value, dict):
154
- labels = yes_no_labels(",".join(str(item) for item in raw_value.values()))
155
- if labels:
156
- return labels
157
-
158
- text = str(value or "").strip().lower()
159
- if not text:
160
- return []
161
- if re.fullmatch(r"[yn](?:\s*,\s*[yn])+", text, re.I):
162
- return [token.upper() for token in re.findall(r"[yn]", text, re.I)]
163
- tokens = re.findall(r"(?<![๊ฐ€-ํžฃ])์˜ˆ(?![๊ฐ€-ํžฃ])|์•„๋‹ˆ์˜ค|์•„๋‹ˆ์š”|\byes\b|\bno\b", text, re.I)
164
- labels = []
165
- for token in tokens:
166
- lowered = token.lower()
167
- labels.append("Y" if token == "์˜ˆ" or lowered == "yes" else "N")
168
- return labels if len(labels) >= 2 else []
169
-
170
-
171
- def structured_answer_values(value) -> list[str]:
172
- if isinstance(value, (list, dict)):
173
- raw_value = value
174
- else:
175
- try:
176
- raw_value = json.loads(value) if isinstance(value, str) else None
177
- except Exception:
178
- raw_value = None
179
-
180
- if isinstance(raw_value, list):
181
- values = [
182
- item.get("value") or item.get("answer") or item.get("selected_answer")
183
- for item in raw_value
184
- if isinstance(item, dict)
185
- ]
186
- elif isinstance(raw_value, dict):
187
- values = list(raw_value.values())
188
- else:
189
- return []
190
- return [str(item).strip() for item in values if str(item).strip()]
191
-
192
-
193
- def option_text(options: list[str], label: str) -> str:
194
- label = option_label(label)
195
- for index, option in enumerate(options, 1):
196
- text = str(option).strip()
197
- keys = {str(index), chr(ord("A") + index - 1)}
198
- match = re.match(r"^([A-Z1-9])[\.\)]\s+(.+)$", text, re.I)
199
- if match:
200
- keys.add(option_label(match.group(1)))
201
- if match.group(1).isdigit():
202
- keys.add(match.group(1))
203
- if label in keys:
204
- return text
205
- return ""
206
-
207
-
208
- def _option_body(option: str) -> str:
209
- text = str(option or "").strip()
210
- match = re.match(r"^([A-Z1-9])[\.\)]\s+(.+)$", text, re.I)
211
- return (match.group(2) if match and match.group(2) else text).strip()
212
-
213
-
214
- def option_labels_from_texts(options: list[str], values, allow_duplicates: bool = False) -> list[str]:
215
- if not values:
216
- return []
217
- if isinstance(values, str):
218
- values = [values]
219
- labels = []
220
- for value in values:
221
- target = re.sub(r"\s+", " ", str(value or "").strip()).lower()
222
- if not target:
223
- continue
224
- for index, option in enumerate(options, 1):
225
- body = re.sub(r"\s+", " ", _option_body(option)).lower()
226
- if body and (target == body or target in body or body in target):
227
- label = chr(ord("A") + index - 1)
228
- if allow_duplicates or label not in labels:
229
- labels.append(label)
230
- break
231
- return labels
232
-
233
-
234
  def visual_analysis(question) -> dict:
235
  try:
236
  return json.loads(question.visual_analysis_json) if question.visual_analysis_json else {}
@@ -246,20 +35,17 @@ def clean_explanation(raw: str) -> str:
246
 
247
  def effective_answer(question) -> str:
248
  answer = question.answer or ""
249
- question_type = (question.question_type or "").lower()
250
  options = normalize_options(question.get_options()) or extract_options_from_stem(question.stem)
251
  structured_values = structured_answer_values(answer)
252
  if structured_values:
253
  structured_yn = yes_no_labels(",".join(structured_values))
254
  return ",".join(structured_yn or structured_values)
255
 
256
- is_structured_visual = any(
257
- keyword in question_type
258
- for keyword in ["hotspot", "table_choice", "matching", "ordering", "yes_no", "true/false"]
259
- ) and "in-context" not in question_type
260
  if is_structured_visual:
261
  answer_labels = yes_no_labels(answer)
262
- if answer_labels and "true/false" in question_type:
263
  return ",".join(answer_labels)
264
  analysis = visual_analysis(question)
265
  statements = analysis.get("statements")
@@ -294,7 +80,7 @@ def effective_answer(question) -> str:
294
 
295
  def build_tutor_explanation(question, chosen: str, answer: str) -> str:
296
  options = normalize_options(question.get_options()) or extract_options_from_stem(question.stem)
297
- ordered = (question.question_type or "").lower() in {"ordering", "table_choice", "hotspot"}
298
  answer_yn = yes_no_labels(answer)
299
  chosen_yn = yes_no_labels(chosen)
300
  answer_labels = choice_labels(answer)
@@ -306,7 +92,7 @@ def build_tutor_explanation(question, chosen: str, answer: str) -> str:
306
  answer_option_lines = [line for line in answer_option_lines if line]
307
  chosen_option_lines = [option_text(options, label) for label in chosen_labels]
308
  chosen_option_lines = [line for line in chosen_option_lines if line]
309
- correct = normalize_choice_answer(chosen, ordered=ordered) == normalize_choice_answer(answer, ordered=ordered)
310
 
311
  lines = [f"#### ์ •๋‹ต", f"**{answer_label}**"]
312
  for answer_option in answer_option_lines:
@@ -470,8 +256,8 @@ class QuizService:
470
  answer = effective_answer(question)
471
  if not answer or answer in {"[]", "{}", "None"}:
472
  answer = extract_answer_from_stem(question.stem)
473
- ordered = (question.question_type or "").lower() in {"ordering", "table_choice", "hotspot"}
474
- correct = normalize_choice_answer(chosen, ordered=ordered) == normalize_choice_answer(answer, ordered=ordered)
475
  self.repo.add_attempt(
476
  user_id=user_id,
477
  question_id=question.id,
 
1
  from cert_study_app.config import DEFAULT_USER
2
  from cert_study_app.models import Question
3
  from cert_study_app.repositories.question_repository import QuestionRepository
 
4
  from cert_study_app.services.question_concept_service import concept_label
5
+ from cert_study_app.services.answer_normalizer import (
6
+ choice_labels,
7
+ evaluate_answer,
8
+ extract_answer_from_stem,
9
+ extract_options_from_stem,
10
+ normalize_options,
11
+ option_label,
12
+ option_labels_from_texts,
13
+ option_text,
14
+ structured_answer_values,
15
+ yes_no_labels,
16
+ )
17
+ from cert_study_app.services.question_type_metadata_service import is_ordered_answer_type, normalize_question_type
18
  from typing import Optional
19
  import json
20
  import re
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  def visual_analysis(question) -> dict:
24
  try:
25
  return json.loads(question.visual_analysis_json) if question.visual_analysis_json else {}
 
35
 
36
  def effective_answer(question) -> str:
37
  answer = question.answer or ""
38
+ question_type = normalize_question_type(question.question_type)
39
  options = normalize_options(question.get_options()) or extract_options_from_stem(question.stem)
40
  structured_values = structured_answer_values(answer)
41
  if structured_values:
42
  structured_yn = yes_no_labels(",".join(structured_values))
43
  return ",".join(structured_yn or structured_values)
44
 
45
+ is_structured_visual = question_type in {"hotspot", "table_choice", "matching", "ordering", "yes_no"}
 
 
 
46
  if is_structured_visual:
47
  answer_labels = yes_no_labels(answer)
48
+ if answer_labels and question_type == "yes_no":
49
  return ",".join(answer_labels)
50
  analysis = visual_analysis(question)
51
  statements = analysis.get("statements")
 
80
 
81
  def build_tutor_explanation(question, chosen: str, answer: str) -> str:
82
  options = normalize_options(question.get_options()) or extract_options_from_stem(question.stem)
83
+ ordered = is_ordered_answer_type(question.question_type)
84
  answer_yn = yes_no_labels(answer)
85
  chosen_yn = yes_no_labels(chosen)
86
  answer_labels = choice_labels(answer)
 
92
  answer_option_lines = [line for line in answer_option_lines if line]
93
  chosen_option_lines = [option_text(options, label) for label in chosen_labels]
94
  chosen_option_lines = [line for line in chosen_option_lines if line]
95
+ correct = evaluate_answer(chosen, answer, ordered=ordered).correct
96
 
97
  lines = [f"#### ์ •๋‹ต", f"**{answer_label}**"]
98
  for answer_option in answer_option_lines:
 
256
  answer = effective_answer(question)
257
  if not answer or answer in {"[]", "{}", "None"}:
258
  answer = extract_answer_from_stem(question.stem)
259
+ evaluation = evaluate_answer(chosen, answer, ordered=is_ordered_answer_type(question.question_type))
260
+ correct = evaluation.correct
261
  self.repo.add_attempt(
262
  user_id=user_id,
263
  question_id=question.id,
docker-compose.yml CHANGED
@@ -3,15 +3,15 @@ services:
3
  image: postgres:16-alpine
4
  container_name: cert-study-postgres
5
  environment:
6
- POSTGRES_DB: cert_study
7
- POSTGRES_USER: cert_study
8
- POSTGRES_PASSWORD: cert_study
9
  ports:
10
  - "5432:5432"
11
  volumes:
12
  - postgres_data:/var/lib/postgresql/data
13
  healthcheck:
14
- test: ["CMD-SHELL", "pg_isready -U cert_study -d cert_study"]
15
  interval: 5s
16
  timeout: 5s
17
  retries: 20
@@ -30,16 +30,16 @@ services:
30
  STREAMLIT_SERVER_ADDRESS: 0.0.0.0
31
  STREAMLIT_SERVER_PORT: 8501
32
  STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
33
- CERT_STUDY_LLM_PROVIDER: ollama
34
- OLLAMA_MODEL: qwen2.5:14b
35
- OLLAMA_FAST_MODEL: qwen3.5:9b
36
- OLLAMA_VISUAL_MODEL: qwen3-vl:8b-instruct-q4_K_M
37
- EMBEDDING_MODEL: BAAI/bge-m3
38
- OLLAMA_BASE_URL: http://host.docker.internal:11434
39
- DATABASE_URL: postgresql+pg8000://cert_study:cert_study@postgres:5432/cert_study
40
- AIRFLOW_API_BASE_URL: http://host.docker.internal:8080
41
- AIRFLOW_API_USERNAME: admin
42
- AIRFLOW_API_PASSWORD: admin
43
  volumes:
44
  - ./data:/app/data
45
  - ./chroma_db:/app/chroma_db
@@ -57,20 +57,23 @@ services:
57
  entrypoint: /bin/bash
58
  command:
59
  - -c
60
- - airflow db migrate && airflow users create --username admin --password admin --firstname Cert --lastname Study --role Admin --email admin@example.com || true
61
  environment: &airflow-env
62
  AIRFLOW__CORE__EXECUTOR: SequentialExecutor
63
  AIRFLOW__CORE__LOAD_EXAMPLES: "false"
64
  AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION: "false"
65
  AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: sqlite:////opt/airflow_db/airflow.db
66
  AIRFLOW__API__AUTH_BACKENDS: airflow.api.auth.backend.basic_auth
67
- CERT_STUDY_LLM_PROVIDER: ollama
68
- OLLAMA_MODEL: qwen2.5:14b
69
- OLLAMA_FAST_MODEL: qwen3.5:9b
70
- OLLAMA_VISUAL_MODEL: qwen3-vl:8b-instruct-q4_K_M
71
- EMBEDDING_MODEL: BAAI/bge-m3
72
- OLLAMA_BASE_URL: http://host.docker.internal:11434
73
- DATABASE_URL: postgresql+pg8000://cert_study:cert_study@postgres:5432/cert_study
 
 
 
74
  PYTHONPATH: /opt/airflow
75
  volumes: &airflow-volumes
76
  - ./dags:/opt/airflow/dags
 
3
  image: postgres:16-alpine
4
  container_name: cert-study-postgres
5
  environment:
6
+ POSTGRES_DB: ${POSTGRES_DB:-cert_study}
7
+ POSTGRES_USER: ${POSTGRES_USER:-cert_study}
8
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cert_study}
9
  ports:
10
  - "5432:5432"
11
  volumes:
12
  - postgres_data:/var/lib/postgresql/data
13
  healthcheck:
14
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cert_study} -d ${POSTGRES_DB:-cert_study}"]
15
  interval: 5s
16
  timeout: 5s
17
  retries: 20
 
30
  STREAMLIT_SERVER_ADDRESS: 0.0.0.0
31
  STREAMLIT_SERVER_PORT: 8501
32
  STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
33
+ CERT_STUDY_LLM_PROVIDER: ${CERT_STUDY_LLM_PROVIDER:-ollama}
34
+ OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen2.5:14b}
35
+ OLLAMA_FAST_MODEL: ${OLLAMA_FAST_MODEL:-qwen3.5:9b}
36
+ OLLAMA_VISUAL_MODEL: ${OLLAMA_VISUAL_MODEL:-qwen3-vl:8b-instruct-q4_K_M}
37
+ EMBEDDING_MODEL: ${EMBEDDING_MODEL:-BAAI/bge-m3}
38
+ OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
39
+ DATABASE_URL: ${DATABASE_URL:-postgresql+pg8000://cert_study:cert_study@postgres:5432/cert_study}
40
+ AIRFLOW_API_BASE_URL: ${AIRFLOW_API_BASE_URL:-http://host.docker.internal:8080}
41
+ AIRFLOW_API_USERNAME: ${AIRFLOW_API_USERNAME:-admin}
42
+ AIRFLOW_API_PASSWORD: ${AIRFLOW_API_PASSWORD:-admin}
43
  volumes:
44
  - ./data:/app/data
45
  - ./chroma_db:/app/chroma_db
 
57
  entrypoint: /bin/bash
58
  command:
59
  - -c
60
+ - airflow db migrate && airflow users create --username "$${AIRFLOW_ADMIN_USERNAME:-admin}" --password "$${AIRFLOW_ADMIN_PASSWORD:-admin}" --firstname Cert --lastname Study --role Admin --email "$${AIRFLOW_ADMIN_EMAIL:-admin@example.com}" || true
61
  environment: &airflow-env
62
  AIRFLOW__CORE__EXECUTOR: SequentialExecutor
63
  AIRFLOW__CORE__LOAD_EXAMPLES: "false"
64
  AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION: "false"
65
  AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: sqlite:////opt/airflow_db/airflow.db
66
  AIRFLOW__API__AUTH_BACKENDS: airflow.api.auth.backend.basic_auth
67
+ CERT_STUDY_LLM_PROVIDER: ${CERT_STUDY_LLM_PROVIDER:-ollama}
68
+ OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen2.5:14b}
69
+ OLLAMA_FAST_MODEL: ${OLLAMA_FAST_MODEL:-qwen3.5:9b}
70
+ OLLAMA_VISUAL_MODEL: ${OLLAMA_VISUAL_MODEL:-qwen3-vl:8b-instruct-q4_K_M}
71
+ EMBEDDING_MODEL: ${EMBEDDING_MODEL:-BAAI/bge-m3}
72
+ OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
73
+ DATABASE_URL: ${DATABASE_URL:-postgresql+pg8000://cert_study:cert_study@postgres:5432/cert_study}
74
+ AIRFLOW_ADMIN_USERNAME: ${AIRFLOW_ADMIN_USERNAME:-admin}
75
+ AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-admin}
76
+ AIRFLOW_ADMIN_EMAIL: ${AIRFLOW_ADMIN_EMAIL:-admin@example.com}
77
  PYTHONPATH: /opt/airflow
78
  volumes: &airflow-volumes
79
  - ./dags:/opt/airflow/dags
docs/02_architecture.md CHANGED
@@ -15,6 +15,8 @@
15
  | Streamlit | ์‚ฌ์šฉ์ž ํ™”๋ฉด ์ œ๊ณต, PDF ์—…๋กœ๋“œ, ๋ฌธ์ œํ’€์ด UI |
16
  | PostgreSQL | ๋ฌธ์ œ, ๋ณด๊ธฐ, ์ •๋‹ต, ํ•ด์„ค, ์‚ฌ์šฉ์ž ํ’€์ด ๊ธฐ๋ก ์ €์žฅ |
17
  | Chroma | ๋ฌธ์ œ ์ž„๋ฒ ๋”ฉ ์ €์žฅ, ์œ ์‚ฌ ๋ฌธ์ œ ๊ฒ€์ƒ‰ |
 
 
18
  | Airflow | PDF ์ฒ˜๋ฆฌ, ํŒŒ์‹ฑ, ์ž„๋ฒ ๋”ฉ, ์ €์žฅ ์ž‘์—…์˜ ํŒŒ์ดํ”„๋ผ์ธ ๊ด€๋ฆฌ |
19
  | Docker / Docker Compose | ๊ฐœ๋ฐœ ๋ฐ ์‹คํ–‰ ํ™˜๊ฒฝ ํ‘œ์ค€ํ™” |
20
  | Oracle Cloud Ubuntu | ํ–ฅํ›„ ์„œ๋น„์Šค ๋ฐฐํฌ ๋Œ€์ƒ ์„œ๋ฒ„ |
@@ -51,6 +53,10 @@
51
 
52
  ํ˜„์žฌ ์ฝ”๋“œ ๊ธฐ์ค€์œผ๋กœ๋Š” Streamlit ํ™”๋ฉด์ด `QuizService`, `IngestionJobService`, `QuestionVectorStore` ๊ฐ™์€ ์„œ๋น„์Šค ๊ณ„์ธต์„ ํ˜ธ์ถœํ•˜๊ณ , PDF ์ฒ˜๋ฆฌ์ฒ˜๋Ÿผ ์‹œ๊ฐ„์ด ์˜ค๋ž˜ ๊ฑธ๋ฆฌ๋Š” ์ž‘์—…์€ Airflow DAG์™€ PDF Ingestion Graph๋ฅผ ํ†ตํ•ด ๋‹จ๊ณ„๋ณ„๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค.
53
 
 
 
 
 
54
  ## 6. PDF ์ฒ˜๋ฆฌ ํ๋ฆ„
55
 
56
  PDF ์—…๋กœ๋“œ ์ดํ›„์—๋Š” ํŒŒ์‹ฑ ๊ณผ์ •์„ ํ†ตํ•ด ๋ฌธ์ œ๋ฅผ ์ถ”์ถœํ•˜๊ณ , ๋ฌธ์ œ/๋ณด๊ธฐ/์ •๋‹ต/ํ•ด์„ค ํ˜•ํƒœ๋กœ ๊ตฌ์กฐํ™”ํ•ฉ๋‹ˆ๋‹ค.
@@ -73,6 +79,8 @@ PDF ์—…๋กœ๋“œ ์ดํ›„์—๋Š” ํŒŒ์‹ฑ ๊ณผ์ •์„ ํ†ตํ•ด ๋ฌธ์ œ๋ฅผ ์ถ”์ถœํ•˜๊ณ , ๋ฌธ
73
  8. ์ตœ์ข… ๊ฒ€์ฆ
74
  9. ์ฒ˜๋ฆฌ ์™„๋ฃŒ
75
 
 
 
76
  ## 7. ๋ฐ์ดํ„ฐ ์ €์žฅ ๊ตฌ์กฐ
77
 
78
  ์ด ์‹œ์Šคํ…œ์—์„œ๋Š” PostgreSQL๊ณผ Chroma๋ฅผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
@@ -136,6 +144,7 @@ Chroma๋Š” ๋ณ„๋„ ์ปจํ…Œ์ด๋„ˆ๋ผ๊ธฐ๋ณด๋‹ค ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์˜ `chroma_db` ๋””
136
  ํ˜„์žฌ ํ”„๋กœ์ ํŠธ๋Š” ๊ฐœ๋ฐœ ์ค‘์ด๋ฉฐ, ๋‹ค์Œ ๊ธฐ๋Šฅ์„ ์ค‘์‹ฌ์œผ๋กœ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.
137
 
138
  - Streamlit ๊ธฐ๋ฐ˜ ๋ฌธ์ œํ’€์ด ํ™”๋ฉด
 
139
  - PDF ์—…๋กœ๋“œ ๋ฐ ํŒŒ์‹ฑ ๊ตฌ์กฐ
140
  - PostgreSQL ๊ธฐ๋ฐ˜ ๋ฌธ์ œ ๋ฐ์ดํ„ฐ ์ €์žฅ
141
  - Chroma ๊ธฐ๋ฐ˜ ์œ ์‚ฌ ๋ฌธ์ œ ๊ฒ€์ƒ‰ ๊ตฌ์กฐ
@@ -152,6 +161,7 @@ Chroma๋Š” ๋ณ„๋„ ์ปจํ…Œ์ด๋„ˆ๋ผ๊ธฐ๋ณด๋‹ค ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์˜ `chroma_db` ๋””
152
  - ์˜ค๋‹ต๋…ธํŠธ ์ž๋™ ์ƒ์„ฑ
153
  - ๋ถ๋งˆํฌ ๊ธฐ๋Šฅ
154
  - ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๋ฌธ์ œ ๋ถ„๋ฅ˜
 
155
  - LLM ๊ธฐ๋ฐ˜ ํ•ด์„ค ๋ณด์กฐ ์ƒ์„ฑ
156
  - ์œ ์‚ฌ ๋ฌธ์ œ ์ถ”์ฒœ
157
  - PDF ํŒŒ์‹ฑ ์‹คํŒจ ์ผ€์ด์Šค ๊ด€๋ฆฌ
 
15
  | Streamlit | ์‚ฌ์šฉ์ž ํ™”๋ฉด ์ œ๊ณต, PDF ์—…๋กœ๋“œ, ๋ฌธ์ œํ’€์ด UI |
16
  | PostgreSQL | ๋ฌธ์ œ, ๋ณด๊ธฐ, ์ •๋‹ต, ํ•ด์„ค, ์‚ฌ์šฉ์ž ํ’€์ด ๊ธฐ๋ก ์ €์žฅ |
17
  | Chroma | ๋ฌธ์ œ ์ž„๋ฒ ๋”ฉ ์ €์žฅ, ์œ ์‚ฌ ๋ฌธ์ œ ๊ฒ€์ƒ‰ |
18
+ | Question Type Metadata | ๋‹ค์–‘ํ•œ ๋ฌธ์ œ ์œ ํ˜• ์ด๋ฆ„์„ ๋‚ด๋ถ€ ํ‘œ์ค€ ์œ ํ˜•์œผ๋กœ ์ •๋ฆฌ |
19
+ | Answer Normalizer | ๊ฐ๊ด€์‹, ๋ณต์ˆ˜ ์„ ํƒ, Yes/No, ์ˆœ์„œํ˜• ๋‹ต์•ˆ์„ ๋น„๊ต ๊ฐ€๋Šฅํ•œ ํ˜•ํƒœ๋กœ ์ •๊ทœํ™” |
20
  | Airflow | PDF ์ฒ˜๋ฆฌ, ํŒŒ์‹ฑ, ์ž„๋ฒ ๋”ฉ, ์ €์žฅ ์ž‘์—…์˜ ํŒŒ์ดํ”„๋ผ์ธ ๊ด€๋ฆฌ |
21
  | Docker / Docker Compose | ๊ฐœ๋ฐœ ๋ฐ ์‹คํ–‰ ํ™˜๊ฒฝ ํ‘œ์ค€ํ™” |
22
  | Oracle Cloud Ubuntu | ํ–ฅํ›„ ์„œ๋น„์Šค ๋ฐฐํฌ ๋Œ€์ƒ ์„œ๋ฒ„ |
 
53
 
54
  ํ˜„์žฌ ์ฝ”๋“œ ๊ธฐ์ค€์œผ๋กœ๋Š” Streamlit ํ™”๋ฉด์ด `QuizService`, `IngestionJobService`, `QuestionVectorStore` ๊ฐ™์€ ์„œ๋น„์Šค ๊ณ„์ธต์„ ํ˜ธ์ถœํ•˜๊ณ , PDF ์ฒ˜๋ฆฌ์ฒ˜๋Ÿผ ์‹œ๊ฐ„์ด ์˜ค๋ž˜ ๊ฑธ๋ฆฌ๋Š” ์ž‘์—…์€ Airflow DAG์™€ PDF Ingestion Graph๋ฅผ ํ†ตํ•ด ๋‹จ๊ณ„๋ณ„๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค.
55
 
56
+ ๋ฌธ์ œํ’€์ด ํ๋ฆ„์—์„œ๋Š” ๋ฌธ์ œ ์œ ํ˜•์ด ์—ฌ๋Ÿฌ ์ด๋ฆ„์œผ๋กœ ๋“ค์–ด์˜ฌ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— `question_type_metadata_service.py`์—์„œ ๋จผ์ € ํ‘œ์ค€ ์œ ํ˜•์œผ๋กœ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด `Hotspot (True/False)`๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ Yes/No ์ง„์ˆ ํ˜•์œผ๋กœ, `Hotspot (Drag and Drop)`์€ ๋งค์นญํ˜•์œผ๋กœ ๋‹ค๋ฃน๋‹ˆ๋‹ค.
57
+
58
+ ์ •๋‹ต ๋น„๊ต๋Š” `answer_normalizer.py`๋ฅผ ํ†ตํ•ด ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์ด ๊ณ„์ธต์€ `Y,N,Y`, `์˜ˆ/์•„๋‹ˆ์˜ค`, ๋ณต์ˆ˜ ์„ ํƒ, ์ˆœ์„œ๊ฐ€ ์ค‘์š”ํ•œ ๋‹ต์•ˆ์„ ๋น„๊ต ๊ฐ€๋Šฅํ•œ ๋ฌธ์ž์—ด๋กœ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•ด ๋‘๋ฉด ํ™”๋ฉด UI์™€ ์ฑ„์  ๋กœ์ง์ด ์„œ๋กœ ๋‹ค๋ฅธ ๊ธฐ์ค€์œผ๋กœ ๋™์ž‘ํ•˜๋Š” ๋ฌธ์ œ๋ฅผ ์ค„์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
59
+
60
  ## 6. PDF ์ฒ˜๋ฆฌ ํ๋ฆ„
61
 
62
  PDF ์—…๋กœ๋“œ ์ดํ›„์—๋Š” ํŒŒ์‹ฑ ๊ณผ์ •์„ ํ†ตํ•ด ๋ฌธ์ œ๋ฅผ ์ถ”์ถœํ•˜๊ณ , ๋ฌธ์ œ/๋ณด๊ธฐ/์ •๋‹ต/ํ•ด์„ค ํ˜•ํƒœ๋กœ ๊ตฌ์กฐํ™”ํ•ฉ๋‹ˆ๋‹ค.
 
79
  8. ์ตœ์ข… ๊ฒ€์ฆ
80
  9. ์ฒ˜๋ฆฌ ์™„๋ฃŒ
81
 
82
+ ํŒŒ์‹ฑ ํ’ˆ์งˆ ๊ฒ€์ฆ์—์„œ๋„ ๋ฌธ์ œ ์œ ํ˜• ํ‘œ์ค€ํ™” ๊ฒฐ๊ณผ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์‹œ๊ฐํ˜• ๋ฌธ์ œ๋ฅผ ์ผ๋ฐ˜ ๊ฐ๊ด€์‹์œผ๋กœ ์ž˜๋ชป ๋ณด๊ณ  `๋ณด๊ธฐ๊ฐ€ ๋ถ€์กฑํ•˜๋‹ค`๊ณ  ํŒ๋‹จํ•˜๋Š” ์ผ์„ ์ค„์ด๊ธฐ ์œ„ํ•œ ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค.
83
+
84
  ## 7. ๋ฐ์ดํ„ฐ ์ €์žฅ ๊ตฌ์กฐ
85
 
86
  ์ด ์‹œ์Šคํ…œ์—์„œ๋Š” PostgreSQL๊ณผ Chroma๋ฅผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
 
144
  ํ˜„์žฌ ํ”„๋กœ์ ํŠธ๋Š” ๊ฐœ๋ฐœ ์ค‘์ด๋ฉฐ, ๋‹ค์Œ ๊ธฐ๋Šฅ์„ ์ค‘์‹ฌ์œผ๋กœ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.
145
 
146
  - Streamlit ๊ธฐ๋ฐ˜ ๋ฌธ์ œํ’€์ด ํ™”๋ฉด
147
+ - ๋ฌธ์ œ ์œ ํ˜• ํ‘œ์ค€ํ™” ๋ฐ ์ •๋‹ต ์ •๊ทœํ™” ๊ตฌ์กฐ
148
  - PDF ์—…๋กœ๋“œ ๋ฐ ํŒŒ์‹ฑ ๊ตฌ์กฐ
149
  - PostgreSQL ๊ธฐ๋ฐ˜ ๋ฌธ์ œ ๋ฐ์ดํ„ฐ ์ €์žฅ
150
  - Chroma ๊ธฐ๋ฐ˜ ์œ ์‚ฌ ๋ฌธ์ œ ๊ฒ€์ƒ‰ ๊ตฌ์กฐ
 
161
  - ์˜ค๋‹ต๋…ธํŠธ ์ž๋™ ์ƒ์„ฑ
162
  - ๋ถ๋งˆํฌ ๊ธฐ๋Šฅ
163
  - ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๋ฌธ์ œ ๋ถ„๋ฅ˜
164
+ - ๋ฌธ์ œ ์œ ํ˜•๋ณ„ ํ…Œ์ŠคํŠธ ๋ฒ”์œ„ ํ™•๋Œ€
165
  - LLM ๊ธฐ๋ฐ˜ ํ•ด์„ค ๋ณด์กฐ ์ƒ์„ฑ
166
  - ์œ ์‚ฌ ๋ฌธ์ œ ์ถ”์ฒœ
167
  - PDF ํŒŒ์‹ฑ ์‹คํŒจ ์ผ€์ด์Šค ๊ด€๋ฆฌ
docs/05_deployment.md CHANGED
@@ -15,6 +15,7 @@
15
  ## ๋กœ์ปฌ ์‹คํ–‰
16
 
17
  ```bash
 
18
  docker compose up --build
19
  ```
20
 
@@ -73,7 +74,20 @@ Space์—๋Š” ์•„๋ž˜ ๋Ÿฐํƒ€์ž„ ๋ฐ์ดํ„ฐ๋Š” ์˜ฌ๋ฆฌ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
73
 
74
  ๋Œ€์‹  `cert_study_app/demo_data/questions_seed.json`๊ณผ `static/question_assets/`๋ฅผ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. ์•ฑ ์‹œ์ž‘ ์‹œ DB๊ฐ€ ๋น„์–ด ์žˆ๊ฑฐ๋‚˜ ๊ธฐ์กด seed๊ฐ€ ์˜ค๋ž˜๋œ ๊ฒฝ์šฐ seed ๊ธฐ์ค€์œผ๋กœ ๋ฌธ์ œ๋ฅผ ์ถ”๊ฐ€/๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค.
75
 
76
- ํ˜„์žฌ seed์—๋Š” 321๋ฌธํ•ญ, ๊ณตํ†ต ์ง€๋ฌธ, ์›๋ฌธ ์ด๋ฏธ์ง€, Yes/No ์ง„์ˆ ํ˜• ๋ฌธ์ œ ๊ตฌ์กฐ๊ฐ€ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  ## ํ™˜๊ฒฝ ๋ณ€์ˆ˜
79
 
@@ -96,6 +110,19 @@ OLLAMA_BASE_URL=http://host.docker.internal:11434
96
 
97
  ์šด์˜ ๋ฐฐํฌ์—์„œ๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ API ์ •๋ณด๊ฐ€ ์ฝ”๋“œ์— ์ง์ ‘ ๋“ค์–ด๊ฐ€์ง€ ์•Š๋„๋ก `.env` ๋˜๋Š” ์„œ๋ฒ„ ๋น„๋ฐ€๊ฐ’ ๊ด€๋ฆฌ ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  ## Oracle Cloud Ubuntu์— ๋ฐฐํฌํ•˜๋ ค๋Š” ์ด์œ 
100
 
101
  ํ–ฅํ›„ ๋ฐฐํฌ ๋Œ€์ƒ์€ Oracle Cloud Ubuntu ์„œ๋ฒ„๋ฅผ ๊ณ„ํšํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.
@@ -120,14 +147,14 @@ OLLAMA_BASE_URL=http://host.docker.internal:11434
120
 
121
  ## ๋ฐฐํฌ ์ „์— ์ •๋ฆฌํ•  ๊ฒƒ
122
 
123
- - ์šด์˜์šฉ ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ถ„๋ฆฌ
124
- - `.env.example` ์ •๋ฆฌ
125
  - PostgreSQL ๋ฐ์ดํ„ฐ ๋ฐฑ์—… ๋ฐฉ์‹
126
  - Chroma ์ธ๋ฑ์Šค ์žฌ์ƒ์„ฑ ๋ฐฉ์‹
127
  - Airflow ๊ด€๋ฆฌ์ž ๊ณ„์ • ์„ค์ •
128
  - ์—…๋กœ๋“œ ํŒŒ์ผ ์ €์žฅ ์œ„์น˜
129
  - ๋กœ๊ทธ ๊ด€๋ฆฌ
130
  - HTTPS ์ ์šฉ ์—ฌ๋ถ€
 
131
 
132
  ## ํ˜„์žฌ ์ƒํƒœ
133
 
 
15
  ## ๋กœ์ปฌ ์‹คํ–‰
16
 
17
  ```bash
18
+ cp .env.example .env
19
  docker compose up --build
20
  ```
21
 
 
74
 
75
  ๋Œ€์‹  `cert_study_app/demo_data/questions_seed.json`๊ณผ `static/question_assets/`๋ฅผ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. ์•ฑ ์‹œ์ž‘ ์‹œ DB๊ฐ€ ๋น„์–ด ์žˆ๊ฑฐ๋‚˜ ๊ธฐ์กด seed๊ฐ€ ์˜ค๋ž˜๋œ ๊ฒฝ์šฐ seed ๊ธฐ์ค€์œผ๋กœ ๋ฌธ์ œ๋ฅผ ์ถ”๊ฐ€/๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค.
76
 
77
+ ํ˜„์žฌ seed์—๋Š” 321๋ฌธํ•ญ, ๊ณตํ†ต ์ง€๋ฌธ ํฌํ•จ 36๋ฌธํ•ญ, ์›๋ฌธ ์ด๋ฏธ์ง€, Yes/No ์ง„์ˆ ํ˜• ๋ฌธ์ œ ๊ตฌ์กฐ๊ฐ€ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.
78
+
79
+ seed๋ฅผ ๋‹ค์‹œ ๋งŒ๋“ค๋ฉด ๊ฒ€์ฆ ๋ฆฌํฌํŠธ๋„ ํ•จ๊ป˜ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.
80
+
81
+ ```bash
82
+ python scripts/export_hf_seed.py
83
+ ```
84
+
85
+ ์ƒ์„ฑ ํŒŒ์ผ:
86
+
87
+ - `cert_study_app/demo_data/questions_seed.json`
88
+ - `cert_study_app/demo_data/questions_seed.report.json`
89
+
90
+ ๋ฆฌํฌํŠธ์—์„œ๋Š” ์ „์ฒด ๋ฌธํ•ญ ์ˆ˜, ๊ณตํ†ต ์ง€๋ฌธ ํฌํ•จ ๋ฌธํ•ญ ์ˆ˜, ์œ ํ˜•๋ณ„ ๊ฐœ์ˆ˜, ์ค‘๋ณต ๋ฒˆํ˜ธ, ์ •๋‹ต ๋ˆ„๋ฝ, ์ด๋ฏธ์ง€ ๋ˆ„๋ฝ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
91
 
92
  ## ํ™˜๊ฒฝ ๋ณ€์ˆ˜
93
 
 
110
 
111
  ์šด์˜ ๋ฐฐํฌ์—์„œ๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ API ์ •๋ณด๊ฐ€ ์ฝ”๋“œ์— ์ง์ ‘ ๋“ค์–ด๊ฐ€์ง€ ์•Š๋„๋ก `.env` ๋˜๋Š” ์„œ๋ฒ„ ๋น„๋ฐ€๊ฐ’ ๊ด€๋ฆฌ ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
112
 
113
+ ์ฒ˜์Œ ์‹คํ–‰ํ•  ๋•Œ๋Š” ์˜ˆ์‹œ ํŒŒ์ผ์„ ๋ณต์‚ฌํ•ด์„œ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.
114
+
115
+ ```bash
116
+ cp .env.example .env
117
+ ```
118
+
119
+ ์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” ์ตœ์†Œํ•œ ์•„๋ž˜ ๊ฐ’์€ ๊ธฐ๋ณธ๊ฐ’์—์„œ ๋ฐ”๊พธ๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.
120
+
121
+ - `POSTGRES_PASSWORD`
122
+ - `DATABASE_URL`
123
+ - `AIRFLOW_API_PASSWORD`
124
+ - `AIRFLOW_ADMIN_PASSWORD`
125
+
126
  ## Oracle Cloud Ubuntu์— ๋ฐฐํฌํ•˜๋ ค๋Š” ์ด์œ 
127
 
128
  ํ–ฅํ›„ ๋ฐฐํฌ ๋Œ€์ƒ์€ Oracle Cloud Ubuntu ์„œ๋ฒ„๋ฅผ ๊ณ„ํšํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.
 
147
 
148
  ## ๋ฐฐํฌ ์ „์— ์ •๋ฆฌํ•  ๊ฒƒ
149
 
150
+ - ์šด์˜์šฉ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ `.env`๋กœ ๋ถ„๋ฆฌ
 
151
  - PostgreSQL ๋ฐ์ดํ„ฐ ๋ฐฑ์—… ๋ฐฉ์‹
152
  - Chroma ์ธ๋ฑ์Šค ์žฌ์ƒ์„ฑ ๋ฐฉ์‹
153
  - Airflow ๊ด€๋ฆฌ์ž ๊ณ„์ • ์„ค์ •
154
  - ์—…๋กœ๋“œ ํŒŒ์ผ ์ €์žฅ ์œ„์น˜
155
  - ๋กœ๊ทธ ๊ด€๋ฆฌ
156
  - HTTPS ์ ์šฉ ์—ฌ๋ถ€
157
+ - `python scripts/check_quality.py` ํ†ต๊ณผ ์—ฌ๋ถ€
158
 
159
  ## ํ˜„์žฌ ์ƒํƒœ
160
 
docs/06_decision_log.md CHANGED
@@ -170,3 +170,49 @@ PDF ํŒŒ์‹ฑ ํ›„ ๋ฐ”๋กœ ์ €์žฅํ•˜์ง€ ์•Š๊ณ , ํŒŒ์‹ฑ ํ’ˆ์งˆ ๊ฒ€์ฆ๊ณผ Quality Gate
170
  ### ๊ฒฐ๊ณผ
171
 
172
  ํŒŒ์ดํ”„๋ผ์ธ ๋‹จ๊ณ„๋Š” ๋Š˜์–ด๋‚˜์ง€๋งŒ, ๋ฌธ์ œ ๋ฐ์ดํ„ฐ์˜ ์‹ ๋ขฐ๋„๋ฅผ ๋†’์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์•ž์œผ๋กœ๋Š” ํ’ˆ์งˆ ๋ฆฌํฌํŠธ๋ฅผ UI์—์„œ ๋” ๋ณด๊ธฐ ์‰ฝ๊ฒŒ ๋งŒ๋“œ๋Š” ์ž‘์—…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  ### ๊ฒฐ๊ณผ
171
 
172
  ํŒŒ์ดํ”„๋ผ์ธ ๋‹จ๊ณ„๋Š” ๋Š˜์–ด๋‚˜์ง€๋งŒ, ๋ฌธ์ œ ๋ฐ์ดํ„ฐ์˜ ์‹ ๋ขฐ๋„๋ฅผ ๋†’์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์•ž์œผ๋กœ๋Š” ํ’ˆ์งˆ ๋ฆฌํฌํŠธ๋ฅผ UI์—์„œ ๋” ๋ณด๊ธฐ ์‰ฝ๊ฒŒ ๋งŒ๋“œ๋Š” ์ž‘์—…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.
173
+
174
+ ## ADR-008. ๋ฌธ์ œ ์œ ํ˜•๊ณผ ์ •๋‹ต ๋น„๊ต๋Š” ๋ณ„๋„ ๊ณ„์ธต์—์„œ ํ‘œ์ค€ํ™”ํ•œ๋‹ค
175
+
176
+ - ์ƒํƒœ: ์ฑ„ํƒ
177
+ - ๋‚ ์งœ: 2026-06-07
178
+
179
+ ### ๋ฐฐ๊ฒฝ
180
+
181
+ ์ž๊ฒฉ์ฆ ๋ฌธ์ œ์—๋Š” ๋‹จ์ผ ์„ ํƒ, ๋ณต์ˆ˜ ์„ ํƒ, Yes/No ์ง„์ˆ ํ˜•, ํ•ซ์ŠคํŒŸ, ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ, ๊ณตํ†ต ์ง€๋ฌธ ๋ฌธ์ œ๊ฐ€ ์„ž์—ฌ ์žˆ์Šต๋‹ˆ๋‹ค. ์›๋ณธ ๋ฐ์ดํ„ฐ์˜ `question_type`๋„ `Hotspot (True/False)`์ฒ˜๋Ÿผ ๋‹ค์–‘ํ•œ ๋ฌธ์ž์—ด๋กœ ๋“ค์–ด์˜ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
182
+
183
+ ### ๊ฒฐ์ •
184
+
185
+ ๋ฌธ์ œ ์œ ํ˜•์€ `question_type_metadata_service.py`์—์„œ ๋‚ด๋ถ€ ํ‘œ์ค€ ์œ ํ˜•์œผ๋กœ ๋ณ€ํ™˜ํ•˜๊ณ , ์ •๋‹ต ๋น„๊ต๋Š” `answer_normalizer.py`์—์„œ ์ฒ˜๋ฆฌํ•œ๋‹ค.
186
+
187
+ ### ์ด์œ 
188
+
189
+ - ํ™”๋ฉด, ๊ฒ€์ˆ˜, ์ฑ„์  ๋กœ์ง์ด ์„œ๋กœ ๋‹ค๋ฅธ ๊ธฐ์ค€์„ ์“ฐ๋Š” ๋ฌธ์ œ๋ฅผ ์ค„์ผ ์ˆ˜ ์žˆ๋‹ค.
190
+ - ์ƒˆ ๋ฌธ์ œ ์œ ํ˜• alias๊ฐ€ ๋“ค์–ด์™€๋„ ํ•œ ๊ณณ์—์„œ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋‹ค.
191
+ - Yes/No์™€ ์ˆœ์„œํ˜• ๋‹ต์•ˆ์ฒ˜๋Ÿผ ์ผ๋ฐ˜ ๊ฐ๊ด€์‹๊ณผ ๋‹ค๋ฅธ ์ฑ„์  ๋ฐฉ์‹์„ ๋ถ„๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค.
192
+
193
+ ### ๊ฒฐ๊ณผ
194
+
195
+ ๋ฌธ์ œํ’€์ด ํ™”๋ฉด๊ณผ ์ž๋™ ๊ฒ€์ˆ˜ ์„œ๋น„์Šค๊ฐ€ ๊ฐ™์€ ๋ฌธ์ œ ์œ ํ˜• ๊ธฐ์ค€์„ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค. ์•ž์œผ๋กœ ์ƒˆ ์œ ํ˜•์„ ์ถ”๊ฐ€ํ•  ๋•Œ๋Š” ๋จผ์ € ํ‘œ์ค€ ์œ ํ˜• ๋ณ€ํ™˜๊ณผ ํ…Œ์ŠคํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
196
+
197
+ ## ADR-009. ๋ฐฐํฌ ์ „ ํ’ˆ์งˆ ๊ฒ€์‚ฌ๋Š” ์Šคํฌ๋ฆฝํŠธ์™€ GitHub Actions๋กœ ์ž๋™ํ™”ํ•œ๋‹ค
198
+
199
+ - ์ƒํƒœ: ์ฑ„ํƒ
200
+ - ๋‚ ์งœ: 2026-06-07
201
+
202
+ ### ๋ฐฐ๊ฒฝ
203
+
204
+ Hugging Face Space์—๋Š” DB ์ „์ฒด๊ฐ€ ์•„๋‹ˆ๋ผ seed ํŒŒ์ผ๊ณผ ์ •์  ์ด๋ฏธ์ง€๋ฅผ ์˜ฌ๋ฆฝ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ seed ๊ตฌ์กฐ, ๋ฌธ์ œ ์œ ํ˜• alias, ์ •๋‹ต ์ •๊ทœํ™”๊ฐ€ ๊นจ์ง€๋ฉด ๋ฐฐํฌ ํ›„ ๋ฌธ์ œํ’€์ด๊ฐ€ ๋ฐ”๋กœ ์˜ํ–ฅ์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.
205
+
206
+ ### ๊ฒฐ์ •
207
+
208
+ `scripts/check_quality.py`๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , GitHub Actions์—์„œ ํ•ต์‹ฌ ํ…Œ์ŠคํŠธ์™€ ๋ฌธ๋ฒ• ๊ฒ€์‚ฌ๋ฅผ ์‹คํ–‰ํ•œ๋‹ค.
209
+
210
+ ### ์ด์œ 
211
+
212
+ - push ์ „์— ๋กœ์ปฌ์—์„œ ๊ฐ™์€ ๊ฒ€์‚ฌ๋ฅผ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค.
213
+ - GitHub์—์„œ๋„ ์ตœ์†Œ ํ’ˆ์งˆ ๊ฒ€์‚ฌ๋ฅผ ์ž๋™์œผ๋กœ ๋‚จ๊ธธ ์ˆ˜ ์žˆ๋‹ค.
214
+ - ๋ฌด๊ฑฐ์šด OCR/LLM ์˜์กด์„ฑ์„ ์„ค์น˜ํ•˜์ง€ ์•Š๊ณ  ํ•ต์‹ฌ ๋„๋ฉ”์ธ ๋กœ์ง๋งŒ ๋น ๋ฅด๊ฒŒ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ๋‹ค.
215
+
216
+ ### ๊ฒฐ๊ณผ
217
+
218
+ ํ˜„์žฌ ์ž๋™ ๊ฒ€์‚ฌ๋Š” ๋ฌธ์ œ ์œ ํ˜•/์ •๋‹ต/seed ๋ฆฌํฌํŠธ ์ค‘์‹ฌ์ž…๋‹ˆ๋‹ค. ์ดํ›„ PDF ํŒŒ์‹ฑ๊ณผ DB ์ €์žฅ ํ…Œ์ŠคํŠธ๊นŒ์ง€ ์ ์ง„์ ์œผ๋กœ ๋„“ํž ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
docs/07_troubleshooting.md CHANGED
@@ -212,6 +212,33 @@ Yes/No ์ง„์ˆ ํ˜• ๋ฌธ์ œ๋Š” ์ผ๋ฐ˜ ๊ฐ๊ด€์‹๊ณผ ๋‹ค๋ฅด๊ฒŒ ์ง„์ˆ ๋ณ„ `์˜ˆ / ์•„๋‹ˆ
212
  - ์ •๋‹ต์ด `Y,N,Y` ๋˜๋Š” `[{key, value}]` ํ˜•ํƒœ๋กœ ์ •๊ทœํ™”๋˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
213
  - `1-A / 1-B / 2-A / 2-B` ํ˜•์‹์€ ํ•ญ๋ชฉ๋ณ„ ์„ ํƒ UI๋กœ ํ‘œ์‹œ๋˜์–ด์•ผ ํ•œ๋‹ค.
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  ## Chroma ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์ด์ƒํ•  ๋•Œ
216
 
217
  ### ์ฆ์ƒ
 
212
  - ์ •๋‹ต์ด `Y,N,Y` ๋˜๋Š” `[{key, value}]` ํ˜•ํƒœ๋กœ ์ •๊ทœํ™”๋˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
213
  - `1-A / 1-B / 2-A / 2-B` ํ˜•์‹์€ ํ•ญ๋ชฉ๋ณ„ ์„ ํƒ UI๋กœ ํ‘œ์‹œ๋˜์–ด์•ผ ํ•œ๋‹ค.
214
 
215
+ ### ํ™•์ธ ๋ช…๋ น
216
+
217
+ ๋ฌธ์ œ ์œ ํ˜• alias์™€ ์ •๋‹ต ์ •๊ทœํ™” ํ…Œ์ŠคํŠธ๋ฅผ ๋จผ์ € ์‹คํ–‰ํ•œ๋‹ค.
218
+
219
+ ```bash
220
+ python -m pytest tests/test_answer_normalizer.py tests/test_question_type_metadata_service.py
221
+ ```
222
+
223
+ ### ํ•ด๊ฒฐ ๋ฐฉํ–ฅ
224
+
225
+ - ์ƒˆ ๋ฌธ์ œ ์œ ํ˜• ๋ฌธ์ž์—ด์ด ์ถ”๊ฐ€๋˜๋ฉด `question_type_metadata_service.py`์˜ alias์— ๋จผ์ € ๋“ฑ๋กํ•œ๋‹ค.
226
+ - ์ฑ„์ ์ด ์ด์ƒํ•˜๋ฉด ํ™”๋ฉด ์ฝ”๋“œ๋ณด๋‹ค `answer_normalizer.py`์—์„œ `chosen`๊ณผ `answer`๊ฐ€ ๊ฐ™์€ ๊ธฐ์ค€์œผ๋กœ ์ •๊ทœํ™”๋˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
227
+ - Yes/No๋Š” ์ผ๋ฐ˜ ๊ฐ๊ด€์‹ A/B/C๊ฐ€ ์•„๋‹ˆ๋ผ `Y,N,Y`์ฒ˜๋Ÿผ ํ–‰๋ณ„ ๋‹ต์•ˆ์œผ๋กœ ๋น„๊ตํ•˜๋Š” ๊ฒƒ์ด ๊ธฐ์ค€์ด๋‹ค.
228
+
229
+ ## ๋ชจ๋ฐ”์ผ ํ™”๋ฉด์ด ๋ณต์žกํ•˜๊ฒŒ ๋ณด์ผ ๋•Œ
230
+
231
+ ### ์ฆ์ƒ
232
+
233
+ ํœด๋Œ€ํฐ์—์„œ ๋ฉ”๋‰ด๊ฐ€ ๋„ˆ๋ฌด ๋งŽ๊ฑฐ๋‚˜, ๋‹ต์•ˆ ์„ ํƒ ์˜์—ญ์ด ์ž‘์•„ ๋ณด์ž…๋‹ˆ๋‹ค.
234
+
235
+ ### ํ•ด๊ฒฐ ๋ฐฉํ–ฅ
236
+
237
+ - ํ™ˆ ํ™”๋ฉด์€ ๋ฌธ์ œํ’€์ด, ์˜ค๋‹ต/๋ณต์Šต, ๊ฐœ๋… ์ •๋ฆฌ ๊ฐ™์€ ํ•™์Šต ๊ธฐ๋Šฅ์„ ๋จผ์ € ๋ณด์—ฌ์ค€๋‹ค.
238
+ - PDF ์—…๋กœ๋“œ, ์ฒ˜๋ฆฌ ํ˜„ํ™ฉ, AI ์ƒ‰์ธ์€ `์—…๋กœ๋“œ/๊ด€๋ฆฌ` ์˜์—ญ์œผ๋กœ ๋ฌถ๋Š”๋‹ค.
239
+ - ๋‹ต์•ˆ ์„ ํƒ UI๋Š” ์ตœ์†Œ 44px ์ด์ƒ์˜ ํ„ฐ์น˜ ์˜์—ญ์„ ์œ ์ง€ํ•œ๋‹ค.
240
+ - ๋ฌธ์ œ ์œ ํ˜•๋ณ„๋กœ radio, checkbox, selectbox, Yes/No matrix๋ฅผ ๊ตฌ๋ถ„ํ•ด์„œ ์‚ฌ์šฉํ•œ๋‹ค.
241
+
242
  ## Chroma ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์ด์ƒํ•  ๋•Œ
243
 
244
  ### ์ฆ์ƒ
docs/08_publish_automation.md CHANGED
@@ -71,10 +71,19 @@ python scripts/export_hf_seed.py
71
  ์ƒ์„ฑ ๊ฒฐ๊ณผ:
72
 
73
  - `cert_study_app/demo_data/questions_seed.json`
 
74
  - `static/question_assets/`
75
 
76
  `dump.pdf` ์ „์ฒด๋‚˜ `data/images/` ์ „์ฒด๋ฅผ ์˜ฌ๋ฆฌ์ง€ ์•Š๊ณ , ๋ฌธ์ œํ’€์ด์™€ ์›๋ฌธ ํ™•์ธ์— ํ•„์š”ํ•œ ์ฐธ์กฐ ์ด๋ฏธ์ง€๋งŒ ๋ณต์‚ฌํ•ฉ๋‹ˆ๋‹ค. ํ˜„์žฌ seed์—๋Š” 321๋ฌธํ•ญ, ๊ณตํ†ต ์ง€๋ฌธ, ์›๋ฌธ ์ด๋ฏธ์ง€, Yes/No ์ง„์ˆ ํ˜• ๋ฌธ์ œ ๊ตฌ์กฐ๊ฐ€ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.
77
 
 
 
 
 
 
 
 
 
78
  ์‹ค์ œ ๊ธฐ์ถœ/๋คํ”„ ์„ฑ๊ฒฉ์˜ ๋ฌธ์ œ๋ฅผ ํฌํ•จํ•˜๋Š” ๊ฒฝ์šฐ Hugging Face Space๋Š” private๋กœ ์šด์˜ํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
79
 
80
  ๋ฐ๋ชจ seed๋ฅผ ๋„๊ณ  ์‹ถ๋‹ค๋ฉด ์‹คํ–‰ ํ™˜๊ฒฝ์— ์•„๋ž˜ ๊ฐ’์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.
@@ -158,6 +167,7 @@ git push hf main
158
  ํ‰์†Œ์—๋Š” ์•„๋ž˜ ๋ฐฉ์‹์ด ๊ฐ€์žฅ ๋‹จ์ˆœํ•ฉ๋‹ˆ๋‹ค.
159
 
160
  ```bash
 
161
  git push origin main
162
  ```
163
 
 
71
  ์ƒ์„ฑ ๊ฒฐ๊ณผ:
72
 
73
  - `cert_study_app/demo_data/questions_seed.json`
74
+ - `cert_study_app/demo_data/questions_seed.report.json`
75
  - `static/question_assets/`
76
 
77
  `dump.pdf` ์ „์ฒด๋‚˜ `data/images/` ์ „์ฒด๋ฅผ ์˜ฌ๋ฆฌ์ง€ ์•Š๊ณ , ๋ฌธ์ œํ’€์ด์™€ ์›๋ฌธ ํ™•์ธ์— ํ•„์š”ํ•œ ์ฐธ์กฐ ์ด๋ฏธ์ง€๋งŒ ๋ณต์‚ฌํ•ฉ๋‹ˆ๋‹ค. ํ˜„์žฌ seed์—๋Š” 321๋ฌธํ•ญ, ๊ณตํ†ต ์ง€๋ฌธ, ์›๋ฌธ ์ด๋ฏธ์ง€, Yes/No ์ง„์ˆ ํ˜• ๋ฌธ์ œ ๊ตฌ์กฐ๊ฐ€ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.
78
 
79
+ push ์ „์— ํ•ต์‹ฌ ๊ฒ€์‚ฌ๋ฅผ ์‹คํ–‰ํ•˜๋ฉด ์œ ํ˜• ์ฒ˜๋ฆฌ๋‚˜ seed ๋ฆฌํฌํŠธ ์˜ค๋ฅ˜๋ฅผ ๋จผ์ € ์žก์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
80
+
81
+ ```bash
82
+ python scripts/check_quality.py
83
+ ```
84
+
85
+ `main` ๋ธŒ๋žœ์น˜์— pushํ•˜๋ฉด `Quality Check` GitHub Actions๊ฐ€ ๋จผ์ € ์‹คํ–‰๋˜๊ณ , ๊ฐ™์€ push ์ด๋ฒคํŠธ์—์„œ Hugging Face ๋™๊ธฐํ™” workflow๋„ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค.
86
+
87
  ์‹ค์ œ ๊ธฐ์ถœ/๋คํ”„ ์„ฑ๊ฒฉ์˜ ๋ฌธ์ œ๋ฅผ ํฌํ•จํ•˜๋Š” ๊ฒฝ์šฐ Hugging Face Space๋Š” private๋กœ ์šด์˜ํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
88
 
89
  ๋ฐ๋ชจ seed๋ฅผ ๋„๊ณ  ์‹ถ๋‹ค๋ฉด ์‹คํ–‰ ํ™˜๊ฒฝ์— ์•„๋ž˜ ๊ฐ’์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.
 
167
  ํ‰์†Œ์—๋Š” ์•„๋ž˜ ๋ฐฉ์‹์ด ๊ฐ€์žฅ ๋‹จ์ˆœํ•ฉ๋‹ˆ๋‹ค.
168
 
169
  ```bash
170
+ python scripts/check_quality.py
171
  git push origin main
172
  ```
173
 
docs/devlog/2026-06-07.md CHANGED
@@ -129,3 +129,28 @@ GitHub์™€ Hugging Face Space ๋ฐฐํฌ ์ž๋™ํ™”๋ฅผ ์ •๋ฆฌํ–ˆ๋‹ค. Hugging Face์—
129
  ```bash
130
  scripts/git_daily_changes.sh 2026-06-07
131
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  ```bash
130
  scripts/git_daily_changes.sh 2026-06-07
131
  ```
132
+
133
+ ## ๋ฌธ์ œ ์œ ํ˜•/๋ชจ๋ฐ”์ผ UI ๊ฐœ์„ 
134
+
135
+ ๋ฌธ์ œ ์œ ํ˜•์ด ์—ฌ๋Ÿฌ ํ˜•ํƒœ๋กœ ๋“ค์–ด์˜ค๋ฉด์„œ ํ™”๋ฉด ํ‘œ์‹œ์™€ ์ฑ„์  ๊ธฐ์ค€์ด ์–ด๊ธ‹๋‚  ์ˆ˜ ์žˆ๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด `Hotspot (True/False)`, `True/False (In-Context)`, `Hotspot (Drag and Drop)`์ฒ˜๋Ÿผ ์›๋ฌธ์—์„œ ์˜จ ์œ ํ˜•๋ช…์ด ๋‚ด๋ถ€ ๋กœ์ง๊ณผ ์ •ํ™•ํžˆ ๋งž์ง€ ์•Š์œผ๋ฉด Yes/No ๋ฌธ์ œ๊ฐ€ ๊ฐ๊ด€์‹์ฒ˜๋Ÿผ ๋ณด์ด๊ฑฐ๋‚˜, ์ˆœ์„œ๊ฐ€ ์ค‘์š”ํ•œ ๋‹ต์•ˆ์ด ์ผ๋ฐ˜ ๋ณต์ˆ˜ ์„ ํƒ์ฒ˜๋Ÿผ ๋น„๊ต๋  ์ˆ˜ ์žˆ์—ˆ๋‹ค.
136
+
137
+ ์ด๋ฅผ ์ค„์ด๊ธฐ ์œ„ํ•ด ๋‹ค์Œ ๊ตฌ์กฐ๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋‹ค.
138
+
139
+ - `answer_normalizer.py`: Yes/No, ๊ฐ๊ด€์‹, ๋ณต์ˆ˜ ์„ ํƒ, ์ˆœ์„œํ˜• ๋‹ต์•ˆ์„ ๋น„๊ต ๊ฐ€๋Šฅํ•œ ํ˜•ํƒœ๋กœ ์ •๊ทœํ™”
140
+ - `question_type_metadata_service.py`: ๋‹ค์–‘ํ•œ ๋ฌธ์ œ ์œ ํ˜•๋ช…์„ ๋‚ด๋ถ€ ํ‘œ์ค€ ์œ ํ˜•์œผ๋กœ ๋ณ€ํ™˜
141
+ - `tests/test_answer_normalizer.py`: ์ •๋‹ต ์ •๊ทœํ™” ํ…Œ์ŠคํŠธ
142
+ - `tests/test_question_type_metadata_service.py`: ๋ฌธ์ œ ์œ ํ˜• alias ํ…Œ์ŠคํŠธ
143
+
144
+ ๋ชจ๋ฐ”์ผ ํ™”๋ฉด์€ ๋ฌธ์ œํ’€์ด๋ฅผ ์ฒซ ๋ฒˆ์งธ ๋™์„ ์œผ๋กœ ๋‘๊ณ , ์—…๋กœ๋“œ/์ฒ˜๋ฆฌ ํ˜„ํ™ฉ/AI ์ƒ‰์ธ์€ ๊ด€๋ฆฌ ์˜์—ญ์œผ๋กœ ๋ฌถ์—ˆ๋‹ค. ๋‹ต์•ˆ ์„ ํƒ UI๋Š” ํ„ฐ์น˜ํ•˜๊ธฐ ์‰ฝ๋„๋ก ๋ฒ„ํŠผ๊ณผ radio ์˜์—ญ์˜ ๋†’์ด์™€ ๊ฐ„๊ฒฉ์„ ์กฐ์ •ํ–ˆ๋‹ค.
145
+
146
+ ## ๋ฐฐํฌ/ํ’ˆ์งˆ ๋ณด์™„
147
+
148
+ ์ถ”๊ฐ€๋กœ ๋ฐฐํฌ ์ „์— ๋†“์น˜๊ธฐ ์‰ฌ์šด ๋ถ€๋ถ„์„ ์ •๋ฆฌํ–ˆ๋‹ค.
149
+
150
+ - seed ๊ฐฑ์‹  ์‹œ ์‚ฌ๋žŒ์ด ๊ฒ€์ˆ˜ํ•œ ๋ฌธ์ œ๋ฅผ ๊ธฐ๋ณธ์ ์œผ๋กœ ๋ฎ์–ด์“ฐ์ง€ ์•Š๋„๋ก ๋ณดํ˜ธ
151
+ - seed export ๊ฒฐ๊ณผ๋ฅผ `questions_seed.report.json`์œผ๋กœ ์ €์žฅ
152
+ - `scripts/check_quality.py`๋กœ ๋ฌธ๋ฒ• ๊ฒ€์‚ฌ์™€ ํ•ต์‹ฌ ํ…Œ์ŠคํŠธ๋ฅผ ํ•œ ๋ฒˆ์— ์‹คํ–‰
153
+ - GitHub Actions `quality-check.yml` ์ถ”๊ฐ€
154
+ - `.env.example` ์ถ”๊ฐ€
155
+ - Docker Compose์—์„œ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋ชจ๋ธ ์„ค์ •์„ ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋กœ ๋ถ„๋ฆฌ
156
+ - `CHANGELOG.md` ์ถ”๊ฐ€
requirements-dev.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -r requirements.txt
2
+ pytest==8.4.1
scripts/check_quality.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+
6
+
7
+ PYTHON_FILES = [
8
+ "streamlit_app.py",
9
+ "cert_study_app/services/answer_normalizer.py",
10
+ "cert_study_app/services/demo_seed_service.py",
11
+ "cert_study_app/services/parse_quality_service.py",
12
+ "cert_study_app/services/question_review_service.py",
13
+ "cert_study_app/services/question_type_metadata_service.py",
14
+ "cert_study_app/services/quiz_service.py",
15
+ "scripts/export_hf_seed.py",
16
+ ]
17
+
18
+ TEST_FILES = [
19
+ "tests/test_answer_normalizer.py",
20
+ "tests/test_question_type_metadata_service.py",
21
+ "tests/test_export_hf_seed.py",
22
+ ]
23
+
24
+
25
+ def run(command: list[str]) -> None:
26
+ print("+", " ".join(command))
27
+ subprocess.run(command, check=True)
28
+
29
+
30
+ def main() -> int:
31
+ run([sys.executable, "-m", "py_compile", *PYTHON_FILES])
32
+ run([sys.executable, "-m", "pytest", *TEST_FILES])
33
+ return 0
34
+
35
+
36
+ if __name__ == "__main__":
37
+ raise SystemExit(main())
scripts/export_hf_seed.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import argparse
4
  import json
 
5
  import shutil
6
  from pathlib import Path
7
  from typing import Any
@@ -13,6 +14,7 @@ DEFAULT_PARENT_SOURCE_JSON = ROOT / "data" / "parsed_json" / "reparsed_multipage
13
  DEFAULT_IMAGE_ROOT = ROOT / "data" / "images"
14
  DEFAULT_SEED_PATH = ROOT / "cert_study_app" / "demo_data" / "questions_seed.json"
15
  DEFAULT_ASSET_ROOT = ROOT / "static" / "question_assets"
 
16
 
17
 
18
  def _load_rows(path: Path) -> list[dict[str, Any]]:
@@ -75,6 +77,15 @@ def _copy_image_reference(image_path: Any, asset_root: Path) -> str | None:
75
  return _copy_image(source_path, asset_root)
76
 
77
 
 
 
 
 
 
 
 
 
 
78
  def _question_number(row: dict[str, Any]) -> Any:
79
  return row.get("question_number") or row.get("question_id") or row.get("number")
80
 
@@ -111,6 +122,15 @@ def _parent_context_by_scenario(
111
  return contexts
112
 
113
 
 
 
 
 
 
 
 
 
 
114
  def _export_row(
115
  row: dict[str, Any],
116
  image_root: Path,
@@ -143,6 +163,11 @@ def _export_row(
143
  if copied_path:
144
  parent_image_paths.append(copied_path)
145
 
 
 
 
 
 
146
  return {
147
  "question_number": question_number,
148
  "stem": row.get("stem") or row.get("question") or "",
@@ -157,7 +182,7 @@ def _export_row(
157
  "image_path": image_path,
158
  "raw_text": row.get("raw_text") or row.get("stem") or row.get("question") or "",
159
  "group_id": parent_context.get("group_id") or row.get("group_id"),
160
- "parent_stem": parent_context.get("parent_stem") or row.get("parent_stem") or row.get("scenario"),
161
  "parent_image_paths": parent_image_paths,
162
  "concept_tags": row.get("concept_tags") or [],
163
  "parse_status": row.get("parse_status") or "approved",
@@ -177,6 +202,7 @@ def export_seed(
177
  image_root: Path,
178
  seed_path: Path,
179
  asset_root: Path,
 
180
  ) -> int:
181
  rows = _load_rows(source_json)
182
  parent_rows = _load_rows(parent_source_json) if parent_source_json.exists() else []
@@ -192,9 +218,74 @@ def export_seed(
192
  with seed_path.open("w", encoding="utf-8") as file:
193
  json.dump(exported, file, ensure_ascii=False, indent=2)
194
  file.write("\n")
 
 
 
 
 
195
  return len(exported)
196
 
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  def main() -> None:
199
  parser = argparse.ArgumentParser(description="Export deployable question seed data for Hugging Face Space.")
200
  parser.add_argument("--source-json", type=Path, default=DEFAULT_SOURCE_JSON)
@@ -202,6 +293,7 @@ def main() -> None:
202
  parser.add_argument("--image-root", type=Path, default=DEFAULT_IMAGE_ROOT)
203
  parser.add_argument("--seed-path", type=Path, default=DEFAULT_SEED_PATH)
204
  parser.add_argument("--asset-root", type=Path, default=DEFAULT_ASSET_ROOT)
 
205
  args = parser.parse_args()
206
 
207
  count = export_seed(
@@ -210,8 +302,10 @@ def main() -> None:
210
  image_root=args.image_root,
211
  seed_path=args.seed_path,
212
  asset_root=args.asset_root,
 
213
  )
214
  print(f"Exported {count} questions to {args.seed_path}")
 
215
 
216
 
217
  if __name__ == "__main__":
 
2
 
3
  import argparse
4
  import json
5
+ import re
6
  import shutil
7
  from pathlib import Path
8
  from typing import Any
 
14
  DEFAULT_IMAGE_ROOT = ROOT / "data" / "images"
15
  DEFAULT_SEED_PATH = ROOT / "cert_study_app" / "demo_data" / "questions_seed.json"
16
  DEFAULT_ASSET_ROOT = ROOT / "static" / "question_assets"
17
+ DEFAULT_REPORT_PATH = ROOT / "cert_study_app" / "demo_data" / "questions_seed.report.json"
18
 
19
 
20
  def _load_rows(path: Path) -> list[dict[str, Any]]:
 
77
  return _copy_image(source_path, asset_root)
78
 
79
 
80
+ def _asset_exists(path: Any) -> bool:
81
+ if not path:
82
+ return False
83
+ asset_path = Path(str(path))
84
+ if not asset_path.is_absolute():
85
+ asset_path = ROOT / asset_path
86
+ return asset_path.exists()
87
+
88
+
89
  def _question_number(row: dict[str, Any]) -> Any:
90
  return row.get("question_number") or row.get("question_id") or row.get("number")
91
 
 
122
  return contexts
123
 
124
 
125
+ def _meaningful_parent_stem(value: Any) -> str | None:
126
+ text = str(value or "").strip()
127
+ if not text or text == "๋‹จ์ผ๋ฌธ์ œ":
128
+ return None
129
+ if len(text) < 40 and not re.search(r"[\n:]", text):
130
+ return None
131
+ return text
132
+
133
+
134
  def _export_row(
135
  row: dict[str, Any],
136
  image_root: Path,
 
163
  if copied_path:
164
  parent_image_paths.append(copied_path)
165
 
166
+ parent_stem = (
167
+ _meaningful_parent_stem(parent_context.get("parent_stem"))
168
+ or _meaningful_parent_stem(row.get("parent_stem"))
169
+ )
170
+
171
  return {
172
  "question_number": question_number,
173
  "stem": row.get("stem") or row.get("question") or "",
 
182
  "image_path": image_path,
183
  "raw_text": row.get("raw_text") or row.get("stem") or row.get("question") or "",
184
  "group_id": parent_context.get("group_id") or row.get("group_id"),
185
+ "parent_stem": parent_stem,
186
  "parent_image_paths": parent_image_paths,
187
  "concept_tags": row.get("concept_tags") or [],
188
  "parse_status": row.get("parse_status") or "approved",
 
202
  image_root: Path,
203
  seed_path: Path,
204
  asset_root: Path,
205
+ report_path: Path | None = DEFAULT_REPORT_PATH,
206
  ) -> int:
207
  rows = _load_rows(source_json)
208
  parent_rows = _load_rows(parent_source_json) if parent_source_json.exists() else []
 
218
  with seed_path.open("w", encoding="utf-8") as file:
219
  json.dump(exported, file, ensure_ascii=False, indent=2)
220
  file.write("\n")
221
+ if report_path:
222
+ report_path.parent.mkdir(parents=True, exist_ok=True)
223
+ with report_path.open("w", encoding="utf-8") as file:
224
+ json.dump(build_seed_report(exported, seed_path), file, ensure_ascii=False, indent=2)
225
+ file.write("\n")
226
  return len(exported)
227
 
228
 
229
+ def build_seed_report(rows: list[dict[str, Any]], seed_path: Path) -> dict[str, Any]:
230
+ numbers = []
231
+ duplicate_numbers = []
232
+ seen_numbers = set()
233
+ type_counts: dict[str, int] = {}
234
+ missing_images = []
235
+ missing_answers = []
236
+ missing_options = []
237
+ common_passage_count = 0
238
+
239
+ for row in rows:
240
+ number = row.get("question_number")
241
+ if number in seen_numbers:
242
+ duplicate_numbers.append(number)
243
+ seen_numbers.add(number)
244
+ numbers.append(number)
245
+ question_type = str(row.get("question_type") or "unparsed")
246
+ type_counts[question_type] = type_counts.get(question_type, 0) + 1
247
+ if row.get("parent_stem"):
248
+ common_passage_count += 1
249
+ if not row.get("answer"):
250
+ missing_answers.append(number)
251
+ if not row.get("options"):
252
+ missing_options.append(number)
253
+ image_paths = [row.get("image_path")] + list(row.get("parent_image_paths") or [])
254
+ for image_path in image_paths:
255
+ if image_path and not _asset_exists(image_path):
256
+ missing_images.append({"question_number": number, "image_path": image_path})
257
+
258
+ numeric_numbers = []
259
+ for number in numbers:
260
+ try:
261
+ numeric_numbers.append(int(number))
262
+ except Exception:
263
+ continue
264
+
265
+ return {
266
+ "schema_version": 1,
267
+ "seed_path": _display_path(seed_path),
268
+ "question_count": len(rows),
269
+ "first_question_number": min(numeric_numbers) if numeric_numbers else None,
270
+ "last_question_number": max(numeric_numbers) if numeric_numbers else None,
271
+ "common_passage_count": common_passage_count,
272
+ "type_counts": dict(sorted(type_counts.items())),
273
+ "duplicate_numbers": duplicate_numbers,
274
+ "missing_answers": missing_answers,
275
+ "missing_options": missing_options,
276
+ "missing_images": missing_images,
277
+ "ready": not duplicate_numbers and not missing_answers and not missing_images,
278
+ }
279
+
280
+
281
+ def _display_path(path: Path) -> str:
282
+ try:
283
+ resolved = path.resolve()
284
+ return resolved.relative_to(ROOT).as_posix()
285
+ except Exception:
286
+ return path.as_posix()
287
+
288
+
289
  def main() -> None:
290
  parser = argparse.ArgumentParser(description="Export deployable question seed data for Hugging Face Space.")
291
  parser.add_argument("--source-json", type=Path, default=DEFAULT_SOURCE_JSON)
 
293
  parser.add_argument("--image-root", type=Path, default=DEFAULT_IMAGE_ROOT)
294
  parser.add_argument("--seed-path", type=Path, default=DEFAULT_SEED_PATH)
295
  parser.add_argument("--asset-root", type=Path, default=DEFAULT_ASSET_ROOT)
296
+ parser.add_argument("--report-path", type=Path, default=DEFAULT_REPORT_PATH)
297
  args = parser.parse_args()
298
 
299
  count = export_seed(
 
302
  image_root=args.image_root,
303
  seed_path=args.seed_path,
304
  asset_root=args.asset_root,
305
+ report_path=args.report_path,
306
  )
307
  print(f"Exported {count} questions to {args.seed_path}")
308
+ print(f"Wrote seed report to {args.report_path}")
309
 
310
 
311
  if __name__ == "__main__":
streamlit_app.py CHANGED
@@ -20,7 +20,12 @@ from cert_study_app.services.concept_note_service import ConceptNoteService
20
  from cert_study_app.services.demo_seed_service import seed_demo_questions_if_empty
21
  from cert_study_app.services.ingestion_job_service import IngestionJobService
22
  from cert_study_app.services.parse_quality_service import default_quality_report_path
23
- from cert_study_app.services.question_type_metadata_service import automation_summary, status_label, type_metadata
 
 
 
 
 
24
  from cert_study_app.services.question_concept_service import classify_question_batch, concept_label
25
  from cert_study_app.services.quiz_service import QuizService, yes_no_labels
26
  from cert_study_app.services.study_assistant_service import StudyAssistantService
@@ -112,8 +117,13 @@ def apply_mobile_styles():
112
  st.markdown(
113
  """
114
  <style>
 
 
 
 
 
115
  .block-container {
116
- padding-top: 1rem;
117
  padding-left: 1rem;
118
  padding-right: 1rem;
119
  max-width: 760px;
@@ -123,6 +133,36 @@ def apply_mobile_styles():
123
  }
124
  div[data-testid="stButton"] > button {
125
  min-height: 44px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  }
127
  div[role="radiogroup"] {
128
  gap: 0.35rem;
@@ -147,6 +187,10 @@ def apply_mobile_styles():
147
  margin-top: 0.8rem;
148
  }
149
  @media (max-width: 640px) {
 
 
 
 
150
  h1 {
151
  font-size: 1.6rem;
152
  }
@@ -159,6 +203,15 @@ def apply_mobile_styles():
159
  div[data-testid="stMetric"] {
160
  padding: 0.25rem 0;
161
  }
 
 
 
 
 
 
 
 
 
162
  }
163
  </style>
164
  """,
@@ -210,21 +263,21 @@ def render_home(exams):
210
  total_questions = sum(exam["count"] for exam in exams)
211
  st.caption(f"๋“ฑ๋ก๋œ ์‹œํ—˜ {len(exams)}๊ฐœ ยท ์ „์ฒด ๋ฌธํ•ญ {total_questions}๊ฐœ")
212
 
213
- col1, col2 = st.columns(2)
214
- if col1.button("๋ฌธ์ œ ํ’€์ด", type="primary", use_container_width=True):
215
  go_to("๋ฌธ์ œ ํ’€์ด")
216
- if col2.button("PDF ์—…๋กœ๋“œ", use_container_width=True):
217
- go_to("PDF ์—…๋กœ๋“œ")
218
-
219
- with st.expander("ํ•™์Šต ๋ฉ”๋‰ด", expanded=True):
220
- if st.button("์ทจ์•ฝ ๊ฐœ๋… ํ•™์Šต", use_container_width=True):
221
- go_to("์ทจ์•ฝ ๊ฐœ๋… ํ•™์Šต")
222
- if st.button("์˜ค๋‹ต/๋ณต์Šต", use_container_width=True):
223
- go_to("์˜ค๋‹ต/๋ณต์Šต")
224
- if st.button("๊ฐœ๋… ์ •๋ฆฌ", use_container_width=True):
225
- go_to("๊ฐœ๋… ์ •๋ฆฌ")
226
-
227
- with st.expander("๊ด€๋ฆฌ ๋ฉ”๋‰ด", expanded=False):
 
228
  if st.button("์ฒ˜๋ฆฌ ํ˜„ํ™ฉ", use_container_width=True):
229
  go_to("์ฒ˜๋ฆฌ ํ˜„ํ™ฉ")
230
  if st.button("์‹œํ—˜ ํ˜„ํ™ฉ", use_container_width=True):
@@ -539,8 +592,8 @@ def is_multi_answer(question) -> bool:
539
 
540
 
541
  def is_per_row_choice(question) -> bool:
542
- question_type = (question.get("question_type") or "").lower()
543
- if question_type not in {"table_choice", "hotspot"}:
544
  return False
545
  if visual_answer_areas(question):
546
  return True
@@ -684,12 +737,10 @@ def yes_no_answer_labels(value: str) -> list[str]:
684
 
685
 
686
  def is_yes_no_hotspot(question) -> bool:
687
- question_type = (question.get("question_type") or "").lower()
688
- if "in-context" in question_type:
689
- return False
690
- if not any(keyword in question_type for keyword in ["hotspot", "table_choice", "yes_no", "true/false"]):
691
  return False
692
- if "true/false" in question_type:
693
  return True
694
  text = " ".join(
695
  [
@@ -809,7 +860,7 @@ def render_yes_no_matrix(question, key_prefix, rows, caption="์ง„์ˆ ๋ณ„ ๋‹ต์•ˆ")
809
 
810
  def render_answer_input(question, key_prefix):
811
  options = question["options"]
812
- question_type = (question.get("question_type") or "").lower()
813
  statement_rows = [
814
  str(statement.get("text") or "").strip()
815
  for statement in visual_statements(question)
@@ -836,7 +887,7 @@ def render_answer_input(question, key_prefix):
836
 
837
  if not options:
838
  answer = str(question.get("answer") or "").upper()
839
- if question_type in {"yes_no", "table_choice", "hotspot"} and re.search(r"(์˜ˆ|์•„๋‹ˆ์˜ค|์•„๋‹ˆ์š”|YES|NO)", answer, re.I):
840
  rows = yes_no_lines(question.get("question") or "") or ["์ง„์ˆ  1", "์ง„์ˆ  2", "์ง„์ˆ  3"]
841
  return render_yes_no_matrix(question, key_prefix, rows)
842
 
 
20
  from cert_study_app.services.demo_seed_service import seed_demo_questions_if_empty
21
  from cert_study_app.services.ingestion_job_service import IngestionJobService
22
  from cert_study_app.services.parse_quality_service import default_quality_report_path
23
+ from cert_study_app.services.question_type_metadata_service import (
24
+ automation_summary,
25
+ normalize_question_type,
26
+ status_label,
27
+ type_metadata,
28
+ )
29
  from cert_study_app.services.question_concept_service import classify_question_batch, concept_label
30
  from cert_study_app.services.quiz_service import QuizService, yes_no_labels
31
  from cert_study_app.services.study_assistant_service import StudyAssistantService
 
117
  st.markdown(
118
  """
119
  <style>
120
+ :root {
121
+ --cert-primary: #2563eb;
122
+ --cert-border: rgba(15, 23, 42, 0.12);
123
+ --cert-soft: rgba(37, 99, 235, 0.08);
124
+ }
125
  .block-container {
126
+ padding-top: 0.75rem;
127
  padding-left: 1rem;
128
  padding-right: 1rem;
129
  max-width: 760px;
 
133
  }
134
  div[data-testid="stButton"] > button {
135
  min-height: 44px;
136
+ border-radius: 8px;
137
+ white-space: normal;
138
+ line-height: 1.25;
139
+ }
140
+ div[data-testid="stRadio"] label,
141
+ div[data-testid="stCheckbox"] label {
142
+ min-height: 40px;
143
+ align-items: flex-start;
144
+ }
145
+ div[role="radiogroup"] > label {
146
+ border: 1px solid var(--cert-border);
147
+ border-radius: 8px;
148
+ padding: 0.55rem 0.7rem;
149
+ margin-bottom: 0.35rem;
150
+ }
151
+ div[role="radiogroup"] > label:has(input:checked) {
152
+ border-color: var(--cert-primary);
153
+ background: var(--cert-soft);
154
+ }
155
+ .cert-quick-actions {
156
+ display: grid;
157
+ grid-template-columns: repeat(2, minmax(0, 1fr));
158
+ gap: 0.5rem;
159
+ margin: 0.35rem 0 0.75rem;
160
+ }
161
+ .cert-section-title {
162
+ margin: 1.2rem 0 0.35rem;
163
+ font-size: 0.95rem;
164
+ font-weight: 700;
165
+ color: rgba(15, 23, 42, 0.72);
166
  }
167
  div[role="radiogroup"] {
168
  gap: 0.35rem;
 
187
  margin-top: 0.8rem;
188
  }
189
  @media (max-width: 640px) {
190
+ .block-container {
191
+ padding-left: 0.7rem;
192
+ padding-right: 0.7rem;
193
+ }
194
  h1 {
195
  font-size: 1.6rem;
196
  }
 
203
  div[data-testid="stMetric"] {
204
  padding: 0.25rem 0;
205
  }
206
+ div[data-testid="stHorizontalBlock"] {
207
+ flex-wrap: wrap;
208
+ }
209
+ div[data-testid="column"] {
210
+ min-width: 100%;
211
+ }
212
+ div[data-testid="column"] div[data-testid="stMetric"] {
213
+ border-bottom: 1px solid var(--cert-border);
214
+ }
215
  }
216
  </style>
217
  """,
 
263
  total_questions = sum(exam["count"] for exam in exams)
264
  st.caption(f"๋“ฑ๋ก๋œ ์‹œํ—˜ {len(exams)}๊ฐœ ยท ์ „์ฒด ๋ฌธํ•ญ {total_questions}๊ฐœ")
265
 
266
+ if st.button("๋ฌธ์ œ ํ’€์ด ์‹œ์ž‘", type="primary", use_container_width=True):
 
267
  go_to("๋ฌธ์ œ ํ’€์ด")
268
+
269
+ st.markdown('<div class="cert-section-title">ํ•™์Šต</div>', unsafe_allow_html=True)
270
+ col1, col2 = st.columns(2)
271
+ if col1.button("์˜ค๋‹ต/๋ณต์Šต", use_container_width=True):
272
+ go_to("์˜ค๋‹ต/๋ณต์Šต")
273
+ if col2.button("๊ฐœ๋… ์ •๋ฆฌ", use_container_width=True):
274
+ go_to("๊ฐœ๋… ์ •๋ฆฌ")
275
+ if st.button("์ทจ์•ฝ ๊ฐœ๋… ํ•™์Šต", use_container_width=True):
276
+ go_to("์ทจ์•ฝ ๊ฐœ๋… ํ•™์Šต")
277
+
278
+ with st.expander("์—…๋กœ๋“œ/๊ด€๋ฆฌ", expanded=False):
279
+ if st.button("PDF ์—…๋กœ๋“œ", use_container_width=True):
280
+ go_to("PDF ์—…๋กœ๋“œ")
281
  if st.button("์ฒ˜๋ฆฌ ํ˜„ํ™ฉ", use_container_width=True):
282
  go_to("์ฒ˜๋ฆฌ ํ˜„ํ™ฉ")
283
  if st.button("์‹œํ—˜ ํ˜„ํ™ฉ", use_container_width=True):
 
592
 
593
 
594
  def is_per_row_choice(question) -> bool:
595
+ question_type = normalize_question_type(question.get("question_type"))
596
+ if question_type not in {"table_choice", "hotspot", "matching"}:
597
  return False
598
  if visual_answer_areas(question):
599
  return True
 
737
 
738
 
739
  def is_yes_no_hotspot(question) -> bool:
740
+ question_type = normalize_question_type(question.get("question_type"))
741
+ if question_type not in {"yes_no", "hotspot", "table_choice"}:
 
 
742
  return False
743
+ if question_type == "yes_no":
744
  return True
745
  text = " ".join(
746
  [
 
860
 
861
  def render_answer_input(question, key_prefix):
862
  options = question["options"]
863
+ question_type = normalize_question_type(question.get("question_type"))
864
  statement_rows = [
865
  str(statement.get("text") or "").strip()
866
  for statement in visual_statements(question)
 
887
 
888
  if not options:
889
  answer = str(question.get("answer") or "").upper()
890
+ if question_type in {"yes_no", "table_choice", "hotspot"} and re.search(r"(์˜ˆ|์•„๋‹ˆ์˜ค|์•„๋‹ˆ์š”|YES|NO|Y|N)", answer, re.I):
891
  rows = yes_no_lines(question.get("question") or "") or ["์ง„์ˆ  1", "์ง„์ˆ  2", "์ง„์ˆ  3"]
892
  return render_yes_no_matrix(question, key_prefix, rows)
893
 
tests/test_answer_normalizer.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from cert_study_app.services.answer_normalizer import evaluate_answer, normalize_answer, yes_no_labels
2
+
3
+
4
+ def test_yes_no_labels_from_letters():
5
+ assert yes_no_labels("Y,N,Y") == ["Y", "N", "Y"]
6
+
7
+
8
+ def test_yes_no_labels_from_words():
9
+ assert yes_no_labels("Yes, No, Selected") == ["Y", "N", "Y"]
10
+
11
+
12
+ def test_yes_no_labels_from_structured_list():
13
+ raw = [
14
+ {"statement": "A", "selected_answer": "Yes"},
15
+ {"statement": "B", "selected_answer": "No"},
16
+ {"statement": "C", "selected_answer": "Yes"},
17
+ ]
18
+ assert yes_no_labels(raw) == ["Y", "N", "Y"]
19
+
20
+
21
+ def test_multi_choice_answer_is_sorted_when_unordered():
22
+ assert normalize_answer("C,A") == "A,C"
23
+
24
+
25
+ def test_ordered_answer_preserves_order():
26
+ assert normalize_answer("C,A", ordered=True) == "C,A"
27
+
28
+
29
+ def test_evaluate_answer_handles_yes_no_matrix():
30
+ result = evaluate_answer("์˜ˆ, ์•„๋‹ˆ์˜ค, ์˜ˆ", "Y,N,Y", ordered=True)
31
+ assert result.correct is True
32
+ assert result.normalized_chosen == "Y,N,Y"
33
+
34
+
35
+ def test_evaluate_answer_handles_unordered_multi_select():
36
+ assert evaluate_answer("C,A", "A,C").correct is True
tests/test_export_hf_seed.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from scripts.export_hf_seed import build_seed_report
4
+
5
+
6
+ def test_build_seed_report_flags_duplicates_and_missing_answer():
7
+ rows = [
8
+ {
9
+ "question_number": 1,
10
+ "question_type": "MCQ",
11
+ "answer": "A",
12
+ "options": ["A. One", "B. Two"],
13
+ "parent_stem": "Common passage",
14
+ },
15
+ {
16
+ "question_number": 1,
17
+ "question_type": "Hotspot (True/False)",
18
+ "answer": "",
19
+ "options": [],
20
+ },
21
+ ]
22
+
23
+ report = build_seed_report(rows, Path("cert_study_app/demo_data/questions_seed.json"))
24
+
25
+ assert report["question_count"] == 2
26
+ assert report["common_passage_count"] == 1
27
+ assert report["duplicate_numbers"] == [1]
28
+ assert report["missing_answers"] == [1]
29
+ assert report["ready"] is False
tests/test_question_type_metadata_service.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from cert_study_app.services.question_type_metadata_service import (
2
+ is_ordered_answer_type,
3
+ is_visual_question_type,
4
+ normalize_question_type,
5
+ )
6
+
7
+
8
+ def test_normalize_question_type_aliases():
9
+ assert normalize_question_type("Hotspot (True/False)") == "yes_no"
10
+ assert normalize_question_type("True/False (In-Context)") == "yes_no"
11
+ assert normalize_question_type("Hotspot (Drag and Drop)") == "matching"
12
+ assert normalize_question_type("Multiple Select") == "multi_select"
13
+
14
+
15
+ def test_visual_question_type_detection_uses_normalized_type():
16
+ assert is_visual_question_type("Hotspot (True/False)") is True
17
+ assert is_visual_question_type("MCQ") is False
18
+
19
+
20
+ def test_ordered_answer_type_detection_uses_normalized_type():
21
+ assert is_ordered_answer_type("Hotspot (Drag and Drop)") is True
22
+ assert is_ordered_answer_type("Multiple Select") is False