diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2328210..b79cf04 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,27 +1,9 @@ -# CODEOWNERS — PR이 해당 경로를 수정하면 소유자에게 자동으로 리뷰 요청이 갑니다. -# 채우는 법: -# 1) GitHub Org > Teams 에서 팀 생성 (예: core-maintainers, group1 ... group6) -# 2) 아래 @CausalInferenceLab/ 핸들을 실제 팀 이름으로 교체 -# 3) 조 폴더가 생기면 한 줄씩 추가: /cases/group3-*/ @CausalInferenceLab/group3 -# 4) 팀에 저장소 Write 권한을 부여해야 리뷰 요청이 동작합니다. -# 규칙: 아래쪽 줄이 위쪽 줄보다 우선합니다. - -# 기본: 운영진 -* @CausalInferenceLab/mentors - -# 공통 엔진 -/core/ @CausalInferenceLab/core-maintainers -/tests/core/ @CausalInferenceLab/core-maintainers - -# 운영/인프라 -/.github/ @CausalInferenceLab/mentors -/app/ @CausalInferenceLab/mentors -/cases/_template/ @CausalInferenceLab/mentors - -# 조별 케이스 (조 편성 후 주석 해제·수정) -# /cases/group1-*/ @CausalInferenceLab/group1 -# /cases/group2-*/ @CausalInferenceLab/group2 -# /cases/group3-*/ @CausalInferenceLab/group3 -# /cases/group4-*/ @CausalInferenceLab/group4 -# /cases/group5-*/ @CausalInferenceLab/group5 -# /cases/group6-*/ @CausalInferenceLab/group6 +# 코드 소유자: PR이 열리면 아래 사람에게 자동으로 리뷰 요청이 갑니다. +# 멘티 폴더(cases/-<주제>/)는 폴더를 만들 때 한 줄씩 추가합니다. +# 예) /cases/gildong-*/ @gildong + +* @jsshin2022 +/core/ @jsshin2022 +/site/ @jsshin2022 +/catalog/ @jsshin2022 +/.github/ @jsshin2022 diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index b9c66f3..0b8c4ba 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -7,7 +7,7 @@ body: id: where attributes: label: 위치 - placeholder: core/estimators, app/streamlit_app.py, cases/group3-.../estimate.py + placeholder: core/estimators, app/streamlit_app.py, cases/gildong-local-currency/estimate.py validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/case-proposal.yml b/.github/ISSUE_TEMPLATE/case-proposal.yml index e876ea1..68bef34 100644 --- a/.github/ISSUE_TEMPLATE/case-proposal.yml +++ b/.github/ISSUE_TEMPLATE/case-proposal.yml @@ -1,5 +1,5 @@ name: 케이스 제안 -description: 분석할 정책/사회 문제를 제안합니다 (조 주제 후보) +description: 내가 분석할 정책·사회 문제를 제안합니다 title: "[케이스] " labels: ["case-proposal"] body: @@ -8,10 +8,10 @@ body: value: | 결과를 보기 전에 질문을 먼저 적는 것이 목표입니다. 모르는 칸은 "미정"으로 두세요. - type: input - id: group + id: owner attributes: - label: 조 - placeholder: group3 + label: 담당자 GitHub ID + placeholder: gildong validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/site-question.yml b/.github/ISSUE_TEMPLATE/site-question.yml new file mode 100644 index 0000000..e9571c2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/site-question.yml @@ -0,0 +1,41 @@ +name: 사이트 대화에서 온 질문 +description: 사이트의 "궁금한 정책 이야기" 대화를 제안으로 올립니다 (보통 사이트 버튼이 자동으로 채워 줍니다) +title: "[사이트 질문] " +labels: ["from-site"] +body: + - type: markdown + attributes: + value: | + 사이트 대화창에서 정리한 질문입니다. 올려 주신 질문은 **요즘 궁금해하는 주제 순위**에 반영되고, + 멘토·멘티가 검토해 새 주제·데이터·분석으로 이어집니다. 개인정보나 API 키는 적지 마세요. + - type: textarea + id: question + attributes: + label: 궁금한 점 + description: 처음 적은 질문 + validations: + required: true + - type: input + id: topic + attributes: + label: 관련 주제 + description: 사이트가 연결한 주제입니다. 맞지 않으면 고치거나 "새 주제"로 두세요. + placeholder: 부동산 거래 규제 (housing-transaction-regulation) + - type: textarea + id: conversation + attributes: + label: 대화 내용 + description: 사이트 대화가 자동으로 들어갑니다. 필요 없는 부분은 지워도 됩니다. + render: markdown + - type: textarea + id: proposal + attributes: + label: 정리된 제안 + description: 분석 계획 초안, 새로 찾은 데이터셋, 새 주제의 세 가지(언제·누가·무엇) 등 + - type: checkboxes + id: consent + attributes: + label: 확인 + options: + - label: 이 내용이 공개 저장소에 올라가는 것을 알고 있습니다. 개인정보나 비밀값은 없습니다. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d0a083f..c8da859 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,8 @@ ## 무엇을 했나요? -## 조 / 케이스 - +## 케이스 + ## 체크리스트 - [ ] `plan.yaml`을 **결과를 보기 전에** 작성·커밋했습니다 (이후 변경 시 이유를 커밋 메시지에 기록) @@ -10,7 +10,8 @@ - [ ] API 키·`.env`·개인정보가 담긴 원자료·재배포 불가 데이터를 포함하지 않았습니다 - [ ] 그림/수치는 `fetch.py` → `estimate.py` 실행으로 **재현** 가능합니다 - [ ] `make check` (ruff + pytest)가 통과합니다 -- [ ] 다른 조의 폴더나 `core/`를 (합의 없이) 수정하지 않았습니다 +- [ ] 다른 사람의 폴더나 `core/`를 (합의 없이) 수정하지 않았습니다 +- [ ] 원자료·가공 데이터 파일을 커밋하지 않았습니다 (시뮬레이션·조례 목록 제외) ## 리뷰어에게 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ffd301..d27c9cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,8 @@ jobs: run: | ruff check . ruff format --check . || echo "::warning::ruff format differences (run 'ruff format .')" + - name: License check (no GPL/AGPL dependencies) + run: python scripts/check_licenses.py - name: Pytest run: pytest -q - name: Example flow (offline) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index cdcfaff..e7ca9c5 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,16 +2,19 @@ name: pages # main 에 반영되면 정적 대시보드(_site/)를 만들어 GitHub Pages 로 배포한다. # 최초 1회: Settings → Pages → Build and deployment → Source: "GitHub Actions" -# 선택: Settings → Secrets → Actions 에 LAW_OC(법제처 OC) 를 넣으면 조례 목록을 자동 수집한다. +# 선택: Settings → Secrets → Actions 에 넣으면 켜지는 것 (값은 절대 레포에 적지 않는다) +# LAW_OC 법제처 조례 목록 자동 수집 +# NAVER_CLIENT_ID, NAVER_CLIENT_SECRET '요즘 궁금해하는 주제' 순위에 검색 관심도 추가 on: push: branches: [main] workflow_dispatch: schedule: - - cron: "0 18 * * 0" # 매주 월요일 03:00 KST 데이터 갱신 + - cron: "0 18 * * *" # 매일 03:00 KST: 순위·데이터 갱신 후 다시 공개 permissions: contents: read + issues: read # 사이트 질문(라벨 from-site) 수를 세어 순위에 반영 pages: write id-token: write @@ -24,6 +27,8 @@ jobs: runs-on: ubuntu-latest env: LAW_OC: ${{ secrets.LAW_OC }} + NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} + NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -34,6 +39,10 @@ jobs: - name: Refresh ordinance snapshots (법제처, optional) if: ${{ env.LAW_OC != '' }} run: python scripts/refresh_law_snapshots.py + - name: Refresh topic ranking (site questions + optional Naver DataLab) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/refresh_trends.py - run: python site/build.py - uses: actions/upload-pages-artifact@v3 with: diff --git a/.gitignore b/.gitignore index f0f97ec..3b04189 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ data/cache/ *.xlsx~ ~$* +# Generated at build time (GitHub Actions) +catalog/snapshots/trends.json + # OS / editors .DS_Store Thumbs.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4bb8d3a..05d7df8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,66 +1,69 @@ -# 기여 가이드 (CONTRIBUTING) +# 기여 가이드 -GitHub 협업이 처음이라면 먼저 [`docs/ops/github-onboarding.md`](docs/ops/github-onboarding.md)를 따라 하세요. +GitHub가 처음이라면 [GitHub 따라 하기](docs/ops/github-onboarding.md)부터 보세요. 전체 흐름은 [멘티 참여 가이드](docs/ops/mentee-guide.md)에 있습니다. -## 1. 작업 방식: 조별 브랜치 (권장) +## 1. 한 사람, 한 폴더, 한 브랜치 -| 방식 | 언제 | 비고 | -|---|---|---| -| **조직 저장소에서 브랜치** (권장) | 조직 초대를 수락한 멘티 | CI·리뷰·모니터링이 한 곳에서 보임 | -| Fork → PR | 초대 전이거나 외부 기여자 | PR 대상은 `main` | - -- `main`은 보호 브랜치입니다. **직접 push 금지, PR로만 병합.** -- 브랜치 이름: `<조>/<작업>` — 예) `group3/plan`, `group3/fetch-kosis`, `group1/fix-report` -- 공통 코드(`core/`)는 `core/<작업>` 브랜치로, 먼저 이슈에서 논의한 뒤 수정합니다. +- 분석은 한 사람이 주제 하나를 맡아 `cases/<내 GitHub ID>-<주제>/` 폴더에서 합니다. 예: `cases/gildong-local-currency/` +- 브랜치 이름은 `<내 GitHub ID>/<작업>`입니다. 예: `gildong/plan`, `gildong/fetch` +- `main`에는 직접 올릴 수 없습니다. 모든 변경은 PR로 합칩니다. +- 조직 초대를 받기 전이거나 외부 기여자라면 fork 후 PR을 올려도 됩니다. -## 2. 폴더 소유권 +## 2. 어디를 고치나 -| 경로 | 소유 | 규칙 | +| 경로 | 누가 | 규칙 | |---|---|---| -| `cases/<조>-<주제>/` | 해당 조 | 조 안에서 자유롭게. 다른 조 폴더는 수정하지 않음 | -| `core/` | 멘토·코어 메인테이너 | 이슈 → 합의 → PR. 테스트 필수 | -| `cases/_template/`, `app/`, `.github/`, `docs/` | 운영진 | 개선 제안은 이슈로 | +| `cases/<내ID>-<주제>/` | 폴더 주인 | 자유롭게. 남의 폴더는 고치지 않습니다 | +| `catalog/` | 누구나 | 새 데이터셋·이슈·주제 제안. 원본 페이지에서 확인한 값만 적습니다 | +| `core/`, `site/` | 멘토·메인테이너 | 먼저 이슈로 논의한 뒤 PR. 테스트 필수 | +| `.github/`, `docs/` | 운영진 | 개선 제안은 이슈로 | -소유자는 [`.github/CODEOWNERS`](.github/CODEOWNERS)로 자동 리뷰 요청됩니다. - -## 3. 커밋 규칙 (Conventional Commits) +## 3. 커밋 메시지 ``` -(): <요약, 50자 이내> +<종류>(<범위>): <요약, 50자 이내> ``` -- type: `feat` 기능 · `fix` 버그 · `data` 수집/전처리 · `analysis` 추정/그림 · `docs` 문서 · `plan` plan.yaml · `test` · `chore` -- scope: 조 폴더명 또는 `core`, `app` -- 예) `plan(group3-youth-rent): 처치·대조 지역 정의`, `analysis(group3-youth-rent): DiD 1차 추정` -- 작은 단위로 자주 커밋하세요. 주 1회 이상 커밋이 활동 확인 기준입니다. +- 종류: `plan` 분석 계획 · `data` 수집 · `analysis` 추정·그림 · `docs` 문서 · `feat` 기능 · `fix` 버그 · `test` · `chore` +- 범위: 내 케이스 폴더 이름, 또는 `core`, `catalog`, `site` +- 예: `plan(gildong-local-currency): 처치·대조 지역 정의` ## 4. PR 흐름 -1. 최신 `main`에서 브랜치 생성 → 작업 → `make check` 통과 확인 -2. PR 생성 (템플릿 체크리스트 작성). 작업 중이면 **Draft PR**로 일찍 올리세요. -3. 리뷰: **승인 1명 + CI 통과** 시 병합 (조원 상호 리뷰 가능, `core/`는 코어 메인테이너 승인) -4. 병합 방식: **Squash merge** (PR 제목이 커밋 메시지가 되므로 규칙에 맞게) -5. 병합 후 브랜치 삭제, 로컬 `git switch main && git pull` +1. 최신 `main`에서 브랜치를 만들고 작업한 뒤 `make check`로 검사합니다. +2. PR을 올리고 템플릿 체크리스트를 채웁니다. 작업 중이면 Draft PR로 일찍 올리세요. +3. 멘토 또는 다른 멘티 1명 승인 + 자동 검사 통과 뒤 **Squash merge**로 합칩니다. +4. 합친 뒤에는 브랜치를 지우고 `git switch main && git pull`. + +## 5. 분석 원칙 (리뷰에서 확인합니다) -## 5. 분석 원칙 (리뷰에서 확인) +- **`plan.yaml`을 결과보다 먼저 커밋합니다.** 나중에 바꾸면 커밋 메시지에 이유를 적습니다. +- 데이터 출처와 이용 조건(공공누리 유형 등)을 `plan.yaml`의 `data_sources`에 적습니다. +- 그림과 숫자는 `fetch.py` → `make flow` 실행으로 다시 만들 수 있어야 합니다. +- 가정이 깨지면 판정을 "판단 불가"로 두는 것도 좋은 결과입니다. -- **plan.yaml을 결과보다 먼저 커밋** (사전 등록). 이후 변경은 커밋 메시지에 이유를 적습니다. -- 데이터 출처·라이선스 명시 (공공누리 유형 등). 재배포 불가 원자료, 개인정보, API 키는 커밋 금지. -- 그림·수치는 `estimate.py` 실행으로 재현 가능해야 합니다. -- 가정이 깨지면 `abstention` 규칙에 따라 **결론을 보류**하는 것도 좋은 결과입니다. +## 6. 올리면 안 되는 것 -## 6. 개발 환경 +- API 키·인증값(`.env`, 법제처 OC 등). 레포 비밀값(Settings → Secrets)으로만 관리합니다. +- **원자료와 가공한 데이터 파일.** 데이터는 `fetch.py`로 각자 받게 합니다. 특히 공공누리 3·4유형(변경금지, 예: 에어코리아)과 KOSIS 자료의 가공본은 다시 배포하지 않습니다. 예외는 시뮬레이션 데이터와 법제처 조례 목록(조례는 저작권 보호 대상이 아님)입니다. +- 개인정보가 담긴 자료. +- GPL·AGPL 라이선스 패키지를 필수 의존성으로 추가하지 않습니다(예: `rdrobust`, `differences`, PolicyEngine, OpenFisca). 필요하면 선택 설치로 분리합니다. CI가 확인합니다. + +## 7. 공식 결과가 아닙니다 + +이 프로젝트의 분석은 학습·연구용 오픈소스 결과물이며 정부·공공기관의 공식 평가나 통계가 아닙니다. 리포트와 PR 설명에 "정부 발표", "공식 결과"처럼 오해할 표현을 쓰지 않습니다. + +## 8. 개발 환경 ```bash -make install # 의존성 설치 (dev 포함) -make check # ruff + pytest — CI와 동일 -make app # Streamlit 로컬 실행 +make install # 의존성 설치 +make check # 코드 검사 + 테스트 (CI와 같음) +make flow CASE=cases/<내ID>-<주제> +python site/build.py && python -m http.server -d _site # 사이트 미리보기 ``` -선택: `pre-commit install` 로 커밋 시 ruff 자동 실행. - -## 7. 질문·제안 +## 9. 질문과 제안 -- 버그: 이슈 → `버그 리포트` -- 새 케이스 주제: 이슈 → `케이스 제안` +- 사이트 대화창에서 정리한 질문은 "이 대화를 제안으로 올리기" 버튼으로 이슈가 됩니다. +- 버그는 "버그 리포트", 새 분석 주제는 "케이스 제안" 이슈로 올립니다. - 행동 강령: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) diff --git a/README.md b/README.md index 1bfc0ea..9ce6c8c 100644 --- a/README.md +++ b/README.md @@ -5,30 +5,46 @@ 뉴스와 SNS에서 사람들이 묻는 정책을 공공데이터로 확인하는 오픈소스입니다. 정책을 받은 곳과 안 받은 곳을 비교하고, 데이터로 판단할 수 없으면 판단할 수 없다고 말합니다. -**[사이트 열기](https://causalinferencelab.github.io/policy-effect-analytics-agent/)** · [지금 이슈](https://causalinferencelab.github.io/policy-effect-analytics-agent/issues.html) · [데이터 지도](https://causalinferencelab.github.io/policy-effect-analytics-agent/data.html) · [구조](https://causalinferencelab.github.io/policy-effect-analytics-agent/architecture.html) · [조별 운영 가이드](docs/ops/group-guide.md) +**[사이트 열기](https://causalinferencelab.github.io/policy-effect-analytics-agent/)** · [지금 이슈](https://causalinferencelab.github.io/policy-effect-analytics-agent/issues.html) · [데이터 지도](https://causalinferencelab.github.io/policy-effect-analytics-agent/data.html) · [구조](https://causalinferencelab.github.io/policy-effect-analytics-agent/architecture.html) · [멘티 참여 가이드](docs/ops/mentee-guide.md) > 가짜연구소 인과추론팀 × 오픈업 오픈소스 AI 특화형 트랙3 「에이전틱 AI × 데이터」 (2026.9 ~ 11) +> +> **공식 평가가 아닙니다.** 학습·연구용 오픈소스 분석이며, 정부·공공기관의 공식 평가나 통계가 아니고 데이터 제공 기관의 후원·보증을 뜻하지 않습니다. --- -## 무엇을 하나 +## 사이트에서 할 수 있는 것 + +| 화면 | 하는 일 | +|---|---| +| **대화창** | "궁금한 정책 이야기를 적어 보세요"에 적으면 주제를 찾고, 받을 수 있는 데이터·조심할 점·분석 계획 초안을 대화로 정리합니다. 키 없이 쓰는 **가이드 모드**가 기본이고, 내 AI 키(Claude 또는 Ollama 같은 OpenAI 호환)를 넣으면 **AI 모드**로 자유롭게 묻습니다. 키는 저장하지 않습니다. | +| **요즘 궁금해하는 주제** | 대화창 아래 순위입니다. 최근 30일 사이트 질문 수 → 네이버 검색 관심도(키가 있을 때) → 최근 시행·발표일 순이고, 어떤 기준을 썼는지 화면에 적습니다. | +| **지금 이슈** | 토지거래허가구역, 6·27 대출 한도, 민생회복 소비쿠폰, 고유가 피해지원금, K-패스 '모두의 카드' 등 6개 정책의 분석 가이드입니다. | +| **데이터 지도** | 공공데이터 35개를 역할(누가 언제 받았나 / 무엇이 변했나 / 다른 요인), 단위, 받는 법, 이용 조건으로 정리했습니다. | +| **주제 8개** | 주제마다 정책 타임라인, 세 가지 확인 상태, 데이터, 분석 예시가 있습니다. | + +## 대화가 오픈소스가 되는 길 + +``` + 질문 ──▶ 정리 ──▶ 제안 ──▶ 반영 + 대화창 주제·데이터· "제안으로 올리기" → 멘토·멘티가 검토해 catalog/(새 주제·데이터), + 계획 초안 GitHub 이슈(from-site) cases/(분석)에 PR → 사이트·순위 갱신 +``` + +대화는 브라우저 안에서만 쓰이고, **사용자가 버튼을 누를 때만** GitHub 이슈 작성 화면으로 넘어갑니다. 올라온 질문 목록: [label:from-site](https://github.com/CausalInferenceLab/policy-effect-analytics-agent/issues?q=label%3Afrom-site) + +## 어떻게 확인하나 (6단계) | 단계 | 하는 일 | |---|---| -| 1. 찾기 | 뉴스·SNS에서 사람들이 궁금해하는 **주제**를 찾습니다 | +| 1. 찾기 | 사람들이 궁금해하는 **주제**를 고릅니다 | | 2. 모으기 | 그 주제의 정책을 **전부** 모읍니다. 어느 지역이 언제 시작했는지 | | 3. 거르기 | 세 가지를 확인합니다. 언제 시작했나 · 누가 받았나 · 무엇으로 재나 | | 4. 계획하기 | 데이터를 보기 전에 분석 계획(`plan.yaml`)을 먼저 커밋합니다 | | 5. 비교하기 | 정책을 받은 곳과 안 받은 곳의 변화를 비교합니다 | | 6. 말하기 | **효과 근거 있음 · 조건부 · 판단 불가** 중 하나로 씁니다 | -**왜 정책 하나가 아니라 주제로 보나?** 화제가 된 정책만 골라 분석하면, 결과를 보고 사례를 고르는 것과 같아져 효과가 부풀려집니다. 그래서 화제는 "어느 주제를 볼지"까지만 정하고, 그 주제의 정책을 모두 모아 비교합니다. - -## 사이트에서 볼 수 있는 것 - -- **지금 이슈**: 10·15 토지거래허가구역, 6·27 대출 한도, 민생회복 소비쿠폰, 고유가 피해지원금, K-패스 '모두의 카드' 등 6개 정책의 분석 가이드입니다. 누구와 비교할지, 어떤 방법을 쓸지, 어떤 데이터를 받을 수 있는지 정리했습니다. -- **데이터 지도**: 공공데이터 35개를 역할(누가 언제 받았나 / 무엇이 변했나 / 다른 요인), 단위, 받는 법으로 정리했습니다. 검색과 필터를 쓸 수 있습니다. -- **주제 8개**: 주제마다 정책 타임라인, 세 가지 확인 상태, 받을 수 있는 데이터, 분석 예시를 보여줍니다. +**왜 정책 하나가 아니라 주제로 보나?** 화제가 된 정책만 골라 분석하면 결과를 보고 사례를 고르는 것과 같아져 효과가 부풀려집니다. 그래서 화제(와 순위)는 "어느 주제를 볼지"까지만 정하고, 그 주제의 정책을 모두 모아 비교합니다. ## 5분 만에 돌려 보기 @@ -38,7 +54,7 @@ cd policy-effect-analytics-agent python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -e ".[dev]" -make flow CASE=cases/t3-land-permit-2025 # 토지거래허가구역 예시를 6단계로 실행 +make flow CASE=cases/t3-land-permit-2025 # 토지거래허가구역 예시를 6단계로 실행 python site/build.py && python -m http.server -d _site # 사이트를 http://localhost:8000 에서 보기 ``` @@ -46,37 +62,48 @@ API 키 없이 돌아갑니다. 실제 데이터를 받으려면 `.env.example` > 토지거래허가구역 예시는 지금 **시뮬레이션 데이터**입니다. 분석 과정을 보여주기 위한 것이고, 실제 정책 효과가 아닙니다. +## 참여하기 (멘티 한 사람 = 주제 하나) + +1. **주제 고르기**: 사이트 대화창·순위·지금 이슈에서 고르고 "케이스 제안" 이슈를 엽니다. +2. **계획 올리기**: `cp -r cases/_template cases/<내ID>-<주제>`로 내 폴더를 만들고 `plan.yaml`을 써서 PR을 올립니다. 대화창의 "분석 계획 초안"을 출발점으로 써도 됩니다. 쓰는 법: [계획 작성 가이드](docs/strategy/plan-guide.md) +3. **실행하고 공개**: `make flow CASE=cases/<내ID>-<주제>`로 돌리고 결과를 PR로 올립니다. 합쳐지면 사이트에 자동으로 올라옵니다. + +규칙은 세 가지입니다. 브랜치는 `<내ID>/<작업>`, 수정은 내 폴더만, 합치기는 리뷰 1명과 자동 검사 통과 뒤. 자세한 내용: [멘티 참여 가이드](docs/ops/mentee-guide.md) · [CONTRIBUTING](CONTRIBUTING.md) · [GitHub가 처음이라면](docs/ops/github-onboarding.md) + ## 폴더 안내 -| 폴더 | 하는 일 | 맡는 역할 | +| 폴더 | 하는 일 | 누가 고치나 | |---|---|---| -| [`catalog/`](catalog/) | 주제 · 이슈 · 데이터셋 목록 (데이터 지도) | 문제 정의 | -| [`core/discovery/`](core/discovery/) | 소셜 반응 → 주제 → 정책 · 데이터 | 문제 정의 | -| [`core/adapters/`](core/adapters/) | 공공데이터 · 법제처 수집기 | 데이터 수집 | -| [`core/estimators/`](core/estimators/) | 효과 계산 · 점검 · 판정 | 추정 | -| [`core/agent/`](core/agent/) | 6단계 실행, 계획 커밋 확인, 과장 표현 차단 | 리포트 · 에이전트 | -| [`cases/`](cases/) | 조별 분석 폴더 (`_template`에서 시작) | 조 전체 | -| [`site/`](site/) | 공개 사이트 (GitHub Pages) | 리포트 · 에이전트 | -| [`docs/`](docs/) | 국내 사례, 주제 고르기, 계획 작성법, 운영 가이드 | — | +| [`cases/`](cases/) | 멘티별 분석 폴더 (`<내ID>-<주제>`, `_template`에서 시작) | 폴더 주인 | +| [`catalog/`](catalog/) | 주제 · 이슈 · 데이터셋 목록 (데이터 지도) | 누구나 (PR) | +| [`site/`](site/) | 공개 사이트와 대화창(`ask.js`) | 메인테이너 | +| [`scripts/`](scripts/) | 조례 목록 · 순위 갱신, 라이선스 확인, 활동 확인 | 메인테이너 | +| [`core/`](core/) | 주제 찾기(`discovery`) · 수집기(`adapters`) · 계산(`estimators`) · 6단계 실행(`agent`) | 메인테이너 | +| [`docs/`](docs/) | 국내 사례, 주제 고르기, 계획 작성법, 운영 가이드 | 운영진 | -## 조별로 참여하기 +## 지금 상태와 필요한 것 -1. **주제 고르기**: 사이트의 [지금 이슈](https://causalinferencelab.github.io/policy-effect-analytics-agent/issues.html)나 주제 목록에서 고릅니다. -2. **계획 올리기**: `cp -r cases/_template cases/group1-<주제>`로 폴더를 만들고 `plan.yaml`을 써서 PR을 올립니다. 쓰는 법은 [계획 작성 가이드](docs/strategy/plan-guide.md)에 있습니다. -3. **실행하고 공개**: `make flow CASE=cases/group1-<주제>`로 돌리고 결과를 PR로 올립니다. 합쳐지면 사이트에 자동으로 올라옵니다. +- **완성**: 대화창(가이드 · 내 AI 키) · 대화 → 이슈 · 주제 순위 · 데이터 지도 · 6단계 실행 · 계획 커밋 확인 · 이중차분/단절 시계열 · 정책 지역이 적을 때의 무작위화 추론 · 과장 표현 차단 · 사이트 자동 공개(매일) +- **키 대기**: 국토부 실거래가(실제 데이터 전환) · 법제처 조례 자동 수집(`LAW_OC`) · 순위의 검색 관심도(`NAVER_CLIENT_ID/SECRET`, 선택). 모두 레포 Settings → Secrets에만 넣습니다. +- **결정 필요**: 키 없는 사람도 AI 대화를 쓰게 할지(사용량 제한이 있는 작은 중계 서버와 비용 부담 주체가 필요) +- **다음**: 시차 도입 이중차분(4주차) · 합성통제(5주차) · 공공데이터포털 검색을 공식 API로 전환 · 검색량으로 "미리 반응했나" 점검 -규칙은 세 가지입니다. 브랜치는 `group<번호>/<설명>`, 수정은 자기 조 폴더만, 합치기는 리뷰 1명과 자동 검사 통과 뒤입니다. 자세한 내용은 [CONTRIBUTING](CONTRIBUTING.md)과 [GitHub가 처음이라면](docs/ops/github-onboarding.md)을 보세요. +## 관련 프로젝트 -## 지금 상태 +겹치거나 부딪히는 부분이 있는지 확인했습니다. 라이선스 충돌은 없고, 서로 보완하는 관계입니다. -- **완성**: 주제 찾기 · 데이터 지도 · 6단계 실행 · 계획 커밋 확인 · 이중차분/단절 시계열 · 정책 지역이 적을 때의 무작위화 추론 · 과장 표현 차단 · 사이트 자동 공개(매주 월요일 갱신) -- **데이터 키 대기**: 국토부 실거래가(실제 데이터 전환) · 법제처 조례 자동 수집 -- **다음**: 시차 도입 이중차분(4주차) · 합성통제(5주차) · 검색량으로 "미리 반응했나" 점검 +| 프로젝트 | 무엇을 | 우리와의 관계 | +|---|---|---| +| [CAIS (causal-agent)](https://github.com/causalNLP/causal-agent), [Causal-Copilot](https://github.com/Lancelot39/Causal-Copilot) | LLM 인과 분석 에이전트 (MIT) | 범용 도구. 우리는 한국 공공데이터 · 정책 시작일 · 사전 등록에 집중 | +| [PublicDataReader](https://github.com/WooilJeong/PublicDataReader), [kpubdata](https://github.com/yeongseon/kpubdata), [data-go-mcp-servers](https://github.com/Koomook/data-go-mcp-servers) | 공공데이터 수집 라이브러리 · MCP 서버 (MIT · Apache-2.0) | 수집 층을 보완. 필요하면 가져다 씀 | +| [PolicyEngine](https://github.com/PolicyEngine/policyengine-core), [OpenFisca](https://github.com/openfisca/openfisca-core) | 세금·복지 제도 사전 시뮬레이션 (AGPL-3.0) | 목적이 다름. AGPL이라 코드를 가져오지 않음 | +| 국회예산정책처 · 기획재정부 재정사업 평가 | 공식 평가 | 우리 결과는 공식 평가가 아니며 그렇게 보이지 않게 표시 | -## 라이선스 +## 라이선스와 데이터 -코드는 [MIT](LICENSE)입니다. 데이터는 출처마다 이용 조건이 다르며, 데이터 지도와 각 케이스의 `plan.yaml`에 적어 둡니다. +코드는 [MIT](LICENSE)입니다. GPL·AGPL 패키지는 필수 의존성으로 넣지 않고 CI가 확인합니다(`scripts/check_licenses.py`). +데이터는 출처마다 이용 조건이 다르며 데이터 지도와 각 케이스의 `plan.yaml`에 적습니다. 원자료와 가공 데이터 파일은 레포에 올리지 않고 `fetch.py`로 각자 받습니다. --- -**English.** An open-source platform that checks whether Korean public policies people talk about actually worked. Public conversation only picks the *topic* (never a single trending policy, which would mean choosing cases by their outcomes); every policy in that topic is collected and checked on three gates (when / who / what). A pre-analysis plan must be committed before estimation, designs are chosen by rules rather than by an LLM, and results are published as a static site with a searchable data map and an analysis guide for currently debated policies. +**English.** An open-source platform that checks whether Korean public policies people talk about actually worked. A chat box on the site (rule-based by default, or your own AI key in the browser) turns a question into a topic, candidate datasets, pitfalls and a draft pre-analysis plan; with one click the conversation becomes a GitHub issue that maintainers fold into the catalog and cases, and site questions drive the "topics people are curious about" ranking. Public conversation only picks the *topic*; every policy in that topic is collected and checked on three gates (when / who / what). A pre-analysis plan must be committed before estimation, designs are chosen by rules rather than by an LLM, and results are published as a static site. Not an official government evaluation. diff --git a/cases/_example_night_clinic/README.md b/cases/_example_night_clinic/README.md index 26aafe8..85a83a4 100644 --- a/cases/_example_night_clinic/README.md +++ b/cases/_example_night_clinic/README.md @@ -19,8 +19,8 @@ python cases/_example_night_clinic/estimate.py # figures/*.png, report.md, res | `estimate.py` | `core.pipeline.run_plan` 실행 + 보조 DiD + 그림 + 리포트 | | `report.md` | 자동 생성 리포트 (판정·경고가 수치보다 먼저) | -## 우리 조 케이스로 바꾸려면 -1. 이 폴더를 `cases/<조이름>_<주제>/` 로 복사 (또는 `cases/_template/` 사용) +## 내 케이스로 바꾸려면 +1. 이 폴더를 `cases/<내ID>-<주제>/` 로 복사 (또는 `cases/_template/` 사용) 2. `plan.yaml` 을 먼저 쓴다 — 특히 `control.rationale` 과 `abstention` 은 데이터 보기 **전에** 확정 3. `fetch.py` 를 실데이터 어댑터로 교체하고 `synthetic_data: false`, `data_sources.license` 를 공공누리 유형으로 4. 실제 지정 시점이 지역마다 다르면 `treatment.first_treat_col` 사용 → 시차도입 경고가 뜨는 것이 정상. diff --git a/cases/_example_night_clinic/data/README.md b/cases/_example_night_clinic/data/README.md index d9d9bfb..d733068 100644 --- a/cases/_example_night_clinic/data/README.md +++ b/cases/_example_night_clinic/data/README.md @@ -5,7 +5,7 @@ | `panel.csv` | **합성** 시군구×연도 패널 (`region_id, year, treated, first_treat, night_ed_rate`) | `simulate_panel(seed=42)` / synthetic | | `panel.source.json` | 생성 파라미터·시각 (재현용) | — | -## 실데이터 후보 (조별 확인 필요 — 가용성·공간단위·라이선스 미검증) +## 실데이터 후보 (각자 확인 필요 — 가용성·공간단위·라이선스 미검증) - **처치 시점**: 보건복지부/지자체 달빛어린이병원 지정 현황 (공공데이터포털 파일데이터 또는 보도자료) - **결과변수**: 국가응급진료정보망(NEDIS) 기반 응급실 이용 통계 — 시군구·시간대·KTAS 단위 공개 여부 확인 필요. 공개 수준이 시도 단위뿐이면 클러스터가 17개 → `few_clusters` 경고 대상 diff --git a/cases/_template/README.md b/cases/_template/README.md index 9008e36..fdd710d 100644 --- a/cases/_template/README.md +++ b/cases/_template/README.md @@ -1,11 +1,11 @@ # 케이스 템플릿 ```bash -git switch -c group3/plan -cp -r cases/_template cases/group3-youth-rent # <조>-<주제>, 소문자-하이픈 +git switch -c <내ID>/plan +cp -r cases/_template cases/<내ID>-<주제> # 예: cases/gildong-local-currency (소문자-하이픈) ``` -복사한 뒤 이 README는 조의 메모로 바꿔도 됩니다. +복사한 뒤 이 README는 내 분석 메모로 바꿔도 됩니다. ## 4단계 @@ -17,8 +17,8 @@ cp -r cases/_template cases/group3-youth-rent # <조>-<주제>, 소문자-하 | 4. 리포트 | `report.md` | 결과·한계·시사점 | `make app`에서 카드로 보임 | ```bash -python cases/group3-youth-rent/fetch.py -python cases/group3-youth-rent/estimate.py +python cases/<내ID>-<주제>/fetch.py +python cases/<내ID>-<주제>/estimate.py make app ``` diff --git a/catalog/README.md b/catalog/README.md index 2bab4c7..570183c 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -14,7 +14,7 @@ | `topics.yaml` | 주제 8개. 키워드, 정책 시점, 세 가지 확인(언제·누가·무엇으로) | 새 주제를 제안할 때 | | `issues.yaml` | 지금 이슈인 정책 6개. 시작일(공식 출처), 비교 방법, 쓸 데이터, 조심할 점 | 새 이슈를 분석하고 싶을 때 | | `datasets.yaml` | 공공데이터 35개. 역할·단위·받는 법·이용 조건·확인 날짜 | 쓸 만한 데이터를 찾았을 때 | -| `policies.yaml` | 분석 예시가 있는 정책의 설계 정보 | 조가 분석 케이스를 만들 때 | +| `policies.yaml` | 분석 예시가 있는 정책의 설계 정보 | 멘티가 분석 케이스를 만들 때 | ## 데이터셋의 세 가지 역할 diff --git a/catalog/issues.yaml b/catalog/issues.yaml index aa2ec3e..c9cee84 100644 --- a/catalog/issues.yaml +++ b/catalog/issues.yaml @@ -1,6 +1,6 @@ # 지금 이슈인 정책 — 효과 추정 가이드 # 이슈는 '주제'에 속합니다(topic). 분석은 이슈 하나가 아니라 그 주제의 정책 전체를 대상으로 하되, -# 이 목록은 조가 어디서부터 볼지 정하는 출발점입니다. 시행일은 모두 source 링크에서 확인했습니다. +# 이 목록은 멘티가 어디서부터 볼지 정하는 출발점입니다. 시행일은 모두 source 링크에서 확인했습니다. # design: did_simultaneous(동시 도입 이중차분) | did_staggered(시차 도입) | scm(합성통제) | its(단절 시계열) # datasets: catalog/datasets.yaml 의 id version: 1 diff --git a/docs/ops/github-onboarding.md b/docs/ops/github-onboarding.md index b923711..d881e84 100644 --- a/docs/ops/github-onboarding.md +++ b/docs/ops/github-onboarding.md @@ -1,6 +1,6 @@ # GitHub 온보딩 (처음 쓰는 분용) -명령어는 그대로 복사해서 실행하세요. `group3`, `youth-rent`만 자기 조/주제로 바꾸면 됩니다. +명령어는 그대로 복사해서 실행하세요. `gildong`은 자기 GitHub ID로, `local-currency`는 자기 주제로 바꾸면 됩니다. ## 0. 준비 (1회) @@ -37,18 +37,18 @@ make check # 통과하면 준비 끝 ```bash git switch main git pull -git switch -c group3/plan +git switch -c gildong/plan ``` ## 4. 작업하고 커밋하기 ```bash -cp -r cases/_template cases/group3-youth-rent # 첫 작업일 때만 +cp -r cases/_template cases/gildong-local-currency # 첫 작업일 때만 # ... plan.yaml 수정 ... git status # 무엇이 바뀌었는지 확인 -git add cases/group3-youth-rent -git commit -m "plan(group3-youth-rent): 질문과 처치·대조 정의" -git push -u origin group3/plan # 두 번째부터는 git push +git add cases/gildong-local-currency +git commit -m "plan(gildong-local-currency): 질문과 처치·대조 정의" +git push -u origin gildong/plan # 두 번째부터는 git push ``` - `git add .` 대신 **자기 폴더만** add 하는 습관을 들이세요 (`.env`, 원자료 실수 방지). @@ -62,14 +62,14 @@ gh pr create --draft --fill # 아직 작업 중이면 Draft 1. PR 본문 체크리스트를 채웁니다. 2. CI(초록 체크)를 기다립니다. 빨간 X면 `Details`를 눌러 로그를 확인하고, 고쳐서 다시 push 하면 PR이 자동 갱신됩니다. -3. 조원 또는 멘토 1명 **Approve** 후 **Squash and merge**. +3. 멘토 또는 다른 멘티 1명 **Approve** 후 **Squash and merge**. ## 6. 병합 후 정리 ```bash git switch main git pull -git branch -d group3/plan +git branch -d gildong/plan ``` ## 자주 막히는 곳 @@ -80,5 +80,5 @@ git branch -d group3/plan | `rejected ... fetch first` | `git pull --rebase` 후 다시 push | | 충돌(conflict) | 충돌 파일에서 `<<<<<<<` 구간 정리 → `git add 파일` → `git rebase --continue` | | `.env`를 실수로 커밋 | push 전이면 `git reset HEAD~1`. **push 했다면 즉시 키를 재발급**하고 운영진에게 알림 | -| main에 커밋해버림 | `git switch -c group3/fix` 로 브랜치를 만든 뒤 push (main은 보호되어 push 안 됨) | +| main에 커밋해버림 | `git switch -c gildong/fix` 로 브랜치를 만든 뒤 push (main은 보호되어 push 안 됨) | | CI의 ruff 에러 | 로컬에서 `make format` 후 다시 커밋 | diff --git a/docs/ops/group-guide.md b/docs/ops/group-guide.md deleted file mode 100644 index 7c630fe..0000000 --- a/docs/ops/group-guide.md +++ /dev/null @@ -1,59 +0,0 @@ -# 조별 운영 가이드 - -조(약 5명)마다 **주제 하나**를 맡아, 공용 저장소의 `cases/<조-주제>/` 폴더에 분석을 쌓습니다. -조원 역할은 6단계 흐름(찾기 → 모으기 → 거르기 → 계획하기 → 비교하기 → 말하기)에 맞춰 나눕니다. - -**먼저 볼 것** -- [지금 이슈](https://causalinferencelab.github.io/policy-effect-analytics-agent/issues.html): 논쟁 중인 정책 6개의 분석 가이드 (비교 방법, 받을 수 있는 데이터, 조심할 점) -- [데이터 지도](https://causalinferencelab.github.io/policy-effect-analytics-agent/data.html): 공공데이터 35개를 역할·단위·받는 법으로 정리 -- 분석 예시: [`cases/t3-land-permit-2025/`](../../cases/t3-land-permit-2025/) (토지거래허가구역, 지금은 시뮬레이션 데이터) -- 계획 쓰는 법: [`docs/strategy/plan-guide.md`](../strategy/plan-guide.md) · 주제 후보 더 보기: [`docs/strategy/topic-guide.md`](../strategy/topic-guide.md) - -## 1. 역할 (5인 기준, 겸임 가능) - -| 역할 | 맡는 단계 | 주 산출물 | -|---|---|---| -| **문제 정의** (조장 겸임 권장) | ① 문제 정의 | `plan.yaml` — 질문·처치·대조·결과지표·가정·보류 규칙 | -| **데이터 수집** | ② 수집 | `fetch.py`, `data/README.md` (출처·라이선스·수집일) | -| **지표·품질** | ③ 지표 구조화 | 정제 패널(`data/panel.csv`), 품질 점검 통과 | -| **추정** | ④ 효과 추정 | `estimate.py`, 사전추세·placebo 결과 | -| **리포트·에이전트** | ⑤ 과잉해석 방지, ⑥ 리포트 | `report.md`, 그림, `flow_log.json` | - -4명 이하면 **수집 + 지표·품질**, **추정 + 리포트**를 한 사람이 맡습니다. 역할은 주마다 바꿔도 됩니다. - -## 2. 주차별 마일스톤 - -| 모임 | 주차 | 조가 끝낼 것 | 인증(커밋/PR) | -|---|---|---|---| -| 9.27(일) | W2 | 조 편성, 주제 후보 2개 선택, 데이터 접근 확인(API 키 발급) | `cases/<조>/` 폴더 + 이슈 "케이스 제안" | -| 10.2(금) 오프라인 | — | 주제 1개 확정, **`plan.yaml` 사전 등록 PR** | plan.yaml PR 병합 | -| 10.4(일) | W3 | `fetch.py` 동작, ③ 품질 점검 통과 | 수집·정제 PR | -| 10.11(일) | W4 | ④ 1차 추정 + 사전추세 그림 | 추정 PR | -| 10.18(일) | W5 | 반박 검정 추가, 에이전트 Flow로 ①~⑥ 한 번에 실행 | `flow_log.json` 커밋 | -| 10.25(일) | W6 | ⑤ 가드 통과(과잉 인과 표현 제거), 판정 문구 확정 | 리포트 PR | -| 11.1(일) | W7 | 다른 조가 README만 보고 재현 성공 → 공개 | 교차 재현 결과 이슈 | - -Level 1(정리된 데이터 + 문서)에서 멈춰도 산출물입니다. 판정이 "식별 불가"로 나와도 근거가 명확하면 좋은 결과입니다. - -## 3. 브랜치·PR 규칙 - -- 브랜치: `group/<짧은-설명>` (예: `group2/fetch-airkorea`). `main`에 직접 push 하지 않습니다. -- 수정 범위: **자기 조 `cases/<조>/` 폴더만.** `core/` 수정이 필요하면 이슈를 먼저 엽니다. -- **사전 등록**: `plan.yaml`을 먼저 PR로 병합한 뒤 데이터를 봅니다. Flow ①단계가 커밋되지 않은 plan.yaml을 막습니다. -- PR 하나에 한 단계. 리뷰 1명 승인 + CI 통과 후 병합(Squash). -- 올리면 안 되는 것: API 키(`.env`), 원자료 대용량 파일(`data/raw/`), 개인정보가 담긴 데이터, 재배포가 금지된 데이터(라이선스 확인). - -## 4. 실행 방법 - -```bash -make install # 최초 1회 -cp -r cases/_template cases/group1-topic # 조 폴더 만들기 -make flow CASE=cases/group1-topic # ①~⑥ 실행 (plan.yaml 커밋 후) -make app # Streamlit → Flow 페이지에서 단계별 결과 확인 -``` - -## 5. 활동 인증 - -- 매 모임 전까지 조 브랜치에 커밋 1회 이상 + PR 링크를 디스코드 조 채널에 공유합니다. -- 멘토는 `python scripts/weekly_activity.py`로 조별 커밋·변경 파일을 확인합니다 ([monitoring.md](monitoring.md)). -- 조모임은 전체 모임과 별도로 자유롭게 잡고, 결정 사항은 PR 설명이나 이슈에 남깁니다. diff --git a/docs/ops/mentee-guide.md b/docs/ops/mentee-guide.md new file mode 100644 index 0000000..cfc9cb2 --- /dev/null +++ b/docs/ops/mentee-guide.md @@ -0,0 +1,54 @@ +# 멘티 참여 가이드 + +이 프로젝트는 **한 사람이 주제 하나**를 맡아 진행합니다. 각자 공용 저장소의 `cases/<내 GitHub ID>-<주제>/` 폴더에 분석을 쌓고, 합쳐지면 [사이트](https://causalinferencelab.github.io/policy-effect-analytics-agent/)에 자동으로 올라갑니다. + +## 먼저 볼 것 + +- [사이트 첫 화면](https://causalinferencelab.github.io/policy-effect-analytics-agent/): 궁금한 정책을 적으면 주제·데이터·분석 방법을 함께 정리해 주는 대화창이 있습니다. +- [지금 이슈](https://causalinferencelab.github.io/policy-effect-analytics-agent/issues.html): 논쟁 중인 정책 6개의 분석 가이드 (비교 방법, 받을 수 있는 데이터, 조심할 점) +- [데이터 지도](https://causalinferencelab.github.io/policy-effect-analytics-agent/data.html): 공공데이터 35개를 역할·단위·받는 법으로 정리 +- 분석 예시: [`cases/t3-land-permit-2025/`](../../cases/t3-land-permit-2025/) (토지거래허가구역, 지금은 시뮬레이션 데이터) +- 계획 쓰는 법: [`docs/strategy/plan-guide.md`](../strategy/plan-guide.md) + +## 혼자서 끝까지 가는 6단계 + +| 단계 | 할 일 | 결과물 | +|---|---|---| +| 1. 찾기 | 사이트 대화창이나 지금 이슈에서 주제를 고릅니다 | GitHub 이슈 "케이스 제안" | +| 2. 모으기 | 그 주제의 정책을 모두 모읍니다. 어느 지역이 언제 시작했는지 | 정책 목록 표 | +| 3. 거르기 | 언제 시작했나 · 누가 받았나 · 무엇으로 재나를 확인합니다 | 이슈에 확인 결과 기록 | +| 4. 계획하기 | 데이터를 보기 전에 `plan.yaml`을 쓰고 PR로 올립니다 | 계획 PR | +| 5. 비교하기 | `fetch.py`로 데이터를 받고 `make flow`로 실행합니다 | 수집·분석 PR | +| 6. 말하기 | 판정(효과 근거 있음 · 조건부 · 판단 불가)과 조심할 점을 리포트에 씁니다 | `report.md` | + +"판단 불가"도 좋은 결과입니다. 왜 판단할 수 없는지가 분명하면 됩니다. + +## 주차별 목표 + +| 모임 | 주차 | 끝낼 것 | +|---|---|---| +| 9.27(일) | 2주 | 주제 후보 2개 고르기, 데이터 키 신청(공공데이터포털 자동승인) | +| 10.2(금) 오프라인 | — | 주제 1개 확정, `plan.yaml` 계획 PR | +| 10.4(일) | 3주 | `fetch.py`로 데이터 받기, 품질 점검 통과 | +| 10.11(일) | 4주 | 1차 추정과 추세 그림 | +| 10.18(일) | 5주 | 점검 추가, `make flow`로 6단계 한 번에 실행 | +| 10.25(일) | 6주 | 과장 표현 점검 통과, 판정 문구 확정 | +| 11.1(일) | 7주 | 다른 멘티가 README만 보고 다시 돌려 보기 → 공개 | + +## 폴더와 브랜치 + +```bash +git switch -c <내ID>/plan # 브랜치: <내 GitHub ID>/<작업> +cp -r cases/_template cases/<내ID>-<주제> # 예: cases/gildong-local-currency +``` + +- 수정은 **내 폴더만** 합니다. `core/`를 고쳐야 하면 먼저 이슈를 엽니다. +- PR 하나에 한 단계만 담고, 멘토 또는 다른 멘티 1명 승인 + 자동 검사 통과 뒤 합칩니다. +- 올리면 안 되는 것: API 키(`.env`), 원자료와 가공 데이터 파일, 개인정보. 데이터는 `fetch.py`로 각자 받게 합니다. + +## 활동 인증 + +- 매 모임 전까지 내 브랜치에 커밋 1회 이상, PR 링크를 디스코드에 공유합니다. +- 멘토는 `python scripts/weekly_activity.py`로 케이스 폴더별 커밋을 확인합니다. + +GitHub가 처음이라면 [github-onboarding.md](github-onboarding.md)를 따라 하세요. diff --git a/docs/ops/monitoring.md b/docs/ops/monitoring.md index 067fe28..e6df4e3 100644 --- a/docs/ops/monitoring.md +++ b/docs/ops/monitoring.md @@ -1,63 +1,49 @@ -# 활동 모니터링 (멘토·PM용) +# 활동 확인 (멘토용) -원칙: **커밋·PR이 곧 출석**입니다. 조별로 주 1회 이상 의미 있는 커밋과 PR 흐름이 보이면 건강한 상태입니다. +원칙: **커밋과 PR이 곧 출석**입니다. 멘티마다 주 1회 이상 의미 있는 커밋과 PR 흐름이 보이면 건강한 상태입니다. -## 1. 로컬 스크립트 (네트워크 불필요) +## 로컬에서 한 번에 보기 ```bash -git fetch --all --prune # 병합 전 브랜치까지 포함하려면 먼저 -python scripts/weekly_activity.py # 최근 7일, 모든 브랜치 -python scripts/weekly_activity.py --days 14 --ref origin/main # main에 반영된 것만 -make activity +git fetch --all +python scripts/weekly_activity.py # 최근 7일, 모든 브랜치 +python scripts/weekly_activity.py --days 14 --ref origin/main ``` -출력: `cases/<조>`별 커밋 수·작성자 수·변경 파일 수·추가/삭제 줄 수. 활동 없는 조는 `<- no activity` 표시. +출력: 케이스 폴더(`cases/-<주제>`)별 커밋 수 · 작성자 · 변경 파일 수 · 추가/삭제 줄 수. 활동이 없는 폴더는 `<- no activity`로 표시됩니다. -## 2. `gh` CLI 한 줄 명령 +## gh 명령으로 보기 ```bash R=CausalInferenceLab/policy-effect-analytics-agent -SINCE=$(date -d '7 days ago' +%F 2>/dev/null || date -v-7d +%F) # Linux || macOS +SINCE=$(date -d '7 days ago' +%F) -# 이번 주 열린/병합된 PR -gh pr list -R $R --state all --search "created:>=$SINCE" --limit 100 -gh pr list -R $R --state merged --search "merged:>=$SINCE" +# 열린 PR 전체 +gh pr list -R $R --state open -# 리뷰 대기 중인 PR (오래된 순) -gh pr list -R $R --search "is:open review:required sort:created-asc" +# 한 멘티의 브랜치 PR (브랜치 이름 = /<작업>) +gh pr list -R $R --state all --json headRefName,author,title,state \ + --jq '.[] | select(.headRefName|startswith("gildong/")) | [.state,.author.login,.title] | @tsv' -# 조별 브랜치의 PR (브랜치 prefix = 조) -gh pr list -R $R --state all --json headRefName,title,state,author \ - --jq '.[] | select(.headRefName|startswith("group3/")) | [.state,.author.login,.title] | @tsv' +# 한 멘티 폴더의 최근 커밋 +gh api "repos/$R/commits?path=cases/gildong-local-currency&since=${SINCE}T00:00:00Z" \ + --jq '.[] | [.commit.author.date,.commit.author.name,.commit.message] | @tsv' -# 이번 주 main 커밋 작성자별 수 -gh api "repos/$R/commits?since=${SINCE}T00:00:00Z&per_page=100" --paginate \ - --jq '.[].author.login' | sort | uniq -c | sort -rn - -# 특정 조 폴더의 커밋 -gh api "repos/$R/commits?path=cases/group3-youth-rent&since=${SINCE}T00:00:00Z" \ - --jq '.[] | [.commit.author.date[:10], .author.login, .commit.message] | @tsv' - -# CI 실패 현황 -gh run list -R $R --status failure --limit 20 - -# 케이스 제안 이슈 -gh issue list -R $R --label case-proposal --state all +# 사이트 대화창에서 올라온 질문 +gh issue list -R $R --label from-site ``` -## 3. "건강함"의 기준 +## 건강 신호 -| 지표 | 건강 | 주의 (멘토링 필요) | +| 항목 | 건강 | 확인 필요 | |---|---|---| -| 조별 주간 커밋 | 3개 이상, 2명 이상 작성자 | 0개 또는 1명만 커밋 | -| PR 흐름 | 주 1개 이상 병합 | 7일 넘게 열린 PR, 리뷰 없음 | -| CI | 병합 전 초록 | 같은 PR에서 3회 이상 연속 실패 | -| 사전 등록 | 2주차에 `plan.yaml` 병합 | 결과 그림이 plan.yaml보다 먼저 커밋 | -| 데이터 위생 | `data_sources.license` 기재 | 대용량/원자료 커밋, `.env` 흔적 | - -## 4. 주간 루틴 (15분) - -1. `git fetch --all && make activity` → 조용한 조 확인 -2. `gh pr list ... review:required` → 오래된 PR 리뷰 배정 -3. `gh run list --status failure` → 반복 실패 조에 도움 요청 코멘트 -4. 결과를 주간 공지(노션/채널)에 3줄 요약 +| 주간 커밋 | 2개 이상 | 0개 | +| 계획 PR | 10.2까지 `plan.yaml` 합쳐짐 | 계획 없이 결과 먼저 | +| 리뷰 | PR이 3일 안에 리뷰됨 | 1주 넘게 대기 | +| CI | 초록 | 빨간 채로 방치 | + +## 주간 루틴 + +1. `git fetch --all && python scripts/weekly_activity.py` → 조용한 폴더 확인 +2. 열린 PR 리뷰, 3일 넘은 PR에 코멘트 +3. `from-site` 라벨 이슈 훑어보기 → 좋은 질문은 `catalog/`로 옮기기 diff --git a/docs/strategy/korea-cases.md b/docs/strategy/korea-cases.md index 7f2fcc6..6e35532 100644 --- a/docs/strategy/korea-cases.md +++ b/docs/strategy/korea-cases.md @@ -1,6 +1,6 @@ # 국내 정책효과 분석 사례 + 재현용 해외 교과서 사례 -> 목적: 멘티 조가 "누가 이미 이 질문을 어떤 방법·데이터로 풀었나"를 5분 안에 파악하고, **오픈데이터만으로 4주 안에 재현·확장 가능한지** 판단하도록 돕는다. +> 목적: 멘티가 "누가 이미 이 질문을 어떤 방법·데이터로 풀었나"를 5분 안에 파악하고, **오픈데이터만으로 4주 안에 재현·확장 가능한지** 판단하도록 돕는다. > 작성: Strategy, 2026-09-24. 링크는 모두 직접 확인한 것. "초록 미확인"은 제목/서지만 확인하고 본문을 열지 못한 경우(KCI·DBpia는 자동 접근 차단). ## 0. 한눈에 보기 diff --git a/docs/strategy/plan-guide.md b/docs/strategy/plan-guide.md index 74b6e89..b5b0cfe 100644 --- a/docs/strategy/plan-guide.md +++ b/docs/strategy/plan-guide.md @@ -5,7 +5,7 @@ ## 1. 필수 필드와 결정 기준 -| 필드 | 조가 결정할 것 | 최소 기준 | OES 대응 섹션 | +| 필드 | 멘티가 결정할 것 | 최소 기준 | OES 대응 섹션 | |---|---|---|---| | `question` | 한 문장의 인과 질문 | "X가 Y를 (얼마나) 바꿨나" 형식. 추정대상(estimand: ATT/ATE/효과 시점)을 명시 | Project description / Hypotheses | | `unit` | 분석 단위 × 시간 단위 | 처치가 배정되는 단위보다 작으면 클러스터링 수준을 따로 적는다 | Data structure | diff --git a/docs/strategy/topic-guide.md b/docs/strategy/topic-guide.md index 95266ca..80842fd 100644 --- a/docs/strategy/topic-guide.md +++ b/docs/strategy/topic-guide.md @@ -1,7 +1,7 @@ -# 주제 선정 가이드 (조별 case 후보 8선) +# 주제 선정 가이드 (케이스 후보 8선) > 기준: ① 처치 시점·대상이 공개 자료로 명확하다. ② 결과지표가 **오픈데이터**이고 API나 파일로 받을 수 있다. ③ 대조군을 만들 수 있다. ④ 4주 안에 fetch → estimate → report까지 갈 수 있다. -> 검증 표기: ✅ 이번에 데이터 페이지를 직접 열어 확인함 / ⚠️ 존재는 확인했지만 세부(기간·컬럼·ID)는 미확인 / ❓ 미확인이므로 조가 1주차에 확인할 것. +> 검증 표기: ✅ 이번에 데이터 페이지를 직접 열어 확인함 / ⚠️ 존재는 확인했지만 세부(기간·컬럼·ID)는 미확인 / ❓ 미확인이므로 맡은 멘티가 1주차에 확인할 것. > 사례 근거는 [korea-cases.md](korea-cases.md) 참조. ## 요약표 @@ -81,7 +81,7 @@ - **처치/대조**: 스쿨존 안 vs 밖의 어린이 사고. 또는 단속카메라 설치 스쿨존 vs 미설치 스쿨존. - **데이터**: 전국어린이보호구역표준데이터 ✅ [15012891](https://www.data.go.kr/data/15012891/standard.do) — 위경도, CCTV 설치 여부·대수, 반기 갱신. 단, **지정일·설치일 컬럼은 확인되지 않음 ❓**(시점 식별에 치명적). 사고는 TAAS ⚠️. - **핵심 함정**: 시행 시점이 **코로나19 등교 중단(2020.3~)과 정확히 겹친다.** 어린이 통행 자체가 줄었으므로 노출량(exposure)을 통제할 수 없으면 식별이 불가능하다. 선행 DiD도 유의하지 않았다([규제연구 2024](https://journal.kci.go.kr/ksrs2002/archive/articlePdf?artiId=ART003165741)). -- **용도**: 에이전트가 **"식별 불가"를 선언하는 golden case**로 쓴다. 조 주제로 고른다면 "2021~ 등교 정상화 이후 카메라 설치 시차"로 질문을 좁혀야 한다. +- **용도**: 에이전트가 **"식별 불가"를 선언하는 golden case**로 쓴다. 개인 주제로 고른다면 "2021~ 등교 정상화 이후 카메라 설치 시차"로 질문을 좁혀야 한다. ## T6. 윤창호법 → 음주운전 사고 ★ (입문) - **질문**: 제1(2018.12.18)·제2(2019.6.25) 윤창호법이 음주운전 사고를 지속적으로 줄였나? @@ -107,7 +107,7 @@ --- -## 선택 체크리스트 (조별 1주차) +## 선택 체크리스트 (1주차) 1. 처치 시점을 **날짜 단위로** 표에 적을 수 있는가? (못 적으면 탈락) 2. 결과 데이터를 실제로 1회 호출하거나 다운로드해 사전 기간 최소 8개 시점(월/분기)을 확보했는가? 3. 대조군 후보가 3개 이상이고, 사전추세 그림을 그릴 수 있는가? diff --git a/scripts/check_licenses.py b/scripts/check_licenses.py new file mode 100644 index 0000000..02e11e6 --- /dev/null +++ b/scripts/check_licenses.py @@ -0,0 +1,96 @@ +"""이 프로젝트가 끌어오는 파이썬 패키지 중 GPL·AGPL 라이선스가 있는지 확인한다 (CI에서 실행). + +이 프로젝트는 MIT입니다. GPL·AGPL 패키지를 필수 의존성으로 넣으면 배포 조건이 바뀔 수 있어 막습니다. +- 확인 범위: policy-effect-analytics-agent 와 선택 설치(agent, causal)가 끌어오는 의존성 전체 (설치된 것만) +- 허용: MIT·BSD·Apache 등, 그리고 LGPL·MPL 같은 약한 카피레프트, 여러 라이선스 중 고를 수 있는 경우(예: Apache 또는 GPL) +- 꼭 필요하면 선택 설치로 분리하고 ALLOW 에 이유와 함께 적습니다. + + python scripts/check_licenses.py +""" + +from __future__ import annotations + +import re +from importlib.metadata import PackageNotFoundError, distribution + +from packaging.requirements import Requirement + +ROOT_PKG = "policy-effect-analytics-agent" +EXTRAS = {"agent", "causal"} +ALLOW: dict[str, str] = {} # {"패키지 이름": "허용 이유"} + +STRONG = re.compile(r"\bA?GPL\b|GNU (Affero )?General Public License", re.I) +PERMISSIVE = re.compile( + r"\bMIT\b|\bBSD\b|Apache|\bISC\b|\bPSF\b|Python Software Foundation|MPL|Mozilla", re.I +) + + +def canon(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def license_text(dist) -> str: + md = dist.metadata + parts = [ + md.get("License-Expression") or "", + (md.get("License") or "").splitlines()[0][:120] if md.get("License") else "", + ] + parts += [ + c.split("::")[-1].strip() + for c in md.get_all("Classifier") or [] + if c.startswith("License ::") + ] + return " | ".join(p for p in parts if p) + + +def is_blocked(text: str) -> bool: + """강한 카피레프트만 있고 다른 선택지가 없으면 막는다. LGPL(Lesser)은 허용.""" + strong = STRONG.sub( + "", re.sub(r"LGPL|Lesser General Public License|Library or Lesser", "", text, flags=re.I) + ) + if strong == re.sub( + r"LGPL|Lesser General Public License|Library or Lesser", "", text, flags=re.I + ): + return False # GPL·AGPL 언급 없음 + return not PERMISSIVE.search(text) + + +def walk(root: str, extras: set[str]) -> dict[str, str]: + seen: dict[str, str] = {} + stack: list[tuple[str, set[str]]] = [(root, extras)] + while stack: + name, ex = stack.pop() + key = canon(name) + if key in seen: + continue + try: + dist = distribution(name) + except PackageNotFoundError: + continue + seen[key] = license_text(dist) + for raw in dist.requires or []: + req = Requirement(raw) + env_ok = req.marker is None or any( + req.marker.evaluate({"extra": e}) for e in (ex or {""}) + ) + if env_ok: + stack.append((req.name, set(req.extras))) + return seen + + +def main() -> int: + tree = walk(ROOT_PKG, EXTRAS) + if not tree: + print(f"{ROOT_PKG} 가 설치되어 있지 않습니다: pip install -e .") + return 1 + bad = [f"{n}: {t}" for n, t in sorted(tree.items()) if n not in ALLOW and is_blocked(t)] + if bad: + print("GPL·AGPL 라이선스 의존성이 있습니다 (CONTRIBUTING.md 6절 참고):") + print("\n".join(f" - {b}" for b in bad)) + return 1 + print(f"라이선스 확인: 의존성 {len(tree)}개 중 GPL·AGPL 없음") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/refresh_trends.py b/scripts/refresh_trends.py new file mode 100644 index 0000000..e076bf7 --- /dev/null +++ b/scripts/refresh_trends.py @@ -0,0 +1,189 @@ +"""'요즘 궁금해하는 주제' 순위의 재료를 모은다 → catalog/snapshots/trends.json + +두 가지 신호를 쓰고, 없는 신호는 비워 둔다(지어내지 않는다). + 1. 사이트 질문 수 — 사이트 대화창에서 올린 GitHub 이슈(라벨 from-site) 중 최근 30일, 주제별 개수. + GITHUB_TOKEN, GITHUB_REPOSITORY 가 있을 때만 (Actions 에서는 자동으로 있음). + 2. 검색 관심도 — 네이버 데이터랩 검색어 트렌드, 최근 4주 평균(가장 높은 주제 = 100). + NAVER_CLIENT_ID, NAVER_CLIENT_SECRET 시크릿이 있을 때만. +둘 다 없으면 사이트는 '최근 시행·발표 순'으로 보여 주고 그렇게 표시한다. + + python scripts/refresh_trends.py +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from collections import Counter +from datetime import UTC, date, datetime, timedelta +from pathlib import Path + +import requests + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from core.discovery import load_topics # noqa: E402 + +OUT = ROOT / "catalog" / "snapshots" / "trends.json" +DAYS = 30 +NAVER_URL = "https://openapi.naver.com/v1/datalab/search" + + +# ─── 1. 사이트 질문 ───────────────────────────────────────────────────────── +TOPIC_FIELD = re.compile(r"###\s*관련 주제\s*\n+(.+)") +ID_IN_PARENS = re.compile(r"\(([a-z0-9-]+)\)\s*$") + + +def topic_of(body: str, topic_ids: set[str]) -> str | None: + """이슈 본문의 '관련 주제' 칸에서 주제 id를 꺼낸다. 없거나 모르는 값이면 None(=새 주제).""" + m = TOPIC_FIELD.search(body or "") + if not m: + return None + val = m.group(1).strip() + idm = ID_IN_PARENS.search(val) + if idm and idm.group(1) in topic_ids: + return idm.group(1) + return val if val in topic_ids else None + + +def count_site_questions(issues: list[dict], topic_ids: set[str]) -> tuple[Counter, int]: + counts: Counter = Counter() + new = 0 + for it in issues: + if "pull_request" in it: + continue + tid = topic_of(it.get("body") or "", topic_ids) + if tid: + counts[tid] += 1 + else: + new += 1 + return counts, new + + +def fetch_site_issues() -> list[dict] | None: + token, repo = os.getenv("GITHUB_TOKEN"), os.getenv("GITHUB_REPOSITORY") + if not (token and repo): + return None + since = (datetime.now(UTC) - timedelta(days=DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ") + out, page = [], 1 + while page <= 5: + r = requests.get( + f"https://api.github.com/repos/{repo}/issues", + params={ + "labels": "from-site", + "state": "all", + "since": since, + "per_page": 100, + "page": page, + }, + headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}, + timeout=20, + ) + r.raise_for_status() + batch = r.json() + out += [i for i in batch if i.get("created_at", "") >= since] + if len(batch) < 100: + break + page += 1 + return out + + +# ─── 2. 네이버 데이터랩 ────────────────────────────────────────────────────── +def naver_groups(topics) -> list[dict]: + """주제마다 검색어 묶음 하나(최대 5개 단어).""" + return [{"groupName": t.id, "keywords": t.keywords[:5]} for t in topics] + + +def combine_batches(batches: list[dict[str, float]], anchor: str) -> dict[str, float]: + """요청마다 기준(100)이 달라지므로, 모든 요청에 넣은 anchor 주제 값으로 눈금을 맞춘 뒤 최대=100으로 다시 맞춘다.""" + ref = batches[0].get(anchor, 0.0) + merged: dict[str, float] = {} + for b in batches: + a = b.get(anchor, 0.0) + scale = (ref / a) if a else 0.0 + for k, v in b.items(): + merged.setdefault(k, v * scale) + top = max(merged.values(), default=0.0) + return {k: round(100 * v / top, 1) if top else 0.0 for k, v in merged.items()} + + +def fetch_naver(topics) -> dict[str, float] | None: + cid, sec = os.getenv("NAVER_CLIENT_ID"), os.getenv("NAVER_CLIENT_SECRET") + if not (cid and sec): + return None + end = date.today() + start = end - timedelta(weeks=4) + groups = naver_groups(topics) + anchor, rest = groups[0], groups[1:] + batches: list[dict[str, float]] = [] + chunks = [rest[i : i + 4] for i in range(0, len(rest), 4)] or [[]] + for chunk in chunks: + body = { + "startDate": start.isoformat(), + "endDate": end.isoformat(), + "timeUnit": "week", + "keywordGroups": [anchor, *chunk], + } + r = requests.post( + NAVER_URL, + json=body, + headers={"X-Naver-Client-Id": cid, "X-Naver-Client-Secret": sec}, + timeout=20, + ) + r.raise_for_status() + batches.append( + { + res["title"]: ( + sum(d["ratio"] for d in res["data"]) / len(res["data"]) if res["data"] else 0.0 + ) + for res in r.json()["results"] + } + ) + return combine_batches(batches, anchor["groupName"]) + + +def main() -> int: + topics = load_topics() + ids = {t.id for t in topics} + data: dict = { + "updated": date.today().isoformat(), + "window_days": DAYS, + "sources": [], + "topics": {}, + } + + try: + issues = fetch_site_issues() + except requests.RequestException as e: + print(f"사이트 질문 수집 실패(건너뜀): {e}") + issues = None + if issues is not None: + counts, new = count_site_questions(issues, ids) + data["sources"].append("site_questions") + data["new_topic_questions"] = new + for t in ids: + data["topics"].setdefault(t, {})["questions"] = counts.get(t, 0) + print(f"사이트 질문 {len(issues)}건 (새 주제 {new}건)") + + try: + search = fetch_naver(topics) + except (requests.RequestException, KeyError, ValueError) as e: + print(f"네이버 데이터랩 실패(건너뜀): {e}") + search = None + if search is not None: + data["sources"].append("naver_datalab") + for t, v in search.items(): + data["topics"].setdefault(t, {})["search"] = v + print("네이버 데이터랩 검색 관심도 반영") + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"→ {OUT.relative_to(ROOT)} (신호: {', '.join(data['sources']) or '없음'})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/weekly_activity.py b/scripts/weekly_activity.py index 85ba521..f816ec2 100644 --- a/scripts/weekly_activity.py +++ b/scripts/weekly_activity.py @@ -1,4 +1,4 @@ -"""Print commits and files changed per cases/ over the last N days. +"""Print commits and files changed per cases/- (one folder per mentee) over the last N days. Uses local `git log` only (no network). Run `git fetch --all` first to include branches that have not been merged yet. diff --git a/site/architecture.py b/site/architecture.py index 1a32b64..5940483 100644 --- a/site/architecture.py +++ b/site/architecture.py @@ -1,4 +1,4 @@ -"""구조 페이지 본문 — 과정 목표에서 출발해 어떤 판단을 거쳐 지금 구조가 됐는지 순서대로 보여준다.""" +"""구조 페이지 본문 — 한 장 그림 → 층 → 결정 → 필요한 것 → 폴더 → 상태 → 관련 오픈소스 순서로 보여준다.""" from __future__ import annotations @@ -10,14 +10,91 @@ ("누구나 바로 쓰는 오픈소스", "GitHub와 분석 사이트로 공유"), ] +# 한 장 그림: 두 개의 고리. (이름, 설명, 코드 위치) +LOOP_TALK = [ + ("질문", "사이트 대화창에 궁금한 정책을 적는다", "site/ask.js"), + ("정리", "주제·데이터·조심할 점·계획 초안을 함께 만든다", "catalog/ · 가이드/AI 모드"), + ( + "제안", + "대화가 GitHub 이슈가 된다 (라벨 from-site)", + ".github/ISSUE_TEMPLATE/site-question.yml", + ), + ("반영", "검토 후 카탈로그·케이스 PR로 들어가고 순위가 갱신된다", "scripts/refresh_trends.py"), +] +LOOP_ANALYZE = [ + ("주제", "주제의 정책을 전부 모으고 세 가지를 확인한다", "catalog/topics.yaml"), + ("계획", "데이터를 보기 전에 plan.yaml을 커밋한다", "cases/<내ID>-<주제>/"), + ("계산", "규칙이 고른 방법으로 효과를 계산하고 점검한다", "core/estimators · core/agent"), + ("공개", "판정과 그림이 사이트에 자동으로 올라간다", "site/build.py · Actions"), +] + +LAYERS = [ + ( + "화면", + "누가 보나", + [ + ("대화창", "가이드 모드(키 없이) · 내 AI 키 모드"), + ("요즘 궁금한 주제", "사이트 질문 수 · 검색 관심도 순위"), + ("주제 · 이슈 · 데이터 지도", "정적 페이지, 누구나"), + ], + ), + ( + "에이전트", + "순서대로 실행", + [ + ("① 계획 확인", "plan.yaml 검증 + 커밋 확인"), + ("② 수집 · ③ 정리", "데이터 받기, 품질 점검"), + ("④ 계산", "규칙이 고른 방법으로"), + ("⑤ 점검 · ⑥ 리포트", "과장 차단, 재현 기록"), + ], + ), + ( + "분석 엔진", + "AI 없이 계산만", + [ + ("계획 양식", "core/schema"), + ("추정", "이중차분 · 단절 시계열"), + ("추론", "무작위화 추론(정책 지역 소수)"), + ("판정", "근거 있음 · 조건부 · 판단 불가"), + ], + ), + ( + "데이터 지도", + "무엇을 어디서", + [ + ("주제 · 이슈", "topics.yaml · issues.yaml"), + ("데이터셋", "역할 · 단위 · 받는 법 · 이용 조건"), + ("수집기", "국토부 · KOSIS · 법제처 · 파일"), + ], + ), +] + # (질문, 근거, 결정, 코드 위치) DECISIONS = [ ( "소셜 반응으로 무엇을 정하나?", "출발 키트: 화제가 된 정책만 고르면 결과를 보고 사례를 고르는 셈이 되어 효과가 부풀려진다", - "소셜 반응은 주제까지만 정한다. 분석은 그 주제의 정책 전체로", + "소셜 반응은 주제까지만 정한다. 순위도 주제를 고르는 데만 쓰고, 분석은 그 주제의 정책 전체로", "catalog/topics.yaml · core/discovery", ), + ( + "대화창의 AI는 어떻게 부르나?", + "사이트는 서버가 없는 GitHub Pages다. 공용 키를 사이트에 넣으면 누구나 볼 수 있다", + "키 없이도 되는 가이드 모드가 기본. AI 대화는 사용자가 자기 키를 넣으면 브라우저가 직접 부르고, 키는 저장하지 않는다", + "site/ask.js", + ), + ( + "대화를 어떻게 오픈소스에 반영하나?", + "대화가 브라우저 안에만 있으면 쌓이지 않는다. 반대로 자동 저장하면 개인정보 위험이 있다", + "사용자가 직접 누를 때만 GitHub 이슈로 올린다. 사람이 검토해 카탈로그·케이스 PR로 넣는다", + "site-question.yml · CONTRIBUTING.md", + ), + ( + "순위는 무엇으로 매기나?", + "지어낸 인기도는 신뢰를 깎는다. 확인 가능한 신호만 쓴다", + "최근 30일 사이트 질문 수 → 네이버 검색 관심도(키가 있을 때) → 최근 시행·발표일. 쓴 기준을 화면에 적는다", + "scripts/refresh_trends.py", + ), ( "필요한 데이터를 어떻게 정리하나?", "효과를 재려면 '누가 언제 받았나', '무엇이 변했나', '다른 요인' 세 종류가 모두 있어야 한다", @@ -28,17 +105,11 @@ "정책 목록은 어떻게 모으나?", "출발 키트: 법제처 조례 API는 무료·즉시 승인이고 '어느 지역이 언제부터'가 한 줄에 나온다", "조례형 주제는 법제처 API로 자동 수집, 나머지는 고시·발표 자료로 정리", - "core/adapters/law.py · 매주 자동 갱신", - ), - ( - "분석해도 되는 주제인지 어떻게 거르나?", - "출발 키트의 세 가지 확인: 언제 시작했나 · 누가 받았나 · 무엇으로 재나", - "주제마다 세 가지 상태를 기록하고, 통과하지 못하면 계산하지 않는다", - "topics.yaml gates · 사이트 배지", + "core/adapters/law.py", ), ( "AI에게 어디까지 맡기나?", - "자동화가 목표지만 인과 판단을 AI에 맡기면 과장 위험이 크다. 비슷한 오픈소스(CAIS)도 규칙으로 방법을 고른다", + "인과 판단을 AI에 맡기면 과장 위험이 크다. 비슷한 오픈소스(CAIS)도 규칙으로 방법을 고른다", "AI는 주제 찾기와 글쓰기만. 방법은 데이터 모양을 보고 규칙이, 숫자는 검증된 라이브러리가 계산", "core/estimators · core/agent/guard.py", ), @@ -57,73 +128,87 @@ ( "누구나 보려면 어디에 공개하나?", "출발 키트: Streamlit은 서버가 켜져 있어야 한다. 서버 없이 GitHub Pages로도 된다", - "이 사이트를 GitHub Actions가 만들어 Pages로 공개한다. Streamlit은 개발용", + "GitHub Actions가 매일 사이트를 만들어 Pages로 공개한다. Streamlit은 개발용", "site/ · .github/workflows/pages.yml", ), ] -LAYERS = [ +# (무엇, 왜, 누가, 상태 배지, 상태) +NEEDS = [ ( - "화면", - "누가 보나", - [ - ("이 사이트", "GitHub Pages · 누구나"), - ("Streamlit", "개발·점검용"), - ("명령어", "make flow CASE=…"), - ], + "법제처 OC 인증값", + "조례형 주제의 정책 목록 자동 수집", + "운영진 · 레포 Secrets", + "warn", + "등록 대기", ), ( - "에이전트", - "순서대로 실행", - [ - ("① 계획 확인", "plan.yaml 검증 + 커밋 확인"), - ("② 수집 · ③ 정리", "데이터 받기, 품질 점검"), - ("④ 계산", "규칙이 고른 방법으로"), - ("⑤ 점검 · ⑥ 리포트", "과장 차단, 재현 기록"), - ], + "공공데이터포털 인증키", + "실거래가 등 실제 데이터로 분석 (지금 예시는 시뮬레이션)", + "멘티 각자 · .env", + "warn", + "각자 신청", ), ( - "분석 엔진", - "AI 없이 계산만", - [ - ("계획 양식", "core/schema"), - ("추정", "이중차분 · 단절 시계열"), - ("추론", "무작위화 추론(정책 지역 소수)"), - ("판정", "근거 있음 · 조건부 · 판단 불가"), - ], + "네이버 데이터랩 키", + "순위의 검색 관심도, '미리 반응했나' 점검", + "운영진 · 레포 Secrets", + "info", + "선택", ), ( - "데이터 지도", - "무엇을 어디서", - [ - ("주제 · 이슈", "topics.yaml · issues.yaml"), - ("데이터셋", "역할 · 단위 · 받는 법"), - ("수집기", "국토부 · KOSIS · 법제처 · 파일"), - ], + "모두가 쓰는 AI 대화", + "지금은 사용자 본인 키가 있어야 AI 모드. 키 없이 모두 쓰게 하려면 사용량 제한이 있는 작은 중계 서버와 비용 부담 주체가 필요", + "운영진 결정", + "info", + "결정 필요", + ), + ( + "사이트 질문 검토", + "올라온 질문을 새 주제 · 데이터 · 분석으로 나누고 PR로 반영", + "멘토 + 멘티 순번 · 주 1회", + "warn", + "시작 전", + ), + ( + "공공데이터포털 검색 전환", + "지금 검색은 웹 페이지를 읽는 방식. 공식 목록 API로 바꾼다", + "개발", + "info", + "다음", + ), + ( + "시차 도입 이중차분 · 합성통제", + "지역마다 시작일이 다르거나 정책 지역이 한두 곳인 이슈", + "개발 · 4~5주차", + "info", + "다음", ), ] +# (폴더, 하는 일, 누가 고치나) CODE_MAP = [ - ("catalog/", "주제 · 이슈 · 데이터셋 목록 (데이터 지도)", "문제 정의", "2~3주"), - ("core/discovery/", "소셜 반응 → 주제 → 정책 · 데이터", "문제 정의", "3주"), - ("core/adapters/", "공공데이터 · 법제처 수집기", "데이터 수집", "2주"), - ("core/schema/", "분석 계획(plan.yaml) 규칙", "문제 정의", "3주"), - ("core/estimators/", "효과 계산 · 점검 · 판정", "추정", "4~5주"), - ("core/agent/", "6단계 실행, 과장 차단", "리포트 · 에이전트", "5~6주"), - ("cases/", "조별 분석 폴더", "조 전체", "3~7주"), - ("site/", "이 사이트", "리포트 · 에이전트", "7주"), + ("cases/", "멘티별 분석 폴더 (<내ID>-<주제>, _template에서 시작)", "폴더 주인"), + ("catalog/", "주제 · 이슈 · 데이터셋 목록 (데이터 지도)", "누구나 (PR)"), + ("site/", "이 사이트 · 대화창(ask.js)", "메인테이너"), + ("scripts/", "조례 · 순위 갱신, 활동 확인", "메인테이너"), + ("core/discovery/", "소셜 반응 → 주제 → 정책 · 데이터", "메인테이너"), + ("core/adapters/", "공공데이터 · 법제처 수집기", "메인테이너"), + ("core/schema/", "분석 계획(plan.yaml) 규칙", "메인테이너"), + ("core/estimators/", "효과 계산 · 점검 · 판정", "메인테이너"), + ("core/agent/", "6단계 실행, 과장 차단", "메인테이너"), ] STATUS = [ ( "go", "완성", - "주제 찾기 · 데이터 지도 · 6단계 실행 · 계획 커밋 확인 · 이중차분/단절 시계열 · 무작위화 추론 · 과장 차단 · 사이트 자동 공개", + "대화창(가이드 · 내 AI 키) · 대화 → 이슈 · 주제 순위 · 데이터 지도 · 6단계 실행 · 계획 커밋 확인 · 이중차분/단절 시계열 · 무작위화 추론 · 과장 차단 · 사이트 자동 공개", ), ( "warn", - "데이터 키 대기", - "국토부 실거래가(토허구역 예시는 지금 시뮬레이션) · 법제처 조례 자동 수집(인증값 등록 후 다음 갱신부터)", + "키 대기", + "국토부 실거래가(토허구역 예시는 지금 시뮬레이션) · 법제처 조례 자동 수집 · 검색 관심도(선택)", ), ( "info", @@ -132,6 +217,59 @@ ), ] +# (이름, 무엇을, 관계, 라이선스) +RELATED = [ + ( + "CAIS (causal-agent)", + "자연어 인과 질문 → 방법 선택 → 추정", + "방법 선택 규칙을 참고. 한국 공공데이터·사전 등록은 없음", + "MIT", + ), + ( + "Causal-Copilot", + "LLM 인과 분석 에이전트", + "보완 관계. 우리는 한국 공공데이터·사전 등록에 집중", + "MIT", + ), + ( + "PublicDataReader · kpubdata", + "공공데이터 API 파이썬 래퍼", + "필요하면 수집기로 가져다 쓸 수 있음", + "MIT", + ), + ("data-go-mcp-servers", "공공데이터포털 MCP 서버", "AI 도구 연결 시 후보", "Apache-2.0"), + ( + "PolicyEngine · OpenFisca", + "세금·복지 제도 시뮬레이터", + "목적이 다름(사전 시뮬레이션). AGPL이라 코드 가져오지 않음", + "AGPL-3.0", + ), + ( + "hollobit/PAX", + "공공 AI 도입 사례 아카이브", + "화면 구성만 참고. 라이선스 표기가 없어 코드·글은 가져오지 않음", + "표기 없음", + ), + ( + "국회예산정책처 · 기획재정부 평가", + "공식 재정사업 평가", + "우리 결과는 공식 평가가 아니며 그렇게 보이지 않게 표시", + "—", + ), +] + + +def _loop(title: str, steps, e) -> str: + cells = [] + for i, (a, b, where) in enumerate(steps): + if i: + cells.append('') + cells.append( + f'
{i + 1:02d}{e(a)}

