Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dcb0f91d0 | |||
| cd94f98c63 | |||
| 01969b2d24 | |||
| add9946bc2 | |||
| 9bccac82f3 | |||
| 489066940d | |||
| 467e752629 | |||
| f5203e0466 | |||
| 3ba93c55f4 | |||
| a693874662 | |||
| 846477075e | |||
| 1e753bfaab | |||
| 20576cabf3 | |||
| 02dd3df1f9 | |||
| 33f19e704c | |||
| 8eee58de60 | |||
| 98c47d5507 | |||
| 9a149cdaa6 | |||
| 00c9b44c0e | |||
| 73e952c617 | |||
| 9f51dc3cdb | |||
| 1e6bc536b4 | |||
| 869de1c250 | |||
| 2cd77c68c1 | |||
| ad5b889bf1 | |||
| 75e6b09464 | |||
| 47238f8567 | |||
| 5e0e2994c2 | |||
| 9629bfcef4 | |||
| b4097730ef | |||
| abb06b78e2 | |||
| 92171cbfb6 | |||
| 8e884b5e76 | |||
| ed973357e8 | |||
| 4ac0754ea7 | |||
| db977270a3 | |||
| bac773ae4b | |||
| 564a9b68a5 | |||
| 7742673cc0 | |||
| c157c37cde | |||
| 3b8fa3e7a0 | |||
| 438399646f | |||
| 9ed4ca126a | |||
| f3ae76ce68 | |||
| 7ee3bdef84 | |||
| 500c44feb1 | |||
| 896501deb1 | |||
| c95800e65d | |||
| 90372a6a6d | |||
| c01764243f | |||
| dfaac4caf8 | |||
| 17c3a2f888 | |||
| fa0f68e3b4 | |||
| a6c698c6bd | |||
| 07c272c96a | |||
| 975a337af4 | |||
| e644def141 | |||
| 3671926430 |
+37
-6
@@ -19,10 +19,11 @@ BASE_URL=192.168.1.5:8000
|
|||||||
LLAMA_SERVER_URL=http://192.168.1.213:8080/v1
|
LLAMA_SERVER_URL=http://192.168.1.213:8080/v1
|
||||||
LLAMA_MODEL_NAME=llama-3.1-8b-instruct
|
LLAMA_MODEL_NAME=llama-3.1-8b-instruct
|
||||||
|
|
||||||
# ChromaDB Configuration
|
# Embedding Server Configuration
|
||||||
# For Docker: This is automatically set to /app/data/chromadb
|
# If set, uses a custom OpenAI-compatible embedding server (e.g. llama-server)
|
||||||
# For local development: Set to a local directory path
|
# Falls back to OpenAI embeddings if not set
|
||||||
CHROMADB_PATH=./data/chromadb
|
EMBEDDING_SERVER_URL=http://192.168.1.7:8086/v1
|
||||||
|
EMBEDDING_MODEL_NAME=all-minilm
|
||||||
|
|
||||||
# OpenAI Configuration
|
# OpenAI Configuration
|
||||||
OPENAI_API_KEY=your-openai-api-key
|
OPENAI_API_KEY=your-openai-api-key
|
||||||
@@ -92,9 +93,39 @@ EMAIL_HMAC_SECRET=
|
|||||||
# Set to false to disable Mailgun signature validation in development
|
# Set to false to disable Mailgun signature validation in development
|
||||||
MAILGUN_SIGNATURE_VALIDATION=true
|
MAILGUN_SIGNATURE_VALIDATION=true
|
||||||
|
|
||||||
|
# SendBlue Configuration (iMessage)
|
||||||
|
SENDBLUE_API_KEY=your-sendblue-api-key
|
||||||
|
SENDBLUE_API_SECRET=your-sendblue-api-secret
|
||||||
|
SENDBLUE_FROM_NUMBER=+1XXXXXXXXXX
|
||||||
|
SENDBLUE_WEBHOOK_SECRET=your-sendblue-webhook-secret
|
||||||
|
# Set to false to disable SendBlue signature validation in development
|
||||||
|
SENDBLUE_SIGNATURE_VALIDATION=true
|
||||||
|
# Comma-separated list of iMessage numbers allowed to use the service (E.164 format)
|
||||||
|
# Use * to allow any number
|
||||||
|
ALLOWED_IMESSAGE_NUMBERS=
|
||||||
|
# Rate limiting: max messages per window (default: 10 messages per 60 seconds)
|
||||||
|
# IMESSAGE_RATE_LIMIT_MAX=10
|
||||||
|
# IMESSAGE_RATE_LIMIT_WINDOW=60
|
||||||
|
|
||||||
|
# Google Calendar Configuration (via gws CLI)
|
||||||
|
GOOGLE_CALENDAR_ENABLED=true
|
||||||
|
# Export credentials: gws auth export --unmasked > credentials.json
|
||||||
|
# The file is mounted into the container at /app/config/gws-credentials.json
|
||||||
|
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/app/config/gws-credentials.json
|
||||||
|
|
||||||
|
# SimbaKit / PetKit Configuration
|
||||||
|
# PetKit account credentials (used by SimbaKit to poll device data)
|
||||||
|
PETKIT_USERNAME=your-petkit-email
|
||||||
|
PETKIT_PASSWORD=your-petkit-password
|
||||||
|
PETKIT_REGION=US
|
||||||
|
PETKIT_TIMEZONE=America/New_York
|
||||||
|
# Internal URL for SimbaKit service (Docker network)
|
||||||
|
SIMBAKIT_URL=http://simbakit:8585
|
||||||
|
|
||||||
# Obsidian Configuration (headless sync)
|
# Obsidian Configuration (headless sync)
|
||||||
# Auth token from Obsidian account (Settings → Account → API token)
|
# Obsidian account credentials (used for `ob login` on container startup)
|
||||||
OBSIDIAN_AUTH_TOKEN=your-obsidian-auth-token
|
OBSIDIAN_EMAIL=your-obsidian-email
|
||||||
|
OBSIDIAN_PASSWORD=your-obsidian-password
|
||||||
# Vault ID to sync (found in Obsidian sync settings)
|
# Vault ID to sync (found in Obsidian sync settings)
|
||||||
OBSIDIAN_VAULT_ID=your-vault-id
|
OBSIDIAN_VAULT_ID=your-vault-id
|
||||||
# End-to-end encryption password (if vault uses E2E encryption)
|
# End-to-end encryption password (if vault uses E2E encryption)
|
||||||
|
|||||||
+1
-3
@@ -11,11 +11,9 @@ wheels/
|
|||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
credentials.json
|
||||||
|
|
||||||
# Database files
|
# Database files
|
||||||
chromadb/
|
|
||||||
chromadb_openai/
|
|
||||||
chroma_db/
|
|
||||||
database/
|
database/
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## 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.
|
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 PostgreSQL via pgvector, and uses LLMs (Ollama or OpenAI) to answer questions.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -54,9 +54,8 @@ docker compose up -d
|
|||||||
│ Docker Compose │
|
│ Docker Compose │
|
||||||
├─────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────┤
|
||||||
│ raggr (port 8080) │ postgres (port 5432) │
|
│ raggr (port 8080) │ postgres (port 5432) │
|
||||||
│ ├── Quart backend │ PostgreSQL 16 │
|
│ ├── Quart backend │ PostgreSQL 16 + pgvector│
|
||||||
│ ├── React frontend (served) │ │
|
│ └── React frontend (served) │ │
|
||||||
│ └── ChromaDB (volume) │ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -8,7 +8,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
curl \
|
curl \
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
&& apt-get install -y nodejs \
|
&& apt-get install -y nodejs \
|
||||||
&& npm install -g yarn obsidian-headless \
|
&& npm install -g yarn obsidian-headless @googleworkspace/cli \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh
|
&& curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
@@ -37,15 +37,14 @@ WORKDIR /app/raggr-frontend
|
|||||||
RUN yarn install && yarn build
|
RUN yarn install && yarn build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create ChromaDB and database directories
|
# Create database directory
|
||||||
RUN mkdir -p /app/chromadb /app/database
|
RUN mkdir -p /app/database
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
ENV CHROMADB_PATH=/app/chromadb
|
|
||||||
|
|
||||||
# Run the startup script
|
# Run the startup script
|
||||||
CMD ["./startup.sh"]
|
CMD ["./startup.sh"]
|
||||||
|
|||||||
+2
-3
@@ -34,16 +34,15 @@ COPY . .
|
|||||||
WORKDIR /app/raggr-frontend
|
WORKDIR /app/raggr-frontend
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
# Create ChromaDB and database directories
|
# Create database directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN mkdir -p /app/chromadb /app/database
|
RUN mkdir -p /app/database
|
||||||
|
|
||||||
# Make startup script executable
|
# Make startup script executable
|
||||||
RUN chmod +x /app/startup-dev.sh
|
RUN chmod +x /app/startup-dev.sh
|
||||||
|
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
ENV CHROMADB_PATH=/app/chromadb
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
.PHONY: deploy build up down restart logs migrate migrate-new frontend test
|
.PHONY: deploy redeploy build up down restart logs migrate migrate-new frontend test
|
||||||
|
|
||||||
# Build and deploy
|
# Build and deploy
|
||||||
deploy: build up
|
deploy: build up
|
||||||
|
|
||||||
|
redeploy:
|
||||||
|
git pull && $(MAKE) down && $(MAKE) up
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker compose build raggr
|
docker compose build raggr
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from quart import Quart, jsonify, render_template, request, send_from_directory
|
from quart import Quart, jsonify, render_template, send_from_directory
|
||||||
from quart_jwt_extended import JWTManager, get_jwt_identity, jwt_refresh_token_required
|
from quart_jwt_extended import JWTManager, get_jwt_identity, jwt_refresh_token_required
|
||||||
from tortoise import Tortoise
|
from tortoise import Tortoise
|
||||||
|
|
||||||
@@ -12,9 +14,10 @@ import blueprints.email
|
|||||||
import blueprints.rag
|
import blueprints.rag
|
||||||
import blueprints.users
|
import blueprints.users
|
||||||
import blueprints.whatsapp
|
import blueprints.whatsapp
|
||||||
|
import blueprints.imessage
|
||||||
|
import blueprints.scheduled_messages
|
||||||
import blueprints.users.models
|
import blueprints.users.models
|
||||||
from config.db import TORTOISE_CONFIG
|
from config.db import TORTOISE_CONFIG
|
||||||
from main import consult_simba_oracle
|
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -38,6 +41,8 @@ app = Quart(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "SECRET_KEY")
|
app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "SECRET_KEY")
|
||||||
|
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
|
||||||
|
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
|
||||||
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB upload limit
|
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB upload limit
|
||||||
jwt = JWTManager(app)
|
jwt = JWTManager(app)
|
||||||
|
|
||||||
@@ -47,6 +52,29 @@ app.register_blueprint(blueprints.conversation.conversation_blueprint)
|
|||||||
app.register_blueprint(blueprints.email.email_blueprint)
|
app.register_blueprint(blueprints.email.email_blueprint)
|
||||||
app.register_blueprint(blueprints.rag.rag_blueprint)
|
app.register_blueprint(blueprints.rag.rag_blueprint)
|
||||||
app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint)
|
app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint)
|
||||||
|
app.register_blueprint(blueprints.imessage.imessage_blueprint)
|
||||||
|
app.register_blueprint(blueprints.scheduled_messages.scheduled_messages_blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
async def _obsidian_sync_loop():
|
||||||
|
"""Background task that incrementally syncs Obsidian documents to pgvector."""
|
||||||
|
from blueprints.rag.logic import sync_obsidian_documents
|
||||||
|
|
||||||
|
interval = int(os.getenv("OBSIDIAN_SYNC_INTERVAL", "60"))
|
||||||
|
logger = logging.getLogger("obsidian_sync")
|
||||||
|
logger.info(f"Obsidian sync watcher started (interval={interval}s)")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
result = await sync_obsidian_documents()
|
||||||
|
if result["added"] or result["updated"] or result["deleted"]:
|
||||||
|
logger.info(
|
||||||
|
f"Obsidian sync: {result['added']} added, "
|
||||||
|
f"{result['updated']} updated, {result['deleted']} deleted"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Obsidian sync error")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
# Initialize Tortoise ORM with lifecycle hooks
|
# Initialize Tortoise ORM with lifecycle hooks
|
||||||
@@ -55,7 +83,21 @@ async def lifespan():
|
|||||||
logging.info("Initializing Tortoise ORM...")
|
logging.info("Initializing Tortoise ORM...")
|
||||||
await Tortoise.init(config=TORTOISE_CONFIG)
|
await Tortoise.init(config=TORTOISE_CONFIG)
|
||||||
logging.info("Tortoise ORM initialized successfully")
|
logging.info("Tortoise ORM initialized successfully")
|
||||||
|
|
||||||
|
watcher_task = None
|
||||||
|
if os.getenv("OBSIDIAN_CONTINUOUS_SYNC") == "true":
|
||||||
|
watcher_task = asyncio.create_task(_obsidian_sync_loop())
|
||||||
|
|
||||||
|
from blueprints.scheduled_messages.scheduler import scheduled_messages_loop
|
||||||
|
|
||||||
|
scheduler_task = asyncio.create_task(scheduled_messages_loop())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
scheduler_task.cancel()
|
||||||
|
if watcher_task is not None:
|
||||||
|
watcher_task.cancel()
|
||||||
|
|
||||||
logging.info("Closing Tortoise ORM connections...")
|
logging.info("Closing Tortoise ORM connections...")
|
||||||
await Tortoise.close_connections()
|
await Tortoise.close_connections()
|
||||||
|
|
||||||
@@ -66,48 +108,42 @@ async def static_files(filename):
|
|||||||
return await send_from_directory(app.static_folder, filename)
|
return await send_from_directory(app.static_folder, filename)
|
||||||
|
|
||||||
|
|
||||||
|
# Allowed file extensions for static frontend assets
|
||||||
|
ALLOWED_STATIC_EXTENSIONS = {
|
||||||
|
".html",
|
||||||
|
".css",
|
||||||
|
".js",
|
||||||
|
".svg",
|
||||||
|
".png",
|
||||||
|
".ico",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".webp",
|
||||||
|
".woff",
|
||||||
|
".woff2",
|
||||||
|
".ttf",
|
||||||
|
".txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
# JSON files explicitly allowed to be served (e.g. PWA manifest)
|
||||||
|
ALLOWED_JSON_FILES = {"manifest.json"}
|
||||||
|
|
||||||
|
|
||||||
# Serve the React app for all routes (catch-all)
|
# Serve the React app for all routes (catch-all)
|
||||||
@app.route("/", defaults={"path": ""})
|
@app.route("/", defaults={"path": ""})
|
||||||
@app.route("/<path:path>")
|
@app.route("/<path:path>")
|
||||||
async def serve_react_app(path):
|
async def serve_react_app(path):
|
||||||
if path and os.path.exists(os.path.join(app.template_folder, path)):
|
if path:
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
basename = os.path.basename(path)
|
||||||
|
allowed = ext in ALLOWED_STATIC_EXTENSIONS or (
|
||||||
|
ext == ".json" and basename in ALLOWED_JSON_FILES
|
||||||
|
)
|
||||||
|
if allowed and os.path.exists(os.path.join(app.template_folder, path)):
|
||||||
return await send_from_directory(app.template_folder, path)
|
return await send_from_directory(app.template_folder, path)
|
||||||
return await render_template("index.html")
|
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"])
|
@app.route("/api/messages", methods=["GET"])
|
||||||
@jwt_refresh_token_required
|
@jwt_refresh_token_required
|
||||||
async def get_messages():
|
async def get_messages():
|
||||||
@@ -132,17 +168,10 @@ async def get_messages():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
name = conversation.name
|
|
||||||
if len(messages) > 8:
|
|
||||||
name = await blueprints.conversation.logic.rename_conversation(
|
|
||||||
user=user,
|
|
||||||
conversation=conversation,
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"id": str(conversation.id),
|
"id": str(conversation.id),
|
||||||
"name": name,
|
"name": conversation.name,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"created_at": conversation.created_at.isoformat(),
|
"created_at": conversation.created_at.isoformat(),
|
||||||
"updated_at": conversation.updated_at.isoformat(),
|
"updated_at": conversation.updated_at.isoformat(),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import datetime
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
@@ -20,8 +19,8 @@ from .agents import main_agent
|
|||||||
from .logic import (
|
from .logic import (
|
||||||
add_message_to_conversation,
|
add_message_to_conversation,
|
||||||
get_conversation_by_id,
|
get_conversation_by_id,
|
||||||
rename_conversation,
|
|
||||||
)
|
)
|
||||||
|
from .memory import get_memories_for_user
|
||||||
from .models import (
|
from .models import (
|
||||||
Conversation,
|
Conversation,
|
||||||
PydConversation,
|
PydConversation,
|
||||||
@@ -36,15 +35,27 @@ conversation_blueprint = Blueprint(
|
|||||||
_SYSTEM_PROMPT = SIMBA_SYSTEM_PROMPT
|
_SYSTEM_PROMPT = SIMBA_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_system_prompt_with_memories(user_id: str) -> str:
|
||||||
|
"""Append user memories to the base system prompt."""
|
||||||
|
memories = await get_memories_for_user(user_id)
|
||||||
|
if not memories:
|
||||||
|
return _SYSTEM_PROMPT
|
||||||
|
memory_block = "\n".join(f"- {m}" for m in memories)
|
||||||
|
return f"{_SYSTEM_PROMPT}\n\nUSER MEMORIES (facts the user has asked you to remember):\n{memory_block}"
|
||||||
|
|
||||||
|
|
||||||
def _build_messages_payload(
|
def _build_messages_payload(
|
||||||
conversation, query_text: str, image_description: str | None = None
|
conversation,
|
||||||
|
query_text: str,
|
||||||
|
image_description: str | None = None,
|
||||||
|
system_prompt: str | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
recent_messages = (
|
recent_messages = (
|
||||||
conversation.messages[-10:]
|
conversation.messages[-10:]
|
||||||
if len(conversation.messages) > 10
|
if len(conversation.messages) > 10
|
||||||
else conversation.messages
|
else conversation.messages
|
||||||
)
|
)
|
||||||
messages_payload = [{"role": "system", "content": _SYSTEM_PROMPT}]
|
messages_payload = [{"role": "system", "content": system_prompt or _SYSTEM_PROMPT}]
|
||||||
for msg in recent_messages[:-1]: # Exclude the message we just added
|
for msg in recent_messages[:-1]: # Exclude the message we just added
|
||||||
role = "user" if msg.speaker == "user" else "assistant"
|
role = "user" if msg.speaker == "user" else "assistant"
|
||||||
text = msg.text
|
text = msg.text
|
||||||
@@ -80,10 +91,16 @@ async def query():
|
|||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
messages_payload = _build_messages_payload(conversation, query)
|
system_prompt = await _build_system_prompt_with_memories(str(user.id))
|
||||||
|
messages_payload = _build_messages_payload(
|
||||||
|
conversation, query, system_prompt=system_prompt
|
||||||
|
)
|
||||||
payload = {"messages": messages_payload}
|
payload = {"messages": messages_payload}
|
||||||
|
agent_config = {
|
||||||
|
"configurable": {"user_id": str(user.id), "is_admin": user.is_admin()}
|
||||||
|
}
|
||||||
|
|
||||||
response = await main_agent.ainvoke(payload)
|
response = await main_agent.ainvoke(payload, config=agent_config)
|
||||||
message = response.get("messages", [])[-1].content
|
message = response.get("messages", [])[-1].content
|
||||||
await add_message_to_conversation(
|
await add_message_to_conversation(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
@@ -163,15 +180,21 @@ async def stream_query():
|
|||||||
logging.error(f"Failed to analyze image: {e}")
|
logging.error(f"Failed to analyze image: {e}")
|
||||||
image_description = "[Image could not be analyzed]"
|
image_description = "[Image could not be analyzed]"
|
||||||
|
|
||||||
|
system_prompt = await _build_system_prompt_with_memories(str(user.id))
|
||||||
messages_payload = _build_messages_payload(
|
messages_payload = _build_messages_payload(
|
||||||
conversation, query_text or "", image_description
|
conversation, query_text or "", image_description, system_prompt=system_prompt
|
||||||
)
|
)
|
||||||
payload = {"messages": messages_payload}
|
payload = {"messages": messages_payload}
|
||||||
|
agent_config = {
|
||||||
|
"configurable": {"user_id": str(user.id), "is_admin": user.is_admin()}
|
||||||
|
}
|
||||||
|
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
final_message = None
|
final_message = None
|
||||||
try:
|
try:
|
||||||
async for event in main_agent.astream_events(payload, version="v2"):
|
async for event in main_agent.astream_events(
|
||||||
|
payload, version="v2", config=agent_config
|
||||||
|
):
|
||||||
event_type = event.get("event")
|
event_type = event.get("event")
|
||||||
|
|
||||||
if event_type == "on_tool_start":
|
if event_type == "on_tool_start":
|
||||||
@@ -221,8 +244,6 @@ async def stream_query():
|
|||||||
@jwt_refresh_token_required
|
@jwt_refresh_token_required
|
||||||
async def get_conversation(conversation_id: str):
|
async def get_conversation(conversation_id: str):
|
||||||
conversation = await Conversation.get(id=conversation_id)
|
conversation = await Conversation.get(id=conversation_id)
|
||||||
current_user_uuid = get_jwt_identity()
|
|
||||||
user = await blueprints.users.models.User.get(id=current_user_uuid)
|
|
||||||
await conversation.fetch_related("messages")
|
await conversation.fetch_related("messages")
|
||||||
|
|
||||||
# Manually serialize the conversation with messages
|
# Manually serialize the conversation with messages
|
||||||
@@ -237,18 +258,10 @@ async def get_conversation(conversation_id: str):
|
|||||||
"image_key": msg.image_key,
|
"image_key": msg.image_key,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
name = conversation.name
|
|
||||||
if len(messages) > 8 and "datetime" in name.lower():
|
|
||||||
name = await rename_conversation(
|
|
||||||
user=user,
|
|
||||||
conversation=conversation,
|
|
||||||
)
|
|
||||||
print(name)
|
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"id": str(conversation.id),
|
"id": str(conversation.id),
|
||||||
"name": name,
|
"name": conversation.name,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"created_at": conversation.created_at.isoformat(),
|
"created_at": conversation.created_at.isoformat(),
|
||||||
"updated_at": conversation.updated_at.isoformat(),
|
"updated_at": conversation.updated_at.isoformat(),
|
||||||
@@ -262,7 +275,7 @@ async def create_conversation():
|
|||||||
user_uuid = get_jwt_identity()
|
user_uuid = get_jwt_identity()
|
||||||
user = await blueprints.users.models.User.get(id=user_uuid)
|
user = await blueprints.users.models.User.get(id=user_uuid)
|
||||||
conversation = await Conversation.create(
|
conversation = await Conversation.create(
|
||||||
name=f"{user.username} {datetime.datetime.now().timestamp}",
|
name="New Conversation",
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -275,7 +288,7 @@ async def create_conversation():
|
|||||||
async def get_all_conversations():
|
async def get_all_conversations():
|
||||||
user_uuid = get_jwt_identity()
|
user_uuid = get_jwt_identity()
|
||||||
user = await blueprints.users.models.User.get(id=user_uuid)
|
user = await blueprints.users.models.User.get(id=user_uuid)
|
||||||
conversations = Conversation.filter(user=user)
|
conversations = Conversation.filter(user=user).order_by("-updated_at")
|
||||||
serialized_conversations = await PydListConversation.from_queryset(conversations)
|
serialized_conversations = await PydListConversation.from_queryset(conversations)
|
||||||
|
|
||||||
return jsonify(serialized_conversations.model_dump())
|
return jsonify(serialized_conversations.model_dump())
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ from dotenv import load_dotenv
|
|||||||
from langchain.agents import create_agent
|
from langchain.agents import create_agent
|
||||||
from langchain.chat_models import BaseChatModel
|
from langchain.chat_models import BaseChatModel
|
||||||
from langchain.tools import tool
|
from langchain.tools import tool
|
||||||
|
from langchain_core.runnables import RunnableConfig
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from tavily import AsyncTavilyClient
|
from tavily import AsyncTavilyClient
|
||||||
|
|
||||||
|
from blueprints.conversation.memory import save_memory
|
||||||
from blueprints.rag.logic import query_vector_store
|
from blueprints.rag.logic import query_vector_store
|
||||||
|
from utils.mealie_service import MealieService
|
||||||
from utils.obsidian_service import ObsidianService
|
from utils.obsidian_service import ObsidianService
|
||||||
|
from utils.simbakit_service import SimbaKitService
|
||||||
from utils.ynab_service import YNABService
|
from utils.ynab_service import YNABService
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
@@ -41,6 +45,22 @@ except (ValueError, Exception) as e:
|
|||||||
print(f"YNAB service not initialized: {e}")
|
print(f"YNAB service not initialized: {e}")
|
||||||
ynab_enabled = False
|
ynab_enabled = False
|
||||||
|
|
||||||
|
# Initialize SimbaKit service (will only work if SIMBAKIT_URL is set)
|
||||||
|
try:
|
||||||
|
simbakit_service = SimbaKitService()
|
||||||
|
simbakit_enabled = True
|
||||||
|
except (ValueError, Exception) as e:
|
||||||
|
print(f"SimbaKit service not initialized: {e}")
|
||||||
|
simbakit_enabled = False
|
||||||
|
|
||||||
|
# Initialize Mealie service (will only work if MEALIE_BASE_URL is set)
|
||||||
|
try:
|
||||||
|
mealie_service = MealieService()
|
||||||
|
mealie_enabled = True
|
||||||
|
except (ValueError, Exception) as e:
|
||||||
|
print(f"Mealie service not initialized: {e}")
|
||||||
|
mealie_enabled = False
|
||||||
|
|
||||||
# Initialize Obsidian service (will only work if OBSIDIAN_VAULT_PATH is set)
|
# Initialize Obsidian service (will only work if OBSIDIAN_VAULT_PATH is set)
|
||||||
try:
|
try:
|
||||||
obsidian_service = ObsidianService()
|
obsidian_service = ObsidianService()
|
||||||
@@ -63,9 +83,10 @@ def get_current_date() -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Today's date in YYYY-MM-DD format
|
Today's date in YYYY-MM-DD format
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
return date.today().isoformat()
|
return datetime.now(ZoneInfo("America/New_York")).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -118,7 +139,7 @@ async def simba_search(query: str):
|
|||||||
Relevant information from Simba's documents
|
Relevant information from Simba's documents
|
||||||
"""
|
"""
|
||||||
print(f"[SIMBA SEARCH] Tool called with query: {query}")
|
print(f"[SIMBA SEARCH] Tool called with query: {query}")
|
||||||
serialized, docs = await query_vector_store(query=query)
|
serialized, docs = await query_vector_store(query=query, source="paperless")
|
||||||
print(f"[SIMBA SEARCH] Found {len(docs)} documents")
|
print(f"[SIMBA SEARCH] Found {len(docs)} documents")
|
||||||
print(f"[SIMBA SEARCH] Serialized result length: {len(serialized)}")
|
print(f"[SIMBA SEARCH] Serialized result length: {len(serialized)}")
|
||||||
print(f"[SIMBA SEARCH] First 200 chars: {serialized[:200]}")
|
print(f"[SIMBA SEARCH] First 200 chars: {serialized[:200]}")
|
||||||
@@ -306,6 +327,223 @@ def ynab_insights(months_back: int = 3) -> str:
|
|||||||
return f"Error generating insights: {str(e)}"
|
return f"Error generating insights: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def mealie_todays_meals() -> str:
|
||||||
|
"""Get today's meal plan from Mealie.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- What's for dinner/lunch/breakfast today
|
||||||
|
- Today's meal plan
|
||||||
|
- What meals are planned for today
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Today's planned meals with recipe names and meal types.
|
||||||
|
"""
|
||||||
|
if not mealie_enabled:
|
||||||
|
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await mealie_service.get_todays_meals()
|
||||||
|
|
||||||
|
if result["total_meals"] == 0:
|
||||||
|
return f"No meals planned for today ({result['today']})."
|
||||||
|
|
||||||
|
lines = [f"Meals planned for today ({result['today']}):"]
|
||||||
|
for meal in result["meals"]:
|
||||||
|
name = meal["recipe_name"] or meal["title"] or "Untitled"
|
||||||
|
lines.append(f"- {meal['entry_type'].capitalize()}: {name}")
|
||||||
|
if meal["note"]:
|
||||||
|
lines.append(f" Note: {meal['note']}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching today's meals: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
|
||||||
|
"""Get meal plans for a date range from Mealie.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- Meal plans for this week or a specific date range
|
||||||
|
- What's planned for upcoming meals
|
||||||
|
- Weekly meal schedule
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date in YYYY-MM-DD format (optional, defaults to today)
|
||||||
|
end_date: End date in YYYY-MM-DD format (optional, defaults to 7 days from start)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Meal plans organized by date.
|
||||||
|
"""
|
||||||
|
if not mealie_enabled:
|
||||||
|
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await mealie_service.get_meal_plans(
|
||||||
|
start_date=start_date or None,
|
||||||
|
end_date=end_date or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["total_plans"] == 0:
|
||||||
|
return f"No meal plans found from {result['start_date']} to {result['end_date']}."
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Meal plans from {result['start_date']} to {result['end_date']} ({result['total_plans']} meals):"
|
||||||
|
]
|
||||||
|
for date, plans in sorted(result["plans_by_date"].items()):
|
||||||
|
lines.append(f"\n{date}:")
|
||||||
|
for plan in plans:
|
||||||
|
name = plan["recipe_name"] or plan["title"] or "Untitled"
|
||||||
|
lines.append(f" - {plan['entry_type'].capitalize()}: {name}")
|
||||||
|
if plan["note"]:
|
||||||
|
lines.append(f" Note: {plan['note']}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching meal plans: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def mealie_get_recipe(recipe_slug: str) -> str:
|
||||||
|
"""Get full recipe details from Mealie including ingredients and instructions.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- Recipe details, ingredients, or instructions
|
||||||
|
- How to make a specific recipe
|
||||||
|
- What ingredients are needed for a meal
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_slug: The recipe's slug identifier (from meal plan data)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full recipe with ingredients, instructions, prep/cook times, and servings.
|
||||||
|
"""
|
||||||
|
if not mealie_enabled:
|
||||||
|
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe = await mealie_service.get_recipe(recipe_slug)
|
||||||
|
|
||||||
|
lines = [f"**{recipe['name']}**"]
|
||||||
|
if recipe["description"]:
|
||||||
|
lines.append(recipe["description"])
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if recipe["servings"]:
|
||||||
|
lines.append(f"Servings: {recipe['servings']}")
|
||||||
|
times = []
|
||||||
|
if recipe["prep_time"]:
|
||||||
|
times.append(f"Prep: {recipe['prep_time']}")
|
||||||
|
if recipe["cook_time"]:
|
||||||
|
times.append(f"Cook: {recipe['cook_time']}")
|
||||||
|
if recipe["total_time"]:
|
||||||
|
times.append(f"Total: {recipe['total_time']}")
|
||||||
|
if times:
|
||||||
|
lines.append(" | ".join(times))
|
||||||
|
|
||||||
|
if recipe["ingredients"]:
|
||||||
|
lines.append("\nIngredients:")
|
||||||
|
for ing in recipe["ingredients"]:
|
||||||
|
lines.append(f"- {ing['display']}")
|
||||||
|
|
||||||
|
if recipe["instructions"]:
|
||||||
|
lines.append("\nInstructions:")
|
||||||
|
for i, step in enumerate(recipe["instructions"], 1):
|
||||||
|
text = step.get("text", "")
|
||||||
|
lines.append(f"{i}. {text}")
|
||||||
|
|
||||||
|
if recipe["tags"]:
|
||||||
|
lines.append(f"\nTags: {', '.join(recipe['tags'])}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching recipe: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def mealie_shopping_lists() -> str:
|
||||||
|
"""Get shopping lists and their items from Mealie.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- What's on the shopping list
|
||||||
|
- What groceries are needed
|
||||||
|
- Shopping list items
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Shopping lists with unchecked (needed) and checked (done) items.
|
||||||
|
"""
|
||||||
|
if not mealie_enabled:
|
||||||
|
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await mealie_service.get_shopping_lists()
|
||||||
|
|
||||||
|
if result["total_lists"] == 0:
|
||||||
|
return "No shopping lists found."
|
||||||
|
|
||||||
|
lines = [f"Shopping lists ({result['total_unchecked_items']} items needed):"]
|
||||||
|
for sl in result["shopping_lists"]:
|
||||||
|
lines.append(
|
||||||
|
f"\n**{sl['name']}** ({len(sl['unchecked_items'])} items needed)"
|
||||||
|
)
|
||||||
|
if sl["unchecked_items"]:
|
||||||
|
for item in sl["unchecked_items"]:
|
||||||
|
qty = (
|
||||||
|
f" x{item['quantity']}"
|
||||||
|
if item["quantity"] and item["quantity"] != 1
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
lines.append(f" - [ ] {item['name']}{qty}")
|
||||||
|
if sl["checked_items"]:
|
||||||
|
lines.append(
|
||||||
|
f" ({len(sl['checked_items'])} items already checked off)"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching shopping lists: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def mealie_create_meal_plan(
|
||||||
|
date: str,
|
||||||
|
entry_type: str = "dinner",
|
||||||
|
recipe_slug: str = "",
|
||||||
|
title: str = "",
|
||||||
|
note: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Create a new meal plan entry in Mealie.
|
||||||
|
|
||||||
|
Use this tool when the user wants to:
|
||||||
|
- Add a meal to the meal plan
|
||||||
|
- Plan dinner/lunch/breakfast for a specific day
|
||||||
|
- Schedule a recipe for a date
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: Date in YYYY-MM-DD format
|
||||||
|
entry_type: Type of meal - breakfast, lunch, dinner, or side (default: dinner)
|
||||||
|
recipe_slug: Recipe slug to add (optional)
|
||||||
|
title: Custom title if no recipe (optional)
|
||||||
|
note: Additional notes (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation of the created meal plan entry.
|
||||||
|
"""
|
||||||
|
if not mealie_enabled:
|
||||||
|
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await mealie_service.create_meal_plan(
|
||||||
|
date=date,
|
||||||
|
entry_type=entry_type,
|
||||||
|
recipe_slug=recipe_slug or None,
|
||||||
|
title=title or None,
|
||||||
|
note=note or None,
|
||||||
|
)
|
||||||
|
name = result.get("recipe_name") or result.get("title") or "meal"
|
||||||
|
return f"Added {result['entry_type']} to {result['date']}: {name}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error creating meal plan: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
async def obsidian_search_notes(query: str) -> str:
|
async def obsidian_search_notes(query: str) -> str:
|
||||||
"""Search through Obsidian vault notes for information.
|
"""Search through Obsidian vault notes for information.
|
||||||
@@ -326,8 +564,8 @@ async def obsidian_search_notes(query: str) -> str:
|
|||||||
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Query ChromaDB for obsidian documents
|
# Query vector store filtered to obsidian source only
|
||||||
serialized, docs = await query_vector_store(query=query)
|
serialized, docs = await query_vector_store(query=query, source="obsidian")
|
||||||
return serialized
|
return serialized
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -589,8 +827,224 @@ async def obsidian_create_task(
|
|||||||
return f"Error creating task: {str(e)}"
|
return f"Error creating task: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def save_user_memory(content: str, config: RunnableConfig) -> str:
|
||||||
|
"""Save a fact or preference about the user for future conversations.
|
||||||
|
|
||||||
|
Use this tool when the user:
|
||||||
|
- Explicitly asks you to remember something ("remember that...", "keep in mind...")
|
||||||
|
- Shares a personal preference that would be useful in future conversations
|
||||||
|
(e.g., "I prefer metric units", "my cat's name is Luna")
|
||||||
|
- Tells you a meaningful personal fact (e.g., "I'm allergic to peanuts")
|
||||||
|
|
||||||
|
Do NOT save:
|
||||||
|
- Trivial or ephemeral info (e.g., "I'm tired today")
|
||||||
|
- Information already in the system prompt or documents
|
||||||
|
- Conversation-specific context that won't matter later
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: A concise statement of the fact or preference to remember.
|
||||||
|
Write it as a standalone sentence (e.g., "User prefers dark mode"
|
||||||
|
rather than "likes dark mode").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation that the memory was saved.
|
||||||
|
"""
|
||||||
|
user_id = config["configurable"]["user_id"]
|
||||||
|
return await save_memory(user_id=user_id, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def get_calendar_events(
|
||||||
|
time_range: str = "today",
|
||||||
|
days: int = 0,
|
||||||
|
calendar_id: str = "primary",
|
||||||
|
*,
|
||||||
|
config: RunnableConfig,
|
||||||
|
) -> str:
|
||||||
|
"""Get upcoming Google Calendar events including all-day events.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- What's on their calendar today or this week
|
||||||
|
- Upcoming meetings or events
|
||||||
|
- Scheduling or availability questions
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_range: One of "today", "tomorrow", or "week" (default: "today")
|
||||||
|
days: If set to a positive number, show events for this many upcoming days
|
||||||
|
(overrides time_range)
|
||||||
|
calendar_id: Calendar ID to query (default: "primary")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Calendar events as JSON
|
||||||
|
"""
|
||||||
|
if not config["configurable"].get("is_admin"):
|
||||||
|
return "Calendar access is restricted to admin users."
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
tz = ZoneInfo("America/New_York")
|
||||||
|
now = datetime.now(tz)
|
||||||
|
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
if days > 0:
|
||||||
|
end = start + timedelta(days=days)
|
||||||
|
elif time_range == "tomorrow":
|
||||||
|
start = start + timedelta(days=1)
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
elif time_range == "week":
|
||||||
|
end = start + timedelta(days=7)
|
||||||
|
else:
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"gws",
|
||||||
|
"calendar",
|
||||||
|
"events",
|
||||||
|
"list",
|
||||||
|
"--calendarId",
|
||||||
|
calendar_id,
|
||||||
|
"--timeMin",
|
||||||
|
start.isoformat(),
|
||||||
|
"--timeMax",
|
||||||
|
end.isoformat(),
|
||||||
|
"--singleEvents",
|
||||||
|
"true",
|
||||||
|
"--orderBy",
|
||||||
|
"startTime",
|
||||||
|
]
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return f"Calendar error: {stderr.decode()}"
|
||||||
|
return stdout.decode()
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def simbakit_device_status() -> str:
|
||||||
|
"""Get the status of all PetKit smart devices and any active alerts.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- PetKit device statuses or health
|
||||||
|
- Whether any pet devices have issues or alerts
|
||||||
|
- Litter box, water fountain, or feeder device status
|
||||||
|
- Any device errors or warnings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Device statuses and active alerts from all PetKit devices.
|
||||||
|
"""
|
||||||
|
if not simbakit_enabled:
|
||||||
|
return "SimbaKit integration is not configured. Please set SIMBAKIT_URL environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
devices = await simbakit_service.get_devices()
|
||||||
|
alerts = await simbakit_service.get_alerts()
|
||||||
|
return f"{devices}\n\n{alerts}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching device status: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def simbakit_weight_history(pet_name: str = "", days: int = 30) -> str:
|
||||||
|
"""Get pet weight history and trends from PetKit smart litter box.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- How much Simba weighs or pet weight
|
||||||
|
- Weight trends or changes over time
|
||||||
|
- Recent weight measurements
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pet_name: Name of the pet to filter by (optional, defaults to all pets)
|
||||||
|
days: Number of days of history to retrieve (default 30)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Weight records, averages, and trends.
|
||||||
|
"""
|
||||||
|
if not simbakit_enabled:
|
||||||
|
return "SimbaKit integration is not configured. Please set SIMBAKIT_URL environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await simbakit_service.get_pet_weights(
|
||||||
|
pet_name=pet_name or None,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching weight history: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def simbakit_litter_activity(pet_name: str = "", days: int = 7) -> str:
|
||||||
|
"""Get litter box activity (bathroom visits) from PetKit smart litter box.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- Simba's litter box usage or bathroom habits
|
||||||
|
- How often the cat poops or pees
|
||||||
|
- Litter box visit statistics
|
||||||
|
- Recent litter box events
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pet_name: Name of the pet to filter by (optional, defaults to all pets)
|
||||||
|
days: Number of days of history to retrieve (default 7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Litter box visit statistics and recent events.
|
||||||
|
"""
|
||||||
|
if not simbakit_enabled:
|
||||||
|
return "SimbaKit integration is not configured. Please set SIMBAKIT_URL environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await simbakit_service.get_litter_events(
|
||||||
|
pet_name=pet_name or None,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching litter activity: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def simbakit_drinking_activity(pet_name: str = "", days: int = 7) -> str:
|
||||||
|
"""Get water fountain drinking activity from the PetKit smart water fountain.
|
||||||
|
|
||||||
|
Use this tool when the user asks about:
|
||||||
|
- How much water Simba is drinking or hydration
|
||||||
|
- Water fountain usage or visits
|
||||||
|
- Drinking habits, frequency, or trends
|
||||||
|
- Recent drinking sessions
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pet_name: Name of the pet to filter by (optional, defaults to all pets)
|
||||||
|
days: Number of days of history to retrieve (default 7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Drinking session statistics and recent fountain events.
|
||||||
|
"""
|
||||||
|
if not simbakit_enabled:
|
||||||
|
return "SimbaKit integration is not configured. Please set SIMBAKIT_URL environment variable."
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await simbakit_service.get_drinking_events(
|
||||||
|
pet_name=pet_name or None,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error fetching drinking activity: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
# Create tools list based on what's available
|
# Create tools list based on what's available
|
||||||
tools = [get_current_date, simba_search, web_search]
|
tools = [get_current_date, simba_search, web_search, save_user_memory]
|
||||||
|
if simbakit_enabled:
|
||||||
|
tools.extend(
|
||||||
|
[
|
||||||
|
simbakit_device_status,
|
||||||
|
simbakit_weight_history,
|
||||||
|
simbakit_litter_activity,
|
||||||
|
simbakit_drinking_activity,
|
||||||
|
]
|
||||||
|
)
|
||||||
if ynab_enabled:
|
if ynab_enabled:
|
||||||
tools.extend(
|
tools.extend(
|
||||||
[
|
[
|
||||||
@@ -600,6 +1054,16 @@ if ynab_enabled:
|
|||||||
ynab_insights,
|
ynab_insights,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
if mealie_enabled:
|
||||||
|
tools.extend(
|
||||||
|
[
|
||||||
|
mealie_todays_meals,
|
||||||
|
mealie_meal_plans,
|
||||||
|
mealie_get_recipe,
|
||||||
|
mealie_shopping_lists,
|
||||||
|
mealie_create_meal_plan,
|
||||||
|
]
|
||||||
|
)
|
||||||
if obsidian_enabled:
|
if obsidian_enabled:
|
||||||
tools.extend(
|
tools.extend(
|
||||||
[
|
[
|
||||||
@@ -613,6 +1077,8 @@ if obsidian_enabled:
|
|||||||
journal_complete_task,
|
journal_complete_task,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
if os.getenv("GOOGLE_CALENDAR_ENABLED"):
|
||||||
|
tools.append(get_calendar_events)
|
||||||
|
|
||||||
# Llama 3.1 supports native function calling via OpenAI-compatible API
|
# Llama 3.1 supports native function calling via OpenAI-compatible API
|
||||||
main_agent = create_agent(model=model_with_fallback, tools=tools)
|
main_agent = create_agent(model=model_with_fallback, tools=tools)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import tortoise.exceptions
|
import tortoise.exceptions
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
|
|
||||||
import blueprints.users.models
|
import blueprints.users.models
|
||||||
|
|
||||||
from .models import Conversation, ConversationMessage, RenameConversationOutputSchema
|
from .models import Conversation, ConversationMessage
|
||||||
|
|
||||||
|
|
||||||
async def create_conversation(name: str = "") -> Conversation:
|
async def create_conversation(name: str = "") -> Conversation:
|
||||||
@@ -19,6 +18,12 @@ async def add_message_to_conversation(
|
|||||||
image_key: str | None = None,
|
image_key: str | None = None,
|
||||||
) -> ConversationMessage:
|
) -> ConversationMessage:
|
||||||
print(conversation, message, speaker)
|
print(conversation, message, speaker)
|
||||||
|
|
||||||
|
# Name the conversation after the first user message
|
||||||
|
if speaker == "user" and not await conversation.messages.all().exists():
|
||||||
|
conversation.name = message[:100]
|
||||||
|
await conversation.save()
|
||||||
|
|
||||||
message = await ConversationMessage.create(
|
message = await ConversationMessage.create(
|
||||||
text=message,
|
text=message,
|
||||||
speaker=speaker,
|
speaker=speaker,
|
||||||
@@ -42,11 +47,27 @@ async def get_the_only_conversation() -> Conversation:
|
|||||||
|
|
||||||
async def get_conversation_for_user(user: blueprints.users.models.User) -> Conversation:
|
async def get_conversation_for_user(user: blueprints.users.models.User) -> Conversation:
|
||||||
try:
|
try:
|
||||||
return await Conversation.get(user=user)
|
conversation = await Conversation.get(user=user)
|
||||||
|
except tortoise.exceptions.MultipleObjectsReturned:
|
||||||
|
conversation = (
|
||||||
|
await Conversation.filter(user=user).order_by("created_at").first()
|
||||||
|
)
|
||||||
except tortoise.exceptions.DoesNotExist:
|
except tortoise.exceptions.DoesNotExist:
|
||||||
await Conversation.get_or_create(name=f"{user.username}'s chat", user=user)
|
conversation = await Conversation.create(
|
||||||
|
name=f"{user.username}'s chat", user=user
|
||||||
|
)
|
||||||
|
return conversation
|
||||||
|
|
||||||
return await Conversation.get(user=user)
|
|
||||||
|
async def get_conversation_for_channel(
|
||||||
|
user: blueprints.users.models.User, channel: str
|
||||||
|
) -> Conversation:
|
||||||
|
conversation = await Conversation.filter(user=user, channel=channel).first()
|
||||||
|
if conversation is None:
|
||||||
|
conversation = await Conversation.create(
|
||||||
|
name=f"{user.username}'s {channel} chat", user=user, channel=channel
|
||||||
|
)
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
async def get_conversation_by_id(id: str) -> Conversation:
|
async def get_conversation_by_id(id: str) -> Conversation:
|
||||||
@@ -61,22 +82,3 @@ async def get_conversation_transcript(
|
|||||||
messages.append(f"{message.speaker} at {message.created_at}: {message.text}")
|
messages.append(f"{message.speaker} at {message.created_at}: {message.text}")
|
||||||
|
|
||||||
return "\n".join(messages)
|
return "\n".join(messages)
|
||||||
|
|
||||||
|
|
||||||
async def rename_conversation(
|
|
||||||
user: blueprints.users.models.User,
|
|
||||||
conversation: Conversation,
|
|
||||||
) -> str:
|
|
||||||
messages: str = await get_conversation_transcript(
|
|
||||||
user=user, conversation=conversation
|
|
||||||
)
|
|
||||||
|
|
||||||
llm = ChatOpenAI(model="gpt-4o-mini")
|
|
||||||
structured_llm = llm.with_structured_output(RenameConversationOutputSchema)
|
|
||||||
|
|
||||||
prompt = f"Summarize the following conversation into a sassy one-liner title:\n\n{messages}"
|
|
||||||
response = structured_llm.invoke(prompt)
|
|
||||||
new_name: str = response.get("title", "")
|
|
||||||
conversation.name = new_name
|
|
||||||
await conversation.save()
|
|
||||||
return new_name
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from .models import UserMemory
|
||||||
|
|
||||||
|
|
||||||
|
async def get_memories_for_user(user_id: str) -> list[str]:
|
||||||
|
"""Return all memory content strings for a user, ordered by most recently updated."""
|
||||||
|
memories = await UserMemory.filter(user_id=user_id).order_by("-updated_at")
|
||||||
|
return [m.content for m in memories]
|
||||||
|
|
||||||
|
|
||||||
|
async def save_memory(user_id: str, content: str) -> str:
|
||||||
|
"""Save a new memory or touch an existing one (exact-match dedup)."""
|
||||||
|
existing = await UserMemory.filter(user_id=user_id, content=content).first()
|
||||||
|
if existing:
|
||||||
|
existing.updated_at = None # auto_now=True will refresh it on save
|
||||||
|
await existing.save(update_fields=["updated_at"])
|
||||||
|
return "Memory already exists (refreshed)."
|
||||||
|
|
||||||
|
await UserMemory.create(user_id=user_id, content=content)
|
||||||
|
return "Memory saved."
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import enum
|
import enum
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.contrib.pydantic import (
|
from tortoise.contrib.pydantic import (
|
||||||
@@ -9,12 +8,6 @@ from tortoise.contrib.pydantic import (
|
|||||||
from tortoise.models import Model
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RenameConversationOutputSchema:
|
|
||||||
title: str
|
|
||||||
justification: str
|
|
||||||
|
|
||||||
|
|
||||||
class Speaker(enum.Enum):
|
class Speaker(enum.Enum):
|
||||||
USER = "user"
|
USER = "user"
|
||||||
SIMBA = "simba"
|
SIMBA = "simba"
|
||||||
@@ -28,6 +21,7 @@ class Conversation(Model):
|
|||||||
user: fields.ForeignKeyRelation = fields.ForeignKeyField(
|
user: fields.ForeignKeyRelation = fields.ForeignKeyField(
|
||||||
"models.User", related_name="conversations", null=True
|
"models.User", related_name="conversations", null=True
|
||||||
)
|
)
|
||||||
|
channel = fields.CharField(max_length=20, default="web", null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "conversations"
|
table = "conversations"
|
||||||
@@ -47,6 +41,17 @@ class ConversationMessage(Model):
|
|||||||
table = "conversation_messages"
|
table = "conversation_messages"
|
||||||
|
|
||||||
|
|
||||||
|
class UserMemory(Model):
|
||||||
|
id = fields.UUIDField(primary_key=True)
|
||||||
|
user = fields.ForeignKeyField("models.User", related_name="memories")
|
||||||
|
content = fields.TextField()
|
||||||
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "user_memories"
|
||||||
|
|
||||||
|
|
||||||
PydConversationMessage = pydantic_model_creator(ConversationMessage)
|
PydConversationMessage = pydantic_model_creator(ConversationMessage)
|
||||||
PydConversation = pydantic_model_creator(
|
PydConversation = pydantic_model_creator(
|
||||||
Conversation, name="Conversation", allow_cycles=True, exclude=("user",)
|
Conversation, name="Conversation", allow_cycles=True, exclude=("user",)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
SIMBA_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_SYSTEM_PROMPT = """You are Simba, Ryan's helpful personal assistant. You're named after his orange cat. You have a warm, friendly personality with a light cat-themed touch, but your priority is always being genuinely useful — give thorough, detailed answers and think things through carefully. When asked about Simba the cat, you speak as him in first person. For everything else, you're just a great assistant who happens to have a cat's name.
|
||||||
|
|
||||||
SIMBA FACTS (as of January 2026):
|
SIMBA FACTS (as of January 2026):
|
||||||
- Name: Simba
|
- Name: Simba
|
||||||
@@ -40,6 +40,15 @@ You have access to Ryan's budget data through YNAB (You Need A Budget). When use
|
|||||||
- Use ynab_insights to provide spending trends, patterns, and recommendations
|
- Use ynab_insights to provide spending trends, patterns, and recommendations
|
||||||
Always use these tools when asked about budgets, spending, transactions, or financial health.
|
Always use these tools when asked about budgets, spending, transactions, or financial health.
|
||||||
|
|
||||||
|
MEAL PLANNING (Mealie Integration):
|
||||||
|
You have access to Ryan's meal plans, recipes, and shopping lists through Mealie. When users ask about food or meal planning, use the appropriate Mealie tools:
|
||||||
|
- Use mealie_todays_meals to check what's planned for today
|
||||||
|
- Use mealie_meal_plans to see the week's meal schedule or a custom date range
|
||||||
|
- Use mealie_get_recipe to get full recipe details (ingredients, instructions)
|
||||||
|
- Use mealie_shopping_lists to check what groceries are needed
|
||||||
|
- Use mealie_create_meal_plan to add meals to the plan
|
||||||
|
Always use these tools when asked about meals, recipes, what's for dinner, or shopping lists.
|
||||||
|
|
||||||
NOTES & RESEARCH (Obsidian Integration):
|
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:
|
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_search_notes to search through your vault for relevant information
|
||||||
@@ -49,9 +58,12 @@ You have access to Ryan's Obsidian vault through the Obsidian integration. When
|
|||||||
Always use these tools when users ask about notes, research, ideas, tasks, or when you want to save information for future reference.
|
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):
|
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.
|
You have access to Ryan's daily journal notes. Each note lives at 50 - Journal/YYYY/MM/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_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_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_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 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."""
|
Use these tools when Ryan asks about today's tasks, wants to add something to his list, or wants to mark a task complete.
|
||||||
|
|
||||||
|
USER MEMORY:
|
||||||
|
You can remember facts about the user across conversations using the save_user_memory tool. When a user explicitly asks you to remember something, or shares a meaningful preference or personal fact, save it. Saved memories will automatically appear at the end of this prompt in future conversations under "USER MEMORIES"."""
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from quart import Blueprint, request
|
|||||||
|
|
||||||
from blueprints.users.models import User
|
from blueprints.users.models import User
|
||||||
from blueprints.conversation.logic import (
|
from blueprints.conversation.logic import (
|
||||||
get_conversation_for_user,
|
get_conversation_for_channel,
|
||||||
add_message_to_conversation,
|
add_message_to_conversation,
|
||||||
)
|
)
|
||||||
from blueprints.conversation.agents import main_agent
|
from blueprints.conversation.agents import main_agent
|
||||||
@@ -176,7 +176,7 @@ async def webhook():
|
|||||||
|
|
||||||
# Get or create conversation
|
# Get or create conversation
|
||||||
try:
|
try:
|
||||||
conversation = await get_conversation_for_user(user=user)
|
conversation = await get_conversation_for_channel(user=user, channel="email")
|
||||||
await conversation.fetch_related("messages")
|
await conversation.fetch_related("messages")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get conversation for user {user.username}: {e}")
|
logger.error(f"Failed to get conversation for user {user.username}: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import os
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import functools
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from quart import Blueprint, request, jsonify
|
||||||
|
|
||||||
|
from blueprints.users.models import User
|
||||||
|
from blueprints.conversation.logic import (
|
||||||
|
get_conversation_for_channel,
|
||||||
|
add_message_to_conversation,
|
||||||
|
)
|
||||||
|
from blueprints.conversation.agents import main_agent
|
||||||
|
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
imessage_blueprint = Blueprint("imessage_api", __name__, url_prefix="/api/imessage")
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Rate limiting: per-number message timestamps
|
||||||
|
_rate_limit_store: dict[str, list[float]] = defaultdict(list)
|
||||||
|
|
||||||
|
RATE_LIMIT_MAX = int(os.getenv("IMESSAGE_RATE_LIMIT_MAX", "10"))
|
||||||
|
RATE_LIMIT_WINDOW = int(os.getenv("IMESSAGE_RATE_LIMIT_WINDOW", "60"))
|
||||||
|
|
||||||
|
MAX_MESSAGE_LENGTH = 2000
|
||||||
|
|
||||||
|
SENDBLUE_API_BASE = "https://api.sendblue.co"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sendblue_headers() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"sb-api-key-id": os.getenv("SENDBLUE_API_KEY", ""),
|
||||||
|
"sb-api-secret-key": os.getenv("SENDBLUE_API_SECRET", ""),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
cutoff = now - RATE_LIMIT_WINDOW
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def send_imessage(to_number: str, content: str) -> dict:
|
||||||
|
"""Send an iMessage via SendBlue API."""
|
||||||
|
from_number = os.getenv("SENDBLUE_FROM_NUMBER", "")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{SENDBLUE_API_BASE}/api/send-message",
|
||||||
|
headers=_get_sendblue_headers(),
|
||||||
|
json={
|
||||||
|
"number": to_number,
|
||||||
|
"from_number": from_number,
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_sendblue_signature(f):
|
||||||
|
"""Decorator to validate the SendBlue webhook signing secret."""
|
||||||
|
|
||||||
|
@functools.wraps(f)
|
||||||
|
async def decorated_function(*args, **kwargs):
|
||||||
|
if os.getenv("SENDBLUE_SIGNATURE_VALIDATION", "true").lower() == "false":
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
|
secret = os.getenv("SENDBLUE_WEBHOOK_SECRET")
|
||||||
|
if not secret:
|
||||||
|
logger.error("SENDBLUE_WEBHOOK_SECRET not set — rejecting request")
|
||||||
|
return jsonify({"error": "Server misconfigured"}), 500
|
||||||
|
|
||||||
|
sig = request.headers.get("sb-signing-secret", "")
|
||||||
|
if not hmac.compare_digest(sig, secret):
|
||||||
|
logger.warning("Invalid SendBlue signing secret")
|
||||||
|
return jsonify({"error": "Unauthorized"}), 403
|
||||||
|
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
@imessage_blueprint.route("/webhook", methods=["POST"])
|
||||||
|
@validate_sendblue_signature
|
||||||
|
async def webhook():
|
||||||
|
"""Handle incoming iMessages from SendBlue."""
|
||||||
|
data = await request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "Invalid payload"}), 400
|
||||||
|
|
||||||
|
from_number = data.get("from_number")
|
||||||
|
content = data.get("content")
|
||||||
|
is_outbound = data.get("is_outbound", False)
|
||||||
|
|
||||||
|
# Ignore outbound messages (our own replies echoed back)
|
||||||
|
if is_outbound:
|
||||||
|
return jsonify({"status": "ignored"}), 200
|
||||||
|
|
||||||
|
if not from_number or not content:
|
||||||
|
return jsonify({"error": "Missing from_number or content"}), 400
|
||||||
|
|
||||||
|
content = content.strip()
|
||||||
|
if not content:
|
||||||
|
await send_imessage(
|
||||||
|
from_number, "I received an empty message. Please send some text!"
|
||||||
|
)
|
||||||
|
return jsonify({"status": "ok"}), 200
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
if not _check_rate_limit(from_number):
|
||||||
|
logger.warning(f"Rate limit exceeded for {from_number}")
|
||||||
|
await send_imessage(
|
||||||
|
from_number,
|
||||||
|
"You're sending messages too quickly. Please wait a moment and try again.",
|
||||||
|
)
|
||||||
|
return jsonify({"status": "rate_limited"}), 200
|
||||||
|
|
||||||
|
# Truncate overly long messages
|
||||||
|
if len(content) > MAX_MESSAGE_LENGTH:
|
||||||
|
content = content[:MAX_MESSAGE_LENGTH]
|
||||||
|
logger.info(
|
||||||
|
f"Truncated long message from {from_number} to {MAX_MESSAGE_LENGTH} chars"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Received iMessage from {from_number}: {content[:100]}")
|
||||||
|
|
||||||
|
# Identify or create user
|
||||||
|
user = await User.filter(imessage_number=from_number).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
allowed_numbers = os.getenv("ALLOWED_IMESSAGE_NUMBERS", "").split(",")
|
||||||
|
if from_number not in allowed_numbers and "*" not in allowed_numbers:
|
||||||
|
await send_imessage(
|
||||||
|
from_number, "Sorry, you are not authorized to use this service."
|
||||||
|
)
|
||||||
|
return jsonify({"status": "unauthorized"}), 200
|
||||||
|
|
||||||
|
username = f"im_{from_number.lstrip('+')}"
|
||||||
|
try:
|
||||||
|
user = await User.create(
|
||||||
|
username=username,
|
||||||
|
email=f"{username}@imessage.simbarag.local",
|
||||||
|
imessage_number=from_number,
|
||||||
|
auth_provider="imessage",
|
||||||
|
)
|
||||||
|
logger.info(f"Created new user for iMessage: {username}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create user for {from_number}: {e}")
|
||||||
|
await send_imessage(
|
||||||
|
from_number, "Sorry, something went wrong setting up your account."
|
||||||
|
)
|
||||||
|
return jsonify({"status": "error"}), 200
|
||||||
|
|
||||||
|
# iMessage is restricted to admins
|
||||||
|
if not user.is_admin():
|
||||||
|
logger.warning(f"Non-admin user {user.username} attempted iMessage access")
|
||||||
|
await send_imessage(from_number, "Sorry, this feature is restricted to admins.")
|
||||||
|
return jsonify({"status": "forbidden"}), 200
|
||||||
|
|
||||||
|
# Get or create conversation
|
||||||
|
try:
|
||||||
|
conversation = await get_conversation_for_channel(user=user, channel="imessage")
|
||||||
|
await conversation.fetch_related("messages")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get conversation for user {user.username}: {e}")
|
||||||
|
await send_imessage(
|
||||||
|
from_number, "Sorry, something went wrong. Please try again later."
|
||||||
|
)
|
||||||
|
return jsonify({"status": "error"}), 200
|
||||||
|
|
||||||
|
# Add user message to conversation
|
||||||
|
await add_message_to_conversation(
|
||||||
|
conversation=conversation,
|
||||||
|
message=content,
|
||||||
|
speaker="user",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build messages payload for LangChain agent
|
||||||
|
try:
|
||||||
|
messages = await conversation.messages.all()
|
||||||
|
recent_messages = list(messages)[-10:]
|
||||||
|
|
||||||
|
messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}]
|
||||||
|
|
||||||
|
for msg in recent_messages[:-1]:
|
||||||
|
role = "user" if msg.speaker == "user" else "assistant"
|
||||||
|
messages_payload.append({"role": role, "content": msg.text})
|
||||||
|
|
||||||
|
messages_payload.append({"role": "user", "content": content})
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error invoking agent: {e}")
|
||||||
|
response_text = "Sorry, I'm having trouble thinking right now."
|
||||||
|
|
||||||
|
# Save and send response
|
||||||
|
await add_message_to_conversation(
|
||||||
|
conversation=conversation,
|
||||||
|
message=response_text,
|
||||||
|
speaker="simba",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
from utils.strip_markdown import strip_markdown
|
||||||
|
|
||||||
|
await send_imessage(from_number, strip_markdown(response_text))
|
||||||
|
|
||||||
|
return jsonify({"status": "ok"}), 200
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
from quart import Blueprint, jsonify
|
from quart import Blueprint, jsonify
|
||||||
from quart_jwt_extended import jwt_refresh_token_required
|
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 .logic import (
|
||||||
|
delete_all_documents,
|
||||||
|
get_vector_store_stats,
|
||||||
|
index_documents,
|
||||||
|
sync_obsidian_documents,
|
||||||
|
)
|
||||||
from blueprints.users.decorators import admin_required
|
from blueprints.users.decorators import admin_required
|
||||||
|
|
||||||
rag_blueprint = Blueprint("rag_api", __name__, url_prefix="/api/rag")
|
rag_blueprint = Blueprint("rag_api", __name__, url_prefix="/api/rag")
|
||||||
@@ -32,14 +37,7 @@ async def trigger_index():
|
|||||||
async def trigger_reindex():
|
async def trigger_reindex():
|
||||||
"""Clear and reindex all documents. Admin only."""
|
"""Clear and reindex all documents. Admin only."""
|
||||||
try:
|
try:
|
||||||
# Clear existing documents
|
delete_all_documents()
|
||||||
collection = vector_store._collection
|
|
||||||
all_docs = collection.get()
|
|
||||||
|
|
||||||
if all_docs["ids"]:
|
|
||||||
collection.delete(ids=all_docs["ids"])
|
|
||||||
|
|
||||||
# Reindex
|
|
||||||
await index_documents()
|
await index_documents()
|
||||||
stats = get_vector_store_stats()
|
stats = get_vector_store_stats()
|
||||||
return jsonify({"status": "success", "stats": stats})
|
return jsonify({"status": "success", "stats": stats})
|
||||||
@@ -50,9 +48,9 @@ async def trigger_reindex():
|
|||||||
@rag_blueprint.post("/index-obsidian")
|
@rag_blueprint.post("/index-obsidian")
|
||||||
@admin_required
|
@admin_required
|
||||||
async def trigger_obsidian_index():
|
async def trigger_obsidian_index():
|
||||||
"""Index all Obsidian markdown documents into vector store. Admin only."""
|
"""Incrementally sync Obsidian documents into vector store. Admin only."""
|
||||||
try:
|
try:
|
||||||
result = await index_obsidian_documents()
|
result = await sync_obsidian_documents()
|
||||||
stats = get_vector_store_stats()
|
stats = get_vector_store_stats()
|
||||||
return jsonify({"status": "success", "result": result, "stats": stats})
|
return jsonify({"status": "success", "result": result, "stats": stats})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+376
-34
@@ -1,11 +1,19 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from langchain_chroma import Chroma
|
|
||||||
from langchain_core.documents import Document
|
from langchain_core.documents import Document
|
||||||
from langchain_openai import OpenAIEmbeddings
|
from langchain_openai import OpenAIEmbeddings
|
||||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
from langchain_postgres import PGVector
|
||||||
|
from langchain_text_splitters import (
|
||||||
|
MarkdownHeaderTextSplitter,
|
||||||
|
RecursiveCharacterTextSplitter,
|
||||||
|
)
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
|
||||||
from .fetchers import PaperlessNGXService
|
from .fetchers import PaperlessNGXService
|
||||||
from utils.obsidian_service import ObsidianService
|
from utils.obsidian_service import ObsidianService
|
||||||
@@ -13,13 +21,51 @@ from utils.obsidian_service import ObsidianService
|
|||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
vector_store = Chroma(
|
_embedding_server_url = os.getenv("EMBEDDING_SERVER_URL")
|
||||||
collection_name="simba_docs",
|
_embedding_model = os.getenv("EMBEDDING_MODEL_NAME", "text-embedding-3-small")
|
||||||
embedding_function=embeddings,
|
|
||||||
persist_directory=os.getenv("CHROMADB_PATH", ""),
|
if _embedding_server_url:
|
||||||
|
embeddings = OpenAIEmbeddings(
|
||||||
|
model=_embedding_model,
|
||||||
|
base_url=_embedding_server_url,
|
||||||
|
api_key="not-needed",
|
||||||
|
check_embedding_ctx_length=False,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
embeddings = OpenAIEmbeddings(model=_embedding_model)
|
||||||
|
|
||||||
|
# Convert Tortoise-style postgres:// URL to SQLAlchemy-style postgresql+psycopg://
|
||||||
|
_db_url = os.getenv(
|
||||||
|
"DATABASE_URL", "postgres://raggr:raggr_dev_password@localhost:5432/raggr"
|
||||||
|
)
|
||||||
|
_pgvector_url = _db_url.replace("postgres://", "postgresql+psycopg://")
|
||||||
|
|
||||||
|
# Lazy-initialized vector store (defers DB connection to first use)
|
||||||
|
_vector_store = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_vector_store() -> PGVector:
|
||||||
|
global _vector_store
|
||||||
|
if _vector_store is None:
|
||||||
|
_vector_store = PGVector(
|
||||||
|
embeddings=embeddings,
|
||||||
|
collection_name="simba_docs",
|
||||||
|
connection=_pgvector_url,
|
||||||
|
use_jsonb=True,
|
||||||
|
create_extension=False, # created by docker init script
|
||||||
|
async_mode=True,
|
||||||
|
)
|
||||||
|
return _vector_store
|
||||||
|
|
||||||
|
|
||||||
|
def _get_engine():
|
||||||
|
"""Get a SQLAlchemy engine for direct queries."""
|
||||||
|
if not hasattr(_get_engine, "_engine"):
|
||||||
|
_get_engine._engine = create_engine(_pgvector_url)
|
||||||
|
return _get_engine._engine
|
||||||
|
|
||||||
|
|
||||||
text_splitter = RecursiveCharacterTextSplitter(
|
text_splitter = RecursiveCharacterTextSplitter(
|
||||||
chunk_size=1000, # chunk size (characters)
|
chunk_size=1000, # chunk size (characters)
|
||||||
@@ -27,6 +73,62 @@ text_splitter = RecursiveCharacterTextSplitter(
|
|||||||
add_start_index=True, # track index in original document
|
add_start_index=True, # track index in original document
|
||||||
)
|
)
|
||||||
|
|
||||||
|
md_header_splitter = MarkdownHeaderTextSplitter(
|
||||||
|
headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
|
||||||
|
strip_headers=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
md_chunk_splitter = RecursiveCharacterTextSplitter(
|
||||||
|
chunk_size=1000,
|
||||||
|
chunk_overlap=200,
|
||||||
|
add_start_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_markdown_document(doc: Document) -> list[Document]:
|
||||||
|
"""Split a markdown document by headers first, then by size.
|
||||||
|
|
||||||
|
Prepends the note filename to each chunk so chunks are self-contained.
|
||||||
|
"""
|
||||||
|
note_name = (
|
||||||
|
Path(doc.metadata.get("filepath", "")).stem
|
||||||
|
if doc.metadata.get("filepath")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Split by markdown headers
|
||||||
|
header_splits = md_header_splitter.split_text(doc.page_content)
|
||||||
|
|
||||||
|
# Carry over original document metadata to each header split
|
||||||
|
for split in header_splits:
|
||||||
|
split.metadata.update(doc.metadata)
|
||||||
|
|
||||||
|
# Then apply size-based splitting on large sections
|
||||||
|
sized_splits = md_chunk_splitter.split_documents(header_splits)
|
||||||
|
|
||||||
|
# Prepend note name for self-contained context
|
||||||
|
if note_name:
|
||||||
|
for split in sized_splits:
|
||||||
|
split.page_content = f"[Note: {note_name}]\n{split.page_content}"
|
||||||
|
|
||||||
|
return sized_splits
|
||||||
|
|
||||||
|
|
||||||
|
def _get_collection_id():
|
||||||
|
"""Get the UUID of our collection from the langchain_pg_collection table."""
|
||||||
|
engine = _get_engine()
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(
|
||||||
|
text("SELECT uuid FROM langchain_pg_collection WHERE name = :name"),
|
||||||
|
{"name": "simba_docs"},
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
except Exception:
|
||||||
|
# Table doesn't exist yet (first run before any indexing)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def date_to_epoch(date_str: str) -> float:
|
def date_to_epoch(date_str: str) -> float:
|
||||||
split_date = date_str.split("-")
|
split_date = date_str.split("-")
|
||||||
@@ -49,6 +151,7 @@ async def fetch_documents_from_paperless_ngx() -> list[Document]:
|
|||||||
documents = []
|
documents = []
|
||||||
for doc in data:
|
for doc in data:
|
||||||
metadata = {
|
metadata = {
|
||||||
|
"source": "paperless",
|
||||||
"created_date": date_to_epoch(doc["created_date"]),
|
"created_date": date_to_epoch(doc["created_date"]),
|
||||||
"filename": doc["original_file_name"],
|
"filename": doc["original_file_name"],
|
||||||
"document_type": doctypes.get(doc["document_type"], ""),
|
"document_type": doctypes.get(doc["document_type"], ""),
|
||||||
@@ -58,12 +161,54 @@ async def fetch_documents_from_paperless_ngx() -> list[Document]:
|
|||||||
return documents
|
return documents
|
||||||
|
|
||||||
|
|
||||||
|
def _make_serializable(value):
|
||||||
|
"""Convert a value to a JSON-serializable type."""
|
||||||
|
if isinstance(value, (str, int, float, bool, type(None))):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_make_serializable(v) for v in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {k: _make_serializable(v) for k, v in value.items()}
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_text(text_content: str) -> str:
|
||||||
|
"""Strip non-printable and invalid characters that break embedding tokenizers."""
|
||||||
|
# Remove null bytes and control characters (keep newlines and tabs)
|
||||||
|
text_content = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", text_content)
|
||||||
|
# Remove Unicode surrogates and other problematic Unicode
|
||||||
|
text_content = re.sub(r"[\ud800-\udfff\ufffe\uffff]", "", text_content)
|
||||||
|
# Remove replacement character clusters
|
||||||
|
text_content = text_content.replace("\ufffd", "")
|
||||||
|
# Collapse excessive whitespace
|
||||||
|
text_content = re.sub(r" {3,}", " ", text_content)
|
||||||
|
return text_content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_documents(documents: list[Document]) -> list[Document]:
|
||||||
|
"""Sanitize page_content of all documents for embedding compatibility."""
|
||||||
|
for doc in documents:
|
||||||
|
doc.page_content = _sanitize_text(doc.page_content)
|
||||||
|
return [doc for doc in documents if doc.page_content]
|
||||||
|
|
||||||
|
|
||||||
async def index_documents():
|
async def index_documents():
|
||||||
"""Index Paperless-NGX documents into vector store."""
|
"""Index Paperless-NGX documents into vector store."""
|
||||||
documents = await fetch_documents_from_paperless_ngx()
|
documents = await fetch_documents_from_paperless_ngx()
|
||||||
|
|
||||||
splits = text_splitter.split_documents(documents)
|
splits = text_splitter.split_documents(documents)
|
||||||
await vector_store.aadd_documents(documents=splits)
|
splits = _sanitize_documents(splits)
|
||||||
|
logger.info(f"Indexing {len(splits)} chunks from {len(documents)} documents")
|
||||||
|
vector_store = _get_vector_store()
|
||||||
|
for i, split in enumerate(splits):
|
||||||
|
try:
|
||||||
|
await vector_store.aadd_documents(documents=[split])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to embed chunk {i} from {split.metadata.get('filename', 'unknown')}: {e}"
|
||||||
|
)
|
||||||
|
logger.debug(f"Chunk content preview: {split.page_content[:200]!r}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def fetch_obsidian_documents() -> list[Document]:
|
async def fetch_obsidian_documents() -> list[Document]:
|
||||||
@@ -85,20 +230,29 @@ async def fetch_obsidian_documents() -> list[Document]:
|
|||||||
parsed = obsidian_service.parse_markdown(content, md_path)
|
parsed = obsidian_service.parse_markdown(content, md_path)
|
||||||
|
|
||||||
# Create LangChain Document with obsidian source
|
# Create LangChain Document with obsidian source
|
||||||
document = Document(
|
|
||||||
page_content=parsed["content"],
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"source": "obsidian",
|
"source": "obsidian",
|
||||||
"filepath": parsed["filepath"],
|
"filepath": parsed["filepath"],
|
||||||
|
"folder": str(Path(parsed["filepath"]).parent)
|
||||||
|
if parsed["filepath"]
|
||||||
|
else "",
|
||||||
"tags": parsed["tags"],
|
"tags": parsed["tags"],
|
||||||
"created_at": parsed["metadata"].get("created_at"),
|
"created_at": parsed["metadata"].get("created_at"),
|
||||||
**{k: v for k, v in parsed["metadata"].items() if k not in ["created_at", "created_by"]},
|
"indexed_at": time.time(),
|
||||||
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in parsed["metadata"].items()
|
||||||
|
if k not in ["created_at", "created_by"]
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
document = Document(
|
||||||
|
page_content=parsed["content"],
|
||||||
|
metadata=_make_serializable(metadata),
|
||||||
)
|
)
|
||||||
documents.append(document)
|
documents.append(document)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading {md_path}: {e}")
|
logger.warning(f"Error reading {md_path}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return documents
|
return documents
|
||||||
@@ -109,27 +263,168 @@ async def index_obsidian_documents():
|
|||||||
|
|
||||||
Deletes existing obsidian source chunks before re-indexing.
|
Deletes existing obsidian source chunks before re-indexing.
|
||||||
"""
|
"""
|
||||||
obsidian_service = ObsidianService()
|
|
||||||
documents = await fetch_obsidian_documents()
|
documents = await fetch_obsidian_documents()
|
||||||
|
|
||||||
if not documents:
|
if not documents:
|
||||||
print("No Obsidian documents found to index")
|
logger.info("No Obsidian documents found to index")
|
||||||
return {"indexed": 0}
|
return {"indexed": 0}
|
||||||
|
|
||||||
# Delete existing obsidian chunks
|
# Delete existing obsidian chunks
|
||||||
existing_results = vector_store.get(where={"source": "obsidian"})
|
delete_documents_by_metadata("source", "obsidian")
|
||||||
if existing_results.get("ids"):
|
|
||||||
await vector_store.adelete(existing_results["ids"])
|
|
||||||
|
|
||||||
# Split and index documents
|
# Split using markdown-aware chunking, sanitize, and index
|
||||||
splits = text_splitter.split_documents(documents)
|
splits = []
|
||||||
|
for doc in documents:
|
||||||
|
splits.extend(_split_markdown_document(doc))
|
||||||
|
splits = _sanitize_documents(splits)
|
||||||
|
vector_store = _get_vector_store()
|
||||||
await vector_store.aadd_documents(documents=splits)
|
await vector_store.aadd_documents(documents=splits)
|
||||||
|
|
||||||
return {"indexed": len(documents)}
|
return {"indexed": len(documents)}
|
||||||
|
|
||||||
|
|
||||||
async def query_vector_store(query: str):
|
# In-memory cache of indexed obsidian files: {filepath: indexed_at}
|
||||||
retrieved_docs = await vector_store.asimilarity_search(query, k=2)
|
_obsidian_index_cache: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_obsidian_index_cache() -> dict[str, float]:
|
||||||
|
"""Load indexed obsidian files from DB into cache (cold start only)."""
|
||||||
|
collection_id = _get_collection_id()
|
||||||
|
if not collection_id:
|
||||||
|
return {}
|
||||||
|
engine = _get_engine()
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT DISTINCT cmetadata->>'filepath' AS filepath, "
|
||||||
|
"MAX((cmetadata->>'indexed_at')::float) AS indexed_at "
|
||||||
|
"FROM langchain_pg_embedding "
|
||||||
|
"WHERE collection_id = :cid AND cmetadata->>'source' = 'obsidian' "
|
||||||
|
"GROUP BY cmetadata->>'filepath'"
|
||||||
|
),
|
||||||
|
{"cid": collection_id},
|
||||||
|
)
|
||||||
|
return {row[0]: row[1] for row in result if row[0] is not None}
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_obsidian_documents() -> dict[str, int]:
|
||||||
|
"""Incrementally sync Obsidian documents to pgvector.
|
||||||
|
|
||||||
|
Compares file mtimes against stored indexed_at timestamps to only
|
||||||
|
re-index changed/new files and remove deleted ones.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with counts of added, updated, and deleted files.
|
||||||
|
"""
|
||||||
|
global _obsidian_index_cache
|
||||||
|
|
||||||
|
obsidian_service = ObsidianService()
|
||||||
|
|
||||||
|
# Load cache from DB on first run
|
||||||
|
if not _obsidian_index_cache:
|
||||||
|
_obsidian_index_cache = _load_obsidian_index_cache()
|
||||||
|
|
||||||
|
# Build map of current vault files -> mtime
|
||||||
|
vault_files: dict[str, float] = {}
|
||||||
|
for md_path in obsidian_service.walk_vault():
|
||||||
|
vault_files[str(md_path)] = md_path.stat().st_mtime
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
updated = 0
|
||||||
|
deleted = 0
|
||||||
|
|
||||||
|
# Find files to add or update
|
||||||
|
files_to_index: list[str] = []
|
||||||
|
for filepath, mtime in vault_files.items():
|
||||||
|
indexed_at = _obsidian_index_cache.get(filepath)
|
||||||
|
if indexed_at is None:
|
||||||
|
files_to_index.append(filepath)
|
||||||
|
added += 1
|
||||||
|
elif mtime > indexed_at:
|
||||||
|
# Delete old chunks first
|
||||||
|
delete_documents_by_metadata("filepath", filepath)
|
||||||
|
files_to_index.append(filepath)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
# Find deleted files (in cache but not on disk)
|
||||||
|
for filepath in list(_obsidian_index_cache):
|
||||||
|
if filepath not in vault_files:
|
||||||
|
delete_documents_by_metadata("filepath", filepath)
|
||||||
|
del _obsidian_index_cache[filepath]
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
# Index new/changed files
|
||||||
|
if files_to_index:
|
||||||
|
now = time.time()
|
||||||
|
documents = []
|
||||||
|
for filepath in files_to_index:
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
parsed = obsidian_service.parse_markdown(content, filepath)
|
||||||
|
metadata = {
|
||||||
|
"source": "obsidian",
|
||||||
|
"filepath": parsed["filepath"],
|
||||||
|
"folder": str(Path(parsed["filepath"]).parent)
|
||||||
|
if parsed["filepath"]
|
||||||
|
else "",
|
||||||
|
"tags": parsed["tags"],
|
||||||
|
"created_at": parsed["metadata"].get("created_at"),
|
||||||
|
"indexed_at": now,
|
||||||
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in parsed["metadata"].items()
|
||||||
|
if k not in ["created_at", "created_by"]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
document = Document(
|
||||||
|
page_content=parsed["content"],
|
||||||
|
metadata=_make_serializable(metadata),
|
||||||
|
)
|
||||||
|
documents.append(document)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error reading {filepath}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if documents:
|
||||||
|
splits = []
|
||||||
|
for doc in documents:
|
||||||
|
splits.extend(_split_markdown_document(doc))
|
||||||
|
splits = _sanitize_documents(splits)
|
||||||
|
if splits:
|
||||||
|
vector_store = _get_vector_store()
|
||||||
|
await vector_store.aadd_documents(documents=splits)
|
||||||
|
|
||||||
|
# Update cache for successfully processed files
|
||||||
|
for filepath in files_to_index:
|
||||||
|
_obsidian_index_cache[filepath] = now
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Obsidian sync complete: {added} added, {updated} updated, {deleted} deleted"
|
||||||
|
)
|
||||||
|
return {"added": added, "updated": updated, "deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
async def query_vector_store(
|
||||||
|
query: str,
|
||||||
|
source: str | None = None,
|
||||||
|
k: int = 8,
|
||||||
|
):
|
||||||
|
"""Query the vector store with optional source filtering and MMR.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query text
|
||||||
|
source: Filter by source metadata (e.g., "obsidian", "paperless")
|
||||||
|
k: Number of results to return
|
||||||
|
"""
|
||||||
|
vector_store = _get_vector_store()
|
||||||
|
filter_dict = {"source": source} if source else None
|
||||||
|
retrieved_docs = await vector_store.amax_marginal_relevance_search(
|
||||||
|
query,
|
||||||
|
k=k,
|
||||||
|
fetch_k=k * 3,
|
||||||
|
filter=filter_dict,
|
||||||
|
)
|
||||||
serialized = "\n\n".join(
|
serialized = "\n\n".join(
|
||||||
(f"Source: {doc.metadata}\nContent: {doc.page_content}")
|
(f"Source: {doc.metadata}\nContent: {doc.page_content}")
|
||||||
for doc in retrieved_docs
|
for doc in retrieved_docs
|
||||||
@@ -137,32 +432,79 @@ async def query_vector_store(query: str):
|
|||||||
return serialized, retrieved_docs
|
return serialized, retrieved_docs
|
||||||
|
|
||||||
|
|
||||||
|
def delete_all_documents():
|
||||||
|
"""Delete all documents from the vector store collection."""
|
||||||
|
collection_id = _get_collection_id()
|
||||||
|
if not collection_id:
|
||||||
|
return
|
||||||
|
engine = _get_engine()
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text("DELETE FROM langchain_pg_embedding WHERE collection_id = :cid"),
|
||||||
|
{"cid": collection_id},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_documents_by_metadata(key: str, value: str):
|
||||||
|
"""Delete documents matching a metadata key/value pair."""
|
||||||
|
collection_id = _get_collection_id()
|
||||||
|
if not collection_id:
|
||||||
|
return
|
||||||
|
engine = _get_engine()
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"DELETE FROM langchain_pg_embedding "
|
||||||
|
"WHERE collection_id = :cid AND cmetadata->>:key = :value"
|
||||||
|
),
|
||||||
|
{"cid": collection_id, "key": key, "value": value},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def get_vector_store_stats():
|
def get_vector_store_stats():
|
||||||
"""Get statistics about the vector store."""
|
"""Get statistics about the vector store."""
|
||||||
collection = vector_store._collection
|
collection_id = _get_collection_id()
|
||||||
count = collection.count()
|
count = 0
|
||||||
|
if collection_id:
|
||||||
|
engine = _get_engine()
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM langchain_pg_embedding WHERE collection_id = :cid"
|
||||||
|
),
|
||||||
|
{"cid": collection_id},
|
||||||
|
)
|
||||||
|
count = result.scalar()
|
||||||
return {
|
return {
|
||||||
"total_documents": count,
|
"total_documents": count,
|
||||||
"collection_name": collection.name,
|
"collection_name": "simba_docs",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_all_documents(limit: int = 10):
|
def list_all_documents(limit: int = 10):
|
||||||
"""List documents in the vector store with their metadata."""
|
"""List documents in the vector store with their metadata."""
|
||||||
collection = vector_store._collection
|
collection_id = _get_collection_id()
|
||||||
results = collection.get(limit=limit, include=["metadatas", "documents"])
|
if not collection_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
engine = _get_engine()
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, document, cmetadata FROM langchain_pg_embedding "
|
||||||
|
"WHERE collection_id = :cid LIMIT :limit"
|
||||||
|
),
|
||||||
|
{"cid": collection_id, "limit": limit},
|
||||||
|
)
|
||||||
documents = []
|
documents = []
|
||||||
for i, doc_id in enumerate(results["ids"]):
|
for row in result:
|
||||||
documents.append(
|
documents.append(
|
||||||
{
|
{
|
||||||
"id": doc_id,
|
"id": str(row[0]),
|
||||||
"metadata": results["metadatas"][i]
|
"metadata": row[2],
|
||||||
if results.get("metadatas")
|
"content_preview": row[1][:200] if row[1] else None,
|
||||||
else None,
|
|
||||||
"content_preview": results["documents"][i][:200]
|
|
||||||
if results.get("documents")
|
|
||||||
else None,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from quart import Blueprint, request, jsonify
|
||||||
|
|
||||||
|
from blueprints.users.decorators import admin_required
|
||||||
|
from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence
|
||||||
|
|
||||||
|
scheduled_messages_blueprint = Blueprint(
|
||||||
|
"scheduled_messages_api", __name__, url_prefix="/api/scheduled-messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(msg: ScheduledMessage) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(msg.id),
|
||||||
|
"recipient": msg.recipient,
|
||||||
|
"channel": msg.channel.value,
|
||||||
|
"content": msg.content,
|
||||||
|
"subject": msg.subject,
|
||||||
|
"scheduled_at": msg.scheduled_at.isoformat(),
|
||||||
|
"status": msg.status.value,
|
||||||
|
"recurrence": msg.recurrence.value,
|
||||||
|
"use_agent": msg.use_agent,
|
||||||
|
"error_message": msg.error_message,
|
||||||
|
"created_at": msg.created_at.isoformat(),
|
||||||
|
"updated_at": msg.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_messages_blueprint.route("/", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
async def list_messages():
|
||||||
|
messages = await ScheduledMessage.all().order_by("-scheduled_at")
|
||||||
|
return jsonify([_serialize(m) for m in messages])
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_messages_blueprint.route("/", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
async def create_message():
|
||||||
|
data = await request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "Invalid payload"}), 400
|
||||||
|
|
||||||
|
recipient = (data.get("recipient") or "").strip()
|
||||||
|
channel = data.get("channel")
|
||||||
|
content = (data.get("content") or "").strip()
|
||||||
|
subject = (data.get("subject") or "").strip() or None
|
||||||
|
scheduled_at_str = data.get("scheduled_at")
|
||||||
|
|
||||||
|
recurrence_str = data.get("recurrence", "none")
|
||||||
|
|
||||||
|
if not recipient or not channel or not content or not scheduled_at_str:
|
||||||
|
return jsonify(
|
||||||
|
{"error": "recipient, channel, content, and scheduled_at are required"}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
channel_enum = MessageChannel(channel)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify(
|
||||||
|
{"error": f"Invalid channel: {channel}. Must be 'imessage' or 'email'"}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
recurrence_enum = Recurrence(recurrence_str)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": f"Invalid recurrence: {recurrence_str}. Must be 'none', 'daily', 'weekly', or 'monthly'"
|
||||||
|
}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
if channel_enum == MessageChannel.EMAIL and not subject:
|
||||||
|
return jsonify({"error": "subject is required for email messages"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheduled_at = datetime.fromisoformat(scheduled_at_str)
|
||||||
|
if scheduled_at.tzinfo is None:
|
||||||
|
scheduled_at = scheduled_at.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "Invalid scheduled_at format"}), 400
|
||||||
|
|
||||||
|
if scheduled_at <= datetime.now(timezone.utc):
|
||||||
|
return jsonify({"error": "scheduled_at must be in the future"}), 400
|
||||||
|
|
||||||
|
from quart_jwt_extended import get_jwt_identity
|
||||||
|
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
|
||||||
|
use_agent = bool(data.get("use_agent", False))
|
||||||
|
|
||||||
|
msg = await ScheduledMessage.create(
|
||||||
|
recipient=recipient,
|
||||||
|
channel=channel_enum,
|
||||||
|
content=content,
|
||||||
|
subject=subject,
|
||||||
|
scheduled_at=scheduled_at,
|
||||||
|
recurrence=recurrence_enum,
|
||||||
|
use_agent=use_agent,
|
||||||
|
created_by_id=user_id,
|
||||||
|
)
|
||||||
|
return jsonify(_serialize(msg)), 201
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_messages_blueprint.route("/<msg_id>", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
async def update_message(msg_id: str):
|
||||||
|
msg = await ScheduledMessage.get_or_none(id=msg_id)
|
||||||
|
if not msg:
|
||||||
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
|
||||||
|
if msg.status != MessageStatus.PENDING:
|
||||||
|
return jsonify({"error": "Can only update pending messages"}), 400
|
||||||
|
|
||||||
|
data = await request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "Invalid payload"}), 400
|
||||||
|
|
||||||
|
if "recipient" in data:
|
||||||
|
msg.recipient = data["recipient"].strip()
|
||||||
|
if "channel" in data:
|
||||||
|
try:
|
||||||
|
msg.channel = MessageChannel(data["channel"])
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": f"Invalid channel: {data['channel']}"}), 400
|
||||||
|
if "content" in data:
|
||||||
|
msg.content = data["content"].strip()
|
||||||
|
if "subject" in data:
|
||||||
|
msg.subject = data["subject"].strip() or None
|
||||||
|
if "recurrence" in data:
|
||||||
|
try:
|
||||||
|
msg.recurrence = Recurrence(data["recurrence"])
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": f"Invalid recurrence: {data['recurrence']}"}), 400
|
||||||
|
if "use_agent" in data:
|
||||||
|
msg.use_agent = bool(data["use_agent"])
|
||||||
|
if "scheduled_at" in data:
|
||||||
|
try:
|
||||||
|
scheduled_at = datetime.fromisoformat(data["scheduled_at"])
|
||||||
|
if scheduled_at.tzinfo is None:
|
||||||
|
scheduled_at = scheduled_at.replace(tzinfo=timezone.utc)
|
||||||
|
if scheduled_at <= datetime.now(timezone.utc):
|
||||||
|
return jsonify({"error": "scheduled_at must be in the future"}), 400
|
||||||
|
msg.scheduled_at = scheduled_at
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "Invalid scheduled_at format"}), 400
|
||||||
|
if "status" in data and data["status"] == "cancelled":
|
||||||
|
msg.status = MessageStatus.CANCELLED
|
||||||
|
|
||||||
|
if msg.channel == MessageChannel.EMAIL and not msg.subject:
|
||||||
|
return jsonify({"error": "subject is required for email messages"}), 400
|
||||||
|
|
||||||
|
await msg.save()
|
||||||
|
return jsonify(_serialize(msg))
|
||||||
|
|
||||||
|
|
||||||
|
@scheduled_messages_blueprint.route("/<msg_id>", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
async def delete_message(msg_id: str):
|
||||||
|
msg = await ScheduledMessage.get_or_none(id=msg_id)
|
||||||
|
if not msg:
|
||||||
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
|
||||||
|
if msg.status not in (MessageStatus.PENDING, MessageStatus.CANCELLED):
|
||||||
|
return jsonify({"error": "Can only delete pending or cancelled messages"}), 400
|
||||||
|
|
||||||
|
await msg.delete()
|
||||||
|
return jsonify({"status": "deleted"})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import enum
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
class MessageChannel(enum.Enum):
|
||||||
|
IMESSAGE = "imessage"
|
||||||
|
EMAIL = "email"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageStatus(enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
SENT = "sent"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class Recurrence(enum.Enum):
|
||||||
|
NONE = "none"
|
||||||
|
DAILY = "daily"
|
||||||
|
WEEKLY = "weekly"
|
||||||
|
MONTHLY = "monthly"
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledMessage(Model):
|
||||||
|
id = fields.UUIDField(primary_key=True)
|
||||||
|
recipient = fields.CharField(max_length=255)
|
||||||
|
channel = fields.CharEnumField(enum_type=MessageChannel, max_length=20)
|
||||||
|
content = fields.TextField()
|
||||||
|
subject = fields.CharField(max_length=255, null=True)
|
||||||
|
scheduled_at = fields.DatetimeField()
|
||||||
|
status = fields.CharEnumField(
|
||||||
|
enum_type=MessageStatus, max_length=20, default=MessageStatus.PENDING
|
||||||
|
)
|
||||||
|
recurrence = fields.CharEnumField(
|
||||||
|
enum_type=Recurrence, max_length=20, default=Recurrence.NONE
|
||||||
|
)
|
||||||
|
use_agent = fields.BooleanField(default=False)
|
||||||
|
error_message = fields.TextField(null=True)
|
||||||
|
created_by = fields.ForeignKeyField(
|
||||||
|
"models.User", related_name="scheduled_messages"
|
||||||
|
)
|
||||||
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "scheduled_messages"
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
POLL_INTERVAL = 15
|
||||||
|
|
||||||
|
RECURRENCE_DELTAS = {
|
||||||
|
Recurrence.DAILY: relativedelta(days=1),
|
||||||
|
Recurrence.WEEKLY: relativedelta(weeks=1),
|
||||||
|
Recurrence.MONTHLY: relativedelta(months=1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_agent(prompt: str) -> str:
|
||||||
|
"""Run a prompt through the LangChain agent and return the response text."""
|
||||||
|
from blueprints.conversation.agents import main_agent
|
||||||
|
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
messages_payload = [
|
||||||
|
{"role": "system", "content": SIMBA_SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
]
|
||||||
|
response = await main_agent.ainvoke({"messages": messages_payload})
|
||||||
|
return response.get("messages", [])[-1].content
|
||||||
|
|
||||||
|
|
||||||
|
async def _schedule_next_occurrence(msg: ScheduledMessage):
|
||||||
|
"""Create the next pending occurrence for a recurring message."""
|
||||||
|
delta = RECURRENCE_DELTAS.get(msg.recurrence)
|
||||||
|
if not delta:
|
||||||
|
return
|
||||||
|
|
||||||
|
next_at = msg.scheduled_at + delta
|
||||||
|
# If we missed several intervals, advance until we're in the future
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
while next_at <= now:
|
||||||
|
next_at += delta
|
||||||
|
|
||||||
|
await ScheduledMessage.create(
|
||||||
|
recipient=msg.recipient,
|
||||||
|
channel=msg.channel,
|
||||||
|
content=msg.content,
|
||||||
|
subject=msg.subject,
|
||||||
|
scheduled_at=next_at,
|
||||||
|
recurrence=msg.recurrence,
|
||||||
|
use_agent=msg.use_agent,
|
||||||
|
created_by_id=msg.created_by_id,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Scheduled next {msg.recurrence.value} occurrence for {msg.id} at {next_at.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def scheduled_messages_loop():
|
||||||
|
"""Background loop that polls for and sends due scheduled messages."""
|
||||||
|
logger.info(f"Scheduled messages loop started (interval={POLL_INTERVAL}s)")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
due = await ScheduledMessage.filter(
|
||||||
|
status=MessageStatus.PENDING,
|
||||||
|
scheduled_at__lte=now,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for msg in due:
|
||||||
|
try:
|
||||||
|
send_content = msg.content
|
||||||
|
|
||||||
|
if msg.use_agent:
|
||||||
|
send_content = await _run_agent(msg.content)
|
||||||
|
|
||||||
|
if msg.channel == MessageChannel.IMESSAGE:
|
||||||
|
from blueprints.imessage import send_imessage
|
||||||
|
from utils.strip_markdown import strip_markdown
|
||||||
|
|
||||||
|
await send_imessage(msg.recipient, strip_markdown(send_content))
|
||||||
|
|
||||||
|
elif msg.channel == MessageChannel.EMAIL:
|
||||||
|
from blueprints.email import send_email_reply
|
||||||
|
|
||||||
|
await send_email_reply(
|
||||||
|
to=msg.recipient,
|
||||||
|
subject=msg.subject or "(no subject)",
|
||||||
|
body=send_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
msg.status = MessageStatus.SENT
|
||||||
|
msg.error_message = None
|
||||||
|
await msg.save()
|
||||||
|
logger.info(
|
||||||
|
f"Sent scheduled {msg.channel.value} message {msg.id} to {msg.recipient}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Schedule next occurrence for recurring messages
|
||||||
|
if msg.recurrence != Recurrence.NONE:
|
||||||
|
await _schedule_next_occurrence(msg)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
msg.status = MessageStatus.FAILED
|
||||||
|
msg.error_message = str(e)
|
||||||
|
await msg.save()
|
||||||
|
logger.error(f"Failed to send scheduled message {msg.id}: {e}")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error in scheduled messages loop")
|
||||||
|
|
||||||
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
@@ -212,32 +212,42 @@ async def me():
|
|||||||
user = await User.get_or_none(id=user_id)
|
user = await User.get_or_none(id=user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({"error": "User not found"}), 404
|
return jsonify({"error": "User not found"}), 404
|
||||||
return jsonify({
|
return jsonify(
|
||||||
|
{
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"is_admin": user.is_admin(),
|
"is_admin": user.is_admin(),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@user_blueprint.route("/admin/users", methods=["GET"])
|
@user_blueprint.route("/admin/users", methods=["GET"])
|
||||||
@admin_required
|
@admin_required
|
||||||
async def list_users():
|
async def list_users():
|
||||||
from blueprints.email.helpers import get_user_email_address
|
from blueprints.email.helpers import get_user_email_address
|
||||||
|
|
||||||
users = await User.all().order_by("username")
|
users = await User.all().order_by("username")
|
||||||
mailgun_domain = os.getenv("MAILGUN_DOMAIN", "")
|
mailgun_domain = os.getenv("MAILGUN_DOMAIN", "")
|
||||||
return jsonify([
|
return jsonify(
|
||||||
|
[
|
||||||
{
|
{
|
||||||
"id": str(u.id),
|
"id": str(u.id),
|
||||||
"username": u.username,
|
"username": u.username,
|
||||||
"email": u.email,
|
"email": u.email,
|
||||||
"whatsapp_number": u.whatsapp_number,
|
"whatsapp_number": u.whatsapp_number,
|
||||||
|
"imessage_number": u.imessage_number,
|
||||||
"auth_provider": u.auth_provider,
|
"auth_provider": u.auth_provider,
|
||||||
"email_enabled": u.email_enabled,
|
"email_enabled": u.email_enabled,
|
||||||
"email_address": get_user_email_address(u.email_hmac_token, mailgun_domain) if u.email_hmac_token and u.email_enabled else None,
|
"email_address": get_user_email_address(
|
||||||
|
u.email_hmac_token, mailgun_domain
|
||||||
|
)
|
||||||
|
if u.email_hmac_token and u.email_enabled
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
for u in users
|
for u in users
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@user_blueprint.route("/admin/users/<user_id>/whatsapp", methods=["PUT"])
|
@user_blueprint.route("/admin/users/<user_id>/whatsapp", methods=["PUT"])
|
||||||
@@ -254,17 +264,21 @@ async def set_whatsapp(user_id):
|
|||||||
|
|
||||||
conflict = await User.filter(whatsapp_number=number).exclude(id=user_id).first()
|
conflict = await User.filter(whatsapp_number=number).exclude(id=user_id).first()
|
||||||
if conflict:
|
if conflict:
|
||||||
return jsonify({"error": "That WhatsApp number is already linked to another account"}), 409
|
return jsonify(
|
||||||
|
{"error": "That WhatsApp number is already linked to another account"}
|
||||||
|
), 409
|
||||||
|
|
||||||
user.whatsapp_number = number
|
user.whatsapp_number = number
|
||||||
await user.save()
|
await user.save()
|
||||||
return jsonify({
|
return jsonify(
|
||||||
|
{
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"whatsapp_number": user.whatsapp_number,
|
"whatsapp_number": user.whatsapp_number,
|
||||||
"auth_provider": user.auth_provider,
|
"auth_provider": user.auth_provider,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@user_blueprint.route("/admin/users/<user_id>/whatsapp", methods=["DELETE"])
|
@user_blueprint.route("/admin/users/<user_id>/whatsapp", methods=["DELETE"])
|
||||||
@@ -279,11 +293,55 @@ async def unlink_whatsapp(user_id):
|
|||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@user_blueprint.route("/admin/users/<user_id>/imessage", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
async def set_imessage(user_id):
|
||||||
|
data = await request.get_json()
|
||||||
|
number = (data or {}).get("imessage_number", "").strip()
|
||||||
|
if not number:
|
||||||
|
return jsonify({"error": "imessage_number is required"}), 400
|
||||||
|
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
conflict = await User.filter(imessage_number=number).exclude(id=user_id).first()
|
||||||
|
if conflict:
|
||||||
|
return jsonify(
|
||||||
|
{"error": "That iMessage number is already linked to another account"}
|
||||||
|
), 409
|
||||||
|
|
||||||
|
user.imessage_number = number
|
||||||
|
await user.save()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"id": str(user.id),
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"imessage_number": user.imessage_number,
|
||||||
|
"auth_provider": user.auth_provider,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@user_blueprint.route("/admin/users/<user_id>/imessage", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
async def unlink_imessage(user_id):
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
user.imessage_number = None
|
||||||
|
await user.save()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@user_blueprint.route("/admin/users/<user_id>/email", methods=["PUT"])
|
@user_blueprint.route("/admin/users/<user_id>/email", methods=["PUT"])
|
||||||
@admin_required
|
@admin_required
|
||||||
async def toggle_email(user_id):
|
async def toggle_email(user_id):
|
||||||
"""Enable email channel for a user, generating an HMAC token."""
|
"""Enable email channel for a user, generating an HMAC token."""
|
||||||
from blueprints.email.helpers import generate_email_token, get_user_email_address
|
from blueprints.email.helpers import generate_email_token, get_user_email_address
|
||||||
|
|
||||||
user = await User.get_or_none(id=user_id)
|
user = await User.get_or_none(id=user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({"error": "User not found"}), 404
|
return jsonify({"error": "User not found"}), 404
|
||||||
@@ -299,15 +357,19 @@ async def toggle_email(user_id):
|
|||||||
user.email_enabled = True
|
user.email_enabled = True
|
||||||
await user.save()
|
await user.save()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify(
|
||||||
|
{
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"whatsapp_number": user.whatsapp_number,
|
"whatsapp_number": user.whatsapp_number,
|
||||||
"auth_provider": user.auth_provider,
|
"auth_provider": user.auth_provider,
|
||||||
"email_enabled": user.email_enabled,
|
"email_enabled": user.email_enabled,
|
||||||
"email_address": get_user_email_address(user.email_hmac_token, mailgun_domain),
|
"email_address": get_user_email_address(
|
||||||
})
|
user.email_hmac_token, mailgun_domain
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@user_blueprint.route("/admin/users/<user_id>/email", methods=["DELETE"])
|
@user_blueprint.route("/admin/users/<user_id>/email", methods=["DELETE"])
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ class User(Model):
|
|||||||
username = fields.CharField(max_length=255)
|
username = fields.CharField(max_length=255)
|
||||||
password = fields.BinaryField(null=True) # Hashed - nullable for OIDC users
|
password = fields.BinaryField(null=True) # Hashed - nullable for OIDC users
|
||||||
email = fields.CharField(max_length=100, unique=True)
|
email = fields.CharField(max_length=100, unique=True)
|
||||||
whatsapp_number = fields.CharField(max_length=30, unique=True, null=True, index=True)
|
whatsapp_number = fields.CharField(
|
||||||
|
max_length=30, unique=True, null=True, index=True
|
||||||
|
)
|
||||||
|
imessage_number = fields.CharField(
|
||||||
|
max_length=30, unique=True, null=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Email channel fields
|
# Email channel fields
|
||||||
email_enabled = fields.BooleanField(default=False)
|
email_enabled = fields.BooleanField(default=False)
|
||||||
email_hmac_token = fields.CharField(max_length=16, unique=True, null=True, index=True)
|
email_hmac_token = fields.CharField(
|
||||||
|
max_length=16, unique=True, null=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
# OIDC fields
|
# OIDC fields
|
||||||
oidc_subject = fields.CharField(
|
oidc_subject = fields.CharField(
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class OIDCUserService:
|
|||||||
claims.get("preferred_username") or claims.get("name") or user.username
|
claims.get("preferred_username") or claims.get("name") or user.username
|
||||||
)
|
)
|
||||||
# Update LDAP groups from claims
|
# Update LDAP groups from claims
|
||||||
user.ldap_groups = claims.get("groups", [])
|
user.ldap_groups = claims.get("groups") or []
|
||||||
await user.save()
|
await user.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class OIDCUserService:
|
|||||||
user.oidc_subject = oidc_subject
|
user.oidc_subject = oidc_subject
|
||||||
user.auth_provider = "oidc"
|
user.auth_provider = "oidc"
|
||||||
user.password = None # Clear password
|
user.password = None # Clear password
|
||||||
user.ldap_groups = claims.get("groups", [])
|
user.ldap_groups = claims.get("groups") or []
|
||||||
await user.save()
|
await user.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ class OIDCUserService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Extract LDAP groups from claims
|
# Extract LDAP groups from claims
|
||||||
groups = claims.get("groups", [])
|
groups = claims.get("groups") or []
|
||||||
|
|
||||||
user = await User.create(
|
user = await User.create(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
|
||||||
import functools
|
import functools
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from quart import Blueprint, request, jsonify, abort
|
from quart import Blueprint, request, abort
|
||||||
from twilio.request_validator import RequestValidator
|
from twilio.request_validator import RequestValidator
|
||||||
from twilio.twiml.messaging_response import MessagingResponse
|
from twilio.twiml.messaging_response import MessagingResponse
|
||||||
|
|
||||||
from blueprints.users.models import User
|
from blueprints.users.models import User
|
||||||
from blueprints.conversation.logic import (
|
from blueprints.conversation.logic import (
|
||||||
get_conversation_for_user,
|
get_conversation_for_channel,
|
||||||
add_message_to_conversation,
|
add_message_to_conversation,
|
||||||
get_conversation_transcript,
|
|
||||||
)
|
)
|
||||||
from blueprints.conversation.agents import main_agent
|
from blueprints.conversation.agents import main_agent
|
||||||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||||
@@ -69,6 +67,7 @@ def validate_twilio_request(f):
|
|||||||
so the validated URL matches what Twilio signed against.
|
so the validated URL matches what Twilio signed against.
|
||||||
Set TWILIO_SIGNATURE_VALIDATION=false to disable in development.
|
Set TWILIO_SIGNATURE_VALIDATION=false to disable in development.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@functools.wraps(f)
|
@functools.wraps(f)
|
||||||
async def decorated_function(*args, **kwargs):
|
async def decorated_function(*args, **kwargs):
|
||||||
if os.getenv("TWILIO_SIGNATURE_VALIDATION", "true").lower() == "false":
|
if os.getenv("TWILIO_SIGNATURE_VALIDATION", "true").lower() == "false":
|
||||||
@@ -94,6 +93,7 @@ def validate_twilio_request(f):
|
|||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
return await f(*args, **kwargs)
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
@@ -108,7 +108,11 @@ async def webhook():
|
|||||||
body = form_data.get("Body")
|
body = form_data.get("Body")
|
||||||
|
|
||||||
if not from_number or not body:
|
if not from_number or not body:
|
||||||
return _twiml_response("Invalid message received.") if from_number else ("Missing From or Body", 400)
|
return (
|
||||||
|
_twiml_response("Invalid message received.")
|
||||||
|
if from_number
|
||||||
|
else ("Missing From or Body", 400)
|
||||||
|
)
|
||||||
|
|
||||||
# Strip whitespace and check for empty body
|
# Strip whitespace and check for empty body
|
||||||
body = body.strip()
|
body = body.strip()
|
||||||
@@ -118,12 +122,16 @@ async def webhook():
|
|||||||
# Rate limiting
|
# Rate limiting
|
||||||
if not _check_rate_limit(from_number):
|
if not _check_rate_limit(from_number):
|
||||||
logger.warning(f"Rate limit exceeded for {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.")
|
return _twiml_response(
|
||||||
|
"You're sending messages too quickly. Please wait a moment and try again."
|
||||||
|
)
|
||||||
|
|
||||||
# Truncate overly long messages
|
# Truncate overly long messages
|
||||||
if len(body) > MAX_MESSAGE_LENGTH:
|
if len(body) > MAX_MESSAGE_LENGTH:
|
||||||
body = 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"Truncated long message from {from_number} to {MAX_MESSAGE_LENGTH} chars"
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Received WhatsApp message from {from_number}: {body[:100]}")
|
logger.info(f"Received WhatsApp message from {from_number}: {body[:100]}")
|
||||||
|
|
||||||
@@ -143,16 +151,18 @@ async def webhook():
|
|||||||
username=username,
|
username=username,
|
||||||
email=f"{username}@whatsapp.simbarag.local",
|
email=f"{username}@whatsapp.simbarag.local",
|
||||||
whatsapp_number=from_number,
|
whatsapp_number=from_number,
|
||||||
auth_provider="whatsapp"
|
auth_provider="whatsapp",
|
||||||
)
|
)
|
||||||
logger.info(f"Created new user for WhatsApp: {username}")
|
logger.info(f"Created new user for WhatsApp: {username}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create user for {from_number}: {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.")
|
return _twiml_response(
|
||||||
|
"Sorry, something went wrong setting up your account. Please try again later."
|
||||||
|
)
|
||||||
|
|
||||||
# Get or create a conversation for this user
|
# Get or create a conversation for this user
|
||||||
try:
|
try:
|
||||||
conversation = await get_conversation_for_user(user=user)
|
conversation = await get_conversation_for_channel(user=user, channel="whatsapp")
|
||||||
await conversation.fetch_related("messages")
|
await conversation.fetch_related("messages")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get conversation for user {user.username}: {e}")
|
logger.error(f"Failed to get conversation for user {user.username}: {e}")
|
||||||
@@ -166,9 +176,6 @@ async def webhook():
|
|||||||
user=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
|
# Build messages payload for LangChain agent with system prompt and conversation history
|
||||||
try:
|
try:
|
||||||
# Get last 10 messages for conversation history
|
# Get last 10 messages for conversation history
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ TORTOISE_CONFIG = {
|
|||||||
"blueprints.conversation.models",
|
"blueprints.conversation.models",
|
||||||
"blueprints.users.models",
|
"blueprints.users.models",
|
||||||
"blueprints.email.models",
|
"blueprints.email.models",
|
||||||
|
"blueprints.scheduled_messages.models",
|
||||||
"aerich.models",
|
"aerich.models",
|
||||||
],
|
],
|
||||||
"default_connection": "default",
|
"default_connection": "default",
|
||||||
|
|||||||
+37
-5
@@ -2,7 +2,7 @@ version: "3.8"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: pgvector/pgvector:pg16
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
- POSTGRES_DB=${POSTGRES_DB:-raggr}
|
- POSTGRES_DB=${POSTGRES_DB:-raggr}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./docker/init-pgvector.sql:/docker-entrypoint-initdb.d/init-pgvector.sql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-raggr}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-raggr}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -29,8 +30,9 @@ services:
|
|||||||
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN}
|
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN}
|
||||||
- BASE_URL=${BASE_URL}
|
- BASE_URL=${BASE_URL}
|
||||||
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
|
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
|
||||||
- CHROMADB_PATH=/app/data/chromadb
|
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
- EMBEDDING_SERVER_URL=${EMBEDDING_SERVER_URL}
|
||||||
|
- EMBEDDING_MODEL_NAME=${EMBEDDING_MODEL_NAME}
|
||||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||||
- LLAMA_SERVER_URL=${LLAMA_SERVER_URL}
|
- LLAMA_SERVER_URL=${LLAMA_SERVER_URL}
|
||||||
- LLAMA_MODEL_NAME=${LLAMA_MODEL_NAME}
|
- LLAMA_MODEL_NAME=${LLAMA_MODEL_NAME}
|
||||||
@@ -43,13 +45,16 @@ services:
|
|||||||
- TAVILY_API_KEY=${TAVILIY_API_KEY}
|
- TAVILY_API_KEY=${TAVILIY_API_KEY}
|
||||||
- YNAB_ACCESS_TOKEN=${YNAB_ACCESS_TOKEN}
|
- YNAB_ACCESS_TOKEN=${YNAB_ACCESS_TOKEN}
|
||||||
- YNAB_BUDGET_ID=${YNAB_BUDGET_ID}
|
- YNAB_BUDGET_ID=${YNAB_BUDGET_ID}
|
||||||
|
- MEALIE_BASE_URL=${MEALIE_BASE_URL}
|
||||||
|
- MEALIE_API_TOKEN=${MEALIE_API_TOKEN}
|
||||||
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
|
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
|
||||||
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
|
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
|
||||||
- TWILIO_WHATSAPP_NUMBER=${TWILIO_WHATSAPP_NUMBER}
|
- TWILIO_WHATSAPP_NUMBER=${TWILIO_WHATSAPP_NUMBER}
|
||||||
- ALLOWED_WHATSAPP_NUMBERS=${ALLOWED_WHATSAPP_NUMBERS}
|
- ALLOWED_WHATSAPP_NUMBERS=${ALLOWED_WHATSAPP_NUMBERS}
|
||||||
- TWILIO_SIGNATURE_VALIDATION=${TWILIO_SIGNATURE_VALIDATION:-true}
|
- TWILIO_SIGNATURE_VALIDATION=${TWILIO_SIGNATURE_VALIDATION:-true}
|
||||||
- TWILIO_WEBHOOK_URL=${TWILIO_WEBHOOK_URL:-}
|
- TWILIO_WEBHOOK_URL=${TWILIO_WEBHOOK_URL:-}
|
||||||
- OBSIDIAN_AUTH_TOKEN=${OBSIDIAN_AUTH_TOKEN}
|
- OBSIDIAN_EMAIL=${OBSIDIAN_EMAIL}
|
||||||
|
- OBSIDIAN_PASSWORD=${OBSIDIAN_PASSWORD}
|
||||||
- OBSIDIAN_VAULT_ID=${OBSIDIAN_VAULT_ID}
|
- OBSIDIAN_VAULT_ID=${OBSIDIAN_VAULT_ID}
|
||||||
- OBSIDIAN_E2E_PASSWORD=${OBSIDIAN_E2E_PASSWORD}
|
- OBSIDIAN_E2E_PASSWORD=${OBSIDIAN_E2E_PASSWORD}
|
||||||
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
|
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
|
||||||
@@ -62,14 +67,41 @@ services:
|
|||||||
- S3_REGION=${S3_REGION:-garage}
|
- S3_REGION=${S3_REGION:-garage}
|
||||||
- OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11434}
|
- OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11434}
|
||||||
- FERNET_KEY=${FERNET_KEY}
|
- FERNET_KEY=${FERNET_KEY}
|
||||||
|
- GOOGLE_CALENDAR_ENABLED=${GOOGLE_CALENDAR_ENABLED:-}
|
||||||
|
- GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=${GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE:-/app/config/gws-credentials.json}
|
||||||
|
- MAILGUN_API_KEY=${MAILGUN_API_KEY}
|
||||||
|
- MAILGUN_DOMAIN=${MAILGUN_DOMAIN}
|
||||||
|
- MAILGUN_WEBHOOK_SIGNING_KEY=${MAILGUN_WEBHOOK_SIGNING_KEY}
|
||||||
|
- MAILGUN_SIGNATURE_VALIDATION=${MAILGUN_SIGNATURE_VALIDATION:-true}
|
||||||
|
- EMAIL_RATE_LIMIT_MAX=${EMAIL_RATE_LIMIT_MAX:-5}
|
||||||
|
- EMAIL_RATE_LIMIT_WINDOW=${EMAIL_RATE_LIMIT_WINDOW:-300}
|
||||||
|
- SENDBLUE_API_KEY=${SENDBLUE_API_KEY}
|
||||||
|
- SENDBLUE_API_SECRET=${SENDBLUE_API_SECRET}
|
||||||
|
- SENDBLUE_FROM_NUMBER=${SENDBLUE_FROM_NUMBER}
|
||||||
|
- SENDBLUE_WEBHOOK_SECRET=${SENDBLUE_WEBHOOK_SECRET}
|
||||||
|
- SENDBLUE_SIGNATURE_VALIDATION=${SENDBLUE_SIGNATURE_VALIDATION:-true}
|
||||||
|
- ALLOWED_IMESSAGE_NUMBERS=${ALLOWED_IMESSAGE_NUMBERS}
|
||||||
|
- SIMBAKIT_URL=${SIMBAKIT_URL:-http://simbakit:8585}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- chromadb_data:/app/data/chromadb
|
|
||||||
- ./obvault:/app/data/obsidian
|
- ./obvault:/app/data/obsidian
|
||||||
|
- ./credentials.json:/app/config/gws-credentials.json:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
simbakit:
|
||||||
|
build:
|
||||||
|
context: ./simbakit
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- simbakit-data:/app/data
|
||||||
|
environment:
|
||||||
|
- PETKIT_USERNAME=${PETKIT_USERNAME}
|
||||||
|
- PETKIT_PASSWORD=${PETKIT_PASSWORD}
|
||||||
|
- PETKIT_REGION=${PETKIT_REGION:-US}
|
||||||
|
- PETKIT_TIMEZONE=${PETKIT_TIMEZONE:-America/New_York}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
chromadb_data:
|
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
simbakit-data:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
|
|
||||||
from dotenv import 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", ""))
|
|
||||||
simba_docs = client.get_or_create_collection(name="simba_docs2")
|
|
||||||
feline_vet_lookup = client.get_or_create_collection(name="feline_vet_lookup")
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="An LLM tool to query information about Simba <3"
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument("query", type=str, help="questions about simba's health")
|
|
||||||
parser.add_argument(
|
|
||||||
"--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 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
|
|
||||||
chunker = Chunker(collection)
|
|
||||||
|
|
||||||
logging.info(f"chunking {len(docs)} documents")
|
|
||||||
texts: list[str] = [doc["content"] for doc in docs]
|
|
||||||
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 chunk_text(texts: list[str], collection):
|
|
||||||
chunker = Chunker(collection)
|
|
||||||
|
|
||||||
for index, text in enumerate(texts):
|
|
||||||
metadata = {}
|
|
||||||
chunker.chunk_document(
|
|
||||||
document=text,
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
|
||||||
# Step 1: Get the text
|
|
||||||
ppngx = PaperlessNGXService()
|
|
||||||
docs = ppngx.get_data()
|
|
||||||
|
|
||||||
chunk_data(docs, collection=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__":
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.reindex:
|
|
||||||
reindex()
|
|
||||||
|
|
||||||
if args.classify:
|
|
||||||
consult_simba_oracle(input="yohohoho testing")
|
|
||||||
consult_simba_oracle(input="write an email")
|
|
||||||
consult_simba_oracle(input="how much does simba weigh")
|
|
||||||
|
|
||||||
if args.query:
|
|
||||||
logging.info("Consulting oracle ...")
|
|
||||||
print(
|
|
||||||
consult_oracle(
|
|
||||||
input=args.query,
|
|
||||||
collection=simba_docs,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logging.info("please provide a query")
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
CREATE TABLE IF NOT EXISTS "user_memories" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "email_accounts" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"email_address" VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
"display_name" VARCHAR(255),
|
||||||
|
"imap_host" VARCHAR(255) NOT NULL,
|
||||||
|
"imap_port" INT NOT NULL DEFAULT 993,
|
||||||
|
"imap_username" VARCHAR(255) NOT NULL,
|
||||||
|
"imap_password" TEXT NOT NULL,
|
||||||
|
"is_active" BOOL NOT NULL DEFAULT True,
|
||||||
|
"last_error" TEXT,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE "email_accounts" IS 'Email account configuration for IMAP connections.';
|
||||||
|
CREATE TABLE IF NOT EXISTS "emails" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"message_id" VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
"subject" VARCHAR(500) NOT NULL,
|
||||||
|
"from_address" VARCHAR(255) NOT NULL,
|
||||||
|
"to_address" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMPTZ NOT NULL,
|
||||||
|
"body_text" TEXT,
|
||||||
|
"body_html" TEXT,
|
||||||
|
"chromadb_doc_id" VARCHAR(255),
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expires_at" TIMESTAMPTZ NOT NULL,
|
||||||
|
"account_id" UUID NOT NULL REFERENCES "email_accounts" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_emails_message_981ddd" ON "emails" ("message_id");
|
||||||
|
COMMENT ON TABLE "emails" IS 'Email message metadata and content.';
|
||||||
|
CREATE TABLE IF NOT EXISTS "email_sync_status" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"last_sync_date" TIMESTAMPTZ,
|
||||||
|
"last_message_uid" INT NOT NULL DEFAULT 0,
|
||||||
|
"message_count" INT NOT NULL DEFAULT 0,
|
||||||
|
"consecutive_failures" INT NOT NULL DEFAULT 0,
|
||||||
|
"last_failure_date" TIMESTAMPTZ,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"account_id" UUID NOT NULL REFERENCES "email_accounts" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE "email_sync_status" IS 'Tracks sync progress and state per email account.';"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
DROP TABLE IF EXISTS "user_memories";
|
||||||
|
DROP TABLE IF EXISTS "email_accounts";
|
||||||
|
DROP TABLE IF EXISTS "emails";
|
||||||
|
DROP TABLE IF EXISTS "email_sync_status";"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"eJztXGtv2zYU/SuCPrVAFjTPbcUwwE7czVudDLGz9ZFCoCXa1ixRGkk1NYr+911Skq0HZV"
|
||||||
|
"t+RUr1oU1C8lLU4SV57tGVvuquZ2GHHV955DOmDHHbI/pr7atOkIvhF2X9kaYj31/UigKO"
|
||||||
|
"ho40MBMtZQ0aMk6RyaFyhByGocjCzKS2H12MBI4jCj0TGtpkvCgKiP1fgA3ujTGfYAoVHz"
|
||||||
|
"9BsU0s/AWz+E9/aoxs7FipcduWuLYsN/jMl2X3993rN7KluNzQMD0ncMmitT/jE4/MmweB"
|
||||||
|
"bR0LG1E3xgRTxLGVuA0xyui246JwxFDAaYDnQ7UWBRYeocARYOi/jAJiCgw0eSXx3/mveg"
|
||||||
|
"l4AGoBrU24wOLrt/CuFvcsS3VxqavfW3cvzi5fyrv0GB9TWSkR0b9JQ8RRaCpxXQApf+ag"
|
||||||
|
"vJogqoYybp8BEwa6CYxxwQLHhQ/FQMYAbYaa7qIvhoPJmE/gz9OLiyUw/t26k0hCKwmlB3"
|
||||||
|
"4dev1NVHUa1glIFxCaFItbNhDPA3kNNdx2sRrMtGUGUisyPY5/qSjAcA/WLXFm0SJYgu+g"
|
||||||
|
"2+v0B63eX+JOXMb+cyRErUFH1JzK0lmm9MVlZirmnWj/dAe/a+JP7cPtTSfr+/N2gw+6GB"
|
||||||
|
"MKuGcQ79FAVmK9xqUxMKmJDXxrw4lNWzYT+6QTGw0+Ma8MU6PcCZIw2eIYicZ2wEnc/NAQ"
|
||||||
|
"R+9oqjwzBBh58N54FNtj8ieeSQi7MA5ETNVhEZGO+6ibqoK2KF2MgqLHORtJOgXcHdwT5u"
|
||||||
|
"Hp2epfta47usRwiMzpI6KWUQCmixlDY8zygLYjyzd/3mFnTs3UWCYJXC/ssZq7ShG2Eivv"
|
||||||
|
"1EtglEIvX+WeutkSROC+reja4kpL0FnBghMgrkeGjeRENqS41qSY4y+KI38ApWoo4/Z1Ic"
|
||||||
|
"XLjvLOu0HqFI+p74te693L1En+9vbmt7h5gipfvb1tNwz5ORKpPENmPkZTFRkQAWSHBG6O"
|
||||||
|
"CqRmN2H+xEtHv+937l5r4kR/IP1ur916rTHbHSJ9vSlORZknr9YIMk9eFcaYoiq9gGwXTh"
|
||||||
|
"ZjimdlQvWU0Ub4Hp56pYG8ODldA0loVQilrMtsRslDu9yRqTDd5flZ03DAzIiHW4YFWS2y"
|
||||||
|
"siiujA8U7lI2TtgnKxbxVw+7Hp3pCjKcqF3KgWUQ5IqGdsN9nwH3hYtwTErR34RJw4AbBv"
|
||||||
|
"xdMeBGI34WE1sdjbham2FdROIKs8AtVOJ9s78i3rea8TVMr/5MT8xj2cf/SZu6cL0DpAD4"
|
||||||
|
"iLFHjyo8s20TRGdqMJNWGTCHMx5GU5VTaJaA1xa8N3m6A2Tt7k3r7r2aOsftk37bfj/otD"
|
||||||
|
"LoYhfZThkvnRvsxkVXr/hdOujJq/Xkw2X6YU5AfJwgzmBLN0jgDosEWzWYCtOdiImHRfVs"
|
||||||
|
"HVDPijE9y0EqnczARNyeauF7noMRWeKgSdvs8gfjfW2mZY/qEuv/9vZtav23u9nQ+L7X7o"
|
||||||
|
"DzSpihkR1Soe7NQAnuxEUmcIQpVuiKK1Z/xraGHntyuc42kI2QErvAZdZjPdsyDRYM/8Wm"
|
||||||
|
"IlotBjRrV0Mw93LqQ/w4MXzqfbatcltqzvBwVEp3PBM5W3DRzBOadbbVi+Jt9SK3rToW8o"
|
||||||
|
"0x9QJfkRLzR//2Rg1pxiwD6D2Bu/xo2SY/0hyb8U97g/fjp/3wfHHny1XJrACZIVaig0aV"
|
||||||
|
"fJbiVaNKPtOJnSfG5VShVVmFudc0dpNaWOWINJ9SmFwRySeUm2ORfihaPc9fC4qQHyPT9A"
|
||||||
|
"JhthUgHdFXK+yqZpDsU1yVsOgKdbUTKxPF8qqcnvX0VV12p0WZp/CTI6H2aYhYWvRQ9ljP"
|
||||||
|
"oLSOzQN5IH3uwbal+YgybGlyUJps+GjzCYTTP1hoplEs2sNgjrW3NpkyjXva1YR6LrpuP5"
|
||||||
|
"CRR7XPEDPAD4YRNSeaiXw0tCHsg5UoR9bowDvih1vowJErKJ92Fccwaas6Cm17iQk3CK+3"
|
||||||
|
"jayfXFK/WEuxvFiiWF7kFcsR7CKCGIEfK86oYjSzdvWEdC++CbyyENAlye1eHeE8dIKPiI"
|
||||||
|
"jKxlqxTT2jrJpEVfFtL42Xh541M8q+9ZEyqkl+9aGXhcRowl3F47sVwMZGDbDqhELJsuGK"
|
||||||
|
"MLSSzE1hWhOQm5f5G+VsU0kUf/Ft6G2DiU1b1nNiazKRax3WkXJVMjszbdUkaMaA5DEsna"
|
||||||
|
"NZXxHwKJOrmXaSKqVrpjAuEhYTc7BCX0zJv+vqjJGNkAlH9jigUhvWhMrX7bX+EsUES7mL"
|
||||||
|
"FamOJXpIaJBzK4otITfCGEMVEhOTznxwNC1OpTtK9KExzDlcnh09EKFuxt2AO/OAHWv9wP"
|
||||||
|
"c9ypnmgovZvoPjFkzzMZXvgjYaZUU0yshpy8tBOcNGqYwVC5v5DpoZZVOAs3ZN7JB4S9s3"
|
||||||
|
"JuDYZeBMGdVFXTsUmGJ/zoPZJQXCQcomg6W9P27y889nW0Apv0Xzw+nJ+Y/nP51dnv8ETe"
|
||||||
|
"RQ5iU/LgE3nzkpMdgktT9n2DhjxhkLk/w7MQ8p1rRyPdQF3UMLWzYE2sBHPit8d2lKdcru"
|
||||||
|
"gOnU84O/wtnUDmLcwJR6iizVYpdNW9XkmG9e7G70wiaFspnY5sXu5sXu6r7YnRE2dpGEWS"
|
||||||
|
"8s0zlTM2IaoSq3AyD60Ft/3lmNINm7fJxApkhBToO3SkTOTNxqHXkA1VOmCTvNp57YdJjM"
|
||||||
|
"PBWdYCm74qRQnNeRS/cgdOSegBn+MU1w2tBYnL1g4/pHYSF0ZkJf2JqnxsJOGCnHI+gwoF"
|
||||||
|
"gTdzeFgYg0Vxaqx5oNsR92MfTvhB0LA8matQn86kDzRkWuiIosIxrptJuka+Wtd8DtqhUh"
|
||||||
|
"VYjKrfUoWE5JnIkcqNZJoVaoMj2cZPhqC2q+Y8EwxqDgaXAhgDm77xI90T02AyE8GdExoS"
|
||||||
|
"AxhSAWmX+XWMolGaGw+Q6d7aDZpJ94k26UlOeppDR5WM8sD2vfSQ31z8JqYWqbE10RPUc1"
|
||||||
|
"R8uCZrRoU5kv5xU/S1+TEUcT+KTJMjvhIcVho3j9Xflt8+KH6QmTujzoPcQHc2BplAAxal"
|
||||||
|
"5PAPfyGbfCj3MXfxin+OPcB/sozt4O3Z19FKfENzZ2f7x8+x8fHBMe"
|
||||||
|
)
|
||||||
+2
-2
@@ -5,7 +5,8 @@ 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",
|
"langchain-postgres>=0.0.13",
|
||||||
|
"psycopg[binary]>=3.1.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"flask>=3.1.2",
|
"flask>=3.1.2",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
@@ -30,7 +31,6 @@ dependencies = [
|
|||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"langchain-openai>=1.1.6",
|
"langchain-openai>=1.1.6",
|
||||||
"langchain>=1.2.0",
|
"langchain>=1.2.0",
|
||||||
"langchain-chroma>=1.0.0",
|
|
||||||
"langchain-community>=0.4.1",
|
"langchain-community>=0.4.1",
|
||||||
"jq>=1.10.0",
|
"jq>=1.10.0",
|
||||||
"tavily-python>=0.7.17",
|
"tavily-python>=0.7.17",
|
||||||
|
|||||||
@@ -1,48 +1,13 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import { ChatScreen } from "./components/ChatScreen";
|
import { ChatScreen } from "./components/ChatScreen";
|
||||||
import { LoginScreen } from "./components/LoginScreen";
|
import { LoginScreen } from "./components/LoginScreen";
|
||||||
import { conversationService } from "./api/conversationService";
|
import { useAuthCheck } from "./hooks/useAuthCheck";
|
||||||
import catIcon from "./assets/cat.png";
|
import catIcon from "./assets/cat.png";
|
||||||
|
|
||||||
const AppContainer = () => {
|
const AppContainer = () => {
|
||||||
const [isAuthenticated, setAuthenticated] = useState<boolean>(false);
|
const { isAuthenticated, isChecking, isAdmin, setAuthenticated } = useAuthCheck();
|
||||||
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) {
|
if (isChecking) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
||||||
@@ -61,7 +26,7 @@ const AppContainer = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<ChatScreen setAuthenticated={setAuthenticated} />
|
<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />
|
||||||
) : (
|
) : (
|
||||||
<LoginScreen setAuthenticated={setAuthenticated} />
|
<LoginScreen setAuthenticated={setAuthenticated} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { userService } from "./userService";
|
||||||
|
|
||||||
|
export interface ScheduledMessage {
|
||||||
|
id: string;
|
||||||
|
recipient: string;
|
||||||
|
channel: "imessage" | "email";
|
||||||
|
content: string;
|
||||||
|
subject: string | null;
|
||||||
|
scheduled_at: string;
|
||||||
|
status: "pending" | "sent" | "failed" | "cancelled";
|
||||||
|
recurrence: "none" | "daily" | "weekly" | "monthly";
|
||||||
|
use_agent: boolean;
|
||||||
|
error_message: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScheduledMessage {
|
||||||
|
recipient: string;
|
||||||
|
channel: "imessage" | "email";
|
||||||
|
content: string;
|
||||||
|
subject?: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
recurrence?: "none" | "daily" | "weekly" | "monthly";
|
||||||
|
use_agent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScheduledMessageService {
|
||||||
|
private baseUrl = "/api/scheduled-messages";
|
||||||
|
|
||||||
|
async list(): Promise<ScheduledMessage[]> {
|
||||||
|
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`);
|
||||||
|
if (!response.ok) throw new Error("Failed to list scheduled messages");
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: CreateScheduledMessage): Promise<ScheduledMessage> {
|
||||||
|
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.error ?? "Failed to create scheduled message");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<CreateScheduledMessage> & { status?: string }): Promise<ScheduledMessage> {
|
||||||
|
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.error ?? "Failed to update scheduled message");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.error ?? "Failed to delete scheduled message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scheduledMessageService = new ScheduledMessageService();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react";
|
import { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react";
|
||||||
import { userService, type AdminUserRecord } from "../api/userService";
|
import { userService, type AdminUserRecord } from "../api/userService";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
@@ -12,27 +12,19 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "./ui/table";
|
} from "./ui/table";
|
||||||
|
import { useAdminUsers } from "../hooks/useAdminUsers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminPanel = ({ onClose }: Props) => {
|
export const AdminPanel = ({ onClose }: Props) => {
|
||||||
const [users, setUsers] = useState<AdminUserRecord[]>([]);
|
const { users, loading, updateUser } = useAdminUsers();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
const [rowError, setRowError] = useState<Record<string, string>>({});
|
const [rowError, setRowError] = useState<Record<string, string>>({});
|
||||||
const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({});
|
const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
userService
|
|
||||||
.adminListUsers()
|
|
||||||
.then(setUsers)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startEdit = (user: AdminUserRecord) => {
|
const startEdit = (user: AdminUserRecord) => {
|
||||||
setEditingId(user.id);
|
setEditingId(user.id);
|
||||||
setEditValue(user.whatsapp_number ?? "");
|
setEditValue(user.whatsapp_number ?? "");
|
||||||
@@ -49,8 +41,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||||
try {
|
try {
|
||||||
const updated = await userService.adminSetWhatsapp(userId, editValue);
|
const updated = await userService.adminSetWhatsapp(userId, editValue);
|
||||||
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
|
updateUser(userId, () => updated);
|
||||||
setRowSuccess((p) => ({ ...p, [userId]: "Saved ✓" }));
|
setRowSuccess((p) => ({ ...p, [userId]: "Saved" }));
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -65,10 +57,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||||
try {
|
try {
|
||||||
await userService.adminUnlinkWhatsapp(userId);
|
await userService.adminUnlinkWhatsapp(userId);
|
||||||
setUsers((p) =>
|
updateUser(userId, (u) => ({ ...u, whatsapp_number: null }));
|
||||||
p.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)),
|
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked" }));
|
||||||
);
|
|
||||||
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked ✓" }));
|
|
||||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRowError((p) => ({
|
setRowError((p) => ({
|
||||||
@@ -82,8 +72,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||||
try {
|
try {
|
||||||
const updated = await userService.adminToggleEmail(userId);
|
const updated = await userService.adminToggleEmail(userId);
|
||||||
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
|
updateUser(userId, () => updated);
|
||||||
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled ✓" }));
|
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled" }));
|
||||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRowError((p) => ({
|
setRowError((p) => ({
|
||||||
@@ -97,10 +87,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||||
try {
|
try {
|
||||||
await userService.adminDisableEmail(userId);
|
await userService.adminDisableEmail(userId);
|
||||||
setUsers((p) =>
|
updateUser(userId, (u) => ({ ...u, email_enabled: false, email_address: null }));
|
||||||
p.map((u) => (u.id === userId ? { ...u, email_enabled: false, email_address: null } : u)),
|
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled" }));
|
||||||
);
|
|
||||||
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled ✓" }));
|
|
||||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRowError((p) => ({
|
setRowError((p) => ({
|
||||||
@@ -112,7 +100,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
|
|
||||||
const copyToClipboard = (text: string, userId: string) => {
|
const copyToClipboard = (text: string, userId: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
setRowSuccess((p) => ({ ...p, [userId]: "Copied ✓" }));
|
setRowSuccess((p) => ({ ...p, [userId]: "Copied" }));
|
||||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,7 +116,6 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
"border border-sand-light/60",
|
"border border-sand-light/60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center">
|
<div className="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center">
|
||||||
@@ -146,7 +133,6 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="overflow-y-auto flex-1 rounded-b-3xl">
|
<div className="overflow-y-auto flex-1 rounded-b-3xl">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||||
@@ -155,7 +141,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||||
</div>
|
</div>
|
||||||
Loading users…
|
Loading users...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
@@ -204,7 +190,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
: "text-warm-gray/40 italic",
|
: "text-warm-gray/40 italic",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{user.whatsapp_number ?? "—"}
|
{user.whatsapp_number ?? "\u2014"}
|
||||||
</span>
|
</span>
|
||||||
{rowSuccess[user.id] && (
|
{rowSuccess[user.id] && (
|
||||||
<span className="text-xs text-leaf-dark">
|
<span className="text-xs text-leaf-dark">
|
||||||
@@ -235,7 +221,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-warm-gray/40 italic">—</span>
|
<span className="text-sm text-warm-gray/40 italic">\u2014</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
@@ -6,7 +7,7 @@ type AnswerBubbleProps = {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
export const AnswerBubble = React.memo(({ text, loading }: AnswerBubbleProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start message-enter">
|
<div className="flex justify-start message-enter">
|
||||||
<div
|
<div
|
||||||
@@ -17,7 +18,6 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
|||||||
"overflow-hidden",
|
"overflow-hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* amber accent bar */}
|
|
||||||
<div className="h-0.5 w-full bg-gradient-to-r from-amber-soft via-amber-glow/50 to-transparent" />
|
<div className="h-0.5 w-full bg-gradient-to-r from-amber-soft via-amber-glow/50 to-transparent" />
|
||||||
|
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
@@ -36,4 +36,4 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -1,232 +1,102 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useCallback, useState, useRef } from "react";
|
||||||
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
|
import { LogOut, Shield, Clock, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
|
||||||
import { conversationService } from "../api/conversationService";
|
|
||||||
import { userService } from "../api/userService";
|
|
||||||
import { QuestionBubble } from "./QuestionBubble";
|
import { QuestionBubble } from "./QuestionBubble";
|
||||||
import { AnswerBubble } from "./AnswerBubble";
|
import { AnswerBubble } from "./AnswerBubble";
|
||||||
import { ToolBubble } from "./ToolBubble";
|
import { ToolBubble } from "./ToolBubble";
|
||||||
import { MessageInput } from "./MessageInput";
|
import { MessageInput } from "./MessageInput";
|
||||||
import { ConversationList } from "./ConversationList";
|
import { ConversationList } from "./ConversationList";
|
||||||
import { AdminPanel } from "./AdminPanel";
|
import { AdminPanel } from "./AdminPanel";
|
||||||
|
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { useConversations } from "../hooks/useConversations";
|
||||||
|
import { useChat } from "../hooks/useChat";
|
||||||
import catIcon from "../assets/cat.png";
|
import catIcon from "../assets/cat.png";
|
||||||
|
|
||||||
type Message = {
|
|
||||||
text: string;
|
|
||||||
speaker: "simba" | "user" | "tool";
|
|
||||||
image_key?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Conversation = {
|
|
||||||
title: string;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ChatScreenProps = {
|
type ChatScreenProps = {
|
||||||
setAuthenticated: (isAuth: boolean) => void;
|
setAuthenticated: (isAuth: boolean) => void;
|
||||||
|
isAdmin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOOL_MESSAGES: Record<string, string> = {
|
export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||||
simba_search: "🔍 Searching Simba's records...",
|
const [query, setQuery] = useState("");
|
||||||
web_search: "🌐 Searching the web...",
|
const [simbaMode, setSimbaMode] = useState(false);
|
||||||
get_current_date: "📅 Checking today's date...",
|
const [showConversations, setShowConversations] = useState(false);
|
||||||
ynab_budget_summary: "💰 Checking budget summary...",
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
ynab_search_transactions: "💳 Looking up transactions...",
|
const [showAdminPanel, setShowAdminPanel] = useState(false);
|
||||||
ynab_category_spending: "📊 Analyzing category spending...",
|
const [showScheduler, setShowScheduler] = useState(false);
|
||||||
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 [simbaMode, setSimbaMode] = useState<boolean>(false);
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
||||||
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 [isAdmin, setIsAdmin] = useState<boolean>(false);
|
|
||||||
const [showAdminPanel, setShowAdminPanel] = useState<boolean>(false);
|
|
||||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const isMountedRef = useRef<boolean>(true);
|
const isLoadingRef = useRef(false);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = useCallback(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
requestAnimationFrame(() => {
|
||||||
};
|
messagesEndRef.current?.scrollIntoView({
|
||||||
|
behavior: isLoadingRef.current ? "instant" : "smooth",
|
||||||
useEffect(() => {
|
|
||||||
isMountedRef.current = true;
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
abortControllerRef.current?.abort();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectConversation = (conversation: Conversation) => {
|
|
||||||
setShowConversations(false);
|
|
||||||
setSelectedConversation(conversation);
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
const fetched = await conversationService.getConversation(conversation.id);
|
|
||||||
setMessages(
|
|
||||||
fetched.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load messages:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadConversations = async () => {
|
|
||||||
try {
|
|
||||||
const fetched = await conversationService.getAllConversations();
|
|
||||||
const parsed = fetched.map((c) => ({ id: c.id, title: c.name }));
|
|
||||||
setConversations(parsed);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load conversations:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateNewConversation = async () => {
|
|
||||||
const newConv = await conversationService.createConversation();
|
|
||||||
await loadConversations();
|
|
||||||
setSelectedConversation({ title: newConv.name, id: newConv.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConversations();
|
|
||||||
userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const load = async () => {
|
|
||||||
if (!selectedConversation) return;
|
|
||||||
try {
|
|
||||||
const conv = await conversationService.getConversation(selectedConversation.id);
|
|
||||||
setSelectedConversation({ id: conv.id, title: conv.name });
|
|
||||||
setMessages(conv.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load messages:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, [selectedConversation?.id]);
|
|
||||||
|
|
||||||
const handleQuestionSubmit = async () => {
|
|
||||||
if ((!query.trim() && !pendingImage) || isLoading) return;
|
|
||||||
|
|
||||||
let activeConversation = selectedConversation;
|
|
||||||
if (!activeConversation) {
|
|
||||||
const newConv = await conversationService.createConversation();
|
|
||||||
activeConversation = { title: newConv.name, id: newConv.id };
|
|
||||||
setSelectedConversation(activeConversation);
|
|
||||||
setConversations((prev) => [activeConversation!, ...prev]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture pending image before clearing state
|
|
||||||
const imageFile = pendingImage;
|
|
||||||
|
|
||||||
const currMessages = messages.concat([{ text: query, speaker: "user" }]);
|
|
||||||
setMessages(currMessages);
|
|
||||||
setQuery("");
|
|
||||||
setPendingImage(null);
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
if (simbaMode) {
|
|
||||||
const randomElement = simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)];
|
|
||||||
setMessages((prev) => prev.concat([{ text: randomElement, speaker: "simba" }]));
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortController = new AbortController();
|
|
||||||
abortControllerRef.current = abortController;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Upload image first if present
|
|
||||||
let imageKey: string | undefined;
|
|
||||||
if (imageFile) {
|
|
||||||
const uploadResult = await conversationService.uploadImage(
|
|
||||||
imageFile,
|
|
||||||
activeConversation.id,
|
|
||||||
);
|
|
||||||
imageKey = uploadResult.image_key;
|
|
||||||
|
|
||||||
// Update the user message with the image key
|
|
||||||
setMessages((prev) => {
|
|
||||||
const updated = [...prev];
|
|
||||||
// Find the last user message we just added
|
|
||||||
for (let i = updated.length - 1; i >= 0; i--) {
|
|
||||||
if (updated[i].speaker === "user") {
|
|
||||||
updated[i] = { ...updated[i], image_key: imageKey };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
await conversationService.streamQuery(
|
const {
|
||||||
query,
|
conversations,
|
||||||
activeConversation.id,
|
selectedConversation,
|
||||||
(event) => {
|
selectConversation,
|
||||||
if (!isMountedRef.current) return;
|
createConversation,
|
||||||
if (event.type === "tool_start") {
|
refreshConversations,
|
||||||
const friendly = TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`;
|
} = useConversations();
|
||||||
setMessages((prev) => prev.concat([{ text: friendly, speaker: "tool" }]));
|
|
||||||
} else if (event.type === "response") {
|
const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]);
|
||||||
setMessages((prev) => prev.concat([{ text: event.message, speaker: "simba" }]));
|
|
||||||
} else if (event.type === "error") {
|
const {
|
||||||
console.error("Stream error:", event.message);
|
messages,
|
||||||
}
|
setMessages,
|
||||||
|
isLoading,
|
||||||
|
pendingImage,
|
||||||
|
setPendingImage,
|
||||||
|
sendMessage,
|
||||||
|
} = useChat({
|
||||||
|
selectedConversation,
|
||||||
|
createConversation,
|
||||||
|
refreshConversations,
|
||||||
|
onSessionExpired,
|
||||||
|
scrollToBottom,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep ref in sync for scrollToBottom behavior
|
||||||
|
isLoadingRef.current = isLoading;
|
||||||
|
|
||||||
|
const handleSelectConversation = useCallback(
|
||||||
|
async (conversation: { title: string; id: string }) => {
|
||||||
|
setShowConversations(false);
|
||||||
|
const loaded = await selectConversation(conversation);
|
||||||
|
setMessages(loaded);
|
||||||
},
|
},
|
||||||
abortController.signal,
|
[selectConversation, setMessages],
|
||||||
imageKey,
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
|
||||||
console.log("Request was aborted");
|
|
||||||
} else {
|
|
||||||
console.error("Failed to send query:", error);
|
|
||||||
if (error instanceof Error && error.message.includes("Session expired")) {
|
|
||||||
setAuthenticated(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMountedRef.current) setIsLoading(false);
|
|
||||||
abortControllerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQueryChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleCreateNewConversation = useCallback(async () => {
|
||||||
|
await createConversation();
|
||||||
|
setMessages([]);
|
||||||
|
}, [createConversation, setMessages]);
|
||||||
|
|
||||||
|
const handleQuestionSubmit = useCallback(() => {
|
||||||
|
sendMessage(query, simbaMode);
|
||||||
|
setQuery("");
|
||||||
|
}, [query, simbaMode, sendMessage]);
|
||||||
|
|
||||||
|
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setQuery(event.target.value);
|
setQuery(event.target.value);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
||||||
if (kev.key === "Enter" && !kev.shiftKey) {
|
if (kev.key === "Enter" && !kev.shiftKey) {
|
||||||
kev.preventDefault();
|
kev.preventDefault();
|
||||||
handleQuestionSubmit();
|
handleQuestionSubmit();
|
||||||
}
|
}
|
||||||
};
|
}, [handleQuestionSubmit]);
|
||||||
|
|
||||||
|
const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
|
||||||
|
const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("access_token");
|
localStorage.removeItem("access_token");
|
||||||
@@ -236,7 +106,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
|
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
|
||||||
{/* ── Desktop Sidebar ─────────────────────────────── */}
|
{/* Desktop Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden md:flex md:flex-col",
|
"hidden md:flex md:flex-col",
|
||||||
@@ -245,7 +115,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{sidebarCollapsed ? (
|
{sidebarCollapsed ? (
|
||||||
/* Collapsed state */
|
|
||||||
<div className="flex flex-col items-center py-4 gap-4 h-full">
|
<div className="flex flex-col items-center py-4 gap-4 h-full">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarCollapsed(false)}
|
onClick={() => setSidebarCollapsed(false)}
|
||||||
@@ -260,9 +129,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Expanded state */
|
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
|
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||||
@@ -281,7 +148,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversations */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||||
<ConversationList
|
<ConversationList
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
@@ -291,9 +157,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
|
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdminPanel(true)}
|
onClick={() => setShowAdminPanel(true)}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||||
@@ -301,6 +167,14 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
<Shield size={14} />
|
<Shield size={14} />
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowScheduler(true)}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Clock size={14} />
|
||||||
|
<span>Scheduler</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
@@ -314,12 +188,10 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Admin Panel modal */}
|
|
||||||
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||||
|
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
|
||||||
|
|
||||||
{/* ── Main chat area ──────────────────────────────── */}
|
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||||
{/* Mobile header */}
|
|
||||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
|
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||||
@@ -347,9 +219,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
/* ── Empty / homepage state ── */
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
|
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
|
||||||
{/* Mobile conversation drawer */}
|
|
||||||
{showConversations && (
|
{showConversations && (
|
||||||
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||||
<ConversationList
|
<ConversationList
|
||||||
@@ -380,17 +250,15 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
setSimbaMode={setSimbaMode}
|
setSimbaMode={setSimbaMode}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
pendingImage={pendingImage}
|
pendingImage={pendingImage}
|
||||||
onImageSelect={(file) => setPendingImage(file)}
|
onImageSelect={handleImageSelect}
|
||||||
onClearImage={() => setPendingImage(null)}
|
onClearImage={handleClearImage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── Active chat state ── */
|
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-6">
|
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||||
<div className="max-w-2xl mx-auto flex flex-col gap-3">
|
<div className="max-w-2xl mx-auto flex flex-col gap-3">
|
||||||
{/* Mobile conversation drawer */}
|
|
||||||
{showConversations && (
|
{showConversations && (
|
||||||
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||||
<ConversationList
|
<ConversationList
|
||||||
@@ -416,7 +284,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="border-t border-sand-light/40 bg-cream/80 backdrop-blur-sm">
|
<footer className="border-t border-sand-light/40 bg-cream">
|
||||||
<div className="max-w-2xl mx-auto px-4 py-3">
|
<div className="max-w-2xl mx-auto px-4 py-3">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
query={query}
|
query={query}
|
||||||
@@ -426,8 +294,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
setSimbaMode={setSimbaMode}
|
setSimbaMode={setSimbaMode}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
pendingImage={pendingImage}
|
pendingImage={pendingImage}
|
||||||
onImageSelect={(file) => setPendingImage(file)}
|
onImageSelect={handleImageSelect}
|
||||||
onClearImage={() => setPendingImage(null)}
|
onClearImage={handleClearImage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { conversationService } from "../api/conversationService";
|
|
||||||
|
|
||||||
type Conversation = {
|
type Conversation = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -23,32 +21,8 @@ export const ConversationList = ({
|
|||||||
selectedId,
|
selectedId,
|
||||||
variant = "dark",
|
variant = "dark",
|
||||||
}: ConversationProps) => {
|
}: ConversationProps) => {
|
||||||
const [items, setItems] = useState(conversations);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
let fetched = await conversationService.getAllConversations();
|
|
||||||
if (fetched.length === 0) {
|
|
||||||
await conversationService.createConversation();
|
|
||||||
fetched = await conversationService.getAllConversations();
|
|
||||||
}
|
|
||||||
setItems(fetched.map((c) => ({ id: c.id, title: c.name })));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load conversations:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keep in sync when parent updates conversations
|
|
||||||
useEffect(() => {
|
|
||||||
setItems(conversations);
|
|
||||||
}, [conversations]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{/* New thread button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onCreateNewConversation}
|
onClick={onCreateNewConversation}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -63,8 +37,7 @@ export const ConversationList = ({
|
|||||||
<span>New thread</span>
|
<span>New thread</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Conversation items */}
|
{conversations.map((conv) => {
|
||||||
{items.map((conv) => {
|
|
||||||
const isActive = conv.id === selectedId;
|
const isActive = conv.id === selectedId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,66 +1,19 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { userService } from "../api/userService";
|
|
||||||
import { oidcService } from "../api/oidcService";
|
|
||||||
import catIcon from "../assets/cat.png";
|
import catIcon from "../assets/cat.png";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { useOIDCAuth } from "../hooks/useOIDCAuth";
|
||||||
|
|
||||||
type LoginScreenProps = {
|
type LoginScreenProps = {
|
||||||
setAuthenticated: (isAuth: boolean) => void;
|
setAuthenticated: (isAuth: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
||||||
const [error, setError] = useState<string>("");
|
const { isChecking, isLoggingIn, error, handleLogin } = useOIDCAuth({
|
||||||
const [isChecking, setIsChecking] = useState<boolean>(true);
|
setAuthenticated,
|
||||||
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initAuth = async () => {
|
|
||||||
const callbackParams = oidcService.getCallbackParamsFromURL();
|
|
||||||
if (callbackParams) {
|
|
||||||
try {
|
|
||||||
setIsLoggingIn(true);
|
|
||||||
const result = await oidcService.handleCallback(
|
|
||||||
callbackParams.code,
|
|
||||||
callbackParams.state,
|
|
||||||
);
|
|
||||||
localStorage.setItem("access_token", result.access_token);
|
|
||||||
localStorage.setItem("refresh_token", result.refresh_token);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const isValid = await userService.validateToken();
|
|
||||||
if (isValid) setAuthenticated(true);
|
|
||||||
setIsChecking(false);
|
|
||||||
};
|
|
||||||
initAuth();
|
|
||||||
}, [setAuthenticated]);
|
|
||||||
|
|
||||||
const handleOIDCLogin = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoggingIn(true);
|
|
||||||
setError("");
|
|
||||||
const authUrl = await oidcService.initiateLogin();
|
|
||||||
window.location.href = authUrl;
|
|
||||||
} catch {
|
|
||||||
setError("Failed to initiate login. Please try again.");
|
|
||||||
setIsLoggingIn(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isChecking || isLoggingIn) {
|
if (isChecking || isLoggingIn) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
||||||
{/* Subtle dot grid */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 pointer-events-none opacity-[0.035]"
|
className="fixed inset-0 pointer-events-none opacity-[0.035]"
|
||||||
style={{
|
style={{
|
||||||
@@ -85,7 +38,6 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-cream flex items-center justify-center p-4 relative overflow-hidden">
|
<div className="h-screen bg-cream flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
{/* Background dot texture */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 pointer-events-none opacity-[0.04]"
|
className="fixed inset-0 pointer-events-none opacity-[0.04]"
|
||||||
style={{
|
style={{
|
||||||
@@ -94,12 +46,10 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Decorative background blobs */}
|
|
||||||
<div className="absolute top-1/4 -left-20 w-72 h-72 rounded-full bg-leaf-pale/60 blur-3xl pointer-events-none" />
|
<div className="absolute top-1/4 -left-20 w-72 h-72 rounded-full bg-leaf-pale/60 blur-3xl pointer-events-none" />
|
||||||
<div className="absolute bottom-1/4 -right-20 w-64 h-64 rounded-full bg-amber-pale/70 blur-3xl pointer-events-none" />
|
<div className="absolute bottom-1/4 -right-20 w-64 h-64 rounded-full bg-amber-pale/70 blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm">
|
<div className="relative w-full max-w-sm">
|
||||||
{/* Branding */}
|
|
||||||
<div className="flex flex-col items-center mb-8">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<div className="relative mb-5">
|
<div className="relative mb-5">
|
||||||
<div className="absolute -inset-5 bg-amber-soft/30 rounded-full blur-2xl" />
|
<div className="absolute -inset-5 bg-amber-soft/30 rounded-full blur-2xl" />
|
||||||
@@ -120,7 +70,6 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-warm-white rounded-3xl border border-sand-light",
|
"bg-warm-white rounded-3xl border border-sand-light",
|
||||||
@@ -138,7 +87,7 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleOIDCLogin}
|
onClick={handleLogin}
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full py-3.5 px-4 rounded-2xl text-sm font-semibold tracking-wide",
|
"w-full py-3.5 px-4 rounded-2xl text-sm font-semibold tracking-wide",
|
||||||
@@ -154,7 +103,7 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sand mt-5 text-xs tracking-widest select-none">
|
<p className="text-center text-sand mt-5 text-xs tracking-widest select-none">
|
||||||
✦ meow ✦
|
* meow *
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowUp, ImagePlus, X } from "lucide-react";
|
import { ArrowUp, ImagePlus, X } from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Textarea } from "./ui/textarea";
|
import { Textarea } from "./ui/textarea";
|
||||||
@@ -15,7 +15,7 @@ type MessageInputProps = {
|
|||||||
onClearImage: () => void;
|
onClearImage: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MessageInput = ({
|
export const MessageInput = React.memo(({
|
||||||
query,
|
query,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handleQueryChange,
|
handleQueryChange,
|
||||||
@@ -29,6 +29,18 @@ export const MessageInput = ({
|
|||||||
const [simbaMode, setLocalSimbaMode] = useState(false);
|
const [simbaMode, setLocalSimbaMode] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Create blob URL once per file, revoke on cleanup
|
||||||
|
const previewUrl = useMemo(
|
||||||
|
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
|
||||||
|
[pendingImage],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
const toggleSimbaMode = () => {
|
const toggleSimbaMode = () => {
|
||||||
const next = !simbaMode;
|
const next = !simbaMode;
|
||||||
setLocalSimbaMode(next);
|
setLocalSimbaMode(next);
|
||||||
@@ -59,7 +71,7 @@ export const MessageInput = ({
|
|||||||
<div className="px-3 pt-3">
|
<div className="px-3 pt-3">
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
<img
|
<img
|
||||||
src={URL.createObjectURL(pendingImage)}
|
src={previewUrl!}
|
||||||
alt="Pending upload"
|
alt="Pending upload"
|
||||||
className="h-20 rounded-lg object-cover border border-sand"
|
className="h-20 rounded-lg object-cover border border-sand"
|
||||||
/>
|
/>
|
||||||
@@ -145,4 +157,4 @@ export const MessageInput = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { conversationService } from "../api/conversationService";
|
import { usePresignedUrl } from "../hooks/usePresignedUrl";
|
||||||
|
|
||||||
type QuestionBubbleProps = {
|
type QuestionBubbleProps = {
|
||||||
text: string;
|
text: string;
|
||||||
image_key?: string | null;
|
image_key?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
const { imageUrl, imageError } = usePresignedUrl(image_key);
|
||||||
const [imageError, setImageError] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!image_key) return;
|
|
||||||
conversationService
|
|
||||||
.getPresignedImageUrl(image_key)
|
|
||||||
.then(setImageUrl)
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Failed to load image:", err);
|
|
||||||
setImageError(true);
|
|
||||||
});
|
|
||||||
}, [image_key]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end message-enter">
|
<div className="flex justify-end message-enter">
|
||||||
@@ -34,7 +22,6 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
|||||||
>
|
>
|
||||||
{imageError && (
|
{imageError && (
|
||||||
<div className="flex items-center gap-2 text-xs text-charcoal/50 bg-charcoal/5 rounded-xl px-3 py-2 mb-2">
|
<div className="flex items-center gap-2 text-xs text-charcoal/50 bg-charcoal/5 rounded-xl px-3 py-2 mb-2">
|
||||||
<span>🖼️</span>
|
|
||||||
<span>Image failed to load</span>
|
<span>Image failed to load</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -49,4 +36,4 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { X, Clock, Send, Trash2, XCircle, RotateCcw, Repeat, Bot } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "./ui/table";
|
||||||
|
import { useScheduledMessages } from "../hooks/useScheduledMessages";
|
||||||
|
import {
|
||||||
|
scheduledMessageService,
|
||||||
|
type CreateScheduledMessage,
|
||||||
|
} from "../api/scheduledMessageService";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, "amber" | "default" | "destructive" | "muted"> = {
|
||||||
|
pending: "amber",
|
||||||
|
sent: "default",
|
||||||
|
failed: "destructive",
|
||||||
|
cancelled: "muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||||
|
const { messages, loading, refresh } = useScheduledMessages();
|
||||||
|
const [channel, setChannel] = useState<"imessage" | "email">("imessage");
|
||||||
|
const [recipient, setRecipient] = useState("");
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [scheduledAt, setScheduledAt] = useState("");
|
||||||
|
const [recurrence, setRecurrence] = useState<"none" | "daily" | "weekly" | "monthly">("none");
|
||||||
|
const [useAgent, setUseAgent] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setError("");
|
||||||
|
if (!recipient || !content || !scheduledAt) {
|
||||||
|
setError("Recipient, content, and scheduled time are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (channel === "email" && !subject) {
|
||||||
|
setError("Subject is required for email.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const data: CreateScheduledMessage = {
|
||||||
|
recipient,
|
||||||
|
channel,
|
||||||
|
content,
|
||||||
|
scheduled_at: new Date(scheduledAt).toISOString(),
|
||||||
|
recurrence,
|
||||||
|
use_agent: useAgent,
|
||||||
|
};
|
||||||
|
if (channel === "email") data.subject = subject;
|
||||||
|
await scheduledMessageService.create(data);
|
||||||
|
setRecipient("");
|
||||||
|
setSubject("");
|
||||||
|
setContent("");
|
||||||
|
setScheduledAt("");
|
||||||
|
setRecurrence("none");
|
||||||
|
setUseAgent(false);
|
||||||
|
refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to schedule message");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await scheduledMessageService.update(id, { status: "cancelled" });
|
||||||
|
refresh();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await scheduledMessageService.remove(id);
|
||||||
|
refresh();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const futureTime = new Date(Date.now() + 30_000).toISOString();
|
||||||
|
await scheduledMessageService.update(id, { scheduled_at: futureTime });
|
||||||
|
refresh();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-charcoal/40 backdrop-blur-sm"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-warm-white rounded-3xl shadow-2xl shadow-charcoal/20",
|
||||||
|
"w-full max-w-3xl mx-4 max-h-[85vh] flex flex-col",
|
||||||
|
"border border-sand-light/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-amber-pale flex items-center justify-center">
|
||||||
|
<Clock size={14} className="text-amber-glow" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-sm font-semibold text-charcoal">
|
||||||
|
Scheduled Messages
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 rounded-b-3xl">
|
||||||
|
{/* Create form */}
|
||||||
|
<div className="px-6 py-5 border-b border-sand-light/60 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setChannel("imessage")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||||
|
channel === "imessage"
|
||||||
|
? "bg-leaf-pale text-leaf-dark"
|
||||||
|
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
iMessage
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannel("email")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||||
|
channel === "email"
|
||||||
|
? "bg-leaf-pale text-leaf-dark"
|
||||||
|
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => setRecipient(e.target.value)}
|
||||||
|
placeholder={channel === "imessage" ? "+15551234567" : "user@example.com"}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={scheduledAt}
|
||||||
|
onChange={(e) => setScheduledAt(e.target.value)}
|
||||||
|
className="w-52"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Repeat size={12} className="text-warm-gray" />
|
||||||
|
<span className="text-xs text-warm-gray">Repeat:</span>
|
||||||
|
{(["none", "daily", "weekly", "monthly"] as const).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => setRecurrence(r)}
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 py-1 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||||
|
recurrence === r
|
||||||
|
? "bg-amber-pale text-amber-glow"
|
||||||
|
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r === "none" ? "Once" : r.charAt(0).toUpperCase() + r.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setUseAgent(!useAgent)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||||
|
useAgent
|
||||||
|
? "bg-leaf-pale text-leaf-dark"
|
||||||
|
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bot size={12} />
|
||||||
|
Ask Simba
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-warm-gray">
|
||||||
|
{useAgent ? "Content is a prompt — Simba's response will be sent" : "Content sent as-is"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{channel === "email" && (
|
||||||
|
<Input
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
placeholder="Subject"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={useAgent ? "Enter a prompt for Simba..." : "Message content..."}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-xl border border-sand bg-cream-light px-3 py-2 text-sm text-charcoal placeholder:text-warm-gray/50 focus:outline-none focus:ring-2 focus:ring-leaf/30 resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<Button onClick={handleCreate} disabled={submitting} size="sm">
|
||||||
|
<Send size={12} />
|
||||||
|
{submitting ? "Scheduling..." : "Schedule"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||||
|
<div className="flex justify-center gap-1.5 mb-3">
|
||||||
|
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||||
|
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||||
|
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||||
|
</div>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||||
|
No scheduled messages yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Channel</TableHead>
|
||||||
|
<TableHead>Recipient</TableHead>
|
||||||
|
<TableHead>Content</TableHead>
|
||||||
|
<TableHead>Scheduled</TableHead>
|
||||||
|
<TableHead>Repeat</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="w-28">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<TableRow key={msg.id}>
|
||||||
|
<TableCell className="capitalize text-xs">
|
||||||
|
{msg.channel}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs truncate max-w-[140px]" title={msg.recipient}>
|
||||||
|
{msg.recipient}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs max-w-[180px]" title={msg.content}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{msg.use_agent && <Bot size={10} className="text-leaf-dark shrink-0" />}
|
||||||
|
<span className="truncate">
|
||||||
|
{msg.content.length > 60
|
||||||
|
? msg.content.slice(0, 60) + "..."
|
||||||
|
: msg.content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-warm-gray">
|
||||||
|
{new Date(msg.scheduled_at).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-warm-gray capitalize">
|
||||||
|
{msg.recurrence === "none" ? "—" : msg.recurrence}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={STATUS_BADGE[msg.status]}>{msg.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{msg.status === "pending" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost-dark"
|
||||||
|
onClick={() => handleCancel(msg.id)}
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<XCircle size={11} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{msg.status === "failed" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost-dark"
|
||||||
|
onClick={() => handleRetry(msg.id)}
|
||||||
|
title="Retry"
|
||||||
|
>
|
||||||
|
<RotateCcw size={11} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(msg.status === "pending" || msg.status === "cancelled") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDelete(msg.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
export const ToolBubble = ({ text }: { text: string }) => (
|
export const ToolBubble = React.memo(({ text }: { text: string }) => (
|
||||||
<div className="flex justify-center message-enter">
|
<div className="flex justify-center message-enter">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -12,4 +13,4 @@ export const ToolBubble = ({ text }: { text: string }) => (
|
|||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const badgeVariants = cva(
|
|||||||
default: "bg-leaf-pale text-leaf-dark border border-leaf-light/50",
|
default: "bg-leaf-pale text-leaf-dark border border-leaf-light/50",
|
||||||
amber: "bg-amber-pale text-amber-glow border border-amber-soft/40",
|
amber: "bg-amber-pale text-amber-glow border border-amber-soft/40",
|
||||||
muted: "bg-sand-light/60 text-warm-gray border border-sand/40",
|
muted: "bg-sand-light/60 text-warm-gray border border-sand/40",
|
||||||
|
destructive: "bg-red-50 text-red-600 border border-red-200/50",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { userService, type AdminUserRecord } from "../api/userService";
|
||||||
|
|
||||||
|
export function useAdminUsers() {
|
||||||
|
const [users, setUsers] = useState<AdminUserRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
userService
|
||||||
|
.adminListUsers()
|
||||||
|
.then(setUsers)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateUser = (userId: string, updater: (u: AdminUserRecord) => AdminUserRecord) => {
|
||||||
|
setUsers((prev) => prev.map((u) => (u.id === userId ? updater(u) : u)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { users, loading, updateUser };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { userService } from "../api/userService";
|
||||||
|
|
||||||
|
export function useAuthCheck() {
|
||||||
|
const [isAuthenticated, setAuthenticated] = useState(false);
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const accessToken = localStorage.getItem("access_token");
|
||||||
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
|
||||||
|
if (!accessToken && !refreshToken) {
|
||||||
|
setIsChecking(false);
|
||||||
|
setAuthenticated(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const me = await userService.getMe();
|
||||||
|
setAuthenticated(true);
|
||||||
|
setIsAdmin(me.is_admin);
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
setAuthenticated(false);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isAuthenticated, isChecking, isAdmin, setAuthenticated };
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { conversationService } from "../api/conversationService";
|
||||||
|
import type { Conversation } from "./useConversations";
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
text: string;
|
||||||
|
speaker: "simba" | "user" | "tool";
|
||||||
|
image_key?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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...",
|
||||||
|
};
|
||||||
|
|
||||||
|
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
||||||
|
|
||||||
|
type UseChatOptions = {
|
||||||
|
selectedConversation: Conversation | null;
|
||||||
|
createConversation: () => Promise<Conversation>;
|
||||||
|
refreshConversations: () => Promise<void>;
|
||||||
|
onSessionExpired: () => void;
|
||||||
|
scrollToBottom: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChat({
|
||||||
|
selectedConversation,
|
||||||
|
createConversation,
|
||||||
|
refreshConversations,
|
||||||
|
onSessionExpired,
|
||||||
|
scrollToBottom,
|
||||||
|
}: UseChatOptions) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateMessages = useCallback(
|
||||||
|
(updater: Message[] | ((prev: Message[]) => Message[])) => {
|
||||||
|
setMessages(updater);
|
||||||
|
scrollToBottom();
|
||||||
|
},
|
||||||
|
[scrollToBottom],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
async (query: string, simbaMode: boolean) => {
|
||||||
|
if ((!query.trim() && !pendingImage) || isLoading) return;
|
||||||
|
|
||||||
|
let activeConversation = selectedConversation;
|
||||||
|
let createdNew = false;
|
||||||
|
if (!activeConversation) {
|
||||||
|
activeConversation = await createConversation();
|
||||||
|
createdNew = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFile = pendingImage;
|
||||||
|
|
||||||
|
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
|
||||||
|
setPendingImage(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
if (simbaMode) {
|
||||||
|
const randomElement =
|
||||||
|
simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)];
|
||||||
|
updateMessages((prev) =>
|
||||||
|
prev.concat([{ text: randomElement, speaker: "simba" }]),
|
||||||
|
);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let imageKey: string | undefined;
|
||||||
|
if (imageFile) {
|
||||||
|
const uploadResult = await conversationService.uploadImage(
|
||||||
|
imageFile,
|
||||||
|
activeConversation.id,
|
||||||
|
);
|
||||||
|
imageKey = uploadResult.image_key;
|
||||||
|
|
||||||
|
updateMessages((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
for (let i = updated.length - 1; i >= 0; i--) {
|
||||||
|
if (updated[i].speaker === "user") {
|
||||||
|
updated[i] = { ...updated[i], image_key: imageKey };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await conversationService.streamQuery(
|
||||||
|
query,
|
||||||
|
activeConversation.id,
|
||||||
|
(event) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
if (event.type === "tool_start") {
|
||||||
|
const friendly =
|
||||||
|
TOOL_MESSAGES[event.tool] ?? `Using ${event.tool}...`;
|
||||||
|
updateMessages((prev) =>
|
||||||
|
prev.concat([{ text: friendly, speaker: "tool" }]),
|
||||||
|
);
|
||||||
|
} else if (event.type === "response") {
|
||||||
|
updateMessages((prev) =>
|
||||||
|
prev.concat([{ text: event.message, speaker: "simba" }]),
|
||||||
|
);
|
||||||
|
} else if (event.type === "error") {
|
||||||
|
console.error("Stream error:", event.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
abortController.signal,
|
||||||
|
imageKey,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
console.log("Request was aborted");
|
||||||
|
} else {
|
||||||
|
console.error("Failed to send query:", error);
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.includes("Session expired")
|
||||||
|
) {
|
||||||
|
onSessionExpired();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
if (createdNew) {
|
||||||
|
refreshConversations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
pendingImage,
|
||||||
|
isLoading,
|
||||||
|
selectedConversation,
|
||||||
|
createConversation,
|
||||||
|
refreshConversations,
|
||||||
|
onSessionExpired,
|
||||||
|
updateMessages,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
setMessages: updateMessages,
|
||||||
|
isLoading,
|
||||||
|
pendingImage,
|
||||||
|
setPendingImage,
|
||||||
|
sendMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { conversationService } from "../api/conversationService";
|
||||||
|
|
||||||
|
export type Conversation = {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
text: string;
|
||||||
|
speaker: "simba" | "user" | "tool";
|
||||||
|
image_key?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useConversations() {
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
|
const [selectedConversation, setSelectedConversation] =
|
||||||
|
useState<Conversation | null>(null);
|
||||||
|
|
||||||
|
const refreshConversations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const fetched = await conversationService.getAllConversations();
|
||||||
|
setConversations(fetched.map((c) => ({ id: c.id, title: c.name })));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load conversations:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshConversations();
|
||||||
|
}, [refreshConversations]);
|
||||||
|
|
||||||
|
const selectConversation = useCallback(
|
||||||
|
async (conversation: Conversation): Promise<Message[]> => {
|
||||||
|
setSelectedConversation(conversation);
|
||||||
|
try {
|
||||||
|
const fetched = await conversationService.getConversation(
|
||||||
|
conversation.id,
|
||||||
|
);
|
||||||
|
return fetched.messages.map((m) => ({
|
||||||
|
text: m.text,
|
||||||
|
speaker: m.speaker,
|
||||||
|
image_key: m.image_key,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load messages:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createConversation = useCallback(async (): Promise<Conversation> => {
|
||||||
|
const newConv = await conversationService.createConversation();
|
||||||
|
const conversation = { title: newConv.name, id: newConv.id };
|
||||||
|
setConversations((prev) => [conversation, ...prev]);
|
||||||
|
setSelectedConversation(conversation);
|
||||||
|
return conversation;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversations,
|
||||||
|
selectedConversation,
|
||||||
|
setSelectedConversation,
|
||||||
|
selectConversation,
|
||||||
|
createConversation,
|
||||||
|
refreshConversations,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { userService } from "../api/userService";
|
||||||
|
import { oidcService } from "../api/oidcService";
|
||||||
|
|
||||||
|
type UseOIDCAuthOptions = {
|
||||||
|
setAuthenticated: (isAuth: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useOIDCAuth({ setAuthenticated }: UseOIDCAuthOptions) {
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
const callbackParams = oidcService.getCallbackParamsFromURL();
|
||||||
|
if (callbackParams) {
|
||||||
|
try {
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
const result = await oidcService.handleCallback(
|
||||||
|
callbackParams.code,
|
||||||
|
callbackParams.state,
|
||||||
|
);
|
||||||
|
localStorage.setItem("access_token", result.access_token);
|
||||||
|
localStorage.setItem("refresh_token", result.refresh_token);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isValid = await userService.validateToken();
|
||||||
|
if (isValid) setAuthenticated(true);
|
||||||
|
setIsChecking(false);
|
||||||
|
};
|
||||||
|
initAuth();
|
||||||
|
}, [setAuthenticated]);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
setError("");
|
||||||
|
const authUrl = await oidcService.initiateLogin();
|
||||||
|
window.location.href = authUrl;
|
||||||
|
} catch {
|
||||||
|
setError("Failed to initiate login. Please try again.");
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isChecking, isLoggingIn, error, handleLogin };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { conversationService } from "../api/conversationService";
|
||||||
|
|
||||||
|
const urlCache = new Map<string, string>();
|
||||||
|
|
||||||
|
export function usePresignedUrl(imageKey: string | null | undefined) {
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(
|
||||||
|
imageKey ? (urlCache.get(imageKey) ?? null) : null,
|
||||||
|
);
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageKey) return;
|
||||||
|
|
||||||
|
const cached = urlCache.get(imageKey);
|
||||||
|
if (cached) {
|
||||||
|
setImageUrl(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationService
|
||||||
|
.getPresignedImageUrl(imageKey)
|
||||||
|
.then((url) => {
|
||||||
|
urlCache.set(imageKey, url);
|
||||||
|
setImageUrl(url);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to load image:", err);
|
||||||
|
setImageError(true);
|
||||||
|
});
|
||||||
|
}, [imageKey]);
|
||||||
|
|
||||||
|
return { imageUrl, imageError };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
scheduledMessageService,
|
||||||
|
type ScheduledMessage,
|
||||||
|
} from "../api/scheduledMessageService";
|
||||||
|
|
||||||
|
export function useScheduledMessages() {
|
||||||
|
const [messages, setMessages] = useState<ScheduledMessage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
scheduledMessageService
|
||||||
|
.list()
|
||||||
|
.then(setMessages)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { messages, loading, refresh };
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Link an iMessage phone number to an existing user account.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/link_imessage.py <username_or_email> <phone_number>
|
||||||
|
python scripts/link_imessage.py ryan +15551234567
|
||||||
|
|
||||||
|
Run inside Docker:
|
||||||
|
docker compose exec raggr python scripts/link_imessage.py ryan +15551234567
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from tortoise import Tortoise
|
||||||
|
|
||||||
|
from blueprints.users.models import User
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite://database/raggr.db")
|
||||||
|
|
||||||
|
|
||||||
|
async def link_imessage(identifier: str, phone_number: str):
|
||||||
|
await Tortoise.init(
|
||||||
|
db_url=DATABASE_URL,
|
||||||
|
modules={
|
||||||
|
"models": [
|
||||||
|
"blueprints.users.models",
|
||||||
|
"blueprints.conversation.models",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await User.filter(username=identifier).first()
|
||||||
|
if not user:
|
||||||
|
user = await User.filter(email=identifier).first()
|
||||||
|
if not user:
|
||||||
|
print(f"Error: No user found with username or email '{identifier}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
conflict = (
|
||||||
|
await User.filter(imessage_number=phone_number).exclude(id=user.id).first()
|
||||||
|
)
|
||||||
|
if conflict:
|
||||||
|
print(
|
||||||
|
f"Error: {phone_number} is already linked to user '{conflict.username}'"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.imessage_number = phone_number
|
||||||
|
await user.save()
|
||||||
|
print(f"Linked {phone_number} to user '{user.username}' ({user.email})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print(
|
||||||
|
"Usage: python scripts/link_imessage.py <username_or_email> <phone_number>"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
success = asyncio.run(link_imessage(sys.argv[1], sys.argv[2]))
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -6,19 +6,19 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from blueprints.rag.logic import (
|
from blueprints.rag.logic import (
|
||||||
|
delete_all_documents,
|
||||||
get_vector_store_stats,
|
get_vector_store_stats,
|
||||||
index_documents,
|
index_documents,
|
||||||
list_all_documents,
|
list_all_documents,
|
||||||
vector_store,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def stats():
|
def stats():
|
||||||
"""Show vector store statistics."""
|
"""Show vector store statistics."""
|
||||||
stats = get_vector_store_stats()
|
s = get_vector_store_stats()
|
||||||
print("=== Vector Store Statistics ===")
|
print("=== Vector Store Statistics ===")
|
||||||
print(f"Collection: {stats['collection_name']}")
|
print(f"Collection: {s['collection_name']}")
|
||||||
print(f"Total Documents: {stats['total_documents']}")
|
print(f"Total Documents: {s['total_documents']}")
|
||||||
|
|
||||||
|
|
||||||
async def index():
|
async def index():
|
||||||
@@ -26,23 +26,15 @@ async def index():
|
|||||||
print("Starting indexing process...")
|
print("Starting indexing process...")
|
||||||
print("Fetching documents from Paperless-NGX...")
|
print("Fetching documents from Paperless-NGX...")
|
||||||
await index_documents()
|
await index_documents()
|
||||||
print("✓ Indexing complete!")
|
print("Indexing complete!")
|
||||||
stats()
|
stats()
|
||||||
|
|
||||||
|
|
||||||
async def reindex():
|
async def reindex():
|
||||||
"""Clear and reindex all documents."""
|
"""Clear and reindex all documents."""
|
||||||
print("Clearing existing documents...")
|
print("Clearing existing documents...")
|
||||||
collection = vector_store._collection
|
delete_all_documents()
|
||||||
all_docs = collection.get()
|
print("Cleared")
|
||||||
|
|
||||||
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()
|
await index()
|
||||||
|
|
||||||
|
|
||||||
@@ -113,7 +105,7 @@ Examples:
|
|||||||
print("\n\nOperation cancelled by user")
|
print("\n\nOperation cancelled by user")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n❌ Error: {e}", file=sys.stderr)
|
print(f"\nError: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
from bs4 import BeautifulSoup
|
|
||||||
import chromadb
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
client = chromadb.PersistentClient(path="/Users/ryanchen/Programs/raggr/chromadb")
|
|
||||||
|
|
||||||
# Scrape
|
|
||||||
BASE_URL = "https://www.vet.cornell.edu"
|
|
||||||
LIST_URL = "/departments-centers-and-institutes/cornell-feline-health-center/health-information/feline-health-topics"
|
|
||||||
|
|
||||||
QUERY_URL = BASE_URL + LIST_URL
|
|
||||||
r = httpx.get(QUERY_URL)
|
|
||||||
soup = BeautifulSoup(r.text)
|
|
||||||
|
|
||||||
container = soup.find("div", class_="field-body")
|
|
||||||
a_s = container.find_all("a", href=True)
|
|
||||||
|
|
||||||
new_texts = []
|
|
||||||
|
|
||||||
for link in a_s:
|
|
||||||
endpoint = link["href"]
|
|
||||||
query_url = BASE_URL + endpoint
|
|
||||||
r2 = httpx.get(query_url)
|
|
||||||
article_soup = BeautifulSoup(r2.text)
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.git/
|
||||||
|
docs/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
PETKIT_USERNAME=your_email@example.com
|
||||||
|
PETKIT_PASSWORD=your_password
|
||||||
|
PETKIT_REGION=US
|
||||||
|
PETKIT_TIMEZONE=America/New_York
|
||||||
|
POLL_INTERVAL_SECONDS=120
|
||||||
|
DB_PATH=simbakit.db
|
||||||
|
WEB_HOST=127.0.0.1
|
||||||
|
WEB_PORT=8585
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.14-slim
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY src/ src/
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
VOLUME /app/data
|
||||||
|
ENV DB_PATH=/app/data/simbakit.db
|
||||||
|
ENV WEB_HOST=0.0.0.0
|
||||||
|
|
||||||
|
EXPOSE 8585
|
||||||
|
|
||||||
|
CMD ["uv", "run", "python", "-m", "simbakit"]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Petlibro Integration (Future)
|
||||||
|
|
||||||
|
No standalone PyPI package exists. Best source is the Home Assistant custom integration:
|
||||||
|
https://github.com/jjjonesjr33/petlibro
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
- **Base URL**: `https://api.us.petlibro.com`
|
||||||
|
- **Auth**: POST `/member/auth/login` with email + MD5(password), returns token
|
||||||
|
- **Token**: passed in `token` header on all subsequent requests
|
||||||
|
- **Deps**: just `aiohttp`
|
||||||
|
|
||||||
|
## Key Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `/device/device/list` | List all devices |
|
||||||
|
| `/device/device/realInfo` | Real-time device status |
|
||||||
|
| `/data/data/realInfo` | Real-time sensor data |
|
||||||
|
| `/data/event/deviceEventsV2` | Event/activity logs |
|
||||||
|
| `/device/feedingPlan/list` | Feeding schedules |
|
||||||
|
| `/device/device/manualFeed` | Trigger manual feed |
|
||||||
|
|
||||||
|
## Available Data (Feeders)
|
||||||
|
|
||||||
|
- Serial number, model, MAC, firmware
|
||||||
|
- Wi-Fi SSID + signal strength
|
||||||
|
- Battery level/percentage
|
||||||
|
- Today's total feeding amount (weight & volume)
|
||||||
|
- Last/next feed time
|
||||||
|
- Feeding plan status
|
||||||
|
- Desiccant/filter cycle info
|
||||||
|
|
||||||
|
## Supported Devices
|
||||||
|
|
||||||
|
- Granary Smart Feeder (PLAF103)
|
||||||
|
- Space Smart Feeder (PLAF107)
|
||||||
|
- Air Smart Feeder (PLAF108)
|
||||||
|
- Polar Wet Food Feeder (PLAF109)
|
||||||
|
- Granary Smart Camera Feeder (PLAF203)
|
||||||
|
- One RFID Smart Feeder (PLAF301)
|
||||||
|
- Dockstream Smart Fountain (PLWF105)
|
||||||
|
- Dockstream RFID Smart Fountain (PLWF305)
|
||||||
|
|
||||||
|
## Extraction Plan
|
||||||
|
|
||||||
|
Extract from HA integration (`custom_components/petlibro/`):
|
||||||
|
- `api.py` - main client (remove HA imports, use standalone aiohttp session)
|
||||||
|
- `const.py` - constants/enums
|
||||||
|
- `exceptions.py` - error handling
|
||||||
|
- `devices/` - device model classes (optional)
|
||||||
|
|
||||||
|
Request headers:
|
||||||
|
```
|
||||||
|
source: ANDROID
|
||||||
|
language: EN
|
||||||
|
timezone: [configured]
|
||||||
|
version: 1.3.45
|
||||||
|
Content-Type: application/json
|
||||||
|
token: [auth_token]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config Vars Needed
|
||||||
|
|
||||||
|
```
|
||||||
|
PETLIBRO_EMAIL=
|
||||||
|
PETLIBRO_PASSWORD=
|
||||||
|
PETLIBRO_REGION=US
|
||||||
|
```
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# SimbaKit
|
||||||
|
|
||||||
|
A self-hosted PetKit data polling service with a web dashboard. Polls the PetKit cloud API every 2 minutes and stores everything in SQLite — device snapshots, alerts, pet weight, and litter box activity.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Python 3.14** + **uv** for package management
|
||||||
|
- **pypetkitapi** for PetKit cloud API
|
||||||
|
- **aiohttp** for HTTP + web server
|
||||||
|
- **aiosqlite** for async SQLite
|
||||||
|
- **Chart.js** for frontend charts
|
||||||
|
- **Docker** for deployment
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- Device cards with live status (firmware, serial, last seen)
|
||||||
|
- Alert banner — surfaces warnings and critical issues from all devices
|
||||||
|
- Per-device detail panel with time-series charts and snapshot history
|
||||||
|
- Full raw JSON stored per poll (nothing is ever lost)
|
||||||
|
|
||||||
|
### Alerts
|
||||||
|
Automatically extracted per poll cycle based on device state:
|
||||||
|
|
||||||
|
| Device | Alerts |
|
||||||
|
|--------|--------|
|
||||||
|
| Litter box | Waste bin full, litter low, deodorant empty/low/expiring, pet detection error, device errors |
|
||||||
|
| Water fountain | Water low, filter warning/low %, malfunction |
|
||||||
|
| Feeder | Food low, blockage, desiccant expiring, battery low |
|
||||||
|
| Purifier | Liquid low, consumable expiring |
|
||||||
|
|
||||||
|
### Weight Tracker
|
||||||
|
- Pulls pet weight from litter box camera events (`device_pet_graph_out`)
|
||||||
|
- Stats: latest, average, min/max range, trend direction
|
||||||
|
- Chart with multi-pet support
|
||||||
|
- Filter by pet and time range (7d / 30d / 90d / 1y)
|
||||||
|
|
||||||
|
### Litter Activity
|
||||||
|
- Tracks every litter box visit — poop vs pee (`is_shit`)
|
||||||
|
- Stats: total visits, poop/pee count, avg time in box, avg poop weight
|
||||||
|
- Event timeline with pet name, duration, weight
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
### Local
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # fill in PetKit credentials
|
||||||
|
uv sync
|
||||||
|
uv run python -m simbakit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard at `http://localhost:8585`.
|
||||||
|
|
||||||
|
## Config (`.env`)
|
||||||
|
|
||||||
|
```
|
||||||
|
PETKIT_USERNAME=your_email@example.com
|
||||||
|
PETKIT_PASSWORD=your_password
|
||||||
|
PETKIT_REGION=US
|
||||||
|
PETKIT_TIMEZONE=America/New_York
|
||||||
|
POLL_INTERVAL_SECONDS=120
|
||||||
|
DB_PATH=simbakit.db
|
||||||
|
WEB_HOST=127.0.0.1
|
||||||
|
WEB_PORT=8585
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
simbakit/
|
||||||
|
├── Dockerfile
|
||||||
|
├── compose.yaml
|
||||||
|
├── pyproject.toml
|
||||||
|
├── .env.example
|
||||||
|
├── docs/
|
||||||
|
│ └── petlibro-integration.md # future: Petlibro feeder support
|
||||||
|
└── src/simbakit/
|
||||||
|
├── __main__.py # entry point, signal handling
|
||||||
|
├── config.py # frozen dataclass from env vars
|
||||||
|
├── database.py # SQLite schema + read/write
|
||||||
|
├── poller.py # PetKit polling + field extraction
|
||||||
|
├── web.py # REST API (aiohttp)
|
||||||
|
└── static/index.html # dashboard SPA
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `devices` | Device registry, upserted each poll |
|
||||||
|
| `device_snapshots` | Append-only full state snapshots with raw JSON |
|
||||||
|
| `alerts` | Extracted alerts per poll cycle |
|
||||||
|
| `pet_weights` | Weight measurements from litter box events |
|
||||||
|
| `litter_events` | Poop/pee visit log with duration and weights |
|
||||||
|
|
||||||
|
## Future
|
||||||
|
|
||||||
|
- **Petlibro feeder support** — API research done, notes in `docs/petlibro-integration.md`
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[project]
|
||||||
|
name = "simbakit"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"pypetkitapi>=1.28.0",
|
||||||
|
"aiohttp",
|
||||||
|
"aiosqlite",
|
||||||
|
"python-dotenv",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
simbakit = "simbakit.__main__:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/simbakit"]
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from simbakit.config import Config
|
||||||
|
from simbakit.database import Database
|
||||||
|
from simbakit.poller import run_poller
|
||||||
|
from simbakit.web import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_signal(
|
||||||
|
sig: signal.Signals, shutdown_event: asyncio.Event, log: logging.Logger
|
||||||
|
) -> None:
|
||||||
|
log.info("Received %s, shutting down...", sig.name)
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_main(config: Config) -> None:
|
||||||
|
logger = logging.getLogger("simbakit")
|
||||||
|
|
||||||
|
db = Database(config.db_path)
|
||||||
|
await db.initialize()
|
||||||
|
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
loop.add_signal_handler(sig, _handle_signal, sig, shutdown_event, logger)
|
||||||
|
|
||||||
|
# Start web server
|
||||||
|
app = create_app(db)
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, config.web_host, config.web_port)
|
||||||
|
await site.start()
|
||||||
|
logger.info("Web UI available at http://%s:%d", config.web_host, config.web_port)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SimbaKit starting -- polling %s every %ds",
|
||||||
|
config.petkit_region,
|
||||||
|
config.poll_interval_seconds,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await run_poller(config, db, shutdown_event)
|
||||||
|
finally:
|
||||||
|
await runner.cleanup()
|
||||||
|
await db.close()
|
||||||
|
logger.info("SimbaKit shut down cleanly")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("simbakit")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = Config()
|
||||||
|
except KeyError as e:
|
||||||
|
logger.error("Missing required environment variable: %s", e)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
asyncio.run(_async_main(config))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
petkit_username: str = field(default_factory=lambda: os.environ["PETKIT_USERNAME"])
|
||||||
|
petkit_password: str = field(default_factory=lambda: os.environ["PETKIT_PASSWORD"])
|
||||||
|
petkit_region: str = field(default_factory=lambda: os.getenv("PETKIT_REGION", "US"))
|
||||||
|
petkit_timezone: str = field(
|
||||||
|
default_factory=lambda: os.getenv("PETKIT_TIMEZONE", "America/New_York")
|
||||||
|
)
|
||||||
|
poll_interval_seconds: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("POLL_INTERVAL_SECONDS", "120"))
|
||||||
|
)
|
||||||
|
db_path: Path = field(
|
||||||
|
default_factory=lambda: Path(os.getenv("DB_PATH", "simbakit.db"))
|
||||||
|
)
|
||||||
|
web_host: str = field(default_factory=lambda: os.getenv("WEB_HOST", "127.0.0.1"))
|
||||||
|
web_port: int = field(default_factory=lambda: int(os.getenv("WEB_PORT", "8585")))
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
device_id TEXT PRIMARY KEY,
|
||||||
|
device_type TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
sn TEXT,
|
||||||
|
firmware TEXT,
|
||||||
|
first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS device_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
device_type TEXT NOT NULL,
|
||||||
|
polled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
raw_json TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
firmware TEXT,
|
||||||
|
error_code INTEGER,
|
||||||
|
error_msg TEXT,
|
||||||
|
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snapshots_device_time
|
||||||
|
ON device_snapshots(device_id, polled_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snapshots_polled_at
|
||||||
|
ON device_snapshots(polled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
polled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
alert_key TEXT NOT NULL,
|
||||||
|
alert_label TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'warning',
|
||||||
|
value TEXT,
|
||||||
|
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alerts_device_time
|
||||||
|
ON alerts(device_id, polled_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alerts_polled_at
|
||||||
|
ON alerts(polled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pet_weights (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
pet_name TEXT,
|
||||||
|
pet_id TEXT,
|
||||||
|
weight_g INTEGER NOT NULL,
|
||||||
|
recorded_at TEXT NOT NULL,
|
||||||
|
polled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
duration_s INTEGER,
|
||||||
|
FOREIGN KEY (device_id) REFERENCES devices(device_id),
|
||||||
|
UNIQUE(device_id, pet_name, recorded_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pet_weights_name_time
|
||||||
|
ON pet_weights(pet_name, recorded_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS litter_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
event_id TEXT UNIQUE,
|
||||||
|
pet_name TEXT,
|
||||||
|
pet_id TEXT,
|
||||||
|
recorded_at TEXT NOT NULL,
|
||||||
|
polled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
is_shit INTEGER,
|
||||||
|
pet_weight_g INTEGER,
|
||||||
|
shit_weight_g INTEGER,
|
||||||
|
duration_s INTEGER,
|
||||||
|
toilet_time_s INTEGER,
|
||||||
|
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_litter_events_time
|
||||||
|
ON litter_events(recorded_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_litter_events_pet
|
||||||
|
ON litter_events(pet_name, recorded_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS drinking_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
event_id TEXT UNIQUE,
|
||||||
|
pet_name TEXT,
|
||||||
|
pet_id TEXT,
|
||||||
|
recorded_at TEXT NOT NULL,
|
||||||
|
polled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
record_type TEXT,
|
||||||
|
duration_s INTEGER,
|
||||||
|
stay_time_s INTEGER,
|
||||||
|
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_drinking_events_time
|
||||||
|
ON drinking_events(recorded_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_drinking_events_pet
|
||||||
|
ON drinking_events(pet_name, recorded_at);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_UPSERT_DEVICE = """
|
||||||
|
INSERT INTO devices (device_id, device_type, name, sn, firmware, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(device_id) DO UPDATE SET
|
||||||
|
device_type = excluded.device_type,
|
||||||
|
name = excluded.name,
|
||||||
|
sn = excluded.sn,
|
||||||
|
firmware = excluded.firmware,
|
||||||
|
last_seen_at = datetime('now');
|
||||||
|
"""
|
||||||
|
|
||||||
|
_INSERT_SNAPSHOT = """
|
||||||
|
INSERT INTO device_snapshots (device_id, device_type, raw_json, name, firmware, error_code, error_msg)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_path: Path) -> None:
|
||||||
|
self._db_path = db_path
|
||||||
|
self._conn: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
self._conn = await aiosqlite.connect(self._db_path)
|
||||||
|
await self._conn.executescript(_SCHEMA)
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def upsert_device(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
device_type: str,
|
||||||
|
name: str | None,
|
||||||
|
sn: str | None,
|
||||||
|
firmware: str | None,
|
||||||
|
) -> None:
|
||||||
|
assert self._conn is not None
|
||||||
|
await self._conn.execute(
|
||||||
|
_UPSERT_DEVICE, (device_id, device_type, name, sn, firmware)
|
||||||
|
)
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def insert_snapshot(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
device_type: str,
|
||||||
|
raw_json: str,
|
||||||
|
name: str | None = None,
|
||||||
|
firmware: str | None = None,
|
||||||
|
error_code: int | None = None,
|
||||||
|
error_msg: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
assert self._conn is not None
|
||||||
|
await self._conn.execute(
|
||||||
|
_INSERT_SNAPSHOT,
|
||||||
|
(device_id, device_type, raw_json, name, firmware, error_code, error_msg),
|
||||||
|
)
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def insert_alerts(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
alerts: list[dict],
|
||||||
|
) -> None:
|
||||||
|
assert self._conn is not None
|
||||||
|
if not alerts:
|
||||||
|
return
|
||||||
|
await self._conn.executemany(
|
||||||
|
"INSERT INTO alerts (device_id, alert_key, alert_label, severity, value) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
(device_id, a["key"], a["label"], a["severity"], a.get("value"))
|
||||||
|
for a in alerts
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def get_active_alerts(self) -> list[dict]:
|
||||||
|
"""Get the most recent alerts per device (from latest poll only)."""
|
||||||
|
assert self._conn is not None
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
cursor = await self._conn.execute("""
|
||||||
|
SELECT a.*, d.name AS device_name, d.device_type
|
||||||
|
FROM alerts a
|
||||||
|
JOIN devices d ON a.device_id = d.device_id
|
||||||
|
WHERE a.polled_at = d.last_seen_at
|
||||||
|
ORDER BY
|
||||||
|
CASE a.severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
|
||||||
|
a.polled_at DESC
|
||||||
|
""")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def get_alerts_for_device(
|
||||||
|
self, device_id: str, limit: int = 50
|
||||||
|
) -> list[dict]:
|
||||||
|
assert self._conn is not None
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"SELECT * FROM alerts WHERE device_id = ? ORDER BY polled_at DESC LIMIT ?",
|
||||||
|
(device_id, limit),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def insert_litter_events(self, events: list[dict]) -> None:
|
||||||
|
assert self._conn is not None
|
||||||
|
if not events:
|
||||||
|
return
|
||||||
|
await self._conn.executemany(
|
||||||
|
"""INSERT OR IGNORE INTO litter_events
|
||||||
|
(device_id, event_id, pet_name, pet_id, recorded_at,
|
||||||
|
is_shit, pet_weight_g, shit_weight_g, duration_s, toilet_time_s)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
e["device_id"],
|
||||||
|
e.get("event_id"),
|
||||||
|
e.get("pet_name"),
|
||||||
|
e.get("pet_id"),
|
||||||
|
e["recorded_at"],
|
||||||
|
e.get("is_shit"),
|
||||||
|
e.get("pet_weight_g"),
|
||||||
|
e.get("shit_weight_g"),
|
||||||
|
e.get("duration_s"),
|
||||||
|
e.get("toilet_time_s"),
|
||||||
|
)
|
||||||
|
for e in events
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def get_litter_events(
|
||||||
|
self, pet_name: str | None = None, days: int = 30
|
||||||
|
) -> list[dict]:
|
||||||
|
assert self._conn is not None
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
if pet_name:
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"""SELECT * FROM litter_events
|
||||||
|
WHERE pet_name = ? AND recorded_at >= datetime('now', ?)
|
||||||
|
ORDER BY recorded_at DESC""",
|
||||||
|
(pet_name, f"-{days} days"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"""SELECT * FROM litter_events
|
||||||
|
WHERE recorded_at >= datetime('now', ?)
|
||||||
|
ORDER BY recorded_at DESC""",
|
||||||
|
(f"-{days} days",),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def get_litter_stats(
|
||||||
|
self, pet_name: str | None = None, days: int = 7
|
||||||
|
) -> dict:
|
||||||
|
assert self._conn is not None
|
||||||
|
where = "WHERE recorded_at >= datetime('now', ?)"
|
||||||
|
params: list = [f"-{days} days"]
|
||||||
|
if pet_name:
|
||||||
|
where += " AND pet_name = ?"
|
||||||
|
params.append(pet_name)
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
f"""SELECT
|
||||||
|
COUNT(*) as total_visits,
|
||||||
|
SUM(CASE WHEN is_shit = 1 THEN 1 ELSE 0 END) as poop_count,
|
||||||
|
SUM(CASE WHEN is_shit = 0 OR is_shit IS NULL THEN 1 ELSE 0 END) as pee_count,
|
||||||
|
AVG(toilet_time_s) as avg_toilet_time,
|
||||||
|
AVG(CASE WHEN shit_weight_g > 0 THEN shit_weight_g END) as avg_shit_weight
|
||||||
|
FROM litter_events {where}""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return {
|
||||||
|
"total_visits": row[0] or 0,
|
||||||
|
"poop_count": row[1] or 0,
|
||||||
|
"pee_count": row[2] or 0,
|
||||||
|
"avg_toilet_time": round(row[3], 1) if row[3] else 0,
|
||||||
|
"avg_shit_weight": round(row[4], 1) if row[4] else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def insert_pet_weights(self, records: list[dict]) -> None:
|
||||||
|
assert self._conn is not None
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
await self._conn.executemany(
|
||||||
|
"""INSERT OR IGNORE INTO pet_weights
|
||||||
|
(device_id, pet_name, pet_id, weight_g, recorded_at, duration_s)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
r["device_id"],
|
||||||
|
r["pet_name"],
|
||||||
|
r.get("pet_id"),
|
||||||
|
r["weight_g"],
|
||||||
|
r["recorded_at"],
|
||||||
|
r.get("duration_s"),
|
||||||
|
)
|
||||||
|
for r in records
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def get_pet_weights(
|
||||||
|
self, pet_name: str | None = None, days: int = 30
|
||||||
|
) -> list[dict]:
|
||||||
|
assert self._conn is not None
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
if pet_name:
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"""SELECT * FROM pet_weights
|
||||||
|
WHERE pet_name = ? AND recorded_at >= datetime('now', ?)
|
||||||
|
ORDER BY recorded_at ASC""",
|
||||||
|
(pet_name, f"-{days} days"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"""SELECT * FROM pet_weights
|
||||||
|
WHERE recorded_at >= datetime('now', ?)
|
||||||
|
ORDER BY recorded_at ASC""",
|
||||||
|
(f"-{days} days",),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def insert_drinking_events(self, events: list[dict]) -> None:
|
||||||
|
assert self._conn is not None
|
||||||
|
if not events:
|
||||||
|
return
|
||||||
|
await self._conn.executemany(
|
||||||
|
"""INSERT OR IGNORE INTO drinking_events
|
||||||
|
(device_id, event_id, pet_name, pet_id, recorded_at,
|
||||||
|
record_type, duration_s, stay_time_s)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
e["device_id"],
|
||||||
|
e.get("event_id"),
|
||||||
|
e.get("pet_name"),
|
||||||
|
e.get("pet_id"),
|
||||||
|
e["recorded_at"],
|
||||||
|
e.get("record_type"),
|
||||||
|
e.get("duration_s"),
|
||||||
|
e.get("stay_time_s"),
|
||||||
|
)
|
||||||
|
for e in events
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def get_drinking_events(
|
||||||
|
self, pet_name: str | None = None, days: int = 7
|
||||||
|
) -> list[dict]:
|
||||||
|
assert self._conn is not None
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
if pet_name:
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"""SELECT * FROM drinking_events
|
||||||
|
WHERE pet_name = ? AND recorded_at >= datetime('now', ?)
|
||||||
|
ORDER BY recorded_at DESC""",
|
||||||
|
(pet_name, f"-{days} days"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"""SELECT * FROM drinking_events
|
||||||
|
WHERE recorded_at >= datetime('now', ?)
|
||||||
|
ORDER BY recorded_at DESC""",
|
||||||
|
(f"-{days} days",),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def get_drinking_stats(
|
||||||
|
self, pet_name: str | None = None, days: int = 7
|
||||||
|
) -> dict:
|
||||||
|
assert self._conn is not None
|
||||||
|
where = "WHERE recorded_at >= datetime('now', ?)"
|
||||||
|
params: list = [f"-{days} days"]
|
||||||
|
if pet_name:
|
||||||
|
where += " AND pet_name = ?"
|
||||||
|
params.append(pet_name)
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
f"""SELECT
|
||||||
|
SUM(CASE WHEN record_type = 'drink_over' THEN 1 ELSE 0 END) as drink_count,
|
||||||
|
SUM(CASE WHEN record_type = 'pet_detect' THEN 1 ELSE 0 END) as detect_count,
|
||||||
|
AVG(CASE WHEN record_type = 'drink_over' THEN duration_s END) as avg_duration,
|
||||||
|
AVG(CASE WHEN record_type = 'drink_over' THEN stay_time_s END) as avg_stay_time
|
||||||
|
FROM drinking_events {where}""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
f"""SELECT pet_name, COUNT(*) as drinks, AVG(duration_s) as avg_duration
|
||||||
|
FROM drinking_events {where} AND record_type = 'drink_over'
|
||||||
|
AND pet_name IS NOT NULL
|
||||||
|
GROUP BY pet_name ORDER BY drinks DESC""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
per_pet = [
|
||||||
|
{
|
||||||
|
"pet_name": r[0],
|
||||||
|
"drinks": r[1],
|
||||||
|
"avg_duration": round(r[2], 1) if r[2] else 0,
|
||||||
|
}
|
||||||
|
for r in await cursor.fetchall()
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"total_drinks": row[0] or 0,
|
||||||
|
"detections": row[1] or 0,
|
||||||
|
"avg_duration": round(row[2], 1) if row[2] else 0,
|
||||||
|
"avg_stay_time": round(row[3], 1) if row[3] else 0,
|
||||||
|
"per_pet": per_pet,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_pet_names(self) -> list[str]:
|
||||||
|
assert self._conn is not None
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"""SELECT DISTINCT pet_name FROM pet_weights WHERE pet_name IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT pet_name FROM drinking_events WHERE pet_name IS NOT NULL
|
||||||
|
ORDER BY pet_name"""
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
async def get_all_devices(self) -> list[dict]:
|
||||||
|
assert self._conn is not None
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"SELECT * FROM devices ORDER BY last_seen_at DESC"
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def get_latest_snapshot_per_device(self) -> dict[str, dict]:
|
||||||
|
assert self._conn is not None
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
cursor = await self._conn.execute("""
|
||||||
|
SELECT ds.* FROM device_snapshots ds
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT device_id, MAX(polled_at) AS max_polled
|
||||||
|
FROM device_snapshots GROUP BY device_id
|
||||||
|
) latest ON ds.device_id = latest.device_id
|
||||||
|
AND ds.polled_at = latest.max_polled
|
||||||
|
""")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return {r["device_id"]: dict(r) for r in rows}
|
||||||
|
|
||||||
|
async def get_snapshots(
|
||||||
|
self, device_id: str, limit: int = 100, offset: int = 0
|
||||||
|
) -> list[dict]:
|
||||||
|
assert self._conn is not None
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"SELECT * FROM device_snapshots WHERE device_id = ? ORDER BY polled_at DESC LIMIT ? OFFSET ?",
|
||||||
|
(device_id, limit, offset),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def get_snapshot_count(self, device_id: str) -> int:
|
||||||
|
assert self._conn is not None
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM device_snapshots WHERE device_id = ?",
|
||||||
|
(device_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
async def get_chart_data(self, device_id: str, hours: int = 24) -> list[dict]:
|
||||||
|
assert self._conn is not None
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
cursor = await self._conn.execute(
|
||||||
|
"""SELECT id, polled_at, raw_json, error_code FROM device_snapshots
|
||||||
|
WHERE device_id = ? AND polled_at >= datetime('now', ?)
|
||||||
|
ORDER BY polled_at ASC""",
|
||||||
|
(device_id, f"-{hours} hours"),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._conn is not None:
|
||||||
|
await self._conn.close()
|
||||||
|
self._conn = None
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from pypetkitapi.client import PetKitClient
|
||||||
|
|
||||||
|
from simbakit.config import Config
|
||||||
|
from simbakit.database import Database
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_entity(entity: object) -> str:
|
||||||
|
if hasattr(entity, "model_dump"):
|
||||||
|
return json.dumps(entity.model_dump(), default=str)
|
||||||
|
return json.dumps(vars(entity), default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_fields(entity: object) -> dict:
|
||||||
|
state = getattr(entity, "state", None)
|
||||||
|
return {
|
||||||
|
"name": getattr(entity, "name", None),
|
||||||
|
"firmware": str(fw) if (fw := getattr(entity, "firmware", None)) else None,
|
||||||
|
"error_code": getattr(state, "error_code", None)
|
||||||
|
or getattr(entity, "error_code", None),
|
||||||
|
"error_msg": getattr(state, "error_msg", None)
|
||||||
|
or getattr(entity, "error_msg", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_alerts(entity: object) -> list[dict]:
|
||||||
|
"""Extract active alerts from a device entity based on its type."""
|
||||||
|
alerts = []
|
||||||
|
state = getattr(entity, "state", None)
|
||||||
|
if state is None:
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
# --- Litter box alerts ---
|
||||||
|
if hasattr(state, "box_full") and state.box_full:
|
||||||
|
alerts.append(
|
||||||
|
{"key": "box_full", "label": "Waste bin is full", "severity": "critical"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(state, "sand_lack") and state.sand_lack:
|
||||||
|
alerts.append(
|
||||||
|
{"key": "sand_lack", "label": "Litter is low", "severity": "warning"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# sand_percent: 0 is a default/unused value on some models; cross-check with sand_weight
|
||||||
|
sand_pct = getattr(state, "sand_percent", None)
|
||||||
|
sand_weight = getattr(state, "sand_weight", None)
|
||||||
|
if sand_pct is not None and isinstance(sand_pct, (int, float)) and sand_pct > 0:
|
||||||
|
if sand_pct <= 15:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "sand_percent",
|
||||||
|
"label": f"Litter level critically low ({sand_pct}%)",
|
||||||
|
"severity": "critical",
|
||||||
|
"value": str(sand_pct),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif sand_pct <= 30:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "sand_percent",
|
||||||
|
"label": f"Litter level low ({sand_pct}%)",
|
||||||
|
"severity": "warning",
|
||||||
|
"value": str(sand_pct),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
sand_pct == 0
|
||||||
|
and sand_weight
|
||||||
|
and isinstance(sand_weight, (int, float))
|
||||||
|
and sand_weight < 1000
|
||||||
|
):
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "sand_percent",
|
||||||
|
"label": "Litter level very low",
|
||||||
|
"severity": "critical",
|
||||||
|
"value": "0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(state, "liquid_empty") and state.liquid_empty:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "liquid_empty",
|
||||||
|
"label": "Deodorant is empty",
|
||||||
|
"severity": "critical",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif hasattr(state, "liquid_lack") and state.liquid_lack:
|
||||||
|
alerts.append(
|
||||||
|
{"key": "liquid_lack", "label": "Deodorant is low", "severity": "warning"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# deodorant_left_days: 0 can mean "not installed" on some models; only alert if > 0 and low
|
||||||
|
deo_days = getattr(state, "deodorant_left_days", None)
|
||||||
|
if (
|
||||||
|
deo_days is not None
|
||||||
|
and isinstance(deo_days, (int, float))
|
||||||
|
and 0 < deo_days <= 3
|
||||||
|
):
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "deodorant_left_days",
|
||||||
|
"label": f"Deodorant expires in {deo_days} day(s)",
|
||||||
|
"severity": "warning",
|
||||||
|
"value": str(deo_days),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(state, "pet_error") and state.pet_error:
|
||||||
|
alerts.append(
|
||||||
|
{"key": "pet_error", "label": "Pet detection error", "severity": "warning"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Litter error state
|
||||||
|
err_code = getattr(state, "error_code", None)
|
||||||
|
err_msg = getattr(state, "error_msg", None)
|
||||||
|
if err_code and str(err_code) != "0":
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "error",
|
||||||
|
"label": f"Device error: {err_msg or err_code}",
|
||||||
|
"severity": "critical",
|
||||||
|
"value": str(err_code),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Water fountain alerts ---
|
||||||
|
if hasattr(entity, "lack_warning") and entity.lack_warning:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "lack_warning",
|
||||||
|
"label": "Water level is low",
|
||||||
|
"severity": "critical",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(entity, "filter_warning") and entity.filter_warning:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "filter_warning",
|
||||||
|
"label": "Filter needs replacement",
|
||||||
|
"severity": "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_pct = getattr(entity, "filter_percent", None)
|
||||||
|
if filter_pct is not None and isinstance(filter_pct, (int, float)):
|
||||||
|
if filter_pct <= 10:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "filter_percent",
|
||||||
|
"label": f"Filter life critically low ({filter_pct}%)",
|
||||||
|
"severity": "critical",
|
||||||
|
"value": str(filter_pct),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif filter_pct <= 25:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "filter_percent",
|
||||||
|
"label": f"Filter life low ({filter_pct}%)",
|
||||||
|
"severity": "warning",
|
||||||
|
"value": str(filter_pct),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(entity, "breakdown_warning") and entity.breakdown_warning:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "breakdown_warning",
|
||||||
|
"label": "Fountain malfunction detected",
|
||||||
|
"severity": "critical",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Eversweet Ultra (W7H) fountain alerts ---
|
||||||
|
# cwt_state is distinctive to the W7H FountainState model
|
||||||
|
if hasattr(state, "cwt_state"):
|
||||||
|
if getattr(state, "stg_full_state", None) and getattr(
|
||||||
|
state, "stg_install", None
|
||||||
|
):
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "waste_tank_full",
|
||||||
|
"label": "Waste water tank is full",
|
||||||
|
"severity": "critical",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_days = getattr(state, "filter_left_days", None)
|
||||||
|
if filter_days is not None and isinstance(filter_days, (int, float)):
|
||||||
|
if 0 < filter_days <= 3:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "filter_left_days",
|
||||||
|
"label": f"Filter expires in {filter_days} day(s)",
|
||||||
|
"severity": "critical",
|
||||||
|
"value": str(filter_days),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif 3 < filter_days <= 7:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "filter_left_days",
|
||||||
|
"label": f"Filter expires in {filter_days} day(s)",
|
||||||
|
"severity": "warning",
|
||||||
|
"value": str(filter_days),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Semantics of these state ints are unverified; log them so alert
|
||||||
|
# thresholds can be tuned against real data later.
|
||||||
|
logger.info(
|
||||||
|
"W7H fountain state: cwt=%s wt=%s stg_full=%s pump=%s water_pump=%s filter_days=%s",
|
||||||
|
getattr(state, "cwt_state", None),
|
||||||
|
getattr(state, "wt_state", None),
|
||||||
|
getattr(state, "stg_full_state", None),
|
||||||
|
getattr(state, "pump_state", None),
|
||||||
|
getattr(state, "water_pump_state", None),
|
||||||
|
getattr(state, "filter_left_days", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Feeder alerts ---
|
||||||
|
food_level = getattr(state, "food", None)
|
||||||
|
if food_level is not None and isinstance(food_level, (int, float)):
|
||||||
|
if food_level <= 10:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "food",
|
||||||
|
"label": f"Food level critically low ({food_level}%)",
|
||||||
|
"severity": "critical",
|
||||||
|
"value": str(food_level),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif food_level <= 25:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "food",
|
||||||
|
"label": f"Food level low ({food_level}%)",
|
||||||
|
"severity": "warning",
|
||||||
|
"value": str(food_level),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(state, "block") and state.block:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "block",
|
||||||
|
"label": "Feeder blockage detected",
|
||||||
|
"severity": "critical",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
desiccant_days = getattr(state, "desiccant_left_days", None)
|
||||||
|
if (
|
||||||
|
desiccant_days is not None
|
||||||
|
and isinstance(desiccant_days, (int, float))
|
||||||
|
and desiccant_days <= 3
|
||||||
|
):
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "desiccant_left_days",
|
||||||
|
"label": f"Desiccant expires in {desiccant_days} day(s)",
|
||||||
|
"severity": "warning",
|
||||||
|
"value": str(desiccant_days),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
battery_pwr = getattr(state, "battery_power", None) or getattr(
|
||||||
|
state, "battery", None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
battery_pwr is not None
|
||||||
|
and isinstance(battery_pwr, (int, float))
|
||||||
|
and 0 <= battery_pwr <= 15
|
||||||
|
):
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "battery",
|
||||||
|
"label": f"Battery low ({battery_pwr}%)",
|
||||||
|
"severity": "warning",
|
||||||
|
"value": str(battery_pwr),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(state, "low_power") and state.low_power:
|
||||||
|
alerts.append({"key": "low_power", "label": "Low power", "severity": "warning"})
|
||||||
|
|
||||||
|
# --- Purifier alerts ---
|
||||||
|
if hasattr(entity, "liquid_lack") and entity.liquid_lack:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "liquid_lack",
|
||||||
|
"label": "Purifier liquid is low",
|
||||||
|
"severity": "warning",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
left_day = getattr(state, "left_day", None)
|
||||||
|
if left_day is not None and isinstance(left_day, (int, float)) and left_day <= 3:
|
||||||
|
alerts.append(
|
||||||
|
{
|
||||||
|
"key": "left_day",
|
||||||
|
"label": f"Purifier consumable expires in {left_day} day(s)",
|
||||||
|
"severity": "warning",
|
||||||
|
"value": str(left_day),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pet_weights(entity: object, device_id: str) -> list[dict]:
|
||||||
|
"""Extract pet weight records from litter box PetOutGraph data."""
|
||||||
|
records = []
|
||||||
|
|
||||||
|
# From device_pet_graph_out (T5/T6/T7 with camera)
|
||||||
|
graph_out = getattr(entity, "device_pet_graph_out", None)
|
||||||
|
if graph_out:
|
||||||
|
for event in graph_out:
|
||||||
|
content = getattr(event, "content", None)
|
||||||
|
weight = getattr(content, "pet_weight", None) if content else None
|
||||||
|
if weight and isinstance(weight, (int, float)) and weight > 0:
|
||||||
|
ts = getattr(event, "time", None)
|
||||||
|
recorded_at = (
|
||||||
|
datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
if ts
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if recorded_at:
|
||||||
|
duration = getattr(event, "duration", None)
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"device_id": device_id,
|
||||||
|
"pet_name": getattr(event, "pet_name", None),
|
||||||
|
"pet_id": str(pid)
|
||||||
|
if (pid := getattr(event, "pet_id", None))
|
||||||
|
else None,
|
||||||
|
"weight_g": int(weight),
|
||||||
|
"recorded_at": recorded_at,
|
||||||
|
"duration_s": duration,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# From device_records (T3/T4 and others) - may be a list or a single object
|
||||||
|
device_records = getattr(entity, "device_records", None)
|
||||||
|
if device_records and isinstance(device_records, list):
|
||||||
|
for rec in device_records:
|
||||||
|
content = getattr(rec, "content", None)
|
||||||
|
weight = getattr(content, "pet_weight", None) if content else None
|
||||||
|
if weight and isinstance(weight, (int, float)) and weight > 0:
|
||||||
|
ts = getattr(content, "time_in", None) or getattr(rec, "time", None)
|
||||||
|
recorded_at = (
|
||||||
|
datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
if ts
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if recorded_at:
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"device_id": device_id,
|
||||||
|
"pet_name": getattr(rec, "pet_name", None),
|
||||||
|
"pet_id": str(pid)
|
||||||
|
if (pid := getattr(rec, "pet_id", None))
|
||||||
|
else None,
|
||||||
|
"weight_g": int(weight),
|
||||||
|
"recorded_at": recorded_at,
|
||||||
|
"duration_s": getattr(content, "interval", None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_litter_events(entity: object, device_id: str) -> list[dict]:
|
||||||
|
"""Extract litter box visit events (poop/pee) from PetOutGraph data."""
|
||||||
|
events = []
|
||||||
|
|
||||||
|
graph_out = getattr(entity, "device_pet_graph_out", None)
|
||||||
|
if not graph_out:
|
||||||
|
return events
|
||||||
|
|
||||||
|
for event in graph_out:
|
||||||
|
ts = getattr(event, "time", None)
|
||||||
|
if not ts:
|
||||||
|
continue
|
||||||
|
recorded_at = datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
content = getattr(event, "content", None)
|
||||||
|
|
||||||
|
events.append(
|
||||||
|
{
|
||||||
|
"device_id": device_id,
|
||||||
|
"event_id": getattr(event, "event_id", None),
|
||||||
|
"pet_name": getattr(event, "pet_name", None),
|
||||||
|
"pet_id": str(pid) if (pid := getattr(event, "pet_id", None)) else None,
|
||||||
|
"recorded_at": recorded_at,
|
||||||
|
"is_shit": getattr(content, "is_shit", None) if content else None,
|
||||||
|
"pet_weight_g": getattr(content, "pet_weight", None)
|
||||||
|
if content
|
||||||
|
else None,
|
||||||
|
"shit_weight_g": getattr(content, "shit_weight", None)
|
||||||
|
if content
|
||||||
|
else None,
|
||||||
|
"duration_s": getattr(event, "duration", None),
|
||||||
|
"toilet_time_s": getattr(event, "toilet_time", None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
DRINKING_RECORD_TYPES = {"drink_over", "pet_detect"}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_drinking_events(entity: object, device_id: str) -> list[dict]:
|
||||||
|
"""Extract fountain drinking/visit events from W7H device records."""
|
||||||
|
events = []
|
||||||
|
|
||||||
|
# Litter boxes also populate device_records; only fountains have drink data
|
||||||
|
if type(entity).__name__ != "WaterFountain":
|
||||||
|
return events
|
||||||
|
|
||||||
|
device_records = getattr(entity, "device_records", None)
|
||||||
|
if not device_records or not isinstance(device_records, list):
|
||||||
|
return events
|
||||||
|
|
||||||
|
for rec in device_records:
|
||||||
|
raw_type = getattr(rec, "enum_event_type", None)
|
||||||
|
record_type = str(getattr(raw_type, "value", raw_type)) if raw_type else None
|
||||||
|
if record_type not in DRINKING_RECORD_TYPES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ts = getattr(rec, "timestamp", None)
|
||||||
|
if not ts:
|
||||||
|
continue
|
||||||
|
recorded_at = datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
|
||||||
|
events.append(
|
||||||
|
{
|
||||||
|
"device_id": device_id,
|
||||||
|
"event_id": getattr(rec, "event_id", None),
|
||||||
|
"pet_name": getattr(rec, "pet_name", None),
|
||||||
|
"pet_id": str(pid) if (pid := getattr(rec, "pet_id", None)) else None,
|
||||||
|
"recorded_at": recorded_at,
|
||||||
|
"record_type": record_type,
|
||||||
|
"duration_s": getattr(rec, "duration", None),
|
||||||
|
"stay_time_s": getattr(rec, "stay_time", None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_once(config: Config, db: Database) -> int:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
client = PetKitClient(
|
||||||
|
username=config.petkit_username,
|
||||||
|
password=config.petkit_password,
|
||||||
|
region=config.petkit_region,
|
||||||
|
timezone=config.petkit_timezone,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
await client.get_devices_data()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for device_id, entity in client.petkit_entities.items():
|
||||||
|
device_type = type(entity).__name__
|
||||||
|
raw_json = _serialize_entity(entity)
|
||||||
|
fields = _extract_fields(entity)
|
||||||
|
|
||||||
|
await db.upsert_device(
|
||||||
|
device_id=str(device_id),
|
||||||
|
device_type=device_type,
|
||||||
|
name=fields["name"],
|
||||||
|
sn=getattr(entity, "sn", None),
|
||||||
|
firmware=fields["firmware"],
|
||||||
|
)
|
||||||
|
await db.insert_snapshot(
|
||||||
|
device_id=str(device_id),
|
||||||
|
device_type=device_type,
|
||||||
|
raw_json=raw_json,
|
||||||
|
**fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
alerts = _extract_alerts(entity)
|
||||||
|
if alerts:
|
||||||
|
await db.insert_alerts(str(device_id), alerts)
|
||||||
|
for a in alerts:
|
||||||
|
logger.warning(
|
||||||
|
"ALERT [%s] %s: %s",
|
||||||
|
fields["name"],
|
||||||
|
a["severity"].upper(),
|
||||||
|
a["label"],
|
||||||
|
)
|
||||||
|
|
||||||
|
weight_records = _extract_pet_weights(entity, str(device_id))
|
||||||
|
if weight_records:
|
||||||
|
await db.insert_pet_weights(weight_records)
|
||||||
|
logger.info(
|
||||||
|
"Stored %d pet weight record(s) from %s",
|
||||||
|
len(weight_records),
|
||||||
|
fields["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
litter_events = _extract_litter_events(entity, str(device_id))
|
||||||
|
if litter_events:
|
||||||
|
await db.insert_litter_events(litter_events)
|
||||||
|
logger.info(
|
||||||
|
"Stored %d litter event(s) from %s", len(litter_events), fields["name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
drinking_events = _extract_drinking_events(entity, str(device_id))
|
||||||
|
if drinking_events:
|
||||||
|
await db.insert_drinking_events(drinking_events)
|
||||||
|
logger.info(
|
||||||
|
"Stored %d drinking event(s) from %s",
|
||||||
|
len(drinking_events),
|
||||||
|
fields["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
logger.info(
|
||||||
|
"Stored snapshot for %s '%s' (id=%s, alerts=%d)",
|
||||||
|
device_type,
|
||||||
|
fields["name"],
|
||||||
|
device_id,
|
||||||
|
len(alerts),
|
||||||
|
)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
async def run_poller(
|
||||||
|
config: Config, db: Database, shutdown_event: asyncio.Event
|
||||||
|
) -> None:
|
||||||
|
logger.info("Poller starting (interval=%ds)", config.poll_interval_seconds)
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
count = await poll_once(config, db)
|
||||||
|
logger.info("Poll complete: %d entities stored", count)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Poll failed, will retry next cycle")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
shutdown_event.wait(), timeout=config.poll_interval_seconds
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
logger.info("Poller stopped")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from simbakit.database import Database
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(db: Database) -> web.Application:
|
||||||
|
app = web.Application()
|
||||||
|
app["db"] = db
|
||||||
|
app.router.add_get("/", index)
|
||||||
|
app.router.add_get("/api/devices", api_devices)
|
||||||
|
app.router.add_get("/api/devices/{device_id}/snapshots", api_snapshots)
|
||||||
|
app.router.add_get("/api/devices/{device_id}/chart", api_chart)
|
||||||
|
app.router.add_get("/api/alerts", api_alerts)
|
||||||
|
app.router.add_get("/api/devices/{device_id}/alerts", api_device_alerts)
|
||||||
|
app.router.add_get("/api/pets/weights", api_pet_weights)
|
||||||
|
app.router.add_get("/api/pets/litter-events", api_litter_events)
|
||||||
|
app.router.add_get("/api/pets/drinking-events", api_drinking_events)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def index(request: web.Request) -> web.FileResponse:
|
||||||
|
return web.FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
async def api_devices(request: web.Request) -> web.Response:
|
||||||
|
db: Database = request.app["db"]
|
||||||
|
devices = await db.get_all_devices()
|
||||||
|
latest = await db.get_latest_snapshot_per_device()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for d in devices:
|
||||||
|
snap = latest.get(d["device_id"])
|
||||||
|
entry = {**d}
|
||||||
|
if snap:
|
||||||
|
entry["latest_snapshot"] = {
|
||||||
|
"polled_at": snap["polled_at"],
|
||||||
|
"error_code": snap["error_code"],
|
||||||
|
"error_msg": snap["error_msg"],
|
||||||
|
"raw_json": snap["raw_json"],
|
||||||
|
}
|
||||||
|
result.append(entry)
|
||||||
|
|
||||||
|
return web.json_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_snapshots(request: web.Request) -> web.Response:
|
||||||
|
db: Database = request.app["db"]
|
||||||
|
device_id = request.match_info["device_id"]
|
||||||
|
limit = min(int(request.query.get("limit", "50")), 500)
|
||||||
|
offset = int(request.query.get("offset", "0"))
|
||||||
|
|
||||||
|
snapshots = await db.get_snapshots(device_id, limit=limit, offset=offset)
|
||||||
|
total = await db.get_snapshot_count(device_id)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"total": total, "limit": limit, "offset": offset, "snapshots": snapshots}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_chart(request: web.Request) -> web.Response:
|
||||||
|
db: Database = request.app["db"]
|
||||||
|
device_id = request.match_info["device_id"]
|
||||||
|
hours = min(int(request.query.get("hours", "24")), 168)
|
||||||
|
|
||||||
|
rows = await db.get_chart_data(device_id, hours=hours)
|
||||||
|
|
||||||
|
timestamps = []
|
||||||
|
error_codes = []
|
||||||
|
metrics: dict[str, list] = {}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
timestamps.append(row["polled_at"])
|
||||||
|
error_codes.append(row["error_code"] or 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.loads(row["raw_json"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract numeric fields from state for charting
|
||||||
|
state = raw.get("state", {})
|
||||||
|
if isinstance(state, dict):
|
||||||
|
for key, val in state.items():
|
||||||
|
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
||||||
|
metrics.setdefault(key, []).append(
|
||||||
|
{"t": row["polled_at"], "v": val}
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"timestamps": timestamps,
|
||||||
|
"error_codes": error_codes,
|
||||||
|
"metrics": metrics,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_alerts(request: web.Request) -> web.Response:
|
||||||
|
db: Database = request.app["db"]
|
||||||
|
alerts = await db.get_active_alerts()
|
||||||
|
return web.json_response(alerts)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_device_alerts(request: web.Request) -> web.Response:
|
||||||
|
db: Database = request.app["db"]
|
||||||
|
device_id = request.match_info["device_id"]
|
||||||
|
limit = min(int(request.query.get("limit", "50")), 200)
|
||||||
|
alerts = await db.get_alerts_for_device(device_id, limit=limit)
|
||||||
|
return web.json_response(alerts)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_pet_weights(request: web.Request) -> web.Response:
|
||||||
|
db: Database = request.app["db"]
|
||||||
|
pet_name = request.query.get("pet")
|
||||||
|
days = min(int(request.query.get("days", "30")), 365)
|
||||||
|
weights = await db.get_pet_weights(pet_name=pet_name, days=days)
|
||||||
|
pet_names = await db.get_pet_names()
|
||||||
|
return web.json_response({"pet_names": pet_names, "weights": weights})
|
||||||
|
|
||||||
|
|
||||||
|
async def api_litter_events(request: web.Request) -> web.Response:
|
||||||
|
db: Database = request.app["db"]
|
||||||
|
pet_name = request.query.get("pet")
|
||||||
|
days = min(int(request.query.get("days", "7")), 90)
|
||||||
|
events = await db.get_litter_events(pet_name=pet_name, days=days)
|
||||||
|
stats = await db.get_litter_stats(pet_name=pet_name, days=days)
|
||||||
|
pet_names = await db.get_pet_names()
|
||||||
|
return web.json_response({"pet_names": pet_names, "events": events, "stats": stats})
|
||||||
|
|
||||||
|
|
||||||
|
async def api_drinking_events(request: web.Request) -> web.Response:
|
||||||
|
db: Database = request.app["db"]
|
||||||
|
pet_name = request.query.get("pet")
|
||||||
|
days = min(int(request.query.get("days", "7")), 90)
|
||||||
|
events = await db.get_drinking_events(pet_name=pet_name, days=days)
|
||||||
|
stats = await db.get_drinking_stats(pet_name=pet_name, days=days)
|
||||||
|
pet_names = await db.get_pet_names()
|
||||||
|
return web.json_response({"pet_names": pet_names, "events": events, "stats": stats})
|
||||||
Generated
+851
@@ -0,0 +1,851 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 2
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiofiles"
|
||||||
|
version = "24.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiohappyeyeballs"
|
||||||
|
version = "2.6.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiohttp"
|
||||||
|
version = "3.14.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aiohappyeyeballs" },
|
||||||
|
{ name = "aiosignal" },
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "frozenlist" },
|
||||||
|
{ name = "multidict" },
|
||||||
|
{ name = "propcache" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
{ name = "yarl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiosignal"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "frozenlist" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiosqlite"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "attrs"
|
||||||
|
version = "26.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "frozenlist"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "m3u8"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multidict"
|
||||||
|
version = "6.7.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "propcache"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycryptodome"
|
||||||
|
version = "3.23.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.13.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.46.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pypetkitapi"
|
||||||
|
version = "1.28.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aiofiles" },
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "m3u8" },
|
||||||
|
{ name = "pycryptodome" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "tenacity" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/77/a4/9ae26d4494ce5a51757e66964d4deb3569a2824487710354573f185d5f1d/pypetkitapi-1.28.0.tar.gz", hash = "sha256:41fc506a1b371a732a902c83e024734ce9b34d5b885d7d77bf8104b1f2021b9e", size = 56251, upload-time = "2026-07-08T06:04:25.048Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/a5/5479bd8f5ce0c88c031159da72501e32e25617187caead3e9771a471576c/pypetkitapi-1.28.0-py3-none-any.whl", hash = "sha256:a9bd7e923f6a877bcf5283fdabd090bf74a8f05a62bbb70fb3df662554fdbab0", size = 55424, upload-time = "2026-07-08T06:04:26.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simbakit"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "pypetkitapi" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "pypetkitapi", specifier = ">=1.28.0" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tenacity"
|
||||||
|
version = "9.1.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yarl"
|
||||||
|
version = "1.24.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "multidict" },
|
||||||
|
{ name = "propcache" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" },
|
||||||
|
]
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "Initializing directories..."
|
|
||||||
mkdir -p /app/data/chromadb
|
|
||||||
|
|
||||||
echo "Rebuilding frontend..."
|
echo "Rebuilding frontend..."
|
||||||
cd /app/raggr-frontend
|
cd /app/raggr-frontend
|
||||||
yarn build
|
yarn build
|
||||||
|
|||||||
+25
-2
@@ -8,8 +8,31 @@ mkdir -p /app/data/obsidian
|
|||||||
|
|
||||||
# Start continuous Obsidian sync if enabled
|
# Start continuous Obsidian sync if enabled
|
||||||
if [ "${OBSIDIAN_CONTINUOUS_SYNC}" = "true" ]; then
|
if [ "${OBSIDIAN_CONTINUOUS_SYNC}" = "true" ]; then
|
||||||
echo "Starting Obsidian continuous sync in background..."
|
if [ -z "${OBSIDIAN_EMAIL}" ] || [ -z "${OBSIDIAN_PASSWORD}" ] || [ -z "${OBSIDIAN_VAULT_ID}" ]; then
|
||||||
ob sync --continuous &
|
echo "WARNING: OBSIDIAN_EMAIL, OBSIDIAN_PASSWORD, or OBSIDIAN_VAULT_ID not set. Skipping sync."
|
||||||
|
else
|
||||||
|
echo "Setting up Obsidian sync..."
|
||||||
|
|
||||||
|
VAULT_PATH="${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}"
|
||||||
|
|
||||||
|
# Login and setup sync (foreground, must complete before sync starts)
|
||||||
|
if ob login --email "${OBSIDIAN_EMAIL}" --password "${OBSIDIAN_PASSWORD}" && \
|
||||||
|
ob sync-setup \
|
||||||
|
--vault "${OBSIDIAN_VAULT_ID}" \
|
||||||
|
--path "${VAULT_PATH}" \
|
||||||
|
--password "${OBSIDIAN_E2E_PASSWORD}" \
|
||||||
|
--device-name "${OBSIDIAN_DEVICE_NAME:-simbarag}"; then
|
||||||
|
# Remove stale lock from previous container run
|
||||||
|
rm -rf "${VAULT_PATH}/.obsidian/.sync.lock"
|
||||||
|
# Set sync to pull-only (read-only) mode
|
||||||
|
ob sync-config --mode pull-only --path "${VAULT_PATH}"
|
||||||
|
# Start continuous sync in background
|
||||||
|
echo "Starting Obsidian continuous sync (pull-only)..."
|
||||||
|
ob sync --continuous --path "${VAULT_PATH}" &
|
||||||
|
else
|
||||||
|
echo "WARNING: Obsidian sync setup failed. Continuing without sync."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting application..."
|
echo "Starting application..."
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
"""Tests for text preprocessing functions in utils/chunker.py."""
|
|
||||||
|
|
||||||
from utils.chunker import (
|
|
||||||
remove_headers_footers,
|
|
||||||
remove_special_characters,
|
|
||||||
remove_repeated_substrings,
|
|
||||||
remove_extra_spaces,
|
|
||||||
preprocess_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRemoveHeadersFooters:
|
|
||||||
def test_removes_default_header(self):
|
|
||||||
text = "Header Line\nActual content here"
|
|
||||||
result = remove_headers_footers(text)
|
|
||||||
assert "Header" not in result
|
|
||||||
assert "Actual content here" in result
|
|
||||||
|
|
||||||
def test_removes_default_footer(self):
|
|
||||||
text = "Actual content\nFooter Line"
|
|
||||||
result = remove_headers_footers(text)
|
|
||||||
assert "Footer" not in result
|
|
||||||
assert "Actual content" in result
|
|
||||||
|
|
||||||
def test_custom_patterns(self):
|
|
||||||
text = "PAGE 1\nContent\nCopyright 2024"
|
|
||||||
result = remove_headers_footers(
|
|
||||||
text,
|
|
||||||
header_patterns=[r"^PAGE \d+$"],
|
|
||||||
footer_patterns=[r"^Copyright.*$"],
|
|
||||||
)
|
|
||||||
assert "PAGE 1" not in result
|
|
||||||
assert "Copyright" not in result
|
|
||||||
assert "Content" in result
|
|
||||||
|
|
||||||
def test_no_match_preserves_text(self):
|
|
||||||
text = "Just normal content"
|
|
||||||
result = remove_headers_footers(text)
|
|
||||||
assert result == "Just normal content"
|
|
||||||
|
|
||||||
def test_empty_string(self):
|
|
||||||
assert remove_headers_footers("") == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestRemoveSpecialCharacters:
|
|
||||||
def test_removes_special_chars(self):
|
|
||||||
text = "Hello @world #test $100"
|
|
||||||
result = remove_special_characters(text)
|
|
||||||
assert "@" not in result
|
|
||||||
assert "#" not in result
|
|
||||||
assert "$" not in result
|
|
||||||
|
|
||||||
def test_preserves_allowed_chars(self):
|
|
||||||
text = "Hello, world! How's it going? Yes-no."
|
|
||||||
result = remove_special_characters(text)
|
|
||||||
assert "," in result
|
|
||||||
assert "!" in result
|
|
||||||
assert "'" in result
|
|
||||||
assert "?" in result
|
|
||||||
assert "-" in result
|
|
||||||
assert "." in result
|
|
||||||
|
|
||||||
def test_custom_pattern(self):
|
|
||||||
text = "keep @this but not #that"
|
|
||||||
result = remove_special_characters(text, special_chars=r"[#]")
|
|
||||||
assert "@this" in result
|
|
||||||
assert "#" not in result
|
|
||||||
|
|
||||||
def test_empty_string(self):
|
|
||||||
assert remove_special_characters("") == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestRemoveRepeatedSubstrings:
|
|
||||||
def test_collapses_dots(self):
|
|
||||||
text = "Item.....Value"
|
|
||||||
result = remove_repeated_substrings(text)
|
|
||||||
assert result == "Item.Value"
|
|
||||||
|
|
||||||
def test_single_dot_preserved(self):
|
|
||||||
text = "End of sentence."
|
|
||||||
result = remove_repeated_substrings(text)
|
|
||||||
assert result == "End of sentence."
|
|
||||||
|
|
||||||
def test_custom_pattern(self):
|
|
||||||
text = "hello---world"
|
|
||||||
result = remove_repeated_substrings(text, pattern=r"-{2,}")
|
|
||||||
# Function always replaces matched pattern with "."
|
|
||||||
assert result == "hello.world"
|
|
||||||
|
|
||||||
def test_empty_string(self):
|
|
||||||
assert remove_repeated_substrings("") == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestRemoveExtraSpaces:
|
|
||||||
def test_collapses_multiple_blank_lines(self):
|
|
||||||
text = "Line 1\n\n\n\nLine 2"
|
|
||||||
result = remove_extra_spaces(text)
|
|
||||||
# After collapsing newlines to \n\n, then \s+ collapses everything to single spaces
|
|
||||||
assert "\n\n\n" not in result
|
|
||||||
|
|
||||||
def test_collapses_multiple_spaces(self):
|
|
||||||
text = "Hello world"
|
|
||||||
result = remove_extra_spaces(text)
|
|
||||||
assert result == "Hello world"
|
|
||||||
|
|
||||||
def test_strips_whitespace(self):
|
|
||||||
text = " Hello world "
|
|
||||||
result = remove_extra_spaces(text)
|
|
||||||
assert result == "Hello world"
|
|
||||||
|
|
||||||
def test_empty_string(self):
|
|
||||||
assert remove_extra_spaces("") == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestPreprocessText:
|
|
||||||
def test_full_pipeline(self):
|
|
||||||
text = "Header Info\nHello @world... with spaces\nFooter Info"
|
|
||||||
result = preprocess_text(text)
|
|
||||||
assert "Header" not in result
|
|
||||||
assert "Footer" not in result
|
|
||||||
assert "@" not in result
|
|
||||||
assert "..." not in result
|
|
||||||
assert " " not in result
|
|
||||||
|
|
||||||
def test_preserves_meaningful_content(self):
|
|
||||||
text = "The cat weighs 10 pounds."
|
|
||||||
result = preprocess_text(text)
|
|
||||||
assert "cat" in result
|
|
||||||
assert "10" in result
|
|
||||||
assert "pounds" in result
|
|
||||||
|
|
||||||
def test_empty_string(self):
|
|
||||||
assert preprocess_text("") == ""
|
|
||||||
|
|
||||||
def test_already_clean(self):
|
|
||||||
text = "Simple clean text here."
|
|
||||||
result = preprocess_text(text)
|
|
||||||
assert "Simple" in result
|
|
||||||
assert "clean" in result
|
|
||||||
@@ -93,13 +93,15 @@ class TestGetDailyNotePath:
|
|||||||
def test_formats_path_correctly(self, service):
|
def test_formats_path_correctly(self, service):
|
||||||
date = datetime(2026, 3, 15)
|
date = datetime(2026, 3, 15)
|
||||||
path = service.get_daily_note_path(date)
|
path = service.get_daily_note_path(date)
|
||||||
assert path == "journal/2026/2026-03-15.md"
|
assert path == "50 - Journal/2026/03/2026-03-15.md"
|
||||||
|
|
||||||
def test_defaults_to_today(self, service):
|
def test_defaults_to_today(self, service):
|
||||||
path = service.get_daily_note_path()
|
path = service.get_daily_note_path()
|
||||||
today = datetime.now()
|
today = datetime.now()
|
||||||
assert today.strftime("%Y-%m-%d") in path
|
assert today.strftime("%Y-%m-%d") in path
|
||||||
assert path.startswith(f"journal/{today.strftime('%Y')}/")
|
assert path.startswith(
|
||||||
|
f"50 - Journal/{today.strftime('%Y')}/{today.strftime('%m')}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestWalkVault:
|
class TestWalkVault:
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Tests for SimbaKit service data formatting logic."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def simbakit_service():
|
||||||
|
"""Create a SimbaKitService pointed at a fake URL."""
|
||||||
|
with patch.dict(os.environ, {"SIMBAKIT_URL": "http://simbakit:8585"}):
|
||||||
|
from utils.simbakit_service import SimbaKitService
|
||||||
|
|
||||||
|
yield SimbaKitService()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDrinkingEvents:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_formats_stats_and_events(self, simbakit_service):
|
||||||
|
payload = {
|
||||||
|
"pet_names": ["Simba"],
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"pet_name": "Simba",
|
||||||
|
"recorded_at": "2026-07-09 14:00:00",
|
||||||
|
"record_type": "drink_over",
|
||||||
|
"duration_s": 25,
|
||||||
|
"stay_time_s": 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pet_name": "Simba",
|
||||||
|
"recorded_at": "2026-07-09 10:00:00",
|
||||||
|
"record_type": "pet_detect",
|
||||||
|
"duration_s": None,
|
||||||
|
"stay_time_s": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"total_drinks": 12,
|
||||||
|
"detections": 3,
|
||||||
|
"avg_duration": 23.2,
|
||||||
|
"avg_stay_time": 35.0,
|
||||||
|
"per_pet": [{"pet_name": "Simba", "drinks": 12, "avg_duration": 23.2}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
simbakit_service._get = AsyncMock(return_value=payload)
|
||||||
|
|
||||||
|
result = await simbakit_service.get_drinking_events(pet_name="Simba", days=7)
|
||||||
|
|
||||||
|
simbakit_service._get.assert_awaited_once_with(
|
||||||
|
"/api/pets/drinking-events", params={"pet": "Simba", "days": "7"}
|
||||||
|
)
|
||||||
|
assert "12 drinking sessions" in result
|
||||||
|
assert "Average drink duration: 23s" in result
|
||||||
|
assert "Camera detections (visits without drinking): 3" in result
|
||||||
|
assert "Simba drank at 2026-07-09 14:00:00 for 25s" in result
|
||||||
|
assert "Simba visited the fountain at 2026-07-09 10:00:00" in result
|
||||||
|
# Single pet: no per-pet breakdown
|
||||||
|
assert "By pet:" not in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_per_pet_breakdown_with_multiple_pets(self, simbakit_service):
|
||||||
|
payload = {
|
||||||
|
"pet_names": ["Nala", "Simba"],
|
||||||
|
"events": [],
|
||||||
|
"stats": {
|
||||||
|
"total_drinks": 20,
|
||||||
|
"detections": 0,
|
||||||
|
"avg_duration": 18.0,
|
||||||
|
"avg_stay_time": 30.0,
|
||||||
|
"per_pet": [
|
||||||
|
{"pet_name": "Simba", "drinks": 14, "avg_duration": 20.0},
|
||||||
|
{"pet_name": "Nala", "drinks": 6, "avg_duration": 14.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
simbakit_service._get = AsyncMock(return_value=payload)
|
||||||
|
|
||||||
|
result = await simbakit_service.get_drinking_events()
|
||||||
|
|
||||||
|
assert "By pet:" in result
|
||||||
|
assert "Simba: 14 drinks" in result
|
||||||
|
assert "Nala: 6 drinks" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_data(self, simbakit_service):
|
||||||
|
payload = {
|
||||||
|
"pet_names": [],
|
||||||
|
"events": [],
|
||||||
|
"stats": {
|
||||||
|
"total_drinks": 0,
|
||||||
|
"detections": 0,
|
||||||
|
"avg_duration": 0,
|
||||||
|
"avg_stay_time": 0,
|
||||||
|
"per_pet": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
simbakit_service._get = AsyncMock(return_value=payload)
|
||||||
|
|
||||||
|
result = await simbakit_service.get_drinking_events()
|
||||||
|
|
||||||
|
assert result == "No water fountain activity found."
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import os
|
|
||||||
from math import ceil
|
|
||||||
import re
|
|
||||||
from typing import Union
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
from chromadb.utils.embedding_functions.openai_embedding_function import (
|
|
||||||
OpenAIEmbeddingFunction,
|
|
||||||
)
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from llm import LLMClient
|
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
def remove_headers_footers(text, header_patterns=None, footer_patterns=None):
|
|
||||||
if header_patterns is None:
|
|
||||||
header_patterns = [r"^.*Header.*$"]
|
|
||||||
if footer_patterns is None:
|
|
||||||
footer_patterns = [r"^.*Footer.*$"]
|
|
||||||
|
|
||||||
for pattern in header_patterns + footer_patterns:
|
|
||||||
text = re.sub(pattern, "", text, flags=re.MULTILINE)
|
|
||||||
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def remove_special_characters(text, special_chars=None):
|
|
||||||
if special_chars is None:
|
|
||||||
special_chars = r"[^A-Za-z0-9\s\.,;:\'\"\?\!\-]"
|
|
||||||
|
|
||||||
text = re.sub(special_chars, "", text)
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def remove_repeated_substrings(text, pattern=r"\.{2,}"):
|
|
||||||
text = re.sub(pattern, ".", text)
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def remove_extra_spaces(text):
|
|
||||||
text = re.sub(r"\n\s*\n", "\n\n", text)
|
|
||||||
text = re.sub(r"\s+", " ", text)
|
|
||||||
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def preprocess_text(text):
|
|
||||||
# Remove headers and footers
|
|
||||||
text = remove_headers_footers(text)
|
|
||||||
|
|
||||||
# Remove special characters
|
|
||||||
text = remove_special_characters(text)
|
|
||||||
|
|
||||||
# Remove repeated substrings like dots
|
|
||||||
text = remove_repeated_substrings(text)
|
|
||||||
|
|
||||||
# Remove extra spaces between lines and within lines
|
|
||||||
text = remove_extra_spaces(text)
|
|
||||||
|
|
||||||
# Additional cleaning steps can be added here
|
|
||||||
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
class Chunk:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
size: int,
|
|
||||||
document_id: UUID,
|
|
||||||
chunk_id: int,
|
|
||||||
embedding,
|
|
||||||
):
|
|
||||||
self.text = text
|
|
||||||
self.size = size
|
|
||||||
self.document_id = document_id
|
|
||||||
self.chunk_id = chunk_id
|
|
||||||
self.embedding = embedding
|
|
||||||
|
|
||||||
|
|
||||||
class Chunker:
|
|
||||||
def __init__(self, collection) -> None:
|
|
||||||
self.collection = collection
|
|
||||||
self.llm_client = LLMClient()
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
chunk_size = min(chunk_size, len(document)) or 1
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
num_chunks = ceil(len(document) / chunk_size)
|
|
||||||
document_length = len(document)
|
|
||||||
|
|
||||||
for i in range(num_chunks):
|
|
||||||
curr_pos = i * num_chunks
|
|
||||||
to_pos = (
|
|
||||||
curr_pos + chunk_size
|
|
||||||
if curr_pos + chunk_size < document_length
|
|
||||||
else document_length
|
|
||||||
)
|
|
||||||
text_chunk = self.clean_document(document[curr_pos:to_pos])
|
|
||||||
|
|
||||||
embedding = self.embedding_fx([text_chunk])
|
|
||||||
self.collection.add(
|
|
||||||
ids=[str(doc_uuid) + ":" + str(i)],
|
|
||||||
documents=[text_chunk],
|
|
||||||
embeddings=embedding,
|
|
||||||
metadatas=[metadata],
|
|
||||||
)
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
def clean_document(self, document: str) -> str:
|
|
||||||
"""This function will remove information that is noise or already known.
|
|
||||||
|
|
||||||
Example: We already know all the things in here are Simba-related, so we don't need things like
|
|
||||||
"Sumamry of simba's visit"
|
|
||||||
"""
|
|
||||||
|
|
||||||
document = document.replace("\\n", "")
|
|
||||||
document = document.strip()
|
|
||||||
|
|
||||||
return preprocess_text(document)
|
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
"""Mealie API service for querying meal plans, recipes, and shopping lists."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Optional
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
class MealieService:
|
||||||
|
"""Service for interacting with Mealie API."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize Mealie API client."""
|
||||||
|
logger.info("[MEALIE] Initializing Mealie service...")
|
||||||
|
self.base_url = os.getenv("MEALIE_BASE_URL", "")
|
||||||
|
self.api_token = os.getenv("MEALIE_API_TOKEN", "")
|
||||||
|
|
||||||
|
if not self.base_url:
|
||||||
|
logger.error("[MEALIE] MEALIE_BASE_URL not found in environment")
|
||||||
|
raise ValueError("MEALIE_BASE_URL environment variable is required")
|
||||||
|
|
||||||
|
if not self.api_token:
|
||||||
|
logger.error("[MEALIE] MEALIE_API_TOKEN not found in environment")
|
||||||
|
raise ValueError("MEALIE_API_TOKEN environment variable is required")
|
||||||
|
|
||||||
|
# Remove trailing slash from base URL if present
|
||||||
|
self.base_url = self.base_url.rstrip("/")
|
||||||
|
|
||||||
|
logger.info(f"[MEALIE] Base URL: {self.base_url}")
|
||||||
|
logger.info(f"[MEALIE] API token found: {self.api_token[:10]}...")
|
||||||
|
|
||||||
|
# Create HTTP client
|
||||||
|
self.client = httpx.AsyncClient(
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("[MEALIE] Mealie service initialized successfully")
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the HTTP client."""
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
async def get_shopping_lists(self) -> dict[str, Any]:
|
||||||
|
"""Get all shopping lists with their items.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing shopping lists and their items.
|
||||||
|
"""
|
||||||
|
logger.info("[MEALIE] get_shopping_lists() called")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("[MEALIE] Fetching shopping lists from Mealie API...")
|
||||||
|
response = await self.client.get(
|
||||||
|
f"{self.base_url}/api/households/shopping/lists"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[MEALIE] Retrieved {len(data.get('items', []))} shopping lists"
|
||||||
|
)
|
||||||
|
|
||||||
|
shopping_lists = []
|
||||||
|
total_unchecked_items = 0
|
||||||
|
|
||||||
|
for shopping_list in data.get("items", []):
|
||||||
|
list_id = shopping_list.get("id")
|
||||||
|
list_name = shopping_list.get("name", "Unnamed List")
|
||||||
|
|
||||||
|
# Get items for this list
|
||||||
|
items_response = await self.client.get(
|
||||||
|
f"{self.base_url}/api/households/shopping/items",
|
||||||
|
params={"shopping_list_id": list_id},
|
||||||
|
)
|
||||||
|
items_response.raise_for_status()
|
||||||
|
items_data = items_response.json()
|
||||||
|
|
||||||
|
unchecked_items = []
|
||||||
|
checked_items = []
|
||||||
|
|
||||||
|
for item in items_data.get("items", []):
|
||||||
|
item_data = {
|
||||||
|
"name": item.get("display", item.get("note", "Unknown item")),
|
||||||
|
"quantity": item.get("quantity", 1),
|
||||||
|
"note": item.get("note", ""),
|
||||||
|
"food": item.get("foodId"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.get("checked", False):
|
||||||
|
checked_items.append(item_data)
|
||||||
|
else:
|
||||||
|
unchecked_items.append(item_data)
|
||||||
|
total_unchecked_items += 1
|
||||||
|
|
||||||
|
shopping_lists.append(
|
||||||
|
{
|
||||||
|
"id": list_id,
|
||||||
|
"name": list_name,
|
||||||
|
"unchecked_items": unchecked_items,
|
||||||
|
"checked_items": checked_items,
|
||||||
|
"total_items": len(unchecked_items) + len(checked_items),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[MEALIE] Processed {len(shopping_lists)} lists with {total_unchecked_items} unchecked items"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"shopping_lists": shopping_lists,
|
||||||
|
"total_lists": len(shopping_lists),
|
||||||
|
"total_unchecked_items": total_unchecked_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] HTTP error in get_shopping_lists(): {e.response.status_code} - {e.response.text}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] Error in get_shopping_lists(): {type(e).__name__}: {str(e)}"
|
||||||
|
)
|
||||||
|
logger.exception("[MEALIE] Full traceback:")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_meal_plans(
|
||||||
|
self,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get meal plans for a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date in YYYY-MM-DD format (defaults to today)
|
||||||
|
end_date: End date in YYYY-MM-DD format (defaults to 7 days from start)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing meal plans for the specified date range.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"[MEALIE] get_meal_plans() called - start_date={start_date}, end_date={end_date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set default dates
|
||||||
|
if not start_date:
|
||||||
|
start_date = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
if not end_date:
|
||||||
|
# Default to 7 days from start
|
||||||
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
|
end_dt = start_dt + timedelta(days=7)
|
||||||
|
end_date = end_dt.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
logger.info(f"[MEALIE] Fetching meal plans from {start_date} to {end_date}")
|
||||||
|
|
||||||
|
response = await self.client.get(
|
||||||
|
f"{self.base_url}/api/households/mealplans",
|
||||||
|
params={"start_date": start_date, "end_date": end_date},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
logger.info(f"[MEALIE] Retrieved {len(data.get('items', []))} meal plans")
|
||||||
|
|
||||||
|
meal_plans = []
|
||||||
|
for plan in data.get("items", []):
|
||||||
|
meal_plans.append(
|
||||||
|
{
|
||||||
|
"id": plan.get("id"),
|
||||||
|
"date": plan.get("date"),
|
||||||
|
"entry_type": plan.get("entryType", "unknown"),
|
||||||
|
"title": plan.get("title", ""),
|
||||||
|
"recipe_name": plan.get("recipeName", ""),
|
||||||
|
"recipe_slug": plan.get("recipeSlug", ""),
|
||||||
|
"note": plan.get("note", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group by date
|
||||||
|
plans_by_date = {}
|
||||||
|
for plan in meal_plans:
|
||||||
|
date = plan["date"]
|
||||||
|
if date not in plans_by_date:
|
||||||
|
plans_by_date[date] = []
|
||||||
|
plans_by_date[date].append(plan)
|
||||||
|
|
||||||
|
logger.info(f"[MEALIE] Organized into {len(plans_by_date)} unique dates")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"meal_plans": meal_plans,
|
||||||
|
"plans_by_date": plans_by_date,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"total_plans": len(meal_plans),
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] HTTP error in get_meal_plans(): {e.response.status_code} - {e.response.text}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] Error in get_meal_plans(): {type(e).__name__}: {str(e)}"
|
||||||
|
)
|
||||||
|
logger.exception("[MEALIE] Full traceback:")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_todays_meals(self) -> dict[str, Any]:
|
||||||
|
"""Get today's meal plans.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing today's meal plans.
|
||||||
|
"""
|
||||||
|
logger.info("[MEALIE] get_todays_meals() called")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("[MEALIE] Fetching today's meals from Mealie API...")
|
||||||
|
response = await self.client.get(
|
||||||
|
f"{self.base_url}/api/households/mealplans/today"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[MEALIE] Retrieved {len(data.get('items', []))} meals for today"
|
||||||
|
)
|
||||||
|
|
||||||
|
meals = []
|
||||||
|
for plan in data.get("items", []):
|
||||||
|
meals.append(
|
||||||
|
{
|
||||||
|
"id": plan.get("id"),
|
||||||
|
"entry_type": plan.get("entryType", "unknown"),
|
||||||
|
"title": plan.get("title", ""),
|
||||||
|
"recipe_name": plan.get("recipeName", ""),
|
||||||
|
"recipe_slug": plan.get("recipeSlug", ""),
|
||||||
|
"note": plan.get("note", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"today": datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
"meals": meals,
|
||||||
|
"total_meals": len(meals),
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] HTTP error in get_todays_meals(): {e.response.status_code} - {e.response.text}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] Error in get_todays_meals(): {type(e).__name__}: {str(e)}"
|
||||||
|
)
|
||||||
|
logger.exception("[MEALIE] Full traceback:")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_recipe(self, recipe_slug: str) -> dict[str, Any]:
|
||||||
|
"""Get recipe details by slug.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_slug: The recipe's slug identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing recipe details including ingredients and instructions.
|
||||||
|
"""
|
||||||
|
logger.info(f"[MEALIE] get_recipe() called - recipe_slug={recipe_slug}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"[MEALIE] Fetching recipe '{recipe_slug}' from Mealie API...")
|
||||||
|
response = await self.client.get(
|
||||||
|
f"{self.base_url}/api/households/self/recipes/{recipe_slug}"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
recipe = response.json()
|
||||||
|
|
||||||
|
logger.info(f"[MEALIE] Retrieved recipe: {recipe.get('name', 'Unknown')}")
|
||||||
|
|
||||||
|
# Extract key information
|
||||||
|
ingredients = []
|
||||||
|
for ingredient in recipe.get("recipeIngredient", []):
|
||||||
|
if isinstance(ingredient, dict):
|
||||||
|
ingredients.append(
|
||||||
|
{
|
||||||
|
"display": ingredient.get("display", ""),
|
||||||
|
"quantity": ingredient.get("quantity"),
|
||||||
|
"unit": ingredient.get("unit", {}).get("name", ""),
|
||||||
|
"food": ingredient.get("food", {}).get("name", ""),
|
||||||
|
"note": ingredient.get("note", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Sometimes ingredients are just strings
|
||||||
|
ingredients.append({"display": str(ingredient)})
|
||||||
|
|
||||||
|
instructions = []
|
||||||
|
for instruction in recipe.get("recipeInstructions", []):
|
||||||
|
if isinstance(instruction, dict):
|
||||||
|
instructions.append(
|
||||||
|
{
|
||||||
|
"title": instruction.get("title", ""),
|
||||||
|
"text": instruction.get("text", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
instructions.append({"text": str(instruction)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": recipe.get("name", "Unknown Recipe"),
|
||||||
|
"slug": recipe_slug,
|
||||||
|
"description": recipe.get("description", ""),
|
||||||
|
"ingredients": ingredients,
|
||||||
|
"instructions": instructions,
|
||||||
|
"prep_time": recipe.get("prepTime", ""),
|
||||||
|
"cook_time": recipe.get("performTime", ""),
|
||||||
|
"total_time": recipe.get("totalTime", ""),
|
||||||
|
"servings": recipe.get("recipeYield", ""),
|
||||||
|
"tags": [tag.get("name", "") for tag in recipe.get("tags", [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] HTTP error in get_recipe(): {e.response.status_code} - {e.response.text}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] Error in get_recipe(): {type(e).__name__}: {str(e)}"
|
||||||
|
)
|
||||||
|
logger.exception("[MEALIE] Full traceback:")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def create_meal_plan(
|
||||||
|
self,
|
||||||
|
date: str,
|
||||||
|
entry_type: str = "dinner",
|
||||||
|
recipe_slug: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new meal plan entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: Date in YYYY-MM-DD format
|
||||||
|
entry_type: Type of meal (breakfast, lunch, dinner, side)
|
||||||
|
recipe_slug: Recipe slug to add to the meal plan (optional)
|
||||||
|
title: Custom title for the meal (optional, used if no recipe)
|
||||||
|
note: Additional notes for the meal (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing the created meal plan entry.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"[MEALIE] create_meal_plan() called - date={date}, entry_type={entry_type}, recipe={recipe_slug}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build request body
|
||||||
|
body = {
|
||||||
|
"date": date,
|
||||||
|
"entryType": entry_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
if recipe_slug:
|
||||||
|
body["recipeId"] = recipe_slug # API might use recipeId or recipeSlug
|
||||||
|
if title:
|
||||||
|
body["title"] = title
|
||||||
|
if note:
|
||||||
|
body["text"] = note # API might use 'text' or 'note'
|
||||||
|
|
||||||
|
logger.info(f"[MEALIE] Creating meal plan with body: {body}")
|
||||||
|
|
||||||
|
response = await self.client.post(
|
||||||
|
f"{self.base_url}/api/households/mealplans", json=body
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[MEALIE] Successfully created meal plan entry with ID: {data.get('id')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": data.get("id"),
|
||||||
|
"date": data.get("date"),
|
||||||
|
"entry_type": data.get("entryType"),
|
||||||
|
"title": data.get("title"),
|
||||||
|
"recipe_name": data.get("recipeName"),
|
||||||
|
"note": data.get("text", data.get("note", "")),
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] HTTP error in create_meal_plan(): {e.response.status_code} - {e.response.text}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] Error in create_meal_plan(): {type(e).__name__}: {str(e)}"
|
||||||
|
)
|
||||||
|
logger.exception("[MEALIE] Full traceback:")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def update_meal_plan(
|
||||||
|
self,
|
||||||
|
item_id: int,
|
||||||
|
date: Optional[str] = None,
|
||||||
|
entry_type: Optional[str] = None,
|
||||||
|
recipe_slug: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update an existing meal plan entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: ID of the meal plan entry to update
|
||||||
|
date: Date in YYYY-MM-DD format (optional)
|
||||||
|
entry_type: Type of meal (breakfast, lunch, dinner, side) (optional)
|
||||||
|
recipe_slug: Recipe slug to update (optional)
|
||||||
|
title: Custom title for the meal (optional)
|
||||||
|
note: Additional notes for the meal (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing the updated meal plan entry.
|
||||||
|
"""
|
||||||
|
logger.info(f"[MEALIE] update_meal_plan() called - item_id={item_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build request body with only provided fields
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
if date:
|
||||||
|
body["date"] = date
|
||||||
|
if entry_type:
|
||||||
|
body["entryType"] = entry_type
|
||||||
|
if recipe_slug:
|
||||||
|
body["recipeId"] = recipe_slug
|
||||||
|
if title:
|
||||||
|
body["title"] = title
|
||||||
|
if note:
|
||||||
|
body["text"] = note
|
||||||
|
|
||||||
|
logger.info(f"[MEALIE] Updating meal plan {item_id} with body: {body}")
|
||||||
|
|
||||||
|
response = await self.client.put(
|
||||||
|
f"{self.base_url}/api/households/mealplans/{item_id}", json=body
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
logger.info(f"[MEALIE] Successfully updated meal plan entry {item_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": data.get("id"),
|
||||||
|
"date": data.get("date"),
|
||||||
|
"entry_type": data.get("entryType"),
|
||||||
|
"title": data.get("title"),
|
||||||
|
"recipe_name": data.get("recipeName"),
|
||||||
|
"note": data.get("text", data.get("note", "")),
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] HTTP error in update_meal_plan(): {e.response.status_code} - {e.response.text}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] Error in update_meal_plan(): {type(e).__name__}: {str(e)}"
|
||||||
|
)
|
||||||
|
logger.exception("[MEALIE] Full traceback:")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def delete_meal_plan(self, item_id: int) -> dict[str, Any]:
|
||||||
|
"""Delete a meal plan entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: ID of the meal plan entry to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing success status and deleted entry info.
|
||||||
|
"""
|
||||||
|
logger.info(f"[MEALIE] delete_meal_plan() called - item_id={item_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self.client.delete(
|
||||||
|
f"{self.base_url}/api/households/mealplans/{item_id}"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
logger.info(f"[MEALIE] Successfully deleted meal plan entry {item_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": item_id,
|
||||||
|
"deleted": True,
|
||||||
|
"entry": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] HTTP error in delete_meal_plan(): {e.response.status_code} - {e.response.text}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MEALIE] Error in delete_meal_plan(): {type(e).__name__}: {str(e)}"
|
||||||
|
)
|
||||||
|
logger.exception("[MEALIE] Full traceback:")
|
||||||
|
raise
|
||||||
+54
-14
@@ -61,7 +61,9 @@ class ObsidianService:
|
|||||||
|
|
||||||
return md_files
|
return md_files
|
||||||
|
|
||||||
def parse_markdown(self, content: str, filepath: Optional[Path] = None) -> dict[str, Any]:
|
def parse_markdown(
|
||||||
|
self, content: str, filepath: Optional[Path] = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Parse Obsidian markdown to extract metadata and clean content.
|
"""Parse Obsidian markdown to extract metadata and clean content.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -104,8 +106,12 @@ class ObsidianService:
|
|||||||
embeds = [e.split(":")[0].strip() if ":" in e else e.strip() for e in embeds]
|
embeds = [e.split(":")[0].strip() if ":" in e else e.strip() for e in embeds]
|
||||||
|
|
||||||
# Clean body content
|
# Clean body content
|
||||||
# Remove wikilinks [[...]] and embeds [[!...]]
|
# Remove embeds ![[...]]
|
||||||
cleaned_content = re.sub(r"\[\[.*?\]\]", "", body_content)
|
cleaned_content = re.sub(r"!\[\[.*?\]\]", "", body_content)
|
||||||
|
# Convert wikilinks to display text: [[target|display]] → display, [[target]] → target
|
||||||
|
cleaned_content = re.sub(
|
||||||
|
r"\[\[([^\]|]+\|)?([^\]]+)\]\]", r"\2", cleaned_content
|
||||||
|
)
|
||||||
cleaned_content = re.sub(r"\n{3,}", "\n\n", cleaned_content).strip()
|
cleaned_content = re.sub(r"\n{3,}", "\n\n", cleaned_content).strip()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -187,7 +193,9 @@ class ObsidianService:
|
|||||||
default_frontmatter.setdefault("tags", []).extend(tags)
|
default_frontmatter.setdefault("tags", []).extend(tags)
|
||||||
|
|
||||||
# Write note
|
# Write note
|
||||||
frontmatter_yaml = yaml.dump(default_frontmatter, allow_unicode=True, default_flow_style=False)
|
frontmatter_yaml = yaml.dump(
|
||||||
|
default_frontmatter, allow_unicode=True, default_flow_style=False
|
||||||
|
)
|
||||||
full_content = f"---\n{frontmatter_yaml}---\n\n{content}"
|
full_content = f"---\n{frontmatter_yaml}---\n\n{content}"
|
||||||
|
|
||||||
with open(note_path, "w", encoding="utf-8") as f:
|
with open(note_path, "w", encoding="utf-8") as f:
|
||||||
@@ -247,7 +255,7 @@ class ObsidianService:
|
|||||||
"""
|
"""
|
||||||
if date is None:
|
if date is None:
|
||||||
date = datetime.now()
|
date = datetime.now()
|
||||||
return f"journal/{date.strftime('%Y')}/{date.strftime('%Y-%m-%d')}.md"
|
return f"50 - Journal/{date.strftime('%Y')}/{date.strftime('%m')}/{date.strftime('%Y-%m-%d')}.md"
|
||||||
|
|
||||||
def get_daily_note(self, date: Optional[datetime] = None) -> dict[str, Any]:
|
def get_daily_note(self, date: Optional[datetime] = None) -> dict[str, Any]:
|
||||||
"""Read a daily note from the vault.
|
"""Read a daily note from the vault.
|
||||||
@@ -264,12 +272,22 @@ class ObsidianService:
|
|||||||
note_path = Path(self.vault_path) / relative_path
|
note_path = Path(self.vault_path) / relative_path
|
||||||
|
|
||||||
if not note_path.exists():
|
if not note_path.exists():
|
||||||
return {"found": False, "path": relative_path, "content": None, "date": date.strftime("%Y-%m-%d")}
|
return {
|
||||||
|
"found": False,
|
||||||
|
"path": relative_path,
|
||||||
|
"content": None,
|
||||||
|
"date": date.strftime("%Y-%m-%d"),
|
||||||
|
}
|
||||||
|
|
||||||
with open(note_path, "r", encoding="utf-8") as f:
|
with open(note_path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
return {"found": True, "path": relative_path, "content": content, "date": date.strftime("%Y-%m-%d")}
|
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]:
|
def get_daily_tasks(self, date: Optional[datetime] = None) -> dict[str, Any]:
|
||||||
"""Extract tasks from a daily note's tasks section.
|
"""Extract tasks from a daily note's tasks section.
|
||||||
@@ -284,7 +302,12 @@ class ObsidianService:
|
|||||||
date = datetime.now()
|
date = datetime.now()
|
||||||
note = self.get_daily_note(date)
|
note = self.get_daily_note(date)
|
||||||
if not note["found"]:
|
if not note["found"]:
|
||||||
return {"found": False, "tasks": [], "date": note["date"], "path": note["path"]}
|
return {
|
||||||
|
"found": False,
|
||||||
|
"tasks": [],
|
||||||
|
"date": note["date"],
|
||||||
|
"path": note["path"],
|
||||||
|
}
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
in_tasks = False
|
in_tasks = False
|
||||||
@@ -302,9 +325,16 @@ class ObsidianService:
|
|||||||
elif todo_match:
|
elif todo_match:
|
||||||
tasks.append({"text": todo_match.group(1), "done": False})
|
tasks.append({"text": todo_match.group(1), "done": False})
|
||||||
|
|
||||||
return {"found": True, "tasks": tasks, "date": note["date"], "path": note["path"]}
|
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]:
|
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.
|
"""Add a task checkbox to a daily note, creating the note if needed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -336,7 +366,9 @@ class ObsidianService:
|
|||||||
log_match = re.search(r"\n(### log)", content, re.IGNORECASE)
|
log_match = re.search(r"\n(### log)", content, re.IGNORECASE)
|
||||||
if log_match:
|
if log_match:
|
||||||
insert_pos = log_match.start()
|
insert_pos = log_match.start()
|
||||||
content = content[:insert_pos] + f"\n- [ ] {task_text}" + content[insert_pos:]
|
content = (
|
||||||
|
content[:insert_pos] + f"\n- [ ] {task_text}" + content[insert_pos:]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
content = content.rstrip() + f"\n- [ ] {task_text}\n"
|
content = content.rstrip() + f"\n- [ ] {task_text}\n"
|
||||||
|
|
||||||
@@ -345,7 +377,9 @@ class ObsidianService:
|
|||||||
|
|
||||||
return {"success": True, "created_note": False, "path": relative_path}
|
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]:
|
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.
|
"""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
|
Searches for a task matching the given text (exact or partial) and
|
||||||
@@ -374,9 +408,15 @@ class ObsidianService:
|
|||||||
if exact in content:
|
if exact in content:
|
||||||
content = content.replace(exact, f"- [x] {task_text}", 1)
|
content = content.replace(exact, f"- [x] {task_text}", 1)
|
||||||
else:
|
else:
|
||||||
match = re.search(r"- \[ \] .*" + re.escape(task_text) + r".*", content, re.IGNORECASE)
|
match = re.search(
|
||||||
|
r"- \[ \] .*" + re.escape(task_text) + r".*", content, re.IGNORECASE
|
||||||
|
)
|
||||||
if not match:
|
if not match:
|
||||||
return {"success": False, "error": f"Task '{task_text}' not found", "path": relative_path}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Task '{task_text}' not found",
|
||||||
|
"path": relative_path,
|
||||||
|
}
|
||||||
completed = match.group(0).replace("- [ ]", "- [x]", 1)
|
completed = match.group(0).replace("- [ ]", "- [x]", 1)
|
||||||
content = content.replace(match.group(0), completed, 1)
|
content = content.replace(match.group(0), completed, 1)
|
||||||
task_text = match.group(0).replace("- [ ] ", "")
|
task_text = match.group(0).replace("- [ ] ", "")
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""SimbaKit API service for querying PetKit device and pet data."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SimbaKitService:
|
||||||
|
"""Service for interacting with SimbaKit API."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = os.getenv("SIMBAKIT_URL", "")
|
||||||
|
if not self.base_url:
|
||||||
|
raise ValueError("SIMBAKIT_URL environment variable is required")
|
||||||
|
self.base_url = self.base_url.rstrip("/")
|
||||||
|
|
||||||
|
async def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(f"{self.base_url}{path}", params=params) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
return await resp.json()
|
||||||
|
|
||||||
|
async def get_devices(self) -> str:
|
||||||
|
"""Get all devices with latest status."""
|
||||||
|
devices = await self._get("/api/devices")
|
||||||
|
if not devices:
|
||||||
|
return "No PetKit devices found."
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for d in devices:
|
||||||
|
snap = d.get("latest_snapshot", {})
|
||||||
|
error = snap.get("error_code")
|
||||||
|
status = f"Error {error}: {snap.get('error_msg', '')}" if error else "OK"
|
||||||
|
lines.append(
|
||||||
|
f"- {d.get('name', d['device_id'])} ({d.get('device_type', 'unknown')}): "
|
||||||
|
f"Status={status}, Firmware={d.get('firmware', 'N/A')}, "
|
||||||
|
f"Last seen={d.get('last_seen_at', 'N/A')}"
|
||||||
|
)
|
||||||
|
return "PetKit Devices:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
async def get_alerts(self, device_id: Optional[str] = None) -> str:
|
||||||
|
"""Get active alerts, optionally filtered by device."""
|
||||||
|
if device_id:
|
||||||
|
alerts = await self._get(f"/api/devices/{device_id}/alerts")
|
||||||
|
else:
|
||||||
|
alerts = await self._get("/api/alerts")
|
||||||
|
|
||||||
|
if not alerts:
|
||||||
|
return "No active alerts."
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for a in alerts:
|
||||||
|
device_name = a.get("device_name", a.get("device_id", "unknown"))
|
||||||
|
lines.append(
|
||||||
|
f"- [{a['severity'].upper()}] {a['alert_label']} "
|
||||||
|
f"(device: {device_name}, at: {a.get('polled_at', 'N/A')})"
|
||||||
|
)
|
||||||
|
return "Active Alerts:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
async def get_pet_weights(
|
||||||
|
self, pet_name: Optional[str] = None, days: Optional[int] = None
|
||||||
|
) -> str:
|
||||||
|
"""Get pet weight records."""
|
||||||
|
params: dict[str, str] = {}
|
||||||
|
if pet_name:
|
||||||
|
params["pet"] = pet_name
|
||||||
|
if days:
|
||||||
|
params["days"] = str(days)
|
||||||
|
|
||||||
|
data = await self._get("/api/pets/weights", params=params or None)
|
||||||
|
weights = data.get("weights", [])
|
||||||
|
|
||||||
|
if not weights:
|
||||||
|
return "No weight records found."
|
||||||
|
|
||||||
|
# Summary stats
|
||||||
|
weight_values = [w["weight_g"] for w in weights]
|
||||||
|
latest = weights[-1]
|
||||||
|
avg_g = sum(weight_values) / len(weight_values)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Pet Weight Data ({len(weights)} records):",
|
||||||
|
f"- Latest: {latest.get('pet_name', 'Unknown')} - {latest['weight_g'] / 1000:.2f} kg at {latest['recorded_at']}",
|
||||||
|
f"- Average: {avg_g / 1000:.2f} kg",
|
||||||
|
f"- Range: {min(weight_values) / 1000:.2f} - {max(weight_values) / 1000:.2f} kg",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Trend from last 5 vs previous 5
|
||||||
|
if len(weights) >= 10:
|
||||||
|
recent = sum(w["weight_g"] for w in weights[-5:]) / 5
|
||||||
|
previous = sum(w["weight_g"] for w in weights[-10:-5]) / 5
|
||||||
|
diff = recent - previous
|
||||||
|
if abs(diff) > 10:
|
||||||
|
direction = "gaining" if diff > 0 else "losing"
|
||||||
|
lines.append(f"- Trend: {direction} ({diff / 1000:+.2f} kg)")
|
||||||
|
else:
|
||||||
|
lines.append("- Trend: stable")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def get_litter_events(
|
||||||
|
self, pet_name: Optional[str] = None, days: Optional[int] = None
|
||||||
|
) -> str:
|
||||||
|
"""Get litter box activity events and stats."""
|
||||||
|
params: dict[str, str] = {}
|
||||||
|
if pet_name:
|
||||||
|
params["pet"] = pet_name
|
||||||
|
if days:
|
||||||
|
params["days"] = str(days)
|
||||||
|
|
||||||
|
data = await self._get("/api/pets/litter-events", params=params or None)
|
||||||
|
events = data.get("events", [])
|
||||||
|
stats = data.get("stats", {})
|
||||||
|
|
||||||
|
if not events and not stats.get("total_visits"):
|
||||||
|
return "No litter box activity found."
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Litter Box Activity ({stats.get('total_visits', 0)} visits):",
|
||||||
|
f"- Poops: {stats.get('poop_count', 0)}",
|
||||||
|
f"- Pees: {stats.get('pee_count', 0)}",
|
||||||
|
]
|
||||||
|
|
||||||
|
avg_time = stats.get("avg_toilet_time", 0)
|
||||||
|
if avg_time:
|
||||||
|
lines.append(f"- Average time in box: {avg_time:.0f}s")
|
||||||
|
|
||||||
|
avg_weight = stats.get("avg_shit_weight", 0)
|
||||||
|
if avg_weight:
|
||||||
|
lines.append(f"- Average poop weight: {avg_weight:.0f}g")
|
||||||
|
|
||||||
|
# Recent events
|
||||||
|
if events:
|
||||||
|
lines.append("\nRecent events:")
|
||||||
|
for e in events[:5]:
|
||||||
|
event_type = "Pooped" if e.get("is_shit") == 1 else "Peed"
|
||||||
|
pet = e.get("pet_name", "Unknown")
|
||||||
|
time = e.get("recorded_at", "N/A")
|
||||||
|
duration = f", {e['toilet_time_s']}s" if e.get("toilet_time_s") else ""
|
||||||
|
lines.append(f"- {pet} {event_type} at {time}{duration}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def get_drinking_events(
|
||||||
|
self, pet_name: Optional[str] = None, days: Optional[int] = None
|
||||||
|
) -> str:
|
||||||
|
"""Get water fountain drinking activity events and stats."""
|
||||||
|
params: dict[str, str] = {}
|
||||||
|
if pet_name:
|
||||||
|
params["pet"] = pet_name
|
||||||
|
if days:
|
||||||
|
params["days"] = str(days)
|
||||||
|
|
||||||
|
data = await self._get("/api/pets/drinking-events", params=params or None)
|
||||||
|
events = data.get("events", [])
|
||||||
|
stats = data.get("stats", {})
|
||||||
|
|
||||||
|
if not events and not stats.get("total_drinks"):
|
||||||
|
return "No water fountain activity found."
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Water Fountain Activity ({stats.get('total_drinks', 0)} drinking sessions):",
|
||||||
|
]
|
||||||
|
|
||||||
|
avg_duration = stats.get("avg_duration", 0)
|
||||||
|
if avg_duration:
|
||||||
|
lines.append(f"- Average drink duration: {avg_duration:.0f}s")
|
||||||
|
|
||||||
|
detections = stats.get("detections", 0)
|
||||||
|
if detections:
|
||||||
|
lines.append(f"- Camera detections (visits without drinking): {detections}")
|
||||||
|
|
||||||
|
per_pet = stats.get("per_pet", [])
|
||||||
|
if len(per_pet) > 1:
|
||||||
|
lines.append("- By pet:")
|
||||||
|
for p in per_pet:
|
||||||
|
lines.append(f" - {p['pet_name']}: {p['drinks']} drinks")
|
||||||
|
|
||||||
|
# Recent events
|
||||||
|
if events:
|
||||||
|
lines.append("\nRecent events:")
|
||||||
|
for e in events[:5]:
|
||||||
|
pet = e.get("pet_name", "Unknown")
|
||||||
|
time = e.get("recorded_at", "N/A")
|
||||||
|
if e.get("record_type") == "drink_over":
|
||||||
|
duration = f" for {e['duration_s']}s" if e.get("duration_s") else ""
|
||||||
|
lines.append(f"- {pet} drank at {time}{duration}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- {pet} visited the fountain at {time}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def strip_markdown(text: str) -> str:
|
||||||
|
"""Strip markdown formatting from text for plain-text channels like iMessage."""
|
||||||
|
# Code blocks (fenced)
|
||||||
|
text = re.sub(
|
||||||
|
r"```[\s\S]*?```", lambda m: re.sub(r"```\w*\n?", "", m.group()), text
|
||||||
|
)
|
||||||
|
# Inline code
|
||||||
|
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||||
|
# Images
|
||||||
|
text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", text)
|
||||||
|
# Links — keep the link text
|
||||||
|
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
||||||
|
# Bold/italic (order matters: bold+italic first)
|
||||||
|
text = re.sub(r"\*\*\*(.+?)\*\*\*", r"\1", text)
|
||||||
|
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||||
|
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||||
|
text = re.sub(r"___(.+?)___", r"\1", text)
|
||||||
|
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||||
|
text = re.sub(r"_(.+?)_", r"\1", text)
|
||||||
|
# Headers
|
||||||
|
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
|
||||||
|
# Horizontal rules
|
||||||
|
text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
|
||||||
|
# Bullet lists — remove the bullet marker
|
||||||
|
text = re.sub(r"^[\s]*[-*+]\s+", "", text, flags=re.MULTILINE)
|
||||||
|
# Numbered lists — remove the number marker
|
||||||
|
text = re.sub(r"^[\s]*\d+\.\s+", "", text, flags=re.MULTILINE)
|
||||||
|
# Blockquotes
|
||||||
|
text = re.sub(r"^>\s?", "", text, flags=re.MULTILINE)
|
||||||
|
# Collapse multiple blank lines
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
return text.strip()
|
||||||
Reference in New Issue
Block a user