← Phase 1 Phase 1 • Day 2 Reboot

Local SE Agent Masterclass

MacBook M3 16GB • Complete Implementation Standard
📍 ส่วนที่ 1: หมุดตั้งโจทย์ & วัตถุประสงค์หลัก (Title Connection)

Chapter 2 — JSON Mode & Structured Outputs

ทำไมต้องชื่อนี้และเรียนไปเพื่ออะไร?

ใน Day 1 เราเชื่อมท่อสื่อสารรับข้อความสตริงกลับมาได้แล้ว แต่ในโลกของ Software Engineering ถ้า LLM ยังตอบเป็นข้อความแชทอิสระ (Free-form text) เช่น "ผมคิดว่าฟังก์ชันนี้มีบั๊กนะครับ ลองแก้แบบนี้ดู..." โปรแกรม Python ของเราจะไม่มีทางนำผลลัพธ์ไปเขียน if-else, ตรวจเช็คสถานะ, หรือส่งต่อให้ Tool อื่นทำงานต่ออัตโนมัติได้เลย

Day 2 จึงเป็นการใส่บังเหียนให้กับโมเดลด้วย JSON Mode & Structured Output บังคับให้ Ollama ตอบกลับมาในรูปของ Schema ที่เคร่งครัด พร้อมทั้งเขียน Parser ชั้นดีที่แปลงสตริง JSON ให้เป็น Python Dictionary หรือ Data Model ที่แข็งแรงพร้อมรับมือกับ Malformed Output

✓ Day 1: Setup & Ollama API Day 2: JSON Mode & Structured Output Day 3-5: Base Tools Day 6+: Autonomous ReAct Loop
📂 ส่วนที่ 2: ผังโครงสร้างโปรเจกต์จริง & ตำแหน่งไฟล์ (Project File Structure)

โค้ดของ Day 2 จะต่อยอดจากโฟลเดอร์ se_agent/ ของ Day 1 โดยมีการอัปเดต core/client.py ให้รองรับ JSON format และเพิ่มโมดูล core/parser.py สำหรับจัดการ Validation และ Error Recovery:

se_agent/
├── requirements.txt                       # ระบุ ollama>=0.4.0 (ใช้ตัวเดิมจาก Day 1)
├── test_connection.py                    # สคริปต์ทดสอบ Day 1
├── test_structured_output.py             # [ไฟล์ใหม่ Day 2] ทดสอบยิง Prompt และแกะผลลัพธ์เป็น Dictionary
├── core/
│   ├── __init__.py
│   ├── client.py                          # [อัปเกรด Day 2] เพิ่มพารามิเตอร์ format="json" ในเมธอด chat
│   └── parser.py                          # [ไฟล์ใหม่ Day 2] ตัวถอดรหัส JSON พร้อมกลไก Clean Markdown Codeblocks
└── main.py
📍 core/parser.py ทำหน้าที่อะไร?

ทำหน้าที่เป็น Defensive Parsing Layer ดักจับกรณีที่ LLM เผลอห่อข้อความมาใน ```json ... ``` หรือข้อความเกริ่นนำ แล้วแปลงสตริงเป็น Python dict อย่างปลอดภัย หาก JSON พังจะโยน Custom Exception ให้ระบบส่งแก้ตัว

📍 test_structured_output.py ทำหน้าที่อะไร?

เป็นสคริปต์รันบน Terminal เพื่อทดสอบกระบวนการตั้งแต่ส่ง Schema กำหนดใน System Prompt ➔ รับ JSON ดิบ ➔ ผ่าน Parser ➔ นำฟิลด์ผลลัพธ์ไปประมวลผลต่อในตรรกะโปรแกรมจริง

🏛️ ส่วนที่ 3: สถาปัตยกรรม Structured Output & Validation Flow (Mental Model)
📝
1. Schema-Injected Prompt
ระบุคีย์ที่ต้องการใน System Prompt + `format="json"`
➔
🦙
2. Constrained Sampling
Ollama บังคับโทเค็นให้ออกเฉพาะไวยากรณ์ `{ ... }`
➔
🛡️
3. Robust Parser
ถอด Markdown fence ตัด whitespace แปลงเป็น Dict
ทำไมต้องใส่ทั้ง `format="json"` และ Schema ใน Prompt?
  • `format="json"` ของ Ollama: บังคับระดับ Token Sampler ให้โมเดลตอบตามไวยากรณ์ JSON ที่ valid เสมอ (ปิดปีกกาถูกต้อง ไม่หลุดเป็นข้อความอิสระ)
  • Schema ใน System Prompt: กำหนด **Semantic Structure** บอกโมเดลว่าต้องมี key ชื่ออะไรบ้าง ชนิดข้อมูลเป็นอย่างไร (เช่น `{"has_bug": bool, "severity": str}`)
