diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..cdcfaff --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,50 @@ +name: pages + +# main 에 반영되면 정적 대시보드(_site/)를 만들어 GitHub Pages 로 배포한다. +# 최초 1회: Settings → Pages → Build and deployment → Source: "GitHub Actions" +# 선택: Settings → Secrets → Actions 에 LAW_OC(법제처 OC) 를 넣으면 조례 목록을 자동 수집한다. +on: + push: + branches: [main] + workflow_dispatch: + schedule: + - cron: "0 18 * * 0" # 매주 월요일 03:00 KST 데이터 갱신 + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + env: + LAW_OC: ${{ secrets.LAW_OC }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - run: pip install -e . + - name: Refresh ordinance snapshots (법제처, optional) + if: ${{ env.LAW_OC != '' }} + run: python scripts/refresh_law_snapshots.py + - run: python site/build.py + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index aba5276..f0f97ec 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ Thumbs.db .idea/ .vscode/ *.swp +_site/ diff --git a/README.md b/README.md index e94d302..751e166 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ --- +**대시보드**: https://causalinferencelab.github.io/policy-effect-analytics-agent/ (GitHub Pages, `main` 반영 시 자동 배포) + ## 왜 하나요? 1. 공공·사회 문제를 **데이터로 정의**하고, 그 해법(정책)이 **실제로 어떤 효과를 냈는지 추정**합니다. @@ -75,9 +77,12 @@ cp -r cases/_template cases/group3-youth-rent # 폴더명: <조>-<주제>, 소 | 6 | 리포트·플랫폼 | `report.md`, Streamlit 반영 | | 7 | 발표·회고·공개 정리 | 최종 PR 머지, 릴리스 태그 | -## 이슈 → 데이터 → 효과 +## 이슈 → 주제 → 정책 전체 → 효과 -소셜 반응(뉴스 제목·SNS 글) 한 건에서 출발해 정책을 찾고, 공공데이터를 추천하고, 효과 분석까지 잇습니다. +소셜 반응(뉴스 제목·SNS 글)은 **어느 주제를 볼지까지만** 정합니다. 화제가 된 정책 하나만 골라 분석하면 +결과를 보고 사례를 고르는 셈이 되기 때문입니다(출발 키트 05). 주제가 정해지면 그 주제의 정책을 +전부 모으고(법제처 조례·고시), 세 관문(언제·누가·무엇을)을 통과한 것만 분석합니다. +주제 목록: `catalog/topics.yaml` · 정적 대시보드: `python site/build.py` → `_site/` 1. **정책 식별**: `catalog/policies.yaml`(정책 × 데이터셋 카탈로그)에서 키워드로 찾습니다. LLM은 후보 중에서 고르는 보조 역할만 합니다. 2. **데이터셋 추천**: 카탈로그에 검증해 둔 데이터셋과 공공데이터포털 실시간 검색 결과를 보여줍니다. diff --git a/cases/t3-land-permit-2025/figures/event_study.png b/cases/t3-land-permit-2025/figures/event_study.png index dcefe06..eb9dff9 100644 Binary files a/cases/t3-land-permit-2025/figures/event_study.png and b/cases/t3-land-permit-2025/figures/event_study.png differ diff --git a/cases/t3-land-permit-2025/flow_log.json b/cases/t3-land-permit-2025/flow_log.json index 86b00b2..960e731 100644 --- a/cases/t3-land-permit-2025/flow_log.json +++ b/cases/t3-land-permit-2025/flow_log.json @@ -4,17 +4,17 @@ "question": "2025년 3월 24일 강남·서초·송파·용산구 전체 아파트를 토지거래허가구역으로 지정한 뒤, 서울의 나머지 21개 자치구와 비교해 이 4개 구의 아파트 매매 거래건수와 ㎡당 가격이 달라졌는가?\n", "status": "warn", "verdict": "conditional", - "started_at": "2026-09-26T07:07:15+00:00", - "ended_at": "2026-09-26T07:07:21+00:00", + "started_at": "2026-09-26T07:28:46+00:00", + "ended_at": "2026-09-26T07:28:52+00:00", "steps": [ { "index": 1, "step": "define_problem", "title": "① 문제 정의", "status": "ok", - "started_at": "2026-09-26T07:07:15+00:00", - "ended_at": "2026-09-26T07:07:15+00:00", - "duration_s": 0.011, + "started_at": "2026-09-26T07:28:46+00:00", + "ended_at": "2026-09-26T07:28:46+00:00", + "duration_s": 0.012, "message": "분석계획 검증 완료, 사전 등록(git 커밋) 확인: t3-land-permit-2025", "artifacts": { "plan": "plan.yaml", @@ -27,8 +27,8 @@ "step": "collect", "title": "② 데이터 수집", "status": "warn", - "started_at": "2026-09-26T07:07:15+00:00", - "ended_at": "2026-09-26T07:07:15+00:00", + "started_at": "2026-09-26T07:28:46+00:00", + "ended_at": "2026-09-26T07:28:46+00:00", "duration_s": 0.0, "message": "기존 스냅샷 재사용 (새로 받으려면 파일 삭제 후 재실행) / ⚠️ 합성 데이터 — 결과는 실제 정책 효과가 아님", "artifacts": { @@ -47,8 +47,8 @@ "step": "structure_metrics", "title": "③ 지표 구조화·품질 점검", "status": "ok", - "started_at": "2026-09-26T07:07:15+00:00", - "ended_at": "2026-09-26T07:07:15+00:00", + "started_at": "2026-09-26T07:28:46+00:00", + "ended_at": "2026-09-26T07:28:46+00:00", "duration_s": 0.004, "message": "12개 점검 모두 통과", "artifacts": { @@ -61,9 +61,9 @@ "step": "estimate", "title": "④ 효과 추정", "status": "warn", - "started_at": "2026-09-26T07:07:15+00:00", - "ended_at": "2026-09-26T07:07:20+00:00", - "duration_s": 4.896, + "started_at": "2026-09-26T07:28:46+00:00", + "ended_at": "2026-09-26T07:28:51+00:00", + "duration_s": 5.026, "message": "event_study: -0.403 [-0.494, -0.323] → identified", "artifacts": { "log_trades": { @@ -89,8 +89,8 @@ "step": "guard", "title": "⑤ 과잉해석 가드", "status": "ok", - "started_at": "2026-09-26T07:07:20+00:00", - "ended_at": "2026-09-26T07:07:20+00:00", + "started_at": "2026-09-26T07:28:51+00:00", + "ended_at": "2026-09-26T07:28:51+00:00", "duration_s": 0.0, "message": "판정 identified, 서술=template", "artifacts": { @@ -104,9 +104,9 @@ "step": "report", "title": "⑥ 리포트·재현 기록", "status": "ok", - "started_at": "2026-09-26T07:07:20+00:00", - "ended_at": "2026-09-26T07:07:21+00:00", - "duration_s": 0.31, + "started_at": "2026-09-26T07:28:51+00:00", + "ended_at": "2026-09-26T07:28:52+00:00", + "duration_s": 0.334, "message": "리포트 2개 그림 포함 작성", "artifacts": { "report": "report.md", diff --git a/cases/t3-land-permit-2025/run_manifest.json b/cases/t3-land-permit-2025/run_manifest.json index e44f554..1657c3e 100644 --- a/cases/t3-land-permit-2025/run_manifest.json +++ b/cases/t3-land-permit-2025/run_manifest.json @@ -1,8 +1,8 @@ { "case_id": "t3-land-permit-2025", - "started_at": "2026-09-26T07:07:15+00:00", - "finished_at": "2026-09-26T07:07:20+00:00", - "git_sha": "0e9570f1f745fa79b42366e581270e928bb1a41d", + "started_at": "2026-09-26T07:28:46+00:00", + "finished_at": "2026-09-26T07:28:52+00:00", + "git_sha": "3ccf0b76a74f39efc81fdcd9537e96c99efece83", "git_dirty": true, "plan_sha256": "8f7e7b2d06338470404f27bdcac6a6acf9784785e5851a6729ca6673b27a1ff2", "data_sha256": "c35f969dc154aa5732b6e05759185eb3cf246f9a3f9641c5182ab89214a43aa6", diff --git a/catalog/topics.yaml b/catalog/topics.yaml new file mode 100644 index 0000000..24ef0b3 --- /dev/null +++ b/catalog/topics.yaml @@ -0,0 +1,98 @@ +# 주제(topic) 카탈로그 — 소셜 신호는 "어느 주제를 볼지"까지만 정합니다. +# +# 출발 키트 05 "주제 고르는 법"의 B 방식: +# 소셜 신호 → 주제 지정 → 그 주제의 정책을 전부 모으기(법령·조례·고시) → 3개 관문 → 계획 먼저 → 효과 추정 +# 소셜 신호는 분석 대상을 고르는 데 쓰지 않고, ① 주제 좁히기 ② 선반영(미리 반응) 점검 ③ 관심도 자체를 결과로 — 에만 씁니다. +# +# collect: 정책 목록을 모으는 방법 +# law_ordinance : 법제처 자치법규 API로 조례 제정일·지자체를 자동 수집 (query 사용) +# notice : 부처·지자체 고시문에서 지정·해제 이력을 수집 (에이전트 과제) +# curated : 카탈로그에 적은 정책만 (전국 단위 등) + +version: 1 +topics: + - id: housing-regulation + name: 부동산 거래 규제 + question: 토지거래허가구역·규제지역 지정과 해제는 거래와 가격을 바꿨나? + keywords: [부동산, 집값, 아파트, 토지거래허가, 토허제, 토허구역, 토허, 규제지역, 투기과열, 조정대상, 강남3구, 용산, 잠삼대청, 풍선효과, 실거래가, 거래량] + policies: [T3] + collect: + method: notice + note: "국토부·서울시 고시에서 지정·해제 이력 전체(2017~)를 수집 → 지역×시점 처치표. 정리된 표가 없어 에이전트에게 맡기기 좋은 일" + events: + - {date: 2020-06-23, what: "잠실·삼성·대치·청담 토지거래허가구역 지정"} + - {date: 2025-02-12, what: "잠실·삼성·대치·청담 지정 해제 (발표·시행 월 기준)"} + - {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: + when: {status: pass, note: "고시·보도자료로 날짜 확인"} + who: {status: pass, note: "지정 구 vs 미지정 구"} + what: {status: key, note: "국토부 실거래가 API — 키 발급 전이라 지금은 시뮬레이션"} + + - id: local-currency + name: 지역화폐 (지역사랑상품권) + question: 지역화폐를 일찍 도입한 지자체의 지역 소비·소상공인 매출이 달라졌나? + keywords: [지역화폐, 지역사랑상품권, 지역상품권, 상품권, 지역경제, 골목상권, 소상공인] + policies: [] + collect: + method: law_ordinance + query: [지역사랑상품권, 지역화폐] + note: "법제처 자치법규 API로 지자체별 조례·시행일자 자동 수집 (무료, 신청 즉시 승인). 발행 지자체가 2018년 66곳 → 2019년 172곳으로 늘어 시차 도입 비교가 가능. 검색 결과는 최신 개정 기준이라 '제정일'은 연혁으로 확인" + gates: + when: {status: pass, note: "조례 제정일을 법제처 API로 자동 수집"} + who: {status: pass, note: "일찍 도입 vs 늦게 도입 지자체 (시차 도입)"} + what: {status: key, note: "조폐공사 결제정보 API·상권 매출 — 키 필요"} + pitfalls: ["옆 지자체 소비를 끌어온다는 지적 → 인접 지자체 대조군 오염", "조례 제정일과 실제 발행일이 다름", "이름이 같아도 할인율·규모가 다름"] + + - id: traffic-safety + name: 교통안전 + question: 속도 하향·처벌 강화 같은 교통안전 정책은 사고를 줄였나? + keywords: [교통사고, 교통안전, 안전속도, "5030", 제한속도, 민식이법, 스쿨존, 어린이보호구역, 윤창호법, 음주운전, 보행자] + policies: [T4, T5, T6] + collect: + method: curated + note: "지자체별 조기 시행일(5030)은 보도자료·고시로 추가 수집" + gates: + when: {status: pass, note: "법 시행일 확정"} + who: {status: check, note: "5030은 조기 도입 지자체로 구분 가능, 민식이법·윤창호법은 전국 동시(관문 2 약함)"} + what: {status: key, note: "TAAS·도로교통공단 API — 키 필요"} + + - id: air-quality + name: 대기질 + question: 운행제한·계절관리제는 미세먼지와 이산화질소를 줄였나? + keywords: [미세먼지, 초미세먼지, 대기질, 공기, 녹색교통, 5등급, 운행제한, 계절관리제, NO2, 이산화질소, PM2.5] + policies: [T1, T8] + collect: + method: curated + note: "시행일만 보고 시행(오염도와 무관) → 비교가 깔끔" + gates: + when: {status: pass, note: "2019.12 시작, 매년 12~3월"} + who: {status: check, note: "녹색교통: 사대문 안 측정소 소수(합성통제 필요). 계절관리제: 수도권 위주 → 비수도권 비교 가능한지 확인"} + what: {status: pass, note: "에어코리아 확정자료 파일 (API는 하루 500회 제한)"} + + - id: retail-hours + name: 대형마트 의무휴업 + question: 의무휴업일을 평일로 바꾼 자치구의 골목상권 매출이 달라졌나? + keywords: [대형마트, 의무휴업, 휴업일, 전통시장, 골목상권, 상권, 매출] + policies: [T2] + collect: + method: notice + note: "자치구별 전환 고시일 수집 (시차 도입)" + gates: + when: {status: check, note: "전환 자치구 목록·날짜를 고시로 구축해야 함"} + who: {status: pass, note: "전환 vs 미전환 자치구"} + what: {status: key, note: "서울 상권분석서비스 추정매출 — 키 필요"} + + - id: birth-marriage + name: 출산·혼인 지원 + question: 지자체 출산·혼인장려금은 출생과 혼인을 늘렸나? + keywords: [출산, 출생, 저출산, 출산장려금, 출산지원금, 혼인, 결혼장려금, 혼인장려금, 인구] + policies: [T7] + collect: + method: law_ordinance + query: [출산장려금, 혼인장려금] + note: "법제처 자치법규 API로 조례 제정일 수집 → 처치 데이터셋 구축이 과제의 절반" + gates: + when: {status: pass, note: "조례 제정일 자동 수집"} + who: {status: pass, note: "도입 vs 미도입 시군구"} + what: {status: key, note: "KOSIS 시군구 출생·혼인 — 키 필요"} diff --git a/core/adapters/law.py b/core/adapters/law.py new file mode 100644 index 0000000..b35effd --- /dev/null +++ b/core/adapters/law.py @@ -0,0 +1,79 @@ +"""법제처 국가법령정보 공동활용 API — 자치법규(조례) 검색 어댑터. + +- 인증: open.law.go.kr 에서 신청(무료, 즉시 승인)한 OC 값을 LAW_OC 환경변수에 넣는다. +- 용도: 주제(예: 지역사랑상품권)의 조례를 지자체별로 모아 '어느 지역이 언제부터'를 자동으로 만든다 + (출발 키트 05 관문 1). +- 주의: 검색 결과의 날짜는 **현행(최신 개정) 기준**이다. 처음 제정된 날은 연혁 조회로 확인해야 한다. + 그래서 이 어댑터가 만든 표는 '후보 처치표'이고, 제정일 확정은 사람이 검증한다. +""" + +from __future__ import annotations + +import time +import xml.etree.ElementTree as ET + +import pandas as pd + +from .base import BaseAdapter, SourceMeta + +SEARCH_URL = "https://www.law.go.kr/DRF/lawSearch.do" +KEEP = [ + "자치법규ID", + "자치법규명", + "지자체기관명", + "제개정구분명", + "공포일자", + "시행일자", + "자치법규종류", +] + + +def parse_ordinance_xml(xml: bytes | str) -> tuple[list[dict], int]: + root = ET.fromstring(xml) + total = int(root.findtext("totalCnt") or 0) + rows = [{c.tag: (c.text or "").strip() for c in item} for item in root.iter("law")] + return rows, total + + +class LawOrdinanceAdapter(BaseAdapter): + meta = SourceMeta( + name="법제처 자치법규 검색", + provider="법제처 국가법령정보 공동활용", + license="other", # 법령·자치법규 본문은 저작권 보호 대상이 아님(저작권법 제7조). 메타데이터만 사용 + url="https://open.law.go.kr", + ) + api_key_env = "LAW_OC" + + def fetch(self, queries: list[str], per_page: int = 100, max_pages: int = 20) -> pd.DataFrame: + import requests + + rows: list[dict] = [] + for q in queries: + page, total = 1, None + while page <= max_pages and (total is None or (page - 1) * per_page < total): + r = requests.get( + SEARCH_URL, + params={ + "OC": self.api_key(), + "target": "ordin", + "type": "XML", + "query": q, + "display": per_page, + "page": page, + }, + timeout=30, + ) + r.raise_for_status() + got, total = parse_ordinance_xml(r.content) + rows += [{**g, "query": q} for g in got] + page += 1 + time.sleep(0.3) + return tidy(pd.DataFrame(rows)) + + +def tidy(df: pd.DataFrame) -> pd.DataFrame: + if df.empty: + return pd.DataFrame(columns=[*KEEP, "query", "year"]) + df = df[[c for c in [*KEEP, "query"] if c in df.columns]].drop_duplicates("자치법규ID") + date = pd.to_datetime(df["시행일자"], format="%Y%m%d", errors="coerce") + return df.assign(year=date.dt.year).sort_values("시행일자").reset_index(drop=True) diff --git a/core/agent/nodes.py b/core/agent/nodes.py index 36161c0..2040794 100644 --- a/core/agent/nodes.py +++ b/core/agent/nodes.py @@ -342,7 +342,7 @@ def report(s: FlowState): figs["Raw trends"] = "figures/raw_trends.png" if "coefs" in r.extra: note = ( - "per-period CIs cluster-robust, reference only: few treated units" + "per-period CIs: reference only (few treated units)" if "few_treated_clusters" in r.triggers else "cluster-robust" ) diff --git a/core/discovery/__init__.py b/core/discovery/__init__.py index 77c4ac6..58cac82 100644 --- a/core/discovery/__init__.py +++ b/core/discovery/__init__.py @@ -1,4 +1,7 @@ -"""소셜 반응 → 정책 식별 → 데이터셋 추천 → 분석계획 연결 (Flow ① 앞단). +"""소셜 반응 → 주제 → 주제 안의 정책 전체 → 데이터셋 → 분석계획 (Flow ① 앞단). + +원칙(출발 키트 B): 소셜 신호는 '어느 주제를 볼지'까지만 정한다. 화제가 된 정책 하나만 +골라 분석하면 결과를 보고 사례를 고르는 셈이 되어 효과가 과대 추정된다. from core.discovery import discover r = discover("토허제 확대하고 강남 집값 잡혔나요?") @@ -11,8 +14,8 @@ - 분석 방법은 LLM이 아니라 카탈로그의 design(규칙 기반 판별 결과)으로 정한다. """ -from .catalog import Dataset, Policy, load_catalog -from .match import Match, match_issue +from .catalog import Dataset, Policy, Topic, load_catalog, load_topics +from .match import Match, TopicMatch, match_issue, match_topic from .pipeline import DiscoveryResult, discover from .search import SearchHit, search_datago @@ -20,6 +23,10 @@ "Dataset", "Policy", "load_catalog", + "Topic", + "load_topics", + "TopicMatch", + "match_topic", "Match", "match_issue", "SearchHit", diff --git a/core/discovery/catalog.py b/core/discovery/catalog.py index 8ff3b88..50832a2 100644 --- a/core/discovery/catalog.py +++ b/core/discovery/catalog.py @@ -70,3 +70,46 @@ def design_ko(self) -> str: def load_catalog(path: str | Path = DEFAULT_CATALOG) -> list[Policy]: raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) return [Policy.model_validate(p) for p in raw["policies"]] + + +DEFAULT_TOPICS = ROOT / "catalog" / "topics.yaml" + +GateStatus = Literal["pass", "check", "key", "fail"] +GATE_KO = {"when": "언제", "who": "누가", "what": "무엇을"} +GATE_STATUS_KO = {"pass": "통과", "check": "확인 필요", "key": "API 키 필요", "fail": "탈락"} + + +class Gate(BaseModel): + status: GateStatus + note: str = "" + + +class Collect(BaseModel): + method: Literal["law_ordinance", "notice", "curated"] + query: list[str] = [] + note: str = "" + + +class Event(BaseModel): + date: date + what: str + source: str | None = None + + +class Topic(BaseModel): + """소셜 신호가 가리키는 '주제'. 분석 대상은 주제 안의 정책 전체다(출발 키트 B 방식).""" + + id: str + name: str + question: str + keywords: list[str] + policies: list[str] = [] + collect: Collect + events: list[Event] = [] + gates: dict[Literal["when", "who", "what"], Gate] + pitfalls: list[str] = [] + + +def load_topics(path: str | Path = DEFAULT_TOPICS) -> list[Topic]: + raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + return [Topic.model_validate(t) for t in raw["topics"]] diff --git a/core/discovery/match.py b/core/discovery/match.py index d976c76..e627a0d 100644 --- a/core/discovery/match.py +++ b/core/discovery/match.py @@ -10,7 +10,7 @@ import re from dataclasses import dataclass, field -from .catalog import Policy +from .catalog import Policy, Topic @dataclass @@ -68,3 +68,25 @@ def _llm_pick(text: str, cands: list[Match]) -> None: m.picked_by, m.reason = "llm", f"LLM: {ans.get('reason', '')} ({m.reason})" cands.insert(0, cands.pop(i)) return + + +@dataclass +class TopicMatch: + topic: Topic + score: float + hits: list[str] + + +def match_topic( + text: str, topics: list[Topic], policies: list[Policy], top_k: int = 3 +) -> list[TopicMatch]: + """주제 키워드 + 주제에 속한 정책 키워드로 점수. 소셜 신호는 주제까지만 정한다.""" + by_id = {p.id: p for p in policies} + t = _norm(text) + out = [] + for tp in topics: + kws = dict.fromkeys(tp.keywords + [k for pid in tp.policies for k in by_id[pid].keywords]) + hits = [k for k in kws if _norm(k) in t] + if hits: + out.append(TopicMatch(tp, float(sum(min(len(_norm(k)), 6) for k in hits)), hits)) + return sorted(out, key=lambda m: m.score, reverse=True)[:top_k] diff --git a/core/discovery/pipeline.py b/core/discovery/pipeline.py index 548cfb6..f84c215 100644 --- a/core/discovery/pipeline.py +++ b/core/discovery/pipeline.py @@ -5,8 +5,8 @@ from dataclasses import dataclass, field from pathlib import Path -from .catalog import ROOT, Policy, load_catalog -from .match import Match, match_issue +from .catalog import ROOT, Policy, Topic, load_catalog, load_topics +from .match import Match, TopicMatch, match_issue, match_topic from .search import SearchHit, search_datago @@ -16,6 +16,12 @@ class DiscoveryResult: matches: list[Match] live_hits: list[SearchHit] = field(default_factory=list) live_status: str = "실시간 검색 안 함" + topics: list[TopicMatch] = field(default_factory=list) + policies_in_topic: list[Policy] = field(default_factory=list) + + @property + def topic(self) -> Topic | None: + return self.topics[0].topic if self.topics else None @property def top(self) -> Policy | None: @@ -46,6 +52,10 @@ def discover( ) -> DiscoveryResult: cat = catalog if catalog is not None else load_catalog() res = DiscoveryResult(text=text, matches=match_issue(text, cat, use_llm=use_llm)) + res.topics = match_topic(text, load_topics(), cat) + if res.topic: + by_id = {p.id: p for p in cat} + res.policies_in_topic = [by_id[i] for i in res.topic.policies] if live_search and res.top and res.top.search_terms: res.live_hits, res.live_status = search_datago(res.top.search_terms) return res diff --git a/scripts/refresh_law_snapshots.py b/scripts/refresh_law_snapshots.py new file mode 100644 index 0000000..1d45777 --- /dev/null +++ b/scripts/refresh_law_snapshots.py @@ -0,0 +1,36 @@ +"""법제처 API로 주제별 조례 스냅샷을 갱신한다 → catalog/snapshots/law_.csv + +GitHub Actions(pages.yml)에서 LAW_OC 시크릿이 있을 때만 실행된다. 로컬: LAW_OC=... python scripts/refresh_law_snapshots.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from core.adapters.base import MissingAPIKey # noqa: E402 +from core.adapters.law import LawOrdinanceAdapter # noqa: E402 +from core.discovery import load_topics # noqa: E402 + + +def main() -> int: + out_dir = ROOT / "catalog" / "snapshots" + ad = LawOrdinanceAdapter() + for t in load_topics(): + if t.collect.method != "law_ordinance": + continue + try: + df = ad.fetch(t.collect.query) + except MissingAPIKey as e: + print(f"건너뜀: {e}") + return 0 + path = ad.save(df, out_dir / f"law_{t.id}.csv", {"queries": t.collect.query}) + print(f"{t.id}: 조례 {len(df)}건 → {path.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/site/build.py b/site/build.py new file mode 100644 index 0000000..3939c1f --- /dev/null +++ b/site/build.py @@ -0,0 +1,340 @@ +"""정적 대시보드 빌더 → _site/ (GitHub Pages, 서버 없음) + + python site/build.py # _site/index.html, _site/topics/.html + python -m http.server -d _site # 로컬 미리보기 + +입력(모두 레포에 커밋된 파일): catalog/topics.yaml, catalog/policies.yaml, +catalog/snapshots/law_.csv(있으면), cases//flow_log.json·figures/*.png +""" + +from __future__ import annotations + +import html +import json +import shutil +import sys +from pathlib import Path + +import pandas as pd + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +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, +) + +OUT = ROOT / "_site" +REPO = "https://github.com/CausalInferenceLab/policy-effect-analytics-agent" +VERDICT = { + "identified": ("식별됨", "go"), + "conditional": ("조건부", "warn"), + "not_identified": ("식별 불가", "stop"), +} +GATE_CLASS = {"pass": "go", "check": "warn", "key": "warn", "fail": "stop"} +ACCESS_KO = {"api_key": "API 키(자동승인)", "file": "파일", "manual": "수동 수집"} +COLLECT_KO = { + "law_ordinance": "법제처 조례 자동 수집", + "notice": "고시문에서 이력 수집", + "curated": "카탈로그 목록", +} + +e = html.escape + +CSS = """ +:root{--paper:#f4f5f7;--surface:#fff;--ink:#16181d;--ink2:#454b57;--muted:#6f7683;--hair:#d9dce2; +--accent:#1d5b8f;--go:#1f6b4f;--go-bg:#e2f1ea;--warn:#8a5a00;--warn-bg:#fbefd7;--stop:#9b2c33;--stop-bg:#f8e3e4; +--sans:"Pretendard","Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif} +@media (prefers-color-scheme:dark){:root{--paper:#121418;--surface:#1b1e24;--ink:#eceef2;--ink2:#c3c8d1;--muted:#8d94a1; +--hair:#2e333c;--accent:#7cb4e6;--go:#79d1a8;--go-bg:#16302a;--warn:#e7b35a;--warn-bg:#33291a;--stop:#ec8e95;--stop-bg:#361e21}} +*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.7 var(--sans);word-break:keep-all} +a{color:var(--accent)}.wrap{max-width:1040px;margin:0 auto;padding:40px 16px 80px} +header.top{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap;border-bottom:2px solid var(--ink);padding-bottom:14px} +header.top a{font-size:14px}h1{font-size:clamp(28px,4.4vw,40px);line-height:1.2;margin:22px 0 8px} +h2{font-size:21px;margin:44px 0 12px}h3{font-size:17px;margin:0 0 6px}.lede{color:var(--ink2);max-width:66ch;margin:0} +.muted{color:var(--muted);font-size:14px}.card{background:var(--surface);border:1px solid var(--hair);border-radius:10px;padding:18px} +.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(290px,1fr))} +.steps{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));margin-top:16px} +.step{background:var(--surface);border:1px solid var(--hair);border-radius:10px;padding:12px 14px;font-size:14px} +.step b{display:block;font-size:15px}.step .n{color:var(--accent);font-weight:700;font-size:13px} +.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)} +.gates{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0 4px} +textarea{width:100%;min-height:74px;font:inherit;padding:12px;border-radius:10px;border:1px solid var(--hair);background:var(--surface);color:var(--ink)} +.hit{outline:2px solid var(--accent)}table{width:100%;border-collapse:collapse;font-size:14px} +th,td{text-align:left;padding:8px 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:10px} +.banner{border-radius:10px;padding:12px 16px;margin:14px 0;font-weight:600} +.kpi{display:flex;gap:28px;flex-wrap:wrap;margin:8px 0}.kpi div{min-width:120px}.kpi .v{font-size:26px;font-weight:700} +.figs{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(300px,1fr))}.figs img{width:100%;background:#fff;border-radius:8px;border:1px solid var(--hair)} +svg text{fill:var(--ink2);font:12px var(--sans)}ul{padding-left:20px}footer{margin-top:60px;color:var(--muted);font-size:13px;border-top:1px solid var(--hair);padding-top:14px} +""" + + +def page(title: str, body: str, depth: int = 0) -> str: + up = "../" * depth + return f""" +{e(title)} +
+
정책 효과 분석 플랫폼 +GitHub · 가짜연구소 인과추론팀 × OpenUp
+{body} +
모든 수치와 판정은 레포의 catalog/·cases/에서 GitHub Actions가 자동 생성합니다. +시뮬레이션 데이터로 만든 결과에는 별도 표시가 붙습니다. · {REPO}
+
""" + + +def gate_badges(t) -> str: + return "".join( + f'' + f"{GATE_KO[k]} · {GATE_STATUS_KO[g.status]}" + for k, g in t.gates.items() + ) + + +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 + + def x(t): + return x0 + (x1 - x0) * ((t - lo) / (hi - lo)) + + ticks = "".join( + f'' + f'{y}' + for y in range(lo.year, hi.year + 1) + ) + dots = "".join( + f'' + f'{i + 1}' + for i, t in enumerate(d) + ) + items = "".join( + f"
  • {ev.date:%Y.%m.%d} {e(ev.what)}" + + (f' 출처' if ev.source else "") + + "
  • " + for ev in events + ) + return ( + f'' + f'{ticks}{dots}' + f"
      {items}
    " + ) + + +def bars_svg(counts: pd.Series, label: str) -> str: + if counts.empty: + return "" + w, h, pad = 900, 220, 30 + bw = (w - 2 * pad) / len(counts) + mx = counts.max() + bars = [] + for i, (k, v) in enumerate(counts.items()): + bh = (h - 60) * v / mx + xx = pad + i * bw + bars.append( + f'' + f'{k}' + f'{v}' + ) + return f'{"".join(bars)}' + + +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 + + plan = yaml.safe_load((case / "plan.yaml").read_text(encoding="utf-8")) + dest = OUT / "cases" / case.name + dest.mkdir(parents=True, exist_ok=True) + figs = [] + for f in sorted((case / "figures").glob("*.png")): + shutil.copy(f, dest / f.name) + figs.append(f'{e(f.stem)}') + label, cls = VERDICT.get(log.get("verdict"), ("-", "warn")) + 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)}' + ) + out.append( + '
    ' + f"{''.join(rows)}
    결과 지표추정치95% 신뢰구간p판정경고
    " + ) + narr = (log.get("guard") or {}).get("narrative") + if narr: + out.append(f'

    {e(narr).replace("**", "")}

    ') + if figs: + out.append(f'
    {"".join(figs)}
    ') + out.append( + 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"

    {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 index_page(topics, policies) -> str: + cards = [] + for t in topics: + cards.append( + f'' + f"

    {e(t.name)}

    {e(t.question)}

    " + f'
    {gate_badges(t)}
    ' + ) + 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] + ) + ) + for t in topics + } + steps = [ + ("소셜 신호", "뉴스 제목·SNS 글"), + ("주제", "신호는 주제까지만 정함"), + ("정책 전체 수집", "법제처 조례·고시로 지역×시점"), + ("세 관문", "언제 · 누가 · 무엇을"), + ("사전 등록", "계획을 먼저 커밋"), + ("효과 추정", "판정: 식별됨·조건부·식별 불가"), + ] + body = f""" +

    소셜 반응에서 정책 효과까지

    +

    사람들이 이야기하는 정책이 실제로 효과가 있었는지, 공공데이터로 확인합니다. +화제가 된 정책 하나만 골라 분석하지 않고, 그 주제의 정책을 전부 모아 비교합니다. +화제성으로 사례를 고르면 결과를 보고 사례를 고르는 셈이 되기 때문입니다.

    +
    {"".join(f'
    {i + 1}{a}{b}
    ' for i, (a, b) in enumerate(steps))}
    +

    지금 어떤 이야기가 궁금하세요?

    + +

    글을 입력하면 해당하는 주제를 찾아 표시합니다. (브라우저 안에서만 동작, 서버로 보내지 않음)

    +

    주제

    {"".join(cards)}
    +""" + return page("정책 효과 분석 플랫폼", body) + + +def main() -> None: + topics, policies = load_topics(), load_catalog() + if OUT.exists(): + shutil.rmtree(OUT) + (OUT / "topics").mkdir(parents=True) + (OUT / "index.html").write_text(index_page(topics, policies), encoding="utf-8") + for t in topics: + (OUT / "topics" / f"{t.id}.html").write_text(topic_page(t, policies), encoding="utf-8") + (OUT / ".nojekyll").write_text("") + print(f"_site/ 생성: 주제 {len(topics)}개") + + +if __name__ == "__main__": + main() diff --git a/tests/core/test_law_adapter.py b/tests/core/test_law_adapter.py new file mode 100644 index 0000000..a3a6f56 --- /dev/null +++ b/tests/core/test_law_adapter.py @@ -0,0 +1,17 @@ +import pandas as pd + +from core.adapters.law import parse_ordinance_xml, tidy + +# 법제처 자치법규 검색 응답 형식 (필드 구조만 재현한 최소 예시) +XML = """ordin2 +<자치법규ID>1<자치법규명>가 지역화폐 조례<지자체기관명>경기도 가군 +<제개정구분명>제정<공포일자>20190101<시행일자>20190101 +<자치법규ID>2<자치법규명>나 지역사랑상품권 조례<지자체기관명>강원도 나군 +<제개정구분명>일부개정<공포일자>20210301<시행일자>20210301""" + + +def test_parse_and_tidy(): + rows, total = parse_ordinance_xml(XML) + assert total == 2 and rows[0]["지자체기관명"] == "경기도 가군" + df = tidy(pd.DataFrame(rows + rows)) # 중복 제거 + assert len(df) == 2 and df["year"].tolist() == [2019, 2021] diff --git a/tests/test_site_build.py b/tests/test_site_build.py new file mode 100644 index 0000000..2c23aa2 --- /dev/null +++ b/tests/test_site_build.py @@ -0,0 +1,25 @@ +"""정적 대시보드가 모든 주제 페이지를 만들고, 로컬 경로·개인정보를 담지 않는지.""" + +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_site_builds(tmp_path, monkeypatch): + spec = importlib.util.spec_from_file_location("site_build", ROOT / "site" / "build.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + monkeypatch.setattr(mod, "OUT", tmp_path / "_site") + mod.main() + out = tmp_path / "_site" + topics = mod.load_topics() + assert (out / "index.html").exists() + for t in topics: + page = (out / "topics" / f"{t.id}.html").read_text(encoding="utf-8") + assert t.name in page + housing = (out / "topics" / "housing-regulation.html").read_text(encoding="utf-8") + assert "시뮬레이션 데이터 결과" in housing # 합성 결과 표시 + for f in out.rglob("*.html"): + text = f.read_text(encoding="utf-8") + assert "/home/" not in text and "/tmp/" not in text