{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "# Week 12 · 텍스트 패턴 포스터 미션\n",
        "\n",
        "수업용 창작 텍스트 세 편 중 한 편을 선택하고, 원문을 보존한 채 전체 단어 빈도표와 문장 길이 흐름을 계산하여 1600×2200 포스터로 완성합니다.\n",
        "\n",
        "**완료 조건:** 마지막 셀의 `🎉 WEEK 12 TEXT PATTERN MISSION COMPLETE` 확인 + 실행 결과가 남은 노트북·전체 빈도표 CSV·포스터 PNG 세 파일 제출.\n",
        "\n",
        "`EDIT`라고 적힌 STEP 1과 STEP 5만 수정합니다. 다른 코드 셀을 고치면 고정된 분석 조건과 자동 검사가 달라질 수 있습니다.\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 0 · 환경과 수업용 텍스트 준비\n",
        "\n",
        "분석과 시각화에 필요한 도구, 한글 글꼴, 저작권과 개인정보 문제가 없는 수업 창작 텍스트 세 편을 준비합니다. 이 셀은 수정하지 않습니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 0 · 실행 환경과 수업용 텍스트 세 편 준비 — 이 셀은 수정하지 않습니다.\n",
        "from collections import Counter\n",
        "from pathlib import Path\n",
        "from textwrap import fill\n",
        "import importlib.util\n",
        "import re\n",
        "import subprocess\n",
        "import sys\n",
        "\n",
        "requirements = [\n",
        "    (\"pandas\", \"pandas\"),\n",
        "    (\"matplotlib\", \"matplotlib\"),\n",
        "    (\"PIL\", \"pillow\"),\n",
        "]\n",
        "missing_packages = [\n",
        "    package_name\n",
        "    for module_name, package_name in requirements\n",
        "    if importlib.util.find_spec(module_name) is None\n",
        "]\n",
        "if missing_packages:\n",
        "    subprocess.check_call(\n",
        "        [sys.executable, \"-m\", \"pip\", \"install\", \"-q\", *missing_packages]\n",
        "    )\n",
        "\n",
        "import pandas as pd\n",
        "import matplotlib.pyplot as plt\n",
        "from matplotlib import font_manager\n",
        "from PIL import Image\n",
        "from IPython.display import display\n",
        "\n",
        "mission_step0_execution = get_ipython().execution_count\n",
        "\n",
        "google_module_available = importlib.util.find_spec(\"google\") is not None\n",
        "colab_available = (\n",
        "    google_module_available\n",
        "    and importlib.util.find_spec(\"google.colab\") is not None\n",
        ")\n",
        "nanum_path = Path(\"/usr/share/fonts/truetype/nanum/NanumGothic.ttf\")\n",
        "if colab_available and not nanum_path.is_file():\n",
        "    subprocess.run(\n",
        "        [\"apt-get\", \"-qq\", \"install\", \"fonts-nanum\"],\n",
        "        check=True,\n",
        "        stdout=subprocess.DEVNULL,\n",
        "        stderr=subprocess.DEVNULL,\n",
        "    )\n",
        "\n",
        "font_candidates = [\n",
        "    nanum_path,\n",
        "    Path(\"/System/Library/Fonts/AppleSDGothicNeo.ttc\"),\n",
        "    Path(\"/System/Library/Fonts/Supplemental/AppleGothic.ttf\"),\n",
        "    Path(\"C:/Windows/Fonts/malgun.ttf\"),\n",
        "]\n",
        "korean_font_path = next(\n",
        "    (candidate for candidate in font_candidates if candidate.is_file()),\n",
        "    None,\n",
        ")\n",
        "if korean_font_path is not None:\n",
        "    resolved_font_path = str(korean_font_path.resolve())\n",
        "    registered_font_paths = {\n",
        "        str(Path(font_entry.fname).resolve())\n",
        "        for font_entry in font_manager.fontManager.ttflist\n",
        "    }\n",
        "    if resolved_font_path not in registered_font_paths:\n",
        "        font_manager.fontManager.addfont(resolved_font_path)\n",
        "    korean_font_name = font_manager.FontProperties(\n",
        "        fname=resolved_font_path\n",
        "    ).get_name()\n",
        "else:\n",
        "    korean_font_name = \"DejaVu Sans\"\n",
        "    print(\"한글 글꼴을 찾지 못했습니다. Colab에서 STEP 0을 다시 실행하세요.\")\n",
        "\n",
        "plt.rcParams[\"font.family\"] = korean_font_name\n",
        "plt.rcParams[\"axes.unicode_minus\"] = False\n",
        "\n",
        "TEXT_LIBRARY = {'library_night': {'title': '밤의 도서관', 'source': 'Contents Programming Practice Week 12 · 교수자 창작 자료', 'usage': '수업 목적의 분석·시각화·제출 허용', 'raw_text': '밤의 도서관은 조용히 문을 연다. 작은 빛 하나가 긴 책상 위에 머문다. 학생은 빛 아래에서 오래된 지도를 펼친다. 지도에는 사라진 골목과 낯선 이름이 남아 있다. 학생은 지도 옆에 작은 메모를 남긴다. 메모에는 빛, 골목, 질문이라는 세 단어가 적힌다. 질문은 길을 만들고 길은 새로운 이야기를 부른다. 이야기는 창가의 빛을 따라 천천히 길어진다. 도서관은 늦은 시간에도 조용한 이야기를 품는다. 학생은 마지막 문장을 읽고 지도를 접는다. 문은 닫히지만 질문은 메모 속에 남는다. 다음 밤이 오면 빛은 다시 문을 연다.', 'excluded_tokens': ('위에', '아래에서', '옆에', '속에'), 'expected': {'raw_characters': 291, 'sentence_count': 12, 'raw_token_count': 76, 'raw_type_count': 65, 'analysis_token_count': 72, 'analysis_type_count': 61, 'top10': [('빛', 3), ('학생은', 3), ('도서관은', 2), ('문을', 2), ('연다', 2), ('작은', 2), ('지도를', 2), ('질문은', 2), ('이야기를', 2), ('밤의', 1)], 'sentence_lengths': [5, 7, 6, 7, 6, 7, 7, 6, 6, 6, 6, 7]}}, 'rain_garden': {'title': '비 온 뒤의 정원', 'source': 'Contents Programming Practice Week 12 · 교수자 창작 자료', 'usage': '수업 목적의 분석·시각화·제출 허용', 'raw_text': '비가 그친 정원에는 작은 물방울이 남아 있다. 정원사는 젖은 흙 위에 새 씨앗을 놓는다. 씨앗 옆에는 이름을 적은 작은 표지가 서 있다. 바람은 잎 사이로 천천히 지나간다. 잎은 물방울을 흔들어 흙으로 돌려보낸다. 정원사는 물방울이 모인 길을 따라 걷는다. 길 끝에서는 노란 꽃 세 송이가 고개를 든다. 작은 벌 한 마리가 꽃 사이를 둥글게 돈다. 정원은 비가 오기 전보다 선명한 색을 품는다. 정원사는 달라진 빛과 냄새를 짧게 기록한다. 기록에는 씨앗, 물방울, 꽃이라는 세 단어가 반복된다. 다음 비가 오면 정원은 또 다른 기록을 만든다.', 'excluded_tokens': ('위에', '옆에는', '사이로', '끝에서는'), 'expected': {'raw_characters': 303, 'sentence_count': 12, 'raw_token_count': 82, 'raw_type_count': 70, 'analysis_token_count': 78, 'analysis_type_count': 66, 'top10': [('비가', 3), ('작은', 3), ('정원사는', 3), ('물방울이', 2), ('있다', 2), ('씨앗', 2), ('꽃', 2), ('세', 2), ('정원은', 2), ('그친', 1)], 'sentence_lengths': [7, 7, 8, 5, 5, 6, 8, 8, 7, 6, 7, 8]}}, 'morning_market': {'title': '아침 시장의 목소리', 'source': 'Contents Programming Practice Week 12 · 교수자 창작 자료', 'usage': '수업 목적의 분석·시각화·제출 허용', 'raw_text': '아침 시장은 첫 가게의 불빛과 함께 문을 연다. 상인은 붉은 사과를 나무 상자 위에 가지런히 놓는다. 옆 가게에서는 따뜻한 빵 냄새가 천천히 퍼진다. 손님은 사과와 빵 사이에서 잠시 걸음을 멈춘다. 상인은 오늘 들어온 사과의 맛과 산지를 설명한다. 손님은 작은 사과 세 개와 둥근 빵 하나를 고른다. 시장 안쪽에서는 생선 상자와 꽃 바구니가 나란히 놓인다. 사람들은 필요한 물건을 찾으며 짧은 질문을 주고받는다. 질문은 가격과 수량을 확인하는 말로 이어진다. 가게마다 다른 목소리가 좁은 길을 채운다. 손님은 마지막 가게에서 작은 꽃 한 송이를 더 산다. 해가 높아지면 시장의 첫 번째 이야기는 새로운 이야기로 바뀐다.', 'excluded_tokens': ('위에', '가게에서는', '사이에서', '안쪽에서는'), 'expected': {'raw_characters': 346, 'sentence_count': 12, 'raw_token_count': 91, 'raw_type_count': 83, 'analysis_token_count': 87, 'analysis_type_count': 79, 'top10': [('빵', 3), ('손님은', 3), ('첫', 2), ('상인은', 2), ('작은', 2), ('꽃', 2), ('아침', 1), ('시장은', 1), ('가게의', 1), ('불빛과', 1)], 'sentence_lengths': [8, 8, 7, 7, 7, 9, 8, 7, 6, 6, 9, 9]}}}\n",
        "TEXT_CHOICES = {\n",
        "    text_id: record[\"title\"]\n",
        "    for text_id, record in TEXT_LIBRARY.items()\n",
        "}\n",
        "\n",
        "print(\"선택 가능한 수업 창작 텍스트\")\n",
        "for text_id, title in TEXT_CHOICES.items():\n",
        "    print(\"-\", text_id, \"→\", title)\n",
        "print(\"준비 완료: 텍스트\", len(TEXT_LIBRARY), \"편\")\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 1 · 제출 정보와 텍스트 선택\n",
        "\n",
        "학번과 이름을 입력하고 `library_night`, `rain_garden`, `morning_market` 중 하나를 `text_choice`에 적습니다. 따옴표는 남겨 둡니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 1 · EDIT — 학번·이름과 분석할 텍스트 ID를 수정합니다.\n",
        "mission_step1_execution = get_ipython().execution_count\n",
        "\n",
        "student_id = \"학번\"\n",
        "student_name = \"이름\"\n",
        "text_choice = \"library_night\"\n",
        "\n",
        "if text_choice not in TEXT_LIBRARY:\n",
        "    raise KeyError(\n",
        "        \"text_choice는 library_night, rain_garden, morning_market 중 하나여야 합니다.\"\n",
        "    )\n",
        "\n",
        "print(\"제출자:\", student_id, student_name)\n",
        "print(\"선택한 텍스트:\", text_choice, \"·\", TEXT_CHOICES[text_choice])\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 2 · 원문과 출처 보존\n",
        "\n",
        "선택한 원문을 `raw_text`에 그대로 보존하고 제목, 출처, 이용 조건, 지정 제외 토큰을 별도 변수로 기록합니다. 이 셀은 수정하지 않습니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 2 · 원문과 출처 정보를 별도 변수에 보존 — 이 셀은 수정하지 않습니다.\n",
        "mission_step2_execution = get_ipython().execution_count\n",
        "\n",
        "selected_record = TEXT_LIBRARY[text_choice]\n",
        "text_title = selected_record[\"title\"]\n",
        "text_source = selected_record[\"source\"]\n",
        "text_usage = selected_record[\"usage\"]\n",
        "excluded_tokens = set(selected_record[\"excluded_tokens\"])\n",
        "expected = selected_record[\"expected\"]\n",
        "\n",
        "raw_text = selected_record[\"raw_text\"]\n",
        "raw_snapshot = raw_text\n",
        "source_snapshot = {\n",
        "    \"title\": text_title,\n",
        "    \"source\": text_source,\n",
        "    \"usage\": text_usage,\n",
        "    \"excluded_tokens\": tuple(selected_record[\"excluded_tokens\"]),\n",
        "}\n",
        "\n",
        "preliminary_sentences = [\n",
        "    sentence.strip()\n",
        "    for sentence in re.split(r\"[.!?]+\", raw_text)\n",
        "    if sentence.strip()\n",
        "]\n",
        "\n",
        "print(\"제목:\", text_title)\n",
        "print(\"출처:\", text_source)\n",
        "print(\"이용 조건:\", text_usage)\n",
        "print(\"원문 글자 수:\", len(raw_text))\n",
        "print(\"문장 수:\", len(preliminary_sentences))\n",
        "print(\"지정 제외 토큰:\", sorted(excluded_tokens))\n",
        "print(\"원문 미리보기:\", raw_text[:90])\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 3 · 텍스트를 세고 표로 바꾸기\n",
        "\n",
        "문장부호를 공백으로 바꾼 뒤 공백 기준으로 토큰화합니다. 지정 토큰을 제외하기 전후의 전체 토큰 수와 고유 토큰 수를 비교하고, 모든 분석 토큰의 빈도표와 열두 문장의 길이표를 만듭니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 3 · 문장 분리, 정규화, 공백 기준 토큰화와 빈도 계산 — 수정하지 않습니다.\n",
        "mission_step3_execution = get_ipython().execution_count\n",
        "\n",
        "sentences = [\n",
        "    sentence.strip()\n",
        "    for sentence in re.split(r\"[.!?]+\", raw_text)\n",
        "    if sentence.strip()\n",
        "]\n",
        "normalized_text = re.sub(r\"[^0-9A-Za-z가-힣\\s]\", \" \", raw_text)\n",
        "raw_tokens = normalized_text.split()\n",
        "tokens = [\n",
        "    token\n",
        "    for token in raw_tokens\n",
        "    if token not in excluded_tokens\n",
        "]\n",
        "\n",
        "token_counter = Counter(tokens)\n",
        "frequency_df = pd.DataFrame(\n",
        "    token_counter.most_common(),\n",
        "    columns=[\"word\", \"count\"],\n",
        ")\n",
        "top10_df = frequency_df.head(10).copy()\n",
        "\n",
        "sentence_lengths = [\n",
        "    len(re.sub(r\"[^0-9A-Za-z가-힣\\s]\", \" \", sentence).split())\n",
        "    for sentence in sentences\n",
        "]\n",
        "sentence_df = pd.DataFrame(\n",
        "    {\n",
        "        \"sentence_order\": range(1, len(sentences) + 1),\n",
        "        \"token_count\": sentence_lengths,\n",
        "    }\n",
        ")\n",
        "\n",
        "top_word = str(top10_df.iloc[0][\"word\"])\n",
        "top_count = int(top10_df.iloc[0][\"count\"])\n",
        "maximum_sentence_length = max(sentence_lengths)\n",
        "minimum_sentence_length = min(sentence_lengths)\n",
        "longest_sentence_orders = [\n",
        "    order\n",
        "    for order, length in enumerate(sentence_lengths, start=1)\n",
        "    if length == maximum_sentence_length\n",
        "]\n",
        "shortest_sentence_orders = [\n",
        "    order\n",
        "    for order, length in enumerate(sentence_lengths, start=1)\n",
        "    if length == minimum_sentence_length\n",
        "]\n",
        "\n",
        "print(\"원문:\", len(raw_tokens), \"토큰 ·\", len(set(raw_tokens)), \"종\")\n",
        "print(\"분석용:\", len(tokens), \"토큰 ·\", len(set(tokens)), \"종\")\n",
        "print(\"상위 10개 토큰:\")\n",
        "print(top10_df.to_string(index=False))\n",
        "print(\"문장별 토큰 수:\", sentence_lengths)\n",
        "print(\"가장 긴 문장:\", longest_sentence_orders, \"·\", maximum_sentence_length, \"토큰\")\n",
        "print(\"가장 짧은 문장:\", shortest_sentence_orders, \"·\", minimum_sentence_length, \"토큰\")\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 4 · 두 그래프의 수치 확인\n",
        "\n",
        "상위 열 개 토큰의 가로 막대그래프와 열두 문장의 순서별 토큰 수 그래프를 확인합니다. 가로축의 문장 순서는 시간이 아닙니다. 이 셀은 수정하지 않습니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 4 · 상위 단어 막대와 문장 길이 흐름 확인 — 이 셀은 수정하지 않습니다.\n",
        "mission_step4_execution = get_ipython().execution_count\n",
        "\n",
        "preview_figure, (bar_axis, line_axis) = plt.subplots(\n",
        "    2,\n",
        "    1,\n",
        "    figsize=(8, 9),\n",
        "    gridspec_kw={\"height_ratios\": [1.2, 1]},\n",
        ")\n",
        "preview_figure.patch.set_facecolor(\"#f4f0e7\")\n",
        "\n",
        "bar_frame = top10_df.iloc[::-1].reset_index(drop=True)\n",
        "bar_axis.barh(\n",
        "    bar_frame[\"word\"],\n",
        "    bar_frame[\"count\"],\n",
        "    color=\"#1e716d\",\n",
        "    height=0.66,\n",
        ")\n",
        "maximum_count = int(top10_df[\"count\"].max())\n",
        "bar_axis.set_xlim(0, maximum_count + 0.8)\n",
        "bar_axis.set_xlabel(\"빈도 · 반복 횟수\")\n",
        "bar_axis.set_title(\"상위 10개 토큰\", loc=\"left\", fontweight=\"bold\")\n",
        "for row_index, count in enumerate(bar_frame[\"count\"]):\n",
        "    bar_axis.text(\n",
        "        count + 0.08,\n",
        "        row_index,\n",
        "        str(int(count)),\n",
        "        va=\"center\",\n",
        "        fontweight=\"bold\",\n",
        "    )\n",
        "\n",
        "line_axis.plot(\n",
        "    sentence_df[\"sentence_order\"],\n",
        "    sentence_df[\"token_count\"],\n",
        "    color=\"#a44336\",\n",
        "    marker=\"o\",\n",
        "    linewidth=2.4,\n",
        "    markersize=7,\n",
        ")\n",
        "line_axis.set_xticks(range(1, 13))\n",
        "line_axis.set_xlabel(\"문장 순서 · 시간 아님\")\n",
        "line_axis.set_ylabel(\"공백 기준 토큰 수\")\n",
        "line_axis.set_title(\"문장 순서에 따른 길이\", loc=\"left\", fontweight=\"bold\")\n",
        "line_axis.grid(axis=\"y\", alpha=0.25)\n",
        "for order, length in zip(\n",
        "    sentence_df[\"sentence_order\"],\n",
        "    sentence_df[\"token_count\"],\n",
        "):\n",
        "    line_axis.text(order, length + 0.12, str(length), ha=\"center\", fontsize=9)\n",
        "\n",
        "for axis in (bar_axis, line_axis):\n",
        "    axis.spines[[\"top\", \"right\"]].set_visible(False)\n",
        "\n",
        "preview_figure.suptitle(text_title, fontsize=20, fontweight=\"bold\")\n",
        "preview_figure.tight_layout()\n",
        "plt.show()\n",
        "plt.close(preview_figure)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 5 · 질문, 관찰 두 문장과 한계 작성\n",
        "\n",
        "네 문자열을 모두 자신의 문장으로 바꿉니다. 빈도 관찰에는 최상위 토큰과 실제 횟수를, 흐름 관찰에는 가장 긴 문장의 순서와 토큰 수를 포함합니다. 한계에는 공백 기준 토큰화가 한국어를 충분히 나누지 못하는 이유나 실제 토큰 예를 적습니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 5 · EDIT — 수치 근거가 있는 질문·관찰·한계를 자신의 문장으로 씁니다.\n",
        "mission_step5_execution = get_ipython().execution_count\n",
        "\n",
        "poster_question = (\n",
        "    \"선택한 텍스트의 반복 단어와 문장 흐름은 어떤 특징을 보이는가?\"\n",
        ")\n",
        "frequency_observation = (\n",
        "    \"상위 단어와 반복 횟수를 확인한 뒤 수치를 포함한 관찰 문장으로 바꾸세요.\"\n",
        ")\n",
        "rhythm_observation = (\n",
        "    \"가장 긴 문장 또는 가장 짧은 문장의 순서와 토큰 수를 포함해 바꾸세요.\"\n",
        ")\n",
        "limitation_statement = (\n",
        "    \"공백 기준 토큰화의 한계를 선택한 텍스트의 실제 토큰 예와 함께 바꾸세요.\"\n",
        ")\n",
        "\n",
        "print(\"관찰에 사용할 근거\")\n",
        "print(\"- 최상위 토큰:\", top_word, \"·\", top_count, \"회\")\n",
        "print(\"- 가장 긴 문장:\", longest_sentence_orders, \"·\", maximum_sentence_length, \"토큰\")\n",
        "print(\"- 가장 짧은 문장:\", shortest_sentence_orders, \"·\", minimum_sentence_length, \"토큰\")\n",
        "print(\"- 공백 기준에서 서로 다른 토큰 예를 원문 목록에서 직접 찾으세요.\")\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 6 · CSV와 포스터 저장\n",
        "\n",
        "전체 빈도표를 `word`, `count` 두 열의 CSV로 저장하고, 그래프 두 개와 질문·관찰·한계·출처·분석 규칙을 1600×2200 PNG에 배치합니다. 이 셀은 수정하지 않습니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 6 · 전체 빈도표 CSV와 1600×2200 포스터 저장 — 수정하지 않습니다.\n",
        "mission_step6_execution = get_ipython().execution_count\n",
        "\n",
        "safe_student_id = str(student_id).strip()\n",
        "safe_student_name = str(student_name).strip()\n",
        "notebook_filename = f\"week12_{safe_student_id}_{safe_student_name}.ipynb\"\n",
        "frequency_filename = (\n",
        "    f\"week12_{safe_student_id}_{safe_student_name}_word_frequency.csv\"\n",
        ")\n",
        "poster_filename = (\n",
        "    f\"week12_{safe_student_id}_{safe_student_name}_text_poster.png\"\n",
        ")\n",
        "\n",
        "frequency_df.to_csv(\n",
        "    frequency_filename,\n",
        "    index=False,\n",
        "    encoding=\"utf-8-sig\",\n",
        ")\n",
        "\n",
        "poster_figure = plt.figure(figsize=(8, 11), dpi=200)\n",
        "poster_figure.patch.set_facecolor(\"#f4f0e7\")\n",
        "grid = poster_figure.add_gridspec(\n",
        "    100,\n",
        "    1,\n",
        "    left=0.12,\n",
        "    right=0.92,\n",
        "    top=0.82,\n",
        "    bottom=0.25,\n",
        ")\n",
        "poster_bar_axis = poster_figure.add_subplot(grid[:52, 0])\n",
        "poster_line_axis = poster_figure.add_subplot(grid[64:, 0])\n",
        "\n",
        "poster_bar_axis.barh(\n",
        "    bar_frame[\"word\"],\n",
        "    bar_frame[\"count\"],\n",
        "    color=\"#1e716d\",\n",
        "    height=0.66,\n",
        ")\n",
        "poster_bar_axis.set_xlim(0, maximum_count + 0.8)\n",
        "poster_bar_axis.set_xlabel(\"빈도 · 반복 횟수\")\n",
        "poster_bar_axis.set_title(\"01 · 상위 10개 토큰 빈도\", loc=\"left\", fontweight=\"bold\")\n",
        "for row_index, count in enumerate(bar_frame[\"count\"]):\n",
        "    poster_bar_axis.text(\n",
        "        count + 0.08,\n",
        "        row_index,\n",
        "        str(int(count)),\n",
        "        va=\"center\",\n",
        "        fontweight=\"bold\",\n",
        "    )\n",
        "\n",
        "poster_line_axis.plot(\n",
        "    sentence_df[\"sentence_order\"],\n",
        "    sentence_df[\"token_count\"],\n",
        "    color=\"#a44336\",\n",
        "    marker=\"o\",\n",
        "    linewidth=2.4,\n",
        "    markersize=7,\n",
        ")\n",
        "poster_line_axis.set_xticks(range(1, 13))\n",
        "poster_line_axis.set_xlabel(\"문장 순서 · 시간 아님\")\n",
        "poster_line_axis.set_ylabel(\"공백 기준 토큰 수\")\n",
        "poster_line_axis.set_title(\"02 · 문장 순서에 따른 길이\", loc=\"left\", fontweight=\"bold\")\n",
        "poster_line_axis.grid(axis=\"y\", alpha=0.25)\n",
        "for order, length in zip(\n",
        "    sentence_df[\"sentence_order\"],\n",
        "    sentence_df[\"token_count\"],\n",
        "):\n",
        "    poster_line_axis.text(\n",
        "        order,\n",
        "        length + 0.12,\n",
        "        str(length),\n",
        "        ha=\"center\",\n",
        "        fontsize=8,\n",
        "    )\n",
        "\n",
        "for axis in (poster_bar_axis, poster_line_axis):\n",
        "    axis.set_facecolor(\"#fffdf8\")\n",
        "    axis.spines[[\"top\", \"right\"]].set_visible(False)\n",
        "\n",
        "poster_figure.text(\n",
        "    0.08,\n",
        "    0.945,\n",
        "    \"TEXT PATTERN REPORT\",\n",
        "    fontsize=11,\n",
        "    fontweight=\"bold\",\n",
        "    color=\"#1e716d\",\n",
        ")\n",
        "poster_figure.text(\n",
        "    0.08,\n",
        "    0.905,\n",
        "    text_title,\n",
        "    fontsize=24,\n",
        "    fontweight=\"bold\",\n",
        "    color=\"#202523\",\n",
        ")\n",
        "poster_figure.text(\n",
        "    0.08,\n",
        "    0.855,\n",
        "    fill(poster_question, width=45),\n",
        "    fontsize=12,\n",
        "    color=\"#414846\",\n",
        ")\n",
        "\n",
        "information_text = (\n",
        "    \"OBSERVATION 01 · \" + frequency_observation + \"\\n\\n\"\n",
        "    \"OBSERVATION 02 · \" + rhythm_observation + \"\\n\\n\"\n",
        "    \"LIMIT · \" + limitation_statement + \"\\n\\n\"\n",
        "    \"SOURCE · \" + text_source + \"\\n\"\n",
        "    \"USAGE · \" + text_usage + \"\\n\"\n",
        "    \"RULE · 문장부호를 공백으로 바꾸고 공백 기준으로 토큰화한 뒤 \"\n",
        "    + \", \".join(sorted(excluded_tokens))\n",
        "    + \"을 제외함\"\n",
        ")\n",
        "poster_figure.text(\n",
        "    0.08,\n",
        "    0.205,\n",
        "    fill(information_text, width=68, replace_whitespace=False),\n",
        "    fontsize=8.8,\n",
        "    linespacing=1.35,\n",
        "    va=\"top\",\n",
        "    color=\"#303634\",\n",
        ")\n",
        "poster_figure.text(\n",
        "    0.92,\n",
        "    0.045,\n",
        "    safe_student_id + \" · \" + safe_student_name,\n",
        "    ha=\"right\",\n",
        "    fontsize=8,\n",
        "    color=\"#59615e\",\n",
        ")\n",
        "\n",
        "poster_figure.savefig(\n",
        "    poster_filename,\n",
        "    dpi=200,\n",
        "    facecolor=poster_figure.get_facecolor(),\n",
        ")\n",
        "plt.close(poster_figure)\n",
        "\n",
        "saved_poster_size = Image.open(poster_filename).size\n",
        "print(\"예상 노트북 이름:\", notebook_filename)\n",
        "print(\"전체 빈도표 저장:\", frequency_filename, \"·\", len(frequency_df), \"행\")\n",
        "print(\"포스터 저장:\", poster_filename, \"·\", saved_poster_size)\n",
        "display(Image.open(poster_filename))\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## FINAL CHECK · 새 런타임 전체 실행\n",
        "\n",
        "수정이 끝나면 **런타임 → 세션 다시 시작**, 이어서 **런타임 → 모두 실행**을 한 번 선택합니다. 마지막 셀은 원문 보존, 수량, 그래프 데이터, 자신의 문장, 저장 파일을 함께 검사합니다. 검사 코드는 수정하지 않습니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# FINAL CHECK · 새 런타임에서 STEP 0부터 모두 실행한 뒤 확인합니다.\n",
        "mission_final_execution = get_ipython().execution_count\n",
        "\n",
        "execution_order_ok = (\n",
        "    mission_step0_execution,\n",
        "    mission_step1_execution,\n",
        "    mission_step2_execution,\n",
        "    mission_step3_execution,\n",
        "    mission_step4_execution,\n",
        "    mission_step5_execution,\n",
        "    mission_step6_execution,\n",
        "    mission_final_execution,\n",
        ") == (1, 2, 3, 4, 5, 6, 7, 8)\n",
        "\n",
        "identity_ok = (\n",
        "    safe_student_id not in {\"\", \"학번\", \"20260000\"}\n",
        "    and safe_student_name not in {\"\", \"이름\", \"홍길동\"}\n",
        "    and re.fullmatch(r\"[0-9A-Za-z가-힣_-]+\", safe_student_id) is not None\n",
        "    and re.fullmatch(r\"[0-9A-Za-z가-힣_-]+\", safe_student_name) is not None\n",
        ")\n",
        "selection_ok = text_choice in TEXT_LIBRARY and len(TEXT_LIBRARY) == 3\n",
        "source_ok = (\n",
        "    text_title == selected_record[\"title\"]\n",
        "    and text_source == selected_record[\"source\"]\n",
        "    and text_usage == selected_record[\"usage\"]\n",
        "    and \"교수자 창작 자료\" in text_source\n",
        "    and \"수업 목적\" in text_usage\n",
        ")\n",
        "raw_preserved_ok = (\n",
        "    raw_text == raw_snapshot == selected_record[\"raw_text\"]\n",
        "    and source_snapshot[\"title\"] == text_title\n",
        "    and source_snapshot[\"source\"] == text_source\n",
        "    and source_snapshot[\"usage\"] == text_usage\n",
        "    and source_snapshot[\"excluded_tokens\"]\n",
        "    == tuple(selected_record[\"excluded_tokens\"])\n",
        ")\n",
        "sentence_ok = (\n",
        "    len(sentences) == expected[\"sentence_count\"] == 12\n",
        "    and sentence_lengths == expected[\"sentence_lengths\"]\n",
        "    and sentence_df[\"sentence_order\"].tolist() == list(range(1, 13))\n",
        "    and sentence_df[\"token_count\"].tolist() == sentence_lengths\n",
        ")\n",
        "token_ok = (\n",
        "    len(raw_tokens) == expected[\"raw_token_count\"]\n",
        "    and len(set(raw_tokens)) == expected[\"raw_type_count\"]\n",
        "    and len(tokens) == expected[\"analysis_token_count\"]\n",
        "    and len(set(tokens)) == expected[\"analysis_type_count\"]\n",
        "    and set(excluded_tokens) == set(selected_record[\"excluded_tokens\"])\n",
        "    and not set(tokens).intersection(excluded_tokens)\n",
        ")\n",
        "frequency_ok = (\n",
        "    list(frequency_df.columns) == [\"word\", \"count\"]\n",
        "    and len(frequency_df) == len(set(tokens))\n",
        "    and int(frequency_df[\"count\"].sum()) == len(tokens)\n",
        "    and [tuple(item) for item in top10_df.values.tolist()]\n",
        "    == [tuple(item) for item in expected[\"top10\"]]\n",
        "    and top10_df[\"count\"].is_monotonic_decreasing\n",
        ")\n",
        "chart_ok = (\n",
        "    len(top10_df) == 10\n",
        "    and len(bar_frame) == 10\n",
        "    and len(sentence_df) == 12\n",
        "    and int(bar_frame.iloc[-1][\"count\"]) == top_count\n",
        "    and sentence_df[\"token_count\"].min() == minimum_sentence_length\n",
        "    and sentence_df[\"token_count\"].max() == maximum_sentence_length\n",
        ")\n",
        "\n",
        "default_question = \"선택한 텍스트의 반복 단어와 문장 흐름은 어떤 특징을 보이는가?\"\n",
        "default_frequency = \"상위 단어와 반복 횟수를 확인한 뒤 수치를 포함한 관찰 문장으로 바꾸세요.\"\n",
        "default_rhythm = \"가장 긴 문장 또는 가장 짧은 문장의 순서와 토큰 수를 포함해 바꾸세요.\"\n",
        "default_limit = \"공백 기준 토큰화의 한계를 선택한 텍스트의 실제 토큰 예와 함께 바꾸세요.\"\n",
        "question_ok = (\n",
        "    poster_question != default_question\n",
        "    and len(poster_question.strip()) >= 20\n",
        "    and (\"?\" in poster_question or \"？\" in poster_question)\n",
        ")\n",
        "frequency_observation_ok = (\n",
        "    frequency_observation != default_frequency\n",
        "    and len(frequency_observation.strip()) >= 30\n",
        "    and top_word in frequency_observation\n",
        "    and str(top_count) in frequency_observation\n",
        ")\n",
        "rhythm_observation_ok = (\n",
        "    rhythm_observation != default_rhythm\n",
        "    and len(rhythm_observation.strip()) >= 30\n",
        "    and str(maximum_sentence_length) in rhythm_observation\n",
        "    and any(str(order) in rhythm_observation for order in longest_sentence_orders)\n",
        ")\n",
        "limitation_ok = (\n",
        "    limitation_statement != default_limit\n",
        "    and len(limitation_statement.strip()) >= 30\n",
        "    and (\"공백\" in limitation_statement or \"형태소\" in limitation_statement)\n",
        ")\n",
        "\n",
        "expected_notebook_filename = (\n",
        "    f\"week12_{safe_student_id}_{safe_student_name}.ipynb\"\n",
        ")\n",
        "expected_frequency_filename = (\n",
        "    f\"week12_{safe_student_id}_{safe_student_name}_word_frequency.csv\"\n",
        ")\n",
        "expected_poster_filename = (\n",
        "    f\"week12_{safe_student_id}_{safe_student_name}_text_poster.png\"\n",
        ")\n",
        "filenames_ok = (\n",
        "    notebook_filename == expected_notebook_filename\n",
        "    and frequency_filename == expected_frequency_filename\n",
        "    and poster_filename == expected_poster_filename\n",
        "    and Path(frequency_filename).is_file()\n",
        "    and Path(poster_filename).is_file()\n",
        ")\n",
        "\n",
        "saved_frequency_df = pd.read_csv(frequency_filename)\n",
        "saved_outputs_ok = (\n",
        "    list(saved_frequency_df.columns) == [\"word\", \"count\"]\n",
        "    and saved_frequency_df.equals(frequency_df)\n",
        "    and int(saved_frequency_df[\"count\"].sum()) == len(tokens)\n",
        "    and Image.open(poster_filename).size == (1600, 2200)\n",
        "    and Path(frequency_filename).stat().st_size > 0\n",
        "    and Path(poster_filename).stat().st_size > 0\n",
        ")\n",
        "\n",
        "checks = {\n",
        "    \"새 런타임에서 STEP 0부터 여덟 코드 셀 순서대로 실행\": execution_order_ok,\n",
        "    \"학번·이름과 안전한 파일명\": identity_ok,\n",
        "    \"수업 창작 텍스트 세 편 중 한 편 선택\": selection_ok,\n",
        "    \"제목·출처·이용 조건 기록\": source_ok,\n",
        "    \"raw_text와 지정 제외 목록 보존\": raw_preserved_ok,\n",
        "    \"열두 문장과 문장별 토큰 수\": sentence_ok,\n",
        "    \"제외 전후 전체 토큰 수와 고유 토큰 수\": token_ok,\n",
        "    \"word·count 전체 빈도표와 상위 열 개\": frequency_ok,\n",
        "    \"막대 열 개와 문장 점 열두 개\": chart_ok,\n",
        "    \"물음표가 있는 20자 이상의 분석 질문\": question_ok,\n",
        "    \"최상위 토큰과 횟수가 있는 30자 이상의 빈도 관찰\": frequency_observation_ok,\n",
        "    \"문장 순서와 토큰 수가 있는 30자 이상의 흐름 관찰\": rhythm_observation_ok,\n",
        "    \"공백 기준 토큰화의 30자 이상 한계\": limitation_ok,\n",
        "    \"규칙에 맞는 노트북·CSV·PNG 파일명\": filenames_ok,\n",
        "    \"현재 분석과 일치하는 전체 CSV와 1600×2200 PNG\": saved_outputs_ok,\n",
        "}\n",
        "\n",
        "failed_checks = [label for label, passed in checks.items() if not passed]\n",
        "\n",
        "print(\"=\" * 68)\n",
        "print(\"WEEK 12 FINAL CHECK\")\n",
        "print(\"=\" * 68)\n",
        "for label, passed in checks.items():\n",
        "    print(\"✅\" if passed else \"❌\", label)\n",
        "\n",
        "if failed_checks:\n",
        "    print(\"\\n아직 통과하지 못한 조건:\")\n",
        "    for label in failed_checks:\n",
        "        print(\"-\", label)\n",
        "    raise AssertionError(\n",
        "        \"표시된 조건을 수정한 뒤 런타임을 다시 시작하고 모두 실행하세요.\"\n",
        "    )\n",
        "\n",
        "print(\"\\n제출 노트북:\", notebook_filename)\n",
        "print(\"제출 빈도표:\", frequency_filename)\n",
        "print(\"제출 포스터:\", poster_filename)\n",
        "print(\"🎉 WEEK 12 TEXT PATTERN MISSION COMPLETE\")\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## DOWNLOAD · PASS 뒤 결과 파일 받기\n",
        "\n",
        "FINAL CHECK가 모두 초록색인지 먼저 확인합니다. 아래 셀은 CSV와 PNG를 내려받습니다. 노트북은 Colab 상단의 **파일 → 다운로드 → .ipynb 다운로드**로 별도 저장합니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# DOWNLOAD · FINAL CHECK가 PASS인 뒤 CSV와 PNG를 내려받습니다.\n",
        "if colab_available:\n",
        "    from google.colab import files\n",
        "\n",
        "    files.download(frequency_filename)\n",
        "    files.download(poster_filename)\n",
        "else:\n",
        "    print(\"저장 위치:\", Path(frequency_filename).resolve())\n",
        "    print(\"저장 위치:\", Path(poster_filename).resolve())\n",
        "\n",
        "print(\"노트북은 파일 → 다운로드 → .ipynb 다운로드로 받습니다.\")\n"
      ]
    }
  ],
  "metadata": {
    "colab": {
      "name": "week-12-text-pattern-mission.ipynb",
      "provenance": []
    },
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "name": "python",
      "version": "3"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 5
}
