@f
Biến bản gỡ băng (transcript) podcast thô, các mốc thời gian (timestamps) và ghi chú về khách mời thành bản ghi chú tập (show notes) hoàn chỉnh sẵn sàng xuất bản: tiêu đề tập, tóm tắt chuẩn SEO, các ý chính đắt giá, mốc phân đoạn (chapter markers) cho YouTube, Spotify và Podcasting 2.0, các câu trích dẫn ấn tượng kèm mốc thời gian, tiểu sử khách mời, danh sách liên kết và các bài đăng mạng xã hội. Sử dụng khi người dùng chia sẻ bản gỡ băng và yêu cầu "viết show notes giúp tôi", "tạo phân đoạn chương cho tập này" hoặc "trích xuất câu nói hay từ buổi phỏng vấn này". Đi kèm công cụ tìm chương bằng thư viện chuẩn Python đã được kiểm thử.
---
name: podcast-show-notes-structurer
description: Biến bản gỡ băng (transcript) podcast thô, các mốc thời gian (timestamps) và ghi chú về khách mời thành bản ghi chú tập (show notes) hoàn chỉnh sẵn sàng xuất bản: tiêu đề tập, tóm tắt chuẩn SEO, các ý chính đắt giá, mốc phân đoạn (chapter markers) cho YouTube, Spotify và Podcasting 2.0, các câu trích dẫn ấn tượng kèm mốc thời gian, tiểu sử khách mời, danh sách liên kết và các bài đăng mạng xã hội. Sử dụng khi người dùng chia sẻ bản gỡ băng và yêu cầu "viết show notes giúp tôi", "tạo phân đoạn chương cho tập này" hoặc "trích xuất câu nói hay từ buổi phỏng vấn này". Đi kèm công cụ tìm chương bằng thư viện chuẩn Python đã được kiểm thử.
---
# Trình Cấu trúc Ghi chú Tập Podcast (Show Notes)
Bạn giúp các nhà sản xuất podcast biến một bản gỡ băng lộn xộn thành bản ghi chú tập (show notes) mà người nghe thực sự muốn đọc và các công cụ tìm kiếm có thể dễ dàng tìm thấy. Bạn tìm ra cấu trúc thực sự của cuộc trò chuyện, đặt tên các phân đoạn (chapters) theo đúng cách một người nghe sẽ mô tả chúng, chọn lọc những câu trích dẫn đứng độc lập ngoài ngữ cảnh vẫn trọn vẹn ý nghĩa, và viết phần tóm tắt chỉ trong hai dòng giúp người đọc hiểu ngay tại sao tập podcast này xứng đáng với thời gian của họ. Bạn tuyệt đối không bao giờ tự bịa ra dữ kiện, đường dẫn liên kết hay câu trích dẫn không có trong tài liệu gốc.
## Các tệp trong kỹ năng này
- `scripts/chapterize.py` - đọc các bản gỡ băng có mốc thời gian, định dạng WebVTT, có tiêu đề hoặc văn bản thuần túy, đề xuất điểm bắt đầu của từng chương dựa trên tiêu đề, dấu hiệu nghỉ, các cụm từ chuyển ý ("let's talk about", "after the break") và sự chuyển dịch từ vựng, phác thảo tiêu đề từ khóa, liệt kê các điểm đánh dấu quảng cáo và âm nhạc, đồng thời xếp hạng các câu trích dẫn tiềm năng (chỉ sử dụng thư viện chuẩn Python 3)
- `references/show-notes-structure.md` - nội dung cần có trong từng phần, giới hạn độ dài, cùng cách viết tiêu đề, tóm tắt và các ý chính (key takeaways)
- `references/chapters-and-platforms.md` - quy tắc phân đoạn chương cho YouTube, Spotify, Apple Podcasts và Podcasting 2.0, kèm theo nguyên tắc đạo đức khi trích dẫn và gắn mốc thời gian
- `templates/show-notes.md` - mẫu tài liệu show notes để trả kết quả về
- `examples/example-rooftop-beekeeping-episode.md` - một ví dụ thực tế hoàn chỉnh từ cuộc phỏng vấn dài 25 phút
## Quy trình làm việc
### 1. Thu thập tài liệu
Hỏi người dùng, hoặc đưa ra giả định hợp lý và nêu rõ giả định đó:
- Bản gỡ băng (bất kỳ định dạng nào) và liệu các mốc thời gian trong đó có khớp với bản dựng âm thanh cuối cùng hay không. Nếu phần mở đầu (intro) hoặc quảng cáo được chèn thêm sau khi gỡ băng, hãy lấy độ lệch thời gian (offset) tính bằng giây.
- Tên chương trình, tên người dẫn (host) và khách mời với cách viết chính xác, vai trò của khách mời, cùng các đường dẫn liên kết họ muốn đưa vào (chỉ sử dụng những liên kết do người dùng cung cấp).
- Nơi bản ghi chú sẽ được xuất bản (nền tảng lưu trữ podcast, YouTube, trang web, bản tin newsletter) và bất kỳ quy chuẩn phong cách riêng nào: giọng điệu, độ dài, biểu tượng cảm xúc (emoji), cách diễn đạt cho nhà tài trợ.
- Đối tượng thính giả mục tiêu và 2 hoặc 3 cụm từ tìm kiếm mà người nghe có thể gõ để tìm thấy tập này.
### 2. Chạy công cụ tìm chương
```bash
python3 scripts/chapterize.py transcript.txt
python3 scripts/chapterize.py transcript.txt --min-chapter 3 --offset 45 --duration 52:30
python3 scripts/chapterize.py episode.vtt --format youtube
python3 scripts/chapterize.py - --format json < transcript.txt
```
Các điểm bắt đầu chương và câu trích dẫn do script tạo ra chỉ là các ứng viên gợi ý, không phải kết quả cuối cùng. Hãy đọc kỹ bản gỡ băng xung quanh mỗi điểm bắt đầu được đề xuất và dịch chuyển mốc đó tới đúng câu mà chủ đề mới thực sự bắt đầu. Hãy viết lại mọi tiêu đề dạng từ khóa (ví dụ: "Roof / Hive / Water / Wind") thành tiêu đề ngắn gọn hướng tới người nghe ("Keeping bees on a windy roof" - Nuôi ong trên mái nhà đầy gió). Nếu bản gỡ băng không có mốc thời gian, các con số thời gian chỉ là ước tính (được đánh dấu `~`); hãy nhắc người dùng thay thế chúng bằng mốc thời gian chính xác từ phần mềm dựng âm thanh. Nếu bạn không thể chạy script, hãy tìm các chương thủ công theo cùng nguyên tắc: dựa vào tiêu đề, điểm nghỉ, cụm từ chuyển ý, rồi đến các điểm thay đổi chủ đề.
### 3. Viết ghi chú tập (Show notes)
1. Tiêu đề: dài 40 đến 60 ký tự, nêu bật giá trị hứa hẹn chính của tập, kèm tên khách mời nếu điều đó giúp ích cho việc tìm kiếm.
2. Tóm tắt: 2 câu dùng cho phần xem trước trên ứng dụng (150 ký tự đầu tiên phải tự đứng độc lập và truyền tải trọn ý), tiếp theo là một đoạn văn ngắn.
3. Các ý chính (Key takeaways): 3 đến 5 gạch đầu dòng, mỗi gạch đầu dòng là một ý tưởng cụ thể rút ra từ tập podcast, không phải là tên một chủ đề chung chung.
4. Phân đoạn chương (Chapters): từ 4 đến 10 chương đối với một tập dài 20 đến 60 phút, chương đầu tiên bắt đầu ở 00:00, tiêu đề dưới 40 ký tự, sắp xếp theo đúng trình tự thời gian diễn ra.
5. Trích dẫn (Quotes): 3 đến 5 câu có ý nghĩa trọn vẹn ngay cả khi tách khỏi ngữ cảnh, ghi kèm tên người nói và mốc thời gian. Chỉ lược bỏ các từ đệm; tuyệt đối không làm thay đổi ý nghĩa. Đánh dấu rõ các câu trích dẫn đã biên tập nhẹ để người dùng đối chiếu lại với bản thu âm.
6. Tiểu sử khách mời (2 hoặc 3 câu), các liên kết và tài nguyên được nhắc đến (chỉ đưa các liên kết có thật từ tài liệu gốc hoặc do người dùng cung cấp), thông tin ghi nhận đóng góp (credits), và tuyên bố minh bạch về nhà tài trợ nếu có quảng cáo.
7. Bài đăng mạng xã hội: một bài cho mỗi nền tảng mà người dùng nêu tên, mỗi bài sử dụng một câu trích dẫn hoặc một ý chính đắt giá.
### 4. Bàn giao kết quả
Điền vào biểu mẫu `templates/show-notes.md` đồng thời tuân thủ hướng dẫn trong `references/show-notes-structure.md` và `references/chapters-and-platforms.md`. Trình bày theo phong cách của `examples/example-rooftop-beekeeping-episode.md`. Bổ sung một danh sách ngắn "những mục cần kiểm tra trước khi xuất bản": các mốc thời gian ước tính, các câu trích dẫn đã biên tập, chính tả tên riêng, cùng bất kỳ dữ kiện hay con số nào khách mời đã nêu mà cần được kiểm chứng lại.
## Quy tắc
- Chỉ sử dụng các dữ kiện, tên riêng, con số và liên kết có trong bản gỡ băng hoặc do người dùng cung cấp; gắn thẻ [check] cho bất kỳ thông tin nào chưa rõ ràng.
- Các câu trích dẫn phải là lời nói nguyên văn của người phát biểu; việc lược bỏ các từ đệm như "um", "you know" là chấp nhận được, nhưng diễn đạt lại bằng từ ngữ khác thì không.
- Giữ cho tiêu đề các chương mang tính mô tả và trung thực; không giật tít câu view (clickbait) những điều mà nội dung tập không mang lại.
- Tôn trọng khách mời: không trích dẫn các nhận xét riêng tư, những khoảnh khắc trò chuyện ngoài lề (off-the-record), hoặc bất kỳ phần nào người dùng đã đánh dấu cắt bỏ.
- Hòa hợp với giọng điệu chung của chương trình; mặc định sử dụng văn phong ấm áp, rõ ràng và cụ thể.
FILE:references/show-notes-structure.md
# Cấu trúc bản ghi chú tập (Show notes)
Người nghe thường đọc lướt show notes trong ứng dụng podcast trên điện thoại, các công cụ tìm kiếm đọc chúng trên trang web của tập, và khách mời sẽ chia sẻ chúng. Mỗi phần đều có một nhiệm vụ duy nhất.
| Phần | Nhiệm vụ | Độ dài |
|---|---|---|
| Tiêu đề tập (Episode title) | Nêu bật giá trị hứa hẹn chính; hỗ trợ tìm kiếm | 40 đến 60 ký tự; hầu hết ứng dụng sẽ cắt bớt sau khoảng 60 ký tự |
| Tóm tắt xem trước (Preview summary) | Khiến người dùng nhấn nút phát ngay từ danh sách trên ứng dụng | 150 ký tự đầu tiên phải tự truyền tải trọn ý; tổng cộng 2 câu |
| Tóm tắt đầy đủ (Full summary) | Giải thích khách mời là ai và những nội dung được bàn luận | 60 đến 120 từ |
| Các ý chính (Key takeaways) | Cung cấp giá trị cốt lõi cho người đọc lướt | 3 đến 5 gạch đầu dòng, mỗi dòng một ý tưởng, dưới 20 từ |
| Phân đoạn chương (Chapters) | Giúp người nghe chuyển nhanh đến phần quan tâm | 4 đến 10 chương cho thời lượng 20 đến 60 phút; tiêu đề dưới 40 ký tự |
| Trích dẫn nổi bật (Pull quotes) | Những câu nói đắt giá dùng cho phần ghi chú và mạng xã hội | 3 đến 5 câu, mỗi câu từ 12 đến 30 từ, kèm tên người nói và mốc thời gian |
| Tiểu sử khách mời (Guest bio) | Giới thiệu và ghi nhận khách mời | 2 hoặc 3 câu, viết ở ngôi thứ ba |
| Liên kết và tài nguyên (Links and resources) | Mọi thứ được nhắc đến mà có đường dẫn liên kết | Chỉ đưa những liên kết do người dùng hoặc khách mời cung cấp |
| Ghi nhận và minh bạch tài trợ (Credits and disclosure) | Người dẫn, khách mời, nhà sản xuất, âm nhạc, nhà tài trợ | Mỗi mục một dòng |
| Lời kêu gọi hành động (Call to action) | Một bước tiếp theo duy nhất | Đăng ký theo dõi, đánh giá, nhận bản tin, hoặc một câu hỏi dành cho người nghe |
## Tiêu đề
- Mở đầu bằng ý tưởng cốt lõi chứ không phải số thứ tự tập: "Keeping Bees on a Hotel Roof with Daniel Okafor" (Nuôi ong trên mái khách sạn cùng Daniel Okafor) hiệu quả hơn hẳn "Ep. 48: Daniel Okafor".
- Sử dụng những từ ngữ mà người nghe thực sự sẽ gõ tìm kiếm: "rooftop beekeeping" (nuôi ong trên sân thượng), "city honey" (mật ong phố thị).
- Tránh đặt những câu hỏi mà tập podcast không giải đáp và những lời hứa hẹn mà khách mời không hề đưa ra.
## Tóm tắt
- Câu 1: ai và nói về điều gì, bằng ngôn từ giản dị. Câu 2: điểm bất ngờ nhất hoặc hữu ích nhất.
- Viết cho con người đọc chứ không phải để nhồi nhét từ khóa; lồng ghép tự nhiên 2 hoặc 3 cụm từ tìm kiếm, mỗi cụm một lần.
- Không dùng lối mở đầu sáo rỗng "Trong tập này chúng ta sẽ nói về..."; hãy đi thẳng vào nội dung thực chất.
## Các ý chính (Key takeaways)
Hãy viết mỗi ý chính dưới dạng một nhận định hoặc hướng dẫn cụ thể rút ra từ tập podcast, chứ không phải tên một chủ đề:
- Yếu: "Nước trên mái nhà."
- Mạnh: "Hãy đặt một khay nước nông cho đàn ong trên sân thượng, nếu không chúng sẽ tìm đến bể bơi của hàng xóm."
## Tiêu đề chương
- Mô tả những gì người nghe sẽ nhận được trong phần đó chỉ bằng 2 đến 6 từ: "Tại sao gió mới là kẻ thù thực sự".
- Giữ cấu trúc ngữ pháp song song đồng nhất (tất cả cùng là câu khẳng định hoặc tất cả cùng là câu hỏi).
- Chỉ đặt tên cho các đoạn nghỉ quảng cáo nếu nền tảng không tự động ẩn chúng; tuyệt đối không gắn nhãn một đoạn quảng cáo như thể đó là một chương nội dung.
## Trích dẫn
- Chọn những câu có thể đứng độc lập: một nhận định rõ ràng, một bài học, một con số, hoặc một hình ảnh sống động.
- Tránh chọn những lời chào hỏi, câu hỏi dẫn dắt, và những câu cần phải đọc câu trước đó mới hiểu được.
- Việc lược bỏ các từ đệm (um, uh, you know) là hoàn toàn ổn; hãy đánh dấu câu trích dẫn là đã biên tập nhẹ. Tuyệt đối không được tự ý thay đổi từ ngữ.
## Khả năng tiếp cận và độ tin cậy
- Đính kèm liên kết dẫn tới toàn bộ bản gỡ băng (transcript) khi có sẵn; điều này giúp ích cho người nghe khiếm thính hoặc nghe kém cũng như hỗ trợ công cụ tìm kiếm.
- Công bố rõ ràng các nhà tài trợ và các liên kết tiếp thị liên kết (affiliate links).
- Đặt thẻ [check] vào bất kỳ con số, tên riêng hoặc nhận định nào cần được xác minh lại trước khi xuất bản.
FILE:references/chapters-and-platforms.md
# Phân đoạn chương và quy tắc của các nền tảng
Các quy tắc có thể thay đổi theo thời gian; khi nghi ngờ, hãy kiểm tra trang trợ giúp hiện hành của từng nền tảng. Dưới đây là những nguyên tắc cơ bản ổn định nhất.
## YouTube (podcast video và tệp âm thanh tải lên)
- Các chương được tạo từ các mốc thời gian trong phần mô tả, mỗi chương trên một dòng: `00:00 Intro`.
- Mốc thời gian đầu tiên bắt buộc phải là 00:00.
- Phải có ít nhất 3 mốc thời gian, và mỗi chương phải dài tối thiểu 10 giây.
- Sử dụng định dạng `MM:SS` cho các video dưới một giờ và `H:MM:SS` một khi có bất kỳ chương nào vượt quá mốc một giờ.
- Lệnh `chapterize.py --format youtube` sẽ in các dòng theo đúng định dạng này và cảnh báo khi có quy tắc bị vi phạm.
## Spotify
- Spotify đọc các mốc thời gian chương từ phần mô tả tập theo cùng kiểu `MM:SS Title` đối với các tập trên nền tảng của mình; hãy tuân thủ các quy tắc tương tự như YouTube (bắt đầu tại 00:00, cách nhau vài phút).
## Apple Podcasts và hầu hết các ứng dụng khác
- Các chương được nhúng trực tiếp vào tệp âm thanh: các khung ID3 CHAP trong tệp MP3 hoặc một rãnh chương (chapter track) trong tệp M4A/AAC. Hãy thêm chúng trong phần mềm dựng âm thanh hoặc công cụ gắn thẻ (tagging tool) trước khi tải lên.
- Apple có thể hiển thị các chương được tạo tự động khi một tập không có chương nào; việc tự cung cấp chương riêng giúp bạn giữ quyền kiểm soát các tiêu đề.
## Podcasting 2.0 (RSS)
- Thẻ `<podcast:chapters>` trỏ tới một tệp JSON chứa danh sách chương, được hỗ trợ bởi nhiều ứng dụng và dịch vụ lưu trữ (host) hiện đại:
```json
{
"version": "1.2.0",
"chapters": [
{ "startTime": 0, "title": "Intro" },
{ "startTime": 280, "title": "Keeping bees on a windy roof" }
]
}
```
- `startTime` được tính bằng giây; lệnh `chapterize.py --format json` trả về giá trị `start` tính bằng giây cho từng chương.
## Mốc thời gian (Timestamps)
- Thời gian trong bản gỡ băng thường lệch so với tệp âm thanh cuối cùng: nhạc mở đầu, quảng cáo và việc cắt dựng sẽ làm dịch chuyển chúng. Hãy sử dụng `--offset` cho một khoảng dịch chuyển cố định và kiểm tra lại một vài chương trong tệp âm thanh.
- Khi không có mốc thời gian, `chapterize.py` sẽ ước tính thời gian dựa trên số lượng từ (mặc định 150 từ mỗi phút) và đánh dấu chúng bằng ký hiệu `~`. Tuyệt đối không xuất bản các mốc thời gian ước tính mà chưa kiểm tra lại.
- Cơ chế chèn quảng cáo động (dynamic ad insertion) làm thay đổi thời gian tùy theo từng người nghe; chỉ đưa các chương vào tệp hoặc phần mô tả khi quảng cáo đã được ghép cứng (baked in) vào âm thanh hoặc khi nền tảng lưu trữ hỗ trợ tự động điều chỉnh mốc chương.
## Cách script tìm điểm bắt đầu chương
1. Các dòng tiêu đề (`# Title`, `Chapter 3: ...`, `Segment: ...`) luôn bắt đầu một chương mới.
2. Các dấu hiệu nghỉ (`[music]`, `[ad break]`, `[pause]`, `***`, `---`) đánh dấu khối lời thoại tiếp theo là một ứng viên bắt đầu chương.
3. Các cụm từ chuyển phần được chấm điểm cao ("let's talk about", "moving on", "switching gears", "welcome back", "before we wrap up"); các cụm từ chuyển câu hỏi đơn lẻ ("next question") được chấm điểm thấp hơn để giữ cho toàn bộ phần hỏi đáp (Q&A) nằm gọn trong một chương.
4. Sự chuyển dịch từ vựng: mức độ khác biệt của các từ ngữ trong 3 khối lời thoại trước và sau một thời điểm.
5. Các điểm bắt đầu cách nhau gần hơn `--min-chapter` phút sẽ bị loại bỏ (ngoại trừ các dòng tiêu đề), và không có chương nào được bắt đầu trong phút cuối cùng.
## Đạo đức khi trích dẫn và sử dụng bản gỡ băng
- Giữ nguyên sự trung thực của các câu trích dẫn; đánh dấu việc lược bỏ từ đệm là đã qua biên tập.
- Xin phép trước khi trích dẫn câu chuyện cá nhân của khách mời lên mạng xã hội nếu đó là nội dung nhạy cảm.
- Không xuất bản những nhận xét mà người dùng đã đánh dấu là trò chuyện ngoài lề (off the record) hoặc đã bị cắt khỏi tập podcast.
FILE:templates/show-notes.md
# episode_title
**show_name** | Tập episode_number | duration | Khách mời: guest_name
preview_summary_two_sentences
full_summary_paragraph
## Các ý chính (Key takeaways)
-
-
-
## Phân đoạn chương (Chapters)
```
00:00 chapter_1
time chapter_2
```
## Trích dẫn nổi bật (Quotes)
> "quote" - speaker (time)
## Giới thiệu về guest_name
guest_bio
## Liên kết và tài nguyên được nhắc đến
- resource - url_or_check
## Ghi nhận đóng góp (Credits)
Dẫn chương trình: host | Khách mời: guest_name | Sản xuất: producer | Âm nhạc: music_credit
sponsor_disclosure
call_to_action
---
## Bài đăng mạng xã hội
- **platform:** post_with_quote_or_takeaway
## Kiểm tra trước khi xuất bản
- [ ] Đã đối chiếu mốc thời gian các chương với tệp âm thanh cuối cùng (độ lệch offset: offset)
- [ ] Đã kiểm tra các câu trích dẫn qua biên tập với tệp âm thanh
- [ ] Đã xác nhận tên riêng và chính tả
- [ ] Đã xác minh các dữ kiện và con số được gắn thẻ [check]
- [ ] Đã kiểm tra hoạt động của các đường dẫn liên kết
FILE:examples/example-rooftop-beekeeping-episode.md
# Ví dụ: Bản ghi chú tập (show notes) cho cuộc phỏng vấn dài 25 phút
## Yêu cầu của người dùng
"Đây là bản gỡ băng của tập 48 chương trình Small Plot Radio trò chuyện cùng Daniel Okafor, một người nuôi ong trên mái khách sạn. Chúng tôi đã chèn thêm một đoạn nhạc mở đầu dài 45 giây trong lúc dựng, nên tệp âm thanh cuối cùng có thời lượng 26:02. Bạn có thể viết giúp tôi bản show notes kèm các phân đoạn chương cho YouTube và Spotify, một vài câu trích dẫn hay, và một bài đăng Instagram được không? Các buổi tham quan trên mái khách sạn và khóa học của Daniel đều có liên kết trên trang web của họ; tôi sẽ tự điền chúng vào sau."
## Chạy công cụ tìm chương
```
$ python3 scripts/chapterize.py rooftop-bees.txt
Transcript: 1140 words, about 25:17 long, timestamps found, speakers: Daniel (20 turns), Maya (19 turns)
Chapter candidates (6, min 4 min apart):
1. 00:00 4.7 min [detected] Hotel Manager / Hive / Twelve / Bees
2. 04:40 4.5 min [detected] Roof / Hive / Water / Wind
3. 09:12 5.3 min [detected] Honey Tour / Spring Honey / Late Summer / Guests
4. 14:30 5.0 min [detected] City / Bees / Honey / Roof
5. 19:30 4.7 min [detected] Hive / Balcony / Bees / Priya
6. 24:10 1.1 min [detected] Keeping / Colony / Association / Find
Markers (breaks, music, ads): 13:30 [ad break]
Quote candidates (6):
00:36 Daniel: "I built my first hive from a kit that winter and I made every beginner mistake you can imagine."
01:24 Daniel: "The biggest lesson I learned that first year was that good beekeeping is mostly patience."
05:33 Daniel: "If you don't give them water, they will find the hotel pool or the neighbors' air conditioning drip, and that is how complaints start."
...
$ python3 scripts/chapterize.py rooftop-bees.txt --offset 45 --format youtube
00:00 Hotel Manager / Hive / Twelve / Bees
05:25 Roof / Hive / Water / Wind
09:57 Honey Tour / Spring Honey / Late Summer / Guests
15:15 City / Bees / Honey / Roof
20:15 Hive / Balcony / Bees / Priya
24:55 Keeping / Colony / Association / Find
# note: YouTube needs the first chapter at 00:00; the first one was moved to 00:00
```
Đánh giá các ứng viên chương: cả sáu điểm bắt đầu đều rơi đúng vào các câu chuyển ý thực sự ("Let's talk about the roof itself", "Switching gears to the hotel manager", "Welcome back", "Let's move on to listener questions", "Before we wrap up"). Đoạn nghỉ quảng cáo ở phút 13:30 được giữ nguyên bên trong chương 3 vì quảng cáo đã được ghép cứng vào tệp âm thanh. Chương 1 khá dài nên được tách thủ công tại mốc 01:20 ("What was the biggest mistake?"), trở thành 02:05 sau khi cộng độ lệch thời gian (offset). Mọi tiêu đề dạng từ khóa đều đã được viết lại.
## Bản ghi chú tập (mẫu đã điền)
# Keeping Bees on a Hotel Roof with Daniel Okafor (Nuôi ong trên mái khách sạn cùng Daniel Okafor)
**Small Plot Radio** | Tập 48 | 26:02 | Khách mời: Daniel Okafor
Daniel Okafor nuôi mười hai thùng ong trên mái một khách sạn ở trung tâm thành phố. Anh chia sẻ cách khắc phục gió lớn, nắng nóng và cơn khát cho đàn ong trên mái nhà, cũng như lý do vì sao việc trồng hoa lại giúp ích cho các loài thụ phấn nhiều hơn là đặt thêm thùng ong.
Chín năm trước, một đàn ong di cư đã đáp xuống ban công nhà Daniel; ngày nay anh vận hành mười hai thùng ong trên mái một khách sạn, cung cấp mật ong cho gian bếp khách sạn và dẫn dắt các tour tham quan mật ong trên sân thượng luôn kín chỗ. Maya và Daniel trò chuyện về những sai lầm của người mới bắt đầu, hàng rào chắn gió, khay nước, tải trọng mái nhà, cách anh thuyết phục người quản lý khách sạn, liệu mật ong thành phố có an toàn để thưởng thức hay không, và tại sao việc đặt quá nhiều thùng ong trong thành phố có thể lấn át không gian sống của các loài ong hoang dã. Cuối chương trình, họ giải đáp các câu hỏi của thính giả về việc nuôi ong ngoài ban công, dụng cụ khởi đầu và sản lượng mật thu hoạch.
## Các ý chính (Key takeaways)
- Kiểm tra thùng ong định kỳ mỗi 7 đến 10 ngày vào mùa xuân theo kế hoạch rõ ràng, và để chúng yên ổn trong khoảng thời gian giữa các lần kiểm tra.
- Trên mái nhà, gió là kẻ thù chính: một hàng rào chắn gió dạng nan cao 1.5 m ở các mặt đón gió sẽ tạo ra sự khác biệt lớn.
- Hãy đặt cho đàn ong trên sân thượng một khay nước nông có rải sỏi, nếu không chúng sẽ tìm đến bể bơi của hàng xóm.
- Cần kiểm tra tải trọng mái nhà trước tiên: một thùng ong đầy có thể nặng từ 80 đến 100 kg.
- Việc trồng thêm hoa giúp ích cho các loài thụ phấn nhiều hơn là đặt thêm một thùng ong nữa vào một thành phố vốn đã chật kín.
## Phân đoạn chương (Chapters)
```
00:00 Cơ duyên một đàn ong biến Daniel thành người nuôi ong
02:05 Sai lầm của người mới: mở thùng ong quá thường xuyên
05:25 Nuôi ong trên mái nhà đầy gió
09:57 Thuyết phục khách sạn (và cả gian bếp của họ)
15:15 Mật ong thành phố có an toàn không, và liệu đã có quá nhiều thùng ong?
20:15 Câu hỏi từ thính giả: ban công, dụng cụ và sản lượng mật
24:55 Điều duy nhất mà mọi người mới nuôi ong đều nên biết
```
## Trích dẫn nổi bật (Quotes)
> "Bài học lớn nhất tôi nhận ra trong năm đầu tiên đó là nghề nuôi ong giỏi phần lớn nằm ở sự kiên nhẫn." - Daniel Okafor (02:09)
> "Nếu bạn không cung cấp nước cho chúng, chúng sẽ tìm đến bể bơi khách sạn hoặc ống nhỏ giọt điều hòa của hàng xóm, và đó chính là lúc những lời phàn nàn bắt đầu xuất hiện." - Daniel Okafor (06:18)
> "Nếu bạn muốn giúp các loài thụ phấn, việc trồng hoa có ý nghĩa hơn nhiều so với việc đặt thêm một thùng ong nữa." - Daniel Okafor (16:53)
## Giới thiệu về Daniel Okafor
Daniel Okafor là một người nuôi ong đô thị với chín năm kinh nghiệm và hiện đang chăm sóc mười hai thùng ong trên mái một khách sạn ở trung tâm thành phố. Anh dẫn dắt các tour tham quan mật ong trên sân thượng hàng tháng của khách sạn và giảng dạy khóa học dành cho người mới bắt đầu tại hiệp hội nuôi ong thành phố vào mỗi mùa xuân.
## Liên kết và tài nguyên được nhắc đến
- Tour tham quan mật ong trên sân thượng khách sạn - [liên kết từ người dùng]
- Khóa học nuôi ong cho người mới bắt đầu, hiệp hội nuôi ong thành phố - [liên kết từ người dùng]
- Nhà cho ong bầu (Mason bee) đặt ngoài ban công (được nhắc đến ở phút 20:15) - không có liên kết
## Ghi nhận đóng góp (Credits)
Dẫn chương trình: Maya | Khách mời: Daniel Okafor | Sản xuất: [check] | Âm nhạc: [check]
Tập này có bao gồm một đoạn quảng cáo tại mốc 14:15. [check tên nhà tài trợ để công bố minh bạch]
Bạn có câu hỏi nào về việc nuôi ong ở ban công hay trên mái nhà muốn gửi tới Daniel không? Hãy gửi về cho chúng tôi và chúng tôi sẽ giải đáp trong một tập sắp tới.
---
## Bài đăng mạng xã hội
- **Instagram:** "Việc trồng hoa có ý nghĩa hơn nhiều so với việc đặt thêm một thùng ong nữa." Daniel Okafor đang nuôi mười hai thùng ong trên mái một khách sạn, và lời khuyên của anh nhằm giúp đỡ các loài thụ phấn nơi đô thị có thể sẽ khiến bạn bất ngờ. Tập mới của Small Plot Radio đã lên sóng, liên kết nằm trong phần tiểu sử (bio).
## Kiểm tra trước khi xuất bản
- [ ] Đã đối chiếu mốc thời gian các chương với tệp âm thanh cuối cùng (độ lệch offset: cộng thêm +45 giây vào mọi mốc thời gian trong bản gỡ băng)
- [ ] Đã kiểm tra các câu trích dẫn với tệp âm thanh (không có câu nào bị chỉnh sửa)
- [ ] Đã xác nhận cách đánh vần tên "Okafor" với khách mời
- [ ] Các dữ kiện cần xác minh: "không có vị khách nào bị ong đốt trong suốt chín mùa", kết quả kiểm nghiệm phòng thí nghiệm hàng năm đều dưới ngưỡng giới hạn, sản lượng 15 đến 30 kg mỗi thùng
- [ ] Đã thêm và kiểm tra hoạt động của các đường dẫn liên kết
FILE:scripts/chapterize.py
#!/usr/bin/env python3
"""Split a podcast transcript into rough chapter candidates and pull quote candidates.
Usage:
python3 chapterize.py TRANSCRIPT [options]
python3 chapterize.py - < transcript.txt (read stdin)
Accepted input (detected automatically, can be mixed):
- timestamp lines: "[00:12:30] Ana: text", "00:12:30 text", "(12:30) text", "12:30 - text"
- WebVTT/SRT cue times: "00:01:02.000 --> 00:01:05.000" (the cue text follows)
- headings: "# Intro", "## Part 2", "Chapter 3: Pricing", "SEGMENT: listener questions"
- pause markers: [pause], [music], [break], [ad break], (long pause), ***, ---
- plain paragraphs separated by blank lines (times are estimated from --wpm)
Options:
--min-chapter MIN shortest chapter in minutes (default 4)
--max-chapters N most chapters to return (default 12)
--wpm N speaking rate used to estimate times without timestamps (default 150)
--offset SEC seconds to add to every time, for an intro added in the edit (default 0)
--quotes N number of quote candidates (default 6)
--format FMT text (default), json, or youtube (chapter lines "00:00 Title")
--duration HH:MM:SS total episode length, to check the last chapter and YouTube rules
Chapter boundaries come from headings first, then pause markers and cue phrases
("let's talk about", "moving on", "after the break"), then vocabulary shifts between
neighbouring blocks. Titles are keyword guesses: always rewrite them for the listener.
Exit codes: 0 ok, 1 input too short for chapters, 2 usage or read error.
Python 3 standard library only.
"""
import json
import re
import sys
from collections import Counter
TS_LINE = re.compile(r"^\s*[\[(]?((?:\d{1,2}:)?\d{1,2}:\d{2})(?:[.,]\d+)?[\])]?\s*(?:[-\u2013\u2014|:]\s*)?(.*)$")
CUE_LINE = re.compile(r"^\s*((?:\d{1,2}:)?\d{1,2}:\d{2})[.,]\d{1,3}\s*-->\s*((?:\d{1,2}:)?\d{1,2}:\d{2})?")
LAST_CUE_END = [0]
HEADING = re.compile(r"^\s*(?:#{1,6}\s+(.+)|(?:chapter|part|segment|section|topic)\s*\d*\s*[:.\-\u2013]\s*(.+))\s*$", re.I)
PAUSE = re.compile(r"^\s*(?:\[(?:pause|long pause|music|break|ad break|ad|sponsor|interlude|silence)[^\]]*\]|"
r"\((?:pause|long pause|music|break|ad break|silence)[^)]*\)|\*{3,}|-{3,}|={3,})\s*$", re.I)
SPEAKER = re.compile(r"^([A-Z][\w.'-]*(?: [A-Z][\w.'-]*){0,2}):\s+(.*)$")
# section-level cue phrases start a new chapter; item-level ones (single questions) only nudge
CUES = ["let's talk about", "lets talk about", "let's move on", "moving on", "switching gears", "change gears",
"after the break", "welcome back", "before we wrap", "to wrap up", "let's get into", "let's dive into",
"let's start with", "rapid fire", "listener questions"]
ITEM_CUES = ["next question", "first question", "last question", "final question", "the next thing",
"another thing i want", "speaking of"]
INTRO = re.compile(r"\b(welcome (to|back)|my guest|today i'm talking|today i am talking|thanks for (having|listening)|"
r"see you (next|in)|where can people find)\b", re.I)
STOP = set("""a about above after again against all also am an and any are aren't as at be because been before being
below between both but by can can't could couldn't did didn't do does doesn't doing don't down during each even ever
every few for from further get gets getting go going gonna got had hadn't has hasn't have haven't having he he'd he'll
he's her here here's hers herself him himself his how how's i i'd i'll i'm i've if in into is isn't it it's its itself
just know kind kinda let let's like lot really right said say says see so some such sort than that that's the their
theirs them themselves then there there's these they they'd they'll they're they've thing things think this those
though through to too um uh under until up us very want was wasn't way we we'd we'll we're we've well were weren't what
what's when when's where where's which while who who's whom why why's will with won't would wouldn't yeah yes you you'd
you'll you're you've your yours yourself yourselves okay ok actually basically maybe mean pretty probably stuff sure
talk talking yeah one two three lot lots much many more most other others same back new good great time people
something anything everything nothing make made making take took come came look looking need needs needed thank
thanks today episode podcast show welcome not first question questions listener listeners she she's she'd she'll now""".split())
QUOTE_WORDS = re.compile(r"\b(never|always|nobody|everyone|the secret|the key|the biggest|the hardest|the best|the worst|"
r"mistake|learned|lesson|truth|matters|changed|surprised|realized|if you|you have to|don't)\b", re.I)
FILLER = re.compile(r"\b(um+|uh+|you know|i mean|sort of|kind of|like,)\s*", re.I)
class UsageError(Exception):
pass
def to_sec(ts):
parts = [int(p) for p in ts.split(":")]
while len(parts) < 3:
parts.insert(0, 0)
h, m, s = parts
return h * 3600 + m * 60 + s
def fmt(sec, force_hours=False):
sec = int(round(sec))
h, rem = divmod(sec, 3600)
m, s = divmod(rem, 60)
return f"{h:d}:{m:02d}:{s:02d}" if h or force_hours else f"{m:02d}:{s:02d}"
def words(text):
return re.findall(r"[A-Za-z][A-Za-z'-]+", text)
def parse(text):
"""Return a list of units: dict(kind=text|heading|pause, text, time or None, speaker)."""
units, pending_time = [], None
lines = text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff").split("\n")
para = []
def flush():
if para:
joined = " ".join(p["text"] for p in para)
units.append({"kind": "text", "text": joined, "time": para[0]["time"], "speaker": para[0]["speaker"]})
para.clear()
for raw in lines:
line = raw.strip()
if not line or line.upper() == "WEBVTT" or line.isdigit():
if not line:
flush()
continue
m = CUE_LINE.match(line)
if m:
pending_time = to_sec(m.group(1))
if m.group(2):
LAST_CUE_END[0] = max(LAST_CUE_END[0], to_sec(m.group(2)))
continue
h = HEADING.match(line)
if h:
flush()
units.append({"kind": "heading", "text": (h.group(1) or h.group(2)).strip(), "time": pending_time,
"speaker": None})
continue
if PAUSE.match(line):
flush()
units.append({"kind": "pause", "text": line, "time": pending_time, "speaker": None})
continue
t = TS_LINE.match(line)
time = pending_time
if t and t.group(2) is not None and (":" in t.group(1)):
time, line = to_sec(t.group(1)), t.group(2).strip()
flush()
if not line:
pending_time = time
continue
h = HEADING.match(line)
if h:
units.append({"kind": "heading", "text": (h.group(1) or h.group(2)).strip(), "time": time, "speaker": None})
pending_time = None
continue
if PAUSE.match(line):
units.append({"kind": "pause", "text": line, "time": time, "speaker": None})
pending_time = None
continue
sp = SPEAKER.match(line)
speaker = None
if sp and len(sp.group(1)) <= 30:
speaker, line = sp.group(1), sp.group(2)
flush()
if time is not None or pending_time is not None:
flush()
para.append({"text": line, "time": time, "speaker": speaker})
pending_time = None
flush()
return units
def assign_times(units, wpm, offset):
"""Fill missing times by interpolating between known ones or from the word count."""
known = [i for i, u in enumerate(units) if u["time"] is not None]
clock = 0.0
timed = bool(known)
for i, u in enumerate(units):
if u["time"] is not None:
clock = float(u["time"])
u["estimated"] = False
else:
u["time"] = clock
u["estimated"] = True
clock = u["time"] + len(words(u["text"])) / wpm * 60 if u["kind"] == "text" else u["time"]
for u in units:
u["time"] += offset
return timed
EXTRA_STOP = set()
def keywords(text, n=4):
ws = [w.lower().strip("'-") for w in words(text)]
ws = [w for w in ws if len(w) > 2 and w not in STOP and w not in EXTRA_STOP]
counts = Counter(ws)
bigrams = Counter(f"{a} {b}" for a, b in zip(ws, ws[1:]) if a != b)
out = [b for b, c in bigrams.most_common(3) if c >= 2]
for w, _c in counts.most_common(12):
if len(out) >= n:
break
if not any(w in b.split() for b in out):
out.append(w)
return out[:n]
def similarity(a, b):
wa = {w.lower() for w in words(a) if w.lower() not in STOP and len(w) > 2}
wb = {w.lower() for w in words(b) if w.lower() not in STOP and len(w) > 2}
if not wa or not wb:
return 0.0
return len(wa & wb) / len(wa | wb)
def boundaries(units, min_gap, max_chapters, end_time):
"""Score each unit as a chapter start; return chosen indexes (always includes the first text unit)."""
scores = {}
for i, u in enumerate(units):
if u["kind"] == "heading":
scores[i] = 10.0
elif u["kind"] == "pause":
nxt = next((j for j in range(i + 1, len(units)) if units[j]["kind"] == "text"), None)
if nxt is not None:
scores[nxt] = max(scores.get(nxt, 0), 3.0)
elif u["kind"] == "text":
low = u["text"].lower()
cue = 4.0 if any(c in low[:160] for c in CUES) else 1.5 if any(c in low[:160] for c in ITEM_CUES) else 0.0
before = " ".join(x["text"] for x in units[max(0, i - 3):i] if x["kind"] == "text")
after = " ".join(x["text"] for x in units[i:i + 3] if x["kind"] == "text")
shift = 1.0 - similarity(before, after) if before and after else 0.0
score = shift * 2.0 + cue
if score > 1.6:
scores[i] = max(scores.get(i, 0), score)
first = next((i for i, u in enumerate(units) if u["kind"] in ("text", "heading")), 0)
chosen = [first]
for i in sorted(scores, key=lambda k: (-scores[k], k)):
if len(chosen) >= max_chapters:
break
t = units[i]["time"]
forced = units[i]["kind"] == "heading"
strong = forced or scores[i] >= 4.0 # heading or cue phrase
if end_time - t < (60 if strong else min_gap / 2) and not forced:
continue # too close to the end of the episode
if all(abs(t - units[c]["time"]) >= min_gap for c in chosen) or (forced and i not in chosen and
all(abs(t - units[c]["time"]) >= 10 for c in chosen)):
chosen.append(i)
return sorted(set(chosen)), scores
def sentences(text):
return [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
def quote_candidates(units, n):
cands = []
for u in units:
if u["kind"] != "text":
continue
for s in sentences(u["text"]):
clean = re.sub(r"\s{2,}", " ", FILLER.sub("", s)).strip()
wc = len(clean.split())
if wc < 8 or wc > 40 or clean.endswith("?"):
continue
score = 0.0
score += 2.0 if 12 <= wc <= 28 else 0.5
score += 1.5 * len(QUOTE_WORDS.findall(clean))
score += 1.0 if re.search(r"\b\d+(?:[.,]\d+)?\s*(%|percent|x|times|years|hours|days)\b", clean, re.I) else 0
score += 0.5 if re.search(r"\b(I|we)\b", clean) else 0
score -= 1.0 if FILLER.search(s) else 0
score -= 3.0 if INTRO.search(clean) else 0
cands.append({"time": u["time"], "speaker": u["speaker"], "text": clean, "score": round(score, 1),
"edited": clean != s})
cands.sort(key=lambda c: (-c["score"], c["time"]))
out, seen = [], set()
for c in cands:
key = c["text"][:40].lower()
if key in seen:
continue
seen.add(key)
out.append(c)
if len(out) >= n:
break
return sorted(out, key=lambda c: c["time"])
def build(units, chosen, end_time):
chapters = []
for k, start in enumerate(chosen):
stop = chosen[k + 1] if k + 1 < len(chosen) else len(units)
seg = units[start:stop]
text = " ".join(u["text"] for u in seg if u["kind"] == "text")
heading = next((u["text"] for u in seg[:1] if u["kind"] == "heading"), None)
t0 = units[start]["time"]
t1 = units[stop]["time"] if stop < len(units) else end_time
speakers = Counter(u["speaker"] for u in seg if u["speaker"])
first = sentences(text)[:1]
chapters.append({
"n": k + 1, "start": round(t0), "start_hms": fmt(t0), "end": round(t1), "minutes": round((t1 - t0) / 60, 1),
"estimated_time": any(u.get("estimated") for u in seg[:1]),
"source": "heading" if heading else "detected",
"title_draft": heading or " / ".join(keywords(text)).title() or "(untitled)",
"keywords": keywords(text, 6), "words": len(words(text)),
"speakers": [s for s, _ in speakers.most_common()],
"opening_line": first[0][:160] if first else "",
})
return chapters
def youtube_lines(chapters):
lines, problems = [], []
if chapters and chapters[0]["start"] != 0:
problems.append("YouTube needs the first chapter at 00:00; the first one was moved to 00:00")
for c in chapters:
start = 0 if c is chapters[0] else c["start"]
lines.append(f"{fmt(start, force_hours=chapters[-1]['start'] >= 3600)} {c['title_draft']}")
if len(chapters) < 3:
problems.append("YouTube shows chapters only with at least 3 of them")
short = [c["n"] for c in chapters if c["minutes"] * 60 < 10]
if short:
problems.append(f"chapters {short} are shorter than 10 seconds (YouTube minimum)")
return lines, problems
def parse_args(argv):
opts = {"file": None, "min_chapter": 4.0, "max_chapters": 12, "wpm": 150.0, "offset": 0.0, "quotes": 6,
"format": "text", "duration": None}
numeric = {"--min-chapter": ("min_chapter", float), "--max-chapters": ("max_chapters", int),
"--wpm": ("wpm", float), "--offset": ("offset", float), "--quotes": ("quotes", int)}
i = 0
while i < len(argv):
a = argv[i]
if a in ("-h", "--help"):
print(__doc__)
sys.exit(0)
if a in numeric or a in ("--format", "--duration"):
if i + 1 >= len(argv):
raise UsageError(f"{a} needs a value")
v = argv[i + 1]
if a == "--format":
if v not in ("text", "json", "youtube"):
raise UsageError("--format must be text, json, or youtube")
opts["format"] = v
elif a == "--duration":
if not re.fullmatch(r"(?:\d{1,2}:)?\d{1,2}:\d{2}", v):
raise UsageError("--duration must look like 52:30 or 1:05:00")
opts["duration"] = to_sec(v)
else:
key, typ = numeric[a]
try:
opts[key] = typ(v)
except ValueError:
raise UsageError(f"{a} needs a number, got {v!r}")
if opts[key] < 0 or (key in ("wpm", "max_chapters") and opts[key] == 0):
raise UsageError(f"{a} must be positive")
i += 2
elif a.startswith("--"):
raise UsageError(f"unknown option {a}")
elif opts["file"] is None:
opts["file"] = a
i += 1
else:
raise UsageError(f"unexpected argument {a!r}")
if opts["file"] is None:
raise UsageError("give a transcript file, or - for stdin")
return opts
def main(argv):
try:
opts = parse_args(argv)
if opts["file"] == "-":
text = sys.stdin.read()
else:
with open(opts["file"], encoding="utf-8") as f:
text = f.read()
except UsageError as e:
print(f"error: {e}", file=sys.stderr)
return 2
except (OSError, UnicodeDecodeError) as e:
print(f"error: cannot read {opts['file']}: {getattr(e, 'strerror', None) or e}", file=sys.stderr)
return 2
units = parse(text)
total_words = sum(len(words(u["text"])) for u in units if u["kind"] == "text")
if total_words < 50:
print(f"error: only {total_words} words of transcript; need at least 50 to suggest chapters", file=sys.stderr)
return 1
timed = assign_times(units, opts["wpm"], opts["offset"])
last = units[-1]
end_time = last["time"] + (len(words(last["text"])) / opts["wpm"] * 60 if last["kind"] == "text" else 0)
end_time = max(end_time, LAST_CUE_END[0] + opts["offset"] if LAST_CUE_END[0] else 0)
if opts["duration"]:
end_time = max(end_time, opts["duration"])
EXTRA_STOP.update(w.lower() for u in units if u["speaker"] for w in u["speaker"].split())
chosen, _scores = boundaries(units, opts["min_chapter"] * 60, opts["max_chapters"], end_time)
chapters = build(units, chosen, end_time)
quotes = quote_candidates(units, opts["quotes"])
speakers = Counter(u["speaker"] for u in units if u["speaker"])
notes = []
if not timed:
notes.append(f"no timestamps found: times are estimates at {opts['wpm']:g} words per minute; replace them "
"with real times from the audio editor before publishing")
if opts["duration"] and opts["duration"] < end_time - 30 and timed:
notes.append("the transcript runs past --duration; check the timestamps or the offset")
markers = [{"time": round(u["time"]), "time_hms": fmt(u["time"]), "marker": u["text"]}
for u in units if u["kind"] == "pause"]
yt_lines, yt_problems = youtube_lines(chapters)
report = {"markers": markers, "words": total_words, "timestamps": "found" if timed else "estimated", "end": round(end_time),
"end_hms": fmt(end_time), "speakers": dict(speakers.most_common()), "chapters": chapters,
"quotes": quotes, "youtube": yt_lines, "youtube_problems": yt_problems, "notes": notes}
if opts["format"] == "json":
print(json.dumps(report, indent=2, ensure_ascii=False))
return 0
if opts["format"] == "youtube":
print("\n".join(yt_lines))
for p in yt_problems + notes:
print(f"# note: {p}")
return 0
spk = ", ".join(f"{s} ({c} turns)" for s, c in speakers.most_common()) or "not labeled"
print(f"Transcript: {total_words} words, about {fmt(end_time)} long, timestamps {report['timestamps']}, "
f"speakers: {spk}")
print(f"\nChapter candidates ({len(chapters)}, min {opts['min_chapter']:g} min apart):")
for c in chapters:
est = "~" if c["estimated_time"] else " "
print(f" {c['n']:>2}. {est}{c['start_hms']:>8} {c['minutes']:>5.1f} min [{c['source']}] {c['title_draft']}")
print(f" keywords: {', '.join(c['keywords']) or '-'}")
if c["opening_line"]:
print(f" opens: \"{c['opening_line']}\"")
if markers:
print("\nMarkers (breaks, music, ads): " + ", ".join(f"{m['time_hms']} {m['marker']}" for m in markers))
print(f"\nQuote candidates ({len(quotes)}):")
for q in quotes:
who = f"{q['speaker']}: " if q["speaker"] else ""
edited = " (fillers removed, check against audio)" if q["edited"] else ""
print(f" {fmt(q['time']):>8} {who}\"{q['text']}\"{edited}")
print("\nYouTube chapter lines:")
for line in yt_lines:
print(f" {line}")
for p in yt_problems + notes:
print(f"note: {p}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Đánh giá lược đồ (schema) và các truy vấn SQL chạy chậm, đề xuất các chỉ mục phức hợp (composite indexes) theo đúng thứ tự cột (so sánh bằng, sắp xếp, khoảng giá trị) kèm các cột bao phủ (covering columns) và sự đánh đổi hiệu năng, gắn cờ các bộ lọc không thể tận dụng chỉ mục (non-sargable) như dùng hàm trên cột hoặc LIKE có ký tự đại diện ở đầu, đồng thời cảnh báo về các chỉ mục trùng lặp, dư thừa và có độ chọn lọc thấp. Sử dụng khi người dùng chia sẻ một truy vấn chậm, kết quả EXPLAIN, hoặc schema và hỏi "tôi cần chỉ mục nào?" hoặc "có quá nhiều chỉ mục không?". Bao gồm công cụ hỗ trợ bằng thư viện chuẩn Python đã được kiểm thử.
---
name: sql-index-recommendation-reviewer
description: Đánh giá lược đồ (schema) và các truy vấn SQL chạy chậm, đề xuất các chỉ mục phức hợp (composite indexes) theo đúng thứ tự cột (so sánh bằng, sắp xếp, khoảng giá trị) kèm các cột bao phủ (covering columns) và sự đánh đổi hiệu năng, gắn cờ các bộ lọc không thể tận dụng chỉ mục (non-sargable) như dùng hàm trên cột hoặc LIKE có ký tự đại diện ở đầu, đồng thời cảnh báo về các chỉ mục trùng lặp, dư thừa và có độ chọn lọc thấp. Sử dụng khi người dùng chia sẻ một truy vấn chậm, kết quả EXPLAIN, hoặc schema và hỏi "tôi cần chỉ mục nào?" hoặc "có quá nhiều chỉ mục không?". Bao gồm công cụ hỗ trợ bằng thư viện chuẩn Python đã được kiểm thử.
---
# Trình Đánh Giá và Khuyến Nghị Chỉ Mục SQL (SQL Index)
Bạn giúp các lập trình viên tăng tốc các truy vấn chậm mà không làm bảng dữ liệu bị quá tải bởi hàng tá chỉ mục (index). Bạn đọc schema và truy vấn theo đúng tư duy của một kỹ sư cơ sở dữ liệu: cột nào được so sánh bằng (equality), cột nào lọc theo khoảng giá trị (range), truy vấn sắp xếp theo cột nào, kết nối (join) trên cột nào, và những chỉ mục nào đã tồn tại sẵn. Bạn đề xuất tập hợp chỉ mục nhỏ gọn nhất đáp ứng được tải công việc (workload) theo đúng thứ tự cột, và luôn thẳng thắn rằng chỉ có lệnh EXPLAIN chạy trên dữ liệu thực tế mới có thể kiểm chứng chắc chắn kết quả.
## Các tệp trong kỹ năng này
- `scripts/suggest_indexes.py` - phân tích cú pháp các câu lệnh CREATE TABLE / CREATE INDEX và các truy vấn SELECT, UPDATE, DELETE (tách riêng các truy vấn con subquery và phần thân CTE), sau đó in ra các chỉ mục ứng viên kèm mã DDL, các truy vấn mà mỗi chỉ mục phục vụ, các cột bao phủ (covering columns), sự chồng chéo và các phát hiện trên schema (chỉ dùng thư viện chuẩn Python 3; không kết nối cơ sở dữ liệu)
- `references/indexing-rules.md` - quy tắc về thứ tự cột, chỉ mục bao phủ (covering indexes), chỉ mục từng phần (partial) và chỉ mục biểu thức (expression), tính chất sargable (khả năng tận dụng chỉ mục), cùng các quy tắc tránh đánh chỉ mục quá mức
- `references/explain-reading-guide.md` - hướng dẫn cách đọc kết quả EXPLAIN trong PostgreSQL, MySQL và SQLite để xác nhận hoặc bác bỏ một đề xuất
- `templates/index-review.md` - mẫu báo cáo đánh giá trả về cho người dùng
- `examples/example-shop-orders-review.md` - ví dụ thực tế đánh giá các truy vấn chậm của một cửa hàng trực tuyến
## Quy trình làm việc
### 1. Thu thập thông tin
Hỏi người dùng, hoặc tự giả định và nêu rõ điều đó:
- Hệ quản trị cơ sở dữ liệu và phiên bản (PostgreSQL, MySQL/MariaDB, SQLite, SQL Server), cùng với schema và các chỉ mục hiện có (`\d table`, `SHOW CREATE TABLE`, hoặc các tệp migration).
- Các truy vấn chậm nguyên bản như khi ứng dụng gửi đi, kèm theo các giá trị tham số điển hình, tần suất chạy của mỗi truy vấn và mức độ chậm thực tế.
- Kích thước bảng (số dòng), tần suất ghi (write-heavy) của mỗi bảng, và bất kỳ kết quả EXPLAIN hoặc EXPLAIN ANALYZE nào hiện có.
- Các ràng buộc vận hành: nhóm có thể tạo chỉ mục trực tuyến (online) không, dung lượng ổ cứng còn trống và những lo ngại về độ trễ sao chép dữ liệu (replication lag).
### 2. Chạy công cụ hỗ trợ
```bash
python3 scripts/suggest_indexes.py --schema schema.sql slow-queries.sql
python3 scripts/suggest_indexes.py all-in-one.sql --dialect mysql
python3 scripts/suggest_indexes.py --schema schema.sql - --json < query.sql
python3 scripts/suggest_indexes.py --schema schema.sql queries.sql --strict # exit 1 on non-sargable filters or duplicate indexes
```
Công cụ hỗ trợ này là một trình phân tích cú pháp dựa trên quy tắc ước lượng (heuristic parser), không phải bộ lập kế hoạch truy vấn (query planner): nó không biết số lượng dòng, phân phối dữ liệu hay kế hoạch thực thi mà cơ sở dữ liệu sẽ chọn. Nó có thể bỏ sót các điều kiện lọc (predicates) trong câu SQL phức tạp (và sẽ liệt kê chúng vào mục "not understood" - chưa hiểu được). Hãy tự mình đọc kỹ từng truy vấn và coi mỗi gợi ý từ công cụ là một ứng viên cần kiểm chứng. Nếu bạn không thể chạy script, hãy áp dụng thủ công các quy tắc trong `references/indexing-rules.md` theo đúng trình tự tương tự.
### 3. Ra quyết định
1. Sửa lại câu truy vấn trước khi thêm chỉ mục: viết lại các bộ lọc không thể tận dụng chỉ mục (non-sargable filters — như gọi hàm trên cột, phép tính số học trên cột, toán tử LIKE có ký tự đại diện `%` ở đầu, phép OR giữa nhiều cột khác nhau) để cơ sở dữ liệu có thể sử dụng được chỉ mục.
2. Đối với mỗi truy vấn, hãy chọn thứ tự cột: các cột so sánh bằng (ưu tiên cột có độ chọn lọc cao nhất lên trước khi bạn biết rõ điều đó), tiếp theo là các cột trong ORDER BY hoặc GROUP BY nếu truy vấn có sắp xếp đi kèm LIMIT, sau cùng là một cột lọc theo khoảng giá trị (range).
3. Hợp nhất chỉ mục: một chỉ mục phức hợp (composite index) có thể phục vụ nhiều truy vấn cùng lúc khi chúng dùng chung các cột ngoài cùng bên trái (leftmost columns). Ưu tiên mở rộng một chỉ mục sẵn có hơn là tạo thêm chỉ mục mới.
4. Chỉ thêm các cột bao phủ (INCLUDE / covering columns) cho các truy vấn chạy cực kỳ thường xuyên (hot) và thiên về đọc dữ liệu (read-heavy), đồng thời giữ số lượng cột này ở mức tối thiểu.
5. Dọn dẹp trước khi thêm mới: xóa bỏ các chỉ mục bị trùng lặp hoàn toàn và các chỉ mục tiền tố dư thừa (redundant-prefix indexes) sau khi đã kiểm tra thống kê tần suất sử dụng (`pg_stat_user_indexes`, `sys.schema_unused_indexes`).
6. Tính toán chi phí đánh đổi: mỗi chỉ mục thêm vào đều làm chậm thao tác INSERT và UPDATE trên các cột của nó, đồng thời tiêu tốn dung lượng ổ cứng và bộ nhớ RAM. Những bảng có tần suất ghi cao (write-heavy) nên có ít chỉ mục hơn và ưu tiên các chỉ mục đa dụng.
### 4. Bàn giao kết quả
Điền đầy đủ vào mẫu `templates/index-review.md`: chẩn đoán từng truy vấn, mã lệnh DDL khuyến nghị theo đúng thứ tự khởi tạo kèm danh sách các truy vấn mà mỗi chỉ mục phục vụ, các chỉ mục cần xóa bỏ, cách viết lại truy vấn, những sự đánh đổi (tradeoffs), và hướng dẫn cách kiểm chứng bằng lệnh EXPLAIN trước và sau khi áp dụng (xem `references/explain-reading-guide.md`). Tuân theo văn phong trình bày của `examples/example-shop-orders-review.md`.
## Quy tắc
- Không bao giờ tuyên bố truy vấn sẽ nhanh hơn bao nhiêu lần mà không chỉ rõ cách đo lường; hãy nêu những thay đổi dự kiến trong kế hoạch thực thi ("chuyển sang quét chỉ mục index scan thay vì quét tuần tự sequential scan và sắp xếp sort"), tuyệt đối không tự bịa ra các con số thời gian chạy.
- Sử dụng các lệnh tạo chỉ mục an toàn cho môi trường production: `CREATE INDEX CONCURRENTLY` trong PostgreSQL, tạo DDL trực tuyến (`ALGORITHM=INPLACE, LOCK=NONE`) trong MySQL, và lên lịch khung giờ bảo trì (maintenance window) đối với SQLite.
- Không khuyến nghị tạo chỉ mục đơn lẻ trên một cột có độ chọn lọc thấp (như cột trạng thái status, cột boolean); hãy đưa cột đó vào bên trong một chỉ mục phức hợp hoặc dùng làm điều kiện cho chỉ mục từng phần (partial index).
- Giữ nguyên các khóa chính (primary keys) và ràng buộc duy nhất (unique constraints); không bao giờ đề xuất xóa chúng đi để giảm "sự dư thừa".
- Nhắn người dùng tạo từng chỉ mục một và đối chiếu kế hoạch thực thi (execution plans), đồng thời gỡ bỏ bất kỳ chỉ mục nào mà lệnh EXPLAIN không bao giờ sử dụng tới.
FILE:references/indexing-rules.md
# Các quy tắc đánh chỉ mục (Indexing rules)
Dưới đây là những quy tắc mà công cụ hỗ trợ áp dụng, cùng với những đánh giá chuyên môn mà công cụ không thể tự quyết định thay con người. Các ví dụ sử dụng cú pháp PostgreSQL trừ khi có ghi chú khác.
## 1. Thứ tự cột trong chỉ mục phức hợp (composite index)
Một chỉ mục B-tree được sắp xếp theo cột đầu tiên của nó, sau đó đến cột thứ hai, và cứ tiếp tục như vậy. Một câu truy vấn có thể tìm kiếm nhảy cóc (seek) trên một chuỗi các cột liên tiếp tính từ ngoài cùng bên trái được so sánh bằng (equality), sau đó sử dụng thêm một cột tiếp theo cho điều kiện khoảng (range) hoặc cho việc sắp xếp (sorting).
Thứ tự chuẩn: **các cột so sánh bằng (equality columns), tiếp đến là các cột sắp xếp (sort columns), rồi đến một cột khoảng giá trị (range column)**.
```sql
-- WHERE customer_id = ? AND status = 'paid' ORDER BY created_at DESC LIMIT 20
CREATE INDEX ON orders (customer_id, status, created_at);
```
- Giữa các cột cùng dùng phép so sánh bằng, hãy đặt cột được sử dụng bởi nhiều truy vấn nhất lên đầu tiên để chỉ mục đó phục vụ được tất cả các truy vấn này; độ chọn lọc (selectivity) ít quan trọng hơn khả năng tái sử dụng một khi tất cả các cột đó đều là phép so sánh bằng.
- Đằng sau một cột lọc theo khoảng giá trị (`>`, `<`, `BETWEEN`, `LIKE 'abc%'`), các cột đứng sau nó không thể được dùng để tìm kiếm định vị (seeking) nữa, mà chỉ có thể dùng để lọc tuần tự ngay bên trong chỉ mục.
- Thao tác sắp xếp (sort) chỉ có thể tận dụng chỉ mục nếu mọi cột trong mệnh đề ORDER BY đều nằm trong chỉ mục theo đúng thứ tự đó và có chiều sắp xếp giống hệt nhau (hoặc đảo ngược hoàn toàn tất cả). Nếu chiều sắp xếp hỗn hợp (tăng giảm đan xen), cần phải định nghĩa chỉ mục khớp đúng theo chiều đó (`created_at DESC, id ASC`).
- Khi vừa có điều kiện khoảng giá trị vừa có mệnh đề ORDER BY trên một cột khác, cơ sở dữ liệu buộc phải lựa chọn: hoặc tìm kiếm theo khoảng giá trị rồi mới đem đi sắp xếp, hoặc duyệt theo thứ tự đã sắp xếp sẵn của chỉ mục rồi lọc dần. Khi đi kèm với LIMIT, phương án duyệt theo thứ tự sắp xếp sẵn thường chiến thắng.
## 2. Tiền tố ngoài cùng bên trái (Leftmost prefix) và việc hợp nhất chỉ mục
Một chỉ mục trên `(a, b, c)` phục vụ được các truy vấn lọc trên `a`, trên `a, b`, và trên `a, b, c`, nhưng không phục vụ được truy vấn chỉ lọc riêng trên `b` hoặc `c`. Hai chỉ mục ứng viên mà trong đó một cái là tiền tố ngoài cùng bên trái của cái kia thì nên được gộp lại thành một chỉ mục duy nhất. Một chỉ mục hiện có trên `(a)` sẽ trở nên dư thừa một khi đã có chỉ mục `(a, b)`, trừ phi chỉ mục `(a)` đó có ràng buộc duy nhất (unique), là chỉ mục từng phần (partial), hoặc có kích thước nhỏ hơn hẳn và được truy cập cực kỳ thường xuyên.
## 3. Phép nối (Joins) và khóa ngoại (Foreign keys)
- Hãy đánh chỉ mục trên cột thuộc về phía bảng được tra cứu: thông thường chính là cột khóa ngoại (`orders.customer_id`). Phía khóa chính vốn dĩ đã được đánh chỉ mục sẵn.
- PostgreSQL không tự động đánh chỉ mục cho các khóa ngoại (trong khi MySQL InnoDB thì có). Việc thiếu chỉ mục khóa ngoại cũng khiến thao tác xóa (DELETE) trên bảng cha bị chậm đi đáng kể.
- Cột dùng để join cộng với các điều kiện lọc riêng của chính bảng đó có thể kết hợp lại thành một chỉ mục phức hợp: `(customer_id, created_at)`.
## 4. Chỉ mục bao phủ (Covering indexes)
Nếu chỉ mục đã lưu giữ toàn bộ mọi cột mà câu truy vấn cần đọc, cơ sở dữ liệu có thể bỏ qua bước đọc bảng chính (quét chỉ mục thuần túy — index-only scan).
- PostgreSQL 11+ và SQL Server: dùng mệnh đề `INCLUDE (col, ...)` để đính kèm thêm các cột phụ vào trang lá của chỉ mục mà không cần sắp xếp theo chúng.
- MySQL và SQLite: nối thêm các cột đó vào cuối phần khóa chỉ mục; các chỉ mục phụ (secondary indexes) của InnoDB vốn đã tự động đính kèm khóa chính bên trong.
- Chỉ nên áp dụng cho các truy vấn chạy liên tục, chủ yếu là đọc dữ liệu và chỉ lấy ra một vài cột. Các chỉ mục bao phủ quá rộng sẽ chiếm nhiều dung lượng và làm chậm tốc độ ghi.
- Quá trình quét chỉ mục thuần túy (index-only scan) trên PostgreSQL cũng đòi hỏi bảng dữ liệu phải được dọn dẹp (`VACUUM`) thường xuyên để cập nhật bản đồ hiển thị (visibility map).
## 5. Chỉ mục từng phần (Partial) và chỉ mục biểu thức (Expression)
- Chỉ mục từng phần (PostgreSQL, SQLite; tương đương filtered index trong SQL Server): `CREATE INDEX ON orders (created_at) WHERE status = 'paid' AND shipped_at IS NULL;` Cực kỳ nhỏ gọn và nhanh chóng khi điều kiện WHERE chỉ khớp với một tỷ lệ nhỏ các dòng dữ liệu. Lưu ý câu truy vấn bắt buộc phải lặp lại chính xác điều kiện lọc đó thì mới kích hoạt được chỉ mục.
- Biểu thức (Expression): `CREATE INDEX ON users (lower(email));` phục vụ điều kiện `WHERE lower(email) = ?`. MySQL 8 hỗ trợ chỉ mục hàm (functional index) `((lower(email)))` hoặc tạo một cột sinh tự động (generated column) kèm chỉ mục.
## 6. Khả năng tận dụng chỉ mục (Sargability): những bộ lọc mà chỉ mục không thể sử dụng
| Khuôn mẫu | Vấn đề | Cách khắc phục |
|---|---|---|
| `WHERE date(created_at) = '2026-10-12'` | gọi hàm trên cột | `created_at >= '2026-10-12' AND created_at < '2026-10-13'` |
| `WHERE YEAR(updated_at) = 2026` | gọi hàm trên cột | lọc theo khoảng giá trị trên cột |
| `WHERE price * 1.2 > 100` | phép tính số học trên cột | `price > 100 / 1.2` |
| `WHERE name LIKE '%lamp%'` | ký tự đại diện (wildcard) ở đầu | tìm kiếm toàn văn (full-text search), hoặc chỉ mục GIN `pg_trgm` |
| `WHERE lower(name) = ?` | gọi hàm | chỉ mục biểu thức trên `lower(name)` hoặc dùng bảng đối chiếu không phân biệt hoa thường (case-insensitive collation) |
| `WHERE a = 1 OR b = 2` | dùng OR giữa các cột khác nhau | `UNION` hai câu truy vấn có chỉ mục, hoặc tạo một chỉ mục cho mỗi cột (bitmap OR trong PostgreSQL) |
| `WHERE status <> 'done'`, `NOT IN` | bộ lọc phủ định | dùng danh sách khẳng định `IN (...)` hoặc chỉ mục một phần (partial index) |
| `WHERE id = '42'` trên cột số nguyên | ép kiểu ngầm định (MySQL có thể bỏ qua chỉ mục trên các cột chuỗi khi đem so sánh với số) | so sánh đúng kiểu dữ liệu |
## 7. Đánh chỉ mục quá mức (Over-indexing)
- Mỗi chỉ mục đều phải cập nhật khi thực hiện INSERT, DELETE và khi UPDATE các cột của nó; ngoài ra nó còn tiêu tốn dung lượng ổ đĩa, bộ nhớ (buffer cache), cũng như băng thông sao lưu và nhân bản dữ liệu (replication).
- Các chỉ mục trùng lặp (cùng danh sách cột, cùng điều kiện) hoàn toàn chỉ gây tốn kém chi phí: hãy xóa bớt một cái.
- Chỉ mục đơn cột trên các cột có độ chọn lọc thấp (boolean, status, type) hiếm khi được sử dụng; bộ lập kế hoạch (planner) thường ưu tiên quét bảng khi một giá trị khớp với hơn khoảng 5 đến 20 phần trăm số dòng. Hãy đưa chúng vào trong các chỉ mục phức hợp (composite index) hoặc dùng làm điều kiện cho chỉ mục một phần (partial index).
- Theo kinh nghiệm thực tế, nếu một bảng có tần suất ghi cao mà có trên 5 hoặc 6 chỉ mục thì cần được rà soát lại; các bảng chủ yếu phục vụ đọc có thể chịu được nhiều chỉ mục hơn.
- Tìm các chỉ mục không được sử dụng trước khi xóa: trên PostgreSQL kiểm tra `pg_stat_user_indexes.idx_scan = 0` (kể từ lần đặt lại thống kê gần nhất, trên tất cả các máy chủ replica), trên MySQL kiểm tra `sys.schema_unused_indexes`. Xóa bằng lệnh `DROP INDEX CONCURRENTLY` trong PostgreSQL.
## 8. Xây dựng chỉ mục an toàn
- PostgreSQL: `CREATE INDEX CONCURRENTLY` giúp tránh chặn các thao tác ghi; lệnh này không thể chạy bên trong một giao dịch (transaction) và sẽ để lại một chỉ mục ở trạng thái INVALID nếu thất bại (hãy xóa đi và thử lại).
- MySQL 8: `ALTER TABLE ... ADD INDEX ..., ALGORITHM=INPLACE, LOCK=NONE`; cần theo dõi độ trễ nhân bản (replication lag) trên các bảng lớn.
- SQLite: quá trình tạo chỉ mục sẽ khóa cơ sở dữ liệu; hãy chạy vào thời điểm ít lưu lượng truy cập.
- Tạo từng chỉ mục một, kiểm tra kế hoạch thực thi, rồi mới tiếp tục với chỉ mục tiếp theo.
FILE:references/explain-reading-guide.md
# Đọc EXPLAIN để xác nhận hiệu quả chỉ mục
Luôn so sánh kế hoạch thực thi trước và sau khi thay đổi, trên dữ liệu tương tự môi trường production và với các giá trị tham số thực tế. Các bảng kiểm thử quy mô nhỏ sẽ khiến bộ lập kế hoạch chọn phương án quét bảng vốn chỉ phù hợp cho bảng nhỏ.
## PostgreSQL
```sql
EXPLAIN (ANALYZE, BUFFERS) SELECT ...; -- runs the query; wrap writes in BEGIN ... ROLLBACK
```
| Nút kế hoạch (Plan node) | Ý nghĩa |
|---|---|
| Seq Scan | đọc toàn bộ bảng; ổn đối với bảng nhỏ hoặc khi phần lớn số dòng đều khớp điều kiện |
| Index Scan | tra cứu chỉ mục, sau đó đọc các dòng tương ứng trong bảng |
| Index Only Scan | trả kết quả trực tiếp từ chỉ mục; hãy kiểm tra chỉ số "Heap Fetches" xem có thấp không (nếu không thấp hãy chạy vacuum) |
| Bitmap Index Scan + Bitmap Heap Scan | thu thập nhiều kết quả khớp, sau đó đọc các trang dữ liệu theo thứ tự; phổ biến với độ chọn lọc trung bình và điều kiện OR |
| Sort (kèm "external merge") | quá trình sắp xếp bị tràn ra ổ đĩa; một chỉ mục theo đúng thứ tự ORDER BY có thể loại bỏ bước này |
| Rows Removed by Filter | số dòng đã đọc lên nhưng bị loại bỏ; con số này cao nghĩa là chỉ mục chưa bao phủ được bộ lọc |
Hãy chú ý đến: thời gian thực tế (actual time) của nút trên cùng, số dòng ước tính so với thực tế (chênh lệch gấp 10 lần nghĩa là thống kê đã cũ: hãy chạy `ANALYZE table`), và các chỉ số bộ đệm shared hit/read buffers. Xác nhận chỉ mục mới thực sự được sử dụng thông qua `pg_stat_user_indexes.idx_scan` sau một ngày có lưu lượng truy cập thực tế.
## MySQL 8
```sql
EXPLAIN FORMAT=TREE SELECT ...;
EXPLAIN ANALYZE SELECT ...; -- 8.0.18+, runs the query
```
Trong đầu ra `EXPLAIN` truyền thống, hãy kiểm tra: `type` (ALL = quét toàn bộ bảng, ref/eq_ref/range = có dùng chỉ mục, index = quét toàn bộ chỉ mục), `key` (chỉ mục được chọn), `rows` (ước tính), và `Extra` ("Using index" = chỉ mục bao phủ, "Using filesort" = phát sinh bước sắp xếp phụ, "Using temporary" = dùng bảng tạm cho GROUP BY hoặc DISTINCT). Làm mới thống kê bằng lệnh `ANALYZE TABLE t`.
## SQLite
```sql
EXPLAIN QUERY PLAN SELECT ...;
```
"SCAN t" là quét toàn bộ bảng; "SEARCH t USING INDEX idx (a=? AND b>?)" cho biết những cột nào được dùng để tra cứu; "USING COVERING INDEX" nghĩa là không cần truy cập vào bảng dữ liệu; "USE TEMP B-TREE FOR ORDER BY" nghĩa là phát sinh thêm bước sắp xếp. Hãy chạy `ANALYZE;` để bộ lập kế hoạch có dữ liệu thống kê.
## Dấu hiệu của sự thành công
- Kế hoạch thực thi của câu truy vấn chậm chuyển từ quét toàn bộ bảng kèm sắp xếp sang quét chỉ mục (hoặc chỉ quét chỉ mục - index only scan) mà không cần sắp xếp, đồng thời số dòng phải đọc giảm xuống gần bằng số dòng trả về.
- Các thao tác ghi trên bảng không bị chậm đi đáng kể (hãy kiểm tra độ trễ p95 của thao tác insert và update sau khi triển khai).
- Không có câu truy vấn quan trọng nào khác bị đổi sang kế hoạch thực thi tệ hơn; hãy chạy tập các truy vấn chiếm nhiều tổng thời gian nhất (`pg_stat_statements`, các bảng digest trong performance schema của MySQL) trước và sau khi thay đổi.
## Khi chỉ mục không được sử dụng
1. Thống kê đã cũ: hãy chạy ANALYZE.
2. Bộ lọc không có khả năng tận dụng chỉ mục - non-sargable (xem mục 6 trong indexing-rules.md).
3. Giá trị cần tìm khớp với quá nhiều dòng; bộ lập kế hoạch chọn quét bảng là hoàn toàn hợp lý.
4. Kiểu dữ liệu của cột hoặc bảng đối chiếu (collation) không đồng nhất giữa câu truy vấn và cột trong bảng (ép kiểu ngầm định).
5. Câu truy vấn tham số hóa sử dụng kế hoạch thực thi chung (generic plan); hãy kiểm thử bằng câu lệnh prepared statement thực tế.
6. Các cột trong chỉ mục đang sắp xếp sai thứ tự so với yêu cầu của câu truy vấn.
FILE:templates/index-review.md
# Đánh giá chỉ mục: app_or_service
**Hệ CSDL (Engine):** engine_and_version | **Các bảng:** tables_with_row_counts | **Tải ghi (Write load):** write_profile
**Công cụ hỗ trợ (Helper):** Đã phân tích n_queries truy vấn, n_suggested ứng viên chỉ mục, n_findings phát hiện về lược đồ (schema)
## Tóm tắt
Hai hoặc ba câu: nguyên nhân chính gây chậm, các chỉ mục cần thêm vào và xóa đi, cùng sự thay đổi dự kiến trong kế hoạch thực thi.
## Chẩn đoán theo từng truy vấn
| Truy vấn | Tần suất chạy | Hiện tại (kế hoạch / thời gian) | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
| Q1 | per_minute | Seq Scan + Sort | | |
## Viết lại câu truy vấn (thực hiện bước này trước)
```sql
-- before
-- after
```
## Các chỉ mục cần thêm (tạo theo đúng thứ tự này)
| # | Lệnh DDL | Phục vụ truy vấn | Lý do chọn thứ tự cột này | Chi phí |
|---|---|---|---|---|
| 1 | | | | |
## Các chỉ mục cần xóa (sau khi đã kiểm tra mức độ sử dụng)
| Chỉ mục | Lý do | Kiểm tra trước khi xóa |
|---|---|---|
| | trùng lặp / tiền tố dư thừa / không được sử dụng / độ chọn lọc thấp | `idx_scan` trên máy chủ chính (primary) và các máy chủ bản sao (replicas) |
## Tác động ròng trên từng bảng
| Bảng | Số chỉ mục trước | Sau khi thay đổi | Ghi chú |
|---|---|---|---|
## Cách xác minh
1. Thu thập `EXPLAIN (ANALYZE, BUFFERS)` cho từng câu truy vấn trước khi thay đổi.
2. Tạo từng chỉ mục một (`CONCURRENTLY` / online DDL), chạy `ANALYZE`, sau đó thu thập lại kế hoạch thực thi.
3. Kỳ vọng: (sự thay đổi kế hoạch thực thi cho từng câu truy vấn).
4. Theo dõi độ trễ ghi và độ trễ nhân bản (replication lag) trong một ngày; sau đó mới xóa các chỉ mục được liệt kê ở trên.
## Lưu ý
- Các đề xuất chỉ dựa trên văn bản câu truy vấn và lược đồ (schema); số lượng dòng thực tế và độ lệch dữ liệu (data skew) có thể làm thay đổi lựa chọn tối ưu nhất.
FILE:examples/example-shop-orders-review.md
# Ví dụ: Đánh giá chỉ mục cho một cửa hàng trực tuyến
## Yêu cầu của người dùng
"Cơ sở dữ liệu cửa hàng chạy PostgreSQL 16 của chúng tôi đang bị chậm dần. Bảng orders có 12 triệu dòng và nhận khoảng 40 lệnh insert mỗi giây, bảng customers có 900 nghìn dòng, bảng products có 20 nghìn dòng. Dưới đây là schema và sáu câu truy vấn xuất hiện trong pg_stat_statements. Chúng tôi nên thêm những chỉ mục nào, và liệu hiện tại chúng tôi đã có quá nhiều chỉ mục hay chưa?"
## Chạy công cụ hỗ trợ (Helper)
```
$ python3 scripts/suggest_indexes.py --schema shop-schema.sql slow-queries.sql
Schema: 4 table(s), 12 existing index(es) incl. keys | dialect: postgres
Queries analyzed: 7
Q1 SELECT: SELECT o.id, o.total, o.created_at FROM orders o WHERE o.customer_id = $1 AND o.status = 'paid' ORDER BY o.cre...
equality: orders.customer_id (=), orders.status (=)
order by: orders.created_at DESC (LIMIT)
Q2 SELECT: SELECT c.email, count(*) AS orders FROM customers c JOIN orders o ON o.customer_id = c.id WHERE c.country = 'D...
equality: customers.country (=)
range: orders.created_at (range)
joins: orders.customer_id = customers.id
group by: customers.email
Q3 SELECT: SELECT * FROM products WHERE lower(name) LIKE '%lamp%'
WARN non-sargable: lower(name) in the WHERE clause hides the column from a normal index; ... the LIKE pattern '%lamp%' also starts with a wildcard, so only full-text search or a trigram index can help
Q4 SELECT: ... WHERE oi.order_id IN (SELECT id FROM orders WHERE customer_id = 42 AND created_at > now() - interval '30 days')
note: subquery analyzed as Q5
Q6 UPDATE: UPDATE orders SET status = 'shipped', shipped_at = now() WHERE status = 'paid' AND shipped_at IS NULL AND crea...
Q7 SELECT: SELECT id FROM customers WHERE email = 'ann@example.com' OR phone = '+491701234567'
WARN OR across different columns (...): one composite index cannot serve both sides; consider UNION ALL of two indexed queries or one index per side
Suggested indexes (4):
1. CREATE INDEX CONCURRENTLY idx_customers_country_email ON customers (country, email);
2. CREATE INDEX CONCURRENTLY idx_orders_customer_id_created_at ON orders (customer_id, created_at);
serves: Q2, Q5 | overlap: same leading column as #3
3. CREATE INDEX CONCURRENTLY idx_orders_customer_id_status_created_at ON orders (customer_id, status, created_at) INCLUDE (id, total);
serves: Q1 | overlap: same leading column as #2
4. CREATE INDEX CONCURRENTLY idx_orders_status_shipped_at_created_at ON orders (status, shipped_at, created_at);
serves: Q6 | alternative: ... ON orders (status, created_at) WHERE shipped_at IS NULL;
Schema findings (5):
WARN redundant-prefix customers: idx_customers_country (country) is a leftmost prefix of idx_customers_country_created ...
WARN low-selectivity customers: idx_customers_active indexes only 'is_active' ...
HIGH duplicate-index orders: idx_orders_created and orders_created_at_idx have the same columns (created_at); drop one
WARN low-selectivity orders: idx_orders_status indexes only 'status' ...
WARN many-indexes orders: 4 existing + 3 suggested = 7 indexes (limit 6) ...
```
## Bản đánh giá (mẫu đã điền, rút gọn)
**Tóm tắt:** Bảng orders không có chỉ mục nào bắt đầu bằng cột customer_id, vì vậy trang lịch sử đơn hàng (Q1) và truy vấn tra cứu đơn hàng gần đây (Q5) phải quét hàng triệu dòng, đồng thời hai trong số bốn chỉ mục hiện có của bảng này là một chỉ mục bị trùng lặp và một chỉ mục trên cột status có độ chọn lọc thấp. Hãy thêm một chỉ mục phức hợp và một chỉ mục một phần (partial index) trên bảng orders, thêm một chỉ mục cột phone và một chỉ mục trigram ở các bảng khác, đồng thời xóa đi ba chỉ mục: bảng orders cuối cùng vẫn giữ nguyên tổng số 4 chỉ mục nhưng mọi chỉ mục đều thực sự hữu ích.
| Truy vấn | Vấn đề | Cách khắc phục |
|---|---|---|
| Q1 lịch sử đơn hàng | không có chỉ mục trên customer_id; cần sắp xếp theo created_at | đánh chỉ mục (customer_id, created_at) kèm status và total trong INCLUDE: lấy ngay các dòng mới nhất của khách hàng đó, lọc status ngay bên trong chỉ mục, LIMIT dừng lại sau 20 dòng |
| Q2 báo cáo theo quốc gia | phép join cần orders.customer_id; cột customers.country đã được phục vụ bởi idx_customers_country_created | dùng chung chỉ mục của bảng orders ở trên; không cần thêm chỉ mục mới cho bảng customers (đề xuất #1 của công cụ hỗ trợ chỉ giúp ích cho GROUP BY, mà báo cáo này chỉ chạy mỗi đêm một lần) |
| Q3 tìm kiếm sản phẩm | `lower(name) LIKE '%lamp%'` không thể dùng chỉ mục B-tree | tạo chỉ mục GIN pg_trgm trên biểu thức lower(name) |
| Q4/Q5 các mặt hàng gần đây | truy vấn con cần (customer_id, created_at); khóa chính PK (order_id, product_id) của bảng order_items đã phục vụ toán tử IN | dùng chung chỉ mục của bảng orders ở trên |
| Q6 tiến trình giao hàng | quét bảng theo cột status | tạo chỉ mục một phần (partial index) trên cột created_at chỉ dành riêng cho các đơn hàng đã thanh toán nhưng chưa giao |
| Q7 tra cứu hỗ trợ khách hàng | dùng OR giữa hai cột email và phone | tách thành UNION của hai truy vấn tra cứu; cột email đã có ràng buộc unique sẵn, chỉ cần thêm một chỉ mục cho cột phone |
Tại sao không thêm cả chỉ mục đề xuất #3 của công cụ hỗ trợ: đề xuất #2 và #3 có chung cột dẫn đầu customer_id, và trên một bảng có tới 40 lệnh insert mỗi giây thì duy trì một chỉ mục sẽ tốt hơn là hai. Người dùng đã xác nhận rằng khoảng 95 phần trăm đơn hàng ở trạng thái đã thanh toán (paid), do đó Q1 chỉ phải loại bỏ rất ít dòng, và chỉ mục (customer_id, created_at) có đính kèm status trong INCLUDE vẫn giữ cho Q1 chạy rất nhanh. Nếu tỷ lệ đơn hàng chưa thanh toán tăng lên trong tương lai, khi đó hãy chuyển sang thứ tự cột của đề xuất #3.
**Các chỉ mục cần thêm (theo đúng thứ tự này):**
```sql
CREATE INDEX CONCURRENTLY idx_orders_customer_created
ON orders (customer_id, created_at) INCLUDE (id, status, total); -- Q1, Q2, Q5
CREATE INDEX CONCURRENTLY idx_orders_paid_unshipped
ON orders (created_at) WHERE status = 'paid' AND shipped_at IS NULL; -- Q6, holds only paid, unshipped orders
CREATE INDEX CONCURRENTLY idx_customers_phone ON customers (phone); -- Q7 after the rewrite
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY idx_products_name_trgm
ON products USING gin (lower(name) gin_trgm_ops); -- Q3, query unchanged
```
**Viết lại câu truy vấn (Q7):**
```sql
SELECT id FROM customers WHERE email = 'ann@example.com'
UNION
SELECT id FROM customers WHERE phone = '+491701234567';
```
**Các chỉ mục cần xóa (sau khi kiểm tra `pg_stat_user_indexes.idx_scan` trên máy chủ chính và cả hai máy chủ replica):**
| Chỉ mục | Lý do |
|---|---|
| orders_created_at_idx | trùng lặp với idx_orders_created |
| idx_orders_status | độ chọn lọc thấp; Q6 hiện đã chuyển sang dùng chỉ mục một phần (partial index) |
| idx_customers_country | là tiền tố ngoài cùng bên trái của idx_customers_country_created |
| idx_customers_active (cần kiểm tra) | kiểu boolean; chỉ giữ lại nếu idx_scan cho thấy có sử dụng thực tế |
**Tác động ròng:** bảng orders từ 4 -> 4 chỉ mục (thay thế hai chỉ mục cũ bằng hai chỉ mục tốt hơn), bảng customers từ 5 -> 5 (xóa một, thêm chỉ mục phone; hoặc giảm xuống 4 nếu xóa luôn cả is_active), bảng products từ 2 -> 3.
**Kiểm tra lại bằng công cụ hỗ trợ** (lược đồ sau khi thay đổi, các câu truy vấn sau khi viết lại; số thứ tự truy vấn tuân theo tệp mới, nên Q3 là Q5 cũ còn Q4/Q5 là hai nhánh của phép UNION):
```
$ python3 scripts/suggest_indexes.py --schema shop-schema-after.sql shop-queries-after.sql --no-covering
Already served by existing indexes (5):
customers (email) -> customers_email_key (Q4)
customers (id, country) -> customers_pkey (Q2)
customers (phone) -> idx_customers_phone (Q5)
orders (created_at) -> idx_orders_created (Q2)
orders (customer_id, created_at) -> idx_orders_customer_created (Q2, Q3)
```
Công cụ hỗ trợ vẫn liệt kê (customer_id, status, created_at) cho Q1 và (country, email) cho Q2; cả hai đều đã được chủ đích loại bỏ vì những lý do nêu trên. Không còn phát hiện nào về chỉ mục trùng lặp hay tiền tố dư thừa nữa.
**Cách xác minh:** trước và sau mỗi lần tạo chỉ mục, hãy chạy `EXPLAIN (ANALYZE, BUFFERS)` cho Q1 với một mã khách hàng (customer id) thực tế. Kết quả kỳ vọng là "Index Scan using idx_orders_customer_created" (hoặc Index Only Scan) không còn nút Sort và chỉ đọc khoảng 20 dòng thay vì phải thực hiện Bitmap Heap Scan qua hàng nghìn dòng. Hãy theo dõi độ trễ thao tác insert trên bảng orders trong một ngày trước khi xóa các chỉ mục cũ.
FILE:scripts/suggest_indexes.py
#!/usr/bin/env python3
"""Suggest candidate indexes for slow SQL queries from a schema and the query text (no database needed).
Usage:
python3 suggest_indexes.py --schema schema.sql queries.sql [options]
python3 suggest_indexes.py all-in-one.sql (CREATE statements and queries in one file)
python3 suggest_indexes.py --schema schema.sql - < slow.sql
Options:
--schema FILE CREATE TABLE / CREATE INDEX statements (may also be inside the query file)
--dialect NAME postgres (default), mysql, or sqlite; changes INCLUDE and online-build hints
--max-indexes N warn when a table would have more than N indexes (default 6)
--no-covering do not suggest covering (INCLUDE) columns
--json print the result as JSON
--strict exit 1 if any query has a non-sargable predicate or the schema has duplicate indexes
What it does: lightweight parsing of SELECT, UPDATE, and DELETE statements (subqueries are analyzed
separately) to find equality filters, range filters, join columns, ORDER BY and GROUP BY columns,
and selected columns per table. It builds composite candidates (equality columns, then sort, then
range), merges candidates that are prefixes of each other, skips those already served by an existing
index, and reports duplicate and redundant indexes, low-selectivity single-column indexes, and tables
with many indexes. It does not know row counts or data distribution: confirm every suggestion with
EXPLAIN on real data.
Exit codes: 0 ok, 1 --strict findings, 2 usage or read error.
Python 3 standard library only.
"""
import json
import re
import signal
import sys
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_DFL) # quiet exit when piped into head
KEYWORDS = set("""select from where and or not in is null like ilike between join inner left right full outer cross on
using group by order having limit offset as asc desc distinct union all exists case when then else end update set
delete insert into values returning with true false interval current_date current_timestamp now lateral natural
fetch first rows only for share nowait skip locked""".split())
LOW_CARD = re.compile(r"^(is_|has_|can_|should_)|^(status|state|type|kind|flag|active|enabled|deleted|archived|gender|"
r"visible|published|verified|priority|level|tier|role)$", re.I)
CLAUSE_END = r"(?=\b(?:where|group\s+by|order\s+by|having|limit|offset|union|returning|for\s+update|window)\b|$)"
class UsageError(Exception):
pass
# ---------------------------------------------------------------- text helpers
def strip_comments(sql):
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.S)
return re.sub(r"--[^\n]*", " ", sql)
def split_statements(sql):
out, buf, quote = [], [], None
for ch in sql:
if quote:
buf.append(ch)
if ch == quote:
quote = None
continue
if ch in "'\"`":
quote = ch
if ch == ";":
out.append("".join(buf))
buf = []
else:
buf.append(ch)
out.append("".join(buf))
return [s.strip() for s in out if s.strip()]
def unquote_idents(sql):
sql = re.sub(r'"([A-Za-z_][\w$]*)"', r"\1", sql)
sql = re.sub(r"`([A-Za-z_][\w$]*)`", r"\1", sql)
return re.sub(r"\[([A-Za-z_][\w$]*)\]", r"\1", sql)
def mask_strings(sql):
"""Replace string literals with placeholders, remembering LIKE patterns."""
lits = []
def rep(m):
lits.append(m.group(0)[1:-1])
return f" __lit{len(lits) - 1}__ "
return re.sub(r"'(?:[^']|'')*'", rep, sql), lits
def split_top(text, sep_regex):
"""Split on a keyword at parenthesis depth 0."""
parts, depth, last = [], 0, 0
for m in re.finditer(r"\(|\)|" + sep_regex, text, flags=re.I):
tok = m.group(0)
if tok == "(":
depth += 1
elif tok == ")":
depth -= 1
elif depth == 0:
parts.append(text[last:m.start()])
last = m.end()
parts.append(text[last:])
return [p.strip() for p in parts if p.strip()]
def split_and(text):
"""Split on top-level AND, keeping 'x BETWEEN a AND b' together."""
parts = split_top(text, r"\band\b")
out = []
for part in parts:
if out and re.search(r"\bbetween\s+[^\s]+$", out[-1], re.I):
out[-1] = f"{out[-1]} AND {part}"
else:
out.append(part)
return out
def extract_subqueries(sql):
"""Pull out innermost (SELECT ...) blocks; return (outer_sql, [subqueries])."""
subs = []
pattern = re.compile(r"\(\s*(select\b[^()]*(?:\([^()]*\)[^()]*)*)\)", re.I)
while True:
m = pattern.search(sql)
if not m:
break
subs.append(m.group(1))
sql = sql[:m.start()] + " __subq__ " + sql[m.end():]
return sql, subs
# ---------------------------------------------------------------- schema
def parse_columns_list(text):
return [re.sub(r"\s+(asc|desc)\b.*$", "", c.strip(), flags=re.I).strip().lower()
for c in split_top(text, r",")]
def parse_schema(statements):
tables, indexes = {}, []
for st in statements:
s = unquote_idents(strip_comments(st)).strip()
m = re.match(r"create\s+table\s+(?:if\s+not\s+exists\s+)?([\w.]+)\s*\((.*)\)\s*[^)]*$", s, re.I | re.S)
if m:
name = m.group(1).split(".")[-1].lower()
cols, pk = [], None
for part in split_top(m.group(2), r","):
p = part.strip()
low = p.lower()
c = re.match(r"(?:constraint\s+\w+\s+)?primary\s+key\s*\(([^)]*)\)", low)
if c:
pk = parse_columns_list(c.group(1))
continue
c = re.match(r"(?:constraint\s+(\w+)\s+)?unique\s*(?:key\s+\w+\s*)?\(([^)]*)\)", low)
if c:
indexes.append({"name": c.group(1) or f"{name}_unique", "table": name,
"columns": parse_columns_list(c.group(2)), "unique": True, "kind": "unique"})
continue
c = re.match(r"(?:key|index)\s+(\w+)\s*\(([^)]*)\)", low)
if c:
indexes.append({"name": c.group(1), "table": name, "columns": parse_columns_list(c.group(2)),
"unique": False, "kind": "index"})
continue
if re.match(r"(constraint|foreign\s+key|check|exclude)\b", low):
continue
col = re.match(r"([a-z_][\w$]*)\s+", low + " ")
if col:
cols.append(col.group(1))
if re.search(r"\bprimary\s+key\b", low):
pk = [col.group(1)]
elif re.search(r"\bunique\b", low):
indexes.append({"name": f"{name}_{col.group(1)}_key", "table": name,
"columns": [col.group(1)], "unique": True, "kind": "unique"})
tables[name] = {"columns": cols}
if pk:
indexes.append({"name": f"{name}_pkey", "table": name, "columns": pk, "unique": True,
"kind": "primary"})
continue
m = re.match(r"create\s+(unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?(\w+)\s+on\s+"
r"([\w.]+)\s*(?:using\s+\w+\s*)?\(([^;]*?)\)\s*(include\s*\(([^)]*)\))?\s*(where\s+.*)?$",
s, re.I | re.S)
if m:
cols = parse_columns_list(m.group(4))
indexes.append({"name": m.group(2), "table": m.group(3).split(".")[-1].lower(), "columns": cols,
"unique": bool(m.group(1)), "kind": "index",
"include": parse_columns_list(m.group(6)) if m.group(6) else [],
"partial": (m.group(7) or "").strip() or None})
return tables, indexes
# ---------------------------------------------------------------- queries
def parse_from(from_text):
"""Return (tables {alias: table}, order list, join predicates text list)."""
aliases, order, ons = {}, [], []
pieces = re.split(r"\b((?:natural\s+)?(?:inner\s+|left\s+(?:outer\s+)?|right\s+(?:outer\s+)?|full\s+(?:outer\s+)?|"
r"cross\s+)?join)\b", from_text, flags=re.I)
items = [pieces[0]] + [pieces[i + 1] for i in range(1, len(pieces) - 1, 2)]
for item in items:
on_split = re.split(r"\bon\b", item, maxsplit=1, flags=re.I)
using = re.search(r"\busing\s*\(([^)]*)\)", on_split[0], re.I)
head = re.sub(r"\busing\s*\([^)]*\)", "", on_split[0], flags=re.I)
for ref in head.split(","):
toks = [t for t in re.split(r"\s+", ref.strip()) if t and t.lower() not in ("as", "lateral", "only")]
if not toks or toks[0] == "__subq__":
continue
table = toks[0].split(".")[-1].lower()
alias = toks[1].lower() if len(toks) > 1 else table
aliases[alias] = table
order.append(alias)
if len(on_split) > 1:
ons.append(on_split[1])
if using and len(order) >= 2:
for col in parse_columns_list(using.group(1)):
ons.append(f"{order[-2]}.{col} = {order[-1]}.{col}")
return aliases, order, ons
COL = r"((?:[a-z_][\w$]*\.)?[a-z_][\w$]*)"
VALUE = r"(?:__lit\d+__|\?|:\w+|\$\d+|@\w+|%s|-?\d+(?:\.\d+)?|true|false|null|__subq__|current_date|current_timestamp|now\(\))"
def resolve(ref, aliases, tables):
ref = ref.lower()
if "." in ref:
a, c = ref.split(".", 1)
return (aliases.get(a, a), c)
if len(set(aliases.values())) == 1:
return (next(iter(aliases.values())), ref)
owners = [t for t in set(aliases.values()) if ref in tables.get(t, {}).get("columns", [])]
return (owners[0], ref) if len(owners) == 1 else (None, ref)
def analyze_predicate(pred, lits, aliases, tables, q):
p = pred.strip()
while p.startswith("(") and p.endswith(")") and len(split_top(p[1:-1], r"\band\b")) >= 1 and \
p.count("(") == p.count(")") and _balanced_wrap(p):
p = p[1:-1].strip()
if len(split_and(p)) > 1:
for sub in split_and(p):
analyze_predicate(sub, lits, aliases, tables, q)
return
ors = split_top(p, r"\bor\b")
if len(ors) > 1:
cols = set()
for o in ors:
m = re.match(rf"^\(?\s*{COL}\s*(=|in\b)", o.strip(), re.I)
cols.add(m.group(1).lower() if m else None)
if len(cols) == 1 and None not in cols:
t, c = resolve(cols.pop(), aliases, tables)
q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "OR of equalities (same as IN)"})
else:
shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", p)
shown = shown if len(shown) <= 70 else shown[:67] + "..."
q["warnings"].append(f"OR across different columns ({shown}): one composite index cannot serve "
"both sides; consider UNION ALL of two indexed queries or one index per side")
return
if re.match(r"^not\b", p, re.I) or re.search(r"(<>|!=|\bnot\s+in\b|\bnot\s+like\b)", p, re.I):
m = re.match(rf"^(?:not\s+)?{COL}", p, re.I)
shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", p)[:50]
q["notes"].append(f"negative predicate '{shown}' rarely uses an index; it is applied as a filter")
return
m = re.match(rf"^{COL}\s*(=|<=>)\s*{COL}$", p, re.I)
if m and not re.fullmatch(VALUE, m.group(3), re.I) and m.group(3).lower() not in KEYWORDS:
left, right = resolve(m.group(1), aliases, tables), resolve(m.group(3), aliases, tables)
if left[0] and right[0] and left[0] != right[0]:
q["joins"].append((left, right))
return
m = re.match(rf"^{VALUE}\s*(=|<|>|<=|>=)\s*{COL}$", p, re.I)
if m:
flip = {"<": ">", ">": "<", "<=": ">=", ">=": "<=", "=": "="}
p = f"{m.group(2)} {flip[m.group(1)]} __v__"
m = re.match(rf"^{COL}\s*(=|<=>)\s*(.+)$", p, re.I)
if m and m.group(1).lower() not in KEYWORDS:
t, c = resolve(m.group(1), aliases, tables)
q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "="})
return
m = re.match(rf"^{COL}\s+in\s+__subq__$", p, re.I)
if m:
t, c = resolve(m.group(1), aliases, tables)
q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IN (subquery)"})
return
m = re.match(rf"^{COL}\s+in\s*\((.*)\)$", p, re.I | re.S)
if m:
t, c = resolve(m.group(1), aliases, tables)
q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IN"})
return
m = re.match(rf"^{COL}\s+is\s+(not\s+)?null$", p, re.I)
if m:
t, c = resolve(m.group(1), aliases, tables)
if m.group(2):
q["notes"].append(f"'{c} IS NOT NULL' is usually not selective; a partial index can help if most rows are NULL")
else:
q["filters"].append({"table": t, "column": c, "kind": "eq", "op": "IS NULL"})
return
m = re.match(rf"^{COL}\s*(<|>|<=|>=)\s*(.+)$", p, re.I) or re.match(rf"^{COL}\s+between\s+(.+)\s+and\s+(.+)$", p, re.I)
if m and m.group(1).lower() not in KEYWORDS:
t, c = resolve(m.group(1), aliases, tables)
q["filters"].append({"table": t, "column": c, "kind": "range", "op": "range"})
return
m = re.match(rf"^{COL}\s+(i?like)\s+__lit(\d+)__", p, re.I)
if m:
t, c = resolve(m.group(1), aliases, tables)
pattern = lits[int(m.group(3))]
if pattern.startswith(("%", "_")):
q["warnings"].append(f"non-sargable: {c} {m.group(2).upper()} '{pattern}' starts with a wildcard, so a "
"B-tree index cannot seek; use full-text search or a trigram index (PostgreSQL pg_trgm)")
q["non_sargable"] = True
else:
q["filters"].append({"table": t, "column": c, "kind": "range", "op": f"LIKE '{pattern}'"})
if m.group(2).lower() == "ilike":
q["notes"].append(f"ILIKE on {c}: a plain B-tree index is case-sensitive; index lower({c}) instead")
return
m = re.match(rf"^([a-z_]+)\s*\(\s*{COL}[^)]*\)\s*(=|<|>|<=|>=|like|between)", p, re.I)
if m and m.group(1).lower() not in KEYWORDS:
func, col = m.group(1).lower(), m.group(2)
t, c = resolve(col, aliases, tables)
wild = re.search(r"like\s+__lit(\d+)__", p, re.I)
extra = ""
if wild and lits[int(wild.group(1))].startswith(("%", "_")):
extra = (f"; the LIKE pattern '{lits[int(wild.group(1))]}' also starts with a wildcard, so only full-text "
"search or a trigram index can help")
q["warnings"].append(f"non-sargable: {func}({c}) in the WHERE clause hides the column from a normal index; "
f"rewrite as a range on {c} or add an expression index on {func}({c}){extra}")
q["non_sargable"] = True
return
m = re.match(rf"^{COL}\s*[-+*/]\s*\S+\s*(=|<|>|<=|>=)", p, re.I)
if m:
t, c = resolve(m.group(1), aliases, tables)
q["warnings"].append(f"non-sargable: arithmetic on {c} ('{p[:40]}'); move the arithmetic to the other side")
q["non_sargable"] = True
return
if "__subq__" in p and re.search(r"\bexists\b", p, re.I):
return
q["notes"].append(f"predicate not understood, check by hand: {p[:60]}")
def _balanced_wrap(p):
depth = 0
for i, ch in enumerate(p):
depth += ch == "("
depth -= ch == ")"
if depth == 0 and i < len(p) - 1:
return False
return True
def parse_query(raw, tables, number, lits=None, outer_aliases=None):
if lits is None:
sql, lits = mask_strings(unquote_idents(strip_comments(raw)))
else:
sql = raw
sql = re.sub(r"\s+", " ", sql).strip()
outer, subs = extract_subqueries(sql)
shown = re.sub(r"__lit(\d+)__", lambda m: "'" + lits[int(m.group(1))] + "'", re.sub(r"\s+", " ", raw.strip()))
shown = re.sub(r"\s+", " ", shown)
q = {"n": number, "text": shown[:110] + ("..." if len(shown) > 110 else ""), "kind": None, "aliases": {},
"filters": [], "joins": [], "order_by": [], "group_by": [], "selected": {}, "select_star": False,
"limit": False, "warnings": [], "notes": [], "non_sargable": False, "subqueries": subs, "lits": lits}
low = outer.lower()
cte_names = set()
if low.startswith("with "):
cte_names = {n.lower() for n in re.findall(r"(?:with|,)\s*(?:recursive\s+)?(\w+)\s+as\b", outer, re.I)}
q["notes"].append(f"CTE {', '.join(sorted(cte_names))} analyzed as separate queries; the final SELECT reads from it")
m = re.search(r"(?:\)|__subq__)\s*((?:select|update|delete)\b.*)$", outer, re.I)
outer = m.group(1) if m else outer
low = outer.lower()
if low.startswith("select"):
q["kind"] = "SELECT"
m = re.match(r"select\s+(?:distinct\s+)?(.*?)\s+from\s+(.*?)" + CLAUSE_END, outer, re.I)
if not m:
q["notes"].append("no FROM clause found")
return q
select_list, from_text = m.group(1), m.group(2)
elif low.startswith("update"):
q["kind"] = "UPDATE"
m = re.match(r"update\s+(.*?)\s+set\s+", outer, re.I)
select_list, from_text = "", m.group(1) if m else ""
elif low.startswith("delete"):
q["kind"] = "DELETE"
m = re.match(r"delete\s+from\s+(.*?)" + CLAUSE_END, outer, re.I)
select_list, from_text = "", m.group(1) if m else ""
else:
return None
own, _order, ons = parse_from(from_text)
own = {a: t for a, t in own.items() if t not in cte_names}
q["aliases"] = own
aliases = {**(outer_aliases or {}), **own} # outer aliases resolve correlated references
unknown = [t for t in set(own.values()) if tables and t not in tables]
if unknown:
q["notes"].append(f"table(s) not in the schema: {', '.join(sorted(unknown))}; existing indexes unknown")
for on in ons:
for pred in split_and(on):
analyze_predicate(pred, lits, aliases, tables, q)
w = re.search(r"\bwhere\b(.*?)(?=\b(?:group\s+by|order\s+by|having|limit|offset|returning|for\s+update)\b|$)", outer, re.I)
if w:
for pred in split_and(w.group(1)):
analyze_predicate(pred, lits, aliases, tables, q)
for clause, key in (("group\\s+by", "group_by"), ("order\\s+by", "order_by")):
m = re.search(rf"\b{clause}\b(.*?)(?=\b(?:having|order\s+by|limit|offset|for\s+update)\b|$)", outer, re.I)
if m:
for item in split_top(m.group(1), r","):
mm = re.match(rf"^{COL}\s*(asc|desc)?(?:\s+nulls\s+(?:first|last))?$", item.strip(), re.I)
if mm:
t, c = resolve(mm.group(1), aliases, tables)
q[key].append({"table": t, "column": c, "dir": (mm.group(2) or "asc").lower()})
else:
q["notes"].append(f"{key.replace('_', ' ')} expression '{item.strip()[:40]}' cannot use a plain column index")
q["limit"] = bool(re.search(r"\b(limit|fetch\s+first)\b", outer, re.I))
if select_list:
if re.search(r"(^|,)\s*(\w+\.)?\*\s*(,|$)", select_list):
q["select_star"] = True
for ref in re.findall(rf"\b{COL}\b(?!\s*\()", select_list, re.I):
if ref.lower() in KEYWORDS or ref.startswith("__") or re.fullmatch(r"\d+", ref):
continue
t, c = resolve(ref, aliases, tables)
if t and (not tables or c in tables.get(t, {}).get("columns", [c])):
q["selected"].setdefault(t, [])
if c not in q["selected"][t]:
q["selected"][t].append(c)
for f in q["filters"]:
if f["table"] is None:
q["notes"].append(f"column '{f['column']}' could belong to several tables; qualify it with an alias")
return q
# ---------------------------------------------------------------- candidates
def candidates_for(q, dialect, covering, pk_cols=None):
out = []
by_table = {}
for f in q["filters"]:
if f["table"]:
by_table.setdefault(f["table"], {"eq": [], "range": []})
lst = by_table[f["table"]][f["kind"]]
if f["column"] not in lst:
lst.append(f["column"])
tables_in_q = set(q["aliases"].values())
correlated = set()
for left, right in q["joins"]:
if left[0] in tables_in_q and right[0] not in tables_in_q:
correlated.add(left[0])
if right[0] in tables_in_q and left[0] not in tables_in_q:
correlated.add(right[0])
order_tables = {o["table"] for o in q["order_by"]}
for t in tables_in_q:
if t in correlated:
continue # reached through the correlated join below
eq = by_table.get(t, {}).get("eq", [])
rng = [c for c in by_table.get(t, {}).get("range", []) if c not in eq]
sort = []
if q["order_by"] and order_tables == {t} and len({o["dir"] for o in q["order_by"]}) == 1:
sort = [o["column"] for o in q["order_by"] if o["column"] not in eq]
elif q["group_by"] and {g["table"] for g in q["group_by"]} == {t}:
sort = [g["column"] for g in q["group_by"] if g["column"] not in eq]
key, why = list(eq), []
if eq:
why.append(f"equality on {', '.join(eq)} first")
if sort and (not rng or q["limit"]):
key += [c for c in sort if c not in key]
purpose = "already sorted" if q["order_by"] else "already grouped"
why.append(f"then {', '.join(sort)} so rows come out {purpose}" + (" (stops early with LIMIT)" if q["limit"] else ""))
if rng and rng[0] not in key:
key.append(rng[0])
why.append(f"{rng[0]} last as an in-index filter")
elif rng:
key.append(rng[0])
why.append(f"then the range on {rng[0]}" if eq else f"range on {rng[0]}")
if len(rng) > 1:
q["notes"].append(f"several range filters on {t} ({', '.join(rng)}): only the first can seek; the rest filter")
if sort:
q["notes"].append(f"ORDER BY on {t} cannot use the same index as the range on {rng[0]}; expect a sort step")
if not key:
continue
include = []
if covering and not q["select_star"] and q["kind"] == "SELECT":
extra = [c for c in q["selected"].get(t, []) if c not in key
and not (dialect == "mysql" and c in (pk_cols or {}).get(t, []))]
if 0 < len(extra) <= 3:
include = extra
out.append({"table": t, "columns": key, "include": include, "why": "; ".join(why), "queries": [q["n"]],
"source": "filter", "eq": list(eq),
"null_filter": [f["column"] for f in q["filters"] if f["table"] == t and f["op"] == "IS NULL"]})
for left, right in q["joins"]:
for (t, c), (ot, oc) in ((left, right), (right, left)):
if t not in tables_in_q:
continue # outer table of a correlated subquery
eq = [e for e in by_table.get(t, {}).get("eq", []) if e != c]
rng = [r for r in by_table.get(t, {}).get("range", []) if r != c and r not in eq][:1]
why = f"join column {t}.{c} = {ot}.{oc} first (lets the planner look up matching rows)"
if eq or rng:
why += f", then {', '.join(eq + rng)} to filter inside the index"
if t in correlated:
why = f"correlated lookup {t}.{c} = outer {ot}.{oc} first" + (f", then {', '.join(eq + rng)}" if eq or rng else "")
out.append({"table": t, "columns": [c] + eq + rng, "include": [], "queries": [q["n"]], "source": "join",
"eq": [c] + eq, "why": why})
return out
def served_by(cand, indexes):
for ix in indexes:
if ix["table"] != cand["table"]:
continue
cols = ix["columns"]
if cand["source"] == "join" and ix["unique"] and cols == cand["columns"][:1] and not ix.get("partial"):
return ix # joining on a unique key: one row per lookup, extra columns add little
if cols[:len(cand["columns"])] == cand["columns"]:
return ix
n_eq = len(cand.get("eq", []))
if n_eq > 1 and len(cols) >= len(cand["columns"]) and set(cols[:n_eq]) == set(cand["columns"][:n_eq]) \
and cols[n_eq:len(cand["columns"])] == cand["columns"][n_eq:]:
return ix # same equality columns in another order serve the query equally well
return None
def served_by_named(c, indexes):
ix = served_by(c, indexes)
return {"existing": ix["name"] if ix else "?"}
def merge(cands):
merged = []
for c in sorted(cands, key=lambda c: -len(c["columns"])):
for m in merged:
if m["table"] == c["table"] and m["columns"][:len(c["columns"])] == c["columns"]:
m["queries"] = sorted(set(m["queries"]) | set(c["queries"]))
m["include"] = m["include"] or []
break
else:
merged.append(dict(c))
return sorted(merged, key=lambda c: (c["table"], c["columns"]))
def schema_findings(indexes, max_indexes, new_by_table):
out = []
by_table = {}
for ix in indexes:
by_table.setdefault(ix["table"], []).append(ix)
for t, ixs in by_table.items():
for i, a in enumerate(ixs):
for b in ixs[i + 1:]:
if a["columns"] == b["columns"] and (a.get("partial") == b.get("partial")):
out.append({"level": "high", "rule": "duplicate-index",
"message": f"{t}: {a['name']} and {b['name']} have the same columns ({', '.join(a['columns'])}); drop one"})
elif a["columns"] != b["columns"]:
short, long_ = (a, b) if len(a["columns"]) < len(b["columns"]) else (b, a)
if long_["columns"][:len(short["columns"])] == short["columns"] and not short["unique"] \
and not short.get("partial"):
out.append({"level": "warn", "rule": "redundant-prefix",
"message": f"{t}: {short['name']} ({', '.join(short['columns'])}) is a leftmost prefix of "
f"{long_['name']} ({', '.join(long_['columns'])}); usually droppable"})
for ix in ixs:
if len(ix["columns"]) == 1 and LOW_CARD.search(ix["columns"][0]) and ix["kind"] == "index" and not ix.get("partial"):
out.append({"level": "warn", "rule": "low-selectivity",
"message": f"{t}: {ix['name']} indexes only '{ix['columns'][0]}', which sounds like a flag or status "
"with few values; use a composite or partial index instead"})
total = len(ixs) + new_by_table.get(t, 0)
if total > max_indexes:
out.append({"level": "warn", "rule": "many-indexes",
"message": f"{t}: {len(ixs)} existing + {new_by_table.get(t, 0)} suggested = {total} indexes "
f"(limit {max_indexes}); every insert and update maintains all of them"})
return out
def ddl(c, dialect):
name = f"idx_{c['table']}_{'_'.join(c['columns'])}"[:63]
cols = ", ".join(c["columns"])
if c["include"]:
if dialect == "postgres":
return f"CREATE INDEX CONCURRENTLY {name} ON {c['table']} ({cols}) INCLUDE ({', '.join(c['include'])});"
cols += ", " + ", ".join(c["include"])
if dialect == "postgres":
return f"CREATE INDEX CONCURRENTLY {name} ON {c['table']} ({cols});"
if dialect == "mysql":
return f"ALTER TABLE {c['table']} ADD INDEX {name} ({cols}), ALGORITHM=INPLACE, LOCK=NONE;"
return f"CREATE INDEX {name} ON {c['table']} ({cols});"
# ---------------------------------------------------------------- main
def parse_args(argv):
opts = {"schema": None, "files": [], "dialect": "postgres", "max_indexes": 6, "covering": True,
"json": False, "strict": False}
i = 0
while i < len(argv):
a = argv[i]
if a in ("-h", "--help"):
print(__doc__)
sys.exit(0)
if a in ("--schema", "--dialect", "--max-indexes"):
if i + 1 >= len(argv):
raise UsageError(f"{a} needs a value")
v = argv[i + 1]
if a == "--schema":
opts["schema"] = v
elif a == "--dialect":
if v not in ("postgres", "mysql", "sqlite"):
raise UsageError("--dialect must be postgres, mysql, or sqlite")
opts["dialect"] = v
else:
try:
opts["max_indexes"] = int(v)
except ValueError:
raise UsageError(f"--max-indexes needs a whole number, got {v!r}")
i += 2
elif a == "--no-covering":
opts["covering"] = False; i += 1
elif a == "--json":
opts["json"] = True; i += 1
elif a == "--strict":
opts["strict"] = True; i += 1
elif a.startswith("--"):
raise UsageError(f"unknown option {a}")
else:
opts["files"].append(a); i += 1
if not opts["files"] and not opts["schema"]:
raise UsageError("give a query file (or - for stdin), optionally with --schema FILE")
return opts
def read(path):
if path == "-":
return sys.stdin.read()
with open(path, encoding="utf-8") as f:
return f.read()
def main(argv):
try:
opts = parse_args(argv)
schema_text = read(opts["schema"]) if opts["schema"] else ""
query_texts = [read(p) for p in opts["files"]]
except UsageError as e:
print(f"error: {e}", file=sys.stderr)
return 2
except OSError as e:
print(f"error: cannot read {e.filename}: {e.strerror}", file=sys.stderr)
return 2
statements = split_statements(strip_comments(schema_text))
query_stmts = []
for text in query_texts:
clean = strip_comments(text)
for st in split_statements(clean):
(statements if re.match(r"\s*create\s", st, re.I) else query_stmts).append(st)
tables, indexes = parse_schema(statements)
queries, n = [], 0
expanded = []
for st in query_stmts:
masked, _l = mask_strings(st)
if len(split_top(masked, r"\bunion(?:\s+all)?\b")) > 1: # a real top-level UNION, not one inside a string
expanded.extend(split_top(st, r"\bunion(?:\s+all)?\b")) # each branch is analyzed as its own query
else:
expanded.append(st)
for st in expanded:
pending = [(st, None, None)]
while pending:
cur, lits, outer = pending.pop(0)
n += 1
q = parse_query(cur, tables, n, lits, outer)
if q is None:
n -= 1
continue
queries.append(q)
for sub in q["subqueries"]:
pending.append((sub, q["lits"], {**(outer or {}), **q["aliases"]}))
q["notes"].append(f"subquery analyzed as Q{n + len(pending)}")
if not queries:
print("error: no SELECT, UPDATE, or DELETE statements found", file=sys.stderr)
return 2
pks = {ix["table"]: ix["columns"] for ix in indexes if ix["kind"] == "primary"}
cands = []
for q in queries:
cands += candidates_for(q, opts["dialect"], opts["covering"], pks)
covered, open_cands = [], []
for c in cands:
ix = served_by(c, indexes)
if ix:
covered.append({**c, "existing": ix["name"]})
else:
open_cands.append(c)
suggested = merge(open_cands)
covered = merge(covered)
for c in covered:
c["existing"] = next(x["existing"] for x in [served_by_named(c, indexes)])
new_by_table = {}
for c in suggested:
new_by_table[c["table"]] = new_by_table.get(c["table"], 0) + 1
c["ddl"] = ddl(c, opts["dialect"])
lead = c["columns"][0]
c["tradeoff"] = ("every INSERT, UPDATE of these columns, and DELETE on " + c["table"] +
" also writes this index; size grows with the row count and column widths")
if LOW_CARD.search(lead) and len(c["columns"]) == 1:
c["tradeoff"] += f"; '{lead}' looks low-selectivity, so the planner may still prefer a scan"
for a in suggested:
others = [k for k, b in enumerate(suggested, 1) if b is not a and b["table"] == a["table"]
and b["columns"][0] == a["columns"][0]]
a["overlaps"] = others
a["makes_redundant"] = [ix["name"] for ix in indexes if ix["table"] == a["table"] and not ix["unique"]
and ix["kind"] != "primary" and len(ix["columns"]) < len(a["columns"])
and a["columns"][:len(ix["columns"])] == ix["columns"]]
if a.get("null_filter") and opts["dialect"] == "postgres":
col = a["null_filter"][0]
rest = [c for c in a["columns"] if c != col]
name = f"idx_{a['table']}_{'_'.join(rest)}_{col}_null"[:63]
a["partial_hint"] = (f"CREATE INDEX CONCURRENTLY {name} ON {a['table']} ({', '.join(rest)}) "
f"WHERE {col} IS NULL; -- smaller if few rows match")
findings = schema_findings(indexes, opts["max_indexes"], new_by_table)
report = {"dialect": opts["dialect"], "tables": {t: len(v["columns"]) for t, v in tables.items()},
"existing_indexes": indexes, "queries": [{k: v for k, v in q.items() if k not in ("subqueries", "lits")}
for q in queries],
"suggested": suggested, "already_covered": covered, "schema_findings": findings}
for q in report["queries"]:
q["joins"] = [f"{a[0]}.{a[1]} = {b[0]}.{b[1]}" for a, b in q["joins"]]
strict_fail = opts["strict"] and (any(q["non_sargable"] for q in queries) or
any(f["rule"] == "duplicate-index" for f in findings))
if opts["json"]:
print(json.dumps(report, indent=2))
return 1 if strict_fail else 0
print(f"Schema: {len(tables)} table(s), {len(indexes)} existing index(es) incl. keys | dialect: {opts['dialect']}")
print(f"Queries analyzed: {len(queries)}")
for q in report["queries"]:
print(f"\nQ{q['n']} {q['kind']}: {q['text']}")
tabs = ", ".join(f"{t} {a}" if a != t else t for a, t in q["aliases"].items())
print(f" tables: {tabs or '-'}")
eqs = [f"{f['table'] or '?'}.{f['column']} ({f['op']})" for f in q["filters"] if f["kind"] == "eq"]
rngs = [f"{f['table'] or '?'}.{f['column']} ({f['op']})" for f in q["filters"] if f["kind"] == "range"]
if eqs:
print(f" equality: {', '.join(eqs)}")
if rngs:
print(f" range: {', '.join(rngs)}")
if q["joins"]:
print(f" joins: {', '.join(q['joins'])}")
if q["order_by"]:
print(" order by: " + ", ".join(f"{o['table']}.{o['column']} {o['dir'].upper()}" for o in q["order_by"])
+ (" (LIMIT)" if q["limit"] else ""))
if q["group_by"]:
print(" group by: " + ", ".join(f"{g['table']}.{g['column']}" for g in q["group_by"]))
for w in q["warnings"]:
print(f" WARN {w}")
for nt in q["notes"]:
print(f" note: {nt}")
print(f"\nSuggested indexes ({len(suggested)}):")
if not suggested:
print(" none: existing indexes already lead with the needed columns")
for k, c in enumerate(suggested, 1):
print(f" {k}. {c['ddl']}")
print(f" serves: {', '.join('Q' + str(x) for x in c['queries'])} | why: {c['why']}")
if c["include"]:
print(f" covering: {', '.join(c['include'])} added so the query can skip the table (optional)")
print(f" tradeoff: {c['tradeoff']}")
if c.get("overlaps"):
print(f" overlap: same leading column as #{', #'.join(map(str, c['overlaps']))}; "
"check whether one index can serve both before creating two")
if c.get("makes_redundant"):
print(f" makes redundant: {', '.join(c['makes_redundant'])} (leftmost prefix of this index; drop after checking usage)")
if c.get("partial_hint"):
print(f" alternative: {c['partial_hint']}")
if covered:
print(f"\nAlready served by existing indexes ({len(covered)}):")
for c in covered:
print(f" {c['table']} ({', '.join(c['columns'])}) -> {c['existing']} (Q{', Q'.join(map(str, c['queries']))})")
print(f"\nSchema findings ({len(findings)}):")
if not findings:
print(" none")
for f in findings:
print(f" {f['level'].upper():<4} {f['rule']:<17} {f['message']}")
print("\nNext: run EXPLAIN (ANALYZE, BUFFERS) or EXPLAIN on real data before and after; build one index at a time.")
return 1 if strict_fail else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Mô tả khu vực, mùa, trình độ kỹ năng, phương tiện di chuyển và các loài chim hoặc môi trường sống mục tiêu của bạn, để nhận lại kế hoạch thực địa cuối tuần thực tế: danh sách rút gọn các địa điểm kèm ghi chú lối vào, lịch trình từ bình minh đến hoàng hôn, danh sách kiểm tra hành lý và đạo đức quan sát, mẹo nhận dạng các loài dễ bắt gặp, phương án dự phòng thời tiết và mẫu phiếu ghi chép đơn giản.
Hãy đóng vai một người lập kế hoạch chuyến đi thực địa ngắm chim (birdwatching) vào dịp cuối tuần. Bạn đã từng hướng dẫn những người mới bắt đầu và người ngắm chim trình độ trung cấp trong các chuyến đi trong ngày cũng như nghỉ qua đêm cuối tuần tại các vùng đất ngập nước, rừng rậm, bờ biển và công viên đô thị. Bạn lập kế hoạch bám sát thời điểm chim hót buổi bình minh (dawn chorus) thực tế, bảng thủy triều khi có liên quan, quy định tiếp cận nơi công cộng và khoảng cách quan sát có đạo đức. Bạn luôn khích lệ, chính xác và đề cao sự an toàn: bạn không bao giờ tự bịa ra các loài chim quý hiếm không hợp lý với địa điểm và mùa đó, đồng thời bạn luôn đặt môi trường sống và sự an toàn của loài chim lên trên cuộc đua săn lùng "danh sách loài từng thấy trong đời" (life list). Thông tin về chuyến đi của tôi: - Khu vực / quốc gia và thị trấn gần nhất: southern England, near Salisbury - Mùa và ngày đi: mid-April weekend, Saturday and Sunday - Mức độ kinh nghiệm: beginner-intermediate; I know common garden birds, struggle with warblers and waders - Các loài chim hoặc môi trường sống mục tiêu (tùy chọn): reedbeds, farmland edges, and any spring migrants; would love to hear a nightingale - Phương tiện di chuyển: car shared with one friend; can walk 8 to 10 km a day on easy paths - Điểm xuất phát / nơi nghỉ qua đêm: camping near the first reserve, or a B&B if rain is heavy - Mức ngân sách dự kiến: modest; free reserves preferred, happy to pay one paid reserve entry - Các ràng buộc/hạn chế: no early Friday departure; must be back by Sunday 18:00; friend has a mild knee issue so avoid steep climbs - Thiết bị quang học và dụng cụ tôi đã có: 8x42 binoculars, phone with Merlin Bird ID, light rain jacket, no spotting scope - Dự báo thời tiết tôi nắm được tính đến hiện tại: cool, partly cloudy, light NW wind, 40% chance of showers Sunday afternoon Vui lòng thực hiện các yêu cầu sau: 1. Bắt đầu bằng phần đánh giá tổng quan ngắn gọn về chuyến đi: 3 câu nêu rõ dịp cuối tuần này thuận lợi cho hoạt động gì tại khu vực và mùa của tôi, 2 hạn chế lớn nhất mà bạn nhận thấy, cùng bất kỳ giả định nào bạn đang đặt ra về lối tiếp cận hoặc thủy triều. 2. Lập danh sách rút gọn từ 3 đến 5 địa điểm (khu bảo tồn, công viên, dải bờ biển, hoặc các điểm quan sát vùng đất nông nghiệp) bao gồm: loại môi trường sống, các loài chim đặc trưng theo mùa mà tôi thực sự có thể kỳ vọng bắt gặp, ghi chú về việc tiếp cận (bãi đỗ xe, nhà vệ sinh, độ khó của đường mòn, phí vào cửa), thời điểm lý tưởng nhất trong ngày, và lý do địa điểm đó phù hợp với trình độ của tôi. 3. Xây dựng lịch trình từ bình minh đến hoàng hôn cho Thứ Bảy và Chủ Nhật kèm theo thời gian di chuyển giữa các địa điểm, khoảng thời gian dự phòng cho thời tiết, và phương án dự phòng rõ ràng theo kiểu "nếu X không thuận lợi, hãy chuyển sang Y" cho mỗi nửa ngày. 4. Đưa ra danh sách kiểm tra đồ cần mang theo (packing checklist): các lớp trang phục, dụng cụ bảo quản thiết bị quang học, chuẩn bị sổ tay hoặc ứng dụng, đồ ăn và nước uống, cùng những vật dụng tôi không nên mang theo (flycam/drone, loa phát tiếng chim mồi, trang phục màu sắc sặc sỡ khi ở gần chòi quan sát nếu có liên quan). 5. Hướng dẫn kỹ năng thực địa và đạo đức ngắm chim: khoảng cách tiếp cận, quy tắc ứng xử trong chòi quan sát (hide etiquette), khi nào không được dùng loa phát tiếng chim gọi bầy, quy định về chó cưng/rác thải, và cách chia sẻ không gian trong chòi quan sát mà không làm hỏng tầm nhìn của người khác. 6. Liệt kê từ 8 đến 12 loài chim có khả năng cao sẽ xuất hiện tại các môi trường sống dự kiến trong mùa này, mỗi loài kèm theo một mẹo nhận dạng ngoài thực địa (tiếng hót, bóng dáng, tập tính, hoặc dấu hiệu môi trường sống) phù hợp với trình độ của tôi; đánh dấu rõ những loài nào cần ống ngắm đơn (scope) so với ống nhòm hai mắt (binoculars). 7. Bổ sung các phương án dự phòng thời tiết: gió lớn, mưa to, hiện tượng lóa nhiệt (heat shimmer), và kế hoạch xử lý khi "mọi thứ đều im ắng" (các điểm ngồi chờ tĩnh lặng, công viên dự phòng trong đô thị). 8. Cung cấp một mẫu phiếu ghi chép (log sheet) đơn giản mà tôi có thể sao chép: ngày, địa điểm, thời gian bắt đầu/kết thúc, thời tiết, loài chim, số lượng hoặc mật độ, ghi chú, và cột mức độ tự tin (chắc chắn / có khả năng / chỉ nghe thấy tiếng). 9. Kết thúc bằng bản tóm tắt dài một trang: lộ trình đã chọn, 5 loài chim đáng lắng nghe nhất, 3 món đồ bắt buộc phải mang theo, và nguyên tắc ứng xử quan trọng nhất cho cả dịp cuối tuần. Quy tắc: - Ưu tiên các địa điểm mở cửa cho công chúng; nếu địa điểm nào cần giấy phép hoặc thẻ thành viên, hãy nêu rõ và đề xuất một phương án thay thế. - Không tự bịa ra các cuộc săn lùng loài quý hiếm hoặc lối đi vào đất tư nhân; hãy gắn nhãn những lối vào chưa chắc chắn là thông tin cần kiểm tra lại. - Giữ cho lời khuyên mang tính thực tế đối với việc ngắm chim chỉ bằng ống nhòm hai mắt (binoculars), trừ khi tôi có nói rõ là mình mang theo ống ngắm chuyên dụng (scope). - Không bao giờ khuyến khích việc xua đuổi, làm xáo trộn tổ chim, rải mồi nhử, hoặc ghi âm trái phép các loài được bảo vệ. - Nếu thiếu thông tin thiết yếu nào đó (giờ thủy triều, giờ mở cửa khu bảo tồn), hãy đưa ra giả định hợp lý và nêu rõ điều đó thay vì hỏi lại quá nhiều câu hỏi.
Mô tả chủ đề bài nói chuyện, khán giả, thời lượng và những điểm đọng lại chính, để nhận lại dữ liệu JSON có cấu trúc gồm mạch kể chuyện, đề cương slide bấm giờ, gợi ý ghi chú cho diễn giả, các nhịp demo hoặc kể chuyện, điểm tương tác với khán giả, slide dự phòng và danh sách kiểm tra tập dượt.
1{2 "role": "Bạn là một huấn luyện viên diễn thuyết hội nghị đã giúp các kỹ sư, nhà nghiên cứu và người làm nghề thực tế biến những kiến thức chuyên môn phức tạp thành các bài nói chuyện rõ ràng kéo dài từ 20 đến 45 phút. Bạn quan tâm đến những giá trị đọng lại cho khán giả, việc phân bổ thời gian trung thực, và những slide đóng vai trò hỗ trợ cho lời nói chứ không thay thế người nói. Bạn không bao giờ tự bịa ra các nghiên cứu tình huống (case studies) giả, số liệu ảo, hoặc các tuyên bố về công ty/tổ chức mà diễn giả không cung cấp.",3 "task": "Xây dựng một đề cương slide hoàn chỉnh, có phân bổ thời gian cho bài diễn thuyết hội nghị được mô tả bên dưới: mạch kể chuyện (narrative arc), kế hoạch từng slide kèm gợi ý ghi chú cho diễn giả, các nhịp demo hoặc kể chuyện, các điểm tương tác với khán giả, các slide dự phòng, và danh sách kiểm tra tập dượt, chỉ trả về dưới định dạng JSON.",4 "inputs": {5 "talk_title_working": "${title:Shipping Feature Flags Without Melting Prod}",6 "topic_and_angle": "${topic:practical lessons from rolling out flags at a mid-size SaaS; focus on kill switches, ownership, and what broke}",7 "audience": "${audience:mid-level backend and platform engineers; mixed experience with LaunchDarkly-style tools}",8 "length_minutes": "${length:30}",9 "format": "${format:single speaker, on-stage with slides, optional live CLI demo}",10 "must_cover": "${must:why flags fail in prod, a simple ownership model, one war story, three rules to take home}",...+95 dòng nữa
Mô tả mục tiêu ngôn ngữ đích của bạn, trình độ của bạn đồng hành, khung thời gian và các chủ đề cần tránh, để nhận lại kế hoạch buổi học định dạng YAML gồm phần khởi động, các khung giờ hội thoại bấm giờ, cách thức sửa lỗi, thu hoạch từ vựng, bài tập nhỏ về nhà và kịch bản phản hồi nhẹ nhàng, tất cả được trình bày bằng ghi chú lập kế hoạch rõ ràng để phục vụ cho việc thực hành bằng ngôn ngữ đích.
1role: >2 Bạn là một huấn luyện viên điều phối buổi trao đổi ngôn ngữ (language-exchange session coach). Bạn giúp hai người không phải là giáo viên chuyên nghiệp tổ chức một buổi thực hành hội thoại công bằng và hiệu quả.3 Bạn lập kế hoạch bằng ngôn ngữ rõ ràng để cả hai người bạn đồng hành đều nắm được cấu trúc buổi học, trong khi phần thực hành nói sẽ diễn ra bằng (các) ngôn ngữ đích.4 Bạn giữ cho các buổi thực hành luôn thân thiện, đúng khung thời gian (timeboxed) và cân bằng để không ai độc chiếm toàn bộ thời lượng.5task: >6 Xây dựng một kế hoạch buổi trao đổi ngôn ngữ hoàn chỉnh dưới định dạng YAML cho các thông tin đầu vào bên dưới: phần khởi động, các khung hội thoại bấm giờ, thỏa thuận cách sửa lỗi,7 thu hoạch từ vựng, bài tập nhỏ về nhà (micro-homework), và một kịch bản phản hồi ngắn gọn.8inputs:9 my_native_language: "${native:English}"10 target_language: "${target:Spanish}"...+42 dòng nữa

Phong cách bản in risograph hai màu khắc họa toa tàu điện ngầm chật kín người vào ban đêm: mực in màu xanh mòng két huỳnh quang và đỏ ấm trên giấy màu kem, hành khách được thể hiện bằng các hình khối giản lược, hiện tượng lệch màu chồng lớp, hạt tram (halftone dots) nhìn thấy rõ, ánh phản chiếu trên tay vịn và tâm trạng mệt mỏi tĩnh lặng, được đóng khung như một bản in hoàn chỉnh đặt trên bàn.
Một bản in risograph hoàn chỉnh khắc họa cảnh toa tàu điện ngầm đông đúc vào ban đêm, chỉ sử dụng hai màu mực xanh mòng két (teal) và đỏ gạch ấm áp trên nền giấy màu kem dịu, khung hình ngang 16:9, bản in chiếm phần lớn khung hình như thể được chụp phẳng từ trên xuống trên mặt bàn màu nhạt. Các chủ thể chính xuất hiện trước tiên: những hành khách đang đứng chen chúc dọc lối đi giữa toa, tay vịn màu bạc, các dải đèn huỳnh quang phía trên đầu, và những dáng người đang ngồi dọc hai dãy ghế, tất cả đều được tạo nên từ các mảng màu phẳng giới hạn với đường viền đậm nét. Toa tàu được cấu thành từ các hình khối đồ họa giản lược: cửa ra vào phía xa có ô cửa sổ tròn, các cột dọc, tay nắm treo lơ lửng, và ánh sáng từ một chiếc điện thoại đang mở hiện lên như một hình chữ nhật nhỏ màu đỏ. Con người là những bóng đen vô danh và các mảng hạt tram (half-tones): áo khoác, ba lô, một mái đầu đang ngủ gục vào cửa kính, một đứa trẻ đang nắm chặt cột tàu. Dấu vết đặc trưng của kỹ thuật in: sự lệch màu (misregistration) nhẹ giữa sắc xanh mòng két và đỏ trên các cột dọc, các hạt tram thô trong vùng tối, độ nhám tự nhiên của bề mặt giấy (paper tooth), và vết mực in lượt thứ hai mờ nhạt trên sàn tàu. Khung hình chụp trên mặt bàn dịu nhẹ với mép tờ giấy màu kem lộ rõ, ánh sáng lan tỏa đều và êm dịu. Cảm xúc tĩnh lặng và mệt mỏi của chuyến tàu muộn, mang năng lượng của một tấm áp phích đồ họa. Không phải ảnh chụp chân thực (photoreal), không phải 3D, không phải tranh màu nước. Không có chữ viết đọc được, không có logo, không có hình mờ (watermark), không có bàn tay người.

Bức ảnh chụp studio chân thực (photoreal) một chiếc đĩa pháp lam (cloisonné enamel) hình tròn: hai chú hạc trắng đen đang bay qua mặt hồ vùng núi xanh thẫm, các ô dây vàng mảnh mai được lấp đầy bằng lớp men màu bão hòa, viền đĩa trang trí họa tiết hoa, bề mặt nung bóng loáng, đặt trên nền vải lanh tối màu với ánh sáng tạt nhẹ từ bên cạnh.
Một bức ảnh chụp cận cảnh trong studio ghi lại hình ảnh chiếc đĩa pháp lam (cloisonné enamel) hình tròn đặt chính giữa khung hình 16:9 trên nền vải lanh màu xám than tối. Chủ thể chính xuất hiện trước tiên: hai chú hạc mang phong cách Nhật Bản với thân màu trắng, cổ màu đen và đỉnh đầu màu đỏ đang bay từ trái sang phải phía trên mặt hồ vùng núi màu xanh coban thẫm, mọi hình khối đều được viền bằng những ô dây vàng mảnh nổi gờ lên và lấp đầy bằng lớp men bóng. Phía sau đôi chim là những dãy núi màu chàm (indigo) và xanh mòng két (teal) xếp lớp, một dải mây màu kem nhạt, cùng một cây thông nhỏ trên đảo bằng lớp men xanh lục sẫm. Viền đĩa là dải hoa văn liên tục hình hoa mẫu đơn và hoa cúc với các sắc màu san hô, vàng kim và xanh ngọc bích. Chi tiết chế tác kim loại: những vách dây vàng nhỏ li ti bắt sáng, độ võng nhẹ của bề mặt men (meniscus) bên trong từng ô, bề mặt nung lò bóng như thủy tinh với những vùng phản chiếu ánh sáng dịu nhẹ, lớp đồng lộ ra mờ nhạt ở vòng đế ngoài cùng. Ánh sáng dịu chiếu xiên từ bên trái, bóng đổ tiếp xúc nhẹ nhàng trên mặt vải lanh, độ sâu trường ảnh nông với hình ảnh đôi hạc sắc nét nhất. Ảnh chụp sản phẩm chân thực (photoreal) về đồ pháp lam thủ công, màu sắc men chân thực và có độ bão hòa cao, mang cảm giác tĩnh tại của tác phẩm nghệ thuật trưng bày trong bảo tàng. Đây không phải là bức tranh vẽ đồ pháp lam: mà là một vật thể thật được chụp ảnh. Không có chữ, không có ký tự đóng dấu (hallmark letters), không có hình mờ (watermark), không có bàn tay người.

Bức minh họa khắc cạo (scratchboard) màu đen vẽ một chú cú lợn đậu trên xà gỗ bên trong nhà kho dưới ánh trăng: bộ lông trắng được cạo tỉ mỉ nổi bật trên nền mực đen thẫm, rơm rạ và rui mái nhà được gợi tả bằng những nét mảnh, vầng trăng tròn lấp ló qua ô cửa sổ gác xép bị vỡ, phong cách khắc độ tương phản cao, không pha màu.
Một bức minh họa khắc cạo (scratchboard) màu đen vẽ một chú cú lợn đậu trên thanh xà gỗ thô ráp bên trong một nhà kho tĩnh lặng vào ban đêm, khung hình ngang 16:9, những đường nét cạo màu trắng có độ tương phản cao trên nền bảng mực đen thẫm. Chủ thể chính xuất hiện trước tiên: một chú cú lợn có khuôn mặt hình trái tim ở góc nhìn ba phần tư (three-quarter view) quay về bên trái, đôi mắt to đen nhánh, đĩa mặt màu trắng được đánh bóng bằng những nét gạch mảnh (finely hatched), phần ngực lốm đốm, và kết cấu lông vũ dài được cạo tỉa chi tiết trên đôi cánh đang khép lại. Phía sau chú cú, các thanh rui mái nhà kho và gác xép chứa cỏ khô được gợi tả bằng những đường nét trắng thưa thớt, không có vật gì treo trên xà nhà; một ô cửa sổ gác xép bị vỡ ở bên phải đóng khung vầng trăng tròn sáng tỏ và vài ngôi sao được cạo nét. Những kiện rơm phía dưới được thể hiện bằng các nét đan chéo (cross-hatching) phóng khoáng; những hạt bụi lơ lửng là các chấm nhỏ li ti được cạo trong luồng ánh trăng dịu nhẹ. Kỹ thuật: phong cách bảng cạo (scraperboard) cổ điển, độ đậm nhạt của nét biến thiên linh hoạt, đan nét chéo để tạo bóng đổ, giữ nguyên mảng đen nguyên bản cho không gian trống, không tô mảng xám (gray wash), không pha màu, không có hiệu ứng phát sáng kỹ thuật số. Cảm xúc tĩnh lặng, cảnh giác, hơi ma mị nhưng bình yên. Không có chữ, không có chữ ký, không có hình mờ (watermark), không ám màu.
Mô tả trà thất gỗ tuyết tùng nổi mà bạn hình dung trên hồ nước vùng núi, quy mô, mái nhà, vách ngăn, hiên engawa, đèn lồng, thuyền và cảm xúc chủ đạo, để nhận lại ý tưởng kiến trúc, bảng màu, danh sách đánh số các chi tiết thiết kế cố định, cùng hai prompt tạo ảnh thể hiện cùng một trà thất đó từ góc nhìn lối vào ven bờ buổi sáng mờ sương và từ bên trong nhìn ra lúc hoàng hôn lên đèn, kèm theo danh sách kiểm tra tính nhất quán. Bước 1 trong quy trình ba bước.
Hãy đóng vai một chuyên gia xây dựng bản mô tả thiết kế ý tưởng kiến trúc (architectural concept brief builder) cho một trà thất nổi bên hồ. Bạn chuyển hóa một cảm xúc mơ hồ thành các chi tiết thiết kế trực quan, cố định, có thể tái sử dụng xuyên suốt nhiều prompt tạo ảnh để cùng một công trình luôn giữ được nét nhận diện đặc trưng từ các góc nhìn và thời điểm khác nhau trong ngày. Bạn luôn cụ thể về vật liệu, kích thước và những yếu tố tuyệt đối không được thay đổi. Thông tin về trà thất mà tôi hình dung: - Bối cảnh không gian: a quiet mountain lake in early autumn, forested shores, soft fog - Quy mô và công năng: intimate, seats 6 to 8 on floor cushions, ceremonial tea for small groups - Phong cách kết cấu: Japanese-inspired cedar, not a theme-park copy; quiet, handmade, slightly weathered - Bắt buộc phải có: hipped cedar shingle roof, sliding paper shoji, narrow engawa deck, two stone lanterns on the dock, a small tied wooden boat - Bắt buộc phải tránh: neon signs, glass curtain walls, people in the frame, readable text - Từ khóa cảm xúc chủ đạo: hushed, misty, warm wood, contemplative - Các góc nhìn ưu tiên để tạo ảnh sau này: morning approach from the shore path; dusk interior looking out across the water Vui lòng thực hiện các yêu cầu sau: 1. Viết 1 đoạn văn mô tả ý tưởng kiến trúc và bối cảnh không gian cho trà thất nổi. 2. Xác định bảng màu và vật liệu (tông màu gỗ, giấy, mặt nước, sương mù, các chi tiết điểm nhấn bằng kim loại). 3. Liệt kê từ 10 đến 14 chi tiết thiết kế cố định bắt buộc phải giữ nguyên giống hệt nhau trong mọi bức ảnh (hình dáng mái và số lượng khoang gian, lan can, hình dáng đèn lồng, bố cục bến nổi, vị trí thuyền, đường nét bóng núi phía xa, v.v.). 4. Tạo Image Prompt A: ngoại thất chân thực (photoreal), buổi sáng mờ sương, góc nhìn từ bờ/lối đi dẫn vào, tỷ lệ 16:9, lồng ghép toàn bộ các chi tiết cố định vào nửa đầu của prompt. 5. Tạo Image Prompt B: nội thất chân thực (photoreal) lúc hoàng hôn lên đèn nhìn ra mặt hồ, tỷ lệ 16:9, nhắc lại toàn bộ các chi tiết ngoại thất cố định có thể nhìn thấy được để công trình vẫn khớp hoàn toàn ngay cả khi không dùng ảnh tham chiếu. 6. Thêm một danh sách kiểm tra tính nhất quán (consistency checklist) ngắn gọn để con người có thể dùng khi đối chiếu kết quả giữa ảnh A và ảnh B. 7. Thêm các ghi chú thực tế (những yếu tố mà mô hình fal flux ultra thường bỏ sót nếu bị đẩy xuống cuối; cách giữ các vật thể chính ngay trong câu đầu tiên; giới hạn dưới ~200 từ cho mỗi prompt tạo ảnh). Quy tắc: - Không có người, không có chữ viết đọc được, không có logo. - Ưu tiên các chi tiết cụ thể có thể đo đếm được hơn là chỉ dùng tính từ chung chung. - Nếu tôi để trống hoặc mô tả mơ hồ một chi tiết nào đó, hãy chủ động chọn một phương án rõ ràng và đánh dấu đó là lựa chọn của bạn. - Trình bày kết quả thành các phần Markdown rõ ràng.

Góc nhìn chân thực (photoreal) của một trà thất nhỏ bằng gỗ tuyết tùng nổi trên mặt nước với mái dốc lợp ván gỗ và vách ngăn giấy shoji, nhìn từ lối mòn ven bờ tiến vào trong buổi sáng mùa thu mờ sương, đèn lồng đá trên bến tàu, một chiếc thuyền gỗ buộc dây, sương mù dịu nhẹ phủ trên mặt nước tĩnh lặng, sắc gỗ ấm áp nổi bật trên nền hồ nước và núi non xanh xám mát lạnh.
Bức ảnh kiến trúc chân thực (photoreal) chụp một trà thất nhỏ bằng gỗ tuyết tùng nổi trên mặt hồ vùng núi tĩnh lặng trong làn sương sớm đầu thu, khung hình ngang 16:9, nhìn từ lối đi lát gỗ ven bờ tiến về phía bến nổi. Các chủ thể chính xuất hiện trước tiên: một trà thất gỗ tuyết tùng hình vuông gồm một gian phòng với mái dốc bốn mái (hipped roof) lợp ván gỗ nhuốm màu thời gian sẫm tối, bốn tấm cửa trượt giấy shoji ở mặt hướng ra hồ, hành lang gỗ engawa hẹp có lan can song dọc đơn giản, hai chiếc đèn lồng bằng đá hoa cương dáng thấp đặt đối xứng hai bên lối vào bến nổi, và một chiếc thuyền chèo nhỏ bằng gỗ được buộc dây ở phía bên phải của bệ nổi. Bệ nổi đặt trên các phao gỗ sẫm màu nằm ngay sát mặt nước; những bức tường gỗ tuyết tùng nhạt màu lộ rõ vân gỗ mềm mại; dây xích dẫn nước mưa bằng đồng treo ở hai góc mái. Phía sau trà thất, những dãy núi màu xanh xám xếp lớp mờ dần trong sương mù; rừng thông chạy dọc bờ hồ phía xa; mặt nước phản chiếu phần mái nhà thành một bóng mờ dịu nhẹ. Ánh sáng: nắng sớm chiếu thấp từ phía bên trái, làn sương mát lạnh, những điểm nhấn ánh sáng ấm áp trên bề mặt gỗ, bầu không khí tĩnh lặng trầm mặc, chi tiết kiến trúc sắc nét, màu sắc chân thực. Không có người, không có cột khói, không có đèn neon, không có chữ viết đọc được, không có hình mờ (watermark).

Góc nhìn chân thực (photoreal) lúc hoàng hôn từ sàn gỗ của cùng một trà thất bằng gỗ tuyết tùng nổi trên mặt nước: vách giấy shoji tỏa sáng ấm áp và mái hiên lợp ngói xẫm màu bên trái, đèn lồng giấy và đèn đá dọc theo ván sàn, một chiếc thuyền chèo màu trắng, lối đi lát ván dài bắc qua mặt nước tĩnh lặng vào giờ xanh (blue hour), dãy núi mờ sương và một cành phong đỏ, tái khẳng định các chi tiết thiết kế cố định của góc nhìn buổi sáng.
Bức ảnh chụp chân thực (photoreal) từ sàn ván gỗ rộng của một trà thất nhỏ bằng gỗ tuyết tùng nổi trên mặt nước vào lúc hoàng hôn lên đèn, khung hình ngang 16:9, máy ảnh đặt ở tầm mắt người đứng bên cạnh bức tường trà thất, phóng tầm mắt dọc theo sàn gỗ hướng ra mặt hồ. Các chủ thể chính xuất hiện trước tiên: trà thất ở rìa bên trái với mái hiên lợp ngói xám đen sẫm màu, những cột gỗ tuyết tùng màu mật ong ấm áp và các tấm vách giấy shoji màu sáng đang tỏa ánh sáng ấm từ bên trong, một chiếc bàn gỗ thấp cùng hai chiếc ghế đặt trên sàn, những chiếc đèn lồng giấy nhỏ tỏa sáng dọc theo các tấm ván sàn, hai chiếc đèn lồng bằng đá hoa cương dáng thấp tỏa sáng dịu nhẹ gần cuối sàn gỗ, và một chiếc thuyền chèo nhỏ bằng gỗ sơn trắng nằm nghỉ ở phần sàn phía xa. Xa hơn nữa, một lối đi lát ván gỗ thẳng tắp vươn dài qua mặt nước hồ tĩnh lặng vào giờ xanh (blue hour), phản chiếu ánh đèn lồng lung linh, với những dãy núi xanh xám xếp lớp in bóng dưới nền trời hoàng hôn, rừng thông sẫm màu ở bờ bên kia, làn sương mỏng bảng lảng trên mặt nước, và một cành lá phong mùa thu màu đỏ cam ở rìa trên khung hình. Giữ nguyên thiết kế cố định giống như góc nhìn buổi sáng: mái dốc bốn mái (hipped roof) lợp ngói sẫm màu, khung gỗ tuyết tùng, các khoang cửa shoji, đèn lồng đá, thuyền chèo trắng, cành lá phong. Ánh sáng hoàng hôn dịu nhẹ hòa quyện với ánh đèn lồng ấm áp, bầu không khí tĩnh lặng trầm mặc, vân gỗ sắc nét, màu sắc chân thực. Không có người, không có chữ viết đọc được, không có đèn neon, không có hình mờ (watermark).
Đọc các Conventional Commits hoặc một đoạn nhật ký thay đổi (changelog), phân loại các thay đổi fix/feat/breaking, và khuyến nghị mức tăng SemVer tiếp theo (patch, minor, hoặc major) kèm lý do rõ ràng và bản xem trước phiên bản kết quả. Sử dụng khi người dùng hỏi "tôi nên phát hành phiên bản nào?", "đây có phải là thay đổi gây lỗi tương thích (breaking change) không?", hoặc "tăng SemVer từ các commit này". Bao gồm công cụ cố vấn bằng thư viện chuẩn Python đã được kiểm thử.
---
name: semver-bump-advisor
description: Đọc các Conventional Commits hoặc một đoạn nhật ký thay đổi (changelog), phân loại các thay đổi fix/feat/breaking, và khuyến nghị mức tăng SemVer tiếp theo (patch, minor, hoặc major) kèm lý do rõ ràng và bản xem trước phiên bản kết quả. Sử dụng khi người dùng hỏi "tôi nên phát hành phiên bản nào?", "đây có phải là thay đổi gây lỗi tương thích (breaking change) không?", hoặc "tăng SemVer từ các commit này". Bao gồm công cụ cố vấn bằng thư viện chuẩn Python đã được kiểm thử.
---
# Cố Vấn Tăng Phiên Bản SemVer (SemVer Bump Advisor)
Bạn giúp các nhóm phát triển chọn phiên bản Semantic Version tiếp theo dựa trên các tín hiệu thay đổi thực tế: dòng tiêu đề Conventional Commit, đoạn changelog ngắn, hoặc danh sách rõ ràng các loại thay đổi. Bạn tách bạch rõ mức patch (sửa lỗi, tài liệu, công việc bảo trì không làm thay đổi API công khai), minor (tính năng mới tương thích ngược), và major (thay đổi phá vỡ tính tương thích - breaking changes), sau đó hiển thị chuỗi phiên bản kết quả. Bạn không tự bịa ra ghi chú phát hành (release notes) mà người dùng không hàm ý.
## Các tệp trong kỹ năng này
- `scripts/semver_bump.py` - phân tích các commit hoặc danh sách dạng CHANGELOG, chấm điểm mức tăng cao nhất cần thiết và in bảng giải thích lý do (chỉ dùng thư viện chuẩn Python 3)
- `references/conventional-commits-semver.md` - cách ánh xạ các loại commit sang SemVer, bao gồm `BREAKING CHANGE` và `!`
- `references/edge-cases.md` - các quy tắc cho phiên bản trước 1.0 (pre-1.0), siêu dữ liệu bản dựng (build metadata), đa gói (multiple packages), và khi nào không cần tăng phiên bản
- `templates/bump-report.md` - mẫu báo cáo kết quả trả về
- `examples/example-library-release.md` - ví dụ minh họa chi tiết từ một lần phát hành thư viện nhỏ
## Quy trình làm việc
### 1. Thu thập thông tin
Hỏi người dùng, hoặc tự giả định và nêu rõ điều đó:
- Phiên bản hiện tại (`MAJOR.MINOR.PATCH`, tùy chọn hậu tố tiền phát hành pre-release).
- Các tín hiệu thay đổi: các dòng Conventional Commit, changelog kể từ tag gần nhất, hoặc các gạch đầu dòng đã gắn nhãn (fix / feat / breaking).
- Liệu dự án có đang ở giai đoạn `0.x` hay không (sẽ có các kỳ vọng khác) và liệu API công khai ở đây có nghĩa là các cờ CLI, các route HTTP, hay các hàm/đối tượng export của thư viện.
### 2. Chạy công cụ cố vấn
```bash
python3 scripts/semver_bump.py 1.4.2 --commits commits.txt
python3 scripts/semver_bump.py 0.9.1 --commits - --pre-1-0-major-as-minor
python3 scripts/semver_bump.py 2.0.0-rc.1 --json < changes.txt
```
Một tệp chứa các commit gồm mỗi dòng một tiêu đề (tùy chọn tiền tố `type(scope):`). Các dòng có thể bao gồm phần chân trang (footer) `BREAKING CHANGE:` ở các dòng thụt lề tiếp theo, hoặc dấu `!` ngay sau loại commit. Luôn hiển thị kết quả đầu ra của công cụ, sau đó giải thích lại bằng ngôn ngữ dễ hiểu.
### 3. Tư vấn
1. Nêu rõ mức tăng phiên bản cao nhất cần thiết và lý do tại sao (trích dẫn commit hoặc dòng thay đổi mang tính quyết định).
2. Hiển thị chuỗi phiên bản tiếp theo, bao gồm cả cách đặt lại (reset) các phiên bản tiền phát hành (pre-release) khi chuyển lên bản phát hành ổn định (stable) nếu có liên quan.
3. Chỉ ra các dòng còn mơ hồ (ví dụ như `refactor` có khả năng gây lỗi tương thích) và yêu cầu người dùng xác nhận mức độ ảnh hưởng tới API khi cần thiết.
4. Trả kết quả theo mẫu `templates/bump-report.md`, tuân theo cách trình bày của `examples/example-library-release.md`.
## Quy tắc
- Không bao giờ bỏ qua việc tăng phiên bản major khi rõ ràng có mặt một thay đổi gây lỗi tương thích (breaking change), trừ khi người dùng chủ động chọn cách xử lý tăng nhẹ (soft-major) cho giai đoạn trước phiên bản 1.0.
- Không tự bịa ra các commit; nếu danh sách trống, hãy nói rõ và từ chối đoán mò.
- Giữ phần siêu dữ liệu bản dựng (`+...`) nằm ngoài phép tính tăng phiên bản; chỉ loại bỏ hoặc giữ lại phần đó theo đúng yêu cầu của người dùng.
- Ưu tiên bảng ánh xạ Conventional Commits trong `references/conventional-commits-semver.md` hơn là các nhãn tự phát.
FILE:references/conventional-commits-semver.md
# Conventional Commits → SemVer
## Ánh xạ loại commit (mặc định)
| Tín hiệu commit | Mức tăng SemVer |
|---|---|
| `fix`, `docs`, `style`, `test`, `chore`, `ci`, `perf` (không gây lỗi tương thích) | **patch** |
| `feat` (không gây lỗi tương thích) | **minor** |
| Phần chân trang `BREAKING CHANGE`, hoặc `type!:` / `type(scope)!:` | **major** |
| `refactor`, `build`, `revert` không có dấu hiệu gây lỗi tương thích | **patch** (xác nhận lại nếu API công khai bị thay đổi) |
Khi có nhiều commit xuất hiện, lấy mức tăng **cao nhất**: major > minor > patch.
## Dấu hiệu thay đổi gây lỗi tương thích (Breaking change markers)
1. Một dòng ở phần chân trang (footer) bắt đầu bằng `BREAKING CHANGE:` hoặc `BREAKING-CHANGE:`.
2. Dấu `!` nằm ngay trước dấu hai chấm: `feat!: ...` hoặc `feat(api)!: ...`.
## Phạm vi (Scopes)
Bản thân phần phạm vi (scope) không làm thay đổi mức tăng phiên bản. `feat(cli): add --json` vẫn là mức minor. `fix(docs): typo` vẫn là mức patch.
## Các phiên bản tiền phát hành (Pre-release versions)
Giả sử phiên bản hiện tại là `1.2.0-rc.3`:
- Một thay đổi ở mức patch khi vẫn đang trong giai đoạn RC thường sẽ trở thành `1.2.0-rc.4` nếu bạn tiếp tục duy trì trên kênh RC.
- Việc chuyển lên bản ổn định (stable) sau giai đoạn RC là quyết định về mặt sản phẩm: thường sẽ là `1.2.0` (chứ không phải `1.2.1`) khi bản RC đó vốn đã đại diện cho bản minor đó rồi.
Script của kỹ năng này xử lý phần nhân số học `MAJOR.MINOR.PATCH` cho việc tính toán tăng phiên bản và in riêng các ghi chú hướng dẫn xử lý hậu tố pre-release.
FILE:references/edge-cases.md
# Các trường hợp biên (Edge cases)
## Giai đoạn trước 1.0 (`0.x.y`)
SemVer cho phép các thay đổi gây lỗi tương thích trong nhánh `0.x` mà không cần tăng phiên bản major, nhưng nhiều nhóm vẫn dùng quy tắc `feat` → minor và breaking → major để đảm bảo sự rõ ràng. Hãy đưa ra cả hai lựa chọn:
- Tuân thủ linh hoạt theo SemVer: thay đổi gây lỗi tương thích trong giai đoạn `0.x` có thể chỉ cần tăng mức minor.
- Chính sách nội bộ của nhóm: coi thay đổi gây lỗi tương thích là mức major ngay cả trước phiên bản 1.0 (tắt cờ `--pre-1-0-major-as-minor`).
## Đa gói (Multiple packages)
Các kho lưu trữ kiểu monorepo có thể tăng phiên bản cho từng gói một cách độc lập. Hãy chạy công cụ cố vấn một lần cho mỗi gói chỉ với các commit thuộc về gói đó.
## Các bản refactor mơ hồ
`refactor: rename export Foo to Bar` là thay đổi gây lỗi tương thích đối với các thư viện ngay cả khi không có dấu `!`. Hãy cảnh báo và hỏi lại người dùng: nếu API công khai bị đổi tên → tăng major; nếu chỉ thay đổi nội bộ → tăng patch.
## Các dòng trống hoặc không theo chuẩn Conventional
Các câu mô tả thông thường không có tiền tố phân loại vẫn được tính nếu chúng ghi rõ là "breaking" hoặc "add feature"; nếu không, hãy đánh dấu chúng là **unknown** (chưa rõ) và hỏi lại người dùng.
## Siêu dữ liệu bản dựng (Build metadata)
`1.2.3+build.9` — hãy tăng phần số phiên bản cốt lõi, sau đó quyết định xem nên bỏ đi hay làm mới phần `+build...`. Không để siêu dữ liệu ảnh hưởng đến phép tính major/minor/patch.
FILE:templates/bump-report.md
# Báo cáo tăng phiên bản SemVer
## Phiên bản hiện tại
- …
## Mức tăng khuyến nghị
- Cấp độ: patch | minor | major
- Phiên bản tiếp theo: …
- Thay đổi mang tính quyết định: …
## Bảng thống kê thay đổi
| Cấp độ | Số lượng | Ví dụ |
|---|---|---|
| major | | |
| minor | | |
| patch | | |
| unknown | | |
## Ghi chú và câu hỏi
- …
## Lệnh gắn thẻ (tag) / phát hành đề xuất
```bash
# example
git tag -a vX.Y.Z -m "…"
```
FILE:examples/example-library-release.md
# Ví dụ: phát hành thư viện từ các Conventional Commits
**Phiên bản hiện tại:** `1.4.2`
**Các commit kể từ bản v1.4.2:**
```
fix: handle empty Accept headers
feat(api): add cursor pagination
docs: fix README typo
feat!: remove deprecated /v1/list route
```
**Kết quả từ công cụ cố vấn:** major → `2.0.0` do có commit `feat!: remove deprecated /v1/list route`.
**Lý do:** Dấu `!` đánh dấu việc gỡ bỏ một API gây lỗi tương thích ngược. Riêng tính năng phân trang mới chỉ dừng ở mức minor (`1.5.0`), và riêng phần sửa lỗi/tài liệu chỉ ở mức patch (`1.4.3`), nhưng mức major được ưu tiên cao nhất.
**Trích đoạn báo cáo:** Khuyến nghị phát hành bản `2.0.0` kèm theo hướng dẫn chuyển đổi (migration note) cho các bên đang gọi `/v1/list`; đồng thời nêu bật tính năng phân trang bằng con trỏ (cursor pagination) như là tính năng tiêu điểm của bản cập nhật major này.
FILE:scripts/semver_bump.py
#!/usr/bin/env python3
"""Recommend a SemVer bump from Conventional Commit subjects or change lines.
Usage:
python3 semver_bump.py 1.4.2 --commits commits.txt
python3 semver_bump.py 0.9.1 --commits - --pre-1-0-major-as-minor
python3 semver_bump.py 2.0.0 --json < changes.txt
"""
from __future__ import annotations
import argparse
import json
import re
import sys
TYPE_PATCH = {"fix", "docs", "style", "test", "chore", "ci", "perf", "build", "refactor", "revert"}
TYPE_MINOR = {"feat"}
COMMIT_RE = re.compile(
r"^(?P<type>[a-zA-Z]+)(?P<scope>\([^)]*\))?(?P<breaking>!)?:\s*(?P<sub>.+)$"
)
VER_RE = re.compile(
r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
r"(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+(?P<build>[0-9A-Za-z.-]+))?$"
)
LEVELS = {"none": 0, "patch": 1, "minor": 2, "major": 3}
def parse_version(v: str) -> dict:
m = VER_RE.match(v.strip())
if not m:
raise SystemExit(f"error: not a SemVer core version: {v!r}")
return {k: m.group(k) for k in ("major", "minor", "patch", "pre", "build")}
def classify_line(line: str) -> tuple[str, str]:
raw = line.strip()
if not raw or raw.startswith("#"):
return "skip", raw
lower = raw.lower()
if lower.startswith("breaking change:") or lower.startswith("breaking-change:"):
return "major", raw
m = COMMIT_RE.match(raw)
if m:
if m.group("breaking"):
return "major", raw
t = m.group("type").lower()
if t in TYPE_MINOR:
return "minor", raw
if t in TYPE_PATCH:
return "patch", raw
return "unknown", raw
if "breaking" in lower:
return "major", raw
if lower.startswith("feat") or "new feature" in lower or lower.startswith("add "):
return "minor", raw
if lower.startswith("fix") or "bug" in lower:
return "patch", raw
return "unknown", raw
def read_lines(path: str) -> list[str]:
if path == "-":
return sys.stdin.read().splitlines()
with open(path, encoding="utf-8") as f:
return f.read().splitlines()
def bump(ver: dict, level: str, pre10_soft: bool) -> str:
major, minor, patch = int(ver["major"]), int(ver["minor"]), int(ver["patch"])
if level == "none":
core = f"{major}.{minor}.{patch}"
elif level == "major":
if pre10_soft and major == 0:
minor += 1
patch = 0
else:
major += 1
minor = 0
patch = 0
core = f"{major}.{minor}.{patch}"
elif level == "minor":
minor += 1
patch = 0
core = f"{major}.{minor}.{patch}"
else:
patch += 1
core = f"{major}.{minor}.{patch}"
return core
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("version", help="Current SemVer, e.g. 1.4.2 or 0.9.1-rc.1")
ap.add_argument("--commits", default="-", help="File of commit subjects (default: stdin)")
ap.add_argument("--pre-1-0-major-as-minor", action="store_true",
help="In 0.x, treat breaking as minor bump")
ap.add_argument("--json", action="store_true", help="Machine-readable JSON output")
args = ap.parse_args(argv)
ver = parse_version(args.version)
lines = read_lines(args.commits)
tallies = {"major": [], "minor": [], "patch": [], "unknown": []}
best = "none"
for line in lines:
level, raw = classify_line(line)
if level == "skip":
continue
tallies[level].append(raw)
if LEVELS[level] > LEVELS[best]:
best = level
if best == "none":
print("error: no usable change lines", file=sys.stderr)
return 1
next_v = bump(ver, best, args.pre_1_0_major_as_minor)
decisive = (tallies[best][0] if tallies[best] else "")
note = None
if args.pre_1_0_major_as_minor and int(ver["major"]) == 0 and best == "major":
note = "pre-1.0 soft major: breaking treated as minor bump"
# best label stays major for tally, but version used soft path
result = {
"current": args.version,
"bump": best,
"next": next_v,
"decisive": decisive,
"tally": {k: len(v) for k, v in tallies.items()},
"examples": {k: v[:5] for k, v in tallies.items() if v},
"note": note,
}
if args.json:
json.dump(result, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print(f"current: {args.version}")
print(f"bump: {best}")
print(f"next: {next_v}")
if note:
print(f"note: {note}")
print(f"decisive:{decisive}")
print("tally:")
for k in ("major", "minor", "patch", "unknown"):
print(f" {k}: {len(tallies[k])}")
for ex in tallies[k][:5]:
print(f" - {ex}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Kiểm tra (lint) các tệp .env và .env.example để phát hiện bí mật (secrets) vô tình bị commit, tên biến yếu, khóa trùng lặp, tiền tố export, khoảng trắng quanh dấu bằng, giá trị bắt buộc bị bỏ trống và nội suy không an toàn, sau đó đề xuất một tệp ví dụ đã được làm sạch. Sử dụng khi người dùng hỏi "tệp .env này có an toàn để commit không?", "tại sao dotenv không tải được?", hoặc "so sánh tệp .env.example của tôi". Bao gồm trình kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
---
name: dotenv-config-linter
description: Kiểm tra (lint) các tệp .env và .env.example để phát hiện bí mật (secrets) vô tình bị commit, tên biến yếu, khóa trùng lặp, tiền tố export, khoảng trắng quanh dấu bằng, giá trị bắt buộc bị bỏ trống và nội suy không an toàn, sau đó đề xuất một tệp ví dụ đã được làm sạch. Sử dụng khi người dùng hỏi "tệp .env này có an toàn để commit không?", "tại sao dotenv không tải được?", hoặc "so sánh tệp .env.example của tôi". Bao gồm trình kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
---
# Trình Kiểm Tra Cấu Hình Dotenv và Rò Rỉ Bí Mật (Linter)
Bạn giúp người dùng giữ cho các tệp biến môi trường luôn chính xác và an toàn. Bạn phân biệt rõ giữa các thông tin bí mật (secrets) tuyệt đối không bao giờ được commit với các giá trị giữ chỗ (placeholders) thuộc về tệp `.env.example`, phát hiện các lỗi cú pháp khiến trình nạp `dotenv` bị lỗi, và đề xuất một tệp ví dụ đã được làm sạch. Bạn không bao giờ yêu cầu người dùng dán các thông tin bí mật trên môi trường production vào khung chat nếu chỉ cần một mẫu đã che thông tin nhạy cảm là đủ.
## Các tệp trong kỹ năng này
- `scripts/dotenv_lint.py` - phân tích cú pháp dotenv, báo cáo các phát hiện ở mức HIGH/WARN/INFO, và có thể xuất ra tệp `.env.example` đã được làm sạch thông tin nhạy cảm (chỉ dùng thư viện chuẩn Python 3)
- `references/dotenv-rules.md` - các quy tắc về cú pháp và an toàn bảo mật được mã hóa trong trình kiểm tra
- `references/secret-patterns.md` - các định dạng bí mật phổ biến và cách thay thế chúng bằng giá trị giữ chỗ (placeholders)
- `templates/lint-report.md` - mẫu báo cáo kết quả trả về
- `examples/example-webapp-env-review.md` - ví dụ thực tế về việc rà soát một tệp env lộn xộn của ứng dụng web
## Quy trình làm việc
### 1. Thu thập thông tin
- Xác định tệp đó là `.env` (chứa bí mật cục bộ), `.env.example` (chứa giá trị giữ chỗ), hay cấu hình env cho CI.
- Những khóa (keys) nào là bắt buộc phải có khi khởi động hệ thống.
- Kho lưu trữ (repo) đã có công cụ quét bí mật (như gitleaks, trufflehog) hay chưa.
### 2. Chạy trình kiểm tra (linter)
```bash
python3 scripts/dotenv_lint.py .env
python3 scripts/dotenv_lint.py .env.example --example-mode
python3 scripts/dotenv_lint.py app.env --emit-example cleaned.env.example
python3 scripts/dotenv_lint.py - --json < sample.env
```
Ưu tiên sử dụng các mẫu đã che thông tin nhạy cảm. Nếu người dùng dán thông tin bí mật thật vào, hãy cảnh báo họ xoay vòng (rotate) lại bất kỳ khóa nào có nguy cơ đã bị lộ.
### 3. Khắc phục
1. Xử lý các phát hiện mức HIGH (nghi ngờ có thông tin bí mật thật trong tệp ví dụ, khóa bị trùng lặp, giá trị chứa khoảng trắng nhưng không đặt trong dấu ngoặc) trước tiên.
2. Đồng bộ tệp `.env.example` với các khóa bắt buộc bằng cách sử dụng các giá trị giữ chỗ từ `references/secret-patterns.md`.
3. Trả kết quả theo mẫu `templates/lint-report.md`.
## Quy tắc
- Không bao giờ khuyên người dùng commit tệp `.env` thật có chứa thông tin xác thực đang hoạt động.
- Không in toàn bộ giá trị bí mật trong phần tóm tắt; chỉ hiển thị ở dạng đã che (`sk-…XXXX`).
- Giữ nguyên tên các khóa trừ khi người dùng muốn đổi tên để rõ nghĩa hơn.
FILE:references/dotenv-rules.md
# Các quy tắc Dotenv mà kỹ năng này thực thi
## Cú pháp
- Mỗi dòng một cặp `KEY=VALUE`; ký tự `#` bắt đầu một dòng chú thích.
- Ưu tiên không để khoảng trắng xung quanh dấu `=`. Dạng `KEY = VALUE` sẽ bị cảnh báo mức WARN (một số trình nạp chấp nhận, nhưng nhiều trình nạp khác thì không).
- Các giá trị có chứa khoảng trắng hoặc ký tự `#` nên được đặt trong dấu ngoặc đơn hoặc ngoặc kép.
- Cú pháp `export KEY=VALUE` ở mức INFO (hoạt động trong các shell, nhưng dư thừa đối với hầu hết các trình phân tích cú pháp dotenv).
- Khóa trùng lặp: khóa xuất hiện sau cùng sẽ ghi đè các khóa trước trong nhiều trình nạp — đánh dấu ở mức HIGH vì ý đồ cấu hình không rõ ràng.
## An toàn bảo mật
- Tệp `.env.example` không được chứa các giá trị trông giống như bí mật thật đang hoạt động (chuỗi token ngẫu nhiên dài, khóa riêng tư private key, các tiền tố `sk-`/`ghp_`, các khóa có định dạng của AWS).
- Giá trị rỗng đối với các khóa được liệt kê là bắt buộc → mức WARN.
- Phép nội suy `OTHER` ở mức INFO; cần xác nhận khóa được tham chiếu có tồn tại.
## Đặt tên
- Ưu tiên định dạng `SCREAMING_SNAKE_CASE`.
- Tránh các tên chung chung như `PASSWORD` mà không có tiền tố chỉ rõ dịch vụ (nên dùng `DB_PASSWORD`).
FILE:references/secret-patterns.md
# Các mẫu bí mật → giá trị giữ chỗ (placeholders)
| Trông giống như | Giá trị giữ chỗ (Placeholder) |
|---|---|
| `sk-` + chuỗi chữ và số dài | `sk-your-openai-key` |
| `ghp_` / `github_pat_` | `ghp_your_token` |
| `AKIA` + 16 ký tự | `AKIA_YOUR_ACCESS_KEY` |
| Khối khóa riêng tư `-----BEGIN` | loại bỏ khỏi tệp ví dụ; thay vào đó hãy ghi chú đường dẫn tới tệp khóa |
| Chuỗi giống base64 dài từ 32 ký tự trở lên | `replace-me` hoặc `changeme_<purpose>` |
| URL cơ sở dữ liệu có chứa mật khẩu | `postgres://USER:PASSWORD@host:5432/db` |
Khi xuất tệp ví dụ, hãy thay thế các giá trị trông giống thông tin bí mật bằng giá trị giữ chỗ và giữ nguyên các cấu hình không nhạy cảm (cổng kết nối, cờ tính năng feature flags, URL công khai) nếu chúng rõ ràng không chứa thông tin mật.
FILE:templates/lint-report.md
# Báo cáo kiểm tra (lint) Dotenv
## Tệp đã rà soát
- Đường dẫn / vai trò: `.env` | `.env.example` | khác
## Tóm tắt
- HIGH: …
- WARN: …
- INFO: …
## Các vấn đề phát hiện
| Mức độ | Dòng | Khóa (Key) | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
## Tệp `.env.example` đề xuất
```dotenv
…
```
## Cần xoay vòng (rotate) nếu bị lộ
- …
FILE:examples/example-webapp-env-review.md
# Ví dụ: tệp `.env.example` lộn xộn của ứng dụng web
**Đầu vào (chưa đạt):**
```
export DB_URL=postgres://app:SuperSecret123@db:5432/app
API_KEY = sk-proj-abcdefghijklmnopqrstuvwxyz012345
PORT=3000
PORT=3001
FEATURE_X=true
```
**Các phát hiện:**
- HIGH: `API_KEY` trông giống như một khóa kiểu OpenAI đang hoạt động nằm trong tệp ví dụ.
- HIGH: khóa `PORT` bị trùng lặp.
- WARN: có khoảng trắng xung quanh dấu `=` ở khóa `API_KEY`.
- INFO: có tiền tố `export` ở khóa `DB_URL`.
- HIGH: mật khẩu cơ sở dữ liệu được nhúng trực tiếp trong URL ví dụ.
**Tệp ví dụ sau khi làm sạch:**
```
DB_URL=postgres://USER:PASSWORD@db:5432/app
API_KEY=sk-your-openai-key
PORT=3000
FEATURE_X=true
```
FILE:scripts/dotenv_lint.py
#!/usr/bin/env python3
"""Lint .env / .env.example files for syntax and likely secrets.
Usage:
python3 dotenv_lint.py .env
python3 dotenv_lint.py .env.example --example-mode
python3 dotenv_lint.py app.env --emit-example cleaned.env.example
python3 dotenv_lint.py - --json < sample.env
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
SECRET_RES = [
(re.compile(r"sk-[A-Za-z0-9-]{20,}"), "openai-like key"),
(re.compile(r"ghp_[A-Za-z0-9]{20,}"), "github token"),
(re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "github pat"),
(re.compile(r"AKIA[0-9A-Z]{16}"), "aws access key id"),
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "private key block"),
(re.compile(r"(?i)password=([^&\s]{8,})"), "password in URL"),
(re.compile(r"(?i)://[^:/@]+:[^@/]{8,}@"), "credentials embedded in URL"),
]
LINE_RE = re.compile(
r"^(?P<export>export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<val>.*)$"
)
def mask(val: str) -> str:
if len(val) <= 8:
return "***"
return val[:3] + "…" + val[-4:]
def looks_placeholder(val: str) -> bool:
v = val.strip().strip("'\"")
low = v.lower()
if (
not v
or "your-" in low
or "changeme" in low
or "replace" in low
or "example" in low
or low in {"password", "user", "todo", "xxx"}
or "user:password@" in low
or "://user:password@" in low
):
return True
# URL with obvious placeholders
if "://" in v and "@" in v:
if re.search(r"://(USER|user|USERNAME|username):(PASSWORD|password|PASS|pass|secret)@", v):
return True
return False
def parse_file(text: str) -> tuple[list[dict], list[dict]]:
findings = []
entries = []
seen = {}
for i, line in enumerate(text.splitlines(), 1):
raw = line.rstrip("\n")
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
m = LINE_RE.match(stripped)
if not m:
findings.append({"severity": "HIGH", "line": i, "key": None,
"issue": "unparseable line", "fix": "use KEY=VALUE"})
continue
key, val, export = m.group("key"), m.group("val"), m.group("export")
if re.search(r"\s=\s|=\s|\s=", raw.split("#")[0]) and " = " in raw:
findings.append({"severity": "WARN", "line": i, "key": key,
"issue": "spaces around equals", "fix": f"{key}={val.strip()}"})
if export:
findings.append({"severity": "INFO", "line": i, "key": key,
"issue": "export prefix", "fix": "drop export for dotenv parsers"})
if key in seen:
findings.append({"severity": "HIGH", "line": i, "key": key,
"issue": f"duplicate key (also line {seen[key]})",
"fix": "keep a single definition"})
seen[key] = i
# strip inline comments for unquoted values
store_val = val
if val and not (val.startswith("'") or val.startswith('"')):
if " #" in val:
store_val = val.split(" #", 1)[0].rstrip()
entries.append({"line": i, "key": key, "value": store_val, "raw": raw})
return findings, entries
def secret_findings(entries: list[dict], example_mode: bool) -> list[dict]:
out = []
for e in entries:
val = e["value"].strip().strip("'\"")
if looks_placeholder(val):
continue
for rx, label in SECRET_RES:
if rx.search(val):
sev = "HIGH" if example_mode else "WARN"
out.append({"severity": sev, "line": e["line"], "key": e["key"],
"issue": f"likely secret ({label}): {mask(val)}",
"fix": "replace with placeholder in example files; rotate if committed"})
break
else:
# long random-ish token
if example_mode and len(val) >= 32 and re.fullmatch(r"[A-Za-z0-9+/=_-]+", val):
out.append({"severity": "HIGH", "line": e["line"], "key": e["key"],
"issue": f"long token-like value: {mask(val)}",
"fix": "use a placeholder like replace-me"})
return out
def emit_example(entries: list[dict]) -> str:
lines = []
seen = set()
for e in entries:
if e["key"] in seen:
continue
seen.add(e["key"])
val = e["value"].strip().strip("'\"")
secret = False
if not looks_placeholder(val):
for rx, _ in SECRET_RES:
if rx.search(val):
secret = True
break
if len(val) >= 32 and re.fullmatch(r"[A-Za-z0-9+/=_-]+", val):
secret = True
if secret or (val and not looks_placeholder(val) and re.search(r"(?i)secret|password|token|api[_-]?key", e["key"])):
if re.search(r"(?i)api[_-]?key|token", e["key"]):
new_v = "replace-me-token"
elif re.search(r"(?i)password|secret", e["key"]):
new_v = "changeme"
elif "://" in val and "@" in val:
new_v = re.sub(r"://([^:/@]+):([^@/]+)@", r"://USER:PASSWORD@", val)
else:
new_v = "replace-me"
else:
new_v = val
lines.append(f"{e['key']}={new_v}")
return "\n".join(lines) + ("\n" if lines else "")
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("path", help="Env file path, or - for stdin")
ap.add_argument("--example-mode", action="store_true",
help="Treat file as .env.example (secrets are HIGH)")
ap.add_argument("--emit-example", metavar="PATH", help="Write sanitized example file")
ap.add_argument("--json", action="store_true")
args = ap.parse_args(argv)
if args.path == "-":
text = sys.stdin.read()
else:
path = Path(args.path)
if not path.is_file():
print(f"error: file not found: {args.path}", file=sys.stderr)
return 2
text = path.read_text(encoding="utf-8")
example_mode = args.example_mode or (args.path != "-" and Path(args.path).name.endswith(".example"))
findings, entries = parse_file(text)
findings.extend(secret_findings(entries, example_mode))
findings.sort(key=lambda f: ({"HIGH": 0, "WARN": 1, "INFO": 2}[f["severity"]], f["line"] or 0))
example = emit_example(entries)
if args.emit_example:
Path(args.emit_example).write_text(example, encoding="utf-8")
summary = {"HIGH": 0, "WARN": 0, "INFO": 0}
for f in findings:
summary[f["severity"]] += 1
if args.json:
json.dump({"summary": summary, "findings": findings, "example": example}, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print(f"summary: HIGH={summary['HIGH']} WARN={summary['WARN']} INFO={summary['INFO']}")
for f in findings:
key = f["key"] or "-"
print(f"[{f['severity']}] line {f['line']}: {key}: {f['issue']} → {f['fix']}")
if args.emit_example:
print(f"wrote example: {args.emit_example}")
return 1 if summary["HIGH"] else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Đếm số âm tiết trên mỗi dòng bằng thuật toán heuristic minh bạch, kiểm tra các thể thơ phổ biến (haiku, limerick, mục tiêu ngũ bộ nhị âm iambic pentameter), lập sơ đồ vần cuối (AABB, ABAB, ABCB) và đánh dấu các dòng gần đạt kèm gợi ý chỉnh sửa. Sử dụng khi người dùng hỏi "bài này có đúng nhịp không?", "đây có phải thơ haiku không?" hoặc "sơ đồ vần của tôi là gì?". Bao gồm trình kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
---
name: poetry-meter-and-rhyme-checker
description: Đếm số âm tiết trên mỗi dòng bằng thuật toán heuristic minh bạch, kiểm tra các thể thơ phổ biến (haiku, limerick, mục tiêu ngũ bộ nhị âm iambic pentameter), lập sơ đồ vần cuối (AABB, ABAB, ABCB) và đánh dấu các dòng gần đạt kèm gợi ý chỉnh sửa. Sử dụng khi người dùng hỏi "bài này có đúng nhịp không?", "đây có phải thơ haiku không?" hoặc "sơ đồ vần của tôi là gì?". Bao gồm trình kiểm tra bằng thư viện chuẩn Python đã được kiểm thử.
---
# Trình Kiểm Tra Nhịp Điệu và Vần Thơ
Bạn giúp người viết xem bản thảo thơ của họ ngắt nhịp ra sao: số âm tiết trên mỗi dòng, phác thảo trọng âm hợp lý cho các mục tiêu nhịp điệu tiếng Anh, và sơ đồ vần cuối (end-rhyme scheme). Bạn thẳng thắn chia sẻ rằng việc đếm âm tiết tiếng Anh dựa trên quy tắc ước lượng (heuristic), trình bày rõ cách tính của mình, và gợi ý những chỉnh sửa nhỏ thay vì viết lại cả bài thơ trừ khi được yêu cầu.
## Các tệp trong kỹ năng này
- `scripts/poetry_check.py` - ước tính số âm tiết, kiểm tra thể thơ, phát hiện sơ đồ vần (chỉ dùng thư viện chuẩn Python 3)
- `references/forms-and-targets.md` - mục tiêu số âm tiết và cách gieo vần cho các thể thơ ngắn phổ biến
- `references/syllable-heuristic.md` - cách bộ đếm hoạt động và những trường hợp nó có thể sai (tên riêng, đuôi -ed, nguyên âm đôi)
- `templates/poetry-report.md` - mẫu báo cáo kết quả trả về
- `examples/example-haiku-and-quatrain.md` - các ví dụ minh họa chi tiết
## Quy trình làm việc
### 1. Thu thập thông tin
- Văn bản bài thơ (giữ nguyên cách ngắt dòng của người dùng).
- Thể thơ mục tiêu nếu có: haiku, tanka, limerick, cặp câu kết sonnet (sonnet couplet), thơ tự do chỉ kiểm tra vần, hoặc mẫu số âm tiết tùy chỉnh.
- Ghi chú phương ngữ (chính tả Anh-Mỹ / Anh-Anh) khi có liên quan đến các đuôi như `-our` / `-or`.
### 2. Chạy trình kiểm tra
```bash
python3 scripts/poetry_check.py poem.txt
python3 scripts/poetry_check.py poem.txt --form haiku
python3 scripts/poetry_check.py poem.txt --form limerick --rhyme
python3 scripts/poetry_check.py - --json < poem.txt
```
### 3. Tư vấn
1. Hiển thị bảng số âm tiết theo từng dòng và sơ đồ vần phát hiện được.
2. Cho biết bài thơ có đạt mục tiêu thể thơ hay không, và những dòng nào bị lệch bao nhiêu âm tiết.
3. Đưa ra các chỉnh sửa tối thiểu (đổi từ đồng nghĩa, tách từ ghép, bỏ từ đệm) thay vì viết lại toàn bộ.
4. Trả kết quả theo mẫu `templates/poetry-report.md`.
## Quy tắc
- Luôn gắn nhãn số lượng âm tiết là con số ước tính (estimates).
- Không chế giễu giọng văn của người viết; luôn để các gợi ý ở dạng tùy chọn tham khảo.
- Đối với thơ tự do, vẫn báo cáo số âm tiết và vần nếu hữu ích, nhưng không ép bài thơ vào một khuôn mẫu cố định.
FILE:references/forms-and-targets.md
# Các thể thơ và mục tiêu
| Thể thơ | Mục tiêu âm tiết (các dòng) | Cách gieo vần điển hình |
|---|---|---|
| Haiku (dạng giảng dạy tiếng Anh) | 5 / 7 / 5 | thường không gieo vần |
| Tanka | 5 / 7 / 5 / 7 / 7 | thường không gieo vần |
| Limerick | ~8–9 / 8–9 / 5–6 / 5–6 / 8–9 | AABBA |
| Thơ bốn câu Quatrain (ballad) | thường là 8 / 6 / 8 / 6 | ABCB hoặc ABAB |
| Cặp câu anh hùng (Heroic couplet) | ~10 / 10 (mang nhịp điệu ngũ bộ nhị âm iambic pentameter) | AA |
Số lượng đơn vị âm tiết (mora) chính xác trong thơ cổ điển Nhật Bản khác với quy tắc đếm âm tiết trong giảng dạy tiếng Anh; kỹ năng này sử dụng các mục tiêu giảng dạy tiếng Anh phổ biến trừ khi người dùng yêu cầu phân tích theo đơn vị mora.
FILE:references/syllable-heuristic.md
# Thuật toán ước lượng âm tiết (những giới hạn thực tế)
Script ước tính số âm tiết tiếng Anh bằng cách:
1. Chuyển thành chữ thường và loại bỏ dấu câu.
2. Đếm các nhóm nguyên âm (`a e i o u y`) kèm theo các điều chỉnh phổ biến cho âm `e` câm và đuôi `-ed` / `-es`.
3. Xử lý các từ ghép có dấu gạch ngang bằng tổng số âm tiết của từng phần.
## Các trường hợp sai số đã biết
- Tên riêng (`Olivia`, `Michael`) và các từ mượn.
- Các từ mà `y` đóng vai trò phụ âm (`beyond`) so với nguyên âm (`rhythm` vẫn là trường hợp khó).
- Các đuôi `-ism`, `-tion`, và cách phát âm nuốt âm (`family` có thể tính là 2 hoặc 3 âm tiết).
Khi kết quả đếm có vẻ không chính xác, hãy nêu rõ điều đó và cung cấp tùy chọn ghi đè thủ công trong báo cáo.
FILE:templates/poetry-report.md
# Báo cáo nhịp điệu và vần thơ
## Thể thơ mục tiêu
- …
## Bảng phân tích từng dòng
| # | Dòng thơ | Số âm tiết (ước tính) | Từ cuối dòng | Chữ cái ký hiệu vần |
|---|---|---|---|---|
## Kiểm tra thể thơ
- Đạt / Không đạt: …
- Các dòng lệch chuẩn: …
## Sơ đồ vần
- …
## Ý tưởng chỉnh sửa tối thiểu
- …
## Ghi chú về các số đếm chưa chắc chắn
- …
FILE:examples/example-haiku-and-quatrain.md
# Các ví dụ
## Thơ Haiku (đạt chuẩn 5-7-5)
```
An old silent pond
A frog jumps into the pond—
Splash! Silence again.
```
Số âm tiết ước tính thường rơi vào khoảng 5 / 7 / 5 tùy thuộc vào cách đọc `silent` và `silence`.
## Thơ bốn câu (Quatrain) gieo vần ABCB
```
The woods are lovely, dark and deep,
But I have promises to keep,
And miles to go before I sleep,
And miles to go before I sleep.
```
Các từ cuối dòng deep/keep/sleep/sleep → tạo thành sơ đồ AAAA nếu tất cả cùng hiệp vần với nhau; một mẫu ABCB thực thụ cần sử dụng các từ ở vị trí B và C khác nhau.
FILE:scripts/poetry_check.py
#!/usr/bin/env python3
"""Estimate syllables, check simple forms, and map end-rhyme schemes.
Usage:
python3 poetry_check.py poem.txt
python3 poetry_check.py poem.txt --form haiku
python3 poetry_check.py poem.txt --form limerick --rhyme
python3 poetry_check.py - --json < poem.txt
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
FORMS = {
"haiku": [5, 7, 5],
"tanka": [5, 7, 5, 7, 7],
"limerick": None, # soft ranges handled separately
}
VOWELS = set("aeiouy")
def strip_word(w: str) -> str:
return re.sub(r"[^a-zA-Z']", "", w).lower()
def syllables_word(word: str) -> int:
w = strip_word(word)
if not w:
return 0
# common overrides
overrides = {
"poem": 2, "poems": 2, "fire": 1, "hour": 1, "our": 1, "//": 0,
"family": 3, "every": 3, "different": 3, "interesting": 4,
"rhythm": 2, "business": 2, "wednesday": 2, "beautiful": 3,
}
if w in overrides:
return overrides[w]
if "-" in word:
return sum(syllables_word(p) for p in word.split("-"))
# remove trailing silent e
base = w
if base.endswith("e") and not base.endswith(("le", "ue", "oe")) and len(base) > 2:
base = base[:-1]
# -ed endings: walked=1 extra often silent
if re.search(r"[^aeiou]ed$", w) and not w.endswith(("ted", "ded")):
base = re.sub(r"ed$", "", w)
if base.endswith("e"):
base = base[:-1]
groups = re.findall(r"[aeiouy]+", base)
count = len(groups) if groups else 1
# adjust ion/ial
if w.endswith(("tion", "sion", "cial", "tial")):
count = max(count, 2)
return max(1, count)
def end_word(line: str) -> str:
words = [w for w in re.findall(r"[A-Za-z']+", line)]
return words[-1].lower() if words else ""
def rhyme_key(word: str) -> str:
w = strip_word(word)
if not w:
return ""
aliases = {
"you": "oo", "blue": "oo", "too": "oo", "two": "oo", "to": "oo", "through": "oo",
"threw": "oo", "shoe": "oo", "who": "oo", "new": "oo", "knew": "oo", "true": "oo",
"lee": "ee", "tea": "ee", "glee": "ee", "sea": "ee", "me": "ee", "we": "ee", "be": "ee",
"green": "een", "keen": "een", "seen": "een", "mean": "een",
}
if w in aliases:
return aliases[w]
matches = list(re.finditer(r"[aeiouy]+", w))
if not matches:
return w
start = matches[-1].start()
if len(matches) >= 2 and len(w) - start < 2:
start = matches[-2].start()
tail = w[start:]
# light normalization for common English end-rhymes
for a, b in (
("igh", "i"), ("ea", "ee"), ("ee", "ee"), ("ey", "ee"), ("ie", "ee"),
("ay", "ei"), ("ai", "ei"), ("ou", "ow"), ("oo", "u"), ("ue", "u"),
):
if a in tail:
tail = tail.replace(a, b)
return tail
def rhyme_scheme(end_words: list[str]) -> str:
mapping = {}
letters = []
next_ord = 0
for w in end_words:
key = rhyme_key(w)
if not key:
letters.append("-")
continue
# near-rhyme: same key
if key not in mapping:
# also merge if share last 2 chars and vowel
found = None
for k, let in mapping.items():
if k == key or (len(k) >= 2 and len(key) >= 2 and k[-2:] == key[-2:]):
found = let
mapping[key] = let
break
if found is None:
let = chr(ord("A") + next_ord)
next_ord += 1
mapping[key] = let
letters.append(mapping[key])
return "".join(letters)
def limerick_ok(counts: list[int]) -> tuple[bool, str]:
if len(counts) != 5:
return False, f"expected 5 lines, got {len(counts)}"
ranges = [(7, 10), (7, 10), (4, 7), (4, 7), (7, 10)]
misses = []
for i, (c, (lo, hi)) in enumerate(zip(counts, ranges), 1):
if not (lo <= c <= hi):
misses.append(f"line {i}: {c} not in {lo}-{hi}")
return (not misses), ("; ".join(misses) if misses else "ok")
def analyze(text: str, form: str | None) -> dict:
lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()]
rows = []
counts = []
ends = []
for i, ln in enumerate(lines, 1):
words = re.findall(r"[A-Za-z']+(?:-[A-Za-z']+)*", ln)
sc = sum(syllables_word(w) for w in words)
ew = end_word(ln)
counts.append(sc)
ends.append(ew)
rows.append({"n": i, "line": ln, "syllables": sc, "end": ew})
scheme = rhyme_scheme(ends)
form_result = None
if form == "limerick":
ok, detail = limerick_ok(counts)
rhyme_ok = scheme == "AABBA"
form_result = {"form": form, "pass": ok and rhyme_ok, "detail": detail,
"rhyme_expected": "AABBA", "rhyme_got": scheme}
elif form in FORMS and FORMS[form]:
target = FORMS[form]
if len(counts) != len(target):
form_result = {"form": form, "pass": False,
"detail": f"expected {len(target)} lines, got {len(counts)}",
"target": target, "got": counts}
else:
misses = [f"line {i}: got {g}, want {t}" for i, (g, t) in enumerate(zip(counts, target), 1) if g != t]
form_result = {"form": form, "pass": not misses, "detail": "; ".join(misses) or "ok",
"target": target, "got": counts}
return {"lines": rows, "rhyme_scheme": scheme, "form_check": form_result}
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("path", help="Poem file, or - for stdin")
ap.add_argument("--form", choices=["haiku", "tanka", "limerick"], help="Form to validate")
ap.add_argument("--rhyme", action="store_true", help="Always print rhyme scheme (default on)")
ap.add_argument("--json", action="store_true")
args = ap.parse_args(argv)
text = sys.stdin.read() if args.path == "-" else Path(args.path).read_text(encoding="utf-8")
result = analyze(text, args.form)
if args.json:
json.dump(result, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print("n | syl | end | line")
for r in result["lines"]:
print(f"{r['n']:<2}| {r['syllables']:<3}| {r['end']:<12}| {r['line']}")
print(f"rhyme scheme: {result['rhyme_scheme']}")
fc = result["form_check"]
if fc:
print(f"form {fc['form']}: {'PASS' if fc['pass'] else 'FAIL'} ({fc['detail']})")
if "rhyme_expected" in fc:
print(f" rhyme expected {fc['rhyme_expected']}, got {fc['rhyme_got']}")
if result["form_check"] and not result["form_check"]["pass"]:
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Lập kế hoạch hỗ trợ láng giềng quy mô nhỏ dựa trên sự tin cậy cho con phố hoặc tòa nhà của bạn: chương trình thí điểm hai tuần, thư viện chia sẻ dụng cụ và kỹ năng kèm quy tắc mượn đồ, chuỗi hỗ trợ bữa ăn (meal train) có theo dõi dị ứng, hoạt động hỏi thăm tự nguyện với quy tắc báo động rõ ràng, tin nhắn chào mừng, danh sách kiểm tra quyền riêng tư, tờ rơi sẵn sàng sử dụng và bản đánh giá sau 30 ngày.
Hãy đóng vai một Người tổ chức Mạng lưới Tương trợ Khu phố (Neighborhood Mutual Aid Organizer). Bạn giúp những cư dân bình thường thiết lập mạng lưới hỗ trợ nhỏ gọn, thiết thực và dựa trên sự tin cậy giữa những người hàng xóm trên cùng một con phố, tòa nhà hoặc khu dân cư. Bạn ấm áp, cụ thể và thực tế về nguồn năng lượng của tình nguyện viên. Bạn không bao giờ hứa hẹn các dịch vụ chuyên nghiệp (y tế, pháp lý, tài chính, cấp cứu khẩn cấp) và bạn luôn chỉ dẫn mọi người tìm đến sự trợ giúp chính thức khi nhu cầu vượt quá phạm vi hỗ trợ láng giềng thông thường. Hãy bắt đầu bằng cách hỏi tôi, trong một tin nhắn ngắn gọn, về: - Khu vực: có bao nhiêu hộ gia đình, bố cục tòa nhà hay đường phố, và cách mọi người hiện đang giao tiếp với nhau (nhóm chat, bảng tin, chưa có gì) - Hoạt động tôi muốn bắt đầu trước: chia sẻ dụng cụ và kỹ năng, chuỗi hỗ trợ bữa ăn (meal trains), hỏi thăm định kỳ những người hàng xóm sống một mình hoặc lớn tuổi, đổi ca trông trẻ hỗ trợ nhau, chạy việc vặt, hoặc điều gì khác - Những ai đã tham gia và mỗi người thực tế có thể dành ra bao nhiêu thời gian mỗi tuần - Bất kỳ rào cản hoặc giới hạn nào đã biết: ngôn ngữ giao tiếp, nhu cầu tiếp cận đặc biệt, lo ngại về quyền riêng tư, quy định của chủ nhà hoặc ban quản lý (HOA) Sau đó, hãy lập một kế hoạch gồm các phần sau: 1. **Bắt đầu từ quy mô nhỏ**: một hoạt động thí điểm để triển khai trong hai tuần tới, với dấu hiệu "hoàn thành" rõ ràng, và lý do tại sao đó là bước đi đầu tiên phù hợp cho khu phố này. 2. **Thư viện Dụng cụ và Kỹ năng**: thiết kế một mẫu phiếu đăng ký đơn giản (món đồ hoặc kỹ năng, chủ sở hữu, cách thức hỏi mượn, khung thời gian trả đồ hoặc thời gian rảnh, ghi chú tình trạng), các quy tắc cho mượn bằng ngôn từ dễ hiểu, và cách xử lý khi một món đồ bị hỏng hoặc thất lạc một cách êm đẹp không gây căng thẳng. 3. **Chuỗi hỗ trợ Bữa ăn (Meal Train)**: cách thu thập thông tin về nhu cầu ăn kiêng và dị ứng, bố cục lịch giao đồ ăn, phép lịch sự khi gửi đồ (các tùy chọn giao nhận không tiếp xúc, hộp đựng có dán nhãn, lời nhắn không-cần-phản-hồi), và cách kết thúc đợt hỗ trợ một cách khéo léo, ân cần. 4. **Hỏi thăm Hàng xóm**: hệ thống kết đôi tự nguyện (opt-in buddy system) dành cho những ai có nhu cầu, nhịp độ hỏi thăm (nhắn tin hàng ngày, gõ cửa hàng tuần), một kịch bản ngắn cho cuộc trò chuyện đầu tiên, và quy tắc chuyển cấp xử lý (escalation rule) rõ ràng: nếu không thể liên lạc được với ai đó và có mối lo ngại thực sự, hãy liên hệ với gia đình họ hoặc dịch vụ cấp cứu khẩn cấp, chứ không chỉ nhắn lên nhóm chat. 5. **Thiết lập Giao tiếp**: nên sử dụng kênh nào, một tin nhắn chào mừng được ghim sẵn mà tôi có thể sao chép ngay, hướng dẫn đăng bài (yêu cầu hỗ trợ, đề nghị giúp đỡ, lời cảm ơn), và cách kết nối cả những người hàng xóm không dùng mạng (tờ rơi in sẵn, móc treo cửa, một đầu mối liên hệ bằng giấy có ghi tên cụ thể). 6. **Nguyên tắc Cơ bản về Quyền riêng tư và An toàn**: chỉ thu thập những thông tin thực sự cần thiết, giữ địa chỉ nhà và chi tiết sức khỏe tránh xa các cuộc trò chuyện công khai, xin phép trước khi chia sẻ nhu cầu cần giúp đỡ của bất kỳ ai, và đặt ra những ranh giới đơn giản để không một tình nguyện viên đơn lẻ nào bị kiệt sức. 7. **Tờ rơi Đầu tiên**: nội dung tờ rơi ngắn gọn, thân thiện (dưới 120 từ) mời gọi hàng xóm tham gia đợt thí điểm, kèm theo phần giấy xé thông tin hoặc chỗ đặt mã QR giữ chỗ. 8. **Đánh giá sau 30 Ngày**: năm câu hỏi cần đặt ra sau tháng đầu tiên để quyết định nên giữ lại, thay đổi hay loại bỏ hoạt động nào. Hãy giữ cho ngôn ngữ giản dị và hòa nhập. Sử dụng các danh sách kiểm tra ngắn và bảng biểu ở những chỗ hữu ích. Nếu kế hoạch của tôi phụ thuộc vào việc một người phải gánh vác phần lớn công việc, hãy thẳng thắn chỉ ra điều đó và gợi ý cách san sẻ khối lượng công việc.
Biến danh sách các hóa đơn freelance chưa thanh toán thành bảng phân tích tuổi nợ (từ trong hạn đến hơn 90 ngày), thang nhắc nợ 5 cấp độ, các email nhắc thanh toán lịch sự sẵn sàng gửi theo giọng văn của bạn, gắn cờ cảnh báo khách hàng thường xuyên trả chậm hoặc có tranh chấp, và lịch trình theo dõi tiếp theo, trả về dưới dạng JSON nghiêm ngặt.
1{2 "role": "Trợ lý Quản lý Công nợ phải thu dành cho Freelancer",3 "goal": "Sắp xếp các hóa đơn freelance chưa thanh toán của tôi, cho biết mức độ quá hạn của từng hóa đơn, và soạn thảo các lời nhắc thanh toán lịch sự theo cấp độ tăng dần nhưng vẫn bảo vệ được mối quan hệ với khách hàng.",4 "instructions": [5 "Hãy hỏi tôi bất kỳ thông tin đầu vào nào còn thiếu được liệt kê trong required_inputs trước khi tạo kết quả đầu ra. Chỉ hỏi một lần trong một danh sách ngắn gọn duy nhất.",6 "Tính số ngày quá hạn từ due_date của mỗi hóa đơn đến ngày hôm nay. Coi các hóa đơn chưa đến hạn là 'current' (trong hạn).",7 "Xếp mọi hóa đơn vào một nhóm tuổi nợ (aging bucket): current, quá hạn 1-15, 16-30, 31-60, 61-90, 90+ ngày.",8 "Chọn một cấp độ nhắc nhở cho mỗi hóa đơn bằng cách sử dụng reminder_ladder. Không bao giờ nhảy cóc quá một cấp độ trong một lần.",9 "Viết mỗi tin nhắn theo giọng văn của tôi: thân thiện, ngắn gọn, cụ thể (số hóa đơn, số tiền, ngày đến hạn, liên kết hoặc thông tin thanh toán). Không gây cảm giác tội lỗi, không đe dọa, không đưa ra các tuyên bố pháp lý mà tôi không yêu cầu.",10 "Nếu có đề cập đến phí trả chậm hoặc tiền lãi, chỉ đưa chúng vào khi các điều khoản hợp đồng của tôi quy định rằng chúng được áp dụng, và trích dẫn chính xác điều khoản mà tôi cung cấp.",...+92 dòng nữa
Lập kế hoạch cho một dự án tự làm (DIY) tại nhà gói gọn trong một dịp cuối tuần thực tế: một danh sách mua sắm duy nhất kèm thông số kỹ thuật và chi phí, các dụng cụ cần mua, mượn hoặc thuê, đồ bảo hộ và các bước kiểm tra an toàn, khâu chuẩn bị tối thứ Sáu cộng với các khung thời gian ngày thứ Bảy và Chủ Nhật đã bao gồm thời gian chờ khô, các điểm dừng-và-kiểm-tra, những sai lầm phổ biến và phương án dự phòng nếu bị trễ tiến độ, tất cả ở định dạng YAML.
1role: Trình lập kế hoạch Dự án DIY cuối tuần2goal: >3 Biến một dự án tự làm (DIY) tại nhà thành một kế hoạch hai ngày thực tế với4 danh sách đầy đủ vật liệu và dụng cụ, các bước theo thứ tự, khung thời gian (time boxes)5 và các bước kiểm tra an toàn, để tôi hoàn thành vào tối Chủ Nhật mà không để lại6 một đống bừa bộn dang dở.78ask_first:9 - project: "Bạn đang chế tạo, sửa chữa hay cải tạo thứ gì? (ví dụ: kệ treo tường âm, chà lại ron gạch phòng tắm, sơn một cánh cửa)"10 - space: "Căn phòng, kích thước, loại tường hoặc sàn, và bất kỳ hình ảnh hoặc số đo nào bạn có"...+52 dòng nữa

Bức tranh in khắc cao su (linocut) ba màu vẽ một chú cáo đỏ màu chu sa đang bước qua đường ray xe lửa phủ đầy tuyết lúc chập tối, với đường ray và tà vẹt khắc nét đục màu đen đậm, chiếc đèn lồng tín hiệu đang sáng, những hàng cây bạch dương trụi lá, bầu trời khắc màu xanh xám đá, độ lệch bản in nhẹ và kết cấu giấy in màu kem.
Một chú cáo đỏ đang sải bước băng qua hai đường ray xe lửa phủ đầy tuyết lúc chập tối, được thể hiện dưới dạng bản in khắc cao su (linocut) chạm khắc thủ công. Chú cáo là mảng màu ấm duy nhất: một lớp mực màu đỏ chu sa (vermilion) duy nhất cho cơ thể và chiếc đuôi xù của nó, để lộ nền giấy trắng sắc nét tạo thành phần ngực và chóp đuôi. Đường ray chạy chéo từ góc dưới bên trái vào khoảng không phía xa, thanh ray thép và tà vẹt gỗ được khắc thành những đường màu đen đậm nét, một nửa bị vùi trong lớp tuyết được diễn tả bằng những vết đục khắc. Một cột tín hiệu nhỏ bằng gỗ với chiếc đèn lồng đang thắp sáng đứng ở bên phải, cùng một hàng cây bạch dương trụi lá và ngọn đồi thấp trải dọc đường chân trời. Bầu trời là lớp mực thứ hai màu xanh xám đá (slate-blue) nhạt với những vệt khắc ngang và một vài ngôi sao được khoét trắng hiện lên. Kết cấu tranh khắc linocut hiện rõ trên toàn bộ tác phẩm: những nét đục chữ V dày dặn, độ loang mực hơi không đều, sự lệch tầng màu (registration offset) mờ nhạt giữa lớp màu xanh và màu đỏ, cùng vân hạt của giấy in nghệ thuật màu kem với phần lề trắng hẹp. Bảng màu giới hạn chỉ gồm ba màu (đen, xanh xám đá, đỏ chu sa). Tâm trạng tĩnh mịch, giá lạnh và lắng đọng. Không có chữ, không có chữ ký, không có người.

Bức tranh màu bột gouache lì ấm cúng vẽ một chiếc xà lan kênh đào màu xanh rừng thẫm được cải tạo thành hiệu sách, với các thùng sách và hoa phong lữ trên mái, những kệ sách lộ ra qua các cửa sập mở, một chú mèo mướp đang ngủ ở mũi tàu, cây cầu đi bộ bằng gạch, những hàng liễu rủ và bóng phản chiếu buổi chiều muộn êm đềm trên mặt nước tĩnh lặng.
Một chiếc xà lan kênh đào dài và hẹp được cải tạo thành hiệu sách nổi, neo đậu dọc theo một con kênh yên tĩnh rợp bóng cây, được vẽ dưới dạng tranh minh họa màu bột gouache mềm mại. Chiếc xà lan có màu xanh rừng thẫm với đường viền màu kem và cánh cửa màu đỏ bo tròn; phần mái của nó đặt những thùng gỗ đựng sách, các chậu hoa phong lữ và một mái hiên kẻ sọc đang gấp gọn. Những ô cửa sổ dạng nắp sập mở ra làm lộ các kệ chất đầy gáy sách đủ màu sắc, và một giá vẽ bảng phấn nhỏ đặt trên lối đi ven kênh vẫn còn để trống. Một chú mèo mướp ngủ ngon lành trên chồng sách ở mũi tàu. Phía sau xà lan, một cây cầu đi bộ bằng gạch cổ kính uốn vòm qua mặt nước, những cành liễu rủ buông lơi, và một dãy nhà liền kề nhỏ hẹp nằm ở hậu cảnh. Ánh sáng chiều muộn êm đềm, những mảng sáng màu hồng đào ấm áp trên tường gạch và bóng phản chiếu dịu dàng của thân tàu màu xanh lên mặt nước phẳng lặng. Kết cấu màu bột gouache lì với những nét cọ hiện rõ, sắc màu đục hơi phấn, đường viền mềm mại, và bảng màu trầm gồm xanh xô thơm (sage), vàng đất (ochre), đỏ gạch và màu kem. Tâm trạng ấm cúng, thong thả, đậm chất truyện cổ tích. Không có chữ đọc được, không có người.

Bức ảnh chụp 35mm chân thực về một nhà kính nhỏ bằng kính tựa tường chất đầy cà chua bi, húng quế, ớt, dâu tây và cây giống trên một ban công căn hộ nhỏ hẹp, với những giọt sương ngưng tụ trên mặt kính, sàn gỗ tếch, ghế bistro và đường chân trời thành phố lấy nét mềm mại trong ánh nắng ngược chiều vàng rực.
Những dây cà chua trĩu quả cà chua bi màu đỏ và cam, húng quế rủ xuống, những cây ớt và các chậu dâu tây lấp đầy một nhà kính nhỏ tựa tường bằng kính và thép đen đặt trên ban công hẹp của một căn hộ thành phố vào giờ vàng (golden hour). Cửa nhà kính được chống mở; bên trong, ba tầng kệ gỗ nan thưa đặt những chậu đất nung (terracotta), các khay ươm hạt với mầm xanh tươi mới nhú, và một bình tưới cây nhỏ bằng đồng thau. Những giọt hơi nước ngưng tụ đọng trên các tấm kính phía dưới. Bên ngoài nhà kính, ban công có sàn gỗ tếch nhuốm màu thời gian, một chiếc ghế xếp kiểu bistro, và lan can kim loại màu đen quấn dây đèn trang trí chưa bật sáng. Phía xa ngoài lan can, đường chân trời thành phố lấy nét mềm mại (soft-focus) với những dãy nhà chung cư xây gạch và những mái nhà đang rực lên trong ánh nắng chiều ấm áp chiếu thấp. Chân thực như ảnh chụp (photorealistic), được chụp bằng máy ảnh full-frame với ống kính 35mm ở khẩu độ f/2.8, góc máy ngang tầm mắt, độ sâu trường ảnh nông lấy nét vào những quả cà chua, ánh sáng ngược tự nhiên màu vàng tạo viền sáng (rim light) trên lá cây và mặt kính, kết cấu và sự phản chiếu chân thực. Tâm trạng bình yên, tràn đầy hy vọng, mang hơi thở cuộc sống. Không có người, không có chữ, không có logo.
Xây dựng bản tóm tắt thiết kế sẵn sàng để tạo ảnh cho một cabin nhỏ tự cung tự cấp (off-grid) của nhà thiên văn học trên sống núi: định hướng khu đất, hình khối nhỏ gọn với mái vòm kính thiên văn, vật liệu và bảng màu, bố cục nội thất với bàn làm việc, thị kính và bếp củi, phương án chiếu sáng ban đêm bằng ánh sáng đỏ, các chi tiết thị giác cố định để tạo ảnh nhất quán và hai hạt giống prompt (prompt seeds).
Hãy đóng vai một nhà thiết kế ý tưởng kiến trúc chuyên về các khu nghỉ dưỡng nhỏ tự cung tự cấp (off-grid) dành cho các nhà thiên văn học nghiệp dư. Hãy giúp tôi xây dựng một bản tóm tắt thiết kế (design brief) rõ ràng, sẵn sàng dùng để tạo ảnh cho một cabin đài quan sát nhỏ trên núi. Trước tiên hãy hỏi tôi, trong một danh sách ngắn gọn, về: địa điểm (sống núi, độ cao, khí hậu, tuyết), đối tượng sử dụng (một người ngắm sao, một cặp đôi, một câu lạc bộ nhỏ), mức ngân sách (khiêm tốn, trung bình, cao), loại và kích thước kính thiên văn, cũng như bất kỳ tiện ích bắt buộc nào (bếp củi, giường tầng, đèn đỏ phòng tối, nguồn điện tự cung tự cấp). Sau đó hãy tạo bản tóm tắt thiết kế với các phần sau: 1. **Ý tưởng trong Một Câu**: cảm giác và mục đích của cabin. 2. **Khu đất và Định hướng**: vị trí cabin nằm trên sống núi, hướng cửa sổ chính nhìn về đâu, tầm nhìn thẳng tới phần tối nhất của bầu trời, giải pháp chắn gió và tuyết. 3. **Hình khối và Bố cục không gian**: diện tích mặt bằng (giữ ở mức dưới 40 mét vuông), hình dạng mái nhà, và mái vòm kính thiên văn xoay hoặc mái trượt mở (roll-off roof), kèm theo kích thước ước tính. 4. **Vật liệu và Bảng màu**: tấm ốp ngoại thất (ví dụ: ván gỗ tuyết tùng ghép dọc), vật liệu lợp mái, lớp hoàn thiện mái vòm, khung cửa sổ và gỗ nội thất, mỗi loại đều kèm theo màu sắc hoặc tông màu cụ thể. 5. **Bố cục Nội thất**: bàn làm việc để đặt bản đồ sao và máy tính xách tay, điểm tiếp cận kính thiên văn hoặc vị trí thị kính, bếp củi, chỗ ngồi, nơi lưu trữ thị kính và bản đồ sao, cùng một góc ngủ nhỏ nếu cần. 6. **Phương án Chiếu sáng Ban đêm**: đèn làm việc màu đỏ dịu, đèn chiếu thấp ấm áp, và cách ngăn ánh sáng lọt ra ngoài làm hỏng khả năng thích nghi với bóng tối của mắt. 7. **Các Chi tiết Thị giác Cố định**: danh sách đánh số từ 8 đến 10 chi tiết phải giữ nguyên hoàn toàn trong mọi bức ảnh chụp cabin này (hướng tấm ốp, màu mái vòm, số lượng và hình dạng cửa sổ, vị trí ống khói, hiên gỗ, lối đi, cây cối xung quanh, đường viền bóng dáng sống núi). 8. **Hạt giống Prompt Tạo ảnh (Image Prompt Seeds)**: hai đoạn prompt mở đầu ngắn gọn, một cho góc nhìn ngoại thất ban đêm từ sống núi dưới Dải Ngân Hà và một cho không gian nội thất ấm áp nhìn ra bầu trời sao. Mỗi prompt phải nêu lại các chi tiết thị giác cố định và đặt các vật thể chính ngay trong câu đầu tiên. Hãy giữ cho nội dung ngắn gọn, thực tế và có khả năng thi công. Tránh nêu tên các kiến trúc sư hoặc thương hiệu có thật. Hãy cảnh báo bất kỳ yếu tố nào không an toàn hoặc thiếu thực tế trong điều kiện khí hậu núi cao.

Góc nhìn ban đêm phơi sáng dài chân thực của một cabin nhỏ gọn ốp gỗ tuyết tùng dọc với mái vòm kính thiên văn màu trắng và ống khói bằng thép trên sống núi đá, một cửa sổ màu hổ phách ấm áp, hiên gỗ nhỏ, lối đi lát đá, những cây thông lùn và lớp tuyết cũ, dưới vùng lõi Dải Ngân Hà sống động. Bước 2 trong quy trình Cabin Đài quan sát trên Núi.
Một cabin nhỏ bằng gỗ tuyết tùng với mái vòm kính thiên văn xoay màu trắng trên mái nằm trên một sống núi đá dưới Dải Ngân Hà sống động uốn cong ngang bầu trời đêm. Cabin là một khối hộp một tầng nhỏ gọn được ốp bằng các tấm ván gỗ tuyết tùng màu nâu ấm ghép dọc, với mái dốc một chiều bằng kim loại tối màu thấp, một mái vòm bán cầu màu trắng mờ ở đầu bên trái với khe hẹp mở một nửa để lộ ống kính thiên văn vừa đủ nhìn thấy bên trong, và một ống khói bếp bằng thép đen ở đầu bên phải với một làn khói mỏng uốn lượn. Một cửa sổ hình chữ nhật rộng ở mặt trước phát sáng màu hổ phách ấm dịu, và một hiên gỗ nhỏ với lan can đơn giản hướng ra thung lũng. Một lối đi rải sỏi hẹp được viền bằng những viên đá phẳng dẫn lên hiên. Những cây thông lùn rải rác và các mảng tuyết cũ bao quanh cabin, cùng bóng dáng sống núi lởm chởm mờ dần vào một thung lũng sâu với những đỉnh núi xa xăm. Hàng ngàn ngôi sao sắc nét, vùng lõi thiên hà rực rỡ mang sắc tím dịu và vàng kim. Nhiếp ảnh thiên văn phơi sáng dài chân thực, góc rộng 16:9, góc máy thấp, không ô nhiễm ánh sáng, không có người, không có chữ.

Ảnh chụp chân thực ban đêm bên trong cùng một cabin đài quan sát bằng gỗ tuyết tùng: bàn làm việc với bản đồ sao và đèn đỏ, thị kính kính thiên văn hướng lên cửa sập mái vòm trắng, bếp củi bằng gang với ống khói, ghế bành bọc da và hai cửa sổ phía trước ánh màu hổ phách đóng khung sống núi cùng Dải Ngân Hà. Bước 3 trong quy trình Cabin Đài quan sát trên Núi.
Căn phòng nội thất của một cabin đài quan sát nhỏ bằng gỗ tuyết tùng vào ban đêm: tường và trần nhà bằng các tấm ván gỗ tuyết tùng ấm áp ghép dọc đồng bộ với ngoại thất, một chiếc bàn gỗ với các bản đồ sao đang mở và một chiếc đèn bàn phát sáng màu đỏ, thị kính kính thiên văn và ống kính thiên văn màu đen chếch lên qua cửa sập trần nhà tiến vào mái vòm bán cầu màu trắng phía trên, cùng một chiếc bếp củi nhỏ bằng gang màu đen với ánh lửa màu cam và ống khói bằng thép đen. Một chiếc ghế bành bọc da sờn cũ có phủ chăn len đặt giữa bàn làm việc và bếp củi; một kệ treo tường đựng các hộp thị kính và một quả cầu sao bằng đồng thau. Máy ảnh đặt bên trong nhìn về phía hai cửa sổ hình chữ nhật mặt trước đang tỏa ánh sáng màu hổ phách từ bên trong và đóng khung lấy một sống núi lởm chởm, những cây thông lùn, lớp tuyết cũ và Dải Ngân Hà rực rỡ phía xa. Ánh sáng pha trộn giữa đèn làm việc màu đỏ dịu và ánh lửa ấm áp; khung cửa sổ mang sắc thái mát mẻ và tối hơn ở phía bên ngoài. Nhiếp ảnh nội thất chân thực như ảnh chụp (photorealistic), ống kính 24mm, tỷ lệ 16:9, ấm cúng và tĩnh lặng, không có người, không có chữ.
Chọn mã trạng thái HTTP chính xác cho một phản hồi API từ mô tả ngắn gọn về những gì đã xảy ra (đã tạo, xác thực thất bại, không tìm thấy, xung đột, giới hạn tần suất, dịch vụ thượng nguồn ngừng hoạt động) và giải thích lý do, kèm theo các lỗi phổ biến cần tránh. Sử dụng khi thiết kế các endpoint REST, đánh giá việc xử lý lỗi API, hoặc khi phân vân "trường hợp này nên trả về 400, 404, 409 hay 422?". Bao gồm một trình gợi ý sử dụng thư viện chuẩn Python.
---
name: http-status-code-advisor
description: Chọn mã trạng thái HTTP chính xác cho một phản hồi API từ mô tả ngắn gọn về những gì đã xảy ra (đã tạo, xác thực thất bại, không tìm thấy, xung đột, giới hạn tần suất, dịch vụ thượng nguồn ngừng hoạt động) và giải thích lý do, kèm theo các lỗi phổ biến cần tránh. Sử dụng khi thiết kế các endpoint REST, đánh giá việc xử lý lỗi API, hoặc khi phân vân "trường hợp này nên trả về 400, 404, 409 hay 422?". Bao gồm một trình gợi ý sử dụng thư viện chuẩn Python.
---
# Cố vấn Mã trạng thái HTTP
Bạn giúp các nhà thiết kế và người đánh giá API chọn mã trạng thái HTTP phù hợp nhất với những gì thực sự đã xảy ra trong một yêu cầu (request). Bạn lập luận dựa trên phương thức yêu cầu (request method), kết quả đầu ra và bên nào có lỗi (máy khách, máy chủ hay dịch vụ thượng nguồn - upstream), đồng thời ưu tiên mã tiêu chuẩn cụ thể nhất mà máy khách có thể dựa vào đó để xử lý. Bạn tuân thủ ngữ nghĩa RFC 9110 và không tự bịa ra các mã phi tiêu chuẩn.
## Các tệp trong kỹ năng này
- `scripts/suggest_status.py` - ánh xạ một mô tả tình huống ngắn gọn (các từ khóa cộng với phương thức) sang một mã được đề xuất, các phương án thay thế và lý do dài một dòng (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/status-cheatsheet.md` - các mã quan trọng đối với API, được nhóm theo phân lớp (class), kèm theo thời điểm sử dụng từng mã
- `references/common-mistakes.md` - các cách dùng sai phổ biến (trả về 200 kèm nội dung báo lỗi, nhầm lẫn 401 với 403, dùng 404 cho danh sách trống, dùng 500 cho lỗi xác thực dữ liệu)
- `templates/decision-tree.md` - luồng câu hỏi từng bước để đi đến mã phù hợp
- `examples/api-scenarios.md` - các tình huống thực tế đã được giải quyết kèm theo mã được chọn và cấu trúc phản hồi
## Quy trình làm việc
### 1. Thu thập thông tin thực tế
Yêu cầu cung cấp, hoặc suy luận từ mã nguồn hay mô tả:
- Phương thức HTTP và endpoint (ví dụ: `POST /orders`)
- Chuyện gì đã xảy ra: thành công, thành công một phần, đã chấp nhận xử lý bất đồng bộ, sự cố đầu vào, sự cố xác thực/phân quyền, thiếu tài nguyên, xung đột trạng thái, giới hạn tần suất (rate limit), lỗi máy chủ hoặc dịch vụ phụ thuộc
- Có trả về phần thân phản hồi (response body) hay không, và có tài nguyên mới nào được tạo ra hay không
- Bất kỳ quy ước API hiện có nào mà nhóm đang tuân theo
### 2. Duyệt qua cây quyết định
Sử dụng `templates/decision-tree.md`. Quyết định phân lớp trước (2xx, 3xx, 4xx, 5xx), sau đó mới đến mã cụ thể. Bên nào gây ra lỗi sẽ quyết định việc chọn 4xx hay 5xx.
### 3. Chạy công cụ hỗ trợ (tùy chọn)
```
python3 scripts/suggest_status.py --method POST "created new order"
python3 scripts/suggest_status.py --method GET "user not logged in"
python3 scripts/suggest_status.py --json "email already exists"
```
Hãy coi kết quả của script như một dự đoán ban đầu; cần xác nhận lại với bảng tra cứu nhanh (cheatsheet).
### 4. Kiểm tra các lỗi phổ biến
Đối chiếu lựa chọn với `references/common-mistakes.md`, đặc biệt là 401 so với 403, 400 so với 422, và 404 so với danh sách trống trả về mã 200.
### 5. Trả lời
Trả về:
1. **Mã được đề xuất** và cụm từ lý do (ví dụ: `409 Conflict`)
2. **Tại sao** trong một hoặc hai câu, gắn liền với các thông tin thực tế
3. **Các phương án thay thế** đã cân nhắc và lý do chúng không được chọn
4. **Các header cần bao gồm** khi có liên quan (`Location` cho 201, `Retry-After` cho 429/503, `WWW-Authenticate` cho 401, `Allow` cho 405)
5. **Phác thảo phần thân báo lỗi** sử dụng chuẩn RFC 9457 Problem Details (`type`, `title`, `status`, `detail`, `instance`) đối với các mã 4xx/5xx
## Quy tắc
- Không bao giờ trả về 2xx cho một thao tác thất bại.
- Chỉ sử dụng 5xx khi máy chủ hoặc dịch vụ thượng nguồn mà nó phụ thuộc vào gặp sự cố, không bao giờ dùng cho dữ liệu đầu vào sai từ phía máy khách.
- Giữ sự nhất quán với các quy ước hiện có của nhóm nếu chúng hợp lý; chỉ cần nhắc đến lựa chọn tiêu chuẩn một lần nếu có sự khác biệt.
- Nếu yêu cầu bảo mật đòi hỏi phải che giấu sự tồn tại của một tài nguyên, hãy nói rõ điều đó khi đề xuất dùng 404 thay vì 403.
FILE:references/common-mistakes.md
# Các lỗi Mã trạng thái phổ biến
1. **Trả về 200 kèm phần thân báo lỗi** (`{"success": false}`). Các máy khách, proxy, bộ nhớ đệm (caches) và hệ thống giám sát đều coi 200 là thành công. Hãy sử dụng đúng mã 4xx/5xx và đưa chi tiết lỗi vào phần thân (body).
2. **Nhầm lẫn giữa 401 và 403.** 401 có nghĩa là "bạn là ai?" (thiếu thông tin xác thực hoặc thông tin xác thực sai). 403 có nghĩa là "tôi biết bạn là ai, và câu trả lời là không được phép." Một người dùng đã đăng nhập nhưng không có quyền sẽ nhận được 403, không phải 401.
3. **Dùng 500 cho đầu vào không hợp lệ.** Lỗi xác thực dữ liệu (validation) là lỗi của phía máy khách: hãy dùng 400 hoặc 422. Dành riêng mã 500 cho các lỗi phần mềm (bugs) và sự cố máy chủ ngoài dự kiến. Sự gia tăng đột biến của mã 500 phải mang ý nghĩa là có thứ gì đó đang bị hỏng ở phía hệ thống của bạn.
4. **Dùng 404 cho một tập hợp trống.** `GET /users?role=admin` khi không có kết quả khớp phải là `200` kèm theo `[]`. Mã 404 chỉ dành cho một tài nguyên cụ thể bị thiếu, chẳng hạn như `/users/9999`.
5. **Dùng 400 cho mọi thứ.** Ưu tiên sử dụng mã cụ thể khi nó giúp máy khách biết cách xử lý: 409 cho dữ liệu trùng lặp, 413 cho dữ liệu vượt quá kích thước, 415 cho sai kiểu nội dung (content type), 429 cho giới hạn tần suất (rate limits).
6. **Dùng 201 mà không có `Location`.** Khi bạn tạo một tài nguyên có URL riêng, hãy gửi kèm `Location: /orders/123`.
7. **Dùng 204 kèm theo phần thân (body).** Mã 204 không được phép chứa nội dung trả về. Nếu bạn trả về tài nguyên, hãy sử dụng 200.
8. **Dùng 302 trong API.** Các máy khách có thể tự động đổi phương thức POST thành GET. Hãy sử dụng 307 hoặc 308 khi cần giữ nguyên phương thức gọi.
9. **503 so với 500.** 503 có nghĩa là sự cố tạm thời và có thể thử lại (đang bảo trì, quá tải), lý tưởng nhất là đi kèm `Retry-After`. 500 có nghĩa là một lỗi bất ngờ không lường trước.
10. **Làm lộ sự tồn tại của tài nguyên qua mã 403.** Đối với các tài nguyên riêng tư, việc trả về 404 cho người dùng không có quyền có thể ngăn chặn hành vi dò quét liệt kê (enumeration). Hãy quyết định một cách có chủ đích và ghi lại vào tài liệu.
11. **Sử dụng 422 và 400 thiếu nhất quán.** Hãy chọn một quy tắc (400 = không thể phân tích cú pháp, 422 = đã phân tích cú pháp nhưng giá trị không hợp lệ) và áp dụng nó trên mọi endpoint.
12. **Các mã phi tiêu chuẩn** (ví dụ: dùng 418 trên môi trường production, 499, các mã 6xx tự chế). Nhiều máy khách và proxy xử lý chúng rất kém. Hãy tuân thủ các mã đã được đăng ký chính thức.
FILE:references/status-cheatsheet.md
# Bảng tra cứu nhanh Mã trạng thái HTTP cho API
Dựa trên RFC 9110 (Ngữ nghĩa HTTP), RFC 6585 (429), và RFC 9457 (Chi tiết sự cố - Problem Details).
## 2xx Thành công (Success)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 200 | OK | Yêu cầu thành công và phần thân chứa kết quả (GET, hoặc PUT/PATCH trả về tài nguyên đã cập nhật). |
| 201 | Created | Một tài nguyên mới đã được tạo. Gửi kèm `Location` chứa URL của nó; phần thân thường chứa tài nguyên đó. |
| 202 | Accepted | Công việc đã được đưa vào hàng đợi và sẽ hoàn tất sau. Trả về một URL theo dõi trạng thái hoặc job id. |
| 204 | No Content | Thành công nhưng không có phần thân trả về (DELETE, hoặc PUT/PATCH khi bạn không trả về dữ liệu gì). |
| 206 | Partial Content | Phục vụ một dải byte (byte range) để phản hồi lại header `Range`. |
## 3xx Chuyển hướng (Redirection)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 301 | Moved Permanently | Tài nguyên có một URL cố định mới (phương thức có thể bị đổi thành GET trên các máy khách cũ). |
| 304 | Not Modified | Yêu cầu GET có điều kiện (`If-None-Match` / `If-Modified-Since`) và không có gì thay đổi. |
| 307 | Temporary Redirect | Di chuyển tạm thời; máy khách phải lặp lại yêu cầu với cùng phương thức và phần thân. |
| 308 | Permanent Redirect | Di chuyển vĩnh viễn; giữ nguyên cùng phương thức và phần thân. |
## 4xx Lỗi phía máy khách (Client errors)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 400 | Bad Request | Cú pháp sai định dạng: JSON không hợp lệ, sai cấu trúc nội dung, thiếu tham số truy vấn (query param) bắt buộc. |
| 401 | Unauthorized | Không có thông tin xác thực hoặc thông tin xác thực không hợp lệ/đã hết hạn. Gửi kèm `WWW-Authenticate`. |
| 403 | Forbidden | Đã xác thực danh tính, nhưng không được phép thực hiện thao tác này. |
| 404 | Not Found | Tài nguyên không tồn tại (hoặc bạn muốn ẩn việc nó có tồn tại). |
| 405 | Method Not Allowed | URL có tồn tại nhưng không hỗ trợ phương thức này. Gửi kèm `Allow`. |
| 406 | Not Acceptable | Không thể tạo ra bất kỳ định dạng nào được liệt kê trong `Accept`. |
| 409 | Conflict | Yêu cầu xung đột với trạng thái hiện tại: trùng khóa duy nhất (unique key), xung đột phiên bản, chuyển đổi trạng thái không hợp lệ. |
| 410 | Gone | Tài nguyên từng tồn tại và đã bị xóa vĩnh viễn. |
| 412 | Precondition Failed | Điều kiện tiên quyết `If-Match` / `If-Unmodified-Since` thất bại (khóa lạc quan - optimistic locking). |
| 413 | Content Too Large | Phần thân vượt quá giới hạn kích thước. |
| 415 | Unsupported Media Type | `Content-Type` không được hỗ trợ. |
| 422 | Unprocessable Content | Cú pháp hoàn toàn ổn nhưng giá trị không vượt qua được bước kiểm tra hợp lệ (email sai, ngày kết thúc trước ngày bắt đầu). |
| 428 | Precondition Required | Máy chủ yêu cầu một truy vấn có điều kiện để ngăn chặn việc ghi đè làm mất dữ liệu cập nhật. |
| 429 | Too Many Requests | Vượt quá giới hạn tần suất gọi. Gửi kèm `Retry-After`. |
## 5xx Lỗi phía máy chủ (Server errors)
| Mã | Tên | Sử dụng khi |
|---|---|---|
| 500 | Internal Server Error | Sự cố bất ngờ trong mã nguồn của bạn. |
| 501 | Not Implemented | Máy chủ hoàn toàn không hỗ trợ chức năng này. |
| 502 | Bad Gateway | Một dịch vụ thượng nguồn trả về phản hồi không hợp lệ. |
| 503 | Service Unavailable | Tạm thời quá tải hoặc đang bảo trì. Gửi kèm `Retry-After` nếu biết thời gian. |
| 504 | Gateway Timeout | Một dịch vụ thượng nguồn không phản hồi kịp thời gian quy định. |
FILE:templates/decision-tree.md
# Cây quyết định Mã trạng thái
Trả lời theo thứ tự. Dừng lại ở kết quả khớp đầu tiên.
1. **Yêu cầu có thành công không?**
- Có, công việc đã hoàn tất và một tài nguyên mới được tạo ra -> **201** (+ `Location`)
- Có, công việc đã được chấp nhận nhưng sẽ hoàn tất sau -> **202** (+ URL trạng thái)
- Có, không có gì để trả về -> **204**
- Có, có trả về dữ liệu -> **200**
- Không -> chuyển sang câu 2
2. **Máy khách có đang được chuyển hướng không?**
- GET có điều kiện, dữ liệu không đổi -> **304**
- Đã di chuyển, phải giữ nguyên phương thức -> **307** (tạm thời) / **308** (vĩnh viễn)
- Không -> chuyển sang câu 3
3. **Đó có phải là vấn đề xác thực danh tính (authentication) không?**
- Không có thông tin xác thực, hoặc token không hợp lệ / đã hết hạn -> **401** (+ `WWW-Authenticate`)
- Không -> chuyển sang câu 4
4. **Người gọi đã xác thực có bị từ chối quyền thực hiện không?**
- Có, và có thể tiết lộ sự tồn tại của tài nguyên -> **403**
- Có, nhưng phải che giấu sự tồn tại của tài nguyên -> **404**
- Không -> chuyển sang câu 5
5. **Đích đến có bị thiếu hoặc phương thức có bị sai không?**
- Tài nguyên không tồn tại -> **404**
- Tài nguyên đã bị xóa vĩnh viễn -> **410**
- URL tồn tại, nhưng phương thức không được hỗ trợ -> **405** (+ `Allow`)
- Không -> chuyển sang câu 6
6. **Bản thân yêu cầu có ở trạng thái không thể sử dụng được không?**
- Không thể phân tích cú pháp (JSON lỗi, sai kiểu dữ liệu) -> **400**
- Sai `Content-Type` -> **415**
- Phần thân quá lớn -> **413**
- Không thể đáp ứng `Accept` -> **406**
- Không -> chuyển sang câu 7
7. **Đầu vào hợp lệ về cú pháp nhưng bị từ chối?**
- Không vượt qua kiểm tra quy tắc nghiệp vụ -> **422**
- Xung đột với trạng thái hiện tại (trùng lặp, sai phiên bản, máy trạng thái) -> **409**
- Điều kiện tiên quyết `If-Match` thất bại -> **412**
- Quá nhiều yêu cầu -> **429** (+ `Retry-After`)
- Không -> chuyển sang câu 8
8. **Sự cố phía máy chủ**
- Dịch vụ thượng nguồn trả về dữ liệu rác/lỗi -> **502**
- Dịch vụ thượng nguồn bị hết thời gian chờ (timed out) -> **504**
- Tạm thời không khả dụng / đang bảo trì -> **503** (+ `Retry-After`)
- Tính năng hoàn toàn không được hỗ trợ -> **501**
- Lỗi phần mềm (bug) bất ngờ -> **500**
## Mẫu câu trả lời
```
Recommended: <code> <reason phrase>
Why: <1-2 sentences>
Alternatives: <code> - <why not>; <code> - <why not>
Headers: <header: value>
Body (Problem Details):
{
"type": "https://example.com/problems/<slug>",
"title": "<short title>",
"status": <code>,
"detail": "<specific explanation>",
"instance": "<request path or id>"
}
```
FILE:examples/api-scenarios.md
# Các tình huống API thực tế
## 1. Đăng ký bằng một email đã tồn tại
`POST /users` với `{"email": "ana@example.com"}` và email đó đã có người dùng.
- **Đề xuất:** 409 Conflict
- **Tại sao:** Đầu vào đúng định dạng và hợp lệ, nhưng nó xung đột với trạng thái hiện có (một khóa duy nhất).
- **Phương án thay thế:** 422 (dùng được nếu nhóm của bạn sử dụng 422 cho mọi lỗi vi phạm quy tắc, nhưng 409 cho máy khách biết vấn đề cụ thể nằm ở trạng thái dữ liệu); 400 (quá mơ hồ).
- **Phần thân (Body):**
```json
{"type": "https://example.com/problems/email-taken", "title": "Email already registered", "status": 409, "detail": "An account with this email already exists.", "instance": "/users"}
```
## 2. Đã tạo đơn hàng
`POST /orders` thành công và trả về đơn hàng đó.
- **Đề xuất:** 201 Created, `Location: /orders/8812`
- **Phương án thay thế:** 200 (làm mất tín hiệu "đã tạo mới"), 202 (chỉ dùng nếu quá trình xử lý là bất đồng bộ và đơn hàng chưa được lưu bền vững).
## 3. Xuất báo cáo được đưa vào hàng đợi
`POST /reports/exports` bắt đầu một tác vụ kéo dài 5 phút.
- **Đề xuất:** 202 Accepted kèm theo `{"jobId": "exp_41", "statusUrl": "/reports/exports/exp_41"}`
## 4. Người xem (Viewer) cố gắng xóa một dự án
Người dùng đã đăng nhập với vai trò chỉ đọc gọi `DELETE /projects/7`.
- **Đề xuất:** 403 Forbidden
- **Tại sao:** Danh tính đã được xác định; thứ bị thiếu là quyền hạn. Mã 401 sẽ gợi ý sai rằng họ nên đăng nhập lại.
## 5. Access token đã hết hạn
`GET /me` với một JWT đã hết hạn.
- **Đề xuất:** 401 Unauthorized, `WWW-Authenticate: Bearer error="invalid_token"`
## 6. Tìm kiếm không có kết quả khớp
`GET /products?q=zzzz`
- **Đề xuất:** 200 OK kèm theo `{"items": [], "total": 0}`
- **Tại sao:** Tập hợp tài nguyên có tồn tại; nó chỉ đang trống mà thôi. Mã 404 sẽ làm lỗi các máy khách vốn coi 404 là một lỗi thực sự.
## 7. Ngày bắt đầu sau ngày kết thúc
`POST /bookings` với `start=2026-10-20`, `end=2026-10-18`.
- **Đề xuất:** 422 Unprocessable Content
- **Phương án thay thế:** 400 (chấp nhận được nếu quy ước của bạn gộp chung lỗi phân tích cú pháp và lỗi kiểm tra tính hợp lệ).
## 8. Nhà cung cấp thanh toán hết thời gian chờ
`POST /checkout` và cổng thanh toán không trả lời trong vòng 10 giây.
- **Đề xuất:** 504 Gateway Timeout
- **Phương án thay thế:** 502 (nếu cổng thanh toán có trả lời nhưng bằng một phản hồi không hợp lệ), 503 (nếu bạn đang chủ động giảm tải).
## 9. Quá nhiều lần thử đăng nhập
- **Đề xuất:** 429 Too Many Requests, `Retry-After: 60`
## 10. Cập nhật dữ liệu cũ (Stale update)
`PUT /docs/3` với `If-Match: "v4"` nhưng phiên bản hiện tại là `"v5"`.
- **Đề xuất:** 412 Precondition Failed
FILE:scripts/suggest_status.py
#!/usr/bin/env python3
"""Suggest an HTTP status code from a short scenario description.
Usage:
python3 suggest_status.py [--method METHOD] [--json] "scenario text"
python3 suggest_status.py --self-test
Keyword-based and deliberately simple: it gives a first guess plus
alternatives. Always confirm against references/status-cheatsheet.md.
Python 3 standard library only.
"""
import argparse
import json
import re
import sys
# Ordered rules: first match wins. Each rule: (keywords, code, reason, alternatives, headers)
RULES = [
(["rate limit", "too many", "throttl", "quota exceeded"], 429,
"Client exceeded a rate limit or quota.", [503], ["Retry-After"]),
(["not logged in", "no token", "missing token", "expired token", "invalid token",
"unauthenticated", "no credentials", "bad credentials", "login required"], 401,
"Credentials are missing or invalid.", [403], ["WWW-Authenticate"]),
(["no permission", "not allowed", "forbidden", "insufficient role", "not authorized",
"read-only user", "not the owner"], 403,
"Caller is authenticated but lacks permission.", [404], []),
(["already exists", "duplicate", "conflict", "version mismatch", "already taken",
"invalid state", "state transition"], 409,
"Request conflicts with the current state of the resource.", [422, 412], []),
(["precondition", "if-match", "etag mismatch", "stale"], 412,
"A conditional request header did not match.", [409], ["ETag"]),
(["too large", "payload size", "file too big", "body too big"], 413,
"Request body exceeds the allowed size.", [400], []),
(["unsupported media", "content-type", "wrong content type"], 415,
"Content-Type is not supported.", [400], ["Accept"]),
(["malformed", "invalid json", "parse error", "cannot parse", "syntax error",
"missing required"], 400,
"Request cannot be parsed or is missing required parts.", [422], []),
(["validation", "invalid email", "out of range", "must be", "before start",
"invalid value", "fails rule"], 422,
"Request is well-formed but values fail validation.", [400], []),
(["method not allowed", "wrong method", "unsupported method"], 405,
"The URL exists but does not support this method.", [404], ["Allow"]),
(["gone", "permanently deleted", "removed permanently"], 410,
"Resource existed and was permanently removed.", [404], []),
(["not found", "does not exist", "no such", "unknown id"], 404,
"Target resource does not exist.", [410], []),
(["upstream timeout", "gateway timeout", "timed out", "timeout"], 504,
"An upstream dependency did not respond in time.", [502, 503], []),
(["bad gateway", "upstream error", "invalid upstream", "upstream returned"], 502,
"An upstream dependency returned an invalid response.", [504], []),
(["maintenance", "overloaded", "unavailable", "down for"], 503,
"Service is temporarily unavailable.", [500], ["Retry-After"]),
(["not implemented", "unsupported feature"], 501,
"The server does not support this functionality.", [405], []),
(["crash", "exception", "bug", "unexpected error", "internal error"], 500,
"Unexpected server-side failure.", [503], []),
(["queued", "async", "background job", "accepted for processing", "will process"], 202,
"Work was accepted and will complete later.", [201], ["Location"]),
(["created", "new resource", "signed up", "registered"], 201,
"A new resource was created.", [200, 202], ["Location"]),
(["deleted", "no content", "nothing to return"], 204,
"Success with no response body.", [200], []),
(["not modified", "unchanged", "cache hit"], 304,
"Conditional request and resource is unchanged.", [200], ["ETag"]),
(["empty list", "no results", "no matches", "empty collection"], 200,
"Collection exists but is empty: return 200 with an empty list.", [404], []),
(["success", "ok", "returned", "fetched", "updated"], 200,
"Request succeeded and returns content.", [204], []),
]
REASONS = {
200: "OK", 201: "Created", 202: "Accepted", 204: "No Content", 304: "Not Modified",
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
405: "Method Not Allowed", 409: "Conflict", 410: "Gone", 412: "Precondition Failed",
413: "Content Too Large", 415: "Unsupported Media Type", 422: "Unprocessable Content",
429: "Too Many Requests", 500: "Internal Server Error", 501: "Not Implemented",
502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
}
def suggest(text, method=None):
t = " " + re.sub(r"\s+", " ", text.lower()) + " "
for keywords, code, reason, alts, headers in RULES:
for kw in keywords:
# word-boundary match for very short keywords like "ok"
if len(kw) <= 3:
if re.search(r"\b" + re.escape(kw) + r"\b", t):
break
elif kw in t:
break
else:
continue
m = (method or "").upper()
notes = []
if code == 200 and m == "DELETE":
code, reason, alts = 204, "DELETE succeeded; usually no body.", [200]
if code == 201 and m and m not in ("POST", "PUT"):
notes.append("201 is normally returned by POST or PUT.")
if code == 204 and m == "GET":
notes.append("GET rarely returns 204; prefer 200 with a body.")
return {
"code": code,
"reason_phrase": REASONS.get(code, ""),
"why": reason,
"alternatives": [f"{a} {REASONS.get(a, '')}".strip() for a in alts],
"headers": headers,
"matched_keyword": kw,
"notes": notes,
}
return {
"code": None,
"reason_phrase": "",
"why": "No rule matched. Walk templates/decision-tree.md manually.",
"alternatives": [],
"headers": [],
"matched_keyword": None,
"notes": [],
}
def self_test():
cases = [
("created new order", "POST", 201),
("user not logged in", "GET", 401),
("read-only user tries to delete", "DELETE", 403),
("email already exists", "POST", 409),
("search returned no results", "GET", 200),
("end date before start date validation", "POST", 422),
("invalid json body", "POST", 400),
("payment gateway timed out", "POST", 504),
("too many login attempts", "POST", 429),
("order id does not exist", "GET", 404),
("report export queued as background job", "POST", 202),
("record deleted", "DELETE", 204),
("unexpected exception in handler", "GET", 500),
("something odd happened", "GET", None),
]
failed = 0
for text, method, expected in cases:
got = suggest(text, method)["code"]
ok = got == expected
failed += not ok
print(f"{'PASS' if ok else 'FAIL'} {method:6} {text!r} -> {got} (expected {expected})")
print(f"\n{len(cases) - failed}/{len(cases)} passed")
return 1 if failed else 0
def main(argv=None):
p = argparse.ArgumentParser(description="Suggest an HTTP status code for an API scenario.")
p.add_argument("scenario", nargs="*", help="Short description of what happened")
p.add_argument("--method", help="HTTP method, e.g. POST")
p.add_argument("--json", action="store_true", help="Print JSON output")
p.add_argument("--self-test", action="store_true", help="Run built-in tests")
a = p.parse_args(argv)
if a.self_test:
return self_test()
if not a.scenario:
p.error("scenario text is required (or use --self-test)")
r = suggest(" ".join(a.scenario), a.method)
if a.json:
print(json.dumps(r, indent=2))
return 0
if r["code"] is None:
print(r["why"])
return 2
print(f"Recommended: {r['code']} {r['reason_phrase']}")
print(f"Why: {r['why']}")
if r["alternatives"]:
print("Alternatives: " + ", ".join(r["alternatives"]))
if r["headers"]:
print("Headers: " + ", ".join(r["headers"]))
for n in r["notes"]:
print("Note: " + n)
return 0
if __name__ == "__main__":
sys.exit(main())