{
  "cells": [
    {
      "cell_type": "markdown",
      "id": "poster-00",
      "metadata": {},
      "source": "# AI와 문화공간 포스터 만들기 · 실행 가능한 완성 예제\n\n이 노트북은 Grilling에서 선택을 마친 교수자 사례입니다. 실제 AI 대화 로그가 아닙니다.\n마포구 문화공간의 종류별 등록 기록 수를 제목·그래프·짧은 해석·출처가 있는 포스터로 만듭니다.\n처음부터 따라 하려면 walkthrough.md의 요청문을 같은 AI 대화에서 순서대로 사용합니다.\n\n## 실행 방법\n\nCodex 또는 Claude Code에서 **이 파일과 data/가 있는 폴더**를 엽니다.\nAI에게 Python·Jupyter 커널·필요한 패키지를 확인하고 이 노트북을 실행하도록 요청합니다.\n실행 도구는 requirements-notebook.txt, 재현 명령은 README.md에 있습니다.\nrun_notebook.py는 준비부터 지정한 단계까지 새 커널로 실행하고 출력이 있는 사본을 output/에 저장합니다.\n원본 poster.ipynb는 그대로 보존합니다. 필요한 경우 Jupyter 화면에서도 위에서부터 실행할 수 있습니다.\n\n- A까지: 준비·집계 → 실제 기록과 합계 확인\n- B까지: 준비·집계·첫 포스터 → 그림 직접 확인\n- C까지: 준비부터 수정까지 → 같은 값으로 정렬 전후 비교\n\n앱의 노트북 미리보기 기능과 코드 실행은 구분합니다. 실행된 노트북에 출력이 저장되어 있는지 확인하고,\n그림은 output/poster-before.html과 output/poster.html을 브라우저에서 직접 엽니다.\n\n자료: 서울시 문화공간 정보 · 서울특별시 문화본부 문화정책과 · 수업 보관본 2026-10-21.\n공공누리 제1유형(출처표시). [원본 출처](https://data.seoul.go.kr/dataList/OA-15487/S/1/datasetView.do).\n현재 운영 현황이나 인기도를 뜻하지 않습니다.\n"
    },
    {
      "cell_type": "markdown",
      "id": "poster-01",
      "metadata": {},
      "source": "## 준비 · 같은 폴더와 Python\n\n출력의 Python 경로와 프로젝트 위치를 확인합니다."
    },
    {
      "cell_type": "code",
      "id": "poster-02",
      "metadata": {
        "week09_stage": "setup"
      },
      "source": "from pathlib import Path\nimport hashlib\nimport json\nimport csv\nimport sys\nfrom collections import Counter\nfrom html import escape\nfrom IPython.display import HTML, display\n\nproject = Path.cwd()\nif not (project / \"data/source.json\").is_file():\n    raise FileNotFoundError(\"이 노트북이 있는 프로젝트 폴더를 실행 위치로 지정하세요. data/source.json이 필요합니다.\")\noutput = project / \"output\"\noutput.mkdir(exist_ok=True)\nDISTRICT = \"마포구\"\nTITLE = \"마포구의 문화공간, 어떤 종류가 많을까?\"\nprint(\"실행 Python:\", sys.executable)\nprint(\"프로젝트:\", project)\nprint(\"입력:\", project / \"data/source.json\")\n",
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "markdown",
      "id": "poster-03",
      "metadata": {},
      "source": "## Ticket A · 자료와 집계 확인\n\n마포구 55건, 7종류. 원본 일부와 종류별 수치·합계를 대조한 뒤 다음 작업을 고릅니다."
    },
    {
      "cell_type": "code",
      "id": "poster-04",
      "metadata": {
        "week09_stage": "counts"
      },
      "source": "def read_data(root):\n    meta = json.loads((root / 'data/source.json').read_text(encoding='utf-8'))\n    source = root / 'data' / meta['snapshot_filename']\n    digest = hashlib.sha256(source.read_bytes()).hexdigest()\n    if digest != meta['sha256']:\n        raise ValueError('원본이 보관본 정보와 다릅니다. 입력 자료와 source.json을 함께 확인하세요.')\n    with source.open(encoding=meta['encoding'], newline='') as stream:\n        reader = csv.DictReader(stream)\n        required = {'번호', '자치구', '주제분류', '문화시설명'}\n        if not required.issubset(reader.fieldnames or []):\n            raise ValueError('필요한 열이 없습니다: ' + ', '.join(sorted(required)))\n        rows = list(reader)\n    if len(rows) != meta['rows']:\n        raise ValueError('전체 기록 수가 보관본 정보와 다릅니다.')\n    selected = [row for row in rows if row['자치구'] == DISTRICT]\n    if not selected:\n        raise ValueError('선택한 지역의 기록이 없습니다. 지역 값을 확인하세요.')\n    if any(not row['주제분류'].strip() or not row['번호'].strip() for row in selected):\n        raise ValueError('선택한 기록에 빈 분류나 번호가 있습니다. 처리 기준부터 정하세요.')\n    if len({row['번호'] for row in selected}) != len(selected):\n        raise ValueError('선택한 기록의 번호가 중복됩니다. 한 건의 기준부터 확인하세요.')\n    counts = Counter(row['주제분류'] for row in selected)\n    return meta, selected, counts\n\nmeta, selected, counts = read_data(project)\nevidence = dict(district=DISTRICT, selected_records=len(selected), unit=\"등록 기록 1건\",\n                counts=dict(sorted(counts.items())), source_sha256=meta[\"sha256\"],\n                source=meta, selected_ids=[row[\"번호\"] for row in selected])\n(output / \"counts.json\").write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + \"\\n\", encoding=\"utf-8\")\nprint(f\"원본 {meta['rows']}건 → {DISTRICT} {len(selected)}건 / {len(counts)}종류 / 합계 {sum(counts.values())}건\")\nprint(\"원본 일부와 대조할 기록:\")\nfor row in selected[:5]:\n    print(row[\"번호\"], row[\"문화시설명\"], row[\"자치구\"], row[\"주제분류\"], sep=\" | \")\nprint(\"종류별 집계:\")\nfor label, value in sorted(counts.items()):\n    print(f\"{label}: {value}건\")\n",
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "markdown",
      "id": "poster-05",
      "metadata": {},
      "source": "## Ticket B · 첫 포스터\n\n종류 이름 순으로 배치합니다. 제목·숫자·해석·출처가 보이는지 확인합니다."
    },
    {
      "cell_type": "code",
      "id": "poster-06",
      "metadata": {
        "week09_stage": "first"
      },
      "source": "def poster_svg(meta, counts, order):\n    ordered = sorted(counts.items(), key=(lambda pair: pair[0]) if order == 'name'\n                     else (lambda pair: (-pair[1], pair[0])))\n    total = sum(counts.values())\n    largest = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))[0]\n    maximum = max(counts.values())\n    axis_max = max(5, ((maximum + 4) // 5) * 5)\n    x0, width = 320, 740\n    parts = ['<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"1560\" viewBox=\"0 0 1200 1560\" role=\"img\" aria-labelledby=\"poster-title poster-desc\">',\n             f'<title id=\"poster-title\">{TITLE}</title>',\n             f'<desc id=\"poster-desc\">수업 보관본의 마포구 등록 기록 {total}건. ' +\n             escape(', '.join(f'{name} {count}건' for name, count in ordered)) +\n             '. 현재 운영 현황이나 인기도를 뜻하지 않습니다.</desc>',\n             '<style>text{font-family:\"Apple SD Gothic Neo\",\"Malgun Gothic\",\"Noto Sans KR\",sans-serif;fill:#292d2b} .muted{fill:#63685f} .accent{fill:#ad492f} .rule{stroke:#c8c9bc;stroke-width:1} .grid{stroke:#ddded2;stroke-width:1}</style>',\n             '<rect width=\"1200\" height=\"1560\" fill=\"#f5f3e9\"/>']\n\n    def text(x, y, value, size=24, attrs=''):\n        parts.append(f'<text x=\"{x}\" y=\"{y}\" font-size=\"{size}\" {attrs}>{escape(str(value))}</text>')\n\n    text(88, 81, '동네를 읽는 데이터   /   마포구', 22, 'class=\"accent\" font-weight=\"700\"')\n    text(1112, 81, '01', 24, 'text-anchor=\"end\" class=\"muted\"')\n    parts.append('<path d=\"M88 112H1112\" class=\"rule\"/>')\n    text(84, 209, '마포구의 문화공간,', 72, 'font-weight=\"800\"')\n    text(84, 295, '어떤 종류가 많을까?', 72, 'font-weight=\"800\"')\n    text(88, 350, '서울시 문화공간 정보에 등록된 기록의 종류별 분포', 26, 'class=\"muted\"')\n    text(84, 483, total, 108, 'class=\"accent\" font-weight=\"700\"')\n    text(258, 433, f'건의 등록 기록 · {len(counts)}가지 분류', 30, 'font-weight=\"650\"')\n    text(258, 476, '공간을 고르기 전에, 목록의 구성을 살펴봅니다.', 24, 'class=\"muted\"')\n    parts.append('<path d=\"M88 530H1112\" class=\"rule\"/>')\n    text(88, 580, '종류별 등록 기록 수', 26, 'font-weight=\"700\"')\n    text(1112, 580, '단위: 건', 22, 'text-anchor=\"end\" class=\"muted\"')\n    for tick in range(0, axis_max + 1, 5):\n        x = x0 + tick / axis_max * width\n        parts.append(f'<path d=\"M{x:g} 642V1163\" class=\"grid\"/>')\n        text(f'{x:g}', 627, tick, 20, 'text-anchor=\"middle\" class=\"muted\"')\n    for index, (label, count) in enumerate(ordered):\n        y = 674 + index * 74\n        bar_width = count / axis_max * width\n        parts.append(f'<g data-category=\"{escape(label, quote=True)}\" data-count=\"{count}\">')\n        text(88, y + 29, label, 26)\n        parts.append(f'<rect x=\"{x0}\" y=\"{y}\" width=\"{bar_width:.3f}\" height=\"38\" fill=\"#ad492f\"/>')\n        text(f'{x0 + bar_width + 14:.3f}', y + 29, count, 26, 'font-weight=\"700\"')\n        parts.append('</g>')\n    parts.append('<path d=\"M88 1212H1112\" class=\"rule\"/>')\n    text(88, 1269, f'{largest[0]} {largest[1]}건, 등록 기록 중 가장 큰 비중.', 30, 'font-weight=\"700\"')\n    text(88, 1311, '이 그림은 사람들의 선호가 아니라, 목록에 담긴 공간의 구성을 보여줍니다.', 24, 'class=\"muted\"')\n    text(88, 1383, '출처: ' + meta['provider'], 20, 'class=\"muted\"')\n    parts.append(f'<a href=\"{escape(meta[\"source_url\"], quote=True)}\">')\n    text(88, 1415, meta['title'] + ' · 공공누리 제1유형(출처표시)', 20, 'class=\"muted\" text-decoration=\"underline\"')\n    parts.append('</a>')\n    text(88, 1452, f'수업 보관본 수집일: {meta[\"retrieved_on\"]} · 자치구가 마포구인 기록만 선택', 20, 'class=\"muted\"')\n    text(88, 1485, '원본의 등록 기록 하나를 한 건으로 셉니다. 현재 운영 현황이나 인기도를 뜻하지 않습니다.', 20, 'class=\"muted\"')\n    text(88, 1518, '수집일에 모든 시설의 정보가 확인되었다는 의미는 아니며, 목록 밖의 공간은 포함하지 않습니다.', 20, 'class=\"muted\"')\n    return '\\n'.join(parts + ['</svg>']) + '\\n'\n\ndef poster_html(svg, counts, order):\n    label = '첫 포스터 · 종류 이름 순' if order == 'name' else '수정 포스터 · 기록 수가 많은 순'\n    rows = ''.join(f'<tr><th scope=\"row\">{escape(name)}</th><td>{count}</td></tr>'\n                   for name, count in sorted(counts.items(), key=lambda pair: (-pair[1], pair[0])))\n    return f'''<!doctype html>\n<html lang=\"ko\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>{TITLE} | {label}</title>\n<style>*{{box-sizing:border-box}}body{{margin:0;background:#e6e5dd;color:#292d2b;font:16px/1.7 \"Apple SD Gothic Neo\",\"Malgun Gothic\",sans-serif}}main{{max-width:1200px;margin:auto}}nav,details{{padding:20px 28px}}nav{{display:flex;gap:20px;justify-content:space-between;flex-wrap:wrap}}a{{color:inherit;text-underline-offset:4px}}svg{{display:block;width:100%;height:auto}}summary{{cursor:pointer}}table{{border-collapse:collapse;width:min(100%,600px);margin-top:20px}}th,td{{text-align:left;border-bottom:1px solid #bfc2b7;padding:8px}}@media print{{@page{{size:200mm 260mm;margin:0}}nav,details{{display:none}}body{{background:#f5f3e9}}main{{width:200mm}}}}</style>\n</head><body><main><nav aria-label=\"결과 확인\"><span>{label}</span><a href=\"{'poster-before.svg' if order == 'name' else 'poster.svg'}\" download>포스터 원본 SVG 저장</a></nav>\n{svg}\n<details><summary>그래프의 수치를 표로 확인하기</summary><table><caption>마포구 종류별 등록 기록 수 · 단위: 건</caption><thead><tr><th scope=\"col\">종류</th><th scope=\"col\">기록 수</th></tr></thead><tbody>{rows}</tbody><tfoot><tr><th scope=\"row\">합계</th><td>{sum(counts.values())}</td></tr></tfoot></table></details>\n</main></body></html>\\n'''\n\ndef show_poster(svg):\n    responsive = svg.replace('width=\"1200\" height=\"1560\"', 'style=\"width:100%;height:auto\"', 1)\n    display(HTML('<div style=\"max-width:600px\">' + responsive + '</div>'))\n\nfirst_svg = poster_svg(meta, counts, \"name\")\n(output / \"poster-before.svg\").write_text(first_svg, encoding=\"utf-8\")\n(output / \"poster-before.html\").write_text(poster_html(first_svg, counts, \"name\"), encoding=\"utf-8\")\nprint(\"첫 포스터 · 종류 이름 순\")\nshow_poster(first_svg)\n",
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "markdown",
      "id": "poster-07",
      "metadata": {},
      "source": "## Ticket C · 정렬 수정\n\n같은 자료·수치·색·문구를 유지하고 기록 수가 많은 순으로 바꿉니다. 동률은 종류 이름 순입니다."
    },
    {
      "cell_type": "code",
      "id": "poster-08",
      "metadata": {
        "week09_stage": "revise"
      },
      "source": "before_counts = json.loads((output / \"counts.json\").read_text(encoding=\"utf-8\"))[\"counts\"]\nbefore_svg = (output / \"poster-before.svg\").read_text(encoding=\"utf-8\")\nfinal_svg = poster_svg(meta, counts, \"count\")\n(output / \"poster.svg\").write_text(final_svg, encoding=\"utf-8\")\n(output / \"poster.html\").write_text(poster_html(final_svg, counts, \"count\"), encoding=\"utf-8\")\nassert dict(counts) == before_counts, \"종류별 수치가 바뀌었습니다.\"\nassert sum(counts.values()) == len(selected), \"합계를 다시 확인하세요.\"\nassert (output / \"poster-before.svg\").read_text(encoding=\"utf-8\") == before_svg\nprint(\"수정 후 · 기록 수가 많은 순 / 수치와 첫 포스터 보존 확인\")\nshow_poster(final_svg)\nprint(\"두 포스터에서 제목·색·축·단위·출처가 같은지 직접 확인하세요.\")\n",
      "execution_count": null,
      "outputs": []
    },
    {
      "cell_type": "markdown",
      "id": "poster-09",
      "metadata": {},
      "source": "## 실행 뒤 내 판단 남기기\n\n- 어떤 AI 제안을 선택했고 왜 선택했는가?\n- 실제로 실행한 단계와 결과 파일은 무엇인가?\n- 원본·수치·표현에서 무엇을 확인했는가?\n- 정렬 수정이 관객의 읽기에 도움이 되는가?\n- 아직 확인하지 않은 것은 무엇이며 다음 작업은 무엇인가?\n\n이 내용을 worklog.md에 적습니다. 실제 관객의 이해도와 현재 시설 운영 여부는 아직 확인하지 않았습니다.\n코드를 바꾸면 해당 셀과 뒤의 셀을 다시 실행합니다. 새 커널에서는 준비 셀부터 실행합니다.\n"
    }
  ],
  "metadata": {
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "name": "python",
      "version": "3"
    },
    "week09_source": {
      "title": "서울시 문화공간 정보",
      "provider": "서울특별시 문화본부 문화정책과",
      "source_url": "https://data.seoul.go.kr/dataList/OA-15487/S/1/datasetView.do",
      "original_system": "서울문화포털",
      "retrieved_on": "2026-10-21",
      "portal_data_updated_on": "2026-10-21",
      "license": "공공누리 제1유형: 출처표시 (상업적 이용 및 변경 가능)",
      "original_filename": "서울시 문화공간 정보.csv",
      "snapshot_filename": "week-09-seoul-culture-original.csv",
      "encoding": "cp949",
      "rows": 1096,
      "columns": 26,
      "sha256": "67fb08d319421114e425a1b66548c4563de6f067519aa353a2d45e71c0dfb201",
      "changes": "없음. Sheet 탭의 CSV 내려받기로 받은 원본 바이트를 보존함.",
      "note": "수집일 기준의 시설 목록이며 모든 시설의 운영 정보가 수집일에 확인되었다는 의미는 아님."
    },
    "week09_use": "AI 앱에서 작성·실행하는 포스터 사례 · 로컬 Python/Jupyter"
  },
  "nbformat": 4,
  "nbformat_minor": 5
}