💻 ส่วนที่ 4: ซอร์สโค้ดจริง & คำอธิบายทีละบรรทัด (Line-by-Line Implementation)
📄 1. core/client.py (อัปเดตให้รองรับ format="json") แก้ไขจาก Day 1
import ollama
from typing import List, Dict, Any, Optional

class OllamaLLMClient:
    """
    Wrapper ควบคุมการสื่อสารกับโมเดลภาษาในเครื่องผ่าน Ollama Daemon
    """
    def __init__(self, model_name: str = "qwen2.5-coder:7b", host: Optional[str] = None):
        self.model_name = model_name
        self.client = ollama.Client(host=host) if host else ollama.Client()

    def check_health(self) -> bool:
        """ตรวจสอบว่า daemon พร้อมใช้งานและมีโมเดลเป้าหมายอยู่หรือไม่"""
        try:
            models_response = self.client.list()
            available_models = [m.model for m in models_response.models]
            return any(self.model_name in name for name in available_models)
        except Exception:
            return False

    def chat(
        self, 
        messages: List[Dict[str, str]], 
        temperature: float = 0.2,
        json_mode: bool = False
    ) -> str:
        """
        ส่งประวัติบทสนทนาและรับข้อความตอบกลับจากโมเดล
        :param json_mode: หากเป็น True จะส่ง format="json" บังคับให้โมเดลตอบเฉพาะ JSON ไวยากรณ์เป๊ะ
        """
        try:
            # กำหนด parameters สำหรับการส่ง chat
            chat_kwargs: Dict[str, Any] = {
                "model": self.model_name,
                "messages": messages,
                "options": {"temperature": temperature}
            }

            # [จุดสำคัญของ Day 2]: ถ้าเปิด json_mode ให้ระบุ format="json"
            if json_mode:
                chat_kwargs["format"] = "json"

            response = self.client.chat(**chat_kwargs)
            return response.message.content
        except ollama.ResponseError as e:
            raise RuntimeError(f"Ollama Response Error (Status {e.status_code}): {e.error}")
        except Exception as e:
            raise ConnectionError(f"ไม่สามารถเชื่อมต่อ Ollama daemon ที่ localhost:11434 ได้: {str(e)}")
📄 2. core/parser.py (ไฟล์ใหม่ประจำ Day 2) ตัวถอดรหัส JSON ป้องกันความผิดพลาด
import json
import re
from typing import Dict, Any

class JSONDecodeError(Exception):
    """Custom exception สำหรับข้อผิดพลาดเมื่อผลลัพธ์ไม่สามารถ parse เป็น JSON ได้"""
    pass

class StructuredOutputParser:
    """
    ตัวจัดการถอดรหัสข้อความจากโมเดลให้กลายเป็น Python Dictionary
    พร้อมระบบ Clean Markdown Code blocks (```json ... ```)
    """

    @staticmethod
    def clean_text(raw_text: str) -> str:
        """
        ตัด Markdown code fences ออก เช่น ```json ... ``` เพื่อให้ได้สตริง JSON แท้
        """
        text = raw_text.strip()
        # ตัดแท็ก ```json หรือ ``` ที่หัวและท้ายถ้าโมเดลเผลอส่งติดมา
        if text.startswith("```"):
            text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
            text = re.sub(r"\s*```$", "", text)
        return text.strip()

    @classmethod
    def parse(cls, raw_text: str) -> Dict[str, Any]:
        """
        แปลงสตริงผลลัพธ์จาก LLM เป็น Python Dictionary
        """
        cleaned = cls.clean_text(raw_text)
        try:
            parsed_data = json.loads(cleaned)
            if not isinstance(parsed_data, dict):
                raise JSONDecodeError(f"ผลลัพธ์ไม่ได้อยู่ในรูปแบบ JSON Object (dict) แต่ได้: {type(parsed_data).__name__}")
            return parsed_data
        except json.JSONDecodeError as err:
            raise JSONDecodeError(
                f"ไม่สามารถแปลงเป็น JSON ได้: {str(err)}\n"
                f"ข้อความดิบที่ได้รับมา:\n{raw_text}"
            )
ชำแหละไวยากรณ์สำคัญใน core/parser.py
re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)

