diff --git a/README.md b/README.md index 4159ef1..1bfc0ea 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,82 @@ -# 정책 효과 분석 플랫폼 · policy-effect-analytics-agent +# 정책 효과 분석 플랫폼 -**사람들이 묻는 정책, 정말 효과가 있었을까?** -소셜 반응에서 출발해, 그 주제의 정책을 전부 모으고, 공공데이터로 효과를 추정하는 오픈소스입니다. -결론을 낼 수 없으면 "식별 불가"라고 말하는 것까지가 이 프로젝트의 일입니다. +**그 정책, 정말 효과가 있었을까?** -[대시보드 보기](https://causalinferencelab.github.io/policy-effect-analytics-agent/) · [어떻게 동작하나](https://causalinferencelab.github.io/policy-effect-analytics-agent/architecture.html) · [조별 운영 가이드](docs/ops/group-guide.md) · [GitHub 처음이라면](docs/ops/github-onboarding.md) +뉴스와 SNS에서 사람들이 묻는 정책을 공공데이터로 확인하는 오픈소스입니다. +정책을 받은 곳과 안 받은 곳을 비교하고, 데이터로 판단할 수 없으면 판단할 수 없다고 말합니다. -> 가짜연구소 인과추론팀 × NIPA 오픈업 오픈소스 AI 특화형 2차 트랙3 「에이전틱 AI × 데이터」 +**[사이트 열기](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) ---- +> 가짜연구소 인과추론팀 × 오픈업 오픈소스 AI 특화형 트랙3 「에이전틱 AI × 데이터」 (2026.9 ~ 11) -## 한눈에 보기 +--- -``` -소셜 신호 ─▶ 주제 ─▶ 정책 전부 모으기 ─▶ 세 관문 ─▶ 계획 먼저 ─▶ 효과 추정·판정 -(뉴스·SNS) (신호는 여기까지만) (법제처 조례·고시) (언제·누가·무엇을) (plan.yaml 커밋) (식별됨·조건부·식별 불가) -``` +## 무엇을 하나 -| 원칙 | 왜 | +| 단계 | 하는 일 | |---|---| -| 소셜 신호는 **주제까지만** 정한다 | 화제가 된 정책 하나만 고르면 결과를 보고 사례를 고르는 셈이 된다 | -| 계획을 **먼저 커밋**해야 추정이 실행된다 | 결과를 본 뒤 설계를 바꾸는 것을 막는다 | -| 방법은 **규칙이** 고르고, 수치는 **라이브러리가** 계산한다 | LLM은 주제 매칭과 서술만 돕는다 | -| 처치 지역이 적으면 **무작위화 추론** | 기존 표준오차는 처치 4/25곳에서 크게 과신한다 | -| 한계는 **배지로 공개** | 시뮬레이션·키 대기·확인 필요 상태를 숨기지 않는다 | +| 1. 찾기 | 뉴스·SNS에서 사람들이 궁금해하는 **주제**를 찾습니다 | +| 2. 모으기 | 그 주제의 정책을 **전부** 모읍니다. 어느 지역이 언제 시작했는지 | +| 3. 거르기 | 세 가지를 확인합니다. 언제 시작했나 · 누가 받았나 · 무엇으로 재나 | +| 4. 계획하기 | 데이터를 보기 전에 분석 계획(`plan.yaml`)을 먼저 커밋합니다 | +| 5. 비교하기 | 정책을 받은 곳과 안 받은 곳의 변화를 비교합니다 | +| 6. 말하기 | **효과 근거 있음 · 조건부 · 판단 불가** 중 하나로 씁니다 | + +**왜 정책 하나가 아니라 주제로 보나?** 화제가 된 정책만 골라 분석하면, 결과를 보고 사례를 고르는 것과 같아져 효과가 부풀려집니다. 그래서 화제는 "어느 주제를 볼지"까지만 정하고, 그 주제의 정책을 모두 모아 비교합니다. + +## 사이트에서 볼 수 있는 것 + +- **지금 이슈**: 10·15 토지거래허가구역, 6·27 대출 한도, 민생회복 소비쿠폰, 고유가 피해지원금, K-패스 '모두의 카드' 등 6개 정책의 분석 가이드입니다. 누구와 비교할지, 어떤 방법을 쓸지, 어떤 데이터를 받을 수 있는지 정리했습니다. +- **데이터 지도**: 공공데이터 35개를 역할(누가 언제 받았나 / 무엇이 변했나 / 다른 요인), 단위, 받는 법으로 정리했습니다. 검색과 필터를 쓸 수 있습니다. +- **주제 8개**: 주제마다 정책 타임라인, 세 가지 확인 상태, 받을 수 있는 데이터, 분석 예시를 보여줍니다. ## 5분 만에 돌려 보기 ```bash git clone https://github.com/CausalInferenceLab/policy-effect-analytics-agent.git cd policy-effect-analytics-agent -python -m venv .venv && source .venv/bin/activate +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단계로 실행 -python site/build.py && python -m http.server -d _site # 대시보드를 http://localhost:8000 에서 +make flow CASE=cases/t3-land-permit-2025 # 토지거래허가구역 예시를 6단계로 실행 +python site/build.py && python -m http.server -d _site # 사이트를 http://localhost:8000 에서 보기 ``` -API 키 없이 돌아갑니다. 키가 필요한 데이터는 `.env.example`을 복사해 채우세요(`.env`는 커밋되지 않습니다). +API 키 없이 돌아갑니다. 실제 데이터를 받으려면 `.env.example`을 `.env`로 복사해 키를 넣으세요. `.env`는 커밋되지 않습니다. + +> 토지거래허가구역 예시는 지금 **시뮬레이션 데이터**입니다. 분석 과정을 보여주기 위한 것이고, 실제 정책 효과가 아닙니다. -## 무엇이 들어 있나 +## 폴더 안내 -| 폴더 | 하는 일 | 조원 역할 | +| 폴더 | 하는 일 | 맡는 역할 | |---|---|---| -| [`catalog/`](catalog/) | 주제 6개 · 정책 8개 · 추천 데이터셋 | 문제 정의 | -| [`core/discovery/`](core/discovery/) | 소셜 신호 → 주제 → 정책 목록 | 문제 정의 | -| [`core/adapters/`](core/adapters/) | 국토부 실거래가 · KOSIS · 법제처 · 파일 수집 | 데이터 수집 | -| [`core/estimators/`](core/estimators/) | DiD · 이벤트 스터디 · ITS · 무작위화 추론 · 판정 | 추정 | -| [`core/agent/`](core/agent/) | 6단계 Flow, 사전 등록 게이트, 과잉해석 가드 | 리포트·에이전트 | -| [`cases/`](cases/) | 조별 분석 케이스 (`_template`에서 시작) | 조 전체 | -| [`site/`](site/) | 공개 대시보드 (GitHub Pages) | 리포트·에이전트 | -| [`app/`](app/) | Streamlit 개발용 화면 | — | -| [`docs/`](docs/) | 전략(국내 사례·주제 가이드·계획 작성법), 운영 가이드 | — | +| [`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/) | 국내 사례, 주제 고르기, 계획 작성법, 운영 가이드 | — | ## 조별로 참여하기 -1. **주제 고르기**: [대시보드](https://causalinferencelab.github.io/policy-effect-analytics-agent/#topics)에서 주제를 고르거나 `catalog/topics.yaml`에 제안합니다. -2. **계획 PR**: `cp -r cases/_template cases/group1-<주제>` → `plan.yaml` 작성 → PR로 사전 등록합니다. 작성법은 [plan-guide](docs/strategy/plan-guide.md)를 보세요. -3. **실행·공개**: `make flow CASE=cases/group1-<주제>`를 돌리고 결과를 PR로 올리면, `main`에 반영될 때 대시보드에 자동으로 올라갑니다. +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로 올립니다. 합쳐지면 사이트에 자동으로 올라옵니다. -브랜치는 `group/<설명>`, 수정은 자기 조 폴더만, 병합은 리뷰 1명 + CI 통과 후입니다. 자세한 규칙은 [CONTRIBUTING](CONTRIBUTING.md)에 있습니다. +규칙은 세 가지입니다. 브랜치는 `group<번호>/<설명>`, 수정은 자기 조 폴더만, 합치기는 리뷰 1명과 자동 검사 통과 뒤입니다. 자세한 내용은 [CONTRIBUTING](CONTRIBUTING.md)과 [GitHub가 처음이라면](docs/ops/github-onboarding.md)을 보세요. ## 지금 상태 -- **구현됨**: 주제 매칭 · 6단계 Flow · 사전 등록 게이트 · DiD/이벤트 스터디/ITS · 무작위화 추론 · 과장 표현 가드 · 대시보드 자동 배포(매주 월요일 갱신) -- **키 대기**: 국토부 실거래가(토허구역 예시는 지금 **시뮬레이션 데이터**) · 법제처 조례 자동 수집 -- **다음 단계**: 시차 도입 추정(Callaway–Sant'Anna) · 합성통제 · 검색량으로 선반영 점검 +- **완성**: 주제 찾기 · 데이터 지도 · 6단계 실행 · 계획 커밋 확인 · 이중차분/단절 시계열 · 정책 지역이 적을 때의 무작위화 추론 · 과장 표현 차단 · 사이트 자동 공개(매주 월요일 갱신) +- **데이터 키 대기**: 국토부 실거래가(실제 데이터 전환) · 법제처 조례 자동 수집 +- **다음**: 시차 도입 이중차분(4주차) · 합성통제(5주차) · 검색량으로 "미리 반응했나" 점검 ## 라이선스 -코드는 [MIT](LICENSE)입니다. 데이터는 각 출처의 이용 조건(공공누리 유형 등)을 따르며, 케이스마다 `plan.yaml`의 `data_sources`에 적습니다. +코드는 [MIT](LICENSE)입니다. 데이터는 출처마다 이용 조건이 다르며, 데이터 지도와 각 케이스의 `plan.yaml`에 적어 둡니다. --- -**English.** An open-source platform that starts from public conversation, picks a *topic* (never a single trending policy, to avoid selecting cases on outcomes), collects every policy in that topic from Korean public sources, checks three gates (when / who / what), requires a committed pre-analysis plan, and estimates effects with rule-selected designs (DiD, event study, ITS; randomization inference when few units are treated). Results are published as a static dashboard on GitHub Pages. +**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. diff --git a/catalog/README.md b/catalog/README.md new file mode 100644 index 0000000..2bab4c7 --- /dev/null +++ b/catalog/README.md @@ -0,0 +1,50 @@ +# catalog/ — 데이터 지도 + +정책 효과를 재는 데 필요한 것을 네 개의 파일로 정리합니다. 사이트의 [데이터 지도](https://causalinferencelab.github.io/policy-effect-analytics-agent/data.html)와 [지금 이슈](https://causalinferencelab.github.io/policy-effect-analytics-agent/issues.html)는 이 파일들에서 자동으로 만들어집니다. + +``` +주제 (topics.yaml) ── 속한다 ◀── 이슈 (issues.yaml, 지금 논쟁 중인 정책) + │ │ + └──────── 쓴다 ────────▶ 데이터셋 (datasets.yaml) ── 제공 ──▶ 포털 + 역할 · 단위 · 받는 법 · 이용 조건 +``` + +| 파일 | 무엇 | 언제 고치나 | +|---|---|---| +| `topics.yaml` | 주제 8개. 키워드, 정책 시점, 세 가지 확인(언제·누가·무엇으로) | 새 주제를 제안할 때 | +| `issues.yaml` | 지금 이슈인 정책 6개. 시작일(공식 출처), 비교 방법, 쓸 데이터, 조심할 점 | 새 이슈를 분석하고 싶을 때 | +| `datasets.yaml` | 공공데이터 35개. 역할·단위·받는 법·이용 조건·확인 날짜 | 쓸 만한 데이터를 찾았을 때 | +| `policies.yaml` | 분석 예시가 있는 정책의 설계 정보 | 조가 분석 케이스를 만들 때 | + +## 데이터셋의 세 가지 역할 + +| 역할 | 질문 | 예시 | +|---|---|---| +| 처치 `treatment` | 누가, 언제 정책을 받았나 | 법제처 조례 목록, 지역사랑상품권 판매정책 | +| 결과 `outcome` | 무엇이 달라졌나 | 아파트 실거래가, 교통사고 통계, 출생등록자 수 | +| 통제 `covariate` | 결과에 영향을 준 다른 요인 | 기상 관측, 주민등록 인구 | + +세 역할이 모두 있어야 분석을 시작할 수 있습니다. + +## 데이터셋 추가하기 + +`datasets.yaml`에 아래처럼 한 항목을 넣고 PR을 올리세요. **원본 페이지를 열어 확인한 값만** 적고, 모르면 `확인필요`라고 씁니다. + +```yaml +- id: "15126468" # 포털의 데이터 ID + name: 국토교통부_아파트 매매 실거래가 상세 자료 + provider: 국토교통부 + portal: data.go.kr # data.go.kr | kosis | seoul | law | ... + url: https://www.data.go.kr/data/15126468/openapi.do + access: open_api # open_api | file | manual + approval: 자동승인 # 자동승인 | 즉시 | 심의 | 없음 | 확인필요 + license: other # KOGL-1~4 | other | 확인필요 + space: 시군구 # 전국 | 시도 | 시군구 | 읍면동 | 지점 | 개별 + time: 월 # 일 | 주 | 월 | 분기 | 연 | 수시 + measures: [거래금액, 전용면적, 계약일] + roles: [outcome] + topics: [housing-regulation] + verified: "2026-09-27 페이지 확인" +``` + +`python -c "from core.discovery.ontology import check_links; print(check_links())"`가 빈 목록이면 연결이 맞습니다(CI에서도 확인합니다). diff --git a/catalog/datasets.yaml b/catalog/datasets.yaml new file mode 100644 index 0000000..280117b --- /dev/null +++ b/catalog/datasets.yaml @@ -0,0 +1,763 @@ +# 데이터 지도(온톨로지) — 정책 효과 분석에 쓸 수 있는 공공데이터 목록 +# +# 한 데이터셋이 분석에서 맡는 역할(roles) +# treatment : 누가·언제 정책을 받았나 (처치) +# outcome : 무엇이 달라졌나 (결과) +# covariate : 결과에 영향을 주는 다른 요인 (통제) +# 단위: space(전국·시도·시군구·읍면동·지점·개별) × time(일·주·월·분기·연·수시) +# 접근: access(open_api·file·manual) + approval(자동승인·즉시·심의·없음·확인필요) +# license: KOGL-1~4(공공누리) | other(포털 표기 "이용허락범위 제한 없음" 등, license_note 참고) | 확인필요 +# verified: 사람이(또는 에이전트가) 원본 페이지를 열어 확인한 날짜와 방법. 확인하지 못한 값은 '확인필요'. +# 스키마: core/discovery/ontology.py · 설명: catalog/README.md +version: 1 +datasets: +- id: '15126468' + name: 국토교통부_아파트 매매 실거래가 상세 자료 + provider: 국토교통부 + portal: data.go.kr + url: https://www.data.go.kr/data/15126468/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음 / 무료 / 개발계정 10,000건' + space: 시군구 + time: 월 + period: 미기재 + measures: + - 거래금액 + - 전용면적 + - 층 + - 계약일 + - 법정동 + roles: + - outcome + topics: + - housing-regulation + notes: 요청 키 = 법정동코드 앞 5자리 + 계약년월(YYYYMM). REST/XML. + verified: 2026-09-27 페이지 확인 +- id: '15134761' + name: 한국부동산원_부동산통계 조회 서비스 + provider: 한국부동산원 + portal: data.go.kr + url: https://www.data.go.kr/data/15134761/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 확인필요 + time: 확인필요 + period: 미기재 + measures: + - 한국부동산원 공표 부동산통계(가격지수 등) + roles: + - outcome + topics: + - housing-regulation + notes: R-ONE 자체 API(https://www.reb.or.kr/r-one/openapi/SttsApiTblData.do)도 있음. STATBL_ID·DTACYCLE_CD·CLS_ID 파라미터, 로그인 후 + 인증키 발급, 키 없이 호출하면 5건만 반환(R-ONE 개발가이드 확인). 주간 주택가격동향 표 ID는 확인 못함. + verified: 2026-09-27 페이지 확인 (R-ONE 개발가이드도 확인) +- id: '15044283' + name: 한국부동산원_공동주택 실거래가격지수_아파트_월별 지역별 매매 + provider: 한국부동산원 + portal: data.go.kr + url: https://www.data.go.kr/data/15044283/fileData.do + access: file + approval: 없음 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 시도 + time: 월 + period: 페이지 미기재 (파일명 기준 20250331까지) + measures: + - 아파트 실거래가격지수 + roles: + - outcome + topics: + - housing-regulation + notes: 업데이트 주기 '수시(자동 갱신)'이나 최종 수정일 2025-06-10. 2025년 하반기 정책 분석엔 부족할 수 있음 → 15134761/R-ONE으로 보완. + verified: 2026-09-27 페이지 확인 +- id: OA-21275 + name: 서울시 부동산 실거래가 정보 + provider: 서울특별시 + portal: seoul + url: https://data.seoul.go.kr/dataList/OA-21275/S/1/datasetView.do + access: open_api + approval: 즉시 + license: KOGL-1 + space: 개별 + time: 일 + period: 미기재 + measures: + - 실거래 매매 건별 정보(자치구·법정동·거래금액 등) + roles: + - outcome + topics: + - housing-regulation + notes: 갱신주기 매일1회. 서울 25개 구 내부 비교용. 이전 버전 OA-15548은 2022.05.12 종료. + verified: 2026-09-27 페이지 확인 (approval은 서울 열린데이터광장 인증키 즉시발급 관행 기준, 페이지 문구로는 미확인) +- id: '3076115' + name: 울산광역시_토지거래허가구역 지정현황 + provider: 울산광역시 + portal: data.go.kr + url: https://www.data.go.kr/data/3076115/fileData.do + access: file + approval: 없음 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 개별 + time: 연 + period: 페이지 미기재 (최종 2026-05-22) + measures: + - 지정지역 + - 지정기간 + - 지정면적 + - 지정위치 + roles: + - treatment + topics: + - housing-regulation + notes: 지자체가 올린 지정현황 파일의 예시. 전국 단위 처치 목록은 없음 → 국토부·시도 고시문으로 직접 코딩해야 함. + verified: 2026-09-27 페이지 확인 +- id: '15125217' + name: 한국조폐공사_지역사랑상품권_지자체별_판매정책정보 + provider: 한국조폐공사 + portal: data.go.kr + url: https://www.data.go.kr/data/15125217/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 시군구 + time: 수시 + period: 미기재 + measures: + - 사용처지역코드 + - 할인정책적용시작일자 + - 할인정책적용종료일자 + - 할인율 + - 월간구매제한금액 + roles: + - treatment + topics: + - local-currency + - consumer-support + notes: 처치 시점·강도(할인율)를 지자체별로 직접 얻을 수 있는 핵심 자료. 조폐공사 운영대행 지자체만 포함될 가능성 → 커버리지 확인 필요. + verified: 2026-09-27 페이지 확인 +- id: '15108292' + name: 한국조폐공사_지역사랑상품권_결제정보 + provider: 한국조폐공사 + portal: data.go.kr + url: https://www.data.go.kr/data/15108292/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 시군구 + time: 확인필요 + period: 미기재 + measures: + - 지역별 결제금액 + - 결제 건수 + roles: + - outcome + topics: + - local-currency + - consumer-support + verified: 2026-09-27 페이지 확인 +- id: '15108296' + name: 한국조폐공사_지역사랑상품권_운영정보 + provider: 한국조폐공사 + portal: data.go.kr + url: https://www.data.go.kr/data/15108296/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 시군구 + time: 확인필요 + period: 미기재 + measures: + - 카드발행수량 + - 휴대가입자 수 + - 지류판매액 + - 지류회수액 + roles: + - treatment + topics: + - local-currency + - consumer-support + verified: 2026-09-27 페이지 확인 +- id: '15107284' + name: 행정안전부_통계연보_지역사랑상품권 + provider: 행정안전부 + portal: data.go.kr + url: https://www.data.go.kr/data/15107284/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 확인필요 + time: 연 + period: 미기재 + measures: + - 지역사랑상품권 발행·판매 연간 통계 + roles: + - treatment + topics: + - local-currency + verified: 2026-09-27 페이지 확인 (세부 항목은 미확인) +- id: '15085746' + name: 행정안전부_지역사랑상품권 월별 판매 현황 + provider: 행정안전부 + portal: data.go.kr + url: https://www.data.go.kr/data/15085746/fileData.do + access: file + approval: 없음 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 확인필요 + time: 월 + period: 페이지 미기재 (1회성, 2021-08-09 등록) + measures: + - 지역사랑상품권 월별 판매액 + roles: + - treatment + topics: + - local-currency + notes: 1회성 데이터라 2021년 이후 갱신 없음. 과거(코로나기) 분석용. + verified: 2026-09-27 페이지 확인 +- id: '15083033' + name: 소상공인시장진흥공단_상가(상권)정보 + provider: 소상공인시장진흥공단 + portal: data.go.kr + url: https://www.data.go.kr/data/15083033/fileData.do + access: file + approval: 없음 + license: 확인필요 + space: 개별 + time: 분기 + period: 페이지 미기재 (최신 20260630, 차기 2026-10-31) + measures: + - 상호 + - 업종코드 + - 주소 + - 좌표 + roles: + - outcome + - covariate + topics: + - local-currency + - retail-hours + - consumer-support + notes: API판은 15012005(소상공인시장진흥공단_상가(상권)정보_API, 자동승인). 분기 스냅샷 → 점포 수·업종 구성 변화로 결과/통제 변수 구성. + verified: 2026-09-27 페이지 확인 (파일판·API판 모두 확인, 라이선스 문구는 추출 못함) +- id: '15070297' + name: 한국도로교통공단_시도 시군구별 교통사고 통계 + provider: 한국도로교통공단 + portal: data.go.kr + url: https://www.data.go.kr/data/15070297/fileData.do + access: file + approval: 없음 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 시군구 + time: 연 + period: 페이지 미기재 (최신 20251231, 차기 2027-08-03) + measures: + - 사고건수 + - 사망자수 + - 부상자수 + roles: + - outcome + topics: + - traffic-safety + verified: 2026-09-27 페이지 확인 +- id: '15056770' + name: 한국도로교통공단_지자체별 대상 교통사고 통계 + provider: 한국도로교통공단 + portal: data.go.kr + url: https://www.data.go.kr/data/15056770/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 시군구 + time: 연 + period: 미기재 + measures: + - 부문별(어린이·보행자·음주 등) 교통사고 건수·사상자 + roles: + - outcome + topics: + - traffic-safety + verified: 2026-09-27 페이지 확인 +- id: '15058311' + name: 한국도로교통공단_어린이보호구역내 어린이 교통사고 다발지역 + provider: 한국도로교통공단 + portal: data.go.kr + url: https://www.data.go.kr/data/15058311/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 지점 + time: 연 + period: 미기재 + measures: + - 스쿨존 내 12세 이하 어린이 사고 다발지점 + - 사고건수 + roles: + - outcome + topics: + - traffic-safety + notes: 다발지역만 제공(조건 충족 지점) → 전체 사고가 아님. 선택 편향 주의. + verified: 2026-09-27 페이지 확인 +- id: '15012891' + name: 전국어린이보호구역표준데이터 + provider: 지방자치단체 + portal: data.go.kr + url: https://www.data.go.kr/data/15012891/standard.do + access: open_api + approval: 자동승인 + license: 확인필요 + space: 개별 + time: 수시 + period: 페이지 미기재 (갱신주기 반기, 수정 2026-09-22) + measures: + - 보호구역 위치 + - CCTV 설치 여부·대수 등 + roles: + - treatment + topics: + - traffic-safety + notes: 민식이법(스쿨존) 처치 단위. 지정일 필드 유무는 확인필요. + verified: 2026-09-27 페이지 확인 +- id: '15028200' + name: 전국무인교통단속카메라표준데이터 + provider: 경찰청(지방경찰청 기초자료 입력), 지방자치단체 + portal: data.go.kr + url: https://www.data.go.kr/data/15028200/standard.do + access: open_api + approval: 자동승인 + license: 확인필요 + space: 지점 + time: 수시 + period: 페이지 미기재 (갱신주기 반기, 수정 2026-06-19) + measures: + - 단속카메라 위치 + - 제한속도 + - 설치연도 + - 보호구역 구분 + roles: + - treatment + topics: + - traffic-safety + notes: 제한속도(50/30) 필드로 안전속도5030 처치 강도 근사 가능. 필드명은 표준데이터 명세로 재확인. + verified: 2026-09-27 페이지 확인 +- id: '15094185' + name: 한국도로교통공단_음주운전 교통사고 일별 시도별 통계 + provider: 한국도로교통공단 + portal: data.go.kr + url: https://www.data.go.kr/data/15094185/fileData.do + access: file + approval: 없음 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 시도 + time: 일 + period: 2013~2017 + measures: + - 사고건수 + - 사망자수 + - 부상자수 + roles: + - outcome + topics: + - traffic-safety + notes: 1회성. 2018년 이후는 없음 → 윤창호법(2018~2019) 사후기간 확보 불가, 15056770 연간 음주 부문으로 보완. + verified: 2026-09-27 페이지 확인 +- id: '15073861' + name: 한국환경공단_에어코리아_대기오염정보 + provider: 한국환경공단 + portal: data.go.kr + url: https://www.data.go.kr/data/15073861/openapi.do + access: open_api + approval: 자동승인 + license: KOGL-3 + license_note: '포털 표기: 공공저작물 출처표시, 변경금지 (제3유형) — 가공 데이터 재배포 시 주의' + space: 지점 + time: 일 + period: 미기재 + measures: + - PM10 + - PM2.5 + - NO2 + - O3 + - CO + - SO2 측정값 + roles: + - outcome + topics: + - air-quality + notes: 실시간·시간 단위 조회 위주. 장기 과거 자료는 에어코리아 확정자료 다운로드 필요할 수 있음(미확인). + verified: 2026-09-27 페이지 확인 +- id: OA-2218 + name: 서울시 일별 평균 대기오염도 정보 + provider: 서울특별시 + portal: seoul + url: https://data.seoul.go.kr/dataList/OA-2218/S/1/datasetView.do + access: open_api + approval: 즉시 + license: KOGL-1 + space: 지점 + time: 일 + period: 미기재 + measures: + - 측정소별 일평균 대기오염 농도 + roles: + - outcome + topics: + - air-quality + notes: 녹색교통지역(사대문 안) 측정소 vs 그 밖 서울 측정소 비교용. + verified: 2026-09-27 페이지 확인 +- id: '15156644' + name: 한국환경공단_노후차 운행제한 정보 조회 서비스 + provider: 한국환경공단 + portal: data.go.kr + url: https://www.data.go.kr/data/15156644/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 확인필요 + time: 확인필요 + period: 페이지 미기재 (등록 2025-12-17) + measures: + - 노후차 운행제한(비상저감조치·계절관리제) 정보 + roles: + - treatment + topics: + - air-quality + notes: '파일판: 15132867(발령 및 단속제외), 15122707(단속정보, 연간).' + verified: 2026-09-27 페이지 확인 +- id: '15122707' + name: 한국환경공단_노후차 운행제한 단속정보 + provider: 한국환경공단 + portal: data.go.kr + url: https://www.data.go.kr/data/15122707/fileData.do + access: file + approval: 없음 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 확인필요 + time: 연 + period: 페이지 미기재 (최신 20260228) + measures: + - 노후차 운행제한 단속 건수 + roles: + - treatment + topics: + - air-quality + verified: 2026-09-27 페이지 확인 +- id: '15142381' + name: 한국전력거래소_계절관리제 석탄발전 감축실적 + provider: 한국전력거래소 + portal: data.go.kr + url: https://www.data.go.kr/data/15142381/fileData.do + access: file + approval: 없음 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 전국 + time: 확인필요 + period: 2019.12-2024.3 + measures: + - 계절관리제 기간 석탄발전 감축 실적 + roles: + - treatment + topics: + - air-quality + notes: 계절관리제 처치 강도(감축량) 보조지표. 1회성. + verified: 2026-09-27 페이지 확인 +- id: '15059093' + name: 기상청_지상(종관, ASOS) 일자료 조회서비스 + provider: 기상청 + portal: data.go.kr + url: https://www.data.go.kr/data/15059093/openapi.do + access: open_api + approval: 자동승인 + license: KOGL-1 + space: 지점 + time: 일 + period: 미기재 + measures: + - 기온 + - 강수량 + - 풍속 + - 습도 등 일 기상 + roles: + - covariate + topics: + - air-quality + - traffic-safety + notes: 대기질 분석의 필수 통제변수(기상). 교통사고(강우·결빙) 통제에도 사용. + verified: 2026-09-27 페이지 확인 +- id: '15154948' + name: 행정안전부_생활_대규모점포 조회서비스 + provider: 행정안전부 + portal: data.go.kr + url: https://www.data.go.kr/data/15154948/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 개별 + time: 수시 + period: 페이지 미기재 (등록 2025-11-27) + measures: + - 인허가일자 + - 영업상태 + - 사업장명 + - 소재지주소 + - 업태(대형마트·백화점 등) + roles: + - treatment + topics: + - retail-hours + notes: 파일판 15045013(수시 자동갱신). 처치 단위(대형마트 위치)와 지자체 매핑용. + verified: 2026-09-27 페이지 확인 (파일판 15045013도 확인) +- id: '15057575' + name: 법제처_자치법규 목록 조회 + provider: 법제처 + portal: law + url: https://www.data.go.kr/data/15057575/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 시군구 + time: 수시 + period: 미기재 + measures: + - 자치법규ID + - 자치법규명 + - 공포일자 + - 시행일자 + - 지자체기관명 + - 제개정구분 + roles: + - treatment + topics: + - retail-hours + - birth-marriage + - local-currency + notes: LINK형 → 실제 호출은 http://www.law.go.kr/DRF/lawSearch.do?target=ordin (OC 인증값, query, org, ancYd 공포일자 범위, type=XML/JSON; + 법제처 가이드 확인). 본문은 15058294(자치법규 본문 조회). 의무휴업일 평일 전환·출산장려금 조례 개정 시점을 지자체별로 뽑는 처치 시점 자료. + verified: 2026-09-27 페이지 확인 (open.law.go.kr 가이드도 확인) +- id: '15061360' + name: 산업통상부_주요 유통업체 전년동월대비 매출증가율 추이 + provider: 산업통상부 + portal: data.go.kr + url: https://www.data.go.kr/data/15061360/fileData.do + access: file + approval: 없음 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 전국 + time: 월 + period: 페이지 미기재 (최신 20260731, 차기 2026-10-04) + measures: + - 오프라인 15개사 업태별 전년동월대비 매출증가율 + roles: + - outcome + topics: + - retail-hours + notes: 전국 집계라 지역 DiD 불가. ITS·배경 추세용. + verified: 2026-09-27 페이지 확인 +- id: '15058527' + name: 경기도_시군별 출산장려 및 양육비 지원현황 + provider: 경기도 + portal: data.go.kr + url: https://www.data.go.kr/data/15058527/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 시군구 + time: 수시 + period: 미기재 + measures: + - 시군명 + - 사업명 + - 첫째·둘째·셋째 이상 지원금 + - 지원기간 + - 지급기준 + - 근거 조례 + roles: + - treatment + topics: + - birth-marriage + notes: LINK형(경기데이터드림). 경기 31개 시군만. 현재 스냅샷이라 과거 금액 변경 이력은 15057575(조례 개정)로 복원. + verified: 2026-09-27 페이지 확인 +- id: '15108075' + name: 행정안전부_행정동별(통반단위) 성별 출생등록자수 + provider: 행정안전부 + portal: data.go.kr + url: https://www.data.go.kr/data/15108075/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 읍면동 + time: 월 + period: 미기재 + measures: + - 총출생자수 + - 남자출생자수 + - 여자출생자수 + roles: + - outcome + topics: + - birth-marriage + notes: 주민등록 출생등록 기준(통계청 인구동향 출생과 기준 다름). + verified: 2026-09-27 페이지 확인 +- id: '15108072' + name: 행정안전부_행정동별(통반단위) 성/연령별 주민등록 인구수 + provider: 행정안전부 + portal: data.go.kr + url: https://www.data.go.kr/data/15108072/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 읍면동 + time: 월 + period: 미기재 + measures: + - 성별·연령별 주민등록 인구 + roles: + - covariate + topics: + - birth-marriage + - local-currency + - traffic-safety + notes: 출생률 분모(가임기 여성 인구)·인구 통제변수. 파일판 15097972(월간). + verified: 2026-09-27 페이지 확인 (파일판 15097972도 확인) +- id: DT_1B8000G + name: 월.분기.연간 인구동향(출생,사망,혼인,이혼) + provider: 통계청 + portal: kosis + url: https://kosis.kr/statHtml/statHtml.do?orgId=101&tblId=DT_1B8000G + access: open_api + approval: 즉시 + license: 확인필요 + space: 확인필요 + time: 월 + period: 확인필요 + measures: + - 출생아수 + - 사망자수 + - 혼인건수 + - 이혼건수 + roles: + - outcome + topics: + - birth-marriage + notes: KOSIS OpenAPI(인증키 필요)로 수집. 지역 단위(시도/시군구) 미확인. + verified: 2026-09-27 검색 색인 표 이름만 확인 (KOSIS 페이지 JS·오류로 본문 미확인) +- id: DT_1B81A28 + name: 시군구/ 모의 평균 출산연령, 모의 연령별(5세간격) 출생 + provider: 통계청 + portal: kosis + url: https://kosis.kr/statHtml/statHtml.do?orgId=101&tblId=DT_1B81A28 + access: open_api + approval: 즉시 + license: 확인필요 + space: 시군구 + time: 연 + period: 확인필요 + measures: + - 모의 연령별 출생아수 + - 평균 출산연령 + roles: + - outcome + topics: + - birth-marriage + verified: 2026-09-27 검색 색인 표 이름만 확인 (KOSIS 본문 미확인) +- id: DT_1B040A3 + name: 행정구역(시군구)별, 성별 인구수 + provider: 행정안전부(KOSIS 수록) + portal: kosis + url: https://kosis.kr/statHtml/statHtml.do?orgId=101&tblId=DT_1B040A3 + access: open_api + approval: 즉시 + license: 확인필요 + space: 시군구 + time: 월 + period: 확인필요 + measures: + - 시군구 성별 주민등록 인구 + roles: + - covariate + topics: + - birth-marriage + - housing-regulation + - local-currency + verified: 2026-09-27 검색 색인 표 이름만 확인 (KOSIS 본문 미확인) +- id: '15142080' + name: 국토교통부_대중교통 이용인원수 + provider: 국토교통부 + portal: data.go.kr + url: https://www.data.go.kr/data/15142080/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 확인필요 + time: 일 + period: 페이지 미기재 (등록 2025-02-25) + measures: + - 버스·지하철 일별 이용인원 + roles: + - outcome + topics: + - public-transit + verified: 2026-09-27 페이지 확인 +- id: '15142075' + name: 국토교통부_대중교통 수단간분담률 + provider: 국토교통부 + portal: data.go.kr + url: https://www.data.go.kr/data/15142075/openapi.do + access: open_api + approval: 자동승인 + license: other + license_note: '포털 표기: 이용허락범위 제한 없음' + space: 확인필요 + time: 일 + period: 페이지 미기재 (등록 2025-02-25) + measures: + - 버스·지하철 수단 분담률 + roles: + - outcome + topics: + - public-transit + verified: 2026-09-27 페이지 확인 +- id: OA-12914 + name: 서울시 지하철호선별 역별 승하차 인원 정보 + provider: 서울특별시 + portal: seoul + url: https://data.seoul.go.kr/dataList/OA-12914/S/1/datasetView.do + access: open_api + approval: 즉시 + license: KOGL-1 + space: 지점 + time: 일 + period: 미기재 + measures: + - 역별 일 승차·하차 인원 + roles: + - outcome + topics: + - public-transit + notes: 갱신주기 매일, 원본 교통카드 정산시스템. 시간대별은 OA-12252(매월 5일 갱신). + verified: 2026-09-27 페이지 확인 diff --git a/catalog/issues.yaml b/catalog/issues.yaml new file mode 100644 index 0000000..aa2ec3e --- /dev/null +++ b/catalog/issues.yaml @@ -0,0 +1,143 @@ +# 지금 이슈인 정책 — 효과 추정 가이드 +# 이슈는 '주제'에 속합니다(topic). 분석은 이슈 하나가 아니라 그 주제의 정책 전체를 대상으로 하되, +# 이 목록은 조가 어디서부터 볼지 정하는 출발점입니다. 시행일은 모두 source 링크에서 확인했습니다. +# design: did_simultaneous(동시 도입 이중차분) | did_staggered(시차 도입) | scm(합성통제) | its(단절 시계열) +# datasets: catalog/datasets.yaml 의 id +version: 1 +issues: +- id: seoul-ltp-1015-2025 + name: 10·15 부동산 대책 — 서울 전역·경기 12곳 토지거래허가구역 지정 + topic: housing-regulation + issue: 서울 전역을 한 번에 묶은 거래 허가제가 집값을 잡았나, 거래만 얼어붙게 했나. + effective: '2025-10-20' + source: https://www.korea.kr/news/policyNewsView.do?newsId=148950973 + treatment: 서울 신규 21개 구 + 경기 12곳(과천·광명·성남 분당/수정/중원·수원 영통/장안/팔달·안양 동안·용인 수지·의왕·하남) + control: 지정되지 않은 수도권 시군구(인천, 경기 나머지). 서울 기존 4개 구(강남·서초·송파·용산)는 이미 처치 → 제외 또는 별도 코호트 + outcomes: + - 아파트 매매 거래건수 + - 거래금액(㎡당) + - 가격지수 + design: did_simultaneous + datasets: + - '15126468' + - '15134761' + - OA-21275 + - DT_1B040A3 + pitfalls: + - 같은 날 투기과열지구·조정대상지역 지정과 대출 규제(15억 초과 주담대 한도 축소)가 함께 발표 → 토허제만의 효과로 분리 불가, '패키지 효과'로 해석. + - 발표(10.15)~효력(10.20) 사이 선취매 가능성 → 기준일을 발표일로도 돌려보기. + - 비지정 경기 지역으로 수요가 옮겨가는 풍선효과 → 대조군이 오염됨(SUTVA 위반). + - 실거래 신고 기한(계약 후 30일) 때문에 최근 월 거래가 과소 집계. + difficulty: 2 +- id: seoul-ltp-gangnam-2025 + name: 강남3구·용산 아파트 토지거래허가구역 지정 (2025.3) + topic: housing-regulation + issue: 해제 직후 급등하자 다시 묶은 조치가 가격 급등을 멈췄나. 10·15와 묶어 단계적 도입 비교 가능. + effective: '2025-03-24' + source: https://www.korea.kr/news/policyNewsView.do?newsId=148940714 + treatment: 강남·서초·송파·용산구 전체 아파트 (2025.3.24 1차 코호트) → 나머지 서울 21개 구 (2025.10.20 2차 코호트) + control: 아직 지정되지 않은 서울·수도권 구(각 시점 기준 not-yet-treated) + outcomes: + - 아파트 매매 거래건수 + - 거래가격 + design: did_staggered + datasets: + - '15126468' + - '15134761' + - OA-21275 + pitfalls: + - 직전 토지거래허가 해제 조치의 급등 효과와 겹침 → 사전추세가 평행하지 않음. + - 지정 대상을 '아파트'로 한정 → 결과 변수도 아파트로 맞춰야 함. + - 단계적 DiD는 TWFE 편향 → Callaway-Sant'Anna 등 이질효과 추정기 사용. + difficulty: 3 +- id: mortgage-cap-627-2025 + name: 6·27 가계부채 관리 강화 방안 — 수도권·규제지역 주담대 6억 원 한도 + topic: housing-regulation + issue: 대출 한도 상한이 고가 주택 거래를 줄였나, 지방과의 격차를 키웠나. + effective: '2025-06-28' + source: https://www.korea.kr/news/policyNewsView.do?newsId=148945077 + treatment: 수도권·규제지역 주택 구입 (주담대 6억 한도, 6개월 전입의무) + control: 비수도권 시군구 + outcomes: + - 아파트 매매 거래건수 + - 고가(대출 6억 초과 필요) 구간 거래 비중 + - 거래가격 + design: did_simultaneous + datasets: + - '15126468' + - '15134761' + - DT_1B040A3 + pitfalls: + - 가계대출 총량 축소는 전 금융권 전국 적용 → 대조군(지방)도 부분 처치. + - 효과는 대출 6억 초과가 필요한 고가 구간에 몰림 → 가격대별 삼중차분(DDD) 권장. + - 사후기간이 10·15 대책(2025.10.15) 전까지 약 3.5개월로 짧음. + difficulty: 2 +- id: consumption-coupon-2025 + name: 민생회복 소비쿠폰 (2025) + topic: consumer-support + issue: 전 국민 현금성 쿠폰이 지역 소상공인 매출을 실제로 늘렸나, 비수도권 가산금은 효과가 더 컸나. + effective: '2025-07-21' + source: https://www.korea.kr/news/policyNewsView.do?newsId=148945532 + treatment: 1차 1인당 기본 15만 원 + 비수도권 3만 원 + 인구감소지역 5만 원 가산(수급자 등 추가) → 지역별 1인당 지급 강도 차이 + control: 수도권(가산 없음, 최저 강도)을 기준 집단으로 둔 강도(dose) 비교 + outcomes: + - 지역사랑상품권 결제금액·건수 + - 소상공인 점포 수 변화 + design: did_simultaneous + datasets: + - '15108292' + - '15108296' + - '15125217' + - '15083033' + - DT_1B040A3 + pitfalls: + - 전국 동시 지급 → 순수 대조군 없음. 가산금 차이(강도)로만 식별. + - 인구감소지역은 원래 소비 추세가 다름 → 평행추세 검정 필수. + - 지급 수단(카드·선불·지역사랑상품권) 선택 비율이 지역마다 달라 상품권 결제 API는 쿠폰 소비 일부만 보임. + - 2차 지급(9.22 예정, 국민 90%에 10만 원)과 사후기간이 겹침. + difficulty: 2 +- id: high-oil-support-2026 + name: 고유가 피해지원금 (2026) + topic: consumer-support + issue: 소비쿠폰을 1년 만에 다시 푼 지원금이 효과가 있었나, 지역 차등(10~25만 원)이 소비 격차를 줄였나. + effective: '2026-04-27' + source: https://www.korea.kr/news/policyNewsView.do?newsId=148963023 + treatment: 2차(2026.5.18~7.3, 소득하위 70%) 수도권 10만 / 비수도권 15만 / 인구감소 우대지역 20만 / 특별지역 25만 원 → 4단계 강도 + control: 수도권(최저 강도) 기준 강도 비교, 사용기한(2026.8.31) 이후를 사후 종료 구간으로 + outcomes: + - 지역사랑상품권 결제금액·건수 + - 소상공인 점포 수 + design: did_simultaneous + datasets: + - '15108292' + - '15125217' + - '15083033' + - DT_1B040A3 + pitfalls: + - 지원 이유(고유가)가 소비 자체를 줄이는 충격 → 처치와 결과가 같은 원인에 묶임. + - 2025 소비쿠폰 직후라 이월효과·학습효과 가능. + - 1차(4.27, 취약계층)와 2차(5.18) 시점이 달라 기준일 정의가 결과를 바꿈. + difficulty: 3 +- id: modu-card-2026 + name: K-패스 개편 '모두의 카드' (기준금액 초과분 전액 환급) + topic: public-transit + issue: 정액 초과분 전액 환급이 대중교통 이용을 늘렸나, 재정 대비 효과는 충분한가. + effective: 2026-01 + effective_note: 공식 출처에 '2026년 1월부터 시행'으로만 적혀 있어 일(day) 미확인 + source: https://www.korea.kr/news/policyFocusView.do?newsId=148957978&pkgId=49500831 + treatment: '전국 K-패스 이용자 자동 전환. 기준금액 차등: 수도권 일반형 62,000원 / 일반 지방권 55,000원(대중교통 여건이 나쁜 지역일수록 낮음)' + control: 전국 동시 시행 → 자체 대조군 없음. 시행 전 추세(ITS) 또는 기준금액이 낮은 지역 vs 수도권 강도 비교 + outcomes: + - 대중교통 일별 이용인원 + - 수단 분담률 + - 서울 지하철 역별 승하차 + design: its + datasets: + - '15142080' + - '15142075' + - OA-12914 + pitfalls: + - 전국 동시 도입 + 자동 전환 → 대조군이 없어 계절성·요금 인상·노선 변경을 모두 통제해야 함. + - 서울은 기후동행카드가 이미 있어 추가 처치 강도가 다른 지역보다 약함. + - 가입자·환급액 자료는 비공개 → 처치 강도를 지역 평균으로 근사해야 함. + difficulty: 3 diff --git a/catalog/topics.yaml b/catalog/topics.yaml index 24ef0b3..9ed6738 100644 --- a/catalog/topics.yaml +++ b/catalog/topics.yaml @@ -22,6 +22,7 @@ topics: events: - {date: 2020-06-23, what: "잠실·삼성·대치·청담 토지거래허가구역 지정"} - {date: 2025-02-12, what: "잠실·삼성·대치·청담 지정 해제 (발표·시행 월 기준)"} + - {date: 2025-06-28, what: "6·27 대책 — 수도권·규제지역 주담대 6억 원 한도", source: "https://www.korea.kr/news/policyNewsView.do?newsId=148945077"} - {date: 2025-03-24, what: "강남·서초·송파·용산 전역 지정", source: "https://www.ajunews.com/view/20250324064239711"} - {date: 2025-10-20, what: "서울 전역·경기 12곳 지정 (10·15 대책)", source: "https://www.korea.kr/news/policyNewsView.do?newsId=148950973"} gates: @@ -96,3 +97,36 @@ topics: when: {status: pass, note: "조례 제정일 자동 수집"} who: {status: pass, note: "도입 vs 미도입 시군구"} what: {status: key, note: "KOSIS 시군구 출생·혼인 — 키 필요"} + + - id: consumer-support + name: 소비 지원금·쿠폰 + question: 민생회복 소비쿠폰·고유가 피해지원금 같은 현금성 지원이 동네 소비를 늘렸나? + keywords: [소비쿠폰, 민생회복, 지원금, 피해지원금, 고유가, 재난지원금, 소상공인, 동네 가게, 소비, 매출] + policies: [] + collect: + method: curated + note: "정부 발표문에서 지역별 지급액(수도권·비수도권·인구감소지역 차등)을 표로 정리 → 지급 강도로 비교" + events: + - {date: 2025-07-21, what: "민생회복 소비쿠폰 1차 지급 시작", source: "https://www.korea.kr/news/policyNewsView.do?newsId=148945532"} + - {date: 2026-04-27, what: "고유가 피해지원금 1차 지급 시작", source: "https://www.korea.kr/news/policyNewsView.do?newsId=148963023"} + gates: + when: {status: pass, note: "정부 발표로 지급 시작일 확인"} + who: {status: check, note: "전국 동시 지급 → 지역별 지급액 차이(강도)로만 비교 가능"} + what: {status: key, note: "조폐공사 지역사랑상품권 결제정보 API(자동승인) — 키 필요"} + pitfalls: ["순수 대조군이 없음 → '더 받은 지역 vs 덜 받은 지역'으로 비교", "지급 수단(카드·상품권) 선택이 지역마다 달라 상품권 결제는 일부만 보임", "1차·2차 지급 시점이 겹침"] + + - id: public-transit + name: 대중교통 요금 지원 + question: K-패스 '모두의 카드' 같은 요금 환급이 대중교통 이용을 늘렸나? + keywords: [대중교통, K-패스, 케이패스, 모두의 카드, 기후동행카드, 교통비, 환급, 버스, 지하철, 승하차] + policies: [] + collect: + method: curated + note: "지역별 환급 기준금액(수도권 62,000원 / 일반 지방권 55,000원 등)을 정리해 강도 비교" + events: + - {date: 2026-01-01, display: "2026.01", what: "K-패스 '모두의 카드' 시행 (공식 자료에 '1월부터'로만 표기)", source: "https://www.korea.kr/news/policyFocusView.do?newsId=148957978&pkgId=49500831"} + gates: + when: {status: check, note: "시행 '월'만 확인, 정확한 날짜 확인 필요"} + who: {status: check, note: "전국 동시 → 기준금액이 다른 지역끼리 비교하거나 시행 전후 추세 비교"} + what: {status: key, note: "국토부 대중교통 이용인원 API(자동승인) — 키 필요"} + pitfalls: ["서울은 기후동행카드가 먼저 있어 추가 효과가 약함", "요금 인상·노선 변경·계절 요인을 함께 통제해야 함"] diff --git a/core/discovery/catalog.py b/core/discovery/catalog.py index 50832a2..1894302 100644 --- a/core/discovery/catalog.py +++ b/core/discovery/catalog.py @@ -94,6 +94,11 @@ class Event(BaseModel): date: date what: str source: str | None = None + display: str | None = None # 날짜를 '월'까지만 아는 경우 표시용 (예: "2026.01") + + @property + def label(self) -> str: + return self.display or self.date.strftime("%Y.%m.%d") class Topic(BaseModel): diff --git a/core/discovery/ontology.py b/core/discovery/ontology.py new file mode 100644 index 0000000..51c0c2d --- /dev/null +++ b/core/discovery/ontology.py @@ -0,0 +1,109 @@ +"""데이터 지도(온톨로지): 주제 · 이슈 · 데이터셋과 그 관계. + + 주제(Topic) ──포함──▶ 이슈(Issue, 지금 논쟁 중인 정책) + │ │ + └──쓴다(역할)──▶ 데이터셋(Dataset) ──제공──▶ 포털(Portal) + │ + ├─ 역할: 처치(누가·언제) / 결과(무엇이 변했나) / 통제(다른 요인) + ├─ 단위: 공간(시군구 등) × 시간(월 등) + └─ 접근: 오픈API·파일 + 승인 방식 + 라이선스 + +파일: catalog/topics.yaml · catalog/issues.yaml · catalog/datasets.yaml +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel + +from .catalog import ROOT, Design, load_topics + +Role = Literal["treatment", "outcome", "covariate"] +ROLE_KO = { + "treatment": "처치 · 누가 언제", + "outcome": "결과 · 무엇이 변했나", + "covariate": "통제 · 다른 요인", +} +ROLE_SHORT = {"treatment": "처치", "outcome": "결과", "covariate": "통제"} +PORTAL_KO = { + "data.go.kr": "공공데이터포털", + "kosis": "KOSIS 국가통계포털", + "seoul": "서울 열린데이터광장", + "law": "법제처 국가법령정보", + "airkorea": "에어코리아", + "reb": "한국부동산원 R-ONE", + "koroad": "도로교통공단", + "komsco": "한국조폐공사", + "other": "기타", +} +ACCESS_KO = {"open_api": "오픈API", "file": "파일", "manual": "수동"} +DESIGN_PLAIN = { + "did_simultaneous": "같은 날 시작한 지역과 안 한 지역의 변화 차이를 비교 (이중차분)", + "did_staggered": "지역마다 시작일이 다를 때, 아직 안 한 지역과 순서대로 비교 (시차 도입 이중차분)", + "scm": "정책 지역이 몇 곳뿐일 때, 비슷한 지역을 섞어 '가상의 대조 지역'을 만듦 (합성통제)", + "its": "대조 지역이 없을 때, 시행 전 추세가 이어졌다면의 값과 비교 (단절 시계열)", +} + + +class Dataset(BaseModel): + id: str + name: str + provider: str + portal: str + url: str + access: Literal["open_api", "file", "manual"] + approval: str + license: str + license_note: str | None = None + space: str + time: str + period: str = "미기재" + measures: list[str] = [] + roles: list[Role] + topics: list[str] + notes: str | None = None + verified: str + + +class Issue(BaseModel): + id: str + name: str + topic: str + issue: str + effective: str + effective_note: str | None = None + source: str + treatment: str + control: str + outcomes: list[str] + design: Design + datasets: list[str] + pitfalls: list[str] = [] + difficulty: int + + +def load_datasets(path: Path = ROOT / "catalog" / "datasets.yaml") -> list[Dataset]: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + return [Dataset.model_validate(d) for d in raw["datasets"]] + + +def load_issues(path: Path = ROOT / "catalog" / "issues.yaml") -> list[Issue]: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + return [Issue.model_validate(d) for d in raw["issues"]] + + +def check_links() -> list[str]: + """깨진 관계를 찾는다: 없는 주제·데이터셋을 가리키는 항목, 중복 id.""" + topics = {t.id for t in load_topics()} + ds = load_datasets() + ids = [d.id for d in ds] + errs = [f"중복 데이터셋 id: {i}" for i in {i for i in ids if ids.count(i) > 1}] + errs += [f"{d.id}: 없는 주제 {t}" for d in ds for t in d.topics if t not in topics] + for i in load_issues(): + if i.topic not in topics: + errs.append(f"{i.id}: 없는 주제 {i.topic}") + errs += [f"{i.id}: 없는 데이터셋 {x}" for x in i.datasets if x not in ids] + return errs diff --git a/docs/ops/group-guide.md b/docs/ops/group-guide.md index 8e0b999..7c630fe 100644 --- a/docs/ops/group-guide.md +++ b/docs/ops/group-guide.md @@ -1,11 +1,13 @@ # 조별 운영 가이드 -조(약 5명)마다 공공데이터로 정책 효과 질문을 **하나** 정하고, 공용 저장소의 `cases/<조-주제>/` 폴더 하나에 결과를 쌓습니다. -전체 흐름은 6단계 에이전트 Flow(README의 Flow 절)와 같고, 조원 역할도 이 단계에 맞춰 나눕니다. - -- 완성 예시: [`cases/_example_night_clinic/`](../../cases/_example_night_clinic/) — 합성 데이터로 6단계를 끝까지 돌린 샘플 -- 주제 후보: [`docs/strategy/topic-guide.md`](../strategy/topic-guide.md) (T1~T8) -- 분석계획 작성법: [`docs/strategy/plan-guide.md`](../strategy/plan-guide.md) +조(약 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인 기준, 겸임 가능) diff --git a/site/architecture.py b/site/architecture.py index 7fde971..1a32b64 100644 --- a/site/architecture.py +++ b/site/architecture.py @@ -1,158 +1,163 @@ -"""아키텍처 페이지 본문 — 지침에서 출발해 어떤 판단을 거쳐 지금 구조가 됐는지 순서대로 설명한다.""" +"""구조 페이지 본문 — 과정 목표에서 출발해 어떤 판단을 거쳐 지금 구조가 됐는지 순서대로 보여준다.""" from __future__ import annotations REPO = "https://github.com/CausalInferenceLab/policy-effect-analytics-agent" GOALS = [ - ("문제를 데이터로 정의하고 효과를 추정", "공공·사회 문제의 해법이 실제로 어떤 효과를 냈는지"), - ("전 과정을 LLM 에이전트로 자동화", "수집 → 지표 구조화 → 효과 추정 → 리포팅"), - ("누구나 바로 쓰는 오픈소스", "GitHub와 분석 플랫폼으로 공유"), + ("문제를 데이터로 정의하고 효과를 추정", "공공·사회 문제의 해법이 실제로 효과를 냈는지"), + ("전 과정을 AI 에이전트로 자동화", "수집 → 지표 정리 → 효과 추정 → 리포트"), + ("누구나 바로 쓰는 오픈소스", "GitHub와 분석 사이트로 공유"), ] -# (질문, 근거, 결정, 어디에 반영됐나) +# (질문, 근거, 결정, 코드 위치) DECISIONS = [ ( - "소셜 신호로 무엇을 정하나?", - "출발 키트 05: 화제성으로 사례를 고르면 결과를 보고 고르는 셈 → 효과 과대 추정", - "신호는 주제까지만 정한다. 분석은 그 주제의 정책 전체로", + "소셜 반응으로 무엇을 정하나?", + "출발 키트: 화제가 된 정책만 고르면 결과를 보고 사례를 고르는 셈이 되어 효과가 부풀려진다", + "소셜 반응은 주제까지만 정한다. 분석은 그 주제의 정책 전체로", "catalog/topics.yaml · core/discovery", ), + ( + "필요한 데이터를 어떻게 정리하나?", + "효과를 재려면 '누가 언제 받았나', '무엇이 변했나', '다른 요인' 세 종류가 모두 있어야 한다", + "데이터셋마다 역할·단위·받는 법·이용 조건을 붙여 데이터 지도로 관리한다", + "catalog/datasets.yaml · core/discovery/ontology.py", + ), ( "정책 목록은 어떻게 모으나?", - "출발 키트 05: 법제처 조례 API는 무료·즉시 승인, '어느 지역이 언제부터'가 한 줄에", - "조례형 주제는 법제처 API로 자동 수집, 고시형 주제는 에이전트가 고시문에서 이력 수집", - "core/adapters/law.py · 주간 Actions", + "출발 키트: 법제처 조례 API는 무료·즉시 승인이고 '어느 지역이 언제부터'가 한 줄에 나온다", + "조례형 주제는 법제처 API로 자동 수집, 나머지는 고시·발표 자료로 정리", + "core/adapters/law.py · 매주 자동 갱신", ), ( "분석해도 되는 주제인지 어떻게 거르나?", - "출발 키트의 세 관문: 언제 시작했나 · 누가 받았나 · 무엇으로 재나", - "주제마다 관문 상태를 기록하고, 통과 못 하면 추정하지 않는다", - "topics.yaml gates · 대시보드 배지", + "출발 키트의 세 가지 확인: 언제 시작했나 · 누가 받았나 · 무엇으로 재나", + "주제마다 세 가지 상태를 기록하고, 통과하지 못하면 계산하지 않는다", + "topics.yaml gates · 사이트 배지", ), ( - "LLM에게 어디까지 맡기나?", - "목표 2(자동화) vs 인과 판단의 과장 위험. 선행 시스템 CAIS도 규칙 기반 방법 선택을 택함", - "LLM은 주제 매칭 보조·서술만. 추정 방법은 데이터 모양으로 규칙이 고르고, 수치는 라이브러리가 계산", + "AI에게 어디까지 맡기나?", + "자동화가 목표지만 인과 판단을 AI에 맡기면 과장 위험이 크다. 비슷한 오픈소스(CAIS)도 규칙으로 방법을 고른다", + "AI는 주제 찾기와 글쓰기만. 방법은 데이터 모양을 보고 규칙이, 숫자는 검증된 라이브러리가 계산", "core/estimators · core/agent/guard.py", ), ( "결과를 보고 계획을 바꾸면?", - "공공 평가 기관의 사전 등록 관행(GSA OES, World Bank DIME)", - "plan.yaml을 git에 커밋해야 추정 단계가 실행된다", - "core/agent/nodes.py ① 게이트", + "공공 평가 기관의 사전 등록 관행(미국 GSA OES, 세계은행 DIME)", + "plan.yaml을 git에 커밋해야 계산 단계가 실행된다", + "core/agent/nodes.py", ), ( - "처치 지역이 몇 곳뿐이면?", - "토허구역 사례: 25개 구 중 4개. 기존 표준오차는 사전추세가 없어도 50~80% 기각", - "처치 단위 10곳 미만이면 무작위화 추론으로 자동 전환", + "정책 지역이 몇 곳뿐이면?", + "토허구역 예시는 25개 구 중 4개. 기존 방식은 추세 차이가 없어도 50~80% '차이 있음'으로 판정했다", + "정책 지역이 10곳 미만이면 무작위화 추론으로 자동 전환", "core/estimators/ri.py", ), ( "누구나 보려면 어디에 공개하나?", - "출발 키트 04: Streamlit은 서버가 필요, 서버 없이 GitHub Pages로도 가능", - "정적 대시보드를 Actions가 만들어 Pages로 배포. Streamlit은 개발용", + "출발 키트: Streamlit은 서버가 켜져 있어야 한다. 서버 없이 GitHub Pages로도 된다", + "이 사이트를 GitHub Actions가 만들어 Pages로 공개한다. Streamlit은 개발용", "site/ · .github/workflows/pages.yml", ), ] LAYERS = [ ( - "인터페이스", + "화면", "누가 보나", [ - ("대시보드", "GitHub Pages · 누구나"), - ("Streamlit 앱", "개발·디버깅용"), - ("CLI", "make flow CASE=…"), + ("이 사이트", "GitHub Pages · 누구나"), + ("Streamlit", "개발·점검용"), + ("명령어", "make flow CASE=…"), ], ), ( "에이전트", "순서대로 실행", [ - ("① 문제 정의", "plan.yaml 검증 + 사전 등록 게이트"), - ("② 수집 · ③ 지표", "어댑터 실행, 품질 점검"), - ("④ 추정", "규칙이 고른 방법으로"), - ("⑤ 가드 · ⑥ 리포트", "과장 차단, 재현 기록"), + ("① 계획 확인", "plan.yaml 검증 + 커밋 확인"), + ("② 수집 · ③ 정리", "데이터 받기, 품질 점검"), + ("④ 계산", "규칙이 고른 방법으로"), + ("⑤ 점검 · ⑥ 리포트", "과장 차단, 재현 기록"), ], ), ( - "분석 코어", - "LLM 없음, 결정론", + "분석 엔진", + "AI 없이 계산만", [ - ("분석계획 스키마", "core/schema"), - ("추정기", "DiD · 이벤트 스터디 · ITS"), - ("추론", "무작위화 추론(소수 처치)"), - ("판정", "식별됨 · 조건부 · 식별 불가"), + ("계획 양식", "core/schema"), + ("추정", "이중차분 · 단절 시계열"), + ("추론", "무작위화 추론(정책 지역 소수)"), + ("판정", "근거 있음 · 조건부 · 판단 불가"), ], ), ( - "데이터", - "무엇을 모으나", + "데이터 지도", + "무엇을 어디서", [ - ("카탈로그", "주제 6 · 정책 8 · 데이터셋"), - ("어댑터", "국토부 · KOSIS · 법제처 · 파일"), - ("스냅샷", "출처·라이선스·해시 기록"), + ("주제 · 이슈", "topics.yaml · issues.yaml"), + ("데이터셋", "역할 · 단위 · 받는 법"), + ("수집기", "국토부 · KOSIS · 법제처 · 파일"), ], ), ] CODE_MAP = [ - ("catalog/", "주제·정책·데이터셋 카탈로그", "문제 정의", "W2–W3"), - ("core/discovery/", "소셜 신호 → 주제 → 정책 목록", "문제 정의", "W3"), - ("core/adapters/", "공공데이터·법제처 수집", "데이터 수집", "W2"), - ("core/schema/", "분석계획(plan.yaml) 규칙", "문제 정의", "W3"), - ("core/estimators/", "추정·검증·판정", "추정", "W4–W5"), - ("core/agent/", "6단계 Flow, 과잉해석 가드", "리포트·에이전트", "W5–W6"), - ("cases/<조>/", "조별 분석 케이스", "조 전체", "W3–W7"), - ("site/", "공개 대시보드", "리포트·에이전트", "W7"), + ("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주"), ] STATUS = [ ( "go", - "구현됨", - "주제 매칭 · 6단계 Flow · 사전 등록 게이트 · 이벤트 스터디/DiD/ITS · 무작위화 추론 · 과장 표현 가드 · 대시보드 자동 배포", + "완성", + "주제 찾기 · 데이터 지도 · 6단계 실행 · 계획 커밋 확인 · 이중차분/단절 시계열 · 무작위화 추론 · 과장 차단 · 사이트 자동 공개", ), ( "warn", - "키 대기", - "국토부 실거래가(실데이터 전환) · 법제처 조례 자동 수집(시크릿 등록 후 다음 배포부터)", + "데이터 키 대기", + "국토부 실거래가(토허구역 예시는 지금 시뮬레이션) · 법제처 조례 자동 수집(인증값 등록 후 다음 갱신부터)", ), ( "info", - "다음 단계", - "시차 도입 추정(Callaway–Sant'Anna, W4) · 합성통제(W5) · 검색량으로 선반영 점검(데이터랩 키)", + "다음", + "시차 도입 이중차분(4주차) · 합성통제(5주차) · 검색량으로 '미리 반응했나' 점검", ), ] def body(e) -> str: goals = "".join( - f'
{i + 1}

