6 Commits

Author SHA1 Message Date
Ryan Chen
b3793d2d32 Adding web search infra 2026-01-11 17:35:05 -05:00
Ryan Chen
733ffae8cf RAG optimizations 2026-01-11 09:36:36 -05:00
Ryan Chen
07512409f1 Adding loading indicator 2026-01-11 09:22:28 -05:00
Ryan Chen
12eb110313 linter 2026-01-11 09:12:37 -05:00
ryan
1a026f76a1 Merge pull request 'okok' (#10) from rc/01012025-retitling into main
Reviewed-on: #10
2026-01-01 22:00:32 -05:00
Ryan Chen
da3a464897 okok 2026-01-01 22:00:12 -05:00
29 changed files with 2073 additions and 122 deletions

View File

@@ -19,7 +19,9 @@ OLLAMA_URL=http://192.168.1.14:11434
OLLAMA_HOST=http://192.168.1.14:11434 OLLAMA_HOST=http://192.168.1.14:11434
# ChromaDB Configuration # ChromaDB Configuration
CHROMADB_PATH=/path/to/chromadb # For Docker: This is automatically set to /app/data/chromadb
# For local development: Set to a local directory path
CHROMADB_PATH=./data/chromadb
# OpenAI Configuration # OpenAI Configuration
OPENAI_API_KEY=your-openai-api-key OPENAI_API_KEY=your-openai-api-key

2
.gitignore vendored
View File

@@ -14,5 +14,7 @@ wheels/
# Database files # Database files
chromadb/ chromadb/
chromadb_openai/
chroma_db/
database/ database/
*.db *.db

13
classifier.py Normal file
View File

@@ -0,0 +1,13 @@
import os
from llm import LLMClient
USE_OPENAI = os.getenv("OLLAMA_URL")
class Classifier:
def __init__(self):
self.llm_client = LLMClient()
def classify_query_by_action(self, query):
_prompt = "Classify the query into one of the following options: "

View File

@@ -1,5 +1,3 @@
version: "3.8"
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
@@ -17,7 +15,7 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
raggr-backend: raggr:
build: build:
context: ./services/raggr context: ./services/raggr
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
@@ -30,7 +28,7 @@ services:
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN} - PAPERLESS_TOKEN=${PAPERLESS_TOKEN}
- BASE_URL=${BASE_URL} - BASE_URL=${BASE_URL}
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
- CHROMADB_PATH=/app/chromadb - CHROMADB_PATH=/app/data/chromadb
- OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY}
- OIDC_ISSUER=${OIDC_ISSUER} - OIDC_ISSUER=${OIDC_ISSUER}
@@ -41,31 +39,28 @@ services:
- DATABASE_URL=postgres://raggr:raggr_dev_password@postgres:5432/raggr - DATABASE_URL=postgres://raggr:raggr_dev_password@postgres:5432/raggr
- FLASK_ENV=development - FLASK_ENV=development
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- NODE_ENV=development
- TAVILY_KEY=${TAVILIY_KEY}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
volumes: volumes:
# Mount source code for hot reload - chromadb_data:/app/data/chromadb
- ./services/raggr:/app develop:
# Exclude node_modules and Python cache watch:
- /app/raggr-frontend/node_modules # Sync+restart on any file change under services/raggr
- /app/__pycache__ - action: sync+restart
# Persist data path: ./services/raggr
- chromadb_data:/app/chromadb target: /app
command: sh -c "chmod +x /app/startup-dev.sh && /app/startup-dev.sh" ignore:
- __pycache__/
raggr-frontend: - "*.pyc"
build: - "*.pyo"
context: ./services/raggr/raggr-frontend - "*.pyd"
dockerfile: Dockerfile.dev - .git/
environment: - chromadb/
- NODE_ENV=development - node_modules/
volumes: - raggr-frontend/dist/
# Mount source code for hot reload
- ./services/raggr/raggr-frontend:/app
# Exclude node_modules to use container's version
- /app/node_modules
command: sh -c "yarn build && yarn watch:build"
volumes: volumes:
chromadb_data: chromadb_data:

View File

@@ -26,7 +26,7 @@ services:
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN} - PAPERLESS_TOKEN=${PAPERLESS_TOKEN}
- BASE_URL=${BASE_URL} - BASE_URL=${BASE_URL}
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
- CHROMADB_PATH=/app/chromadb - CHROMADB_PATH=/app/data/chromadb
- OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY}
- OIDC_ISSUER=${OIDC_ISSUER} - OIDC_ISSUER=${OIDC_ISSUER}
@@ -39,7 +39,7 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
volumes: volumes:
- chromadb_data:/app/chromadb - chromadb_data:/app/data/chromadb
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -2,10 +2,13 @@ FROM python:3.13-slim
WORKDIR /app WORKDIR /app
# Install system dependencies and uv # Install system dependencies, Node.js, uv, and yarn
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
build-essential \ build-essential \
curl \ curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g yarn \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh && curl -LsSf https://astral.sh/uv/install.sh | sh
@@ -18,16 +21,33 @@ COPY pyproject.toml ./
# Install Python dependencies using uv # Install Python dependencies using uv
RUN uv pip install --system -e . RUN uv pip install --system -e .
# Copy frontend package files and install dependencies
COPY raggr-frontend/package.json raggr-frontend/yarn.lock* raggr-frontend/
WORKDIR /app/raggr-frontend
RUN yarn install
# Copy application source code
WORKDIR /app
COPY . .
# Build frontend
WORKDIR /app/raggr-frontend
RUN yarn build
# Create ChromaDB and database directories # Create ChromaDB and database directories
WORKDIR /app
RUN mkdir -p /app/chromadb /app/database RUN mkdir -p /app/chromadb /app/database
# Expose port # Make startup script executable
EXPOSE 8080 RUN chmod +x /app/startup-dev.sh
# Set environment variables # Set environment variables
ENV PYTHONPATH=/app ENV PYTHONPATH=/app
ENV CHROMADB_PATH=/app/chromadb ENV CHROMADB_PATH=/app/chromadb
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
# The actual source code will be mounted as a volume # Expose port
# No CMD here - will be specified in docker-compose EXPOSE 8080
# Default command
CMD ["/app/startup-dev.sh"]

