File size: 9,582 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 | """
core/answer_interpreter.py — ให้ LLM ตีความ "คำตอบของลูกค้าต่อคำถามที่บอทเพิ่งถาม" (ผู้ใช้เลือก 25 ต.ค. · ข้อ 1)
ที่มา: แนวทางแยก "เข้าใจ" ออกจาก "ตัดสิน" (Bocklisch et al. 2024 · Rasa CALM · arXiv 2402.12234)
LLM ทำแค่แปลงข้อความภาษาพูดเป็นโครงสร้าง — ไม่ตอบลูกค้า ไม่เลือกรุ่น ไม่เติมตัวเลขที่ลูกค้าไม่ได้พูด
โค้ดตรวจโครงสร้างนั้น (ชนิด · ช่วงค่า) แล้วเดินขั้นตอนเดิมทุกอย่าง
ทำไม: กฎ regex ตีความภาษาพูดได้ไม่ครบ ทุกแบบที่กฎไม่รู้จักคือรูรั่วใหม่ (test_conversation_leaks · 25 ต.ค.:
"ไม่ใช่" · "ลบ 35" · "พิทช์คืออะไร" · "ระยะ 50 มิล" ต้องอุดทีละจุด)
ขอบเขต: เฉพาะเทิร์นที่ลูกค้ากำลังตอบคำถามที่บอทถาม (รู้ว่าถามอะไร คาดคำตอบชนิดไหน)
ทางสำรอง: เรียกไม่ได้ · JSON เสีย · ค่าหลุดช่วง → คืน None ผู้เรียกใช้กฎเดิม (พฤติกรรมเท่าก่อนมีตัวนี้)
ไม่รู้จักชื่อแบรนด์ — ผู้เรียกส่งคำถามและชนิดคำตอบที่คาดมา
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass, field
from typing import Callable, Optional
KINDS = ("value", "yes", "no", "unknown", "question", "other")
EXPECT = ("number_mm", "number_c", "yes_no", "wet_dry", "free")
# ช่วงที่รับได้ต่อชนิด — นอกช่วง = ไม่เชื่อ LLM ใช้กฎเดิม
RANGES = {"number_mm": (1.0, 200.0), "number_c": (-80.0, 400.0)}
SYSTEM_TH = """คุณคือตัวแปลงคำตอบของลูกค้าเป็น JSON สำหรับระบบขายสายพาน ไม่ต้องตอบลูกค้า
อ่าน "คำถามของบอท" และ "คำตอบของลูกค้า" แล้วคืน JSON เท่านั้น รูปแบบ:
{"kind": "...", "value": ตัวเลขหรือ null, "unit": "...หรือ null", "wet_or_dry": "wet" | "dry" | null, "note": "สั้น ๆ"}
kind:
- "value" ลูกค้าให้ค่าตัวเลขที่ถูกถาม (ใส่ value และ unit)
- "yes" ลูกค้ายืนยันสิ่งที่บอทเสนอ (ใช่ ถูกต้อง ครับ โอเค ตามนั้น)
- "no" ลูกค้าปฏิเสธสิ่งที่บอทเสนอ โดยไม่ได้บอกค่าใหม่
- "unknown" ลูกค้าไม่ทราบ ไม่แน่ใจ ข้าม ไม่มีข้อมูล
- "question" ลูกค้าถามกลับ หรือไม่เข้าใจคำถาม (เช่น คืออะไร หมายถึงอะไร วัดยังไง)
- "other" ลูกค้าพูดเรื่องอื่น เปลี่ยนเรื่อง หรือถามสินค้าอื่น
กฎ:
- ห้ามเดาตัวเลขที่ลูกค้าไม่ได้พูด ถ้าไม่มีตัวเลขในคำตอบ value = null
- unit: "mm" "inch" "c" ตามที่ลูกค้าพูดหรือบริบทของคำถาม (คำถามเรื่องพิทช์ไม่บอกหน่วย = "mm" · เรื่องอุณหภูมิ = "c")
- "ลบ 35" "ติดลบ 35" "-35" = -35 · "ครึ่งนิ้ว" = 0.5 inch · "สองนิ้ว" = 2 inch · "50 มิล" = 50 mm
- ถ้าลูกค้าตอบว่าไม่ใช่และบอกค่าใหม่ในประโยคเดียว ให้ kind = "value"
- ถ้าลูกค้าบอกค่าแล้วถามยืนยัน (เช่น "ห่างกันประมาณ 5 เซน น่าจะตรงกับ 2 นิ้วมั้ย") ให้ kind = "value" ใช้ค่าที่ลูกค้าวัดได้
- "5 เซน" "5 ซม." = 5 cm (unit "cm")
- wet_or_dry: ใส่เมื่อคำตอบบอกสภาพไลน์ (มีน้ำ ฉีดล้าง ชื้น = wet · แห้ง ไม่โดนน้ำ = dry) ไม่งั้น null
"""
@dataclass
class AnswerReading:
kind: str
value: Optional[float] = None
unit: Optional[str] = None
wet_or_dry: Optional[str] = None
note: str = ""
raw: dict = field(default_factory=dict)
ms: float = 0.0
def number(self, expect: str) -> Optional[float]:
"""ค่าในหน่วยที่ผู้เรียกต้องการ (มม. หรือ °C) — แปลงนิ้วเป็นมม. ที่นี่ (ไม่ให้ LLM คำนวณ)"""
if self.value is None:
return None
v = float(self.value)
if expect == "number_mm" and (self.unit or "").lower() in ("inch", "in", "นิ้ว", '"'):
v = round(v * 25.4, 2)
elif expect == "number_mm" and (self.unit or "").lower() in ("cm", "ซม", "ซม.", "เซน", "เซนติเมตร"):
v = round(v * 10, 2) # sim5 G05: "5 เซน" = 50 มม.
return v
class AnswerInterpreter:
"""complete(system, user, json_mode=True, temperature=0) -> str (core/llm.py GeminiClient/GenAIClient.complete)"""
def __init__(self, complete: Callable[..., str], log: Optional[Callable[[str], None]] = None):
self.complete = complete
self.log = log or (lambda m: None)
self.calls = self.failures = 0
self.total_ms = 0.0 # เวลารวมที่ใช้เรียก LLM (simulate_customers วัดเวลาต่อเทิร์น)
def interpret(self, question_th: str, answer: str, expect: str) -> Optional[AnswerReading]:
if expect not in EXPECT or not (answer or "").strip():
return None
user = (f"ชนิดคำตอบที่คาด: {expect}\n"
f"คำถามของบอท: {question_th}\n"
f"คำตอบของลูกค้า: {answer.strip()}")
t0 = time.perf_counter()
self.calls += 1
try:
text = self.complete(SYSTEM_TH, user, json_mode=True, temperature=0)
reading = parse_reading(text, expect)
except Exception as e: # noqa: BLE001 — เรียกไม่ได้ = ใช้กฎเดิม
self.total_ms += (time.perf_counter() - t0) * 1000
self.failures += 1
self.log(f"⚠️ ตัวตีความคำตอบ: {type(e).__name__}: {e} — ใช้กฎเดิม")
return None
self.total_ms += (time.perf_counter() - t0) * 1000
if reading is None:
self.failures += 1
self.log(f"⚠️ ตัวตีความคำตอบ: ผลไม่ผ่านการตรวจ ({(text or '')[:120]!r}) — ใช้กฎเดิม")
return None
reading.ms = round((time.perf_counter() - t0) * 1000, 1)
return reading
def parse_reading(text: str, expect: str) -> Optional[AnswerReading]:
"""ตรวจ JSON จาก LLM — ผิดรูป · kind แปลก · ค่าหลุดช่วง → None (ผู้เรียกใช้กฎเดิม)"""
m = re.search(r"\{.*\}", text or "", re.DOTALL)
if not m:
return None
try:
d = json.loads(m.group(0))
except json.JSONDecodeError:
return None
if not isinstance(d, dict) or d.get("kind") not in KINDS:
return None
val = d.get("value")
if val is not None:
try:
val = float(val)
except (TypeError, ValueError):
return None
wd = d.get("wet_or_dry")
if wd not in (None, "wet", "dry"):
wd = None
r = AnswerReading(kind=d["kind"], value=val, unit=d.get("unit"), wet_or_dry=wd,
note=str(d.get("note") or "")[:200], raw=d)
if r.kind == "value":
if expect in RANGES:
n = r.number(expect)
lo, hi = RANGES[expect]
if n is None or not (lo <= n <= hi):
return None
elif expect == "wet_dry" and r.wet_or_dry is None:
return None
return r
|