Spaces:
Running
Running
Commit ·
165565d
1
Parent(s): 23b4d13
feat: normalize reference extraction and popup UI
Browse files- webapp/tipitaka-api/app/database/sqlite_db.py +20 -0
- webapp/tipitaka-api/app/routers/reference.py +4 -5
- webapp/tipitaka-api/app/scripts/index_references.py +98 -0
- webapp/tipitaka-api/app/services/llm_service.py +25 -4
- webapp/tipitaka-api/app/services/page_service.py +39 -52
- webapp/tipitaka-web/src/components/common/ErrorBoundary.tsx +61 -0
- webapp/tipitaka-web/src/components/layout/AppShell.tsx +9 -2
- webapp/tipitaka-web/src/components/layout/ReaderPanel.tsx +38 -7
- webapp/tipitaka-web/src/components/reader/ReferencePopup.tsx +16 -1
webapp/tipitaka-api/app/database/sqlite_db.py
CHANGED
|
@@ -82,6 +82,24 @@ class SQLiteDB:
|
|
| 82 |
conn.execute("CREATE INDEX IF NOT EXISTS idx_search_log_query ON search_log(query)")
|
| 83 |
conn.commit()
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
@property
|
| 86 |
def is_in_memory(self) -> bool:
|
| 87 |
return self._mem_conn is not None
|
|
@@ -97,4 +115,6 @@ def get_db() -> SQLiteDB:
|
|
| 97 |
if _db is None:
|
| 98 |
settings = get_settings()
|
| 99 |
_db = SQLiteDB(settings.DATABASE_PATH)
|
|
|
|
|
|
|
| 100 |
return _db
|
|
|
|
| 82 |
conn.execute("CREATE INDEX IF NOT EXISTS idx_search_log_query ON search_log(query)")
|
| 83 |
conn.commit()
|
| 84 |
|
| 85 |
+
def ensure_reference_tables(self) -> None:
|
| 86 |
+
"""Create reference_markers table on disk if not exists."""
|
| 87 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 88 |
+
conn.execute("""
|
| 89 |
+
CREATE TABLE IF NOT EXISTS reference_markers (
|
| 90 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 91 |
+
volume_num INTEGER NOT NULL,
|
| 92 |
+
page_num INTEGER NOT NULL,
|
| 93 |
+
marker_id TEXT NOT NULL,
|
| 94 |
+
type TEXT NOT NULL, -- 'footnote' | 'abbrev'
|
| 95 |
+
content TEXT NOT NULL,
|
| 96 |
+
UNIQUE(volume_num, page_num, marker_id, type)
|
| 97 |
+
)
|
| 98 |
+
""")
|
| 99 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_ref_vol_page ON reference_markers(volume_num, page_num)")
|
| 100 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_ref_marker ON reference_markers(marker_id)")
|
| 101 |
+
conn.commit()
|
| 102 |
+
|
| 103 |
@property
|
| 104 |
def is_in_memory(self) -> bool:
|
| 105 |
return self._mem_conn is not None
|
|
|
|
| 115 |
if _db is None:
|
| 116 |
settings = get_settings()
|
| 117 |
_db = SQLiteDB(settings.DATABASE_PATH)
|
| 118 |
+
_db.ensure_search_log_table()
|
| 119 |
+
_db.ensure_reference_tables()
|
| 120 |
return _db
|
webapp/tipitaka-api/app/routers/reference.py
CHANGED
|
@@ -18,8 +18,8 @@ class ReferenceLookupRequest(BaseModel):
|
|
| 18 |
def get_page_service(db: SQLiteDB = Depends(get_db)) -> PageService:
|
| 19 |
return PageService(db)
|
| 20 |
|
| 21 |
-
def get_llm_service() -> LLMService:
|
| 22 |
-
return LLMService()
|
| 23 |
|
| 24 |
@router.get("/lookup")
|
| 25 |
async def lookup_reference(
|
|
@@ -37,12 +37,11 @@ async def lookup_reference(
|
|
| 37 |
return {"content": content, "found": True}
|
| 38 |
|
| 39 |
elif type == "abbrev":
|
| 40 |
-
#
|
| 41 |
-
# We fetch the page text if not provided
|
| 42 |
page_data = service.get_page(vol, page)
|
| 43 |
context = page_data["content_text"] if page_data else ""
|
| 44 |
|
| 45 |
-
expansion = await llm.expand_abbreviation(
|
| 46 |
return {"content": expansion, "found": True}
|
| 47 |
|
| 48 |
else:
|
|
|
|
| 18 |
def get_page_service(db: SQLiteDB = Depends(get_db)) -> PageService:
|
| 19 |
return PageService(db)
|
| 20 |
|
| 21 |
+
def get_llm_service(db: SQLiteDB = Depends(get_db)) -> LLMService:
|
| 22 |
+
return LLMService(db)
|
| 23 |
|
| 24 |
@router.get("/lookup")
|
| 25 |
async def lookup_reference(
|
|
|
|
| 37 |
return {"content": content, "found": True}
|
| 38 |
|
| 39 |
elif type == "abbrev":
|
| 40 |
+
# Fetch page text for LLM context if DB index fails
|
|
|
|
| 41 |
page_data = service.get_page(vol, page)
|
| 42 |
context = page_data["content_text"] if page_data else ""
|
| 43 |
|
| 44 |
+
expansion = await llm.expand_abbreviation(vol, page, id, context)
|
| 45 |
return {"content": expansion, "found": True}
|
| 46 |
|
| 47 |
else:
|
webapp/tipitaka-api/app/scripts/index_references.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import sqlite3
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
# Add app to path to import services/db
|
| 8 |
+
sys.path.append(str(Path(__file__).parent.parent.parent))
|
| 9 |
+
|
| 10 |
+
from app.database.sqlite_db import get_db
|
| 11 |
+
|
| 12 |
+
def extract_references():
|
| 13 |
+
db = get_db()
|
| 14 |
+
print(f"Starting reference indexing on {db.db_path}...")
|
| 15 |
+
|
| 16 |
+
# We use disk connection for writes
|
| 17 |
+
with db.get_disk_connection() as conn:
|
| 18 |
+
cursor = conn.cursor()
|
| 19 |
+
|
| 20 |
+
# 1. Get all pages
|
| 21 |
+
cursor.execute("""
|
| 22 |
+
SELECT p.id, v.volume_number, p.page_number, p.content_html
|
| 23 |
+
FROM pages p
|
| 24 |
+
JOIN volumes v ON p.volume_id = v.id
|
| 25 |
+
WHERE p.content_html IS NOT NULL
|
| 26 |
+
""")
|
| 27 |
+
|
| 28 |
+
pages = cursor.fetchall()
|
| 29 |
+
total = len(pages)
|
| 30 |
+
print(f"Processing {total} pages...")
|
| 31 |
+
|
| 32 |
+
indexed_count = 0
|
| 33 |
+
for i, page in enumerate(pages):
|
| 34 |
+
vol_num = page["volume_number"]
|
| 35 |
+
page_num = page["page_number"]
|
| 36 |
+
raw_html = page["content_html"]
|
| 37 |
+
|
| 38 |
+
# Extract footnotes (@ markers)
|
| 39 |
+
# Strip line numbers first
|
| 40 |
+
fn_html = re.sub(r'<span\s+class="LineNumber">.*?</span>', '', raw_html)
|
| 41 |
+
lines = fn_html.split('\n')
|
| 42 |
+
|
| 43 |
+
current_fn = None
|
| 44 |
+
for line in lines:
|
| 45 |
+
line_strip = line.strip()
|
| 46 |
+
if line_strip.startswith('@'):
|
| 47 |
+
line_plain = re.sub(r'<[^>]+>', '', line_strip)
|
| 48 |
+
# Match @ marker content
|
| 49 |
+
m = re.match(r'^@\s*([(\[]?[\u0E50-\u0E59\d]+[)\]-]?)\s*(.*)', line_plain)
|
| 50 |
+
if m:
|
| 51 |
+
marker = m.group(1).strip()
|
| 52 |
+
text = m.group(2).strip()
|
| 53 |
+
clean_marker = re.sub(r'[()\[\]-]', '', marker).strip()
|
| 54 |
+
|
| 55 |
+
if clean_marker and not re.search(r'เชิงอรรถ', marker):
|
| 56 |
+
# Save current if exists
|
| 57 |
+
if current_fn:
|
| 58 |
+
save_ref(cursor, vol_num, page_num, current_fn["id"], "footnote", current_fn["content"])
|
| 59 |
+
indexed_count += 1
|
| 60 |
+
|
| 61 |
+
current_fn = {"id": clean_marker, "content": text}
|
| 62 |
+
elif current_fn:
|
| 63 |
+
# Continuation line starting with @
|
| 64 |
+
current_fn["content"] += " " + line_plain.lstrip('@').strip()
|
| 65 |
+
elif current_fn:
|
| 66 |
+
# Non-@ line ends the footnote block
|
| 67 |
+
save_ref(cursor, vol_num, page_num, current_fn["id"], "footnote", current_fn["content"])
|
| 68 |
+
indexed_count += 1
|
| 69 |
+
current_fn = None
|
| 70 |
+
|
| 71 |
+
if current_fn:
|
| 72 |
+
save_ref(cursor, vol_num, page_num, current_fn["id"], "footnote", current_fn["content"])
|
| 73 |
+
indexed_count += 1
|
| 74 |
+
|
| 75 |
+
# Extract abbreviations (ย่อ) - this is harder as they are often just (ย่อ) in text
|
| 76 |
+
# For now, we can look for specific (ย่อ) patterns that might have been manually defined
|
| 77 |
+
# or common ones we want to pre-cache.
|
| 78 |
+
# In the current DB, most (ย่อ) are in-line.
|
| 79 |
+
# If there are any @(ย่อ) or similar, we catch them above.
|
| 80 |
+
|
| 81 |
+
if i % 1000 == 0:
|
| 82 |
+
print(f"Processed {i}/{total} pages... Indexed {indexed_count} markers")
|
| 83 |
+
conn.commit()
|
| 84 |
+
|
| 85 |
+
conn.commit()
|
| 86 |
+
print(f"Finished! Total indexed: {indexed_count}")
|
| 87 |
+
|
| 88 |
+
def save_ref(cursor, vol, page, marker_id, ref_type, content):
|
| 89 |
+
try:
|
| 90 |
+
cursor.execute("""
|
| 91 |
+
INSERT OR REPLACE INTO reference_markers (volume_num, page_num, marker_id, type, content)
|
| 92 |
+
VALUES (?, ?, ?, ?, ?)
|
| 93 |
+
""", (vol, page, marker_id, ref_type, content.strip()))
|
| 94 |
+
except Exception as e:
|
| 95 |
+
print(f"Error saving ref {vol}:{page} {marker_id}: {e}")
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
extract_references()
|
webapp/tipitaka-api/app/services/llm_service.py
CHANGED
|
@@ -39,8 +39,11 @@ SYSTEM_PROMPT = (
|
|
| 39 |
)
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
| 42 |
class LLMService:
|
| 43 |
-
def __init__(self):
|
| 44 |
settings = get_settings()
|
| 45 |
self.client = AsyncOpenAI(
|
| 46 |
api_key=settings.LLM_API_KEY,
|
|
@@ -52,6 +55,8 @@ class LLMService:
|
|
| 52 |
"reasoner": settings.LLM_MODEL_REASONER,
|
| 53 |
}
|
| 54 |
self.rag_service = RAGService()
|
|
|
|
|
|
|
| 55 |
|
| 56 |
def _resolve_model(self, mode: str) -> str:
|
| 57 |
return self.model_map.get(mode, self.model_map["fast"])
|
|
@@ -110,14 +115,30 @@ class LLMService:
|
|
| 110 |
|
| 111 |
except Exception as e:
|
| 112 |
yield {'data': json.dumps({'error': str(e)})}
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
prompt = (
|
| 116 |
f"คุณคือผู้เชี่ยวชาญพระไตรปิฎก มจร. "
|
| 117 |
f"ในเนื้อหาต่อไปนี้มีคำว่า '{abbrev_text}' (เช่น (ย่อ), ฯลฯ) "
|
| 118 |
f"กรุณาอธิบายหรือขยายความสิ่งที่ถูกย่อไว้ให้สมบูรณ์ที่สุดตามหลักฐานในพระไตรปิฎก "
|
| 119 |
f"โดยพิจารณาจากบริบทแวดล้อมที่ให้มา:\n\n"
|
| 120 |
-
f"เนื้อหาบริบท:\n{
|
| 121 |
f"กรุณาตอบเป็นข้อความสั้นๆ ที่เป็นเนื้อหาที่ถูกย่อไว้ หรืออธิบายว่าส่วนนี้ย่อมาจากอะไร "
|
| 122 |
f"ถ้าไม่แน่ใจให้บอกว่าเป็นการย่อเพื่อละเนื้อหาที่ซ้ำกัน"
|
| 123 |
)
|
|
|
|
| 39 |
)
|
| 40 |
|
| 41 |
|
| 42 |
+
from app.database.sqlite_db import SQLiteDB, get_db
|
| 43 |
+
from app.services.search_service import SearchService
|
| 44 |
+
|
| 45 |
class LLMService:
|
| 46 |
+
def __init__(self, db: Optional[SQLiteDB] = None):
|
| 47 |
settings = get_settings()
|
| 48 |
self.client = AsyncOpenAI(
|
| 49 |
api_key=settings.LLM_API_KEY,
|
|
|
|
| 55 |
"reasoner": settings.LLM_MODEL_REASONER,
|
| 56 |
}
|
| 57 |
self.rag_service = RAGService()
|
| 58 |
+
self.db = db or get_db()
|
| 59 |
+
self.search_service = SearchService(self.db)
|
| 60 |
|
| 61 |
def _resolve_model(self, mode: str) -> str:
|
| 62 |
return self.model_map.get(mode, self.model_map["fast"])
|
|
|
|
| 115 |
|
| 116 |
except Exception as e:
|
| 117 |
yield {'data': json.dumps({'error': str(e)})}
|
| 118 |
+
|
| 119 |
+
async def expand_abbreviation(self, vol: int, page: int, abbrev_text: str, context: str = "") -> str:
|
| 120 |
+
"""Expand abbreviation (ย่อ) using DB index or LLM as fallback."""
|
| 121 |
+
clean_id = re.sub(r'[()\[\]-]', '', abbrev_text).strip()
|
| 122 |
+
|
| 123 |
+
# 1. Try DB Index
|
| 124 |
+
with self.db.get_connection() as conn:
|
| 125 |
+
cursor = conn.cursor()
|
| 126 |
+
cursor.execute("""
|
| 127 |
+
SELECT content FROM reference_markers
|
| 128 |
+
WHERE volume_num = ? AND page_num = ? AND marker_id = ? AND type = 'abbrev'
|
| 129 |
+
""", (vol, page, clean_id))
|
| 130 |
+
row = cursor.fetchone()
|
| 131 |
+
if row:
|
| 132 |
+
return row["content"]
|
| 133 |
+
|
| 134 |
+
# 2. LLM Fallback
|
| 135 |
+
clean_ctx = self.search_service.clean_text(context) if context else ""
|
| 136 |
prompt = (
|
| 137 |
f"คุณคือผู้เชี่ยวชาญพระไตรปิฎก มจร. "
|
| 138 |
f"ในเนื้อหาต่อไปนี้มีคำว่า '{abbrev_text}' (เช่น (ย่อ), ฯลฯ) "
|
| 139 |
f"กรุณาอธิบายหรือขยายความสิ่งที่ถูกย่อไว้ให้สมบูรณ์ที่สุดตามหลักฐานในพระไตรปิฎก "
|
| 140 |
f"โดยพิจารณาจากบริบทแวดล้อมที่ให้มา:\n\n"
|
| 141 |
+
f"เนื้อหาบริบท:\n{clean_ctx[:4000]}\n\n"
|
| 142 |
f"กรุณาตอบเป็นข้อความสั้นๆ ที่เป็นเนื้อหาที่ถูกย่อไว้ หรืออธิบายว่าส่วนนี้ย่อมาจากอะไร "
|
| 143 |
f"ถ้าไม่แน่ใจให้บอกว่าเป็นการย่อเพื่อละเนื้อหาที่ซ้ำกัน"
|
| 144 |
)
|
webapp/tipitaka-api/app/services/page_service.py
CHANGED
|
@@ -152,9 +152,16 @@ class PageService:
|
|
| 152 |
|
| 153 |
def apply_inline_refs(text: str) -> str:
|
| 154 |
"""Wrap inline (Thai_digit) and (ย่อ) references as superscript."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
text = re.sub(
|
| 156 |
r'(?<=\S)\s*\(([\u0E50-\u0E59]+|\d+)\)',
|
| 157 |
-
|
| 158 |
text
|
| 159 |
)
|
| 160 |
text = re.sub(
|
|
@@ -178,11 +185,12 @@ class PageService:
|
|
| 178 |
if re.match(footnote_pattern, line):
|
| 179 |
flush_para()
|
| 180 |
marker = line.strip()
|
|
|
|
| 181 |
# Attach inline to the previous <p> so it doesn't create a gap
|
| 182 |
if wrapped_parts and wrapped_parts[-1].startswith('<p') and wrapped_parts[-1].endswith('</p>'):
|
| 183 |
-
wrapped_parts[-1] = wrapped_parts[-1][:-4] + f' <sup class="footnote-ref">{marker}</sup></p>'
|
| 184 |
else:
|
| 185 |
-
wrapped_parts.append(f'<p class="footnote-ref">{marker}</p>')
|
| 186 |
elif re.match(abbrev_pattern, line):
|
| 187 |
# (ย่อ) / ๓ (ย่อ) / (๓) (ย่อ) — split into separate sups:
|
| 188 |
# number part → footnote-ref, (ย่อ) → abbrev-ref
|
|
@@ -372,59 +380,38 @@ class PageService:
|
|
| 372 |
columns = [c[0] for c in cursor.description]
|
| 373 |
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
| 374 |
def get_footnote(self, volume_number: int, page_number: int, footnote_id: str) -> Optional[str]:
|
| 375 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
with self.db.get_connection() as conn:
|
| 377 |
cursor = conn.cursor()
|
|
|
|
|
|
|
| 378 |
cursor.execute("""
|
| 379 |
-
SELECT
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
WHERE v.volume_number = ? AND p.page_number = ?
|
| 383 |
-
""", (volume_number, page_number))
|
| 384 |
row = cursor.fetchone()
|
| 385 |
-
if
|
| 386 |
-
return
|
| 387 |
-
|
| 388 |
-
raw_html = row["content_html"]
|
| 389 |
-
lines = raw_html.split('\n')
|
| 390 |
|
| 391 |
-
#
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
start_pattern = rf'^@\s*[(\[]?{re.escape(clean_id)}[)\]-]?.*'
|
| 407 |
|
| 408 |
-
if re.match(start_pattern, line_plain):
|
| 409 |
-
capturing = True
|
| 410 |
-
# Strip the @ and marker part from the plain text to get content
|
| 411 |
-
content = re.sub(rf'^@\s*[(\[]?{re.escape(clean_id)}[)\]-]?\s*', '', line_plain)
|
| 412 |
-
if content:
|
| 413 |
-
found_lines.append(content)
|
| 414 |
-
elif capturing:
|
| 415 |
-
if line_strip.startswith('@'):
|
| 416 |
-
# Check if it's the next footnote or just a continuation line
|
| 417 |
-
# Continuation lines also start with @ but usually don't have a new marker immediately
|
| 418 |
-
next_marker = re.match(r'^@\s*[(\[]?[\u0E50-\u0E59\d]+[)\]-]?\s+\S', line_strip)
|
| 419 |
-
if next_marker:
|
| 420 |
-
break # Found next footnote
|
| 421 |
-
else:
|
| 422 |
-
# Continuation line
|
| 423 |
-
found_lines.append(line_strip.lstrip('@').strip())
|
| 424 |
-
else:
|
| 425 |
-
# Non-@ line ends the footnote block
|
| 426 |
-
break
|
| 427 |
-
|
| 428 |
-
if found_lines:
|
| 429 |
-
return " ".join(found_lines)
|
| 430 |
return None
|
|
|
|
| 152 |
|
| 153 |
def apply_inline_refs(text: str) -> str:
|
| 154 |
"""Wrap inline (Thai_digit) and (ย่อ) references as superscript."""
|
| 155 |
+
# Use a counter or some logic if we need multiple unique refs on one page?
|
| 156 |
+
# For now, just using the ID itself is usually enough unless the same ref appears twice.
|
| 157 |
+
def _wrap_fn(m):
|
| 158 |
+
val = m.group(1)
|
| 159 |
+
clean = re.sub(r'[()\[\]-]', '', val).strip()
|
| 160 |
+
return f'<sup id="ref-{clean}" class="footnote-ref">({val})</sup>'
|
| 161 |
+
|
| 162 |
text = re.sub(
|
| 163 |
r'(?<=\S)\s*\(([\u0E50-\u0E59]+|\d+)\)',
|
| 164 |
+
_wrap_fn,
|
| 165 |
text
|
| 166 |
)
|
| 167 |
text = re.sub(
|
|
|
|
| 185 |
if re.match(footnote_pattern, line):
|
| 186 |
flush_para()
|
| 187 |
marker = line.strip()
|
| 188 |
+
clean = re.sub(r'[()\[\]-]', '', marker).strip()
|
| 189 |
# Attach inline to the previous <p> so it doesn't create a gap
|
| 190 |
if wrapped_parts and wrapped_parts[-1].startswith('<p') and wrapped_parts[-1].endswith('</p>'):
|
| 191 |
+
wrapped_parts[-1] = wrapped_parts[-1][:-4] + f' <sup id="ref-{clean}" class="footnote-ref">{marker}</sup></p>'
|
| 192 |
else:
|
| 193 |
+
wrapped_parts.append(f'<p class="footnote-ref"><sup id="ref-{clean}">{marker}</sup></p>')
|
| 194 |
elif re.match(abbrev_pattern, line):
|
| 195 |
# (ย่อ) / ๓ (ย่อ) / (๓) (ย่อ) — split into separate sups:
|
| 196 |
# number part → footnote-ref, (ย่อ) → abbrev-ref
|
|
|
|
| 380 |
columns = [c[0] for c in cursor.description]
|
| 381 |
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
| 382 |
def get_footnote(self, volume_number: int, page_number: int, footnote_id: str) -> Optional[str]:
|
| 383 |
+
"""Lookup footnote from indexed reference_markers table."""
|
| 384 |
+
# Clean footnote_id (e.g. "(๑)" -> "๑", "๑-" -> "๑")
|
| 385 |
+
clean_id = re.sub(r'[()\[\]-]', '', footnote_id).strip()
|
| 386 |
+
if not clean_id:
|
| 387 |
+
return None
|
| 388 |
+
|
| 389 |
with self.db.get_connection() as conn:
|
| 390 |
cursor = conn.cursor()
|
| 391 |
+
|
| 392 |
+
# 1. Try exact page first
|
| 393 |
cursor.execute("""
|
| 394 |
+
SELECT content FROM reference_markers
|
| 395 |
+
WHERE volume_num = ? AND page_num = ? AND marker_id = ? AND type = 'footnote'
|
| 396 |
+
""", (volume_number, page_number, clean_id))
|
|
|
|
|
|
|
| 397 |
row = cursor.fetchone()
|
| 398 |
+
if row:
|
| 399 |
+
return row["content"]
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
+
# 2. Try window search (+/- 2 pages) to handle markers defined nearby
|
| 402 |
+
# This is common in MCU where a footnote ref on page X might be defined at the start of page X+1
|
| 403 |
+
# or end of page X-1.
|
| 404 |
+
cursor.execute("""
|
| 405 |
+
SELECT content, page_num FROM reference_markers
|
| 406 |
+
WHERE volume_num = ?
|
| 407 |
+
AND page_num BETWEEN ? AND ?
|
| 408 |
+
AND marker_id = ?
|
| 409 |
+
AND type = 'footnote'
|
| 410 |
+
ORDER BY ABS(page_num - ?) ASC
|
| 411 |
+
LIMIT 1
|
| 412 |
+
""", (volume_number, page_number - 2, page_number + 2, clean_id, page_number))
|
| 413 |
+
row = cursor.fetchone()
|
| 414 |
+
if row:
|
| 415 |
+
return row["content"]
|
|
|
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
return None
|
webapp/tipitaka-web/src/components/common/ErrorBoundary.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
| 2 |
+
|
| 3 |
+
interface Props {
|
| 4 |
+
children: ReactNode;
|
| 5 |
+
fallback?: ReactNode;
|
| 6 |
+
componentName?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface State {
|
| 10 |
+
hasError: boolean;
|
| 11 |
+
error: Error | null;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
class ErrorBoundary extends Component<Props, State> {
|
| 15 |
+
public state: State = {
|
| 16 |
+
hasError: false,
|
| 17 |
+
error: null
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
public static getDerivedStateFromError(error: Error): State {
|
| 21 |
+
// Update state so the next render will show the fallback UI.
|
| 22 |
+
return { hasError: true, error };
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
| 26 |
+
console.error(`Uncaught error in ${this.props.componentName || 'component'}:`, error, errorInfo);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
public render() {
|
| 30 |
+
if (this.state.hasError) {
|
| 31 |
+
if (this.props.fallback) {
|
| 32 |
+
return this.props.fallback;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-200 text-sm">
|
| 37 |
+
<div className="flex items-center gap-2 mb-2">
|
| 38 |
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 39 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
| 40 |
+
</svg>
|
| 41 |
+
<span className="font-bold">เกิดข้อผิดพลาดในการแสดงผล</span>
|
| 42 |
+
</div>
|
| 43 |
+
<p className="opacity-80 leading-relaxed">
|
| 44 |
+
มีบางอย่างผิดพลาดในส่วนนี้ของแอปพลิเคชัน
|
| 45 |
+
{this.props.componentName && ` (${this.props.componentName})`}
|
| 46 |
+
</p>
|
| 47 |
+
<button
|
| 48 |
+
onClick={() => this.setState({ hasError: false, error: null })}
|
| 49 |
+
className="mt-3 text-xs font-medium underline hover:no-underline opacity-60 hover:opacity-100"
|
| 50 |
+
>
|
| 51 |
+
ลองใหม่อีกครั้ง
|
| 52 |
+
</button>
|
| 53 |
+
</div>
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return this.props.children;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export default ErrorBoundary;
|
webapp/tipitaka-web/src/components/layout/AppShell.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import ReaderPanel from './ReaderPanel';
|
|
| 4 |
import RightToolbar from '../toolbar/RightToolbar';
|
| 5 |
import MobileBottomBar from './MobileBottomBar';
|
| 6 |
import AIPopup from '../ai/AIPopup';
|
|
|
|
| 7 |
import { Menu } from 'lucide-react';
|
| 8 |
import { useUIStore, useThemeStore } from '../../stores/appStore';
|
| 9 |
import { useAIStore } from '../../stores/aiStore';
|
|
@@ -79,7 +80,9 @@ const AppShell: React.FC = () => {
|
|
| 79 |
|
| 80 |
{/* Scrollable reader — id is referenced by RightToolbar scroll-to-top */}
|
| 81 |
<div id="reader-scroll-container" className="flex-1 overflow-y-auto scroll-smooth">
|
| 82 |
-
<
|
|
|
|
|
|
|
| 83 |
</div>
|
| 84 |
</div>
|
| 85 |
|
|
@@ -102,7 +105,11 @@ const AppShell: React.FC = () => {
|
|
| 102 |
|
| 103 |
{/* AI Popup */}
|
| 104 |
<AnimatePresence>
|
| 105 |
-
{isOpen &&
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
</AnimatePresence>
|
| 107 |
</div>
|
| 108 |
);
|
|
|
|
| 4 |
import RightToolbar from '../toolbar/RightToolbar';
|
| 5 |
import MobileBottomBar from './MobileBottomBar';
|
| 6 |
import AIPopup from '../ai/AIPopup';
|
| 7 |
+
import ErrorBoundary from '../common/ErrorBoundary';
|
| 8 |
import { Menu } from 'lucide-react';
|
| 9 |
import { useUIStore, useThemeStore } from '../../stores/appStore';
|
| 10 |
import { useAIStore } from '../../stores/aiStore';
|
|
|
|
| 80 |
|
| 81 |
{/* Scrollable reader — id is referenced by RightToolbar scroll-to-top */}
|
| 82 |
<div id="reader-scroll-container" className="flex-1 overflow-y-auto scroll-smooth">
|
| 83 |
+
<ErrorBoundary componentName="ReaderPanel">
|
| 84 |
+
<ReaderPanel />
|
| 85 |
+
</ErrorBoundary>
|
| 86 |
</div>
|
| 87 |
</div>
|
| 88 |
|
|
|
|
| 105 |
|
| 106 |
{/* AI Popup */}
|
| 107 |
<AnimatePresence>
|
| 108 |
+
{isOpen && (
|
| 109 |
+
<ErrorBoundary componentName="AIPopup">
|
| 110 |
+
<AIPopup />
|
| 111 |
+
</ErrorBoundary>
|
| 112 |
+
)}
|
| 113 |
</AnimatePresence>
|
| 114 |
</div>
|
| 115 |
);
|
webapp/tipitaka-web/src/components/layout/ReaderPanel.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import api from '../../lib/api';
|
|
| 4 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
import SelectionPopup from '../reader/SelectionPopup';
|
| 6 |
import ReferencePopup, { type RefPopupPos } from '../reader/ReferencePopup';
|
|
|
|
| 7 |
import { useSwipeNav } from '../../hooks/useSwipeNav';
|
| 8 |
|
| 9 |
const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
|
|
@@ -160,10 +161,31 @@ const ReaderPanel: React.FC = () => {
|
|
| 160 |
}
|
| 161 |
};
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
return (
|
| 164 |
<div ref={swipeRef} className={`flex-1 min-h-screen transition-colors duration-300 ${shellCls}`}>
|
| 165 |
<SelectionPopup />
|
| 166 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
<div className="max-w-3xl mx-auto px-4 py-10 lg:py-16">
|
| 168 |
<AnimatePresence mode="wait">
|
| 169 |
{loading ? (
|
|
@@ -222,12 +244,21 @@ const ReaderPanel: React.FC = () => {
|
|
| 222 |
เชิงอรรถ (Footnotes)
|
| 223 |
</h5>
|
| 224 |
<div className="space-y-4">
|
| 225 |
-
{content.footnotes.map((fn: any, idx: number) =>
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
<
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
</div>
|
| 232 |
</div>
|
| 233 |
)}
|
|
|
|
| 4 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
import SelectionPopup from '../reader/SelectionPopup';
|
| 6 |
import ReferencePopup, { type RefPopupPos } from '../reader/ReferencePopup';
|
| 7 |
+
import ErrorBoundary from '../common/ErrorBoundary';
|
| 8 |
import { useSwipeNav } from '../../hooks/useSwipeNav';
|
| 9 |
|
| 10 |
const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
|
|
|
|
| 161 |
}
|
| 162 |
};
|
| 163 |
|
| 164 |
+
const scrollToElement = (id: string) => {
|
| 165 |
+
const element = document.getElementById(id);
|
| 166 |
+
if (element) {
|
| 167 |
+
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
| 168 |
+
element.classList.add('bg-[#c8860a]/20');
|
| 169 |
+
setTimeout(() => element.classList.remove('bg-[#c8860a]/20'), 2000);
|
| 170 |
+
}
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
const scrollToFootnote = (fnId: string) => {
|
| 174 |
+
const cleanId = fnId.replace(/[()\[\]-]/g, '').trim();
|
| 175 |
+
scrollToElement(`fn-item-${cleanId}`);
|
| 176 |
+
setRefPos(null); // Close popup after jump
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
return (
|
| 180 |
<div ref={swipeRef} className={`flex-1 min-h-screen transition-colors duration-300 ${shellCls}`}>
|
| 181 |
<SelectionPopup />
|
| 182 |
+
<ErrorBoundary componentName="ReferencePopup">
|
| 183 |
+
<ReferencePopup
|
| 184 |
+
pos={refPos}
|
| 185 |
+
onClose={() => setRefPos(null)}
|
| 186 |
+
onJump={scrollToFootnote}
|
| 187 |
+
/>
|
| 188 |
+
</ErrorBoundary>
|
| 189 |
<div className="max-w-3xl mx-auto px-4 py-10 lg:py-16">
|
| 190 |
<AnimatePresence mode="wait">
|
| 191 |
{loading ? (
|
|
|
|
| 244 |
เชิงอรรถ (Footnotes)
|
| 245 |
</h5>
|
| 246 |
<div className="space-y-4">
|
| 247 |
+
{content.footnotes.map((fn: any, idx: number) => {
|
| 248 |
+
const cleanId = fn.id.replace(/[()\[\]-]/g, '').trim();
|
| 249 |
+
return (
|
| 250 |
+
<div
|
| 251 |
+
key={idx}
|
| 252 |
+
id={`fn-item-${cleanId}`}
|
| 253 |
+
className="flex gap-3 text-[13px] leading-relaxed text-white/50 hover:text-white/80 transition-all p-2 -m-2 rounded-lg cursor-pointer group"
|
| 254 |
+
onClick={() => scrollToElement(`ref-${cleanId}`)}
|
| 255 |
+
title="คลิกเพื่อกลับไปยังเนื้อหา"
|
| 256 |
+
>
|
| 257 |
+
<span className="text-[#c8860a] font-bold min-w-[24px] text-right shrink-0 group-hover:scale-110 transition-transform">{fn.id}</span>
|
| 258 |
+
<span className="font-light">{fn.content}</span>
|
| 259 |
+
</div>
|
| 260 |
+
);
|
| 261 |
+
})}
|
| 262 |
</div>
|
| 263 |
</div>
|
| 264 |
)}
|
webapp/tipitaka-web/src/components/reader/ReferencePopup.tsx
CHANGED
|
@@ -13,9 +13,10 @@ export interface RefPopupPos {
|
|
| 13 |
interface Props {
|
| 14 |
pos: RefPopupPos | null;
|
| 15 |
onClose: () => void;
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
-
const ReferencePopup: React.FC<Props> = ({ pos, onClose }) => {
|
| 19 |
const popupRef = useRef<HTMLDivElement>(null);
|
| 20 |
|
| 21 |
const [content, setContent] = React.useState<string | null>(null);
|
|
@@ -131,6 +132,20 @@ const ReferencePopup: React.FC<Props> = ({ pos, onClose }) => {
|
|
| 131 |
<span className="text-[10px] text-white/30 uppercase tracking-tighter">AI Expanded</span>
|
| 132 |
</div>
|
| 133 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
</div>
|
| 135 |
|
| 136 |
{/* Tail arrow */}
|
|
|
|
| 13 |
interface Props {
|
| 14 |
pos: RefPopupPos | null;
|
| 15 |
onClose: () => void;
|
| 16 |
+
onJump?: (id: string) => void;
|
| 17 |
}
|
| 18 |
|
| 19 |
+
const ReferencePopup: React.FC<Props> = ({ pos, onClose, onJump }) => {
|
| 20 |
const popupRef = useRef<HTMLDivElement>(null);
|
| 21 |
|
| 22 |
const [content, setContent] = React.useState<string | null>(null);
|
|
|
|
| 132 |
<span className="text-[10px] text-white/30 uppercase tracking-tighter">AI Expanded</span>
|
| 133 |
</div>
|
| 134 |
)}
|
| 135 |
+
|
| 136 |
+
{pos.type === 'footnote' && !isLoading && onJump && (
|
| 137 |
+
<div className="mt-4 pt-3 border-t border-white/5">
|
| 138 |
+
<button
|
| 139 |
+
onClick={() => onJump(pos.id)}
|
| 140 |
+
className="w-full py-2 px-3 rounded-xl bg-white/5 hover:bg-white/10 text-[#c8860a] text-xs font-bold transition-all flex items-center justify-center gap-2 group"
|
| 141 |
+
>
|
| 142 |
+
<svg className="w-3.5 h-3.5 transform group-hover:translate-y-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 143 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
| 144 |
+
</svg>
|
| 145 |
+
ดูเนื้อหาเต็มด้านล่าง
|
| 146 |
+
</button>
|
| 147 |
+
</div>
|
| 148 |
+
)}
|
| 149 |
</div>
|
| 150 |
|
| 151 |
{/* Tail arrow */}
|