Download core/decide.py from SupantBT/Beltbot: direct link, hf CLI and curl.
- Browser
- Download file 31.8 kB
-
https://huggingface.co/spaces/SupantBT/Beltbot/resolve/main/core/decide.py
- Command line
-
hf download hf://spaces/SupantBT/Beltbot/core/decide.py
-
curl -L -o decide.py https://huggingface.co/spaces/SupantBT/Beltbot/resolve/main/core/decide.py
31.8 kB
| """ | |
| core/decide.py | |
| ============== | |
| ทาง จ ส่วนกลาง — ตัดสินในโค้ดว่าจะ "ตอบ / ถามกลับ / ปฏิเสธ / ส่งฝ่ายขาย" ก่อนถึง LLM | |
| ย้ายมาจาก DialogueManager.decide ชั้น 4 · ส่วนที่ผูกกับแบรนด์ย้ายไป brands/<key>/plugin.py | |
| ลำดับการตัดสินในหนึ่งเทิร์น (เท่าเดิม) | |
| รูปที่ระบบปฏิเสธ → reject_image | |
| คำตอบของการถามกลับ → รวมกับคำถามเดิม (ถ้าถามแบรนด์ไว้ ดูว่าตอบแบรนด์ไหน) | |
| ถามแบรนด์เพราะรูป (core/image_policy) → ใช้ผลรูปที่เก็บไว้กับแบรนด์ที่ตอบ | |
| ทักทาย/ขอบคุณ → smalltalk | |
| ไม่เกี่ยวกับสินค้า → refuse_offtopic | |
| ขอช่องทางติดต่ออย่างเดียว → escalate (ไม่ต้องรู้แบรนด์) | |
| สัญญาณแข็งหลายแบรนด์ → ask ยืนยันแบรนด์ | |
| สแตนเลสไม่ระบุแบรนด์ → cross_brand (ทุกแบรนด์ตอบส่วนของตัวเอง) | |
| ยังไม่รู้แบรนด์ → ask แบรนด์ | |
| รู้แบรนด์แล้ว → plugin.decide() | |
| ไม่ import torch / qdrant / genai — เทสต์ด้วย python ล้วน | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import re | |
| from typing import Optional, Sequence | |
| from core import router as _router | |
| from core import templates as T | |
| from core.protocol import BrandPlugin, DecideInput, Decision, ImageScore | |
| from core.image_policy import ImageCarry | |
| from core.sales import escalation_gate | |
| from core.text import any_word, has | |
| # คำที่บอกว่าเป็นคำถามเรื่องสินค้า (ไม่มีคำพวกนี้ + ไม่มีรหัส = นอกเรื่อง) | |
| # ชื่อแบรนด์ไม่อยู่ที่นี่ — แต่ละแบรนด์เติมผ่าน plugin.domain_terms() | |
| DOMAIN_WORDS = [ | |
| "สายพาน", "โซ่", "สเตอร์", "เฟือง", "เพลา", "พิน", "ก้าน", "ลำเลียง", "คอนเวเยอร์", | |
| "belt", "chain", "sprocket", "conveyor", | |
| "กว้าง", "ความยาว", "หนา", "workload", "โหลด", "รับน้ำหนัก", "แรงดึง", "น้ำหนัก", "แข็งแรง", | |
| "วัสดุ", "ทำจาก", "เลี้ยว", "โค้ง", "วิ่งตรง", "รัศมี", "ผลิต", "ทน", "อุณหภูมิ", "องศา", | |
| "ร้อน", "เย็น", "แช่แข็ง", "เปียก", "แห้ง", "drawing", "datasheet", "ดรออิ้ง", "แบบ", "สเปก", "spec", | |
| "ราคา", "สต็อก", "สต๊อก", "ส่งของ", "อะไหล่", "ผิว", "ไลน์", "รุ่น", "series", "ซีรีส์", | |
| "สเปค", "เทียบ", # 25 ต.ค. เทสบนเว็บ: "ขอดูสเปคทั้งสองตัวเทียบกัน" (สะกด ค) ถูกตีเป็นนอกเรื่อง | |
| "ฟัน", "pitch", "พิทช์", "เบอร์", "ขนาด", | |
| # หน่วยวัด — ลูกค้าถามเปลี่ยนหน่วยบ่อยมาก และเดิมถูกตีเป็นคำถามนอกเรื่อง | |
| # (พบตอนเทส 2026-09-29: "ขอให้ตอบเป็นหน่วยนิ้วได้ไหม" ถูกปฏิเสธ ทั้งที่คุยรุ่น HC127 C ค้างอยู่) | |
| "นิ้ว", "inch", "มิลลิเมตร", "เซนติเมตร", "มม", "ซม", "ฟุต", "feet", "หน่วย", "เมตร", | |
| # การค้า — ลูกค้าถามเรื่องสั่งซื้อและช่องทางติดต่อเป็นข้อความแรกได้ | |
| # (พบตอนเทส 2026-10-02: "ขอช่องทางติดต่อฝ่ายขาย" ถูกตีเป็นนอกเรื่อง | |
| # เพราะด่านตรวจนอกเรื่องอยู่ก่อนเกตฝ่ายขาย จึงไม่มีโอกาสถึงเกต) | |
| "สั่งซื้อ", "ซื้อ", "สั่ง", "ออเดอร์", "order", "ใบเสนอราคา", "quotation", | |
| "ติดต่อ", "ฝ่ายขาย", "เซลล์", "อีเมล", "email", "โทร", "ไลน์", | |
| ] | |
| # คำถามต่อเนื่องสั้น ๆ หลังจากมีรุ่นอยู่ในโฟกัสแล้ว ให้ถือว่าเป็นเรื่องสินค้า | |
| # เหตุผล: ลูกค้าที่เพิ่งถามสเปกของรุ่นหนึ่งแล้วพิมพ์สั้น ๆ ต่อ ("แล้วราคาล่ะ" "อันไหนดีกว่า" | |
| # "ส่งของกี่วัน") กำลังถามถึงรุ่นนั้น การปฏิเสธว่านอกเรื่องทำให้เสียงานโดยไม่จำเป็น | |
| # ไม่กระทบเคสนอกเรื่องจริง (CLARIF-002 "อากาศวันนี้เป็นยังไง") เพราะเป็นเทิร์นแรก ยังไม่มีรุ่นในโฟกัส | |
| FOLLOWUP_MAX_CHARS = 40 | |
| THANKS_WORDS = ["ขอบคุณ", "ขอบใจ", "thank"] | |
| THANKS_MAX_CHARS = 25 | |
| SMALLTALK_WORDS = ["สวัสดี", "ขอบคุณ", "ขอบใจ", "hello", "hi ", "thank", "thanks", "ดีครับ", "ดีค่ะ"] | |
| GREETING_WORDS = ["สวัสดี", "hello", "hi ", "ดีครับ", "ดีค่ะ"] # ทัก (ไม่ใช่ขอบคุณ) → ข้อความทักกลับ | |
| # คุยต่อในบทที่มีแบรนด์อยู่แล้ว — ไม่มีคำสินค้าแต่ไม่ใช่นอกเรื่อง (redteam r2 · 27 ต.ค.) | |
| # R17: "เป็นทางตรงครับ … เดี๋ยวขอเอาไปปรึกษาทีมก่อน" · R13: "ถามซ้ำจัง คุยไม่รู้เรื่องละ" — เดิมได้ "ตอบได้เฉพาะเรื่องสินค้า" | |
| CONTINUE_WORDS = ["ปรึกษา", "คุยกับ", "หัวหน้า", "เดี๋ยว", "ไว้ก่อน", "เข้าใจแล้ว", "ถามซ้ำ", "บอกไปแล้ว", | |
| "ไม่รู้เรื่อง", "ไม่เข้าใจ", "ทางตรง", "ทางโค้ง"] | |
| REFERENCE_WORDS = ["ตัวนั้น", "ตัวนี้", "อันนั้น", "อันนี้", "รุ่นนั้น", "รุ่นนี้", "ตัวที่", "อันที่", | |
| "ตัวแรก", "ตัวหลัง", "เมื่อกี้", "ข้างบน", "ตัวเดิม"] | |
| STAINLESS_RX = r"สแตนเลส|stainless|(?<![A-Za-z])SS(?![A-Za-z])" | |
| CROSS_BRAND_STAINLESS = "stainless" | |
| # ถามยืนยันแบรนด์เดียวจากรูป (core/image_policy › confirm · 27 ต.ค.) — pending = "brand:<key>" | |
| BRAND_CONFIRM = "brand:" | |
| _CONFIRM_NO = re.compile(r"ไม่ใช่|ไม่ได้เป็น|^\s*ไม่\s*(?:ครับ|ค่ะ|คะ)?\s*$|\bno\b|\bnope\b|\bnot\b", re.I) | |
| _CONFIRM_YES = re.compile(r"ใช่|ถูกต้อง|ถูกแล้ว|โอเค|\bok\b|\byes\b|^\s*(?:ครับ|ค่ะ|คะ|จ้า)\s*$", re.I) | |
| def brand_pending(p) -> bool: | |
| """กำลังรอคำตอบเรื่องแบรนด์ — เมนูหลายแบรนด์ ("brand") หรือถามยืนยันแบรนด์เดียว ("brand:<key>")""" | |
| return p == "brand" or (isinstance(p, str) and p.startswith(BRAND_CONFIRM)) | |
| # 27 ต.ค. (ผู้ใช้เลือกถ้อยคำ): เดิม "ระบบตรวจพบว่ารูปภาพ…ไม่เกี่ยวข้องกับสินค้าในระบบ" — ผิดกับของจริงที่ถ่ายไกล | |
| # รูปชิ้นสายพานบนกล่องกระดาษ: รูปเต็ม 0.568 / −0.090 → ปฏิเสธ · ครอปเหลือชิ้นงาน 0.888 / +0.083 → ผ่าน (direct) | |
| # จึงบอกสาเหตุที่เป็นไปได้ทั้งสองทาง และวิธีแก้ที่ได้ผลจริง (ถ่ายใกล้ให้เต็มกรอบ) ไม่กล่าวหาว่ารูปไม่เกี่ยว | |
| GENERIC_REJECT_IMAGE_TH = ("รูปนี้อาจถ่ายไกลเกินไป หรืออาจไม่ใช่โซ่หรือสายพานครับ " | |
| "หากเป็นโซ่หรือสายพาน กรุณาถ่ายใกล้กว่านี้ให้ตัวสินค้าเต็มกรอบและเห็นผิวด้านบนชัดเจน " | |
| "หรือแจ้งรหัสรุ่นที่ปั๊มบนตัวสินค้าครับ") | |
| class Dialogue: | |
| """ | |
| plugins เรียงตาม info.order (ลำดับนี้คือลำดับในข้อความและการจับคำตอบเลือกแบรนด์) | |
| state_cls คลาสจาก core.dialogue.state_class ที่ผูก schema ของแบรนด์ชุดเดียวกัน | |
| image_brands แบรนด์ที่รูปในเทิร์นนี้อาจเป็นของมัน (ImageRouter ตัดสินก่อนส่งมา) | |
| """ | |
| def __init__(self, plugins: Sequence[BrandPlugin], state_cls: type, | |
| image_brands: Sequence[str] = ()): | |
| self.plugins = {p.info.key: p for p in sorted(plugins, key=lambda p: p.info.order)} | |
| self.infos = [p.info for p in self.plugins.values()] | |
| self.state_cls = state_cls | |
| self.image_brands = tuple(image_brands) | |
| self.ask_brand_th = T.ask_brand(self.infos) | |
| self.offtopic_th = T.offtopic(self.infos) | |
| self.smalltalk_th = T.smalltalk(self.infos) | |
| self.greeting_th = T.greeting(self.infos) | |
| self._domain_extra = [w for p in self.plugins.values() for w in p.domain_terms()] | |
| # ─────────────────────────── API หลัก ─────────────────────────── | |
| def decide(self, text: str, has_image: bool = False, image: Optional[ImageScore] = None, | |
| image_rejected: bool = False, state=None, | |
| image_carry: Optional[ImageCarry] = None, image_confirm: Optional[str] = None) -> Decision: | |
| """image_confirm = แบรนด์ที่รูปน่าจะเป็น แต่ไม่มั่นใจพอ (core/image_policy › confirm) → ถาม "ใช่ไหม" """ | |
| st = copy.deepcopy(state) if state is not None else self.state_cls() | |
| text = (text or "").strip() | |
| if image_rejected: | |
| key = image.brand if image is not None else ( | |
| self.image_brands[0] if len(self.image_brands) == 1 else None) | |
| reply = (self.plugins[key].image_reject_th if key in self.plugins else "") or GENERIC_REJECT_IMAGE_TH | |
| return Decision("reject_image", key, reply, "ViT/SigLIP ไม่พบสินค้า", | |
| effective_text=text, state=st) | |
| if image_confirm in self.plugins and has_image: | |
| st.pending, st.pending_question = BRAND_CONFIRM + image_confirm, text | |
| return Decision("ask", None, T.confirm_brand(self.plugins[image_confirm].info), | |
| f"รูปน่าจะเป็น {image_confirm} แต่ไม่มั่นใจ → ถามยืนยัน", topic="brand", | |
| effective_text=text, state=st) | |
| # ── ขอบคุณ/ขอบใจล้วน ๆ ตอนบอทค้างคำถามไว้ = จบเรื่องนั้น ไม่ใช่คำตอบ (test_conversation_leaks C01 · 25 ต.ค.) | |
| # เดิมรวมกับคำถามเดิมแล้วถามซ้ำ เช่น บอทถามเปียก/แห้ง → "ขอบคุณครับ" → ถามเปียก/แห้งอีกรอบ | |
| if st.pending and any_word(THANKS_WORDS, text) and not any_word(DOMAIN_WORDS, text) \ | |
| and len(text) <= THANKS_MAX_CHARS: | |
| st.pending, st.pending_question = None, "" | |
| return Decision("smalltalk", st.brand, self.smalltalk_th, "ขอบคุณ (ปิดคำถามที่ค้าง)", | |
| effective_text=text, state=st) | |
| # ── ข้อความใหม่พูดถึงอีกแบรนด์ชัดเจนระหว่างที่แบรนด์เดิมรอคำตอบ = คำถามใหม่ ไม่ใช่คำตอบ ── | |
| # redteam R08 (26 ต.ค.): บอทถามพิทช์ของแบรนด์หนึ่ง → "แล้วโซ่ <อีกแบรนด์> 880 มีขนาดไหนบ้าง" | |
| # เดิมรวมกับคำถามเดิม → เจอสัญญาณแรงสองแบรนด์ → ถามแบรนด์ แล้ววนถามไม่จบ ลูกค้าเลิกคุยทั้งสองรอบ | |
| # กลับแบรนด์เดิมด้วยคำงาน ("กลับมาเรื่องสายพานไก่ พิทช์ 2 นิ้ว") มีแค่สัญญาณอ่อน — นับด้วยถ้าแบรนด์ที่รออยู่ไม่มีสัญญาณเลย | |
| if st.pending and not brand_pending(st.pending) and st.brand: | |
| sig_now = {k: p.detect(text).strength for k, p in self.plugins.items()} | |
| other = [k for k, v in sig_now.items() if k != st.brand and v in ("strong", "weak")] | |
| if other and (sig_now.get(st.brand) in (None, "none") | |
| or (sig_now.get(st.brand) != "strong" and any(sig_now[k] == "strong" for k in other))): | |
| st.pending, st.pending_question = None, "" | |
| # ── คำตอบของการถามกลับรอบก่อน: รวมกับคำถามเดิม ── | |
| effective = text | |
| answered_brand = None # ลูกค้าตอบชื่อแบรนด์ตอนบอทถามแบรนด์ | |
| carried = None # ผลรูปของเทิร์นที่ถามแบรนด์ ใช้ได้เมื่อตอบแบรนด์เจ้าของคะแนนรูป | |
| # ถามแบรนด์จากเทิร์นที่มีแต่รูป (ไม่มีข้อความ) — คำถามเดิมว่างได้ แต่ยังต้องอ่านคำตอบแบรนด์ | |
| confirmed = None # ตอบ ใช่/ไม่ใช่ ของคำถามยืนยันแบรนด์เดียว | |
| if st.pending and (st.pending_question or brand_pending(st.pending)): | |
| if brand_pending(st.pending): | |
| for key, p in self.plugins.items(): | |
| if any(has(pat, text) for pat in T.brand_answer_patterns(p.info)): | |
| st.brand = answered_brand = key | |
| break | |
| ask_key = st.pending[len(BRAND_CONFIRM):] if st.pending.startswith(BRAND_CONFIRM) else None | |
| if ask_key in self.plugins and answered_brand is None: | |
| others = [k for k in self.plugins if k != ask_key] | |
| if _CONFIRM_NO.search(text): | |
| if len(others) != 1: # หลายแบรนด์ที่เหลือ → กลับไปเมนูแบรนด์ | |
| st.pending = "brand" | |
| return Decision("ask", None, self.ask_brand_th, "ตอบไม่ใช่ · เหลือหลายแบรนด์", | |
| topic="brand", effective_text=st.pending_question, state=st) | |
| st.brand = answered_brand = confirmed = others[0] | |
| elif _CONFIRM_YES.search(text): | |
| st.brand = answered_brand = confirmed = ask_key | |
| # รูปเปล่าแล้วตอบแค่แบรนด์ (ใช่/ไม่ใช่ · ชื่อ · เลขเมนู) — ยังไม่มีคำถามให้ตอบ | |
| # เดิมส่ง "2" หรือชื่อแบรนด์ให้ LLM เป็นคำถาม (เจอ 27 ต.ค. ตอนเพิ่มการถามยืนยัน) | |
| brand_only = confirmed is not None or ( | |
| answered_brand is not None and len(text) <= 20 | |
| and not any(p.detect(text).has_code for p in self.plugins.values())) | |
| if brand_only and not st.pending_question and not has_image: | |
| sc = image_carry.best if image_carry is not None else None | |
| if not (sc is not None and sc.brand == answered_brand): | |
| # แบรนด์ที่อ่านรูปไม่ได้ → ขอคำถาม (ไม่เดา) · แบรนด์ที่อ่านรูปได้ไหลต่อด้วยผลรูปเดิมตามปกติ | |
| # "ระบุรุ่นจากรูปไม่ได้" core/turn เติมให้จากรูปที่เก็บไว้ (_image_note) ไม่ใส่ซ้ำที่นี่ | |
| info = self.plugins[answered_brand].info | |
| st.pending, st.pending_question = None, "" | |
| return Decision("ask", answered_brand, T.image_need_question(info), | |
| f"ตอบแบรนด์ {answered_brand} หลังรูปเปล่า · ไม่มีคำถาม", topic="image", | |
| effective_text="", state=st) | |
| if image_carry is not None and st.brand is not None and not has_image: | |
| sc = image_carry.best | |
| if sc is not None and sc.brand == st.brand: | |
| if image_carry.rejected: | |
| reply = self.plugins[st.brand].image_reject_th or GENERIC_REJECT_IMAGE_TH | |
| st.pending, st.pending_question = None, "" | |
| return Decision("reject_image", st.brand, reply, | |
| f"ตอบ {st.brand} · รูปเทิร์นก่อนถูกปฏิเสธ", | |
| effective_text=text, state=st, | |
| payload={"image_carried": True}) | |
| carried = sc | |
| # ตอบ ใช่/ไม่ใช่ = คำตอบเรื่องแบรนด์ล้วน ไม่ใช่ส่วนของคำถาม | |
| effective = st.pending_question.strip() if confirmed else f"{st.pending_question} {text}".strip() | |
| pending_q_before = st.pending_question | |
| st.pending, pending_before, st.pending_question = None, st.pending, "" | |
| # ── สัญญาณของทุกแบรนด์ ── | |
| signals = {k: p.detect(effective) for k, p in self.plugins.items()} | |
| route = _router.choose(signals, prev_brand=st.brand, has_image=has_image, | |
| image_brands=self.image_brands) | |
| if answered_brand and route.kind in ("conflict_strong", "conflict_weak"): | |
| # ลูกค้าตอบแล้วว่าแบรนด์ไหน — ข้อความเดิมยังมีชื่อทั้งสองแบรนด์ ถ้าตัดสินใหม่จะชนซ้ำทุกรอบ (R08) | |
| route = _router.RouteResult(answered_brand, "strong", [], {answered_brand: "ลูกค้าตอบแบรนด์"}, | |
| route.signals) | |
| has_code = any(s.has_code for s in signals.values()) | |
| # ── ทักทาย / นอกเรื่อง ── | |
| # มีรุ่นค้างอยู่ในบทสนทนา + พิมพ์สั้น = ถามต่อเรื่องรุ่นนั้น ไม่ใช่เปลี่ยนเรื่อง | |
| in_focus = bool(st.brand) and any(p.has_focus(st.brands[k]) for k, p in self.plugins.items()) | |
| followup = in_focus and len(text) <= FOLLOWUP_MAX_CHARS | |
| domain = has_code or has_image or bool(any_word(DOMAIN_WORDS, effective)) \ | |
| or bool(any_word(self._domain_extra, effective)) or pending_before is not None \ | |
| or has(STAINLESS_RX, effective) or followup | |
| if not domain: | |
| if any_word(SMALLTALK_WORDS, text): | |
| greet = any_word(GREETING_WORDS, text) and not any_word(["ขอบคุณ", "ขอบใจ", "thank"], text) | |
| return Decision("smalltalk", st.brand, self.greeting_th if greet else self.smalltalk_th, "ทักทาย", | |
| effective_text=effective, state=st) | |
| if not (st.brand and (any_word(REFERENCE_WORDS, text) or any_word(CONTINUE_WORDS, text))): | |
| return Decision("refuse_offtopic", st.brand, self.offtopic_th, "ไม่มีคำเกี่ยวกับสินค้า", | |
| effective_text=effective, state=st) | |
| # ── ขอช่องทางติดต่อ — ตอบได้โดยไม่ต้องรู้แบรนด์ ── | |
| # บริษัทเดียว เบอร์เดียว ถามแบรนด์ก่อนให้เบอร์เป็นการกั้นโดยไม่จำเป็น | |
| # ต้องอยู่ก่อนด่านถามแบรนด์ ไม่งั้น "ขอเบอร์ฝ่ายขาย" จะได้คำถามกลับว่าสนใจแบรนด์ไหน | |
| # เงื่อนไข: หัวข้อต้องเป็นช่องทางติดต่อล้วน ถ้ามีคำถามสเปกปนมาให้ไหลไปทางปกติ | |
| # (หัวข้อช่องทางติดต่อไม่ใช้ชื่อรุ่นในข้อความ จึงไม่ต้องส่งรหัสของแบรนด์ใดเข้าไป) | |
| _esc = escalation_gate(effective) | |
| if _esc and _esc.topics == ["ช่องทางติดต่อ"] and not _esc.has_spec_question: | |
| # ขอเบอร์กลางคำถามที่แบรนด์ถามค้าง → ให้เบอร์แล้วถามข้อเดิมต่อ (การรอคำตอบไม่หาย) | |
| # ปลั๊กอินเลือกเองผ่าน pending_reask(state) — ไม่มีเมธอดนี้ = พฤติกรรมเดิม | |
| # 26 ต.ค. (C09): เดิมล้างการรอคำตอบ เทิร์นหน้า "2 นิ้ว" ไม่ถูกอ่านเป็นคำตอบ → บันทึกเป็น "ไม่ทราบ" | |
| reask = getattr(self.plugins.get(st.brand), "pending_reask", None) \ | |
| if pending_before is not None and not brand_pending(pending_before) and st.brand else None | |
| q = reask(st.brands[st.brand]) if reask else None | |
| if q: | |
| st.pending, st.pending_question = pending_before, pending_q_before | |
| return Decision("ask", st.brand, f"{_esc.reply_th}\n\n{q}", "ขอช่องทางติดต่อกลางการถาม → ถามข้อเดิมต่อ", | |
| topic="escalate", effective_text=effective, state=st) | |
| return Decision("escalate", st.brand, _esc.reply_th, "ขอช่องทางติดต่อ", | |
| topic="escalate", effective_text=effective, state=st) | |
| # ── slot 0: แบรนด์ ── | |
| if route.kind == "conflict_strong": | |
| st.pending, st.pending_question = "brand", effective | |
| conflict = [self.plugins[k].info for k in route.conflict] | |
| only_image = all(v == _router.IMAGE_EVIDENCE for v in route.evidence.values()) | |
| why = ("รูปไม่ระบุแบรนด์ (บางแบรนด์ยังไม่มีตัวให้คะแนนรูป)" if only_image | |
| else f"เจอรหัสของ{T.all_brands_th(len(conflict))}") | |
| return Decision("ask", None, T.clarify_conflict(conflict), why, | |
| topic="brand", effective_text=effective, state=st) | |
| brand = route.brand if route.brand else st.brand | |
| if route.brand and st.brand and route.brand != st.brand: | |
| # เปลี่ยนแบรนด์กลางบทสนทนา — แต่ละแบรนด์เลือกเองว่าล้างอะไร | |
| for k, p in self.plugins.items(): | |
| st.brands[k] = p.on_brand_switch(st.brands[k]) | |
| if brand is None and has(STAINLESS_RX, effective): | |
| # คำถามเรื่องวัสดุสแตนเลสโดยไม่บอกแบรนด์ → ตอบจากข้อมูลทุกแบรนด์ (ผู้ใช้เลือก 2026-09-25) | |
| n = len(self.plugins) | |
| # ค้างคำถามแบรนด์ไว้ — ลูกค้าเลือกแบรนด์ทีหลัง ("เอาของแบรนด์นี้") ต้องได้คำถามสแตนเลสต่อ | |
| # (test_conversation_leaks C19 · 25 ต.ค. — เดิมเรื่องสแตนเลสหาย ได้คำแนะนำทั่วไป) | |
| st.pending, st.pending_question = "brand", effective | |
| return Decision("cross_brand", None, self.cross_brand_th(CROSS_BRAND_STAINLESS), | |
| f"SS ไม่ระบุแบรนด์ → ตอบ{T.all_brands_th(n)}", | |
| topic="material", effective_text=effective, state=st) | |
| if brand is None: | |
| st.pending, st.pending_question = "brand", effective | |
| return Decision("ask", None, self.ask_brand_th, "ยังไม่รู้แบรนด์", topic="brand", | |
| effective_text=effective, state=st) | |
| st.brand = brand | |
| plugin = self.plugins[brand] | |
| img = image if (has_image and image is not None and image.brand == brand) else None | |
| use_image = has_image | |
| if carried is not None and carried.brand == brand: | |
| img, use_image = carried, True | |
| d = plugin.decide(DecideInput(text=effective, signal=signals[brand], has_image=use_image, | |
| image=img, answer=text if pending_before else ""), st.brands[brand]) | |
| if d.pending: | |
| # ข้อความที่ปลั๊กอินใช้ตัดสินจริง (ปกติ = effective) — ปลั๊กอินเริ่มคำขอใหม่จากคำตอบได้ | |
| # 26 ต.ค. (C33): เปลี่ยนงานกลางการถาม ปลั๊กอินตัดคำขอเดิมทิ้ง แต่ที่นี่เคยเก็บข้อความรวมเดิมไว้ | |
| # เทิร์นหน้าจึงกลับไปเป็นงานเดิม | |
| st.pending, st.pending_question = d.pending, (d.effective_text or effective) | |
| if carried is not None and carried.brand == brand: | |
| d.payload = {**(d.payload or {}), "image_carried": True} | |
| d.state = st | |
| return d | |
| # ─────────────────────────── ข้ามแบรนด์ ─────────────────────────── | |
| def cross_brand_th(self, topic: str) -> str: | |
| """สร้างจากข้อมูลจริงของทุกแบรนด์ทุกครั้ง — แต่ละแบรนด์เขียนส่วนของตัวเอง""" | |
| sections = [(p.info, p.cross_brand_section(topic)) for p in self.plugins.values()] | |
| sections = [(i, s) for i, s in sections if s is not None] | |
| lines = [f"สินค้าที่เกี่ยวกับสแตนเลส (SS) ของ{T.all_brands_th(len(self.plugins))}ครับ", ""] | |
| for n, (info, sec) in enumerate(sections): | |
| if n: | |
| lines.append("") | |
| lines.append(f"**{info.name} ({info.kind_th})**") | |
| lines += sec | |
| lines += ["", "สนใจแบรนด์ไหนหรือรุ่นไหน บอกได้เลยครับ จะให้รายละเอียดสเปกเพิ่ม"] | |
| return "\n".join(lines) | |