dhammawatthumpra commited on
Commit
6b67cae
·
1 Parent(s): 165565d

fix: merge adjacent <B> headings with <I> footnote refs, fix marker normalization exclusion list

Browse files

- Merge <B>text</B><I>fn-</I><B>text</B> into one heading before B→h4
conversion to prevent footnote refs from splitting into multiple h4.
- Add ']' and Thai digits (\u0E50-\u0E59) to marker normalization
preceding-char exclusion list to prevent:
• [๓๒] ๒. → [๓๒] + \n๒.
• ๑๖. → ๑ + \n๖.

check_vols.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Check all volumes via direct SQLite query + spot-check via API."""
2
+ import sqlite3
3
+ import json
4
+ import urllib.request
5
+ import re
6
+
7
+ DB = r"F:\_Ai\Tipitaka-AI-Expert\RAG\tipitaka_mcu.db"
8
+ API = "http://localhost:8000/api/pages"
9
+
10
+ db = sqlite3.connect(DB)
11
+ db.row_factory = sqlite3.Row
12
+ cur = db.cursor()
13
+
14
+ # 1. Volume-by-volume footnote counts
15
+ cur.execute("""
16
+ SELECT v.volume_number, v.title, COUNT(r.id) as fn_count
17
+ FROM volumes v
18
+ LEFT JOIN reference_markers r ON r.volume_num = v.volume_number AND r.type = 'footnote'
19
+ GROUP BY v.volume_number
20
+ ORDER BY v.volume_number
21
+ """)
22
+ rows = cur.fetchall()
23
+
24
+ print(f"{'Vol':>3} {'FN':>5} Title")
25
+ print("=" * 55)
26
+ total_fn = 0
27
+ v_fn = 0
28
+ v_no = 0
29
+ for r in rows:
30
+ cnt = r["fn_count"]
31
+ total_fn += cnt
32
+ if cnt > 0: v_fn += 1
33
+ else: v_no += 1
34
+ print(f"{r['volume_number']:>3} {cnt:>5} {r['title'][:40]}")
35
+
36
+ print(f"\nWith FNs: {v_fn}, Without: {v_no}, Total: {total_fn}")
37
+
38
+ # 2. Distinct marker IDs per volume
39
+ print()
40
+ print("Unique markers per volume:")
41
+ cur.execute("""
42
+ SELECT volume_num, COUNT(DISTINCT marker_id) as unique_ids
43
+ FROM reference_markers WHERE type = 'footnote'
44
+ GROUP BY volume_num ORDER BY volume_num
45
+ """)
46
+ for r in cur.fetchall():
47
+ print(f" Vol {r['volume_num']}: {r['unique_ids']} unique marker IDs")
48
+
49
+ # 3. Pages with most footnotes
50
+ print()
51
+ print("Top pages (most footnotes):")
52
+ cur.execute("""
53
+ SELECT volume_num, page_num, COUNT(*) as cnt
54
+ FROM reference_markers WHERE type = 'footnote'
55
+ GROUP BY volume_num, page_num ORDER BY cnt DESC LIMIT 10
56
+ """)
57
+ for r in cur.fetchall():
58
+ print(f" Vol {r['volume_num']:>2} P{r['page_num']:>3}: {r['cnt']}")
59
+
60
+ db.close()
61
+
62
+ # 4. Spot-check via API (first 15 volumes + heavy ones)
63
+ print("\n\n=== API Spot Check ===")
64
+ def get_json(url, timeout=8):
65
+ try:
66
+ resp = urllib.request.urlopen(url, timeout=timeout)
67
+ return json.loads(resp.read())
68
+ except Exception as e:
69
+ return {"_error": str(e)}
70
+
71
+ # Check first 15 volumes for sup tags
72
+ for vol in [1,2,3,5,10,14,15,20,25,30,35,40,45]:
73
+ data = get_json(f"{API}/{vol}/1")
74
+ if "_error" in data:
75
+ print(f"Vol {vol}: API ERROR - {data['_error']}")
76
+ continue
77
+ fns = data.get("footnotes", [])
78
+ html = data.get("content_html_formatted", "")
79
+ sups = re.findall(r'<sup[^>]*>.*?</sup>', html)
80
+ blank = data.get("is_blank", False)
81
+ fn_str = f"{len(fns)} FN"
82
+ sup_str = f"{len(sups)} Sup"
83
+ status = "✅"
84
+ if len(fns) > 0 and len(sups) == 0:
85
+ status = "⚠️ NOSUP"
86
+ elif blank:
87
+ status = "⬜ blank"
88
+ print(f"Vol {vol:>2} P1: {fn_str:>7}, {sup_str:>7} {status}")
89
+
90
+ # Check pages with most footnotes
91
+ print()
92
+ heavy = [(5,34), (11,172), (11,180)] # from top10
93
+ for vol, pg in heavy:
94
+ data = get_json(f"{API}/{vol}/{pg}")
95
+ if "_error" not in data:
96
+ fns = data.get("footnotes", [])
97
+ html = data.get("content_html_formatted", "")
98
+ sups = re.findall(r'<sup[^>]*>.*?</sup>', html)
99
+ print(f"Vol {vol:>2} P{pg:>3}: {len(fns)} FN, {len(sups)} Sup {'✅' if len(fns)==len(sups) or len(sups)>0 else '⚠️'}")
scripts/preindex_abbrevs.log ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 2026-05-17 03:29:22,079 [INFO] 🚀 Starting preindex_abbrevs | model=deepseek-ai/deepseek-v4-flash
2
+ 2026-05-17 03:29:22,079 [INFO] DB: F:\_Ai\Tipitaka-AI-Expert\RAG\tipitaka_mcu.db
3
+ 2026-05-17 03:29:22,081 [INFO] Existing abbrev entries: 0
4
+ 2026-05-17 03:29:22,287 [INFO] Pages with (ย่อ): 1743
5
+ 2026-05-17 03:29:22,288 [INFO] (limited to 3)
6
+ 2026-05-17 03:30:56,957 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
7
+ 2026-05-17 03:30:56,974 [INFO] ✅ Vol 18 P39: 8 abbrevs, 0 chars
8
+ 2026-05-17 03:31:29,264 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
9
+ 2026-05-17 03:31:29,275 [INFO] ✅ Vol 37 P37: 2 abbrevs, 824 chars
10
+ 2026-05-17 03:31:41,947 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
11
+ 2026-05-17 03:31:41,971 [INFO] ✅ Vol 37 P39: 1 abbrevs, 0 chars
12
+ 2026-05-17 03:31:41,972 [INFO] ============================================================
13
+ 2026-05-17 03:31:41,972 [INFO] ✅ Done!
14
+ 2026-05-17 03:31:41,972 [INFO] Pages: 3 success, 0 failed (of 3)
15
+ 2026-05-17 03:31:41,972 [INFO] Abbrevs indexed: 11
16
+ 2026-05-17 03:31:41,972 [INFO] Time: 140s (46.5s/page)
17
+ 2026-05-17 03:31:41,972 [INFO] Avg: 0.1 abbrevs/min
18
+ 2026-05-17 03:32:45,241 [INFO] 🚀 Starting preindex_abbrevs | model=deepseek-ai/deepseek-v4-flash
19
+ 2026-05-17 03:32:45,241 [INFO] DB: F:\_Ai\Tipitaka-AI-Expert\RAG\tipitaka_mcu.db
20
+ 2026-05-17 03:32:45,244 [INFO] Existing abbrev entries: 0
21
+ 2026-05-17 03:32:45,466 [INFO] Pages with (ย่อ): 1743
22
+ 2026-05-17 03:32:45,467 [INFO] (limited to 3)
23
+ 2026-05-17 03:34:00,493 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
24
+ 2026-05-17 03:34:00,517 [INFO] Parsed 0/8 expansions
25
+ 2026-05-17 03:34:00,517 [WARNING] [Vol 18 P39] All expansions empty! Raw: ```json
26
+ [
27
+ {
28
+ "num": 34,
29
+ "title": "ชราธัมมสูตร (ว่าด้วยความแก่)",
30
+ "detail": "พระผู้มีพระภาคตรัสว่า สิ่งทั้งปวงมีความแก่เป็นธรรมดา ได้แก่ จักษุ รูป จักษุวิญญาณ จักษุสัมผัส และเวทนาที่เกิดจา
31
+ 2026-05-17 03:35:00,577 [INFO] 🚀 Starting preindex_abbrevs | model=deepseek-ai/deepseek-v4-flash
32
+ 2026-05-17 03:35:00,577 [INFO] DB: F:\_Ai\Tipitaka-AI-Expert\RAG\tipitaka_mcu.db
33
+ 2026-05-17 03:35:00,579 [INFO] Existing abbrev entries: 0
34
+ 2026-05-17 03:35:00,787 [INFO] Pages with (ย่อ): 1743
35
+ 2026-05-17 03:35:00,788 [INFO] (limited to 3)
36
+ 2026-05-17 03:36:59,770 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
37
+ 2026-05-17 03:36:59,796 [INFO] Parsed 9/8 expansions
38
+ 2026-05-17 03:36:59,807 [INFO] ✅ Vol 18 P39: 9 abbrevs, 953 chars
39
+ 2026-05-17 04:03:38,710 [INFO] 🚀 preindex_abbrevs | model=deepseek-ai/deepseek-v4-flash
40
+ 2026-05-17 04:03:38,711 [INFO] DB: F:\_Ai\Tipitaka-AI-Expert\RAG\tipitaka_mcu.db
41
+ 2026-05-17 04:03:38,711 [INFO] Key: nvapi-kK...
42
+ 2026-05-17 04:03:38,711 [INFO] Rate: 2.0s
43
+ 2026-05-17 04:03:38,715 [INFO] Existing: 1 abbrev entries
44
+ 2026-05-17 04:03:38,931 [INFO] Pages: 1743
45
+ 2026-05-17 04:03:38,989 [INFO]
46
+ ============================================================
47
+ 2026-05-17 04:03:38,989 [INFO] Processing 1743 pages...
48
+ 2026-05-17 04:03:38,989 [INFO] ============================================================
49
+
50
+ 2026-05-17 04:03:55,248 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
51
+ 2026-05-17 04:03:55,280 [INFO] ✅ Vol 18 P39: 8 abbrevs, 268c
52
+ 2026-05-17 04:04:20,502 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
53
+ 2026-05-17 04:04:20,515 [INFO] ✅ Vol 37 P37: 3 abbrevs, 209c
54
+ 2026-05-17 04:04:23,893 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
55
+ 2026-05-17 04:04:23,904 [INFO] ✅ Vol 37 P39: 2 abbrevs, 114c
56
+ 2026-05-17 04:04:46,374 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
57
+ 2026-05-17 04:04:46,385 [INFO] ✅ Vol 37 P43: 4 abbrevs, 314c
58
+ 2026-05-17 04:06:09,875 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
59
+ 2026-05-17 04:06:10,154 [INFO] ✅ Vol 37 P54: 3 abbrevs, 80c
60
+ 2026-05-17 04:06:29,539 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
61
+ 2026-05-17 04:06:29,953 [INFO] ✅ Vol 37 P85: 15 abbrevs, 838c
62
+ 2026-05-17 04:06:43,054 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
63
+ 2026-05-17 04:06:43,067 [INFO] ✅ Vol 37 P108: 3 abbrevs, 436c
64
+ 2026-05-17 04:06:58,717 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
65
+ 2026-05-17 04:06:58,728 [INFO] ✅ Vol 37 P176: 2 abbrevs, 103c
66
+ 2026-05-17 04:07:04,079 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
67
+ 2026-05-17 04:07:04,092 [INFO] ✅ Vol 38 P157: 2 abbrevs, 80c
68
+ 2026-05-17 04:07:15,607 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
69
+ 2026-05-17 04:07:15,618 [INFO] ✅ Vol 40 P37: 3 abbrevs, 108c
70
+ 2026-05-17 04:07:30,573 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
71
+ 2026-05-17 04:07:30,606 [INFO] ✅ Vol 40 P104: 18 abbrevs, 642c
72
+ 2026-05-17 04:08:02,935 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
73
+ 2026-05-17 04:08:02,946 [INFO] ✅ Vol 40 P105: 25 abbrevs, 1103c
74
+ 2026-05-17 04:08:48,306 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
75
+ 2026-05-17 04:08:48,336 [INFO] ✅ Vol 40 P107: 23 abbrevs, 676c
76
+ 2026-05-17 04:09:47,383 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
77
+ 2026-05-17 04:09:47,394 [INFO] ✅ Vol 40 P109: 22 abbrevs, 1318c
78
+ 2026-05-17 04:10:37,257 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
79
+ 2026-05-17 04:10:37,269 [INFO] ✅ Vol 40 P111: 24 abbrevs, 1434c
80
+ 2026-05-17 04:11:28,831 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
81
+ 2026-05-17 04:11:28,843 [INFO] ✅ Vol 40 P113: 23 abbrevs, 1244c
82
+ 2026-05-17 04:11:51,876 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
83
+ 2026-05-17 04:11:51,889 [INFO] ✅ Vol 40 P115: 25 abbrevs, 1399c
84
+ 2026-05-17 04:12:24,758 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
85
+ 2026-05-17 04:12:24,768 [INFO] ✅ Vol 40 P116: 3 abbrevs, 660c
86
+ 2026-05-17 04:12:37,534 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
87
+ 2026-05-17 04:12:37,546 [INFO] ✅ Vol 40 P117: 15 abbrevs, 766c
88
+ 2026-05-17 04:13:50,627 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
89
+ 2026-05-17 04:13:50,638 [INFO] ✅ Vol 40 P118: 22 abbrevs, 1187c
90
+ 2026-05-17 04:14:11,666 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
91
+ 2026-05-17 04:14:11,690 [INFO] ✅ Vol 40 P119: 23 abbrevs, 1090c
92
+ 2026-05-17 04:14:36,526 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
93
+ 2026-05-17 04:14:36,538 [INFO] ✅ Vol 40 P121: 23 abbrevs, 1347c
94
+ 2026-05-17 04:15:41,654 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
95
+ 2026-05-17 04:15:41,666 [INFO] ✅ Vol 40 P171: 23 abbrevs, 988c
96
+ 2026-05-17 04:15:59,997 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
97
+ 2026-05-17 04:16:00,009 [INFO] ✅ Vol 40 P172: 23 abbrevs, 626c
98
+ 2026-05-17 04:17:31,849 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
99
+ 2026-05-17 04:17:32,487 [INFO] ✅ Vol 40 P173: 24 abbrevs, 1034c
100
+ 2026-05-17 04:17:51,983 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
101
+ 2026-05-17 04:17:51,995 [INFO] ✅ Vol 40 P174: 3 abbrevs, 252c
102
+ 2026-05-17 04:18:46,797 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
103
+ 2026-05-17 04:18:46,810 [INFO] ✅ Vol 40 P176: 22 abbrevs, 917c
104
+ 2026-05-17 04:19:17,329 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
105
+ 2026-05-17 04:19:17,347 [INFO] ✅ Vol 40 P177: 20 abbrevs, 827c
106
+ 2026-05-17 04:19:43,892 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
107
+ 2026-05-17 04:19:43,902 [INFO] ✅ Vol 40 P178: 19 abbrevs, 1061c
108
+ 2026-05-17 04:19:56,695 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
109
+ 2026-05-17 04:19:56,723 [INFO] ✅ Vol 40 P179: 21 abbrevs, 651c
110
+ 2026-05-17 04:19:58,656 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
111
+ 2026-05-17 04:19:58,726 [INFO] ✅ Vol 40 P180: 2 abbrevs, 67c
112
+ 2026-05-17 04:20:09,057 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
113
+ 2026-05-17 04:20:09,138 [INFO] ✅ Vol 40 P181: 2 abbrevs, 108c
114
+ 2026-05-17 04:22:09,142 [WARNING] ⚠ Vol 40 P183 Error (attempt 1): Request timed out.
115
+ 2026-05-17 04:23:10,900 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
116
+ 2026-05-17 04:23:11,332 [INFO] ✅ Vol 40 P183: 13 abbrevs, 760c
117
+ 2026-05-17 04:24:08,773 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
118
+ 2026-05-17 04:24:09,206 [INFO] ✅ Vol 40 P186: 13 abbrevs, 883c
119
+ 2026-05-17 04:24:28,705 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
120
+ 2026-05-17 04:24:28,717 [INFO] ✅ Vol 40 P188: 23 abbrevs, 923c
121
+ 2026-05-17 04:26:25,183 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
122
+ 2026-05-17 04:26:25,203 [INFO] ✅ Vol 40 P189: 23 abbrevs, 1387c
123
+ 2026-05-17 04:27:26,063 [INFO] HTTP Request: POST https://integrate.api.nvidia.com/v1/chat/completions "HTTP/1.1 200 OK"
124
+ 2026-05-17 04:27:26,075 [INFO] ✅ Vol 40 P191: 22 abbrevs, 1323c
scripts/preindex_abbrevs.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pre-index all (ย่อ) abbreviation markers using NVIDIA NIM (free DeepSeek, 40 RPM).
3
+
4
+ Usage:
5
+ # Full run (all 1,743 pages)
6
+ python scripts/preindex_abbrevs.py
7
+
8
+ # Resume from interrupted run
9
+ python scripts/preindex_abbrevs.py --resume
10
+
11
+ # Test: first 10 pages
12
+ python scripts/preindex_abbrevs.py --limit 10
13
+
14
+ Scans all pages in tipitaka_mcu.db for (ย่อ) markers.
15
+ For each page, calls NIM DeepSeek to expand ALL abbreviations in one request.
16
+ Stores JSON array in reference_markers (type='abbrev', marker_id='_default').
17
+ Supports resume — skips pages already in DB.
18
+
19
+ Log file: scripts/preindex_abbrevs.log
20
+ """
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ import re
25
+ import time
26
+ import argparse
27
+ import sqlite3
28
+ from pathlib import Path
29
+
30
+ from openai import AsyncOpenAI
31
+ from dotenv import load_dotenv
32
+ import os
33
+
34
+ # ── Paths ──
35
+ SCRIPT_DIR = Path(__file__).resolve().parent
36
+ PROJECT_ROOT = SCRIPT_DIR.parent
37
+ DB_PATH = PROJECT_ROOT / "tipitaka_mcu.db"
38
+ LOG_PATH = SCRIPT_DIR / "preindex_abbrevs.log"
39
+ ENV_PATH = PROJECT_ROOT.parent / "tipitaka_context" / "nvidia" / ".env"
40
+
41
+ # ══════════════════════════════════════════════════════════════
42
+ # Config (loaded from .env)
43
+ # ══════════════════════════════════════════════════════════════
44
+ load_dotenv(ENV_PATH)
45
+
46
+ NIM_API_KEY = os.getenv("TIPITAKA_API_KEY") or os.getenv("NVIDIA_API_KEY") or ""
47
+ NIM_BASE_URL = os.getenv("TIPITAKA_BASE_URL", "https://integrate.api.nvidia.com/v1")
48
+ NIM_MODEL = os.getenv("TIPITAKA_MODEL", "deepseek-ai/deepseek-v4-flash")
49
+ RATE_INTERVAL = float(os.getenv("TIPITAKA_RATE_INTERVAL", "1.6")) # 40 RPM
50
+
51
+ MAX_RETRIES = 4
52
+ RETRY_DELAYS = [10, 20, 40, 80]
53
+
54
+ # ── Logging ──
55
+ logging.basicConfig(
56
+ level=logging.INFO,
57
+ format="%(asctime)s [%(levelname)s] %(message)s",
58
+ handlers=[
59
+ logging.StreamHandler(),
60
+ logging.FileHandler(LOG_PATH, encoding="utf-8"),
61
+ ],
62
+ )
63
+ log = logging.getLogger(__name__)
64
+
65
+
66
+ # ══════════════════════════════════════════════════════════════
67
+ # Prompts
68
+ # ══════════════════════════════════════════════════════════════
69
+ SYSTEM_PROMPT = (
70
+ "คุณคือผู้เชี่ยวชาญพระไตรปิฎก มจร.\n"
71
+ "ตอบเป็น JSON array เท่านั้น โดยแต่ละรายการเป็น string"
72
+ )
73
+
74
+ USER_PROMPT_TEMPLATE = """ขยายความ (ย่อ) แต่ละแห่งในเนื้อหาด้านล่าง
75
+
76
+ ให้ตอบเป็น JSON array:
77
+ [
78
+ "ข้อความที่ถูกย่อแห่งที่ 1...",
79
+ "ข้อความที่ถูกย่อแห่งที่ 2...",
80
+ ...
81
+ ]
82
+ โดยเรียงลำดับตามที่ (ย่อ) ปรากฏในเนื้อหา
83
+ แต่ละรายการต้องเป็น string สั้นๆ อธิบายสิ่งที่ถูกย่อไว้
84
+ ตอบเฉพาะ JSON array เท่านั้น
85
+
86
+ เนื้อหา:
87
+ {content}"""
88
+
89
+
90
+ def build_prompt(content_text: str) -> str:
91
+ return USER_PROMPT_TEMPLATE.format(content=content_text[:8000])
92
+
93
+
94
+ # ══════════════════════════════════════════════════════════════
95
+ # Parsing
96
+ # ══════════════════════════════════════════════════════════════
97
+ def parse_response(raw: str, expected_count: int) -> list[str]:
98
+ """Parse JSON array from NIM response into list of expansion strings."""
99
+ raw = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip()
100
+ raw = re.sub(r'^```(?:json)?\s*', '', raw)
101
+ raw = re.sub(r'\s*```$', '', raw)
102
+ raw = raw.strip()
103
+
104
+ match = re.search(r'\[[\s\S]*\]', raw)
105
+ if not match:
106
+ raise ValueError(f"No JSON array found: {raw[:300]}")
107
+
108
+ data = json.loads(match.group(0))
109
+ if not isinstance(data, list):
110
+ raise ValueError(f"Not an array: {type(data)}")
111
+
112
+ result = []
113
+ for item in data:
114
+ if isinstance(item, str):
115
+ result.append(item)
116
+ elif isinstance(item, dict):
117
+ # Try known keys, then concatenate all values
118
+ for key in ("expansion", "expanded", "content", "text",
119
+ "explanation", "detail", "description", "meaning",
120
+ "abbrev", "ข���ายความ", "ข้อความ", "ความหมาย", "answer"):
121
+ val = item.get(key, "")
122
+ if isinstance(val, str) and len(val.strip()) > 10:
123
+ result.append(val.strip())
124
+ break
125
+ else:
126
+ parts = [v for v in item.values()
127
+ if isinstance(v, str) and len(v.strip()) > 10]
128
+ if parts:
129
+ result.append(" | ".join(parts))
130
+
131
+ return result
132
+
133
+
134
+ # ══════════════════════════════════════════════════════════════
135
+ # Core: expand one page
136
+ # ══════════════════════════════════════════════════════════════
137
+ async def expand_page(
138
+ client: AsyncOpenAI,
139
+ vol: int, page: int, content_text: str,
140
+ conn: sqlite3.Connection,
141
+ ) -> bool:
142
+ """Expand all (ย่อ) on one page. Stores result in DB. Returns True on success."""
143
+ abbrev_count = content_text.count("(ย่อ)")
144
+ if abbrev_count == 0:
145
+ return False
146
+
147
+ prompt = build_prompt(content_text)
148
+
149
+ for attempt in range(MAX_RETRIES):
150
+ try:
151
+ resp = await client.chat.completions.create(
152
+ model=NIM_MODEL,
153
+ messages=[
154
+ {"role": "system", "content": SYSTEM_PROMPT},
155
+ {"role": "user", "content": prompt},
156
+ ],
157
+ temperature=0.3,
158
+ max_tokens=2000,
159
+ timeout=120,
160
+ )
161
+
162
+ raw = resp.choices[0].message.content or ""
163
+
164
+ expansions = parse_response(raw, abbrev_count)
165
+
166
+ # Filter meaningful expansions
167
+ expansions = [e for e in expansions if len(e.strip()) > 15]
168
+
169
+ if not expansions:
170
+ log.warning(f" Empty! retrying... raw={raw[:150]}")
171
+ if attempt < MAX_RETRIES - 1:
172
+ await asyncio.sleep(RETRY_DELAYS[attempt])
173
+ continue
174
+ return False
175
+
176
+ # Store in DB
177
+ stored = json.dumps(expansions, ensure_ascii=False)
178
+ conn.execute("""
179
+ INSERT OR REPLACE INTO reference_markers
180
+ (volume_num, page_num, marker_id, type, content)
181
+ VALUES (?, ?, '_default', 'abbrev', ?)
182
+ """, (vol, page, stored))
183
+ conn.commit()
184
+
185
+ total_chars = sum(len(e) for e in expansions)
186
+ log.info(f" ✅ Vol {vol} P{page}: {len(expansions)} abbrevs, {total_chars}c")
187
+ return True
188
+
189
+ except json.JSONDecodeError as e:
190
+ log.warning(f" ⚠ Vol {vol} P{page} JSON error (attempt {attempt+1}): {e}")
191
+ if attempt < MAX_RETRIES - 1:
192
+ await asyncio.sleep(RETRY_DELAYS[attempt])
193
+ except Exception as e:
194
+ err_str = str(e)
195
+ is_429 = "429" in err_str or "Too Many Requests" in err_str
196
+ log.warning(f" ⚠ Vol {vol} P{page} Error (attempt {attempt+1}): {e}")
197
+ if attempt < MAX_RETRIES - 1:
198
+ delay = RETRY_DELAYS[attempt] * (2 if is_429 else 1)
199
+ await asyncio.sleep(delay)
200
+
201
+ log.error(f" ❌ Vol {vol} P{page}: Failed after {MAX_RETRIES} attempts")
202
+ return False
203
+
204
+
205
+ # ══════════════════════════════════════════════════════════════
206
+ # Main
207
+ # ══════════════════════════════════════════════════════════════
208
+ async def main():
209
+ parser = argparse.ArgumentParser(description="Pre-index (ย่อ) abbreviations via NVIDIA NIM")
210
+ parser.add_argument("--resume", action="store_true", help="Skip already-indexed pages")
211
+ parser.add_argument("--force", action="store_true", help="Re-index even if already in DB")
212
+ parser.add_argument("--limit", type=int, default=0, help="Max pages (0 = all)")
213
+ args = parser.parse_args()
214
+
215
+ if not NIM_API_KEY:
216
+ log.error(f"❌ No API key found. Check env file: {ENV_PATH}")
217
+ log.error(" Set TIPITAKA_API_KEY or NVIDIA_API_KEY")
218
+ return
219
+
220
+ if not DB_PATH.exists():
221
+ log.error(f"❌ DB not found: {DB_PATH}")
222
+ return
223
+
224
+ log.info(f"🚀 preindex_abbrevs | model={NIM_MODEL}")
225
+ log.info(f" DB: {DB_PATH}")
226
+ log.info(f" Key: {NIM_API_KEY[:8]}...")
227
+ log.info(f" Rate: {RATE_INTERVAL}s")
228
+
229
+ # ── Load pages ──
230
+ conn = sqlite3.connect(str(DB_PATH))
231
+ conn.row_factory = sqlite3.Row
232
+ c = conn.cursor()
233
+
234
+ c.execute("SELECT COUNT(*) as cnt FROM reference_markers WHERE type='abbrev'")
235
+ log.info(f" Existing: {c.fetchone()['cnt']} abbrev entries")
236
+
237
+ c.execute("""
238
+ SELECT v.volume_number, p.page_number, p.content_text
239
+ FROM pages p
240
+ JOIN volumes v ON p.volume_id = v.id
241
+ WHERE p.content_text LIKE '%(ย่อ)%'
242
+ ORDER BY v.volume_number, p.page_number
243
+ """)
244
+ rows = c.fetchall()
245
+ log.info(f" Pages: {len(rows)}")
246
+
247
+ if args.limit > 0:
248
+ rows = rows[:args.limit]
249
+ log.info(f" Limit: {args.limit}")
250
+
251
+ if args.resume:
252
+ c.execute("SELECT DISTINCT volume_num, page_num FROM reference_markers WHERE type='abbrev'")
253
+ indexed = {(r["volume_num"], r["page_num"]) for r in c.fetchall()}
254
+ rows = [r for r in rows if (r["volume_number"], r["page_number"]) not in indexed]
255
+ log.info(f" Resume: {len(indexed)} done, {len(rows)} remaining")
256
+ if not rows:
257
+ log.info("✅ All done!")
258
+ conn.close()
259
+ return
260
+ elif args.force:
261
+ c.execute("DELETE FROM reference_markers WHERE type='abbrev'")
262
+ conn.commit()
263
+ log.info(" Force: cleared all")
264
+
265
+ # ── Setup client ──
266
+ client = AsyncOpenAI(
267
+ base_url=NIM_BASE_URL,
268
+ api_key=NIM_API_KEY,
269
+ max_retries=0,
270
+ )
271
+
272
+ total = len(rows)
273
+ success = fail = 0
274
+ start = time.time()
275
+
276
+ log.info(f"\n{'='*60}")
277
+ log.info(f"Processing {total} pages...")
278
+ log.info(f"{'='*60}\n")
279
+
280
+ for i, row in enumerate(rows):
281
+ vol = row["volume_number"]
282
+ page = row["page_number"]
283
+ text = row["content_text"] or ""
284
+
285
+ # Rate limit
286
+ if i > 0:
287
+ elapsed = time.time() - start
288
+ expected = i * RATE_INTERVAL
289
+ wait = max(0, expected - elapsed)
290
+ if wait > 0:
291
+ await asyncio.sleep(wait)
292
+
293
+ ok = await expand_page(client, vol, page, text, conn)
294
+
295
+ if ok:
296
+ success += 1
297
+ else:
298
+ fail += 1
299
+
300
+ # Progress every 50 pages
301
+ if (i + 1) % 50 == 0:
302
+ elapsed = time.time() - start
303
+ rate = (i + 1) / elapsed * 60
304
+ remain = total - i - 1
305
+ eta = remain / max(rate, 0.1) * 60
306
+ log.info(f"📊 [{i+1}/{total}] {rate:.0f} pg/min | ETA: {eta/60:.1f}h")
307
+
308
+ # ── Summary ──
309
+ elapsed = time.time() - start
310
+ log.info(f"\n{'='*60}")
311
+ log.info(f"✅ Done!")
312
+ log.info(f" Success: {success} Failed: {fail}")
313
+ log.info(f" Time: {elapsed:.0f}s ({elapsed/max(success,1):.1f}s/page)")
314
+ log.info(f" Log: {LOG_PATH}")
315
+
316
+ conn.close()
317
+
318
+
319
+ if __name__ == "__main__":
320
+ asyncio.run(main())
webapp/tipitaka-api/app/routers/reference.py CHANGED
@@ -3,7 +3,6 @@ from pydantic import BaseModel
3
  from typing import Optional
4
 
5
  from app.services.page_service import PageService
6
- from app.services.llm_service import LLMService
7
  from app.database.sqlite_db import get_db, SQLiteDB
8
 
9
  router = APIRouter(prefix="/reference", tags=["Reference"])
@@ -18,9 +17,6 @@ class ReferenceLookupRequest(BaseModel):
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(
26
  vol: int,
@@ -28,7 +24,6 @@ async def lookup_reference(
28
  type: str,
29
  id: str,
30
  service: PageService = Depends(get_page_service),
31
- llm: LLMService = Depends(get_llm_service)
32
  ):
33
  if type == "footnote":
34
  content = service.get_footnote(vol, page, id)
@@ -37,6 +32,10 @@ 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 ""
 
3
  from typing import Optional
4
 
5
  from app.services.page_service import PageService
 
6
  from app.database.sqlite_db import get_db, SQLiteDB
7
 
8
  router = APIRouter(prefix="/reference", tags=["Reference"])
 
17
  def get_page_service(db: SQLiteDB = Depends(get_db)) -> PageService:
18
  return PageService(db)
19
 
 
 
 
20
  @router.get("/lookup")
21
  async def lookup_reference(
22
  vol: int,
 
24
  type: str,
25
  id: str,
26
  service: PageService = Depends(get_page_service),
 
27
  ):
28
  if type == "footnote":
29
  content = service.get_footnote(vol, page, id)
 
32
  return {"content": content, "found": True}
33
 
34
  elif type == "abbrev":
35
+ # Lazily initialize LLM service — only needed for abbreviation expansion
36
+ from app.services.llm_service import LLMService
37
+ from app.database.sqlite_db import get_db
38
+ llm = LLMService(get_db())
39
  # Fetch page text for LLM context if DB index fails
40
  page_data = service.get_page(vol, page)
41
  context = page_data["content_text"] if page_data else ""
webapp/tipitaka-api/app/schemas.py CHANGED
@@ -7,6 +7,10 @@ class SectionInfo(BaseModel):
7
  level: int
8
 
9
 
 
 
 
 
10
  class PageResponse(BaseModel):
11
  id: int
12
  volume_id: int
@@ -19,6 +23,7 @@ class PageResponse(BaseModel):
19
  title: Optional[str] = None
20
  sections: List[SectionInfo] = Field(default_factory=list)
21
  end_markers: List[str] = Field(default_factory=list)
 
22
  requested_page: Optional[int] = None
23
  is_blank: Optional[bool] = False
24
  skipped_from: Optional[int] = None
 
7
  level: int
8
 
9
 
10
+ class FootnoteInfo(BaseModel):
11
+ id: str
12
+ content: str
13
+
14
  class PageResponse(BaseModel):
15
  id: int
16
  volume_id: int
 
23
  title: Optional[str] = None
24
  sections: List[SectionInfo] = Field(default_factory=list)
25
  end_markers: List[str] = Field(default_factory=list)
26
+ footnotes: List[FootnoteInfo] = Field(default_factory=list)
27
  requested_page: Optional[int] = None
28
  is_blank: Optional[bool] = False
29
  skipped_from: Optional[int] = None
webapp/tipitaka-api/app/services/llm_service.py CHANGED
@@ -1,5 +1,6 @@
1
  import json
2
  from typing import AsyncGenerator, List, Optional
 
3
  from openai import AsyncOpenAI
4
  from app.config import get_settings
5
  from pydantic import BaseModel
@@ -119,6 +120,7 @@ class LLMService:
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:
@@ -126,10 +128,19 @@ class LLMService:
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 ""
@@ -152,6 +163,20 @@ class LLMService:
152
  ],
153
  max_tokens=500
154
  )
155
- return response.choices[0].message.content.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  except Exception as e:
157
  return f"ไม่สามารถขยายความได้ในขณะนี้: {str(e)}"
 
1
  import json
2
  from typing import AsyncGenerator, List, Optional
3
+ import re
4
  from openai import AsyncOpenAI
5
  from app.config import get_settings
6
  from pydantic import BaseModel
 
120
  async def expand_abbreviation(self, vol: int, page: int, abbrev_text: str, context: str = "") -> str:
121
  """Expand abbreviation (ย่อ) using DB index or LLM as fallback."""
122
  clean_id = re.sub(r'[()\[\]-]', '', abbrev_text).strip()
123
+ lookup_id = clean_id if clean_id else "_default"
124
 
125
  # 1. Try DB Index
126
  with self.db.get_connection() as conn:
 
128
  cursor.execute("""
129
  SELECT content FROM reference_markers
130
  WHERE volume_num = ? AND page_num = ? AND marker_id = ? AND type = 'abbrev'
131
+ """, (vol, page, lookup_id))
132
  row = cursor.fetchone()
133
  if row:
134
+ content = row["content"]
135
+ # Check if stored as JSON array (multiple abbrevs per page)
136
+ if content.startswith('['):
137
+ try:
138
+ entries = json.loads(content)
139
+ parts = [e.get("expansion", "") for e in entries if e.get("expansion")]
140
+ return "\n\n".join(parts) if parts else content
141
+ except (json.JSONDecodeError, TypeError):
142
+ pass
143
+ return content
144
 