แม้โมเดลรุ่นใหม่จะอยู่ใน JSON Mode บางครั้งโมเดลก็อาจติดนิสัยห่อข้อความด้วย Markdown fence การใช้ Regex ตัดทิ้งช่วยป้องกัน json.JSONDecodeError ได้อย่างเด็ดขาด

if not isinstance(parsed_data, dict):

ป้องกันกรณีโมเดลส่ง JSON array เช่น ["item1", "item2"] หรือค่าเดี่ยวๆ มา ซึ่งจะทำให้โค้ดส่วนอื่นที่เรียกใช้แบบคีย์ data["status"] เกิดข้อผิดพลาด

chat_kwargs["format"] = "json"

ส่งคำสั่งไปยัง Ollama C++ daemon ให้เปิดใช้งาน Constrained Grammar Token Sampling การันตีว่าจะไม่มีการหลุดไวยากรณ์ JSON แม้แต่อักขระเดียว

📄 3. test_structured_output.py (สคริปต์ทดสอบผลลัพธ์จริง) สคริปต์รันบน Terminal
from core.client import OllamaLLMClient
from core.parser import StructuredOutputParser, JSONDecodeError

def main():
    print("🚀 เริ่มทดสอบ Day 2: Structured Outputs & JSON Mode บน Mac M3...")
    client = OllamaLLMClient(model_name="qwen2.5-coder:7b")

    if not client.check_health():
        print("❌ ข้อผิดพลาด: ไม่พบ Ollama Daemon หรือโมเดล qwen2.5-coder:7b")
        return

    # 1. กำหนด System Prompt บังคับ Schema ที่ชัดเจน
    system_instruction = (
        "You are an automated Code Reviewer. Analyze code and respond ONLY in valid JSON format.\n"
        "Schema:\n"
        "{\n"
        '  "has_bug": boolean,\n'
        '  "error_type": string,\n'
        '  "line_number": integer,\n'
        '  "suggested_fix": string\n'
        "}"
    )

    sample_bad_code = """
    def calculate_average(numbers):
        total = sum(numbers)
        return total / len(numbers) # บั๊ก: numbers อาจเป็น list ว่าง (ZeroDivisionError)
    """

    messages = [
        {"role": "system", "content": system_instruction},
        {"role": "user", "content": f"ตรวจหาบั๊กในโค้ดนี้:\n{sample_bad_code}"}
    ]

    print("📡 กำลังส่ง Prompt พร้อมเปิดใช้งาน json_mode=True...")
    raw_response = client.chat(messages, temperature=0.1, json_mode=True)
    
    print("\n--- ผลลัพธ์สตริงดิบจาก Ollama ---")
    print(raw_response)
    print("---------------------------------")

    # 2. นำไปผ่าน Structured Parser
    try:
        parsed_result = StructuredOutputParser.parse(raw_response)
        print("\n✅ Parsing สำเร็จ! ข้อมูลกลายเป็น Python Dictionary เรียบร้อย:")
        print(f"  🐞 Has Bug: {parsed_result.get('has_bug')}")
        print(f"  🏷️ Error Type: {parsed_result.get('error_type')}")
        print(f"  📍 Line Number: {parsed_result.get('line_number')}")
        print(f"  💡 Suggested Fix: {parsed_result.get('suggested_fix')}")

        # ยืนยันว่าโปรแกรมสามารถนำค่าไปเขียน Logic ต่อได้จริง
        if parsed_result.get("has_bug"):
            print("\n🎉 ยินดีด้วยครับ! โปรแกรมสามารถนำฟิลด์ 'has_bug' ไปต่อยอดใน ReAct Loop ของ Day 3 ได้แล้ว!")

    except JSONDecodeError as err:
        print(f"❌ ล้มเหลวในการ Parse: {err}")

if __name__ == "__main__":
    main()
⚡ ส่วนที่ 5: ปฏิบัติการจริงบน Terminal Mac M3 (Step-by-Step Lab)

เปิด Terminal บน MacBook M3 แล้วทำตาม 4 ขั้นตอนนี้เพื่อทดสอบ Structured Output ทันที:

ขั้นตอนที่ 1: เข้าโฟลเดอร์โปรเจกต์เดิมและเปิด Virtual Environment
cd ~/se_agent
source .venv/bin/activate
ขั้นตอนที่ 2: สร้างไฟล์ใหม่สำหรับ Day 2
touch core/parser.py test_structured_output.py
ขั้นตอนที่ 3: วางโค้ดลงในไฟล์

