From 45a5e92aee5f1e559fb5732421a80ad4b102f2d9 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Thu, 23 Oct 2025 22:29:12 -0400 Subject: [PATCH] Added conversation history (#4) Reviewed-on: https://git.torrtle.co/ryan/simbarag/pulls/4 Co-authored-by: Ryan Chen Co-committed-by: Ryan Chen --- app.py | 88 +++++++-- blueprints/conversation/__init__.py | 17 ++ blueprints/conversation/logic.py | 32 +++ blueprints/conversation/models.py | 41 ++++ main.py | 16 +- pyproject.toml | 8 + raggr-frontend/src/App.tsx | 293 ++++++++++++++++------------ uv.lock | 291 +++++++++++++++++++++++++++ 8 files changed, 641 insertions(+), 145 deletions(-) create mode 100644 blueprints/conversation/__init__.py create mode 100644 blueprints/conversation/logic.py create mode 100644 blueprints/conversation/models.py diff --git a/app.py b/app.py index d4f61d3..9997b1f 100644 --- a/app.py +++ b/app.py @@ -1,43 +1,101 @@ import os -from flask import Flask, request, jsonify, render_template, send_from_directory +from quart import Quart, request, jsonify, render_template, send_from_directory +from tortoise.contrib.quart import register_tortoise + +from quart_jwt_extended import JWTManager from main import consult_simba_oracle +from blueprints.conversation.logic import ( + get_the_only_conversation, + add_message_to_conversation, +) -app = Flask( +app = Quart( __name__, static_folder="raggr-frontend/dist/static", template_folder="raggr-frontend/dist", ) +app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "SECRET_KEY") +jwt = JWTManager(app) + +# Initialize Tortoise ORM +register_tortoise( + app, + db_url=os.getenv("DATABASE_URL", "sqlite://raggr.db"), + modules={"models": ["blueprints.conversation.models"]}, + generate_schemas=True, +) + # Serve React static files @app.route("/static/") -def static_files(filename): - return send_from_directory(app.static_folder, filename) +async def static_files(filename): + return await send_from_directory(app.static_folder, filename) # Serve the React app for all routes (catch-all) @app.route("/", defaults={"path": ""}) @app.route("/") -def serve_react_app(path): +async def serve_react_app(path): if path and os.path.exists(os.path.join(app.template_folder, path)): - return send_from_directory(app.template_folder, path) - return render_template("index.html") + return await send_from_directory(app.template_folder, path) + return await render_template("index.html") @app.route("/api/query", methods=["POST"]) -def query(): - data = request.get_json() +async def query(): + data = await request.get_json() query = data.get("query") - return jsonify({"response": consult_simba_oracle(query)}) + # add message to database + conversation = await get_the_only_conversation() + print(conversation) + await add_message_to_conversation( + conversation=conversation, message=query, speaker="user" + ) + + response = consult_simba_oracle(query) + await add_message_to_conversation( + conversation=conversation, message=response, speaker="simba" + ) + return jsonify({"response": response}) -@app.route("/api/ingest", methods=["POST"]) -def webhook(): - data = request.get_json() - print(data) - return jsonify({"status": "received"}) +@app.route("/api/messages", methods=["GET"]) +async def get_messages(): + conversation = await get_the_only_conversation() + # Prefetch related messages + await conversation.fetch_related("messages") + + # Manually serialize the conversation with messages + messages = [] + for msg in conversation.messages: + messages.append( + { + "id": str(msg.id), + "text": msg.text, + "speaker": msg.speaker.value, + "created_at": msg.created_at.isoformat(), + } + ) + + return jsonify( + { + "id": str(conversation.id), + "name": conversation.name, + "messages": messages, + "created_at": conversation.created_at.isoformat(), + "updated_at": conversation.updated_at.isoformat(), + } + ) + + +# @app.route("/api/ingest", methods=["POST"]) +# def webhook(): +# data = request.get_json() +# print(data) +# return jsonify({"status": "received"}) if __name__ == "__main__": diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py new file mode 100644 index 0000000..2b95f26 --- /dev/null +++ b/blueprints/conversation/__init__.py @@ -0,0 +1,17 @@ +from quart import Blueprint, jsonify +from .models import ( + Conversation, + PydConversation, +) + +conversation_blueprint = Blueprint( + "conversation_api", __name__, url_prefix="/api/conversation" +) + + +@conversation_blueprint.route("/") +async def get_conversation(conversation_id: str): + conversation = await Conversation.get(id=conversation_id) + serialized_conversation = await PydConversation.from_tortoise_orm(conversation) + + return jsonify(serialized_conversation.model_dump_json()) diff --git a/blueprints/conversation/logic.py b/blueprints/conversation/logic.py new file mode 100644 index 0000000..cad2815 --- /dev/null +++ b/blueprints/conversation/logic.py @@ -0,0 +1,32 @@ +from .models import Conversation, ConversationMessage + + +async def create_conversation(name: str = "") -> Conversation: + conversation = await Conversation.create(name=name) + return conversation + + +async def add_message_to_conversation( + conversation: Conversation, + message: str, + speaker: str, +) -> ConversationMessage: + print(conversation, message, speaker) + message = await ConversationMessage.create( + text=message, + speaker=speaker, + conversation=conversation, + ) + + return message + + +async def get_the_only_conversation() -> Conversation: + try: + conversation = await Conversation.all().first() + if conversation is None: + conversation = await Conversation.create(name="simba_chat") + except Exception as _e: + conversation = await Conversation.create(name="simba_chat") + + return conversation diff --git a/blueprints/conversation/models.py b/blueprints/conversation/models.py new file mode 100644 index 0000000..79fcc93 --- /dev/null +++ b/blueprints/conversation/models.py @@ -0,0 +1,41 @@ +import enum + +from tortoise.models import Model +from tortoise import fields +from tortoise.contrib.pydantic import ( + pydantic_queryset_creator, + pydantic_model_creator, +) + + +class Speaker(enum.Enum): + USER = "user" + SIMBA = "simba" + + +class Conversation(Model): + id = fields.UUIDField(primary_key=True) + name = fields.CharField(max_length=255) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "conversations" + + +class ConversationMessage(Model): + id = fields.UUIDField(primary_key=True) + text = fields.TextField() + conversation = fields.ForeignKeyField( + "models.Conversation", related_name="messages" + ) + created_at = fields.DatetimeField(auto_now_add=True) + speaker = fields.CharEnumField(enum_type=Speaker, max_length=10) + + class Meta: + table = "conversation_messages" + + +PydConversationMessage = pydantic_model_creator(ConversationMessage) +PydConversation = pydantic_model_creator(Conversation, name="Conversation") +PydListConversationMessage = pydantic_queryset_creator(ConversationMessage) diff --git a/main.py b/main.py index 2eda189..ba9a85c 100644 --- a/main.py +++ b/main.py @@ -222,14 +222,14 @@ if __name__ == "__main__": # if args.index: # with open(args.index) as file: - # extension = args.index.split(".")[-1] - # if extension == "pdf": - # pdf_path = ppngx.download_pdf_from_id(id=document_id) - # image_paths = pdf_to_image(filepath=pdf_path) - # print(f"summarizing {file}") - # generated_summary = summarize_pdf_image(filepaths=image_paths) - # elif extension in [".md", ".txt"]: - # chunk_text(texts=[file.readall()], collection=simba_docs) + # extension = args.index.split(".")[-1] + # if extension == "pdf": + # pdf_path = ppngx.download_pdf_from_id(id=document_id) + # image_paths = pdf_to_image(filepath=pdf_path) + # print(f"summarizing {file}") + # generated_summary = summarize_pdf_image(filepaths=image_paths) + # elif extension in [".md", ".txt"]: + # chunk_text(texts=[file.readall()], collection=simba_docs) if args.query: logging.info("Consulting oracle ...") diff --git a/pyproject.toml b/pyproject.toml index afa81db..d6ab609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,4 +16,12 @@ dependencies = [ "pymupdf>=1.24.0", "black>=25.9.0", "pillow-heif>=1.1.1", + "flask-jwt-extended>=4.7.1", + "bcrypt>=5.0.0", + "pony>=0.7.19", + "flask-login>=0.6.3", + "quart>=0.20.0", + "tortoise-orm>=0.25.1", + "quart-jwt-extended>=0.1.0", + "pre-commit>=4.3.0", ] diff --git a/raggr-frontend/src/App.tsx b/raggr-frontend/src/App.tsx index 1bbb1dc..a660f12 100644 --- a/raggr-frontend/src/App.tsx +++ b/raggr-frontend/src/App.tsx @@ -1,155 +1,204 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import axios from "axios"; import ReactMarkdown from "react-markdown"; import "./App.css"; type QuestionAnswer = { - question: string; - answer: string; + question: string; + answer: string; }; type QuestionBubbleProps = { - text: string; + text: string; }; type AnswerBubbleProps = { - text: string; - loading: string; + text: string; + loading: string; }; type QuestionAnswerPairProps = { - question: string; - answer: string; - loading: boolean; + question: string; + answer: string; + loading: boolean; +}; + +type Conversation = { + title: string; + id: string; +}; + +type Message = { + text: string; + speaker: "simba" | "user"; +}; + +type ConversationMenuProps = { + conversations: Conversation[]; +}; + +const ConversationMenu = ({ conversations }: ConversationMenuProps) => { + return ( +
+

askSimba!

+ {conversations.map((conversation) => ( +

+ {conversation.title} +

+ ))} +
+ ); }; const QuestionBubble = ({ text }: QuestionBubbleProps) => { - return
🤦: {text}
; + return
🤦: {text}
; }; const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => { - return ( -
- {loading ? ( -
-
-
-
-
-
-
-
-
-
- ) : ( -
- {"🐈: " + text} -
- )} -
- ); + return ( +
+ {loading ? ( +
+
+
+
+
+
+
+
+
+
+ ) : ( +
+ {"🐈: " + text} +
+ )} +
+ ); }; const QuestionAnswerPair = ({ - question, - answer, - loading, + question, + answer, + loading, }: QuestionAnswerPairProps) => { - return ( -
- - -
- ); + return ( +
+ + +
+ ); }; const App = () => { - const [query, setQuery] = useState(""); - const [answer, setAnswer] = useState(""); - const [simbaMode, setSimbaMode] = useState(false); - const [questionsAnswers, setQuestionsAnswers] = useState( - [] - ); + const [query, setQuery] = useState(""); + const [answer, setAnswer] = useState(""); + const [simbaMode, setSimbaMode] = useState(false); + const [questionsAnswers, setQuestionsAnswers] = useState( + [], + ); + const [messages, setMessages] = useState([]); + const [conversations, setConversations] = useState([ + { title: "simba meow meow", id: "uuid" }, + ]); - const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; + const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; - const handleQuestionSubmit = () => { - if (simbaMode) { - console.log("simba mode activated"); - const randomIndex = Math.floor(Math.random() * simbaAnswers.length); - const randomElement = simbaAnswers[randomIndex]; - setAnswer(randomElement); - setQuestionsAnswers( - questionsAnswers.concat([ - { - question: query, - answer: randomElement, - }, - ]) - ); - return; - } - const payload = { query: query }; - axios - .post("/api/query", payload) - .then((result) => - setQuestionsAnswers( - questionsAnswers.concat([ - { question: query, answer: result.data.response }, - ]) - ) - ); - }; - const handleQueryChange = (event) => { - setQuery(event.target.value); - }; - return ( -
-
-
-
-
-

ask simba!

-
- {questionsAnswers.map((qa) => ( - - ))} -