Spaces:
Running
Running
Sync from GitHub f8e98d3f03952efd3d7c1b217df60c77c3992aec
Browse files- .env.example +16 -5
- CHANGELOG.md +16 -0
- README.md +27 -2
- cert_study_app/demo_data/questions_seed.json +0 -0
- cert_study_app/demo_data/questions_seed.report.json +27 -0
- cert_study_app/services/answer_normalizer.py +262 -0
- cert_study_app/services/demo_seed_service.py +32 -3
- cert_study_app/services/parse_quality_service.py +4 -4
- cert_study_app/services/question_review_service.py +17 -13
- cert_study_app/services/question_type_metadata_service.py +73 -1
- cert_study_app/services/quiz_service.py +20 -234
- docker-compose.yml +25 -22
- docs/02_architecture.md +10 -0
- docs/05_deployment.md +30 -3
- docs/06_decision_log.md +46 -0
- docs/07_troubleshooting.md +27 -0
- docs/08_publish_automation.md +10 -0
- docs/devlog/2026-06-07.md +25 -0
- requirements-dev.txt +2 -0
- scripts/check_quality.py +37 -0
- scripts/export_hf_seed.py +95 -1
- streamlit_app.py +76 -25
- tests/test_answer_normalizer.py +36 -0
- tests/test_export_hf_seed.py +29 -0
- tests/test_question_type_metadata_service.py +22 -0
.env.example
CHANGED
|
@@ -1,12 +1,23 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
| 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://
|
| 11 |
AIRFLOW_API_USERNAME=admin
|
| 12 |
-
AIRFLOW_API_PASSWORD=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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
|
| 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
|
| 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 =
|
| 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
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
| 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
|
| 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
|
| 155 |
-
if question_type
|
| 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
|
| 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
|
| 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 (
|
| 219 |
issues.append("ํ์ด ํ๋ฉด์์ ์ฌ์ฉํ ๋ณด๊ธฐ/์์/์ง์ ๊ตฌ์กฐ๊ฐ ์์ต๋๋ค. ์ฃผ๊ด์ ์
๋ ฅ์ผ๋ก ์ฒ๋ฆฌํ๋ฉด ์ ๋ฉ๋๋ค.")
|
| 220 |
|
| 221 |
visual_playable = (
|
| 222 |
-
(
|
| 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 |
-
|
| 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
|
| 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
|
| 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 =
|
| 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
|
| 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
|
| 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 =
|
| 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 |
-
|
| 474 |
-
correct =
|
| 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":
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 |
-
|
| 214 |
-
if col1.button("๋ฌธ์ ํ์ด", type="primary", use_container_width=True):
|
| 215 |
go_to("๋ฌธ์ ํ์ด")
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
| 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")
|
| 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")
|
| 688 |
-
if
|
| 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
|
| 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")
|
| 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
|