File size: 24,415 Bytes
ede84b4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 | """
core/protocol.py
================
สัญญาที่ปลั๊กอินแบรนด์ต้องตอบ — แกนกลางรู้จักแบรนด์ผ่านไฟล์นี้เท่านั้น
หนึ่งแบรนด์ = หนึ่งโฟลเดอร์ brands/<key>/ ที่มี brand.yaml + plugin.py
plugin.py ต้องมีฟังก์ชัน create(ctx: PluginContext) -> BrandPlugin
ข้อห้ามของไฟล์นี้และทุกไฟล์ใน core/
· ไม่มีชื่อแบรนด์สักคำ (ตรวจด้วย grep ใน training/run_gates.py)
· ไม่ import torch / qdrant / genai — เทสต์ด้วย python ล้วนได้
คำถามหกข้อจาก HANDOFF ข้อ 3 → เมธอดที่ตอบ
1. รู้จักรหัสในข้อความนี้ไหม มั่นใจแค่ไหน detect · domain_terms
2. ค้นข้อมูลอย่างไร build_turn (ปลั๊กอินเลือกวิธีค้นเอง)
3. มีเกตอะไร เรียงลำดับไหน gate_chain · decide
4. ใส่กฎอะไรลง prompt build_turn → LLMTurn.system_prompt
5. เก็บสถานะอะไรข้ามเทิร์น empty_state · has_focus · on_brand_switch · sanitize_state
6. มีโมเดลรูปไหม collection ชื่ออะไร brand.yaml › image + image_scorer
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Literal, Optional, Protocol, Sequence, runtime_checkable
PROTOCOL_VERSION = 1 # brand.yaml ต้องประกาศ protocol: 1 — ไม่ตรงให้ registry หยุดพร้อมบอกค่า
Strength = Literal["none", "weak", "strong"]
Action = Literal["reject_image", "smalltalk", "refuse_offtopic", "ask",
"escalate", "cross_brand", "search", "answer"]
# ═══════════════════════════════════════════════════════════════
# ข้อมูลประจำแบรนด์ — อ่านจาก brand.yaml (ไม่ใช่โค้ด)
# ═══════════════════════════════════════════════════════════════
@dataclass(frozen=True)
class BrandInfo:
"""ข้อความที่แกนกลางใช้ประกอบเทมเพลตรวมหลายแบรนด์ ทุกช่องมาจาก brand.yaml
ตัวอย่างการใช้ในแกนกลาง (สองแบรนด์ต้องได้ข้อความเดิมทุกตัวอักษร)
ถามแบรนด์ "{order}. {menu_line_th}" ทีละบรรทัด
สัญญาณชนกัน "{name} ({kind_th})" ต่อกันด้วย " หรือ "
นอกเรื่อง "{offtopic_th}" ต่อกันด้วย " และ" (ไม่มีช่องว่างหลัง และ — ตามข้อความเดิม)
ทักทาย "{category_th}" ต่อกันด้วย "หรือ"
"""
key: str # ชื่อโฟลเดอร์ ใช้เป็น prefix ตอนแปลงสถานะเป็น dict แบน
name: str # ชื่อที่ลูกค้าเห็น
order: int # ลำดับในเมนูถามแบรนด์ — ตอบเลขนี้ = เลือกแบรนด์นี้
kind_th: str # "โซ่ท็อปเชน"
menu_line_th: str # บรรทัดในเมนูถามแบรนด์
offtopic_th: str # ชื่อในข้อความปฏิเสธนอกเรื่อง
category_th: str # คำกว้าง ๆ ในข้อความทักทาย
answer_patterns: tuple[str, ...] = () # regex ที่แปลว่าลูกค้าตอบเลือกแบรนด์นี้
# ═══════════════════════════════════════════════════════════════
# 1 · รู้จักข้อความนี้ไหม
# ═══════════════════════════════════════════════════════════════
@dataclass
class BrandSignal:
"""ผลของ detect() — แกนกลางเอาไปเทียบข้ามแบรนด์ใน core/router.py
strength strong = รหัส/ชื่อแบรนด์/รูปที่โมเดลของแบรนด์นี้รับ · weak = คำอุตสาหกรรม/งาน · none
has_code เจอรหัสสินค้าจริงในข้อความ (ใช้ตัดสินว่าเป็นคำถามเรื่องสินค้า แยกจาก strength
เพราะชื่อแบรนด์เป็น strong แต่ไม่ใช่รหัส)
parsed ของที่ปลั๊กอินแยกได้ แกนกลางไม่เปิดดู ส่งคืนเข้า decide() ตัวเดิม
ไม่ต้องแยกรหัสซ้ำสองรอบ และไม่มีชนิดข้อมูลของแบรนด์รั่วเข้าแกน
"""
strength: Strength = "none"
evidence: str = "" # เหตุผลสั้น ๆ ลง debug เช่น "รหัส/ซีรีส์" หรือคำที่จับได้
has_code: bool = False
parsed: Any = None
# ═══════════════════════════════════════════════════════════════
# 5 · สถานะบทสนทนา (ตัวคลาสอยู่ core/dialogue.py — ที่นี่คือส่วนที่ปลั๊กอินต้องรู้)
# ═══════════════════════════════════════════════════════════════
#
# ในหน่วยความจำ state.brands[key] = dict ที่ปลั๊กอินถือเอง แกนกลางไม่เปิดดู
# ตอนส่งออก แบนเป็น "<key>_<field>" เช่น brands["x"]["codes"] → "x_codes"
# รูปแบบนี้ตรงกับ dict ที่เบราว์เซอร์เก็บไว้อยู่แล้ว แชตเก่ากู้ได้โดยไม่ต้องแปลง
# และไม่ต้องมีชื่อแบรนด์ในโค้ดแปลง
# key ของแบรนด์ห้ามมี _ (registry ตรวจ) — แยก "<key>_<field>" กลับได้ที่ _ ตัวแรกเสมอ ไม่กำกวม
# ═══════════════════════════════════════════════════════════════
# 3 · ผลการตัดสินหนึ่งเทิร์น
# ═══════════════════════════════════════════════════════════════
@dataclass
class DecideInput:
"""สิ่งที่แกนกลางส่งให้ decide() หลังเลือกแบรนด์แล้ว"""
text: str # คำถามที่รวมคำตอบของการถามกลับแล้ว (effective)
signal: BrandSignal # ผล detect() ของแบรนด์นี้ในเทิร์นนี้
has_image: bool = False
image: Optional["ImageScore"] = None # มีเมื่อ ImageRouter เลือกแบรนด์นี้จากรูป
answer: str = "" # ข้อความดิบของเทิร์นนี้ เมื่อเป็นคำตอบของการถามกลับรอบก่อน ("" = ไม่ใช่)
@dataclass
class Decision:
"""ผลการตัดสิน — สร้างโดยแกนกลาง (ก่อนเลือกแบรนด์) หรือโดยปลั๊กอิน (หลังเลือก)
payload ข้อมูลของแบรนด์ที่ตัดสิน (รหัส slot คำค้น) ปลั๊กอินอ่านกลับใน build_turn
reply_th มีค่าเมื่อเป็นเทมเพลต — ตอบได้โดยไม่เรียก LLM
"""
action: Action
brand: Optional[str]
reply_th: str = ""
reason: str = ""
topic: Optional[str] = None
effective_text: str = ""
payload: dict = field(default_factory=dict)
state: Any = None # core.dialogue.DialogState
sales_handoff_th: str = "" # คำถามผสม: โค้ดต่อท้ายหลังคำตอบ LLM
sales_topics: list = field(default_factory=list)
pending: Optional[str] = None # ปลั๊กอินถามกลับค้างไว้ — แกนกลางเก็บลงสถานะพร้อมคำถามเดิม
@property
def needs_llm(self) -> bool:
return self.action in ("answer", "search")
# ═══════════════════════════════════════════════════════════════
# 2 + 4 · ค้นข้อมูล และประกอบ prompt
# ═══════════════════════════════════════════════════════════════
@dataclass
class TurnInput:
"""หนึ่งเทิร์นที่แกนกลางส่งเข้าปลั๊กอินตอนต้องเรียก LLM
ของที่ฝากข้ามเทิร์นมีสามช่อง (ตำแหน่งเดิมใน tuple ของ chat_interaction) แกนกลางไม่เปิดดู
last_focus รหัสที่พูดถึงในเทิร์น LLM ล่าสุด (list)
last_image สัญญาณรูปล่าสุดที่ปลั๊กอินฝากไว้
last_context บริบทค้น/กราฟล่าสุดที่ปลั๊กอินฝากไว้
"""
user_text: str
image: Any # PIL.Image หรือ None
history: list # [{"role", "content"}] แบบเดียวกับที่หน้าเว็บส่งมา
session_id: str
effective_text: str = "" # ข้อความหลังเติมรหัสในโฟกัส (ก่อนตัดสิน)
followup: Any = False # คำถามต่อเนื่องสั้น ๆ (ค่าเดิมอาจเป็น "" — คงไว้ให้ debug เหมือนเดิม)
last_focus: list = field(default_factory=list)
last_image: Any = None
last_context: Any = None
image_score: Optional["ImageScore"] = None # ผลจาก ImageRouter ถ้าเทิร์นนี้มีรูป
log: Any = None # turn log ของ PipelineLogger (ปลั๊กอินบันทึกขั้นค้นของตัวเอง)
@dataclass
class LLMTurn:
"""สิ่งที่ปลั๊กอินขอให้แกนกลางส่งเข้า LLM — แกนกลางเป็นคนเดียวที่คุยกับ LLM"""
system_prompt: str
user_parts: list # [str] หรือ [image_part, str]
tools: Sequence[Callable] = ()
debug: list = field(default_factory=list) # บรรทัดลงช่อง "ข้อมูลอ้างอิง"
state: Any = None # ของภายในปลั๊กอินที่ต้องใช้ต่อใน finish_turn (ผลค้น ranking ฯลฯ)
direct_reply: Optional[str] = None # มีค่า = ตอบเลยโดยไม่เรียก LLM (ค้นแล้วไม่เจออะไร)
@dataclass
class TurnOutput:
reply: str
extra_messages: list = field(default_factory=list) # เช่น รูป datasheet แยกเป็นข้อความต่างหาก
debug: list = field(default_factory=list)
# ของฝากเทิร์นหน้า — คำนวณหลังแกนกลางต่อข้อความฝ่ายขายแล้ว (ใช้คำตอบสุดท้าย)
# คืน (last_focus, last_image, last_context) · None = คงของเดิมไว้
carry_fn: Optional[Callable[[str], tuple]] = None
# แก้สถานะของแบรนด์นี้หลังได้คำตอบ (เช่น รุ่นที่คำตอบเพิ่งเสนอ → โฟกัสของเทิร์นหน้า) · {} = ไม่แก้
state_patch: dict = field(default_factory=dict)
# ═══════════════════════════════════════════════════════════════
# 6 · รูปภาพ
# ═══════════════════════════════════════════════════════════════
@dataclass
class ImageScore:
"""คะแนนจากโมเดลรูปของหนึ่งแบรนด์ — core/image_router.py เทียบข้ามแบรนด์
แกนกลางใช้แค่ confidence กับ accepted ตัดสิน ที่เหลือส่งคืนปลั๊กอิน
accepted = ระบบรูปของแบรนด์นี้หาสินค้าเจอ (ไม่เจอ = ถือว่ารูปไม่ใช่ของแบรนด์นี้)
เกณฑ์ความมั่นใจระดับรุ่น/ซีรีส์ ปลั๊กอินใช้เองใน decide (thresholds.json)
ห้ามใส่เกรดวัสดุหรือสภาวะเปียก/แห้งในนี้ — อนุมานจากรูปไม่ได้ (HANDOFF ข้อ 4)
"""
brand: str
confidence: float # 0..1
accepted: bool # ผ่านเกณฑ์ขั้นต่ำของแบรนด์ตัวเอง (thresholds.json)
label: Optional[str] = None # คลาสจาก labels.json
group: Optional[str] = None # กลุ่มที่มองด้วยตาแยกไม่ออก (visual_groups.json) เช่น ซีรีส์
dominant: bool = False
product_ref: str = ""
raw: Any = None # ผลค้นเต็มของปลั๊กอิน ใช้ต่อใน build_turn โดยไม่ต้องค้นซ้ำ
@runtime_checkable
class ImageScorer(Protocol):
def score(self, image: Any, text: str) -> ImageScore: ...
# ═══════════════════════════════════════════════════════════════
# สิ่งที่แกนกลางให้ปลั๊กอินตอนสร้าง
# ═══════════════════════════════════════════════════════════════
@dataclass
class PluginContext:
"""registry สร้างแล้วส่งให้ create() ของปลั๊กอิน
shared ของที่โหลดครั้งเดียวใช้ร่วมกันทุกแบรนด์ (embedding model, device, logger)
ใส่โดยตัวเริ่มระบบ ไม่ใช่โดยแกนกลาง — เทสต์ส่ง {} ได้ ปลั๊กอินต้องทำงานได้
ในโหมดไม่มีโมเดล (ตัดสินอย่างเดียว) ไม่งั้นชุดเกตรันไม่ได้
"""
brand_dir: str
config: dict # brand.yaml ทั้งไฟล์
image_model: Any = None # core.models.ImageModelSpec ที่ตรวจแล้ว หรือ None ถ้าปิด
shared: dict = field(default_factory=dict)
# ═══════════════════════════════════════════════════════════════
# ตัวสัญญา
# ═══════════════════════════════════════════════════════════════
@runtime_checkable
class BrandPlugin(Protocol):
info: BrandInfo
gate_chain: tuple[str, ...] # ชื่อเกตตามลำดับที่รันจริง — ใช้ในเทสต์และ debug
image_reject_th: str # ข้อความปฏิเสธรูปของแบรนด์นี้ ("" = ไม่รับรูป)
# ── 1 · รู้จักข้อความนี้ไหม ──────────────────────────────────
def detect(self, text: str) -> BrandSignal:
"""อ่านข้อความอย่างเดียว ไม่ดูรูป (รูปผ่าน image_scorer) · ห้ามมี side effect"""
...
def domain_terms(self) -> Sequence[str]:
"""คำที่แปลว่าเป็นคำถามเรื่องสินค้าของแบรนด์นี้ (รวมกับคำกลางใน core เพื่อกันนอกเรื่อง)"""
...
# ── 5 · สถานะข้ามเทิร์น ───────────────────────────────────
def empty_state(self) -> dict: ...
def has_focus(self, st: dict) -> bool:
"""มีรุ่นค้างอยู่ในบทสนทนาไหม — ใช้ตัดสินว่าข้อความสั้นเป็นคำถามต่อเนื่อง"""
...
def on_brand_switch(self, st: dict) -> dict:
"""ลูกค้าเปลี่ยนแบรนด์กลางบทสนทนา — คืนสถานะที่ล้างแล้ว (ปลั๊กอินเลือกว่าล้างอะไร)"""
...
def sanitize_state(self, raw: dict) -> dict:
"""กรองสถานะที่เบราว์เซอร์ส่งกลับมา เก็บเฉพาะรหัสที่มีจริงในฐานข้อมูลของแบรนด์นี้
raw คือฟิลด์ของแบรนด์นี้ที่ตัด prefix แล้ว (webapi ตัดความยาว list/dict ให้ก่อน)
ค่าที่ไม่รู้จักต้องทิ้ง ไม่ใช่ raise"""
...
def resumable(self, st: dict) -> bool:
"""สถานะนี้มีของพอให้กู้บทสนทนาไหม (ใช้ตอนเบราว์เซอร์ส่งประวัติกลับมา)"""
...
# ── 3 · ตัดสินและเกต ─────────────────────────────────────
def decide(self, inp: DecideInput, st: dict) -> Decision:
"""ตัดสินหลังเลือกแบรนด์แล้ว: ask / escalate / search / answer / reject_image
แก้ st ได้ (แกนกลางส่งสำเนามา) · ห้ามเรียก LLM · ห้ามเข้าเน็ต
ถามกลับแล้วรอคำตอบ → ตั้ง Decision.pending (แกนกลางเก็บคำถามเดิมให้เอง)"""
...
# ── 2 + 4 · ค้นและประกอบ prompt ───────────────────────────
def build_turn(self, decision: Decision, turn: TurnInput) -> LLMTurn:
"""เรียกเฉพาะเมื่อ decision.needs_llm · ค้นข้อมูลด้วยวิธีของแบรนด์ แล้วคืน prompt + tools"""
...
def finish_turn(self, decision: Decision, turn: TurnInput, llm: LLMTurn, result: Any) -> TurnOutput:
"""หลัง LLM ตอบ — result คือ core.llm.LLMResult (response · session · tool_calls)
ดึงข้อความ ต่อของที่โค้ดต้องเขียนเอง (รูป datasheet) และคืน carry_fn ของเทิร์นหน้า"""
...
# ── ข้ามแบรนด์ ──────────────────────────────────────────
def cross_brand_section(self, topic: str) -> Optional[list[str]]:
"""ส่วนของแบรนด์นี้ในคำตอบรวมทุกแบรนด์ (เช่น "สแตนเลส" ไม่ระบุแบรนด์)
คืน None = แบรนด์นี้ไม่ตอบหัวข้อนี้ · สร้างจากข้อมูลจริงทุกครั้ง ไม่ฮาร์ดโค้ดรายชื่อรุ่น"""
...
# ── 6 · รูป ─────────────────────────────────────────────
def image_scorer(self) -> Optional[ImageScorer]:
"""None = แบรนด์นี้ไม่รับรูป (หรือ enabled: auto แล้วยังไม่มีโมเดล/collection)"""
...
|