{e(a)}

{e(b)}

' + f'
{i + 1}

{e(a)}

{e(b)}

' for i, (a, b) in enumerate(GOALS) ) decisions = "".join( - f'
' - f"결정 {i + 1:02d}

{q}

" - f'

근거 — {why}

→ {what}

' - f'

{where}

' + f'
질문 {i + 1:02d}' + f"

{q}

근거 · {why}

" + f"

→ {what}

{where}

" for i, (q, why, what, where) in enumerate(DECISIONS) ) layers = "".join( - f'
' + '
' f'

{name}

{hint}
' - f'
' + '
' + "".join( f'
{a}' - f'
{b}
' + f'
{b}
' for a, b in items ) + "
" for name, hint, items in LAYERS ) code = "".join( - f"/', '')}'>{e(p)}" + f"{e(p)}" f"{e(r)}{e(w)}{e(wk)}" for p, r, w, wk in CODE_MAP ) @@ -161,21 +166,21 @@ def body(e) -> str: for c, lab, t in STATUS ) return f""" -
ARCHITECTURE -

어떻게 동작하나

-

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

+
구조 +

어떻게 만들어졌나

+

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

-

출발점: 과정 목표

{goals}
+

출발점: 과정 목표

{goals}
-

결정의 흐름

목표를 코드로 옮기면서 부딪힌 질문 일곱 개입니다.