{e(b)}

' + f'{e(where)}
' + ) + return f'

{title}

{"".join(cells)}
' + def body(e) -> str: goals = "".join( @@ -145,43 +283,62 @@ def body(e) -> str: for i, (q, why, what, where) in enumerate(DECISIONS) ) layers = "".join( - '
' + '
' f'

{name}

{hint}
' - '
' + '
' + "".join( - f'
{a}' - f'
{b}
' + f'
{a}
{b}
' for a, b in items ) + "
" for name, hint, items in LAYERS ) + needs = "".join( + f"{e(a)}{e(why)}{e(who)}" + f'{e(st)}' + for a, why, who, c, st in NEEDS + ) code = "".join( - f"{e(p)}" - f"{e(r)}{e(w)}{e(wk)}" - for p, r, w, wk in CODE_MAP + f"{e(p)}{e(r)}{e(w)}" + for p, r, w in CODE_MAP ) status = "".join( f'
{lab}

{e(t)}

' for c, lab, t in STATUS ) + related = "".join( + f"{e(n)}{e(w)}{e(r)}{e(lic)}" + for n, w, r, lic in RELATED + ) return f"""
구조 -

어떻게 만들어졌나

-

과정 목표 세 가지에서 출발해, 만들면서 부딪힌 질문에 어떤 근거로 답했는지 순서대로 적었습니다. -지금의 구조는 그 답을 쌓은 결과입니다.