- นำโค้ด core/client.py ใน ส่วนที่ 4 (ไฟล์ที่ 1) ไปอัปเดตทับไฟล์เดิม
- นำโค้ด core/parser.py ไปวางใน core/parser.py
- นำโค้ด test_structured_output.py ไปวางใน test_structured_output.py

ขั้นตอนที่ 4: รันสคริปต์ทดสอบผลลัพธ์
python test_structured_output.py

คุณจะเห็นผลลัพธ์เป็น JSON ดิบจาก Ollama และถูกแปลงเป็น Python Dict ดึงค่า has_bug: True ออกมาแสดงผลอย่างสวยงามใน Terminal ทันที!

📝 ส่วนที่ 6: ชุดควิซประเมินความรู้เชิงวิศวกรรม 15 ข้อ (ตอบปุ๊บรู้ผลทันที)

ข้อที่ 1: วัตถุประสงค์หลักของการเปิดใช้งาน `format="json"` ใน `ollama.Client.chat()` คืออะไร?

ข้อที่ 2: แม้จะใส่ `format="json"` แล้ว เหตุใดเราจึงยังต้องระบุ Schema (เช่น `{"has_bug": boolean, ...}`) ใน System Prompt ซ้ำอีก?

ข้อที่ 3: ในไฟล์ `core/parser.py` ฟังก์ชัน `clean_text()` ใช้ Regex จัดการกับปัญหาคลาสสิกข้อใดของ LLM?

ข้อที่ 4: ฟังก์ชันมาตรฐานของ Python ตัวใดที่ใช้แปลงสตริง JSON ให้กลายเป็น Dictionary ในหน่วยความจำ?

ข้อที่ 5: เหตุใดใน `core/parser.py` จึงต้องมีเงื่อนไข `if not isinstance(parsed_data, dict):`?

ข้อที่ 6: การแยกโมดูล `core/parser.py` ออกจาก `core/client.py` เป็นไปตามหลักการออกแบบซอฟต์แวร์ข้อใด?

ข้อที่ 7: ลักษณะข้อใดบ่งชี้ว่าเป็น Invalid JSON Syntax ที่ทำให้ `json.loads()` โยน Exception?

ข้อที่ 8: เมื่อได้ Python Dictionary จาก Parser มาแล้ว การดึงค่าฟิลด์ด้วย `data.get("has_bug", False)` ดีกว่า `data["has_bug"]` อย่างไร?

ข้อที่ 9: ในงาน Agent ระดับ Production หากพบว่า `StructuredOutputParser.parse()` โยน `JSONDecodeError` ทางออกที่ดีที่สุดในลูป ReAct คืออะไร?

ข้อที่ 10: ค่า `temperature` ที่แนะนำสำหรับการทำ Structured Output & JSON Mode ควรกำหนดไว้ที่เท่าใด?

ข้อที่ 11: หากต้องการแปลง Python Dictionary กลับเป็นสตริง JSON ที่สวยงามและอ่านง่าย (Pretty Print) เราจะใช้คำสั่งใด?

ข้อที่ 12: โมเดล `qwen2.5-coder:7b` มีความแม่นยำในการทำ JSON Mode สูงเพราะสาเหตุใด?

ข้อที่ 13: เมื่อเราทำ JSON Mode สำเร็จใน Day 2 สะพานนี้จะส่งผลดีอย่างไรต่อ **Day 3 (Tool Calling & Function Calling)**?

ข้อที่ 14: ในโค้ด `test_structured_output.py` เหตุใดเราจึงใช้ Prompt เป็นภาษาอังกฤษในการกำหนด Schema?

ข้อที่ 15: ในฐานะวิศวกรซอฟต์แวร์ เมื่อคุณเห็นว่าโมเดลตอบกลับมาเป็น JSON และแปลงเป็น Python Dictionary เพื่อสั่งงานต่อได้ รู้สึกอย่างไร?

🏁 สรุปหมุดเชื่อมโยง Day 2 (Chapter Wrap-up)

ใน Day 2 — JSON Mode & Structured Outputs คุณได้อัปเกรด core/client.py ให้รองรับ format="json" สร้างโมดูล core/parser.py ดักจับความผิดพลาดทางไวยากรณ์ และทดสอบรัน test_structured_output.py บนชิป Apple Silicon M3 ได้สำเร็จจริง ตอนนี้สมองกลของคุณพร้อมพูดภาษาเดียวกับโปรแกรมคอมพิวเตอร์แล้ว พร้อมก้าวสู่ Day 3: Concept of Tool Calling (Function Calling) เพื่อให้โมเดลเริ่มสั่งงานฟังก์ชันภายนอกได้แล้วครับ!