見出し画像

アプリを作ろう--妙高市雪情報グラフ--第2回:AIエージェントにコードを書いてもらう

アプリを作ろう──妙高市雪情報グラフ

第2回:AIエージェントにコードを書いてもらう

こんにちは。
前回は、要件定義書を作るところまで進めました。

今回はいよいよ、その要件定義書をもとに
AIエージェントにコードを書いてもらう回です。

:::message
この連載は、Webアプリ開発初心者が、同じく初心者の方に向けて書いている記事です。
私自身も学びながら進めているので、一緒に「できた!」を積み重ねていけたら嬉しいです。
:::



この連載でやっていること

おさらいです。
この連載では、新潟県妙高市の「雪情報ホームページ」に掲載されている
降雪量・積雪量の表データを、グラフで見られるWebアプリを作っています。

使う技術は、とてもシンプルです。

  • Python

  • Streamlit(PythonだけでWebアプリが作れるライブラリ)

そして今回の主役は、AIエージェント(Cursor Agent)です。


要件定義からコードを生成してもらう

前回作った要件定義書(requirements.md)を、
CursorのAIエージェント機能に渡しました。

プロンプトは、とてもシンプルです。

requirements.md の要件定義に基づいて、必要なコード、ファイル、を作成してください。

たったこれだけ。
要件定義書を 「設計図」として渡す ことで、
AIが自分で考えて、必要なファイルを一式作ってくれます。


1〜2分で、アプリが完成した

驚くべきことに、わずか1〜2分
以下のファイルが自動生成されました。

  • main.py … メインアプリケーション

  • data_urls.csv … データ取得先URL一覧(44件)

  • requirements.txt … 依存パッケージ一覧

  • README.md … ドキュメント

  • .gitignore … Git除外設定

要件定義で書いた内容が、ほぼそのままコードになっています。
「こうしてほしい」と言葉で書いたことが、動くコードに変わる。
この体験は、やっぱり感動します。


作成されたファイルを見てみよう

1. main.py(メインアプリケーション)

一番大事なファイルです。
346行のPythonコードが生成されました。

実装されている機能:

  • ✅ CSVファイルからURL情報を自動読み込み

  • ✅ HTMLページから表データを自動取得・解析

  • ✅ サイドバーで年・月・地点を最大3件選択可能

  • ✅ 降雪量(折れ線グラフ)と積雪量(棒グラフ)を同一グラフに表示

  • ✅ グラフを縦に3つまで並べて表示

  • ✅ デフォルトで最新データの3地点を表示

  • ✅ キャッシュ機能による高速化

  • ✅ エラー処理と適切なメッセージ表示

要件定義書に書いた「受入条件」を、ほぼすべて満たしています。


main.pyの全体構成

コードは、大きく分けて 4つの関数 で構成されています。

# 1. URL一覧CSVファイルを読み込む
def load_url_data() -> pd.DataFrame:

# 2. 指定URLから降雪・積雪データを取得してDataFrame化する
def fetch_snow_data(url: str, year: int, month: int) -> Optional[pd.DataFrame]:

# 3. 降雪・積雪のグラフを作成
def create_snow_graph(df: pd.DataFrame, year: int, month: int, location: str) -> go.Figure:

# 4. データが存在する最新の年月を取得
def get_latest_available_month(url_df: pd.DataFrame) -> Tuple[int, int]:

そして、これらを呼び出す `main()` 関数があります。

初心者の方には、この構成がとても参考になると思います。
「データを読む」「加工する」「表示する」を、
それぞれ 別の関数に分ける ことで、
コードが読みやすく、変更しやすくなっています。


コードのポイント① キャッシュ機能

Streamlitには、キャッシュ機能があります。
同じURLへ何度もアクセスしないように、結果を保存しておく仕組みです。

@st.cache_data(ttl=3600)
def load_url_data() -> pd.DataFrame:
    """URL一覧CSVファイルを読み込む"""
    # ...

`@st.cache_data` というデコレータを関数の上に書くだけで、
1時間(3600秒)はキャッシュが有効になります。

要件定義書に「キャッシュ機能を使う」と書いておいたので、
AIがちゃんと実装してくれました。


コードのポイント② HTMLの解析

妙高市のホームページから、表データを取り出す部分です。

# BeautifulSoupでパース
soup = BeautifulSoup(response.text, 'lxml')

# テーブルを探す
tables = soup.find_all('table')

`BeautifulSoup` というライブラリを使って、
HTMLの中から `<table>` タグを探しています。

このあたりは、ちょっと難しく見えるかもしれませんが、
「HTML = 構造化された文書」
「BeautifulSoup = その構造から情報を取り出す道具」
と思っておけばOKです。


コードのポイント③ グラフの作成

Plotlyというライブラリで、グラフを作成しています。

