asifdzakiy-droid commited on
Commit
70a33a4
·
1 Parent(s): cd72263

feat: Add SQLite metadata dump script and UI, remove PostgreSQL

Browse files
Files changed (12) hide show
  1. .gitignore +0 -0
  2. Dockerfile +44 -0
  3. README.md +3 -3
  4. api.py +156 -0
  5. app.py +41 -0
  6. create_sqlite_dump.py +172 -0
  7. ingest_worker.py +237 -0
  8. init.sql +128 -0
  9. mcp_server.py +110 -0
  10. nginx.conf +79 -0
  11. requirements.txt +12 -0
  12. run.sh +30 -0
.gitignore ADDED
Binary file (40 Bytes). View file
 
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM timescale/timescaledb-ha:pg17
2
+
3
+ USER root
4
+
5
+ # Install Python 3, pip, and venv
6
+ RUN apt-get update && \
7
+ apt-get install -y python3 python3-pip python3-venv curl nginx && \
8
+ apt-get clean && \
9
+ rm -rf /var/lib/apt/lists/*
10
+
11
+ # Setup directories for Hugging Face Spaces (UID 1000)
12
+ # HF Persistent Storage is mounted at /data if enabled.
13
+ RUN mkdir -p /data/postgres /var/lib/postgresql/data /app /var/log/nginx /var/lib/nginx /etc/nginx && \
14
+ chown -R 1000:1000 /data /var/lib/postgresql/data /var/run/postgresql /app /var/log/nginx /var/lib/nginx /etc/nginx
15
+
16
+ # Switch to HF user
17
+ USER 1000
18
+ WORKDIR /app
19
+
20
+ # Create virtual environment and install dependencies
21
+ RUN python3 -m venv /app/venv
22
+ ENV PATH="/app/venv/bin:$PATH"
23
+
24
+ COPY requirements.txt /app/
25
+ RUN pip install --no-cache-dir -r requirements.txt
26
+
27
+ # Copy Nginx config
28
+ COPY nginx.conf /etc/nginx/nginx.conf
29
+
30
+ # Postgres variables
31
+ ENV POSTGRES_PASSWORD=password
32
+ ENV PGDATA=/data/postgres
33
+
34
+ # Copy initialization SQL
35
+ COPY init.sql /docker-entrypoint-initdb.d/
36
+
37
+ # Copy application code
38
+ COPY app.py run.sh ingest_worker.py api.py /app/
39
+
40
+ # Expose port 7860 (Hugging Face default)
41
+ EXPOSE 7860
42
+
43
+ # Execute the supervisor script
44
+ CMD ["bash", "/app/run.sh"]
README.md CHANGED
@@ -1,11 +1,11 @@
1
  ---
2
- title: Test
3
- emoji: 🌍
4
  colorFrom: red
5
  colorTo: purple
6
  sdk: docker
7
  pinned: false
8
- short_description: tesst
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Samela
3
+ emoji: 👀
4
  colorFrom: red
5
  colorTo: purple
6
  sdk: docker
7
  pinned: false
8
+ short_description: samela
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
api.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from fastapi import FastAPI, Query, HTTPException
3
+ from sqlalchemy import create_engine, text
4
+ from typing import Optional
5
+
6
+ DB_URI = os.getenv("DB_URI", "postgresql://postgres:password@localhost:5432/postgres")
7
+ engine = create_engine(DB_URI)
8
+
9
+ app = FastAPI(
10
+ title="Shamela4 Library API",
11
+ description="REST API to query the Shamela4 dataset including categories, authors, books, and pages.",
12
+ version="1.0.0"
13
+ )
14
+
15
+ def fetch_data(query: str, params: dict = {}):
16
+ try:
17
+ with engine.connect() as conn:
18
+ result = conn.execute(text(query), params)
19
+ return [dict(row._mapping) for row in result]
20
+ except Exception as e:
21
+ raise HTTPException(status_code=500, detail=str(e))
22
+
23
+ def fetch_one(query: str, params: dict = {}):
24
+ try:
25
+ with engine.connect() as conn:
26
+ result = conn.execute(text(query), params).fetchone()
27
+ if result:
28
+ return dict(result._mapping)
29
+ return None
30
+ except Exception as e:
31
+ raise HTTPException(status_code=500, detail=str(e))
32
+
33
+ @app.get("/api/stats", tags=["Stats"])
34
+ def get_statistics():
35
+ """Get database statistics (counts)."""
36
+ try:
37
+ with engine.connect() as conn:
38
+ books_count = conn.execute(text("SELECT COUNT(book_id) FROM books")).scalar() or 0
39
+ authors_count = conn.execute(text("SELECT COUNT(author_id) FROM authors")).scalar() or 0
40
+ categories_count = conn.execute(text("SELECT COUNT(category_id) FROM categories")).scalar() or 0
41
+ pages_count = conn.execute(text("SELECT COUNT(page_id) FROM pages")).scalar() or 0
42
+ synced_books = conn.execute(text("SELECT COUNT(DISTINCT book_id) FROM pages")).scalar() or 0
43
+ return {
44
+ "total_categories": categories_count,
45
+ "total_authors": authors_count,
46
+ "total_books": books_count,
47
+ "synced_books": synced_books,
48
+ "total_pages_downloaded": pages_count,
49
+ "sync_percentage": round((synced_books / books_count * 100), 2) if books_count > 0 else 0
50
+ }
51
+ except Exception as e:
52
+ raise HTTPException(status_code=500, detail=str(e))
53
+
54
+ @app.get("/api/categories", tags=["Categories"])
55
+ def get_categories(limit: int = Query(50, le=1000), offset: int = 0):
56
+ """Get list of categories."""
57
+ query = "SELECT category_id, category_name_ar, name_en, sort_order FROM categories ORDER BY sort_order LIMIT :limit OFFSET :offset"
58
+ return fetch_data(query, {"limit": limit, "offset": offset})
59
+
60
+ @app.get("/api/categories/{category_id}", tags=["Categories"])
61
+ def get_category_detail(category_id: int):
62
+ """Get details of a specific category."""
63
+ query = "SELECT category_id, category_name_ar, name_en, sort_order FROM categories WHERE category_id = :category_id"
64
+ category = fetch_one(query, {"category_id": category_id})
65
+ if not category:
66
+ raise HTTPException(status_code=404, detail="Category not found")
67
+ return category
68
+
69
+ @app.get("/api/authors", tags=["Authors"])
70
+ def get_authors(search: Optional[str] = None, limit: int = Query(50, le=1000), offset: int = 0):
71
+ """Get list of authors. Optionally search by name."""
72
+ if search:
73
+ query = "SELECT author_id, name_ar, death_hijri FROM authors WHERE name_ar LIKE :search ORDER BY death_hijri LIMIT :limit OFFSET :offset"
74
+ return fetch_data(query, {"search": f"%{search}%", "limit": limit, "offset": offset})
75
+ else:
76
+ query = "SELECT author_id, name_ar, death_hijri FROM authors ORDER BY death_hijri LIMIT :limit OFFSET :offset"
77
+ return fetch_data(query, {"limit": limit, "offset": offset})
78
+
79
+ @app.get("/api/authors/{author_id}", tags=["Authors"])
80
+ def get_author_detail(author_id: int):
81
+ """Get details of a specific author and their books."""
82
+ query = "SELECT * FROM authors WHERE author_id = :author_id"
83
+ author = fetch_one(query, {"author_id": author_id})
84
+ if not author:
85
+ raise HTTPException(status_code=404, detail="Author not found")
86
+
87
+ books_query = "SELECT book_id, title_ar, category_id, volume_count_observed FROM books WHERE main_author_id = :author_id ORDER BY book_id"
88
+ author['books'] = fetch_data(books_query, {"author_id": author_id})
89
+ return author
90
+
91
+ @app.get("/api/books", tags=["Books"])
92
+ def get_books(search: Optional[str] = None, category_id: Optional[int] = None, author_id: Optional[int] = None, limit: int = Query(50, le=1000), offset: int = 0):
93
+ """Get list of books. Filter by category, author, or search keyword."""
94
+ where_clauses = []
95
+ params = {"limit": limit, "offset": offset}
96
+
97
+ if search:
98
+ where_clauses.append("(title_ar LIKE :search OR main_author_name_ar LIKE :search)")
99
+ params["search"] = f"%{search}%"
100
+ if category_id:
101
+ where_clauses.append("category_id = :category_id")
102
+ params["category_id"] = category_id
103
+ if author_id:
104
+ where_clauses.append("main_author_id = :author_id")
105
+ params["author_id"] = author_id
106
+
107
+ where_str = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
108
+ query = f"SELECT book_id, title_ar, category_id, main_author_id, main_author_name_ar, main_author_death_hijri, volume_count_observed, version_major FROM books {where_str} ORDER BY book_id LIMIT :limit OFFSET :offset"
109
+ return fetch_data(query, params)
110
+
111
+ @app.get("/api/books/{book_id}", tags=["Books"])
112
+ def get_book_detail(book_id: int):
113
+ """Get details of a specific book, including synopsis and TOC."""
114
+ query = "SELECT * FROM books WHERE book_id = :book_id"
115
+ book = fetch_one(query, {"book_id": book_id})
116
+ if not book:
117
+ raise HTTPException(status_code=404, detail="Book not found")
118
+
119
+ # Get TOC (Daftar Isi) if available
120
+ toc_query = "SELECT title_id, title_text, page_id, parent_id FROM toc WHERE book_id = :book_id ORDER BY title_id"
121
+ book['toc'] = fetch_data(toc_query, {"book_id": book_id})
122
+
123
+ # Get total pages
124
+ pages_query = "SELECT COUNT(*) as total_pages FROM pages WHERE book_id = :book_id"
125
+ pages_result = fetch_one(pages_query, {"book_id": book_id})
126
+ book['total_pages'] = pages_result['total_pages'] if pages_result else 0
127
+
128
+ return book
129
+
130
+ @app.get("/api/books/{book_id}/pages", tags=["Pages"])
131
+ def get_book_pages(book_id: int, limit: int = Query(100, le=500), offset: int = 0):
132
+ """Get pages of a specific book, ordered by sequence_num."""
133
+ query = "SELECT * FROM pages WHERE book_id = :book_id ORDER BY sequence_num LIMIT :limit OFFSET :offset"
134
+ return fetch_data(query, {"book_id": book_id, "limit": limit, "offset": offset})
135
+
136
+ @app.get("/api/search", tags=["Search"])
137
+ def search_text(query: str, limit: int = Query(20, le=100), offset: int = 0):
138
+ """Full-text search inside book pages."""
139
+ # We use PostgreSQL to_tsquery for full-text search. The body column has a GIN index on to_tsvector('arabic', body).
140
+ # We format the query string to join words with & (AND) for tsquery
141
+ words = [w for w in query.split() if w.strip()]
142
+ if not words:
143
+ return []
144
+ tsquery = " & ".join(words)
145
+
146
+ sql = """
147
+ SELECT p.page_id, p.book_id, p.page_num, p.sequence_num,
148
+ ts_headline('arabic', p.body, to_tsquery('arabic', :tsquery)) as highlight,
149
+ b.title_ar as book_title
150
+ FROM pages p
151
+ JOIN books b ON p.book_id = b.book_id
152
+ WHERE to_tsvector('arabic', p.body) @@ to_tsquery('arabic', :tsquery)
153
+ ORDER BY p.book_id, p.sequence_num
154
+ LIMIT :limit OFFSET :offset
155
+ """
156
+ return fetch_data(sql, {"tsquery": tsquery, "limit": limit, "offset": offset})
app.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import os
3
+
4
+ st.set_page_config(page_title="Shamela4 SQLite Ingest", layout="centered", page_icon="⚙️")
5
+
6
+ st.title("⚙️ Shamela4 SQLite Ingest")
7
+
8
+ st.markdown("<br>", unsafe_allow_html=True)
9
+ st.info("Aplikasi ini secara khusus digunakan untuk mengambil data (ingest) metadata kitab dari HuggingFace dan mengubahnya menjadi format dump database SQLite (.sql). Seluruh proses ini tidak memerlukan koneksi ke PostgreSQL.", icon="ℹ️")
10
+ st.markdown("<br>", unsafe_allow_html=True)
11
+
12
+ st.markdown("### 💾 Ekspor Skema & Metadata (SQLite)")
13
+ st.write("Unduh skema lengkap database (termasuk struktur tabel `pages`, `toc` dll yang kosong) beserta *insert data* untuk metadata (`authors`, `categories`, `books`).")
14
+ st.markdown("---")
15
+
16
+ col_btn1, col_btn2 = st.columns(2)
17
+
18
+ with col_btn1:
19
+ if st.button("🔄 Generate Ulang File SQL", use_container_width=True):
20
+ with st.spinner("Sedang men-generate file metadata_dump.sql dari HuggingFace... (Ini bisa memakan waktu beberapa saat)"):
21
+ try:
22
+ from create_sqlite_dump import generate_sqlite_dump
23
+ generate_sqlite_dump()
24
+ st.success("File berhasil diperbarui!")
25
+ except Exception as e:
26
+ st.error(f"Terjadi kesalahan: {e}")
27
+
28
+ with col_btn2:
29
+ if os.path.exists("metadata_dump.sql"):
30
+ with open("metadata_dump.sql", "r", encoding="utf-8") as f:
31
+ sql_data = f.read()
32
+ st.download_button(
33
+ label="⬇️ Download metadata_dump.sql",
34
+ data=sql_data,
35
+ file_name="metadata_dump.sql",
36
+ mime="application/sql",
37
+ type="primary",
38
+ use_container_width=True
39
+ )
40
+ else:
41
+ st.button("🚫 File belum tersedia", disabled=True, use_container_width=True)
create_sqlite_dump.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import pandas as pd
3
+ import json
4
+ import numpy as np
5
+
6
+ def generate_sqlite_dump():
7
+ # Connect to in-memory SQLite database
8
+ conn = sqlite3.connect(':memory:')
9
+ cursor = conn.cursor()
10
+
11
+ # 1. Create SQLite Schema for metadata
12
+ schema = """
13
+ CREATE TABLE IF NOT EXISTS authors (
14
+ author_id INTEGER PRIMARY KEY,
15
+ name_ar TEXT,
16
+ death_hijri INTEGER,
17
+ death_hijri_text TEXT,
18
+ alpha_sort TEXT,
19
+ biography TEXT
20
+ );
21
+
22
+ CREATE TABLE IF NOT EXISTS categories (
23
+ category_id INTEGER PRIMARY KEY,
24
+ category_name_ar TEXT,
25
+ name_en TEXT,
26
+ sort_order INTEGER
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS books (
30
+ book_id INTEGER PRIMARY KEY,
31
+ shamela_id INTEGER,
32
+ title_ar TEXT,
33
+ book_type INTEGER,
34
+ book_type_label TEXT,
35
+ category_id INTEGER REFERENCES categories(category_id),
36
+ main_author_id INTEGER REFERENCES authors(author_id),
37
+ main_author_name_ar TEXT,
38
+ main_author_death_hijri INTEGER,
39
+ main_author_death_hijri_text TEXT,
40
+ authors_text TEXT,
41
+ hijri_era TEXT,
42
+ printed BOOLEAN,
43
+ is_hidden BOOLEAN,
44
+ parent_id INTEGER,
45
+ group_id INTEGER,
46
+ version_major INTEGER,
47
+ version_minor INTEGER,
48
+ betaka_text TEXT,
49
+ meta TEXT,
50
+ volume_count_observed INTEGER,
51
+ has_multi_part BOOLEAN,
52
+ authors_json TEXT
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS book_authors (
56
+ book_id INTEGER REFERENCES books(book_id),
57
+ author_id INTEGER REFERENCES authors(author_id),
58
+ role TEXT,
59
+ name_ar TEXT,
60
+ death_hijri INTEGER,
61
+ PRIMARY KEY (book_id, author_id)
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS pages (
65
+ page_id INTEGER PRIMARY KEY,
66
+ book_id INTEGER REFERENCES books(book_id),
67
+ shamela_page_id INTEGER,
68
+ part TEXT,
69
+ page_num INTEGER,
70
+ sequence_num INTEGER,
71
+ body TEXT,
72
+ footnotes TEXT,
73
+ hints TEXT,
74
+ services_raw TEXT
75
+ );
76
+
77
+ CREATE INDEX IF NOT EXISTS pages_body_idx ON pages (body);
78
+
79
+ CREATE TABLE IF NOT EXISTS toc (
80
+ title_id INTEGER PRIMARY KEY,
81
+ book_id INTEGER REFERENCES books(book_id),
82
+ page_id INTEGER REFERENCES pages(page_id),
83
+ parent_id INTEGER REFERENCES toc(title_id),
84
+ shamela_title_id INTEGER,
85
+ title_text TEXT
86
+ );
87
+
88
+ CREATE TABLE IF NOT EXISTS quran_verses (
89
+ verse_id INTEGER PRIMARY KEY,
90
+ surah_id INTEGER,
91
+ ayah_id INTEGER,
92
+ text_ar TEXT,
93
+ data TEXT
94
+ );
95
+
96
+ CREATE TABLE IF NOT EXISTS narrators (
97
+ narrator_id INTEGER PRIMARY KEY,
98
+ name_ar TEXT,
99
+ data TEXT
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS root_dictionary (
103
+ root_id INTEGER PRIMARY KEY,
104
+ token TEXT,
105
+ data TEXT
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS hadith_xrefs (
109
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
110
+ book_id INTEGER REFERENCES books(book_id),
111
+ page_id INTEGER REFERENCES pages(page_id),
112
+ data TEXT
113
+ );
114
+
115
+ CREATE TABLE IF NOT EXISTS tafsir_xrefs (
116
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
117
+ book_id INTEGER REFERENCES books(book_id),
118
+ page_id INTEGER REFERENCES pages(page_id),
119
+ data TEXT
120
+ );
121
+
122
+ CREATE TABLE IF NOT EXISTS page_isnads (
123
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
124
+ book_id INTEGER REFERENCES books(book_id),
125
+ page_id INTEGER REFERENCES pages(page_id),
126
+ narrator_id INTEGER REFERENCES narrators(narrator_id),
127
+ data TEXT
128
+ );
129
+ """
130
+ cursor.executescript(schema)
131
+
132
+ # 2. Download and insert metadata
133
+ print("Downloading Categories...")
134
+ df_cat = pd.read_parquet("https://huggingface.co/datasets/AuthenticIlm/Shamela4_Full_DB/resolve/main/_meta/categories.parquet")
135
+ if 'id' in df_cat.columns: df_cat.rename(columns={'id': 'category_id'}, inplace=True)
136
+ if 'name_ar' in df_cat.columns and 'category_name_ar' not in df_cat.columns: df_cat.rename(columns={'name_ar': 'category_name_ar'}, inplace=True)
137
+ if 'category_name' in df_cat.columns and 'category_name_ar' not in df_cat.columns: df_cat.rename(columns={'category_name': 'category_name_ar'}, inplace=True)
138
+ cols_cat = ['category_id', 'category_name_ar', 'name_en', 'sort_order']
139
+ df_cat = df_cat[[c for c in cols_cat if c in df_cat.columns]]
140
+ df_cat.to_sql("categories", conn, if_exists="append", index=False)
141
+
142
+ print("Downloading Authors...")
143
+ df_auth = pd.read_parquet("https://huggingface.co/datasets/AuthenticIlm/Shamela4_Full_DB/resolve/main/_meta/authors.parquet")
144
+ if 'id' in df_auth.columns: df_auth.rename(columns={'id': 'author_id'}, inplace=True)
145
+ cols_auth = ['author_id', 'name_ar', 'death_hijri', 'death_hijri_text', 'alpha_sort', 'biography']
146
+ df_auth = df_auth[[c for c in cols_auth if c in df_auth.columns]]
147
+ df_auth.to_sql("authors", conn, if_exists="append", index=False)
148
+
149
+ print("Downloading Books Metadata...")
150
+ df_books = pd.read_parquet("https://huggingface.co/datasets/AuthenticIlm/Shamela4_Full_DB/resolve/main/_meta/book_metadata.parquet")
151
+ cols_books = ['book_id', 'shamela_id', 'title_ar', 'book_type', 'book_type_label', 'category_id', 'main_author_id', 'main_author_name_ar', 'main_author_death_hijri', 'main_author_death_hijri_text', 'authors_text', 'hijri_era', 'printed', 'is_hidden', 'parent_id', 'group_id', 'version_major', 'version_minor', 'betaka_text', 'meta', 'volume_count_observed', 'has_multi_part', 'authors_json']
152
+ df_books = df_books[[c for c in cols_books if c in df_books.columns]]
153
+ if 'meta' in df_books.columns:
154
+ df_books['meta'] = df_books['meta'].apply(lambda x: json.dumps(x.tolist()) if isinstance(x, np.ndarray) else (json.dumps(x) if isinstance(x, dict) else (x if pd.isna(x) else json.dumps(x))))
155
+ if 'authors_json' in df_books.columns:
156
+ df_books['authors_json'] = df_books['authors_json'].apply(lambda x: json.dumps(x.tolist()) if isinstance(x, np.ndarray) else (json.dumps(x) if isinstance(x, dict) else (x if pd.isna(x) else json.dumps(x))))
157
+ df_books.to_sql("books", conn, if_exists="append", index=False)
158
+
159
+ # 3. Dump to .sql file
160
+ output_filename = 'metadata_dump.sql'
161
+ print(f"Dumping to {output_filename}...")
162
+ with open(output_filename, 'w', encoding='utf-8') as f:
163
+ for line in conn.iterdump():
164
+ # Include only schemas and INSERTs for metadata tables.
165
+ # conn.iterdump() does a full backup of the schema and inserts for tables in memory.
166
+ f.write('%s\n' % line)
167
+
168
+ print(f"Done! The SQL dump is ready at: {output_filename}")
169
+ conn.close()
170
+
171
+ if __name__ == "__main__":
172
+ generate_sqlite_dump()
ingest_worker.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import json
3
+ import requests
4
+ import pandas as pd
5
+ from sqlalchemy import create_engine, text
6
+ from huggingface_hub import HfFileSystem
7
+
8
+ DB_URI = "postgresql://postgres:password@localhost:5432/postgres"
9
+ engine = create_engine(DB_URI)
10
+ fs = HfFileSystem()
11
+
12
+ import io
13
+ import csv
14
+
15
+ session = requests.Session()
16
+ adapter = requests.adapters.HTTPAdapter(pool_connections=20, pool_maxsize=20)
17
+ session.mount('https://', adapter)
18
+
19
+ def copy_to_db(table_name, df, engine):
20
+ """Fast bulk insert using PostgreSQL COPY via psycopg2."""
21
+ if df.empty:
22
+ return
23
+
24
+ # Clean data to prevent Postgres COPY errors (carriage returns, null bytes, and float64 floats)
25
+ for col in df.columns:
26
+ if str(df[col].dtype) == 'float64':
27
+ df[col] = df[col].astype('Int64')
28
+ elif str(df[col].dtype) == 'object':
29
+ df[col] = df[col].apply(lambda x: x.replace('\r', ' ').replace('\0', '') if isinstance(x, str) else x)
30
+
31
+ # Convert string JSON to ensure no weird quoting issues
32
+ buffer = io.StringIO()
33
+ df.to_csv(buffer, index=False, header=False, sep='\t', quoting=csv.QUOTE_MINIMAL, na_rep='\\N')
34
+ buffer.seek(0)
35
+
36
+ # We use psycopg2 raw connection
37
+ conn = engine.raw_connection()
38
+ try:
39
+ with conn.cursor() as cur:
40
+ columns = ', '.join(df.columns)
41
+ sql = f"COPY {table_name} ({columns}) FROM STDIN WITH (FORMAT CSV, DELIMITER '\t', NULL '\\N', QUOTE '\"', ESCAPE '\"')"
42
+ cur.copy_expert(sql, buffer)
43
+ conn.commit()
44
+ except Exception as e:
45
+ conn.rollback()
46
+ raise e
47
+ finally:
48
+ conn.close()
49
+
50
+ def build_path_mapping(fs):
51
+ print("Building path mapping from HuggingFace (this might take a few seconds)...")
52
+ paths = fs.glob("datasets/AuthenticIlm/Shamela4_Full_DB/*/*")
53
+ mapping = {}
54
+ for p in paths:
55
+ parts = p.split('/')
56
+ if len(parts) >= 4:
57
+ book_dir = parts[-1]
58
+ try:
59
+ book_id_str = book_dir.split('__')[0]
60
+ if book_id_str.isdigit():
61
+ mapping[int(book_id_str)] = f"{parts[-2]}/{parts[-1]}/pages.jsonl"
62
+ except:
63
+ pass
64
+ print(f"Path mapping built for {len(mapping)} books.")
65
+ return mapping
66
+
67
+ def process_book(book_id, file_path):
68
+ if not file_path:
69
+ return False, "Not found in mapping"
70
+
71
+ # TOC insertion moved to the end of process_book to prevent FK violations
72
+
73
+ url = f"https://huggingface.co/datasets/AuthenticIlm/Shamela4_Full_DB/resolve/main/{file_path}"
74
+ try:
75
+ with session.get(url, stream=True, timeout=30) as response:
76
+ if response.status_code != 200:
77
+ return False, "Download failed"
78
+
79
+ pages = []
80
+
81
+ for line in response.iter_lines(chunk_size=65536):
82
+ if line:
83
+ try:
84
+ page_data = json.loads(line)
85
+ if 'services_raw' in page_data and page_data['services_raw'] is not None:
86
+ page_data['services_raw'] = json.dumps(page_data['services_raw'])
87
+
88
+ pages.append(page_data)
89
+
90
+ if len(pages) >= 5000:
91
+ pages_df = pd.DataFrame(pages)
92
+ cols = ['page_id', 'book_id', 'shamela_page_id', 'part', 'page_num', 'sequence_num', 'body', 'footnotes', 'hints', 'services_raw']
93
+ pages_df = pages_df[[c for c in cols if c in pages_df.columns]]
94
+ copy_to_db('pages', pages_df, engine)
95
+ pages = []
96
+ except Exception as e:
97
+ pass
98
+
99
+ if len(pages) > 0:
100
+ try:
101
+ pages_df = pd.DataFrame(pages)
102
+ cols = ['page_id', 'book_id', 'shamela_page_id', 'part', 'page_num', 'sequence_num', 'body', 'footnotes', 'hints', 'services_raw']
103
+ pages_df = pages_df[[c for c in cols if c in pages_df.columns]]
104
+ copy_to_db('pages', pages_df, engine)
105
+ except Exception as e:
106
+ print(f"Error saving final pages for book {book_id}: {e}")
107
+
108
+ # Now that pages are inserted, we can safely insert TOC which depends on pages(page_id)
109
+ toc_file_path = file_path.replace("pages.jsonl", "toc.jsonl")
110
+ toc_url = f"https://huggingface.co/datasets/AuthenticIlm/Shamela4_Full_DB/resolve/main/{toc_file_path}"
111
+ toc_entries = []
112
+ try:
113
+ with session.get(toc_url, stream=True, timeout=30) as toc_response:
114
+ if toc_response.status_code == 200:
115
+ for line in toc_response.iter_lines(chunk_size=65536):
116
+ if line:
117
+ try:
118
+ toc_entries.append(json.loads(line))
119
+ except Exception as e:
120
+ pass
121
+ except Exception as e:
122
+ print(f"Error downloading TOC for {book_id}: {e}")
123
+
124
+ if len(toc_entries) > 0:
125
+ try:
126
+ # Ensure correct columns mapping if json has extra fields
127
+ toc_df = pd.DataFrame(toc_entries)
128
+ expected_cols = ['title_id', 'book_id', 'page_id', 'parent_id', 'shamela_title_id', 'title_text']
129
+ toc_df = toc_df[[c for c in expected_cols if c in toc_df.columns]]
130
+
131
+ # Fix Shamela edge-case where page_id=0 for general headings, which violates FK constraints
132
+ if 'page_id' in toc_df.columns:
133
+ toc_df.loc[toc_df['page_id'] == 0, 'page_id'] = None
134
+
135
+ copy_to_db('toc', toc_df, engine)
136
+ except Exception as e:
137
+ print(f"Error saving TOC for book {book_id}: {e}")
138
+
139
+ return True, "Success"
140
+ except Exception as e:
141
+ return False, str(e)
142
+
143
+ def process_wrapper(args):
144
+ book_id, file_path = args
145
+ print(f"Processing book {book_id}...")
146
+ success, msg = process_book(book_id, file_path)
147
+ if success:
148
+ print(f"Book {book_id} processed successfully.")
149
+ else:
150
+ print(f"Failed to process book {book_id}: {msg}")
151
+
152
+ def load_metadata():
153
+ import numpy as np
154
+ print("Downloading Categories...")
155
+ df = pd.read_parquet("https://huggingface.co/datasets/AuthenticIlm/Shamela4_Full_DB/resolve/main/_meta/categories.parquet")
156
+ if 'id' in df.columns: df.rename(columns={'id': 'category_id'}, inplace=True)
157
+ if 'name_ar' in df.columns and 'category_name_ar' not in df.columns: df.rename(columns={'name_ar': 'category_name_ar'}, inplace=True)
158
+ if 'category_name' in df.columns and 'category_name_ar' not in df.columns: df.rename(columns={'category_name': 'category_name_ar'}, inplace=True)
159
+ cols = ['category_id', 'category_name_ar', 'name_en', 'sort_order']
160
+ df[[c for c in cols if c in df.columns]].to_sql("categories", engine, if_exists="append", index=False)
161
+
162
+ print("Downloading Authors...")
163
+ df = pd.read_parquet("https://huggingface.co/datasets/AuthenticIlm/Shamela4_Full_DB/resolve/main/_meta/authors.parquet")
164
+ if 'id' in df.columns: df.rename(columns={'id': 'author_id'}, inplace=True)
165
+ cols = ['author_id', 'name_ar', 'death_hijri', 'death_hijri_text', 'alpha_sort', 'biography']
166
+ df[[c for c in cols if c in df.columns]].to_sql("authors", engine, if_exists="append", index=False)
167
+
168
+ print("Downloading Books Metadata...")
169
+ df = pd.read_parquet("https://huggingface.co/datasets/AuthenticIlm/Shamela4_Full_DB/resolve/main/_meta/book_metadata.parquet")
170
+ cols = ['book_id', 'shamela_id', 'title_ar', 'book_type', 'book_type_label', 'category_id', 'main_author_id', 'main_author_name_ar', 'main_author_death_hijri', 'main_author_death_hijri_text', 'authors_text', 'hijri_era', 'printed', 'is_hidden', 'parent_id', 'group_id', 'version_major', 'version_minor', 'betaka_text', 'meta', 'volume_count_observed', 'has_multi_part', 'authors_json']
171
+ df = df[[c for c in cols if c in df.columns]]
172
+ if 'meta' in df.columns:
173
+ df['meta'] = df['meta'].apply(lambda x: json.dumps(x.tolist()) if isinstance(x, np.ndarray) else (json.dumps(x) if isinstance(x, dict) else (x if pd.isna(x) else json.dumps(x))))
174
+ if 'authors_json' in df.columns:
175
+ df['authors_json'] = df['authors_json'].apply(lambda x: json.dumps(x.tolist()) if isinstance(x, np.ndarray) else (json.dumps(x) if isinstance(x, dict) else (x if pd.isna(x) else json.dumps(x))))
176
+ df.to_sql("books", engine, if_exists="append", index=False)
177
+ print("Metadata loading complete!")
178
+
179
+ def run_worker():
180
+ print("Ingest Worker Started...")
181
+ from concurrent.futures import ThreadPoolExecutor
182
+ path_mapping = None
183
+
184
+ while True:
185
+ try:
186
+ # Get list of all books
187
+ df_books = pd.read_sql("SELECT book_id FROM books", engine)
188
+ if df_books.empty:
189
+ print("No books found in DB. Auto-loading metadata...")
190
+ try:
191
+ load_metadata()
192
+ except Exception as e:
193
+ print(f"Failed to load metadata: {e}")
194
+ time.sleep(60)
195
+ continue
196
+
197
+ if path_mapping is None:
198
+ path_mapping = build_path_mapping(fs)
199
+
200
+ all_books = set(df_books['book_id'].tolist())
201
+
202
+ # Get list of processed books
203
+ df_processed = pd.read_sql("SELECT DISTINCT book_id FROM pages", engine)
204
+ processed_books = set(df_processed['book_id'].tolist())
205
+
206
+ pending_books = list(all_books - processed_books)
207
+ print(f"Total books: {len(all_books)}, Processed: {len(processed_books)}, Pending: {len(pending_books)}")
208
+
209
+ if not pending_books:
210
+ print("All books processed. Creating full-text index if it doesn't exist...")
211
+ with engine.begin() as conn:
212
+ conn.execute(text("CREATE INDEX IF NOT EXISTS pages_body_idx ON pages USING GIN (to_tsvector('arabic', body));"))
213
+ print("Index created. Sleeping for 1 hour...")
214
+ time.sleep(3600)
215
+ continue
216
+
217
+ # If we have pending books, drop the index for faster ingestion
218
+ with engine.begin() as conn:
219
+ conn.execute(text("DROP INDEX IF EXISTS pages_body_idx;"))
220
+
221
+ # Prepare arguments for multiprocessing
222
+ tasks = [(b, path_mapping.get(b)) for b in pending_books]
223
+
224
+ # Process books concurrently with ThreadPoolExecutor
225
+ with ThreadPoolExecutor(max_workers=5) as executor:
226
+ executor.map(process_wrapper, tasks)
227
+
228
+ print("Batch finished. Checking again...")
229
+ time.sleep(5)
230
+
231
+ except Exception as e:
232
+ print(f"Worker Error: {e}")
233
+ time.sleep(60)
234
+
235
+ if __name__ == "__main__":
236
+ time.sleep(10)
237
+ run_worker()
init.sql ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE EXTENSION IF NOT EXISTS ai CASCADE;
2
+
3
+ -- 1. Authors
4
+ CREATE TABLE IF NOT EXISTS authors (
5
+ author_id INT PRIMARY KEY,
6
+ name_ar TEXT,
7
+ death_hijri INT,
8
+ death_hijri_text TEXT,
9
+ alpha_sort TEXT,
10
+ biography TEXT
11
+ );
12
+
13
+ -- 2. Categories
14
+ CREATE TABLE IF NOT EXISTS categories (
15
+ category_id INT PRIMARY KEY,
16
+ category_name_ar TEXT,
17
+ name_en TEXT,
18
+ sort_order INT
19
+ );
20
+
21
+ -- 3. Books
22
+ CREATE TABLE IF NOT EXISTS books (
23
+ book_id INT PRIMARY KEY,
24
+ shamela_id INT,
25
+ title_ar TEXT,
26
+ book_type INT,
27
+ book_type_label TEXT,
28
+ category_id INT REFERENCES categories(category_id),
29
+ main_author_id INT REFERENCES authors(author_id),
30
+ main_author_name_ar TEXT,
31
+ main_author_death_hijri INT,
32
+ main_author_death_hijri_text TEXT,
33
+ authors_text TEXT,
34
+ hijri_era TEXT,
35
+ printed BOOLEAN,
36
+ is_hidden BOOLEAN,
37
+ parent_id INT,
38
+ group_id INT,
39
+ version_major INT,
40
+ version_minor INT,
41
+ betaka_text TEXT,
42
+ meta JSONB,
43
+ volume_count_observed INT,
44
+ has_multi_part BOOLEAN,
45
+ authors_json JSONB
46
+ );
47
+
48
+ -- Book Authors (Many-to-Many relation between books and authors)
49
+ CREATE TABLE IF NOT EXISTS book_authors (
50
+ book_id INT REFERENCES books(book_id),
51
+ author_id INT REFERENCES authors(author_id),
52
+ role TEXT,
53
+ name_ar TEXT,
54
+ death_hijri INT,
55
+ PRIMARY KEY (book_id, author_id)
56
+ );
57
+
58
+ -- 4. Pages
59
+ CREATE TABLE IF NOT EXISTS pages (
60
+ page_id BIGINT PRIMARY KEY,
61
+ book_id INT REFERENCES books(book_id),
62
+ shamela_page_id INT,
63
+ part TEXT,
64
+ page_num INT,
65
+ sequence_num INT,
66
+ body TEXT,
67
+ footnotes TEXT,
68
+ hints TEXT,
69
+ services_raw JSONB
70
+ );
71
+
72
+ -- Index for Full-Text Search on Arabic text
73
+ CREATE INDEX IF NOT EXISTS pages_body_idx ON pages USING GIN (to_tsvector('arabic', body));
74
+
75
+ -- 5. Table of Contents (TOC)
76
+ CREATE TABLE IF NOT EXISTS toc (
77
+ title_id BIGINT PRIMARY KEY,
78
+ book_id INT REFERENCES books(book_id),
79
+ page_id BIGINT REFERENCES pages(page_id),
80
+ parent_id BIGINT REFERENCES toc(title_id),
81
+ shamela_title_id INT,
82
+ title_text TEXT
83
+ );
84
+
85
+ -- 6. Other Metadata Tables (Narrators, Quran, Roots, Xrefs)
86
+ -- We use JSONB for flexible schema mapping until exact column definitions are parsed
87
+
88
+ CREATE TABLE IF NOT EXISTS quran_verses (
89
+ verse_id INT PRIMARY KEY,
90
+ surah_id INT,
91
+ ayah_id INT,
92
+ text_ar TEXT,
93
+ data JSONB
94
+ );
95
+
96
+ CREATE TABLE IF NOT EXISTS narrators (
97
+ narrator_id INT PRIMARY KEY,
98
+ name_ar TEXT,
99
+ data JSONB
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS root_dictionary (
103
+ root_id INT PRIMARY KEY,
104
+ token TEXT,
105
+ data JSONB
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS hadith_xrefs (
109
+ id SERIAL PRIMARY KEY,
110
+ book_id INT REFERENCES books(book_id),
111
+ page_id BIGINT REFERENCES pages(page_id),
112
+ data JSONB
113
+ );
114
+
115
+ CREATE TABLE IF NOT EXISTS tafsir_xrefs (
116
+ id SERIAL PRIMARY KEY,
117
+ book_id INT REFERENCES books(book_id),
118
+ page_id BIGINT REFERENCES pages(page_id),
119
+ data JSONB
120
+ );
121
+
122
+ CREATE TABLE IF NOT EXISTS page_isnads (
123
+ id SERIAL PRIMARY KEY,
124
+ book_id INT REFERENCES books(book_id),
125
+ page_id BIGINT REFERENCES pages(page_id),
126
+ narrator_id INT REFERENCES narrators(narrator_id),
127
+ data JSONB
128
+ );
mcp_server.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uvicorn
3
+ from mcp.server.fastmcp import FastMCP
4
+ from sqlalchemy import create_engine, text
5
+
6
+ # 1. Database Connection (Optimized for 2 CPU / 16GB RAM)
7
+ DB_URI = os.getenv("DB_URI", "postgresql://postgres:password@localhost:5432/postgres")
8
+ # Pool size 5 prevents too many context switches on 2 vCPUs
9
+ engine = create_engine(DB_URI, pool_size=5, max_overflow=10)
10
+
11
+ # 2. Initialize FastMCP
12
+ mcp = FastMCP("Shamela4")
13
+
14
+ def fetch_data(query: str, params: dict = {}):
15
+ try:
16
+ with engine.connect() as conn:
17
+ result = conn.execute(text(query), params)
18
+ return [dict(row._mapping) for row in result]
19
+ except Exception as e:
20
+ return {"error": str(e)}
21
+
22
+ def fetch_one(query: str, params: dict = {}):
23
+ try:
24
+ with engine.connect() as conn:
25
+ result = conn.execute(text(query), params).fetchone()
26
+ if result:
27
+ return dict(result._mapping)
28
+ return None
29
+ except Exception as e:
30
+ return {"error": str(e)}
31
+
32
+ @mcp.tool()
33
+ def get_database_stats() -> dict:
34
+ """Get database statistics: counts of books, authors, categories, and pages."""
35
+ try:
36
+ with engine.connect() as conn:
37
+ books_count = conn.execute(text("SELECT COUNT(book_id) FROM books")).scalar() or 0
38
+ authors_count = conn.execute(text("SELECT COUNT(author_id) FROM authors")).scalar() or 0
39
+ categories_count = conn.execute(text("SELECT COUNT(category_id) FROM categories")).scalar() or 0
40
+ pages_count = conn.execute(text("SELECT COUNT(page_id) FROM pages")).scalar() or 0
41
+ return {
42
+ "total_categories": categories_count,
43
+ "total_authors": authors_count,
44
+ "total_books": books_count,
45
+ "total_pages": pages_count,
46
+ }
47
+ except Exception as e:
48
+ return {"error": str(e)}
49
+
50
+ @mcp.tool()
51
+ def search_categories(limit: int = 50) -> list:
52
+ """Get list of categories in the library."""
53
+ limit = min(limit, 100) # Hard limit
54
+ return fetch_data("SELECT category_id, category_name_ar, name_en FROM categories ORDER BY sort_order LIMIT :limit", {"limit": limit})
55
+
56
+ @mcp.tool()
57
+ def search_authors(search_term: str, limit: int = 10) -> list:
58
+ """Search for authors by their Arabic name."""
59
+ limit = min(limit, 20)
60
+ query = "SELECT author_id, name_ar, death_hijri FROM authors WHERE name_ar LIKE :search ORDER BY death_hijri LIMIT :limit"
61
+ return fetch_data(query, {"search": f"%{search_term}%", "limit": limit})
62
+
63
+ @mcp.tool()
64
+ def search_books(title_or_author: str, limit: int = 10) -> list:
65
+ """Search for books by title or author name."""
66
+ limit = min(limit, 20)
67
+ query = """
68
+ SELECT book_id, title_ar, main_author_name_ar, main_author_death_hijri
69
+ FROM books
70
+ WHERE title_ar LIKE :search OR main_author_name_ar LIKE :search
71
+ ORDER BY book_id LIMIT :limit
72
+ """
73
+ return fetch_data(query, {"search": f"%{title_or_author}%", "limit": limit})
74
+
75
+ @mcp.tool()
76
+ def get_book_toc(book_id: int) -> list:
77
+ """Get the Table of Contents (Daftar Isi) for a specific book_id."""
78
+ query = "SELECT title_id, title_text, page_id, parent_id FROM toc WHERE book_id = :book_id ORDER BY title_id"
79
+ return fetch_data(query, {"book_id": book_id})
80
+
81
+ @mcp.tool()
82
+ def search_text_in_books(query: str, limit: int = 5) -> list:
83
+ """
84
+ Full-text search inside book pages.
85
+ Use this to find specific Arabic text/words inside the books.
86
+ Returns highly relevant text snippets (highlights).
87
+ """
88
+ limit = min(limit, 10) # Strict limit for efficiency on 16GB RAM
89
+
90
+ # Use websearch_to_tsquery for efficient and safe natural language parsing
91
+ sql = """
92
+ SELECT p.book_id, p.page_num, p.sequence_num, b.title_ar as book_title,
93
+ ts_headline('arabic', p.body, websearch_to_tsquery('arabic', :query)) as snippet
94
+ FROM pages p
95
+ JOIN books b ON p.book_id = b.book_id
96
+ WHERE to_tsvector('arabic', p.body) @@ websearch_to_tsquery('arabic', :query)
97
+ ORDER BY p.book_id, p.sequence_num
98
+ LIMIT :limit
99
+ """
100
+ return fetch_data(sql, {"query": query, "limit": limit})
101
+
102
+ @mcp.tool()
103
+ def get_page_content(book_id: int, page_num: int) -> dict:
104
+ """Retrieve the exact, full text of a specific page number in a book."""
105
+ query = "SELECT page_num, body, footnotes FROM pages WHERE book_id = :book_id AND page_num = :page_num LIMIT 1"
106
+ return fetch_one(query, {"book_id": book_id, "page_num": page_num})
107
+
108
+ if __name__ == "__main__":
109
+ print("Starting Shamela4 FastMCP Server with SSE on port 8001...")
110
+ mcp.run(transport="sse", host="0.0.0.0", port=8001)
nginx.conf ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ worker_processes 1;
2
+ pid /tmp/nginx.pid;
3
+
4
+ events {
5
+ worker_connections 1024;
6
+ }
7
+
8
+ http {
9
+ include mime.types;
10
+ default_type application/octet-stream;
11
+
12
+ client_body_temp_path /tmp/client_body;
13
+ proxy_temp_path /tmp/proxy_temp;
14
+ fastcgi_temp_path /tmp/fastcgi_temp;
15
+ uwsgi_temp_path /tmp/uwsgi_temp;
16
+ scgi_temp_path /tmp/scgi_temp;
17
+ access_log /tmp/access.log;
18
+ error_log /tmp/error.log;
19
+
20
+ sendfile on;
21
+ keepalive_timeout 65;
22
+
23
+ server {
24
+ listen 7860;
25
+ server_name localhost;
26
+
27
+ # Route /api and /docs to FastAPI (Port 8000)
28
+ location /api {
29
+ proxy_pass http://127.0.0.1:8000;
30
+ proxy_set_header Host $host;
31
+ proxy_set_header X-Real-IP $remote_addr;
32
+ }
33
+
34
+ location /docs {
35
+ proxy_pass http://127.0.0.1:8000;
36
+ proxy_set_header Host $host;
37
+ proxy_set_header X-Real-IP $remote_addr;
38
+ }
39
+
40
+ location /openapi.json {
41
+ proxy_pass http://127.0.0.1:8000;
42
+ proxy_set_header Host $host;
43
+ proxy_set_header X-Real-IP $remote_addr;
44
+ }
45
+
46
+ # MCP SSE Endpoints
47
+ location /sse {
48
+ proxy_pass http://127.0.0.1:8001;
49
+ proxy_set_header Host $host;
50
+ proxy_set_header X-Real-IP $remote_addr;
51
+
52
+ # SSE specific headers to keep connection alive
53
+ proxy_set_header Connection '';
54
+ proxy_http_version 1.1;
55
+ chunked_transfer_encoding off;
56
+ proxy_buffering off;
57
+ proxy_cache off;
58
+ proxy_read_timeout 24h;
59
+ }
60
+
61
+ location /messages {
62
+ proxy_pass http://127.0.0.1:8001;
63
+ proxy_set_header Host $host;
64
+ proxy_set_header X-Real-IP $remote_addr;
65
+ }
66
+
67
+ # Route everything else to Streamlit (Port 8501)
68
+ location / {
69
+ proxy_pass http://127.0.0.1:8501;
70
+ proxy_set_header Host $host;
71
+ proxy_set_header X-Real-IP $remote_addr;
72
+
73
+ # WebSocket support for Streamlit
74
+ proxy_http_version 1.1;
75
+ proxy_set_header Upgrade $http_upgrade;
76
+ proxy_set_header Connection "upgrade";
77
+ }
78
+ }
79
+ }
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit
2
+ psycopg2-binary
3
+ sqlalchemy
4
+ datasets
5
+ polars
6
+ pandas
7
+ pyarrow
8
+ huggingface-hub
9
+ fastapi
10
+ uvicorn
11
+ beautifulsoup4
12
+ mcp
run.sh ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Run standard postgres entrypoint in background
5
+ # This will initialize the DB (running init.sql) if it's the first time
6
+ echo "Starting PostgreSQL in background..."
7
+ bash /docker-entrypoint.sh postgres &
8
+
9
+ # Wait for PostgreSQL to become available
10
+ echo "Waiting for PostgreSQL to start..."
11
+ until pg_isready -h localhost -p 5432 -U postgres; do
12
+ sleep 2
13
+ done
14
+ echo "PostgreSQL is ready!"
15
+
16
+ # Start the Streamlit application in the foreground
17
+ echo "Starting Background Ingest Worker..."
18
+ python3 -u ingest_worker.py > /tmp/ingest.log 2>&1 &
19
+
20
+ echo "Starting FastAPI Server..."
21
+ uvicorn api:app --host 0.0.0.0 --port 8000 &
22
+
23
+ echo "Starting Streamlit..."
24
+ streamlit run app.py --server.port 8501 --server.address 0.0.0.0 &
25
+
26
+ echo "Starting MCP SSE Server..."
27
+ python3 -u mcp_server.py > /tmp/mcp.log 2>&1 &
28
+
29
+ echo "Starting Nginx Proxy..."
30
+ nginx -g "daemon off;"