mostly a hosted interfaces rewrite and better documentation
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
KJV_SERMON_DB="/home/tyler/kjv-api/sermon-analyzer/data/sermons.db"
|
||||
KJV_SERMON_DB="./data/sermons.db"
|
||||
KJV_SECRET_API_KEY="testing"
|
||||
|
||||
@@ -35,6 +35,7 @@ logging.basicConfig(
|
||||
)
|
||||
|
||||
logging.info(f"Started: {timestamp_runtime}")
|
||||
logging.info(f"Using Sermon Database: {SERMON_DB}")
|
||||
|
||||
try:
|
||||
sanity_check.check_sanity()
|
||||
@@ -687,6 +688,9 @@ def idx(shortcode=""):
|
||||
logging.info(f"/{shortcode}")
|
||||
return send_file("html/index.html")
|
||||
|
||||
@app.get("/fe")
|
||||
def frontend():
|
||||
return send_file('kjv_fe.html')
|
||||
|
||||
@app.get("/salvation") # a web page about going to heaven
|
||||
def salvation():
|
||||
|
||||
Binary file not shown.
+931
@@ -0,0 +1,931 @@
|
||||
# kjv_api.py
|
||||
from flask import (
|
||||
Flask,
|
||||
request,
|
||||
jsonify,
|
||||
)
|
||||
import random
|
||||
from datetime import date # used for daily verse
|
||||
import sqlite3
|
||||
import string # for literals shortcut
|
||||
import json # for dictionary
|
||||
import os
|
||||
import socket
|
||||
import json
|
||||
from tools import sanity_check
|
||||
from dotenv import load_dotenv
|
||||
# this is python-dotenv package in pip3, 'dotenv' with pip doesn't work!
|
||||
load_dotenv()
|
||||
|
||||
|
||||
kjv_api = Flask(__name__, template_folder="html")
|
||||
design_goals = """
|
||||
GOALS GOALS GOALS GOALS GOALS
|
||||
|
||||
This rewrite should end up with a very flat and (ideally) stateless API...
|
||||
EXCEPT for integration with the sermon database, which is a separate project.
|
||||
AND the semantic search, which is also a separate project.
|
||||
|
||||
Later, those projects may be integrated into this one (mostly via database merge),
|
||||
but for now, they are separate.
|
||||
|
||||
All endpoints that return verses should return a consistent JSON format, an array containing verse objects with the following fields:
|
||||
- bookname: the name of the book
|
||||
-- A string
|
||||
- book_id: the internal book_id (1-66)
|
||||
-- An integer
|
||||
- chapter: the chapter number
|
||||
-- An integer
|
||||
- verse: the verse number
|
||||
-- An integer
|
||||
- text: the verse text
|
||||
-- A string
|
||||
- verse_id: the internal verse_id
|
||||
-- A string
|
||||
- full_ref: the full reference in the format "book chapter:verse"
|
||||
-- A string
|
||||
- chapter_length: the number of verses in the chapter
|
||||
-- An integer
|
||||
- book_length: the number of chapters in the book
|
||||
-- An integer
|
||||
- definitions: the definitions of the words in the verse (if available)
|
||||
-- An array of words by the key of the word, the value containing the definitions of each word
|
||||
|
||||
AND if the user has a valid token and a network connection, the following fields:
|
||||
- similar_verses: an array of semantically similar verses (if available)
|
||||
-- An array of verse objects
|
||||
- sermons: an array of sermons that reference the verse (if available)
|
||||
-- An array of sermon objects
|
||||
(this should be filtered by the user's key, so they can only see sermons they have access to)
|
||||
|
||||
An example response:
|
||||
[
|
||||
{
|
||||
"bookname": "Genesis",
|
||||
"book_id": 1,
|
||||
"chapter": 1,
|
||||
"verse": 1,
|
||||
"text": "In the beginning God created the heaven and the earth.",
|
||||
"verse_id": "01001001",
|
||||
"full_ref": "Genesis 1:1",
|
||||
"chapter_length": 31,
|
||||
"book_length": 50,
|
||||
"definitions": {
|
||||
"beginning": "The first part of time; the commencement; the first part of a series or sequence; the first part of a course.",
|
||||
"God": "The Supreme Being; Jehovah; the eternal and infinite spirit, the creator, and the sovereign of the universe.",
|
||||
"created": "Formed; made; produced; caused to exist; brought into being.",
|
||||
"heaven": "The region of the air; the higher regions or the atmosphere; the place where the celestial orbs revolve; the firmament; the sky."
|
||||
},
|
||||
"similar_verses": [
|
||||
{
|
||||
"bookname": "Genesis",
|
||||
"book_id": 1,
|
||||
"chapter": 1,
|
||||
"verse": 2,
|
||||
"text": "And the earth was without form, and void; and darkness was upon the face of the deep. And the Spirit of God moved upon the face of the waters.",
|
||||
"verse_id": "01001002",
|
||||
"full_ref": "Genesis 1:2",
|
||||
"chapter_length": 31,
|
||||
"book_length": 50
|
||||
},
|
||||
{
|
||||
"bookname": "Genesis",
|
||||
"book_id": 1,
|
||||
"chapter": 1,
|
||||
"verse": 3,
|
||||
"text": "And God said, Let there be light: and there was light.",
|
||||
"verse_id": "01001003",
|
||||
"full_ref": "Genesis 1:3",
|
||||
"chapter_length": 31,
|
||||
"book_length": 50
|
||||
}
|
||||
],
|
||||
"sermons": [
|
||||
{
|
||||
"title": "The Creation of the World",
|
||||
"source_url": "https://example.com/sermon/1",
|
||||
"url": "https://example.com/sermon/1",
|
||||
"preacher": "John Doe"
|
||||
},
|
||||
{
|
||||
"title": "The Beginning of All Things",
|
||||
"source_url": "https://example.com/sermon/2",
|
||||
"url": "https://example.com/sermon/2",
|
||||
"preacher": "John Smith"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
All endpoints that return sermons should return a consistent JSON format, an array containing sermon objects with the following fields:
|
||||
- title: the title of the sermon
|
||||
-- A string
|
||||
- source_url: the URL of the source sermon
|
||||
-- A string
|
||||
- url: the URL of the cached sermon in the API
|
||||
-- A string
|
||||
- preacher: the name of the preacher
|
||||
-- A string
|
||||
- church_name: the name of the church
|
||||
-- A string
|
||||
- denomination: the denomination of the church
|
||||
-- A string
|
||||
- filename/sermon_id: the filename of the sermon (the filename variable name should be changed to sermon_id)
|
||||
-- A string
|
||||
- verses: an array of verse objects that the sermon references
|
||||
-- An array of verse objects
|
||||
- similar_sermons: an array of semantically similar sermons (if available)
|
||||
-- An array of sermon objects
|
||||
- text: the full text of the sermon
|
||||
-- An array of objects containing:
|
||||
-- timestamp: the timestamp of when the text was spoken in the recording
|
||||
-- text: the text of the sermon at that timestamp
|
||||
-- matches: an array of verse objects that the text references at that timestamp
|
||||
- license: the license of the sermon
|
||||
-- A string
|
||||
- language: the language of the sermon
|
||||
-- A string
|
||||
- date: the date the sermon was given
|
||||
-- A string
|
||||
- duration: the duration of the sermon
|
||||
-- A string
|
||||
- views: the number of views the sermon has received on the API
|
||||
-- An integer
|
||||
- likes: the number of likes the sermon has received on the API
|
||||
-- An integer
|
||||
- notes: the notes of the sermon provided by the preacher or church
|
||||
-- A string
|
||||
- comments: the comments on the sermon provided by users
|
||||
-- An array of objects containing:
|
||||
-- user: the username of the commenter
|
||||
-- comment: the comment text
|
||||
-- timestamp: the timestamp of the comment
|
||||
|
||||
|
||||
An example sermon response:
|
||||
[
|
||||
{
|
||||
"title": "The Power of Love",
|
||||
"source_url": "https://example.com/sermon/3",
|
||||
"url": "https://example.com/sermon/3",
|
||||
"preacher": "John Smith",
|
||||
"church_name": "Community Church",
|
||||
"denomination": "Non-Denominational",
|
||||
"sermon_id": "3",
|
||||
"verses": [
|
||||
{
|
||||
"bookname": "John",
|
||||
"book_id": 43,
|
||||
"chapter": 3,
|
||||
"verse": 16,
|
||||
"text": "For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.",
|
||||
"verse_id": "43003016",
|
||||
"full_ref": "John 3:16",
|
||||
"chapter_length": 50,
|
||||
"book_length": 21
|
||||
},
|
||||
{
|
||||
"bookname": "1 Corinthians",
|
||||
"book_id": 46,
|
||||
"chapter": 13,
|
||||
"verse": 4,
|
||||
"text": "Charity suffereth long, and is kind; charity envieth not; charity vaunteth not itself, is not puffed up.",
|
||||
"verse_id": "46013004",
|
||||
"full_ref": "1 Corinthians 13:4",
|
||||
"chapter_length": 16,
|
||||
"book_length": 21
|
||||
}
|
||||
],
|
||||
"similar_sermons": [
|
||||
{
|
||||
"title": "The Love of God",
|
||||
"source_url": "https://example.com/sermon/4",
|
||||
"url": "https://example.com/sermon/4",
|
||||
"preacher": "Jane Doe"
|
||||
},
|
||||
{
|
||||
"title": "Love Your Neighbor",
|
||||
"source_url": "https://example.com/sermon/5",
|
||||
"url": "https://example.com/sermon/5",
|
||||
"preacher": "Mark Johnson"
|
||||
}
|
||||
],
|
||||
"text": [
|
||||
{
|
||||
"timestamp": "00:00:00",
|
||||
"text": "Welcome to today's sermon on the power of love.",
|
||||
"matches": [
|
||||
{
|
||||
"bookname": "John",
|
||||
"book_id": 43,
|
||||
"chapter": 3,
|
||||
"verse": 16,
|
||||
"text": "For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.",
|
||||
"verse_id": "43003016",
|
||||
"full_ref": "John 3:16",
|
||||
"chapter_length": 50,
|
||||
"book_length": 21
|
||||
},
|
||||
{
|
||||
"bookname": "1 Corinthians",
|
||||
"book_id": 46,
|
||||
"chapter": 13,
|
||||
"verse": 4,
|
||||
"text": "Charity suffereth long, and is kind; charity envieth not; charity vaunteth not itself, is not puffed up.",
|
||||
"verse_id": "46013004",
|
||||
"full_ref": "1 Corinthians 13:4",
|
||||
"chapter_length": 16,
|
||||
"book_length": 21
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"license": "CC BY-NC-ND 4.0",
|
||||
"language": "English",
|
||||
"date": "2022-01-01",
|
||||
"duration": "00:30:00",
|
||||
"views": 100,
|
||||
"likes": 50,
|
||||
"notes": "This sermon explores the power of love in our lives and how it can transform us.",
|
||||
"comments": [
|
||||
{
|
||||
"user": "user1",
|
||||
"comment": "Great sermon!",
|
||||
"timestamp": "2022-01-01 12:00:00"
|
||||
},
|
||||
{
|
||||
"user": "user2",
|
||||
"comment": "Thank you for sharing this message.",
|
||||
"timestamp": "2022-01-01 12:30:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
Sermon Endpoints:
|
||||
- Add endpoint for listing all sermons
|
||||
-- Endpoint name: /sermons
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for listing all preachers
|
||||
-- Endpoint name: /preachers
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for listing all churches
|
||||
-- Endpoint name: /churches
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for listing all denominations
|
||||
-- Endpoint name: /denominations
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching sermons by scripture reference
|
||||
-- Endpoint name: /sermon/search/<ref>
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching sermons by preacher
|
||||
-- Endpoint name: /sermons/preacher/<preacher>
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching sermons by church
|
||||
-- Endpoint name: /sermons/church/<church_name>
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching sermons by denomination
|
||||
-- Endpoint name: /sermons/denomination/<denomination>
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching sermons by filename
|
||||
-- Endpoint name: /sermon/<filename>
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching sermons by URL
|
||||
-- Endpoint name: /sermon/url/<url>
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching sermons by keyword
|
||||
-- Endpoint name: /sermon/search/<keyword>
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching sermon content by semantic search
|
||||
-- Endpoint name: /sermon/search/semantic
|
||||
-- HTTP Method: POST
|
||||
- Add endpoint for searching sermons by random sermon
|
||||
-- Endpoint name: /sermon/random
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching sermons by sermon of the day
|
||||
-- Endpoint name: /sermon/votd
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching sermons by similar sermons
|
||||
-- Endpoint name: /sermon/similar/<filename>
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for removing sermons
|
||||
-- Endpoint name: /sermon/remove/<filename>
|
||||
-- HTTP Method: DELETE
|
||||
- Add endpoint for adding sermons
|
||||
-- Endpoint name: /sermon/add
|
||||
-- HTTP Method: POST
|
||||
- Add endpoint for updating sermons
|
||||
-- Endpoint name: /sermon/update/<filename>
|
||||
-- HTTP Method: PUT
|
||||
|
||||
|
||||
Semantic Search Integration:
|
||||
- Add endpoint for semantic search
|
||||
-- Endpoint name: /search/semantic
|
||||
-- HTTP Method: POST
|
||||
-- Request Body: {"query":"myquerystring"}
|
||||
-- Result: JSON object with search results as verse objects
|
||||
|
||||
|
||||
|
||||
General API Improvements (see old app.py for more details):
|
||||
(These should be implemented on the client and locally served but we still need to provide a standard)
|
||||
(and can provide a fallback that just serves the json)
|
||||
These can probaby call from the reference implementation we will make freely available.
|
||||
- Add endpoint for serving the API documentation
|
||||
-- Endpoint name: /docs
|
||||
-- HTTP Method: GET
|
||||
-- Result: HTML page with API documentation
|
||||
- Add endpoint for searching by verse_id
|
||||
-- Endpoint name: /verse_id/<verse_id>
|
||||
-- HTTP Method: GET
|
||||
- Add endpoint for searching by verse reference
|
||||
- Add endpoint for searching by keyword
|
||||
- Add endpoint for searching by random verse
|
||||
- Add endpoint for searching by verse of the day
|
||||
- Add endpoint for searching by definition
|
||||
- Add endpoint for searching by semantically
|
||||
- Add endpoint for searching by similar verses
|
||||
|
||||
|
||||
Key Control Implementation:
|
||||
(All actions require a valid token, which is stored in the database and can be revoked at any time)
|
||||
(Admins can generate new keys, revoke keys, and view usage metrics by giving a new user a unique single use token)
|
||||
(Keys contain a prefix that identifies the key group subscriber, and a suffix that is a random string of characters per user of the key group)
|
||||
(this is psuedo-secret and can be sniffed, but will rotate at a regualr intervals)
|
||||
(This should allow for subscriber teirs, and rate limiting per key)
|
||||
- Add endpoint for key generation
|
||||
-- Endpoint name: /key
|
||||
-- HTTP Method: POST
|
||||
-- Reuqest Body: {"action":"generate", "token":"mytokenstring"}
|
||||
-- Result: JSON object with new key
|
||||
-- Result Example: {"key":"mykeystring"}
|
||||
- Add endpoint for key revocation
|
||||
-- Endpoint name: /key
|
||||
-- HTTP Method: DELETE
|
||||
-- Request Body: {"action":"revoke", "token":"mytokenstring"}
|
||||
-- Result: JSON object with success message
|
||||
-- Result Example: {"success":"Key revoked"}
|
||||
- Add endpoint for key validation
|
||||
-- Endpoint name: /key
|
||||
-- HTTP Method: POST
|
||||
-- Request Body: {"action":"validate", "token":"userkeystring"}
|
||||
-- Result: JSON object with validation status
|
||||
-- Result Example: {"valid":true}
|
||||
- Add endpoint for listing active keys and their usage metrics
|
||||
-- Endpoint name: /keys
|
||||
-- HTTP Method: GET
|
||||
-- Request Body: {"action":"list", "token":"mytokenstring"}
|
||||
-- Result: JSON object with list of keys and their usage metrics (defined later)
|
||||
-- Result Example: {"keys":[{"key":"mykeystring","usage":100},{"key":"myotherkeystring","usage":50}]}
|
||||
|
||||
|
||||
Database Improvements:
|
||||
- Merge all databases into one
|
||||
- current databases are:
|
||||
-- KJV Bible database
|
||||
-- Sermon database
|
||||
-- Semantic search database
|
||||
- Evaluate using postgresql instead of sqlite
|
||||
|
||||
|
||||
Sermon Analysis and Feature Improvements:
|
||||
- Implement sentiment analysis on sermon text (is it positive, negative, encouraging, neutral?)
|
||||
- Implement keyword extraction from sermon text (will be needed as database grows)
|
||||
- Implement topic modeling on sermon text (what is the sermon about?)
|
||||
- Implement named entity/event recognition on sermon text
|
||||
- people, places, organizations, dates, etc.
|
||||
- current events, historical events, etc.
|
||||
- Implement sermon text summarization (MAYBE NOT! Could be accidentally heretical!)
|
||||
- Implement sermon text translation (MAYBE NOT! Could be accidentally heretical!)
|
||||
- Implement sermon text language detection
|
||||
- Store all analysis results in the sermon database
|
||||
- Store a copy of the sermon source file on local storage for user playback
|
||||
- Store a copy of the sermon audio file on local storage for user playback (low bandwidth/offline mode)
|
||||
|
||||
|
||||
Abuse Prevention:
|
||||
- Implement abuse detection for high volume requests
|
||||
- Implement rate limiting
|
||||
- Implement key-based access control for compute-intensive operations
|
||||
- Implement notification system for abuse detection
|
||||
|
||||
|
||||
Logging and Metrics:
|
||||
- Implement logging for all errors
|
||||
- Implement logging for all queries
|
||||
- we want to know what users are doing, what they're searching for, and what they're not finding
|
||||
- we want to know what errors are happening, and when they're happening
|
||||
- we want to know how many users are using the service, and how often
|
||||
|
||||
|
||||
General Code Improvements:
|
||||
- Implement a more verbose and readable code style
|
||||
- Implement a more modular code structure
|
||||
- Define an OpenAPI schema for the API (Critical)
|
||||
|
||||
"""
|
||||
|
||||
SERMON_DB = os.getenv("KJV_SERMON_DB")
|
||||
# currently it would require a differnt instance of the API to run with a different sermon database
|
||||
# but these can be rolled into one database later
|
||||
|
||||
SECRET_API_KEY = os.getenv("KJV_SECRET_API_KEY") # this should end up being in the database
|
||||
# we want to be able to revoke keys, and have multiple keys for different users
|
||||
# this should be moved to the database, and the database should be able to handle multiple keys
|
||||
# and revoke them as needed. This is currently just for testing purposes.
|
||||
|
||||
# makes data fetched from db easier to process
|
||||
# might ditch this if we move to postgres
|
||||
def dict_factory(cursor, row):
|
||||
fields = [column[0] for column in cursor.description]
|
||||
return {key: value for key, value in zip(fields, row)}
|
||||
|
||||
# prevent editor complaints about these being undefined
|
||||
kjv_cur = None
|
||||
sermon_cur = None
|
||||
# where we are in our sqlite db
|
||||
|
||||
if __name__ == "__main__":
|
||||
# single user mode, running without uwsgi, for testing
|
||||
usermode: str = "Single"
|
||||
kjv_cur = sqlite3.connect("data/kjv.db", check_same_thread=False).cursor()
|
||||
# really as long as you aren't trying to write to the db, you can get away with this
|
||||
if SERMON_DB:
|
||||
sermon_con = sqlite3.connect(SERMON_DB, check_same_thread=False)
|
||||
sermon_con.row_factory = dict_factory
|
||||
sermon_cur = sermon_con.cursor()
|
||||
else:
|
||||
# you're running with uwsgi, as you should in a production environment
|
||||
usermode: str = "Multi"
|
||||
kjv_cur = sqlite3.connect("data/kjv.db").cursor()
|
||||
if SERMON_DB:
|
||||
sermon_con = sqlite3.connect(SERMON_DB)
|
||||
sermon_con.row_factory = dict_factory
|
||||
sermon_cur = sermon_con.cursor()
|
||||
|
||||
if not kjv_cur:
|
||||
exit("CRITICAL: Error connecting to KJV Database")
|
||||
if not sermon_cur:
|
||||
print("ERROR: Error connecting to Sermon Database")
|
||||
|
||||
# this is a performance optimization, we don't want to have to query the database for this info every time
|
||||
# we can just cache it in memory
|
||||
cached: dict = {
|
||||
"verse_ids": [i[0] for i in kjv_cur.execute("SELECT id FROM fts_kjv;").fetchall()],
|
||||
"book_names": [
|
||||
i[0] for i in kjv_cur.execute("SELECT n FROM key_english").fetchall()
|
||||
],
|
||||
"chapter_list": [],
|
||||
"books_chapter_lengths": {},
|
||||
"json_dictionary": json.load(open("data/1828_Webster_KJV.json", "r")),
|
||||
}
|
||||
|
||||
# Fill the chapter list with unique chapters for seeking back and forward by index.
|
||||
# it is annoying to do it this way but makes it easier to seek back and forward by index
|
||||
# to be honest I forgot why I did it this way, but it works.
|
||||
[
|
||||
cached["chapter_list"].append(int(str(chap)[:-3]))
|
||||
for chap in cached.get("verse_ids")
|
||||
if int(str(chap)[:-3]) not in cached["chapter_list"]
|
||||
]
|
||||
cached["chapter_list"].sort()
|
||||
|
||||
for book in range(1, 67):
|
||||
cached["books_chapter_lengths"][book] = kjv_cur.execute(
|
||||
"SELECT c FROM fts_kjv where b = ? ORDER BY id DESC LIMIT 1;", (book,)
|
||||
).fetchone()[0]
|
||||
|
||||
# this was used for sharing verse groups with others, but will become a problem
|
||||
# with multiple independent instances of the API by local users.
|
||||
# It may be possible to share verse groups between users in the future by
|
||||
# having it be a free service, but for now it's disabled and not in the API spec.
|
||||
def generate_short_id():
|
||||
remain = random.randrange(614_656, 17_210_367) # resolves to length of 5
|
||||
chars = "0123456789ACEFHJKLMNPRTUVWXY"
|
||||
length = len(chars)
|
||||
result = ""
|
||||
while remain > 0:
|
||||
pos = remain % length
|
||||
remain = remain // length
|
||||
result = chars[pos] + result
|
||||
return result
|
||||
|
||||
# helper functions...
|
||||
def lookup_bookname(book_id):
|
||||
result = kjv_cur.execute(
|
||||
"SELECT n FROM key_english WHERE b = ?;",(book_id,)).fetchone()
|
||||
if result:
|
||||
result = result[0]
|
||||
return result
|
||||
|
||||
|
||||
def lookup_book_id(bookname) -> int:
|
||||
result = kjv_cur.execute(
|
||||
"SELECT b FROM key_english WHERE n = ?;",(bookname,)).fetchone()
|
||||
if result:
|
||||
result = result[0]
|
||||
return result
|
||||
|
||||
# fts is full text search, it's a virtual table that allows us to search the text of the bible quickly
|
||||
# without having to scan the entire database, it's a performance optimization, but it's a feature
|
||||
# of sqlite that we may have to find a workaround for if we move to postgres
|
||||
def lookup_fts(kwds):
|
||||
return kjv_cur.execute("SELECT * FROM fts_kjv(?)", (kwds,)).fetchall()
|
||||
|
||||
|
||||
def lookup_by_verse_id(start_verse, end_verse=None) -> list:
|
||||
# This is an internal function - we don't use placeholders because SQL injection should be impossible here
|
||||
if not end_verse:
|
||||
results = kjv_cur.execute(
|
||||
f"SELECT * FROM fts_kjv WHERE id = {start_verse};"
|
||||
).fetchall()
|
||||
else:
|
||||
results = kjv_cur.execute(
|
||||
f"SELECT * FROM fts_kjv WHERE id BETWEEN {start_verse} AND {end_verse};"
|
||||
).fetchall()
|
||||
return results
|
||||
|
||||
def get_sermons_by_verse_id(verse_id) -> list:
|
||||
if not sermon_cur:
|
||||
return
|
||||
return sermon_cur.execute(f"select * from verse_refs where verse_id={verse_id}").fetchall()
|
||||
|
||||
# this gives us a database key for a verse by book, chapter, and verse, because of the way the database is structured
|
||||
def get_verse_id(book_id, chapter: str, verse: str) -> str:
|
||||
# verse_id is 00000000 (00 000 000) (book + chapter + verse)
|
||||
chapterid = chapter.zfill(3)
|
||||
verseid = verse.zfill(3)
|
||||
if verseid == "000":
|
||||
verseid = "001" # Avoid verse_ids list IndexErrors on :- searches
|
||||
return str(book_id) + chapterid + verseid
|
||||
|
||||
# this gives us a human readable reference from a verse_id
|
||||
def get_ref_from_verse_id(verse_id):
|
||||
# verse_id is 00000000 (00 000 000) (book + chapter + verse)
|
||||
verse_id = str(verse_id).zfill(8)
|
||||
book_id = verse_id[:2]
|
||||
chapter = verse_id[2:5]
|
||||
verse = verse_id[5:8]
|
||||
bookname = kjv_cur.execute("SELECT n FROM key_english WHERE b = ?;",(book_id,)).fetchone()[0]
|
||||
return bookname + " " + chapter + ":" + verse
|
||||
|
||||
# this is for parsing a reference from a user query, it is complex but catches a lot of edge cases
|
||||
# and makes it easier to search the database, as well as improving security by cleaning the input
|
||||
def ref_input_cleaning(ref):
|
||||
valid_chars = (string.ascii_letters + string.digits + ":-,")
|
||||
ref = ref.replace(" ", "")
|
||||
if ref.count(":") != 1:
|
||||
if len(ref) == 0:
|
||||
return "Invalid Query"
|
||||
if ref[-1] in string.digits:
|
||||
ref += ":-"
|
||||
else:
|
||||
ref += "1:-"
|
||||
if ref.endswith(":"):
|
||||
ref += "-"
|
||||
if ref.count("-") > 1:
|
||||
return
|
||||
if not ref[-1].isdigit() and ref[-1] != "-":
|
||||
return
|
||||
ref = list(ref)
|
||||
# preserve book number if we can guess the first
|
||||
# character is a number and 3nd is a letter
|
||||
if ref[0].isdigit() and ref[1].isalpha():
|
||||
prefix = ref.pop(0) + " "
|
||||
else:
|
||||
prefix = ""
|
||||
# clean the rest of the reference
|
||||
cleaned_ref = []
|
||||
for character in ref:
|
||||
if character in valid_chars:
|
||||
cleaned_ref.append(character)
|
||||
# Was there a problem?
|
||||
if len(cleaned_ref) == 0:
|
||||
return
|
||||
else:
|
||||
return "".join(cleaned_ref)
|
||||
|
||||
# similar to ref_input_cleaning, but for search queries, it is more permissive because
|
||||
# sqlite3 has search operators that can be used to search the fts table
|
||||
def search_input_cleaning(query):
|
||||
valid_chars = string.ascii_letters + string.digits + "+^* "
|
||||
cleaned_query = []
|
||||
for character in query:
|
||||
if character in valid_chars:
|
||||
cleaned_query.append(character)
|
||||
return "".join(cleaned_query)
|
||||
|
||||
# this finds books if someone puts in a partial book name
|
||||
def ref_parse_book(maybe_book):
|
||||
for book in reversed(cached["book_names"]):
|
||||
if book.startswith(maybe_book):
|
||||
return book
|
||||
return False
|
||||
|
||||
# this is a helper function for the semantic search, it sends a query to the semantic search server
|
||||
# and returns the results. This is a separate project that will be integrated into this one later.
|
||||
def request_semantic_search(query):
|
||||
HOST = 'localhost'
|
||||
PORT = 1613
|
||||
data = {"query": query}
|
||||
|
||||
data_string = json.dumps(data).encode()
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect((HOST, PORT))
|
||||
s.sendall(data_string)
|
||||
data = b''
|
||||
while True:
|
||||
part = s.recv(1024)
|
||||
if not part:
|
||||
break
|
||||
data += part
|
||||
return json.loads(data.decode())
|
||||
|
||||
# this explains the semantic search endpoint for developers
|
||||
@kjv_api.get("/natural")
|
||||
def explain_natural():
|
||||
res = 'Submit a POST request to this endpoint with the following params in the body: '
|
||||
res += '{"token": "mytokenstring", "query" : "myquerystring"}'
|
||||
return jsonify({'error':res})
|
||||
|
||||
# this actually does the semantic search, it is a POST request because it is more secure
|
||||
# and we require a token to access it, this is a feature of the API that will be expanded
|
||||
# especially when we integrate the semantic search project into this one.
|
||||
@kjv_api.post("/natural") # needs valid token and query in request body
|
||||
def do_semantic_search():
|
||||
request_data = request.get_json()
|
||||
if request_data is None:
|
||||
# Handle error: No JSON data sent
|
||||
return jsonify({'error': 'Invalid request format'}), 400
|
||||
|
||||
token_string = request_data.get('token')
|
||||
query_string = request_data.get('query')
|
||||
if token_string is None:
|
||||
# Handle error: Missing token in request
|
||||
return jsonify({'error': 'Missing token in request body'}), 401
|
||||
|
||||
if token_string != SECRET_API_KEY:
|
||||
return jsonify({'error': "Invalid Authorization"})
|
||||
|
||||
result = request_semantic_search(query=query_string)
|
||||
return jsonify(result)
|
||||
|
||||
# this returns an internal verse_id by english scripture reference (1John5:7)
|
||||
@kjv_api.get("/ref/<ref>")
|
||||
def get_single_reference_verse_id(ref:str):
|
||||
ref = ref.split(",")[0]
|
||||
ref = ref.split("-")[0]
|
||||
# only want the first single ref, not a range, and not a group.
|
||||
ref = ref_input_cleaning(ref)
|
||||
maybe_chapter: str = ref.split(":")[0][3:]
|
||||
ref_chapter: str = ""
|
||||
for maybedigit in maybe_chapter:
|
||||
if maybedigit.isdigit():
|
||||
ref_chapter += maybedigit
|
||||
ref_verse = ref.split(":")[1]
|
||||
book_str: str = ref_input_cleaning(ref, string.ascii_letters)
|
||||
ref_bookname: str = ref_parse_book(book_str)
|
||||
ref_book_id: int = lookup_book_id(ref_bookname)
|
||||
if not ref_book_id:
|
||||
return
|
||||
return get_verse_id(ref_book_id, ref_chapter, ref_verse)
|
||||
|
||||
# this finds sermons that contain an english scripture reference (1John5:7)
|
||||
@kjv_api.get("/sermon/search/<ref>")
|
||||
def find_sermon_by_ref(ref:str=None):
|
||||
verse_id = get_single_reference_verse_id(ref=ref)
|
||||
if not sermon_cur:
|
||||
return jsonify({'error':"Sermon database not found"})
|
||||
sermons = get_sermons_by_verse_id(verse_id=verse_id)
|
||||
for sermon in sermons:
|
||||
sermon.update({'kjv':find_by_verseid(sermon.get("verse_id"))[4]})
|
||||
return jsonify(sermons)
|
||||
|
||||
# there are a lot of endpoints here, almost all of which are just database queries. Once the individial
|
||||
# endpoint names are decided, these can change of course.
|
||||
|
||||
@kjv_api.get("/sermons") # lists all the sermons in our database
|
||||
def list_sermons():
|
||||
return jsonify(sermon_cur.execute("select distinct title,url,denomination,preacher,church_name,filename from verse_refs").fetchall())
|
||||
|
||||
@kjv_api.get("/sermon/<filename>/verses") # show all matched info on a sermon by its filename (hash)
|
||||
def list_sermon_references(filename):
|
||||
return jsonify(sermon_cur.execute("select distinct verse_id,text from verse_refs where filename=?", (filename,)).fetchall())
|
||||
|
||||
@kjv_api.get("/preachers") # return a list of preachers stored in database, their denomination and church name
|
||||
def list_preachers():
|
||||
return jsonify(sermon_cur.execute("select distinct denomination,preacher,church_name from verse_refs").fetchall())
|
||||
|
||||
@kjv_api.get("/churches") # return a list of churches with sermons in the database
|
||||
def list_churches():
|
||||
return jsonify(sermon_cur.execute("select distinct denomination,church_name from verse_refs").fetchall())
|
||||
|
||||
@kjv_api.get("/denominations") # return a list of denominations included in the database
|
||||
def list_denominations():
|
||||
return jsonify(sermon_cur.execute("select distinct denomination from verse_refs").fetchall())
|
||||
|
||||
@kjv_api.get("/sermons/preacher/<preacher>") # show all sermons by a paticular preacher
|
||||
def find_sermons_by_preacher(preacher):
|
||||
return jsonify(sermon_cur.execute("select distinct title,url,denomination,preacher,church_name from verse_refs where preacher=?", (preacher,)).fetchall())
|
||||
|
||||
@kjv_api.get("/sermons/church/<church_name>") # show all sermons by a paticular church
|
||||
def find_sermons_by_church(church_name):
|
||||
return jsonify(sermon_cur.execute("select distinct title,url,denomination,preacher,church_name from verse_refs where church_name=?", (church_name,)).fetchall())
|
||||
|
||||
@kjv_api.get("/sermons/denomination/<denomination>") # show all sermons by a paticular denomination
|
||||
def find_sermons_by_denomination(denomination):
|
||||
return jsonify(sermon_cur.execute("select distinct title,url,denomination,preacher,church_name from verse_refs where denomination=?", (denomination,)).fetchall())
|
||||
|
||||
@kjv_api.get("/sermon/<filename>") # show general information about a sermon by filename (hash)
|
||||
def find_sermons_by_filename(filename):
|
||||
return jsonify(sermon_cur.execute("select distinct title,url,denomination,preacher,church_name from verse_refs where filename=?", (filename,)).fetchall())
|
||||
|
||||
@kjv_api.get("/sermon/url/<url>") # show general information about a sermon by url
|
||||
def find_sermons_by_url(url):
|
||||
return jsonify(sermon_cur.execute("select distinct title,url,denomination,preacher,church_name from verse_refs where url=?", (url,)).fetchall())
|
||||
|
||||
# this is in case someone searches for a verseid maliciously >:^)
|
||||
def clean_verseid(verse_id):
|
||||
verse_id = str(verse_id)
|
||||
clean_verseid = ''
|
||||
for char in verse_id:
|
||||
if char in string.digits:
|
||||
clean_verseid += char
|
||||
return clean_verseid
|
||||
|
||||
|
||||
# i do not remember why this is done this way, I was probably tired
|
||||
def find_by_verseid(verse_id):
|
||||
return lookup_by_verse_id(verse_id)[0]
|
||||
|
||||
# I think this just returns a singular verse object
|
||||
# as the API spec is changing, this will need to return an array of 1 verse object.
|
||||
@kjv_api.get("/verse_id/<verse_id>") # return the database entry of an internal verse_id
|
||||
def web_verseid(verse_id):
|
||||
clean_verse_id = clean_verseid(verse_id=verse_id)
|
||||
return jsonify(lookup_by_verse_id(clean_verse_id)[0])
|
||||
|
||||
# This used to be used for a direct search by word in the v1 API, but needs to change for v2
|
||||
def define(word=''):
|
||||
# might have issues with case-matching
|
||||
word = word.strip()
|
||||
if not word:
|
||||
return {'error':'No definition word provided'}
|
||||
if not word in cached.get("json_dictionary").keys():
|
||||
return {'error':'Definition not found'}
|
||||
return {word: cached.get("json_dictionary")[word]}
|
||||
|
||||
# this checks for a valid request, and needs to check for a valid
|
||||
# key in the database, it is a security feature
|
||||
def is_valid_request():
|
||||
request_data = request.get_json()
|
||||
if request_data is None:
|
||||
return {'error': 'Invalid request format'}
|
||||
token_string = request_data.get('token')
|
||||
query_string = request_data.get('query')
|
||||
mode_string = request_data.get('mode')
|
||||
if token_string is None:
|
||||
return {'error': 'Missing token in request body'}
|
||||
if token_string != SECRET_API_KEY:
|
||||
#TODO Replace SECRET_API_KEY with a list of valid keys in database
|
||||
return {'error': "Invalid token"}
|
||||
if query_string is None:
|
||||
return {'error': 'Missing query parameter in request body'}
|
||||
if mode_string is None:
|
||||
return {'error': 'Missing mode parameter in request body'}
|
||||
return {'success':"Authorized"}
|
||||
|
||||
|
||||
# this is used for retrieving scripture by reference, it is a core feature of the API that
|
||||
# is supposed to be implemented in the reference implementation, but is here for testing
|
||||
# and development purposes. It is a complex function that handles a lot of edge cases.
|
||||
# This will handle multiple references, and return an array of verse objects, as well
|
||||
# as verse ranges and entire chapters.
|
||||
# For the reference implementation, called functions should be simplified to
|
||||
# make it easier to implement in other languages.
|
||||
def get_kjv_verses_by_ref(ref=None, get_chapter=False):
|
||||
if not ref:
|
||||
return {'error':'Must provide reference query'}
|
||||
if ref.endswith(","):
|
||||
ref = ref[:-1]
|
||||
refs: list = []
|
||||
for r in ref.split(","):
|
||||
r_ = ref_input_cleaning(r.strip())
|
||||
if r_:
|
||||
refs.append(r_)
|
||||
if not refs:
|
||||
return {'error':'Invalid Query'}
|
||||
verse_results: list = []
|
||||
# check if the search is for multiple refs
|
||||
for ref in refs:
|
||||
if len(ref) < 2:
|
||||
continue
|
||||
maybe_chapter: str = ref.split(":")[0][3:]
|
||||
ref_chapter: str = ""
|
||||
for maybedigit in maybe_chapter:
|
||||
if maybedigit.isdigit():
|
||||
ref_chapter += maybedigit
|
||||
ref_verse = ref.split(":")[1]
|
||||
if ref.__contains__("-") or get_chapter:
|
||||
if ref.endswith("-") or get_chapter:
|
||||
start_verse = ref_verse.split("-")[0]
|
||||
end_verse = "500" # just get all the verses if it ends with a dash
|
||||
else:
|
||||
start_verse, end_verse = (
|
||||
ref_verse.split("-")[0],
|
||||
ref_verse.split("-")[1],
|
||||
)
|
||||
else:
|
||||
start_verse = ref_verse
|
||||
end_verse = None
|
||||
end_verse_id = None
|
||||
book_str: str = ref_input_cleaning(ref, string.ascii_letters)
|
||||
ref_bookname: str = ref_parse_book(book_str)
|
||||
ref_book_id: int = lookup_book_id(ref_bookname)
|
||||
if not ref_book_id:
|
||||
return {'error':'Invalid Query'}
|
||||
maximum_chapters: int = cached.get("books_chapter_lengths").get(ref_book_id)
|
||||
if int(ref_chapter) > maximum_chapters:
|
||||
ref_chapter = str(maximum_chapters)
|
||||
start_verse_id = get_verse_id(ref_book_id, ref_chapter, start_verse)
|
||||
if end_verse:
|
||||
end_verse_id = get_verse_id(ref_book_id, ref_chapter, end_verse)
|
||||
results = lookup_by_verse_id(start_verse_id, end_verse_id)
|
||||
for result in results:
|
||||
verse_results.append(result)
|
||||
response_list = scripture_response(verse_results)
|
||||
return response_list
|
||||
|
||||
# just packages the verse object into a dictionary for the API response
|
||||
def response_dict(scripture):
|
||||
verse = {
|
||||
"bookname": lookup_bookname(scripture[1]),
|
||||
"chapter": scripture[2],
|
||||
"verse": scripture[3],
|
||||
"text": scripture[4],
|
||||
}
|
||||
return verse
|
||||
|
||||
# this packages a group of verse objects into an array of dictionaries for the API response
|
||||
# this is mostly used for the get_kjv_verses_by_ref function and other reference implementation functions
|
||||
def scripture_response(scriptures):
|
||||
response = []
|
||||
for scripture in scriptures:
|
||||
response.append(response_dict(scripture))
|
||||
return response
|
||||
|
||||
|
||||
# might be good to implement detection of malicious endpoint 'testing'
|
||||
# we don't currently have any secret endpoints, but may in the future
|
||||
@kjv_api.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return jsonify({"error": "Invalid api endpoint"})
|
||||
|
||||
@kjv_api.get("/") # index
|
||||
def idx():
|
||||
return jsonify({'error':'Nothing at this top level'})
|
||||
|
||||
# believe it or not, this is the most important endpoint in the API
|
||||
# additionally, normal random.choice isn't random enough!
|
||||
# This will be retained as a single verse object at this endpoint for backwards compatibility
|
||||
@kjv_api.get("/random") # truely random Bible verse
|
||||
def random_verse():
|
||||
rand_id = random.SystemRandom().choice(cached["verse_ids"])
|
||||
scripture = lookup_by_verse_id(rand_id)
|
||||
response_list = scripture_response(scripture)
|
||||
return jsonify(response_list)
|
||||
|
||||
# same story as the random verse, but this is for the verse of the day and has a seed
|
||||
@kjv_api.get("/votd") # today's Bible verse
|
||||
def verse_of_the_day():
|
||||
today = int(str(date.today()).replace("-", ""))
|
||||
random.seed(today)
|
||||
scripture = lookup_by_verse_id(random.choice(cached["verse_ids"]))
|
||||
return jsonify(scripture_response(scripture))
|
||||
|
||||
|
||||
def kjv_keyword_search(keywords):
|
||||
if keywords:
|
||||
cleaned_keywords = search_input_cleaning(keywords)
|
||||
if not cleaned_keywords:
|
||||
return {'error':'Cleaning of search parameters returned nothing'}
|
||||
else:
|
||||
return {'error':'No search parameters supplied'}
|
||||
results = lookup_fts(cleaned_keywords)
|
||||
response_list = scripture_response(results)
|
||||
return response_list
|
||||
|
||||
# if we run this in single user mode, we can test the API by running it locally
|
||||
# but we will not have the security features of uwsgi, and we will not have the
|
||||
# performance features of uwsgi. This is just for testing and development purposes.
|
||||
if __name__ == "__main__":
|
||||
print("Operating in single user debug mode.")
|
||||
# port 1612 is our test port, in production we will use 1611 because that's
|
||||
# the year the KJV was published!
|
||||
kjv_api.run(host="0.0.0.0", port=1612, debug=True, threaded=True)
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Search</title>
|
||||
<style>
|
||||
#search-box {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#results li {
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Bible Semantic Search</h1>
|
||||
<p>This is NOT a keyword search. This means searching single words will not have the results you expect. For that, use the normal keyword search at <a href='/'>api.1611.social</a></p>
|
||||
<p>Use a sentence like "The Lord will kill his enemies without mercy" - you are supposed to search an idea, not individual words.</p>
|
||||
<h3>Recent searches:</h3>
|
||||
<ul id="last-searched"></ul>
|
||||
<input type="text" id="search-box" placeholder="The lord god is a man of war, just in all his dealings">
|
||||
<button id="search-button">Search</button>
|
||||
<ul id="results"></ul>
|
||||
|
||||
<script>
|
||||
const searchButton = document.getElementById('search-button');
|
||||
const searchBox = document.getElementById('search-box');
|
||||
const resultsList = document.getElementById('results');
|
||||
const lastSearched = document.getElementById('last-searched');
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
const lastSearchedURL = `https://api.1611.social/natural`;
|
||||
const data = {
|
||||
token: 'testing',
|
||||
query: '__last_searched__'
|
||||
};
|
||||
const response = await fetch(lastSearchedURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (response.ok) {
|
||||
const results = await response.json();
|
||||
lastSearched.innerHTML = ''; // Clear previous results
|
||||
|
||||
results.forEach(result => {
|
||||
const listItem = document.createElement('li');
|
||||
listItem.innerHTML = `
|
||||
${result.query}<br>
|
||||
`;
|
||||
lastSearched.appendChild(listItem);
|
||||
});
|
||||
} else {
|
||||
console.error('Error fetching data:', response.statusText);
|
||||
}
|
||||
});
|
||||
|
||||
searchButton.addEventListener('click', async () => {
|
||||
const query = searchBox.value;
|
||||
if (query.length >= 100){
|
||||
alert("Query too long");
|
||||
return;}
|
||||
const url = `https://api.1611.social/natural`; // Replace with your actual URL
|
||||
|
||||
const data = {
|
||||
token: 'testing',
|
||||
query: query
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const results = await response.json();
|
||||
resultsList.innerHTML = ''; // Clear previous results
|
||||
|
||||
results.forEach(result => {
|
||||
const listItem = document.createElement('li');
|
||||
const uriRef = encodeURIComponent(result.ref);
|
||||
listItem.innerHTML = `
|
||||
<strong>Verse ID:</strong> ${result.verse_id}<br>
|
||||
<strong>Text:</strong> ${result.text}<br>
|
||||
<strong>Ref:</strong> <a href=/find?kw=${uriRef}>${result.ref}</a><br>
|
||||
<strong>Score:</strong> ${result.score}
|
||||
`;
|
||||
resultsList.appendChild(listItem);
|
||||
});
|
||||
} else {
|
||||
console.error('Error fetching data:', response.statusText);
|
||||
alert('An error occurred while searching. Please try again later.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user