You can find several samples of my work on the sidebar.
All of the projects here are available on both
Github and
a more lightweight Gitea
instance.
// The following snippet is a user-finding algorithm derived from a Android social media app.
fun searchForUsers(
query: String,
fresh: Boolean,
queryLimit: Int,
callBack: (() -> Unit)? = null,
) {
lastQuery = query
if (fresh) {
tmpValue = 0
aggScroll = 0
aggScrollThreshold = 0
queryPos = 0
queryUpperRange = 0
viewCount = 0
iMap.clear()
}
val activity = requireActivity() as LandingActivity
val username = activity.username
val token = activity.token
val handler = Handler(requireContext().mainLooper)
val logTag = "SearchPeople"
val removeViews = fun() {
binding.linearLayout.removeAllViews()
binding.scrollView.scrollTo(0, 0)
viewCount = 0
}
Executors.newSingleThreadExecutor().execute {
try {
val bufferS = if (query == "") {
"search-for-users&$token&$username&$queryPos&$queryLimit;"
} else {
"search-for-users-query&$token&$username&$queryPos&$queryLimit&$query;"
}
queryUpperRange = queryPos + (queryLimit - 1)
Log.d(
logTag,
"queryRange: $queryPos->$queryUpperRange (fresh=$fresh)," +
" viewCount: $viewCount, ratio=${
(queryUpperRange * 1.0f) / (Math.max(
viewCount,
1
) * 1.0f)
}"
)
activity.restorePeopleQuery = query
activity.restorePeopleQueryP = true
queryPos += queryLimit // Fine to go here, even if we end up with extra data
activity.restorePeopleQueryLimit = queryPos
Log.d(
logTag, "username=$username, token=$token, query=$query, lQ=$lastQuery qL=$queryLimit, " +
"queryPos=$queryPos"
)
requestData(bufferS, fun(line: String) {
//Log.d(logTag, "Got line: '$line'")
if (isSuccess(line)) {
var users = line.split(";")
// Handle trailing ;
if (users[users.size - 1] == "")
users = users.subList(0, users.size - 1)
Log.d(logTag, "Successfully searched for users as $username: Got $line")
Log.d(logTag, "Got ${users.size} users")
handler.post {
if (_binding == null)
return@post
if (fresh)
removeViews()
var i = 0
val views = ArrayList<View>()
var glideElapsedMS: Long = 0
val elapsedMS = measureTimeMillis {
for (user in users) {
if (user == "")
continue
val split = user.split(":")
// age (name)
val name = split[2] + " (" + split[1] + ")"
val distance = split[3]
val avatar = URLDecoder.decode(split[5], "UTF-8")
val uID = split[0]
Log.d(logTag, "Found user: $split")
//Log.d(logTag, "Found image: $avatar")
if (i == 0)
Log.d(logTag, "Adding views for user")
val button = CircleImageView(context) // like 'ImageButton(context)'
button.minimumWidth = 211
button.minimumHeight = 211
button.setOnClickListener {
button.colorFilter = PorterDuffColorFilter(
Color.parseColor("#66f900ff"),
PorterDuff.Mode.ADD
)
Executors.newSingleThreadExecutor().execute {
for (j in 1..40) {
Thread.sleep(15)
var opacity = Integer.toHexString((40 - j) * (255 / 100))
//Log.d("j", "$j, $opacity, ${(40-j)*(255/100)}")
if (opacity.length == 1)
opacity = "0$opacity"
button.colorFilter = PorterDuffColorFilter(
Color.parseColor("#" + opacity + "f900ff"),
PorterDuff.Mode.ADD
)
}
button.colorFilter = null
}
@Suppress("NAME_SHADOWING")
Executors.newSingleThreadExecutor().execute {
requestData("request-user&$token&$username&$uID;", fun(line: String) {
val logTag = "SearchPeople (callback)"
if (isSuccess(line)) {
//Log.d(logTag, "Successfully requested user: Got '$line'")
var parts = line.split("&")
if (line.startsWith("chat")) {
parts = parts.subList(1, parts.size)
val otherUser = parts[0]
val otherName = parts[1]
val chatStack = parts[2]
val chatStackTip = parts[3]
Log.d(
logTag,
"Callback: Already friends with $otherUser ($otherName): Entering chat."
)
handler.post {
showChat(otherUser, otherName, chatStack, chatStackTip)
}
} else {
if (line != "success")
Log.d(logTag, "Invalid data format: '$line'")
}
} else {
Log.e(logTag, "An error occurred in the callback: Server says: $line")
}
})
}
}
// NOTE color on the default avatar is #88badc or so
// Remove background, keep animation (unlike button.setBackgroundColor)
//button.backgroundTintMode = PorterDuff.Mode.ADD
val text = TextView(context)
iMap[uID] = iMap.size + 1
text.text = name// + if (debugMode) " (DEBUG: ${iMap[uID]})" else ""
val dist = TextView(context)
dist.text = distance
val params = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
val params2 = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
params.setMargins(15, 60, 0, 0)
params2.setMargins(15, 0, 0, 0)
i++
glideElapsedMS += measureNanoTime {
Glide.with(this@PeopleFragment)
.load(avatar)
.apply(RequestOptions().override(211, 211))
.into(button)
}
binding.linearLayout.addView(button, params)
binding.linearLayout.addView(text, params2)
binding.linearLayout.addView(dist, params2)
views.add(button)
views.add(text)
views.add(dist)
viewCount++
}
}
glideElapsedMS /= 1000
glideElapsedMS /= 1000
Log.d(
logTag,
"elapsedMS(inner)=$elapsedMS, glideElapsedMS(inner)=$glideElapsedMS for users=[${users.size}]"
)
// Log.d(logTag, "views=[${views.size}]")
Log.d(logTag, "elapsedMS(outer)=${System.currentTimeMillis()-ms1} for users=[${users.size}]")
if (callBack != null) {
handler.post {
callBack()
}
}
}
} else {
if (fresh) {
Log.e(logTag, "Failed to search for users: Server says: $line")
Log.d(logTag, "elapsedMS(outer)=${System.currentTimeMillis() - ms1} for users=???")
if (line == "ERROR: No matching users were found.") {
val text = TextView(context)
text.text = "Unfortunately, we couldn't find anyone who lives near you."
val params = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
params.setMargins(15, 60, 0, 0)
handler.post {
removeViews()
binding.linearLayout.addView(text, params)
}
} else
throw Exception("Failed to search for users: $line")
}
}
})
} catch (e: Throwable) {
Log.d(logTag, "elapsedMS(outer)=${System.currentTimeMillis()-ms1} for users=???")
Log.e("Error logging in", e.toString())
throw IOException("Error logging in (no data)", e)
}
}
}
;; NOTE: The following snippet is derived from a Common Lisp codebase. The dependencies needed to
;; compile this snippet are contained in the second section. You also need SBCL (https://www.sbcl.org/)
;; ----------------------------------------------------------------------------------------------------
;;
;; lev (let-value) is a macro for introducing immutable bindings.
;; It is syntactically identical to let [http://clhs.lisp.se/Body/s_let_l.htm]
;;
;; Compare:
;;
;; (let ((x 1))
;; (setq x 0) ;; fine
;; (print x))
;;
;; (lev ((x 1))
;; (setq x 0) ;; compile error
;; x)
;;
;; (lev ((x 1))
;; (lev ((x 0))
;; ;; Fine - x is 0 here
;; (print x))
;; ;; x is 1 again
;; (print x))
;;
(defmacro lev (bindings &body forms)
`(progn
(lev-compiler "lev" ,bindings ,@forms)
(let ,bindings ,@forms)))
;; (lev*) is to (lev) as (let*) is to (let).
(defmacro lev* (bindings &body forms)
`(progn
(lev-compiler "lev*" ,bindings ,@forms)
(let* ,bindings ,@forms)))
;; NOTE: The following lists are dynamically created throughout a real codebase using a special macro.
;; They are incomplete in this snippet.
;; Forbid (setf CONST VALUE) etc
(defvar .lev-forbidden-car-parameters
(list 'setf 'setq 'incf 'decf 'nreverse 'close 'sb-thread:signal-semaphore 'extend))
;; Forbid (push VALUE CONST) etc
(defvar .lev-forbidden-cadr-parameters (list 'push 'delete 'delete-if 'edelete))
;; Forbid (lev ((CONST (POINTER))) ...) etc
(defvar .lev-inherently-mutable-cars (list 'pointer))
;; Forbid (setf (nth* N CONST) ...),
;; (push VALUE (nth* N CONST))
;;
;; where nth* is any number of nested nth forms
;; NOTE: only forbidden when used with .lev-forbidden-ca(d)r-parameters
(defvar .lev-forbidden-recursive-cadr-forms (list 'car 'cdr 'nth 'symbol-value 'gethash))
(defmacro lev-compiler (name bindings &body forms)
`(progn
;; Returns the relevant symbol of a form (ie x from (nth 0 x))
,@(labels ((basic-form (form)
(declare (type (or symbol cons sb-comma) form))
;; Define a global parameter *basic-forms*, and the possible types can be tested like such:
;; [NOTE that *basic-forms* may not be pushed to unless (wipe-REDACTED-cache) is run first.]
;; (push (type-of form) *basic-forms*)
;; (remove-if (lambda (x) (or (symbolp x) (consp x) (sb-int:comma-p x))) *basic-forms*)
(if (and
(listp form)
(find (car form) .lev-forbidden-recursive-cadr-forms))
(basic-form (lastcar form))
form))
(prohibit-rebindings (branch forbidden-rebindings allowed-rebindings)
(declare (type list branch)) ;; Note that the initial branch for an empty (lev ((...))) is nil.
(declare (type cons forbidden-rebindings))
(declare (type elist allowed-rebindings))
(let ((last-twig nil))
(mapcar (lambda (twig)
(cond ((listp twig)
(cond
;; (let ((...)) a b c ...)
;; ^last ^current ^next twigs
((eql last-twig 'let)
(when (list-of-lists-p twig)
;; 1) New elist for this (let ...) - otherwise, these new bindings
;; are allowed for everything that is evaluated after this point,
;; regardless of scope.
(setq allowed-rebindings (copy-elist allowed-rebindings))
(dolist (binding twig)
(let ((symbol (car binding)))
(extend allowed-rebindings symbol))))
;; Must be (), (x), ((x)), ((x) (y))
;; This prevents (let ((y (setq x 4))) ...) etc
(prohibit-rebindings twig forbidden-rebindings allowed-rebindings))
;; (incf <binding>), (setf <binding> <val>), etc
((find (car twig) .lev-forbidden-car-parameters)
(let ((basic-form (basic-form (second twig))))
(when (and
(not (efind basic-form allowed-rebindings))
(find-one basic-form forbidden-rebindings))
(error "Modification of binding in (~A) via (~A): form is ~A"
name (car twig) twig))))
;; (push <val> <binding>), etc
((find (car twig) .lev-forbidden-cadr-parameters)
(let ((basic-form (basic-form (third twig))))
(when (and
(not (efind basic-form allowed-rebindings))
(find-one basic-form forbidden-rebindings))
(error "Modification of binding in (~A) via (~A): form is ~A"
name (car twig) twig))))
(t (prohibit-rebindings twig forbidden-rebindings allowed-rebindings))))
;; For ,symbol ,(1 2 3) etc
((sb-int:comma-p twig)
(let ((expr (sb-int:comma-expr twig)))
(if (listp expr)
(prohibit-rebindings expr forbidden-rebindings allowed-rebindings)
twig)))
(t twig))
(setq last-twig twig))
branch)))
;; We prohibit (lev ((x (pointer))) ...) because pointers are meant to be assigned at runtime
;; in a way that is not cost-effective to prevent. You can still write (lev ((x (list))) ...)
;; and do the same thing, which is an inherent limitation of this macro.
(prohibit-inherently-mutables (binding-values)
;; (let nil) is valid (and returns nil), so (lev nil) is also valid
(declare (type list binding-values))
;; eg '(pointer X)
(dolist (value binding-values)
(when (listp value)
;; eg 'pointer
(when (find (car value) .lev-inherently-mutable-cars)
(error "Attempting to run inherently-mutable form ~A in (~A) form." value name))))))
(returning-nil
;; Since (let (x y z) ...) is valid, we need to remove plain symbols first
(prohibit-inherently-mutables (mapcar (lambda (x) (cadr x)) (remove-if-not #'listp `,bindings))))
(returning-nil
;; Avoid a lot of work for forms like (lev nil 1 2 3 ...) [which is identical to (lev () 1 2 3 ...)]
(when `,bindings
(prohibit-rebindings (copy-tree `,forms) (nil-or-xs/cars `,bindings) (elist)))))))
;; Assorted dependencies required to compile the previous code snippet.
;; This file will only be of interest to you if you want to test the lev/lev* macro.
;; Simply evaluate this file first, then lev.lisp second. Only SBCL is supported.
;; ---------------------------------------------------------------------------------
;;
(eval-when (:compile-toplevel :load-toplevel :execute)
(defun returning-nil (x)
(declare (ignore x))
nil)
(defun find-one (needle haystack &key (test #'eql))
(dolist (needle-2 haystack)
(when (funcall test needle needle-2)
(return-from find-one t)))))
(deftype sb-comma ()
`(satisfies sb-int:comma-p))
(defun nil-or-xs/cars (x)
(cond ((null x) nil)
((listp x) (mapcar #'(lambda (x) (if (listp x) (car x) x)) x))
(t (error "'~A' is of type ~A, not (or null list)" x (type-of x)))))
(defun t-p (thing)
(declare (ignore thing))
t)
(defun copy-array (array &key (element-type (array-element-type array))
(fill-pointer (and (array-has-fill-pointer-p array)
(fill-pointer array)))
(adjustable (adjustable-array-p array)))
"Returns an undisplaced copy of ARRAY, with same fill-pointer and
adjustability (if any) as the original, unless overridden by the keyword
arguments."
(let* ((dimensions (array-dimensions array))
(new-array (make-array dimensions
:element-type element-type
:adjustable adjustable
:fill-pointer fill-pointer)))
(dotimes (i (array-total-size array))
(setf (row-major-aref new-array i)
(row-major-aref array i)))
new-array))
(eval-when (:compile-toplevel :load-toplevel :execute)
(defstruct elist
(:sequence nil :type (vector t))
;; A field named 'type' is a probable syntax error and halts comp
(:typef #'t-p :type function)))
(defun elist (&rest initial-contents)
(declare (dynamic-extent initial-contents))
(make-elist :sequence
(make-array (length initial-contents)
:adjustable t
:fill-pointer (length initial-contents)
:element-type t
:initial-contents initial-contents)))
(let ((sb-ext:*muffled-warnings* 'sb-int:duplicate-definition))
(fmakunbound 'copy-elist)
(defun copy-elist (elist)
(declare (type elist elist))
(make-elist :sequence (copy-array (elist-sequence elist))
:typef (elist-typef elist))))
(defun extend (elist element)
(declare (type elist elist))
(assert (funcall (elist-typef elist) element))
(vector-push-extend element (elist-sequence elist))
elist)
(defun efind (item elist &key (test #'eql))
(declare (type elist elist))
(find item (elist-sequence elist) :test test))
(defun lastcar (list)
(car (last list)))
(defun empty? (list)
(eql (length list) 0))
(defun full? (list)
(not (empty? list)))
(defun list-of-lists-p (x)
(and (listp x)
(full? x)
(every #'consp x)))
(deftype list-of-lists ()
`(satisfies list-of-lists-p))
(defun pointer (&optional to)
(list to))
#!/usr/bin/env python3
######################################################
# main.py: A simple Flask app to host the portfolio. #
# https://git.krischerven.info/dev/portfolio-webpage #
# #
# Usage (brackets represent optional arguments): #
# ./main.py #
# ./main.py [+debug] [+skip-npm] #
# #
######################################################
import logging
import os
import sqlite3
import sys
from hashlib import md5
from threading import Lock, Thread
import flask
from flask import request
from work import server_work
app = flask.Flask(__name__, template_folder=os.getcwd(), static_folder="static")
app.config["TEMPLATES_AUTO_RELOAD"] = True
database = sqlite3.connect("./portfolio-webpage.db", check_same_thread=False)
database.execute("""
create table if not exists hashed_ips (id integer primary key, timestamp datetime
default current_timestamp not null,
checksum text not null,
page text not null)
""")
mutex = Lock()
logging.basicConfig()
logger = logging.getLogger("portfolio-webpage")
logger.setLevel(logging.DEBUG)
def static_file(dir):
"Return [static]/[dir], where [static] is Flask's static folder."
return app.static_folder.split("/")[-1] + "/" + dir
def download(dir):
"Return [static]/downloads/[dir], where [static] is Flask's static folder."
return app.static_folder.split("/")[-1] + "/downloads/" + dir
def read_file(file):
"Shorthand for open([file], 'r').read()"
return open(file).read()
def get_client_address():
if request.environ.get('HTTP_X_FORWARDED_FOR') is None:
return request.environ['REMOTE_ADDR']
# if behind a proxy
else:
return request.environ['HTTP_X_FORWARDED_FOR']
def store_hashed_address(raw_address, page):
with mutex:
database.execute("insert into hashed_ips (checksum, page) values(?, ?)",
(md5(raw_address.encode()).hexdigest(), page))
database.commit()
@app.route('/')
def landing():
"Render landing.html"
Thread(target=store_hashed_address, args=(get_client_address(), "landing")).start()
search_for_users_snippet = read_file("snippets/search-for-users.kt")
lev_snippet = read_file("snippets/lev.lisp")
lev_deps_snippet = read_file("snippets/lev-dependencies.lisp")
serve_snippet = read_file(__file__)
maints_snippet = read_file(static_file("javascript/main.ts"))
landing_snippet = read_file("landing.html")
stylesheet_snippet = read_file("stylesheet.css")
chatbot_snippet = read_file("portfolio-chatbot/main.go")
return flask.render_template("landing.html",
search_for_users_snippet_1=search_for_users_snippet,
metaprog_snippet_1=lev_snippet,
metaprog_snippet_2=lev_deps_snippet,
website_snippet_1=serve_snippet,
website_snippet_2=maints_snippet,
website_snippet_3=landing_snippet,
website_snippet_4=stylesheet_snippet,
chatbot_snippet_1=chatbot_snippet,
search_for_users_download_1=download(
"search-for-users.kt"),
metaprog_download_1=download("lev.lisp"),
metaprog_download_2=download("lev-dependencies.lisp"),
website_download_1=download("main.py"),
website_download_2=download("main.ts"),
website_download_3=download("landing.html"),
website_download_4=download("stylesheet.css"),
chatbot_download_1=download("main.go"))
@app.route('/contact')
def contact():
"Render contact.html"
Thread(target=store_hashed_address, args=(get_client_address(), "contact")).start()
return flask.render_template("contact.html")
@app.route("/question/<uuid>/<question>")
def question(uuid, question):
"Answer a question by invoking portfolio-chatbot --uuid $uuid --question $question"
hashed_address = md5(get_client_address().encode()).hexdigest()
cmd = f"cd portfolio-chatbot && ./portfolio-chatbot \"{uuid}\" \"{hashed_address}\" \"{question}\""
return {"response": os.popen(cmd).read()}
def main():
"Main function"
logger.debug("Serving landing.html")
debug_mode = "+debug" in sys.argv
app.run("0.0.0.0", debug=debug_mode)
logger.debug("Program exited (0)")
if __name__ == "__main__":
# server_work() should only run be run here for local testing (ie ./main.py), because
# some of the processes in server_work() require sudo. There is no guarantee that
# NOPASSWD will be set for all of these, which can cause server_work() to hang if it
# is run in the background via gunicorn. gunicorn also has a tendency to execute this
# file three times (<2023-08-30 Wed> - test via print outside this scope), which is
# problematic if we run server_work() here.
#
# For gunicorn, server_work() runs as part of the update-webpage bash script.
server_work(tscompile_npm_work=("+skip-npm" not in sys.argv))
main()
/* ******************************************************
* main.ts: Typescript functionality for landing.html *
* https://git.krischerven.info/dev/portfolio-webpage *
****************************************************** */
const chatbotUUID = crypto.randomUUID()
function set_code_tab(name: String) {
for (const lang of ["welcome", "metaprog", "search-for-users", "website", "chatbot"]) {
const x = document.getElementById(`${lang}-tab`)
if (x != null)
x.style.display = "none"
document.getElementById(`${lang}-tab-content`)!!.style.display = "none"
}
const x = document.getElementById(`${name}-tab`)
if (x != null)
x.style.display = ""
document.getElementById(`${name}-tab-content`)!!.style.display = ""
}
const Dayjs = () => {
function hour(): number {
const hour = eval("dayjs().hour()")
console.assert(typeof (hour) === 'number', "dayjs().hour() did not return a number")
return hour
}
return { hour: hour }
}
function get_welcome_blurb(hour_?: number): string {
const hour: number = hour_ ?? Dayjs().hour()
if (hour > 2 && hour < 12)
return "Good morning."
else if (hour >= 12 && hour < 18)
return "Good afternoon."
else
return "Good evening."
}
function set_welcome_blurb() {
document.getElementById("welcome-blurb")!!.innerHTML = get_welcome_blurb()
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function toggle_AI_assistant_dialogue() {
const element = document.getElementById("AI-assistant")!!
if (getComputedStyle(element).display === "none") {
element.classList.remove("fade-out")
element.style.display = "block"
} else {
element.classList.add("fade-out")
await sleep(300)
element.style.display = "none"
}
}
function ask_chatbot_question_interactively(question: string) {
const host = location.host.startsWith("localhost") ?
"http://localhost:5000" : "https://krischerven.info"
fetch(host + "/question/" + chatbotUUID + "/" + question)
.then((response) => response.json())
.then((json) => console.log(json.response))
.catch((error) => console.error(error))
}
function ask_chatbot_question() {
const host = location.host.startsWith("localhost") ?
"http://localhost:5000" : "https://krischerven.info"
const input = document.getElementById("AI-message-input") as HTMLInputElement
const question = input.value
input.value = ""
// /question/ without an argument is 404
if (question === "") {
create_chatbot_question("")
create_chatbot_answer("Please ask me a question.", 2)
} else {
create_chatbot_question(question)
fetch(host + "/question/" + chatbotUUID + "/" + question)
.then((response) => response.json())
.then((json) => create_chatbot_answer(json.response))
.catch((error) => console.error(error))
}
}
function create_chatbot_question(question: string) {
const span = document.createElement("span")
span.innerText = "Q: " + question
const messageArea = document.getElementById("AI-message-area")!!
messageArea.appendChild(span)
messageArea.appendChild(document.createElement("br"))
}
function create_chatbot_answer(answer: string, br=1) {
const span = document.createElement("span")
span.innerText = (answer == "") ? "An error occured on the server." : "A: " + answer
const messageArea = document.getElementById("AI-message-area")!!
messageArea.appendChild(span)
for (let i = 0; i < br; i++)
messageArea.appendChild(document.createElement("br"))
console.log("CHATBOT: " + answer)
}
if (typeof document === 'undefined')
describe('main.ts', function () {
const chai = require('chai')
it('test_welcome_blurb', function () {
function log2(i: number, x: string): number {
//console.log(i, "(" + x + ")")
return i
}
for (let i = 0; i < 3; i++)
chai.expect(get_welcome_blurb(log2(i, "evening"))).equal("Good evening.")
for (let i = 3; i < 12; i++)
chai.expect(get_welcome_blurb(log2(i, "morning"))).equal("Good morning.")
for (let i = 12; i < 18; i++)
chai.expect(get_welcome_blurb(log2(i, "afternoon"))).equal("Good afternoon.")
for (let i = 18; i < 24; i++)
chai.expect(get_welcome_blurb(log2(i, "evening"))).equal("Good evening.")
});
});
if (typeof document !== 'undefined')
set_welcome_blurb()
<!DOCTYPE html>
<!---------------------------------------------------------->
<!-- landing.html: The landing page of the portfolio. -->
<!-- https://git.krischerven.info/dev/portfolio-webpage -->
<!---------------------------------------------------------->
<html>
<head>
<title>Kris Cherven</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='assets/logo.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='styles/bootstrap.min.css') }}"/>
<link rel="stylesheet" href="{{ url_for('static', filename='styles/prism.css') }}"/>
<link rel="stylesheet" href="{{ url_for('static', filename='styles/stylesheet.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='styles/code.css') }}">
<script src="{{ url_for('static', filename='javascript/bootstrap.bundle.min.js') }}"></script>
<script src="{{ url_for('static', filename='javascript/dayjs.min.js') }}"></script>
<script src="{{ url_for('static', filename='javascript/prism.js') }}"></script>
<script src="{{ url_for('static', filename='javascript/prism-customization.js') }}"></script>
<script src="{{ url_for('static', filename='javascript/fontawesome.js') }}"
crossorigin="anonymous"></script>
</head>
<!-- See: https://prismjs.com/plugins/line-numbers/#how-to-use -->
<body class="line-numbers match-braces">
<nav class="navbar sticky-top navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="#" style="margin-left:1.7em">Kris Cherven</a>
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link" target="_blank" rel="noopener noreferrer"
href="https://github.com/krischerven">GitHub</a>
</li>
<li class="nav-item">
<a class="nav-link" target="_blank" rel="noopener noreferrer"
href="https://linkedin.com/in/kris-cherven">LinkedIn</a>
</li>
<li class="nav-item">
<a class="nav-link" rel="noopener noreferrer" href="./contact">Contact</a>
</li>
<li class="nav-item">
<a class="nav-link" id="toggle-AI-assistant" rel="noopener noreferrer"
href="#" onclick="toggle_AI_assistant_dialogue(); return false">AI Assistant</a>
</li>
</ul>
</nav>
<div class="container-fluid" style="margin-top:2em;">
<div class="row">
<br/>
<!-- Side navbar: Website Source, Metaprogramming, etc -->
<div class="col side-navbar" style="left:1em;">
<div class="row">
<div class="col">
<ul class="nav flex-column nav-pills" id="snippet-tab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active mx-auto" id="welcome-toggle" data-bs-toggle="tab"
type="button" aria-selected="false"
onclick="javascript:set_code_tab('welcome')">Portfolio</button>
<hr style="margin-top: 0.2em">
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="search-for-users-toggle" data-bs-toggle="tab" type="button"
aria-selected="true" onclick="javascript:set_code_tab('search-for-users')">
People search (Kotlin)</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="metaprog-toggle" data-bs-toggle="tab" type="button"
aria-selected="true" onclick="javascript:set_code_tab('metaprog')">
Metaprogramming (Lisp)</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="website-toggle" data-bs-toggle="tab" type="button"
aria-selected="false" onclick="javascript:set_code_tab('website')">
Portfolio (Python, TS, etc.)</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="chatbot-toggle" data-bs-toggle="tab" type="button"
aria-selected="false" onclick="javascript:set_code_tab('chatbot')">
Portfolio chatbot (Go)</button>
</li>
</ul>
</div>
</div>
</div>
<div class="col codearea">
<!-- Upper navbar: Used for toggling different parts of this snippet (1/2, 2/2, et al.) -->
<ul class="nav nav-pills" id="metaprog-tab" role="tablist" style="display:none">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#metaprog-code-1"
type="button" role="tab" aria-controls="metaprog-code-1" aria-selected="true">
1/2</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#metaprog-code-2"
type="button" role="tab" aria-controls="metaprog-code-2" aria-selected="false">
2/2</button>
</li>
</ul>
<!-- Upper navbar: Used for toggling different parts of this snippet (1/2, 2/2, et al.) -->
<ul class="nav nav-pills" id="website-tab" role="tablist" style="display:none">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#website-code-1"
type="button" role="tab" aria-controls="website-code-1" aria-selected="true">
1/4</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#website-code-2"
type="button" role="tab" aria-controls="website-code-2" aria-selected="false">
2/4</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#website-code-3"
type="button" role="tab" aria-controls="website-code-3" aria-selected="false">
3/4</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#website-code-4"
type="button" role="tab" aria-controls="website-code-4" aria-selected="false">
4/4</button>
</li>
</ul>
<!-- Welcome blurb -->
<div class="tab-content" id="welcome-tab-content">
<div class="tab-pane fade show active" id="welcome-code-1" role="tabpanel"
aria-labelledby="welcome-tab-1">
<h1 class="display-2" id="welcome-blurb"></h1>
<!-- Ready to load JS immediately after #welcome-blurb (which requires Day.js) -->
<script src="{{ url_for('static', filename='javascript/main.js') }}"></script>
<script src="{{ url_for('static', filename='javascript/debug.js') }}"></script>
<p style="font-size: 2em;">You can find several samples of my work on the sidebar.
<br>
All of the projects here are available on both
<a href="https://github.com/krischerven">Github</a> and
<a href="https://git.krischerven.info/explore/repos">a more lightweight Gitea
instance</a>.
</div>
</div>
<!-- Search for users code -->
<div class="tab-content" id="search-for-users-tab-content" style="display:none">
<div class="tab-pane fade show active" id="search-for-users-code-1" role="tabpanel"
aria-labelledby="search-for-users-tab-1">
<pre class="language-kotlin" data-src="{{ search_for_users_download_1 }}" data-download-link>
<code class="language-kotlin code-search">
{{ search_for_users_snippet_1 }}
</code>
</pre>
</div>
</div>
<!-- Metaprogramming code -->
<div class="tab-content" id="metaprog-tab-content" style="display:none">
<div class="tab-pane fade show active" id="metaprog-code-1" role="tabpanel"
aria-labelledby="metaprog-tab-1">
<pre class="language-lisp" data-src="{{ metaprog_download_1 }}" data-download-link>
<code class="language-lisp code-lev">
{{ metaprog_snippet_1 }}
</code>
</pre>
</div>
<div class="tab-pane fade" id="metaprog-code-2" role="tabpanel"
aria-labelledby="metaprog-tab-2">
<pre class="language-lisp" data-src="{{ metaprog_download_2 }}" data-download-link>
<code class="language-lisp code-lev-deps">
{{ metaprog_snippet_2 }}
</code>
</pre>
</div>
</div>
<!-- Website code -->
<div class="tab-content" id="website-tab-content" style="display:none">
<div class="tab-pane fade show active" id="website-code-1" role="tabpanel"
aria-labelledby="website-tab-1">
<pre class="language-python" data-src="{{ website_download_1 }}" data-download-link>
<code class="language-python code-main-py">
{{ website_snippet_1 }}
</code>
</pre>
</div>
<div class="tab-pane fade" id="website-code-2" role="tabpanel"
aria-labelledby="website-tab-2">
<pre class="language-typescript" data-src="{{ website_download_2 }}" data-download-link>
<code class="language-typescript code-main-ts">
{{ website_snippet_2 }}
</code>
</pre>
</div>
<div class="tab-pane fade" id="website-code-3" role="tabpanel"
aria-labelledby="website-tab-3">
<pre class="language-html" data-src="{{ website_download_3 }}" data-download-link>
<code class="language-html code-landing-html">
{{ website_snippet_3 }}
</code>
</pre>
</div>
<div class="tab-pane fade" id="website-code-4" role="tabpanel"
aria-labelledby="website-tab-4">
<pre class="language-css" data-src="{{ website_download_4 }}" data-download-link>
<code class="language-css code-stylesheet-css">
{{ website_snippet_4 }}
</code>
</pre>
</div>
</div>
<!-- Chatbot code -->
<div class="tab-content" id="chatbot-tab-content" style="display:none">
<div class="tab-pane fade show active" id="chatbot-code-1" role="tabpanel"
aria-labelledby="chatbot-tab-1">
<pre class="language-go" data-src="{{ chatbot_download_1 }}" data-download-link>
<code class="language-go code-chatbot-go">
{{ chatbot_snippet_1 }}
</code>
</pre>
</div>
</div>
</div>
</div>
<div id="AI-assistant">
Hello, I am your personal AI assistant. Please ask me anything about Kris!
<br><br>
<div id="AI-message-area">
</div>
<br>
<textarea id="AI-message-input" cols="30" rows="2">
</textarea>
<button type="button" class="btn btn-info btn-sm" id="AI-send-button" onclick="ask_chatbot_question()">Send <i class="fa-solid fa-paper-plane"/></i></button>
</div>
</div>
</body>
</html>
/* ******************************************************
* stylesheet.css: CSS for landing.html *
* https://git.krischerven.info/dev/portfolio-webpage *
****************************************************** */
@font-face {
src: url("../fonts/DejaVuSansMono/DejaVuSansMono.ttf");
font-family: 'DejaVuSansMono';
}
@font-face {
src: url("../fonts/DejaVuSansMono/DejaVuSansMono-Oblique.ttf");
font-family: 'DejaVuSansMono';
font-style: italic;
}
@font-face {
src: url("../fonts/DejaVuSansMono/DejaVuSansMono-Bold.ttf");
font-family: 'DejaVuSansMono';
font-weight: bold;
}
@font-face {
src: url("../fonts/DejaVuSansMono/DejaVuSansMono-BoldOblique.ttf");
font-family: 'DejaVuSansMono';
font-weight: bold;
font-style: italic;
}
#welcome-blurb {
animation: slideIn 1.2s forwards;
-webkit-animation: slideIn 1.2s forwards;
transform: translateX(10%);
-webkit-transform: translateX(10%);
opacity: 0;
}
@keyframes slideIn {
100% { transform: translateX(0%); opacity: 1.0; }
}
@-webkit-keyframes slideIn {
100% { transform: translateX(0%); opacity: 1.0; }
}
.tab-content pre {
max-height:90vh;
}
/* Prism override to fix line numbers */
.line-numbers .line-numbers-rows {
font-size: 16px !important;
top: -0.19em !important;
}
div.codearea {
width: 33.3%;
max-width: 100%;
border-top: 1px solid #E5E5E5;
padding-top: 0.5em;
margin-top: 0.3em;
}
div.codearea pre code {
font-family: 'DejaVu Sans Mono';
font-size: 8px;
}
div.side-navbar {
font-size: 0.7em;
-ms-flex: 0 0 100%;
flex: 0 0 100%;
}
#AI-assistant {
opacity: 0;
display: none;
animation: fadeIn 0.3s forwards;
-webkit-animation: fadeIn 0.3s forwards;
position: absolute;
background-color: #d7d7d7;
border-style: outset;
padding: 15px;
width: 400px;
left: 700px;
top: 100px;
}
@media (max-width: 1103px) {
#AI-assistant {
left: 600px;
}
}
@media (max-width: 1026px) {
#AI-assistant {
left: 235px;
top: 135px;
}
}
@media (max-width: 991px) {
#AI-assistant {
left: 495px;
}
}
@media (max-width: 897px) {
#AI-assistant {
left: 384px;
}
}
@media (max-width: 804px) {
#AI-assistant {
display: none !important;
}
#toggle-AI-assistant {
display: none;
}
}
#AI-message-area {
max-height: 700px;
overflow-y: scroll;
}
@keyframes fadeIn {
100% { opacity: 1.0; }
}
@-webkit-keyframes fadeIn {
100% { opacity: 1.0; }
}
#AI-assistant.fade-out {
opacity: 1.0;
animation: fadeOut 0.3s forwards;
-webkit-animation: fadeOut 0.3s forwards;
}
@keyframes fadeOut {
100% { opacity: 0; }
}
@-webkit-keyframes fadeOut {
100% { opacity: 0; }
}
#AI-assistant button {
height:38px;
font-size:0.9em;
margin-top:2px;
}
/* ********************************
* Layout-related media queries *
******************************** */
/* Small devices (landscape phones, 576px and up) */
@media (min-width: 576px) {
div.side-navbar {
font-size: 1em;
}
}
/* X-Large devices (large desktops, 1200px and up) */
@media (min-width: 1200px) {
div.col.codearea {
border-top: none;
padding-top: 0em;
margin-top: 0em;
}
div.side-navbar {
-ms-flex: 0 0 12%;
flex: 0 0 12%;
font-size: 1.2em;
}
}
/* XX-Large devices (larger desktops, 1400px and up) */
/* Currently there is no special logic for these devices. */
@media (min-width: 1400px) {}
/* *****************************************************************
* main.go: The entire source code for the chatbot on this page. *
* https://git.krischerven.info/dev/portfolio-chatbot *
***************************************************************** */
package main
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
openai "github.com/sashabaranov/go-openai"
logrus "github.com/sirupsen/logrus"
"math/rand"
)
type debugMode_t int
const (
__debugModeOff debugMode_t = iota
debugModeSimple
debugModeAdvanced
)
type rateLimitTestMode_t int
const (
rateLimitByUUID rateLimitTestMode_t = iota
rateLimitByIpAddrHash
rateLimitByUUIDAndIpAddrHash
)
const (
instructions1 = `You are an assistant who answers career-related questions about a software engineer named Kris Cherven. The following
is information about his career. In this information, there is a 'facts section' and a 'resume section'. Information in the facts section
takes priority over information in the resume section. The resume section starts after the text BEGINNING OF RESUME SECTION and ends at the
text END OF RESUME SECTION. The facts section starts after the text BEGINNING OF FACTS SECTION and ends at the text END OF FACTS SECTION.
When answering questions about the school Kris Cherven went to, talk about Grand Circus Java Bootcamp. Do not mention the 'facts section'
or the 'resume section', or "the information provided" or any other meta-information provided in this paragraph when answering questions.
The information about Kris Cherven is as follows:`
instructions2 = `Please answer the last of the following questions about Kris Cherven, using the preceding chat history as context.
In the chat history, you are "AI" and the questioner is "USER". However, new messages should never be prefixed with "AI:". Also remember
that you only have about 10 KB of chat history. Please try to answer the question briefly. If you do not understand the question, or if
the question is not a valid English question, please ask the questioner to clarify what they are asking:`
debugMode = debugModeSimple
)
var (
facts = []string{
// <2023-09-06 Wed> GPT-3 thinks its October 2022 unless you tell it otherwise
fmt.Sprintf("The current date is %s %d, %d.", time.Now().Month(), time.Now().Day(), time.Now().Year()),
"Kris Cherven is 24 years old.",
}
falseResponseN = make(map[string]uint64)
log = logrus.New()
rateLimitTestMode = rateLimitByUUIDAndIpAddrHash
storageLimitPerClient = 1024 * 10
GCMessageThreshold = 10000
GCTimeThreshold = 7200 * 1000
)
func rateLimitMessage(timeRemaining int) string {
timeRemaining = Max(1, timeRemaining)
if timeRemaining == 1 {
return fmt.Sprintf("Sorry, but please wait %d more second before sending another message.", timeRemaining)
} else {
return fmt.Sprintf("Sorry, but please wait %d more seconds before sending another message.", timeRemaining)
}
}
func initializeClient() *openai.Client {
apiKey := strings.TrimRight(readFile("API_KEY"), "\r\n")
if apiKey == "" {
log.Fatal("Missing API key")
}
return openai.NewClient(apiKey)
}
func information() string {
outFile := "instructions.txt"
{
if fileExists("resume.pdf") {
fail(exec.Command("pdftotext", "resume.pdf").Run())
// Use the resume.pdf from the parent project (portfolio-webpage)
} else if fileExists("../portfolio-webpage-untracked/resume.pdf") {
fail(exec.Command("pdftotext", "../portfolio-webpage-untracked/resume.pdf").Run())
fail(exec.Command("mv", "../portfolio-webpage-untracked/resume.txt", "resume.txt").Run())
} else {
log.Fatal("resume.pdf does not exist; aborting")
}
fail(exec.Command("mv", "resume.txt", outFile).Run())
text := readFile(outFile)
text = strings.Replace(instructions1, "\n", " ", -1) + "\n\nBEGINNING OF RESUME SECTION\n\n" + text + "\n\nEND OF RESUME SECTION\n\n"
text = text + "BEGINNING OF FACTS SECTION\n\n" + strings.Join(facts, "\n") + "\n\nEND OF FACTS SECTION\n\n" + instructions2 + "\n"
os.WriteFile(outFile, []byte(text), 0644)
}
return readFile(outFile)
}
type WIPsettings struct {
chatbotEnabled Maybe_t[bool]
falseResponse Maybe_t[bool]
maxQuestionLength Maybe_t[int]
rateLimitCount Maybe_t[int]
rateLimitDelay Maybe_t[int]
}
type settings struct {
chatbotEnabled bool
falseResponse bool
maxQuestionLength int
rateLimitCount int
rateLimitDelay int
}
func getSettings() settings {
settings_ := WIPsettings{}
loadSettings := func(fileName string, oldSettings *settings) settings {
lines := strings.Split(readFile(fileName), "\n")
for i, line := range lines {
if i == len(lines)-1 && strings.Trim(line, " ") == "" {
break
}
kv := strings.Split(line, "=")
if len(kv) != 2 {
log.Errorf("Found malformed line '%s' in %s; skipping", line, fileName)
continue
}
setting := kv[0]
val := kv[1]
outOfRange := func(val, lRange, uRange int64) {
if val < lRange {
log.Fatalf("%s: Setting '%s' is out-of-range (%d < %d)", fileName, setting, val, lRange)
} else if val > uRange {
log.Fatalf("%s: Setting '%s' is out-of-range (%d > %d)", fileName, setting, val, uRange)
}
}
switch setting {
case "chatbot-enabled":
if val == "true" || val == "false" {
b, err := strconv.ParseBool(val)
settings_.chatbotEnabled = Maybe(b)
fail(err)
} else {
log.Fatalf("%s: Setting '%s' has invalid val '%v'", fileName, setting, val)
}
case "false-response":
if val == "true" || val == "false" {
b, err := strconv.ParseBool(val)
settings_.falseResponse = Maybe(b)
fail(err)
} else {
log.Fatalf("%s: Setting '%s' has invalid val '%v'", fileName, setting, val)
}
case "max-question-length":
len, err := strconv.ParseInt(val, 10, 64)
if err == nil {
outOfRange(len, 1, 2000)
settings_.maxQuestionLength = Maybe(int(len))
} else {
log.Fatalf("%s: Setting '%s' has invalid val '%v'", fileName, setting, val)
}
case "rate-limit-count":
len, err := strconv.ParseInt(val, 10, 64)
if err == nil {
outOfRange(len, 1, 100)
settings_.rateLimitCount = Maybe(int(len))
} else {
log.Fatalf("%s: Setting '%s' has invalid val '%v'", fileName, setting, val)
}
case "rate-limit-delay":
len, err := strconv.ParseInt(val, 10, 64)
if err == nil {
outOfRange(len, 30, 3600*1000)
settings_.rateLimitDelay = Maybe(int(len))
} else {
log.Fatalf("%s: Setting '%s' has invalid val '%v'", fileName, setting, val)
}
default:
log.Errorf("%s: Found setting '%s' with val '%v', but it's not a valid setting.", fileName, setting, val)
}
}
if oldSettings == nil {
if !settings_.chatbotEnabled.ok {
log.Fatalf("%s: Missing setting: chatbot-enabled", fileName)
}
if !settings_.falseResponse.ok {
log.Fatalf("%s: Missing setting: false-response", fileName)
}
if !settings_.maxQuestionLength.ok {
log.Fatalf("%s: Missing setting: max-question-length", fileName)
}
if !settings_.rateLimitCount.ok {
log.Fatalf("%s: Missing setting: rate-limit-count", fileName)
}
if !settings_.rateLimitDelay.ok {
log.Fatalf("%s: Missing setting: rate-limit-delay", fileName)
}
} else {
if !settings_.chatbotEnabled.ok {
settings_.chatbotEnabled = Maybe(oldSettings.chatbotEnabled)
}
if !settings_.falseResponse.ok {
settings_.falseResponse = Maybe(oldSettings.falseResponse)
}
if !settings_.maxQuestionLength.ok {
settings_.maxQuestionLength = Maybe(oldSettings.maxQuestionLength)
}
if !settings_.rateLimitCount.ok {
settings_.rateLimitCount = Maybe(oldSettings.rateLimitCount)
}
if !settings_.rateLimitDelay.ok {
settings_.rateLimitDelay = Maybe(oldSettings.rateLimitDelay)
}
}
return settings{
settings_.chatbotEnabled.v,
settings_.falseResponse.v,
settings_.maxQuestionLength.v,
settings_.rateLimitCount.v,
settings_.rateLimitDelay.v,
}
}
settings := loadSettings("./settings", nil)
if fileExists("./local-settings") {
settings = loadSettings("./local-settings", &settings)
}
return settings
}
func debugln(yes bool, x ...interface{}) {
if !yes {
return
}
assert(len(x) > 0, "debugln called with only one argument")
switch x[0].(type) {
case string:
// go vet (and therefore go test) fails if we call a custom function with <fmt.Printf>-style
// formatting directives (e.g., %s, %d), so we use dollar-sign style directives (e.g., $s, $d) instead
fmt.Printf(strings.Replace(x[0].(string), "$", "%", -1)+"\n", x[1:]...)
default:
fmt.Println(x...)
}
}
func answerQuestion(uuid string, ipAddrHash string, question string, settings settings, ctx context.Context,
conn *pgx.Conn, client *openai.Client, debugMode debugMode_t) string {
if settings.chatbotEnabled == false {
return "Sorry, but I cannot answer your question at the moment. Please try again later."
}
// Only relevant when portfolio-chatbot is run interactively; It's impossible to send empty messages via the frontend
if len(strings.Trim(question, " \t\n\r\v\f")) == 0 {
return "Please ask me a question."
}
if len(question) > settings.maxQuestionLength {
return fmt.Sprintf("You question is too long (>%d characters). Please condense it and try again.",
settings.maxQuestionLength)
}
exec := func(query string, args ...any) {
unwrap(conn.Exec(ctx, query, args...))
}
query := func(query string, args ...any) pgx.Rows {
return unwrap(conn.Query(ctx, query, args...))
}
rows := query(`SELECT (EXTRACT(EPOCH FROM (current_timestamp - timestamp_)) * 1000)::INT
FROM ratelimit
WHERE (key = $1 OR key = $2)
AND count >= $3
AND EXTRACT(EPOCH FROM (current_timestamp - timestamp_))*1000 < $4`,
uuid, ipAddrHash, settings.rateLimitCount, settings.rateLimitDelay)
defer finishRows(rows)
if rows.Next() {
var timeElapsed int
fail(rows.Scan(&timeElapsed))
return rateLimitMessage(Ceil((float64(settings.rateLimitDelay) - float64(timeElapsed)) / 1000.0))
}
for _, key := range []string{uuid, ipAddrHash} {
rows = query(`SELECT key
FROM ratelimit
WHERE key = $1
AND count > 1
AND EXTRACT(EPOCH FROM (current_timestamp - timestamp_))*1000 >= $2`,
key, settings.rateLimitDelay)
if rows.Next() {
finishRows(rows)
exec("UPDATE ratelimit SET count = 0 WHERE key = $1", key)
} else {
finishRows(rows)
}
}
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Every ~10,000 (GCMessageThreshold) messages (within an order of magnitude of 10 MB of data) ,
// prune messages older than 2 hours (GCTimeThreshold/1000 seconds). FIXME write a rationale for this
rnum := rng.Intn(GCMessageThreshold)
if rnum < 1 {
debugln(debugMode >= debugModeSimple, "Running random GC")
exec(`DELETE FROM message_queue
WHERE id IN (
SELECT id
FROM message_queue
WHERE uuid = (
SELECT uuid
FROM last_activity
WHERE uuid = $1
LIMIT 1
)
AND EXTRACT(EPOCH FROM (current_timestamp - timestamp_))*1000 >= $2
)`, uuid, GCTimeThreshold)
}
exec("INSERT INTO message_queue (uuid, message) VALUES ($1, $2)",
uuid, fmt.Sprintf("USER: %s", question))
exec(`INSERT INTO last_activity (uuid) VALUES ($1)
ON CONFLICT (uuid)
DO UPDATE SET timestamp_ = DEFAULT`, uuid)
if rateLimitTestMode == rateLimitByUUID || rateLimitTestMode == rateLimitByUUIDAndIpAddrHash {
exec(`INSERT INTO ratelimit (key) VALUES ($1)
ON CONFLICT (key)
DO UPDATE SET count = ratelimit.count + 1, timestamp_ = DEFAULT`, uuid)
}
if rateLimitTestMode == rateLimitByIpAddrHash || rateLimitTestMode == rateLimitByUUIDAndIpAddrHash {
exec(`INSERT INTO ratelimit (key) VALUES ($1)
ON CONFLICT (key)
DO UPDATE SET count = ratelimit.count + 1, timestamp_ = DEFAULT`, ipAddrHash)
}
rows = query("SELECT message FROM message_queue WHERE uuid = $1 ORDER BY timestamp_ ASC", uuid)
defer finishRows(rows)
var recentQuestions []string
var questionSizes []int
var questionsSize int
for rows.Next() {
var question string
fail(rows.Scan(&question))
recentQuestions = append(recentQuestions, question)
questionSizes = append(questionSizes, len(question))
questionsSize += len(question)
}
// Start deleting questions after this user has consumed more than ~10KB storage
removedOldStorage := false
for questionsSize > storageLimitPerClient {
removedOldStorage = true
uuid_prefix := strings.Split(uuid, "-")[0]
if debugMode >= debugModeSimple {
fmt.Printf("Pruning storage for %s... due to data exceeding %dKB (%d bytes left)\n",
uuid_prefix,
storageLimitPerClient/1024,
questionsSize)
}
exec(`DELETE FROM message_queue
WHERE id = (
SELECT id
FROM message_queue
WHERE uuid = $1
ORDER BY timestamp_ ASC
LIMIT 1
)`, uuid)
questionsSize -= questionSizes[0]
questionSizes = questionSizes[1:]
}
if removedOldStorage {
debugln(debugMode >= debugModeSimple, "Done pruning storage for $s. New storage is $d bytes\n", uuid, questionsSize)
}
debugln(debugMode >= debugModeSimple,
"--- BEGIN PREVIOUS CONVERSATION LOG ---\n"+strings.Join(recentQuestions, "\n")+"\n--- END PREVIOUS CONVERSATION LOG ---")
content := information() + "\n" + strings.Join(recentQuestions, "\n")
if settings.falseResponse || client == nil {
falseResponseN[uuid]++
response := fmt.Sprintf("Response message #%d", falseResponseN[uuid])
exec(`INSERT INTO message_queue (uuid, message) VALUES ($1, $2)`, uuid, fmt.Sprintf("AI: %s", response))
return response
} else {
// https://pkg.go.dev/github.com/sashabaranov/go-openai#Client.CreateChatCompletion
resp, err := client.CreateChatCompletion(ctx,
openai.ChatCompletionRequest{
Model: openai.GPT4,
MaxTokens: 200,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleUser,
Content: content,
},
},
})
fail(err)
response := resp.Choices[0].Message.Content
exec(`INSERT INTO message_queue (uuid, message) VALUES ($1, $2)`, uuid, fmt.Sprintf("AI: %s", response))
return response
}
}
func setupDB(ctx context.Context) *pgx.Conn {
conn, err := pgx.Connect(ctx, "postgres://portfolio_cb_user@localhost:5432/portfolio_cb")
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
os.Exit(1)
}
exec := func(query string, args ...any) {
unwrap(conn.Exec(ctx, query, args...))
}
exec(`CREATE TABLE IF NOT EXISTS message_queue (id SERIAL PRIMARY KEY,
uuid TEXT,
message TEXT,
timestamp_ TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`)
exec(`CREATE TABLE IF NOT EXISTS last_activity (uuid TEXT PRIMARY KEY,
timestamp_ TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`)
exec(`CREATE TABLE IF NOT EXISTS ratelimit (key TEXT PRIMARY KEY,
count INTEGER DEFAULT 1,
timestamp_ TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`)
return conn
}
func main() {
settings := getSettings() // handle failure early
ctx := context.Background()
conn := setupDB(ctx)
defer conn.Close(ctx)
if len(os.Args) > 1 {
// command mode
if len(os.Args) == 4 {
fmt.Println(answerQuestion(os.Args[1], os.Args[2], os.Args[3], settings, ctx, conn, initializeClient(), __debugModeOff))
} else {
fmt.Println("Error: Wrong format: Should be ./portfolio-chatbot {uuid} {ipAddrHash} \"{question}\".")
}
} else {
// interactive mode
log.SetLevel(logrus.DebugLevel)
scanner := bufio.NewScanner(os.Stdin)
uuid_ := uuid.NewString()
fakeAddressHash := uuid.NewString()
client := initializeClient()
fmt.Println("(interactive mode) Hello! I am portfolio-chatbot. Please go ahead and ask me any questions you have about Kris!")
for scanner.Scan() {
settings = getSettings() // settings may have changed by now
fmt.Println(answerQuestion(uuid_, fakeAddressHash, scanner.Text(), settings, ctx, conn, client, debugMode))
}
}
}