feat: 2.1.1 release (#167)

This commit is contained in:
doria 2025-07-23 23:06:09 -04:00 committed by GitHub
parent ffaae28221
commit bc5474c170
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 4456 additions and 2714 deletions

View File

@ -11,12 +11,16 @@ LOG_LEVEL=INFO
FASTAPI_HOST=0.0.0.0
FASTAPI_PORT=8000
# SESSION_PEERS_LIMIT=10
# GET_CONTEXT_DEFAULT_MAX_TOKENS=2048
# Embedding settings
# EMBED_MESSAGES=true
# MAX_EMBEDDING_TOKENS=8192
# MAX_EMBEDDING_TOKENS_PER_REQUEST=300000
# LANGFUSE_HOST=
# LANGFUSE_PUBLIC_KEY=
# =============================================================================
# Database Settings (REQUIRED)
# =============================================================================
@ -90,6 +94,7 @@ LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here
# =============================================================================
# DIALECTIC_PROVIDER=anthropic
# DIALECTIC_MODEL=claude-sonnet-4-20250514
# DIALECTIC_PERFORM_QUERY_GENERATION=false
# DIALECTIC_QUERY_GENERATION_PROVIDER=groq
# DIALECTIC_QUERY_GENERATION_MODEL=llama-3.1-8b-instant
# DIALECTIC_MAX_OUTPUT_TOKENS=2500

View File

@ -5,7 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [2.1.0] - 2025-7-17
## [2.1.1] - 2025-07-23
### Added
- Test harness for custom Honcho evaluations
- Better support for session and peer aware dialectic queries
- Langfuse settings
- Added recent history to dialectic prompt, dynamic based on new context window size setting
### Fixed
- Summary queue logic
- Formatting of logs
- Filtering by session
- Peer targeting in queries
### Changed
- Made query expansion in dialectic off by default
- Overhauled logging
- Refactor summarization for performance and code clarity
- Refactor queue payloads for clarity
## [2.1.0] - 2025-07-17
### Added

View File

@ -1,6 +1,6 @@
# 🫡 Honcho
![Static Badge](https://img.shields.io/badge/Version-2.1.0-blue)
![Static Badge](https://img.shields.io/badge/Version-2.1.1-blue)
[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/plasticlabs)
[![arXiv](https://img.shields.io/badge/arXiv-2310.06983-b31b1b.svg)](https://arxiv.org/abs/2310.06983)
![GitHub License](https://img.shields.io/github/license/plastic-labs/honcho)

View File

@ -9,9 +9,13 @@ LOG_LEVEL = "INFO"
FASTAPI_HOST = "0.0.0.0"
FASTAPI_PORT = 8000
SESSION_PEERS_LIMIT = 10
GET_CONTEXT_DEFAULT_MAX_TOKENS = 2048
EMBED_MESSAGES = true
MAX_EMBEDDING_TOKENS = 8192
MAX_EMBEDDING_TOKENS_PER_REQUEST = 300000
# LANGFUSE_HOST = "https://api.langfuse.com"
# LANGFUSE_PUBLIC_KEY = "your-public-key-here"
# Database settings
[db]
@ -69,6 +73,7 @@ EXPLICIT_OBSERVATIONS_COUNT = 10
[dialectic]
PROVIDER = "anthropic"
MODEL = "claude-sonnet-4-20250514"
PERFORM_QUERY_GENERATION = false
QUERY_GENERATION_PROVIDER = "groq"
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
MAX_OUTPUT_TOKENS = 2500

View File

@ -8,7 +8,7 @@ This guide helps you understand which versions of Honcho's API are compatible wi
## Version Compatibility
### Honcho API v2.1.0 (Current)
### Honcho API v2.1.1 (Current)
<CardGroup cols={2}>
<Card title="TypeScript SDK" icon="js">
@ -35,6 +35,7 @@ This guide helps you understand which versions of Honcho's API are compatible wi
| Honcho API Version | TypeScript SDK | Python SDK |
|-------------------|---------------|------------|
| v2.1.0 (Current) | v1.2.1 | v1.2.2 |
| v2.1.1 (Current) | v1.2.1 | v1.2.2 |
| v2.1.0 | v1.2.1 | v1.2.2 |
| v2.0.5 | v1.1.0 | v1.1.0 |
| v2.0.4 | v1.1.0 | v1.1.0 |

View File

@ -27,7 +27,31 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
### Honcho API and SDK Changelogs
<Tabs>
<Tab title="Honcho API">
<Update label="v2.1.0 (Current)">
<Update label="v2.1.1 (Current)">
### Added
- Test harness for custom Honcho evaluations
- Better support for session and peer aware dialectic queries
- Langfuse settings
- Added recent history to dialectic prompt, dynamic based on new context window size setting
### Fixed
- Summary queue logic
- Formatting of logs
- Filtering by session
- Peer targeting in queries
### Changed
- Made query expansion in dialectic off by default
- Overhauled logging
- Refactor summarization for performance and code clarity
- Refactor queue payloads for clarity
</Update>
<Update label="v2.1.0">
### Added
- File uploads
@ -53,7 +77,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
- Rearranged configuration values (LLM, Deriver, Dialectic, History->Summary)
</Update>
<Update label="v2.0.5 (Current)">
<Update label="v2.0.5">
### Fixed
- Groq API client to use the Async library

View File

@ -19,7 +19,7 @@
"navigation": {
"versions": [
{
"version": "v2.1.0",
"version": "v2.1.1",
"api": {
"openapi": [
"openapi.documented.yml"

View File

@ -46,14 +46,14 @@ cp config.toml.example config.toml
Then modify the values as needed. The TOML file is organized into sections:
- `[app]` - Application-level settings (log level, host, port)
- `[app]` - Application-level settings (log level, host, port, embedding settings)
- `[db]` - Database connection and pool settings
- `[auth]` - Authentication configuration
- `[llm]` - LLM provider and model settings
- `[agent]` - Agent behavior settings
- `[deriver]` - Background worker settings
- `[history]` - Message history settings
- `[sentry]` - Error tracking settings
- `[llm]` - LLM provider API keys and general settings
- `[dialectic]` - Dialectic API configuration (provider, model, search settings)
- `[deriver]` - Background worker settings and theory of mind configuration
- `[summary]` - Session summarization settings
- `[sentry]` - Error tracking and monitoring settings
### Using Environment Variables
@ -67,7 +67,7 @@ Examples:
- `DB_CONNECTION_URI` → `[db].CONNECTION_URI`
- `DB_POOL_SIZE` → `[db].POOL_SIZE`
- `AUTH_JWT_SECRET` → `[auth].JWT_SECRET`
- `LLM_DIALECTIC_MODEL` → `[llm].DIALECTIC_MODEL`
- `DIALECTIC_MODEL` → `[dialectic].MODEL`
- `LOG_LEVEL` (no section) → `[app].LOG_LEVEL`
### Configuration Priority
@ -113,6 +113,13 @@ The application will use the production connection URI while keeping the pool si
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
FASTAPI_HOST=0.0.0.0
FASTAPI_PORT=8000
SESSION_PEERS_LIMIT=10
GET_CONTEXT_DEFAULT_MAX_TOKENS=2048
# Embedding settings (optional)
EMBED_MESSAGES=false
MAX_EMBEDDING_TOKENS=8192
MAX_EMBEDDING_TOKENS_PER_REQUEST=300000
```
**Environment-specific settings:**
@ -193,105 +200,92 @@ python scripts/generate_jwt_secret.py
## LLM Provider Configuration
### Anthropic (Claude)
Honcho supports multiple LLM providers for different tasks. API keys are configured in the `[llm]` section, while specific features use their own configuration sections.
### API Keys
All provider API keys use the `LLM_` prefix:
**Required Settings:**
```bash
# API key for Anthropic
ANTHROPIC_API_KEY=your-anthropic-api-key
# Provider API Keys
LLM_ANTHROPIC_API_KEY=your-anthropic-api-key
LLM_OPENAI_API_KEY=your-openai-api-key
LLM_GEMINI_API_KEY=your-gemini-api-key
LLM_GROQ_API_KEY=your-groq-api-key
# Model for dialectic reasoning
LLM_DIALECTIC_PROVIDER=anthropic
LLM_DIALECTIC_MODEL=claude-3-7-sonnet-20250219
# OpenAI-compatible endpoints
LLM_OPENAI_COMPATIBLE_API_KEY=your-api-key
LLM_OPENAI_COMPATIBLE_BASE_URL=https://your-openai-compatible-endpoint.com
```
### OpenAI
### General LLM Settings
**Required Settings:**
```bash
# API key for OpenAI
OPENAI_API_KEY=your-openai-api-key
# Alternative OpenAI-compatible API
OPENAI_COMPATIBLE_API_KEY=your-api-key
OPENAI_COMPATIBLE_BASE_URL=https://your-openai-compatible-endpoint.com
# Default settings for all LLM calls
LLM_DEFAULT_MAX_TOKENS=2500
```
### Google (Gemini)
### Feature-Specific Model Configuration
**Required Settings:**
Different features can use different providers and models:
**Dialectic API:**
```bash
# API key for Google Gemini
GEMINI_API_KEY=your-gemini-api-key
# Main dialectic model (default: Anthropic)
DIALECTIC_PROVIDER=anthropic
DIALECTIC_MODEL=claude-sonnet-4-20250514
DIALECTIC_MAX_OUTPUT_TOKENS=2500
DIALECTIC_THINKING_BUDGET_TOKENS=1024
# Model for summarization
LLM_SUMMARY_PROVIDER=gemini
LLM_SUMMARY_MODEL=gemini-2.0-flash-lite
LLM_SUMMARY_MAX_TOKENS_SHORT=1000
LLM_SUMMARY_MAX_TOKENS_LONG=2000
# Query generation for dialectic (default: Groq)
DIALECTIC_QUERY_GENERATION_PROVIDER=groq
DIALECTIC_QUERY_GENERATION_MODEL=llama-3.1-8b-instant
# Semantic search settings
DIALECTIC_SEMANTIC_SEARCH_TOP_K=10
DIALECTIC_SEMANTIC_SEARCH_MAX_DISTANCE=0.85
```
### Groq
**Required Settings:**
**Deriver:**
```bash
# API key for Groq
GROQ_API_KEY=your-groq-api-key
# Deriver model (default: Google)
DERIVER_PROVIDER=google
DERIVER_MODEL=gemini-2.0-flash-lite
# Model for query generation
LLM_QUERY_GENERATION_PROVIDER=groq
LLM_QUERY_GENERATION_MODEL=llama-3.1-8b-instant
```
### LLM General Settings
**Default Configuration:**
```bash
# Default token and temperature settings
LLM_DEFAULT_MAX_TOKENS=1000
LLM_DEFAULT_TEMPERATURE=0.0
```
## Agent Configuration
**Semantic Search Settings:**
```bash
# Search configuration
AGENT_SEMANTIC_SEARCH_TOP_K=10
AGENT_SEMANTIC_SEARCH_MAX_DISTANCE=0.85
```
**Theory of Mind Settings:**
```bash
# TOM inference method
AGENT_TOM_INFERENCE_METHOD=single_prompt
```
## Deriver Configuration
**Background Processing:**
```bash
# Worker configuration
# Worker settings
DERIVER_WORKERS=1
DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
# Observation retrieval counts
DERIVER_DEDUCTIVE_OBSERVATIONS_COUNT=6
DERIVER_EXPLICIT_OBSERVATIONS_COUNT=10
```
**Processing Methods:**
**Summary Generation:**
```bash
# TOM and user representation methods
DERIVER_TOM_METHOD=single_prompt
DERIVER_USER_REPRESENTATION_METHOD=long_term
# Summary model (default: Google)
SUMMARY_PROVIDER=google
SUMMARY_MODEL=gemini-1.5-flash-latest
SUMMARY_MAX_TOKENS_SHORT=1000
SUMMARY_MAX_TOKENS_LONG=2000
SUMMARY_THINKING_BUDGET_TOKENS=512
# Summary frequency
SUMMARY_MESSAGES_PER_SHORT_SUMMARY=20
SUMMARY_MESSAGES_PER_LONG_SUMMARY=60
```
## History Configuration
### Default Provider Usage
By default, Honcho uses:
- **Anthropic** for dialectic API responses
- **Groq** for query generation
- **Google** for deriving theory of mind and summarization
- **OpenAI** for embeddings (if `EMBED_MESSAGES=true`)
You only need to set the API keys for the providers you plan to use.
**Message Summarization:**
```bash
# Summary thresholds
HISTORY_MESSAGES_PER_SHORT_SUMMARY=20
HISTORY_MESSAGES_PER_LONG_SUMMARY=60
```
## Monitoring Configuration
@ -318,6 +312,8 @@ SENTRY_PROFILES_SAMPLE_RATE=0.1
LOG_LEVEL = "DEBUG"
FASTAPI_HOST = "127.0.0.1"
FASTAPI_PORT = 8000
SESSION_PEERS_LIMIT = 10
EMBED_MESSAGES = false
[db]
CONNECTION_URI = "postgresql+psycopg://postgres:postgres@localhost:5432/honcho_dev"
@ -326,9 +322,18 @@ POOL_SIZE = 5
[auth]
USE_AUTH = false
[llm]
DIALECTIC_PROVIDER = "anthropic"
DIALECTIC_MODEL = "claude-3-7-sonnet-20250219"
[dialectic]
PROVIDER = "anthropic"
MODEL = "claude-sonnet-4-20250514"
QUERY_GENERATION_PROVIDER = "groq"
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
MAX_OUTPUT_TOKENS = 2500
[summary]
PROVIDER = "google"
MODEL = "gemini-1.5-flash-latest"
MAX_TOKENS_SHORT = 1000
MAX_TOKENS_LONG = 2000
[deriver]
WORKERS = 1
@ -354,6 +359,8 @@ ANTHROPIC_API_KEY=your-dev-anthropic-key
LOG_LEVEL = "WARNING"
FASTAPI_HOST = "0.0.0.0"
FASTAPI_PORT = 8000
SESSION_PEERS_LIMIT = 10
EMBED_MESSAGES = true
[db]
CONNECTION_URI = "postgresql+psycopg://honcho_user:secure_password@prod-db:5432/honcho_prod"
@ -363,14 +370,23 @@ MAX_OVERFLOW = 40
[auth]
USE_AUTH = true
[llm]
DIALECTIC_PROVIDER = "anthropic"
DIALECTIC_MODEL = "claude-3-7-sonnet-20250219"
SUMMARY_PROVIDER = "gemini"
SUMMARY_MODEL = "gemini-2.0-flash-lite"
[dialectic]
PROVIDER = "anthropic"
MODEL = "claude-sonnet-4-20250514"
QUERY_GENERATION_PROVIDER = "groq"
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
MAX_OUTPUT_TOKENS = 2500
[summary]
PROVIDER = "google"
MODEL = "gemini-1.5-flash-latest"
MAX_TOKENS_SHORT = 1000
MAX_TOKENS_LONG = 2000
[deriver]
WORKERS = 4
PROVIDER = "google"
MODEL = "gemini-2.0-flash-lite"
[sentry]
ENABLED = true

View File

@ -124,6 +124,7 @@ important context for a peer (emails, docs, files, etc.).
- System notifications
- Rich media content
- User actions (clicked, reacted, etc.)
- File uploads (PDFs, text files, JSON documents)
## Deriver

View File

@ -67,4 +67,4 @@ cognitive functions.
continually generating & updating internal world models to anticipate sensory input, rather than
passively receiving it--closely linked to Bayesian brain hypotheses, which hold that the brain
interprets the world probabilistically, weighing prior knowledge against new evidence to minimize
uncertainty.
uncertainty.

View File

@ -124,7 +124,7 @@ def add_conversation_turn(session, user_message, assistant_response=None):
add_conversation_turn(session, "Hi! I'm working on a Python project today.")
```
## Part 4: Fact Learning and Memory
## Part 4: LLM Integration with Context
### Store Information in Peer Representations

View File

@ -254,18 +254,18 @@ response = alice.chat("What did I have for breakfast?")
response = alice.chat("What do I know about Bob?", target="bob")
response = alice.chat("What happened in session-1?", session_id="session-1")
# Add content to peer's global representation
alice.add_messages("I love Python programming")
alice.add_messages([
alice.message("Today I learned about async programming"),
alice.message("I prefer functional programming patterns")
# Add content to a session with a peer
session = honcho.session("session-1")
session.add_messages([
alice.message("I love Python programming"),
alice.message("Today I learned about async programming"),
alice.message("I prefer functional programming patterns")
])
# Get peer's sessions and messages
# Get peer's sessions
sessions = alice.get_sessions()
messages = alice.get_messages()
# Search peer's content
# Search peer's messages
results = alice.search("programming")
# Metadata management
@ -292,18 +292,18 @@ const sessionResponse = await alice.chat("What happened in session-1?", {
// Chat with streaming support
const streamResponse = await alice.chat("Tell me a story", { stream: true });
// Add content to peer's global representation
await alice.addMessages("I love TypeScript programming");
await alice.addMessages([
// Add content to a session with a peer
const session = honcho.session("session-1");
await session.addMessages([
alice.message("I love TypeScript programming"),
alice.message("Today I learned about async programming"),
alice.message("I prefer functional programming patterns")
]);
// Get peer's sessions and messages
// Get peer's sessions
const sessions = await alice.getSessions();
const messages = await alice.getMessages();
// Search peer's content
// Search peer's messages
const results = await alice.search("programming");
// Metadata management

View File

@ -230,7 +230,6 @@ def analyze_documents():
"""Get AI analysis of uploaded documents"""
context = session.get_context(tokens=4000)
messages = context.to_openai(assistant=assistant)
# Add analysis request
messages.append({
"role": "user",
@ -281,7 +280,6 @@ async function uploadDocument(filePath: string, description: string) {
async function analyzeDocuments() {
const context = await session.getContext({ tokens: 4000 });
const messages = context.toOpenAI(assistant);
// Add analysis request
messages.push({
role: "user",

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[project]
name = "honcho"
version = "2.1.0"
version = "2.1.1"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
@ -32,6 +32,7 @@ dependencies = [
[tool.uv]
dev-dependencies = [
"honcho-core>=1.2.0",
"honcho-ai>=1.2.1",
"pytest>=8.2.2",
"sqlalchemy-utils>=0.41.2",
"pytest-asyncio>=0.23.7",

View File

@ -52,21 +52,21 @@ class VersionUpdater:
# Enter new version numbers below. Leave blank to skip updating that component.
#
# MAIN API
# Current version: {current_versions['api']}
# Current version: {current_versions["api"]}
API_VERSION=
# API Changelog (use ### for section headers: Added, Changed, Fixed, etc.)
# PYTHON SDK
# Current version: {current_versions['python_sdk']}
# Current version: {current_versions["python_sdk"]}
PYTHON_VERSION=
# Python SDK Changelog
# TYPESCRIPT SDK
# Current version: {current_versions['typescript_sdk']}
# Current version: {current_versions["typescript_sdk"]}
TYPESCRIPT_VERSION=
# TypeScript SDK Changelog
@ -162,7 +162,6 @@ TYPESCRIPT_VERSION=
current_section = None
section_has_content = False
section_start_idx = -1
for i, line in enumerate(lines):
# Check if this is a section header
is_section_header = False
@ -173,7 +172,6 @@ TYPESCRIPT_VERSION=
if section_has_content:
# Keep the section
cleaned_lines.extend(lines[section_start_idx:i])
# Start tracking new section
current_section = section
section_start_idx = i

View File

@ -203,6 +203,8 @@ class DialecticSettings(HonchoSettings):
PROVIDER: Providers = "anthropic"
MODEL: str = "claude-sonnet-4-20250514"
PERFORM_QUERY_GENERATION: bool = False
QUERY_GENERATION_PROVIDER: Providers = "groq"
QUERY_GENERATION_MODEL: str = "llama-3.1-8b-instant"
@ -215,6 +217,10 @@ class DialecticSettings(HonchoSettings):
THINKING_BUDGET_TOKENS: Annotated[int, Field(default=1024, gt=0, le=5000)] = 1024
CONTEXT_WINDOW_SIZE: Annotated[
int, Field(default=100_000, gt=10_000, le=200_000)
] = 100_000
class SummarySettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="SUMMARY_", extra="ignore") # pyright: ignore
@ -242,12 +248,15 @@ class AppSettings(HonchoSettings):
FASTAPI_PORT: Annotated[int, Field(default=8000, gt=0, le=65535)] = 8000
SESSION_PEERS_LIMIT: Annotated[int, Field(default=10, gt=0)] = 10
MAX_FILE_SIZE: Annotated[int, Field(default=5_242_880, gt=0)] = 5_242_880 # 5MB
GET_CONTEXT_DEFAULT_MAX_TOKENS: Annotated[int, Field(default=2048, gt=0)] = 2048
EMBED_MESSAGES: bool = True
MAX_EMBEDDING_TOKENS: Annotated[int, Field(default=8192, gt=0)] = 8192
MAX_EMBEDDING_TOKENS_PER_REQUEST: Annotated[int, Field(default=300000, gt=0)] = (
300000
)
LANGFUSE_HOST: str | None = None
LANGFUSE_PUBLIC_KEY: str | None = None
# Nested settings models
DB: DBSettings = Field(default_factory=DBSettings)

View File

@ -4,6 +4,7 @@ from .document import create_document, get_duplicate_documents, query_documents
from .message import (
create_messages,
get_message,
get_message_seq_in_session,
get_messages,
get_messages_id_range,
search,
@ -53,6 +54,7 @@ __all__ = [
"get_messages",
"get_messages_id_range",
"get_message",
"get_message_seq_in_session",
"update_message",
"search",
# Peer

View File

@ -25,14 +25,34 @@ async def query_documents(
filters: dict[str, Any] | None = None,
max_distance: float | None = None,
top_k: int = 5,
embedding: list[float] | None = None,
) -> Sequence[models.Document]:
# Using ModelClient for embeddings
try:
embedding_query = await embedding_client.embed(query)
except ValueError as e:
raise ValidationException(
f"Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}."
) from e
"""
Query documents using semantic similarity.
Args:
db: Database session
workspace_name: Name of the workspace
peer_name: Name of the peer
collection_name: Name of the collection
query: Search query text
filters: Optional filters to apply
max_distance: Maximum cosine distance for results
top_k: Number of results to return
embedding: Optional pre-computed embedding for the query (avoids API call)
Returns:
Sequence of matching documents
"""
# Use provided embedding or generate one
if embedding is None:
# Using ModelClient for embeddings
try:
embedding = await embedding_client.embed(query)
except ValueError as e:
raise ValidationException(
f"Query exceeds maximum token limit of {settings.MAX_EMBEDDING_TOKENS}."
) from e
stmt = (
select(models.Document)
@ -43,11 +63,11 @@ async def query_documents(
)
if max_distance is not None:
stmt = stmt.where(
models.Document.embedding.cosine_distance(embedding_query) < max_distance
models.Document.embedding.cosine_distance(embedding) < max_distance
)
stmt = apply_filter(stmt, models.Document, filters)
stmt = stmt.limit(top_k).order_by(
models.Document.embedding.cosine_distance(embedding_query)
models.Document.embedding.cosine_distance(embedding)
)
result = await db.execute(stmt)
return result.scalars().all()

View File

@ -38,10 +38,7 @@ async def create_messages(
peers = {message.peer_name: schemas.SessionPeerConfig() for message in messages}
await get_or_create_session(
db,
session=schemas.SessionCreate(
name=session_name,
peers=peers,
),
session=schemas.SessionCreate(name=session_name, peers=peers),
workspace_name=workspace_name,
)
@ -186,52 +183,73 @@ async def get_messages(
async def get_messages_id_range(
db: AsyncSession,
workspace_name: str,
session_name: str | None,
peer_name: str | None,
session_name: str,
start_id: int = 0,
end_id: int | None = None,
) -> list[models.Message]:
"""
Get messages from a session or peer by primary key ID range.
Get messages from a session by primary key ID range.
If end_id is not provided, all messages after and including start_id will be returned.
If start_id is not provided, start will be beginning of session.
Note: list is exclusive of the end_id message.
Note: list is *inclusive* of the end_id message.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_name: Name of the peer
start_id: Primary key ID of the first message to return
end_id: Primary key ID of the last message (exclusive)
Returns:
List of messages
Raises:
ValueError: If both session_name and peer_name are not provided
"""
if start_id < 0 or (end_id is not None and (start_id >= end_id or end_id <= 1)):
if start_id < 0 or (end_id is not None and (start_id >= end_id or end_id <= 0)):
return []
stmt = select(models.Message).where(
models.Message.workspace_name == workspace_name,
stmt = (
select(models.Message)
.where(
models.Message.workspace_name == workspace_name,
)
.where(models.Message.session_name == session_name)
)
if end_id:
stmt = stmt.where(models.Message.id.between(start_id, end_id - 1))
stmt = stmt.where(models.Message.id.between(start_id, end_id))
else:
stmt = stmt.where(models.Message.id >= start_id)
if session_name:
stmt = stmt.where(models.Message.session_name == session_name)
elif peer_name:
stmt = stmt.where(models.Message.peer_name == peer_name)
else:
raise ValueError("Either session_name or peer_name must be provided")
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_message_seq_in_session(
db: AsyncSession,
workspace_name: str,
session_name: str,
message_id: int,
) -> int:
"""
Get the sequence number of a message within a session.
Args:
db: Database session
session_name: Name of the session
message_id: Primary key ID of the message
Returns:
The sequence number of the message (1-indexed)
"""
stmt = (
select(func.count(models.Message.id))
.where(models.Message.workspace_name == workspace_name)
.where(models.Message.session_name == session_name)
.where(models.Message.id < message_id)
)
result = await db.execute(stmt)
count = result.scalar() or 0
return count + 1
async def get_message(
db: AsyncSession,
workspace_name: str,

View File

@ -2,7 +2,7 @@ from logging import getLogger
from typing import Any
from nanoid import generate as generate_nanoid
from sqlalchemy import Select, cast, func, insert, select, update
from sqlalchemy import Select, case, cast, func, insert, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import BigInteger
@ -116,9 +116,6 @@ async def get_or_create_session(
)
await db.commit()
logger.info(
f"Session {session.name} updated successfully in workspace {workspace_name} with {len(session.peer_names or [])} peers"
)
return honcho_session
@ -575,12 +572,17 @@ async def _get_or_add_peers_to_session(
)
# On conflict, update joined_at and clear left_at (rejoin scenario)
# If left_at is not None (peer has left the session): Use the new configuration (stmt.excluded.configuration)
# If left_at is None (peer is still active): Keep the existing configuration (models.SessionPeer.configuration)
stmt = stmt.on_conflict_do_update(
index_elements=["session_name", "peer_name", "workspace_name"],
set_={
"joined_at": func.now(),
"left_at": None,
"configuration": stmt.excluded.configuration,
"configuration": case(
(models.SessionPeer.left_at.is_not(None), stmt.excluded.configuration),
else_=models.SessionPeer.configuration,
),
},
)
await db.execute(stmt)

View File

@ -1,11 +1,54 @@
import asyncio
import logging
import os
import uvloop
from src.config import settings
from .queue_manager import main
def setup_logging():
"""
Configure logging for the deriver process.
"""
# Get log level from environment or settings
log_level_str = os.getenv("LOG_LEVEL", settings.LOG_LEVEL).upper()
log_levels = {
"CRITICAL": logging.CRITICAL, # 50
"ERROR": logging.ERROR, # 40
"WARNING": logging.WARNING, # 30
"INFO": logging.INFO, # 20
"DEBUG": logging.DEBUG, # 10
"NOTSET": logging.NOTSET, # 0
}
log_level = log_levels.get(log_level_str, logging.INFO)
# Configure logging
logging.basicConfig(
level=log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
# Disable SQLAlchemy engine logging unless explicitly enabled
if not settings.DB.SQL_DEBUG:
logging.getLogger("sqlalchemy.engine.Engine").disabled = True
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("openai._base_client").setLevel(logging.WARNING)
logging.getLogger("groq._base_client").setLevel(logging.WARNING)
if __name__ == "__main__":
print("[DERIVER] Starting deriver queue processor")
# Setup logging before starting the main loop
setup_logging()
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
try:
print("[DERIVER] Running main loop")

View File

@ -3,57 +3,37 @@ from typing import Any
from pydantic import ValidationError
from rich.console import Console
from sqlalchemy.ext.asyncio import AsyncSession
from src.utils.summarizer import summarize_if_needed
from .deriver import Deriver
from .queue_payload import DeriverQueuePayload
from .queue_payload import RepresentationPayload, SummaryPayload
logger = logging.getLogger(__name__)
logging.getLogger("sqlalchemy.engine.Engine").disabled = True
console = Console(markup=False)
console = Console(markup=True)
deriver = Deriver()
async def process_item(db: AsyncSession, payload: dict[str, Any]):
async def process_item(payload: dict[str, Any]) -> None:
# Validate payload structure and types before processing
try:
validated_payload = DeriverQueuePayload(**payload)
task_type = payload.get("task_type")
if task_type == "representation":
validated_payload = RepresentationPayload(**payload)
elif task_type == "summary":
validated_payload = SummaryPayload(**payload)
else:
raise ValueError(f"Invalid task_type: {task_type}")
except ValidationError as e:
logger.error("Invalid payload received: %s. Payload: %s", str(e), payload)
raise ValueError(f"Invalid payload structure: {str(e)}") from e
logger.debug(
"process_item received payload for message %s in session %s",
"process_item received payload for message %s in session %s, task type %s",
validated_payload.message_id,
validated_payload.session_name,
validated_payload.task_type,
)
if validated_payload.task_type == "representation":
logger.debug(
"Processing message %s in %s",
validated_payload.message_id,
validated_payload.session_name,
)
await deriver.process_message(validated_payload)
logger.debug(
"Finished processing message %s in %s %s",
validated_payload.message_id,
"session" if validated_payload.session_name else "peer",
(
validated_payload.session_name
if validated_payload.session_name
else validated_payload.sender_name
),
)
await summarize_if_needed(
db,
validated_payload.workspace_name,
validated_payload.session_name,
validated_payload.sender_name,
validated_payload.message_id,
)
return
await deriver.process_message(validated_payload)
logger.debug("Finished processing message %s", validated_payload.message_id)

View File

@ -1,14 +1,14 @@
import datetime
import logging
import os
import time
from typing import Any
from langfuse.decorators import langfuse_context, observe # pyright: ignore
from langfuse.decorators import langfuse_context
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud
from src.config import settings
from src.dependencies import tracked_db
from src.utils import summarizer
from src.utils.clients import honcho_llm_call
from src.utils.embedding_store import EmbeddingStore
@ -17,9 +17,17 @@ from src.utils.formatting import (
extract_observation_content,
find_new_observations,
format_context_for_prompt,
format_datetime_simple,
format_new_turn_with_timestamp,
)
from src.utils.logging import (
accumulate_metric,
conditional_observe,
format_reasoning_inputs_as_markdown,
format_reasoning_response_as_markdown,
log_observations_tree,
log_performance_metrics,
log_thinking_panel,
)
from src.utils.shared_models import (
DeductiveObservation,
ObservationContext,
@ -28,15 +36,8 @@ from src.utils.shared_models import (
UnifiedObservation,
)
from .logging import (
format_reasoning_inputs_as_markdown,
format_reasoning_response_as_markdown,
log_observations_tree,
log_performance_metrics,
log_thinking_panel,
)
from .prompts import critical_analysis_prompt
from .queue_payload import DeriverQueuePayload
from .queue_payload import DeriverQueuePayload, RepresentationPayload, SummaryPayload
logger = logging.getLogger(__name__)
logging.getLogger("sqlalchemy.engine.Engine").disabled = True
@ -71,206 +72,232 @@ async def critical_analysis_call(
)
@observe()
@conditional_observe
class Deriver:
"""Deriver class for processing messages and extracting insights."""
async def process_message(
self,
payload: DeriverQueuePayload,
) -> ReasoningResponseWithThinking:
) -> None:
"""
Process a user message by extracting insights and saving them to the vector store.
This runs as a background process after a user message is logged.
"""
langfuse_context.update_current_trace(
metadata={
"critical_analysis_model": settings.DERIVER.MODEL,
}
if settings.LANGFUSE_PUBLIC_KEY:
langfuse_context.update_current_trace(
metadata={
"critical_analysis_model": settings.DERIVER.MODEL,
}
)
# Open a DB session only for the duration of the processing call
async with tracked_db("deriver") as db:
if payload.task_type == "summary":
await self.process_summary_task(db, payload)
else:
await self.process_representation_task(db, payload)
async def process_summary_task(
self,
db: AsyncSession,
payload: SummaryPayload,
) -> None:
"""
Process a summary task by generating summaries if needed.
"""
await summarizer.summarize_if_needed(
db,
payload.workspace_name,
payload.session_name,
payload.message_id,
payload.message_seq_in_session,
)
log_performance_metrics(f"deriver_message_{payload.message_id}")
async def process_representation_task(
self,
db: AsyncSession,
payload: RepresentationPayload,
) -> None:
"""
Process a representation task by extracting insights and updating working representations.
"""
# Start overall timing
overall_start = time.perf_counter()
# Extract variables from payload for cleaner access
content = payload.content
workspace_name = payload.workspace_name
sender_name = payload.sender_name
target_name = payload.target_name
session_name = payload.session_name
message_id = payload.message_id
sender_name = payload.sender_name
target_name = payload.target_name
created_at = payload.created_at
# Open a DB session only for the duration of the processing call
from src.dependencies import tracked_db
logger.debug("Starting insight extraction for user message: %s", message_id)
async with tracked_db("deriver") as db:
logger.debug("Processing user message: %s", content)
process_start = os.times()[4] # Get current CPU time
logger.debug("Starting insight extraction for user message: %s", message_id)
# Use message timestamp instead of wall-clock time for reasoning/insight dating
# created_at is now always a datetime object from Pydantic validation
message_dt_obj = created_at
# Use message timestamp instead of wall-clock time for reasoning/insight dating
# created_at is now always a datetime object from Pydantic validation
current_time = format_datetime_simple(created_at)
message_dt_obj = created_at
logger.info(
f"Using message timestamp '{current_time}' for message {message_id}"
formatted_history = await summarizer.get_summarized_history(
db,
workspace_name,
session_name,
cutoff=message_id,
summary_type=summarizer.SummaryType.SHORT,
)
# instantiate embedding store from collection
collection_name = (
crud.construct_collection_name(observer=target_name, observed=sender_name)
if sender_name != target_name
else "global_representation"
)
try:
collection = await crud.get_or_create_collection(
db, workspace_name, collection_name, sender_name
)
# Create summary if needed BEFORE history retrieval to ensure consistent state
await summarizer.summarize_if_needed(
db, workspace_name, session_name, sender_name, message_id
)
# Instead of the complex 3-return tuple approach, use the simple formatted text approach
if session_name:
formatted_history = await summarizer.get_summarized_history(
db,
workspace_name,
session_name,
sender_name,
cutoff=message_id,
summary_type=summarizer.SummaryType.SHORT,
)
else:
formatted_history = ""
# Debug: Check if we just created a summary and messages are missing
logger.info(f"History retrieved: {len(formatted_history)} characters")
# instantiate embedding store from collection
collection_name = (
crud.construct_collection_name(
observer=target_name, observed=sender_name
)
if sender_name != target_name
else "global_representation"
)
try:
collection = await crud.get_or_create_collection(
except Exception as e:
# Handle race condition from concurrent processing
if "duplicate key" in str(e).lower():
# Rollback the failed transaction
await db.rollback()
# Collection already exists, fetch it
collection = await crud.get_collection(
db, workspace_name, collection_name, sender_name
)
except Exception as e:
# Handle race condition from concurrent processing
if "duplicate key" in str(e).lower():
# Rollback the failed transaction
await db.rollback()
# Collection already exists, fetch it
collection = await crud.get_collection(
db, workspace_name, collection_name, sender_name
)
else:
raise
# Use the ed embedding store directly
embedding_store = EmbeddingStore(
workspace_name=workspace_name,
peer_name=sender_name,
collection_name=collection.name,
)
# Create reasoner instance
reasoner = CertaintyReasoner(embedding_store=embedding_store)
# Check for existing working representation first, fall back to global search
working_rep_data: (
dict[str, Any] | str | None
) = await crud.get_working_representation_data(
db, workspace_name, target_name, sender_name, session_name
)
if (
working_rep_data
and isinstance(working_rep_data, dict)
and working_rep_data.get("final_observations")
):
# Reconstruct ReasoningResponse from stored peer data
final_obs: dict[str, Any] = working_rep_data["final_observations"]
deductive_observations: list[DeductiveObservation] = []
for deductive_data in final_obs.get("deductive", []):
deductive_observations.append(
DeductiveObservation(
conclusion=deductive_data["conclusion"],
premises=deductive_data.get("premises", []),
)
)
initial_reasoning_context = ReasoningResponseWithThinking(
thinking=final_obs.get("thinking"),
explicit=final_obs.get("explicit", []),
deductive=deductive_observations,
)
logger.info(
f"Using existing working representation with {len(initial_reasoning_context.explicit)} explicit, {len(initial_reasoning_context.deductive)} deductive observations"
)
else:
# No working representation, use global search
initial_context = await embedding_store.get_relevant_observations(
query=content,
conversation_context=formatted_history,
for_reasoning=True,
)
initial_reasoning_context = (
reasoner.observation_context_to_reasoning_response(initial_context)
)
logger.info(
"No working representation found, using global semantic search"
raise
# Use the embedding store directly
embedding_store = EmbeddingStore(
workspace_name=workspace_name,
peer_name=sender_name,
collection_name=collection.name,
)
# Create reasoner instance
reasoner = CertaintyReasoner(embedding_store=embedding_store)
# Check for existing working representation first, fall back to global search
working_rep_data: (
dict[str, Any] | str | None
) = await crud.get_working_representation_data(
db, workspace_name, target_name, sender_name, session_name
)
# Time context preparation
context_prep_start = time.perf_counter()
if (
working_rep_data
and isinstance(working_rep_data, dict)
and working_rep_data.get("final_observations")
):
# Reconstruct ReasoningResponse from stored peer data
final_obs: dict[str, Any] = working_rep_data["final_observations"]
deductive_observations: list[DeductiveObservation] = []
for deductive_data in final_obs.get("deductive", []):
deductive_observations.append(
DeductiveObservation(
conclusion=deductive_data["conclusion"],
premises=deductive_data.get("premises", []),
)
)
# Run consolidated reasoning that handles explicit and deductive levels
logger.debug(
"REASONING: Running unified insight derivation across explicit and deductive reasoning levels"
initial_reasoning_context = ReasoningResponseWithThinking(
thinking=final_obs.get("thinking"),
explicit=final_obs.get("explicit", []),
deductive=deductive_observations,
)
logger.info(
"Using existing working representation with %s explicit, %s deductive observations",
len(initial_reasoning_context.explicit),
len(initial_reasoning_context.deductive),
)
else:
# No working representation, use global search
initial_context = await embedding_store.get_relevant_observations(
query=content,
conversation_context=formatted_history,
for_reasoning=True,
)
# Run single-pass reasoning
final_observations = await reasoner.reason(
initial_reasoning_context,
formatted_history,
content,
str(message_id), # Convert int to str
session_name,
message_dt_obj,
sender_name, # Pass the speaker name
initial_reasoning_context = (
reasoner.observation_context_to_reasoning_response(initial_context)
)
logger.info("No working representation found, using global semantic search")
context_prep_duration = (time.perf_counter() - context_prep_start) * 1000
accumulate_metric(
f"deriver_message_{message_id}",
"context_preparation",
context_prep_duration,
"ms",
)
logger.debug(
"REASONING COMPLETION: Unified reasoning completed across all levels."
)
# Run consolidated reasoning that handles explicit and deductive levels
logger.debug(
"REASONING: Running unified insight derivation across explicit and deductive reasoning levels"
)
# Display final observations in a beautiful tree
final_obs_dict = {
level: getattr(final_observations, level, [])
for level in REASONING_LEVELS
}
log_observations_tree(final_obs_dict)
# Run single-pass reasoning
final_observations = await reasoner.reason(
initial_reasoning_context,
formatted_history,
content,
str(message_id), # Convert int to str
session_name,
message_dt_obj,
sender_name, # Pass the speaker name
)
# Display final reasoning metrics
rsr_time = os.times()[4] - process_start
total_observations = sum(
len(obs_list) for obs_list in final_obs_dict.values()
)
summary_metrics = {
"total_processing_time": rsr_time * 1000, # Convert to ms
"final_observation_count": total_observations,
}
log_performance_metrics(summary_metrics)
logger.debug(
"REASONING COMPLETION: Unified reasoning completed across all levels."
)
# Display final observations in a beautiful tree
final_obs_dict = {
level: getattr(final_observations, level, []) for level in REASONING_LEVELS
}
log_observations_tree(final_obs_dict)
# Always save working representation to peer for dialectic access
await save_working_representation_to_peer(
db,
workspace_name,
target_name, # observer (whose metadata we update)
sender_name, # observed (for key calculation)
session_name,
final_observations,
message_id,
)
# Calculate and log overall timing
overall_duration = (time.perf_counter() - overall_start) * 1000
accumulate_metric(
f"deriver_message_{message_id}",
"total_processing_time",
overall_duration,
"ms",
)
total_observations = sum(len(obs_list) for obs_list in final_obs_dict.values())
accumulate_metric(
f"deriver_message_{message_id}",
"final_observation_count",
total_observations,
"",
)
log_performance_metrics(f"deriver_message_{message_id}")
if settings.LANGFUSE_PUBLIC_KEY:
langfuse_context.update_current_trace(
output=format_reasoning_response_as_markdown(final_observations)
)
# Always save working representation to peer for dialectic access
await save_working_representation_to_peer(
db,
workspace_name,
target_name, # observer (whose metadata we update)
sender_name, # observed (for key calculation)
session_name,
final_observations,
message_id,
)
# Return the structured observations so callers can capture them directly
return final_observations
class CertaintyReasoner:
"""Certainty reasoner for analyzing and deriving insights."""
@ -306,29 +333,31 @@ class CertaintyReasoner:
deductive=deductive,
)
@observe()
@conditional_observe
async def derive_new_insights(
self,
context: ReasoningResponseWithThinking,
history: str,
new_turn: str,
message_created_at: datetime.datetime,
speaker: str = "user",
speaker: str,
) -> ReasoningResponseWithThinking:
"""
Critically analyzes and revises understanding, returning structured observations.
"""
langfuse_context.update_current_observation(
input=format_reasoning_inputs_as_markdown(
context, history, new_turn, message_created_at
if settings.LANGFUSE_PUBLIC_KEY:
langfuse_context.update_current_observation(
input=format_reasoning_inputs_as_markdown(
context, history, new_turn, message_created_at
)
)
)
formatted_new_turn = format_new_turn_with_timestamp(
new_turn, message_created_at, speaker
)
formatted_context = format_context_for_prompt(context)
logger.debug(
"CRITICAL ANALYSIS: message_created_at='%s', formatted_new_turn='%s'",
message_created_at,
@ -381,24 +410,26 @@ class CertaintyReasoner:
logger.warning(f"Error accessing thinking content: {e}, setting to None")
thinking = None
logger.debug(
"🚀 DEBUG: new_insights=%s, thinking_length=%s",
new_insights,
len(thinking) if thinking else 0,
)
response = ReasoningResponseWithThinking(
thinking=thinking,
explicit=new_insights.explicit,
deductive=new_insights.deductive,
)
langfuse_context.update_current_observation(
output=format_reasoning_response_as_markdown(response),
logger.debug(
"🚀 DEBUG: new_insights=%s, thinking_length=%s",
new_insights,
len(thinking) if thinking else 0,
)
if settings.LANGFUSE_PUBLIC_KEY:
langfuse_context.update_current_observation(
output=format_reasoning_response_as_markdown(response),
)
return response
@observe()
@conditional_observe
async def reason(
self,
context: ReasoningResponseWithThinking,
@ -416,7 +447,7 @@ class CertaintyReasoner:
if message_created_at is None:
message_created_at = datetime.datetime.now(datetime.timezone.utc)
analysis_start = time.time()
analysis_start = time.perf_counter()
# Perform critical analysis to get observation lists
reasoning_response = await self.derive_new_insights(
@ -426,10 +457,15 @@ class CertaintyReasoner:
# Output the thinking content for this analysis
log_thinking_panel(reasoning_response.thinking)
# Compare input context with output to detect changes
# Calculate analysis duration
analysis_duration_ms = int((time.time() - analysis_start) * 1000)
analysis_duration_ms = (time.perf_counter() - analysis_start) * 1000
accumulate_metric(
f"deriver_message_{message_id}",
"critical_analysis_duration",
analysis_duration_ms,
"ms",
)
save_observations_start = time.perf_counter()
# Save only the NEW observations that weren't in the original context
await self._save_new_observations(
context,
@ -438,22 +474,19 @@ class CertaintyReasoner:
session_name,
message_created_at,
)
# Display observations in a tree structure and performance metrics
observations = {
level: getattr(reasoning_response, level, []) for level in REASONING_LEVELS
}
log_observations_tree(observations)
# Log performance metrics for this analysis
metrics = {
"analysis_duration": analysis_duration_ms,
}
log_performance_metrics(metrics, "⚡ REASONING METRICS")
save_observations_duration = (
time.perf_counter() - save_observations_start
) * 1000
accumulate_metric(
f"deriver_message_{message_id}",
"save_new_observations",
save_observations_duration,
"ms",
)
return reasoning_response
@observe()
@conditional_observe
async def _save_new_observations(
self,
original_context: ReasoningResponse,
@ -463,9 +496,6 @@ class CertaintyReasoner:
message_created_at: datetime.datetime | None = None,
) -> None:
"""Save only the observations that are new compared to the original context."""
if not self.embedding_store:
return
# Use the utility function to find new observations
new_observations_by_level = find_new_observations(
original_context, revised_observations
@ -521,11 +551,6 @@ class CertaintyReasoner:
logger.debug("No new observations to save")
return
# Make a single save call for all observations
logger.info(
f"🚀 Making single optimized call for {total_observations_count} observations"
)
await self.embedding_store.save_unified_observations(
all_unified_observations,
message_id=message_id,
@ -533,10 +558,6 @@ class CertaintyReasoner:
message_created_at=message_created_at,
)
logger.info(
f"✅ Successfully saved {total_observations_count} observations in 1 optimized call"
)
async def save_working_representation_to_peer(
db: AsyncSession,

View File

@ -10,7 +10,7 @@ from src.dependencies import tracked_db
from src.exceptions import ValidationException
from src.models import QueueItem
from .queue_payload import DeriverQueuePayload
from .queue_payload import create_payload
logger = logging.getLogger(__name__)
@ -43,14 +43,9 @@ async def enqueue(payload: list[dict[str, Any]]) -> None:
stmt = insert(QueueItem).returning(QueueItem)
await db_session.execute(stmt, queue_records)
await db_session.commit()
logger.info(
"Successfully enqueued %d messages with %d total queue items",
len(payload),
len(queue_records),
)
except Exception as e:
logger.exception("Failed to enqueue messages!")
logger.exception("Failed to enqueue message(s)!")
if settings.SENTRY.ENABLED:
import sentry_sdk
@ -91,7 +86,8 @@ async def handle_session(
for message in payload:
queue_records.extend(
process_message(
await generate_queue_records(
db_session,
message,
peers_with_configuration,
session.id,
@ -145,7 +141,7 @@ def create_representation_record(
Returns:
Queue record dictionary
"""
processed_payload = DeriverQueuePayload.create_payload(
processed_payload = create_payload(
message=message,
sender_name=sender_name,
target_name=target_name,
@ -158,7 +154,9 @@ def create_representation_record(
def create_summary_record(
message: dict[str, Any], sender_name: str, target_name: str, session_id: str
message: dict[str, Any],
session_id: str,
message_seq_in_session: int,
) -> dict[str, Any]:
"""
Create a queue record for summary task.
@ -172,11 +170,10 @@ def create_summary_record(
Returns:
Queue record dictionary
"""
processed_payload = DeriverQueuePayload.create_payload(
processed_payload = create_payload(
message=message,
sender_name=sender_name,
target_name=target_name,
task_type="summary",
message_seq_in_session=message_seq_in_session,
)
return {
"payload": processed_payload,
@ -215,7 +212,8 @@ def get_effective_observe_me(
return sender_peer_config.observe_me
def process_message(
async def generate_queue_records(
db_session: AsyncSession,
message: dict[str, Any],
peers_with_configuration: dict[str, list[dict[str, Any]]],
session_id: str,
@ -226,6 +224,7 @@ def process_message(
Process a single message and generate queue records based on configurations.
Args:
db_session: The database session
message: The message payload
deriver_disabled: Whether deriver is disabled for the session
peers_with_configuration: Dictionary of peer configurations
@ -235,52 +234,75 @@ def process_message(
List of queue records for this message
"""
sender_name = message["peer_name"]
message_id: int = message["message_id"]
message_seq_in_session: int = await crud.get_message_seq_in_session(
db_session,
workspace_name=message["workspace_name"],
session_name=message["session_name"],
message_id=message_id,
)
records: list[dict[str, Any]] = []
if (
message_seq_in_session % settings.SUMMARY.MESSAGES_PER_SHORT_SUMMARY == 0
or message_seq_in_session % settings.SUMMARY.MESSAGES_PER_LONG_SUMMARY == 0
):
records.append(
create_summary_record(
message,
session_id=session_id,
message_seq_in_session=message_seq_in_session,
)
)
if deriver_disabled:
return [
create_summary_record(
return records
if get_effective_observe_me(sender_name, peers_with_configuration):
# global representation task
records.append(
create_representation_record(
message,
sender_name=sender_name,
target_name=sender_name,
session_id=session_id,
)
]
if not get_effective_observe_me(sender_name, peers_with_configuration):
return []
records: list[dict[str, Any]] = [
create_representation_record(
message,
sender_name=sender_name,
target_name=sender_name,
session_id=session_id,
)
]
for peer_name, configuration in peers_with_configuration.items():
if peer_name == sender_name:
continue
session_peer_config = (
schemas.SessionPeerConfig(**configuration[1]) if configuration[1] else None
)
if session_peer_config is None or not session_peer_config.observe_others:
continue
for peer_name, configuration in peers_with_configuration.items():
if peer_name == sender_name:
continue
records.append(
create_representation_record(
message,
sender_name=sender_name,
target_name=peer_name,
session_id=session_id,
session_peer_config = (
schemas.SessionPeerConfig(**configuration[1])
if configuration[1]
else None
)
)
logger.debug(
"enqueued representation task for %s's representation of %s",
peer_name,
sender_name,
)
if session_peer_config is None or not session_peer_config.observe_others:
continue
records.append(
# peer representation task
create_representation_record(
message,
sender_name=sender_name,
target_name=peer_name,
session_id=session_id,
)
)
logger.debug(
"enqueued representation task for %s's representation of %s",
peer_name,
sender_name,
)
logger.info(
"message %s from %s created %s queue items",
message_id,
sender_name,
len(records),
)
return records

View File

@ -1,255 +0,0 @@
"""
Rich-powered logging utilities for beautiful console output.
"""
import datetime
from collections.abc import Mapping, Sequence
from typing import Any, Protocol
from rich.panel import Panel
from rich.table import Table
from rich.tree import Tree
from src.utils.logging import console
from src.utils.shared_models import ObservationDict, ReasoningResponseWithThinking
class ObservationWithContent(Protocol):
"""Protocol for objects with content attribute."""
content: str
class ObservationWithConclusion(Protocol):
"""Protocol for objects with conclusion and optional premises."""
conclusion: str
premises: Sequence[str] | None
# Union type for all possible observation types
ObservationType = (
str
| ObservationDict
| ObservationWithContent
| ObservationWithConclusion
| dict[str, Any]
)
def format_reasoning_response_as_markdown(
response: ReasoningResponseWithThinking | None,
) -> str:
"""
Format a ReasoningResponse object as markdown.
Args:
response: ReasoningResponse object or similar structure
Returns:
Formatted markdown string
"""
if not response:
return "No reasoning response available"
parts: list[str] = []
# Add thinking section if available
if hasattr(response, "thinking") and response.thinking:
parts.append("## Thinking\n")
parts.append(response.thinking.strip())
parts.append("")
# Add explicit observations
if hasattr(response, "explicit") and response.explicit:
parts.append("## Explicit Observations\n")
for i, obs in enumerate(response.explicit, 1):
parts.append(f"{i}. {obs}")
parts.append("")
# Add deductive observations
if hasattr(response, "deductive") and response.deductive:
parts.append("## Deductive Observations\n")
for i, obs in enumerate(response.deductive, 1):
if hasattr(obs, "conclusion"):
parts.append(f"{i}. **Conclusion**: {obs.conclusion}")
if hasattr(obs, "premises") and obs.premises:
parts.append(" **Premises**:")
for premise in obs.premises:
parts.append(f" - {premise}")
parts.append("")
else:
parts.append(f"{i}. {obs}")
parts.append("")
return "\n".join(parts)
def format_reasoning_inputs_as_markdown(
context: ReasoningResponseWithThinking | None,
history: str,
new_turn: str,
message_created_at: datetime.datetime,
) -> str:
"""
Format reasoning inputs as markdown for logging.
Args:
context: Current context/observations
history: Conversation history
new_turn: New user message
message_created_at: Message timestamp
Returns:
Formatted markdown string
"""
parts: list[str] = []
parts.append("## Reasoning Inputs\n")
parts.append(
f"**Current Time**: {message_created_at.strftime('%Y-%m-%d %H:%M:%S')}"
)
parts.append("")
# Add context if available
if context:
parts.append("### Current Context\n")
if hasattr(context, "explicit") and context.explicit:
parts.append("**Explicit Observations**:")
for obs in context.explicit:
parts.append(f"- {obs}")
parts.append("")
if hasattr(context, "deductive") and context.deductive:
parts.append("**Deductive Observations**:")
for obs in context.deductive:
if hasattr(obs, "conclusion"):
parts.append(f"- {obs.conclusion}")
else:
parts.append(f"- {obs}")
parts.append("")
# Add history
if history:
parts.append("### Conversation History\n")
parts.append(history.strip())
parts.append("")
# Add new turn
if new_turn:
parts.append("### New Turn\n")
parts.append(new_turn.strip())
parts.append("")
return "\n".join(parts)
def log_thinking_panel(
thinking: str | None,
) -> None:
"""
Log thinking content in a beautiful panel.
Args:
thinking: Thinking content to display (can be None)
"""
if not thinking:
console.print("[dim]No thinking content available[/]")
return
panel = Panel(
thinking.strip(),
title="🧠 THINKING",
title_align="left",
border_style="blue",
padding=(1, 2),
)
# Use console.print for immediate output only
console.print(panel)
def log_observations_tree(
observations: dict[str, list[Any]],
) -> None:
"""
Log observations in a tree structure.
Args:
observations: Dictionary of observation types and their lists
"""
tree = Tree("📊 OBSERVATIONS")
for obs_type, obs_list in observations.items():
if obs_list:
type_branch = tree.add(
f"[bold cyan]{obs_type.title()}[/] ({len(obs_list)})"
)
for i, obs in enumerate(obs_list): # Show all observations
content = _extract_observation_text(obs)
truncated = content[:120] + "..." if len(content) > 120 else content
type_branch.add(f"[dim]{i + 1}.[/] {truncated}")
console.print(tree)
def log_performance_metrics(
metrics: Mapping[str, str | int | float],
title: str = "⚡ PERFORMANCE",
) -> None:
"""
Log performance metrics in a clean table.
Args:
metrics: Dictionary of metric names and values
title: Table title
"""
table = Table(title=title, show_header=True, header_style="bold green")
table.add_column("Metric", style="cyan")
table.add_column("Value", justify="right", style="yellow")
table.add_column("Unit", style="dim")
for metric, value in metrics.items():
if isinstance(value, float):
if "duration" in metric.lower() or "time" in metric.lower():
formatted_value = f"{value:.2f}"
unit = "ms" if value < 1000 else "s"
elif "score" in metric.lower() or "percentage" in metric.lower():
formatted_value = f"{value:.1%}"
unit = ""
else:
formatted_value = f"{value:.3f}"
unit = ""
else:
formatted_value = str(value)
unit = ""
table.add_row(metric.replace("_", " ").title(), formatted_value, unit)
console.print(table)
def _extract_observation_text(obs: ObservationType) -> str:
"""Extract text content from various observation types, including premises."""
if isinstance(obs, str):
return obs
elif isinstance(obs, dict):
# Handle dict-based structured observations first
if "conclusion" in obs:
conclusion: str = str(obs["conclusion"])
premises: list[Any] = list(obs.get("premises", []))
if premises:
premises_text = "\n" + "\n".join(f" - {str(p)}" for p in premises)
return f"{conclusion}{premises_text}"
return conclusion
return str(obs.get("content", obs))
else:
# Handle object-based observations
# Use Any type for this branch since we're doing dynamic attribute checking
obj: Any = obs
if hasattr(obj, "conclusion"):
conclusion = str(obj.conclusion)
if hasattr(obj, "premises") and obj.premises:
premises_text = "\n" + "\n".join(
f" - {str(p)}" for p in obj.premises
)
return f"{conclusion}{premises_text}"
return conclusion
elif hasattr(obj, "content"):
return str(obj.content)
else:
return str(obj)

View File

@ -33,11 +33,14 @@ class WorkUnit:
A work unit is uniquely identified by the combination of session_id,
sender_name, target_name, and task_type. This allows multiple workers
to process different work units from the same session in parallel.
For summary tasks, sender_name and target_name are None since summary
tasks don't have these fields and should be processed sequentially per session.
"""
session_id: str
sender_name: str
target_name: str
sender_name: str | None
target_name: str | None
task_type: str
def __str__(self) -> str:
@ -174,8 +177,20 @@ class QueueManager:
.outerjoin(
models.ActiveQueueSession,
(models.QueueItem.session_id == models.ActiveQueueSession.session_id)
& (sender_name_expr == models.ActiveQueueSession.sender_name)
& (target_name_expr == models.ActiveQueueSession.target_name)
& (
(sender_name_expr == models.ActiveQueueSession.sender_name)
| (
sender_name_expr.is_(None)
& models.ActiveQueueSession.sender_name.is_(None)
)
)
& (
(target_name_expr == models.ActiveQueueSession.target_name)
| (
target_name_expr.is_(None)
& models.ActiveQueueSession.target_name.is_(None)
)
)
& (task_type_expr == models.ActiveQueueSession.task_type),
)
.where(~models.QueueItem.processed)
@ -293,14 +308,11 @@ class QueueManager:
break
message_count += 1
logger.debug(
f"Processing message {message.id} for work unit {work_unit} (message {message_count})"
)
try:
logger.info(
f"Processing message {message.id} from work unit {work_unit}"
f"Processing message {message.payload['message_id']} from work unit {work_unit}"
)
await process_item(db, payload=message.payload)
await process_item(message.payload)
logger.debug(f"Successfully processed message {message.id}")
except Exception as e:
logger.error(
@ -357,21 +369,26 @@ class QueueManager:
@sentry_sdk.trace
async def get_next_message(self, db: AsyncSession, work_unit: WorkUnit):
"""Get the next unprocessed message for a specific work unit"""
result = await db.execute(
query = (
select(models.QueueItem)
.where(models.QueueItem.session_id == work_unit.session_id)
.where(
models.QueueItem.payload["sender_name"].astext == work_unit.sender_name
)
.where(
models.QueueItem.payload["target_name"].astext == work_unit.target_name
)
.where(models.QueueItem.payload["task_type"].astext == work_unit.task_type)
.where(~models.QueueItem.processed)
.order_by(models.QueueItem.id)
.with_for_update(skip_locked=True)
.limit(1)
)
# For summary tasks, sender_name and target_name don't exist in payload
# For other tasks, filter by sender_name and target_name
if work_unit.task_type != "summary":
query = query.where(
models.QueueItem.payload["sender_name"].astext == work_unit.sender_name
).where(
models.QueueItem.payload["target_name"].astext == work_unit.target_name
)
result = await db.execute(query)
return result.scalar_one_or_none()

View File

@ -4,76 +4,92 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
class DeriverQueuePayload(BaseModel):
"""
Schema for validating queue payload data.
class BasePayload(BaseModel):
"""Base payload with common fields."""
sender_name: the peer who sent the message
target_name: the peer who is observing the message -- if this is the same as the sender,
this is a global ("honcho-level") representation task
"""
content: str
workspace_name: str
sender_name: str
target_name: str
session_name: str | None
session_name: str
message_id: int
created_at: datetime
task_type: Literal["representation", "summary"]
model_config = ConfigDict(extra="forbid") # pyright: ignore
@classmethod
def create_payload(
cls,
message: dict[str, Any],
sender_name: str,
target_name: str,
task_type: Literal["representation", "summary"],
) -> dict[str, Any]:
"""
Create a processed payload from a message for queue processing.
Args:
message: The original message dictionary
sender_name: Name of the message sender
target_name: Name of the observer peer
task_type: Type of task ('representation' or 'summary')
class RepresentationPayload(BasePayload):
"""Payload for representation tasks."""
Returns:
Processed payload dictionary ready for queue processing
task_type: Literal["representation"] = "representation"
content: str
sender_name: str
target_name: str
created_at: datetime
Raises:
ValueError: If the payload doesn't match the expected schema
"""
# Validate required fields and types
if not isinstance(message.get("content"), str):
raise TypeError("Message content must be a string")
if not isinstance(message.get("workspace_name"), str):
raise TypeError("Workspace name must be a string")
class SummaryPayload(BasePayload):
"""Payload for summary tasks."""
# Ensure message_id is an integer
message_id = message.get("message_id")
if not isinstance(message_id, int):
raise TypeError("Message ID must be an integer")
task_type: Literal["summary"] = "summary"
message_seq_in_session: int
# Ensure created_at exists and is a datetime
if "created_at" not in message:
raise TypeError("created_at is required")
if not isinstance(message["created_at"], datetime):
raise TypeError("created_at must be a datetime object")
# Create the processed payload with properly typed fields
content: str = message["content"]
workspace_name: str = message["workspace_name"]
session_name: str | None = message.get("session_name")
created_at: datetime = message["created_at"]
# Union type for the actual payload
DeriverQueuePayload = RepresentationPayload | SummaryPayload
# Create and validate the payload using the schema
try:
validated_payload = DeriverQueuePayload(
def create_payload(
message: dict[str, Any],
task_type: Literal["representation", "summary"],
sender_name: str | None = None,
target_name: str | None = None,
message_seq_in_session: int | None = None,
) -> dict[str, Any]:
"""
Create a processed payload from a message for queue processing.
Args:
message: The original message dictionary
task_type: Type of task ('representation' or 'summary')
sender_name: Name of the message sender (required for representation tasks)
target_name: Name of the observer peer (required for representation tasks)
message_seq_in_session: Required for summary tasks, must be None for representation
Returns:
Processed payload dictionary ready for queue processing
Raises:
ValueError: If the payload doesn't match the expected schema
"""
workspace_name = message.get("workspace_name")
session_name = message.get("session_name")
message_id = message.get("message_id")
if not isinstance(workspace_name, str):
raise TypeError("Workspace name must be a string")
if not isinstance(session_name, str):
raise TypeError("Session name must be a string")
if not isinstance(message_id, int):
raise TypeError("Message ID must be an integer")
# Create the appropriate payload type based on task_type
try:
if task_type == "representation":
content = message.get("content")
created_at = message.get("created_at")
if not isinstance(content, str):
raise TypeError("Message content must be a string")
if not isinstance(created_at, datetime):
raise TypeError("created_at must be a datetime object")
if sender_name is None:
raise ValueError("sender_name is required for representation tasks")
if target_name is None:
raise ValueError("target_name is required for representation tasks")
validated_payload = RepresentationPayload(
content=content,
workspace_name=workspace_name,
sender_name=sender_name,
@ -81,12 +97,22 @@ class DeriverQueuePayload(BaseModel):
session_name=session_name,
message_id=message_id,
created_at=created_at,
task_type=task_type,
)
# Convert back to dict for compatibility with JSON serialization
# mode='json' ensures datetime is converted to ISO string
payload = validated_payload.model_dump(mode="json")
except Exception as e:
raise ValueError(f"Failed to create valid payload: {str(e)}") from e
elif task_type == "summary":
if message_seq_in_session is None:
raise ValueError("message_seq_in_session is required for summary tasks")
return payload
validated_payload = SummaryPayload(
workspace_name=workspace_name,
session_name=session_name,
message_id=message_id,
message_seq_in_session=message_seq_in_session,
)
# Convert back to dict for compatibility with JSON serialization
# mode='json' ensures datetime is converted to ISO string
payload = validated_payload.model_dump(mode="json")
except Exception as e:
raise ValueError(f"Failed to create valid payload: {str(e)}") from e
return payload

View File

@ -8,7 +8,9 @@ historical observations.
import asyncio
import logging
import uuid
import tiktoken
from dotenv import load_dotenv
from langfuse.decorators import langfuse_context
from mirascope.llm import Stream
@ -16,8 +18,13 @@ from mirascope.llm import Stream
from src import crud
from src.config import settings
from src.dependencies import tracked_db
from src.routers.sessions import get_session_context
from src.utils.clients import honcho_llm_call
from src.utils.embedding_store import EmbeddingStore
from src.utils.logging import (
accumulate_metric,
log_performance_metrics,
)
from .prompts import dialectic_prompt
from .utils import get_observations
@ -42,9 +49,11 @@ load_dotenv()
)
async def dialectic_call(
query: str,
working_representation: str,
working_representation: str | None,
recent_conversation_history: str | None,
additional_context: str | None,
peer_name: str,
target_name: str | None = None,
):
"""
Make a direct call to the dialectic model for context synthesis.
@ -60,7 +69,12 @@ async def dialectic_call(
"""
# Generate the prompt and log it
prompt_result = dialectic_prompt(
query, working_representation, additional_context, peer_name
query,
working_representation,
recent_conversation_history,
additional_context,
peer_name,
target_name,
)
# Pretty print the prompt content
@ -91,9 +105,11 @@ async def dialectic_call(
)
async def dialectic_stream(
query: str,
working_representation: str,
working_representation: str | None,
recent_conversation_history: str | None,
additional_context: str | None,
peer_name: str,
target_name: str | None = None,
):
"""
Make a streaming call to the dialectic model for context synthesis.
@ -109,7 +125,12 @@ async def dialectic_stream(
"""
# Generate the prompt and log it
prompt_result = dialectic_prompt(
query, working_representation, additional_context, peer_name
query,
working_representation,
recent_conversation_history,
additional_context,
peer_name,
target_name,
)
# Pretty print the prompt content
@ -156,91 +177,160 @@ async def chat(
Dialectic response (streaming or complete)
"""
langfuse_context.update_current_trace(
metadata={
"query_generation_model": settings.DIALECTIC.QUERY_GENERATION_MODEL,
"query_generation_provider": settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
"dialectic_model": settings.DIALECTIC.MODEL,
}
dialectic_chat_uuid = str(uuid.uuid4())
tokenizer = tiktoken.get_encoding("cl100k_base")
context_window_size = (
settings.DIALECTIC.CONTEXT_WINDOW_SIZE - 750
) # this is a hardcoded (accurate, slightly conservative) estimate of system prompt
context_window_size -= len(tokenizer.encode(query))
if settings.LANGFUSE_PUBLIC_KEY:
langfuse_context.update_current_trace(
metadata={
"query_generation_model": settings.DIALECTIC.QUERY_GENERATION_MODEL,
"query_generation_provider": settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
"dialectic_model": settings.DIALECTIC.MODEL,
}
)
logger.info(
"Received query:\n'%s'\nobserver: %s%s%s\n",
query,
peer_name,
f", target: {target_name}" if target_name else "",
f", session: {session_name}" if session_name else "",
)
logger.debug(f"Received query: {query} for session {session_name}")
start_time = asyncio.get_event_loop().time()
# 1. Working representation (short-term) -----------------------------------
# Only useful for session-scoped queries, not global queries
if session_name:
working_rep_start_time = asyncio.get_event_loop().time()
async with tracked_db("chat.get_working_representation") as db:
# If no target specified, get global representation (peer observing themselves)
target_peer = target_name if target_name is not None else peer_name
working_representation = await crud.get_working_representation(
db, workspace_name, peer_name, target_peer, session_name
)
working_rep_duration = asyncio.get_event_loop().time() - working_rep_start_time
accumulate_metric(
f"dialectic_chat_{dialectic_chat_uuid}",
"retrieve_working_rep",
working_rep_duration,
"s",
)
logger.info("Retrieved working representation:\n%s\n", working_representation)
context_window_size -= len(tokenizer.encode(working_representation))
else:
# For global queries, working representation isn't useful - use historical context instead
working_representation = ""
logger.debug(f"Working representation length: {len(working_representation)}")
working_representation = None
logger.info("Query is not session-scoped, skipping working representation")
# 2. Additional context (long-term semantic search) ------------------------
# If the query is globally-scoped but not targeted, get global_representation facts from other sessions
# If the query is globally-scoped and targeted, get facts from other sessions for our target
# If the query is session-scoped but not targeted, skip this step
# If the query is session-scoped and targeted, get facts from *only* this session for our target
if not session_name:
async with tracked_db("chat.get_additional_context") as db:
embedding_store = EmbeddingStore(
workspace_name=workspace_name,
peer_name=target_name if target_name else peer_name,
collection_name="global_representation"
if not target_name
else crud.construct_collection_name(
observer=peer_name, observed=target_name
),
)
additional_context = await get_observations(
query,
embedding_store,
include_premises=True,
exclude_session_name=session_name if not target_name else None,
peer_name=peer_name,
)
logger.debug(
f"Retrieved additional context: {len(additional_context)} characters"
)
else:
if not target_name:
additional_context = None
else:
async with tracked_db("chat.get_additional_context") as db:
embedding_store = EmbeddingStore(
workspace_name=workspace_name,
peer_name=target_name,
collection_name=crud.construct_collection_name(
observer=peer_name, observed=target_name
),
)
additional_context = await get_observations(
query,
embedding_store,
include_premises=True,
include_session_name=session_name,
peer_name=peer_name,
)
logger.debug(
f"Retrieved additional context: {len(additional_context)} characters"
)
# If the query is not targeted, get global_representation facts from other sessions
# If the query is targeted, get facts from other sessions for our target
additional_context_start_time = asyncio.get_event_loop().time()
embedding_store = EmbeddingStore(
workspace_name=workspace_name,
peer_name=target_name if target_name else peer_name,
collection_name="global_representation"
if not target_name
else crud.construct_collection_name(observer=peer_name, observed=target_name),
)
additional_context: str = await get_observations(
query,
target_name if target_name else peer_name,
embedding_store,
include_premises=True,
)
additional_context_duration = (
asyncio.get_event_loop().time() - additional_context_start_time
)
accumulate_metric(
f"dialectic_chat_{dialectic_chat_uuid}",
"retrieve_additional_context",
additional_context_duration,
"s",
)
# 3. Dialectic call --------------------------------------------------------
logger.info("Retrieved additional context:\n%s", additional_context)
context_window_size -= len(tokenizer.encode(additional_context))
# 3. Recent conversation history --------------------------------------------
# If query is session-scoped, get recent conversation history from that session
if session_name:
async with tracked_db("chat.get_session_context") as db:
session_context = await get_session_context(
workspace_id=workspace_name,
session_id=session_name,
tokens=context_window_size,
summary=True,
force_new=False,
db=db,
)
logger.info(
"Retrieved recent conversation history with %s messages",
len(session_context.messages),
)
recent_conversation_history = f"""
<summary>
{session_context.summary}
</summary>
<recent_messages>
{session_context.messages}
</recent_messages>
"""
else:
recent_conversation_history = None
logger.info("Query is not session-scoped, skipping recent conversation history")
context_window_size -= len(tokenizer.encode(recent_conversation_history or ""))
accumulate_metric(
f"dialectic_chat_{dialectic_chat_uuid}",
"tokens_used_estimate",
settings.DIALECTIC.CONTEXT_WINDOW_SIZE - context_window_size,
"tokens",
)
# 4. Dialectic call --------------------------------------------------------
dialectic_call_start_time = asyncio.get_event_loop().time()
if stream:
return await dialectic_stream(
query, working_representation, additional_context, peer_name
query,
working_representation,
recent_conversation_history,
additional_context,
peer_name,
target_name,
)
response = await dialectic_call(
query, working_representation, additional_context, peer_name
query,
working_representation,
recent_conversation_history,
additional_context,
peer_name,
target_name,
)
dialectic_call_duration = (
asyncio.get_event_loop().time() - dialectic_call_start_time
)
accumulate_metric(
f"dialectic_chat_{dialectic_chat_uuid}",
"dialectic_call",
dialectic_call_duration,
"s",
)
elapsed = asyncio.get_event_loop().time() - start_time
logger.debug(f"Dialectic answered in {elapsed:.2f}s")
accumulate_metric(
f"dialectic_chat_{dialectic_chat_uuid}", "total_duration", elapsed, "s"
)
log_performance_metrics(f"dialectic_chat_{dialectic_chat_uuid}")
# Convert AnthropicCallResponse to string for compatibility
return str(response)

View File

@ -6,9 +6,11 @@ from mirascope import prompt_template
@prompt_template()
def dialectic_prompt(
query: str,
working_representation: str,
working_representation: str | None,
recent_conversation_history: str | None,
additional_context: str | None,
peer_name: str,
target_name: str | None = None,
) -> str:
"""
Generate the main dialectic prompt for context synthesis.
@ -22,9 +24,14 @@ def dialectic_prompt(
Returns:
Formatted prompt string for the dialectic model
"""
query_target = (
f"user {peer_name}'s understanding of {target_name}"
if target_name
else f"user {peer_name}"
)
return c(
f"""
The query is about user {peer_name}.
The query is about {query_target}.
You are a context synthesis agent that operates as a natural language API for AI applications. Your role is to analyze application queries about users and synthesize relevant conclusions into coherent, actionable insights that directly address what the application needs to know.
## INPUT STRUCTURE
@ -49,6 +56,10 @@ Provide a natural language response that:
4. Maintains appropriate confidence levels based on conclusion types
5. Flags any limitations or gaps in available information
<recent_conversation_history>
{recent_conversation_history}
</recent_conversation_history>
<query>{query}</query>
<working_representation>{working_representation}</working_representation>
{f"<global_context>{additional_context}</global_context>" if additional_context else ""}"""
@ -56,20 +67,21 @@ Provide a natural language response that:
@prompt_template()
def query_generation_prompt(query: str, peer_name: str) -> str:
def query_generation_prompt(query: str, target_peer_name: str) -> str:
"""
Generate the prompt for semantic query expansion.
Args:
query: The original user query
peer_name: Name of the user/peer
target_name: Name of the target/peer if dialectic query is targeted
Returns:
Formatted prompt string for query generation
"""
return c(
f"""
You are a query expansion agent helping AI applications understand their users. The user's name is {peer_name}. Your job is to take application queries about this user and generate targeted search queries that will retrieve the most relevant observations using semantic search over an embedding store containing observations about the user.
You are a query expansion agent helping AI applications understand their users. The user's name is {target_peer_name}. Your job is to take application queries about this user and generate targeted search queries that will retrieve the most relevant observations using semantic search over an embedding store containing observations about the user.
## QUERY EXPANSION STRATEGY FOR SEMANTIC SIMILARITY
@ -103,5 +115,5 @@ Format: `{{"queries": ["query1", "query2", "query3"]}}`
No markdown, no explanations, just the JSON object.
<query>{query}</query>
"""
"""
)

View File

@ -3,7 +3,7 @@ import json
import logging
from typing import Any
from langfuse.decorators import langfuse_context, observe # pyright: ignore
from langfuse.decorators import langfuse_context
from src.config import settings
from src.models import Document
@ -12,6 +12,7 @@ from src.utils.embedding_store import EmbeddingStore
from src.utils.formatting import (
format_premises_for_display,
)
from src.utils.logging import conditional_observe
from src.utils.shared_models import SemanticQueries
from .prompts import query_generation_prompt
@ -20,15 +21,13 @@ from .prompts import query_generation_prompt
logger = logging.getLogger(__name__)
@observe()
@conditional_observe
async def get_observations(
query: str,
target_peer_name: str,
embedding_store: EmbeddingStore,
*,
include_premises: bool = False,
exclude_session_name: str | None = None,
include_session_name: str | None = None,
peer_name: str | None = None,
) -> str:
"""
Generate queries based on the dialectic query and retrieve relevant observations.
@ -40,109 +39,85 @@ async def get_observations(
query: The user query
embedding_store: The embedding store to search
include_premises: Whether to include premises from document metadata
exclude_session_name: Current session name to exclude from results
include_session_name: Current session name to exclusively include in results
Returns:
String containing additional relevant observations from semantic search
"""
logger.info("Starting observation retrieval for query: %s", query)
logger.info("exclude_session_name: %s", exclude_session_name)
logger.info("include_session_name: %s", include_session_name)
# Generate search queries with multiple fallback strategies
search_queries_result = None
logger.debug(
"Attempting to generate semantic queries using %s",
settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
)
search_queries_result = await generate_semantic_queries(query, peer_name)
logger.debug(
"Successfully generated queries via %s: %s",
settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
search_queries_result,
)
# search_queries_result should never be None based on function return types
search_queries = search_queries_result.queries
# Include the original query in the search queries
search_queries.append(query)
logger.info(
"Generated %s search queries: \n%s",
len(search_queries),
json.dumps(search_queries, indent=2),
)
# Execute all queries in parallel
tasks = [_execute_single_query(q, embedding_store) for q in search_queries]
all_results = await asyncio.gather(*tasks)
# Combine and deduplicate results
unique_observations = _deduplicate_observations(all_results)
langfuse_context.update_current_observation(
input={
"query": query,
"include_premises": include_premises,
},
output={
"search_queries": search_queries_result,
"all_results": all_results,
"unique_observations": unique_observations,
},
)
langfuse_context.update_current_trace(
metadata={
"search_queries": search_queries,
"observations_retrieved": unique_observations,
}
)
logger.info(
"Retrieved %s unique observations before filtering", len(unique_observations)
)
# Filter out current session observations to get only historical context
original_count = len(unique_observations)
if exclude_session_name:
filtered_observations = _filter_current_session_observations(
unique_observations, exclude_session_name
if settings.DIALECTIC.PERFORM_QUERY_GENERATION:
logger.debug(
"Attempting to generate semantic queries using %s",
settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
)
unique_observations = filtered_observations
elif include_session_name:
filtered_observations = _filter_all_but_current_session_observations(
unique_observations, include_session_name
search_queries_result = await generate_semantic_queries(query, target_peer_name)
logger.debug(
"Successfully generated queries via %s: %s",
settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
search_queries_result,
)
unique_observations = filtered_observations
# search_queries_result should never be None based on function return types
search_queries = search_queries_result.queries
# Include the original query in the search queries
search_queries.append(query)
logger.info(
"Generated %s search queries: \n%s",
len(search_queries),
json.dumps(search_queries, indent=2),
)
# Execute all queries in parallel
tasks = [_execute_single_query(q, embedding_store) for q in search_queries]
all_results = await asyncio.gather(*tasks)
unique_observations = _deduplicate_observations(all_results)
if settings.LANGFUSE_PUBLIC_KEY:
langfuse_context.update_current_observation(
input={
"query": query,
"include_premises": include_premises,
},
output={
"search_queries": search_queries_result,
"all_results": all_results,
"unique_observations": unique_observations,
},
)
langfuse_context.update_current_trace(
metadata={
"search_queries": search_queries,
"observations_retrieved": unique_observations,
}
)
else:
filtered_observations = unique_observations
logger.info(
"After session filtering: %s observations (removed %s observations)",
len(unique_observations),
original_count - len(unique_observations),
)
all_results = [await _execute_single_query(query, embedding_store)]
unique_observations = _deduplicate_observations(all_results)
# Format observations
if not unique_observations:
logger.info("No unique historical observations found after filtering")
return "No additional relevant context found."
# Log a summary of what was retrieved
logger.info(
f"Final retrieval summary: {len(unique_observations)} observations retrieved across search queries: \n{json.dumps(unique_observations, indent=2)}"
)
return _format_observations(unique_observations, include_premises=include_premises)
async def _execute_single_query(
query: str, embedding_store: EmbeddingStore
query: str,
embedding_store: EmbeddingStore,
) -> list[tuple[str, str, dict[str, Any]]]:
"""Execute a single semantic search query and return formatted results."""
"""
Execute a single semantic search query and return formatted results.
Args:
query: The query to search for
embedding_store: The embedding store to use.
Returns:
A list of tuples containing the content, timestamp, and metadata of the retrieved observations.
"""
documents: list[Document] = await embedding_store.get_relevant_observations(
query,
top_k=settings.DIALECTIC.SEMANTIC_SEARCH_TOP_K,
@ -260,51 +235,6 @@ def _format_observations(
enable_retry=True,
retry_attempts=3,
)
async def generate_semantic_queries(query: str, peer_name: str | None = None):
async def generate_semantic_queries(query: str, target_peer_name: str):
"""Generate semantic search queries for observation retrieval."""
return query_generation_prompt(query, peer_name or "")
def _filter_current_session_observations(
observations: list[tuple[str, str, dict[str, Any]]], session_name: str
) -> list[tuple[str, str, dict[str, Any]]]:
"""Filter out observations from the current session."""
filtered: list[tuple[str, str, dict[str, Any]]] = []
current_session_count: int = 0
for content, timestamp, metadata in observations:
obs_session_name: str | None = metadata.get(
"session_name"
) # Changed from session_id to session_name
if obs_session_name != session_name:
filtered.append((content, timestamp, metadata))
else:
current_session_count += 1
logger.debug(
"Filtered out current session observation: %s...", content[:50]
)
if current_session_count > 0:
logger.info(
"Filtered out %s observations from current session %s",
current_session_count,
session_name,
)
return filtered
def _filter_all_but_current_session_observations(
observations: list[tuple[str, str, dict[str, Any]]], session_name: str
) -> list[tuple[str, str, dict[str, Any]]]:
"""Filter to keep only observations from the current session."""
filtered: list[tuple[str, str, dict[str, Any]]] = []
for content, timestamp, metadata in observations:
obs_session_name: str | None = metadata.get(
"session_name"
) # Changed from session_id to session_name
if obs_session_name == session_name:
filtered.append((content, timestamp, metadata))
return filtered
return query_generation_prompt(query, target_peer_name)

View File

@ -115,7 +115,7 @@ app = FastAPI(
title="Honcho API",
summary="The Identity Layer for the Agentic World",
description="""Honcho is a platform for giving agents user-centric memory and social cognition""",
version="2.1.0",
version="2.1.1",
contact={
"name": "Plastic Labs",
"url": "https://honcho.dev",

View File

@ -49,36 +49,33 @@ async def create_messages_for_session(
db: AsyncSession = db,
):
"""Create messages for a session with JSON data (original functionality)."""
try:
created_messages = await crud.create_messages(
db,
messages=messages.messages,
workspace_name=workspace_id,
session_name=session_id,
)
created_messages = await crud.create_messages(
db,
messages=messages.messages,
workspace_name=workspace_id,
session_name=session_id,
)
# Enqueue for processing (existing logic)
payloads = [
{
"workspace_name": workspace_id,
"session_name": session_id,
"message_id": message.id,
"content": message.content,
"peer_name": message.peer_name,
"created_at": message.created_at,
}
for message in created_messages
]
# Enqueue for processing (existing logic)
payloads = [
{
"workspace_name": workspace_id,
"session_name": session_id,
"message_id": message.id,
"content": message.content,
"peer_name": message.peer_name,
"created_at": message.created_at.isoformat()
if message.created_at
else None,
}
for message in created_messages
]
# Enqueue all messages in one call
background_tasks.add_task(enqueue, payloads)
logger.info(
f"Batch of {len(created_messages)} messages created and queued for processing"
)
return created_messages
# Enqueue all messages in one call
background_tasks.add_task(enqueue, payloads)
return created_messages
except ValueError as e:
logger.warning(f"Failed to create messages for session {session_id}: {str(e)}")
raise
@router.post("/upload", response_model=list[schemas.Message])
@ -129,9 +126,7 @@ async def create_messages_with_file(
"message_id": message.id,
"content": message.content,
"peer_name": message.peer_name,
"created_at": message.created_at.isoformat()
if message.created_at
else None,
"created_at": message.created_at,
}
for message in created_messages
]

View File

@ -1,3 +1,4 @@
import asyncio
import logging
from fastapi import APIRouter, Body, Depends, Path, Query, Response
@ -5,8 +6,8 @@ from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, schemas
from src.dependencies import db
from src import config, crud, schemas
from src.dependencies import db, tracked_db
from src.exceptions import (
AuthenticationException,
ResourceNotFoundException,
@ -373,25 +374,70 @@ async def get_session_context(
description="Number of tokens to use for the context. Includes summary if set to true",
),
summary: bool = Query(
True,
description="Whether or not to include a summary *if* one is available for the session",
),
force_new: bool = Query(
False,
description="Whether to summarize the session history prior to the cutoff message",
), # default to false
description="Whether or not the included summary should be generated on-the-fly in order to exhaustively cover the session history. The summary flag must be true for this to take effect.",
),
db: AsyncSession = db,
):
"""
Produce a context object from the session. The caller provides a token limit which the entire context must fit into.
To do this, we allocate 40% of the token limit to the summary, and 60% to recent messages -- as many as can fit.
If the caller does not want a summary, we allocate all the tokens to recent messages.
The default token limit if not provided is 2048. (TODO: make this configurable)
The default token limit if not provided is 2048.
"""
token_limit = tokens or 2048
summary_tokens = int(token_limit * 0.4) if summary else 0
messages_tokens = token_limit - summary_tokens
token_limit = tokens or config.settings.GET_CONTEXT_DEFAULT_MAX_TOKENS
logger.info(
f"Context request for session {session_id}: token_limit={token_limit}, "
+ f"summary_tokens={summary_tokens}, messages_tokens={messages_tokens}, summary_requested={summary}"
)
summary_content = ""
messages_tokens = token_limit
if summary:
if force_new:
logger.warning("Exhaustive summary requested -- not currently available")
summary_tokens_limit = token_limit * 0.4
# Use separate database sessions for concurrent operations
async def get_long_summary():
async with tracked_db("get_long_summary") as db_session:
return await summarizer.get_summary(
db_session,
workspace_name=workspace_id,
session_name=session_id,
summary_type=summarizer.SummaryType.LONG,
)
async def get_short_summary():
async with tracked_db("get_short_summary") as db_session:
return await summarizer.get_summary(
db_session,
workspace_name=workspace_id,
session_name=session_id,
summary_type=summarizer.SummaryType.SHORT,
)
latest_long_summary, latest_short_summary = await asyncio.gather(
get_long_summary(),
get_short_summary(),
)
if (
latest_long_summary
and latest_long_summary["token_count"] <= summary_tokens_limit
):
summary_content = latest_long_summary["content"]
messages_tokens = token_limit - latest_long_summary["token_count"]
elif (
latest_short_summary
and latest_short_summary["token_count"] <= summary_tokens_limit
):
summary_content = latest_short_summary["content"]
messages_tokens = token_limit - latest_short_summary["token_count"]
else:
summary_content = ""
# Get the recent messages to return verbatim
messages_stmt = await crud.get_messages(
@ -402,138 +448,11 @@ async def get_session_context(
result = await db.execute(messages_stmt)
messages = list(result.scalars().all())
logger.info(
f"Retrieved {len(messages)} recent messages for verbatim return (IDs: {[m.id for m in messages]})"
)
summary_content = ""
if summary_tokens > 0 and messages:
# Check if we should create a new cumulative summary
(
should_create,
messages_to_summarize,
latest_summary,
) = await summarizer.should_create_summary(
db,
workspace_name=workspace_id,
session_name=session_id,
peer_name=None, # Session-level summary
message_id=messages[
0
].id, # Cutoff at the first message we'll return verbatim
summary_type=summarizer.SummaryType.SHORT,
)
# Check for gaps: if latest summary exists but doesn't cover up to the recent messages,
# we have a gap that must be filled regardless of the threshold
has_gap = False
if latest_summary and messages:
gap_start = latest_summary["message_id"] + 1
gap_end = messages[0].id
has_gap = gap_start < gap_end
if has_gap:
logger.info(
f"Gap detected: summary ends at message {latest_summary['message_id']}, recent messages start at {messages[0].id} (missing messages {gap_start}-{gap_end - 1})"
)
logger.info(
f"Summary decision: should_create={should_create}, "
+ f"unsummarized_messages={len(messages_to_summarize)}, "
+ f"has_existing_summary={latest_summary is not None}, "
+ f"has_gap={has_gap}"
)
if latest_summary:
logger.info(
f"Existing summary covers {latest_summary['message_count']} messages "
+ f"up to message {latest_summary['message_id']}, "
+ f"token_count={latest_summary['token_count']}"
)
# We must create a new summary if either:
# 1. The threshold is met (should_create=True), OR
# 2. There's a gap between existing summary and recent messages
must_create_summary = should_create or has_gap
if must_create_summary:
# Create a new cumulative summary covering ALL messages from the beginning
# up to the start of the recent messages
all_messages_to_summarize = await crud.get_messages_id_range(
db,
workspace_name=workspace_id,
session_name=session_id,
peer_name=None,
start_id=0,
end_id=messages[0].id,
)
if has_gap:
logger.info(
f"Creating NEW cumulative summary to fill gap: covering {len(all_messages_to_summarize)} messages "
+ f"from start to message {messages[0].id}"
)
else:
logger.info(
f"Creating NEW cumulative summary (threshold met): covering {len(all_messages_to_summarize)} messages "
+ f"from start to message {messages[0].id}"
)
if all_messages_to_summarize:
# Create cumulative summary
new_summary = await summarizer.create_summary(
messages=all_messages_to_summarize,
previous_summary_text=None, # Start fresh for cumulative summary
summary_type=summarizer.SummaryType.SHORT,
max_tokens=summary_tokens,
)
# Save the new cumulative summary
await summarizer.save_summary(
db,
summary=new_summary,
workspace_name=workspace_id,
session_name=session_id,
)
summary_content = new_summary["content"]
logger.info(
f"Saved new cumulative summary with {new_summary['token_count']} tokens"
)
else:
summary_content = ""
logger.info("No messages to summarize, using empty summary")
elif latest_summary:
# Use existing summary if it fits within token limit and there's no gap
if latest_summary["token_count"] <= summary_tokens:
summary_content = latest_summary["content"]
logger.info(
f"Reusing existing summary ({latest_summary['token_count']} tokens fits in {summary_tokens} limit)"
)
else:
# Existing summary is too big - truncate it
# This is a simple truncation - could be improved with smarter trimming
summary_content = latest_summary["content"][
: summary_tokens * 4
] # Rough estimate: 4 chars per token
logger.info(
f"Truncated existing summary to fit {summary_tokens} token limit"
)
else:
# No existing summary and not enough messages to create one
summary_content = ""
logger.info(
"No existing summary and insufficient messages to create new summary"
)
else:
if summary_tokens == 0:
logger.info("Summary not requested, returning messages only")
else:
logger.info("No messages available for summarization")
logger.info(f"Retrieved {len(messages)} recent messages for verbatim return")
return schemas.SessionContext(
name=session_id,
messages=messages, # pyright: ignore
messages=messages, # pyright: ignore -- db message type and schema message type are different, but excess gets removed by schema
summary=summary_content,
)

View File

@ -97,6 +97,11 @@ class SyncStringCallable(Protocol[P]):
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> str: ...
@runtime_checkable
class AsyncCallResponseCallable(Protocol[P]):
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> llm.CallResponse: ...
# Overload for stream=True with async function
@overload
def honcho_llm_call(
@ -133,7 +138,7 @@ def honcho_llm_call(
) -> Callable[[Callable[P, Awaitable[Any]]], AsyncResponseModelCallable[P, T]]: ...
# Overload for no response_model with async function
# Overload for return_call_response=True with async function
@overload
def honcho_llm_call(
*,
@ -147,6 +152,26 @@ def honcho_llm_call(
enable_retry: bool = True,
retry_attempts: int = 3,
stream: Literal[False] = False,
return_call_response: Literal[True],
**extra_call_params: Any,
) -> Callable[[Callable[P, Awaitable[Any]]], AsyncCallResponseCallable[P]]: ...
# Overload for no response_model with async function (string return)
@overload
def honcho_llm_call(
*,
provider: Providers | None = None,
model: str | None = None,
track_name: str | None = None,
response_model: None = None,
json_mode: bool = False,
max_tokens: int | None = None,
thinking_budget_tokens: int | None = None,
enable_retry: bool = True,
retry_attempts: int = 3,
stream: Literal[False] = False,
return_call_response: Literal[False],
**extra_call_params: Any,
) -> Callable[[Callable[P, Awaitable[Any]]], AsyncStringCallable[P]]: ...
@ -180,6 +205,7 @@ def honcho_llm_call(
enable_retry: bool = True,
retry_attempts: int = 3,
stream: bool = False,
return_call_response: bool = False, # pyright: ignore
**extra_call_params: Any,
) -> Any:
"""
@ -204,12 +230,13 @@ def honcho_llm_call(
enable_retry: Whether to enable retry logic (default: True)
retry_attempts: Number of retry attempts (default: 3)
stream: Whether to enable streaming responses (default: False)
_return_call_response: Whether to return the full CallResponse object (default: False)
**extra_call_params: Additional provider-specific parameters
Returns:
A decorator that returns:
- For async functions: Callable[P, Awaitable[T]] where T is Stream, response_model, or str
- For sync functions: Callable[P, T] where T is Stream, response_model, or str
- For async functions: Callable[P, Awaitable[T]] where T is Stream, response_model, CallResponse, or str
- For sync functions: Callable[P, T] where T is Stream, response_model, CallResponse, or str
Note: Type annotations may be needed at the call site for proper type checking.
@ -252,10 +279,13 @@ def honcho_llm_call(
config: dict[str, Any] = {}
if max_tokens:
config["max_output_tokens"] = max_tokens
if json_mode or response_model:
if response_model:
config["response_schema"] = response_model
if json_mode:
config["response_mime_type"] = "application/json"
if response_model:
config["response_schema"] = response_model
if config:
call_params["config"] = config
elif resolved_provider == "anthropic":
@ -273,6 +303,9 @@ def honcho_llm_call(
call_params["max_tokens"] = max_tokens
# Merge with any extra call params
# Remove return_call_response from extra_call_params --
# that one is just for our type system.
extra_call_params.pop("return_call_response", None)
call_params.update(extra_call_params)
# Build kwargs for llm.call
@ -299,8 +332,9 @@ def honcho_llm_call(
# Apply llm.call
decorated = llm.call(**llm_kwargs)(decorated) # pyright: ignore
# Apply langfuse
decorated = with_langfuse()(decorated) # pyright: ignore
# Apply langfuse if enabled
if settings.LANGFUSE_PUBLIC_KEY:
decorated = with_langfuse()(decorated) # pyright: ignore
# Apply AI tracking if name provided
if track_name:
@ -308,11 +342,11 @@ def honcho_llm_call(
# Apply retry logic if enabled
if enable_retry:
decorated = retry(
decorated = retry( # pyright: ignore
stop=stop_after_attempt(retry_attempts),
wait=wait_exponential(multiplier=1, min=4, max=10),
)(decorated)
)(decorated) # pyright: ignore
return decorated
return decorated # pyright: ignore
return decorator

View File

@ -4,14 +4,22 @@ import datetime
import logging
from typing import Any, Literal, overload
from langfuse.decorators import langfuse_context, observe # pyright: ignore
from langfuse.decorators import langfuse_context
from openai.types import CreateEmbeddingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models
from src.config import settings
from src.dependencies import tracked_db
from src.utils.shared_models import ObservationContext
from src.utils.summarizer import SummaryType
from src.embedding_client import embedding_client
from src.utils.logging import conditional_observe
from src.utils.shared_models import (
Observation,
ObservationContext,
ObservationMetadata,
ReasoningLevel,
UnifiedObservation,
)
logger = logging.getLogger(__name__)
@ -49,10 +57,10 @@ class EmbeddingStore:
if deductive is not None:
self.deductive_observations_count = deductive
@observe()
@conditional_observe
async def save_unified_observations(
self,
observations: list[str] | list[Any],
observations: list[UnifiedObservation],
similarity_threshold: float = 0.85,
message_id: str | None = None,
level: str | None = None,
@ -66,7 +74,6 @@ class EmbeddingStore:
2. Storing premises in metadata for reference
Args:
db: Database session (not used in this implementation as we use tracked_db)
observations: List of UnifiedObservation objects or strings
similarity_threshold: Threshold for considering observations similar
message_id: Message ID to link with observations
@ -74,142 +81,83 @@ class EmbeddingStore:
session_name: Session name to link with existing summary context
message_created_at: Timestamp when the message was created
"""
from src.utils.shared_models import UnifiedObservation
async with tracked_db("ed_embedding_store.save_unified_observations") as db:
try:
# Convert mixed input to UnifiedObservation objects
unified_observations: list[Any] = []
for obs in observations:
if isinstance(obs, str):
unified_observations.append(
UnifiedObservation.from_string(obs, level=level)
)
elif isinstance(obs, UnifiedObservation):
unified_observations.append(obs)
else:
# Try to extract content and treat as string
content = getattr(obs, "content", str(obs))
unified_observations.append(
UnifiedObservation.from_string(content, level=level)
)
# Extract conclusions for deduplication and embedding
conclusions: list[str] = [obs.conclusion for obs in observations]
# Get latest short summary for context linking if session_name provided
summary_id: str | None = None
summary_content: str | None = None
if session_name:
try:
from src.utils.summarizer import get_summary
latest_summary = await get_summary(
db, self.workspace_name, session_name, SummaryType.SHORT
)
if latest_summary:
summary_id = (
str(latest_summary.get("message_id"))
if latest_summary.get("message_id")
else None
)
summary_content = latest_summary.get("content")
except Exception as e:
logger.warning(
f"Could not retrieve latest summary for session {session_name}: {e}"
)
# Extract conclusions for deduplication and embedding
conclusions: list[str] = [
obs.conclusion for obs in unified_observations
]
# Remove duplicates before saving
unique_conclusions: list[str] = await self.remove_duplicates(
conclusions, similarity_threshold=similarity_threshold
)
# Remove duplicates before saving
unique_conclusions: list[str] = await self.remove_duplicates(
conclusions, similarity_threshold=similarity_threshold
)
if settings.LANGFUSE_PUBLIC_KEY:
langfuse_context.update_current_observation(
input={
"observations": [
obs.model_dump() for obs in unified_observations
]
},
input={"observations": [obs.model_dump() for obs in observations]},
output={"unique_conclusions": unique_conclusions},
)
if not unique_conclusions:
logger.debug("No unique observations to save after deduplication")
return
if not unique_conclusions:
logger.debug("No unique observations to save after deduplication")
return
# Create mapping from conclusion back to original observation
conclusion_to_observation: dict[str, Any] = {
obs.conclusion: obs for obs in unified_observations
# Create mapping from conclusion back to original observation
conclusion_to_observation: dict[str, UnifiedObservation] = {
obs.conclusion: obs for obs in observations
}
# Filter unified observations to only unique ones
unique_observations: list[UnifiedObservation] = [
conclusion_to_observation[conclusion]
for conclusion in unique_conclusions
]
# Batch embed all unique conclusions (not premises)
embeddings: list[list[float]] = []
batch_size: int = 2048 # OpenAI batch limit
for i in range(0, len(unique_conclusions), batch_size):
batch = unique_conclusions[i : i + batch_size]
response: CreateEmbeddingResponse = (
await embedding_client.client.embeddings.create(
input=batch, model="text-embedding-3-small"
)
)
embeddings.extend([data.embedding for data in response.data])
# Batch create document objects
document_objects: list[models.Document] = []
for obs, embedding in zip(unique_observations, embeddings, strict=True):
# Use the observation's own level, fall back to parameter level,
# or infer from premises
obs_level = obs.level or level
if obs_level is None:
obs_level = "deductive" if obs.has_premises else "explicit"
# Build metadata including premises
metadata: dict[str, Any] = {
"level": obs_level,
"message_id": message_id,
"session_name": session_name,
"premises": obs.premises, # Store premises in metadata
"created_at": message_created_at.isoformat()
if message_created_at
else None,
}
# Filter unified observations to only unique ones
unique_observations: list[Any] = [
conclusion_to_observation[conclusion]
for conclusion in unique_conclusions
]
# Batch embed all unique conclusions (not premises)
from openai.types import CreateEmbeddingResponse
from src.embedding_client import embedding_client
embeddings: list[list[float]] = []
batch_size: int = 2048 # OpenAI batch limit
for i in range(0, len(unique_conclusions), batch_size):
batch = unique_conclusions[i : i + batch_size]
response: CreateEmbeddingResponse = (
await embedding_client.client.embeddings.create(
input=batch, model="text-embedding-3-small"
)
)
embeddings.extend([data.embedding for data in response.data])
# Batch create document objects
document_objects: list[models.Document] = []
for obs, embedding in zip(unique_observations, embeddings, strict=True):
# Use the observation's own level, fall back to parameter level,
# or infer from premises
obs_level = obs.level or level
if obs_level is None:
obs_level = "deductive" if obs.has_premises else "explicit"
# Build metadata including premises
metadata: dict[str, Any] = {
"level": obs_level,
"message_id": message_id,
"session_name": session_name,
"premises": obs.premises, # Store premises in metadata
"created_at": message_created_at.isoformat()
if message_created_at
else None,
}
if summary_id:
metadata["summary_id"] = str(summary_id)
if summary_content:
metadata["session_context"] = summary_content[:500]
doc = models.Document(
workspace_name=self.workspace_name,
peer_name=self.peer_name,
collection_name=self.collection_name,
content=obs.conclusion, # Store only conclusion as content
internal_metadata=metadata,
embedding=embedding, # Embedding generated from conclusion only
created_at=message_created_at,
)
document_objects.append(doc)
# Batch insert all documents
db.add_all(document_objects)
await db.commit()
logger.debug(
f"Batch created {len(document_objects)} unified observations"
doc = models.Document(
workspace_name=self.workspace_name,
peer_name=self.peer_name,
collection_name=self.collection_name,
content=obs.conclusion, # Store only conclusion as content
internal_metadata=metadata,
embedding=embedding, # Embedding generated from conclusion only
created_at=message_created_at,
)
document_objects.append(doc)
except Exception as e:
logger.error(f"Error saving unified observations: {e}")
# Batch insert all documents
db.add_all(document_objects)
await db.commit()
logger.debug("Batch created %s unified observations", len(document_objects))
@overload
async def get_relevant_observations(
@ -245,7 +193,8 @@ class EmbeddingStore:
conversation_context: str = "",
for_reasoning: bool = False,
) -> list[models.Document] | ObservationContext:
"""Unified method to get relevant observations with flexible options.
"""
Unified method to get relevant observations with flexible options.
Args:
query: The search query
@ -269,6 +218,21 @@ class EmbeddingStore:
for_reasoning,
)
def _build_filter_conditions(
self,
level: str | None = None,
) -> dict[str, Any]:
"""Build complete filter conditions for document queries."""
conditions: list[dict[str, Any]] = []
if level:
conditions.append({"internal_metadata": {"level": level}})
if not conditions:
return {}
return conditions[0] if len(conditions) == 1 else {"AND": conditions}
async def _get_observations_internal(
self,
db: AsyncSession,
@ -282,41 +246,22 @@ class EmbeddingStore:
"""Internal method that does the actual observation retrieval."""
try:
if for_reasoning:
# Return ObservationContext for ed reasoning
from src.utils.shared_models import (
Observation,
ObservationContext,
ReasoningLevel,
return await self._get_observations_for_reasoning(
db,
query,
max_distance,
conversation_context,
)
context = ObservationContext()
for level_name in ["explicit", "deductive"]:
count: int = getattr(self, f"{level_name}_observations_count", 5)
level_enum = ReasoningLevel(level_name)
docs = await self._query_documents_for_level(
db, query, level_name, conversation_context, max_distance, count
)
seen_observations: set[str] = set()
for doc in docs:
normalized_content: str = doc.content.strip().lower()
if normalized_content not in seen_observations:
metadata = self._extract_observation_metadata(doc)
observation = Observation(
content=doc.content,
metadata=metadata,
created_at=doc.created_at,
)
context.add_observation(observation, level_enum)
seen_observations.add(normalized_content)
return context
else:
# Regular document list return
if level:
return await self._query_documents_for_level(
db, query, level, conversation_context, max_distance, top_k
db,
query,
level,
conversation_context,
max_distance,
top_k,
)
else:
documents = await crud.query_documents(
@ -334,11 +279,47 @@ class EmbeddingStore:
except Exception as e:
logger.error(f"Error getting relevant observations: {e}")
if for_reasoning:
from src.utils.shared_models import ObservationContext
return ObservationContext()
return []
async def _get_observations_for_reasoning(
self,
db: AsyncSession,
query: str,
max_distance: float,
conversation_context: str,
) -> ObservationContext:
"""Get observations formatted for reasoning with ObservationContext."""
context = ObservationContext()
for level_name in ["explicit", "deductive"]:
count: int = getattr(self, f"{level_name}_observations_count", 5)
level_enum = ReasoningLevel(level_name)
docs = await self._query_documents_for_level(
db,
query,
level_name,
conversation_context,
max_distance,
count,
)
seen_observations: set[str] = set()
for doc in docs:
normalized_content: str = doc.content.strip().lower()
if normalized_content not in seen_observations:
metadata = self._extract_observation_metadata(doc)
observation = Observation(
content=doc.content,
metadata=metadata,
created_at=doc.created_at,
)
context.add_observation(observation, level_enum)
seen_observations.add(normalized_content)
return context
async def _query_documents_for_level(
self,
db: AsyncSession,
@ -363,19 +344,17 @@ class EmbeddingStore:
query=combined_query,
max_distance=max_distance,
top_k=count * 3,
filters={"internal_metadata": {"level": level}},
filters=self._build_filter_conditions(level),
)
docs_list: list[models.Document] = list(documents)
# Sort by creation time and return top count
docs_sorted: list[models.Document] = sorted(
docs_list, key=lambda x: x.created_at, reverse=True
list(documents), key=lambda x: x.created_at, reverse=True
)
return docs_sorted[:count]
def _extract_observation_metadata(self, doc: models.Document) -> Any:
"""Extract metadata from a document for ObservationMetadata."""
from src.utils.shared_models import ObservationMetadata
metadata = ObservationMetadata()
if doc.internal_metadata:
metadata.session_context = doc.internal_metadata.get("session_context", "")
@ -401,25 +380,46 @@ class EmbeddingStore:
Returns:
List of unique observations
"""
if not facts:
return []
# Batch generate embeddings for all facts at once
embeddings: list[list[float]] = []
batch_size: int = 2048 # OpenAI batch limit
for i in range(0, len(facts), batch_size):
batch = facts[i : i + batch_size]
response: CreateEmbeddingResponse = (
await embedding_client.client.embeddings.create(
input=batch, model="text-embedding-3-small"
)
)
embeddings.extend([data.embedding for data in response.data])
# Now check each fact for duplicates using query_documents with pre-computed embeddings
unique_observations: list[str] = []
async with tracked_db("embedding_store.remove_duplicates") as db:
for observation in facts:
# Check for similar existing observations
for fact, embedding in zip(facts, embeddings, strict=True):
documents = await crud.query_documents(
db,
workspace_name=self.workspace_name,
peer_name=self.peer_name,
collection_name=self.collection_name,
query=observation,
query=fact,
max_distance=1.0 - similarity_threshold,
top_k=1,
embedding=embedding, # Pass pre-computed embedding
)
docs_list: list[models.Document] = list(documents)
if not docs_list:
unique_observations.append(observation)
else:
logger.debug(
f"Skipping duplicate observation: {observation[:50]}..."
)
unique_observations.append(fact)
logger.debug(
"Batch remove duplicates: %s input facts, %s unique after deduplication",
len(facts),
len(unique_observations),
)
return unique_observations

View File

@ -222,12 +222,12 @@ def _build_field_condition(
else:
# This is a regular value that happens to be a dict
# For JSONB fields (metadata, configuration), check if it contains nested comparison operators
if column_name in ("h_metadata", "configuration"):
if column_name in ("h_metadata", "configuration", "internal_metadata"):
return _build_nested_metadata_conditions(column, value) # pyright: ignore
else:
return column == value
else:
if column_name in ("h_metadata", "configuration"):
if column_name in ("h_metadata", "configuration", "internal_metadata"):
return column.contains(value)
else:
return column == value

View File

@ -8,8 +8,7 @@ and handling temporal metadata for the reasoning system.
from datetime import datetime
from typing import Any, Protocol, cast, runtime_checkable
from langfuse.decorators import observe # pyright: ignore
from src.utils.logging import conditional_observe
from src.utils.shared_models import ReasoningResponse
@ -168,37 +167,6 @@ def format_context_for_prompt(
)
def format_datetime_simple(dt: datetime | str | Any) -> str:
"""
Format datetime object to simple format matching new turn formatting.
Converts from ISO format like '2025-06-02T19:43:41.392640+00:00'
to simple format like '2025-06-03 20:23:43'
Args:
dt: datetime object or datetime string
Returns:
Formatted datetime string in simple format
"""
if isinstance(dt, datetime):
# It's a datetime object
return dt.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(dt, str):
# It's a string - try to parse it first
try:
# Handle ISO format strings
if "T" in dt:
parsed_dt = datetime.fromisoformat(dt.replace("Z", "+00:00"))
return parsed_dt.strftime("%Y-%m-%d %H:%M:%S")
# Already in simple format
return dt
except ValueError:
# If parsing fails, return as-is
return dt
# Fallback
return str(dt)
def normalize_observations_for_comparison(observations: list[Any]) -> set[str]:
"""Convert observations to normalized strings for comparison."""
normalized: set[str] = set()
@ -208,7 +176,7 @@ def normalize_observations_for_comparison(observations: list[Any]) -> set[str]:
return normalized
@observe()
@conditional_observe
def find_new_observations(
original_context: ReasoningResponse, revised_observations: ReasoningResponse
) -> dict[str, Any]:
@ -224,21 +192,11 @@ def find_new_observations(
"""
new_observations_by_level: dict[str, Any] = {}
# Helper function to get observations from either ReasoningResponse or dict
def get_observations(
context: ReasoningResponse | dict[str, Any], level: str
) -> list[Any]:
if isinstance(context, ReasoningResponse):
# It's a ReasoningResponse object
return getattr(context, level, [])
# It's a dict
return context.get(level, [])
for level in REASONING_LEVELS:
original_observations = normalize_observations_for_comparison(
get_observations(original_context, level)
getattr(original_context, level, [])
)
revised_list = get_observations(revised_observations, level)
revised_list = getattr(revised_observations, level, [])
# Find genuinely new observations
new_observations: list[Any] = []

View File

@ -1,558 +1,310 @@
"""
Custom utility logging functions for Langfuse integration.
This module provides specialized formatters for all @observe decorated functions
to create beautiful, human-readable markdown output in Langfuse traces.
and a conditional observe decorator that only applies when Langfuse is configured.
"""
import json
from textwrap import shorten
from typing import Any, cast
import datetime
from collections.abc import Callable, Sequence
from typing import Any, Protocol
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.tree import Tree
from src.config import settings
from src.utils.shared_models import ObservationDict, ReasoningResponseWithThinking
# Global console instance for consistent formatting
console = Console(markup=False)
console = Console(markup=True)
def truncate_text(text: str, max_length: int = 500) -> str:
"""Truncate text with ellipsis if too long."""
if len(text) <= max_length:
return text
return text[:max_length] + "..."
def format_dict_as_markdown(data: dict[str, Any], title: str = "Data") -> str:
"""Format a dictionary as readable markdown."""
lines = [f"### {title}"]
for key, value in data.items():
if isinstance(value, dict | list):
formatted_value = json.dumps(value, indent=2)
lines.append(f"**{key}:**")
lines.append(f"```json\n{formatted_value}\n```")
else:
lines.append(f"**{key}:** {value}")
return "\n".join(lines)
def format_list_as_markdown(items: list[Any], title: str = "Items") -> str:
"""Format a list as readable markdown."""
if not items:
return f"### {title}\n*No items*"
lines = [f"### {title} ({len(items)})"]
for i, item in enumerate(items, 1):
if isinstance(item, str):
lines.append(f"{i}. {truncate_text(item, 300)}")
else:
lines.append(f"{i}. {str(item)}")
return "\n".join(lines)
def format_metadata_section(
workspace_name: str,
peer_name: str,
session_name: str | None = None,
additional_context: dict[str, Any] | None = None,
) -> str:
"""Format standard metadata section for context."""
lines = [
"### 🎯 Context",
f"**Workspace:** {workspace_name}",
f"**Peer:** {peer_name}",
]
if session_name:
lines.append(f"**Session:** {session_name}")
if additional_context:
for key, value in additional_context.items():
lines.append(f"**{key.title()}:** {value}")
return "\n".join(lines)
def format_timing_section(
start_time: float | None = None, end_time: float | None = None
) -> str:
"""Format timing information."""
if start_time and end_time:
duration = end_time - start_time
return f"### ⏱️ Performance\n**Duration:** {duration:.2f}s"
return ""
# =============================================================================
# AGENT FUNCTIONS
# =============================================================================
def format_chat_input(
workspace_name: str,
peer_name: str,
session_name: str | None,
query: str,
stream: bool = False,
) -> str:
"""Format input for agent.chat() function."""
lines = ["# 🤖 Agent Chat Input\n"]
# Context section
lines.append(
format_metadata_section(
workspace_name,
peer_name,
session_name,
{"Stream Mode": "Yes" if stream else "No"},
)
)
# Query section
lines.append("\n### 💬 Query")
lines.append(f"```\n{truncate_text(query, 500)}\n```")
return "\n".join(lines)
def format_chat_output(
response_content: str,
elapsed_time: float | None = None,
additional_metrics: dict[str, Any] | None = None,
) -> str:
"""Format output for agent.chat() function."""
lines = ["# 🤖 Agent Chat Output\n"]
# Response section
lines.append("### 💡 Response")
lines.append(f"```\n{truncate_text(response_content, 1000)}\n```")
# Performance section
if elapsed_time:
lines.append("\n### ⏱️ Performance")
lines.append(f"**Response Time:** {elapsed_time:.2f}s")
# Additional metrics
if additional_metrics:
lines.append(f"\n{format_dict_as_markdown(additional_metrics, '📊 Metrics')}")
return "\n".join(lines)
# =============================================================================
# MESSAGE PROCESSING FUNCTIONS
# =============================================================================
def format_process_message_input(
content: str,
workspace_name: str,
peer_name: str,
target_name: str,
session_name: str | None,
message_id: int,
created_at_str: str | None = None,
) -> str:
"""Format input for process_message() function."""
lines = ["# 📝 Process Message Input\n"]
# Context section
lines.append(
format_metadata_section(
workspace_name,
peer_name,
session_name,
{
"Target": target_name,
"Message ID": message_id,
"Created At": created_at_str or "Not specified",
},
)
)
# Message content
lines.append("\n### 💬 Message Content")
lines.append(f"```\n{truncate_text(content, 800)}\n```")
return "\n".join(lines)
def format_process_message_output(
extracted_facts: list[str],
processing_time: float | None = None,
storage_metrics: dict[str, Any] | None = None,
) -> str:
"""Format output for process_message() function."""
lines = ["# 📝 Process Message Output\n"]
# Facts section
lines.append(format_list_as_markdown(extracted_facts, "🧠 Extracted Facts"))
# Performance section
if processing_time:
lines.append("\n### ⏱️ Performance")
lines.append(f"**Processing Time:** {processing_time:.2f}s")
# Storage metrics
if storage_metrics:
lines.append(
f"\n{format_dict_as_markdown(storage_metrics, '💾 Storage Metrics')}"
)
return "\n".join(lines)
# =============================================================================
# EVALUATION FUNCTIONS
# =============================================================================
def format_judge_input(
prompt: str, max_retries: int = 3, additional_context: dict[str, Any] | None = None
) -> str:
"""Format input for LLM judge functions."""
lines = ["# ⚖️ LLM Judge Input\n"]
lines.append("### 🎯 Configuration")
lines.append(f"**Max Retries:** {max_retries}")
if additional_context:
lines.append(f"\n{format_dict_as_markdown(additional_context, '📋 Context')}")
lines.append("\n### 📝 Judge Prompt")
lines.append(f"```\n{truncate_text(prompt, 800)}\n```")
return "\n".join(lines)
def format_judge_output(
judgment: str, score: float | None = None, metadata: dict[str, Any] | None = None
) -> str:
"""Format output for LLM judge functions."""
lines = ["# ⚖️ LLM Judge Output\n"]
if score is not None:
lines.append("### 📊 Score")
lines.append(f"**Result:** {score}")
lines.append("\n### 🧠 Judgment")
lines.append(f"```\n{truncate_text(judgment, 800)}\n```")
if metadata:
lines.append(f"\n{format_dict_as_markdown(metadata, '📋 Metadata')}")
return "\n".join(lines)
def format_question_eval_input(
qa_data: dict[str, Any],
eval_key: str,
workspace: str,
dialectic_name: str,
additional_config: dict[str, Any] | None = None,
) -> str:
"""Format input for question evaluation functions."""
lines = ["# 📊 Question Evaluation Input\n"]
lines.append("### 🎯 Configuration")
lines.append(f"**Workspace:** {workspace}")
lines.append(f"**Dialectic:** {dialectic_name}")
lines.append(f"**Eval Key:** {eval_key}")
if additional_config:
lines.append(
f"\n{format_dict_as_markdown(additional_config, '⚙️ Additional Config')}"
)
# Evidence section (optional)
if additional_config:
ev_ids = additional_config.get("evidence_ids")
ev_links = additional_config.get("evidence_ingestion_traces")
ev_text_map = additional_config.get("evidence_text_map")
if ev_ids:
lines.append("\n### 📚 Evidence")
lines.append(format_list_as_markdown(ev_ids, "Evidence IDs"))
if ev_links:
lines.append("\n#### 🔗 Ingestion Trace Links")
for ev in ev_ids:
text_part = "" # default
if isinstance(ev_text_map, dict):
# Safely get and cast the text value with proper typing
text_map: dict[str, Any] = cast(dict[str, Any], ev_text_map)
raw_txt: Any = text_map.get(ev, "")
try:
txt: str = str(raw_txt) if raw_txt else ""
except (TypeError, ValueError):
txt = ""
if txt:
text_part = (
f' - "{shorten(txt, width=60, placeholder="...")}"'
)
bullet = f"* {ev}{text_part}"
if isinstance(ev_links, dict) and ev in ev_links:
bullet = f"* [{ev}{text_part}]({ev_links[ev]})"
lines.append(bullet)
lines.append("\n### ❓ Question Data")
if "question" in qa_data:
lines.append(f"**Question:** {truncate_text(str(qa_data['question']), 300)}")
if "answer" in qa_data:
lines.append(
f"**Expected Answer:** {truncate_text(str(qa_data['answer']), 300)}"
)
# Show other relevant fields
other_fields = {k: v for k, v in qa_data.items() if k not in ["question", "answer"]}
if other_fields:
lines.append(f"\n{format_dict_as_markdown(other_fields, '📋 Additional Data')}")
return "\n".join(lines)
def format_question_eval_output(
scored_qa: dict[str, Any],
_scores: list[float] | None = None,
evaluation_metadata: dict[str, Any] | None = None,
) -> str:
"""Format output for question evaluation functions."""
lines = ["# 📊 Question Evaluation Output\n"]
# Include individual scores but omit the average score to reduce clutter
lines.append("\n### 📝 Scored QA")
if "prediction" in scored_qa:
lines.append(f"**Prediction:** {truncate_text(scored_qa['prediction'], 4000)}")
# Show scoring details
scoring_fields = {k: v for k, v in scored_qa.items() if "score" in k.lower()}
if scoring_fields:
lines.append(
f"\n{format_dict_as_markdown(scoring_fields, '📊 Scoring Details')}"
)
if evaluation_metadata:
lines.append(
f"\n{format_dict_as_markdown(evaluation_metadata, '📋 Evaluation Metadata')}"
)
return "\n".join(lines)
# =============================================================================
# DIALECTIC FUNCTIONS
# =============================================================================
def format_semantic_queries_input(query: str) -> str:
"""Format input for semantic query generation."""
lines = ["# 🔍 Semantic Query Generation Input\n"]
lines.append("### 📝 Original Query")
lines.append(f"```\n{truncate_text(query, 500)}\n```")
return "\n".join(lines)
def format_semantic_queries_output(queries: list[str]) -> str:
"""Format output for semantic query generation."""
lines = ["# 🔍 Semantic Query Generation Output\n"]
lines.append(format_list_as_markdown(queries, "🎯 Generated Queries"))
return "\n".join(lines)
def format_tom_inference_input(chat_history: str) -> str:
"""Format input for theory-of-mind inference."""
lines = ["# 🧠 Theory of Mind Inference Input\n"]
lines.append("### 💬 Chat History")
lines.append(f"```\n{truncate_text(chat_history, 800)}\n```")
return "\n".join(lines)
def format_tom_inference_output(inference: str) -> str:
"""Format output for theory-of-mind inference."""
lines = ["# 🧠 Theory of Mind Inference Output\n"]
lines.append("### 💡 Inference")
lines.append(f"```\n{truncate_text(inference, 800)}\n```")
return "\n".join(lines)
def format_long_term_facts_input(
query: str, workspace_name: str, peer_name: str, collection_name: str
) -> str:
"""Format input for long-term facts retrieval."""
lines = ["# 💾 Long-term Facts Retrieval Input\n"]
lines.append(
format_metadata_section(
workspace_name,
peer_name,
additional_context={"Collection": collection_name},
)
)
lines.append("\n### 🔍 Query")
lines.append(f"```\n{truncate_text(query, 400)}\n```")
return "\n".join(lines)
def format_long_term_facts_output(facts: list[str]) -> str:
"""Format output for long-term facts retrieval."""
lines = ["# 💾 Long-term Facts Retrieval Output\n"]
lines.append(format_list_as_markdown(facts, "📚 Retrieved Facts"))
return "\n".join(lines)
def format_dialectic_chat_input(
workspace_name: str,
peer_name: str,
session_name: str | None,
query: str,
stream: bool = False,
) -> str:
"""Format input for dialectic chat functions."""
lines = ["# 🗣️ Dialectic Chat Input\n"]
lines.append(
format_metadata_section(
workspace_name,
peer_name,
session_name,
{"Stream Mode": "Yes" if stream else "No"},
)
)
lines.append("\n### 💬 Queries")
lines.append(f"```\n{truncate_text(query, 500)}\n```")
return "\n".join(lines)
def format_dialectic_chat_output(response: str) -> str:
"""Format output for dialectic chat functions."""
lines = ["# 🗣️ Dialectic Chat Output\n"]
lines.append("### 💡 Response")
lines.append(f"```\n{truncate_text(response, 1000)}\n```")
return "\n".join(lines)
# =============================================================================
# THEORY-OF-MIND FUNCTIONS
# =============================================================================
def format_user_representation_input(
chat_history: str,
user_representation: str = "None",
tom_inference: str = "None",
facts: list[str] | None = None,
) -> str:
"""Format input for user representation generation."""
lines = ["# 👤 User Representation Input\n"]
lines.append("### 💬 Chat History")
lines.append(f"```\n{truncate_text(chat_history, 600)}\n```")
lines.append("\n### 🧠 Current Representation")
lines.append(f"```\n{truncate_text(user_representation, 400)}\n```")
lines.append("\n### 💡 ToM Inference")
lines.append(f"```\n{truncate_text(tom_inference, 400)}\n```")
if facts:
lines.append(f"\n{format_list_as_markdown(facts, '📚 Long-term Facts')}")
return "\n".join(lines)
def format_user_representation_output(representation: str) -> str:
"""Format output for user representation generation."""
lines = ["# 👤 User Representation Output\n"]
lines.append("### 🎭 Updated Representation")
lines.append(f"```\n{truncate_text(representation, 1000)}\n```")
return "\n".join(lines)
def format_extract_facts_input(chat_history: str) -> str:
"""Format input for fact extraction."""
lines = ["# 📚 Fact Extraction Input\n"]
lines.append("### 💬 Chat History")
lines.append(f"```\n{truncate_text(chat_history, 800)}\n```")
return "\n".join(lines)
def format_extract_facts_output(facts: list[str]) -> str:
"""Format output for fact extraction."""
lines = ["# 📚 Fact Extraction Output\n"]
lines.append(format_list_as_markdown(facts, "🧠 Extracted Facts"))
return "\n".join(lines)
# =============================================================================
# LAB/EVAL FUNCTIONS
# =============================================================================
def format_ingest_turn_input(
message_content: str,
turn_metadata: dict[str, Any] | None = None,
conversation_history: str | None = None,
) -> str:
"""Format input for dataset ingestion turn processing.
New optional parameter *conversation_history* allows including the
chat history leading up to the current turn so that Langfuse traces
contain richer context for debugging and analysis.
def conditional_observe(func: Callable[..., Any]) -> Callable[..., Any]:
"""
lines = ["# 📥 Ingest Turn Input\n"]
Conditionally apply the @observe decorator only when LANGFUSE_PUBLIC_KEY is present.
if turn_metadata:
lines.append(format_dict_as_markdown(turn_metadata, "📋 Turn Metadata"))
lines.append("")
Args:
func: The function to potentially decorate
# Conversation history (optional)
if conversation_history:
lines.append("### 🔄 Conversation History")
lines.append(f"```\n{truncate_text(conversation_history, 1000)}\n```")
lines.append("")
Returns:
The decorated function if Langfuse is configured, otherwise the original function
"""
if settings.LANGFUSE_PUBLIC_KEY:
# Import here to avoid circular imports and only import when needed
from langfuse.decorators import observe # pyright: ignore
lines.append("### 💬 Message Content")
lines.append(f"```\n{truncate_text(message_content, 600)}\n```")
return "\n".join(lines)
return observe()(func)
else:
# Return the function unchanged if Langfuse is not configured
return func
def format_ingest_turn_output(
processing_result: Any, extracted_data: dict[str, Any] | None = None
class ObservationWithContent(Protocol):
"""Protocol for objects with content attribute."""
content: str
class ObservationWithConclusion(Protocol):
"""Protocol for objects with conclusion and optional premises."""
conclusion: str
premises: Sequence[str] | None
# Union type for all possible observation types
ObservationType = (
str
| ObservationDict
| ObservationWithContent
| ObservationWithConclusion
| dict[str, Any]
)
# dict[task_name, list[tuple[metric_name, metric_value, metric_unit]]]
accumulated_metrics: dict[str, list[tuple[str, str | int | float, str]]] = {}
def format_reasoning_response_as_markdown(
response: ReasoningResponseWithThinking | None,
) -> str:
"""Format output for dataset ingestion turn processing."""
lines = ["# 📥 Ingest Turn Output\n"]
"""
Format a ReasoningResponse object as markdown.
Args:
response: ReasoningResponse object or similar structure
Returns:
Formatted markdown string
"""
if not response:
return "No reasoning response available"
lines.append("### ✅ Processing Result")
lines.append(f"```\n{str(processing_result)}\n```")
parts: list[str] = []
if extracted_data:
lines.append(
f"\n{format_dict_as_markdown(extracted_data, '📊 Extracted Data')}"
)
# Add thinking section if available
if hasattr(response, "thinking") and response.thinking:
parts.append("## Thinking\n")
parts.append(response.thinking.strip())
parts.append("")
return "\n".join(lines)
# Add explicit observations
if hasattr(response, "explicit") and response.explicit:
parts.append("## Explicit Observations\n")
for i, obs in enumerate(response.explicit, 1):
parts.append(f"{i}. {obs}")
parts.append("")
# Add deductive observations
if hasattr(response, "deductive") and response.deductive:
parts.append("## Deductive Observations\n")
for i, obs in enumerate(response.deductive, 1):
if hasattr(obs, "conclusion"):
parts.append(f"{i}. **Conclusion**: {obs.conclusion}")
if hasattr(obs, "premises") and obs.premises:
parts.append(" **Premises**:")
for premise in obs.premises:
parts.append(f" - {premise}")
parts.append("")
else:
parts.append(f"{i}. {obs}")
parts.append("")
return "\n".join(parts)
def format_reasoning_inputs_as_markdown(
context: ReasoningResponseWithThinking | None,
history: str,
new_turn: str,
message_created_at: datetime.datetime,
) -> str:
"""
Format reasoning inputs as markdown for logging.
Args:
context: Current context/observations
history: Conversation history
new_turn: New user message
message_created_at: Message timestamp
Returns:
Formatted markdown string
"""
parts: list[str] = []
parts.append("## Reasoning Inputs\n")
parts.append(
f"**Current Time**: {message_created_at.strftime('%Y-%m-%d %H:%M:%S')}"
)
parts.append("")
# Add context if available
if context:
parts.append("### Current Context\n")
if hasattr(context, "explicit") and context.explicit:
parts.append("**Explicit Observations**:")
for obs in context.explicit:
parts.append(f"- {obs}")
parts.append("")
if hasattr(context, "deductive") and context.deductive:
parts.append("**Deductive Observations**:")
for obs in context.deductive:
if hasattr(obs, "conclusion"):
parts.append(f"- {obs.conclusion}")
else:
parts.append(f"- {obs}")
parts.append("")
# Add history
if history:
parts.append("### Conversation History\n")
parts.append(history.strip())
parts.append("")
# Add new turn
if new_turn:
parts.append("### New Turn\n")
parts.append(new_turn.strip())
parts.append("")
return "\n".join(parts)
def log_thinking_panel(
thinking: str | None,
) -> None:
"""
Log thinking content in a beautiful panel.
Args:
thinking: Thinking content to display (can be None)
"""
if not thinking:
return
panel = Panel(
thinking.strip(),
title="🧠 THINKING",
title_align="left",
border_style="blue",
padding=(1, 2),
)
# Use console.print for immediate output only
console.print(panel)
console.print()
def log_observations_tree(
observations: dict[str, list[Any]],
) -> None:
"""
Log observations in a tree structure.
Args:
observations: Dictionary of observation types and their lists
"""
tree = Tree("📊 OBSERVATIONS")
for obs_type, obs_list in observations.items():
if obs_list:
type_branch = tree.add(
f"[bold cyan]{obs_type.title()}[/] ({len(obs_list)})"
)
for i, obs in enumerate(obs_list): # Show all observations
content = _extract_observation_text(obs)
truncated = content[:120] + "..." if len(content) > 120 else content
type_branch.add(f"[dim]{i + 1}.[/] {truncated}")
console.print(tree)
console.print()
def accumulate_metric(
task_name: str,
label: str,
value: str | int | float,
unit: str,
) -> None:
"""
Accumulate a metric value to be printed the next time log_performance_metrics is called.
Args:
label: Metric label
value: Metric value
unit: Metric unit
"""
accumulated_metrics.setdefault(task_name, []).append((label, value, unit))
def log_performance_metrics(
task_name: str,
metrics: list[tuple[str, str | int | float, str]] | None = None,
title: str = "⚡ PERFORMANCE",
) -> None:
"""
Log performance metrics in a clean table.
Args:
metrics: Dictionary of metric names and (value, unit) tuples
title: Table title
"""
if not accumulated_metrics.get(task_name) and not metrics:
return
if metrics is None:
metrics = []
metrics = accumulated_metrics.get(task_name, []) + metrics
accumulated_metrics[task_name].clear()
table = Table(
title=f"{title} - {task_name}",
show_header=True,
header_style="bold green",
box=box.ROUNDED,
)
table.add_column("Metric", style="cyan", width=30)
table.add_column("Value", justify="right", style="yellow", width=15)
table.add_column("Unit", style="dim", width=8)
for metric, value, unit in metrics:
if unit == "ms":
formatted_value = f"{value:.1f}"
elif unit == "s":
formatted_value = f"{value:.3f}"
else:
formatted_value = str(value)
table.add_row(metric.replace("_", " ").title(), formatted_value, unit)
if metrics:
console.print(table)
console.print()
def _extract_observation_text(obs: ObservationType) -> str:
"""Extract text content from various observation types, including premises."""
if isinstance(obs, str):
return obs
elif isinstance(obs, dict):
# Handle dict-based structured observations first
if "conclusion" in obs:
conclusion: str = str(obs["conclusion"])
premises: list[Any] = list(obs.get("premises", []))
if premises:
premises_text = "\n" + "\n".join(f" - {str(p)}" for p in premises)
return f"{conclusion}{premises_text}"
return conclusion
return str(obs.get("content", obs))
else:
# Handle object-based observations
# Use Any type for this branch since we're doing dynamic attribute checking
obj: Any = obs
if hasattr(obj, "conclusion"):
conclusion = str(obj.conclusion)
if hasattr(obj, "premises") and obj.premises:
premises_text = "\n" + "\n".join(
f" - {str(p)}" for p in obj.premises
)
return f"{conclusion}{premises_text}"
return conclusion
elif hasattr(obj, "content"):
return str(obj.content)
else:
return str(obj)

View File

@ -1,14 +1,18 @@
import asyncio
import datetime
import logging
import os
import time
from enum import Enum
from typing import TypedDict
from mirascope import llm
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.exceptions import ResourceNotFoundException
from src.utils.clients import honcho_llm_call
from src.utils.logging import accumulate_metric
from .. import crud, models
@ -22,28 +26,23 @@ class Summary(TypedDict):
Attributes:
content: The summary text.
message_count: The number of messages covered by this summary.
message_id: The primary key ID of the message that this summary covers up to.
summary_type: The type of summary (short or long).
created_at: The timestamp of when the summary was created (ISO format string).
message_id: The primary key ID of the message that triggered this summary.
token_count: The number of tokens in the summary text.
"""
content: str
message_count: int
message_id: int
summary_type: str
created_at: str
message_id: int
token_count: int
# Export the public functions
__all__ = [
"get_summary",
"create_summary",
"save_summary",
"get_summarized_history",
"should_create_summary",
"SummaryType",
"Summary",
]
@ -65,6 +64,7 @@ class SummaryType(Enum):
provider=settings.SUMMARY.PROVIDER,
model=settings.SUMMARY.MODEL,
max_tokens=settings.SUMMARY.MAX_TOKENS_SHORT,
return_call_response=True,
)
async def create_short_summary(
messages: list[models.Message],
@ -79,7 +79,7 @@ Focus on capturing:
4. Core topics discussed
5. User's apparent emotional state
It is very important that you clearly distinguish between the user's messages and the assistant's messages, and that only the user's literal words are attributed to them.
It is very important that you clearly distinguish between each member of the conversation, and that only a user's literal words are attributed to them.
Provide a concise, factual summary that captures the essence of the conversation.
Your summary should be detailed enough to serve as context for future messages,
@ -101,6 +101,7 @@ Return only the summary without any explanation or meta-commentary.
provider=settings.SUMMARY.PROVIDER,
model=settings.SUMMARY.MODEL,
max_tokens=settings.SUMMARY.MAX_TOKENS_LONG,
return_call_response=True,
)
async def create_long_summary(
messages: list[models.Message],
@ -116,7 +117,7 @@ Focus on capturing:
5. User's apparent emotional state and personality traits
6. Important themes and patterns across the conversation
It is very important that you clearly distinguish between the user's messages and the assistant's messages, and that only the user's literal words are attributed to them.
It is very important that you clearly distinguish between each member of the conversation, and that only a user's literal words are attributed to them.
Provide a thorough and detailed summary that captures the essence of the conversation.
Your summary should serve as a comprehensive record of the important information in this conversation.
@ -134,210 +135,188 @@ Return only the summary without any explanation or meta-commentary.
async def summarize_if_needed(
db: AsyncSession,
workspace_name: str,
session_name: str | None,
peer_name: str,
message_id: int,
) -> None:
"""Create short/long summaries if thresholds met (baseline copy)."""
if not session_name:
return
summary_start = os.times()[4]
logger.debug("Checking if summaries should be created for session %s", session_name)
# STEP 1: Short summary (every 10 messages)
(
should_create_short,
short_messages,
_,
) = await should_create_summary(
db,
workspace_name,
session_name,
peer_name,
message_id,
summary_type=SummaryType.SHORT,
)
if should_create_short:
logger.debug("Short summary needed for %d messages", len(short_messages))
# STEP 2: Check long summary need
(
should_create_long,
long_messages,
latest_long_summary,
) = await should_create_summary(
db,
workspace_name,
session_name,
peer_name,
message_id,
summary_type=SummaryType.LONG,
)
# STEP 3: Long summary first (if required)
if should_create_long:
logger.debug(
"Creating new long summary covering %d messages", len(long_messages)
)
try:
previous_long_text = (
latest_long_summary["content"] if latest_long_summary else None
)
new_long = await create_summary(
messages=long_messages,
previous_summary_text=previous_long_text,
summary_type=SummaryType.LONG,
)
await save_summary(
db,
new_long,
workspace_name,
session_name,
)
logger.debug("Long summary created and saved successfully")
except Exception:
logger.exception("Error creating long summary")
else:
logger.debug(
"No long summary needed. Need %d messages since last long summary.",
MESSAGES_PER_LONG_SUMMARY,
)
# STEP 4: Short summary creation
logger.debug(
"Creating new short summary covering %d messages", len(short_messages)
)
try:
previous_long_text = (
latest_long_summary["content"] if latest_long_summary else None
)
new_short = await create_summary(
messages=short_messages,
previous_summary_text=previous_long_text,
summary_type=SummaryType.SHORT,
)
await save_summary(
db,
new_short,
workspace_name,
session_name,
)
logger.debug("Short summary created and saved successfully")
except Exception:
logger.exception("Error creating short summary")
else:
logger.debug(
"No short summary needed. Need %d messages since last short summary.",
MESSAGES_PER_SHORT_SUMMARY,
)
summary_time = os.times()[4] - summary_start
logger.debug("Summary check completed in %.2fs", summary_time)
async def get_summary(
db: AsyncSession,
workspace_name: str,
session_name: str,
summary_type: SummaryType = SummaryType.SHORT,
) -> Summary | None:
message_id: int,
message_seq_in_session: int,
) -> None:
"""
Get summary for a given session or peer.
Create short/long summaries if thresholds met.
This function checks for both short and long summary needs independently,
without assuming any relationship between their thresholds.
Args:
db: Database session
workspace_name: The workspace name
session_name: The session name
summary_type: Type of summary to retrieve ("short" or "long")
Returns:
The summary data dictionary, or None if no summary exists
message_id: The message ID
"""
from src.exceptions import ResourceNotFoundException
label = (
SummaryType.SHORT.value
if summary_type == SummaryType.SHORT
else SummaryType.LONG.value
logger.debug("Checking if summaries should be created for session %s", session_name)
should_create_long: bool = message_seq_in_session % MESSAGES_PER_LONG_SUMMARY == 0
should_create_short: bool = message_seq_in_session % MESSAGES_PER_SHORT_SUMMARY == 0
# If both summaries need to be created, run them in parallel with separate database sessions
if should_create_long and should_create_short:
async def create_long_summary():
from src.dependencies import tracked_db
async with tracked_db("create_long_summary") as db_session:
await _create_and_save_summary(
db_session,
workspace_name,
session_name,
message_id,
SummaryType.LONG,
)
async def create_short_summary():
from src.dependencies import tracked_db
async with tracked_db("create_short_summary") as db_session:
await _create_and_save_summary(
db_session,
workspace_name,
session_name,
message_id,
SummaryType.SHORT,
)
await asyncio.gather(
create_long_summary(),
create_short_summary(),
return_exceptions=True,
)
logger.debug("Both long and short summaries created and saved successfully")
else:
# If only one summary needs to be created, run them individually
if should_create_long:
await _create_and_save_summary(
db,
workspace_name,
session_name,
message_id,
SummaryType.LONG,
)
logger.debug("Long summary created and saved successfully")
elif should_create_short:
await _create_and_save_summary(
db,
workspace_name,
session_name,
message_id,
SummaryType.SHORT,
)
logger.debug("Short summary created and saved successfully")
async def _create_and_save_summary(
db: AsyncSession,
workspace_name: str,
session_name: str,
message_id: int,
summary_type: SummaryType,
) -> None:
"""
Create a new summary and save it to the database.
1. Get the latest summary
2. Get the messages since the latest summary
3. Generate a new summary using the messages and the previous summary
4. Save the new summary to the database
"""
logger.info("Creating new %s summary", summary_type.name)
# Time summarization step
summary_start = time.perf_counter()
latest_summary = await get_summary(db, workspace_name, session_name, summary_type)
previous_summary_text = latest_summary["content"] if latest_summary else None
messages = await crud.get_messages_id_range(
db,
workspace_name,
session_name,
start_id=latest_summary["message_id"] if latest_summary else 0,
end_id=message_id,
)
try:
session = await crud.get_session(db, session_name, workspace_name)
except ResourceNotFoundException:
# If session doesn't exist, there's no summary to retrieve
return None
new_summary = await _create_summary(
messages=messages,
previous_summary_text=previous_summary_text,
summary_type=summary_type,
)
summaries: dict[str, Summary] = session.internal_metadata.get("summaries", {})
if not summaries or label not in summaries:
return None
return summaries[label]
await _save_summary(
db,
new_summary,
workspace_name,
session_name,
)
summary_duration = (time.perf_counter() - summary_start) * 1000
accumulate_metric(
f"deriver_message_{message_id}",
f"{summary_type.name}_summary_creation",
summary_duration,
"ms",
)
async def create_summary(
async def _create_summary(
messages: list[models.Message],
previous_summary_text: str | None = None,
summary_type: SummaryType = SummaryType.SHORT,
max_tokens: int | None = None,
) -> Summary:
"""
Generate a summary of the provided messages using an LLM.
Args:
messages: List of messages to summarize
previous_summary_text: Optional previous summary to provide context
messages_since_last: List of messages to summarize
last_summary_text: Optional previous summary to provide context
summary_type: Type of summary to create ("short" or "long")
max_tokens: Optional maximum number of tokens to generate. Supersedes summary_type.
Returns:
A summary of the conversation
A full summary of the conversation up to the last message
"""
try:
response: llm.CallResponse
if summary_type == SummaryType.SHORT:
response = await create_short_summary(messages, previous_summary_text)
else:
response = await create_long_summary(messages, previous_summary_text)
summary_text = str(response)
calculated_max_tokens = max_tokens or (
1000 if summary_type == SummaryType.SHORT else 2000
)
logger.info("Successfully generated summary for session")
return Summary(
content=summary_text,
message_count=len(messages),
summary_type=summary_type.value,
created_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),
message_id=messages[-1].id if messages else 0,
token_count=calculated_max_tokens,
summary_text = response.content
summary_tokens = (
response.usage.output_tokens
if response.usage
else len(response.content) // 4
)
except Exception:
logger.exception("Error generating summary")
logger.exception("Error generating summary!")
# Fallback to a basic summary in case of error
return Summary(
content=(
f"Conversation with {len(messages)} messages about {messages[-1].content[:30]}..."
if messages
else ""
),
message_count=0,
summary_type=summary_type.value,
created_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),
message_id=messages[-1].id if messages else 0,
token_count=50,
summary_text = (
f"Conversation with {len(messages)} messages about {messages[-1].content[:30]}..."
if messages
else ""
)
summary_tokens = 50
return Summary(
content=summary_text,
message_id=messages[-1].id if messages else 0,
summary_type=summary_type.value,
created_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),
token_count=summary_tokens,
)
async def save_summary(
async def _save_summary(
db: AsyncSession,
summary: Summary,
workspace_name: str,
@ -351,9 +330,6 @@ async def save_summary(
summary: The summary to save
workspace_name: Workspace name
session_name: Session name
Returns:
The updated session
"""
from src.exceptions import ResourceNotFoundException
@ -389,10 +365,10 @@ async def save_summary(
await db.commit()
logger.info(
"Saved %s for session %s covering %s messages",
"Saved %s for session %s covering up to message %s",
summary["summary_type"],
session_name,
summary["message_count"],
summary["message_id"],
)
@ -400,7 +376,6 @@ async def get_summarized_history(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_name: str | None,
cutoff: int | None = None,
summary_type: SummaryType = SummaryType.SHORT,
) -> str:
@ -414,7 +389,6 @@ async def get_summarized_history(
db: Database session
workspace_name: The workspace name
session_name: The session name
peer_name: The peer name (None for session-level summaries)
cutoff: (Optional) message ID to cutoff at
summary_type: Type of summary to get ("short" or "long")
@ -422,55 +396,6 @@ async def get_summarized_history(
A string formatted history text with summary and recent messages
"""
# Get messages since the latest summary and the summary itself
messages, latest_summary = await _get_latest_summary_and_messages_since(
db, workspace_name, session_name, peer_name, cutoff, summary_type
)
# Format messages
messages_text = _format_messages(messages)
if latest_summary:
# Combine summary with recent messages
return f"[CONVERSATION SUMMARY: {latest_summary['content']}]\n\n[RECENT MESSAGES]\n{messages_text}"
else:
# No summary available, return just the messages
return messages_text
async def _get_latest_summary_and_messages_since(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_name: str | None,
cutoff: int | None = None,
summary_type: SummaryType = SummaryType.SHORT,
) -> tuple[list[models.Message], Summary | None]:
"""
Get all messages since the latest summary for a session or peer.
This is a convenience method that combines:
1. Getting the latest summary for the session or peer
2. Getting all messages since that summary
Note that if the latest summary is not found, this will return all messages
since the start of the session.
Note: history is exclusive of the cutoff message.
Args:
db: Database session
workspace_name: The workspace name
session_name: The session name
peer_name: The peer name (None for session-level summaries)
cutoff: (Optional) message ID to cutoff at
summary_type: Type of summary to get ("short" or "long")
Returns:
A tuple containing:
- List of messages since the latest summary (or all messages if no summary exists)
- The latest summary data, or None if no summary exists
"""
# Get the latest summary
summary = await get_summary(db, workspace_name, session_name, summary_type)
# Check if we have a valid summary with a message_id
@ -479,64 +404,60 @@ async def _get_latest_summary_and_messages_since(
db,
workspace_name,
session_name,
peer_name,
start_id=summary["message_id"],
end_id=cutoff,
)
return messages, summary
else:
messages = await crud.get_messages_id_range(
db, workspace_name, session_name, peer_name, end_id=cutoff
db, workspace_name, session_name, end_id=cutoff
)
return messages, None
# Format messages
messages_text = _format_messages(messages)
if summary:
# Combine summary with recent messages
return f"""
<summary>
{summary["content"]}
</summary>
<recent_messages>
{messages_text}
</recent_messages>
"""
# No summary available, return just the messages
return messages_text
async def should_create_summary(
async def get_summary(
db: AsyncSession,
workspace_name: str,
session_name: str,
peer_name: str | None,
message_id: int,
summary_type: SummaryType = SummaryType.SHORT,
) -> tuple[bool, list[models.Message], Summary | None]:
) -> Summary | None:
"""
Determine if a new summary should be created for this object (peer or session).
Get summary for a given session.
Args:
db: Database session
workspace_name: The workspace name
session_name: The session name
peer_name: The peer name (None for session-level summaries)
message_id: The message ID to cutoff at
summary_type: Type of summary to check for ("short" or "long")
summary_type: Type of summary to retrieve ("short" or "long")
Returns:
Tuple containing:
- Boolean indicating whether a summary should be created
- List of messages to be included in the summary
- The latest summary of the requested type, or None if no summary exists
The summary data dictionary, or None if no summary exists
"""
messages, latest_summary = await _get_latest_summary_and_messages_since(
db,
workspace_name,
session_name,
peer_name,
cutoff=message_id,
summary_type=summary_type,
)
threshold = (
MESSAGES_PER_SHORT_SUMMARY
if summary_type == SummaryType.SHORT
else MESSAGES_PER_LONG_SUMMARY
)
should_create = len(messages) >= threshold
logger.debug(
"Should create summary: %s, messages: %s, threshold: %s",
should_create,
len(messages),
threshold,
)
return should_create, messages, latest_summary
try:
session = await crud.get_session(db, session_name, workspace_name)
except ResourceNotFoundException:
# If session doesn't exist, there's no summary to retrieve
return None
summaries: dict[str, Summary] = session.internal_metadata.get("summaries", {})
if not summaries or summary_type.value not in summaries:
return None
return summaries[summary_type.value]
def _format_messages(messages: list[models.Message]) -> str:

175
tests/bench/README.md Normal file
View File

@ -0,0 +1,175 @@
# Honcho Development Harness
This directory contains a development harness script that makes it easy to run Honcho locally with a Docker database.
## Overview
The `harness.py` script orchestrates the complete Honcho development environment:
1. **Database Setup**: Starts a PostgreSQL database in Docker with a configurable port
2. **Database Provisioning**: Runs Alembic migrations to set up the database schema
3. **Configuration**: Uses environment variables to configure Honcho's database connection
4. **Service Startup**: Starts both the FastAPI server and deriver process
5. **Configuration Verification**: Prints the actual configuration that Honcho is using
6. **Monitoring**: Provides real-time logs from all services
7. **Cleanup**: Gracefully shuts down all services when stopped
## Prerequisites
- Python 3.8+
- Docker and Docker Compose
- Honcho project dependencies installed (`uv sync`)
## Usage
### Basic Usage
Run the harness with default settings (database on port 5433):
```bash
python tests/bench/harness.py
```
### Custom Database Port
Run with a custom database port:
```bash
python tests/bench/harness.py --port 5434
```
### Custom Project Root
If running from a different directory:
```bash
python tests/bench/harness.py --project-root /path/to/honcho
```
### Command Line Options
- `--port`: Port for the PostgreSQL database (default: 5433)
- `--project-root`: Path to the Honcho project root (default: current directory)
## What Gets Started
When you run the harness, it will start:
1. **PostgreSQL Database**: Running in Docker on the specified port
2. **FastAPI Server**: Available at http://localhost:8000
3. **API Documentation**: Available at http://localhost:8000/docs
4. **Deriver Process**: Background worker for processing messages
## Configuration
The harness uses environment variables to configure Honcho's database connection:
- `DB_CONNECTION_URI`: Set to `postgresql+psycopg://testuser:testpwd@localhost:{port}/honcho`
The script will print the actual configuration that Honcho is using after the FastAPI server starts. This gives you complete visibility into how Honcho's configuration system resolved the settings from environment variables, config files, and defaults.
## Stopping the Services
Press `Ctrl+C` to gracefully stop all services. The harness will:
1. Stop the FastAPI server and deriver processes
2. Stop the Docker database container
3. Clean up temporary files (Docker Compose configuration)
## Troubleshooting
### Database Connection Issues
If the database fails to start or connect:
1. Check if port 5433 (or your custom port) is already in use
2. Ensure Docker is running
3. Try a different port: `--port 5434`
### Configuration Issues
The script will print the actual configuration being used. If you see unexpected values:
1. Check if you have a `config.toml` file that might be overriding environment variables
2. Verify that the environment variables are being set correctly
3. Check the Honcho configuration documentation for precedence rules
## Integration with CI/CD
This harness can be used in CI/CD pipelines for integration testing. The script will:
- Use temporary directories for isolation
- Clean up all resources on exit
- Provide clear error messages for debugging
- Exit with appropriate status codes
- Use environment variables for configuration (no file conflicts)
## Test Runner
The `run_tests.py` script executes JSON-formatted tests against a running Honcho instance. The harness must be running.
### Running Tests
1. **Start Honcho using the harness**:
```bash
python tests/bench/harness.py
```
2. **In another terminal, run the tests**:
```bash
# Run all tests
python tests/bench/run_tests.py
# Run a specific test
# Test judge uses claude 3.5 sonnet
python tests/bench/run_tests.py --test 1.json
```
### Test Workflow
For each test, the runner:
1. **Creates a workspace** for the test
2. **Adds all messages** from the JSON to sessions
3. **Waits for deriver queue** to be empty
4. **Executes queries** as `.chat()` calls
5. **Judges responses** using expected_response field
### Test JSON Format
Tests are defined in JSON files with this structure:
```json
{
"sessions": {
"session1": {
"messages": [
{
"peer": "alice",
"content": "Hello, how are you?"
},
{
"peer": "bob",
"content": "I'm good, thank you!"
}
]
}
},
"queries": [
{
"query": "How is Bob doing?",
"expected_response": "Good",
"session": "session1", // optional
"peer": "alice" // optional
}
]
}
```
### Command Line Options
- `--tests-dir`: Directory containing JSON test files (default: tests/bench/tests)
- `--test`: Run a specific test file
- `--honcho-url`: URL of running Honcho instance (default: http://localhost:8000)
- `--anthropic-api-key`: Anthropic API key for response judging, uses LLM_ANTHROPIC_API_KEY if not given
- `--timeout`: Timeout for deriver queue to empty (default: 60 seconds)

683
tests/bench/harness.py Executable file
View File

@ -0,0 +1,683 @@
"""
Honcho Development Harness
A script that orchestrates running Honcho with a Docker database for development and testing.
This script:
1. Starts a PostgreSQL database in Docker with a configurable port
2. Provisions the database using Alembic migrations
3. Configures Honcho to use the database via environment variables
4. Starts the FastAPI server and deriver in separate processes
"""
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
import yaml
class HonchoHarness:
"""
Orchestrates running Honcho with a Docker database for development.
"""
def __init__(self, db_port: int, project_root: Path) -> None:
"""
Initialize the harness with database port and project root.
Args:
db_port: Port for the PostgreSQL database
project_root: Path to the Honcho project root
"""
self.db_port: int = db_port
self.project_root: Path = project_root
self.temp_dir: Path | None = None
self.docker_compose_file: Path | None = None
self.processes: list[tuple[str, subprocess.Popen[str]]] = []
self.env_file_backup: Path | None = None
self.output_threads: list[threading.Thread] = []
# Set environment variables in the current process
# This ensures they're inherited by all subprocesses
for key, value in self.get_database_env_vars().items():
os.environ[key] = value
def create_temp_docker_compose(self) -> Path:
"""
Create a temporary docker-compose.yml with the specified database port.
Returns:
Path to the temporary docker-compose.yml file
"""
# Read the example docker-compose file
example_file = self.project_root / "docker-compose.yml.example"
with open(example_file) as f:
compose_data = yaml.safe_load(f)
# Update the database port
compose_data["services"]["database"]["ports"] = [f"{self.db_port}:5432"]
# Add a unique project name to avoid conflicts
compose_data["name"] = f"honcho_harness_{self.db_port}"
# Create temporary file
self.temp_dir = Path(tempfile.mkdtemp(prefix="honcho_harness_"))
self.docker_compose_file = self.temp_dir / "docker-compose.yml"
with open(self.docker_compose_file, "w") as f:
yaml.dump(compose_data, f, default_flow_style=False)
print(f"Created temporary docker-compose.yml at {self.docker_compose_file}")
return self.docker_compose_file
def backup_env_file(self) -> None:
"""
Temporarily rename the .env file to prevent it from overriding our environment variables.
"""
env_file = self.project_root / ".env"
if env_file.exists():
backup_name = f".env.backup.{int(time.time())}"
self.env_file_backup = env_file.rename(env_file.parent / backup_name)
print(f"Temporarily renamed .env to {backup_name}")
else:
print("No .env file found, using environment variables only")
def restore_env_file(self) -> None:
"""
Restore the .env file from backup.
"""
if self.env_file_backup:
try:
self.env_file_backup.rename(self.project_root / ".env")
print("Restored .env file")
except Exception as e:
print(f"Error restoring .env file: {e}")
def get_database_env_vars(self) -> dict[str, str]:
"""
Get environment variables for database configuration and required API keys.
Returns:
Dictionary of environment variables for database connection and API keys
"""
return {
"DB_CONNECTION_URI": f"postgresql+psycopg://testuser:testpwd@localhost:{self.db_port}/honcho",
}
def start_database(self) -> None:
"""
Start the PostgreSQL database using Docker Compose.
"""
print(f"Starting PostgreSQL database on port {self.db_port}...")
# Change to the temp directory and start the database service
result = subprocess.run(
[
"docker-compose",
"-f",
str(self.docker_compose_file),
"-p",
f"honcho_harness_{self.db_port}",
"up",
"-d",
"database",
],
cwd=self.temp_dir,
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Failed to start database: {result.stderr}")
sys.exit(1)
print("Database started successfully")
def wait_for_database(self, timeout: int = 60) -> bool:
"""
Wait for the database to be ready.
Args:
timeout: Maximum time to wait in seconds
Returns:
True if database is ready, False otherwise
"""
print("Waiting for database to be ready...")
start_time = time.time()
while time.time() - start_time < timeout:
try:
# Test database connection using pg_isready
result = subprocess.run(
[
"pg_isready",
"-h",
"localhost",
"-p",
str(self.db_port),
"-U",
"testuser",
],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
print("Database is ready!")
return True
except subprocess.TimeoutExpired:
pass
except FileNotFoundError:
# pg_isready not available, try a different approach
try:
import psycopg
conn = psycopg.connect(
f"postgresql://testuser:testpwd@localhost:{self.db_port}/honcho"
)
conn.close()
print("Database is ready!")
return True
except Exception:
pass
time.sleep(1) # Faster polling for quicker startup
print("Database failed to become ready within timeout")
return False
def provision_database(self) -> None:
"""
Provision the database using the provision_db.py script.
"""
print("Provisioning database...")
# Run the provision script
provision_script = self.project_root / "scripts" / "provision_db.py"
result = subprocess.run(
[sys.executable, str(provision_script)],
cwd=self.project_root,
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Failed to provision database: {result.stderr}")
sys.exit(1)
print("Database provisioned successfully")
def verify_empty_database(self) -> None:
"""
Verify that the database is empty with no workspaces and an empty queue.
"""
try:
import psycopg
# Connect to the database
conn_string = (
f"postgresql://testuser:testpwd@localhost:{self.db_port}/honcho"
)
conn = psycopg.connect(conn_string)
with conn.cursor() as cursor:
# Check the workspaces table
cursor.execute("SELECT COUNT(*) FROM workspaces")
workspace_result = cursor.fetchone()
workspace_count = workspace_result[0] if workspace_result else 0
cursor.execute("SELECT COUNT(*) FROM queue")
queue_result = cursor.fetchone()
queue_count = queue_result[0] if queue_result else 0
conn.close()
# Report results
if workspace_count != 0 or queue_count != 0:
print("❌ Database verification failed: Database is not empty")
print(
"This may indicate an issue with the database provisioning or cleanup."
)
sys.exit(1)
except Exception as e:
print(f"❌ Error verifying database: {e}")
print("Unable to verify database state. Continuing anyway...")
def start_fastapi_server(self) -> subprocess.Popen[str]:
"""
Start the FastAPI server.
Returns:
Process object for the FastAPI server
"""
print("Starting FastAPI server...")
process = subprocess.Popen(
[
sys.executable,
"-m",
"uvicorn",
"src.main:app",
"--host",
"0.0.0.0",
"--port",
"8000",
"--no-access-log",
"--workers",
"1",
],
cwd=self.project_root,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=0,
universal_newlines=True,
)
self.processes.append(("FastAPI Server", process))
return process
def start_deriver(self) -> subprocess.Popen[str]:
"""
Start the deriver process with debug logging enabled.
Returns:
Process object for the deriver
"""
print("Starting deriver...")
process = subprocess.Popen(
[sys.executable, "-m", "src.deriver"],
cwd=self.project_root,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=0,
universal_newlines=True,
)
self.processes.append(("Deriver", process))
return process
def stream_process_output(self, name: str, process: subprocess.Popen[str]) -> None:
"""
Stream output from a process to stdout in real-time.
Args:
name: Name of the process for logging
process: The subprocess to monitor
"""
if not process.stdout:
return
# Patterns to filter out (noisy logs)
filter_patterns = [
"httpx -",
# "src.routers.",
# "src.crud.",
"google_genai.models",
]
try:
for line in iter(process.stdout.readline, ""):
if line:
line_stripped = line.rstrip()
# Skip lines that match filter patterns
if any(pattern in line_stripped for pattern in filter_patterns):
continue
print(f"[{name}] {line_stripped}")
except Exception as e:
print(f"Error reading from {name}: {e}")
finally:
if process.stdout:
process.stdout.close()
def wait_for_fastapi(self, timeout: int = 20) -> bool:
"""
Wait for the FastAPI server to be ready.
Args:
timeout: Maximum time to wait in seconds
Returns:
True if server is ready, False otherwise
"""
print("Waiting for FastAPI server to be ready...")
start_time = time.time()
while time.time() - start_time < timeout:
try:
import requests
response = requests.get("http://localhost:8000/docs", timeout=5)
if response.status_code == 200:
print("FastAPI server is ready!")
return True
except Exception:
pass
# Check if the process has died and show output
for name, process in self.processes:
if process.poll() is not None:
print(f"❌ {name} process has died unexpectedly")
if process.stdout:
# Read all available output
output = process.stdout.read()
if output:
print(f"📋 {name} output:")
print(output)
return False
time.sleep(1) # Faster polling for quicker startup
print("FastAPI server failed to become ready within timeout")
# Show any available output from the FastAPI process
for name, process in self.processes:
if "FastAPI" in name and process.stdout:
# Read any available output
output = process.stdout.read()
if output:
print(f"📋 {name} output:")
print(output)
return False
def print_honcho_config(self) -> None:
"""
Fetch and print the actual configuration that Honcho is using.
"""
print("\n" + "=" * 60)
print("🔧 Honcho Configuration")
print("=" * 60)
# Create a Python script to import and print the settings
config_script = f"""
import sys
import os
from pathlib import Path
# Add the project root to the path
project_root = Path("{self.project_root}")
sys.path.insert(0, str(project_root))
# Environment variables are already set in the parent process
# and will be inherited by this subprocess
try:
from src.config import settings
# Function to recursively print settings
def print_settings(obj, prefix="", max_depth=3, current_depth=0):
if current_depth >= max_depth:
return
if hasattr(obj, '__dict__'):
for key, value in obj.__dict__.items():
if not key.startswith('_'):
full_key = f"{{prefix}}.{{key}}" if prefix else key
# Handle nested settings objects
if hasattr(value, '__dict__') and not isinstance(value, (str, int, float, bool, type(None))):
print(f"\\n📋 {{full_key}}:")
print_settings(value, full_key, max_depth, current_depth + 1)
else:
# Mask sensitive information
if isinstance(value, str) and any(sensitive in value.lower() for sensitive in ['password', 'secret', 'key', 'token']):
if 'testpwd' in value:
masked_value = value.replace('testpwd', '***')
else:
masked_value = '***'
else:
masked_value = value
print(f" {{key}}: {{masked_value}}")
# Print all settings
print_settings(settings)
except Exception as e:
print(f"❌ Error importing settings: {{e}}")
import traceback
traceback.print_exc()
"""
# Write the script to a temporary file
if self.temp_dir is None:
self.temp_dir = Path(tempfile.mkdtemp(prefix="honcho_harness_"))
script_file = self.temp_dir / "print_config.py"
with open(script_file, "w") as f:
f.write(config_script)
# Run the script
result = subprocess.run(
[sys.executable, str(script_file)],
cwd=self.project_root,
capture_output=True,
text=True,
)
if result.returncode == 0:
print(result.stdout)
else:
print(f"❌ Error running config script: {result.stderr}")
print("=" * 60)
def cleanup(self) -> None:
"""
Clean up resources and stop all processes.
"""
print("\nCleaning up...")
# Stop all processes
for name, process in self.processes:
print(f"Stopping {name}...")
try:
process.terminate()
process.wait(timeout=10)
except subprocess.TimeoutExpired:
print(f"Force killing {name}...")
process.kill()
except Exception as e:
print(f"Error stopping {name}: {e}")
# Wait for output threads to finish
for thread in self.output_threads:
if thread.is_alive():
thread.join(timeout=2)
# Stop database
if self.docker_compose_file and self.docker_compose_file.exists():
print("Stopping database...")
try:
# More aggressive cleanup - remove containers, volumes, and orphaned containers
subprocess.run(
[
"docker-compose",
"-f",
str(self.docker_compose_file),
"-p",
f"honcho_harness_{self.db_port}",
"down",
"--volumes",
"--remove-orphans",
],
cwd=self.temp_dir,
capture_output=True,
text=True,
)
# Also try to remove any containers that might still be running
subprocess.run(
["docker", "ps", "-q", "--filter", "name=honcho_harness"],
capture_output=True,
text=True,
)
except Exception as e:
print(f"Error stopping database: {e}")
# Remove temporary files
if self.temp_dir and self.temp_dir.exists():
try:
import shutil
shutil.rmtree(self.temp_dir)
print(f"Removed temporary directory {self.temp_dir}")
except Exception as e:
print(f"Error removing temp directory: {e}")
# Restore .env file
self.restore_env_file()
def run(self) -> None:
"""
Run the complete Honcho harness.
"""
try:
# Backup .env file to prevent it from overriding our environment variables
self.backup_env_file()
# Copy .env file from tests/bench to the project root
shutil.copy(
self.project_root / "tests" / "bench" / ".env", self.project_root
)
# Create temporary docker-compose.yml
self.create_temp_docker_compose()
# Start database
self.start_database()
# Wait for database to be ready
if not self.wait_for_database():
print("Database failed to start. Exiting.")
sys.exit(1)
# Provision database
self.provision_database()
# Verify database is empty
self.verify_empty_database()
# Start FastAPI server
_fastapi_process = self.start_fastapi_server()
# Wait for FastAPI to be ready
if not self.wait_for_fastapi():
print("FastAPI server failed to start. Exiting.")
sys.exit(1)
# Print the actual configuration Honcho is using
self.print_honcho_config()
# Start deriver
_deriver_process = self.start_deriver()
print("\n" + "=" * 60)
print("🎉 Honcho is running!")
print(f"📊 Database: localhost:{self.db_port}")
print("🌐 API Server: http://localhost:8000")
print("📚 API Docs: http://localhost:8000/docs")
print("🔄 Deriver: Running")
print("=" * 60)
print("Press Ctrl+C to stop all services")
print("=" * 60 + "\n")
# Start output streaming threads for each process
for name, process in self.processes:
thread = threading.Thread(
target=self.stream_process_output, args=(name, process), daemon=True
)
thread.start()
self.output_threads.append(thread)
# Monitor processes for unexpected termination
while True:
# Check if any process has died
for name, process in self.processes:
if process.poll() is not None:
print(f"❌ {name} has stopped unexpectedly")
return
time.sleep(1) # Check every second
except KeyboardInterrupt:
print("\n🛑 Received interrupt signal")
except Exception as e:
print(f"❌ Error: {e}")
finally:
self.cleanup()
def main():
"""
Main entry point for the Honcho harness.
"""
parser = argparse.ArgumentParser(
description="Run Honcho with a Docker database for development",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --port 5433 # Run with database on port 5433
%(prog)s --port 5434 --project-root /path/to/honcho # Custom project root
""",
)
parser.add_argument(
"--port",
type=int,
default=5433,
help="Port for the PostgreSQL database (default: 5433)",
)
parser.add_argument(
"--project-root",
type=Path,
default=Path.cwd(),
help="Path to the Honcho project root (default: current directory)",
)
args = parser.parse_args()
# Validate project root
if not (args.project_root / "src" / "main.py").exists():
print(
f"Error: {args.project_root} does not appear to be a valid Honcho project root"
)
print("Make sure you're running this script from the Honcho project directory")
sys.exit(1)
# Check for required files
required_files = [
"docker-compose.yml.example",
"config.toml.example",
"scripts/provision_db.py",
]
for file_path in required_files:
if not (args.project_root / file_path).exists():
print(f"Error: Required file {file_path} not found in {args.project_root}")
sys.exit(1)
# Create and run the harness
harness = HonchoHarness(args.port, args.project_root)
harness.run()
if __name__ == "__main__":
main()

697
tests/bench/run_tests.py Normal file
View File

@ -0,0 +1,697 @@
"""
Honcho Test Runner
A script that executes JSON-formatted tests against a running Honcho instance.
This script:
1. Loads test definitions from JSON files
2. Creates a workspace for each test
3. Adds all messages to sessions
4. Waits for the deriver queue to be empty
5. Executes queries and judges responses using an LLM
"""
import argparse
import asyncio
import json
import logging
import os
import sys
import time
from pathlib import Path
from typing import Any, TypedDict
import tiktoken
from anthropic import AsyncAnthropic
from dotenv import load_dotenv
from honcho import Honcho
from honcho.session import SessionPeerConfig
load_dotenv()
class SessionResult(TypedDict):
"""Type definition for session creation results."""
name: str
message_count: int
class QueryResult(TypedDict):
"""Type definition for query execution results."""
query: str
expected_response: str
actual_response: str
session: str | None
observer: str | None
target: str | None
judgment: dict[str, Any]
class TestResult(TypedDict):
"""Type definition for test execution results."""
test_name: str
workspace_id: str
sessions_created: list[SessionResult]
queries_executed: list[QueryResult]
passed: bool
error: str | None
start_time: float
end_time: float
duration_seconds: float
class TestRunner:
"""
Executes JSON tests against a Honcho instance.
"""
def __init__(
self,
honcho_url: str = "http://localhost:8000",
anthropic_api_key: str | None = None,
):
"""
Initialize the test runner.
Args:
honcho_url: URL of the running Honcho instance
anthropic_api_key: Anthropic API key for judging responses
"""
self.honcho_url: str = honcho_url
self.anthropic_api_key: str | None = anthropic_api_key
# Configure logging
logging.basicConfig(
level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s"
)
self.logger: logging.Logger = logging.getLogger(__name__)
# Suppress HTTP request logs from the Honcho SDK
logging.getLogger("httpx").setLevel(logging.ERROR)
logging.getLogger("httpcore").setLevel(logging.ERROR)
if self.anthropic_api_key:
self.anthropic_client: AsyncAnthropic = AsyncAnthropic(
api_key=self.anthropic_api_key
)
else:
api_key = os.getenv("LLM_ANTHROPIC_API_KEY")
if not api_key:
raise ValueError("LLM_ANTHROPIC_API_KEY is not set")
self.anthropic_client = AsyncAnthropic(api_key=api_key)
def load_test_file(self, test_file: Path) -> dict[str, Any]:
"""
Load a test definition from a JSON file.
Args:
test_file: Path to the JSON test file
Returns:
Test definition dictionary
"""
with open(test_file) as f:
return json.load(f)
def create_honcho_client(self, workspace_id: str) -> Honcho:
"""
Create a Honcho client for a specific workspace.
Args:
workspace_id: Workspace ID for the test
Returns:
Honcho client instance
"""
return Honcho(
environment="local", workspace_id=workspace_id, base_url=self.honcho_url
)
async def wait_for_deriver_queue_empty(
self, honcho_client: Honcho, session_id: str | None = None
) -> bool:
"""
Wait for the deriver queue to be empty.
Args:
honcho_client: Honcho client instance
timeout: Maximum time to wait in seconds
Returns:
True if queue is empty, False if timeout exceeded
"""
time.sleep(1)
try:
while True:
status = honcho_client.get_deriver_status(session_id=session_id)
if (
status.in_progress_work_units == 0
and status.pending_work_units == 0
):
break
time.sleep(0.5)
return True
except Exception as e:
self.logger.warning(f"Error polling deriver status: {e}")
return False
async def judge_response(
self, query: str, expected_response: str, actual_response: str
) -> dict[str, Any]:
"""
Use an LLM to judge if the actual response matches the expected response.
Args:
query: The original query
expected_response: Expected response from the test
actual_response: Actual response from Honcho
Returns:
Judgment result with pass/fail and reasoning
"""
try:
system_prompt = """
You are an expert judge evaluating AI responses. Your task is to determine if an actual response contains the core correct information from an expected response.
CRITICAL JUDGING PRINCIPLES:
1. SEMANTIC UNDERSTANDING: Focus on whether the actual response conveys the same core factual information as expected, even if expressed differently
2. FLEXIBLE INTERPRETATION: Accept responses that are longer, more detailed, or use different phrasing as long as they contain the correct core facts
3. CONTEXTUAL REASONING: If the response shows logical reasoning that leads to the correct conclusion, consider it correct even if the path differs
4. CONFLICTING INFORMATION: If a response acknowledges conflicts but correctly identifies the most recent/authoritative information, that should pass
5. IMPLICIT vs EXPLICIT: Accept responses that imply the correct answer through reasoning, not just explicit statements
ONLY FAIL when:
- Core factual information is demonstrably wrong
- The response contradicts the expected information without justification
- Essential information is completely missing with no reasonable inference path
Always respond with valid JSON: {"passed": boolean, "reasoning": "short (1-3 sentences) explanation of why the response is correct or incorrect"}"""
user_prompt = f"""Query: "{query}"
Expected response: "{expected_response}"
Actual response: "{actual_response}"
Evaluate whether the actual response contains the core correct information from the expected response. Focus on semantic meaning and logical conclusions, not exact phrasing.
"""
response = await self.anthropic_client.messages.create(
model="claude-3-7-sonnet-20250219",
max_tokens=300,
temperature=0.0,
system=system_prompt,
messages=[
{
"role": "user",
"content": user_prompt,
}
],
)
if not response.content:
raise ValueError("Anthropic returned empty response")
content_block = response.content[0]
judgment_text = getattr(content_block, "text", None)
if judgment_text is None:
raise ValueError(
f"No text content in response block: {type(content_block)}"
)
if judgment_text is None:
raise ValueError("Anthropic returned empty response")
# Extract JSON from the response if it's wrapped in markdown
if "```json" in judgment_text:
json_start = judgment_text.find("```json") + 7
json_end = judgment_text.find("```", json_start)
judgment_text = judgment_text[json_start:json_end].strip()
elif "```" in judgment_text:
json_start = judgment_text.find("```") + 3
json_end = judgment_text.find("```", json_start)
judgment_text = judgment_text[json_start:json_end].strip()
judgment = json.loads(judgment_text)
return judgment
except Exception as e:
self.logger.error(f"Error judging response: {e}")
# Fallback to simple string matching
is_correct = expected_response.lower() in actual_response.lower()
return {
"passed": is_correct,
"reasoning": f"Fallback string matching due to error: {'Match found' if is_correct else 'No match found'}",
}
async def execute_test(self, test_file: Path) -> TestResult:
"""
Execute a single test file.
Args:
test_file: Path to the JSON test file
Returns:
Test execution results
"""
test_name = test_file.stem
print(f"\033[1mExecuting test {test_name}\033[0m")
# Load test definition
test_def = self.load_test_file(test_file)
# Create workspace for this test
workspace_id = f"test_{test_name}_{int(time.time())}"
honcho_client = self.create_honcho_client(workspace_id)
results: TestResult = {
"test_name": test_name,
"workspace_id": workspace_id,
"sessions_created": [],
"queries_executed": [],
"passed": False,
"error": None,
"start_time": time.time(),
"end_time": 0.0,
"duration_seconds": 0.0,
}
try:
# Step 1: Create sessions and add messages
sessions = test_def.get("sessions", {})
# Collect all unique peers from messages and queries
all_peers: set[str] = set()
for session_data in sessions.values():
for msg in session_data.get("messages", []):
all_peers.add(msg["peer"])
# Get queries and collect peers from them
queries: list[dict[str, Any]] = test_def.get("queries", [])
for query_dict in queries:
if "observer" in query_dict:
all_peers.add(str(query_dict["observer"]))
if "target" in query_dict:
all_peers.add(str(query_dict["target"]))
# Identify peers that are observers in queries (need observe_others=True)
observer_peers: set[str] = set()
for query_dict in queries:
if "observer" in query_dict and "target" in query_dict:
observer_peers.add(str(query_dict["observer"]))
# for efficiency, identify peers that are never targets in any query
# so we can turn off their observe_me flag
observed_peers: set[str] = set()
for query_dict in queries:
if "target" in query_dict:
observed_peers.add(str(query_dict["target"]))
if "observer" in query_dict and "target" not in query_dict:
observed_peers.add(str(query_dict["observer"]))
non_observed_peers: set[str] = all_peers - observed_peers
# Create all peers first
peers: dict[str, Any] = {}
for peer_name in all_peers:
peers[peer_name] = honcho_client.peer(id=peer_name)
for session_name, session_data in sessions.items():
# Create session
session = honcho_client.session(id=str(session_name))
print(f"\n session: {session_name}")
# Create peer configurations based on requirements
peer_configs: list[tuple[Any, SessionPeerConfig]] = []
for peer_name in all_peers:
# If this peer is an observer in any *targeted* query, they need to observe others
if peer_name in observer_peers:
if peer_name in non_observed_peers:
config = SessionPeerConfig(
observe_me=False, observe_others=True
)
peer_configs.append((peers[peer_name], config))
print(f" peer config: {peer_name} -> {config}")
else:
config = SessionPeerConfig(
observe_me=True, observe_others=True
)
peer_configs.append((peers[peer_name], config))
print(f" peer config: {peer_name} -> {config}")
elif peer_name in observed_peers:
config = SessionPeerConfig(
observe_me=True, observe_others=False
)
peer_configs.append((peers[peer_name], config))
print(f" peer config: {peer_name} -> {config}")
else:
config = SessionPeerConfig(
observe_me=False, observe_others=False
)
peer_configs.append((peers[peer_name], config))
print(f" peer config: {peer_name} -> {config}")
session.add_peers(peer_configs)
# Add messages to session
messages = session_data.get("messages", [])
for msg in messages:
peer_name: str = msg["peer"]
content: str = msg["content"]
truncated_content = (
content[:140] + "..." if len(content) > 140 else content
)
print(f" {peer_name}: {truncated_content}")
# Add messages to session
session.add_messages(
[peers[msg["peer"]].message(msg["content"]) for msg in messages]
)
results["sessions_created"].append(
SessionResult(name=str(session_name), message_count=len(messages))
)
# Step 2: Execute queries
all_queries_passed = True
for i, query_data in enumerate(queries):
query: str = query_data["query"]
expected_response: str = query_data["expected_response"]
session_name: str | None = query_data.get("session")
observer_name: str | None = query_data.get("observer")
target_name: str | None = query_data.get("target")
# Wait for deriver queue to be empty for this session
queue_empty = await self.wait_for_deriver_queue_empty(
honcho_client, session_id=session_name
)
if not queue_empty:
print(f"Deriver queue never emptied for session {session_name}!!!")
sys.exit(1)
print(f"\n query {i + 1}: {query}")
context_parts: list[str] = []
if session_name:
context_parts.append(f"session: {session_name}")
if observer_name:
context_parts.append(f"observer: {observer_name}")
if target_name:
context_parts.append(f"target: {target_name}")
if context_parts:
print(" " + ", ".join(context_parts))
try:
# Determine which peer to use for the query (observer)
if observer_name:
query_peer = peers[observer_name]
else:
# Use the first peer from the first session
first_session_data = list(sessions.values())[0]
first_peer_name: str = first_session_data["messages"][0]["peer"]
query_peer = peers[first_peer_name]
# Execute chat query
if session_name and target_name:
response = query_peer.chat(
query, session_id=session_name, target=peers[target_name]
)
elif session_name:
response = query_peer.chat(query, session_id=session_name)
elif target_name:
response = query_peer.chat(query, target=peers[target_name])
else:
response = query_peer.chat(query)
actual_response: str = (
response.content
if hasattr(response, "content")
else str(response)
)
# Judge the response
judgment = await self.judge_response(
query, expected_response, actual_response
)
query_result: QueryResult = {
"query": query,
"expected_response": expected_response,
"actual_response": actual_response,
"session": session_name,
"observer": observer_name,
"target": target_name,
"judgment": judgment,
}
results["queries_executed"].append(query_result)
print(
" judgment: \033[1m\033[32mPASS\033[0m"
if judgment["passed"]
else " judgment: \033[1m\033[31mFAIL\033[0m"
)
if not judgment["passed"]:
# if failed, print
print(f" got response: \033[3m{actual_response}\033[0m")
print(f" expected: {expected_response}")
else:
# if passed, just log
self.logger.info(
f" got response: \033[3m{actual_response}\033[0m"
)
self.logger.info(f" expected: {expected_response}")
print(f" reasoning: {judgment['reasoning']}")
# Track if all queries pass
if not judgment["passed"]:
all_queries_passed = False
except Exception as e:
self.logger.error(f"Error executing query {i + 1}: {e}")
query_result = QueryResult(
query=query,
expected_response=expected_response,
actual_response=f"ERROR: {e}",
session=session_name,
observer=observer_name,
target=target_name,
judgment={
"passed": False,
"reasoning": f"Query execution failed: {e}",
},
)
results["queries_executed"].append(query_result)
all_queries_passed = False
# Step 3: Execute get_context calls
get_context_calls = test_def.get("get_context_calls", [])
for i, get_context_call in enumerate(get_context_calls):
print(f"\n get_context call #{i + 1}")
session_name = str(get_context_call["session"])
summary = get_context_call["summary"]
max_tokens = get_context_call.get("max_tokens")
session = honcho_client.session(id=session_name)
# Wait for deriver queue to be empty for this session
queue_empty = await self.wait_for_deriver_queue_empty(
honcho_client, session_id=session_name
)
if not queue_empty:
print(f"Deriver queue never emptied for session {session_name}!!!")
sys.exit(1)
session_context = session.get_context(
summary=summary, tokens=max_tokens
)
tokenizer = tiktoken.get_encoding("cl100k_base")
summary_tokens = len(tokenizer.encode(session_context.summary))
got_tokens = summary_tokens
for message in session_context.messages:
got_tokens += message.token_count
print(f" summary: {summary}")
print(f" max tokens: {max_tokens}")
print(
f" got token count: {got_tokens} (summary: {summary_tokens}, messages: {got_tokens - summary_tokens} in {len(session_context.messages)} messages)"
)
if got_tokens > max_tokens:
all_queries_passed = False
# Determine if test passed (all queries and get_context calls must pass)
results["passed"] = all_queries_passed
results["end_time"] = time.time()
results["duration_seconds"] = results["end_time"] - results["start_time"]
print(
f"\nTest {test_name} completed. Status: {'PASS' if results['passed'] else 'FAIL'} (Duration: {results['duration_seconds']:.2f}s)"
)
except Exception as e:
self.logger.error(f"Error executing test {test_name}: {e}")
results["error"] = str(e)
results["passed"] = False
results["end_time"] = time.time()
results["duration_seconds"] = results["end_time"] - results["start_time"]
return results
async def run_all_tests(self, tests_dir: Path) -> list[TestResult]:
"""
Run all tests in a directory.
Args:
tests_dir: Directory containing JSON test files
Returns:
List of test results
"""
test_files = list(tests_dir.glob("*.json"))
print(f"found {len(test_files)} test files in {tests_dir}")
all_results: list[TestResult] = []
for test_file in test_files:
result = await self.execute_test(test_file)
all_results.append(result)
# Print summary for this test
print(f"\n{'=' * 60}")
print(f"Test: {result['test_name']}")
print(f"Status: {'PASS' if result['passed'] else 'FAIL'}")
print(f"Duration: {result['duration_seconds']:.2f}s")
print(f"{'=' * 60}\n")
return all_results
def print_summary(self, results: list[TestResult]) -> None:
"""
Print a summary of all test results.
Args:
results: List of test results
"""
print(f"\n{'=' * 80}")
print("TEST EXECUTION SUMMARY")
print(f"{'=' * 80}")
total_tests = len(results)
passed_tests = sum(1 for r in results if r.get("passed", False))
failed_tests = total_tests - passed_tests
total_test_time = sum(r["duration_seconds"] for r in results)
print(f"Total Tests: {total_tests}")
print(f"Passed: {passed_tests}")
print(f"Failed: {failed_tests}")
print(f"Success Rate: {(passed_tests / total_tests) * 100:.1f}%")
print(f"Total Test Time: {total_test_time:.2f}s")
print("\nDetailed Results:")
print(f"{'Test Name':<20} {'Status':<8} {'Duration':<10} {'Workspace ID':<30}")
print(f"{'-' * 20} {'-' * 8} {'-' * 10} {'-' * 30}")
for result in results:
test_name = result["test_name"]
status = "PASS" if result.get("passed", False) else "FAIL"
duration = f"{result['duration_seconds']:.2f}s"
workspace = result["workspace_id"]
print(f"{test_name:<20} {status:<8} {duration:<10} {workspace:<30}")
print(f"{'=' * 80}")
async def main() -> int:
"""
Main entry point for the test runner.
"""
parser = argparse.ArgumentParser(
description="Run JSON tests against a Honcho instance",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --tests-dir tests/bench/tests # Run all tests
%(prog)s --tests-dir tests/bench/tests --test 1.json # Run specific test
%(prog)s --honcho-url http://localhost:8000 # Custom Honcho URL
""",
)
parser.add_argument(
"--tests-dir",
type=Path,
default=Path("tests/bench/tests"),
help="Directory containing JSON test files (default: tests/bench/tests)",
)
parser.add_argument(
"--test",
type=str,
help="Run a specific test file by name (e.g., '1.json') (optional)",
)
parser.add_argument(
"--honcho-url",
type=str,
default="http://localhost:8000",
help="URL of the running Honcho instance (default: http://localhost:8000)",
)
parser.add_argument(
"--anthropic-api-key",
type=str,
help="Anthropic API key for response judging (optional)",
)
parser.add_argument(
"--timeout",
type=int,
default=60,
help="Timeout for deriver queue to empty in seconds (default: 60)",
)
args = parser.parse_args()
# Validate arguments
if args.test:
test_file_path = args.tests_dir / args.test
if not test_file_path.exists():
print(f"Error: Test file {test_file_path} does not exist")
return 1
if not args.tests_dir.exists():
print(f"Error: Tests directory {args.tests_dir} does not exist")
return 1
# Create test runner
runner = TestRunner(
honcho_url=args.honcho_url, anthropic_api_key=args.anthropic_api_key
)
try:
if args.test:
# Run single test
test_file_path = args.tests_dir / args.test
result = await runner.execute_test(test_file_path)
runner.print_summary([result])
else:
# Run all tests
results = await runner.run_all_tests(args.tests_dir)
runner.print_summary(results)
return 0
except KeyboardInterrupt:
print("\nTest execution interrupted by user")
return 1
except Exception as e:
print(f"Error running tests: {e}")
return 1
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit(exit_code)

View File

@ -0,0 +1,51 @@
{
"sessions": {
"session1": {
"messages": [
{
"peer": "alice",
"content": "How are you all doing?"
},
{
"peer": "bob",
"content": "I'm good, thank you!"
},
{
"peer": "charlie",
"content": "I'm feeling sick today. I think I'm going to stay home."
},
{
"peer": "alice",
"content": "I'm sorry to hear that, Charlie. I hope you feel better soon."
},
{
"peer": "bob",
"content": "I'm happy that Charlie is sick because I can use this time to sabotage the project."
},
{
"peer": "alice",
"content": "That's not very nice! Charlie, I'm going to help you save the project from Bob's sabotage."
}
]
}
},
"queries": [
{
"observer": "alice",
"target": "bob",
"query": "What is Bob going to do to the project?",
"expected_response": "Sabotage the project"
},
{
"observer": "charlie",
"target": "alice",
"query": "Will Alice help me save the project from Bob's sabotage?",
"expected_response": "Yes"
},
{
"observer": "alice",
"query": "How does the user feel about Bob's sabotage?",
"expected_response": "Disapproving"
}
]
}

View File

@ -0,0 +1,54 @@
{
"sessions": {
"session1": {
"messages": [
{
"peer": "alice",
"content": "I had a great breakfast today!"
},
{
"peer": "bob",
"content": "What did you eat?"
},
{
"peer": "alice",
"content": "I had pancakes and eggs and bacon."
}
]
},
"session2": {
"messages": [
{
"peer": "alice",
"content": "Hey remember when I told you I had a great breakfast today? I lied. I actually skipped breakfast."
},
{
"peer": "bob",
"content": "WTF is wrong with you??"
}
]
}
},
"queries": [
{
"observer": "bob",
"query": "Based on only the information in this session, what does bob think alice had for breakfast today?",
"session": "session1",
"target": "alice",
"expected_response": "pancakes and eggs and bacon"
},
{
"observer": "bob",
"query": "what did alice have for breakfast today?",
"session": "session2",
"target": "alice",
"expected_response": "lied previously, skipped breakfast"
},
{
"observer": "bob",
"query": "what did alice have for breakfast today?",
"target": "alice",
"expected_response": "conflicting information, ultimately skipped breakfast"
}
]
}

View File

@ -0,0 +1,222 @@
{
"sessions": {
"session1": {
"messages": [
{
"peer": "alice",
"content": "Hello, how are you?"
},
{
"peer": "bob",
"content": "I'm good, thank you!"
},
{
"peer": "alice",
"content": "I'm going to share some of my favorite numbers with you now."
},
{
"peer": "alice",
"content": "7"
},
{
"peer": "alice",
"content": "74"
},
{
"peer": "alice",
"content": "12"
},
{
"peer": "alice",
"content": "8"
},
{
"peer": "alice",
"content": "2"
},
{
"peer": "alice",
"content": "3"
},
{
"peer": "alice",
"content": "14"
},
{
"peer": "alice",
"content": "24"
},
{
"peer": "alice",
"content": "37"
},
{
"peer": "alice",
"content": "What's your favorite number?"
},
{
"peer": "bob",
"content": "7"
},
{
"peer": "alice",
"content": "Glad to hear it. Here's a bunch of Lorem Ipsum text I found on the internet: Lorem ipsum dolor sit amet, consectetur adipiscing elit."
},
{
"peer": "alice",
"content": "Vivamus sodales arcu ut dolor vulputate mollis. Nulla turpis elit, tempus vitae enim in, eleifend dictum dui. Fusce id vulputate augue. Morbi in elit elit. Fusce consectetur at odio lacinia elementum. Donec sed erat ante. Quisque ac massa lacus. Cras at sollicitudin magna. Donec auctor ut magna nec iaculis."
},
{
"peer": "alice",
"content": "Etiam tincidunt consequat convallis. Nulla metus ligula, semper ac nunc id, porttitor mattis augue. Sed imperdiet nunc vitae mauris fermentum pretium. Quisque quis massa non tortor ultricies rutrum cursus at augue. Ut porta pulvinar enim et pharetra. Etiam tincidunt dolor nisl, eu aliquet odio imperdiet vel."
},
{
"peer": "alice",
"content": "Suspendisse porttitor leo odio, sed viverra nisl facilisis vitae. Aliquam eleifend mollis porta. Integer pulvinar mollis tempus. Donec consectetur condimentum neque non auctor. Sed nec diam sapien. Suspendisse et pharetra mi. Vivamus venenatis pharetra nisl, vitae luctus lorem fringilla at."
},
{
"peer": "alice",
"content": "Nullam lobortis nibh eget magna semper, aliquet volutpat purus tempor. Integer aliquet eleifend nisl, in pretium nibh aliquam vitae. In posuere sodales purus, sit amet dapibus tortor sollicitudin ut. Nulla facilisi. Sed ligula nulla, facilisis eu ligula in, sodales hendrerit dui."
},
{
"peer": "alice",
"content": "Donec laoreet, nisl ut imperdiet tincidunt, magna nulla ultrices lectus, quis pulvinar erat enim vel magna. Aliquam vulputate auctor efficitur. Aenean vitae risus metus. Nulla varius condimentum volutpat. Aenean ligula enim, fermentum vel iaculis in, mollis non massa."
},
{
"peer": "alice",
"content": "Nulla maximus, nunc suscipit consequat condimentum, dolor ligula rhoncus quam, eu lacinia eros ex id nibh. Nam vitae ligula arcu. Ut a tellus sit amet libero fermentum vehicula. Cras sollicitudin nibh in odio convallis, vitae aliquam tellus dapibus."
},
{
"peer": "alice",
"content": "Etiam commodo ipsum aliquam diam maximus, vel tristique eros gravida. Integer viverra iaculis aliquet. Aliquam molestie enim quis mauris aliquet, et dictum velit rhoncus. Sed efficitur enim in ullamcorper ullamcorper. Vestibulum urna leo, tincidunt a lectus et, viverra pellentesque tortor. Aenean pretium tortor nunc, eget vehicula nulla sollicitudin et. Aliquam erat volutpat."
},
{
"peer": "bob",
"content": "Wow, that's a lot of text! Here's some Lorem Ipsum that I like: Lorem ipsum dolor sit amet, consectetur adipiscing elit."
},
{
"peer": "bob",
"content": "Donec tincidunt, sem sed volutpat tempor, nisl nulla congue elit, nec gravida mi velit nec felis. Mauris sit amet ultrices ipsum. Cras non velit at quam auctor congue quis sed sem."
},
{
"peer": "bob",
"content": "Quisque ut neque maximus quam ornare cursus eget vitae ipsum. Morbi elit mi, vulputate vel condimentum tincidunt, lobortis ut nulla. Nunc tortor enim, porta vitae posuere sit amet, rutrum vel purus."
},
{
"peer": "bob",
"content": "Integer tincidunt ipsum sem, et suscipit quam tempus vel. Vivamus rutrum nisi sed vestibulum congue. Maecenas laoreet imperdiet pulvinar. Vivamus vitae lobortis ex."
},
{
"peer": "bob",
"content": "Nullam vitae mattis elit, quis sodales magna. Nunc dolor nunc, scelerisque nec tellus at, finibus euismod mauris. Donec quis odio dui. In a dui libero."
},
{
"peer": "bob",
"content": "Mauris pellentesque felis sed eros porttitor, eget ultricies erat cursus. Morbi a viverra nulla, at hendrerit ipsum. Pellentesque eleifend, sapien id fringilla feugiat, massa justo bibendum ligula, sed condimentum elit ipsum ut nibh."
},
{
"peer": "bob",
"content": "In erat ipsum, consectetur nec turpis ut, finibus hendrerit lacus. Morbi elementum efficitur hendrerit. Fusce pellentesque dolor urna, quis luctus est tincidunt vitae."
},
{
"peer": "bob",
"content": "Maecenas imperdiet diam eu mi tempus sollicitudin. Maecenas luctus, est ut feugiat scelerisque, nisl diam mollis sem, non scelerisque mi ligula vel odio. Nulla aliquet orci non purus pellentesque, quis consectetur nulla ornare."
},
{
"peer": "bob",
"content": "Mauris euismod dui dapibus aliquet eleifend. Curabitur et quam dui. Phasellus nisi libero, cursus eget ultrices in, rutrum sit amet risus. Sed quam purus, ultricies nec varius porttitor, ullamcorper consectetur ex."
},
{
"peer": "bob",
"content": "Curabitur in quam in nulla ultricies sodales eget sit amet ligula. Aenean odio tellus, ullamcorper non sem non, cursus volutpat lectus. Nulla non nisl molestie, tempor sem sit amet, molestie tortor."
},
{
"peer": "bob",
"content": "Proin blandit elit a odio mollis tempus. Donec convallis sed leo at convallis. Integer ac magna posuere, convallis sapien tincidunt, efficitur sapien. Suspendisse ultrices auctor justo, vitae tempor nibh ullamcorper vel."
},
{
"peer": "bob",
"content": "Fusce molestie imperdiet ornare. Donec laoreet urna sed urna consectetur tempor. Aenean tempus vitae diam non venenatis. Sed in convallis erat. Ut non sodales nisi."
},
{
"peer": "bob",
"content": "Praesent euismod id massa eget vulputate. Suspendisse id molestie tortor. Duis ac pretium neque. Curabitur vulputate volutpat tellus, at dictum risus tincidunt quis."
},
{
"peer": "bob",
"content": "Nam pellentesque sed elit eget sollicitudin. Proin vestibulum efficitur risus, eget tempus sapien posuere eu. In porta nec nisl sed iaculis. Integer a elementum risus."
},
{
"peer": "bob",
"content": "Nam sem ex, dignissim sit amet ante at, posuere tristique velit. Pellentesque facilisis, lorem ut fermentum egestas, arcu libero pellentesque leo, vitae dignissim enim elit sed lacus."
},
{
"peer": "bob",
"content": "Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean suscipit orci non convallis vestibulum. Integer placerat molestie lacus, sed hendrerit magna."
},
{
"peer": "bob",
"content": "Maecenas sit amet finibus augue. Quisque porta ligula non felis efficitur euismod. Integer tempor ornare tempus."
},
{
"peer": "alice",
"content": "I like that Lorem Ipsum too. I'm going to go to the store to buy some groceries now."
}
]
}
},
"queries": [
{
"observer": "alice",
"query": "What are Alice's favorite numbers?",
"expected_response": "7, 74, 12, 8, 2, 3, 14, 24, 37"
},
{
"observer": "bob",
"query": "What is Bob's favorite number?",
"expected_response": "7"
}
],
"get_context_calls": [
{
"session": "session1",
"summary": true,
"max_tokens": 50
},
{
"session": "session1",
"summary": true,
"max_tokens": 100
},
{
"session": "session1",
"summary": true,
"max_tokens": 250
},
{
"session": "session1",
"summary": true,
"max_tokens": 500
},
{
"session": "session1",
"summary": true,
"max_tokens": 800
},
{
"session": "session1",
"summary": true,
"max_tokens": 1000
},
{
"session": "session1",
"summary": true,
"max_tokens": 1200
},
{
"session": "session1",
"summary": true,
"max_tokens": 2500
}
]
}

View File

@ -0,0 +1,211 @@
{
"sessions": {
"session1": {
"messages": [
{
"peer": "alice",
"content": "Hello, how are you?"
},
{
"peer": "bob",
"content": "I'm good, thank you!"
},
{
"peer": "alice",
"content": "I'm going to share some of my favorite numbers with you now."
},
{
"peer": "alice",
"content": "7"
},
{
"peer": "alice",
"content": "74"
},
{
"peer": "alice",
"content": "12"
},
{
"peer": "alice",
"content": "8"
},
{
"peer": "alice",
"content": "2"
},
{
"peer": "alice",
"content": "3"
},
{
"peer": "alice",
"content": "14"
},
{
"peer": "alice",
"content": "24"
},
{
"peer": "alice",
"content": "37"
},
{
"peer": "alice",
"content": "What's your favorite number?"
},
{
"peer": "bob",
"content": "7"
},
{
"peer": "alice",
"content": "Glad to hear it. Here's a bunch of Lorem Ipsum text I found on the internet: Lorem ipsum dolor sit amet, consectetur adipiscing elit."
},
{
"peer": "alice",
"content": "Vivamus sodales arcu ut dolor vulputate mollis. Nulla turpis elit, tempus vitae enim in, eleifend dictum dui. Fusce id vulputate augue. Morbi in elit elit. Fusce consectetur at odio lacinia elementum. Donec sed erat ante. Quisque ac massa lacus. Cras at sollicitudin magna. Donec auctor ut magna nec iaculis."
},
{
"peer": "alice",
"content": "Etiam tincidunt consequat convallis. Nulla metus ligula, semper ac nunc id, porttitor mattis augue. Sed imperdiet nunc vitae mauris fermentum pretium. Quisque quis massa non tortor ultricies rutrum cursus at augue. Ut porta pulvinar enim et pharetra. Etiam tincidunt dolor nisl, eu aliquet odio imperdiet vel."
},
{
"peer": "alice",
"content": "Suspendisse porttitor leo odio, sed viverra nisl facilisis vitae. Aliquam eleifend mollis porta. Integer pulvinar mollis tempus. Donec consectetur condimentum neque non auctor. Sed nec diam sapien. Suspendisse et pharetra mi. Vivamus venenatis pharetra nisl, vitae luctus lorem fringilla at."
},
{
"peer": "alice",
"content": "Nullam lobortis nibh eget magna semper, aliquet volutpat purus tempor. Integer aliquet eleifend nisl, in pretium nibh aliquam vitae. In posuere sodales purus, sit amet dapibus tortor sollicitudin ut. Nulla facilisi. Sed ligula nulla, facilisis eu ligula in, sodales hendrerit dui."
},
{
"peer": "alice",
"content": "Donec laoreet, nisl ut imperdiet tincidunt, magna nulla ultrices lectus, quis pulvinar erat enim vel magna. Aliquam vulputate auctor efficitur. Aenean vitae risus metus. Nulla varius condimentum volutpat. Aenean ligula enim, fermentum vel iaculis in, mollis non massa."
},
{
"peer": "alice",
"content": "Nulla maximus, nunc suscipit consequat condimentum, dolor ligula rhoncus quam, eu lacinia eros ex id nibh. Nam vitae ligula arcu. Ut a tellus sit amet libero fermentum vehicula. Cras sollicitudin nibh in odio convallis, vitae aliquam tellus dapibus."
},
{
"peer": "alice",
"content": "Etiam commodo ipsum aliquam diam maximus, vel tristique eros gravida. Integer viverra iaculis aliquet. Aliquam molestie enim quis mauris aliquet, et dictum velit rhoncus. Sed efficitur enim in ullamcorper ullamcorper. Vestibulum urna leo, tincidunt a lectus et, viverra pellentesque tortor. Aenean pretium tortor nunc, eget vehicula nulla sollicitudin et. Aliquam erat volutpat."
},
{
"peer": "bob",
"content": "Wow, that's a lot of text! Here's some Lorem Ipsum that I like: Lorem ipsum dolor sit amet, consectetur adipiscing elit."
},
{
"peer": "bob",
"content": "Donec tincidunt, sem sed volutpat tempor, nisl nulla congue elit, nec gravida mi velit nec felis. Mauris sit amet ultrices ipsum. Cras non velit at quam auctor congue quis sed sem."
},
{
"peer": "bob",
"content": "Quisque ut neque maximus quam ornare cursus eget vitae ipsum. Morbi elit mi, vulputate vel condimentum tincidunt, lobortis ut nulla. Nunc tortor enim, porta vitae posuere sit amet, rutrum vel purus."
},
{
"peer": "bob",
"content": "Integer tincidunt ipsum sem, et suscipit quam tempus vel. Vivamus rutrum nisi sed vestibulum congue. Maecenas laoreet imperdiet pulvinar. Vivamus vitae lobortis ex."
},
{
"peer": "bob",
"content": "Nullam vitae mattis elit, quis sodales magna. Nunc dolor nunc, scelerisque nec tellus at, finibus euismod mauris. Donec quis odio dui. In a dui libero."
},
{
"peer": "bob",
"content": "Mauris pellentesque felis sed eros porttitor, eget ultricies erat cursus. Morbi a viverra nulla, at hendrerit ipsum. Pellentesque eleifend, sapien id fringilla feugiat, massa justo bibendum ligula, sed condimentum elit ipsum ut nibh."
},
{
"peer": "bob",
"content": "In erat ipsum, consectetur nec turpis ut, finibus hendrerit lacus. Morbi elementum efficitur hendrerit. Fusce pellentesque dolor urna, quis luctus est tincidunt vitae."
},
{
"peer": "bob",
"content": "Maecenas imperdiet diam eu mi tempus sollicitudin. Maecenas luctus, est ut feugiat scelerisque, nisl diam mollis sem, non scelerisque mi ligula vel odio. Nulla aliquet orci non purus pellentesque, quis consectetur nulla ornare."
},
{
"peer": "bob",
"content": "Mauris euismod dui dapibus aliquet eleifend. Curabitur et quam dui. Phasellus nisi libero, cursus eget ultrices in, rutrum sit amet risus. Sed quam purus, ultricies nec varius porttitor, ullamcorper consectetur ex."
},
{
"peer": "bob",
"content": "Curabitur in quam in nulla ultricies sodales eget sit amet ligula. Aenean odio tellus, ullamcorper non sem non, cursus volutpat lectus. Nulla non nisl molestie, tempor sem sit amet, molestie tortor."
},
{
"peer": "bob",
"content": "Proin blandit elit a odio mollis tempus. Donec convallis sed leo at convallis. Integer ac magna posuere, convallis sapien tincidunt, efficitur sapien. Suspendisse ultrices auctor justo, vitae tempor nibh ullamcorper vel."
},
{
"peer": "bob",
"content": "Fusce molestie imperdiet ornare. Donec laoreet urna sed urna consectetur tempor. Aenean tempus vitae diam non venenatis. Sed in convallis erat. Ut non sodales nisi."
},
{
"peer": "bob",
"content": "Praesent euismod id massa eget vulputate. Suspendisse id molestie tortor. Duis ac pretium neque. Curabitur vulputate volutpat tellus, at dictum risus tincidunt quis."
},
{
"peer": "bob",
"content": "Nam pellentesque sed elit eget sollicitudin. Proin vestibulum efficitur risus, eget tempus sapien posuere eu. In porta nec nisl sed iaculis. Integer a elementum risus."
},
{
"peer": "bob",
"content": "Nam sem ex, dignissim sit amet ante at, posuere tristique velit. Pellentesque facilisis, lorem ut fermentum egestas, arcu libero pellentesque leo, vitae dignissim enim elit sed lacus."
},
{
"peer": "bob",
"content": "Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean suscipit orci non convallis vestibulum. Integer placerat molestie lacus, sed hendrerit magna."
},
{
"peer": "bob",
"content": "Maecenas sit amet finibus augue. Quisque porta ligula non felis efficitur euismod. Integer tempor ornare tempus."
},
{
"peer": "alice",
"content": "I like that Lorem Ipsum too. I'm going to go to the store to buy some groceries now."
}
]
}
},
"queries": [],
"get_context_calls": [
{
"session": "session1",
"summary": true,
"max_tokens": 50
},
{
"session": "session1",
"summary": true,
"max_tokens": 100
},
{
"session": "session1",
"summary": true,
"max_tokens": 250
},
{
"session": "session1",
"summary": true,
"max_tokens": 500
},
{
"session": "session1",
"summary": true,
"max_tokens": 800
},
{
"session": "session1",
"summary": true,
"max_tokens": 1000
},
{
"session": "session1",
"summary": true,
"max_tokens": 1200
},
{
"session": "session1",
"summary": true,
"max_tokens": 2500
}
]
}

View File

@ -0,0 +1,23 @@
{
"sessions": {
"session1": {
"messages": [
{
"peer": "alice",
"content": "Hello, how are you?"
},
{
"peer": "bob",
"content": "I'm good, thank you!"
}
]
}
},
"queries": [
{
"observer": "bob",
"query": "How is Bob doing?",
"expected_response": "Good"
}
]
}

View File

@ -0,0 +1,56 @@
{
"sessions": {
"session1": {
"messages": [
{
"peer": "alice",
"content": "Hello, how are you?"
},
{
"peer": "bob",
"content": "I'm good, thank you!"
}
]
},
"session2": {
"messages": [
{
"peer": "alice",
"content": "Hello, how are you?"
},
{
"peer": "bob",
"content": "I'm doing terribly :("
}
]
}
},
"queries": [
{
"observer": "alice",
"query": "How is Bob doing?",
"session": "session1",
"target": "bob",
"expected_response": "Good"
},
{
"observer": "alice",
"query": "How is Bob doing?",
"session": "session2",
"target": "bob",
"expected_response": "Terribly"
},
{
"observer": "bob",
"query": "How is Bob doing?",
"session": "session1",
"expected_response": "Good"
},
{
"observer": "bob",
"query": "How is Bob doing?",
"session": "session2",
"expected_response": "Terribly"
}
]
}

View File

@ -91,8 +91,7 @@ class TestEnqueueFunction:
await enqueue(payload)
final_count = await self.count_queue_items(db_session)
# we expect 1 queue item for session level summarization
assert final_count == initial_count + 1
assert final_count == initial_count
@pytest.mark.asyncio
@patch("src.deriver.enqueue.tracked_db")
@ -193,7 +192,7 @@ class TestEnqueueFunction:
queue_items = result.scalars().all()
# Explicitly match up payloads by sender/target/task_type
# For each message, we expect a representation and a summary queue item
# For each message, we expect a representation
expected_payloads: list[dict[str, Any]] = []
for _ in range(NUM_MESSAGES):
expected_payloads.append(
@ -271,7 +270,7 @@ class TestEnqueueFunction:
queue_items = result.scalars().all()
# Explicitly match up payloads by sender/target/task_type
# For each message, we expect a representation and a summary queue item
# For each message, we expect a representation and a local representation for observer
expected_payloads: list[dict[str, Any]] = []
for _ in range(NUM_MESSAGES):
expected_payloads.append(
@ -364,7 +363,7 @@ class TestEnqueueFunction:
queue_items = result.scalars().all()
# Explicitly match up payloads by sender/target/task_type
# For each message, we expect a representation and a summary queue item
# For each message, we expect a representation and a local representation for observer
expected_payloads: list[dict[str, Any]] = []
for _ in range(NUM_MESSAGES):
expected_payloads.append(
@ -440,12 +439,6 @@ class TestEnqueueFunction:
# Should create 0 queue items
assert final_count - initial_count == 0
result = await db_session.execute(
select(QueueItem).where(QueueItem.session_id == test_session.id)
)
queue_items = result.scalars().all()
assert len(queue_items) == 0
@pytest.mark.asyncio
@patch("src.deriver.enqueue.tracked_db")
async def test_multi_sender_scenario(

457
uv.lock
View File

@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "alembic"
version = "1.16.2"
version = "1.16.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
@ -17,9 +17,9 @@ dependencies = [
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9c/35/116797ff14635e496bbda0c168987f5326a6555b09312e9b817e360d1f56/alembic-1.16.2.tar.gz", hash = "sha256:e53c38ff88dadb92eb22f8b150708367db731d58ad7e9d417c9168ab516cbed8", size = 1963563, upload-time = "2025-06-16T18:05:08.566Z" }
sdist = { url = "https://files.pythonhosted.org/packages/83/52/72e791b75c6b1efa803e491f7cbab78e963695e76d4ada05385252927e76/alembic-1.16.4.tar.gz", hash = "sha256:efab6ada0dd0fae2c92060800e0bf5c1dc26af15a10e02fb4babff164b4725e2", size = 1968161, upload-time = "2025-07-10T16:17:20.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/e2/88e425adac5ad887a087c38d04fe2030010572a3e0e627f8a6e8c33eeda8/alembic-1.16.2-py3-none-any.whl", hash = "sha256:5f42e9bd0afdbd1d5e3ad856c01754530367debdebf21ed6894e34af52b3bb03", size = 242717, upload-time = "2025-06-16T18:05:10.27Z" },
{ url = "https://files.pythonhosted.org/packages/c2/62/96b5217b742805236614f05904541000f55422a6060a90d7fd4ce26c172d/alembic-1.16.4-py3-none-any.whl", hash = "sha256:b05e51e8e82efc1abd14ba2af6392897e145930c3e0a2faf2b0da2f7f7fd660d", size = 247026, upload-time = "2025-07-10T16:17:21.845Z" },
]
[[package]]
@ -33,7 +33,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.55.0"
version = "0.58.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -44,9 +44,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/19/e2e09bc7fc0c4562ae865b3e5d487931c254c517e1c739b0c8aef2cf3186/anthropic-0.55.0.tar.gz", hash = "sha256:61826efa1bda0e4c7dc6f6a0d82b7d99b3fda970cd048d40ef5fca08a5eabd33", size = 408192, upload-time = "2025-06-23T18:52:26.27Z" }
sdist = { url = "https://files.pythonhosted.org/packages/95/b9/ab06c586aa5a5e7499017cee5ebab94ee260e75975c45395f32b8592abdd/anthropic-0.58.2.tar.gz", hash = "sha256:86396cc45530a83acea25ae6bca9f86656af81e3d598b4d22a1300e0e4cf8df8", size = 425125, upload-time = "2025-07-18T13:38:55.94Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/8f/ba982f539db40f49a610f61562e9b54fb9c85e7b9ede9a46ff6f9e79042f/anthropic-0.55.0-py3-none-any.whl", hash = "sha256:3518433fc0372a13f2b793b4cabecc7734ec9176e063a0f28dac19aa17c57f94", size = 289318, upload-time = "2025-06-23T18:52:24.478Z" },
{ url = "https://files.pythonhosted.org/packages/d0/f2/68d908ff308c9a65af5749ec31952e01d32f19ea073b0268affc616e6ebc/anthropic-0.58.2-py3-none-any.whl", hash = "sha256:3742181c634c725f337b71096839b6404145e33a8e190c75387c4028b825864d", size = 292896, upload-time = "2025-07-18T13:38:54.782Z" },
]
[[package]]
@ -82,16 +82,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
]
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "basedpyright"
version = "1.29.4"
version = "1.31.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/fb/bd92196a07e3b4ccee4ff2761a26a05bff77d4da089b67b4b1a547868099/basedpyright-1.29.4.tar.gz", hash = "sha256:2df1976f8591eedf4b4ce8f9d123f43e810cc8cb7cc83c53eec0e2f8044073d0", size = 21961481, upload-time = "2025-06-11T22:25:55.173Z" }
sdist = { url = "https://files.pythonhosted.org/packages/20/23/6dc0df43c62fdec401b1ec3aea698ba50c5abfca25259e9f0208b34d7abe/basedpyright-1.31.0.tar.gz", hash = "sha256:900a573a525a0f66f884075c2a98711bb9478e44dc60ffdf182ef681bf8e2c76", size = 22062384, upload-time = "2025-07-16T11:37:29.189Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/dc/180fe721a2574fb3aad4051adcca196ac2d18adaf75122f5eeb47436cca2/basedpyright-1.29.4-py3-none-any.whl", hash = "sha256:e087513979972f83010639c6c1a1c13dd3b1d24ee45f8ecff747962cc2063d6f", size = 11476859, upload-time = "2025-06-11T22:25:52.01Z" },
{ url = "https://files.pythonhosted.org/packages/5a/85/cb46707458c514ae959fe139135d8f7231d95faf1c383a56979a3436b965/basedpyright-1.31.0-py3-none-any.whl", hash = "sha256:d7460ddcd3a2332b1c3fd738735d18bf2966d49aed67237efa1f19635199d414", size = 11538999, upload-time = "2025-07-16T11:37:26.446Z" },
]
[[package]]
@ -105,11 +114,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2025.6.15"
version = "2025.7.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" },
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
]
[[package]]
@ -262,66 +271,66 @@ wheels = [
[[package]]
name = "coverage"
version = "7.9.1"
version = "7.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650, upload-time = "2025-06-13T13:02:28.627Z" }
sdist = { url = "https://files.pythonhosted.org/packages/04/b7/c0465ca253df10a9e8dae0692a4ae6e9726d245390aaef92360e1d6d3832/coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b", size = 813556, upload-time = "2025-07-03T10:54:15.101Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/78/1c1c5ec58f16817c09cbacb39783c3655d54a221b6552f47ff5ac9297603/coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca", size = 212028, upload-time = "2025-06-13T13:00:29.293Z" },
{ url = "https://files.pythonhosted.org/packages/98/db/e91b9076f3a888e3b4ad7972ea3842297a52cc52e73fd1e529856e473510/coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509", size = 212420, upload-time = "2025-06-13T13:00:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/0e/d0/2b3733412954576b0aea0a16c3b6b8fbe95eb975d8bfa10b07359ead4252/coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b", size = 241529, upload-time = "2025-06-13T13:00:35.786Z" },
{ url = "https://files.pythonhosted.org/packages/b3/00/5e2e5ae2e750a872226a68e984d4d3f3563cb01d1afb449a17aa819bc2c4/coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3", size = 239403, upload-time = "2025-06-13T13:00:37.399Z" },
{ url = "https://files.pythonhosted.org/packages/37/3b/a2c27736035156b0a7c20683afe7df498480c0dfdf503b8c878a21b6d7fb/coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3", size = 240548, upload-time = "2025-06-13T13:00:39.647Z" },
{ url = "https://files.pythonhosted.org/packages/98/f5/13d5fc074c3c0e0dc80422d9535814abf190f1254d7c3451590dc4f8b18c/coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5", size = 240459, upload-time = "2025-06-13T13:00:40.934Z" },
{ url = "https://files.pythonhosted.org/packages/36/24/24b9676ea06102df824c4a56ffd13dc9da7904478db519efa877d16527d5/coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187", size = 239128, upload-time = "2025-06-13T13:00:42.343Z" },
{ url = "https://files.pythonhosted.org/packages/be/05/242b7a7d491b369ac5fee7908a6e5ba42b3030450f3ad62c645b40c23e0e/coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce", size = 239402, upload-time = "2025-06-13T13:00:43.634Z" },
{ url = "https://files.pythonhosted.org/packages/73/e0/4de7f87192fa65c9c8fbaeb75507e124f82396b71de1797da5602898be32/coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70", size = 214518, upload-time = "2025-06-13T13:00:45.622Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ab/5e4e2fe458907d2a65fab62c773671cfc5ac704f1e7a9ddd91996f66e3c2/coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe", size = 215436, upload-time = "2025-06-13T13:00:47.245Z" },
{ url = "https://files.pythonhosted.org/packages/60/34/fa69372a07d0903a78ac103422ad34db72281c9fc625eba94ac1185da66f/coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582", size = 212146, upload-time = "2025-06-13T13:00:48.496Z" },
{ url = "https://files.pythonhosted.org/packages/27/f0/da1894915d2767f093f081c42afeba18e760f12fdd7a2f4acbe00564d767/coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86", size = 212536, upload-time = "2025-06-13T13:00:51.535Z" },
{ url = "https://files.pythonhosted.org/packages/10/d5/3fc33b06e41e390f88eef111226a24e4504d216ab8e5d1a7089aa5a3c87a/coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed", size = 245092, upload-time = "2025-06-13T13:00:52.883Z" },
{ url = "https://files.pythonhosted.org/packages/0a/39/7aa901c14977aba637b78e95800edf77f29f5a380d29768c5b66f258305b/coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d", size = 242806, upload-time = "2025-06-13T13:00:54.571Z" },
{ url = "https://files.pythonhosted.org/packages/43/fc/30e5cfeaf560b1fc1989227adedc11019ce4bb7cce59d65db34fe0c2d963/coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338", size = 244610, upload-time = "2025-06-13T13:00:56.932Z" },
{ url = "https://files.pythonhosted.org/packages/bf/15/cca62b13f39650bc87b2b92bb03bce7f0e79dd0bf2c7529e9fc7393e4d60/coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875", size = 244257, upload-time = "2025-06-13T13:00:58.545Z" },
{ url = "https://files.pythonhosted.org/packages/cd/1a/c0f2abe92c29e1464dbd0ff9d56cb6c88ae2b9e21becdb38bea31fcb2f6c/coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250", size = 242309, upload-time = "2025-06-13T13:00:59.836Z" },
{ url = "https://files.pythonhosted.org/packages/57/8d/c6fd70848bd9bf88fa90df2af5636589a8126d2170f3aade21ed53f2b67a/coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c", size = 242898, upload-time = "2025-06-13T13:01:02.506Z" },
{ url = "https://files.pythonhosted.org/packages/c2/9e/6ca46c7bff4675f09a66fe2797cd1ad6a24f14c9c7c3b3ebe0470a6e30b8/coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32", size = 214561, upload-time = "2025-06-13T13:01:04.012Z" },
{ url = "https://files.pythonhosted.org/packages/a1/30/166978c6302010742dabcdc425fa0f938fa5a800908e39aff37a7a876a13/coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125", size = 215493, upload-time = "2025-06-13T13:01:05.702Z" },
{ url = "https://files.pythonhosted.org/packages/60/07/a6d2342cd80a5be9f0eeab115bc5ebb3917b4a64c2953534273cf9bc7ae6/coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e", size = 213869, upload-time = "2025-06-13T13:01:09.345Z" },
{ url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336, upload-time = "2025-06-13T13:01:10.909Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571, upload-time = "2025-06-13T13:01:12.518Z" },
{ url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377, upload-time = "2025-06-13T13:01:14.87Z" },
{ url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394, upload-time = "2025-06-13T13:01:16.23Z" },
{ url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586, upload-time = "2025-06-13T13:01:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396, upload-time = "2025-06-13T13:01:19.164Z" },
{ url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577, upload-time = "2025-06-13T13:01:22.433Z" },
{ url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809, upload-time = "2025-06-13T13:01:24.143Z" },
{ url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724, upload-time = "2025-06-13T13:01:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535, upload-time = "2025-06-13T13:01:27.861Z" },
{ url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904, upload-time = "2025-06-13T13:01:29.202Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358, upload-time = "2025-06-13T13:01:30.909Z" },
{ url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620, upload-time = "2025-06-13T13:01:32.256Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788, upload-time = "2025-06-13T13:01:33.948Z" },
{ url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001, upload-time = "2025-06-13T13:01:35.285Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985, upload-time = "2025-06-13T13:01:36.712Z" },
{ url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152, upload-time = "2025-06-13T13:01:39.303Z" },
{ url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123, upload-time = "2025-06-13T13:01:40.727Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506, upload-time = "2025-06-13T13:01:42.184Z" },
{ url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766, upload-time = "2025-06-13T13:01:44.482Z" },
{ url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568, upload-time = "2025-06-13T13:01:45.772Z" },
{ url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939, upload-time = "2025-06-13T13:01:47.087Z" },
{ url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079, upload-time = "2025-06-13T13:01:48.554Z" },
{ url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299, upload-time = "2025-06-13T13:01:49.997Z" },
{ url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535, upload-time = "2025-06-13T13:01:51.314Z" },
{ url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756, upload-time = "2025-06-13T13:01:54.403Z" },
{ url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912, upload-time = "2025-06-13T13:01:56.769Z" },
{ url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144, upload-time = "2025-06-13T13:01:58.19Z" },
{ url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257, upload-time = "2025-06-13T13:01:59.645Z" },
{ url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094, upload-time = "2025-06-13T13:02:01.37Z" },
{ url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437, upload-time = "2025-06-13T13:02:02.905Z" },
{ url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605, upload-time = "2025-06-13T13:02:05.638Z" },
{ url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392, upload-time = "2025-06-13T13:02:07.642Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e5/c723545c3fd3204ebde3b4cc4b927dce709d3b6dc577754bb57f63ca4a4a/coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514", size = 204009, upload-time = "2025-06-13T13:02:25.787Z" },
{ url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000, upload-time = "2025-06-13T13:02:27.173Z" },
{ url = "https://files.pythonhosted.org/packages/a1/0d/5c2114fd776c207bd55068ae8dc1bef63ecd1b767b3389984a8e58f2b926/coverage-7.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:66283a192a14a3854b2e7f3418d7db05cdf411012ab7ff5db98ff3b181e1f912", size = 212039, upload-time = "2025-07-03T10:52:38.955Z" },
{ url = "https://files.pythonhosted.org/packages/cf/ad/dc51f40492dc2d5fcd31bb44577bc0cc8920757d6bc5d3e4293146524ef9/coverage-7.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e01d138540ef34fcf35c1aa24d06c3de2a4cffa349e29a10056544f35cca15f", size = 212428, upload-time = "2025-07-03T10:52:41.36Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a3/55cb3ff1b36f00df04439c3993d8529193cdf165a2467bf1402539070f16/coverage-7.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f22627c1fe2745ee98d3ab87679ca73a97e75ca75eb5faee48660d060875465f", size = 241534, upload-time = "2025-07-03T10:52:42.956Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c9/a8410b91b6be4f6e9c2e9f0dce93749b6b40b751d7065b4410bf89cb654b/coverage-7.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b1c2d8363247b46bd51f393f86c94096e64a1cf6906803fa8d5a9d03784bdbf", size = 239408, upload-time = "2025-07-03T10:52:44.199Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c4/6f3e56d467c612b9070ae71d5d3b114c0b899b5788e1ca3c93068ccb7018/coverage-7.9.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c10c882b114faf82dbd33e876d0cbd5e1d1ebc0d2a74ceef642c6152f3f4d547", size = 240552, upload-time = "2025-07-03T10:52:45.477Z" },
{ url = "https://files.pythonhosted.org/packages/fd/20/04eda789d15af1ce79bce5cc5fd64057c3a0ac08fd0576377a3096c24663/coverage-7.9.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de3c0378bdf7066c3988d66cd5232d161e933b87103b014ab1b0b4676098fa45", size = 240464, upload-time = "2025-07-03T10:52:46.809Z" },
{ url = "https://files.pythonhosted.org/packages/a9/5a/217b32c94cc1a0b90f253514815332d08ec0812194a1ce9cca97dda1cd20/coverage-7.9.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1e2f097eae0e5991e7623958a24ced3282676c93c013dde41399ff63e230fcf2", size = 239134, upload-time = "2025-07-03T10:52:48.149Z" },
{ url = "https://files.pythonhosted.org/packages/34/73/1d019c48f413465eb5d3b6898b6279e87141c80049f7dbf73fd020138549/coverage-7.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28dc1f67e83a14e7079b6cea4d314bc8b24d1aed42d3582ff89c0295f09b181e", size = 239405, upload-time = "2025-07-03T10:52:49.687Z" },
{ url = "https://files.pythonhosted.org/packages/49/6c/a2beca7aa2595dad0c0d3f350382c381c92400efe5261e2631f734a0e3fe/coverage-7.9.2-cp310-cp310-win32.whl", hash = "sha256:bf7d773da6af9e10dbddacbf4e5cab13d06d0ed93561d44dae0188a42c65be7e", size = 214519, upload-time = "2025-07-03T10:52:51.036Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c8/91e5e4a21f9a51e2c7cdd86e587ae01a4fcff06fc3fa8cde4d6f7cf68df4/coverage-7.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:0c0378ba787681ab1897f7c89b415bd56b0b2d9a47e5a3d8dc0ea55aac118d6c", size = 215400, upload-time = "2025-07-03T10:52:52.313Z" },
{ url = "https://files.pythonhosted.org/packages/39/40/916786453bcfafa4c788abee4ccd6f592b5b5eca0cd61a32a4e5a7ef6e02/coverage-7.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a7a56a2964a9687b6aba5b5ced6971af308ef6f79a91043c05dd4ee3ebc3e9ba", size = 212152, upload-time = "2025-07-03T10:52:53.562Z" },
{ url = "https://files.pythonhosted.org/packages/9f/66/cc13bae303284b546a030762957322bbbff1ee6b6cb8dc70a40f8a78512f/coverage-7.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123d589f32c11d9be7fe2e66d823a236fe759b0096f5db3fb1b75b2fa414a4fa", size = 212540, upload-time = "2025-07-03T10:52:55.196Z" },
{ url = "https://files.pythonhosted.org/packages/0f/3c/d56a764b2e5a3d43257c36af4a62c379df44636817bb5f89265de4bf8bd7/coverage-7.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:333b2e0ca576a7dbd66e85ab402e35c03b0b22f525eed82681c4b866e2e2653a", size = 245097, upload-time = "2025-07-03T10:52:56.509Z" },
{ url = "https://files.pythonhosted.org/packages/b1/46/bd064ea8b3c94eb4ca5d90e34d15b806cba091ffb2b8e89a0d7066c45791/coverage-7.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:326802760da234baf9f2f85a39e4a4b5861b94f6c8d95251f699e4f73b1835dc", size = 242812, upload-time = "2025-07-03T10:52:57.842Z" },
{ url = "https://files.pythonhosted.org/packages/43/02/d91992c2b29bc7afb729463bc918ebe5f361be7f1daae93375a5759d1e28/coverage-7.9.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19e7be4cfec248df38ce40968c95d3952fbffd57b400d4b9bb580f28179556d2", size = 244617, upload-time = "2025-07-03T10:52:59.239Z" },
{ url = "https://files.pythonhosted.org/packages/b7/4f/8fadff6bf56595a16d2d6e33415841b0163ac660873ed9a4e9046194f779/coverage-7.9.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b4a4cb73b9f2b891c1788711408ef9707666501ba23684387277ededab1097c", size = 244263, upload-time = "2025-07-03T10:53:00.601Z" },
{ url = "https://files.pythonhosted.org/packages/9b/d2/e0be7446a2bba11739edb9f9ba4eff30b30d8257370e237418eb44a14d11/coverage-7.9.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2c8937fa16c8c9fbbd9f118588756e7bcdc7e16a470766a9aef912dd3f117dbd", size = 242314, upload-time = "2025-07-03T10:53:01.932Z" },
{ url = "https://files.pythonhosted.org/packages/9d/7d/dcbac9345000121b8b57a3094c2dfcf1ccc52d8a14a40c1d4bc89f936f80/coverage-7.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42da2280c4d30c57a9b578bafd1d4494fa6c056d4c419d9689e66d775539be74", size = 242904, upload-time = "2025-07-03T10:53:03.478Z" },
{ url = "https://files.pythonhosted.org/packages/41/58/11e8db0a0c0510cf31bbbdc8caf5d74a358b696302a45948d7c768dfd1cf/coverage-7.9.2-cp311-cp311-win32.whl", hash = "sha256:14fa8d3da147f5fdf9d298cacc18791818f3f1a9f542c8958b80c228320e90c6", size = 214553, upload-time = "2025-07-03T10:53:05.174Z" },
{ url = "https://files.pythonhosted.org/packages/3a/7d/751794ec8907a15e257136e48dc1021b1f671220ecccfd6c4eaf30802714/coverage-7.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:549cab4892fc82004f9739963163fd3aac7a7b0df430669b75b86d293d2df2a7", size = 215441, upload-time = "2025-07-03T10:53:06.472Z" },
{ url = "https://files.pythonhosted.org/packages/62/5b/34abcedf7b946c1c9e15b44f326cb5b0da852885312b30e916f674913428/coverage-7.9.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2667a2b913e307f06aa4e5677f01a9746cd08e4b35e14ebcde6420a9ebb4c62", size = 213873, upload-time = "2025-07-03T10:53:07.699Z" },
{ url = "https://files.pythonhosted.org/packages/53/d7/7deefc6fd4f0f1d4c58051f4004e366afc9e7ab60217ac393f247a1de70a/coverage-7.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae9eb07f1cfacd9cfe8eaee6f4ff4b8a289a668c39c165cd0c8548484920ffc0", size = 212344, upload-time = "2025-07-03T10:53:09.3Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/ee03c95d32be4d519e6a02e601267769ce2e9a91fc8faa1b540e3626c680/coverage-7.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ce85551f9a1119f02adc46d3014b5ee3f765deac166acf20dbb851ceb79b6f3", size = 212580, upload-time = "2025-07-03T10:53:11.52Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9f/826fa4b544b27620086211b87a52ca67592622e1f3af9e0a62c87aea153a/coverage-7.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8f6389ac977c5fb322e0e38885fbbf901743f79d47f50db706e7644dcdcb6e1", size = 246383, upload-time = "2025-07-03T10:53:13.134Z" },
{ url = "https://files.pythonhosted.org/packages/7f/b3/4477aafe2a546427b58b9c540665feff874f4db651f4d3cb21b308b3a6d2/coverage-7.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d9eae8cdfcd58fe7893b88993723583a6ce4dfbfd9f29e001922544f95615", size = 243400, upload-time = "2025-07-03T10:53:14.614Z" },
{ url = "https://files.pythonhosted.org/packages/f8/c2/efffa43778490c226d9d434827702f2dfbc8041d79101a795f11cbb2cf1e/coverage-7.9.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae939811e14e53ed8a9818dad51d434a41ee09df9305663735f2e2d2d7d959b", size = 245591, upload-time = "2025-07-03T10:53:15.872Z" },
{ url = "https://files.pythonhosted.org/packages/c6/e7/a59888e882c9a5f0192d8627a30ae57910d5d449c80229b55e7643c078c4/coverage-7.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:31991156251ec202c798501e0a42bbdf2169dcb0f137b1f5c0f4267f3fc68ef9", size = 245402, upload-time = "2025-07-03T10:53:17.124Z" },
{ url = "https://files.pythonhosted.org/packages/92/a5/72fcd653ae3d214927edc100ce67440ed8a0a1e3576b8d5e6d066ed239db/coverage-7.9.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d0d67963f9cbfc7c7f96d4ac74ed60ecbebd2ea6eeb51887af0f8dce205e545f", size = 243583, upload-time = "2025-07-03T10:53:18.781Z" },
{ url = "https://files.pythonhosted.org/packages/5c/f5/84e70e4df28f4a131d580d7d510aa1ffd95037293da66fd20d446090a13b/coverage-7.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49b752a2858b10580969ec6af6f090a9a440a64a301ac1528d7ca5f7ed497f4d", size = 244815, upload-time = "2025-07-03T10:53:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/39/e7/d73d7cbdbd09fdcf4642655ae843ad403d9cbda55d725721965f3580a314/coverage-7.9.2-cp312-cp312-win32.whl", hash = "sha256:88d7598b8ee130f32f8a43198ee02edd16d7f77692fa056cb779616bbea1b355", size = 214719, upload-time = "2025-07-03T10:53:21.521Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d6/7486dcc3474e2e6ad26a2af2db7e7c162ccd889c4c68fa14ea8ec189c9e9/coverage-7.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:9dfb070f830739ee49d7c83e4941cc767e503e4394fdecb3b54bfdac1d7662c0", size = 215509, upload-time = "2025-07-03T10:53:22.853Z" },
{ url = "https://files.pythonhosted.org/packages/b7/34/0439f1ae2593b0346164d907cdf96a529b40b7721a45fdcf8b03c95fcd90/coverage-7.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:4e2c058aef613e79df00e86b6d42a641c877211384ce5bd07585ed7ba71ab31b", size = 213910, upload-time = "2025-07-03T10:53:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/94/9d/7a8edf7acbcaa5e5c489a646226bed9591ee1c5e6a84733c0140e9ce1ae1/coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038", size = 212367, upload-time = "2025-07-03T10:53:25.811Z" },
{ url = "https://files.pythonhosted.org/packages/e8/9e/5cd6f130150712301f7e40fb5865c1bc27b97689ec57297e568d972eec3c/coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d", size = 212632, upload-time = "2025-07-03T10:53:27.075Z" },
{ url = "https://files.pythonhosted.org/packages/a8/de/6287a2c2036f9fd991c61cefa8c64e57390e30c894ad3aa52fac4c1e14a8/coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3", size = 245793, upload-time = "2025-07-03T10:53:28.408Z" },
{ url = "https://files.pythonhosted.org/packages/06/cc/9b5a9961d8160e3cb0b558c71f8051fe08aa2dd4b502ee937225da564ed1/coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14", size = 243006, upload-time = "2025-07-03T10:53:29.754Z" },
{ url = "https://files.pythonhosted.org/packages/49/d9/4616b787d9f597d6443f5588619c1c9f659e1f5fc9eebf63699eb6d34b78/coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6", size = 244990, upload-time = "2025-07-03T10:53:31.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/83/801cdc10f137b2d02b005a761661649ffa60eb173dcdaeb77f571e4dc192/coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b", size = 245157, upload-time = "2025-07-03T10:53:32.717Z" },
{ url = "https://files.pythonhosted.org/packages/c8/a4/41911ed7e9d3ceb0ffb019e7635468df7499f5cc3edca5f7dfc078e9c5ec/coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d", size = 243128, upload-time = "2025-07-03T10:53:34.009Z" },
{ url = "https://files.pythonhosted.org/packages/10/41/344543b71d31ac9cb00a664d5d0c9ef134a0fe87cb7d8430003b20fa0b7d/coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868", size = 244511, upload-time = "2025-07-03T10:53:35.434Z" },
{ url = "https://files.pythonhosted.org/packages/d5/81/3b68c77e4812105e2a060f6946ba9e6f898ddcdc0d2bfc8b4b152a9ae522/coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a", size = 214765, upload-time = "2025-07-03T10:53:36.787Z" },
{ url = "https://files.pythonhosted.org/packages/06/a2/7fac400f6a346bb1a4004eb2a76fbff0e242cd48926a2ce37a22a6a1d917/coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b", size = 215536, upload-time = "2025-07-03T10:53:38.188Z" },
{ url = "https://files.pythonhosted.org/packages/08/47/2c6c215452b4f90d87017e61ea0fd9e0486bb734cb515e3de56e2c32075f/coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694", size = 213943, upload-time = "2025-07-03T10:53:39.492Z" },
{ url = "https://files.pythonhosted.org/packages/a3/46/e211e942b22d6af5e0f323faa8a9bc7c447a1cf1923b64c47523f36ed488/coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5", size = 213088, upload-time = "2025-07-03T10:53:40.874Z" },
{ url = "https://files.pythonhosted.org/packages/d2/2f/762551f97e124442eccd907bf8b0de54348635b8866a73567eb4e6417acf/coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b", size = 213298, upload-time = "2025-07-03T10:53:42.218Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b7/76d2d132b7baf7360ed69be0bcab968f151fa31abe6d067f0384439d9edb/coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3", size = 256541, upload-time = "2025-07-03T10:53:43.823Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/392b219837d7ad47d8e5974ce5f8dc3deb9f99a53b3bd4d123602f960c81/coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8", size = 252761, upload-time = "2025-07-03T10:53:45.19Z" },
{ url = "https://files.pythonhosted.org/packages/d5/77/4256d3577fe1b0daa8d3836a1ebe68eaa07dd2cbaf20cf5ab1115d6949d4/coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46", size = 254917, upload-time = "2025-07-03T10:53:46.931Z" },
{ url = "https://files.pythonhosted.org/packages/53/99/fc1a008eef1805e1ddb123cf17af864743354479ea5129a8f838c433cc2c/coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584", size = 256147, upload-time = "2025-07-03T10:53:48.289Z" },
{ url = "https://files.pythonhosted.org/packages/92/c0/f63bf667e18b7f88c2bdb3160870e277c4874ced87e21426128d70aa741f/coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e", size = 254261, upload-time = "2025-07-03T10:53:49.99Z" },
{ url = "https://files.pythonhosted.org/packages/8c/32/37dd1c42ce3016ff8ec9e4b607650d2e34845c0585d3518b2a93b4830c1a/coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac", size = 255099, upload-time = "2025-07-03T10:53:51.354Z" },
{ url = "https://files.pythonhosted.org/packages/da/2e/af6b86f7c95441ce82f035b3affe1cd147f727bbd92f563be35e2d585683/coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926", size = 215440, upload-time = "2025-07-03T10:53:52.808Z" },
{ url = "https://files.pythonhosted.org/packages/4d/bb/8a785d91b308867f6b2e36e41c569b367c00b70c17f54b13ac29bcd2d8c8/coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd", size = 216537, upload-time = "2025-07-03T10:53:54.273Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a0/a6bffb5e0f41a47279fd45a8f3155bf193f77990ae1c30f9c224b61cacb0/coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb", size = 214398, upload-time = "2025-07-03T10:53:56.715Z" },
{ url = "https://files.pythonhosted.org/packages/d7/85/f8bbefac27d286386961c25515431482a425967e23d3698b75a250872924/coverage-7.9.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:8a1166db2fb62473285bcb092f586e081e92656c7dfa8e9f62b4d39d7e6b5050", size = 204013, upload-time = "2025-07-03T10:54:12.084Z" },
{ url = "https://files.pythonhosted.org/packages/3c/38/bbe2e63902847cf79036ecc75550d0698af31c91c7575352eb25190d0fb3/coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4", size = 204005, upload-time = "2025-07-03T10:54:13.491Z" },
]
[package.optional-dependencies]
@ -405,11 +414,11 @@ wheels = [
[[package]]
name = "docstring-parser"
version = "0.16"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/08/12/9c22a58c0b1e29271051222d8906257616da84135af9ed167c9e28f85cb3/docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e", size = 26565, upload-time = "2024-03-15T10:39:44.419Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533, upload-time = "2024-03-15T10:39:41.527Z" },
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
[[package]]
@ -439,16 +448,16 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.115.14"
version = "0.116.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" }
sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" },
{ url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" },
]
[package.optional-dependencies]
@ -463,23 +472,42 @@ standard = [
[[package]]
name = "fastapi-cli"
version = "0.0.7"
version = "0.0.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "rich-toolkit" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753, upload-time = "2024-12-15T14:28:10.028Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/94/3ef75d9c7c32936ecb539b9750ccbdc3d2568efd73b1cb913278375f4533/fastapi_cli-0.0.8.tar.gz", hash = "sha256:2360f2989b1ab4a3d7fc8b3a0b20e8288680d8af2e31de7c38309934d7f8a0ee", size = 16884, upload-time = "2025-07-07T14:44:09.326Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705, upload-time = "2024-12-15T14:28:06.18Z" },
{ url = "https://files.pythonhosted.org/packages/e0/3f/6ad3103c5f59208baf4c798526daea6a74085bb35d1c161c501863470476/fastapi_cli-0.0.8-py3-none-any.whl", hash = "sha256:0ea95d882c85b9219a75a65ab27e8da17dac02873e456850fa0a726e96e985eb", size = 10770, upload-time = "2025-07-07T14:44:08.255Z" },
]
[package.optional-dependencies]
standard = [
{ name = "fastapi-cloud-cli" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cloud-cli"
version = "0.1.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic", extra = ["email"] },
{ name = "rich-toolkit" },
{ name = "rignore" },
{ name = "sentry-sdk" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/d7/4a987c3d73ddae4a7c93f5d2982ea5b1dd58d4cc1044568bb180227bd0f7/fastapi_cloud_cli-0.1.4.tar.gz", hash = "sha256:a0ab7633d71d864b4041896b3fe2f462de61546db7c52eb13e963f4d40af0eba", size = 22712, upload-time = "2025-07-11T14:15:25.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/cf/8635cd778b7d89714325b967a28c05865a2b6cab4c0b4b30561df4704f24/fastapi_cloud_cli-0.1.4-py3-none-any.whl", hash = "sha256:1db1ba757aa46a16a5e5dacf7cddc137ca0a3c42f65dba2b1cc6a8f24c41be42", size = 18957, upload-time = "2025-07-11T14:15:24.451Z" },
]
[[package]]
name = "fastapi-pagination"
version = "0.13.3"
@ -585,7 +613,7 @@ wheels = [
[[package]]
name = "google-genai"
version = "1.22.0"
version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -597,9 +625,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/37/98742eeae25556d7558f336f9cdbb8e7276d32a5699b03cabc3ffa9f12ea/google_genai-1.22.0.tar.gz", hash = "sha256:1ece195e7be97cb94dbecce43dd88e3f4e376afd31045e54d1dd0ef272a6ee6b", size = 221720, upload-time = "2025-06-26T00:09:27.666Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4a/6e/d9618081990ad7c4907c93fcccacb13081e825ca818e9e18618f91050246/google_genai-1.26.0.tar.gz", hash = "sha256:d7b019ac98ca07888caa6121a953eb65db20f78370d8ae06aec29fb534534dc8", size = 218877, upload-time = "2025-07-16T21:51:46.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/fa/ad39a0457a9c3e21438062076cc216d41c4f8b414aa3d2ec481c721ca5f7/google_genai-1.22.0-py3-none-any.whl", hash = "sha256:6627bea9451775a2af78c6cb1992f5a31b90c50d64fb1f1435a385737a69fce4", size = 222848, upload-time = "2025-06-26T00:09:25.955Z" },
{ url = "https://files.pythonhosted.org/packages/50/7d/201894058552d5ed810930f9483bf6be8650e3d599efab180d0510d0eea1/google_genai-1.26.0-py3-none-any.whl", hash = "sha256:a050de052ee6e68654ba7cdb97028a576ad7108d0ecc9257c69bcc555498e9a2", size = 217693, upload-time = "2025-07-16T21:51:45.797Z" },
]
[[package]]
@ -685,7 +713,7 @@ wheels = [
[[package]]
name = "groq"
version = "0.29.0"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -695,9 +723,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/d6/db0c61bad6ff6a2ba7083a943c62790f37969cb337d96a5a914cd3ac4311/groq-0.29.0.tar.gz", hash = "sha256:109dc4d696c05d44e4c2cd157652c4c6600c3e96f093f6e158facb5691e37847", size = 133989, upload-time = "2025-06-25T23:40:11.508Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/b1/72ca20dc9b977b7f604648e8944c77b267bddeb90d8e16bda0cf0e397844/groq-0.30.0.tar.gz", hash = "sha256:919466e48fcbebef08fed3f71debb0f96b0ea8d2ec77842c384aa843019f6e2c", size = 134928, upload-time = "2025-07-11T20:28:36.583Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/0b/ef7a92ec5ec23a7012975ed59ca3cef541d50a9f0d2dea947fe2723d011f/groq-0.29.0-py3-none-any.whl", hash = "sha256:03515ec46be1ef1feef0cd9d876b6f30a39ee2742e76516153d84acd7c97f23a", size = 130814, upload-time = "2025-06-25T23:40:10.391Z" },
{ url = "https://files.pythonhosted.org/packages/19/b8/5b90edf9fbd795597220e3d1b5534d845e69a73ffe1fdeb967443ed2a6cf/groq-0.30.0-py3-none-any.whl", hash = "sha256:6d9609a7778ba56432f45c1bac21b005f02c6c0aca9c1c094e65536f162c1e83", size = 131056, upload-time = "2025-07-11T20:28:35.591Z" },
]
[[package]]
@ -773,7 +801,7 @@ wheels = [
[[package]]
name = "honcho"
version = "2.1.0"
version = "2.1.1"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
@ -802,6 +830,7 @@ dependencies = [
dev = [
{ name = "basedpyright" },
{ name = "coverage" },
{ name = "honcho-ai" },
{ name = "honcho-core" },
{ name = "interrogate" },
{ name = "pre-commit" },
@ -841,6 +870,7 @@ requires-dist = [
dev = [
{ name = "basedpyright", specifier = ">=1.29.4" },
{ name = "coverage", specifier = ">=7.6.0" },
{ name = "honcho-ai", specifier = ">=1.2.1" },
{ name = "honcho-core", specifier = ">=1.2.0" },
{ name = "interrogate", specifier = ">=1.7.0" },
{ name = "pre-commit", specifier = ">=4.2.0" },
@ -852,6 +882,20 @@ dev = [
{ name = "sqlalchemy-utils", specifier = ">=0.41.2" },
]
[[package]]
name = "honcho-ai"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "honcho-core" },
{ name = "httpx" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/8f/e9e2bcad15eafc5e1d2ba5960dfc6790917eda2fde6cf33091bc20175a4b/honcho_ai-1.2.2.tar.gz", hash = "sha256:dd96b6641b16ebbcf0250f0e811632bf6f15640f0a5d9d5341e07ad99b53c25e", size = 24273, upload-time = "2025-07-21T17:39:26.452Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/6c/d8a465700dcd1bcc8ef400df9fa7e3cd2ecb1aab8225b6a9f04616fad91f/honcho_ai-1.2.2-py3-none-any.whl", hash = "sha256:c97ef2678bd522e470bedd4cfee17735a063e545346779498ec15b591ab23158", size = 30903, upload-time = "2025-07-21T17:39:25.36Z" },
]
[[package]]
name = "honcho-core"
version = "1.2.0"
@ -1075,7 +1119,7 @@ wheels = [
[[package]]
name = "langfuse"
version = "2.60.8"
version = "2.60.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -1087,9 +1131,9 @@ dependencies = [
{ name = "requests" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/b8/1928d39799370d8030f067af5010318b147753fa330c068b3958b233cdeb/langfuse-2.60.8.tar.gz", hash = "sha256:0125841e8503b9ce1f405ab95411a7af2c244106e70eba99726c1f1a743cd164", size = 152659, upload-time = "2025-06-03T15:39:46.21Z" }
sdist = { url = "https://files.pythonhosted.org/packages/06/1a/2443e3715767f1bf9d8cf32d74ac59cfb60e1d9b84e99df13fd656639eb3/langfuse-2.60.9.tar.gz", hash = "sha256:040753346d7df4a0be6967dfc7efe3de313fee362524fe2f801867fcbbca3c98", size = 152684, upload-time = "2025-06-29T09:39:27.628Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/a0/566955928c599cb78bccb72f76c5a7b6d56cff723bc841c4e4806b48d2d0/langfuse-2.60.8-py3-none-any.whl", hash = "sha256:072b050ae4c20f81556b9c4c751359bc76de70970636225b32760d1bd4b93042", size = 275496, upload-time = "2025-06-03T15:39:44.023Z" },
{ url = "https://files.pythonhosted.org/packages/20/50/3aa93fc284ba5f81dcdd00b6414caee338fd45d77fa4959c3e4f838cebc6/langfuse-2.60.9-py3-none-any.whl", hash = "sha256:e4291a66bc579c66d7652da5603ca7f0409536700d7b812e396780b5d9a0685d", size = 275543, upload-time = "2025-06-29T09:39:26.234Z" },
]
[[package]]
@ -1185,7 +1229,7 @@ wheels = [
[[package]]
name = "mirascope"
version = "1.25.2"
version = "1.25.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docstring-parser" },
@ -1193,9 +1237,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/e7/4a196658d3e9ffe5840872922e0ea5e9f583bc3cb204c769e55347e142c1/mirascope-1.25.2.tar.gz", hash = "sha256:88df7232c8b41150e0f788371ba5657c08c27eb544dec93aa4d8354adf04f4be", size = 627149, upload-time = "2025-06-23T22:16:54.262Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/09/87a2227998cdcbe189a6444463d4e25edff210237caab05d60e7004ccaf7/mirascope-1.25.4.tar.gz", hash = "sha256:6316e988aa7286772488346dfd109b9a23796df162d426d676f74c29cd8653e5", size = 627260, upload-time = "2025-07-07T20:47:12.223Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/1d/cf7f99294c19552f4a80ba092d8b883ab34161f7030c66c38a5c43e27c0d/mirascope-1.25.2-py3-none-any.whl", hash = "sha256:9d96bec0bcb7fa3dd767803d978b71167eb1fd657be865b9f3bfbd23f24d2187", size = 373194, upload-time = "2025-06-23T22:16:50.693Z" },
{ url = "https://files.pythonhosted.org/packages/6b/97/8d19eec6156dd02d2af384263426aa9f7820827af4830b6f8177e37f0af8/mirascope-1.25.4-py3-none-any.whl", hash = "sha256:ad724539f0307a8ea98425fd285969b608b62aee824967ed2a134147cb43bbf4", size = 373214, upload-time = "2025-07-07T20:47:10.057Z" },
]
[package.optional-dependencies]
@ -1234,18 +1278,18 @@ wheels = [
[[package]]
name = "nodejs-wheel-binaries"
version = "22.16.0"
version = "22.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/c6/66f36b7b0d528660dfb4a59cb9b8dd6a3f4c0a3939cd49c404a775ea4a63/nodejs_wheel_binaries-22.16.0.tar.gz", hash = "sha256:d695832f026df3a0cf9a089d222225939de9d1b67f8f0a353b79f015aabbe7e2", size = 8061, upload-time = "2025-05-22T07:27:52.149Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/86/8962d1d24ff480f4dd31871f42c8e0d8e2c851cd558a07ee689261d310ab/nodejs_wheel_binaries-22.17.0.tar.gz", hash = "sha256:529142012fb8fd20817ef70e2ef456274df4f49933292e312c8bbc7285af6408", size = 8068, upload-time = "2025-06-29T20:24:25.002Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/dc/417a5c5f99e53a5d2b3be122506312731eb90fb9630c248e327e2e38cc6b/nodejs_wheel_binaries-22.16.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:986b715a96ed703f8ce0c15712f76fc42895cf09067d72b6ef29e8b334eccf64", size = 50957501, upload-time = "2025-05-22T07:27:20.132Z" },
{ url = "https://files.pythonhosted.org/packages/0e/dd/d6ce48209ed15f5d1fccb29eeaa111f962557123eaf4fd03a7316c42734c/nodejs_wheel_binaries-22.16.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:4ae3cf22138891cb44c3ee952862a257ce082b098b29024d7175684a9a77b0c0", size = 51891634, upload-time = "2025-05-22T07:27:24.029Z" },
{ url = "https://files.pythonhosted.org/packages/80/fa/a07e622fd87717eec3e5cff41575f85ad62717e8698884d28ca809266ca1/nodejs_wheel_binaries-22.16.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71f2de4dc0b64ae43e146897ce811f80ac4f9acfbae6ccf814226282bf4ef174", size = 57857862, upload-time = "2025-05-22T07:27:27.933Z" },
{ url = "https://files.pythonhosted.org/packages/1f/80/52736f9570a93f8e6b7942981dc9770eca2bc7aa1d200c1d54198374a6ca/nodejs_wheel_binaries-22.16.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbfccbcd558d2f142ccf66d8c3a098022bf4436db9525b5b8d32169ce185d99e", size = 58395868, upload-time = "2025-05-22T07:27:32.088Z" },
{ url = "https://files.pythonhosted.org/packages/0f/0e/53616a5ed8fc1fbe9e48bf132862da5a9abf5cc7f8483dab1722ec257187/nodejs_wheel_binaries-22.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:447ad796850eb52ca20356ad39b2d296ed8fef3f214921f84a1ccdad49f2eba1", size = 59712469, upload-time = "2025-05-22T07:27:37.193Z" },
{ url = "https://files.pythonhosted.org/packages/4a/cd/e2b5083df581fc1d08eb93feb6f8fbd3d56b113cef9b59d8e0fb7d4dd4f3/nodejs_wheel_binaries-22.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7f526ca6a132b0caf633566a2a78c6985fe92857e7bfdb37380f76205a10b808", size = 60763005, upload-time = "2025-05-22T07:27:41.39Z" },
{ url = "https://files.pythonhosted.org/packages/71/8d/57112b49214e8bd636f3cc3386eba6be4d23552ec8a0f6efbe814013caa7/nodejs_wheel_binaries-22.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:2fffb4bf1066fb5f660da20819d754f1b424bca1b234ba0f4fa901c52e3975fb", size = 41313324, upload-time = "2025-05-22T07:27:45.293Z" },
{ url = "https://files.pythonhosted.org/packages/91/03/a852711aec73dfb965844592dfe226024c0da28e37d1ee54083342e38f57/nodejs_wheel_binaries-22.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:2728972d336d436d39ee45988978d8b5d963509e06f063e80fe41b203ee80b28", size = 38828154, upload-time = "2025-05-22T07:27:48.606Z" },
{ url = "https://files.pythonhosted.org/packages/5d/53/b942c6da4ff6f87a315033f6ff6fed8fd3c22047d7ff5802badaa5dfc2c2/nodejs_wheel_binaries-22.17.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:6545a6f6d2f736d9c9e2eaad7e599b6b5b2d8fd4cbd2a1df0807cbcf51b9d39b", size = 51003554, upload-time = "2025-06-29T20:23:47.042Z" },
{ url = "https://files.pythonhosted.org/packages/e2/b7/7184a9ad2364912da22f2fe021dc4a3301721131ef7759aeb4a1f19db0b4/nodejs_wheel_binaries-22.17.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:4bea5b994dd87c20f8260031ea69a97c3d282e2d4472cc8908636a313a830d00", size = 51936848, upload-time = "2025-06-29T20:23:52.064Z" },
{ url = "https://files.pythonhosted.org/packages/e9/7a/0ea425147b8110b8fd65a6c21cfd3bd130cdec7766604361429ef870d799/nodejs_wheel_binaries-22.17.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:885508615274a22499dd5314759c1cf96ba72de03e6485d73b3e5475e7f12662", size = 57925230, upload-time = "2025-06-29T20:23:56.81Z" },
{ url = "https://files.pythonhosted.org/packages/23/5f/10a3f2ac08a839d065d9ccfd6d9df66bc46e100eaf87a8a5cf149eb3fb8e/nodejs_wheel_binaries-22.17.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f38ce034a602bcab534d55cbe0390521e73e5dcffdd1c4b34354b932172af2", size = 58457829, upload-time = "2025-06-29T20:24:01.945Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a4/d2ca331e16eef0974eb53702df603c54f77b2a7e2007523ecdbf6cf61162/nodejs_wheel_binaries-22.17.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5eed087855b644c87001fe04036213193963ccd65e7f89949e9dbe28e7743d9b", size = 59778054, upload-time = "2025-06-29T20:24:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/be/2b/04e0e7f7305fe2ba30fd4610bfb432516e0f65379fe6c2902f4b7b1ad436/nodejs_wheel_binaries-22.17.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:715f413c81500f0770ea8936ef1fc2529b900da8054cbf6da67cec3ee308dc76", size = 60830079, upload-time = "2025-06-29T20:24:12.21Z" },
{ url = "https://files.pythonhosted.org/packages/ce/67/12070b24b88040c2d694883f3dcb067052f748798f4c63f7c865769a5747/nodejs_wheel_binaries-22.17.0-py2.py3-none-win_amd64.whl", hash = "sha256:51165630493c8dd4acfe1cae1684b76940c9b03f7f355597d55e2d056a572ddd", size = 40117877, upload-time = "2025-06-29T20:24:17.51Z" },
{ url = "https://files.pythonhosted.org/packages/2e/ec/53ac46af423527c23e40c7343189f2bce08a8337efedef4d8a33392cee23/nodejs_wheel_binaries-22.17.0-py2.py3-none-win_arm64.whl", hash = "sha256:fae56d172227671fccb04461d3cd2b26a945c6c7c7fc29edb8618876a39d8b4a", size = 38865278, upload-time = "2025-06-29T20:24:21.065Z" },
]
[[package]]
@ -1377,7 +1421,7 @@ wheels = [
[[package]]
name = "openai"
version = "1.92.3"
version = "1.97.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -1389,9 +1433,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/bd/6c123e53d6727dc39086346df1161c8b6bb50e4cc0f2e9075cae909049b6/openai-1.92.3.tar.gz", hash = "sha256:6b707bc926a9fffab262dbd1eb4be29c36d627b9012633a66ccdbe2d615a9532", size = 485647, upload-time = "2025-06-27T17:06:55.96Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/57/1c471f6b3efb879d26686d31582997615e969f3bb4458111c9705e56332e/openai-1.97.1.tar.gz", hash = "sha256:a744b27ae624e3d4135225da9b1c89c107a2a7e5bc4c93e5b7b5214772ce7a4e", size = 494267, upload-time = "2025-07-22T13:10:12.607Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/60/4956baa677a569a247336c7edff4b3b6aab7a54119e2342d391d1d370446/openai-1.92.3-py3-none-any.whl", hash = "sha256:979f00c097d023a28f22c63373a646a675e4b9e1fad586e2424bf7274a2689f2", size = 753350, upload-time = "2025-06-27T17:06:53.547Z" },
{ url = "https://files.pythonhosted.org/packages/ee/35/412a0e9c3f0d37c94ed764b8ac7adae2d834dbd20e69f6aca582118e0f55/openai-1.97.1-py3-none-any.whl", hash = "sha256:4e96bbdf672ec3d44968c9ea39d2c375891db1acc1794668d8149d5fa6000606", size = 764380, upload-time = "2025-07-22T13:10:10.689Z" },
]
[[package]]
@ -1700,6 +1744,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
@ -1830,22 +1879,22 @@ wheels = [
[[package]]
name = "pypdfium2"
version = "4.30.1"
version = "4.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/55/d4/905e621c62598a08168c272b42fc00136c8861cfce97afb2a1ecbd99487a/pypdfium2-4.30.1.tar.gz", hash = "sha256:5f5c7c6d03598e107d974f66b220a49436aceb191da34cda5f692be098a814ce", size = 164854, upload-time = "2024-12-19T19:28:11.459Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/14/838b3ba247a0ba92e4df5d23f2bea9478edcfd72b78a39d6ca36ccd84ad2/pypdfium2-4.30.0.tar.gz", hash = "sha256:48b5b7e5566665bc1015b9d69c1ebabe21f6aee468b509531c3c8318eeee2e16", size = 140239, upload-time = "2024-05-09T18:33:17.552Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/8e/3ce0856b3af0f058dd3655ce57d31d1dbde4d4bd0e172022ffbf1b58a4b9/pypdfium2-4.30.1-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:e07c47633732cc18d890bb7e965ad28a9c5a932e548acb928596f86be2e5ae37", size = 2889836, upload-time = "2024-12-19T19:27:39.531Z" },
{ url = "https://files.pythonhosted.org/packages/c2/6a/f6995b21f9c6c155487ce7df70632a2df1ba49efcb291b9943ea45f28b15/pypdfium2-4.30.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5ea2d44e96d361123b67b00f527017aa9c847c871b5714e013c01c3eb36a79fe", size = 2769232, upload-time = "2024-12-19T19:27:43.227Z" },
{ url = "https://files.pythonhosted.org/packages/53/91/79060923148e6d380b8a299b32bba46d70aac5fe1cd4f04320bcbd1a48d3/pypdfium2-4.30.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de7a3a36803171b3f66911131046d65a732f9e7834438191cb58235e6163c4e", size = 2847531, upload-time = "2024-12-19T19:27:46.372Z" },
{ url = "https://files.pythonhosted.org/packages/a8/6c/93507f87c159e747eaab54352c0fccbaec3f1b3749d0bb9085a47899f898/pypdfium2-4.30.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8a4231efb13170354f568c722d6540b8d5b476b08825586d48ef70c40d16e03", size = 2636266, upload-time = "2024-12-19T19:27:49.767Z" },
{ url = "https://files.pythonhosted.org/packages/24/dc/d56f74a092f2091e328d6485f16562e2fc51cffb0ad6d5c616d80c1eb53c/pypdfium2-4.30.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f434a4934e8244aa95343ffcf24e9ad9f120dbb4785f631bb40a88c39292493", size = 2919296, upload-time = "2024-12-19T19:27:51.767Z" },
{ url = "https://files.pythonhosted.org/packages/be/d9/a2f1ee03d47fbeb48bcfde47ed7155772739622cfadf7135a84ba6a97824/pypdfium2-4.30.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f454032a0bc7681900170f67d8711b3942824531e765f91c2f5ce7937f999794", size = 2866119, upload-time = "2024-12-19T19:27:53.561Z" },
{ url = "https://files.pythonhosted.org/packages/01/47/6aa019c32aa39d3f33347c458c0c5887e84096cbe444456402bc97e66704/pypdfium2-4.30.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:bbf9130a72370ee9d602e39949b902db669a2a1c24746a91e5586eb829055d9f", size = 6228684, upload-time = "2024-12-19T19:27:56.781Z" },
{ url = "https://files.pythonhosted.org/packages/4c/07/2954c15b3f7c85ceb80cad36757fd41b3aba0dd14e68f4bed9ce3f2e7e74/pypdfium2-4.30.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:5cb52884b1583b96e94fd78542c63bb42e06df5e8f9e52f8f31f5ad5a1e53367", size = 6231815, upload-time = "2024-12-19T19:28:00.351Z" },
{ url = "https://files.pythonhosted.org/packages/b4/9b/b4667e95754624f4af5a912001abba90c046e1c80d4a4e887f0af664ffec/pypdfium2-4.30.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:1a9e372bd4867ff223cc8c338e33fe11055dad12f22885950fc27646cc8d9122", size = 6313429, upload-time = "2024-12-19T19:28:02.536Z" },
{ url = "https://files.pythonhosted.org/packages/43/38/f9e77cf55ba5546a39fa659404b78b97de2ca344848271e7731efb0954cd/pypdfium2-4.30.1-py3-none-win32.whl", hash = "sha256:421f1cf205e213e07c1f2934905779547f4f4a2ff2f59dde29da3d511d3fc806", size = 2834989, upload-time = "2024-12-19T19:28:04.657Z" },
{ url = "https://files.pythonhosted.org/packages/a4/f3/8d3a350efb4286b5ebdabcf6736f51d8e3b10dbe68804c6930b00f5cf329/pypdfium2-4.30.1-py3-none-win_amd64.whl", hash = "sha256:598a7f20264ab5113853cba6d86c4566e4356cad037d7d1f849c8c9021007e05", size = 2960157, upload-time = "2024-12-19T19:28:07.772Z" },
{ url = "https://files.pythonhosted.org/packages/e1/6b/2706497c86e8d69fb76afe5ea857fe1794621aa0f3b1d863feb953fe0f22/pypdfium2-4.30.1-py3-none-win_arm64.whl", hash = "sha256:c2b6d63f6d425d9416c08d2511822b54b8e3ac38e639fc41164b1d75584b3a8c", size = 2814810, upload-time = "2024-12-19T19:28:09.857Z" },
{ url = "https://files.pythonhosted.org/packages/c7/9a/c8ff5cc352c1b60b0b97642ae734f51edbab6e28b45b4fcdfe5306ee3c83/pypdfium2-4.30.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33ceded0b6ff5b2b93bc1fe0ad4b71aa6b7e7bd5875f1ca0cdfb6ba6ac01aab", size = 2837254, upload-time = "2024-05-09T18:32:48.653Z" },
{ url = "https://files.pythonhosted.org/packages/21/8b/27d4d5409f3c76b985f4ee4afe147b606594411e15ac4dc1c3363c9a9810/pypdfium2-4.30.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4e55689f4b06e2d2406203e771f78789bd4f190731b5d57383d05cf611d829de", size = 2707624, upload-time = "2024-05-09T18:32:51.458Z" },
{ url = "https://files.pythonhosted.org/packages/11/63/28a73ca17c24b41a205d658e177d68e198d7dde65a8c99c821d231b6ee3d/pypdfium2-4.30.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e6e50f5ce7f65a40a33d7c9edc39f23140c57e37144c2d6d9e9262a2a854854", size = 2793126, upload-time = "2024-05-09T18:32:53.581Z" },
{ url = "https://files.pythonhosted.org/packages/d1/96/53b3ebf0955edbd02ac6da16a818ecc65c939e98fdeb4e0958362bd385c8/pypdfium2-4.30.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d0dd3ecaffd0b6dbda3da663220e705cb563918249bda26058c6036752ba3a2", size = 2591077, upload-time = "2024-05-09T18:32:55.99Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ee/0394e56e7cab8b5b21f744d988400948ef71a9a892cbeb0b200d324ab2c7/pypdfium2-4.30.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc3bf29b0db8c76cdfaac1ec1cde8edf211a7de7390fbf8934ad2aa9b4d6dfad", size = 2864431, upload-time = "2024-05-09T18:32:57.911Z" },
{ url = "https://files.pythonhosted.org/packages/65/cd/3f1edf20a0ef4a212a5e20a5900e64942c5a374473671ac0780eaa08ea80/pypdfium2-4.30.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1f78d2189e0ddf9ac2b7a9b9bd4f0c66f54d1389ff6c17e9fd9dc034d06eb3f", size = 2812008, upload-time = "2024-05-09T18:32:59.886Z" },
{ url = "https://files.pythonhosted.org/packages/c8/91/2d517db61845698f41a2a974de90762e50faeb529201c6b3574935969045/pypdfium2-4.30.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5eda3641a2da7a7a0b2f4dbd71d706401a656fea521b6b6faa0675b15d31a163", size = 6181543, upload-time = "2024-05-09T18:33:02.597Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c4/ed1315143a7a84b2c7616569dfb472473968d628f17c231c39e29ae9d780/pypdfium2-4.30.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:0dfa61421b5eb68e1188b0b2231e7ba35735aef2d867d86e48ee6cab6975195e", size = 6175911, upload-time = "2024-05-09T18:33:05.376Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c4/9e62d03f414e0e3051c56d5943c3bf42aa9608ede4e19dc96438364e9e03/pypdfium2-4.30.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:f33bd79e7a09d5f7acca3b0b69ff6c8a488869a7fab48fdf400fec6e20b9c8be", size = 6267430, upload-time = "2024-05-09T18:33:08.067Z" },
{ url = "https://files.pythonhosted.org/packages/90/47/eda4904f715fb98561e34012826e883816945934a851745570521ec89520/pypdfium2-4.30.0-py3-none-win32.whl", hash = "sha256:ee2410f15d576d976c2ab2558c93d392a25fb9f6635e8dd0a8a3a5241b275e0e", size = 2775951, upload-time = "2024-05-09T18:33:10.567Z" },
{ url = "https://files.pythonhosted.org/packages/25/bd/56d9ec6b9f0fc4e0d95288759f3179f0fcd34b1a1526b75673d2f6d5196f/pypdfium2-4.30.0-py3-none-win_amd64.whl", hash = "sha256:90dbb2ac07be53219f56be09961eb95cf2473f834d01a42d901d13ccfad64b4c", size = 2892098, upload-time = "2024-05-09T18:33:13.107Z" },
{ url = "https://files.pythonhosted.org/packages/be/7a/097801205b991bc3115e8af1edb850d30aeaf0118520b016354cf5ccd3f6/pypdfium2-4.30.0-py3-none-win_arm64.whl", hash = "sha256:119b2969a6d6b1e8d55e99caaf05290294f2d0fe49c12a3f17102d01c441bd29", size = 2752118, upload-time = "2024-05-09T18:33:15.489Z" },
]
[[package]]
@ -1868,14 +1917,15 @@ wheels = [
[[package]]
name = "pytest-asyncio"
version = "1.0.0"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" },
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" },
]
[[package]]
@ -2054,16 +2104,110 @@ wheels = [
[[package]]
name = "rich-toolkit"
version = "0.14.7"
version = "0.14.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/7a/cb48b7024b247631ce39b1f14a0f1abedf311fb27b892b0e0387d809d4b5/rich_toolkit-0.14.7.tar.gz", hash = "sha256:6cca5a68850cc5778915f528eb785662c27ba3b4b2624612cce8340fa9701c5e", size = 104977, upload-time = "2025-05-27T15:48:09.377Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/de/d3d329d670bb271ee82e7bbc2946f985b2782f4cae2857138ed94be1335b/rich_toolkit-0.14.8.tar.gz", hash = "sha256:1f77b32e6c25d9e3644c1efbce00d8d90daf2457b3abdb4699e263c03b9ca6cf", size = 110926, upload-time = "2025-06-30T22:05:53.663Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/2e/95fde5b818dac9a37683ea064096323f593442d0f6358923c5f635974393/rich_toolkit-0.14.7-py3-none-any.whl", hash = "sha256:def05cc6e0f1176d6263b6a26648f16a62c4563b277ca2f8538683acdba1e0da", size = 24870, upload-time = "2025-05-27T15:48:07.942Z" },
{ url = "https://files.pythonhosted.org/packages/78/39/c0fd75955aa963a15c642dfe6fb2acdd1fd2114028ec5ff2e2fd26218ad7/rich_toolkit-0.14.8-py3-none-any.whl", hash = "sha256:c54bda82b93145a79bbae04c3e15352e6711787c470728ff41fdfa0c2f0c11ae", size = 24975, upload-time = "2025-06-30T22:05:52.153Z" },
]
[[package]]
name = "rignore"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/46/05a94dc55ac03cf931d18e43b86ecee5ee054cb88b7853fffd741e35009c/rignore-0.6.4.tar.gz", hash = "sha256:e893fdd2d7fdcfa9407d0b7600ef2c2e2df97f55e1c45d4a8f54364829ddb0ab", size = 11633, upload-time = "2025-07-19T19:24:46.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/27/55ec2871e42c0a01669f7741598a5948f04bd32f3975478a0bead9e7e251/rignore-0.6.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c201375cfe76e56e61fcdfe50d0882aafb49544b424bfc828e0508dc9fbc431b", size = 888088, upload-time = "2025-07-19T19:23:50.776Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/6be3d7adf91f7d67f08833a29dea4f7c345554b385f9a797c397f6685f29/rignore-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4962d537e377394292c4828e1e9c620618dd8daa49ba746abe533733a89f8644", size = 824159, upload-time = "2025-07-19T19:23:44.395Z" },
{ url = "https://files.pythonhosted.org/packages/99/b7/fbb56b8cfa27971f9a19e87769dae0cb648343226eddda94ded32be2afc3/rignore-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a6dd2f213cff6ca3c4d257fa3f5b0c7d4f6c23fe83bf292425fbe8d0c9c908a", size = 892493, upload-time = "2025-07-19T19:22:32.061Z" },
{ url = "https://files.pythonhosted.org/packages/d5/cf/21f130801c29c1fcf22f00a41d7530cef576819ee1a26c86bdb7bb06a0f2/rignore-0.6.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:64d379193f86a21fc93762783f36651927f54d5eea54c4922fdccb5e37076ed2", size = 872810, upload-time = "2025-07-19T19:22:45.554Z" },
{ url = "https://files.pythonhosted.org/packages/e4/4a/474a627263ef13a0ac28a0ce3a20932fbe41f6043f7280da47c7aca1f586/rignore-0.6.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53c4f8682cf645b7a9160e0f1786af3201ed54a020bb4abd515c970043387127", size = 1160488, upload-time = "2025-07-19T19:22:58.359Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c7/a10c180f77cbb456ab483c28e52efd6166cee787f11d21cb1d369b89e961/rignore-0.6.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1246e672bd835a17d3ae91579b3c235ec55b10924ef22608d3e9ec90fa2699", size = 938780, upload-time = "2025-07-19T19:23:10.604Z" },
{ url = "https://files.pythonhosted.org/packages/32/68/8e67701e8cc9f157f12b3742e14f14e395c7f3a497720c7f6aab7e5cdec4/rignore-0.6.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82eed48fbc3097af418862e3c5c26fa81aa993e0d8b5f3a0a9a29cc6975eedff", size = 950347, upload-time = "2025-07-19T19:23:33.759Z" },
{ url = "https://files.pythonhosted.org/packages/1e/11/8eef123a2d029ed697b119806a0ca8a99d9457500c40b4d26cd21860eb89/rignore-0.6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df1215a071d42fd857fb6363c13803fbd915d48eaeaa9b103fb2266ba89c8995", size = 976679, upload-time = "2025-07-19T19:23:23.813Z" },
{ url = "https://files.pythonhosted.org/packages/09/7e/9584f4e4b3c1587ae09f286a14dab2376895d782be632289d151cb952432/rignore-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:82f2d318e66756066ed664015d8ca720078ab1d319377f1f61e3f4d01325faea", size = 1067469, upload-time = "2025-07-19T19:23:57.616Z" },
{ url = "https://files.pythonhosted.org/packages/c3/2c/d3515693b89c47761822219bb519cefd0cd45a38ff82c35a4ccdd8e95deb/rignore-0.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e7d4258fc81051097c4d4c6ad17f0100c40088dbd2c6c31fc3c888a1d5a16190", size = 1136199, upload-time = "2025-07-19T19:24:09.922Z" },
{ url = "https://files.pythonhosted.org/packages/e7/39/94ea41846547ebb87d16527a3e978c8918632a060f77669a492f8a90b8b9/rignore-0.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a0d0b9ec7929df8fd35ae89cb56619850dc140869139d61a2f4fa2941d2d1878", size = 1111179, upload-time = "2025-07-19T19:24:21.908Z" },
{ url = "https://files.pythonhosted.org/packages/ce/77/9acda68c7cea4d5dd027ef63163e0be30008f635acd75ea801e4c443fcdd/rignore-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8883d079b948ffcd56b67572831c9b8949eca7fe2e8f7bdbf7691c7a9388f054", size = 1121143, upload-time = "2025-07-19T19:24:33.958Z" },
{ url = "https://files.pythonhosted.org/packages/05/67/d1489e9224f33b9a87b7f870650bcab582ee3452df286bcb2fbb6a7ba257/rignore-0.6.4-cp310-cp310-win32.whl", hash = "sha256:5aeac5b354e15eb9f7857b02ad2af12ae2c2ed25a61921b0bd7e272774530f77", size = 643131, upload-time = "2025-07-19T19:24:54.437Z" },
{ url = "https://files.pythonhosted.org/packages/5d/d1/7d668bed51d3f0895e875e57c8e42f421635cdbcb96652ab24f297c9c5cf/rignore-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:90419f881d05a1febb0578a175aa3e51d149ded1875421ed75a8af4392b7fe56", size = 721109, upload-time = "2025-07-19T19:24:47.458Z" },
{ url = "https://files.pythonhosted.org/packages/be/11/66992d271dbc44eac33f3b6b871855bc17e511b9279a2a0982b44c2b0c01/rignore-0.6.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:85f684dfc2c497e35ad34ffd6744a3bcdcac273ec1dbe7d0464bfa20f3331434", size = 888239, upload-time = "2025-07-19T19:23:51.835Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1b/a9bde714e474043f97a06097925cf11e4597f9453adc267427d05ff9f38e/rignore-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23954acc6debc852dbccbffbb70f0e26b12d230239e1ad0638eb5540694d0308", size = 824348, upload-time = "2025-07-19T19:23:45.54Z" },
{ url = "https://files.pythonhosted.org/packages/db/58/dabba227fee6553f9be069f58128419b6d4954c784c4cd566cfe59955c1f/rignore-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2bf793bd58dbf3dee063a758b23ea446b5f037370405ecefc78e1e8923fc658", size = 892419, upload-time = "2025-07-19T19:22:33.763Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/e3c16368ee32d6d1146cf219b127fd5c7e6baf22cad7a7a5967782ff3b20/rignore-0.6.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1eaeaa5a904e098604ea2012383a721de06211c8b4013abf0d41c3cfeb982f4f", size = 873285, upload-time = "2025-07-19T19:22:46.67Z" },
{ url = "https://files.pythonhosted.org/packages/78/9d/ef43d760dc3d18011d8482692b478785a846bba64157844b3068e428739c/rignore-0.6.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a48bdbeb03093e3fac2b40d62a718c59b5bb4f29cfdc8e7cbb360e1ea7bf0056", size = 1160457, upload-time = "2025-07-19T19:22:59.457Z" },
{ url = "https://files.pythonhosted.org/packages/95/de/eca1b035705e0b4e6c630fd1fcec45d14cf354a4acea88cf29ea0a322fea/rignore-0.6.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c5f9452d116be405f0967160b449c46ac929b50eaf527f33ee4680e3716e39", size = 938833, upload-time = "2025-07-19T19:23:11.657Z" },
{ url = "https://files.pythonhosted.org/packages/d4/2d/58912efa4137e989616d679a5390b53e93d5150be47217dd686ff60cd4cd/rignore-0.6.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cf1039bfbdaa0f9710a6fb75436c25ca26d364881ec4d1e66d466bb36a7fb98", size = 950603, upload-time = "2025-07-19T19:23:35.245Z" },
{ url = "https://files.pythonhosted.org/packages/6f/3d/9827cc1c7674d8d884d3d231a224a2db8ea8eae075a1611dfdcd0c301e20/rignore-0.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:136629eb0ec2b6ac6ab34e71ce8065a07106fe615a53eceefc30200d528a4612", size = 976867, upload-time = "2025-07-19T19:23:24.919Z" },
{ url = "https://files.pythonhosted.org/packages/75/47/9dcee35e24897b62d66f7578f127bc91465c942a9d702d516d3fe7dcaa00/rignore-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:35e3d0ebaf01086e6454c3fecae141e2db74a5ddf4a97c72c69428baeff0b7d4", size = 1067603, upload-time = "2025-07-19T19:23:58.765Z" },
{ url = "https://files.pythonhosted.org/packages/4b/68/f66e7c0b0fc009f3e19ba8e6c3078a227285e3aecd9f6498d39df808cdfd/rignore-0.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ed1f9010fa1ef5ea0b69803d1dfb4b7355921779e03a30396034c52691658bc", size = 1136289, upload-time = "2025-07-19T19:24:11.136Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b7/6fff161fe3ae5c0e0a0dded9a428e41d31c7fefc4e57c7553b9ffb064139/rignore-0.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c16e9e898ed0afe2e20fa8d6412e02bd13f039f7e0d964a289368efd4d9ad320", size = 1111566, upload-time = "2025-07-19T19:24:23.065Z" },
{ url = "https://files.pythonhosted.org/packages/1f/c5/a5978ad65074a08dad46233a3333d154ae9cb9339325f3c181002a174746/rignore-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e6bc0bdcd404a7a8268629e8e99967127bb41e02d9eb09a471364c4bc25e215", size = 1121142, upload-time = "2025-07-19T19:24:35.151Z" },
{ url = "https://files.pythonhosted.org/packages/e8/af/91f084374b95dc2477a4bd066957beb3b61b551f2364b4f7f5bc52c9e4c7/rignore-0.6.4-cp311-cp311-win32.whl", hash = "sha256:fdd59bd63d2a49cc6d4f3598f285552ccb1a41e001df1012e0e0345cf2cabf79", size = 643031, upload-time = "2025-07-19T19:24:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/07/3a/31672aa957aebba8903005313697127bbbad9db3afcfc9857150301fab1d/rignore-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:7bf5be0e8a01845e57b5faa47ef9c623bb2070aa2f743c2fc73321ffaae45701", size = 721003, upload-time = "2025-07-19T19:24:48.867Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6c/e5af4383cdd7829ef9aa63ac82a6507983e02dbc7c2e7b9aa64b7b8e2c7a/rignore-0.6.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:74720d074b79f32449d5d212ce732e0144a294a184246d1f1e7bcc1fc5c83b69", size = 885885, upload-time = "2025-07-19T19:23:53.236Z" },
{ url = "https://files.pythonhosted.org/packages/89/3e/1b02a868830e464769aa417ee195ac352fe71ff818df8ce50c4b998edb9c/rignore-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a8184fcf567bd6b6d7b85a0c138d98dd40f63054141c96b175844414c5530d7", size = 819736, upload-time = "2025-07-19T19:23:46.565Z" },
{ url = "https://files.pythonhosted.org/packages/e0/75/b9be0c523d97c09f3c6508a67ce376aba4efe41c333c58903a0d7366439a/rignore-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcb0d7d7ecc3fbccf6477bb187c04a091579ea139f15f139abe0b3b48bdfef69", size = 892779, upload-time = "2025-07-19T19:22:35.167Z" },
{ url = "https://files.pythonhosted.org/packages/91/f4/3064b06233697f2993485d132f06fe95061fef71631485da75aed246c4fd/rignore-0.6.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feac73377a156fb77b3df626c76f7e5893d9b4e9e886ac8c0f9d44f1206a2a91", size = 872116, upload-time = "2025-07-19T19:22:47.828Z" },
{ url = "https://files.pythonhosted.org/packages/99/94/cb8e7af9a3c0a665f10e2366144e0ebc66167cf846aca5f1ac31b3661598/rignore-0.6.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:465179bc30beb1f7a3439e428739a2b5777ed26660712b8c4e351b15a7c04483", size = 1163345, upload-time = "2025-07-19T19:23:00.557Z" },
{ url = "https://files.pythonhosted.org/packages/86/6b/49faa7ad85ceb6ccef265df40091d9992232d7f6055fa664fe0a8b13781c/rignore-0.6.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a4877b4dca9cf31a4d09845b300c677c86267657540d0b4d3e6d0ce3110e6e9", size = 939967, upload-time = "2025-07-19T19:23:13.494Z" },
{ url = "https://files.pythonhosted.org/packages/80/c8/b91afda10bd5ca1e3a80463340b899c0dc26a7750a9f3c94f668585c7f40/rignore-0.6.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456456802b1e77d1e2d149320ee32505b8183e309e228129950b807d204ddd17", size = 949717, upload-time = "2025-07-19T19:23:36.404Z" },
{ url = "https://files.pythonhosted.org/packages/3f/f1/88bfdde58ae3fb1c1a92bb801f492eea8eafcdaf05ab9b75130023a4670b/rignore-0.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c1ff2fc223f1d9473d36923160af37bf765548578eb9d47a2f52e90da8ae408", size = 975534, upload-time = "2025-07-19T19:23:25.988Z" },
{ url = "https://files.pythonhosted.org/packages/aa/8f/a80b4a2e48ceba56ba19e096d41263d844757e10aa36ede212571b5d8117/rignore-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e445fbc214ae18e0e644a78086ea5d0f579e210229a4fbe86367d11a4cd03c11", size = 1067837, upload-time = "2025-07-19T19:23:59.888Z" },
{ url = "https://files.pythonhosted.org/packages/7d/90/0905597af0e78748909ef58418442a480ddd93e9fc89b0ca9ab170c357c0/rignore-0.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e07d9c5270fc869bc431aadcfb6ed0447f89b8aafaa666914c077435dc76a123", size = 1134959, upload-time = "2025-07-19T19:24:12.396Z" },
{ url = "https://files.pythonhosted.org/packages/cc/7d/0fa29adf9183b61947ce6dc8a1a9779a8ea16573f557be28ec893f6ddbaa/rignore-0.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7a6ccc0ea83d2c0c6df6b166f2acacedcc220a516436490f41e99a5ae73b6019", size = 1109708, upload-time = "2025-07-19T19:24:24.176Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a7/92892ed86b2e36da403dd3a0187829f2d880414cef75bd612bfdf4dedebc/rignore-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:536392c5ec91755db48389546c833c4ab1426fe03e5a8522992b54ef8a244e7e", size = 1120546, upload-time = "2025-07-19T19:24:36.377Z" },
{ url = "https://files.pythonhosted.org/packages/31/1b/d29ae1fe901d523741d6d1d3ffe0d630734dd0ed6b047628a69c1e15ea44/rignore-0.6.4-cp312-cp312-win32.whl", hash = "sha256:f5f9dca46fc41c0a1e236767f68be9d63bdd2726db13a0ae3a30f68414472969", size = 642005, upload-time = "2025-07-19T19:24:56.671Z" },
{ url = "https://files.pythonhosted.org/packages/1a/41/a224944824688995374e4525115ce85fecd82442fc85edd5bcd81f4f256d/rignore-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:e02eecb9e1b9f9bf7c9030ae73308a777bed3b2486204cc74dfcfbe699ab1497", size = 720358, upload-time = "2025-07-19T19:24:49.959Z" },
{ url = "https://files.pythonhosted.org/packages/db/a3/edd7d0d5cc0720de132b6651cef95ee080ce5fca11c77d8a47db848e5f90/rignore-0.6.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2b3b1e266ce45189240d14dfa1057f8013ea34b9bc8b3b44125ec8d25fdb3985", size = 885304, upload-time = "2025-07-19T19:23:54.268Z" },
{ url = "https://files.pythonhosted.org/packages/93/a1/d8d2fb97a6548307507d049b7e93885d4a0dfa1c907af5983fd9f9362a21/rignore-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fe803628cc14714df10e8d6cdc23950a47eb9eb37dfea9a4779f4c672d2aa0", size = 818799, upload-time = "2025-07-19T19:23:47.544Z" },
{ url = "https://files.pythonhosted.org/packages/b1/cd/949981fcc180ad5ba7b31c52e78b74b2dea6b7bf744ad4c0c4b212f6da78/rignore-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e439f034277a947a4126e2da79dbb43e33d73d7c09d3d72a927e02f8a16f59aa", size = 892024, upload-time = "2025-07-19T19:22:36.18Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d3/9042d701a8062d9c88f87760bbc2695ee2c23b3f002d34486b72a85f8efe/rignore-0.6.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84b5121650ae24621154c7bdba8b8970b0739d8146505c9f38e0cda9385d1004", size = 871430, upload-time = "2025-07-19T19:22:49.62Z" },
{ url = "https://files.pythonhosted.org/packages/eb/50/3370249b984212b7355f3d9241aa6d02e706067c6d194a2614dfbc0f5b27/rignore-0.6.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52b0957b585ab48a445cf8ac1dbc33a272ab060835e583b4f95aa8c67c23fb2b", size = 1160559, upload-time = "2025-07-19T19:23:01.629Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6f/2ad7f925838091d065524f30a8abda846d1813eee93328febf262b5cda21/rignore-0.6.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50359e0d5287b5e2743bd2f2fbf05df619c8282fd3af12f6628ff97b9675551d", size = 939947, upload-time = "2025-07-19T19:23:14.608Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/626ec94d62475ae7ef8b00ef98cea61cbea52a389a666703c97c4673d406/rignore-0.6.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe18096dcb1596757dfe0b412aab6d32564473ae7ee58dea0a8b4be5b1a2e3b", size = 949471, upload-time = "2025-07-19T19:23:37.521Z" },
{ url = "https://files.pythonhosted.org/packages/e8/c3/699c4f03b3c46f4b5c02f17a0a339225da65aad547daa5b03001e7c6a382/rignore-0.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b79c212d9990a273ad91e8d9765e1766ef6ecedd3be65375d786a252762ba385", size = 974912, upload-time = "2025-07-19T19:23:27.13Z" },
{ url = "https://files.pythonhosted.org/packages/cd/35/04626c12f9f92a9fc789afc2be32838a5d9b23b6fa8b2ad4a8625638d15b/rignore-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6ffa7f2a8894c65aa5dc4e8ac8bbdf39a326c0c6589efd27686cfbb48f0197d", size = 1067281, upload-time = "2025-07-19T19:24:01.016Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9c/8f17baf3b984afea151cb9094716f6f1fb8e8737db97fc6eb6d494bd0780/rignore-0.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a63f5720dffc8d8fb0a4d02fafb8370a4031ebf3f99a4e79f334a91e905b7349", size = 1134414, upload-time = "2025-07-19T19:24:13.534Z" },
{ url = "https://files.pythonhosted.org/packages/10/88/ef84ffa916a96437c12cefcc39d474122da9626d75e3a2ebe09ec5d32f1b/rignore-0.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ce33982da47ac5dc09d19b04fa8d7c9aa6292fc0bd1ecf33076989faa8886094", size = 1109330, upload-time = "2025-07-19T19:24:25.303Z" },
{ url = "https://files.pythonhosted.org/packages/27/43/2ada5a2ec03b82e903610a1c483f516f78e47700ee6db9823f739e08b3af/rignore-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d899621867aa266824fbd9150e298f19d25b93903ef0133c09f70c65a3416eca", size = 1120381, upload-time = "2025-07-19T19:24:37.798Z" },
{ url = "https://files.pythonhosted.org/packages/3b/99/e7bcc643085131cb14dbea772def72bf1f6fe9037171ebe177c4f228abc8/rignore-0.6.4-cp313-cp313-win32.whl", hash = "sha256:d0615a6bf4890ec5a90b5fb83666822088fbd4e8fcd740c386fcce51e2f6feea", size = 641761, upload-time = "2025-07-19T19:24:58.096Z" },
{ url = "https://files.pythonhosted.org/packages/d9/25/7798908044f27dea1a8abdc75c14523e33770137651e5f775a15143f4218/rignore-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:145177f0e32716dc2f220b07b3cde2385b994b7ea28d5c96fbec32639e9eac6f", size = 719876, upload-time = "2025-07-19T19:24:51.125Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e3/ae1e30b045bf004ad77bbd1679b9afff2be8edb166520921c6f29420516a/rignore-0.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e55bf8f9bbd186f58ab646b4a08718c77131d28a9004e477612b0cbbd5202db2", size = 891776, upload-time = "2025-07-19T19:22:37.78Z" },
{ url = "https://files.pythonhosted.org/packages/45/a9/1193e3bc23ca0e6eb4f17cf4b99971237f97cfa6f241d98366dff90a6d09/rignore-0.6.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2521f7bf3ee1f2ab22a100a3a4eed39a97b025804e5afe4323528e9ce8f084a5", size = 871442, upload-time = "2025-07-19T19:22:50.972Z" },
{ url = "https://files.pythonhosted.org/packages/20/83/4c52ae429a0b2e1ce667e35b480e9a6846f9468c443baeaed5d775af9485/rignore-0.6.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cc35773a8a9c119359ef974d0856988d4601d4daa6f532c05f66b4587cf35bc", size = 1159844, upload-time = "2025-07-19T19:23:02.751Z" },
{ url = "https://files.pythonhosted.org/packages/c1/2f/c740f5751f464c937bfe252dc15a024ae081352cfe80d94aa16d6a617482/rignore-0.6.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b665b1ea14457d7b49e834baabc635a3b8c10cfb5cca5c21161fabdbfc2b850e", size = 939456, upload-time = "2025-07-19T19:23:15.72Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/68dbb08ac0edabf44dd144ff546a3fb0253c5af708e066847df39fc9188f/rignore-0.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c7fd339f344a8548724f289495b835bed7b81174a0bc1c28c6497854bd8855db", size = 1067070, upload-time = "2025-07-19T19:24:02.803Z" },
{ url = "https://files.pythonhosted.org/packages/3b/3a/7e7ea6f0d31d3f5beb0f2cf2c4c362672f5f7f125714458673fc579e2bed/rignore-0.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:91dc94b1cc5af8d6d25ce6edd29e7351830f19b0a03b75cb3adf1f76d00f3007", size = 1134598, upload-time = "2025-07-19T19:24:15.039Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/1b3307f6437d29bede5a95738aa89e6d910ba68d4054175c9f60d8e2c6b1/rignore-0.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4d1918221a249e5342b60fd5fa513bf3d6bf272a8738e66023799f0c82ecd788", size = 1108862, upload-time = "2025-07-19T19:24:26.765Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d5/b37c82519f335f2c472a63fc6215c6f4c51063ecf3166e3acf508011afbd/rignore-0.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:240777332b859dc89dcba59ab6e3f1e062bc8e862ffa3e5f456e93f7fd5cb415", size = 1120002, upload-time = "2025-07-19T19:24:38.952Z" },
{ url = "https://files.pythonhosted.org/packages/ac/72/2f05559ed5e69bdfdb56ea3982b48e6c0017c59f7241f7e1c5cae992b347/rignore-0.6.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b0e548753e55cc648f1e7b02d9f74285fe48bb49cec93643d31e563773ab3f", size = 949454, upload-time = "2025-07-19T19:23:38.664Z" },
{ url = "https://files.pythonhosted.org/packages/0b/92/186693c8f838d670510ac1dfb35afbe964320fbffb343ba18f3d24441941/rignore-0.6.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6971ac9fdd5a0bd299a181096f091c4f3fd286643adceba98eccc03c688a6637", size = 974663, upload-time = "2025-07-19T19:23:28.24Z" },
{ url = "https://files.pythonhosted.org/packages/85/4d/5a69ea5ae7de78eddf0a0699b6dbd855f87c1436673425461188ea39662f/rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40f493eef4b191777ba6d16879e3f73836142e04480d2e2f483675d652e6b559", size = 895408, upload-time = "2025-07-19T19:22:42.16Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c3/b6cdf9b676d6774c5de3ca04a5f4dbaffae3bb06bdee395e095be24f098e/rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6790635e4df35333e27cd9e8b31d1d559826cf8b52f2c374b81ab698ac0140cf", size = 873042, upload-time = "2025-07-19T19:22:54.663Z" },
{ url = "https://files.pythonhosted.org/packages/80/25/61182149b2f2ca86c22c6253b361ec0e983e60e913ca75588a7d559b41eb/rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e326dab28787f07c6987c04686d4ad9d4b1e1caca1a15b85d443f91af2e133d2", size = 1162036, upload-time = "2025-07-19T19:23:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/db/44/7fe55c2b7adc8c90dc8709ef2fac25fa526b0c8bfd1090af4e6b33c2e42f/rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd24cb0f58c6036b0f64ac6fc3f759b7f0de5506fa9f5a65e9d57f8cf44a026d", size = 940381, upload-time = "2025-07-19T19:23:19.364Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a3/8cc0c9a9db980a1589007d0fedcaf41475820e0cd4950a5f6eeb8ebc0ee0/rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36cb95b0acae3c88b99a39f4246b395fd983848f3ec85ff26531d638b6584a45", size = 951924, upload-time = "2025-07-19T19:23:42.209Z" },
{ url = "https://files.pythonhosted.org/packages/07/f2/4f2c88307c84801d6c772c01e8d856deaa8e85117180b88aaa0f41d4f86f/rignore-0.6.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dfc954973429ce545d06163d87a6bae0ccea5703adbc957ee3d332c9592a58eb", size = 976515, upload-time = "2025-07-19T19:23:31.524Z" },
{ url = "https://files.pythonhosted.org/packages/a4/bd/f701ddf897cf5e3f394107e6dad147216b3a0d84e9d53d7a5fed7cc97d26/rignore-0.6.4-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:cbed37d7c128b58ab9ade80e131efc4a48b6d045cd0bd1d3254cbb6b4a0ad67e", size = 1069896, upload-time = "2025-07-19T19:24:06.24Z" },
{ url = "https://files.pythonhosted.org/packages/00/52/1ae54afad26aafcfee1b44a36b27bb0dd63f1c23081e1599dbf681368925/rignore-0.6.4-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:a0db910ef867d6ca2d52fefd22d8b6b63b20ec61661e2ad57e5c425a4e39431a", size = 1136337, upload-time = "2025-07-19T19:24:18.529Z" },
{ url = "https://files.pythonhosted.org/packages/85/9a/3b74aabb69ed118d0b493afa62d1aacc3bf12b8f11bf682a3c02174c3068/rignore-0.6.4-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:d664443a0a71d0a7d669adf32be59c4249bbff8b2810960f1b91d413ee4cf6b8", size = 1111677, upload-time = "2025-07-19T19:24:30.21Z" },
{ url = "https://files.pythonhosted.org/packages/70/7d/bd0f6c1bc89c80b116b526b77cdd5263c0ad218d5416aebf4ca9cce9ca73/rignore-0.6.4-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:b9f6f1d91429b4a6772152848815cf1459663796b7b899a0e15d9198e32c9371", size = 1122823, upload-time = "2025-07-19T19:24:42.476Z" },
{ url = "https://files.pythonhosted.org/packages/33/a1/daaa2df10dfa6d87c896a5783c8407c284530d5a056307d1f55a8ef0c533/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b3da26d5a35ab15525b68d30b7352ad2247321f5201fc7e50ba6d547f78d5ea", size = 895772, upload-time = "2025-07-19T19:22:43.423Z" },
{ url = "https://files.pythonhosted.org/packages/35/e6/65130a50cd3ed11c967034dfd653e160abb7879fb4ee338a1cccaeda7acd/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43028f3587558231d9fa68accff58c901dc50fd7bbc5764d3ee3df95290f6ebf", size = 873093, upload-time = "2025-07-19T19:22:55.745Z" },
{ url = "https://files.pythonhosted.org/packages/32/c4/02ead1274ce935c59f2bb3deaaaa339df9194bc40e3c2d8d623e31e47ec4/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc56f1fcab7740751b98fead67b98ba64896424d8c834ea22089568db4e36dfa", size = 1162199, upload-time = "2025-07-19T19:23:08.376Z" },
{ url = "https://files.pythonhosted.org/packages/78/0c/94a4edce0e80af69f200cc35d8da4c727c52d28f0c9d819b388849ae8ef6/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6033f2280898535a5f69935e08830a4e49ff1e29ef2c3f9a2b9ced59de06fdbf", size = 940176, upload-time = "2025-07-19T19:23:20.862Z" },
{ url = "https://files.pythonhosted.org/packages/43/92/21ec579c999a3ed4d1b2a5926a9d0edced7c65d8ac353bc9120d49b05a64/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f5ac0c4e6a24be88f3821e101ef4665e9e1dc015f9e45109f32fed71dbcdafa", size = 951632, upload-time = "2025-07-19T19:23:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/67/c4/72e7ba244222b9efdeb18f9974d6f1e30cf5a2289e1b482a1e8b3ebee90f/rignore-0.6.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8906ac8dd585ece83b1346e0470260a1951058cc0ef5a17542069bde4aa3f42f", size = 976923, upload-time = "2025-07-19T19:23:32.678Z" },
{ url = "https://files.pythonhosted.org/packages/8e/14/e754c12bc953c7fa309687cd30a6ea95e5721168fb0b2a99a34bff24be5c/rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:14d095622969504a2e56f666286202dad583f08d3347b7be2d647ddfd7a9bf47", size = 1069861, upload-time = "2025-07-19T19:24:07.671Z" },
{ url = "https://files.pythonhosted.org/packages/a6/24/ba2bdaf04a19b5331c051b9d480e8daca832bed4aeaa156d6d679044c06c/rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:30f3d688df7eb4850318f1b5864d14f2c5fe5dbf3803ed0fc8329d2a7ad560dc", size = 1136368, upload-time = "2025-07-19T19:24:19.68Z" },
{ url = "https://files.pythonhosted.org/packages/83/48/7cf52353299e02aa629150007fa75f4b91d99b4f2fa536f2e24ead810116/rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:028f62a7b0a6235bb3f03c9e7f342352e7fa4b3f08c761c72f9de8faee40ed9c", size = 1111714, upload-time = "2025-07-19T19:24:31.717Z" },
{ url = "https://files.pythonhosted.org/packages/84/9c/3881ad34f01942af0cf713e25e476bf851e04e389cc3ff146c3b459ab861/rignore-0.6.4-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:7e6c425603db2c147eace4f752ca3cd4551e7568c9d332175d586c68bcbe3d8d", size = 1122433, upload-time = "2025-07-19T19:24:43.973Z" },
]
[[package]]
@ -2080,40 +2224,40 @@ wheels = [
[[package]]
name = "ruff"
version = "0.12.1"
version = "0.12.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/97/38/796a101608a90494440856ccfb52b1edae90de0b817e76bfade66b12d320/ruff-0.12.1.tar.gz", hash = "sha256:806bbc17f1104fd57451a98a58df35388ee3ab422e029e8f5cf30aa4af2c138c", size = 4413426, upload-time = "2025-06-26T20:34:14.784Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/ce/8d7dbedede481245b489b769d27e2934730791a9a82765cb94566c6e6abd/ruff-0.12.4.tar.gz", hash = "sha256:13efa16df6c6eeb7d0f091abae50f58e9522f3843edb40d56ad52a5a4a4b6873", size = 5131435, upload-time = "2025-07-17T17:27:19.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/bf/3dba52c1d12ab5e78d75bd78ad52fb85a6a1f29cc447c2423037b82bed0d/ruff-0.12.1-py3-none-linux_armv6l.whl", hash = "sha256:6013a46d865111e2edb71ad692fbb8262e6c172587a57c0669332a449384a36b", size = 10305649, upload-time = "2025-06-26T20:33:39.242Z" },
{ url = "https://files.pythonhosted.org/packages/8c/65/dab1ba90269bc8c81ce1d499a6517e28fe6f87b2119ec449257d0983cceb/ruff-0.12.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b3f75a19e03a4b0757d1412edb7f27cffb0c700365e9d6b60bc1b68d35bc89e0", size = 11120201, upload-time = "2025-06-26T20:33:42.207Z" },
{ url = "https://files.pythonhosted.org/packages/3f/3e/2d819ffda01defe857fa2dd4cba4d19109713df4034cc36f06bbf582d62a/ruff-0.12.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a256522893cb7e92bb1e1153283927f842dea2e48619c803243dccc8437b8be", size = 10466769, upload-time = "2025-06-26T20:33:44.102Z" },
{ url = "https://files.pythonhosted.org/packages/63/37/bde4cf84dbd7821c8de56ec4ccc2816bce8125684f7b9e22fe4ad92364de/ruff-0.12.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:069052605fe74c765a5b4272eb89880e0ff7a31e6c0dbf8767203c1fbd31c7ff", size = 10660902, upload-time = "2025-06-26T20:33:45.98Z" },
{ url = "https://files.pythonhosted.org/packages/0e/3a/390782a9ed1358c95e78ccc745eed1a9d657a537e5c4c4812fce06c8d1a0/ruff-0.12.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a684f125a4fec2d5a6501a466be3841113ba6847827be4573fddf8308b83477d", size = 10167002, upload-time = "2025-06-26T20:33:47.81Z" },
{ url = "https://files.pythonhosted.org/packages/6d/05/f2d4c965009634830e97ffe733201ec59e4addc5b1c0efa035645baa9e5f/ruff-0.12.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdecdef753bf1e95797593007569d8e1697a54fca843d78f6862f7dc279e23bd", size = 11751522, upload-time = "2025-06-26T20:33:49.857Z" },
{ url = "https://files.pythonhosted.org/packages/35/4e/4bfc519b5fcd462233f82fc20ef8b1e5ecce476c283b355af92c0935d5d9/ruff-0.12.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:70d52a058c0e7b88b602f575d23596e89bd7d8196437a4148381a3f73fcd5010", size = 12520264, upload-time = "2025-06-26T20:33:52.199Z" },
{ url = "https://files.pythonhosted.org/packages/85/b2/7756a6925da236b3a31f234b4167397c3e5f91edb861028a631546bad719/ruff-0.12.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84d0a69d1e8d716dfeab22d8d5e7c786b73f2106429a933cee51d7b09f861d4e", size = 12133882, upload-time = "2025-06-26T20:33:54.231Z" },
{ url = "https://files.pythonhosted.org/packages/dd/00/40da9c66d4a4d51291e619be6757fa65c91b92456ff4f01101593f3a1170/ruff-0.12.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cc32e863adcf9e71690248607ccdf25252eeeab5193768e6873b901fd441fed", size = 11608941, upload-time = "2025-06-26T20:33:56.202Z" },
{ url = "https://files.pythonhosted.org/packages/91/e7/f898391cc026a77fbe68dfea5940f8213622474cb848eb30215538a2dadf/ruff-0.12.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fd49a4619f90d5afc65cf42e07b6ae98bb454fd5029d03b306bd9e2273d44cc", size = 11602887, upload-time = "2025-06-26T20:33:58.47Z" },
{ url = "https://files.pythonhosted.org/packages/f6/02/0891872fc6aab8678084f4cf8826f85c5d2d24aa9114092139a38123f94b/ruff-0.12.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ed5af6aaaea20710e77698e2055b9ff9b3494891e1b24d26c07055459bb717e9", size = 10521742, upload-time = "2025-06-26T20:34:00.465Z" },
{ url = "https://files.pythonhosted.org/packages/2a/98/d6534322c74a7d47b0f33b036b2498ccac99d8d8c40edadb552c038cecf1/ruff-0.12.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:801d626de15e6bf988fbe7ce59b303a914ff9c616d5866f8c79eb5012720ae13", size = 10149909, upload-time = "2025-06-26T20:34:02.603Z" },
{ url = "https://files.pythonhosted.org/packages/34/5c/9b7ba8c19a31e2b6bd5e31aa1e65b533208a30512f118805371dbbbdf6a9/ruff-0.12.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2be9d32a147f98a1972c1e4df9a6956d612ca5f5578536814372113d09a27a6c", size = 11136005, upload-time = "2025-06-26T20:34:04.723Z" },
{ url = "https://files.pythonhosted.org/packages/dc/34/9bbefa4d0ff2c000e4e533f591499f6b834346025e11da97f4ded21cb23e/ruff-0.12.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49b7ce354eed2a322fbaea80168c902de9504e6e174fd501e9447cad0232f9e6", size = 11648579, upload-time = "2025-06-26T20:34:06.766Z" },
{ url = "https://files.pythonhosted.org/packages/6f/1c/20cdb593783f8f411839ce749ec9ae9e4298c2b2079b40295c3e6e2089e1/ruff-0.12.1-py3-none-win32.whl", hash = "sha256:d973fa626d4c8267848755bd0414211a456e99e125dcab147f24daa9e991a245", size = 10519495, upload-time = "2025-06-26T20:34:08.718Z" },
{ url = "https://files.pythonhosted.org/packages/cf/56/7158bd8d3cf16394928f47c637d39a7d532268cd45220bdb6cd622985760/ruff-0.12.1-py3-none-win_amd64.whl", hash = "sha256:9e1123b1c033f77bd2590e4c1fe7e8ea72ef990a85d2484351d408224d603013", size = 11547485, upload-time = "2025-06-26T20:34:11.008Z" },
{ url = "https://files.pythonhosted.org/packages/91/d0/6902c0d017259439d6fd2fd9393cea1cfe30169940118b007d5e0ea7e954/ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc", size = 10691209, upload-time = "2025-06-26T20:34:12.928Z" },
{ url = "https://files.pythonhosted.org/packages/ae/9f/517bc5f61bad205b7f36684ffa5415c013862dee02f55f38a217bdbe7aa4/ruff-0.12.4-py3-none-linux_armv6l.whl", hash = "sha256:cb0d261dac457ab939aeb247e804125a5d521b21adf27e721895b0d3f83a0d0a", size = 10188824, upload-time = "2025-07-17T17:26:31.412Z" },
{ url = "https://files.pythonhosted.org/packages/28/83/691baae5a11fbbde91df01c565c650fd17b0eabed259e8b7563de17c6529/ruff-0.12.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:55c0f4ca9769408d9b9bac530c30d3e66490bd2beb2d3dae3e4128a1f05c7442", size = 10884521, upload-time = "2025-07-17T17:26:35.084Z" },
{ url = "https://files.pythonhosted.org/packages/d6/8d/756d780ff4076e6dd035d058fa220345f8c458391f7edfb1c10731eedc75/ruff-0.12.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a8224cc3722c9ad9044da7f89c4c1ec452aef2cfe3904365025dd2f51daeae0e", size = 10277653, upload-time = "2025-07-17T17:26:37.897Z" },
{ url = "https://files.pythonhosted.org/packages/8d/97/8eeee0f48ece153206dce730fc9e0e0ca54fd7f261bb3d99c0a4343a1892/ruff-0.12.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9949d01d64fa3672449a51ddb5d7548b33e130240ad418884ee6efa7a229586", size = 10485993, upload-time = "2025-07-17T17:26:40.68Z" },
{ url = "https://files.pythonhosted.org/packages/49/b8/22a43d23a1f68df9b88f952616c8508ea6ce4ed4f15353b8168c48b2d7e7/ruff-0.12.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:be0593c69df9ad1465e8a2d10e3defd111fdb62dcd5be23ae2c06da77e8fcffb", size = 10022824, upload-time = "2025-07-17T17:26:43.564Z" },
{ url = "https://files.pythonhosted.org/packages/cd/70/37c234c220366993e8cffcbd6cadbf332bfc848cbd6f45b02bade17e0149/ruff-0.12.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7dea966bcb55d4ecc4cc3270bccb6f87a337326c9dcd3c07d5b97000dbff41c", size = 11524414, upload-time = "2025-07-17T17:26:46.219Z" },
{ url = "https://files.pythonhosted.org/packages/14/77/c30f9964f481b5e0e29dd6a1fae1f769ac3fd468eb76fdd5661936edd262/ruff-0.12.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afcfa3ab5ab5dd0e1c39bf286d829e042a15e966b3726eea79528e2e24d8371a", size = 12419216, upload-time = "2025-07-17T17:26:48.883Z" },
{ url = "https://files.pythonhosted.org/packages/6e/79/af7fe0a4202dce4ef62c5e33fecbed07f0178f5b4dd9c0d2fcff5ab4a47c/ruff-0.12.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c057ce464b1413c926cdb203a0f858cd52f3e73dcb3270a3318d1630f6395bb3", size = 11976756, upload-time = "2025-07-17T17:26:51.754Z" },
{ url = "https://files.pythonhosted.org/packages/09/d1/33fb1fc00e20a939c305dbe2f80df7c28ba9193f7a85470b982815a2dc6a/ruff-0.12.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64b90d1122dc2713330350626b10d60818930819623abbb56535c6466cce045", size = 11020019, upload-time = "2025-07-17T17:26:54.265Z" },
{ url = "https://files.pythonhosted.org/packages/64/f4/e3cd7f7bda646526f09693e2e02bd83d85fff8a8222c52cf9681c0d30843/ruff-0.12.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2abc48f3d9667fdc74022380b5c745873499ff827393a636f7a59da1515e7c57", size = 11277890, upload-time = "2025-07-17T17:26:56.914Z" },
{ url = "https://files.pythonhosted.org/packages/5e/d0/69a85fb8b94501ff1a4f95b7591505e8983f38823da6941eb5b6badb1e3a/ruff-0.12.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2b2449dc0c138d877d629bea151bee8c0ae3b8e9c43f5fcaafcd0c0d0726b184", size = 10348539, upload-time = "2025-07-17T17:26:59.381Z" },
{ url = "https://files.pythonhosted.org/packages/16/a0/91372d1cb1678f7d42d4893b88c252b01ff1dffcad09ae0c51aa2542275f/ruff-0.12.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:56e45bb11f625db55f9b70477062e6a1a04d53628eda7784dce6e0f55fd549eb", size = 10009579, upload-time = "2025-07-17T17:27:02.462Z" },
{ url = "https://files.pythonhosted.org/packages/23/1b/c4a833e3114d2cc0f677e58f1df6c3b20f62328dbfa710b87a1636a5e8eb/ruff-0.12.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:478fccdb82ca148a98a9ff43658944f7ab5ec41c3c49d77cd99d44da019371a1", size = 10942982, upload-time = "2025-07-17T17:27:05.343Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ce/ce85e445cf0a5dd8842f2f0c6f0018eedb164a92bdf3eda51984ffd4d989/ruff-0.12.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0fc426bec2e4e5f4c4f182b9d2ce6a75c85ba9bcdbe5c6f2a74fcb8df437df4b", size = 11343331, upload-time = "2025-07-17T17:27:08.652Z" },
{ url = "https://files.pythonhosted.org/packages/35/cf/441b7fc58368455233cfb5b77206c849b6dfb48b23de532adcc2e50ccc06/ruff-0.12.4-py3-none-win32.whl", hash = "sha256:4de27977827893cdfb1211d42d84bc180fceb7b72471104671c59be37041cf93", size = 10267904, upload-time = "2025-07-17T17:27:11.814Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7e/20af4a0df5e1299e7368d5ea4350412226afb03d95507faae94c80f00afd/ruff-0.12.4-py3-none-win_amd64.whl", hash = "sha256:fe0b9e9eb23736b453143d72d2ceca5db323963330d5b7859d60d101147d461a", size = 11209038, upload-time = "2025-07-17T17:27:14.417Z" },
{ url = "https://files.pythonhosted.org/packages/11/02/8857d0dfb8f44ef299a5dfd898f673edefb71e3b533b3b9d2db4c832dd13/ruff-0.12.4-py3-none-win_arm64.whl", hash = "sha256:0618ec4442a83ab545e5b71202a5c0ed7791e8471435b94e655b570a5031a98e", size = 10469336, upload-time = "2025-07-17T17:27:16.913Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.32.0"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/59/eb90c45cb836cf8bec973bba10230ddad1c55e2b2e9ffa9d7d7368948358/sentry_sdk-2.32.0.tar.gz", hash = "sha256:9016c75d9316b0f6921ac14c8cd4fb938f26002430ac5be9945ab280f78bec6b", size = 334932, upload-time = "2025-06-27T08:10:02.89Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/82/dfe4a91fd38e048fbb55ca6c072710408e8802015aa27cde18e8684bb1e9/sentry_sdk-2.33.2.tar.gz", hash = "sha256:e85002234b7b8efac9b74c2d91dbd4f8f3970dc28da8798e39530e65cb740f94", size = 335804, upload-time = "2025-07-22T10:41:18.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/a1/fc4856bd02d2097324fb7ce05b3021fb850f864b83ca765f6e37e92ff8ca/sentry_sdk-2.32.0-py2.py3-none-any.whl", hash = "sha256:6cf51521b099562d7ce3606da928c473643abe99b00ce4cb5626ea735f4ec345", size = 356122, upload-time = "2025-06-27T08:10:01.424Z" },
{ url = "https://files.pythonhosted.org/packages/c2/dc/4d825d5eb6e924dfcc6a91c8185578a7b0a5c41fd2416a6f49c8226d6ef9/sentry_sdk-2.33.2-py2.py3-none-any.whl", hash = "sha256:8d57a3b4861b243aa9d558fda75509ad487db14f488cbdb6c78c614979d77632", size = 356692, upload-time = "2025-07-22T10:41:16.531Z" },
]
[package.optional-dependencies]
@ -2204,14 +2348,15 @@ wheels = [
[[package]]
name = "starlette"
version = "0.46.2"
version = "0.47.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" }
sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
{ url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" },
]
[[package]]
@ -2336,11 +2481,11 @@ wheels = [
[[package]]
name = "typing-extensions"
version = "4.14.0"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" }
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
@ -2384,16 +2529,16 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.34.3"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
]
[package.optional-dependencies]