dhammawatthumpra commited on
Commit
c61e695
·
1 Parent(s): 4644d66

feat: ปรับปรุงระบบ Tipitaka AI Reader และ LLM Prompt

Browse files

- เพิ่มระบบ AI Disclaimer กาลามสูตร 10 วินาทีใน AIPopup

- เพิ่มปุ่ม Floating Maximize สำหรับ Focus Mode ในหน้า Chat

- ปรับปรุง System Prompt ของ AI ให้ตัดภาษาอังกฤษในหัวข้อ และรองรับชื่อโรมันในวงเล็บ

- ปรับปรุงโครงสร้าง Backend (FastAPI) และการจัดการ RAG

Dockerfile CHANGED
@@ -39,7 +39,7 @@ COPY webapp/tipitaka-api/ ./api/
39
  RUN chmod +x /app/api/startup.sh
40
 
41
  # ── Create data directory (DB + vector files downloaded at runtime) ──
42
- RUN mkdir -p /app/data/db_vector
43
 
44
  # ── Port (HF Space expects 7860) ──
45
  EXPOSE 7860
 
39
  RUN chmod +x /app/api/startup.sh
40
 
41
  # ── Create data directory (DB + vector files downloaded at runtime) ──
42
+ RUN mkdir -p /app/data/qdrant_storage /app/data/snapshots
43
 
44
  # ── Port (HF Space expects 7860) ──
45
  EXPOSE 7860
chat_expert.py CHANGED
@@ -41,12 +41,13 @@ class QwenEmbeddingFunction(EmbeddingFunction):
41
  return embeddings.tolist()
42
 
43
  # ========== Config ==========
44
- CHROMA_PATH = "./db_vector"
 
45
  COLLECTION_NAME = "tipitaka_mcu_qwen"
46
  EMBEDDING_MODEL = "Qwen/Qwen3-Embedding-0.6B"
47
  OLLAMA_MODEL = "gemma-4-e2b-it-q5_k_m"
48
  BATCH_SIZE = 32
49
- DB_PATH = "tipitaka_mcu.db"
50
  N_RESULTS = 10 # ดึงมาเยอะเพื่อ dedup แล้วกรองทีหลัง
51
  SNIPPET_LEN = 400 # จำนวนตัวอักษรที่แสดงในโหมดย่อ
52
  RELEVANCE_GAP = 0.08 # ตัดผลที่ distance ห่างจาก best เกินค่านี้
 
41
  return embeddings.tolist()
42
 
43
  # ========== Config ==========
44
+ DATA_DIR = "F:/_Ai/Tipitaka-AI-Expert/Tipitaka-Data"
45
+ CHROMA_PATH = f"{DATA_DIR}/db_vector"
46
  COLLECTION_NAME = "tipitaka_mcu_qwen"
47
  EMBEDDING_MODEL = "Qwen/Qwen3-Embedding-0.6B"
48
  OLLAMA_MODEL = "gemma-4-e2b-it-q5_k_m"
49
  BATCH_SIZE = 32
50
+ DB_PATH = f"{DATA_DIR}/tipitaka_mcu.db"
51
  N_RESULTS = 10 # ดึงมาเยอะเพื่อ dedup แล้วกรองทีหลัง
52
  SNIPPET_LEN = 400 # จำนวนตัวอักษรที่แสดงในโหมดย่อ
53
  RELEVANCE_GAP = 0.08 # ตัดผลที่ distance ห่างจาก best เกินค่านี้
tipitaka_query.py CHANGED
@@ -37,19 +37,18 @@ def clean_text(
37
  for_tts: bool = False
38
  ) -> str:
39
  """
40
- ทำความสะอาดข้อความพระไตรปิฎก
41
 
42
  Args:
43
  text: ข้อความดิบ
44
  remove_line_numbers: ลบเลขบรรทัด (001, 002, ...)
45
- remove_footnotes: ลบเชิงอรรถ (บรรทัดที่ขึ้นต้นด้วย @)
46
  remove_item_numbers: ลบเลขข้อ [๑], [๒๓], ๑- เป็นต้น
47
  for_tts: เตรียมสำหรับ TTS (ลบทุกอย่างที่ไม่ควรอ่าน)
48
 
49
  Returns:
50
  ข้อความที่ clean แล้ว
51
  """
52
- # ถ้าเป็น TTS mode ให้เปิด option ทั้งหมด
53
  if for_tts:
54
  remove_line_numbers = True
55
  remove_footnotes = True
@@ -59,40 +58,37 @@ def clean_text(
59
  cleaned = []
60
 
61
  for line in lines:
62
- # ลบเลขบรรทัดหน้าบรรทัก่อน (เช่น "001 ", "002 ")
63
  if remove_line_numbers:
64
  line = re.sub(r'^\s*\d{3}\s+', '', line)
65
 
66
- # ข้ามบรรทัดเชิงอรรถ (ขึ้นต้นด้วย @)
67
- if remove_footnotes and line.strip().startswith('@'):
68
- continue
 
 
 
69
 
70
- # ลบเลขข้อ [๑], [๒๓], [๑๒๓] และ ๑-, ๑-๒ เป็นต้น
71
  if remove_item_numbers:
72
- # ลบ [๐-๙] แบบไทย
73
  line = re.sub(r'\[[\u0E50-\u0E59]+\]', '', line)
74
- # ลบ [0-9] แบบอาหรับ
75
  line = re.sub(r'\[\d+\]', '', line)
76
- # ลบ ๑-, ๑-๒ ที่อยู่ท้ายประโยค (footnote markers)
77
  line = re.sub(r'[\u0E50-\u0E59]+-[\u0E50-\u0E59]*', '', line)
78
  line = re.sub(r'\d+-\d*', '', line)
79
  else:
80
- # กรณีไม่ลบลขข้อ (ต้องารเก็บ [๑] ไว้) แต่ลบ ๑- ทิ้ง
81
- line = re.sub(r'[\u0E50-\u0E59]+-', '', line)
82
-
83
- # สำหรับ TTS: ลบ "หน้าว่าง" และข้อความที่ไม่ควรอ่าน
84
- if for_tts:
85
- if 'หน้าว่าง' in line:
86
- continue
87
- # ลบเส้นขีด ___ และ ---
88
- line = re.sub(r'[_-]{3,}', '', line)
89
 
90
  cleaned.append(line)
91
 
92
  # รวมบรรทัดและลบช่องว่างซ้ำ
93
  result = '\n'.join(cleaned)
94
- result = re.sub(r'\n{3,}', '\n\n', result) # ลดบรรทัดว่างซ้ำ
95
- result = re.sub(r' +', ' ', result) # ลดช่องว่างซ้ำ
96
 
97
  return result.strip()
98
 
 
37
  for_tts: bool = False
38
  ) -> str:
39
  """
40
+ ทำความสะอาดข้อความพระไตรปิฎก (รองรับ Clean Dataset)
41
 
42
  Args:
43
  text: ข้อความดิบ
44
  remove_line_numbers: ลบเลขบรรทัด (001, 002, ...)
45
+ remove_footnotes: ลบเชิงอรรถ (บรรทัดที่ขึ้นต้นด้วย @ หรือ [เชิงอรรถ])
46
  remove_item_numbers: ลบเลขข้อ [๑], [๒๓], ๑- เป็นต้น
47
  for_tts: เตรียมสำหรับ TTS (ลบทุกอย่างที่ไม่ควรอ่าน)
48
 
49
  Returns:
50
  ข้อความที่ clean แล้ว
51
  """
 
52
  if for_tts:
53
  remove_line_numbers = True
54
  remove_footnotes = True
 
58
  cleaned = []
59
 
60
  for line in lines:
61
+ # 1. ลบเลขบรรทัด (ถ้ามีหลุมา)
62
  if remove_line_numbers:
63
  line = re.sub(r'^\s*\d{3}\s+', '', line)
64
 
65
+ # 2. ข้ามบรรทัดเชิงอรรถ
66
+ # รองรับทั้ง @ (แบบเก่า) และ [เชิงอรรถ] (แบบคลีน)
67
+ s_line = line.strip()
68
+ if remove_footnotes:
69
+ if s_line.startswith('@') or s_line.startswith('[เชิงอรรถ]'):
70
+ continue
71
 
72
+ # 3. ลบเลขข้อ และ footnote markers
73
  if remove_item_numbers:
74
+ # ลบ [], [1]
75
  line = re.sub(r'\[[\u0E50-\u0E59]+\]', '', line)
 
76
  line = re.sub(r'\[\d+\]', '', line)
77
+ # ลบ ๑-, ๑-๒, 1-, 1-2
78
  line = re.sub(r'[\u0E50-\u0E59]+-[\u0E50-\u0E59]*', '', line)
79
  line = re.sub(r'\d+-\d*', '', line)
80
  else:
81
+ # เก็บ [๑] ไว้ แต่ลบ ๑- (footnote reference) ทิ้ง
82
+ # ต้องระวังไม่ให้ลบ - ที่เป็นส่วนหนึ่งของเลขข้อ เช่น "๑-๕. เรื่อง..."
83
+ # ปกติ footnote reference จะอยู่หลังคำ/ประโยคทันที โดยไม่มีช่องว่าง
84
+ line = re.sub(r'(?<=[^\s])[\u0E50-\u0E59]+-', '', line)
 
 
 
 
 
85
 
86
  cleaned.append(line)
87
 
88
  # รวมบรรทัดและลบช่องว่างซ้ำ
89
  result = '\n'.join(cleaned)
90
+ result = re.sub(r'\n{3,}', '\n\n', result)
91
+ result = re.sub(r' +', ' ', result)
92
 
93
  return result.strip()
94
 
webapp/tipitaka-api/app/config.py CHANGED
@@ -1,20 +1,74 @@
1
  from pydantic_settings import BaseSettings
2
  from functools import lru_cache
3
  import os
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  class Settings(BaseSettings):
7
  """Application settings — loaded from .env file or environment variables."""
8
 
9
- # ── LLM Provider (admin-only, NOT exposed to frontend) ──
10
  LLM_API_KEY: str = ""
11
  LLM_BASE_URL: str = "https://api.deepseek.com"
12
  LLM_MODEL_FAST: str = "deepseek-chat"
13
  LLM_MODEL_REASONER: str = "deepseek-reasoner"
14
 
15
- # ── Database ──
16
- DATABASE_PATH: str = "../../tipitaka_mcu.db" # relative to app root
17
- CHROMA_PERSIST_PATH: str = "../../db_vector"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  # ── CORS ──
20
  CORS_ORIGINS: str = "*"
@@ -24,13 +78,14 @@ class Settings(BaseSettings):
24
  DEBUG: bool = True
25
  PORT: int = 8000
26
 
27
- # ── Production static file serving (used in Docker/HF Space) ──
28
  SERVE_STATIC: bool = False
29
  STATIC_DIR: str = ""
30
 
31
  class Config:
32
  env_file = ".env"
33
  env_file_encoding = "utf-8"
 
34
 
35
 
36
  @lru_cache()
 
1
  from pydantic_settings import BaseSettings
2
  from functools import lru_cache
3
  import os
4
+ from pathlib import Path
5
 
6
+ # Calculate the project root (tipitaka-api folder)
7
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
8
+
9
+ # Detect Data Directory
10
+ # 1. Environment variable DATA_DIR
11
+ # 2. Docker standard path /app/data (Only if on Linux/Docker)
12
+ # 3. Local project 'data' folder
13
+ if os.getenv("DATA_DIR"):
14
+ DATA_DIR_DEFAULT = os.getenv("DATA_DIR")
15
+ elif os.name != 'nt' and os.path.exists("/app/data"):
16
+ DATA_DIR_DEFAULT = "/app/data"
17
+ else:
18
+ DATA_DIR_DEFAULT = str(PROJECT_ROOT / "data")
19
+
20
+ DATA_DIR = Path(DATA_DIR_DEFAULT)
21
+
22
+ # User's local snapshot path fallback (Specific for this user's machine)
23
+ USER_LOCAL_SNAPSHOT_DIR = Path(r"F:\_Ai\Tipitaka-AI-Expert\Tipitaka-Data\data\snapshots")
24
 
25
  class Settings(BaseSettings):
26
  """Application settings — loaded from .env file or environment variables."""
27
 
28
+ # ── LLM Provider ──
29
  LLM_API_KEY: str = ""
30
  LLM_BASE_URL: str = "https://api.deepseek.com"
31
  LLM_MODEL_FAST: str = "deepseek-chat"
32
  LLM_MODEL_REASONER: str = "deepseek-reasoner"
33
 
34
+ # ── Paths ──
35
+ DATA_DIR: str = str(DATA_DIR)
36
+ DATABASE_PATH: str = ""
37
+ QDRANT_PATH: str = ""
38
+ SNAPSHOT_DIR: str = ""
39
+ QDRANT_URL: str | None = None
40
+
41
+ def __init__(self, **values):
42
+ super().__init__(**values)
43
+ data_path = Path(self.DATA_DIR)
44
+ rag_root = PROJECT_ROOT.parent.parent # F:\_Ai\Tipitaka-AI-Expert\RAG
45
+
46
+ # Initialize paths if not explicitly provided
47
+ if not self.DATABASE_PATH:
48
+ # Priority: 1. data/tipitaka_mcu.db, 2. RAG_ROOT/tipitaka_mcu.db
49
+ local_db = data_path / "tipitaka_mcu.db"
50
+ root_db = rag_root / "tipitaka_mcu.db"
51
+ if local_db.exists():
52
+ self.DATABASE_PATH = str(local_db)
53
+ elif root_db.exists():
54
+ self.DATABASE_PATH = str(root_db)
55
+ else:
56
+ self.DATABASE_PATH = str(local_db) # Fallback
57
+
58
+ if not self.QDRANT_PATH:
59
+ self.QDRANT_PATH = str(data_path / "qdrant_storage")
60
+
61
+ if not self.SNAPSHOT_DIR:
62
+ # Check if user local exists, else use data/snapshots
63
+ if USER_LOCAL_SNAPSHOT_DIR.exists():
64
+ self.SNAPSHOT_DIR = str(USER_LOCAL_SNAPSHOT_DIR)
65
+ else:
66
+ # Also check PROJECT_ROOT / snapshots (common in some setups)
67
+ root_snapshots = rag_root / "snapshots"
68
+ if root_snapshots.exists():
69
+ self.SNAPSHOT_DIR = str(root_snapshots)
70
+ else:
71
+ self.SNAPSHOT_DIR = str(data_path / "snapshots")
72
 
73
  # ── CORS ──
74
  CORS_ORIGINS: str = "*"
 
78
  DEBUG: bool = True
79
  PORT: int = 8000
80
 
81
+ # ── Production static file serving ──
82
  SERVE_STATIC: bool = False
83
  STATIC_DIR: str = ""
84
 
85
  class Config:
86
  env_file = ".env"
87
  env_file_encoding = "utf-8"
88
+ extra = "ignore"
89
 
90
 
91
  @lru_cache()
webapp/tipitaka-api/app/main.py CHANGED
@@ -18,11 +18,35 @@ async def lifespan(app: FastAPI):
18
  settings = get_settings()
19
  db = get_db()
20
 