+

질문과 결정

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

{decisions}
-

네 개의 층

위에서 아래로 호출합니다. LLM은 에이전트 층에서 서술과 주제 매칭만 돕고, 분석 코어에는 들어가지 않습니다.

+

네 개의 층

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

{layers}
-

옆에서 GitHub Actions가 돕습니다: 테스트(CI) · 매주 데이터 갱신 · 대시보드 배포.

+

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

-

코드 지도

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

+

폴더 안내

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

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

지금 상태

{status}
diff --git a/site/build.py b/site/build.py index ce481c7..aacf14d 100644 --- a/site/build.py +++ b/site/build.py @@ -1,10 +1,10 @@ """정적 대시보드 빌더 → _site/ (GitHub Pages, 서버 없음) - python site/build.py # _site/index.html, _site/topics/.html - python -m http.server -d _site # 로컬 미리보기 + python site/build.py # _site/ 에 HTML 생성 + python -m http.server -d _site # http://localhost:8000 에서 미리보기 -입력(모두 레포에 커밋된 파일): catalog/topics.yaml, catalog/policies.yaml, -catalog/snapshots/law_.csv(있으면), cases//flow_log.json·figures/*.png +읽는 파일(모두 레포에 커밋된 것): catalog/topics.yaml · issues.yaml · datasets.yaml · policies.yaml, +catalog/snapshots/law_<주제>.csv(있으면), cases/<케이스>/flow_log.json · figures/*.png """ from __future__ import annotations @@ -16,138 +16,189 @@ from pathlib import Path import pandas as pd +import yaml ROOT = Path(__file__).resolve().parents[1] +HERE = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(HERE)) from core.agent.guard import TRIGGER_KO # noqa: E402 -from core.discovery.catalog import ( # noqa: E402 - GATE_KO, - GATE_STATUS_KO, - load_catalog, - load_topics, +from core.discovery.catalog import GATE_STATUS_KO, load_catalog, load_topics # noqa: E402 +from core.discovery.ontology import ( # noqa: E402 + ACCESS_KO, + DESIGN_PLAIN, + PORTAL_KO, + ROLE_KO, + ROLE_SHORT, + load_datasets, + load_issues, ) OUT = ROOT / "_site" REPO = "https://github.com/CausalInferenceLab/policy-effect-analytics-agent" +CSS = (HERE / "style.css").read_text(encoding="utf-8") + +GATE_Q = {"when": "언제 시작했나", "who": "누가 받았나", "what": "무엇으로 재나"} +GATE_CLASS = {"pass": "go", "check": "warn", "key": "warn", "fail": "stop"} VERDICT = { - "identified": ("식별됨", "go"), + "identified": ("효과 근거 있음", "go"), "conditional": ("조건부", "warn"), - "not_identified": ("식별 불가", "stop"), + "not_identified": ("판단 불가", "stop"), +} +DESIGN_SHORT = { + "did_simultaneous": "이중차분", + "did_staggered": "시차 도입 이중차분", + "scm": "합성통제", + "its": "단절 시계열", +} +SUPPORT = { + "did_simultaneous": ("바로 분석 가능", "go"), + "its": ("바로 분석 가능", "go"), + "did_staggered": ("추정 모듈 준비 중 (4주차)", "warn"), + "scm": ("추정 모듈 준비 중 (5주차)", "warn"), } -GATE_CLASS = {"pass": "go", "check": "warn", "key": "warn", "fail": "stop"} -ACCESS_KO = {"api_key": "API 키(자동승인)", "file": "파일", "manual": "수동 수집"} COLLECT_KO = { - "law_ordinance": "법제처 조례 자동 수집", - "notice": "고시문에서 이력 수집", - "curated": "카탈로그 목록", + "law_ordinance": "법제처 조례 API로 자동 수집", + "notice": "고시·보도자료에서 이력 정리", + "curated": "발표 자료를 표로 정리", } +NAV = [ + ("index.html", "홈"), + ("issues.html", "지금 이슈"), + ("index.html#topics", "주제"), + ("data.html", "데이터 지도"), + ("architecture.html", "구조"), + ("index.html#join", "참여하기"), +] +TERMS = [ + ( + "처치 · 대조", + "정책을 받은 쪽이 처치, 받지 않은 쪽이 대조입니다. 대조는 '정책이 없었다면'을 대신 보여줍니다.", + ), + ( + "이중차분", + "정책 지역의 전후 변화에서 대조 지역의 전후 변화를 뺍니다. 경기·계절처럼 둘 다 겪은 변화가 지워집니다.", + ), + ( + "평행 추세", + "정책 전에 두 집단이 나란히 움직였어야 이중차분이 성립합니다. 가장 먼저 확인합니다.", + ), + ( + "사전 등록", + "데이터를 보기 전에 무엇을 어떻게 볼지 plan.yaml에 적어 커밋합니다. 결과를 보고 말을 바꾸지 않기 위해서입니다.", + ), + ( + "무작위화 추론", + "정책 지역이 몇 곳뿐일 때, 가짜 정책 지역을 수백 번 뽑아 비교해 우연인지 판단합니다.", + ), + ("판정", "효과 근거 있음 · 조건부 · 판단 불가 세 가지입니다. 판단 불가도 정직한 결과입니다."), +] e = html.escape -CSS = """ -:root{--paper:#f6f7f9;--surface:#fff;--sunk:#eef0f3;--ink:#14171c;--ink2:#434954;--muted:#6b7280;--hair:#dde0e5; ---accent:#1b5e9b;--accent-soft:#e3eef8;--go:#1e6a4d;--go-bg:#e0f1e8;--warn:#8a5900;--warn-bg:#fbeed3;--stop:#9c2b33;--stop-bg:#f8e2e4; ---sans:"Pretendard","Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,monospace} -@media (prefers-color-scheme:dark){:root{--paper:#111317;--surface:#1a1d23;--sunk:#22262d;--ink:#eceef2;--ink2:#c2c7d0;--muted:#8e95a2; ---hair:#2d323a;--accent:#79b2e8;--accent-soft:#1a2b3c;--go:#7fd3ab;--go-bg:#15302a;--warn:#e8b45c;--warn-bg:#33291a;--stop:#ee8f96;--stop-bg:#371e21}} -*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.7 var(--sans);word-break:keep-all} -a{color:var(--accent)}code{font-family:var(--mono);font-size:.9em;background:var(--sunk);padding:1px 5px;border-radius:4px} -.nav{position:sticky;top:0;z-index:5;background:color-mix(in srgb,var(--paper) 88%,transparent);backdrop-filter:blur(8px);border-bottom:1px solid var(--hair)} -.nav .in{max-width:1080px;margin:0 auto;padding:10px 16px;display:flex;gap:16px;align-items:center;justify-content:space-between;flex-wrap:wrap} -.nav b a{color:var(--ink);text-decoration:none}.nav .links{display:flex;gap:14px;flex-wrap:wrap;font-size:14px}.nav .links a{color:var(--ink2);text-decoration:none} -.nav .links a:hover{color:var(--accent)}.wrap{max-width:1080px;margin:0 auto;padding:0 16px 80px} -.hero{padding:64px 0 28px}.eyebrow{font-size:13px;font-weight:700;letter-spacing:.06em;color:var(--accent)} -h1{font-size:clamp(30px,5vw,48px);line-height:1.18;margin:10px 0 14px;letter-spacing:-.02em} -h2{font-size:clamp(22px,3vw,28px);margin:0 0 6px;letter-spacing:-.01em}h3{font-size:17px;margin:0 0 6px} -.lede{color:var(--ink2);font-size:18px;max-width:62ch;margin:0}.sub{color:var(--ink2);max-width:66ch;margin:0 0 20px} -section{padding:56px 0 8px;scroll-margin-top:56px}.muted{color:var(--muted);font-size:14px} -.cta{display:flex;gap:10px;flex-wrap:wrap;margin:22px 0 0}.btn{display:inline-block;padding:10px 18px;border-radius:999px;font-weight:600;text-decoration:none;font-size:15px} -.btn.primary{background:var(--accent);color:#fff}.btn.ghost{border:1px solid var(--hair);color:var(--ink);background:var(--surface)} -.card{background:var(--surface);border:1px solid var(--hair);border-radius:14px;padding:20px} -.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}.grid3{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))} -.icon{width:36px;height:36px;border-radius:10px;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-weight:800;margin-bottom:10px} -.badge{display:inline-block;font-size:12.5px;font-weight:600;padding:2px 9px;border-radius:999px;white-space:nowrap} -.go{color:var(--go);background:var(--go-bg)}.warn{color:var(--warn);background:var(--warn-bg)}.stop{color:var(--stop);background:var(--stop-bg)}.info{color:var(--accent);background:var(--accent-soft)} -.gates{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0 4px} -.ask{margin-top:26px}.ask textarea{width:100%;min-height:76px;font:inherit;font-size:17px;padding:14px 16px;border-radius:14px;border:1px solid var(--hair);background:var(--surface);color:var(--ink)} -.ask textarea:focus{outline:2px solid var(--accent);border-color:transparent}.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)} -.hit{outline:2px solid var(--accent);outline-offset:2px}.topic{text-decoration:none;color:inherit;display:block}.topic:hover{border-color:var(--accent)} -.list .topic{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,2fr) auto;gap:14px;align-items:center} -.list{display:flex;flex-direction:column;gap:8px}.list .topic p{margin:0} -table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--hair);vertical-align:top}th{color:var(--muted);font-weight:600} -.tablewrap{overflow-x:auto;background:var(--surface);border:1px solid var(--hair);border-radius:12px} -.banner{border-radius:12px;padding:12px 16px;margin:14px 0;font-weight:600} -.figs{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(300px,1fr))}.figs img{width:100%;background:#fff;border-radius:10px;border:1px solid var(--hair)} -svg text{fill:var(--ink2);font:12.5px var(--sans)}svg .strong{fill:var(--ink);font-weight:700}ul,ol{padding-left:20px} -.flow{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));counter-reset:s} -.flow .card{padding:14px;position:relative}.flow .n{font:700 12px var(--mono);color:var(--accent)}.flow b{display:block;margin:2px 0 4px}.flow p{margin:0;font-size:14px;color:var(--ink2)} -.flow .who{margin-top:8px;font-size:12.5px;color:var(--muted)}.split{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(300px,1fr))} -.vs .card h3{display:flex;gap:8px;align-items:center}.stat{font-size:28px;font-weight:800;letter-spacing:-.02em} -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:40px}} -""" - -def page(title: str, body: str, depth: int = 0) -> str: +# ─── 공통 ────────────────────────────────────────────────────────────────────── +def page(title: str, body: str, current: str, depth: int = 0) -> str: up = "../" * depth - home = f"{up}index.html" + links = "".join( + f'{label}' + for href, label in NAV + ) return f""" {e(title)} - + - +
{body} -
가짜연구소 인과추론팀 × 오픈업 오픈소스 AI 특화형 트랙3 · 모든 수치와 판정은 레포의 catalog/·cases/에서 -GitHub Actions가 자동 생성합니다(매주 월요일 갱신). 시뮬레이션 데이터로 만든 결과에는 별도 표시가 붙습니다. -· 소스 코드 (MIT)
""" +
가짜연구소 인과추론팀 × 오픈업 오픈소스 AI 특화형 트랙3 · 이 사이트의 숫자와 판정은 레포의 catalog/·cases/에서 +자동으로 만들어집니다(매주 월요일 갱신). 시뮬레이션 결과에는 따로 표시가 붙습니다. · 소스 코드 (MIT)
+
""" def gate_badges(t) -> str: return "".join( - f'' - f"{GATE_KO[k]} · {GATE_STATUS_KO[g.status]}" + f'{GATE_Q[k]} · {GATE_STATUS_KO[g.status]}' for k, g in t.gates.items() ) +def role_badges(roles) -> str: + return "".join(f'{ROLE_SHORT[r]}' for r in roles) + + +def dots(n: int) -> str: + return f'{"●" * n}{"○" * (3 - n)}' + + +def readiness(t) -> tuple[str, str, str]: + st = {g.status for g in t.gates.values()} + if "fail" in st: + return "fail", "관문 탈락", "stop" + if "check" in st: + return "check", "확인할 것 있음", "warn" + if "key" in st: + return "key", "데이터 키만 있으면 됨", "warn" + return "ready", "분석 가능", "go" + + +def case_logs(t, policies) -> list[str]: + by_id = {p.id: p for p in policies} + return [ + by_id[i].sample_case + for i in t.policies + if by_id[i].sample_case and (ROOT / by_id[i].sample_case / "flow_log.json").exists() + ] + + +def dataset_table(ds) -> str: + rows = "".join( + f"{e(d.name)}
{e(d.provider)} · {PORTAL_KO.get(d.portal, d.portal)}
" + f"{role_badges(d.roles)}{e(d.space)} × {e(d.time)}" + f"{ACCESS_KO[d.access]}
{e(d.approval)}
" + for d in ds + ) + return ( + "
" + f"{rows}
데이터셋역할단위받는 법
" + ) + + +# ─── 그림 ───────────────────────────────────────────────────────────────────── def timeline_svg(events) -> str: - """축 위에 점, 아래에 연도 눈금. 설명은 HTML 목록으로 (긴 한국어 라벨이 겹치지 않게).""" if not events: return "" d = pd.to_datetime([ev.date for ev in events]) lo = pd.Timestamp(year=d.min().year, month=1, day=1) hi = pd.Timestamp(year=d.max().year + 1, month=1, day=1) - w, x0, x1 = 900, 20, 880 + x0, x1 = 20, 880 def x(t): return x0 + (x1 - x0) * ((t - lo) / (hi - lo)) + def yx(y): + return x(pd.Timestamp(year=y, month=1, day=1)) + ticks = "".join( - f'' - f'{y}' + f'' + f'{y}' for y in range(lo.year, hi.year + 1) ) - dots = "".join( + marks = "".join( f'' f'{i + 1}' for i, t in enumerate(d) ) items = "".join( - f"
  • {ev.date:%Y.%m.%d} {e(ev.what)}" + f"
  • {ev.label} {e(ev.what)}" + (f' 출처' if ev.source else "") + "
  • " for ev in events ) return ( - f'' - f'{ticks}{dots}' - f"
      {items}
    " + '' + f'{ticks}{marks}
      {items}
    ' ) @@ -157,26 +208,21 @@ def bars_svg(counts: pd.Series, label: str) -> str: w, h, pad = 900, 220, 30 bw = (w - 2 * pad) / len(counts) mx = counts.max() - bars = [] + parts = [] for i, (k, v) in enumerate(counts.items()): - bh = (h - 60) * v / mx - xx = pad + i * bw - bars.append( - f'' - f'{k}' - f'{v}' + bh, cx = (h - 60) * v / mx, pad + i * bw + bw / 2 + parts.append( + f'' + f'{k}' + f'{v}' ) - return f'{"".join(bars)}' + return f'{"".join(parts)}' +# ─── 분석 결과 ──────────────────────────────────────────────────────────────── def case_block(case_rel: str, depth: int) -> str: case = ROOT / case_rel - log_p = case / "flow_log.json" - if not log_p.exists(): - return '

    아직 실행 결과가 없습니다.

    ' - log = json.loads(log_p.read_text(encoding="utf-8")) - import yaml - + log = json.loads((case / "flow_log.json").read_text(encoding="utf-8")) plan = yaml.safe_load((case / "plan.yaml").read_text(encoding="utf-8")) dest = OUT / "cases" / case.name dest.mkdir(parents=True, exist_ok=True) @@ -188,24 +234,22 @@ def case_block(case_rel: str, depth: int) -> str: out = [] if plan.get("synthetic_data"): out.append( - '" + '" ) out.append(f"

    질문 {e(plan['question'].strip())}

    ") out.append(f'

    종합 판정 {label}

    ') names = {o["col"]: o["name"] for o in plan["outcomes"]} - rows = [] - for r in log["results"]: - lab, c = VERDICT[r["verdict"]] - why = ", ".join(TRIGGER_KO.get(x, x) for x in r.get("triggers") or []) or "-" - rows.append( - f"{e(names.get(r['outcome'], r['outcome']))}{r['estimate']:+.3f}" - f"[{r['ci_low']:.3f}, {r['ci_high']:.3f}]{r['p_value']:.3f}" - f'{lab}{e(why)}' - ) + rows = "".join( + f"{e(names.get(r['outcome'], r['outcome']))}{r['estimate']:+.3f}" + f"{r['ci_low']:.3f} ~ {r['ci_high']:.3f}" + f'{VERDICT[r["verdict"]][0]}' + f"{e(', '.join(TRIGGER_KO.get(x, x) for x in r.get('triggers') or []) or '-')}" + for r in log["results"] + ) out.append( - '
    ' - f"{''.join(rows)}
    결과 지표추정치95% 신뢰구간p판정경고
    " + "
    " + f"{rows}
    결과 지표추정 효과95% 범위판정주의
    " ) narr = (log.get("guard") or {}).get("narrative") if narr: @@ -213,123 +257,57 @@ def case_block(case_rel: str, depth: int) -> str: if figs: out.append(f'
    {"".join(figs)}
    ') out.append( - f'

    분석계획·코드·리포트: {case_rel}

    ' + f'

    분석 계획·코드·리포트: {case_rel}

    ' ) return "\n".join(out) -def topic_page(t, policies) -> str: - by_id = {p.id: p for p in policies} - ps = [by_id[i] for i in t.policies] - body = [ - f"
    주제" - f"

    {e(t.name)}

    {e(t.question)}

    ", - f'
    {gate_badges(t)}
    ', - "

    1. 이 주제의 정책 전체

    ", - f"

    수집 방법: {COLLECT_KO[t.collect.method]} — {e(t.collect.note)}

    ", - timeline_svg(t.events), - ] - snap = ROOT / "catalog" / "snapshots" / f"law_{t.id}.csv" - if t.collect.method == "law_ordinance": - if snap.exists(): - df = pd.read_csv(snap) - cnt = df.dropna(subset=["year"]).astype({"year": int}).groupby("year").size() - body += [ - f"

    조례 시행연도 분포 (법제처, {len(df)}건)

    ", - bars_svg(cnt, "조례 시행연도"), - "

    현행 조례의 시행일자 기준입니다. 처음 제정된 날은 연혁 조회로 확인합니다.

    ", - ] - else: - body.append( - "

    법제처 API로 조례를 자동 수집할 주제입니다. 레포 시크릿 LAW_OC를 " - "등록하면 GitHub Actions가 다음 배포 때 지자체별 조례 목록과 시행일을 채웁니다.

    " - ) - body.append("

    2. 세 가지 관문

    ") - for k, g in t.gates.items(): - body.append( - f'

    {GATE_KO[k]}

    ' - f"{GATE_STATUS_KO[g.status]}

    {e(g.note)}

    " - ) - body.append("
    ") - if ps: - body.append("

    3. 분석 가능한 정책과 추천 데이터

    ") - for p in ps: - ds = "".join( - f"{e(d.name)}{e(d.provider)}" - f"{e(d.granularity)}{ACCESS_KO[d.access]}{e(d.license)}" - for d in p.datasets - ) - sup = "go" if p.support == "지원" else "warn" - body.append( - f'

    {e(p.name)}

    ' - f"

    {e(p.summary)}

    " - f'

    설계 {p.design_ko} {e(p.support)}

    ' - f"
    " - f"{ds}
    데이터셋제공단위접근라이선스
    " - + ( - "
    식별상 함정
      " - + "".join(f"
    • {e(x)}
    • " for x in p.pitfalls) - + "
    " - if p.pitfalls - else "" - ) - + "
    " - ) - cases = [p.sample_case for p in ps if p.sample_case] - if cases: - body.append("

    4. 효과 분석 결과

    ") - body += [case_block(c, depth=1) for c in cases] - if t.pitfalls: - body.append( - "

    주의할 점

      " + "".join(f"
    • {e(x)}
    • " for x in t.pitfalls) + "
    " - ) - body.append( - "

    소셜 신호는 여기에만 씁니다

    • 이 주제를 고르는 데
    • " - "
    • 시행 전 1년 검색량·기사 수로 미리 반응했는지 점검하는 데
    • " - "
    • '정책이 알려지긴 했나'를 묻는다면 관심도 자체를 결과 지표로
    " - ) - return page(t.name, "\n".join(body), depth=1) - - -def readiness(t) -> tuple[str, str, str]: - """주제 전체 준비 상태 (필터용 키, 라벨, 색).""" - st = {g.status for g in t.gates.values()} - if "fail" in st: - return "fail", "관문 탈락", "stop" - if "check" in st: - return "check", "확인 필요", "warn" - if "key" in st: - return "key", "데이터 키 대기", "warn" - return "ready", "분석 가능", "go" - - -def has_result(t, policies) -> bool: - by_id = {p.id: p for p in policies} - return any( - by_id[i].sample_case and (ROOT / by_id[i].sample_case / "flow_log.json").exists() - for i in t.policies - ) - +# ─── 홈 ────────────────────────────────────────────────────────────────────── +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)));}); +document.querySelectorAll('.v').forEach(b=>b.onclick=()=>{ + document.querySelectorAll('.v').forEach(x=>x.setAttribute('aria-pressed',x===b)); + document.getElementById('cards').className=b.dataset.v==='list'?'list':'grid';}); +""" -FLOW_STEPS = [ - ("소셜 신호", "뉴스 제목·SNS 글에서 사람들이 궁금해하는 것을 읽습니다", "누구나"), - ( - "주제 고르기", - "신호는 주제까지만 정합니다. 화제가 된 정책 하나를 고르지 않습니다", - "플랫폼 자동", - ), - ("정책 전부 모으기", "법제처 조례·부처 고시로 '어느 지역이 언제부터'를 모읍니다", "수집 담당"), - ("세 관문", "언제 시작했나 · 누가 받았나 · 무엇으로 재나", "문제 정의 담당"), - ("계획 먼저", "데이터를 보기 전에 분석계획을 커밋합니다(사전 등록)", "문제 정의 담당"), +HOME_STEPS = [ + ("찾기", "뉴스·SNS에서 사람들이 궁금해하는 주제를 찾습니다", "누구나"), + ("모으기", "그 주제의 정책을 전부 모읍니다. 어느 지역이 언제 시작했는지", "수집 담당"), ( - "효과 추정·판정", - "식별됨 · 조건부 · 식별 불가 — 과장 표현은 자동으로 막습니다", - "추정·리포트 담당", + "거르기", + "세 가지를 확인합니다. 언제 시작했나 · 누가 받았나 · 무엇으로 재나", + "문제 정의 담당", ), + ("계획하기", "데이터를 보기 전에 분석 계획을 먼저 적어 둡니다", "문제 정의 담당"), + ("비교하기", "정책을 받은 곳과 안 받은 곳의 변화를 비교합니다", "추정 담당"), + ("말하기", "효과 근거 있음 · 조건부 · 판단 불가 중 하나로 정직하게 씁니다", "리포트 담당"), +] +HOME_EXAMPLES = [ + "토허제 확대하고 강남 집값 잡혔나요?", + "소비쿠폰 받고 동네 가게 매출 늘었나?", + "모두의 카드 나오고 지하철 더 타나?", + "5030 속도 줄이고 사고 줄었나?", ] -def index_page(topics, policies) -> str: +def index_page(topics, policies, issues, datasets) -> str: kw = { t.id: list( dict.fromkeys( @@ -339,122 +317,383 @@ def index_page(topics, policies) -> str: ) for t in topics } + issue_topics = {i.topic for i in issues} cards = [] for t in topics: key, lab, cls = readiness(t) - res = has_result(t, policies) + res = bool(case_logs(t, policies)) + n_ds = sum(t.id in d.topics for d in datasets) cards.append( f'

    {e(t.question)}

    {gate_badges(t)}
    ' + f'

    데이터셋 {n_ds}개

    ' ) - n_ready = sum(1 for t in topics if has_result(t, policies)) + issue_cards = "".join( + f'{e(i.effective)}' + f"

    {e(i.name)}

    {e(i.issue)}

    " + f'
    {DESIGN_SHORT[i.design]}' + f'데이터 {len(i.datasets)}개
    ' + for i in issues + ) + n_api = sum(d.access == "open_api" for d in datasets) + chips = "".join(f'' for x in HOME_EXAMPLES) flow = "".join( f'
    {i + 1:02d}{a}

    {b}

    {c}
    ' - for i, (a, b, c) in enumerate(FLOW_STEPS) + for i, (a, b, c) in enumerate(HOME_STEPS) ) - examples = [ - "토허제 확대하고 강남 집값 잡혔나요?", - "지역화폐 쓰면 동네 가게 매출이 오르나요?", - "5030 속도 줄이고 사고 줄었나?", - "계절관리제 하면 미세먼지 줄어요?", - ] - chips = "".join(f'' for x in examples) + js = HOME_JS.replace("__KW__", json.dumps(kw, ensure_ascii=False)) body = f""" -
    OPEN SOURCE · 공공데이터 · 인과추론 -

    사람들이 묻는 정책,
    정말 효과가 있었을까?

    -

    소셜 반응에서 출발해, 그 주제의 정책을 전부 모으고, 공공데이터로 효과를 추정합니다. -결론을 낼 수 없으면 "식별 불가"라고 말하는 것까지가 이 플랫폼의 일입니다.

    +
    공공데이터 · 인과추론 · 오픈소스 +

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

    +

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

    {chips}
    -

    입력한 글은 이 브라우저 안에서만 쓰입니다. 서버로 보내지 않습니다.

    - -
    - -

    왜 이렇게 만드나

    화제가 된 정책만 골라 분석하면 결과를 보고 사례를 고르는 셈이 됩니다.

    -
    -

    A 화제성으로 사례를 고르면

    -
    • 조용했지만 효과가 컸던 정책이 빠집니다
    • 화제가 되면 신청이 늘어 효과가 부풀려집니다
    • -
    • 시행 전부터 화제였다면 사람들이 미리 움직여 비교가 깨집니다
    -

    B 화제성은 출발점으로만 쓰면

    -
    • 신호는 어느 주제를 볼지만 정합니다
    • 그 주제의 정책을 전부 모아 비교합니다
    • -
    • 신호는 나중에 '미리 반응했나' 점검용으로 다시 씁니다
    - -

    여섯 단계 흐름

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

    +

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

    + + +

    이렇게 확인합니다

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

    {flow}
    -

    주제

    주제마다 세 관문(언제·누가·무엇을)의 통과 여부를 보여줍니다.

    +

    지금 이슈인 정책

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

    +
    {issue_cards}
    + +

    주제

    이슈 하나가 아니라 주제 단위로 봅니다. 배지는 세 가지 확인 사항의 상태입니다.

    - - - - + + + + +
    {"".join(cards)}
    -

    무엇을 믿을 수 있나

    결과보다 과정을 먼저 공개합니다.

    +

    왜 주제 단위로 보나

    화제가 된 정책 하나만 골라 분석하면, 결과를 보고 사례를 고르는 것과 같아집니다.

    +
    +

    피하는 방식 화제가 된 정책만 분석

    +
    • 조용했지만 효과가 컸던 정책이 빠집니다
    • 화제가 되면 신청이 늘어 효과가 부풀려집니다
    • +
    • 시행 전부터 화제였다면 사람들이 미리 움직여 비교가 흔들립니다
    +

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

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

    믿을 수 있게 만드는 장치

    -
    1

    계획을 먼저 커밋

    분석계획(plan.yaml)이 git에 커밋되지 않으면 추정 단계가 실행되지 않습니다.

    -
    2

    방법은 규칙이 정함

    LLM은 주제·서술을 돕고, 추정 방법은 데이터 모양을 보고 규칙이 고릅니다. 수치는 검증된 라이브러리가 계산합니다.

    -
    3

    작은 표본에 맞는 추론

    처치 지역이 10곳 미만이면 무작위화 추론으로 바꿉니다. 시뮬레이션에서 95% 신뢰구간 포함률 95%를 확인했습니다.

    -
    4

    과장 표현 차단

    판정이 '식별됨'이 아니면 "입증", "때문에" 같은 표현을 리포트에서 막습니다.

    -
    5

    출처·라이선스 표시

    모든 데이터셋에 제공 기관, 공공누리 유형, 수집 시점을 붙입니다.

    -
    6

    한계도 공개

    시뮬레이션 결과, 키 대기, 확인 필요 상태를 숨기지 않고 배지로 표시합니다.

    +
    1

    계획을 먼저 적는다

    분석 계획을 커밋하지 않으면 계산 단계가 실행되지 않습니다.

    +
    2

    AI는 돕기만 한다

    AI는 주제 찾기와 글쓰기를 돕고, 분석 방법은 규칙이, 숫자는 검증된 라이브러리가 계산합니다.

    +
    3

    작은 표본도 정직하게

    정책 지역이 10곳 미만이면 가짜 정책 지역을 수백 번 뽑아 비교하는 방식으로 바꿉니다.

    +
    4

    과장하지 않는다

    근거가 부족하면 "입증", "때문에" 같은 말을 리포트에서 자동으로 막습니다.

    +
    5

    출처를 붙인다

    모든 데이터셋에 제공 기관, 이용 조건, 확인한 날짜를 적습니다.

    +
    6

    한계를 숨기지 않는다

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

    -

    참여하기

    조별로 주제 하나를 맡아 cases/에 케이스를 추가합니다.

    +

    참여하기

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

    -
    ①

    주제 고르기

    위 주제 중 하나를 고르거나 catalog/topics.yaml에 새 주제를 제안합니다.

    -
    ②

    계획 PR

    cases/_template을 복사해 plan.yaml을 쓰고 PR로 사전 등록합니다.

    -
    ③

    실행·공개

    make flow로 돌리면 이 사이트에 결과가 자동으로 올라옵니다.

    +
    ①

    주제 고르기

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

    +
    ②

    계획 올리기

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

    +
    ③

    실행하고 공개

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

    +GitHub가 처음이라면
    +""" + return page("정책 효과 분석 플랫폼", body, "index.html") -""" - return page("정책 효과 분석 플랫폼", body) + +# ─── 지금 이슈: 효과 추정 가이드 ─────────────────────────────────────────────── +GUIDE_STEPS = [ + ( + "질문을 한 문장으로", + "'이 정책이 누구의 무엇을 바꿨나'로 씁니다. 예: 토허구역 지정이 그 구의 아파트 거래를 줄였나?", + ), + ("비교할 두 집단 정하기", "정책을 받은 곳(처치)과 비슷하지만 안 받은 곳(대조)을 정합니다."), + ("시작일 확인", "공식 발표·고시로 날짜를 확인합니다. 발표일과 시행일이 다르면 둘 다 적습니다."), + ( + "데이터 받기", + "아래 표의 오픈API로 받습니다. 대부분 공공데이터포털에서 자동승인 키를 받으면 됩니다.", + ), + ( + "계획 → 실행 → 판정", + "plan.yaml을 먼저 커밋하고 실행합니다. 결과는 세 가지 판정 중 하나로 나옵니다.", + ), +] +def issues_page(issues, datasets, topics) -> str: + by_ds = {d.id: d for d in datasets} + by_topic = {t.id: t for t in topics} + guide = "".join( + f'
    {i + 1:02d}' + f"

    {a}

    {b}

    " + for i, (a, b) in enumerate(GUIDE_STEPS) + ) + designs = "".join( + f'

    {DESIGN_SHORT[k]}

    {v}

    ' + f'{SUPPORT[k][0]}
    ' + for k, v in DESIGN_PLAIN.items() + ) + toc = "".join(f'
  • {e(i.name)}
  • ' for i in issues) + sections = [] + for i in issues: + ds = [by_ds[x] for x in i.datasets if x in by_ds] + t = by_topic[i.topic] + sup, cls = SUPPORT[i.design] + note = f" ({e(i.effective_note)})" if i.effective_note else "" + pits = "".join(f"
  • {e(p)}
  • " for p in i.pitfalls) + sections.append( + f'
    {e(t.name)}

    {e(i.name)}

    ' + f'

    {e(i.issue)}

    ' + f'
    시작일
    {e(i.effective)}{note} · 공식 출처
    ' + f"
    정책 받은 곳
    {e(i.treatment)}
    " + f"
    비교할 곳
    {e(i.control)}
    " + f"
    무엇을 재나
    {e(', '.join(i.outcomes))}
    " + f"
    분석 방법
    {DESIGN_SHORT[i.design]} — {DESIGN_PLAIN[i.design]} " + f'{sup}
    ' + f"
    난이도
    {dots(i.difficulty)}
    " + f"

    받을 수 있는 데이터

    {dataset_table(ds)}" + f"

    조심할 점

      {pits}
    " + f'

    {e(t.name)} 주제 전체 보기 →

    ' + ) + body = f""" +
    지금 이슈 +

    논쟁 중인 정책, 이렇게 분석합니다

    +

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

    + +

    효과 추정 5단계

    {guide}
    +

    분석 방법 고르기

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

    +
    {designs}
    +

    정책별 가이드

      {toc}
    {"".join(sections)}
    """ + return page("지금 이슈 · 정책 효과 분석 플랫폼", body, "issues.html") + + +# ─── 데이터 지도 (온톨로지) ───────────────────────────────────────────────────── +DATA_JS = """ +const rows=[...document.querySelectorAll('#dtable tbody tr')]; +const els={q:document.getElementById('dq'),role:document.getElementById('frole'),topic:document.getElementById('ftopic'), + space:document.getElementById('fspace'),access:document.getElementById('faccess')}; +function apply(){ + const q=els.q.value.trim().toLowerCase();let n=0; + rows.forEach(r=>{const ok=(!q||r.dataset.text.includes(q))&&(!els.role.value||r.dataset.roles.split(' ').includes(els.role.value)) + &&(!els.topic.value||r.dataset.topics.split(' ').includes(els.topic.value))&&(!els.space.value||r.dataset.space===els.space.value) + &&(!els.access.value||r.dataset.access===els.access.value);r.hidden=!ok;if(ok)n++;}); + document.getElementById('dcount').textContent=n; +} +Object.values(els).forEach(x=>x.addEventListener('input',apply)); +document.getElementById('goportal').onsubmit=ev=>{ev.preventDefault(); + const k=document.getElementById('pq').value.trim(); + if(k)window.open('https://www.data.go.kr/tcs/dss/selectDataSetList.do?keyword='+encodeURIComponent(k),'_blank','noopener');}; +""" +PORTALS = [ + ( + "공공데이터포털", + "https://www.data.go.kr", + "부처·지자체 데이터 대부분. 오픈API는 활용신청 후 자동승인 키로 받습니다.", + ), + ( + "KOSIS 국가통계포털", + "https://kosis.kr", + "인구·출생·경제 통계. 통계표 ID로 오픈API를 부릅니다.", + ), + ( + "서울 열린데이터광장", + "https://data.seoul.go.kr", + "서울 자치구·역·측정소 단위 자료가 많습니다.", + ), + ( + "법제처 국가법령정보", + "https://open.law.go.kr", + "조례의 지역·시행일 → '누가 언제 정책을 받았나'.", + ), + ("한국부동산원 R-ONE", "https://www.reb.or.kr/r-one/", "주택 가격지수 등 부동산 통계."), +] + + +def _options(pairs) -> str: + return "".join(f'' for v, k in pairs) + + +def data_page(datasets, topics, issues) -> str: + tname = {t.id: t.name for t in topics} + n_role = {r: sum(r in d.roles for d in datasets) for r in ROLE_KO} + spaces = sorted({d.space for d in datasets}) + rows = [] + for d in datasets: + text = " ".join([d.name, d.provider, *d.measures]).lower() + meas = ( + f"
    {e(', '.join(d.measures[:5]))}
    " if d.measures else "" + ) + rows.append( + f'' + f"{e(d.name)}
    {e(d.provider)} · {PORTAL_KO.get(d.portal, d.portal)}
    {meas}" + f"{role_badges(d.roles)}" + f"{e(d.space)} × {e(d.time)}
    {e(d.period)}
    " + f"{ACCESS_KO[d.access]}
    {e(d.approval)}
    " + f"{e(d.license)}" + f"{' · '.join(e(tname.get(t, t)) for t in d.topics)}" + f"{e(d.verified[:10])}" + ) + portals = "".join( + f'

    {n}

    {d}

    ' + for n, u, d in PORTALS + ) + terms = "".join(f"
    {a}
    {b}
    " for a, b in TERMS) + body = f""" +
    데이터 지도 +

    효과를 재려면 어떤 데이터가 필요할까

    +

    정책 효과를 재려면 세 종류의 데이터가 필요합니다. 누가 언제 정책을 받았는지, 무엇이 달라졌는지, +결과에 영향을 준 다른 요인입니다. 주제별로 받을 수 있는 공공데이터 {len(datasets)}개를 이 기준으로 정리했습니다.

    + +

    데이터를 이렇게 연결합니다

    +

    주제 → 이슈 → 데이터셋 → 제공 포털로 이어지고, 데이터셋마다 역할·단위·받는 법이 붙습니다.

    +
    +
    주제

    무엇을 보나

    • {len(topics)}개 주제
    • 세 가지 확인 사항
    +
    이슈 → 주제에 속함

    지금 논쟁 중인 정책

    • {len(issues)}개 이슈
    • 시작일 + 공식 출처
    +
    데이터셋 → 이슈에 쓰임

    역할이 있는 데이터

      +
    • 처치 누가 언제 · {n_role["treatment"]}개
    • +
    • 결과 무엇이 변했나 · {n_role["outcome"]}개
    • +
    • 통제 다른 요인 · {n_role["covariate"]}개
    +
    포털 → 데이터셋을 제공

    어디서 받나

    • 공간 × 시간 단위
    • 오픈API·파일, 승인 방식
    • 이용 조건
    +
    + +

    데이터셋 찾기

    + +
    + + + + +{len(datasets)}개
    +
    +{"".join(rows)}
    데이터셋역할단위받는 법이용 조건주제확인
    +

    이용 조건이 other인 것은 포털에 공공누리 유형 없이 "이용허락범위 제한 없음"으로만 적힌 경우입니다. +KOGL-3(변경금지)은 가공한 데이터를 다시 배포할 때 주의하세요.

    + +

    여기에 없는 데이터 찾기

    + +

    찾은 데이터는 catalog/datasets.yaml에 역할·단위·받는 법을 적어 PR로 추가해 주세요.

    +
    {portals}
    + +

    용어 풀이

    {terms}
    +""" + return page("데이터 지도 · 정책 효과 분석 플랫폼", body, "data.html") + + +# ─── 주제 페이지 ────────────────────────────────────────────────────────────── +def topic_page(t, policies, issues, datasets) -> str: + by_id = {p.id: p for p in policies} + ps = [by_id[i] for i in t.policies] + _, lab, cls = readiness(t) + my_issues = [i for i in issues if i.topic == t.id] + my_ds = [d for d in datasets if t.id in d.topics] + body = [ + f"
    주제" + f"

    {e(t.name)}

    {e(t.question)}

    " + f'
    {lab}
    ', + "

    1. 이 주제의 정책

    ", + f"

    모으는 방법: {COLLECT_KO[t.collect.method]} — {e(t.collect.note)}

    ", + timeline_svg(t.events), + ] + snap = ROOT / "catalog" / "snapshots" / f"law_{t.id}.csv" + if t.collect.method == "law_ordinance": + if snap.exists(): + df = pd.read_csv(snap) + cnt = df.dropna(subset=["year"]).astype({"year": int}).groupby("year").size() + body += [ + f"

    조례 시행 연도 (법제처, {len(df)}건)

    ", + bars_svg(cnt, "조례 시행 연도"), + "

    현재 조례의 시행일 기준입니다. 처음 만든 날은 조례 연혁에서 확인합니다.

    ", + ] + else: + body.append( + "

    법제처 API로 지역별 조례와 시행일을 자동으로 모을 주제입니다. " + "법제처 인증값이 레포에 등록되면 다음 갱신 때 채워집니다.

    " + ) + if my_issues: + items = "".join( + f'
  • {e(i.name)} ({e(i.effective)})
  • ' + for i in my_issues + ) + body.append(f"

    지금 이슈

      {items}
    ") + body.append("

    2. 세 가지 확인

    ") + for k, g in t.gates.items(): + body.append( + f'

    {GATE_Q[k]}

    ' + f"{GATE_STATUS_KO[g.status]}

    {e(g.note)}

    " + ) + body.append("
    ") + if my_ds: + order = ["treatment", "outcome", "covariate"] + body.append( + f"

    3. 받을 수 있는 데이터 ({len(my_ds)}개)

    " + "

    역할별 전체 목록은 데이터 지도에 있습니다.

    " + + dataset_table(sorted(my_ds, key=lambda d: order.index(d.roles[0]))) + + "
    " + ) + if ps: + body.append("

    4. 분석 설계

    ") + for p in ps: + sup, scls = SUPPORT[p.design] + pits = ( + "
    조심할 점
      " + + "".join(f"
    • {e(x)}
    • " for x in p.pitfalls) + + "
    " + if p.pitfalls + else "" + ) + body.append( + f'

    {e(p.name)}

    ' + f"

    {e(p.summary)}

    " + f"

    {DESIGN_SHORT[p.design]} — {DESIGN_PLAIN[p.design]} " + f'{sup}

    {pits}
    ' + ) + body.append("
    ") + cases = case_logs(t, policies) + if cases: + body.append("

    5. 분석 예시

    ") + body += [case_block(c, depth=1) for c in cases] + body.append("
    ") + if t.pitfalls: + items = "".join(f"
  • {e(x)}
  • " for x in t.pitfalls) + body.append(f"

    주의할 점

      {items}
    ") + return page(f"{t.name} · 정책 효과 분석 플랫폼", "\n".join(body), "index.html#topics", depth=1) + + +# ─── 빌드 ──────────────────────────────────────────────────────────────────── def main() -> None: + import architecture + topics, policies = load_topics(), load_catalog() + issues, datasets = load_issues(), load_datasets() if OUT.exists(): shutil.rmtree(OUT) (OUT / "topics").mkdir(parents=True) - (OUT / "index.html").write_text(index_page(topics, policies), encoding="utf-8") + pages = { + "index.html": index_page(topics, policies, issues, datasets), + "issues.html": issues_page(issues, datasets, topics), + "data.html": data_page(datasets, topics, issues), + "architecture.html": page( + "구조 · 정책 효과 분석 플랫폼", architecture.body(e), "architecture.html" + ), + } + for name, text in pages.items(): + (OUT / name).write_text(text, encoding="utf-8") for t in topics: - (OUT / "topics" / f"{t.id}.html").write_text(topic_page(t, policies), encoding="utf-8") - sys.path.insert(0, str(Path(__file__).resolve().parent)) - import architecture - - (OUT / "architecture.html").write_text( - page("아키텍처 · 정책 효과 분석 플랫폼", architecture.body(e)), encoding="utf-8" - ) + (OUT / "topics" / f"{t.id}.html").write_text( + topic_page(t, policies, issues, datasets), encoding="utf-8" + ) (OUT / ".nojekyll").write_text("") - print(f"_site/ 생성: 주제 {len(topics)}개") + print(f"_site/ 생성: 주제 {len(topics)} · 이슈 {len(issues)} · 데이터셋 {len(datasets)}") if __name__ == "__main__": diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..c4bb33b --- /dev/null +++ b/site/style.css @@ -0,0 +1,121 @@ +:root { + --paper: #f6f7f9; --surface: #fff; --sunk: #eef0f3; --ink: #14171c; --ink2: #3f4550; --muted: #6b7280; --hair: #dde0e5; + --accent: #1b5e9b; --accent-soft: #e3eef8; + --go: #1e6a4d; --go-bg: #e0f1e8; --warn: #8a5900; --warn-bg: #fbeed3; --stop: #9c2b33; --stop-bg: #f8e2e4; + --r-treat: #7a3e9d; --r-treat-bg: #f1e6f7; --r-out: #1b5e9b; --r-out-bg: #e3eef8; --r-cov: #5b6470; --r-cov-bg: #eceef1; + --sans: "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, monospace; +} +@media (prefers-color-scheme: dark) { + :root { + --paper: #111317; --surface: #1a1d23; --sunk: #22262d; --ink: #eceef2; --ink2: #c2c7d0; --muted: #8e95a2; --hair: #2d323a; + --accent: #79b2e8; --accent-soft: #1a2b3c; + --go: #7fd3ab; --go-bg: #15302a; --warn: #e8b45c; --warn-bg: #33291a; --stop: #ee8f96; --stop-bg: #371e21; + --r-treat: #d3a6ee; --r-treat-bg: #2e2138; --r-out: #79b2e8; --r-out-bg: #1a2b3c; --r-cov: #b7bec9; --r-cov-bg: #262a31; + } +} +* { box-sizing: border-box } +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) } +code { font-family: var(--mono); font-size: .88em; background: var(--sunk); padding: 1px 5px; border-radius: 4px } + +.nav { position: sticky; top: 0; z-index: 5; background: color-mix(in srgb, var(--paper) 90%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--hair) } +.nav .in { max-width: 1080px; margin: 0 auto; padding: 10px 16px; display: flex; gap: 8px 18px; align-items: center; justify-content: space-between; flex-wrap: wrap } +.nav b a { color: var(--ink); text-decoration: none } +.nav .links { display: flex; gap: 4px 14px; flex-wrap: wrap; font-size: 14px } +.nav .links a { color: var(--ink2); text-decoration: none } +.nav .links a:hover, .nav .links a[aria-current] { color: var(--accent) } +.wrap { max-width: 1080px; margin: 0 auto; padding: 0 16px 80px } + +.hero { padding: 60px 0 24px } +.eyebrow { font-size: 13px; font-weight: 700; letter-spacing: .06em; color: var(--accent) } +h1 { font-size: clamp(30px, 5vw, 46px); line-height: 1.2; margin: 10px 0 14px; letter-spacing: -.02em } +h2 { font-size: clamp(22px, 3vw, 28px); margin: 0 0 6px; letter-spacing: -.01em } +h3 { font-size: 17px; margin: 0 0 6px } +.lede { color: var(--ink2); font-size: 18px; max-width: 64ch; margin: 0 } +.sub { color: var(--ink2); max-width: 68ch; margin: 0 0 18px } +section { padding: 52px 0 6px; scroll-margin-top: 60px } +.muted { color: var(--muted); font-size: 14px } +.small { font-size: 13px } + +.cta { display: flex; gap: 10px; flex-wrap: wrap; margin: 20px 0 0 } +.btn { display: inline-block; padding: 10px 18px; border-radius: 999px; font-weight: 600; text-decoration: none; font-size: 15px } +.btn.primary { background: var(--accent); color: #fff } +.btn.ghost { border: 1px solid var(--hair); color: var(--ink); background: var(--surface) } + +.card { background: var(--surface); border: 1px solid var(--hair); border-radius: 14px; padding: 20px } +.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)) } +.grid3 { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)) } +.split { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)) } +.icon { width: 36px; height: 36px; border-radius: 10px; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; font-weight: 800; margin-bottom: 10px } + +.stats { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin: 26px 0 0 } +.stat { background: var(--surface); border: 1px solid var(--hair); border-radius: 14px; padding: 14px 16px; text-decoration: none; color: inherit } +.stat .v { font-size: 30px; font-weight: 800; letter-spacing: -.02em; line-height: 1.1 } +.stat .l { color: var(--muted); font-size: 14px } + +.badge { display: inline-block; font-size: 12.5px; font-weight: 600; padding: 2px 9px; border-radius: 999px; white-space: nowrap } +.go { color: var(--go); background: var(--go-bg) } .warn { color: var(--warn); background: var(--warn-bg) } +.stop { color: var(--stop); background: var(--stop-bg) } .info { color: var(--accent); background: var(--accent-soft) } +.r-treatment { color: var(--r-treat); background: var(--r-treat-bg) } .r-outcome { color: var(--r-out); background: var(--r-out-bg) } +.r-covariate { color: var(--r-cov); background: var(--r-cov-bg) } +.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 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 } +.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) } +select { font: inherit; font-size: 14px; padding: 6px 10px; border-radius: 10px; border: 1px solid var(--hair); background: var(--surface); color: var(--ink) } +.filters { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; align-items: center } + +.hit { outline: 2px solid var(--accent); outline-offset: 2px } +.topic { text-decoration: none; color: inherit; display: block } +.topic:hover, .issue-card:hover { border-color: var(--accent) } +.list { display: flex; flex-direction: column; gap: 8px } +.list .topic { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 2fr) auto; gap: 14px; align-items: center } +.list .topic p { margin: 0 } +.issue-card { text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: 6px } +.issue-card .date { font: 600 13px var(--mono); color: var(--muted) } + +table { width: 100%; border-collapse: collapse; font-size: 14px } +th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--hair); vertical-align: top } +th { color: var(--muted); font-weight: 600; white-space: nowrap } +.tablewrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--hair); border-radius: 12px } +.banner { border-radius: 12px; padding: 12px 16px; margin: 14px 0; font-weight: 600 } +.figs { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)) } +.figs img { width: 100%; background: #fff; border-radius: 10px; border: 1px solid var(--hair) } +svg text { fill: var(--ink2); font: 12.5px var(--sans) } +ul, ol { padding-left: 20px } +details summary { cursor: pointer; color: var(--ink2) } + +.flow { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)) } +.flow .card { padding: 14px } +.flow .n { font: 700 12px var(--mono); color: var(--accent) } +.flow b { display: block; margin: 2px 0 4px } +.flow p { margin: 0; font-size: 14px; color: var(--ink2) } +.flow .who { margin-top: 8px; font-size: 12.5px; color: var(--muted) } + +.onto { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); align-items: stretch } +.onto .card { padding: 14px; position: relative } +.onto .rel { font: 600 12px var(--mono); color: var(--accent) } +.onto ul { margin: 6px 0 0; font-size: 14px; color: var(--ink2) } + +.issue { border-top: 1px solid var(--hair); padding-top: 28px; margin-top: 28px; scroll-margin-top: 70px } +.kv { display: grid; grid-template-columns: minmax(90px, 140px) 1fr; gap: 6px 14px; margin: 12px 0 } +.kv dt { color: var(--muted); font-size: 14px } +.kv dd { margin: 0 } +dl.terms { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)) } +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 } + +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) } +} diff --git a/tests/discovery/test_ontology.py b/tests/discovery/test_ontology.py new file mode 100644 index 0000000..5ccb048 --- /dev/null +++ b/tests/discovery/test_ontology.py @@ -0,0 +1,12 @@ +from core.discovery.ontology import check_links, load_datasets, load_issues + + +def test_catalog_links_are_consistent(): + assert check_links() == [] + + +def test_every_issue_has_outcome_data_and_source(): + by_id = {d.id: d for d in load_datasets()} + for i in load_issues(): + assert i.source.startswith("https://") + assert any("outcome" in by_id[x].roles for x in i.datasets), i.id diff --git a/tests/test_site_build.py b/tests/test_site_build.py index 614e919..18ed82a 100644 --- a/tests/test_site_build.py +++ b/tests/test_site_build.py @@ -14,7 +14,8 @@ def test_site_builds(tmp_path, monkeypatch): mod.main() out = tmp_path / "_site" topics = mod.load_topics() - assert (out / "index.html").exists() and (out / "architecture.html").exists() + for name in ("index.html", "architecture.html", "data.html", "issues.html"): + assert (out / name).exists(), name for t in topics: page = (out / "topics" / f"{t.id}.html").read_text(encoding="utf-8") assert t.name in page