Example Project

做一個「考試小幫手」網站

問它「最近學校有什麼重要考試?」,它會先去抓學校官網的考試資訊公告,再交給校內 AI 整理成清單。這是 RAG 的真實版本:資料不是事先存好的檔案,而是每次即時從網路上抓。整個網站只有一個 Python 檔,大約 70 行。

先玩玩看: 老師已經架好一個,在校內打開 http://192.168.16.233:8501 就能用。只能在校內網路使用,在家或用手機行動網路連不到。

它怎麼運作

  1. 抓列表用 requests 下載官網「考試資訊」頁,用 BeautifulSoup 找出每則公告的標題、日期、連結。
  2. 抓內文再進入前 8 則公告的內頁,抓出內文(例如「第二次模擬考考試日期為10月28日~29日」)。
  3. 交給 AI把公告、今天的日期和問題一起送給 gpt-oss-120b,要求它只根據公告回答。
  4. 顯示用 Streamlit 做成網頁:一個輸入框、一個按鈕,再把 AI 的回答顯示出來。
為什麼要給它今天的日期? AI 不知道今天是幾號。不告訴它,它就分不出哪些考試「已經考完」、哪些是「最近」。

步驟 1:準備檔案

建立一個資料夾,裡面的檔案長這樣(完整檔案在 GitHub 的 examples/exam_bot):

exam_bot/
├── app.py                  # 主程式
├── requirements.txt        # 要安裝的套件
└── .streamlit/
    └── secrets.toml        # 你的 API key(不能上傳!)

安裝套件:

pip install streamlit openai beautifulsoup4

步驟 2:把 API key 存成檔案

key 向資訊老師申請,一人一把(見 規則與 FAQ)。拿到之後,不要寫在程式裡,而是存在 .streamlit/secrets.toml 這個檔案:

.streamlit/secrets.toml
EXO_API_KEY = "sk-你的API key"

程式裡用 st.secrets["EXO_API_KEY"] 就讀得到,這是 Streamlit 內建的功能,不用自己寫讀檔程式,也不用設環境變數。

這個檔案就是你的密碼。 用 git 的話,一定要在 .gitignore 加一行 .streamlit/secrets.toml,它才不會被上傳到 GitHub。交作業、傳檔案給同學時也要把它排除。外流的 key 老師會直接停用。
存 key 的方法優點要注意
寫死在程式裡最簡單程式一分享 key 就外流,只適合自己練習
環境變數(PyCharm 設定)程式碼裡完全沒有 key每台電腦、每個執行設定都要設一次
存成檔案(本頁)直覺,和程式放在一起好管理一定要加進 .gitignore

步驟 3:主程式

app.py
"""南一中考試小幫手:抓學校官網「考試資訊」,交給校內 LLM 整理後回答。

執行:streamlit run app.py
"""
import datetime

import requests
import streamlit as st
from bs4 import BeautifulSoup
from openai import OpenAI

BASE = "https://www.tnfsh.tn.edu.tw/latestevent/"
LIST_URL = BASE + "index.aspx?Parser=9,3,17"  # 官網「考試資訊」列表

# key 從 .streamlit/secrets.toml 讀,不寫在程式裡
client = OpenAI(
    base_url="http://192.168.16.233:4000/v1",
    api_key=st.secrets["EXO_API_KEY"],
)


def get_soup(url):
    html = requests.get(url, timeout=15).text
    return BeautifulSoup(html, "html.parser")


@st.cache_data(ttl=600)  # 10 分鐘內重複提問不重抓,對學校網站友善
def fetch_exams(n=8):
    """抓列表頁前 n 則公告,再進內頁抓內文,整理成一段純文字。"""
    items = []
    for li in get_soup(LIST_URL).select("ul.list li")[1:n + 1]:  # 第一個 li 是表頭
        a = li.find("a")
        unit, date = [s.get_text(strip=True) for s in li.select("span.w15")]
        link = BASE + a["href"]

        # 內頁的內文在「公布單位」和「您的瀏覽器,不支援script」之間
        text = get_soup(link).get_text(" ", strip=True)
        body = text.split("公布單位")[-1].split("您的瀏覽器")[0].lstrip(" :")[:500]

        items.append(f"標題:{a['title']}\n公布日期:{date}({unit})\n內文:{body}\n連結:{link}")
    return "\n\n".join(items)


def ask(question, data):
    system = (
        f"你是臺南一中的校園小幫手。今天是 {datetime.date.today()}。"
        "只根據提供的公告回答,用繁體中文條列近期的考試或報名截止:日期、對象、重點。"
        "已經過去的不要列;公告沒寫日期的就說「詳見附件」;公告沒寫的(例如對象)不要猜,資料裡沒有的不要自己編。"
        "每一項最後附上公告連結。"
    )
    response = client.chat.completions.create(
        model="gpt-oss-120b",
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": f"公告:\n{data}\n\n問題:{question}"},
        ],
        reasoning_effort="low",
    )
    return response.choices[0].message.content


st.title("南一中考試小幫手")
st.caption("⚠️ 只能在校內網路使用(AI 在校內的叢集上),回答僅供參考,請以學校公告為準。")
question = st.text_input("想問什麼?", "最近學校有什麼重要考試?")

if st.button("問問看"):
    with st.spinner("抓取學校公告中…"):
        data = fetch_exams()
    with st.expander("抓到的原始公告(這就是交給 AI 的資料)"):
        st.text(data)
    with st.spinner("AI 整理中…"):
        st.markdown(ask(question, data))

幾個重點

步驟 4:執行

streamlit run app.py

瀏覽器會自動打開 http://localhost:8501,按「問問看」。抓資料不到 1 秒,AI 整理大約 30~60 秒(叢集忙的時候會更久)。要在校內網路才連得到叢集。

AI 還是可能「補腦」。 我們實測時,就算 system prompt 寫了「公告沒寫的不要猜」,它有時仍會把第二次模擬考的對象寫成「全校學生」,但公告裡根本沒寫對象。所以這種工具要附上公告連結,重要的事一定要點進去看原始公告。

進階挑戰