- -

출발점: 과정 목표

{goals}
+

한 장으로 보는 구조

+

두 개의 고리로 움직입니다. 사람들의 질문이 대화 고리로 들어와 주제와 데이터가 쌓이고, +멘티가 분석 고리로 그 주제의 효과를 확인해 다시 사이트에 올립니다.

-

질문과 결정

목표를 코드로 옮기면서 부딪힌 질문 {len(DECISIONS)}개입니다.

-
{decisions}
+
+{_loop("대화 고리 · 질문이 쌓이는 길", LOOP_TALK, e)} + +{_loop("분석 고리 · 효과를 확인하는 길", LOOP_ANALYZE, e)} +
-

네 개의 층

위에서 아래로 부릅니다. AI는 에이전트 층에서 주제 찾기와 글쓰기만 돕고, 분석 엔진에는 들어가지 않습니다.

+

네 개의 층

위에서 아래로 부릅니다. AI는 화면(대화)과 에이전트(글쓰기)에서만 돕고, 분석 엔진에는 들어가지 않습니다.

{layers}
-

GitHub Actions가 옆에서 돕습니다: 코드 검사 · 매주 데이터 갱신 · 사이트 공개.

+

GitHub Actions가 옆에서 돕습니다: 코드 검사 · 매일 순위 갱신과 사이트 공개 · 조례 수집(키가 있을 때).

