{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Week 14 · 30% Project Prototype Mission\n",
    "\n",
    "이 노트북은 **입력 하나 → 원본 보존 → 처리 규칙 하나 → 시각화 규칙 하나 → 열리는 PNG 또는 HTML 하나**를 완성하는 개인 실습입니다.\n",
    "\n",
    "- 공통 시작 경로에서 수정하는 셀: **STEP 1**, **STEP 5**\n",
    "- 선택 경로: `data`, `text`, `sound`, `image`\n",
    "- 막히면 `input_mode = \"provided\"`를 유지하고 수업 제공 가상 자료로 먼저 완성합니다.\n",
    "- 자신의 파일을 쓰려면 프로젝트 면담에서 승인받고, 아래 규격에 맞춘 뒤 `input_mode = \"own\"`으로 바꿉니다.\n",
    "- 9-13주차의 개인 코드를 재사용하려면 2교시 신청자 면담에서 처리·매핑 규칙을 승인받고 `reuse_status`를 `approved` 또는 `scoped`로 기록한 뒤 STEP 3·4의 **APPROVED REUSE ZONE**만 교체합니다. 제공 입력을 쓰면서 승인 코드를 재사용할 수도 있습니다. 이 구역은 Matplotlib Figure를 만드는 계약이며 Folium·별도 웹페이지 전체를 넣지 않습니다.\n",
    "- 마지막에는 새 런타임에서 **모두 실행**하고 `WEEK 14 PROJECT PROTOTYPE COMPLETE`를 확인합니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 0 · 준비: 라이브러리와 실행 순서\n",
    "from pathlib import Path\n",
    "from collections import Counter\n",
    "import base64\n",
    "import hashlib\n",
    "import html as html_module\n",
    "import importlib.util\n",
    "from importlib.metadata import PackageNotFoundError, version as package_version\n",
    "import io\n",
    "import re\n",
    "import subprocess\n",
    "import sys\n",
    "import textwrap\n",
    "import wave\n",
    "\n",
    "required_packages = {\n",
    "    \"numpy\": (\"numpy\", \"2.5.2\"),\n",
    "    \"pandas\": (\"pandas\", \"2.3.3\"),\n",
    "    \"matplotlib\": (\"matplotlib\", \"3.10.8\"),\n",
    "    \"PIL\": (\"Pillow\", \"12.3.0\"),\n",
    "}\n",
    "packages_to_install = []\n",
    "for module_name, (package_name, required_version) in required_packages.items():\n",
    "    try:\n",
    "        installed_version = package_version(package_name)\n",
    "    except PackageNotFoundError:\n",
    "        installed_version = None\n",
    "    if importlib.util.find_spec(module_name) is None or installed_version != required_version:\n",
    "        packages_to_install.append(f\"{package_name}=={required_version}\")\n",
    "if packages_to_install:\n",
    "    subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", *packages_to_install])\n",
    "\n",
    "import matplotlib.pyplot as plt\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "from PIL import Image\n",
    "from matplotlib import font_manager\n",
    "from matplotlib.colors import to_rgba\n",
    "\n",
    "def font_has_korean_glyphs(font_path):\n",
    "    try:\n",
    "        font = font_manager.get_font(font_path)\n",
    "    except (OSError, RuntimeError):\n",
    "        return False\n",
    "    return all(font.get_char_index(ord(character)) for character in \"한글이름출처\")\n",
    "\n",
    "def find_korean_font():\n",
    "    preferred_tokens = (\n",
    "        \"nanumgothic\",\n",
    "        \"notosanscjk\",\n",
    "        \"notosanskr\",\n",
    "        \"applesdgothic\",\n",
    "        \"malgun\",\n",
    "        \"pretendard\",\n",
    "    )\n",
    "    supporting_fonts = [\n",
    "        font_path\n",
    "        for font_path in sorted(font_manager.findSystemFonts())\n",
    "        if font_has_korean_glyphs(font_path)\n",
    "    ]\n",
    "    for font_path in supporting_fonts:\n",
    "        compact_name = Path(font_path).name.lower().replace(\" \", \"\")\n",
    "        if any(token in compact_name for token in preferred_tokens):\n",
    "            return font_path\n",
    "    return supporting_fonts[0] if supporting_fonts else None\n",
    "\n",
    "korean_font_path = find_korean_font()\n",
    "if korean_font_path is None and Path(\"/etc/debian_version\").exists():\n",
    "    subprocess.check_call([\"apt-get\", \"update\", \"-qq\"])\n",
    "    subprocess.check_call([\"apt-get\", \"install\", \"-y\", \"-qq\", \"fonts-nanum\"])\n",
    "    korean_font_path = find_korean_font()\n",
    "if korean_font_path is None:\n",
    "    raise RuntimeError(\"한글 글꼴을 찾지 못했습니다. Colab 새 런타임에서 STEP 0부터 다시 실행하세요.\")\n",
    "font_manager.fontManager.addfont(korean_font_path)\n",
    "korean_font_name = font_manager.FontProperties(fname=korean_font_path).get_name()\n",
    "\n",
    "_run_order = [0]\n",
    "plt.rcParams.update({\n",
    "    \"font.family\": korean_font_name,\n",
    "    \"figure.facecolor\": \"#f3efe5\",\n",
    "    \"axes.facecolor\": \"#fffdf8\",\n",
    "    \"axes.edgecolor\": \"#c7c8be\",\n",
    "    \"text.color\": \"#202523\",\n",
    "    \"axes.labelcolor\": \"#202523\",\n",
    "    \"xtick.color\": \"#59615e\",\n",
    "    \"ytick.color\": \"#59615e\",\n",
    "    \"axes.unicode_minus\": False,\n",
    "})\n",
    "print(f\"STEP 0 PASS · 준비 완료 · 한글 글꼴 {korean_font_name}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 1 · 프로젝트 카드 입력\n",
    "\n",
    "따옴표 안의 `EDIT:` 문장을 자신의 프로젝트 정보로 바꿉니다. 처음에는 `project_track`만 선택하고 `input_mode = \"provided\"`, `reuse_status = \"guided\"`를 유지하는 것이 가장 안전합니다. 입력 판정과 코드 재사용 판정은 서로 다릅니다. 제공 입력은 `approval_status = \"provided\"`, 자기 입력은 승인 뒤 `approved` 또는 `scoped`를 기록합니다. 공통 코드를 유지하면 `reuse_status = \"guided\"`, STEP 3·4를 바꾸면 승인 뒤 `approved` 또는 `scoped`를 기록합니다. 최종 30-45초 증거 확인을 받은 뒤에만 `teacher_gate`를 `\"confirmed\"`로 바꿉니다.\n",
    "\n",
    "자신의 입력을 사용할 때의 규격은 다음과 같습니다.\n",
    "\n",
    "- 데이터: `category`, `value` 열이 있는 UTF-8 CSV (`value`는 0 이상의 수)\n",
    "- 텍스트: UTF-8 TXT\n",
    "- 소리: 모노 PCM WAV\n",
    "- 규칙 기반 이미지: `x`, `y`, `size`, `color` 열이 있는 UTF-8 CSV (`x`, `y`는 0-1, `color`는 `#RRGGBB`)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 1 · EDIT: 프로젝트 카드와 입력 선택\n",
    "student_id = \"학번\"\n",
    "student_name = \"이름\"\n",
    "\n",
    "project_track = \"data\"  # data, text, sound, image 가운데 하나\n",
    "input_mode = \"provided\"  # provided 또는 own\n",
    "own_source_filename = \"\"  # own일 때 week14_학번_이름_source.확장자 입력\n",
    "output_format = \"png\"  # png 또는 html\n",
    "\n",
    "approval_status = \"EDIT: approved / scoped / provided 가운데 1차 면담 판정\"\n",
    "approval_note = \"EDIT: 승인된 핵심 범위 또는 줄인 기능을 한 문장으로 기록\"\n",
    "reuse_status = \"guided\"  # guided, approved, scoped 가운데 하나\n",
    "reuse_note = \"EDIT: 공통 코드를 유지하거나 승인받아 교체한 STEP 3·4 범위를 기록\"\n",
    "teacher_gate = \"pending\"  # 최종 교수 확인 뒤에만 confirmed로 변경\n",
    "\n",
    "project_question = \"EDIT: 이 입력에서 어떤 차이나 변화가 보이는가?\"\n",
    "intended_audience = \"EDIT: 이 결과를 가장 먼저 볼 사람\"\n",
    "project_source = \"EDIT: 자료의 제목, 작성자 또는 제공자, 주소\"\n",
    "usage_rights = \"EDIT: 직접 제작, 공개 라이선스, 허가 등 이용 근거\"\n",
    "reference_date = \"EDIT: 자료를 수집하거나 제작한 날짜 또는 기간\"\n",
    "privacy_check = \"EDIT: 개인정보를 제외하거나 동의를 확인한 방법\"\n",
    "observation_unit = \"EDIT: 입력 한 행, 토큰, 프레임 또는 매개변수 한 건의 의미\"\n",
    "value_unit = \"EDIT: 합계, 횟수, RMS, px 등 화면 값의 단위\"\n",
    "scope_decision = \"EDIT: 이번 주에는 구현하지 않기로 한 기능 한 가지\"\n",
    "processing_rule = \"EDIT: 입력에서 무엇을 계산하거나 변환하는가\"\n",
    "visual_rule = \"EDIT: 어떤 값을 위치, 길이, 색상 또는 시간에 연결하는가\"\n",
    "\n",
    "def safe_filename_part(value):\n",
    "    return re.sub(r\"[^0-9A-Za-z가-힣_-]+\", \"-\", str(value).strip()).strip(\"-\")\n",
    "\n",
    "safe_student_id = safe_filename_part(student_id)\n",
    "safe_student_name = safe_filename_part(student_name)\n",
    "own_source_suffix = {\n",
    "    \"data\": \".csv\",\n",
    "    \"text\": \".txt\",\n",
    "    \"sound\": \".wav\",\n",
    "    \"image\": \".csv\",\n",
    "}.get(project_track, \"\")\n",
    "expected_own_source_filename = (\n",
    "    f\"week14_{safe_student_id}_{safe_student_name}_source{own_source_suffix}\"\n",
    ")\n",
    "\n",
    "_run_order.append(1)\n",
    "print(\"STEP 1 READY · 프로젝트 카드 입력 완료\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 2 · 입력 불러오기와 원본 보존\n",
    "\n",
    "네 경로는 입력 모양이 다르지만, **처리하기 전 원본을 별도 변수에 보존하고 지문(digest)을 기록한다**는 원칙은 같습니다. 제공 자료는 수업을 위해 만든 가상 자료입니다. 데이터와 이미지 CSV는 결측값·빈 범주·무한대까지 먼저 검사하며, 데이터 막대 경로의 `value`는 0 이상이어야 합니다. `own` 파일은 업로드 전에 `week14_학번_이름_source.확장자`로 이름을 바꾸고 STEP 1에도 같은 이름을 기록합니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 2 · 입력 불러오기와 원본 보존\n",
    "PROVIDED_DATA_CSV = 'category,value\\nArchive,34\\nStudio,21\\nScreening,18\\nArchive,27\\nStudio,19\\nScreening,14\\nArchive,23\\nStudio,23\\nScreening,17\\n'\n",
    "PROVIDED_TEXT = 'record place sound memory walk record image place record sound archive memory record place sound record image walk record place archive memory record place sound walk record place'\n",
    "PROVIDED_SAMPLE_RATE = 8000\n",
    "PROVIDED_SOUND_SECONDS = 4\n",
    "PROVIDED_SOUND_PARAMETERS = {'base_amplitude': 0.2, 'modulation_amplitude': 0.13, 'modulation_hz': 0.42, 'carrier_hz': 220.0}\n",
    "def synthesize_provided_sound(\n",
    "    time_values: np.ndarray,\n",
    "    parameters: dict[str, float],\n",
    ") -> np.ndarray:\n",
    "    \"\"\"Synthesize the shared teaching signal from explicit parameters.\"\"\"\n",
    "\n",
    "    return (\n",
    "        (\n",
    "            parameters[\"base_amplitude\"]\n",
    "            + parameters[\"modulation_amplitude\"]\n",
    "            * np.sin(2 * np.pi * parameters[\"modulation_hz\"] * time_values)\n",
    "        )\n",
    "        * np.sin(2 * np.pi * parameters[\"carrier_hz\"] * time_values)\n",
    "    ).astype(np.float64)\n",
    "PROVIDED_TIME = (\n",
    "    np.arange(PROVIDED_SAMPLE_RATE * PROVIDED_SOUND_SECONDS, dtype=np.float64)\n",
    "    / PROVIDED_SAMPLE_RATE\n",
    ")\n",
    "PROVIDED_SOUND = synthesize_provided_sound(\n",
    "    PROVIDED_TIME,\n",
    "    PROVIDED_SOUND_PARAMETERS,\n",
    ")\n",
    "PROVIDED_IMAGE_CSV = 'x,y,size,color\\n0.18,0.24,58,#116e68\\n0.38,0.68,92,#365f91\\n0.58,0.40,74,#a23d34\\n0.76,0.70,48,#6f4f00\\n0.84,0.28,66,#116e68\\n'\n",
    "EXPECTED_PROVIDED_DIGESTS = {'data': '8e47d6983de18dbdcd6bf4616cf031beae0cd01c2bc60ca67d20aef0a1579313', 'text': '540bc138a88f863cdf23aaf9fae55d700818108876023482944b15d0bec51380', 'sound': '2cb3108cae80e0caab442d18cde65d915c46fd66dbf63cad843991f64ab106c1', 'image': '0916aa8ecb377b50881490c2db2c4ef3ee7c9b97c90d9f7782c447f874e9dd13'}\n",
    "\n",
    "def sha256_bytes(payload):\n",
    "    return hashlib.sha256(payload).hexdigest()\n",
    "\n",
    "def read_checked_table(payload, required_columns, *, label):\n",
    "    table = pd.read_csv(pd.io.common.BytesIO(payload))\n",
    "    missing_columns = set(required_columns) - set(table.columns)\n",
    "    assert not missing_columns, f\"{label}에 필요한 열이 없습니다: {sorted(missing_columns)}\"\n",
    "    return table[list(required_columns)].copy(deep=True)\n",
    "\n",
    "assert project_track in {\"data\", \"text\", \"sound\", \"image\"}, (\n",
    "    \"project_track은 data, text, sound, image 가운데 하나여야 합니다.\"\n",
    ")\n",
    "assert input_mode in {\"provided\", \"own\"}, \"input_mode는 provided 또는 own이어야 합니다.\"\n",
    "assert output_format in {\"png\", \"html\"}, \"output_format은 png 또는 html이어야 합니다.\"\n",
    "\n",
    "source_path = None\n",
    "source_bytes_before = None\n",
    "if input_mode == \"own\":\n",
    "    assert Path(own_source_filename).name == own_source_filename, (\n",
    "        \"own_source_filename에는 폴더가 아닌 업로드한 파일명만 입력하세요.\"\n",
    "    )\n",
    "    assert own_source_filename == expected_own_source_filename, (\n",
    "        f\"자신의 입력 파일명을 {expected_own_source_filename}(으)로 바꾸고 STEP 1부터 다시 실행하세요.\"\n",
    "    )\n",
    "if project_track == \"data\":\n",
    "    if input_mode == \"provided\":\n",
    "        raw_payload = PROVIDED_DATA_CSV.encode(\"utf-8\")\n",
    "    else:\n",
    "        source_path = Path(own_source_filename)\n",
    "        assert source_path.is_file(), \"지정한 CSV 파일을 찾을 수 없습니다.\"\n",
    "        source_bytes_before = source_path.read_bytes()\n",
    "        raw_payload = source_bytes_before\n",
    "    candidate_data = read_checked_table(\n",
    "        raw_payload,\n",
    "        (\"category\", \"value\"),\n",
    "        label=\"데이터 CSV\",\n",
    "    )\n",
    "    assert candidate_data[\"category\"].notna().all(), \"category 열에 결측값이 있습니다.\"\n",
    "    category_values = candidate_data[\"category\"].astype(str).str.strip()\n",
    "    assert category_values.ne(\"\").all(), \"category 열에 빈 문자열이 있습니다.\"\n",
    "    numeric_values = pd.to_numeric(candidate_data[\"value\"], errors=\"coerce\")\n",
    "    assert numeric_values.notna().all(), \"value 열에 결측값 또는 숫자가 아닌 값이 있습니다.\"\n",
    "    assert np.isfinite(numeric_values.to_numpy(dtype=float)).all(), \"value 열에는 유한한 숫자만 사용할 수 있습니다.\"\n",
    "    assert numeric_values.ge(0).all(), \"value 열에는 0 이상의 값만 사용할 수 있습니다.\"\n",
    "    raw_data = pd.DataFrame({\"category\": category_values, \"value\": numeric_values})\n",
    "    raw_snapshot = raw_data.copy(deep=True)\n",
    "elif project_track == \"text\":\n",
    "    if input_mode == \"provided\":\n",
    "        raw_text = PROVIDED_TEXT\n",
    "        raw_payload = raw_text.encode(\"utf-8\")\n",
    "    else:\n",
    "        source_path = Path(own_source_filename)\n",
    "        assert source_path.is_file(), \"지정한 TXT 파일을 찾을 수 없습니다.\"\n",
    "        source_bytes_before = source_path.read_bytes()\n",
    "        raw_payload = source_bytes_before\n",
    "        raw_text = source_bytes_before.decode(\"utf-8\")\n",
    "    assert raw_text.strip(), \"텍스트 입력이 비어 있습니다.\"\n",
    "    raw_snapshot = raw_text\n",
    "elif project_track == \"sound\":\n",
    "    if input_mode == \"provided\":\n",
    "        sample_rate = PROVIDED_SAMPLE_RATE\n",
    "        raw_signal = PROVIDED_SOUND.copy()\n",
    "        raw_payload = raw_signal.tobytes()\n",
    "    else:\n",
    "        source_path = Path(own_source_filename)\n",
    "        assert source_path.is_file(), \"지정한 WAV 파일을 찾을 수 없습니다.\"\n",
    "        source_bytes_before = source_path.read_bytes()\n",
    "        with wave.open(str(source_path), \"rb\") as sound_file:\n",
    "            assert sound_file.getnchannels() == 1, \"WAV는 모노 파일이어야 합니다.\"\n",
    "            assert sound_file.getsampwidth() == 2, \"WAV는 16-bit PCM이어야 합니다.\"\n",
    "            sample_rate = sound_file.getframerate()\n",
    "            assert sample_rate > 0, \"WAV 샘플링 레이트가 올바르지 않습니다.\"\n",
    "            frames = sound_file.readframes(sound_file.getnframes())\n",
    "        raw_signal = np.frombuffer(frames, dtype=\"<i2\").astype(np.float64) / 32768.0\n",
    "        raw_payload = source_bytes_before\n",
    "    assert len(raw_signal) > 0, \"소리 입력이 비어 있습니다.\"\n",
    "    assert np.isfinite(raw_signal).all(), \"소리 입력에 유한하지 않은 값이 있습니다.\"\n",
    "    raw_snapshot = raw_signal.copy()\n",
    "else:\n",
    "    if input_mode == \"provided\":\n",
    "        raw_payload = PROVIDED_IMAGE_CSV.encode(\"utf-8\")\n",
    "    else:\n",
    "        source_path = Path(own_source_filename)\n",
    "        assert source_path.is_file(), \"지정한 이미지 매개변수 CSV를 찾을 수 없습니다.\"\n",
    "        source_bytes_before = source_path.read_bytes()\n",
    "        raw_payload = source_bytes_before\n",
    "    candidate_image = read_checked_table(\n",
    "        raw_payload,\n",
    "        (\"x\", \"y\", \"size\", \"color\"),\n",
    "        label=\"이미지 매개변수 CSV\",\n",
    "    )\n",
    "    assert candidate_image.notna().all().all(), \"이미지 매개변수에 결측값이 있습니다.\"\n",
    "    for numeric_column in (\"x\", \"y\", \"size\"):\n",
    "        candidate_image[numeric_column] = pd.to_numeric(\n",
    "            candidate_image[numeric_column],\n",
    "            errors=\"coerce\",\n",
    "        )\n",
    "        assert candidate_image[numeric_column].notna().all(), f\"{numeric_column} 열에는 숫자만 입력하세요.\"\n",
    "        assert np.isfinite(candidate_image[numeric_column]).all(), f\"{numeric_column} 열에는 유한한 숫자만 사용하세요.\"\n",
    "    assert candidate_image[\"x\"].between(0, 1).all(), \"x 값은 0과 1 사이여야 합니다.\"\n",
    "    assert candidate_image[\"y\"].between(0, 1).all(), \"y 값은 0과 1 사이여야 합니다.\"\n",
    "    assert candidate_image[\"size\"].between(10, 180).all(), \"size 값은 10과 180 사이여야 합니다.\"\n",
    "    color_values = candidate_image[\"color\"].astype(str).str.strip()\n",
    "    assert color_values.str.fullmatch(r\"#[0-9A-Fa-f]{6}\").all(), \"color는 #RRGGBB 형식이어야 합니다.\"\n",
    "    candidate_image[\"color\"] = color_values\n",
    "    raw_image_params = candidate_image\n",
    "    raw_snapshot = raw_image_params.copy(deep=True)\n",
    "\n",
    "source_digest_before = sha256_bytes(raw_payload)\n",
    "if input_mode == \"provided\":\n",
    "    assert source_digest_before == EXPECTED_PROVIDED_DIGESTS[project_track], \"수업 제공 원본이 변경되었습니다.\"\n",
    "_run_order.append(2)\n",
    "print(f\"STEP 2 PASS · {project_track} 원본 보존 · digest {source_digest_before[:12]}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 3 · 처리 규칙 하나 실행\n",
    "\n",
    "- 데이터 경로는 같은 범주의 값을 더합니다.\n",
    "- 텍스트 경로는 단어를 소문자로 정리한 뒤 빈도를 셉니다.\n",
    "- 소리 경로는 짧은 구간마다 RMS 에너지를 계산합니다.\n",
    "- 규칙 기반 이미지 경로는 위치·크기·색 매개변수를 유효한 도형 값으로 준비합니다.\n",
    "\n",
    "출력되는 숫자는 장식이 아니라 시각화가 어떤 값을 사용했는지 보여 주는 증거입니다. 2교시 신청자 면담에서 다른 처리 규칙을 승인받았다면 `reuse_status`와 `reuse_note`를 기록하고 아래 `APPROVED REUSE ZONE`의 해당 분기만 이전 주차 코드로 교체하되, 마지막에 비어 있지 않은 1차원 수치 배열 `processed_values`를 남깁니다. Folium·별도 HTML 저장은 이번 구역에 넣지 않고 15주차 행동으로 기록합니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 3 · 처리 규칙 하나\n",
    "# APPROVED REUSE ZONE · 승인받은 경우 해당 분기 내부만 이전 주차 코드로 교체\n",
    "if project_track == \"data\":\n",
    "    processed = (\n",
    "        raw_data.groupby(\"category\", as_index=False)[\"value\"]\n",
    "        .sum()\n",
    "        .sort_values(\"value\", ascending=True)\n",
    "        .reset_index(drop=True)\n",
    "    )\n",
    "    processed_labels = processed[\"category\"].tolist()\n",
    "    processed_values = processed[\"value\"].to_numpy(dtype=float)\n",
    "    input_count = len(raw_data)\n",
    "    print(f\"입력 {input_count}행 → 범주 {len(processed)}개\")\n",
    "elif project_track == \"text\":\n",
    "    tokens = re.findall(r\"[0-9A-Za-z가-힣]+\", raw_text.lower())\n",
    "    token_counts = Counter(tokens)\n",
    "    ranked_tokens = sorted(token_counts.items(), key=lambda item: (-item[1], item[0]))[:8]\n",
    "    processed_labels = [item[0] for item in ranked_tokens][::-1]\n",
    "    processed_values = np.array([item[1] for item in ranked_tokens][::-1], dtype=float)\n",
    "    input_count = len(tokens)\n",
    "    print(f\"입력 {input_count}토큰 → 상위 단어 {len(ranked_tokens)}개\")\n",
    "elif project_track == \"sound\":\n",
    "    frame_size = max(64, int(sample_rate * 0.05))\n",
    "    hop_size = max(32, int(sample_rate * 0.025))\n",
    "    frame_starts = np.arange(0, len(raw_signal) - frame_size + 1, hop_size)\n",
    "    processed_values = np.array([\n",
    "        np.sqrt(np.mean(raw_signal[start:start + frame_size] ** 2))\n",
    "        for start in frame_starts\n",
    "    ])\n",
    "    processed_times = (frame_starts + frame_size / 2) / sample_rate\n",
    "    assert len(processed_values) > 0, \"소리가 너무 짧아 구간 에너지를 계산할 수 없습니다.\"\n",
    "    input_count = len(raw_signal)\n",
    "    print(f\"입력 {input_count}샘플 → 에너지 구간 {len(processed_values)}개\")\n",
    "else:\n",
    "    processed_x = raw_image_params[\"x\"].to_numpy(dtype=float)\n",
    "    processed_y = raw_image_params[\"y\"].to_numpy(dtype=float)\n",
    "    processed_values = raw_image_params[\"size\"].to_numpy(dtype=float)\n",
    "    processed_colors = raw_image_params[\"color\"].tolist()\n",
    "    input_count = len(raw_image_params)\n",
    "    print(f\"입력 {input_count}개 매개변수 → 도형 {len(processed_values)}개\")\n",
    "\n",
    "processed_values = np.asarray(processed_values, dtype=float)\n",
    "assert processed_values.ndim == 1 and len(processed_values) > 0, \"처리 결과는 비어 있지 않은 1차원 값이어야 합니다.\"\n",
    "assert np.isfinite(processed_values).all(), \"처리 결과에 유한하지 않은 값이 있습니다.\"\n",
    "processing_evidence_values = processed_values.copy()\n",
    "_run_order.append(3)\n",
    "print(\"STEP 3 PASS · 처리 결과 확인\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 4 · 처리 결과를 한 가지 시각 규칙에 연결\n",
    "\n",
    "네 경로 모두 같은 크기의 Figure를 사용합니다. 막대 경로는 처리된 값을 **길이**에, 소리 경로는 시간과 에너지를 **가로·세로 위치**에, 규칙 이미지 경로는 값을 **위치·크기·색**에 연결합니다. 공통 코드를 사용하는 학생은 아래 코드 셀을 수정하지 않고 실행합니다.\n",
    "\n",
    "<details>\n",
    "<summary><strong>승인 재사용 학생만 · 어댑터 증거 계약</strong></summary>\n",
    "<p>승인받은 개인 표현은 다음 공통 어댑터 계약을 모두 지킵니다.</p>\n",
    "<ul>\n",
    "  <li>모든 경로: <code>mapping_source_values</code>, <code>mapping_visual_values</code></li>\n",
    "  <li>데이터·텍스트: <code>mapping_source_labels</code>, <code>mapping_visual_labels</code></li>\n",
    "  <li>소리·이미지: <code>mapping_source_positions</code>, <code>mapping_visual_positions</code></li>\n",
    "  <li>이미지: <code>mapping_source_colors</code>, <code>mapping_visual_colors</code></li>\n",
    "</ul>\n",
    "<p>사용하지 않는 쌍은 <code>None</code>으로 둡니다. FINAL CHECK는 이 공개 변수만 읽으며 <code>visual_element_count</code>는 화면 값의 길이에서 자동 계산합니다.</p>\n",
    "</details>"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 4 · 핵심 시각화 한 화면\n",
    "# APPROVED REUSE ZONE · 승인받은 경우 해당 분기 내부와 증거 추출만 교체\n",
    "mapping_source_labels = None\n",
    "mapping_visual_labels = None\n",
    "mapping_source_positions = None\n",
    "mapping_visual_positions = None\n",
    "mapping_source_colors = None\n",
    "mapping_visual_colors = None\n",
    "\n",
    "figure, axis = plt.subplots(figsize=(8, 5), dpi=200)\n",
    "figure.subplots_adjust(left=0.18, right=0.95, top=0.69, bottom=0.30)\n",
    "figure.suptitle(\"30% PROJECT PROTOTYPE\", x=0.08, y=0.96, ha=\"left\", fontsize=20, fontweight=\"bold\")\n",
    "figure.text(0.08, 0.895, f\"Track: {project_track.upper()} · one input / one rule / one output\", fontsize=10, color=\"#59615e\")\n",
    "figure.text(0.08, 0.84, textwrap.fill(project_question, width=48), fontsize=11.5, fontweight=\"bold\", va=\"top\")\n",
    "\n",
    "if project_track in {\"data\", \"text\"}:\n",
    "    visual_artists = axis.barh(processed_labels, processed_values, color=\"#116e68\", height=0.62)\n",
    "    axis.set_xlim(left=0)\n",
    "    axis.set_xlabel(value_unit)\n",
    "    for bar, value in zip(visual_artists, processed_values):\n",
    "        axis.text(value, bar.get_y() + bar.get_height() / 2, f\" {value:g}\", va=\"center\", fontsize=9)\n",
    "    mapping_visual_values = np.array([bar.get_width() for bar in visual_artists], dtype=float)\n",
    "    mapping_source_labels = [str(label) for label in processed_labels]\n",
    "    mapping_visual_labels = [tick.get_text() for tick in axis.get_yticklabels()]\n",
    "elif project_track == \"sound\":\n",
    "    (visual_artist,) = axis.plot(processed_times, processed_values, color=\"#116e68\", linewidth=2.2)\n",
    "    axis.fill_between(processed_times, processed_values, color=\"#dcebe6\")\n",
    "    axis.set_xlim(processed_times.min(), processed_times.max())\n",
    "    axis.set_ylim(bottom=0)\n",
    "    axis.set_xlabel(\"Time (seconds)\")\n",
    "    axis.set_ylabel(value_unit)\n",
    "    mapping_visual_values = np.asarray(visual_artist.get_ydata(), dtype=float)\n",
    "    mapping_source_positions = np.asarray(processed_times, dtype=float)\n",
    "    mapping_visual_positions = np.asarray(visual_artist.get_xdata(), dtype=float)\n",
    "else:\n",
    "    visual_artist = axis.scatter(\n",
    "        processed_x,\n",
    "        processed_y,\n",
    "        s=processed_values ** 2 / 3,\n",
    "        c=processed_colors,\n",
    "        edgecolors=\"#202523\",\n",
    "        linewidths=0.8,\n",
    "    )\n",
    "    axis.set(xlim=(0, 1), ylim=(0, 1), xlabel=\"x position\", ylabel=\"y position\")\n",
    "    axis.set_aspect(\"equal\", adjustable=\"box\")\n",
    "    mapping_visual_values = np.sqrt(np.asarray(visual_artist.get_sizes(), dtype=float) * 3)\n",
    "    mapping_source_positions = np.column_stack((processed_x, processed_y))\n",
    "    mapping_visual_positions = np.asarray(visual_artist.get_offsets(), dtype=float)\n",
    "    mapping_source_colors = np.asarray([to_rgba(color) for color in processed_colors], dtype=float)\n",
    "    mapping_visual_colors = np.asarray(visual_artist.get_facecolors(), dtype=float)\n",
    "\n",
    "axis.set_title(\"Processed evidence\", loc=\"left\", fontsize=13, fontweight=\"bold\")\n",
    "axis.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "axis.grid(axis=\"x\" if project_track in {\"data\", \"text\"} else \"both\", color=\"#c7c8be\", linewidth=0.8, alpha=0.55)\n",
    "axis.set_axisbelow(True)\n",
    "mapping_source_values = np.asarray(processed_values, dtype=float)\n",
    "mapping_visual_values = np.asarray(mapping_visual_values, dtype=float)\n",
    "_run_order.append(4)\n",
    "print(\"STEP 4 PASS · 처리값을 시각 요소에 연결\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 5 · 관찰, 한계, 다음 행동 작성\n",
    "\n",
    "관찰 근거에는 화면에서 가리킬 수 있는 수치·단어·시간·도형 위치를 짧게 적고, 같은 근거를 관찰 문장에 포함합니다. 한계는 현재 입력만으로 단정할 수 없는 내용을 씁니다. 다음 행동은 “더 예쁘게 만들기” 대신 어떤 요소를 어떻게 수정할지 동사로 시작합니다. 교수는 네 문장이 실제 결과와 맞는지 확인한 뒤 피드백을 남깁니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 5 · EDIT: 관찰, 한계, 15주차 수정 두 가지\n",
    "observation_evidence = \"EDIT: 화면에서 직접 가리킬 수 있는 수치, 단어, 시간 또는 위치\"\n",
    "main_observation = (\n",
    "    \"EDIT: 위 관찰 근거를 그대로 포함한 직접 관찰 한 문장\"\n",
    ")\n",
    "limitation_statement = (\n",
    "    \"EDIT: 현재 자료만으로 단정할 수 없는 내용 한 문장\"\n",
    ")\n",
    "next_step_1 = \"EDIT: 15주차에 먼저 실행할 구체적인 수정\"\n",
    "next_step_2 = \"EDIT: 15주차에 이어서 실행할 구체적인 수정\"\n",
    "teacher_feedback = \"EDIT: 교수가 확인한 관찰·한계·수정 행동에 대한 짧은 기록\"\n",
    "\n",
    "_run_order.append(5)\n",
    "print(\"STEP 5 READY · 해석과 다음 행동 입력 완료\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 6 · PNG 또는 HTML 저장\n",
    "\n",
    "`output_format`에 따라 1600 × 1000 PNG 또는 같은 PNG와 질문·관찰·한계·출처를 내부에 포함한 반응형 HTML을 저장합니다. 이 HTML은 정적 증거 문서이며 Folium·별도 인터랙티브 웹페이지를 합치는 옵션이 아닙니다. 저장한 뒤 노트북 밖에서 직접 열어 빈 화면, 글자 잘림, 지나치게 작은 글자가 없는지 확인합니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 6 · 1600 × 1000 PNG 또는 독립 HTML 저장\n",
    "output_filename = f\"week14_{safe_student_id}_{safe_student_name}_preview.{output_format}\"\n",
    "output_path = Path(output_filename)\n",
    "\n",
    "figure.text(0.08, 0.20, textwrap.fill(main_observation, width=58), fontsize=8.7, color=\"#202523\", va=\"top\")\n",
    "figure.text(0.08, 0.12, textwrap.fill(f\"LIMIT · {limitation_statement}\", width=64), fontsize=7.7, color=\"#59615e\", va=\"top\")\n",
    "source_display = textwrap.shorten(project_source, width=78, placeholder=\"…\")\n",
    "figure.text(0.08, 0.025, f\"SOURCE · {source_display} · {reference_date}\", fontsize=7.2, color=\"#59615e\")\n",
    "\n",
    "preview_buffer = io.BytesIO()\n",
    "figure.savefig(preview_buffer, format=\"png\", dpi=200, facecolor=\"#f3efe5\")\n",
    "preview_png_bytes = preview_buffer.getvalue()\n",
    "if output_format == \"png\":\n",
    "    output_path.write_bytes(preview_png_bytes)\n",
    "else:\n",
    "    encoded_preview = base64.b64encode(preview_png_bytes).decode(\"ascii\")\n",
    "    html_title = html_module.escape(project_question)\n",
    "    html_source = html_module.escape(project_source)\n",
    "    html_rights = html_module.escape(usage_rights)\n",
    "    html_limit = html_module.escape(limitation_statement)\n",
    "    html_observation = html_module.escape(main_observation)\n",
    "    html_alt = html_module.escape(\n",
    "        f\"{project_track} 경로의 핵심 시각화. {main_observation}\",\n",
    "        quote=True,\n",
    "    )\n",
    "    html_output = f'''<!doctype html>\n",
    "<html lang=\"ko\">\n",
    "<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>{html_title}</title><style>body{{margin:0;background:#f3efe5;color:#202523;font-family:system-ui,sans-serif}}main{{width:min(92vw,68rem);margin:0 auto;padding:2rem 0 4rem}}img{{display:block;max-width:100%;height:auto}}figcaption{{line-height:1.6}}figure{{margin:1.5rem 0}}</style></head>\n",
    "<body><main><h1>{html_title}</h1><figure><img src=\"data:image/png;base64,{encoded_preview}\" width=\"1600\" height=\"1000\" alt=\"{html_alt}\"><figcaption><p><strong>관찰:</strong> {html_observation}</p><p><strong>한계:</strong> {html_limit}</p></figcaption></figure><p><strong>출처:</strong> {html_source}</p><p><strong>이용 근거:</strong> {html_rights}</p></main></body>\n",
    "</html>'''\n",
    "    output_path.write_text(html_output, encoding=\"utf-8\")\n",
    "_run_order.append(6)\n",
    "print(f\"STEP 6 PASS · {output_filename} 저장\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 7 · FINAL CHECK\n",
    "\n",
    "이 셀의 코드는 수정하지 않습니다. 새 런타임에서 **모두 실행**했을 때 모든 조건을 통과해야 합니다. 자동 검사는 파일과 수치의 일관성, 관찰 근거의 포함 여부를 확인합니다. 질문의 의미·자료 이용 권한·관찰과 한계의 타당성·수정 행동·시각적 가독성은 교수 확인을 거치며, 확인 전에는 `teacher_gate = \"pending\"`이라 PASS가 나오지 않습니다."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 7 · FINAL CHECK: 이 셀은 수정하지 않습니다\n",
    "assert _run_order == [0, 1, 2, 3, 4, 5, 6], \"새 런타임에서 STEP 0부터 모두 실행하세요.\"\n",
    "\n",
    "def is_finished_text(value, minimum, maximum=180):\n",
    "    text = str(value).strip()\n",
    "    return minimum <= len(text) <= maximum and \"EDIT:\" not in text and \"\\n\" not in text\n",
    "\n",
    "assert safe_student_id and student_id not in {\"학번\", \"student_id\"}, \"실제 학번을 입력하세요.\"\n",
    "assert safe_student_name and student_name not in {\"이름\", \"student_name\"}, \"실제 이름을 입력하세요.\"\n",
    "assert approval_status in {\"approved\", \"scoped\", \"provided\"}, \"1차 면담 판정을 approved, scoped, provided 가운데 하나로 기록하세요.\"\n",
    "if input_mode == \"provided\":\n",
    "    assert approval_status == \"provided\", \"제공 경로는 approval_status를 provided로 기록하세요.\"\n",
    "else:\n",
    "    assert approval_status in {\"approved\", \"scoped\"}, \"자신의 입력은 1차 면담 승인 또는 범위 축소 승인이 필요합니다.\"\n",
    "assert is_finished_text(approval_note, 10, 180), \"승인 또는 축소 범위를 10~180자로 기록하세요.\"\n",
    "assert reuse_status in {\"guided\", \"approved\", \"scoped\"}, \"코드 재사용 판정을 guided, approved, scoped 가운데 하나로 기록하세요.\"\n",
    "assert is_finished_text(reuse_note, 10, 180), \"공통 코드 유지 또는 승인 재사용 범위를 10~180자로 기록하세요.\"\n",
    "assert is_finished_text(project_question, 15, 70), \"질문을 15~70자의 한 줄로 작성하세요.\"\n",
    "assert project_question.rstrip().endswith((\"?\", \"？\")), \"프로젝트 질문은 물음표로 끝내세요.\"\n",
    "assert is_finished_text(intended_audience, 5, 80), \"예상 독자를 5~80자로 작성하세요.\"\n",
    "assert is_finished_text(project_source, 10, 180), \"자료 출처를 10~180자로 작성하세요.\"\n",
    "assert is_finished_text(usage_rights, 10, 180), \"자료 이용 근거를 10~180자로 작성하세요.\"\n",
    "assert is_finished_text(reference_date, 4, 80), \"자료의 수집·제작 날짜 또는 기간을 기록하세요.\"\n",
    "assert is_finished_text(privacy_check, 10, 180), \"개인정보 제외 또는 동의 확인 방법을 기록하세요.\"\n",
    "assert is_finished_text(observation_unit, 8, 120), \"입력 한 건의 관찰 단위를 기록하세요.\"\n",
    "assert is_finished_text(value_unit, 1, 60), \"화면 값의 단위를 기록하세요.\"\n",
    "assert is_finished_text(scope_decision, 10, 180), \"이번 주에 제외한 기능을 구체적으로 작성하세요.\"\n",
    "assert is_finished_text(processing_rule, 12, 180), \"입력 처리 규칙을 작성하세요.\"\n",
    "assert is_finished_text(visual_rule, 12, 180), \"값과 시각 요소의 연결 규칙을 작성하세요.\"\n",
    "assert is_finished_text(observation_evidence, 1, 50), \"화면에서 가리킬 관찰 근거를 1~50자로 작성하세요.\"\n",
    "assert is_finished_text(main_observation, 20, 70), \"관찰을 20~70자의 한 줄로 작성하세요.\"\n",
    "assert observation_evidence in main_observation, \"관찰 문장에 화면에서 가리킬 관찰 근거를 그대로 포함하세요.\"\n",
    "assert is_finished_text(limitation_statement, 20, 70), \"한계를 20~70자의 한 줄로 작성하세요.\"\n",
    "assert any(token in limitation_statement for token in (\"없\", \"못\", \"한계\", \"제한\", \"단정\", \"아니\")), \"한계 문장에는 현재 단정할 수 없는 내용을 분명히 쓰세요.\"\n",
    "assert is_finished_text(next_step_1, 12, 120), \"첫 번째 다음 행동을 12~120자로 작성하세요.\"\n",
    "assert is_finished_text(next_step_2, 12, 120), \"두 번째 다음 행동을 12~120자로 작성하세요.\"\n",
    "assert next_step_1 != next_step_2, \"서로 다른 두 수정 행동을 작성하세요.\"\n",
    "action_tokens = (\"추가\", \"수정\", \"교체\", \"조정\", \"확인\", \"검사\", \"줄\", \"늘\", \"배치\", \"표시\", \"기록\", \"분리\", \"비교\")\n",
    "assert any(token in next_step_1 for token in action_tokens), \"첫 번째 다음 행동에는 구체적인 수정 동사를 쓰세요.\"\n",
    "assert any(token in next_step_2 for token in action_tokens), \"두 번째 다음 행동에는 구체적인 수정 동사를 쓰세요.\"\n",
    "\n",
    "if project_track == \"data\":\n",
    "    pd.testing.assert_frame_equal(raw_data, raw_snapshot)\n",
    "elif project_track == \"text\":\n",
    "    assert raw_text == raw_snapshot, \"텍스트 원본이 변경되었습니다.\"\n",
    "elif project_track == \"sound\":\n",
    "    assert np.array_equal(raw_signal, raw_snapshot), \"소리 원본이 변경되었습니다.\"\n",
    "else:\n",
    "    pd.testing.assert_frame_equal(raw_image_params, raw_snapshot)\n",
    "if input_mode == \"provided\":\n",
    "    assert source_digest_before == EXPECTED_PROVIDED_DIGESTS[project_track], \"제공 입력의 내용이 달라졌습니다.\"\n",
    "else:\n",
    "    assert source_path.name == expected_own_source_filename, \"제출 원본 파일명이 STEP 1의 표준 파일명과 다릅니다.\"\n",
    "    assert source_path.read_bytes() == source_bytes_before, \"외부 입력 파일이 실행 중 변경되었습니다.\"\n",
    "\n",
    "visual_element_count = len(mapping_visual_values)\n",
    "assert visual_element_count == len(mapping_source_values), \"처리 결과와 화면 요소 수가 다릅니다.\"\n",
    "assert np.allclose(mapping_visual_values, mapping_source_values), \"화면에 매핑된 값이 처리 결과와 일치하지 않습니다.\"\n",
    "\n",
    "def assert_adapter_pair(source_values, visual_values, label, *, required, textual=False):\n",
    "    if required:\n",
    "        assert source_values is not None and visual_values is not None, f\"{label} 증거 쌍이 없습니다.\"\n",
    "    assert (source_values is None) == (visual_values is None), f\"{label} 증거는 처리 쪽과 화면 쪽을 함께 남기세요.\"\n",
    "    if source_values is None:\n",
    "        return\n",
    "    if textual:\n",
    "        assert [str(value) for value in visual_values] == [str(value) for value in source_values], f\"{label}이 처리 결과와 일치하지 않습니다.\"\n",
    "        return\n",
    "    source_array = np.asarray(source_values, dtype=float)\n",
    "    visual_array = np.asarray(visual_values, dtype=float)\n",
    "    assert source_array.shape == visual_array.shape, f\"{label} 증거의 모양이 다릅니다.\"\n",
    "    assert np.allclose(visual_array, source_array), f\"{label}이 처리 결과와 일치하지 않습니다.\"\n",
    "\n",
    "assert_adapter_pair(\n",
    "    mapping_source_labels,\n",
    "    mapping_visual_labels,\n",
    "    \"막대 레이블\",\n",
    "    required=project_track in {\"data\", \"text\"},\n",
    "    textual=True,\n",
    ")\n",
    "assert_adapter_pair(\n",
    "    mapping_source_positions,\n",
    "    mapping_visual_positions,\n",
    "    \"화면 위치\",\n",
    "    required=project_track in {\"sound\", \"image\"},\n",
    ")\n",
    "assert_adapter_pair(\n",
    "    mapping_source_colors,\n",
    "    mapping_visual_colors,\n",
    "    \"도형의 색\",\n",
    "    required=project_track == \"image\",\n",
    ")\n",
    "\n",
    "assert output_path.is_file() and output_path.stat().st_size > 20_000, \"미리보기 파일을 찾을 수 없거나 비어 있습니다.\"\n",
    "if output_format == \"png\":\n",
    "    checked_preview_bytes = output_path.read_bytes()\n",
    "else:\n",
    "    checked_html = output_path.read_text(encoding=\"utf-8\")\n",
    "    assert checked_html.lower().startswith(\"<!doctype html>\"), \"HTML 결과에 문서 선언이 없습니다.\"\n",
    "    assert \"max-width:100%;height:auto\" in checked_html, \"HTML 이미지에 반응형 크기 규칙이 없습니다.\"\n",
    "    assert \"<figcaption>\" in checked_html and html_observation in checked_html, \"HTML 결과에 관찰 설명이 없습니다.\"\n",
    "    assert f'alt=\"{html_alt}\"' in checked_html, \"HTML 이미지 대체 설명이 결과와 일치하지 않습니다.\"\n",
    "    encoded_match = re.search(r\"data:image/png;base64,([A-Za-z0-9+/=]+)\", checked_html)\n",
    "    assert encoded_match, \"HTML 결과에 미리보기 이미지가 포함되지 않았습니다.\"\n",
    "    checked_preview_bytes = base64.b64decode(encoded_match.group(1), validate=True)\n",
    "with Image.open(io.BytesIO(checked_preview_bytes)) as saved_image:\n",
    "    assert saved_image.format == \"PNG\", \"결과 안의 미리보기는 PNG여야 합니다.\"\n",
    "    assert saved_image.size == (1600, 1000), \"결과 크기는 1600 × 1000이어야 합니다.\"\n",
    "\n",
    "print(\"AUTOMATIC EVIDENCE READY · TEACHER CHECK REQUIRED\")\n",
    "assert teacher_gate == \"confirmed\", \"교수의 최종 확인 뒤 teacher_gate를 confirmed로 바꾸세요.\"\n",
    "assert is_finished_text(teacher_feedback, 8, 180), \"교수 확인에서 받은 피드백을 8~180자로 기록하세요.\"\n",
    "\n",
    "_run_order.append(7)\n",
    "print(\"=\" * 52)\n",
    "print(\"WEEK 14 PROJECT PROTOTYPE COMPLETE\")\n",
    "print(f\"TRACK · {project_track} / INPUT · {input_mode}\")\n",
    "print(f\"REUSE · {reuse_status}\")\n",
    "print(f\"OUTPUT · {output_filename}\")\n",
    "print(\"교수 확인 · 질문-입력 / 권한 / 관찰-한계 / 다음 행동 / 가독성\")\n",
    "print(\"=\" * 52)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 제출 파일\n",
    "\n",
    "1. `week14_학번_이름_prototype.ipynb`\n",
    "2. `week14_학번_이름_preview.png` 또는 `week14_학번_이름_preview.html`\n",
    "3. `input_mode = \"own\"`이면 실행에 사용한 원본 파일\n",
    "\n",
    "`own`이면 노트북이 실제로 읽은 표준 이름의 원본 파일을 이름을 바꾸지 않고 함께 제출합니다. 결과 파일과 STEP 1·5를 한 화면에 준비해 교수에게 30-45초 증거 확인을 받고, `teacher_gate`를 변경한 뒤 새 런타임에서 모두 실행해 PASS를 받은 다음 제출합니다."
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
