From ab15eeaebe52f11f79367d214478bc533998d43f Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 13 May 2024 14:40:18 -0700 Subject: [PATCH 1/3] mostly a hosted interfaces rewrite and better documentation --- .env | 2 +- app.py | 4 + data/sermons.db | Bin 0 -> 507904 bytes kjv_api.py | 931 ++++++++++++++++++++++++++++++++++++++++++++++++ kjv_fe.html | 115 ++++++ 5 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 data/sermons.db create mode 100644 kjv_api.py create mode 100644 kjv_fe.html diff --git a/.env b/.env index fbbfaa8..9e976e5 100644 --- a/.env +++ b/.env @@ -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" diff --git a/app.py b/app.py index 99c2b8a..040ce29 100644 --- a/app.py +++ b/app.py @@ -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(): diff --git a/data/sermons.db b/data/sermons.db new file mode 100644 index 0000000000000000000000000000000000000000..00b86449afec78a9379b9228eb42c7df6c8db6bf GIT binary patch literal 507904 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 {-41LaFwA0as5@nIM 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 literal 0 HcmV?d00001 diff --git a/kjv_api.py b/kjv_api.py new file mode 100644 index 0000000..1c78dcd --- /dev/null +++ b/kjv_api.py @@ -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/ + -- HTTP Method: GET +- Add endpoint for searching sermons by preacher + -- Endpoint name: /sermons/preacher/ + -- HTTP Method: GET +- Add endpoint for searching sermons by church + -- Endpoint name: /sermons/church/ + -- HTTP Method: GET +- Add endpoint for searching sermons by denomination + -- Endpoint name: /sermons/denomination/ + -- HTTP Method: GET +- Add endpoint for searching sermons by filename + -- Endpoint name: /sermon/ + -- HTTP Method: GET +- Add endpoint for searching sermons by URL + -- Endpoint name: /sermon/url/ + -- HTTP Method: GET +- Add endpoint for searching sermons by keyword + -- Endpoint name: /sermon/search/ + -- 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/ + -- HTTP Method: GET +- Add endpoint for removing sermons + -- Endpoint name: /sermon/remove/ + -- HTTP Method: DELETE +- Add endpoint for adding sermons + -- Endpoint name: /sermon/add + -- HTTP Method: POST +- Add endpoint for updating sermons + -- Endpoint name: /sermon/update/ + -- 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/ + -- 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/") +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/") +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//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/") # 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/") # 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/") # 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/") # 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/") # 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/") # 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) diff --git a/kjv_fe.html b/kjv_fe.html new file mode 100644 index 0000000..1bfd570 --- /dev/null +++ b/kjv_fe.html @@ -0,0 +1,115 @@ + + + + + + Search + + + +

Bible Semantic Search

+

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 api.1611.social

+

Use a sentence like "The Lord will kill his enemies without mercy" - you are supposed to search an idea, not individual words.

+

Recent searches:

