Example Project
做一個「考試小幫手」網站
問它「最近學校有什麼重要考試?」,它會先去抓學校官網的考試資訊公告,再交給校內 AI 整理成清單。這是 RAG 的真實版本:資料不是事先存好的檔案,而是每次即時從網路上抓。整個網站只有一個 Python 檔,大約 70 行。
先玩玩看: 老師已經架好一個,在校內打開 http://192.168.16.233:8501 就能用。只能在校內網路使用,在家或用手機行動網路連不到。
它怎麼運作
- 抓列表用
requests下載官網「考試資訊」頁,用BeautifulSoup找出每則公告的標題、日期、連結。 - 抓內文再進入前 8 則公告的內頁,抓出內文(例如「第二次模擬考考試日期為10月28日~29日」)。
- 交給 AI把公告、今天的日期和問題一起送給 gpt-oss-120b,要求它只根據公告回答。
- 顯示用 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))
幾個重點
@st.cache_data(ttl=600):10 分鐘內再問,就直接用上次抓到的資料,不會一直打學校網站。寫爬蟲要對別人的網站有禮貌。select("ul.list li"):在瀏覽器對公告列表按右鍵 →「檢查」,就看得到每則公告是一個<li>。寫爬蟲的第一步永遠是先看網頁原始碼長什麼樣子。st.expander:把交給 AI 的原始資料也秀出來。回答怪怪的時候,先看資料有沒有抓對。- 不設
max_tokens,用reasoning_effort="low"讓它回答快一點(原因見 快速開始)。
步驟 4:執行
streamlit run app.py
瀏覽器會自動打開 http://localhost:8501,按「問問看」。抓資料不到 1 秒,AI 整理大約 30~60 秒(叢集忙的時候會更久)。要在校內網路才連得到叢集。
AI 還是可能「補腦」。 我們實測時,就算 system prompt 寫了「公告沒寫的不要猜」,它有時仍會把第二次模擬考的對象寫成「全校學生」,但公告裡根本沒寫對象。所以這種工具要附上公告連結,重要的事一定要點進去看原始公告。
進階挑戰
- 有些考試的時間只寫在附件 PDF 裡。試著用
pypdf把附件的文字也抓下來交給 AI,比較回答有沒有變準。 - 把「最新消息」(
Parser=9,3,19)也抓進來,變成「校園公告小幫手」。 - 加上
stream=True,讓回答一個字一個字出現(Streamlit 有st.write_stream)。 - 比較「給今天日期」和「不給日期」時,AI 的回答差在哪裡,這就是一個小研究。