21
- logger.info("Initializing database...")
22
- db.load_to_memory()
23
- logger.info("Database loaded to memory all queries now run in-memory.")
24
- db.ensure_search_log_table()
25
- logger.info("Search log table ready.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  yield
28
  # Shutdown logic (if any)
 
18
  settings = get_settings()
19
  db = get_db()
20
 
21
+ logger.info(f"Using database at: {db.db_path}")
22
+
23
+ # Run heavy DB loading in a background thread to keep startup responsive
24
+ import anyio
25
+
26
+ async def init_db():
27
+ try:
28
+ logger.info("Initializing services in background...")
29
+
30
+ # 1. Load SQLite
31
+ await anyio.to_thread.run_sync(db.load_to_memory)
32
+ logger.info("Database loaded to memory.")
33
+ await anyio.to_thread.run_sync(db.ensure_search_log_table)
34
+ logger.info("Search log table ready.")
35
+
36
+ # 2. Load RAG (This will trigger snapshot extraction if needed)
37
+ from app.routers.ai import get_llm_service
38
+ logger.info("Pre-loading RAG Service (Qdrant + Embedding Model)...")
39
+ await anyio.to_thread.run_sync(get_llm_service)
40
+ logger.info("RAG Service initialized.")
41
+
42
+ except asyncio.CancelledError:
43
+ logger.info("Background initialization cancelled (system shutting down).")
44
+ except Exception as e:
45
+ logger.error(f"Background initialization failed: {e}")
46
+
47
+ # Start the background initialization task
48
+ import asyncio
49
+ task = asyncio.create_task(init_db())
50
 
51
  yield
52
  # Shutdown logic (if any)
webapp/tipitaka-api/app/routers/ai.py CHANGED
@@ -1,11 +1,12 @@
1
  from functools import lru_cache
 
2
  from fastapi import APIRouter, Depends
3
  from fastapi.responses import JSONResponse
4
  from sse_starlette.sse import EventSourceResponse
5
  from app.services.llm_service import LLMService, AskRequest
6
  from app.database.sqlite_db import get_db, SQLiteDB
7
  from app.config import get_settings
8
- import chromadb
9
  import logging
10
  import json
11
 
@@ -54,33 +55,60 @@ async def ask(request: AskRequest, service: LLMService = Depends(get_llm_service
54
  @router.get("/rag-status")
55
  async def rag_status():
56
  """
57
- Check whether RAG (ChromaDB + Qwen) is ready.
58
- Lightweight — does NOT trigger the full Qwen model load.
59
- Uses the LLMService singleton if already created, otherwise
60
- checks the ChromaDB collection directly.
61
  """
62
- # First check if service was already initialized (avoids triggering model load)
63
- try:
64
- service = get_llm_service()
65
- if service.rag_service.collection is not None:
66
- return {"ready": True, "loading": False}
67
- # Collection exists but model failed — check ChromaDB directly
68
- except Exception:
69
- pass
70
-
71
- # Lightweight: check ChromaDB collection exists without loading embedding model
72
  try:
73
  settings = get_settings()
74
- client = chromadb.PersistentClient(path=settings.CHROMA_PERSIST_PATH)
75
- collections = client.list_collections()
76
- names = [c.name for c in collections]
77
- if "tipitaka_mcu_qwen" in names:
78
- # Collection exists but needs full model load — return loading state
79
- return {"ready": False, "loading": True,
80
- "message": "RAG database found model loading on first query"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  else:
82
- return {"ready": False, "loading": False,
83
- "message": "No ChromaDB collection found"}
84
  except Exception as e:
85
  logger.warning(f"RAG status check failed: {e}")
86
  return {"ready": False, "loading": False, "error": str(e)}
 
1
  from functools import lru_cache
2
+ from pathlib import Path
3
  from fastapi import APIRouter, Depends
4
  from fastapi.responses import JSONResponse
5
  from sse_starlette.sse import EventSourceResponse
6
  from app.services.llm_service import LLMService, AskRequest
7
  from app.database.sqlite_db import get_db, SQLiteDB
8
  from app.config import get_settings
9
+ import qdrant_client
10
  import logging
11
  import json
12
 
 
55
  @router.get("/rag-status")
56
  async def rag_status():
57
  """
58
+ Check whether RAG (Qdrant + Qwen) is ready.
59
+ Truly lightweight — does NOT trigger LLMService initialization.
60
+ Uses filesystem checks to avoid Qdrant locking issues.
 
61
  """
 
 
 
 
 
 
 
 
 
 
62
  try:
63
  settings = get_settings()
64
+
65
+ # 1. Determine if we should check Server or Local
66
+ qdrant_url = getattr(settings, "QDRANT_URL", None)
67
+ if not qdrant_url:
68
+ import httpx
69
+ try:
70
+ # Quick check if server exists even if not configured (auto-detect behavior)
71
+ # We use a sync client here but with a very short timeout,
72
+ # or better yet, just skip to the next part since we do it below anyway.
73
+ # Let's just use the same logic as the service: check localhost:6333
74
+ with httpx.Client() as client:
75
+ if client.get("http://localhost:6333/healthz", timeout=0.2).status_code == 200:
76
+ qdrant_url = "http://localhost:6333"
77
+ except:
78
+ pass
79
+
80
+ if qdrant_url:
81
+ # Server Mode Check
82
+ import httpx
83
+ try:
84
+ async with httpx.AsyncClient() as client:
85
+ resp = await client.get(f"{qdrant_url}/collections", timeout=1.0)
86
+ if resp.status_code == 200:
87
+ data = resp.json()
88
+ cols = [c["name"] for c in data.get("result", {}).get("collections", [])]
89
+ # Check if any collection starting with tipitaka_chunks exists
90
+ has_chunks = any(c.startswith("tipitaka_chunks") for c in cols)
91
+ if has_chunks:
92
+ return {"ready": True, "loading": False, "message": f"Connected to Qdrant Server ({qdrant_url})"}
93
+ return {"ready": False, "loading": True, "message": "Qdrant Server starting or empty..."}
94
+ except Exception as e:
95
+ return {"ready": False, "loading": False, "message": f"Cannot connect to Qdrant Server: {str(e)}"}
96
+
97
+ # 2. Local Mode Check (Filesystem)
98
+ qdrant_path = Path(settings.QDRANT_PATH)
99
+ has_chunks = (qdrant_path / "collections" / "tipitaka_chunks").exists()
100
+
101
+ if has_chunks:
102
+ return {"ready": True, "loading": False, "message": "RAG Local Collections ready"}
103
+
104
+ snapshot_dir = Path(settings.SNAPSHOT_DIR)
105
+ has_snapshots = (snapshot_dir / "tipitaka_chunks.snapshot").exists()
106
+
107
+ if has_snapshots:
108
+ return {"ready": False, "loading": True, "message": "Restoring Local snapshots..."}
109
  else:
110
+ return {"ready": False, "loading": False, "message": "No Qdrant data found"}
111
+
112
  except Exception as e:
113
  logger.warning(f"RAG status check failed: {e}")
114
  return {"ready": False, "loading": False, "error": str(e)}
webapp/tipitaka-api/app/routers/health.py CHANGED
@@ -78,16 +78,16 @@ def check_database(settings) -> dict:
78
  return {"status": "error", "error": str(e)}
79
 
80
 
81
- def check_chromadb(settings) -> dict:
82
  try:
83
- import chromadb
84
- p = _resolve(settings.CHROMA_PERSIST_PATH)
85
  if not os.path.isdir(p):
86
  return {"status": "error", "error": "Dir not found"}
87
- client = chromadb.PersistentClient(path=p)
88
- names = [c.name for c in client.list_collections()]
89
- if "tipitaka_mcu_qwen" in names:
90
- return {"status": "ok"}
91
  return {"status": "degraded", "message": "Collection missing"}
92
  except Exception as e:
93
  return {"status": "error", "error": str(e)[:150]}
@@ -130,7 +130,7 @@ def check_system() -> dict:
130
  def _run_all_checks(settings) -> dict:
131
  return {
132
  "database": check_database(settings),
133
- "chromadb": check_chromadb(settings),
134
  "llm_provider": check_llm_provider(settings),
135
  "system": check_system(),
136
  }
 
78
  return {"status": "error", "error": str(e)}
79
 
80
 
81
+ def check_qdrant(settings) -> dict:
82
  try:
83
+ import qdrant_client
84
+ p = _resolve(settings.QDRANT_PATH)
85
  if not os.path.isdir(p):
86
  return {"status": "error", "error": "Dir not found"}
87
+ client = qdrant_client.QdrantClient(path=p)
88
+ names = [c.name for c in client.get_collections().collections]
89
+ if "tipitaka_chunks" in names:
90
+ return {"status": "ok", "collections": names}
91
  return {"status": "degraded", "message": "Collection missing"}
92
  except Exception as e:
93
  return {"status": "error", "error": str(e)[:150]}
 
130
  def _run_all_checks(settings) -> dict:
131
  return {
132
  "database": check_database(settings),
133
+ "qdrant": check_qdrant(settings),
134
  "llm_provider": check_llm_provider(settings),
135
  "system": check_system(),
136
  }
webapp/tipitaka-api/app/services/llm_service.py CHANGED
@@ -13,22 +13,29 @@ class AskRequest(BaseModel):
13
  context: Optional[str] = None
14
  history: List[Message] = []
15
  mode: str = "fast" # "fast" | "reasoner"
16
- use_rag: bool = False
17
 
18
  from app.services.rag_service import RAGService
19
 
20
  SYSTEM_PROMPT = (
21
  "คุณคือผู้ช่วยตอบคำถามพระไตรปิฎก ฉบับมหาจุฬาลงกรณราชวิทยาลัย (มจร.) "
22
- "หน้าที่ของคคือ่วยแสรุป และอธิบายธรรมะจา้อความที่ผู้ใช้อ่าน "
23
- "โดยเนความกตองตามหลักวิชาการ ษาความหมาดั้งเดิม "
24
- "และตอบอย่า้นระับ รงประเด็น หากีบาีปรกอบรักษาไว้\n\n"
25
- "⚠️ ห้ามทนตัวเองว่อามา ให้ใช้ภาษาแบบผู้ช่วยทั่วไป ไม่ใช่ภิกษุ/พงฆ์\n"
26
- "⚠️ ข้กำหนดสำัญกีวกับการอ้างอแหล่งท:\n"
27
- "1. ้ข้อมูลใน \"เน้อหาทีผู้ใช้่าน\" (context) เท่านั้นในการอ้างอิงแหล่งที่มา "
28
- " ห้ามใช้ความรู้ของตวเองในกาเดหรือร้งแหล่งที่มา\n"
29
- "2. หาก context มีข้มูลล่/หน้า/ื่อพระตร ้อ้างอิงตามนทุ\n"
30
- "3. กไม่แน่ใจหือไม่มีข้อมูลแห่งที่มาใน context ใ้บกอย่งตรงไปตรงมาว่า่ทร "
31
- " อยดาหอสรข้อมูลเท็จ"
 
 
 
 
 
 
 
32
  )
33
 
34
 
@@ -63,7 +70,7 @@ class LLMService:
63
  if request.use_rag:
64
  rag_hits = await self.rag_service.query(request.question)
65
  if rag_hits:
66
- extra_ctx = f"\n\nข้อมูลอ้างอิงเพิ่มเติมจากพระไตรปิฎก (RAG):\n{rag_hits}"
67
 
68
  full_ctx = (base_ctx + extra_ctx).strip()
69
  if full_ctx:
 
13
  context: Optional[str] = None
14
  history: List[Message] = []
15
  mode: str = "fast" # "fast" | "reasoner"
16
+ use_rag: bool = True
17
 
18
  from app.services.rag_service import RAGService
19
 
20
  SYSTEM_PROMPT = (
21
  "คุณคือผู้ช่วยตอบคำถามพระไตรปิฎก ฉบับมหาจุฬาลงกรณราชวิทยาลัย (มจร.) "
22
+ "ทำหน้าที่เป็นสารานกรมพระไตรปิฎกเนทีที่มีคามเ็นและถู้องแ่นยำที่สุด "
23
+ "ตอบโดยใช้ความู้ี่ยวกับพระพุทธศสนาและพะไตปิฎ่างเต็ที่ "
24
+ "เน้นความถูกองตามหลักวิาการักษาความหมายดั้งเดละใภาษาที่คารพแต่ตรงไปตรง\n\n"
25
+ "📖 รูปบบกอบแบบ 'วิกิ' (Wiki-style) สำหับบุคคลำคัญ:\n"
26
+ "เมื่ผู้ใช้ถามถึงบุคล (น พระสาวก, พระเจ้แผ่นดิน, หบุคคลในพุทธประวัติ) ใหจัดโครงสร้างการตบดั้เส:\n"
27
+ "### [ชื่อบุคคล]\n"
28
+ "**1. สรุปภพรว:** อธิบายสันๆ ว่าท่านือใคร มีความสำคย่าไรในะศาสา\n"
29
+ "**2. ประวัติและภูมิลัง:** ระบุชื่อเดิก่อบวช (ถ้ามี) าติตระ และเหตุกรณ์สำคญในกเขาสู่พระศาสนา\n"
30
+ "**3. บทบละห้าทีสำคัญ:** ะบุตำแห่งเอตัคคะ หน้าที่หลักในคณะสงฆ์รืความโดดเด่นเฉพะตั (เชน ปัญญามาก, เลิศทางฤทธิ์)\n"
31
+ "**4. ธรรมและพระสูตรที่เกี่ยวข้อง:** ะบุคำสน พระูต หรือเหตุกรณ์สำคัญที่เกี่ยวข้องกับบุคคนั้นโดยฉพาะ\n\n"
32
+ "⚠️ ห้ามแทนตัวเองว่าอาตมา ให้ใช้ภาษาแบบผู้ช่วยผู้เชี่ยวชาญ ไม่ใช่ภิกษุ/พระสงฆ์\n"
33
+ "📌 กฎเกี่ยวกับการอ้างอิงแหล่งที่มา (Citation Rules):\n"
34
+ "1. ให้ความสำคัญกับข้อมูลใน context (เล่ม/หน้���) เป็นอันดับแรก\n"
35
+ "2. หากข้อมูลใน context ไม่เพียงพอ ให้ใช้ความรู้พื้นฐานที่มีได้ แต่ต้องกำกับว่า \"อ้างอิงจากความรู้ทั่วไป\" สำหรับส่วนนั้น\n"
36
+ "3. ห้ามแต่งเลขเล่ม/หน้า หรือข้อมูลเท็จขึ้นมาเองเด็ดขาด\n"
37
+ "✅ สรุป: ตอบคำถามให้ลึกซึ้งและเป็นขั้นตอน หากมีภาษาบาลีให้รักษาไว้เพื่อความถูกต้องของความหมาย\n"
38
+ "🚫 ข้อห้าม: ห้ามใช้คำศัพท์เทคนิค เช่น 'RAG', 'Context', 'Vector' หรือ 'ข้อมูลจากระบบ' ให้ใช้คำว่า 'ข้อมูลอ้างอิง' หรือ 'หลักฐานในพระไตรปิฎก' แทน ห้ามมีภาษาอังกฤษที่เป็นคำแปล (เช่น Overview, Biography) ปนในวงเล็บหรือหลังหัวข้อเด็ดขาด แต่ยังคงอนุญาตให้ใช้ชื่อภาษาบาลีหรือสันสกฤตที่เขียนด้วยอักษรโรมันในวงเล็บได้เพื่อความสมบูรณ์ของข้อมูล (เช่น (Moggallāna))"
39
  )
40
 
41
 
 
70
  if request.use_rag:
71
  rag_hits = await self.rag_service.query(request.question)
72
  if rag_hits:
73
+ extra_ctx = f"\n\nข้อมูลอ้างอิงเพิ่มเติมจากส่วนอื่นของพระไตรปิฎก:\n{rag_hits}"
74
 
75
  full_ctx = (base_ctx + extra_ctx).strip()
76
  if full_ctx:
webapp/tipitaka-api/app/services/page_service.py CHANGED
@@ -71,7 +71,8 @@ class PageService:
71
  WHERE v.volume_number = ? AND c.level <= 2
72
  ORDER BY c.page_number, c.id
73
  """, (volume_number,))
74
- rows = [dict(r) for r in cursor.fetchall()]
 
75
 
76
  def is_noise(title: str) -> bool:
77
  return (
@@ -102,4 +103,5 @@ class PageService:
102
  FROM volumes
103
  ORDER BY volume_number
104
  """)
105
- return [dict(row) for row in cursor.fetchall()]
 
 
71
  WHERE v.volume_number = ? AND c.level <= 2
72
  ORDER BY c.page_number, c.id
73
  """, (volume_number,))
74
+ columns = [c[0] for c in cursor.description]
75
+ rows = [dict(zip(columns, r)) for r in cursor.fetchall()]
76
 
77
  def is_noise(title: str) -> bool:
78
  return (
 
103
  FROM volumes
104
  ORDER BY volume_number
105
  """)
106
+ columns = [c[0] for c in cursor.description]
107
+ return [dict(zip(columns, row)) for row in cursor.fetchall()]
webapp/tipitaka-api/app/services/rag_service.py CHANGED
@@ -1,79 +1,328 @@
1
- import chromadb
2
- from chromadb.utils import embedding_functions
3
- from sentence_transformers import SentenceTransformer
4
- import torch
 
5
  from app.config import get_settings
 
 
 
 
6
 
7
- class QwenEmbeddingFunction:
8
- def __init__(self, model_name: str):
9
- self.device = "cuda" if torch.cuda.is_available() else "cpu"
10
- self.model = SentenceTransformer(model_name, trust_remote_code=True, device=self.device)
11
-
12
- def name(self) -> str:
13
- return "Qwen3-Embedding-0.6B"
14
-
15
- def _encode(self, input: list[str]) -> list[list[float]]:
16
- embeddings = self.model.encode(
17
- input,
18
- batch_size=32,
19
- show_progress_bar=False,
20
- normalize_embeddings=True,
21
- convert_to_numpy=True,
22
- )
23
- return embeddings.tolist()
24
-
25
- def embed_query(self, input: list[str]) -> list[list[float]]:
26
- return self._encode(input)
27
-
28
- def embed_documents(self, input: list[str]) -> list[list[float]]:
29
- return self._encode(input)
30
 
31
- def __call__(self, input: list[str]) -> list[list[float]]:
32
- return self._encode(input)
33
 
34
  class RAGService:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def __init__(self):
36
  settings = get_settings()
37
- self.persist_path = settings.CHROMA_PERSIST_PATH
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- # Initialize ChromaDB client
40
- self.client = chromadb.PersistentClient(path=self.persist_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
- try:
43
- # Load Qwen model first, then get collection with embedding function
44
- self.embedding_fn = QwenEmbeddingFunction("Qwen/Qwen3-Embedding-0.6B")
45
- self.collection = self.client.get_collection(
46
- name="tipitaka_mcu_qwen",
47
- embedding_function=self.embedding_fn,
48
- )
49
  except Exception as e:
50
- print(f"RAG Initialization Error: {e}")
51
- self.collection = None
52
- self.embedding_fn = None
53
 
54
- async def query(self, text: str, n_results: int = 3, threshold: float = 0.55) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  """
56
- Query ChromaDB for relevant chunks and return formatted context.
57
- Uses the distance threshold logic from tipitaka_app_v2.1.py
 
 
 
58
  """
59
- if not self.collection or not self.embedding_fn:
60
  return ""
 
 
 
 
 
 
61
 
62
- results = self.collection.query(
63
- query_texts=[text],
64
- n_results=n_results,
65
- include=["documents", "metadatas", "distances"],
66
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- context_parts = []
69
- if results["documents"] and results["distances"]:
70
- for i, doc in enumerate(results["documents"][0]):
71
- dist = results["distances"][0][i]
72
- # Filter by threshold (0.55 from v2.1)
73
- if dist <= threshold:
74
- meta = results["metadatas"][0][i]
75
- vol = meta.get("volume", meta.get("volume_id", "?"))
76
- page = meta.get("page", meta.get("page_number", "?"))
77
- context_parts.append(f"[เล่ม {vol} หน้า {page}]\n{doc[:1000]}")
78
-
79
- return "\n---\n".join(context_parts) if context_parts else ""
 
1
+ import logging
2
+ from pathlib import Path
3
+ import qdrant_client
4
+ from qdrant_client.http import models as qmodels
5
+ import httpx
6
  from app.config import get_settings
7
+ from app.database.sqlite_db import get_db
8
+ import torch
9
+ import anyio
10
+ import re
11
 
12
+ OLLAMA_URL = "http://localhost:11434"
13
+ EMBED_MODEL = "hf.co/second-state/jina-embeddings-v3-GGUF:jina-embeddings-v3-Q4_K_M.gguf"
14
+ EMBED_DIMS = 1024
15
+ RERANK_MODEL_PATH = "models/jina-v2-onnx"
16
+ RERANK_MODEL_NAME = "jinaai/jina-reranker-v2-base-multilingual"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
+ logger = logging.getLogger(__name__)
 
19
 
20
  class RAGService:
21
+ def _extract_snapshot(self, col_name: str, snap_path: Path):
22
+ """Manually extract a Qdrant snapshot into the storage folder for Local Mode."""
23
+ import tarfile
24
+ import shutil
25
+
26
+ target_dir = Path(self.qdrant_path) / "collections" / col_name
27
+ if target_dir.exists():
28
+ shutil.rmtree(target_dir)
29
+ target_dir.mkdir(parents=True, exist_ok=True)
30
+
31
+ logger.info(f"Extracting snapshot to {target_dir}...")
32
+ try:
33
+ with tarfile.open(snap_path, "r:*") as tar:
34
+ tar.extractall(path=target_dir)
35
+ logger.info(f"Extraction of '{col_name}' complete.")
36
+ return True
37
+ except Exception as e:
38
+ logger.error(f"Error extracting snapshot for {col_name}: {e}")
39
+ return False
40
+ except Exception as e:
41
+ logger.error(f"Error extracting snapshot for {col_name}: {e}")
42
+ return False
43
+
44
  def __init__(self):
45
  settings = get_settings()
46
+ self.qdrant_path = settings.QDRANT_PATH
47
+ self.snapshot_dir = Path(settings.SNAPSHOT_DIR)
48
+ self.collections = ["tipitaka_chunks", "tipitaka_scripture"]
49
+
50
+ self.model = None # Flag: None = not verified, 'ready' = OK
51
+ self.reranker = None
52
+ self.actual_chunks_col = "tipitaka_chunks" # Default name
53
+
54
+ try:
55
+ # 1. Determine Mode (Auto-detect Server vs Local)
56
+ qdrant_url = getattr(settings, "QDRANT_URL", None)
57
+
58
+ if not qdrant_url:
59
+ try:
60
+ with httpx.Client() as client:
61
+ response = client.get("http://localhost:6333/healthz", timeout=1.0)
62
+ if response.status_code == 200:
63
+ qdrant_url = "http://localhost:6333"
64
+ logger.info(f"Auto-detected running Qdrant Server at {qdrant_url}")
65
+ except Exception:
66
+ pass
67
 
68
+ is_local = not bool(qdrant_url)
69
+
70
+ if is_local:
71
+ logger.info(f"Initializing Qdrant in Local Mode at {self.qdrant_path}")
72
+ storage_path = Path(self.qdrant_path)
73
+ storage_path.mkdir(parents=True, exist_ok=True)
74
+
75
+ lock_file = storage_path / ".lock"
76
+ if lock_file.exists():
77
+ try:
78
+ logger.warning(f"Removing stale Qdrant lock file: {lock_file}")
79
+ lock_file.unlink()
80
+ except Exception as e:
81
+ logger.error(f"Failed to remove lock file: {e}")
82
+
83
+ for col_name in self.collections:
84
+ col_dir = storage_path / "collections" / col_name
85
+ if not col_dir.exists():
86
+ snap_path = self.snapshot_dir / f"{col_name}.snapshot"
87
+ if snap_path.exists():
88
+ logger.info(f"Restoring '{col_name}' via manual extraction...")
89
+ self._extract_snapshot(col_name, snap_path)
90
+
91
+ self.client = qdrant_client.QdrantClient(path=self.qdrant_path)
92
+ logger.info("Qdrant Local Client initialized.")
93
+
94
+ else:
95
+ logger.info(f"Connecting to Qdrant Server at {qdrant_url}")
96
+ self.client = qdrant_client.QdrantClient(url=qdrant_url)
97
+ ALLOWED_SNAP_ROOT = Path(r"F:\_Ai\_db\qdrant\snapshots")
98
+
99
+ try:
100
+ all_cols = [c.name for c in self.client.get_collections().collections]
101
+ except Exception as e:
102
+ logger.error(f"Failed to list collections: {e}")
103
+ all_cols = []
104
+
105
+ for col_name in self.collections:
106
+ actual_col = None
107
+ if col_name in all_cols:
108
+ actual_col = col_name
109
+ else:
110
+ matches = [c for c in all_cols if c.startswith(f"{col_name}_") or c.startswith(col_name)]
111
+ if matches: actual_col = matches[0]
112
+
113
+ if actual_col:
114
+ if col_name == "tipitaka_chunks": self.actual_chunks_col = actual_col
115
+ else:
116
+ snap_filename = f"{col_name}.snapshot"
117
+ target_snap = ALLOWED_SNAP_ROOT / snap_filename
118
+ if not target_snap.exists(): target_snap = self.snapshot_dir / snap_filename
119
+
120
+ if target_snap.exists():
121
+ import os
122
+ logger.info(f"Restoring server collection '{col_name}' from {target_snap}...")
123
+ abs_snap_path = os.path.abspath(target_snap).replace("\\", "/")
124
+ if not abs_snap_path.startswith("/"): abs_snap_path = "/" + abs_snap_path
125
+ try:
126
+ self.client.recover_snapshot(col_name, location=f"file://{abs_snap_path}")
127
+ if col_name == "tipitaka_chunks": self.actual_chunks_col = col_name
128
+ except Exception as e:
129
+ logger.error(f"Failed to restore: {e}")
130
+
131
+ # 3. Pre-load Models (Embedding + Reranker)
132
+ self._load_model()
133
+ self._load_reranker()
134
 
 
 
 
 
 
 
 
135
  except Exception as e:
136
+ logger.error(f"RAG Initialization Error: {e}")
137
+ self.client = None
 
138
 
139
+ def _load_model(self):
140
+ """Verify Ollama embedding service is accessible."""
141
+ if self.model is None:
142
+ logger.info(f"Verifying Ollama embedding model...")
143
+ try:
144
+ r = httpx.get(f"{OLLAMA_URL}/api/tags", timeout=5)
145
+ r.raise_for_status()
146
+ self.model = "ready"
147
+ logger.info(f"Ollama embedding service ready.")
148
+ except Exception as e:
149
+ logger.error(f"Ollama not accessible: {e}")
150
+ self.model = None
151
+
152
+ def _load_reranker(self):
153
+ """Pre-load the Reranker model using ONNX for CPU performance."""
154
+ if self.reranker is None:
155
+ logger.info(f"Pre-loading ONNX Reranker from {RERANK_MODEL_PATH}...")
156
+ try:
157
+ from app.services.onnx_reranker import ONNXReranker
158
+ self.reranker = ONNXReranker(RERANK_MODEL_PATH)
159
+ logger.info("ONNX Reranker v2 pre-loaded successfully.")
160
+ except Exception as e:
161
+ logger.error(f"Failed to pre-load ONNX Reranker: {e}")
162
+ # Fallback flag
163
+ self.reranker = "error"
164
+
165
+ def _get_embedding(self, text: str) -> list:
166
+ """Get embedding vector from Ollama API."""
167
+ response = httpx.post(
168
+ f"{OLLAMA_URL}/api/embed",
169
+ json={"model": EMBED_MODEL, "input": text},
170
+ timeout=30
171
+ )
172
+ response.raise_for_status()
173
+ return response.json()["embeddings"][0]
174
+
175
+ async def query(self, text: str, n_results: int = 10, threshold: float = 0.2) -> str:
176
  """
177
+ Hybrid Search Strategy:
178
+ 1. FTS5 Search (SQLite) for exact keyword matches.
179
+ 2. Vector Search (Qdrant) for semantic relevance.
180
+ 3. Merge & Deduplicate.
181
+ 4. Rerank via Jina v2 ONNX.
182
  """
183
+ if not self.client:
184
  return ""
185
+
186
+ # Ensure models are ready
187
+ if self.model is None:
188
+ await anyio.to_thread.run_sync(self._load_model)
189
+ if self.reranker is None:
190
+ await anyio.to_thread.run_sync(self._load_reranker)
191
 
192
+ candidates = []
193
+ seen_keys = set() # For deduplication (vol_page)
194
+
195
+ try:
196
+ # --- PHASE 1: FTS5 Search (SQLite) ---
197
+ logger.info(f"Starting FTS5 search for: {text[:30]}...")
198
+ def _blocking_fts():
199
+ fts_results = []
200
+ db = get_db()
201
+ with db.get_connection() as conn:
202
+ # Search pages_fts and join with pages for metadata
203
+ # We limit to 30 for performance
204
+ query_sql = """
205
+ SELECT id, volume_id, page_number, content_text
206
+ FROM pages
207
+ WHERE id IN (
208
+ SELECT rowid FROM pages_fts
209
+ WHERE pages_fts MATCH ?
210
+ LIMIT 30
211
+ )
212
+ """
213
+ # Sanitize FTS query: wrap in quotes for literal or keep simple
214
+ sanitized_query = text.replace('"', '').strip()
215
+ if not sanitized_query: return []
216
+
217
+ try:
218
+ cursor = conn.execute(query_sql, (f'"{sanitized_query}"',))
219
+ for row in cursor.fetchall():
220
+ key = f"{row['volume_id']}_{row['page_number']}"
221
+ fts_results.append({
222
+ "id": row['id'],
223
+ "score": 0.9, # High initial score for FTS matches
224
+ "payload": {
225
+ "volume": row['volume_id'],
226
+ "page": row['page_number'],
227
+ "content": row['content_text']
228
+ },
229
+ "key": key
230
+ })
231
+ except Exception as e:
232
+ logger.warning(f"FTS5 query failed: {e}")
233
+ return fts_results
234
+
235
+ fts_candidates = await anyio.to_thread.run_sync(_blocking_fts)
236
+ for cand in fts_candidates:
237
+ if cand['key'] not in seen_keys:
238
+ candidates.append(cand)
239
+ seen_keys.add(cand['key'])
240
+
241
+ # --- PHASE 2: Vector Search (Qdrant) ---
242
+ logger.info(f"Starting Vector search for: {text[:30]}...")
243
+ def _blocking_vector():
244
+ query_vector = self._get_embedding(text)
245
+ if hasattr(self.client, "search"):
246
+ hits = self.client.search(
247
+ collection_name=self.actual_chunks_col,
248
+ query_vector=("dense", query_vector),
249
+ limit=30,
250
+ with_payload=True,
251
+ score_threshold=threshold
252
+ )
253
+ else:
254
+ response = self.client.query_points(
255
+ collection_name=self.actual_chunks_col,
256
+ query=query_vector,
257
+ using="dense",
258
+ limit=30,
259
+ with_payload=True,
260
+ score_threshold=threshold
261
+ )
262
+ hits = response.points
263
+
264
+ vec_results = []
265
+ for hit in hits:
266
+ vol = hit.payload.get("volume", hit.payload.get("volume_id"))
267
+ page = hit.payload.get("page", hit.payload.get("page_number"))
268
+ key = f"{vol}_{page}"
269
+ vec_results.append({
270
+ "id": hit.id,
271
+ "score": hit.score,
272
+ "payload": hit.payload,
273
+ "key": key
274
+ })
275
+ return vec_results
276
+
277
+ vector_candidates = await anyio.to_thread.run_sync(_blocking_vector)
278
+ for cand in vector_candidates:
279
+ if cand['key'] not in seen_keys:
280
+ candidates.append(cand)
281
+ seen_keys.add(cand['key'])
282
+
283
+ if not candidates:
284
+ return ""
285
+
286
+ # --- PHASE 3: Rerank stage ---
287
+ if self.reranker and self.reranker != "error":
288
+ logger.info(f"Reranking {len(candidates)} hybrid candidates...")
289
+
290
+ def _blocking_rerank():
291
+ # Prepare pairs: (query, passage)
292
+ passages = [c['payload'].get("content", "")[:1000] for c in candidates]
293
+ pairs = [[text, p] for p in passages]
294
+
295
+ with torch.no_grad():
296
+ scores = self.reranker.predict(pairs, show_progress_bar=False, batch_size=4)
297
+
298
+ for i, cand in enumerate(candidates):
299
+ cand['rerank_score'] = float(scores[i])
300
+
301
+ return sorted(candidates, key=lambda x: x.get('rerank_score', 0), reverse=True)
302
+
303
+ candidates = await anyio.to_thread.run_sync(_blocking_rerank)
304
+
305
+ # --- PHASE 4: Formatting ---
306
+ final_results = candidates[:n_results]
307
+ context_parts = []
308
+ for cand in final_results:
309
+ payload = cand['payload']
310
+ content = payload.get("content", "").strip()
311
+ if not content: continue
312
+
313
+ vol = payload.get("volume")
314
+ page = payload.get("page")
315
+
316
+ # Cleanup and formatting
317
+ clean_content = re.sub(r"---.*?---", "", content, flags=re.DOTALL).strip()
318
+ clean_content = re.sub(r"^\d{3}\s+", "", clean_content, flags=re.MULTILINE)
319
+ clean_content = clean_content[:2000]
320
+
321
+ context_parts.append(f"[เล่ม {vol} หน้า {page}]\n{clean_content}")
322
+
323
+ return "\n---\n".join(context_parts) if context_parts else ""
324
+
325
+ except Exception as e:
326
+ logger.error(f"Hybrid query error: {e}")
327
+ return ""
328
 
 
 
 
 
 
 
 
 
 
 
 
 
webapp/tipitaka-api/download_assets.py CHANGED
@@ -7,83 +7,128 @@ Files are fetched from: dhammawatthumpra/tipitaka-storage
7
  import os
8
  import sys
9
  from pathlib import Path
 
10
 
11
- DATA_DIR = Path("/app/data")
12
- DB_PATH = DATA_DIR / "tipitaka_mcu.db"
13
- VECTOR_DIR = DATA_DIR / "db_vector"
 
 
 
 
 
 
 
 
 
 
14
 
15
  BUCKET_ID = "dhammawatthumpra/tipitaka-storage"
16
 
 
17
  BUCKET_FILES = [
18
- # Main database
19
  ("tipitaka_mcu.db", str(DB_PATH)),
20
- # ChromaDB vector index
21
- ("db_vector/chroma.sqlite3", str(VECTOR_DIR / "chroma.sqlite3")),
22
- ("db_vector/b5791013-c1a3-427a-b4e2-6baa9d0a45e8/data_level0.bin",
23
- str(VECTOR_DIR / "b5791013-c1a3-427a-b4e2-6baa9d0a45e8" / "data_level0.bin")),
24
- ("db_vector/b5791013-c1a3-427a-b4e2-6baa9d0a45e8/header.bin",
25
- str(VECTOR_DIR / "b5791013-c1a3-427a-b4e2-6baa9d0a45e8" / "header.bin")),
26
- ("db_vector/b5791013-c1a3-427a-b4e2-6baa9d0a45e8/index_metadata.pickle",
27
- str(VECTOR_DIR / "b5791013-c1a3-427a-b4e2-6baa9d0a45e8" / "index_metadata.pickle")),
28
- ("db_vector/b5791013-c1a3-427a-b4e2-6baa9d0a45e8/length.bin",
29
- str(VECTOR_DIR / "b5791013-c1a3-427a-b4e2-6baa9d0a45e8" / "length.bin")),
30
- ("db_vector/b5791013-c1a3-427a-b4e2-6baa9d0a45e8/link_lists.bin",
31
- str(VECTOR_DIR / "b5791013-c1a3-427a-b4e2-6baa9d0a45e8" / "link_lists.bin")),
32
  ]
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def download_files() -> None:
36
  """Download missing files from HF bucket."""
37
  hf_token = os.getenv("HF_TOKEN", "")
38
- if not hf_token:
39
- print("WARNING: HF_TOKEN not set skipping asset download.")
40
- print("RAG and database will not be available.")
41
- return
42
-
43
- # Filter to files that don't exist yet
44
- pending = [(remote, local) for remote, local in BUCKET_FILES if not Path(local).exists()]
 
45
 
46
  if not pending:
47
- print("All assets already exist skipping download.")
48
  return
49
 
50
- print(f"Downloading {len(pending)} files from bucket '{BUCKET_ID}'...")
51
-
52
- from huggingface_hub import HfApi
53
- api = HfApi(token=hf_token)
54
 
55
- # Ensure parent directories exist
56
- for _, local in pending:
57
- Path(local).parent.mkdir(parents=True, exist_ok=True)
58
 
59
  try:
60
- api.download_bucket_files(
61
- bucket_id=BUCKET_ID,
62
- files=pending,
63
- )
64
- print("All files downloaded successfully.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  except Exception as e:
66
  print(f"ERROR downloading files: {e}")
67
- sys.exit(1)
68
 
69
 
70
  def verify_assets() -> bool:
71
  """Check that critical files exist."""
72
  missing = []
73
  if not DB_PATH.exists():
74
- missing.append(str(DB_PATH))
75
- if not VECTOR_DIR.exists() or not any(VECTOR_DIR.iterdir()):
76
- missing.append(str(VECTOR_DIR))
 
 
 
 
 
 
 
 
 
77
 
78
  if missing:
79
- print(f"WARNING: Missing critical assets: {missing}")
80
  return False
 
 
81
  return True
82
 
83
 
84
  if __name__ == "__main__":
85
- DATA_DIR.mkdir(parents=True, exist_ok=True)
86
- VECTOR_DIR.mkdir(parents=True, exist_ok=True)
 
 
87
  download_files()
88
  verify_assets()
89
- print("Asset download complete.")
 
7
  import os
8
  import sys
9
  from pathlib import Path
10
+ from dotenv import load_dotenv
11
 
12
+ # Add current directory to path so we can import app.config
13
+ sys.path.append(str(Path(__file__).resolve().parent))
14
+ from app.config import get_settings
15
+
16
+ settings = get_settings()
17
+
18
+ # Define paths from settings
19
+ DB_PATH = Path(settings.DATABASE_PATH)
20
+ QDRANT_DIR = Path(settings.QDRANT_PATH)
21
+ SNAPSHOT_DIR = Path(settings.SNAPSHOT_DIR)
22
+
23
+ # User's local snapshot path fallback (for verification in verify_assets)
24
+ USER_LOCAL_SNAPSHOT_DIR = Path(r"F:\_Ai\Tipitaka-AI-Expert\Tipitaka-Data\data\snapshots")
25
 
26
  BUCKET_ID = "dhammawatthumpra/tipitaka-storage"
27
 
28
+ # Files to check/download
29
  BUCKET_FILES = [
 
30
  ("tipitaka_mcu.db", str(DB_PATH)),
31
+ ("qdrant/tipitaka_chunks.snapshot", "tipitaka_chunks.snapshot"),
32
+ ("qdrant/tipitaka_scripture.snapshot", "tipitaka_scripture.snapshot"),
 
 
 
 
 
 
 
 
 
 
33
  ]
34
 
35
 
36
+ def check_file_exists(local_path_name: str) -> bool:
37
+ """Check if a file exists either in project SNAPSHOT_DIR or USER_LOCAL_SNAPSHOT_DIR."""
38
+ # If it's the DB, check DB_PATH
39
+ if local_path_name == str(DB_PATH):
40
+ return DB_PATH.exists()
41
+
42
+ # If it's a snapshot, check both locations
43
+ in_project = SNAPSHOT_DIR / local_path_name
44
+ in_user_local = USER_LOCAL_SNAPSHOT_DIR / local_path_name
45
+
46
+ return in_project.exists() or in_user_local.exists()
47
+
48
+
49
  def download_files() -> None:
50
  """Download missing files from HF bucket."""
51
  hf_token = os.getenv("HF_TOKEN", "")
52
+
53
+ # Filter to files that don't exist yet in any location
54
+ pending = []
55
+ for remote, local_name in BUCKET_FILES:
56
+ if not check_file_exists(local_name if "snapshot" in local_name else str(DB_PATH)):
57
+ # Determine destination: if it's the DB, use DB_PATH, else use project SNAPSHOT_DIR
58
+ dest = DB_PATH if "db" in local_name else (SNAPSHOT_DIR / local_name)
59
+ pending.append((remote, str(dest)))
60
 
61
  if not pending:
62
+ print("Success: All assets already exist in project or local storage - skipping download.")
63
  return
64
 
65
+ if not hf_token:
66
+ print("WARNING: HF_TOKEN not set — skipping asset download.")
67
+ return
 
68
 
69
+ print(f"Downloading {len(pending)} files from bucket '{BUCKET_ID}'...")
 
 
70
 
71
  try:
72
+ from huggingface_hub import hf_hub_download
73
+
74
+ for remote, dest_str in pending:
75
+ dest_path = Path(dest_str)
76
+ print(f"Downloading {remote} -> {dest_path}")
77
+
78
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
79
+
80
+ # Note: hf_hub_download with local_dir will create subfolders if remote has slashes
81
+ # So if remote is "qdrant/foo.snapshot" and local_dir is DATA_DIR,
82
+ # it becomes DATA_DIR/qdrant/foo.snapshot.
83
+ # But we want it in DATA_DIR/snapshots/foo.snapshot.
84
+ # So we use local_dir_use_symlinks=False and manually move if needed,
85
+ # or just download to a temp and move.
86
+
87
+ downloaded_path = hf_hub_download(
88
+ repo_id=BUCKET_ID,
89
+ filename=remote,
90
+ repo_type="dataset",
91
+ token=hf_token
92
+ )
93
+
94
+ import shutil
95
+ shutil.copy(downloaded_path, dest_path)
96
+
97
+ print("All files processed successfully.")
98
  except Exception as e:
99
  print(f"ERROR downloading files: {e}")
 
100
 
101
 
102
  def verify_assets() -> bool:
103
  """Check that critical files exist."""
104
  missing = []
105
  if not DB_PATH.exists():
106
+ missing.append("tipitaka_mcu.db")
107
+
108
+ chunks_exists = (SNAPSHOT_DIR / "tipitaka_chunks.snapshot").exists() or \
109
+ (USER_LOCAL_SNAPSHOT_DIR / "tipitaka_chunks.snapshot").exists()
110
+
111
+ scripture_exists = (SNAPSHOT_DIR / "tipitaka_scripture.snapshot").exists() or \
112
+ (USER_LOCAL_SNAPSHOT_DIR / "tipitaka_scripture.snapshot").exists()
113
+
114
+ if not chunks_exists:
115
+ missing.append("tipitaka_chunks.snapshot")
116
+ if not scripture_exists:
117
+ missing.append("tipitaka_scripture.snapshot")
118
 
119
  if missing:
120
+ print(f"WARNING: Missing critical assets in any location: {missing}")
121
  return False
122
+
123
+ print("Success: All critical assets verified (either in project or local storage).")
124
  return True
125
 
126
 
127
  if __name__ == "__main__":
128
+ # Ensure directories exist
129
+ QDRANT_DIR.mkdir(parents=True, exist_ok=True)
130
+ SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
131
+
132
  download_files()
133
  verify_assets()
134
+ print("Asset management complete.")
webapp/tipitaka-api/requirements.txt CHANGED
@@ -4,7 +4,7 @@ pydantic-settings==2.9.1
4
  python-dotenv==1.1.0
5
  sse-starlette==1.8.2
6
  openai>=2.0
7
- chromadb>=1.5
8
  sentence-transformers>=3.0
9
  huggingface_hub>=0.20
10
  torch>=2.0
 
4
  python-dotenv==1.1.0
5
  sse-starlette==1.8.2
6
  openai>=2.0
7
+ qdrant-client>=1.12.0
8
  sentence-transformers>=3.0
9
  huggingface_hub>=0.20
10
  torch>=2.0
webapp/tipitaka-api/startup.sh CHANGED
@@ -12,8 +12,10 @@ cd /app/api
12
  python download_assets.py
13
 
14
  # ── 2. Export paths for the API ──
 
15
  export DATABASE_PATH="/app/data/tipitaka_mcu.db"
16
- export CHROMA_PERSIST_PATH="/app/data/db_vector"
 
17
  export SERVE_STATIC="true"
18
  export STATIC_DIR="/app/web/dist"
19
 
 
12
  python download_assets.py
13
 
14
  # ── 2. Export paths for the API ──
15
+ export DATA_DIR="/app/data"
16
  export DATABASE_PATH="/app/data/tipitaka_mcu.db"
17
+ export QDRANT_PATH="/app/data/qdrant_storage"
18
+ export SNAPSHOT_DIR="/app/data/snapshots"
19
  export SERVE_STATIC="true"
20
  export STATIC_DIR="/app/web/dist"
21
 
webapp/tipitaka-web/src/components/ai/AIPopup.tsx CHANGED
@@ -1,13 +1,13 @@
1
  import React, { useState, useRef, useEffect, useCallback } from 'react';
2
  import { useAIStore } from '../../stores/aiStore';
3
  import { useReaderStore, useThemeStore } from '../../stores/appStore';
4
- import { X, Send, Sparkles, Trash2, Zap, Brain } from 'lucide-react';
5
- import { motion } from 'framer-motion';
6
 
7
- const PANEL_STYLES: Record<string, { bg: string; text: string; border: string; msgBg: string }> = {
8
- dark: { bg: 'bg-[#0f0f1e]', text: 'text-[#e0e0e0]', border: 'border-[#3a3a5e]', msgBg: 'bg-[#1a1a2e]' },
9
- light: { bg: 'bg-[#f5edd8]', text: 'text-[#1a1a1a]', border: 'border-[#e0d0b0]', msgBg: 'bg-[#fdfaf5]' },
10
- classic: { bg: 'bg-[#fdfaf5]', text: 'text-[#1a1a1a]', border: 'border-[#d4c4a0]', msgBg: 'bg-[#faf7f2]' },
11
  };
12
 
13
  const AIPopup: React.FC = () => {
@@ -21,7 +21,11 @@ const AIPopup: React.FC = () => {
21
  const { currentVolume, currentPage, currentContent } = useReaderStore();
22
  const { theme } = useThemeStore();
23
  const [input, setInput] = useState('');
 
 
24
  const [showQuickPrompts, setShowQuickPrompts] = useState(true);
 
 
25
  const scrollRef = useRef<HTMLDivElement>(null);
26
  const panelRef = useRef<HTMLDivElement>(null);
27
 
@@ -35,9 +39,22 @@ const AIPopup: React.FC = () => {
35
  if (dragPos) setPos(dragPos);
36
  }, []);
37
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  const handleMouseDown = useCallback((e: React.MouseEvent) => {
39
  // Only desktop — ignore if touch device or small screen
40
  if (window.innerWidth < 768) return;
 
41
 
42
  setIsDragging(true);
43
  const rect = panelRef.current?.getBoundingClientRect();
@@ -45,7 +62,7 @@ const AIPopup: React.FC = () => {
45
  dragOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
46
  }
47
  e.preventDefault();
48
- }, []);
49
 
50
  useEffect(() => {
51
  if (!isDragging) return;
@@ -75,10 +92,10 @@ const AIPopup: React.FC = () => {
75
  const s = PANEL_STYLES[theme] ?? PANEL_STYLES.dark;
76
 
77
  useEffect(() => {
78
- if (scrollRef.current) {
79
  scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
80
  }
81
- }, [messages, isStreaming]);
82
 
83
  // Check RAG status when popup opens
84
  useEffect(() => {
@@ -93,6 +110,17 @@ const AIPopup: React.FC = () => {
93
  setInput('');
94
  };
95
 
 
 
 
 
 
 
 
 
 
 
 
96
  if (!isOpen) return null;
97
 
98
  // RAG status dot color
@@ -105,154 +133,275 @@ const AIPopup: React.FC = () => {
105
  : 'bg-red-400'; // 🔴 ไม่พร้อม
106
 
107
  return (
108
- <motion.div
109
- ref={panelRef}
110
- initial={{ opacity: 0, scale: 0.95, y: 20 }}
111
- animate={{
112
- opacity: 1, scale: 1, y: 0,
113
- ...(pos && window.innerWidth >= 768
114
- ? { left: pos.x, top: pos.y, right: 'auto', bottom: 'auto' }
115
- : {}),
116
- }}
117
- exit={{ opacity: 0, scale: 0.95, y: 20 }}
118
- // Mobile: always centered. Desktop: positioned by state or default bottom-right
119
- className={`
120
- fixed z-50 flex flex-col overflow-hidden rounded-2xl border shadow-2xl
121
- ${s.bg} ${s.text} ${s.border}
122
- bottom-24 left-1/2 -translate-x-1/2
123
- ${pos && window.innerWidth >= 768 ? '' : 'md:left-auto md:right-14 md:translate-x-0'}
124
- w-[calc(100vw-32px)] md:w-[400px]
125
- h-[80vh] md:h-[550px]
126
- ${isDragging ? 'cursor-grabbing select-none' : ''}
127
- `}
128
- style={pos && window.innerWidth >= 768 ? {
129
- position: 'fixed',
130
- left: pos.x,
131
- top: pos.y,
132
- } : undefined}
133
- >
134
- {/* ═══════════ Compact Header + Drag Handle ═══════════ */}
135
- <div
136
- className="flex flex-col flex-shrink-0 bg-[#c8860a] text-white"
137
- onMouseDown={handleMouseDown}
138
- style={{ cursor: window.innerWidth >= 768 ? 'grab' : undefined }}
139
- >
140
- {/* Row 1: Title + actions */}
141
- <div className="flex items-center justify-between px-3 py-2">
142
- <div className="flex items-center gap-2 min-w-0">
143
- <Sparkles size={16} className="flex-shrink-0" />
144
- <h3 className="font-bold text-sm tracking-wide truncate">ผู้ช่วย AI</h3>
145
- </div>
146
- <div className="flex items-center gap-1 flex-shrink-0">
147
- <div className="flex bg-black/15 rounded-lg p-0.5 mr-1">
148
- <button
149
- onClick={() => setMode('fast')}
150
- className={`flex items-center gap-1 px-2 py-1 text-[10px] font-bold uppercase rounded-md transition-all ${
151
- mode === 'fast' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white/90'
152
- }`}
153
- ><Zap size={10} /> เร็ว</button>
154
- <button
155
- onClick={() => setMode('reasoner')}
156
- className={`flex items-center gap-1 px-2 py-1 text-[10px] font-bold uppercase rounded-md transition-all ${
157
- mode === 'reasoner' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white/90'
158
- }`}
159
- ><Brain size={10} /> คิดลึก</button>
160
- </div>
161
- <button onClick={clearHistory} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="ล้างการสนทนา" aria-label="ล้างการสนทนา"><Trash2 size={14} /></button>
162
- <button onClick={toggleOpen} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" aria-label="ปิด"><X size={16} /></button>
163
- </div>
164
- </div>
165
 
166
- {/* Row 2: RAG toggle + Quick prompt toggle */}
167
- <div className="flex items-center justify-between px-3 py-1 border-t border-white/10">
168
- <button
169
- onClick={() => setUseRag(!useRag)}
170
- className="flex items-center gap-1.5 text-[10px] font-medium text-white/60 hover:text-white transition-colors"
171
- >
172
- <span className="flex items-center gap-1">
173
- <span className={`inline-block w-1.5 h-1.5 rounded-full ${ragDot}`} title={
174
- ragLoading ? 'RAG: กำลังโหลดโมเดล...' : ragReady ? 'RAG พร้อม' : 'RAG ไม่พร้อม'
175
- } />
176
- 📚 ค้นเล่มอื่น
177
- </span>
178
- <span className={`inline-flex items-center px-0.5 w-7 h-3.5 rounded-full transition-colors ${
179
- useRag ? 'bg-white/50 justify-end' : 'bg-white/20 justify-start'
180
- }`}>
181
- <span className="w-2.5 h-2.5 bg-white rounded-full shadow-xs" />
182
- </span>
183
- </button>
184
- <button
185
- onClick={() => setShowQuickPrompts(v => !v)}
186
- className="text-[10px] font-bold text-white/50 hover:text-white transition-colors"
187
- >{showQuickPrompts ? '▲ ซ่อนปุ่มลัด' : '▼ ปุ่มลัด'}</button>
188
- </div>
189
- </div>
 
190
 
191
- {/* ═══════════ Quick Prompt Buttons ═══════════ */}
192
- <div className={`grid grid-cols-2 gap-1.5 px-3 overflow-hidden transition-all duration-200 ${
193
- showQuickPrompts ? 'py-2 max-h-32 border-b opacity-100' : 'max-h-0 border-transparent opacity-0'
194
- } ${showQuickPrompts ? s.border : ''}`}>
195
- {[
196
- { emoji: '💬', text: 'อธิบาย', prompt: 'ช่วยอธิบายเนื้อหานี้ให้เข้าใจง่ายขึ้น' },
197
- { emoji: '📝', text: 'สรุป', prompt: 'ช่วยสรุปใจความสำคัญของเนื้อหานี้เป็นข้อๆ' },
198
- { emoji: '⚖️', text: 'วิเคราะห์ธรรม', prompt: 'ช่วยวิเคราะห์หลักธรรมที่ปรากฏในเนื้อหานี้' },
199
- { emoji: '💡', text: 'ประยุกต์ใช้', prompt: 'หลักธรรมนี้ประยุกต์ใช้ในชีวิตประจำวันได้อย่างไร' },
200
- ].map(({ emoji, text, prompt }) => (
201
- <button key={text} onClick={() => { const c = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}\nเนื้อหา:\n${currentContent}`; askAI(prompt, c); }}
202
- disabled={isStreaming}
203
- className={`flex items-center justify-center gap-1 px-2 py-1.5 text-[11px] font-medium rounded-xl border transition-all ${s.msgBg} ${s.border} hover:border-[#c8860a]/50 hover:text-[#c8860a] disabled:opacity-40`}
204
- >{emoji} {text}</button>
205
- ))}
206
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
- {/* ═══════════ Messages ═══════════ */}
209
- <div ref={scrollRef} className="flex-1 overflow-y-auto p-3 md:p-4 space-y-3">
210
- {messages.length === 0 && (
211
- <div className="h-full flex flex-col items-center justify-center text-center opacity-50 px-8 gap-3">
212
- <div className={`w-12 h-12 rounded-full flex items-center justify-center ${s.msgBg}`}>
213
- <Sparkles size={24} className="text-[#c8860a]" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  </div>
215
- <div>
216
- <p className="text-sm font-bold mb-0.5">ยินดีต้อนรับ</p>
217
- <p className="text-xs opacity-60">ลองถามเกี่ยวกับการสรุปหน้าปัจจุบัน หรือคำศัพท์บาลีที่สงสัยดูครับ</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  </div>
219
- </div>
220
- )}
221
 
222
- {messages.map((msg, i) => (
223
- <div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
224
- <div className={`max-w-[85%] rounded-2xl p-2.5 text-sm ${
225
- msg.role === 'user'
226
- ? 'bg-[#c8860a] text-white rounded-tr-none'
227
- : `${s.msgBg} border ${s.border} text-inherit rounded-tl-none`
228
- }`}>
229
- {msg.role === 'assistant' && msg.thinking && (
230
- <div className={`mb-1.5 p-1.5 rounded-lg border-l-2 border-[#c8860a]/30 text-[10px] italic opacity-70 whitespace-pre-wrap ${s.msgBg}`}>
231
- <span className="font-bold flex items-center gap-1 mb-0.5 not-italic text-[#c8860a]/60">
232
- <Brain size={9} /> กำังคิ...
233
- </span>
234
- {msg.thinking}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  </div>
236
  )}
237
- <div className="whitespace-pre-wrap leading-relaxed">
238
- {msg.content || (isStreaming && i === messages.length - 1 ? '...' : '')}
239
- </div>
240
  </div>
241
- </div>
242
- ))}
243
- </div>
244
 
245
- {/* ═══════════ Input ═══════════ */}
246
- <form onSubmit={handleSubmit} className={`p-2 md:p-3 border-t ${s.border} flex gap-2 flex-shrink-0 ${s.bg}`}>
247
- <input type="text" value={input} onChange={(e) => setInput(e.target.value)}
248
- placeholder="ถาม AI ได้เลย..." disabled={isStreaming}
249
- className={`flex-1 px-3 py-2 rounded-2xl outline-none text-sm disabled:opacity-50 border ${s.border} ${s.msgBg} ${s.text} focus:border-[#c8860a] transition-colors`}
250
- />
251
- <button type="submit" disabled={!input.trim() || isStreaming}
252
- className="p-2 bg-[#c8860a] text-white rounded-full disabled:opacity-50 hover:bg-[#9a6307] transition-colors flex-shrink-0"
253
- ><Send size={16} /></button>
254
- </form>
255
- </motion.div>
 
 
 
256
  );
257
  };
258
 
 
1
  import React, { useState, useRef, useEffect, useCallback } from 'react';
2
  import { useAIStore } from '../../stores/aiStore';
3
  import { useReaderStore, useThemeStore } from '../../stores/appStore';
4
+ import { X, Send, Sparkles, Trash2, Zap, Brain, Maximize2, ChevronLeft, BookOpen, Minimize2 } from 'lucide-react';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
 
7
+ const PANEL_STYLES: Record<string, { bg: string; text: string; border: string; msgBg: string; focusBg: string }> = {
8
+ dark: { bg: 'bg-[#0f0f1e]', text: 'text-[#e0e0e0]', border: 'border-[#3a3a5e]', msgBg: 'bg-[#1a1a2e]', focusBg: 'bg-[#0a0a0f]' },
9
+ light: { bg: 'bg-[#f5edd8]', text: 'text-[#1a1a1a]', border: 'border-[#e0d0b0]', msgBg: 'bg-[#fdfaf5]', focusBg: 'bg-[#faf3e0]' },
10
+ classic: { bg: 'bg-[#fdfaf5]', text: 'text-[#1a1a1a]', border: 'border-[#d4c4a0]', msgBg: 'bg-[#faf7f2]', focusBg: 'bg-[#f8f4ed]' },
11
  };
12
 
13
  const AIPopup: React.FC = () => {
 
21
  const { currentVolume, currentPage, currentContent } = useReaderStore();
22
  const { theme } = useThemeStore();
23
  const [input, setInput] = useState('');
24
+ const [showAiDisclaimer, setShowAiDisclaimer] = useState(false);
25
+ const [hasShownDisclaimer, setHasShownDisclaimer] = useState(false);
26
  const [showQuickPrompts, setShowQuickPrompts] = useState(true);
27
+ const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
28
+
29
  const scrollRef = useRef<HTMLDivElement>(null);
30
  const panelRef = useRef<HTMLDivElement>(null);
31
 
 
39
  if (dragPos) setPos(dragPos);
40
  }, []);
41
 
42
+ // Disclaimer Logic: Show for 5s when opening
43
+ useEffect(() => {
44
+ if (isOpen && !hasShownDisclaimer) {
45
+ setShowAiDisclaimer(true);
46
+ const timer = setTimeout(() => {
47
+ setShowAiDisclaimer(false);
48
+ setHasShownDisclaimer(true);
49
+ }, 10000);
50
+ return () => clearTimeout(timer);
51
+ }
52
+ }, [isOpen, hasShownDisclaimer]);
53
+
54
  const handleMouseDown = useCallback((e: React.MouseEvent) => {
55
  // Only desktop — ignore if touch device or small screen
56
  if (window.innerWidth < 768) return;
57
+ if (expandedIndex !== null) return; // Disable drag in focus mode
58
 
59
  setIsDragging(true);
60
  const rect = panelRef.current?.getBoundingClientRect();
 
62
  dragOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
63
  }
64
  e.preventDefault();
65
+ }, [expandedIndex]);
66
 
67
  useEffect(() => {
68
  if (!isDragging) return;
 
92
  const s = PANEL_STYLES[theme] ?? PANEL_STYLES.dark;
93
 
94
  useEffect(() => {
95
+ if (scrollRef.current && expandedIndex === null) {
96
  scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
97
  }
98
+ }, [messages, isStreaming, expandedIndex]);
99
 
100
  // Check RAG status when popup opens
101
  useEffect(() => {
 
110
  setInput('');
111
  };
112
 
113
+ // Keyboard shortcut to close Focus Mode
114
+ useEffect(() => {
115
+ const handleKeyDown = (e: KeyboardEvent) => {
116
+ if (e.key === 'Escape' && expandedIndex !== null) {
117
+ setExpandedIndex(null);
118
+ }
119
+ };
120
+ window.addEventListener('keydown', handleKeyDown);
121
+ return () => window.removeEventListener('keydown', handleKeyDown);
122
+ }, [expandedIndex]);
123
+
124
  if (!isOpen) return null;
125
 
126
  // RAG status dot color
 
133
  : 'bg-red-400'; // 🔴 ไม่พร้อม
134
 
135
  return (
136
+ <AnimatePresence>
137
+ {isOpen && (
138
+ <>
139
+ {/* ═══════════ Focus Mode Overlay ═══════════ */}
140
+ <AnimatePresence>
141
+ {expandedIndex !== null && (
142
+ <motion.div
143
+ initial={{ opacity: 0, y: 20 }}
144
+ animate={{ opacity: 1, y: 0 }}
145
+ exit={{ opacity: 0, y: 20 }}
146
+ className={`fixed inset-0 z-[100] flex flex-col ${s.focusBg} ${s.text}`}
147
+ >
148
+ {/* Focus Header */}
149
+ <div className="flex items-center justify-between px-4 py-3 md:px-8 border-b border-white/5 bg-black/10 backdrop-blur-md">
150
+ <div className="flex items-center gap-3">
151
+ <button
152
+ onClick={() => setExpandedIndex(null)}
153
+ className="p-2 hover:bg-white/10 rounded-full transition-colors"
154
+ aria-label="Back to chat"
155
+ >
156
+ <ChevronLeft size={24} />
157
+ </button>
158
+ <div className="flex items-center gap-2">
159
+ <BookOpen size={20} className="text-[#c8860a]" />
160
+ <h2 className="text-lg font-bold tracking-tight">โหมดการอ่าน</h2>
161
+ </div>
162
+ </div>
163
+ <button
164
+ onClick={() => setExpandedIndex(null)}
165
+ className="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 rounded-xl transition-all border border-white/10"
166
+ >
167
+ <span className="hidden sm:inline text-sm font-medium">ปิดหน้าต่าง</span>
168
+ <X size={20} />
169
+ </button>
170
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
+ {/* Focus Content */}
173
+ <div className="flex-1 overflow-y-auto px-4 py-8 md:px-0">
174
+ <div className="max-w-3xl mx-auto space-y-6">
175
+ {messages[expandedIndex]?.thinking && (
176
+ <div className="p-4 rounded-2xl bg-white/5 border border-white/10 italic text-sm opacity-70">
177
+ <div className="flex items-center gap-2 mb-2 not-italic text-[#c8860a] font-bold">
178
+ <Brain size={16} /> กระบวนการคิด
179
+ </div>
180
+ {messages[expandedIndex].thinking}
181
+ </div>
182
+ )}
183
+ <div className="text-lg md:text-xl leading-relaxed whitespace-pre-wrap font-serif">
184
+ {messages[expandedIndex]?.content}
185
+ </div>
186
+
187
+ {/* Progress indicator or metadata if needed */}
188
+ <div className="pt-12 pb-24 text-center opacity-30 text-xs flex flex-col items-center gap-2">
189
+ <Sparkles size={16} />
190
+ <p>จบเนื้อหาที่ AI ช่วยสรุป</p>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </motion.div>
195
+ )}
196
+ </AnimatePresence>
197
 
198
+ {/* ═══════════ Main Chat Panel ═══════════ */}
199
+ <motion.div
200
+ ref={panelRef}
201
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
202
+ animate={{
203
+ opacity: 1, scale: 1, y: 0,
204
+ ...(pos && window.innerWidth >= 768
205
+ ? { left: pos.x, top: pos.y, right: 'auto', bottom: 'auto' }
206
+ : {}),
207
+ }}
208
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
209
+ className={`
210
+ fixed z-50 flex flex-col overflow-hidden rounded-2xl border shadow-2xl
211
+ ${s.bg} ${s.text} ${s.border}
212
+ bottom-24
213
+ ${pos && window.innerWidth >= 768 ? '' : 'left-1/2 -translate-x-1/2 md:left-auto md:right-14 md:translate-x-0'}
214
+ w-[calc(100vw-32px)] md:w-[400px]
215
+ h-[80vh] md:h-[550px]
216
+ ${isDragging ? 'cursor-grabbing select-none' : ''}
217
+ ${expandedIndex !== null ? 'pointer-events-none opacity-0' : ''}
218
+ transition-opacity duration-300
219
+ `}
220
+ style={pos && window.innerWidth >= 768 ? {
221
+ position: 'fixed',
222
+ left: pos.x,
223
+ top: pos.y,
224
+ } : undefined}
225
+ >
226
+ {/* ═══════════ Compact Header + Drag Handle ═══════════ */}
227
+ <div
228
+ className="flex flex-col flex-shrink-0 bg-[#c8860a] text-white"
229
+ onMouseDown={handleMouseDown}
230
+ style={{ cursor: window.innerWidth >= 768 ? 'grab' : undefined }}
231
+ >
232
+ {/* Row 1: Title + actions */}
233
+ <div className="flex items-center justify-between px-3 py-2">
234
+ <div className="flex items-center gap-2 min-w-0">
235
+ <Sparkles size={16} className="flex-shrink-0" />
236
+ <h3 className="font-bold text-sm tracking-wide truncate">ผู้ช่วย AI</h3>
237
+ </div>
238
+ <div className="flex items-center gap-1 flex-shrink-0">
239
+ <div className="flex bg-black/15 rounded-lg p-0.5 mr-1">
240
+ <button
241
+ onClick={() => setMode('fast')}
242
+ onMouseDown={e => e.stopPropagation()}
243
+ className={`flex items-center gap-1 px-2 py-1 text-[10px] font-bold uppercase rounded-md transition-all ${
244
+ mode === 'fast' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white/90'
245
+ }`}
246
+ ><Zap size={10} /> เร็ว</button>
247
+ <button
248
+ onClick={() => setMode('reasoner')}
249
+ onMouseDown={e => e.stopPropagation()}
250
+ className={`flex items-center gap-1 px-2 py-1 text-[10px] font-bold uppercase rounded-md transition-all ${
251
+ mode === 'reasoner' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white/90'
252
+ }`}
253
+ ><Brain size={10} /> คิดลึก</button>
254
+ </div>
255
+ <button onClick={clearHistory} onMouseDown={e => e.stopPropagation()} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="ล้างการสนทนา" aria-label="ล้างการสนทนา"><Trash2 size={14} /></button>
256
+ <button onClick={toggleOpen} onMouseDown={e => e.stopPropagation()} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" aria-label="ปิด"><X size={16} /></button>
257
+ </div>
258
+ </div>
259
 
260
+ {/* Row 2: RAG toggle + Quick prompt toggle */}
261
+ <div className="flex items-center justify-between px-3 py-1 border-t border-white/10">
262
+ <button
263
+ onClick={() => setUseRag(!useRag)}
264
+ onMouseDown={e => e.stopPropagation()}
265
+ className="flex items-center gap-1.5 text-[10px] font-medium text-white/60 hover:text-white transition-colors"
266
+ >
267
+ <span className="flex items-center gap-1">
268
+ <span className={`inline-block w-1.5 h-1.5 rounded-full ${ragDot}`} title={
269
+ ragLoading ? 'RAG: กำลังโหลดโมเดล...' : ragReady ? 'RAG พร้อม' : 'RAG ไม่พร้อม'
270
+ } />
271
+ 📚 ค้นเล่มอื่น
272
+ </span>
273
+ <span className={`inline-flex items-center px-0.5 w-7 h-3.5 rounded-full transition-colors ${
274
+ useRag ? 'bg-white/50 justify-end' : 'bg-white/20 justify-start'
275
+ }`}>
276
+ <span className="w-2.5 h-2.5 bg-white rounded-full shadow-xs" />
277
+ </span>
278
+ </button>
279
+ <button
280
+ onClick={() => setShowQuickPrompts(v => !v)}
281
+ onMouseDown={e => e.stopPropagation()}
282
+ className="text-[10px] font-bold text-white/50 hover:text-white transition-colors"
283
+ >{showQuickPrompts ? '▲ ซ่อนปุ่มลัด' : '▼ ปุ่มลัด'}</button>
284
+ </div>
285
  </div>
286
+
287
+ {/* ═══════════ Quick Prompt / Disclaimer ═══════════ */}
288
+ <div className={`relative overflow-hidden transition-all duration-300 ${
289
+ (showQuickPrompts || showAiDisclaimer) ? 'max-h-40 border-b py-2' : 'max-h-0 border-transparent'
290
+ } ${s.border}`}>
291
+ <AnimatePresence mode="wait">
292
+ {showAiDisclaimer ? (
293
+ <motion.div
294
+ key="disclaimer"
295
+ initial={{ opacity: 0, y: 10 }}
296
+ animate={{ opacity: 1, y: 0 }}
297
+ exit={{ opacity: 0, y: -10 }}
298
+ className="px-4 py-1"
299
+ >
300
+ <div className="bg-[#c8860a]/10 border border-[#c8860a]/30 rounded-xl p-2.5 relative">
301
+ <button
302
+ onClick={() => { setShowAiDisclaimer(false); setHasShownDisclaimer(true); }}
303
+ className="absolute top-1 right-1 p-1 opacity-50 hover:opacity-100 transition-opacity"
304
+ >
305
+ <X size={12} />
306
+ </button>
307
+ <p className="text-[10.5px] leading-relaxed text-[#c8860a] font-medium pr-4">
308
+ <span className="font-bold">⚠️ ข้อควรระวัง:</span> คำอธิบายนี้ใช้ผู้ช่วย AI ในการตอบคำถาม ผู้อ่านควรใช้พิจารณาในการอ่านและควรวางอยู่บน "ความไม่ปลงใจเชื่อ" ตามหลักกาลามสูตร
309
+ </p>
310
+ </div>
311
+ </motion.div>
312
+ ) : showQuickPrompts && (
313
+ <motion.div
314
+ key="prompts"
315
+ initial={{ opacity: 0 }}
316
+ animate={{ opacity: 1 }}
317
+ exit={{ opacity: 0 }}
318
+ className="grid grid-cols-2 gap-1.5 px-3"
319
+ >
320
+ {[
321
+ { emoji: '💬', text: 'อธิบาย', prompt: 'ช่วยอธิบายเนื้อหานี้ให้เข้าใจง่ายขึ้น' },
322
+ { emoji: '📝', text: 'สรุป', prompt: 'ช่วยสรุปใจความสำคัญของเนื้อหานี้เป็นข้อๆ' },
323
+ { emoji: '⚖️', text: 'วิเคราะห์ธรรม', prompt: 'ช่วยวิเคราะห์หลักธรรมที่ปรากฏในเนื้อหานี้' },
324
+ { emoji: '💡', text: 'ประยุกต์ใช้', prompt: 'หลักธรรมนี้ประยุกต์ใช้ในชีวิตประจำวันได้อย่างไร' },
325
+ ].map(({ emoji, text, prompt }) => (
326
+ <button key={text} onClick={() => { const c = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}\nเนื้อหา:\n${currentContent}`; askAI(prompt, c); }}
327
+ disabled={isStreaming}
328
+ className={`flex items-center justify-center gap-1 px-2 py-1.5 text-[11px] font-medium rounded-xl border transition-all ${s.msgBg} ${s.border} hover:border-[#c8860a]/50 hover:text-[#c8860a] disabled:opacity-40`}
329
+ >{emoji} {text}</button>
330
+ ))}
331
+ </motion.div>
332
+ )}
333
+ </AnimatePresence>
334
  </div>
 
 
335
 
336
+ {/* ═══════════ Messages ═══════════ */}
337
+ <div className="relative flex-1 min-h-0 flex flex-col">
338
+ <div ref={scrollRef} className="flex-1 overflow-y-auto p-3 md:p-4 space-y-4">
339
+ {messages.length === 0 && (
340
+ <div className="h-full flex flex-col items-center justify-center text-center opacity-50 px-8 gap-3">
341
+ <div className={`w-12 h-12 rounded-full flex items-center justify-center ${s.msgBg}`}>
342
+ <Sparkles size={24} className="text-[#c8860a]" />
343
+ </div>
344
+ <div>
345
+ <p className="text-sm font-bold mb-0.5">ยินดีต้อนรับ</p>
346
+ <p className="text-xs opacity-60">ลองถามเี่ยวกับการสรุปหน้าปัจจุบัน หรือคพท์บาลีที่สสัยูครับ</p>
347
+ </div>
348
+ </div>
349
+ )}
350
+
351
+ {messages.map((msg, i) => (
352
+ <div key={i} className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'} group`}>
353
+ <div className={`relative max-w-[90%] rounded-2xl p-3 text-sm ${
354
+ msg.role === 'user'
355
+ ? 'bg-[#c8860a] text-white rounded-tr-none'
356
+ : `${s.msgBg} border ${s.border} text-inherit rounded-tl-none`
357
+ }`}>
358
+ {msg.role === 'assistant' && msg.thinking && (
359
+ <div className={`mb-2 p-2 rounded-lg border-l-2 border-[#c8860a]/30 text-[10px] italic opacity-70 whitespace-pre-wrap ${s.msgBg}`}>
360
+ <span className="font-bold flex items-center gap-1 mb-1 not-italic text-[#c8860a]/60">
361
+ <Brain size={10} /> กำลังคิด...
362
+ </span>
363
+ {msg.thinking}
364
+ </div>
365
+ )}
366
+ <div className="whitespace-pre-wrap leading-relaxed">
367
+ {msg.content || (isStreaming && i === messages.length - 1 ? '...' : '')}
368
+ </div>
369
+ </div>
370
+ </div>
371
+ ))}
372
+ </div>
373
+
374
+ {/* Persistent Floating Maximize Button (Visible when scrolled) */}
375
+ {messages.some(m => m.role === 'assistant') && !isStreaming && (
376
+ <div className="absolute bottom-4 right-4 z-10">
377
+ <button
378
+ onClick={() => {
379
+ const lastAi = messages.findLastIndex(m => m.role === 'assistant');
380
+ if (lastAi !== -1) setExpandedIndex(lastAi);
381
+ }}
382
+ className="p-3 bg-white/10 backdrop-blur-md hover:bg-[#c8860a] text-[#c8860a] hover:text-white rounded-2xl shadow-xl transition-all border border-[#c8860a]/20 hover:border-[#c8860a] active:scale-95"
383
+ title="อ่านแบบเต็มจอ"
384
+ >
385
+ <Maximize2 size={20} />
386
+ </button>
387
  </div>
388
  )}
 
 
 
389
  </div>
 
 
 
390
 
391
+ {/* ═══════════ Input ═══════════ */}
392
+ <form onSubmit={handleSubmit} className={`p-2 md:p-3 border-t ${s.border} flex gap-2 flex-shrink-0 ${s.bg}`}>
393
+ <input type="text" value={input} onChange={(e) => setInput(e.target.value)}
394
+ placeholder="ถาม AI ได้เลย..." disabled={isStreaming}
395
+ className={`flex-1 px-3 py-2 rounded-2xl outline-none text-sm disabled:opacity-50 border ${s.border} ${s.msgBg} ${s.text} focus:border-[#c8860a] transition-colors`}
396
+ />
397
+ <button type="submit" disabled={!input.trim() || isStreaming}
398
+ className="p-2 bg-[#c8860a] text-white rounded-full disabled:opacity-50 hover:bg-[#9a6307] transition-colors flex-shrink-0"
399
+ ><Send size={16} /></button>
400
+ </form>
401
+ </motion.div>
402
+ </>
403
+ )}
404
+ </AnimatePresence>
405
  );
406
  };
407