# 積雪量(棒グラフ)
fig.add_trace(
    go.Bar(
        x=filtered_df['day'],
        y=filtered_df['snowdepth_cm'],
        name='積雪量',
        marker_color='lightblue',
        opacity=0.7
    ),
    secondary_y=False
)

# 降雪量(折れ線グラフ)
fig.add_trace(
    go.Scatter(
        x=filtered_df['day'],
        y=filtered_df['snowfall_cm'],
        name='降雪量',
        mode='lines+markers',
        line=dict(color='red', width=2),
        marker=dict(size=6)
    ),
    secondary_y=False
)

要件定義に書いた
「降雪量は折れ線グラフ、積雪量は棒グラフ、同一グラフに重ねて表示」
が、そのまま実装されています。


2. data_urls.csv(URL一覧)

年度・年・月ごとのページURLを、44件登録しています。

年度,年,月,URL
令和1年度,2019,1,https://www.city.myoko.niigata.jp/life-info/snow-info/snow/r1-1.html
令和1年度,2019,2,https://www.city.myoko.niigata.jp/life-info/snow-info/snow/r1-2.html
...(中略)...
令和8年度,2026,11,https://www.city.myoko.niigata.jp/life-info/snow-info/snow/r8-11.html

要件定義書で「令和1年度〜令和8年度」と指定していたので、
AIがURLを推測して一覧を作ってくれました。

(実際のURLが正しいかどうかは、動作確認時に検証します)


3. requirements.txt(依存パッケージ)

アプリを動かすために必要なライブラリの一覧です。

streamlit>=1.28.0
pandas>=2.0.0
requests>=2.31.0
beautifulsoup4>=4.12.0
plotly>=5.17.0
lxml>=4.9.0

要件定義書に書いた「使用ライブラリ」が、
そのままバージョン指定付きで書かれています。


4. README.md(ドキュメント)

170行のドキュメントが生成されました。

  • アプリの概要と使い方

  • セットアップ手順

  • トラブルシューティング

  • 技術スタック情報

  • 今後の拡張案

まで書いてくれています。


感じたこと:AIは「設計図」があると強い

今回、改めて感じたのは、
要件定義書(設計図)があると、AIの出力精度がぐっと上がる
ということです。

「雪のデータをグラフにして」と曖昧に頼むより、
「こういう構成で、こういう機能があって、こういうエラー処理をして」
と具体的に書いてあるほうが、AIは正確に動きます。

前回、要件定義を先に作ったのは、このためでした。


main.pyの全コード

参考までに、生成された `main.py` の全コードを掲載します。
約350行と長いですが、構造はシンプルなので、
ぜひ眺めてみてください。

import streamlit as st
import pandas as pd
import requests
from bs4 import BeautifulSoup
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from typing import Optional, Tuple, List
import logging
from datetime import datetime

# ロギング設定
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# 定数
LOCATIONS = ["新井消防署", "頸南消防署", "妙高市役所 妙高支所"]
CSV_FILE = "data_urls.csv"


@st.cache_data(ttl=3600)
def load_url_data() -> pd.DataFrame:
    """URL一覧CSVファイルを読み込む"""
    try:
        df = pd.read_csv(CSV_FILE)
        return df
    except Exception as e:
        logger.error(f"CSVファイルの読み込みに失敗: {e}")
        st.error("データURLの読み込みに失敗しました")
        return pd.DataFrame()


