Beltbot / core /decide.py
SupantBT's picture
plugin core + monitor (main 443d2aa)
ede84b4 verified
Raw History Blame Contribute Delete
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)