feat: 2.1.1 release (#167)
This commit is contained in:
parent
ffaae28221
commit
bc5474c170
|
|
@ -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
|
||||
|
|
|
|||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 🫡 Honcho
|
||||
|
||||

|
||||

|
||||
[](https://discord.gg/plasticlabs)
|
||||
[](https://arxiv.org/abs/2310.06983)
|
||||

|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"navigation": {
|
||||
"versions": [
|
||||
{
|
||||
"version": "v2.1.0",
|
||||
"version": "v2.1.1",
|
||||
"api": {
|
||||
"openapi": [
|
||||
"openapi.documented.yml"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
"""
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
457
uv.lock
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue