📧 Email, files and AI in Python · อีเมลและไฟล์ใน Python · 用 Python 处理邮件和文件

Ten tutorials: send an email, work with files, read your inbox, cook with a local AI, send it to the web, swap in NVIDIA's cloud, give the AI a tool it can call, wrap your own sending in a function you import, answer a real email with all of it, then listen to your inbox out loud. Thirty-two small steps. · เก้าบท ส่งอีเมล ทำงานกับไฟล์ อ่านกล่องจดหมาย ให้ AI ในเครื่องทำอาหาร ส่งมันไปค้นเว็บ สลับไปใช้คลาวด์ของ NVIDIA ให้เครื่องมือที่ AI เรียกใช้ได้ ห่อการส่งของคุณเองไว้ในฟังก์ชันที่ import ได้ ตอบอีเมลจริงด้วยทุกอย่างที่เรียนมา แล้วฟังกล่องจดหมายเป็นเสียง รวมสามสิบสองขั้นเล็ก ๆ · 九个教程:发邮件、处理文件、读收件箱、让本机 AI 下厨、让它上网查资料、换成 NVIDIA 的云端、给 AI 一个能调用的工具、把你自己的发送包成一个可以 import 的函数,用这一切去回一封真邮件,最后把收件箱读出声来。三十二个小步骤。

🇬🇧 English

An email is just text with a few labels. Who it is from. Who it is for. What it is about.

Python writes those labels. Then it hands the email to a mail server. That takes about fifteen lines.

You will build it in four steps. Each step is a complete file. You can run every one of them.

🇹🇭 ไทย

อีเมลก็คือข้อความที่มีป้ายกำกับไม่กี่อย่าง ใครส่ง ส่งถึงใคร เรื่องอะไร

Python เขียนป้ายพวกนั้นได้ แล้วส่งอีเมลให้เซิร์ฟเวอร์เมล ใช้โค้ดราวสิบห้าบรรทัด

คุณจะสร้างมันสี่ขั้น แต่ละขั้นเป็นไฟล์ที่สมบูรณ์ และรันได้ทุกขั้น

🇨🇳 中文

一封邮件就是带几个标签的文字:谁发的、发给谁、关于什么。

Python 可以写好这些标签,再把邮件交给邮件服务器,大约十五行代码。

你将分四步建好它。每一步都是一个完整的文件,每一步都能运行。

1 🔑 What you need · ต้องมีอะไรบ้าง · 你需要什么

🇬🇧 English

You need Python. You need an email account a program can log in to.

Do not use your normal password. Make an app password instead. It is a separate password, just for programs. You can cancel it on its own.

Then put your address and that password in your terminal. They stay out of your code.

🇹🇭 ไทย

คุณต้องมี Python และบัญชีอีเมลที่ล็อกอินจากโปรแกรมได้

อย่าใช้รหัสผ่านปกติ ให้สร้าง app password แทน มันคือรหัสผ่านแยกสำหรับโปรแกรม และยกเลิกเฉพาะตัวมันได้

  • Gmail: เปิด 2-Step Verification ก่อน แล้วสร้าง app password ที่ myaccount.google.com/apppasswords
  • Yahoo: Account security → Generate app password

จากนั้นใส่อีเมลและรหัสผ่านนั้นไว้ใน terminal ทั้งสองอย่างจะไม่อยู่ในโค้ดของคุณ

🇨🇳 中文

你需要 Python,以及一个程序能登录的邮箱账号。

不要用你平时的密码。去生成一个应用专用密码:那是给程序用的单独密码,可以单独作废。

然后把邮箱地址和这个密码设在终端里,它们不会出现在你的代码里。

⌨️ TERMINAL · Windows — set them, then keep this window open

set MAIL_USER=you@example.com
set MAIL_PASS=your-app-password

⌨️ TERMINAL · Mac and Linux

export MAIL_USER=you@example.com
export MAIL_PASS=your-app-password
CAREFUL These settings live only in that terminal window. Close it and they are gone. So run your program in the same window. Type the app password with no spaces. Gmail shows it in four groups anyway. ค่าพวกนี้อยู่แค่ในหน้าต่าง terminal นั้น ปิดแล้วหายไป จึงต้องรันโปรแกรมในหน้าต่างเดียวกัน และพิมพ์ app password โดยไม่ใส่ช่องว่าง แม้ Gmail จะแสดงเป็นกลุ่มละสี่ตัว 这些设置只存在于那个终端窗口里,关掉就没了,所以要在同一个窗口运行程序。应用专用密码输入时不要带空格,即使 Gmail 分成四个一组显示。

2 🧱 The shape of the program · โครงของโปรแกรม · 程序的骨架

🇬🇧 English

Start with the shape of the program, before it does anything.

Three imports. One function called main. Two lines at the bottom to run it.

Write the shape first and you always have a working file. You only add to the inside of main() after this.

🇹🇭 ไทย

เริ่มจากโครงของโปรแกรมก่อน ตอนนี้มันยังไม่ทำอะไร

มี import สามตัว ฟังก์ชันชื่อ main หนึ่งตัว และสองบรรทัดท้ายไฟล์ที่สั่งให้มันทำงาน

เขียนโครงก่อน แล้วคุณจะมีไฟล์ที่รันได้เสมอ หลังจากนี้คุณเพิ่มโค้ดแค่ข้างใน main()

🇨🇳 中文

先写出程序的骨架,这时它还什么都不做。

三个 import、一个叫 main 的函数,再加文件末尾那两行来运行它。

先搭骨架,你就始终有一个能运行的文件。之后你只往 main() 里面添东西。

📄 NEW FILE · send1.py — make it in a folder of your own, then copy in all of this code

"""send1.py — the shape of the program, before it sends anything."""
import os
import smtplib
from email.message import EmailMessage


def main():
    """Everything this program does happens in here."""
    print("ready")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
import osLoad os. It reads the settings you typed in the terminal.โหลด os ใช้อ่านค่าที่คุณพิมพ์ไว้ใน terminal载入 os,用来读取你在终端里设置的值。
import smtplibLoad the tools that send email. SMTP is the language mail servers speak.โหลดเครื่องมือส่งอีเมล SMTP คือภาษาที่เซิร์ฟเวอร์เมลใช้คุยกัน载入发邮件的工具。SMTP 是邮件服务器之间说的语言。
from email.message import EmailMessageLoad EmailMessage. It builds the email: from, to, subject, text.โหลด EmailMessage ใช้สร้างอีเมล ทั้งผู้ส่ง ผู้รับ หัวเรื่อง และข้อความ载入 EmailMessage,用来组装邮件:发件人、收件人、主题、正文。
def main():Start a function called main. All your work goes inside it.เริ่มฟังก์ชันชื่อ main งานทั้งหมดของคุณอยู่ข้างใน定义一个叫 main 的函数,你所有的代码都写在里面。
print("ready")For now it prints one word. That shows the file runs.ตอนนี้มันพิมพ์คำเดียว เพื่อให้คุณตรวจว่าไฟล์รันได้现在它只打印一个词,好让你确认文件能运行。
if __name__ == "__main__": main()Run main() when you start this file. Does another file import yours? Python skips these lines. Your program does not start by surprise.สั่งให้ main() ทำงานเมื่อคุณรันไฟล์นี้ ถ้าไฟล์อื่น import ไฟล์ของคุณ Python จะข้ามสองบรรทัดนี้ โปรแกรมจึงไม่เริ่มทำงานเอง运行这个文件时才执行 main()。如果别的文件 import 你的文件,Python 会跳过这两行,程序就不会莫名其妙启动。
YOUR EDITOR MAY COMPLAIN The three imports are not used yet. Some editors grey them out. Some warn imported but unused. Python does not mind. You use all three by step 5. ตอนนี้ import ทั้งสามยังไม่ถูกใช้ โปรแกรมแก้ไขข้อความบางตัวจึงทำให้จางลงหรือเตือนว่า imported but unused Python ไม่ว่าอะไร และพอถึงขั้นที่ 5 คุณจะได้ใช้ครบทั้งสาม 这三个 import 现在还没用上,有些编辑器会把它们变灰或提示 imported but unused。Python 不介意,到第 5 步你就会全部用上。
YOUR TURN
  1. Make a folder for this lesson. Create a file called send1.py in it.
  2. Copy all the code above into the file.
  3. Open a terminal in that folder. Run python send1.py.
  4. Check: it prints ready.
  1. สร้างโฟลเดอร์สำหรับบทเรียนนี้ แล้วสร้างไฟล์ชื่อ send1.py ไว้ข้างใน
  2. คัดลอกโค้ดข้างบนทั้งหมดใส่ไฟล์
  3. เปิด terminal ในโฟลเดอร์นั้น แล้วรัน python send1.py
  4. ตรวจ: มันพิมพ์คำว่า ready
  1. 给这节课建一个文件夹,在里面新建 send1.py
  2. 把上面的代码全部复制进去。
  3. 在那个文件夹里打开终端,运行 python send1.py
  4. 检查:它打印出 ready

3 📦 Put the parts in variables · เก็บแต่ละส่วนไว้ในตัวแปร · 把各部分放进变量

🇬🇧 English

An email has four parts. Who it is from. Who it is for. What it is about. What it says.

Put each part in its own variable, inside main(). A variable is a name for a piece of information.

Your address comes from the terminal, with os.environ. The other three are written straight into the code for now. That is called hard-coding.

🇹🇭 ไทย

อีเมลมีสี่ส่วน ใครส่ง ส่งถึงใคร เรื่องอะไร และข้อความว่าอะไร

เก็บแต่ละส่วนไว้ในตัวแปรของมันเอง ข้างใน main() ตัวแปรคือชื่อที่ใช้เรียกข้อมูลหนึ่งชิ้น

อีเมลของคุณมาจาก terminal ด้วย os.environ ส่วนอีกสามอย่างเขียนลงในโค้ดตรง ๆ ไปก่อน วิธีนี้เรียกว่า hard-code

🇨🇳 中文

一封邮件有四个部分:谁发的、发给谁、关于什么、写了什么。

把每一部分放进自己的变量里,写在 main() 内。变量就是给一条信息起的名字。

你的邮箱地址用 os.environ 从终端取得,另外三个先直接写在代码里,这叫 hard-code(硬编码)

📄 NEW FILE · send2.py — next to send1.py

"""send2.py — put the parts of an email in variables."""
import os
import smtplib
from email.message import EmailMessage


def main():
    """Decide who the email is for, and what it says."""
    sender = os.environ["MAIL_USER"]        # your address, from the terminal
    to = "someone@example.com"              # change this to your own address
    subject = "Hello from Python"
    body = "This email was sent by a Python program."

    print("from:   ", sender)
    print("to:     ", to)
    print("subject:", subject)
    print("body:   ", body)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
sender = os.environ["MAIL_USER"]Read MAIL_USER from the terminal and keep it in sender. Square brackets mean: this must exist, or stop with an error.อ่าน MAIL_USER จาก terminal แล้วเก็บไว้ใน sender วงเล็บเหลี่ยมแปลว่า ต้องมีค่านี้ ไม่งั้นหยุดพร้อม error从终端读取 MAIL_USER,存进 sender。方括号表示:必须有这个值,否则就报错停下。
to = "someone@example.com"Who gets the email. Change it to your own address, so you can test safely.คนที่จะได้รับอีเมล เปลี่ยนเป็นอีเมลของคุณเอง จะได้ทดสอบอย่างปลอดภัย谁收这封邮件。改成你自己的地址,这样测试才安全。
subject = "Hello from Python"The line the person sees in their inbox.บรรทัดที่ผู้รับเห็นในกล่องจดหมาย对方在收件箱里看到的那一行。
body = "This email was sent by a Python program."The message itself.ตัวข้อความ邮件正文本身。
print("from: ", sender)Print each one, to check it holds what you think.พิมพ์ออกมาทีละอย่าง เพื่อตรวจว่าค่าตรงกับที่คิดไว้逐个打印出来,确认里面装的就是你以为的内容。
Hard-codedinput()
Who decidesใครเป็นคนกำหนด谁来决定You, when you write the fileคุณ ตอนเขียนไฟล์你,在写文件的时候The person, each time it runsผู้ใช้ ทุกครั้งที่รัน使用者,每次运行时
Good forเหมาะกับ适合The same email every time: a robot, a daily reportอีเมลเดิมทุกครั้ง เช่น บอท หรือรายงานประจำวัน每次都一样的邮件:机器人、每日报告A different email each timeอีเมลที่ต่างกันทุกครั้ง每次都不一样的邮件
Runs by itselfรันเองได้ไหม能自己运行吗Yesได้可以No. It waits for a person to typeไม่ได้ ต้องรอคนพิมพ์不行,它要等人输入
YOUR TURN
  1. Create send2.py and copy all the code in. Change to to your own address.
  2. Run python send2.py.
  3. Check: it prints the four parts. Your own address is next to from:.
  4. If it fails: KeyError: MAIL_USER means this terminal has no settings. Go back to section 1.
  1. สร้าง send2.py แล้วคัดลอกโค้ดทั้งหมดใส่ เปลี่ยน to เป็นอีเมลของคุณเอง
  2. รัน python send2.py
  3. ตรวจ: มันพิมพ์สี่ส่วน และมีอีเมลของคุณอยู่ข้างหลัง from:
  4. ถ้าไม่ผ่าน: KeyError: MAIL_USER แปลว่า terminal นี้ยังไม่มีค่าที่ตั้งไว้ ให้กลับไปหัวข้อ 1
  1. 新建 send2.py,把代码全部复制进去,把 to 改成你自己的地址。
  2. 运行 python send2.py
  3. 检查:它打印出四个部分,from: 后面是你自己的地址。
  4. 如果出错:KeyError: MAIL_USER 表示这个终端还没设置。回到第 1 节。

4 ⌨️ Ask the person instead: input() · ถามผู้ใช้แทน: input() · 改成问使用者:input()

🇬🇧 English

Hard-coded text suits a program that always sends the same thing. To send something different, ask the person.

input() prints your question. It waits for the person to type. Then it gives back what they typed, always as text.

Only three lines change. The rest of the file stays as it was.

🇹🇭 ไทย

ข้อความที่เขียนตายตัวใช้ได้ดีกับโปรแกรมที่ส่งของเดิมทุกครั้ง ถ้าอยากส่งของที่ต่างออกไป ให้ถามผู้ใช้

input() พิมพ์คำถามของคุณ รอให้ผู้ใช้พิมพ์ แล้วคืนสิ่งที่เขาพิมพ์กลับมา และคืนกลับมาเป็นข้อความเสมอ

เปลี่ยนแค่สามบรรทัด ที่เหลือเหมือนเดิม

🇨🇳 中文

写死的文字适合每次都发同样内容的程序。想发不一样的,就问使用者。

input() 会打印你的问题、等人输入,再把输入的内容返回,而且总是返回文字

只有三行变了,文件其余部分保持不变。

📄 NEW FILE · send3.py — next to the others

"""send3.py — ask the person for the parts of the email."""
import os
import smtplib
from email.message import EmailMessage


def main():
    """Ask who the email is for, and what it says."""
    sender = os.environ["MAIL_USER"]
    to = input("send to: ")
    subject = input("subject: ")
    body = input("message: ")

    print("from:   ", sender)
    print("to:     ", to)
    print("subject:", subject)
    print("body:   ", body)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
to = input("send to: ")Print send to: and wait. What the person types is kept in to.พิมพ์ send to: แล้วรอ สิ่งที่ผู้ใช้พิมพ์จะถูกเก็บไว้ใน to打印 send to: 然后等待,使用者输入的内容存进 to
subject = input("subject: ")Ask what the email is about.ถามว่าอีเมลเรื่องอะไร问这封邮件的主题。
body = input("message: ")Ask what it should say. This is one line only: Enter ends it.ถามว่าจะเขียนอะไร ได้บรรทัดเดียว กด Enter คือจบ问正文写什么。只能一行,按回车就结束。
THE TRAP input() always gives back text, never a number. It checks nothing. Type a name where an address belongs. Your program still tries to send. It fails later, at the server. That error is harder to read than your own check. input() คืนค่าเป็นข้อความเสมอ ไม่ใช่ตัวเลข และมันตรวจอะไรให้ไม่ได้ ถ้าพิมพ์ชื่อคนแทนที่อยู่อีเมล โปรแกรมก็จะพยายามส่งไปให้ แล้วไปล้มทีหลังที่เซิร์ฟเวอร์ พร้อมข้อความที่อ่านยากกว่าการตรวจเอง input() 永远返回文字,不是数字。它也不会替你检查:在该填地址的地方输入一个名字,程序照样会去发,然后在服务器那边失败,报出比你自己检查更难读的信息。
YOUR TURN
  1. Create send3.py and copy all the code in.
  2. Run python send3.py. Type an address, a subject and a message, pressing Enter after each.
  3. Check: it prints back what you typed.
  4. Run it again and answer differently. The file did not change, but the email did.
  1. สร้าง send3.py แล้วคัดลอกโค้ดทั้งหมดใส่
  2. รัน python send3.py พิมพ์อีเมล หัวเรื่อง และข้อความ กด Enter หลังแต่ละอัน
  3. ตรวจ: มันพิมพ์สิ่งที่คุณเพิ่งพิมพ์กลับมา
  4. รันอีกครั้งแล้วตอบต่างออกไป ไฟล์ไม่เปลี่ยน แต่อีเมลเปลี่ยน
  1. 新建 send3.py,把代码全部复制进去。
  2. 运行 python send3.py。输入地址、主题和正文,每个后面按回车。
  3. 检查:它把你输入的内容打印回来。
  4. 再运行一次,换不同的答案。文件没变,邮件变了。

5 📤 Build it and send it · สร้างแล้วส่ง · 组装并发送

🇬🇧 English

Now build the email and hand it to the server. This is the whole thing, as small as it goes.

EmailMessage() makes an empty email. You set three labels and the text. Then SMTP_SSL opens a safe connection to your mail server. It logs in and sends.

Port 465 is the encrypted one. Gmail and Yahoo both accept it.

🇹🇭 ไทย

ตอนนี้มาสร้างอีเมลแล้วส่งให้เซิร์ฟเวอร์ นี่คือทั้งหมด เล็กที่สุดเท่าที่จะทำได้

EmailMessage() สร้างอีเมลเปล่า คุณใส่ป้ายสามอย่างกับตัวข้อความ จากนั้น SMTP_SSL เปิดการเชื่อมต่อแบบเข้ารหัสไปยังเซิร์ฟเวอร์เมล ล็อกอิน แล้วส่ง

พอร์ต 465 คือพอร์ตที่เข้ารหัส ทั้ง Gmail และ Yahoo รับ

🇨🇳 中文

现在来组装邮件并交给服务器。这就是全部,已经不能更小了。

EmailMessage() 新建一封空邮件,你给它设三个标签和正文。然后 SMTP_SSL 和邮件服务器建立加密连接、登录、发送。

465 是加密端口,Gmail 和 Yahoo 都接受。

📄 NEW FILE · send4.py — the whole program

"""send4.py — build the email and send it."""
import os
import smtplib
from email.message import EmailMessage

SMTP_HOST = "smtp.gmail.com"


def main():
    """Ask what to send, build the email, then send it."""
    sender = os.environ["MAIL_USER"]
    password = os.environ["MAIL_PASS"]
    to = input("send to: ")
    subject = input("subject: ")
    body = input("message: ")

    message = EmailMessage()
    message["From"] = sender
    message["To"] = to
    message["Subject"] = subject
    message.set_content(body)

    with smtplib.SMTP_SSL(SMTP_HOST, 465) as server:
        server.login(sender, password)
        server.send_message(message)

    print("sent to", to)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
SMTP_HOST = "smtp.gmail.com"The server that sends your mail. This page assumes Gmail.เซิร์ฟเวอร์ที่ใช้ส่งอีเมลของคุณ หน้านี้ถือว่าคุณใช้ Gmail负责发信的服务器。本页假设你用 Gmail。
password = os.environ["MAIL_PASS"]Read the app password from the terminal. It is never written in the file. So the file is safe to share.อ่าน app password จาก terminal ไม่เขียนลงในไฟล์ คุณจึงแชร์ไฟล์นี้ได้从终端读取应用专用密码。它不会写进文件,所以文件可以分享。
message = EmailMessage()Make an empty email.สร้างอีเมลเปล่า新建一封空邮件。
message["From"] = sender message["To"] = to message["Subject"] = subjectSet the three labels. These are what the other person sees.ใส่ป้ายสามอย่าง นี่คือสิ่งที่อีกฝ่ายมองเห็น设好三个标签,这就是对方看到的内容。
message.set_content(body)Put the text inside the email.ใส่ตัวข้อความลงในอีเมล把正文放进邮件里。
with smtplib.SMTP_SSL(SMTP_HOST, 465) as server:Open an encrypted connection to the server. with closes it at the end, even if sending fails.เปิดการเชื่อมต่อแบบเข้ารหัสไปยังเซิร์ฟเวอร์ with ปิดให้เองเมื่อจบ แม้การส่งจะล้มเหลว和服务器建立加密连接。with 会在结束时关闭它,即使发送失败也一样。
server.login(sender, password)Log in with your address and the app password.ล็อกอินด้วยอีเมลและ app password用你的地址和应用专用密码登录。
server.send_message(message)Send it. This is the line that cannot be taken back.ส่ง บรรทัดนี้คือบรรทัดที่เรียกคืนไม่ได้发出去。这一行是收不回来的。
print("sent to", to)Say so, so you know it finished.บอกให้รู้ว่าเสร็จแล้ว打印一句,让你知道发完了。
SEND IT TO YOURSELF FIRST Put your own address in, every time, until the email looks right. An email cannot be taken back. Never test on a class, or on someone you do not know. That is how a small mistake becomes a public one. ใส่อีเมลของคุณเองก่อนทุกครั้ง จนกว่าอีเมลจะออกมาถูกต้อง อีเมลที่ส่งแล้วเรียกคืนไม่ได้ การส่งข้อความทดสอบไปทั้งห้อง หรือไปหาคนที่คุณไม่รู้จัก คือวิธีที่ความผิดพลาดเล็ก ๆ กลายเป็นเรื่องใหญ่ 先填你自己的地址,一直到邮件看起来没问题为止。邮件寄出去就收不回来。把测试邮件发给全班或不认识的人,正是小错变成大事的方式。
YOUR TURN
  1. Create send4.py and copy all the code in. Using Yahoo? Change SMTP_HOST.
  2. Run python send4.py. Answer with your own address.
  3. Check: it prints sent to …. The email reaches your inbox a moment later.
  4. If it fails: see the table below.
  1. สร้าง send4.py แล้วคัดลอกโค้ดทั้งหมดใส่ ถ้าใช้ Yahoo ให้เปลี่ยน SMTP_HOST
  2. รัน python send4.py แล้วตอบด้วยอีเมลของคุณเอง
  3. ตรวจ: มันพิมพ์ sent to … แล้วอีกครู่อีเมลจะอยู่ในกล่องจดหมายของคุณ
  4. ถ้าไม่ผ่าน: ดูตารางข้างล่าง
  1. 新建 send4.py,把代码全部复制进去。用 Yahoo 的话改 SMTP_HOST
  2. 运行 python send4.py,填你自己的地址。
  3. 检查:它打印 sent to …,过一会儿邮件就出现在你的收件箱里。
  4. 如果出错:看下面的表格。

6 🎨 Use an HTML file as the body · ใช้ไฟล์ HTML เป็นตัวข้อความ · 用 HTML 文件当正文

🇬🇧 English

So far the body has been plain text. An email can carry a second version, written in HTML.

Write that version in a file, read the file, and add it with add_alternative().

Send both. The mail program shows the HTML one, and falls back to the plain one when it cannot. A blind reader's software often prefers the plain one too.

🇹🇭 ไทย

ที่ผ่านมาตัวข้อความเป็นข้อความธรรมดา แต่อีเมลพกเวอร์ชันที่สองได้ คือเวอร์ชันที่เขียนด้วย HTML

เขียนเวอร์ชันนั้นไว้ในไฟล์ อ่านไฟล์ แล้วเพิ่มเข้าไปด้วย add_alternative()

ส่งไปทั้งสองแบบ โปรแกรมอีเมลจะแสดงแบบ HTML และถอยไปใช้แบบธรรมดาเมื่อแสดงไม่ได้ โปรแกรมอ่านหน้าจอของผู้พิการทางสายตาก็มักเลือกแบบธรรมดา

🇨🇳 中文

到目前为止正文都是纯文本。其实一封邮件可以再带一个版本:用 HTML 写的。

把那个版本写进一个文件,读出来,再用 add_alternative() 加进邮件。

两个都发。邮件程序会显示 HTML 版,显示不了就退回纯文本版。读屏软件通常也更喜欢纯文本版。

📄 NEW FILE · letter.html — the body, as a web page

<!doctype html>
<html>
  <body style="font-family: Georgia, serif; color: #0e3d43;">
    <h2 style="color:#1f6f78;">Sawadee from Chiang Mai</h2>
    <p>This body came from a <strong>file on disk</strong>.</p>
    <p style="background:#e7f2f3; padding:10px; border-radius:8px;">
      Styles must be written on the tag itself, like this one.
    </p>
  </body>
</html>

📄 NEW FILE · send5.py

"""send5.py — use an HTML file as the body of the email."""
import os
import smtplib
from email.message import EmailMessage
from pathlib import Path

SMTP_HOST = "smtp.gmail.com"


def main():
    """Send one email twice over: once as plain text, once as HTML."""
    sender = os.environ["MAIL_USER"]
    password = os.environ["MAIL_PASS"]
    to = input("send to: ")

    html = Path("letter.html").read_text(encoding="utf-8")

    message = EmailMessage()
    message["From"] = sender
    message["To"] = to
    message["Subject"] = "An HTML letter from Python"

    # The plain text comes first: it is what old programs and screen readers show.
    message.set_content("This email has an HTML part. Your mail program is showing the plain one.")
    # Then the same email again, as HTML. The mail program picks the last one it can read.
    message.add_alternative(html, subtype="html")

    with smtplib.SMTP_SSL(SMTP_HOST, 465) as server:
        server.login(sender, password)
        server.send_message(message)

    print("sent to", to)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
html = Path("letter.html").read_text(encoding="utf-8")Read the whole HTML file into one string.อ่านไฟล์ HTML ทั้งไฟล์มาเก็บเป็นข้อความก้อนเดียว把整个 HTML 文件读成一个字符串。
message.set_content("This email has an HTML part. …")The plain text version. Write it first, and write it properly: some people only ever see this one.เวอร์ชันข้อความธรรมดา เขียนก่อน และเขียนให้ดี เพราะบางคนเห็นแค่เวอร์ชันนี้纯文本版本。先写它,而且要认真写:有些人只会看到这一版。
message.add_alternative(html, subtype="html")Add the same email again, as HTML. subtype="html" is what says it is a web page, not text.เพิ่มอีเมลเดิมอีกครั้งในรูปแบบ HTML subtype="html" คือสิ่งที่บอกว่านี่เป็นหน้าเว็บ ไม่ใช่ข้อความธรรมดา把同一封邮件再加一遍,这次是 HTML。subtype="html" 说明这是网页,不是纯文字。

🇬🇧 English

That makes one email holding two versions:

multipart/alternative
  text/plain
  text/html

The reader's program picks. Order matters: plain first, HTML last.

🇹🇭 ไทย

ผลคืออีเมลหนึ่งฉบับที่มีสองเวอร์ชันอยู่ข้างใน

multipart/alternative
  text/plain
  text/html

โปรแกรมของผู้รับเป็นคนเลือก ลำดับสำคัญ ข้อความธรรมดาก่อน HTML ทีหลัง

🇨🇳 中文

这样一封邮件里就装了两个版本:

multipart/alternative
  text/plain
  text/html

由收件人的程序来挑。顺序有讲究:纯文本在前,HTML 在后。

EMAIL IS NOT A BROWSER Mail programs throw most of a web page away. Put your styles on the tag itself, as style="…", because a <style> block is often removed. JavaScript never runs. Pictures from the internet are usually blocked until the reader allows them. Keep the page simple, and test it on a phone. โปรแกรมอีเมลตัดทิ้งเกือบทุกอย่างของหน้าเว็บ ให้ใส่สไตล์ไว้บนแท็กโดยตรงแบบ style="…" เพราะบล็อก <style> มักถูกลบ JavaScript ไม่ทำงานเลย รูปจากอินเทอร์เน็ตมักถูกบล็อกจนกว่าผู้อ่านจะอนุญาต ทำหน้าให้เรียบง่าย แล้วลองดูบนโทรศัพท์ 邮件程序会丢掉网页的大部分东西。样式要直接写在标签上,也就是 style="…",因为 <style> 块常被删掉。JavaScript 根本不会运行。网上的图片通常要等读者允许才显示。页面越简单越好,并在手机上试一遍。
YOUR TURN
  1. Create letter.html and send5.py. Copy both in.
  2. Run python send5.py and send it to yourself.
  3. Check: the email has a heading, bold text and a coloured box.
  4. Now open the same email on your phone. It should still read well.
  5. Curious? In Gmail, open the menu and choose Show original. You will see both parts.
  1. สร้าง letter.html และ send5.py แล้วคัดลอกโค้ดทั้งสองใส่
  2. รัน python send5.py แล้วส่งหาตัวเอง
  3. ตรวจ: อีเมลมีหัวข้อ ตัวหนา และกล่องสี
  4. จากนั้นเปิดอีเมลฉบับเดียวกันบนโทรศัพท์ ควรอ่านได้ดีเหมือนกัน
  5. อยากรู้เพิ่ม ใน Gmail เปิดเมนูแล้วเลือก Show original คุณจะเห็นทั้งสองส่วน
  1. 新建 letter.htmlsend5.py,把两份代码都复制进去。
  2. 运行 python send5.py,发给你自己。
  3. 检查:邮件里有标题、粗体字和一个带颜色的方框。
  4. 再在手机上打开同一封邮件,应该照样好读。
  5. 想深入看看?在 Gmail 里打开菜单选 Show original,两个部分都能看到。

📁 Tutorial 2 — Working with files · บทที่ 2 — ทำงานกับไฟล์ · 教程 2 —— 处理文件

🇬🇧 English

A program that only prints forgets everything when it stops. A file is its memory.

Four steps again. Write a file. Read it back. Sort files into folders. Then email one.

🇹🇭 ไทย

โปรแกรมที่พิมพ์ออกจอเฉย ๆ จะลืมทุกอย่างเมื่อหยุดทำงาน ไฟล์คือวิธีที่มันจดจำ

สี่ขั้นอีกครั้ง เขียนไฟล์ อ่านกลับมา จัดไฟล์ลงโฟลเดอร์ แล้วส่งไฟล์หนึ่งไปทางอีเมลเป็นไฟล์แนบ

🇨🇳 中文

只会打印的程序,一停就把一切都忘了。文件就是它记住东西的方式。

还是四步:写一个文件、读回来、把文件放进文件夹,最后把一个文件作为附件寄出去。

7 📝 Write a file, read it back · เขียนไฟล์ แล้วอ่านกลับ · 写一个文件,再读回来

🇬🇧 English

Path is a file name that knows how to do things. Name it, then ask it to write or read.

Two lines do the work. write_text() puts text in. read_text() takes it out.

Always say encoding="utf-8". It lets your file hold Thai, Chinese, or an emoji.

🇹🇭 ไทย

Path คือชื่อไฟล์ที่ทำอะไรเป็น ตั้งชื่อให้มัน แล้วสั่งให้เขียนหรืออ่าน

สองบรรทัดก็พอ write_text() ใส่ข้อความลงไป read_text() ดึงข้อความออกมา

ใส่ encoding="utf-8" เสมอ นั่นคือสิ่งที่ทำให้ไฟล์เก็บภาษาไทย จีน หรืออิโมจิได้

🇨🇳 中文

Path是一个会做事的文件名:给它一个名字,然后叫它写或读。

两行就够了:write_text() 把文字放进去,read_text() 把文字取出来。

永远写上 encoding="utf-8"。有了它,文件才装得下中文、泰文或表情符号。

📄 NEW FILE · file1.py — in the same folder as before

"""file1.py — write a file, then read it back."""
from pathlib import Path


def main():
    """Make a file, put one line in it, and read that line back."""
    note = Path("note.txt")
    note.write_text("Sawadee from Chiang Mai\n", encoding="utf-8")
    print("wrote", note, "-", note.stat().st_size, "bytes")

    text = note.read_text(encoding="utf-8")
    print("read back:", text.strip())


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
from pathlib import PathLoad Path. It is Python's way of naming a file.โหลด Path เป็นวิธีที่ Python ใช้เรียกชื่อไฟล์载入 Path,这是 Python 给文件起名字的方式。
note = Path("note.txt")Name the file. Nothing is created yet.ตั้งชื่อไฟล์ ตอนนี้ยังไม่มีไฟล์เกิดขึ้น先给文件起个名字,这时还没有创建文件。
note.write_text("Sawadee from Chiang Mai\n", encoding="utf-8")Write the text. This makes the file, or empties an old one. \n ends the line.เขียนข้อความลงไป คำสั่งนี้สร้างไฟล์ หรือล้างไฟล์เดิมให้ว่าง ส่วน \n คือจบบรรทัด写入文字。这会创建文件,或者把旧文件清空\n 表示换行。
print("wrote", note, "-", note.stat().st_size, "bytes")stat() asks the computer about the file. Here, its size.stat() ถามคอมพิวเตอร์เกี่ยวกับไฟล์ ตรงนี้คือถามขนาดstat() 向电脑询问这个文件的信息,这里问的是大小。
text = note.read_text(encoding="utf-8")Read the whole file back, as one string.อ่านทั้งไฟล์กลับมาเป็นข้อความก้อนเดียว把整个文件读回来,成为一个字符串。
print("read back:", text.strip())strip() removes the new line at the end.strip() ตัดการขึ้นบรรทัดใหม่ท้ายข้อความออกstrip() 去掉末尾的换行。
THE TRAP write_text() does not add to a file. It replaces everything. Run it twice, and the first text is gone. Step 8 shows how to add instead. write_text() ไม่ได้เขียนต่อท้าย แต่เขียนทับทั้งหมด รันสองครั้ง ข้อความแรกก็หายไป ขั้นที่ 8 จะแสดงวิธีเขียนเพิ่ม write_text() 不是追加,而是整个替换。运行两次,第一次的内容就没了。第 8 步会讲怎么追加。
YOUR TURN
  1. Create file1.py and copy all the code in. Run python file1.py.
  2. Check: it prints the size, then your line. A file called note.txt is now in the folder.
  3. Open that file in your editor. The text is there.
  4. Change the words, run it again, and look at the file. The old text is gone.
  1. สร้าง file1.py คัดลอกโค้ดทั้งหมดใส่ แล้วรัน python file1.py
  2. ตรวจ: มันพิมพ์ขนาดไฟล์ ตามด้วยบรรทัดของคุณ และมีไฟล์ note.txt อยู่ในโฟลเดอร์แล้ว
  3. เปิดไฟล์นั้นด้วยโปรแกรมแก้ไขข้อความ ข้อความอยู่ในนั้น
  4. เปลี่ยนข้อความ รันอีกครั้ง แล้วดูไฟล์ ข้อความเดิมหายไปแล้ว
  1. 新建 file1.py,把代码全部复制进去,运行 python file1.py
  2. 检查:它打印出大小和你写的那行,文件夹里出现了 note.txt
  3. 用编辑器打开这个文件,文字就在里面。
  4. 改几个字再运行一次,然后看文件:旧内容不见了。

8 ➕ open(), and adding to a file · open() และการเขียนต่อท้าย · open(),以及往文件里追加

🇬🇧 English

open() is the older way. It is still the best way, one line at a time.

The letter says what you want. "w" writes. "a" adds to the end. Nothing at all means read.

with closes the file for you at the end. It closes even after an error.

🇹🇭 ไทย

open() คือวิธีเก่ากว่า และยังเป็นวิธีที่ดีที่สุดเมื่ออ่านทีละบรรทัด

ตัวอักษรบอกว่าคุณต้องการอะไร "w" เขียนทับ "a" เขียนต่อท้าย ถ้าไม่ใส่เลยคืออ่าน

with ปิดไฟล์ให้คุณเมื่อจบ แม้โปรแกรมจะหยุดเพราะ error

🇨🇳 中文

open() 是更老的写法,一次读一行时它仍然最好用。

那个字母表示你想做什么:"w" 覆盖写,"a" 追加,什么都不写就是读。

with 会在结束时替你关掉文件,即使程序因为报错而停下。

📄 NEW FILE · file2.py

"""file2.py — write lines, add one more, then read them one at a time."""


def main():
    """Build a shopping list, add to it, then print it with line numbers."""
    items = ["rice", "chillies", "lime"]

    with open("shopping.txt", "w", encoding="utf-8") as f:     # "w" empties the file
        for item in items:
            f.write(item + "\n")

    with open("shopping.txt", "a", encoding="utf-8") as f:     # "a" adds to the end
        f.write("coconut milk\n")

    with open("shopping.txt", encoding="utf-8") as f:          # no letter: read it
        for number, line in enumerate(f, start=1):
            print(number, line.strip())


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
with open("shopping.txt", "w", encoding="utf-8") as f:Open the file for writing. "w" empties it first. f is that open file.เปิดไฟล์เพื่อเขียน "w" ล้างไฟล์ก่อน ส่วน f คือไฟล์ที่เปิดอยู่以写入方式打开文件。"w" 会先清空它,f 就是打开着的文件。
for item in items: f.write(item + "\n")Write each item. write() adds no line break, so you add it.เขียนทีละรายการ write() ไม่ขึ้นบรรทัดใหม่ให้ คุณต้องใส่เอง逐项写入。write() 不会换行,所以要自己加。
with open("shopping.txt", "a", encoding="utf-8") as f:Open it again with "a". Now writing adds to the end, keeping what is there.เปิดอีกครั้งด้วย "a" คราวนี้การเขียนจะต่อท้าย และเก็บของเดิมไว้再用 "a" 打开一次。这次写入会追加到末尾,原有内容保留。
with open("shopping.txt", encoding="utf-8") as f:No letter: read it.ไม่ใส่ตัวอักษร แปลว่าอ่าน不写字母就是读。
for number, line in enumerate(f, start=1):Go through the file one line at a time. enumerate counts from 1.ไล่อ่านไฟล์ทีละบรรทัด enumerate นับให้ด้วย เริ่มจาก 1一行一行地读文件。enumerate 顺便计数,从 1 开始。
print(number, line.strip())Print the number and the line, without its line break.พิมพ์หมายเลขกับบรรทัด โดยตัดการขึ้นบรรทัดใหม่ออก打印行号和这一行,去掉换行符。
CAREFUL "w" and "a" are one letter apart. They do very different things. "w" on a file of student work empties it. Not sure? Read the file first. "w" กับ "a" ต่างกันตัวอักษรเดียว แต่ทำคนละอย่างมาก ถ้าใช้ "w" กับไฟล์งานนักเรียน ไฟล์นั้นจะว่างเปล่า ถ้าไม่แน่ใจ ให้อ่านไฟล์ก่อน "w""a" 只差一个字母,做的事却差很多。对着学生作业文件用 "w",文件就空了。不确定时,先读一遍。
YOUR TURN
  1. Create file2.py and run it.
  2. Check: it prints four numbered lines.
  3. Run it again. Still four lines: "w" cleared the file before writing.
  4. Now put a # in front of the three "w" lines. Run it twice. The list grows each time.
  1. สร้าง file2.py แล้วรัน
  2. ตรวจ: มันพิมพ์สี่บรรทัดพร้อมหมายเลข
  3. รันอีกครั้ง ยังได้สี่บรรทัด เพราะ "w" ล้างไฟล์ก่อนเขียน
  4. ลองใส่ # หน้าสามบรรทัดที่ใช้ "w" แล้วรันสองครั้ง รายการจะยาวขึ้นทุกครั้ง
  1. 新建 file2.py 并运行。
  2. 检查:它打印出四行带编号的内容。
  3. 再运行一次,还是四行:"w" 在写之前把文件清空了。
  4. 现在在用 "w" 的那三行前面加 #,运行两次,列表每次都会变长。

9 📂 Folders, and finding files · โฟลเดอร์ และการหาไฟล์ · 文件夹与查找文件

🇬🇧 English

Files belong in folders. Path handles both.

mkdir() makes a folder. The / sign joins a folder and a name. glob("*.txt") finds every text file.

This is how a program handles many files. One folder, one loop.

🇹🇭 ไทย

ไฟล์ควรอยู่ในโฟลเดอร์ Path จัดการได้ทั้งสองอย่าง

mkdir() สร้างโฟลเดอร์ เครื่องหมาย / เชื่อมโฟลเดอร์กับชื่อไฟล์ ส่วน glob("*.txt") หาไฟล์ข้อความทุกไฟล์ในนั้น

นี่คือวิธีที่โปรแกรมจัดการไฟล์จำนวนมาก โฟลเดอร์เดียว ลูปเดียว

🇨🇳 中文

文件应该放在文件夹里,Path 两者都能处理。

mkdir() 建文件夹,/ 把文件夹和文件名连起来,glob("*.txt") 找出里面所有文本文件。

程序处理很多文件就是这样:一个文件夹,一个循环。

📄 NEW FILE · file3.py

"""file3.py — make a folder, and find the files inside it."""
from pathlib import Path


def main():
    """Put two notes in a folder, then list what is there."""
    folder = Path("notes")
    folder.mkdir(exist_ok=True)                      # no error if it already exists

    (folder / "monday.txt").write_text("test on Friday\n", encoding="utf-8")
    (folder / "tuesday.txt").write_text("bring a pen\n", encoding="utf-8")

    print("folder:", folder.resolve())
    for path in sorted(folder.glob("*.txt")):        # every .txt file, in order
        print(" -", path.name, "|", path.read_text(encoding="utf-8").strip())

    missing = folder / "sunday.txt"
    print("sunday.txt exists:", missing.exists())


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
folder = Path("notes") folder.mkdir(exist_ok=True)Name a folder and make it. exist_ok=True means: fine if it exists.ตั้งชื่อโฟลเดอร์แล้วสร้าง exist_ok=True แปลว่า ถ้ามีอยู่แล้วก็ไม่เป็นไร给文件夹起名并创建。exist_ok=True 表示:已经存在也没关系。
(folder / "monday.txt").write_text("test on Friday\n", encoding="utf-8")/ joins the folder and the file name. The file lands inside the folder./ เชื่อมโฟลเดอร์กับชื่อไฟล์ ไฟล์จึงถูกสร้างในโฟลเดอร์นั้น/ 把文件夹和文件名连起来,文件就建在文件夹里面。
print("folder:", folder.resolve())resolve() shows the full path, so you can find it.resolve() แสดงที่อยู่เต็ม คุณจะได้หาเจอresolve() 显示完整路径,方便你找到它。
for path in sorted(folder.glob("*.txt")):glob finds files by pattern. * means any name. sorted orders them.glob หาไฟล์ตามรูปแบบ * แปลว่าชื่ออะไรก็ได้ ส่วน sorted เรียงลำดับให้glob 按模式找文件,* 表示任意名字,sorted 把它们排好序。
print(" -", path.name, "|", path.read_text(encoding="utf-8").strip())path.name is the file name without the folder.path.name คือชื่อไฟล์ ไม่รวมโฟลเดอร์path.name 是不含文件夹的文件名。
print("sunday.txt exists:", missing.exists())exists() asks before you read. It saves you a crash.exists() ถามก่อนอ่าน ช่วยให้โปรแกรมไม่พังexists() 在读之前先问一句,省得程序崩溃。
YOUR TURN
  1. Create file3.py and run it.
  2. Check: it prints the folder path, two lines with file names, and False.
  3. Open the notes folder and look at the two files.
  4. Add a third file yourself, then run it again. Your file is in the list.
  1. สร้าง file3.py แล้วรัน
  2. ตรวจ: มันพิมพ์ที่อยู่โฟลเดอร์ สองบรรทัดที่มีชื่อไฟล์ และคำว่า False
  3. เปิดโฟลเดอร์ notes แล้วดูไฟล์ทั้งสอง
  4. เพิ่มไฟล์ที่สามด้วยตัวเอง แล้วรันอีกครั้ง ไฟล์ของคุณจะอยู่ในรายการ
  1. 新建 file3.py 并运行。
  2. 检查:它打印出文件夹路径、两行文件名,以及 False
  3. 打开 notes 文件夹,看看那两个文件。
  4. 自己再放一个文件进去,重新运行,你的文件会出现在列表里。

10 📎 Attach a file to an email · แนบไฟล์ไปกับอีเมล · 把文件附在邮件上

🇬🇧 English

Now the two tutorials meet. Read a file from disk. Attach it to an email.

read_bytes() reads the file as raw bytes. An attachment needs bytes.

This code says every file is plain text. A PDF or a picture needs a different type.

🇹🇭 ไทย

ตอนนี้สองบทมาบรรจบกัน อ่านไฟล์จากดิสก์ แล้วแนบไปกับอีเมล

read_bytes() อ่านไฟล์เป็นไบต์ดิบ ซึ่งเป็นสิ่งที่ไฟล์แนบต้องการ

โค้ดนี้ถือว่าทุกไฟล์เป็นข้อความธรรมดา ถ้าเป็น PDF หรือรูปภาพ ต้องระบุชนิดอื่น

🇨🇳 中文

现在两个教程汇合了:从硬盘读一个文件,把它附在邮件上。

read_bytes() 以原始字节读取文件,附件需要的正是字节。

这段代码把每个文件都当成纯文本。如果是 PDF 或图片,就要写别的类型。

📄 NEW FILE · file4.py

"""file4.py — send a file as an attachment."""
import os
import smtplib
from email.message import EmailMessage
from pathlib import Path

SMTP_HOST = "smtp.gmail.com"


def main():
    """Ask for an address and a file, then email that file to them."""
    sender = os.environ["MAIL_USER"]
    password = os.environ["MAIL_PASS"]
    to = input("send to: ")
    attachment = Path(input("file to attach: "))

    message = EmailMessage()
    message["From"] = sender
    message["To"] = to
    message["Subject"] = "A file from Python"
    message.set_content("The file is attached.")
    message.add_attachment(attachment.read_bytes(), maintype="text", subtype="plain",
                           filename=attachment.name)

    with smtplib.SMTP_SSL(SMTP_HOST, 465) as server:
        server.login(sender, password)
        server.send_message(message)

    print("sent", attachment.name, "to", to)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
attachment = Path(input("file to attach: "))Ask for a file name. Turn the answer into a Path.ถามชื่อไฟล์ แล้วแปลงคำตอบเป็น Path问一个文件名,把答案变成 Path
message.add_attachment(attachment.read_bytes(), maintype="text", subtype="plain", filename=attachment.name)Read the file as bytes, then attach it. maintype and subtype say what kind of file it is. filename is the name the other person sees.อ่านไฟล์เป็นไบต์แล้วแนบไป maintype กับ subtype บอกว่าเป็นไฟล์ชนิดไหน ส่วน filename คือชื่อที่อีกฝ่ายเห็น把文件读成字节并附上。maintypesubtype 说明这是什么类型的文件,filename 是对方看到的名字。
IF YOU ATTACH SOMETHING ELSE A PDF needs maintype="application", subtype="pdf". A photo needs maintype="image", subtype="jpeg". Send a PDF with the text setting, and the file arrives broken. ไฟล์ PDF ต้องใช้ maintype="application", subtype="pdf" ส่วนรูปถ่ายใช้ maintype="image", subtype="jpeg" ถ้าส่ง PDF ด้วยค่าของไฟล์ข้อความ อีกฝ่ายจะได้ไฟล์ที่เปิดไม่ได้ PDF 要用 maintype="application", subtype="pdf",照片用 maintype="image", subtype="jpeg"。用文本的设置去发 PDF,对方收到的文件打不开。
YOUR TURN
  1. Create file4.py and run it.
  2. Send it to your own address. Attach note.txt from step 7.
  3. Check: the email arrives with the file attached. Open the attachment.
  4. If it fails: FileNotFoundError means that name is not in this folder.
  1. สร้าง file4.py แล้วรัน
  2. ส่งไปที่อีเมลของคุณเอง และแนบไฟล์ note.txt จากขั้นที่ 7
  3. ตรวจ: อีเมลมาถึงพร้อมไฟล์แนบ ลองเปิดไฟล์แนบดู
  4. ถ้าไม่ผ่าน: FileNotFoundError แปลว่าไม่มีไฟล์ชื่อนั้นในโฟลเดอร์นี้
  1. 新建 file4.py 并运行。
  2. 发到你自己的地址,附上第 7 步的 note.txt
  3. 检查:邮件带着附件到达,打开附件看看。
  4. 如果出错:FileNotFoundError 表示这个文件夹里没有你输入的那个文件。

📥 Tutorial 3 — Reading your inbox · บทที่ 3 — อ่านกล่องจดหมาย · 教程 3 —— 读你的收件箱

🇬🇧 English

Sending was half the job. Here is the other half. Find an email, and read it.

Sending uses SMTP. Reading uses IMAP. A different language, and a different server.

Everything here opens the mailbox read-only. Nothing can be changed or deleted.

🇹🇭 ไทย

การส่งเป็นแค่ครึ่งเดียว ตอนนี้มาถึงอีกครึ่ง คือหาอีเมลให้เจอ แล้วอ่านมัน

การส่งใช้ SMTP ส่วนการอ่านใช้ IMAP คนละภาษา และคนละชื่อเซิร์ฟเวอร์

ทุกอย่างในบทนี้เปิดกล่องจดหมายแบบ อ่านอย่างเดียว อีเมลของคุณจะไม่ถูกแก้หรือลบ

🇨🇳 中文

发送只是一半,现在是另一半:找到一封邮件,并把它读出来。

发信用 SMTP,读信用 IMAP:不同的语言,也是不同的服务器名字。

这一部分全部以只读方式打开邮箱,你的邮件不会被修改或删除。

11 🔌 Open the mailbox · เปิดกล่องจดหมาย · 打开邮箱

🇬🇧 English

Three steps to look inside a mailbox. Connect, log in, choose a folder.

Gmail needs one setting first. Open Settings in Gmail. Go to Forwarding and POP/IMAP. Turn IMAP on.

The same app password works for reading and for sending.

🇹🇭 ไทย

สามขั้นในการเปิดดูกล่องจดหมาย เชื่อมต่อ ล็อกอิน แล้วเลือกโฟลเดอร์

Gmail ต้องตั้งค่าอย่างหนึ่งก่อน เปิด Settings ใน Gmail ไปที่ Forwarding and POP/IMAP แล้วเปิด IMAP

app password ตัวเดิมใช้ได้ทั้งการอ่านและการส่ง

🇨🇳 中文

看邮箱要三步:连接、登录、选择文件夹。

Gmail 要先设置一次:打开 Gmail 的 Settings,进 Forwarding and POP/IMAP,把 IMAP 打开。

同一个应用专用密码,读信和发信都能用。

📄 NEW FILE · inbox1.py

"""inbox1.py — log in to your inbox and count what is in it."""
import imaplib
import os

IMAP_HOST = "imap.gmail.com"


def main():
    """Open the mailbox, count the emails, and close it again."""
    box = imaplib.IMAP4_SSL(IMAP_HOST)
    box.login(os.environ["MAIL_USER"], os.environ["MAIL_PASS"])

    box.select("INBOX", readonly=True)        # readonly: nothing can be changed
    _, ids = box.search(None, "ALL")
    print("emails in INBOX:", len(ids[0].split()))

    box.logout()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
IMAP_HOST = "imap.gmail.com"The server you read from. Sending used smtp.gmail.com.เซิร์ฟเวอร์ที่ใช้อ่าน ส่วนการส่งใช้ smtp.gmail.com读信用的服务器。发信用的是 smtp.gmail.com
box = imaplib.IMAP4_SSL(IMAP_HOST)Open a safe connection to it.เปิดการเชื่อมต่อแบบปลอดภัยไปหามัน和它建立安全连接。
box.login(os.environ["MAIL_USER"], os.environ["MAIL_PASS"])Log in with the same two settings as before.ล็อกอินด้วยค่าสองอย่างเดิม用之前那两个设置登录。
box.select("INBOX", readonly=True)Choose the folder. readonly=True means this program changes nothing.เลือกโฟลเดอร์ readonly=True แปลว่าโปรแกรมนี้แก้หรือลบอะไรไม่ได้选择文件夹。readonly=True 表示这个程序不能修改或删除任何东西。
_, ids = box.search(None, "ALL")Ask for every email. The answer is one long line of numbers. _ holds a status word we ignore.ขอทุกอีเมล คำตอบคือบรรทัดยาว ๆ ที่เป็นตัวเลข ส่วน _ เก็บคำบอกสถานะที่เราไม่ใช้要所有邮件。返回的是一长串数字,_ 接住一个我们用不到的状态词。
print("emails in INBOX:", len(ids[0].split()))split() turns that line into a list. Now you can count it.split() แปลงบรรทัดนั้นเป็นรายการ คุณจึงนับได้split() 把那一行变成列表,这样就能数了。
box.logout()Close the connection. Always do this.ปิดการเชื่อมต่อ ทำแบบนี้ทุกครั้ง关闭连接,每次都要做。
YOUR TURN
  1. Create inbox1.py and run it.
  2. Check: it prints how many emails are in your inbox.
  3. If it fails: Gmail needs IMAP turned on in settings. Use the app password, not your normal one.
  1. สร้าง inbox1.py แล้วรัน
  2. ตรวจ: มันพิมพ์จำนวนอีเมลในกล่องจดหมายของคุณ
  3. ถ้าไม่ผ่าน: Gmail ต้องเปิด IMAP ในหน้าตั้งค่า และต้องใช้ app password ไม่ใช่รหัสผ่านปกติ
  1. 新建 inbox1.py 并运行。
  2. 检查:它打印出你收件箱里有多少封邮件。
  3. 如果出错:Gmail 要在设置里打开 IMAP,而且要用应用专用密码,不是平时的密码。

13 📋 List what you found · แสดงรายการที่เจอ · 列出搜到的邮件

🇬🇧 English

Numbers alone are not useful. Ask the server for the headers.

A header is the small print at the top of an email. Who sent it. When. What it is about.

Ask for headers only. The answer is small and quick, even for a large email.

🇹🇭 ไทย

ตัวเลขเฉย ๆ ยังใช้ประโยชน์ไม่ได้ ให้ขอเฮดเดอร์จากเซิร์ฟเวอร์

เฮดเดอร์คือข้อความตัวเล็ก ๆ ด้านบนของอีเมล บอกว่าใครส่ง ส่งเมื่อไร และเรื่องอะไร

ถ้าขอแค่เฮดเดอร์ คำตอบจะเล็กและเร็ว แม้อีเมลจะใหญ่

🇨🇳 中文

光有编号没什么用,要向服务器要邮件头。

邮件头就是邮件最上面那几行小字:谁发的、什么时候、关于什么。

只要邮件头,返回的内容就又小又快,哪怕邮件很大。

📄 NEW FILE · inbox3.py

"""inbox3.py — list the newest emails that match a search."""
import email
import imaplib
import os

IMAP_HOST = "imap.gmail.com"


def main():
    """Print the sender, date and subject of the five newest matches."""
    box = imaplib.IMAP4_SSL(IMAP_HOST)
    box.login(os.environ["MAIL_USER"], os.environ["MAIL_PASS"])
    box.select("INBOX", readonly=True)

    _, ids = box.search(None, "SUBJECT", '"python"')
    newest_five = ids[0].split()[-5:]                 # the last ones are the newest

    for number in reversed(newest_five):              # newest first
        _, data = box.fetch(number, "(BODY.PEEK[HEADER.FIELDS (FROM DATE SUBJECT)])")
        header = email.message_from_bytes(data[0][1])
        print(number.decode(), "|", header["From"])
        print("   ", header["Date"])
        print("   ", header["Subject"])

    box.logout()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
newest_five = ids[0].split()[-5:]The numbers run oldest to newest. [-5:] takes the last five.หมายเลขเรียงจากเก่าไปใหม่ [-5:] จึงเอาห้าตัวสุดท้าย编号从旧到新,所以 [-5:] 取最后五个。
for number in reversed(newest_five):reversed shows the newest first.reversed ทำให้ฉบับใหม่ที่สุดขึ้นก่อนreversed 让最新的排在最前面。
_, data = box.fetch(number, "(BODY.PEEK[HEADER.FIELDS (FROM DATE SUBJECT)])")Download three headers only. PEEK matters: without it, reading marks the email as read.ดาวน์โหลดแค่สามเฮดเดอร์ PEEK สำคัญมาก ถ้าไม่ใส่ การอ่านจะทำให้อีเมลกลายเป็นอ่านแล้ว只下载三个邮件头。PEEK 很重要:不加它,读一下就会把邮件标成已读。
header = email.message_from_bytes(data[0][1])Turn those bytes into something you can ask questions of.แปลงไบต์พวกนั้นเป็นสิ่งที่ถามข้อมูลได้把那些字节变成可以查询的对象。
print(number.decode(), "|", header["From"])decode() turns the number into text, so it prints neatly.decode() แปลงหมายเลขจากไบต์เป็นข้อความ จะได้พิมพ์ออกมาสวย ๆdecode() 把编号从字节变成文字,打印出来才好看。
YOUR TURN
  1. Create inbox3.py and run it.
  2. Check: up to five emails, newest first, each with sender, date and subject.
  3. Change the search word, and run it again.
  4. Nothing printed? Nothing matched. Try a word you know is there.
  1. สร้าง inbox3.py แล้วรัน
  2. ตรวจ: ได้ไม่เกินห้าอีเมล ใหม่สุดขึ้นก่อน แต่ละฉบับมีผู้ส่ง วันที่ และหัวเรื่อง
  3. เปลี่ยนคำค้นหา แล้วรันอีกครั้ง
  4. ไม่มีอะไรพิมพ์ออกมา แปลว่าไม่มีอะไรตรงเลย ลองคำที่คุณรู้ว่ามีอยู่
  1. 新建 inbox3.py 并运行。
  2. 检查:最多五封邮件,最新的在前,每封都有发件人、日期和主题。
  3. 换一个搜索词,再运行一次。
  4. 什么都没打印?说明没有匹配。换一个你确定有的词试试。

14 📨 Read one email, and save its files · อ่านอีเมลหนึ่งฉบับ และบันทึกไฟล์ · 读一封邮件,保存它的文件

🇬🇧 English

Last step. Open one email properly: its text, and any file attached.

An email is made of parts. The message you read is one. Each attachment is another.

One detail matters: policy=email.policy.default. Without it you get Python's old email object. That one has no get_body(). The mistake costs an hour.

🇹🇭 ไทย

ขั้นสุดท้าย เปิดอีเมลหนึ่งฉบับอย่างจริงจัง ทั้งตัวข้อความ และไฟล์ที่แนบมา

อีเมลประกอบด้วยหลายส่วน ข้อความที่คุณอ่านคือส่วนหนึ่ง ไฟล์แนบแต่ละไฟล์คืออีกส่วน

มีจุดหนึ่งที่สำคัญ policy=email.policy.default ถ้าไม่ใส่ คุณจะได้ออบเจ็กต์อีเมลแบบเก่าของ Python ซึ่งไม่มี get_body() ความผิดพลาดนี้กินเวลาเป็นชั่วโมง

🇨🇳 中文

最后一步:好好地打开一封邮件——它的正文,以及附带的文件。

邮件由若干部分组成:你读到的正文是一部分,每个附件又是一部分。

有个细节很关键:policy=email.policy.default。不写它,你拿到的是 Python 老式的邮件对象,没有 get_body()。这个错误要花一个小时。

📄 NEW FILE · inbox4.py

"""inbox4.py — read one email: its text, and any files attached to it."""
import email
import email.policy
import imaplib
import os
from pathlib import Path

IMAP_HOST = "imap.gmail.com"


def main():
    """Open the newest matching email, print its text, and save its attachments."""
    box = imaplib.IMAP4_SSL(IMAP_HOST)
    box.login(os.environ["MAIL_USER"], os.environ["MAIL_PASS"])
    box.select("INBOX", readonly=True)

    _, ids = box.search(None, "SUBJECT", '"python"')
    if not ids[0]:
        raise SystemExit("No email matched. Try a different word.")
    newest = ids[0].split()[-1]

    _, data = box.fetch(newest, "(BODY.PEEK[])")      # PEEK: it stays unread
    box.logout()
    # policy=default gives the modern email object, the one with get_body()
    message = email.message_from_bytes(data[0][1], policy=email.policy.default)

    print("from:   ", message["From"])
    print("subject:", message["Subject"])

    text = message.get_body(preferencelist=["plain"])
    print("text:   ", text.get_content().strip()[:200] if text else "(no plain text)")

    folder = Path("saved")
    folder.mkdir(exist_ok=True)
    for part in message.iter_attachments():
        name = part.get_filename() or "attachment"
        (folder / name).write_bytes(part.get_payload(decode=True))
        print("saved:  ", folder / name)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
if not ids[0]: raise SystemExit("No email matched. Try a different word.")Nothing matched? Stop here, with a message a person can read.ไม่มีอะไรตรงหรือ หยุดตรงนี้ พร้อมข้อความที่คนอ่านเข้าใจ没有匹配?就在这里停下,并给出人能看懂的提示。
_, data = box.fetch(newest, "(BODY.PEEK[])")Download the whole email this time. Empty brackets mean everything.คราวนี้ดาวน์โหลดทั้งฉบับ วงเล็บว่างแปลว่าเอาทั้งหมด这次下载整封邮件。空的方括号表示全部。
message = email.message_from_bytes(data[0][1], policy=email.policy.default)Turn the bytes into an email. policy=email.policy.default asks for the modern object. That one has get_body() and iter_attachments().แปลงไบต์เป็นอีเมล policy=email.policy.default คือการขอออบเจ็กต์แบบใหม่ ที่มี get_body() และ iter_attachments()把字节变成邮件。policy=email.policy.default 表示要现代的对象,它才有 get_body()iter_attachments()
text = message.get_body(preferencelist=["plain"])Find the plain text part. Many emails also carry a fancy version. This asks for the simple one.หาส่วนที่เป็นข้อความธรรมดา อีเมลหลายฉบับมีเวอร์ชันสวยงามด้วย ตรงนี้ขอแบบเรียบ ๆ找出纯文本那一部分。很多邮件还带一个花哨的版本,这里要的是简单的那个。
for part in message.iter_attachments():Go through the attached files, if there are any.ไล่ดูไฟล์ที่แนบมา ถ้ามี逐个处理附件(如果有的话)。
(folder / name).write_bytes(part.get_payload(decode=True))decode=True gives the real bytes of the file. Write them into the saved folder.decode=True ให้ไบต์จริงของไฟล์ แล้วเขียนลงดิสก์ในโฟลเดอร์ saveddecode=True 给出文件真正的字节,然后写到硬盘上的 saved 文件夹里。
CAREFUL · A FILE NAME FROM OUTSIDE The name comes from whoever sent the email. Do not trust it. A name with .. or a full path can write the file anywhere. Before using this on real mail, keep only letters, digits, dots and dashes. ชื่อไฟล์มาจากคนที่ส่งอีเมล จึงเชื่อถือไม่ได้ ชื่อที่มี .. หรือ path เต็ม อาจทำให้ไฟล์ถูกเขียนไปยังที่ที่คุณไม่ได้เลือก ก่อนใช้กับอีเมลจริง ให้เก็บเฉพาะตัวอักษร ตัวเลข จุด และขีดในชื่อไฟล์ 文件名来自寄信的人,不能轻信。名字里带 .. 或完整路径,可能把文件写到你没选的地方。用在真实邮件上之前,只保留名字里的字母、数字、点和减号。
YOUR TURN
  1. Create inbox4.py and run it.
  2. Check: it prints the sender, subject and text of the newest match.
  3. Did that email have a file attached? Look in the new saved folder.
  4. Send yourself one with file4.py. Run this again to read it back.
  1. สร้าง inbox4.py แล้วรัน
  2. ตรวจ: มันพิมพ์ผู้ส่ง หัวเรื่อง และข้อความของอีเมลที่ใหม่ที่สุดที่ตรงเงื่อนไข
  3. อีเมลฉบับนั้นมีไฟล์แนบไหม ลองดูในโฟลเดอร์ saved ที่เพิ่งเกิดขึ้น
  4. ส่งอีเมลหาตัวเองด้วย file4.py แล้วรันไฟล์นี้อีกครั้งเพื่ออ่านกลับมา
  1. 新建 inbox4.py 并运行。
  2. 检查:它打印出最新那封匹配邮件的发件人、主题和正文。
  3. 那封邮件有附件吗?看看新出现的 saved 文件夹。
  4. file4.py 给自己发一封,再运行这个文件把它读回来。

👩‍🍳 Tutorial 4 — A local AI writes a recipe · บทที่ 4 — AI ในเครื่องเขียนสูตรอาหาร · 教程 4 —— 本机 AI 写一份食谱

🇬🇧 English

You can read files now. Here is what to do with what is inside them.

A model on your computer turns ingredients into a recipe. Nothing goes to a company. No key, no bill.

You need Ollama running, and one model pulled: ollama pull qwen3.

🇹🇭 ไทย

ตอนนี้คุณอ่านไฟล์เป็นแล้ว ต่อไปคือจะทำอะไรกับสิ่งที่อยู่ข้างใน

โมเดลในเครื่องของคุณเปลี่ยนรายการวัตถุดิบให้เป็นสูตรอาหารได้ ไม่มีอะไรถูกส่งไปหาบริษัทไหน ไม่ต้องใช้คีย์ ไม่มีค่าใช้จ่าย

คุณต้องเปิด Ollama ไว้ และดึงโมเดลมาหนึ่งตัว ollama pull qwen3

🇨🇳 中文

你已经会读文件了,接下来是拿文件里的内容做点事。

跑在你自己电脑上的模型,可以把一份食材清单变成一份食谱。什么都不会发给哪家公司,不用密钥,也不花钱。

你需要运行 Ollama,并拉一个模型:ollama pull qwen3

15 🧾 A file and a role, in one prompt · ไฟล์กับบทบาท รวมเป็นพรอมป์ตเดียว · 一个文件加一个角色,合成提示词

🇬🇧 English

Talking to a model is just sending it text. The skill is what you send.

Start with a role: who the model should be. Then the job. Then the rules. Then the material from your file.

The prompt is a normal string with holes in it. format() fills the holes. The dish name comes from a variable, the ingredients from the file.

🇹🇭 ไทย

การคุยกับโมเดลก็คือการส่งข้อความไปให้ ทักษะอยู่ที่ว่าคุณส่งอะไรไป

เริ่มจากบทบาท ว่าโมเดลควรเป็นใคร ตามด้วยงาน แล้วกฎ และวัตถุดิบจากไฟล์ของคุณ

พรอมป์ตคือข้อความธรรมดาที่มีช่องว่าง format() เติมช่องพวกนั้น ชื่ออาหารมาจากตัวแปร ส่วนวัตถุดิบมาจากไฟล์

🇨🇳 中文

和模型对话,其实就是发给它一段文字。功夫在于你发什么。

先给角色:模型该扮演谁。然后是任务规则,最后是来自文件的材料

提示词就是一段带空的普通字符串。format() 把空填上:菜名来自变量,食材来自文件。

📄 NEW FILE · ingredients.txt — whatever is in your kitchen

2 chicken thighs
1 cup coconut milk
3 kaffir lime leaves
2 tablespoons red curry paste
1 handful Thai basil
1 tablespoon fish sauce
1 teaspoon palm sugar
half an aubergine

📄 NEW FILE · chef1.py

"""chef1.py — put a file and a dish name into a prompt for the AI."""
from pathlib import Path

DISH = "green curry"            # change this to any dish you like

CHEF_PROMPT = """You are a Thai chef teaching a beginner.

Write a recipe for {dish}, using only the ingredients listed below. Do not add
anything that is not on the list. Say so if something important is missing.

=== INGREDIENTS ===
{ingredients}

Write the steps as a numbered list. Keep each step short."""


def main():
    """Read the ingredients file, fill the prompt, and print it."""
    ingredients = Path("ingredients.txt").read_text(encoding="utf-8")

    prompt = CHEF_PROMPT.format(dish=DISH, ingredients=ingredients)
    print(prompt)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
DISH = "green curry"The dish name, in a variable. One word to change, and the whole prompt changes.ชื่ออาหารเก็บไว้ในตัวแปร เปลี่ยนคำเดียว พรอมป์ตทั้งอันก็เปลี่ยน菜名放在一个变量里。改一个词,整个提示词就变了。
CHEF_PROMPT = """You are a Thai chef teaching a beginner.The role. It tells the model who to be, and who it teaches.บทบาท บอกโมเดลว่าให้เป็นใคร และกำลังคุยกับใคร角色。告诉模型该扮演谁,以及在跟谁说话。
Write a recipe for {dish}, using only the ingredients listed below.The job, and the first rule. The braces are holes to fill.งาน และกฎข้อแรก วงเล็บปีกกาคือช่องที่ต้องเติม任务,以及第一条规则。花括号是要填的空。
Do not add anything that is not on the list. Say so if something important is missing.More rules. A model invents when you let it. Say what it may not do.กฎเพิ่มเติม ถ้าปล่อยไว้ โมเดลจะแต่งเอง จึงต้องบอกว่าอะไรที่ห้ามทำ更多规则。不管住它,模型就会自己编,所以要写明不许做什么。
=== INGREDIENTS === {ingredients}The material. The fence of equals signs keeps your file apart from your instructions.ตัววัตถุดิบ เส้นเครื่องหมายเท่ากับช่วยแยกไฟล์ของคุณออกจากคำสั่ง材料。等号围成的栏杆,把你的文件和指令分开。
ingredients = Path("ingredients.txt").read_text(encoding="utf-8")Read the file into a variable. This is tutorial 2, used for something.อ่านไฟล์เข้ามาเก็บในตัวแปร นี่คือบทที่ 2 ที่ได้ใช้งานจริง把文件读进一个变量。这就是教程 2 派上用场的地方。
prompt = CHEF_PROMPT.format(dish=DISH, ingredients=ingredients)Fill both holes. format matches the names in braces.เติมทั้งสองช่อง format จับคู่ตามชื่อในวงเล็บปีกกา把两个空都填上。format 按花括号里的名字对应。
print(prompt)Print it, and read it yourself before any model does.พิมพ์ออกมา แล้วอ่านเองก่อนที่โมเดลจะได้อ่าน打印出来,在模型读之前你自己先读一遍。
READ THE PROMPT FIRST Printing costs nothing and takes a second. A slow model costs minutes. Most bad answers are bad prompts. A missing rule. An empty file you did not notice. การพิมพ์ออกมาไม่เสียอะไรเลย ใช้เวลาแค่วินาทีเดียว ส่งให้โมเดลที่ช้าต้องรอเป็นนาที คำตอบที่แย่ส่วนใหญ่มาจากพรอมป์ตที่แย่ เช่น ลืมใส่กฎ หรือไฟล์ว่างที่คุณไม่ทันสังเกต 打印不花钱,只要一秒。发给一个慢模型要等好几分钟。大多数糟糕的回答其实是糟糕的提示词:漏了一条规则,或者文件是空的你没发现。
YOUR TURN
  1. Create ingredients.txt and chef1.py.
  2. Run python chef1.py.
  3. Check: the printed prompt holds your dish name and every ingredient.
  4. Change DISH to something else and run it again.
  1. สร้าง ingredients.txt และ chef1.py
  2. รัน python chef1.py
  3. ตรวจ: พรอมป์ตที่พิมพ์ออกมามีชื่ออาหารของคุณ และวัตถุดิบครบทุกอย่าง
  4. เปลี่ยน DISH เป็นอย่างอื่น แล้วรันอีกครั้ง
  1. 新建 ingredients.txtchef1.py
  2. 运行 python chef1.py
  3. 检查:打印出的提示词里有你的菜名和每一样食材。
  4. DISH 改成别的,再运行一次。

16 🤖 Pass it to a function that talks to Ollama · ส่งให้ฟังก์ชันที่คุยกับ Ollama · 交给一个和 Ollama 对话的函数

🇬🇧 English

Now send it. Ollama listens on your own computer and takes plain JSON. No library needed.

Put the call in its own function. The rest of your program ignores how the model works.

The answer comes back wrapped in JSON. One line digs the text out of it.

🇹🇭 ไทย

ถึงเวลาส่งแล้ว Ollama รออยู่ในเครื่องของคุณเอง และรับ JSON ธรรมดา จึงไม่ต้องใช้ไลบรารีเพิ่ม

เก็บการเรียกไว้ในฟังก์ชันของมันเอง ส่วนที่เหลือของโปรแกรมจะได้ไม่ต้องสนใจว่าโมเดลทำงานอย่างไร

คำตอบกลับมาห่ออยู่ใน JSON บรรทัดเดียวก็ดึงข้อความออกมาได้

🇨🇳 中文

现在把它发出去。Ollama 就在你自己的电脑上监听,收的是普通 JSON,所以不需要装库。

把这次调用放进一个单独的函数。这样程序其他部分就不用管模型怎么工作。

回答是包在 JSON 里回来的,一行就能把文字取出来。

📄 NEW FILE · chef2.py

"""chef2.py — send the prompt to Ollama and print the recipe."""
import json
import urllib.request
from pathlib import Path

OLLAMA = "http://localhost:11434/api/chat"
MODEL = "qwen3"
DISH = "green curry"

CHEF_PROMPT = """You are a Thai chef teaching a beginner.

Write a recipe for {dish}, using only the ingredients listed below. Do not add
anything that is not on the list. Say so if something important is missing.

=== INGREDIENTS ===
{ingredients}

Write the steps as a numbered list. Keep each step short."""


def ask_local_model(prompt, num_ctx=8192):
    """One call to Ollama on this machine. No key, nothing leaves the computer."""
    body = json.dumps({"model": MODEL, "stream": False, "options": {"num_ctx": num_ctx},
                       "messages": [{"role": "user", "content": prompt}]}).encode()
    req = urllib.request.Request(OLLAMA, data=body, headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=1800) as answer:
        return json.load(answer)["message"]["content"]


def main():
    """Turn a list of ingredients into a recipe."""
    ingredients = Path("ingredients.txt").read_text(encoding="utf-8")
    prompt = CHEF_PROMPT.format(dish=DISH, ingredients=ingredients)

    print("asking the model - this can take a few minutes")
    recipe = ask_local_model(prompt)
    print(recipe)

    Path("recipe.md").write_text(recipe, encoding="utf-8")
    print("saved: recipe.md")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
OLLAMA = "http://localhost:11434/api/chat"Where Ollama listens. localhost means this computer, so the text never leaves it.ที่อยู่ที่ Ollama รออยู่ localhost แปลว่าเครื่องนี้ ข้อความจึงไม่ออกไปไหนOllama 监听的地址。localhost 就是本机,所以文字不会离开这台电脑。
MODEL = "qwen3"Which model to use. Any model you pulled with ollama pull works.เลือกโมเดลที่จะใช้ โมเดลไหนที่ดึงมาด้วย ollama pull ก็ใช้ได้用哪个模型。凡是 ollama pull 拉过的都行。
def ask_local_model(prompt, num_ctx=8192):One job, one function: send text, get text. num_ctx is how much the model can hold at once.หนึ่งฟังก์ชันหนึ่งหน้าที่ ส่งข้อความไป รับข้อความกลับ ส่วน num_ctx คือปริมาณข้อความที่โมเดลถือได้ในคราวเดียว一个函数只做一件事:发文字、收文字。num_ctx 是模型一次能装下多少内容。
body = json.dumps({"model": MODEL, "stream": False, ...}).encode()Pack the request as JSON. "stream": False means: send the whole answer at once, not word by word.ห่อคำขอเป็น JSON "stream": False แปลว่า ส่งคำตอบทั้งก้อนทีเดียว ไม่ใช่ทีละคำ把请求打包成 JSON。"stream": False 表示:一次给出完整回答,不是一个词一个词地来。
req = urllib.request.Request(OLLAMA, data=body, headers=...)Build the web request. urllib comes with Python, so nothing to install.สร้างคำขอเว็บ urllib มากับ Python อยู่แล้ว ไม่ต้องติดตั้งอะไร构造网络请求。urllib 是 Python 自带的,不用安装。
with urllib.request.urlopen(req, timeout=1800) as answer:Send it and wait. Thirty minutes, because a laptop with no graphics card is slow.ส่งแล้วรอ ตั้งไว้สามสิบนาที เพราะแล็ปท็อปที่ไม่มีการ์ดจอทำงานช้า发出去然后等。给三十分钟,因为没有显卡的笔记本很慢。
return json.load(answer)["message"]["content"]Read the JSON that came back, and take out only the text.อ่าน JSON ที่กลับมา แล้วเอาเฉพาะข้อความ读回来的 JSON,只取出其中的文字。
recipe = ask_local_model(prompt)One line. The rest of the program does not know it is an AI.บรรทัดเดียว ส่วนที่เหลือของโปรแกรมไม่รู้ด้วยซ้ำว่านี่คือ AI就一行。程序其他部分根本不知道这是 AI。
Path("recipe.md").write_text(recipe, encoding="utf-8")Save it. An answer that took three minutes should survive closing the window.บันทึกไว้ คำตอบที่ใช้เวลาสามนาทีไม่ควรหายไปตอนปิดหน้าต่าง把它存下来。花了三分钟得到的答案,不该在关窗口时消失。
WHAT THE MODEL IS, AND IS NOT It is fluent, and fluent is not correct. It may invent a step. It may ignore your rule. Read the recipe before you cook it. Anything weightier than dinner deserves more care. มันพูดลื่น แต่ลื่นไม่ได้แปลว่าถูก มันอาจแต่งขั้นตอนขึ้นมา หรือไม่ทำตามกฎที่คุณเขียนไว้ อ่านสูตรก่อนลงมือทำ และยิ่งต้องระวังกว่านี้ถ้าเรื่องนั้นสำคัญกว่ามื้อเย็น 它说得流利,但流利不等于正确。它可能编出一个步骤,或者无视你写的规则。做之前先把食谱读一遍。比晚饭更重要的事,更要加倍小心。
YOUR TURN
  1. Start Ollama. Run ollama pull qwen3 once, if you have not.
  2. Create chef2.py and run it. It takes a few minutes on a laptop.
  3. Check: a numbered recipe appears, and recipe.md is in your folder.
  4. Take one ingredient out of the file and run it again. Does the recipe change?
  5. Put it together: send yourself the recipe, with file4.py from step 10.
  6. If it fails: Connection refused means Ollama is not running.
  1. เปิด Ollama แล้วรัน ollama pull qwen3 หนึ่งครั้ง ถ้ายังไม่เคยทำ
  2. สร้าง chef2.py แล้วรัน บนแล็ปท็อปใช้เวลาหลายนาที
  3. ตรวจ: สูตรอาหารแบบมีหมายเลขปรากฏขึ้น และมีไฟล์ recipe.md อยู่ในโฟลเดอร์
  4. ลองเอาวัตถุดิบออกหนึ่งอย่างแล้วรันใหม่ สูตรเปลี่ยนไหม
  5. ต่อยอด: ส่งสูตรนั้นหาตัวเองด้วย file4.py จากขั้นที่ 10
  6. ถ้าไม่ผ่าน: Connection refused แปลว่า Ollama ยังไม่ได้เปิด
  1. 启动 Ollama。没拉过模型的话,先运行一次 ollama pull qwen3
  2. 新建 chef2.py 并运行。在笔记本上要几分钟。
  3. 检查:出现一份带编号的食谱,文件夹里多了 recipe.md
  4. 从文件里拿掉一样食材再运行一次,食谱变了吗?
  5. 串起来:用第 10 步的 file4.py 把食谱寄给自己。
  6. 如果出错:Connection refused 表示 Ollama 没在运行。

🔎 Tutorial 5 — Send the chef to the web · บทที่ 5 — ส่งเชฟไปค้นเว็บ · 教程 5 —— 让厨师上网查资料

🇬🇧 English

Your model knows a lot. It has never read this week's cooking. Give it the web.

Four steps. Search Google. Let the model pick good links. Open one page. Cook.

The model works twice here as a filter, not an oracle. It chooses what to read. It throws away the rest of a page. That is what AI is good at.

🇹🇭 ไทย

โมเดลของคุณรู้เยอะ แต่ไม่เคยอ่านเรื่องทำอาหารของสัปดาห์นี้ ก็ให้เว็บกับมันเสีย

สี่ขั้น ค้นกูเกิล ให้โมเดลเลือกลิงก์ที่ดี เปิดหน้าเว็บหนึ่งหน้า แล้วลงมือทำอาหาร

ตรงนี้เราใช้โมเดลสองครั้งในฐานะตัวกรอง ไม่ใช่ผู้รู้ทุกอย่าง มันเลือกว่าจะอ่านอะไร และทิ้งส่วนที่เหลือของหน้าเว็บไป นั่นคือสิ่งที่ AI ทำได้ดี

🇨🇳 中文

你的模型懂很多,但从没读过这周的做菜内容。那就把网页给它。

四步:搜索 Google,让模型挑出好链接,打开其中一页,然后开火做菜。

这里模型被当作过滤器用了两次,而不是万事通。它决定读什么,并把页面其余部分丢掉。这正是 AI 擅长的事。

17 🌐 Search Google from Python · ค้นกูเกิลจาก Python · 用 Python 搜 Google

🇬🇧 English

Google has no free door for programs. So we open a real browser and read it.

cloakbrowser drives a Chromium that behaves like a person. Install it once: pip install cloakbrowser.

The results give a title and the site name. The link itself is a Google redirect. That is fine. It lands on the real page when you open it.

🇹🇭 ไทย

กูเกิลไม่มีช่องทางฟรีให้โปรแกรมเรียกใช้ เราจึงเปิดเบราว์เซอร์จริงแล้วอ่านหน้าเว็บเอา

cloakbrowser ขับ Chromium ที่ทำตัวเหมือนคนใช้งานจริง ติดตั้งครั้งเดียวด้วย pip install cloakbrowser

ผลลัพธ์ให้ชื่อเรื่องกับชื่อเว็บ ส่วนลิงก์เป็นลิงก์เปลี่ยนทางของกูเกิล ซึ่งไม่เป็นไร เพราะเปิดแล้วมันพาไปหน้าจริง

🇨🇳 中文

Google 没有给程序用的免费入口,所以我们打开一个真正的浏览器去读页面。

cloakbrowser 驱动一个行为像真人的 Chromium。装一次即可:pip install cloakbrowser

结果里有标题和网站名。链接本身是 Google 的跳转链接,没关系:打开时它会落到真正的页面。

📄 NEW FILE · web1.py

"""web1.py — search Google for help with the dish, and save the results."""
import json
import sys
from pathlib import Path
from urllib.parse import quote_plus

import cloakbrowser

DISH = "green curry"

sys.stdout.reconfigure(encoding="utf-8")       # Windows: let the console print Thai


def google(query, wanted=10):
    """Search Google in a real browser. Returns a list of {title, site, link}."""
    with cloakbrowser.launch_context(headless=True, humanize=True) as browser:
        page = browser.new_page()
        page.goto("https://www.google.com/search?q=" + quote_plus(query), timeout=60000)
        page.wait_for_timeout(2500)            # let the results finish arriving

        if "/sorry/" in page.url:              # Google wants proof you are human
            raise SystemExit("Google showed a captcha. Wait a while, or search less often.")

        found = page.eval_on_selector_all("a:has(h3)", """links => links.map(a => ({
            title: a.querySelector('h3').innerText,
            site: a.parentElement.parentElement.querySelector('cite')?.innerText || '',
            link: a.href}))""")

    results, seen = [], set()
    for item in found:                         # the same page can appear twice
        if item["link"] and item["link"] not in seen:
            seen.add(item["link"])
            results.append(item)
    return results[:wanted]


def main():
    """Search for the dish, print what came back, and keep it in a file."""
    results = google(f"{DISH} recipe traditional Thai")
    for item in results:
        print("-", item["title"])
        print(" ", item["site"])

    Path("results.json").write_text(json.dumps(results, indent=2), encoding="utf-8")
    print("saved:", len(results), "results to results.json")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
sys.stdout.reconfigure(encoding="utf-8")Windows terminals cannot print Thai by default. Without this line, a Thai title crashes it.terminal ของ Windows พิมพ์ภาษาไทยไม่ได้ตั้งแต่แรก ถ้าไม่มีบรรทัดนี้ ชื่อเรื่องภาษาไทยอันเดียวก็ทำให้โปรแกรมพังWindows 终端默认打印不了泰文。没有这一行,一个泰文标题就能让程序崩溃。
with cloakbrowser.launch_context(headless=True, humanize=True) as browser:Start the browser. headless=True means you see no window. humanize=True moves like a person.เปิดเบราว์เซอร์ headless=True แปลว่าไม่มีหน้าต่างให้เห็น ส่วน humanize=True ทำให้ขยับเหมือนคน启动浏览器。headless=True 表示看不到窗口,humanize=True 让它的动作像真人。
page.goto("https://www.google.com/search?q=" + quote_plus(query), timeout=60000)Open the search page. quote_plus makes your words safe inside a web address.เปิดหน้าผลค้นหา quote_plus ทำให้คำของคุณใส่ในที่อยู่เว็บได้อย่างปลอดภัย打开搜索页。quote_plus 把你的词变成网址里安全的形式。
if "/sorry/" in page.url:Google's captcha page. Stop with a clear message instead of returning nonsense.นี่คือหน้าแคปช่าของกูเกิล ให้หยุดพร้อมข้อความที่ชัดเจน ดีกว่าคืนค่ามั่ว ๆ这是 Google 的验证码页。要带着清楚的提示停下,而不是返回一堆乱七八糟的东西。
found = page.eval_on_selector_all("a:has(h3)", ...)Run a little JavaScript inside the page. Every result is a link holding a heading.รัน JavaScript เล็ก ๆ ในหน้านั้น ผลการค้นหาแต่ละอันคือลิงก์ที่มีหัวข้ออยู่ข้างใน在页面里跑一小段 JavaScript。每条结果都是一个带标题的链接。
for item in found: if item["link"] and item["link"] not in seen:The same page can appear twice in one search. Keep the first, drop the repeat.หน้าเดียวกันอาจโผล่มาสองครั้งในการค้นหาเดียว ให้เก็บอันแรก แล้วทิ้งอันซ้ำ同一个页面可能在一次搜索里出现两次。留下第一个,丢掉重复的。
Path("results.json").write_text(json.dumps(results, indent=2), encoding="utf-8")Save the results. Now the next step can run without searching again.บันทึกผลไว้ ขั้นต่อไปจะได้ทำงานโดยไม่ต้องค้นใหม่把结果保存下来。下一步就不用再搜一次。
SEARCH GENTLY Google counts. Search a few times an hour and you look like a person. Search every minute and you get a captcha, sometimes for an hour. That is why each step saves its work to a file. There is a paid way round it. smolagents has a GoogleSearchTool. It needs an API key you buy. กูเกิลนับจำนวนการค้นหา ค้นไม่กี่ครั้งต่อชั่วโมงถือว่าเป็นคน แต่ถ้าค้นทุกนาทีจะเจอแคปช่า บางครั้งนานเป็นชั่วโมง นี่คือเหตุผลที่ทุกขั้นบันทึกงานลงไฟล์ มีทางเลี่ยงแบบเสียเงินคือ smolagents มี GoogleSearchTool ซึ่งต้องใช้คีย์ API ที่ต้องซื้อ Google 会数你搜了多少次。一小时搜几次像真人;每分钟都搜就会遇到验证码,有时要等一个小时。所以每一步都把结果存进文件。也有花钱的办法:smolagents 有 GoogleSearchTool,但它需要你花钱买的 API key。
YOUR TURN
  1. Run pip install cloakbrowser once.
  2. Create web1.py and run it.
  3. Check: ten titles with their sites, and a new results.json.
  4. If it stops with a captcha: wait, then search a different dish.
  1. รัน pip install cloakbrowser หนึ่งครั้ง
  2. สร้าง web1.py แล้วรัน
  3. ตรวจ: ได้ชื่อเรื่องสิบอันพร้อมชื่อเว็บ และมีไฟล์ results.json ใหม่
  4. ถ้ามันหยุดเพราะแคปช่า: รอสักพัก แล้วลองค้นเมนูอื่น
  1. 先运行一次 pip install cloakbrowser
  2. 新建 web1.py 并运行。
  3. 检查:十条标题和对应网站,还有一个新的 results.json
  4. 如果被验证码拦下:等一会儿,再换一道菜搜。

19 ✂️ Read one page, keep only the cooking · อ่านหน้าเว็บ เก็บเฉพาะวิธีทำ · 读一个页面,只留做菜的部分

🇬🇧 English

A recipe page is mostly not the recipe. A holiday story, adverts, comments.

Open the page in the browser. Take its visible text. Ask the model for the cooking only.

The page can be enormous, so only the first part is sent. Long text is also slow.

🇹🇭 ไทย

หน้าสูตรอาหารส่วนใหญ่ไม่ใช่ตัวสูตร มีทั้งเรื่องเล่าตอนไปเที่ยว โฆษณา และคอมเมนต์

เปิดหน้านั้นในเบราว์เซอร์ ดึงข้อความที่มองเห็น แล้วขอให้โมเดลเก็บเฉพาะส่วนที่ใช้ทำอาหาร

หน้าเว็บอาจยาวมาก จึงส่งไปแค่ช่วงต้น เพราะโมเดลมีขีดจำกัด และข้อความยาวก็ช้า

🇨🇳 中文

一个食谱页面的大部分都不是食谱:度假故事、广告、评论。

在浏览器里打开页面,取出可见文字,然后让模型只留下做菜要用的部分。

页面可能非常长,所以只发前面一段。模型有容量上限,长文本也慢。

📄 NEW FILE · web3.py

"""web3.py — open the first chosen page, and cut it down to what a cook needs."""
import json
import sys
import urllib.request
from pathlib import Path

import cloakbrowser

OLLAMA = "http://localhost:11434/api/chat"
MODEL = "qwen3"
DISH = "green curry"

sys.stdout.reconfigure(encoding="utf-8")

NOTES_PROMPT = """You are a chef reading someone else's recipe page.

Keep only what a cook needs for {dish}: the ingredients with amounts, the steps in
order, and any times or temperatures. Throw away the story, the adverts and the
comments. If the page is not a recipe, say exactly: NOT A RECIPE.

=== PAGE ===
{page}

Write short lines. No more than 200 words."""


def ask_local_model(prompt, num_ctx=16384):
    """One call to Ollama on this machine."""
    body = json.dumps({"model": MODEL, "stream": False, "options": {"num_ctx": num_ctx},
                       "messages": [{"role": "user", "content": prompt}]}).encode()
    req = urllib.request.Request(OLLAMA, data=body, headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=1800) as answer:
        return json.load(answer)["message"]["content"]


def read_page(link):
    """Open the page in a real browser and return its visible text."""
    with cloakbrowser.launch_context(headless=True, humanize=True) as browser:
        page = browser.new_page()
        page.goto(link, timeout=60000)         # a Google link lands on the real site
        page.wait_for_timeout(2500)
        print("landed on:", page.url[:70])
        return page.inner_text("body")


def main():
    """Fetch the first chosen page, then keep only the useful part of it."""
    chosen = json.loads(Path("chosen.json").read_text(encoding="utf-8"))
    first = chosen[0]
    print("reading:", first["title"])

    page = read_page(first["link"])
    print("page length:", len(page), "characters")

    print("asking the model to cut it down - this can take a few minutes")
    notes = ask_local_model(NOTES_PROMPT.format(dish=DISH, page=page[:12000]))

    Path("notes.md").write_text(notes, encoding="utf-8")
    print(notes[:300])
    print("saved: notes.md")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
page.goto(link, timeout=60000)The saved link is a Google one. Opening it lands on the real recipe site.ลิงก์ที่บันทึกไว้เป็นของกูเกิล พอเปิดแล้วมันพาไปเว็บสูตรอาหารจริง保存的是 Google 的链接,打开后会落到真正的食谱网站。
return page.inner_text("body")The visible words of the page. No tags, no menus you cannot see.คือคำที่มองเห็นบนหน้าเว็บ ไม่มีแท็ก ไม่มีเมนูที่มองไม่เห็น页面上看得见的文字。没有标签,也没有看不见的菜单。
If the page is not a recipe, say exactly: NOT A RECIPE.Give the model a way to say no. Then your program can test for that answer.ให้ทางโมเดลตอบว่าไม่ใช่ได้ด้วย แล้วโปรแกรมของคุณจะตรวจคำตอบนั้นได้给模型一个说“不是”的方式,你的程序就能检查这个答案。
notes = ask_local_model(NOTES_PROMPT.format(dish=DISH, page=page[:12000]))[:12000] sends the first part only. Recipes sit near the top; comments sit at the bottom.[:12000] ส่งไปแค่ช่วงต้น สูตรมักอยู่ด้านบน ส่วนคอมเมนต์อยู่ด้านล่าง[:12000] 只发前面一段。食谱通常在上面,评论在最下面。
Path("notes.md").write_text(notes, encoding="utf-8")Keep the notes. The next step reads them, and you can read them yourself.เก็บโน้ตไว้ ขั้นต่อไปจะอ่าน และคุณก็อ่านเองได้把笔记存下来。下一步要读它,你自己也能读。
WHOSE RECIPE IS IT The page belongs to the person who wrote it. Notes for your own kitchen are fine. Publishing their recipe as yours is not. Read a few pages by hand, slowly, as you would in a shop. Do not point this at a hundred addresses. หน้าเว็บนั้นเป็นของคนที่เขียนมัน ทำโน้ตไว้ใช้ในครัวตัวเองไม่เป็นไร แต่เอาสูตรของเขาไปเผยแพร่เป็นของตัวเองไม่ได้ อ่านทีละไม่กี่หน้าอย่างช้า ๆ เหมือนตอนอยู่ในร้าน อย่าเอาโปรแกรมนี้ไปยิงใส่เว็บเป็นร้อยที่อยู่ 页面属于写它的人。做笔记给自己下厨没问题,把别人的食谱当成自己的发表就不行。像在书店里那样,慢慢读几页就好:别拿这个程序去扫一百个网址。
YOUR TURN
  1. Create web3.py and run it. It opens a browser and then thinks.
  2. Check: it prints where it landed, the page length, and short notes. notes.md appears.
  3. Open notes.md. Is the story gone? Are the amounts still there?
  4. If you see NOT A RECIPE: the model was honest. Try the second link.
  1. สร้าง web3.py แล้วรัน มันจะเปิดเบราว์เซอร์แล้วค่อยคิด
  2. ตรวจ: มันพิมพ์ว่าไปจบที่หน้าไหน ความยาวของหน้า และโน้ตสั้น ๆ แล้วได้ไฟล์ notes.md
  3. เปิด notes.md ดู เรื่องเล่าหายไปไหม ปริมาณวัตถุดิบยังอยู่ไหม
  4. ถ้าเห็น NOT A RECIPE แปลว่าโมเดลซื่อสัตย์ ลองลิงก์ที่สอง
  1. 新建 web3.py 并运行。它会先开浏览器,然后思考。
  2. 检查:它打印落地的网址、页面长度和简短笔记,并生成 notes.md
  3. 打开 notes.md:故事没了吗?分量还在吗?
  4. 如果看到 NOT A RECIPE:模型很诚实。换第二个链接试试。

20 🍲 Cook with your kitchen and the web · ทำอาหารจากครัวของคุณบวกข้อมูลจากเว็บ · 用你的厨房加上网上的资料做菜

🇬🇧 English

Now the chef has two things: your kitchen, and someone else's method.

The prompt keeps them apart. The ingredients rule still holds. The notes give timing and method.

That is the whole pattern of this page. Small steps, each saving a file, joined by one prompt.

🇹🇭 ไทย

ตอนนี้เชฟมีสองอย่าง คือครัวของคุณ กับวิธีทำของคนอื่น

พรอมป์ตแยกสองอย่างนี้ออกจากกัน กฎเรื่องวัตถุดิบยังอยู่ ส่วนโน้ตใช้ได้แค่เรื่องเวลาและวิธีทำ

นี่คือรูปแบบทั้งหมดของหน้านี้ ขั้นเล็ก ๆ แต่ละขั้นบันทึกไฟล์ไว้ แล้วเชื่อมกันด้วยพรอมป์ตเดียว

🇨🇳 中文

现在厨师手里有两样东西:你的厨房,和别人的做法。

提示词把两者分开。食材规则依然有效,笔记只用于时间和方法。

这就是整页的套路:小步骤,每步存一个文件,用一个提示词把它们连起来。

📄 NEW FILE · chef3.py

"""chef3.py — the chef writes the recipe, using your ingredients and the web notes."""
import json
import urllib.request
from pathlib import Path

OLLAMA = "http://localhost:11434/api/chat"
MODEL = "qwen3"
DISH = "green curry"

CHEF_PROMPT = """You are a Thai chef teaching a beginner.

Write a recipe for {dish}, using only the ingredients listed below. Do not add
anything that is not on the list. Say so if something important is missing.

The notes are from a recipe on the web. Use them for timing and method only.

=== INGREDIENTS ===
{ingredients}

=== NOTES FROM THE WEB ===
{notes}

Write the steps as a numbered list. Keep each step short."""


def ask_local_model(prompt, num_ctx=16384):
    """One call to Ollama on this machine."""
    body = json.dumps({"model": MODEL, "stream": False, "options": {"num_ctx": num_ctx},
                       "messages": [{"role": "user", "content": prompt}]}).encode()
    req = urllib.request.Request(OLLAMA, data=body, headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=1800) as answer:
        return json.load(answer)["message"]["content"]


def main():
    """Put your kitchen and the web together, and ask for one recipe."""
    ingredients = Path("ingredients.txt").read_text(encoding="utf-8")
    notes = Path("notes.md").read_text(encoding="utf-8")

    prompt = CHEF_PROMPT.format(dish=DISH, ingredients=ingredients, notes=notes)
    print("asking the chef - this can take a few minutes")
    recipe = ask_local_model(prompt)

    Path("recipe_web.md").write_text(recipe, encoding="utf-8")
    print(recipe)
    print("saved: recipe_web.md")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
The notes are from a recipe on the web. Use them for timing and method only.One sentence keeps the web in its place. Without it, the model cooks their recipe.ประโยคเดียวทำให้ข้อมูลจากเว็บอยู่ในที่ของมัน ถ้าไม่มีประโยคนี้ โมเดลจะทำสูตรของเว็บ ไม่ใช่ของคุณ一句话就把网上的内容摆正位置。没有它,模型做的是网上的食谱,不是你的。
ingredients = Path("ingredients.txt").read_text(encoding="utf-8") notes = Path("notes.md").read_text(encoding="utf-8")Two files, two variables. Each came from a different step.สองไฟล์ สองตัวแปร แต่ละอันมาจากคนละขั้น两个文件,两个变量,各自来自不同的步骤。
prompt = CHEF_PROMPT.format(dish=DISH, ingredients=ingredients, notes=notes)Three holes now. The prompt grew; the code did not.คราวนี้มีสามช่อง พรอมป์ตยาวขึ้น แต่โค้ดเท่าเดิม现在是三个空。提示词变长了,代码没有。

🇬🇧 English

Ours answered with a recipe, then this:

Missing Ingredients:
- Chicken stock
- Green beans
- Lime leaves

Notes:
Your recipe uses red curry paste instead of green.

It read the web page. It compared that with our shopping. Then it said what was short. That is the useful shape. The model checks, and you decide.

🇹🇭 ไทย

ของเราตอบเป็นสูตรอาหาร แล้วตามด้วยข้อความนี้

Missing Ingredients:
- Chicken stock
- Green beans
- Lime leaves

Notes:
Your recipe uses red curry paste instead of green.

มันอ่านหน้าเว็บ เทียบกับของที่เรามี แล้วบอกว่าขาดอะไร นี่คือรูปแบบที่มีประโยชน์ โมเดลตรวจให้ ส่วนคุณเป็นคนตัดสินใจ

🇨🇳 中文

我们这次它先给了食谱,然后是这段:

Missing Ingredients:
- Chicken stock
- Green beans
- Lime leaves

Notes:
Your recipe uses red curry paste instead of green.

它读了网页,和我们买的东西对照,指出缺什么。这才是有用的形态:模型负责核对,你来决定。

YOUR TURN
  1. Create chef3.py and run it.
  2. Check: a recipe that uses your ingredients, and recipe_web.md on disk.
  3. Compare it with recipe.md from step 16. Did the web notes help?
  4. Put it all together: email yourself the recipe, using file4.py from step 10.
  1. สร้าง chef3.py แล้วรัน
  2. ตรวจ: ได้สูตรที่ใช้วัตถุดิบของคุณ และไฟล์ recipe_web.md บนดิสก์
  3. เทียบกับ recipe.md จากขั้นที่ 16 โน้ตจากเว็บช่วยไหม
  4. ต่อยอดทั้งหมด: ส่งสูตรนี้หาตัวเองด้วย file4.py จากขั้นที่ 10
  1. 新建 chef3.py 并运行。
  2. 检查:一份用你食材的食谱,硬盘上多了 recipe_web.md
  3. 和第 16 步的 recipe.md 比一比。网上的笔记有帮助吗?
  4. 全部串起来:用第 10 步的 file4.py 把食谱寄给自己。

☁️ Tutorial 6 — Use NVIDIA's computers instead · บทที่ 6 — ใช้เครื่องของ NVIDIA แทน · 教程 6 —— 改用 NVIDIA 的算力

🇬🇧 English

Your laptop took six minutes to write one recipe. A big machine takes seconds.

NVIDIA lends you one. You get a free key. The code barely changes. The address is different. The key goes in the header. The answer sits in a different pocket of the JSON.

What changes is not the code. It is who sees your text.

🇹🇭 ไทย

แล็ปท็อปของคุณใช้เวลาหกนาทีเพื่อเขียนสูตรเดียว เครื่องใหญ่ใช้เวลาไม่กี่วินาที

NVIDIA ให้คุณยืมเครื่องของเขา คุณขอคีย์ฟรีได้ และโค้ดแทบไม่เปลี่ยน แค่เปลี่ยนที่อยู่ ใส่คีย์ในเฮดเดอร์ และคำตอบอยู่คนละช่องใน JSON

สิ่งที่เปลี่ยนไม่ใช่โค้ด แต่คือใครที่ได้เห็นข้อความของคุณ

🇨🇳 中文

你的笔记本写一份食谱要六分钟,大机器只要几秒。

NVIDIA 借给你一台。你申请一个免费密钥,代码几乎不变:换个地址、请求头里带上密钥,答案在 JSON 的另一个位置。

变的不是代码,而是谁看得到你的文字。

Ollama (tutorial 4)NVIDIA (this one)
Where it runsรันที่ไหน在哪里运行Your own computerเครื่องของคุณเอง你自己的电脑NVIDIA's computersเครื่องของ NVIDIANVIDIA 的电脑
Who sees the textใครเห็นข้อความ谁看得到文字Nobodyไม่มีใคร没有人NVIDIANVIDIANVIDIA
Speedความเร็ว速度Minutes on a laptopหลายนาทีบนแล็ปท็อป笔记本上要几分钟Secondsไม่กี่วินาที几秒
Needsต้องมีอะไร需要什么A download, and patienceดาวน์โหลดโมเดล และความอดทน下载模型,和耐心A key, and the internetคีย์ และอินเทอร์เน็ต一个密钥,和网络
Good forเหมาะกับ适合Student work, private notesงานนักเรียน บันทึกส่วนตัว学生作业、私人笔记Public text, long jobsข้อความสาธารณะ งานยาว ๆ公开的文字、长任务

21 📇 Find a model that works · หาโมเดลที่ใช้ได้จริง · 找一个真的能用的模型

🇬🇧 English

First a surprise: models retire. The one in a tutorial from last year may be gone today.

Worse: a model can be listed, and still refuse you. Listed is not the same as allowed.

So do not guess. Test. This program lists the catalogue. Then it tries a few names. It tells you which one answers.

Get a free key at build.nvidia.com. Then set it in your terminal. Same as the email password in step 1.

🇹🇭 ไทย

เรื่องที่อาจไม่คาดคิด โมเดลมีวันหมดอายุ ตัวที่อยู่ในบทเรียนปีที่แล้ว วันนี้อาจไม่มีแล้ว

แย่กว่านั้น โมเดลอาจอยู่ในรายการของคีย์คุณ แต่ยังปฏิเสธคุณได้ อยู่ในรายการ ไม่เท่ากับ ใช้ได้

อย่าเดา ให้ทดสอบ โปรแกรมนี้แสดงรายการทั้งหมด แล้วลองชื่อไม่กี่ตัว และบอกว่าตัวไหนตอบ

ขอคีย์ฟรีได้ที่ build.nvidia.com แล้วตั้งค่าไว้ใน terminal เหมือนรหัสผ่านอีเมลในขั้นที่ 1

🇨🇳 中文

先说个意外:模型会退役。去年教程里的那个,今天可能已经没了。

更麻烦的是:模型可能列在你的密钥下,却仍然拒绝你。列出来,不等于能用。

所以别猜,去测。这个程序先列出目录,再试几个名字,告诉你哪个能答。

build.nvidia.com 申请免费密钥,然后像第 1 步的邮箱密码那样,把它设在终端里。

⌨️ TERMINAL · set your key, then keep the window open

set NVIDIA_API_KEY=nvapi-your-key-here          # Windows
export NVIDIA_API_KEY=nvapi-your-key-here       # Mac and Linux

📄 NEW FILE · nvidia1.py

"""nvidia1.py — find a model your NVIDIA key can actually use."""
import json
import os
import urllib.error
import urllib.request

BASE = "https://integrate.api.nvidia.com/v1"

# Models worth trying. Edit this list with names from the catalogue below.
CANDIDATES = [
    "openai/gpt-oss-20b",
    "meta/llama-3.1-8b-instruct",
    "nvidia/llama-3.1-nemotron-70b-instruct",
]


def call(path, key, data=None):
    """One request to NVIDIA. Returns (status number, answer)."""
    request = urllib.request.Request(
        BASE + path,
        data=json.dumps(data).encode() if data else None,
        headers={"Authorization": "Bearer " + key,
                 "Content-Type": "application/json",
                 "Accept": "application/json"},
    )
    try:
        with urllib.request.urlopen(request, timeout=90) as answer:
            return 200, json.load(answer)
    except urllib.error.HTTPError as error:
        return error.code, error.read().decode(errors="replace")


def main():
    key = os.environ["NVIDIA_API_KEY"]

    status, catalogue = call("/models", key)
    if status != 200:
        print("Could not even list the models. Status:", status)
        print("Check your key.")
        raise SystemExit(1)

    names = sorted(model["id"] for model in catalogue["data"])
    print(len(names), "models are listed for your key.")
    print("But listed is not the same as allowed. So we test them.")
    print()

    for name in CANDIDATES:
        if name not in names:
            print("--", name, "is not in the catalogue at all")
            continue
        status, answer = call("/chat/completions", key, {
            "model": name,
            "messages": [{"role": "user", "content": "Say OK"}],
            "max_tokens": 200,
        })
        if status == 200:
            print("OK", name, "answers. Use this one.")
        elif status == 410:
            print("--", name, "is retired. It has reached its end of life.")
        elif status == 404:
            print("--", name, "is listed, but not open to your key.")
        else:
            print("--", name, "failed with", status)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
BASE = "https://integrate.api.nvidia.com/v1"One address, two uses. Add /models to list, /chat/completions to ask.ที่อยู่เดียว ใช้สองแบบ เติม /models เพื่อดูรายการ เติม /chat/completions เพื่อถาม一个地址,两种用法。加 /models 看目录,加 /chat/completions 提问。
CANDIDATES = [The short list to try. Edit it with names you see in the catalogue.รายชื่อสั้น ๆ ที่จะลอง แก้ได้ด้วยชื่อที่คุณเห็นในรายการ要试的短名单。用目录里看到的名字来改它。
key = os.environ["NVIDIA_API_KEY"]The key comes from the terminal, never from the file. Same rule as the email password.คีย์มาจาก terminal ไม่ใช่จากไฟล์ กฎเดียวกับรหัสผ่านอีเมล密钥来自终端,不写在文件里。和邮箱密码同一条规则。
"Authorization": "Bearer " + key,How you say who you are. The word Bearer is part of the form, not your name.เป็นวิธีบอกว่าคุณคือใคร คำว่า Bearer เป็นส่วนหนึ่งของรูปแบบ ไม่ใช่ชื่อคุณ这是你表明身份的方式。Bearer 是格式的一部分,不是你的名字。
except urllib.error.HTTPError as error:A refusal is not a crash. We catch it and read the number.การถูกปฏิเสธไม่ใช่การพัง เราจับมันไว้แล้วอ่านตัวเลข被拒绝不等于崩溃。我们接住它,读那个数字。
elif status == 410:410 means retired for everyone. 404 means not open to you. Different problems.410 แปลว่าเลิกให้บริการกับทุกคน 404 แปลว่าไม่เปิดให้คุณ คนละปัญหากัน410 表示对所有人都退役了,404 表示没对你开放。是两种问题。
THIS IS WHAT WE ACTUALLY GOT Running it here printed all three cases at once.
82 models are listed for your key
OK openai/gpt-oss-20b answers
meta/llama-3.1-8b-instruct is not in the catalogue at all
nvidia/llama-3.1-nemotron-70b-instruct is listed, but not open to your key
Your list will differ. That is why the program tests instead of trusting.
ตอนรันบนเครื่องเรา ได้ครบทั้งสามกรณีในครั้งเดียว 82 models are listed for your key ตามด้วย OK openai/gpt-oss-20b answers ตามด้วย meta/llama-3.1-8b-instruct is not in the catalogue at all และ nvidia/llama-3.1-nemotron-70b-instruct is listed, but not open to your key ของคุณจะต่างออกไป นั่นแหละคือเหตุผลที่โปรแกรมต้องทดสอบ ไม่ใช่เชื่อ 在我们机器上跑,一次就出现了三种情况:82 models are listed for your key,然后 OK openai/gpt-oss-20b answers,然后 meta/llama-3.1-8b-instruct is not in the catalogue at all,还有 nvidia/llama-3.1-nemotron-70b-instruct is listed, but not open to your key。你的结果会不同。这正是程序要测试、而不是相信的理由。
YOUR TURN
  1. Get a free key at build.nvidia.com.
  2. Set it in your terminal, as above.
  3. Create nvidia1.py and run it.
  4. Check: at least one line starts with OK. Write that name down.
  5. If it fails with 401: the key is wrong. Or it is the wrong kind of key.
  6. Print names to see all 82, then add one to CANDIDATES.
  1. ขอคีย์ฟรีที่ build.nvidia.com
  2. ตั้งค่าคีย์ไว้ใน terminal ตามข้างบน
  3. สร้าง nvidia1.py แล้วรัน
  4. ตรวจ: ต้องมีอย่างน้อยหนึ่งบรรทัดขึ้นต้นด้วย OK จดชื่อนั้นไว้
  5. ถ้าล้มเหลวด้วย 401: คีย์ผิด หรือเป็นคีย์คนละชนิด
  6. ลองพิมพ์ names เพื่อดูครบ 82 ตัว แล้วเพิ่มสักตัวลงใน CANDIDATES
  1. build.nvidia.com 申请免费密钥。
  2. 照上面的方式设在终端里。
  3. 新建 nvidia1.py 并运行。
  4. 检查:至少有一行以 OK 开头。把那个名字记下来。
  5. 如果报 401:密钥不对,或者拿错了种类。
  6. 打印 names 看全部 82 个,再往 CANDIDATES 里加一个。

22 👩‍🍳 The same recipe, from the cloud · สูตรเดิม แต่มาจากคลาวด์ · 同一份食谱,来自云端

🇬🇧 English

Now the same recipe, from tutorial 4, with two lines changed.

The prompt does not change at all. Neither does the file, the dish, or the rules. Only the function that carries the words changes.

That is worth noticing. Keep the model call in its own function. Then swapping models is a small job.

🇹🇭 ไทย

คราวนี้คือสูตรเดิมจากบทที่ 4 แต่เปลี่ยนแค่สองบรรทัด

พรอมป์ตไม่เปลี่ยนเลย ไฟล์ก็ไม่เปลี่ยน ชื่ออาหารและกฎก็เหมือนเดิม เปลี่ยนแค่ฟังก์ชันที่พาข้อความไปส่ง

ตรงนี้น่าสังเกต ถ้าเก็บการเรียกโมเดลไว้ในฟังก์ชันของมันเอง การสลับโมเดลก็เป็นงานเล็ก ๆ

🇨🇳 中文

现在是教程 4 里那份食谱,只改两行。

提示词完全不变,文件、菜名、规则也都不变。变的只是那个把文字送出去的函数。

这点值得注意:把模型调用放在单独的函数里,换模型就只是件小事。

📄 NEW FILE · chef4.py

"""chef4.py — the same recipe, but NVIDIA's computers do the thinking."""
import json
import os
import urllib.error
import urllib.request
from pathlib import Path

NVIDIA = "https://integrate.api.nvidia.com/v1/chat/completions"
MODEL = "openai/gpt-oss-20b"   # run nvidia1.py to find one that works for you
DISH = "green curry"

CHEF_PROMPT = """You are a Thai chef teaching a beginner.

Write a recipe for {dish}, using only the ingredients listed below. Do not add
anything that is not on the list. Say so if something important is missing.

=== INGREDIENTS ===
{ingredients}

Write the steps as a numbered list. Keep each step short."""


def ask_nvidia(prompt, max_tokens=2000):
    """One call to NVIDIA. Same idea as Ollama, but someone else's computer."""
    body = json.dumps({"model": MODEL,
                       "messages": [{"role": "user", "content": prompt}],
                       "max_tokens": max_tokens}).encode()
    request = urllib.request.Request(NVIDIA, data=body, headers={
        "Authorization": "Bearer " + os.environ["NVIDIA_API_KEY"],
        "Content-Type": "application/json",
        "Accept": "application/json",
    })
    try:
        with urllib.request.urlopen(request, timeout=120) as answer:
            message = json.load(answer)["choices"][0]["message"]
        # Thinking models plan first, then answer. The plan is not the answer.
        if not message.get("content"):
            print("The model thought, but ran out of room to answer.")
            print("Raise max_tokens and try again.")
            raise SystemExit(1)
        return message["content"]
    except urllib.error.HTTPError as error:
        # NVIDIA says why in the body. Show it: the number alone is not enough.
        print("NVIDIA said no:", error.code)
        print(error.read().decode(errors="replace")[:300])
        if error.code in (404, 410):
            print("That model is gone or not on your key. Run nvidia1.py and pick another.")
        raise SystemExit(1)


def main():
    """Turn a list of ingredients into a recipe, using NVIDIA's model."""
    ingredients = Path("ingredients.txt").read_text(encoding="utf-8")
    prompt = CHEF_PROMPT.format(dish=DISH, ingredients=ingredients)

    print("asking NVIDIA - this takes seconds, not minutes")
    recipe = ask_nvidia(prompt)
    print(recipe)

    Path("recipe_nvidia.md").write_text(recipe, encoding="utf-8")
    print("saved: recipe_nvidia.md")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
NVIDIA = "https://integrate.api.nvidia.com/v1/chat/completions"The address for asking questions. Ollama's was localhost:11434; this one is far away.ที่อยู่สำหรับถามคำถาม ของ Ollama คือ localhost:11434 ส่วนอันนี้อยู่ไกลออกไป提问用的地址。Ollama 的是 localhost:11434,这个在很远的地方。
MODEL = "openai/gpt-oss-20b"The name that answered in step 21. Yours may be different. Use what worked.ชื่อที่ตอบได้ในขั้นที่ 21 ของคุณอาจต่างออกไป ใช้ตัวที่ใช้ได้จริง第 21 步里答了的那个名字。你的可能不同,用你试出来的。
"Authorization": "Bearer " + os.environ["NVIDIA_API_KEY"],The only new line in the request: who is asking.บรรทัดใหม่เพียงบรรทัดเดียวในคำขอ คือบอกว่าใครเป็นคนถาม请求里唯一新增的一行:谁在问。
message = json.load(answer)["choices"][0]["message"]A different pocket. Ollama puts the answer in message. NVIDIA wraps it in choices[0] first.อยู่คนละช่อง Ollama เก็บคำตอบไว้ใน message ส่วน NVIDIA ใส่ choices[0] ไว้ข้างหน้าก่อน位置不同。Ollama 把答案放在 message 里,NVIDIA 先套了一层 choices[0]
if not message.get("content"):Some models think first. If thinking uses all the room, the answer comes back empty.บางโมเดลคิดก่อน ถ้าการคิดใช้พื้นที่หมด คำตอบก็จะกลับมาว่างเปล่า有些模型先思考。思考把额度用光,答案就回来是空的。
except urllib.error.HTTPError as error:A call over the internet can be refused. A call to your own computer cannot.การเรียกผ่านอินเทอร์เน็ตถูกปฏิเสธได้ แต่การเรียกเครื่องตัวเองไม่ถูกปฏิเสธ走互联网的调用会被拒绝,调用你自己的电脑不会。
print(error.read().decode(errors="replace")[:300])Print what they said, not just the number. The words tell you what to fix.พิมพ์สิ่งที่เขาบอกมา ไม่ใช่แค่ตัวเลข ถ้อยคำจะบอกว่าต้องแก้อะไร把对方说的话打印出来,不要只看数字。文字才告诉你该改什么。
A THINKING MODEL PLANS BEFORE IT ANSWERS openai/gpt-oss-20b writes a private plan first, then the answer. You only get the answer. With max_tokens=5 our test got an empty content. The plan used every token. So this code checks for an empty answer. It says so, instead of saving an empty file. openai/gpt-oss-20b เขียนแผนส่วนตัวก่อน แล้วค่อยตอบ คุณจะได้เห็นแค่คำตอบ ตอนทดสอบด้วย max_tokens=5 เราได้ content ว่างเปล่า เพราะแผนใช้โทเคนไปหมด โค้ดนี้จึงตรวจคำตอบว่างแล้วบอกออกมา แทนที่จะบันทึกไฟล์เปล่า openai/gpt-oss-20b 会先写一段私下的计划,再给答案。你只拿得到答案。我们用 max_tokens=5 测试时,content 是空的,因为计划把额度用光了。所以这段代码会检查空答案并说出来,而不是存下一个空文件。
MODELS DIE, AND THE ERROR SAYS SO This is a real answer from NVIDIA while writing this page: The model 'meta/llama-3.1-8b-instruct' has reached its end of life on 2026-08-26 and is no longer available. A 410 means retired. A 404 often means your key cannot reach that one. Either way, run nvidia1.py and use a name that answered. นี่คือคำตอบจริงจาก NVIDIA ตอนเขียนหน้านี้ The model 'meta/llama-3.1-8b-instruct' has reached its end of life on 2026-08-26 and is no longer available. รหัส 410 แปลว่าเลิกให้บริการแล้ว ส่วน 404 มักแปลว่าคีย์ของคุณเข้าถึงตัวนั้นไม่ได้ ทั้งสองกรณีให้รัน nvidia1.py แล้วใช้ชื่อที่ตอบได้ 这是写这一页时 NVIDIA 给的真实回答:The model 'meta/llama-3.1-8b-instruct' has reached its end of life on 2026-08-26 and is no longer available. 410 表示已退役,404 往往表示你的密钥用不了那个。两种情况都先跑 nvidia1.py,用那个答了的名字。
WHOSE TEXT IS IT Everything in the prompt goes to NVIDIA. Here that is your ingredients. In another program it could be a student's essay. For other people's work, keep tutorial 4 and its local model. For your own shopping list, this is fine and much faster. ทุกอย่างในพรอมป์ตถูกส่งไปที่ NVIDIA ในที่นี้คือวัตถุดิบของคุณ แต่ในโปรแกรมอื่นอาจเป็นเรียงความของนักเรียน ถ้าเป็นงานของคนอื่น ให้ใช้บทที่ 4 กับโมเดลในเครื่อง ส่วนรายการซื้อของของคุณเอง ใช้อันนี้ได้ และเร็วกว่ามาก 提示词里的一切都会发给 NVIDIA:这里是你的食材,但在别的程序里可能是学生的作文。涉及别人的作业,就用教程 4 和本机模型;自己的购物清单,用这个没问题,而且快得多。
YOUR TURN
  1. Put the name that answered into MODEL.
  2. Create chef4.py and run it.
  3. Check: a recipe in seconds, and recipe_nvidia.md on disk.
  4. If you get 404 or 410: run nvidia1.py again and pick another.
  5. Compare the answer with recipe.md from step 16. Which chef do you trust?
  6. Run it twice. The answer changes each time, and the length changes too.
  1. ใส่ชื่อที่ตอบได้ลงใน MODEL
  2. สร้าง chef4.py แล้วรัน
  3. ตรวจ: ได้สูตรภายในไม่กี่วินาที และไฟล์ recipe_nvidia.md บนดิสก์
  4. ถ้าได้ 404 หรือ 410: รัน nvidia1.py อีกครั้งแล้วเลือกตัวใหม่
  5. เทียบคำตอบกับ recipe.md จากขั้นที่ 16 คุณเชื่อเชฟคนไหนมากกว่า
  6. ลองรันสองครั้ง คำตอบจะต่างกันทุกครั้ง และความยาวก็ต่างด้วย
  1. 把那个答了的名字填进 MODEL
  2. 新建 chef4.py 并运行。
  3. 检查:几秒就出一份食谱,硬盘上出现 recipe_nvidia.md
  4. 如果遇到 404 或 410:再跑一次 nvidia1.py,换一个。
  5. 把结果和第 16 步的 recipe.md 比一比。你更信哪位厨师?
  6. 跑两次。每次答案都不一样,长度也不一样。

🔧 Tutorial 7 — Give the AI a tool · บทที่ 7 — ให้เครื่องมือกับ AI · 教程 7 —— 给 AI 一个工具

🇬🇧 English

The model wrote you a recipe. But it has never seen your kitchen.

It cannot open your files. It cannot check your fridge. So we give it a way to ask.

A tool is just a Python function. You describe it. The model asks for it by name. Your code runs it, and sends the answer back.

Read that last part again. The model never runs your code. It only sends a name.

🇹🇭 ไทย

โมเดลเขียนสูตรให้คุณแล้ว แต่มันไม่เคยเห็นครัวของคุณ

มันเปิดไฟล์ของคุณไม่ได้ ดูในตู้เย็นก็ไม่ได้ เราจึงให้ช่องทางให้มันถาม

เครื่องมือก็คือฟังก์ชัน Python ธรรมดา คุณอธิบายมัน โมเดลขอมันด้วยชื่อ โค้ดของคุณเป็นคนรัน แล้วส่งคำตอบกลับไป

อ่านประโยคท้ายอีกครั้ง โมเดลไม่ได้รันโค้ดของคุณ มันส่งมาแค่ชื่อ

🇨🇳 中文

模型给你写了食谱,但它从没见过你的厨房。

它打不开你的文件,也看不了你的冰箱。所以我们给它一个提问的办法。

工具就是一个普通的 Python 函数。你描述它,模型报出它的名字,由你的代码运行它,再把结果送回去。

最后那句请再读一遍:模型不会运行你的代码,它只送来一个名字。

('Who does it', 'ใครทำ', '谁来做')('What happens', 'เกิดอะไรขึ้น', '发生什么')
1. Offer1. เสนอ1. 提供YouคุณYou describe the function in wordsคุณอธิบายฟังก์ชันเป็นคำพูด你用文字描述这个函数
2. Choose2. เลือก2. 选择The modelโมเดล模型It sends back a name and argumentsมันส่งชื่อกับอาร์กิวเมนต์กลับมา它送回一个名字和参数
3. Run3. รัน3. 运行Your codeโค้ดของคุณ你的代码You call it, then send the answer backคุณเรียกมัน แล้วส่งคำตอบกลับไป你调用它,再把答案送回

23 🍽️ Write the menu the AI reads · เขียนเมนูที่ AI อ่าน · 写一份 AI 读的菜单

🇬🇧 English

Here is the part that surprises people. The description the model reads is the docstring.

So write the docstring for the model, not only for a person. Say what the function does. Say what to put in. Say what comes out.

This program stops before running anything. It only shows you what the model picked.

🇹🇭 ไทย

ตรงนี้คือส่วนที่หลายคนแปลกใจ คำอธิบายที่โมเดลอ่านก็คือ docstring นั่นเอง

ฉะนั้นเขียน docstring เพื่อโมเดลด้วย ไม่ใช่เพื่อคนอย่างเดียว บอกว่าฟังก์ชันทำอะไร บอกว่าต้องใส่อะไร บอกว่าได้อะไรกลับมา

โปรแกรมนี้หยุดก่อนรันอะไรทั้งนั้น มันแค่แสดงให้ดูว่าโมเดลเลือกอะไร

🇨🇳 中文

这里是让人意外的地方:模型读的那段描述,就是 docstring。

所以 docstring 也要写给模型看,不只是写给人看。说清楚它做什么、要传什么、返回什么。

这个程序什么都不会真的运行,它只告诉你模型挑了什么。

📄 NEW FILE · tool1.py

"""tool1.py — give the AI a function, and watch it choose to use it."""
import inspect
import json
import urllib.request

OLLAMA = "http://localhost:11434/api/chat"
MODEL = "hermes3:8b"


def check_pantry(item):
    """Check if one ingredient is in the kitchen right now.
    Give the name of a single ingredient, like "coconut milk".
    Answers "yes" with the amount, or "no".
    """
    kitchen = open("ingredients.txt", encoding="utf-8").read().lower()
    for line in kitchen.splitlines():
        if item.lower() in line:
            return "yes, you have: " + line
    return "no, there is no " + item + " in the kitchen"


# This is the menu the AI reads. The description comes straight from the
# docstring above, so the words you write for a person are the same words
# the model uses to decide.
TOOLS = [{
    "type": "function",
    "function": {
        "name": "check_pantry",
        "description": inspect.getdoc(check_pantry),
        "parameters": {
            "type": "object",
            "properties": {
                "item": {"type": "string", "description": "One ingredient name."},
            },
            "required": ["item"],
        },
    },
}]


def main():
    question = "Do I have any coconut milk?"

    body = json.dumps({
        "model": MODEL,
        "stream": False,
        "tools": TOOLS,
        "messages": [{"role": "user", "content": question}],
    }).encode()
    request = urllib.request.Request(OLLAMA, data=body,
                                     headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=300) as answer:
        reply = json.load(answer)["message"]

    print("question:", question)
    print()

    calls = reply.get("tool_calls")
    if not calls:
        print("The model just talked. It did not use the tool:")
        print(reply.get("content"))
        return

    for call in calls:
        print("the model wants to run:", call["function"]["name"])
        print("with these arguments:  ", call["function"]["arguments"])

    print()
    print("Nothing has run yet. The model only asked.")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
import inspectinspect can read a function's own docstring. That is the whole trick.inspect อ่าน docstring ของฟังก์ชันได้ นั่นคือเคล็ดลับทั้งหมดinspect 能读出函数自己的 docstring。诀窍就在这里。
"""Check if one ingredient is in the kitchen right now.The model reads this. Vague words here give you vague choices.โมเดลอ่านบรรทัดนี้ ถ้าเขียนคลุมเครือ ก็จะได้การเลือกที่คลุมเครือ模型读的就是这句。这里写得含糊,它的选择也含糊。
"description": inspect.getdoc(check_pantry),The docstring becomes the description. One source of truth, not two.docstring กลายเป็นคำอธิบาย มีแหล่งความจริงเดียว ไม่ใช่สองdocstring 直接变成描述。只有一份真相,不是两份。
"parameters": {The shape of the arguments. This tells the model what to fill in.รูปร่างของอาร์กิวเมนต์ บอกโมเดลว่าต้องเติมอะไร参数的形状。它告诉模型该填什么。
"tools": TOOLS,The menu goes with the question. No menu means no tool calls.เมนูถูกส่งไปพร้อมคำถาม ถ้าไม่มีเมนู ก็ไม่มีการเรียกเครื่องมือ菜单和问题一起发过去。没有菜单,就不会有工具调用。
calls = reply.get("tool_calls")The model answers with tool_calls instead of text. That means it wants a tool.โมเดลตอบด้วย tool_calls แทนข้อความ แปลว่ามันต้องการเครื่องมือ模型回的是 tool_calls 而不是文字。那表示它想用工具。
THE DOCSTRING IS THE INTERFACE Change the docstring and you change what the model does. That is a real edit, not a comment. Try making it vague and watch the choices get worse. แก้ docstring ก็เท่ากับแก้พฤติกรรมของโมเดล นั่นคือการแก้โค้ดจริง ไม่ใช่แค่คอมเมนต์ ลองเขียนให้คลุมเครือ แล้วดูว่าการเลือกแย่ลงอย่างไร 改 docstring 就等于改模型的行为。那是真正的改动,不是注释。试着写得含糊,看它的选择怎么变差。
YOUR TURN
  1. Make sure ingredients.txt from step 15 is in the same folder.
  2. Start Ollama, and run ollama pull hermes3:8b once.
  3. Create tool1.py and run it.
  4. Check: it prints check_pantry and {'item': 'coconut milk'}.
  5. Change the question to "What is the capital of Thailand?" It should not use the tool.
  1. ตรวจว่า ingredients.txt จากขั้นที่ 15 อยู่ในโฟลเดอร์เดียวกัน
  2. เปิด Ollama แล้วรัน ollama pull hermes3:8b หนึ่งครั้ง
  3. สร้าง tool1.py แล้วรัน
  4. ตรวจ: ต้องพิมพ์ check_pantry และ {'item': 'coconut milk'}
  5. เปลี่ยนคำถามเป็น "เมืองหลวงของไทยคืออะไร" มันไม่ควรใช้เครื่องมือ
  1. 确认第 15 步的 ingredients.txt 在同一个文件夹里。
  2. 启动 Ollama,运行一次 ollama pull hermes3:8b
  3. 新建 tool1.py 并运行。
  4. 检查:它打印出 check_pantry{'item': 'coconut milk'}
  5. 把问题改成「泰国的首都是哪里?」它就不该用工具。

24 🥄 Run it, and hand back the answer · รันมัน แล้วส่งคำตอบกลับ · 运行它,再把答案交回去

🇬🇧 English

The model asked. Now we answer.

We look up the name in a dictionary. We call the real function. Put the result back in the conversation. Then we ask the model again.

The second question is what turns a file line into a sentence.

🇹🇭 ไทย

โมเดลถามแล้ว คราวนี้เราตอบ

เราค้นชื่อจาก dictionary เรียกฟังก์ชันจริง แล้วใส่ผลลัพธ์กลับเข้าไปในบทสนทนา จากนั้นถามโมเดลอีกครั้ง

คำถามครั้งที่สองนี่เองที่เปลี่ยนบรรทัดในไฟล์ให้กลายเป็นประโยค

🇨🇳 中文

模型问了,现在我们回答。

我们用字典查出名字,调用真正的函数,把结果放回对话里,然后再问模型一次。

正是这第二次提问,把文件里的一行变成一句话。

📄 NEW FILE · tool2.py

"""tool2.py — run the function the model asked for, then give it the answer."""
import inspect
import json
import urllib.request

OLLAMA = "http://localhost:11434/api/chat"
MODEL = "hermes3:8b"


def check_pantry(item):
    """Check if one ingredient is in the kitchen right now.
    Give the name of a single ingredient, like "coconut milk".
    Answers "yes" with the amount, or "no".
    """
    kitchen = open("ingredients.txt", encoding="utf-8").read().lower()
    for line in kitchen.splitlines():
        if item.lower() in line:
            return "yes, you have: " + line
    return "no, there is no " + item + " in the kitchen"


# The name the model says, and the real Python function it means.
KITCHEN = {"check_pantry": check_pantry}

TOOLS = [{
    "type": "function",
    "function": {
        "name": "check_pantry",
        "description": inspect.getdoc(check_pantry),
        "parameters": {
            "type": "object",
            "properties": {
                "item": {"type": "string", "description": "One ingredient name."},
            },
            "required": ["item"],
        },
    },
}]


def ask(messages):
    """Send the whole conversation to the model and return its next message."""
    body = json.dumps({
        "model": MODEL,
        "stream": False,
        "tools": TOOLS,
        "messages": messages,
    }).encode()
    request = urllib.request.Request(OLLAMA, data=body,
                                     headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=300) as answer:
        return json.load(answer)["message"]


def main():
    messages = [{"role": "user", "content": "Do I have any fish sauce?"}]

    reply = ask(messages)
    messages.append(reply)

    for call in reply.get("tool_calls", []):
        name = call["function"]["name"]
        arguments = call["function"]["arguments"]
        print("model asked for:", name, arguments)

        # YOU run the function. The model only sent its name.
        result = KITCHEN[name](**arguments)
        print("your code answered:", result)

        messages.append({"role": "tool", "name": name, "content": result})

    # Now the model has the answer, so ask it again for a sentence.
    final = ask(messages)
    print()
    print("the model says:", final.get("content"))


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
KITCHEN = {"check_pantry": check_pantry}The name the model says, and the function it means. You control this map.ชื่อที่โมเดลพูด กับฟังก์ชันที่มันหมายถึง คุณเป็นคนคุมตารางนี้模型说的名字,和它指的函数。这张表由你掌控。
result = KITCHEN[name](**arguments)Here the function finally runs. ** turns the JSON into arguments.ตรงนี้ฟังก์ชันได้รันจริง ** เปลี่ยน JSON ให้เป็นอาร์กิวเมนต์函数到这里才真的运行。** 把 JSON 变成参数。
messages.append({"role": "tool", ...})A new kind of speaker. Not user, not assistant, but tool.ผู้พูดแบบใหม่ ไม่ใช่ user ไม่ใช่ assistant แต่เป็น tool一种新的说话人。不是 user,不是 assistant,而是 tool
messages.append(reply)Keep the model's own request in the list. It needs to remember asking.เก็บคำขอของโมเดลไว้ในลิสต์ด้วย มันต้องจำได้ว่าเคยถาม把模型自己的请求也留在列表里。它需要记得自己问过。
final = ask(messages)The same function, asked twice. The second time it has the answer.ฟังก์ชันเดิม ถามสองครั้ง ครั้งที่สองมันมีคำตอบแล้ว同一个函数问两次。第二次它已经有答案了。
YOU CHOOSE WHAT THE NAME MEANS The model sends text, like check_pantry. Your KITCHEN dictionary decides what that runs. So never map a name to eval. Or to anything that runs code. Keep the list small and boring. โมเดลส่งข้อความมา เช่น check_pantry dictionary KITCHEN ของคุณเป็นตัวตัดสินว่ามันจะรันอะไร ฉะนั้นอย่าผูกชื่อกับอะไรที่รันโค้ดอะไรก็ได้ เช่น eval เก็บรายการให้เล็กและน่าเบื่อไว้ 模型送来的是文字,比如 check_pantry。由你的 KITCHEN 字典决定它运行什么。所以千万别把名字映射到能运行任意代码的东西,比如 eval。名单越小越无聊越好。
YOUR TURN
  1. Create tool2.py and run it.
  2. Check: three lines. The ask, your answer, then a sentence.
  3. Ask for saffron instead. The tool says no, and the model should say so too.
  4. Delete the messages.append(reply) line and run it. Read the error.
  1. สร้าง tool2.py แล้วรัน
  2. ตรวจ: ได้สามบรรทัด คำขอ คำตอบของคุณ แล้วก็ประโยค
  3. ลองถามหา saffron ดู เครื่องมือจะตอบว่าไม่มี และโมเดลก็ควรบอกแบบนั้น
  4. ลบบรรทัด messages.append(reply) แล้วรัน อ่านข้อผิดพลาดที่ได้
  1. 新建 tool2.py 并运行。
  2. 检查:三行。请求、你的回答,然后一句话。
  3. 改成问 saffron。工具说没有,模型也应该这么说。
  4. 删掉 messages.append(reply) 这行再运行,读一下报错。

25 🛒 Two tools, and a loop · สองเครื่องมือ กับลูป · 两个工具,和一个循环

🇬🇧 English

One tool is a trick. Two tools is an agent.

Now the model must choose. And one of these tools writes to a file.

So we use a loop. Nobody tells the model the order. It works it out.

🇹🇭 ไทย

เครื่องมือเดียวคือกลเม็ด สองเครื่องมือคือเอเจนต์

คราวนี้โมเดลต้องเลือก และเครื่องมือหนึ่งในนั้นเขียนลงไฟล์

เราจึงใช้ลูป ไม่มีใครบอกลำดับให้โมเดล มันคิดเอง

🇨🇳 中文

一个工具是花招,两个工具就是智能体了。

现在模型必须做选择。而且其中一个工具会写文件。

所以我们用循环。顺序没人教它,是它自己想出来的。

📄 NEW FILE · tool3.py

"""tool3.py — two tools, and a loop. One tool reads. One tool writes."""
import inspect
import json
import urllib.request

OLLAMA = "http://localhost:11434/api/chat"
MODEL = "hermes3:8b"


def check_pantry(item):
    """Check if one ingredient is in the kitchen right now.
    Give the name of a single ingredient, like "coconut milk".
    Answers "yes" with the amount, or "no".
    """
    kitchen = open("ingredients.txt", encoding="utf-8").read().lower()
    for line in kitchen.splitlines():
        if item.lower() in line:
            return "yes, you have: " + line
    return "no, there is no " + item + " in the kitchen"


def add_to_shopping_list(item):
    """Add one thing to the shopping list file.
    Only use this for something the kitchen does not have.
    Give the name of a single ingredient.
    """
    with open("shopping.txt", "a", encoding="utf-8") as shopping:
        shopping.write(item + "\n")
    return "added " + item + " to the shopping list"


KITCHEN = {
    "check_pantry": check_pantry,
    "add_to_shopping_list": add_to_shopping_list,
}

ONE_ITEM = {
    "type": "object",
    "properties": {"item": {"type": "string", "description": "One ingredient name."}},
    "required": ["item"],
}

# Build the menu from the functions themselves, so a new tool needs no new text.
TOOLS = [
    {"type": "function",
     "function": {"name": name,
                  "description": inspect.getdoc(function),
                  "parameters": ONE_ITEM}}
    for name, function in KITCHEN.items()
]


def ask(messages):
    """Send the whole conversation to the model and return its next message."""
    body = json.dumps({
        "model": MODEL,
        "stream": False,
        "tools": TOOLS,
        "messages": messages,
    }).encode()
    request = urllib.request.Request(OLLAMA, data=body,
                                     headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=300) as answer:
        return json.load(answer)["message"]


def main():
    messages = [{"role": "user", "content":
                 "I want to cook green curry. Check if I have green curry paste. "
                 "If I do not have it, add it to the shopping list."}]

    # Keep going while the model keeps asking for tools. Stop after 5 turns,
    # so a confused model cannot loop for ever.
    for turn in range(5):
        reply = ask(messages)
        messages.append(reply)
        calls = reply.get("tool_calls")

        if not calls:
            print()
            print("the model says:", reply.get("content"))
            return

        for call in calls:
            name = call["function"]["name"]
            arguments = call["function"]["arguments"]
            result = KITCHEN[name](**arguments)
            print("turn", turn + 1, "|", name, arguments, "->", result)
            messages.append({"role": "tool", "name": name, "content": result})

    print()
    print("Stopped after 5 turns. The model kept asking for tools.")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
with open("shopping.txt", "a", ...)This tool changes something. a means add to the end, not replace.เครื่องมือนี้เปลี่ยนแปลงบางอย่าง a แปลว่าเพิ่มต่อท้าย ไม่ใช่เขียนทับ这个工具会改变东西。a 表示追加到末尾,不是覆盖。
"""Add one thing to the shopping list file.Note the rule inside the docstring. Limits belong here, where the model reads them.สังเกตกฎที่อยู่ใน docstring ข้อจำกัดควรอยู่ตรงนี้ ที่ที่โมเดลอ่าน注意 docstring 里那条规则。限制就该写在模型读得到的地方。
TOOLS = [ ... for name, function in KITCHEN.items()]The menu builds itself. Add a function to the dictionary and it appears.เมนูสร้างตัวเอง เพิ่มฟังก์ชันลง dictionary แล้วมันก็โผล่มาเอง菜单自己生成。往字典里加个函数,它就出现了。
for turn in range(5):The outer loop: one turn per trip to the model. Five is a stop sign. A confused model cannot run for ever.ลูปนอก หนึ่งรอบต่อการไปหาโมเดลหนึ่งครั้ง ห้าคือป้ายหยุด เพื่อไม่ให้โมเดลที่สับสนวนไม่จบ外层循环:每去模型那里一趟算一轮。五是个停止牌,免得糊涂的模型一直转下去。
for call in calls:The inner loop. One reply can ask for several tools at once. Never assume just one.ลูปใน คำตอบเดียวขอเครื่องมือหลายตัวพร้อมกันได้ อย่าคิดว่ามีแค่ตัวเดียว内层循环。一次回复可以同时要好几个工具,别假设只有一个。
if not calls:No tool call means it is done talking to your code. Leave the loop.ถ้าไม่มีการเรียกเครื่องมือ แปลว่ามันคุยกับโค้ดคุณจบแล้ว ออกจากลูป没有工具调用,就是它跟你的代码聊完了。跳出循环。
A TOOL THAT WRITES IS A DIFFERENT ANIMAL check_pantry only reads. add_to_shopping_list changes a file. The model decides when to call it. The model can be wrong. Give it the smallest tool that does the job. A tool that adds one line is safe. A tool that deletes files is not. check_pantry แค่อ่าน แต่ add_to_shopping_list เปลี่ยนไฟล์ โมเดลเป็นคนตัดสินว่าจะเรียกเมื่อไหร่ และโมเดลผิดพลาดได้ ให้เครื่องมือที่เล็กที่สุดที่ทำงานได้ เครื่องมือที่เพิ่มหนึ่งบรรทัดนั้นปลอดภัย เครื่องมือที่ลบไฟล์ไม่ปลอดภัย check_pantry 只读。add_to_shopping_list 会改文件。何时调用由模型决定,而模型会出错。给它能完成任务的最小工具。加一行的工具是安全的,删文件的工具不是。
A SMALL MODEL MAY TALK INSTEAD OF ACT We ran this with llama3.2 first. It checked the kitchen. Then it wrote a nice paragraph. It said it had added the item. It had not. shopping.txt was empty. A system prompt did not fix it. A better model did. Always check the file, not the reply. เราลองรันด้วย llama3.2 ก่อน มันดูในครัว แล้วเขียนย่อหน้าสวย ๆ บอกว่าเพิ่มของให้แล้ว ซึ่งไม่จริง shopping.txt ว่างเปล่า system prompt แก้ไม่ได้ แต่เปลี่ยนโมเดลแก้ได้ ให้ตรวจที่ไฟล์เสมอ ไม่ใช่ที่คำตอบ 我们先用 llama3.2 跑过。它查了厨房,然后写了一段漂亮的话说已经加好了——其实没有,shopping.txt 是空的。system prompt 没能解决,换模型解决了。永远查文件,别只看回答。
('Model', 'โมเดล', '模型')('What it did', 'มันทำอะไร', '它做了什么')('Shopping list', 'รายการซื้อของ', '购物清单')
llama3.2Checked, then only talked about addingตรวจแล้ว แต่พูดถึงการเพิ่มเฉย ๆ查了,然后只是嘴上说要加emptyว่างเปล่า空的
qwen2.5:3bChecked, saw the answer, then added. Two turns.ตรวจ เห็นคำตอบ แล้วค่อยเพิ่ม สองรอบ查了,看到答案,然后加。两轮。writtenมีข้อมูล写好了
hermes3:8bAsked for both tools at once. One turn.ขอเครื่องมือทั้งสองพร้อมกัน รอบเดียว一次要了两个工具。一轮。writtenมีข้อมูล写好了
THE SAME CODE, THREE BEHAVIOURS That table is one program run three times, changing only MODEL. hermes3:8b asked for both tools before it had seen the pantry answer. It guessed, and it guessed right. Then we asked about fish sauce. The kitchen does have that. It correctly added nothing. Your loop must survive all three shapes. ตารางนั้นคือโปรแกรมเดียว รันสามครั้ง เปลี่ยนแค่ MODEL โดย hermes3:8b ขอเครื่องมือทั้งสองก่อนจะเห็นคำตอบจากครัวด้วยซ้ำ มันเดา และเดาถูก พอเราถามถึงน้ำปลาซึ่งมีอยู่ในครัว มันก็ไม่เพิ่มอะไรเลย ถูกต้อง ลูปของคุณต้องรับได้ทั้งสามแบบ 那张表是同一个程序跑三次,只改了 MODELhermes3:8b 在还没看到厨房答案前就把两个工具都要了。它猜了,而且猜对了。我们问鱼露(厨房里有)时,它正确地什么都没加。你的循环要扛得住这三种形态。
YOUR TURN
  1. Create tool3.py and run it.
  2. Check: two tool lines, then open shopping.txt. The line is really there.
  3. Delete shopping.txt and run it again. Watch it come back.
  4. Ask for something you do have. It should not add anything.
  5. Add a third tool, like count_ingredients. Write its docstring first.
  6. Change MODEL to qwen2.5:3b and run again. Count the turns.
  1. สร้าง tool3.py แล้วรัน
  2. ตรวจ: ได้สองบรรทัดเครื่องมือ แล้วเปิด shopping.txt บรรทัดนั้นอยู่จริง
  3. ลบ shopping.txt แล้วรันใหม่ ดูมันกลับมา
  4. ลองถามของที่มีอยู่แล้ว มันไม่ควรเพิ่มอะไร
  5. เพิ่มเครื่องมือที่สาม เช่น count_ingredients เขียน docstring ก่อนเลย
  6. เปลี่ยน MODEL เป็น qwen2.5:3b แล้วรันใหม่ นับจำนวนรอบดู
  1. 新建 tool3.py 并运行。
  2. 检查:两行工具调用,然后打开 shopping.txt,那一行真的在。
  3. 删掉 shopping.txt 再运行一次,看它回来。
  4. 问一样你已经有的东西。它不该加任何东西。
  5. 加第三个工具,比如 count_ingredients。先写它的 docstring。
  6. MODEL 改成 qwen2.5:3b 再跑一次,数数轮数。

🏗️ The same idea, grown up · แนวคิดเดิม ที่โตแล้ว · 同样的想法,长大之后

🇬🇧 English

Real agent libraries do exactly what you just did by hand.

Below is a tool from a working project. A decorator holds the name, the description and the shape. The library builds the menu and runs the loop for you.

You now know what it is doing underneath. That is the point of building it the slow way first.

🇹🇭 ไทย

ไลบรารีเอเจนต์จริง ๆ ทำสิ่งที่คุณเพิ่งทำด้วยมือนี่แหละ

ข้างล่างคือเครื่องมือจากโปรเจกต์ที่ใช้งานจริง เดคอเรเตอร์เก็บชื่อ คำอธิบาย และรูปร่างไว้ ไลบรารีสร้างเมนูและรันลูปให้คุณ

ตอนนี้คุณรู้แล้วว่าข้างใต้มันทำอะไร นั่นคือเหตุผลที่เราสร้างแบบช้า ๆ ด้วยมือก่อน

🇨🇳 中文

真正的智能体库,做的就是你刚才手工做的事。

下面是一个实际项目里的工具。装饰器装着名字、描述和形状,库替你生成菜单、替你跑循环。

现在你知道它底下在做什么了。这就是先用笨办法搭一遍的意义。

📖 For reading only · from a real project, not a step file

from claude_agent_sdk import tool

@tool(
    "spark_models",
    "List the models available on the Spark, so you know what you can offload to.",
    {"type": "object", "properties": {}, "required": []},
)
async def spark_models(args):
    ...

🇬🇧 English

Two differences worth seeing. The description sits in the decorator, not the docstring. And the whole thing is async. Slow tools do not block each other.

Everything else is the menu, the name, and the loop. The same three steps.

🇹🇭 ไทย

มีสองจุดต่างที่ควรสังเกต คำอธิบายอยู่ในเดคอเรเตอร์ ไม่ใช่ใน docstring และทั้งหมดเป็น async เครื่องมือที่ช้าจึงไม่บล็อกกัน

นอกนั้นก็คือเมนู ชื่อ และลูป สามขั้นตอนเดิม

🇨🇳 中文

有两处值得注意:描述放在装饰器里,不在 docstring 里;而且整个是 async 的,慢工具不会互相堵住。

其余就是菜单、名字和循环。还是那三步。

📦 Tutorial 8 — Write it once, use it everywhere · บทที่ 8 — เขียนครั้งเดียว ใช้ได้ทุกที่ · 教程 8 —— 写一次,到处用

🇬🇧 English

Look back at steps 5, 13 and 20. The same seven lines, copied three times.

Copying is fine once. By the third time it is a problem. Fix a bug in one copy. The other two still have it.

So we move those lines into a function. It lives in a file of its own. The parts that change become arguments. Then any program can import it.

🇹🇭 ไทย

ย้อนดูขั้นที่ 5, 13 และ 20 โค้ดเจ็ดบรรทัดเดิม ถูกคัดลอกสามครั้ง

คัดลอกครั้งเดียวไม่เป็นไร พอครั้งที่สามเริ่มเป็นปัญหา แก้บั๊กในสำเนาหนึ่ง อีกสองสำเนายังมีบั๊กอยู่

เราจึงย้ายบรรทัดพวกนั้นไปไว้ในฟังก์ชัน ในไฟล์ของมันเอง ส่วนที่เปลี่ยนก็กลายเป็นอาร์กิวเมนต์ แล้วโปรแกรมไหนก็ import ได้

🇨🇳 中文

回头看第 5、13、20 步。同样的七行,抄了三遍。

抄一次没关系,抄到第三次就是问题了。在一份里修好的 bug,另外两份还在。

所以我们把那几行搬进一个函数,放在它自己的文件里。会变的部分变成参数,然后任何程序都能 import 它。

('Copying the code', 'คัดลอกโค้ด', '复制代码')('One function', 'ฟังก์ชันเดียว', '一个函数')
Fixing a bugแก้บั๊ก修一个 bugFind every copyต้องหาทุกสำเนา要找出每一份复制Fix one fileแก้ไฟล์เดียว改一个文件
New programโปรแกรมใหม่新程序Copy it againคัดลอกอีกครั้ง再抄一遍One import lineimport บรรทัดเดียว一行 import
Reading itอ่านโค้ด读代码Seven lines every timeเจ็ดบรรทัดทุกครั้ง每次都是七行One clear nameชื่อเดียวที่ชัดเจน一个清楚的名字

26 🧰 Put the sending in its own file · ย้ายการส่งไปไว้ในไฟล์ของมันเอง · 把发送单独放进一个文件

🇬🇧 English

This file sends nothing by itself. It only holds a tool.

Everything that changes from letter to letter is now an argument. The sender, the password, who it goes to, the subject, the body.

Notice the docstring again. In tutorial 7 it told the AI what the function does. Here it tells a person. Same words, two readers.

🇹🇭 ไทย

ไฟล์นี้ไม่ส่งอะไรด้วยตัวเอง มันแค่เก็บเครื่องมือไว้

ทุกอย่างที่เปลี่ยนไปในแต่ละฉบับกลายเป็นอาร์กิวเมนต์ ทั้งผู้ส่ง รหัสผ่าน ผู้รับ หัวเรื่อง และเนื้อความ

สังเกต docstring อีกครั้ง ในบทที่ 7 มันบอก AI ว่าฟังก์ชันทำอะไร ตรงนี้มันบอกคน คำเดียวกัน ผู้อ่านสองแบบ

🇨🇳 中文

这个文件自己不发任何东西,它只装着一个工具。

每封信里会变的东西,现在都成了参数:发件人、密码、收件人、主题、正文。

再看看 docstring。教程 7 里它告诉 AI 这个函数做什么,这里它告诉人。同样的文字,两种读者。

📄 NEW FILE · mailer.py

"""mailer.py — one function that sends an email. Other files import it."""
import smtplib
from email.message import EmailMessage

SMTP_HOST = "smtp.gmail.com"   # yahoo: smtp.mail.yahoo.com


def send_email(sender, password, to, subject, body, host=SMTP_HOST):
    """Send one plain email, and return the address it went to.

    sender    the address you send from
    password  the app password for that address
    to        the address you send to
    subject   the subject line
    body      the text of the letter
    host      the mail server, only if you are not on Gmail
    """
    message = EmailMessage()
    message["From"] = sender
    message["To"] = to
    message["Subject"] = subject
    message.set_content(body)

    with smtplib.SMTP_SSL(host, 465) as server:
        server.login(sender, password)
        server.send_message(message)

    return to


if __name__ == "__main__":
    # This file is a toolbox, not a program. Running it sends nothing.
    print("mailer.py holds send_email(). Run send6.py to use it.")
LineWhat it does · ทำอะไร · 做什么
def send_email(sender, password, to, subject, body, host=SMTP_HOST):Six things that change. In step 5 they were all written into the code.หกสิ่งที่เปลี่ยนได้ ในขั้นที่ 5 ทั้งหมดนี้เขียนตายไว้ในโค้ด六样会变的东西。在第 5 步,它们全都写死在代码里。
"""Send one plain email, and return the address it went to.Say what it does, then what each argument is. Write it for the next person.บอกว่ามันทำอะไร แล้วบอกว่าแต่ละอาร์กิวเมนต์คืออะไร เขียนเพื่อคนถัดไป先说它做什么,再说每个参数是什么。写给下一个人看。
host=SMTP_HOSTA default. Leave it out for Gmail, or pass another server.ค่าตั้งต้น ถ้าใช้ Gmail ก็ไม่ต้องใส่ หรือจะส่งเซิร์ฟเวอร์อื่นมาก็ได้一个默认值。用 Gmail 就不用传,也可以传别的服务器。
return toGive something back. The caller can print it, or count it.คืนค่าอะไรสักอย่าง ผู้เรียกจะเอาไปพิมพ์ หรือนับก็ได้要有返回值。调用的人可以打印它,或者拿来计数。
if __name__ == "__main__":The guard from step 2, doing real work. Import this file and the print never runs.ตัวกันจากขั้นที่ 2 ที่ทำงานจริง ถ้า import ไฟล์นี้ บรรทัด print จะไม่ทำงาน第 2 步那个守卫,这里真的起作用了。import 这个文件时,那句 print 不会执行。
A DEFAULT IS FIXED WHEN THE FUNCTION IS BORN We hit this while testing. host=SMTP_HOST reads SMTP_HOST once, as Python reads the def line. Changing SMTP_HOST later does not change the default. Our test still went to Gmail and failed to log in. To use another server, edit the file. Or pass the host when you call. เราเจอเรื่องนี้ตอนทดสอบ host=SMTP_HOST อ่านค่า SMTP_HOST เพียงครั้งเดียว ตอนที่ Python อ่านบรรทัด def การเปลี่ยน SMTP_HOST ทีหลังไม่ทำให้ค่าตั้งต้นเปลี่ยน การทดสอบของเราจึงยังวิ่งไป Gmail แล้วล็อกอินไม่ผ่าน ถ้าจะใช้เซิร์ฟเวอร์อื่น ให้แก้ในไฟล์ หรือส่ง host เข้ามาตอนเรียก 我们测试时就撞上了这个。host=SMTP_HOST 只在 Python 读到 def 那一行时取一次 SMTP_HOST。之后再改 SMTP_HOST 不会改变默认值。我们的测试还是发去了 Gmail,登录失败。要用别的服务器,就改文件,或者调用时把 host 传进去。
YOUR TURN
  1. Create mailer.py in the same folder as your other files.
  2. Run it: python mailer.py
  3. Check: one line of text. No email is sent.
  4. If you are not on Gmail, change SMTP_HOST at the top.
  1. สร้าง mailer.py ในโฟลเดอร์เดียวกับไฟล์อื่น ๆ
  2. รันมัน: python mailer.py
  3. ตรวจ: ได้ข้อความหนึ่งบรรทัด ไม่มีอีเมลถูกส่ง
  4. ถ้าคุณไม่ได้ใช้ Gmail ให้แก้ SMTP_HOST ด้านบน
  1. 在放其他文件的同一个文件夹里新建 mailer.py
  2. 运行它:python mailer.py
  3. 检查:只有一行文字。没有邮件被发出。
  4. 如果你不用 Gmail,改上面的 SMTP_HOST

27 📨 Import it, and send two letters · import มัน แล้วส่งจดหมายสองฉบับ · import 它,发两封信

🇬🇧 English

Now the payoff. One import line, and the tool is yours.

Two letters go out. Different subjects, different words, one function. Nothing about sending appears in this file at all.

That is the whole idea. This file decides what to say. mailer.py decides how to send it.

🇹🇭 ไทย

ทีนี้ก็ได้ผลตอบแทน import บรรทัดเดียว เครื่องมือก็เป็นของคุณ

ส่งจดหมายสองฉบับ คนละหัวเรื่อง คนละข้อความ ใช้ฟังก์ชันเดียว ในไฟล์นี้ไม่มีเรื่องการส่งอยู่เลย

นั่นแหละคือแนวคิดทั้งหมด ไฟล์นี้ตัดสินใจว่าจะพูดอะไร ส่วน mailer.py ตัดสินใจว่าจะส่งอย่างไร

🇨🇳 中文

现在是回报。一行 import,工具就归你了。

两封信发出去,主题不同、内容不同,用的是同一个函数。这个文件里完全没有发送的代码。

这就是全部的想法:这个文件决定说什么,mailer.py 决定怎么发。

📄 NEW FILE · send6.py

"""send6.py — import send_email from mailer.py and send two different letters."""
import os

from mailer import send_email

# Two letters. Same function, different arguments.
LETTERS = [
    ("Homework for Monday",
     "Hello Ploy,\n\nYour homework is on page 12.\n\nKru Eng"),
    ("How Ploy is doing",
     "Hello,\n\nPloy worked hard this week.\n\nKru Eng"),
]


def main():
    """Send every letter in the list, using the one function from mailer.py."""
    sender = os.environ["MAIL_USER"]
    password = os.environ["MAIL_PASS"]
    to = input("send the letters to: ")

    for subject, body in LETTERS:
        sent = send_email(sender, password, to, subject, body)
        print("sent:", subject, "->", sent)

    print("done,", len(LETTERS), "letters")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
from mailer import send_emailTake one name out of mailer.py. No .py, and no folder.ดึงชื่อเดียวออกมาจาก mailer.py ไม่ต้องใส่ .py และไม่ต้องใส่โฟลเดอร์mailer.py 里取一个名字。不写 .py,也不写文件夹。
LETTERS = [The data, kept apart from the code. Add a letter here, change nothing else.ข้อมูลถูกแยกออกจากโค้ด เพิ่มจดหมายตรงนี้ ที่อื่นไม่ต้องแก้数据和代码分开。在这里加一封信,别处都不用动。
for subject, body in LETTERS:One tuple becomes two names. Python unpacks it for you.ทูเพิลหนึ่งตัวกลายเป็นสองชื่อ Python แกะให้เอง一个元组变成两个名字。Python 帮你拆开。
sent = send_email(sender, password, to, subject, body)The same call, twice, with different words. That is what arguments are for.การเรียกแบบเดียวกัน สองครั้ง ด้วยคำที่ต่างกัน อาร์กิวเมนต์มีไว้เพื่อสิ่งนี้同样的调用,两次,内容不同。参数就是干这个用的。
THE FILE NAME IS THE IMPORT NAME from mailer import send_email looks for mailer.py beside this file. Rename the file and the import breaks. Also avoid names Python already uses, like email.py. Your file would hide the real one. Then this tutorial would stop working. from mailer import send_email จะมองหา mailer.py ที่อยู่ข้าง ๆ ไฟล์นี้ ถ้าเปลี่ยนชื่อไฟล์ import ก็พัง และอย่าตั้งชื่อซ้ำกับที่ Python ใช้อยู่ เช่น email.py ไฟล์ของคุณจะไปบังตัวจริง แล้วบทเรียนนี้จะใช้ไม่ได้ from mailer import send_email 会找这个文件旁边的 mailer.py。改了文件名,import 就断了。也别用 Python 已经在用的名字,比如 email.py,你的文件会把真的那个挡住,这一课就跑不起来了。
YOUR TURN
  1. Set MAIL_USER and MAIL_PASS in your terminal, as in step 1.
  2. Create send6.py and run it. Type your own address.
  3. Check: two lines printed, and two emails in your inbox.
  4. Add a third letter to the list. Run it again. You wrote no new sending code.
  5. Import it from inbox4.py as well, to reply to what you found.
  1. ตั้งค่า MAIL_USER และ MAIL_PASS ใน terminal เหมือนขั้นที่ 1
  2. สร้าง send6.py แล้วรัน พิมพ์อีเมลของคุณเอง
  3. ตรวจ: พิมพ์ออกมาสองบรรทัด และมีอีเมลสองฉบับในกล่องจดหมาย
  4. เพิ่มจดหมายฉบับที่สามลงในลิสต์ แล้วรันใหม่ คุณไม่ได้เขียนโค้ดส่งเพิ่มเลย
  5. ลอง import มันใน inbox4.py ด้วย เพื่อตอบกลับอีเมลที่หาเจอ
  1. 在终端里设好 MAIL_USERMAIL_PASS,和第 1 步一样。
  2. 新建 send6.py 并运行,输入你自己的邮箱。
  3. 检查:打印两行,收件箱里有两封邮件。
  4. 往列表里加第三封信,再跑一次。你没有写任何新的发送代码。
  5. 也试着在 inbox4.py 里 import 它,用来回复你找到的邮件。

🏁 Tutorial 9 — Put it all together · บทที่ 9 — รวมทุกอย่างเข้าด้วยกัน · 教程 9 —— 把它们合起来

🇬🇧 English

This is the last one. It uses every part you built.

The program finds an unread email. It asks the AI for a reply. It shows you the reply. Then it sends it, but only if you say yes.

Nothing here is new. You already wrote all three pieces.

🇹🇭 ไทย

นี่คือบทสุดท้าย มันใช้ทุกส่วนที่คุณสร้างมา

โปรแกรมจะหาอีเมลที่ยังไม่ได้อ่าน ขอให้ AI ร่างคำตอบ แสดงคำตอบให้คุณดู แล้วค่อยส่ง ถ้าคุณตอบว่าใช่

ไม่มีอะไรใหม่ตรงนี้เลย คุณเขียนทั้งสามส่วนไปแล้ว

🇨🇳 中文

这是最后一课。它用上了你做过的每一个部分。

程序找出一封未读邮件,请 AI 起草回复,把回复给你看,然后才发送——前提是你说“可以”。

这里没有新东西。这三块你都已经写过了。

('Piece', 'ส่วน', '部分')('From', 'มาจาก', '来自')('What it does', 'ทำอะไร', '做什么')
IMAPIMAPIMAPTutorial 3บทที่ 3教程 3Finds the unread emailหาอีเมลที่ยังไม่ได้อ่าน找出未读邮件
The local modelโมเดลในเครื่อง本机模型Tutorial 4บทที่ 4教程 4Writes the draftเขียนร่างคำตอบ写出草稿
send_emailsend_emailsend_emailTutorial 8บทที่ 8教程 8Sends itส่งมันออกไป把它发出去

28 📥 Read it, and draft an answer · อ่านมัน แล้วร่างคำตอบ · 读取,并起草回复

🇬🇧 English

First, read and draft. Send nothing.

This is tutorial 4's model, not tutorial 6's. That is on purpose. The email belongs to someone else, so it stays on your computer.

Look at the prompt. It says not to invent facts. An email that invents a page number is worse than no email.

🇹🇭 ไทย

ขั้นแรก อ่านแล้วร่าง ยังไม่ส่งอะไร

นี่คือโมเดลจากบทที่ 4 ไม่ใช่บทที่ 6 และตั้งใจให้เป็นแบบนั้น อีเมลเป็นของคนอื่น มันจึงต้องอยู่ในเครื่องคุณ

ดูที่พรอมป์ต มันบอกว่าอย่าแต่งข้อเท็จจริง อีเมลที่มั่วเลขหน้าการบ้าน แย่กว่าการไม่ส่งอีเมลเลย

🇨🇳 中文

第一步:读取并起草,什么都不发。

这里用的是教程 4 的模型,不是教程 6 的。这是刻意的:邮件是别人的,所以它留在你的电脑上。

看看提示词,它要求不要编造事实。一封瞎编作业页码的邮件,比不发还糟。

📄 NEW FILE · reply1.py

"""reply1.py — read one unread email, and ask the local AI to draft a reply."""
import email
import email.policy
import imaplib
import json
import os
import urllib.request

IMAP_HOST = "imap.gmail.com"
OLLAMA = "http://localhost:11434/api/chat"
MODEL = "hermes3:8b"

REPLY_PROMPT = """You are answering an email for a busy English teacher.

Write a short, warm reply. Three sentences at most. Be polite and clear.
Do not invent facts. If the email asks something you cannot know, say you will check.
Write only the reply. No subject line, and no signature.

=== THE EMAIL ===
From: {sender}
Subject: {subject}

{body}
=== END OF EMAIL ==="""


def newest_unread():
    """Find the newest unread email. Return the sender, subject and text."""
    box = imaplib.IMAP4_SSL(IMAP_HOST)
    box.login(os.environ["MAIL_USER"], os.environ["MAIL_PASS"])
    box.select("INBOX", readonly=True)

    _, ids = box.search(None, "UNSEEN")
    if not ids[0]:
        box.logout()
        raise SystemExit("Nothing unread. Send yourself an email first.")

    newest = ids[0].split()[-1]
    _, data = box.fetch(newest, "(BODY.PEEK[])")   # PEEK: it stays unread
    box.logout()

    message = email.message_from_bytes(data[0][1], policy=email.policy.default)
    text = message.get_body(preferencelist=["plain"])
    return message["From"], message["Subject"], text.get_content().strip() if text else ""


def ask_local_model(prompt):
    """Send the prompt to Ollama on this computer, and return the answer."""
    body = json.dumps({"model": MODEL, "stream": False,
                       "messages": [{"role": "user", "content": prompt}]}).encode()
    request = urllib.request.Request(OLLAMA, data=body,
                                     headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=300) as answer:
        return json.load(answer)["message"]["content"]


def main():
    """Show the newest unread email, then a draft reply. Send nothing."""
    sender, subject, body = newest_unread()
    print("from:   ", sender)
    print("subject:", subject)
    print()
    print(body[:400])
    print()
    print("asking the model on this computer, so the email stays here")

    draft = ask_local_model(REPLY_PROMPT.format(sender=sender, subject=subject, body=body))

    print()
    print("--- draft ---")
    print(draft)
    print("--- nothing was sent ---")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
_, ids = box.search(None, "UNSEEN")Unread, not a word in the subject. The mail server already tracks this for you.ยังไม่ได้อ่าน ไม่ใช่คำในหัวเรื่อง เซิร์ฟเวอร์เมลติดตามให้อยู่แล้ว按未读找,而不是按主题里的词。邮件服务器已经替你记着了。
box.select("INBOX", readonly=True)Read only. Looking at an email here does not mark it read.อ่านอย่างเดียว การเปิดดูอีเมลตรงนี้ไม่ทำให้มันถูกทำเครื่องหมายว่าอ่านแล้ว只读。在这里看邮件不会把它标成已读。
Do not invent facts.A rule in the prompt. The model obeyed it in our test, and said it would check.กฎที่อยู่ในพรอมป์ต ในการทดสอบของเรา โมเดลทำตาม และบอกว่าจะไปตรวจสอบให้提示词里的一条规则。我们测试时模型照做了,说它会去确认。
MODEL = "hermes3:8b"The same model as tutorial 7. It runs here, so the email never leaves.โมเดลเดียวกับบทที่ 7 มันรันที่นี่ อีเมลจึงไม่ออกไปไหน和教程 7 同一个模型。它在本机跑,邮件不会离开。
print("--- nothing was sent ---")Say so out loud. A program that can send email must say when it did not.บอกให้ชัด โปรแกรมที่อาจส่งอีเมลได้ ควรบอกให้ชัดเมื่อมันไม่ได้ส่ง明明白白说出来。一个可能发邮件的程序,没发时也要说清楚。
OTHER PEOPLE'S EMAIL STAYS ON YOUR COMPUTER Tutorial 6 was faster, and this one is slower. We use the slow one anyway. A parent wrote to you, not to a company. Look again at the table in tutorial 6. This is the row that says student work and private notes. บทที่ 6 เร็วกว่า ส่วนบทนี้ช้ากว่า แต่เราก็ยังเลือกตัวที่ช้า ผู้ปกครองเขียนถึงคุณ ไม่ได้เขียนถึงบริษัท ลองดูตารางในบทที่ 6 อีกครั้ง นี่คือแถวที่เขียนว่างานนักเรียนและบันทึกส่วนตัว 教程 6 更快,这一课更慢。我们还是选慢的那个。写信给你的是家长,不是公司。再看一遍教程 6 的表格,这就是写着「学生作业、私人笔记」的那一行。
YOUR TURN
  1. Send yourself an email, so there is something unread.
  2. Create reply1.py and run it.
  3. Check: the email is printed, then a draft. Nothing is sent.
  4. Read the draft. Would you send it as it is?
  5. Take Do not invent facts out of the prompt. Run it again and compare.
  1. ส่งอีเมลถึงตัวเอง เพื่อให้มีอีเมลที่ยังไม่ได้อ่าน
  2. สร้าง reply1.py แล้วรัน
  3. ตรวจ: อีเมลถูกพิมพ์ออกมา ตามด้วยร่างคำตอบ ไม่มีอะไรถูกส่ง
  4. อ่านร่างดู คุณจะส่งมันไปแบบนั้นเลยไหม
  5. ลองเอา Do not invent facts ออกจากพรอมป์ต แล้วรันใหม่ เทียบผลดู
  1. 给自己发一封邮件,这样就有未读的了。
  2. 新建 reply1.py 并运行。
  3. 检查:先打印邮件,然后是草稿。什么都没发出去。
  4. 读读那份草稿。你会就这样发出去吗?
  5. 把提示词里的 Do not invent facts 删掉,再跑一次,比一比。

29 ✅ Ask a human, then send · ถามคนก่อน แล้วค่อยส่ง · 先问人,再发送

🇬🇧 English

Now it can send. Two small things make that safe.

One: a person reads the draft and types yes. Two: the email is marked as read. It is never answered twice.

And notice the imports. Your own two files, used like any library.

🇹🇭 ไทย

ตอนนี้มันส่งได้แล้ว มีสองสิ่งเล็ก ๆ ที่ทำให้ปลอดภัย

หนึ่ง คนอ่านร่างแล้วพิมพ์ว่า yes สอง อีเมลถูกทำเครื่องหมายว่าอ่านแล้ว มันจึงไม่ถูกตอบซ้ำสองครั้ง

และสังเกตบรรทัด import ไฟล์สองไฟล์ของคุณเอง ถูกใช้เหมือนไลบรารีทั่วไป

🇨🇳 中文

现在它可以发了。两件小事让这件事安全。

一:由人读过草稿并输入 yes。二:邮件被标为已读,所以永远不会被回两次。

再看看那两行 import:你自己的两个文件,用起来和任何库一样。

📄 NEW FILE · reply2.py

"""reply2.py — draft a reply, show it to you, and send it only if you say yes."""
import email
import email.policy
import imaplib
import os

from mailer import send_email
from reply1 import IMAP_HOST, REPLY_PROMPT, ask_local_model


def main():
    """Answer the newest unread email, after a person says yes."""
    user = os.environ["MAIL_USER"]
    password = os.environ["MAIL_PASS"]

    box = imaplib.IMAP4_SSL(IMAP_HOST)
    box.login(user, password)
    box.select("INBOX")           # not readonly this time: we mark it at the end

    _, ids = box.search(None, "UNSEEN")
    if not ids[0]:
        box.logout()
        raise SystemExit("Nothing unread.")
    newest = ids[0].split()[-1]

    _, data = box.fetch(newest, "(BODY.PEEK[])")
    message = email.message_from_bytes(data[0][1], policy=email.policy.default)
    text = message.get_body(preferencelist=["plain"])
    body = text.get_content().strip() if text else ""

    draft = ask_local_model(REPLY_PROMPT.format(
        sender=message["From"], subject=message["Subject"], body=body))

    print("to:      ", message["From"])
    print("subject: ", "Re: " + message["Subject"])
    print()
    print(draft)
    print()

    # A person reads it before it leaves. This line is the whole safety net.
    if input("send this? type yes: ").strip().lower() != "yes":
        box.logout()
        raise SystemExit("Not sent. The email is still unread.")

    send_email(user, password, message["From"], "Re: " + message["Subject"], draft)

    box.store(newest, "+FLAGS", "\\Seen")   # so it is never answered twice
    box.logout()
    print("sent, and marked as read")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
from mailer import send_emailTutorial 8's file. You never write the sending code again.ไฟล์จากบทที่ 8 คุณไม่ต้องเขียนโค้ดส่งอีกเลย教程 8 的文件。你再也不用写发送代码了。
from reply1 import IMAP_HOST, REPLY_PROMPT, ask_local_modelYour own file again. The prompt lives in one place. You fix it in one place.ไฟล์ของคุณเองอีกครั้ง พรอมป์ตอยู่ที่เดียว คุณจึงแก้ที่เดียว又是你自己的文件。提示词只有一份,所以只改一处。
box.select("INBOX")No readonly this time. We are going to change a flag at the end.คราวนี้ไม่มี readonly เพราะตอนท้ายเราจะไปเปลี่ยนแฟล็ก这次没有 readonly。最后我们要改一个标记。
if input("send this? type yes: ")The whole safety net, in one line. Anything except yes stops the program.ตาข่ายนิรภัยทั้งหมดอยู่ในบรรทัดเดียว อะไรก็ตามที่ไม่ใช่ yes จะหยุดโปรแกรม整张安全网就这一行。除了 yes,任何输入都会停下程序。
box.store(newest, "+FLAGS", "\\Seen")Mark it read. Run the program again and it moves to the next email.ทำเครื่องหมายว่าอ่านแล้ว รันโปรแกรมอีกครั้ง มันจะไปยังอีเมลถัดไป标成已读。再运行一次,它就会去处理下一封。
THIS IS WHY A PERSON MUST READ IT FIRST Our real test produced a good reply. It ended with [Your Name]. The model left a blank for a human to fill in. Send that without looking. A parent gets a letter signed [Your Name]. The draft was not wrong. It was not finished. การทดสอบจริงของเราได้คำตอบที่ดี แล้วลงท้ายด้วย [Your Name] โมเดลเว้นช่องว่างไว้ให้คนมาเติม ถ้าส่งไปโดยไม่ดู ผู้ปกครองจะได้จดหมายที่ลงชื่อว่า [Your Name] ร่างนั้นไม่ได้ผิด แต่มันยังไม่เสร็จ 我们的真实测试写出了不错的回复,结尾却是 [Your Name]。模型留了个空给人来填。不看就发出去,家长收到的信就会署名 [Your Name]。那份草稿没有错,只是还没写完。
NEVER LET IT RUN ON ITS OWN It is tempting to delete the input line. Then run it every morning. Do not. A confused model still answers confidently. And email cannot be taken back. Keep the person. Let the computer do the typing. มันน่าลองลบบรรทัด input ออก แล้วปล่อยให้รันทุกเช้า อย่าทำ โมเดลที่สับสนจะตอบอย่างมั่นใจ และอีเมลที่ส่งไปแล้วเรียกคืนไม่ได้ เก็บคนไว้ ให้คอมพิวเตอร์เป็นคนพิมพ์ 你会很想删掉那行 input,让它每天早上自己跑。别这么做。糊涂的模型会自信地回答,而邮件发出去就收不回来。把人留在流程里,让电脑负责打字。
YOUR TURN
  1. Create reply2.py and run it.
  2. Read the draft. Type anything except yes. Nothing is sent.
  3. Run it again and type yes this time. Check your inbox.
  4. Run it a third time. It goes to the next unread email, not the same one.
  5. Change the prompt in reply1.py. Both programs change together.
  1. สร้าง reply2.py แล้วรัน
  2. อ่านร่าง แล้วพิมพ์อะไรก็ได้ที่ไม่ใช่ yes จะไม่มีอะไรถูกส่ง
  3. รันอีกครั้ง คราวนี้พิมพ์ yes แล้วไปดูกล่องจดหมาย
  4. รันครั้งที่สาม มันจะไปที่อีเมลถัดไปที่ยังไม่ได้อ่าน ไม่ใช่ฉบับเดิม
  5. ลองแก้พรอมป์ตใน reply1.py โปรแกรมทั้งสองจะเปลี่ยนตามไปด้วยกัน
  1. 新建 reply2.py 并运行。
  2. 读草稿,然后输入除 yes 以外的任何内容。什么都不会发出。
  3. 再跑一次,这次输入 yes,然后看看收件箱。
  4. 再跑第三次。它会去处理下一封未读邮件,不是同一封。
  5. 改一下 reply1.py 里的提示词。两个程序会一起改变。

🎓 You finished · คุณเรียนจบแล้ว · 你完成了

🇬🇧 English

Nine tutorials. Twenty-nine steps. Nineteen files that all run.

You can send email, read it, and search it. You can write files and read them back. You can ask an AI on your computer. Or one far away. You can give that AI a tool. And you can wrap your work in a function. Other programs import it.

That last program is a small agent. It reads, it thinks, it asks, it acts. Every serious agent is built from these same four moves.

🇹🇭 ไทย

เก้าบท ยี่สิบเก้าขั้น สิบเก้าไฟล์ที่รันได้จริงทั้งหมด

คุณส่งอีเมลได้ อ่านได้ ค้นหาได้ เขียนไฟล์และอ่านกลับได้ ถาม AI ในเครื่องของคุณเองได้ หรือจะถามตัวที่อยู่ในศูนย์ข้อมูลก็ได้ คุณให้เครื่องมือกับ AI ได้ และห่องานของคุณเองไว้ในฟังก์ชันที่โปรแกรมอื่น import ได้

โปรแกรมสุดท้ายนั้นคือเอเจนต์เล็ก ๆ มันอ่าน มันคิด มันถาม มันลงมือ เอเจนต์จริงจังทุกตัวที่คุณจะได้เจอ สร้างจากสี่จังหวะเดียวกันนี้

🇨🇳 中文

九个教程,二十九步,十九个都能跑的文件。

你能发邮件、读邮件、搜邮件;能写文件也能读回来;能问你自己电脑上的 AI,也能问数据中心里的;能给 AI 一个工具;还能把自己的活儿包成一个函数,让别的程序 import。

最后那个程序就是一个小小的智能体:它读、它想、它问、它动手。你以后遇到的每个正经智能体,都是由这同样四步搭起来的。

🔊 Tutorial 10 — Listen to your email · บทที่ 10 — ฟังอีเมลของคุณ · 教程 10 —— 听你的邮件

🇬🇧 English

You can send email, read it, and answer it. Now you can hear it.

Piper is a speaking voice that runs on your computer. You download a voice file once. After that it works with no internet and no key. No company in the middle.

This is useful on a motorbike, and useful with tired eyes. For someone who cannot see the screen, it is the whole program.

🇹🇭 ไทย

คุณส่งอีเมลได้ อ่านได้ และตอบได้ คราวนี้ฟังได้ด้วย

Piper คือเสียงพูดที่ทำงานบนเครื่องของคุณ ดาวน์โหลดไฟล์เสียงครั้งเดียว หลังจากนั้นใช้ได้โดยไม่ต้องมีอินเทอร์เน็ต ไม่ต้องมีคีย์ และไม่มีบริษัทไหนอยู่ตรงกลาง

มีประโยชน์ตอนขี่มอเตอร์ไซค์ และตอนตาล้า สำหรับคนที่มองจอไม่เห็น นี่คือทั้งโปรแกรมเลย

🇨🇳 中文

你会发邮件、读邮件、回邮件了。现在还能听。

Piper 是一个跑在你自己电脑上的说话声音。语音文件只下载一次,之后就不需要网络、不需要密钥,中间也没有任何公司。

骑摩托车时有用,眼睛累了也有用。对看不见屏幕的人来说,这就是整个程序。

THIS IS THE SAME CHOICE AS TUTORIAL 4 Tutorial 4 kept your words on your own machine. Tutorial 6 sent them away. Speech has the same two doors. Your email is not yours alone. It belongs to whoever wrote it. So this tutorial walks through the door that keeps it here. บทที่ 4 เก็บคำของคุณไว้ในเครื่องคุณเอง ส่วนบทที่ 6 ส่งมันออกไป เรื่องเสียงพูดก็มีสองประตูแบบเดียวกัน อีเมลของคุณไม่ได้เป็นของคุณคนเดียว มันเป็นของคนที่เขียนมันด้วย บทนี้จึงเลือกประตูที่เก็บมันไว้ที่นี่ 教程 4 把你的文字留在自己机器上,教程 6 把它送了出去。语音也是同样的两道门。你的邮件不只属于你,也属于写它的人。所以这一课走的是把它留在本地的那道门。

⌨️ TERMINAL · install it, then fetch one voice

python -m pip install piper-tts
python -m piper.download_voices en_US-lessac-medium --download-dir voices   # about 60 MB, once

30 🗣️ One sentence, one voice · ประโยคเดียว เสียงเดียว · 一句话,一个声音

🇬🇧 English

Start with one sentence and one voice.

A voice is two files on your disk. The .onnx file is the voice itself. The .json beside it tells Piper how to use it.

Turn off your wifi and run it again. It still works.

🇹🇭 ไทย

เริ่มจากประโยคเดียว เสียงเดียว

เสียงหนึ่งเสียงคือไฟล์สองไฟล์บนดิสก์ ไฟล์ .onnx คือตัวเสียงเอง ส่วนไฟล์ .json ที่อยู่ข้าง ๆ บอก Piper ว่าจะใช้มันอย่างไร

ลองปิดไวไฟแล้วรันใหม่ มันก็ยังทำงานได้

🇨🇳 中文

先从一句话、一个声音开始。

一个语音是磁盘上的两个文件。.onnx 是语音本身,旁边的 .json 告诉 Piper 怎么用它。

把 wifi 关掉再跑一次,它照样能用。

📄 NEW FILE · say1.py

"""say1.py — turn a sentence into sound, without leaving your computer."""
import wave

from piper import PiperVoice

VOICE = "voices/en_US-lessac-medium.onnx"


def speak(text, voice_path, out_path):
    """Load a voice, read the text, and save it as a wav file."""
    voice = PiperVoice.load(voice_path)
    with wave.open(out_path, "wb") as wav:
        voice.synthesize_wav(text, wav)
    return out_path


if __name__ == "__main__":
    saved = speak("Good morning, teacher. This never left your computer.",
                  VOICE, "hello.wav")
    print("saved:", saved)
LineWhat it does · ทำอะไร · 做什么
from piper import PiperVoiceOne class does everything. No key, no account, no address.คลาสเดียวทำได้ทุกอย่าง ไม่ต้องมีคีย์ ไม่ต้องมีบัญชี ไม่ต้องมีที่อยู่เว็บ一个类就够了。不用密钥、不用账号、不用网址。
VOICE = "voices/en_US-lessac-medium.onnx"The file you downloaded. Point at the .onnx, not the .json.ไฟล์ที่คุณดาวน์โหลดมา ชี้ไปที่ .onnx ไม่ใช่ .json你下载的那个文件。指向 .onnx,不是 .json。
voice = PiperVoice.load(voice_path)Reading the voice into memory. This is the slow line, and step 31 uses that.โหลดเสียงเข้าหน่วยความจำ บรรทัดนี้ช้า และขั้นที่ 31 จะใช้ประโยชน์จากเรื่องนี้把语音读进内存。这是慢的那一行,第 31 步会利用这一点。
with wave.open(out_path, "wb") as wav:Piper writes a wav, not an mp3. wave comes with Python.Piper เขียนไฟล์ wav ไม่ใช่ mp3 โมดูล wave มากับ Python อยู่แล้วPiper 写的是 wav,不是 mp3。wave 是 Python 自带的。
voice.synthesize_wav(text, wav)One line, and the words become sound.บรรทัดเดียว คำก็กลายเป็นเสียง一行,文字就变成了声音。

🇬🇧 English

There are 177 voices to choose from. Here are ones worth knowing.

🇹🇭 ไทย

มีเสียงให้เลือก 177 เสียง นี่คือเสียงที่ควรรู้จัก

🇨🇳 中文

一共有 177 个语音可选。下面是值得知道的几个。

('Voice file', 'ไฟล์เสียง', '语音文件')('Sounds like', 'ฟังดูเป็น', '听起来像')('Good for', 'เหมาะกับ', '适合')
en_US-lessac-mediumAmerican, clearอเมริกัน ชัดเจน美式,清晰The safe first choiceตัวเลือกแรกที่ปลอดภัย第一个安全的选择
en_US-lessac-highThe same, betterตัวเดิม แต่ดีกว่า同一个,更好Slower, bigger fileช้ากว่า ไฟล์ใหญ่กว่า更慢,文件更大
en_US-amy-mediumAmerican womanอเมริกัน ผู้หญิง美式女声A second speakerผู้พูดคนที่สอง第二个说话人
en_US-ryan-highAmerican manอเมริกัน ผู้ชาย美式男声Dialogue pairsบทสนทนาคู่对话搭档
en_GB-alba-mediumScottishสกอต苏格兰Accent practiceฝึกฟังสำเนียง口音练习
en_GB-cori-highBritish womanอังกฤษ ผู้หญิง英式女声Listening practiceฝึกการฟัง听力练习
en_GB-alan-mediumBritish manอังกฤษ ผู้ชาย英式男声Listening practiceฝึกการฟัง听力练习
en_GB-northern_english_male-mediumNorthern Englishอังกฤษตอนเหนือ英格兰北部Accent practiceฝึกฟังสำเนียง口音练习
zh_CN-huayan-mediumMandarin womanจีนกลาง ผู้หญิง普通话女声Chinese studentsนักเรียนจีน中国学生
zh_CN-xiao_ya-mediumMandarin, youngerจีนกลาง เสียงเด็กกว่า普通话,更年轻Chinese studentsนักเรียนจีน中国学生
YOUR TURN
  1. Install Piper, then download en_US-lessac-medium.
  2. Create say1.py and run it.
  3. Check: hello.wav appears. Ours was 3.2 seconds.
  4. Turn your wifi off and run it again. It still works.
  5. Download a second voice and change VOICE.
  1. ติดตั้ง Piper แล้วดาวน์โหลด en_US-lessac-medium
  2. สร้าง say1.py แล้วรัน
  3. ตรวจ: ไฟล์ hello.wav จะโผล่มา ของเรายาว 3.2 วินาที
  4. ลองปิดไวไฟแล้วรันใหม่ มันก็ยังทำงานได้
  5. ดาวน์โหลดเสียงที่สอง แล้วเปลี่ยนค่า VOICE
  1. 安装 Piper,然后下载 en_US-lessac-medium
  2. 新建 say1.py 并运行。
  3. 检查:出现 hello.wav。我们的是 3.2 秒。
  4. 把 wifi 关掉再跑一次,它照样能用。
  5. 再下载一个语音,改掉 VOICE

31 ⏱️ Load once, speak often · โหลดครั้งเดียว พูดได้เรื่อย ๆ · 加载一次,反复说

🇬🇧 English

Loading a voice is slow. Speaking with it is fast. Those two facts decide your code.

So load the voice once, outside the loop. Then use it as many times as you like.

A second language is a second voice file, and nothing else.

🇹🇭 ไทย

การโหลดเสียงนั้นช้า แต่การพูดด้วยเสียงนั้นเร็ว สองข้อนี้เป็นตัวกำหนดว่าโค้ดคุณควรเป็นอย่างไร

ฉะนั้นโหลดเสียงครั้งเดียว ไว้นอกลูป แล้วใช้มันกี่ครั้งก็ได้

ภาษาที่สองก็คือไฟล์เสียงที่สอง ไม่มีอะไรมากกว่านั้น

🇨🇳 中文

加载语音很慢,用它说话很快。这两件事决定了你的代码该怎么写。

所以把语音加载一次,放在循环外面,然后想用多少次就用多少次。

第二种语言就是第二个语音文件,没有别的。

📄 NEW FILE · say2.py

"""say2.py — two languages, and the one habit that makes this fast."""
import sys
import time
import wave

from piper import PiperVoice

# Windows terminals need this before they can print Chinese or Thai.
sys.stdout.reconfigure(encoding="utf-8")

ENGLISH = "voices/en_US-lessac-medium.onnx"
CHINESE = "voices/zh_CN-huayan-medium.onnx"

LESSON = [
    "Good morning, teacher.",
    "Open your book, please.",
    "Today we read about Chiang Mai.",
]


def main():
    """Load a voice once, then use it for every line."""
    start = time.time()
    voice = PiperVoice.load(ENGLISH)
    print("loading the voice took", round(time.time() - start, 1), "seconds")

    start = time.time()
    for number, line in enumerate(LESSON, start=1):
        with wave.open("lesson" + str(number) + ".wav", "wb") as wav:
            voice.synthesize_wav(line, wav)
        print("saved: lesson" + str(number) + ".wav |", line)
    print("saying all three took", round(time.time() - start, 1), "seconds")

    # A different language needs a different voice file, and nothing else.
    chinese = PiperVoice.load(CHINESE)
    with wave.open("hello_zh.wav", "wb") as wav:
        chinese.synthesize_wav("老师,早上好。", wav)
    print("saved: hello_zh.wav | 老师,早上好。")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
voice = PiperVoice.load(ENGLISH)Outside the loop, on purpose. Put it inside and every line pays again.อยู่นอกลูป โดยตั้งใจ ถ้าเอาไว้ข้างใน ทุกบรรทัดจะต้องจ่ายค่าโหลดใหม่故意放在循环外。放进去的话,每一行都要再付一次代价。
for number, line in enumerate(LESSON, start=1):enumerate counts for you, so the files get useful names.enumerate นับให้คุณ ไฟล์จึงได้ชื่อที่มีความหมายenumerate 替你计数,文件就能有像样的名字。
chinese = PiperVoice.load(CHINESE)A different language. Same two lines of code.คนละภาษา แต่โค้ดสองบรรทัดเดิม换一种语言,代码还是那两行。
sys.stdout.reconfigure(encoding="utf-8")Without this a Windows terminal crashes when it prints Chinese.ถ้าไม่มีบรรทัดนี้ terminal ของ Windows จะพังตอนพิมพ์ภาษาจีน没有这一行,Windows 终端打印中文时会崩掉。
('What we timed', 'เราจับเวลาอะไร', '我们测了什么')('Took', 'ใช้เวลา', '用时')
Loading the voice, onceโหลดเสียง หนึ่งครั้ง加载语音,一次1.8s
Saying three sentencesพูดสามประโยค说三句话0.2s
LOAD ONCE, SPEAK OFTEN Loading took nine times longer than all three sentences together. Move that line inside the loop. Thirty sentences then take a minute, not two seconds. This is the whole lesson of this step. การโหลดใช้เวลามากกว่าการพูดครบทั้งสามประโยคถึงเก้าเท่า ถ้าย้ายบรรทัดนั้นเข้าไปในลูป บทเรียนสามสิบประโยคจะใช้เวลาหนึ่งนาที แทนที่จะเป็นสองวินาที นี่คือบทเรียนทั้งหมดของขั้นนี้ 加载花的时间,是说完三句话的九倍。把那一行挪进循环里,三十句的课文就要一分钟,而不是两秒。这就是这一步的全部内容。
YOUR TURN
  1. Download zh_CN-huayan-medium into the same folder.
  2. Create say2.py and run it.
  3. Check: three lesson files, and one Chinese file.
  4. Move the PiperVoice.load line inside the loop. Time it again.
  5. Add ten more sentences and watch the difference grow.
  1. ดาวน์โหลด zh_CN-huayan-medium ไว้ในโฟลเดอร์เดียวกัน
  2. สร้าง say2.py แล้วรัน
  3. ตรวจ: ได้ไฟล์บทเรียนสามไฟล์ และไฟล์ภาษาจีนหนึ่งไฟล์
  4. ลองย้ายบรรทัด PiperVoice.load เข้าไปในลูป แล้วจับเวลาใหม่
  5. เพิ่มประโยคอีกสิบประโยค แล้วดูว่าต่างกันมากขึ้นแค่ไหน
  1. zh_CN-huayan-medium 下载到同一个文件夹。
  2. 新建 say2.py 并运行。
  3. 检查:三个课文文件,一个中文文件。
  4. PiperVoice.load 挪进循环里,再计一次时。
  5. 再加十句,看看差距怎么变大。

32 📬 Listen to the newest email · ฟังอีเมลฉบับล่าสุด · 听最新的那封邮件

🇬🇧 English

Now the real thing. Find the newest unread email, and listen to it.

The finding is step 28's code again. The speaking is step 30's. The new part sits between them. It matters more than it looks.

Email is written for eyes. A web link is fine to look at. Read aloud, it is a minute of noise.

🇹🇭 ไทย

คราวนี้ของจริง หาอีเมลที่ยังไม่ได้อ่านฉบับล่าสุด แล้วฟังมัน

การค้นหาคือโค้ดจากขั้นที่ 28 การพูดคือขั้นที่ 30 ส่วนใหม่อยู่ตรงกลาง และสำคัญกว่าที่เห็น

อีเมลเขียนไว้ให้ตาอ่าน ลิงก์เว็บดูด้วยตาก็ไม่เป็นไร แต่พออ่านออกเสียง มันคือเสียงรบกวนเป็นนาที

🇨🇳 中文

现在来真的:找出最新的未读邮件,然后听它。

查找用的是第 28 步的代码,朗读用的是第 30 步的。新的部分夹在中间,它比看上去重要。

邮件是写给眼睛看的。网址用看的没问题,念出来就是一分钟的噪音。

📄 NEW FILE · say3.py

"""say3.py — listen to your newest email, with nothing leaving the computer."""
import email
import email.policy
import imaplib
import os
import re
import sys
import wave
from email.utils import parseaddr

from piper import PiperVoice

sys.stdout.reconfigure(encoding="utf-8")

IMAP_HOST = "imap.gmail.com"
VOICE = "voices/en_US-lessac-medium.onnx"
MAX_CHARS = 800


def newest_unread():
    """Find the newest unread email. Return the sender, subject and text."""
    box = imaplib.IMAP4_SSL(IMAP_HOST)
    box.login(os.environ["MAIL_USER"], os.environ["MAIL_PASS"])
    box.select("INBOX", readonly=True)

    _, ids = box.search(None, "UNSEEN")
    if not ids[0]:
        box.logout()
        raise SystemExit("Nothing unread. Send yourself an email first.")

    newest = ids[0].split()[-1]
    _, data = box.fetch(newest, "(BODY.PEEK[])")     # PEEK: it stays unread
    box.logout()

    message = email.message_from_bytes(data[0][1], policy=email.policy.default)
    text = message.get_body(preferencelist=["plain"])
    return message["From"], message["Subject"], text.get_content().strip() if text else ""


def for_the_ear(body):
    """Email is written for eyes. Take out what sounds terrible."""
    keep = []
    for line in body.splitlines():
        if line.startswith(">"):                     # the older email underneath
            continue
        line = re.sub(r"https?://\S+", "a link", line)
        keep.append(line)

    spoken = " ".join(" ".join(keep).split())        # one tidy paragraph
    return spoken[:MAX_CHARS]


def main():
    """Turn the newest unread email into something you can listen to."""
    sender, subject, body = newest_unread()

    # "Ploy <ploy@school.ac.th>" reads badly. Use the name if there is one.
    name, address = parseaddr(sender)
    who = name or address

    script = ("Email from " + who + ". "
              "Subject: " + subject + ". "
              + for_the_ear(body))
    print(script[:200])

    voice = PiperVoice.load(VOICE)
    with wave.open("email.wav", "wb") as wav:
        voice.synthesize_wav(script, wav)
    print("saved: email.wav")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
def for_the_ear(body):The whole lesson in one function. What reads well does not always sound well.บทเรียนทั้งหมดอยู่ในฟังก์ชันเดียว สิ่งที่อ่านแล้วดี ไม่ได้ฟังแล้วดีเสมอไป整节课都在这一个函数里。读着顺的,听着不一定顺。
if line.startswith(">"):Quoted lines are the older email underneath. You have heard it already.บรรทัดที่ขึ้นต้นด้วย > คืออีเมลเก่าที่อยู่ข้างล่าง คุณฟังไปแล้ว以 > 开头的行是下面那封旧邮件。你已经听过了。
line = re.sub(r"https?://\S+", "a link", line)Two words instead of sixty characters of nonsense.สองคำ แทนที่จะเป็นอักขระไร้ความหมายหกสิบตัว两个词,代替六十个毫无意义的字符。
return spoken[:MAX_CHARS]A cap. Nobody wants to hear a very long newsletter.ตัดความยาว ไม่มีใครอยากฟังจดหมายข่าวยาว ๆ设个上限。没人想听完一份超长的通讯。
name, address = parseaddr(sender)Ploy <ploy@school.ac.th> sounds terrible. Use the name when there is one.Ploy <ploy@school.ac.th> ฟังแล้วแย่มาก ถ้ามีชื่อ ให้ใช้ชื่อPloy <ploy@school.ac.th> 念出来很难听。有名字就用名字。
box.fetch(newest, "(BODY.PEEK[])")PEEK again, so listening does not mark it read.ใช้ PEEK อีกครั้ง การฟังจะได้ไม่ทำให้มันถูกทำเครื่องหมายว่าอ่านแล้ว还是用 PEEK,听过不会把它标成已读。
('Version of the same email', 'อีเมลเดียวกัน สองแบบ', '同一封邮件的两个版本')('Characters', 'จำนวนอักขระ', '字符数')('Time to listen', 'เวลาที่ต้องฟัง', '要听多久')
Straight from the serverดิบ ๆ จากเซิร์ฟเวอร์直接来自服务器26021.9s
for_the_ear()1196.9s
WE MEASURED WHAT THE CLEANING SAVES Our test email had one long link and three quoted lines. Cleaned, it lost half its characters. And two thirds of its playing time. That is 69 percent less listening. The link is never read out loud. A URL takes a long time to say. It means nothing to the ear. อีเมลทดสอบของเรามีลิงก์ยาวหนึ่งอัน และบรรทัดที่ถูกอ้างอิงสามบรรทัด พอทำความสะอาดแล้ว อักขระหายไปกว่าครึ่ง และเวลาเล่นหายไปสองในสาม นั่นคือฟังน้อยลง 69 เปอร์เซ็นต์ และลิงก์ไม่ถูกอ่านออกเสียงเลย ที่อยู่เว็บใช้เวลาพูดนาน และไม่มีความหมายอะไรกับหู 我们的测试邮件有一条长链接和三行引用。清理之后,字符少了一半多,播放时间少了三分之二。也就是少听 69%,而且链接一次都没被念出来。网址念起来很久,对耳朵却毫无意义。
A VOICE IS NOT A TOY HERE This is the program to keep. Someone who cannot see the screen can now read their email. Their email never leaves their machine. That matters more for them, not less. So finish it properly. Let them choose the voice. Let them replay it. นี่คือโปรแกรมที่ควรเก็บไว้ คนที่มองจอไม่เห็นก็อ่านอีเมลของตัวเองได้แล้ว และอีเมลของเขาก็ไม่เคยออกไปจากเครื่องด้วย ซึ่งสำคัญกับเขามากกว่า ไม่ใช่น้อยกว่า ฉะนั้นทำให้มันเสร็จดี ๆ ให้เขาเลือกเสียงได้ ให้เขาเล่นซ้ำได้ 这是值得留下的程序。看不见屏幕的人,现在能读自己的邮件了。而且他的邮件从不离开这台机器,这对他来说更重要,不是更不重要。所以要把它做完整:让他选语音,让他重放。
YOUR TURN
  1. Send yourself an email with a long link in it.
  2. Create say3.py and run it.
  3. Check: the printed script says a link, not the address.
  4. Play email.wav. Ours ran 12.1 seconds.
  5. Set MAX_CHARS to 200 and run it again.
  6. Comment out the startswith line. Listen to the difference.
  1. ส่งอีเมลถึงตัวเอง โดยใส่ลิงก์ยาว ๆ ไว้ข้างใน
  2. สร้าง say3.py แล้วรัน
  3. ตรวจ: สคริปต์ที่พิมพ์ออกมาต้องเขียนว่า a link ไม่ใช่ที่อยู่เว็บ
  4. เปิดฟัง email.wav ของเรายาว 12.1 วินาที
  5. ตั้ง MAX_CHARS เป็น 200 แล้วรันใหม่
  6. ลองคอมเมนต์บรรทัด startswith ออก แล้วฟังความต่าง
  1. 给自己发一封带长链接的邮件。
  2. 新建 say3.py 并运行。
  3. 检查:打印出来的稿子写的是 a link,不是那串网址。
  4. 播放 email.wav。我们的是 12.1 秒。
  5. MAX_CHARS 设成 200,再跑一次。
  6. startswith 那行注释掉,听听差别。

33 🩺 When it does not work · เมื่อใช้งานไม่ได้ · 出问题的时候

MessageWhat it means
KeyError: 'MAIL_USER'This terminal has no settings. Set them again, in the window you run from.terminal นี้ยังไม่มีค่าที่ตั้งไว้ ให้ตั้งใหม่ในหน้าต่างที่คุณใช้รัน这个终端里没有设置。在你运行程序的那个窗口里重新设置。
SMTPAuthenticationErrorThe server refused your login. Use the app password, with no spaces, not your normal password.เซิร์ฟเวอร์ปฏิเสธการล็อกอิน ให้ใช้ app password แบบไม่มีช่องว่าง ไม่ใช่รหัสผ่านปกติ服务器拒绝登录。用应用专用密码,不带空格,不是你平时的密码。
SMTPRecipientsRefusedThe server did not like the address you typed. Check it for typing mistakes.เซิร์ฟเวอร์ไม่รับที่อยู่ที่คุณพิมพ์ ตรวจดูว่าพิมพ์ผิดหรือเปล่า服务器不接受你输入的地址。检查有没有拼错。
TimeoutErrorNo answer from the server. Check the internet, and check the server name. Some school networks block port 465.เซิร์ฟเวอร์ไม่ตอบ ตรวจอินเทอร์เน็ตและชื่อเซิร์ฟเวอร์ เครือข่ายโรงเรียนบางแห่งบล็อกพอร์ต 465服务器没有回应。检查网络和服务器名字。有些学校网络会封 465 端口。
The email never arrivesอีเมลไม่มาสักที邮件一直没到Look in the spam folder. A first email from a new program often lands there.ดูในโฟลเดอร์สแปม อีเมลฉบับแรกจากโปรแกรมใหม่มักไปอยู่ตรงนั้น去垃圾邮件文件夹看看。新程序发出的第一封邮件经常落在那里。

🇬🇧 English

🇹🇭 ไทย

🇨🇳 中文