View File

@@ -0,0 +1,97 @@
# Vector Store Management
This document describes how to manage the ChromaDB vector store used for RAG (Retrieval-Augmented Generation).
## Configuration
The vector store location is controlled by the `CHROMADB_PATH` environment variable:
- **Development (local)**: Set in `.env` to a local path (e.g., `/path/to/chromadb`)
- **Docker**: Automatically set to `/app/data/chromadb` and persisted via Docker volume
## Management Commands
### CLI (Command Line)
Use the `manage_vectorstore.py` script for vector store operations:
```bash
# Show statistics
python manage_vectorstore.py stats
# Index documents from Paperless-NGX (incremental)
python manage_vectorstore.py index
# Clear and reindex all documents
python manage_vectorstore.py reindex
# List documents
python manage_vectorstore.py list 10
python manage_vectorstore.py list 20 --show-content
```
### Docker
Run commands inside the Docker container:
```bash
# Show statistics
docker compose -f docker-compose.dev.yml exec -T raggr python manage_vectorstore.py stats
# Reindex all documents
docker compose -f docker-compose.dev.yml exec -T raggr python manage_vectorstore.py reindex
```
### API Endpoints
The following authenticated endpoints are available:
- `GET /api/rag/stats` - Get vector store statistics
- `POST /api/rag/index` - Trigger indexing of new documents
- `POST /api/rag/reindex` - Clear and reindex all documents
## How It Works
1. **Document Fetching**: Documents are fetched from Paperless-NGX via the API
2. **Chunking**: Documents are split into chunks of ~1000 characters with 200 character overlap
3. **Embedding**: Chunks are embedded using OpenAI's `text-embedding-3-large` model
4. **Storage**: Embeddings are stored in ChromaDB with metadata (filename, document type, date)
5. **Retrieval**: User queries are embedded and similar chunks are retrieved for RAG
## Troubleshooting
### "Error creating hnsw segment reader"
This indicates a corrupted index. Solution:
```bash
python manage_vectorstore.py reindex
```
### Empty results
Check if documents are indexed:
```bash
python manage_vectorstore.py stats
```
If count is 0, run:
```bash
python manage_vectorstore.py index
```
### Different results in Docker vs local
Docker and local environments use separate ChromaDB instances. To sync:
1. Index inside Docker: `docker compose exec -T raggr python manage_vectorstore.py reindex`
2. Or mount the same volume for both environments
## Production Considerations
1. **Volume Persistence**: Use Docker volumes or persistent storage for ChromaDB
2. **Backup**: Regularly backup the ChromaDB data directory
3. **Reindexing**: Schedule periodic reindexing to keep data fresh
4. **Monitoring**: Monitor the `/api/rag/stats` endpoint for document counts

View File