-

폴더 안내

폴더마다 맡는 조원 역할과 주차입니다.

-
{code}
폴더하는 일조원 역할주차
+

무엇이 더 필요한가

지금 구조를 끝까지 돌리려면 아래가 필요합니다. 비밀값은 레포에 적지 않고 Settings → Secrets에만 넣습니다.

+
{needs}
무엇왜누가 · 어디에상태
+ +

질문과 결정

과정 목표를 코드로 옮기면서 부딪힌 질문 {len(DECISIONS)}개와, 어떤 근거로 답했는지입니다.

+
{goals}
+
{decisions}
+ +

폴더 안내

멘티는 cases/의 내 폴더와 catalog/만 고치면 됩니다.

+
{code}
폴더하는 일누가 고치나

지금 상태

{status}
+ +

관련 오픈소스와의 관계

비슷한 프로젝트와 겹치거나 부딪히는 부분이 있는지 확인했습니다. 라이선스 충돌은 없고, 서로 보완하는 관계입니다. +GPL·AGPL 코드는 필수 의존성으로 가져오지 않습니다.

+
{related}
프로젝트무엇을우리와의 관계라이선스
""" diff --git a/site/ask.js b/site/ask.js new file mode 100644 index 0000000..d26182a --- /dev/null +++ b/site/ask.js @@ -0,0 +1,319 @@ +/* 대화창 — "궁금한 정책 이야기를 적어 보세요" + * + * 두 가지 방식으로 답합니다. + * 1) 가이드 모드(키 없이): 카탈로그(주제·이슈·데이터셋)를 규칙으로 찾아 대화처럼 안내합니다. + * 2) AI 모드: 사용자가 넣은 자기 AI 키로 브라우저가 직접 모델을 부릅니다. 키는 이 페이지 메모리에만 있고 + * 어디에도 저장·전송되지 않습니다(새로고침하면 사라짐). 서버가 없어도 동작합니다. + * 대화에서 나온 질문·제안은 "제안으로 올리기"로 GitHub 이슈가 되어 오픈소스에 반영됩니다. + */ +(function () { + const CAT = JSON.parse(document.getElementById("catalog").textContent); + const REPO = CAT.repo; + const $ = (s) => document.querySelector(s); + const log = $("#chat-log"), input = $("#q"), form = $("#chat-form"); + const state = { messages: [], topic: null, issue: null, mode: "guide", key: "", pending: null }; + + const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); + const norm = (s) => s.replace(/\s+/g, "").toLowerCase(); + const byId = (arr) => Object.fromEntries(arr.map((x) => [x.id, x])); + const T = byId(CAT.topics), I = byId(CAT.issues), D = byId(CAT.datasets); + const ROLE = { treatment: "처치 · 누가 언제", outcome: "결과 · 무엇이 변했나", covariate: "통제 · 다른 요인" }; + const GATE = { when: "언제 시작했나", who: "누가 받았나", what: "무엇으로 재나" }; + const STATUS = { pass: "통과", check: "확인 필요", key: "데이터 키 필요", fail: "탈락" }; + + // ─── 화면 ─────────────────────────────────────────────────────────── + function bubble(role, html, actions) { + const el = document.createElement("div"); + el.className = "msg " + role; + el.innerHTML = `
${role === "user" ? "나" : "플랫폼"}
${html}
`; + if (actions && actions.length) { + const bar = document.createElement("div"); + bar.className = "chips"; + actions.forEach(([label, fn]) => { + const b = document.createElement("button"); + b.type = "button"; b.className = "chip"; b.textContent = label; + b.onclick = () => fn(); + bar.appendChild(b); + }); + el.appendChild(bar); + } + log.appendChild(el); + log.hidden = false; + el.scrollIntoView({ block: "nearest", behavior: "smooth" }); + return el; + } + function toPlain(html) { + const d = document.createElement("textarea"); + d.innerHTML = html.replace(/<\/(p|li|pre)>|
/g, "\n").replace(/
  • /g, "- ").replace(/<[^>]+>/g, ""); + return d.value.replace(/[ \t]+/g, " ").replace(/\n\s*/g, "\n").trim(); + } + function say(html, actions, plain) { + state.messages.push({ role: "assistant", content: plain || toPlain(html) }); + return bubble("bot", html, actions); + } + + // ─── 가이드 모드: 규칙 기반 대화 ───────────────────────────────────── + function matchTopic(text) { + const t = norm(text); + let best = null, bs = 0, hits = []; + for (const tp of CAT.topics) { + const h = tp.keywords.filter((k) => t.includes(norm(k))); + const s = h.reduce((a, k) => a + Math.min(norm(k).length, 6), 0); + if (s > bs) { bs = s; best = tp; hits = h; } + } + return best ? { topic: best, hits } : null; + } + function matchIssue(text, topic) { + const t = norm(text); + const cands = CAT.issues.filter((i) => !topic || i.topic === topic.id); + let best = null, bs = 0; + for (const i of cands) { + const words = (i.name + " " + i.issue + " " + i.treatment).split(/[\s·,()—\-]+/).filter((w) => w.length >= 2); + // 완전히 같은 말은 1점, 앞 두 글자만 같으면 0.5점 (예: "강남" ↔ "강남3구") + const s = words.reduce((a, w) => a + (t.includes(norm(w)) ? 1 : w.length >= 3 && t.includes(norm(w).slice(0, 2)) ? 0.5 : 0), 0); + if (s > bs) { bs = s; best = i; } + } + return best; + } + const dsLink = (d) => `${esc(d.name)} ${esc(d.space)}×${esc(d.time)} · ${d.access === "open_api" ? "오픈API" : "파일"}(${esc(d.approval)})`; + + function topicReply(m) { + const tp = m.topic; + state.topic = tp; + const issues = CAT.issues.filter((i) => i.topic === tp.id); + const ds = CAT.datasets.filter((d) => d.topics.includes(tp.id)); + const cnt = (r) => ds.filter((d) => d.roles.includes(r)).length; + const gates = Object.entries(tp.gates).map(([k, g]) => `
  • ${GATE[k]}: ${STATUS[g.status]} — ${esc(g.note)}
  • `).join(""); + const iss = issues.length + ? `

    이 주제에서 지금 이슈인 정책은 ${issues.length}개입니다.

    ` + : ""; + say( + `

    ${esc(tp.name)} 주제로 보입니다. (찾은 말: ${m.hits.map(esc).join(", ")})

    +

    질문 하나만 보지 않고, 이 주제의 정책을 모두 모아 비교합니다. ${esc(tp.question)}

    ${iss} +

    분석 전에 확인할 세 가지:

      ${gates}
    +

    받을 수 있는 데이터 ${ds.length}개 — 처치 ${cnt("treatment")} · 결과 ${cnt("outcome")} · 통제 ${cnt("covariate")}

    +

    무엇부터 해볼까요?

    `, + [ + ["분석 계획 초안", () => planReply(state.issue || issues[0] || null)], + ["받을 수 있는 데이터", () => dataReply()], + ["조심할 점", () => pitfallReply(state.issue || issues[0] || null)], + ["제안으로 올리기", () => propose()], + ], + ); + } + function dataReply() { + const tp = state.topic; + if (!tp) return say("먼저 궁금한 정책 이야기를 적어 주세요."); + const ds = CAT.datasets.filter((d) => d.topics.includes(tp.id)); + const block = ["treatment", "outcome", "covariate"].map((r) => { + const xs = ds.filter((d) => d.roles.includes(r)); + return xs.length ? `

    ${ROLE[r]}

      ${xs.slice(0, 5).map((d) => `
    • ${dsLink(d)}
    • `).join("")}
    ` : `

    ${ROLE[r]}: 아직 정리된 데이터가 없습니다. 찾으면 제안해 주세요.

    `; + }).join(""); + say(`${block}

    전체 목록과 검색은 데이터 지도에 있습니다.

    `, + [["분석 계획 초안", () => planReply(null)], ["제안으로 올리기", () => propose()]]); + } + function pitfallReply(issue) { + const tp = state.topic; + const pits = [...(issue ? issue.pitfalls : []), ...(tp ? tp.pitfalls : [])]; + if (!pits.length) return say("아직 정리된 주의점이 없습니다. 공통으로는 ① 정책 전 추세가 나란했는지, ② 같은 시기의 다른 정책, ③ 옆 지역으로 효과가 번지는지를 먼저 확인합니다."); + say(`
      ${pits.map((p) => `
    • ${esc(p)}
    • `).join("")}
    `, [["분석 계획 초안", () => planReply(issue)]]); + } + const DESIGN = { did_simultaneous: "event_study", did_staggered: "event_study", scm: "did", its: "its" }; + const DESIGN_KO = { did_simultaneous: "이중차분", did_staggered: "시차 도입 이중차분(준비 중)", scm: "합성통제(준비 중)", its: "단절 시계열" }; + function planReply(issue) { + const tp = state.topic; + issue = issue || state.issue || (tp && CAT.issues.find((i) => i.topic === tp.id)); + if (!issue) { + return say("이 주제에는 아직 정리된 이슈가 없어 초안의 빈칸이 많습니다. 정책 이름과 시작일, 정책을 받은 곳을 알려 주시면 채워 드릴게요."); + } + state.issue = issue; + const ds = issue.datasets.map((x) => D[x]).filter(Boolean); + const yaml = [ + `case_id: <내ID>-${issue.id}`, + `title: "${issue.name}"`, + `question: >\n ${issue.issue}`, + `synthetic_data: false`, + `unit: {name: 시군구, id_col: region_id} # 데이터에 맞게`, + `time: {col: month_idx, freq: month, start: 0, end: 24}`, + `treatment:\n definition: "${issue.treatment}"\n group_col: treated\n treat_time: 12 # 시작일 ${issue.effective} 에 해당하는 인덱스`, + `control:\n definition: "${issue.control}"\n rationale: "정책이 없었다면의 추세를 대신 보여주는 이유"`, + `outcomes:\n${issue.outcomes.map((o, k) => ` - name: ${o}\n col: y${k + 1}\n definition: "측정 방법"${k === 0 ? "\n primary: true" : ""}`).join("\n")}`, + `estimator: {method: ${DESIGN[issue.design]}, cluster_col: region_id} # ${DESIGN_KO[issue.design]}`, + `assumptions:\n - {name: 평행추세, description: "정책 전 두 집단이 나란히 움직임", check: pretrend_test}`, + `refutations:\n - {kind: placebo_time, params: {shift: 3}}`, + `abstention:\n - {when: pretrend_rejected, verdict: not_identified}\n - {when: ci_crosses_zero, verdict: conditional}`, + `data_sources:\n${ds.map((d) => ` - name: "${d.name}"\n provider: "${d.provider}"\n url: "${d.url}"\n license: ${d.license === "KOGL-1" || d.license === "KOGL-3" ? d.license : "other"}`).join("\n")}`, + ].join("\n"); + state.plan = yaml; + say( + `

    ${esc(issue.name)} 분석 계획 초안입니다. cases/_template을 복사한 폴더의 plan.yaml에 붙여 넣고, 데이터를 보기 전에 커밋하세요.

    +
    ${esc(yaml)}
    `, + [["복사", () => navigator.clipboard && navigator.clipboard.writeText(yaml)], ["조심할 점", () => pitfallReply(issue)], ["제안으로 올리기", () => propose()]], + `분석 계획 초안(${issue.name}) — 전체는 '정리된 제안' 칸에 있습니다.`, + ); + } + function unknownReply(text) { + state.pending = { question: text, step: 0, answers: [] }; + say( + `

    아직 정리된 주제가 아닙니다. 새 주제로 함께 만들어 볼까요? 세 가지만 알려 주세요.

    +
    1. 어떤 정책이고 언제 시작했나요?
    2. 누가 받았고, 누가 안 받았나요?
    3. 무엇이 달라졌는지 알고 싶나요?
    +

    한 번에 적어도 되고, 하나씩 적어도 됩니다. 다 모이면 제안으로 올릴 수 있게 정리해 드립니다.

    `, + [["바로 제안으로 올리기", () => propose()], ["주제 목록 보기", () => (location.hash = "#topics")]], + ); + } + function guide(text) { + const t = norm(text); + if (state.pending && !matchTopic(text)) { + state.pending.answers.push(text); + if (state.pending.answers.length < 3 && text.length < 40) { + const next = ["누가 받았고, 누가 안 받았나요?", "무엇이 달라졌는지 알고 싶나요?"][state.pending.answers.length - 1]; + return say(next || "고맙습니다."); + } + return say("정리됐습니다. 아래 버튼으로 올리면 이슈가 만들어지고, 확인을 거쳐 카탈로그에 새 주제로 들어갑니다.", [["제안으로 올리기", () => propose()]]); + } + if (/계획|plan|초안/.test(t) && state.topic) return planReply(matchIssue(text, state.topic)); + if (/데이터|api|어디서/.test(t) && state.topic) return dataReply(); + if (/조심|함정|주의|한계/.test(t) && state.topic) return pitfallReply(matchIssue(text, state.topic)); + const m = matchTopic(text); + if (m) { + state.issue = matchIssue(text, m.topic); + return topicReply(m); + } + return unknownReply(text); + } + + // ─── AI 모드: 사용자 키로 브라우저에서 직접 호출 ───────────────────────── + function systemPrompt() { + const compact = { + topics: CAT.topics.map((t) => ({ id: t.id, name: t.name, question: t.question, gates: t.gates })), + issues: CAT.issues.map((i) => ({ id: i.id, name: i.name, topic: i.topic, effective: i.effective, treatment: i.treatment, control: i.control, outcomes: i.outcomes, design: i.design, datasets: i.datasets, pitfalls: i.pitfalls })), + datasets: CAT.datasets.map((d) => ({ id: d.id, name: d.name, provider: d.provider, roles: d.roles, space: d.space, time: d.time, access: d.access, approval: d.approval, url: d.url, topics: d.topics })), + }; + return `너는 한국 공공데이터로 정책 효과를 확인하는 오픈소스 프로젝트의 안내자다. 한국어로 짧고 쉬운 문장으로 답한다. +규칙: +1. 사용자의 이야기를 '주제'로 연결한다. 화제가 된 정책 하나만 골라 분석하지 않고, 그 주제의 정책 전체를 모아 비교하도록 안내한다. +2. 분석 전에 세 가지를 확인한다: 언제 시작했나(공식 날짜), 누가 받았나(받은 곳과 안 받은 곳), 무엇으로 재나(전후 데이터). +3. 데이터셋은 아래 카탈로그에 있는 것만 이름과 URL로 추천한다. 카탈로그에 없으면 "카탈로그에 없음, 공공데이터포털에서 찾아 제안해 달라"고 말한다. 데이터 ID나 날짜를 지어내지 않는다. +4. 효과가 '있다/없다'를 단정하지 않는다. 너는 결과를 계산하지 않는다. 방법은 이중차분·시차 도입 이중차분·합성통제·단절 시계열 중에서 데이터 모양에 맞게 제안만 한다. +5. 이 프로젝트는 공식 평가가 아니다. 공식 결과처럼 말하지 않는다. +6. 카탈로그에 없는 새 주제·데이터·이슈를 사용자와 정리했다면 답 마지막에 다음 형식의 블록을 붙인다: +\`\`\`proposal +{"type":"topic|dataset|issue","title":"...","summary":"...","when":"...","who":"...","what":"...","datasets":["url 또는 이름"]} +\`\`\` +카탈로그(JSON): +${JSON.stringify(compact)}`; + } + async function callAI() { + const provider = $("#ai-provider").value, model = $("#ai-model").value.trim(), key = state.key; + const msgs = state.messages.filter((m) => m.role === "user" || m.role === "assistant").slice(-12); + if (provider === "anthropic") { + const r = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01", "anthropic-dangerous-direct-browser-access": "true" }, + body: JSON.stringify({ model: model || "claude-sonnet-5", max_tokens: 1200, system: systemPrompt(), messages: msgs }), + }); + const j = await r.json(); + if (!r.ok) throw new Error((j.error && j.error.message) || r.status); + return j.content.map((c) => c.text || "").join(""); + } + const base = $("#ai-base").value.trim().replace(/\/$/, "") || "http://localhost:11434/v1"; + const r = await fetch(base + "/chat/completions", { + method: "POST", + headers: Object.assign({ "content-type": "application/json" }, key ? { authorization: "Bearer " + key } : {}), + body: JSON.stringify({ model: model || "qwen2.5:7b", messages: [{ role: "system", content: systemPrompt() }, ...msgs] }), + }); + const j = await r.json(); + if (!r.ok) throw new Error((j.error && j.error.message) || r.status); + return j.choices[0].message.content; + } + function renderAI(text) { + const m = text.match(/```proposal\s*([\s\S]*?)```/); + if (m) { try { state.proposal = JSON.parse(m[1]); } catch (_) { state.proposal = { summary: m[1] }; } } + const clean = text.replace(/```proposal[\s\S]*?```/, "").trim(); + const html = esc(clean) + .replace(/```(\w+)?\n([\s\S]*?)```/g, (_, __, c) => `
    ${c}
    `) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/(https?:\/\/[^\s)<]+)/g, '$1') + .split(/\n{2,}/).map((p) => `

    ${p.replace(/\n/g, "
    ")}

    `).join(""); + state.messages.push({ role: "assistant", content: text }); + bubble("bot", html + (state.proposal ? `

    새 제안이 정리됐습니다: ${esc(state.proposal.title || state.proposal.summary || "")}

    ` : ""), [["제안으로 올리기", () => propose()]]); + } + + // ─── 대화 → GitHub 이슈 (오픈소스에 반영) ───────────────────────────── + // GitHub 이슈 작성 주소는 너무 길면 열리지 않습니다(한글은 한 글자가 9자로 늘어남). + // 주소에는 질문·주제·제안을 먼저 넣고 대화는 남는 만큼만 넣습니다. 잘렸으면 전체 대화를 클립보드에 복사합니다. + const URL_BUDGET = 7000; + function issueUrl(fields) { + return `${REPO}/issues/new?${new URLSearchParams(Object.assign({ template: "site-question.yml" }, fields)).toString()}`; + } + function fit(text, room) { + if (encodeURIComponent(text).length <= room) return text; + let lo = 0, hi = text.length; + while (lo < hi) { const mid = (lo + hi + 1) >> 1; encodeURIComponent(text.slice(-mid)).length + 60 <= room ? (lo = mid) : (hi = mid - 1); } + return "…(앞부분 생략, 전체 대화는 아래에 붙여 넣어 주세요)\n" + text.slice(-lo); + } + function propose() { + const first = state.messages.find((m) => m.role === "user"); + const q = (first ? first.content : input.value.trim()).slice(0, 300); + const convo = state.messages.map((m) => `${m.role === "user" ? "Q" : "A"}: ${m.content}`).join("\n\n"); + const prop = state.proposal ? JSON.stringify(state.proposal, null, 2) : state.plan || (state.pending ? [state.pending.question, ...state.pending.answers].join("\n") : ""); + const base = { + title: "[사이트 질문] " + q.slice(0, 60), + question: q, + topic: state.topic ? `${state.topic.name} (${state.topic.id})` : "새 주제", + }; + let fields = Object.assign({}, base, { proposal: fit(prop, 3000) }); + const room = URL_BUDGET - issueUrl(fields).length - 20; + const shortConvo = fit(convo, Math.max(room, 200)); + fields.conversation = shortConvo; + const cut = shortConvo !== convo || fields.proposal !== prop; + if (cut && navigator.clipboard) navigator.clipboard.writeText(convo + (prop ? "\n\n---\n" + prop : "")).catch(() => {}); + window.open(issueUrl(fields), "_blank", "noopener"); + say("GitHub 이슈 작성 화면을 새 창으로 열었습니다. 내용을 확인하고 제출하면 멘토와 멘티가 검토해 카탈로그와 분석에 반영합니다. (GitHub 로그인이 필요합니다)" + + (cut ? " 대화가 길어 앞부분을 줄였습니다. 전체 대화는 클립보드에 복사해 두었으니 이슈 본문에 붙여 넣어 주세요." : "")); + } + + // ─── 입력 처리 ─────────────────────────────────────────────────────── + async function send(text) { + text = text.trim(); + if (!text) return; + bubble("user", esc(text)); + state.messages.push({ role: "user", content: text }); + input.value = ""; + const m = matchTopic(text); + if (m) state.topic = m.topic; + document.querySelectorAll("#cards .topic").forEach((c) => c.classList.toggle("hit", !!state.topic && c.dataset.topic === state.topic.id)); + if (state.mode === "ai" && state.key !== null && ($("#ai-provider").value !== "anthropic" || state.key)) { + const wait = bubble("bot", '생각하는 중…'); + try { + const out = await callAI(); + wait.remove(); + renderAI(out); + } catch (err) { + wait.remove(); + say(`

    AI 호출에 실패했습니다: ${esc(err.message || err)}

    키·모델·주소를 확인하세요. 우선 가이드 모드로 답합니다.

    `); + guide(text); + } + return; + } + guide(text); + } + form.addEventListener("submit", (e) => { e.preventDefault(); send(input.value); }); + input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(input.value); } }); + document.querySelectorAll("[data-ask]").forEach((b) => (b.onclick = () => send(b.dataset.ask))); + + // AI 설정 + const panel = $("#ai-panel"); + $("#ai-toggle").onclick = () => { panel.hidden = !panel.hidden; }; + $("#ai-provider").onchange = () => { $("#ai-base-row").hidden = $("#ai-provider").value === "anthropic"; }; + $("#ai-save").onclick = () => { + state.key = $("#ai-key").value.trim(); + $("#ai-key").value = ""; + const ok = $("#ai-provider").value !== "anthropic" || state.key; + state.mode = ok ? "ai" : "guide"; + $("#mode").textContent = ok ? "AI 모드" : "가이드 모드"; + $("#mode").className = "badge " + (ok ? "go" : "info"); + panel.hidden = true; + }; + $("#ai-off").onclick = () => { state.key = ""; state.mode = "guide"; $("#mode").textContent = "가이드 모드"; $("#mode").className = "badge info"; panel.hidden = true; }; +})(); diff --git a/site/build.py b/site/build.py index aacf14d..1366346 100644 --- a/site/build.py +++ b/site/build.py @@ -113,7 +113,8 @@ def page(title: str, body: str, current: str, depth: int = 0) -> str: {links}GitHub ↗
    {body}
    가짜연구소 인과추론팀 × 오픈업 오픈소스 AI 특화형 트랙3 · 이 사이트의 숫자와 판정은 레포의 catalog/·cases/에서 -자동으로 만들어집니다(매주 월요일 갱신). 시뮬레이션 결과에는 따로 표시가 붙습니다. · 소스 코드 (MIT)
    +자동으로 만들어집니다. 시뮬레이션 결과에는 따로 표시가 붙습니다.
    +공식 평가가 아닙니다. 학습·연구용 오픈소스 분석이며 정부·공공기관의 공식 평가나 통계가 아니고, 데이터 제공 기관의 후원·보증을 뜻하지 않습니다. · 소스 코드 (MIT)
    """ @@ -263,22 +264,8 @@ def case_block(case_rel: str, depth: int) -> str: # ─── 홈 ────────────────────────────────────────────────────────────────────── +# 대화창 엔진은 site/ask.js (가이드 모드 + 사용자 키로 부르는 AI 모드). 여기서는 필터만 다룬다. HOME_JS = """ -const KW=__KW__; -const norm=s=>s.replace(/\\s+/g,'').toLowerCase(); -const q=document.getElementById('q'),ans=document.getElementById('ans'); -function match(){ - const t=norm(q.value);let best=null,bs=0,hits=[]; - for(const [id,ks] of Object.entries(KW)){const h=ks.filter(k=>t.includes(norm(k))); - const s=h.reduce((a,k)=>a+Math.min(norm(k).length,6),0);if(s>bs){bs=s;best=id;hits=h;}} - document.querySelectorAll('#cards .topic').forEach(c=>c.classList.toggle('hit',c.dataset.topic===best)); - if(!t){ans.textContent='입력한 글은 이 브라우저 안에서만 쓰이고, 어디에도 보내지 않습니다.';return;} - if(!best){ans.textContent='맞는 주제를 찾지 못했습니다. 정책 이름이나 지역을 넣어 보세요.';return;} - const c=document.querySelector(`#cards [data-topic="${best}"]`); - ans.innerHTML=`→ ${c.querySelector('h3').textContent} 주제입니다. 이 주제의 정책을 모두 모아서 봅니다. (찾은 말: ${hits.join(', ')})`; -} -q.addEventListener('input',match); -document.querySelectorAll('.ex').forEach(b=>b.onclick=()=>{q.value=b.textContent;match();}); document.querySelectorAll('.f').forEach(b=>b.onclick=()=>{ document.querySelectorAll('.f').forEach(x=>x.setAttribute('aria-pressed',x===b));const f=b.dataset.f; document.querySelectorAll('#cards .topic').forEach(c=>c.hidden=!(f==='all'||(f==='result'?c.dataset.result==='1':f==='issue'?c.dataset.issue==='1':c.dataset.ready===f)));}); @@ -288,16 +275,20 @@ def case_block(case_rel: str, depth: int) -> str: """ HOME_STEPS = [ - ("찾기", "뉴스·SNS에서 사람들이 궁금해하는 주제를 찾습니다", "누구나"), - ("모으기", "그 주제의 정책을 전부 모읍니다. 어느 지역이 언제 시작했는지", "수집 담당"), + ("찾기", "대화창이나 지금 이슈에서 사람들이 궁금해하는 주제를 고릅니다", "2주 · 9.27"), + ("모으기", "그 주제의 정책을 전부 모읍니다. 어느 지역이 언제 시작했는지", "2~3주"), ( "거르기", "세 가지를 확인합니다. 언제 시작했나 · 누가 받았나 · 무엇으로 재나", - "문제 정의 담당", + "10.2 오프라인", + ), + ("계획하기", "데이터를 보기 전에 분석 계획을 먼저 적어 둡니다", "10.2 계획 PR"), + ("비교하기", "정책을 받은 곳과 안 받은 곳의 변화를 비교합니다", "3~5주"), + ( + "말하기", + "효과 근거 있음 · 조건부 · 판단 불가 중 하나로 정직하게 씁니다", + "6~7주 · 11.1 공개", ), - ("계획하기", "데이터를 보기 전에 분석 계획을 먼저 적어 둡니다", "문제 정의 담당"), - ("비교하기", "정책을 받은 곳과 안 받은 곳의 변화를 비교합니다", "추정 담당"), - ("말하기", "효과 근거 있음 · 조건부 · 판단 불가 중 하나로 정직하게 씁니다", "리포트 담당"), ] HOME_EXAMPLES = [ "토허제 확대하고 강남 집값 잡혔나요?", @@ -305,18 +296,148 @@ def case_block(case_rel: str, depth: int) -> str: "모두의 카드 나오고 지하철 더 타나?", "5030 속도 줄이고 사고 줄었나?", ] +REFLECT_STEPS = [ + ("대화", "궁금한 정책을 적으면 주제·데이터·조심할 점·분석 계획 초안을 함께 정리합니다."), + ("제안", "'제안으로 올리기'를 누르면 대화가 GitHub 이슈로 올라갑니다. 로그인만 있으면 됩니다."), + ( + "검토", + "멘토·멘티가 이슈를 보고 새 주제·데이터셋은 카탈로그에, 분석은 케이스 폴더에 PR로 넣습니다.", + ), + ( + "반영", + "합쳐지면 사이트가 다시 만들어집니다. 올라온 질문 수는 '요즘 궁금해하는 주제' 순위가 됩니다.", + ), +] +TRENDS = ROOT / "catalog" / "snapshots" / "trends.json" + + +def catalog_json(topics, policies, issues, datasets) -> str: + """대화창(ask.js)이 읽는 카탈로그. 가 끼어들지 않게 ' str: - kw = { - t.id: list( - dict.fromkeys( - t.keywords - + [k for pid in t.policies for p in policies if p.id == pid for k in p.keywords] - ) +def load_trends() -> dict: + try: + return json.loads(TRENDS.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def latest_event(t, issues, today: str) -> str: + """주제의 가장 최근 시행·발표일 (오늘 이후 예정일은 뺀다). ISO 문자열이라 사전순 비교가 곧 날짜순.""" + ds = [ev.date.isoformat() for ev in t.events] + [i.effective for i in issues if i.topic == t.id] + ds = [d for d in ds if d <= today] + return max(ds, default="") + + +def rank_topics(topics, issues, trends: dict, today: str | None = None): + """사이트 질문 수 → 검색 관심도 → 최근 시행·발표일 순. 없는 신호는 0으로 두고 표시하지 않는다.""" + import datetime as _dt + + today = today or _dt.date.today().isoformat() + tt = trends.get("topics", {}) + rows = [ + ( + t, + tt.get(t.id, {}).get("questions", 0), + tt.get(t.id, {}).get("search", 0.0), + latest_event(t, issues, today), ) for t in topics - } + ] + rows.sort(key=lambda r: (r[1], r[2], r[3]), reverse=True) + srcs = set(trends.get("sources", [])) + has_q = "site_questions" in srcs and any(r[1] for r in rows) + has_s = "naver_datalab" in srcs and any(r[2] for r in rows) + if has_q or has_s: + parts = (["최근 30일 사이트 질문 수"] if has_q else []) + ( + ["네이버 검색 관심도"] if has_s else [] + ) + basis = f"{' · '.join(parts)} 기준 · {e(trends.get('updated', ''))} 갱신" + else: + basis = "아직 모인 질문이 없어 최근 시행·발표 순입니다. 대화를 제안으로 올리면 순위에 반영됩니다." + return rows, has_q, has_s, basis + + +def ranking_block(topics, issues, trends: dict, n: int = 5) -> str: + rows, has_q, has_s, basis = rank_topics(topics, issues, trends) + items = [] + for k, (t, q, s, last) in enumerate(rows[:n], 1): + meta = ([f"질문 {q}"] if has_q else []) + ([f"검색 {s:g}"] if has_s else []) + meta += [f"최근 {last[:7].replace('-', '.')}"] if last else [] + items.append( + f'
  • ' + ) + return ( + '
    요즘 궁금해하는 주제' + f'{basis}
      {"".join(items)}
    ' + ) + + +def index_page(topics, policies, issues, datasets) -> str: issue_topics = {i.topic for i in issues} cards = [] for t in topics: @@ -339,20 +460,47 @@ def index_page(topics, policies, issues, datasets) -> str: for i in issues ) n_api = sum(d.access == "open_api" for d in datasets) - chips = "".join(f'' for x in HOME_EXAMPLES) + chips = "".join( + f'' + for x in HOME_EXAMPLES + ) flow = "".join( f'
    {i + 1:02d}{a}

    {b}

    {c}
    ' for i, (a, b, c) in enumerate(HOME_STEPS) ) - js = HOME_JS.replace("__KW__", json.dumps(kw, ensure_ascii=False)) + reflect = "".join( + f'
    {i + 1:02d}{a}

    {b}

    ' + for i, (a, b) in enumerate(REFLECT_STEPS) + ) body = f""" -
    공공데이터 · 인과추론 · 오픈소스 +
    오픈소스 · 공공데이터 · 인과추론

    그 정책, 정말 효과가 있었을까?

    뉴스와 SNS에서 사람들이 묻는 정책을 공공데이터로 확인합니다. 정책을 받은 곳과 안 받은 곳을 비교하고, 데이터로 판단할 수 없으면 판단할 수 없다고 말합니다.

    -
    + +
    + +
    + +
    +
    가이드 모드 + +대화는 이 브라우저 안에서만 쓰입니다. "제안으로 올리기"를 누를 때만 GitHub 이슈 화면으로 넘어갑니다.
    +
    {chips}
    -

    입력한 글은 이 브라우저 안에서만 쓰이고, 어디에도 보내지 않습니다.

    +{ranking_block(topics, issues, load_trends())} +
    +
    {len(topics)}
    분석 주제
    {len(issues)}
    지금 이슈인 정책
    @@ -360,7 +508,10 @@ def index_page(topics, policies, issues, datasets) -> str:
    {n_api}
    오픈API로 받을 수 있는 것
    -

    이렇게 확인합니다

    여섯 단계는 조원 역할과 7주 일정에 그대로 대응합니다.

    +

    대화가 오픈소스가 되는 길

    여기서 나눈 질문이 새 주제·새 데이터·새 분석으로 쌓입니다. 없던 공공데이터 정책 분석을 함께 만드는 방식입니다.

    +
    {reflect}
    + +

    이렇게 확인합니다

    여섯 단계는 7주 일정에 그대로 대응합니다. 멘티 한 사람이 주제 하나를 끝까지 갑니다.

    {flow}

    지금 이슈인 정책

    요즘 논쟁 중인 정책을 어떻게 분석할 수 있는지, 어떤 데이터를 받을 수 있는지 정리했습니다.

    @@ -383,7 +534,7 @@ def index_page(topics, policies, issues, datasets) -> str:
    • 조용했지만 효과가 컸던 정책이 빠집니다
    • 화제가 되면 신청이 늘어 효과가 부풀려집니다
    • 시행 전부터 화제였다면 사람들이 미리 움직여 비교가 흔들립니다

    우리 방식 화제는 출발점으로만

    -
    • 화제는 어느 주제를 볼지만 정합니다
    • 그 주제의 정책을 전부 모아 비교합니다
    • +
      • 화제는 어느 주제를 볼지만 정합니다. 순위도 주제를 고르는 데만 씁니다
      • 그 주제의 정책을 전부 모아 비교합니다
      • 검색량·기사 수는 '미리 반응했나'를 점검하는 데 다시 씁니다

    믿을 수 있게 만드는 장치

    @@ -396,14 +547,17 @@ def index_page(topics, policies, issues, datasets) -> str:
    6

    한계를 숨기지 않는다

    시뮬레이션, 키 대기, 확인 필요 상태를 배지로 그대로 보여줍니다.

    -

    참여하기

    조마다 주제 하나를 맡아 cases/ 폴더에 분석을 추가합니다.

    +

    참여하기

    멘티 한 사람이 주제 하나를 맡아 cases/<내 GitHub ID>-<주제>/ 폴더에 분석을 추가합니다.

    -
    ①

    주제 고르기

    위 주제나 지금 이슈에서 고릅니다. 새 주제는 GitHub 이슈로 제안합니다.

    -
    ②

    계획 올리기

    cases/_template을 복사해 plan.yaml을 쓰고 PR로 올립니다.

    +
    ①

    주제 고르기

    위 대화창·순위·지금 이슈에서 고르고, GitHub에 "케이스 제안" 이슈를 엽니다.

    +
    ②

    계획 올리기

    cases/_template을 내 폴더로 복사해 plan.yaml을 쓰고 PR로 올립니다. 대화창의 "분석 계획 초안"을 출발점으로 써도 됩니다.

    ③

    실행하고 공개

    make flow로 돌리고 PR이 합쳐지면 이 사이트에 자동으로 올라옵니다.

    -
    -""" +
    + + +""" return page("정책 효과 분석 플랫폼", body, "index.html") @@ -467,7 +621,7 @@ def issues_page(issues, datasets, topics) -> str:

    2025~2026년에 논쟁이 된 정책 {len(issues)}개를 골라, 누구와 누구를 비교할지, 어떤 방법을 쓸지, 어떤 데이터를 받을 수 있는지 정리했습니다. 시작일은 모두 정부 공식 자료에서 확인했습니다.

    +이 목록은 멘티가 어디서부터 볼지 정하는 출발점입니다.

    효과 추정 5단계

    {guide}

    분석 방법 고르기

    방법은 데이터 모양을 보고 규칙이 정합니다. 결과를 본 뒤 사람이나 AI가 고르지 않습니다.

    {designs}
    @@ -692,6 +846,7 @@ def main() -> None: (OUT / "topics" / f"{t.id}.html").write_text( topic_page(t, policies, issues, datasets), encoding="utf-8" ) + shutil.copy(HERE / "ask.js", OUT / "ask.js") (OUT / ".nojekyll").write_text("") print(f"_site/ 생성: 주제 {len(topics)} · 이슈 {len(issues)} · 데이터셋 {len(datasets)}") diff --git a/site/style.css b/site/style.css index c4bb33b..7510994 100644 --- a/site/style.css +++ b/site/style.css @@ -15,6 +15,7 @@ } } * { box-sizing: border-box } +[hidden] { display: none !important } html { scroll-behavior: smooth } body { margin: 0; background: var(--paper); color: var(--ink); font: 16px/1.7 var(--sans); word-break: keep-all; overflow-wrap: break-word } a { color: var(--accent) } @@ -63,10 +64,37 @@ section { padding: 52px 0 6px; scroll-margin-top: 60px } .gates, .tags { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 4px } .dots { letter-spacing: 2px; color: var(--accent) } -.ask { margin-top: 26px } +.ask { margin-top: 26px; max-width: 820px } .ask textarea, .search input { width: 100%; font: inherit; font-size: 17px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--hair); background: var(--surface); color: var(--ink) } -.ask textarea { min-height: 76px } -.ask textarea:focus, .search input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent } +.ask textarea { min-height: 64px; resize: vertical } +.ask textarea:focus, .search input:focus, select:focus, .ai-grid input:focus { outline: 2px solid var(--accent); border-color: transparent } +.chat-form { display: flex; gap: 8px; align-items: flex-end } +.chat-form .btn { border: 0; cursor: pointer; flex: none; height: 48px } +.chat-meta { display: flex; gap: 6px 12px; flex-wrap: wrap; align-items: center; margin: 8px 0 0 } +.linkish { background: none; border: 0; padding: 0; font: inherit; font-size: 14px; color: var(--accent); cursor: pointer; text-decoration: underline } +.chat-log { display: flex; flex-direction: column; gap: 10px; max-height: 60vh; overflow-y: auto; margin: 0 0 12px; padding: 4px 2px } +.msg { max-width: 92%; border-radius: 14px; padding: 10px 14px; border: 1px solid var(--hair); background: var(--surface) } +.msg.user { align-self: flex-end; background: var(--accent-soft); border-color: transparent } +.msg .who { font-size: 12px; font-weight: 700; color: var(--muted); margin-bottom: 2px } +.msg .body p { margin: 0 0 6px } .msg .body p:last-child { margin-bottom: 0 } +.msg .body ul, .msg .body ol { margin: 4px 0 8px } +.msg .chips { margin: 8px 0 0 } +pre.code { background: var(--sunk); border-radius: 10px; padding: 10px 12px; overflow-x: auto; font: 12.5px/1.5 var(--mono); margin: 6px 0 } +pre.code code { background: none; padding: 0 } +.ai-panel { margin-top: 10px; padding: 14px } +.ai-grid { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)) } +.ai-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted) } +.ai-grid input { font: inherit; font-size: 14px; padding: 6px 10px; border-radius: 10px; border: 1px solid var(--hair); background: var(--surface); color: var(--ink) } +.ai-panel .btn { border: 0; cursor: pointer } +.ai-panel .btn.ghost { border: 1px solid var(--hair) } +.rank { margin-top: 14px; background: var(--surface); border: 1px solid var(--hair); border-radius: 14px; padding: 12px 14px } +.rank-head { display: flex; gap: 4px 10px; flex-wrap: wrap; align-items: baseline } +.rank-list { list-style: none; padding: 0; margin: 8px 0 0; display: flex; flex-direction: column; gap: 2px } +.rank-item { width: 100%; display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 10px; align-items: center; text-align: left; background: none; border: 0; border-radius: 10px; padding: 7px 8px; font: inherit; color: var(--ink); cursor: pointer } +.rank-item:hover { background: var(--sunk) } +.rank-item .rn { font: 800 16px var(--mono); color: var(--accent); text-align: center } +.rank-item .rt { font-weight: 600 } +.rank-item .rm { font-size: 12.5px; color: var(--muted); white-space: nowrap } .chips { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 } .chip { border: 1px solid var(--hair); background: var(--surface); color: var(--ink2); border-radius: 999px; padding: 4px 12px; font: inherit; font-size: 13.5px; cursor: pointer } .chip[aria-pressed=true] { background: var(--ink); color: var(--paper); border-color: var(--ink) } @@ -113,9 +141,30 @@ dl.terms { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, min dl.terms div { background: var(--surface); border: 1px solid var(--hair); border-radius: 12px; padding: 12px 14px } dl.terms dt { font-weight: 700 } dl.terms dd { margin: 4px 0 0; color: var(--ink2); font-size: 14.5px } +.loop { background: var(--surface); border: 1px solid var(--hair); border-radius: 16px; padding: 16px } +.loop h3 { margin: 0 0 12px } +.steps { display: grid; grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr; gap: 8px; align-items: stretch } +.step { background: var(--sunk); border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 4px } +.step .n { font: 700 12px var(--mono); color: var(--accent) } +.step p { margin: 0; font-size: 14px; color: var(--ink2); flex: 1 } +.step .where { font-size: 11.5px; color: var(--muted); background: none; padding: 0; word-break: break-all } +.arrow { align-self: center; color: var(--accent); font-weight: 800 } +.loop-join { text-align: center; color: var(--muted); font-size: 14px; margin: 10px 0 } +.layer { display: grid; grid-template-columns: minmax(110px, 150px) 1fr; gap: 14px; align-items: center } +.cells { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)) } +.cell { background: var(--sunk); border-radius: 10px; padding: 8px 10px } +.cell b { font-size: 14px } + footer { margin-top: 64px; color: var(--muted); font-size: 13px; border-top: 1px solid var(--hair); padding-top: 16px } @media (max-width: 640px) { .list .topic { grid-template-columns: minmax(0, 1fr) } .hero { padding-top: 36px } .kv { grid-template-columns: minmax(0, 1fr) } + .chat-form { flex-direction: column; align-items: stretch } + .rank-item { grid-template-columns: 24px minmax(0, 1fr) } + .rank-item .rm { grid-column: 2; white-space: normal } + .msg { max-width: 100% } + .steps { grid-template-columns: minmax(0, 1fr) } + .arrow { transform: rotate(90deg); justify-self: center } + .layer { grid-template-columns: minmax(0, 1fr) } } diff --git a/tests/test_repo_layout.py b/tests/test_repo_layout.py index 364602c..bc05bba 100644 --- a/tests/test_repo_layout.py +++ b/tests/test_repo_layout.py @@ -62,7 +62,7 @@ def test_template_scripts_import(): def test_app_discovers_cases(tmp_path): app = _load(ROOT / "app" / "streamlit_app.py", "streamlit_app") - case = tmp_path / "group1-demo" + case = tmp_path / "gildong-demo" (case / "figures").mkdir(parents=True) (case / "plan.yaml").write_text("title: Demo\nquestion: q?\n", encoding="utf-8") (case / "report.md").write_text("# Demo", encoding="utf-8") @@ -71,7 +71,7 @@ def test_app_discovers_cases(tmp_path): (tmp_path / "_template" / "plan.yaml").write_text("title: T\n", encoding="utf-8") cases = app.discover_cases(tmp_path) - assert [c.slug for c in cases] == ["group1-demo"] + assert [c.slug for c in cases] == ["gildong-demo"] assert cases[0].title == "Demo" and cases[0].report and len(cases[0].figures) == 1 assert len(app.discover_cases(tmp_path, include_templates=True)) == 2 # the real repo has at least the template @@ -82,12 +82,12 @@ def test_weekly_activity_runs_on_temp_repo(tmp_path): act = _load(ROOT / "scripts" / "weekly_activity.py", "weekly_activity") git = ["git", "-C", str(tmp_path), "-c", "user.name=t", "-c", "user.email=t@t"] subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) - f = tmp_path / "cases" / "group1-demo" / "plan.yaml" + f = tmp_path / "cases" / "gildong-demo" / "plan.yaml" f.parent.mkdir(parents=True) f.write_text("a: 1\n") subprocess.run([*git, "add", "."], check=True) - subprocess.run([*git, "commit", "-qm", "plan(group1-demo): init"], check=True) + subprocess.run([*git, "commit", "-qm", "plan(gildong-demo): init"], check=True) stats = act.collect(tmp_path, days=7, ref=None) - assert len(stats["cases/group1-demo"].commits) == 1 - assert stats["cases/group1-demo"].added == 1 + assert len(stats["cases/gildong-demo"].commits) == 1 + assert stats["cases/gildong-demo"].added == 1 diff --git a/tests/test_site_build.py b/tests/test_site_build.py index 18ed82a..1ad4ca9 100644 --- a/tests/test_site_build.py +++ b/tests/test_site_build.py @@ -24,3 +24,84 @@ def test_site_builds(tmp_path, monkeypatch): for f in out.rglob("*.html"): text = f.read_text(encoding="utf-8") assert "/home/" not in text and "/tmp/" not in text + + +def _load_build(): + spec = importlib.util.spec_from_file_location("site_build2", ROOT / "site" / "build.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_home_has_chat_and_catalog(tmp_path, monkeypatch): + """대화창이 동작하는 데 필요한 것: ask.js 복사, 카탈로그 JSON, 요소 id.""" + import json + import re + + mod = _load_build() + monkeypatch.setattr(mod, "OUT", tmp_path / "_site") + mod.main() + out = tmp_path / "_site" + assert (out / "ask.js").exists() + home = (out / "index.html").read_text(encoding="utf-8") + for id_ in ("chat-log", "chat-form", "q", "ai-panel", "ai-provider", "ai-key", "mode", "cards"): + assert f'id="{id_}"' in home, id_ + raw = re.search( + r'', home, re.S + ).group(1) + cat = json.loads(raw.replace("<\\/", "