feat: add common utilities and feedback handling scripts

- Introduced common.js for shared functions including API calls, error reporting, and UI helpers.
- Implemented feedback.js for the feedback reporting page, including form handling and displaying feedback status.
- Added layout.js for managing galaxy and section layouts in the visualization.
- Created login.js for user authentication with session management.
- Developed search.js for term searching based on user input and synonyms.
- Implemented signup.js for user registration with validation and immediate login.
This commit is contained in:
jsahn
2026-10-06 23:14:45 +09:00
parent 04158ac442
commit 4e943700fc
62 changed files with 158771 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
# 실행 중 생기는 DB·비밀값 (atlas.json 은 파이프라인 결과라 커밋)
data/atlas.db
data/atlas.db-*
data/.secret
data/.admin_password
data/drafts/in_*.json
.DS_Store
backups/
+21
View File
@@ -0,0 +1,21 @@
# 지역사회간호학 개념 우주 웹서버 (Apple container / Docker 공용)
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
LANG=C.UTF-8 \
ATLAS_DB=/app/data/atlas.db \
ATLAS_JSON=/app/seed/atlas.json
WORKDIR /app
COPY requirements-server.txt .
RUN pip install -r requirements-server.txt
COPY server/ server/
COPY web/ web/
# 파이프라인 결과(시작 시 DB로 가져옴). DB·비밀번호는 /app/data(마운트)에 저장된다.
COPY data/atlas.json seed/atlas.json
EXPOSE 8088
HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8088/api/admin/me')"
CMD ["python", "-m", "server", "--host", "0.0.0.0", "--port", "8088"]
+101
View File
@@ -0,0 +1,101 @@
# 지역사회간호학 개념 우주
교과서 PDF의 목차·찾아보기(색인)를 추출해 **은하(장) → 별자리(절) → 별(용어)** 로 탐색하는 학습 사이트입니다.
설계 배경은 `*개발설명서*.md`, 원래 화면 시안은 `*.html`(와이어프레임)을 참고하세요.
## 빠른 시작
```bash
uv venv .venv --python 3.13 # 또는 python3 -m venv .venv
uv pip install --python .venv/bin/python -r requirements.txt
.venv/bin/python -m server # http://127.0.0.1:8000
```
- 처음 실행하면 `data/atlas.json`을 DB(`data/atlas.db`)로 가져오고, **교수자 계정**(아이디 `professor`)을 만들어 터미널에 아이디·비밀번호를 한 번 출력합니다.
비밀번호는 `ATLAS_OWNER_PASSWORD`(또는 기존 `data/.owner_password`, 없으면 무작위)로 정하고, 계정을 만든 뒤에는 DB에 해시로만 저장합니다. 아이디·비밀번호는 관리 → 설정에서 바꿉니다.
- 학교 네트워크에 공개하려면 `--host 0.0.0.0`.
| 주소 | 용도 |
| --- | --- |
| `/` | 개념 우주 (검색·지도·목록·용어 상세·경로 찾기·비교·최근 탐색) |
| `/feedback` | 오류 신고 및 처리 현황 (누구나) |
| `/login` · `/signup` · `/account` | 로그인(학생·교수자 공용) · 가입 코드로 회원 가입 · 내 정보(반·학습 기록) |
| `/members` | 회원 관리 (교수자) |
| `/admin` | 관리: 대시보드, 피드백 처리, 용어·관계 편집/검수, 은하·단원, 설정, 수정 기록, 내보내기 |
| `/api/docs` | API 문서 |
## 데이터 흐름
```
교과서 PDF ──(pipeline)──> data/atlas.json ──(seed)──> data/atlas.db ──> 웹 화면
▲ │
data/drafts/out_*.json ────┘ 관리 화면·피드백으로 수정 (edited=1 → 재가져오기에도 보존)
```
```bash
.venv/bin/python -m pipeline.build # PDF → data/atlas.json (약 30초)
.venv/bin/python -m server.seed # atlas.json → DB (사람이 고친 항목은 유지)
```
- **교재**: `pipeline/config.py` 의 `BOOKS`. 은하는 2026 지역사회간호학 1·2권의 9개 장, 퍼시픽 5판·보건교육학은 보조 출처로 연결하고
2026판 절에 배치합니다(배치가 추정이면 화면에 ‘배치 추정’ 표시).
- **자동 추출물은 모두 `검수 전(draft)`** 입니다: 교과서 정의 문장, 영문 동의어, 관계(목차 구조·정의 문장·이름 포함), 쉬운 뜻 초안.
- 쉬운 뜻·사례 초안(`data/drafts/out_*.json`)은 교재 문장을 근거로 AI가 작성한 초안이며, 화면에 ‘교수자 검토 전’으로 표시됩니다.
- 비교 개념 쌍·시안 예시는 `pipeline/curated.py`.
- **공개 범위**: 관리 → 설정에서 학생에게 보여줄 검수 상태(검수 전/검토 중/확정)를 정합니다. 저작권 때문에 학생 화면·공개 API에는 교과서 원문 문장·교재명·판·쪽수를 내보내지 않습니다(쉬운 뜻·사례·관계만). 교재 출처는 로그인한 교수자의 관리 화면에서만 보이며, PDF는 서버에 두지 않습니다.
검수가 끝나면 ‘확정’만 공개하는 것을 권장합니다. 교재 PDF·문장 공개는 저작권을 확인하세요.
## 회원·반·문제
- 누구나: 지도·검색·쉬운 뜻·관계, 오류 신고. **회원**만: 사례(이런 장면을 떠올려 보세요)와 문제.
- 회원 가입: 교수자가 `/members` → 가입 코드에서 반을 지정해 코드를 만들고 수업에서 알려 줍니다. 학생은 `/account`에서 그 코드를 넣으면 **바로** 회원이 되어 그 반에 들어갑니다(승인 단계 없음). 코드마다 사용 횟수·만료일·켜기/끄기.
- `/members`(회원 관리): 회원 목록(검색·상태·반), 회원별 학습 기록, 반 변경·사용 중지·비밀번호 재설정, 가입 코드, 반, 반별 학습 현황.
- 문제: 쉬운 설명이 있는 용어로 자동 객관식(뜻→용어, 용어→뜻)을 만들고, 관리 → 문제에서 직접 출제(객관식·OX)도 합니다. 채점은 서버에서 하며 정답은 미리 내려보내지 않습니다.
- 관리 화면(`/admin`, `/members`)은 **교수자 계정**(아이디·비밀번호)으로만 들어갑니다. 로그인은 `/login`에서 학생과 같이 합니다.
## 오류 피드백 → 수정
1. 학생·교수자가 용어 화면의 **오류 신고** 또는 `/feedback` 에서 신고합니다.
화면 JS 오류와 서버 오류는 자동으로 같은 목록(유형: 자동 수집 오류)에 쌓입니다(같은 오류는 횟수만 증가).
2. 관리 → 피드백에서 확인하고 용어 편집으로 바로 고치거나, 터미널에서 확인합니다:
```bash
.venv/bin/python -m server.cli feedback # 처리 대기 목록
.venv/bin/python -m server.cli show 12 # 상세 + 대상 용어 현재 값·출처
.venv/bin/python -m server.cli resolve 12 "p.188 정의로 교체"
```
3. 추출 규칙 자체가 문제면 `pipeline/` 을 고친 뒤 build → seed (검수된 내용은 보존).
## macmini 배포 (Apple container, 포트 8088)
```bash
deploy/deploy-macmini.sh # 동기화 → 이미지 빌드 → 컨테이너 교체 → 부팅 시 자동 시작 등록 (교재 PDF는 보내지 않음)
deploy/macmini.sh status|logs|restart|stop|password|backup # password: 처음 만든 교수자 계정
deploy/macmini.sh feedback # 오류 신고 목록 (컨테이너 안 server.cli)
deploy/macmini.sh cli show 12
```
- 주소: `http://macmini:8088` (SSH 별칭 `macmini`, 다른 호스트는 `SSH_HOST=… PORT=…`)
- macmini 쪽 폴더 `~/Community_Nursing_Atlas/`: `app/`(빌드 소스) `data/`(DB·관리자 비밀번호, 컨테이너 재생성에도 유지) `logs/`
- 데이터 갱신: 로컬에서 `pipeline.build` 후 다시 배포하면 컨테이너 시작 시 새 `atlas.json`을 가져옵니다(검수·편집한 내용은 보존).
- 부팅 시 자동 시작: `~/Library/LaunchAgents/com.nursing-atlas.container.plist`
- 공개 주소: `https://test.jsahn1026.synology.me` → 시놀로지 리버스 프록시 → `http://<macmini>:8088`.
앱은 `X-Forwarded-For`/`X-Forwarded-Proto` 를 읽어 방문자 IP(신고 횟수 제한)와 https(관리자 쿠키 Secure)를 인식합니다.
## 테스트
```bash
.venv/bin/python -m pytest -q tests
node --test tests/search.test.mjs
```
## 구조
```
pipeline/ config.py(교재·은하) toc.py(목차) index.py(찾아보기) build.py(통합·정의·관계) curated.py
server/ app.py(API) db.py(스키마) seed.py(가져오기) auth.py(관리자) cli.py(피드백 CLI)
web/ index.html·feedback.html·admin.html, static/js(atlas·search·layout·admin·feedback·common), static/css
data/ atlas.json(파이프라인 결과) atlas.db(실행 DB) drafts/(쉬운 뜻 초안)
tests/ API·파이프라인·검색 테스트
```
+138556
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
# 검수 메모: 자동 추출·초안 작성 중 발견한 확인 필요 항목
이 목록은 쉬운 뜻 초안을 작성하면서 함께 발견한 문제입니다. 쉬운 뜻 초안은 교재 문장을 근거로 AI가 작성했고, 모두 교수자 검토 전 상태입니다.
신뢰도가 낮다고 표시된 초안은 학생 화면에 싣지 않았습니다. 해당 용어는 `data/drafts/out_*.json`에서 confidence가 low인 항목입니다.
고칠 때는 관리 화면(`/admin#term/<term_id>`)을 쓰거나, 합치기 기능으로 중복을 정리하세요.
## 교재 문장과 용어가 어긋남 (교재 확인 필요)
- 조출생률(t_c12f852f97): 붙은 문장이 일반출산율(가임기 여성 1,000명당) 정의임
- 위험집단(t_1497cb4db9): 문장이 표적집단 정의. 표적집단(t_d6ca22d776)은 참여집단 정의가 붙음
- 모성사망률(t_694d026945): 수식이 섞인 문장
- 제4급 감염병(t_ebe3af5117): 3급 감염병 목록 문장이 붙음
- 유행성출혈열(t_b84a186c25), 렙토스피라증(t_d623cdb194): 일반 병원체 목록 문장
- 보급(t_82b465c40e): “이용자 체계에서 자원체계로”라는 방향이 반대로 보임 (교재 원문 확인)
- Dignan & Carr: 1922년 표기 → 1992년 오기로 보임
## 중복·표기 변형 (합치기 검토)
- 병원력/병원성, 감수성/숙주의 감수성, 교차비/승산비
- PPM / Public-Private Mix / 민간·공공협력
- 오존층 파괴/오존층의 파괴, 자연독 식중독/자연독에 의한 식중독, 열실신/열허탈증
- 잠함병/감압병/caisson's disease, 군집독/crowded poisoning, 불감기류/insensible air movement, Minamata병/미나마타병
- 가계도/가족구조도, 최고 노출기준/최고허용기준/ceiling limit
- 페뵈/푀뵈, 지각된~/인지된~ (건강신념모형 구성요소), SO/ST/WO/WT (2·3장 중복)
## 이름·동의어 오류
- 잘린 이름: “Ubiquitous Sensor Networ”, “Computer Managed Instructio”, “…중재 구성 단 계”
- 오탈자(교재 색인 원문): Prochacka→Prochaska, Heinrichi→Heinrich, heat prostratioon, disease manabement, Hegue convention, blephalo spasm, againg society, OSSOC→OSOCC, medination
- 잘못된 동의어: 조건자극에 “unconditioned stimulus, UCS”, 화학적 산소요구량에 BOD, 맬서스에 neo-Malthusianism
- 여러 용어가 한 항목: “시각 매체, 청각 매체, 시청각 매체, 상호 작용 매체”
## 인물인데 개념으로 분류됨 (종류를 ‘인물’로 변경 권장)
나이팅게일, 릴리안 왈드, 윌리엄 라스본, 파비올라, 페뵈, Agricola, Alice Hamilton, Bismark, Celsus, Galen, Hippocrates,
Paracelsus, Percivall Pott, Pope, Ramazzini, Taylor, Ajzen & Fishbein, Fishbein, Green & Kreuter, Marc Lalonde, Tannahill,
Clara Louise Maass, Florence Nightingale, Goldberger, Harrow, Krathwhohl, Masia, Robert Köch, Simpson E., Stanhope & Lancaster, Tyler
## 개념이 아닌 조각·일반어 (숨기기 검토)
C, 관리, 개인기록, 건강관련 지표, 사망재해 현황, 사업주 준수사항, 업무상질병 현황, 재해유형별 재해현황, 측정대상,
Bluetooth 4.0, CD, USB, 관리정보, 대상별 유형, 지역보건, 서비스대상자정보, 대상자 특성, 대상자집단, 예방, 자료 분류, 조직
## 뜻이 모호해 초안을 쓰지 않음
precipitation(강수/침전), radiation(복사/방사선), 수명(壽命/羞明), RIS, 통상증상, 사망지수
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+965
View File
@@ -0,0 +1,965 @@
{
"t_6a25adb2db": {
"easy": "현황 분석을 위해 간호사가 직접 새롭게 수집한 자료로, 모으기는 어렵지만 대표성이 높습니다.",
"example": "보건소 간호사가 주민을 직접 만나 건강 설문조사를 하는 장면",
"confidence": "high"
},
"t_1c2b950a6e": {
"easy": "통계자료나 기존 보고서처럼 다른 기관이 이미 수집해 둔 자료를 활용하는 것입니다.",
"example": "간호사가 통계청 사망원인 자료로 지역 주요 건강문제를 확인하는 장면",
"confidence": "high"
},
"t_0b51bd834f": {
"easy": "PEARL 기준 중 하나인 수용성으로, 지역사회 주민이 그 사업을 받아들일 수 있는지를 판단합니다.",
"example": "주민 설명회에서 새 금연사업에 대한 주민들의 반응을 확인하는 장면",
"confidence": "high"
},
"t_48e55a33d0": {
"easy": "기초우선순위평정법으로, 문제의 크기·심각도·사업 효과를 점수로 매겨 보건사업 우선순위를 정하는 방법입니다.",
"example": "보건소 팀이 비만·흡연·고혈압 문제에 점수를 매겨 우선순위를 정하는 장면",
"confidence": "high"
},
"t_69a91cd3fb": {
"easy": "BPRS(기초우선순위평정법)의 영어 이름으로, 점수를 매겨 보건사업 우선순위를 정하는 방법입니다.",
"example": null,
"confidence": "high"
},
"t_4b5c0e5695": {
"easy": "PEARL 기준 중 하나인 경제적 타당성으로, 사업이 경제적으로 실행할 만한지를 판단합니다.",
"example": null,
"confidence": "high"
},
"t_33f4c1783f": {
"easy": "PEARL 기준 중 하나인 적법성으로, 사업이 현행 법에 어긋나지 않는지를 판단합니다.",
"example": null,
"confidence": "high"
},
"t_16e8b7d240": {
"easy": "북미간호진단협회로, 간호진단을 분류한 진단체계를 개발하고 승인하는 단체입니다.",
"example": "간호사가 NANDA 진단명으로 '비효과적 건강관리'를 기록하는 장면",
"confidence": "high"
},
"t_dbd5872bbb": {
"easy": "미국 오마하 방문간호협회가 개발한 분류체계로, 지역사회 간호에서 문제·중재·결과를 분류할 때 씁니다.",
"example": "방문간호사가 오마하 체계에 따라 대상자 문제를 분류해 기록하는 장면",
"confidence": "high"
},
"t_4ddc5d8409": {
"easy": "BPRS로 우선순위를 정한 뒤 적절성·경제성·수용성·자원·적법성으로 사업 실현 가능성을 판단하는 기준입니다.",
"example": "우선순위 1위 사업이 예산이나 법적 문제로 실행 가능한지 점검하는 장면",
"confidence": "high"
},
"t_ffefbd9f07": {
"easy": "PEARL 기준 중 하나인 적절성으로, 그 사업이 기관의 업무 범위에 맞고 적절한지를 판단합니다.",
"example": null,
"confidence": "high"
},
"t_0189424a5a": {
"easy": "PEARL 기준 중 하나인 자원 이용 가능성으로, 사업에 쓸 인력·예산 등 자원이 있는지를 판단합니다.",
"example": null,
"confidence": "high"
},
"t_1556355684": {
"easy": "좋은 목표가 갖춰야 할 기준으로, 구체적이고 측정 가능하며 성취 가능하고 관련성 있으며 기한이 있어야 합니다.",
"example": "'올해 12월까지 참여자 50%가 주 3회 운동'처럼 목표를 다듬는 장면",
"confidence": "high"
},
"t_c727632c30": {
"easy": "온라인에서 사람들이 관계를 맺고 정보를 나누는 서비스로, 보건교육과 홍보 매체로 활용됩니다.",
"example": "보건소가 SNS에 금연 캠페인 영상을 올리는 장면",
"confidence": "high"
},
"t_cd1b646ebd": {
"easy": "SWOT 전략 중 강점(S)을 살려 기회(O)를 활용하는 전략입니다.",
"example": null,
"confidence": "high"
},
"t_9b02d9974c": {
"easy": "SWOT 전략 중 강점(S)을 활용해 위협(T)에 대응하는 전략입니다.",
"example": null,
"confidence": "high"
},
"t_b443a8f7b0": {
"easy": "조직 내부의 강점·약점과 외부의 기회·위협을 분석해 전략을 세우는 방법입니다.",
"example": "보건소 팀이 인력·예산 상황과 정책 변화를 표로 정리해 전략을 짜는 장면",
"confidence": "high"
},
"t_6f30d5e36b": {
"easy": "SNS의 영어 이름으로, 온라인 관계망 서비스를 말합니다.",
"example": null,
"confidence": "high"
},
"t_62380fcbea": {
"easy": "설문지를 이용해 주민에게 직접 정보를 모으는 지역조사 방법입니다.",
"example": "간호사가 가구를 방문해 주민 건강행태 설문지를 받는 장면",
"confidence": "high"
},
"t_d29f180f1b": {
"easy": "SWOT 전략 중 기회(O)를 활용해 약점(W)을 보완하는 전략입니다.",
"example": null,
"confidence": "high"
},
"t_1c01a65c7a": {
"easy": "SWOT 전략 중 약점(W)을 줄이고 위협(T)을 피하는 방어적 전략입니다.",
"example": null,
"confidence": "high"
},
"t_0c068fc3cd": {
"easy": "가정간호 서비스를 코드화하고 분류한 체계로, 지역사회 간호와 가정간호 기록에 주로 쓰입니다.",
"example": null,
"confidence": "high"
},
"t_e6e55f8c7e": {
"easy": "지역사회 자료를 직접 모으지 않고 이미 있는 기존 자료를 활용해 수집하는 방법입니다.",
"example": "간호사가 보건소 연보와 통계자료로 지역 현황을 파악하는 장면",
"confidence": "high"
},
"t_aafb032639": {
"easy": "간호중재를 한 뒤 대상자에게 나타난 변화나 상태로, ICNP의 구성요소 중 하나입니다.",
"example": "교육 후 노인이 스스로 혈당을 측정하게 된 변화를 기록하는 장면",
"confidence": "high"
},
"t_3d6bb4fa60": {
"easy": "사정, 진단, 계획, 수행, 평가의 단계로 대상자의 건강문제를 체계적으로 해결하는 방법입니다.",
"example": "간호사가 마을 건강문제를 조사해 진단하고 사업을 계획·실행·평가하는 장면",
"confidence": "high"
},
"t_ff8f93e13a": {
"easy": "대상자의 건강문제를 해결하기 위해 간호사가 수행하는 활동으로, ICNP의 구성요소 중 하나입니다.",
"example": "방문간호사가 고혈압 노인에게 저염식을 교육하는 장면",
"confidence": "high"
},
"t_74ceba7fa3": {
"easy": "간호진단을 일정한 기준으로 분류해 놓은 체계로, NANDA, 오마하, ICNP 등이 있습니다.",
"example": null,
"confidence": "high"
},
"t_daec431cdf": {
"easy": "보건사업 수행 단계에서 직원과 주민의 활동이 계획대로 이루어지도록 지도하고 살피는 활동입니다.",
"example": "보건소 팀장이 방문간호사의 업무 수행을 확인하고 조언하는 장면",
"confidence": "high"
},
"t_df9b5272f9": {
"easy": "주민의 뜻보다 행정적 필요로 강제로 동원되는 주민참여 형태입니다.",
"example": "행정 지시로 주민들이 원치 않는 행사에 동원되는 장면",
"confidence": "high"
},
"t_e4580f182a": {
"easy": "SWOT 분석에서 조직 내부가 가진 장점이나 유리한 요소를 말합니다.",
"example": "보건소에 경험 많은 방문간호 인력이 있다는 점을 강점으로 정리하는 장면",
"confidence": "high"
},
"t_2204e9d1f4": {
"easy": "대상자 각자의 특성과 상황이 다르므로 개인에게 맞추어 접근해야 한다는 상담·면담의 원리입니다.",
"example": "방문간호사가 같은 당뇨 노인이라도 생활 형편에 맞춰 교육 내용을 바꾸는 장면",
"confidence": "medium"
},
"t_829cccf6b0": {
"easy": "주민참여 단계 중 하나로, 주민이 보건사업의 의사결정 과정에 개입하려 하고 사업 과정의 공개를 요구하는 참여 형태입니다.",
"example": "주민대표가 보건사업 예산 사용 내역을 공개해 달라고 요구하는 장면",
"confidence": "medium"
},
"t_16853e1c7b": {
"easy": "건강유지에 실패해 이미 나타난 현재의 건강문제를 말합니다.",
"example": "방문간호사가 혈압 조절이 안 되는 노인의 상태를 건강결핍으로 분류하는 장면",
"confidence": "high"
},
"t_472671368b": {
"easy": "주민의 건강상태를 주기적으로 확인하고 문제를 조기에 발견하며 자가관리를 지지하는 공간입니다.",
"example": "마을회관 건강관리실에서 간호사가 주민 혈압을 재고 상담하는 장면",
"confidence": "high"
},
"t_b797c1c219": {
"easy": "지금 건강문제는 아니지만 앞으로 문제가 될 수 있는 잠재적 위험요소를 말합니다.",
"example": "흡연 중인 젊은 주민을 간호사가 건강위협 대상으로 분류하는 장면",
"confidence": "high"
},
"t_05cddbd6fc": {
"easy": "사업 결과로 대상자의 건강상태나 행동이 어떻게 달라질지를 정한 목표입니다.",
"example": "'사업 후 참여자 평균 혈압 10% 감소'처럼 결과목표를 세우는 장면",
"confidence": "high"
},
"t_b1d358f783": {
"easy": "보건사업의 최종 목표가 달성되었는지를 생리적 지표, 유병률, 사망률 등으로 평가하는 것입니다.",
"example": "사업 후 참여자의 혈당 수치 변화를 측정해 효과를 평가하는 장면",
"confidence": "high"
},
"t_0615566fdd": {
"easy": "자료 분석 과정의 마지막 단계로, 정리한 자료를 바탕으로 지역사회 건강문제에 대한 결론을 이끌어 내는 단계입니다.",
"example": null,
"confidence": "medium"
},
"t_683b1c20b5": {
"easy": "우선순위 결정 기준 중 하나로, 건강문제가 지역사회나 개인에게 주는 경제적 손실을 말합니다.",
"example": null,
"confidence": "high"
},
"t_d45eac514e": {
"easy": "간호방법이 경제적으로 수행 가능하고 비용 면에서 효과적인지를 판단하는 것입니다.",
"example": "같은 효과라면 비용이 적은 집단교육을 선택하는 장면",
"confidence": "high"
},
"t_ae6e139fc2": {
"easy": "우선순위 결정 기준 중 하나로, 건강문제가 얼마나 심각한지를 조기사망, 장애 정도 등으로 판단합니다.",
"example": null,
"confidence": "high"
},
"t_fac94b44e6": {
"easy": "수행 단계에서 미리 세운 계획에 따라 실제로 사업 활동을 실행하는 것을 말합니다.",
"example": null,
"confidence": "low"
},
"t_4f527a3fbe": {
"easy": "보건소나 마을회관처럼 정해진 장소에 고정해서 운영하는 건강관리실입니다.",
"example": "보건소 1층 건강관리실에 주민들이 혈압을 재러 오는 장면",
"confidence": "high"
},
"t_27f5ad3a77": null,
"t_b366e567fb": {
"easy": "조직 목표 달성을 위해 구성원의 역할, 권한, 책임을 공식적으로 정해 놓은 조직입니다.",
"example": null,
"confidence": "high"
},
"t_8d060dd4b5": {
"easy": "사업이 계획대로 진행되는지 확인하기 위해 수행 과정에서 달성할 활동을 정한 목표입니다.",
"example": "'매주 2회 건강교실 운영, 참여율 80%'처럼 과정목표를 세우는 장면",
"confidence": "high"
},
"t_1f8bc92a95": {
"easy": "보건사업이 진행되는 중에 계획과 실제 진행을 비교해 사업 내용을 조정하는 평가입니다.",
"example": "사업 중간에 참여율이 낮아 교육 시간을 저녁으로 바꾸는 장면",
"confidence": "high"
},
"t_71490c7262": {
"easy": "목표 기준 중 하나로, 목표가 사업 목적과 건강문제 해결에 직접 관련되어야 한다는 것입니다.",
"example": null,
"confidence": "high"
},
"t_98f672125c": {
"easy": "주민참여 단계 중 하나로, 계획부터 수행까지 주민이 함께하지만 최종 결정권은 행정부가 가지는 형식적 참여 단계입니다.",
"example": "공청회에서 주민 의견을 듣지만 결정은 행정기관이 하는 장면",
"confidence": "medium"
},
"t_d3c12dc43b": {
"easy": "보건사업에 투입된 인력, 시설, 장비가 적절했는지를 평가하는 것입니다.",
"example": "건강교실 강사 수와 교육실 크기가 적절했는지 점검하는 장면",
"confidence": "high"
},
"t_88895b2812": {
"easy": "목표 기준 중 하나로, 목표를 구체적이고 명확하게 써야 한다는 것입니다.",
"example": "'운동 늘리기' 대신 '주 3회 30분 걷기'로 목표를 고쳐 쓰는 장면",
"confidence": "high"
},
"t_8228836236": {
"easy": "일반적 목표를 이루기 위한 단계별 세부 행동목표입니다.",
"example": "'고혈압 관리' 목표 아래 '매일 혈압 기록하기'를 세부 목표로 정하는 장면",
"confidence": "high"
},
"t_e0035b6b01": {
"easy": "보건교육에서 학습자가 교육 후 할 수 있어야 하는 구체적인 행동을 적은 목표입니다.",
"example": "'교육 후 참여자는 혈압계를 바르게 사용할 수 있다'처럼 목표를 쓰는 장면",
"confidence": "high"
},
"t_66af498f57": {
"easy": "국제간호협회(ICN)가 개발한 간호실무 분류체계(ICNP)로, 간호현상·중재·결과를 공통 용어로 표현합니다.",
"example": null,
"confidence": "high"
},
"t_007f78b474": {
"easy": "간호방법이 기술적으로 실행 가능하고 효과가 있는지를 판단하는 것입니다.",
"example": "보건소 장비와 인력으로 골밀도 검사가 가능한지 검토하는 장면",
"confidence": "high"
},
"t_e4fed1fdc6": {
"easy": "건강결정요인에 직·간접적으로 영향을 주는 요인을 말합니다.",
"example": "낮은 소득이 나쁜 식습관을 거쳐 비만에 영향을 주는 상황",
"confidence": "high"
},
"t_4e7b752a09": {
"easy": "BPRS로, 문제의 크기, 심각도, 사업 효과를 점수로 매겨 보건사업 우선순위를 정하는 방법입니다.",
"example": null,
"confidence": "high"
},
"t_afbe5aa153": {
"easy": "목표 기준 중 하나로, 목표를 언제까지 달성할지 기한을 분명히 해야 한다는 것입니다.",
"example": "'올해 12월까지'처럼 목표 달성 시점을 정하는 장면",
"confidence": "high"
},
"t_9c19ac987b": {
"easy": "SWOT 분석에서 조직 외부 환경 중 유리하게 활용할 수 있는 요소를 말합니다.",
"example": "정부가 치매 예산을 늘린 것을 보건소의 기회 요인으로 정리하는 장면",
"confidence": "high"
},
"t_b48dc6856d": {
"easy": "보건사업 기획을 위해 가장 먼저 기획에 참여할 사람들로 팀을 구성하는 단계입니다.",
"example": "보건소가 간호사·영양사·주민대표로 기획팀을 꾸리는 장면",
"confidence": "high"
},
"t_2f2241c59f": {
"easy": "우선순위 결정 기준 중 하나로, 문제가 얼마나 긴급하고 주민이 중요하게 여기는지를 말합니다.",
"example": null,
"confidence": "high"
},
"t_2bf0716ead": {
"easy": "보건사업을 실제로 수행하는 기관 내부의 실무자가 하는 평가입니다.",
"example": "보건소 간호사들이 직접 자기 사업의 실적을 평가하는 장면",
"confidence": "high"
},
"t_43a652e92d": {
"easy": "보건사업의 투입·활동·산출·결과가 어떻게 이어지는지 도식으로 보여 주는 기획·평가 모형입니다.",
"example": "간호사가 '인력·예산→교육→참여자 수→혈압 감소'를 그림으로 정리하는 장면",
"confidence": "high"
},
"t_d457a70d46": {
"easy": "비교적 짧은 기간 안에 달성할 목표로, 주로 지식이나 태도의 변화를 다룹니다.",
"example": "교육 직후 참여자의 저염식 지식이 높아졌는지 확인하는 장면",
"confidence": "medium"
},
"t_329fad2911": null,
"t_784973b8c6": {
"easy": "신문, 방송, 인터넷처럼 많은 사람에게 한꺼번에 정보를 전달하는 매체입니다.",
"example": "보건소가 지역 방송에 독감 예방접종 안내를 내보내는 장면",
"confidence": "high"
},
"t_6b4fafd4a4": {
"easy": "주민참여 단계 중 가장 낮은 단계로, 주민의 자발성이 거의 없이 행정의 필요로 강제로 참여시키는 형식적 참여입니다.",
"example": "감염병 방역 활동에 행정 지시로 주민들이 일괄 동원되는 장면",
"confidence": "medium"
},
"t_d2a78c45a0": {
"easy": "암처럼 국가 등록체계에 등록되어 관리되는 질병으로, 지역사회 사정의 자료가 됩니다.",
"example": "간호사가 암 등록 통계로 지역의 주요 암 발생 현황을 확인하는 장면",
"confidence": "high"
},
"t_8158d9be9c": {
"easy": "많은 사람에게 한꺼번에 정보를 전달하기 위한 홍보·교육 자료와 기자재를 말합니다.",
"example": "보건소가 금연 포스터와 리플릿을 만들어 주민센터에 비치하는 장면",
"confidence": "high"
},
"t_0fed40901c": {
"easy": "보건사업 평가에서 처음 세운 목표를 얼마나 이루었는지를 확인하는 것입니다.",
"example": "목표 참여자 100명 중 실제 85명이 교육을 마쳤는지 확인하는 장면",
"confidence": "high"
},
"t_ff11f1ee57": {
"easy": "지역사회 간호계획의 출발점으로, 사업이 이루어야 할 바를 정해 활동 방향과 평가 기준을 마련하는 단계입니다.",
"example": "보건소 팀이 비만 사업의 목표를 수치로 정하는 회의 장면",
"confidence": "high"
},
"t_6dfb8eda6a": {
"easy": "목표에 들어가야 할 요소로, 무엇을, 누가, 어디서, 언제까지, 얼마나 달성할지를 말합니다.",
"example": "'○○동 노인의 70%가 연말까지 독감 예방접종을 받는다'처럼 쓰는 장면",
"confidence": "medium"
},
"t_be864983cc": {
"easy": "대상자의 말이나 행동을 판단하거나 비난하지 않고 받아들여야 한다는 상담·면담의 원리입니다.",
"example": "술을 끊지 못한 대상자를 간호사가 나무라지 않고 이야기를 듣는 장면",
"confidence": "medium"
},
"t_4948a1e0c5": {
"easy": "현재 건강문제는 없지만 임신, 노화처럼 건강에 대한 특별한 요구가 생기는 시기를 말합니다.",
"example": "첫 출산을 앞둔 임신부에게 간호사가 미리 육아 교육을 하는 장면",
"confidence": "high"
},
"t_132deefcfe": {
"easy": "조직이 존재하는 이유와 현재 나아갈 방향을 밝힌 것으로, 사명이라고도 합니다.",
"example": "보건소가 '주민 모두의 건강한 삶 지원'을 기관의 사명으로 내거는 장면",
"confidence": "high"
},
"t_78f40f1561": {
"easy": "가정방문 전에 대상자 기록 확인, 방문 목적과 계획 수립, 물품 준비, 연락 등을 하는 활동입니다.",
"example": "방문간호사가 출발 전 대상자 기록을 확인하고 방문가방을 챙기는 장면",
"confidence": "high"
},
"t_bb0ca0fd52": {
"easy": "가정방문 중 대상자와 관계를 맺고 상태를 사정하며 간호와 교육을 제공하는 활동입니다.",
"example": "방문간호사가 노인 집에서 혈압을 재고 복약 방법을 설명하는 장면",
"confidence": "high"
},
"t_dfa46793a1": {
"easy": "가정방문 후 기록 정리, 결과 평가, 필요한 의뢰와 다음 방문 계획 등을 하는 활동입니다.",
"example": "방문간호사가 보건소로 돌아와 방문 기록을 작성하고 다음 일정을 잡는 장면",
"confidence": "high"
},
"t_231a23546a": {
"easy": "가정방문 시 필요한 간호 물품을 담는 가방으로, 물품의 청결을 유지하고 감염을 막기 위해 사용합니다.",
"example": "방문간호사가 가방을 깨끗한 곳에 놓고 손을 씻은 뒤 물품을 꺼내는 장면",
"confidence": "high"
},
"t_a037fc73a6": {
"easy": "보건소나 의료기관에 오기 어려운 대상자의 가정을 찾아가 간호서비스를 제공하는 활동입니다.",
"example": "방문간호사가 거동이 불편한 노인 집을 찾아가 건강을 살피는 장면",
"confidence": "high"
},
"t_a68dfb5520": {
"easy": "간호사가 건강문제를 해결하기 위해 사용하는 중재의 형태, 전략, 접근 방식을 말합니다.",
"example": "고혈압 관리 방법으로 집단교육과 가정방문 중 무엇을 쓸지 정하는 장면",
"confidence": "high"
},
"t_91d61faa80": {
"easy": "목표 달성에 적합하고 실행 가능한 간호방법과 필요한 자원을 골라 정하는 과정입니다.",
"example": "여러 교육 방법의 효과와 비용을 비교해 가장 알맞은 방법을 고르는 장면",
"confidence": "high"
},
"t_4da5175b7c": {
"easy": "목표를 이루기 위한 활동이 법률이나 제도에 맞는지를 판단하는 것입니다.",
"example": null,
"confidence": "high"
},
"t_9fee01fba6": {
"easy": "우선순위 결정 기준으로, 사업을 통해 건강문제가 실제로 바뀔 수 있는지를 평가하는 것입니다.",
"example": "유전 질환보다 운동으로 개선 가능한 비만을 우선 사업으로 고르는 장면",
"confidence": "high"
},
"t_db468f37b2": null,
"t_efce7b0ab4": {
"easy": "정기적 조사 외에 조사기관의 필요에 따라 수시로 실시하는 조사입니다.",
"example": "감염병 유행 후 보건소가 임시로 주민 예방행동 조사를 하는 장면",
"confidence": "high"
},
"t_f5081d1dd3": {
"easy": "자료분석의 첫 단계로, 수집한 자료를 인구, 환경, 건강상태 등 범주별로 나누는 단계입니다.",
"example": "간호사가 수집한 자료를 인구·환경·건강 항목으로 분류하는 장면",
"confidence": "high"
},
"t_5f3e51b275": {
"easy": "보건사업 우선순위를 정하는 결정기준을 제시한 학자입니다.",
"example": null,
"confidence": "medium"
},
"t_ee6b3a9f43": {
"easy": "상담이나 방문 중 알게 된 대상자의 정보를 동의 없이 다른 사람에게 알리지 않아야 한다는 원리입니다.",
"example": "방문간호사가 대상자의 병명을 이웃에게 말하지 않는 장면",
"confidence": "medium"
},
"t_8245ec7f46": {
"easy": "조직이 미래에 도달하고자 하는 모습으로, 목표와 운영 기준을 제시하고 구성원의 참여를 이끕니다.",
"example": "보건소가 '건강 격차 없는 우리 시'라는 미래 비전을 정하는 장면",
"confidence": "high"
},
"t_2d7a30a9b9": {
"easy": "주민참여 단계 중 주민의 실질적인 참여가 거의 없는 가장 낮은 단계입니다.",
"example": null,
"confidence": "medium"
},
"t_247eaaac37": {
"easy": "목표를 이루기 위한 전략과 세부 사업 내용을 구체적으로 세우는 것을 말합니다.",
"example": null,
"confidence": "medium"
},
"t_6538e3892a": {
"easy": "세운 계획에 따라 보건사업을 실제로 실행하는 것을 말합니다.",
"example": null,
"confidence": "low"
},
"t_2df2f9934a": {
"easy": "사업 계획이 목표 달성에 알맞게 세워졌는지, 인적·물적 자원이 충분한지를 평가하는 것입니다.",
"example": null,
"confidence": "medium"
},
"t_a8348acf05": {
"easy": "SWOT 분석 등을 통해 사업 목표를 달성하기 위해 세우는 전략입니다.",
"example": null,
"confidence": "medium"
},
"t_faf12c3057": {
"easy": "보건사업이 계획대로 진행되고 있는지를 평가하는 것으로, 과정 평가에 해당합니다.",
"example": "월별 교육 횟수가 계획대로 진행되는지 점검하는 장면",
"confidence": "high"
},
"t_99dd0ee4a5": {
"easy": "보건사업이 시작되어 끝날 때까지 진행되는 과정을 말하며, 과정 평가의 대상이 됩니다.",
"example": null,
"confidence": "low"
},
"t_da4169879c": {
"easy": "보건사업이 목표를 얼마나 이루었고 효율적이었는지를 평가하는 것입니다.",
"example": "연말에 보건소 팀이 사업 실적과 효과를 정리해 평가하는 장면",
"confidence": "medium"
},
"t_a67529bb3f": {
"easy": "사업에 투입한 자원에 비해 얼마나 많은 산출을 얻었는지를 평가하는 것입니다.",
"example": "같은 예산으로 몇 명의 주민이 검진을 받았는지 계산하는 장면",
"confidence": "high"
},
"t_9324f02568": {
"easy": "구성원의 개인적·사회적 필요에 따라 자연스럽게 생겨난 비공식 조직입니다.",
"example": "보건소 직원들이 자발적으로 만든 등산 모임",
"confidence": "high"
},
"t_2b688d88fb": {
"easy": "사업 대상자가 그 사업을 받아들일 수 있는지를 확인하는 것입니다.",
"example": "주민 공청회에서 새 사업에 대한 주민 반응을 확인하는 장면",
"confidence": "high"
},
"t_94c1c0044b": {
"easy": "의료기관의 입원·외래 환자 통계 등 질병과 상해에 관한 자료입니다.",
"example": null,
"confidence": "high"
},
"t_93c94bab46": {
"easy": "미리 정한 질문에 응답하게 해 정보를 모으는 조사 도구입니다.",
"example": "보건소 간호사가 주민 건강행태 설문지를 나눠 주는 장면",
"confidence": "high"
},
"t_29b4e2ed42": {
"easy": "세부 사업마다 목표, 방법, 일정, 예산 등을 구체적으로 적은 계획서입니다.",
"example": "간호사가 금연교실 사업의 일정과 예산을 계획서로 작성하는 장면",
"confidence": "high"
},
"t_afd2495321": {
"easy": "간호활동을 실행하는 데 필요한 인적·물적·재정적·행정적 자원을 말합니다.",
"example": "건강교실 운영에 필요한 강사, 교육실, 예산을 확보하는 장면",
"confidence": "high"
},
"t_c411c09116": {
"easy": "대상자를 있는 그대로 존중하고 받아들여야 한다는 상담·면담의 원리입니다.",
"example": "간호사가 위생 상태가 나쁜 가정도 편견 없이 대하는 장면",
"confidence": "medium"
},
"t_71aed10c59": {
"easy": "간호 활동을 누가, 언제, 어디서, 무엇을 가지고 할지 구체적으로 정하는 계획입니다.",
"example": "간호사 2명이 매주 화요일 경로당에서 혈압계로 측정하도록 정하는 장면",
"confidence": "high"
},
"t_aca62655e6": {
"easy": "법정감염병처럼 발생 시 신고하도록 되어 있는 질병으로, 지역사회 사정의 자료가 됩니다.",
"example": "병원에서 결핵 환자를 보건소에 신고하는 장면",
"confidence": "high"
},
"t_661f4eaaec": {
"easy": "목표 기준 중 하나로, 목표가 노력하면 달성할 수 있는 현실적인 수준이어야 한다는 것입니다.",
"example": null,
"confidence": "high"
},
"t_00b0a368ea": {
"easy": "SWOT 분석에서 조직 내부의 부족한 점이나 불리한 요소를 말합니다.",
"example": "보건소 인력 부족과 직원 고령화를 약점으로 정리하는 장면",
"confidence": "high"
},
"t_20323b4d41": {
"easy": "대표성 있는 표본에게 구조화된 설문으로 수집한 숫자로 나타낼 수 있는 자료입니다.",
"example": "전화 설문으로 주민 흡연율을 조사하는 장면",
"confidence": "high"
},
"t_53b5546fd3": {
"easy": "사업으로 대상자의 지식, 태도, 행동 등에 어떤 변화가 생길지를 정한 목표입니다.",
"example": "'교육 후 참여자의 80%가 주 3회 걷기 실천'처럼 영향목표를 세우는 장면",
"confidence": "high"
},
"t_2fb3c966fe": {
"easy": "보건사업의 단기 결과로 나타나는 지식, 태도, 기술, 행동의 변화를 측정하는 평가입니다.",
"example": "교육 전후 참여자의 저염식 실천 여부를 비교하는 장면",
"confidence": "high"
},
"t_8843e9c243": {
"easy": "지역사회, 가족, 개인 수준의 건강문제를 체계적으로 파악하고 진술하기 위한 오마하 체계의 분류입니다.",
"example": "방문간호사가 대상자 문제를 '환경·심리사회·생리·건강행위' 영역으로 분류하는 장면",
"confidence": "high"
},
"t_2de8cd6e63": {
"easy": "문제분류, 중재, 결과평가로 구성되어 지역사회간호와 공중보건에서 많이 쓰이는 통합 분류체계입니다.",
"example": "방문간호사가 오마하 체계로 문제·중재·결과를 기록하는 장면",
"confidence": "high"
},
"t_7621e0acf9": {
"easy": "사업을 수행하는 기관이 아닌 외부 전문기관이나 전문가가 하는 평가입니다.",
"example": "대학 연구팀이 보건소 사업의 효과를 평가하는 장면",
"confidence": "high"
},
"t_47c44eacae": {
"easy": "자료분석 단계 중 하나로, 분류한 자료를 표나 그래프로 간추려 정리하는 단계입니다.",
"example": "간호사가 조사 결과를 표와 그래프로 정리하는 장면",
"confidence": "high"
},
"t_8fe147bc58": {
"easy": "여러 건강문제 가운데 무엇부터 해결할지 기준에 따라 순서를 정하는 것입니다.",
"example": "보건소 팀이 여러 건강문제 중 먼저 다룰 사업을 고르는 장면",
"confidence": "high"
},
"t_ab4cfb8401": {
"easy": "SWOT 분석에서 조직의 목표 달성을 방해할 수 있는 외부 요인을 말합니다.",
"example": "예산 삭감과 신종 감염병 유행을 위협 요인으로 정리하는 장면",
"confidence": "high"
},
"t_f6f5f67a5d": {
"easy": "가장 흔한 주민참여 형태로, 보상 등을 활용해 주민의 참여를 이끌어 내는 것입니다.",
"example": "걷기 대회 참가자에게 기념품을 주어 참여를 이끄는 장면",
"confidence": "high"
},
"t_968fcae2a1": {
"easy": "대상자가 감정을 자유롭게 표현할 수 있도록 의도적으로 돕는 상담·면담의 원리입니다.",
"example": "간호사가 돌봄에 지친 보호자가 속마음을 털어놓도록 기다려 주는 장면",
"confidence": "medium"
},
"t_596290bb31": {
"easy": "차량 등을 이용해 이동하며 운영하는 건강관리실로, 교통이 불편한 지역 주민에게 찾아갑니다.",
"example": "이동검진 차량이 산간 마을을 돌며 주민 건강검진을 하는 장면",
"confidence": "high"
},
"t_a1131419c3": {
"easy": "보건사업의 기획, 실행, 평가에 관련된 기관과 주민이 함께 참여하는 것을 말합니다.",
"example": null,
"confidence": "low"
},
"t_61683186b0": {
"easy": "원인과 결과의 관계로, 목표 설정 시 건강문제와 그 결정요인 간의 관계를 근거로 삼습니다.",
"example": "흡연(원인)과 폐암(결과)의 관계를 근거로 금연을 목표로 정하는 장면",
"confidence": "high"
},
"t_98b96aca6f": {
"easy": "지역사회 간호사업이 궁극적으로 달성하려는 최종 성과를 넓게 제시한 목표입니다.",
"example": "'지역 주민의 심뇌혈관질환 예방'을 일반적 목표로 정하는 장면",
"confidence": "high"
},
"t_0f9144f335": {
"easy": "보건교육을 통해 대상자가 궁극적으로 갖추어야 할 능력과 역할을 넓게 제시한 학습 목표입니다.",
"example": "'참여자는 스스로 혈압을 관리할 수 있다'를 일반 학습 목표로 정하는 장면",
"confidence": "high"
},
"t_b051d66a12": {
"easy": "사업 활동을 언제 시작하고 끝낼지 시간 순서에 따라 정하는 계획입니다.",
"example": null,
"confidence": "low"
},
"t_a49332b8a8": {
"easy": "대상자가 스스로 선택하고 결정할 권리를 존중해야 한다는 상담·면담의 원리입니다.",
"example": "간호사가 여러 치료 방법을 설명하고 최종 선택은 대상자에게 맡기는 장면",
"confidence": "medium"
},
"t_c33cddc1c1": {
"easy": "수집한 자료를 분류, 요약, 확인(비교), 결론의 단계를 거쳐 건강문제를 찾아내는 과정입니다.",
"example": "간호사가 조사 자료를 정리해 지역의 주요 건강문제를 도출하는 장면",
"confidence": "high"
},
"t_e7acd0c9ad": {
"easy": "지역사회의 건강상태와 자원 등을 파악하기 위해 1차·2차 자료를 모으는 과정입니다.",
"example": "간호사가 설문조사와 통계자료로 지역 정보를 모으는 장면",
"confidence": "high"
},
"t_9543e1c550": {
"easy": "기존 통계 등 2차 자료 활용과, 면담·관찰·설문조사·참여관찰 등으로 1차 자료를 직접 모으는 방법이 있습니다.",
"example": "간호사가 통계자료를 검토한 뒤 마을을 돌며 주민과 면담하는 장면",
"confidence": "high"
},
"t_a061908779": {
"easy": "지역사회 사정에 쓰이는 자료의 종류로, 1차·2차 자료나 양적·질적 자료 등으로 나눕니다.",
"example": null,
"confidence": "low"
},
"t_3d610dee7d": {
"easy": "주민이 주도권을 가지고 스스로 참여하는 가장 이상적인 주민참여 형태입니다.",
"example": "주민들이 스스로 걷기 모임을 만들고 보건소에 지원을 요청하는 장면",
"confidence": "high"
},
"t_564f931865": {
"easy": "지역사회에서 활용할 수 있는 기관, 인력, 서비스 등의 자원을 정리해 둔 목록입니다.",
"example": "방문간호사가 지역 복지관, 병원, 봉사단체 연락처를 정리한 목록을 활용하는 장면",
"confidence": "low"
},
"t_d1c31c4b1f": {
"easy": "대상자의 문제 해결에 필요한 다른 기관이나 전문가에게 대상자를 연결해 보내는 것입니다.",
"example": "방문간호사가 생계가 어려운 노인을 주민센터 복지담당에게 의뢰하는 장면",
"confidence": "high"
},
"t_06629ffc33": {
"easy": "여러 기관과 인력 등 자원이 중복이나 누락 없이 효율적으로 쓰이도록 조정하는 것입니다.",
"example": null,
"confidence": "low"
},
"t_020e308085": {
"easy": "지역사회의 인적·물적·사회적 자원을 간호사업에 효과적으로 활용하는 것입니다.",
"example": "보건소가 마을회관과 자원봉사자를 활용해 건강교실을 여는 장면",
"confidence": "low"
},
"t_6662e69006": {
"easy": "일정 기준 나이보다 일찍 사망해 잃어버린 수명을 모두 합한 값으로, 조기사망의 부담을 보여 주는 지표입니다.",
"example": "교통사고로 젊은 사망자가 많은 지역의 손실 수명이 크게 나타나는 통계",
"confidence": "high"
},
"t_78bab787bf": {
"easy": "사업으로 오랜 기간에 걸쳐 이루려는 목표로, 주로 이환율·사망률 같은 건강상태의 변화를 다룹니다.",
"example": "'5년 뒤 지역 뇌졸중 사망률 감소'를 장기목표로 정하는 장면",
"confidence": "medium"
},
"t_890ffc49cd": {
"easy": "질병으로 일찍 사망해 잃은 수명과 장애를 안고 산 기간을 합한 지표로, 질병부담을 보여 주는 지표입니다.",
"example": null,
"confidence": "high"
},
"t_bd6f28d931": {
"easy": "간호문제를 해결하는 과정에서 계획한 중재의 수행을 방해하는 요인입니다.",
"example": "교통이 불편해 노인들이 건강교실에 오지 못하는 문제를 발견하는 장면",
"confidence": "high"
},
"t_657e267753": {
"easy": "수행 단계에서 대상자가 간호와 교육을 잘 받을 수 있도록 장소와 분위기 등 환경을 마련하는 것입니다.",
"example": null,
"confidence": "low"
},
"t_63486a3dfa": {
"easy": "중재를 수행하기 전에 필요한 조건이 갖추어졌는지 확인하는 것입니다.",
"example": null,
"confidence": "low"
},
"t_dc85673942": {
"easy": "일정한 주기로 계속 실시하는 조사입니다.",
"example": "매년 실시되는 지역사회건강조사 결과를 보건소가 활용하는 장면",
"confidence": "medium"
},
"t_4bb7d5721a": {
"easy": "지역 사정을 잘 아는 이장, 지역 지도자 등 정보원을 만나 지역 정보를 얻는 자료수집 방법입니다.",
"example": "간호사가 마을 이장을 만나 주민 건강 문제를 묻는 장면",
"confidence": "high"
},
"t_14b3c9b0e0": {
"easy": "주민참여의 기초단계로, 정책입안자 등이 지식을 제공하고 홍보해 주민의 참여의식을 높이는 단계입니다.",
"example": "보건소가 주민설명회에서 새 사업을 안내하는 장면",
"confidence": "high"
},
"t_431ecf94bb": {
"easy": "수행 단계에서 간호사, 보건요원, 주민의 업무가 서로 맞물려 차질 없이 진행되도록 조율하는 활동입니다.",
"example": "간호사가 예방접종 행사 날 인력 배치와 주민 안내를 조율하는 장면",
"confidence": "high"
},
"t_cd57998283": {
"easy": "주민참여 단계 중 가장 높은 단계로, 주민이 사업을 스스로 이끌고 결정하는 참여 형태입니다.",
"example": "주민협의체가 스스로 마을 건강사업을 기획하고 운영하는 장면",
"confidence": "medium"
},
"t_fc6363cd55": {
"easy": "주민참여 단계 중 가장 높은 수준으로, 주민이 실질적인 의사결정 권한을 갖는 단계입니다.",
"example": "주민위원회가 마을 건강사업 예산 사용을 직접 결정하는 장면",
"confidence": "medium"
},
"t_6bb41e957b": {
"easy": "부녀회, 노인회 등 지역 주민들로 이루어진 모임으로, 보건사업 홍보와 참여에 효과적으로 활용됩니다.",
"example": "간호사가 부녀회를 통해 유방암 검진을 안내하는 장면",
"confidence": "high"
},
"t_86b5d1d552": {
"easy": "기획에 필요한 정보를 제공하는 이해관계자로, 보건사업 이용자나 일반 주민이 해당합니다.",
"example": null,
"confidence": "high"
},
"t_fc3f4c96b0": {
"easy": "단기목표와 장기목표 사이의 기간에 이루려는 목표로, 주로 행동의 변화를 다룹니다.",
"example": null,
"confidence": "medium"
},
"t_d4dafb017c": {
"easy": "공중보건간호 중재를 무엇을, 누구에게(개인·지역사회·체계), 어떤 예방수준에서 할지 보여 주는 실무 틀입니다.",
"example": "간호사가 감염병 사업에서 개인 상담과 지역 홍보, 정책 제안을 함께 계획하는 장면",
"confidence": "high"
},
"t_64f6bb4e38": {
"easy": "학습 목표의 인지적 영역으로, 알고 이해하고 활용하는 지적인 능력을 말합니다.",
"example": "교육 후 참여자가 혈압 정상 범위를 말할 수 있는지 확인하는 장면",
"confidence": "high"
},
"t_26daeb7025": {
"easy": "보건소뿐 아니라 지역 주민 건강에 기여하는 모든 공공·민간·자원봉사 조직을 포함하는 체계입니다.",
"example": null,
"confidence": "high"
},
"t_ef3244bb3c": {
"easy": "지역사회 건강문제를 체계적으로 사정·진단하고 계획·수행·평가하는 순환적인 과정입니다.",
"example": "간호사가 마을 건강조사부터 사업 평가까지 단계별로 진행하는 장면",
"confidence": "high"
},
"t_7019cb03c5": {
"easy": "간호과정에서 계획한 간호중재를 실제로 수행하는 활동으로, 건강증진과 질병 예방을 위한 여러 접근을 포함합니다.",
"example": "간호사가 가정방문, 건강교실, 상담을 실제로 진행하는 장면",
"confidence": "high"
},
"t_2c0f90b484": {
"easy": "지역사회 주민 전체의 건강 상태로, 사망률·유병률 등 건강지표로 사정합니다.",
"example": "간호사가 지역 사망률과 만성질환 유병률을 다른 지역과 비교하는 장면",
"confidence": "high"
},
"t_298d42c719": {
"easy": "지역사회 건강수준을 분석할 때 사용하는 사망률, 유병률 등의 지표를 말합니다.",
"example": null,
"confidence": "high"
},
"t_c084088116": {
"easy": "현재 상황과 바람직한 상황의 차이를 확인해 해결할 건강문제와 영향요인, 해결 능력을 분석하는 과정입니다.",
"example": "보건소 팀이 지역 건강통계와 자원을 분석해 주요 문제를 찾는 장면",
"confidence": "high"
},
"t_7277e4b5a6": {
"easy": "지역사회 안에 있는 공식·비공식 조직으로, 공조직과 사조직으로 나눌 수 있습니다.",
"example": "간호사가 통장 모임, 부녀회 등 지역 조직을 파악하는 장면",
"confidence": "high"
},
"t_c9f5a3bcfb": {
"easy": "주민이 간호사업에 자발적이고 적극적으로 참여하도록 지역의 조직을 새로 구성하거나 개편하는 것입니다.",
"example": "보건소가 주민 건강지킴이 모임을 새로 만드는 장면",
"confidence": "high"
},
"t_41ff5a41ee": {
"easy": "걷거나 차를 타고 지역을 빠르게 돌아보며 환경과 생활 모습을 관찰하는 자료수집 방법입니다.",
"example": "간호사가 차를 타고 동네를 돌며 놀이터, 상가, 쓰레기 처리 상태를 살피는 장면",
"confidence": "high"
},
"t_2d764cf6c9": {
"easy": "지역 주민이 보건사업의 계획, 수행, 평가 과정에 함께하는 것을 말합니다.",
"example": "주민대표가 보건사업 기획 회의에 참석해 의견을 내는 장면",
"confidence": "high"
},
"t_5cab47ea9a": {
"easy": "보건사업의 필요성과 실행을 사회적으로 지지해 주는 집단으로, 여론 지도층이 포함됩니다.",
"example": null,
"confidence": "high"
},
"t_cc7c63c13b": {
"easy": "상담 기법 중 하나로, 대상자의 잘못된 생각이나 행동을 비난 없이 직접 짚어 주는 것입니다.",
"example": "간호사가 \"약을 잘 드신다면서 약이 그대로 남아 있네요\"라고 짚어 주는 장면",
"confidence": "high"
},
"t_7585aeb106": {
"easy": "간호사가 대상자에게 필요한 간호를 직접 제공하는 것입니다.",
"example": "방문간호사가 노인 집에서 상처 드레싱을 직접 해 주는 장면",
"confidence": "medium"
},
"t_a16736691e": {
"easy": "기존 자료가 없거나 믿기 어려울 때 간호사가 면담·관찰·설문 등으로 자료를 직접 수집하는 방법입니다.",
"example": "간호사가 마을 주민을 직접 만나 설문조사를 하는 장면",
"confidence": "high"
},
"t_31eb387f9b": {
"easy": "질병 때문에 생기는 개인과 사회의 부담으로, 의료비가 대표적입니다.",
"example": null,
"confidence": "high"
},
"t_a83209a02a": {
"easy": "소수의 대상에게 면담이나 관찰처럼 구조화되지 않은 방법으로 모은 자료입니다.",
"example": "간호사가 노인 몇 명과 깊이 있게 면담해 생활 어려움을 듣는 장면",
"confidence": "high"
},
"t_03930351ee": {
"easy": "차를 타고 지역을 돌아보며 주거, 환경, 시설, 주민 생활 모습을 빠르게 관찰하는 방법입니다.",
"example": "간호사가 차를 타고 동네를 돌며 주변 환경을 기록하는 장면",
"confidence": "high"
},
"t_20910a0cf1": {
"easy": "지역 모임이나 행사에 직접 참여해 주민의 생활과 행동을 관찰하는 자료수집 방법입니다.",
"example": "간호사가 마을 잔치에 참석해 주민들의 식습관을 관찰하는 장면",
"confidence": "high"
},
"t_07b8f493d5": {
"easy": "주민참여를 참여의 결과에 따라 나누는 분류 방법입니다.",
"example": null,
"confidence": "low"
},
"t_dcc2484a29": {
"easy": "주민참여를 참여 정도나 유형에 따라 낮은 단계에서 높은 단계로 나눈 것입니다.",
"example": null,
"confidence": "medium"
},
"t_2b4bc0d822": {
"easy": "주민참여를 참여 동기에 따라 강요된·유도된·자발적 참여로 나누는 분류입니다.",
"example": null,
"confidence": "high"
},
"t_4d59c1959d": {
"easy": "주민참여를 주민이 얼마나 주도하는지에 따라 단계별로 나누는 분류입니다.",
"example": null,
"confidence": "medium"
},
"t_08b5548163": {
"easy": "필요에 따라 기획과 평가에 참여시키거나 자문을 구하는 대상으로, 관련 전문단체나 시민단체 인사가 해당합니다.",
"example": null,
"confidence": "high"
},
"t_ff7ee6c946": {
"easy": "주민참여의 가장 높은 단계로, 주민이 정책 입안부터 수행·평가까지 모든 권한을 갖고 스스로 관리하는 단계입니다.",
"example": "주민자치회가 마을 건강사업을 직접 결정하고 운영하는 장면",
"confidence": "high"
},
"t_a85e20435d": {
"easy": "보건사업을 투입·과정·산출 등 체계의 요소로 나누어 평가하는 모형입니다.",
"example": null,
"confidence": "medium"
},
"t_67e33d037e": {
"easy": "비슷한 특성을 가진 소수 주민을 모아 특정 주제에 대해 자유롭게 토론하게 하여 자료를 얻는 방법입니다.",
"example": "간호사가 청소년 6~8명과 흡연에 대해 함께 이야기 나누는 장면",
"confidence": "high"
},
"t_06fac585db": {
"easy": "목표 기준 중 하나로, 목표를 숫자와 행동 용어로 측정할 수 있게 써야 한다는 것입니다.",
"example": "'건강해진다' 대신 '혈압 140/90 미만 유지'로 목표를 쓰는 장면",
"confidence": "high"
},
"t_57ffadd45f": {
"easy": "우선순위 결정 기준 중 하나로, 건강문제가 가족이나 집단 등 다른 사람에게 주는 사회적 영향을 말합니다.",
"example": null,
"confidence": "high"
},
"t_6903cc3031": {
"easy": "투입, 과정, 산출의 흐름으로 사업이 계획대로 수행되었는지 점검하는 평가 모형입니다.",
"example": null,
"confidence": "high"
},
"t_901348434f": {
"easy": "사업에 들어간 인력, 예산, 시설 등 자원이 적절했는지를 평가하는 것입니다.",
"example": "사업 후 투입한 인력과 예산이 계획과 맞았는지 확인하는 장면",
"confidence": "high"
},
"t_af97df0325": {
"easy": "누가, 언제, 무엇을, 어떤 도구와 기준으로 평가할지 미리 구체적으로 문서화한 계획입니다.",
"example": "간호사가 사업 시작 전 평가 시기와 설문 도구를 정해 두는 장면",
"confidence": "high"
},
"t_c005591eb9": {
"easy": "평가계획의 구성요소 중 하나로, 언제 평가할지를 정하는 것입니다.",
"example": null,
"confidence": "high"
},
"t_b7d573d5db": {
"easy": "보건사업 평가의 종류로, 투입을 보는 구조평가, 과정을 보는 과정평가, 결과를 보는 결과평가 등이 있습니다.",
"example": null,
"confidence": "high"
},
"t_b3aab86bfa": {
"easy": "평가 대상과 기준을 정하고, 자료를 모아 비교·분석한 뒤 결과를 반영하는 평가의 진행 순서입니다.",
"example": null,
"confidence": "low"
},
"t_5f3bacf77c": {
"easy": "평가를 누가 하는지를 말하며, 내부 실무자가 하는 내부평가와 외부 전문가가 하는 외부평가가 있습니다.",
"example": null,
"confidence": "medium"
},
"t_61defcb8a9": {
"easy": "상담 기법 중 하나로, 대상자의 행동이나 사건이 어떤 의미인지 설명해 주는 것입니다.",
"example": "간호사가 \"요즘 잠을 못 주무시는 게 걱정 때문일 수 있어요\"라고 설명하는 장면",
"confidence": "high"
},
"t_7171265175": {
"easy": "보건문제에 관심이 크고 사업의 신뢰성과 확산을 위해 기획·평가에 반드시 포함해야 하는 이해관계자입니다.",
"example": null,
"confidence": "high"
},
"t_a9b4853b78": {
"easy": "주민참여 단계 중 하나로, 설득으로 참여를 이끌며 사업 계획과 조정에 주민 의사를 반영하는 형태입니다.",
"example": "보건소가 주민 의견을 반영해 건강교실 시간을 조정하는 장면",
"confidence": "high"
},
"t_10f4b5f286": {
"easy": "주민참여 단계 중 하나로, 참여를 유도하지만 강제성이 있고 계획과 조정은 제공자 측이 독점하는 형태입니다.",
"example": "보건소가 정한 일정에 맞춰 주민들이 검진에 참여하도록 요청받는 장면",
"confidence": "high"
},
"t_be6145e3c5": {
"easy": "주민참여 단계 중 주민이 의견을 낼 수 있지만 실제 결정권은 없는, 겉으로만 참여하는 단계입니다.",
"example": "공청회에서 주민 의견을 듣지만 결정에는 반영하지 않는 장면",
"confidence": "medium"
},
"t_e27093b903": {
"easy": "자료분석 단계 중 하나로, 정리한 자료를 다른 지역이나 기준과 비교해 차이와 문제를 확인하는 단계입니다.",
"example": "간호사가 우리 지역 흡연율을 시·도 평균과 비교하는 장면",
"confidence": "high"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+483
View File
@@ -0,0 +1,483 @@
{
"t_98db4511f3": {
"easy": "질병이 생기기 전에 건강을 증진하고 예방접종 등으로 병을 미리 막는 단계의 예방입니다. 새로운 환자 발생을 줄이는 것이 목표입니다.",
"example": "보건소 간호사가 경로당에서 어르신들께 독감 예방접종을 하고 손 씻기를 교육하는 장면",
"confidence": "high"
},
"t_2f064f1481": {
"easy": "면역이 없는 사람이 첫 환자와 접촉한 뒤 잠복기가 지나 같은 병에 걸리는 비율입니다. 병원체의 감염력을 간접적으로 알려 줍니다.",
"example": "어린이집 첫 수두 환자 발생 후, 같은 반 면역 없는 원아 중 몇 명이 추가로 걸렸는지 세어 보는 장면",
"confidence": "high"
},
"t_03c1bf519f": {
"easy": "질병을 일찍 찾아내 빨리 치료함으로써 합병증·장애·조기사망으로 진행되는 것을 막는 예방입니다. 유병률을 줄이는 것이 목표입니다.",
"example": "보건소에서 40대 주민에게 국가암검진을 안내해 위암을 초기에 발견하도록 돕는 장면",
"confidence": "high"
},
"t_ed271a98a2": {
"easy": "이미 병이 생긴 사람의 악화와 장애를 줄이고 재활을 도와 일상생활로 돌아가게 하는 예방입니다.",
"example": "방문간호사가 뇌졸중 후 퇴원한 노인 가정을 찾아 보행 재활 운동을 지도하는 장면",
"confidence": "high"
},
"t_e17e5c9775": {
"easy": "2019년 말 시작된 코로나19가 전 세계로 퍼진 대유행으로, 감염병이 사회 전체에 큰 영향을 줄 수 있음을 보여 준 사건입니다.",
"example": "보건소 간호사들이 선별진료소에서 검사와 역학조사, 자가격리자 관리를 맡던 장면",
"confidence": "high"
},
"t_c003d41fa5": {
"easy": "인구 구성(연령 등)을 보정하지 않고 전체 인구를 분모로 계산한 비율로, 우리말로 조율이라고 합니다.",
"example": "두 지역의 전체 사망자 수를 전체 인구로 나눠 단순 비교한 조사망률을 보는 장면",
"confidence": "medium"
},
"t_83ca029e9d": {
"easy": "인구집단에서 질병과 건강 문제가 어떻게 분포하고 무엇 때문에 생기는지 연구해 예방에 활용하는 학문으로, 우리말로 역학이라고 합니다.",
"example": "보건소가 지역 식중독 발생 환자들을 조사해 공통 음식과 원인을 찾아내는 장면",
"confidence": "high"
},
"t_42c4a5459e": {
"easy": "질병의 자연사에서 병원체가 몸에 들어와 실제 병의 과정이 진행되는 시기로, 우리말로 병원성기라고 합니다.",
"example": null,
"confidence": "low"
},
"t_b7bc559480": {
"easy": "질병의 자연사에서 아직 병에 걸리지 않은 시기로, 우리말로 병원성 이전기(비병원성기)라고 합니다.",
"example": null,
"confidence": "low"
},
"t_90acf00534": {
"easy": "한 여성이 가임기 동안 평균 몇 명의 자녀를 낳을지 나타내는 지표로, 우리말로 합계출산율(총출산율)이라고 합니다.",
"example": "보건소가 지역 출산 수준을 파악하려고 합계출산율 통계를 확인하는 장면",
"confidence": "medium"
},
"t_c5aa675531": {
"easy": "검사가 측정하려는 것을 얼마나 정확하게 측정하는지를 나타내는 정도로, 우리말로 타당도라고 합니다.",
"example": "새 혈당 간이검사기가 실제 당뇨병 환자를 얼마나 정확히 가려내는지 따져 보는 장면",
"confidence": "medium"
},
"t_dc02f8c6d1": {
"easy": "질병이 여러 원인이 거미줄처럼 서로 얽혀 생긴다고 보는 역학 모형으로, 우리말로 거미줄 모형이라고 합니다.",
"example": "심장병 원인으로 흡연·식습관·스트레스·운동부족이 서로 얽힌 관계를 그림으로 그려 보는 장면",
"confidence": "medium"
},
"t_0c3c925da8": {
"easy": "숙주(사람)를 중심에 두고 그 주변을 생물학적·사회적·물리적 환경이 둘러싼 형태로 질병 발생을 설명하는 역학 모형으로, 우리말로 수레바퀴 모형입니다.",
"example": "당뇨병 주민의 유전 요인과 가족·직장 환경을 함께 사정해 중재 계획을 세우는 장면",
"confidence": "medium"
},
"t_770d9c07d0": {
"easy": "병원체가 들어왔을 때 숙주가 그 병에 걸리기 쉬운 정도를 말합니다. 면역이 없을수록 감수성이 높습니다.",
"example": "홍역 예방접종을 받지 않은 아이가 감수성자로 분류되어 접종 대상이 되는 장면",
"confidence": "high"
},
"t_37987e41a3": {
"easy": "질병 자연사의 2단계인 조기병원성기를 달리 부르는 말로, 병원체가 숙주에 막 자극을 주기 시작한 초기 단계입니다.",
"example": null,
"confidence": "medium"
},
"t_ff8db47a14": {
"easy": "병원체가 새로운 숙주의 몸에 들어와 자리 잡고 증식하는 것을 말합니다. 증상이 없어도 감염일 수 있습니다.",
"example": "결핵 환자 가족에게 잠복결핵 감염 검사를 받도록 안내하는 장면",
"confidence": "high"
},
"t_88d128be14": {
"easy": "병원체가 숙주에 감염을 일으키는 능력으로, 감염을 성공시키는 데 필요한 최소한의 병원체 양으로 설명합니다.",
"example": "적은 양의 노로바이러스로도 감염되므로 학교 급식실 위생을 특히 강조하는 장면",
"confidence": "medium"
},
"t_73e6e6a087": {
"easy": "질병이 한 가지 원인이 아니라 여러 원인이 거미줄처럼 얽혀 생긴다고 보는 모형입니다. 만성질환 예방 전략을 세울 때 유용합니다.",
"example": "고혈압 원인으로 짠 식습관·비만·스트레스·음주의 연결고리를 찾아 끊을 곳을 정하는 장면",
"confidence": "high"
},
"t_6e841bd463": {
"easy": "개인이나 집단의 건강상태를 체계적으로 점검해 질병이나 건강 위험을 일찍 찾아내고 건강을 지키기 위한 과정입니다.",
"example": "산업장 근로자가 일반건강검진을 받고 보건관리자가 결과를 상담해 주는 장면",
"confidence": "high"
},
"t_b3f1bbaca0": {
"easy": "감염병 유행지에서 들어온 사람을 잠복기 동안 거처를 신고하게 하거나 머물게 하며 감염 여부를 확인할 때까지 감시하는 것입니다.",
"example": "해외 유행지역에서 입국한 사람의 발열을 공항 검역소에서 확인하는 장면",
"confidence": "high"
},
"t_039c60beb4": {
"easy": "다른 사람에게 병을 옮길 수 있는 감염자를 전파 위험이 없어질 때까지 건강한 사람들과 떼어 놓는 것입니다.",
"example": "전염성 결핵 환자를 치료 초기 동안 격리 병실에서 지내게 하는 장면",
"confidence": "high"
},
"t_0ea65096c1": {
"easy": "환자군과 대조군에서 어떤 요인에 노출된 정도를 비교해 그 요인과 질병의 관련성을 나타내는 지표로, 승산비라고도 합니다.",
"example": "폐암 환자와 건강한 사람의 흡연 경험을 비교해 흡연의 위험 정도를 계산하는 장면",
"confidence": "high"
},
"t_b7f265e2df": {
"easy": "전체 중에서 한 부분이 차지하는 비중을 나타낸 값으로, 상대빈도·구성비·분율이라고도 합니다.",
"example": "보건소 등록 환자 중 고혈압 환자가 차지하는 비율(%)을 계산하는 장면",
"confidence": "high"
},
"t_fe661d8285": {
"easy": "질병이나 건강문제가 ‘누가, 언제, 어디서’ 많이 생기는지 있는 그대로 관찰·기록하는 역학입니다. 원인 가설을 세우는 기초가 됩니다.",
"example": "지역 결핵 환자를 연령·성별·동네별로 정리해 많이 발생하는 곳을 파악하는 장면",
"confidence": "high"
},
"t_21f0ec7af2": {
"easy": "노출군과 비노출군의 발생률 차이로, 특정 요인 때문에 추가로 생긴 질병의 양을 나타냅니다. 그 요인을 없애면 줄일 수 있는 몫을 보여 줍니다.",
"example": "금연사업으로 줄일 수 있는 폐암 발생 규모를 흡연자와 비흡연자 발생률 차이로 추정하는 장면",
"confidence": "high"
},
"t_3dcec81bec": {
"easy": "한 시점에서 질병 상태와 관련 요인을 동시에 조사해 둘의 관련성을 보는 관찰연구입니다. 유병률 파악에 많이 쓰입니다.",
"example": "지역 주민 설문으로 현재 비만 여부와 운동 습관을 한꺼번에 조사하는 장면",
"confidence": "high"
},
"t_9445263fd1": {
"easy": "자료 전체를 하나의 값으로 대표해 보여 주는 수치로, 평균·중앙값·최빈값 등이 있습니다.",
"example": "학교 학생들의 키 자료를 평균과 중앙값으로 요약해 보건실 보고서를 쓰는 장면",
"confidence": "medium"
},
"t_141ba0e217": {
"easy": "병원체가 숙주에게 얼마나 심한 증상을 일으키는지를 나타내는 능력으로, 발병자 중 중증·사망자의 비율로 봅니다.",
"example": null,
"confidence": "medium"
},
"t_453d0d2df5": {
"easy": "클라크와 함께 질병이 진행되는 과정을 단계별로 설명한 질병의 자연사 모형을 제시한 학자입니다.",
"example": null,
"confidence": "high"
},
"t_69a114ff18": {
"easy": "실제로 병이 있는 사람을 검사에서 ‘양성’으로 정확히 찾아내는 능력입니다. 높을수록 환자를 놓칠 가능성이 적습니다.",
"example": "결핵 집단검진에서 환자를 빠뜨리지 않도록 민감도가 높은 검사를 고르는 장면",
"confidence": "high"
},
"t_8d4646eb71": {
"easy": "일정 기간 특정 집단에서 새로 병에 걸린 사람의 비율로, 주로 유행이 있을 때 그 집단에 한해 계산합니다.",
"example": "수학여행 식중독 발생 시 참가 학생 중 증상이 생긴 학생 비율을 계산하는 장면",
"confidence": "high"
},
"t_1fb3ba6fb0": {
"easy": "질병 자연사의 4단계로, 임상 증상이 뚜렷하게 나타나는 시기입니다. 증상기 또는 임상기라고도 합니다.",
"example": "고열과 기침으로 보건소를 찾은 주민이 진단과 치료를 받는 장면",
"confidence": "high"
},
"t_ec5da4c2d5": {
"easy": "병원체가 실제로 증상이 있는 질병을 일으키는 능력으로, 감염된 사람 중 증상이 나타난 사람의 비율로 봅니다. 병원성과 같은 말입니다.",
"example": null,
"confidence": "high"
},
"t_6f5e83ac8d": {
"easy": "병원체가 감염된 사람에게 증상이 있는 질병을 일으키는 능력입니다. 감염자 중 현성 감염자의 비율로 나타냅니다.",
"example": null,
"confidence": "high"
},
"t_2594fc1649": {
"easy": "질병의 자연사에서 아직 병원체가 몸에 들어오지 않아 병에 걸리지 않은 시기입니다. 건강증진과 특수예방이 중요합니다.",
"example": "건강한 지역 주민을 대상으로 운동·영양 교육을 하는 장면",
"confidence": "medium"
},
"t_ef31015b27": {
"easy": "질병의 자연사에서 병원체가 몸에 들어와 질병 과정이 시작되고 진행되는 시기입니다.",
"example": null,
"confidence": "medium"
},
"t_dd8b223fe5": {
"easy": "병원체가 살면서 증식하고, 다른 숙주에게 옮겨 갈 기회를 얻는 곳입니다. 사람, 동물, 곤충, 흙 등이 될 수 있습니다.",
"example": "광견병 예방을 위해 지역 반려견·야생동물 관리 교육을 하는 장면",
"confidence": "high"
},
"t_11f768552d": {
"easy": "병원체가 병원소(예: 감염된 사람)의 몸 밖으로 나오는 단계입니다. 기침, 대변, 혈액 등을 통해 나옵니다.",
"example": "결핵 환자에게 기침할 때 마스크와 휴지로 입을 가리도록 교육하는 장면",
"confidence": "high"
},
"t_bd129729ae": {
"easy": "질병을 일으키는 세균, 바이러스, 기생충 같은 원인 요소입니다. 숙주의 방어를 이겨내야 병을 일으킬 수 있습니다.",
"example": null,
"confidence": "high"
},
"t_c44dd428ca": {
"easy": "연령 구성 등 인구 구조가 다른 집단을 공정하게 비교하려고, 같은 표준인구에 맞춰 다시 계산한 비율입니다. 표준화율이라고도 합니다.",
"example": "노인이 많은 군과 젊은 도시의 사망률을 연령 보정해 비교하는 장면",
"confidence": "high"
},
"t_251ad3c72a": {
"easy": "기술역학에서 얻은 가설을 바탕으로 질병의 원인을 찾고 인과관계를 검증하는 역학입니다.",
"example": "흡연이 폐암의 원인인지 환자-대조군 연구로 확인하는 장면",
"confidence": "high"
},
"t_1b3c969cf4": {
"easy": "감염은 되었지만 아직 증상이 나타나지 않는 질병 자연사의 3단계로, 잠복기라고도 합니다.",
"example": "증상은 없지만 검사로 확인된 감염자를 조기에 찾아 관리하는 장면",
"confidence": "high"
},
"t_d3b0e1a367": {
"easy": "한 값을 다른 값으로 나누어 A:B 또는 A/B로 나타낸 것입니다. 성비가 대표적인 예입니다.",
"example": "지역 인구 통계에서 여자 100명당 남자 수(성비)를 계산하는 장면",
"confidence": "high"
},
"t_c769daff3e": {
"easy": "질병 자연사의 1단계로, 아직 병에 걸리지 않은 건강한 시기입니다. 건강기라고도 합니다.",
"example": "건강한 주민에게 생활습관 개선을 교육하는 장면",
"confidence": "high"
},
"t_4021ec7db8": {
"easy": "일정 기간 동안 어떤 사건이 일어난 빈도를 해당 인구와 비교해 나타낸 값으로, 발생률 계산 등에 쓰입니다.",
"example": null,
"confidence": "medium"
},
"t_b66d0d6ede": {
"easy": "일정 기간 인구 1,000명당 사망한 사람의 비율로, 인구집단의 건강 수준을 보여 주는 지표입니다.",
"example": "보건소가 지역의 연간 사망률 추이를 확인해 사업 우선순위를 정하는 장면",
"confidence": "high"
},
"t_c789d5c088": {
"easy": "임신·출산 또는 출산 후 42일 이내에 사망한 산모 수를 출생아 수로 나눈 지표로, 모성 건강 수준을 보여 줍니다.",
"example": "모자보건 담당 간호사가 지역 모성사망 통계를 보고 산전관리 사업을 계획하는 장면",
"confidence": "high"
},
"t_4d4321e196": {
"easy": "요인에 노출된 집단이 노출되지 않은 집단보다 병에 걸릴 위험이 몇 배인지 나타내는 지표입니다. 클수록 그 요인이 원인일 가능성이 큽니다.",
"example": "흡연 근로자가 비흡연 근로자보다 폐질환 발생 위험이 몇 배인지 계산하는 장면",
"confidence": "high"
},
"t_afa0af9fe9": {
"easy": "전파된 병원체가 입, 코, 피부 상처 등을 통해 새로운 사람의 몸 안으로 들어가는 단계입니다.",
"example": "방문간호사가 상처 소독 시 장갑을 착용해 병원체 침입을 막는 장면",
"confidence": "medium"
},
"t_71ad4b617b": {
"easy": "질병 발생을 숙주·병원체·환경 세 요인의 균형으로 설명하는 모형으로, 균형이 깨지면 병이 생긴다고 봅니다.",
"example": "장마철 환경 변화로 수인성 감염병 위험이 커져 손씻기 교육을 강화하는 장면",
"confidence": "medium"
},
"t_f4b68528ad": {
"easy": "수집한 자료를 표, 그래프, 평균 같은 수치로 정리해 특성을 있는 그대로 보여 주는 통계입니다.",
"example": "건강검진 결과를 연령대별 평균 혈압 표로 정리해 보고하는 장면",
"confidence": "medium"
},
"t_4f2ab27a95": {
"easy": "사람(숙주)을 중심에 두고 생물학적·사회적·물리적 환경이 둘러싸 질병 발생에 영향을 준다고 보는 모형입니다.",
"example": "당뇨 주민의 유전 요인과 가족·직장·주거 환경을 함께 사정하는 장면",
"confidence": "high"
},
"t_8a95095fa0": {
"easy": "병원체에 노출되었을 때 숙주가 병에 걸리기 쉬운 정도로, 감염병 발생 과정의 마지막 단계에 해당합니다.",
"example": "예방접종 기록을 확인해 면역이 없는 아동에게 접종을 권하는 장면",
"confidence": "high"
},
"t_a79aee5f43": {
"easy": "환자-대조군 연구에서 노출과 질병의 관련 정도를 간접적으로 보여 주는 지표로, 교차비와 같은 말입니다.",
"example": "간암 환자와 대조군의 음주 경험을 비교해 승산비를 계산하는 장면",
"confidence": "high"
},
"t_02690edcfd": {
"easy": "같은 대상을 반복해서 측정했을 때 같은 결과가 나오는 정도입니다. 신뢰도가 있어야 타당도를 따질 수 있습니다.",
"example": "간호사 두 명이 같은 주민의 혈압을 재서 결과가 비슷하게 나오는지 확인하는 장면",
"confidence": "high"
},
"t_7454f3d454": {
"easy": "출생 후 28일 이내에 사망한 신생아 수를 출생아 수로 나눈 지표로, 산전·산후 관리 수준을 보여 줍니다.",
"example": "보건소가 신생아 사망 통계를 보고 고위험 임산부 관리를 강화하는 장면",
"confidence": "high"
},
"t_f5ec57669d": {
"easy": "연구자가 중재나 노출을 일부러 주고 그 결과를 관찰해 인과관계를 직접 확인하는 연구입니다.",
"example": "금연 프로그램 참여군과 비참여군의 금연 성공률을 비교하는 연구 장면",
"confidence": "high"
},
"t_ec00d6c740": {
"easy": "검사에서 양성이 나온 사람 중 실제로 병이 있는 사람의 비율입니다.",
"example": "검진에서 ‘양성’을 받은 주민 중 정밀검사로 실제 환자로 확인된 비율을 따지는 장면",
"confidence": "high"
},
"t_f72ba2ac1b": null,
"t_6f99e7a938": {
"easy": "질병 예방·관리에서 원인과 과정을 효율적으로 통제하도록 자원을 어디에 먼저 배분할지 설명하는 모형입니다.",
"example": null,
"confidence": "low"
},
"t_5590d1a7f3": {
"easy": "질병이 생기는 원인과 과정을 구조화해 설명하는 틀로, 예방·관리 전략을 세우는 기초가 됩니다. 생태학적·수레바퀴·거미줄 모형 등이 있습니다.",
"example": null,
"confidence": "high"
},
"t_20ba139645": {
"easy": "특정 연령대 인구 1,000명당 일정 기간 사망한 사람의 비율로, 연령별 건강 위험을 비교할 때 씁니다.",
"example": "지역의 70대 사망률이 다른 지역보다 높은지 비교해 노인보건사업을 계획하는 장면",
"confidence": "high"
},
"t_bbaff4db6c": {
"easy": "검사 결과가 실제 질병 유무를 얼마나 맞게 예측하는지를 나타내는 값으로, 양성예측도와 음성예측도가 있습니다.",
"example": "검진 결과 양성인 주민이 실제 환자일 가능성을 설명해 주는 장면",
"confidence": "high"
},
"t_d614f179f3": {
"easy": "1965년 논문에서 환경과 질병 사이의 관계가 단순한 연관성인지 인과관계인지를 다룬 학자입니다.",
"example": null,
"confidence": "medium"
},
"t_798d92a73a": {
"easy": "실제로는 병이 없는데 검사에서 양성으로 잘못 나온 경우입니다. 특이도가 높을수록 줄어듭니다.",
"example": "유방촬영 검진에서 양성이었으나 정밀검사 결과 정상으로 판명된 주민을 안심시키는 장면",
"confidence": "high"
},
"t_d4a087943a": {
"easy": "통계 분석을 통해 사망률과 질병 패턴을 연구한 역학 초기의 학자입니다.",
"example": null,
"confidence": "high"
},
"t_5bd5f181eb": {
"easy": "검사에서 음성이 나온 사람 중 실제로 병이 없는 사람의 비율입니다.",
"example": "검진 음성 결과를 받은 주민이 실제로 건강할 가능성을 설명하는 장면",
"confidence": "high"
},
"t_2214263af4": {
"easy": "질병이 시작되어 진행하고 회복·장애·사망 등으로 끝나기까지의 전체 과정을 말합니다.",
"example": null,
"confidence": "high"
},
"t_b466c5367b": {
"easy": "몸 밖으로 나온 병원체가 새로운 숙주에게 옮겨 가는 과정입니다. 접촉, 비말, 공기, 물·음식 등을 통해 일어납니다.",
"example": "학교에서 독감 유행 시 기침 예절과 환기를 지도해 전파를 막는 장면",
"confidence": "high"
},
"t_df50ca4b37": {
"easy": "연구를 시작한 시점부터 노출된 집단과 노출되지 않은 집단을 앞으로 추적해 질병 발생을 관찰하는 연구입니다.",
"example": "흡연 근로자와 비흡연 근로자를 10년간 추적하며 폐질환 발생을 비교하는 장면",
"confidence": "high"
},
"t_817e1ade3f": {
"easy": "질병 자연사의 2단계로, 병원체가 숙주에 자극을 주기 시작한 초기 단계입니다. 감수성기라고도 하며 예방접종 등 특수예방이 중요합니다.",
"example": "감염병 유행 초기에 취약 주민에게 예방접종을 서두르는 장면",
"confidence": "medium"
},
"t_d901ffaad5": {
"easy": "나이 등 인구 구성을 보정하지 않고 전체 인구를 분모로 계산한 비율입니다. 조사망률이 대표적입니다.",
"example": "두 군의 전체 사망자 수를 전체 인구로 나눈 조사망률을 단순 비교하는 장면",
"confidence": "high"
},
"t_c12f852f97": {
"easy": "일정 기간 인구 1,000명당 태어난 출생아 수를 나타내는 기본적인 출생 지표입니다.",
"example": "지역 인구 통계에서 연간 출생아 수로 조출생률을 계산하는 장면",
"confidence": "low"
},
"t_3dfe65cdf7": {
"easy": "런던의 사망자 명부를 분석해 인구집단 수준에서 질병과 사망의 패턴을 연구한 인물입니다.",
"example": null,
"confidence": "high"
},
"t_1470204ab3": {
"easy": "콜레라 유행 때 오염된 우물 펌프를 원인으로 밝혀내 ‘역학의 아버지’로 불리는 인물입니다.",
"example": null,
"confidence": "high"
},
"t_a8220f2386": {
"easy": "질병이 생겨서 진행하고 회복되거나 사망에 이르기까지의 시간적 과정입니다. 단계별로 알맞은 예방 수준을 정하는 데 쓰입니다.",
"example": null,
"confidence": "high"
},
"t_7baa7d4113": {
"easy": "인구집단에서 질병이 얼마나 생기고 얼마나 위험한지 객관적으로 평가하는 통계 지표입니다. 발생률·유병률·치명률 등이 있습니다.",
"example": "보건소가 지역 결핵 발생률을 확인해 관리 사업을 계획하는 장면",
"confidence": "high"
},
"t_56f77e2ed7": {
"easy": "증상이 없는 주민 중 병이 있을 가능성이 있는 사람을 간단한 검사로 빠르게 가려내 조기에 발견하는 것입니다.",
"example": "보건소에서 주민 대상 혈당 측정으로 당뇨 의심자를 찾아내는 장면",
"confidence": "high"
},
"t_5a419a80d3": {
"easy": "집단에서 면역을 가진 사람의 비율이 높아 감염병이 퍼지기 어려운 상태를 말합니다. 예방접종으로 높일 수 있습니다.",
"example": "학교 예방접종률을 높여 접종 못 한 학생까지 홍역에서 보호하는 장면",
"confidence": "high"
},
"t_0f6a1bcf8b": {
"easy": "한 여성이 가임기 동안 평균적으로 낳을 것으로 예상되는 자녀 수입니다. 합계출산율이라고도 합니다.",
"example": "지역 합계출산율 통계를 보고 출산 지원 사업을 계획하는 장면",
"confidence": "high"
},
"t_c919450e36": {
"easy": "표본 자료를 바탕으로 전체 집단의 특성을 추정하거나 가설이 맞는지 검정하는 통계입니다.",
"example": "표본 주민 조사 결과로 지역 전체 흡연율을 추정하는 장면",
"confidence": "high"
},
"t_9c78981028": {
"easy": "일정 기간 인구 1,000명당 태어난 출생아 수로, 출산 수준과 인구 변화를 보여 줍니다.",
"example": "지역 연간 출생아 수로 출생률을 계산해 모자보건 사업 규모를 정하는 장면",
"confidence": "high"
},
"t_708d8fcf1d": {
"easy": "출생 현황과 신생아·모성 건강을 평가하는 통계 지표입니다. 출생률, 합계출산율 등이 있습니다.",
"example": "모자보건 담당자가 출생지표를 확인해 지역 사업 계획을 세우는 장면",
"confidence": "high"
},
"t_4bab56d6fc": {
"easy": "측정값과 실제 값 사이의 차이입니다. 측정 오차가 적을수록 신뢰도가 높습니다.",
"example": "혈압계 보정이 안 되어 측정값이 실제보다 높게 나오는지 점검하는 장면",
"confidence": "high"
},
"t_a7d58080c7": {
"easy": "특정 질병에 걸린 사람 중 그 병으로 사망한 사람의 비율로, 질병이 얼마나 치명적인지 보여 줍니다.",
"example": "신종 감염병 확진자 중 사망자 비율을 계산해 위험성을 평가하는 장면",
"confidence": "high"
},
"t_d0387e7567": {
"easy": "같은 특성이나 경험(예: 같은 해 출생, 같은 노출)을 공유하는 집단을 말합니다.",
"example": "같은 해 입사한 근로자 집단을 정해 건강 변화를 추적하는 장면",
"confidence": "high"
},
"t_5637f4501b": {
"easy": "원인으로 생각되는 요인에 노출된 집단과 노출되지 않은 집단을 일정 기간 추적해 질병 발생을 비교하는 연구입니다.",
"example": "석면 노출 근로자와 비노출 근로자를 추적해 폐질환 발생을 비교하는 장면",
"confidence": "high"
},
"t_3a2336a6eb": {
"easy": "레벨과 함께 질병 진행 과정을 단계별로 설명한 질병의 자연사 모형을 제시한 학자입니다.",
"example": null,
"confidence": "high"
},
"t_6e947c69a4": {
"easy": "측정 결과가 실제 참값을 얼마나 정확하게 반영하는지의 정도입니다. 검사에서는 민감도와 특이도로 평가합니다.",
"example": "새 우울 선별도구가 실제 우울증 환자를 제대로 가려내는지 확인하는 장면",
"confidence": "high"
},
"t_9fcbc97e58": {
"easy": "인구를 연령·성별 등 같은 특성의 소집단으로 나누어 각각 계산한 비율로, 소집단율이라고도 합니다.",
"example": "연령별·성별 고혈압 유병률을 따로 계산해 고위험 집단을 찾는 장면",
"confidence": "high"
},
"t_21ca74a5ef": {
"easy": "실제로 병이 없는 사람을 검사에서 ‘음성’으로 정확히 판정하는 능력입니다. 높을수록 위양성이 줄어듭니다.",
"example": "건강한 사람을 환자로 잘못 판정하지 않도록 특이도가 높은 확진 검사를 쓰는 장면",
"confidence": "high"
},
"t_3bb0af7f27": {
"easy": "인구 구성 차이를 없애고 비교할 수 있도록 표준인구에 맞춰 다시 계산한 비율입니다.",
"example": "노인 인구가 많은 지역과 적은 지역의 사망률을 연령표준화해 비교하는 장면",
"confidence": "high"
},
"t_f12dfa17a5": {
"easy": "병이 있는 환자군과 병이 없는 대조군을 정해 과거 노출 여부를 비교하는 연구로, 교차비를 구할 수 있습니다.",
"example": "식중독 환자와 증상 없는 사람에게 먹은 음식을 물어 원인 음식을 찾는 장면",
"confidence": "high"
},
"t_bece97295c": {
"easy": "질병 자연사의 5단계로, 병에서 회복하거나 장애가 남거나 사망에 이르는 시기입니다. 재활 등 3차 예방이 이루어집니다.",
"example": "방문간호사가 퇴원 환자의 재활과 일상 복귀를 돕는 장면",
"confidence": "high"
},
"t_fb49ca8d72": {
"easy": "과거 기록을 이용해 예전 노출 시점부터 현재까지 질병 발생을 거슬러 추적하는 코호트 연구입니다.",
"example": "과거 작업 기록으로 20년 전 분진 노출 근로자들의 폐질환 발생을 확인하는 장면",
"confidence": "high"
},
"t_548e15dbec": {
"easy": "14세기 중반 유럽에서 대유행해 인구의 3분의 1 이상이 사망한 치명적인 감염병입니다.",
"example": null,
"confidence": "high"
},
"t_ece014ab48": {
"easy": "질병이 신의 벌이 아니라 환경·계절·식습관·생활습관과 관련 있다고 주장한 고대 그리스의 의학자입니다.",
"example": null,
"confidence": "high"
}
}
+738
View File
@@ -0,0 +1,738 @@
{
"t_af2e164d2f": {
"easy": "같은 해 영아사망 수를 신생아사망 수로 나눈 값입니다. 1에 가까울수록 피하기 어려운 신생아기 사망이 대부분이어서 보건 수준이 높다고 봅니다.",
"example": "지역 영아사망 통계로 α-index를 계산해 모자보건 수준을 비교하는 장면",
"confidence": "high"
},
"t_e1bf34bdf4": {
"easy": "장기요양기관이 부족한 도서·벽지 거주 등으로 시설 이용이 어려워 가족에게 장기요양을 받는 경우 지급되는 현금 급여입니다.",
"example": "섬마을에서 가족이 거동 불편한 노모를 돌보며 가족요양비를 받는 장면",
"confidence": "high"
},
"t_8d669e9f5b": {
"easy": "노인의 감염병을 예방하기 위해 인플루엔자·폐렴구균 등 예방접종을 시행하는 노인보건사업입니다.",
"example": "보건소가 가을철 65세 이상 어르신께 무료 독감 예방접종을 하는 장면",
"confidence": "medium"
},
"t_641659cf71": {
"easy": "노인의 질병을 조기에 발견하고 관리하기 위해 건강검진을 실시하고 이상 소견자를 사후관리하는 사업입니다.",
"example": "보건소가 노인 검진 후 고혈압 의심자를 만성질환관리사업에 연계하는 장면",
"confidence": "medium"
},
"t_a602406f3d": {
"easy": "노인이 스스로 느끼는 건강의 정도로, 주관적 건강 인식은 노인보건 요구를 파악하는 지표가 됩니다.",
"example": "방문간호사가 어르신께 ‘요즘 건강이 어떠세요?’라며 주관적 건강상태를 묻는 장면",
"confidence": "low"
},
"t_0c63db59b6": {
"easy": "금연·절주·운동·영양 등 건강한 생활습관을 지역 주민에게 널리 퍼뜨리기 위한 보건소 사업입니다.",
"example": "보건소가 경로당에서 어르신 걷기 프로그램과 영양 교육을 운영하는 장면",
"confidence": "medium"
},
"t_e7ce2ec484": {
"easy": "어르신에 대한 사회적 관심과 공경의식을 높이기 위해 정한 달로, 매년 10월입니다.",
"example": "10월 경로의 달에 보건소가 경로당에서 건강 행사를 여는 장면",
"confidence": "high"
},
"t_77b4538ad5": {
"easy": "어른을 공경하고 부모에게 효도하는 전통적인 가치관으로, 노인복지정책에서 이를 높이는 것을 목표로 삼기도 합니다.",
"example": "학교에서 경로의 달을 맞아 학생들이 어르신께 감사 편지를 쓰는 장면",
"confidence": "medium"
},
"t_601a5a4fd9": {
"easy": "전체 인구 중 65세 이상 인구가 14% 이상 20% 미만인 사회를 말합니다.",
"example": "노인 인구 비율이 늘어난 지역 보건소가 노인보건사업 예산을 늘리는 장면",
"confidence": "high"
},
"t_7bd8134ade": {
"easy": "전체 인구 중 65세 이상 인구가 7% 이상 14% 미만인 사회를 말합니다.",
"example": null,
"confidence": "high"
},
"t_f100d02680": {
"easy": "고혈압·당뇨병 등 질환, 조산·사산 경력, 이상 산전검사 소견 등으로 임신·출산 위험이 큰 임산부입니다. 우선적으로 철저히 관리합니다.",
"example": "보건소 간호사가 임신성 당뇨가 있는 임부를 고위험군으로 등록해 관리하는 장면",
"confidence": "high"
},
"t_90091f4464": {
"easy": "노인의 치아와 잇몸 건강을 지키기 위해 구강검진, 교육, 틀니 관리 등을 제공하는 사업입니다.",
"example": "보건소 치위생사가 경로당에서 틀니 세척법과 잇솔질을 교육하는 장면",
"confidence": "medium"
},
"t_11abebe5b4": {
"easy": "국민기초생활보장 수급자인 노인으로, 노인복지시설 입소 등에서 비용 지원 대상이 됩니다.",
"example": "주민센터에서 기초생활수급 어르신의 요양시설 입소 신청을 돕는 장면",
"confidence": "medium"
},
"t_cc04d3870a": {
"easy": "아이를 갖기 원하지만 임신이 잘 되지 않는 부부로, 시술비 지원 등 국가 지원 대상입니다.",
"example": "보건소에서 난임부부에게 시술비 지원 신청 방법을 안내하는 장면",
"confidence": "high"
},
"t_66e4af1e9e": {
"easy": "신생아의 청력 이상을 일찍 찾아내 언어 발달 장애를 막기 위해 청각선별검사와 확진검사를 지원하는 사업입니다.",
"example": "출생 직후 신생아 청각선별검사 결과 재검이 나와 보건소가 확진검사를 안내하는 장면",
"confidence": "medium"
},
"t_01422f1c1b": {
"easy": "생산가능인구(15~64세) 100명이 부양해야 하는 65세 이상 노인 인구 수입니다.",
"example": "지역 노년부양비 상승을 근거로 노인 돌봄 인력 확충을 계획하는 장면",
"confidence": "high"
},
"t_cd2ae1239d": {
"easy": "유소년인구(0~14세) 100명당 65세 이상 노인 인구 수로, 인구 고령화 정도를 보여 줍니다.",
"example": "농촌 군의 노령화지수가 매우 높아 노인보건사업 비중을 늘리는 장면",
"confidence": "high"
},
"t_1794057a14": {
"easy": "만 65세 이상 의료급여수급권자 등을 대상으로 노인성 질환을 조기 발견하기 위해 보건소가 실시하는 건강진단입니다.",
"example": "보건소가 의료급여 어르신께 노인건강진단을 안내하고 결과를 상담하는 장면",
"confidence": "high"
},
"t_5ebce237da": {
"easy": "노인들이 가정과 같은 환경에서 함께 생활하며 숙식과 일상생활 편의를 제공받는 소규모 주거복지시설입니다.",
"example": null,
"confidence": "medium"
},
"t_9c4bbab815": {
"easy": "혼자 생활하기 어려운 노인에게 안부 확인, 가사·활동 지원 등 돌봄을 제공하는 서비스입니다.",
"example": "생활지원사가 독거노인 댁을 방문해 안부를 확인하고 식사를 챙기는 장면",
"confidence": "medium"
},
"t_724a3393e6": {
"easy": "노인과 관련된 문제 중 주로 건강과 보건에 관한 문제를 다루는 분야입니다.",
"example": null,
"confidence": "high"
},
"t_bc346f8f20": {
"easy": "65세 이상 노인의 질병 예방, 만성질환 조기발견·관리, 기능저하 지연, 삶의 질 향상을 위해 지역사회 자원을 연계해 수행하는 사업입니다.",
"example": "보건소가 노인 검진, 낙상예방 운동, 치매 선별을 묶어 운영하는 장면",
"confidence": "high"
},
"t_c3129c2024": {
"easy": "노인학대 예방, 학대받은 노인의 보호와 상담 등을 담당하는 전문기관입니다.",
"example": "방문간호사가 학대가 의심되는 어르신을 발견해 노인보호전문기관에 신고하는 장면",
"confidence": "high"
},
"t_52cf9502c8": {
"easy": "노인의 소득, 주거, 돌봄, 여가 등 생활 전반의 복지를 지원하는 사업입니다.",
"example": null,
"confidence": "low"
},
"t_38dd80f8cc": {
"easy": "노인에게 주거시설을 분양·임대하고 생활지도, 상담, 안전관리 등 일상생활 편의를 제공하는 시설입니다.",
"example": null,
"confidence": "high"
},
"t_fe68df7af9": {
"easy": "노화와 관련해 노인에게 많이 생기는 질환으로, 질병 자체뿐 아니라 일상생활 기능 장애를 함께 평가하는 것이 중요합니다.",
"example": "방문간호사가 관절염 어르신의 통증과 함께 혼자 목욕·식사가 가능한지 평가하는 장면",
"confidence": "high"
},
"t_aeb0e30b61": {
"easy": "백내장·녹내장 등 노인 눈 질환을 일찍 발견하고 수술 등을 지원해 실명을 막는 사업입니다.",
"example": "보건소가 저소득 어르신의 안검진 후 백내장 수술을 연계하는 장면",
"confidence": "medium"
},
"t_f2f8e035fa": {
"easy": "치매·중풍 등으로 돌봄이 필요한 노인에게 가정과 같은 환경에서 요양서비스를 제공하는 소규모 노인의료복지시설입니다.",
"example": null,
"confidence": "medium"
},
"t_ca591336a4": {
"easy": "치매·중풍 등 노인성 질환으로 심신에 상당한 장애가 있는 노인을 입소시켜 돌봄과 요양을 제공하는 노인의료복지시설입니다.",
"example": "장기요양등급을 받은 치매 어르신이 요양원에 입소하는 장면",
"confidence": "medium"
},
"t_2a3765a89c": {
"easy": "어르신의 노고에 감사하고 노인에 대한 관심과 공경을 높이기 위해 정한 날로, 10월 2일입니다.",
"example": null,
"confidence": "high"
},
"t_168a009db4": {
"easy": "노인이 어떤 질환으로 사망하는지를 보여 주는 통계로, 암·심장질환·폐렴·뇌혈관질환 등이 상위를 차지합니다.",
"example": "보건소가 노인 사망원인 통계를 보고 폐렴 예방접종 사업을 강화하는 장면",
"confidence": "high"
},
"t_1aec8bd678": {
"easy": "노인성 질환 등으로 돌봄이 필요한 노인에게 요양서비스를 제공하는 시설로, 노인요양시설과 노인요양공동생활가정이 있습니다.",
"example": null,
"confidence": "high"
},
"t_45f2c0411e": {
"easy": "노인이 자신의 경험과 능력을 살려 지역사회에 봉사하는 활동으로, 사회참여와 건강 유지에 도움이 됩니다.",
"example": "은퇴한 어르신들이 초등학교 등굣길 안전 지킴이로 봉사하는 장면",
"confidence": "medium"
},
"t_0b8b72dcb0": {
"easy": "65세 이상 또는 노인성 질병이 있는 사람이 6개월 이상 혼자 일상생활이 어려울 때 요양급여를 받도록 하는 사회보험 제도입니다.",
"example": "장기요양등급을 받은 어르신 댁에 방문요양과 방문간호 서비스가 연결되는 장면",
"confidence": "high"
},
"t_3d98c722f1": {
"easy": "일상생활에 지장이 없는 노인에게 급식과 생활 편의를 제공해 안정된 노후를 돕는 시설입니다. 양로시설 등이 있습니다.",
"example": null,
"confidence": "high"
},
"t_7ec445b1ea": {
"easy": "노인에게 신체적·정서적·성적·경제적 학대나 방임을 하는 것을 말합니다.",
"example": "방문간호사가 어르신 몸의 설명되지 않는 멍을 보고 학대를 의심하는 장면",
"confidence": "medium"
},
"t_96cdf286e5": {
"easy": "가족이 돌보기 어려운 기간 동안 노인을 일정 기간 시설에 머물게 하며 보호하는 재가 서비스입니다.",
"example": "보호자의 입원 기간 동안 치매 어르신이 단기보호시설에 머무는 장면",
"confidence": "medium"
},
"t_486c1c7d75": {
"easy": "장기요양 신청자의 심신 기능 상태를 평가해 장기요양이 필요한 정도에 따라 등급을 정하는 것입니다.",
"example": "공단 직원 방문조사 후 등급판정위원회가 어르신의 요양등급을 결정하는 장면",
"confidence": "medium"
},
"t_a682a5a3b2": {
"easy": "방문조사 결과와 의사소견서를 바탕으로 신청인의 장기요양 필요 정도를 심의해 등급을 판정하는 위원회입니다.",
"example": null,
"confidence": "high"
},
"t_d56484fed6": {
"easy": "고혈압·당뇨병 등 만성질환을 조기 발견하고 등록·교육·추적관리해 합병증을 예방하는 보건소 사업입니다.",
"example": "보건소가 고혈압 등록 어르신께 정기 혈압 측정과 생활습관 상담을 하는 장면",
"confidence": "high"
},
"t_7e7388f41d": {
"easy": "체계적인 인구 이론을 전개한 영국의 고전경제학자입니다.",
"example": null,
"confidence": "high"
},
"t_eedee2ae31": {
"easy": "인구는 식량보다 빠르게 늘어 인구과잉이 생기므로 인구를 억제해야 한다는 맬서스의 인구 이론입니다.",
"example": null,
"confidence": "medium"
},
"t_05a2cc8264": {
"easy": "넓게는 초경부터 폐경까지의 여성, 좁게는 임신·분만·출산 후 일정 기간의 여성을 말합니다.",
"example": null,
"confidence": "high"
},
"t_2ad2c1a51b": {
"easy": "임신 전부터 산전·분만·산후까지 관리해 건강한 출산을 돕고 합병증을 예방하여 어머니의 생명과 건강을 지키는 것입니다.",
"example": "보건소에서 임부 등록 후 정기 산전검사와 산후 방문을 연계하는 장면",
"confidence": "high"
},
"t_4608f71a48": {
"easy": "임신 전부터 출산 후까지 여성과 태아, 신생아의 생명과 건강을 지키기 위한 공공보건사업입니다.",
"example": "보건소가 임산부에게 엽산제를 나눠 주고 산전교육을 하는 장면",
"confidence": "high"
},
"t_e8f3ea02b6": {
"easy": "모성의 건강 수준을 평가하는 지표로, 연령별출산율, 합계출산율, 모성사망비, 주산기사망률 등이 있습니다.",
"example": null,
"confidence": "high"
},
"t_694d026945": {
"easy": "임신·분만·산욕과 관련된 원인으로 사망한 여성의 수를 나타내는 모성 건강 지표입니다.",
"example": null,
"confidence": "low"
},
"t_99370d8af5": {
"easy": "임신·출산 관련 원인으로 사망한 산모 수를 출생아 수에 대비해 나타낸 지표로, 모성보건 수준을 보여 줍니다.",
"example": null,
"confidence": "medium"
},
"t_3ee4c0720b": {
"easy": "아기에게 엄마 젖을 먹이는 것으로, 영양·면역에 좋고 영아 질병을 줄이며 모아 애착을 높이는 가장 이상적인 수유 방법입니다.",
"example": "방문간호사가 산모 가정에서 올바른 젖 물리기 자세를 지도하는 장면",
"confidence": "high"
},
"t_5e85615cca": {
"easy": "임신·출산부터 영유아기까지 어머니와 아기의 건강을 지키는 보건 분야로, 생애주기 건강의 기초가 됩니다.",
"example": "보건진료소에서 임신부 상담과 영유아 예방접종을 함께 하는 장면",
"confidence": "high"
},
"t_d562d780ca": {
"easy": "어머니와 영유아의 건강 수준으로, 국가와 지역사회 보건 수준을 보여 주는 지표로 쓰입니다.",
"example": null,
"confidence": "medium"
},
"t_21b9e95574": {
"easy": "모성의 생명과 건강을 보호하고 건강한 자녀의 출산·양육을 도와 국민건강을 높이기 위한 사업입니다.",
"example": "보건소가 임산부 등록, 영유아 검진, 예방접종을 운영하는 장면",
"confidence": "high"
},
"t_5d804ac4a1": {
"easy": "모자보건사업에 관한 의사결정을 돕기 위해 사업 정보를 체계적으로 수집·분석·보고하는 과정입니다.",
"example": "연말에 산전관리 등록률과 모유수유율을 분석해 다음 해 계획을 세우는 장면",
"confidence": "high"
},
"t_cbabe3023f": {
"easy": "모자보건사업을 질적·양적으로 평가하는 자료로, 모성보건지표와 영유아보건지표(영아사망률 등)로 나뉩니다.",
"example": null,
"confidence": "high"
},
"t_9fabc98844": {
"easy": "임신 37주 미만에 태어났거나 출생 체중이 2,500g 미만인 영유아로, 발육이 미숙한 채 태어난 아기입니다.",
"example": "보건소 간호사가 미숙아로 퇴원한 아기 가정을 방문해 성장을 확인하는 장면",
"confidence": "high"
},
"t_9080b7c737": {
"easy": "간호사 등이 집으로 찾아가 의사 지시에 따라 간호, 진료 보조, 상담 등을 제공하는 재가 서비스입니다.",
"example": "방문간호사가 욕창이 있는 어르신 댁을 찾아 드레싱을 해 주는 장면",
"confidence": "medium"
},
"t_b518a72705": {
"easy": "목욕 설비를 갖추고 집으로 찾아가 노인의 목욕을 도와주는 재가 서비스입니다.",
"example": "요양보호사들이 이동 목욕차로 거동 불편 어르신의 목욕을 돕는 장면",
"confidence": "medium"
},
"t_23f092520f": {
"easy": "요양보호사가 집으로 찾아가 노인의 신체활동과 가사활동을 돕는 재가 서비스입니다.",
"example": "요양보호사가 어르신 댁에서 식사와 옷 갈아입기를 돕는 장면",
"confidence": "medium"
},
"t_0642b743c4": {
"easy": "15~49세 인구가 전체의 50%를 넘는 인구 피라미드로, 젊은 인구가 유입되는 도시형(전입형)입니다.",
"example": "대도시 구 보건소가 젊은 인구 비중이 커 모자보건·직장인 건강사업을 늘리는 장면",
"confidence": "high"
},
"t_42889d417e": {
"easy": "건강을 유지하거나 회복하기 위해 대상자가 필요로 하는 보건의료 서비스입니다. 노인은 만성질환과 기능저하로 요구가 큽니다.",
"example": "방문간호사가 독거 어르신의 투약·병원 동행 필요를 사정하는 장면",
"confidence": "low"
},
"t_72acc1d87b": {
"easy": "생산가능인구(15~64세) 100명이 부양해야 하는 유소년과 노인 인구 수로, 유소년부양비와 노년부양비로 나뉩니다.",
"example": null,
"confidence": "high"
},
"t_9712f0c752": {
"easy": "안전한 분만을 위해 분만 과정과 산모·신생아 상태를 관리하는 것입니다. 시설 분만과 숙련된 인력이 중요합니다.",
"example": null,
"confidence": "low"
},
"t_626c02a015": {
"easy": "전체 사망자 중 50세 이상 사망자가 차지하는 비율(%)입니다. 높을수록 오래 살고 건강 수준이 좋다고 봅니다.",
"example": "두 나라의 비례사망지수를 비교해 건강 수준을 평가하는 장면",
"confidence": "high"
},
"t_7dd995f4c6": null,
"t_4e7c0fb68e": {
"easy": "사망자 수, 사망 원인, 사망 연령 등 사망에 관한 통계로, 인구집단의 건강 수준 평가에 쓰입니다.",
"example": null,
"confidence": "medium"
},
"t_4ee6bc3574": {
"easy": "나이가 들면서 은퇴, 역할 변화, 인간관계 축소 등 사회적 지위와 관계가 달라지는 것을 말합니다.",
"example": "은퇴 후 외로움을 호소하는 어르신에게 노인복지관 프로그램을 연계하는 장면",
"confidence": "medium"
},
"t_1f5925253e": {
"easy": "임신 기간 동안 정기 검진과 교육으로 임부와 태아의 건강을 확인하고 이상을 일찍 발견하는 관리입니다.",
"example": "보건소에서 임부의 혈압·소변검사를 하고 영양 교육을 하는 장면",
"confidence": "high"
},
"t_27e313d6ef": {
"easy": "출산 후 산모의 몸이 회복되도록 돕고 산후 합병증과 우울 등을 살피는 관리입니다.",
"example": "방문간호사가 출산 후 산모 가정에서 오로와 산후우울 증상을 확인하는 장면",
"confidence": "medium"
},
"t_08525b0b2c": {
"easy": "출산 후 산모의 신체 회복을 돕고 식욕 회복에도 도움을 주는 운동입니다.",
"example": "간호사가 산모에게 골반저근 운동 방법을 알려 주는 장면",
"confidence": "high"
},
"t_234d201bd3": {
"easy": "출산 후 산모의 감염을 막기 위해 회음부와 유방 등 몸을 청결하게 관리하는 것입니다.",
"example": "방문간호사가 산모에게 회음부 청결 관리 방법을 교육하는 장면",
"confidence": "low"
},
"t_ae0861e6e9": {
"easy": "출산 후 산모와 신생아를 일정 기간 함께 돌보는 시설로, 집단 생활로 감염·안전관리가 특히 중요합니다.",
"example": "보건소가 산후조리원의 감염관리 실태를 점검하는 장면",
"confidence": "high"
},
"t_89053efb8b": {
"easy": "출산 후 일정 기간이 지나 산모의 회복 상태와 합병증 유무를 확인하는 진찰입니다.",
"example": null,
"confidence": "low"
},
"t_1d63d3911b": {
"easy": "장기요양 서비스를 받기 위해 신청, 방문조사, 등급판정, 인정서 발급, 서비스 이용 순으로 진행되는 과정입니다.",
"example": "어르신 가족에게 장기요양 신청부터 서비스 이용까지 과정을 안내하는 장면",
"confidence": "low"
},
"t_96386f4d5b": {
"easy": "증상이 없는 대상 중 질병이나 장애 가능성이 있는 사람을 간단한 검사로 가려내는 검사입니다.",
"example": "영유아 건강검진에서 발달 선별검사로 발달 지연 의심 아동을 찾는 장면",
"confidence": "high"
},
"t_81e899d547": {
"easy": "태어날 때부터 청력이 떨어진 상태로, 발생률이 높고 언어·학습장애를 일으킬 수 있어 조기 발견이 중요합니다.",
"example": "신생아 청각선별검사에서 이상이 나와 보건소가 정밀검사를 연계하는 장면",
"confidence": "high"
},
"t_8aa64a194a": {
"easy": "신생아의 발뒤꿈치 혈액으로 선천성 대사질환을 찾아내는 검사로, 생후 48시간 이후 7일 이내에 시행합니다.",
"example": "보건소가 출생 신고 가정에 신생아 선천성대사이상검사 결과를 확인하는 장면",
"confidence": "high"
},
"t_6a96b6e924": {
"easy": "선천성 기형·변형이나 염색체 이상을 지니고 태어나 사망 위험이 있거나 기능 장애가 뚜렷한 영유아입니다.",
"example": "보건소가 선천성 심장기형 아기 가정에 의료비 지원을 안내하는 장면",
"confidence": "high"
},
"t_3b3ed655cb": {
"easy": "인구를 남녀로 나누어 본 구성 상태로, 주로 성비로 나타냅니다.",
"example": null,
"confidence": "medium"
},
"t_459b813fbd": {
"easy": "남녀 인구의 균형을 보여 주는 지수로, 보통 여자 100명당 남자 수로 나타냅니다.",
"example": "노인 인구 성비가 낮은 지역에서 독거 여성 노인 돌봄을 강화하는 장면",
"confidence": "high"
},
"t_37d4b3fea5": {
"easy": "한 여성이 평생 낳는 여자아이 중 가임기까지 살아남는 수를 반영한 지표입니다. 1보다 작으면 장기적으로 인구가 줄어듭니다.",
"example": null,
"confidence": "medium"
},
"t_e5bb0cb041": {
"easy": "장기요양기관(요양시설)에 입소해 신체활동 지원, 간호, 요양 등을 받는 장기요양급여입니다.",
"example": "장기요양등급을 받은 어르신이 노인요양시설에 입소해 돌봄을 받는 장면",
"confidence": "medium"
},
"t_074d3540f4": {
"easy": "맬서스 인구론에 바탕을 두고, 인구증가를 억제하기 위해 산아제한·수태조절이 필요하다고 주장하는 입장입니다.",
"example": null,
"confidence": "high"
},
"t_57c092295d": {
"easy": "나이가 들면서 근골격계 등 신체 구조와 기능이 변하고 약해지는 과정입니다.",
"example": "방문간호사가 근력이 약해진 어르신께 낙상 예방 운동을 지도하는 장면",
"confidence": "high"
},
"t_2f26497aad": {
"easy": "특정 시점에 특정 지역에 실제로 있는 인구로, 현재인구·상주인구·종업지인구 등이 있습니다.",
"example": null,
"confidence": "high"
},
"t_fe9b255ad3": {
"easy": "나이가 들면서 기억력·감각·성격·정서 등 심리적 기능과 적응 방식이 변하는 것을 말합니다.",
"example": "어르신의 기억력 저하 호소를 듣고 치매안심센터 선별검사를 권하는 장면",
"confidence": "medium"
},
"t_f0e1a0f41b": {
"easy": "노인을 입소시켜 급식과 일상생활 편의를 제공하는 노인주거복지시설로, 입소 정원이 10명 이상입니다.",
"example": null,
"confidence": "high"
},
"t_80949fd0fb": {
"easy": "특정 연령층 여성 1,000명당 그 해에 낳은 출생아 수입니다. 모두 더하면 합계출산율이 됩니다.",
"example": null,
"confidence": "high"
},
"t_5b4aba10da": {
"easy": "신경관 결손을 예방하기 위해 임신 초기 임부 등에게 엽산제를 무료로 나누어 주는 보건소 사업입니다.",
"example": "보건소에 임신 등록하러 온 임부에게 엽산제를 지급하는 장면",
"confidence": "medium"
},
"t_1de22b45f5": {
"easy": "출생아 1,000명당 생후 1년 이내에 사망한 영아 수입니다. 국가의 보건 수준을 보여 주는 대표 지표입니다.",
"example": "보건소가 영아사망률을 낮추기 위해 신생아 가정방문을 확대하는 장면",
"confidence": "high"
},
"t_2ce8d196e3": {
"easy": "넓게는 출생부터 미성년자까지, 좁게는 출생부터 취학 전까지의 아동을 말합니다.",
"example": null,
"confidence": "high"
},
"t_67a2c13084": {
"easy": "영유아기에 잘 걸리는 감염병에 대해 면역을 갖게 해 아이를 보호하기 위한 예방접종입니다.",
"example": "보건소에서 생후 2개월 아기에게 표준 일정에 맞춰 예방접종을 하는 장면",
"confidence": "high"
},
"t_3cc433bdd7": {
"easy": "만 6세 미만 아동의 생명과 건강을 보호하고 건강한 성장·발달을 돕는 국가 건강관리 사업입니다.",
"example": "보건소가 영유아 건강검진과 예방접종, 영양 상담을 함께 운영하는 장면",
"confidence": "high"
},
"t_5f18949052": {
"easy": "영유아의 건강 수준을 평가하는 지표로, 영아사망률, 신생아사망률, 저체중아 출생률 등이 있습니다.",
"example": null,
"confidence": "high"
},
"t_79fe4f3366": {
"easy": "특정 질환이나 상태가 있어 예방접종을 하면 위험할 수 있으므로 접종하지 말아야 하는 경우입니다.",
"example": "간호사가 접종 전 예진표로 이전 접종 후 심한 알레르기 반응이 있었는지 확인하는 장면",
"confidence": "medium"
},
"t_1c22560f7d": {
"easy": "입소 비용 전액을 입소자가 부담하는 양로시설입니다.",
"example": null,
"confidence": "medium"
},
"t_1e8672151a": {
"easy": "생산가능인구(15~64세) 100명이 부양해야 하는 유소년(0~14세) 인구 수입니다.",
"example": null,
"confidence": "high"
},
"t_c5f3aba4ba": {
"easy": "생활이 어려운 사람에게 국가가 의료비를 지원하는 의료급여 제도의 대상자입니다.",
"example": "보건소가 의료급여수급 어르신의 건강진단과 시설 입소 신청을 돕는 장면",
"confidence": "medium"
},
"t_65282bf964": {
"easy": "인구 변동을 설명하기 위해 가정한 가상의 인구로, 폐쇄인구·안정인구·정지인구 등이 있습니다.",
"example": null,
"confidence": "high"
},
"t_85b8f5cfad": {
"easy": "태아가 모체 밖에서 생존할 수 없는 시기에 인위적으로 임신을 끝내는 것입니다. 예방을 위해 성교육과 생명존중 교육이 이루어집니다.",
"example": "학교 보건교사가 청소년에게 피임과 생명존중에 관한 성교육을 하는 장면",
"confidence": "medium"
},
"t_ba85574695": {
"easy": "특정한 시간에 특정한 지역에 사는 사람들의 집단을 말합니다.",
"example": null,
"confidence": "high"
},
"t_6ba20392b4": {
"easy": "인구를 성별·연령별로 쌓아 그린 그래프로, 피라미드형·종형·항아리형·별형·호로형 등이 있습니다.",
"example": "보건소가 지역 인구 피라미드를 보고 노인 비중이 큰 항아리형임을 확인하는 장면",
"confidence": "high"
},
"t_ecf6d421fd": {
"easy": "인구집단의 성별·연령별·산업별·도시농촌별 구성 상태로, 지역의 사회·경제적 특성을 보여 줍니다.",
"example": "지역사회 진단 시 인구구조 자료로 노인·아동 비율을 확인하는 장면",
"confidence": "high"
},
"t_b49ff0d88e": {
"easy": "출생·사망·혼인·이주 등 일정 기간 동안 인구가 변하는 상태로, 신고 자료로 파악합니다.",
"example": null,
"confidence": "high"
},
"t_b860f0b4b6": {
"easy": "사회가 산업화되면서 출생률과 사망률이 높은 상태에서 낮은 상태로 변해 가는 인구 변화 과정을 설명하는 이론입니다.",
"example": null,
"confidence": "medium"
},
"t_53ef74373d": {
"easy": "인구의 증가·감소의 원인과 결과, 대책을 설명하는 이론으로, 맬서스주의 등이 있습니다.",
"example": null,
"confidence": "medium"
},
"t_9af586f48a": {
"easy": "인구의 규모나 질을 국가·사회가 원하는 방향으로 바꾸려는 정책입니다.",
"example": "저출산 대응을 위해 출산 지원금과 난임 지원을 확대하는 정책 장면",
"confidence": "high"
},
"t_88ee7c5f66": {
"easy": "특정 한 시점에서 인구의 규모와 구성을 멈춘 상태로 본 것으로, 인구주택총조사(센서스) 등으로 파악합니다.",
"example": null,
"confidence": "medium"
},
"t_12aae52dc5": {
"easy": "인구를 통계적으로 파악해 수치로 나타낸 것으로, 인구동태통계와 인구정태통계로 나뉩니다.",
"example": null,
"confidence": "high"
},
"t_980776d33a": {
"easy": "1년간 출생아 수를 가임여성(15~49세) 인구 1,000명당으로 나타낸 출산 지표입니다.",
"example": null,
"confidence": "high"
},
"t_5b1cf6a17c": {
"easy": "임신 중이거나 분만 후 일정 기간이 지나지 않은 여성입니다. 합병증이 태아에게도 영향을 줄 수 있어 관리가 중요합니다.",
"example": "보건소에서 임산부 등록 후 산전검사 일정을 안내하는 장면",
"confidence": "high"
},
"t_1527a12eb4": {
"easy": "임신·출산에 드는 진료비 부담을 줄이기 위해 의료비를 지원하는 사업으로, 청소년 산모 등이 대상이 됩니다.",
"example": "보건소가 청소년 산모에게 임신·출산 의료비 지원 신청을 안내하는 장면",
"confidence": "medium"
},
"t_d5df1310ac": {
"easy": "6개월 이상 혼자 일상생활이 어려운 사람에게 제공하는 신체·가사활동 지원, 간병 서비스 또는 이를 대신하는 현금입니다.",
"example": null,
"confidence": "high"
},
"t_f7b258a73f": {
"easy": "장기요양을 신청한 사람이 급여를 받을 자격이 있는지 조사와 판정을 거쳐 인정받는 것입니다.",
"example": "공단 직원이 어르신 댁을 방문 조사해 장기요양인정 절차를 진행하는 장면",
"confidence": "medium"
},
"t_4b2e4333d2": {
"easy": "집에서 생활하는 노인에게 방문요양·방문목욕·방문간호·주야간보호·단기보호 등을 제공하는 장기요양급여입니다.",
"example": "재가급여를 받는 어르신 댁에 방문간호사와 요양보호사가 번갈아 방문하는 장면",
"confidence": "medium"
},
"t_1f4da463b0": {
"easy": "방문간호, 방문요양, 주야간보호, 단기보호, 방문목욕 등 재가서비스를 하나 이상 제공하는 시설입니다.",
"example": null,
"confidence": "high"
},
"t_07c37a44f0": {
"easy": "집에서 생활하는 노인에게 상담, 교육, 지역사회 자원 연계 등 생활을 돕는 재가 서비스입니다.",
"example": null,
"confidence": "low"
},
"t_9fbf68bd26": {
"easy": "한 여성이 가임기 동안 낳는 여자아이 수로 다음 세대 인구가 얼마나 재생산되는지 보여 주는 지표입니다. 총재생산율과 순재생산율이 있습니다.",
"example": null,
"confidence": "high"
},
"t_a37a5f777d": {
"easy": "노인장기요양보험을 운영하는 데 드는 돈의 출처로, 장기요양보험료, 국가·지자체 부담, 이용자 본인일부부담금으로 이루어집니다.",
"example": null,
"confidence": "medium"
},
"t_12007beb7d": {
"easy": "출생 시 체중이 2,500g 미만인 신생아로, 건강 문제가 생길 위험이 커 관리가 필요합니다.",
"example": "보건소 간호사가 저체중아로 태어난 아기 가정을 방문해 수유와 체중을 확인하는 장면",
"confidence": "medium"
},
"t_86f6387050": {
"easy": "전체 출생아 중 저체중아가 차지하는 비율로, 영유아 건강 수준을 보여 주는 지표입니다.",
"example": null,
"confidence": "medium"
},
"t_5653792823": {
"easy": "저출산·고령사회 문제에 대응하기 위해 정부가 5년마다 세우는 국가 인구정책 계획입니다.",
"example": null,
"confidence": "high"
},
"t_de33da724e": {
"easy": "임신 기간에 정해진 간격으로 산전검진을 받는 것으로, 임신 후반으로 갈수록 더 자주 받습니다.",
"example": "보건소 간호사가 임부에게 다음 산전검진 날짜를 안내하는 장면",
"confidence": "high"
},
"t_3fdebc3240": {
"easy": "노인의 우울 등 정신건강 문제를 발견·관리하고 자살을 예방하기 위한 사업입니다.",
"example": "보건소가 독거 어르신 대상 우울 선별검사 후 정신건강복지센터를 연계하는 장면",
"confidence": "medium"
},
"t_9ce55345f4": {
"easy": "1년간 총 사망자 수를 그 해 연중앙인구 1,000명당으로 나타낸 가장 기본적인 사망 지표입니다.",
"example": null,
"confidence": "high"
},
"t_f018135d3a": {
"easy": "출생률과 사망률이 모두 낮아 인구 증가가 정체된 선진국형 인구 피라미드입니다.",
"example": null,
"confidence": "high"
},
"t_8748786b1f": {
"easy": "임신 후반기의 사산과 출생 직후 신생아 사망을 합하여 나타낸 지표로, 산전·분만 관리 수준을 보여 줍니다.",
"example": null,
"confidence": "medium"
},
"t_91d02fc2a3": {
"easy": "노인을 하루 중 일정 시간 동안 기관에서 보호하며 돌봄과 프로그램을 제공하는 재가 서비스입니다.",
"example": "낮 동안 치매 어르신이 주간보호센터에서 인지 프로그램에 참여하는 장면",
"confidence": "medium"
},
"t_a8d6f0ad9d": {
"easy": "전체 인구를 나이 순으로 세웠을 때 정확히 가운데 있는 사람의 나이로, 인구 고령화 정도를 보여 줍니다.",
"example": null,
"confidence": "medium"
},
"t_b6d255d1e1": {
"easy": "주민의 질병을 예방하고 고혈압·당뇨·비만 등을 관리하기 위해 보건소가 운영하는 사업입니다.",
"example": "보건소가 체성분 분석 후 비만 주민에게 체중관리 상담을 하는 장면",
"confidence": "low"
},
"t_c889b027d5": {
"easy": "임신 중 철 결핍성 빈혈을 예방하기 위해 임부에게 철분제를 지급하는 보건소 사업입니다.",
"example": "임신 중기 임부에게 보건소가 철분제를 지급하고 복용법을 알려 주는 장면",
"confidence": "medium"
},
"t_5c6bcb22ae": {
"easy": "청소년기에 임신·출산한 산모로, 의료비 지원 등 특별한 지원 대상입니다.",
"example": "보건소가 청소년 산모에게 의료비 지원과 산전관리를 안내하는 장면",
"confidence": "medium"
},
"t_be016b8d4e": {
"easy": "난자와 정자를 몸 밖에서 수정시킨 뒤 자궁에 이식하는 난임 치료 시술로, 비용이 많이 들어 지원 대상이 됩니다.",
"example": "보건소에서 난임부부에게 체외수정시술비 지원 결정 통지를 안내하는 장면",
"confidence": "high"
},
"t_854d45b113": {
"easy": "전체 인구 중 65세 이상 인구가 20% 이상인 사회를 말합니다.",
"example": "초고령 농촌 지역 보건진료소가 방문건강관리를 중심으로 운영되는 장면",
"confidence": "high"
},
"t_f00cdf0882": {
"easy": "생산가능인구(15~64세) 100명이 부양해야 하는 유소년과 노인 인구를 합한 수입니다.",
"example": null,
"confidence": "high"
},
"t_a6f40f20cc": {
"easy": "한 여성이 현재 출산 수준대로 가임기 동안 낳을 것으로 예상되는 여자아이의 수입니다.",
"example": null,
"confidence": "medium"
},
"t_e38203adf6": {
"easy": "출생아 수, 출산율 등 출산에 관한 통계로, 인구 변화와 모자보건 계획의 기초 자료입니다.",
"example": null,
"confidence": "medium"
},
"t_ab452d6ca8": {
"easy": "취학 전 아동의 시력 이상을 일찍 찾아 치료로 연결해 실명과 시력 장애를 예방하는 사업입니다.",
"example": "어린이집에서 보건소가 아동 시력검사를 실시하는 장면",
"confidence": "medium"
},
"t_50199b0fe3": {
"easy": "치매를 조기에 발견하기 위해 노인을 대상으로 선별검사와 진단검사를 실시하는 사업입니다.",
"example": "치매안심센터에서 어르신께 인지 선별검사를 실시하는 장면",
"confidence": "medium"
},
"t_20a8abb49c": {
"easy": "치매 예방, 조기 발견, 치료·돌봄 연계, 가족 지원 등을 포괄하는 치매 관리 사업입니다.",
"example": "치매안심센터가 치매 어르신 가족에게 돌봄 교육과 자원 연계를 하는 장면",
"confidence": "medium"
},
"t_10af3fc5a5": {
"easy": "보건소에 설치되어 치매 상담, 조기검진, 환자 등록·관리 등을 수행하던 기관입니다.",
"example": null,
"confidence": "low"
},
"t_ecd3b4b433": {
"easy": "장기요양기관으로 지정되지 않은 기관에서 장기요양급여에 해당하는 서비스를 받았을 때 비용 일부를 현금으로 지급하는 것입니다.",
"example": null,
"confidence": "high"
},
"t_a0d378f149": {
"easy": "장기요양 서비스 대신 현금으로 지급하는 급여로, 가족요양비와 특례요양비 등이 있습니다.",
"example": null,
"confidence": "high"
},
"t_d943982472": {
"easy": "임신부터 영유아기까지의 검사, 예방접종, 건강관리 내용을 안내하고 기록하는 수첩입니다.",
"example": "보건소에서 임신 등록한 임부에게 모자보건수첩을 주며 기록 방법을 설명하는 장면",
"confidence": "high"
},
"t_6b4e283a4f": {
"easy": "출생률과 사망률이 모두 높아 아래가 넓고 위가 좁은 후진국형 인구 피라미드입니다.",
"example": null,
"confidence": "high"
},
"t_eba1cbae9f": {
"easy": "임신 중 약물 복용의 안전성 등에 대해 무료로 전문 상담을 제공하는 기관입니다.",
"example": "감기약을 먹은 뒤 임신 사실을 안 임부에게 마더세이프 상담을 안내하는 장면",
"confidence": "high"
},
"t_7a6f4e7b34": {
"easy": "가임여성 1명이 평생 낳을 것으로 예상되는 평균 자녀 수로, 연령별 출산율을 모두 더한 값입니다.",
"example": null,
"confidence": "high"
},
"t_1a8e8019ff": {
"easy": "출생률이 사망률보다 낮아 인구가 줄어드는 감퇴형 인구 피라미드로, 일부 선진국에서 나타납니다.",
"example": null,
"confidence": "high"
},
"t_63862392ea": {
"easy": "15~49세 인구가 전체의 50% 미만인 피라미드로, 젊은 인구가 빠져나가는 농촌형(전출형)입니다.",
"example": "젊은 층이 떠난 농촌 군 보건소가 노인 중심 사업을 운영하는 장면",
"confidence": "high"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- 로그인 시 Apple container 런타임과 nursing-atlas 컨테이너를 기동하는 one-shot 에이전트.
__REMOTE_DIR__ 는 deploy-macmini.sh 가 치환한다. -->
<plist version="1.0">
<dict>
<key>Label</key><string>com.nursing-atlas.container</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>/usr/local/bin/container system start; /usr/local/bin/container start nursing-atlas</string>
</array>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>__REMOTE_DIR__/logs/container-boot.log</string>
<key>StandardErrorPath</key><string>__REMOTE_DIR__/logs/container-boot.log</string>
</dict>
</plist>
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# macmini(Apple container)로 배포: 소스 동기화 → 이미지 빌드 → 컨테이너 교체 → 부팅 시 자동 시작 등록.
#
# deploy/deploy-macmini.sh
#
# 저작권: 교재 PDF는 서버로 보내지 않는다 (PDF 추출은 개발 PC에서만).
#
# 환경변수: SSH_HOST(기본 macmini) PORT(기본 8088) REMOTE_DIR(기본 ~/Community_Nursing_Atlas)
set -euo pipefail
SSH_HOST="${SSH_HOST:-macmini}"
PORT="${PORT:-8088}"
NAME="nursing-atlas"
IMAGE="nursing-atlas:latest"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REMOTE_HOME="$(ssh "$SSH_HOST" 'echo $HOME')"
REMOTE_DIR="${REMOTE_DIR:-$REMOTE_HOME/Community_Nursing_Atlas}"
C=/usr/local/bin/container
echo "▶ 대상: $SSH_HOST:$REMOTE_DIR (포트 $PORT)"
[[ -f "$ROOT/data/atlas.json" ]] || { echo "data/atlas.json 이 없습니다. 먼저: .venv/bin/python -m pipeline.build"; exit 1; }
ssh "$SSH_HOST" "mkdir -p '$REMOTE_DIR'/{app/data,data,logs}"
echo "▶ 소스 동기화"
rsync -az --delete \
--include='/Dockerfile' --include='/requirements-server.txt' \
--include='/server/***' --include='/web/***' \
--include='/data/' --include='/data/atlas.json' \
--exclude='__pycache__' --exclude='*' \
"$ROOT/" "$SSH_HOST:$REMOTE_DIR/app/"
echo "▶ 이미지 빌드 및 컨테이너 교체"
ssh "$SSH_HOST" bash -s <<REMOTE
set -euo pipefail
$C system status >/dev/null 2>&1 || $C system start
cd "$REMOTE_DIR/app"
$C build -t $IMAGE -f Dockerfile .
$C stop $NAME >/dev/null 2>&1 || true
$C delete $NAME >/dev/null 2>&1 || true
$C run -d --name $NAME \\
-p 0.0.0.0:$PORT:8088 \\
-v "$REMOTE_DIR/data:/app/data" \\
-c 2 -m 1024M \\
$IMAGE
REMOTE
echo "▶ 부팅 시 자동 시작(launchd) 등록"
sed "s#__REMOTE_DIR__#$REMOTE_DIR#g" "$ROOT/deploy/com.nursing-atlas.container.plist" | \
ssh "$SSH_HOST" "cat > ~/Library/LaunchAgents/com.nursing-atlas.container.plist"
ssh "$SSH_HOST" "launchctl bootout gui/\$(id -u)/com.nursing-atlas.container 2>/dev/null; launchctl bootstrap gui/\$(id -u) ~/Library/LaunchAgents/com.nursing-atlas.container.plist 2>/dev/null || true"
echo "▶ 동작 확인"
for i in $(seq 1 30); do
if ssh "$SSH_HOST" "curl -fsS -o /dev/null http://127.0.0.1:$PORT/api/admin/me"; then
echo "✔ 실행 중: http://$SSH_HOST:$PORT"
echo " 교수자 로그인: /login (아이디·비밀번호는 관리 → 설정에서 변경. 처음 만든 계정은 컨테이너 로그에 한 번 표시됨)"
exit 0
fi
sleep 2
done
echo "✘ 응답이 없습니다. 로그: deploy/macmini.sh logs"; exit 1
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# macmini 컨테이너 관리 도우미
# deploy/macmini.sh status | logs | restart | stop | password(처음 만든 교수자 계정)
# deploy/macmini.sh feedback [--all] # 오류 신고 목록 (server.cli)
# deploy/macmini.sh cli show 12 # server.cli 명령 그대로 실행
# deploy/macmini.sh backup # DB를 로컬 backups/ 로 내려받기
set -euo pipefail
SSH_HOST="${SSH_HOST:-macmini}"
NAME="nursing-atlas"
C=/usr/local/bin/container
REMOTE_DIR="${REMOTE_DIR:-\$HOME/Community_Nursing_Atlas}"
cmd="${1:-status}"; shift || true
case "$cmd" in
status) ssh "$SSH_HOST" "$C list --all | grep -E 'ID|$NAME'; curl -fsS -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:\${PORT:-8088}/ || true" ;;
logs) ssh "$SSH_HOST" "$C logs $NAME | tail -n ${1:-100}" ;;
restart) ssh "$SSH_HOST" "$C stop $NAME; $C start $NAME" ;;
stop) ssh "$SSH_HOST" "$C stop $NAME" ;;
password) ssh "$SSH_HOST" "$C logs $NAME | grep -a '교수자 계정' | tail -1; echo '(비밀번호를 바꿨다면 관리 → 설정의 값이 우선합니다)'" ;;
feedback) ssh "$SSH_HOST" "$C exec $NAME python -m server.cli feedback $*" ;;
cli) ssh "$SSH_HOST" "$C exec $NAME python -m server.cli $(printf '%q ' "$@")" ;;
backup)
ts=$(date +%Y%m%d-%H%M%S); mkdir -p backups
ssh "$SSH_HOST" "$C exec $NAME python -c \"import sqlite3; s=sqlite3.connect('/app/data/atlas.db'); d=sqlite3.connect('/app/data/backup.db'); s.backup(d); d.close()\""
ssh "$SSH_HOST" "cat $REMOTE_DIR/data/backup.db && rm $REMOTE_DIR/data/backup.db" > "backups/atlas-$ts.db" && echo "backups/atlas-$ts.db" ;;
*) echo "사용법: $0 {status|logs|restart|stop|password|feedback|cli|backup}"; exit 1 ;;
esac
View File
+631
View File
@@ -0,0 +1,631 @@
"""PDF → data/atlas.json 빌드.
.venv/bin/python -m pipeline.build
자동 추출 결과는 모두 review_status='draft' 이며 method 필드로 출처(색인/목차/본문/시안)를 남긴다.
"""
import hashlib
import json
import re
import sys
import time
import unicodedata
from collections import defaultdict
from functools import lru_cache
import pymupdf
from . import curated
from .config import (BOOKS, DATA_DIR, GALAXIES, SECONDARY_CHAPTER_MAP, SECONDARY_KEYWORD_MAP,
SECONDARY_SECTION_OVERRIDES)
from .index import extract_index
from .pdfutil import body_text, label_to_index
from .toc import extract_toc
HANGUL = re.compile(r"[가-힣]")
def log(*a):
print(*a, file=sys.stderr, flush=True)
def norm_key(s: str) -> str:
s = unicodedata.normalize("NFC", s).lower()
s = re.sub(r"[\s·\-–_‧∙/'’\.]", "", s)
return s
def tid(key: str) -> str:
return "t_" + hashlib.sha1(key.encode()).hexdigest()[:10]
def rid(a: str, b: str, typ: str) -> str:
return "r_" + hashlib.sha1(f"{a}|{b}|{typ}".encode()).hexdigest()[:10]
def clean_alias(a: str):
"""동의어 정리: 하이픈 줄바꿈 잇기, 연도·숫자만 있는 값 제거."""
a = re.sub(r"(?<=[A-Za-z])- (?=[a-z])", "", a).strip(" ,;")
if not a or re.fullmatch(r"[\d\s.,~\-]+", a) or len(a) < 2:
return None
return a
def split_name(raw: str):
"""'건강 신념 모형(Health Belief Model; HBM)' → ('건강 신념 모형', ['Health Belief Model', 'HBM'])"""
raw = raw.strip()
m = re.match(r"^(.+?)\s*[\((]([^()()]+)[\))]?\s*$", raw)
if m and m.group(1).strip():
name = m.group(1).strip()
inner = m.group(2)
aliases = [c for c in (clean_alias(a) for a in re.split(r"[;,]", inner)) if c]
return name, aliases
return raw, []
# ---------------------------------------------------------------- 단원 매핑
class ChapterIndex:
def __init__(self, toc_by_book):
self.toc = toc_by_book
self.sorted = {b: sorted(n, key=lambda x: (x["page"], x["level"])) for b, n in toc_by_book.items()}
self.by_id = {n["id"]: n for nodes in toc_by_book.values() for n in nodes}
def locate(self, book_id: str, page: int):
"""페이지가 속한 가장 깊은 목차 노드."""
best = None
for n in self.sorted.get(book_id, []):
if n["page"] <= page:
if best is None or n["page"] > best["page"] or (n["page"] == best["page"] and n["level"] >= best["level"]):
best = n
else:
break
return best
def ancestors(self, node):
out = []
while node:
out.append(node)
node = self.by_id.get(node["parent_id"])
return out[::-1]
def chapter_section(self, node):
chain = self.ancestors(node) if node else []
chapter = next((n for n in chain if n["kind"] == "chapter"), None)
section = next((n for n in chain if n["kind"] == "section"), None)
return chapter, section, chain
def galaxy_for(book, chapter, chain):
if book["primary"]:
if chapter is None:
return None
no = int(chapter["number"])
return next((g["id"] for g in GALAXIES if g["chapter_no"] == no), None)
titles = " ".join(n["title"] for n in chain[1:]) if chain else ""
for kw, g in SECONDARY_KEYWORD_MAP:
if kw in titles:
return g
if chapter is not None:
g = SECONDARY_CHAPTER_MAP.get((book["book_id"], int(chapter["number"])))
if g:
return g
return SECONDARY_CHAPTER_MAP.get((book["book_id"], None))
# ---------------------------------------------------------------- 본문 문장
SENT_SPLIT = re.compile(r"(?<=[다요음함됨임])\.\s+|(?<=\.)\s+(?=[가-힣A-Z①-⑳•\-])|\n")
DEF_PATTERNS = [
(re.compile(r"(이)?란\s"), 4),
(re.compile(r"(이)?라\s?(함은|한다|하며|하고|부른다)"), 4),
(re.compile(r"(말한다|의미한다|뜻한다|정의한다|정의된다|일컫는다|가리킨다|이르는 말)"), 4),
(re.compile(r"정의"), 2),
]
class Corpus:
def __init__(self):
self.docs = {}
self.label_maps = {}
def open(self, book):
bid = book["book_id"]
if bid not in self.docs:
self.docs[bid] = pymupdf.open(book["file"]())
self.label_maps[bid] = label_to_index(self.docs[bid])
return self.docs[bid]
def pdf_index(self, book, page: int):
self.open(book)
return self.label_maps[book["book_id"]].get(str(page))
@lru_cache(maxsize=4096)
def sentences(self, book_id: str, pdf_page: int) -> tuple[str, ...]:
doc = self.docs[book_id]
text = body_text(doc, pdf_page)
# 쪽 끝에서 끊긴 문장은 다음 쪽 첫 문장과 잇는다
if text and not re.search(r"[.?!)」』]\s*$", text) and pdf_page + 1 < len(doc):
nxt = body_text(doc, pdf_page + 1)
m = re.match(r"^(.{1,300}?(?:다|요|음)\.)", nxt)
if m:
joiner = "" if re.search(r"[가-힣]$", text) and re.match(r"^[가-힣]", nxt) else " "
text = text + joiner + m.group(1)
text = rejoin_lines(text)
out = []
for s in SENT_SPLIT.split(text):
s = s.strip(" •-·")
if 8 <= len(s) <= 400:
out.append(s if s.endswith(".") or not s.endswith("다") else s + ".")
return tuple(out)
HEADING = re.compile(r"^(\d+[\).]|[①-⑳]|[가-하][\).]|\(\d+\)|[•▪■□○●]|표\s?\d|그림\s?\d)")
WORD_END = set("의은는이가을를에로고며서과와도한된인적및등면데때중게")
def rejoin_lines(text: str) -> str:
"""줄 단위로 끊긴 본문을 문장으로 다시 잇는다. 제목·목록 줄은 그대로 둔다."""
out = []
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if out:
prev = out[-1]
ends = re.search(r"[.?!:)」』]$", prev) or len(prev) < 22 or HEADING.match(line) or "=" in prev
if not ends:
if re.search(r"[가-힣]$", prev) and re.match(r"^[가-힣]", line) and prev[-1] not in WORD_END:
out[-1] = prev + line # 단어 중간에서 줄바꿈
else:
out[-1] = prev + " " + line
continue
out.append(line)
return "\n".join(out)
PARTICLE = r"(?=\s|\(|(|은|는|이|가|란|을|를|의|과|와|에|도|로|으로|,|·|라|$)"
def score_sentence(sent: str, name: str, key: str):
"""정의 문장일수록 높은 점수. 용어가 없으면 None."""
flat = norm_key(sent)
if key not in flat or len(flat) - len(key) < 8: # 제목줄 등 너무 짧은 문장 제외
return None
pat = re.escape(name).replace(r"\ ", r"\s?")
m = re.search(pat + PARTICLE, sent)
if not m:
return 0
sc = 1
paren = r"\s?(?:\([^)]*\)|([^)]*))?\s?"
if re.match(r"^\s*" + pat + paren + r"(?:이란|란|은|는|이라 함은|라 함은|이라고 함은)\s", sent):
sc += 4
if re.search(r"(말한다|의미한다|뜻한다|정의한다|정의된다|일컫는다|가리킨다|이다|것이다)\.?$", sent):
sc += 3
elif re.search(r"(정의|의미|개념|나타내는|지표|방법|과정|상태|활동|체계|이론|모형)", sent):
sc += 2
elif re.search(pat + paren + r"(?:이?라고?\s?(?:한다|부른다|일컫는다|정의한다|명명한다))", sent):
sc += 6
if re.match(r"^(에|을|를|의|은|는|이|가|와|과|로|으로|하여|하고|되어|및|또는|등)\s", sent):
sc -= 6
if 20 <= len(sent) <= 220:
sc += 1
if len(sent) > 300:
sc -= 3
return sc
EN_PAREN = re.compile(r"\(\s*([A-Za-z][A-Za-z0-9 ,;:'&\-/\.]{1,90}?)\s*\)")
def english_aliases(sent: str, name: str):
"""문장에서 '용어(English, ABC)' 꼴을 찾아 영문 표기를 돌려줌."""
flat_name = re.escape(name).replace(r"\ ", r"\s?")
m = re.search(flat_name + r"\s?" + EN_PAREN.pattern, sent)
if not m:
return []
return [c for c in (clean_alias(a) for a in re.split(r"[;,]|:", m.group(1))) if c]
# ---------------------------------------------------------------- 메인
def build():
t0 = time.time()
corpus = Corpus()
toc = {}
entries = []
for b in BOOKS:
doc = corpus.open(b)
toc[b["book_id"]] = extract_toc(doc, b)
es = extract_index(doc, b)
for e in es:
e["book_id"] = b["book_id"]
entries.extend(es)
log(f"[{b['book_id']}] 목차 {len(toc[b['book_id']])}개, 색인 {len(es)}개")
chidx = ChapterIndex(toc)
books_by_id = {b["book_id"]: b for b in BOOKS}
# ---- 1) 색인 항목 → 용어 통합
terms: dict[str, dict] = {}
alias_index: dict[str, str] = {} # 별칭 키 → 용어 키
xrefs = []
def get_term(name, kind):
key = norm_key(name)
t = terms.get(key)
if t is None:
t = terms[key] = {
"key": key, "id": tid(key), "name": name, "kind": kind, "aliases": [],
"sources": [], "index_entries": [],
}
return t
# 한글/인물 먼저, 영문은 뒤에서 별칭 연결을 시도
for e in sorted(entries, key=lambda e: e["section"] == "en"):
if e.get("see"):
xrefs.append(e)
continue
name, aliases = split_name(e["term"])
if e["section"] == "en" and not HANGUL.search(name):
e["_name"], e["_aliases"] = name, aliases
continue
kind = "person" if e["section"] == "person" else "concept"
t = get_term(name, kind)
for a in aliases:
if a not in t["aliases"]:
t["aliases"].append(a)
alias_index.setdefault(norm_key(a), t["key"])
t["index_entries"].append({"book_id": e["book_id"], "raw": e["term"], "pages": e["pages"]})
for p in e["pages"]:
t["sources"].append({"book_id": e["book_id"], "page": p})
# ---- 2) 본문에서 영문 표기 찾기 (색인 페이지의 문장)
log("본문 문장 분석 중…")
for t in terms.values():
for s in t["sources"]:
b = books_by_id[s["book_id"]]
pi = corpus.pdf_index(b, s["page"])
if pi is None:
continue
for sent in corpus.sentences(b["book_id"], pi):
for a in english_aliases(sent, t["name"]):
if a not in t["aliases"] and norm_key(a) != t["key"]:
t["aliases"].append(a)
alias_index.setdefault(norm_key(a), t["key"])
# ---- 3) 영문 색인 항목: 별칭과 일치하면 합치고, 아니면 독립 용어
en_new = 0
for e in entries:
if "_name" not in e:
continue
k = norm_key(e["_name"])
target = alias_index.get(k) or next((alias_index[norm_key(a)] for a in e["_aliases"] if norm_key(a) in alias_index), None)
if target is None and k in terms:
target = k
if target:
t = terms[target]
if e["_name"] not in t["aliases"] and norm_key(e["_name"]) != t["key"]:
t["aliases"].append(e["_name"])
else:
t = get_term(e["_name"], "concept")
t["lang"] = "en"
en_new += 1
for a in e["_aliases"]:
if a not in t["aliases"]:
t["aliases"].append(a)
t["index_entries"].append({"book_id": e["book_id"], "raw": e["term"], "pages": e["pages"]})
for p in e["pages"]:
if {"book_id": e["book_id"], "page": p} not in t["sources"]:
t["sources"].append({"book_id": e["book_id"], "page": p})
log(f"영문 독립 용어 {en_new}개")
for x in xrefs:
src = terms.get(norm_key(split_name(x["see"])[0]))
if src and x["term"] not in src["aliases"]:
src["aliases"].append(x["term"])
# ---- 4) 출처마다 단원·은하·문장 연결, 정의 후보 선택
log("출처·정의 연결 중…")
for t in terms.values():
best_def = None
galaxy_votes = defaultdict(float)
memberships = {}
for s in t["sources"]:
b = books_by_id[s["book_id"]]
node = chidx.locate(b["book_id"], s["page"])
chapter, section, chain = chidx.chapter_section(node)
s["toc_id"] = node["id"] if node else None
s["chapter_id"] = chapter["id"] if chapter else None
s["section_id"] = section["id"] if section else None
s["path"] = " › ".join(n["title"] for n in chain[1:]) if chain else ""
g = galaxy_for(b, chapter, chain)
s["galaxy_id"] = g
if g:
galaxy_votes[g] += 3 if b["primary"] else 1
if b["primary"] and section:
memberships[section["id"]] = g
item = next((n for n in chain if n["kind"] == "item"), None)
if item and section["id"] not in t.setdefault("items", {}):
t["items"][section["id"]] = item["id"]
pi = corpus.pdf_index(b, s["page"])
s["pdf_page"] = pi + 1 if pi is not None else None
s["snippet"] = None
if pi is None:
continue
cand = []
for sent in corpus.sentences(b["book_id"], pi):
sc = score_sentence(sent, t["name"], t["key"])
if sc is not None:
cand.append((sc, sent))
if cand:
cand.sort(key=lambda x: (-x[0], len(x[1])))
sc, sent = cand[0]
s["snippet"] = sent
weight = sc + (1 if b["primary"] else 0)
if sc >= 7 and (best_def is None or weight > best_def[0]):
best_def = (weight, sent, b["book_id"], s["page"])
t["galaxy_id"] = max(galaxy_votes, key=galaxy_votes.get) if galaxy_votes else None
# 주 소속 절: 주 은하에 속한 절 가운데 처음 나온 것
t["section_ids"] = list(dict.fromkeys(memberships))
prim_secs = [sid for sid, g in memberships.items() if g == t["galaxy_id"]]
t["section_id"] = prim_secs[0] if prim_secs else None
if best_def:
t["definition"] = {"text": best_def[1], "book_id": best_def[2], "page": best_def[3], "method": "auto"}
else:
t["definition"] = None
# 보조 교재에만 있는 용어: 2026 절(하위 항목 제목 포함)과 출처 경로의 글자 bigram 유사도로 배정
def bigrams(text):
k = norm_key(text)
return {k[i:i + 2] for i in range(len(k) - 1)}
primary_nodes = [n for b in BOOKS if b["primary"] for n in toc[b["book_id"]]]
sec_nodes = [n for n in primary_nodes if n["kind"] == "section"]
sec_galaxy, sec_bag, item_bag, sec_items = {}, {}, {}, defaultdict(list)
for n in sec_nodes:
ch, _, chain = chidx.chapter_section(n)
sec_galaxy[n["id"]] = galaxy_for(books_by_id[n["book_id"]], ch, chain)
sec_bag[n["id"]] = bigrams(n["title"]) | bigrams(n["title"])
for n in primary_nodes:
if n["kind"] in ("item", "subitem"):
chain = chidx.ancestors(n)
sec = next((c for c in chain if c["kind"] == "section"), None)
if sec:
sec_bag[sec["id"]] |= bigrams(n["title"])
if n["kind"] == "item":
sec_items[sec["id"]].append(n)
item_bag[n["id"]] = bigrams(n["title"])
else:
it = next((c for c in chain if c["kind"] == "item"), None)
if it:
item_bag.setdefault(it["id"], set()).update(bigrams(n["title"]))
def sim(a, b):
return len(a & b) / ((len(a) * len(b)) ** 0.5 or 1)
# 보조 교재의 절 → 2026 절 매핑 (제목·상위·하위 항목 제목 전체의 bigram 유사도)
sec_map = {}
for b in BOOKS:
if b["primary"]:
continue
nodes = toc[b["book_id"]]
kids = defaultdict(list)
for n in nodes:
kids[n["parent_id"]].append(n)
def desc_bag(n):
out = bigrams(n["title"])
for k in kids[n["id"]]:
out |= desc_bag(k)
return out
for n in nodes:
if n["kind"] not in ("section", "chapter"):
continue
chain = chidx.ancestors(n)
ch = next((c for c in chain if c["kind"] == "chapter"), None)
mapped = galaxy_for(b, ch, chain)
titles = " ".join(c["title"] for c in chain[1:])
forced = next((p for kw, title in SECONDARY_SECTION_OVERRIDES if kw in titles
for p in sec_nodes if p["title"].strip() == title), None)
if forced:
sec_map[n["id"]] = forced["id"]
continue
bag = desc_bag(n) | {x for c in chain[1:-1] for x in bigrams(c["title"])}
sec_map[n["id"]] = max(sec_nodes, key=lambda p: sim(bag, sec_bag[p["id"]]) + (0.08 if sec_galaxy[p["id"]] == mapped else 0))["id"]
for t in terms.values():
if t.get("section_id"):
continue
votes = defaultdict(int)
for s in t["sources"]:
if books_by_id[s["book_id"]]["primary"]:
continue
key = s.get("section_id") or s.get("chapter_id")
if key in sec_map:
votes[sec_map[key]] += 1
if votes:
best = max(votes, key=votes.get)
else:
best = max(sec_nodes, key=lambda n: sim(bigrams(t["name"]), sec_bag[n["id"]]))["id"]
t["section_id"] = best
t["galaxy_id"] = sec_galaxy[best]
t["section_guess"] = True
for t in terms.values():
if not t["galaxy_id"]:
t["galaxy_id"] = sec_galaxy.get(t["section_id"], "g1")
# 절 안의 항목(지도에서 작은 묶음): 원문 위치가 없으면 이름 유사도로 추정
item = t.get("items", {}).get(t["section_id"])
if not item and sec_items.get(t["section_id"]):
bag = bigrams(t["name"]) | {b for s in t["sources"] for b in bigrams(s.get("path", "").split(" › ")[-1])}
item = max(sec_items[t["section_id"]], key=lambda n: sim(bag, item_bag.get(n["id"], set())))["id"]
t["item_id"] = item
# ---- 5) 시안 예시 설명 붙이기
def find(name):
k = norm_key(name)
if k in terms:
return terms[k]
if k in alias_index:
return terms[alias_index[k]]
return None
for name, desc, example, aliases in curated.WIREFRAME_TERMS:
t = find(name)
if not t:
log(f" (시안 용어 없음) {name}")
continue
t["easy"] = desc
t["example"] = example
t["search_terms"] = aliases.split()
# 쉬운 뜻·사례 초안 (data/drafts/out_*.json, 교재 문장을 바탕으로 작성한 검토 전 초안)
drafts = {}
for f in sorted((DATA_DIR / "drafts").glob("out_*.json")):
try:
drafts.update({k: v for k, v in json.loads(f.read_text()).items()
if v and v.get("easy") and v.get("confidence") != "low"}) # 신뢰도 낮은 초안은 제외
except json.JSONDecodeError as e:
log(f" 초안 파일 오류 {f.name}: {e}")
used = 0
for t in terms.values():
dr = drafts.get(t["id"])
if dr and not t.get("easy"):
t["easy"] = dr["easy"].strip()
t["example"] = (dr.get("example") or "").strip() or None
t["easy_method"] = "draft"
used += 1
log(f"쉬운 뜻 초안 {used}개 반영")
# ---- 6) 관계 초안
relations = {}
def add_rel(a, b, typ, label, method, evidence=None, directed=True):
if a is None or b is None or a["id"] == b["id"]:
return
r = rid(a["id"], b["id"], typ)
if r in relations or rid(b["id"], a["id"], typ) in relations and not directed:
return
relations[r] = {
"relation_id": r, "source": a["id"], "target": b["id"], "type": typ, "label": label,
"directed": directed, "method": method, "evidence": evidence or [], "review_status": "draft",
}
for a, b, typ, label in curated.WIREFRAME_RELATIONS:
add_rel(find(a), find(b), typ, label, "wireframe")
for a, b, label in curated.CONTRAST_PAIRS:
add_rel(find(a), find(b), "contrasts_with", label, "curated", directed=False)
# 목차 계층: 제목이 용어인 항목 → 상위 항목 (part_of), '과정' 아래 형제는 순서(precedes)
for b in BOOKS:
nodes = toc[b["book_id"]]
by_id = {n["id"]: n for n in nodes}
kids = defaultdict(list)
for n in nodes:
kids[n["parent_id"]].append(n)
for n in nodes:
parent = by_id.get(n["parent_id"])
tn = find(n["title"])
if parent and tn:
tp = find(parent["title"])
if tp:
add_rel(tn, tp, "part_of", f"목차에서 ‘{parent['title']}’의 하위 항목", "toc",
[{"book_id": b["book_id"], "page": n["page"]}])
for pid, ks in kids.items():
parent = by_id.get(pid)
if not parent or "과정" not in parent["title"] and parent["kind"] != "chapter":
continue
if "과정" not in parent["title"]:
continue
seq = [(k, find(k["title"])) for k in ks]
seq = [(k, t) for k, t in seq if t]
for (k1, t1), (k2, t2) in zip(seq, seq[1:]):
add_rel(t1, t2, "precedes", "다음 단계", "toc",
[{"book_id": b["book_id"], "page": k2["page"]}])
# 정의 문장 속에 등장하는 다른 용어 (가장 긴 것 최대 3개)
concept_terms = [t for t in terms.values() if t["kind"] == "concept" and len(t["key"]) >= 2 and HANGUL.search(t["name"])]
by_len = sorted(concept_terms, key=lambda t: -len(t["key"]))
for t in terms.values():
d = t.get("definition")
if not d:
continue
flat = norm_key(d["text"])
found = []
for o in by_len:
if o is t or len(o["key"]) < 3 or o["key"] in t["key"]:
continue
if o["key"] in flat and not any(o["key"] in f["key"] for f in found):
found.append(o)
if len(found) >= 3:
break
for o in found:
add_rel(t, o, "related_to", f"정의 문장에 ‘{o['name']}’이(가) 등장", "definition",
[{"book_id": d["book_id"], "page": d["page"]}])
# 이름 포함: '건강증진사업' → '건강증진' (같은 은하, 가장 긴 포함어 1개)
by_galaxy = defaultdict(list)
for t in concept_terms:
by_galaxy[t["galaxy_id"]].append(t)
for t in concept_terms:
best = None
for o in by_galaxy[t["galaxy_id"]]:
if o is t or len(o["key"]) < 3 or len(o["key"]) >= len(t["key"]):
continue
if o["key"] in t["key"] and (best is None or len(o["key"]) > len(best["key"])):
best = o
if best:
add_rel(t, best, "related_to", f"‘{best['name']}’에서 갈라져 나온 개념", "name")
# ---- 7) 출력
toc_out = []
for b in BOOKS:
for n in toc[b["book_id"]]:
ch, _, chain = chidx.chapter_section(n)
n = dict(n)
n["galaxy_id"] = galaxy_for(b, ch, chain)
toc_out.append(n)
term_out = []
for t in sorted(terms.values(), key=lambda t: t["name"]):
term_out.append({
"term_id": t["id"],
"name": t["name"],
"kind": t["kind"],
"lang": t.get("lang", "ko"),
"aliases": t["aliases"],
"search_terms": t.get("search_terms", []),
"galaxy_id": t["galaxy_id"],
"section_id": t["section_id"],
"section_guess": t.get("section_guess", False),
"item_id": t.get("item_id"),
"section_ids": t["section_ids"],
"definition": t["definition"],
"easy": t.get("easy"),
"example": t.get("example"),
"easy_method": t.get("easy_method") or ("wireframe" if t.get("easy") else None),
"sources": t["sources"],
"index_entries": t["index_entries"],
"review_status": "draft",
})
out = {
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"books": [{k: v for k, v in b.items() if k not in ("file", "toc_pages", "index_pages")} | {"filename": b["file"]().split("/")[-1]} for b in BOOKS],
"galaxies": GALAXIES,
"toc": toc_out,
"terms": term_out,
"relations": list(relations.values()),
}
DATA_DIR.mkdir(exist_ok=True)
path = DATA_DIR / "atlas.json"
path.write_text(json.dumps(out, ensure_ascii=False, indent=1))
by_method = defaultdict(int)
for r in relations.values():
by_method[r["method"]] += 1
log(f"용어 {len(term_out)}개 (정의 후보 {sum(1 for t in term_out if t['definition'])}개), "
f"관계 {len(relations)}개 {dict(by_method)}, {time.time()-t0:.1f}s → {path}")
if __name__ == "__main__":
build()
+142
View File
@@ -0,0 +1,142 @@
"""교재 PDF 목록과 은하(대단원) 구성.
은하는 2026 지역사회간호학 목차의 9개 장을 기준으로 삼고,
나머지 교재의 장은 아래 매핑표로 가장 가까운 은하에 연결한다.
"""
from pathlib import Path
import fnmatch
import unicodedata
ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = ROOT / "data"
def _find(pattern: str) -> str:
nfc = lambda x: unicodedata.normalize("NFC", x)
hits = sorted(str(p) for p in ROOT.iterdir() if fnmatch.fnmatch(nfc(p.name), nfc(pattern)))
if not hits:
raise FileNotFoundError(pattern)
return hits[0]
# toc_pages / index_pages: 0부터 시작하는 PDF 파일상 페이지 번호
BOOKS = [
{
"book_id": "cn26a",
"title": "지역사회간호학 1권",
"short": "지간 2026-1",
"edition": "2026",
"year": 2026,
"primary": True,
"file": lambda: _find("2026 지역사회간호학 1권.pdf"),
"toc_style": "cn2026",
"toc_pages": range(6, 17),
"index_pages": range(402, 408),
},
{
"book_id": "cn26b",
"title": "지역사회간호학 2권",
"short": "지간 2026-2",
"edition": "2026",
"year": 2026,
"primary": True,
"file": lambda: _find("2026 지역사회간호학 2권.pdf"),
"toc_style": "cn2026",
"toc_pages": range(6, 14),
"index_pages": range(312, 317),
},
{
"book_id": "pac23a",
"title": "지역사회간호학 1 (5판, 퍼시픽북스)",
"short": "퍼시픽 5판-1",
"edition": "5판",
"year": 2023,
"primary": False,
"file": lambda: _find("*pacific*-1_*.pdf"),
"toc_style": "pacific",
"toc_pages": range(9, 17),
"index_pages": None, # 자동 탐지
},
{
"book_id": "pac23b",
"title": "지역사회간호학 2 (5판, 퍼시픽북스)",
"short": "퍼시픽 5판-2",
"edition": "5판",
"year": 2023,
"primary": False,
"file": lambda: _find("*pacific*-2_*.pdf"),
"toc_style": "pacific",
"toc_pages": range(9, 17),
"index_pages": None,
},
{
"book_id": "he23",
"title": "보건교육학",
"short": "보건교육학 2023",
"edition": "2023",
"year": 2023,
"primary": False,
"file": lambda: _find("2023 보건교육학*.pdf"),
"toc_style": "healthedu",
"toc_pages": range(5, 12),
"index_pages": range(305, 309),
},
]
# 은하 = 2026 교재의 장. sub(쉬운 보조 설명)는 교수자 검수 대상 초안.
GALAXIES = [
{"id": "g1", "chapter_no": 1, "name": "지역사회간호개론", "sub": "지역사회와 간호의 기본 생각", "color": "#9de0cc"},
{"id": "g2", "chapter_no": 2, "name": "지역사회보건행정", "sub": "제도·정책·조직으로 건강 지키기", "color": "#e5a7c5"},
{"id": "g3", "chapter_no": 3, "name": "지역사회 간호과정", "sub": "문제를 찾고, 해결하고, 돌아보기", "color": "#c4a5ff"},
{"id": "g4", "chapter_no": 4, "name": "건강증진", "sub": "건강하게 살아갈 힘 기르기", "color": "#8fd694"},
{"id": "g5", "chapter_no": 5, "name": "보건교육", "sub": "건강을 배우고 실천하도록 돕기", "color": "#f2d17c"},
{"id": "g6", "chapter_no": 6, "name": "역학", "sub": "질병은 얼마나, 누구에게, 왜 생길까", "color": "#93c8f3"},
{"id": "g7", "chapter_no": 7, "name": "생애주기별 인구집단 간호", "sub": "아기부터 노인까지 나이에 맞는 돌봄", "color": "#f0a98a"},
{"id": "g8", "chapter_no": 8, "name": "건강문제별 인구집단 간호", "sub": "감염병·만성질환 등 문제별 관리", "color": "#ef8f9f"},
{"id": "g9", "chapter_no": 9, "name": "생활터별 인구집단 간호", "sub": "가정·학교·일터·환경에서의 돌봄", "color": "#e5bd8a"},
]
# 보조 교재 장 번호 → 은하. (book_id, 장 번호) 기준, 절 제목 키워드로 덮어쓸 수 있음.
SECONDARY_CHAPTER_MAP = {
("pac23a", 1): "g1",
("pac23a", 2): "g2",
("pac23a", 3): "g3",
("pac23a", 4): "g3",
("pac23a", 5): "g4",
("pac23b", 6): "g9",
("pac23b", 7): "g7",
("pac23b", 8): "g6",
("pac23b", 9): "g8",
("pac23b", 10): "g9",
("pac23b", 11): "g8",
("he23", None): "g5",
}
# 절/항 제목에 이 키워드가 있으면 해당 은하로 보냄 (보조 교재에만 적용)
SECONDARY_KEYWORD_MAP = [
("보건교육", "g5"),
("건강증진", "g4"),
("역학", "g6"),
("가족", "g9"),
("학교", "g9"),
("산업", "g9"),
("환경", "g9"),
("재난", "g8"),
("감염", "g8"),
("만성질환", "g8"),
]
# 보조 교재 목차 경로에 키워드가 있으면 2026 교재의 해당 제목 절로 우선 배정
SECONDARY_SECTION_OVERRIDES = [
("산업", "직업건강간호"),
("작업환경", "직업건강간호"),
("근로자", "직업건강간호"),
("재난", "재난관리"),
("학교", "학교보건"),
("학생", "학교보건"),
("가족", "가족간호"),
("보건통계", "역학적 측정지표"),
("모자보건", "모자보건사업"),
("영유아", "모자보건사업"),
("노인", "노인보건사업"),
("재활", "재활간호사업"),
]
+101
View File
@@ -0,0 +1,101 @@
"""사람이 직접 작성한 초안 데이터. 모두 review_status='draft'로 들어가며 교수자 검수가 필요하다.
- WIREFRAME_*: 기존 HTML 시안에 있던 예시 설명·관계 (교재 확인 전 예시)
- CONTRAST_PAIRS: 비교해서 배워야 하는 대표적 개념 쌍 (일반 지역사회간호학 지식 기반 초안)
"""
# 이름, 쉬운 뜻, 사례, 검색 표현
WIREFRAME_TERMS = [
("건강증진", "사람들이 자신의 건강을 더 잘 관리하고 개선하도록 돕는 과정입니다.", "걷기 좋은 환경을 만들고, 주민이 함께 건강 활동을 계획합니다.", "건강 향상"),
("보건교육", "건강에 관한 지식과 실천 방법을 배우도록 돕는 활동입니다.", "주민들과 식품의 영양표시를 읽고 활용하는 방법을 연습합니다.", "건강교육"),
("건강행태", "건강과 관련된 일상적인 행동을 뜻합니다.", "걷기, 식사, 수면 등 일상에서 반복하는 행동을 살펴봅니다.", "건강행동 생활습관"),
("주민참여", "주민이 지역의 문제를 파악하고 해결하는 과정에 함께하는 것입니다.", "주민이 마을 건강사업의 주제와 운영 방법을 함께 결정합니다.", "지역주민 참여"),
("역량강화", "자신과 공동체의 건강에 영향을 주는 문제를 다룰 힘을 키우는 과정입니다.", "주민 모임이 필요한 정보를 찾고 마을의 개선안을 제안합니다.", "임파워먼트 empowerment"),
("지역사회 간호사정", "지역의 건강 상태와 생활환경, 이용할 수 있는 자원을 살펴보는 과정입니다.", "지역 통계와 주민의 이야기를 모아 어떤 건강 문제가 있는지 알아봅니다.", "지역사회 사정 지역사회 건강사정 자료수집"),
("지역사회 간호진단", "사정한 자료를 바탕으로 간호가 필요한 지역사회의 문제를 정리하는 것입니다.", "수집한 자료를 비교하고, 지역에서 해결해야 할 건강 문제를 구체화합니다.", "지역사회 진단 건강문제"),
("우선순위 설정", "여러 문제 가운데 무엇을 먼저 다룰지 정하는 과정입니다.", "문제의 크기와 심각성, 해결 가능성 등을 살펴 사업의 순서를 정합니다.", "우선순위 무엇부터 먼저"),
("지역사회 간호계획", "목표를 정하고, 이를 달성하기 위한 활동과 자원 등을 구체화하는 단계입니다.", "사업의 대상, 활동, 일정, 담당자와 평가 방법을 정합니다.", "간호계획 사업계획 목표"),
("지역사회 간호수행", "계획한 간호 활동이나 사업을 실제로 실행하는 단계입니다.", "계획에 따라 주민 교육을 운영하고 참여 상황을 기록합니다.", "간호수행 실행 사업운영"),
("지역사회 간호평가", "사업이 어떻게 진행되었고 어떤 변화가 있었는지 살펴 판단하는 과정입니다.", "계획한 활동이 진행되었는지와 목표한 변화가 나타났는지 확인합니다.", "간호평가 사업평가"),
("역학", "인구집단에서 건강과 질병이 어떻게 분포하고 무엇과 관련되는지 연구하는 분야입니다.", "지역이나 연령에 따라 건강 문제의 분포가 다른지 살펴봅니다.", "epidemiology"),
("발생률", "일정 기간에 질병이 새롭게 발생하는 빈도를 나타내는 지표입니다.", "관찰 기간 중 새롭게 질병이 발생한 사람을 살펴봅니다.", "새 환자 신규 발생 incidence"),
("유병률", "특정 시점이나 기간에 질병을 가진 사람의 비율을 나타내는 지표입니다.", "현재 지역주민 가운데 해당 질병을 가진 사람이 얼마나 되는지 살펴봅니다.", "기존 환자 prevalence"),
("위험요인", "질병이나 건강 문제의 발생 가능성과 관련된 특성이나 노출입니다.", "어떤 생활환경이나 행동이 건강 문제와 관련되는지 살펴봅니다.", "위험인자 risk factor"),
("건강지표", "인구집단의 건강 상태 등을 수치로 나타내는 지표입니다.", "지역별 건강 수준을 비교하거나 시간에 따른 변화를 살펴봅니다.", "건강수준 통계"),
("가족간호", "가족의 건강 문제와 강점을 살펴 건강을 유지하고 증진하도록 돕는 간호입니다.", "가족 구성원의 역할과 관계, 건강관리 방법을 함께 살펴봅니다.", "가족 건강"),
("방문간호", "대상자가 생활하는 곳을 방문하여 제공하는 간호입니다.", "가정을 방문하여 건강 상태와 실제 생활환경을 함께 살펴봅니다.", "가정방문 방문건강관리"),
("사례관리", "복합적인 요구가 있는 대상자에게 필요한 서비스가 연결되도록 조정하는 과정입니다.", "건강관리와 생활지원이 함께 필요한 대상자의 서비스 이용을 조정합니다.", "케이스매니지먼트"),
("돌봄의 연속성", "시간이나 서비스 제공 장소가 바뀌어도 돌봄이 이어지는 것을 뜻합니다.", "퇴원 후에도 지역에서 필요한 건강관리를 받을 수 있도록 연결합니다.", "계속 돌봄 지속적 관리"),
("보건의료체계", "사람들에게 보건의료를 제공하기 위한 조직, 인력, 자원과 제도의 체계입니다.", "지역의 기관들이 어떤 역할로 건강관리를 제공하는지 살펴봅니다.", "보건의료시스템"),
("일차보건의료", "주민이 가까운 생활권에서 필요한 기본적인 건강관리를 받을 수 있도록 하는 접근입니다.", "지역의 건강 요구에 대응하는 접근성 높은 서비스를 생각해 봅니다.", "primary health care PHC"),
("보건소", "지역주민의 건강증진과 질병예방 등 공공보건 기능을 수행하는 기관입니다.", "지역의 건강사업을 운영하고 주민에게 필요한 보건서비스를 제공합니다.", "지역보건기관"),
("의뢰", "대상자의 요구에 맞는 전문 인력이나 기관으로 연결하는 것입니다.", "추가적인 서비스가 필요한 대상자를 적절한 기관으로 연결합니다.", "기관 의뢰 referral"),
("자원 연계", "대상자나 지역에 필요한 자원과 서비스를 찾아 연결하는 것입니다.", "건강관리 외에 필요한 생활지원 자원도 찾아 연결합니다.", "서비스 연결 지역자원"),
]
# (시작, 도착, 유형, 라벨)
WIREFRAME_RELATIONS = [
("지역사회 간호사정", "지역사회 간호진단", "precedes", "사정 자료를 바탕으로 문제를 정리"),
("지역사회 간호진단", "우선순위 설정", "precedes", "정리한 문제 중 먼저 다룰 문제를 선택"),
("우선순위 설정", "지역사회 간호계획", "precedes", "선택한 문제에 대한 활동을 계획"),
("지역사회 간호계획", "지역사회 간호수행", "precedes", "계획한 활동을 실행"),
("지역사회 간호수행", "지역사회 간호평가", "precedes", "수행 과정과 결과를 살펴봄"),
("지역사회 간호평가", "지역사회 간호사정", "informs", "평가 결과를 다음 사정에 반영"),
("건강지표", "지역사회 간호사정", "informs", "건강지표를 사정 자료로 활용"),
("보건교육", "건강증진", "part_of", "보건교육은 건강증진에 활용되는 방법"),
("보건교육", "건강행태", "informs", "건강행태에 관한 학습과 실천을 지원"),
("주민참여", "건강증진", "informs", "주민참여를 통해 건강증진을 추진"),
("주민참여", "역량강화", "informs", "참여를 통해 공동체의 역량을 키움"),
("건강지표", "역학", "informs", "역학에서 건강 상태를 나타내는 지표를 활용"),
("발생률", "건강지표", "part_of", "발생률은 건강지표의 한 예"),
("유병률", "건강지표", "part_of", "유병률은 건강지표의 한 예"),
("역학", "위험요인", "related_to", "역학에서 건강 문제와 위험요인의 관련성을 탐구"),
("가족간호", "방문간호", "related_to", "가족이 생활하는 곳에서 간호 제공"),
("방문간호", "사례관리", "informs", "방문 중 파악한 복합적인 요구의 관리를 연결"),
("사례관리", "돌봄의 연속성", "informs", "서비스 조정을 통해 돌봄이 이어지도록 지원"),
("사례관리", "자원 연계", "related_to", "필요한 자원과 서비스를 찾아 연결"),
("일차보건의료", "보건의료체계", "part_of", "보건의료체계 안에서 일차보건의료를 구현"),
("보건소", "보건의료체계", "part_of", "보건소는 지역 보건의료체계의 구성 기관"),
("보건소", "의뢰", "related_to", "필요에 따라 다른 전문 기관으로 의뢰"),
("의뢰", "자원 연계", "related_to", "요구에 맞는 서비스와 자원 연결"),
]
# (A, B, 구분 포인트)
CONTRAST_PAIRS = [
("발생률", "유병률", "새로 생긴 환자 vs 현재 가진 사람 전체"),
("민감도", "특이도", "환자를 양성으로 vs 비환자를 음성으로 판정하는 능력"),
("타당도", "신뢰도", "정확하게 재는가 vs 일관되게 재는가"),
("상대위험도", "기여위험도", "위험의 배수 vs 위험의 차이"),
("코호트 연구", "환자-대조군 연구", "원인→결과로 추적 vs 결과→원인을 거슬러 조사"),
("1차 자료", "2차 자료", "직접 수집한 자료 vs 이미 있는 자료"),
("형성평가", "총괄평가", "진행 중 개선을 위한 평가 vs 끝난 뒤 결과 판단"),
("양성예측도", "음성예측도", "양성 판정자 중 실제 환자 비율 vs 음성 판정자 중 실제 비환자 비율"),
("직접전파", "간접전파", "사람 간 직접 접촉 vs 매개물을 통한 전파"),
("능동면역", "수동면역", "스스로 항체를 만듦 vs 만들어진 항체를 받음"),
("행위별수가제", "포괄수가제", "진료 항목마다 vs 질병군별로 정해진 금액"),
("사회보험", "공공부조", "보험료 기반 vs 세금으로 저소득층 지원"),
("국민건강보험", "의료급여", "보험료 기반 건강보장 vs 저소득층 대상 공공부조"),
("구조적 지역사회", "기능적 지역사회", "지리·행정 경계로 묶임 vs 공동의 목표·기능으로 묶임"),
("공중보건", "지역사회간호", "인구집단 건강 전반 vs 간호사가 수행하는 지역사회 중심 간호"),
("일차예방", "이차예방", "발생 전 예방 vs 조기발견·조기치료"),
("이차예방", "삼차예방", "조기발견 vs 재활·악화 방지"),
("상대평가", "절대평가", "집단 내 순위 기준 vs 정해진 목표 기준"),
("고전적 조건반사이론", "조작적 조건형성이론", "자극-반응 연합 vs 행동 결과(강화)에 따른 학습"),
("기술역학", "분석역학", "누가·언제·어디서 분포 기술 vs 원인 가설 검정"),
("전향적 조사", "후향적 조사", "앞으로 추적 vs 과거 기록 조사"),
("조출생률", "일반출산율", "전체 인구 기준 vs 가임기 여성 기준"),
("영아사망률", "신생아사망률", "생후 1년 미만 vs 생후 28일 미만"),
("감수성", "면역", "감염되기 쉬운 상태 vs 감염에 저항하는 상태"),
("현성감염", "불현성감염", "증상이 나타남 vs 증상 없이 감염"),
("보균자", "환자", "증상 없이 병원체 보유 vs 증상이 있는 감염자"),
("병원체", "병원소", "질병을 일으키는 미생물 vs 병원체가 살며 증식하는 장소"),
("핵가족", "확대가족", "부부와 미혼 자녀 vs 여러 세대·친족 포함"),
("가계도", "가족밀착도", "가족 구조·세대 vs 구성원 간 친밀도"),
("외부체계도", "가족밀착도", "가족과 외부 체계의 관계 vs 가족 내부 관계"),
("허용농도", "노출기준", "작업장 유해물질 허용 한계 관련 개념"),
("산업재해", "직업병", "업무상 사고·질병 전반 vs 작업환경 노출로 생긴 질병"),
("건강증진", "질병예방", "건강 잠재력 향상 vs 특정 질병 발생 방지"),
("효과성", "효율성", "목표를 얼마나 달성했나 vs 투입 대비 산출"),
("투입", "산출", "체계로 들어가는 자원 vs 체계가 내보내는 결과"),
("개방체계", "폐쇄체계", "환경과 교류 vs 교류 없음"),
]
+150
View File
@@ -0,0 +1,150 @@
"""찾아보기(색인) 페이지에서 용어와 인쇄 페이지 번호를 추출."""
import re
import pymupdf
from .pdfutil import Line, page_lines
PAGES_ONLY = re.compile(r"^\d{1,3}(?:\s*[,~\-–]\s*\d{1,3})*$")
ENTRY = re.compile(r"^(.+?)\s*[\s_]\s*(\d{1,3}(?:\s*[,~\-–]\s*\d{1,3})*)$")
XREF = re.compile(r"^(.+?)\s*(?:→|☞|⇒|=>|참조|see)\s*(.+)$")
HEADINGS = {"찾아보기", "한글 찾아보기", "영문 찾아보기", "인물 찾아보기", "기타", "INDEX", "Index"}
JAMO = set("ㄱㄴㄷㄹㅁㅂㅅㅇㅈㅊㅋㅌㅍㅎㄲㄸㅃㅆㅉ가나다라마바사아자차카타파하")
INDEX_PAT = re.compile(r"^(.+?)\s+(\d{1,3}(?:\s*[,~\-–]\s*\d{1,3})*)$")
def find_index_pages(doc: pymupdf.Document) -> list[int]:
"""책 끝 60쪽 가운데 대부분의 줄이 '용어 페이지' 꼴인 쪽."""
pages = []
for i in range(max(0, len(doc) - 60), len(doc)):
lines = page_lines(doc, i)
merged = _merge_numbers(lines)
if len(merged) < 15:
continue
hits = sum(bool(INDEX_PAT.match(l.text)) for l in merged)
if hits / len(merged) > 0.6:
pages.append(i)
# 연속 구간만 사용 (본문 중간의 표 등 배제)
if not pages:
return []
run = [pages[-1]]
for p in reversed(pages[:-1]):
if run[0] - p <= 2:
run.insert(0, p)
else:
break
return run
def _merge_numbers(lines: list[Line]) -> list[Line]:
"""'용어'와 '페이지 번호'가 별개 줄로 나뉜 경우(같은 높이) 하나로 합침."""
out: list[Line] = []
used = set()
nums = [k for k, l in enumerate(lines) if PAGES_ONLY.match(l.text)]
for k in nums:
L = lines[k]
best = None
for j, T in enumerate(lines):
if j == k or j in nums or abs(T.y - L.y) > 3.5:
continue
gap = L.x - T.x1
if -2 <= gap < 160 and (best is None or gap < best[0]):
best = (gap, j)
if best is not None:
used.add(k)
j = best[1]
T = lines[j]
lines[j] = Line(T.page, T.x, T.y, L.x1, T.size, T.font, f"{T.text} {L.text}")
for k, l in enumerate(lines):
if k not in used:
out.append(l)
return out
def _pages(s: str) -> list[int]:
out = []
for part in re.split(r"\s*,\s*", s):
m = re.match(r"(\d+)(?:\s*[~\-–]\s*(\d+))?", part)
if m:
out.append(int(m.group(1)))
return out
def _columns(lines: list[Line]) -> list[list[Line]]:
"""x 시작점으로 단을 나눈 뒤 각 단을 위→아래로 정렬."""
xs = sorted({round(l.x) for l in lines})
cols: list[list[Line]] = []
starts: list[float] = []
for l in sorted(lines, key=lambda l: l.x):
for ci, s in enumerate(starts):
if abs(l.x - s) < 40:
cols[ci].append(l)
break
else:
starts.append(l.x)
cols.append([l])
order = sorted(range(len(cols)), key=lambda c: starts[c])
return [sorted(cols[c], key=lambda l: l.y) for c in order]
def extract_index(doc: pymupdf.Document, book: dict) -> list[dict]:
pages = list(book["index_pages"]) if book.get("index_pages") else find_index_pages(doc)
entries: list[dict] = []
section = "ko"
for pi in pages:
h = doc[pi].rect.height
lines = [l for l in page_lines(doc, pi) if 40 < l.y < h - 40 and l.size < 20]
lines = _merge_numbers(lines)
for col in _columns(lines):
pending: Line | None = None
for l in col:
t = l.text.strip()
if t in HEADINGS or t.endswith("찾아보기"):
if "인물" in t:
section = "person"
elif "영문" in t:
section = "en"
elif "한글" in t:
section = "ko"
pending = None
continue
if len(t) == 1 and (t in JAMO or t.isascii() and t.isalpha()):
if section != "person":
section = "en" if t.isascii() else "ko"
pending = None
continue
if t == "●" or t.isdigit() and int(t) > 400:
continue
m = ENTRY.match(t)
if not m:
x = XREF.match(t)
if x:
entries.append({"term": x.group(1).strip(), "see": x.group(2).strip(),
"pages": [], "section": section, "index_page": pi})
pending = None
continue
# 다음 줄로 이어지는 긴 용어
if pending is not None and abs(l.y - pending.y) < 20:
pending = Line(l.page, pending.x, l.y, l.x1, l.size, l.font, pending.text + " " + t)
else:
pending = l
continue
term = m.group(1).strip()
if pending is not None and abs(l.y - pending.y) < 20:
term = pending.text + " " + term
pending = None
term = re.sub(r"\s*●\s*", "", term).strip(" ,")
if not term or term.isdigit():
continue
# 영문 색인에서 두 항목이 한 줄로 붙은 경우: "a 20, 101 b" → a / b
split = re.match(r"^(.+?) (\d{1,3}(?:, ?\d{1,3})*) ([a-z].+)$", term) if section == "en" else None
if split:
entries.append({"term": split.group(1), "pages": _pages(split.group(2)),
"section": section, "index_page": pi})
term = split.group(3)
sec = section
if sec == "person" and not re.search(r"[가-힣]", term):
sec = "en" # 인물 찾아보기 뒤에 이어진 영문 색인
entries.append({"term": term, "pages": _pages(m.group(2)), "section": sec,
"index_page": pi})
return entries
+82
View File
@@ -0,0 +1,82 @@
"""PDF 텍스트 줄 단위 추출 도우미."""
import re
import unicodedata
from dataclasses import dataclass
import pymupdf
@dataclass
class Line:
page: int
x: float
y: float
x1: float
size: float
font: str
text: str
def clean(s: str) -> str:
s = unicodedata.normalize("NFC", s)
s = "".join(c for c in s if c in "\n\t" or unicodedata.category(c)[0] != "C")
s = s.replace(" ", " ").replace(" ", " ").replace(" ", " ")
s = s.replace("ᝍ", "-").replace("․", "·")
return re.sub(r"\s+", " ", s).strip()
def page_lines(doc: pymupdf.Document, i: int) -> list[Line]:
out = []
for b in doc[i].get_text("dict")["blocks"]:
for l in b.get("lines", []):
spans = l["spans"]
if not spans:
continue
text = clean("".join(s["text"] for s in spans))
if not text:
continue
x0, y0, x1, _ = l["bbox"]
out.append(Line(i, x0, y0, x1, spans[0]["size"], spans[0]["font"], text))
return out
def print_label(doc: pymupdf.Document, i: int) -> str:
return doc[i].get_label() or str(i + 1)
def label_to_index(doc: pymupdf.Document) -> dict[str, int]:
"""인쇄 페이지 번호(라벨) → PDF 파일상 0-based 페이지. 아라비아 숫자 라벨만."""
m = {}
for i in range(len(doc)):
lab = doc[i].get_label() or str(i + 1)
if lab.isdigit() and lab not in m:
m[lab] = i
return m
def body_text(doc: pymupdf.Document, i: int) -> str:
"""머리말·꼬리말을 뺀 본문 텍스트. 줄바꿈으로 끊긴 단어를 이어 붙임."""
h = doc[i].rect.height
parts = []
for b in doc[i].get_text("dict")["blocks"]:
buf = ""
for l in b.get("lines", []):
y = l["bbox"][1]
if y < 50 or y > h - 45:
continue
raw = unicodedata.normalize("NFC", "".join(s["text"] for s in l["spans"]))
if not raw.strip():
continue
if buf and not buf.endswith(" ") and not raw.startswith(" "):
# 한글끼리 줄이 바뀌면 단어 중간에서 끊긴 것으로 보고 붙인다
if not (_is_hangul(buf[-1]) and _is_hangul(raw[0])):
buf += " "
buf += raw
t = clean(buf)
if t:
parts.append(t)
return "\n".join(parts)
def _is_hangul(c: str) -> bool:
return "가" <= c <= "힣"
+139
View File
@@ -0,0 +1,139 @@
"""목차 페이지에서 장·절·항 트리를 추출."""
import hashlib
import re
import pymupdf
from .pdfutil import page_lines
LEADER = re.compile(r"(?:j{3,}|[·․\.…]{3,}|·\s?·)[\sj·․\.…]*")
TRAIL_PAGE = re.compile(r"(?:_|\s|^)(\d{1,3})$")
ROMAN = "ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ"
LEVELS = {"chapter": 1, "part": 2, "section": 3, "item": 4, "subitem": 5}
def _split_page(text: str):
t = LEADER.sub(" ", text).strip()
m = TRAIL_PAGE.search(t)
if m and (m.start() > 0 or t.startswith("_")):
return t[: m.start()].strip(" _"), int(m.group(1))
return t, None
def _classify(style: str, text: str, size: float):
"""(kind, number, title) 또는 None."""
if style == "cn2026":
for kind, pat in (
("chapter", r"^(\d+)장\s*(.+)$"),
("section", r"^(\d+)절\s*(.+)$"),
("item", r"^(\d+)\.\s*(.+)$"),
("subitem", r"^(\d+)\)\s*(.+)$"),
):
m = re.match(pat, text)
if m:
return kind, m.group(1), m.group(2).strip()
return None
if style == "pacific":
m = re.match(r"^제(\d+)장\s*(.*)$", text)
if m:
return "chapter", m.group(1), m.group(2).strip()
m = re.match(rf"^([{ROMAN}])\.?\s*(.*)$", text)
if m:
return "part", str(ROMAN.index(m.group(1)) + 1), m.group(2).strip()
for kind, pat in (
("section", r"^제(\d+)절\s*(.+)$"),
("item", r"^(\d+)\.\s*(.+)$"),
("subitem", r"^(\d+)\)\s*(.+)$"),
):
m = re.match(pat, text)
if m:
return kind, m.group(1), m.group(2).strip()
return None
if style == "healthedu":
m = re.match(r"^(\d+)\.\s*(.+)$", text)
if m and size >= 8.7:
return "chapter", m.group(1), re.sub(r"(?<=[가-힣]) (?=[가-힣]$)", "", m.group(2).strip())
m = re.match(r"^\((\d+)\)\s*(.+)$", text)
if m:
return "subitem", m.group(1), m.group(2).strip()
m = re.match(r"^(\d+)\s+(.+)$", text)
if m and size < 8.2:
return "section", m.group(1), m.group(2).strip()
return None
raise ValueError(style)
def extract_toc(doc: pymupdf.Document, book: dict) -> list[dict]:
style = book["toc_style"]
nodes: list[dict] = []
stack: list[dict] = []
pending_title_for = None # 제목이 다음 줄에 오는 장/부
seq = 0
for pi in book["toc_pages"]:
for ln in page_lines(doc, pi):
if ln.size >= 16 or ln.y < 60: # 쪽 제목·머리말
continue
text, page = _split_page(ln.text)
if not text:
continue
if text in ("Contents", "목차", "w"):
continue
c = _classify(style, text, ln.size)
if c is None:
if pending_title_for is not None and not pending_title_for["title"]:
pending_title_for["title"] = text
if page is not None and pending_title_for["page"] is None:
pending_title_for["page"] = page
pending_title_for = None
elif nodes and nodes[-1]["page"] is None:
nodes[-1]["title"] = (nodes[-1]["title"] + " " + text).strip()
if page is not None:
nodes[-1]["page"] = page
continue
kind, num, title = c
level = LEVELS[kind]
while stack and stack[-1]["level"] >= level:
stack.pop()
seq += 1
node = {
# 공개 화면 주소에 교재가 드러나지 않도록 해시 ID
"id": "s_" + hashlib.sha1(f"{book['book_id']}|{seq}".encode()).hexdigest()[:10],
"book_id": book["book_id"],
"kind": kind,
"level": level,
"number": num,
"title": title,
"page": page,
"parent_id": stack[-1]["id"] if stack else None,
}
# 같은 장이 목차 여러 쪽에 걸쳐 반복 표기된 경우 건너뜀
same = [n for n in nodes if n["kind"] == "chapter" and n["number"] == num
and (not title or not n["title"] or n["title"] == title)]
if kind == "chapter" and same:
seq -= 1
stack = [same[0]]
continue
nodes.append(node)
stack.append(node)
pending_title_for = node if not title else None
_fill_pages(nodes)
return nodes
def _fill_pages(nodes: list[dict]) -> None:
"""페이지가 없는 노드는 첫 자식의 페이지를 쓰고, 끝 페이지를 계산."""
by_parent: dict = {}
for n in nodes:
by_parent.setdefault(n["parent_id"], []).append(n)
for n in reversed(nodes):
if n["page"] is None:
kids = [k for k in by_parent.get(n["id"], []) if k["page"] is not None]
if kids:
n["page"] = kids[0]["page"]
# 페이지 역행(목차 파싱 오류)은 직전 값으로 보정
last = 0
for n in nodes:
if n["page"] is None or n["page"] < last - 50:
n["page"] = last
last = max(last, n["page"])
+3
View File
@@ -0,0 +1,3 @@
# 컨테이너(웹서버) 실행에 필요한 것만. PDF 추출(pymupdf)은 개발 PC에서만 사용.
fastapi>=0.110
uvicorn[standard]>=0.29
+6
View File
@@ -0,0 +1,6 @@
fastapi>=0.110
uvicorn[standard]>=0.29
pymupdf>=1.24
# 테스트
httpx
pytest
View File
+20
View File
@@ -0,0 +1,20 @@
"""python -m server [--host 0.0.0.0] [--port 8000] [--reload]"""
import argparse
import os
import uvicorn
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8000)
ap.add_argument("--reload", action="store_true")
a = ap.parse_args()
# 리버스 프록시(시놀로지 등) 뒤: X-Forwarded-For/Proto 로 실제 방문자 IP·https 여부를 인식
uvicorn.run("server.app:app", host=a.host, port=a.port, reload=a.reload, proxy_headers=True,
forwarded_allow_ips=os.environ.get("ATLAS_FORWARDED_ALLOW_IPS", "*"))
if __name__ == "__main__":
main()
+1068
View File
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
"""인증.
- 교수자(owner): admins 테이블의 아이디·비밀번호 계정. 용어·관계·문제 수정, 회원·반 관리, 설정.
처음 실행할 때 아이디 ATLAS_OWNER_USERNAME(기본 professor), 비밀번호 ATLAS_OWNER_PASSWORD 또는
data/.owner_password 로 계정을 만든다. 이후 비밀번호는 DB에 해시로만 저장된다.
- 회원(member): 학생 계정. 교수자가 승인하면 사례·문제를 볼 수 있다. 학생·방문자는 오류 신고만 할 수 있다.
모두 서명된 쿠키 세션을 쓴다.
"""
import base64
import hashlib
import hmac
import os
import secrets
import time
from fastapi import HTTPException, Request
from .db import DB_PATH
DATA = DB_PATH.parent
COOKIE = "atlas_admin"
MEMBER_COOKIE = "atlas_member"
TTL = 60 * 60 * 24 * 14
def _read_or_create(path, factory):
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
return path.read_text().strip()
v = factory()
path.write_text(v)
try:
os.chmod(path, 0o600)
except OSError:
pass
return v
def secret() -> bytes:
return _read_or_create(DATA / ".secret", lambda: secrets.token_hex(32)).encode()
def owner_password() -> str:
env = os.environ.get("ATLAS_OWNER_PASSWORD")
if env:
return env
return _read_or_create(DATA / ".owner_password", lambda: secrets.token_urlsafe(12))
# ---------------------------------------------------------------- 서명 토큰
def sign(payload: str) -> str:
raw = payload.encode()
sig = hmac.new(secret(), raw, hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(raw).decode() + "." + sig
def unsign(token: str | None) -> str | None:
if not token or "." not in token:
return None
b64, sig = token.rsplit(".", 1)
try:
raw = base64.urlsafe_b64decode(b64.encode())
except Exception:
return None
if not hmac.compare_digest(hmac.new(secret(), raw, hashlib.sha256).hexdigest(), sig):
return None
return raw.decode()
def make_token(name: str, role: str = "owner") -> str:
return sign(f"{name}|{role}|{int(time.time()) + TTL}")
def read_session(token: str | None) -> tuple[str, str] | None:
"""(이름, 역할) 또는 None. 예전 형식(이름|만료)은 검수자로 본다."""
p = unsign(token)
if not p:
return None
parts = p.rsplit("|", 2)
if len(parts) == 3:
name, role, exp = parts
else:
name, exp = p.rsplit("|", 1)
role = "reviewer"
if not exp.isdigit() or int(exp) < time.time():
return None
return name, role
def read_token(token: str | None) -> str | None:
s = read_session(token)
return s[0] if s else None
def ensure_owner_account(con) -> dict | None:
"""교수자 계정이 없으면 만든다. 새로 만들었으면 {username, password} 를 돌려준다."""
if con.execute("SELECT 1 FROM admins").fetchone():
return None
username = os.environ.get("ATLAS_OWNER_USERNAME", "professor")
pw = owner_password()
with con:
con.execute("INSERT INTO admins(username,password_hash,name,created_at) VALUES(?,?,?,?)",
(username, hash_password(pw), "교수자", time.strftime("%Y-%m-%dT%H:%M:%S")))
# 평문 비밀번호 파일은 계정을 만든 뒤 지운다 (이후엔 DB의 해시만 사용)
f = DATA / ".owner_password"
if not os.environ.get("ATLAS_OWNER_PASSWORD") and f.exists():
f.unlink()
return {"username": username, "password": pw}
def check_login(con, username: str, pw: str) -> dict | None:
"""교수자 아이디·비밀번호가 맞으면 계정 행"""
r = con.execute("SELECT * FROM admins WHERE username=?", (username.strip(),)).fetchone()
if r and verify_password(pw, r["password_hash"]):
return dict(r)
return None
def current_admin(request: Request) -> str | None:
"""교수자로 로그인했으면 이름 (예전 검수자 세션은 인정하지 않음)."""
s = read_session(request.cookies.get(COOKIE))
return s[0] if s and s[1] == "owner" else None
def current_role(request: Request) -> str | None:
s = read_session(request.cookies.get(COOKIE))
return s[1] if s else None
def require_admin(request: Request) -> str:
name = current_admin(request)
if not name:
raise HTTPException(401, "교수자 로그인이 필요합니다.")
return name
def require_owner(request: Request) -> str:
s = read_session(request.cookies.get(COOKIE))
if not s:
raise HTTPException(401, "관리자 로그인이 필요합니다.")
if s[1] != "owner":
raise HTTPException(401, "교수자 로그인이 필요합니다.")
return s[0]
# ---------------------------------------------------------------- 회원 비밀번호·세션
def hash_password(pw: str) -> str:
salt = os.urandom(16)
h = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, 200_000)
return f"pbkdf2${salt.hex()}${h.hex()}"
def verify_password(pw: str, stored: str | None) -> bool:
try:
_, salt, h = (stored or "").split("$")
except ValueError:
return False
calc = hashlib.pbkdf2_hmac("sha256", pw.encode(), bytes.fromhex(salt), 200_000).hex()
return hmac.compare_digest(calc, h)
def make_member_token(user_id: int) -> str:
return sign(f"m|{user_id}|{int(time.time()) + TTL}")
def member_id(request: Request) -> int | None:
p = unsign(request.cookies.get(MEMBER_COOKIE))
if not p:
return None
try:
tag, uid, exp = p.split("|")
except ValueError:
return None
if tag != "m" or int(exp) < time.time():
return None
return int(uid)
+100
View File
@@ -0,0 +1,100 @@
"""피드백·오류를 터미널에서 확인하고 처리하는 도구.
.venv/bin/python -m server.cli feedback # 처리 대기(new, in_progress) 목록
.venv/bin/python -m server.cli feedback --all # 전체
.venv/bin/python -m server.cli show 12 # 한 건 자세히 (대상 용어 현재 값 포함)
.venv/bin/python -m server.cli resolve 12 "정의 문장을 교재 p.188 기준으로 수정"
.venv/bin/python -m server.cli status 12 in_progress
.venv/bin/python -m server.cli term 지역사회 간호사정 # 용어 현재 값 확인
"""
import argparse
import json
from . import db
def _print_fb(r, full=False):
tgt = f" [{r['target_type']}:{r['target_label'] or r['target_id']}]" if r["target_type"] else ""
occ = f" ×{r['occurrences']}" if (r["occurrences"] or 1) > 1 else ""
print(f"#{r['id']:<4} {r['status']:<11} {r['kind']:<12} {r['category'] or ''}{tgt}{occ} {r['created_at']}")
msg = r["message"] or ""
print(" " + (msg if full else msg[:160].replace("\n", " ")))
if r["suggestion"]:
print(" → 제안: " + (r["suggestion"] if full else r["suggestion"][:160]))
if full:
for k in ("page_url", "reporter", "contact", "admin_note", "resolution", "handled_by", "user_agent"):
if r[k]:
print(f" {k}: {r[k]}")
if r["context"]:
print(" context:", r["context"][:3000])
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
f = sub.add_parser("feedback")
f.add_argument("--all", action="store_true")
f.add_argument("--kind")
s = sub.add_parser("show")
s.add_argument("id", type=int)
r = sub.add_parser("resolve")
r.add_argument("id", type=int)
r.add_argument("resolution")
st = sub.add_parser("status")
st.add_argument("id", type=int)
st.add_argument("status", choices=["new", "in_progress", "resolved", "rejected", "duplicate"])
t = sub.add_parser("term")
t.add_argument("name", nargs="+")
a = ap.parse_args()
con = db.connect()
if a.cmd == "feedback":
where = "1=1" if a.all else "status IN ('new','in_progress')"
args = []
if a.kind:
where += " AND kind=?"
args.append(a.kind)
rows = con.execute(f"SELECT * FROM feedback WHERE {where} ORDER BY id", args).fetchall()
print(f"{len(rows)}건")
for r in rows:
_print_fb(r)
elif a.cmd == "show":
r = con.execute("SELECT * FROM feedback WHERE id=?", (a.id,)).fetchone()
if not r:
return print("없음")
_print_fb(r, full=True)
if r["target_type"] == "term" and r["target_id"]:
_show_term(con, r["target_id"])
elif a.cmd in ("resolve", "status"):
status = "resolved" if a.cmd == "resolve" else a.status
with con:
if a.cmd == "resolve":
con.execute("UPDATE feedback SET status=?, resolution=?, handled_by='developer', updated_at=? WHERE id=?",
(status, a.resolution, db.now(), a.id))
else:
con.execute("UPDATE feedback SET status=?, handled_by='developer', updated_at=? WHERE id=?", (status, db.now(), a.id))
db.audit(con, "developer(cli)", "update", "feedback", str(a.id), None, {"status": status})
print("완료")
elif a.cmd == "term":
name = " ".join(a.name)
r = con.execute("SELECT term_id FROM terms WHERE name=? OR term_id=?", (name, name)).fetchone()
if not r:
rows = con.execute("SELECT term_id,name FROM terms WHERE name LIKE ? LIMIT 20", (f"%{name}%",)).fetchall()
for x in rows:
print(x["term_id"], x["name"])
return
_show_term(con, r["term_id"])
def _show_term(con, term_id):
t = db.row(con.execute("SELECT * FROM terms WHERE term_id=?", (term_id,)).fetchone())
print("\n--- 대상 용어 현재 값")
print(json.dumps({k: t[k] for k in ("term_id", "name", "aliases", "galaxy_id", "section_id", "definition_text",
"definition_book", "definition_page", "easy", "example", "review_status")},
ensure_ascii=False, indent=1))
for s in con.execute("SELECT book_id,page,path,snippet FROM term_sources WHERE term_id=?", (term_id,)):
print(f" 출처 {s['book_id']} p.{s['page']} {s['path']}\n {(s['snippet'] or '')[:150]}")
if __name__ == "__main__":
main()
+224
View File
@@ -0,0 +1,224 @@
"""SQLite 저장소. 스키마, 연결, JSON 컬럼 도우미."""
import json
import os
import sqlite3
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DB_PATH = Path(os.environ.get("ATLAS_DB", ROOT / "data" / "atlas.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS books (
book_id TEXT PRIMARY KEY, title TEXT, short TEXT, edition TEXT, year INTEGER,
is_primary INTEGER, filename TEXT
);
CREATE TABLE IF NOT EXISTS galaxies (
galaxy_id TEXT PRIMARY KEY, name TEXT, sub TEXT, color TEXT, ord INTEGER, chapter_no INTEGER,
edited INTEGER DEFAULT 0, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS toc (
toc_id TEXT PRIMARY KEY, book_id TEXT, kind TEXT, level INTEGER, number TEXT, title TEXT,
page INTEGER, parent_id TEXT, galaxy_id TEXT, easy_sub TEXT, edited INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS terms (
term_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT DEFAULT 'concept',
lang TEXT DEFAULT 'ko',
aliases TEXT DEFAULT '[]',
search_terms TEXT DEFAULT '[]',
galaxy_id TEXT,
section_id TEXT,
section_guess INTEGER DEFAULT 0,
item_id TEXT,
section_ids TEXT DEFAULT '[]',
definition_text TEXT, definition_book TEXT, definition_page INTEGER, definition_method TEXT,
easy TEXT, example TEXT, easy_method TEXT,
review_status TEXT DEFAULT 'draft',
hidden INTEGER DEFAULT 0,
origin TEXT DEFAULT 'auto',
edited INTEGER DEFAULT 0,
reviewer TEXT, note TEXT,
created_at TEXT, updated_at TEXT
);
CREATE INDEX IF NOT EXISTS terms_galaxy ON terms(galaxy_id);
CREATE INDEX IF NOT EXISTS terms_section ON terms(section_id);
CREATE TABLE IF NOT EXISTS term_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
term_id TEXT NOT NULL, book_id TEXT, page INTEGER, pdf_page INTEGER,
toc_id TEXT, section_id TEXT, galaxy_id TEXT, path TEXT, snippet TEXT,
origin TEXT DEFAULT 'auto'
);
CREATE INDEX IF NOT EXISTS sources_term ON term_sources(term_id);
CREATE TABLE IF NOT EXISTS relations (
relation_id TEXT PRIMARY KEY,
source TEXT NOT NULL, target TEXT NOT NULL,
type TEXT NOT NULL, label TEXT, reason TEXT,
directed INTEGER DEFAULT 1,
method TEXT, evidence TEXT DEFAULT '[]',
review_status TEXT DEFAULT 'draft',
hidden INTEGER DEFAULT 0,
edited INTEGER DEFAULT 0,
reviewer TEXT, created_at TEXT, updated_at TEXT
);
CREATE INDEX IF NOT EXISTS rel_source ON relations(source);
CREATE INDEX IF NOT EXISTS rel_target ON relations(target);
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT, updated_at TEXT,
kind TEXT, -- content | bug | search | suggestion | client_error
category TEXT,
target_type TEXT, target_id TEXT, target_label TEXT,
message TEXT, suggestion TEXT,
page_url TEXT, user_agent TEXT, context TEXT,
reporter TEXT, contact TEXT,
status TEXT DEFAULT 'new', -- new | in_progress | resolved | rejected | duplicate
admin_note TEXT, resolution TEXT, handled_by TEXT,
occurrences INTEGER DEFAULT 1, fingerprint TEXT
);
CREATE INDEX IF NOT EXISTS feedback_status ON feedback(status);
CREATE INDEX IF NOT EXISTS feedback_fp ON feedback(fingerprint);
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT, actor TEXT, action TEXT, entity TEXT, entity_id TEXT, before TEXT, after TEXT
);
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS admins (
admin_id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL, name TEXT,
created_at TEXT, last_login TEXT
);
CREATE TABLE IF NOT EXISTS classes (
class_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, note TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL, student_no TEXT, note TEXT,
status TEXT DEFAULT 'pending', -- pending | approved | rejected | disabled
class_id INTEGER,
created_at TEXT, approved_at TEXT, approved_by TEXT, last_login TEXT
);
CREATE INDEX IF NOT EXISTS users_status ON users(status);
CREATE TABLE IF NOT EXISTS signup_codes (
code_id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE COLLATE NOCASE,
class_id INTEGER, note TEXT,
active INTEGER DEFAULT 1, auto_approve INTEGER DEFAULT 0,
max_uses INTEGER, uses INTEGER DEFAULT 0, expires_at TEXT,
created_by TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS questions (
question_id INTEGER PRIMARY KEY AUTOINCREMENT,
term_id TEXT, galaxy_id TEXT,
qtype TEXT DEFAULT 'mcq', -- mcq | ox
prompt TEXT NOT NULL, choices TEXT DEFAULT '[]', answer INTEGER NOT NULL,
explanation TEXT,
review_status TEXT DEFAULT 'draft', hidden INTEGER DEFAULT 0,
created_by TEXT, created_at TEXT, updated_at TEXT
);
CREATE INDEX IF NOT EXISTS questions_term ON questions(term_id);
CREATE TABLE IF NOT EXISTS attempts (
attempt_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, class_id INTEGER,
qkey TEXT, term_id TEXT, galaxy_id TEXT, qtype TEXT,
choice INTEGER, correct INTEGER, created_at TEXT
);
CREATE INDEX IF NOT EXISTS attempts_user ON attempts(user_id);
CREATE INDEX IF NOT EXISTS attempts_class ON attempts(class_id);
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
"""
DEFAULT_SETTINGS = {
# 학생 화면에 보여줄 검수 상태
"public_statuses": ["draft", "review", "confirmed"],
# 학생 화면에 '검수 전' 표시
"show_status_badges": True,
}
JSON_COLS = {"aliases", "search_terms", "section_ids", "evidence", "choices"}
_local = threading.local()
def now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%S")
def connect(path: Path | None = None) -> sqlite3.Connection:
p = Path(path or DB_PATH)
p.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(p, check_same_thread=False, timeout=15)
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA foreign_keys=ON")
con.executescript(SCHEMA)
_migrate(con)
return con
def _migrate(con):
"""기존 DB에 새 컬럼 추가."""
cols = {r[1] for r in con.execute("PRAGMA table_info(users)")}
if "signup_code_id" not in cols:
con.execute("ALTER TABLE users ADD COLUMN signup_code_id INTEGER")
con.commit()
def get() -> sqlite3.Connection:
con = getattr(_local, "con", None)
if con is None or getattr(_local, "path", None) != str(DB_PATH):
con = _local.con = connect()
_local.path = str(DB_PATH)
return con
def row(r: sqlite3.Row | None) -> dict | None:
if r is None:
return None
d = dict(r)
for k in JSON_COLS & d.keys():
try:
d[k] = json.loads(d[k]) if d[k] else []
except (TypeError, json.JSONDecodeError):
d[k] = []
return d
def rows(rs) -> list[dict]:
return [row(r) for r in rs]
def dumps(v) -> str:
return json.dumps(v, ensure_ascii=False)
def get_setting(con, key):
r = con.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
if r is None:
return DEFAULT_SETTINGS.get(key)
return json.loads(r["value"])
def all_settings(con) -> dict:
out = dict(DEFAULT_SETTINGS)
for r in con.execute("SELECT key, value FROM settings"):
out[r["key"]] = json.loads(r["value"])
return out
def set_setting(con, key, value):
con.execute("INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, dumps(value)))
def audit(con, actor, action, entity, entity_id, before=None, after=None):
con.execute(
"INSERT INTO audit(at, actor, action, entity, entity_id, before, after) VALUES(?,?,?,?,?,?,?)",
(now(), actor, action, entity, entity_id,
dumps(before) if before is not None else None, dumps(after) if after is not None else None),
)
+673
View File
@@ -0,0 +1,673 @@
"""회원(학생)·반·문제.
- 누구나: 지도·검색·쉬운 뜻·관계
- 승인된 회원(또는 관리자): 사례, 문제 풀기
- 교수자만: 가입 코드·회원·반 관리, 직접 출제한 문제 관리
- 학생은 교수자가 수업에서 알려 준 가입 코드를 넣으면 바로 회원이 된다 (코드에 지정된 반으로 배정)
문제는 두 종류다.
1) 자동 객관식: 쉬운 뜻 → 용어 고르기 / 용어 → 뜻 고르기 (쉬운 설명이 있는 용어로 즉석 생성)
2) 직접 출제: questions 테이블 (객관식·OX)
정답은 서버만 안다. 문제마다 서명된 토큰을 주고, 답을 내면 서버가 채점·기록한다.
"""
import json
import random
import re
import time
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
from . import auth, db
router = APIRouter()
PUBLIC_TERM_STATUSES = ("draft", "review", "confirmed")
MIN_EASY = 12
def _rate_limit(request: Request, limit=20, window=600):
from .app import _rate_limit as rl
rl(request, limit=limit, window=window)
# ---------------------------------------------------------------- 회원 상태
def current_member(con, request: Request) -> dict | None:
uid = auth.member_id(request)
if not uid:
return None
r = con.execute(
"SELECT u.*, c.name AS class_name FROM users u LEFT JOIN classes c ON c.class_id=u.class_id WHERE u.user_id=?",
(uid,)).fetchone()
return dict(r) if r else None
def can_view_members_content(con, request: Request) -> bool:
"""사례·문제를 볼 수 있는가: 승인된 회원 또는 관리자(소유자·검수자)."""
if auth.current_admin(request):
return True
m = current_member(con, request)
return bool(m and m["status"] == "approved")
def require_member_content(request: Request):
con = db.get()
if not can_view_members_content(con, request):
m = current_member(con, request)
if m and m["status"] == "pending":
raise HTTPException(403, "가입 승인을 기다리는 중입니다.")
raise HTTPException(401, "회원 전용입니다. 로그인해 주세요.")
return con
# ---------------------------------------------------------------- 가입·로그인
USERNAME = re.compile(r"^[A-Za-z0-9_.\-]{3,30}$")
class SignupIn(BaseModel):
code: str = Field(..., min_length=1, max_length=40)
username: str = Field(..., min_length=3, max_length=30)
password: str = Field(..., min_length=6, max_length=100)
name: str = Field(..., min_length=1, max_length=40)
student_no: str | None = Field(None, max_length=30)
note: str | None = Field(None, max_length=300)
website: str | None = None # 스팸 방지
@router.post("/api/members/signup")
def signup(body: SignupIn, request: Request):
_rate_limit(request, limit=10, window=600)
if body.website:
return {"ok": True, "status": "pending"}
if not USERNAME.match(body.username):
raise HTTPException(400, "아이디는 영문·숫자·_ . - 로 3~30자입니다.")
con = db.get()
code = _valid_code(con, body.code)
if con.execute("SELECT 1 FROM users WHERE username=?", (body.username,)).fetchone() or \
con.execute("SELECT 1 FROM admins WHERE username=?", (body.username,)).fetchone():
raise HTTPException(409, "이미 사용 중인 아이디입니다.")
status = "approved" # 오프라인에서 받은 코드가 곧 인증
with con:
con.execute(
"INSERT INTO users(username,password_hash,name,student_no,note,status,class_id,signup_code_id,created_at,approved_at,approved_by)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(body.username, auth.hash_password(body.password), body.name.strip(), (body.student_no or "").strip() or None,
(body.note or "").strip() or None, status, code["class_id"], code["code_id"], db.now(),
db.now(), f"가입 코드 {code['code']}"))
con.execute("UPDATE signup_codes SET uses=uses+1 WHERE code_id=?", (code["code_id"],))
return {"ok": True, "status": status}
def _valid_code(con, raw: str):
c = con.execute("SELECT * FROM signup_codes WHERE code=?", (raw.strip().upper(),)).fetchone()
if not c or not c["active"]:
raise HTTPException(400, "가입 코드가 맞지 않습니다. 교수자에게 받은 코드를 확인해 주세요.")
if c["expires_at"] and c["expires_at"] < db.now()[:10]:
raise HTTPException(400, "기한이 지난 가입 코드입니다. 교수자에게 새 코드를 받아 주세요.")
if c["max_uses"] and c["uses"] >= c["max_uses"]:
raise HTTPException(400, "사용 횟수가 다 된 가입 코드입니다. 교수자에게 문의해 주세요.")
return c
class LoginIn(BaseModel):
username: str
password: str
@router.post("/api/members/login")
def login(body: LoginIn, request: Request, response: Response):
_rate_limit(request, limit=15, window=300)
con = db.get()
r = con.execute("SELECT * FROM users WHERE username=?", (body.username.strip(),)).fetchone()
if not r or not auth.verify_password(body.password, r["password_hash"]):
raise HTTPException(401, "아이디 또는 비밀번호가 맞지 않습니다.")
if r["status"] in ("rejected", "disabled"):
raise HTTPException(403, "사용할 수 없는 계정입니다. 담당 교수자에게 문의해 주세요.")
with con:
con.execute("UPDATE users SET last_login=? WHERE user_id=?", (db.now(), r["user_id"]))
response.set_cookie(auth.MEMBER_COOKIE, auth.make_member_token(r["user_id"]), max_age=auth.TTL, httponly=True,
samesite="lax", secure=request.url.scheme == "https")
return {"ok": True, "status": r["status"], "name": r["name"]}
@router.post("/api/members/logout")
def logout(response: Response):
response.delete_cookie(auth.MEMBER_COOKIE)
return {"ok": True}
@router.get("/api/members/me")
def me(request: Request):
con = db.get()
m = current_member(con, request)
admin = auth.current_admin(request)
out = {"logged_in": bool(m), "admin": admin, "role": auth.current_role(request),
"can_view": can_view_members_content(con, request)}
if m:
st = con.execute("SELECT COUNT(*), COALESCE(SUM(correct),0) FROM attempts WHERE user_id=?", (m["user_id"],)).fetchone()
out.update(username=m["username"], name=m["name"], status=m["status"], class_name=m["class_name"],
attempts=st[0], correct=st[1])
return out
@router.get("/api/members/my-stats")
def my_stats(request: Request):
con = db.get()
m = current_member(con, request)
if not m:
raise HTTPException(401, "로그인이 필요합니다.")
by_g = [dict(r) for r in con.execute(
"SELECT g.galaxy_id, g.name, COUNT(a.attempt_id) AS attempts, COALESCE(SUM(a.correct),0) AS correct"
" FROM attempts a JOIN galaxies g ON g.galaxy_id=a.galaxy_id WHERE a.user_id=? GROUP BY g.galaxy_id ORDER BY g.ord",
(m["user_id"],))]
wrong = [dict(r) for r in con.execute(
"SELECT t.term_id, t.name, COUNT(*) AS wrong FROM attempts a JOIN terms t ON t.term_id=a.term_id"
" WHERE a.user_id=? AND a.correct=0 GROUP BY t.term_id ORDER BY wrong DESC, MAX(a.attempt_id) DESC LIMIT 15",
(m["user_id"],))]
return {"by_galaxy": by_g, "wrong_terms": wrong}
# ---------------------------------------------------------------- 문제 만들기
def _pool(con):
"""쉬운 설명이 있는 공개 개념 용어."""
return [dict(r) for r in con.execute(
"SELECT term_id, name, easy, galaxy_id, section_id FROM terms WHERE hidden=0 AND kind='concept'"
" AND easy IS NOT NULL AND length(easy)>=? AND review_status IN ('draft','review','confirmed')", (MIN_EASY,))]
def _mask(text: str, name: str, repl="○○") -> str:
pat = re.escape(name).replace(r"\ ", r"\s?")
return re.sub(pat, repl, text)
def _distractors(pool, t, k=3, rng=random):
same_sec = [p for p in pool if p["section_id"] == t["section_id"] and p["term_id"] != t["term_id"]]
same_gal = [p for p in pool if p["galaxy_id"] == t["galaxy_id"] and p["term_id"] != t["term_id"] and p not in same_sec]
others = [p for p in pool if p["galaxy_id"] != t["galaxy_id"]]
out = []
for group in (same_sec, same_gal, others):
rng.shuffle(group)
for p in group:
if len(out) >= k:
return out
# 이름이 서로 포함되면(예: 건강증진/건강증진사업) 헷갈리는 오답이 아니라 함정이 되므로 제외
if p["name"] in t["name"] or t["name"] in p["name"]:
continue
if p["easy"] == t["easy"]:
continue
out.append(p)
return out
def _token(data: dict) -> str:
return auth.sign(json.dumps(data, ensure_ascii=False, separators=(",", ":")))
def auto_question(pool, t, qtype, rng=random):
ds = _distractors(pool, t, 3, rng)
if len(ds) < 3:
return None
opts = [t] + ds
rng.shuffle(opts)
ans = next(i for i, o in enumerate(opts) if o["term_id"] == t["term_id"])
if qtype == "m2t":
prompt = "다음 설명에 해당하는 용어는 무엇일까요?"
quote = _mask(t["easy"], t["name"])
choices = [o["name"] for o in opts]
else:
prompt = f"‘{t['name']}’의 뜻으로 알맞은 것은 무엇일까요?"
quote = None
choices = [_mask(o["easy"], o["name"], "이것") for o in opts]
token = _token({"k": "auto", "t": t["term_id"], "g": t["galaxy_id"], "ty": qtype, "a": ans,
"ids": [o["term_id"] for o in opts], "iat": int(time.time())})
return {"token": token, "qtype": qtype, "prompt": prompt, "quote": quote, "choices": choices,
"term_id": t["term_id"], "origin": "auto"}
def manual_question(q: dict):
token = _token({"k": "q", "id": q["question_id"], "iat": int(time.time())})
choices = q["choices"] if q["qtype"] == "mcq" else ["O", "X"]
return {"token": token, "qtype": q["qtype"], "prompt": q["prompt"], "quote": None, "choices": choices,
"term_id": q["term_id"], "origin": "manual"}
def _manual_for(con, where, args):
return db.rows(con.execute(
f"SELECT * FROM questions WHERE hidden=0 AND review_status IN ('review','confirmed') AND {where}", args))
@router.get("/api/members/questions")
def term_questions(term: str, request: Request):
"""용어 상세의 확인 문제: 직접 출제 문제 + 자동 2문제."""
con = require_member_content(request)
out = [manual_question(q) for q in _manual_for(con, "term_id=?", (term,))]
pool = _pool(con)
t = next((p for p in pool if p["term_id"] == term), None)
if t:
for qt in ("m2t", "t2m"):
q = auto_question(pool, t, qt)
if q:
out.append(q)
return {"questions": out}
@router.get("/api/members/quiz")
def quiz(request: Request, galaxy: str | None = None, n: int = Query(10, ge=1, le=30), wrong_only: bool = False):
"""문제 풀기: 은하(단원)를 골라 n문제. wrong_only=1 이면 내가 틀린 용어 위주."""
con = require_member_content(request)
pool = _pool(con)
targets = [p for p in pool if not galaxy or p["galaxy_id"] == galaxy]
m = current_member(con, request)
if wrong_only and m:
wrong = {r[0] for r in con.execute("SELECT term_id FROM attempts WHERE user_id=? AND correct=0", (m["user_id"],))}
right = {r[0] for r in con.execute(
"SELECT term_id FROM attempts a WHERE user_id=? AND correct=1 AND attempt_id > "
"(SELECT COALESCE(MAX(attempt_id),0) FROM attempts b WHERE b.user_id=a.user_id AND b.term_id=a.term_id AND b.correct=0)",
(m["user_id"],))}
targets = [p for p in targets if p["term_id"] in wrong - right]
manual = _manual_for(con, "(galaxy_id=? OR ?)", (galaxy, 1 if not galaxy else 0))
random.shuffle(targets)
out = [manual_question(q) for q in random.sample(manual, min(len(manual), max(0, n // 3)))] if not wrong_only else []
for t in targets:
if len(out) >= n:
break
q = auto_question(pool, t, random.choice(("m2t", "t2m")))
if q:
out.append(q)
random.shuffle(out)
return {"questions": out[:n], "available": len(targets)}
class AnswerIn(BaseModel):
token: str
choice: int
@router.post("/api/members/answer")
def answer(body: AnswerIn, request: Request):
con = require_member_content(request)
raw = auth.unsign(body.token)
if not raw:
raise HTTPException(400, "문제 정보가 올바르지 않습니다. 새로고침해 주세요.")
d = json.loads(raw)
if d["k"] == "auto":
correct_idx = d["a"]
term_id, galaxy_id, qtype, qkey = d["t"], d["g"], d["ty"], f"auto:{d['ty']}:{d['t']}"
t = con.execute("SELECT name, easy FROM terms WHERE term_id=?", (term_id,)).fetchone()
explanation = f"정답은 ‘{t['name']}’입니다. {t['easy'] or ''}".strip() if t else None
else:
q = db.row(con.execute("SELECT * FROM questions WHERE question_id=?", (d["id"],)).fetchone())
if not q:
raise HTTPException(404, "문제가 삭제되었습니다.")
correct_idx, term_id, galaxy_id, qtype, qkey = q["answer"], q["term_id"], q["galaxy_id"], q["qtype"], f"q:{q['question_id']}"
explanation = q["explanation"]
if not galaxy_id and term_id:
g = con.execute("SELECT galaxy_id FROM terms WHERE term_id=?", (term_id,)).fetchone()
galaxy_id = g["galaxy_id"] if g else None
ok = int(body.choice == correct_idx)
m = current_member(con, request)
if m and m["status"] == "approved":
with con:
con.execute(
"INSERT INTO attempts(user_id,class_id,qkey,term_id,galaxy_id,qtype,choice,correct,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
(m["user_id"], m["class_id"], qkey, term_id, galaxy_id, qtype, body.choice, ok, db.now()))
return {"correct": bool(ok), "answer": correct_idx, "explanation": explanation, "term_id": term_id}
# ---------------------------------------------------------------- 소유자: 회원·반
@router.get("/api/admin/members")
def admin_members(status: str | None = None, class_id: int | None = None, q: str | None = None,
_: str = Depends(auth.require_owner)):
con = db.get()
where, args = ["1=1"], []
if status:
where.append("u.status=?")
args.append(status)
if class_id is not None:
where.append("u.class_id=?")
args.append(class_id)
if q:
where.append("(u.username LIKE ? OR u.name LIKE ? OR u.student_no LIKE ?)")
args += [f"%{q}%"] * 3
rows = [dict(r) for r in con.execute(
"SELECT u.user_id,u.username,u.name,u.student_no,u.note,u.status,u.class_id,c.name AS class_name,u.created_at,"
" u.approved_at,u.approved_by,u.last_login,(SELECT code FROM signup_codes s WHERE s.code_id=u.signup_code_id) AS signup_code,"
" (SELECT COUNT(*) FROM attempts a WHERE a.user_id=u.user_id) AS attempts,"
" (SELECT COALESCE(SUM(correct),0) FROM attempts a WHERE a.user_id=u.user_id) AS correct"
f" FROM users u LEFT JOIN classes c ON c.class_id=u.class_id WHERE {' AND '.join(where)}"
" ORDER BY u.status='pending' DESC, u.created_at DESC", args)]
counts = {r[0]: r[1] for r in con.execute("SELECT status, COUNT(*) FROM users GROUP BY status")}
return {"items": rows, "counts": counts}
@router.get("/api/admin/members/{user_id}")
def admin_member_detail(user_id: int, _: str = Depends(auth.require_owner)):
con = db.get()
u = con.execute(
"SELECT u.*, c.name AS class_name, (SELECT code FROM signup_codes s WHERE s.code_id=u.signup_code_id) AS signup_code"
" FROM users u LEFT JOIN classes c ON c.class_id=u.class_id WHERE u.user_id=?", (user_id,)).fetchone()
if not u:
raise HTTPException(404)
u = dict(u)
u.pop("password_hash", None)
u["by_galaxy"] = [dict(r) for r in con.execute(
"SELECT g.galaxy_id, g.name, COUNT(a.attempt_id) AS attempts, COALESCE(SUM(a.correct),0) AS correct"
" FROM attempts a JOIN galaxies g ON g.galaxy_id=a.galaxy_id WHERE a.user_id=? GROUP BY g.galaxy_id ORDER BY g.ord", (user_id,))]
u["wrong_terms"] = [dict(r) for r in con.execute(
"SELECT t.term_id, t.name, COUNT(*) AS wrong FROM attempts a JOIN terms t ON t.term_id=a.term_id"
" WHERE a.user_id=? AND a.correct=0 GROUP BY t.term_id ORDER BY wrong DESC LIMIT 15", (user_id,))]
u["recent"] = [dict(r) for r in con.execute(
"SELECT a.created_at, a.correct, a.qtype, t.name AS term_name, a.term_id FROM attempts a LEFT JOIN terms t ON t.term_id=a.term_id"
" WHERE a.user_id=? ORDER BY a.attempt_id DESC LIMIT 20", (user_id,))]
u["days"] = [dict(r) for r in con.execute(
"SELECT substr(created_at,1,10) AS day, COUNT(*) AS attempts, SUM(correct) AS correct FROM attempts"
" WHERE user_id=? GROUP BY day ORDER BY day DESC LIMIT 14", (user_id,))]
return u
class MemberPatch(BaseModel):
status: str | None = None
class_id: int | None = None
name: str | None = Field(None, max_length=40)
student_no: str | None = Field(None, max_length=30)
new_password: str | None = Field(None, min_length=6, max_length=100)
@router.patch("/api/admin/members/{user_id}")
def admin_member_update(user_id: int, body: MemberPatch, who: str = Depends(auth.require_owner)):
con = db.get()
u = con.execute("SELECT * FROM users WHERE user_id=?", (user_id,)).fetchone()
if not u:
raise HTTPException(404)
data = body.model_dump(exclude_unset=True)
if "status" in data and data["status"] not in ("pending", "approved", "rejected", "disabled"):
raise HTTPException(400, "잘못된 상태")
if data.get("status") == "approved" and not (data.get("class_id") or u["class_id"]):
raise HTTPException(400, "승인할 때 반을 지정해 주세요.")
if data.get("class_id") and not con.execute("SELECT 1 FROM classes WHERE class_id=?", (data["class_id"],)).fetchone():
raise HTTPException(400, "없는 반입니다.")
sets, args = [], []
for k in ("status", "class_id", "name", "student_no"):
if k in data:
sets.append(f"{k}=?")
args.append(data[k])
if data.get("new_password"):
sets.append("password_hash=?")
args.append(auth.hash_password(data["new_password"]))
if data.get("status") == "approved" and u["status"] != "approved":
sets += ["approved_at=?", "approved_by=?"]
args += [db.now(), who]
if not sets:
return dict(u)
with con:
con.execute(f"UPDATE users SET {','.join(sets)} WHERE user_id=?", (*args, user_id))
db.audit(con, who, "update", "member", str(user_id), {k: u[k] for k in data if k in u.keys()},
{k: v for k, v in data.items() if k != "new_password"})
return dict(con.execute("SELECT * FROM users WHERE user_id=?", (user_id,)).fetchone()) | {"password_hash": None}
@router.delete("/api/admin/members/{user_id}")
def admin_member_delete(user_id: int, who: str = Depends(auth.require_owner)):
con = db.get()
with con:
con.execute("DELETE FROM users WHERE user_id=?", (user_id,))
db.audit(con, who, "delete", "member", str(user_id), None, None)
return {"ok": True}
@router.get("/api/admin/classes")
def admin_classes(_: str = Depends(auth.require_admin)):
con = db.get()
return {"items": [dict(r) for r in con.execute(
"SELECT c.*, (SELECT COUNT(*) FROM users u WHERE u.class_id=c.class_id AND u.status='approved') AS members,"
" (SELECT COUNT(*) FROM attempts a WHERE a.class_id=c.class_id) AS attempts,"
" (SELECT COALESCE(SUM(correct),0) FROM attempts a WHERE a.class_id=c.class_id) AS correct"
" FROM classes c ORDER BY c.name")]}
class ClassIn(BaseModel):
name: str = Field(..., min_length=1, max_length=40)
note: str | None = Field(None, max_length=200)
@router.post("/api/admin/classes")
def admin_class_create(body: ClassIn, who: str = Depends(auth.require_owner)):
con = db.get()
if con.execute("SELECT 1 FROM classes WHERE name=?", (body.name.strip(),)).fetchone():
raise HTTPException(409, "같은 이름의 반이 있습니다.")
with con:
cur = con.execute("INSERT INTO classes(name,note,created_at) VALUES(?,?,?)", (body.name.strip(), body.note, db.now()))
db.audit(con, who, "create", "class", str(cur.lastrowid), None, body.model_dump())
return {"class_id": cur.lastrowid}
@router.put("/api/admin/classes/{class_id}")
def admin_class_update(class_id: int, body: ClassIn, who: str = Depends(auth.require_owner)):
con = db.get()
with con:
con.execute("UPDATE classes SET name=?, note=? WHERE class_id=?", (body.name.strip(), body.note, class_id))
db.audit(con, who, "update", "class", str(class_id), None, body.model_dump())
return {"ok": True}
@router.delete("/api/admin/classes/{class_id}")
def admin_class_delete(class_id: int, who: str = Depends(auth.require_owner)):
con = db.get()
if con.execute("SELECT 1 FROM users WHERE class_id=?", (class_id,)).fetchone():
raise HTTPException(409, "이 반에 회원이 있어 지울 수 없습니다. 회원의 반을 먼저 옮겨 주세요.")
with con:
con.execute("DELETE FROM classes WHERE class_id=?", (class_id,))
db.audit(con, who, "delete", "class", str(class_id), None, None)
return {"ok": True}
@router.get("/api/admin/class-stats")
def admin_class_stats(class_id: int | None = None, _: str = Depends(auth.require_admin)):
"""반별 학습 현황: 은하별 정답률, 많이 틀린 용어."""
con = db.get()
w, args = ("WHERE a.class_id=?", [class_id]) if class_id else ("", [])
by_g = [dict(r) for r in con.execute(
f"SELECT g.galaxy_id, g.name, COUNT(a.attempt_id) AS attempts, COALESCE(SUM(a.correct),0) AS correct"
f" FROM attempts a JOIN galaxies g ON g.galaxy_id=a.galaxy_id {w} GROUP BY g.galaxy_id ORDER BY g.ord", args)]
wrong = [dict(r) for r in con.execute(
f"SELECT t.term_id, t.name, COUNT(*) AS attempts, SUM(1-a.correct) AS wrong FROM attempts a"
f" JOIN terms t ON t.term_id=a.term_id {w} GROUP BY t.term_id HAVING wrong>0 ORDER BY wrong DESC LIMIT 20", args)]
return {"by_galaxy": by_g, "wrong_terms": wrong}
# ---------------------------------------------------------------- 검수자·소유자: 직접 출제 문제
class QuestionIn(BaseModel):
term_id: str | None = None
galaxy_id: str | None = None
qtype: str = "mcq"
prompt: str = Field(..., min_length=2, max_length=2000)
choices: list[str] = []
answer: int
explanation: str | None = Field(None, max_length=2000)
review_status: str = "review"
hidden: bool = False
def _check_q(con, body: QuestionIn):
if body.qtype not in ("mcq", "ox"):
raise HTTPException(400, "문제 유형은 객관식(mcq) 또는 OX입니다.")
if body.qtype == "mcq":
ch = [c.strip() for c in body.choices if c.strip()]
if len(ch) < 2 or len(ch) > 6:
raise HTTPException(400, "보기는 2~6개입니다.")
if not 0 <= body.answer < len(ch):
raise HTTPException(400, "정답 번호를 확인해 주세요.")
body.choices = ch
else:
body.choices = []
if body.answer not in (0, 1):
raise HTTPException(400, "OX 정답은 0(O) 또는 1(X)입니다.")
if body.term_id:
t = con.execute("SELECT galaxy_id FROM terms WHERE term_id=?", (body.term_id,)).fetchone()
if not t:
raise HTTPException(400, "없는 용어입니다.")
body.galaxy_id = body.galaxy_id or t["galaxy_id"]
if body.review_status not in ("draft", "review", "confirmed"):
raise HTTPException(400, "잘못된 검수 상태")
@router.get("/api/admin/questions")
def admin_questions(term: str | None = None, galaxy: str | None = None, _: str = Depends(auth.require_admin)):
con = db.get()
where, args = ["1=1"], []
if term:
where.append("q.term_id=?")
args.append(term)
if galaxy:
where.append("q.galaxy_id=?")
args.append(galaxy)
items = db.rows(con.execute(
f"SELECT q.*, t.name AS term_name,"
f" (SELECT COUNT(*) FROM attempts a WHERE a.qkey='q:'||q.question_id) AS attempts,"
f" (SELECT COALESCE(SUM(correct),0) FROM attempts a WHERE a.qkey='q:'||q.question_id) AS correct"
f" FROM questions q LEFT JOIN terms t ON t.term_id=q.term_id WHERE {' AND '.join(where)} ORDER BY q.question_id DESC", args))
return {"items": items}
@router.post("/api/admin/questions")
def admin_question_create(body: QuestionIn, who: str = Depends(auth.require_admin)):
con = db.get()
_check_q(con, body)
with con:
cur = con.execute(
"INSERT INTO questions(term_id,galaxy_id,qtype,prompt,choices,answer,explanation,review_status,hidden,created_by,created_at,updated_at)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(body.term_id, body.galaxy_id, body.qtype, body.prompt.strip(), db.dumps(body.choices), body.answer,
body.explanation, body.review_status, int(body.hidden), who, db.now(), db.now()))
db.audit(con, who, "create", "question", str(cur.lastrowid), None, body.model_dump())
return {"question_id": cur.lastrowid}
@router.put("/api/admin/questions/{qid}")
def admin_question_update(qid: int, body: QuestionIn, who: str = Depends(auth.require_admin)):
con = db.get()
_check_q(con, body)
with con:
con.execute(
"UPDATE questions SET term_id=?,galaxy_id=?,qtype=?,prompt=?,choices=?,answer=?,explanation=?,review_status=?,hidden=?,updated_at=? WHERE question_id=?",
(body.term_id, body.galaxy_id, body.qtype, body.prompt.strip(), db.dumps(body.choices), body.answer,
body.explanation, body.review_status, int(body.hidden), db.now(), qid))
db.audit(con, who, "update", "question", str(qid), None, body.model_dump())
return {"ok": True}
@router.delete("/api/admin/questions/{qid}")
def admin_question_delete(qid: int, who: str = Depends(auth.require_admin)):
con = db.get()
with con:
con.execute("DELETE FROM questions WHERE question_id=?", (qid,))
db.audit(con, who, "delete", "question", str(qid), None, None)
return {"ok": True}
@router.get("/api/admin/questions/preview")
def admin_question_preview(term: str, _: str = Depends(auth.require_admin)):
"""이 용어로 자동 생성되는 문제 미리보기 (정답 포함)."""
con = db.get()
pool = _pool(con)
t = next((p for p in pool if p["term_id"] == term), None)
if not t:
return {"items": [], "reason": f"쉬운 설명({MIN_EASY}자 이상)이 있어야 자동 문제가 만들어집니다."}
out = []
for qt in ("m2t", "t2m"):
q = auto_question(pool, t, qt)
if q:
q["answer"] = json.loads(auth.unsign(q.pop("token")))["a"]
out.append(q)
return {"items": out}
# ---------------------------------------------------------------- 교수자: 가입 코드
CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # 헷갈리는 0/O, 1/I 제외
def _new_code(con):
for _ in range(50):
c = "".join(random.SystemRandom().choice(CODE_CHARS) for _ in range(6))
c = f"{c[:3]}-{c[3:]}"
if not con.execute("SELECT 1 FROM signup_codes WHERE code=?", (c,)).fetchone():
return c
raise HTTPException(500, "코드를 만들지 못했습니다.")
class CodeIn(BaseModel):
code: str | None = Field(None, max_length=40)
class_id: int | None = None
note: str | None = Field(None, max_length=200)
active: bool = True
auto_approve: bool = False
max_uses: int | None = Field(None, ge=1, le=100000)
expires_at: str | None = None # YYYY-MM-DD
def _check_code_body(con, body: CodeIn):
if not body.class_id:
raise HTTPException(400, "가입 코드에는 반을 지정해 주세요. 이 코드로 가입한 학생이 그 반에 들어갑니다.")
if not con.execute("SELECT 1 FROM classes WHERE class_id=?", (body.class_id,)).fetchone():
raise HTTPException(400, "없는 반입니다.")
if body.expires_at and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", body.expires_at):
raise HTTPException(400, "만료일은 YYYY-MM-DD 형식입니다.")
if body.code is not None:
body.code = body.code.strip().upper()
if not re.fullmatch(r"[A-Z0-9\-]{4,40}", body.code):
raise HTTPException(400, "코드는 영문 대문자·숫자·- 로 4자 이상입니다.")
@router.get("/api/admin/signup-codes")
def admin_codes(_: str = Depends(auth.require_owner)):
con = db.get()
return {"items": [dict(r) for r in con.execute(
"SELECT s.*, c.name AS class_name,"
" (SELECT COUNT(*) FROM users u WHERE u.signup_code_id=s.code_id) AS members"
" FROM signup_codes s LEFT JOIN classes c ON c.class_id=s.class_id ORDER BY s.active DESC, s.code_id DESC")]}
@router.post("/api/admin/signup-codes")
def admin_code_create(body: CodeIn, who: str = Depends(auth.require_owner)):
con = db.get()
_check_code_body(con, body)
code = body.code or _new_code(con)
if con.execute("SELECT 1 FROM signup_codes WHERE code=?", (code,)).fetchone():
raise HTTPException(409, "이미 있는 코드입니다.")
with con:
cur = con.execute(
"INSERT INTO signup_codes(code,class_id,note,active,auto_approve,max_uses,expires_at,created_by,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
(code, body.class_id, body.note, int(body.active), int(body.auto_approve), body.max_uses, body.expires_at, who, db.now()))
db.audit(con, who, "create", "signup_code", str(cur.lastrowid), None, {"code": code, "class_id": body.class_id})
return {"code_id": cur.lastrowid, "code": code}
@router.put("/api/admin/signup-codes/{code_id}")
def admin_code_update(code_id: int, body: CodeIn, who: str = Depends(auth.require_owner)):
con = db.get()
old = con.execute("SELECT * FROM signup_codes WHERE code_id=?", (code_id,)).fetchone()
if not old:
raise HTTPException(404)
_check_code_body(con, body)
with con:
con.execute(
"UPDATE signup_codes SET class_id=?, note=?, active=?, auto_approve=?, max_uses=?, expires_at=? WHERE code_id=?",
(body.class_id, body.note, int(body.active), int(body.auto_approve), body.max_uses, body.expires_at, code_id))
db.audit(con, who, "update", "signup_code", str(code_id), None, body.model_dump(exclude={"code"}))
return {"ok": True}
@router.delete("/api/admin/signup-codes/{code_id}")
def admin_code_delete(code_id: int, who: str = Depends(auth.require_owner)):
con = db.get()
with con:
con.execute("DELETE FROM signup_codes WHERE code_id=?", (code_id,))
db.audit(con, who, "delete", "signup_code", str(code_id), None, None)
return {"ok": True}
+152
View File
@@ -0,0 +1,152 @@
"""data/atlas.json(파이프라인 결과)을 DB로 가져오기.
.venv/bin/python -m server.seed # 사람이 고친 항목(edited=1)은 보존하며 갱신
.venv/bin/python -m server.seed --reset # DB 내용을 지우고 새로 가져옴 (피드백·기록은 유지)
자동 생성 행만 덮어쓰므로 파이프라인을 다시 돌려도 검수 결과가 사라지지 않는다.
"""
import argparse
import json
import os
from pathlib import Path
from . import db
ATLAS_JSON = Path(os.environ.get("ATLAS_JSON", db.ROOT / "data" / "atlas.json"))
def seed(con, data: dict, reset: bool = False) -> dict:
ts = db.now()
stats = {"terms_new": 0, "terms_updated": 0, "terms_kept": 0, "terms_removed": 0,
"relations_new": 0, "relations_updated": 0, "relations_kept": 0, "relations_removed": 0}
with con:
if reset:
for t in ("books", "galaxies", "toc", "terms", "term_sources", "relations"):
con.execute(f"DELETE FROM {t}")
for b in data["books"]:
con.execute(
"INSERT INTO books(book_id,title,short,edition,year,is_primary,filename) VALUES(?,?,?,?,?,?,?) "
"ON CONFLICT(book_id) DO UPDATE SET title=excluded.title, short=excluded.short, edition=excluded.edition,"
" year=excluded.year, is_primary=excluded.is_primary, filename=excluded.filename",
(b["book_id"], b["title"], b["short"], b["edition"], b["year"], int(b["primary"]), b["filename"]),
)
for i, g in enumerate(data["galaxies"]):
con.execute(
"INSERT INTO galaxies(galaxy_id,name,sub,color,ord,chapter_no,updated_at) VALUES(?,?,?,?,?,?,?) "
"ON CONFLICT(galaxy_id) DO UPDATE SET name=excluded.name, sub=excluded.sub, color=excluded.color,"
" ord=excluded.ord, chapter_no=excluded.chapter_no WHERE galaxies.edited=0",
(g["id"], g["name"], g["sub"], g["color"], i, g["chapter_no"], ts),
)
for n in data["toc"]:
con.execute(
"INSERT INTO toc(toc_id,book_id,kind,level,number,title,page,parent_id,galaxy_id) VALUES(?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(toc_id) DO UPDATE SET book_id=excluded.book_id, kind=excluded.kind, level=excluded.level,"
" number=excluded.number, title=excluded.title, page=excluded.page, parent_id=excluded.parent_id,"
" galaxy_id=excluded.galaxy_id WHERE toc.edited=0",
(n["id"], n["book_id"], n["kind"], n["level"], n["number"], n["title"], n["page"], n["parent_id"], n["galaxy_id"]),
)
toc_ids = {n["id"] for n in data["toc"]}
for r in con.execute("SELECT toc_id FROM toc WHERE edited=0").fetchall():
if r["toc_id"] not in toc_ids:
con.execute("DELETE FROM toc WHERE toc_id=?", (r["toc_id"],))
existing = {r["term_id"]: r for r in con.execute("SELECT term_id, edited, origin FROM terms")}
seen = set()
for t in data["terms"]:
tid = t["term_id"]
seen.add(tid)
d = t.get("definition") or {}
vals = dict(
name=t["name"], kind=t["kind"], lang=t["lang"], aliases=db.dumps(t["aliases"]),
search_terms=db.dumps(t.get("search_terms", [])), galaxy_id=t["galaxy_id"], section_id=t["section_id"],
section_guess=int(t.get("section_guess", False)), item_id=t.get("item_id"),
section_ids=db.dumps(t.get("section_ids", [])),
definition_text=d.get("text"), definition_book=d.get("book_id"), definition_page=d.get("page"),
definition_method=d.get("method"), easy=t.get("easy"), example=t.get("example"),
easy_method=t.get("easy_method"),
)
if tid not in existing:
cols = ["term_id", *vals, "review_status", "origin", "created_at", "updated_at"]
con.execute(f"INSERT INTO terms({','.join(cols)}) VALUES({','.join('?' * len(cols))})",
(tid, *vals.values(), "draft", "auto", ts, ts))
stats["terms_new"] += 1
elif not existing[tid]["edited"]:
sets = ",".join(f"{k}=?" for k in vals)
con.execute(f"UPDATE terms SET {sets}, updated_at=? WHERE term_id=?", (*vals.values(), ts, tid))
stats["terms_updated"] += 1
else:
stats["terms_kept"] += 1
con.execute("DELETE FROM term_sources WHERE term_id=? AND origin='auto'", (tid,))
manual = {(r["book_id"], r["page"]) for r in con.execute(
"SELECT book_id, page FROM term_sources WHERE term_id=? AND origin='manual'", (tid,))}
for s in t["sources"]:
if (s["book_id"], s["page"]) in manual: # 사람이 고친 출처가 우선
continue
con.execute(
"INSERT INTO term_sources(term_id,book_id,page,pdf_page,toc_id,section_id,galaxy_id,path,snippet,origin)"
" VALUES(?,?,?,?,?,?,?,?,?,'auto')",
(tid, s["book_id"], s["page"], s.get("pdf_page"), s.get("toc_id"), s.get("section_id"),
s.get("galaxy_id"), s.get("path"), s.get("snippet")),
)
for tid, r in existing.items():
if tid not in seen and r["origin"] == "auto" and not r["edited"]:
con.execute("DELETE FROM terms WHERE term_id=?", (tid,))
con.execute("DELETE FROM term_sources WHERE term_id=?", (tid,))
stats["terms_removed"] += 1
existing = {r["relation_id"]: r for r in con.execute("SELECT relation_id, edited, method FROM relations")}
seen = set()
for r in data["relations"]:
rid = r["relation_id"]
seen.add(rid)
vals = dict(source=r["source"], target=r["target"], type=r["type"], label=r["label"],
directed=int(r["directed"]), method=r["method"], evidence=db.dumps(r["evidence"]))
if rid not in existing:
cols = ["relation_id", *vals, "review_status", "created_at", "updated_at"]
con.execute(f"INSERT INTO relations({','.join(cols)}) VALUES({','.join('?' * len(cols))})",
(rid, *vals.values(), "draft", ts, ts))
stats["relations_new"] += 1
elif not existing[rid]["edited"]:
sets = ",".join(f"{k}=?" for k in vals)
con.execute(f"UPDATE relations SET {sets}, updated_at=? WHERE relation_id=?", (*vals.values(), ts, rid))
stats["relations_updated"] += 1
else:
stats["relations_kept"] += 1
for rid, r in existing.items():
if rid not in seen and r["method"] != "manual" and not r["edited"]:
con.execute("DELETE FROM relations WHERE relation_id=?", (rid,))
stats["relations_removed"] += 1
con.execute("INSERT INTO meta(key,value) VALUES('seeded_at',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", (ts,))
con.execute("INSERT INTO meta(key,value) VALUES('generated_at',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(data.get("generated_at", ""),))
db.audit(con, "system", "seed", "atlas", None, None, stats)
return stats
def seed_if_empty(con) -> None:
"""DB가 비었거나 atlas.json이 새로 만들어졌으면 가져온다 (사람이 고친 항목은 보존)."""
if not ATLAS_JSON.exists():
return
n = con.execute("SELECT COUNT(*) FROM terms").fetchone()[0]
data = json.loads(ATLAS_JSON.read_text())
cur = con.execute("SELECT value FROM meta WHERE key='generated_at'").fetchone()
if n == 0:
print(f"DB가 비어 있어 {ATLAS_JSON}을 가져옵니다…")
elif not cur or cur[0] != data.get("generated_at"):
print(f"새 데이터({data.get('generated_at')})를 가져옵니다…")
else:
return
print(seed(con, data))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--reset", action="store_true")
ap.add_argument("--file", default=str(ATLAS_JSON))
a = ap.parse_args()
con = db.connect()
print(seed(con, json.loads(Path(a.file).read_text()), reset=a.reset))
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
import json
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
os.environ["ATLAS_OWNER_PASSWORD"] = "owner-pass"
@pytest.fixture()
def client(tmp_path, monkeypatch):
from fastapi.testclient import TestClient
from server import db
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
db._local.__dict__.clear()
from server import app as app_module
app_module._hits.clear()
with TestClient(app_module.app) as c:
yield c
db._local.__dict__.clear()
@pytest.fixture()
def admin(client):
r = client.post("/api/admin/login", json={"username": "professor", "password": "owner-pass"})
assert r.status_code == 200
return client
@pytest.fixture(scope="session")
def atlas():
return json.loads((ROOT / "data" / "atlas.json").read_text())
@pytest.fixture()
def owner(client):
r = client.post("/api/admin/login", json={"username": "professor", "password": "owner-pass"})
assert r.status_code == 200 and r.json()["role"] == "owner"
return client
+21
View File
@@ -0,0 +1,21 @@
// 관리 화면의 모든 탭이 오류 없이 열리는지 확인 (브라우저 필요: npm i playwright-core, Chrome 설치)
// node tests/e2e/admin-tabs.mjs http://127.0.0.1:8000 <교수자 비밀번호>
import { chromium } from "playwright-core";
const B = process.argv[2] || "http://127.0.0.1:8000", PW = process.argv[3] || "ownerpass";
const browser = await chromium.launch({ channel: "chrome", headless: true });
const p = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
const errs = []; p.on("pageerror", e => errs.push(e.message));
await p.goto(B + "/login?next=/admin", { waitUntil: "networkidle" });
await p.fill("[name=username]", "professor"); await p.fill("[name=password]", PW); await p.click("button[type=submit]"); await p.waitForTimeout(1500);
const tabs = await p.$$eval("#tabs a", (as) => as.map((a) => a.getAttribute("href")));
const extra = ["#members/list", "#members/codes", "#members/classes", "#members/stats", "#term/t_20cb03023b", "#questions/t_20cb03023b"];
let bad = 0;
for (const h of [...tabs, ...extra]) {
await p.evaluate((x) => (location.hash = x), h); await p.waitForTimeout(900);
const t = await p.locator("#view").innerText();
const fail = /불러오지 못했습니다|Can't find|is not defined|undefined/.test(t);
if (fail) bad++;
console.log((fail ? "FAIL " : "PASS ") + h + (fail ? " → " + t.slice(0, 120) : ""));
}
console.log(errs.join("\n") || "no JS errors", "| 실패", bad);
await browser.close();
+41
View File
@@ -0,0 +1,41 @@
// node --test tests/search.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { buildIndex, search, choseong, initialGroup } from "../web/static/js/search.js";
import { shortestPath } from "../web/static/js/layout.js";
const terms = [
{ i: "a", n: "우선순위 설정", a: [], q: ["무엇부터"], x: "여러 문제 가운데 먼저 다룰 것을 정함" },
{ i: "b", n: "건강신념모형", a: ["Health Belief Model", "HBM"], q: [], x: "" },
{ i: "c", n: "발생률", a: ["incidence"], q: [], x: "새롭게 발생한 환자 비율" },
{ i: "d", n: "유병률", a: ["prevalence"], q: [], x: "질병을 가진 사람 비율" },
];
const idx = buildIndex(terms);
const top = (q) => search(idx, q)[0]?.t.i;
test("부분·동의어·설명 검색", () => {
assert.equal(top("우선"), "a");
assert.equal(top("hbm"), "b");
assert.equal(top("prevalence"), "d");
assert.equal(top("새롭게"), "c");
assert.equal(top("무엇부터 먼저"), "a");
});
test("초성·오타", () => {
assert.equal(top("ㄱㄱㅅㄴ"), "b");
assert.equal(top("우선순이"), "a");
assert.equal(choseong("건강"), "ㄱㄱ");
});
test("결과 없음", () => assert.equal(search(idx, "쀍쀍").length, 0));
test("가나다 묶음", () => {
assert.equal(initialGroup("쌍둥이"), "ㅅ");
assert.equal(initialGroup("WHO"), "A–Z");
});
test("최단 경로", () => {
const rels = [{ i: "r1", s: "a", t: "b" }, { i: "r2", s: "c", t: "b" }];
const adj = new Map(terms.map((t) => [t.i, []]));
for (const r of rels) { adj.get(r.s).push(r); adj.get(r.t).push(r); }
const p = shortestPath(adj, "a", "c");
assert.deepEqual(p.map((s) => s.to), ["b", "c"]);
assert.equal(p[1].forward, false);
assert.equal(shortestPath(adj, "a", "d"), null);
});
+126
View File
@@ -0,0 +1,126 @@
"""웹서버 API 회귀 테스트 (임시 DB에 data/atlas.json을 가져와 실행)."""
def first_term(client):
d = client.get("/api/atlas").json()
return d, d["terms"][0]["i"]
def test_atlas_shape(client):
d, _ = first_term(client)
assert len(d["galaxies"]) == 9
assert len(d["terms"]) > 1000
assert d["sections"] and d["relations"]
t = d["terms"][0]
assert {"i", "n", "g", "s", "st", "a"} <= t.keys()
def test_term_detail_and_404(client):
_, tid = first_term(client)
t = client.get(f"/api/terms/{tid}").json()
assert t["term_id"] == tid and "sources" in t and "relations" in t
assert client.get("/api/terms/t_nope").status_code == 404
def test_feedback_flow(client, admin):
_, tid = first_term(client)
r = client.post("/api/feedback", json={"kind": "content", "category": "뜻·정의가 틀림", "target_type": "term",
"target_id": tid, "message": "정의가 잘렸어요", "contact": "a@b.c"})
fid = r.json()["id"]
pub = client.get("/api/feedback").json()["items"][0]
assert pub["id"] == fid and "contact" not in pub
r = admin.patch(f"/api/admin/feedback/{fid}", json={"status": "resolved", "resolution": "수정함"})
assert r.json()["status"] == "resolved"
assert admin.get(f"/api/admin/feedback/{fid}").json()["handled_by"] == "교수자"
assert client.post("/api/feedback", json={"kind": "content", "message": "x"}).status_code == 422
assert client.post("/api/feedback", json={"kind": "content", "message": "없는 용어", "target_type": "term",
"target_id": "t_nope"}).status_code == 400
def test_client_error_dedup(client, admin):
for _ in range(3):
client.post("/api/client-errors", json={"message": "TypeError: boom", "stack": "at a.js:1"})
items = admin.get("/api/admin/feedback?kind=client_error").json()["items"]
assert len(items) == 1 and items[0]["occurrences"] == 3
def test_admin_requires_login(client):
assert client.get("/api/admin/stats").status_code == 401
assert client.post("/api/admin/login", json={"username": "professor", "password": "wrong"}).status_code == 401
assert client.post("/api/admin/login", json={"username": "professor", "password": "test-pass"}).status_code == 401 # 공용 비밀번호 없음
def test_term_edit_survives_reseed(admin):
_, tid = first_term(admin)
r = admin.put(f"/api/admin/terms/{tid}", json={"easy": "검수한 설명", "review_status": "confirmed"})
assert r.status_code == 200 and r.json()["easy"] == "검수한 설명"
n_src = len(r.json()["sources"])
admin.post("/api/admin/reseed")
t = admin.get(f"/api/admin/terms/{tid}").json()
assert t["easy"] == "검수한 설명" and t["review_status"] == "confirmed"
assert len(t["sources"]) == n_src # 출처 중복 없음
def test_public_status_filter(owner):
admin = owner
_, tid = first_term(admin)
admin.put(f"/api/admin/terms/{tid}", json={"review_status": "confirmed"})
assert admin.put("/api/admin/settings", json={"public_statuses": ["confirmed"]}).status_code == 200
d = admin.get("/api/atlas").json()
assert [t["i"] for t in d["terms"]] == [tid]
def test_relation_crud_and_merge(admin):
d, _ = first_term(admin)
a, b, c = d["terms"][1]["i"], d["terms"][2]["i"], d["terms"][3]["i"]
r = admin.post("/api/admin/relations", json={"source": a, "target": b, "type": "precedes", "label": "다음"})
rid = r.json()["relation_id"]
assert admin.post("/api/admin/relations", json={"source": a, "target": b, "type": "precedes"}).status_code == 409
admin.post("/api/admin/relations", json={"source": c, "target": b, "type": "precedes"})
admin.post(f"/api/admin/terms/{c}/merge", json={"into": a})
rels = admin.get(f"/api/admin/relations?term={a}&limit=1000").json()["items"]
assert sum(1 for x in rels if x["source"] == a and x["target"] == b and x["type"] == "precedes") == 1
assert admin.delete(f"/api/admin/relations/{rid}").json()["ok"]
def test_section_move_moves_terms(admin):
d, _ = first_term(admin)
sec = d["sections"][0]
admin.put(f"/api/admin/toc/{sec['id']}", json={"galaxy_id": "g9"})
terms = admin.get(f"/api/admin/terms?section={sec['id']}&limit=1000").json()["items"]
assert terms and all(t["galaxy_id"] == "g9" for t in terms)
def test_pages_served(client):
for p in ("/", "/feedback", "/admin", "/static/js/atlas.js", "/static/css/base.css"):
assert client.get(p).status_code == 200
def test_no_textbook_info_public(client):
"""저작권: 학생 화면(비로그인)에는 교과서 원문·문장·교재명·쪽수를 보내지 않는다."""
d = client.get("/api/atlas").json()
assert d["books"] == []
assert all("page" not in s and "book_id" not in s for s in d["sections"])
_, tid = first_term(client)
t = client.get(f"/api/terms/{tid}").json()
assert t["sources"] == [] and "definition_text" not in t and "definition_page" not in t
assert client.get("/pdf/x").status_code == 404
def test_admin_keeps_sources(admin):
_, tid = first_term(admin)
t = admin.get(f"/api/admin/terms/{tid}").json()
assert t["sources"] and "book_short" in t["sources"][0]
assert admin.get("/pdf/x").status_code == 404 # 원문 PDF는 관리자에게도 내보내지 않음
def test_unified_login_and_account(client):
# 교수자 아이디로 /api/login → 관리자 세션
r = client.post("/api/login", json={"username": "professor", "password": "owner-pass"}).json()
assert r["kind"] == "admin"
assert client.get("/api/admin/stats").status_code == 200
assert client.put("/api/admin/account", json={"current_password": "wrong", "username": "prof2"}).status_code == 400
r = client.put("/api/admin/account", json={"current_password": "owner-pass", "username": "prof2", "name": "김교수", "new_password": "newpass99"})
assert r.json()["username"] == "prof2"
assert client.post("/api/login", json={"username": "professor", "password": "owner-pass"}).status_code == 401
assert client.post("/api/login", json={"username": "prof2", "password": "newpass99"}).json()["kind"] == "admin"
+89
View File
@@ -0,0 +1,89 @@
"""회원 가입·승인(소유자만, 반 지정)·사례/문제 회원 전용."""
from fastapi.testclient import TestClient
def _term_with_example(client):
d = client.get("/api/atlas").json()
for t in d["terms"]:
if t["e"]:
r = client.get(f"/api/terms/{t['i']}").json()
if r.get("example_locked"):
return t["i"]
raise AssertionError("사례가 있는 용어가 없음")
def test_member_flow(client):
from server.app import app
# 비회원: 사례 잠김, 문제 불가
tid = _term_with_example(client)
assert client.get(f"/api/members/questions?term={tid}").status_code == 401
# 가입 코드 없이는 가입 불가
assert client.post("/api/members/signup", json={"code": "NOPE", "username": "stud1", "password": "secret1", "name": "김학생"}).status_code == 400
with TestClient(app) as own:
own.post("/api/admin/login", json={"username": "professor", "password": "owner-pass"})
assert own.post("/api/admin/signup-codes", json={"max_uses": 2}).status_code == 400 # 반 없는 코드 불가
cid = own.post("/api/admin/classes", json={"name": "1반"}).json()["class_id"]
code = own.post("/api/admin/signup-codes", json={"class_id": cid, "max_uses": 2}).json()["code"]
# 코드로 가입하면 바로 회원 (승인 대기 없음)
r = client.post("/api/members/signup", json={"code": code.lower(), "username": "stud1", "password": "secret1", "name": "김학생"})
assert r.json()["status"] == "approved"
assert client.post("/api/members/signup", json={"code": code, "username": "stud1", "password": "secret1", "name": "x"}).status_code == 409
client.post("/api/members/login", json={"username": "stud1", "password": "secret1"})
# 교수자 비밀번호가 아니면 관리 화면에 들어갈 수 없음
with TestClient(app) as rev:
assert rev.post("/api/admin/login", json={"username": "professor", "password": "test-pass"}).status_code == 401
assert rev.get("/api/admin/members").status_code == 401
# 승인 후: 사례·문제
me = client.get("/api/members/me").json()
assert me["status"] == "approved" and me["class_name"] == "1반" and me["can_view"]
assert client.get(f"/api/terms/{tid}").json()["example"]
qs = client.get(f"/api/members/questions?term={tid}").json()["questions"]
assert qs and all("answer" not in q for q in qs) # 정답은 내려보내지 않음
q = qs[0]
res = client.post("/api/members/answer", json={"token": q["token"], "choice": 0}).json()
assert "correct" in res and "answer" in res
quiz = client.get("/api/members/quiz?galaxy=g6&n=5").json()["questions"]
assert len(quiz) == 5
assert client.get("/api/members/me").json()["attempts"] == 1
# 위조 토큰 거부
assert client.post("/api/members/answer", json={"token": q["token"] + "x", "choice": 0}).status_code == 400
with TestClient(app) as own:
own.post("/api/admin/login", json={"username": "professor", "password": "owner-pass"})
cls = own.get("/api/admin/classes").json()["items"][0]
assert cls["members"] == 1 and cls["attempts"] == 1
assert own.delete(f"/api/admin/classes/{cls['class_id']}").status_code == 409
def test_manual_question(admin):
_, tid = admin.get("/api/atlas").json(), None
tid = admin.get("/api/atlas").json()["terms"][0]["i"]
r = admin.post("/api/admin/questions", json={"term_id": tid, "qtype": "ox", "prompt": "맞을까요?", "answer": 0, "review_status": "confirmed"})
qid = r.json()["question_id"]
qs = admin.get(f"/api/members/questions?term={tid}").json()["questions"] # 관리자는 볼 수 있음
mq = next(q for q in qs if q["origin"] == "manual")
assert mq["choices"] == ["O", "X"]
assert admin.post("/api/members/answer", json={"token": mq["token"], "choice": 0}).json()["correct"]
assert admin.post("/api/admin/questions", json={"qtype": "mcq", "prompt": "x?", "choices": ["a"], "answer": 0}).status_code == 400
assert admin.delete(f"/api/admin/questions/{qid}").json()["ok"]
def test_signup_code_rules(owner):
c = owner
cid = c.post("/api/admin/classes", json={"name": "B반"}).json()["class_id"]
r = c.post("/api/admin/signup-codes", json={"code": "class-b", "class_id": cid, "max_uses": 1}).json()
assert r["code"] == "CLASS-B"
from fastapi.testclient import TestClient
from server.app import app
with TestClient(app) as s:
r = s.post("/api/members/signup", json={"code": "class-b", "username": "autostud", "password": "secret1", "name": "자동"})
assert r.json()["status"] == "approved"
assert s.post("/api/members/signup", json={"code": "CLASS-B", "username": "late", "password": "secret1", "name": "x"}).status_code == 400 # 횟수 초과
items = c.get("/api/admin/members?status=approved").json()["items"]
u = next(x for x in items if x["username"] == "autostud")
assert u["class_name"] == "B반" and u["signup_code"] == "CLASS-B"
d = c.get(f"/api/admin/members/{u['user_id']}").json()
assert "password_hash" not in d and "by_galaxy" in d
code_id = c.get("/api/admin/signup-codes").json()["items"][0]["code_id"]
c.put(f"/api/admin/signup-codes/{code_id}", json={"class_id": cid, "active": False})
+34
View File
@@ -0,0 +1,34 @@
"""PDF 파싱 도우미 단위 테스트 (PDF 없이 실행)."""
from pipeline.build import norm_key, rejoin_lines, score_sentence, split_name
from pipeline.toc import _split_page
def test_norm_key_merges_spacing_variants():
assert norm_key("지역사회 간호과정") == norm_key("지역사회간호과정")
assert norm_key("Health-Belief Model") == "healthbeliefmodel"
def test_split_name_parentheses():
assert split_name("건강 신념 모형(Health Belief Model; HBM)") == ("건강 신념 모형", ["Health Belief Model", "HBM"])
assert split_name("발생률") == ("발생률", [])
def test_toc_leaders_and_page():
assert _split_page("1. 지역사회의 정의jjjjjjjjjjjj3") == ("1. 지역사회의 정의", 3)
assert _split_page("제1절 지역사회의 이해······ 19") == ("제1절 지역사회의 이해", 19)
assert _split_page("1) 서양 근대 공중보건의 확립_62") == ("1) 서양 근대 공중보건의 확립", 62)
def test_rejoin_lines():
text = "유병률은 특정 시점의 비율을 나타내는 역학적\n지표로, 질병의 부담을 평가하는 데 사용된다.\n2) 발병률\n성과관리는 사업 목표의 달성 정도를 확인하며 지역사회 보건사업에서는 프\n로그램 평가를 한다."
out = rejoin_lines(text).split("\n")
assert out[0] == "유병률은 특정 시점의 비율을 나타내는 역학적 지표로, 질병의 부담을 평가하는 데 사용된다."
assert out[1] == "2) 발병률"
assert "프로그램" in out[2]
def test_definition_scoring():
good = "유병률은 특정 시점에 질병을 가진 사람의 비율을 말한다."
weak = "이 장에서는 유병률과 여러 지표를 함께 다룬다."
assert score_sentence(good, "유병률", norm_key("유병률")) >= 7
assert score_sentence(weak, "유병률", norm_key("유병률")) < 7
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>내 계정 · 지역사회간호학 개념 우주</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/account.css">
<script type="module" src="/static/js/account.js"></script>
</head>
<body>
<header class="topbar">
<a class="brand" href="/">
<span class="brand-logo" aria-hidden="true"></span>
<span><span class="brand-title">지역사회간호학 개념 우주</span><br><span class="brand-sub">교과서 색인으로 만든 개념 지도</span></span>
</a>
<nav class="topnav" aria-label="주요 메뉴">
<a href="/">개념 우주</a>
<a href="/#/quiz">문제 풀기</a>
<a href="/feedback">오류 신고</a>
<a href="/account" aria-current="page">내 정보</a>
</nav>
</header>
<main class="acc-wrap" id="main">
<p class="muted">불러오는 중…</p>
</main>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>관리 · 지역사회간호학 개념 우주</title>
<meta name="robots" content="noindex">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<a class="skip-link" href="#view">본문으로 건너뛰기</a>
<header class="topbar" id="topbar">
<a class="brand" href="/">
<span class="brand-logo" aria-hidden="true"></span>
<span>
<span class="brand-title">지역사회간호학 개념 우주</span><br>
<span class="brand-sub">교과서 색인으로 만든 개념 지도</span>
</span>
</a>
<nav class="topnav" aria-label="주 메뉴">
<a href="/">개념 우주</a>
<a href="/feedback">오류 신고</a>
<a href="/admin" aria-current="page">관리</a>
</nav>
<div class="who" id="who" hidden>
<span class="who-name" id="who-name"></span>
<button type="button" class="btn small ghost" id="logout">로그아웃</button>
</div>
</header>
<!-- 관리 화면 -->
<div id="app-view" hidden>
<nav class="tabs" id="tabs" aria-label="관리 메뉴">
<a href="#dashboard" data-tab="dashboard">대시보드</a>
<a href="#feedback" data-tab="feedback">피드백 <span class="count" id="tab-fb-count" hidden></span></a>
<a href="#terms" data-tab="terms">용어</a>
<a href="#relations" data-tab="relations">관계</a>
<a href="#questions" data-tab="questions">문제</a>
<a href="#codes" data-tab="codes">가입 코드</a>
<a href="#members" data-tab="members">회원 관리 <span class="count" id="tab-mem-count" hidden></span></a>
<a href="#structure" data-tab="structure">은하·단원</a>
<a href="#settings" data-tab="settings">설정</a>
<a href="#audit" data-tab="audit">기록</a>
</nav>
<main id="view" class="view" tabindex="-1"></main>
</div>
<script type="module" src="/static/js/admin.js"></script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>오류 신고 · 지역사회간호학 개념 우주</title>
<meta name="description" content="개념 우주의 잘못된 뜻, 관계, 출처, 화면 오류를 알려 주세요.">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/feedback.css">
</head>
<body>
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
<header class="topbar">
<a class="brand" href="/">
<span class="brand-logo" aria-hidden="true"></span>
<span>
<span class="brand-title">지역사회간호학 개념 우주</span><br>
<span class="brand-sub">교과서 색인으로 만든 개념 지도</span>
</span>
</a>
<nav class="topnav" aria-label="주 메뉴">
<a href="/">개념 우주</a>
<a href="/feedback" aria-current="page">오류 신고</a>
<a href="/admin">관리</a>
</nav>
</header>
<main id="main" class="fb-main">
<section class="card fb-form-card" aria-labelledby="fb-title">
<h1 id="fb-title">오류 신고</h1>
<p class="muted fb-lead">뜻이 틀렸거나, 관계가 이상하거나, 출처 페이지가 맞지 않는 곳을 발견하면 알려 주세요. 검수자가 확인한 뒤 고치고, 처리 결과를 아래 목록에 남깁니다.</p>
<div id="fb-done" class="fb-done" hidden tabindex="-1" role="status"></div>
<form id="fb-form" novalidate>
<div class="fb-grid">
<label class="field">
<span>유형 <b class="req" aria-hidden="true">*</b></span>
<select id="fb-type" class="input" name="type" required></select>
</label>
<div class="field fb-target">
<label for="fb-term"><span class="field-label">어떤 용어인가요? <span class="faint">(선택)</span></span></label>
<div class="ac" id="fb-ac">
<input id="fb-term" class="input" type="text" autocomplete="off" placeholder="용어 이름이나 영어 표기로 찾기"
role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="fb-term-list">
<ul id="fb-term-list" class="ac-list" role="listbox" hidden></ul>
</div>
<div id="fb-term-chosen" class="fb-chosen" hidden></div>
<div id="fb-rel-chosen" class="fb-chosen" hidden></div>
</div>
</div>
<label class="field">
<span>무엇이 잘못되었나요? <b class="req" aria-hidden="true">*</b></span>
<textarea id="fb-message" class="input" name="message" required minlength="2" maxlength="4000" rows="5"
aria-describedby="fb-message-help fb-message-count"
placeholder="예: ‘일차보건의료’의 쉬운 뜻이 교과서 정의와 다릅니다."></textarea>
<span class="field-foot"><span id="fb-message-help" class="faint">2자 이상 적어 주세요.</span><span id="fb-message-count" class="faint" aria-live="polite">0 / 4000</span></span>
</label>
<label class="field">
<span>올바른 내용 제안 <span class="faint">(선택)</span></span>
<textarea id="fb-suggestion" class="input" name="suggestion" maxlength="4000" rows="4"
aria-describedby="fb-suggestion-help"
placeholder="바르게 고친 문장이나 연결을 적어 주세요."></textarea>
<span id="fb-suggestion-help" class="faint field-foot">근거가 있다면 함께 적어 주세요.</span>
</label>
<div class="fb-grid">
<label class="field">
<span>이름 <span class="faint">(선택)</span></span>
<input id="fb-reporter" class="input" name="reporter" maxlength="60" autocomplete="name">
</label>
<label class="field">
<span>연락처 <span class="faint">(선택)</span></span>
<input id="fb-contact" class="input" name="contact" maxlength="120" autocomplete="email" aria-describedby="fb-contact-help">
<span id="fb-contact-help" class="faint field-foot">이메일 등. 관리자만 볼 수 있고 공개 목록에는 나오지 않습니다.</span>
</label>
</div>
<label class="field">
<span>신고한 화면 주소 <span class="faint">(선택)</span></span>
<input id="fb-page" class="input" name="page_url" maxlength="500" inputmode="url">
</label>
<div class="hp" aria-hidden="true">
<label>웹사이트 <input type="text" name="website" id="fb-website" tabindex="-1" autocomplete="off"></label>
</div>
<div class="row fb-actions">
<button type="submit" class="btn primary" id="fb-submit">신고 보내기</button>
<button type="reset" class="btn ghost">다시 쓰기</button>
</div>
</form>
</section>
<section class="fb-list-sec" aria-labelledby="fb-list-title">
<div class="row fb-list-head">
<h2 id="fb-list-title" class="grow">접수된 신고</h2>
<span id="fb-total" class="faint" aria-live="polite"></span>
</div>
<div class="chips" role="group" aria-label="처리 상태로 거르기" id="fb-chips"></div>
<ol id="fb-list" class="fb-list" aria-live="polite"></ol>
<div class="fb-more-wrap"><button type="button" class="btn" id="fb-more" hidden>더 보기</button></div>
</section>
</main>
<script type="module" src="/static/js/feedback.js"></script>
</body>
</html>
+97
View File
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>지역사회간호학 개념 우주</title>
<meta name="description" content="지역사회간호학 교과서 색인을 주제·용어·관계로 탐색하는 개념 지도">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/atlas.css">
<script type="module" src="/static/js/atlas.js"></script>
</head>
<body>
<a class="skip-link" href="#detail">설명 영역으로 건너뛰기</a>
<header class="topbar">
<a class="brand" href="/#/">
<span class="brand-logo" aria-hidden="true"></span>
<span><span class="brand-title">지역사회간호학 개념 우주</span><br><span class="brand-sub">교과서 색인으로 만든 개념 지도</span></span>
</a>
<nav class="topnav" aria-label="주요 메뉴">
<a href="/#/" aria-current="page">개념 우주</a>
<a href="/#/path">경로 찾기</a>
<a href="/#/compare">비교하기</a>
<a href="/#/quiz">문제 풀기</a>
<a href="/feedback" data-feedback-link>오류 신고</a>
<a href="/login" data-account>로그인</a>
</nav>
</header>
<div class="search-zone">
<div class="search-area">
<form class="search-form" role="search" autocomplete="off">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 16l4.5 4.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<label class="sr-only" for="q">용어, 동의어, 설명 속 단어, 초성으로 찾기</label>
<input id="q" type="search" placeholder="용어·동의어·설명 속 단어로 찾기 (초성 ㄱㄱㅈㅈ도 가능)" role="combobox" aria-expanded="false" aria-controls="results" aria-autocomplete="list">
<button type="submit" class="search-submit">찾기</button>
</form>
<div class="results" id="results" role="listbox" hidden></div>
</div>
<p class="search-note">단어가 떠오르지 않으면<br>아래 은하에서 주제를 골라 보세요.</p>
</div>
<main class="atlas">
<section class="map-wrap" aria-label="개념 우주 지도">
<div class="map-bar">
<nav class="crumbs" aria-label="현재 위치"></nav>
<div class="map-tools">
<div class="seg" role="group" aria-label="보기 방식">
<button type="button" data-mode="map" aria-pressed="true">지도</button>
<button type="button" data-mode="list" aria-pressed="false">목록</button>
</div>
<span class="zoom-tools">
<button type="button" class="icon-btn" data-zoom="in" aria-label="확대">+</button>
<button type="button" class="icon-btn" data-zoom="out" aria-label="축소">−</button>
<button type="button" class="icon-btn" data-zoom="fit" aria-label="화면에 맞추기">⤢</button>
</span>
</div>
</div>
<div class="map" tabindex="-1">
<div class="starfield" aria-hidden="true"></div>
<svg class="links" aria-hidden="true">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="context-stroke"/></marker>
<marker id="arrow-hi" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#d9c9ff"/></marker>
</defs>
<g class="links-g"></g>
</svg>
<div class="objects"></div>
<div class="map-loading">우주를 불러오는 중…</div>
</div>
<div class="listview" hidden></div>
<div class="map-bottom">
<div class="legend">
<span><i class="lg-planet"></i>은하 · 단원</span>
<span><i class="lg-cluster"></i>별자리 · 절</span>
<span><i class="lg-dot"></i>별 · 용어</span>
<span><i class="lg-line"></i>선 · 개념 관계</span>
<span><i class="lg-arrow"></i>순서·방향</span>
<span><i class="lg-dash"></i>구분할 개념</span>
<span><i class="lg-cross"></i>다른 은하·절로 이어짐</span>
</div>
<span class="map-hint"></span>
</div>
<div class="trail" aria-label="최근 살펴본 용어"></div>
</section>
<aside class="detail" id="detail" tabindex="-1" aria-label="설명"></aside>
</main>
<footer class="site-footer">
<span class="footer-data"></span>
<span>화면의 거리는 배치상의 거리일 뿐 개념의 유사도를 뜻하지 않습니다. 관계는 이름이 붙은 선으로만 나타냅니다.</span>
</footer>
<div class="sr-only" aria-live="polite" id="announce"></div>
<dialog class="compare-dialog" aria-label="개념 비교"></dialog>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>로그인 · 지역사회간호학 개념 우주</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/account.css">
<script type="module" src="/static/js/login.js"></script>
</head>
<body>
<header class="topbar">
<a class="brand" href="/">
<span class="brand-logo" aria-hidden="true"></span>
<span><span class="brand-title">지역사회간호학 개념 우주</span><br><span class="brand-sub">교과서 색인으로 만든 개념 지도</span></span>
</a>
<nav class="topnav" aria-label="주요 메뉴">
<a href="/">개념 우주</a>
<a href="/feedback">오류 신고</a>
<a href="/login" aria-current="page">로그인</a>
<a href="/signup">회원 가입</a>
</nav>
</header>
<main class="auth-wrap" id="main"></main>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>회원 가입 · 지역사회간호학 개념 우주</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/account.css">
<script type="module" src="/static/js/signup.js"></script>
</head>
<body>
<header class="topbar">
<a class="brand" href="/">
<span class="brand-logo" aria-hidden="true"></span>
<span><span class="brand-title">지역사회간호학 개념 우주</span><br><span class="brand-sub">교과서 색인으로 만든 개념 지도</span></span>
</a>
<nav class="topnav" aria-label="주요 메뉴">
<a href="/">개념 우주</a>
<a href="/feedback">오류 신고</a>
<a href="/login">로그인</a>
<a href="/signup" aria-current="page">회원 가입</a>
</nav>
</header>
<main class="auth-wrap" id="main"></main>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
.acc-wrap { max-width: 860px; margin: 0 auto; padding: 28px 16px 60px; }
.acc-wrap h1 { font-size: 22px; margin: 0 0 6px; }
.acc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
.acc-grid .card h2 { font-size: 16px; margin: 0 0 12px; }
.acc-note { font-size: 13px; color: var(--muted); }
.acc-status { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.acc-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-top: 12px; }
.acc-stat { border: 1px solid var(--border-soft); border-radius: 10px; padding: 10px 12px; background: var(--panel-2); }
.acc-stat b { display: block; font-size: 20px; }
.acc-stat small { color: var(--muted); }
.bar { height: 6px; border-radius: 3px; background: var(--border-soft); overflow: hidden; margin-top: 6px; }
.bar i { display: block; height: 100%; background: var(--accent-strong); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips a { border: 1px solid var(--border); border-radius: 99px; padding: 2px 10px; font-size: 12.5px; text-decoration: none; color: var(--text); }
.hp { position: absolute; left: -5000px; }
@media (max-width: 720px) { .acc-grid { grid-template-columns: 1fr; } }
.auth-wrap { max-width: 420px; margin: 0 auto; padding: 40px 16px 60px; }
.auth-wrap h1 { font-size: 22px; margin: 0 0 6px; }
.auth-wrap .card { margin-top: 16px; }
.auth-wrap .btn.primary { width: 100%; justify-content: center; margin-top: 4px; }
.auth-alt { text-align: center; margin-top: 16px; font-size: 13.5px; color: var(--muted); }
+209
View File
@@ -0,0 +1,209 @@
/* 관리 화면 — base.css 토큰을 그대로 쓰는 어두운 우주 테마 */
:root { --topbar-h: 59px; --tabs-h: 45px; }
.skip-link { position: absolute; left: -9999px; top: 8px; z-index: 100; background: var(--panel); padding: 6px 10px; border-radius: 8px; }
.skip-link:focus { left: 8px; }
body { background: radial-gradient(1200px 600px at 85% -10%, color-mix(in srgb, var(--accent-strong) 10%, transparent), transparent 60%), var(--bg); background-attachment: fixed; min-height: 100vh; }
h1 { font-size: 21px; margin: 0; letter-spacing: -.4px; }
h2 { font-size: 15px; margin: 0 0 10px; font-weight: 600; }
code { font-size: 12px; background: var(--panel-2); padding: 1px 5px; border-radius: 5px; }
.small { font-size: 12px; }
.nowrap { white-space: nowrap; }
.row.nowrap { flex-wrap: nowrap; }
.break { word-break: break-all; }
.pre { white-space: pre-wrap; word-break: break-word; }
.ok { color: var(--ok); }
.c { text-align: center; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.dim td, li.dim { opacity: .55; }
.edited { color: var(--accent); }
.badge.warnish { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
.hint { display: inline-block; margin-left: 5px; font-size: 10.5px; color: var(--faint); border: 1px solid var(--border-soft); border-radius: 5px; padding: 0 4px; vertical-align: middle; }
.clamp1, .clamp2 { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; }
.clamp1 { -webkit-line-clamp: 1; line-clamp: 1; }
.clamp2 { -webkit-line-clamp: 2; line-clamp: 2; }
/* 상단 바 사용자 */
.who { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); }
/* 로그인 */
.login-wrap { display: grid; place-items: center; padding: 60px 16px; }
.login-card { width: 100%; max-width: 380px; }
.login-card h1 { margin-bottom: 4px; }
.login-card .btn { width: 100%; justify-content: center; margin-top: 4px; }
.login-hint { font-size: 12px; margin: 12px 0 0; }
/* 탭 */
.tabs { display: flex; gap: 2px; padding: 0 18px; border-bottom: 1px solid var(--border-soft); background: color-mix(in srgb, var(--bg) 92%, transparent); overflow-x: auto; scrollbar-width: thin; position: sticky; top: var(--topbar-h); z-index: 15; backdrop-filter: blur(6px); }
.tabs a { padding: 11px 13px 10px; color: var(--muted); text-decoration: none; font-size: 13.5px; border-bottom: 2px solid transparent; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; }
.tabs a:hover { color: var(--text); }
.tabs a[aria-current="page"] { color: var(--text); border-bottom-color: var(--accent); }
.tabs .count { font-size: 11px; background: var(--accent-strong); color: #fff; border-radius: 99px; padding: 0 6px; line-height: 17px; }
.view { padding: 18px 22px 60px; max-width: 1600px; margin: 0 auto; }
.view:focus { outline: none; }
.view > * + *, #dash > * + *, #st-root > * + * { margin-top: 14px; }
.page-head { margin-bottom: 4px; }
.page-head p { margin: 4px 0 0; }
.state { padding: 28px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: var(--radius); }
.state.error { color: var(--danger); border-color: var(--danger); }
/* 필터 바 (고정) */
.filters { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; position: sticky; top: calc(var(--topbar-h) + var(--tabs-h)); z-index: 10; background: color-mix(in srgb, var(--bg) 94%, transparent); backdrop-filter: blur(6px); }
.filters > label { min-width: 130px; }
.filters > label.grow { min-width: 200px; flex: 1 1 220px; }
.filters .input { padding: 6px 10px; }
.bulkbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px 12px; border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent); background: color-mix(in srgb, var(--accent-strong) 12%, var(--panel)); border-radius: 10px; position: sticky; top: calc(var(--topbar-h) + var(--tabs-h) + 54px); z-index: 9; }
.bulkbar #tf-selcount, .bulkbar #rf-selcount { font-weight: 600; margin-right: 6px; }
/* 표 */
.table-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--panel); }
.table-wrap table.data th { position: static; }
table.data td.chk, table.data th.chk { width: 30px; }
table.data.clickable tbody tr { cursor: pointer; }
table.data tr.sel td { background: color-mix(in srgb, var(--accent-strong) 16%, transparent); }
table.data tr:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
table.data tr.saved td { transition: background .6s; }
.msg-cell { max-width: 420px; min-width: 200px; }
.input.compact { padding: 4px 7px; font-size: 12.5px; border-radius: 7px; }
.rel-table td { vertical-align: middle; }
.rel-pair { min-width: 220px; }
.arrow { color: var(--accent); font-weight: 600; }
.pager { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px 2px; font-size: 12px; }
/* 대시보드 */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
.stat .big { font-size: 28px; font-weight: 600; letter-spacing: -.5px; line-height: 1.2; margin-bottom: 8px; }
.stat .big small { font-size: 13px; color: var(--muted); font-weight: 400; margin-left: 3px; }
.kv { list-style: none; margin: 0; padding: 0; }
.kv li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); font-size: 13px; }
.kv li:last-child { border-bottom: 0; }
.kv li b { font-variant-numeric: tabular-nums; font-weight: 600; }
.kv.cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0 18px; margin-top: 12px; }
.meter { flex: 1; min-width: 60px; height: 7px; border-radius: 99px; background: var(--bg-2); overflow: hidden; border: 1px solid var(--border-soft); margin: 4px 0; }
.meter span { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-strong), var(--ok)); border-radius: 99px; }
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 7px; vertical-align: middle; }
/* 목록 + 상세 */
.split { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
.split-side .card { position: relative; }
@media (min-width: 1100px) {
.split.has-detail { grid-template-columns: minmax(0, 1fr) minmax(380px, 460px); }
.split-side { position: sticky; top: calc(var(--topbar-h) + var(--tabs-h) + 60px); align-self: start; max-height: calc(100vh - var(--topbar-h) - var(--tabs-h) - 76px); overflow: auto; border-radius: var(--radius); }
}
.detail-head h2 { margin: 0; }
.dl { display: grid; grid-template-columns: 80px minmax(0, 1fr); gap: 8px 12px; margin: 12px 0 16px; font-size: 13px; }
.dl dt { color: var(--muted); font-size: 12px; }
.dl dd { margin: 0; min-width: 0; }
.danger-zone { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border-soft); justify-content: flex-end; }
details.json summary { cursor: pointer; color: var(--link); font-size: 12px; }
details.json pre { margin: 6px 0 0; padding: 8px 10px; background: var(--bg-2); border: 1px solid var(--border-soft); border-radius: 8px; max-height: 320px; overflow: auto; font-size: 11.5px; white-space: pre-wrap; word-break: break-word; max-width: 560px; }
/* 용어 편집 */
.editor { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
.editor-main > * + *, .editor-side > * + * { margin-top: 14px; }
@media (min-width: 1100px) {
.editor { grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); align-items: start; }
}
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
.grid3 { display: grid; grid-template-columns: 1fr 1.6fr 1.4fr; gap: 0 12px; }
textarea.input.short { min-height: 54px; }
.method { display: inline-block; margin-left: 6px; font-size: 11px; padding: 0 6px; border-radius: 6px; background: var(--panel-2); border: 1px solid var(--border-soft); color: var(--muted); }
fieldset.radios { border: 0; padding: 0; margin: 0 0 12px; display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: center; }
fieldset.radios legend { font-size: 12px; color: var(--muted); margin-bottom: 6px; padding: 0; width: 100%; }
fieldset.radios p { width: 100%; margin: 0 0 6px; }
fieldset.radios.vertical { flex-direction: column; align-items: flex-start; }
fieldset.radios label, label.check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
label.check { margin-bottom: 12px; }
.savebar { position: sticky; bottom: 0; padding: 10px 0 2px; background: var(--panel); }
.field .lbl { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
table.sources td { vertical-align: top; }
table.sources .num-in { width: 72px; padding: 5px 7px; }
table.sources select.input { min-width: 110px; padding: 5px 7px; }
table.sources .src-text { min-width: 240px; }
table.sources .src-text .input + .input { margin-top: 4px; }
table.sources .src-act { white-space: nowrap; }
table.sources .src-act .btn { margin: 0 0 4px; }
ul.rels, ul.mini { list-style: none; margin: 0; padding: 0; }
ul.rels li { padding: 9px 0; border-bottom: 1px solid var(--border-soft); }
ul.rels li:last-child { border-bottom: 0; }
.rel-edit { display: grid; grid-template-columns: 1fr 1.3fr 0.9fr auto auto; gap: 5px; margin-top: 6px; }
.rel-edit .input { padding: 4px 7px; font-size: 12.5px; }
ul.mini li { padding: 7px 0; border-bottom: 1px solid var(--border-soft); font-size: 13px; }
ul.mini li:last-child { border-bottom: 0; }
details.addbox > summary { cursor: pointer; color: var(--link); font-size: 13px; margin-top: 8px; }
details.addbox[open] > summary { margin-bottom: 10px; }
details.card.addbox > summary { margin-top: 0; font-weight: 500; }
#merge-picker { margin-bottom: 8px; }
/* 관계 추가 */
.ra-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 12px; align-items: end; }
.ra-grid .ra-btn { margin-bottom: 12px; }
/* 은하·단원 */
.g-form { display: flex; flex-wrap: wrap; gap: 0 12px; align-items: flex-end; }
.g-form .field { margin-bottom: 10px; }
.g-form .g-no { font-size: 22px; font-weight: 600; color: var(--accent); align-self: center; min-width: 26px; }
.g-form input.color { width: 52px; height: 36px; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-2); cursor: pointer; }
.galaxy details > summary { cursor: pointer; color: var(--muted); font-size: 13px; margin: 4px 0 8px; }
/* 설정 */
.settings { max-width: 760px; }
.settings > * + * { margin-top: 12px; }
/* 자동 완성 */
.ac { position: relative; }
.ac-list { position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 40; list-style: none; margin: 0; padding: 4px; max-height: 280px; overflow: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow); }
.ac-list li { padding: 6px 9px; border-radius: 7px; cursor: pointer; display: flex; flex-wrap: wrap; gap: 2px 6px; align-items: baseline; font-size: 13px; }
.ac-list li[aria-selected="true"], .ac-list li[data-i]:hover { background: var(--panel-2); }
.ac-list .ac-sub { margin-left: auto; font-size: 11px; color: var(--faint); }
.ac-list .ac-empty { cursor: default; color: var(--muted); }
@media (max-width: 900px) {
.grid3 { grid-template-columns: 1fr; }
.ra-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 720px) {
.view { padding: 14px 12px 48px; }
.tabs { padding: 0 8px; }
.grid2, .ra-grid { grid-template-columns: 1fr; }
.filters { position: static; }
.bulkbar { top: calc(var(--topbar-h) + var(--tabs-h)); }
.filters > label { flex: 1 1 140px; min-width: 0; }
.rel-edit { grid-template-columns: 1fr 1fr; }
.dl { grid-template-columns: 1fr; gap: 2px; }
.dl dd { margin-bottom: 8px; }
.who-name { display: none; }
.card { padding: 13px; }
}
/* 회원·반, 문제 */
.split-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 900px) { .split-2 { grid-template-columns: 1fr; } }
.pbar { height: 6px; border-radius: 3px; background: var(--border-soft); overflow: hidden; margin: 3px 0 10px; }
.pbar i { display: block; height: 100%; background: var(--accent-strong); }
.q-ac { position: relative; z-index: 5; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 9px; background: var(--panel); margin-top: 4px; max-height: 260px; overflow: auto; }
.q-ac[hidden] { display: none; }
.q-ac button { text-align: left; border: 0; background: none; padding: 7px 10px; cursor: pointer; color: var(--text); }
.q-ac button:hover { background: var(--panel-2); }
.q-prev { border: 1px solid var(--border-soft); border-radius: 9px; padding: 8px 12px; margin-bottom: 8px; }
.q-prev ol { margin: 4px 0 0; padding-left: 20px; }
.ok-text { color: var(--ok); font-weight: 600; }
.warn-text { color: var(--warn); }
ul.plain { list-style: none; padding: 0; margin: 0; }
ul.plain li { padding: 3px 0; }
section.card + section.card { margin-top: 16px; }
.subtabs { display: flex; gap: 4px; flex-wrap: wrap; margin: 0 0 14px; border-bottom: 1px solid var(--border-soft); }
.subtabs a { padding: 7px 12px; text-decoration: none; color: var(--muted); border-bottom: 2px solid transparent; font-size: 13.5px; }
.subtabs a[aria-current] { color: var(--text); border-bottom-color: var(--accent); }
.subtabs .count { margin-left: 4px; }
.code-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0 12px; align-items: end; }
.code-form .btn { height: 38px; margin-bottom: 12px; }
.big-code { font-size: 15px; font-weight: 700; letter-spacing: 1px; }
.filters-inline { margin-bottom: 10px; }
.quick-code { margin-bottom: 16px; border-color: color-mix(in srgb, var(--accent) 45%, var(--border-soft)); }
.quick-code h2 { margin: 0 0 10px; }
.qc-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.qc-item { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-soft); border-radius: 10px; padding: 6px 10px; background: var(--panel-2); }
.qc-made { margin-top: 10px; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--ok) 12%, var(--panel)); border: 1px solid color-mix(in srgb, var(--ok) 45%, transparent); }
+306
View File
@@ -0,0 +1,306 @@
/* 개념 우주 메인 화면 */
body { background: radial-gradient(ellipse at 50% 0%, #15123055, transparent 60%), var(--bg); }
.skip-link { position: absolute; left: -999px; top: 0; z-index: 100; background: var(--panel); padding: 8px 12px; }
.skip-link:focus { left: 8px; top: 8px; }
/* 검색 */
.search-zone { padding: 16px 22px 12px; display: flex; gap: 18px; align-items: center; }
.search-area { position: relative; flex: 1; min-width: 0; z-index: 30; max-width: 860px; }
.search-form { display: flex; align-items: center; gap: 10px; border: 1px solid #465077; border-radius: 12px; background: var(--panel-2); padding: 8px 8px 8px 14px; }
.search-form:focus-within { border-color: var(--accent); }
.search-form svg { width: 19px; height: 19px; flex: none; color: #b6accd; }
.search-form input { background: transparent; border: 0; outline: none; min-width: 0; width: 100%; padding: 4px 0; font-size: 15px; }
.search-form input::placeholder { color: var(--faint); }
.search-submit { background: #d2c7fa; border: 0; border-radius: 8px; padding: 6px 16px; color: #1a1530; white-space: nowrap; font-weight: 600; cursor: pointer; }
.search-note { font-size: 12px; color: var(--muted); margin: 0; max-width: 220px; }
.results { position: absolute; left: 0; right: 0; top: calc(100% + 6px); border: 1px solid #4b5577; background: var(--panel); border-radius: 12px; padding: 6px; box-shadow: 0 16px 50px #0009; max-height: min(70vh, 560px); overflow: auto; }
.result-head { display: flex; justify-content: space-between; align-items: center; padding: 5px 8px 7px; color: var(--muted); font-size: 12px; gap: 8px; }
.result-head button { border: 0; background: transparent; color: var(--muted); cursor: pointer; padding: 2px 6px; }
.result { width: 100%; text-align: left; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; cursor: pointer; }
.result:hover, .result[aria-selected="true"] { background: var(--panel-2); outline: 1px solid var(--border); }
.result strong { font-weight: 600; }
.result small { display: block; color: var(--muted); font-size: 12px; }
.result .why { font-size: 11px; color: var(--accent); white-space: nowrap; }
.result mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: inherit; border-radius: 3px; padding: 0 1px; }
.results .empty { margin: 0; padding: 10px; font-size: 13px; color: var(--muted); }
.results .more { width: 100%; margin-top: 4px; }
/* 본문 2단 */
.atlas { display: grid; grid-template-columns: minmax(0, 1fr) 380px; border-top: 1px solid var(--border-soft); min-height: calc(100vh - 150px); }
.map-wrap { min-width: 0; position: relative; display: flex; flex-direction: column;
/* 지도는 밝은 모드에서도 항상 어두운 우주 */
--bg: #070c1b; --bg-2: #0b1124; --panel: #10172a; --panel-2: #151d34; --border: #27314c; --border-soft: #1c2540;
--text: #edf1ff; --muted: #a4b0cf; --faint: #6f7a99; --accent: #c2b4ff; --link: #a9c8ff; color-scheme: dark; color: var(--text); background: var(--bg); }
.map-bar { position: absolute; left: 16px; right: 16px; top: 12px; z-index: 6; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; pointer-events: none; }
.map-bar > * { pointer-events: auto; }
.crumbs { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 12px; color: var(--muted); background: color-mix(in srgb, var(--bg) 75%, transparent); border-radius: 9px; padding: 3px; backdrop-filter: blur(4px); }
.crumbs a, .crumbs span { padding: 4px 8px; border-radius: 7px; text-decoration: none; color: var(--muted); }
.crumbs a:hover { color: var(--text); background: var(--panel-2); }
.crumbs a:first-child { border: 1px solid var(--border); background: var(--panel); color: var(--text); }
.crumbs [aria-current] { color: var(--accent); }
.crumbs .sep { padding: 0; color: var(--faint); }
.map-tools { display: flex; gap: 8px; align-items: center; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: var(--panel); }
.seg button { border: 0; background: transparent; padding: 5px 11px; font-size: 12px; color: var(--muted); cursor: pointer; }
.seg button[aria-pressed="true"] { background: var(--panel-2); color: var(--text); }
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); color: var(--text); cursor: pointer; font-size: 15px; line-height: 1; }
.icon-btn:hover { border-color: var(--accent); }
.zoom-tools { display: inline-flex; gap: 4px; }
.list-mode .zoom-tools { display: none; }
.map { position: relative; flex: 1; min-height: 560px; overflow: hidden; touch-action: none; cursor: grab;
background: radial-gradient(ellipse at 48% 48%, #29204444, transparent 55%), radial-gradient(ellipse at 78% 15%, #143a482a, transparent 35%), #080d1c; }
.map.dragging { cursor: grabbing; }
.starfield, .links, .objects { position: absolute; inset: 0; width: 100%; height: 100%; }
.starfield { pointer-events: none; overflow: hidden; }
.spark { position: absolute; width: 2px; height: 2px; background: #d3def6; border-radius: 50%; opacity: .2; }
.spark.big { width: 3px; height: 3px; opacity: .55; box-shadow: 0 0 8px #d9e3ff66; }
.links { pointer-events: none; overflow: visible; }
.objects { pointer-events: none; }
.objects > * { pointer-events: auto; left: 0; top: 0; will-change: transform; }
.dots { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.map-loading { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); }
.list-mode .map { display: none; }
/* 은하 */
.galaxy { position: absolute; transform: translate(-50%, -18px); width: 190px; border: 0; background: transparent; padding: 38px 6px 6px; text-align: center; cursor: pointer; z-index: 3; color: var(--text); border-radius: 14px; }
.galaxy::before { content: ""; position: absolute; left: 50%; top: 4px; transform: translateX(-50%); width: 24px; height: 24px; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, var(--glow) 33%, #222741 90%);
box-shadow: 0 0 20px 6px color-mix(in srgb, var(--glow) 55%, transparent), 0 0 70px 28px color-mix(in srgb, var(--glow) 16%, transparent); }
.galaxy::after { content: ""; position: absolute; top: -2px; left: 50%; width: 92px; height: 36px; border: 1px solid color-mix(in srgb, var(--glow) 40%, transparent); border-radius: 50%; transform: translateX(-50%) rotate(-24deg); pointer-events: none; }
.galaxy:hover::before, .galaxy:focus-visible::before { box-shadow: 0 0 26px 9px color-mix(in srgb, var(--glow) 70%, transparent), 0 0 80px 30px color-mix(in srgb, var(--glow) 22%, transparent); }
.galaxy .g-name { display: block; font-size: 15px; font-weight: 600; letter-spacing: -.4px; text-shadow: 0 2px 6px #070c1b; }
.galaxy .g-sub { display: block; font-size: 11.5px; color: #bbc4dc; margin-top: 1px; }
.galaxy .g-count { display: block; font-size: 11px; color: var(--faint); margin-top: 2px; }
/* 은하 중심(현재 은하 표시) */
.focus-title { position: absolute; transform: translate(-50%, -50%); text-align: center; pointer-events: none; z-index: 1; width: 220px; }
.focus-title .planet { display: block; width: 46px; height: 46px; margin: 0 auto 10px; border-radius: 50%;
background: radial-gradient(circle at 28% 23%, #fff, var(--glow) 30%, #28274b 72%, #11162b); box-shadow: 0 0 60px 18px color-mix(in srgb, var(--glow) 18%, transparent); }
.focus-title strong { display: block; font-weight: 600; font-size: 17px; }
.focus-title small { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
/* 별자리(절) */
.cluster { position: absolute; transform: translate(-50%, -50%); border-radius: 50%; border: 1px dashed color-mix(in srgb, var(--glow) 45%, transparent);
background: radial-gradient(circle, color-mix(in srgb, var(--glow) 12%, transparent), transparent 70%); cursor: pointer; display: grid; place-items: center; text-align: center; padding: 10px; color: var(--text); z-index: 2; }
.cluster:hover, .cluster:focus-visible { border-style: solid; background: radial-gradient(circle, color-mix(in srgb, var(--glow) 22%, transparent), transparent 72%); }
.cluster .c-name { font-size: 13.5px; font-weight: 600; line-height: 1.35; text-shadow: 0 1px 4px #000; }
.cluster .c-sub { font-size: 11px; color: var(--muted); display: block; margin-top: 2px; }
.cluster .c-count { font-size: 11px; color: var(--faint); display: block; }
.item-label { position: absolute; transform: translate(-50%, 0); font-size: 11.5px; color: color-mix(in srgb, var(--glow) 70%, var(--muted)); white-space: nowrap; pointer-events: auto; z-index: 1; background: none; border: 0; cursor: default; letter-spacing: -.2px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
/* 별(용어) */
.star { position: absolute; transform: translate(-50%, -6px); display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 2px 4px; border: 0; background: transparent; white-space: nowrap; z-index: 4; min-width: 24px; min-height: 24px; cursor: pointer; color: var(--text); }
.star .dot { display: block; width: 7px; height: 7px; background: var(--glow); border-radius: 50%; box-shadow: 0 0 10px color-mix(in srgb, var(--glow) 65%, transparent); position: relative; flex: none; }
.star.person .dot { border-radius: 2px; transform: rotate(45deg); }
.star .nm { font-size: 12px; color: #c8d0e4; background: #090f21c9; border-radius: 5px; padding: 0 5px; text-shadow: 0 1px 4px #000; }
.star.hide-label .nm { display: none; }
.star.hide-label:hover .nm, .star.hide-label:focus-visible .nm { display: block; position: absolute; top: 14px; z-index: 10; }
.star:hover .nm { color: #fff; }
.star.dim { opacity: .35; }
.star.nb .nm { color: #f0eaff; background: #241f3d; }
.star.nb .dot { box-shadow: 0 0 12px 3px color-mix(in srgb, var(--glow) 70%, transparent); }
.star.selected { z-index: 8; }
.star.selected .dot { width: 12px; height: 12px; background: #fff; box-shadow: 0 0 16px 6px var(--glow); }
.star.selected .dot::after { content: ""; position: absolute; inset: -8px; border: 1px solid var(--glow); border-radius: 50%; }
.star.selected .nm { color: #f7f3ff; background: #332c4d; padding: 2px 9px; font-size: 13.5px; font-weight: 600; display: block; position: static; }
.star.on-path .dot { background: #ffe9a8; box-shadow: 0 0 14px 5px #ffd46b88; }
.star.on-path .nm { display: block; position: static; background: #3a321c; color: #fff4d6; }
.star.mini .nm { font-size: 11px; }
.portal { position: absolute; transform: translate(-50%, -50%); border: 1px dashed var(--border); border-radius: 99px; background: var(--panel); padding: 3px 10px; font-size: 11.5px; color: var(--text); cursor: pointer; white-space: nowrap; z-index: 6; }
.portal small { color: var(--muted); margin-left: 4px; }
.portal:hover { border-color: var(--accent); }
/* 선 */
.links path { fill: none; stroke: #4b4e6c; stroke-width: 1; opacity: .5; }
.links path.t-precedes { stroke: #7c86b8; }
.links path.t-contrasts_with { stroke-dasharray: 4 4; stroke: #b88; }
.links path.t-part_of { stroke: #6a7aa0; }
.links path.draft { opacity: .32; }
.links path.hi { stroke: #bfa1f5; stroke-width: 1.6; opacity: .95; }
.links path.hi.t-contrasts_with { stroke: #ff9e9e; }
.links path.path-hi { stroke: #ffd46b; stroke-width: 2.2; opacity: 1; }
.links path.bridge { stroke: #8d86c2; opacity: .22; }
.links text { font-size: 11px; fill: #d6c8f5; paint-order: stroke; stroke: #0b1020; stroke-width: 5px; stroke-linejoin: round; text-anchor: middle; }
/* 아래 막대 */
.map-bottom { padding: 9px 16px; display: flex; justify-content: space-between; align-items: center; gap: 10px; background: var(--bg-2); border-top: 1px solid var(--border-soft); font-size: 11px; color: var(--muted); flex-wrap: wrap; }
.legend { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.legend span { display: flex; gap: 6px; align-items: center; }
.lg-dot { width: 6px; height: 6px; display: inline-block; border-radius: 50%; background: #d7c5ff; box-shadow: 0 0 5px #ae9de877; }
.lg-planet { width: 11px; height: 11px; display: inline-block; border: 1px solid #c6b4f2; border-radius: 50%; background: #736499; }
.lg-cluster { width: 13px; height: 13px; display: inline-block; border: 1px dashed #c6b4f2; border-radius: 50%; }
.lg-line { display: inline-block; width: 18px; height: 1px; background: #b7a0f2; }
.lg-arrow { display: inline-block; width: 16px; height: 1px; background: #b7a0f2; position: relative; }
.lg-arrow::after { content: ""; position: absolute; right: -2px; top: -3px; border: 3.5px solid transparent; border-left: 5px solid #b7a0f2; }
.lg-dash { display: inline-block; width: 18px; border-top: 1px dashed #d99; }
.trail { display: flex; gap: 6px; align-items: center; padding: 8px 16px; border-top: 1px solid var(--border-soft); font-size: 12px; color: var(--muted); flex-wrap: wrap; min-height: 40px; }
.trail .chip { border: 1px solid var(--border); background: var(--panel); border-radius: 99px; padding: 2px 10px; font-size: 12px; cursor: pointer; color: var(--text); text-decoration: none; }
.trail .chip:hover { border-color: var(--accent); }
.trail .clear { border: 0; background: none; color: var(--faint); cursor: pointer; font-size: 11px; }
/* 목록 보기 */
.listview { flex: 1; padding: 60px 22px 22px; overflow: auto; max-height: calc(100vh - 160px); min-height: 560px; }
.listview h2 { font-size: 18px; margin: 0 0 4px; }
.listview h3 { font-size: 14px; margin: 18px 0 6px; color: var(--accent); font-weight: 600; }
.listview .lv-tabs { margin-bottom: 14px; }
.lv-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }
.lv-card { display: block; text-decoration: none; color: var(--text); border: 1px solid var(--border-soft); background: var(--panel); border-radius: 11px; padding: 10px 13px; border-left: 3px solid var(--glow, var(--accent)); }
.lv-card:hover { border-color: var(--accent); }
.lv-card small { color: var(--muted); display: block; }
.lv-terms { display: flex; flex-wrap: wrap; gap: 6px; }
.lv-terms a { text-decoration: none; color: var(--text); border: 1px solid var(--border-soft); background: var(--panel); padding: 2px 9px; border-radius: 99px; font-size: 12.5px; }
.lv-terms a:hover, .lv-terms a[aria-current] { border-color: var(--accent); color: var(--accent); }
.lv-index-nav { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
.lv-index-nav a { padding: 2px 8px; border: 1px solid var(--border-soft); border-radius: 6px; text-decoration: none; color: var(--muted); font-size: 12px; }
/* 설명 패널 */
.detail { background: var(--panel); border-left: 1px solid var(--border); padding: 22px 20px 30px; min-width: 0; overflow: auto; max-height: calc(100vh - 120px); position: sticky; top: 59px; }
.detail:focus { outline: none; }
.kicker { font-size: 11px; color: #c4b3ee; letter-spacing: 1px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.kicker::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--glow, #c3aafa); box-shadow: 0 0 8px var(--glow, #bca3fa); }
.d-path { color: var(--muted); font-size: 12px; margin-bottom: 3px; }
.d-path a { color: var(--muted); }
.detail h2 { font-size: 23px; line-height: 1.35; letter-spacing: -.6px; font-weight: 600; margin: 0 0 6px; word-break: keep-all; }
.detail h2 .badge { font-size: 11px; font-weight: 400; margin-left: 6px; letter-spacing: 0; }
.d-alias { font-size: 12px; color: var(--muted); }
.d-sec { margin-top: 22px; }
.detail h3 { font-size: 11.5px; font-weight: 600; letter-spacing: .6px; color: #b9a5ed; margin: 0 0 8px; display: flex; gap: 8px; align-items: center; justify-content: space-between; }
:root[data-theme="light"] .detail h3 { color: var(--accent); }
.detail p { font-size: 13.5px; line-height: 1.8; margin: 0; word-break: keep-all; overflow-wrap: anywhere; }
.d-none { color: var(--faint); font-size: 13px; }
.quote { border-left: 2px solid #61527e; padding-left: 12px; }
.quote mark, .snip mark { background: color-mix(in srgb, var(--accent) 25%, transparent); color: inherit; border-radius: 3px; }
.cite { display: block; margin-top: 4px; font-size: 11.5px; color: var(--muted); }
.cite a { color: var(--link); }
.example { padding-left: 12px; border-left: 2px solid #4f7a6a; }
.rel-group + .rel-group { margin-top: 12px; }
.rel-group h4 { font-size: 11.5px; color: var(--muted); font-weight: 500; margin: 0 0 6px; }
.neighbors { display: flex; flex-direction: column; gap: 6px; }
.neighbor { border: 1px solid var(--border); background: var(--panel-2); border-radius: 9px; padding: 8px 11px; text-align: left; display: block; width: 100%; cursor: pointer; color: var(--text); text-decoration: none; }
.neighbor:hover { border-color: #8877b2; }
.neighbor .n-name { display: flex; justify-content: space-between; gap: 6px; font-size: 13.5px; }
.neighbor small { display: block; font-size: 11.5px; line-height: 1.55; color: var(--muted); margin-top: 2px; }
.neighbor .dir { color: var(--accent); font-size: 12px; }
.sources { display: flex; flex-direction: column; gap: 8px; }
.source { font-size: 12.5px; border: 1px solid var(--border-soft); border-radius: 9px; padding: 8px 10px; background: var(--bg-2); }
.source .s-head { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 11.5px; }
.source .s-path { color: var(--faint); font-size: 11px; }
.source .snip { margin-top: 4px; line-height: 1.65; }
.d-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 22px; }
.d-actions .btn { justify-content: center; }
.d-actions .wide { grid-column: span 2; }
.more-btn { border: 0; background: none; color: var(--link); cursor: pointer; font-size: 12px; padding: 4px 0; }
.intro h2 { font-size: 20px; }
.intro ol { padding-left: 18px; margin: 8px 0; font-size: 13px; line-height: 1.8; }
.galaxy-list { display: flex; flex-direction: column; gap: 6px; }
.galaxy-list a { display: flex; justify-content: space-between; gap: 8px; text-decoration: none; color: var(--text); border: 1px solid var(--border-soft); background: var(--panel-2); border-radius: 9px; padding: 7px 11px; border-left: 3px solid var(--glow); }
.galaxy-list a small { color: var(--muted); display: block; font-size: 11.5px; }
.galaxy-list a .cnt { color: var(--faint); font-size: 11.5px; white-space: nowrap; }
.galaxy-list a:hover { border-color: var(--accent); }
/* 경로 찾기·비교 */
.picker { position: relative; margin-bottom: 10px; }
.picker label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 3px; }
.picker .results { top: calc(100% + 2px); }
.path-steps { list-style: none; padding: 0; margin: 12px 0 0; }
.path-steps li { position: relative; padding-left: 20px; }
.path-steps li::before { content: ""; position: absolute; left: 4px; top: 8px; width: 8px; height: 8px; border-radius: 50%; background: #ffd46b; }
.path-steps li + li { margin-top: 2px; }
.path-steps .edge { font-size: 12px; color: var(--muted); padding: 4px 0 6px 0; border-left: 1px dashed #ffd46b66; margin-left: -12px; padding-left: 16px; }
.compare-dialog { width: min(1100px, calc(100vw - 32px)); max-height: calc(100vh - 40px); border: 1px solid var(--border); background: var(--panel); color: var(--text); border-radius: 16px; padding: 0; }
.compare-dialog::backdrop { background: #000a; }
.cmp-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border-soft); gap: 10px; }
.cmp-head h2 { font-size: 17px; margin: 0; }
.cmp-bridge { padding: 12px 18px; background: var(--bg-2); border-bottom: 1px solid var(--border-soft); font-size: 13.5px; }
.cmp-grid { display: grid; grid-template-columns: 1fr 1fr; }
.cmp-col { padding: 16px 18px 22px; min-width: 0; }
.cmp-col + .cmp-col { border-left: 1px solid var(--border-soft); }
.cmp-col h3 { font-size: 12px; color: #b9a5ed; margin: 16px 0 6px; }
.cmp-col h2 { margin: 2px 0; font-size: 20px; }
.site-footer { padding: 10px 22px; background: var(--bg-2); border-top: 1px solid var(--border-soft); font-size: 11.5px; color: var(--faint); display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
@media (max-width: 1100px) {
.atlas { grid-template-columns: minmax(0, 1fr) 330px; }
}
@media (max-width: 820px) {
.atlas { grid-template-columns: 1fr; }
.detail { border-left: 0; border-top: 1px solid var(--border); position: static; max-height: none; }
.map { min-height: 62vh; }
.listview { min-height: 0; max-height: none; }
.search-note { display: none; }
.search-zone { padding: 12px 14px 10px; }
.map-bar { left: 8px; right: 8px; flex-direction: column; align-items: stretch; }
.map-tools { justify-content: space-between; }
.legend span:nth-child(n+5) { display: none; }
.cmp-grid { grid-template-columns: 1fr; }
.cmp-col + .cmp-col { border-left: 0; border-top: 1px solid var(--border-soft); }
}
@media (max-width: 480px) {
.galaxy { width: 140px; }
.galaxy .g-sub { display: none; }
.search-form input { font-size: 14px; }
.search-submit { padding: 6px 10px; }
}
/* 연속 확대: 펼쳐진 은하 제목, 펼쳐진 절 제목, 작은 절 원 */
.galaxy-title { position: absolute; text-align: center; pointer-events: none; z-index: 1; white-space: nowrap; }
.galaxy-title strong { display: block; font-size: 20px; font-weight: 600; letter-spacing: -.5px; color: color-mix(in srgb, var(--glow) 55%, #fff); opacity: .85; text-shadow: 0 2px 10px #000; }
.galaxy-title small { display: block; font-size: 12px; color: var(--muted); }
.sec-title { position: absolute; transform-origin: 50% 100%; font-size: 13px; font-weight: 600; white-space: nowrap; color: color-mix(in srgb, var(--glow) 70%, #fff); text-decoration: none; background: #0a1022cc; padding: 1px 9px; border-radius: 99px; border: 1px solid color-mix(in srgb, var(--glow) 35%, transparent); z-index: 5; }
.sec-title:hover { border-color: var(--glow); }
.cluster { transform: none; }
.cluster .c-body { display: block; padding: 6px; }
.cluster.tiny .c-body { display: none; }
.cluster.tiny { border-style: dotted; }
/* 은하·절을 넘나드는 연결 */
.links path.bundle { stroke: #8d86c2; opacity: .28; stroke-linecap: round; }
.links path.bundle.cross { stroke: #b7a8ff; opacity: .38; }
.links path.cross:not(.bundle):not(.hi) { stroke-dasharray: 5 3; opacity: .6; }
.links path.stub { stroke-width: 1.6; opacity: .75; stroke-linecap: round; stroke-dasharray: 2 3; }
.links path.hi.cross { stroke-dasharray: 6 3; }
.edge-chip { position: absolute; display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 8px; border-radius: 99px; font-size: 11.5px; white-space: nowrap; cursor: pointer; z-index: 7;
color: var(--text); background: #0d1428e6; border: 1px solid color-mix(in srgb, var(--glow) 55%, transparent); box-shadow: 0 2px 10px #0008; }
.edge-chip:hover, .edge-chip:focus-visible { border-color: var(--glow); background: #161f3a; }
.edge-chip i { width: 7px; height: 7px; border-radius: 50%; background: var(--glow); box-shadow: 0 0 6px var(--glow); flex: none; }
.edge-chip b { font-weight: 600; color: var(--glow); }
.edge-chip small { color: var(--muted); }
.edge-chip.named { border-style: solid; }
.edge-chip.inside { opacity: .85; }
.lg-cross { display: inline-block; width: 18px; border-top: 1.5px dotted #b7a8ff; }
.legend span:has(.lg-cross)::after { content: "→ 가장자리 표지"; color: var(--faint); }
.keep-sel { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; margin: -6px 0 16px; padding: 8px 11px; border-radius: 10px; font-size: 12.5px;
border: 1px solid color-mix(in srgb, var(--glow) 50%, transparent); background: color-mix(in srgb, var(--glow) 10%, var(--panel)); }
.keep-sel i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--glow); margin-right: 6px; box-shadow: 0 0 6px var(--glow); }
.keep-sel button { border: 1px solid var(--border); background: var(--panel); border-radius: 7px; padding: 2px 8px; cursor: pointer; font-size: 12px; margin-left: 6px; }
.edge-chip.selected-chip { border-width: 2px; font-weight: 600; }
.map * { touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
@media (max-width: 600px) {
.edge-chip { font-size: 11px; padding: 2px 8px 2px 6px; gap: 4px; }
.sec-title { font-size: 12px; }
}
/* 회원 전용·문제 */
.locked { border: 1px dashed var(--border); border-radius: 10px; padding: 10px 12px; font-size: 13px; color: var(--muted); background: var(--bg-2); }
.question { border: 1px solid var(--border-soft); border-radius: 11px; padding: 12px; background: var(--bg-2); margin-top: 8px; }
.q-prompt { font-weight: 600; font-size: 13.5px; margin: 0 0 6px; }
.q-quote { margin: 0 0 8px; padding: 8px 10px; border-left: 3px solid var(--accent); background: var(--panel); border-radius: 0 8px 8px 0; font-size: 13px; line-height: 1.7; }
.q-choices { display: flex; flex-direction: column; gap: 6px; }
.q-choices.ox { flex-direction: row; }
.q-choices.ox .q-choice { flex: 1; justify-content: center; font-size: 18px; font-weight: 700; }
.q-choice { display: flex; gap: 8px; align-items: flex-start; text-align: left; border: 1px solid var(--border); background: var(--panel); border-radius: 9px; padding: 8px 10px; cursor: pointer; font-size: 13px; line-height: 1.55; color: var(--text); }
.q-choice:hover:not(:disabled) { border-color: var(--accent); }
.q-choice:disabled { cursor: default; }
.q-choice.right { border-color: var(--ok); background: color-mix(in srgb, var(--ok) 14%, var(--panel)); }
.q-choice.wrong { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, var(--panel)); }
.q-no { flex: none; width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--border); display: grid; place-items: center; font-size: 11px; color: var(--muted); }
.q-result { margin-top: 8px; font-size: 13px; line-height: 1.6; }
.q-result .ok { color: var(--ok); }
.q-result .no { color: var(--danger); }
.qz-progress { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.qz-done { text-align: center; padding: 18px 0; }
.qz-done b { display: block; font-size: 32px; }
.qz-done span { color: var(--muted); }
+108
View File
@@ -0,0 +1,108 @@
/* 공통 토큰·기본 요소. 우주 테마(어두운 배경)를 기본으로 하고, 밝은 모드도 지원한다. */
:root {
--bg: #070c1b;
--bg-2: #0b1124;
--panel: #10172a;
--panel-2: #151d34;
--border: #27314c;
--border-soft: #1c2540;
--text: #edf1ff;
--muted: #a4b0cf;
--faint: #6f7a99;
--accent: #c2b4ff;
--accent-strong: #9d86ff;
--link: #a9c8ff;
--ok: #7fd6a4;
--warn: #f2c36b;
--danger: #ff8b8b;
--focus: #d3c7ff;
--shadow: 0 10px 30px rgba(0, 0, 0, .35);
--radius: 12px;
--font: "Pretendard", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f4f5fb; --bg-2: #eceef8; --panel: #ffffff; --panel-2: #f6f7fc; --border: #d7dbea; --border-soft: #e5e8f3;
--text: #1a1d2b; --muted: #555d78; --faint: #8b92aa; --accent: #6b55d9; --accent-strong: #5640c4; --link: #3559c7;
--ok: #1f8a52; --warn: #a86a00; --danger: #c43c3c; --focus: #6b55d9; --shadow: 0 8px 24px rgba(30, 30, 60, .12);
color-scheme: light;
}
}
:root[data-theme="light"] {
--bg: #f4f5fb; --bg-2: #eceef8; --panel: #ffffff; --panel-2: #f6f7fc; --border: #d7dbea; --border-soft: #e5e8f3;
--text: #1a1d2b; --muted: #555d78; --faint: #8b92aa; --accent: #6b55d9; --accent-strong: #5640c4; --link: #3559c7;
--ok: #1f8a52; --warn: #a86a00; --danger: #c43c3c; --focus: #6b55d9; --shadow: 0 8px 24px rgba(30, 30, 60, .12);
color-scheme: light;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; line-height: 1.6; -webkit-font-smoothing: antialiased; }
a { color: var(--link); }
button, input, select, textarea { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
[hidden] { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* 상단 바 */
.topbar { display: flex; align-items: center; gap: 16px; padding: 14px 22px; border-bottom: 1px solid var(--border-soft); background: var(--bg); position: sticky; top: 0; z-index: 20; }
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); min-width: 0; }
.brand-logo { width: 30px; height: 30px; border: 1px solid #726892; border-radius: 50%; position: relative; transform: rotate(-28deg); flex: none; }
.brand-logo::before { content: ""; position: absolute; width: 40px; height: 12px; left: -6px; top: 8px; border: 1px solid #bca5ff; border-radius: 50%; }
.brand-logo::after { content: ""; position: absolute; width: 6px; height: 6px; border-radius: 50%; background: #e6ddff; right: 0; top: 0; box-shadow: 0 0 12px #b393ff; }
.brand-title { font-size: 17px; font-weight: 600; letter-spacing: -.4px; white-space: nowrap; }
.brand-sub { font-size: 12px; color: var(--muted); white-space: nowrap; }
.topnav { display: flex; gap: 4px; margin-left: auto; flex-wrap: wrap; }
.topnav a, .topnav button { padding: 6px 10px; border-radius: 8px; color: var(--muted); text-decoration: none; background: none; border: 0; cursor: pointer; font-size: 13px; }
.topnav a:hover, .topnav button:hover, .topnav a[aria-current="page"] { color: var(--text); background: var(--panel-2); }
/* 버튼·입력 */
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel-2); color: var(--text); cursor: pointer; font-size: 13px; text-decoration: none; line-height: 1.4; }
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent-strong); border-color: var(--accent-strong); color: #fff; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.ghost { background: transparent; }
.btn.danger { color: var(--danger); }
.btn.small { padding: 4px 9px; font-size: 12px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.input, select.input, textarea.input { width: 100%; padding: 8px 11px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-2); color: var(--text); }
textarea.input { min-height: 90px; resize: vertical; }
.input:focus { border-color: var(--accent); outline: none; }
label.field { display: block; margin-bottom: 12px; }
label.field > span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
/* 배지 */
.badge { display: inline-block; padding: 1px 8px; border-radius: 99px; font-size: 11px; border: 1px solid var(--border); color: var(--muted); white-space: nowrap; vertical-align: middle; }
.badge.draft { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
.badge.review { color: var(--link); border-color: color-mix(in srgb, var(--link) 45%, transparent); }
.badge.confirmed { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); }
.badge.rejected { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
.badge.new { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
.badge.in_progress { color: var(--link); border-color: color-mix(in srgb, var(--link) 45%, transparent); }
.badge.resolved { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); }
.badge.duplicate { color: var(--faint); }
.card { background: var(--panel); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 16px 18px; }
.muted { color: var(--muted); }
.faint { color: var(--faint); }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.grow { flex: 1; min-width: 0; }
.stack > * + * { margin-top: 10px; }
/* 알림 */
.toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--border); padding: 10px 16px; border-radius: 10px; box-shadow: var(--shadow); z-index: 1000; font-size: 13px; max-width: calc(100vw - 32px); }
.toast.error { border-color: var(--danger); color: var(--danger); }
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
table.data th, table.data td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--border-soft); vertical-align: top; }
table.data th { color: var(--muted); font-weight: 500; font-size: 12px; position: sticky; top: 0; background: var(--panel); }
table.data tr:hover td { background: color-mix(in srgb, var(--panel-2) 70%, transparent); }
@media (max-width: 720px) {
.topbar { padding: 10px 14px; gap: 10px; flex-wrap: wrap; }
.brand-sub { display: none; }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
+58
View File
@@ -0,0 +1,58 @@
/* 오류 신고 페이지 */
.skip-link { position: absolute; left: -9999px; top: 8px; z-index: 100; background: var(--panel); padding: 6px 10px; border-radius: 8px; }
.skip-link:focus { left: 8px; }
.fb-main { max-width: 860px; margin: 0 auto; padding: 24px 16px 64px; }
.fb-form-card h1 { margin: 0 0 6px; font-size: 22px; letter-spacing: -.4px; }
.fb-lead { margin: 0 0 18px; }
.req { color: var(--danger); font-weight: 500; }
.fb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.field-label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.field-foot { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; margin-top: 4px; }
.fb-target { margin-bottom: 12px; position: relative; }
.fb-actions { margin-top: 6px; }
/* 스팸 방지 숨은 칸 */
.hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
.fb-chosen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 6px; font-size: 13px; padding: 6px 10px; border-radius: 9px; background: var(--panel-2); border: 1px solid var(--border-soft); }
.fb-chosen code { font-size: 12px; }
.fb-done { margin-bottom: 16px; padding: 12px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--ok) 50%, transparent); background: color-mix(in srgb, var(--ok) 10%, transparent); }
.fb-done:focus { outline: none; }
/* 자동 완성 */
.ac { position: relative; }
.ac-list { position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 30; list-style: none; margin: 0; padding: 4px; max-height: 300px; overflow: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow); }
.ac-list li { padding: 7px 9px; border-radius: 7px; cursor: pointer; display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: baseline; }
.ac-list li[aria-selected="true"], .ac-list li[data-i]:hover { background: var(--panel-2); }
.ac-list .ac-sub { margin-left: auto; font-size: 11px; color: var(--muted); }
.ac-list .ac-empty { cursor: default; color: var(--muted); font-size: 13px; }
/* 목록 */
.fb-list-sec { margin-top: 34px; }
.fb-list-head h2 { margin: 0; font-size: 18px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 14px; }
.chip { padding: 5px 12px; border-radius: 99px; border: 1px solid var(--border); background: transparent; color: var(--muted); cursor: pointer; font-size: 13px; }
.chip:hover { color: var(--text); border-color: var(--accent); }
.chip[aria-pressed="true"] { background: var(--accent-strong); border-color: var(--accent-strong); color: #fff; }
.fb-list { list-style: none; margin: 0; padding: 0; }
.fb-item { padding: 14px 16px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--panel); margin-bottom: 10px; }
.fb-item-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; font-size: 13px; }
.fb-id { font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
.fb-cat { font-weight: 500; }
.fb-target { color: var(--muted); }
.fb-date { margin-left: auto; font-size: 12px; }
.fb-msg { margin: 8px 0 0; white-space: pre-wrap; word-break: break-word; }
.fb-msg.clamped { display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.fb-res { margin: 10px 0 0; padding: 8px 10px; border-left: 3px solid var(--ok); background: var(--panel-2); border-radius: 0 8px 8px 0; white-space: pre-wrap; word-break: break-word; }
.fb-empty { padding: 20px; text-align: center; border: 1px dashed var(--border); border-radius: var(--radius); }
.fb-more-wrap { text-align: center; margin-top: 10px; }
.linkish { background: none; border: 0; padding: 2px 0; color: var(--link); cursor: pointer; font-size: 12px; }
@media (max-width: 640px) {
.fb-grid { grid-template-columns: 1fr; }
.fb-main { padding: 16px 12px 48px; }
.fb-form-card { padding: 14px; }
.fb-date { margin-left: 0; width: 100%; }
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#070c1b"/><ellipse cx="16" cy="16" rx="12" ry="4.5" fill="none" stroke="#bca5ff" stroke-width="1.4" transform="rotate(-28 16 16)"/><circle cx="16" cy="16" r="6" fill="none" stroke="#726892" stroke-width="1.4"/><circle cx="24" cy="9" r="2.2" fill="#e6ddff"/></svg>

After

Width:  |  Height:  |  Size: 366 B

+59
View File
@@ -0,0 +1,59 @@
// 내 정보: 로그인한 학생의 반·학습 기록 (로그인 안 했으면 /login 으로)
import { api, esc, toast } from "./common.js";
const main = document.getElementById("main");
const next = new URLSearchParams(location.search).get("next") || "/";
async function load() {
let me;
try { me = await api("/api/members/me"); } catch (e) { main.innerHTML = `<p class="muted">불러오지 못했습니다: ${esc(e.message)}</p>`; return; }
if (!me.logged_in) return renderGuest(me);
if (me.status === "approved") return renderMember(me);
main.innerHTML = `<h1>${esc(me.name)} 님</h1>
<div class="card" style="margin-top:16px">
<div class="acc-status"><span class="badge new">사용할 수 없는 계정</span>
<span class="acc-note">담당 교수자에게 문의해 주세요.</span></div>
<p style="margin-top:14px"><button class="btn" id="logout">로그아웃</button> <a class="btn ghost" href="/">개념 우주로</a></p>
</div>`;
document.getElementById("logout").onclick = logout;
}
function renderGuest(me) {
if (me.admin) {
main.innerHTML = `<h1>${esc(me.admin)} 님</h1><p class="acc-note">교수자로 로그인되어 있습니다.</p>
<p><a class="btn primary" href="/admin">관리 화면</a> <a class="btn" href="/members">회원 관리</a></p>`;
return;
}
location.replace("/login?next=" + encodeURIComponent("/account"));
}
async function renderMember(me) {
const rate = me.attempts ? Math.round((me.correct / me.attempts) * 100) : 0;
main.innerHTML = `<h1>${esc(me.name)} 님</h1>
<div class="acc-status"><span class="badge confirmed">승인됨</span><span class="acc-note">${esc(me.class_name || "")}</span>
<button class="btn small" id="logout">로그아웃</button></div>
<div class="acc-stats">
<div class="acc-stat"><small>푼 문제</small><b>${me.attempts}</b></div>
<div class="acc-stat"><small>맞힌 문제</small><b>${me.correct}</b></div>
<div class="acc-stat"><small>정답률</small><b>${rate}%</b></div>
</div>
<p style="margin-top:14px"><a class="btn primary" href="/#/quiz">문제 풀러 가기</a> <a class="btn" href="/#/quiz/wrong">틀린 문제 다시 풀기</a></p>
<div class="acc-grid" id="stats"><p class="muted">기록을 불러오는 중…</p></div>`;
document.getElementById("logout").onclick = logout;
try {
const s = await api("/api/members/my-stats");
document.getElementById("stats").innerHTML = `
<div class="card"><h2>단원별 정답률</h2>${s.by_galaxy.length ? s.by_galaxy.map((g) => {
const p = g.attempts ? Math.round((g.correct / g.attempts) * 100) : 0;
return `<div style="margin-bottom:10px"><div class="row"><span class="grow">${esc(g.name)}</span><span class="acc-note">${g.correct}/${g.attempts} · ${p}%</span></div><div class="bar"><i style="width:${p}%"></i></div></div>`;
}).join("") : `<p class="acc-note">아직 푼 문제가 없습니다.</p>`}</div>
<div class="card"><h2>자주 틀린 용어</h2>${s.wrong_terms.length ? `<div class="chips">${s.wrong_terms.map((t) => `<a href="/#/t/${esc(t.term_id)}">${esc(t.name)} · ${t.wrong}</a>`).join("")}</div>` : `<p class="acc-note">틀린 용어가 없습니다.</p>`}</div>`;
} catch (e) { document.getElementById("stats").innerHTML = `<p class="muted">${esc(e.message)}</p>`; }
}
async function logout() {
try { await api("/api/members/logout", { method: "POST" }); } catch { /* 무시 */ }
load();
}
load();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
// 모든 페이지 공통: API 호출, 오류 자동 보고, 작은 도우미.
export const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
export const STATUS_LABEL = { draft: "검수 전", review: "검토 중", confirmed: "확정", rejected: "반려" };
export const FEEDBACK_STATUS_LABEL = { new: "접수", in_progress: "처리 중", resolved: "반영 완료", rejected: "반영 안 함", duplicate: "중복" };
export const REL_TYPE_LABEL = {
part_of: "구성 요소",
precedes: "다음 단계",
informs: "자료로 활용",
contrasts_with: "구분할 개념",
related_to: "관련 개념",
};
export const METHOD_LABEL = { wireframe: "시안 예시", curated: "비교 개념 초안", toc: "목차 구조", definition: "정의 문장", name: "이름 포함", manual: "직접 입력", auto: "자동 추출", draft: "AI 초안(교재 문장 기반)" };
export async function api(path, opts = {}) {
const init = { credentials: "same-origin", ...opts, headers: { ...(opts.headers || {}) } };
if (opts.body && typeof opts.body !== "string") {
init.body = JSON.stringify(opts.body);
init.headers["Content-Type"] = "application/json";
}
const res = await fetch(path, init);
let data = null;
const ct = res.headers.get("content-type") || "";
if (ct.includes("json")) data = await res.json();
else data = await res.text();
if (!res.ok) {
const msg = (data && data.detail) ? (Array.isArray(data.detail) ? data.detail.map((d) => d.msg).join(", ") : data.detail) : `요청 실패 (${res.status})`;
const err = new Error(msg);
err.status = res.status;
throw err;
}
return data;
}
let toastTimer;
export function toast(msg, isError = false) {
let el = document.querySelector(".toast");
if (!el) {
el = document.createElement("div");
el.className = "toast";
el.setAttribute("role", "status");
document.body.appendChild(el);
}
el.textContent = msg;
el.classList.toggle("error", isError);
el.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (el.hidden = true), 3500);
}
// 화면 오류를 서버로 보내 관리자 피드백 목록(자동 수집 오류)에 쌓는다.
const sent = new Set();
function report(message, stack, source) {
const key = message + "|" + (stack || "").split("\n")[0];
if (sent.has(key) || sent.size > 20) return;
sent.add(key);
try {
fetch("/api/client-errors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: String(message).slice(0, 2000), stack: (stack || "").slice(0, 8000), url: location.href, source }),
keepalive: true,
}).catch(() => {});
} catch (_) { /* 보고 실패는 무시 */ }
}
window.addEventListener("error", (e) => report(e.message, e.error && e.error.stack, `${e.filename}:${e.lineno}:${e.colno}`));
window.addEventListener("unhandledrejection", (e) => {
const r = e.reason || {};
if (r.status && r.status < 500) return; // 4xx는 사용자 입력 문제
report(r.message || String(r), r.stack, "promise");
});
export function fmtDate(s) {
if (!s) return "";
return s.replace("T", " ").slice(0, 16);
}
export function debounce(fn, ms = 150) {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
}
export function qs(name) {
return new URLSearchParams(location.search).get(name);
}
+333
View File
@@ -0,0 +1,333 @@
// 오류 신고 페이지: 신고 양식 + 공개 신고 목록.
import { esc, api, toast, fmtDate, debounce, qs, FEEDBACK_STATUS_LABEL } from "./common.js";
const TYPES = [
{ key: "meaning", label: "뜻·정의가 틀림", kind: "content" },
{ key: "relation", label: "관계가 틀림", kind: "content" },
{ key: "source", label: "출처·페이지가 틀림", kind: "content" },
{ key: "chapter", label: "소속 단원이 틀림", kind: "content" },
{ key: "typo", label: "오타·표기", kind: "content" },
{ key: "missing", label: "빠진 용어", kind: "suggestion" },
{ key: "search", label: "검색이 안 됨", kind: "search" },
{ key: "bug", label: "화면·기능 오류", kind: "bug" },
{ key: "other", label: "기타 제안", kind: "suggestion" },
];
const PAGE = 20;
const $ = (id) => document.getElementById(id);
const form = $("fb-form");
const typeSel = $("fb-type");
const termInput = $("fb-term");
const termList = $("fb-term-list");
const chosenEl = $("fb-term-chosen");
const relEl = $("fb-rel-chosen");
const msg = $("fb-message");
const state = {
terms: null, // [{i,n,a,g}]
galaxies: {},
term: null, // 선택한 용어 {i,n,g}
relation: null, // relation_id
acItems: [],
acActive: -1,
status: "",
offset: 0,
};
// ---------------------------------------------------------------- 양식
typeSel.innerHTML = TYPES.map((t) => `<option value="${t.key}">${esc(t.label)}</option>`).join("");
async function loadAtlas() {
if (state.terms) return state.terms;
try {
const d = await api("/api/atlas");
state.galaxies = Object.fromEntries((d.galaxies || []).map((g) => [g.galaxy_id, g.name]));
state.terms = (d.terms || []).map((t) => ({ i: t.i, n: t.n, a: t.a || [], g: t.g, key: [t.n, ...(t.a || [])].join("\u0001").toLowerCase() }));
} catch (e) {
state.terms = [];
toast("용어 목록을 불러오지 못했습니다. 용어 이름을 메시지에 적어 주세요.", true);
}
return state.terms;
}
function renderChosen() {
if (state.term) {
const g = state.galaxies[state.term.g];
chosenEl.innerHTML = `<span>선택한 용어: <strong>${esc(state.term.n)}</strong>${g ? ` <span class="faint">· ${esc(g)}</span>` : ""}</span>
<a href="/#/t/${encodeURIComponent(state.term.i)}" target="_blank" rel="noopener">개념 우주에서 보기</a>
<button type="button" class="btn small ghost" data-act="clear-term" aria-label="선택한 용어 지우기">지우기</button>`;
chosenEl.hidden = false;
termInput.value = state.term.n;
} else {
chosenEl.hidden = true;
chosenEl.innerHTML = "";
}
if (state.relation) {
relEl.innerHTML = `<span>신고 대상 관계: <code>${esc(state.relation)}</code></span>
<button type="button" class="btn small ghost" data-act="clear-rel" aria-label="관계 선택 지우기">지우기</button>`;
relEl.hidden = false;
} else {
relEl.hidden = true;
relEl.innerHTML = "";
}
}
chosenEl.addEventListener("click", (e) => {
if (e.target.closest("[data-act=clear-term]")) {
state.term = null;
termInput.value = "";
renderChosen();
termInput.focus();
}
});
relEl.addEventListener("click", (e) => {
if (e.target.closest("[data-act=clear-rel]")) {
state.relation = null;
renderChosen();
}
});
function closeAc() {
termList.hidden = true;
termList.innerHTML = "";
termInput.setAttribute("aria-expanded", "false");
termInput.removeAttribute("aria-activedescendant");
state.acItems = [];
state.acActive = -1;
}
function searchTerms(q) {
const s = q.trim().toLowerCase();
if (!s || !state.terms) return [];
const starts = [], has = [];
for (const t of state.terms) {
const idx = t.key.indexOf(s);
if (idx < 0) continue;
(t.n.toLowerCase().startsWith(s) ? starts : has).push(t);
if (starts.length >= 12) break;
}
return [...starts, ...has].slice(0, 12);
}
const onType = debounce(async () => {
await loadAtlas();
const q = termInput.value;
if (state.term && q !== state.term.n) {
state.term = null;
renderChosen();
}
const items = searchTerms(q);
state.acItems = items;
state.acActive = -1;
if (!q.trim()) return closeAc();
if (!items.length) {
termList.innerHTML = `<li class="ac-empty" role="option" aria-disabled="true">찾는 용어가 없습니다. 빠진 용어라면 유형을 ‘빠진 용어’로 골라 주세요.</li>`;
} else {
const s = q.trim().toLowerCase();
termList.innerHTML = items.map((t, i) => {
const alias = t.n.toLowerCase().includes(s) ? "" : (t.a.find((a) => a.toLowerCase().includes(s)) || "");
return `<li id="fb-ac-${i}" role="option" data-i="${i}" aria-selected="false">
<span class="ac-name">${esc(t.n)}</span>${alias ? ` <span class="faint">(${esc(alias)})</span>` : ""}
<span class="ac-sub">${esc(state.galaxies[t.g] || "")}</span></li>`;
}).join("");
}
termList.hidden = false;
termInput.setAttribute("aria-expanded", "true");
}, 120);
function setActive(i) {
const lis = termList.querySelectorAll("[data-i]");
if (!lis.length) return;
state.acActive = (i + lis.length) % lis.length;
lis.forEach((li, k) => li.setAttribute("aria-selected", String(k === state.acActive)));
const cur = lis[state.acActive];
termInput.setAttribute("aria-activedescendant", cur.id);
cur.scrollIntoView({ block: "nearest" });
}
function pick(i) {
const t = state.acItems[i];
if (!t) return;
state.term = t;
closeAc();
renderChosen();
}
termInput.addEventListener("input", onType);
termInput.addEventListener("focus", () => loadAtlas());
termInput.addEventListener("keydown", (e) => {
if (termList.hidden) return;
if (e.key === "ArrowDown") { e.preventDefault(); setActive(state.acActive + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(state.acActive - 1); }
else if (e.key === "Enter" && state.acActive >= 0) { e.preventDefault(); pick(state.acActive); }
else if (e.key === "Escape") { closeAc(); }
});
termList.addEventListener("mousedown", (e) => {
const li = e.target.closest("[data-i]");
if (li) { e.preventDefault(); pick(+li.dataset.i); }
});
termInput.addEventListener("blur", () => setTimeout(closeAc, 150));
msg.addEventListener("input", () => { $("fb-message-count").textContent = `${msg.value.length} / 4000`; });
async function prefill() {
const page = qs("page");
$("fb-page").value = page || (document.referrer && new URL(document.referrer).origin === location.origin ? document.referrer : "");
const rel = qs("relation");
if (rel) {
state.relation = rel.slice(0, 80);
typeSel.value = "relation";
}
const tid = qs("term");
if (tid) {
const terms = await loadAtlas();
const t = terms.find((x) => x.i === tid);
if (t) state.term = t;
else state.term = { i: tid, n: tid, a: [], g: null };
}
renderChosen();
}
form.addEventListener("reset", () => {
setTimeout(() => {
state.term = null;
state.relation = null;
renderChosen();
$("fb-message-count").textContent = "0 / 4000";
});
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const message = msg.value.trim();
if (message.length < 2) {
toast("무엇이 잘못되었는지 2자 이상 적어 주세요.", true);
msg.focus();
return;
}
const type = TYPES.find((t) => t.key === typeSel.value) || TYPES[0];
const body = {
kind: type.kind,
category: type.label,
message,
suggestion: $("fb-suggestion").value.trim() || null,
page_url: $("fb-page").value.trim().slice(0, 500) || null,
reporter: $("fb-reporter").value.trim() || null,
contact: $("fb-contact").value.trim() || null,
website: $("fb-website").value || null,
};
if (state.relation) {
body.target_type = "relation";
body.target_id = state.relation;
body.target_label = state.term ? `${state.term.n}의 관계` : null;
} else if (state.term) {
body.target_type = "term";
body.target_id = state.term.i;
body.target_label = state.term.n;
} else if (termInput.value.trim()) {
// 목록에 없는 용어(빠진 용어 등)는 이름만 남긴다.
body.target_type = "text";
body.target_label = termInput.value.trim().slice(0, 200);
}
const btn = $("fb-submit");
btn.disabled = true;
btn.textContent = "보내는 중…";
try {
const r = await api("/api/feedback", { method: "POST", body });
const done = $("fb-done");
done.innerHTML = `<strong>신고가 접수되었습니다.</strong> 접수번호 <b>#${esc(r.id)}</b> · 처리 결과는 아래 목록에서 확인하실 수 있습니다. 고맙습니다.`;
done.hidden = false;
form.reset();
done.focus();
state.offset = 0;
loadList(false);
} catch (err) {
if (err.status === 429) toast("신고가 너무 잦습니다. 잠시 후 다시 시도해 주세요.", true);
else if (err.status === 400 || err.status === 422) toast(`입력을 확인해 주세요: ${err.message}`, true);
else toast(err.message || "신고를 보내지 못했습니다.", true);
} finally {
btn.disabled = false;
btn.textContent = "신고 보내기";
}
});
// ---------------------------------------------------------------- 공개 목록
const CHIPS = [["", "전체"], ["new", "접수"], ["in_progress", "처리 중"], ["resolved", "반영 완료"], ["rejected", "반영 안 함"]];
const chipsEl = $("fb-chips");
chipsEl.innerHTML = CHIPS.map(([k, l]) => `<button type="button" class="chip" data-status="${k}" aria-pressed="${k === "" ? "true" : "false"}">${esc(l)}</button>`).join("");
chipsEl.addEventListener("click", (e) => {
const b = e.target.closest("[data-status]");
if (!b) return;
state.status = b.dataset.status;
chipsEl.querySelectorAll(".chip").forEach((c) => c.setAttribute("aria-pressed", String(c === b)));
state.offset = 0;
loadList(false);
});
const listEl = $("fb-list");
const moreBtn = $("fb-more");
const LONG = 220;
function itemHtml(f) {
let target = "";
if (f.target_type === "term" && f.target_id) {
target = `<a href="/#/t/${encodeURIComponent(f.target_id)}">${esc(f.target_label || f.target_id)}</a>`;
} else if (f.target_label) {
target = esc(f.target_label);
}
const m = f.message || "";
const long = m.length > LONG;
const st = f.status || "new";
return `<li class="fb-item">
<div class="fb-item-head">
<span class="fb-id">#${esc(f.id)}</span>
<span class="badge ${esc(st)}">${esc(FEEDBACK_STATUS_LABEL[st] || st)}</span>
<span class="fb-cat">${esc(f.category || "")}</span>
${target ? `<span class="fb-target">· ${target}</span>` : ""}
<time class="faint fb-date" datetime="${esc(f.created_at)}">${esc(fmtDate(f.created_at))}</time>
</div>
<p class="fb-msg${long ? " clamped" : ""}">${esc(m)}</p>
${long ? `<button type="button" class="linkish" data-act="expand" aria-expanded="false">전체 보기</button>` : ""}
${f.resolution ? `<p class="fb-res"><strong>처리 결과:</strong> ${esc(f.resolution)}</p>` : ""}
</li>`;
}
listEl.addEventListener("click", (e) => {
const b = e.target.closest("[data-act=expand]");
if (!b) return;
const p = b.previousElementSibling;
const open = p.classList.toggle("clamped") === false;
b.setAttribute("aria-expanded", String(open));
b.textContent = open ? "접기" : "전체 보기";
});
let listReq = 0;
async function loadList(append) {
const my = ++listReq;
if (!append) listEl.innerHTML = `<li class="fb-empty muted">불러오는 중…</li>`;
moreBtn.disabled = true;
const p = new URLSearchParams({ limit: PAGE, offset: state.offset });
if (state.status) p.set("status", state.status);
try {
const d = await api(`/api/feedback?${p}`);
if (my !== listReq) return;
const html = d.items.map(itemHtml).join("");
if (append) listEl.insertAdjacentHTML("beforeend", html);
else listEl.innerHTML = html || `<li class="fb-empty muted">아직 접수된 신고가 없습니다.</li>`;
state.offset += d.items.length;
$("fb-total").textContent = `총 ${d.total}건`;
moreBtn.hidden = state.offset >= d.total;
} catch (err) {
if (my !== listReq) return;
if (!append) listEl.innerHTML = `<li class="fb-empty muted">목록을 불러오지 못했습니다.</li>`;
toast(err.message, true);
} finally {
moreBtn.disabled = false;
}
}
moreBtn.addEventListener("click", () => loadList(true));
prefill();
loadList(false);
+154
View File
@@ -0,0 +1,154 @@
// 지도 배치와 그래프 계산 (DOM 없음). 좌표는 '세계 좌표'이며 화면에서 확대·이동을 적용한다.
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
// 전체 우주: 은하 9개의 고정 배치 (지역사회 간호과정을 가운데에)
const UNIVERSE_SLOTS = {
g1: [-0.36, -0.30], g2: [0, -0.36], g4: [0.36, -0.30],
g5: [-0.40, 0.02], g3: [0, 0], g6: [0.40, 0.02],
g7: [-0.34, 0.33], g8: [0, 0.38], g9: [0.34, 0.33],
};
export function universeLayout(galaxies) {
const W = 1000, H = 760;
const pos = {};
galaxies.forEach((g, i) => {
const slot = UNIVERSE_SLOTS[g.galaxy_id] || [Math.cos(i) * 0.35, Math.sin(i) * 0.3];
pos[g.galaxy_id] = { x: slot[0] * W, y: slot[1] * H };
});
return { pos, bounds: { minX: -W / 2, maxX: W / 2, minY: -H / 2, maxY: H / 2 } };
}
/** 은하 안: 절(별자리)을 원형으로 */
export function galaxyLayout(sections, countOf) {
const n = sections.length;
const sizes = sections.map((s) => 128 + Math.sqrt(countOf(s.id)) * 7); // 지름
const gap = 34;
const circumference = sizes.reduce((a, b) => a + b + gap, 0);
const R = Math.max(240, circumference / (2 * Math.PI) / 0.9);
const pos = {};
let angle = -Math.PI / 2;
sections.forEach((s, i) => {
const share = ((sizes[i] + gap) / circumference) * Math.PI * 2;
const a = angle + share / 2;
pos[s.id] = { x: Math.cos(a) * R, y: Math.sin(a) * R * 0.86, r: sizes[i] / 2 };
angle += share;
});
const pad = Math.max(...sizes, 60) / 2 + 30;
return { pos, R, bounds: { minX: -R - pad, maxX: R + pad, minY: -R * 0.86 - pad, maxY: R * 0.86 + pad } };
}
/** 절 안: 항목별 작은 묶음 + 묶음 안 해바라기 배치 */
export function sectionLayout(groups) {
// groups: [{id, title, terms:[term]}]
const SPACING = 40;
const clusters = groups.map((g) => ({ ...g, r: Math.sqrt(g.terms.length) * SPACING * 0.62 + 34 }));
const pos = {};
const centers = {};
if (clusters.length === 1) {
centers[clusters[0].id] = { x: 0, y: 0, r: clusters[0].r };
} else {
const circumference = clusters.reduce((a, c) => a + c.r * 2 + 50, 0);
const R = Math.max(230, circumference / (2 * Math.PI));
let angle = -Math.PI / 2;
for (const c of clusters) {
const share = ((c.r * 2 + 50) / circumference) * Math.PI * 2;
const a = angle + share / 2;
centers[c.id] = { x: Math.cos(a) * R, y: Math.sin(a) * R, r: c.r };
angle += share;
}
}
for (const c of clusters) {
const ctr = centers[c.id];
// 이름순 정렬 후 해바라기 배치 (관계 많은 별은 가운데)
c.terms.forEach((t, k) => {
const rr = Math.sqrt(k + 0.6) * SPACING * 0.62;
const a = k * GOLDEN;
pos[t.i] = { x: ctr.x + Math.cos(a) * rr, y: ctr.y + Math.sin(a) * rr * 0.9 };
});
}
let minX = 0, maxX = 0, minY = 0, maxY = 0;
for (const c of Object.values(centers)) {
minX = Math.min(minX, c.x - c.r); maxX = Math.max(maxX, c.x + c.r);
minY = Math.min(minY, c.y - c.r); maxY = Math.max(maxY, c.y + c.r);
}
const pad = 70;
return { pos, centers, bounds: { minX: minX - pad, maxX: maxX + pad, minY: minY - pad - 20, maxY: maxY + pad } };
}
/** 화면 크기에 맞춰 보기 (scale, tx, ty) */
export function fit(bounds, w, h, { max = 1.4, min = 0.25, margin = 24 } = {}) {
const bw = bounds.maxX - bounds.minX, bh = bounds.maxY - bounds.minY;
const s = Math.max(min, Math.min(max, (w - margin * 2) / bw, (h - margin * 2) / bh));
const cx = (bounds.minX + bounds.maxX) / 2, cy = (bounds.minY + bounds.maxY) / 2;
return { s, tx: w / 2 - cx * s, ty: h / 2 - cy * s };
}
/** 라벨 겹침 제거: 우선순위 순으로 넣고 겹치면 숨김. items: [{id, x, y, w, h, prio}] → Set(보이는 id) */
export function cullLabels(items) {
const placed = [];
const visible = new Set();
const sorted = [...items].sort((a, b) => b.prio - a.prio);
const cell = 80;
const grid = new Map();
const key = (gx, gy) => gx + "," + gy;
for (const it of sorted) {
const box = { x0: it.x - it.w / 2, x1: it.x + it.w / 2, y0: it.y, y1: it.y + it.h };
let hit = false;
const gx0 = Math.floor(box.x0 / cell), gx1 = Math.floor(box.x1 / cell);
const gy0 = Math.floor(box.y0 / cell), gy1 = Math.floor(box.y1 / cell);
outer: for (let gx = gx0; gx <= gx1; gx++) {
for (let gy = gy0; gy <= gy1; gy++) {
for (const o of grid.get(key(gx, gy)) || []) {
if (box.x0 < o.x1 && box.x1 > o.x0 && box.y0 < o.y1 && box.y1 > o.y0) { hit = true; break outer; }
}
}
}
if (hit && !it.force) continue;
visible.add(it.id);
placed.push(box);
for (let gx = gx0; gx <= gx1; gx++) for (let gy = gy0; gy <= gy1; gy++) {
const k = key(gx, gy);
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(box);
}
}
return visible;
}
export function labelWidth(text, px = 12) {
let w = 0;
for (const ch of text) w += /[ㄱ-힣]/.test(ch) ? px * 1.1 : px * 0.6;
return w + 16;
}
/** 등록된 관계로 최단 경로 (방향 무시) — [{term, rel, forward}] 또는 null */
export function shortestPath(adj, from, to, { maxDepth = 8, allow = () => true } = {}) {
if (from === to) return [];
const prev = new Map([[from, null]]);
let frontier = [from];
for (let depth = 0; depth < maxDepth && frontier.length; depth++) {
const next = [];
for (const id of frontier) {
for (const r of adj.get(id) || []) {
if (!allow(r)) continue;
const other = r.s === id ? r.t : r.s;
if (prev.has(other)) continue;
prev.set(other, { id, r });
if (other === to) {
const path = [];
let cur = to;
while (prev.get(cur)) {
const p = prev.get(cur);
path.unshift({ from: p.id, to: cur, rel: p.r, forward: p.r.s === p.id });
cur = p.id;
}
return path;
}
next.push(other);
}
}
frontier = next;
}
return null;
}
+43
View File
@@ -0,0 +1,43 @@
// 로그인: 학생·교수자 공용. 교수자 아이디면 관리 화면으로.
import { api, esc, toast } from "./common.js";
const main = document.getElementById("main");
const params = new URLSearchParams(location.search);
const next = params.get("next") || "";
const safe = (u) => (u && u.startsWith("/") && !u.startsWith("//") ? u : "");
main.innerHTML = `<h1>로그인</h1>
<p class="acc-note">사례와 문제는 회원만 볼 수 있습니다.</p>
<form class="card" id="f">
<label class="field"><span>아이디</span><input class="input" name="username" autocomplete="username" required autofocus></label>
<label class="field"><span>비밀번호</span><input class="input" name="password" type="password" autocomplete="current-password" required></label>
<button class="btn primary" type="submit">로그인</button>
</form>
<p class="auth-alt">아직 회원이 아니신가요? <a href="/signup${next ? "?next=" + encodeURIComponent(next) : ""}">가입 코드로 회원 가입</a></p>
<p class="auth-alt" style="font-size:12px">아이디·비밀번호를 잊었다면 담당 교수자에게 문의해 주세요.</p>`;
document.getElementById("f").addEventListener("submit", async (e) => {
e.preventDefault();
const v = Object.fromEntries(new FormData(e.target).entries());
const btn = e.target.querySelector("button");
btn.disabled = true;
try {
const r = await api("/api/login", { method: "POST", body: v });
if (r.kind === "admin") {
const n = safe(next);
location.href = n.startsWith("/admin") || n.startsWith("/members") ? n : "/admin";
} else {
location.href = safe(next) || "/";
}
} catch (err) {
toast(err.status === 429 ? "시도가 너무 많습니다. 잠시 후 다시 해 주세요." : err.message, true);
btn.disabled = false;
}
});
// 이미 로그인한 상태면 알려 줌
api("/api/members/me").then((me) => {
if (me.logged_in || me.admin) {
main.insertAdjacentHTML("afterbegin", `<p class="acc-note">이미 ${esc(me.logged_in ? me.name : me.admin)} 님으로 로그인되어 있습니다. <a href="${me.admin ? "/admin" : "/account"}">${me.admin ? "관리 화면" : "내 정보"}</a></p>`);
}
}).catch(() => {});
+131
View File
@@ -0,0 +1,131 @@
// 규칙 기반 용어 검색 (AI 없음). 등록된 이름·동의어·검색 표현·설명 문자열만 찾는다.
const PUNCT = /[\s·\-–_/.,()()'"’‘“”「」『』\[\]:;!?~]/g;
const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
export function normalize(s) {
return String(s ?? "").normalize("NFC").toLowerCase().replace(PUNCT, "");
}
export function choseong(s) {
let out = "";
for (const ch of String(s ?? "")) {
const c = ch.charCodeAt(0);
if (c >= 0xac00 && c <= 0xd7a3) out += CHO[Math.floor((c - 0xac00) / 588)];
else if (!PUNCT.test(ch)) out += ch.toLowerCase();
PUNCT.lastIndex = 0;
}
return out;
}
export const isChoseongQuery = (q) => /^[ㄱ-ㅎ]{2,}$/.test(q.replace(/\s/g, ""));
function editDistance(a, b, max) {
if (Math.abs(a.length - b.length) > max) return max + 1;
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const cur = [i];
let rowMin = i;
for (let j = 1; j <= b.length; j++) {
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
rowMin = Math.min(rowMin, cur[j]);
}
if (rowMin > max) return max + 1;
prev = cur;
}
return prev[b.length];
}
/** terms: [{i, n, a:[], q:[], x}] → 검색용 색인 */
export function buildIndex(terms) {
return terms.map((t) => ({
t,
name: normalize(t.n),
cho: choseong(t.n),
aliases: (t.a || []).map((a) => [a, normalize(a)]),
extra: (t.q || []).map((a) => [a, normalize(a)]),
text: normalize(t.x || ""),
}));
}
/**
* 반환: [{t, score, reason}] 점수순.
* reason: 사용자에게 보여줄 일치 이유 (예: "동의어 ‘HBM’ 일치")
*/
export function search(index, query, { limit = Infinity, fuzzy = true } = {}) {
const raw = String(query ?? "").trim();
if (!raw) return [];
const q = normalize(raw);
const words = raw.split(/[\s,]+/).map(normalize).filter(Boolean);
const cho = isChoseongQuery(raw) ? raw.replace(/\s/g, "") : null;
const hits = [];
for (const e of index) {
let score = 0;
let reason = "";
if (q) {
if (e.name === q) { score = 100; reason = "용어 일치"; }
else if (e.name.startsWith(q)) { score = 80; reason = "용어 앞부분 일치"; }
else if (e.name.includes(q)) { score = 62; reason = "용어 일부 일치"; }
if (score < 70) {
for (const [orig, a] of e.aliases) {
if (a === q) { score = 72; reason = `동의어 ‘${orig}’ 일치`; break; }
if (a.includes(q) && score < 46) { score = 46; reason = `동의어 ‘${orig}’에 포함`; }
}
}
if (score < 42) {
for (const [orig, a] of e.extra) {
if (a.includes(q) || (a.length >= 3 && q.includes(a))) { score = 42; reason = `검색 표현 ‘${orig}’`; break; }
}
}
if (!score && e.text.includes(q)) { score = 16; reason = "설명 속 단어"; }
}
if (words.length > 1) {
let matched = 0;
let sub = 0;
for (const w of words) {
if (e.name.includes(w)) { sub += 8; matched++; }
else if (e.aliases.some(([, a]) => a.includes(w)) || e.extra.some(([, a]) => a.includes(w))) { sub += 5; matched++; }
else if (e.text.includes(w)) { sub += 2; matched++; }
}
if (matched) {
const all = matched === words.length;
const s = sub + (all ? 12 : 0);
if (s > score) {
score = s;
reason = all ? "모든 단어 포함" : `${words.length}개 단어 중 ${matched}개 포함`;
}
}
}
if (cho && !score) {
if (e.cho.startsWith(cho)) { score = 50; reason = "초성 일치"; }
else if (e.cho.includes(cho)) { score = 34; reason = "초성 포함"; }
}
if (score) hits.push({ t: e.t, score: score - Math.min(e.name.length, 30) * 0.05, reason });
}
if (fuzzy && hits.length < 3 && q.length >= 2 && !cho) {
const max = q.length <= 4 ? 1 : 2;
const seen = new Set(hits.map((h) => h.t.i));
for (const e of index) {
if (seen.has(e.t.i)) continue;
let d = editDistance(q, e.name, max);
if (d > max && e.name.length > q.length) {
// 긴 이름 앞부분과 비교 (예: '우선순이' → '우선순위 설정')
d = editDistance(q, e.name.slice(0, q.length), max);
}
if (d <= max) hits.push({ t: e.t, score: 8 - d, reason: "비슷한 표기" });
}
}
hits.sort((a, b) => b.score - a.score || a.t.n.localeCompare(b.t.n, "ko"));
return hits.slice(0, limit);
}
/** 가나다 색인용 첫 글자 묶음 */
export function initialGroup(name) {
const c = String(name).charCodeAt(0);
if (c >= 0xac00 && c <= 0xd7a3) {
const ch = CHO[Math.floor((c - 0xac00) / 588)];
return { ㄲ: "ㄱ", ㄸ: "ㄷ", ㅃ: "ㅂ", ㅆ: "ㅅ", ㅉ: "ㅈ" }[ch] || ch;
}
if (/[a-z]/i.test(name[0])) return "A–Z";
return "기타";
}
+38
View File
@@ -0,0 +1,38 @@
// 회원 가입: 수업에서 받은 가입 코드가 있어야 함. 가입하면 바로 이용.
import { api, toast } from "./common.js";
const main = document.getElementById("main");
const next = new URLSearchParams(location.search).get("next") || "";
const safe = (u) => (u && u.startsWith("/") && !u.startsWith("//") ? u : "");
main.innerHTML = `<h1>회원 가입</h1>
<p class="acc-note">수업에서 교수자에게 받은 <b>가입 코드</b>가 있어야 가입할 수 있습니다. 가입하면 바로 사례와 문제를 볼 수 있어요.</p>
<form class="card" id="f">
<label class="field"><span>가입 코드</span><input class="input" name="code" required maxlength="40" autocomplete="off" placeholder="예: K7P-2QX" style="text-transform:uppercase"></label>
<label class="field"><span>아이디 (영문·숫자 3~30자)</span><input class="input" name="username" autocomplete="username" required minlength="3" maxlength="30" pattern="[A-Za-z0-9_.\\-]{3,30}"></label>
<label class="field"><span>비밀번호 (6자 이상)</span><input class="input" name="password" type="password" autocomplete="new-password" required minlength="6"></label>
<label class="field"><span>비밀번호 확인</span><input class="input" name="password2" type="password" autocomplete="new-password" required minlength="6"></label>
<label class="field"><span>이름</span><input class="input" name="name" required maxlength="40"></label>
<label class="field"><span>학번 (선택)</span><input class="input" name="student_no" maxlength="30"></label>
<input class="hp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
<button class="btn primary" type="submit">가입하기</button>
</form>
<p class="auth-alt">이미 회원인가요? <a href="/login${next ? "?next=" + encodeURIComponent(next) : ""}">로그인</a></p>`;
document.getElementById("f").addEventListener("submit", async (e) => {
e.preventDefault();
const v = Object.fromEntries(new FormData(e.target).entries());
if (v.password !== v.password2) return toast("비밀번호 확인이 일치하지 않습니다.", true);
delete v.password2;
const btn = e.target.querySelector("button");
btn.disabled = true;
try {
await api("/api/members/signup", { method: "POST", body: v });
await api("/api/members/login", { method: "POST", body: { username: v.username, password: v.password } });
toast("가입했습니다!");
location.href = safe(next) || "/";
} catch (err) {
toast(err.message, true);
btn.disabled = false;
}
});