@st.cache_data(ttl=3600)
def fetch_snow_data(url: str, year: int, month: int) -> Optional[pd.DataFrame]:
    """
    指定URLから降雪・積雪データを取得してDataFrame化する
    
    Args:
        url: データ取得元のURL
        year: 年
        month: 月
        
    Returns:
        tidyデータ形式のDataFrame、取得失敗時はNone
    """
    try:
        # HTMLを取得
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        response.encoding = response.apparent_encoding
        
        # BeautifulSoupでパース
        soup = BeautifulSoup(response.text, 'lxml')
        
        # テーブルを探す
        tables = soup.find_all('table')
        if not tables:
            logger.warning(f"テーブルが見つかりません: {url}")
            return None
        
        # 最初のテーブルを使用(通常、データテーブルは最初にある)
        table = tables[0]
        
        # データを格納するリスト
        data_rows = []
        
        # テーブルの行を解析
        rows = table.find_all('tr')
        
        # ヘッダー行をスキップして、データ行を処理
        for row in rows[1:]:  # 最初の行はヘッダーと仮定
            cols = row.find_all(['td', 'th'])
            if len(cols) < 7:  # 日、降雪×3、積雪×3 の最低7列必要
                continue
            
            # テキストを抽出
            cols_text = [col.get_text(strip=True) for col in cols]
            
            # 日付を取得
            try:
                day = int(cols_text[0])
            except (ValueError, IndexError):
                continue
            
            # 各観測地点のデータを処理
            # 想定: 列順が [日, 降雪1, 降雪2, 降雪3, 積雪1, 積雪2, 積雪3]
            if len(cols_text) >= 7:
                for i, location in enumerate(LOCATIONS):
                    # 降雪量(インデックス 1, 2, 3)
                    snowfall_idx = i + 1
                    # 積雪量(インデックス 4, 5, 6)
                    snowdepth_idx = i + 4
                    
                    snowfall = cols_text[snowfall_idx] if snowfall_idx < len(cols_text) else "-"
                    snowdepth = cols_text[snowdepth_idx] if snowdepth_idx < len(cols_text) else "-"
                    
                    # "-" を NaN に変換
                    snowfall_cm = None if snowfall == "-" or snowfall == "" else float(snowfall)
                    snowdepth_cm = None if snowdepth == "-" or snowdepth == "" else float(snowdepth)
                    
                    data_rows.append({
                        'year': year,
                        'month': month,
                        'day': day,
                        'location': location,
                        'snowfall_cm': snowfall_cm,
                        'snowdepth_cm': snowdepth_cm
                    })
        
        if not data_rows:
            logger.warning(f"データが抽出できませんでした: {url}")
            return None
        
        df = pd.DataFrame(data_rows)
        return df
        
    except requests.RequestException as e:
        logger.error(f"HTTPリクエストエラー: {url} - {e}")
        return None
    except Exception as e:
        logger.error(f"データ取得エラー: {url} - {e}")
        return None


def create_snow_graph(df: pd.DataFrame, year: int, month: int, location: str) -> go.Figure:
    """
    降雪・積雪のグラフを作成
    
    Args:
        df: 降雪・積雪データ
        year: 年
        month: 月
        location: 観測地点
        
    Returns:
        Plotlyのグラフオブジェクト
    """
    # 指定された条件でフィルタ
    filtered_df = df[
        (df['year'] == year) & 
        (df['month'] == month) & 
        (df['location'] == location)
    ].sort_values('day')
    
    # グラフ作成
    fig = make_subplots(specs=[[{"secondary_y": True}]])
    
    # 積雪量(棒グラフ)
    fig.add_trace(
        go.Bar(
            x=filtered_df['day'],
            y=filtered_df['snowdepth_cm'],
            name='積雪量',
            marker_color='lightblue',
            opacity=0.7
        ),
        secondary_y=False
    )
    
    # 降雪量(折れ線グラフ)
    fig.add_trace(
        go.Scatter(
            x=filtered_df['day'],
            y=filtered_df['snowfall_cm'],
            name='降雪量',
            mode='lines+markers',
            line=dict(color='red', width=2),
            marker=dict(size=6)
        ),
        secondary_y=False
    )
    
    # レイアウト設定
    fig.update_layout(
        title=f"{year}年{month}月 / {location}",
        xaxis_title="日",
        yaxis_title="cm",
        hovermode='x unified',
        height=400,
        showlegend=True,
        legend=dict(
            orientation="h",
            yanchor="bottom",
            y=1.02,
            xanchor="right",
            x=1
        )
    )
    
    # X軸の範囲を1-31に設定
    fig.update_xaxes(range=[0.5, 31.5], dtick=5)
    
    return fig


def get_latest_available_month(url_df: pd.DataFrame) -> Tuple[int, int]:
    """
    データが存在する最新の年月を取得
    
    Args:
        url_df: URL一覧データ
        
    Returns:
        (年, 月) のタプル
    """
    if url_df.empty:
        current_date = datetime.now()
        return current_date.year, current_date.month
    
    # 年月でソート
    sorted_df = url_df.sort_values(['年', '月'], ascending=False)
    latest = sorted_df.iloc[0]
    return int(latest['年']), int(latest['月'])


