From b3e8dd5359e220ba154f4c9a05fdebd6e891ae29 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 23 Mar 2026 04:23:57 +0900 Subject: [PATCH 01/23] =?UTF-8?q?chore:=20CodeRabbit=20AI=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EC=96=B4=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리뷰 응답 언어를 한국어로 설정 - 백엔드/AI 코드 품질을 위해 assertive 모드 적용 - develop 및 main 브랜치 대상 자동 리뷰 활성화 - 불필요한 poem 작성 및 상세 노이즈 제거 --- .coderabbit.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..00f37c1 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,17 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "ko-KR" +reviews: + profile: "assertive" # 깐깐하게 로직 검토 + request_changes_workflow: false # AI가 승인을 막지 않도록 설정 + high_level_summary: true + review_status: true + review_details: false + poem: false # 불필요한 기능 제거 + auto_review: + enabled: true + drafts: false + base_branches: + - "develop" + - "main" +chat: + auto_reply: true \ No newline at end of file From bdcd80b412bb3d868d51e9fa63c55f7b14e23e8a Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 23 Mar 2026 16:34:14 +0900 Subject: [PATCH 02/23] chore: move pull request template to github root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PR 템플릿 경로를 .github/pull_request_template.md로 수정 - GitHub 기본 PR 템플릿 자동 인식 경로로 정리 --- .github/{ISSUE_TEMPLATE => }/pull_request_template.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ISSUE_TEMPLATE => }/pull_request_template.md (100%) diff --git a/.github/ISSUE_TEMPLATE/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from .github/ISSUE_TEMPLATE/pull_request_template.md rename to .github/pull_request_template.md From 95972ed83a5c58cb2a6f4b2c1f60ba36195ccf9b Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 23 Mar 2026 16:35:16 +0900 Subject: [PATCH 03/23] chore: add editorconfig and gitattributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장소 공통 포맷 규칙 추가 - 줄바꿈(LF/CRLF) 정책 통일 --- .editorconfig | 28 ++++++++++++++++++++++++++++ .gitattributes | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd104f0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml,json}] +indent_size = 2 + +[*.{java,kt,kts,gradle,sql}] +indent_size = 4 + +[*.py] +indent_size = 4 +max_line_length = 88 + +[*.{sh,bash}] +end_of_line = lf + +[*.{bat,cmd,ps1}] +end_of_line = crlf \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..67d5cfe --- /dev/null +++ b/.gitattributes @@ -0,0 +1,38 @@ +# Default: text files are normalized to LF in repo +* text=auto eol=lf + +# Windows scripts +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Unix scripts +*.sh text eol=lf +*.bash text eol=lf + +# Source files +*.java text eol=lf +*.kt text eol=lf +*.kts text eol=lf +*.py text eol=lf +*.sql text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.md text eol=lf + +# Gradle wrapper/script +gradlew text eol=lf +gradlew.bat text eol=crlf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.jar binary +*.zip binary +*.gz binary +*.7z binary \ No newline at end of file From 3fff591e8d7d911bbf4df77bb723569691863a4b Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 23 Mar 2026 16:39:33 +0900 Subject: [PATCH 04/23] chore: ignore pycharm idea directory --- .gitignore | Bin 4688 -> 4911 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.gitignore b/.gitignore index b7faf403d915ca307532bb0eb9cceaf0214e8e5b..8c5a6ed1e04364b6566a48b885dadde74e4b6127 100644 GIT binary patch delta 1908 zcmZ8i&u<(x6y7vRn+|b)s49&}DJv0L6trF>P{of^8WI{%qHUA52t+5|8Sk!>*|9tJ zZgwg}{{>*q9Fe#n3KD0oh*Kr5+&FMX;)n#_^UQ7{F1Fu$-+O-c`~3WD?fdIr+}L12 z7@9m1QBQ_plLhPC#JFCoJHt z-1T(Ig7!cqG2+B>C=+cOvfH&JD(h&{CuyQZOsdMlt2t3^C^6oA6SEPSBap2eha!V? z(vvdr>Ym7C)A&6bC);E7It6Q}w4(v$Dz#1|iHv#F=~BKhvS4>(Z)baJ|DZflE8nS5 z87p3!+MZ&?qp8+SLia_gI?_6SQX`|%VFIrwx{F&d$~3UzZsb_;*VMIXz>1}FFX2vl zuAfADNU1nIqA4vWC}k$P5}NUnuaiJkU_q*_blBMPQ9oh91mdOj(uIYuML5l*Td1{N9mBySi@OpwoGWEI1pgq9c)n z$jYg-YY`unY;*f4QBJwi%8|Mlo9XEl}=B?C-(k-Xw`EPGnt4q$AV^1G>vdMHuB{Gay480oa5y8ap{w#j9B zZpey%rtg71cV=$aNlR4#xPb!EgDKMhTVx98Pcw&rUY@0KF#8bWZ?mtgz^$bWZHjA; zkR!#pcOMsgVXL@veig9fJb`~bzjg(-!SD>uXz8+?u<=#-qw~O!QXro`|D7RNH-hG!`sU+zLpECWTOwQf;L}mFbiSg>rMMg&#A#Za(xP29%oCn6~!rW5k-b#c`#smXz4O z$-%oim~tc|S06tR#Ij^r$tp{!m*OdENsMZo?s5&Zw8Z(6*wvP%7&9v`P^ z^1OLWB0sXkva@SSv6W}C?s6mRp6S&qLG$#D>f8Bis0~pS2%L`<&*rbZi0dqg9?SNi zOKKlz6HT|zzG5HEkX-z*c&qrDT|y|uP$-Y*W8RclK18yvltU(+onvH3} zMM0u|V@FbuwB}_VlblUhDOJdd!hi-*u*y?WN;=KK(%Pf;Q}Q$>u-w~-iK5zq-?SkU zs$+6GVyr~lh-wxqzfag1*D*)P#Zbm1;=)*3mMo95VF70b7ukEbzq`G4_ykYiZ+3;5 z@eu8Hu57!nuJk_vTtx*RGGp+y|g?fBU_c3oWPl^JcCvE6)a5Cf|00`qIHn56+}n_8I3j3Syhq?KPu}j9KP5e z%t4}2qof@%!=W%>#>m7ciXnCikQi_AL{ni(irvBpOWuMIi%Y>*4SLnBjJ$ zlo>4(IERIn8c0j>G%}x;+_LvCiQ$Q?wV)-Y*#^3dyDYx`B4c;qOs6abA~9>i_I`jp z%rsR^2ab$aiTnQQYf~UcX5!?WJ%H^ukB~`tRn>SiUdd8jFrX3uKCbcBI@p(%rBaTe z`>GLX0=3M>N^)U6i+9g?sk9vOk|jAC(z=8cfJVY5nG{1lU*KgSt7@2-xX0I?x*x9X z+y(>I7GNP%6kmeJaD|}W^?_6H`|J9#dvE5>D`ch5748A0%*F_OdgQ8^f%|>t*oCvJ zx5!F8x$r5;tZu-+<$ju7?lA80%gNhhrFFL0PO%c0fG5l=ANA(kZFhfe;J%$Z*no(< z>fj)GYy`HjJw4PA%>yllvL8cr-4uSW3)Q(_gXv6XVE9K`3*w2f=;Q8DaY z1U1HGI?)-4CaA$LCMd;ZYySv_h?qnv9k;et!i`h|v*X}JwIcm{~0qR^50o!jh3v2_s zdd9Xyhxz7v+;|5}Aj{qJ`Q8J31ZZlD?-?27EUk;cG*v1~L$a35-sn;NW)tRR<$j$1 z^eSxUPHsMOy9>A6i-phJuM5XZWF_J`qaOP(g{e;6-QIo2dVB7d-r6#Ws5g555jQn0 zgF{>ATn3vmXYQZgz#ZQlLAsCJ!s6?0ZE@#a*e047_%CVCc>t5;;<0N`x}v!67T;LH zD=7}Wg{aM18>!uIi_hGhrGfi$>4OCri{8i!AvJS1@di!b!j Ms2Kj Date: Mon, 23 Mar 2026 16:43:11 +0900 Subject: [PATCH 05/23] chore: fix gitignore encoding and ignore idea directory --- .gitignore | Bin 4911 -> 4696 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.gitignore b/.gitignore index 8c5a6ed1e04364b6566a48b885dadde74e4b6127..e12d78e7a6fe0ce7ec20345964add9fdcea75e40 100644 GIT binary patch delta 1787 zcmZuyL2n#26jo`Q5Ld}IDFOsV8I+Wus5^s1Rh5cM8xj(vgl*EMh^m?O%y?&=of#Y3 zv&nGT8&{Al4jd3i4hV7J!k+-djeo)kiO-&FngfURy!ZUQXTSG8zy0N%zgNFmn~*q` zO-j=ti{pr_hq8H=itNkhWPMdOVOmhF30Vm?0(NRxrMajyP+BoTS)A}2$}3TGQq#Ukm?=}wL7E=HGE0}+vsSK82`U|EL11*wEaL~V3L@;A53s%Fp<|LuRW{!2QZiKK3{(Q(liFpif<0ka zBE$&Ymra$#sAW2mf>*|~c;}3lQi%aCSe&r|tqX(z+7UKRMK$2_xl0yNWy7kmeRSoC z{qf57O&DBl0qdfo?j?8(mk!jq+P4zFzpftH_hxRrLRNZQx-(G9bmV|f4sAKpw|~qW z+3xJ>4YE>?&-bKYMm5;qv_H=-Pcin<%kkS}rQK|?Q^g1{!QC(`z1N#_H|@Q-zWr`) z{|-E|atep!u_0{T_V%bDjKcqhk2iS;kMDIidzwYIjZlvPh)yKwOSx0~UpDoxa5|b6R+;L7fXCu)RjH zz%)43Gqxo<=9}+v<2NvY47bnbJNMlqaHh8So{>Suk~$A`QzoL&ByHL3jULr&HZU(s z`_ueqmvNjuzW&heEZnfq7e2SYEgUV8m5^tQdhCM)Q=Qt|oqLvbcI~g7wPg}gZ}j_z zT-US+4osbK5p0Nz*?&8Idvtw>a39-+#n#>;tn{BK_7zH_ RbG^_q!rxoF?YVlEP{of^8WI{%qHUA52t+5|8Sk!>*|9tJ zZgwg}{{>*q9Fe#n3KD0oh!YYQ4%|3!M&gJB-}B6FA}+Syd*6G0_WS(&+uFC+KfAHP zf-p3BB%+=S!zK&Xxye6@bo}^Ztk+D=qeNKC0v5bj->l1#lc`lYg=U>w4gKebt4~7*xR;?+Ho$)@ppHcqz3>U9d%P-#a4%vEZgND>+IsMDo!k}NMa7{=@a*MA zyPmX)E(ZoDkE{!QJSaR0Gu7}Q6RAyv8`rR}eJG7%lKGt@k#=?6x`(Xli1&b$-PMuS)9nal1N9W_Xcz+ zS$$7C+1C0P87Y&e?GQH}9UHAuSCM>Y%w&`4j!I-0%Z?Z%6bK$f@-WhAM|J%*P;8US z^xTjY|4iQleeTTMu9KFk0B{2Zq6br^0k+5#&>vH$JrZlzfVi?xH)Lc8v_u zoAX)H%*bz?Qf q*cF+gX4&_1GsNHGRD=VJ6^S*F!myM*d3jCM;+m|9TC>J#LG3?Kg);d7 From fbf54ce7429c702b77be4b732265e9f0f8f71eda Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 23 Mar 2026 18:03:04 +0900 Subject: [PATCH 06/23] chore: add ai service bootstrap for docker deployment --- .dockerignore | 12 ++++++++++++ .env.example | 1 + .github/workflows/ci-ai.yml | 22 ++++++++++++++++++++++ Dockerfile | 8 ++++++++ app/main.py | 23 +++++++++++++++++++++++ docs/env.md | 7 +++++++ requirements.txt | 2 ++ 7 files changed, 75 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/ci-ai.yml create mode 100644 Dockerfile create mode 100644 app/main.py create mode 100644 docs/env.md create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..95d4eb5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +.github +.idea +__pycache__ +*.pyc +*.pyo +*.pyd +.venv +venv +.env +.env.* \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9847a1d --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY= \ No newline at end of file diff --git a/.github/workflows/ci-ai.yml b/.github/workflows/ci-ai.yml new file mode 100644 index 0000000..e2dc8ce --- /dev/null +++ b/.github/workflows/ci-ai.yml @@ -0,0 +1,22 @@ +name: AI CI + +on: + pull_request: + branches: [ "develop", "main" ] + push: + branches: [ "develop" ] + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install deps + run: pip install -r requirements.txt + - name: Import check + run: python -m py_compile app/main.py + - name: Docker build + run: docker build -t gachi-ai:ci . \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..06048ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app app +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f66e2ee --- /dev/null +++ b/app/main.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI(title="GACHI-AI", version="0.1.0") + + +class EchoRequest(BaseModel): + text: str + + +@app.get("/ai/health") +def health() -> dict: + return {"status": "ok"} + + +@app.get("/ai/ping") +def ping() -> dict: + return {"message": "pong"} + + +@app.post("/ai/echo") +def echo(req: EchoRequest) -> dict: + return {"text": req.text} \ No newline at end of file diff --git a/docs/env.md b/docs/env.md new file mode 100644 index 0000000..3b19f18 --- /dev/null +++ b/docs/env.md @@ -0,0 +1,7 @@ +# AI Environment Variables + +## Required +- `OPENAI_API_KEY`: LLM API key + +## Optional +- `LOG_LEVEL`: default `INFO` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f037fb5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 \ No newline at end of file From 4a31aab7863db0a7215bf5ee91c9d2ca44ccc0fd Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 24 Mar 2026 01:42:06 +0900 Subject: [PATCH 07/23] chore: replace ai ci workflow with docker hub image pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 ci-ai workflow를 docker-ai workflow로 교체 - develop/main push 시 파이썬 문법 검사 후 Docker Hub 이미지 빌드/푸시 - latest + sha 태그 전략으로 이미지 추적성 확보 --- .github/workflows/ci-ai.yml | 22 -------------- .github/workflows/docker-ai.yml | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 22 deletions(-) delete mode 100644 .github/workflows/ci-ai.yml create mode 100644 .github/workflows/docker-ai.yml diff --git a/.github/workflows/ci-ai.yml b/.github/workflows/ci-ai.yml deleted file mode 100644 index e2dc8ce..0000000 --- a/.github/workflows/ci-ai.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: AI CI - -on: - pull_request: - branches: [ "develop", "main" ] - push: - branches: [ "develop" ] - -jobs: - build-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install deps - run: pip install -r requirements.txt - - name: Import check - run: python -m py_compile app/main.py - - name: Docker build - run: docker build -t gachi-ai:ci . \ No newline at end of file diff --git a/.github/workflows/docker-ai.yml b/.github/workflows/docker-ai.yml new file mode 100644 index 0000000..c9ae0d2 --- /dev/null +++ b/.github/workflows/docker-ai.yml @@ -0,0 +1,53 @@ +name: AI Docker CI + +on: + push: + branches: [ "develop", "main" ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install deps + run: pip install -r requirements.txt + + - name: Syntax check + run: python -m py_compile app/main.py + + - name: Login Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set image tags + id: tags + run: | + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/gachi-ai" + SHORT_SHA="${GITHUB_SHA::7}" + if [ "${GITHUB_REF_NAME}" = "main" ]; then + echo "tags=${IMAGE}:latest,${IMAGE}:sha-${SHORT_SHA}" >> $GITHUB_OUTPUT + else + echo "tags=${IMAGE}:develop,${IMAGE}:sha-${SHORT_SHA}" >> $GITHUB_OUTPUT + fi + + - name: Build and Push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.tags.outputs.tags }} + platforms: linux/amd64 \ No newline at end of file From f225a3639e3ed95849c6bc4c43c307c1165bda65 Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 24 Mar 2026 02:29:06 +0900 Subject: [PATCH 08/23] chore: enable node24 compatibility in ai docker workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHub Actions Node24 강제 실행 옵션 추가 - actions/checkout v6, setup-python v6로 업그레이드 --- .github/workflows/docker-ai.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-ai.yml b/.github/workflows/docker-ai.yml index c9ae0d2..2b6763e 100644 --- a/.github/workflows/docker-ai.yml +++ b/.github/workflows/docker-ai.yml @@ -8,16 +8,19 @@ on: permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build-and-push: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" From 8a99bee7f95ab1a521d74cb23b82f565acfa6ad3 Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 24 Mar 2026 02:41:59 +0900 Subject: [PATCH 09/23] chore: upgrade docker actions to node24-ready versions --- .github/workflows/docker-ai.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-ai.yml b/.github/workflows/docker-ai.yml index 2b6763e..f30c075 100644 --- a/.github/workflows/docker-ai.yml +++ b/.github/workflows/docker-ai.yml @@ -31,7 +31,7 @@ jobs: run: python -m py_compile app/main.py - name: Login Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -48,7 +48,7 @@ jobs: fi - name: Build and Push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: true From d1dea4400e39a50a750095fafa96a7652b4c4f8a Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 24 Mar 2026 10:44:24 +0900 Subject: [PATCH 10/23] fix: expose ai swagger under /ai path Set FastAPI docs/openapi/redoc URLs to /ai/docs, /ai/openapi.json, /ai/redoc for nginx reverse proxy compatibility. --- app/main.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index f66e2ee..ecadd8e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,13 @@ from fastapi import FastAPI from pydantic import BaseModel -app = FastAPI(title="GACHI-AI", version="0.1.0") +app = FastAPI( + title="GACHI-AI", + version="0.1.0", + docs_url="/ai/docs", + redoc_url="/ai/redoc", + openapi_url="/ai/openapi.json", +) class EchoRequest(BaseModel): @@ -20,4 +26,4 @@ def ping() -> dict: @app.post("/ai/echo") def echo(req: EchoRequest) -> dict: - return {"text": req.text} \ No newline at end of file + return {"text": req.text} From 347b7b4afea5aa71fd85cbdcd4dd27d84409efdd Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 24 Mar 2026 14:53:08 +0900 Subject: [PATCH 11/23] docs: add ai deploy guide and collaboration rules Document image tag strategy, EC2 redeploy commands, rollback steps, and team collaboration conventions. --- README.md | 18 +++++++++++++++++- docs/deploy.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 docs/deploy.md diff --git a/README.md b/README.md index 7117ee9..9f97f63 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # GACHI-AI -가치(GACHI) AI 서버 - FastAPI 기반 OCR 인식 및 LLM 연동 모듈 + +GACHI 프로젝트 AI 서버(FastAPI) 레포지토리입니다. + +## 문서 +- `docs/env.md`: 환경 변수 가이드 +- `docs/deploy.md`: 이미지 태그/배포 가이드 + +## 협업 규칙 +- 기본 브랜치: `develop` +- 브랜치 전략: `feat/xx`, `refac/xx`, `hotfix/xx`, `chore/xx`, `design/xx`, `bugfix/xx` +- 커밋 타입: `feat`, `fix`, `refactor`, `docs`, `style`, `chore` +- `main`, `develop` 직접 push 금지, PR 승인 후 머지 +- CI 체크(`build-and-push`) 통과 후 머지 + +## 배포 태그 규칙 +- `develop` push: `/gachi-ai:develop` +- `main` push: `/gachi-ai:latest` diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..db88849 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,42 @@ +# GACHI-AI Deploy Guide + +## 1) 이미지 태그 규칙 +- `develop` 브랜치 push: `/gachi-ai:develop`, `sha-<7자리>` +- `main` 브랜치 push: `/gachi-ai:latest`, `sha-<7자리>` + +## 2) GitHub Actions 필수 시크릿 +- `DOCKERHUB_USERNAME` +- `DOCKERHUB_TOKEN` + +## 3) EC2 반영 (BE compose에서 함께 기동) + +### develop 반영 +```bash +cd ~/GACHI-BE/deploy +sed -i 's|^AI_IMAGE=.*|AI_IMAGE=/gachi-ai:develop|' .env +docker compose --env-file .env pull ai +docker compose --env-file .env up -d --force-recreate ai nginx +``` + +### main 반영 +```bash +cd ~/GACHI-BE/deploy +sed -i 's|^AI_IMAGE=.*|AI_IMAGE=/gachi-ai:latest|' .env +docker compose --env-file .env pull ai +docker compose --env-file .env up -d --force-recreate ai nginx +``` + +## 4) 상태 확인 +```bash +docker compose --env-file .env ps +curl -i http://localhost/ai/health +curl -i http://localhost/ai/docs +``` + +## 5) 롤백 +```bash +cd ~/GACHI-BE/deploy +sed -i 's|^AI_IMAGE=.*|AI_IMAGE=/gachi-ai:sha-<원하는태그>|' .env +docker compose --env-file .env pull ai +docker compose --env-file .env up -d --force-recreate ai nginx +``` From db4278f862e9d8985cbf461692add66ccb07b094 Mon Sep 17 00:00:00 2001 From: minju Date: Thu, 26 Mar 2026 00:00:06 +0900 Subject: [PATCH 12/23] chore: add ruff config and AI quality workflow Introduce Ruff lint/format rules via pyproject.toml and enforce checks on develop/main through GitHub Actions. --- .github/workflows/quality-ai.yml | 34 ++++++++++++++++++++++++++++++++ pyproject.toml | 11 +++++++++++ 2 files changed, 45 insertions(+) create mode 100644 .github/workflows/quality-ai.yml create mode 100644 pyproject.toml diff --git a/.github/workflows/quality-ai.yml b/.github/workflows/quality-ai.yml new file mode 100644 index 0000000..d22f745 --- /dev/null +++ b/.github/workflows/quality-ai.yml @@ -0,0 +1,34 @@ +name: AI Quality + +on: + pull_request: + branches: [ "develop", "main" ] + push: + branches: [ "develop", "main" ] + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install Ruff + run: pip install ruff + + - name: Ruff check + run: ruff check . + + - name: Ruff format check + run: ruff format --check . diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9bc5cf4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" From e17fac8bae4d8a62c01d66749bcd3872620eec20 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 18 May 2026 22:37:36 +0900 Subject: [PATCH 13/23] =?UTF-8?q?chore:=20AI=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B0=8F=20CI/CD=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coderabbit.yaml | 11 +- .github/workflows/deploy-ai-ec2.yml | 203 +++++++++++++++++++++++++++ .github/workflows/docker-ai.yml | 37 +++-- .github/workflows/quality-ai.yml | 27 +++- README.md | 49 +++++-- app/main.py | 23 +-- app/routers/health.py | 13 ++ app/routers/newsletters.py | 22 +++ app/schemas.py | 93 ++++++++++++ app/services/newsletter_extractor.py | 195 +++++++++++++++++++++++++ app/services/newsletter_prompt.py | 111 +++++++++++++++ docs/deploy.md | 61 ++++---- docs/env.md | 16 ++- docs/newsletter-extraction.md | 55 ++++++++ docs/newsletter-labeling-guide.md | 39 +++++ requirements.txt | 3 +- 16 files changed, 871 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/deploy-ai-ec2.yml create mode 100644 app/routers/health.py create mode 100644 app/routers/newsletters.py create mode 100644 app/schemas.py create mode 100644 app/services/newsletter_extractor.py create mode 100644 app/services/newsletter_prompt.py create mode 100644 docs/newsletter-extraction.md create mode 100644 docs/newsletter-labeling-guide.md diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 00f37c1..d9de751 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,12 +1,15 @@ # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json language: "ko-KR" reviews: - profile: "assertive" # 깐깐하게 로직 검토 - request_changes_workflow: false # AI가 승인을 막지 않도록 설정 + profile: "assertive" + request_changes_workflow: false high_level_summary: true review_status: true review_details: false - poem: false # 불필요한 기능 제거 + poem: false + pre_merge_checks: + docstrings: + mode: "off" auto_review: enabled: true drafts: false @@ -14,4 +17,4 @@ reviews: - "develop" - "main" chat: - auto_reply: true \ No newline at end of file + auto_reply: true diff --git a/.github/workflows/deploy-ai-ec2.yml b/.github/workflows/deploy-ai-ec2.yml new file mode 100644 index 0000000..83c462c --- /dev/null +++ b/.github/workflows/deploy-ai-ec2.yml @@ -0,0 +1,203 @@ +name: Deploy AI to EC2 + +"on": + workflow_run: + workflows: ["AI Docker CI"] + types: [completed] + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + id-token: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + deploy: + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + concurrency: + group: deploy-ai-ec2-ssm + cancel-in-progress: false + + steps: + - name: Resolve deploy path + id: deploy-path + env: + EC2_DEPLOY_PATH_SECRET: ${{ secrets.EC2_DEPLOY_PATH }} + run: | + VALUE="${EC2_DEPLOY_PATH_SECRET:-}" + VALUE="${VALUE%$'\r'}" + VALUE="$(printf '%s' "$VALUE" | sed 's/[[:space:]]*$//')" + if [ -n "$VALUE" ]; then + echo "value=$VALUE" >> "$GITHUB_OUTPUT" + else + echo "value=/home/ubuntu/GACHI-BE/deploy" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve AWS region + id: aws-region + env: + AWS_REGION_SECRET: ${{ secrets.AWS_REGION }} + run: | + VALUE="${AWS_REGION_SECRET:-}" + VALUE="${VALUE%$'\r'}" + VALUE="$(printf '%s' "$VALUE" | sed 's/[[:space:]]*$//')" + if [ -n "$VALUE" ]; then + echo "value=$VALUE" >> "$GITHUB_OUTPUT" + else + echo "value=ap-northeast-2" >> "$GITHUB_OUTPUT" + fi + + - name: Validate deploy inputs + id: auth-check + env: + EC2_INSTANCE_ID: ${{ secrets.EC2_INSTANCE_ID }} + AWS_OIDC_ROLE_ARN: ${{ secrets.AWS_OIDC_ROLE_ARN }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + set -Eeuo pipefail + if [ -z "${EC2_INSTANCE_ID:-}" ]; then + echo "::error::EC2_INSTANCE_ID secret is required." + exit 1 + fi + if [ -z "${DOCKERHUB_USERNAME:-}" ]; then + echo "::error::DOCKERHUB_USERNAME secret is required." + exit 1 + fi + if [ -n "${AWS_OIDC_ROLE_ARN:-}" ]; then + echo "auth_mode=oidc" >> "$GITHUB_OUTPUT" + echo "has_session_token=false" >> "$GITHUB_OUTPUT" + else + if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ]; then + echo "::error::Set AWS_OIDC_ROLE_ARN or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY." + exit 1 + fi + echo "auth_mode=access_key" >> "$GITHUB_OUTPUT" + if [ -n "${AWS_SESSION_TOKEN:-}" ]; then + echo "has_session_token=true" >> "$GITHUB_OUTPUT" + else + echo "has_session_token=false" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Configure AWS credentials with OIDC + if: ${{ steps.auth-check.outputs.auth_mode == 'oidc' }} + uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: ${{ steps.aws-region.outputs.value }} + role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }} + role-session-name: gachi-ai-deploy-ssm + + - name: Configure AWS credentials with access key + if: ${{ steps.auth-check.outputs.auth_mode == 'access_key' && steps.auth-check.outputs.has_session_token != 'true' }} + uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: ${{ steps.aws-region.outputs.value }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Configure AWS credentials with access key session + if: ${{ steps.auth-check.outputs.auth_mode == 'access_key' && steps.auth-check.outputs.has_session_token == 'true' }} + uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: ${{ steps.aws-region.outputs.value }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }} + + - name: Send AI deploy command via SSM + id: ssm-send + env: + AWS_REGION: ${{ steps.aws-region.outputs.value }} + EC2_INSTANCE_ID: ${{ secrets.EC2_INSTANCE_ID }} + EC2_DEPLOY_PATH: ${{ steps.deploy-path.outputs.value }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + set -Eeuo pipefail + COMMANDS="$(jq -cn \ + --arg deployPath "$EC2_DEPLOY_PATH" \ + --arg image "$DOCKERHUB_USERNAME/gachi-ai:latest" \ + '{ + commands: [[ + "set -Eeuo pipefail", + "cd " + ($deployPath | @sh), + "test -f .env", + "if grep -q ^AI_IMAGE= .env; then sed -i " + ("s|^AI_IMAGE=.*|AI_IMAGE=" + $image + "|" | @sh) + " .env; else echo " + ("AI_IMAGE=" + $image | @sh) + " >> .env; fi", + "docker compose --env-file .env pull ai", + "docker compose --env-file .env up -d --remove-orphans --force-recreate ai", + "AI_CID=$(docker compose --env-file .env ps -q ai)", + "test -n \"$AI_CID\"", + "for i in $(seq 1 24); do AI_HEALTH=$(docker inspect --format '\''{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}'\'' \"$AI_CID\" 2>/dev/null || echo missing); echo \"ai health: $AI_HEALTH\"; [ \"$AI_HEALTH\" = healthy ] && break; sleep 5; done", + "test \"$AI_HEALTH\" = healthy", + "docker compose --env-file .env up -d --remove-orphans --force-recreate --no-deps nginx", + "docker compose --env-file .env ps", + "docker compose --env-file .env logs --tail=80 ai nginx || true" + ] | join(" && ")], + executionTimeout: ["900"] + }')" + COMMAND_ID="$(aws ssm send-command \ + --region "$AWS_REGION" \ + --document-name "AWS-RunShellScript" \ + --instance-ids "$EC2_INSTANCE_ID" \ + --comment "deploy gachi-ai (run=${GITHUB_RUN_ID})" \ + --max-concurrency "1" \ + --max-errors "0" \ + --parameters "$COMMANDS" \ + --query "Command.CommandId" \ + --output text)" + echo "command_id=$COMMAND_ID" >> "$GITHUB_OUTPUT" + + - name: Wait for SSM command completion + env: + AWS_REGION: ${{ steps.aws-region.outputs.value }} + EC2_INSTANCE_ID: ${{ secrets.EC2_INSTANCE_ID }} + COMMAND_ID: ${{ steps.ssm-send.outputs.command_id }} + run: | + set -Eeuo pipefail + for attempt in $(seq 1 90); do + STATUS="$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$COMMAND_ID" \ + --instance-id "$EC2_INSTANCE_ID" \ + --query "Status" \ + --output text 2>/dev/null || true)" + echo "[$attempt/90] status: ${STATUS:-not-ready}" + case "$STATUS" in + Success|Failed|TimedOut|Cancelled|Cancelling) + break + ;; + *) + sleep 10 + ;; + esac + done + + INVOCATION_JSON="$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$COMMAND_ID" \ + --instance-id "$EC2_INSTANCE_ID" \ + --output json)" + FINAL_STATUS="$(printf '%s' "$INVOCATION_JSON" | jq -r '.Status // "Unknown"')" + STDOUT_CONTENT="$(printf '%s' "$INVOCATION_JSON" | jq -r '.StandardOutputContent // ""')" + STDERR_CONTENT="$(printf '%s' "$INVOCATION_JSON" | jq -r '.StandardErrorContent // ""')" + + echo "::group::SSM stdout" + printf '%s\n' "$STDOUT_CONTENT" + echo "::endgroup::" + + if [ -n "$STDERR_CONTENT" ]; then + echo "::group::SSM stderr" + printf '%s\n' "$STDERR_CONTENT" + echo "::endgroup::" + fi + + if [ "$FINAL_STATUS" != "Success" ]; then + echo "::error::SSM command failed. status=$FINAL_STATUS" + exit 1 + fi diff --git a/.github/workflows/docker-ai.yml b/.github/workflows/docker-ai.yml index f30c075..8bf6bb4 100644 --- a/.github/workflows/docker-ai.yml +++ b/.github/workflows/docker-ai.yml @@ -1,8 +1,10 @@ name: AI Docker CI -on: +"on": + pull_request: + branches: ["develop", "main"] push: - branches: [ "develop", "main" ] + branches: ["develop", "main"] workflow_dispatch: permissions: @@ -12,7 +14,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - build-and-push: + build: runs-on: ubuntu-latest steps: @@ -24,13 +26,16 @@ jobs: with: python-version: "3.11" - - name: Install deps - run: pip install -r requirements.txt + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt - - name: Syntax check - run: python -m py_compile app/main.py + - name: Compile app + run: python -m compileall app - name: Login Docker Hub + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -42,15 +47,25 @@ jobs: IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/gachi-ai" SHORT_SHA="${GITHUB_SHA::7}" if [ "${GITHUB_REF_NAME}" = "main" ]; then - echo "tags=${IMAGE}:latest,${IMAGE}:sha-${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "tags=${IMAGE}:latest,${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" else - echo "tags=${IMAGE}:develop,${IMAGE}:sha-${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "tags=${IMAGE}:develop,${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" fi - - name: Build and Push + - name: Build image for pull request + if: github.event_name == 'pull_request' + uses: docker/build-push-action@v7 + with: + context: . + push: false + tags: gachi-ai:pr-${{ github.event.pull_request.number }} + platforms: linux/amd64 + + - name: Build and push image + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' uses: docker/build-push-action@v7 with: context: . push: true tags: ${{ steps.tags.outputs.tags }} - platforms: linux/amd64 \ No newline at end of file + platforms: linux/amd64 diff --git a/.github/workflows/quality-ai.yml b/.github/workflows/quality-ai.yml index d22f745..851211f 100644 --- a/.github/workflows/quality-ai.yml +++ b/.github/workflows/quality-ai.yml @@ -1,10 +1,10 @@ name: AI Quality -on: +"on": pull_request: - branches: [ "develop", "main" ] + branches: ["develop", "main"] push: - branches: [ "develop", "main" ] + branches: ["develop", "main"] permissions: contents: read @@ -13,8 +13,9 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - ruff: + quality: runs-on: ubuntu-latest + steps: - name: Checkout uses: actions/checkout@v6 @@ -24,11 +25,25 @@ jobs: with: python-version: "3.11" - - name: Install Ruff - run: pip install ruff + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install ruff pytest - name: Ruff check run: ruff check . - name: Ruff format check run: ruff format --check . + + - name: Compile app + run: python -m compileall app + + - name: Test if present + run: | + if [ -d tests ]; then + pytest + else + echo "tests 디렉터리가 없어 pytest를 건너뜁니다." + fi diff --git a/README.md b/README.md index 9f97f63..c5b2623 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,45 @@ # GACHI-AI -GACHI 프로젝트 AI 서버(FastAPI) 레포지토리입니다. +GACHI 프로젝트의 AI 서버입니다. 백엔드와 분리된 FastAPI 애플리케이션으로 운영하며, 기존 EC2 `docker-compose`의 `ai` 서비스로 배포합니다. -## 문서 -- `docs/env.md`: 환경 변수 가이드 -- `docs/deploy.md`: 이미지 태그/배포 가이드 +## 역할 + +- 가정통신문 원문과 날짜 후보를 기반으로 일정, 마감, 체크리스트, 알림 항목을 추출합니다. +- OpenAI API 호출 전에도 검증할 수 있도록 비용 없는 rule-based baseline을 제공합니다. +- 실제 LLM에 전달할 prompt-preview API를 제공합니다. + +## 로컬 실행 + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +Windows PowerShell에서는 다음처럼 가상환경을 활성화합니다. + +```powershell +.\.venv\Scripts\Activate.ps1 +``` + +## 주요 엔드포인트 + +- `GET /ai/health`: 헬스체크 +- `GET /ai/docs`: Swagger UI +- `POST /ai/newsletters/extract-items`: 날짜 후보 기반 baseline 추출 +- `POST /ai/newsletters/prompt-preview`: LLM 입력용 prompt와 response schema 미리보기 + +## 작업 규칙 -## 협업 규칙 - 기본 브랜치: `develop` -- 브랜치 전략: `feat/xx`, `refac/xx`, `hotfix/xx`, `chore/xx`, `design/xx`, `bugfix/xx` +- 브랜치 예시: `feat/issue-1-feature-name`, `chore-issue-1-ai-server-ci-cd-setup` - 커밋 타입: `feat`, `fix`, `refactor`, `docs`, `style`, `chore` -- `main`, `develop` 직접 push 금지, PR 승인 후 머지 -- CI 체크(`build-and-push`) 통과 후 머지 +- `main`, `develop` 직접 커밋은 피하고 PR로 병합합니다. + +## 문서 -## 배포 태그 규칙 -- `develop` push: `/gachi-ai:develop` -- `main` push: `/gachi-ai:latest` +- `docs/env.md`: 환경변수 +- `docs/deploy.md`: Docker image와 EC2 배포 방식 +- `docs/newsletter-extraction.md`: 가정통신문 추출 API와 프롬프트 흐름 +- `docs/newsletter-labeling-guide.md`: 정답 데이터 라벨링 기준 diff --git a/app/main.py b/app/main.py index ecadd8e..e535acd 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI -from pydantic import BaseModel + +from app.routers import health, newsletters app = FastAPI( title="GACHI-AI", @@ -9,21 +10,5 @@ openapi_url="/ai/openapi.json", ) - -class EchoRequest(BaseModel): - text: str - - -@app.get("/ai/health") -def health() -> dict: - return {"status": "ok"} - - -@app.get("/ai/ping") -def ping() -> dict: - return {"message": "pong"} - - -@app.post("/ai/echo") -def echo(req: EchoRequest) -> dict: - return {"text": req.text} +app.include_router(health.router) +app.include_router(newsletters.router) diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..25bdb9b --- /dev/null +++ b/app/routers/health.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/ai", tags=["health"]) + + +@router.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@router.get("/ping") +def ping() -> dict[str, str]: + return {"message": "pong"} diff --git a/app/routers/newsletters.py b/app/routers/newsletters.py new file mode 100644 index 0000000..6c34554 --- /dev/null +++ b/app/routers/newsletters.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter + +from app.schemas import ( + NewsletterExtractionRequest, + NewsletterExtractionResponse, + PromptPreviewResponse, +) +from app.services.newsletter_extractor import extract_newsletter_items +from app.services.newsletter_prompt import EXTRACTION_RESPONSE_SCHEMA, build_prompt_messages + +router = APIRouter(prefix="/ai/newsletters", tags=["newsletters"]) + + +@router.post("/extract-items", response_model=NewsletterExtractionResponse) +def extract_items(req: NewsletterExtractionRequest) -> NewsletterExtractionResponse: + return extract_newsletter_items(req) + + +@router.post("/prompt-preview", response_model=PromptPreviewResponse) +def prompt_preview(req: NewsletterExtractionRequest) -> PromptPreviewResponse: + messages = build_prompt_messages(req) + return PromptPreviewResponse(messages=messages, responseSchema=EXTRACTION_RESPONSE_SCHEMA) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..8431cce --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,93 @@ +from datetime import date +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field + + +class ExtractedItemType(StrEnum): + SCHEDULE = "schedule" + DEADLINE = "deadline" + CHECKLIST = "checklist" + REMINDER = "reminder" + + +class DateStatus(StrEnum): + CONFIRMED = "confirmed" + AMBIGUOUS = "ambiguous" + MISSING = "missing" + + +class DateCandidate(BaseModel): + candidate_id: str | None = Field(default=None, alias="candidateId") + original_text: str = Field(alias="originalText") + normalized_date: date = Field(alias="normalizedDate") + start_offset: int = Field(alias="startOffset") + end_offset: int = Field(alias="endOffset") + extraction_type: str | None = Field(default=None, alias="extractionType") + + class Config: + allow_population_by_field_name = True + populate_by_name = True + + +class NewsletterExtractionRequest(BaseModel): + original_text: str = Field(alias="originalText") + translated_text: str | None = Field(default=None, alias="translatedText") + language: str = "KO" + reference_date: date | None = Field(default=None, alias="referenceDate") + timezone: str = "Asia/Seoul" + date_candidates: list[DateCandidate] = Field(default_factory=list, alias="dateCandidates") + + class Config: + allow_population_by_field_name = True + populate_by_name = True + + +class SelectedDateCandidate(BaseModel): + index: int + candidate_id: str | None = Field(default=None, alias="candidateId") + original_text: str = Field(alias="originalText") + normalized_date: date = Field(alias="normalizedDate") + + class Config: + allow_population_by_field_name = True + populate_by_name = True + + +class ExtractedItem(BaseModel): + type: ExtractedItemType + title: str + selected_date_candidate: SelectedDateCandidate | None = Field( + default=None, alias="selectedDateCandidate" + ) + date_status: DateStatus = Field(alias="dateStatus") + datetime: str | None = None + timezone: str + evidence_text: str = Field(alias="evidenceText") + confidence: float = Field(ge=0.0, le=1.0) + needs_user_confirmation: bool = Field(alias="needsUserConfirmation") + confirmation_question: str | None = Field(default=None, alias="confirmationQuestion") + + class Config: + allow_population_by_field_name = True + populate_by_name = True + + +class NewsletterExtractionResponse(BaseModel): + items: list[ExtractedItem] + meta: dict[str, Any] = Field(default_factory=dict) + + +class PromptMessage(BaseModel): + role: str + content: str + + +class PromptPreviewResponse(BaseModel): + messages: list[PromptMessage] + response_schema: dict[str, Any] = Field(alias="responseSchema") + + class Config: + allow_population_by_field_name = True + populate_by_name = True diff --git a/app/services/newsletter_extractor.py b/app/services/newsletter_extractor.py new file mode 100644 index 0000000..7899eb4 --- /dev/null +++ b/app/services/newsletter_extractor.py @@ -0,0 +1,195 @@ +import re +from collections.abc import Iterable + +from app.schemas import ( + DateCandidate, + DateStatus, + ExtractedItem, + ExtractedItemType, + NewsletterExtractionRequest, + NewsletterExtractionResponse, + SelectedDateCandidate, +) + +DEADLINE_KEYWORDS = ( + "마감", + "까지", + "제출", + "신청", + "접수", + "납부", + "등록", + "동의서", + "회신", +) +SCHEDULE_KEYWORDS = ( + "일정", + "행사", + "체험", + "상담", + "설명회", + "교육", + "운영", + "참여", + "개최", + "방문", + "학습", +) +CHECKLIST_KEYWORDS = ( + "준비물", + "지참", + "가져", + "챙겨", + "확인", + "작성", + "서명", + "제출", +) + + +def extract_newsletter_items( + request: NewsletterExtractionRequest, +) -> NewsletterExtractionResponse: + text = request.original_text or "" + items = _extract_candidate_backed_items(text, request) + items.extend(_extract_missing_date_checklists(text, request)) + return NewsletterExtractionResponse( + items=_dedupe_items(items), + meta={ + "mode": "rule_based_baseline", + "dateCandidateCount": len(request.date_candidates), + "requiresLLMReview": True, + }, + ) + + +def _extract_candidate_backed_items( + text: str, + request: NewsletterExtractionRequest, +) -> list[ExtractedItem]: + items = [] + for index, candidate in enumerate(request.date_candidates): + evidence = _evidence_window(text, candidate) + item_type = _classify_item_type(evidence) + selected = SelectedDateCandidate( + index=index, + candidateId=candidate.candidate_id, + originalText=candidate.original_text, + normalizedDate=candidate.normalized_date, + ) + items.append( + ExtractedItem( + type=item_type, + title=_build_title(evidence, item_type), + selectedDateCandidate=selected, + dateStatus=DateStatus.CONFIRMED, + datetime=candidate.normalized_date.isoformat(), + timezone=request.timezone, + evidenceText=evidence, + confidence=_confidence_for(item_type), + needsUserConfirmation=False, + confirmationQuestion=None, + ) + ) + return items + + +def _extract_missing_date_checklists( + text: str, + request: NewsletterExtractionRequest, +) -> list[ExtractedItem]: + items = [] + for sentence in _split_sentences(text): + if not _contains_any(sentence, CHECKLIST_KEYWORDS): + continue + if _overlaps_any_candidate(sentence, request.date_candidates): + continue + items.append( + ExtractedItem( + type=ExtractedItemType.CHECKLIST, + title=_compact_title(sentence), + selectedDateCandidate=None, + dateStatus=DateStatus.MISSING, + datetime=None, + timezone=request.timezone, + evidenceText=sentence, + confidence=0.55, + needsUserConfirmation=True, + confirmationQuestion="이 항목을 체크리스트에 추가할까요?", + ) + ) + return items + + +def _classify_item_type(evidence: str) -> ExtractedItemType: + if _contains_any(evidence, DEADLINE_KEYWORDS): + return ExtractedItemType.DEADLINE + if _contains_any(evidence, SCHEDULE_KEYWORDS): + return ExtractedItemType.SCHEDULE + if _contains_any(evidence, CHECKLIST_KEYWORDS): + return ExtractedItemType.CHECKLIST + return ExtractedItemType.REMINDER + + +def _build_title(evidence: str, item_type: ExtractedItemType) -> str: + title = _compact_title(evidence) + if item_type == ExtractedItemType.DEADLINE and "마감" not in title: + return f"{title} 마감" + return title + + +def _compact_title(text: str) -> str: + title = re.sub(r"\s+", " ", text).strip(" \n\t-::") + if len(title) <= 40: + return title + return title[:39].rstrip() + "..." + + +def _evidence_window(text: str, candidate: DateCandidate) -> str: + if not text: + return candidate.original_text + + start = max(candidate.start_offset - 45, 0) + end = min(candidate.end_offset + 70, len(text)) + window = text[start:end] + left_break = max(window.rfind("\n", 0, candidate.start_offset - start), 0) + right_break = window.find("\n", candidate.end_offset - start) + if right_break == -1: + right_break = len(window) + evidence = window[left_break:right_break] + return re.sub(r"\s+", " ", evidence).strip() or candidate.original_text + + +def _split_sentences(text: str) -> Iterable[str]: + for part in re.split(r"[\n.!?。]+", text): + sentence = re.sub(r"\s+", " ", part).strip(" -::") + if sentence: + yield sentence + + +def _overlaps_any_candidate(sentence: str, candidates: list[DateCandidate]) -> bool: + return any(candidate.original_text in sentence for candidate in candidates) + + +def _contains_any(text: str, keywords: tuple[str, ...]) -> bool: + return any(keyword in text for keyword in keywords) + + +def _confidence_for(item_type: ExtractedItemType) -> float: + if item_type in (ExtractedItemType.DEADLINE, ExtractedItemType.SCHEDULE): + return 0.82 + if item_type == ExtractedItemType.CHECKLIST: + return 0.74 + return 0.62 + + +def _dedupe_items(items: list[ExtractedItem]) -> list[ExtractedItem]: + seen = set() + result = [] + for item in items: + key = (item.type, item.datetime, item.evidence_text) + if key in seen: + continue + seen.add(key) + result.append(item) + return result diff --git a/app/services/newsletter_prompt.py b/app/services/newsletter_prompt.py new file mode 100644 index 0000000..f35be02 --- /dev/null +++ b/app/services/newsletter_prompt.py @@ -0,0 +1,111 @@ +from app.schemas import NewsletterExtractionRequest + +EXTRACTION_RESPONSE_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "type", + "title", + "selectedDateCandidate", + "dateStatus", + "datetime", + "timezone", + "evidenceText", + "confidence", + "needsUserConfirmation", + "confirmationQuestion", + ], + "properties": { + "type": { + "type": "string", + "enum": ["schedule", "deadline", "checklist", "reminder"], + }, + "title": {"type": "string"}, + "selectedDateCandidate": {"type": ["object", "null"]}, + "dateStatus": { + "type": "string", + "enum": ["confirmed", "ambiguous", "missing"], + }, + "datetime": {"type": ["string", "null"]}, + "timezone": {"type": "string"}, + "evidenceText": {"type": "string"}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "needsUserConfirmation": {"type": "boolean"}, + "confirmationQuestion": {"type": ["string", "null"]}, + }, + }, + } + }, +} + + +def build_prompt_messages(request: NewsletterExtractionRequest) -> list[dict[str, str]]: + return [ + {"role": "system", "content": _build_system_prompt()}, + {"role": "user", "content": _build_user_prompt(request)}, + ] + + +def _build_system_prompt() -> str: + return """ +역할: 학교 가정통신문에서 캘린더, 알림, 체크리스트로 만들 항목을 추출한다. + +핵심 규칙: +- 구체적인 날짜는 반드시 제공된 date candidates 중 하나만 선택할 것. +- date candidates에 없는 날짜를 새로 만들거나 추론하지 말 것. +- 날짜 근거가 명확할 때만 dateStatus를 "confirmed"로 설정할 것. +- 날짜 후보가 없거나 근거가 약하면 "ambiguous" 또는 "missing"을 사용할 것. +- evidenceText는 원문에서 짧게 가져올 것. +- response schema에 맞는 JSON만 반환할 것. + +항목 분류 기준: +- deadline: 제출, 신청, 납부, 등록, 동의, 회신, 마감 행동 +- schedule: 행사, 수업, 상담, 체험학습, 설명회, 운영일 +- checklist: 준비물, 지참물, 확인 문서, 보호자나 학생이 해야 할 행동 +- reminder: deadline이나 schedule은 아니지만 알림으로 보여줄 가치가 있는 항목 +""".strip() + + +def _build_user_prompt(request: NewsletterExtractionRequest) -> str: + translated_text = request.translated_text.strip() if request.translated_text else "" + reference_date = request.reference_date.isoformat() if request.reference_date else "null" + sections = [ + f"referenceDate: {reference_date}", + f"timezone: {request.timezone}", + f"language: {request.language}", + "", + "", + _format_candidates(request), + "", + "", + "", + request.original_text.strip(), + "", + ] + if translated_text: + sections.extend(["", "", translated_text, ""]) + return "\n".join(sections) + + +def _format_candidates(request: NewsletterExtractionRequest) -> str: + if not request.date_candidates: + return "[]" + + lines = [] + for index, candidate in enumerate(request.date_candidates): + lines.append( + f"- index: {index}, " + f"candidateId: {candidate.candidate_id or 'null'}, " + f"originalText: {candidate.original_text}, " + f"normalizedDate: {candidate.normalized_date.isoformat()}, " + f"startOffset: {candidate.start_offset}, " + f"endOffset: {candidate.end_offset}" + ) + return "\n".join(lines) diff --git a/docs/deploy.md b/docs/deploy.md index db88849..9d86667 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,42 +1,43 @@ -# GACHI-AI Deploy Guide +# GACHI-AI 배포 가이드 -## 1) 이미지 태그 규칙 -- `develop` 브랜치 push: `/gachi-ai:develop`, `sha-<7자리>` -- `main` 브랜치 push: `/gachi-ai:latest`, `sha-<7자리>` +## Docker image 태그 + +- `develop` push: `/gachi-ai:develop`, `/gachi-ai:sha-xxxxxxx` +- `main` push: `/gachi-ai:latest`, `/gachi-ai:sha-xxxxxxx` + +## GitHub Actions secrets + +Docker image build/push에 필요합니다. -## 2) GitHub Actions 필수 시크릿 - `DOCKERHUB_USERNAME` - `DOCKERHUB_TOKEN` -## 3) EC2 반영 (BE compose에서 함께 기동) +EC2 배포에 필요합니다. -### develop 반영 -```bash -cd ~/GACHI-BE/deploy -sed -i 's|^AI_IMAGE=.*|AI_IMAGE=/gachi-ai:develop|' .env -docker compose --env-file .env pull ai -docker compose --env-file .env up -d --force-recreate ai nginx -``` +- `EC2_INSTANCE_ID` +- `AWS_REGION` +- `AWS_OIDC_ROLE_ARN` 또는 `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` +- 선택: `AWS_SESSION_TOKEN` +- 선택: `EC2_DEPLOY_PATH` 기본값은 `/home/ubuntu/GACHI-BE/deploy` -### main 반영 -```bash -cd ~/GACHI-BE/deploy -sed -i 's|^AI_IMAGE=.*|AI_IMAGE=/gachi-ai:latest|' .env -docker compose --env-file .env pull ai -docker compose --env-file .env up -d --force-recreate ai nginx -``` +## 배포 방식 + +AI 서버는 별도 EC2를 만들지 않고 기존 백엔드 EC2의 compose 파일에 정의된 `ai` 서비스로 배포합니다. + +`main` push에서 `AI Docker CI`가 성공하거나, `workflow_dispatch`로 `deploy-ai-ec2.yml`을 직접 실행하면 다음 작업을 수행합니다. + +1. EC2의 deploy path로 이동 +2. `.env`의 `AI_IMAGE`를 `/gachi-ai:latest`로 갱신 +3. `docker compose --env-file .env pull ai` +4. `ai` 컨테이너 재생성 +5. `ai` health check 확인 +6. 필요 시 `nginx` 재생성 + +## 수동 확인 -## 4) 상태 확인 ```bash +cd /home/ubuntu/GACHI-BE/deploy docker compose --env-file .env ps +curl -i http://localhost:8000/ai/health curl -i http://localhost/ai/health -curl -i http://localhost/ai/docs -``` - -## 5) 롤백 -```bash -cd ~/GACHI-BE/deploy -sed -i 's|^AI_IMAGE=.*|AI_IMAGE=/gachi-ai:sha-<원하는태그>|' .env -docker compose --env-file .env pull ai -docker compose --env-file .env up -d --force-recreate ai nginx ``` diff --git a/docs/env.md b/docs/env.md index 3b19f18..8cc1b13 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1,7 +1,13 @@ -# AI Environment Variables +# AI 서버 환경변수 -## Required -- `OPENAI_API_KEY`: LLM API key +## 필수 -## Optional -- `LOG_LEVEL`: default `INFO` \ No newline at end of file +- `OPENAI_API_KEY`: 추후 LLM API 호출을 붙일 때 사용할 OpenAI API key + +## 선택 + +- `LOG_LEVEL`: 로그 레벨. 기본값은 `INFO` + +## 현재 상태 + +현재 구현은 OpenAI API를 직접 호출하지 않습니다. `OPENAI_API_KEY`는 기존 EC2 compose 환경과 향후 LLM client 연결을 고려해 유지합니다. diff --git a/docs/newsletter-extraction.md b/docs/newsletter-extraction.md new file mode 100644 index 0000000..b626a5a --- /dev/null +++ b/docs/newsletter-extraction.md @@ -0,0 +1,55 @@ +# 가정통신문 항목 추출 설계 + +## 목적 + +가정통신문 본문에서 일정, 마감, 체크리스트, 알림 항목을 추출합니다. + +핵심 원칙은 날짜를 AI가 새로 만들지 않게 하는 것입니다. 구체적인 날짜는 백엔드나 전처리 단계에서 만든 `dateCandidates` 중 하나만 선택해야 합니다. + +## 엔드포인트 + +### `POST /ai/newsletters/extract-items` + +비용 없이 실행되는 rule-based baseline입니다. OpenAI API를 붙이기 전에도 스키마, 날짜 후보 매칭, 샘플 케이스를 확인할 수 있습니다. + +### `POST /ai/newsletters/prompt-preview` + +LLM에 전달할 system/user prompt와 응답 JSON schema를 생성합니다. ChatGPT Plus에서 수동 실험하거나, 추후 OpenAI API 호출에 그대로 사용할 수 있습니다. + +## 요청 예시 + +```json +{ + "originalText": "5월 10일까지 참가 신청서를 제출해주세요.", + "translatedText": null, + "language": "KO", + "referenceDate": "2026-05-06", + "timezone": "Asia/Seoul", + "dateCandidates": [ + { + "candidateId": "dc_1", + "originalText": "5월 10일", + "normalizedDate": "2026-05-10", + "startOffset": 0, + "endOffset": 6, + "extractionType": "REGEX" + } + ] +} +``` + +## 추출 규칙 + +- `confirmed`는 항목이 제공된 date candidate 중 하나를 사용할 때만 부여합니다. +- 날짜 표현이 있지만 후보 매칭이 불확실하면 `ambiguous`로 둡니다. +- 실행 가능한 체크리스트인데 날짜가 없으면 `missing`으로 둡니다. +- `evidenceText`는 원문 근거를 짧게 담습니다. +- 캘린더와 알림 생성은 `confirmed` 항목만 대상으로 삼습니다. + +## 작업 흐름 + +1. 백엔드 또는 전처리 단계에서 날짜 후보를 만듭니다. +2. AI 서버에 원문과 `dateCandidates`를 전달합니다. +3. `extract-items`로 비용 없는 baseline 결과를 먼저 확인합니다. +4. 부족한 케이스는 `prompt-preview` 결과를 ChatGPT Plus에 넣어 비교합니다. +5. 충분히 안정화된 뒤 OpenAI API 호출을 AI 서버 내부에 붙입니다. diff --git a/docs/newsletter-labeling-guide.md b/docs/newsletter-labeling-guide.md new file mode 100644 index 0000000..b347776 --- /dev/null +++ b/docs/newsletter-labeling-guide.md @@ -0,0 +1,39 @@ +# 가정통신문 라벨링 기준 + +본 문서는 사람이 직접 구축하는 정답 데이터와 AI 모델의 판단 기준을 일치시키기 위한 라벨링 가이드라인입니다. + +## 기본 원칙 + +- 원문 근거가 있는 항목만 라벨링한다. +- 날짜는 제공된 `dateCandidates` 중 하나만 선택한다. +- 날짜 후보에 없는 날짜를 사람이 임의로 만들지 않는다. +- 캘린더/알림 생성은 `dateStatus=confirmed` 항목만 대상으로 본다. + +## 항목 분류 + +- `deadline`: 제출, 신청, 납부, 등록, 동의, 회신, 마감처럼 특정 날짜까지 해야 하는 행동 +- `schedule`: 행사, 수업, 상담, 체험학습, 설명회, 운영일처럼 실제로 진행되는 일정 +- `checklist`: 준비물, 지참물, 확인할 문서, 보호자나 학생이 해야 할 행동 +- `reminder`: deadline이나 schedule은 아니지만 알림으로 보여줄 가치가 있는 정보 + +## 날짜 상태 + +- `confirmed`: 원문 근거와 `dateCandidates` 중 하나가 명확히 연결된 상태 +- `ambiguous`: 날짜 표현은 있지만 후보 매칭이 불확실한 상태 +- `missing`: 행동 항목은 있지만 날짜가 없는 상태 + +## 라벨링 예시 + +```json +{ + "type": "deadline", + "title": "체험학습 동의서 제출", + "evidenceText": "5월 20일까지 체험학습 동의서를 제출해주세요.", + "selectedDateCandidateId": "dc_1", + "dateStatus": "confirmed", + "date": "2026-05-20", + "target": "parent", + "actionRequired": true, + "schoolContext": "보호자가 동의서를 확인하고 제출해야 하는 안내" +} +``` diff --git a/requirements.txt b/requirements.txt index f037fb5..60d3c14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ fastapi==0.116.1 -uvicorn[standard]==0.35.0 \ No newline at end of file +pydantic>=2.0,<3.0 +uvicorn[standard]==0.35.0 From ffa82eebc130125c6dfd26d18a17bc5f50d866f7 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 18 May 2026 22:59:42 +0900 Subject: [PATCH 14/23] =?UTF-8?q?docs:=20=EC=9D=BC=EB=B0=98=20PR=EA=B3=BC?= =?UTF-8?q?=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20PR=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE/release.md | 31 ++++++++++++++++++++++++ .github/pull_request_template.md | 16 +++++------- 2 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE/release.md diff --git a/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md new file mode 100644 index 0000000..182c038 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -0,0 +1,31 @@ +## 📌 작업 요약 + +- 요약: + - develop 브랜치의 누적 변경사항을 main으로 릴리즈 배포 +- 관련 이슈: closes # + +## 🌿 브랜치 정보 + +- **Source**: `develop` (기본) +- **Target**: `main` (릴리즈) + +## ✅ 체크리스트 + +- [ ] 브랜치 컨벤션 준수 (`feat/refac/hotfix/chore/design/bugfix`) +- [ ] 커밋 컨벤션 준수 (`feat/fix/refactor/docs/style/chore`) +- [ ] self-review 완료 +- [ ] 테스트 및 로컬 실행 확인 완료 + +## 🧪 테스트 결과 + +- GitHub Actions `deploy-ec2` 실행 확인 (`workflow_dispatch`, ref: `main`) + - 결과: + - 스크린샷: ![ssm-send-step]() + +- 원격 배포 순서/재기동 확인 + - 결과: + - 스크린샷: ![ssm-order]() + +- 배포 후 컨테이너 상태 확인 + - 결과: + - 스크린샷: ![compose-ps]() diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1939f6d..2ce0da1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,24 +1,20 @@ ## 📌 작업 요약 -- 요약: + +- 요약: - 관련 이슈: closes # ## 🌿 브랜치 정보 + - **Source**: `feat/#이슈번호-기능명` - **Target**: `develop` (기본) / `main` (릴리즈, 핫픽스) -## 🧩 변경 타입 -- [ ] feat: 새로운 기능 추가 -- [ ] fix: 버그 수정 -- [ ] refactor: 코드 리팩토링 -- [ ] docs: 문서 수정 -- [ ] style: 코드 포맷팅, 세미콜론 누락 등 -- [ ] chore: 빌드 업무, 패키지 매니저 설정 등 - ## ✅ 체크리스트 + - [ ] 브랜치 컨벤션 준수 (`feat/refac/hotfix/chore/design/bugfix`) - [ ] 커밋 컨벤션 준수 (`feat/fix/refactor/docs/style/chore`) - [ ] self-review 완료 - [ ] 테스트 및 로컬 실행 확인 완료 ## 🧪 테스트 결과 -- (테스트 코드 실행 결과 스크린샷이나 로그, 또는 테스트 방법) \ No newline at end of file + +- (테스트 코드 실행 결과 스크린샷이나 로그, 또는 테스트 방법) From e68d30809e623f19a051fa03abda2f05e4b64b73 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 18 May 2026 23:24:56 +0900 Subject: [PATCH 15/23] =?UTF-8?q?fix:=20CodeRabbit=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE/release.md | 2 +- .github/workflows/docker-ai.yml | 22 ++++++++----- README.md | 2 +- app/schemas.py | 40 +++++++++++------------- 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md index 182c038..b8cff89 100644 --- a/.github/PULL_REQUEST_TEMPLATE/release.md +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -18,7 +18,7 @@ ## 🧪 테스트 결과 -- GitHub Actions `deploy-ec2` 실행 확인 (`workflow_dispatch`, ref: `main`) +- GitHub Actions `Deploy AI to EC2` 실행 확인 (`workflow_dispatch`, ref: `main`) - 결과: - 스크린샷: ![ssm-send-step]() diff --git a/.github/workflows/docker-ai.yml b/.github/workflows/docker-ai.yml index 8bf6bb4..b139edb 100644 --- a/.github/workflows/docker-ai.yml +++ b/.github/workflows/docker-ai.yml @@ -35,7 +35,7 @@ jobs: run: python -m compileall app - name: Login Docker Hub - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (github.ref_name == 'main' || github.ref_name == 'develop') uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -44,25 +44,31 @@ jobs: - name: Set image tags id: tags run: | - IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/gachi-ai" SHORT_SHA="${GITHUB_SHA::7}" - if [ "${GITHUB_REF_NAME}" = "main" ]; then + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + echo "tags=gachi-ai:pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + elif [ "${GITHUB_REF_NAME}" = "main" ]; then + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/gachi-ai" echo "tags=${IMAGE}:latest,${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" - else + elif [ "${GITHUB_REF_NAME}" = "develop" ]; then + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/gachi-ai" echo "tags=${IMAGE}:develop,${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" + else + SAFE_REF="$(echo "${GITHUB_REF_NAME}" | tr '/' '-')" + echo "tags=gachi-ai:${SAFE_REF},gachi-ai:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" fi - - name: Build image for pull request - if: github.event_name == 'pull_request' + - name: Build image without push + if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.ref_name != 'main' && github.ref_name != 'develop') uses: docker/build-push-action@v7 with: context: . push: false - tags: gachi-ai:pr-${{ github.event.pull_request.number }} + tags: ${{ steps.tags.outputs.tags }} platforms: linux/amd64 - name: Build and push image - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (github.ref_name == 'main' || github.ref_name == 'develop') uses: docker/build-push-action@v7 with: context: . diff --git a/README.md b/README.md index c5b2623..ee0601a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Windows PowerShell에서는 다음처럼 가상환경을 활성화합니다. ## 작업 규칙 - 기본 브랜치: `develop` -- 브랜치 예시: `feat/issue-1-feature-name`, `chore-issue-1-ai-server-ci-cd-setup` +- 브랜치 예시: `feat/#1-feature-name`, `chore/#1-ci-cd-setup` - 커밋 타입: `feat`, `fix`, `refactor`, `docs`, `style`, `chore` - `main`, `develop` 직접 커밋은 피하고 PR로 병합합니다. diff --git a/app/schemas.py b/app/schemas.py index 8431cce..867c90c 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -2,7 +2,7 @@ from enum import StrEnum from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class ExtractedItemType(StrEnum): @@ -19,19 +19,25 @@ class DateStatus(StrEnum): class DateCandidate(BaseModel): + model_config = ConfigDict(populate_by_name=True) + candidate_id: str | None = Field(default=None, alias="candidateId") original_text: str = Field(alias="originalText") normalized_date: date = Field(alias="normalizedDate") - start_offset: int = Field(alias="startOffset") - end_offset: int = Field(alias="endOffset") + start_offset: int = Field(alias="startOffset", ge=0) + end_offset: int = Field(alias="endOffset", ge=0) extraction_type: str | None = Field(default=None, alias="extractionType") - class Config: - allow_population_by_field_name = True - populate_by_name = True + @model_validator(mode="after") + def validate_offsets(self) -> "DateCandidate": + if self.end_offset < self.start_offset: + raise ValueError("endOffset must be greater than or equal to startOffset") + return self class NewsletterExtractionRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + original_text: str = Field(alias="originalText") translated_text: str | None = Field(default=None, alias="translatedText") language: str = "KO" @@ -39,23 +45,19 @@ class NewsletterExtractionRequest(BaseModel): timezone: str = "Asia/Seoul" date_candidates: list[DateCandidate] = Field(default_factory=list, alias="dateCandidates") - class Config: - allow_population_by_field_name = True - populate_by_name = True - class SelectedDateCandidate(BaseModel): + model_config = ConfigDict(populate_by_name=True) + index: int candidate_id: str | None = Field(default=None, alias="candidateId") original_text: str = Field(alias="originalText") normalized_date: date = Field(alias="normalizedDate") - class Config: - allow_population_by_field_name = True - populate_by_name = True - class ExtractedItem(BaseModel): + model_config = ConfigDict(populate_by_name=True) + type: ExtractedItemType title: str selected_date_candidate: SelectedDateCandidate | None = Field( @@ -69,10 +71,6 @@ class ExtractedItem(BaseModel): needs_user_confirmation: bool = Field(alias="needsUserConfirmation") confirmation_question: str | None = Field(default=None, alias="confirmationQuestion") - class Config: - allow_population_by_field_name = True - populate_by_name = True - class NewsletterExtractionResponse(BaseModel): items: list[ExtractedItem] @@ -85,9 +83,7 @@ class PromptMessage(BaseModel): class PromptPreviewResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + messages: list[PromptMessage] response_schema: dict[str, Any] = Field(alias="responseSchema") - - class Config: - allow_population_by_field_name = True - populate_by_name = True From 4a0ba84c0a599ac6199520200f9fe4edb9083707 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 18 May 2026 23:57:33 +0900 Subject: [PATCH 16/23] =?UTF-8?q?chore:=20AI=20quality=20job=EC=9D=84=20te?= =?UTF-8?q?st=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/quality-ai.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quality-ai.yml b/.github/workflows/quality-ai.yml index 851211f..842aeec 100644 --- a/.github/workflows/quality-ai.yml +++ b/.github/workflows/quality-ai.yml @@ -13,7 +13,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - quality: + test: runs-on: ubuntu-latest steps: From 84ea06419b17181cf88c7ca5198727880f48080b Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 20 May 2026 01:25:55 +0900 Subject: [PATCH 17/23] =?UTF-8?q?refactor:=20=EA=B0=80=EC=A0=95=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EB=AC=B8=20=EB=B6=84=EC=84=9D=20API=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 ++-- app/routers/newsletters.py | 13 ++- app/schemas.py | 13 ++- app/services/newsletter_extractor.py | 71 ++++++++++++-- app/services/newsletter_prompt.py | 138 +++++++++++++++++---------- docs/newsletter-extraction.md | 133 ++++++++++++++++++++++---- 6 files changed, 292 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index ee0601a..cc9cb60 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # GACHI-AI -GACHI 프로젝트의 AI 서버입니다. 백엔드와 분리된 FastAPI 애플리케이션으로 운영하며, 기존 EC2 `docker-compose`의 `ai` 서비스로 배포합니다. +GACHI 프로젝트의 AI 서버입니다. BE와 분리된 FastAPI 애플리케이션으로 운영하며, 기존 EC2 `docker-compose`의 `ai` 서비스로 배포합니다. ## 역할 -- 가정통신문 원문과 날짜 후보를 기반으로 일정, 마감, 체크리스트, 알림 항목을 추출합니다. -- OpenAI API 호출 전에도 검증할 수 있도록 비용 없는 rule-based baseline을 제공합니다. -- 실제 LLM에 전달할 prompt-preview API를 제공합니다. +- 가정통신문 원문과 날짜 후보를 기반으로 제목, 요약, 일정/마감/체크리스트 항목을 분석합니다. +- AI 서버는 분석 결과 JSON만 반환하고, DB 저장은 BE가 담당합니다. +- OpenAI API 연결 전에도 검증할 수 있도록 비용 없는 rule-based baseline과 prompt-preview API를 제공합니다. ## 로컬 실행 @@ -27,19 +27,20 @@ Windows PowerShell에서는 다음처럼 가상환경을 활성화합니다. - `GET /ai/health`: 헬스체크 - `GET /ai/docs`: Swagger UI -- `POST /ai/newsletters/extract-items`: 날짜 후보 기반 baseline 추출 -- `POST /ai/newsletters/prompt-preview`: LLM 입력용 prompt와 response schema 미리보기 +- `POST /ai/newsletters/analyze`: 제목, 요약, 항목을 함께 반환하는 가정통신문 전체 분석 +- `POST /ai/newsletters/extract-items`: 날짜 후보 기반 baseline 항목 추출 +- `POST /ai/newsletters/prompt-preview`: LLM 입력용 prompt와 최종 분석 response schema 미리보기 ## 작업 규칙 - 기본 브랜치: `develop` - 브랜치 예시: `feat/#1-feature-name`, `chore/#1-ci-cd-setup` - 커밋 타입: `feat`, `fix`, `refactor`, `docs`, `style`, `chore` -- `main`, `develop` 직접 커밋은 피하고 PR로 병합합니다. +- `main`, `develop` 직접 커밋은 지양하고 PR로 병합합니다. ## 문서 - `docs/env.md`: 환경변수 - `docs/deploy.md`: Docker image와 EC2 배포 방식 -- `docs/newsletter-extraction.md`: 가정통신문 추출 API와 프롬프트 흐름 +- `docs/newsletter-extraction.md`: 가정통신문 분석 API 스펙과 프롬프트 흐름 - `docs/newsletter-labeling-guide.md`: 정답 데이터 라벨링 기준 diff --git a/app/routers/newsletters.py b/app/routers/newsletters.py index 6c34554..39cb56b 100644 --- a/app/routers/newsletters.py +++ b/app/routers/newsletters.py @@ -1,16 +1,23 @@ from fastapi import APIRouter from app.schemas import ( + NewsletterAnalysisRequest, + NewsletterAnalysisResponse, NewsletterExtractionRequest, NewsletterExtractionResponse, PromptPreviewResponse, ) -from app.services.newsletter_extractor import extract_newsletter_items -from app.services.newsletter_prompt import EXTRACTION_RESPONSE_SCHEMA, build_prompt_messages +from app.services.newsletter_extractor import analyze_newsletter, extract_newsletter_items +from app.services.newsletter_prompt import ANALYSIS_RESPONSE_SCHEMA, build_prompt_messages router = APIRouter(prefix="/ai/newsletters", tags=["newsletters"]) +@router.post("/analyze", response_model=NewsletterAnalysisResponse) +def analyze(req: NewsletterAnalysisRequest) -> NewsletterAnalysisResponse: + return analyze_newsletter(req) + + @router.post("/extract-items", response_model=NewsletterExtractionResponse) def extract_items(req: NewsletterExtractionRequest) -> NewsletterExtractionResponse: return extract_newsletter_items(req) @@ -19,4 +26,4 @@ def extract_items(req: NewsletterExtractionRequest) -> NewsletterExtractionRespo @router.post("/prompt-preview", response_model=PromptPreviewResponse) def prompt_preview(req: NewsletterExtractionRequest) -> PromptPreviewResponse: messages = build_prompt_messages(req) - return PromptPreviewResponse(messages=messages, responseSchema=EXTRACTION_RESPONSE_SCHEMA) + return PromptPreviewResponse(messages=messages, responseSchema=ANALYSIS_RESPONSE_SCHEMA) diff --git a/app/schemas.py b/app/schemas.py index 867c90c..4a341f9 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -35,7 +35,7 @@ def validate_offsets(self) -> "DateCandidate": return self -class NewsletterExtractionRequest(BaseModel): +class NewsletterAnalysisRequest(BaseModel): model_config = ConfigDict(populate_by_name=True) original_text: str = Field(alias="originalText") @@ -46,6 +46,10 @@ class NewsletterExtractionRequest(BaseModel): date_candidates: list[DateCandidate] = Field(default_factory=list, alias="dateCandidates") +class NewsletterExtractionRequest(NewsletterAnalysisRequest): + pass + + class SelectedDateCandidate(BaseModel): model_config = ConfigDict(populate_by_name=True) @@ -77,6 +81,13 @@ class NewsletterExtractionResponse(BaseModel): meta: dict[str, Any] = Field(default_factory=dict) +class NewsletterAnalysisResponse(BaseModel): + title: str + summary: str + items: list[ExtractedItem] + meta: dict[str, Any] = Field(default_factory=dict) + + class PromptMessage(BaseModel): role: str content: str diff --git a/app/services/newsletter_extractor.py b/app/services/newsletter_extractor.py index 7899eb4..7f71bd0 100644 --- a/app/services/newsletter_extractor.py +++ b/app/services/newsletter_extractor.py @@ -6,6 +6,8 @@ DateStatus, ExtractedItem, ExtractedItemType, + NewsletterAnalysisRequest, + NewsletterAnalysisResponse, NewsletterExtractionRequest, NewsletterExtractionResponse, SelectedDateCandidate, @@ -50,22 +52,47 @@ def extract_newsletter_items( request: NewsletterExtractionRequest, ) -> NewsletterExtractionResponse: + items = _extract_items(request) + return NewsletterExtractionResponse( + items=items, + meta=_build_meta(request), + ) + + +def analyze_newsletter( + request: NewsletterAnalysisRequest, +) -> NewsletterAnalysisResponse: + items = _extract_items(request) + return NewsletterAnalysisResponse( + title=_extract_document_title(request), + summary=_summarize_document(request, items), + items=items, + meta=_build_meta(request), + ) + + +def _extract_items( + request: NewsletterAnalysisRequest, +) -> list[ExtractedItem]: text = request.original_text or "" items = _extract_candidate_backed_items(text, request) items.extend(_extract_missing_date_checklists(text, request)) - return NewsletterExtractionResponse( - items=_dedupe_items(items), - meta={ - "mode": "rule_based_baseline", - "dateCandidateCount": len(request.date_candidates), - "requiresLLMReview": True, - }, - ) + return _dedupe_items(items) + + +def _build_meta(request: NewsletterAnalysisRequest) -> dict[str, object]: + return { + "mode": "rule_based_baseline", + "dateCandidateCount": len(request.date_candidates), + "requiresLLMReview": True, + "retainedDateCandidateInput": True, + "retainedItemResponse": True, + } def _extract_candidate_backed_items( text: str, - request: NewsletterExtractionRequest, + request: NewsletterAnalysisRequest, ) -> list[ExtractedItem]: items = [] for index, candidate in enumerate(request.date_candidates): @@ -96,7 +123,7 @@ def _extract_candidate_backed_items( def _extract_missing_date_checklists( text: str, - request: NewsletterExtractionRequest, + request: NewsletterAnalysisRequest, ) -> list[ExtractedItem]: items = [] for sentence in _split_sentences(text): @@ -193,3 +220,27 @@ def _dedupe_items(items: list[ExtractedItem]) -> list[ExtractedItem]: seen.add(key) result.append(item) return result + + +def _extract_document_title(request: NewsletterAnalysisRequest) -> str: + for line in request.original_text.splitlines(): + title = re.sub(r"\s+", " ", line).strip(" -:\t") + if title: + return title[:80].rstrip() + return "가정통신문" + + +def _summarize_document( + request: NewsletterAnalysisRequest, + items: list[ExtractedItem], +) -> str: + text = request.translated_text or request.original_text + sentences = list(_split_sentences(text)) + if sentences: + summary = " ".join(sentences[:2]) + if len(summary) > 180: + return summary[:179].rstrip() + "..." + return summary + if items: + return f"추출된 주요 항목 {len(items)}건을 확인해야 합니다." + return "분석할 본문 내용이 충분하지 않습니다." diff --git a/app/services/newsletter_prompt.py b/app/services/newsletter_prompt.py index f35be02..b6a4917 100644 --- a/app/services/newsletter_prompt.py +++ b/app/services/newsletter_prompt.py @@ -1,52 +1,84 @@ -from app.schemas import NewsletterExtractionRequest +from app.schemas import NewsletterAnalysisRequest + +SELECTED_DATE_CANDIDATE_SCHEMA = { + "type": ["object", "null"], + "additionalProperties": False, + "required": ["index", "candidateId", "originalText", "normalizedDate"], + "properties": { + "index": {"type": "integer", "minimum": 0}, + "candidateId": {"type": ["string", "null"]}, + "originalText": {"type": "string"}, + "normalizedDate": {"type": "string", "format": "date"}, + }, +} + +ITEM_RESPONSE_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": [ + "type", + "title", + "selectedDateCandidate", + "dateStatus", + "datetime", + "timezone", + "evidenceText", + "confidence", + "needsUserConfirmation", + "confirmationQuestion", + ], + "properties": { + "type": { + "type": "string", + "enum": ["schedule", "deadline", "checklist", "reminder"], + }, + "title": {"type": "string"}, + "selectedDateCandidate": SELECTED_DATE_CANDIDATE_SCHEMA, + "dateStatus": { + "type": "string", + "enum": ["confirmed", "ambiguous", "missing"], + }, + "datetime": {"type": ["string", "null"]}, + "timezone": {"type": "string"}, + "evidenceText": {"type": "string"}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "needsUserConfirmation": {"type": "boolean"}, + "confirmationQuestion": {"type": ["string", "null"]}, + }, +} EXTRACTION_RESPONSE_SCHEMA = { "type": "object", "additionalProperties": False, "required": ["items"], "properties": { - "items": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": False, - "required": [ - "type", - "title", - "selectedDateCandidate", - "dateStatus", - "datetime", - "timezone", - "evidenceText", - "confidence", - "needsUserConfirmation", - "confirmationQuestion", - ], - "properties": { - "type": { - "type": "string", - "enum": ["schedule", "deadline", "checklist", "reminder"], - }, - "title": {"type": "string"}, - "selectedDateCandidate": {"type": ["object", "null"]}, - "dateStatus": { - "type": "string", - "enum": ["confirmed", "ambiguous", "missing"], - }, - "datetime": {"type": ["string", "null"]}, - "timezone": {"type": "string"}, - "evidenceText": {"type": "string"}, - "confidence": {"type": "number", "minimum": 0, "maximum": 1}, - "needsUserConfirmation": {"type": "boolean"}, - "confirmationQuestion": {"type": ["string", "null"]}, - }, + "items": {"type": "array", "items": ITEM_RESPONSE_SCHEMA}, + "meta": {"type": "object"}, + }, +} + +ANALYSIS_RESPONSE_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["title", "summary", "items", "meta"], + "properties": { + "title": {"type": "string"}, + "summary": {"type": "string"}, + "items": {"type": "array", "items": ITEM_RESPONSE_SCHEMA}, + "meta": { + "type": "object", + "additionalProperties": True, + "properties": { + "mode": {"type": "string"}, + "dateCandidateCount": {"type": "integer", "minimum": 0}, + "requiresLLMReview": {"type": "boolean"}, }, - } + }, }, } -def build_prompt_messages(request: NewsletterExtractionRequest) -> list[dict[str, str]]: +def build_prompt_messages(request: NewsletterAnalysisRequest) -> list[dict[str, str]]: return [ {"role": "system", "content": _build_system_prompt()}, {"role": "user", "content": _build_user_prompt(request)}, @@ -55,25 +87,30 @@ def build_prompt_messages(request: NewsletterExtractionRequest) -> list[dict[str def _build_system_prompt() -> str: return """ -역할: 학교 가정통신문에서 캘린더, 알림, 체크리스트로 만들 항목을 추출한다. +역할: 학교 가정통신문 원문을 분석해서 저장 가능한 제목, 요약, +주요 일정/마감/체크리스트 항목을 JSON으로 반환한다. -핵심 규칙: -- 구체적인 날짜는 반드시 제공된 date candidates 중 하나만 선택할 것. -- date candidates에 없는 날짜를 새로 만들거나 추론하지 말 것. -- 날짜 근거가 명확할 때만 dateStatus를 "confirmed"로 설정할 것. -- 날짜 후보가 없거나 근거가 약하면 "ambiguous" 또는 "missing"을 사용할 것. -- evidenceText는 원문에서 짧게 가져올 것. -- response schema에 맞는 JSON만 반환할 것. +응답 원칙: +- response schema에 맞는 JSON만 반환한다. +- AI 서버는 DB 저장을 직접 알지 않는다. 저장 판단은 BE가 하며, AI 서버는 분석 결과만 반환한다. +- title은 문서 제목으로 사용할 수 있는 짧은 문자열로 작성한다. +- summary는 보호자나 학생이 빠르게 확인할 수 있는 1~2문장으로 작성한다. +- items의 구조는 /ai/newsletters/extract-items 응답 형식을 유지한다. +- 구체적인 날짜는 제공된 dateCandidates 중 하나만 선택한다. +- dateCandidates에 없는 날짜를 새로 만들거나 추론해서 confirmed로 반환하지 않는다. +- 날짜 근거가 명확할 때만 dateStatus를 confirmed로 설정한다. +- 날짜 정보가 없거나 근거가 약하면 ambiguous 또는 missing을 사용한다. +- evidenceText는 원문에서 직접 가져온 근거 문장이나 구절로 작성한다. 항목 분류 기준: - deadline: 제출, 신청, 납부, 등록, 동의, 회신, 마감 행동 - schedule: 행사, 수업, 상담, 체험학습, 설명회, 운영일 -- checklist: 준비물, 지참물, 확인 문서, 보호자나 학생이 해야 할 행동 +- checklist: 준비물, 지참물, 확인 문서, 보호자나 학생이 해야 하는 행동 - reminder: deadline이나 schedule은 아니지만 알림으로 보여줄 가치가 있는 항목 """.strip() -def _build_user_prompt(request: NewsletterExtractionRequest) -> str: +def _build_user_prompt(request: NewsletterAnalysisRequest) -> str: translated_text = request.translated_text.strip() if request.translated_text else "" reference_date = request.reference_date.isoformat() if request.reference_date else "null" sections = [ @@ -94,7 +131,7 @@ def _build_user_prompt(request: NewsletterExtractionRequest) -> str: return "\n".join(sections) -def _format_candidates(request: NewsletterExtractionRequest) -> str: +def _format_candidates(request: NewsletterAnalysisRequest) -> str: if not request.date_candidates: return "[]" @@ -106,6 +143,7 @@ def _format_candidates(request: NewsletterExtractionRequest) -> str: f"originalText: {candidate.original_text}, " f"normalizedDate: {candidate.normalized_date.isoformat()}, " f"startOffset: {candidate.start_offset}, " - f"endOffset: {candidate.end_offset}" + f"endOffset: {candidate.end_offset}, " + f"extractionType: {candidate.extraction_type or 'null'}" ) return "\n".join(lines) diff --git a/docs/newsletter-extraction.md b/docs/newsletter-extraction.md index b626a5a..50c51ea 100644 --- a/docs/newsletter-extraction.md +++ b/docs/newsletter-extraction.md @@ -1,26 +1,52 @@ -# 가정통신문 항목 추출 설계 +# 가정통신문 분석 API 스펙 ## 목적 -가정통신문 본문에서 일정, 마감, 체크리스트, 알림 항목을 추출합니다. +BE가 가정통신문 저장에 필요한 `title`, `summary`, `items`를 AI 서버에서 한 번에 받을 수 있도록 최종 분석 API 계약을 정의한다. -핵심 원칙은 날짜를 AI가 새로 만들지 않게 하는 것입니다. 구체적인 날짜는 백엔드나 전처리 단계에서 만든 `dateCandidates` 중 하나만 선택해야 합니다. +AI 서버의 책임은 원문과 날짜 후보를 분석해 JSON을 반환하는 것이다. DB 저장 여부, 저장 모델 매핑, 사용자 확인 플로우는 BE가 담당한다. ## 엔드포인트 +### `POST /ai/newsletters/analyze` + +가정통신문 전체 분석 API다. 제목, 요약, 일정/마감/체크리스트 항목, 분석 메타데이터를 반환한다. + ### `POST /ai/newsletters/extract-items` -비용 없이 실행되는 rule-based baseline입니다. OpenAI API를 붙이기 전에도 스키마, 날짜 후보 매칭, 샘플 케이스를 확인할 수 있습니다. +기존 항목 추출 API다. `items` 응답 형식 검증과 비용 없는 rule-based baseline 확인 용도로 유지한다. ### `POST /ai/newsletters/prompt-preview` -LLM에 전달할 system/user prompt와 응답 JSON schema를 생성합니다. ChatGPT Plus에서 수동 실험하거나, 추후 OpenAI API 호출에 그대로 사용할 수 있습니다. +LLM 호출 전 system/user prompt와 최종 분석 응답 JSON schema를 확인하는 API다. `responseSchema`는 `/ai/newsletters/analyze` 응답 구조를 기준으로 한다. + +## Analyze 요청 스키마 + +`/ai/newsletters/analyze`는 기존 `extract-items` 입력 형식을 그대로 사용한다. + +| 필드 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| `originalText` | `string` | 예 | 가정통신문 원문 | +| `translatedText` | `string \| null` | 아니오 | 번역 또는 OCR 보정 텍스트. 없으면 `null` | +| `language` | `string` | 아니오 | 원문 언어. 기본값은 `KO` | +| `referenceDate` | `date \| null` | 아니오 | 상대 날짜 해석 기준일. ISO-8601 날짜 | +| `timezone` | `string` | 아니오 | 날짜/시간 기준 타임존. 기본값은 `Asia/Seoul` | +| `dateCandidates` | `DateCandidate[]` | 아니오 | BE 또는 전처리 단계가 추출한 날짜 후보 목록 | -## 요청 예시 +### `DateCandidate` + +| 필드 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| `candidateId` | `string \| null` | 아니오 | BE가 후보 추적에 사용할 수 있는 식별자 | +| `originalText` | `string` | 예 | 원문에 등장한 날짜 표현 | +| `normalizedDate` | `date` | 예 | 정규화된 날짜 | +| `startOffset` | `integer` | 예 | 원문 기준 시작 offset | +| `endOffset` | `integer` | 예 | 원문 기준 종료 offset | +| `extractionType` | `string \| null` | 아니오 | 후보 추출 방식. 예: `REGEX`, `OCR`, `MANUAL` | ```json { - "originalText": "5월 10일까지 참가 신청서를 제출해주세요.", + "originalText": "2026학년도 체험학습 신청 안내\n5월 10일까지 참가 신청서를 제출해 주세요.", "translatedText": null, "language": "KO", "referenceDate": "2026-05-06", @@ -30,26 +56,91 @@ LLM에 전달할 system/user prompt와 응답 JSON schema를 생성합니다. Ch "candidateId": "dc_1", "originalText": "5월 10일", "normalizedDate": "2026-05-10", - "startOffset": 0, - "endOffset": 6, + "startOffset": 18, + "endOffset": 24, "extractionType": "REGEX" } ] } ``` -## 추출 규칙 +## Analyze 응답 스키마 + +| 필드 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| `title` | `string` | 예 | 문서 제목. BE의 가정통신문 제목 저장값으로 사용 가능 | +| `summary` | `string` | 예 | 문서 요약. BE의 요약 저장값으로 사용 가능 | +| `items` | `ExtractedItem[]` | 예 | 일정, 마감, 체크리스트, 알림 항목 | +| `meta` | `object` | 예 | 분석 모드, 후보 개수 등 저장 비대상 메타데이터 | + +### `ExtractedItem` + +기존 `extract-items` 응답 형식을 유지한다. + +| 필드 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| `type` | `schedule \| deadline \| checklist \| reminder` | 예 | 항목 분류 | +| `title` | `string` | 예 | 항목 제목. 문서 제목인 top-level `title`과 다름 | +| `selectedDateCandidate` | `SelectedDateCandidate \| null` | 예 | 선택된 날짜 후보. 날짜가 없으면 `null` | +| `dateStatus` | `confirmed \| ambiguous \| missing` | 예 | 날짜 신뢰 상태 | +| `datetime` | `string \| null` | 예 | 저장 가능한 날짜/시간 문자열. 없으면 `null` | +| `timezone` | `string` | 예 | 항목 날짜/시간 기준 타임존 | +| `evidenceText` | `string` | 예 | 원문 근거 | +| `confidence` | `number` | 예 | 0~1 범위 신뢰도 | +| `needsUserConfirmation` | `boolean` | 예 | 사용자 확인 필요 여부 | +| `confirmationQuestion` | `string \| null` | 예 | 사용자에게 물어볼 확인 질문 | + +### `SelectedDateCandidate` + +| 필드 | 타입 | 필수 | 설명 | +| --- | --- | --- | --- | +| `index` | `integer` | 예 | 요청 `dateCandidates` 배열의 index | +| `candidateId` | `string \| null` | 예 | 요청 후보의 `candidateId` | +| `originalText` | `string` | 예 | 요청 후보의 `originalText` | +| `normalizedDate` | `date` | 예 | 요청 후보의 `normalizedDate` | + +```json +{ + "title": "2026학년도 체험학습 신청 안내", + "summary": "체험학습 참가 신청서를 5월 10일까지 제출해야 합니다.", + "items": [ + { + "type": "deadline", + "title": "체험학습 참가 신청서 제출", + "selectedDateCandidate": { + "index": 0, + "candidateId": "dc_1", + "originalText": "5월 10일", + "normalizedDate": "2026-05-10" + }, + "dateStatus": "confirmed", + "datetime": "2026-05-10", + "timezone": "Asia/Seoul", + "evidenceText": "5월 10일까지 참가 신청서를 제출해 주세요.", + "confidence": 0.86, + "needsUserConfirmation": false, + "confirmationQuestion": null + } + ], + "meta": { + "mode": "rule_based_baseline", + "dateCandidateCount": 1, + "requiresLLMReview": true + } +} +``` + +## 유지 여부 검토 -- `confirmed`는 항목이 제공된 date candidate 중 하나를 사용할 때만 부여합니다. -- 날짜 표현이 있지만 후보 매칭이 불확실하면 `ambiguous`로 둡니다. -- 실행 가능한 체크리스트인데 날짜가 없으면 `missing`으로 둡니다. -- `evidenceText`는 원문 근거를 짧게 담습니다. -- 캘린더와 알림 생성은 `confirmed` 항목만 대상으로 삼습니다. +- `dateCandidates` 입력 형식은 유지한다. 날짜 정규화 책임을 BE 또는 전처리 단계에 두고, AI 서버는 후보 중 하나를 선택한다. +- `items` 응답 형식은 유지한다. 기존 `extract-items` 소비 코드가 항목 구조를 그대로 검증할 수 있어야 한다. +- `items[].title`은 항목 제목이고, top-level `title`은 문서 제목이다. BE 매핑 시 두 필드를 구분해야 한다. +- `meta`는 저장 모델과 직접 매핑하지 않는다. 디버깅, 분석 모드 표시, 후보 개수 확인용이다. +- `selectedDateCandidate`, `evidenceText`, `confidence`, `needsUserConfirmation`, `confirmationQuestion`은 BE 저장 정책에 따라 저장하거나 무시할 수 있는 분석 보조 필드다. +- `datetime`은 `dateStatus`가 `confirmed`일 때만 캘린더/알림 저장 대상으로 보는 것을 권장한다. `ambiguous` 또는 `missing`은 사용자 확인 플로우로 넘긴다. -## 작업 흐름 +## 경계 -1. 백엔드 또는 전처리 단계에서 날짜 후보를 만듭니다. -2. AI 서버에 원문과 `dateCandidates`를 전달합니다. -3. `extract-items`로 비용 없는 baseline 결과를 먼저 확인합니다. -4. 부족한 케이스는 `prompt-preview` 결과를 ChatGPT Plus에 넣어 비교합니다. -5. 충분히 안정화된 뒤 OpenAI API 호출을 AI 서버 내부에 붙입니다. +- BE: 원문 저장, 날짜 후보 생성 또는 전달, 분석 결과 저장, 사용자 확인 처리. +- AI 서버: 원문 분석, 제목/요약/항목 추출, 날짜 후보 매칭, JSON 반환. +- AI 서버는 DB 테이블명, 저장 ID, ORM 모델을 요청이나 응답 스키마에 포함하지 않는다. From 6249fa81e3401f8633a556a7ea7c88226f7c4982 Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 20 May 2026 01:53:35 +0900 Subject: [PATCH 18/23] =?UTF-8?q?fix:=20=EA=B0=80=EC=A0=95=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EB=AC=B8=20=EB=B6=84=EC=84=9D=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/newsletter_extractor.py | 3 ++- docs/newsletter-extraction.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/services/newsletter_extractor.py b/app/services/newsletter_extractor.py index 7f71bd0..8b234a8 100644 --- a/app/services/newsletter_extractor.py +++ b/app/services/newsletter_extractor.py @@ -234,7 +234,8 @@ def _summarize_document( request: NewsletterAnalysisRequest, items: list[ExtractedItem], ) -> str: - text = request.translated_text or request.original_text + translated = (request.translated_text or "").strip() + text = translated or request.original_text sentences = list(_split_sentences(text)) if sentences: summary = " ".join(sentences[:2]) diff --git a/docs/newsletter-extraction.md b/docs/newsletter-extraction.md index 50c51ea..5384732 100644 --- a/docs/newsletter-extraction.md +++ b/docs/newsletter-extraction.md @@ -22,7 +22,7 @@ LLM 호출 전 system/user prompt와 최종 분석 응답 JSON schema를 확인 ## Analyze 요청 스키마 -`/ai/newsletters/analyze`는 기존 `extract-items` 입력 형식을 그대로 사용한다. +`/ai/newsletters/analyze`는 기존 `extract-items` 입력 계약을 그대로 사용한다. 따라서 `dateCandidates`의 `startOffset`, `endOffset`도 기존과 동일하게 필수다. | 필드 | 타입 | 필수 | 설명 | | --- | --- | --- | --- | @@ -133,6 +133,7 @@ LLM 호출 전 system/user prompt와 최종 분석 응답 JSON schema를 확인 ## 유지 여부 검토 - `dateCandidates` 입력 형식은 유지한다. 날짜 정규화 책임을 BE 또는 전처리 단계에 두고, AI 서버는 후보 중 하나를 선택한다. +- `startOffset`, `endOffset`은 기존 `extract-items` 입력 계약과 동일하게 필수다. AI 서버가 원문 근거 범위를 안정적으로 찾기 위해 사용한다. - `items` 응답 형식은 유지한다. 기존 `extract-items` 소비 코드가 항목 구조를 그대로 검증할 수 있어야 한다. - `items[].title`은 항목 제목이고, top-level `title`은 문서 제목이다. BE 매핑 시 두 필드를 구분해야 한다. - `meta`는 저장 모델과 직접 매핑하지 않는다. 디버깅, 분석 모드 표시, 후보 개수 확인용이다. From 3783fcb058d3ff7f22c4ef3b0a88409e19620acb Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 20 May 2026 13:22:13 +0900 Subject: [PATCH 19/23] =?UTF-8?q?feat:=20OpenAI=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=96=B4=EB=8C=91=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 +- README.md | 5 +- app/config.py | 42 +++++++ app/routers/newsletters.py | 16 ++- app/services/newsletter_extractor.py | 20 ++++ app/services/openai_adapter.py | 112 +++++++++++++++++++ docs/env.md | 18 ++- docs/newsletter-extraction.md | 161 ++++----------------------- 8 files changed, 231 insertions(+), 149 deletions(-) create mode 100644 app/config.py create mode 100644 app/services/openai_adapter.py diff --git a/.env.example b/.env.example index 9847a1d..22f53fa 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,5 @@ -OPENAI_API_KEY= \ No newline at end of file +OPENAI_ENABLED=false +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_TIMEOUT_SECONDS=60 diff --git a/README.md b/README.md index cc9cb60..4292b40 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ GACHI 프로젝트의 AI 서버입니다. BE와 분리된 FastAPI 애플리케 - 가정통신문 원문과 날짜 후보를 기반으로 제목, 요약, 일정/마감/체크리스트 항목을 분석합니다. - AI 서버는 분석 결과 JSON만 반환하고, DB 저장은 BE가 담당합니다. -- OpenAI API 연결 전에도 검증할 수 있도록 비용 없는 rule-based baseline과 prompt-preview API를 제공합니다. +- `OPENAI_ENABLED=true`일 때 OpenAI API를 호출하고, 기본값에서는 비용 없는 rule-based baseline으로 응답합니다. +- 기존 baseline API와 prompt-preview API를 유지합니다. ## 로컬 실행 @@ -42,5 +43,5 @@ Windows PowerShell에서는 다음처럼 가상환경을 활성화합니다. - `docs/env.md`: 환경변수 - `docs/deploy.md`: Docker image와 EC2 배포 방식 -- `docs/newsletter-extraction.md`: 가정통신문 분석 API 스펙과 프롬프트 흐름 +- `docs/newsletter-extraction.md`: 가정통신문 분석 구현 경계와 유지 결정 - `docs/newsletter-labeling-guide.md`: 정답 데이터 라벨링 기준 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..86f5a28 --- /dev/null +++ b/app/config.py @@ -0,0 +1,42 @@ +import os +from dataclasses import dataclass + + +def _read_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _read_float(name: str, default: float) -> float: + value = os.getenv(name) + if value is None or value.strip() == "": + return default + try: + return float(value) + except ValueError: + return default + + +@dataclass(frozen=True) +class OpenAISettings: + enabled: bool + api_key: str | None + model: str + base_url: str + timeout_seconds: float + + @classmethod + def from_env(cls) -> "OpenAISettings": + return cls( + enabled=_read_bool("OPENAI_ENABLED", default=False), + api_key=os.getenv("OPENAI_API_KEY") or None, + model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"), + base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), + timeout_seconds=_read_float("OPENAI_TIMEOUT_SECONDS", 60.0), + ) + + +def get_openai_settings() -> OpenAISettings: + return OpenAISettings.from_env() diff --git a/app/routers/newsletters.py b/app/routers/newsletters.py index 39cb56b..d3f0b9a 100644 --- a/app/routers/newsletters.py +++ b/app/routers/newsletters.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, status from app.schemas import ( NewsletterAnalysisRequest, @@ -9,13 +9,25 @@ ) from app.services.newsletter_extractor import analyze_newsletter, extract_newsletter_items from app.services.newsletter_prompt import ANALYSIS_RESPONSE_SCHEMA, build_prompt_messages +from app.services.openai_adapter import OpenAIAdapterError, OpenAIConfigurationError router = APIRouter(prefix="/ai/newsletters", tags=["newsletters"]) @router.post("/analyze", response_model=NewsletterAnalysisResponse) def analyze(req: NewsletterAnalysisRequest) -> NewsletterAnalysisResponse: - return analyze_newsletter(req) + try: + return analyze_newsletter(req) + except OpenAIConfigurationError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except OpenAIAdapterError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc @router.post("/extract-items", response_model=NewsletterExtractionResponse) diff --git a/app/services/newsletter_extractor.py b/app/services/newsletter_extractor.py index 8b234a8..9884c59 100644 --- a/app/services/newsletter_extractor.py +++ b/app/services/newsletter_extractor.py @@ -1,6 +1,8 @@ +import logging import re from collections.abc import Iterable +from app.config import get_openai_settings from app.schemas import ( DateCandidate, DateStatus, @@ -12,6 +14,9 @@ NewsletterExtractionResponse, SelectedDateCandidate, ) +from app.services.openai_adapter import OpenAINewsletterAdapter + +logger = logging.getLogger(__name__) DEADLINE_KEYWORDS = ( "마감", @@ -62,6 +67,21 @@ def extract_newsletter_items( def analyze_newsletter( request: NewsletterAnalysisRequest, ) -> NewsletterAnalysisResponse: + settings = get_openai_settings() + if settings.enabled: + logger.info("[NewsletterAnalysis] OpenAI 분석 모드로 실행합니다. model=%s", settings.model) + response = OpenAINewsletterAdapter(settings).analyze(request) + meta = dict(response.meta) + meta.update( + { + "mode": "openai", + "model": settings.model, + "dateCandidateCount": len(request.date_candidates), + "requiresLLMReview": False, + } + ) + return response.model_copy(update={"meta": meta}) + items = _extract_items(request) return NewsletterAnalysisResponse( title=_extract_document_title(request), diff --git a/app/services/openai_adapter.py b/app/services/openai_adapter.py new file mode 100644 index 0000000..fe5b9e5 --- /dev/null +++ b/app/services/openai_adapter.py @@ -0,0 +1,112 @@ +import json +import logging +import urllib.error +import urllib.request +from typing import Any + +from pydantic import ValidationError + +from app.config import OpenAISettings +from app.schemas import NewsletterAnalysisRequest, NewsletterAnalysisResponse +from app.services.newsletter_prompt import ANALYSIS_RESPONSE_SCHEMA, build_prompt_messages + +logger = logging.getLogger(__name__) + + +class OpenAIAdapterError(RuntimeError): + pass + + +class OpenAIConfigurationError(OpenAIAdapterError): + pass + + +class OpenAINewsletterAdapter: + def __init__(self, settings: OpenAISettings) -> None: + self.settings = settings + + def analyze(self, request: NewsletterAnalysisRequest) -> NewsletterAnalysisResponse: + if not self.settings.api_key: + raise OpenAIConfigurationError("OPENAI_API_KEY가 설정되어 있지 않습니다.") + + payload = { + "model": self.settings.model, + "input": build_prompt_messages(request), + "text": { + "format": { + "type": "json_schema", + "name": "newsletter_analysis", + "schema": ANALYSIS_RESPONSE_SCHEMA, + "strict": False, + } + }, + } + + response_body = self._post_json("/responses", payload) + parsed = self._extract_output_json(response_body) + try: + return NewsletterAnalysisResponse.model_validate(parsed) + except ValidationError as exc: + logger.warning("[OpenAIAdapter] 응답 스키마 검증 실패. error=%s", exc) + raise OpenAIAdapterError("OpenAI 응답이 분석 스키마와 일치하지 않습니다.") from exc + + def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + url = self.settings.base_url.rstrip("/") + path + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {self.settings.api_key}", + "Content-Type": "application/json", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=self.settings.timeout_seconds) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + logger.warning( + "[OpenAIAdapter] OpenAI 호출 실패. status=%s, body=%s", + exc.code, + error_body[:1000], + ) + raise OpenAIAdapterError(f"OpenAI 호출 실패. status={exc.code}") from exc + except urllib.error.URLError as exc: + logger.warning("[OpenAIAdapter] OpenAI 통신 오류. reason=%s", exc.reason) + raise OpenAIAdapterError("OpenAI 통신 오류가 발생했습니다.") from exc + except TimeoutError as exc: + logger.warning( + "[OpenAIAdapter] OpenAI 호출 timeout. timeout=%s", + self.settings.timeout_seconds, + ) + raise OpenAIAdapterError("OpenAI 호출 시간이 초과되었습니다.") from exc + except json.JSONDecodeError as exc: + logger.warning("[OpenAIAdapter] OpenAI 응답 JSON 파싱 실패. error=%s", exc) + raise OpenAIAdapterError("OpenAI 응답을 JSON으로 해석할 수 없습니다.") from exc + + def _extract_output_json(self, response_body: dict[str, Any]) -> dict[str, Any]: + output_text = response_body.get("output_text") + if isinstance(output_text, str) and output_text.strip(): + return self._loads_model_json(output_text) + + for output in response_body.get("output", []): + for content in output.get("content", []): + text = content.get("text") + if isinstance(text, str) and text.strip(): + return self._loads_model_json(text) + + raise OpenAIAdapterError("OpenAI 응답에서 출력 텍스트를 찾을 수 없습니다.") + + def _loads_model_json(self, value: str) -> dict[str, Any]: + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + logger.warning("[OpenAIAdapter] 모델 출력 JSON 파싱 실패. output=%s", value[:1000]) + raise OpenAIAdapterError("OpenAI 모델 출력이 JSON 형식이 아닙니다.") from exc + + if not isinstance(parsed, dict): + raise OpenAIAdapterError("OpenAI 모델 출력이 JSON object가 아닙니다.") + return parsed diff --git a/docs/env.md b/docs/env.md index 8cc1b13..06e2b19 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1,13 +1,19 @@ # AI 서버 환경변수 -## 필수 +## OpenAI 호출 -- `OPENAI_API_KEY`: 추후 LLM API 호출을 붙일 때 사용할 OpenAI API key +- `OPENAI_ENABLED`: OpenAI 실제 호출 활성화 여부. 기본값은 `false` +- `OPENAI_API_KEY`: OpenAI API key. `OPENAI_ENABLED=true`일 때 필요 +- `OPENAI_MODEL`: 사용할 모델. 기본값은 `gpt-4o-mini` +- `OPENAI_BASE_URL`: OpenAI API base URL. 기본값은 `https://api.openai.com/v1` +- `OPENAI_TIMEOUT_SECONDS`: OpenAI 호출 timeout 초. 기본값은 `60` -## 선택 +비용 방지를 위해 로컬과 배포 기본값은 `OPENAI_ENABLED=false`로 둔다. +이 상태에서 `/ai/newsletters/analyze`는 기존 rule-based baseline으로 응답한다. -- `LOG_LEVEL`: 로그 레벨. 기본값은 `INFO` +`OPENAI_ENABLED=true`인데 `OPENAI_API_KEY`가 없으면 `/ai/newsletters/analyze`는 `503`을 반환한다. +OpenAI 호출 또는 응답 파싱이 실패하면 `502`를 반환하고, 로그에는 상태 코드나 예외 사유를 남긴다. -## 현재 상태 +## 기타 -현재 구현은 OpenAI API를 직접 호출하지 않습니다. `OPENAI_API_KEY`는 기존 EC2 compose 환경과 향후 LLM client 연결을 고려해 유지합니다. +- `LOG_LEVEL`: 로그 레벨. 기본값은 `INFO` diff --git a/docs/newsletter-extraction.md b/docs/newsletter-extraction.md index 5384732..770b6da 100644 --- a/docs/newsletter-extraction.md +++ b/docs/newsletter-extraction.md @@ -1,147 +1,32 @@ -# 가정통신문 분석 API 스펙 +# 가정통신문 분석 구현 메모 -## 목적 +이 문서는 API 명세서가 아니라 AI 서버 구현 경계와 유지 결정만 정리한다. +상세 request/response 명세는 노션을 기준으로 관리한다. -BE가 가정통신문 저장에 필요한 `title`, `summary`, `items`를 AI 서버에서 한 번에 받을 수 있도록 최종 분석 API 계약을 정의한다. +## 구현 범위 -AI 서버의 책임은 원문과 날짜 후보를 분석해 JSON을 반환하는 것이다. DB 저장 여부, 저장 모델 매핑, 사용자 확인 플로우는 BE가 담당한다. +- AI 서버는 가정통신문 원문을 분석해 JSON 결과만 반환한다. +- DB 저장, 저장 모델 매핑, 사용자 확인 플로우는 BE가 담당한다. +- `POST /ai/newsletters/analyze`는 문서 제목, 요약, 항목, 메타데이터를 반환한다. +- `POST /ai/newsletters/extract-items`는 기존 항목 추출 baseline 확인용으로 유지한다. +- `POST /ai/newsletters/prompt-preview`는 LLM 호출 전 prompt와 응답 schema를 확인하는 용도로 유지한다. -## 엔드포인트 +## 유지 결정 -### `POST /ai/newsletters/analyze` +- `dateCandidates` 입력 형식은 기존 `extract-items` 계약을 유지한다. +- `startOffset`, `endOffset`은 기존 계약과 동일하게 필수다. +- `items` 응답 형식은 기존 `extract-items` 응답 구조를 유지한다. +- top-level `title`은 문서 제목이고, `items[].title`은 항목 제목이다. +- `meta`는 저장 모델과 직접 매핑하지 않는 분석 보조 정보다. -가정통신문 전체 분석 API다. 제목, 요약, 일정/마감/체크리스트 항목, 분석 메타데이터를 반환한다. +## 책임 경계 -### `POST /ai/newsletters/extract-items` - -기존 항목 추출 API다. `items` 응답 형식 검증과 비용 없는 rule-based baseline 확인 용도로 유지한다. - -### `POST /ai/newsletters/prompt-preview` - -LLM 호출 전 system/user prompt와 최종 분석 응답 JSON schema를 확인하는 API다. `responseSchema`는 `/ai/newsletters/analyze` 응답 구조를 기준으로 한다. - -## Analyze 요청 스키마 - -`/ai/newsletters/analyze`는 기존 `extract-items` 입력 계약을 그대로 사용한다. 따라서 `dateCandidates`의 `startOffset`, `endOffset`도 기존과 동일하게 필수다. - -| 필드 | 타입 | 필수 | 설명 | -| --- | --- | --- | --- | -| `originalText` | `string` | 예 | 가정통신문 원문 | -| `translatedText` | `string \| null` | 아니오 | 번역 또는 OCR 보정 텍스트. 없으면 `null` | -| `language` | `string` | 아니오 | 원문 언어. 기본값은 `KO` | -| `referenceDate` | `date \| null` | 아니오 | 상대 날짜 해석 기준일. ISO-8601 날짜 | -| `timezone` | `string` | 아니오 | 날짜/시간 기준 타임존. 기본값은 `Asia/Seoul` | -| `dateCandidates` | `DateCandidate[]` | 아니오 | BE 또는 전처리 단계가 추출한 날짜 후보 목록 | - -### `DateCandidate` - -| 필드 | 타입 | 필수 | 설명 | -| --- | --- | --- | --- | -| `candidateId` | `string \| null` | 아니오 | BE가 후보 추적에 사용할 수 있는 식별자 | -| `originalText` | `string` | 예 | 원문에 등장한 날짜 표현 | -| `normalizedDate` | `date` | 예 | 정규화된 날짜 | -| `startOffset` | `integer` | 예 | 원문 기준 시작 offset | -| `endOffset` | `integer` | 예 | 원문 기준 종료 offset | -| `extractionType` | `string \| null` | 아니오 | 후보 추출 방식. 예: `REGEX`, `OCR`, `MANUAL` | - -```json -{ - "originalText": "2026학년도 체험학습 신청 안내\n5월 10일까지 참가 신청서를 제출해 주세요.", - "translatedText": null, - "language": "KO", - "referenceDate": "2026-05-06", - "timezone": "Asia/Seoul", - "dateCandidates": [ - { - "candidateId": "dc_1", - "originalText": "5월 10일", - "normalizedDate": "2026-05-10", - "startOffset": 18, - "endOffset": 24, - "extractionType": "REGEX" - } - ] -} -``` - -## Analyze 응답 스키마 - -| 필드 | 타입 | 필수 | 설명 | -| --- | --- | --- | --- | -| `title` | `string` | 예 | 문서 제목. BE의 가정통신문 제목 저장값으로 사용 가능 | -| `summary` | `string` | 예 | 문서 요약. BE의 요약 저장값으로 사용 가능 | -| `items` | `ExtractedItem[]` | 예 | 일정, 마감, 체크리스트, 알림 항목 | -| `meta` | `object` | 예 | 분석 모드, 후보 개수 등 저장 비대상 메타데이터 | - -### `ExtractedItem` - -기존 `extract-items` 응답 형식을 유지한다. - -| 필드 | 타입 | 필수 | 설명 | -| --- | --- | --- | --- | -| `type` | `schedule \| deadline \| checklist \| reminder` | 예 | 항목 분류 | -| `title` | `string` | 예 | 항목 제목. 문서 제목인 top-level `title`과 다름 | -| `selectedDateCandidate` | `SelectedDateCandidate \| null` | 예 | 선택된 날짜 후보. 날짜가 없으면 `null` | -| `dateStatus` | `confirmed \| ambiguous \| missing` | 예 | 날짜 신뢰 상태 | -| `datetime` | `string \| null` | 예 | 저장 가능한 날짜/시간 문자열. 없으면 `null` | -| `timezone` | `string` | 예 | 항목 날짜/시간 기준 타임존 | -| `evidenceText` | `string` | 예 | 원문 근거 | -| `confidence` | `number` | 예 | 0~1 범위 신뢰도 | -| `needsUserConfirmation` | `boolean` | 예 | 사용자 확인 필요 여부 | -| `confirmationQuestion` | `string \| null` | 예 | 사용자에게 물어볼 확인 질문 | - -### `SelectedDateCandidate` - -| 필드 | 타입 | 필수 | 설명 | -| --- | --- | --- | --- | -| `index` | `integer` | 예 | 요청 `dateCandidates` 배열의 index | -| `candidateId` | `string \| null` | 예 | 요청 후보의 `candidateId` | -| `originalText` | `string` | 예 | 요청 후보의 `originalText` | -| `normalizedDate` | `date` | 예 | 요청 후보의 `normalizedDate` | - -```json -{ - "title": "2026학년도 체험학습 신청 안내", - "summary": "체험학습 참가 신청서를 5월 10일까지 제출해야 합니다.", - "items": [ - { - "type": "deadline", - "title": "체험학습 참가 신청서 제출", - "selectedDateCandidate": { - "index": 0, - "candidateId": "dc_1", - "originalText": "5월 10일", - "normalizedDate": "2026-05-10" - }, - "dateStatus": "confirmed", - "datetime": "2026-05-10", - "timezone": "Asia/Seoul", - "evidenceText": "5월 10일까지 참가 신청서를 제출해 주세요.", - "confidence": 0.86, - "needsUserConfirmation": false, - "confirmationQuestion": null - } - ], - "meta": { - "mode": "rule_based_baseline", - "dateCandidateCount": 1, - "requiresLLMReview": true - } -} -``` - -## 유지 여부 검토 - -- `dateCandidates` 입력 형식은 유지한다. 날짜 정규화 책임을 BE 또는 전처리 단계에 두고, AI 서버는 후보 중 하나를 선택한다. -- `startOffset`, `endOffset`은 기존 `extract-items` 입력 계약과 동일하게 필수다. AI 서버가 원문 근거 범위를 안정적으로 찾기 위해 사용한다. -- `items` 응답 형식은 유지한다. 기존 `extract-items` 소비 코드가 항목 구조를 그대로 검증할 수 있어야 한다. -- `items[].title`은 항목 제목이고, top-level `title`은 문서 제목이다. BE 매핑 시 두 필드를 구분해야 한다. -- `meta`는 저장 모델과 직접 매핑하지 않는다. 디버깅, 분석 모드 표시, 후보 개수 확인용이다. -- `selectedDateCandidate`, `evidenceText`, `confidence`, `needsUserConfirmation`, `confirmationQuestion`은 BE 저장 정책에 따라 저장하거나 무시할 수 있는 분석 보조 필드다. -- `datetime`은 `dateStatus`가 `confirmed`일 때만 캘린더/알림 저장 대상으로 보는 것을 권장한다. `ambiguous` 또는 `missing`은 사용자 확인 플로우로 넘긴다. +- BE: 원문 저장, 날짜 후보 생성 또는 전달, 분석 결과 저장, 사용자 확인 처리. +- AI 서버: 제목/요약/항목 추출, 날짜 후보 매칭, JSON 반환. +- AI 서버 요청/응답에는 DB 테이블명, 저장 ID, ORM 모델을 포함하지 않는다. -## 경계 +## 문서 관리 원칙 -- BE: 원문 저장, 날짜 후보 생성 또는 전달, 분석 결과 저장, 사용자 확인 처리. -- AI 서버: 원문 분석, 제목/요약/항목 추출, 날짜 후보 매칭, JSON 반환. -- AI 서버는 DB 테이블명, 저장 ID, ORM 모델을 요청이나 응답 스키마에 포함하지 않는다. +- API 명세의 원본은 노션으로 둔다. +- repo 문서는 코드 변경 시 같이 봐야 하는 구현 결정만 남긴다. +- 한글 표 정렬이 깨지는 문제를 피하기 위해 긴 필드 표는 repo 문서에 두지 않는다. From f393436b7c972445bcb5ccaa97e942fcd41d3aed Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 20 May 2026 17:01:08 +0900 Subject: [PATCH 20/23] =?UTF-8?q?fix:=20OpenAI=20=EC=96=B4=EB=8C=91?= =?UTF-8?q?=ED=84=B0=20=EC=84=A4=EC=A0=95=20=EA=B2=80=EC=A6=9D=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 24 ++++++++++++++++++------ app/services/openai_adapter.py | 24 +++++++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/app/config.py b/app/config.py index 86f5a28..a3dc697 100644 --- a/app/config.py +++ b/app/config.py @@ -9,12 +9,23 @@ def _read_bool(name: str, default: bool = False) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} -def _read_float(name: str, default: float) -> float: +def _read_str(name: str, default: str | None = None) -> str | None: + value = os.getenv(name) + if value is None: + return default + normalized = value.strip() + return normalized or default + + +def _read_float(name: str, default: float, *, min_value: float | None = None) -> float: value = os.getenv(name) if value is None or value.strip() == "": return default try: - return float(value) + parsed = float(value) + if min_value is not None and parsed < min_value: + return default + return parsed except ValueError: return default @@ -31,10 +42,11 @@ class OpenAISettings: def from_env(cls) -> "OpenAISettings": return cls( enabled=_read_bool("OPENAI_ENABLED", default=False), - api_key=os.getenv("OPENAI_API_KEY") or None, - model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"), - base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), - timeout_seconds=_read_float("OPENAI_TIMEOUT_SECONDS", 60.0), + api_key=_read_str("OPENAI_API_KEY"), + model=_read_str("OPENAI_MODEL", "gpt-4o-mini") or "gpt-4o-mini", + base_url=_read_str("OPENAI_BASE_URL", "https://api.openai.com/v1") + or "https://api.openai.com/v1", + timeout_seconds=_read_float("OPENAI_TIMEOUT_SECONDS", 60.0, min_value=0.001), ) diff --git a/app/services/openai_adapter.py b/app/services/openai_adapter.py index fe5b9e5..d0d8009 100644 --- a/app/services/openai_adapter.py +++ b/app/services/openai_adapter.py @@ -69,9 +69,9 @@ def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: except urllib.error.HTTPError as exc: error_body = exc.read().decode("utf-8", errors="replace") logger.warning( - "[OpenAIAdapter] OpenAI 호출 실패. status=%s, body=%s", + "[OpenAIAdapter] OpenAI 호출 실패. status=%s, body_length=%s", exc.code, - error_body[:1000], + len(error_body), ) raise OpenAIAdapterError(f"OpenAI 호출 실패. status={exc.code}") from exc except urllib.error.URLError as exc: @@ -92,8 +92,19 @@ def _extract_output_json(self, response_body: dict[str, Any]) -> dict[str, Any]: if isinstance(output_text, str) and output_text.strip(): return self._loads_model_json(output_text) - for output in response_body.get("output", []): - for content in output.get("content", []): + outputs = response_body.get("output", []) + if not isinstance(outputs, list): + raise OpenAIAdapterError("OpenAI 응답의 output 형식이 올바르지 않습니다.") + + for output in outputs: + if not isinstance(output, dict): + continue + contents = output.get("content", []) + if not isinstance(contents, list): + continue + for content in contents: + if not isinstance(content, dict): + continue text = content.get("text") if isinstance(text, str) and text.strip(): return self._loads_model_json(text) @@ -104,7 +115,10 @@ def _loads_model_json(self, value: str) -> dict[str, Any]: try: parsed = json.loads(value) except json.JSONDecodeError as exc: - logger.warning("[OpenAIAdapter] 모델 출력 JSON 파싱 실패. output=%s", value[:1000]) + logger.warning( + "[OpenAIAdapter] 모델 출력 JSON 파싱 실패. output_length=%s", + len(value), + ) raise OpenAIAdapterError("OpenAI 모델 출력이 JSON 형식이 아닙니다.") from exc if not isinstance(parsed, dict): From 55b52617e6edb048ab2f78417d1392b44f828552 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 25 May 2026 12:38:19 +0900 Subject: [PATCH 21/23] =?UTF-8?q?feat:=20=EA=B0=80=EC=A0=95=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EB=AC=B8=20=EB=9D=BC=EB=B2=A8=EB=A7=81=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=8F=89=EA=B0=80=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 + README.md | 11 + app/config.py | 2 +- data/newsletter-labels/README.md | 34 ++ data/newsletter-labels/newsletter-001.json | 86 ++++ data/newsletter-labels/newsletter-002.json | 117 +++++ data/newsletter-labels/newsletter-003.json | 139 +++++ data/newsletter-labels/newsletter-004.json | 66 +++ data/newsletter-labels/newsletter-005.json | 98 ++++ data/newsletter-labels/newsletter-006.json | 55 ++ data/newsletter-labels/newsletter-007.json | 94 ++++ data/newsletter-labels/newsletter-008.json | 60 +++ data/newsletter-labels/newsletter-009.json | 83 +++ data/newsletter-labels/newsletter-010.json | 71 +++ data/newsletter-labels/newsletter-011.json | 60 +++ data/newsletter-labels/newsletter-012.json | 94 ++++ data/newsletter-labels/newsletter-013.json | 60 +++ data/newsletter-labels/newsletter-014.json | 66 +++ data/newsletter-labels/newsletter-015.json | 55 ++ data/newsletter-labels/newsletter-016.json | 77 +++ data/newsletter-labels/newsletter-017.json | 75 +++ data/newsletter-labels/newsletter-018.json | 53 ++ data/newsletter-labels/newsletter-019.json | 64 +++ data/newsletter-labels/newsletter-020.json | 49 ++ data/newsletter-labels/newsletter-021.json | 55 ++ data/newsletter-labels/newsletter-022.json | 83 +++ data/newsletter-labels/newsletter-023.json | 53 ++ data/newsletter-labels/newsletter-024.json | 64 +++ data/newsletter-labels/newsletter-025.json | 55 ++ data/newsletter-labels/newsletter-026.json | 94 ++++ data/newsletter-labels/newsletter-027.json | 82 +++ data/newsletter-labels/newsletter-028.json | 174 +++++++ data/newsletter-labels/newsletter-029.json | 88 ++++ data/newsletter-labels/newsletter-030.json | 110 ++++ docs/env.md | 2 +- docs/newsletter-evaluation.md | 133 +++++ scripts/evaluate_newsletter_labels.py | 561 +++++++++++++++++++++ 37 files changed, 3128 insertions(+), 2 deletions(-) create mode 100644 data/newsletter-labels/README.md create mode 100644 data/newsletter-labels/newsletter-001.json create mode 100644 data/newsletter-labels/newsletter-002.json create mode 100644 data/newsletter-labels/newsletter-003.json create mode 100644 data/newsletter-labels/newsletter-004.json create mode 100644 data/newsletter-labels/newsletter-005.json create mode 100644 data/newsletter-labels/newsletter-006.json create mode 100644 data/newsletter-labels/newsletter-007.json create mode 100644 data/newsletter-labels/newsletter-008.json create mode 100644 data/newsletter-labels/newsletter-009.json create mode 100644 data/newsletter-labels/newsletter-010.json create mode 100644 data/newsletter-labels/newsletter-011.json create mode 100644 data/newsletter-labels/newsletter-012.json create mode 100644 data/newsletter-labels/newsletter-013.json create mode 100644 data/newsletter-labels/newsletter-014.json create mode 100644 data/newsletter-labels/newsletter-015.json create mode 100644 data/newsletter-labels/newsletter-016.json create mode 100644 data/newsletter-labels/newsletter-017.json create mode 100644 data/newsletter-labels/newsletter-018.json create mode 100644 data/newsletter-labels/newsletter-019.json create mode 100644 data/newsletter-labels/newsletter-020.json create mode 100644 data/newsletter-labels/newsletter-021.json create mode 100644 data/newsletter-labels/newsletter-022.json create mode 100644 data/newsletter-labels/newsletter-023.json create mode 100644 data/newsletter-labels/newsletter-024.json create mode 100644 data/newsletter-labels/newsletter-025.json create mode 100644 data/newsletter-labels/newsletter-026.json create mode 100644 data/newsletter-labels/newsletter-027.json create mode 100644 data/newsletter-labels/newsletter-028.json create mode 100644 data/newsletter-labels/newsletter-029.json create mode 100644 data/newsletter-labels/newsletter-030.json create mode 100644 docs/newsletter-evaluation.md create mode 100644 scripts/evaluate_newsletter_labels.py diff --git a/.gitignore b/.gitignore index e12d78e..879d79f 100644 --- a/.gitignore +++ b/.gitignore @@ -191,6 +191,13 @@ cython_debug/ # Ruff stuff: .ruff_cache/ +# Newsletter evaluation +reports/ +data/newsletter-labels/*.pdf +data/newsletter-labels/*.jpg +data/newsletter-labels/*.jpeg +data/newsletter-labels/*.png + # PyPI configuration file .pypirc diff --git a/README.md b/README.md index 4292b40..286cae0 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,16 @@ Windows PowerShell에서는 다음처럼 가상환경을 활성화합니다. - `POST /ai/newsletters/extract-items`: 날짜 후보 기반 baseline 항목 추출 - `POST /ai/newsletters/prompt-preview`: LLM 입력용 prompt와 최종 분석 response schema 미리보기 +## 분석 평가 + +라벨링된 가정통신문 JSON으로 baseline 또는 OpenAI adapter 결과를 평가할 수 있습니다. + +```powershell +python scripts/evaluate_newsletter_labels.py data/newsletter-labels --report-output reports/newsletter-eval-baseline.json +``` + +기본 모드는 비용이 발생하지 않는 baseline입니다. 실제 OpenAI 호출은 `--mode openai`를 명시한 경우에만 실행합니다. + ## 작업 규칙 - 기본 브랜치: `develop` @@ -45,3 +55,4 @@ Windows PowerShell에서는 다음처럼 가상환경을 활성화합니다. - `docs/deploy.md`: Docker image와 EC2 배포 방식 - `docs/newsletter-extraction.md`: 가정통신문 분석 구현 경계와 유지 결정 - `docs/newsletter-labeling-guide.md`: 정답 데이터 라벨링 기준 +- `docs/newsletter-evaluation.md`: 라벨링 데이터 기반 분석 평가 실행 방법 diff --git a/app/config.py b/app/config.py index a3dc697..38540a3 100644 --- a/app/config.py +++ b/app/config.py @@ -43,7 +43,7 @@ def from_env(cls) -> "OpenAISettings": return cls( enabled=_read_bool("OPENAI_ENABLED", default=False), api_key=_read_str("OPENAI_API_KEY"), - model=_read_str("OPENAI_MODEL", "gpt-4o-mini") or "gpt-4o-mini", + model=_read_str("OPENAI_MODEL", "gpt-4.1-mini") or "gpt-4.1-mini", base_url=_read_str("OPENAI_BASE_URL", "https://api.openai.com/v1") or "https://api.openai.com/v1", timeout_seconds=_read_float("OPENAI_TIMEOUT_SECONDS", 60.0, min_value=0.001), diff --git a/data/newsletter-labels/README.md b/data/newsletter-labels/README.md new file mode 100644 index 0000000..d736a74 --- /dev/null +++ b/data/newsletter-labels/README.md @@ -0,0 +1,34 @@ +# 가정통신문 라벨링 데이터 + +이 디렉터리는 가정통신문 분석 평가에 사용하는 JSON 정답셋을 둔다. + +## 파일 매칭 + +- JSON 파일명과 원본 파일명은 같은 번호를 사용한다. +- 예: `newsletter-001.json`의 원본은 `newsletter-001.pdf` +- 원본 파일 확장자는 `pdf`, `jpg`, `png`를 사용할 수 있다. +- `hwp`는 현재 파이프라인 입력으로 사용하지 않는다. + +## 커밋 기준 + +- JSON 라벨 파일은 평가 재현을 위해 repo에 포함한다. +- PDF/JPG/PNG 원본은 로컬 검수와 Notion 공유용으로 유지하고 repo에는 포함하지 않는다. +- 원본이 필요하면 Notion 자료 또는 로컬 파일명 번호로 JSON과 매칭한다. + +## 현재 JSON 스키마 + +현재 라벨 파일은 다음 구조를 사용한다. + +```json +{ + "documentId": "doc_001", + "documentTitle": "문서 제목", + "documentDate": "2026-03-24", + "school": "학교명", + "dateCandidates": [], + "labels": [] +} +``` + +`originalText`가 없는 라벨은 평가 스크립트가 `documentTitle`, `school`, `documentDate`, `labels[].evidenceText`를 이어 붙여 baseline 입력으로 사용한다. +OCR 원문까지 평가하려면 추후 JSON에 `originalText`를 추가한다. diff --git a/data/newsletter-labels/newsletter-001.json b/data/newsletter-labels/newsletter-001.json new file mode 100644 index 0000000..3a048f0 --- /dev/null +++ b/data/newsletter-labels/newsletter-001.json @@ -0,0 +1,86 @@ +{ + "documentId": "doc_001", + "documentTitle": "2026학년도 1학기 학습준비물 안내", + "documentDate": "2026-03-24", + "school": "서울세륜초등학교", + "dateCandidates": [], + "labels": [ + { + "type": "reminder", + "title": "학교 지원 vs 가정 구매 준비물 구분 안내", + "evidenceText": "학부모님의 부담을 경감하고자 학생들에게 학습준비물을 지원하고 있습니다. / 학년 공통으로 지원되는 학습준비물 / 가정에서 직접 구매가 필요한 학습준비물", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 초등학교는 일부 학용품을 학교 예산으로 무료 제공합니다. '학교 지원' 목록에 있는 것은 구매하지 않아도 됩니다. '가정 구매' 목록에 있는 것만 직접 준비하면 됩니다." + }, + { + "type": "checklist", + "title": "1학년 가정 구매 준비물 준비", + "evidenceText": "가정에서 직접 구매가 필요한 학습준비물 / 1학년: A4 클리어파일(미술작품보관용)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": null + }, + { + "type": "checklist", + "title": "2학년 가정 구매 준비물 준비", + "evidenceText": "2학년: 가위, 풀, 색연필, 사인펜, 투명 테이프, 15cm 자, 줄노트, 연필 등", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": null + }, + { + "type": "checklist", + "title": "3학년 가정 구매 준비물 준비", + "evidenceText": "3학년: 자, 네임펜, 공책, 연필, 지우개 등", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": null + }, + { + "type": "checklist", + "title": "4학년 가정 구매 준비물 준비", + "evidenceText": "4학년: 리코더, 삼각자(작년에 나눠준 리코더, 컴퍼스 세트 없는 사람 준비), 풀, 테이프, 공책류 등", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "리코더는 한국 초등학교 음악 수업에서 자주 사용하는 부는 악기입니다. 문서에 “작년에 나눠준 리코더, 컴퍼스 세트 없는 사람 준비”라고 되어 있으므로, 이미 학교에서 받은 물품이 있으면 다시 사지 않아도 되고 없는 경우에만 준비하면 됩니다." + }, + { + "type": "checklist", + "title": "5학년 가정 구매 준비물 준비", + "evidenceText": "5학년: 필기도구(천으로 된 필통, 연필 3자루 이상, 지우개, 플라스틱 15cm 자), 가위, 풀, 스카치테이프, 검정색 네임펜, 색연필(12색), 싸인펜(12색), L자 파일(가정통신문 배부용), 줄노트, 줄넘기", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "L자 파일은 학교에서 가정으로 보내는 종이 공지문(가정통신문)을 보관하는 용도입니다. 한국 학교는 공지사항을 종이로 인쇄해 학생 편에 보내는 문화가 있습니다. 문구점에서 쉽게 구매할 수 있습니다." + }, + { + "type": "checklist", + "title": "6학년 가정 구매 준비물 준비", + "evidenceText": "6학년: 필기도구(천으로 된 필통, 연필 3자루 이상, 지우개, 플라스틱 15cm 자), 가위, 풀, 스카치테이프, 검정색 네임펜, 색연필(12색), 싸인펜(12색), L자 파일(가정통신문 배부용), 줄노트, 부는 악기류(음악시간 안내)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "L자 파일은 학교에서 가정으로 보내는 종이 공지문(가정통신문)을 보관하는 용도입니다. '부는 악기류'는 음악 수업 시작 시 담임 선생님이 별도로 안내합니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-002.json b/data/newsletter-labels/newsletter-002.json new file mode 100644 index 0000000..52e4f7c --- /dev/null +++ b/data/newsletter-labels/newsletter-002.json @@ -0,0 +1,117 @@ +{ + "documentId": "doc_002", + "documentTitle": "세륜 한마음 대운동회 안내", + "documentDate": "2026-04-24", + "school": "서울세륜초등학교", + "dateCandidates": [ + { + "id": "dc_003_1", + "raw": "2026. 5. 4.(월)", + "resolved": "2026-05-04", + "note": "운동회 당일 날짜" + }, + { + "id": "dc_003_2", + "raw": "9:00", + "resolved": "2026-05-04T09:00", + "note": "운동회 시작 시간" + }, + { + "id": "dc_003_3", + "raw": "13:40", + "resolved": "2026-05-04T13:40", + "note": "1~2학년 하교 시간" + }, + { + "id": "dc_003_4", + "raw": "14:30", + "resolved": "2026-05-04T14:30", + "note": "3~6학년 하교 시간" + }, + { + "id": "dc_003_5", + "raw": "12시", + "resolved": "2026-05-04T12:00", + "note": "급식 시작 시간" + } + ], + "labels": [ + { + "type": "schedule", + "title": "세륜 한마음 대운동회", + "evidenceText": "일시: 2026. 5. 4.(월) 9:00~14:30 / 장소: 본교 운동장 (우천 시, 순연)", + "selectedDateCandidateId": "dc_003_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "both", + "actionRequired": false, + "schoolContext": "한국 초등학교 운동회는 학부모도 참관할 수 있는 공개 행사입니다. 학교가 지정한 학부모 관람 구역(학부모석)이 있으며, 돗자리 등을 직접 가져와 앉아서 관람합니다. 별도 입장권은 필요 없습니다." + }, + { + "type": "reminder", + "title": "우천 시 행사 순연 안내", + "evidenceText": "장소: 본교 운동장 (우천 시, 순연)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "비가 오면 행사가 취소되는 것이 아니라 날짜가 뒤로 미뤄집니다(순연). 순연 날짜는 학교가 별도로 공지하므로 학교 홈페이지나 알림 앱을 확인하세요." + }, + { + "type": "reminder", + "title": "운동회 당일 하교 시간 변경", + "evidenceText": "운동회 당일 하교 시간 안내: 1~2학년 13:40분, 3~6학년 14:30분에 하교할 예정입니다.", + "selectedDateCandidateId": "dc_003_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "parent", + "actionRequired": false, + "schoolContext": "운동회 당일은 평소와 하교 시간이 다릅니다. 자녀를 데리러 오시는 경우 위 시간을 참고하세요." + }, + { + "type": "checklist", + "title": "돗자리 등 관람 준비물 지참", + "evidenceText": "학부모님은 학부모석(출진문 옆 천막)에서 자유롭게 관람합니다(돗자리 등 준비).", + "selectedDateCandidateId": "dc_003_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "parent", + "actionRequired": true, + "schoolContext": null + }, + { + "type": "checklist", + "title": "편한 복장 및 운동화 착용", + "evidenceText": "운동회 진행 시 각 팀의 학부모 참여를 요청할 수 있으므로 편한 복장과 운동화를 신고 참여해 주시기 바랍니다.", + "selectedDateCandidateId": "dc_003_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "parent", + "actionRequired": true, + "schoolContext": "한국 운동회에서는 학부모도 일부 경기(줄다리기 등)에 직접 참여하는 경우가 있습니다. 편한 옷과 운동화를 준비하세요." + }, + { + "type": "reminder", + "title": "외부 음식 학급 전달 금지", + "evidenceText": "학생 및 학급에 외부음식 전달은 금지합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "운동회 당일 학교 급식이 제공됩니다. 식품 알레르기와 형평성 문제를 방지하기 위해 학부모가 교실이나 학생에게 별도 음식을 전달하는 것은 금지됩니다." + }, + { + "type": "checklist", + "title": "학부모 점심 간편식 지참", + "evidenceText": "학부모님의 점심식사는 간편식으로 준비해 오셔서 드시기를 권장합니다.", + "selectedDateCandidateId": "dc_003_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "parent", + "actionRequired": true, + "schoolContext": "학교 급식은 학생에게만 제공됩니다. 참관하는 학부모는 점심을 직접 준비해 오셔야 합니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-003.json b/data/newsletter-labels/newsletter-003.json new file mode 100644 index 0000000..c0e607e --- /dev/null +++ b/data/newsletter-labels/newsletter-003.json @@ -0,0 +1,139 @@ +{ + "documentId": "doc_003", + "documentTitle": "2026학년도 서울세륜초등학교 영재학급 영재교육대상자 선발 요강", + "documentDate": "2026-03-13", + "school": "서울세륜초등학교", + "dateCandidates": [ + { + "id": "dc_004_1", + "raw": "3.30.(월)~4.3.(금) 16:00까지", + "resolved": "2026-04-03", + "note": "원서 접수 마감일" + }, + { + "id": "dc_004_2", + "raw": "4.6.(월)~4.7.(화)", + "resolved": "2026-04-06", + "note": "관찰추천 기간" + }, + { + "id": "dc_004_3", + "raw": "4.7.(화) 14:40~16:00", + "resolved": "2026-04-07T14:40", + "note": "지필평가 일시" + }, + { + "id": "dc_004_4", + "raw": "4.10.(금) 16:00", + "resolved": "2026-04-10T16:00", + "note": "최종 합격자 발표" + }, + { + "id": "dc_004_5", + "raw": "4.17.(금)", + "resolved": "2026-04-17", + "note": "입급식 날짜" + } + ], + "labels": [ + { + "type": "reminder", + "title": "영재학급 제도 안내", + "evidenceText": "서울세륜초등학교 영재학급의 설치를 서울시교육청으로부터 정식 인가받아 운영하고 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "영재학급은 수학·과학 등 특정 분야에서 뛰어난 능력을 보이는 학생을 선발해 심화 교육을 제공하는 한국의 공식 교육 제도입니다. 일반 수업과 별도로 운영되며, 서울시교육청의 공식 인가를 받은 프로그램입니다." + }, + { + "type": "checklist", + "title": "담임 선생님께 추천 의향 확인", + "evidenceText": "2026년 3월 현재, 우리 학교의 4~5학년 재학생으로 담임교사의 추천을 받은 학생", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "한국 영재교육 지원은 학부모가 단독으로 신청할 수 없습니다. 반드시 담임 선생님의 추천이 있어야 지원 자격이 생깁니다. 원서 접수 전에 먼저 담임 선생님과 상담하세요." + }, + { + "type": "deadline", + "title": "영재학급 원서 접수", + "evidenceText": "접수 기간: 2026년 3월 30일(월) ~ 4월 3일(금) 16:00까지 (기한엄수) / 접수 장소: (4층) 교과교육실", + "selectedDateCandidateId": "dc_004_1", + "dateStatus": "confirmed", + "date": "2026-04-03", + "target": "parent", + "actionRequired": true, + "schoolContext": null + }, + { + "type": "schedule", + "title": "창의적 문제해결력 평가 (지필평가)", + "evidenceText": "4.7.(화) 14:40~16:00 창의적 문제해결력 평가 응시 / 평가 시작 10분 전에 입실을 완료해야 평가에 응시할 수 있음", + "selectedDateCandidateId": "dc_004_3", + "dateStatus": "confirmed", + "date": "2026-04-07", + "target": "both", + "actionRequired": true, + "schoolContext": "한국 영재학급 선발에는 창의적 문제해결력을 평가하는 지필시험이 포함됩니다. 시험 10분 전까지 입실해야 응시 가능하며, 준비물은 학생이 직접 챙겨야 합니다." + }, + { + "type": "schedule", + "title": "최종 합격자 발표", + "evidenceText": "최종 합격자 이알리미 개별 공지 / 4.10.(금) 16:00 예정", + "selectedDateCandidateId": "dc_004_4", + "dateStatus": "confirmed", + "date": "2026-04-10", + "target": "parent", + "actionRequired": false, + "schoolContext": "이알리미는 한국 학교에서 공지사항을 학부모 스마트폰으로 전송하는 알림 서비스입니다. 앱을 설치하지 않은 경우 알림을 받지 못할 수 있으니 확인하세요." + }, + { + "type": "reminder", + "title": "다문화가정 자녀 사회다양성전형 우선 선발 안내", + "evidenceText": "다문화가정 자녀 - 사회다양성전형 1순위", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 영재교육에는 다문화가정 자녀를 위한 별도 전형(사회다양성전형)이 있습니다. 일반 경쟁 없이 우선 선발 대상이 되며, 소득 기준(중위소득 160% 이하)을 충족하면 지원할 수 있습니다." + }, + { + "type": "checklist", + "title": "다문화가정 전형 증빙 서류 준비", + "evidenceText": "다문화가정 자녀: 본인 기본증명서 1부, 부모 혼인관계 증명서 1부, 외국인 등록증명서(해당자) 1부 또는 귀화 증명서(해당자) 1부", + "selectedDateCandidateId": "dc_004_1", + "dateStatus": "confirmed", + "date": "2026-04-03", + "target": "parent", + "actionRequired": true, + "schoolContext": "사회다양성전형으로 지원하는 다문화가정은 위 서류를 원서 접수 시 함께 제출해야 합니다. 서류가 없으면 일반전형으로 전환됩니다." + }, + { + "type": "reminder", + "title": "영재학급 이수 시 학교생활기록부 기재", + "evidenceText": "학교생활기록부에 영재교육 이수 실적이 기재됨", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "학교생활기록부(생기부)는 한국 학생의 학교 생활 전반이 기록되는 공식 문서로, 중학교·고등학교 진학 시 중요하게 활용됩니다. 영재학급 이수 기록이 여기에 남으면 진학에 도움이 될 수 있습니다." + }, + { + "type": "reminder", + "title": "수강료 및 저소득 가정 지원 안내", + "evidenceText": "학생 1인당 수강료: 금 600,000원 / 기회균등전형은 자유수강권을 활용하여 수강이 가능", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "영재학급은 공식 교육 제도이지만 수강료(연 60만원)가 부과됩니다. 단, 기초생활수급자 등 저소득 가정은 자유수강권(정부 지원금)으로 수강료를 면제받을 수 있습니다. 해당 여부는 학교에 문의하세요." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-004.json b/data/newsletter-labels/newsletter-004.json new file mode 100644 index 0000000..34bd1c3 --- /dev/null +++ b/data/newsletter-labels/newsletter-004.json @@ -0,0 +1,66 @@ +{ + "documentId": "doc_004", + "documentTitle": "학생생활규정, 학교규칙 6차 개정 공포 및 시행 안내", + "documentDate": "2026-03-09", + "school": "서울세륜초등학교", + "dateCandidates": [ + { + "id": "dc_009_1", + "raw": "26.3.9.일자", + "resolved": "2026-03-09", + "note": "개정 규정 공포일" + }, + { + "id": "dc_009_2", + "raw": "2026.3.1.시행", + "resolved": "2026-03-01", + "note": "초중등교육법 개정 시행일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "학생생활규정·학교규칙 개정 안내", + "evidenceText": "개정된 학생생활규정과 학교규칙을 26.3.9.일자로 공포합니다.", + "selectedDateCandidateId": "dc_009_1", + "dateStatus": "confirmed", + "date": "2026-03-09", + "target": "parent", + "actionRequired": false, + "schoolContext": "학생생활규정과 학교규칙은 학생의 학교생활 전반에 적용되는 규칙입니다. 개정될 때마다 가정통신문으로 학부모에게 안내됩니다. 전체 내용은 e알리미 앱 또는 학교 홈페이지에서 확인할 수 있습니다." + }, + { + "type": "reminder", + "title": "수업 중 스마트기기 사용 금지 규정 강화", + "evidenceText": "제14조(교내 스마트기기 사용 제한 등) ① 학생은 수업 중에 휴대전화 등 스마트기기를 사용해서는 안 된다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "both", + "actionRequired": false, + "schoolContext": "한국 학교에서는 수업 중 휴대폰 사용이 엄격히 제한됩니다. 이번 개정으로 위반 시 교사가 기기를 직접 수거·보관할 수 있게 되었습니다. 자녀에게 수업 중 스마트폰 사용 금지를 미리 안내해주세요." + }, + { + "type": "reminder", + "title": "학생 징계 단계 및 기준 안내", + "evidenceText": "제36조(징계) 학교장은 생활교육(소)위원회의 결정에 따라 학생 교육을 위해 필요하다고 인정할 때에는 학생에게 다음 각 호의 징계를 할 수 있다. 1. 학교 내의 봉사 2. 사회봉사 3. 특별교육이수 4. 출석정지", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 학교에서 학생이 규칙을 위반하면 단계적 징계가 적용됩니다. 가장 가벼운 것이 학교 내 봉사이고, 가장 무거운 것이 출석정지입니다. 출석정지는 미인정 결석으로 처리되어 학교생활기록부에 기록됩니다. 이번 개정으로 출석정지 조항이 새로 추가되었습니다." + }, + { + "type": "reminder", + "title": "생활교육(소)위원회 개념 안내", + "evidenceText": "학교장은 생활교육(소)위원회의 결정에 따라 학생 교육을 위해 필요하다고 인정할 때에는 학생을 징계할 수 있고", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "생활교육위원회는 학생 징계를 심의하는 학교 내 위원회입니다. 학교장 단독으로 징계를 결정하는 것이 아니라 위원회의 심의를 거쳐야 합니다. 징계가 결정되면 학부모에게 의견 진술 기회가 주어집니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-005.json b/data/newsletter-labels/newsletter-005.json new file mode 100644 index 0000000..32f9d79 --- /dev/null +++ b/data/newsletter-labels/newsletter-005.json @@ -0,0 +1,98 @@ +[ + { + "documentId": "doc_005", + "documentTitle": "2026학년도 교외체험학습 안내", + "documentDate": "2026-03-03", + "school": "서울세륜초등학교", + "dateCandidates": [ + { + "id": "dc_001_1", + "raw": "3일 전까지", + "note": "체험학습 실시일 기준 상대적 날짜 — 절대 날짜 특정 불가" + }, + { + "id": "dc_001_2", + "raw": "7일 이내", + "note": "체험학습 종료일 기준 상대적 날짜 — 절대 날짜 특정 불가" + }, + { + "id": "dc_001_3", + "raw": "연속 10일 이내", + "note": "허용 기간 범위 표현 — 특정 날짜 아님" + }, + { + "id": "dc_001_4", + "raw": "19일 이내", + "note": "연간 총 허용 일수 — 특정 날짜 아님" + } + ], + "labels": [ + { + "type": "reminder", + "title": "교외체험학습 제도 안내", + "evidenceText": "개인 계획에 의하여 학교장의 사전 허가를 받은 후 실시한 체험학습으로 관찰, 조사, 수집, 현장 견학, 답사, 문화체험, 직업체험 등의 직접적인 경험, 활동, 실천이 중심이 되어 교육적 효과를 나타내는 폭넓은 학습을 의미합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국에서는 학부모가 사전에 신청하고 학교장이 허가하면, 가족 여행이나 체험 활동을 수업일에 해도 출석으로 인정받을 수 있습니다. 단, 정해진 절차(신청서 제출 → 허가 → 실시 → 보고서 제출)를 모두 지켜야 합니다." + }, + { + "type": "deadline", + "title": "교외체험학습 신청서 제출", + "evidenceText": "체험학습 신청서 제출(3일 전까지)", + "selectedDateCandidateId": "dc_001_1", + "dateStatus": "ambiguous", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "교외체험학습을 가기 전에 반드시 신청서를 학교에 제출하고 허가를 받아야 합니다. 허가 없이 결석하면 미인정 결석으로 처리됩니다. 서식은 학교 홈페이지 [Quick menu]-[각종서식]에서 다운로드할 수 있습니다." + }, + { + "type": "deadline", + "title": "교외체험학습 보고서 제출", + "evidenceText": "보고서 제출(7일 이내)", + "selectedDateCandidateId": "dc_001_2", + "dateStatus": "ambiguous", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "체험학습을 마친 후 7일 이내에 결과 보고서도 제출해야 합니다. 신청서와 보고서 두 가지 모두 제출해야 출석으로 인정됩니다. 보고서 서식도 학교 홈페이지에서 다운로드할 수 있습니다." + }, + { + "type": "reminder", + "title": "연간 허용 일수 초과 시 미인정 결석 처리", + "evidenceText": "19일 초과 시 미인정 결석으로 처리됨", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "미인정 결석은 학교가 출석으로 인정하지 않는 결석입니다. 한국에서는 미인정 결석이 누적되면 학교생활기록부(학생의 공식 이력 문서)에 기록되어 이후 진학에 영향을 줄 수 있습니다." + }, + { + "type": "reminder", + "title": "허용 일수 계산 기준 안내", + "evidenceText": "연속 10일 이내(학교자율휴업일, 토요일, 공휴일 제외)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "교외체험학습 허용 일수를 계산할 때 주말, 공휴일, 학교가 자체적으로 지정한 휴업일은 날수에 포함되지 않습니다. 실제 수업일 기준으로만 계산합니다." + }, + { + "type": "reminder", + "title": "연속 5일 초과 시 담임 교사 연락 필요", + "evidenceText": "연속 5일을 초과하여 실시(재량휴업일, 토요일, 공휴일 제외)하는 경우, 학생 안전 및 건강 상태에 대해 학교에 알리기 위해 기간 중 1회 이상 학생이 담임교사에게 연락해야 합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "both", + "actionRequired": true, + "schoolContext": "한국 학교는 장기 체험학습 중에도 학생의 안전을 확인할 의무가 있습니다. 5일을 넘는 경우 학생이 직접 담임 선생님께 연락해 안전하다는 것을 알려야 합니다." + } + ] + } +] \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-006.json b/data/newsletter-labels/newsletter-006.json new file mode 100644 index 0000000..aed8d24 --- /dev/null +++ b/data/newsletter-labels/newsletter-006.json @@ -0,0 +1,55 @@ +{ + "documentId": "doc_006", + "documentTitle": "2026 고교학점제 학부모 설명회", + "documentDate": "2026-05-15", + "school": "경기도안양과천교육지원청", + "dateCandidates": [ + { + "id": "dc_005_1", + "raw": "2026. 6. 13.(토) 10:00 ~ 12:00", + "resolved": "2026-06-13T10:00", + "note": "설명회 일시" + }, + { + "id": "dc_005_2", + "raw": "2026. 5. 20.(수) 09:00부터", + "resolved": "2026-05-20T09:00", + "note": "신청 시작 일시 (선착순 마감)" + } + ], + "labels": [ + { + "type": "reminder", + "title": "고교학점제 제도 안내", + "evidenceText": "고교학점제 및 대입 변화에 대해 이해를 돕고자 설명회를 개최하고자 하오니 많은 관심과 참여 부탁드립니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "고교학점제는 2025년부터 한국 전체 고등학교에 도입된 새로운 교육 제도입니다. 학생이 대학처럼 원하는 과목을 선택해 수업을 듣고 학점을 이수하는 방식이며, 대학 입시 방식도 이에 따라 변화하고 있습니다. 초등학생 자녀를 둔 학부모도 미리 알아두면 도움이 됩니다." + }, + { + "type": "deadline", + "title": "고교학점제 설명회 신청", + "evidenceText": "신청 일정: 2026. 5. 20.(수) 09:00부터 (※ 선착순 마감)", + "selectedDateCandidateId": "dc_005_2", + "dateStatus": "confirmed", + "date": "2026-05-20", + "target": "parent", + "actionRequired": true, + "schoolContext": "선착순 마감이므로 정원이 차면 신청이 불가합니다. 신청 시작일(5월 20일) 오전 9시에 바로 신청하는 것을 권장합니다. 신청 방법: http://anyanggwacheon.joongboo.com/" + }, + { + "type": "schedule", + "title": "고교학점제 학부모 설명회 참석", + "evidenceText": "일시: 2026. 6. 13.(토) 10:00 ~ 12:00 / 장소: 동안고등학교 체육관(안양시 동안구 부림로 71)", + "selectedDateCandidateId": "dc_005_1", + "dateStatus": "confirmed", + "date": "2026-06-13", + "target": "parent", + "actionRequired": false, + "schoolContext": null + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-007.json b/data/newsletter-labels/newsletter-007.json new file mode 100644 index 0000000..9ec8723 --- /dev/null +++ b/data/newsletter-labels/newsletter-007.json @@ -0,0 +1,94 @@ +{ + "documentId": "doc_007", + "documentTitle": "2026학년도 2기 방과후 프로그램 운영 확정 및 폐강 안내", + "documentDate": "2026-05-22", + "school": "석수초등학교", + "dateCandidates": [ + { + "id": "dc_006_1", + "raw": "2025. 6. 15.(월) ~ 6. 19.(금)", + "resolved": "2025-06-19", + "note": "수강료 이체 마감일 (문서 내 연도 표기가 2025로 되어 있어 원문 그대로 반영)" + }, + { + "id": "dc_006_2", + "raw": "2025. 6. 1.(월) ~ 8. 28.(금)", + "resolved": "2025-06-01", + "note": "교육 기간 시작일" + }, + { + "id": "dc_006_3", + "raw": "7.28. ~ 7.31.", + "resolved": "2025-07-28", + "note": "방과후 여름방학 미운영 기간 시작" + } + ], + "labels": [ + { + "type": "reminder", + "title": "방과후 프로그램 개념 안내", + "evidenceText": "2026학년도 2기 방과후 프로그램 수강신청 결과를 바탕으로 최종 운영 프로그램을 안내드립니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "방과후 프로그램은 정규 수업이 끝난 후 학교에서 운영하는 선택 활동 수업입니다. 바둑, 코딩, 방송댄스 등 다양한 과목이 있으며, 별도 수강료를 내고 신청합니다. 학교 밖 학원과 달리 학교 안에서 진행됩니다." + }, + { + "type": "reminder", + "title": "폐강 안내", + "evidenceText": "수강 인원이 10명 미만인 부서는 폐강됩니다. / 폐강부서: 음악줄넘기 B반, 로봇교실 AB반", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "신청 인원이 기준(10명)에 미달하면 수업이 열리지 않습니다. 폐강된 경우 이미 낸 수강료는 환불받을 수 있으며, 다른 프로그램으로 변경 신청이 가능합니다." + }, + { + "type": "deadline", + "title": "수강료 납부", + "evidenceText": "수강료 이체일: 2025. 6. 15.(월) ~ 6. 19.(금) (스쿨뱅킹 또는 신청자에 한해 신용카드 일괄출금)", + "selectedDateCandidateId": "dc_006_1", + "dateStatus": "confirmed", + "date": "2025-06-19", + "target": "parent", + "actionRequired": true, + "schoolContext": "스쿨뱅킹은 학교 납부금을 은행 계좌에서 자동으로 이체하는 한국의 학교 납부 시스템입니다. 입학 시 등록한 계좌에서 지정일에 자동 출금됩니다. 해당 기간에 계좌 잔액을 미리 확인해두세요." + }, + { + "type": "reminder", + "title": "자유수강권 지원 안내 (연간 60만원)", + "evidenceText": "자유수강권 지원 안내(연간 60만원 지원) / 1순위: 법정 저소득층(기초생활수급자, 한부모가정, 법정 차상위 대상자, 난민, 특별기여자, 탈북가정학생)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 정부는 저소득 가정 학생의 방과후 수강료를 연간 최대 60만원까지 지원합니다. 기초생활수급자, 한부모가정, 난민, 탈북 학생 등이 1순위 대상입니다. 해당되면 학교 행정실에 문의해 신청할 수 있습니다." + }, + { + "type": "reminder", + "title": "환불 규정 안내", + "evidenceText": "교재비·교구비·재료비는 수강 취소 시에도 환불되지 않습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "수강을 취소해도 이미 구입한 교재나 재료비는 돌려받을 수 없습니다. 수강 신청 전에 교재비 포함 여부를 확인하세요." + }, + { + "type": "reminder", + "title": "초3 방과후 프로그램 이용권 안내", + "evidenceText": "초3 방과후 프로그램 이용권: 3학년 방과후 수강학생 1인당 연간 50만원 지원", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 정부는 초등학교 3학년 학생에게 방과후 프로그램 수강료를 연간 50만원까지 별도로 지원합니다. 3학년 자녀가 있으면 학교 행정실에 이용권 신청 방법을 문의하세요." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-008.json b/data/newsletter-labels/newsletter-008.json new file mode 100644 index 0000000..9346070 --- /dev/null +++ b/data/newsletter-labels/newsletter-008.json @@ -0,0 +1,60 @@ +{ + "documentId": "doc_008", + "documentTitle": "2026학년도 1학기 방과후[맞춤형, 수익자 방과후] 공개의 날 참관 안내", + "documentDate": "2026-04-30", + "school": "석수초등학교", + "dateCandidates": [ + { + "id": "dc_013_1", + "raw": "2026년 5월 18일(월) ~ 2026년 5월 22일(금)", + "resolved": "2026-05-22", + "note": "방과후 공개의 날 운영 기간 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "방과후 공개의 날 제도 안내", + "evidenceText": "방과후 운영의 활성화와 질적 향상을 위하여 방과후 공개의 날을 아래와 같이 실시하고자 하오니, 보호자님께서는 바쁘시더라도 참관하시어 학생들이 활동하는 모습을 보시고 격려해 주시면 감사하겠습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "방과후 공개의 날은 학부모가 자녀의 방과후 수업을 직접 참관할 수 있는 날입니다. 한국 학교에서는 수업 참관 기회를 학기마다 제공하며, 참관록을 작성하고 등록부에 서명해야 합니다." + }, + { + "type": "schedule", + "title": "방과후 공개의 날 참관", + "evidenceText": "운영 기간: 2026년 5월 18일(월) ~ 2026년 5월 22일(금), 5일간", + "selectedDateCandidateId": "dc_013_1", + "dateStatus": "confirmed", + "date": "2026-05-22", + "target": "parent", + "actionRequired": false, + "schoolContext": "자녀의 프로그램 요일과 공개시간을 표에서 확인한 뒤 해당 요일에 방문하면 됩니다. 사전 신청은 별도로 요구하지 않으나, 방문 시 등록부 서명이 필요합니다." + }, + { + "type": "reminder", + "title": "수업 중 사진 촬영 및 공유 금지", + "evidenceText": "강사 및 학생의 개인정보 보호를 위해 수업 중 사진 촬영 및 공유는 자제해 주시기 바랍니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국에서는 다른 학생의 얼굴이 찍힌 사진을 무단으로 공유하는 것이 개인정보보호법 위반이 될 수 있습니다. 수업 참관 중 사진이나 동영상 촬영은 삼가주세요." + }, + { + "type": "reminder", + "title": "맞춤형 vs 수익자 방과후 프로그램 구분", + "evidenceText": "운영 대상: 맞춤형 5개, 방과후 10개 프로그램", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "맞춤형 프로그램은 저학년(1~2학년) 학생을 위한 돌봄 중심 프로그램이며, 수익자 방과후는 학부모가 수강료를 내고 신청하는 선택 활동 수업입니다. 두 가지는 운영 시간과 장소가 다릅니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-009.json b/data/newsletter-labels/newsletter-009.json new file mode 100644 index 0000000..185812e --- /dev/null +++ b/data/newsletter-labels/newsletter-009.json @@ -0,0 +1,83 @@ +{ + "documentId": "doc_009", + "documentTitle": "2025학년도 1·4학년 학생 건강검진 안내", + "documentDate": "2025-06-02", + "school": "서울잠실초등학교", + "dateCandidates": [ + { + "id": "dc_008_1", + "raw": "2025. 6.9.(월) ~ 2025. 6.27.(금)", + "resolved": "2025-06-27", + "note": "건강검진 기간 마감일" + }, + { + "id": "dc_008_2", + "raw": "6.20.(금)까지", + "resolved": "2025-06-20", + "note": "혼잡 방지를 위한 권장 완료일" + }, + { + "id": "dc_008_3", + "raw": "6.11.(수)", + "resolved": "2025-06-11", + "note": "검진기관 휴진일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "학교 건강검진 제도 안내", + "evidenceText": "학교건강검사 규칙에 의거하여 1, 4학년 학생들은 건강검진 기관의 내과, 구강 검진을 받도록 되어 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국에서는 초등학교 1학년과 4학년 학생을 대상으로 매년 국가가 지정한 건강검진을 의무적으로 실시합니다. 검진 비용은 학교가 부담하므로 무료입니다. 검진을 받지 않으면 학교로부터 별도 안내를 받을 수 있습니다." + }, + { + "type": "schedule", + "title": "건강검진 기간", + "evidenceText": "검진 기간: 2025. 6.9.(월) ~ 2025. 6.27.(금)까지 (*6.11.(수) 휴진)", + "selectedDateCandidateId": "dc_008_1", + "dateStatus": "confirmed", + "date": "2025-06-27", + "target": "parent", + "actionRequired": true, + "schoolContext": "검진은 학교에서 하는 것이 아니라 지정된 검진 기관(병원)에 보호자가 자녀를 데리고 직접 방문해야 합니다. 방문 시 보호자 동반이 필수입니다." + }, + { + "type": "checklist", + "title": "문진표 미리 작성 후 지참", + "evidenceText": "배부된 내과 및 구강 검진 문진표(1장)는 미리 작성해서 검진 기관에 제출합니다. (분실 시 담임선생님께 재배부받습니다)", + "selectedDateCandidateId": "dc_008_1", + "dateStatus": "confirmed", + "date": "2025-06-27", + "target": "parent", + "actionRequired": true, + "schoolContext": "문진표는 자녀의 건강 상태와 병력을 기록하는 서류입니다. 학교에서 배부하며, 검진 기관 방문 전에 미리 작성해서 가져가야 합니다. 분실했다면 담임 선생님께 요청하세요." + }, + { + "type": "checklist", + "title": "4학년 경도비만 이상 학생: 혈액검사 전 5시간 공복 유지", + "evidenceText": "4학년 아동 중 경도비만 이상 학생은 혈액검사 대상입니다. (* 5시간 이상의 공복 상태 유지 필요)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "혈액검사는 채혈 전 일정 시간 금식이 필요합니다. 검진 당일 아침 식사를 하지 않고 방문해야 정확한 검사가 가능합니다. 해당 여부가 불확실하면 담임 선생님께 확인하세요." + }, + { + "type": "checklist", + "title": "검진 후 검진확인서 담임 선생님께 제출", + "evidenceText": "검진 후 검진 기관에서 배부하는 검진확인서는 담임선생님께 제출해 주시기 바랍니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "검진을 마치면 기관에서 확인서를 발급해줍니다. 이 확인서를 학교에 제출해야 검진 완료로 처리됩니다. 제출하지 않으면 미검진 처리될 수 있습니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-010.json b/data/newsletter-labels/newsletter-010.json new file mode 100644 index 0000000..8a82658 --- /dev/null +++ b/data/newsletter-labels/newsletter-010.json @@ -0,0 +1,71 @@ +{ + "documentId": "doc_010", + "documentTitle": "2026년 교육급여 및 교육비 집중신청 기간 동시 신청 안내", + "documentDate": "2026-03-13", + "school": "서울삼성초등학교", + "dateCandidates": [ + { + "id": "dc_011_1", + "raw": "2026.3.3.(화) ~ 3.20.(금)", + "resolved": "2026-03-20", + "note": "집중신청기간 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "교육급여·교육비 지원 제도 안내", + "evidenceText": "우리 학교에서는 지원 대상 자격을 갖춘 희망하는 가정에 교육급여, 교육비를 지원하고 있으니", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 정부는 저소득 가정 학생에게 교육급여(학용품·교육활동비)와 교육비(급식비·방과후 수강료 등)를 지원합니다. 기초생활수급자, 한부모가정, 차상위계층 등이 주요 대상입니다. 해당될 경우 꼭 신청하세요." + }, + { + "type": "deadline", + "title": "교육급여·교육비 집중신청 기간 신청", + "evidenceText": "집중신청 기간: 2026.3.3.(화) ~ 3.20.(금) / 집중신청기간 외에도 연중 언제든지 신청이 가능합니다. 다만 신청일이 속한 달(月)부터 지원합니다.", + "selectedDateCandidateId": "dc_011_1", + "dateStatus": "confirmed", + "date": "2026-03-20", + "target": "parent", + "actionRequired": true, + "schoolContext": "집중신청 기간에 신청해야 학기 초부터 지원받을 수 있습니다. 기간이 지나도 신청은 가능하지만, 신청한 달부터만 지원되므로 빠를수록 유리합니다." + }, + { + "type": "reminder", + "title": "교육급여 지원 항목 및 금액", + "evidenceText": "교육급여 / 초등학생: 교육활동지원비(502,000원)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "교육급여는 학용품, 교재비 등 교육활동에 사용할 수 있는 바우처(포인트)로 지급됩니다. 초등학생은 연간 502,000원을 받을 수 있습니다. 선정 후 온라인(e-voucher.kosaf.go.kr)에서 별도로 신청해야 사용 가능합니다." + }, + { + "type": "checklist", + "title": "신청 방법 선택 및 서류 준비", + "evidenceText": "신청 방법(택1): ① 방문신청 - 부모 주민등록주소지 동 주민센터 / ② 온라인 신청 - 복지로 또는 교육비 원클릭 신청시스템 / 제출 서류: 신청서, 소득재산신고서, 금융정보등 제공 동의서, 각종 증빙자료", + "selectedDateCandidateId": "dc_011_1", + "dateStatus": "confirmed", + "date": "2026-03-20", + "target": "parent", + "actionRequired": true, + "schoolContext": "주민센터(동사무소)는 주민등록주소지 기준으로 방문해야 합니다. 온라인 신청은 복지로(www.bokjiro.go.kr) 또는 교육비 원클릭 시스템(oneclick.neis.go.kr)에서 가능합니다." + }, + { + "type": "reminder", + "title": "법정자격 대상자 교육비 납부유예 안내", + "evidenceText": "교육비 납부유예: 법정자격 대상자(기초, 한부모, 차상위)의 경우 교무실로 증명서를 제출하면 납부유예 가능합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "기초생활수급자, 한부모가정, 차상위계층에 해당하면 급식비 등 교육비를 심사 결과가 나올 때까지 납부하지 않아도 됩니다. 교무실에 증명서(수급자 증명서 등)를 제출하면 됩니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-011.json b/data/newsletter-labels/newsletter-011.json new file mode 100644 index 0000000..98766cc --- /dev/null +++ b/data/newsletter-labels/newsletter-011.json @@ -0,0 +1,60 @@ +{ + "documentId": "doc_011", + "documentTitle": "2026년 1차 학교폭력 실태조사 참여 안내", + "documentDate": "2026-04-13", + "school": "서울삼성초등학교", + "dateCandidates": [ + { + "id": "dc_012_1", + "raw": "4. 14.(화) 09:00 ~ 5. 13.(수) 18:00", + "resolved": "2026-05-13", + "note": "실태조사 참여 마감일시" + } + ], + "labels": [ + { + "type": "reminder", + "title": "학교폭력 실태조사 제도 안내", + "evidenceText": "학교폭력 걱정이 없는 행복하고 안전한 학교를 만들기 위한 방안을 마련하고자, 전국 시·도교육감은 2026년 1차 학교폭력 실태조사를 실시합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국에서는 매년 전국 초·중·고 학생을 대상으로 학교폭력 경험 여부를 온라인으로 조사합니다. 이는 정부가 의무적으로 실시하는 전수조사이며, 학생의 응답은 익명으로 처리되고 불이익은 없습니다." + }, + { + "type": "schedule", + "title": "학교폭력 실태조사 참여 기간", + "evidenceText": "일정: 4. 14.(화) 09:00 ~ 5. 13.(수) 18:00(기간 중 24시간 참여 가능) / 대상: 초등학교 4학년 ~ 6학년 재학생", + "selectedDateCandidateId": "dc_012_1", + "dateStatus": "confirmed", + "date": "2026-05-13", + "target": "both", + "actionRequired": true, + "schoolContext": null + }, + { + "type": "checklist", + "title": "자녀가 가정에서 개별적으로 조사 참여하도록 안내", + "evidenceText": "솔직한 응답 및 비밀보장을 위해 가정에서 개별적으로 참여할 수 있도록 지도 부탁드립니다.", + "selectedDateCandidateId": "dc_012_1", + "dateStatus": "confirmed", + "date": "2026-05-13", + "target": "parent", + "actionRequired": true, + "schoolContext": "학교폭력 실태조사는 학교에서도 하지만, 가정에서 자녀가 혼자 참여하면 더 솔직하게 응답할 수 있습니다. 컴퓨터나 스마트폰으로 조사 사이트(https://survey.eduro.go.kr)에 접속해 참여할 수 있습니다." + }, + { + "type": "reminder", + "title": "다문화 학생 다국어 지원 안내", + "evidenceText": "다문화 학생은 본인이 주로 사용하는 언어 버튼을 클릭하여 선택한 언어로 설문참여 가능 (지원 다국어: 영어, 중국어, 일본어, 베트남어, 태국어, 필리핀어, 러시아어)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "both", + "actionRequired": false, + "schoolContext": "학교폭력 실태조사는 다국어를 지원합니다. 한국어가 어려운 다문화 학생은 영어, 중국어, 베트남어 등 자신에게 편한 언어를 선택해 응답할 수 있습니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-012.json b/data/newsletter-labels/newsletter-012.json new file mode 100644 index 0000000..220f17b --- /dev/null +++ b/data/newsletter-labels/newsletter-012.json @@ -0,0 +1,94 @@ +{ + "documentId": "doc_012", + "documentTitle": "2026년도 학부모 수업 공개 및 학교설명회 안내", + "documentDate": "2026-03-10", + "school": "서울삼성초등학교", + "dateCandidates": [ + { + "id": "dc_014_1", + "raw": "3월 18일(수) 5교시 12:50~13:30", + "resolved": "2026-03-18T12:50", + "note": "학부모 수업 공개 시간" + }, + { + "id": "dc_014_2", + "raw": "3월 18일(수) 13:50~16:00", + "resolved": "2026-03-18T13:50", + "note": "학교·학급 설명회 시간" + }, + { + "id": "dc_014_3", + "raw": "6월 중 별도 실시(6월 2주 예정)", + "resolved": null, + "note": "개별반 수업 공개 — 날짜 미확정" + } + ], + "labels": [ + { + "type": "reminder", + "title": "학부모 수업 공개 제도 안내", + "evidenceText": "본교에서는 2026학년도 학부모 수업 공개와 학교 교육활동 및 학급 운영을 안내하는 학교 설명회를 아래와 같이 실시하고자 합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 초등학교는 학기 초에 학부모가 자녀의 수업을 직접 참관하고, 담임 선생님과 학교 운영 방향을 공유하는 '수업 공개 및 학교설명회'를 운영합니다. 자녀의 수업 모습을 직접 볼 수 있는 중요한 행사입니다." + }, + { + "type": "schedule", + "title": "학부모 수업 공개", + "evidenceText": "1~6학년 학급 담임 교사 / 3월 18일(수) 5교시 12:50~13:30 / 해당 교실", + "selectedDateCandidateId": "dc_014_1", + "dateStatus": "confirmed", + "date": "2026-03-18", + "target": "parent", + "actionRequired": false, + "schoolContext": "5교시는 오후 12시 50분에 시작합니다. 한국 초등학교는 수업을 '교시' 단위로 운영하며, 1교시는 보통 오전 9시에 시작하고 한 교시는 40분입니다." + }, + { + "type": "schedule", + "title": "학교설명회 및 학급설명회", + "evidenceText": "일시: 3월 18일 (수) 13:50~16:00 / 내용: 2부 학교 설명회 / 3부 학급 설명회", + "selectedDateCandidateId": "dc_014_2", + "dateStatus": "confirmed", + "date": "2026-03-18", + "target": "parent", + "actionRequired": false, + "schoolContext": "학교설명회에서는 학교장의 교육 방향과 연간 교육활동을 안내받습니다. 학급설명회에서는 담임 선생님이 학급 운영 방식, 평가 방법, 생활지도 방향 등을 설명합니다. 학부모회 임원 선출도 이 자리에서 이루어집니다." + }, + { + "type": "checklist", + "title": "e알리미 수업 참관 신청 및 당일 신청 화면 제시", + "evidenceText": "수업 참관을 희망하시는 학부모님께서는 e알리미로 신청해 주세요. e알리미 미회신 시 수업 참관이 불가합니다. / 당일 학교 출입 시 e알리미 참관 신청 화면을 보안관실에 제시해 주시기 바랍니다.", + "selectedDateCandidateId": "dc_014_1", + "dateStatus": "confirmed", + "date": "2026-03-18", + "target": "parent", + "actionRequired": true, + "schoolContext": "수업 참관은 사전 신청이 필수입니다. e알리미 앱에서 신청하고, 당일 학교 입구 보안관실에서 신청 화면을 보여줘야 입장이 가능합니다. 신청하지 않으면 참관이 불가합니다." + }, + { + "type": "reminder", + "title": "수업 장면 촬영·공유 금지", + "evidenceText": "다음 사항은 법에 위반되므로 주의해 주세요! - 수업 장면 촬영(사진, 동영상 등) / - 수업 장면 무단 공유/공개(문자, 카카오톡, 블로그, 카페 등)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국에서는 수업 중 다른 학생이 포함된 사진·영상을 무단으로 SNS나 메시지로 공유하면 개인정보보호법 위반이 될 수 있습니다. 수업 참관 중 촬영은 금지됩니다." + }, + { + "type": "reminder", + "title": "학부모회 임원 선출 안내", + "evidenceText": "학부모회 총회 13:50~14:00 / 학부모회 임원 선출 / 총회 안건 투표 결과 안내", + "selectedDateCandidateId": "dc_014_2", + "dateStatus": "confirmed", + "date": "2026-03-18", + "target": "parent", + "actionRequired": false, + "schoolContext": "학부모회는 학교 교육활동을 지원하고 학부모 의견을 학교에 전달하는 공식 조직입니다. 학기 초 설명회에서 학부모 투표를 통해 회장, 부회장 등 임원을 선출합니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-013.json b/data/newsletter-labels/newsletter-013.json new file mode 100644 index 0000000..e0b11b3 --- /dev/null +++ b/data/newsletter-labels/newsletter-013.json @@ -0,0 +1,60 @@ +{ + "documentId": "doc_013", + "documentTitle": "2026년 학생 식품알레르기 실태조사 협조 안내", + "documentDate": "2026-03-06", + "school": "서울잠실초등학교", + "dateCandidates": [ + { + "id": "dc_015_1", + "raw": "2025. 3. 6.(금)까지", + "resolved": "2025-03-06", + "note": "제출 기한 (문서 내 연도가 2025로 표기됨 — 원문 그대로 반영)" + } + ], + "labels": [ + { + "type": "reminder", + "title": "학교급식 식품알레르기 관리 제도 안내", + "evidenceText": "안전한 학교급식 운영을 위해 학생들의 식품알레르기 실태를 파악하여 관리하고자 합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 학교급식은 매일 학교에서 식사를 제공하는 제도입니다. 알레르기 정보를 학교에 사전에 알려두면, 급식 식단표에서 알레르기 유발 식품 번호를 확인하여 자녀가 해당 음식을 피할 수 있도록 관리해줍니다." + }, + { + "type": "deadline", + "title": "식품알레르기 실태조사 제출", + "evidenceText": "제출기한: 2025. 3. 6.(금)까지 / 제출대상: 식품알레르기가 있는 학생 / 제출방법: '스쿨투게더 메시지'를 통해 제출", + "selectedDateCandidateId": "dc_015_1", + "dateStatus": "confirmed", + "date": "2025-03-06", + "target": "parent", + "actionRequired": true, + "schoolContext": "스쿨투게더는 학교와 학부모가 메시지를 주고받는 한국의 학교 전용 앱입니다. 알레르기가 있는 학생의 학부모만 회신하면 됩니다. 알레르기가 없으면 별도로 응답하지 않아도 됩니다." + }, + { + "type": "reminder", + "title": "급식 식단표 알레르기 유발식품 표시제 안내", + "evidenceText": "학교급식 식단표에 알레르기 유발식품 표시제를 실시하고 있습니다. 알레르기 유발식품의 번호를 식단 이름 옆에 상세히 안내하고 있으니 증상이 있는 학생들이 해당 식품을 섭취하지 않도록 가정에서 확인과 지도바랍니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 학교급식 식단표에는 19가지 법정 알레르기 유발 식품이 번호로 표시됩니다(예: 1=난류, 2=우유, 5=대두). 학교 홈페이지나 급식 게시판에서 매일 식단을 확인하고, 자녀가 해당 번호 음식을 스스로 피할 수 있도록 지도해주세요." + }, + { + "type": "reminder", + "title": "아나필락시스 쇼크 학생 에피네프린 응급주사 안내", + "evidenceText": "혹시, 심한 알레르기 증상인 아나필락시스 쇼크환자로 에피네프린 응급주사가 필요한가요?", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "아나필락시스는 특정 식품에 노출 시 호흡곤란 등 생명을 위협하는 심각한 알레르기 반응입니다. 해당하는 학생은 반드시 학교 보건실에 사전에 알리고 에피네프린 자동주사기(에피펜)를 보건실에 보관해두어야 합니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-014.json b/data/newsletter-labels/newsletter-014.json new file mode 100644 index 0000000..a9c04e0 --- /dev/null +++ b/data/newsletter-labels/newsletter-014.json @@ -0,0 +1,66 @@ +{ + "documentId": "doc_0014", + "documentTitle": "학교자율휴업일 안내", + "documentDate": "2026-04-22", + "school": "서울위례초등학교", + "dateCandidates": [ + { + "id": "dc_007_1", + "raw": "5월 4일(월)", + "resolved": "2026-05-04", + "note": "학교자율휴업일" + }, + { + "id": "dc_007_2", + "raw": "5월 6일(수)", + "resolved": "2026-05-06", + "note": "정상 등교일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "학교자율휴업일 안내", + "evidenceText": "학교자율휴업일: 5월 4일(월)", + "selectedDateCandidateId": "dc_007_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "parent", + "actionRequired": false, + "schoolContext": "학교자율휴업일은 학교가 자체적으로 지정한 휴일로, 공휴일은 아니지만 학생이 등교하지 않습니다. 이 날은 맞춤형교실, 방과후교실도 운영하지 않습니다. 한국 초등학교는 이런 자율휴업일을 학기 중 몇 차례 지정할 수 있습니다." + }, + { + "type": "reminder", + "title": "정상 등교일 확인", + "evidenceText": "정상 등교일: 5월 6일(수)", + "selectedDateCandidateId": "dc_007_2", + "dateStatus": "confirmed", + "date": "2026-05-06", + "target": "parent", + "actionRequired": false, + "schoolContext": null + }, + { + "type": "reminder", + "title": "맞춤형교실·방과후교실 미운영 안내", + "evidenceText": "맞춤형교실 및 방과후교실은 운영하지 않음", + "selectedDateCandidateId": "dc_007_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "parent", + "actionRequired": false, + "schoolContext": "맞춤형교실은 저학년 학생을 위한 돌봄 프로그램이고, 방과후교실은 수업 후 운영되는 선택 활동 수업입니다. 자율휴업일에는 둘 다 운영되지 않으므로 자녀 돌봄 계획을 미리 세워두세요." + }, + { + "type": "reminder", + "title": "돌봄교실 필요 시 e알리미 회신 안내", + "evidenceText": "돌봄교실은 신청 학생 대상으로 통합반으로 운영 예정 / 해당일 돌봄이 필요한 학생은 e알리미 '학교 자율휴업일 돌봄운영 수요조사'에 회신", + "selectedDateCandidateId": "dc_007_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "parent", + "actionRequired": true, + "schoolContext": "e알리미는 한국 학교에서 학부모 스마트폰으로 공지와 설문을 전송하는 앱 서비스입니다. 돌봄교실 이용을 원하면 앱을 통해 미리 신청해야 합니다. 신청하지 않으면 이용이 불가합니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-015.json b/data/newsletter-labels/newsletter-015.json new file mode 100644 index 0000000..cdbd358 --- /dev/null +++ b/data/newsletter-labels/newsletter-015.json @@ -0,0 +1,55 @@ +{ + "documentId": "doc_015", + "documentTitle": "꿈·끼 탐색주간 학부모 재능기부 참여 안내", + "documentDate": "2026-04-23", + "school": "서울잠실초등학교", + "dateCandidates": [ + { + "id": "dc_016_1", + "raw": "~2026. 4. 30. (목) 오전 11:45 까지", + "resolved": "2026-04-30", + "note": "재능기부 신청 마감일시" + }, + { + "id": "dc_016_2", + "raw": "2026. 6. 15. (월) ~ 6. 19. (금)", + "resolved": "2026-06-19", + "note": "꿈·끼 탐색주간 운영 기간 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "꿈·끼 탐색주간 제도 안내", + "evidenceText": "2026학년도 꿈·끼 탐색주간에 학부모님들의 재능기부를 통해 다양한 직업에 대하여 알아보고 진로탐색교육을 하고자 합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "꿈·끼 탐색주간은 한국 교육부가 학생들의 진로 탐색을 위해 지정한 교육 주간입니다. 이 기간에는 다양한 직업인을 초청해 진로 체험 교육을 진행하며, 학부모도 자신의 직업을 소개하는 '재능기부 명예교사'로 참여할 수 있습니다." + }, + { + "type": "deadline", + "title": "학부모 재능기부 신청", + "evidenceText": "신청 기간: ~2026. 4. 30. (목) 오전 11:45 까지 / 신청 방법: 구글 설문 (https://forms.gle/wNDRXWR3SAUwmA8x9)", + "selectedDateCandidateId": "dc_016_1", + "dateStatus": "confirmed", + "date": "2026-04-30", + "target": "parent", + "actionRequired": true, + "schoolContext": "재능기부는 자녀의 교실이 아닌 다른 학년·반에 배정될 수 있습니다. 신청은 구글 설문 링크 또는 QR코드로 가능하며, 직업 분야와 희망 날짜를 입력하면 됩니다." + }, + { + "type": "schedule", + "title": "꿈·끼 탐색주간 재능기부 활동", + "evidenceText": "운영 기간: 2026. 6. 15. (월) ~ 6. 19. (금) (기간 중 1일) / 희망하시는 날 같은 내용으로 2교시(9:50~10:30), 3교시(10:40~11:20) 2개 학급에서 직업 소개 및 진로 탐색 활동이 진행될 수 있습니다.", + "selectedDateCandidateId": "dc_016_2", + "dateStatus": "confirmed", + "date": "2026-06-19", + "target": "parent", + "actionRequired": false, + "schoolContext": "재능기부 명예교사로 선정되면 학교 교실에서 학생들에게 약 40분씩 2회 직업을 소개합니다. 별도 교육 자료는 자유롭게 준비하면 되며, 수업 방식에 대한 사전 안내를 학교에서 제공합니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-016.json b/data/newsletter-labels/newsletter-016.json new file mode 100644 index 0000000..5feef26 --- /dev/null +++ b/data/newsletter-labels/newsletter-016.json @@ -0,0 +1,77 @@ +{ + "documentId": "doc_016", + "documentTitle": "2026학년도 학부모 상담 운영 안내", + "documentDate": "2026-03-20", + "school": "서울세륜초등학교", + "dateCandidates": [ + { + "id": "dc_010_1", + "raw": "3.30(월) ~ 4.10.(금)", + "resolved": "2026-04-10", + "note": "1~2학년 1학기 상담주간 마감일" + }, + { + "id": "dc_010_2", + "raw": "8.31.(월) ~ 9.11.(금)", + "resolved": "2026-09-11", + "note": "3~6학년 2학기 상담주간 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "상담주간 제도 안내", + "evidenceText": "상담주간: 학부모상담주간을 지정하여 해당 기간 동안에 상담 운영", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 초등학교는 학기마다 특정 기간을 '상담주간'으로 지정해 담임 선생님과 학부모가 1:1로 면담하는 시간을 운영합니다. 이 기간에는 학교에서 시간을 배정하므로 별도 신청 없이 안내에 따라 참석하면 됩니다." + }, + { + "type": "schedule", + "title": "1~2학년 1학기 상담주간", + "evidenceText": "1~2학년 / 1학기: 상담주간 운영 {3.30(월) ~ 4.10.(금)}", + "selectedDateCandidateId": "dc_010_1", + "dateStatus": "confirmed", + "date": "2026-04-10", + "target": "parent", + "actionRequired": true, + "schoolContext": "1~2학년 학부모는 이 기간 중 담임 선생님과 면담 일정을 잡게 됩니다. 학교에서 개별 일정 안내가 올 예정이므로 확인하세요." + }, + { + "type": "schedule", + "title": "3~6학년 2학기 상담주간", + "evidenceText": "3~6학년 / 2학기: 상담주간 운영 {8.31.(월) ~ 9.11.(금)}", + "selectedDateCandidateId": "dc_010_2", + "dateStatus": "confirmed", + "date": "2026-09-11", + "target": "parent", + "actionRequired": true, + "schoolContext": null + }, + { + "type": "reminder", + "title": "수시상담 신청 방법 안내", + "evidenceText": "수시상담: 기간 지정 없이 학부모님의 요청에 따라 수시로 대면 또는 전화 상담 운영", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "수시상담은 상담주간과 관계없이 언제든지 담임 선생님께 연락해 일정을 잡는 개별 상담입니다. 자녀의 학교생활에 궁금한 점이 생기면 언제든지 담임 선생님께 전화하거나 알림장을 통해 상담을 요청할 수 있습니다." + }, + { + "type": "reminder", + "title": "상담 주요 주제 안내", + "evidenceText": "교실 안에서의 문제, 교우 관계, 성적 및 진로 문제, 학교 폭력 및 집단 따돌림, 학교 부적응 문제 등", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 담임 선생님과의 상담은 자녀의 학습뿐 아니라 친구 관계, 학교 적응 문제 등 폭넓은 주제를 다룹니다. 학교생활 전반에 대해 자유롭게 질문하고 도움을 요청할 수 있습니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-017.json b/data/newsletter-labels/newsletter-017.json new file mode 100644 index 0000000..2829bf2 --- /dev/null +++ b/data/newsletter-labels/newsletter-017.json @@ -0,0 +1,75 @@ +{ + "documentId": "doc_017", + "documentTitle": "2026학년도 학교생활기록부 출결 관리 안내", + "documentDate": "2026-03-04", + "school": "대전관저초등학교", + "dateCandidates": [], + "labels": [ + { + "type": "reminder", + "title": "출결 관리와 학교생활기록부 연관성 안내", + "evidenceText": "2026학년도 학교생활기록부 기재요령에 따라 본교 학생 출결 관리에 대한 안내를 하고자 하오니 자녀의 학교생활에 참고하시기 바랍니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국에서는 출결(출석·결석·지각·조퇴) 기록이 모두 학교생활기록부에 남습니다. 결석 사유에 따라 '출석인정결석'과 '미인정결석'으로 구분되며, 미인정결석은 불리한 기록으로 남을 수 있습니다. 결석 시 반드시 사유를 학교에 알리고 서류를 제출해야 합니다." + }, + { + "type": "reminder", + "title": "법정감염병 결석 시 출석인정 절차", + "evidenceText": "법정 전염병: 법정감염병 및 기타 전염성이 강한 질병 / 담임교사에게 유선 연락 / 발병 시 즉시 등교중지하고 완치 후 서류 제출 / 결석계 + 증빙서류(진단서, 진료확인서, 의사 소견서, 약봉투 중 1통)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "수두, 독감 등 법정감염병에 걸리면 학교에 즉시 연락하고 등교를 중지해야 합니다. 완치 후 등교할 때 진단서나 약봉투 중 하나를 제출하면 결석이 아닌 출석으로 인정받습니다. 서류 없이 결석하면 미인정결석으로 처리될 수 있습니다." + }, + { + "type": "reminder", + "title": "질병 결석 시 증빙서류 제출 기준", + "evidenceText": "질병으로 인한 2일 이내 결석: 결석계 제출 / 질병으로 인한 3일 이상의 결석: 결석계 + 병명, 진료기간 등이 기록된 증빙서류(진단서, 진료확인서, 의사 소견서, 약봉투 중 1통)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "아파서 학교를 쉴 때는 결석계를 제출해야 출석으로 인정받습니다. 2일 이하는 결석계만으로 충분하지만, 3일 이상 결석하면 반드시 병원 서류도 함께 제출해야 합니다. 결석계는 사유 발생 후 5일 이내에 제출해야 합니다." + }, + { + "type": "reminder", + "title": "경조사 결석 인정 기준 안내", + "evidenceText": "결혼: 형제, 자매, 부, 모 - 1일 / 사망: 부모, 조부모, 외조부모 - 5일 / 부모의 형제·자매 및 그의 배우자 - 3일 / 토요휴업일 및 공휴일은 경조사 일수에 포함하지 않음.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "가족 경조사(결혼, 사망 등)가 있을 때도 학교에 신고하면 출석으로 인정받는 일수가 정해져 있습니다. 경조사 일수는 주말과 공휴일을 제외하고 계산합니다. 부고장이나 사망진단서 등 증빙서류를 함께 제출해야 합니다." + }, + { + "type": "reminder", + "title": "미인정결석 기준 안내", + "evidenceText": "미인정: 태만, 가출, 출석 거부 등 고의 결석 / 기타 합당하지 않은 사유 / 지각: 수업 시작 시간인 9시 이후로 수업에 참여하는 경우", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "사유 없이 학교에 오지 않거나, 인정되지 않는 이유로 결석하면 미인정결석으로 학교생활기록부에 기록됩니다. 수업 시작 시간(오전 9시) 이후에 도착하면 지각으로 처리됩니다. 지각·조퇴가 3회 누적되면 결석 1일로 계산됩니다." + }, + { + "type": "reminder", + "title": "나이스 학부모 서비스로 결석계 온라인 제출 가능", + "evidenceText": "결석계 제출은 온라인 <나이스 학부모 서비스>를 통해 가능하십니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "나이스(NEIS) 학부모 서비스는 한국 교육부가 운영하는 학부모용 교육 포털입니다. 스마트폰 앱이나 웹사이트에서 결석계를 온라인으로 제출할 수 있어 직접 학교를 방문하지 않아도 됩니다. 앱 설치를 권장합니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-018.json b/data/newsletter-labels/newsletter-018.json new file mode 100644 index 0000000..71bf18b --- /dev/null +++ b/data/newsletter-labels/newsletter-018.json @@ -0,0 +1,53 @@ +{ + "documentId": "doc_018", + "documentTitle": "2026학년도 6학년 학교생활기록부 진로정보 연계 개인정보 활용 동의서", + "documentDate": "2026-04-06", + "school": "부양초등학교", + "dateCandidates": [], + "labels": [ + { + "type": "reminder", + "title": "학교생활기록부(생기부) 개념 안내", + "evidenceText": "본교에서는 교육부의 지침에 따라 학교생활기록부 진로 관련 사항이 초·중·고등학교 간 연계될 수 있도록 6학년 학생들을 대상으로 학교 생활기록부 진로정보 연계 개인정보 활용 동의서를 수집하고자 합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "학교생활기록부(생기부)는 한국 학생의 재학 기간 동안의 학습·활동·진로 이력을 공식적으로 기록하는 문서입니다. 성적뿐 아니라 창의적 체험활동, 봉사활동, 수상 이력 등이 모두 포함되며, 중학교·고등학교 입학 및 진로 지도에 활용됩니다." + }, + { + "type": "reminder", + "title": "진로정보 초·중·고 연계 서비스 개념 안내", + "evidenceText": "진로정보 초·중·고 연계 서비스란 초·중·고가 연계하여 지속적·심층적 진로교육 지도를 위한 기반을 마련하기 위해 학생의 진로 관련 학교생활기록을 나이스 시스템을 통해 전달·제공하는 것입니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "나이스(NEIS)는 한국 교육부가 운영하는 교육행정 전산 시스템입니다. 학생의 성적, 출결, 활동 기록이 이 시스템에 저장되고 상급학교로 전달됩니다. 초등학교 6학년의 진로활동 기록이 중학교에서 계속 활용될 수 있도록 연계하는 서비스입니다." + }, + { + "type": "checklist", + "title": "진로정보 연계 개인정보 활용 동의서 제출", + "evidenceText": "본인은 학교생활기록부의 진로 관련 사항을 상급학교 장에게 제공하기 위한 개인정보 수집·이용에 동의합니다. [ □ 예 □ 아니오 ]", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "동의 여부를 선택하는 문서입니다. 동의하지 않아도 불이익은 없지만, 거부할 경우 중학교 담임 선생님이 자녀의 초등학교 진로활동 기록을 참고할 수 없게 됩니다. 학생과 보호자 모두 서명해야 합니다." + }, + { + "type": "reminder", + "title": "개인정보 활용 동의 거부 시 불이익 안내", + "evidenceText": "동의를 거부할 수 있으며, 동의 거부 시 담임교사와 진로진학상담지도교사에게 학생 진로지도 및 상담을 위한 자료 제공이 제한됩니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "동의를 거부해도 입학 전형이나 학생 선발에는 영향이 없습니다. 다만 중학교에서 자녀의 초등학교 진로 관련 활동 기록을 참고하지 못하므로, 진로 상담 시 학부모가 직접 이전 기록을 공유해야 할 수 있습니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-019.json b/data/newsletter-labels/newsletter-019.json new file mode 100644 index 0000000..92521fd --- /dev/null +++ b/data/newsletter-labels/newsletter-019.json @@ -0,0 +1,64 @@ +{ + "documentId": "doc_019", + "documentTitle": "학교폭력 예방을 위한 안내문", + "documentDate": "2024-09-04", + "school": "대전관저초등학교", + "dateCandidates": [], + "labels": [ + { + "type": "reminder", + "title": "학교폭력 법적 정의 안내", + "evidenceText": "'학교폭력'이란 학교 내·외에서 학생을 대상으로 발생한 상해, 폭행, 감금, 협박, 약취·유인, 명예훼손·모욕, 공갈, 강요·강제적인 심부름 및 성폭력, 따돌림, 사이버 따돌림, 정보통신망을 이용한 음란·폭력 정보 등에 의하여 신체·정신 또는 재산상의 피해를 수반하는 행위", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국에서 학교폭력은 신체적 폭행뿐 아니라 언어폭력, 따돌림(왕따), 사이버폭력, 금품갈취까지 법으로 정의된 광범위한 개념입니다. 장난처럼 보이는 행위도 피해학생이 고통을 느끼면 학교폭력으로 간주될 수 있습니다." + }, + { + "type": "reminder", + "title": "학교폭력 피해학생 징후 안내", + "evidenceText": "피해학생의 징후: 늦잠을 자고 몸이 아프다고 하며 학교 가기를 꺼림 / 학교생활 및 친구관계에 대한 대화를 시도할 때 예민한 반응을 보임 / 용돈을 평소보다 많이 달라고 하거나 스마트폰 요금이 많이 부과됨", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "자녀가 학교폭력 피해를 당하고 있어도 직접 말하지 않는 경우가 많습니다. 위 징후들이 반복적으로 나타나면 담임 선생님에게 먼저 연락하거나 자녀와 부드럽게 대화해보세요. 절대 자녀를 탓하거나 다그치지 마세요." + }, + { + "type": "reminder", + "title": "학교폭력 피해 발생 시 학부모 대처 방법", + "evidenceText": "피해학생의 경우: 1) 피해 사실을 확인하세요 2) 안정시켜 주세요 3) 공감해 주세요 4) 들어주세요 5) 도움을 요청하세요 - 먼저 담임선생님께 도움을 요청하세요 6) 보복하지 마세요", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국에서 학교폭력 피해가 발생하면 학교에 신고하면 학교폭력대책심의위원회가 열리고 공식 절차가 시작됩니다. 먼저 담임 선생님에게 연락하는 것이 첫 번째 단계이며, 긴급한 경우 학교폭력 신고전화(117)를 이용할 수 있습니다." + }, + { + "type": "reminder", + "title": "학교폭력 가해학생 조치 단계 안내", + "evidenceText": "가해학생 교육·선도 조치: 제1호 서면사과 / 제2호 접촉·협박 및 보복행위 금지 / 제3호 학교에서의 봉사 / 제4호 사회봉사 / 제5호 특별교육이수 또는 심리치료 / 제6호 출석정지 / 제7호 학급교체 / 제8호 전학", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "학교폭력이 인정되면 가해학생에게 단계별 조치가 내려집니다. 경미한 경우 서면사과부터 시작해, 심각한 경우 전학 조치까지 가능합니다. 이 조치 결과는 학교생활기록부에 기재되어 이후 입시에 영향을 줄 수 있습니다." + }, + { + "type": "reminder", + "title": "학교폭력 신고 연락처 안내", + "evidenceText": "학교폭력 발생 시 신고·상담 연락처: 담임교사 또는 교무실, 학교폭력전담경찰관, 학교폭력 신고 전화(117)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "117은 학교폭력 전국 신고전화로, 24시간 운영됩니다. 한국어 외에도 외국어 통역 서비스를 제공합니다. 긴급하지 않은 경우 먼저 담임 선생님에게 연락하는 것이 권장됩니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-020.json b/data/newsletter-labels/newsletter-020.json new file mode 100644 index 0000000..4a33aeb --- /dev/null +++ b/data/newsletter-labels/newsletter-020.json @@ -0,0 +1,49 @@ +{ + "documentId": "doc_020", + "documentTitle": "학교생활기록부 진로정보 초·중·고 연계를 위한 개인정보 활용 동의 안내", + "documentDate": "2023-11-29", + "school": "대전관저초등학교", + "dateCandidates": [ + { + "id": "dc_025_1", + "raw": "12월 1일(금)까지", + "resolved": "2023-12-01", + "note": "동의서 제출 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "진로정보 초·중·고 연계 서비스 안내", + "evidenceText": "진로정보 초·중·고 연계 서비스란 초·중·고가 연계하여 지속적·심층적 진로교육 지도를 위한 기반을 마련하기 위해 학생의 진로 관련 학교생활기록을 나이스 시스템을 통해 전달·제공하는 것입니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "이 서비스는 초등학교 6학년 때의 진로활동 기록(창의적 체험활동 중 진로활동 영역)을 중학교에 자동으로 전달하는 것입니다. 중학교 담임 선생님이 학생의 초등학교 진로 기록을 참고해 더 맞춤화된 진로 상담을 할 수 있습니다." + }, + { + "type": "deadline", + "title": "개인정보 활용 동의서 제출", + "evidenceText": "담임선생님께 12월 1일(금)까지 제출해주시기 바랍니다.", + "selectedDateCandidateId": "dc_025_1", + "dateStatus": "confirmed", + "date": "2023-12-01", + "target": "parent", + "actionRequired": true, + "schoolContext": "동의서에 예/아니오를 체크하고 학생과 보호자 모두 서명한 뒤 담임 선생님께 제출하면 됩니다. 동의하지 않아도 불이익은 없지만, 거부 시 중학교에서 진로 관련 정보를 연계해서 활용하지 못합니다." + }, + { + "type": "reminder", + "title": "개인정보 보유 기간 및 활용 범위 안내", + "evidenceText": "수집한 학생의 진로 관련 사항은 상급학교(중·고등학교) 재학(휴학) 기간에만 해당 학교에서 이용할 수 있습니다. / 입학전형 및 학생선발 자료로는 활용되지 않습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "이 정보는 진로 상담 목적으로만 사용되며, 중학교·고등학교 입학 시험이나 선발에는 사용되지 않습니다. 학생이 상급학교를 졸업하거나 전학하면 더 이상 사용되지 않습니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-021.json b/data/newsletter-labels/newsletter-021.json new file mode 100644 index 0000000..88068ec --- /dev/null +++ b/data/newsletter-labels/newsletter-021.json @@ -0,0 +1,55 @@ +{ + "documentId": "doc_021", + "documentTitle": "2025학년도 1학기 일일형 체험학습(롯데월드) 참가 신청 안내", + "documentDate": "2025-06-04", + "school": "포천삼정초등학교", + "dateCandidates": [ + { + "id": "dc_020_1", + "raw": "6월 5일(목)까지", + "resolved": "2025-06-05", + "note": "참가 신청서 제출 마감일" + }, + { + "id": "dc_020_2", + "raw": "2025.06.19.(목) 08:00~17:30", + "resolved": "2025-06-19T08:00", + "note": "체험학습 당일 일정" + } + ], + "labels": [ + { + "type": "reminder", + "title": "학교 주관 현장체험학습 개념 안내", + "evidenceText": "체험학습 교육과정 운영을 통하여 친구들끼리 친밀감도 높이고, 올바른 사회성과 바른 인성을 기르고자 합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "학교 주관 현장체험학습은 교외체험학습(개인 신청)과 다릅니다. 학교가 전체 학생을 대상으로 계획하고 인솔하는 단체 활동으로, 개별 신청이 아닌 참가 의사만 확인합니다. 모든 학생이 참여하는 것을 원칙으로 하며, 비용은 학교에서 부담합니다." + }, + { + "type": "deadline", + "title": "체험학습 참가 신청서 제출", + "evidenceText": "아래의 내용을 잘 확인하셔서 체험학습 참가 신청서를 작성하시고 담임교사에게 6월 5일(목)까지 제출해 주시기 바랍니다.", + "selectedDateCandidateId": "dc_020_1", + "dateStatus": "confirmed", + "date": "2025-06-05", + "target": "parent", + "actionRequired": true, + "schoolContext": "참가 신청서는 참석 여부(동의 및 참석/불참)를 선택해 담임 선생님에게 제출하는 서류입니다. 불참을 선택하는 경우 사유를 적어야 합니다." + }, + { + "type": "schedule", + "title": "일일형 현장체험학습(롯데월드)", + "evidenceText": "기간: 2025.06.19.(목) 08:00~17:30 / 장소: 서울시 송파구 롯데월드 어드벤처 / 참가비: 없음 (입장료, 체험비, 중식비, 간식비 일체 학교에서 지원함)", + "selectedDateCandidateId": "dc_020_2", + "dateStatus": "confirmed", + "date": "2025-06-19", + "target": "both", + "actionRequired": false, + "schoolContext": "학교 주관 체험학습은 입장료와 식비를 학교에서 부담하므로 별도 비용이 없습니다. 당일 학교에서 버스로 이동하며 교사가 인솔합니다. 오전 8시까지 학교에 집합해야 합니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-022.json b/data/newsletter-labels/newsletter-022.json new file mode 100644 index 0000000..16431ea --- /dev/null +++ b/data/newsletter-labels/newsletter-022.json @@ -0,0 +1,83 @@ +{ + "documentId": "doc_022", + "documentTitle": "2025학년도 2학기 우유급식 신청 조사", + "documentDate": "2025-08-22", + "school": "대전관저초등학교", + "dateCandidates": [ + { + "id": "dc_021_1", + "raw": "8월 28일(목)까지", + "resolved": "2025-08-28", + "note": "우유급식 신청 마감일" + }, + { + "id": "dc_021_2", + "raw": "9월 3일(수) / 9월 5일(금)", + "resolved": "2025-09-03", + "note": "CMS 자동이체일 (두 날짜 중 하나)" + }, + { + "id": "dc_021_3", + "raw": "9월 8일~12월 26일", + "resolved": "2025-09-08", + "note": "우유급식 기간 시작일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "학교 우유급식 제도 안내", + "evidenceText": "2025학년도 2학기 우유급식 신청을 받고자 하오니 우유급식을 희망하는 학생은 하이클래스로 8월 28일(목)까지 제출해 주시기 바랍니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 학교에서는 점심 급식 외에 오전 중 우유를 별도로 제공하는 프로그램이 있습니다. 이는 선택 사항으로, 희망하는 학생만 신청하고 비용(학기당 약 33,000원)을 납부합니다. 신청하지 않으면 제공되지 않습니다." + }, + { + "type": "deadline", + "title": "우유급식 신청", + "evidenceText": "우유급식을 희망하는 학생은 하이클래스로 8월 28일(목)까지 제출해 주시기 바랍니다. (기한 내에 미신청 시 우유급식을 희망하지 않는 것으로 간주)", + "selectedDateCandidateId": "dc_021_1", + "dateStatus": "confirmed", + "date": "2025-08-28", + "target": "parent", + "actionRequired": true, + "schoolContext": "하이클래스는 학교와 학부모가 소통하는 한국의 학교 전용 앱입니다. 앱을 통해 우유급식 신청서를 제출할 수 있습니다. 기한이 지나면 추가 신청이 원칙적으로 불가합니다." + }, + { + "type": "deadline", + "title": "우유급식비 CMS 자동이체", + "evidenceText": "우유급식기간: 9월 8일~12월 26일 / CMS 자동이체일: 9월 3일(수) / 9월 5일(금) (현금수납 불가, 통장잔액 확인)", + "selectedDateCandidateId": "dc_021_2", + "dateStatus": "confirmed", + "date": "2025-09-03", + "target": "parent", + "actionRequired": true, + "schoolContext": "CMS는 학교 납부금을 계좌에서 자동으로 인출하는 시스템입니다. 현금 납부는 불가하며, 지정일에 등록된 계좌에서 자동 출금됩니다. 잔액이 부족하면 이체가 실패하므로 미리 확인해두세요." + }, + { + "type": "reminder", + "title": "무상 우유급식 지원 대상 안내", + "evidenceText": "농림축산식품부 「학교우유급식사업」에 따라 자격기준을 충족하는 가정의 학생에게 무상으로 우유를 지원하고 있습니다. 자격기준: 국민기초생활수급자, 차상위계층, 한부모가족, 특수교육대상자, 교육비지원대상자, 국가유공자 자녀", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "저소득 가정, 한부모가정 등은 우유급식 비용을 정부에서 지원받을 수 있습니다. 해당 대상자도 반드시 신청해야 지원받을 수 있으며, 신청하지 않으면 자동으로 지원되지 않습니다. 해당 여부가 불확실하면 학교 행정실에 문의하세요." + }, + { + "type": "reminder", + "title": "우유 알레르기·소화장애 학생 신청 주의", + "evidenceText": "우유에 이상반응(알레르기, 소화장애 등)이 있는 학생은 신청을 고려하시기 바랍니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "유당불내증(우유를 마시면 소화가 안 되는 증상)이 있는 자녀는 우유급식 신청을 하지 않는 것이 좋습니다. 신청 후 취소는 전출(전학)하는 경우에만 가능하며, 일반적인 취소는 한 달 단위로만 가능합니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-023.json b/data/newsletter-labels/newsletter-023.json new file mode 100644 index 0000000..453b84d --- /dev/null +++ b/data/newsletter-labels/newsletter-023.json @@ -0,0 +1,53 @@ +{ + "documentId": "doc_023", + "documentTitle": "녹색어머니회 활동 위치 및 등하굣길 교통안전 협조 안내", + "documentDate": "2026-03-24", + "school": "도일초등학교", + "dateCandidates": [], + "labels": [ + { + "type": "reminder", + "title": "녹색어머니회 개념 안내", + "evidenceText": "녹색어머니회 활동 위치와 안전한 등하굣길을 위한 협조 사항에 대해 안내드립니다. / 깃발과 조끼 보관 위치: 1층 체력단련실", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "녹색어머니회는 학부모 자원봉사자들이 등하교 시간에 학교 주변 교차로나 횡단보도에서 어린이 교통안전을 도와주는 한국 특유의 학교 봉사 활동입니다. 녹색 깃발과 조끼를 착용하고 아이들이 안전하게 길을 건너도록 돕습니다. 학부모들이 순번제로 참여하는 경우가 많습니다." + }, + { + "type": "reminder", + "title": "개인 차량 등교 시 하차 위치 안내", + "evidenceText": "학생을 개인차량으로 등교시켜야 할 경우, 정문 방향으로 진입하셔야 하는데, 정문을 지나 차량이 정차하는 경우 뒤따라오는 차량으로 인하여 학생들이 하차 시에 차와 부딪힐 위험이 있습니다. 정문 앞 횡단보도에서 우회전하여 하차 후, 교통안전지킴이의 안내에 따라 학생들이 학교까지 안전하게 등교할 수 있도록 협조해 주시기를 부탁드립니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 학교는 안전상 정문 앞에 차량 정차를 금지하는 경우가 많습니다. 자녀를 차로 데려다줄 때는 학교 안내에 따라 지정된 위치에서 하차시켜야 합니다. 정문을 지나쳐 정차하면 안전사고 위험이 있습니다." + }, + { + "type": "reminder", + "title": "자전거 등하교 금지 안내", + "evidenceText": "자전거로 등하교를 하는 학생들이 있습니다. 학생의 안전을 위해 자전거 등하교를 금지합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "both", + "actionRequired": false, + "schoolContext": "많은 한국 학교에서는 안전을 이유로 자전거 등하교를 금지하고 있습니다. 학교 근처 도로는 보행자가 많아 자전거 사고 위험이 높기 때문입니다. 귀가 후 학교를 재방문할 때도 자전거 이용은 금지됩니다." + }, + { + "type": "reminder", + "title": "스쿨존 어린이 교통안전 주의사항", + "evidenceText": "스쿨존 내 학생 교통사고 사례: 무단횡단하는 어른을 보고 따라 하는 경우 / 초록 불이 들어오자마자 갑자기 뛰어드는 경우 / 통학버스 또는 부모님 차량에서 내려 급하게 횡단보도를 건너가는 경우", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "both", + "actionRequired": false, + "schoolContext": "스쿨존(어린이보호구역)은 학교 주변 300m 이내 도로로, 차량 속도가 시속 30km로 제한되는 특별 구역입니다. 스쿨존 내 교통법규 위반 시 벌금이 일반 도로보다 2배 이상 무겁습니다. 자녀에게 차에서 내릴 때 급하게 뛰지 않도록 미리 안내해주세요." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-024.json b/data/newsletter-labels/newsletter-024.json new file mode 100644 index 0000000..63d72c8 --- /dev/null +++ b/data/newsletter-labels/newsletter-024.json @@ -0,0 +1,64 @@ +{ + "documentId": "doc_024", + "documentTitle": "2026학년도 1학기 평가 안내 (수행평가)", + "documentDate": "2026-04-02", + "school": "성라초등학교", + "dateCandidates": [], + "labels": [ + { + "type": "reminder", + "title": "수행평가 개념 안내", + "evidenceText": "학습으로의 평가는 논술형 평가, 정의적 능력 평가, 협력적 문제해결력 평가를 포함한 다양한 수행평가(논술, 토의·토론, 실험·실습, 포트폴리오, 보고서 등)로 실시합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 초등학교에서는 지필시험(종이 시험지) 대신 수행평가 방식으로 학생을 평가합니다. 수업 중 발표, 실험, 글쓰기, 토론 등을 통해 과목별로 수시로 평가가 이루어집니다. 단 한 번의 시험으로 성적이 결정되는 것이 아니므로, 평소 수업 참여가 중요합니다." + }, + { + "type": "reminder", + "title": "평가 단계(4단계 성취도) 안내", + "evidenceText": "평가단계: 매우 잘함 - 잘함 - 보통 - 노력 요함 / 교과평가(4단계 성취도)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국 초등학교는 100점 만점 점수 대신 4단계 성취도(매우 잘함·잘함·보통·노력 요함)로 평가 결과를 표시합니다. 이 결과는 학기말에 생활통지표로 가정에 발송됩니다. 중학교부터는 점수제로 전환됩니다." + }, + { + "type": "reminder", + "title": "창의적 체험활동 평가 안내", + "evidenceText": "창의적 체험활동: 활동 상황의 관찰, 질문지를 이용한 조사, 학생의 작품과 기록, 교사의 의견 교환 등", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "창의적 체험활동(창체)은 교과 수업 외에 자율활동, 동아리활동, 봉사활동, 진로활동 등을 포함합니다. 학업 성적과는 별도로 평가되며 학교생활기록부에 기재됩니다. 체육대회, 현장학습, 각종 행사 참여도 창체 활동에 해당합니다." + }, + { + "type": "reminder", + "title": "생활통지표 발송 안내", + "evidenceText": "평가 결과는 학기별로 가정으로 안내 / 생활통지표 가정 통지 내용: 1학기 - 기본학적사항, 출결 상황, 교과평가(4단계 성취도), 창의적 체험활동상황 / 2학기 - 기본학적사항, 출결 상황, 교과평가, 창의적 체험활동상황, 행동특성 및 종합의견", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "생활통지표는 학기말에 학교에서 학부모에게 발송하는 성적표입니다. 교과 성취도, 출결, 활동 내용이 담겨 있습니다. 2학기 생활통지표에는 담임 선생님이 작성하는 '행동특성 및 종합의견'이 추가되며, 이 내용이 학교생활기록부에 그대로 기재됩니다." + }, + { + "type": "reminder", + "title": "정의적 능력 평가 개념 안내", + "evidenceText": "정의적 능력 수시 평가: 근면성, 책임감, 자주성, 협동성, 준법성, 예절성 등", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "정의적 능력 평가는 학업 실력이 아닌 태도와 인성을 평가하는 항목입니다. 수업 중 참여도, 친구와의 협동, 규칙 준수 등을 교사가 관찰해서 평가합니다. 시험을 잘 봐도 이 항목에서 낮은 평가를 받을 수 있으니, 자녀의 학교 태도에도 관심을 기울여주세요." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-025.json b/data/newsletter-labels/newsletter-025.json new file mode 100644 index 0000000..7d74ad7 --- /dev/null +++ b/data/newsletter-labels/newsletter-025.json @@ -0,0 +1,55 @@ +{ + "documentId": "doc_025", + "documentTitle": "2026학년도 독서통장 운영 안내", + "documentDate": "2026-03-25", + "school": "시흥신일초등학교", + "dateCandidates": [ + { + "id": "dc_024_1", + "raw": "2026. 3. 30. ~ 7. 9.", + "resolved": "2026-07-09", + "note": "독서통장 기록 마감일" + }, + { + "id": "dc_024_2", + "raw": "독서통장 제출일: 7. 10.", + "resolved": "2026-07-10", + "note": "독서통장 담임 제출 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "독서통장 제도 안내", + "evidenceText": "2026학년도에는 학생들의 문해력과 독서능력 향상을 위해 전 학년을 대상으로 독서통장을 실시하고자 합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "독서통장은 학생이 읽은 책 목록과 간단한 감상을 기록하는 수첩입니다. 은행 통장처럼 책 한 권을 읽을 때마다 한 줄씩 기록하며, 학기말에 담임 선생님께 제출해 확인을 받습니다. 목표 권수를 달성하면 도서관에서 시상이 있습니다." + }, + { + "type": "checklist", + "title": "학년별 독서 목표 권수 달성 및 독서통장 제출", + "evidenceText": "성취기준(아래의 권수만큼 독서통장에 기록 후 담임선생님께 제출 및 확인): 2학년 20권 / 3학년 15권 / 4학년 15권 / 5학년 10권 / 6학년 10권 / 독서통장 제출일: 7. 10.", + "selectedDateCandidateId": "dc_024_2", + "dateStatus": "confirmed", + "date": "2026-07-10", + "target": "both", + "actionRequired": true, + "schoolContext": "가정, 도서관 어디서 읽은 책이든 모두 기록할 수 있습니다. 단, 만화책, 그림이 1/3 이상인 책(도감, 화보 등), 퀴즈·유머·종이접기 등의 책은 인정되지 않습니다. 1학년은 2학기부터 시작합니다." + }, + { + "type": "reminder", + "title": "인정되지 않는 도서 및 기록 안내", + "evidenceText": "인정하지 않는 도서의 종류: 사진·그림이 책의 1/3 이상인 책 제외(만화, 화보, 도감 등) / 퀴즈, 유머, 종이접기, 그리기, 만들기, 악보, 잡지 등의 책 제외 / 인정하지 않는 기록 사례: 불성실한 감상평(동일한 감상평) / 동일 도서로 2번 이상 기록", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "both", + "actionRequired": false, + "schoolContext": "독서통장에는 책 제목, 읽은 날짜, 간단한 감상을 성실하게 적어야 합니다. 같은 내용을 복사해서 쓰거나 성의 없는 감상평은 인정받지 못할 수 있습니다. 한 권의 책은 한 번만 기록할 수 있습니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-026.json b/data/newsletter-labels/newsletter-026.json new file mode 100644 index 0000000..e027b2a --- /dev/null +++ b/data/newsletter-labels/newsletter-026.json @@ -0,0 +1,94 @@ +{ + "documentId": "doc_026", + "documentTitle": "학교안전공제회 보상시스템 및 학부모 직접청구 절차 안내", + "documentDate": "2025-03-24", + "school": "덕성초등학교", + "dateCandidates": [ + { + "id": "dc_026_1", + "raw": "사고 발생일로부터 3년 이내", + "resolved": null, + "note": "청구권 소멸시효 — 절대 날짜 아님" + }, + { + "id": "dc_026_2", + "raw": "14일 이내", + "resolved": null, + "note": "공제급여 지급 결정 기간 — 절대 날짜 아님" + }, + { + "id": "dc_026_3", + "raw": "사고 후 최대한 빨리 2~3일 이내", + "resolved": null, + "note": "사고통지 권장 기간 — 절대 날짜 아님" + } + ], + "labels": [ + { + "type": "reminder", + "title": "학교안전공제회 제도 안내", + "evidenceText": "본교에서는 학교 교육활동 중 발생한 학생의 안전사고에 대비하여 「학교안전사고예방 및 보상에 관한 법률」에 의거 학교안전공제회에 가입하고 교육활동 중 안전사고가 발생하면 안전공제회에 사고통지를 하고 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국의 학교안전공제회는 학생이 학교 교육활동 중 다쳤을 때 치료비를 보상해주는 공적 보험 제도입니다. 별도 가입 없이 모든 재학생이 자동으로 적용되며, 국민건강보험으로 부담한 본인부담금 전액과 비급여 항목 일부를 지원받을 수 있습니다." + }, + { + "type": "reminder", + "title": "학교안전공제회 보상 범위 안내", + "evidenceText": "국민건강보험 적용 급여 항목의 본인부담금은 전액 보상하며, 비급여 항목 진료비는 학교안전교제회에서 심사 후 결정하여 지급합니다. 공제급여의 청구 횟수 제한은 없으며, 치료가 종료된 경우는 물론, 치료 중인 경우에도 청구 가능합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "국민건강보험이 적용되는 치료비 중 학부모가 부담한 금액(본인부담금)은 전액 돌려받을 수 있습니다. MRI 등 비급여 항목도 일부 보상이 가능합니다. 치료가 끝나지 않아도 중간에 청구할 수 있으며, 여러 번 나눠서 청구도 가능합니다." + }, + { + "type": "checklist", + "title": "학교 안전사고 발생 시 즉시 담임 선생님께 사고통지", + "evidenceText": "치료비 청구 의사가 있으신 학부모님께서는 사고 발생 후 최대한 빨리(2~3일 이내) 담임선생님께 알려주세요. (학교를 통해 사고통지가 접수되지 않는 경우 청구가 불가하니 청구 의사가 있으신 경우 반드시 알려주시기 바랍니다)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "치료비를 청구하려면 반드시 학교를 통해 먼저 사고통지가 접수되어야 합니다. 학교에 알리지 않고 직접 공제회에 청구하면 거부될 수 있습니다. 사고 후 2~3일 이내에 담임 선생님에게 연락하는 것이 중요합니다." + }, + { + "type": "checklist", + "title": "공제급여 청구에 필요한 서류 준비", + "evidenceText": "공제급여청구서 / 진료비(치료비) 계산서 영수증 또는 납부확인서 원본 (카드매출전표, 간이영수증, 현금영수증 등은 불인정) / 청구인(보호자) 통장 사본 / 진료비(비급여) 세부내역서 / 사고학생 주민등록등본 [가족관계증명서]", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "병원에서 받은 진료비 영수증(계산서 원본)이 반드시 필요합니다. 카드 영수증이나 간이영수증은 인정되지 않으니 병원에서 정식 영수증을 꼭 받아두세요. 외국인 학생의 경우 주민등록등본 대신 외국인등록증으로 대체 가능합니다." + }, + { + "type": "reminder", + "title": "온라인 청구 방법 안내", + "evidenceText": "학교안전공제회 공제급여관리시스템 접속(http://www.schoolsafe.or.kr) / 방법 1) 학부모 시스템로그인(모바일 인증 로그인) / 공제급여청구 클릭 → 청구서 작성", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "공제급여는 학교안전공제회 홈페이지(www.schoolsafe.or.kr)에서 온라인으로 직접 청구할 수 있습니다. 모바일 인증 로그인 후 자녀 이름과 사고 정보를 입력하면 됩니다. 서류는 파일로 업로드하거나 우편으로 제출할 수 있습니다." + }, + { + "type": "reminder", + "title": "청구권 소멸시효 안내", + "evidenceText": "청구권 소멸시효: 사고 발생일로부터 3년 경과 시 청구권 소멸", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "사고가 발생하고 3년이 지나면 치료비를 청구할 수 없습니다. 당장 치료비가 크지 않더라도 나중에 후유증이 생길 수 있으므로, 사고 후 반드시 담임 선생님께 알려두는 것이 중요합니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-027.json b/data/newsletter-labels/newsletter-027.json new file mode 100644 index 0000000..445153c --- /dev/null +++ b/data/newsletter-labels/newsletter-027.json @@ -0,0 +1,82 @@ +{ + "documentId": "doc_027", + "documentTitle": "위(Wee)클래스 이용 및 상담 동의 안내", + "documentDate": "2026-03-23", + "school": "도일초등학교", + "dateCandidates": [ + { + "id": "dc_028_1", + "raw": "3월 27일(금)까지", + "resolved": "2026-03-27", + "note": "상담 동의서 및 개인정보 동의서 제출 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "위(Wee)클래스 제도 안내", + "evidenceText": "본교 위클래스 상담실에서는 학생들의 심리·정서적 안정과 학교생활 적응을 돕고, 상담 활동을 전문화하여 학생들의 고민과 문제를 함께 해결하고자 노력하고 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "위(Wee)클래스는 한국 교육부가 전국 학교에 설치한 학생 상담 전문 공간입니다. 전문 상담교사가 상주하며 학생의 학교 적응, 친구 관계, 심리·정서 문제 등을 무료로 상담해줍니다. 별도 비용이 없으며 학부모도 이용할 수 있습니다." + }, + { + "type": "reminder", + "title": "위클래스 상담 내용과 이용 방법 안내", + "evidenceText": "개인상담, 집단상담: 친구 및 가족관계, 학업 및 진로, 성격, 심리·정서 관련, 학교 적응 등 / 각종 심리검사 실시 및 해석 상담 / 상담 진행: 약 4~10회기 진행 (1회기 상담은 주 1회, 40분)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "상담은 보통 4~10회에 걸쳐 진행되며, 주 1회 약 40분씩 이루어집니다. 단순 대화가 아닌 전문 상담교사가 진행하는 구조화된 상담입니다. 심리검사(ADHD 성향, 불안, 우울 등)도 실시할 수 있으며, 필요시 외부 전문 병원이나 기관으로 연계해 줍니다." + }, + { + "type": "deadline", + "title": "위클래스 상담 동의서 및 개인정보 동의서 제출", + "evidenceText": "아래 내용을 살펴보신 후 학생 상담 동의 및 개인정보 수집·이용 동의서를 3월 27일(금)까지 이알리미로 제출해 주시기 바랍니다.", + "selectedDateCandidateId": "dc_028_1", + "dateStatus": "confirmed", + "date": "2026-03-27", + "target": "parent", + "actionRequired": true, + "schoolContext": "위클래스를 이용하려면 학부모 동의서가 필요합니다. 동의서는 이알리미 앱을 통해 제출하며, 동의하지 않으면 상담, 심리검사, 상담 프로그램 참여에 제한이 생깁니다. 동의서를 미리 제출해두면 자녀가 필요할 때 바로 이용할 수 있습니다." + }, + { + "type": "reminder", + "title": "상담 내용 비밀 보장 안내", + "evidenceText": "상담의 내용은 비밀 보장되며, 학교생활기록부에도 일절 기록되지 않습니다. 다만, 담임(교과)교사에게는 학생의 동의하에 제한적인 정보 제공과 교육적 자문이 이루어질 수 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "상담 내용은 학교생활기록부(성적표)에 기록되지 않습니다. 상담을 받았다는 사실이 이후 진학이나 성적에 영향을 주지 않으므로 안심하고 이용할 수 있습니다. 다만 자녀나 타인에게 위험이 있는 경우(자해, 학교폭력, 아동학대 등)에는 비밀 보장 예외가 적용됩니다." + }, + { + "type": "reminder", + "title": "비밀 보장 예외 상황 안내", + "evidenceText": "단, 몇 가지 예외 사항이 있습니다. 자신이나 타인의 신체 및 재산에 해를 가할 가능성(자해 및 자살, 학교폭력, 성폭력 등), 가정 폭력 및 아동학대와 관련된 경우, 범죄와 연루된 경우, 수사기관이 요구하는 경우 등이 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "자녀가 자해, 자살 위험, 학교폭력 피해, 아동학대 등과 관련된 내용을 상담에서 이야기할 경우 상담교사는 의무적으로 관계기관에 신고하거나 학부모에게 알려야 합니다. 이는 아이의 안전을 위한 법적 의무이며, 상담교사의 자의적 판단이 아닙니다." + }, + { + "type": "reminder", + "title": "ADHD·우울·자해 등 심각한 문제의 경우 외부기관 연계 안내", + "evidenceText": "ADHD, 심각한 우울, 자해, 품행장애 등의 문제를 가진 학생들은 진단을 통해 심리치료(병원, 외부 전문기관)를 함께 받아야 합니다. 또한 학교 상담은 많은 학생을 대상으로 하므로 장기간 상담이 어려워 지속적으로 상담을 받을 수 있는 센터나 사설 기관으로 의뢰하는 경우도 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "학교 위클래스는 경미한 어려움을 다루는 초기 상담 공간입니다. 전문적인 치료가 필요한 수준이라면 학교 상담만으로는 한계가 있어 외부 전문기관(정신건강복지센터, 병원 등)으로 연계합니다. 연계된 외부기관도 대부분 무료 또는 저렴하게 이용할 수 있습니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-028.json b/data/newsletter-labels/newsletter-028.json new file mode 100644 index 0000000..0834b55 --- /dev/null +++ b/data/newsletter-labels/newsletter-028.json @@ -0,0 +1,174 @@ +{ + "documentId": "doc_028", + "documentTitle": "감염성 질환 등교중지 협조 안내문", + "documentDate": "2026-03-03", + "school": "삼산초등학교", + "dateCandidates": [], + "labels": [ + { + "type": "reminder", + "title": "감염병 등교중지 제도 개념 안내", + "evidenceText": "학교보건법 등 관련 법령 및 지침에 의거하여 학생이 감염성 질환에 감염되었을 경우에는 '학교 내 감염의 전파를 예방하기 위하여 등교중지'를 실시하고 있음을 알려드리니", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국에서는 전염성이 있는 감염병에 걸린 학생은 법으로 정해진 기간 동안 학교에 오지 못하도록 규정하고 있습니다. 이를 '등교중지'라고 합니다. 단순히 아픈 것과 달리, 특정 감염병으로 확인되면 증상이 나아도 정해진 기간이 지날 때까지 등교할 수 없습니다. 이 기간의 결석은 출석으로 인정받을 수 있으나 반드시 서류를 제출해야 합니다." + }, + { + "type": "reminder", + "title": "감염병 등교중지 시 출석 인정 원칙 안내", + "evidenceText": "감염병으로 인한 등교중지의 경우, 아래 출석 인정 근거 자료 제출 시 결석으로 처리되지 않음", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "감염병으로 인한 등교중지 기간의 결석은 '출석인정결석'으로 처리되어 학교생활기록부에 불이익이 없습니다. 단, 반드시 병원에서 발급받은 서류를 학교에 제출해야 합니다. 서류를 제출하지 않으면 일반 결석(미인정결석)으로 처리될 수 있으니 반드시 챙겨야 합니다." + }, + { + "type": "checklist", + "title": "등교중지 3단계 절차 이행", + "evidenceText": "1) 등교 전 감염병이 의심되면, 학교 담임선생님께 연락을 한 후 병원 진료 / 2) 감염병이 확인되면, 학교는 물론 학원 및 기타 공동생활을 하는 곳에 가지 않고 가정에서 요양 / 3) 완치 후 등교 시 진단명이 기재된 증빙서류(진료확인서, 의사소견서, 진단서 등 중 1개)를 제출", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "순서가 중요합니다. ① 먼저 담임 선생님에게 연락 → ② 병원 방문 → ③ 집에서 요양 → ④ 완치 후 등교 시 서류 제출. 담임 선생님에게 먼저 연락하지 않으면 사고통지가 접수되지 않아 출석인정을 받지 못할 수 있습니다. 학원도 함께 쉬어야 합니다." + }, + { + "type": "checklist", + "title": "등교 재개 시 병원 서류 제출", + "evidenceText": "완치 후 등교 시 진단명이 기재된 증빙서류(진료확인서, 의사소견서, 진단서 등 중 1개)를 제출 / 부득이한 경우 처방전도 인정", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "등교를 다시 시작할 때 병원에서 발급받은 서류를 담임 선생님께 제출해야 출석인정이 됩니다. 진료확인서, 의사소견서, 진단서 중 하나면 충분합니다. 서류 발급이 어려운 경우 처방전도 인정됩니다. 처방전에 적힌 질병코드(알파벳+숫자 형태)로 감염병 여부를 확인합니다." + }, + { + "type": "reminder", + "title": "인플루엔자(독감) 등교중지 기간 및 조건", + "evidenceText": "인플루엔자 / 임상증상: 발열, 두통, 근육통, 인후통, 기침, 객담 / 등교중지 권고 기간: 해열제 없이 정상체온 회복 후 24시간이 경과할 때까지. (단, 해열제를 투약한 경우 마지막 해열제 투약 시점부터 48시간이 경과해야 함)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "독감(인플루엔자)에 걸리면 열이 내려도 바로 등교할 수 없습니다. 해열제를 먹지 않고 자연적으로 열이 내린 경우 24시간 후에 등교 가능하고, 해열제를 먹어서 열을 낮춘 경우에는 마지막으로 해열제를 먹은 시점부터 48시간이 지나야 합니다. 증상이 없어 보여도 이 시간을 반드시 지켜야 합니다." + }, + { + "type": "reminder", + "title": "수두 등교중지 기간", + "evidenceText": "수두 / 임상증상: 피부 발진, 수포, 발열, 피로감 / 등교중지 권고 기간: 모든 수포에 가피가 형성될 때까지", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "수두는 물집(수포)이 모두 딱지(가피)로 변할 때까지 등교할 수 없습니다. 열이 내리거나 새 물집이 생기지 않아도 기존 물집이 전부 딱지가 되지 않으면 등교가 불가합니다. 통상 발진 후 7~10일 정도 걸리며, 완치 확인은 의사에게 받으세요." + }, + { + "type": "reminder", + "title": "코로나19 등교중지 기간", + "evidenceText": "코로나19 / 임상증상: 발열, 기침, 인후통, 두통, 근육통 등 / 등교중지 권고 기간: 증상이 사라진 다음날부터 등교가능", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "코로나19는 발열, 기침 등 모든 증상이 사라진 다음날부터 등교가 가능합니다. 증상이 없어진 당일은 등교할 수 없고, 다음날부터 가능합니다. 양성 확인 후 반드시 담임 선생님에게 연락하고, 완치 후 등교 시 진료 서류를 제출하세요." + }, + { + "type": "reminder", + "title": "노로바이러스(장염) 등교중지 기간", + "evidenceText": "노로바이러스 / 임상증상: 오심, 구토, 설사, 복통, 권태감, 발열 / 등교중지 권고 기간: 증상 소실 후 48시간까지", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "노로바이러스(식중독·장염의 일종)는 구토나 설사 등 증상이 완전히 없어진 후에도 48시간(2일)이 더 지나야 등교할 수 있습니다. 몸이 다 나은 것처럼 느껴져도 이 기간을 반드시 지켜야 합니다. 이 기간에도 전염력이 남아 있기 때문입니다." + }, + { + "type": "reminder", + "title": "수족구병 등교중지 기간", + "evidenceText": "수족구병 / 임상증상: 발열, 손, 발바닥과 구강 내 수포 및 궤양 / 등교중지 권고 기간: 수포 발생 후 6일간 또는 가피가 형성될 때까지", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "수족구병은 손, 발, 입 안에 물집이 생기는 전염성 질환으로, 주로 영유아와 초등 저학년에서 많이 발생합니다. 물집이 생긴 날부터 6일이 지나거나, 물집이 모두 딱지로 변할 때까지 등교할 수 없습니다. 두 조건 중 먼저 충족되는 쪽을 기준으로 합니다." + }, + { + "type": "reminder", + "title": "유행성이하선염(볼거리) 등교중지 기간", + "evidenceText": "유행성이하선염(볼거리) / 임상증상: 이하선 부종, 발열, 두통, 근육통 / 등교중지 권고 기간: 증상 발생 후 5일까지", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "볼거리(유행성이하선염)는 귀 아래 턱 주변이 부어오르는 바이러스성 전염병입니다. 증상이 나타난 날부터 5일까지는 등교할 수 없습니다. 한국에서는 MMR 예방접종(홍역·볼거리·풍진 혼합)으로 예방할 수 있으며, 입학 시 접종 여부를 확인합니다." + }, + { + "type": "reminder", + "title": "성홍열 등교중지 기간", + "evidenceText": "성홍열 / 임상증상: 미만성 구진, 발열, 두통, 구토, 복통, 오한 및 인후염 / 등교중지 권고 기간: 항생제 치료 시작 후 24시간까지", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "성홍열은 세균성 감염으로 온몸에 붉은 발진이 생기고 고열이 나는 질환입니다. 항생제를 복용하기 시작하면 빠르게 전염성이 줄어들어, 항생제 투여 후 24시간이 지나면 등교가 가능합니다. 다른 감염병보다 복귀 기간이 짧은 편이지만, 의사의 지시에 따라 항생제를 끝까지 복용해야 합니다." + }, + { + "type": "reminder", + "title": "결핵 등교중지 기간", + "evidenceText": "결핵 / 임상증상: 발열, 전신 피로감, 식은땀, 체중감소 / 등교중지 권고 기간: 약물 치료 시작 후 2주까지", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "결핵은 공기로 전파되는 세균성 폐 질환으로, 치료약을 복용하기 시작한 날부터 2주가 지나야 등교할 수 있습니다. 결핵은 증상이 가볍거나 없는 경우도 있어 발견이 늦어지는 경우가 많습니다. 2주 이상 기침이 지속되거나 원인 모를 체중 감소가 있으면 병원을 방문하세요." + }, + { + "type": "reminder", + "title": "홍역·풍진 등교중지 기간", + "evidenceText": "홍역: 발진, 발열, 기침, 콧물, koplik 반점 / 등교중지 권고 기간: 발진이 나타난 후 4일까지 / 풍진: 구진성 발진, 림프절 종창, 미열 등 감기증상 / 등교중지 권고 기간: 발진이 나타난 후 7일까지", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "홍역과 풍진은 발진(온몸에 붉은 반점)이 나타나는 바이러스성 전염병입니다. 홍역은 발진 후 4일, 풍진은 발진 후 7일까지 등교할 수 없습니다. 두 질환 모두 MMR 백신(홍역·볼거리·풍진 혼합 예방접종)으로 예방 가능합니다. 외국 출신 학생 중 MMR 접종 기록이 없는 경우 반드시 확인하세요." + }, + { + "type": "reminder", + "title": "백일해 등교중지 기간", + "evidenceText": "백일해 / 임상증상: 상기도 감염 증상, 발작성 기침, 구토 / 등교중지 권고 기간: 항생제 투여 후 5일까지", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "백일해는 '100일 기침'이라고도 불리는 세균성 호흡기 질환으로, 발작적으로 심하게 기침합니다. 항생제를 복용하기 시작한 날부터 5일까지 등교할 수 없습니다. DTaP 또는 Tdap 예방접종으로 예방 가능합니다. 초등학교 입학 시 DTaP 5차 접종 완료가 필수입니다." + }, + { + "type": "reminder", + "title": "등교중지 기간에 학원도 가지 말아야 함", + "evidenceText": "감염병이 확인되면, 학교는 물론 학원 및 기타 공동생활을 하는 곳에 가지 않고 가정에서 요양", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "등교중지 기간에는 학교뿐 아니라 학원, 태권도장, 수영장 등 다른 사람과 함께 생활하는 모든 장소에도 가지 않아야 합니다. 학교만 빠지고 학원에 보내는 경우가 있는데, 이는 다른 사람에게 전파할 수 있어 법적으로도 협조 의무가 있습니다." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-029.json b/data/newsletter-labels/newsletter-029.json new file mode 100644 index 0000000..4e65838 --- /dev/null +++ b/data/newsletter-labels/newsletter-029.json @@ -0,0 +1,88 @@ +{ + "documentId": "doc_029", + "documentTitle": "2025학년도 겨울방학 방과후학교 연계형 돌봄교실 신청 안내", + "documentDate": "2025-12-01", + "school": "대전관저초등학교", + "dateCandidates": [ + { + "id": "dc_027_1", + "raw": "12월 5일(금)까지", + "resolved": "2025-12-05", + "note": "신청서 및 서류 제출 마감일" + }, + { + "id": "dc_027_2", + "raw": "2026. 1. 12.(월) ~ 2. 13.(금)", + "resolved": "2026-02-13", + "note": "겨울방학 돌봄교실 운영 기간 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "방과후학교 연계형 돌봄교실 개념 안내", + "evidenceText": "2025학년도 겨울방학 방과후학교 연계형 돌봄교실 신청 방법을 아래와 같이 안내하오니, 참여를 희망하시는 학부모님께서는 신청서와 서류를 12월 5일(금)까지 돌봄교실 2반으로 제출해주시기 바랍니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "방과후학교 연계형 돌봄교실은 방학 중 맞벌이·저소득 가정 등 돌봄이 필요한 학생을 위해 학교에서 운영하는 돌봄 서비스입니다. 방학 중에도 학교에서 자녀를 안전하게 돌봐주며, 방과후 프로그램과 연계해 수업도 함께 진행합니다. 모든 학생이 이용하는 것이 아니라 신청자에 한해 선발합니다." + }, + { + "type": "reminder", + "title": "돌봄교실 우선순위 선발 기준 안내", + "evidenceText": "1순위: 국민기초생활보장수급자 / 2순위: 법정차상위 한부모가족보호대상자, 법정차상위 자활근로참가자 등 / 4순위: 한부모 가족 / 5순위: 취업 부모 / 6순위: 두 자녀 이상 다자녀", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "돌봄교실 자리가 제한되어 있어 우선순위에 따라 선발합니다. 국민기초생활수급자, 한부모가정, 저소득층이 우선 대상이며, 맞벌이 가정도 취업 증빙서류를 제출하면 신청할 수 있습니다. 다문화가정은 별도 우선순위가 없더라도 소득 기준 충족 시 해당 순위로 신청 가능합니다." + }, + { + "type": "deadline", + "title": "겨울방학 돌봄교실 신청서 및 증빙서류 제출", + "evidenceText": "신청서와 서류를 12월 5일(금)까지 돌봄교실 2반으로 제출해주시기 바랍니다. (학기 중 참여자는 신청서만 제출)", + "selectedDateCandidateId": "dc_027_1", + "dateStatus": "confirmed", + "date": "2025-12-05", + "target": "parent", + "actionRequired": true, + "schoolContext": "처음 신청하는 경우 신청서와 해당 순위 증빙서류를 함께 제출해야 합니다. 이미 학기 중 돌봄교실에 참여하고 있는 학생은 신청서만 제출하면 됩니다. 서류 없이 신청서만 내면 선정에서 제외될 수 있습니다." + }, + { + "type": "schedule", + "title": "겨울방학 돌봄교실 운영 기간", + "evidenceText": "운영 기간: 2026. 1. 12.(월) ~ 2. 13.(금) *공휴일 및 주말 제외 / 운영 시간: 09:00 ~ 11:50", + "selectedDateCandidateId": "dc_027_2", + "dateStatus": "confirmed", + "date": "2026-02-13", + "target": "parent", + "actionRequired": false, + "schoolContext": "방학 중 돌봄교실은 오전 9시부터 11시 50분까지 운영됩니다. 이 시간 안에서 학부모가 원하는 귀가 시간을 지정할 수 있습니다. 급식과 간식은 제공되지 않으므로 자녀가 배가 고프지 않도록 아침을 먹이고 등교시키세요." + }, + { + "type": "reminder", + "title": "취업 부모 신청 시 필요 서류 안내", + "evidenceText": "취업 부모: 재직증명서(사업장 업체전화번호, 근로시간 기입) 또는 근무시간확인서 / 고용보험피보험자격내역서 / 직장건강보험 자격득실확인서 / 부, 모 각각 제출(총 4부)", + "selectedDateCandidateId": "dc_027_1", + "dateStatus": "confirmed", + "date": "2025-12-05", + "target": "parent", + "actionRequired": true, + "schoolContext": "맞벌이 가정으로 신청하려면 부모 모두의 재직증명서와 고용 관련 서류를 각각 제출해야 합니다(총 4부). 재직증명서는 직장에서 발급받을 수 있으며, 고용보험 확인서는 고용지원센터나 온라인에서 발급 가능합니다. 자영업자는 사업자등록증과 소득증빙 서류를 대신 제출합니다." + }, + { + "type": "reminder", + "title": "방학 중 돌봄교실 급·간식 미제공 안내", + "evidenceText": "운영 시간: 09:00 ~ 11:50 / 급·간식 미제공", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "학기 중 급식과 달리 방학 중 돌봄교실에서는 점심 급식과 간식을 제공하지 않습니다. 운영 시간이 오전 중에만 이루어지므로 귀가 전 식사가 필요한 경우 도시락을 따로 챙겨주거나, 귀가 후 바로 식사할 수 있도록 준비해 두세요." + } + ] +} \ No newline at end of file diff --git a/data/newsletter-labels/newsletter-030.json b/data/newsletter-labels/newsletter-030.json new file mode 100644 index 0000000..9f0070a --- /dev/null +++ b/data/newsletter-labels/newsletter-030.json @@ -0,0 +1,110 @@ +{ + "documentId": "doc_030", + "documentTitle": "2025학년도 초등돌봄교실 재학생 모집 안내", + "documentDate": "2024-12-01", + "school": "서정초등학교", + "dateCandidates": [ + { + "id": "dc_030_1", + "raw": "2024년 12월 18일(수) ~ 12월 20일(금) 16:00까지", + "resolved": "2024-12-20", + "note": "돌봄교실 신청 마감일시" + }, + { + "id": "dc_030_2", + "raw": "2025년 3월 4일(화) ~ 2026년 2월 27일(금)", + "resolved": "2025-03-04", + "note": "돌봄교실 운영 기간 시작일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "초등돌봄교실 제도 안내", + "evidenceText": "2025학년도 돌봄이 필요한 2학년 학생을 대상으로 돌봄교실 학생을 모집하오니 입급을 희망하는 가정에서는 내용을 참고하시어 기한 내 신청하여 주시기 바랍니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "초등돌봄교실은 한국 정부가 맞벌이·저소득 가정 등 돌봄이 필요한 초등학생을 위해 학교 안에서 방과 후까지 아이를 돌봐주는 공적 서비스입니다. 별도 사교육 기관이 아닌 학교 내에서 운영되며, 간식 제공과 다양한 특별 프로그램도 포함됩니다. 자리가 제한되어 있어 선발 기준에 따라 신청해야 합니다." + }, + { + "type": "reminder", + "title": "돌봄교실 신청 대상 및 운영 시간 안내", + "evidenceText": "신청 대상: 맞벌이·사회적 배려 대상자 가정 자녀 중 1~2학년 희망자 / 운영 시간 - 학기 중: 방과 후~17:00 (17:00~19:00 통합 운영) / 방학 중: 9:00~16:00 (돌봄교실 참여 학생 중 신청자)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "돌봄교실은 정규 수업이 끝난 후 오후 5시까지 운영됩니다. 추가로 오후 7시까지 연장반도 운영됩니다. 방학 중에는 오전 9시부터 오후 4시까지 별도로 신청한 학생에 한해 운영됩니다. 학기 중과 방학 중 운영 시간이 다르므로 주의하세요." + }, + { + "type": "reminder", + "title": "돌봄교실 선발 우선순위 기준 안내", + "evidenceText": "1순위: 맞벌이가정 중 기초생활 수급자 / 2순위: 맞벌이가정 중 차상위, 한부모, 조손가정 / 3순위: 일반 맞벌이가정 / 증빙서류를 제출한 학생 중 1순위, 2순위는 우선선발이며, 정원 미달 시에는 3순위에서 선발하고, 동 순위자에서 정원 초과 시 저소득 순으로 입급 확정(의료보험 납입액 순)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "정원이 제한되어 있어 저소득 가정, 한부모가정 등이 우선 선발됩니다. 일반 맞벌이 가정도 신청할 수 있지만, 1·2순위 신청자가 많으면 탈락할 수 있습니다. 같은 순위에서 정원을 초과하면 건강보험료 납입액이 낮은 가정(소득이 낮은 가정)부터 선발합니다." + }, + { + "type": "deadline", + "title": "돌봄교실 신청서 및 증빙서류 제출", + "evidenceText": "신청 기간: 2024년 12월 18일(수) ~ 12월 20일(금) 16:00까지 / 신청 방법: 인편 제출(1학년 돌봄교실) / 구비 서류: 돌봄 신청서 및 증빙서류(부, 모 재직 증명서)", + "selectedDateCandidateId": "dc_030_1", + "dateStatus": "confirmed", + "date": "2024-12-20", + "target": "parent", + "actionRequired": true, + "schoolContext": "신청서는 학교 돌봄교실에 직접 방문해서 제출해야 합니다(인편 제출). 온라인 제출이 아닙니다. 부모 모두의 재직증명서를 각각 준비해야 하며, 재직증명서는 근무 중인 직장에서 발급받을 수 있습니다. 신청 기간이 3일로 매우 짧으니 놓치지 않도록 주의하세요." + }, + { + "type": "reminder", + "title": "돌봄교실 비용 안내 (간식비·저소득층 무상 지원)", + "evidenceText": "수익자 부담(저소득층 무상 지원) / 학기 중 간식비: 약 2,000원 예정 × 간식일 수(1개월당 징수) / 방학 중 급식비: 약 7,000원 예정 × 급식일 수(여름, 겨울방학 전 신청받아 징수)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "돌봄교실 자체는 무료이지만, 간식비는 별도로 납부해야 합니다. 학기 중에는 월 단위로 간식비(일당 약 2,000원 × 일수)가 자동 청구됩니다. 방학 중 급식비는 방학 전에 별도 신청받아 징수합니다. 단, 기초생활수급자 등 저소득 가정은 이 비용도 무상으로 지원받습니다." + }, + { + "type": "reminder", + "title": "연장반(17:00~19:00) 운영 안내", + "evidenceText": "운영 시간 - 학기 중: 방과 후~17:00 (17:00~19:00 통합 운영) / 17:00~19:00 (연장반 운영): 개별활동, 과제 해결, 자기주도적 활동(독서, 그림그리기, 만들기) 정리 및 귀가 준비", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "오후 5시 이후에도 자녀를 맡길 수 있는 연장반이 오후 7시까지 운영됩니다. 연장반은 돌봄교실 이용자 중 추가 신청자에 한해 이용할 수 있습니다. 연장반에서는 자유 활동 위주로 운영되며, 귀가는 오후 7시까지 보호자가 직접 데리러 와야 합니다." + }, + { + "type": "reminder", + "title": "방학 중 돌봄교실 별도 신청 필요", + "evidenceText": "운영 기간 / ※미운영 기간: 주말, 법정 공휴일, 대체휴일, 하계, 동계 방학 중 3일(신학기 준비기간 포함) / 방학 중: 9:00~16:00 (돌봄교실 참여 학생 중 신청자)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "학기 중 돌봄교실에 등록되어 있어도 여름방학·겨울방학 중 돌봄교실 이용은 별도로 신청해야 합니다. 방학 중에는 신학기 준비기간 3일 동안은 운영되지 않습니다. 방학이 시작되기 전에 방학 중 돌봄교실 신청 공지가 별도로 발송되므로 놓치지 않도록 주의하세요." + }, + { + "type": "reminder", + "title": "신청서 가구 유형 선택 안내", + "evidenceText": "가구 유형(√): 교육비 지원 대상(주민센터 등록자) - 기초수급자, 법정차상위, 법정한부모, 조손가정, 한부모 / 교육비 비지원 대상 - 일반 맞벌이", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "신청서에서 가구 유형을 정확히 선택해야 우선순위 혜택을 받을 수 있습니다. 다문화가정은 별도 항목이 없지만, 소득 기준에 해당하면 기초수급자·차상위·한부모 등 해당 유형을 선택하면 됩니다. 주민센터에서 발급받은 증빙서류가 있어야 해당 유형으로 인정받을 수 있습니다." + } + ] +} \ No newline at end of file diff --git a/docs/env.md b/docs/env.md index 06e2b19..cd5b954 100644 --- a/docs/env.md +++ b/docs/env.md @@ -4,7 +4,7 @@ - `OPENAI_ENABLED`: OpenAI 실제 호출 활성화 여부. 기본값은 `false` - `OPENAI_API_KEY`: OpenAI API key. `OPENAI_ENABLED=true`일 때 필요 -- `OPENAI_MODEL`: 사용할 모델. 기본값은 `gpt-4o-mini` +- `OPENAI_MODEL`: 사용할 모델. 기본값은 `gpt-4.1-mini` - `OPENAI_BASE_URL`: OpenAI API base URL. 기본값은 `https://api.openai.com/v1` - `OPENAI_TIMEOUT_SECONDS`: OpenAI 호출 timeout 초. 기본값은 `60` diff --git a/docs/newsletter-evaluation.md b/docs/newsletter-evaluation.md new file mode 100644 index 0000000..562bb20 --- /dev/null +++ b/docs/newsletter-evaluation.md @@ -0,0 +1,133 @@ +# 가정통신문 분석 평가 스크립트 + +라벨링된 가정통신문 JSON을 기준 정답으로 사용해 `/ai/newsletters/analyze` 품질을 반복 측정한다. + +## 목적 + +- rule-based baseline과 OpenAI adapter 결과를 같은 기준으로 비교한다. +- 프롬프트, 모델, 파서 수정 후 품질 회귀 여부를 확인한다. +- BE 저장 연동 전에 `title`, `summary`, `items` 응답 품질을 수치와 mismatch 리포트로 확인한다. + +## 실행 방법 + +기본 실행은 비용이 발생하지 않는 baseline 모드다. + +```powershell +python scripts/evaluate_newsletter_labels.py data/newsletter-labels +``` + +상세 리포트를 JSON으로 저장하려면 다음처럼 실행한다. + +```powershell +python scripts/evaluate_newsletter_labels.py data/newsletter-labels --report-output reports/newsletter-eval-baseline.json +``` + +OpenAI 호출은 명시적으로 `--mode openai`를 줄 때만 실행한다. + +```powershell +$env:OPENAI_API_KEY="..." +python scripts/evaluate_newsletter_labels.py data/newsletter-labels --mode openai --report-output reports/newsletter-eval-openai.json +``` + +CI나 로컬 기준선 확인에서 최소 F1을 강제하려면 `--fail-under-f1`을 사용할 수 있다. + +```powershell +python scripts/evaluate_newsletter_labels.py data/newsletter-labels --fail-under-f1 0.75 +``` + +## 라벨 JSON 형식 + +스크립트는 JSON 파일 하나, JSON 배열 파일, 또는 디렉터리 내 `*.json` 파일들을 입력으로 받는다. + +현재 라벨링 데이터는 다음 형식을 사용한다. + +```json +{ + "documentId": "doc_001", + "documentTitle": "2026학년도 1학기 학습준비물 안내", + "documentDate": "2026-03-24", + "school": "서울세륜초등학교", + "dateCandidates": [ + { + "id": "dc_001_1", + "raw": "2026. 5. 4.(월)", + "resolved": "2026-05-04", + "note": "행사 날짜" + } + ], + "labels": [ + { + "type": "checklist", + "title": "준비물 준비", + "evidenceText": "가정에서 직접 구매가 필요한 학습준비물", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": null + } + ] +} +``` + +`originalText`가 없는 라벨은 `documentTitle`, `school`, `documentDate`, `labels[].evidenceText`를 이어 붙여 분석 입력으로 사용한다. 이 방식은 라벨셋 기반 회귀 확인용이며, OCR 결과 품질까지 포함해 평가하려면 JSON에 `originalText`를 추가해야 한다. + +`input/expected`를 명시하는 확장 형식도 사용할 수 있다. + +```json +{ + "sampleId": "newsletter-001", + "input": { + "originalText": "가정통신문 원문", + "translatedText": null, + "language": "KO", + "referenceDate": "2026-05-24", + "timezone": "Asia/Seoul", + "dateCandidates": [ + { + "candidateId": "dc_1", + "originalText": "5월 25일", + "normalizedDate": "2026-05-25", + "startOffset": 10, + "endOffset": 16, + "extractionType": "REGEX" + } + ] + }, + "expected": { + "title": "가정통신문 제목", + "summary": "가정통신문 요약", + "items": [ + { + "type": "deadline", + "title": "동의서 제출", + "evidenceText": "5월 25일까지 동의서를 제출해 주세요.", + "selectedDateCandidateId": "dc_1", + "dateStatus": "confirmed", + "date": "2026-05-25" + } + ] + } +} +``` + +## 평가 지표 + +- `item_precision`: 예측 항목 중 정답과 매칭된 비율 +- `item_recall`: 정답 항목 중 예측과 매칭된 비율 +- `item_f1`: precision과 recall의 조화 평균 +- `title_accuracy`, `summary_accuracy`: 정규화된 문자열 완전 일치율 +- `type_accuracy`: 매칭된 항목의 `type` 일치율 +- `datetime_accuracy`: 매칭된 항목의 날짜 일치율 +- `date_status_accuracy`: 매칭된 항목의 `dateStatus` 일치율 + +항목 매칭은 `type`, `dateStatus`, 날짜, 제목 유사도를 함께 사용한다. 제목 표현이 조금 달라도 같은 날짜와 분류가 맞으면 비교 대상으로 잡기 위한 기준이다. + +## 데이터 관리 + +- 평가 데이터는 API 명세가 아니라 품질 검증용 정답셋이다. +- JSON 라벨 파일은 평가 재현을 위해 repo에 포함한다. +- PDF/JPG/PNG 원본은 Notion과 로컬 검수용으로 유지하고 repo에는 포함하지 않는다. +- 원본과 JSON은 `newsletter-001.json` ↔ `newsletter-001.pdf`처럼 같은 번호로 매칭한다. +- 라벨 기준은 `docs/newsletter-labeling-guide.md`를 따른다. diff --git a/scripts/evaluate_newsletter_labels.py b/scripts/evaluate_newsletter_labels.py new file mode 100644 index 0000000..a663a9d --- /dev/null +++ b/scripts/evaluate_newsletter_labels.py @@ -0,0 +1,561 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import asdict, dataclass +from datetime import date +from difflib import SequenceMatcher +from pathlib import Path +from typing import Any + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.schemas import NewsletterAnalysisRequest, NewsletterAnalysisResponse # noqa: E402 +from app.services.newsletter_extractor import analyze_newsletter # noqa: E402 + + +@dataclass(frozen=True) +class LabelItem: + type: str + title: str + date_status: str + datetime: str | None + evidence_text: str | None + selected_date_candidate_id: str | None + + +@dataclass(frozen=True) +class LabelSample: + sample_id: str + source_file: str | None + request: NewsletterAnalysisRequest + expected_title: str | None + expected_summary: str | None + expected_items: list[LabelItem] + + +@dataclass(frozen=True) +class ItemMatch: + expected_index: int + predicted_index: int + score: float + type_match: bool + title_similarity: float + date_status_match: bool + datetime_match: bool + + +@dataclass(frozen=True) +class SampleReport: + sample_id: str + title_match: bool | None + title_similarity: float | None + summary_match: bool | None + summary_similarity: float | None + expected_item_count: int + predicted_item_count: int + matched_item_count: int + item_precision: float + item_recall: float + item_f1: float + type_accuracy: float | None + datetime_accuracy: float | None + date_status_accuracy: float | None + title_similarity_avg: float | None + missing_expected_items: list[dict[str, Any]] + extra_predicted_items: list[dict[str, Any]] + matches: list[dict[str, Any]] + + +@dataclass(frozen=True) +class EvaluationReport: + mode: str + sample_count: int + item_precision: float + item_recall: float + item_f1: float + title_accuracy: float | None + summary_accuracy: float | None + type_accuracy: float | None + datetime_accuracy: float | None + date_status_accuracy: float | None + title_similarity_avg: float | None + summary_similarity_avg: float | None + samples: list[SampleReport] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="라벨링된 가정통신문 JSON으로 /analyze 품질을 평가합니다." + ) + parser.add_argument("dataset", help="라벨 JSON 파일 또는 JSON 파일 디렉터리") + parser.add_argument( + "--mode", + choices=("baseline", "openai"), + default="baseline", + help=( + "baseline은 비용 없이 rule-based 분석을 실행하고, " + "openai는 실제 OpenAI 호출을 허용합니다." + ), + ) + parser.add_argument("--report-output", help="상세 평가 리포트를 저장할 JSON 경로") + parser.add_argument( + "--fail-under-f1", + type=float, + default=None, + help="전체 item F1이 기준 미만이면 exit code 1을 반환합니다.", + ) + args = parser.parse_args() + + if args.mode == "baseline": + os.environ["OPENAI_ENABLED"] = "false" + else: + os.environ["OPENAI_ENABLED"] = "true" + + samples = load_samples(Path(args.dataset)) + report = evaluate_samples(samples, mode=args.mode) + + print_summary(report) + if args.report_output: + output_path = Path(args.report_output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(asdict(report), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(f"상세 리포트 저장: {output_path}") + + if args.fail_under_f1 is not None and report.item_f1 < args.fail_under_f1: + return 1 + return 0 + + +def load_samples(dataset_path: Path) -> list[LabelSample]: + if dataset_path.is_dir(): + raw_samples: list[dict[str, Any]] = [] + for path in sorted(dataset_path.glob("*.json")): + raw_samples.extend(_read_sample_file(path)) + else: + raw_samples = _read_sample_file(dataset_path) + + if not raw_samples: + raise ValueError("평가할 라벨 JSON이 없습니다.") + return [parse_sample(raw, index) for index, raw in enumerate(raw_samples, start=1)] + + +def _read_sample_file(path: Path) -> list[dict[str, Any]]: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, list): + return [_with_file_metadata(_ensure_mapping(item, path), path) for item in data] + if isinstance(data, dict) and isinstance(data.get("samples"), list): + return [_with_file_metadata(_ensure_mapping(item, path), path) for item in data["samples"]] + if isinstance(data, dict): + return [_with_file_metadata(data, path)] + raise ValueError(f"{path}는 JSON object, object array, samples array 중 하나여야 합니다.") + + +def _ensure_mapping(value: Any, path: Path) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"{path}의 sample은 JSON object여야 합니다.") + return value + + +def _with_file_metadata(value: dict[str, Any], path: Path) -> dict[str, Any]: + result = dict(value) + result.setdefault("_fileStem", path.stem) + result.setdefault("sourceFile", _find_partner_source_file(path)) + return result + + +def _find_partner_source_file(path: Path) -> str | None: + for suffix in (".pdf", ".jpg", ".jpeg", ".png"): + candidate = path.with_suffix(suffix) + if candidate.exists(): + return candidate.name + return None + + +def parse_sample(raw: dict[str, Any], index: int) -> LabelSample: + if "labels" in raw: + return parse_labeling_sample(raw, index) + + request_data = _first_mapping(raw, "input", "request") + if request_data is None: + request_data = { + key: raw[key] + for key in ( + "originalText", + "translatedText", + "language", + "referenceDate", + "timezone", + "dateCandidates", + ) + if key in raw + } + request = NewsletterAnalysisRequest.model_validate(request_data) + + expected_data = _first_mapping(raw, "expected", "label", "labels") or raw + expected_items_raw = expected_data.get("items", []) + if not isinstance(expected_items_raw, list): + raise ValueError("expected.items는 배열이어야 합니다.") + + return LabelSample( + sample_id=str( + raw.get("sampleId") + or raw.get("id") + or raw.get("_fileStem") + or f"sample_{index}" + ), + source_file=_optional_str(raw.get("sourceFile")), + request=request, + expected_title=_optional_str(expected_data.get("title")), + expected_summary=_optional_str(expected_data.get("summary")), + expected_items=[parse_label_item(item) for item in expected_items_raw], + ) + + +def parse_labeling_sample(raw: dict[str, Any], index: int) -> LabelSample: + original_text = _optional_str(raw.get("originalText") or raw.get("ocrText") or raw.get("text")) + if original_text is None: + original_text = _compose_text_from_labels(raw) + + request = NewsletterAnalysisRequest.model_validate( + { + "originalText": original_text, + "translatedText": raw.get("translatedText"), + "language": raw.get("language") or "KO", + "referenceDate": raw.get("documentDate"), + "timezone": raw.get("timezone") or "Asia/Seoul", + "dateCandidates": [ + normalize_date_candidate(candidate, original_text, index) + for index, candidate in enumerate(raw.get("dateCandidates") or [], start=1) + ], + } + ) + + labels = raw.get("labels") + if not isinstance(labels, list): + raise ValueError("labels는 배열이어야 합니다.") + + return LabelSample( + sample_id=str( + raw.get("sampleId") + or raw.get("_fileStem") + or raw.get("documentId") + or f"sample_{index}" + ), + source_file=_optional_str(raw.get("sourceFile")), + request=request, + expected_title=_optional_str(raw.get("documentTitle")), + expected_summary=_optional_str(raw.get("summary")), + expected_items=[parse_label_item(item) for item in labels], + ) + + +def _compose_text_from_labels(raw: dict[str, Any]) -> str: + parts = [ + _optional_str(raw.get("documentTitle")), + _optional_str(raw.get("school")), + _optional_str(raw.get("documentDate")), + ] + for item in raw.get("labels") or []: + if isinstance(item, dict): + parts.append(_optional_str(item.get("evidenceText"))) + + # OCR 원문이 없는 라벨셋도 평가 스크립트에 태우기 위한 fallback입니다. + # 실제 OCR 품질까지 보려면 originalText가 포함된 JSON으로 확장해야 합니다. + return "\n".join(dict.fromkeys(part for part in parts if part)) + + +def normalize_date_candidate( + raw: dict[str, Any], + source_text: str, + index: int, +) -> dict[str, Any]: + original_text = str(raw.get("originalText") or raw.get("raw") or raw.get("text") or "") + normalized_date = _date_part( + raw.get("normalizedDate") or raw.get("resolved") or raw.get("date") or date.today() + ) + start_offset = raw.get("startOffset") + end_offset = raw.get("endOffset") + if start_offset is None or end_offset is None: + found_at = source_text.find(original_text) if original_text else -1 + start_offset = max(found_at, 0) + end_offset = start_offset + len(original_text) + + return { + "candidateId": raw.get("candidateId") or raw.get("id") or f"dc_{index}", + "originalText": original_text or str(normalized_date), + "normalizedDate": normalized_date, + "startOffset": start_offset, + "endOffset": end_offset, + "extractionType": raw.get("extractionType") or raw.get("type") or "LABEL", + } + + +def parse_label_item(raw: Any) -> LabelItem: + if not isinstance(raw, dict): + raise ValueError("items 항목은 JSON object여야 합니다.") + + selected = raw.get("selectedDateCandidate") + selected_id = None + if isinstance(selected, dict): + selected_id = _optional_str(selected.get("candidateId")) + + return LabelItem( + type=str(raw.get("type") or ""), + title=str(raw.get("title") or ""), + date_status=str(raw.get("dateStatus") or raw.get("date_status") or ""), + datetime=_optional_str(raw.get("datetime") or raw.get("date")), + evidence_text=_optional_str(raw.get("evidenceText") or raw.get("evidence_text")), + selected_date_candidate_id=_optional_str(raw.get("selectedDateCandidateId")) or selected_id, + ) + + +def evaluate_samples(samples: list[LabelSample], *, mode: str) -> EvaluationReport: + reports = [] + for sample in samples: + predicted = analyze_newsletter(sample.request) + reports.append(evaluate_sample(sample, predicted)) + + return EvaluationReport( + mode=mode, + sample_count=len(reports), + item_precision=_avg([report.item_precision for report in reports]), + item_recall=_avg([report.item_recall for report in reports]), + item_f1=_avg([report.item_f1 for report in reports]), + title_accuracy=_ratio([report.title_match for report in reports]), + summary_accuracy=_ratio([report.summary_match for report in reports]), + type_accuracy=_avg_optional([report.type_accuracy for report in reports]), + datetime_accuracy=_avg_optional([report.datetime_accuracy for report in reports]), + date_status_accuracy=_avg_optional([report.date_status_accuracy for report in reports]), + title_similarity_avg=_avg_optional([report.title_similarity for report in reports]), + summary_similarity_avg=_avg_optional([report.summary_similarity for report in reports]), + samples=reports, + ) + + +def evaluate_sample(sample: LabelSample, predicted: NewsletterAnalysisResponse) -> SampleReport: + predicted_items = [prediction_to_label_item(item) for item in predicted.items] + matches = match_items(sample.expected_items, predicted_items) + matched_expected = {match.expected_index for match in matches} + matched_predicted = {match.predicted_index for match in matches} + + precision = ( + len(matches) / len(predicted_items) + if predicted_items + else float(not sample.expected_items) + ) + recall = len(matches) / len(sample.expected_items) if sample.expected_items else 1.0 + f1 = _f1(precision, recall) + + return SampleReport( + sample_id=sample.sample_id, + title_match=_match_optional_text(sample.expected_title, predicted.title), + title_similarity=_similarity_optional(sample.expected_title, predicted.title), + summary_match=_match_optional_text(sample.expected_summary, predicted.summary), + summary_similarity=_similarity_optional(sample.expected_summary, predicted.summary), + expected_item_count=len(sample.expected_items), + predicted_item_count=len(predicted_items), + matched_item_count=len(matches), + item_precision=precision, + item_recall=recall, + item_f1=f1, + type_accuracy=_match_accuracy(matches, "type_match"), + datetime_accuracy=_match_accuracy(matches, "datetime_match"), + date_status_accuracy=_match_accuracy(matches, "date_status_match"), + title_similarity_avg=_avg_optional([match.title_similarity for match in matches]), + missing_expected_items=[ + asdict(item) + for index, item in enumerate(sample.expected_items) + if index not in matched_expected + ], + extra_predicted_items=[ + asdict(item) + for index, item in enumerate(predicted_items) + if index not in matched_predicted + ], + matches=[asdict(match) for match in matches], + ) + + +def prediction_to_label_item(item: Any) -> LabelItem: + selected_id = None + if item.selected_date_candidate is not None: + selected_id = item.selected_date_candidate.candidate_id + return LabelItem( + type=str(item.type.value), + title=item.title, + date_status=str(item.date_status.value), + datetime=item.datetime, + evidence_text=item.evidence_text, + selected_date_candidate_id=selected_id, + ) + + +def match_items( + expected_items: list[LabelItem], + predicted_items: list[LabelItem], +) -> list[ItemMatch]: + candidates = [] + for expected_index, expected in enumerate(expected_items): + for predicted_index, predicted in enumerate(predicted_items): + score = score_item(expected, predicted) + if score >= 0.55: + candidates.append((score, expected_index, predicted_index)) + + matches = [] + used_expected = set() + used_predicted = set() + for score, expected_index, predicted_index in sorted(candidates, reverse=True): + if expected_index in used_expected or predicted_index in used_predicted: + continue + expected = expected_items[expected_index] + predicted = predicted_items[predicted_index] + matches.append( + ItemMatch( + expected_index=expected_index, + predicted_index=predicted_index, + score=round(score, 4), + type_match=expected.type == predicted.type, + title_similarity=round(_similarity(expected.title, predicted.title), 4), + date_status_match=expected.date_status == predicted.date_status, + datetime_match=_normalize_date(expected.datetime) + == _normalize_date(predicted.datetime), + ) + ) + used_expected.add(expected_index) + used_predicted.add(predicted_index) + return sorted(matches, key=lambda match: match.expected_index) + + +def score_item(expected: LabelItem, predicted: LabelItem) -> float: + score = 0.0 + if expected.type == predicted.type: + score += 0.35 + if expected.date_status == predicted.date_status: + score += 0.15 + if _normalize_date(expected.datetime) == _normalize_date(predicted.datetime): + score += 0.25 + score += _similarity(expected.title, predicted.title) * 0.25 + return score + + +def print_summary(report: EvaluationReport) -> None: + print(f"mode: {report.mode}") + print(f"samples: {report.sample_count}") + print( + "items: " + f"precision={report.item_precision:.3f}, " + f"recall={report.item_recall:.3f}, " + f"f1={report.item_f1:.3f}" + ) + print(f"title_accuracy: {_format_optional(report.title_accuracy)}") + print(f"summary_accuracy: {_format_optional(report.summary_accuracy)}") + print(f"type_accuracy: {_format_optional(report.type_accuracy)}") + print(f"datetime_accuracy: {_format_optional(report.datetime_accuracy)}") + print(f"date_status_accuracy: {_format_optional(report.date_status_accuracy)}") + + weak_samples = [sample for sample in report.samples if sample.item_f1 < 1.0] + if weak_samples: + print("mismatch samples:") + for sample in weak_samples[:10]: + print( + f"- {sample.sample_id}: " + f"expected={sample.expected_item_count}, " + f"predicted={sample.predicted_item_count}, " + f"matched={sample.matched_item_count}, " + f"f1={sample.item_f1:.3f}" + ) + + +def _first_mapping(raw: dict[str, Any], *keys: str) -> dict[str, Any] | None: + for key in keys: + value = raw.get(key) + if isinstance(value, dict): + return value + return None + + +def _optional_str(value: Any) -> str | None: + if value is None: + return None + text = str(value) + return text if text != "" else None + + +def _date_part(value: Any) -> str: + return str(value)[:10] + + +def _normalize_text(value: str | None) -> str: + if value is None: + return "" + text = re.sub(r"\s+", " ", value).strip().casefold() + return re.sub(r"[^\w가-힣]+", "", text) + + +def _normalize_date(value: str | None) -> str | None: + if value is None or value == "": + return None + return value[:10] + + +def _similarity(left: str | None, right: str | None) -> float: + return SequenceMatcher(None, _normalize_text(left), _normalize_text(right)).ratio() + + +def _match_optional_text(expected: str | None, predicted: str) -> bool | None: + if expected is None: + return None + return _normalize_text(expected) == _normalize_text(predicted) + + +def _similarity_optional(expected: str | None, predicted: str) -> float | None: + if expected is None: + return None + return _similarity(expected, predicted) + + +def _match_accuracy(matches: list[ItemMatch], field_name: str) -> float | None: + if not matches: + return None + return sum(1 for match in matches if getattr(match, field_name)) / len(matches) + + +def _ratio(values: list[bool | None]) -> float | None: + scoped = [value for value in values if value is not None] + if not scoped: + return None + return sum(1 for value in scoped if value) / len(scoped) + + +def _avg(values: list[float]) -> float: + return sum(values) / len(values) if values else 0.0 + + +def _avg_optional(values: list[float | None]) -> float | None: + scoped = [value for value in values if value is not None] + if not scoped: + return None + return sum(scoped) / len(scoped) + + +def _f1(precision: float, recall: float) -> float: + if precision + recall == 0: + return 0.0 + return 2 * precision * recall / (precision + recall) + + +def _format_optional(value: float | None) -> str: + return "n/a" if value is None else f"{value:.3f}" + + +if __name__ == "__main__": + raise SystemExit(main()) From 01ba340823a1c1032b8400eb8fdb24701b378bb3 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 25 May 2026 13:05:47 +0900 Subject: [PATCH 22/23] =?UTF-8?q?fix:=20=EA=B0=80=EC=A0=95=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EB=AC=B8=20=EB=9D=BC=EB=B2=A8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=98=95=EC=8B=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/newsletter-labels/README.md | 23 ++- data/newsletter-labels/newsletter-005.json | 190 ++++++++++----------- data/newsletter-labels/newsletter-014.json | 130 +++++++------- data/newsletter-labels/newsletter-025.json | 108 ++++++------ data/newsletter-labels/newsletter-027.json | 162 +++++++++--------- data/newsletter-labels/newsletter-029.json | 174 +++++++++---------- scripts/evaluate_newsletter_labels.py | 9 +- 7 files changed, 404 insertions(+), 392 deletions(-) diff --git a/data/newsletter-labels/README.md b/data/newsletter-labels/README.md index d736a74..c2f1626 100644 --- a/data/newsletter-labels/README.md +++ b/data/newsletter-labels/README.md @@ -25,8 +25,27 @@ "documentTitle": "문서 제목", "documentDate": "2026-03-24", "school": "학교명", - "dateCandidates": [], - "labels": [] + "dateCandidates": [ + { + "id": "dc_001_1", + "raw": "5월 20일", + "resolved": "2026-05-20", + "note": "제출 마감일" + } + ], + "labels": [ + { + "type": "schedule | deadline | checklist | reminder", + "title": "항목 제목", + "evidenceText": "원문 근거 텍스트", + "selectedDateCandidateId": "dc_001_1 또는 null", + "dateStatus": "confirmed | ambiguous | missing", + "date": "YYYY-MM-DD 또는 null", + "target": "parent | student | both", + "actionRequired": true, + "schoolContext": "학교 문화 맥락 설명 또는 null" + } + ] } ``` diff --git a/data/newsletter-labels/newsletter-005.json b/data/newsletter-labels/newsletter-005.json index 32f9d79..314e23e 100644 --- a/data/newsletter-labels/newsletter-005.json +++ b/data/newsletter-labels/newsletter-005.json @@ -1,98 +1,96 @@ -[ +{ + "documentId": "doc_005", + "documentTitle": "2026학년도 교외체험학습 안내", + "documentDate": "2026-03-03", + "school": "서울세륜초등학교", + "dateCandidates": [ { - "documentId": "doc_005", - "documentTitle": "2026학년도 교외체험학습 안내", - "documentDate": "2026-03-03", - "school": "서울세륜초등학교", - "dateCandidates": [ - { - "id": "dc_001_1", - "raw": "3일 전까지", - "note": "체험학습 실시일 기준 상대적 날짜 — 절대 날짜 특정 불가" - }, - { - "id": "dc_001_2", - "raw": "7일 이내", - "note": "체험학습 종료일 기준 상대적 날짜 — 절대 날짜 특정 불가" - }, - { - "id": "dc_001_3", - "raw": "연속 10일 이내", - "note": "허용 기간 범위 표현 — 특정 날짜 아님" - }, - { - "id": "dc_001_4", - "raw": "19일 이내", - "note": "연간 총 허용 일수 — 특정 날짜 아님" - } - ], - "labels": [ - { - "type": "reminder", - "title": "교외체험학습 제도 안내", - "evidenceText": "개인 계획에 의하여 학교장의 사전 허가를 받은 후 실시한 체험학습으로 관찰, 조사, 수집, 현장 견학, 답사, 문화체험, 직업체험 등의 직접적인 경험, 활동, 실천이 중심이 되어 교육적 효과를 나타내는 폭넓은 학습을 의미합니다.", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "한국에서는 학부모가 사전에 신청하고 학교장이 허가하면, 가족 여행이나 체험 활동을 수업일에 해도 출석으로 인정받을 수 있습니다. 단, 정해진 절차(신청서 제출 → 허가 → 실시 → 보고서 제출)를 모두 지켜야 합니다." - }, - { - "type": "deadline", - "title": "교외체험학습 신청서 제출", - "evidenceText": "체험학습 신청서 제출(3일 전까지)", - "selectedDateCandidateId": "dc_001_1", - "dateStatus": "ambiguous", - "date": null, - "target": "parent", - "actionRequired": true, - "schoolContext": "교외체험학습을 가기 전에 반드시 신청서를 학교에 제출하고 허가를 받아야 합니다. 허가 없이 결석하면 미인정 결석으로 처리됩니다. 서식은 학교 홈페이지 [Quick menu]-[각종서식]에서 다운로드할 수 있습니다." - }, - { - "type": "deadline", - "title": "교외체험학습 보고서 제출", - "evidenceText": "보고서 제출(7일 이내)", - "selectedDateCandidateId": "dc_001_2", - "dateStatus": "ambiguous", - "date": null, - "target": "parent", - "actionRequired": true, - "schoolContext": "체험학습을 마친 후 7일 이내에 결과 보고서도 제출해야 합니다. 신청서와 보고서 두 가지 모두 제출해야 출석으로 인정됩니다. 보고서 서식도 학교 홈페이지에서 다운로드할 수 있습니다." - }, - { - "type": "reminder", - "title": "연간 허용 일수 초과 시 미인정 결석 처리", - "evidenceText": "19일 초과 시 미인정 결석으로 처리됨", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "미인정 결석은 학교가 출석으로 인정하지 않는 결석입니다. 한국에서는 미인정 결석이 누적되면 학교생활기록부(학생의 공식 이력 문서)에 기록되어 이후 진학에 영향을 줄 수 있습니다." - }, - { - "type": "reminder", - "title": "허용 일수 계산 기준 안내", - "evidenceText": "연속 10일 이내(학교자율휴업일, 토요일, 공휴일 제외)", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "교외체험학습 허용 일수를 계산할 때 주말, 공휴일, 학교가 자체적으로 지정한 휴업일은 날수에 포함되지 않습니다. 실제 수업일 기준으로만 계산합니다." - }, - { - "type": "reminder", - "title": "연속 5일 초과 시 담임 교사 연락 필요", - "evidenceText": "연속 5일을 초과하여 실시(재량휴업일, 토요일, 공휴일 제외)하는 경우, 학생 안전 및 건강 상태에 대해 학교에 알리기 위해 기간 중 1회 이상 학생이 담임교사에게 연락해야 합니다.", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "both", - "actionRequired": true, - "schoolContext": "한국 학교는 장기 체험학습 중에도 학생의 안전을 확인할 의무가 있습니다. 5일을 넘는 경우 학생이 직접 담임 선생님께 연락해 안전하다는 것을 알려야 합니다." - } - ] + "id": "dc_001_1", + "raw": "3일 전까지", + "note": "체험학습 실시일 기준 상대적 날짜 — 절대 날짜 특정 불가" + }, + { + "id": "dc_001_2", + "raw": "7일 이내", + "note": "체험학습 종료일 기준 상대적 날짜 — 절대 날짜 특정 불가" + }, + { + "id": "dc_001_3", + "raw": "연속 10일 이내", + "note": "허용 기간 범위 표현 — 특정 날짜 아님" + }, + { + "id": "dc_001_4", + "raw": "19일 이내", + "note": "연간 총 허용 일수 — 특정 날짜 아님" + } + ], + "labels": [ + { + "type": "reminder", + "title": "교외체험학습 제도 안내", + "evidenceText": "개인 계획에 의하여 학교장의 사전 허가를 받은 후 실시한 체험학습으로 관찰, 조사, 수집, 현장 견학, 답사, 문화체험, 직업체험 등의 직접적인 경험, 활동, 실천이 중심이 되어 교육적 효과를 나타내는 폭넓은 학습을 의미합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "한국에서는 학부모가 사전에 신청하고 학교장이 허가하면, 가족 여행이나 체험 활동을 수업일에 해도 출석으로 인정받을 수 있습니다. 단, 정해진 절차(신청서 제출 → 허가 → 실시 → 보고서 제출)를 모두 지켜야 합니다." + }, + { + "type": "deadline", + "title": "교외체험학습 신청서 제출", + "evidenceText": "체험학습 신청서 제출(3일 전까지)", + "selectedDateCandidateId": "dc_001_1", + "dateStatus": "ambiguous", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "교외체험학습을 가기 전에 반드시 신청서를 학교에 제출하고 허가를 받아야 합니다. 허가 없이 결석하면 미인정 결석으로 처리됩니다. 서식은 학교 홈페이지 [Quick menu]-[각종서식]에서 다운로드할 수 있습니다." + }, + { + "type": "deadline", + "title": "교외체험학습 보고서 제출", + "evidenceText": "보고서 제출(7일 이내)", + "selectedDateCandidateId": "dc_001_2", + "dateStatus": "ambiguous", + "date": null, + "target": "parent", + "actionRequired": true, + "schoolContext": "체험학습을 마친 후 7일 이내에 결과 보고서도 제출해야 합니다. 신청서와 보고서 두 가지 모두 제출해야 출석으로 인정됩니다. 보고서 서식도 학교 홈페이지에서 다운로드할 수 있습니다." + }, + { + "type": "reminder", + "title": "연간 허용 일수 초과 시 미인정 결석 처리", + "evidenceText": "19일 초과 시 미인정 결석으로 처리됨", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "미인정 결석은 학교가 출석으로 인정하지 않는 결석입니다. 한국에서는 미인정 결석이 누적되면 학교생활기록부(학생의 공식 이력 문서)에 기록되어 이후 진학에 영향을 줄 수 있습니다." + }, + { + "type": "reminder", + "title": "허용 일수 계산 기준 안내", + "evidenceText": "연속 10일 이내(학교자율휴업일, 토요일, 공휴일 제외)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "교외체험학습 허용 일수를 계산할 때 주말, 공휴일, 학교가 자체적으로 지정한 휴업일은 날수에 포함되지 않습니다. 실제 수업일 기준으로만 계산합니다." + }, + { + "type": "reminder", + "title": "연속 5일 초과 시 담임 교사 연락 필요", + "evidenceText": "연속 5일을 초과하여 실시(재량휴업일, 토요일, 공휴일 제외)하는 경우, 학생 안전 및 건강 상태에 대해 학교에 알리기 위해 기간 중 1회 이상 학생이 담임교사에게 연락해야 합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "both", + "actionRequired": true, + "schoolContext": "한국 학교는 장기 체험학습 중에도 학생의 안전을 확인할 의무가 있습니다. 5일을 넘는 경우 학생이 직접 담임 선생님께 연락해 안전하다는 것을 알려야 합니다." } -] \ No newline at end of file + ] +} diff --git a/data/newsletter-labels/newsletter-014.json b/data/newsletter-labels/newsletter-014.json index a9c04e0..a0b91fe 100644 --- a/data/newsletter-labels/newsletter-014.json +++ b/data/newsletter-labels/newsletter-014.json @@ -1,66 +1,66 @@ { - "documentId": "doc_0014", - "documentTitle": "학교자율휴업일 안내", - "documentDate": "2026-04-22", - "school": "서울위례초등학교", - "dateCandidates": [ - { - "id": "dc_007_1", - "raw": "5월 4일(월)", - "resolved": "2026-05-04", - "note": "학교자율휴업일" - }, - { - "id": "dc_007_2", - "raw": "5월 6일(수)", - "resolved": "2026-05-06", - "note": "정상 등교일" - } - ], - "labels": [ - { - "type": "reminder", - "title": "학교자율휴업일 안내", - "evidenceText": "학교자율휴업일: 5월 4일(월)", - "selectedDateCandidateId": "dc_007_1", - "dateStatus": "confirmed", - "date": "2026-05-04", - "target": "parent", - "actionRequired": false, - "schoolContext": "학교자율휴업일은 학교가 자체적으로 지정한 휴일로, 공휴일은 아니지만 학생이 등교하지 않습니다. 이 날은 맞춤형교실, 방과후교실도 운영하지 않습니다. 한국 초등학교는 이런 자율휴업일을 학기 중 몇 차례 지정할 수 있습니다." - }, - { - "type": "reminder", - "title": "정상 등교일 확인", - "evidenceText": "정상 등교일: 5월 6일(수)", - "selectedDateCandidateId": "dc_007_2", - "dateStatus": "confirmed", - "date": "2026-05-06", - "target": "parent", - "actionRequired": false, - "schoolContext": null - }, - { - "type": "reminder", - "title": "맞춤형교실·방과후교실 미운영 안내", - "evidenceText": "맞춤형교실 및 방과후교실은 운영하지 않음", - "selectedDateCandidateId": "dc_007_1", - "dateStatus": "confirmed", - "date": "2026-05-04", - "target": "parent", - "actionRequired": false, - "schoolContext": "맞춤형교실은 저학년 학생을 위한 돌봄 프로그램이고, 방과후교실은 수업 후 운영되는 선택 활동 수업입니다. 자율휴업일에는 둘 다 운영되지 않으므로 자녀 돌봄 계획을 미리 세워두세요." - }, - { - "type": "reminder", - "title": "돌봄교실 필요 시 e알리미 회신 안내", - "evidenceText": "돌봄교실은 신청 학생 대상으로 통합반으로 운영 예정 / 해당일 돌봄이 필요한 학생은 e알리미 '학교 자율휴업일 돌봄운영 수요조사'에 회신", - "selectedDateCandidateId": "dc_007_1", - "dateStatus": "confirmed", - "date": "2026-05-04", - "target": "parent", - "actionRequired": true, - "schoolContext": "e알리미는 한국 학교에서 학부모 스마트폰으로 공지와 설문을 전송하는 앱 서비스입니다. 돌봄교실 이용을 원하면 앱을 통해 미리 신청해야 합니다. 신청하지 않으면 이용이 불가합니다." - } - ] -} \ No newline at end of file + "documentId": "doc_014", + "documentTitle": "학교자율휴업일 안내", + "documentDate": "2026-04-22", + "school": "서울위례초등학교", + "dateCandidates": [ + { + "id": "dc_007_1", + "raw": "5월 4일(월)", + "resolved": "2026-05-04", + "note": "학교자율휴업일" + }, + { + "id": "dc_007_2", + "raw": "5월 6일(수)", + "resolved": "2026-05-06", + "note": "정상 등교일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "학교자율휴업일 안내", + "evidenceText": "학교자율휴업일: 5월 4일(월)", + "selectedDateCandidateId": "dc_007_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "parent", + "actionRequired": false, + "schoolContext": "학교자율휴업일은 학교가 자체적으로 지정한 휴일로, 공휴일은 아니지만 학생이 등교하지 않습니다. 이 날은 맞춤형교실, 방과후교실도 운영하지 않습니다. 한국 초등학교는 이런 자율휴업일을 학기 중 몇 차례 지정할 수 있습니다." + }, + { + "type": "reminder", + "title": "정상 등교일 확인", + "evidenceText": "정상 등교일: 5월 6일(수)", + "selectedDateCandidateId": "dc_007_2", + "dateStatus": "confirmed", + "date": "2026-05-06", + "target": "parent", + "actionRequired": false, + "schoolContext": null + }, + { + "type": "reminder", + "title": "맞춤형교실·방과후교실 미운영 안내", + "evidenceText": "맞춤형교실 및 방과후교실은 운영하지 않음", + "selectedDateCandidateId": "dc_007_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "parent", + "actionRequired": false, + "schoolContext": "맞춤형교실은 저학년 학생을 위한 돌봄 프로그램이고, 방과후교실은 수업 후 운영되는 선택 활동 수업입니다. 자율휴업일에는 둘 다 운영되지 않으므로 자녀 돌봄 계획을 미리 세워두세요." + }, + { + "type": "reminder", + "title": "돌봄교실 필요 시 e알리미 회신 안내", + "evidenceText": "돌봄교실은 신청 학생 대상으로 통합반으로 운영 예정 / 해당일 돌봄이 필요한 학생은 e알리미 '학교 자율휴업일 돌봄운영 수요조사'에 회신", + "selectedDateCandidateId": "dc_007_1", + "dateStatus": "confirmed", + "date": "2026-05-04", + "target": "parent", + "actionRequired": true, + "schoolContext": "e알리미는 한국 학교에서 학부모 스마트폰으로 공지와 설문을 전송하는 앱 서비스입니다. 돌봄교실 이용을 원하면 앱을 통해 미리 신청해야 합니다. 신청하지 않으면 이용이 불가합니다." + } + ] +} diff --git a/data/newsletter-labels/newsletter-025.json b/data/newsletter-labels/newsletter-025.json index 7d74ad7..0ab721d 100644 --- a/data/newsletter-labels/newsletter-025.json +++ b/data/newsletter-labels/newsletter-025.json @@ -1,55 +1,55 @@ { - "documentId": "doc_025", - "documentTitle": "2026학년도 독서통장 운영 안내", - "documentDate": "2026-03-25", - "school": "시흥신일초등학교", - "dateCandidates": [ - { - "id": "dc_024_1", - "raw": "2026. 3. 30. ~ 7. 9.", - "resolved": "2026-07-09", - "note": "독서통장 기록 마감일" - }, - { - "id": "dc_024_2", - "raw": "독서통장 제출일: 7. 10.", - "resolved": "2026-07-10", - "note": "독서통장 담임 제출 마감일" - } - ], - "labels": [ - { - "type": "reminder", - "title": "독서통장 제도 안내", - "evidenceText": "2026학년도에는 학생들의 문해력과 독서능력 향상을 위해 전 학년을 대상으로 독서통장을 실시하고자 합니다.", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "독서통장은 학생이 읽은 책 목록과 간단한 감상을 기록하는 수첩입니다. 은행 통장처럼 책 한 권을 읽을 때마다 한 줄씩 기록하며, 학기말에 담임 선생님께 제출해 확인을 받습니다. 목표 권수를 달성하면 도서관에서 시상이 있습니다." - }, - { - "type": "checklist", - "title": "학년별 독서 목표 권수 달성 및 독서통장 제출", - "evidenceText": "성취기준(아래의 권수만큼 독서통장에 기록 후 담임선생님께 제출 및 확인): 2학년 20권 / 3학년 15권 / 4학년 15권 / 5학년 10권 / 6학년 10권 / 독서통장 제출일: 7. 10.", - "selectedDateCandidateId": "dc_024_2", - "dateStatus": "confirmed", - "date": "2026-07-10", - "target": "both", - "actionRequired": true, - "schoolContext": "가정, 도서관 어디서 읽은 책이든 모두 기록할 수 있습니다. 단, 만화책, 그림이 1/3 이상인 책(도감, 화보 등), 퀴즈·유머·종이접기 등의 책은 인정되지 않습니다. 1학년은 2학기부터 시작합니다." - }, - { - "type": "reminder", - "title": "인정되지 않는 도서 및 기록 안내", - "evidenceText": "인정하지 않는 도서의 종류: 사진·그림이 책의 1/3 이상인 책 제외(만화, 화보, 도감 등) / 퀴즈, 유머, 종이접기, 그리기, 만들기, 악보, 잡지 등의 책 제외 / 인정하지 않는 기록 사례: 불성실한 감상평(동일한 감상평) / 동일 도서로 2번 이상 기록", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "both", - "actionRequired": false, - "schoolContext": "독서통장에는 책 제목, 읽은 날짜, 간단한 감상을 성실하게 적어야 합니다. 같은 내용을 복사해서 쓰거나 성의 없는 감상평은 인정받지 못할 수 있습니다. 한 권의 책은 한 번만 기록할 수 있습니다." - } - ] -} \ No newline at end of file + "documentId": "doc_025", + "documentTitle": "2026학년도 독서통장 운영 안내", + "documentDate": "2026-03-25", + "school": "시흥신일초등학교", + "dateCandidates": [ + { + "id": "dc_025_1", + "raw": "2026. 3. 30. ~ 7. 9.", + "resolved": "2026-07-09", + "note": "독서통장 기록 마감일" + }, + { + "id": "dc_025_2", + "raw": "독서통장 제출일: 7. 10.", + "resolved": "2026-07-10", + "note": "독서통장 담임 제출 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "독서통장 제도 안내", + "evidenceText": "2026학년도에는 학생들의 문해력과 독서능력 향상을 위해 전 학년을 대상으로 독서통장을 실시하고자 합니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "독서통장은 학생이 읽은 책 목록과 간단한 감상을 기록하는 수첩입니다. 은행 통장처럼 책 한 권을 읽을 때마다 한 줄씩 기록하며, 학기말에 담임 선생님께 제출해 확인을 받습니다. 목표 권수를 달성하면 도서관에서 시상이 있습니다." + }, + { + "type": "checklist", + "title": "학년별 독서 목표 권수 달성 및 독서통장 제출", + "evidenceText": "성취기준(아래의 권수만큼 독서통장에 기록 후 담임선생님께 제출 및 확인): 2학년 20권 / 3학년 15권 / 4학년 15권 / 5학년 10권 / 6학년 10권 / 독서통장 제출일: 7. 10.", + "selectedDateCandidateId": "dc_025_2", + "dateStatus": "confirmed", + "date": "2026-07-10", + "target": "both", + "actionRequired": true, + "schoolContext": "가정, 도서관 어디서 읽은 책이든 모두 기록할 수 있습니다. 단, 만화책, 그림이 1/3 이상인 책(도감, 화보 등), 퀴즈·유머·종이접기 등의 책은 인정되지 않습니다. 1학년은 2학기부터 시작합니다." + }, + { + "type": "reminder", + "title": "인정되지 않는 도서 및 기록 안내", + "evidenceText": "인정하지 않는 도서의 종류: 사진·그림이 책의 1/3 이상인 책 제외(만화, 화보, 도감 등) / 퀴즈, 유머, 종이접기, 그리기, 만들기, 악보, 잡지 등의 책 제외 / 인정하지 않는 기록 사례: 불성실한 감상평(동일한 감상평) / 동일 도서로 2번 이상 기록", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "both", + "actionRequired": false, + "schoolContext": "독서통장에는 책 제목, 읽은 날짜, 간단한 감상을 성실하게 적어야 합니다. 같은 내용을 복사해서 쓰거나 성의 없는 감상평은 인정받지 못할 수 있습니다. 한 권의 책은 한 번만 기록할 수 있습니다." + } + ] +} diff --git a/data/newsletter-labels/newsletter-027.json b/data/newsletter-labels/newsletter-027.json index 445153c..e13cab9 100644 --- a/data/newsletter-labels/newsletter-027.json +++ b/data/newsletter-labels/newsletter-027.json @@ -1,82 +1,82 @@ { - "documentId": "doc_027", - "documentTitle": "위(Wee)클래스 이용 및 상담 동의 안내", - "documentDate": "2026-03-23", - "school": "도일초등학교", - "dateCandidates": [ - { - "id": "dc_028_1", - "raw": "3월 27일(금)까지", - "resolved": "2026-03-27", - "note": "상담 동의서 및 개인정보 동의서 제출 마감일" - } - ], - "labels": [ - { - "type": "reminder", - "title": "위(Wee)클래스 제도 안내", - "evidenceText": "본교 위클래스 상담실에서는 학생들의 심리·정서적 안정과 학교생활 적응을 돕고, 상담 활동을 전문화하여 학생들의 고민과 문제를 함께 해결하고자 노력하고 있습니다.", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "위(Wee)클래스는 한국 교육부가 전국 학교에 설치한 학생 상담 전문 공간입니다. 전문 상담교사가 상주하며 학생의 학교 적응, 친구 관계, 심리·정서 문제 등을 무료로 상담해줍니다. 별도 비용이 없으며 학부모도 이용할 수 있습니다." - }, - { - "type": "reminder", - "title": "위클래스 상담 내용과 이용 방법 안내", - "evidenceText": "개인상담, 집단상담: 친구 및 가족관계, 학업 및 진로, 성격, 심리·정서 관련, 학교 적응 등 / 각종 심리검사 실시 및 해석 상담 / 상담 진행: 약 4~10회기 진행 (1회기 상담은 주 1회, 40분)", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "상담은 보통 4~10회에 걸쳐 진행되며, 주 1회 약 40분씩 이루어집니다. 단순 대화가 아닌 전문 상담교사가 진행하는 구조화된 상담입니다. 심리검사(ADHD 성향, 불안, 우울 등)도 실시할 수 있으며, 필요시 외부 전문 병원이나 기관으로 연계해 줍니다." - }, - { - "type": "deadline", - "title": "위클래스 상담 동의서 및 개인정보 동의서 제출", - "evidenceText": "아래 내용을 살펴보신 후 학생 상담 동의 및 개인정보 수집·이용 동의서를 3월 27일(금)까지 이알리미로 제출해 주시기 바랍니다.", - "selectedDateCandidateId": "dc_028_1", - "dateStatus": "confirmed", - "date": "2026-03-27", - "target": "parent", - "actionRequired": true, - "schoolContext": "위클래스를 이용하려면 학부모 동의서가 필요합니다. 동의서는 이알리미 앱을 통해 제출하며, 동의하지 않으면 상담, 심리검사, 상담 프로그램 참여에 제한이 생깁니다. 동의서를 미리 제출해두면 자녀가 필요할 때 바로 이용할 수 있습니다." - }, - { - "type": "reminder", - "title": "상담 내용 비밀 보장 안내", - "evidenceText": "상담의 내용은 비밀 보장되며, 학교생활기록부에도 일절 기록되지 않습니다. 다만, 담임(교과)교사에게는 학생의 동의하에 제한적인 정보 제공과 교육적 자문이 이루어질 수 있습니다.", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "상담 내용은 학교생활기록부(성적표)에 기록되지 않습니다. 상담을 받았다는 사실이 이후 진학이나 성적에 영향을 주지 않으므로 안심하고 이용할 수 있습니다. 다만 자녀나 타인에게 위험이 있는 경우(자해, 학교폭력, 아동학대 등)에는 비밀 보장 예외가 적용됩니다." - }, - { - "type": "reminder", - "title": "비밀 보장 예외 상황 안내", - "evidenceText": "단, 몇 가지 예외 사항이 있습니다. 자신이나 타인의 신체 및 재산에 해를 가할 가능성(자해 및 자살, 학교폭력, 성폭력 등), 가정 폭력 및 아동학대와 관련된 경우, 범죄와 연루된 경우, 수사기관이 요구하는 경우 등이 있습니다.", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "자녀가 자해, 자살 위험, 학교폭력 피해, 아동학대 등과 관련된 내용을 상담에서 이야기할 경우 상담교사는 의무적으로 관계기관에 신고하거나 학부모에게 알려야 합니다. 이는 아이의 안전을 위한 법적 의무이며, 상담교사의 자의적 판단이 아닙니다." - }, - { - "type": "reminder", - "title": "ADHD·우울·자해 등 심각한 문제의 경우 외부기관 연계 안내", - "evidenceText": "ADHD, 심각한 우울, 자해, 품행장애 등의 문제를 가진 학생들은 진단을 통해 심리치료(병원, 외부 전문기관)를 함께 받아야 합니다. 또한 학교 상담은 많은 학생을 대상으로 하므로 장기간 상담이 어려워 지속적으로 상담을 받을 수 있는 센터나 사설 기관으로 의뢰하는 경우도 있습니다.", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "학교 위클래스는 경미한 어려움을 다루는 초기 상담 공간입니다. 전문적인 치료가 필요한 수준이라면 학교 상담만으로는 한계가 있어 외부 전문기관(정신건강복지센터, 병원 등)으로 연계합니다. 연계된 외부기관도 대부분 무료 또는 저렴하게 이용할 수 있습니다." - } - ] -} \ No newline at end of file + "documentId": "doc_027", + "documentTitle": "위(Wee)클래스 이용 및 상담 동의 안내", + "documentDate": "2026-03-23", + "school": "도일초등학교", + "dateCandidates": [ + { + "id": "dc_027_1", + "raw": "3월 27일(금)까지", + "resolved": "2026-03-27", + "note": "상담 동의서 및 개인정보 동의서 제출 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "위(Wee)클래스 제도 안내", + "evidenceText": "본교 위클래스 상담실에서는 학생들의 심리·정서적 안정과 학교생활 적응을 돕고, 상담 활동을 전문화하여 학생들의 고민과 문제를 함께 해결하고자 노력하고 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "위(Wee)클래스는 한국 교육부가 전국 학교에 설치한 학생 상담 전문 공간입니다. 전문 상담교사가 상주하며 학생의 학교 적응, 친구 관계, 심리·정서 문제 등을 무료로 상담해줍니다. 별도 비용이 없으며 학부모도 이용할 수 있습니다." + }, + { + "type": "reminder", + "title": "위클래스 상담 내용과 이용 방법 안내", + "evidenceText": "개인상담, 집단상담: 친구 및 가족관계, 학업 및 진로, 성격, 심리·정서 관련, 학교 적응 등 / 각종 심리검사 실시 및 해석 상담 / 상담 진행: 약 4~10회기 진행 (1회기 상담은 주 1회, 40분)", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "상담은 보통 4~10회에 걸쳐 진행되며, 주 1회 약 40분씩 이루어집니다. 단순 대화가 아닌 전문 상담교사가 진행하는 구조화된 상담입니다. 심리검사(ADHD 성향, 불안, 우울 등)도 실시할 수 있으며, 필요시 외부 전문 병원이나 기관으로 연계해 줍니다." + }, + { + "type": "deadline", + "title": "위클래스 상담 동의서 및 개인정보 동의서 제출", + "evidenceText": "아래 내용을 살펴보신 후 학생 상담 동의 및 개인정보 수집·이용 동의서를 3월 27일(금)까지 이알리미로 제출해 주시기 바랍니다.", + "selectedDateCandidateId": "dc_027_1", + "dateStatus": "confirmed", + "date": "2026-03-27", + "target": "parent", + "actionRequired": true, + "schoolContext": "위클래스를 이용하려면 학부모 동의서가 필요합니다. 동의서는 이알리미 앱을 통해 제출하며, 동의하지 않으면 상담, 심리검사, 상담 프로그램 참여에 제한이 생깁니다. 동의서를 미리 제출해두면 자녀가 필요할 때 바로 이용할 수 있습니다." + }, + { + "type": "reminder", + "title": "상담 내용 비밀 보장 안내", + "evidenceText": "상담의 내용은 비밀 보장되며, 학교생활기록부에도 일절 기록되지 않습니다. 다만, 담임(교과)교사에게는 학생의 동의하에 제한적인 정보 제공과 교육적 자문이 이루어질 수 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "상담 내용은 학교생활기록부(성적표)에 기록되지 않습니다. 상담을 받았다는 사실이 이후 진학이나 성적에 영향을 주지 않으므로 안심하고 이용할 수 있습니다. 다만 자녀나 타인에게 위험이 있는 경우(자해, 학교폭력, 아동학대 등)에는 비밀 보장 예외가 적용됩니다." + }, + { + "type": "reminder", + "title": "비밀 보장 예외 상황 안내", + "evidenceText": "단, 몇 가지 예외 사항이 있습니다. 자신이나 타인의 신체 및 재산에 해를 가할 가능성(자해 및 자살, 학교폭력, 성폭력 등), 가정 폭력 및 아동학대와 관련된 경우, 범죄와 연루된 경우, 수사기관이 요구하는 경우 등이 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "자녀가 자해, 자살 위험, 학교폭력 피해, 아동학대 등과 관련된 내용을 상담에서 이야기할 경우 상담교사는 의무적으로 관계기관에 신고하거나 학부모에게 알려야 합니다. 이는 아이의 안전을 위한 법적 의무이며, 상담교사의 자의적 판단이 아닙니다." + }, + { + "type": "reminder", + "title": "ADHD·우울·자해 등 심각한 문제의 경우 외부기관 연계 안내", + "evidenceText": "ADHD, 심각한 우울, 자해, 품행장애 등의 문제를 가진 학생들은 진단을 통해 심리치료(병원, 외부 전문기관)를 함께 받아야 합니다. 또한 학교 상담은 많은 학생을 대상으로 하므로 장기간 상담이 어려워 지속적으로 상담을 받을 수 있는 센터나 사설 기관으로 의뢰하는 경우도 있습니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "학교 위클래스는 경미한 어려움을 다루는 초기 상담 공간입니다. 전문적인 치료가 필요한 수준이라면 학교 상담만으로는 한계가 있어 외부 전문기관(정신건강복지센터, 병원 등)으로 연계합니다. 연계된 외부기관도 대부분 무료 또는 저렴하게 이용할 수 있습니다." + } + ] +} diff --git a/data/newsletter-labels/newsletter-029.json b/data/newsletter-labels/newsletter-029.json index 4e65838..fe82a24 100644 --- a/data/newsletter-labels/newsletter-029.json +++ b/data/newsletter-labels/newsletter-029.json @@ -1,88 +1,88 @@ { - "documentId": "doc_029", - "documentTitle": "2025학년도 겨울방학 방과후학교 연계형 돌봄교실 신청 안내", - "documentDate": "2025-12-01", - "school": "대전관저초등학교", - "dateCandidates": [ - { - "id": "dc_027_1", - "raw": "12월 5일(금)까지", - "resolved": "2025-12-05", - "note": "신청서 및 서류 제출 마감일" - }, - { - "id": "dc_027_2", - "raw": "2026. 1. 12.(월) ~ 2. 13.(금)", - "resolved": "2026-02-13", - "note": "겨울방학 돌봄교실 운영 기간 마감일" - } - ], - "labels": [ - { - "type": "reminder", - "title": "방과후학교 연계형 돌봄교실 개념 안내", - "evidenceText": "2025학년도 겨울방학 방과후학교 연계형 돌봄교실 신청 방법을 아래와 같이 안내하오니, 참여를 희망하시는 학부모님께서는 신청서와 서류를 12월 5일(금)까지 돌봄교실 2반으로 제출해주시기 바랍니다.", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "방과후학교 연계형 돌봄교실은 방학 중 맞벌이·저소득 가정 등 돌봄이 필요한 학생을 위해 학교에서 운영하는 돌봄 서비스입니다. 방학 중에도 학교에서 자녀를 안전하게 돌봐주며, 방과후 프로그램과 연계해 수업도 함께 진행합니다. 모든 학생이 이용하는 것이 아니라 신청자에 한해 선발합니다." - }, - { - "type": "reminder", - "title": "돌봄교실 우선순위 선발 기준 안내", - "evidenceText": "1순위: 국민기초생활보장수급자 / 2순위: 법정차상위 한부모가족보호대상자, 법정차상위 자활근로참가자 등 / 4순위: 한부모 가족 / 5순위: 취업 부모 / 6순위: 두 자녀 이상 다자녀", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "돌봄교실 자리가 제한되어 있어 우선순위에 따라 선발합니다. 국민기초생활수급자, 한부모가정, 저소득층이 우선 대상이며, 맞벌이 가정도 취업 증빙서류를 제출하면 신청할 수 있습니다. 다문화가정은 별도 우선순위가 없더라도 소득 기준 충족 시 해당 순위로 신청 가능합니다." - }, - { - "type": "deadline", - "title": "겨울방학 돌봄교실 신청서 및 증빙서류 제출", - "evidenceText": "신청서와 서류를 12월 5일(금)까지 돌봄교실 2반으로 제출해주시기 바랍니다. (학기 중 참여자는 신청서만 제출)", - "selectedDateCandidateId": "dc_027_1", - "dateStatus": "confirmed", - "date": "2025-12-05", - "target": "parent", - "actionRequired": true, - "schoolContext": "처음 신청하는 경우 신청서와 해당 순위 증빙서류를 함께 제출해야 합니다. 이미 학기 중 돌봄교실에 참여하고 있는 학생은 신청서만 제출하면 됩니다. 서류 없이 신청서만 내면 선정에서 제외될 수 있습니다." - }, - { - "type": "schedule", - "title": "겨울방학 돌봄교실 운영 기간", - "evidenceText": "운영 기간: 2026. 1. 12.(월) ~ 2. 13.(금) *공휴일 및 주말 제외 / 운영 시간: 09:00 ~ 11:50", - "selectedDateCandidateId": "dc_027_2", - "dateStatus": "confirmed", - "date": "2026-02-13", - "target": "parent", - "actionRequired": false, - "schoolContext": "방학 중 돌봄교실은 오전 9시부터 11시 50분까지 운영됩니다. 이 시간 안에서 학부모가 원하는 귀가 시간을 지정할 수 있습니다. 급식과 간식은 제공되지 않으므로 자녀가 배가 고프지 않도록 아침을 먹이고 등교시키세요." - }, - { - "type": "reminder", - "title": "취업 부모 신청 시 필요 서류 안내", - "evidenceText": "취업 부모: 재직증명서(사업장 업체전화번호, 근로시간 기입) 또는 근무시간확인서 / 고용보험피보험자격내역서 / 직장건강보험 자격득실확인서 / 부, 모 각각 제출(총 4부)", - "selectedDateCandidateId": "dc_027_1", - "dateStatus": "confirmed", - "date": "2025-12-05", - "target": "parent", - "actionRequired": true, - "schoolContext": "맞벌이 가정으로 신청하려면 부모 모두의 재직증명서와 고용 관련 서류를 각각 제출해야 합니다(총 4부). 재직증명서는 직장에서 발급받을 수 있으며, 고용보험 확인서는 고용지원센터나 온라인에서 발급 가능합니다. 자영업자는 사업자등록증과 소득증빙 서류를 대신 제출합니다." - }, - { - "type": "reminder", - "title": "방학 중 돌봄교실 급·간식 미제공 안내", - "evidenceText": "운영 시간: 09:00 ~ 11:50 / 급·간식 미제공", - "selectedDateCandidateId": null, - "dateStatus": "missing", - "date": null, - "target": "parent", - "actionRequired": false, - "schoolContext": "학기 중 급식과 달리 방학 중 돌봄교실에서는 점심 급식과 간식을 제공하지 않습니다. 운영 시간이 오전 중에만 이루어지므로 귀가 전 식사가 필요한 경우 도시락을 따로 챙겨주거나, 귀가 후 바로 식사할 수 있도록 준비해 두세요." - } - ] -} \ No newline at end of file + "documentId": "doc_029", + "documentTitle": "2025학년도 겨울방학 방과후학교 연계형 돌봄교실 신청 안내", + "documentDate": "2025-12-01", + "school": "대전관저초등학교", + "dateCandidates": [ + { + "id": "dc_029_1", + "raw": "12월 5일(금)까지", + "resolved": "2025-12-05", + "note": "신청서 및 서류 제출 마감일" + }, + { + "id": "dc_029_2", + "raw": "2026. 1. 12.(월) ~ 2. 13.(금)", + "resolved": "2026-02-13", + "note": "겨울방학 돌봄교실 운영 기간 마감일" + } + ], + "labels": [ + { + "type": "reminder", + "title": "방과후학교 연계형 돌봄교실 개념 안내", + "evidenceText": "2025학년도 겨울방학 방과후학교 연계형 돌봄교실 신청 방법을 아래와 같이 안내하오니, 참여를 희망하시는 학부모님께서는 신청서와 서류를 12월 5일(금)까지 돌봄교실 2반으로 제출해주시기 바랍니다.", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "방과후학교 연계형 돌봄교실은 방학 중 맞벌이·저소득 가정 등 돌봄이 필요한 학생을 위해 학교에서 운영하는 돌봄 서비스입니다. 방학 중에도 학교에서 자녀를 안전하게 돌봐주며, 방과후 프로그램과 연계해 수업도 함께 진행합니다. 모든 학생이 이용하는 것이 아니라 신청자에 한해 선발합니다." + }, + { + "type": "reminder", + "title": "돌봄교실 우선순위 선발 기준 안내", + "evidenceText": "1순위: 국민기초생활보장수급자 / 2순위: 법정차상위 한부모가족보호대상자, 법정차상위 자활근로참가자 등 / 4순위: 한부모 가족 / 5순위: 취업 부모 / 6순위: 두 자녀 이상 다자녀", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "돌봄교실 자리가 제한되어 있어 우선순위에 따라 선발합니다. 국민기초생활수급자, 한부모가정, 저소득층이 우선 대상이며, 맞벌이 가정도 취업 증빙서류를 제출하면 신청할 수 있습니다. 다문화가정은 별도 우선순위가 없더라도 소득 기준 충족 시 해당 순위로 신청 가능합니다." + }, + { + "type": "deadline", + "title": "겨울방학 돌봄교실 신청서 및 증빙서류 제출", + "evidenceText": "신청서와 서류를 12월 5일(금)까지 돌봄교실 2반으로 제출해주시기 바랍니다. (학기 중 참여자는 신청서만 제출)", + "selectedDateCandidateId": "dc_029_1", + "dateStatus": "confirmed", + "date": "2025-12-05", + "target": "parent", + "actionRequired": true, + "schoolContext": "처음 신청하는 경우 신청서와 해당 순위 증빙서류를 함께 제출해야 합니다. 이미 학기 중 돌봄교실에 참여하고 있는 학생은 신청서만 제출하면 됩니다. 서류 없이 신청서만 내면 선정에서 제외될 수 있습니다." + }, + { + "type": "schedule", + "title": "겨울방학 돌봄교실 운영 기간", + "evidenceText": "운영 기간: 2026. 1. 12.(월) ~ 2. 13.(금) *공휴일 및 주말 제외 / 운영 시간: 09:00 ~ 11:50", + "selectedDateCandidateId": "dc_029_2", + "dateStatus": "confirmed", + "date": "2026-02-13", + "target": "parent", + "actionRequired": false, + "schoolContext": "방학 중 돌봄교실은 오전 9시부터 11시 50분까지 운영됩니다. 이 시간 안에서 학부모가 원하는 귀가 시간을 지정할 수 있습니다. 급식과 간식은 제공되지 않으므로 자녀가 배가 고프지 않도록 아침을 먹이고 등교시키세요." + }, + { + "type": "reminder", + "title": "취업 부모 신청 시 필요 서류 안내", + "evidenceText": "취업 부모: 재직증명서(사업장 업체전화번호, 근로시간 기입) 또는 근무시간확인서 / 고용보험피보험자격내역서 / 직장건강보험 자격득실확인서 / 부, 모 각각 제출(총 4부)", + "selectedDateCandidateId": "dc_029_1", + "dateStatus": "confirmed", + "date": "2025-12-05", + "target": "parent", + "actionRequired": true, + "schoolContext": "맞벌이 가정으로 신청하려면 부모 모두의 재직증명서와 고용 관련 서류를 각각 제출해야 합니다(총 4부). 재직증명서는 직장에서 발급받을 수 있으며, 고용보험 확인서는 고용지원센터나 온라인에서 발급 가능합니다. 자영업자는 사업자등록증과 소득증빙 서류를 대신 제출합니다." + }, + { + "type": "reminder", + "title": "방학 중 돌봄교실 급·간식 미제공 안내", + "evidenceText": "운영 시간: 09:00 ~ 11:50 / 급·간식 미제공", + "selectedDateCandidateId": null, + "dateStatus": "missing", + "date": null, + "target": "parent", + "actionRequired": false, + "schoolContext": "학기 중 급식과 달리 방학 중 돌봄교실에서는 점심 급식과 간식을 제공하지 않습니다. 운영 시간이 오전 중에만 이루어지므로 귀가 전 식사가 필요한 경우 도시락을 따로 챙겨주거나, 귀가 후 바로 식사할 수 있도록 준비해 두세요." + } + ] +} diff --git a/scripts/evaluate_newsletter_labels.py b/scripts/evaluate_newsletter_labels.py index a663a9d..04b36ed 100644 --- a/scripts/evaluate_newsletter_labels.py +++ b/scripts/evaluate_newsletter_labels.py @@ -207,10 +207,7 @@ def parse_sample(raw: dict[str, Any], index: int) -> LabelSample: return LabelSample( sample_id=str( - raw.get("sampleId") - or raw.get("id") - or raw.get("_fileStem") - or f"sample_{index}" + raw.get("sampleId") or raw.get("id") or raw.get("_fileStem") or f"sample_{index}" ), source_file=_optional_str(raw.get("sourceFile")), request=request, @@ -348,9 +345,7 @@ def evaluate_sample(sample: LabelSample, predicted: NewsletterAnalysisResponse) matched_predicted = {match.predicted_index for match in matches} precision = ( - len(matches) / len(predicted_items) - if predicted_items - else float(not sample.expected_items) + len(matches) / len(predicted_items) if predicted_items else float(not sample.expected_items) ) recall = len(matches) / len(sample.expected_items) if sample.expected_items else 1.0 f1 = _f1(precision, recall) From d871c263470e73297af2e18dddacf127398bf127 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 25 May 2026 18:05:47 +0900 Subject: [PATCH 23/23] =?UTF-8?q?chore:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/release.md | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/release.md diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md new file mode 100644 index 0000000..b2c2191 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release.md @@ -0,0 +1,32 @@ +--- +name: "Release" +about: "develop → main 릴리즈 체크리스트" +title: "[TASK] 릴리즈 배포: develop → main" +--- + +## 🎯 목적 + +develop 브랜치에 누적된 변경사항을 main으로 릴리즈 배포합니다. + +## 📦 포함 범위 + +- **포함 이슈** +- # + +- **포함 PR** +- # + +- **제외 이슈** +- # + +- **제외 PR** +- # + +## ✅ 릴리즈 체크리스트 + +- [ ] develop 최신 상태 확인 +- [ ] release PR 생성 (develop → main) +- [ ] 리뷰/체크 통과 +- [ ] main 머지 +- [ ] deploy-ec2 수동 실행 (workflow_dispatch, ref: main) +- [ ] 배포 검증 완료 (ssm-send-step, ssm-order, compose-ps)