@@ -6,6 +6,7 @@ from tortoise.contrib.quart import register_tortoise
import blueprints.conversation import blueprints.conversation
import blueprints.conversation.logic import blueprints.conversation.logic
import blueprints.rag
import blueprints.users import blueprints.users
import blueprints.users.models import blueprints.users.models
from main import consult_simba_oracle from main import consult_simba_oracle
@@ -22,12 +23,12 @@ jwt = JWTManager(app)
# Register blueprints # Register blueprints
app.register_blueprint(blueprints.users.user_blueprint) app.register_blueprint(blueprints.users.user_blueprint)
app.register_blueprint(blueprints.conversation.conversation_blueprint) app.register_blueprint(blueprints.conversation.conversation_blueprint)
app.register_blueprint(blueprints.rag.rag_blueprint)
# Database configuration with environment variable support # Database configuration with environment variable support
DATABASE_URL = os.getenv( DATABASE_URL = os.getenv(
"DATABASE_URL", "DATABASE_URL", "postgres://raggr:raggr_dev_password@localhost:5432/raggr"
"postgres://raggr:raggr_dev_password@localhost:5432/raggr"
) )
TORTOISE_CONFIG = { TORTOISE_CONFIG = {
@@ -123,10 +124,17 @@ async def get_messages():
} }
) )
name = conversation.name
if len(messages) > 8:
name = await blueprints.conversation.logic.rename_conversation(
user=user,
conversation=conversation,
)
return jsonify( return jsonify(
{ {
"id": str(conversation.id), "id": str(conversation.id),
"name": conversation.name, "name": name,
"messages": messages, "messages": messages,
"created_at": conversation.created_at.isoformat(), "created_at": conversation.created_at.isoformat(),
"updated_at": conversation.updated_at.isoformat(), "updated_at": conversation.updated_at.isoformat(),

View File

@@ -1,27 +1,88 @@
import datetime import datetime
from quart import Blueprint, jsonify, request
from quart_jwt_extended import ( from quart_jwt_extended import (
jwt_refresh_token_required,
get_jwt_identity, get_jwt_identity,
jwt_refresh_token_required,
) )
from quart import Blueprint, jsonify import blueprints.users.models
from .agents import main_agent
from .logic import (
add_message_to_conversation,
get_conversation_by_id,
rename_conversation,
)
from .models import ( from .models import (
Conversation, Conversation,
PydConversation, PydConversation,
PydListConversation, PydListConversation,
) )
import blueprints.users.models
conversation_blueprint = Blueprint( conversation_blueprint = Blueprint(
"conversation_api", __name__, url_prefix="/api/conversation" "conversation_api", __name__, url_prefix="/api/conversation"
) )
@conversation_blueprint.post("/query")
@jwt_refresh_token_required
async def query():
current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid)
data = await request.get_json()
query = data.get("query")
conversation_id = data.get("conversation_id")
conversation = await get_conversation_by_id(conversation_id)
await conversation.fetch_related("messages")
await add_message_to_conversation(
conversation=conversation,
message=query,
speaker="user",
user=user,
)
# Build conversation history from recent messages (last 10 for context)
recent_messages = (
conversation.messages[-10:]
if len(conversation.messages) > 10
else conversation.messages
)
messages_payload = [
{
"role": "system",
"content": "You are a helpful cat assistant named Simba that understands veterinary terms. When there are questions to you specifically, they are referring to Simba the cat. Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.\n\nIMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions.",
}
]
# Add recent conversation history
for msg in recent_messages[:-1]: # Exclude the message we just added
role = "user" if msg.speaker == "user" else "assistant"
messages_payload.append({"role": role, "content": msg.text})
# Add current query
messages_payload.append({"role": "user", "content": query})
payload = {"messages": messages_payload}
response = await main_agent.ainvoke(payload)
message = response.get("messages", [])[-1].content
await add_message_to_conversation(
conversation=conversation,
message=message,
speaker="simba",
user=user,
)
return jsonify({"response": message})
@conversation_blueprint.route("/<conversation_id>") @conversation_blueprint.route("/<conversation_id>")
@jwt_refresh_token_required
async def get_conversation(conversation_id: str): async def get_conversation(conversation_id: str):
conversation = await Conversation.get(id=conversation_id) conversation = await Conversation.get(id=conversation_id)
current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid)
await conversation.fetch_related("messages") await conversation.fetch_related("messages")
# Manually serialize the conversation with messages # Manually serialize the conversation with messages
@@ -35,11 +96,18 @@ async def get_conversation(conversation_id: str):
"created_at": msg.created_at.isoformat(), "created_at": msg.created_at.isoformat(),
} }
) )
name = conversation.name
if len(messages) > 8 and "datetime" in name.lower():
name = await rename_conversation(
user=user,
conversation=conversation,
)
print(name)
return jsonify( return jsonify(
{ {
"id": str(conversation.id), "id": str(conversation.id),
"name": conversation.name, "name": name,
"messages": messages, "messages": messages,
"created_at": conversation.created_at.isoformat(), "created_at": conversation.created_at.isoformat(),
"updated_at": conversation.updated_at.isoformat(), "updated_at": conversation.updated_at.isoformat(),

View File

@@ -0,0 +1,78 @@
import os
from typing import cast
from langchain.agents import create_agent
from langchain.chat_models import BaseChatModel
from langchain.tools import tool
from langchain_ollama import ChatOllama
from langchain_openai import ChatOpenAI
from tavily import AsyncTavilyClient
from blueprints.rag.logic import query_vector_store
openai_gpt_5_mini = ChatOpenAI(model="gpt-5-mini")
ollama_deepseek = ChatOllama(model="llama3.1:8b", base_url=os.getenv("OLLAMA_URL"))
model_with_fallback = cast(
BaseChatModel, ollama_deepseek.with_fallbacks([openai_gpt_5_mini])
)
client = AsyncTavilyClient(os.getenv("TAVILY_KEY"), "")
@tool
async def web_search(query: str) -> str:
"""Search the web for current information using Tavily.
Use this tool when you need to:
- Find current information not in the knowledge base
- Look up recent events, news, or updates
- Verify facts or get additional context
- Search for information outside of Simba's documents
Args:
query: The search query to look up on the web
Returns:
Search results from the web with titles, content, and source URLs
"""
response = await client.search(query=query, search_depth="basic")
results = response.get("results", [])
if not results:
return "No results found for the query."
formatted = "\n\n".join(
[
f"**{result['title']}**\n{result['content']}\nSource: {result['url']}"
for result in results[:5]
]
)
return formatted
@tool(response_format="content_and_artifact")
async def simba_search(query: str):
"""Search through Simba's medical records, veterinary documents, and personal information.
Use this tool whenever the user asks questions about:
- Simba's health history, medical records, or veterinary visits
- Medications, treatments, or diagnoses
- Weight, diet, or physical characteristics over time
- Veterinary recommendations or advice
- Ryan's (the owner's) information related to Simba
- Any factual information that would be found in documents
Args:
query: The user's question or information need about Simba
Returns:
Relevant information from Simba's documents
"""
print(f"[SIMBA SEARCH] Tool called with query: {query}")
serialized, docs = await query_vector_store(query=query)
print(f"[SIMBA SEARCH] Found {len(docs)} documents")
print(f"[SIMBA SEARCH] Serialized result length: {len(serialized)}")
print(f"[SIMBA SEARCH] First 200 chars: {serialized[:200]}")
return serialized, docs
main_agent = create_agent(model=model_with_fallback, tools=[simba_search, web_search])

View File

@@ -1,9 +1,10 @@
import tortoise.exceptions import tortoise.exceptions
from langchain_openai import ChatOpenAI
from .models import Conversation, ConversationMessage
import blueprints.users.models import blueprints.users.models
from .models import Conversation, ConversationMessage, RenameConversationOutputSchema
async def create_conversation(name: str = "") -> Conversation: async def create_conversation(name: str = "") -> Conversation:
conversation = await Conversation.create(name=name) conversation = await Conversation.create(name=name)
@@ -58,3 +59,22 @@ async def get_conversation_transcript(
messages.append(f"{message.speaker} at {message.created_at}: {message.text}") messages.append(f"{message.speaker} at {message.created_at}: {message.text}")
return "\n".join(messages) return "\n".join(messages)
async def rename_conversation(
user: blueprints.users.models.User,
conversation: Conversation,
) -> str:
messages: str = await get_conversation_transcript(
user=user, conversation=conversation
)
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(RenameConversationOutputSchema)
prompt = f"Summarize the following conversation into a sassy one-liner title:\n\n{messages}"
response = structured_llm.invoke(prompt)
new_name: str = response.get("title", "")
conversation.name = new_name
await conversation.save()
return new_name

View File

@@ -1,11 +1,18 @@
import enum import enum
from dataclasses import dataclass
from tortoise.models import Model
from tortoise import fields from tortoise import fields
from tortoise.contrib.pydantic import ( from tortoise.contrib.pydantic import (
pydantic_queryset_creator,
pydantic_model_creator, pydantic_model_creator,
pydantic_queryset_creator,
) )
from tortoise.models import Model
@dataclass
class RenameConversationOutputSchema:
title: str
justification: str
class Speaker(enum.Enum): class Speaker(enum.Enum):

View File

@@ -0,0 +1,46 @@
from quart import Blueprint, jsonify
from quart_jwt_extended import jwt_refresh_token_required
from .logic import get_vector_store_stats, index_documents, vector_store
rag_blueprint = Blueprint("rag_api", __name__, url_prefix="/api/rag")
@rag_blueprint.get("/stats")
@jwt_refresh_token_required
async def get_stats():
"""Get vector store statistics."""
stats = get_vector_store_stats()
return jsonify(stats)
@rag_blueprint.post("/index")
@jwt_refresh_token_required
async def trigger_index():
"""Trigger indexing of documents from Paperless-NGX."""
try:
await index_documents()
stats = get_vector_store_stats()
return jsonify({"status": "success", "stats": stats})
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@rag_blueprint.post("/reindex")
@jwt_refresh_token_required
async def trigger_reindex():
"""Clear and reindex all documents."""
try:
# Clear existing documents
collection = vector_store._collection
all_docs = collection.get()
if all_docs["ids"]:
collection.delete(ids=all_docs["ids"])
# Reindex
await index_documents()
stats = get_vector_store_stats()
return jsonify({"status": "success", "stats": stats})
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500

View File

@@ -0,0 +1,75 @@
import os
import tempfile
import httpx
class PaperlessNGXService:
def __init__(self):
self.base_url = os.getenv("BASE_URL")
self.token = os.getenv("PAPERLESS_TOKEN")
self.url = f"http://{os.getenv('BASE_URL')}/api/documents/?tags__id=8"
self.headers = {"Authorization": f"Token {os.getenv('PAPERLESS_TOKEN')}"}
def get_data(self):
print(f"Getting data from: {self.url}")
r = httpx.get(self.url, headers=self.headers)
results = r.json()["results"]
nextLink = r.json().get("next")
while nextLink:
r = httpx.get(nextLink, headers=self.headers)
results += r.json()["results"]
nextLink = r.json().get("next")
return results
def get_doc_by_id(self, doc_id: int):
url = f"http://{os.getenv('BASE_URL')}/api/documents/{doc_id}/"
r = httpx.get(url, headers=self.headers)
return r.json()
def download_pdf_from_id(self, id: int) -> str:
download_url = f"http://{os.getenv('BASE_URL')}/api/documents/{id}/download/"
response = httpx.get(
download_url, headers=self.headers, follow_redirects=True, timeout=30
)
response.raise_for_status()
# Use a temporary file for the downloaded PDF
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")
temp_file.write(response.content)
temp_file.close()
temp_pdf_path = temp_file.name
pdf_to_process = temp_pdf_path
return pdf_to_process
def upload_cleaned_content(self, document_id, data):
PUTS_URL = f"http://{os.getenv('BASE_URL')}/api/documents/{document_id}/"
r = httpx.put(PUTS_URL, headers=self.headers, data=data)
r.raise_for_status()
def upload_description(self, description_filepath, file, title, exif_date: str):
POST_URL = f"http://{os.getenv('BASE_URL')}/api/documents/post_document/"
files = {"document": ("description_filepath", file, "application/txt")}
data = {
"title": title,
"create": exif_date,
"document_type": 3,
"tags": [7],
}
r = httpx.post(POST_URL, headers=self.headers, data=data, files=files)
r.raise_for_status()
def get_tags(self):
GET_URL = f"http://{os.getenv('BASE_URL')}/api/tags/"
r = httpx.get(GET_URL, headers=self.headers)
data = r.json()
return {tag["id"]: tag["name"] for tag in data["results"]}
def get_doctypes(self):
GET_URL = f"http://{os.getenv('BASE_URL')}/api/document_types/"
r = httpx.get(GET_URL, headers=self.headers)
data = r.json()
return {doctype["id"]: doctype["name"] for doctype in data["results"]}

View File

@@ -0,0 +1,101 @@
import datetime
import os
from langchain_chroma import Chroma
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from .fetchers import PaperlessNGXService
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
collection_name="simba_docs",
embedding_function=embeddings,
persist_directory=os.getenv("CHROMADB_PATH", ""),
)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # chunk size (characters)
chunk_overlap=200, # chunk overlap (characters)
add_start_index=True, # track index in original document
)
def date_to_epoch(date_str: str) -> float:
split_date = date_str.split("-")
date = datetime.datetime(
int(split_date[0]),
int(split_date[1]),
int(split_date[2]),
0,
0,
0,
)
return date.timestamp()
async def fetch_documents_from_paperless_ngx() -> list[Document]:
ppngx = PaperlessNGXService()
data = ppngx.get_data()
doctypes = ppngx.get_doctypes()
documents = []
for doc in data:
metadata = {
"created_date": date_to_epoch(doc["created_date"]),
"filename": doc["original_file_name"],
"document_type": doctypes.get(doc["document_type"], ""),
}
documents.append(Document(page_content=doc["content"], metadata=metadata))
return documents
async def index_documents():
documents = await fetch_documents_from_paperless_ngx()
splits = text_splitter.split_documents(documents)
await vector_store.aadd_documents(documents=splits)
async def query_vector_store(query: str):
retrieved_docs = await vector_store.asimilarity_search(query, k=2)
serialized = "\n\n".join(
(f"Source: {doc.metadata}\nContent: {doc.page_content}")
for doc in retrieved_docs
)
return serialized, retrieved_docs
def get_vector_store_stats():
"""Get statistics about the vector store."""
collection = vector_store._collection
count = collection.count()
return {
"total_documents": count,
"collection_name": collection.name,
}
def list_all_documents(limit: int = 10):
"""List documents in the vector store with their metadata."""
collection = vector_store._collection
results = collection.get(limit=limit, include=["metadatas", "documents"])
documents = []
for i, doc_id in enumerate(results["ids"]):
documents.append(
{
"id": doc_id,
"metadata": results["metadatas"][i]
if results.get("metadatas")
else None,
"content_preview": results["documents"][i][:200]
if results.get("documents")
else None,
}
)
return documents

View File

View File

@@ -1,18 +1,16 @@
import httpx
import os
from pathlib import Path
import logging import logging
import tempfile import os
import sqlite3
import httpx
from dotenv import load_dotenv
from image_process import describe_simba_image from image_process import describe_simba_image
from request import PaperlessNGXService from request import PaperlessNGXService
import sqlite3
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
from dotenv import load_dotenv
load_dotenv() load_dotenv()
# Configuration from environment variables # Configuration from environment variables
@@ -89,7 +87,7 @@ if __name__ == "__main__":
image_date = description.image_date image_date = description.image_date
description_filepath = os.path.join( description_filepath = os.path.join(
"/Users/ryanchen/Programs/raggr", f"SIMBA_DESCRIBE_001.txt" "/Users/ryanchen/Programs/raggr", "SIMBA_DESCRIBE_001.txt"
) )
file = open(description_filepath, "w+") file = open(description_filepath, "w+")
file.write(image_description) file.write(image_description)

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""CLI tool to inspect the vector store contents."""
import argparse
import os
from dotenv import load_dotenv
from blueprints.rag.logic import (
get_vector_store_stats,
index_documents,
list_all_documents,
)
# Load .env from the root directory
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
env_path = os.path.join(root_dir, ".env")
load_dotenv(env_path)
def print_stats():
"""Print vector store statistics."""
stats = get_vector_store_stats()
print("=== Vector Store Statistics ===")
print(f"Collection Name: {stats['collection_name']}")
print(f"Total Documents: {stats['total_documents']}")
print()
def print_documents(limit: int = 10, show_content: bool = False):
"""Print documents in the vector store."""
docs = list_all_documents(limit=limit)
print(f"=== Documents (showing {len(docs)} of {limit} requested) ===\n")
for i, doc in enumerate(docs, 1):
print(f"Document {i}:")
print(f" ID: {doc['id']}")
print(f" Metadata: {doc['metadata']}")
if show_content:
print(f" Content Preview: {doc['content_preview']}")
print()
async def run_index():
"""Run the indexing process."""
print("Starting indexing process...")
await index_documents()
print("Indexing complete!")
print_stats()
def main():
import asyncio
parser = argparse.ArgumentParser(description="Inspect the vector store contents")
parser.add_argument(
"--stats", action="store_true", help="Show vector store statistics"
)
parser.add_argument(
"--list", type=int, metavar="N", help="List N documents from the vector store"
)
parser.add_argument(
"--show-content",
action="store_true",
help="Show content preview when listing documents",
)
parser.add_argument(
"--index",
action="store_true",
help="Index documents from Paperless-NGX into the vector store",
)
args = parser.parse_args()
# Handle indexing first if requested
if args.index:
asyncio.run(run_index())
return
# If no arguments provided, show stats by default
if not any([args.stats, args.list]):
args.stats = True
if args.stats:
print_stats()
if args.list:
print_documents(limit=args.list, show_content=args.show_content)
if __name__ == "__main__":
main()

View File

@@ -1,23 +1,19 @@
import argparse
import datetime import datetime
import logging import logging
import os import os
import sqlite3 import sqlite3
import argparse
import chromadb
import ollama
import time import time
import ollama
from dotenv import load_dotenv
from request import PaperlessNGXService import chromadb
from chunker import Chunker from chunker import Chunker
from cleaner import pdf_to_image, summarize_pdf_image from cleaner import pdf_to_image, summarize_pdf_image
from llm import LLMClient from llm import LLMClient
from query import QueryGenerator from query import QueryGenerator
from request import PaperlessNGXService
from dotenv import load_dotenv
_dotenv_loaded = load_dotenv() _dotenv_loaded = load_dotenv()
@@ -186,7 +182,7 @@ def consult_oracle(
def llm_chat(input: str, transcript: str = "") -> str: def llm_chat(input: str, transcript: str = "") -> str:
system_prompt = "You are a helpful assistant that understands veterinary terms." system_prompt = "You are a helpful assistant that understands veterinary terms."
transcript_prompt = f"Here is the message transcript thus far {transcript}." transcript_prompt = f"Here is the message transcript thus far {transcript}."
prompt = f"""Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive. prompt = f"""Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
{transcript_prompt if len(transcript) > 0 else ""} {transcript_prompt if len(transcript) > 0 else ""}
Respond to this prompt: {input}""" Respond to this prompt: {input}"""
output = llm_client.chat(prompt=prompt, system_prompt=system_prompt) output = llm_client.chat(prompt=prompt, system_prompt=system_prompt)

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Management script for vector store operations."""
import argparse
import asyncio
import sys
from blueprints.rag.logic import (
get_vector_store_stats,
index_documents,
list_all_documents,
vector_store,
)
def stats():
"""Show vector store statistics."""
stats = get_vector_store_stats()
print("=== Vector Store Statistics ===")
print(f"Collection: {stats['collection_name']}")
print(f"Total Documents: {stats['total_documents']}")
async def index():
"""Index documents from Paperless-NGX."""
print("Starting indexing process...")
print("Fetching documents from Paperless-NGX...")
await index_documents()
print("✓ Indexing complete!")
stats()
async def reindex():
"""Clear and reindex all documents."""
print("Clearing existing documents...")
collection = vector_store._collection
all_docs = collection.get()
if all_docs["ids"]:
print(f"Deleting {len(all_docs['ids'])} existing documents...")
collection.delete(ids=all_docs["ids"])
print("✓ Cleared")
else:
print("Collection is already empty")
await index()
def list_docs(limit: int = 10, show_content: bool = False):
"""List documents in the vector store."""
docs = list_all_documents(limit=limit)
print(f"\n=== Documents (showing {len(docs)}) ===\n")
for i, doc in enumerate(docs, 1):
print(f"Document {i}:")
print(f" ID: {doc['id']}")
print(f" Metadata: {doc['metadata']}")
if show_content:
print(f" Content: {doc['content_preview']}")
print()
def main():
parser = argparse.ArgumentParser(
description="Manage vector store for RAG system",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s stats # Show vector store statistics
%(prog)s index # Index new documents from Paperless-NGX
%(prog)s reindex # Clear and reindex all documents
%(prog)s list 10 # List first 10 documents
%(prog)s list 20 --show-content # List 20 documents with content preview
""",
)
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
# Stats command
subparsers.add_parser("stats", help="Show vector store statistics")
# Index command
subparsers.add_parser("index", help="Index documents from Paperless-NGX")
# Reindex command
subparsers.add_parser("reindex", help="Clear and reindex all documents")
# List command
list_parser = subparsers.add_parser("list", help="List documents in vector store")
list_parser.add_argument(
"limit", type=int, default=10, nargs="?", help="Number of documents to list"
)
list_parser.add_argument(
"--show-content", action="store_true", help="Show content preview"
)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
try:
if args.command == "stats":
stats()
elif args.command == "index":
asyncio.run(index())
elif args.command == "reindex":
asyncio.run(reindex())
elif args.command == "list":
list_docs(limit=args.limit, show_content=args.show_content)
except KeyboardInterrupt:
print("\n\nOperation cancelled by user")
sys.exit(1)
except Exception as e:
print(f"\n❌ Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -4,7 +4,39 @@ version = "0.1.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = ["chromadb>=1.1.0", "python-dotenv>=1.0.0", "flask>=3.1.2", "httpx>=0.28.1", "ollama>=0.6.0", "openai>=2.0.1", "pydantic>=2.11.9", "pillow>=10.0.0", "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", "tortoise-orm-stubs>=1.0.2", "aerich>=0.8.0", "tomlkit>=0.13.3", "authlib>=1.3.0", "asyncpg>=0.30.0"] dependencies = [
"chromadb>=1.1.0",
"python-dotenv>=1.0.0",
"flask>=3.1.2",
"httpx>=0.28.1",
"ollama>=0.6.0",
"openai>=2.0.1",
"pydantic>=2.11.9",
"pillow>=10.0.0",
"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",
"tortoise-orm-stubs>=1.0.2",
"aerich>=0.8.0",
"tomlkit>=0.13.3",
"authlib>=1.3.0",
"asyncpg>=0.30.0",
"langchain-openai>=1.1.6",
"langchain>=1.2.0",
"langchain-chroma>=1.0.0",
"langchain-community>=0.4.1",
"jq>=1.10.0",
"langchain-ollama>=1.0.1",
"tavily-python>=0.7.17",
]
[tool.aerich] [tool.aerich]
tortoise_orm = "app.TORTOISE_CONFIG" tortoise_orm = "app.TORTOISE_CONFIG"

View File

@@ -8,8 +8,11 @@ COPY package.json yarn.lock* ./
# Install dependencies # Install dependencies
RUN yarn install RUN yarn install
# Copy application source code
COPY . .
# Expose rsbuild dev server port (default 3000) # Expose rsbuild dev server port (default 3000)
EXPOSE 3000 EXPOSE 3000
# The actual source code will be mounted as a volume # Default command
# CMD will be specified in docker-compose CMD ["sh", "-c", "yarn build && yarn watch:build"]

View File

@@ -37,7 +37,7 @@ class ConversationService {
conversation_id: string, conversation_id: string,
): Promise<QueryResponse> { ): Promise<QueryResponse> {
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithRefreshToken(
`${this.baseUrl}/query`, `${this.conversationBaseUrl}/query`,
{ {
method: "POST", method: "POST",
body: JSON.stringify({ query, conversation_id }), body: JSON.stringify({ query, conversation_id }),

View File

@@ -40,6 +40,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
const [selectedConversation, setSelectedConversation] = const [selectedConversation, setSelectedConversation] =
useState<Conversation | null>(null); useState<Conversation | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false); const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
@@ -80,6 +81,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setConversations(parsedConversations); setConversations(parsedConversations);
setSelectedConversation(parsedConversations[0]); setSelectedConversation(parsedConversations[0]);
console.log(parsedConversations); console.log(parsedConversations);
console.log("JELLYFISH@");
} catch (error) { } catch (error) {
console.error("Failed to load messages:", error); console.error("Failed to load messages:", error);
} }
@@ -104,11 +106,18 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
useEffect(() => { useEffect(() => {
const loadMessages = async () => { const loadMessages = async () => {
console.log(selectedConversation);
console.log("JELLYFISH");
if (selectedConversation == null) return; if (selectedConversation == null) return;
try { try {
const conversation = await conversationService.getConversation( const conversation = await conversationService.getConversation(
selectedConversation.id, selectedConversation.id,
); );
// Update the conversation title in case it changed
setSelectedConversation({
id: conversation.id,
title: conversation.name,
});
setMessages( setMessages(
conversation.messages.map((message) => ({ conversation.messages.map((message) => ({
text: message.text, text: message.text,
@@ -120,14 +129,15 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
} }
}; };
loadMessages(); loadMessages();
}, [selectedConversation]); }, [selectedConversation?.id]);
const handleQuestionSubmit = async () => { const handleQuestionSubmit = async () => {
if (!query.trim()) return; // Don't submit empty messages if (!query.trim() || isLoading) return; // Don't submit empty messages or while loading
const currMessages = messages.concat([{ text: query, speaker: "user" }]); const currMessages = messages.concat([{ text: query, speaker: "user" }]);
setMessages(currMessages); setMessages(currMessages);
setQuery(""); // Clear input immediately after submission setQuery(""); // Clear input immediately after submission
setIsLoading(true);
if (simbaMode) { if (simbaMode) {
console.log("simba mode activated"); console.log("simba mode activated");
@@ -142,6 +152,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
}, },
]), ]),
); );
setIsLoading(false);
return; return;
} }
@@ -162,6 +173,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
if (error instanceof Error && error.message.includes("Session expired")) { if (error instanceof Error && error.message.includes("Session expired")) {
setAuthenticated(false); setAuthenticated(false);
} }
} finally {
setIsLoading(false);
} }
}; };
@@ -180,9 +193,11 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
return ( return (
<div className="h-screen flex flex-row bg-[#F9F5EB]"> <div className="h-screen flex flex-row bg-[#F9F5EB]">
{/* Sidebar - Expanded */} {/* Sidebar - Expanded */}
<aside className={`hidden md:flex md:flex-col bg-white border-r border-gray-200 p-4 overflow-y-auto transition-all duration-300 ${sidebarCollapsed ? 'w-20' : 'w-64'}`}> <aside
className={`hidden md:flex md:flex-col bg-[#F9F5EB] border-r border-gray-200 p-4 overflow-y-auto transition-all duration-300 ${sidebarCollapsed ? "w-20" : "w-64"}`}
>
{!sidebarCollapsed ? ( {!sidebarCollapsed ? (
<> <div className="bg-[#F9F5EB]">
<div className="flex flex-row items-center gap-2 mb-6"> <div className="flex flex-row items-center gap-2 mb-6">
<img <img
src={catIcon} src={catIcon}
@@ -205,7 +220,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
logout logout
</button> </button>
</div> </div>
</> </div>
) : ( ) : (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<img <img
@@ -243,7 +258,18 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</header> </header>
{/* Messages area */} {/* Messages area */}
<div className="flex-1 overflow-y-auto px-4 py-4"> {selectedConversation && (
<div className="sticky top-0 mx-auto w-full">
<div className="bg-[#F9F5EB] text-black px-6 w-full py-3">
<h2 className="text-lg font-semibold">
{selectedConversation.title || "Untitled Conversation"}
</h2>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto relative px-4 py-6">
{/* Floating conversation name */}
<div className="max-w-2xl mx-auto flex flex-col gap-4"> <div className="max-w-2xl mx-auto flex flex-col gap-4">
{showConversations && ( {showConversations && (
<div className="md:hidden"> <div className="md:hidden">
@@ -260,6 +286,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
} }
return <QuestionBubble key={index} text={msg.text} />; return <QuestionBubble key={index} text={msg.text} />;
})} })}
{isLoading && <AnswerBubble text="" loading={true} />}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
</div> </div>
@@ -273,6 +300,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
handleKeyDown={handleKeyDown} handleKeyDown={handleKeyDown}
handleQuestionSubmit={handleQuestionSubmit} handleQuestionSubmit={handleQuestionSubmit}
setSimbaMode={setSimbaMode} setSimbaMode={setSimbaMode}
isLoading={isLoading}
/> />
</div> </div>
</footer> </footer>

View File

@@ -1,43 +1,56 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
type MessageInputProps = { type MessageInputProps = {
handleQueryChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void; handleQueryChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleKeyDown: (event: React.ChangeEvent<HTMLTextAreaElement>) => void; handleKeyDown: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleQuestionSubmit: () => void; handleQuestionSubmit: () => void;
setSimbaMode: (sdf: boolean) => void; setSimbaMode: (sdf: boolean) => void;
query: string; query: string;
} isLoading: boolean;
};
export const MessageInput = ({query, handleKeyDown, handleQueryChange, handleQuestionSubmit, setSimbaMode}: MessageInputProps) => { export const MessageInput = ({
return ( query,
<div className="flex flex-col gap-4 sticky bottom-0 bg-[#3D763A] p-6 rounded-xl"> handleKeyDown,
<div className="flex flex-row justify-between grow"> handleQueryChange,
<textarea handleQuestionSubmit,
className="p-3 sm:p-4 border border-blue-200 rounded-md grow bg-[#F9F5EB] min-h-[44px] resize-y" setSimbaMode,
onChange={handleQueryChange} isLoading,
onKeyDown={handleKeyDown} }: MessageInputProps) => {
value={query} return (
rows={2} <div className="flex flex-col gap-4 sticky bottom-0 bg-[#3D763A] p-6 rounded-xl">
placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)" <div className="flex flex-row justify-between grow">
/> <textarea
</div> className="p-3 sm:p-4 border border-blue-200 rounded-md grow bg-[#F9F5EB] min-h-[44px] resize-y"
<div className="flex flex-row justify-between gap-2 grow"> onChange={handleQueryChange}
<button onKeyDown={handleKeyDown}
className="p-3 sm:p-4 min-h-[44px] border border-blue-400 bg-[#EDA541] hover:bg-blue-400 cursor-pointer rounded-md flex-grow text-sm sm:text-base" value={query}
onClick={() => handleQuestionSubmit()} rows={2}
type="submit" placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)"
> />
Submit </div>
</button> <div className="flex flex-row justify-between gap-2 grow">
</div> <button
<div className="flex flex-row justify-center gap-2 grow items-center"> className={`p-3 sm:p-4 min-h-[44px] border border-blue-400 rounded-md flex-grow text-sm sm:text-base ${
<input isLoading
type="checkbox" ? "bg-gray-400 cursor-not-allowed opacity-50"
onChange={(event) => setSimbaMode(event.target.checked)} : "bg-[#EDA541] hover:bg-blue-400 cursor-pointer"
className="w-5 h-5 cursor-pointer" }`}
/> onClick={() => handleQuestionSubmit()}
<p className="text-sm sm:text-base">simba mode?</p> type="submit"
</div> disabled={isLoading}
</div> >
); {isLoading ? "Sending..." : "Submit"}
} </button>
</div>
<div className="flex flex-row justify-center gap-2 grow items-center">
<input
type="checkbox"
onChange={(event) => setSimbaMode(event.target.checked)}
className="w-5 h-5 cursor-pointer"
/>
<p className="text-sm sm:text-base">simba mode?</p>
</div>
</div>
);
};

View File

@@ -2,13 +2,12 @@
set -e set -e
echo "Initializing directories..." echo "Initializing directories..."
mkdir -p /app/chromadb mkdir -p /app/data/chromadb
echo "Waiting for frontend to build..." echo "Rebuilding frontend..."
while [ ! -f /app/raggr-frontend/dist/index.html ]; do cd /app/raggr-frontend
sleep 1 yarn build
done cd /app
echo "Frontend built successfully!"
echo "Setting up database..." echo "Setting up database..."
# Give PostgreSQL a moment to be ready (healthcheck in docker-compose handles this) # Give PostgreSQL a moment to be ready (healthcheck in docker-compose handles this)
@@ -22,8 +21,5 @@ else
aerich init-db aerich init-db
fi fi
echo "Starting reindex process..."
python main.py "" --reindex || echo "Reindex failed, continuing anyway..."
echo "Starting Flask application in debug mode..." echo "Starting Flask application in debug mode..."
python app.py python app.py

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Test the query_vector_store function."""
import asyncio
import os
from dotenv import load_dotenv
from blueprints.rag.logic import query_vector_store
# Load .env from the root directory
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
env_path = os.path.join(root_dir, ".env")
load_dotenv(env_path)
async def test_query(query: str):
"""Test a query against the vector store."""
print(f"Query: {query}\n")
result, docs = await query_vector_store(query)
print(f"Found {len(docs)} documents\n")
print("Serialized result:")
print(result)
print("\n" + "=" * 80 + "\n")
async def main():
queries = [
"What is Simba's weight?",
"What medications is Simba taking?",
"Tell me about Simba's recent vet visits",
]
for query in queries:
await test_query(query)
if __name__ == "__main__":
asyncio.run(main())

1091
services/raggr/uv.lock generated

File diff suppressed because it is too large Load Diff

12
users.py Normal file
View File

@@ -0,0 +1,12 @@
import sqlite3
class User:
def __init__(self, email: str, password_hash: str):
self.email = email
self.is_authenticated
if __name__ == "__main__":
connection = sqlite3.connect("users.db")
c = connection.cursor()