145
  # 2. LLM Fallback
146
  clean_ctx = self.search_service.clean_text(context) if context else ""
 
163
  ],
164
  max_tokens=500
165
  )
166
+ result = response.choices[0].message.content.strip()
167
+
168
+ # 3. Cache result in DB for future lookups
169
+ try:
170
+ with self.db.get_connection() as conn:
171
+ cursor = conn.cursor()
172
+ cursor.execute("""
173
+ INSERT OR REPLACE INTO reference_markers
174
+ (volume_num, page_num, marker_id, type, content)
175
+ VALUES (?, ?, ?, 'abbrev', ?)
176
+ """, (vol, page, lookup_id, result))
177
+ except Exception:
178
+ pass # Cache failure is non-critical
179
+
180
+ return result
181
  except Exception as e:
182
  return f"ไม่สามารถขยายความได้ในขณะนี้: {str(e)}"
webapp/tipitaka-api/app/services/page_service.py CHANGED
@@ -18,7 +18,7 @@ class PageService:
18
  return True
19
  return len(stripped) < 20
20
 
21
- def format_content_html(self, html: str) -> str:
22
  if not html:
23
  return ""
24
  # Strip line number spans
@@ -29,6 +29,16 @@ class PageService:
29
  html = re.sub(r'^[=_-]{3,}\s*$', '', html, flags=re.MULTILINE)
30
  # Strip <B> containing only separator characters (______, ===, ---)
31
  html = re.sub(r'<B>\s*[=_-]{3,}\s*</B>', '', html, flags=re.IGNORECASE)
 
 
 
 
 
 
 
 
 
 
32
  # Convert <B> to h4 headings; strip <B> containing "จบ" (shown as end markers via sections)
33
  def _replace_b(m: re.Match) -> str:
34
  text = m.group(1).strip()
@@ -45,17 +55,17 @@ class PageService:
45
  if not line.strip():
46
  cleaned_lines.append(line)
47
  continue
48
- # If line starts with a marker, protect it, then strip others
49
- # Pattern for start of line markers
50
- start_marker = re.match(r'^(\[[\u0E50-\u0E59]+\]|\[[\u0E01-\u0E39]+\]|\[\d+\])', line)
51
  if start_marker:
52
- marker = start_marker.group(1)
53
- rest = line[len(marker):]
 
54
  # Strip markers from the rest
55
  rest = re.sub(r'\[[\u0E50-\u0E59]+\]', '', rest)
56
  rest = re.sub(r'\[[\u0E01-\u0E39]+\]', '', rest)
57
  rest = re.sub(r'\[\d+\]', '', rest)
58
- cleaned_lines.append(marker + rest)
59
  else:
60
  line = re.sub(r'\[[\u0E50-\u0E59]+\]', '', line)
61
  line = re.sub(r'\[[\u0E01-\u0E39]+\]', '', line)
@@ -65,6 +75,18 @@ class PageService:
65
 
66
  html = _strip_footnotes(html)
67
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  # Remove inline footnote refs like ๑- (only when preceded by non-space)
69
  html = re.sub(r'(?<=[^\s])[\u0E50-\u0E59]+-', '', html)
70
  # Remove leading item markers: ๑- at line start, [ก] etc
@@ -78,9 +100,12 @@ class PageService:
78
 
79
  # ── Marker Normalization ──
80
  # Ensure markers like (๑), ถาม., ตอบ. start on a new line if they are currently inline
81
- # NOTE: Exclude '(' from the preceding char group — otherwise (๑) gets split into ( + \n๑)
82
- marker_regex = r'(\([\u0E50-\u0E59]+\)|\[[\u0E50-\u0E59]+\]|[\u0E50-\u0E59]+\.|ถาม\.|ตอบ\.|\[\d+\]|\d+\.|\(\d+\)|\d+\))'
83
- html = re.sub(r'([^\s\n\r>(\[])\s*' + marker_regex, r'\1\n\2', html)
 
 
 
84
 
85
  # ── Semantic Paragraph Wrapping ──
86
  # Split by existing block elements (h4) to wrap text between them
@@ -88,14 +113,15 @@ class PageService:
88
  wrapped_parts = []
89
 
90
  # Pattern 1: Section-heading markers that START a line AND have content after them
91
- # e.g. ๑. ผู้มีวิชชา... ถาม. / ถาม : / ตอบ : — must have text following the marker
92
- # Also includes parenthetical Thai numerals: (๑), (๒), (๑๐) etc.
93
- num_pattern = r'^\s*([\u0E50-\u0E59]+[\.\)]|\([\u0E50-\u0E59]+\)|ถาม\s*[\.\:]|ตอบ\s*[\.\:]|\d+[\.\)]|\(\d+\))\s+\S'
 
94
 
95
  # Pattern 2: Standalone footnote/endnote reference markers — MUST be fully bracketed
96
  # e.g. (๑) [๑] (1) — bare digit) is NOT included because it may be a split tail
97
  # of a longer expression like (เรื่องที่ ๓) broken across two raw lines.
98
- footnote_pattern = r'^\s*(\([\u0E50-\u0E59]+\)|\[[\u0E50-\u0E59]+\]|\(\d+\)|\[\d+\])\s*$'
99
 
100
  for part in parts:
101
  if part.startswith('<h4'):
@@ -141,6 +167,7 @@ class PageService:
141
  # semantic paragraph. Grouping them lets text-align:justify work
142
  # (justify only has effect on lines that aren't the last line of a <p>).
143
  para_buf = [] # buffered body lines for the current paragraph
 
144
 
145
  # Option B: length threshold — short merged content = left-align item,
146
  # long merged content = paragraph (justify applies meaningfully).
@@ -151,19 +178,20 @@ class PageService:
151
  abbrev_pattern = r'^\s*([\u0E50-\u0E59]+|\([\u0E50-\u0E59]+\))?\s*\(ย่อ\)\s*$'
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(
168
  r'\s*\(ย่อ\)',
169
  r' <sup class="abbrev-ref">(ย่อ)</sup>',
@@ -175,8 +203,13 @@ class PageService:
175
  """Output buffered lines as a <p>. Short content → left-align."""
176
  if para_buf:
177
  text = apply_inline_refs(' '.join(para_buf))
178
- if len(text) >= PARA_MIN_CHARS:
179
- wrapped_parts.append(f'<p>{text}</p>')
 
 
 
 
 
180
  else:
181
  wrapped_parts.append(f'<p class="structured-line">{text}</p>')
182
  para_buf.clear()
@@ -186,23 +219,33 @@ class PageService:
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
197
  flush_para()
198
  m = re.match(
199
- r'^\s*([\u0E50-\u0E59]+|\([\u0E50-\u0E59]+\))?\s*(\(ย่อ\))\s*$',
200
  line.strip()
201
  )
202
  if m and m.group(1):
 
 
 
 
 
203
  sup_html = (
204
- f'<sup class="footnote-ref">{m.group(1)}</sup>'
205
- f'\u202f<sup class="abbrev-ref">{m.group(2)}</sup>'
206
  )
207
  else:
208
  sup_html = f'<sup class="abbrev-ref">(ย่อ)</sup>'
@@ -211,9 +254,15 @@ class PageService:
211
  else:
212
  wrapped_parts.append(sup_html)
213
 
214
-
 
 
 
 
 
 
 
215
  elif line.startswith('...'):
216
- # Continuation / repetition marker in Patthana — no paragraph indent
217
  flush_para()
218
  formatted_line = apply_inline_refs(line)
219
  wrapped_parts.append(f'<p class="continuation-line">{formatted_line}</p>')
@@ -221,19 +270,35 @@ class PageService:
221
  elif re.match(num_pattern, line):
222
  flush_para()
223
  wrapped_parts.append(f'<p class="numbered-line">{line}</p>')
224
- elif re.search(r'[ \t]{4,}', line):
225
- # Structural line — large internal spaces (tabular data)
226
  flush_para()
227
- clean = re.sub(r'[ \t]{2,}', ' ', line).strip()
228
  wrapped_parts.append(f'<p class="structured-line">{clean}</p>')
 
 
 
 
 
 
 
 
 
 
 
 
229
  elif re.search(r'(มี\s*[๐-๙\d]+\s*วาระ|\(ย่อ\)|\(อย่อ\)|ฯลฯ)\s*$', line):
230
- # Patthana/Abhidhamma terminal — flush trigger, then length decides class
231
  para_buf.append(line)
232
  flush_para()
233
  else:
234
- # Regular body text accumulate into current paragraph
 
 
 
 
235
  para_buf.append(line)
236
 
 
 
237
  flush_para() # flush any remaining lines at end of section
238
 
239
 
@@ -294,32 +359,37 @@ class PageService:
294
  if tm and tm not in end_markers:
295
  end_markers.append(tm)
296
 
297
- # All Footnotes
298
- # Strip line numbers first as they might precede the @ on the same line
299
- fn_html = re.sub(r'<span\s+class="LineNumber">.*?</span>', '', raw_html)
300
- lines = fn_html.split('\n')
301
- current_fn = None
302
- for line in lines:
303
- line_strip = line.strip()
304
- if not line_strip.startswith('@'):
305
- continue
306
-
307
- line_plain = re.sub(r'<[^>]+>', '', line_strip)
308
- # Match @ marker content
309
- # e.g. @๑ เนื้อหา... or @(๑) เนื้อหา...
310
- m = re.match(r'^@\s*([(\[]?[\u0E50-\u0E59\d]+[)\]-]?)\s*(.*)', line_plain)
311
- if m:
312
- marker = m.group(1).strip()
313
- text = m.group(2).strip()
314
- # Clean marker for lookup: [๑] -> ๑
315
- clean_marker = re.sub(r'[()\[\]-]', '', marker).strip()
316
-
317
- if clean_marker and not re.search(r'เชิงอรรถ', marker):
318
- current_fn = {"id": marker, "content": text}
319
- footnotes.append(current_fn)
320
- elif current_fn:
321
- # Continuation line starting with @
322
- current_fn["content"] += " " + line_plain.lstrip('@').strip()
 
 
 
 
 
323
 
324
  return {
325
  "id": row["id"],
@@ -329,7 +399,9 @@ class PageService:
329
  "total_pages": row["total_pages"] or 0,
330
  "content_text": "" if is_blank else cleaned,
331
  "content_html": "" if is_blank else raw_html,
332
- "content_html_formatted": "" if is_blank else self.format_content_html(raw_html),
 
 
333
  "title": row["title"],
334
  "sections": sections,
335
  "end_markers": end_markers,
@@ -396,7 +468,7 @@ class PageService:
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
@@ -412,6 +484,6 @@ class PageService:
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
 
18
  return True
19
  return len(stripped) < 20
20
 
21
+ def format_content_html(self, html: str, footnote_ids: set = None) -> str:
22
  if not html:
23
  return ""
24
  # Strip line number spans
 
29
  html = re.sub(r'^[=_-]{3,}\s*$', '', html, flags=re.MULTILINE)
30
  # Strip <B> containing only separator characters (______, ===, ---)
31
  html = re.sub(r'<B>\s*[=_-]{3,}\s*</B>', '', html, flags=re.IGNORECASE)
32
+ # Merge consecutive <B> elements separated only by whitespace and <I> tags
33
+ # e.g. "<B>อมราวิกเขปวาทะ</B><I>๑-</I><B> ๔</B>\n<B>ความเห็นหลบเลี่ยง</B>"
34
+ # → "<B>อมราวิกเขปวาทะ<I>๑-</I> ๔ ความเห็นหลบเลี่ยง</B>"
35
+ # This prevents footnote refs inside headings from splitting into multiple h4.
36
+ html = re.sub(
37
+ r'</B>\s*((?:<I>[^<]*</I>\s*)+)<B>',
38
+ lambda m: ' ' + m.group(1).strip() + ' ',
39
+ html,
40
+ flags=re.DOTALL | re.IGNORECASE
41
+ )
42
  # Convert <B> to h4 headings; strip <B> containing "จบ" (shown as end markers via sections)
43
  def _replace_b(m: re.Match) -> str:
44
  text = m.group(1).strip()
 
55
  if not line.strip():
56
  cleaned_lines.append(line)
57
  continue
58
+ # If line starts with a marker (possibly with leading whitespace), protect it
59
+ start_marker = re.match(r'^(\s*)(\[[\u0E50-\u0E59]+\]|\[[\u0E01-\u0E39]+\]|\[\d+\])', line)
 
60
  if start_marker:
61
+ ws = start_marker.group(1) # leading whitespace (tabs etc)
62
+ marker = start_marker.group(2) # the bracket marker
63
+ rest = line[len(ws) + len(marker):]
64
  # Strip markers from the rest
65
  rest = re.sub(r'\[[\u0E50-\u0E59]+\]', '', rest)
66
  rest = re.sub(r'\[[\u0E01-\u0E39]+\]', '', rest)
67
  rest = re.sub(r'\[\d+\]', '', rest)
68
+ cleaned_lines.append(ws + marker + rest)
69
  else:
70
  line = re.sub(r'\[[\u0E50-\u0E59]+\]', '', line)
71
  line = re.sub(r'\[[\u0E01-\u0E39]+\]', '', line)
 
75
 
76
  html = _strip_footnotes(html)
77
 
78
+ # ── Convert <I>fn</I> inline footnote refs to sup BEFORE stripping ๑- ──
79
+ # e.g. <I>๑-</I> → <sup>, <I>*</I> → <sup>
80
+ if footnote_ids:
81
+ sorted_ids = sorted(footnote_ids, key=lambda x: -len(x))
82
+ ids_alt = '|'.join(re.escape(fid) for fid in sorted_ids)
83
+ html = re.sub(
84
+ r'<I>\s*(' + ids_alt + r')\s*-?\s*</I>',
85
+ r' <sup class="footnote-ref">\1</sup>',
86
+ html,
87
+ flags=re.IGNORECASE
88
+ )
89
+
90
  # Remove inline footnote refs like ๑- (only when preceded by non-space)
91
  html = re.sub(r'(?<=[^\s])[\u0E50-\u0E59]+-', '', html)
92
  # Remove leading item markers: ๑- at line start, [ก] etc
 
100
 
101
  # ── Marker Normalization ──
102
  # Ensure markers like (๑), ถาม., ตอบ. start on a new line if they are currently inline
103
+ # NOTE: Exclude '(' and '[' from the preceding char group — otherwise (๑) gets split into ( + \n๑) or [x](x) splits
104
+ # NOTE: Exclude ']' from preceding char — otherwise [๓๒]. splits into [๓๒] + \n๒.
105
+ # NOTE: Exclude Thai digits (\u0E50-\u0E59) from preceding char — otherwise ๑๖. splits into ๑ + \n๖.
106
+ # NOTE: \d in Python 3 matches Thai digits (u0E50+), so use [0-9] for ASCII-only digits
107
+ marker_regex = r'(\([\u0E50-\u0E59]+\)|\[[\u0E50-\u0E59]+\]|[\u0E50-\u0E59]+\.|ถาม\.|ตอบ\.|\[[0-9]+\]|[0-9]+\.|\([0-9]+\)|[0-9]+\))'
108
+ html = re.sub(r'([^\s\n\r>(\[\]\u0E50-\u0E59])\s*' + marker_regex, r'\1\n\2', html)
109
 
110
  # ── Semantic Paragraph Wrapping ──
111
  # Split by existing block elements (h4) to wrap text between them
 
113
  wrapped_parts = []
114
 
115
  # Pattern 1: Section-heading markers that START a line AND have content after them
116
+ # e.g. ๑. ผู้มีวิชชา... ๑) ... ถาม. / ถาม : / ตอบ : — must have text following the marker
117
+ # NOTE: (๑) parenthetical forms are deliberately excluded they're part of body text,
118
+ # not section headers (e.g. numbered list items in Vinaya).
119
+ num_pattern = r'^\s*([\u0E50-\u0E59]+[\.\)]|ถาม\s*[\.\:]|ตอบ\s*[\.\:]|\d+[\.\)])\s+\S'
120
 
121
  # Pattern 2: Standalone footnote/endnote reference markers — MUST be fully bracketed
122
  # e.g. (๑) [๑] (1) — bare digit) is NOT included because it may be a split tail
123
  # of a longer expression like (เรื่องที่ ๓) broken across two raw lines.
124
+ footnote_pattern = r'^\s*(\([\u0E50-\u0E59]+\)|\[[\u0E50-\u0E59]+\]|\([0-9]+\)|\[[0-9]+\])\s*$'
125
 
126
  for part in parts:
127
  if part.startswith('<h4'):
 
167
  # semantic paragraph. Grouping them lets text-align:justify work
168
  # (justify only has effect on lines that aren't the last line of a <p>).
169
  para_buf = [] # buffered body lines for the current paragraph
170
+ _in_numbered_seq = False # track when inside (x) list
171
 
172
  # Option B: length threshold — short merged content = left-align item,
173
  # long merged content = paragraph (justify applies meaningfully).
 
178
  abbrev_pattern = r'^\s*([\u0E50-\u0E59]+|\([\u0E50-\u0E59]+\))?\s*\(ย่อ\)\s*$'
179
 
180
  def apply_inline_refs(text: str) -> str:
181
+ """Wrap inline footnote markers as superscript — only for known refs."""
182
+ if footnote_ids:
183
+ # Build pattern from known footnote markers only
184
+ # Sort by length descending so multi-digit (๑๐) matches before single (๑)
185
+ sorted_ids = sorted(footnote_ids, key=lambda x: -len(x))
186
+ ids_alt = '|'.join(re.escape(fid) for fid in sorted_ids)
187
+ # (๑) parenthesis pattern — NOT when followed by (ย่อ)
188
+ # Exclude < and > (e.g. <br> before (๑) is NOT a footnote ref)
189
+ text = re.sub(
190
+ r'(?<=[^\s<(\[\]>])\s*\(\s*(' + ids_alt + r')\s*\)(?!\s*\(ย่อ\))',
191
+ r'<sup class="footnote-ref">(\1)</sup>',
192
+ text
193
+ )
194
+ # (ย่อ) always wraps — triggers LLM expansion
195
  text = re.sub(
196
  r'\s*\(ย่อ\)',
197
  r' <sup class="abbrev-ref">(ย่อ)</sup>',
 
203
  """Output buffered lines as a <p>. Short content → left-align."""
204
  if para_buf:
205
  text = apply_inline_refs(' '.join(para_buf))
206
+ # Detect (x)-style numbered lists — cancel text-indent so all items align left
207
+ first_line = para_buf[0].lstrip()
208
+ is_numbered_list = bool(re.match(r'^\([\u0E50-\u0E59]+\)', first_line))
209
+ is_section_marker = bool(re.match(r'^\[[\u0E50-\u0E59]+\]', first_line))
210
+ if len(text) >= PARA_MIN_CHARS or is_section_marker:
211
+ cls = ' numbered-list' if is_numbered_list else ''
212
+ wrapped_parts.append(f'<p class="{cls.strip()}">{text}</p>' if cls else f'<p>{text}</p>')
213
  else:
214
  wrapped_parts.append(f'<p class="structured-line">{text}</p>')
215
  para_buf.clear()
 
219
  flush_para()
220
  marker = line.strip()
221
  clean = re.sub(r'[()\[\]-]', '', marker).strip()
222
+ if footnote_ids is None or clean in footnote_ids:
223
+ if wrapped_parts and wrapped_parts[-1].startswith('<p') and wrapped_parts[-1].endswith('</p>'):
224
+ wrapped_parts[-1] = wrapped_parts[-1][:-4] + f' <sup id="ref-{clean}" class="footnote-ref">{marker}</sup></p>'
225
+ else:
226
+ wrapped_parts.append(f'<p class="footnote-ref"><sup id="ref-{clean}">{marker}</sup></p>')
227
  else:
228
+ if re.match(r'^\[', marker):
229
+ wrapped_parts.append(f'<p>{marker}</p>')
230
+ elif wrapped_parts and wrapped_parts[-1].startswith('<p') and wrapped_parts[-1].endswith('</p>'):
231
+ wrapped_parts[-1] = wrapped_parts[-1][:-4] + f' {marker}</p>'
232
+ else:
233
+ wrapped_parts.append(f'<p class="structured-line">{marker}</p>')
234
  elif re.match(abbrev_pattern, line):
 
 
235
  flush_para()
236
  m = re.match(
237
+ r'^\s*([-]+|\([-]+\))?\s*(\(ย่อ\))\s*$',
238
  line.strip()
239
  )
240
  if m and m.group(1):
241
+ clean_abbrev_id = re.sub(r'[()\[\]-]', '', m.group(1)).strip()
242
+ if footnote_ids is None or clean_abbrev_id in footnote_ids:
243
+ fn_sup = f'<sup class="footnote-ref">{m.group(1)}</sup>'
244
+ else:
245
+ fn_sup = m.group(1)
246
  sup_html = (
247
+ f'{fn_sup}'
248
+ f'<sup class="abbrev-ref">{m.group(2)}</sup>'
249
  )
250
  else:
251
  sup_html = f'<sup class="abbrev-ref">(ย่อ)</sup>'
 
254
  else:
255
  wrapped_parts.append(sup_html)
256
 
257
+ elif re.match(r'^\s*\[[๐-๙]+\]', line):
258
+ # Section/paragraph marker like [๑๓๔], [๔], [๑]
259
+ # Keep [x] with its content in the same <p> (don't wrap in <span>)
260
+ if para_buf and len(para_buf) == 1 and re.match(r'^\s*<sup', para_buf[0]):
261
+ para_buf[0] = para_buf[0] + ' ' + line
262
+ else:
263
+ flush_para()
264
+ para_buf.append(line)
265
  elif line.startswith('...'):
 
266
  flush_para()
267
  formatted_line = apply_inline_refs(line)
268
  wrapped_parts.append(f'<p class="continuation-line">{formatted_line}</p>')
 
270
  elif re.match(num_pattern, line):
271
  flush_para()
272
  wrapped_parts.append(f'<p class="numbered-line">{line}</p>')
273
+ elif re.search(r'[ ]{4,}', line):
 
274
  flush_para()
275
+ clean = re.sub(r'[ ]{2,}', ' ', line).strip()
276
  wrapped_parts.append(f'<p class="structured-line">{clean}</p>')
277
+ elif re.match(r'^\s*\((?:[๐-๙]+|\d+)\)', line):
278
+ # Check if para already has [x] or (x) as its first item
279
+ if para_buf:
280
+ first = para_buf[0].lstrip()
281
+ is_num_seq = bool(re.match(r'^(?:<sup[^>]*>.*?</sup>\s*)?(?:<span[^>]*>.*?</span>\s*)?(?:\[[๐-๙]+\]\s*)?\((?:[๐-๙]+|\d+)\)', first))
282
+ if not is_num_seq:
283
+ flush_para()
284
+ _in_numbered_seq = True
285
+ if para_buf:
286
+ para_buf.append('<br>')
287
+ para_buf.append(line)
288
+
289
  elif re.search(r'(มี\s*[๐-๙\d]+\s*วาระ|\(ย่อ\)|\(อย่อ\)|ฯลฯ)\s*$', line):
 
290
  para_buf.append(line)
291
  flush_para()
292
  else:
293
+ # If we were in a (x) numbered sequence and this line looks
294
+ # like a closing sentence (starts with ภิกษุ), flush first.
295
+ if _in_numbered_seq and re.match(r'^\s*ภิกษุ', line):
296
+ flush_para()
297
+ _in_numbered_seq = False
298
  para_buf.append(line)
299
 
300
+
301
+
302
  flush_para() # flush any remaining lines at end of section
303
 
304
 
 
359
  if tm and tm not in end_markers:
360
  end_markers.append(tm)
361
 
362
+ # All Footnotes — query from reference_markers table
363
+ # (pre-indexed, much faster than scanning raw HTML)
364
+ try:
365
+ cursor.execute("""
366
+ SELECT marker_id, content FROM reference_markers
367
+ WHERE volume_num = ? AND page_num = ? AND type = 'footnote'
368
+ ORDER BY id
369
+ """, (volume_number, page_number))
370
+ for ref_row in cursor.fetchall():
371
+ content = ref_row["content"].lstrip(". ")
372
+ footnotes.append({"id": ref_row["marker_id"], "content": content})
373
+ except Exception:
374
+ # Fallback: scan raw HTML if table query fails
375
+ fn_html = re.sub(r'<span\s+class="LineNumber">.*?</span>', '', raw_html)
376
+ lines = fn_html.split('\n')
377
+ current_fn = None
378
+ for line in lines:
379
+ line_strip = line.strip()
380
+ if not line_strip.startswith('@'):
381
+ continue
382
+ line_plain = re.sub(r'<[^>]+>', '', line_strip)
383
+ m = re.match(r'^@\s*([(\[]?[\u0E50-\u0E59\d]+[)\]\-]?)\s*(.*)', line_plain)
384
+ if m:
385
+ marker = m.group(1).strip()
386
+ text = m.group(2).strip()
387
+ clean_marker = re.sub(r'[()\[\]-]', '', marker).strip()
388
+ if clean_marker and not re.search(r'เชิงอรรถ', marker):
389
+ current_fn = {"id": marker, "content": text}
390
+ footnotes.append(current_fn)
391
+ elif current_fn:
392
+ current_fn["content"] += " " + line_plain.lstrip('@').strip()
393
 
394
  return {
395
  "id": row["id"],
 
399
  "total_pages": row["total_pages"] or 0,
400
  "content_text": "" if is_blank else cleaned,
401
  "content_html": "" if is_blank else raw_html,
402
+ "content_html_formatted": "" if is_blank else self.format_content_html(
403
+ raw_html, footnote_ids={f["id"] for f in footnotes}
404
+ ),
405
  "title": row["title"],
406
  "sections": sections,
407
  "end_markers": end_markers,
 
468
  """, (volume_number, page_number, clean_id))
469
  row = cursor.fetchone()
470
  if row:
471
+ return row["content"].lstrip(". ")
472
 
473
  # 2. Try window search (+/- 2 pages) to handle markers defined nearby
474
  # This is common in MCU where a footnote ref on page X might be defined at the start of page X+1
 
484
  """, (volume_number, page_number - 2, page_number + 2, clean_id, page_number))
485
  row = cursor.fetchone()
486
  if row:
487
+ return row["content"].lstrip(". ")
488
+
489
  return None
webapp/tipitaka-api/test_all_volumes.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fast volume check - fetch page data only, no popup API calls (huge savings)."""
2
+ import json, re, urllib.request, sys
3
+
4
+ API = "http://localhost:8000/api/pages"
5
+
6
+ def get_json(url, timeout=8):
7
+ try:
8
+ resp = urllib.request.urlopen(url, timeout=timeout)
9
+ return json.loads(resp.read())
10
+ except Exception as e:
11
+ return {"_error": str(e)}
12
+
13
+ def lookup(vol, page, fid):
14
+ """Quick popup test."""
15
+ import urllib.parse
16
+ url = f"http://localhost:8000/api/reference/lookup?vol={vol}&page={page}&type=footnote&id={urllib.parse.quote(fid)}"
17
+ try:
18
+ resp = urllib.request.urlopen(url, timeout=5)
19
+ d = json.loads(resp.read())
20
+ return "OK" if d.get("found") else "NF"
21
+ except:
22
+ return "ERR"
23
+
24
+ print(f"{'Vol':>3} {'Pg':>3} {'FN':>3} {'Sup':>3} {'First FN':<50} {'Status'}")
25
+ print("=" * 80)
26
+
27
+ all_data = {} # vol -> (page, fn_count, sup_count, first_fn, all_fns)
28
+ errors = []
29
+
30
+ for vol in range(1, 46):
31
+ d = None
32
+ used = None
33
+ for pn in range(1, 4):
34
+ d = get_json(f"{API}/{vol}/{pn}")
35
+ if "_error" in d:
36
+ print(f"{vol:>3} --- --- --- ERROR: {d['_error']:<50} ❌")
37
+ errors.append(vol)
38
+ d = None
39
+ break
40
+ fns = d.get("footnotes", [])
41
+ blank = d.get("is_blank", False)
42
+ if fns or not blank or pn == 3:
43
+ used = pn
44
+ break
45
+ # blank page with no footnotes -> try next
46
+ if d is None or d.get("_error"):
47
+ continue
48
+
49
+ fns = d.get("footnotes", [])
50
+ html = d.get("content_html_formatted", "")
51
+ sups = re.findall(r'<sup[^>]*>.*?</sup>', html)
52
+ fn_count = len(fns)
53
+ sup_count = len(sups)
54
+ first_fn = (fns[0]["content"][:50].replace("\n", " ")) if fns else "(none)"
55
+
56
+ # Check for anomalies
57
+ issues = []
58
+ if fn_count > 0 and sup_count == 0:
59
+ issues.append("NOSUP")
60
+ if fn_count > 0 and sup_count > 0 and sup_count < fn_count:
61
+ # Some markers might be different type - note for detail
62
+ issues.append(f"Sup<FN")
63
+
64
+ if issues:
65
+ iss = " | ".join(issues)
66
+ print(f"{vol:>3} {used:>3} {fn_count:>3} {sup_count:>3} {first_fn:<50} ⚠️ {iss}")
67
+ all_data[vol] = (used, fn_count, sup_count, fns, html)
68
+ else:
69
+ print(f"{vol:>3} {used:>3} {fn_count:>3} {sup_count:>3} {first_fn:<50} ✅")
70
+ all_data[vol] = (used, fn_count, sup_count, fns, html)
71
+
72
+ print("=" * 80)
73
+ good = 45 - len(errors) - len([v for v in all_data if v in all_data and v not in errors])
74
+
75
+ issues_list = [v for v in all_data if v not in errors]
76
+ issue_vols = [v for v in issues_list if all_data[v][1] > 0 and all_data[v][2] == 0] # FN>0 but Sup=0
77
+
78
+ print(f"\nChecked: 45 volumes")
79
+ print(f"Full OK: {45 - len(errors) - len(issues_list)}")
80
+ print(f"Has footnotes: {sum(1 for v in all_data if v not in errors and all_data[v][1] > 0)}")
81
+ print(f"Has NO footnotes: {sum(1 for v in all_data if v not in errors and all_data[v][1] == 0)}")
82
+
83
+ if issue_vols:
84
+ print(f"\n🔴 Volumes with FN>0 but Sup=0 (NO sup tags!): {len(issue_vols)}")
85
+ for v in issue_vols:
86
+ pg, fnc, spc, fns, html = all_data[v]
87
+ print(f" Vol {v} Page {pg}: {fnc} footnotes, {spc} sup tags")
88
+ for fn in fns:
89
+ print(f" [{fn['id']}] {fn['content'][:80]}")
90
+ # Test popup
91
+ for fn in fns[:1]:
92
+ r = lookup(v, pg, fn["id"])
93
+ print(f" popup test: {r}")
94
+
95
+ # Check volumes with Sup<FN
96
+ partial = [v for v in all_data if v not in errors and all_data[v][1] > 0 and all_data[v][2] > 0 and all_data[v][2] < all_data[v][1]]
97
+ if partial:
98
+ print(f"\n🟡 Volumes with Sup<FN (some markers not converted to sup):")
99
+ for v in partial:
100
+ pg, fnc, spc, fns, html = all_data[v]
101
+ print(f" Vol {v} Page {pg}: {fnc} FN, {spc} Sup")
webapp/tipitaka-web/src/components/common/ErrorBoundary.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { Component, ErrorInfo, ReactNode } from 'react';
2
 
3
  interface Props {
4
  children: ReactNode;
 
1
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
2
 
3
  interface Props {
4
  children: ReactNode;
webapp/tipitaka-web/src/components/layout/ReaderPanel.tsx CHANGED
@@ -241,7 +241,7 @@ const ReaderPanel: React.FC = () => {
241
  <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
242
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 4v12l-4-2-4 2V4M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
243
  </svg>
244
- เชิงอรรถ (Footnotes)
245
  </h5>
246
  <div className="space-y-4">
247
  {content.footnotes.map((fn: any, idx: number) => {
 
241
  <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
242
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 4v12l-4-2-4 2V4M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
243
  </svg>
244
+ เชิงอรรถ
245
  </h5>
246
  <div className="space-y-4">
247
  {content.footnotes.map((fn: any, idx: number) => {
webapp/tipitaka-web/src/components/reader/ReferencePopup.tsx CHANGED
@@ -16,18 +16,47 @@ interface Props {
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);
23
  const [isLoading, setIsLoading] = React.useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
 
 
 
 
 
25
  useEffect(() => {
26
  if (!pos) {
 
27
  setContent(null);
28
  return;
29
  }
30
 
 
 
 
 
31
  const fetchRef = async () => {
32
  setIsLoading(true);
33
  try {
@@ -43,23 +72,101 @@ const ReferencePopup: React.FC<Props> = ({ pos, onClose, onJump }) => {
43
  setIsLoading(false);
44
  }
45
  };
46
-
47
  fetchRef();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  }, [pos]);
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  useEffect(() => {
51
- // Click outside popup → close
52
  const onMouseDown = (e: MouseEvent | TouchEvent) => {
53
- // Don't close if they clicked on another reference (it will update pos)
54
  const target = e.target as HTMLElement;
55
- if (target.tagName?.toLowerCase() === 'sup' && (target.classList.contains('abbrev-ref') || target.classList.contains('footnote-ref'))) {
56
- return;
57
- }
58
- if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
59
- onClose();
60
- }
61
  };
62
-
63
  document.addEventListener('mousedown', onMouseDown);
64
  document.addEventListener('touchstart', onMouseDown);
65
  return () => {
@@ -74,43 +181,75 @@ const ReferencePopup: React.FC<Props> = ({ pos, onClose, onJump }) => {
74
  <motion.div
75
  ref={popupRef}
76
  key="ref-popup"
77
- initial={{ opacity: 0, scale: 0.95, y: 8 }}
78
- animate={{ opacity: 1, scale: 1, y: 0 }}
79
- exit={{ opacity: 0, scale: 0.95, y: 8 }}
80
- transition={{ duration: 0.15, ease: 'easeOut' }}
 
 
 
81
  style={{
82
  position: 'fixed',
83
- left: pos.x,
84
- top: pos.y,
85
- transform: 'translate(-50%, 8px)',
86
  zIndex: 9998,
 
 
 
87
  }}
88
- className="w-80 rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-[#1a1a2e]/90 backdrop-blur-xl border border-white/10 text-[#e8e4da] overflow-hidden"
89
  >
90
  {/* Gradient top bar */}
91
- <div className="h-1 bg-gradient-to-r from-[#c8860a]/0 via-[#c8860a] to-[#c8860a]/0" />
92
-
93
- <div className="p-4">
94
- <div className="flex items-center justify-between mb-3 pb-2 border-b border-white/5">
95
- <div className="flex items-center gap-2">
96
- <span className="bg-[#c8860a] text-[#1a1a2e] text-xs font-bold px-1.5 py-0.5 rounded">
 
 
 
 
 
 
 
 
 
 
97
  {pos.id}
98
  </span>
99
- <span className="text-xs font-medium text-white/50 tracking-wide uppercase">
100
- {pos.type === 'abbrev' ? 'Abbreviation' : 'Footnote'}
101
  </span>
102
  </div>
103
- <button
104
- onClick={onClose}
105
- className="text-white/30 hover:text-white/70 transition-colors"
106
- >
107
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
108
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
109
- </svg>
110
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  </div>
112
-
113
- <div className="max-h-60 overflow-y-auto custom-scrollbar pr-1">
 
114
  {isLoading ? (
115
  <div className="space-y-3 py-1">
116
  <div className="h-3 bg-white/10 rounded-full w-full animate-pulse" />
@@ -118,27 +257,25 @@ const ReferencePopup: React.FC<Props> = ({ pos, onClose, onJump }) => {
118
  <div className="h-3 bg-white/10 rounded-full w-4/6 animate-pulse" />
119
  </div>
120
  ) : (
121
- <div className="text-[15px] leading-relaxed font-light text-white/90">
 
122
  {content || (
123
  <span className="text-white/40 italic">ไม่พบข้อมูลอ้างอิง</span>
124
  )}
125
  </div>
126
  )}
127
  </div>
128
-
129
  {pos.type === 'abbrev' && !isLoading && (
130
- <div className="mt-3 pt-2 border-t border-white/5 flex items-center gap-1.5">
131
  <div className="w-1.5 h-1.5 rounded-full bg-[#c8860a] animate-pulse" />
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>
@@ -148,11 +285,27 @@ const ReferencePopup: React.FC<Props> = ({ pos, onClose, onJump }) => {
148
  )}
149
  </div>
150
 
151
- {/* Tail arrow */}
152
- <div
153
- className="pointer-events-none absolute left-1/2 -translate-x-1/2 -top-[5px]
154
- w-2.5 h-2.5 bg-[#1a1a2e] border-l border-t border-white/10 rotate-45"
155
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  </motion.div>
157
  )}
158
  </AnimatePresence>
 
16
  onJump?: (id: string) => void;
17
  }
18
 
19
+ const MIN_W = 240;
20
+ const MAX_W = 600;
21
+ const INIT_W = 320;
22
+
23
  const ReferencePopup: React.FC<Props> = ({ pos, onClose, onJump }) => {
24
  const popupRef = useRef<HTMLDivElement>(null);
25
 
26
  const [content, setContent] = React.useState<string | null>(null);
27
  const [isLoading, setIsLoading] = React.useState(false);
28
+ const [popupFontSize, setPopupFontSize] = React.useState(15);
29
+ const [popupWidth, setPopupWidth] = React.useState(INIT_W);
30
+ const [popupHeight, setPopupHeight] = React.useState<number | null>(null);
31
+ const [visible, setVisible] = React.useState(false);
32
+
33
+ // ── Position state (top-left of popup) ──
34
+ const [posX, setPosX] = React.useState(0);
35
+ const [posY, setPosY] = React.useState(0);
36
+ const [showArrow, setShowArrow] = React.useState(false);
37
+ const [arrowShift, setArrowShift] = React.useState(0);
38
+ const [flipUp, setFlipUp] = React.useState(false);
39
+
40
+ // ── Drag state ──
41
+ const dragging = useRef(false);
42
+ const dragStart = useRef({ x: 0, y: 0, px: 0, py: 0 });
43
 
44
+ // ── Resize state ──
45
+ const resizing = useRef(false);
46
+ const resizeStart = useRef({ x: 0, y: 0, w: INIT_W, h: 0 });
47
+
48
+ // ── Initial positioning when pos changes ──
49
  useEffect(() => {
50
  if (!pos) {
51
+ setVisible(false);
52
  setContent(null);
53
  return;
54
  }
55
 
56
+ setPopupWidth(INIT_W);
57
+ setPopupHeight(null);
58
+
59
+ // Fetch content
60
  const fetchRef = async () => {
61
  setIsLoading(true);
62
  try {
 
72
  setIsLoading(false);
73
  }
74
  };
 
75
  fetchRef();
76
+
77
+ // Calculate initial position (top-left of popup, not centered)
78
+ requestAnimationFrame(() => {
79
+ const viewW = window.innerWidth;
80
+ const viewH = window.innerHeight;
81
+ const halfW = INIT_W / 2;
82
+ const EDGE = 12;
83
+
84
+ // Start: sup center → convert to top-left
85
+ let left = pos.x - halfW;
86
+ let top = pos.y + 6; // 6px below sup
87
+ let flip = false;
88
+
89
+ // Horizontal clamp
90
+ if (left < EDGE) left = EDGE;
91
+ else if (left + INIT_W > viewW - EDGE) left = viewW - INIT_W - EDGE;
92
+
93
+ // Arrow horizontal shift
94
+ const centerX = left + INIT_W / 2;
95
+ let aShift = pos.x - centerX;
96
+
97
+ // Vertical flip if near bottom
98
+ const estH = 260;
99
+ if (pos.y + estH + 20 > viewH) {
100
+ flip = true;
101
+ top = pos.y - 6 - estH;
102
+ if (top < EDGE) top = EDGE;
103
+ }
104
+
105
+ setPosX(left);
106
+ setPosY(top);
107
+ setArrowShift(aShift);
108
+ setFlipUp(flip);
109
+ setShowArrow(true);
110
+ setVisible(true);
111
+
112
+ // Fine-tune after render
113
+ setTimeout(() => {
114
+ if (popupRef.current) {
115
+ const actualH = popupRef.current.offsetHeight;
116
+ if (flip && pos.y - actualH - 6 < EDGE) {
117
+ setPosY(EDGE);
118
+ setFlipUp(false);
119
+ } else if (!flip && pos.y + actualH + 20 > viewH) {
120
+ setPosY(Math.max(EDGE, viewH - actualH - 10));
121
+ setFlipUp(true);
122
+ }
123
+ }
124
+ }, 60);
125
+ });
126
  }, [pos]);
127
 
128
+ // ── Drag handler ──
129
+ const onDragStart = React.useCallback((e: React.MouseEvent) => {
130
+ e.preventDefault();
131
+ dragging.current = true;
132
+ dragStart.current = { x: e.clientX, y: e.clientY, px: posX, py: posY };
133
+ setShowArrow(false);
134
+ const onMove = (ev: MouseEvent) => {
135
+ if (!dragging.current) return;
136
+ setPosX(dragStart.current.px + (ev.clientX - dragStart.current.x));
137
+ setPosY(dragStart.current.py + (ev.clientY - dragStart.current.y));
138
+ };
139
+ const onUp = () => { dragging.current = false; };
140
+ document.addEventListener('mousemove', onMove);
141
+ document.addEventListener('mouseup', onUp, { once: true });
142
+ }, [posX, posY]);
143
+
144
+ // ── Resize handler ──
145
+ const onResizeStart = React.useCallback((e: React.MouseEvent) => {
146
+ e.preventDefault();
147
+ e.stopPropagation();
148
+ resizing.current = true;
149
+ resizeStart.current = { x: e.clientX, y: e.clientY, w: popupWidth, h: popupHeight || 300 };
150
+ const onMove = (ev: MouseEvent) => {
151
+ if (!resizing.current) return;
152
+ const newW = Math.max(MIN_W, Math.min(MAX_W, resizeStart.current.w + (ev.clientX - resizeStart.current.x)));
153
+ const newH = Math.max(150, resizeStart.current.h + (ev.clientY - resizeStart.current.y));
154
+ setPopupWidth(newW);
155
+ setPopupHeight(newH);
156
+ };
157
+ const onUp = () => { resizing.current = false; };
158
+ document.addEventListener('mousemove', onMove);
159
+ document.addEventListener('mouseup', onUp, { once: true });
160
+ }, [popupWidth, popupHeight]);
161
+
162
+ // ── Click outside ──
163
  useEffect(() => {
 
164
  const onMouseDown = (e: MouseEvent | TouchEvent) => {
 
165
  const target = e.target as HTMLElement;
166
+ if (target.tagName?.toLowerCase() === 'sup' &&
167
+ (target.classList.contains('abbrev-ref') || target.classList.contains('footnote-ref'))) return;
168
+ if (popupRef.current && !popupRef.current.contains(e.target as Node)) onClose();
 
 
 
169
  };
 
170
  document.addEventListener('mousedown', onMouseDown);
171
  document.addEventListener('touchstart', onMouseDown);
172
  return () => {
 
181
  <motion.div
182
  ref={popupRef}
183
  key="ref-popup"
184
+ initial={{ opacity: 0, scale: 0.95 }}
185
+ animate={{
186
+ opacity: visible ? 1 : 0,
187
+ scale: visible ? 1 : 0.95,
188
+ }}
189
+ exit={{ opacity: 0, scale: 0.95 }}
190
+ transition={{ duration: 0.12, ease: 'easeOut' }}
191
  style={{
192
  position: 'fixed',
193
+ left: posX,
194
+ top: posY,
 
195
  zIndex: 9998,
196
+ width: popupWidth,
197
+ height: popupHeight || 'auto',
198
+ maxHeight: popupHeight ? 'none' : (flipUp ? '50vh' : 'calc(100vh - 120px)'),
199
  }}
200
+ className="rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-[#1a1a2e]/95 backdrop-blur-xl border border-white/10 text-[#e8e4da] overflow-hidden flex flex-col"
201
  >
202
  {/* Gradient top bar */}
203
+ <div className="h-1 bg-gradient-to-r from-[#c8860a]/0 via-[#c8860a] to-[#c8860a]/0 shrink-0" />
204
+
205
+ <div className="p-4 flex flex-col flex-1 min-h-0">
206
+ {/* Header drag handle */}
207
+ <div
208
+ onMouseDown={onDragStart}
209
+ className="flex items-center justify-between mb-3 pb-2 border-b border-white/5 shrink-0 cursor-grab active:cursor-grabbing select-none"
210
+ >
211
+ <div className="flex items-center gap-2 min-w-0">
212
+ {/* Drag indicator */}
213
+ <svg className="w-3.5 h-3.5 text-white/15 shrink-0" viewBox="0 0 24 24" fill="currentColor">
214
+ <circle cx="9" cy="5" r="1.5"/><circle cx="15" cy="5" r="1.5"/>
215
+ <circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
216
+ <circle cx="9" cy="19" r="1.5"/><circle cx="15" cy="19" r="1.5"/>
217
+ </svg>
218
+ <span className="bg-[#c8860a] text-[#1a1a2e] text-xs font-bold px-1.5 py-0.5 rounded shrink-0">
219
  {pos.id}
220
  </span>
221
+ <span className="text-xs font-medium text-white/50 tracking-wide uppercase truncate">
222
+ {pos.type === 'abbrev' ? 'คำย่อ' : 'เชิงอรรถ'}
223
  </span>
224
  </div>
225
+ <div className="flex items-center gap-1 shrink-0">
226
+ <button onClick={() => setPopupFontSize(s => Math.max(s - 2, 11))}
227
+ className="text-white/30 hover:text-white/70 transition-colors p-0.5" title="ย่อ">
228
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
229
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 10h-4m-4 0H6" />
230
+ </svg>
231
+ </button>
232
+ <button onClick={() => setPopupFontSize(15)}
233
+ className="text-[9px] text-white/20 hover:text-white/50 transition-colors font-mono w-3.5 text-center" title="รีเซ็ต">
234
+ A
235
+ </button>
236
+ <button onClick={() => setPopupFontSize(s => Math.min(s + 2, 28))}
237
+ className="text-white/30 hover:text-white/70 transition-colors p-0.5" title="ขยาย">
238
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
239
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
240
+ </svg>
241
+ </button>
242
+ <button onClick={onClose}
243
+ className="text-white/30 hover:text-white/70 transition-colors ml-1">
244
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
245
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
246
+ </svg>
247
+ </button>
248
+ </div>
249
  </div>
250
+
251
+ {/* Content */}
252
+ <div className="overflow-y-auto custom-scrollbar pr-1 flex-1 min-h-0">
253
  {isLoading ? (
254
  <div className="space-y-3 py-1">
255
  <div className="h-3 bg-white/10 rounded-full w-full animate-pulse" />
 
257
  <div className="h-3 bg-white/10 rounded-full w-4/6 animate-pulse" />
258
  </div>
259
  ) : (
260
+ <div className="leading-relaxed font-light text-white/90"
261
+ style={{ fontSize: `${popupFontSize}px` }}>
262
  {content || (
263
  <span className="text-white/40 italic">ไม่พบข้อมูลอ้างอิง</span>
264
  )}
265
  </div>
266
  )}
267
  </div>
268
+
269
  {pos.type === 'abbrev' && !isLoading && (
270
+ <div className="mt-3 pt-2 border-t border-white/5 flex items-center gap-1.5 shrink-0">
271
  <div className="w-1.5 h-1.5 rounded-full bg-[#c8860a] animate-pulse" />
272
+ <span className="text-[10px] text-white/30 uppercase tracking-tighter">AI ขยายความ</span>
273
  </div>
274
  )}
 
275
  {pos.type === 'footnote' && !isLoading && onJump && (
276
+ <div className="mt-4 pt-3 border-t border-white/5 shrink-0">
277
+ <button onClick={() => onJump(pos.id)}
278
+ 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">
 
 
279
  <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">
280
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
281
  </svg>
 
285
  )}
286
  </div>
287
 
288
+ {/* Resize handle */}
289
+ <div onMouseDown={onResizeStart}
290
+ className="absolute bottom-0 right-0 w-5 h-5 cursor-se-resize group">
291
+ <svg className="w-full h-full text-white/20 group-hover:text-[#c8860a] transition-colors"
292
+ viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
293
+ strokeLinecap="round" strokeLinejoin="round">
294
+ <path d="M21 15v4a2 2 0 01-2 2h-4" />
295
+ <path d="M21 3v4a2 2 0 01-2 2h-4" />
296
+ </svg>
297
+ </div>
298
+
299
+ {/* Tail arrow — hidden after manual drag */}
300
+ {showArrow && (
301
+ flipUp ? (
302
+ <div className="pointer-events-none absolute left-1/2 -bottom-[5px] w-2.5 h-2.5 bg-[#1a1a2e] border-r border-b border-white/10 -rotate-45"
303
+ style={{ marginLeft: arrowShift }} />
304
+ ) : (
305
+ <div className="pointer-events-none absolute left-1/2 -top-[5px] w-2.5 h-2.5 bg-[#1a1a2e] border-l border-t border-white/10 rotate-45"
306
+ style={{ marginLeft: arrowShift }} />
307
+ )
308
+ )}
309
  </motion.div>
310
  )}
311
  </AnimatePresence>
webapp/tipitaka-web/src/index.css CHANGED
@@ -73,6 +73,12 @@ body {
73
  margin-bottom: 0.1rem !important;
74
  }
75
 
 
 
 
 
 
 
76
 
77
 
78
  /* Continuation / repetition lines starting with "..." — no paragraph indent */
 
73
  margin-bottom: 0.1rem !important;
74
  }
75
 
76
+ /* Numbered-list paragraphs (๑)/(๒)/(๓) — cancel text-indent so all items align left */
77
+ .reader-content p.numbered-list,
78
+ .content-body p.numbered-list {
79
+ text-indent: 0 !important;
80
+ }
81
+
82
 
83
 
84
  /* Continuation / repetition lines starting with "..." — no paragraph indent */