+
    + + +
      + + + + \ No newline at end of file From 8b4e8970d2f87fc1c12e51092b98ca27be3d74ba Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 13 May 2024 14:41:35 -0700 Subject: [PATCH 2/3] Begin OpenAPI Specification --- .gitignore | 3 + doc/openapi.yaml | 247 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 .gitignore create mode 100644 doc/openapi.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19ab9be --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +*.log + diff --git a/doc/openapi.yaml b/doc/openapi.yaml new file mode 100644 index 0000000..bb92aff --- /dev/null +++ b/doc/openapi.yaml @@ -0,0 +1,247 @@ +openapi: 3.0.0 +info: + title: Bible API + description: API for accessing and searching biblical information. + version: 1.0.0 + +servers: + - url: https://api.1611.social + +# Add security definitions for authorization tokens +security: + - bearerAuth: + type: apiKey + name: Authorization + in: header + +paths: + /verses: + get: + summary: Get verses by reference. + description: Retrieve a list of verses by providing a verse reference or range. + parameters: + - name: verse_reference + in: query + description: The verse reference, e.g., "1cor5:1,2cor1:2-5". + required: true + schema: + type: string + responses: + '200': + description: List of verses in JSON format. + content: + application/json: + schema: + type: array + items: + type: object + properties: + book: + type: string + chapter: + type: integer + verse: + type: integer + text: + type: string + post: + summary: Submit sermon information. + description: Submit details of a sermon for review. Requires authorization. + security: + - bearerAuth: [] + parameters: + - name: Authorization + in: header + description: Bearer token for authorization. + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + url: + type: string + preacher: + type: string + date_preached: + type: string + format: date + church_name: + type: string + denomination: + type: string + responses: + '201': + description: Sermon submitted successfully. + content: {} + '401': + description: Unauthorized access. + + /verses/{verse_id}: + get: + summary: Get verse by internal ID. + description: Retrieve a specific verse using its internal ID. + parameters: + - name: verse_id + in: path + description: The internal ID of the verse. + required: true + schema: + type: string + responses: + '200': + description: Verse details in JSON format. + content: + application/json: + schema: + type: object + properties: + book: + type: string + chapter: + type: integer + verse: + type: integer + text: + type: string + + /search: + get: + summary: Search verses by keyword. + description: Return a list of verses matching a keyword search. + parameters: + - name: query + in: query + description: The keyword to search for. + required: true + schema: + type: string + responses: + '200': + description: List of verses in JSON format. + content: + application/json: + schema: + type: array + items: + type: object + properties: + book: + type: string + chapter: + type: integer + verse: + type: integer + text: + type: string + + /search/semantic: + get: + summary: Search verses using semantic search. + description: Return a list of verses "most like" a query text. Requires authorization. + security: + - bearerAuth: [] + parameters: + - name: Authorization + in: header + description: Bearer token for authorization. + required: true + schema: + type: string + - name: query + in: query + description: The query text. + required: true + schema: + type: string + responses: + '200': + description: List of verses in JSON format. + content: + application/json: + schema: + type: array + items: + type: object + properties: + book: + type: string + chapter: + type: integer + verse: + type: integer + text: + type: string + + /verses/{verse_reference}/like: + get: + summary: Find similar verses. + description: Return a list of verses similar to the provided reference. + parameters: + - name: verse_reference + in: path + description: The verse reference for comparison. + required: true + schema: + type: string + responses: + '200': + description: List of similar verses in JSON format. + content: + application/json: + schema: + type: array + items: + type: object + properties: + book: + type: string + chapter: + type: integer + verse: + type: integer + text: + type: string + similarity_score: + type: number + description: Measure of similarity with the reference verse. + + /sermons: + get: + summary: Get sermons referencing a verse. + description: Retrieve sermons containing a specific verse reference. + parameters: + - name: verse_reference + in: query + description: The verse reference to search for. + required: true + schema: + type: string + responses: + '200': + description: List of sermons in JSON format. + content: + application/json: + schema: + type: array + items: + type: object + properties: + title: + type: string + url: + type: string + preacher: + type: string + date_preached: + type: string + format: date + church_name: + type: string + denomination: + type: string \ No newline at end of file From 4db79f8e634157c195ed7b6991049812e28cb6df Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 13 May 2024 15:00:23 -0700 Subject: [PATCH 3/3] additional documentation --- kjv_api.py | 134 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 106 insertions(+), 28 deletions(-) diff --git a/kjv_api.py b/kjv_api.py index 1c78dcd..bdf9f54 100644 --- a/kjv_api.py +++ b/kjv_api.py @@ -22,14 +22,19 @@ kjv_api = Flask(__name__, template_folder="html") design_goals = """ GOALS GOALS GOALS GOALS GOALS + ACTUAL SERMON ENDPOINTS LOCATIONS ARE NOT FINAL + 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. +but for now, they are separate and communicate via sqlite files and sockets. -All endpoints that return verses should return a consistent JSON format, an array containing verse objects with the following fields: +# RESPONSE TYPES + +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) @@ -53,9 +58,9 @@ All endpoints that return verses should return a consistent JSON format, an arra 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 + -- An array of verse objects that condain an additional 'score' field (a float) which is how similar the verse is - sermons: an array of sermons that reference the verse (if available) - -- An array of sermon objects + -- An array of sermon objects that contain an additional 'score' field (a float) which is how relevant the sermon is (this should be filtered by the user's key, so they can only see sermons they have access to) An example response: @@ -86,7 +91,8 @@ An example response: "verse_id": "01001002", "full_ref": "Genesis 1:2", "chapter_length": 31, - "book_length": 50 + "book_length": 50, + "score": 0.9 }, { "bookname": "Genesis", @@ -97,7 +103,8 @@ An example response: "verse_id": "01001003", "full_ref": "Genesis 1:3", "chapter_length": 31, - "book_length": 50 + "book_length": 50, + "score": 0.8 } ], "sermons": [ @@ -105,21 +112,32 @@ An example response: "title": "The Creation of the World", "source_url": "https://example.com/sermon/1", "url": "https://example.com/sermon/1", - "preacher": "John Doe" + "preacher": "John Doe", + "score": 0.9 }, { "title": "The Beginning of All Things", "source_url": "https://example.com/sermon/2", "url": "https://example.com/sermon/2", "preacher": "John Smith" + "score": 0.8 } ] } ] - +Access to the sermon database for lists of sermons/simple lookups are free. +Access to the sermon database for full sermon text and other advanced features is restricted by a key. +Access to the semantic search database is restricted by a key. + + +Except for an individual sermon queries (by ID/URL), sermon endpoints should return a summary of the sermon information, +not the full text of the sermon and a complete list of referenced verses. +The full text of the sermon should only be returned in an individual sermon query. -All endpoints that return sermons should return a consistent JSON format, an array containing sermon objects with the following fields: +All endpoints that return an individual sermon should return a consistent JSON format, +an array containing sermon objects with the following fields: +Sermon List Information: - title: the title of the sermon -- A string - source_url: the URL of the source sermon @@ -132,17 +150,8 @@ All endpoints that return sermons should return a consistent JSON format, an arr -- 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) +- sermon_id (currently 'filename'): 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 @@ -157,6 +166,17 @@ All endpoints that return sermons should return a consistent JSON format, an arr -- An integer - notes: the notes of the sermon provided by the preacher or church -- A string + +Individual Sermon Information (by ID/URL) will also include the following fields: +- 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 - comments: the comments on the sermon provided by users -- An array of objects containing: -- user: the username of the commenter @@ -164,7 +184,44 @@ All endpoints that return sermons should return a consistent JSON format, an arr -- timestamp: the timestamp of the comment -An example sermon response: +An example sermon list response: +[ + { + "title": "Sermon 1", + "source_url": "https://example.com/sermon/1", + "url": "https://example.com/sermon/1", + "preacher": "John Doe", + "church_name": "Community Church", + "denomination": "Non-Denominational", + "sermon_id": "1", + "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." + }, + { + "title": "Sermon 2", + "source_url": "https://example.com/sermon/2", + "url": "https://example.com/sermon/2", + "preacher": "Mark Smith", + "church_name": "Grace Church", + "denomination": "Baptist", + "sermon_id": "2", + "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." + } +] + + +An example individual sermon response: [ { "title": "The Power of Love", @@ -265,17 +322,19 @@ An example sermon response: ] - Sermon Endpoints: - Add endpoint for listing all sermons -- Endpoint name: /sermons -- HTTP Method: GET + -- Returns: JSON object of summary information for all sermons - Add endpoint for listing all preachers -- Endpoint name: /preachers -- HTTP Method: GET + -- Returns: JSON object with list of preachers and their denominations and churches and sermons - Add endpoint for listing all churches -- Endpoint name: /churches -- HTTP Method: GET + -- Returns: JSON object with list of churches and their denominations and sermons and preachers - Add endpoint for listing all denominations -- Endpoint name: /denominations -- HTTP Method: GET @@ -323,13 +382,23 @@ Sermon Endpoints: -- HTTP Method: PUT -Semantic Search Integration: -- Add endpoint for semantic search +Semantic Search improvements: +# semantic search is slow and expensive, so we need to cache results and precompute common queries +- Add endpoint for general text semantic search -- Endpoint name: /search/semantic -- HTTP Method: POST -- Request Body: {"query":"myquerystring"} -- Result: JSON object with search results as verse objects - +- Add endpoint for searching by similar verses + -- Endpoint name: /search/similar + -- HTTP Method: POST + -- Request Body: {"verse_id":"myverseidstring"} + -- Result: JSON object with search results as verse objects +- Cache semantic search results for faster response times (semantic search is EXPENSIVE and SLOW) + -- possibly use semantic search on cached results for faster response times, + and if the cache is empty, use the semantic search on the full text +- Use context windowing to improve semantic search results (later improvement) +- Precompute semantic search results (including individual keywords) for common queries (later improvement) General API Improvements (see old app.py for more details): @@ -406,13 +475,20 @@ Sermon Analysis and Feature Improvements: - 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) +- Quality control on sermon text and metadata + -- is it accurate to the audio? (transcription) + -- are there any misspellings? + -- are there any formatting issues? + -- are there any missing fields? + -- who is responsible for quality control? (the requester, the API host (us), the sermon provider?) - + 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 +- spam, DDoS, etc (keys should help with this) Logging and Metrics: @@ -424,12 +500,14 @@ Logging and Metrics: 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) +- Convert this outline to an OpenAPI schema for the API (Critical) """ +openapi_yaml = """ +>:^) +""" + 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