tipitaka / scripts /preindex_abbrevs.py
dhammawatthumpra's picture
refactor: disable LLM fallback for (ฒ่อ), fix JSON string array parsing
efc32dd
Raw
History Blame Contribute Delete
13.6 kB
"""
Pre-index all (ฒ่อ) abbreviation markers.
Supports:
- NVIDIA NIM (free DeepSeek, 40 RPM) β†’ --provider nim
- DeepSeek API directly β†’ --provider deepseek
Usage:
# Full run (default: NVIDIA NIM)
python scripts/preindex_abbrevs.py
# Resume with DeepSeek API
python scripts/preindex_abbrevs.py --resume --provider deepseek
# Test: first 10 pages
python scripts/preindex_abbrevs.py --limit 10
Scans all pages in tipitaka_mcu.db for (ฒ่อ) markers.
For each page, calls LLM to expand ALL abbreviations in one request.
Stores JSON array in reference_markers (type='abbrev', marker_id='_default').
Supports resume β€” skips pages already in DB.
Log file: scripts/preindex_abbrevs.log
"""
import asyncio
import json
import logging
import re
import time
import argparse
import sqlite3
from pathlib import Path
from openai import AsyncOpenAI
from dotenv import load_dotenv
import os
# ── Paths ──
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent
DB_PATH = PROJECT_ROOT / "tipitaka_mcu.db"
LOG_PATH = SCRIPT_DIR / "preindex_abbrevs.log"
ENV_PATH = PROJECT_ROOT.parent / "tipitaka_context" / "nvidia" / ".env"
# ══════════════════════════════════════════════════════════════
# Config (loaded from .env)
# ══════════════════════════════════════════════════════════════
load_dotenv(ENV_PATH)
# NVIDIA NIM
NIM_API_KEY = os.getenv("TIPITAKA_API_KEY") or os.getenv("NVIDIA_API_KEY") or ""
NIM_BASE_URL = os.getenv("TIPITAKA_BASE_URL", "https://integrate.api.nvidia.com/v1")
NIM_MODEL = os.getenv("TIPITAKA_MODEL", "deepseek-ai/deepseek-v4-flash")
# DeepSeek API
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") or os.getenv("DEEPSEEK_API_KEY") or ""
DEEPSEEK_BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
RATE_INTERVAL = float(os.getenv("TIPITAKA_RATE_INTERVAL", "1.6")) # 40 RPM
MAX_RETRIES = 4
RETRY_DELAYS = [10, 20, 40, 80]
# ── Logging ──
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(LOG_PATH, encoding="utf-8"),
],
)
log = logging.getLogger(__name__)
# ══════════════════════════════════════════════════════════════
# Prompts
# ══════════════════════════════════════════════════════════════
SYSTEM_PROMPT = (
"ΰΈ„ΰΈΈΰΈ“ΰΈ„ΰΈ·ΰΈ­ΰΈœΰΈΉΰΉ‰ΰΉ€ΰΈŠΰΈ΅ΰΉˆΰΈ’ΰΈ§ΰΈŠΰΈ²ΰΈΰΈžΰΈ£ΰΈ°ΰΉ„ΰΈ•ΰΈ£ΰΈ›ΰΈ΄ΰΈŽΰΈ ฑจร.\n"
"ΰΈ•ΰΈ­ΰΈšΰΉ€ΰΈ›ΰΉ‡ΰΈ™ JSON array ΰΉ€ΰΈ—ΰΉˆΰΈ²ΰΈ™ΰΈ±ΰΉ‰ΰΈ™ ΰΉ‚ΰΈ”ΰΈ’ΰΉΰΈ•ΰΉˆΰΈ₯ะราฒการเป็น string"
)
USER_PROMPT_TEMPLATE = """ΰΈ‚ΰΈ’ΰΈ²ΰΈ’ΰΈ„ΰΈ§ΰΈ²ΰΈ‘ (ฒ่อ) ΰΉΰΈ•ΰΉˆΰΈ₯ΰΈ°ΰΉΰΈ«ΰΉˆΰΈ‡ΰΉƒΰΈ™ΰΉ€ΰΈ™ΰΈ·ΰΉ‰ΰΈ­ΰΈ«ΰΈ²ΰΈ”ΰΉ‰ΰΈ²ΰΈ™ΰΈ₯ΰΉˆΰΈ²ΰΈ‡
ΰΉƒΰΈ«ΰΉ‰ΰΈ•ΰΈ­ΰΈšΰΉ€ΰΈ›ΰΉ‡ΰΈ™ JSON array:
[
"ΰΈ‚ΰΉ‰ΰΈ­ΰΈ„ΰΈ§ΰΈ²ΰΈ‘ΰΈ—ΰΈ΅ΰΉˆΰΈ–ΰΈΉΰΈΰΈ’ΰΉˆΰΈ­ΰΉΰΈ«ΰΉˆΰΈ‡ΰΈ—ΰΈ΅ΰΉˆ 1...",
"ΰΈ‚ΰΉ‰ΰΈ­ΰΈ„ΰΈ§ΰΈ²ΰΈ‘ΰΈ—ΰΈ΅ΰΉˆΰΈ–ΰΈΉΰΈΰΈ’ΰΉˆΰΈ­ΰΉΰΈ«ΰΉˆΰΈ‡ΰΈ—ΰΈ΅ΰΉˆ 2...",
...
]
ΰΉ‚ΰΈ”ΰΈ’ΰΉ€ΰΈ£ΰΈ΅ΰΈ’ΰΈ‡ΰΈ₯ΰΈ³ΰΈ”ΰΈ±ΰΈšΰΈ•ΰΈ²ΰΈ‘ΰΈ—ΰΈ΅ΰΉˆ (ฒ่อ) ปรากฏในเนื้อหา
ΰΉΰΈ•ΰΉˆΰΈ₯ะราฒการต้องเป็น string ΰΈͺΰΈ±ΰΉ‰ΰΈ™ΰΉ† อธิบาฒΰΈͺΰΈ΄ΰΉˆΰΈ‡ΰΈ—ΰΈ΅ΰΉˆΰΈ–ΰΈΉΰΈΰΈ’ΰΉˆΰΈ­ΰΉ„ΰΈ§ΰΉ‰
ΰΈ•ΰΈ­ΰΈšΰΉ€ΰΈ‰ΰΈžΰΈ²ΰΈ° JSON array ΰΉ€ΰΈ—ΰΉˆΰΈ²ΰΈ™ΰΈ±ΰΉ‰ΰΈ™
ΰΉ€ΰΈ™ΰΈ·ΰΉ‰ΰΈ­ΰΈ«ΰΈ²:
{content}"""
def build_prompt(content_text: str) -> str:
return USER_PROMPT_TEMPLATE.format(content=content_text[:8000])
# ══════════════════════════════════════════════════════════════
# Parsing
# ══════════════════════════════════════════════════════════════
def parse_response(raw: str, expected_count: int) -> list[str]:
"""Parse JSON array from LLM response into list of expansion strings."""
raw = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip()
raw = re.sub(r'^```(?:json)?\s*', '', raw)
raw = re.sub(r'\s*```$', '', raw)
raw = raw.strip()
match = re.search(r'\[[\s\S]*\]', raw)
if not match:
raise ValueError(f"No JSON array found: {raw[:300]}")
data = json.loads(match.group(0))
if not isinstance(data, list):
raise ValueError(f"Not an array: {type(data)}")
result = []
for item in data:
if isinstance(item, str):
result.append(item)
elif isinstance(item, dict):
# Try known keys, then concatenate all values
for key in ("expansion", "expanded", "content", "text",
"explanation", "detail", "description", "meaning",
"abbrev", "ΰΈ‚ΰΈ’ΰΈ²ΰΈ’ΰΈ„ΰΈ§ΰΈ²ΰΈ‘", "ΰΈ‚ΰΉ‰ΰΈ­ΰΈ„ΰΈ§ΰΈ²ΰΈ‘", "ΰΈ„ΰΈ§ΰΈ²ΰΈ‘ΰΈ«ΰΈ‘ΰΈ²ΰΈ’", "answer"):
val = item.get(key, "")
if isinstance(val, str) and len(val.strip()) > 10:
result.append(val.strip())
break
else:
parts = [v for v in item.values()
if isinstance(v, str) and len(v.strip()) > 10]
if parts:
result.append(" | ".join(parts))
return result
# ══════════════════════════════════════════════════════════════
# Core: expand one page
# ══════════════════════════════════════════════════════════════
async def expand_page(
client: AsyncOpenAI,
model: str,
vol: int, page: int, content_text: str,
conn: sqlite3.Connection,
) -> bool:
"""Expand all (ฒ่อ) on one page. Stores result in DB. Returns True on success."""
abbrev_count = content_text.count("(ฒ่อ)")
if abbrev_count == 0:
return False
prompt = build_prompt(content_text)
for attempt in range(MAX_RETRIES):
try:
resp = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
temperature=0.3,
max_tokens=2000,
timeout=120,
)
raw = resp.choices[0].message.content or ""
expansions = parse_response(raw, abbrev_count)
# Filter meaningful expansions
expansions = [e for e in expansions if len(e.strip()) > 15]
if not expansions:
log.warning(f" Empty! retrying... raw={raw[:150]}")
if attempt < MAX_RETRIES - 1:
await asyncio.sleep(RETRY_DELAYS[attempt])
continue
return False
# Store in DB
stored = json.dumps(expansions, ensure_ascii=False)
conn.execute("""
INSERT OR REPLACE INTO reference_markers
(volume_num, page_num, marker_id, type, content)
VALUES (?, ?, '_default', 'abbrev', ?)
""", (vol, page, stored))
conn.commit()
total_chars = sum(len(e) for e in expansions)
log.info(f" βœ… Vol {vol} P{page}: {len(expansions)} abbrevs, {total_chars}c")
return True
except json.JSONDecodeError as e:
log.warning(f" ⚠ Vol {vol} P{page} JSON error (attempt {attempt+1}): {e}")
if attempt < MAX_RETRIES - 1:
await asyncio.sleep(RETRY_DELAYS[attempt])
except Exception as e:
err_str = str(e)
is_429 = "429" in err_str or "Too Many Requests" in err_str
log.warning(f" ⚠ Vol {vol} P{page} Error (attempt {attempt+1}): {e}")
if attempt < MAX_RETRIES - 1:
delay = RETRY_DELAYS[attempt] * (2 if is_429 else 1)
await asyncio.sleep(delay)
log.error(f" ❌ Vol {vol} P{page}: Failed after {MAX_RETRIES} attempts")
return False
# ══════════════════════════════════════════════════════════════
# Main
# ══════════════════════════════════════════════════════════════
async def main():
parser = argparse.ArgumentParser(
description="Pre-index (ฒ่อ) abbreviations via LLM"
)
parser.add_argument("--resume", action="store_true", help="Skip already-indexed pages")
parser.add_argument("--force", action="store_true", help="Re-index even if already in DB")
parser.add_argument("--limit", type=int, default=0, help="Max pages (0 = all)")
parser.add_argument(
"--provider", choices=["nim", "deepseek"], default="nim",
help="LLM provider: nim (NVIDIA NIM, default) or deepseek (DeepSeek API)"
)
args = parser.parse_args()
# ── Select provider config ──
if args.provider == "nim":
api_key = NIM_API_KEY
base_url = NIM_BASE_URL
model = NIM_MODEL
provider_name = "NVIDIA NIM"
else:
api_key = DEEPSEEK_API_KEY or os.getenv("DEEPSEEK_API_KEY") or ""
base_url = DEEPSEEK_BASE_URL
model = DEEPSEEK_MODEL
provider_name = "DeepSeek API"
if not api_key:
log.error(f"❌ No API key for provider '{args.provider}'. Check env file: {ENV_PATH}")
if args.provider == "nim":
log.error(" Set TIPITAKA_API_KEY or NVIDIA_API_KEY")
else:
log.error(" Set DEEPSEEK_API_KEY")
return
if not DB_PATH.exists():
log.error(f"❌ DB not found: {DB_PATH}")
return
log.info(f"πŸš€ preindex_abbrevs | provider={provider_name} model={model}")
log.info(f" DB: {DB_PATH}")
log.info(f" Key: {api_key[:8]}...")
log.info(f" Rate: {RATE_INTERVAL}s")
# ── Load pages ──
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT COUNT(*) as cnt FROM reference_markers WHERE type='abbrev'")
log.info(f" Existing: {c.fetchone()['cnt']} abbrev entries")
c.execute("""
SELECT v.volume_number, p.page_number, p.content_text
FROM pages p
JOIN volumes v ON p.volume_id = v.id
WHERE p.content_text LIKE '%(ฒ่อ)%'
ORDER BY v.volume_number, p.page_number
""")
rows = c.fetchall()
log.info(f" Pages: {len(rows)}")
if args.limit > 0:
rows = rows[:args.limit]
log.info(f" Limit: {args.limit}")
if args.resume:
c.execute("SELECT DISTINCT volume_num, page_num FROM reference_markers WHERE type='abbrev'")
indexed = {(r["volume_num"], r["page_num"]) for r in c.fetchall()}
rows = [r for r in rows if (r["volume_number"], r["page_number"]) not in indexed]
log.info(f" Resume: {len(indexed)} done, {len(rows)} remaining")
if not rows:
log.info("βœ… All done!")
conn.close()
return
elif args.force:
c.execute("DELETE FROM reference_markers WHERE type='abbrev'")
conn.commit()
log.info(" Force: cleared all")
# ── Setup client ──
client = AsyncOpenAI(
base_url=base_url,
api_key=api_key,
max_retries=0,
)
total = len(rows)
success = fail = 0
start = time.time()
log.info(f"\n{'='*60}")
log.info(f"Processing {total} pages...")
log.info(f"{'='*60}\n")
for i, row in enumerate(rows):
vol = row["volume_number"]
page = row["page_number"]
text = row["content_text"] or ""
# Rate limit
if i > 0:
elapsed = time.time() - start
expected = i * RATE_INTERVAL
wait = max(0, expected - elapsed)
if wait > 0:
await asyncio.sleep(wait)
ok = await expand_page(client, model, vol, page, text, conn)
if ok:
success += 1
else:
fail += 1
# Progress every 50 pages
if (i + 1) % 50 == 0:
elapsed = time.time() - start
rate = (i + 1) / elapsed * 60
remain = total - i - 1
eta = remain / max(rate, 0.1) * 60
log.info(f"πŸ“Š [{i+1}/{total}] {rate:.0f} pg/min | ETA: {eta/60:.1f}h")
# ── Summary ──
elapsed = time.time() - start
log.info(f"\n{'='*60}")
log.info(f"βœ… Done!")
log.info(f" Success: {success} Failed: {fail}")
log.info(f" Time: {elapsed:.0f}s ({elapsed/max(success,1):.1f}s/page)")
log.info(f" Log: {LOG_PATH}")
conn.close()
if __name__ == "__main__":
asyncio.run(main())