Compare commits
86 Commits
data-prepr
...
97be5262a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97be5262a8 | ||
|
|
86cc269b3a | ||
|
|
0e3684031b | ||
|
|
6d7d713532 | ||
|
|
6ae36b51a0 | ||
|
|
f0f72cce36 | ||
|
|
32020a6c60 | ||
|
|
713a058c4f | ||
|
|
12f7d9ead1 | ||
|
|
ad39904dda | ||
|
|
1fd2e860b2 | ||
|
|
7cfad5baba | ||
|
|
f68a79bdb7 | ||
|
|
52153cdf1e | ||
|
|
6eb3775e0f | ||
|
|
b3793d2d32 | ||
|
|
033429798e | ||
|
|
733ffae8cf | ||
|
|
0895668ddd | ||
|
|
07512409f1 | ||
|
|
12eb110313 | ||
|
|
1a026f76a1 | ||
|
|
da3a464897 | ||
|
|
913875188a | ||
|
|
f5e2d68cd2 | ||
|
|
70799ffb7d | ||
|
|
7f1d4fbdda | ||
|
|
5ebdd60ea0 | ||
|
|
289045e7d0 | ||
|
|
ceea83cb54 | ||
|
|
1b60aab97c | ||
|
|
210bfc1476 | ||
|
|
454fb1b52c | ||
|
|
c3f2501585 | ||
|
|
1da21fabee | ||
|
|
dd5690ee53 | ||
|
|
5e7ac28b6f | ||
|
|
29f8894e4a | ||
|
|
19d1df2f68 | ||
|
|
e577cb335b | ||
|
|
591788dfa4 | ||
|
|
561b5bddce | ||
|
|
ddd455a4c6 | ||
|
|
07424e77e0 | ||
|
|
a56f752917 | ||
|
|
e8264e80ce | ||
|
|
04350045d3 | ||
|
|
f16e13fccc | ||
|
|
245db92524 | ||
|
|
29ac724d50 | ||
|
|
7161c09a4e | ||
|
|
68d73b62e8 | ||
|
|
6b616137d3 | ||
|
|
841b6ebd4f | ||
|
|
45a5e92aee | ||
|
|
8479898cc4 | ||
|
|
acaf681927 | ||
|
|
2bbe33fedc | ||
|
|
b872750444 | ||
|
|
376baccadb | ||
|
|
c978b1a255 | ||
|
|
51b9932389 | ||
|
|
ebf39480b6 | ||
|
|
e4a04331cb | ||
|
|
166ffb4c09 | ||
|
|
64e286e623 | ||
|
|
c6c14729dd | ||
|
|
910097d13b | ||
|
|
0bb3e3172b | ||
|
|
24b30bc8a3 | ||
|
|
3ffc95a1b0 | ||
|
|
c5091dc07a | ||
|
|
c140758560 | ||
|
|
ab3a0eb442 | ||
|
|
c619d78922 | ||
|
|
c20ae0a4b9 | ||
|
|
26cc01b58b | ||
|
|
746b60e070 | ||
|
|
577c9144ac | ||
|
|
2b2891bd79 | ||
|
|
03b033e9a4 | ||
|
|
a640ae5fed | ||
|
|
99c98b7e42 | ||
|
|
a69f7864f3 | ||
|
|
679cfb08e4 | ||
|
|
fc504d3e9c |
85
.env.example
Normal file
85
.env.example
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Database Configuration
|
||||||
|
# PostgreSQL is recommended (required for OIDC features)
|
||||||
|
DATABASE_URL=postgres://raggr:changeme@postgres:5432/raggr
|
||||||
|
|
||||||
|
# PostgreSQL credentials (if using docker-compose postgres service)
|
||||||
|
POSTGRES_USER=raggr
|
||||||
|
POSTGRES_PASSWORD=changeme
|
||||||
|
POSTGRES_DB=raggr
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET_KEY=your-secret-key-here
|
||||||
|
|
||||||
|
# Paperless Configuration
|
||||||
|
PAPERLESS_TOKEN=your-paperless-token
|
||||||
|
BASE_URL=192.168.1.5:8000
|
||||||
|
|
||||||
|
# llama-server Configuration (OpenAI-compatible API)
|
||||||
|
# If set, uses llama-server as the primary LLM backend with OpenAI as fallback
|
||||||
|
LLAMA_SERVER_URL=http://192.168.1.213:8080/v1
|
||||||
|
LLAMA_MODEL_NAME=llama-3.1-8b-instruct
|
||||||
|
|
||||||
|
# ChromaDB Configuration
|
||||||
|
# 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_API_KEY=your-openai-api-key
|
||||||
|
|
||||||
|
# Tavily Configuration (for web search)
|
||||||
|
TAVILY_API_KEY=your-tavily-api-key
|
||||||
|
|
||||||
|
# Immich Configuration
|
||||||
|
IMMICH_URL=http://192.168.1.5:2283
|
||||||
|
IMMICH_API_KEY=your-immich-api-key
|
||||||
|
SEARCH_QUERY=simba cat
|
||||||
|
DOWNLOAD_DIR=./simba_photos
|
||||||
|
|
||||||
|
# OIDC Configuration (Authelia)
|
||||||
|
OIDC_ISSUER=https://auth.example.com
|
||||||
|
OIDC_CLIENT_ID=simbarag
|
||||||
|
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||||
|
OIDC_REDIRECT_URI=http://localhost:8080/
|
||||||
|
OIDC_USE_DISCOVERY=true
|
||||||
|
|
||||||
|
# Optional: Manual OIDC endpoints (if discovery is disabled)
|
||||||
|
# OIDC_AUTHORIZATION_ENDPOINT=https://auth.example.com/api/oidc/authorization
|
||||||
|
# OIDC_TOKEN_ENDPOINT=https://auth.example.com/api/oidc/token
|
||||||
|
# OIDC_USERINFO_ENDPOINT=https://auth.example.com/api/oidc/userinfo
|
||||||
|
# OIDC_JWKS_URI=https://auth.example.com/api/oidc/jwks
|
||||||
|
|
||||||
|
# YNAB Configuration
|
||||||
|
# Get your Personal Access Token from https://app.ynab.com/settings/developer
|
||||||
|
YNAB_ACCESS_TOKEN=your-ynab-personal-access-token
|
||||||
|
# Optional: Specify a budget ID, or leave empty to use the default/first budget
|
||||||
|
YNAB_BUDGET_ID=
|
||||||
|
|
||||||
|
# Twilio Configuration (WhatsApp)
|
||||||
|
TWILIO_ACCOUNT_SID=your-twilio-account-sid
|
||||||
|
TWILIO_AUTH_TOKEN=your-twilio-auth-token
|
||||||
|
TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886
|
||||||
|
# Comma-separated list of WhatsApp numbers allowed to use the service (e.g., whatsapp:+1234567890)
|
||||||
|
# Use * to allow any number
|
||||||
|
ALLOWED_WHATSAPP_NUMBERS=
|
||||||
|
# Set to false to disable Twilio signature validation in development
|
||||||
|
TWILIO_SIGNATURE_VALIDATION=true
|
||||||
|
# If behind a reverse proxy, set this to your public webhook URL so signature validation works
|
||||||
|
# TWILIO_WEBHOOK_URL=https://your-domain.com/api/whatsapp/webhook
|
||||||
|
# Rate limiting: max messages per window (default: 10 messages per 60 seconds)
|
||||||
|
# WHATSAPP_RATE_LIMIT_MAX=10
|
||||||
|
# WHATSAPP_RATE_LIMIT_WINDOW=60
|
||||||
|
|
||||||
|
# Obsidian Configuration (headless sync)
|
||||||
|
# Auth token from Obsidian account (Settings → Account → API token)
|
||||||
|
OBSIDIAN_AUTH_TOKEN=your-obsidian-auth-token
|
||||||
|
# Vault ID to sync (found in Obsidian sync settings)
|
||||||
|
OBSIDIAN_VAULT_ID=your-vault-id
|
||||||
|
# End-to-end encryption password (if vault uses E2E encryption)
|
||||||
|
OBSIDIAN_E2E_PASSWORD=
|
||||||
|
# Device name shown in Obsidian sync activity
|
||||||
|
OBSIDIAN_DEVICE_NAME=simbarag
|
||||||
|
# Set to true to run continuous sync in the background
|
||||||
|
OBSIDIAN_CONTINUOUS_SYNC=false
|
||||||
|
# Local path to Obsidian vault (where files are synced)
|
||||||
|
OBSIDIAN_VAULT_PATH=/app/data/obsidian
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -9,5 +9,15 @@ wheels/
|
|||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
chromadb/
|
||||||
|
chromadb_openai/
|
||||||
|
chroma_db/
|
||||||
|
database/
|
||||||
|
*.db
|
||||||
|
|
||||||
|
obvault/
|
||||||
|
.claude
|
||||||
|
|||||||
6
.pre-commit-config.yaml
Normal file
6
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.8.2
|
||||||
|
hooks:
|
||||||
|
- id: ruff # Linter
|
||||||
|
- id: ruff-format # Formatter
|
||||||
109
CLAUDE.md
Normal file
109
CLAUDE.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
SimbaRAG is a RAG (Retrieval-Augmented Generation) conversational AI system for querying information about Simba (a cat). It ingests documents from Paperless-NGX, stores embeddings in ChromaDB, and uses LLMs (Ollama or OpenAI) to answer questions.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start environment
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f raggr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations (Aerich/Tortoise ORM)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate migration (must run in Docker with DB access)
|
||||||
|
docker compose exec raggr aerich migrate --name describe_change
|
||||||
|
|
||||||
|
# Apply migrations (auto-runs on startup, manual if needed)
|
||||||
|
docker compose exec raggr aerich upgrade
|
||||||
|
|
||||||
|
# View migration history
|
||||||
|
docker compose exec raggr aerich history
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd raggr-frontend
|
||||||
|
yarn install
|
||||||
|
yarn build # Production build
|
||||||
|
yarn dev # Dev server (rarely needed, backend serves frontend)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build raggr
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Docker Compose │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ raggr (port 8080) │ postgres (port 5432) │
|
||||||
|
│ ├── Quart backend │ PostgreSQL 16 │
|
||||||
|
│ ├── React frontend (served) │ │
|
||||||
|
│ └── ChromaDB (volume) │ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend** (root directory):
|
||||||
|
- `app.py` - Quart application entry, serves API and static frontend
|
||||||
|
- `main.py` - RAG logic, document indexing, LLM interaction, LangChain agent
|
||||||
|
- `llm.py` - LLM client with Ollama primary, OpenAI fallback
|
||||||
|
- `aerich_config.py` - Database migration configuration
|
||||||
|
- `blueprints/` - API routes organized as Quart blueprints
|
||||||
|
- `users/` - OIDC auth, JWT tokens, RBAC with LDAP groups
|
||||||
|
- `conversation/` - Chat conversations and message history
|
||||||
|
- `rag/` - Document indexing endpoints (admin-only)
|
||||||
|
- `config/` - Configuration modules
|
||||||
|
- `oidc_config.py` - OIDC authentication configuration
|
||||||
|
- `utils/` - Reusable utilities
|
||||||
|
- `chunker.py` - Document chunking for embeddings
|
||||||
|
- `cleaner.py` - PDF cleaning and summarization
|
||||||
|
- `image_process.py` - Image description with LLM
|
||||||
|
- `request.py` - Paperless-NGX API client
|
||||||
|
- `scripts/` - Administrative and utility scripts
|
||||||
|
- `add_user.py` - Create users manually
|
||||||
|
- `user_message_stats.py` - User message statistics
|
||||||
|
- `manage_vectorstore.py` - Vector store management CLI
|
||||||
|
- `inspect_vector_store.py` - Inspect ChromaDB contents
|
||||||
|
- `query.py` - Query generation utilities
|
||||||
|
- `migrations/` - Database migration files
|
||||||
|
|
||||||
|
**Frontend** (`raggr-frontend/`):
|
||||||
|
- React 19 with Rsbuild bundler
|
||||||
|
- Tailwind CSS for styling
|
||||||
|
- Built to `dist/`, served by backend at `/`
|
||||||
|
|
||||||
|
**Auth Flow**: LLDAP → Authelia (OIDC) → Backend JWT → Frontend localStorage
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
- All endpoints are async (`async def`)
|
||||||
|
- Use `@jwt_refresh_token_required` for authenticated endpoints
|
||||||
|
- Use `@admin_required` for admin-only endpoints (checks `lldap_admin` group)
|
||||||
|
- Tortoise ORM models in `blueprints/*/models.py`
|
||||||
|
- Frontend API services in `raggr-frontend/src/api/`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.example`. Key ones:
|
||||||
|
- `DATABASE_URL` - PostgreSQL connection
|
||||||
|
- `OIDC_*` - Authelia OIDC configuration
|
||||||
|
- `OLLAMA_URL` - Local LLM server
|
||||||
|
- `OPENAI_API_KEY` - Fallback LLM
|
||||||
|
- `PAPERLESS_TOKEN` / `BASE_URL` - Document source
|
||||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies, Node.js, Yarn, and uv
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& npm install -g yarn obsidian-headless \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
|
# Add uv to PATH
|
||||||
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
|
# Copy dependency files
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
|
# Install Python dependencies using uv
|
||||||
|
RUN uv pip install --system -e .
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY *.py ./
|
||||||
|
COPY blueprints ./blueprints
|
||||||
|
COPY migrations ./migrations
|
||||||
|
COPY utils ./utils
|
||||||
|
COPY config ./config
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY startup.sh ./
|
||||||
|
RUN chmod +x startup.sh
|
||||||
|
|
||||||
|
# Copy frontend code and build
|
||||||
|
COPY raggr-frontend ./raggr-frontend
|
||||||
|
WORKDIR /app/raggr-frontend
|
||||||
|
RUN yarn install && yarn build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create ChromaDB and database directories
|
||||||
|
RUN mkdir -p /app/chromadb /app/database
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV CHROMADB_PATH=/app/chromadb
|
||||||
|
|
||||||
|
# Run the startup script
|
||||||
|
CMD ["./startup.sh"]
|
||||||
53
Dockerfile.dev
Normal file
53
Dockerfile.dev
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies, Node.js, uv, and yarn
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
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/* \
|
||||||
|
&& curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
|
# Add uv to PATH
|
||||||
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
|
# Copy dependency files
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
|
# Install Python dependencies using uv
|
||||||
|
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
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir -p /app/chromadb /app/database
|
||||||
|
|
||||||
|
# Make startup script executable
|
||||||
|
RUN chmod +x /app/startup-dev.sh
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV CHROMADB_PATH=/app/chromadb
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Default command
|
||||||
|
CMD ["/app/startup-dev.sh"]
|
||||||
371
README.md
371
README.md
@@ -1,7 +1,370 @@
|
|||||||
# simbarag
|
# SimbaRAG 🐱
|
||||||
|
|
||||||
**Goal:** Learn how retrieval-augmented generation works and also create a neat little tool to ask about Simba's health.
|
A Retrieval-Augmented Generation (RAG) conversational AI system for querying information about Simba the cat. Built with LangChain, ChromaDB, and modern web technologies.
|
||||||
|
|
||||||
**Current objectives:**
|
## Features
|
||||||
|
|
||||||
- [ ] Successfully use RAG to ask a question about existing information (e.g. how many teeth has Simba had extracted)
|
- 🤖 **Intelligent Conversations** - LangChain-powered agent with tool use and memory
|
||||||
|
- 📚 **Document Retrieval** - RAG system using ChromaDB vector store
|
||||||
|
- 🔍 **Web Search** - Integrated Tavily API for real-time web searches
|
||||||
|
- 🔐 **OIDC Authentication** - Secure auth via Authelia with LDAP group support
|
||||||
|
- 💬 **Multi-Conversation** - Manage multiple conversation threads per user
|
||||||
|
- 🎨 **Modern UI** - React 19 frontend with Tailwind CSS
|
||||||
|
- 🐳 **Docker Ready** - Containerized deployment with Docker Compose
|
||||||
|
|
||||||
|
## System Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Client Layer"
|
||||||
|
Browser[Web Browser]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Frontend - React"
|
||||||
|
UI[React UI<br/>Tailwind CSS]
|
||||||
|
Auth[Auth Service]
|
||||||
|
API[API Client]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Backend - Quart/Python"
|
||||||
|
App[Quart App<br/>app.py]
|
||||||
|
|
||||||
|
subgraph "Blueprints"
|
||||||
|
Users[Users Blueprint<br/>OIDC + JWT]
|
||||||
|
Conv[Conversation Blueprint<br/>Chat Management]
|
||||||
|
RAG[RAG Blueprint<br/>Document Indexing]
|
||||||
|
end
|
||||||
|
|
||||||
|
Agent[LangChain Agent<br/>main.py]
|
||||||
|
LLM[LLM Client<br/>llm.py]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Tools & Utilities"
|
||||||
|
Search[Simba Search Tool]
|
||||||
|
Web[Web Search Tool<br/>Tavily]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Data Layer"
|
||||||
|
Postgres[(PostgreSQL<br/>Users & Conversations)]
|
||||||
|
Chroma[(ChromaDB<br/>Vector Store)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "External Services"
|
||||||
|
Authelia[Authelia<br/>OIDC Provider]
|
||||||
|
LLDAP[LLDAP<br/>User Directory]
|
||||||
|
Ollama[Ollama<br/>Local LLM]
|
||||||
|
OpenAI[OpenAI<br/>Fallback LLM]
|
||||||
|
Paperless[Paperless-NGX<br/>Documents]
|
||||||
|
TavilyAPI[Tavily API<br/>Web Search]
|
||||||
|
end
|
||||||
|
|
||||||
|
Browser --> UI
|
||||||
|
UI --> Auth
|
||||||
|
UI --> API
|
||||||
|
API --> App
|
||||||
|
|
||||||
|
App --> Users
|
||||||
|
App --> Conv
|
||||||
|
App --> RAG
|
||||||
|
|
||||||
|
Conv --> Agent
|
||||||
|
Agent --> Search
|
||||||
|
Agent --> Web
|
||||||
|
Agent --> LLM
|
||||||
|
|
||||||
|
Search --> Chroma
|
||||||
|
Web --> TavilyAPI
|
||||||
|
RAG --> Chroma
|
||||||
|
RAG --> Paperless
|
||||||
|
|
||||||
|
Users --> Postgres
|
||||||
|
Conv --> Postgres
|
||||||
|
|
||||||
|
Users --> Authelia
|
||||||
|
Authelia --> LLDAP
|
||||||
|
|
||||||
|
LLM --> Ollama
|
||||||
|
LLM -.Fallback.-> OpenAI
|
||||||
|
|
||||||
|
style Browser fill:#e1f5ff
|
||||||
|
style UI fill:#fff3cd
|
||||||
|
style App fill:#d4edda
|
||||||
|
style Agent fill:#d4edda
|
||||||
|
style Postgres fill:#f8d7da
|
||||||
|
style Chroma fill:#f8d7da
|
||||||
|
style Ollama fill:#e2e3e5
|
||||||
|
style OpenAI fill:#e2e3e5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- PostgreSQL (or use Docker)
|
||||||
|
- Ollama (optional, for local LLM)
|
||||||
|
- Paperless-NGX instance (for document source)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/simbarag.git
|
||||||
|
cd simbarag
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure environment variables**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start the services**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development (local PostgreSQL only)
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Or full Docker deployment
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access the application**
|
||||||
|
|
||||||
|
Open `http://localhost:8080` in your browser.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Local Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start PostgreSQL
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 2. Set environment variables
|
||||||
|
export DATABASE_URL="postgres://raggr:raggr_dev_password@localhost:5432/raggr"
|
||||||
|
export CHROMADB_PATH="./chromadb"
|
||||||
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
|
||||||
|
# 3. Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cd raggr-frontend && yarn install && yarn build && cd ..
|
||||||
|
|
||||||
|
# 4. Run migrations
|
||||||
|
aerich upgrade
|
||||||
|
|
||||||
|
# 5. Start the server
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
See [docs/development.md](docs/development.md) for detailed development guide.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
simbarag/
|
||||||
|
├── app.py # Quart application entry point
|
||||||
|
├── main.py # RAG logic & LangChain agent
|
||||||
|
├── llm.py # LLM client with Ollama/OpenAI
|
||||||
|
├── aerich_config.py # Database migration configuration
|
||||||
|
│
|
||||||
|
├── blueprints/ # API route blueprints
|
||||||
|
│ ├── users/ # Authentication & authorization
|
||||||
|
│ ├── conversation/ # Chat conversations
|
||||||
|
│ └── rag/ # Document indexing
|
||||||
|
│
|
||||||
|
├── config/ # Configuration modules
|
||||||
|
│ └── oidc_config.py # OIDC authentication settings
|
||||||
|
│
|
||||||
|
├── utils/ # Reusable utilities
|
||||||
|
│ ├── chunker.py # Document chunking for embeddings
|
||||||
|
│ ├── cleaner.py # PDF cleaning and summarization
|
||||||
|
│ ├── image_process.py # Image description with LLM
|
||||||
|
│ └── request.py # Paperless-NGX API client
|
||||||
|
│
|
||||||
|
├── scripts/ # Administrative scripts
|
||||||
|
│ ├── add_user.py
|
||||||
|
│ ├── user_message_stats.py
|
||||||
|
│ ├── manage_vectorstore.py
|
||||||
|
│ └── inspect_vector_store.py
|
||||||
|
│
|
||||||
|
├── raggr-frontend/ # React frontend
|
||||||
|
│ └── src/
|
||||||
|
│
|
||||||
|
├── migrations/ # Database migrations
|
||||||
|
│
|
||||||
|
├── docs/ # Documentation
|
||||||
|
│ ├── index.md # Documentation hub
|
||||||
|
│ ├── development.md # Development guide
|
||||||
|
│ ├── deployment.md # Deployment & migrations
|
||||||
|
│ ├── VECTORSTORE.md # Vector store management
|
||||||
|
│ ├── MIGRATIONS.md # Migration reference
|
||||||
|
│ └── authentication.md # Authentication setup
|
||||||
|
│
|
||||||
|
├── docker-compose.yml # Production compose
|
||||||
|
├── docker-compose.dev.yml # Development compose
|
||||||
|
├── Dockerfile # Production Dockerfile
|
||||||
|
├── Dockerfile.dev # Development Dockerfile
|
||||||
|
├── CLAUDE.md # AI assistant instructions
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Technologies
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Quart** - Async Python web framework
|
||||||
|
- **LangChain** - Agent framework with tool use
|
||||||
|
- **Tortoise ORM** - Async ORM for PostgreSQL
|
||||||
|
- **Aerich** - Database migration tool
|
||||||
|
- **ChromaDB** - Vector database for embeddings
|
||||||
|
- **OpenAI** - Embeddings & LLM (fallback)
|
||||||
|
- **Ollama** - Local LLM (primary)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **React 19** - UI framework
|
||||||
|
- **Rsbuild** - Fast bundler
|
||||||
|
- **Tailwind CSS** - Utility-first styling
|
||||||
|
- **Axios** - HTTP client
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- **Authelia** - OIDC provider
|
||||||
|
- **LLDAP** - Lightweight LDAP server
|
||||||
|
- **JWT** - Token-based auth
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `GET /api/user/oidc/login` - Initiate OIDC login
|
||||||
|
- `GET /api/user/oidc/callback` - OIDC callback handler
|
||||||
|
- `POST /api/user/refresh` - Refresh JWT token
|
||||||
|
|
||||||
|
### Conversations
|
||||||
|
- `POST /api/conversation/` - Create conversation
|
||||||
|
- `GET /api/conversation/` - List conversations
|
||||||
|
- `GET /api/conversation/<id>` - Get conversation with messages
|
||||||
|
- `POST /api/conversation/query` - Send message and get response
|
||||||
|
|
||||||
|
### RAG (Admin Only)
|
||||||
|
- `GET /api/rag/stats` - Vector store statistics
|
||||||
|
- `POST /api/rag/index` - Index new documents
|
||||||
|
- `POST /api/rag/reindex` - Clear and reindex all
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://...` |
|
||||||
|
| `CHROMADB_PATH` | ChromaDB storage path | `./chromadb` |
|
||||||
|
| `OLLAMA_URL` | Ollama server URL | `http://localhost:11434` |
|
||||||
|
| `OPENAI_API_KEY` | OpenAI API key | - |
|
||||||
|
| `PAPERLESS_TOKEN` | Paperless-NGX API token | - |
|
||||||
|
| `BASE_URL` | Paperless-NGX base URL | - |
|
||||||
|
| `OIDC_ISSUER` | OIDC provider URL | - |
|
||||||
|
| `OIDC_CLIENT_ID` | OIDC client ID | - |
|
||||||
|
| `OIDC_CLIENT_SECRET` | OIDC client secret | - |
|
||||||
|
| `JWT_SECRET_KEY` | JWT signing key | - |
|
||||||
|
| `TAVILY_KEY` | Tavily web search API key | - |
|
||||||
|
|
||||||
|
See `.env.example` for full list.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
```bash
|
||||||
|
# Add a new user
|
||||||
|
python scripts/add_user.py
|
||||||
|
|
||||||
|
# View message statistics
|
||||||
|
python scripts/user_message_stats.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vector Store Management
|
||||||
|
```bash
|
||||||
|
# Show vector store statistics
|
||||||
|
python scripts/manage_vectorstore.py stats
|
||||||
|
|
||||||
|
# Index new documents from Paperless
|
||||||
|
python scripts/manage_vectorstore.py index
|
||||||
|
|
||||||
|
# Clear and reindex everything
|
||||||
|
python scripts/manage_vectorstore.py reindex
|
||||||
|
|
||||||
|
# Inspect vector store contents
|
||||||
|
python scripts/inspect_vector_store.py
|
||||||
|
```
|
||||||
|
|
||||||
|
See [docs/vectorstore.md](docs/vectorstore.md) for details.
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a new migration
|
||||||
|
aerich migrate --name "describe_your_changes"
|
||||||
|
|
||||||
|
# Apply pending migrations
|
||||||
|
aerich upgrade
|
||||||
|
|
||||||
|
# View migration history
|
||||||
|
aerich history
|
||||||
|
|
||||||
|
# Rollback last migration
|
||||||
|
aerich downgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
See [docs/deployment.md](docs/deployment.md) for detailed migration workflows.
|
||||||
|
|
||||||
|
## LangChain Agent
|
||||||
|
|
||||||
|
The conversational agent has access to two tools:
|
||||||
|
|
||||||
|
1. **simba_search** - Query the vector store for Simba's documents
|
||||||
|
- Used for: Medical records, veterinary history, factual information
|
||||||
|
|
||||||
|
2. **web_search** - Search the web via Tavily API
|
||||||
|
- Used for: Recent events, external knowledge, general questions
|
||||||
|
|
||||||
|
The agent automatically selects the appropriate tool based on the user's query.
|
||||||
|
|
||||||
|
## Authentication Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User → Authelia (OIDC) → Backend (JWT) → Frontend (localStorage)
|
||||||
|
↓
|
||||||
|
LLDAP
|
||||||
|
```
|
||||||
|
|
||||||
|
1. User clicks "Login"
|
||||||
|
2. Frontend redirects to Authelia
|
||||||
|
3. User authenticates via Authelia (backed by LLDAP)
|
||||||
|
4. Authelia redirects back with authorization code
|
||||||
|
5. Backend exchanges code for OIDC tokens
|
||||||
|
6. Backend issues JWT tokens
|
||||||
|
7. Frontend stores tokens in localStorage
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Run tests and linting
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Development Guide](docs/development.md) - Setup and development workflow
|
||||||
|
- [Deployment Guide](docs/deployment.md) - Deployment and migrations
|
||||||
|
- [Vector Store Guide](docs/vectorstore.md) - Managing the vector database
|
||||||
|
- [Authentication Guide](docs/authentication.md) - OIDC and LDAP setup
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[Your License Here]
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- Built for Simba, the most important cat in the world 🐱
|
||||||
|
- Powered by LangChain, ChromaDB, and the open-source community
|
||||||
|
|||||||
24
aerich_config.py
Normal file
24
aerich_config.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Database configuration with environment variable support
|
||||||
|
# Use DATABASE_PATH for relative paths or DATABASE_URL for full connection strings
|
||||||
|
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite://{DATABASE_PATH}")
|
||||||
|
|
||||||
|
TORTOISE_ORM = {
|
||||||
|
"connections": {"default": DATABASE_URL},
|
||||||
|
"apps": {
|
||||||
|
"models": {
|
||||||
|
"models": [
|
||||||
|
"blueprints.conversation.models",
|
||||||
|
"blueprints.users.models",
|
||||||
|
"aerich.models",
|
||||||
|
],
|
||||||
|
"default_connection": "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
159
app.py
Normal file
159
app.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from quart import Quart, jsonify, render_template, request, send_from_directory
|
||||||
|
from quart_jwt_extended import JWTManager, get_jwt_identity, jwt_refresh_token_required
|
||||||
|
from tortoise import Tortoise
|
||||||
|
|
||||||
|
import blueprints.conversation
|
||||||
|
import blueprints.conversation.logic
|
||||||
|
import blueprints.rag
|
||||||
|
import blueprints.users
|
||||||
|
import blueprints.whatsapp
|
||||||
|
import blueprints.users.models
|
||||||
|
from main import consult_simba_oracle
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(blueprints.users.user_blueprint)
|
||||||
|
app.register_blueprint(blueprints.conversation.conversation_blueprint)
|
||||||
|
app.register_blueprint(blueprints.rag.rag_blueprint)
|
||||||
|
app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
# Database configuration with environment variable support
|
||||||
|
DATABASE_URL = os.getenv(
|
||||||
|
"DATABASE_URL", "postgres://raggr:raggr_dev_password@localhost:5432/raggr"
|
||||||
|
)
|
||||||
|
|
||||||
|
TORTOISE_CONFIG = {
|
||||||
|
"connections": {"default": DATABASE_URL},
|
||||||
|
"apps": {
|
||||||
|
"models": {
|
||||||
|
"models": [
|
||||||
|
"blueprints.conversation.models",
|
||||||
|
"blueprints.users.models",
|
||||||
|
"aerich.models",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize Tortoise ORM with lifecycle hooks
|
||||||
|
@app.while_serving
|
||||||
|
async def lifespan():
|
||||||
|
logging.info("Initializing Tortoise ORM...")
|
||||||
|
await Tortoise.init(config=TORTOISE_CONFIG)
|
||||||
|
logging.info("Tortoise ORM initialized successfully")
|
||||||
|
yield
|
||||||
|
logging.info("Closing Tortoise ORM connections...")
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
|
||||||
|
|
||||||
|
# Serve React static files
|
||||||
|
@app.route("/static/<path: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("/<path:path>")
|
||||||
|
async def serve_react_app(path):
|
||||||
|
if path and os.path.exists(os.path.join(app.template_folder, path)):
|
||||||
|
return await send_from_directory(app.template_folder, path)
|
||||||
|
return await render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/query", methods=["POST"])
|
||||||
|
@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 blueprints.conversation.logic.get_conversation_by_id(
|
||||||
|
conversation_id
|
||||||
|
)
|
||||||
|
await conversation.fetch_related("messages")
|
||||||
|
await blueprints.conversation.logic.add_message_to_conversation(
|
||||||
|
conversation=conversation,
|
||||||
|
message=query,
|
||||||
|
speaker="user",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
transcript = await blueprints.conversation.logic.get_conversation_transcript(
|
||||||
|
user=user, conversation=conversation
|
||||||
|
)
|
||||||
|
|
||||||
|
response = consult_simba_oracle(input=query, transcript=transcript)
|
||||||
|
await blueprints.conversation.logic.add_message_to_conversation(
|
||||||
|
conversation=conversation,
|
||||||
|
message=response,
|
||||||
|
speaker="simba",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
return jsonify({"response": response})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/messages", methods=["GET"])
|
||||||
|
@jwt_refresh_token_required
|
||||||
|
async def get_messages():
|
||||||
|
current_user_uuid = get_jwt_identity()
|
||||||
|
user = await blueprints.users.models.User.get(id=current_user_uuid)
|
||||||
|
|
||||||
|
conversation = await blueprints.conversation.logic.get_conversation_for_user(
|
||||||
|
user=user
|
||||||
|
)
|
||||||
|
# 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(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
name = conversation.name
|
||||||
|
if len(messages) > 8:
|
||||||
|
name = await blueprints.conversation.logic.rename_conversation(
|
||||||
|
user=user,
|
||||||
|
conversation=conversation,
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"id": str(conversation.id),
|
||||||
|
"name": name,
|
||||||
|
"messages": messages,
|
||||||
|
"created_at": conversation.created_at.isoformat(),
|
||||||
|
"updated_at": conversation.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8080, debug=True)
|
||||||
1
blueprints/__init__.py
Normal file
1
blueprints/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Blueprints package
|
||||||
261
blueprints/conversation/__init__.py
Normal file
261
blueprints/conversation/__init__.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
from quart import Blueprint, jsonify, make_response, request
|
||||||
|
from quart_jwt_extended import (
|
||||||
|
get_jwt_identity,
|
||||||
|
jwt_refresh_token_required,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (
|
||||||
|
Conversation,
|
||||||
|
PydConversation,
|
||||||
|
PydListConversation,
|
||||||
|
)
|
||||||
|
|
||||||
|
conversation_blueprint = Blueprint(
|
||||||
|
"conversation_api", __name__, url_prefix="/api/conversation"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SYSTEM_PROMPT = """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.
|
||||||
|
|
||||||
|
SIMBA FACTS (as of January 2026):
|
||||||
|
- Name: Simba
|
||||||
|
- Species: Feline (Domestic Short Hair / American Short Hair)
|
||||||
|
- Sex: Male, Neutered
|
||||||
|
- Date of Birth: August 8, 2016 (approximately 9 years 5 months old)
|
||||||
|
- Color: Orange
|
||||||
|
- Current Weight: 16 lbs (as of 1/8/2026)
|
||||||
|
- Owner: Ryan Chen
|
||||||
|
- Location: Long Island City, NY
|
||||||
|
- Veterinarian: Court Square Animal Hospital
|
||||||
|
|
||||||
|
Medical Conditions:
|
||||||
|
- Hypertrophic Cardiomyopathy (HCM): Diagnosed 12/11/2025. Concentric left ventricular hypertrophy with no left atrial dilation. Grade II-III/VI systolic heart murmur. No cardiac medications currently needed. Must avoid Domitor, acepromazine, and ketamine during anesthesia.
|
||||||
|
- Dental Issues: Prior extraction of teeth 307 and 407 due to resorption. Tooth 107 extracted on 1/8/2026. Early resorption lesions present on teeth 207, 309, and 409.
|
||||||
|
|
||||||
|
Recent Medical Events:
|
||||||
|
- 1/8/2026: Dental cleaning and tooth 107 extraction. Prescribed Onsior for 3 days. Oravet sealant applied.
|
||||||
|
- 12/11/2025: Echocardiogram confirming HCM diagnosis. Pre-op bloodwork was normal.
|
||||||
|
- 12/1/2025: Visited for decreased appetite/nausea. Received subcutaneous fluids and Cerenia.
|
||||||
|
|
||||||
|
Diet & Lifestyle:
|
||||||
|
- Diet: Hill's I/D wet and dry food
|
||||||
|
- Supplements: Plaque Off
|
||||||
|
- Indoor only cat, only pet in the household
|
||||||
|
|
||||||
|
Upcoming Appointments:
|
||||||
|
- Rabies Vaccine: Due 2/19/2026
|
||||||
|
- Routine Examination: Due 6/1/2026
|
||||||
|
- FVRCP-3yr Vaccine: Due 10/2/2026
|
||||||
|
|
||||||
|
IMPORTANT: 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.
|
||||||
|
|
||||||
|
BUDGET & FINANCE (YNAB Integration):
|
||||||
|
You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools:
|
||||||
|
- Use ynab_budget_summary for overall budget health and status questions
|
||||||
|
- Use ynab_search_transactions to find specific purchases or spending at particular stores
|
||||||
|
- Use ynab_category_spending to analyze spending by category for a month
|
||||||
|
- Use ynab_insights to provide spending trends, patterns, and recommendations
|
||||||
|
Always use these tools when asked about budgets, spending, transactions, or financial health.
|
||||||
|
|
||||||
|
NOTES & RESEARCH (Obsidian Integration):
|
||||||
|
You have access to Ryan's Obsidian vault through the Obsidian integration. When users ask about research, personal notes, or information that might be stored in markdown files, use the appropriate Obsidian tools:
|
||||||
|
- Use obsidian_search_notes to search through your vault for relevant information
|
||||||
|
- Use obsidian_read_note to read the full content of a specific note by path
|
||||||
|
- Use obsidian_create_note to save new findings, ideas, or research to your vault
|
||||||
|
- Use obsidian_create_task to create task notes with due dates
|
||||||
|
Always use these tools when users ask about notes, research, ideas, tasks, or when you want to save information for future reference.
|
||||||
|
|
||||||
|
DAILY JOURNAL (Task Tracking):
|
||||||
|
You have access to Ryan's daily journal notes. Each note lives at journal/YYYY/YYYY-MM-DD.md and has two sections: tasks and log.
|
||||||
|
- Use journal_get_today to read today's full daily note (tasks + log)
|
||||||
|
- Use journal_get_tasks to list tasks (done/pending) for today or a specific date
|
||||||
|
- Use journal_add_task to add a new task to today's (or a given date's) note
|
||||||
|
- Use journal_complete_task to check off a task as done
|
||||||
|
Use these tools when Ryan asks about today's tasks, wants to add something to his list, or wants to mark a task complete."""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_messages_payload(conversation, query_text: str) -> list:
|
||||||
|
recent_messages = (
|
||||||
|
conversation.messages[-10:]
|
||||||
|
if len(conversation.messages) > 10
|
||||||
|
else conversation.messages
|
||||||
|
)
|
||||||
|
messages_payload = [{"role": "system", "content": _SYSTEM_PROMPT}]
|
||||||
|
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})
|
||||||
|
messages_payload.append({"role": "user", "content": query_text})
|
||||||
|
return messages_payload
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages_payload = _build_messages_payload(conversation, 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.post("/stream-query")
|
||||||
|
@jwt_refresh_token_required
|
||||||
|
async def stream_query():
|
||||||
|
current_user_uuid = get_jwt_identity()
|
||||||
|
user = await blueprints.users.models.User.get(id=current_user_uuid)
|
||||||
|
data = await request.get_json()
|
||||||
|
query_text = 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_text,
|
||||||
|
speaker="user",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages_payload = _build_messages_payload(conversation, query_text)
|
||||||
|
payload = {"messages": messages_payload}
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
final_message = None
|
||||||
|
try:
|
||||||
|
async for event in main_agent.astream_events(payload, version="v2"):
|
||||||
|
event_type = event.get("event")
|
||||||
|
|
||||||
|
if event_type == "on_tool_start":
|
||||||
|
yield f"data: {json.dumps({'type': 'tool_start', 'tool': event['name']})}\n\n"
|
||||||
|
|
||||||
|
elif event_type == "on_tool_end":
|
||||||
|
yield f"data: {json.dumps({'type': 'tool_end', 'tool': event['name']})}\n\n"
|
||||||
|
|
||||||
|
elif event_type == "on_chain_end":
|
||||||
|
output = event.get("data", {}).get("output")
|
||||||
|
if isinstance(output, dict):
|
||||||
|
msgs = output.get("messages", [])
|
||||||
|
if msgs:
|
||||||
|
last_msg = msgs[-1]
|
||||||
|
content = getattr(last_msg, "content", None)
|
||||||
|
if isinstance(content, str) and content:
|
||||||
|
final_message = content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||||
|
|
||||||
|
if final_message:
|
||||||
|
await add_message_to_conversation(
|
||||||
|
conversation=conversation,
|
||||||
|
message=final_message,
|
||||||
|
speaker="simba",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
yield f"data: {json.dumps({'type': 'response', 'message': final_message})}\n\n"
|
||||||
|
else:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': 'No response generated'})}\n\n"
|
||||||
|
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
return await make_response(
|
||||||
|
event_generator(),
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@conversation_blueprint.route("/<conversation_id>")
|
||||||
|
@jwt_refresh_token_required
|
||||||
|
async def get_conversation(conversation_id: str):
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 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(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
name = conversation.name
|
||||||
|
if len(messages) > 8 and "datetime" in name.lower():
|
||||||
|
name = await rename_conversation(
|
||||||
|
user=user,
|
||||||
|
conversation=conversation,
|
||||||
|
)
|
||||||
|
print(name)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"id": str(conversation.id),
|
||||||
|
"name": name,
|
||||||
|
"messages": messages,
|
||||||
|
"created_at": conversation.created_at.isoformat(),
|
||||||
|
"updated_at": conversation.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@conversation_blueprint.post("/")
|
||||||
|
@jwt_refresh_token_required
|
||||||
|
async def create_conversation():
|
||||||
|
user_uuid = get_jwt_identity()
|
||||||
|
user = await blueprints.users.models.User.get(id=user_uuid)
|
||||||
|
conversation = await Conversation.create(
|
||||||
|
name=f"{user.username} {datetime.datetime.now().timestamp}",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized_conversation = await PydConversation.from_tortoise_orm(conversation)
|
||||||
|
return jsonify(serialized_conversation.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@conversation_blueprint.get("/")
|
||||||
|
@jwt_refresh_token_required
|
||||||
|
async def get_all_conversations():
|
||||||
|
user_uuid = get_jwt_identity()
|
||||||
|
user = await blueprints.users.models.User.get(id=user_uuid)
|
||||||
|
conversations = Conversation.filter(user=user)
|
||||||
|
serialized_conversations = await PydListConversation.from_queryset(conversations)
|
||||||
|
|
||||||
|
return jsonify(serialized_conversations.model_dump())
|
||||||
618
blueprints/conversation/agents.py
Normal file
618
blueprints/conversation/agents.py
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
import os
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from langchain.agents import create_agent
|
||||||
|
from langchain.chat_models import BaseChatModel
|
||||||
|
from langchain.tools import tool
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from tavily import AsyncTavilyClient
|
||||||
|
|
||||||
|
from blueprints.rag.logic import query_vector_store
|
||||||
|
from utils.obsidian_service import ObsidianService
|
||||||
|
from utils.ynab_service import YNABService
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configure LLM with llama-server or OpenAI fallback
|
||||||
|
llama_url = os.getenv("LLAMA_SERVER_URL")
|
||||||
|
if llama_url:
|
||||||
|
llama_chat = ChatOpenAI(
|
||||||
|
base_url=llama_url,
|
||||||
|
api_key="not-needed",
|
||||||
|
model=os.getenv("LLAMA_MODEL_NAME", "llama-3.1-8b-instruct"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
llama_chat = None
|
||||||
|
|
||||||
|
openai_fallback = ChatOpenAI(model="gpt-5-mini")
|
||||||
|
model_with_fallback = cast(
|
||||||
|
BaseChatModel,
|
||||||
|
llama_chat.with_fallbacks([openai_fallback]) if llama_chat else openai_fallback,
|
||||||
|
)
|
||||||
|
client = AsyncTavilyClient(api_key=os.getenv("TAVILY_API_KEY", ""))
|
||||||
|
|
||||||
|
# Initialize YNAB service (will only work if YNAB_ACCESS_TOKEN is set)
|
||||||
|
try:
|
||||||
|
ynab_service = YNABService()
|
||||||
|
ynab_enabled = True
|
||||||
|
except (ValueError, Exception) as e:
|
||||||
|
print(f"YNAB service not initialized: {e}")
|
||||||
|
ynab_enabled = False
|
||||||
|
|
||||||
|
# Initialize Obsidian service (will only work if OBSIDIAN_VAULT_PATH is set)
|
||||||
|
try:
|
||||||
|
obsidian_service = ObsidianService()
|
||||||
|
obsidian_enabled = True
|
||||||
|
except (ValueError, Exception) as e:
|
||||||
|
print(f"Obsidian service not initialized: {e}")
|
||||||
|
obsidian_enabled = False
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def get_current_date() -> str:
|
||||||
|
"""Get today's date in a human-readable format.
|
||||||
|
|
||||||
|
Use this tool when you need to:
|
||||||
|
- Reference today's date in your response
|
||||||
|
- Answer questions like "what is today's date"
|
||||||
|
- Format dates in messages or documents
|
||||||
|
- Calculate time periods relative to today
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Today's date in YYYY-MM-DD format
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
return date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def ynab_budget_summary() -> str:
|
||||||
|
"""Get overall budget summary and health status from YNAB.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- Overall budget health or status
|
||||||
|
- How much money is to be budgeted
|
||||||
|
- Total budget amounts or spending
|
||||||
|
- General budget overview questions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary of budget health, to-be-budgeted amount, total budgeted,
|
||||||
|
total activity, and available amounts.
|
||||||
|
"""
|
||||||
|
if not ynab_enabled:
|
||||||
|
return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = ynab_service.get_budget_summary()
|
||||||
|
return summary["summary"]
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching budget summary: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def ynab_search_transactions(
|
||||||
|
start_date: str = "",
|
||||||
|
end_date: str = "",
|
||||||
|
category_name: str = "",
|
||||||
|
payee_name: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Search YNAB transactions by date range, category, or payee.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- Specific transactions or purchases
|
||||||
|
- Spending at a particular store or payee
|
||||||
|
- Transactions in a specific category
|
||||||
|
- What was spent during a time period
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago)
|
||||||
|
end_date: End date in YYYY-MM-DD format (optional, defaults to today)
|
||||||
|
category_name: Filter by category name (optional, partial match)
|
||||||
|
payee_name: Filter by payee/store name (optional, partial match)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching transactions with dates, amounts, categories, and payees.
|
||||||
|
"""
|
||||||
|
if not ynab_enabled:
|
||||||
|
return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = ynab_service.get_transactions(
|
||||||
|
start_date=start_date or None,
|
||||||
|
end_date=end_date or None,
|
||||||
|
category_name=category_name or None,
|
||||||
|
payee_name=payee_name or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["count"] == 0:
|
||||||
|
return "No transactions found matching the specified criteria."
|
||||||
|
|
||||||
|
# Format transactions for readability
|
||||||
|
txn_list = []
|
||||||
|
for txn in result["transactions"][:10]: # Limit to 10 for readability
|
||||||
|
txn_list.append(
|
||||||
|
f"- {txn['date']}: {txn['payee']} - ${abs(txn['amount']):.2f} ({txn['category'] or 'Uncategorized'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Found {result['count']} transactions from {result['start_date']} to {result['end_date']}. "
|
||||||
|
f"Total: ${abs(result['total_amount']):.2f}\n\n"
|
||||||
|
+ "\n".join(txn_list)
|
||||||
|
+ (
|
||||||
|
f"\n\n(Showing first 10 of {result['count']} transactions)"
|
||||||
|
if result["count"] > 10
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error searching transactions: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def ynab_category_spending(month: str = "") -> str:
|
||||||
|
"""Get spending breakdown by category for a specific month.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- Spending by category
|
||||||
|
- What categories were overspent
|
||||||
|
- Monthly spending breakdown
|
||||||
|
- Budget vs actual spending for a month
|
||||||
|
|
||||||
|
Args:
|
||||||
|
month: Month in YYYY-MM format (optional, defaults to current month)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Spending breakdown by category with budgeted, spent, and available amounts.
|
||||||
|
"""
|
||||||
|
if not ynab_enabled:
|
||||||
|
return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = ynab_service.get_category_spending(month=month or None)
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"Budget spending for {result['month']}:\n"
|
||||||
|
f"Total budgeted: ${result['total_budgeted']:.2f}\n"
|
||||||
|
f"Total spent: ${result['total_spent']:.2f}\n"
|
||||||
|
f"Total available: ${result['total_available']:.2f}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["overspent_categories"]:
|
||||||
|
summary += (
|
||||||
|
f"\nOverspent categories ({len(result['overspent_categories'])}):\n"
|
||||||
|
)
|
||||||
|
for cat in result["overspent_categories"][:5]:
|
||||||
|
summary += f"- {cat['name']}: Budgeted ${cat['budgeted']:.2f}, Spent ${cat['spent']:.2f}, Over by ${cat['overspent_by']:.2f}\n"
|
||||||
|
|
||||||
|
# Add top spending categories
|
||||||
|
summary += "\nTop spending categories:\n"
|
||||||
|
for cat in result["categories"][:10]:
|
||||||
|
if cat["activity"] < 0: # Only show spending (negative activity)
|
||||||
|
summary += f"- {cat['category']}: ${abs(cat['activity']):.2f} (budgeted: ${cat['budgeted']:.2f}, available: ${cat['available']:.2f})\n"
|
||||||
|
|
||||||
|
return summary
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching category spending: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def ynab_insights(months_back: int = 3) -> str:
|
||||||
|
"""Generate insights about spending patterns and budget health over time.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- Spending trends or patterns
|
||||||
|
- Budget recommendations
|
||||||
|
- Which categories are frequently overspent
|
||||||
|
- How current spending compares to past months
|
||||||
|
- Overall budget health analysis
|
||||||
|
|
||||||
|
Args:
|
||||||
|
months_back: Number of months to analyze (default 3, max 6)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Insights about spending trends, frequently overspent categories,
|
||||||
|
and personalized recommendations.
|
||||||
|
"""
|
||||||
|
if not ynab_enabled:
|
||||||
|
return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Limit to reasonable range
|
||||||
|
months_back = min(max(1, months_back), 6)
|
||||||
|
result = ynab_service.get_spending_insights(months_back=months_back)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
return result["error"]
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"Spending insights for the last {months_back} months:\n\n"
|
||||||
|
f"Average monthly spending: ${result['average_monthly_spending']:.2f}\n"
|
||||||
|
f"Current month spending: ${result['current_month_spending']:.2f}\n"
|
||||||
|
f"Spending trend: {result['spending_trend']}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["frequently_overspent_categories"]:
|
||||||
|
summary += "\nFrequently overspent categories:\n"
|
||||||
|
for cat in result["frequently_overspent_categories"][:5]:
|
||||||
|
summary += f"- {cat['category']}: overspent in {cat['months_overspent']} of {months_back} months\n"
|
||||||
|
|
||||||
|
if result["recommendations"]:
|
||||||
|
summary += "\nRecommendations:\n"
|
||||||
|
for rec in result["recommendations"]:
|
||||||
|
summary += f"- {rec}\n"
|
||||||
|
|
||||||
|
return summary
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error generating insights: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def obsidian_search_notes(query: str) -> str:
|
||||||
|
"""Search through Obsidian vault notes for information.
|
||||||
|
|
||||||
|
Use this tool when you need to:
|
||||||
|
- Find information in personal notes
|
||||||
|
- Research past ideas or thoughts from your vault
|
||||||
|
- Look up information stored in markdown files
|
||||||
|
- Search for content that would be in your notes
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The search query to look up in your Obsidian vault
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relevant notes with their content and metadata
|
||||||
|
"""
|
||||||
|
if not obsidian_enabled:
|
||||||
|
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Query ChromaDB for obsidian documents
|
||||||
|
serialized, docs = await query_vector_store(query=query)
|
||||||
|
return serialized
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error searching Obsidian notes: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def obsidian_read_note(relative_path: str) -> str:
|
||||||
|
"""Read a specific note from your Obsidian vault.
|
||||||
|
|
||||||
|
Use this tool when you want to:
|
||||||
|
- Read the full content of a specific note
|
||||||
|
- Get detailed information from a particular markdown file
|
||||||
|
- Access content from a known note path
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relative_path: Path to note relative to vault root (e.g., "notes/my-note.md")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full content and metadata of the requested note
|
||||||
|
"""
|
||||||
|
if not obsidian_enabled:
|
||||||
|
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
note = obsidian_service.read_note(relative_path)
|
||||||
|
content_data = note["content"]
|
||||||
|
|
||||||
|
result = f"File: {note['path']}\n\n"
|
||||||
|
result += f"Frontmatter:\n{content_data['metadata']}\n\n"
|
||||||
|
result += f"Content:\n{content_data['content']}\n\n"
|
||||||
|
result += f"Tags: {', '.join(content_data['tags'])}\n"
|
||||||
|
result += f"Contains {len(content_data['wikilinks'])} wikilinks and {len(content_data['embeds'])} embeds"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return f"Note not found at '{relative_path}'. Please check the path is correct."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error reading note: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def obsidian_create_note(
|
||||||
|
title: str,
|
||||||
|
content: str,
|
||||||
|
folder: str = "notes",
|
||||||
|
tags: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Create a new note in your Obsidian vault.
|
||||||
|
|
||||||
|
Use this tool when you want to:
|
||||||
|
- Save research findings or ideas to your vault
|
||||||
|
- Create a new document with a specific title
|
||||||
|
- Write notes for future reference
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: The title of the note (will be used as filename)
|
||||||
|
content: The body content of the note
|
||||||
|
folder: The folder where to create the note (default: "notes")
|
||||||
|
tags: Comma-separated list of tags to add (default: "")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the created note
|
||||||
|
"""
|
||||||
|
if not obsidian_enabled:
|
||||||
|
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse tags from comma-separated string
|
||||||
|
tag_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||||
|
|
||||||
|
relative_path = obsidian_service.create_note(
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
folder=folder,
|
||||||
|
tags=tag_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"Successfully created note: {relative_path}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error creating note: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def journal_get_today() -> str:
|
||||||
|
"""Get today's daily journal note, including all tasks and log entries.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- What's on their plate today
|
||||||
|
- Today's tasks or to-do list
|
||||||
|
- Today's journal entry
|
||||||
|
- What they've logged today
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The full content of today's daily note, or a message if it doesn't exist.
|
||||||
|
"""
|
||||||
|
if not obsidian_enabled:
|
||||||
|
return "Obsidian integration is not configured."
|
||||||
|
|
||||||
|
try:
|
||||||
|
note = obsidian_service.get_daily_note()
|
||||||
|
if not note["found"]:
|
||||||
|
return f"No daily note found for {note['date']}. Use journal_add_task to create one."
|
||||||
|
return f"Daily note for {note['date']}:\n\n{note['content']}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error reading daily note: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def journal_get_tasks(date: str = "") -> str:
|
||||||
|
"""Get tasks from a daily journal note.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- Open or pending tasks for a day
|
||||||
|
- What tasks are done or not done
|
||||||
|
- Task status for today or a specific date
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: Date in YYYY-MM-DD format (optional, defaults to today)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tasks with their completion status.
|
||||||
|
"""
|
||||||
|
if not obsidian_enabled:
|
||||||
|
return "Obsidian integration is not configured."
|
||||||
|
|
||||||
|
try:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
|
parsed_date = dt.strptime(date, "%Y-%m-%d") if date else None
|
||||||
|
result = obsidian_service.get_daily_tasks(parsed_date)
|
||||||
|
|
||||||
|
if not result["found"]:
|
||||||
|
return f"No daily note found for {result['date']}."
|
||||||
|
|
||||||
|
if not result["tasks"]:
|
||||||
|
return f"No tasks found in the {result['date']} note."
|
||||||
|
|
||||||
|
lines = [f"Tasks for {result['date']}:"]
|
||||||
|
for task in result["tasks"]:
|
||||||
|
status = "[x]" if task["done"] else "[ ]"
|
||||||
|
lines.append(f"- {status} {task['text']}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error reading tasks: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def journal_add_task(task: str, date: str = "") -> str:
|
||||||
|
"""Add a task to a daily journal note.
|
||||||
|
|
||||||
|
Use this tool when the user wants to:
|
||||||
|
- Add a task or to-do to today's note
|
||||||
|
- Remind themselves to do something
|
||||||
|
- Track a new item in their daily note
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The task description to add
|
||||||
|
date: Date in YYYY-MM-DD format (optional, defaults to today)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of the added task.
|
||||||
|
"""
|
||||||
|
if not obsidian_enabled:
|
||||||
|
return "Obsidian integration is not configured."
|
||||||
|
|
||||||
|
try:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
|
parsed_date = dt.strptime(date, "%Y-%m-%d") if date else None
|
||||||
|
result = obsidian_service.add_task_to_daily_note(task, parsed_date)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
note_date = date or dt.now().strftime("%Y-%m-%d")
|
||||||
|
extra = " (created new note)" if result["created_note"] else ""
|
||||||
|
return f"Added task '{task}' to {note_date}{extra}."
|
||||||
|
return "Failed to add task."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error adding task: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def journal_complete_task(task: str, date: str = "") -> str:
|
||||||
|
"""Mark a task as complete in a daily journal note.
|
||||||
|
|
||||||
|
Use this tool when the user wants to:
|
||||||
|
- Check off a task as done
|
||||||
|
- Mark something as completed
|
||||||
|
- Update task status in their daily note
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The task text to mark complete (exact or partial match)
|
||||||
|
date: Date in YYYY-MM-DD format (optional, defaults to today)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation that the task was marked complete.
|
||||||
|
"""
|
||||||
|
if not obsidian_enabled:
|
||||||
|
return "Obsidian integration is not configured."
|
||||||
|
|
||||||
|
try:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
|
parsed_date = dt.strptime(date, "%Y-%m-%d") if date else None
|
||||||
|
result = obsidian_service.complete_task_in_daily_note(task, parsed_date)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return f"Marked '{result['completed_task']}' as complete."
|
||||||
|
return f"Could not complete task: {result.get('error', 'unknown error')}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error completing task: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def obsidian_create_task(
|
||||||
|
title: str,
|
||||||
|
content: str = "",
|
||||||
|
folder: str = "tasks",
|
||||||
|
due_date: str = "",
|
||||||
|
tags: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Create a new task note in your Obsidian vault.
|
||||||
|
|
||||||
|
Use this tool when you want to:
|
||||||
|
- Create a task to remember to do something
|
||||||
|
- Add a task with a due date
|
||||||
|
- Track tasks in your vault
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: The title of the task
|
||||||
|
content: The description of the task (optional)
|
||||||
|
folder: The folder to place the task (default: "tasks")
|
||||||
|
due_date: Due date in YYYY-MM-DD format (optional)
|
||||||
|
tags: Comma-separated list of tags to add (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the created task note
|
||||||
|
"""
|
||||||
|
if not obsidian_enabled:
|
||||||
|
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse tags from comma-separated string
|
||||||
|
tag_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||||
|
|
||||||
|
relative_path = obsidian_service.create_task(
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
folder=folder,
|
||||||
|
due_date=due_date or None,
|
||||||
|
tags=tag_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"Successfully created task: {relative_path}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error creating task: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
# Create tools list based on what's available
|
||||||
|
tools = [get_current_date, simba_search, web_search]
|
||||||
|
if ynab_enabled:
|
||||||
|
tools.extend(
|
||||||
|
[
|
||||||
|
ynab_budget_summary,
|
||||||
|
ynab_search_transactions,
|
||||||
|
ynab_category_spending,
|
||||||
|
ynab_insights,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if obsidian_enabled:
|
||||||
|
tools.extend(
|
||||||
|
[
|
||||||
|
obsidian_search_notes,
|
||||||
|
obsidian_read_note,
|
||||||
|
obsidian_create_note,
|
||||||
|
obsidian_create_task,
|
||||||
|
journal_get_today,
|
||||||
|
journal_get_tasks,
|
||||||
|
journal_add_task,
|
||||||
|
journal_complete_task,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Llama 3.1 supports native function calling via OpenAI-compatible API
|
||||||
|
main_agent = create_agent(model=model_with_fallback, tools=tools)
|
||||||
80
blueprints/conversation/logic.py
Normal file
80
blueprints/conversation/logic.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import tortoise.exceptions
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
|
import blueprints.users.models
|
||||||
|
|
||||||
|
from .models import Conversation, ConversationMessage, RenameConversationOutputSchema
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
user: blueprints.users.models.User,
|
||||||
|
) -> 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
|
||||||
|
|
||||||
|
|
||||||
|
async def get_conversation_for_user(user: blueprints.users.models.User) -> Conversation:
|
||||||
|
try:
|
||||||
|
return await Conversation.get(user=user)
|
||||||
|
except tortoise.exceptions.DoesNotExist:
|
||||||
|
await Conversation.get_or_create(name=f"{user.username}'s chat", user=user)
|
||||||
|
|
||||||
|
return await Conversation.get(user=user)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_conversation_by_id(id: str) -> Conversation:
|
||||||
|
return await Conversation.get(id=id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_conversation_transcript(
|
||||||
|
user: blueprints.users.models.User, conversation: Conversation
|
||||||
|
) -> str:
|
||||||
|
messages = []
|
||||||
|
for message in conversation.messages:
|
||||||
|
messages.append(f"{message.speaker} at {message.created_at}: {message.text}")
|
||||||
|
|
||||||
|
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
|
||||||
61
blueprints/conversation/models.py
Normal file
61
blueprints/conversation/models.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import enum
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
from tortoise.contrib.pydantic import (
|
||||||
|
pydantic_model_creator,
|
||||||
|
pydantic_queryset_creator,
|
||||||
|
)
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenameConversationOutputSchema:
|
||||||
|
title: str
|
||||||
|
justification: str
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
user: fields.ForeignKeyRelation = fields.ForeignKeyField(
|
||||||
|
"models.User", related_name="conversations", null=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", allow_cycles=True, exclude=("user",)
|
||||||
|
)
|
||||||
|
PydConversationWithMessages = pydantic_model_creator(
|
||||||
|
Conversation,
|
||||||
|
name="ConversationWithMessages",
|
||||||
|
allow_cycles=True,
|
||||||
|
exclude=("user",),
|
||||||
|
include=("messages",),
|
||||||
|
)
|
||||||
|
PydListConversation = pydantic_queryset_creator(Conversation)
|
||||||
|
PydListConversationMessage = pydantic_queryset_creator(ConversationMessage)
|
||||||
59
blueprints/rag/__init__.py
Normal file
59
blueprints/rag/__init__.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from quart import Blueprint, jsonify
|
||||||
|
from quart_jwt_extended import jwt_refresh_token_required
|
||||||
|
|
||||||
|
from .logic import fetch_obsidian_documents, get_vector_store_stats, index_documents, index_obsidian_documents, vector_store
|
||||||
|
from blueprints.users.decorators import admin_required
|
||||||
|
|
||||||
|
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")
|
||||||
|
@admin_required
|
||||||
|
async def trigger_index():
|
||||||
|
"""Trigger indexing of documents from Paperless-NGX. Admin only."""
|
||||||
|
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")
|
||||||
|
@admin_required
|
||||||
|
async def trigger_reindex():
|
||||||
|
"""Clear and reindex all documents. Admin only."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@rag_blueprint.post("/index-obsidian")
|
||||||
|
@admin_required
|
||||||
|
async def trigger_obsidian_index():
|
||||||
|
"""Index all Obsidian markdown documents into vector store. Admin only."""
|
||||||
|
try:
|
||||||
|
result = await index_obsidian_documents()
|
||||||
|
stats = get_vector_store_stats()
|
||||||
|
return jsonify({"status": "success", "result": result, "stats": stats})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "message": str(e)}), 500
|
||||||
79
blueprints/rag/fetchers.py
Normal file
79
blueprints/rag/fetchers.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
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"]}
|
||||||
169
blueprints/rag/logic.py
Normal file
169
blueprints/rag/logic.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
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
|
||||||
|
from utils.obsidian_service import ObsidianService
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
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():
|
||||||
|
"""Index Paperless-NGX documents into vector store."""
|
||||||
|
documents = await fetch_documents_from_paperless_ngx()
|
||||||
|
|
||||||
|
splits = text_splitter.split_documents(documents)
|
||||||
|
await vector_store.aadd_documents(documents=splits)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_obsidian_documents() -> list[Document]:
|
||||||
|
"""Fetch all markdown documents from Obsidian vault.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of LangChain Document objects with source='obsidian' metadata.
|
||||||
|
"""
|
||||||
|
obsidian_service = ObsidianService()
|
||||||
|
documents = []
|
||||||
|
|
||||||
|
for md_path in obsidian_service.walk_vault():
|
||||||
|
try:
|
||||||
|
# Read markdown file
|
||||||
|
with open(md_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Parse metadata
|
||||||
|
parsed = obsidian_service.parse_markdown(content, md_path)
|
||||||
|
|
||||||
|
# Create LangChain Document with obsidian source
|
||||||
|
document = Document(
|
||||||
|
page_content=parsed["content"],
|
||||||
|
metadata={
|
||||||
|
"source": "obsidian",
|
||||||
|
"filepath": parsed["filepath"],
|
||||||
|
"tags": parsed["tags"],
|
||||||
|
"created_at": parsed["metadata"].get("created_at"),
|
||||||
|
**{k: v for k, v in parsed["metadata"].items() if k not in ["created_at", "created_by"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
documents.append(document)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading {md_path}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return documents
|
||||||
|
|
||||||
|
|
||||||
|
async def index_obsidian_documents():
|
||||||
|
"""Index all Obsidian markdown documents into vector store.
|
||||||
|
|
||||||
|
Deletes existing obsidian source chunks before re-indexing.
|
||||||
|
"""
|
||||||
|
obsidian_service = ObsidianService()
|
||||||
|
documents = await fetch_obsidian_documents()
|
||||||
|
|
||||||
|
if not documents:
|
||||||
|
print("No Obsidian documents found to index")
|
||||||
|
return {"indexed": 0}
|
||||||
|
|
||||||
|
# Delete existing obsidian chunks
|
||||||
|
existing_results = vector_store.get(where={"source": "obsidian"})
|
||||||
|
if existing_results.get("ids"):
|
||||||
|
await vector_store.adelete(existing_results["ids"])
|
||||||
|
|
||||||
|
# Split and index documents
|
||||||
|
splits = text_splitter.split_documents(documents)
|
||||||
|
await vector_store.aadd_documents(documents=splits)
|
||||||
|
|
||||||
|
return {"indexed": len(documents)}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
0
blueprints/rag/models.py
Normal file
0
blueprints/rag/models.py
Normal file
188
blueprints/users/__init__.py
Normal file
188
blueprints/users/__init__.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
from quart import Blueprint, jsonify, request
|
||||||
|
from quart_jwt_extended import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
jwt_refresh_token_required,
|
||||||
|
get_jwt_identity,
|
||||||
|
)
|
||||||
|
from .models import User
|
||||||
|
from .oidc_service import OIDCUserService
|
||||||
|
from config.oidc_config import oidc_config
|
||||||
|
import secrets
|
||||||
|
import httpx
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
|
user_blueprint = Blueprint("user_api", __name__, url_prefix="/api/user")
|
||||||
|
|
||||||
|
# In-memory storage for OIDC state/PKCE (production: use Redis or database)
|
||||||
|
# Format: {state: {"pkce_verifier": str, "redirect_after_login": str}}
|
||||||
|
_oidc_sessions = {}
|
||||||
|
|
||||||
|
|
||||||
|
@user_blueprint.route("/oidc/login", methods=["GET"])
|
||||||
|
async def oidc_login():
|
||||||
|
"""
|
||||||
|
Initiate OIDC login flow
|
||||||
|
Generates PKCE parameters and redirects to Authelia
|
||||||
|
"""
|
||||||
|
if not oidc_config.validate_config():
|
||||||
|
return jsonify({"error": "OIDC not configured"}), 500
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Generate PKCE parameters
|
||||||
|
code_verifier = secrets.token_urlsafe(64)
|
||||||
|
|
||||||
|
# For PKCE, we need code_challenge = BASE64URL(SHA256(code_verifier))
|
||||||
|
code_challenge = (
|
||||||
|
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
|
||||||
|
.decode()
|
||||||
|
.rstrip("=")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate state for CSRF protection
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# Store PKCE verifier and state for callback validation
|
||||||
|
_oidc_sessions[state] = {
|
||||||
|
"pkce_verifier": code_verifier,
|
||||||
|
"redirect_after_login": request.args.get("redirect", "/"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get authorization endpoint from discovery
|
||||||
|
discovery = await oidc_config.get_discovery_document()
|
||||||
|
auth_endpoint = discovery.get("authorization_endpoint")
|
||||||
|
|
||||||
|
# Build authorization URL
|
||||||
|
params = {
|
||||||
|
"client_id": oidc_config.client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": oidc_config.redirect_uri,
|
||||||
|
"scope": "openid email profile groups",
|
||||||
|
"state": state,
|
||||||
|
"code_challenge": code_challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_url = f"{auth_endpoint}?{urlencode(params)}"
|
||||||
|
|
||||||
|
return jsonify({"auth_url": auth_url})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"OIDC login failed: {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@user_blueprint.route("/oidc/callback", methods=["GET"])
|
||||||
|
async def oidc_callback():
|
||||||
|
"""
|
||||||
|
Handle OIDC callback from Authelia
|
||||||
|
Exchanges authorization code for tokens, verifies ID token, and creates/updates user
|
||||||
|
"""
|
||||||
|
# Get authorization code and state from callback
|
||||||
|
code = request.args.get("code")
|
||||||
|
state = request.args.get("state")
|
||||||
|
error = request.args.get("error")
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return jsonify({"error": f"OIDC error: {error}"}), 400
|
||||||
|
|
||||||
|
if not code or not state:
|
||||||
|
return jsonify({"error": "Missing code or state"}), 400
|
||||||
|
|
||||||
|
# Validate state and retrieve PKCE verifier
|
||||||
|
session = _oidc_sessions.pop(state, None)
|
||||||
|
if not session:
|
||||||
|
return jsonify({"error": "Invalid or expired state"}), 400
|
||||||
|
|
||||||
|
pkce_verifier = session["pkce_verifier"]
|
||||||
|
|
||||||
|
# Exchange authorization code for tokens
|
||||||
|
discovery = await oidc_config.get_discovery_document()
|
||||||
|
token_endpoint = discovery.get("token_endpoint")
|
||||||
|
|
||||||
|
token_data = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": oidc_config.redirect_uri,
|
||||||
|
"client_id": oidc_config.client_id,
|
||||||
|
"client_secret": oidc_config.client_secret,
|
||||||
|
"code_verifier": pkce_verifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use client_secret_post method (credentials in POST body)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
token_response = await client.post(token_endpoint, data=token_data)
|
||||||
|
|
||||||
|
if token_response.status_code != 200:
|
||||||
|
return jsonify(
|
||||||
|
{"error": f"Failed to exchange code for token: {token_response.text}"}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
tokens = token_response.json()
|
||||||
|
|
||||||
|
id_token = tokens.get("id_token")
|
||||||
|
if not id_token:
|
||||||
|
return jsonify({"error": "No ID token received"}), 400
|
||||||
|
|
||||||
|
# Verify ID token
|
||||||
|
try:
|
||||||
|
claims = await oidc_config.verify_id_token(id_token)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"ID token verification failed: {str(e)}"}), 400
|
||||||
|
|
||||||
|
# Get or create user from OIDC claims
|
||||||
|
user = await OIDCUserService.get_or_create_user_from_oidc(claims)
|
||||||
|
|
||||||
|
# Issue backend JWT tokens
|
||||||
|
access_token = create_access_token(identity=str(user.id))
|
||||||
|
refresh_token = create_refresh_token(identity=str(user.id))
|
||||||
|
|
||||||
|
# Return tokens to frontend
|
||||||
|
# Frontend will handle storing these and redirecting
|
||||||
|
return jsonify(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user={
|
||||||
|
"id": str(user.id),
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"groups": user.ldap_groups,
|
||||||
|
"is_admin": user.is_admin(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@user_blueprint.route("/refresh", methods=["POST"])
|
||||||
|
@jwt_refresh_token_required
|
||||||
|
async def refresh():
|
||||||
|
"""Refresh access token (unchanged from original)"""
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
new_token = create_access_token(identity=user_id)
|
||||||
|
return jsonify(access_token=new_token)
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy username/password login - kept for backward compatibility during migration
|
||||||
|
@user_blueprint.route("/login", methods=["POST"])
|
||||||
|
async def login():
|
||||||
|
"""
|
||||||
|
Legacy username/password login
|
||||||
|
This can be removed after full OIDC migration is complete
|
||||||
|
"""
|
||||||
|
data = await request.get_json()
|
||||||
|
username = data.get("username")
|
||||||
|
password = data.get("password")
|
||||||
|
|
||||||
|
user = await User.filter(username=username).first()
|
||||||
|
|
||||||
|
if not user or not user.verify_password(password):
|
||||||
|
return jsonify({"msg": "Invalid credentials"}), 401
|
||||||
|
|
||||||
|
access_token = create_access_token(identity=str(user.id))
|
||||||
|
refresh_token = create_refresh_token(identity=str(user.id))
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user={"id": str(user.id), "username": user.username},
|
||||||
|
)
|
||||||
26
blueprints/users/decorators.py
Normal file
26
blueprints/users/decorators.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
Authentication decorators for role-based access control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
from quart import jsonify
|
||||||
|
from quart_jwt_extended import jwt_refresh_token_required, get_jwt_identity
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(fn):
|
||||||
|
"""
|
||||||
|
Decorator that requires the user to be an admin (member of lldap_admin group).
|
||||||
|
Must be used on async route handlers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
@jwt_refresh_token_required
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if not user or not user.is_admin():
|
||||||
|
return jsonify({"error": "Admin access required"}), 403
|
||||||
|
return await fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
47
blueprints/users/models.py
Normal file
47
blueprints/users/models.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from tortoise.models import Model
|
||||||
|
from tortoise import fields
|
||||||
|
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
|
||||||
|
class User(Model):
|
||||||
|
id = fields.UUIDField(primary_key=True)
|
||||||
|
username = fields.CharField(max_length=255)
|
||||||
|
password = fields.BinaryField(null=True) # Hashed - nullable for OIDC users
|
||||||
|
email = fields.CharField(max_length=100, unique=True)
|
||||||
|
whatsapp_number = fields.CharField(max_length=30, unique=True, null=True, index=True)
|
||||||
|
|
||||||
|
# OIDC fields
|
||||||
|
oidc_subject = fields.CharField(
|
||||||
|
max_length=255, unique=True, null=True, index=True
|
||||||
|
) # "sub" claim from OIDC
|
||||||
|
auth_provider = fields.CharField(
|
||||||
|
max_length=50, default="local"
|
||||||
|
) # "local" or "oidc"
|
||||||
|
ldap_groups = fields.JSONField(default=[]) # LDAP groups from OIDC claims
|
||||||
|
|
||||||
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "users"
|
||||||
|
|
||||||
|
def has_group(self, group: str) -> bool:
|
||||||
|
"""Check if user belongs to a specific LDAP group."""
|
||||||
|
return group in (self.ldap_groups or [])
|
||||||
|
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
"""Check if user is an admin (member of lldap_admin group)."""
|
||||||
|
return self.has_group("lldap_admin")
|
||||||
|
|
||||||
|
def set_password(self, plain_password: str):
|
||||||
|
self.password = bcrypt.hashpw(
|
||||||
|
plain_password.encode("utf-8"),
|
||||||
|
bcrypt.gensalt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify_password(self, plain_password: str):
|
||||||
|
if not self.password:
|
||||||
|
return False
|
||||||
|
return bcrypt.checkpw(plain_password.encode("utf-8"), self.password)
|
||||||
81
blueprints/users/oidc_service.py
Normal file
81
blueprints/users/oidc_service.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
OIDC User Management Service
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCUserService:
|
||||||
|
"""Service for managing OIDC user authentication and provisioning"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_or_create_user_from_oidc(claims: Dict[str, Any]) -> User:
|
||||||
|
"""
|
||||||
|
Get existing user by OIDC subject, or create new user from OIDC claims
|
||||||
|
|
||||||
|
Args:
|
||||||
|
claims: Decoded OIDC ID token claims
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object (existing or newly created)
|
||||||
|
"""
|
||||||
|
oidc_subject = claims.get("sub")
|
||||||
|
if not oidc_subject:
|
||||||
|
raise ValueError("No 'sub' claim in ID token")
|
||||||
|
|
||||||
|
# Try to find existing user by OIDC subject
|
||||||
|
user = await User.filter(oidc_subject=oidc_subject).first()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# Update user info from latest claims (optional)
|
||||||
|
user.email = claims.get("email", user.email)
|
||||||
|
user.username = (
|
||||||
|
claims.get("preferred_username") or claims.get("name") or user.username
|
||||||
|
)
|
||||||
|
# Update LDAP groups from claims
|
||||||
|
user.ldap_groups = claims.get("groups", [])
|
||||||
|
await user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Check if user exists by email (migration case)
|
||||||
|
email = claims.get("email")
|
||||||
|
if email:
|
||||||
|
user = await User.filter(email=email, auth_provider="local").first()
|
||||||
|
if user:
|
||||||
|
# Migrate existing local user to OIDC
|
||||||
|
user.oidc_subject = oidc_subject
|
||||||
|
user.auth_provider = "oidc"
|
||||||
|
user.password = None # Clear password
|
||||||
|
user.ldap_groups = claims.get("groups", [])
|
||||||
|
await user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Create new user from OIDC claims
|
||||||
|
username = (
|
||||||
|
claims.get("preferred_username")
|
||||||
|
or claims.get("name")
|
||||||
|
or claims.get("email", "").split("@")[0]
|
||||||
|
or f"user_{oidc_subject[:8]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract LDAP groups from claims
|
||||||
|
groups = claims.get("groups", [])
|
||||||
|
|
||||||
|
user = await User.create(
|
||||||
|
id=uuid4(),
|
||||||
|
username=username,
|
||||||
|
email=email or f"{oidc_subject}@oidc.local", # Fallback if no email claim
|
||||||
|
oidc_subject=oidc_subject,
|
||||||
|
auth_provider="oidc",
|
||||||
|
password=None,
|
||||||
|
ldap_groups=groups,
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def find_user_by_oidc_subject(oidc_subject: str) -> Optional[User]:
|
||||||
|
"""Find user by OIDC subject ID"""
|
||||||
|
return await User.filter(oidc_subject=oidc_subject).first()
|
||||||
254
blueprints/whatsapp/__init__.py
Normal file
254
blueprints/whatsapp/__init__.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from quart import Blueprint, request, jsonify, abort
|
||||||
|
from twilio.request_validator import RequestValidator
|
||||||
|
from twilio.twiml.messaging_response import MessagingResponse
|
||||||
|
|
||||||
|
from blueprints.users.models import User
|
||||||
|
from blueprints.conversation.logic import (
|
||||||
|
get_conversation_for_user,
|
||||||
|
add_message_to_conversation,
|
||||||
|
get_conversation_transcript,
|
||||||
|
)
|
||||||
|
from blueprints.conversation.agents import main_agent
|
||||||
|
|
||||||
|
whatsapp_blueprint = Blueprint("whatsapp_api", __name__, url_prefix="/api/whatsapp")
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Rate limiting: per-number message timestamps
|
||||||
|
# Format: {phone_number: [timestamp1, timestamp2, ...]}
|
||||||
|
_rate_limit_store: dict[str, list[float]] = defaultdict(list)
|
||||||
|
|
||||||
|
# Configurable via env: max messages per window (default: 10 per 60s)
|
||||||
|
RATE_LIMIT_MAX = int(os.getenv("WHATSAPP_RATE_LIMIT_MAX", "10"))
|
||||||
|
RATE_LIMIT_WINDOW = int(os.getenv("WHATSAPP_RATE_LIMIT_WINDOW", "60"))
|
||||||
|
|
||||||
|
# Max message length to process (WhatsApp max is 4096, but we cap for LLM sanity)
|
||||||
|
MAX_MESSAGE_LENGTH = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def _twiml_response(text: str) -> tuple[str, int]:
|
||||||
|
"""Helper to return a TwiML MessagingResponse."""
|
||||||
|
resp = MessagingResponse()
|
||||||
|
resp.message(text)
|
||||||
|
return str(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(phone_number: str) -> bool:
|
||||||
|
"""Check if a phone number has exceeded the rate limit.
|
||||||
|
|
||||||
|
Returns True if the request is allowed, False if rate-limited.
|
||||||
|
Also cleans up expired entries.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
cutoff = now - RATE_LIMIT_WINDOW
|
||||||
|
|
||||||
|
# Remove expired timestamps
|
||||||
|
timestamps = _rate_limit_store[phone_number]
|
||||||
|
_rate_limit_store[phone_number] = [t for t in timestamps if t > cutoff]
|
||||||
|
|
||||||
|
if len(_rate_limit_store[phone_number]) >= RATE_LIMIT_MAX:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_rate_limit_store[phone_number].append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate_twilio_request(f):
|
||||||
|
"""Decorator to validate that the request comes from Twilio.
|
||||||
|
|
||||||
|
Validates the X-Twilio-Signature header using the TWILIO_AUTH_TOKEN.
|
||||||
|
Set TWILIO_WEBHOOK_URL if behind a reverse proxy (e.g., ngrok, Caddy)
|
||||||
|
so the validated URL matches what Twilio signed against.
|
||||||
|
Set TWILIO_SIGNATURE_VALIDATION=false to disable in development.
|
||||||
|
"""
|
||||||
|
@functools.wraps(f)
|
||||||
|
async def decorated_function(*args, **kwargs):
|
||||||
|
if os.getenv("TWILIO_SIGNATURE_VALIDATION", "true").lower() == "false":
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
|
auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
||||||
|
if not auth_token:
|
||||||
|
logger.error("TWILIO_AUTH_TOKEN not set — rejecting request")
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
twilio_signature = request.headers.get("X-Twilio-Signature")
|
||||||
|
if not twilio_signature:
|
||||||
|
logger.warning("Missing X-Twilio-Signature header")
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
# Use configured webhook URL if behind a proxy, otherwise use request URL
|
||||||
|
url = os.getenv("TWILIO_WEBHOOK_URL") or request.url
|
||||||
|
form_data = await request.form
|
||||||
|
|
||||||
|
validator = RequestValidator(auth_token)
|
||||||
|
if not validator.validate(url, form_data, twilio_signature):
|
||||||
|
logger.warning(f"Invalid Twilio signature for URL: {url}")
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_blueprint.route("/webhook", methods=["POST"])
|
||||||
|
@validate_twilio_request
|
||||||
|
async def webhook():
|
||||||
|
"""
|
||||||
|
Handle incoming WhatsApp messages from Twilio.
|
||||||
|
"""
|
||||||
|
form_data = await request.form
|
||||||
|
from_number = form_data.get("From") # e.g., "whatsapp:+1234567890"
|
||||||
|
body = form_data.get("Body")
|
||||||
|
|
||||||
|
if not from_number or not body:
|
||||||
|
return _twiml_response("Invalid message received.") if from_number else ("Missing From or Body", 400)
|
||||||
|
|
||||||
|
# Strip whitespace and check for empty body
|
||||||
|
body = body.strip()
|
||||||
|
if not body:
|
||||||
|
return _twiml_response("I received an empty message. Please send some text!")
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
if not _check_rate_limit(from_number):
|
||||||
|
logger.warning(f"Rate limit exceeded for {from_number}")
|
||||||
|
return _twiml_response("You're sending messages too quickly. Please wait a moment and try again.")
|
||||||
|
|
||||||
|
# Truncate overly long messages
|
||||||
|
if len(body) > MAX_MESSAGE_LENGTH:
|
||||||
|
body = body[:MAX_MESSAGE_LENGTH]
|
||||||
|
logger.info(f"Truncated long message from {from_number} to {MAX_MESSAGE_LENGTH} chars")
|
||||||
|
|
||||||
|
logger.info(f"Received WhatsApp message from {from_number}: {body[:100]}")
|
||||||
|
|
||||||
|
# Identify or create user
|
||||||
|
user = await User.filter(whatsapp_number=from_number).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
# Check if number is in allowlist
|
||||||
|
allowed_numbers = os.getenv("ALLOWED_WHATSAPP_NUMBERS", "").split(",")
|
||||||
|
if from_number not in allowed_numbers and "*" not in allowed_numbers:
|
||||||
|
return _twiml_response("Sorry, you are not authorized to use this service.")
|
||||||
|
|
||||||
|
# Create a new user for this WhatsApp number
|
||||||
|
username = f"wa_{from_number.split(':')[-1]}"
|
||||||
|
try:
|
||||||
|
user = await User.create(
|
||||||
|
username=username,
|
||||||
|
email=f"{username}@whatsapp.simbarag.local",
|
||||||
|
whatsapp_number=from_number,
|
||||||
|
auth_provider="whatsapp"
|
||||||
|
)
|
||||||
|
logger.info(f"Created new user for WhatsApp: {username}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create user for {from_number}: {e}")
|
||||||
|
return _twiml_response("Sorry, something went wrong setting up your account. Please try again later.")
|
||||||
|
|
||||||
|
# Get or create a conversation for this user
|
||||||
|
try:
|
||||||
|
conversation = await get_conversation_for_user(user=user)
|
||||||
|
await conversation.fetch_related("messages")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get conversation for user {user.username}: {e}")
|
||||||
|
return _twiml_response("Sorry, something went wrong. Please try again later.")
|
||||||
|
|
||||||
|
# Add user message to conversation
|
||||||
|
await add_message_to_conversation(
|
||||||
|
conversation=conversation,
|
||||||
|
message=body,
|
||||||
|
speaker="user",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get transcript for context
|
||||||
|
transcript = await get_conversation_transcript(user=user, conversation=conversation)
|
||||||
|
|
||||||
|
# Build messages payload for LangChain agent with system prompt and conversation history
|
||||||
|
try:
|
||||||
|
# System prompt with Simba's facts and medical information
|
||||||
|
system_prompt = """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.
|
||||||
|
|
||||||
|
SIMBA FACTS (as of January 2026):
|
||||||
|
- Name: Simba
|
||||||
|
- Species: Feline (Domestic Short Hair / American Short Hair)
|
||||||
|
- Sex: Male, Neutered
|
||||||
|
- Date of Birth: August 8, 2016 (approximately 9 years 5 months old)
|
||||||
|
- Color: Orange
|
||||||
|
- Current Weight: 16 lbs (as of 1/8/2026)
|
||||||
|
- Owner: Ryan Chen
|
||||||
|
- Location: Long Island City, NY
|
||||||
|
- Veterinarian: Court Square Animal Hospital
|
||||||
|
|
||||||
|
Medical Conditions:
|
||||||
|
- Hypertrophic Cardiomyopathy (HCM): Diagnosed 12/11/2025. Concentric left ventricular hypertrophy with no left atrial dilation. Grade II-III/VI systolic heart murmur. No cardiac medications currently needed. Must avoid Domitor, acepromazine, and ketamine during anesthesia.
|
||||||
|
- Dental Issues: Prior extraction of teeth 307 and 407 due to resorption. Tooth 107 extracted on 1/8/2026. Early resorption lesions present on teeth 207, 309, and 409.
|
||||||
|
|
||||||
|
Recent Medical Events:
|
||||||
|
- 1/8/2026: Dental cleaning and tooth 107 extraction. Prescribed Onsior for 3 days. Oravet sealant applied.
|
||||||
|
- 12/11/2025: Echocardiogram confirming HCM diagnosis. Pre-op bloodwork was normal.
|
||||||
|
- 12/1/2025: Visited for decreased appetite/nausea. Received subcutaneous fluids and Cerenia.
|
||||||
|
|
||||||
|
Diet & Lifestyle:
|
||||||
|
- Diet: Hill's I/D wet and dry food
|
||||||
|
- Supplements: Plaque Off
|
||||||
|
- Indoor only cat, only pet in the household
|
||||||
|
|
||||||
|
Upcoming Appointments:
|
||||||
|
- Rabies Vaccine: Due 2/19/2026
|
||||||
|
- Routine Examination: Due 6/1/2026
|
||||||
|
- FVRCP-3yr Vaccine: Due 10/2/2026
|
||||||
|
|
||||||
|
IMPORTANT: 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.
|
||||||
|
|
||||||
|
BUDGET & FINANCE (YNAB Integration):
|
||||||
|
You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools:
|
||||||
|
- Use ynab_budget_summary for overall budget health and status questions
|
||||||
|
- Use ynab_search_transactions to find specific purchases or spending at particular stores
|
||||||
|
- Use ynab_category_spending to analyze spending by category for a month
|
||||||
|
- Use ynab_insights to provide spending trends, patterns, and recommendations
|
||||||
|
Always use these tools when asked about budgets, spending, transactions, or financial health."""
|
||||||
|
|
||||||
|
# Get last 10 messages for conversation history
|
||||||
|
messages = await conversation.messages.all()
|
||||||
|
recent_messages = list(messages)[-10:]
|
||||||
|
|
||||||
|
# Build messages payload
|
||||||
|
messages_payload = [{"role": "system", "content": system_prompt}]
|
||||||
|
|
||||||
|
# Add recent conversation history (exclude the message we just added)
|
||||||
|
for msg in recent_messages[:-1]:
|
||||||
|
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": body})
|
||||||
|
|
||||||
|
# Invoke LangChain agent
|
||||||
|
logger.info(f"Invoking LangChain agent with {len(messages_payload)} messages")
|
||||||
|
response = await main_agent.ainvoke({"messages": messages_payload})
|
||||||
|
response_text = response.get("messages", [])[-1].content
|
||||||
|
|
||||||
|
# Log YNAB availability
|
||||||
|
if os.getenv("YNAB_ACCESS_TOKEN"):
|
||||||
|
logger.info("YNAB integration is available for this conversation")
|
||||||
|
else:
|
||||||
|
logger.info("YNAB integration is not configured")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error invoking agent: {e}")
|
||||||
|
response_text = "Sorry, I'm having trouble thinking right now. 😿"
|
||||||
|
|
||||||
|
# Add Simba's response to conversation
|
||||||
|
await add_message_to_conversation(
|
||||||
|
conversation=conversation,
|
||||||
|
message=response_text,
|
||||||
|
speaker="simba",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _twiml_response(response_text)
|
||||||
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
118
config/oidc_config.py
Normal file
118
config/oidc_config.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
OIDC Configuration for Authelia Integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Dict, Any
|
||||||
|
from authlib.jose import jwt
|
||||||
|
from authlib.jose.errors import JoseError
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCConfig:
|
||||||
|
"""OIDC Configuration Manager"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Load from environment variables
|
||||||
|
self.issuer = os.getenv("OIDC_ISSUER") # e.g., https://auth.example.com
|
||||||
|
self.client_id = os.getenv("OIDC_CLIENT_ID")
|
||||||
|
self.client_secret = os.getenv("OIDC_CLIENT_SECRET")
|
||||||
|
self.redirect_uri = os.getenv(
|
||||||
|
"OIDC_REDIRECT_URI", "http://localhost:8080/api/user/oidc/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
# OIDC endpoints (can use discovery or manual config)
|
||||||
|
self.use_discovery = os.getenv("OIDC_USE_DISCOVERY", "true").lower() == "true"
|
||||||
|
|
||||||
|
# Manual endpoint configuration (fallback if discovery fails)
|
||||||
|
self.authorization_endpoint = os.getenv("OIDC_AUTHORIZATION_ENDPOINT")
|
||||||
|
self.token_endpoint = os.getenv("OIDC_TOKEN_ENDPOINT")
|
||||||
|
self.userinfo_endpoint = os.getenv("OIDC_USERINFO_ENDPOINT")
|
||||||
|
self.jwks_uri = os.getenv("OIDC_JWKS_URI")
|
||||||
|
|
||||||
|
# Cached discovery document and JWKS
|
||||||
|
self._discovery_doc: Dict[str, Any] | None = None
|
||||||
|
self._jwks: Dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def validate_config(self) -> bool:
|
||||||
|
"""Validate that required configuration is present"""
|
||||||
|
if not self.issuer or not self.client_id or not self.client_secret:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_discovery_document(self) -> Dict[str, Any]:
|
||||||
|
"""Fetch OIDC discovery document from .well-known endpoint"""
|
||||||
|
if self._discovery_doc:
|
||||||
|
return self._discovery_doc
|
||||||
|
|
||||||
|
if not self.use_discovery:
|
||||||
|
# Return manual configuration
|
||||||
|
return {
|
||||||
|
"issuer": self.issuer,
|
||||||
|
"authorization_endpoint": self.authorization_endpoint,
|
||||||
|
"token_endpoint": self.token_endpoint,
|
||||||
|
"userinfo_endpoint": self.userinfo_endpoint,
|
||||||
|
"jwks_uri": self.jwks_uri,
|
||||||
|
}
|
||||||
|
|
||||||
|
discovery_url = f"{self.issuer.rstrip('/')}/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(discovery_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
self._discovery_doc = response.json()
|
||||||
|
return self._discovery_doc
|
||||||
|
|
||||||
|
async def get_jwks(self) -> Dict[str, Any]:
|
||||||
|
"""Fetch JSON Web Key Set for token verification"""
|
||||||
|
if self._jwks:
|
||||||
|
return self._jwks
|
||||||
|
|
||||||
|
discovery = await self.get_discovery_document()
|
||||||
|
jwks_uri = discovery.get("jwks_uri")
|
||||||
|
|
||||||
|
if not jwks_uri:
|
||||||
|
raise ValueError("No jwks_uri found in discovery document")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(jwks_uri)
|
||||||
|
response.raise_for_status()
|
||||||
|
self._jwks = response.json()
|
||||||
|
return self._jwks
|
||||||
|
|
||||||
|
async def verify_id_token(self, id_token: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify and decode ID token from OIDC provider
|
||||||
|
|
||||||
|
Returns the decoded claims if valid
|
||||||
|
Raises exception if invalid
|
||||||
|
"""
|
||||||
|
jwks = await self.get_jwks()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify token signature and claims
|
||||||
|
claims = jwt.decode(
|
||||||
|
id_token,
|
||||||
|
jwks,
|
||||||
|
claims_options={
|
||||||
|
"iss": {"essential": True, "value": self.issuer},
|
||||||
|
"aud": {"essential": True, "value": self.client_id},
|
||||||
|
"exp": {"essential": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Additional validation
|
||||||
|
claims.validate()
|
||||||
|
|
||||||
|
return claims
|
||||||
|
|
||||||
|
except JoseError as e:
|
||||||
|
raise ValueError(f"Invalid ID token: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
oidc_config = OIDCConfig()
|
||||||
68
docker-compose.yml
Normal file
68
docker-compose.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:-raggr}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:-raggr}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-raggr}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
raggr:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: torrtle/simbarag:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN}
|
||||||
|
- BASE_URL=${BASE_URL}
|
||||||
|
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
|
||||||
|
- CHROMADB_PATH=/app/data/chromadb
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||||
|
- LLAMA_SERVER_URL=${LLAMA_SERVER_URL}
|
||||||
|
- LLAMA_MODEL_NAME=${LLAMA_MODEL_NAME}
|
||||||
|
- OIDC_ISSUER=${OIDC_ISSUER}
|
||||||
|
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||||
|
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI}
|
||||||
|
- OIDC_USE_DISCOVERY=${OIDC_USE_DISCOVERY:-true}
|
||||||
|
- DATABASE_URL=${DATABASE_URL:-postgres://raggr:changeme@postgres:5432/raggr}
|
||||||
|
- TAVILY_API_KEY=${TAVILIY_API_KEY}
|
||||||
|
- YNAB_ACCESS_TOKEN=${YNAB_ACCESS_TOKEN}
|
||||||
|
- YNAB_BUDGET_ID=${YNAB_BUDGET_ID}
|
||||||
|
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
|
||||||
|
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
|
||||||
|
- TWILIO_WHATSAPP_NUMBER=${TWILIO_WHATSAPP_NUMBER}
|
||||||
|
- ALLOWED_WHATSAPP_NUMBERS=${ALLOWED_WHATSAPP_NUMBERS}
|
||||||
|
- TWILIO_SIGNATURE_VALIDATION=${TWILIO_SIGNATURE_VALIDATION:-true}
|
||||||
|
- TWILIO_WEBHOOK_URL=${TWILIO_WEBHOOK_URL:-}
|
||||||
|
- OBSIDIAN_AUTH_TOKEN=${OBSIDIAN_AUTH_TOKEN}
|
||||||
|
- OBSIDIAN_VAULT_ID=${OBSIDIAN_VAULT_ID}
|
||||||
|
- OBSIDIAN_E2E_PASSWORD=${OBSIDIAN_E2E_PASSWORD}
|
||||||
|
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
|
||||||
|
- OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false}
|
||||||
|
- OBSIDIAN_VAULT_PATH=${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- chromadb_data:/app/data/chromadb
|
||||||
|
- ./obvault:/app/data/obsidian
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
chromadb_data:
|
||||||
|
postgres_data:
|
||||||
54
docs/MIGRATIONS.md
Normal file
54
docs/MIGRATIONS.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Database Migrations with Aerich
|
||||||
|
|
||||||
|
## Initial Setup (Run Once)
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
uv pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Initialize Aerich:
|
||||||
|
```bash
|
||||||
|
aerich init-db
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Create a `migrations/` directory
|
||||||
|
- Generate the initial migration based on your models
|
||||||
|
- Create all tables in the database
|
||||||
|
|
||||||
|
## When You Add/Change Models
|
||||||
|
|
||||||
|
1. Generate a new migration:
|
||||||
|
```bash
|
||||||
|
aerich migrate --name "describe_your_changes"
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
aerich migrate --name "add_user_profile_model"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Apply the migration:
|
||||||
|
```bash
|
||||||
|
aerich upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
- `aerich init-db` - Initialize database (first time only)
|
||||||
|
- `aerich migrate --name "description"` - Generate new migration
|
||||||
|
- `aerich upgrade` - Apply pending migrations
|
||||||
|
- `aerich downgrade` - Rollback last migration
|
||||||
|
- `aerich history` - Show migration history
|
||||||
|
- `aerich heads` - Show current migration heads
|
||||||
|
|
||||||
|
## Docker Setup
|
||||||
|
|
||||||
|
In Docker, migrations run automatically on container startup via the startup script.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Migration files are stored in `migrations/models/`
|
||||||
|
- Always commit migration files to version control
|
||||||
|
- Don't modify migration files manually after they're created
|
||||||
53
docs/TASKS.md
Normal file
53
docs/TASKS.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Tasks & Feature Requests
|
||||||
|
|
||||||
|
## Feature Requests
|
||||||
|
|
||||||
|
### YNAB Integration (Admin-Only)
|
||||||
|
- **Description**: Integration with YNAB (You Need A Budget) API to enable financial data queries and insights
|
||||||
|
- **Requirements**:
|
||||||
|
- Admin-guarded endpoint (requires `lldap_admin` group)
|
||||||
|
- YNAB API token configuration in environment variables
|
||||||
|
- Sync budget data, transactions, and categories
|
||||||
|
- Store YNAB data for RAG queries
|
||||||
|
- **Endpoints**:
|
||||||
|
- `POST /api/admin/ynab/sync` - Trigger YNAB data sync
|
||||||
|
- `GET /api/admin/ynab/status` - Check sync status and last update
|
||||||
|
- `GET /api/admin/ynab/budgets` - List available budgets
|
||||||
|
- **Implementation Notes**:
|
||||||
|
- Use YNAB API v1 (https://api.youneedabudget.com/v1)
|
||||||
|
- Consider rate limiting (200 requests per hour)
|
||||||
|
- Store transaction data in PostgreSQL with appropriate indexing
|
||||||
|
- Index transaction descriptions and categories in ChromaDB for RAG queries
|
||||||
|
|
||||||
|
### Money Insights
|
||||||
|
- **Description**: AI-powered financial insights and analysis based on YNAB data
|
||||||
|
- **Features**:
|
||||||
|
- Spending pattern analysis
|
||||||
|
- Budget vs. actual comparisons
|
||||||
|
- Category-based spending trends
|
||||||
|
- Anomaly detection (unusual transactions)
|
||||||
|
- Natural language queries like "How much did I spend on groceries last month?"
|
||||||
|
- Month-over-month and year-over-year comparisons
|
||||||
|
- **Implementation Notes**:
|
||||||
|
- Leverage existing LangChain agent architecture
|
||||||
|
- Add custom tools for financial calculations
|
||||||
|
- Use LLM to generate insights and summaries
|
||||||
|
- Create visualizations or data exports for frontend display
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
- [ ] YNAB API client module
|
||||||
|
- [ ] YNAB data models (Budget, Transaction, Category, Account)
|
||||||
|
- [ ] Database schema for financial data
|
||||||
|
- [ ] YNAB sync background job/scheduler
|
||||||
|
- [ ] Financial insights LangChain tools
|
||||||
|
- [ ] Admin UI for YNAB configuration
|
||||||
|
- [ ] Frontend components for money insights display
|
||||||
|
|
||||||
|
## Technical Debt
|
||||||
|
|
||||||
|
_To be added_
|
||||||
|
|
||||||
|
## Bugs
|
||||||
|
|
||||||
|
_To be added_
|
||||||
97
docs/VECTORSTORE.md
Normal file
97
docs/VECTORSTORE.md
Normal 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 `scripts/manage_vectorstore.py` script for vector store operations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show statistics
|
||||||
|
python scripts/manage_vectorstore.py stats
|
||||||
|
|
||||||
|
# Index documents from Paperless-NGX (incremental)
|
||||||
|
python scripts/manage_vectorstore.py index
|
||||||
|
|
||||||
|
# Clear and reindex all documents
|
||||||
|
python scripts/manage_vectorstore.py reindex
|
||||||
|
|
||||||
|
# List documents
|
||||||
|
python scripts/manage_vectorstore.py list 10
|
||||||
|
python scripts/manage_vectorstore.py list 20 --show-content
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Run commands inside the Docker container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show statistics
|
||||||
|
docker compose exec raggr python scripts/manage_vectorstore.py stats
|
||||||
|
|
||||||
|
# Reindex all documents
|
||||||
|
docker compose exec raggr python scripts/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 scripts/manage_vectorstore.py reindex
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty results
|
||||||
|
|
||||||
|
Check if documents are indexed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/manage_vectorstore.py stats
|
||||||
|
```
|
||||||
|
|
||||||
|
If count is 0, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/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 raggr python scripts/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
|
||||||
274
docs/authentication.md
Normal file
274
docs/authentication.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# Authentication Architecture
|
||||||
|
|
||||||
|
This document describes the authentication stack for SimbaRAG: LLDAP → Authelia → OAuth2/OIDC.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐
|
||||||
|
│ LLDAP │────▶│ Authelia │────▶│ OAuth2/OIDC │────▶│ SimbaRAG │
|
||||||
|
│ (Users) │ │ (IdP) │ │ (Flow) │ │ (App) │
|
||||||
|
└─────────┘ └──────────┘ └──────────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Component | Role |
|
||||||
|
|-----------|------|
|
||||||
|
| **LLDAP** | Lightweight LDAP server storing users and groups |
|
||||||
|
| **Authelia** | Identity provider that authenticates against LLDAP and issues OIDC tokens |
|
||||||
|
| **SimbaRAG** | Relying party that consumes OIDC tokens and manages sessions |
|
||||||
|
|
||||||
|
## OIDC Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `OIDC_ISSUER` | Authelia server URL | Required |
|
||||||
|
| `OIDC_CLIENT_ID` | Client ID registered in Authelia | Required |
|
||||||
|
| `OIDC_CLIENT_SECRET` | Client secret for token exchange | Required |
|
||||||
|
| `OIDC_REDIRECT_URI` | Callback URL after authentication | Required |
|
||||||
|
| `OIDC_USE_DISCOVERY` | Enable automatic discovery | `true` |
|
||||||
|
| `JWT_SECRET_KEY` | Secret for signing backend JWTs | Required |
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
When `OIDC_USE_DISCOVERY=true`, the application fetches endpoints from:
|
||||||
|
|
||||||
|
```
|
||||||
|
{OIDC_ISSUER}/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides:
|
||||||
|
|
||||||
|
- Authorization endpoint
|
||||||
|
- Token endpoint
|
||||||
|
- JWKS URI for signature verification
|
||||||
|
- Supported scopes and claims
|
||||||
|
|
||||||
|
## Authentication Flow
|
||||||
|
|
||||||
|
### 1. Login Initiation
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/user/oidc/login
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Generate PKCE code verifier and challenge (S256)
|
||||||
|
2. Generate CSRF state token
|
||||||
|
3. Store state in session storage
|
||||||
|
4. Return authorization URL for frontend redirect
|
||||||
|
|
||||||
|
### 2. Authorization
|
||||||
|
|
||||||
|
User is redirected to Authelia where they:
|
||||||
|
|
||||||
|
1. Enter LDAP credentials
|
||||||
|
2. Complete MFA if configured
|
||||||
|
3. Consent to requested scopes
|
||||||
|
|
||||||
|
### 3. Callback
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/user/oidc/callback?code=...&state=...
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Validate state matches stored value (CSRF protection)
|
||||||
|
2. Exchange authorization code for tokens using PKCE verifier
|
||||||
|
3. Verify ID token signature using JWKS
|
||||||
|
4. Validate claims (issuer, audience, expiration)
|
||||||
|
5. Create or update user in database
|
||||||
|
6. Issue backend JWT tokens (access + refresh)
|
||||||
|
|
||||||
|
### 4. Token Refresh
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/user/refresh
|
||||||
|
Authorization: Bearer <refresh_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Issues a new access token without re-authentication.
|
||||||
|
|
||||||
|
## User Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
class User(Model):
|
||||||
|
id = UUIDField(primary_key=True)
|
||||||
|
username = CharField(max_length=255)
|
||||||
|
password = BinaryField(null=True) # Nullable for OIDC-only users
|
||||||
|
email = CharField(max_length=100, unique=True)
|
||||||
|
|
||||||
|
# OIDC fields
|
||||||
|
oidc_subject = CharField(max_length=255, unique=True, null=True)
|
||||||
|
auth_provider = CharField(max_length=50, default="local") # "local" or "oidc"
|
||||||
|
ldap_groups = JSONField(default=[]) # LDAP groups from OIDC claims
|
||||||
|
|
||||||
|
created_at = DatetimeField(auto_now_add=True)
|
||||||
|
updated_at = DatetimeField(auto_now=True)
|
||||||
|
|
||||||
|
def has_group(self, group: str) -> bool:
|
||||||
|
"""Check if user belongs to a specific LDAP group."""
|
||||||
|
return group in (self.ldap_groups or [])
|
||||||
|
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
"""Check if user is an admin (member of lldap_admin group)."""
|
||||||
|
return self.has_group("lldap_admin")
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Provisioning
|
||||||
|
|
||||||
|
The `OIDCUserService` handles automatic user creation:
|
||||||
|
|
||||||
|
1. Extract claims from ID token (`sub`, `email`, `preferred_username`)
|
||||||
|
2. Check if user exists by `oidc_subject`
|
||||||
|
3. If not, check by email for migration from local auth
|
||||||
|
4. Create new user or update existing
|
||||||
|
|
||||||
|
## JWT Tokens
|
||||||
|
|
||||||
|
Backend issues its own JWTs after OIDC authentication:
|
||||||
|
|
||||||
|
| Token Type | Purpose | Typical Lifetime |
|
||||||
|
|------------|---------|------------------|
|
||||||
|
| Access Token | API authorization | 15 minutes |
|
||||||
|
| Refresh Token | Obtain new access tokens | 7 days |
|
||||||
|
|
||||||
|
### Claims
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"identity": "<user-uuid>",
|
||||||
|
"type": "access|refresh",
|
||||||
|
"exp": 1234567890,
|
||||||
|
"iat": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protected Endpoints
|
||||||
|
|
||||||
|
All API endpoints use the `@jwt_refresh_token_required` decorator for basic authentication:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@blueprint.route("/example")
|
||||||
|
@jwt_refresh_token_required
|
||||||
|
async def protected_endpoint():
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Role-Based Access Control (RBAC)
|
||||||
|
|
||||||
|
RBAC is implemented using LDAP groups passed through Authelia as OIDC claims. Users in the `lldap_admin` group have admin privileges.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ LLDAP │
|
||||||
|
│ Groups: lldap_admin, lldap_user │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Authelia │
|
||||||
|
│ Scope: groups → Claim: groups = ["lldap_admin"] │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ SimbaRAG │
|
||||||
|
│ 1. Extract groups from ID token │
|
||||||
|
│ 2. Store in User.ldap_groups │
|
||||||
|
│ 3. Check membership with @admin_required decorator │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authelia Configuration
|
||||||
|
|
||||||
|
Ensure Authelia is configured to pass the `groups` claim:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
identity_providers:
|
||||||
|
oidc:
|
||||||
|
clients:
|
||||||
|
- client_id: simbarag
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- profile
|
||||||
|
- email
|
||||||
|
- groups # Required for RBAC
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin-Only Endpoints
|
||||||
|
|
||||||
|
The `@admin_required` decorator protects privileged endpoints:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from blueprints.users.decorators import admin_required
|
||||||
|
|
||||||
|
@blueprint.post("/admin-action")
|
||||||
|
@admin_required
|
||||||
|
async def admin_only_endpoint():
|
||||||
|
# Only users in lldap_admin group can access
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Protected endpoints:**
|
||||||
|
|
||||||
|
| Endpoint | Access | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `POST /api/rag/index` | Admin | Trigger document indexing |
|
||||||
|
| `POST /api/rag/reindex` | Admin | Clear and reindex all documents |
|
||||||
|
| `GET /api/rag/stats` | All users | View vector store statistics |
|
||||||
|
|
||||||
|
### User Response
|
||||||
|
|
||||||
|
The OIDC callback returns group information:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "...",
|
||||||
|
"refresh_token": "...",
|
||||||
|
"user": {
|
||||||
|
"id": "uuid",
|
||||||
|
"username": "john",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"groups": ["lldap_admin", "lldap_user"],
|
||||||
|
"is_admin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Current Gaps
|
||||||
|
|
||||||
|
| Issue | Risk | Mitigation |
|
||||||
|
|-------|------|------------|
|
||||||
|
| In-memory session storage | State lost on restart, not scalable | Use Redis for production |
|
||||||
|
| No token revocation | Tokens valid until expiry | Implement blacklist or short expiry |
|
||||||
|
| No audit logging | Cannot track auth events | Add event logging |
|
||||||
|
| Single JWT secret | Compromise affects all tokens | Rotate secrets, use asymmetric keys |
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
|
||||||
|
1. **Use Redis** for OIDC state storage in production
|
||||||
|
2. **Implement logout** with token blacklisting
|
||||||
|
3. **Add audit logging** for authentication events
|
||||||
|
4. **Rotate JWT secrets** regularly
|
||||||
|
5. **Use short-lived access tokens** (15 min) with refresh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Reference
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `services/raggr/oidc_config.py` | OIDC client configuration and discovery |
|
||||||
|
| `services/raggr/blueprints/users/models.py` | User model definition with group helpers |
|
||||||
|
| `services/raggr/blueprints/users/oidc_service.py` | User provisioning from OIDC claims |
|
||||||
|
| `services/raggr/blueprints/users/__init__.py` | Auth endpoints and flow |
|
||||||
|
| `services/raggr/blueprints/users/decorators.py` | Auth decorators (`@admin_required`) |
|
||||||
188
docs/deployment.md
Normal file
188
docs/deployment.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Deployment & Migrations Guide
|
||||||
|
|
||||||
|
This document covers database migrations and deployment workflows for SimbaRAG.
|
||||||
|
|
||||||
|
## Migration Workflow
|
||||||
|
|
||||||
|
Migrations are managed by [Aerich](https://github.com/tortoise/aerich), the migration tool for Tortoise ORM.
|
||||||
|
|
||||||
|
### Key Principles
|
||||||
|
|
||||||
|
1. **Generate migrations in Docker** - Aerich needs database access to detect schema changes
|
||||||
|
2. **Migrations auto-apply on startup** - Both `startup.sh` and `startup-dev.sh` run `aerich upgrade`
|
||||||
|
3. **Commit migrations to git** - Migration files must be in the repo for production deploys
|
||||||
|
|
||||||
|
### Generating a New Migration
|
||||||
|
|
||||||
|
#### Development (Recommended)
|
||||||
|
|
||||||
|
With `docker-compose.dev.yml`, your local `services/raggr` directory is synced to the container. Migrations generated inside the container appear on your host automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start the dev environment
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 2. Generate migration (runs inside container, syncs to host)
|
||||||
|
docker compose -f docker-compose.dev.yml exec raggr aerich migrate --name describe_your_change
|
||||||
|
|
||||||
|
# 3. Verify migration was created
|
||||||
|
ls services/raggr/migrations/models/
|
||||||
|
|
||||||
|
# 4. Commit the migration
|
||||||
|
git add services/raggr/migrations/
|
||||||
|
git commit -m "Add migration: describe_your_change"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Production Container
|
||||||
|
|
||||||
|
For production, migration files are baked into the image. You must generate migrations in dev first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If you need to generate a migration from production (not recommended):
|
||||||
|
docker compose exec raggr aerich migrate --name describe_your_change
|
||||||
|
|
||||||
|
# Copy the file out of the container
|
||||||
|
docker cp $(docker compose ps -q raggr):/app/migrations/models/ ./services/raggr/migrations/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Applying Migrations
|
||||||
|
|
||||||
|
Migrations apply automatically on container start via the startup scripts.
|
||||||
|
|
||||||
|
**Manual application (if needed):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev
|
||||||
|
docker compose -f docker-compose.dev.yml exec raggr aerich upgrade
|
||||||
|
|
||||||
|
# Production
|
||||||
|
docker compose exec raggr aerich upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking Migration Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View applied migrations
|
||||||
|
docker compose exec raggr aerich history
|
||||||
|
|
||||||
|
# View pending migrations
|
||||||
|
docker compose exec raggr aerich heads
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rolling Back
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Downgrade one migration
|
||||||
|
docker compose exec raggr aerich downgrade
|
||||||
|
|
||||||
|
# Downgrade to specific version
|
||||||
|
docker compose exec raggr aerich downgrade -v 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Workflows
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start with watch mode (auto-restarts on file changes)
|
||||||
|
docker compose -f docker-compose.dev.yml up
|
||||||
|
|
||||||
|
# Or with docker compose watch (requires Docker Compose v2.22+)
|
||||||
|
docker compose -f docker-compose.dev.yml watch
|
||||||
|
```
|
||||||
|
|
||||||
|
The dev environment:
|
||||||
|
- Syncs `services/raggr/` to `/app` in the container
|
||||||
|
- Rebuilds frontend on changes
|
||||||
|
- Auto-applies migrations on startup
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and deploy
|
||||||
|
docker compose build raggr
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f raggr
|
||||||
|
|
||||||
|
# Verify migrations applied
|
||||||
|
docker compose exec raggr aerich history
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fresh Deploy (New Database)
|
||||||
|
|
||||||
|
On first deploy with an empty database, `startup-dev.sh` runs `aerich init-db` instead of `aerich upgrade`. This creates all tables from the current models.
|
||||||
|
|
||||||
|
For production (`startup.sh`), ensure the database exists and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If aerich table doesn't exist yet
|
||||||
|
docker compose exec raggr aerich init-db
|
||||||
|
|
||||||
|
# Or if migrating from existing schema
|
||||||
|
docker compose exec raggr aerich upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No migrations found" on startup
|
||||||
|
|
||||||
|
The `migrations/models/` directory is empty or not copied into the image.
|
||||||
|
|
||||||
|
**Fix:** Ensure migrations are committed and the Dockerfile copies them:
|
||||||
|
```dockerfile
|
||||||
|
COPY migrations ./migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration fails with "relation already exists"
|
||||||
|
|
||||||
|
The database has tables but aerich doesn't know about them (fresh aerich setup on existing DB).
|
||||||
|
|
||||||
|
**Fix:** Fake the initial migration:
|
||||||
|
```bash
|
||||||
|
# Mark initial migration as applied without running it
|
||||||
|
docker compose exec raggr aerich upgrade --fake
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model changes not detected
|
||||||
|
|
||||||
|
Aerich compares models against the last migration's state. If state is out of sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Regenerate migration state (dangerous - review carefully)
|
||||||
|
docker compose exec raggr aerich migrate --name fix_state
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database connection errors
|
||||||
|
|
||||||
|
Ensure PostgreSQL is healthy before running migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check postgres status
|
||||||
|
docker compose ps postgres
|
||||||
|
|
||||||
|
# Wait for postgres then run migrations
|
||||||
|
docker compose exec raggr bash -c "sleep 5 && aerich upgrade"
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Reference
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `pyproject.toml` | Aerich config (`[tool.aerich]` section) |
|
||||||
|
| `migrations/models/` | Migration files |
|
||||||
|
| `startup.sh` | Production startup (runs `aerich upgrade`) |
|
||||||
|
| `startup-dev.sh` | Dev startup (runs `aerich upgrade` or `init-db`) |
|
||||||
|
| `app.py` | Contains `TORTOISE_CONFIG` |
|
||||||
|
| `aerich_config.py` | Aerich initialization configuration |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|------|---------|
|
||||||
|
| Generate migration | `docker compose -f docker-compose.dev.yml exec raggr aerich migrate --name name` |
|
||||||
|
| Apply migrations | `docker compose exec raggr aerich upgrade` |
|
||||||
|
| View history | `docker compose exec raggr aerich history` |
|
||||||
|
| Rollback | `docker compose exec raggr aerich downgrade` |
|
||||||
|
| Fresh init | `docker compose exec raggr aerich init-db` |
|
||||||
258
docs/development.md
Normal file
258
docs/development.md
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# Development Guide
|
||||||
|
|
||||||
|
This guide explains how to run SimbaRAG in development mode.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Option 1: Local Development (Recommended)
|
||||||
|
|
||||||
|
Run PostgreSQL in Docker and the application locally for faster iteration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start PostgreSQL
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 2. Set environment variables
|
||||||
|
export DATABASE_URL="postgres://raggr:raggr_dev_password@localhost:5432/raggr"
|
||||||
|
export CHROMADB_PATH="./chromadb"
|
||||||
|
export $(grep -v '^#' .env | xargs) # Load other vars from .env
|
||||||
|
|
||||||
|
# 3. Install dependencies (first time)
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cd raggr-frontend && yarn install && yarn build && cd ..
|
||||||
|
|
||||||
|
# 4. Run migrations
|
||||||
|
aerich upgrade
|
||||||
|
|
||||||
|
# 5. Start the server
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at `http://localhost:8080`.
|
||||||
|
|
||||||
|
### Option 2: Full Docker Development
|
||||||
|
|
||||||
|
Run everything in Docker with hot reload (slower, but matches production):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Uncomment the raggr service in docker-compose.dev.yml first!
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose -f docker-compose.dev.yml logs -f raggr
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
raggr/
|
||||||
|
├── app.py # Quart application entry point
|
||||||
|
├── main.py # RAG logic and LangChain agent
|
||||||
|
├── llm.py # LLM client (Ollama + OpenAI fallback)
|
||||||
|
├── aerich_config.py # Database migration configuration
|
||||||
|
│
|
||||||
|
├── blueprints/ # API route blueprints
|
||||||
|
│ ├── users/ # Authentication (OIDC, JWT, RBAC)
|
||||||
|
│ ├── conversation/ # Chat conversations and messages
|
||||||
|
│ └── rag/ # Document indexing (admin only)
|
||||||
|
│
|
||||||
|
├── config/ # Configuration modules
|
||||||
|
│ └── oidc_config.py # OIDC authentication settings
|
||||||
|
│
|
||||||
|
├── utils/ # Reusable utilities
|
||||||
|
│ ├── chunker.py # Document chunking for embeddings
|
||||||
|
│ ├── cleaner.py # PDF cleaning and summarization
|
||||||
|
│ ├── image_process.py # Image description with LLM
|
||||||
|
│ └── request.py # Paperless-NGX API client
|
||||||
|
│
|
||||||
|
├── scripts/ # Administrative scripts
|
||||||
|
│ ├── add_user.py # Create users manually
|
||||||
|
│ ├── user_message_stats.py # User message statistics
|
||||||
|
│ ├── manage_vectorstore.py # Vector store management
|
||||||
|
│ ├── inspect_vector_store.py # Inspect ChromaDB contents
|
||||||
|
│ └── query.py # Query generation utilities
|
||||||
|
│
|
||||||
|
├── raggr-frontend/ # React frontend
|
||||||
|
│ └── src/ # Frontend source code
|
||||||
|
│
|
||||||
|
├── migrations/ # Database migrations
|
||||||
|
└── docs/ # Documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Making Changes
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
|
||||||
|
**Local development:**
|
||||||
|
1. Edit Python files
|
||||||
|
2. Save
|
||||||
|
3. Restart `python app.py` (or use a tool like `watchdog` for auto-reload)
|
||||||
|
|
||||||
|
**Docker development:**
|
||||||
|
1. Edit Python files
|
||||||
|
2. Files are synced via Docker watch mode
|
||||||
|
3. Container automatically restarts
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd raggr-frontend
|
||||||
|
|
||||||
|
# Development mode with hot reload
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# Production build (for testing)
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend serves built files from `raggr-frontend/dist/`.
|
||||||
|
|
||||||
|
### Database Model Changes
|
||||||
|
|
||||||
|
When you modify Tortoise ORM models:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate migration
|
||||||
|
aerich migrate --name "describe_your_change"
|
||||||
|
|
||||||
|
# Apply migration
|
||||||
|
aerich upgrade
|
||||||
|
|
||||||
|
# View history
|
||||||
|
aerich history
|
||||||
|
```
|
||||||
|
|
||||||
|
See [deployment.md](deployment.md) for detailed migration workflows.
|
||||||
|
|
||||||
|
### Adding Dependencies
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
# Add to requirements.txt or use uv
|
||||||
|
pip install package-name
|
||||||
|
pip freeze > requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd raggr-frontend
|
||||||
|
yarn add package-name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to PostgreSQL
|
||||||
|
docker compose -f docker-compose.dev.yml exec postgres psql -U raggr -d raggr
|
||||||
|
|
||||||
|
# Reset database
|
||||||
|
docker compose -f docker-compose.dev.yml down -v
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
aerich init-db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vector Store
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show statistics
|
||||||
|
python scripts/manage_vectorstore.py stats
|
||||||
|
|
||||||
|
# Index new documents from Paperless
|
||||||
|
python scripts/manage_vectorstore.py index
|
||||||
|
|
||||||
|
# Clear and reindex everything
|
||||||
|
python scripts/manage_vectorstore.py reindex
|
||||||
|
```
|
||||||
|
|
||||||
|
See [vectorstore.md](vectorstore.md) for details.
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a new user
|
||||||
|
python scripts/add_user.py
|
||||||
|
|
||||||
|
# View message statistics
|
||||||
|
python scripts/user_message_stats.py
|
||||||
|
|
||||||
|
# Inspect vector store contents
|
||||||
|
python scripts/inspect_vector_store.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure:
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection | `postgres://user:pass@localhost:5432/db` |
|
||||||
|
| `CHROMADB_PATH` | ChromaDB storage path | `./chromadb` |
|
||||||
|
| `OLLAMA_URL` | Ollama server URL | `http://localhost:11434` |
|
||||||
|
| `OPENAI_API_KEY` | OpenAI API key (fallback LLM) | `sk-...` |
|
||||||
|
| `PAPERLESS_TOKEN` | Paperless-NGX API token | `...` |
|
||||||
|
| `BASE_URL` | Paperless-NGX URL | `https://paperless.example.com` |
|
||||||
|
| `OIDC_ISSUER` | OIDC provider URL | `https://auth.example.com` |
|
||||||
|
| `OIDC_CLIENT_ID` | OIDC client ID | `simbarag` |
|
||||||
|
| `OIDC_CLIENT_SECRET` | OIDC client secret | `...` |
|
||||||
|
| `JWT_SECRET_KEY` | JWT signing key | `random-secret` |
|
||||||
|
| `TAVILY_KEY` | Tavily web search API key | `tvly-...` |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find and kill process on port 8080
|
||||||
|
lsof -ti:8080 | xargs kill -9
|
||||||
|
|
||||||
|
# Or change the port in app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if PostgreSQL is running
|
||||||
|
docker compose -f docker-compose.dev.yml ps postgres
|
||||||
|
|
||||||
|
# View PostgreSQL logs
|
||||||
|
docker compose -f docker-compose.dev.yml logs postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Not Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd raggr-frontend
|
||||||
|
rm -rf node_modules dist
|
||||||
|
yarn install
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
### ChromaDB Errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clear and recreate ChromaDB
|
||||||
|
rm -rf chromadb/
|
||||||
|
python scripts/manage_vectorstore.py reindex
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Errors After Reorganization
|
||||||
|
|
||||||
|
Ensure you're in the project root directory when running scripts, or use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add project root to Python path
|
||||||
|
export PYTHONPATH="${PYTHONPATH}:$(pwd)"
|
||||||
|
python scripts/your_script.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hot Tips
|
||||||
|
|
||||||
|
- Use `python -m pdb app.py` for debugging
|
||||||
|
- Enable Quart debug mode in `app.py`: `app.run(debug=True)`
|
||||||
|
- Check API logs: They appear in the terminal running `python app.py`
|
||||||
|
- Frontend logs: Open browser DevTools console
|
||||||
|
- Use `docker compose -f docker-compose.dev.yml down -v` for a clean slate
|
||||||
203
docs/index.md
Normal file
203
docs/index.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# SimbaRAG Documentation
|
||||||
|
|
||||||
|
Welcome to the SimbaRAG documentation! This guide will help you understand, develop, and deploy the SimbaRAG conversational AI system.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
New to SimbaRAG? Start here:
|
||||||
|
|
||||||
|
1. Read the main [README](../README.md) for project overview and architecture
|
||||||
|
2. Follow the [Development Guide](development.md) to set up your environment
|
||||||
|
3. Learn about [Authentication](authentication.md) setup with OIDC and LDAP
|
||||||
|
|
||||||
|
## Documentation Structure
|
||||||
|
|
||||||
|
### Core Guides
|
||||||
|
|
||||||
|
- **[Development Guide](development.md)** - Local development setup, project structure, and workflows
|
||||||
|
- **[Deployment Guide](deployment.md)** - Database migrations, deployment workflows, and troubleshooting
|
||||||
|
- **[Vector Store Guide](VECTORSTORE.md)** - Managing ChromaDB, indexing documents, and RAG operations
|
||||||
|
- **[Migrations Guide](MIGRATIONS.md)** - Database migration reference
|
||||||
|
- **[Authentication Guide](authentication.md)** - OIDC, Authelia, LLDAP configuration and user management
|
||||||
|
|
||||||
|
### Quick Reference
|
||||||
|
|
||||||
|
| Task | Documentation |
|
||||||
|
|------|---------------|
|
||||||
|
| Set up local dev environment | [Development Guide → Quick Start](development.md#quick-start) |
|
||||||
|
| Run database migrations | [Deployment Guide → Migration Workflow](deployment.md#migration-workflow) |
|
||||||
|
| Index documents | [Vector Store Guide → Management Commands](VECTORSTORE.md#management-commands) |
|
||||||
|
| Configure authentication | [Authentication Guide](authentication.md) |
|
||||||
|
| Run administrative scripts | [Development Guide → Scripts](development.md#scripts) |
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start local development
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
export DATABASE_URL="postgres://raggr:raggr_dev_password@localhost:5432/raggr"
|
||||||
|
export CHROMADB_PATH="./chromadb"
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate migration
|
||||||
|
aerich migrate --name "your_change"
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
aerich upgrade
|
||||||
|
|
||||||
|
# View history
|
||||||
|
aerich history
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vector Store Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show statistics
|
||||||
|
python scripts/manage_vectorstore.py stats
|
||||||
|
|
||||||
|
# Index new documents
|
||||||
|
python scripts/manage_vectorstore.py index
|
||||||
|
|
||||||
|
# Reindex everything
|
||||||
|
python scripts/manage_vectorstore.py reindex
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
SimbaRAG is built with:
|
||||||
|
|
||||||
|
- **Backend**: Quart (async Python), LangChain, Tortoise ORM
|
||||||
|
- **Frontend**: React 19, Rsbuild, Tailwind CSS
|
||||||
|
- **Database**: PostgreSQL (users, conversations)
|
||||||
|
- **Vector Store**: ChromaDB (document embeddings)
|
||||||
|
- **LLM**: Ollama (primary), OpenAI (fallback)
|
||||||
|
- **Auth**: Authelia (OIDC), LLDAP (user directory)
|
||||||
|
|
||||||
|
See the [README](../README.md#system-architecture) for detailed architecture diagram.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
simbarag/
|
||||||
|
├── app.py # Quart app entry point
|
||||||
|
├── main.py # RAG & LangChain agent
|
||||||
|
├── llm.py # LLM client
|
||||||
|
├── blueprints/ # API routes
|
||||||
|
├── config/ # Configuration
|
||||||
|
├── utils/ # Utilities
|
||||||
|
├── scripts/ # Admin scripts
|
||||||
|
├── raggr-frontend/ # React UI
|
||||||
|
├── migrations/ # Database migrations
|
||||||
|
├── docs/ # This documentation
|
||||||
|
├── docker-compose.yml # Production Docker setup
|
||||||
|
└── docker-compose.dev.yml # Development Docker setup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### RAG (Retrieval-Augmented Generation)
|
||||||
|
|
||||||
|
SimbaRAG uses RAG to answer questions about Simba:
|
||||||
|
|
||||||
|
1. Documents are fetched from Paperless-NGX
|
||||||
|
2. Documents are chunked and embedded using OpenAI
|
||||||
|
3. Embeddings are stored in ChromaDB
|
||||||
|
4. User queries are embedded and matched against the store
|
||||||
|
5. Relevant chunks are passed to the LLM for context
|
||||||
|
6. LLM generates an answer using retrieved context
|
||||||
|
|
||||||
|
### LangChain Agent
|
||||||
|
|
||||||
|
The conversational agent has two tools:
|
||||||
|
|
||||||
|
- **simba_search**: Queries the vector store for Simba's documents
|
||||||
|
- **web_search**: Searches the web via Tavily API
|
||||||
|
|
||||||
|
The agent automatically selects tools based on the query.
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. User initiates OIDC login via Authelia
|
||||||
|
2. Authelia authenticates against LLDAP
|
||||||
|
3. Backend receives OIDC tokens and issues JWT
|
||||||
|
4. Frontend stores JWT in localStorage
|
||||||
|
5. Subsequent requests use JWT for authorization
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Key environment variables (see `.env.example` for complete list):
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection |
|
||||||
|
| `CHROMADB_PATH` | Vector store location |
|
||||||
|
| `OLLAMA_URL` | Local LLM server |
|
||||||
|
| `OPENAI_API_KEY` | OpenAI for embeddings/fallback |
|
||||||
|
| `PAPERLESS_TOKEN` | Document source API |
|
||||||
|
| `OIDC_*` | Authentication configuration |
|
||||||
|
| `TAVILY_KEY` | Web search API |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `GET /api/user/oidc/login` - Start OIDC flow
|
||||||
|
- `GET /api/user/oidc/callback` - OIDC callback
|
||||||
|
- `POST /api/user/refresh` - Refresh JWT
|
||||||
|
|
||||||
|
### Conversations
|
||||||
|
- `POST /api/conversation/` - Create conversation
|
||||||
|
- `GET /api/conversation/` - List conversations
|
||||||
|
- `POST /api/conversation/query` - Chat message
|
||||||
|
|
||||||
|
### RAG (Admin Only)
|
||||||
|
- `GET /api/rag/stats` - Vector store stats
|
||||||
|
- `POST /api/rag/index` - Index documents
|
||||||
|
- `POST /api/rag/reindex` - Reindex all
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Port already in use | Check if services are running: `lsof -ti:8080` |
|
||||||
|
| Database connection error | Ensure PostgreSQL is running: `docker compose ps` |
|
||||||
|
| ChromaDB errors | Clear and reindex: `python scripts/manage_vectorstore.py reindex` |
|
||||||
|
| Import errors | Check you're in `services/raggr/` directory |
|
||||||
|
| Frontend not building | `cd raggr-frontend && yarn install && yarn build` |
|
||||||
|
|
||||||
|
See individual guides for detailed troubleshooting.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Read the [Development Guide](development.md)
|
||||||
|
2. Set up your local environment
|
||||||
|
3. Make changes and test locally
|
||||||
|
4. Generate migrations if needed
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [LangChain Documentation](https://python.langchain.com/)
|
||||||
|
- [ChromaDB Documentation](https://docs.trychroma.com/)
|
||||||
|
- [Quart Documentation](https://quart.palletsprojects.com/)
|
||||||
|
- [Tortoise ORM Documentation](https://tortoise.github.io/)
|
||||||
|
- [Authelia Documentation](https://www.authelia.com/)
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
- Check the relevant guide in this documentation
|
||||||
|
- Review troubleshooting sections
|
||||||
|
- Check application logs: `docker compose logs -f`
|
||||||
|
- Inspect database: `docker compose exec postgres psql -U raggr`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Documentation Version**: 1.0
|
||||||
|
**Last Updated**: January 2026
|
||||||
0
docs/ynab_integration/specification.md
Normal file
0
docs/ynab_integration/specification.md
Normal file
81
index.html
Normal file
81
index.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<!doctype html>
|
||||||
|
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="author" content="Paperless-ngx project and contributors">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
|
||||||
|
<title>
|
||||||
|
|
||||||
|
Paperless-ngx sign in
|
||||||
|
|
||||||
|
</title>
|
||||||
|
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/static/base.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="text-center">
|
||||||
|
<div class="position-absolute top-50 start-50 translate-middle">
|
||||||
|
<form class="form-accounts" id="form-account" method="post">
|
||||||
|
<input type="hidden" name="csrfmiddlewaretoken" value="KLQ3mMraTFHfK9sMmc6DJcNIS6YixeHnSJiT3A12LYB49HeEXOpx5RnY9V6uPSrD">
|
||||||
|
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" width='300' class='logo mb-4'>
|
||||||
|
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
|
||||||
|
<g class="text" style="fill:#000">
|
||||||
|
<path d="M1022.3,428.7c-17.8-19.9-42.7-29.8-74.7-29.8c-22.3,0-42.4,5.7-60.5,17.3c-18.1,11.6-32.3,27.5-42.5,47.8 s-15.3,42.9-15.3,67.8c0,24.9,5.1,47.5,15.3,67.8c10.3,20.3,24.4,36.2,42.5,47.8c18.1,11.5,38.3,17.3,60.5,17.3 c32,0,56.9-9.9,74.7-29.8v20.4v0.2h84.5V408.3h-84.5V428.7z M1010.5,575c-10.2,11.7-23.6,17.6-40.2,17.6s-29.9-5.9-40-17.6 s-15.1-26.1-15.1-43.3c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6c16.6,0,30,5.9,40.2,17.6s15.3,26.1,15.3,43.3 S1020.7,563.3,1010.5,575z" transform="translate(0)"/>
|
||||||
|
<path d="M1381,416.1c-18.1-11.5-38.3-17.3-60.5-17.4c-32,0-56.9,9.9-74.7,29.8v-20.4h-84.5v390.7h84.5v-164 c17.8,19.9,42.7,29.8,74.7,29.8c22.3,0,42.4-5.7,60.5-17.3s32.3-27.5,42.5-47.8c10.2-20.3,15.3-42.9,15.3-67.8s-5.1-47.5-15.3-67.8 C1413.2,443.6,1399.1,427.7,1381,416.1z M1337.9,575c-10.1,11.7-23.4,17.6-40,17.6s-29.9-5.9-40-17.6s-15.1-26.1-15.1-43.3 c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6s29.9,5.9,40,17.6s15.1,26.1,15.1,43.3S1347.9,563.3,1337.9,575z" transform="translate(0)"/>
|
||||||
|
<path d="M1672.2,416.8c-20.5-12-43-18-67.6-18c-24.9,0-47.6,5.9-68,17.6c-20.4,11.7-36.5,27.7-48.2,48s-17.6,42.7-17.6,67.3 c0.3,25.2,6.2,47.8,17.8,68c11.5,20.2,28,36,49.3,47.6c21.3,11.5,45.9,17.3,73.8,17.3c48.6,0,86.8-14.7,114.7-44l-52.5-48.9 c-8.6,8.3-17.6,14.6-26.7,19c-9.3,4.3-21.1,6.4-35.3,6.4c-11.6,0-22.5-3.6-32.7-10.9c-10.3-7.3-17.1-16.5-20.7-27.8h180l0.4-11.6 c0-29.6-6-55.7-18-78.2S1692.6,428.8,1672.2,416.8z M1558.3,503.2c2.1-12.1,7.5-21.8,16.2-29.1s18.7-10.9,30-10.9 s21.2,3.6,29.8,10.9c8.6,7.2,13.9,16.9,16,29.1H1558.3z" transform="translate(0)"/>
|
||||||
|
<path d="M1895.3,411.7c-11,5.6-20.3,13.7-28,24.4h-0.1v-28h-84.5v247.3h84.5V536.3c0-22.6,4.7-38.1,14.2-46.5 c9.5-8.5,22.7-12.7,39.6-12.7c6.2,0,13.5,1,21.8,3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9C1917.1,403.3,1906.3,406.1,1895.3,411.7z" transform="translate(0)"/>
|
||||||
|
<rect x="1985" y="277.4" width="84.5" height="377.8" transform="translate(0)"/>
|
||||||
|
<path d="M2313.2,416.8c-20.5-12-43-18-67.6-18c-24.9,0-47.6,5.9-68,17.6s-36.5,27.7-48.2,48c-11.7,20.3-17.6,42.7-17.6,67.3 c0.3,25.2,6.2,47.8,17.8,68c11.5,20.2,28,36,49.3,47.6c21.3,11.5,45.9,17.3,73.8,17.3c48.6,0,86.8-14.7,114.7-44l-52.5-48.9 c-8.6,8.3-17.6,14.6-26.7,19c-9.3,4.3-21.1,6.4-35.3,6.4c-11.6,0-22.5-3.6-32.7-10.9c-10.3-7.3-17.1-16.5-20.7-27.8h180l0.4-11.6 c0-29.6-6-55.7-18-78.2S2333.6,428.8,2313.2,416.8z M2199.3,503.2c2.1-12.1,7.5-21.8,16.2-29.1s18.7-10.9,30-10.9 s21.2,3.6,29.8,10.9c8.6,7.2,13.9,16.9,16,29.1H2199.3z" transform="translate(0)"/>
|
||||||
|
<path d="M2583.6,507.7c-13.8-4.4-30.6-8.1-50.5-11.1c-15.1-2.7-26.1-5.2-32.9-7.6c-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7,6.7-10.9 c4.4-2.2,11.5-3.3,21.3-3.3c11.6,0,24.3,2.4,38.1,7.2c13.9,4.8,26.2,11,36.9,18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8 c-18.7-7.1-39.6-10.7-62.7-10.7c-33.7,0-60.2,7.6-79.3,22.7c-19.1,15.1-28.7,36.1-28.7,63.1c0,19,4.8,33.9,14.4,44.7 c9.6,10.8,21,18.5,34,22.9c13.1,4.5,28.9,8.3,47.6,11.6c14.6,2.7,25.1,5.3,31.6,7.8s9.8,6.5,9.8,11.8c0,10.4-9.7,15.6-29.3,15.6 c-13.7,0-28.5-2.3-44.7-6.9c-16.1-4.6-29.2-11.3-39.3-20.2l-33.3,60c9.2,7.4,24.6,14.7,46.2,22c21.7,7.3,45.2,10.9,70.7,10.9 c34.7,0,62.9-7.4,84.5-22.4c21.7-15,32.5-37.3,32.5-66.9c0-19.3-5-34.2-15.1-44.9S2597.4,512.1,2583.6,507.7z" transform="translate(0)"/>
|
||||||
|
<path d="M2883.4,575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1c-15.1-2.7-26.1-5.2-32.9-7.6 c-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7,6.7-10.9c4.4-2.2,11.5-3.3,21.3-3.3c11.6,0,24.3,2.4,38.1,7.2c13.9,4.8,26.2,11,36.9,18.4 l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8c-18.7-7.1-39.6-10.7-62.7-10.7c-33.7,0-60.2,7.6-79.3,22.7 c-19.1,15.1-28.7,36.1-28.7,63.1c0,19,4.8,33.9,14.4,44.7c9.6,10.8,21,18.5,34,22.9c13.1,4.5,28.9,8.3,47.6,11.6 c14.6,2.7,25.1,5.3,31.6,7.8s9.8,6.5,9.8,11.8c0,10.4-9.7,15.6-29.3,15.6c-13.7,0-28.5-2.3-44.7-6.9c-16.1-4.6-29.2-11.3-39.3-20.2 l-33.3,60c9.2,7.4,24.6,14.7,46.2,22c21.7,7.3,45.2,10.9,70.7,10.9c34.7,0,62.9-7.4,84.5-22.4 C2872.6,627.2,2883.4,604.9,2883.4,575.3z" transform="translate(0)"/>
|
||||||
|
<rect x="2460.7" y="738.7" width="59.6" height="17.2" transform="translate(0)"/>
|
||||||
|
<path d="M2596.5,706.4c-5.7,0-11,1-15.8,3s-9,5-12.5,8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6,2.1-15.3,6.3-20c4.2-4.7,9.5-7.1,15.9-7.1 c7.8,0,13.4,2.3,16.8,6.7c3.4,4.5,5.1,11.3,5.1,20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3 C2615.8,709.8,2607.3,706.4,2596.5,706.4z" transform="translate(0)"/>
|
||||||
|
<path d="M2733.8,717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7,0-16.5,2.1-23.5,6.3s-12.5,10-16.5,17.3 c-4,7.3-6,15.4-6,24.4c0,8.9,2,17.1,6,24.3c4,7.3,9.5,13,16.5,17.2s14.9,6.3,23.5,6.3c5.6,0,11-1,16.2-3.1 c5.1-2.1,9.5-4.8,13.1-8.2v24.4c0,8.5-2.5,14.8-7.6,18.7c-5,3.9-11,5.9-18,5.9c-6.7,0-12.4-1.6-17.3-4.7c-4.8-3.1-7.6-7.7-8.3-13.8 h-19.4c0.6,7.7,2.9,14.2,7.1,19.5s9.6,9.3,16.2,12c6.6,2.7,13.8,4,21.7,4c12.8,0,23.5-3.4,32-10.1c8.6-6.7,12.8-17.1,12.8-31.1 V708.9h-19.2V717.7z M2732.2,770.1c-2.5,4.7-6,8.3-10.4,11.2c-4.4,2.7-9.4,4-14.9,4c-5.7,0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4 c-2.3-4.8-3.5-9.8-3.5-15.2c0-5.5,1.1-10.6,3.5-15.3s5.8-8.5,10.2-11.3s9.5-4.2,15.2-4.2c5.5,0,10.5,1.4,14.9,4s7.9,6.3,10.4,11 s3.8,10,3.8,15.8S2734.7,765.4,2732.2,770.1z" transform="translate(0)"/>
|
||||||
|
<polygon points="2867.9,708.9 2846.5,708.9 2820.9,741.9 2795.5,708.9 2773.1,708.9 2809.1,755 2771.5,802.5 2792.9,802.5 2820.1,767.9 2847.2,802.6 2869.6,802.6 2832,754.4 " transform="translate(0)"/>
|
||||||
|
<path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z" transform="translate(0)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please sign in.
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="form-floating form-stacked-top">
|
||||||
|
<input type="text" name="login" id="inputUsername" placeholder="Username" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
|
||||||
|
<label for="inputUsername">Username</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating form-stacked-bottom">
|
||||||
|
<input type="password" name="password" id="inputPassword" placeholder="Password" class="form-control" required>
|
||||||
|
<label for="inputPassword">Password</label>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid mt-3">
|
||||||
|
<button class="btn btn-lg btn-primary" type="submit">Sign in</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
llm.py
Normal file
46
llm.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
def __init__(self):
|
||||||
|
llama_url = os.getenv("LLAMA_SERVER_URL")
|
||||||
|
if llama_url:
|
||||||
|
self.client = OpenAI(base_url=llama_url, api_key="not-needed")
|
||||||
|
self.model = os.getenv("LLAMA_MODEL_NAME", "llama-3.1-8b-instruct")
|
||||||
|
self.PROVIDER = "llama_server"
|
||||||
|
logging.info("Using llama_server as LLM backend")
|
||||||
|
else:
|
||||||
|
self.client = OpenAI()
|
||||||
|
self.model = "gpt-4o-mini"
|
||||||
|
self.PROVIDER = "openai"
|
||||||
|
logging.info("Using OpenAI as LLM backend")
|
||||||
|
|
||||||
|
def chat(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
system_prompt: str,
|
||||||
|
):
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": system_prompt,
|
||||||
|
},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return response.choices[0].message.content
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
client = LLMClient()
|
||||||
|
print(client.chat(prompt="Hello!", system_prompt="You are a helpful assistant."))
|
||||||
264
main.py
264
main.py
@@ -1,21 +1,23 @@
|
|||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import argparse
|
import time
|
||||||
import chromadb
|
|
||||||
import ollama
|
|
||||||
|
|
||||||
|
|
||||||
from request import PaperlessNGXService
|
|
||||||
from chunker import Chunker
|
|
||||||
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
import chromadb
|
||||||
|
from utils.chunker import Chunker
|
||||||
|
from utils.cleaner import pdf_to_image, summarize_pdf_image
|
||||||
|
from llm import LLMClient
|
||||||
|
from scripts.query import QueryGenerator
|
||||||
|
from utils.request import PaperlessNGXService
|
||||||
|
|
||||||
|
_dotenv_loaded = load_dotenv()
|
||||||
|
|
||||||
client = chromadb.PersistentClient(path=os.getenv("CHROMADB_PATH", ""))
|
client = chromadb.PersistentClient(path=os.getenv("CHROMADB_PATH", ""))
|
||||||
simba_docs = client.get_or_create_collection(name="simba_docs")
|
simba_docs = client.get_or_create_collection(name="simba_docs2")
|
||||||
feline_vet_lookup = client.get_or_create_collection(name="feline_vet_lookup")
|
feline_vet_lookup = client.get_or_create_collection(name="feline_vet_lookup")
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -26,59 +28,251 @@ parser.add_argument("query", type=str, help="questions about simba's health")
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--reindex", action="store_true", help="re-index the simba documents"
|
"--reindex", action="store_true", help="re-index the simba documents"
|
||||||
)
|
)
|
||||||
|
parser.add_argument("--classify", action="store_true", help="test classification")
|
||||||
|
parser.add_argument("--index", help="index a file")
|
||||||
|
|
||||||
|
ppngx = PaperlessNGXService()
|
||||||
|
|
||||||
|
llm_client = LLMClient()
|
||||||
|
|
||||||
|
|
||||||
def chunk_data(texts: list[str], collection):
|
def index_using_pdf_llm(doctypes):
|
||||||
|
logging.info("reindex data...")
|
||||||
|
files = ppngx.get_data()
|
||||||
|
for file in files:
|
||||||
|
document_id: int = file["id"]
|
||||||
|
pdf_path = ppngx.download_pdf_from_id(id=document_id)
|
||||||
|
image_paths = pdf_to_image(filepath=pdf_path)
|
||||||
|
logging.info(f"summarizing {file}")
|
||||||
|
generated_summary = summarize_pdf_image(filepaths=image_paths)
|
||||||
|
file["content"] = generated_summary
|
||||||
|
|
||||||
|
chunk_data(files, simba_docs, doctypes=doctypes)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_data(docs, collection, doctypes):
|
||||||
# Step 2: Create chunks
|
# Step 2: Create chunks
|
||||||
chunker = Chunker(collection)
|
chunker = Chunker(collection)
|
||||||
|
|
||||||
print(f"chunking {len(texts)} documents")
|
logging.info(f"chunking {len(docs)} documents")
|
||||||
for text in texts:
|
texts: list[str] = [doc["content"] for doc in docs]
|
||||||
chunker.chunk_document(document=text)
|
with sqlite3.connect("database/visited.db") as conn:
|
||||||
|
to_insert = []
|
||||||
|
c = conn.cursor()
|
||||||
|
for index, text in enumerate(texts):
|
||||||
|
metadata = {
|
||||||
|
"created_date": date_to_epoch(docs[index]["created_date"]),
|
||||||
|
"filename": docs[index]["original_file_name"],
|
||||||
|
"document_type": doctypes.get(docs[index]["document_type"], ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
if doctypes:
|
||||||
|
metadata["type"] = doctypes.get(docs[index]["document_type"])
|
||||||
|
|
||||||
|
chunker.chunk_document(
|
||||||
|
document=text,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
to_insert.append((docs[index]["id"],))
|
||||||
|
|
||||||
|
c.executemany(
|
||||||
|
"INSERT INTO indexed_documents (paperless_id) values (?)", to_insert
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def consult_oracle(input: str, collection):
|
def chunk_text(texts: list[str], collection):
|
||||||
# Ask
|
chunker = Chunker(collection)
|
||||||
embeddings = Chunker.embedding_fx(input=[input])
|
|
||||||
results = collection.query(query_texts=[input], query_embeddings=embeddings)
|
|
||||||
|
|
||||||
# Generate
|
for index, text in enumerate(texts):
|
||||||
output = ollama.generate(
|
metadata = {}
|
||||||
model="gemma3n:e4b",
|
chunker.chunk_document(
|
||||||
prompt=f"You are a helpful assistant that understandings veterinary terms. Using the following data, help answer the user's query by providing as many details as possible. Using this data: {results}. Respond to this prompt: {input}",
|
document=text,
|
||||||
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
print(output["response"])
|
|
||||||
|
def classify_query(query: str, transcript: str) -> bool:
|
||||||
|
logging.info("Starting query generation")
|
||||||
|
qg_start = time.time()
|
||||||
|
qg = QueryGenerator()
|
||||||
|
query_type = qg.get_query_type(input=query, transcript=transcript)
|
||||||
|
logging.info(query_type)
|
||||||
|
qg_end = time.time()
|
||||||
|
logging.info(f"Query generation took {qg_end - qg_start:.2f} seconds")
|
||||||
|
return query_type == "Simba"
|
||||||
|
|
||||||
|
|
||||||
|
def consult_oracle(
|
||||||
|
input: str,
|
||||||
|
collection,
|
||||||
|
transcript: str = "",
|
||||||
|
):
|
||||||
|
chunker = Chunker(collection)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Ask
|
||||||
|
logging.info("Starting query generation")
|
||||||
|
qg_start = time.time()
|
||||||
|
qg = QueryGenerator()
|
||||||
|
doctype_query = qg.get_doctype_query(input=input)
|
||||||
|
# metadata_filter = qg.get_query(input)
|
||||||
|
metadata_filter = {**doctype_query}
|
||||||
|
logging.info(metadata_filter)
|
||||||
|
qg_end = time.time()
|
||||||
|
logging.info(f"Query generation took {qg_end - qg_start:.2f} seconds")
|
||||||
|
|
||||||
|
logging.info("Starting embedding generation")
|
||||||
|
embedding_start = time.time()
|
||||||
|
embeddings = chunker.embedding_fx(inputs=[input])
|
||||||
|
embedding_end = time.time()
|
||||||
|
logging.info(
|
||||||
|
f"Embedding generation took {embedding_end - embedding_start:.2f} seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.info("Starting collection query")
|
||||||
|
query_start = time.time()
|
||||||
|
results = collection.query(
|
||||||
|
query_texts=[input],
|
||||||
|
query_embeddings=embeddings,
|
||||||
|
where=metadata_filter,
|
||||||
|
)
|
||||||
|
query_end = time.time()
|
||||||
|
logging.info(f"Collection query took {query_end - query_start:.2f} seconds")
|
||||||
|
|
||||||
|
# Generate
|
||||||
|
logging.info("Starting LLM generation")
|
||||||
|
llm_start = time.time()
|
||||||
|
system_prompt = "You are a helpful assistant that understands veterinary terms."
|
||||||
|
transcript_prompt = f"Here is the message transcript thus far {transcript}."
|
||||||
|
prompt = f"""Using the following data, help answer the user's query by providing as many details as possible.
|
||||||
|
Using this data: {results}. {transcript_prompt if len(transcript) > 0 else ""}
|
||||||
|
Respond to this prompt: {input}"""
|
||||||
|
output = llm_client.chat(prompt=prompt, system_prompt=system_prompt)
|
||||||
|
llm_end = time.time()
|
||||||
|
logging.info(f"LLM generation took {llm_end - llm_start:.2f} seconds")
|
||||||
|
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
logging.info(f"Total consult_oracle execution took {total_time:.2f} seconds")
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def llm_chat(input: str, transcript: str = "") -> str:
|
||||||
|
system_prompt = "You are a helpful assistant that understands veterinary terms."
|
||||||
|
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.
|
||||||
|
{transcript_prompt if len(transcript) > 0 else ""}
|
||||||
|
Respond to this prompt: {input}"""
|
||||||
|
output = llm_client.chat(prompt=prompt, system_prompt=system_prompt)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
def paperless_workflow(input):
|
def paperless_workflow(input):
|
||||||
# Step 1: Get the text
|
# Step 1: Get the text
|
||||||
ppngx = PaperlessNGXService()
|
ppngx = PaperlessNGXService()
|
||||||
docs = ppngx.get_data()
|
docs = ppngx.get_data()
|
||||||
texts = [doc["content"] for doc in docs]
|
|
||||||
|
|
||||||
chunk_data(texts, collection=simba_docs)
|
chunk_data(docs, collection=simba_docs)
|
||||||
consult_oracle(input, simba_docs)
|
consult_oracle(input, simba_docs)
|
||||||
|
|
||||||
|
|
||||||
|
def consult_simba_oracle(input: str, transcript: str = ""):
|
||||||
|
is_simba_related = classify_query(query=input, transcript=transcript)
|
||||||
|
|
||||||
|
if is_simba_related:
|
||||||
|
logging.info("Query is related to simba")
|
||||||
|
return consult_oracle(
|
||||||
|
input=input,
|
||||||
|
collection=simba_docs,
|
||||||
|
transcript=transcript,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.info("Query is NOT related to simba")
|
||||||
|
|
||||||
|
return llm_chat(input=input, transcript=transcript)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_indexed_files(docs):
|
||||||
|
with sqlite3.connect("database/visited.db") as conn:
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS indexed_documents (id INTEGER PRIMARY KEY AUTOINCREMENT, paperless_id INTEGER)"
|
||||||
|
)
|
||||||
|
c.execute("SELECT paperless_id FROM indexed_documents")
|
||||||
|
rows = c.fetchall()
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
visited = {row[0] for row in rows}
|
||||||
|
return [doc for doc in docs if doc["id"] not in visited]
|
||||||
|
|
||||||
|
|
||||||
|
def reindex():
|
||||||
|
with sqlite3.connect("database/visited.db") as conn:
|
||||||
|
c = conn.cursor()
|
||||||
|
# Ensure the table exists before trying to delete from it
|
||||||
|
c.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS indexed_documents (id INTEGER PRIMARY KEY AUTOINCREMENT, paperless_id INTEGER)"
|
||||||
|
)
|
||||||
|
c.execute("DELETE FROM indexed_documents")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Delete all documents from the collection
|
||||||
|
all_docs = simba_docs.get()
|
||||||
|
if all_docs["ids"]:
|
||||||
|
simba_docs.delete(ids=all_docs["ids"])
|
||||||
|
|
||||||
|
logging.info("Fetching documents from Paperless-NGX")
|
||||||
|
ppngx = PaperlessNGXService()
|
||||||
|
docs = ppngx.get_data()
|
||||||
|
docs = filter_indexed_files(docs)
|
||||||
|
logging.info(f"Fetched {len(docs)} documents")
|
||||||
|
|
||||||
|
# Delete all chromadb data
|
||||||
|
ids = simba_docs.get(ids=None, limit=None, offset=0)
|
||||||
|
all_ids = ids["ids"]
|
||||||
|
if len(all_ids) > 0:
|
||||||
|
simba_docs.delete(ids=all_ids)
|
||||||
|
|
||||||
|
# Chunk documents
|
||||||
|
logging.info("Chunking documents now ...")
|
||||||
|
doctype_lookup = ppngx.get_doctypes()
|
||||||
|
chunk_data(docs, collection=simba_docs, doctypes=doctype_lookup)
|
||||||
|
logging.info("Done chunking documents")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.reindex:
|
if args.reindex:
|
||||||
logging.info(msg="Fetching documents from Paperless-NGX")
|
reindex()
|
||||||
ppngx = PaperlessNGXService()
|
|
||||||
docs = ppngx.get_data()
|
|
||||||
texts = [doc["content"] for doc in docs]
|
|
||||||
logging.info(msg=f"Fetched {len(texts)} documents")
|
|
||||||
|
|
||||||
logging.info(msg="Chunking documents now ...")
|
if args.classify:
|
||||||
chunk_data(texts, collection=simba_docs)
|
consult_simba_oracle(input="yohohoho testing")
|
||||||
logging.info(msg="Done chunking documents")
|
consult_simba_oracle(input="write an email")
|
||||||
|
consult_simba_oracle(input="how much does simba weigh")
|
||||||
|
|
||||||
if args.query:
|
if args.query:
|
||||||
logging.info("Consulting oracle ...")
|
logging.info("Consulting oracle ...")
|
||||||
|
print(
|
||||||
consult_oracle(
|
consult_oracle(
|
||||||
input=args.query,
|
input=args.query,
|
||||||
collection=simba_docs,
|
collection=simba_docs,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print("please provide a query")
|
logging.info("please provide a query")
|
||||||
|
|||||||
72
migrations/models/1_20260131214411_None.py
Normal file
72
migrations/models/1_20260131214411_None.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
CREATE TABLE IF NOT EXISTS "users" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"username" VARCHAR(255) NOT NULL,
|
||||||
|
"password" BYTEA,
|
||||||
|
"email" VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
"oidc_subject" VARCHAR(255) UNIQUE,
|
||||||
|
"auth_provider" VARCHAR(50) NOT NULL DEFAULT 'local',
|
||||||
|
"ldap_groups" JSONB NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_users_oidc_su_5aec5a" ON "users" ("oidc_subject");
|
||||||
|
CREATE TABLE IF NOT EXISTS "conversations" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"name" VARCHAR(255) NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"user_id" UUID REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "conversation_messages" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"speaker" VARCHAR(10) NOT NULL,
|
||||||
|
"conversation_id" UUID NOT NULL REFERENCES "conversations" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
COMMENT ON COLUMN "conversation_messages"."speaker" IS 'USER: user\nSIMBA: simba';
|
||||||
|
CREATE TABLE IF NOT EXISTS "aerich" (
|
||||||
|
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||||
|
"version" VARCHAR(255) NOT NULL,
|
||||||
|
"app" VARCHAR(100) NOT NULL,
|
||||||
|
"content" JSONB NOT NULL
|
||||||
|
);"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"eJztmm1v4jgQx78Kyquu1KtatnRX1emkQOkttwuceNinXhWZ2ICviZ1NnG1R1e9+tkmIkz"
|
||||||
|
"gUKFDY401bxh5s/zzO/Mfpo+FSiJzgpEbJT+QHgGFKjMvSo0GAi/gf2vbjkgE8L2kVBgYG"
|
||||||
|
"jnSwlZ6yBQwC5gOb8cYhcALETRAFto+9aDASOo4wUpt3xGSUmEKCf4TIYnSE2Bj5vOHmlp"
|
||||||
|
"sxgegBBfFH784aYuTA1LwxFGNLu8UmnrT1+42ra9lTDDewbOqELkl6exM2pmTWPQwxPBE+"
|
||||||
|
"om2ECPIBQ1BZhphltOzYNJ0xNzA/RLOpwsQA0RCEjoBh/D4MiS0YlORI4sf5H8YSeDhqgR"
|
||||||
|
"YTJlg8Pk1XlaxZWg0xVO2D2Tl6e/FGrpIGbOTLRknEeJKOgIGpq+SagJS/cyhrY+DrUcb9"
|
||||||
|
"MzD5RFfBGBsSjkkMxSBjQKtRM1zwYDmIjNiYfyxXKnMwfjY7kiTvJVFSHtfTqG9FTeVpm0"
|
||||||
|
"CaILR9JJZsAZYHecVbGHaRHmbaM4MURq4n8R87CpivAbaJM4kOwRy+vUaz3u2Zzb/FStwg"
|
||||||
|
"+OFIRGavLlrK0jrJWI8uMlsx+5LSl0bvQ0l8LH1vt+rZ2J/16303xJxAyKhF6L0FoHJeY2"
|
||||||
|
"sMJrWxoQdX3Ni052FjX3Vjo8kr+xog31ougyguL0gj0dy2uImrJw2Reod32pwhYOThXVMf"
|
||||||
|
"4RH5iCYSYYPPAxBblywi0dGPvmZXoSXWZBY+uJ+pETUo+Or4mhCbZk+zWzOv6oZkOAD23T"
|
||||||
|
"3woVUA00VBAEYoyAOtRp7XHzvImUkzPUtVwDWn37ibT5UitpIVLVOFUYpevsktu1kLIHzd"
|
||||||
|
"MBpbjDSHzjMqWIG4mBi21I08iOK9FsUMPWhSfo9b9Sjj/vsiiuel8vrXXiqLx9L3qGl+fZ"
|
||||||
|
"PK5J/arT/j7opUrn1qVw8K+VcUUnmFHHgI3OnEgCgg6yR0c1IgtbuK+ysfHaPfrXcuSyKj"
|
||||||
|
"/0O6jWbVvCwF2B0AY7EtTlWZZ6cLFJlnp4U1pmjKHCA10Sz3mNe4rvOZv6cS1s5ceL1Qym"
|
||||||
|
"bvz3aW4rOaVhMuy2rbTSo5WTNopFtcSxRrNXG0D9ps/7WZ2MdlLy1Vn33RaFu4uPRAENxT"
|
||||||
|
"XxOZVUyAP9HDVL0yMAcTNq1/drWk18GrCr2qyi2OrNpomZ1veskb91fjtvqtVzczdJELsL"
|
||||||
|
"NMlM4c1hOiz5/4dQbo2eliomee6snJHoqhbQXh4F9kayqHYpJZv5WAZoN0uzw3cuC5lh9b"
|
||||||
|
"nk9/Ylgk2vVAc47be4oaDrWB84I0lOZaWSRMK8VRWskFqQOBZ418GnqaO7y/uu2WHmnGLQ"
|
||||||
|
"O0T/gqbyC22XHJwQG73Rjem9vNpHix8vkXCdk7g8wzVXzB4SLhf3KRcHjV9kts7OwmP1cQ"
|
||||||
|
"PvcaJPd/Jet5F7LLYnS770BM5GN7bGhq56jleF71DJI+O1M+N0jBdby2ehaYM8EQ7fyrim"
|
||||||
|
"j5Juq38tn5u/P3by/O3/MuciYzy7s5D4NGq/dMtSwOgvaKq1jrKS6HWjmRzvxoLCOYp933"
|
||||||
|
"E+BGajk+IkNEk96LJbLi8lryeGO3DmuTx0tk2/Wnl6f/AHvgrXs="
|
||||||
|
)
|
||||||
42
migrations/models/2_20260228125713_add_whatsapp_number.py
Normal file
42
migrations/models/2_20260228125713_add_whatsapp_number.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "users" ADD "whatsapp_number" VARCHAR(20) UNIQUE;"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
DROP INDEX IF EXISTS "uid_users_whatsap_e6b586";
|
||||||
|
ALTER TABLE "users" DROP COLUMN "whatsapp_number";"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"eJztmm1v4jgQx78Kyquu1KtatnRX1emkQOkttwuceNinXhWZxECuiZ2NnaWo6nc/2yTESR"
|
||||||
|
"wgFCjs8aYtYw+2fx5n/p70SXOxBR1yVsPoJ/QJoDZG2nXpSUPAhewPZftpSQOeF7dyAwUD"
|
||||||
|
"RziYUk/RAgaE+sCkrHEIHAKZyYLE9G0vHAwFjsON2GQdbTSKTQGyfwTQoHgE6Rj6rOHunp"
|
||||||
|
"ltZMFHSKKP3oMxtKFjJeZtW3xsYTfo1BO2fr9xcyt68uEGhomdwEVxb29KxxjNuweBbZ1x"
|
||||||
|
"H942ggj6gEJLWgafZbjsyDSbMTNQP4DzqVqxwYJDEDgchvb7MEAmZ1ASI/Efl39oBfAw1B"
|
||||||
|
"ytjShn8fQ8W1W8ZmHV+FC1D3rn5O3VG7FKTOjIF42CiPYsHAEFM1fBNQYpfmdQ1sbAV6OM"
|
||||||
|
"+qdgsomugzEyxBzjGIpARoDWo6a54NFwIBrRMftYrlQWYPysdwRJ1kugxCyuZ1HfCpvKsz"
|
||||||
|
"aONEZo+pAv2QA0C/KGtVDbhWqYSc8UUit0PYv+2FPAbA1WGznT8BAs4NtrNOvdnt78m6/E"
|
||||||
|
"JeSHIxDpvTpvKQvrNGU9uUptxfxLSl8avQ8l/rH0vd2qp2N/3q/3XeNzAgHFBsITA1jSeY"
|
||||||
|
"2sEZjExgaetebGJj2PG/uqGxtOXtpXAn2jWAaRXF6QRsK57XAT108aPPUOH5Q5g8PIwrvF"
|
||||||
|
"PrRH6COcCoQNNg+ATFWyCEVHP/yafYUWW+NZ+GAyVyNyULDVsTVBOsueerem39Q1wXAAzI"
|
||||||
|
"cJ8C0jB6YLCQEjSLJAq6Hn7ccOdObSTM1SFnDN2Tfu51Mlj61ghctYYpSgl21yy27aAhBb"
|
||||||
|
"txWOzUdaQGeJCpYgriaGDXkjj6L4oEUxhY+KlN9jVjXKqP+hiOJFqbz+tZfI4pH0PWnqX9"
|
||||||
|
"8kMvmnduvPqLsklWuf2tWjQv4VhVRWIRMPggeVGOAXyDoK3IwUSOyu5P7KR0frd+ud6xLP"
|
||||||
|
"6P+gbqNZ1a9LxHYHQFttixO3zIvzFS6ZF+e5d0zelDpAcqIp9phXuG7ymX+gEtZMFbxeKG"
|
||||||
|
"XT9bO9pbhU0yrCpai23aaSE3cGhXSL7hL5Wo0f7aM2O3xtxvexaNFS9jkUjbaDwqUHCJlg"
|
||||||
|
"XxGZVRsBf6qGKXulYA6mdHb/2dcrvQpeletVWW4xZNVGS+98U0veqL8ct9VvvbqeogtdYD"
|
||||||
|
"tFonTusJkQXX7iNxmgF+eriZ5FqicjeyZjQAl7pBtMSQ7yZKYapsJ1LazpUN0t1fIqUMv5"
|
||||||
|
"TMsZpNi2TIMEg3+hqbiM5fNM+x0izG08Q9n1aGx4Pv5pW8UCNOO4u8SkOdgEzgsye5JrZZ"
|
||||||
|
"UgreQHaSUTpI4FPGPk48BTlEX/6rZbaqQptxTQPmKrvLNsk56WHJvQ+63hvbvfjmriK19c"
|
||||||
|
"m0mXYVJpin/BsTbzP6nNHN9e/hIbO385krljL3uzlPlXnc28Xtpnfb/b10o69G1zrCnKEW"
|
||||||
|
"HL6aKCBIj77E1FooFy3nAoCxIccyoYwp1/1XuJeLn3W/ni8t3l+7dXl+9ZFzGTueXdgodB"
|
||||||
|
"o9VbUoDgB0FZNczXepLLsfwQS2d2NIoI5ln3wwS4lesxG5FCpEjv+RJZcnkteby1Qs7G5H"
|
||||||
|
"GBbLv59PL8Hy/ZG1k="
|
||||||
|
)
|
||||||
25
mkdocs.yml
Normal file
25
mkdocs.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
site_name: SimbaRAG Documentation
|
||||||
|
site_description: Documentation for SimbaRAG - RAG-powered conversational AI
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
features:
|
||||||
|
- content.code.copy
|
||||||
|
- navigation.sections
|
||||||
|
- navigation.expand
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- admonition
|
||||||
|
- pymdownx.highlight:
|
||||||
|
anchor_linenums: true
|
||||||
|
- pymdownx.superfences
|
||||||
|
- pymdownx.tabbed:
|
||||||
|
alternate_style: true
|
||||||
|
- tables
|
||||||
|
- toc:
|
||||||
|
permalink: true
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- Home: index.md
|
||||||
|
- Architecture:
|
||||||
|
- Authentication: authentication.md
|
||||||
@@ -4,4 +4,42 @@ 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 = []
|
dependencies = [
|
||||||
|
"chromadb>=1.1.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"flask>=3.1.2",
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
"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,<1.0.0",
|
||||||
|
"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",
|
||||||
|
"tavily-python>=0.7.17",
|
||||||
|
"ynab>=1.3.0",
|
||||||
|
"ollama>=0.6.1",
|
||||||
|
"twilio>=9.10.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.aerich]
|
||||||
|
tortoise_orm = "app.TORTOISE_CONFIG"
|
||||||
|
location = "./migrations"
|
||||||
|
src_folder = "./."
|
||||||
|
|||||||
98
query.py
98
query.py
@@ -1,98 +0,0 @@
|
|||||||
import json
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from ollama import chat, ChatResponse
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
# This uses inferred filters — which means using LLM to create the metadata filters
|
|
||||||
|
|
||||||
|
|
||||||
class FilterOperation(BaseModel):
|
|
||||||
op: Literal["$gt", "$gte", "$eq", "$ne", "$lt", "$lte", "$in", "$nin"]
|
|
||||||
value: str | list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class FilterQuery(BaseModel):
|
|
||||||
field_name: Literal["created_date, tags"]
|
|
||||||
op: FilterOperation
|
|
||||||
|
|
||||||
|
|
||||||
class AndQuery(BaseModel):
|
|
||||||
op: Literal["$and", "$or"]
|
|
||||||
subqueries: list[FilterQuery]
|
|
||||||
|
|
||||||
|
|
||||||
class GeneratedQuery(BaseModel):
|
|
||||||
fields: list[str]
|
|
||||||
extracted_metadata_fields: str
|
|
||||||
|
|
||||||
|
|
||||||
PROMPT = """
|
|
||||||
You are an information specialist that processes user queries. The user queries are all about
|
|
||||||
a cat, Simba, and its records. The types of records are listed below. Using the query, extract the
|
|
||||||
type of record the user is trying to query and the date range the user is trying to query.
|
|
||||||
|
|
||||||
|
|
||||||
You have several operators at your disposal:
|
|
||||||
- $gt: greater than
|
|
||||||
- $gte: greater than or equal
|
|
||||||
- $eq: equal
|
|
||||||
- $ne: not equal
|
|
||||||
- $lt: less than
|
|
||||||
- $lte: less than or equal to
|
|
||||||
- $in: in
|
|
||||||
- $nin: not in
|
|
||||||
|
|
||||||
Logical operators:
|
|
||||||
- $and, $or
|
|
||||||
|
|
||||||
### Example 1
|
|
||||||
Query: "Who is Simba's current vet?"
|
|
||||||
Metadata fields: "{"created_date, tags"}"
|
|
||||||
Extracted metadata fields: {"$and": [{"created_date: {"$gt": "2025-01-01"}, "tags": {"$in": ["bill", "medical records", "aftercare"]}}]}
|
|
||||||
|
|
||||||
### Example 2
|
|
||||||
Query: "How many teeth has Simba had removed?"
|
|
||||||
Metadata fields: {"tags"}
|
|
||||||
Extracted metadata fields: {"tags": "medical records"}
|
|
||||||
|
|
||||||
### Example 3
|
|
||||||
Query: "How many times has Simba been to the vet this year?"
|
|
||||||
Metadata fields: {"tags", "created_date"}
|
|
||||||
Extracted metadata fields: {"$and": [{"created_date": {"gt": "2025-01-01"}, "tags": {"$in": ["bill"]}}]}
|
|
||||||
|
|
||||||
document_types:
|
|
||||||
- aftercare
|
|
||||||
- bill
|
|
||||||
- insurance claim
|
|
||||||
- medical records
|
|
||||||
|
|
||||||
Only return the extracted metadata fields. Make sure the extracted metadata fields are valid JSON
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class QueryGenerator:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_query(self, input: str):
|
|
||||||
response: ChatResponse = chat(
|
|
||||||
model="gemma3n:e4b",
|
|
||||||
messages=[
|
|
||||||
{"role": "system", "content": PROMPT},
|
|
||||||
{"role": "user", "content": input},
|
|
||||||
],
|
|
||||||
format=GeneratedQuery.model_json_schema(),
|
|
||||||
)
|
|
||||||
|
|
||||||
print(
|
|
||||||
json.loads(
|
|
||||||
json.loads(response["message"]["content"])["extracted_metadata_fields"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
qg = QueryGenerator()
|
|
||||||
qg.get_query("How old is Simba?")
|
|
||||||
9
raggr-frontend/.dockerignore
Normal file
9
raggr-frontend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.cache
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
17
raggr-frontend/.gitignore
vendored
Normal file
17
raggr-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Local
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
|
*.log*
|
||||||
|
|
||||||
|
# Dist
|
||||||
|
node_modules
|
||||||
|
dist/
|
||||||
|
.yarn
|
||||||
|
|
||||||
|
# Profile
|
||||||
|
.rspack-profile-*/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
1
raggr-frontend/.yarnrc.yml
Normal file
1
raggr-frontend/.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
18
raggr-frontend/Dockerfile.dev
Normal file
18
raggr-frontend/Dockerfile.dev
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json yarn.lock* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
|
# Copy application source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose rsbuild dev server port (default 3000)
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Default command
|
||||||
|
CMD ["sh", "-c", "yarn build && yarn watch:build"]
|
||||||
36
raggr-frontend/README.md
Normal file
36
raggr-frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Rsbuild project
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
Start the dev server, and the app will be available at [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the app for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview the production build locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn more
|
||||||
|
|
||||||
|
To learn more about Rsbuild, check out the following resources:
|
||||||
|
|
||||||
|
- [Rsbuild documentation](https://rsbuild.rs) - explore Rsbuild features and APIs.
|
||||||
|
- [Rsbuild GitHub repository](https://github.com/web-infra-dev/rsbuild) - your feedback and contributions are welcome!
|
||||||
63
raggr-frontend/TOKEN_REFRESH_IMPLEMENTATION.md
Normal file
63
raggr-frontend/TOKEN_REFRESH_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Token Refresh Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The API services now automatically handle token refresh when access tokens expire. This provides a seamless user experience without requiring manual re-authentication.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. **userService.ts**
|
||||||
|
|
||||||
|
The `userService` now includes:
|
||||||
|
|
||||||
|
- **`refreshToken()`**: Automatically gets the refresh token from localStorage, calls the `/api/user/refresh` endpoint, and updates the access token
|
||||||
|
- **`fetchWithAuth()`**: A wrapper around `fetch()` that:
|
||||||
|
1. Automatically adds the Authorization header with the access token
|
||||||
|
2. Detects 401 (Unauthorized) responses
|
||||||
|
3. Automatically refreshes the token using the refresh token
|
||||||
|
4. Retries the original request with the new access token
|
||||||
|
5. Throws an error if refresh fails (e.g., refresh token expired)
|
||||||
|
|
||||||
|
### 2. **conversationService.ts**
|
||||||
|
|
||||||
|
Now uses `userService.fetchWithAuth()` for all API calls:
|
||||||
|
- `sendQuery()` - No longer needs token parameter
|
||||||
|
- `getMessages()` - No longer needs token parameter
|
||||||
|
|
||||||
|
### 3. **Components Updated**
|
||||||
|
|
||||||
|
**ChatScreen.tsx**:
|
||||||
|
- Removed manual token handling
|
||||||
|
- Now simply calls `conversationService.sendQuery(query)` and `conversationService.getMessages()`
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
✅ **Automatic token refresh** - Users stay logged in longer
|
||||||
|
✅ **Transparent retry logic** - Failed requests due to expired tokens are automatically retried
|
||||||
|
✅ **Cleaner code** - Components don't need to manage tokens
|
||||||
|
✅ **Better UX** - No interruptions when access token expires
|
||||||
|
✅ **Centralized auth logic** - All auth handling in one place
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If refresh token is missing or invalid, the error is thrown
|
||||||
|
- Components can catch these errors and redirect to login
|
||||||
|
- LocalStorage is automatically cleared when refresh fails
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Old way (manual token management)
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const result = await conversationService.sendQuery(query, token);
|
||||||
|
|
||||||
|
// New way (automatic token refresh)
|
||||||
|
const result = await conversationService.sendQuery(query);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Token Storage
|
||||||
|
|
||||||
|
- **Access Token**: `localStorage.getItem("access_token")`
|
||||||
|
- **Refresh Token**: `localStorage.getItem("refresh_token")`
|
||||||
|
|
||||||
|
Both are automatically managed by the services.
|
||||||
2677
raggr-frontend/package-lock.json
generated
Normal file
2677
raggr-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
raggr-frontend/package.json
Normal file
42
raggr-frontend/package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "raggr-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rsbuild build",
|
||||||
|
"dev": "rsbuild dev --open",
|
||||||
|
"preview": "rsbuild preview",
|
||||||
|
"watch": "npm-watch build",
|
||||||
|
"watch:build": "rsbuild build --watch"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"marked": "^16.3.0",
|
||||||
|
"npm-watch": "^0.13.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"watch": "^1.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.3.10",
|
||||||
|
"@rsbuild/core": "^1.5.6",
|
||||||
|
"@rsbuild/plugin-react": "^1.4.0",
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"build": {
|
||||||
|
"patterns": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"extensions": "ts,tsx,css,js,jsx",
|
||||||
|
"delay": 1000,
|
||||||
|
"quiet": false,
|
||||||
|
"inherit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
raggr-frontend/postcss.config.mjs
Normal file
5
raggr-frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
10
raggr-frontend/rsbuild.config.ts
Normal file
10
raggr-frontend/rsbuild.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from '@rsbuild/core';
|
||||||
|
import { pluginReact } from '@rsbuild/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [pluginReact()],
|
||||||
|
html: {
|
||||||
|
title: 'Raggr',
|
||||||
|
favicon: './src/assets/favicon.svg',
|
||||||
|
},
|
||||||
|
});
|
||||||
170
raggr-frontend/src/App.css
Normal file
170
raggr-frontend/src/App.css
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,600;0,700;1,600&display=swap');
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-cream: #FBF7F0;
|
||||||
|
--color-cream-dark: #F3EDE2;
|
||||||
|
--color-warm-white: #FFFDF9;
|
||||||
|
--color-amber-glow: #E8943A;
|
||||||
|
--color-amber-soft: #F5C882;
|
||||||
|
--color-amber-pale: #FFF0D6;
|
||||||
|
--color-forest: #2D5A3D;
|
||||||
|
--color-forest-light: #3D763A;
|
||||||
|
--color-forest-pale: #E8F5E4;
|
||||||
|
--color-charcoal: #2C2420;
|
||||||
|
--color-warm-gray: #8A7E74;
|
||||||
|
--color-sand: #D4C5B0;
|
||||||
|
--color-sand-light: #E8DED0;
|
||||||
|
--color-blush: #F2D1B3;
|
||||||
|
--color-sidebar-bg: #2C2420;
|
||||||
|
--color-sidebar-hover: #3D352F;
|
||||||
|
--color-sidebar-active: #4A3F38;
|
||||||
|
--font-display: 'Playfair Display', Georgia, serif;
|
||||||
|
--font-body: 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background-color: var(--color-cream);
|
||||||
|
color: var(--color-charcoal);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-sand);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-warm-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown content styling in answer bubbles */
|
||||||
|
.markdown-content h1,
|
||||||
|
.markdown-content h2,
|
||||||
|
.markdown-content h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.markdown-content h1 { font-size: 1.25rem; }
|
||||||
|
.markdown-content h2 { font-size: 1.1rem; }
|
||||||
|
.markdown-content h3 { font-size: 1rem; }
|
||||||
|
|
||||||
|
.markdown-content p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content code {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.88em;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre {
|
||||||
|
background: var(--color-charcoal);
|
||||||
|
color: #F3EDE2;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a {
|
||||||
|
color: var(--color-forest);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content blockquote {
|
||||||
|
border-left: 3px solid var(--color-amber-glow);
|
||||||
|
padding-left: 1em;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
color: var(--color-warm-gray);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading skeleton animation */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-sand-light) 25%,
|
||||||
|
var(--color-cream) 50%,
|
||||||
|
var(--color-sand-light) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade-in animation for messages */
|
||||||
|
@keyframes fadeSlideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-enter {
|
||||||
|
animation: fadeSlideUp 0.35s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle pulse for loading dots */
|
||||||
|
@keyframes catPulse {
|
||||||
|
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
40% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dot {
|
||||||
|
animation: catPulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
/* Textarea focus glow */
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-amber-soft);
|
||||||
|
}
|
||||||
80
raggr-frontend/src/App.tsx
Normal file
80
raggr-frontend/src/App.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import "./App.css";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
|
import { ChatScreen } from "./components/ChatScreen";
|
||||||
|
import { LoginScreen } from "./components/LoginScreen";
|
||||||
|
import { conversationService } from "./api/conversationService";
|
||||||
|
import catIcon from "./assets/cat.png";
|
||||||
|
|
||||||
|
const AppContainer = () => {
|
||||||
|
const [isAuthenticated, setAuthenticated] = useState<boolean>(false);
|
||||||
|
const [isChecking, setIsChecking] = useState<boolean>(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const accessToken = localStorage.getItem("access_token");
|
||||||
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
|
||||||
|
// No tokens at all, not authenticated
|
||||||
|
if (!accessToken && !refreshToken) {
|
||||||
|
setIsChecking(false);
|
||||||
|
setAuthenticated(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to verify token by making a request
|
||||||
|
try {
|
||||||
|
await conversationService.getAllConversations();
|
||||||
|
// If successful, user is authenticated
|
||||||
|
setAuthenticated(true);
|
||||||
|
} catch (error) {
|
||||||
|
// Token is invalid or expired
|
||||||
|
console.error("Authentication check failed:", error);
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
setAuthenticated(false);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show loading state while checking authentication
|
||||||
|
if (isChecking) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
||||||
|
<img
|
||||||
|
src={catIcon}
|
||||||
|
alt="Simba"
|
||||||
|
className="w-16 h-16 animate-bounce"
|
||||||
|
/>
|
||||||
|
<p className="text-warm-gray font-medium text-lg tracking-wide">
|
||||||
|
waking up simba...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<ChatScreen setAuthenticated={setAuthenticated} />
|
||||||
|
) : (
|
||||||
|
<LoginScreen setAuthenticated={setAuthenticated} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContainer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
178
raggr-frontend/src/api/conversationService.ts
Normal file
178
raggr-frontend/src/api/conversationService.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { userService } from "./userService";
|
||||||
|
|
||||||
|
export type SSEEvent =
|
||||||
|
| { type: "tool_start"; tool: string }
|
||||||
|
| { type: "tool_end"; tool: string }
|
||||||
|
| { type: "response"; message: string }
|
||||||
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
|
export type SSEEventCallback = (event: SSEEvent) => void;
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
speaker: "user" | "simba";
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
messages?: Message[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
user_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryRequest {
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryResponse {
|
||||||
|
response: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateConversationRequest {
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConversationService {
|
||||||
|
private baseUrl = "/api";
|
||||||
|
private conversationBaseUrl = "/api/conversation";
|
||||||
|
|
||||||
|
async sendQuery(
|
||||||
|
query: string,
|
||||||
|
conversation_id: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<QueryResponse> {
|
||||||
|
const response = await userService.fetchWithRefreshToken(
|
||||||
|
`${this.conversationBaseUrl}/query`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ query, conversation_id }),
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to send query");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessages(): Promise<Conversation> {
|
||||||
|
const response = await userService.fetchWithRefreshToken(
|
||||||
|
`${this.baseUrl}/messages`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversation(conversationId: string): Promise<Conversation> {
|
||||||
|
const response = await userService.fetchWithRefreshToken(
|
||||||
|
`${this.conversationBaseUrl}/${conversationId}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConversation(): Promise<Conversation> {
|
||||||
|
const response = await userService.fetchWithRefreshToken(
|
||||||
|
`${this.conversationBaseUrl}/`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to create conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllConversations(): Promise<Conversation[]> {
|
||||||
|
const response = await userService.fetchWithRefreshToken(
|
||||||
|
`${this.conversationBaseUrl}/`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch conversations");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamQuery(
|
||||||
|
query: string,
|
||||||
|
conversation_id: string,
|
||||||
|
onEvent: SSEEventCallback,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await userService.fetchWithRefreshToken(
|
||||||
|
`${this.conversationBaseUrl}/stream-query`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ query, conversation_id }),
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to stream query");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._readSSEStream(response, onEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _readSSEStream(
|
||||||
|
response: Response,
|
||||||
|
onEvent: SSEEventCallback,
|
||||||
|
): Promise<void> {
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split("\n\n");
|
||||||
|
buffer = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const line = part.trim();
|
||||||
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === "[DONE]") return;
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(data) as SSEEvent;
|
||||||
|
onEvent(event);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const conversationService = new ConversationService();
|
||||||
94
raggr-frontend/src/api/oidcService.ts
Normal file
94
raggr-frontend/src/api/oidcService.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* OIDC Authentication Service
|
||||||
|
* Handles OAuth 2.0 Authorization Code flow with PKCE
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface OIDCLoginResponse {
|
||||||
|
auth_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OIDCCallbackResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class OIDCService {
|
||||||
|
private baseUrl = "/api/user/oidc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate OIDC login flow
|
||||||
|
* Returns authorization URL to redirect user to
|
||||||
|
*/
|
||||||
|
async initiateLogin(redirectAfterLogin: string = "/"): Promise<string> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.baseUrl}/login?redirect=${encodeURIComponent(redirectAfterLogin)}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to initiate OIDC login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: OIDCLoginResponse = await response.json();
|
||||||
|
return data.auth_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle OIDC callback
|
||||||
|
* Exchanges authorization code for tokens
|
||||||
|
*/
|
||||||
|
async handleCallback(
|
||||||
|
code: string,
|
||||||
|
state: string
|
||||||
|
): Promise<OIDCCallbackResponse> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.baseUrl}/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("OIDC callback failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract OIDC callback parameters from URL
|
||||||
|
*/
|
||||||
|
getCallbackParamsFromURL(): { code: string; state: string } | null {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get("code");
|
||||||
|
const state = params.get("state");
|
||||||
|
|
||||||
|
if (code && state) {
|
||||||
|
return { code, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear callback parameters from URL without reload
|
||||||
|
*/
|
||||||
|
clearCallbackParams(): void {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete("code");
|
||||||
|
url.searchParams.delete("state");
|
||||||
|
url.searchParams.delete("error");
|
||||||
|
window.history.replaceState({}, "", url.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const oidcService = new OIDCService();
|
||||||
139
raggr-frontend/src/api/userService.ts
Normal file
139
raggr-frontend/src/api/userService.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
interface LoginResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshResponse {
|
||||||
|
access_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserService {
|
||||||
|
private baseUrl = "/api/user";
|
||||||
|
|
||||||
|
async login(username: string, password: string): Promise<LoginResponse> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(): Promise<string> {
|
||||||
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error("No refresh token available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${refreshToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Refresh token is invalid or expired, clear storage
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
throw new Error("Failed to refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: RefreshResponse = await response.json();
|
||||||
|
localStorage.setItem("access_token", data.access_token);
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateToken(): Promise<boolean> {
|
||||||
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchWithAuth(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const accessToken = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
// Add authorization header
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
|
// If unauthorized, try refreshing the token
|
||||||
|
if (response.status === 401) {
|
||||||
|
try {
|
||||||
|
const newAccessToken = await this.refreshToken();
|
||||||
|
|
||||||
|
// Retry the request with new token
|
||||||
|
headers.Authorization = `Bearer ${newAccessToken}`;
|
||||||
|
response = await fetch(url, { ...options, headers });
|
||||||
|
} catch (error) {
|
||||||
|
// Refresh failed, redirect to login or throw error
|
||||||
|
throw new Error("Session expired. Please log in again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchWithRefreshToken(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
|
||||||
|
// Add authorization header
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
...(refreshToken && { Authorization: `Bearer ${refreshToken}` }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
|
// If unauthorized, try refreshing the token
|
||||||
|
if (response.status === 401) {
|
||||||
|
try {
|
||||||
|
const newAccessToken = await this.refreshToken();
|
||||||
|
|
||||||
|
// Retry the request with new token
|
||||||
|
headers.Authorization = `Bearer ${newAccessToken}`;
|
||||||
|
response = await fetch(url, { ...options, headers });
|
||||||
|
} catch (error) {
|
||||||
|
// Refresh failed, redirect to login or throw error
|
||||||
|
throw new Error("Session expired. Please log in again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userService = new UserService();
|
||||||
BIN
raggr-frontend/src/assets/cat.png
Normal file
BIN
raggr-frontend/src/assets/cat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
3
raggr-frontend/src/assets/favicon.svg
Normal file
3
raggr-frontend/src/assets/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<text y="80" font-size="80" font-family="system-ui, -apple-system, sans-serif">🐱</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 163 B |
31
raggr-frontend/src/components/AnswerBubble.tsx
Normal file
31
raggr-frontend/src/components/AnswerBubble.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
type AnswerBubbleProps = {
|
||||||
|
text: string;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-orange-100 p-3 sm:p-4 w-2/3">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col w-full animate-pulse gap-2">
|
||||||
|
<div className="flex flex-row gap-2 w-full">
|
||||||
|
<div className="bg-gray-400 w-1/2 p-3 rounded-lg" />
|
||||||
|
<div className="bg-gray-400 w-1/2 p-3 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 w-full">
|
||||||
|
<div className="bg-gray-400 w-1/3 p-3 rounded-lg" />
|
||||||
|
<div className="bg-gray-400 w-2/3 p-3 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className=" flex flex-col break-words overflow-wrap-anywhere text-sm sm:text-base [&>*]:break-words">
|
||||||
|
<ReactMarkdown>
|
||||||
|
{"🐈: " + text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
405
raggr-frontend/src/components/ChatScreen.tsx
Normal file
405
raggr-frontend/src/components/ChatScreen.tsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { conversationService } from "../api/conversationService";
|
||||||
|
import { QuestionBubble } from "./QuestionBubble";
|
||||||
|
import { AnswerBubble } from "./AnswerBubble";
|
||||||
|
import { ToolBubble } from "./ToolBubble";
|
||||||
|
import { MessageInput } from "./MessageInput";
|
||||||
|
import { ConversationList } from "./ConversationList";
|
||||||
|
import catIcon from "../assets/cat.png";
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
text: string;
|
||||||
|
speaker: "simba" | "user" | "tool";
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuestionAnswer = {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Conversation = {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatScreenProps = {
|
||||||
|
setAuthenticated: (isAuth: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOL_MESSAGES: Record<string, string> = {
|
||||||
|
simba_search: "🔍 Searching Simba's records...",
|
||||||
|
web_search: "🌐 Searching the web...",
|
||||||
|
get_current_date: "📅 Checking today's date...",
|
||||||
|
ynab_budget_summary: "💰 Checking budget summary...",
|
||||||
|
ynab_search_transactions: "💳 Looking up transactions...",
|
||||||
|
ynab_category_spending: "📊 Analyzing category spending...",
|
||||||
|
ynab_insights: "📈 Generating budget insights...",
|
||||||
|
obsidian_search_notes: "📝 Searching notes...",
|
||||||
|
obsidian_read_note: "📖 Reading note...",
|
||||||
|
obsidian_create_note: "✏️ Saving note...",
|
||||||
|
obsidian_create_task: "✅ Creating task...",
|
||||||
|
journal_get_today: "📔 Reading today's journal...",
|
||||||
|
journal_get_tasks: "📋 Getting tasks...",
|
||||||
|
journal_add_task: "➕ Adding task...",
|
||||||
|
journal_complete_task: "✔️ Completing task...",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||||
|
const [query, setQuery] = useState<string>("");
|
||||||
|
const [answer, setAnswer] = useState<string>("");
|
||||||
|
const [simbaMode, setSimbaMode] = useState<boolean>(false);
|
||||||
|
const [questionsAnswers, setQuestionsAnswers] = useState<QuestionAnswer[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([
|
||||||
|
{ title: "simba meow meow", id: "uuid" },
|
||||||
|
]);
|
||||||
|
const [showConversations, setShowConversations] = useState<boolean>(false);
|
||||||
|
const [selectedConversation, setSelectedConversation] =
|
||||||
|
useState<Conversation | null>(null);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isMountedRef = useRef<boolean>(true);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup effect to handle component unmounting
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
// Abort any pending requests when component unmounts
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectConversation = (conversation: Conversation) => {
|
||||||
|
setShowConversations(false);
|
||||||
|
setSelectedConversation(conversation);
|
||||||
|
const loadMessages = async () => {
|
||||||
|
try {
|
||||||
|
const fetchedConversation = await conversationService.getConversation(
|
||||||
|
conversation.id,
|
||||||
|
);
|
||||||
|
setMessages(
|
||||||
|
fetchedConversation.messages.map((message) => ({
|
||||||
|
text: message.text,
|
||||||
|
speaker: message.speaker,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load messages:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadMessages();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadConversations = async () => {
|
||||||
|
try {
|
||||||
|
const fetchedConversations =
|
||||||
|
await conversationService.getAllConversations();
|
||||||
|
const parsedConversations = fetchedConversations.map((conversation) => ({
|
||||||
|
id: conversation.id,
|
||||||
|
title: conversation.name,
|
||||||
|
}));
|
||||||
|
setConversations(parsedConversations);
|
||||||
|
setSelectedConversation(parsedConversations[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load messages:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNewConversation = async () => {
|
||||||
|
const newConversation = await conversationService.createConversation();
|
||||||
|
await loadConversations();
|
||||||
|
setSelectedConversation({
|
||||||
|
title: newConversation.name,
|
||||||
|
id: newConversation.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConversations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMessages = async () => {
|
||||||
|
if (selectedConversation == null) return;
|
||||||
|
try {
|
||||||
|
const conversation = await conversationService.getConversation(
|
||||||
|
selectedConversation.id,
|
||||||
|
);
|
||||||
|
// Update the conversation title in case it changed
|
||||||
|
setSelectedConversation({
|
||||||
|
id: conversation.id,
|
||||||
|
title: conversation.name,
|
||||||
|
});
|
||||||
|
setMessages(
|
||||||
|
conversation.messages.map((message) => ({
|
||||||
|
text: message.text,
|
||||||
|
speaker: message.speaker,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load messages:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadMessages();
|
||||||
|
}, [selectedConversation?.id]);
|
||||||
|
|
||||||
|
const handleQuestionSubmit = async () => {
|
||||||
|
if (!query.trim() || isLoading) return; // Don't submit empty messages or while loading
|
||||||
|
|
||||||
|
const currMessages = messages.concat([{ text: query, speaker: "user" }]);
|
||||||
|
setMessages(currMessages);
|
||||||
|
setQuery(""); // Clear input immediately after submission
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
if (simbaMode) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * simbaAnswers.length);
|
||||||
|
const randomElement = simbaAnswers[randomIndex];
|
||||||
|
setAnswer(randomElement);
|
||||||
|
setQuestionsAnswers(
|
||||||
|
questionsAnswers.concat([
|
||||||
|
{
|
||||||
|
question: query,
|
||||||
|
answer: randomElement,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new AbortController for this request
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await conversationService.streamQuery(
|
||||||
|
query,
|
||||||
|
selectedConversation.id,
|
||||||
|
(event) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
if (event.type === "tool_start") {
|
||||||
|
const friendly =
|
||||||
|
TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`;
|
||||||
|
setMessages((prev) => prev.concat([{ text: friendly, speaker: "tool" }]));
|
||||||
|
} else if (event.type === "response") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.concat([{ text: event.message, speaker: "simba" }]),
|
||||||
|
);
|
||||||
|
} else if (event.type === "error") {
|
||||||
|
console.error("Stream error:", event.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore abort errors (these are intentional cancellations)
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
console.log("Request was aborted");
|
||||||
|
} else {
|
||||||
|
console.error("Failed to send query:", error);
|
||||||
|
// If session expired, redirect to login
|
||||||
|
if (error instanceof Error && error.message.includes("Session expired")) {
|
||||||
|
setAuthenticated(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Only update loading state if component is still mounted
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
// Clear the abort controller reference
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQueryChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Submit on Enter, but allow Shift+Enter for new line
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleQuestionSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
setAuthenticated(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-row bg-cream">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`hidden md:flex md:flex-col bg-sidebar-bg transition-all duration-300 ease-in-out ${
|
||||||
|
sidebarCollapsed ? "w-[68px]" : "w-72"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!sidebarCollapsed ? (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Sidebar header */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-5 border-b border-white/10">
|
||||||
|
<img
|
||||||
|
src={catIcon}
|
||||||
|
alt="Simba"
|
||||||
|
className="w-9 h-9 cursor-pointer hover:scale-110 transition-transform duration-200 flex-shrink-0"
|
||||||
|
onClick={() => setSidebarCollapsed(true)}
|
||||||
|
/>
|
||||||
|
<h2 className="font-[family-name:var(--font-display)] text-xl font-bold text-cream tracking-tight">
|
||||||
|
asksimba
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversations */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 py-3">
|
||||||
|
<ConversationList
|
||||||
|
conversations={conversations}
|
||||||
|
onCreateNewConversation={handleCreateNewConversation}
|
||||||
|
onSelectConversation={handleSelectConversation}
|
||||||
|
selectedId={selectedConversation?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<div className="px-3 pb-4 pt-2 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
className="w-full py-2.5 px-3 text-sm text-cream/60 hover:text-cream hover:bg-white/5
|
||||||
|
rounded-lg transition-all duration-200 cursor-pointer"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center py-5 h-full">
|
||||||
|
<img
|
||||||
|
src={catIcon}
|
||||||
|
alt="Simba"
|
||||||
|
className="w-9 h-9 cursor-pointer hover:scale-110 transition-transform duration-200"
|
||||||
|
onClick={() => setSidebarCollapsed(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main chat area */}
|
||||||
|
<div className="flex-1 flex flex-col h-screen overflow-hidden">
|
||||||
|
{/* Mobile header */}
|
||||||
|
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<img src={catIcon} alt="Simba" className="w-8 h-8" />
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-lg font-bold text-charcoal">
|
||||||
|
asksimba
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-cream-dark text-charcoal
|
||||||
|
hover:bg-sand-light transition-colors cursor-pointer"
|
||||||
|
onClick={() => setShowConversations(!showConversations)}
|
||||||
|
>
|
||||||
|
{showConversations ? "Hide" : "Threads"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-lg text-warm-gray
|
||||||
|
hover:bg-cream-dark transition-colors cursor-pointer"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Conversation title bar */}
|
||||||
|
{selectedConversation && (
|
||||||
|
<div className="bg-warm-white/80 backdrop-blur-sm border-b border-sand-light/50 px-6 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-charcoal truncate max-w-2xl mx-auto">
|
||||||
|
{selectedConversation.title || "Untitled Conversation"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages area */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||||
|
<div className="max-w-2xl mx-auto flex flex-col gap-4">
|
||||||
|
{/* Mobile conversation list */}
|
||||||
|
{showConversations && (
|
||||||
|
<div className="md:hidden mb-2">
|
||||||
|
<ConversationList
|
||||||
|
conversations={conversations}
|
||||||
|
onCreateNewConversation={handleCreateNewConversation}
|
||||||
|
onSelectConversation={handleSelectConversation}
|
||||||
|
selectedId={selectedConversation?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{messages.length === 0 && !isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute -inset-4 bg-amber-soft/20 rounded-full blur-2xl" />
|
||||||
|
<img
|
||||||
|
src={catIcon}
|
||||||
|
alt="Simba"
|
||||||
|
className="relative w-16 h-16 opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-warm-gray text-sm">
|
||||||
|
Ask Simba anything
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, index) => {
|
||||||
|
if (msg.speaker === "tool")
|
||||||
|
return <ToolBubble key={index} text={msg.text} />;
|
||||||
|
if (msg.speaker === "simba")
|
||||||
|
return <AnswerBubble key={index} text={msg.text} />;
|
||||||
|
return <QuestionBubble key={index} text={msg.text} />;
|
||||||
|
})}
|
||||||
|
{isLoading && <AnswerBubble text="" loading={true} />}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<footer className="border-t border-sand-light/50 bg-warm-white/60 backdrop-blur-sm">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-4">
|
||||||
|
<MessageInput
|
||||||
|
query={query}
|
||||||
|
handleQueryChange={handleQueryChange}
|
||||||
|
handleKeyDown={handleKeyDown}
|
||||||
|
handleQuestionSubmit={handleQuestionSubmit}
|
||||||
|
setSimbaMode={setSimbaMode}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
69
raggr-frontend/src/components/ConversationList.tsx
Normal file
69
raggr-frontend/src/components/ConversationList.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import { conversationService } from "../api/conversationService";
|
||||||
|
type Conversation = {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConversationProps = {
|
||||||
|
conversations: Conversation[];
|
||||||
|
onSelectConversation: (conversation: Conversation) => void;
|
||||||
|
onCreateNewConversation: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConversationList = ({
|
||||||
|
conversations,
|
||||||
|
onSelectConversation,
|
||||||
|
onCreateNewConversation,
|
||||||
|
}: ConversationProps) => {
|
||||||
|
const [conservations, setConversations] = useState(conversations);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConversations = async () => {
|
||||||
|
try {
|
||||||
|
let fetchedConversations =
|
||||||
|
await conversationService.getAllConversations();
|
||||||
|
|
||||||
|
if (conversations.length == 0) {
|
||||||
|
await conversationService.createConversation();
|
||||||
|
fetchedConversations =
|
||||||
|
await conversationService.getAllConversations();
|
||||||
|
}
|
||||||
|
setConversations(
|
||||||
|
fetchedConversations.map((conversation) => ({
|
||||||
|
id: conversation.id,
|
||||||
|
title: conversation.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load messages:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadConversations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-stone-200 rounded-md p-3 sm:p-4 flex flex-col gap-1">
|
||||||
|
{conservations.map((conversation) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={conversation.id}
|
||||||
|
className="bg-stone-200 hover:bg-stone-300 cursor-pointer rounded-md p-3 min-h-[44px] flex items-center"
|
||||||
|
onClick={() => onSelectConversation(conversation)}
|
||||||
|
>
|
||||||
|
<p className="text-sm sm:text-base truncate w-full">
|
||||||
|
{conversation.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div
|
||||||
|
className="bg-stone-200 hover:bg-stone-300 cursor-pointer rounded-md p-3 min-h-[44px] flex items-center"
|
||||||
|
onClick={() => onCreateNewConversation()}
|
||||||
|
>
|
||||||
|
<p className="text-sm sm:text-base"> + Start a new thread</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
raggr-frontend/src/components/ConversationMenu.tsx
Normal file
24
raggr-frontend/src/components/ConversationMenu.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
type Conversation = {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConversationMenuProps = {
|
||||||
|
conversations: Conversation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConversationMenu = ({ conversations }: ConversationMenuProps) => {
|
||||||
|
return (
|
||||||
|
<div className="absolute bg-white w-md rounded-md shadow-xl m-4 p-4">
|
||||||
|
<p className="py-2 px-4 rounded-md w-full text-xl font-bold">askSimba!</p>
|
||||||
|
{conversations.map((conversation) => (
|
||||||
|
<p
|
||||||
|
key={conversation.id}
|
||||||
|
className="py-2 px-4 rounded-md hover:bg-stone-200 w-full text-xl font-bold cursor-pointer"
|
||||||
|
>
|
||||||
|
{conversation.title}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
154
raggr-frontend/src/components/LoginScreen.tsx
Normal file
154
raggr-frontend/src/components/LoginScreen.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { userService } from "../api/userService";
|
||||||
|
import { oidcService } from "../api/oidcService";
|
||||||
|
import catIcon from "../assets/cat.png";
|
||||||
|
|
||||||
|
type LoginScreenProps = {
|
||||||
|
setAuthenticated: (isAuth: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
const [isChecking, setIsChecking] = useState<boolean>(true);
|
||||||
|
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
// First, check for OIDC callback parameters
|
||||||
|
const callbackParams = oidcService.getCallbackParamsFromURL();
|
||||||
|
|
||||||
|
if (callbackParams) {
|
||||||
|
// Handle OIDC callback
|
||||||
|
try {
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
const result = await oidcService.handleCallback(
|
||||||
|
callbackParams.code,
|
||||||
|
callbackParams.state
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store tokens
|
||||||
|
localStorage.setItem("access_token", result.access_token);
|
||||||
|
localStorage.setItem("refresh_token", result.refresh_token);
|
||||||
|
|
||||||
|
// Clear URL parameters
|
||||||
|
oidcService.clearCallbackParams();
|
||||||
|
|
||||||
|
setAuthenticated(true);
|
||||||
|
setIsChecking(false);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("OIDC callback error:", err);
|
||||||
|
setError("Login failed. Please try again.");
|
||||||
|
oidcService.clearCallbackParams();
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
setIsChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already authenticated
|
||||||
|
const isValid = await userService.validateToken();
|
||||||
|
if (isValid) {
|
||||||
|
setAuthenticated(true);
|
||||||
|
}
|
||||||
|
setIsChecking(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
}, [setAuthenticated]);
|
||||||
|
|
||||||
|
const handleOIDCLogin = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Get authorization URL from backend
|
||||||
|
const authUrl = await oidcService.initiateLogin();
|
||||||
|
|
||||||
|
// Redirect to Authelia
|
||||||
|
window.location.href = authUrl;
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to initiate login. Please try again.");
|
||||||
|
console.error("OIDC login error:", err);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading state while checking authentication or processing callback
|
||||||
|
if (isChecking || isLoggingIn) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
||||||
|
<img
|
||||||
|
src={catIcon}
|
||||||
|
alt="Simba"
|
||||||
|
className="w-16 h-16 animate-bounce"
|
||||||
|
/>
|
||||||
|
<p className="text-warm-gray font-medium text-lg tracking-wide">
|
||||||
|
{isLoggingIn ? "letting you in..." : "checking credentials..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen bg-cream flex items-center justify-center p-4">
|
||||||
|
{/* Decorative background texture */}
|
||||||
|
<div className="fixed inset-0 opacity-[0.03] pointer-events-none"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `radial-gradient(circle at 1px 1px, var(--color-charcoal) 1px, transparent 0)`,
|
||||||
|
backgroundSize: '24px 24px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-sm">
|
||||||
|
{/* Cat icon & branding */}
|
||||||
|
<div className="flex flex-col items-center mb-8">
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<div className="absolute -inset-3 bg-amber-soft/40 rounded-full blur-xl" />
|
||||||
|
<img
|
||||||
|
src={catIcon}
|
||||||
|
alt="Simba"
|
||||||
|
className="relative w-20 h-20 drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-4xl font-bold text-charcoal tracking-tight">
|
||||||
|
asksimba
|
||||||
|
</h1>
|
||||||
|
<p className="text-warm-gray text-sm mt-1.5 tracking-wide">
|
||||||
|
your feline knowledge companion
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login card */}
|
||||||
|
<div className="bg-warm-white rounded-2xl shadow-lg shadow-sand/40 border border-sand-light/60 p-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 text-sm bg-red-50 text-red-700 p-3 rounded-xl border border-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center text-warm-gray text-sm mb-6">
|
||||||
|
Sign in to start chatting with Simba
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="w-full py-3.5 px-4 bg-forest text-white font-semibold rounded-xl
|
||||||
|
hover:bg-forest-light transition-all duration-200
|
||||||
|
active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
shadow-md shadow-forest/20 hover:shadow-lg hover:shadow-forest/30
|
||||||
|
cursor-pointer text-sm tracking-wide"
|
||||||
|
onClick={handleOIDCLogin}
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
>
|
||||||
|
{isLoggingIn ? "Redirecting..." : "Sign in with Authelia"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer paw prints */}
|
||||||
|
<p className="text-center text-sand mt-6 text-xs tracking-widest select-none">
|
||||||
|
~ meow ~
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
56
raggr-frontend/src/components/MessageInput.tsx
Normal file
56
raggr-frontend/src/components/MessageInput.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
|
type MessageInputProps = {
|
||||||
|
handleQueryChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
|
handleKeyDown: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
|
handleQuestionSubmit: () => void;
|
||||||
|
setSimbaMode: (sdf: boolean) => void;
|
||||||
|
query: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageInput = ({
|
||||||
|
query,
|
||||||
|
handleKeyDown,
|
||||||
|
handleQueryChange,
|
||||||
|
handleQuestionSubmit,
|
||||||
|
setSimbaMode,
|
||||||
|
isLoading,
|
||||||
|
}: MessageInputProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 sticky bottom-0 bg-[#3D763A] p-6 rounded-xl">
|
||||||
|
<div className="flex flex-row justify-between grow">
|
||||||
|
<textarea
|
||||||
|
className="p-3 sm:p-4 border border-blue-200 rounded-md grow bg-[#F9F5EB] min-h-[44px] resize-y"
|
||||||
|
onChange={handleQueryChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
value={query}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-between gap-2 grow">
|
||||||
|
<button
|
||||||
|
className={`p-3 sm:p-4 min-h-[44px] border border-blue-400 rounded-md flex-grow text-sm sm:text-base ${
|
||||||
|
isLoading
|
||||||
|
? "bg-gray-400 cursor-not-allowed opacity-50"
|
||||||
|
: "bg-[#EDA541] hover:bg-blue-400 cursor-pointer"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleQuestionSubmit()}
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
11
raggr-frontend/src/components/QuestionBubble.tsx
Normal file
11
raggr-frontend/src/components/QuestionBubble.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
type QuestionBubbleProps = {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuestionBubble = ({ text }: QuestionBubbleProps) => {
|
||||||
|
return (
|
||||||
|
<div className="w-2/3 rounded-md bg-stone-200 p-3 sm:p-4 break-words overflow-wrap-anywhere text-sm sm:text-base ml-auto">
|
||||||
|
🤦: {text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
raggr-frontend/src/components/ToolBubble.tsx
Normal file
5
raggr-frontend/src/components/ToolBubble.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const ToolBubble = ({ text }: { text: string }) => (
|
||||||
|
<div className="text-sm text-gray-500 italic px-3 py-1 self-start">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
56
raggr-frontend/src/contexts/AuthContext.tsx
Normal file
56
raggr-frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { createContext, useContext, useState, ReactNode } from "react";
|
||||||
|
import { userService } from "../api/userService";
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
token: string | null;
|
||||||
|
login: (username: string, password: string) => Promise<any>;
|
||||||
|
logout: () => void;
|
||||||
|
isAuthenticated: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||||
|
const [token, setToken] = useState(localStorage.getItem("access_token"));
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const data = await userService.login(username, password);
|
||||||
|
setToken(data.access_token);
|
||||||
|
localStorage.setItem("access_token", data.access_token);
|
||||||
|
localStorage.setItem("refresh_token", data.refresh_token);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login failed:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setToken(null);
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAuthenticated = () => {
|
||||||
|
return token !== null && token !== undefined && token !== "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ token, login, logout, isAuthenticated }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
11
raggr-frontend/src/env.d.ts
vendored
Normal file
11
raggr-frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="@rsbuild/core/types" />
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports the SVG file as a React component.
|
||||||
|
* @requires [@rsbuild/plugin-svgr](https://npmjs.com/package/@rsbuild/plugin-svgr)
|
||||||
|
*/
|
||||||
|
declare module '*.svg?react' {
|
||||||
|
import type React from 'react';
|
||||||
|
const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||||
|
export default ReactComponent;
|
||||||
|
}
|
||||||
13
raggr-frontend/src/index.tsx
Normal file
13
raggr-frontend/src/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const rootEl = document.getElementById('root');
|
||||||
|
if (rootEl) {
|
||||||
|
const root = ReactDOM.createRoot(rootEl);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
raggr-frontend/src/simba_cute.jpeg
Normal file
BIN
raggr-frontend/src/simba_cute.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
BIN
raggr-frontend/src/simba_troll.jpeg
Normal file
BIN
raggr-frontend/src/simba_troll.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
25
raggr-frontend/tsconfig.json
Normal file
25
raggr-frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "ES2020"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"target": "ES2020",
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
|
||||||
|
/* modules */
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
/* type checking */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
2877
raggr-frontend/yarn.lock
Normal file
2877
raggr-frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
24
request.py
24
request.py
@@ -1,24 +0,0 @@
|
|||||||
import os
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
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/?query=simba"
|
|
||||||
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)
|
|
||||||
return r.json()["results"]
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pp = PaperlessNGXService()
|
|
||||||
pp.get_data()
|
|
||||||
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
146
scripts/add_user.py
Normal file
146
scripts/add_user.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# GENERATED BY CLAUDE
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
from tortoise import Tortoise
|
||||||
|
from blueprints.users.models import User
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Database configuration with environment variable support
|
||||||
|
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite://{DATABASE_PATH}")
|
||||||
|
|
||||||
|
print(DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_user(username: str, email: str, password: str):
|
||||||
|
"""Add a new user to the database"""
|
||||||
|
await Tortoise.init(
|
||||||
|
db_url=DATABASE_URL,
|
||||||
|
modules={
|
||||||
|
"models": [
|
||||||
|
"blueprints.users.models",
|
||||||
|
"blueprints.conversation.models",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if user already exists
|
||||||
|
existing_user = await User.filter(email=email).first()
|
||||||
|
if existing_user:
|
||||||
|
print(f"Error: User with email '{email}' already exists!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
existing_username = await User.filter(username=username).first()
|
||||||
|
if existing_username:
|
||||||
|
print(f"Error: Username '{username}' is already taken!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
print("✓ User created successfully!")
|
||||||
|
print(f" Username: {username}")
|
||||||
|
print(f" Email: {email}")
|
||||||
|
print(f" ID: {user.id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating user: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_users():
|
||||||
|
"""List all users in the database"""
|
||||||
|
await Tortoise.init(
|
||||||
|
db_url=DATABASE_URL,
|
||||||
|
modules={
|
||||||
|
"models": [
|
||||||
|
"blueprints.users.models",
|
||||||
|
"blueprints.conversation.models",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
users = await User.all()
|
||||||
|
if not users:
|
||||||
|
print("No users found in database.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\nFound {len(users)} user(s):")
|
||||||
|
print("-" * 60)
|
||||||
|
for user in users:
|
||||||
|
print(f"Username: {user.username}")
|
||||||
|
print(f"Email: {user.email}")
|
||||||
|
print(f"ID: {user.id}")
|
||||||
|
print(f"Created: {user.created_at}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error listing users: {e}")
|
||||||
|
finally:
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
|
||||||
|
|
||||||
|
def print_usage():
|
||||||
|
"""Print usage instructions"""
|
||||||
|
print("Usage:")
|
||||||
|
print(" python add_user.py add <username> <email> <password>")
|
||||||
|
print(" python add_user.py list")
|
||||||
|
print("\nExamples:")
|
||||||
|
print(" python add_user.py add ryan ryan@example.com mypassword123")
|
||||||
|
print(" python add_user.py list")
|
||||||
|
print("\nEnvironment Variables:")
|
||||||
|
print(" DATABASE_PATH - Path to database file (default: database/raggr.db)")
|
||||||
|
print(" DATABASE_URL - Full database URL (overrides DATABASE_PATH)")
|
||||||
|
print("\n Example with custom database:")
|
||||||
|
print(" DATABASE_PATH=dev.db python add_user.py list")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print_usage()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
command = sys.argv[1].lower()
|
||||||
|
|
||||||
|
if command == "add":
|
||||||
|
if len(sys.argv) != 5:
|
||||||
|
print("Error: Missing arguments for 'add' command")
|
||||||
|
print_usage()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
username = sys.argv[2]
|
||||||
|
email = sys.argv[3]
|
||||||
|
password = sys.argv[4]
|
||||||
|
|
||||||
|
success = await add_user(username, email, password)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
elif command == "list":
|
||||||
|
await list_users()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"Error: Unknown command '{command}'")
|
||||||
|
print_usage()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
118
scripts/index_immich.py
Normal file
118
scripts/index_immich.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent directory to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from utils.image_process import describe_simba_image
|
||||||
|
from utils.request import PaperlessNGXService
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configuration from environment variables
|
||||||
|
IMMICH_URL = os.getenv("IMMICH_URL", "http://localhost:2283")
|
||||||
|
API_KEY = os.getenv("IMMICH_API_KEY")
|
||||||
|
PERSON_NAME = os.getenv("PERSON_NAME", "Simba") # Name of the tagged person/pet
|
||||||
|
DOWNLOAD_DIR = os.getenv("DOWNLOAD_DIR", "./simba_photos")
|
||||||
|
|
||||||
|
# Set up headers
|
||||||
|
headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
VISITED = {}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
conn = sqlite3.connect("./database/visited.db")
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("select immich_id from visited")
|
||||||
|
rows = c.fetchall()
|
||||||
|
for row in rows:
|
||||||
|
VISITED.add(row[0])
|
||||||
|
|
||||||
|
ppngx = PaperlessNGXService()
|
||||||
|
people_url = f"{IMMICH_URL}/api/search/person?name=Simba"
|
||||||
|
people = httpx.get(people_url, headers=headers).json()
|
||||||
|
|
||||||
|
simba_id = people[0]["id"]
|
||||||
|
|
||||||
|
ids = {}
|
||||||
|
|
||||||
|
asset_search = f"{IMMICH_URL}/api/search/smart"
|
||||||
|
request_body = {"query": "orange cat"}
|
||||||
|
results = httpx.post(asset_search, headers=headers, json=request_body)
|
||||||
|
|
||||||
|
assets = results.json()["assets"]
|
||||||
|
for asset in assets["items"]:
|
||||||
|
if asset["type"] == "IMAGE" and asset["id"] not in VISITED:
|
||||||
|
ids[asset["id"]] = asset.get("originalFileName")
|
||||||
|
nextPage = assets.get("nextPage")
|
||||||
|
|
||||||
|
# while nextPage != None:
|
||||||
|
# logging.info(f"next page: {nextPage}")
|
||||||
|
# request_body["page"] = nextPage
|
||||||
|
# results = httpx.post(asset_search, headers=headers, json=request_body)
|
||||||
|
# assets = results.json()["assets"]
|
||||||
|
|
||||||
|
# for asset in assets["items"]:
|
||||||
|
# if asset["type"] == "IMAGE":
|
||||||
|
# ids.add(asset['id'])
|
||||||
|
|
||||||
|
# nextPage = assets.get("nextPage")
|
||||||
|
|
||||||
|
asset_search = f"{IMMICH_URL}/api/search/smart"
|
||||||
|
request_body = {"query": "simba"}
|
||||||
|
results = httpx.post(asset_search, headers=headers, json=request_body)
|
||||||
|
for asset in results.json()["assets"]["items"]:
|
||||||
|
if asset["type"] == "IMAGE":
|
||||||
|
ids[asset["id"]] = asset.get("originalFileName")
|
||||||
|
|
||||||
|
for immich_asset_id, immich_filename in ids.items():
|
||||||
|
try:
|
||||||
|
response = httpx.get(
|
||||||
|
f"{IMMICH_URL}/api/assets/{immich_asset_id}/original", headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
path = os.path.join("/Users/ryanchen/Programs/raggr", immich_filename)
|
||||||
|
file = open(path, "wb+")
|
||||||
|
for chunk in response.iter_bytes(chunk_size=8192):
|
||||||
|
file.write(chunk)
|
||||||
|
|
||||||
|
logging.info("Processing image ...")
|
||||||
|
description = describe_simba_image(path)
|
||||||
|
|
||||||
|
image_description = description.description
|
||||||
|
image_date = description.image_date
|
||||||
|
|
||||||
|
description_filepath = os.path.join(
|
||||||
|
"/Users/ryanchen/Programs/raggr", "SIMBA_DESCRIBE_001.txt"
|
||||||
|
)
|
||||||
|
file = open(description_filepath, "w+")
|
||||||
|
file.write(image_description)
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
file = open(description_filepath, "rb")
|
||||||
|
ppngx.upload_description(
|
||||||
|
description_filepath=description_filepath,
|
||||||
|
file=file,
|
||||||
|
title="SIMBA_DESCRIBE_001.txt",
|
||||||
|
exif_date=image_date,
|
||||||
|
)
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
c.execute("INSERT INTO visited (immich_id) values (?)", (immich_asset_id,))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Processing complete. Deleting file.")
|
||||||
|
os.remove(file.name)
|
||||||
|
except Exception as e:
|
||||||
|
logging.info(f"something went wrong for {immich_filename}")
|
||||||
|
logging.info(e)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
92
scripts/inspect_vector_store.py
Normal file
92
scripts/inspect_vector_store.py
Normal 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()
|
||||||
121
scripts/manage_vectorstore.py
Normal file
121
scripts/manage_vectorstore.py
Normal 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()
|
||||||
244
scripts/query.py
Normal file
244
scripts/query.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import json
|
||||||
|
from typing import Literal
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# This uses inferred filters — which means using LLM to create the metadata filters
|
||||||
|
|
||||||
|
|
||||||
|
class FilterOperation(BaseModel):
|
||||||
|
op: Literal["$gt", "$gte", "$eq", "$ne", "$lt", "$lte", "$in", "$nin"]
|
||||||
|
value: str | list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class FilterQuery(BaseModel):
|
||||||
|
field_name: Literal["created_date, tags"]
|
||||||
|
op: FilterOperation
|
||||||
|
|
||||||
|
|
||||||
|
class AndQuery(BaseModel):
|
||||||
|
op: Literal["$and", "$or"]
|
||||||
|
subqueries: list[FilterQuery]
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratedQuery(BaseModel):
|
||||||
|
fields: list[str]
|
||||||
|
extracted_metadata_fields: str
|
||||||
|
|
||||||
|
|
||||||
|
class Time(BaseModel):
|
||||||
|
time: int
|
||||||
|
|
||||||
|
|
||||||
|
DOCTYPE_OPTIONS = [
|
||||||
|
"Bill",
|
||||||
|
"Image Description",
|
||||||
|
"Insurance",
|
||||||
|
"Medical Record",
|
||||||
|
"Documentation",
|
||||||
|
"Letter",
|
||||||
|
]
|
||||||
|
|
||||||
|
QUERY_TYPE_OPTIONS = [
|
||||||
|
"Simba",
|
||||||
|
"Other",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentType(BaseModel):
|
||||||
|
type: list[str] = Field(description="type of document", enum=DOCTYPE_OPTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
class QueryType(BaseModel):
|
||||||
|
type: str = Field(desciption="type of query", enum=QUERY_TYPE_OPTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT = """
|
||||||
|
You are an information specialist that processes user queries. The current year is 2025. The user queries are all about
|
||||||
|
a cat, Simba, and its records. The types of records are listed below. Using the query, extract the
|
||||||
|
the date range the user is trying to query. You should return it as a JSON. The date tag is created_date. Return the date in epoch time.
|
||||||
|
|
||||||
|
If the created_date cannot be ascertained, set it to epoch time start.
|
||||||
|
|
||||||
|
You have several operators at your disposal:
|
||||||
|
- $gt: greater than
|
||||||
|
- $gte: greater than or equal
|
||||||
|
- $eq: equal
|
||||||
|
- $ne: not equal
|
||||||
|
- $lt: less than
|
||||||
|
- $lte: less than or equal to
|
||||||
|
- $in: in
|
||||||
|
- $nin: not in
|
||||||
|
|
||||||
|
Logical operators:
|
||||||
|
- $and, $or
|
||||||
|
|
||||||
|
### Example 1
|
||||||
|
Query: "Who is Simba's current vet?"
|
||||||
|
Metadata fields: "{"created_date"}"
|
||||||
|
Extracted metadata fields: {"created_date: {"$gt": "2025-01-01"}}
|
||||||
|
|
||||||
|
### Example 2
|
||||||
|
Query: "How many teeth has Simba had removed?"
|
||||||
|
Metadata fields: {}
|
||||||
|
Extracted metadata fields: {}
|
||||||
|
|
||||||
|
### Example 3
|
||||||
|
Query: "How many times has Simba been to the vet this year?"
|
||||||
|
Metadata fields: {"created_date"}
|
||||||
|
Extracted metadata fields: {"created_date": {"gt": "2025-01-01"}}
|
||||||
|
|
||||||
|
document_types:
|
||||||
|
- aftercare
|
||||||
|
- bill
|
||||||
|
- insurance claim
|
||||||
|
- medical records
|
||||||
|
|
||||||
|
Only return the extracted metadata fields. Make sure the extracted metadata fields are valid JSON
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
DOCTYPE_PROMPT = f"""You are an information specialist that processes user queries. A query can have two tags attached from the following options. Based on the query, determine which of the following options is most appropriate: {",".join(DOCTYPE_OPTIONS)}
|
||||||
|
|
||||||
|
### Example 1
|
||||||
|
Query: "Who is Simba's current vet?"
|
||||||
|
Tags: ["Bill", "Medical Record"]
|
||||||
|
|
||||||
|
|
||||||
|
### Example 2
|
||||||
|
Query: "Who does Simba know?"
|
||||||
|
Tags: ["Letter", "Documentation"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
QUERY_TYPE_PROMPT = f"""You are an information specialist that processes user queries.
|
||||||
|
A query can have one tag attached from the following options. Based on the query and the transcript which is listed below, determine
|
||||||
|
which of the following options is most appropriate: {",".join(QUERY_TYPE_OPTIONS)}
|
||||||
|
|
||||||
|
### Example 1
|
||||||
|
Query: "Who is Simba's current vet?"
|
||||||
|
Tags: ["Simba"]
|
||||||
|
|
||||||
|
|
||||||
|
### Example 2
|
||||||
|
Query: "What is the capital of Tokyo?"
|
||||||
|
Tags: ["Other"]
|
||||||
|
|
||||||
|
|
||||||
|
### Example 3
|
||||||
|
Query: "Can you help me write an email?"
|
||||||
|
Tags: ["Other"]
|
||||||
|
|
||||||
|
TRANSCRIPT:
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class QueryGenerator:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def date_to_epoch(self, 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()
|
||||||
|
|
||||||
|
def get_doctype_query(self, input: str):
|
||||||
|
client = OpenAI()
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You are an information specialist that is really good at deciding what tags a query should have",
|
||||||
|
},
|
||||||
|
{"role": "user", "content": DOCTYPE_PROMPT + " " + input},
|
||||||
|
],
|
||||||
|
model="gpt-4o",
|
||||||
|
response_format={
|
||||||
|
"type": "json_schema",
|
||||||
|
"json_schema": {
|
||||||
|
"name": "document_type",
|
||||||
|
"schema": DocumentType.model_json_schema(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response_json_str = response.choices[0].message.content
|
||||||
|
type_data = json.loads(response_json_str)
|
||||||
|
metadata_query = {"document_type": {"$in": type_data["type"]}}
|
||||||
|
return metadata_query
|
||||||
|
|
||||||
|
def get_query_type(self, input: str, transcript: str):
|
||||||
|
client = OpenAI()
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You are an information specialist that is really good at deciding what tags a query should have",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": f"{QUERY_TYPE_PROMPT}\nTRANSCRIPT:\n{transcript}\nQUERY:{input}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model="gpt-4o",
|
||||||
|
response_format={
|
||||||
|
"type": "json_schema",
|
||||||
|
"json_schema": {
|
||||||
|
"name": "query_type",
|
||||||
|
"schema": QueryType.model_json_schema(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response_json_str = response.choices[0].message.content
|
||||||
|
type_data = json.loads(response_json_str)
|
||||||
|
return type_data["type"]
|
||||||
|
|
||||||
|
def get_query(self, input: str):
|
||||||
|
client = OpenAI()
|
||||||
|
response = client.responses.parse(
|
||||||
|
model="gpt-4o",
|
||||||
|
input=[
|
||||||
|
{"role": "system", "content": PROMPT},
|
||||||
|
{"role": "user", "content": input},
|
||||||
|
],
|
||||||
|
text_format=GeneratedQuery,
|
||||||
|
)
|
||||||
|
print(response.output)
|
||||||
|
query = json.loads(response.output_parsed.extracted_metadata_fields)
|
||||||
|
# response: ChatResponse = ollama_client.chat(
|
||||||
|
# model="gemma3n:e4b",
|
||||||
|
# messages=[
|
||||||
|
# {"role": "system", "content": PROMPT},
|
||||||
|
# {"role": "user", "content": input},
|
||||||
|
# ],
|
||||||
|
# format=GeneratedQuery.model_json_schema(),
|
||||||
|
# )
|
||||||
|
|
||||||
|
# query = json.loads(
|
||||||
|
# json.loads(response["message"]["content"])["extracted_metadata_fields"]
|
||||||
|
# )
|
||||||
|
# date_key = list(query["created_date"].keys())[0]
|
||||||
|
# query["created_date"][date_key] = self.date_to_epoch(
|
||||||
|
# query["created_date"][date_key]
|
||||||
|
# )
|
||||||
|
|
||||||
|
# if "$" not in date_key:
|
||||||
|
# query["created_date"]["$" + date_key] = query["created_date"][date_key]
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
qg = QueryGenerator()
|
||||||
|
print(qg.get_doctype_query("How heavy is Simba?"))
|
||||||
39
scripts/test_query.py
Normal file
39
scripts/test_query.py
Normal 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())
|
||||||
79
scripts/user_message_stats.py
Normal file
79
scripts/user_message_stats.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script to show how many messages each user has written
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from tortoise import Tortoise
|
||||||
|
from blueprints.users.models import User
|
||||||
|
from blueprints.conversation.models import Speaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_message_stats():
|
||||||
|
"""Get message count statistics per user"""
|
||||||
|
|
||||||
|
# Initialize database connection
|
||||||
|
database_url = os.getenv("DATABASE_URL", "sqlite://raggr.db")
|
||||||
|
await Tortoise.init(
|
||||||
|
db_url=database_url,
|
||||||
|
modules={
|
||||||
|
"models": [
|
||||||
|
"blueprints.users.models",
|
||||||
|
"blueprints.conversation.models",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n📊 User Message Statistics\n")
|
||||||
|
print(
|
||||||
|
f"{'Username':<20} {'Total Messages':<15} {'User Messages':<15} {'Conversations':<15}"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Get all users
|
||||||
|
users = await User.all()
|
||||||
|
|
||||||
|
total_users = 0
|
||||||
|
total_messages = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
# Get all conversations for this user
|
||||||
|
conversations = await user.conversations.all()
|
||||||
|
|
||||||
|
if not conversations:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_users += 1
|
||||||
|
|
||||||
|
# Count messages across all conversations
|
||||||
|
user_message_count = 0
|
||||||
|
total_message_count = 0
|
||||||
|
|
||||||
|
for conversation in conversations:
|
||||||
|
messages = await conversation.messages.all()
|
||||||
|
total_message_count += len(messages)
|
||||||
|
|
||||||
|
# Count only user messages (not assistant responses)
|
||||||
|
user_messages = [msg for msg in messages if msg.speaker == Speaker.USER]
|
||||||
|
user_message_count += len(user_messages)
|
||||||
|
|
||||||
|
total_messages += user_message_count
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{user.username:<20} {total_message_count:<15} {user_message_count:<15} {len(conversations):<15}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("\n📈 Summary:")
|
||||||
|
print(f" Total active users: {total_users}")
|
||||||
|
print(f" Total user messages: {total_messages}")
|
||||||
|
print(
|
||||||
|
f" Average messages per user: {total_messages / total_users if total_users > 0 else 0:.1f}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(get_user_message_stats())
|
||||||
25
startup-dev.sh
Executable file
25
startup-dev.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Initializing directories..."
|
||||||
|
mkdir -p /app/data/chromadb
|
||||||
|
|
||||||
|
echo "Rebuilding frontend..."
|
||||||
|
cd /app/raggr-frontend
|
||||||
|
yarn build
|
||||||
|
cd /app
|
||||||
|
|
||||||
|
echo "Setting up database..."
|
||||||
|
# Give PostgreSQL a moment to be ready (healthcheck in docker-compose handles this)
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
if ls migrations/models/0_*.py 1> /dev/null 2>&1; then
|
||||||
|
echo "Running database migrations..."
|
||||||
|
aerich upgrade
|
||||||
|
else
|
||||||
|
echo "No migrations found, initializing database..."
|
||||||
|
aerich init-db
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting Flask application in debug mode..."
|
||||||
|
python app.py
|
||||||
19
startup.sh
Normal file
19
startup.sh
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
aerich upgrade
|
||||||
|
|
||||||
|
# Ensure Obsidian vault directory exists
|
||||||
|
mkdir -p /app/data/obsidian
|
||||||
|
|
||||||
|
# Start continuous Obsidian sync if enabled
|
||||||
|
if [ "${OBSIDIAN_CONTINUOUS_SYNC}" = "true" ]; then
|
||||||
|
echo "Starting Obsidian continuous sync in background..."
|
||||||
|
ob sync --continuous &
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting reindex process in background..."
|
||||||
|
python main.py "" --reindex &
|
||||||
|
|
||||||
|
echo "Starting application..."
|
||||||
|
python app.py
|
||||||
189
tickets.md
Normal file
189
tickets.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Integration: Twilio API for WhatsApp Interface (Multi-User)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Integrate Twilio's WhatsApp API to allow users to interact with Simba via WhatsApp. This requires multi-user support, linking WhatsApp numbers to existing or new user accounts.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Phase 1: Infrastructure and Database Changes
|
||||||
|
- [x] **[TICKET-001]** Update `User` model to include `whatsapp_number`.
|
||||||
|
- [x] **[TICKET-002]** Generate and apply migrations for the database changes.
|
||||||
|
|
||||||
|
### Phase 2: Twilio Integration Blueprint
|
||||||
|
- [x] **[TICKET-003]** Create a new blueprint for Twilio/WhatsApp webhook.
|
||||||
|
- [x] **[TICKET-004]** Implement Twilio signature validation for security.
|
||||||
|
- Decorator enabled on webhook. Set `TWILIO_SIGNATURE_VALIDATION=false` to disable in dev. Set `TWILIO_WEBHOOK_URL` if behind a reverse proxy.
|
||||||
|
- [x] **[TICKET-005]** Implement User identification from WhatsApp phone number.
|
||||||
|
|
||||||
|
### Phase 3: Core Messaging Logic
|
||||||
|
- [x] **[TICKET-006]** Integrate `consult_simba_oracle` with the WhatsApp blueprint.
|
||||||
|
- [x] **[TICKET-007]** Implement outgoing WhatsApp message responses.
|
||||||
|
- [x] **[TICKET-008]** Handle conversation context in WhatsApp.
|
||||||
|
|
||||||
|
### Phase 4: Configuration and Deployment
|
||||||
|
- [x] **[TICKET-009]** Add Twilio credentials to environment variables.
|
||||||
|
- Keys: `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_WHATSAPP_NUMBER`.
|
||||||
|
- [ ] **[TICKET-010]** Document the Twilio webhook setup in `docs/whatsapp_integration.md`.
|
||||||
|
- Include: Webhook URL format, Twilio Console setup instructions.
|
||||||
|
|
||||||
|
### Phase 5: Multi-user & Edge Cases
|
||||||
|
- [ ] **[TICKET-011]** Handle first-time users (auto-creation of accounts or invitation system).
|
||||||
|
- [ ] **[TICKET-012]** Handle media messages (optional/future: images, audio).
|
||||||
|
- [x] **[TICKET-013]** Rate limiting and error handling for Twilio requests.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Twilio Webhook Payload (POST)
|
||||||
|
- `SmsMessageSid`, `NumMedia`, `Body`, `From`, `To`, `AccountSid`, etc.
|
||||||
|
- We primarily care about `Body` (user message) and `From` (user WhatsApp number).
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
1. Twilio receives a message -> POST to `/api/whatsapp/webhook`.
|
||||||
|
2. Validate signature.
|
||||||
|
3. Identify `User` by `From` number.
|
||||||
|
4. If not found, create a new `User` or return an error.
|
||||||
|
5. Get/create `Conversation` for this `User`.
|
||||||
|
6. Call `consult_simba_oracle` with the query and context.
|
||||||
|
7. Return response via TwiML `<Message>` tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Integration: Obsidian Bidirectional Data Store
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Integrate Obsidian as a bidirectional data store using the [`obsidian-headless`](https://github.com/obsidianmd/obsidian-headless) npm package. SimbaRAG will be able to read/search Obsidian notes for RAG context and write new notes, research summaries, and tasks back to the vault via the LangChain agent.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Phase 1: Infrastructure
|
||||||
|
- [ ] **[OBS-001]** Upgrade Node.js from 20 to 22 in `Dockerfile` (required by obsidian-headless).
|
||||||
|
- [ ] **[OBS-002]** Install `obsidian-headless` globally via npm in `Dockerfile`.
|
||||||
|
- [ ] **[OBS-003]** Add `obsidian_vault_data` volume and Obsidian env vars to `docker-compose.yml`.
|
||||||
|
- [ ] **[OBS-004]** Document Obsidian env vars in `.env.example` (`OBSIDIAN_AUTH_TOKEN`, `OBSIDIAN_VAULT_ID`, `OBSIDIAN_E2E_PASSWORD`, `OBSIDIAN_DEVICE_NAME`, `OBSIDIAN_CONTINUOUS_SYNC`).
|
||||||
|
- [ ] **[OBS-005]** Update `startup.sh` to conditionally run `ob sync --continuous` in background when `OBSIDIAN_CONTINUOUS_SYNC=true`.
|
||||||
|
|
||||||
|
### Phase 2: Core Service
|
||||||
|
- [ ] **[OBS-006]** Create `utils/obsidian_service.py` with `ObsidianService` class.
|
||||||
|
- Vault setup via `ob sync-setup` (async subprocess)
|
||||||
|
- One-time sync via `ob sync`
|
||||||
|
- Sync status via `ob sync-status`
|
||||||
|
- Walk vault directory for `.md` files (skip `.obsidian/`)
|
||||||
|
- Parse Obsidian markdown: YAML frontmatter → metadata, wikilink conversion, embed stripping, tag extraction
|
||||||
|
- Read specific note by relative path
|
||||||
|
- Create new note with frontmatter (auto-adds `created_by: simbarag` + timestamp)
|
||||||
|
- Create task note in configurable tasks folder
|
||||||
|
|
||||||
|
### Phase 3: RAG Indexing (Read)
|
||||||
|
- [ ] **[OBS-007]** Add `fetch_obsidian_documents()` to `blueprints/rag/logic.py` — uses `ObsidianService` to parse all vault `.md` files into LangChain `Document` objects with `source=obsidian` metadata.
|
||||||
|
- [ ] **[OBS-008]** Add `index_obsidian_documents()` to `blueprints/rag/logic.py` — deletes existing `source=obsidian` chunks, splits documents with shared `text_splitter`, embeds into shared `vector_store`.
|
||||||
|
- [ ] **[OBS-009]** Add `POST /api/rag/index-obsidian` endpoint (`@admin_required`) to `blueprints/rag/__init__.py`.
|
||||||
|
|
||||||
|
### Phase 4: Agent Tools (Read + Write)
|
||||||
|
- [ ] **[OBS-010]** Add `obsidian_search_notes` tool to `blueprints/conversation/agents.py` — semantic search via ChromaDB with `where={"source": "obsidian"}` filter.
|
||||||
|
- [ ] **[OBS-011]** Add `obsidian_read_note` tool to `blueprints/conversation/agents.py` — reads a specific note by relative path.
|
||||||
|
- [ ] **[OBS-012]** Add `obsidian_create_note` tool to `blueprints/conversation/agents.py` — creates a new markdown note in the vault (title, content, folder, tags).
|
||||||
|
- [ ] **[OBS-013]** Add `obsidian_create_task` tool to `blueprints/conversation/agents.py` — creates a task note with optional due date.
|
||||||
|
- [ ] **[OBS-014]** Register Obsidian tools conditionally (follow YNAB pattern: `obsidian_enabled` flag).
|
||||||
|
- [ ] **[OBS-015]** Update system prompt in `blueprints/conversation/__init__.py` with Obsidian tool usage instructions.
|
||||||
|
|
||||||
|
### Phase 5: Testing & Verification
|
||||||
|
- [ ] **[OBS-016]** Verify Docker image builds with Node.js 22 + obsidian-headless.
|
||||||
|
- [ ] **[OBS-017]** Test vault sync: setup → sync → verify files appear in `/app/data/obsidian`.
|
||||||
|
- [ ] **[OBS-018]** Test indexing: `POST /api/rag/index-obsidian` → verify chunks in ChromaDB with `source=obsidian`.
|
||||||
|
- [ ] **[OBS-019]** Test agent read tools: chat queries trigger `obsidian_search_notes` and `obsidian_read_note`.
|
||||||
|
- [ ] **[OBS-020]** Test agent write tools: chat creates notes/tasks → files appear in vault → sync pushes to Obsidian.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
- `utils/obsidian_service.py` — new, core service (follows `utils/ynab_service.py` pattern)
|
||||||
|
- `blueprints/conversation/agents.py` — add tools (follows YNAB tool pattern at lines 101-279)
|
||||||
|
- `blueprints/conversation/__init__.py` — update system prompt (line ~94)
|
||||||
|
- `blueprints/rag/logic.py` — add indexing functions (reuse `vector_store`, `text_splitter`)
|
||||||
|
- `blueprints/rag/__init__.py` — add index endpoint
|
||||||
|
|
||||||
|
### Write-back Model
|
||||||
|
Files written to the vault directory are automatically synced to Obsidian Sync by the `ob sync --continuous` background process. No separate push step needed.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `OBSIDIAN_AUTH_TOKEN` | Yes | Auth token for Obsidian Sync (non-interactive) |
|
||||||
|
| `OBSIDIAN_VAULT_ID` | Yes | Remote vault ID or name |
|
||||||
|
| `OBSIDIAN_E2E_PASSWORD` | If E2EE | End-to-end encryption password |
|
||||||
|
| `OBSIDIAN_DEVICE_NAME` | No | Client identifier (default: `simbarag-server`) |
|
||||||
|
| `OBSIDIAN_CONTINUOUS_SYNC` | No | Enable background sync (default: `false`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Integration: WhatsApp to LangChain Agent Migration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Migrate the WhatsApp blueprint from custom LLM logic to the LangChain agent-based system used by the conversation blueprint. This will provide Tavily web search, YNAB integration, and improved message handling capabilities.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Phase 1: Import and Setup Changes
|
||||||
|
- [x] **[WA-001]** Remove dependency on `main.py`'s `consult_simba_oracle` import in `blueprints/whatsapp/__init__.py`.
|
||||||
|
- [x] **[WA-002]** Import `main_agent` from `blueprints.conversation.agents` in `blueprints/whatsapp/__init__.py`.
|
||||||
|
- [ ] **[WA-003]** Add import for `query_vector_store` from `blueprints.rag.logic` (if needed for simba_search tool).
|
||||||
|
- [x] **[WA-004]** Verify `main_agent` is already initialized as a global variable in `agents.py` (it is at line 295).
|
||||||
|
|
||||||
|
### Phase 2: Agent Invocation Adaptation
|
||||||
|
- [x] **[WA-005]** Replace `consult_simba_oracle()` call (lines 171-178) with LangChain agent invocation.
|
||||||
|
- [x] **[WA-006]** Add system prompt with Simba facts, medical conditions, and recent events from `blueprints/conversation/__init__.py` (lines 55-95).
|
||||||
|
- [x] **[WA-007]** Build messages payload with role-based conversation history (last 10 messages).
|
||||||
|
- [x] **[WA-008]** Handle agent response extraction: `response.get("messages", [])[-1].content`.
|
||||||
|
- [x] **[WA-009]** Keep existing error handling around agent invocation (try/except block).
|
||||||
|
|
||||||
|
### Phase 3: Configuration and Logging
|
||||||
|
- [x] **[WA-010]** Add YNAB availability logging (check `os.getenv("YNAB_ACCESS_TOKEN")` is not None) in webhook handler.
|
||||||
|
- [x] **[WA-011]** Ensure `main_agent` tools include `simba_search`, `web_search`, and optionally YNAB tools (already configured in `agents.py`).
|
||||||
|
- [x] **[WA-012]** Verify `simba_search` tool uses `query_vector_store()` which supports `where={"source": "paperless"}` filter (no change needed, works with existing ChromaDB collection).
|
||||||
|
|
||||||
|
### Phase 4: Testing Strategy
|
||||||
|
- [ ] **[WA-013]** Test Simba queries (e.g., "How much does Simba weigh?") — should use `simba_search` tool.
|
||||||
|
- [ ] **[WA-014]** Test general chat queries (e.g., "What's the weather?") — should use LLM directly, no tools.
|
||||||
|
- [ ] **[WA-015]** Test web search capability (e.g., "What's the latest cat health research?") — should use `web_search` tool with Tavily.
|
||||||
|
- [ ] **[WA-016]** Test YNAB integration if configured (e.g., "How much did I spend on food?") — should use appropriate YNAB tool.
|
||||||
|
- [ ] **[WA-017]** Test conversation context preservation (send multiple messages in sequence).
|
||||||
|
- [ ] **[WA-018]** Test rate limiting still works after migration.
|
||||||
|
- [ ] **[WA-019]** Test user creation and allowlist still function correctly.
|
||||||
|
- [ ] **[WA-020]** Test error handling for agent failures (returns "Sorry, I'm having trouble thinking right now. 😿").
|
||||||
|
|
||||||
|
### Phase 5: Cleanup and Documentation
|
||||||
|
- [ ] **[WA-021]** Optionally remove or deprecate deprecated `main.py` functions: `classify_query()`, `consult_oracle()`, `llm_chat()`, `consult_simba_oracle()` (keep for CLI tool usage).
|
||||||
|
- [ ] **[WA-022]** Update code comments in `main.py` to indicate WhatsApp no longer uses these functions.
|
||||||
|
- [ ] **[WA-023]** Document the agent-based approach in `docs/whatsapp_integration.md` (if file exists) or create new documentation.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Current WhatsApp Flow
|
||||||
|
1. Twilio webhook → `blueprints/whatsapp/__init__.webhook()`
|
||||||
|
2. Call `consult_simba_oracle(input, transcript)` from `main.py`
|
||||||
|
3. `consult_simba_oracle()` uses custom `QueryGenerator` to classify query
|
||||||
|
4. Routes to `consult_oracle()` (ChromaDB) or `llm_chat()` (simple chat)
|
||||||
|
5. Returns text response
|
||||||
|
|
||||||
|
### Target WhatsApp Flow
|
||||||
|
1. Twilio webhook → `blueprints/whatsapp/__init__.webhook()`
|
||||||
|
2. Build LangChain messages payload with system prompt and conversation history
|
||||||
|
3. Invoke `main_agent.ainvoke({"messages": messages_payload})`
|
||||||
|
4. Agent decides when to use tools (simba_search, web_search, YNAB)
|
||||||
|
5. Returns text response from last message
|
||||||
|
|
||||||
|
### Key Differences
|
||||||
|
1. **No manual query classification** — Agent decides based on LLM reasoning
|
||||||
|
2. **Tavily web_search** now available for current information
|
||||||
|
3. **YNAB integration** available if configured
|
||||||
|
4. **System prompt consistency** with conversation blueprint
|
||||||
|
5. **Message format** — LangChain messages array vs transcript string
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
No new environment variables needed. Uses existing:
|
||||||
|
- `LLAMA_SERVER_URL` — for LLM model
|
||||||
|
- `TAVILY_API_KEY` — for web search
|
||||||
|
- `YNAB_ACCESS_TOKEN` — for budget integration (optional)
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `blueprints/whatsapp/__init__.py` — Main webhook handler
|
||||||
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
@@ -1,12 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
from math import ceil
|
from math import ceil
|
||||||
import re
|
import re
|
||||||
|
from typing import Union
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
from chromadb.utils.embedding_functions.openai_embedding_function import (
|
||||||
from chromadb.utils.embedding_functions.ollama_embedding_function import (
|
OpenAIEmbeddingFunction,
|
||||||
OllamaEmbeddingFunction,
|
|
||||||
)
|
)
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from llm import LLMClient
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -79,18 +80,26 @@ class Chunk:
|
|||||||
|
|
||||||
|
|
||||||
class Chunker:
|
class Chunker:
|
||||||
embedding_fx = OllamaEmbeddingFunction(
|
|
||||||
url=os.getenv("OLLAMA_URL", ""),
|
|
||||||
model_name="mxbai-embed-large",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, collection) -> None:
|
def __init__(self, collection) -> None:
|
||||||
self.collection = collection
|
self.collection = collection
|
||||||
|
self.llm_client = LLMClient()
|
||||||
|
|
||||||
def chunk_document(self, document: str, chunk_size: int = 1000) -> list[Chunk]:
|
def embedding_fx(self, inputs):
|
||||||
|
openai_embedding_fx = OpenAIEmbeddingFunction(
|
||||||
|
api_key=os.getenv("OPENAI_API_KEY"),
|
||||||
|
model_name="text-embedding-3-small",
|
||||||
|
)
|
||||||
|
return openai_embedding_fx(inputs)
|
||||||
|
|
||||||
|
def chunk_document(
|
||||||
|
self,
|
||||||
|
document: str,
|
||||||
|
chunk_size: int = 1000,
|
||||||
|
metadata: dict[str, Union[str, float]] = {},
|
||||||
|
) -> list[Chunk]:
|
||||||
doc_uuid = uuid4()
|
doc_uuid = uuid4()
|
||||||
|
|
||||||
chunk_size = min(chunk_size, len(document))
|
chunk_size = min(chunk_size, len(document)) or 1
|
||||||
|
|
||||||
chunks = []
|
chunks = []
|
||||||
num_chunks = ceil(len(document) / chunk_size)
|
num_chunks = ceil(len(document) / chunk_size)
|
||||||
@@ -110,6 +119,7 @@ class Chunker:
|
|||||||
ids=[str(doc_uuid) + ":" + str(i)],
|
ids=[str(doc_uuid) + ":" + str(i)],
|
||||||
documents=[text_chunk],
|
documents=[text_chunk],
|
||||||
embeddings=embedding,
|
embeddings=embedding,
|
||||||
|
metadatas=[metadata],
|
||||||
)
|
)
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
165
utils/cleaner.py
Normal file
165
utils/cleaner.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import ollama
|
||||||
|
from PIL import Image
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
from .request import PaperlessNGXService
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configure ollama client with URL from environment or default to localhost
|
||||||
|
ollama_client = ollama.Client(host=os.getenv("OLLAMA_URL", "http://localhost:11434"))
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="use llm to clean documents")
|
||||||
|
parser.add_argument("document_id", type=str, help="questions about simba's health")
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_to_image(filepath: str, dpi=300) -> list[str]:
|
||||||
|
"""Returns the filepaths to the created images"""
|
||||||
|
image_temp_files = []
|
||||||
|
try:
|
||||||
|
pdf_document = fitz.open(filepath)
|
||||||
|
print(f"\nConverting '{os.path.basename(filepath)}' to temporary images...")
|
||||||
|
|
||||||
|
for page_num in range(len(pdf_document)):
|
||||||
|
page = pdf_document.load_page(page_num)
|
||||||
|
zoom = dpi / 72
|
||||||
|
mat = fitz.Matrix(zoom, zoom)
|
||||||
|
pix = page.get_pixmap(matrix=mat)
|
||||||
|
|
||||||
|
# Create a temporary file for the image. delete=False is crucial.
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
delete=False,
|
||||||
|
suffix=".png",
|
||||||
|
prefix=f"pdf_page_{page_num + 1}_",
|
||||||
|
) as temp_image_file:
|
||||||
|
temp_image_path = temp_image_file.name
|
||||||
|
|
||||||
|
# Save the pixel data to the temporary file
|
||||||
|
pix.save(temp_image_path)
|
||||||
|
image_temp_files.append(temp_image_path)
|
||||||
|
print(
|
||||||
|
f" -> Saved page {page_num + 1} to temporary file: '{temp_image_path}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\nConversion successful! ✨")
|
||||||
|
return image_temp_files
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An error occurred during PDF conversion: {e}", file=sys.stderr)
|
||||||
|
# Clean up any image files that were created before the error
|
||||||
|
for path in image_temp_files:
|
||||||
|
os.remove(path)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def merge_images_vertically_to_tempfile(image_paths):
|
||||||
|
"""
|
||||||
|
Merges a list of images vertically and saves the result to a temporary file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_paths (list): A list of strings, where each string is the
|
||||||
|
filepath to an image.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The filepath of the temporary merged image file.
|
||||||
|
"""
|
||||||
|
if not image_paths:
|
||||||
|
print("Error: The list of image paths is empty.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Open all images and check for consistency
|
||||||
|
try:
|
||||||
|
images = [Image.open(path) for path in image_paths]
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
print(f"Error: Could not find image file: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
widths, heights = zip(*(img.size for img in images))
|
||||||
|
max_width = max(widths)
|
||||||
|
|
||||||
|
# All images must have the same width
|
||||||
|
if not all(width == max_width for width in widths):
|
||||||
|
print("Warning: Images have different widths. They will be resized.")
|
||||||
|
resized_images = []
|
||||||
|
for img in images:
|
||||||
|
if img.size[0] != max_width:
|
||||||
|
img = img.resize(
|
||||||
|
(max_width, int(img.size[1] * (max_width / img.size[0])))
|
||||||
|
)
|
||||||
|
resized_images.append(img)
|
||||||
|
images = resized_images
|
||||||
|
heights = [img.size[1] for img in images]
|
||||||
|
|
||||||
|
# Calculate the total height of the merged image
|
||||||
|
total_height = sum(heights)
|
||||||
|
|
||||||
|
# Create a new blank image with the combined dimensions
|
||||||
|
merged_image = Image.new("RGB", (max_width, total_height))
|
||||||
|
|
||||||
|
# Paste each image onto the new blank image
|
||||||
|
y_offset = 0
|
||||||
|
for img in images:
|
||||||
|
merged_image.paste(img, (0, y_offset))
|
||||||
|
y_offset += img.height
|
||||||
|
|
||||||
|
# Create a temporary file and save the image
|
||||||
|
temp_file = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
||||||
|
temp_path = temp_file.name
|
||||||
|
merged_image.save(temp_path)
|
||||||
|
temp_file.close()
|
||||||
|
|
||||||
|
print(f"Successfully merged {len(images)} images into temporary file: {temp_path}")
|
||||||
|
return temp_path
|
||||||
|
|
||||||
|
|
||||||
|
OCR_PROMPT = """
|
||||||
|
You job is to extract text from the images I provide you. Extract every bit of the text in the image. Don't say anything just do your job. Text should be same as in the images. If there are multiple images, categorize the transcriptions by page.
|
||||||
|
|
||||||
|
Things to avoid:
|
||||||
|
- Don't miss anything to extract from the images
|
||||||
|
|
||||||
|
Things to include:
|
||||||
|
- Include everything, even anything inside [], (), {} or anything.
|
||||||
|
- Include any repetitive things like "..." or anything
|
||||||
|
- If you think there is any mistake in image just include it too
|
||||||
|
|
||||||
|
Someone will kill the innocent kittens if you don't extract the text exactly. So, make sure you extract every bit of the text. Only output the extracted text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_pdf_image(filepaths: list[str]):
|
||||||
|
res = ollama_client.chat(
|
||||||
|
model="gemma3:4b",
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": OCR_PROMPT,
|
||||||
|
"images": filepaths,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return res["message"]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = parser.parse_args()
|
||||||
|
ppngx = PaperlessNGXService()
|
||||||
|
|
||||||
|
if args.document_id:
|
||||||
|
doc_id = args.document_id
|
||||||
|
file = ppngx.get_doc_by_id(doc_id=doc_id)
|
||||||
|
pdf_path = ppngx.download_pdf_from_id(doc_id)
|
||||||
|
print(pdf_path)
|
||||||
|
image_paths = pdf_to_image(filepath=pdf_path)
|
||||||
|
summary = summarize_pdf_image(filepaths=image_paths)
|
||||||
|
print(summary)
|
||||||
|
file["content"] = summary
|
||||||
|
print(file)
|
||||||
|
ppngx.upload_cleaned_content(doc_id, file)
|
||||||
83
utils/image_process.py
Normal file
83
utils/image_process.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from ollama import Client
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from PIL import Image, ExifTags
|
||||||
|
from pillow_heif import register_heif_opener
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
register_heif_opener()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="SimbaImageProcessor",
|
||||||
|
description="What the program does",
|
||||||
|
epilog="Text at the bottom of help",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("filepath")
|
||||||
|
|
||||||
|
client = Client(host=os.getenv("OLLAMA_HOST", "http://localhost:11434"))
|
||||||
|
|
||||||
|
|
||||||
|
class SimbaImageDescription(BaseModel):
|
||||||
|
image_date: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
def describe_simba_image(input):
|
||||||
|
logging.info("Opening image of Simba ...")
|
||||||
|
if "heic" in input.lower() or "heif" in input.lower():
|
||||||
|
new_filepath = input.split(".")[0] + ".jpg"
|
||||||
|
img = Image.open(input)
|
||||||
|
img.save(new_filepath, "JPEG")
|
||||||
|
logging.info("Extracting EXIF...")
|
||||||
|
exif = {
|
||||||
|
ExifTags.TAGS[k]: v for k, v in img.getexif().items() if k in ExifTags.TAGS
|
||||||
|
}
|
||||||
|
img = Image.open(new_filepath)
|
||||||
|
input = new_filepath
|
||||||
|
else:
|
||||||
|
img = Image.open(input)
|
||||||
|
|
||||||
|
logging.info("Extracting EXIF...")
|
||||||
|
exif = {
|
||||||
|
ExifTags.TAGS[k]: v for k, v in img.getexif().items() if k in ExifTags.TAGS
|
||||||
|
}
|
||||||
|
|
||||||
|
if "MakerNote" in exif:
|
||||||
|
exif.pop("MakerNote")
|
||||||
|
|
||||||
|
logging.info(exif)
|
||||||
|
|
||||||
|
prompt = f"Simba is an orange cat belonging to Ryan Chen. In 2025, they lived in New York. In 2024, they lived in California. Analyze the following image and tell me what Simba seems to be doing. Be extremely descriptive about Simba, things in the background, and the setting of the image. I will also include the EXIF data of the image, please use it to help you determine information about Simba. EXIF: {exif}. Put the notes in the description field and the date in the image_date field."
|
||||||
|
|
||||||
|
logging.info("Sending info to Ollama ...")
|
||||||
|
response = client.chat(
|
||||||
|
model="gemma3:4b",
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "you are a very shrewd and descriptive note taker. all of your responses will be formatted like notes in bullet points. be very descriptive. do not leave a single thing out.",
|
||||||
|
},
|
||||||
|
{"role": "user", "content": prompt, "images": [input]},
|
||||||
|
],
|
||||||
|
format=SimbaImageDescription.model_json_schema(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = SimbaImageDescription.model_validate_json(response["message"]["content"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.filepath:
|
||||||
|
logging.info
|
||||||
|
describe_simba_image(input=args.filepath)
|
||||||
446
utils/obsidian_service.py
Normal file
446
utils/obsidian_service.py
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
"""Obsidian headless sync service for querying and modifying vaults."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import yaml
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
from subprocess import run
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class ObsidianService:
|
||||||
|
"""Service for interacting with Obsidian vault via obsidian-headless CLI."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize Obsidian Sync client."""
|
||||||
|
self.vault_path = os.getenv("OBSIDIAN_VAULT_PATH", "/app/data/obsidian")
|
||||||
|
|
||||||
|
# Create vault path if it doesn't exist
|
||||||
|
Path(self.vault_path).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Validate vault has .md files
|
||||||
|
self._validate_vault()
|
||||||
|
|
||||||
|
def _validate_vault(self) -> None:
|
||||||
|
"""Validate that vault directory exists and has .md files."""
|
||||||
|
vault_dir = Path(self.vault_path)
|
||||||
|
if not vault_dir.exists():
|
||||||
|
raise ValueError(
|
||||||
|
f"Obsidian vault path '{self.vault_path}' does not exist. "
|
||||||
|
"Please ensure the vault is synced to this location."
|
||||||
|
)
|
||||||
|
|
||||||
|
md_files = list(vault_dir.rglob("*.md"))
|
||||||
|
if not md_files:
|
||||||
|
raise ValueError(
|
||||||
|
f"Vault at '{self.vault_path}' contains no markdown files. "
|
||||||
|
"Please ensure the vault is synced with obsidian-headless."
|
||||||
|
)
|
||||||
|
|
||||||
|
def walk_vault(self) -> list[Path]:
|
||||||
|
"""Walk through vault directory and return paths to .md files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of paths to markdown files, excluding .obsidian directory.
|
||||||
|
"""
|
||||||
|
vault_dir = Path(self.vault_path)
|
||||||
|
md_files = []
|
||||||
|
|
||||||
|
# Walk vault, excluding .obsidian directory
|
||||||
|
for md_file in vault_dir.rglob("*.md"):
|
||||||
|
# Skip .obsidian directory and its contents
|
||||||
|
if ".obsidian" in md_file.parts:
|
||||||
|
continue
|
||||||
|
md_files.append(md_file)
|
||||||
|
|
||||||
|
return md_files
|
||||||
|
|
||||||
|
def parse_markdown(self, content: str, filepath: Optional[Path] = None) -> dict[str, Any]:
|
||||||
|
"""Parse Obsidian markdown to extract metadata and clean content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Raw markdown content
|
||||||
|
filepath: Optional file path for context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing parsed content:
|
||||||
|
- metadata: Parsed YAML frontmatter (or empty dict if none)
|
||||||
|
- content: Cleaned body content
|
||||||
|
- tags: Extracted tags
|
||||||
|
- wikilinks: List of wikilinks found
|
||||||
|
- embeds: List of embeds found
|
||||||
|
"""
|
||||||
|
# Split frontmatter from content
|
||||||
|
frontmatter_pattern = r"^---\n(.*?)\n---"
|
||||||
|
match = re.match(frontmatter_pattern, content, re.DOTALL)
|
||||||
|
|
||||||
|
metadata = {}
|
||||||
|
body_content = content
|
||||||
|
|
||||||
|
if match:
|
||||||
|
frontmatter = match.group(1)
|
||||||
|
body_content = content[match.end():].strip()
|
||||||
|
try:
|
||||||
|
metadata = yaml.safe_load(frontmatter) or {}
|
||||||
|
except yaml.YAMLError:
|
||||||
|
# Invalid YAML, treat as empty metadata
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
# Extract tags (#tag format)
|
||||||
|
tags = re.findall(r"#(\w+)", content)
|
||||||
|
tags = [tag for tag in tags if tag] # Remove empty strings
|
||||||
|
|
||||||
|
# Extract wikilinks [[wiki link]]
|
||||||
|
wikilinks = re.findall(r"\[\[([^\]]+)\]\]", content)
|
||||||
|
|
||||||
|
# Extract embeds [[!embed]] or [[!embed:file]]
|
||||||
|
embeds = re.findall(r"\[\[!(.*?)\]\]", content)
|
||||||
|
embeds = [e.split(":")[0].strip() if ":" in e else e.strip() for e in embeds]
|
||||||
|
|
||||||
|
# Clean body content
|
||||||
|
# Remove wikilinks [[...]] and embeds [[!...]]
|
||||||
|
cleaned_content = re.sub(r"\[\[.*?\]\]", "", body_content)
|
||||||
|
cleaned_content = re.sub(r"\n{3,}", "\n\n", cleaned_content).strip()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"metadata": metadata,
|
||||||
|
"content": cleaned_content,
|
||||||
|
"tags": tags,
|
||||||
|
"wikilinks": wikilinks,
|
||||||
|
"embeds": embeds,
|
||||||
|
"filepath": str(filepath) if filepath else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def read_note(self, relative_path: str) -> dict[str, Any]:
|
||||||
|
"""Read a specific note from the vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relative_path: Path to note relative to vault root (e.g., "My Notes/simba.md")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing parsed note content and metadata.
|
||||||
|
"""
|
||||||
|
vault_dir = Path(self.vault_path)
|
||||||
|
note_path = vault_dir / relative_path
|
||||||
|
|
||||||
|
if not note_path.exists():
|
||||||
|
raise FileNotFoundError(f"Note not found at '{relative_path}'")
|
||||||
|
|
||||||
|
with open(note_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parsed = self.parse_markdown(content, note_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content": parsed,
|
||||||
|
"path": relative_path,
|
||||||
|
"full_path": str(note_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_note(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
content: str,
|
||||||
|
folder: str = "notes",
|
||||||
|
tags: Optional[list[str]] = None,
|
||||||
|
frontmatter: Optional[dict[str, Any]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create a new note in the vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Note title (will be used as filename)
|
||||||
|
content: Note body content
|
||||||
|
folder: Folder path (default: "notes")
|
||||||
|
tags: List of tags to add
|
||||||
|
frontmatter: Optional custom frontmatter to merge with defaults
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to created note (relative to vault root).
|
||||||
|
"""
|
||||||
|
vault_dir = Path(self.vault_path)
|
||||||
|
note_folder = vault_dir / folder
|
||||||
|
note_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Sanitize title for filename
|
||||||
|
safe_title = re.sub(r"[^a-z0-9-_]", "-", title.lower().strip())
|
||||||
|
safe_title = re.sub(r"-+", "-", safe_title).strip("-")
|
||||||
|
|
||||||
|
note_path = note_folder / f"{safe_title}.md"
|
||||||
|
|
||||||
|
# Build frontmatter
|
||||||
|
default_frontmatter = {
|
||||||
|
"created_by": "simbarag",
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if frontmatter:
|
||||||
|
default_frontmatter.update(frontmatter)
|
||||||
|
|
||||||
|
# Add tags to frontmatter if provided
|
||||||
|
if tags:
|
||||||
|
default_frontmatter.setdefault("tags", []).extend(tags)
|
||||||
|
|
||||||
|
# Write note
|
||||||
|
frontmatter_yaml = yaml.dump(default_frontmatter, allow_unicode=True, default_flow_style=False)
|
||||||
|
full_content = f"---\n{frontmatter_yaml}---\n\n{content}"
|
||||||
|
|
||||||
|
with open(note_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(full_content)
|
||||||
|
|
||||||
|
return f"{folder}/{safe_title}.md"
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
content: str = "",
|
||||||
|
folder: str = "tasks",
|
||||||
|
due_date: Optional[str] = None,
|
||||||
|
tags: Optional[list[str]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create a task note in the vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Task title
|
||||||
|
content: Task description
|
||||||
|
folder: Folder to place task (default: "tasks")
|
||||||
|
due_date: Optional due date in YYYY-MM-DD format
|
||||||
|
tags: Optional list of tags to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to created task note (relative to vault root).
|
||||||
|
"""
|
||||||
|
task_content = f"# {title}\n\n{content}"
|
||||||
|
|
||||||
|
# Add checkboxes if content is empty (simple task)
|
||||||
|
if not content.strip():
|
||||||
|
task_content += "\n- [ ]"
|
||||||
|
|
||||||
|
# Add due date if provided
|
||||||
|
if due_date:
|
||||||
|
task_content += f"\n\n**Due**: {due_date}"
|
||||||
|
|
||||||
|
# Add tags if provided
|
||||||
|
if tags:
|
||||||
|
task_content += "\n\n" + " ".join([f"#{tag}" for tag in tags])
|
||||||
|
|
||||||
|
return self.create_note(
|
||||||
|
title=title,
|
||||||
|
content=task_content,
|
||||||
|
folder=folder,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_daily_note_path(self, date: Optional[datetime] = None) -> str:
|
||||||
|
"""Return the relative vault path for a daily note.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: Date for the note (defaults to today)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relative path like "journal/2026/2026-03-03.md"
|
||||||
|
"""
|
||||||
|
if date is None:
|
||||||
|
date = datetime.now()
|
||||||
|
return f"journal/{date.strftime('%Y')}/{date.strftime('%Y-%m-%d')}.md"
|
||||||
|
|
||||||
|
def get_daily_note(self, date: Optional[datetime] = None) -> dict[str, Any]:
|
||||||
|
"""Read a daily note from the vault.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: Date for the note (defaults to today)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with found status, path, raw content, and date string.
|
||||||
|
"""
|
||||||
|
if date is None:
|
||||||
|
date = datetime.now()
|
||||||
|
relative_path = self.get_daily_note_path(date)
|
||||||
|
note_path = Path(self.vault_path) / relative_path
|
||||||
|
|
||||||
|
if not note_path.exists():
|
||||||
|
return {"found": False, "path": relative_path, "content": None, "date": date.strftime("%Y-%m-%d")}
|
||||||
|
|
||||||
|
with open(note_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
return {"found": True, "path": relative_path, "content": content, "date": date.strftime("%Y-%m-%d")}
|
||||||
|
|
||||||
|
def get_daily_tasks(self, date: Optional[datetime] = None) -> dict[str, Any]:
|
||||||
|
"""Extract tasks from a daily note's tasks section.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: Date for the note (defaults to today)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with tasks list (each has "text" and "done" keys) and metadata.
|
||||||
|
"""
|
||||||
|
if date is None:
|
||||||
|
date = datetime.now()
|
||||||
|
note = self.get_daily_note(date)
|
||||||
|
if not note["found"]:
|
||||||
|
return {"found": False, "tasks": [], "date": note["date"], "path": note["path"]}
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
in_tasks = False
|
||||||
|
for line in note["content"].split("\n"):
|
||||||
|
if re.match(r"^###\s+tasks\s*$", line, re.IGNORECASE):
|
||||||
|
in_tasks = True
|
||||||
|
continue
|
||||||
|
if in_tasks and re.match(r"^#{1,3}\s", line):
|
||||||
|
break
|
||||||
|
if in_tasks:
|
||||||
|
done_match = re.match(r"^- \[x\] (.+)$", line, re.IGNORECASE)
|
||||||
|
todo_match = re.match(r"^- \[ \] (.+)$", line)
|
||||||
|
if done_match:
|
||||||
|
tasks.append({"text": done_match.group(1), "done": True})
|
||||||
|
elif todo_match:
|
||||||
|
tasks.append({"text": todo_match.group(1), "done": False})
|
||||||
|
|
||||||
|
return {"found": True, "tasks": tasks, "date": note["date"], "path": note["path"]}
|
||||||
|
|
||||||
|
def add_task_to_daily_note(self, task_text: str, date: Optional[datetime] = None) -> dict[str, Any]:
|
||||||
|
"""Add a task checkbox to a daily note, creating the note if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_text: The task description text
|
||||||
|
date: Date for the note (defaults to today)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status, path, and whether note was created.
|
||||||
|
"""
|
||||||
|
if date is None:
|
||||||
|
date = datetime.now()
|
||||||
|
relative_path = self.get_daily_note_path(date)
|
||||||
|
note_path = Path(self.vault_path) / relative_path
|
||||||
|
|
||||||
|
if not note_path.exists():
|
||||||
|
note_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = (
|
||||||
|
f"---\nmodified: {datetime.now().isoformat()}\n---\n"
|
||||||
|
f"### tasks\n\n- [ ] {task_text}\n\n### log\n"
|
||||||
|
)
|
||||||
|
with open(note_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
return {"success": True, "created_note": True, "path": relative_path}
|
||||||
|
|
||||||
|
with open(note_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Insert before ### log if present, otherwise append before end
|
||||||
|
log_match = re.search(r"\n(### log)", content, re.IGNORECASE)
|
||||||
|
if log_match:
|
||||||
|
insert_pos = log_match.start()
|
||||||
|
content = content[:insert_pos] + f"\n- [ ] {task_text}" + content[insert_pos:]
|
||||||
|
else:
|
||||||
|
content = content.rstrip() + f"\n- [ ] {task_text}\n"
|
||||||
|
|
||||||
|
with open(note_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
return {"success": True, "created_note": False, "path": relative_path}
|
||||||
|
|
||||||
|
def complete_task_in_daily_note(self, task_text: str, date: Optional[datetime] = None) -> dict[str, Any]:
|
||||||
|
"""Mark a task as complete in a daily note by matching task text.
|
||||||
|
|
||||||
|
Searches for a task matching the given text (exact or partial) and
|
||||||
|
replaces `- [ ]` with `- [x]`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_text: The task text to search for (exact or partial match)
|
||||||
|
date: Date for the note (defaults to today)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status, matched task text, and path.
|
||||||
|
"""
|
||||||
|
if date is None:
|
||||||
|
date = datetime.now()
|
||||||
|
relative_path = self.get_daily_note_path(date)
|
||||||
|
note_path = Path(self.vault_path) / relative_path
|
||||||
|
|
||||||
|
if not note_path.exists():
|
||||||
|
return {"success": False, "error": "Note not found", "path": relative_path}
|
||||||
|
|
||||||
|
with open(note_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Try exact match first, then partial
|
||||||
|
exact = f"- [ ] {task_text}"
|
||||||
|
if exact in content:
|
||||||
|
content = content.replace(exact, f"- [x] {task_text}", 1)
|
||||||
|
else:
|
||||||
|
match = re.search(r"- \[ \] .*" + re.escape(task_text) + r".*", content, re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
return {"success": False, "error": f"Task '{task_text}' not found", "path": relative_path}
|
||||||
|
completed = match.group(0).replace("- [ ]", "- [x]", 1)
|
||||||
|
content = content.replace(match.group(0), completed, 1)
|
||||||
|
task_text = match.group(0).replace("- [ ] ", "")
|
||||||
|
|
||||||
|
with open(note_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
return {"success": True, "completed_task": task_text, "path": relative_path}
|
||||||
|
|
||||||
|
def sync_vault(self) -> dict[str, Any]:
|
||||||
|
"""Trigger a one-time sync of the vault.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing sync result and output.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = run(
|
||||||
|
["ob", "sync"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.stderr or "Sync failed",
|
||||||
|
"stdout": result.stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Vault synced successfully",
|
||||||
|
"stdout": result.stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
def sync_status(self) -> dict[str, Any]:
|
||||||
|
"""Check sync status of the vault.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing sync status information.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = run(
|
||||||
|
["ob", "sync-status"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"output": result.stdout,
|
||||||
|
"stderr": result.stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
86
utils/request.py
Normal file
86
utils/request.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
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"]}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pp = PaperlessNGXService()
|
||||||
|
pp.get_data()
|
||||||
342
utils/ynab_service.py
Normal file
342
utils/ynab_service.py
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
"""YNAB API service for querying budget data."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import ynab
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class YNABService:
|
||||||
|
"""Service for interacting with YNAB API."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize YNAB API client."""
|
||||||
|
self.access_token = os.getenv("YNAB_ACCESS_TOKEN", "")
|
||||||
|
self.budget_id = os.getenv("YNAB_BUDGET_ID", "")
|
||||||
|
|
||||||
|
if not self.access_token:
|
||||||
|
raise ValueError("YNAB_ACCESS_TOKEN environment variable is required")
|
||||||
|
|
||||||
|
# Configure API client
|
||||||
|
configuration = ynab.Configuration(access_token=self.access_token)
|
||||||
|
self.api_client = ynab.ApiClient(configuration)
|
||||||
|
|
||||||
|
# Initialize API endpoints
|
||||||
|
self.budgets_api = ynab.BudgetsApi(self.api_client)
|
||||||
|
self.transactions_api = ynab.TransactionsApi(self.api_client)
|
||||||
|
self.months_api = ynab.MonthsApi(self.api_client)
|
||||||
|
self.categories_api = ynab.CategoriesApi(self.api_client)
|
||||||
|
|
||||||
|
# Get budget ID if not provided
|
||||||
|
if not self.budget_id:
|
||||||
|
budgets_response = self.budgets_api.get_budgets()
|
||||||
|
if budgets_response.data and budgets_response.data.budgets:
|
||||||
|
self.budget_id = budgets_response.data.budgets[0].id
|
||||||
|
else:
|
||||||
|
raise ValueError("No YNAB budgets found")
|
||||||
|
|
||||||
|
def get_budget_summary(self) -> dict[str, Any]:
|
||||||
|
"""Get overall budget summary and health status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing budget summary with to-be-budgeted amount,
|
||||||
|
total budgeted, total activity, and overall budget health.
|
||||||
|
"""
|
||||||
|
budget_response = self.budgets_api.get_budget_by_id(self.budget_id)
|
||||||
|
budget_data = budget_response.data.budget
|
||||||
|
|
||||||
|
# Calculate totals from categories
|
||||||
|
to_be_budgeted = (
|
||||||
|
budget_data.months[0].to_be_budgeted / 1000 if budget_data.months else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
total_budgeted = 0
|
||||||
|
total_activity = 0
|
||||||
|
total_available = 0
|
||||||
|
|
||||||
|
for category_group in budget_data.category_groups or []:
|
||||||
|
if category_group.deleted or category_group.hidden:
|
||||||
|
continue
|
||||||
|
for category in category_group.categories or []:
|
||||||
|
if category.deleted or category.hidden:
|
||||||
|
continue
|
||||||
|
total_budgeted += category.budgeted / 1000
|
||||||
|
total_activity += category.activity / 1000
|
||||||
|
total_available += category.balance / 1000
|
||||||
|
|
||||||
|
return {
|
||||||
|
"budget_name": budget_data.name,
|
||||||
|
"to_be_budgeted": round(to_be_budgeted, 2),
|
||||||
|
"total_budgeted": round(total_budgeted, 2),
|
||||||
|
"total_activity": round(total_activity, 2),
|
||||||
|
"total_available": round(total_available, 2),
|
||||||
|
"currency_format": budget_data.currency_format.iso_code
|
||||||
|
if budget_data.currency_format
|
||||||
|
else "USD",
|
||||||
|
"summary": f"Budget '{budget_data.name}' has ${abs(to_be_budgeted):.2f} {'to be budgeted' if to_be_budgeted > 0 else 'overbudgeted'}. "
|
||||||
|
f"Total budgeted: ${total_budgeted:.2f}, Total spent: ${abs(total_activity):.2f}, "
|
||||||
|
f"Total available: ${total_available:.2f}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_transactions(
|
||||||
|
self,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
category_name: Optional[str] = None,
|
||||||
|
payee_name: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get transactions filtered by date range, category, or payee.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date in YYYY-MM-DD format (defaults to 30 days ago)
|
||||||
|
end_date: End date in YYYY-MM-DD format (defaults to today)
|
||||||
|
category_name: Filter by category name (case-insensitive partial match)
|
||||||
|
payee_name: Filter by payee name (case-insensitive partial match)
|
||||||
|
limit: Maximum number of transactions to return (default 50)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing matching transactions and summary statistics.
|
||||||
|
"""
|
||||||
|
# Set default date range if not provided
|
||||||
|
if not start_date:
|
||||||
|
start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||||
|
if not end_date:
|
||||||
|
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Get transactions
|
||||||
|
transactions_response = self.transactions_api.get_transactions(
|
||||||
|
self.budget_id, since_date=start_date
|
||||||
|
)
|
||||||
|
|
||||||
|
transactions = transactions_response.data.transactions or []
|
||||||
|
|
||||||
|
# Filter by date range, category, and payee
|
||||||
|
filtered_transactions = []
|
||||||
|
total_amount = 0
|
||||||
|
|
||||||
|
for txn in transactions:
|
||||||
|
# Skip if deleted or before start date or after end date
|
||||||
|
if txn.deleted:
|
||||||
|
continue
|
||||||
|
txn_date = str(txn.date)
|
||||||
|
if txn_date < start_date or txn_date > end_date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter by category if specified
|
||||||
|
if category_name and txn.category_name:
|
||||||
|
if category_name.lower() not in txn.category_name.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter by payee if specified
|
||||||
|
if payee_name and txn.payee_name:
|
||||||
|
if payee_name.lower() not in txn.payee_name.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
amount = txn.amount / 1000 # Convert milliunits to dollars
|
||||||
|
filtered_transactions.append(
|
||||||
|
{
|
||||||
|
"date": txn_date,
|
||||||
|
"payee": txn.payee_name,
|
||||||
|
"category": txn.category_name,
|
||||||
|
"memo": txn.memo,
|
||||||
|
"amount": round(amount, 2),
|
||||||
|
"approved": txn.approved,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total_amount += amount
|
||||||
|
|
||||||
|
# Sort by date (most recent first) and limit
|
||||||
|
filtered_transactions.sort(key=lambda x: x["date"], reverse=True)
|
||||||
|
filtered_transactions = filtered_transactions[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"transactions": filtered_transactions,
|
||||||
|
"count": len(filtered_transactions),
|
||||||
|
"total_amount": round(total_amount, 2),
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"filters": {"category": category_name, "payee": payee_name},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_category_spending(self, month: Optional[str] = None) -> dict[str, Any]:
|
||||||
|
"""Get spending breakdown by category for a specific month.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
month: Month in YYYY-MM format (defaults to current month)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing spending by category and summary.
|
||||||
|
"""
|
||||||
|
if not month:
|
||||||
|
month = datetime.now().strftime("%Y-%m-01")
|
||||||
|
else:
|
||||||
|
# Ensure format is YYYY-MM-01
|
||||||
|
if len(month) == 7: # YYYY-MM
|
||||||
|
month = f"{month}-01"
|
||||||
|
|
||||||
|
# Get budget month
|
||||||
|
month_response = self.months_api.get_budget_month(self.budget_id, month)
|
||||||
|
|
||||||
|
month_data = month_response.data.month
|
||||||
|
|
||||||
|
categories_spending = []
|
||||||
|
total_budgeted = 0
|
||||||
|
total_activity = 0
|
||||||
|
total_available = 0
|
||||||
|
overspent_categories = []
|
||||||
|
|
||||||
|
for category in month_data.categories or []:
|
||||||
|
if category.deleted or category.hidden:
|
||||||
|
continue
|
||||||
|
|
||||||
|
budgeted = category.budgeted / 1000
|
||||||
|
activity = category.activity / 1000
|
||||||
|
available = category.balance / 1000
|
||||||
|
|
||||||
|
total_budgeted += budgeted
|
||||||
|
total_activity += activity
|
||||||
|
total_available += available
|
||||||
|
|
||||||
|
# Track overspent categories
|
||||||
|
if available < 0:
|
||||||
|
overspent_categories.append(
|
||||||
|
{
|
||||||
|
"name": category.name,
|
||||||
|
"budgeted": round(budgeted, 2),
|
||||||
|
"spent": round(abs(activity), 2),
|
||||||
|
"overspent_by": round(abs(available), 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only include categories with activity
|
||||||
|
if activity != 0:
|
||||||
|
categories_spending.append(
|
||||||
|
{
|
||||||
|
"category": category.name,
|
||||||
|
"budgeted": round(budgeted, 2),
|
||||||
|
"activity": round(activity, 2),
|
||||||
|
"available": round(available, 2),
|
||||||
|
"goal_type": category.goal_type
|
||||||
|
if hasattr(category, "goal_type")
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by absolute activity (highest spending first)
|
||||||
|
categories_spending.sort(key=lambda x: abs(x["activity"]), reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"month": month[:7], # Return YYYY-MM format
|
||||||
|
"categories": categories_spending,
|
||||||
|
"total_budgeted": round(total_budgeted, 2),
|
||||||
|
"total_spent": round(abs(total_activity), 2),
|
||||||
|
"total_available": round(total_available, 2),
|
||||||
|
"overspent_categories": overspent_categories,
|
||||||
|
"to_be_budgeted": round(month_data.to_be_budgeted / 1000, 2)
|
||||||
|
if month_data.to_be_budgeted
|
||||||
|
else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_spending_insights(self, months_back: int = 3) -> dict[str, Any]:
|
||||||
|
"""Generate insights about spending patterns and budget health.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
months_back: Number of months to analyze (default 3)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing spending insights, trends, and recommendations.
|
||||||
|
"""
|
||||||
|
current_month = datetime.now()
|
||||||
|
monthly_data = []
|
||||||
|
|
||||||
|
# Collect data for the last N months
|
||||||
|
for i in range(months_back):
|
||||||
|
month_date = current_month - timedelta(days=30 * i)
|
||||||
|
month_str = month_date.strftime("%Y-%m-01")
|
||||||
|
|
||||||
|
try:
|
||||||
|
month_spending = self.get_category_spending(month_str)
|
||||||
|
monthly_data.append(
|
||||||
|
{
|
||||||
|
"month": month_str[:7],
|
||||||
|
"total_spent": month_spending["total_spent"],
|
||||||
|
"total_budgeted": month_spending["total_budgeted"],
|
||||||
|
"overspent_categories": month_spending["overspent_categories"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Skip months that don't have data yet
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not monthly_data:
|
||||||
|
return {"error": "No spending data available for analysis"}
|
||||||
|
|
||||||
|
# Calculate average spending
|
||||||
|
avg_spending = sum(m["total_spent"] for m in monthly_data) / len(monthly_data)
|
||||||
|
current_spending = monthly_data[0]["total_spent"] if monthly_data else 0
|
||||||
|
|
||||||
|
# Identify spending trend
|
||||||
|
if len(monthly_data) >= 2:
|
||||||
|
recent_avg = sum(m["total_spent"] for m in monthly_data[:2]) / 2
|
||||||
|
older_avg = sum(m["total_spent"] for m in monthly_data[1:]) / (
|
||||||
|
len(monthly_data) - 1
|
||||||
|
)
|
||||||
|
trend = (
|
||||||
|
"increasing"
|
||||||
|
if recent_avg > older_avg * 1.1
|
||||||
|
else "decreasing"
|
||||||
|
if recent_avg < older_avg * 0.9
|
||||||
|
else "stable"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
trend = "insufficient data"
|
||||||
|
|
||||||
|
# Find frequently overspent categories
|
||||||
|
overspent_frequency = {}
|
||||||
|
for month in monthly_data:
|
||||||
|
for cat in month["overspent_categories"]:
|
||||||
|
cat_name = cat["name"]
|
||||||
|
if cat_name not in overspent_frequency:
|
||||||
|
overspent_frequency[cat_name] = 0
|
||||||
|
overspent_frequency[cat_name] += 1
|
||||||
|
|
||||||
|
frequently_overspent = [
|
||||||
|
{"category": cat, "months_overspent": count}
|
||||||
|
for cat, count in overspent_frequency.items()
|
||||||
|
if count > 1
|
||||||
|
]
|
||||||
|
frequently_overspent.sort(key=lambda x: x["months_overspent"], reverse=True)
|
||||||
|
|
||||||
|
# Generate recommendations
|
||||||
|
recommendations = []
|
||||||
|
if current_spending > avg_spending * 1.2:
|
||||||
|
recommendations.append(
|
||||||
|
f"Current month spending (${current_spending:.2f}) is significantly higher than your {months_back}-month average (${avg_spending:.2f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if frequently_overspent:
|
||||||
|
top_overspent = frequently_overspent[0]
|
||||||
|
recommendations.append(
|
||||||
|
f"'{top_overspent['category']}' has been overspent in {top_overspent['months_overspent']} of the last {months_back} months"
|
||||||
|
)
|
||||||
|
|
||||||
|
if trend == "increasing":
|
||||||
|
recommendations.append(
|
||||||
|
"Your spending trend is increasing. Consider reviewing your budget allocations."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"analysis_period": f"Last {months_back} months",
|
||||||
|
"average_monthly_spending": round(avg_spending, 2),
|
||||||
|
"current_month_spending": round(current_spending, 2),
|
||||||
|
"spending_trend": trend,
|
||||||
|
"frequently_overspent_categories": frequently_overspent,
|
||||||
|
"recommendations": recommendations,
|
||||||
|
"monthly_breakdown": monthly_data,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user