def main():
    """メインアプリケーション"""
    
    # ページ設定
    st.set_page_config(
        page_title="妙高市 降雪・積雪データ可視化",
        page_icon="❄️",
        layout="wide"
    )
    
    # タイトル
    st.title("❄️ 妙高市 降雪・積雪データ可視化")
    st.markdown("---")
    
    # URL一覧の読み込み
    url_df = load_url_data()
    
    if url_df.empty:
        st.error("データURLが読み込めません。data_urls.csv を確認してください。")
        return
    
    # 利用可能な年月のリストを作成
    available_years = sorted(url_df['年'].unique())
    
    # 最新の年月を取得
    latest_year, latest_month = get_latest_available_month(url_df)
    
    # サイドバー
    st.sidebar.header("📊 表示条件設定")
    st.sidebar.markdown("最大3件まで選択できます")
    
    # 条件選択(最大3件)
    selections = []
    
    for i in range(3):
        st.sidebar.markdown(f"### 条件 {i+1}")
        
        col1, col2 = st.sidebar.columns(2)
        
        with col1:
            # デフォルト値を設定(最新月)
            default_year_idx = available_years.index(latest_year) if latest_year in available_years else 0
            year = st.selectbox(
                "年",
                options=available_years,
                index=default_year_idx,
                key=f"year_{i}"
            )
        
        with col2:
            # 選択された年で利用可能な月を取得
            available_months = sorted(url_df[url_df['年'] == year]['月'].unique())
            default_month_idx = available_months.index(latest_month) if latest_month in available_months else 0
            month = st.selectbox(
                "月",
                options=available_months,
                index=default_month_idx,
                key=f"month_{i}"
            )
        
        # 観測地点(デフォルトで各地点を割り当て)
        default_location_idx = i if i < len(LOCATIONS) else 0
        location = st.sidebar.selectbox(
            "観測地点",
            options=LOCATIONS,
            index=default_location_idx,
            key=f"location_{i}"
        )
        
        selections.append({
            'year': year,
            'month': month,
            'location': location
        })
        
        st.sidebar.markdown("---")
    
    # 重複チェック
    unique_selections = []
    seen = set()
    for sel in selections:
        key = (sel['year'], sel['month'], sel['location'])
        if key not in seen:
            unique_selections.append(sel)
            seen.add(key)
        else:
            st.sidebar.warning(f"⚠️ {sel['year']}年{sel['month']}月 / {sel['location']} が重複しています")
    
    # メイン表示エリア
    st.markdown("## 📈 グラフ表示")
    
    # 各条件についてグラフを表示
    for idx, sel in enumerate(unique_selections):
        year = sel['year']
        month = sel['month']
        location = sel['location']
        
        # URLを取得
        url_row = url_df[(url_df['年'] == year) & (url_df['月'] == month)]
        
        if url_row.empty:
            st.warning(f"⚠️ {year}年{month}月のデータはありません")
            continue
        
        url = url_row.iloc[0]['URL']
        
        # データ取得
        with st.spinner(f"{year}年{month}月のデータを取得中..."):
            df = fetch_snow_data(url, year, month)
        
        if df is None or df.empty:
            st.error(f"❌ {year}年{month}月 / {location} のデータ取得に失敗しました")
            continue
        
        # グラフ作成
        fig = create_snow_graph(df, year, month, location)
        st.plotly_chart(fig, use_container_width=True)
    
    # フッター
    st.markdown("---")
    st.markdown("""
    <div style='text-align: center; color: #888; font-size: 0.9em;'>
        データ出典: <a href='https://www.city.myoko.niigata.jp/life-info/snow-info/snow/' target='_blank'>妙高市 雪情報ホームページ</a><br>
        観測時刻: 9時 | 降雪量: 前日分 | 積雪量: 当日分
    </div>
    """, unsafe_allow_html=True)


if __name__ == "__main__":
    main()

GitHubへのコミット

生成されたファイルは、GitHubにコミットしました。
今回のコミットは以下のリンクから確認できます。

🔗 GitHub コミット履歴


次回予告:動作確認と修正

さて、ここまでは「AIが書いてくれた」話です。

でも、実は このコードはまだ正しく動きません
(READMEの冒頭にも「動作確認中です」と書いてあります)

次回は、
実際に動かしてみて、問題点を確認し、修正していく
工程に進みます。

AIが書いたコードを、人間が確認して直していく。
その過程こそが、一番の学びになるはずです。

お楽しみに。


今回のまとめ

  • 要件定義書をAIに渡すと、1〜2分でコード一式が生成される

  • 生成されたのは5ファイル:main.py, data_urls.csv, requirements.txt, README.md, .gitignore

  • コードは「データを読む」「加工する」「表示する」を関数で分けた構造

  • キャッシュ機能、エラー処理など、要件通りの実装がされていた

  • ただし、まだ動作確認前。次回で修正していく


学びポイント 💡

「設計図があると、AIの精度が上がる」

今回、AIエージェントがスムーズにコードを書けたのは、
前回しっかり要件定義書を作っておいたからです。

曖昧なお願いより、具体的な設計図。
これは、AIとの協業で一番大事なポイントかもしれません。


続く


書いている人について

投稿者の紹介です。
noteでは、次の3つを軸に、日々の学びや記録を残しています。

  • Python・AI
    趣味の開発で試したこと、つまずいた点、学びの記録

  • 健康・からだ
    自分自身の不調や気づきをもとにした、学びと養生の記録

  • 生まれ故郷への想い
    新潟・妙高(小局)にまつわる歴史や記憶を残すための記録

👉 マイペースで続けている記録はこちら

👉アプリ開発に関するマガジン


いいなと思ったら応援しよう!