Beltbot / check_app.py
SupantBT's picture
plugin core + monitor (main 443d2aa)
ede84b4 verified
Raw History Blame Contribute Delete
32.8 kB
#!/usr/bin/env python3
"""ตรวจโฟลเดอร์เดียวก่อนรัน — ใช้กับผังแบบรวมทุกอย่างไว้ที่โฟลเดอร์นี้
ต่างจาก check_local.py เดิมที่แยก space/ กับ web/ ตัวนี้คาดว่าไฟล์ของบอตอยู่แบน ๆ
ที่โฟลเดอร์นี้ และมีแต่ web/ ที่ยังเป็นโฟลเดอร์ย่อย (เพราะ Next.js ต้องมี package.json
ของตัวเอง) เหตุผลที่ไฟล์บอตต้องอยู่ "ที่ราก" คือ chatbot_v5.py อ้างพาธแบบ ./xxx
ซึ่งนับจากโฟลเดอร์ที่สั่งรัน ไม่ใช่ที่ไฟล์วางอยู่ — วางลึกกว่านี้แล้วเปิดจากที่อื่นจะหาไม่เจอ
สิ่งที่ตัวนี้จับได้และคนมักพลาด
1. เอา zip เก่า (27 ก.ย.) ทับไฟล์ที่แก้วันที่ 29–30 ก.ย. — ไม่มี error บอตยังรัน แค่ตอบผิดเดิม
2. ลืมคัดลอกฐานข้อมูลหรือโมเดล — พังตอน import ไม่ใช่ตอนตอบ จะดูเหมือนโค้ดเสีย
3. python คนละตัวกับรอบที่รันได้ (30 ก.ย. เจอมาแล้ว prompt ขึ้น (.venv) แต่เรียกตัวหลัก)
4. PROXY_SECRET สองฝั่งไม่ตรง — หน้าเว็บขึ้นปกติแต่พิมพ์แล้ว error
รัน: python check_app.py
"""
from __future__ import annotations
import json
import os
import re
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
WEB = ROOT / "web"
ok: list[str] = []
warn: list[str] = []
bad: list[str] = []
def check(cond: bool, good: str, fail: str, hard: bool = True) -> bool:
(ok if cond else (bad if hard else warn)).append(good if cond else fail)
return cond
def read(p: Path) -> str:
try:
return p.read_text(encoding="utf-8", errors="ignore")
except OSError:
return ""
def human(n: int) -> str:
for u in ("B", "KB", "MB", "GB"):
if n < 1024 or u == "GB":
return f"{n:.0f} {u}" if u == "B" else f"{n:.1f} {u}"
n /= 1024
return f"{n:.1f} GB"
def dir_size(p: Path) -> int:
return sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
# ── 0. สภาพแวดล้อม ───────────────────────────────────────────
# 30 ก.ย. ระบบพังเพราะ prompt ขึ้น (.venv) แต่ python ไปเรียก interpreter ตัวหลัก
# ซึ่งมี transformers คนละเวอร์ชัน โค้ดไม่ได้เปลี่ยนเลยแต่พังตั้งแต่ import
# ที่เสียเวลาที่สุดคือการไล่หาว่าอะไรเปลี่ยน ทั้งที่ traceback บอกอยู่แล้ว
# ตัวตรวจเวอร์ชันนี้ต้องแยกสามกรณีให้ออก ไม่ใช่สองกรณี
# · import ไม่ได้ = ไม่ได้ติดตั้งจริง → ต้องแก้
# · import ได้ ไม่มี __version__ = ติดตั้งแล้ว แค่แพ็กเกจไม่ประกาศ → ไม่ใช่ปัญหา
# · import ได้ มีเวอร์ชัน = ปกติ
# รุ่นแรกเขียน __import__(mod).__version__ ใน try เดียว แล้วจับ Exception กว้าง
# qdrant_client ไม่มี __version__ (ยืนยันแล้วกับ 1.19.1) จึงโดนรายงานว่า "ไม่ได้ติดตั้ง"
# แล้วขึ้น ✗ บล็อกการรัน ทั้งที่แพ็กเกจอยู่ครบและบอตรันได้ — ตัวตรวจผิด ไม่ใช่เครื่องผิด
CHECKER_VERSION = "2" # ขึ้นเลขนี้เมื่อวิธีอ่านเวอร์ชันเปลี่ยน
DIST_NAME = {"qdrant_client": "qdrant-client"} # ชื่อแพ็กเกจ ≠ ชื่อโมดูล
def probe(mod: str) -> tuple[str, bool]:
"""คืน (ข้อความเวอร์ชัน, ติดตั้งแล้วหรือไม่)"""
try:
m = __import__(mod)
except ImportError:
return "(ไม่ได้ติดตั้ง)", False
except Exception as e: # noqa: BLE001
return f"(import พังแล้ว: {type(e).__name__})", False
v = getattr(m, "__version__", None)
if isinstance(v, str) and v:
return v, True
try:
from importlib.metadata import version
return version(DIST_NAME.get(mod, mod)), True
except Exception: # noqa: BLE001
return "(ติดตั้งแล้ว อ่านเวอร์ชันไม่ได้)", True
print("0) สภาพแวดล้อม")
env: dict[str, str] = {"python": sys.executable}
missing_pkg: list[str] = []
for mod in ("transformers", "torch", "qdrant_client", "peft", "gradio"):
env[mod], installed = probe(mod)
if not installed:
missing_pkg.append(mod)
for k, v in env.items():
print(f" {k:<14} {v}")
check(not missing_pkg,
" ✓ ไลบรารีหลักครบ",
f" ✗ ยังไม่ได้ติดตั้ง {', '.join(missing_pkg)} ใน python ตัวนี้\n"
" ถ้าเคยรันได้แปลว่ากำลังใช้ python ผิดตัว ไม่ใช่ลืมติดตั้ง")
LOCK = ROOT / "env_lock.json"
_stamp = dict(env, _checker=CHECKER_VERSION)
if LOCK.is_file():
try:
was = json.loads(LOCK.read_text(encoding="utf-8"))
except json.JSONDecodeError:
was = {}
# ถ้าล็อกถูกบันทึกด้วยตัวตรวจรุ่นก่อน ค่าที่ต่างอาจมาจากวิธีอ่านที่เปลี่ยน
# ไม่ใช่สภาพแวดล้อมที่เปลี่ยน — เขียนทับเงียบ ๆ ดีกว่าเตือนผิดแล้วคนเลิกเชื่อคำเตือน
if was.get("_checker") != CHECKER_VERSION:
LOCK.write_text(json.dumps(_stamp, ensure_ascii=False, indent=1), encoding="utf-8")
print(f" อัปเดต {LOCK.name} ให้ตรงกับวิธีอ่านเวอร์ชันแบบใหม่")
was = _stamp
diff = [k for k in env if was.get(k) != env[k]]
check(not diff,
" ✓ ตรงกับสภาพแวดล้อมที่บันทึกไว้",
" ⚠ สภาพแวดล้อมเปลี่ยนจากที่บันทึกไว้:\n" +
"\n".join(f" {k}: {was.get(k)} → {env[k]}" for k in diff) +
"\n ถ้าพฤติกรรมเปลี่ยน ให้สงสัยตรงนี้ก่อนสงสัยโค้ด", hard=False)
else:
LOCK.write_text(json.dumps(_stamp, ensure_ascii=False, indent=1), encoding="utf-8")
print(f" บันทึกไว้ที่ {LOCK.name} แล้ว รอบหน้าจะเทียบให้")
# ── 1. โค้ด ──────────────────────────────────────────────────
# ชื่อพวกนี้มาจากบรรทัด import ของ assistant.py และปลั๊กอิน ขาดตัวไหนพังตอน import ทันที
CODE = {
"assistant.py": "ตัวบอต — โหลดโมเดล ต่อ Gemini ประกอบปลั๊กอิน (ชื่อใหม่ของ chatbot_v5.py)",
"chatbot_v5.py": "ชื่อเดิม → assistant.py",
"core/turn.py": "หนึ่งเทิร์นของแชต (ไม่รู้จักแบรนด์)",
"brands/movex/runtime.py": "ค้น/tool/prompt ของ Movex (ครอบ retriever_v4)",
"brands/modutech/runtime.py": "ค้น/tool ของ Modutech (collection แยก)",
"app.py": "FastAPI ที่หน้าเว็บเรียก (มาจาก beltbot_web_deploy.zip)",
"retriever_v4.py": "ค้นข้อมูลจาก Qdrant",
"knowledge_graph.py": "กราฟความเข้ากันได้ของ Movex",
"pipeline_logger.py": "บันทึกทุกขั้นของการตอบ",
"brands/modutech/gates.py": "เกตกรองคำตอบ Modutech",
"brands/modutech/router.py": "แยกว่าคำถามเป็น Movex หรือ Modutech",
"brands/modutech/turn.py": "ประกอบคำตอบหนึ่งเทิร์นของ Modutech",
"dialogue_state.py": "ทางเข้าชื่อเดิม → core/decide.py + brands/*/plugin.py",
"core/decide.py": "ตัดสินก่อนถึง LLM ส่วนกลาง (ไม่รู้จักแบรนด์)",
"brands/movex/plugin.py": "ตัดสินของ Movex",
"brands/modutech/plugin.py": "ตัดสินของ Modutech",
"brands/modutech/kg.py": "กราฟความสัมพันธ์ + ตัวโหลดที่ตรวจลายนิ้วมือ",
# ชื่อเดิมที่ราก — ตัวชี้ไปโมดูลจริงใน brands/modutech/ (ชุดเกต import ชื่อนี้)
"modutech_gates.py": "ชื่อเดิม → brands/modutech/gates.py",
"modutech_router.py": "ชื่อเดิม → brands/modutech/router.py",
"modutech_turn.py": "ชื่อเดิม → brands/modutech/turn.py",
"modutech_kg.py": "ชื่อเดิม → brands/modutech/kg.py",
"knowledge_graph.py": "คลาสกราฟที่ใช้ร่วมกับ Movex (โหลดไฟล์ .db)",
"sales_contact.py": "เบอร์/อีเมลฝ่ายขายตัวจริง",
}
print("\n1) ไฟล์โค้ด (ที่รากกับใต้ brands/)")
for f, why in CODE.items():
check((ROOT / f).is_file(), f" ✓ {f} — {why}", f" ✗ ไม่พบ {f} ({why})")
# ── 2. ข้อมูลและโมเดลในเครื่อง ────────────────────────────────
# พาธพวกนี้อ่านจาก brands/<key>/brand.yaml › data (ชั้น 5 — เดิมอ่านจากค่าคงที่ใน chatbot_v5.py)
src5 = read(ROOT / "assistant.py")
def _brand(key: str) -> dict:
import yaml
p = ROOT / "brands" / key / "brand.yaml"
return (yaml.safe_load(p.read_text(encoding="utf-8")) or {}) if p.is_file() else {}
def _bpath(key: str, *keys: str, default: str = "") -> str:
node = _brand(key)
for k in keys:
node = (node or {}).get(k) if isinstance(node, dict) else None
if not node:
return default
rel = os.path.relpath(os.path.normpath(os.path.join(ROOT, "brands", key, node)), ROOT)
return "./" + rel.replace(os.sep, "/")
_CONSTS = {
"QDRANT_PATH": _bpath("movex", "data", "qdrant_path", default="./qdrant_db"),
"JSON_PATH": _bpath("movex", "data", "products", default="./extracted_data_v3_machine_ready.json"),
"KG_DB_PATH": _bpath("movex", "data", "graph_db", default="./movex_kg.db"),
"VIT_MODEL_PATH": _bpath("movex", "image", "vit", "adapter", default="./movex-lora-tuned-3"),
"MODUTECH_JSON": _bpath("modutech", "data", "catalogue", default="./modutech_v30_3.json"),
"MODUTECH_KG_DB": _bpath("modutech", "data", "graph_db", default="./modutech_kg.db"),
"MODUTECH_QDRANT": _bpath("modutech", "data", "qdrant_path", default="./qdrant_modutech_db"),
}
def const(name: str, default: str) -> str:
return _CONSTS.get(name) or default
NEEDED = [
(const("QDRANT_PATH", "./qdrant_db"), "dir", "ฐานเวกเตอร์ Movex — ห้ามแก้ ห้ามรัน build ทับ"),
(const("JSON_PATH", "./extracted_data_v3_machine_ready.json"), "file", "ข้อมูลสินค้า Movex"),
(const("KG_DB_PATH", "./movex_kg.db"), "file", "กราฟ Movex"),
(const("VIT_MODEL_PATH", "./movex-lora-tuned-3"), "dir", "ViT LoRA ที่เทรนไว้ (ใช้ตอนส่งรูป)"),
(const("MODUTECH_JSON", "./modutech_v30_3.json"), "file", "ข้อมูล Modutech"),
(const("MODUTECH_QDRANT", "./qdrant_modutech_db"), "dir", "ฐานเวกเตอร์ Modutech (collection แยกจาก Movex)"),
]
print("\n2) ข้อมูลและโมเดลในเครื่อง")
for rel, kind, why in NEEDED:
p = (ROOT / rel).resolve()
hit = p.is_dir() if kind == "dir" else p.is_file()
size = f" [{human(dir_size(p) if kind == 'dir' else p.stat().st_size)}]" if hit else ""
check(hit, f" ✓ {rel}{size} — {why}", f" ✗ ไม่พบ {rel} ({why})")
# collection ของ Qdrant แบบไฟล์ในเครื่องอยู่ใต้ <path>/collection/<ชื่อ>
# ถ้าคัดลอกมาไม่ครบ บอตจะขึ้นมาได้แต่ค้นไม่เจออะไรเลย ซึ่งดูเหมือนโมเดลโง่ ไม่เหมือนไฟล์หาย
for rel, expect in ((const("QDRANT_PATH", "./qdrant_db"), ("movex_text_chunks", "movex_images")),
("./qdrant_modutech_db", ())):
cdir = (ROOT / rel / "collection")
if cdir.is_dir():
got = sorted(d.name for d in cdir.iterdir() if d.is_dir())
check(bool(got), f" ✓ {rel} มี collection: {', '.join(got)}",
f" ✗ {rel}/collection ว่าง — คัดลอกมาไม่ครบ")
miss = [c for c in expect if c not in got]
if expect:
check(not miss, f" ✓ collection ของ Movex ครบ",
f" ✗ {rel} ขาด {', '.join(miss)} — Movex จะค้นไม่เจอ")
elif (ROOT / rel).is_dir():
warn.append(f" ⚠ {rel} ไม่มีโฟลเดอร์ collection/ — อาจเป็นผังของ Qdrant คนละรุ่น")
# ── 3. เวอร์ชันของไฟล์ที่แก้ 29–30 ก.ย. ───────────────────────
# ตรวจด้วยข้อความเฉพาะในโค้ด ไม่ใช่วันที่ไฟล์ ซึ่งเปลี่ยนตอนคัดลอกอยู่แล้ว
MARKERS = {
"brands/modutech/gates.py": [
("inch_basis", "ค่านิ้วคำนวณจาก มม. ทุกฟิลด์ (30 ก.ย.)"),
("NO_INCREMENT_TH", "ไม่ประกาศค่าเพิ่มทีละที่ยืนยันไม่ได้ (29 ก.ย.)"),
("catalogue_nonstandard_increment", "อ่านขนาดนอกมาตรฐานจากเล่ม (29 ก.ย.)"),
("temperature_unconfirmed_th", "คำเตือน MD254 RR (29 ก.ย.)"),
],
# ย้ายจาก dialogue_state.py ไป core/decide.py (ชั้น 4)
"core/decide.py": [
("FOLLOWUP_MAX_CHARS", "คำถามต่อเนื่องสั้น ๆ ไม่ถูกตีเป็นนอกเรื่อง (29 ก.ย.)"),
("นิ้ว", "คำหน่วยวัดใน DOMAIN_WORDS (29 ก.ย.)"),
],
}
print("\n3) เวอร์ชันของไฟล์ที่แก้ 29–30 ก.ย.")
for fname, marks in MARKERS.items():
src = read(ROOT / fname)
if not src:
continue
for token, why in marks:
check(token in src, f" ✓ {fname}: {why}",
f" ✗ {fname} เป็นรุ่นเก่า — ขาด {why} (เอา zip เก่าทับ?)")
if src5:
got = const("MODUTECH_JSON", "(ไม่พบบรรทัด)")
check(got.endswith("modutech_v30_3.json"),
f" ✓ brands/modutech/brand.yaml ชี้ไป {got}",
f" ✗ brands/modutech/brand.yaml ชี้ไป {got} — ต้องเป็น ./modutech_v30_3.json ไม่ใช่ v30 หรือ v29")
# torch_dtype= พังกับ transformers บางเวอร์ชัน (TypeError: hasattr) แล้วกลับมารันได้เอง
# จึงเตือนอย่างเดียว ไม่บล็อก
check("torch_dtype=" not in src5,
" ✓ ไม่ได้ส่ง torch_dtype เข้า from_pretrained",
" ⚠ assistant.py ส่ง torch_dtype= เข้า Jina v3 — เคยพังเมื่อ transformers อัปเดต\n"
" ถ้าขึ้น TypeError: hasattr(): attribute name must be string ให้ตัดออก\n"
" แล้วใช้ .to(device=DEVICE, dtype=...) ต่อท้ายแทน", hard=False)
# ── 4. เนื้อข้อมูล ───────────────────────────────────────────
print("\n4) เนื้อข้อมูล Modutech")
jp = ROOT / const("MODUTECH_JSON", "./modutech_v30_3.json").lstrip("./")
if jp.is_file():
try:
data = json.loads(jp.read_text(encoding="utf-8"))
prods = data.get("products", [])
n_pair = sum(len(p.get("industries") or []) for p in prods)
n_app = sum(len(a.get("applications") or [])
for p in prods for a in p.get("industries") or [])
check((len(prods), n_pair, n_app) == (68, 211, 651),
f" ✓ นับได้ {len(prods)} สายพาน · {n_pair} คู่ · {n_app} การใช้งาน",
f" ✗ นับได้ {len(prods)} · {n_pair} · {n_app} ต้องเป็น 68 · 211 · 651")
empty = [p["belt_code"] for p in prods
if not (p.get("specifications", {}).get("notes") or [])]
check(not empty, " ✓ ทุกรุ่นมีหมายเหตุจากเล่มครบ",
f" ✗ มี {len(empty)} รุ่นที่ notes ว่าง — ไฟล์นี้คือ v30 เก่า ไม่ใช่ v30_3")
md = next((p for p in prods if p["belt_code"] == "MD254 RR"), None)
if md:
# เกรดอยู่ใต้ specifications.variants ไม่ใช่ที่รากของสินค้า
v = [x for x in md.get("specifications", {}).get("variants") or []
if x.get("variant_id") == "md254-rr--pph-pph"]
check(bool(v) and v[0].get("temperature_trust") == "unconfirmed",
" ✓ MD254 RR เกรด PPH ตั้ง temperature_trust = unconfirmed แล้ว",
" ✗ MD254 RR เกรด PPH ยังไม่ได้ตั้ง unconfirmed — จะตอบ 93 °C โดยไม่เตือน")
# v30_2 (11 ต.ค.): หมายเหตุสภาวะเปียกเขียนใหม่ ของเดิมเก็บไว้ใน wet_note_th_v30
effs = [x.get("temperature_effective") or {} for p in prods
for x in p.get("specifications", {}).get("variants") or []]
n_new = sum("wet_note_th_v30" in e for e in effs)
old_left = sum("ชั้นที่มีข้อมูล" in (e.get("wet_note_th") or "") for e in effs)
check(n_new == 75 and not old_left,
f" ✓ หมายเหตุสภาวะเปียกเป็นฉบับ v30_2 ({n_new} variant)",
f" ✗ หมายเหตุสภาวะเปียกยังเป็นของเก่า ({n_new}/75 · เหลือข้อความเดิม {old_left}) — ไฟล์นี้คือ v30_1 ไม่ใช่ v30_2")
# v30_3 (11 ต.ค. ค่ำ): ทุกจุดที่ให้ลูกค้ายืนยันใช้พนักงานฝ่ายขาย ไม่ใช่ฝ่ายเทคนิค
tech_left = jp.read_text(encoding="utf-8").count("ฝ่ายเทคนิค") - sum(
x.get("old", "").count("ฝ่ายเทคนิค") for x in data.get("_text_patch_v30_3") or [])
check("_text_patch_v30_3" in data and tech_left == 0,
" ✓ ข้อความยืนยันใช้พนักงานฝ่ายขาย (v30_3)",
f" ✗ ยังไม่ใช่ v30_3 (เหลือ 'ฝ่ายเทคนิค' {tech_left} จุดนอกบันทึกแพตช์)")
except json.JSONDecodeError as e:
bad.append(f" ✗ อ่าน {jp.name} ไม่ได้: {e}")
sc = read(ROOT / "sales_contact.py")
if sc:
check(bool(re.search(r"\d{2,3}-\d{3,4}-?\d{3,4}|@", sc)),
" ✓ sales_contact.py มีช่องทางติดต่อจริง",
" ✗ sales_contact.py ว่าง — น่าจะโดน zip เก่าทับ ต้องใส่เบอร์/อีเมลกลับ", hard=False)
# ── 4.5 กราฟความสัมพันธ์สินค้า ─────────────────────────────────
# ไฟล์ .db เป็นสำเนาที่สองของความสัมพันธ์ใน JSON ความเสี่ยงคือแก้ JSON แล้วลืมสร้างใหม่
# ลายนิ้วมือใน metadata จับเรื่องนี้ได้ ตรวจที่นี่จะรู้ก่อนรัน ไม่ต้องไปอ่าน log ตอนสตาร์ต
print("\n4.5) กราฟความสัมพันธ์สินค้า")
KG_DB = ROOT / const("MODUTECH_KG_DB", "./modutech_kg.db").lstrip("./")
if not KG_DB.is_file():
warn.append(f" ⚠ ไม่พบ {KG_DB.name} — ระบบจะสร้างกราฟจาก JSON ทุกครั้งที่สตาร์ต (ยังตอบถูก)\n"
f" สร้างไฟล์ด้วย: python build_modutech_graph.py")
else:
try:
sys.path.insert(0, str(ROOT))
from modutech_kg import (assert_no_blocked_fields, catalogue_fingerprint,
db_fingerprint)
want = catalogue_fingerprint(json.loads(jp.read_text(encoding="utf-8"))) if jp.is_file() else None
got = db_fingerprint(str(KG_DB))
if got is None:
check(False, "", f" ⚠ {KG_DB.name} ไม่มีลายนิ้วมือ — สร้างด้วย builder รุ่นเก่า\n"
f" สร้างใหม่: python build_modutech_graph.py", hard=False)
elif want and got != want:
check(False, "", f" ⚠ {KG_DB.name} ล้าสมัย — ไม่ตรงกับ {jp.name} ปัจจุบัน\n"
f" ไฟล์ {got[:12]} · ข้อมูล {want[:12]}\n"
f" สร้างใหม่: python build_modutech_graph.py", hard=False)
else:
ok.append(f" ✓ {KG_DB.name} ลายนิ้วมือตรงกับข้อมูลปัจจุบัน")
blocked = assert_no_blocked_fields(str(KG_DB))
check(not blocked, " ✓ กราฟไม่มีฟิลด์ที่ sprocket_gate บล็อก",
f" ⚠ กราฟมีฟิลด์ที่ sprocket_gate บล็อกไว้: {', '.join(blocked)}\n"
f" สร้างใหม่ด้วย build_modutech_graph.py รุ่นที่ตัดฟิลด์พวกนี้ออก", hard=False)
except ImportError as e:
warn.append(f" ⚠ ตรวจกราฟไม่ได้: {e}")
check((ROOT / "build_modutech_graph.py").is_file(),
" ✓ มี build_modutech_graph.py ไว้สร้างกราฟใหม่",
" ⚠ ไม่มี build_modutech_graph.py — แก้ข้อมูลแล้วสร้างกราฟใหม่ไม่ได้", hard=False)
# ── 5. หน้าเว็บ ──────────────────────────────────────────────
print("\n5) หน้าเว็บ (web/)")
for f in ("package.json", "app/page.tsx", "app/layout.tsx", "app/globals.css"):
check((WEB / f).is_file(), f" ✓ web/{f}", f" ✗ ไม่พบ web/{f}")
check((WEB / "node_modules").is_dir(), " ✓ ติดตั้ง node_modules แล้ว",
" ✗ ยังไม่ได้ npm install ใน web/ (สคริปต์รันจะทำให้ถ้าไม่มี)", hard=False)
check(shutil.which("node") is not None, " ✓ มี node ในเครื่อง",
" ✗ ไม่พบ node — ติดตั้ง Node.js 18+ ก่อน")
# ── 5.5 ชุดทดสอบที่ควรมีติดไว้ ────────────────────────────────
print("\n5.5) ชุดทดสอบ")
for f, why in (("testsuite_120.py", "136 เคส ชั้นเกต"),
("test_sprocket_flow.py", "ทางเดินคำถามสเตอร์ + กันขนาดหลุด"),
("test_modutech_kg.py", "กราฟตรงกับ JSON")):
check((ROOT / f).is_file(), f" ✓ {f} — {why}",
f" ⚠ ไม่มี {f} ({why}) รันได้แต่ไม่มีตัวจับการถดถอย", hard=False)
# ── 6. ค่าตั้ง ───────────────────────────────────────────────
print("\n6) ค่าตั้ง")
def env_of(path: Path) -> dict[str, str]:
out: dict[str, str] = {}
for line in read(path).splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
return out
se, we = env_of(ROOT / ".env"), env_of(WEB / ".env.local")
check(bool(se), " ✓ .env มีอยู่ที่รากโฟลเดอร์", " ✗ ไม่พบ .env — คัดลอกจาก .env.example")
check(bool(we), " ✓ web/.env.local มีอยู่", " ✗ ไม่พบ web/.env.local — คัดลอกจาก .env.local.example")
s_secret, w_secret = se.get("PROXY_SECRET", ""), we.get("PROXY_SECRET", "")
check(bool(s_secret) and s_secret == w_secret,
" ✓ PROXY_SECRET สองฝั่งตรงกัน",
" ✗ PROXY_SECRET ไม่ตรงกันหรือยังว่าง — หน้าเว็บจะขึ้นปกติแต่พิมพ์แล้ว error")
# 10 ต.ค. — สองไฟล์ตรงกันทั้งคู่แต่ได้ 403: Next.js ตัดคำพูดออก ตัวอ่าน .env ของ run_app.ps1 ไม่ตัด
# backend จึงถือ "abc" (มีคำพูด) ส่วนหน้าเว็บส่ง abc → เทียบไฟล์ดิบอย่างเดียวจับไม่ได้
# ทุกค่าใน .env ไม่ใช่แค่ PROXY_SECRET — GEMINI_API_KEY ที่มีคำพูดได้ API_KEY_INVALID (เจอ 10 ต.ค.)
def _q(v: str) -> bool:
return len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'"
_quoted = [f".env:{k}" for k, v in se.items() if _q(v)] + \
[f"web/.env.local:{k}" for k in ("PROXY_SECRET",) if _q(we.get(k, ""))]
check(not _quoted, " ✓ ค่าใน .env ไม่มีเครื่องหมายคำพูดครอบ",
f" ✗ มีเครื่องหมายคำพูดครอบ: {', '.join(_quoted)} — run_app.ps1 ไม่ตัดคำพูด"
" (PROXY_SECRET → 403 · GEMINI_API_KEY → API_KEY_INVALID) · ลบคำพูดออก")
check(we.get("SPACE_URL", "").startswith("http://127.0.0.1"),
f" ✓ SPACE_URL = {we.get('SPACE_URL')}",
f" ✗ SPACE_URL = {we.get('SPACE_URL') or '(ว่าง)'} รันในเครื่องต้องเป็น http://127.0.0.1:7860")
check(bool(se.get("GEMINI_API_KEY") or os.environ.get("GEMINI_API_KEY")),
" ✓ มี GEMINI_API_KEY", " ✗ ยังไม่มี GEMINI_API_KEY ใน .env")
# 13 ต.ค. — GEMINI_THINKING ต้องใช้ google-genai · venv นี้ Python 3.10.0 + gradio (pydantic < 2.12) ใช้ได้แค่ 2.8.x
_think = se.get("GEMINI_THINKING") or os.environ.get("GEMINI_THINKING") or ""
if _think.strip():
try:
from core.llm import parse_thinking
_tc = parse_thinking(_think)
check(True, f" ✓ GEMINI_THINKING={_think.strip()} → {_tc}", "")
except ValueError as e:
check(False, "", f" ✗ {e}")
try:
from importlib.metadata import version as _ver
_gg, _pd = _ver("google-genai"), _ver("pydantic")
check(_gg.startswith("2.8.") and tuple(int(x) for x in _pd.split(".")[:2]) < (2, 12),
f" ✓ google-genai {_gg} · pydantic {_pd}",
f" ✗ google-genai {_gg} · pydantic {_pd} — ต้องเป็น google-genai 2.8.x กับ pydantic < 2.12 "
"(ดูหัวไฟล์ core/llm.py)")
import google.genai # noqa: F401
check(True, " ✓ import google-genai ได้", "")
except Exception as e: # noqa: BLE001
check(False, "", f" ✗ GEMINI_THINKING ตั้งไว้แต่ใช้ google-genai ไม่ได้ ({type(e).__name__}: {e})"
" — ติดตั้งตามหัวไฟล์ core/llm.py หรือลบ GEMINI_THINKING ออกจาก .env")
else:
warn.append(" ⚠ ยังไม่ได้ตั้ง GEMINI_THINKING — ใช้ thinking เต็ม (ช้ากว่า) · ดูหัวไฟล์ core/llm.py")
# ── สรุป ─────────────────────────────────────────────────────
for line in ok:
print(line)
print()
for line in warn:
print(line.replace("✗", "⚠"))
for line in bad:
print(line)
print()
if bad:
print(f"❌ ยังรันไม่ได้ — ต้องแก้ {len(bad)} จุดข้างบนก่อน")
sys.exit(1)
print("✅ พร้อมรัน")
print(" สำคัญ: ต้องสั่ง uvicorn จาก 'โฟลเดอร์นี้' เพราะพาธในโค้ดเป็นแบบ ./xxx")
print(f" {ROOT}")
if warn:
print(f" (มีข้อเตือน {len(warn)} จุด รันได้แต่ควรดู)")
sys.exit(0)