Honcho 2.1.0 "ROTE" deriver (#160)
* feat: update SDKs to core 1.2.0 * feat: 2.1.0 introduce ROTE deriver/dialectic chore: refactor repo * fix: get typescript sdk tests working again, bump version numbers * chore: cleanup * chore: update unit test provider config * fix: remove "backup" query gen * fix: remove old utils from conftest
This commit is contained in:
parent
27bde3d6db
commit
a14899521c
|
|
@ -12,6 +12,11 @@ FASTAPI_HOST=0.0.0.0
|
|||
FASTAPI_PORT=8000
|
||||
# SESSION_PEERS_LIMIT=10
|
||||
|
||||
# Embedding settings
|
||||
# EMBED_MESSAGES=true
|
||||
# MAX_EMBEDDING_TOKENS=8192
|
||||
# MAX_EMBEDDING_TOKENS_PER_REQUEST=300000
|
||||
|
||||
# =============================================================================
|
||||
# Database Settings (REQUIRED)
|
||||
# =============================================================================
|
||||
|
|
@ -21,6 +26,7 @@ DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
|
|||
|
||||
# Optional database settings
|
||||
# DB_SCHEMA=public
|
||||
# DB_POOL_CLASS=default
|
||||
# DB_POOL_SIZE=10
|
||||
# DB_MAX_OVERFLOW=20
|
||||
# DB_POOL_TIMEOUT=30
|
||||
|
|
@ -63,48 +69,43 @@ LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
|||
# LLM Configuration
|
||||
# =============================================================================
|
||||
# Global LLM settings
|
||||
# LLM_DEFAULT_MAX_TOKENS=1000
|
||||
# LLM_DEFAULT_TEMPERATURE=0.0
|
||||
|
||||
# Dialectic LLM settings
|
||||
# LLM_DIALECTIC_PROVIDER=anthropic
|
||||
# LLM_DIALECTIC_MODEL=claude-3-7-sonnet-20250219
|
||||
|
||||
# Query generation LLM settings
|
||||
# LLM_QUERY_GENERATION_PROVIDER=groq
|
||||
# LLM_QUERY_GENERATION_MODEL=llama-3.1-8b-instant
|
||||
|
||||
# Summarization LLM settings
|
||||
# LLM_SUMMARY_PROVIDER=google
|
||||
# LLM_SUMMARY_MODEL=gemini-2.0-flash-lite
|
||||
# LLM_SUMMARY_MAX_TOKENS_SHORT=1000
|
||||
# LLM_SUMMARY_MAX_TOKENS_LONG=2000
|
||||
|
||||
# Embedding settings
|
||||
# LLM_MAX_EMBEDDING_TOKENS=8192
|
||||
# LLM_MAX_EMBEDDING_TOKENS_PER_REQUEST=300000
|
||||
|
||||
# =============================================================================
|
||||
# Agent Settings
|
||||
# =============================================================================
|
||||
# AGENT_SEMANTIC_SEARCH_TOP_K=10
|
||||
# AGENT_SEMANTIC_SEARCH_MAX_DISTANCE=0.85
|
||||
# AGENT_TOM_INFERENCE_METHOD=single_prompt
|
||||
# LLM_DEFAULT_MAX_TOKENS=2500
|
||||
|
||||
# =============================================================================
|
||||
# Deriver (Background Worker) Settings
|
||||
# =============================================================================
|
||||
# DERIVER_WORKERS=1
|
||||
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
|
||||
# DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
|
||||
# DERIVER_TOM_METHOD=single_prompt
|
||||
# DERIVER_USER_REPRESENTATION_METHOD=long_term
|
||||
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
|
||||
# DERIVER_PROVIDER=google
|
||||
# DERIVER_MODEL=gemini-2.0-flash-lite
|
||||
# MAX_OUTPUT_TOKENS=2500
|
||||
# only applied when using Anthropic as provider
|
||||
# THINKING_BUDGET_TOKENS=1024
|
||||
# DERIVER_DEDUCTIVE_OBSERVATIONS_COUNT=6
|
||||
# DERIVER_EXPLICIT_OBSERVATIONS_COUNT=10
|
||||
|
||||
# =============================================================================
|
||||
# History Settings
|
||||
# Dialectic Settings
|
||||
# =============================================================================
|
||||
# HISTORY_MESSAGES_PER_SHORT_SUMMARY=20
|
||||
# HISTORY_MESSAGES_PER_LONG_SUMMARY=60
|
||||
# DIALECTIC_PROVIDER=anthropic
|
||||
# DIALECTIC_MODEL=claude-sonnet-4-20250514
|
||||
# DIALECTIC_QUERY_GENERATION_PROVIDER=groq
|
||||
# DIALECTIC_QUERY_GENERATION_MODEL=llama-3.1-8b-instant
|
||||
# DIALECTIC_MAX_OUTPUT_TOKENS=2500
|
||||
# DIALECTIC_SEMANTIC_SEARCH_TOP_K=10
|
||||
# DIALECTIC_SEMANTIC_SEARCH_MAX_DISTANCE=0.85
|
||||
# DIALECTIC_THINKING_BUDGET_TOKENS=1024
|
||||
|
||||
# =============================================================================
|
||||
# Summary Settings
|
||||
# =============================================================================
|
||||
# SUMMARY_MESSAGES_PER_SHORT_SUMMARY=20
|
||||
# SUMMARY_MESSAGES_PER_LONG_SUMMARY=60
|
||||
# SUMMARY_PROVIDER=google
|
||||
# SUMMARY_MODEL=gemini-1.5-flash-latest
|
||||
# SUMMARY_MAX_TOKENS_SHORT=1000
|
||||
# SUMMARY_MAX_TOKENS_LONG=2000
|
||||
|
||||
# =============================================================================
|
||||
# Monitoring and Observability (Optional)
|
||||
|
|
@ -113,6 +114,6 @@ LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
|||
# SENTRY_ENABLED=false
|
||||
# SENTRY_DSN=your-sentry-dsn-here
|
||||
# SENTRY_RELEASE=your-release-semver
|
||||
# SENTRY_ENVIRONMENT=string-that-labels-deployment (default "development")
|
||||
# SENTRY_ENVIRONMENT=development
|
||||
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
# SENTRY_PROFILES_SAMPLE_RATE=0.1
|
||||
|
|
|
|||
|
|
@ -51,11 +51,11 @@ jobs:
|
|||
SENTRY_ENABLED: false
|
||||
LLM_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
LLM_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
LLM_DIALECTIC_PROVIDER: openai
|
||||
LLM_DIALECTIC_MODEL: test
|
||||
LLM_QUERY_GENERATION_PROVIDER: openai
|
||||
LLM_QUERY_GENERATION_MODEL: test
|
||||
LLM_TOM_INFERENCE_PROVIDER: openai
|
||||
LLM_TOM_INFERENCE_MODEL: test
|
||||
LLM_SUMMARY_PROVIDER: openai
|
||||
LLM_SUMMARY_MODEL: test
|
||||
DERIVER_PROVIDER: openai
|
||||
DERIVER_MODEL: test
|
||||
DIALECTIC_PROVIDER: openai
|
||||
DIALECTIC_MODEL: test
|
||||
DIALECTIC_QUERY_GENERATION_PROVIDER: openai
|
||||
DIALECTIC_QUERY_GENERATION_MODEL: test
|
||||
SUMMARY_PROVIDER: openai
|
||||
SUMMARY_MODEL: test
|
||||
|
|
|
|||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -5,6 +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
|
||||
|
||||
### Added
|
||||
|
||||
- File uploads
|
||||
- Brand new "ROTE" deriver system
|
||||
- Updated dialectic system
|
||||
- Local working representations
|
||||
- Better logging for deriver/dialectic
|
||||
- Endpoint for deriver queue status
|
||||
|
||||
### Fixed
|
||||
|
||||
- Document insertion
|
||||
- Session-scoped and peer-targeted dialectic queries work now
|
||||
|
||||
### Removed
|
||||
|
||||
- Peer-level messages
|
||||
|
||||
### Changed
|
||||
|
||||
- Dialectic chat endpoint takes a single query
|
||||
- Rearranged configuration values (LLM, Deriver, Dialectic, History->Summary)
|
||||
|
||||
## [2.0.5] - 2025-07-11
|
||||
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# 🫡 Honcho
|
||||
|
||||

|
||||

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

|
||||
|
|
@ -143,9 +143,13 @@ security.
|
|||
Below are the required configurations:
|
||||
|
||||
```env
|
||||
DB_CONNECTION_URI= # Connection uri for a postgres database
|
||||
OPENAI_API_KEY= # API Key for OpenAI used for embedding documents
|
||||
ANTHROPIC_API_KEY= # API Key for Anthropic used for the deriver and dialectic API
|
||||
DB_CONNECTION_URI= # Connection uri for a postgres database (with postgresql+psycopg prefix)
|
||||
|
||||
# LLM Provider API Keys (at least one required depending on your configuration)
|
||||
LLM_ANTHROPIC_API_KEY= # API Key for Anthropic (used for dialectic by default)
|
||||
LLM_OPENAI_API_KEY= # API Key for OpenAI (optional, for embeddings if EMBED_MESSAGES=true)
|
||||
LLM_GEMINI_API_KEY= # API Key for Google Gemini (used for summary/deriver by default)
|
||||
LLM_GROQ_API_KEY= # API Key for Groq (used for query generation by default)
|
||||
```
|
||||
|
||||
> Note that the `DB_CONNECTION_URI` must have the prefix `postgresql+psycopg` to
|
||||
|
|
@ -247,13 +251,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
|
||||
- `[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
|
||||
|
||||
|
|
@ -266,7 +271,8 @@ Examples:
|
|||
|
||||
- `DB_CONNECTION_URI` - Database connection string
|
||||
- `AUTH_JWT_SECRET` - JWT secret key
|
||||
- `LLM_DIALECTIC_MODEL` - Dialectic LLM model
|
||||
- `DIALECTIC_MODEL` - Dialectic API model
|
||||
- `SUMMARY_PROVIDER` - Summary generation provider
|
||||
- `LOG_LEVEL` - Application log level
|
||||
|
||||
### Configuration Priority
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@
|
|||
LOG_LEVEL = "INFO"
|
||||
FASTAPI_HOST = "0.0.0.0"
|
||||
FASTAPI_PORT = 8000
|
||||
SESSION_PEERS_LIMIT = 10
|
||||
EMBED_MESSAGES = true
|
||||
MAX_EMBEDDING_TOKENS = 8192
|
||||
MAX_EMBEDDING_TOKENS_PER_REQUEST = 300000
|
||||
|
||||
# Database settings
|
||||
[db]
|
||||
|
|
@ -32,27 +36,14 @@ JWT_SECRET = "your-secret-key-here" # Must be set if USE_AUTH is true
|
|||
[sentry]
|
||||
ENABLED = false
|
||||
DSN = ""
|
||||
RELEASE = ""
|
||||
ENVIRONMENT = "development"
|
||||
TRACES_SAMPLE_RATE = 0.1
|
||||
PROFILES_SAMPLE_RATE = 0.1
|
||||
|
||||
# LLM settings
|
||||
[llm]
|
||||
DEFAULT_MAX_TOKENS = 1000
|
||||
DEFAULT_TEMPERATURE = 0.0
|
||||
|
||||
# Dialectic specific
|
||||
DIALECTIC_PROVIDER = "anthropic"
|
||||
DIALECTIC_MODEL = "claude-3-7-sonnet-20250219"
|
||||
|
||||
# Query Generation specific
|
||||
QUERY_GENERATION_PROVIDER = "groq"
|
||||
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
|
||||
|
||||
# Summarization specific
|
||||
SUMMARY_PROVIDER = "google"
|
||||
SUMMARY_MODEL = "gemini-2.0-flash-lite"
|
||||
SUMMARY_MAX_TOKENS_SHORT = 1000
|
||||
SUMMARY_MAX_TOKENS_LONG = 2000
|
||||
DEFAULT_MAX_TOKENS = 2500
|
||||
|
||||
# API Keys for LLM providers
|
||||
# ANTHROPIC_API_KEY = "your-api-key"
|
||||
|
|
@ -60,23 +51,37 @@ SUMMARY_MAX_TOKENS_LONG = 2000
|
|||
# OPENAI_COMPATIBLE_API_KEY = "your-api-key"
|
||||
# GEMINI_API_KEY = "your-api-key"
|
||||
# GROQ_API_KEY = "your-api-key"
|
||||
# OPENAI_COMPATIBLE_BASE_URL = "your-api-key"
|
||||
|
||||
# Agent settings
|
||||
[agent]
|
||||
SEMANTIC_SEARCH_TOP_K = 10
|
||||
SEMANTIC_SEARCH_MAX_DISTANCE = 0.85
|
||||
TOM_INFERENCE_METHOD = "single_prompt"
|
||||
# OPENAI_COMPATIBLE_BASE_URL = "your-base-url"
|
||||
|
||||
# Deriver settings
|
||||
[deriver]
|
||||
WORKERS = 1
|
||||
STALE_SESSION_TIMEOUT_MINUTES = 5
|
||||
POLLING_SLEEP_INTERVAL_SECONDS = 1.0
|
||||
TOM_METHOD = "single_prompt"
|
||||
USER_REPRESENTATION_METHOD = "long_term"
|
||||
STALE_SESSION_TIMEOUT_MINUTES = 5
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-2.0-flash-lite"
|
||||
MAX_OUTPUT_TOKENS = 2500
|
||||
THINKING_BUDGET_TOKENS = 1024 # only applied when using Anthropic
|
||||
DEDUCTIVE_OBSERVATIONS_COUNT = 6
|
||||
EXPLICIT_OBSERVATIONS_COUNT = 10
|
||||
|
||||
# History settings
|
||||
[history]
|
||||
# Dialectic settings
|
||||
[dialectic]
|
||||
PROVIDER = "anthropic"
|
||||
MODEL = "claude-sonnet-4-20250514"
|
||||
QUERY_GENERATION_PROVIDER = "groq"
|
||||
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
|
||||
MAX_OUTPUT_TOKENS = 2500
|
||||
SEMANTIC_SEARCH_TOP_K = 10
|
||||
SEMANTIC_SEARCH_MAX_DISTANCE = 0.85
|
||||
THINKING_BUDGET_TOKENS = 1024
|
||||
|
||||
# Summary settings
|
||||
[summary]
|
||||
MESSAGES_PER_SHORT_SUMMARY = 20
|
||||
MESSAGES_PER_LONG_SUMMARY = 60
|
||||
PROVIDER = "google"
|
||||
MODEL = "gemini-1.5-flash-latest"
|
||||
MAX_TOKENS_SHORT = 1000
|
||||
MAX_TOKENS_LONG = 2000
|
||||
THINKING_BUDGET_TOKENS = 512
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@
|
|||
"dependencies": {
|
||||
"@mintlify/scraping": "^4.0.284",
|
||||
"honcho-ai": "^0.0.11",
|
||||
"mintlify": "^4.1.96",
|
||||
},
|
||||
"devDependencies": {
|
||||
"mint": "^4.2.13",
|
||||
"mint": "^4.1.96",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -103,25 +104,25 @@
|
|||
|
||||
"@mdx-js/react": ["@mdx-js/react@3.1.0", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ=="],
|
||||
|
||||
"@mintlify/cli": ["@mintlify/cli@4.0.617", "", { "dependencies": { "@mintlify/common": "1.0.445", "@mintlify/link-rot": "3.0.567", "@mintlify/models": "0.0.205", "@mintlify/prebuild": "1.0.563", "@mintlify/previewing": "4.0.606", "@mintlify/validation": "0.1.409", "chalk": "^5.2.0", "detect-port": "^1.5.1", "fs-extra": "^11.2.0", "ink": "^6.0.1", "inquirer": "^12.3.0", "js-yaml": "^4.1.0", "react": "^19.1.0", "semver": "^7.7.2", "yargs": "^17.6.0" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-/kLgjR0crqpXZNJHKsM1iCyz3/uH/hSWxSBAPpjxA7pEmQfck5fVneGaNU+ClRXdX9QLjninshs0liec4yCE3Q=="],
|
||||
"@mintlify/cli": ["@mintlify/cli@4.0.619", "", { "dependencies": { "@mintlify/common": "1.0.447", "@mintlify/link-rot": "3.0.569", "@mintlify/models": "0.0.206", "@mintlify/prebuild": "1.0.565", "@mintlify/previewing": "4.0.608", "@mintlify/validation": "0.1.411", "chalk": "^5.2.0", "detect-port": "^1.5.1", "fs-extra": "^11.2.0", "ink": "^6.0.1", "inquirer": "^12.3.0", "js-yaml": "^4.1.0", "react": "^19.1.0", "semver": "^7.7.2", "yargs": "^17.6.0" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-BRKvb3F0GBnkB9eHCgaQ+9D4UAp7nuniz+tc2Jp6s21nreHZ9D1R0Bb+CDP7PD5eTwObyhdCOoduOky8SGoHlQ=="],
|
||||
|
||||
"@mintlify/common": ["@mintlify/common@1.0.444", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.204", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.408", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-hUIIJo2QAm+olEeJgcDsYL+1W6UhT+TwTJroOF9zBiQ0N5rVAvIX3TSjc7kiF1ZdhqTSCp6US7+3AADjMF5aYw=="],
|
||||
"@mintlify/common": ["@mintlify/common@1.0.447", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.206", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.411", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-Fo06wwxuDpim+NfP406SdDDjT1MonJXayla91e3uDhkuVhsXnV2s7tyAK2wBZugHw8Qwm79mT7ufZz3RmAGGZw=="],
|
||||
|
||||
"@mintlify/link-rot": ["@mintlify/link-rot@3.0.567", "", { "dependencies": { "@mintlify/common": "1.0.445", "@mintlify/prebuild": "1.0.563", "@mintlify/previewing": "4.0.606", "fs-extra": "^11.1.0", "unist-util-visit": "^4.1.1" } }, "sha512-2/jXjjzAasgWP3ZM4Ug06NEPSW0VSHdzllRYMaMEzaFEmn9BlOexRtgFnUui7MvRS4jsLJ2IA2Oy6XV6Bc7aGA=="],
|
||||
"@mintlify/link-rot": ["@mintlify/link-rot@3.0.569", "", { "dependencies": { "@mintlify/common": "1.0.447", "@mintlify/prebuild": "1.0.565", "@mintlify/previewing": "4.0.608", "fs-extra": "^11.1.0", "unist-util-visit": "^4.1.1" } }, "sha512-me37ZeCatENRUmAd3D9bTztVaVHS5pGdlGKzmoKHfZ+w4DSmejedG0m4NY5iRe8JwPY+79+tU1GkPXgcTd+Tjg=="],
|
||||
|
||||
"@mintlify/mdx": ["@mintlify/mdx@2.0.3", "", { "dependencies": { "@shikijs/transformers": "^3.6.0", "hast-util-to-string": "^3.0.1", "mdast-util-mdx-jsx": "^3.2.0", "next-mdx-remote-client": "^1.0.3", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-smartypants": "^3.0.2", "shiki": "^3.6.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" } }, "sha512-UGlwavma8QooWAlhtXpTAG5MAUZTTUKI8Qu25Wqfp1HMOPrYGvo5YQPmlqqogbMsqDMcFPLP/ZYnaZsGUYBspQ=="],
|
||||
|
||||
"@mintlify/models": ["@mintlify/models@0.0.205", "", { "dependencies": { "axios": "^1.8.3", "openapi-types": "^12.0.0" } }, "sha512-uA5nlI2oeCCkl5sDXdXZZ/HBDnDoM9MknpEYUOkyGZhrYcXQgl43w659Rhg7GHU+3MuguouEu7lasItRlPlTLg=="],
|
||||
"@mintlify/models": ["@mintlify/models@0.0.206", "", { "dependencies": { "axios": "^1.8.3", "openapi-types": "^12.0.0" } }, "sha512-/EYzfsgWg3zv/GgyqWOiR9qZo9OGpquNe8vyq35PtgHfyhpJ0POURt5GL7NBh42K90b02NwSDwMfyqPMRe2yeQ=="],
|
||||
|
||||
"@mintlify/openapi-parser": ["@mintlify/openapi-parser@0.0.7", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-3ecbkzPbsnkKVZJypVL0H5pCTR7a4iLv4cP7zbffzAwy+vpH70JmPxNVpPPP62yLrdZlfNcMxu5xKeT7fllgMg=="],
|
||||
|
||||
"@mintlify/prebuild": ["@mintlify/prebuild@1.0.563", "", { "dependencies": { "@mintlify/common": "1.0.445", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/scraping": "4.0.301", "@mintlify/validation": "0.1.409", "chalk": "^5.3.0", "favicons": "^7.2.0", "fs-extra": "^11.1.0", "gray-matter": "^4.0.3", "js-yaml": "^4.1.0", "mdast": "^3.0.0", "openapi-types": "^12.0.0", "unist-util-visit": "^4.1.1" } }, "sha512-k8fvZEqIiaL141airNEUmZ9v8M3pPwsYoK9gQfDLa1E/r7s7njuNbNg1hOeIWrTFmLCBprxJ0wX3TfMa7i7XGQ=="],
|
||||
"@mintlify/prebuild": ["@mintlify/prebuild@1.0.565", "", { "dependencies": { "@mintlify/common": "1.0.447", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/scraping": "4.0.303", "@mintlify/validation": "0.1.411", "chalk": "^5.3.0", "favicons": "^7.2.0", "fs-extra": "^11.1.0", "gray-matter": "^4.0.3", "js-yaml": "^4.1.0", "mdast": "^3.0.0", "openapi-types": "^12.0.0", "unist-util-visit": "^4.1.1" } }, "sha512-DinWiKKmaVGx5cCDCdd/jVfkdHaJbmg32tNSM30NU7ldM3BtiXk6TEVJZqmJl84T2rUQB0IkhCynoveBtJUgRA=="],
|
||||
|
||||
"@mintlify/previewing": ["@mintlify/previewing@4.0.606", "", { "dependencies": { "@mintlify/common": "1.0.445", "@mintlify/prebuild": "1.0.563", "@mintlify/validation": "0.1.409", "better-opn": "^3.0.2", "chalk": "^5.1.0", "chokidar": "^3.5.3", "express": "^4.18.2", "fs-extra": "^11.1.0", "got": "^13.0.0", "gray-matter": "^4.0.3", "ink": "^6.0.1", "ink-spinner": "^5.0.0", "is-online": "^10.0.0", "js-yaml": "^4.1.0", "mdast": "^3.0.0", "openapi-types": "^12.0.0", "react": "^19.1.0", "socket.io": "^4.7.2", "tar": "^6.1.15", "unist-util-visit": "^4.1.1", "yargs": "^17.6.0" } }, "sha512-p94v+YwN8swZYs4x0XCNnUOXEVhAlg4SXX+st+J9SW+LDNPdxVztVa91XiB46yzvy8UaZTH1nRmR/H4esOtyjw=="],
|
||||
"@mintlify/previewing": ["@mintlify/previewing@4.0.608", "", { "dependencies": { "@mintlify/common": "1.0.447", "@mintlify/prebuild": "1.0.565", "@mintlify/validation": "0.1.411", "better-opn": "^3.0.2", "chalk": "^5.1.0", "chokidar": "^3.5.3", "express": "^4.18.2", "fs-extra": "^11.1.0", "got": "^13.0.0", "gray-matter": "^4.0.3", "ink": "^6.0.1", "ink-spinner": "^5.0.0", "is-online": "^10.0.0", "js-yaml": "^4.1.0", "mdast": "^3.0.0", "openapi-types": "^12.0.0", "react": "^19.1.0", "socket.io": "^4.7.2", "tar": "^6.1.15", "unist-util-visit": "^4.1.1", "yargs": "^17.6.0" } }, "sha512-7J9KkYzFd9wUwioM3pKB4o7a9o0c/OgazIlelkfGO6CLqzZfW+t2t+ugRGPPtWaZJ069lgEmx/h2mC52EqZkHA=="],
|
||||
|
||||
"@mintlify/scraping": ["@mintlify/scraping@4.0.300", "", { "dependencies": { "@mintlify/common": "1.0.444", "@mintlify/openapi-parser": "^0.0.7", "fs-extra": "^11.1.1", "hast-util-to-mdast": "^10.1.0", "js-yaml": "^4.1.0", "mdast-util-mdx-jsx": "^3.1.3", "neotraverse": "^0.6.18", "puppeteer": "^22.14.0", "rehype-parse": "^9.0.0", "remark-gfm": "^4.0.0", "remark-mdx": "^3.0.1", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "yargs": "^17.6.0", "zod": "^3.20.6" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-ceZJ5Mt0YoU9f9JjV6msODq5y/b7CVSVpTWKOkOy9tGVOPWL0xpmQjSxteslHuWzNI4HdlzIxIMFKgw016z/2g=="],
|
||||
"@mintlify/scraping": ["@mintlify/scraping@4.0.303", "", { "dependencies": { "@mintlify/common": "1.0.447", "@mintlify/openapi-parser": "^0.0.7", "fs-extra": "^11.1.1", "hast-util-to-mdast": "^10.1.0", "js-yaml": "^4.1.0", "mdast-util-mdx-jsx": "^3.1.3", "neotraverse": "^0.6.18", "puppeteer": "^22.14.0", "rehype-parse": "^9.0.0", "remark-gfm": "^4.0.0", "remark-mdx": "^3.0.1", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "yargs": "^17.6.0", "zod": "^3.20.6" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-imT+oEdZhqsMvZx5sIKOEpTV2ohTyOFw+ecL1oSD79w+HHNwQ/FdpsXObQ9J5uMupWRpd7lUXPxSvqoZ0OiiFQ=="],
|
||||
|
||||
"@mintlify/validation": ["@mintlify/validation@0.1.409", "", { "dependencies": { "@mintlify/models": "0.0.205", "lcm": "^0.0.3", "lodash": "^4.17.21", "openapi-types": "^12.0.0", "zod": "^3.20.6", "zod-to-json-schema": "^3.20.3" } }, "sha512-tTLowyfKs3CH1Df6jnFZMnsY2IAumfpjPa9bwnh831/hryiA/HgGcnMZjz/a+lIcmYYpn62NBKcgXmle04jayQ=="],
|
||||
"@mintlify/validation": ["@mintlify/validation@0.1.411", "", { "dependencies": { "@mintlify/models": "0.0.206", "lcm": "^0.0.3", "lodash": "^4.17.21", "openapi-types": "^12.0.0", "zod": "^3.20.6", "zod-to-json-schema": "^3.20.3" } }, "sha512-+vnA0pcAkFk5lmU+drKnYjtLeWETxjVoj7b7vzimuNCO5B3e6gWvfE1LA1XXPcTiAT9UsyaKIoK800gxolBFEA=="],
|
||||
|
||||
"@openapi-contrib/openapi-schema-to-json-schema": ["@openapi-contrib/openapi-schema-to-json-schema@3.2.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-Gj6C0JwCr8arj0sYuslWXUBSP/KnUlEGnPW4qxlXvAl543oaNQgMgIgkQUA6vs5BCCvwTEiL8m/wdWzfl4UvSw=="],
|
||||
|
||||
|
|
@ -143,19 +144,19 @@
|
|||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.3.0", "", { "dependencies": { "debug": "^4.3.5", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.4.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@3.7.0", "", { "dependencies": { "@shikijs/types": "3.7.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg=="],
|
||||
"@shikijs/core": ["@shikijs/core@3.8.0", "", { "dependencies": { "@shikijs/types": "3.8.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-gWt8NNZFurL6FMESO4lEsmspDh0H1fyUibhx1NnEH/S3kOXgYiWa6ZFqy+dcjBLhZqCXsepuUaL1QFXk6PrpsQ=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.7.0", "", { "dependencies": { "@shikijs/types": "3.7.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-0t17s03Cbv+ZcUvv+y33GtX75WBLQELgNdVghnsdhTgU3hVcWcMsoP6Lb0nDTl95ZJfbP1mVMO0p3byVh3uuzA=="],
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.8.0", "", { "dependencies": { "@shikijs/types": "3.8.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-IBULFFpQ1N5Cg/C7jPCGnjIKz72CcRtD0BIbNhSuXPUOxLG0bF1URsP/uLfxQFQ9ORfunCQwL7UuSX1RSRBwUQ=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.7.0", "", { "dependencies": { "@shikijs/types": "3.7.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw=="],
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.8.0", "", { "dependencies": { "@shikijs/types": "3.8.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Tx7kR0oFzqa+rY7t80LjN8ZVtHO3a4+33EUnBVx2qYP3fGxoI9H0bvnln5ySelz9SIUTsS0/Qn+9dg5zcUMsUw=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@3.7.0", "", { "dependencies": { "@shikijs/types": "3.7.0" } }, "sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ=="],
|
||||
"@shikijs/langs": ["@shikijs/langs@3.8.0", "", { "dependencies": { "@shikijs/types": "3.8.0" } }, "sha512-mfGYuUgjQ5GgXinB5spjGlBVhG2crKRpKkfADlp8r9k/XvZhtNXxyOToSnCEnF0QNiZnJjlt5MmU9PmhRdwAbg=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@3.7.0", "", { "dependencies": { "@shikijs/types": "3.7.0" } }, "sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ=="],
|
||||
"@shikijs/themes": ["@shikijs/themes@3.8.0", "", { "dependencies": { "@shikijs/types": "3.8.0" } }, "sha512-yaZiLuyO23sXe16JFU76KyUMTZCJi4EMQKIrdQt7okoTzI4yAaJhVXT2Uy4k8yBIEFRiia5dtD7gC1t8m6y3oQ=="],
|
||||
|
||||
"@shikijs/transformers": ["@shikijs/transformers@3.7.0", "", { "dependencies": { "@shikijs/core": "3.7.0", "@shikijs/types": "3.7.0" } }, "sha512-VplaqIMRNsNOorCXJHkbF5S0pT6xm8Z/s7w7OPZLohf8tR93XH0krvUafpNy/ozEylrWuShJF0+ftEB+wFRwGA=="],
|
||||
"@shikijs/transformers": ["@shikijs/transformers@3.8.0", "", { "dependencies": { "@shikijs/core": "3.8.0", "@shikijs/types": "3.8.0" } }, "sha512-EleKVjNH5Me8yhTtnYD5QGFtY7Acu2HJAWNmDjuOC/Egwt7n31p2nbyBhBqGz5cpdwa1wZkLdVgj/LsZ3ReyAQ=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@3.7.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg=="],
|
||||
"@shikijs/types": ["@shikijs/types@3.8.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-I/b/aNg0rP+kznVDo7s3UK8jMcqEGTtoPDdQ+JlQ2bcJIyu/e2iRvl42GLIDMK03/W1YOHOuhlhQ7aM+XbKUeg=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
|
|
@ -227,7 +228,7 @@
|
|||
|
||||
"@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="],
|
||||
|
||||
"@types/node": ["@types/node@18.19.117", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hcxGs9TfQGghOM8atpRT+bBMUX7V8WosdYt98bQ59wUToJck55eCOlemJ+0FpOZOQw5ff7LSi9+IO56KvYEFyQ=="],
|
||||
"@types/node": ["@types/node@18.19.118", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-hIPK0hSrrcaoAu/gJMzN3QClXE4QdCdFvaenJ0JsjIbExP1JFFVH+RHcBt25c9n8bx5dkIfqKE+uw6BmBns7ug=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
|
||||
|
||||
|
|
@ -295,7 +296,7 @@
|
|||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"avsc": ["avsc@5.7.8", "", {}, "sha512-6SyyCw6XzdiNrkJ3UF9dIj0cVexNQAGxwjZ69CRgEneBxdDKjjc2IdfEJD11iP4BfwQWrL8HwR04bx/Y3uUNEA=="],
|
||||
"avsc": ["avsc@5.7.9", "", {}, "sha512-yOA4wFeI7ET3v32Di/sUybQ+ttP20JHSW3mxLuNGeO0uD6PPcvLrIQXSvy/rhJOWU5JrYh7U4OHplWMmtAtjMg=="],
|
||||
|
||||
"axios": ["axios@1.10.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw=="],
|
||||
|
||||
|
|
@ -981,7 +982,9 @@
|
|||
|
||||
"minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="],
|
||||
|
||||
"mint": ["mint@4.2.13", "", { "dependencies": { "@mintlify/cli": "4.0.617" }, "bin": { "mint": "index.js", "mintlify": "index.js" } }, "sha512-GIgZCh1wmr9vzkilkuEz91GQQsAeCKsWxIbTGKlKiMgydbxo5vGupIGlK7ecphwVzlu97W92NiznthoS40DHjQ=="],
|
||||
"mint": ["mint@4.2.15", "", { "dependencies": { "@mintlify/cli": "4.0.619" }, "bin": { "mint": "index.js", "mintlify": "index.js" } }, "sha512-ZmDKezGM2kf0dNqcrE9CkElFoCxM4U75wIb7RjVZbPLjt8jjs40U88wQXuvv4p/OLnFr+Je9ck8jD3EGET6cNQ=="],
|
||||
|
||||
"mintlify": ["mintlify@4.2.15", "", { "dependencies": { "@mintlify/cli": "4.0.619" }, "bin": { "mint": "index.js", "mintlify": "index.js" } }, "sha512-9hFI7So7jLhuIkg9MIy1AZ9UlISJYHEViKKoz6lElTg+qQJhV5d+53ULUA6JKqv37PeOaMIL/UfNKd97kklU9A=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
|
|
@ -1219,7 +1222,7 @@
|
|||
|
||||
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
|
||||
"shiki": ["shiki@3.7.0", "", { "dependencies": { "@shikijs/core": "3.7.0", "@shikijs/engine-javascript": "3.7.0", "@shikijs/engine-oniguruma": "3.7.0", "@shikijs/langs": "3.7.0", "@shikijs/themes": "3.7.0", "@shikijs/types": "3.7.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ZcI4UT9n6N2pDuM2n3Jbk0sR4Swzq43nLPgS/4h0E3B/NrFn2HKElrDtceSf8Zx/OWYOo7G1SAtBLypCp+YXqg=="],
|
||||
"shiki": ["shiki@3.8.0", "", { "dependencies": { "@shikijs/core": "3.8.0", "@shikijs/engine-javascript": "3.8.0", "@shikijs/engine-oniguruma": "3.8.0", "@shikijs/langs": "3.8.0", "@shikijs/themes": "3.8.0", "@shikijs/types": "3.8.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-yPqK0y68t20aakv+3aMTpUMJZd6UHaBY2/SBUDowh9M70gVUwqT0bf7Kz5CWG0AXfHtFvXCHhBBHVAzdp0ILoQ=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
|
|
@ -1245,7 +1248,7 @@
|
|||
|
||||
"socket.io-parser": ["socket.io-parser@4.2.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" } }, "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew=="],
|
||||
|
||||
"socks": ["socks@2.8.5", "", { "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" } }, "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww=="],
|
||||
"socks": ["socks@2.8.6", "", { "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" } }, "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA=="],
|
||||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||
|
||||
|
|
@ -1443,24 +1446,10 @@
|
|||
|
||||
"@inquirer/select/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
|
||||
|
||||
"@mintlify/cli/@mintlify/common": ["@mintlify/common@1.0.445", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.205", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.409", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-4YN2EWeU6VI6voVD9l9FYGwrCUgXM0+1i/Fyn2DFMZyG4LNtaAbJCyS1K2puFvLGiZwXeipfx9DJzG/f8sisbg=="],
|
||||
|
||||
"@mintlify/common/@mintlify/models": ["@mintlify/models@0.0.204", "", { "dependencies": { "axios": "^1.8.3", "openapi-types": "^12.0.0" } }, "sha512-QhpfM3JUwRfkLEQYuS7RLqAzQyGpcaBSwWhhqJsXFOWEjgrSM7hBYwIg/cUEejILcmqPXIUVzpTM1lb9UA3Rhg=="],
|
||||
|
||||
"@mintlify/common/@mintlify/validation": ["@mintlify/validation@0.1.408", "", { "dependencies": { "@mintlify/models": "0.0.204", "lcm": "^0.0.3", "lodash": "^4.17.21", "openapi-types": "^12.0.0", "zod": "^3.20.6", "zod-to-json-schema": "^3.20.3" } }, "sha512-+6dWRFhVniZgX/7/gM3GqOCH3jKqYbtSjg21gnJ1mUWaiTVmH0EutY8drF0uR8AC2l9L1Dbrn3seY4VeibZ0tQ=="],
|
||||
|
||||
"@mintlify/link-rot/@mintlify/common": ["@mintlify/common@1.0.445", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.205", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.409", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-4YN2EWeU6VI6voVD9l9FYGwrCUgXM0+1i/Fyn2DFMZyG4LNtaAbJCyS1K2puFvLGiZwXeipfx9DJzG/f8sisbg=="],
|
||||
|
||||
"@mintlify/link-rot/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="],
|
||||
|
||||
"@mintlify/prebuild/@mintlify/common": ["@mintlify/common@1.0.445", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.205", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.409", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-4YN2EWeU6VI6voVD9l9FYGwrCUgXM0+1i/Fyn2DFMZyG4LNtaAbJCyS1K2puFvLGiZwXeipfx9DJzG/f8sisbg=="],
|
||||
|
||||
"@mintlify/prebuild/@mintlify/scraping": ["@mintlify/scraping@4.0.301", "", { "dependencies": { "@mintlify/common": "1.0.445", "@mintlify/openapi-parser": "^0.0.7", "fs-extra": "^11.1.1", "hast-util-to-mdast": "^10.1.0", "js-yaml": "^4.1.0", "mdast-util-mdx-jsx": "^3.1.3", "neotraverse": "^0.6.18", "puppeteer": "^22.14.0", "rehype-parse": "^9.0.0", "remark-gfm": "^4.0.0", "remark-mdx": "^3.0.1", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "yargs": "^17.6.0", "zod": "^3.20.6" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-Qv+dyE8jrNpaD6P0olQm6+E4JmgOtHRUpWnD8bcaUD1wV8Jw+cyFTqBZc1uveka7kIGfJxRJ9aD8et2zXbWWnQ=="],
|
||||
|
||||
"@mintlify/prebuild/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="],
|
||||
|
||||
"@mintlify/previewing/@mintlify/common": ["@mintlify/common@1.0.445", "", { "dependencies": { "@asyncapi/parser": "^3.4.0", "@mintlify/mdx": "^2.0.3", "@mintlify/models": "0.0.205", "@mintlify/openapi-parser": "^0.0.7", "@mintlify/validation": "0.1.409", "@sindresorhus/slugify": "^2.1.1", "acorn": "^8.11.2", "acorn-jsx": "^5.3.2", "estree-util-to-js": "^2.0.0", "estree-walker": "^3.0.3", "gray-matter": "^4.0.3", "hast-util-from-html": "^2.0.3", "hast-util-to-html": "^9.0.4", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mdast": "^3.0.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.3", "micromark-extension-mdx-jsx": "^3.0.1", "openapi-types": "^12.0.0", "remark": "^15.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-mdx": "^3.1.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-map": "^4.0.0", "unist-util-remove": "^4.0.0", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-4YN2EWeU6VI6voVD9l9FYGwrCUgXM0+1i/Fyn2DFMZyG4LNtaAbJCyS1K2puFvLGiZwXeipfx9DJzG/f8sisbg=="],
|
||||
|
||||
"@mintlify/previewing/unist-util-visit": ["unist-util-visit@4.1.2", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^5.1.1" } }, "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg=="],
|
||||
|
||||
"@stoplight/better-ajv-errors/leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
|
||||
|
|
@ -1559,26 +1548,18 @@
|
|||
|
||||
"@inquirer/select/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||
|
||||
"@mintlify/link-rot/@mintlify/common/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
|
||||
|
||||
"@mintlify/link-rot/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"@mintlify/link-rot/unist-util-visit/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="],
|
||||
|
||||
"@mintlify/link-rot/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@5.1.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0" } }, "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg=="],
|
||||
|
||||
"@mintlify/prebuild/@mintlify/common/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
|
||||
|
||||
"@mintlify/prebuild/@mintlify/scraping/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
|
||||
|
||||
"@mintlify/prebuild/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"@mintlify/prebuild/unist-util-visit/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="],
|
||||
|
||||
"@mintlify/prebuild/unist-util-visit/unist-util-visit-parents": ["unist-util-visit-parents@5.1.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0" } }, "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg=="],
|
||||
|
||||
"@mintlify/previewing/@mintlify/common/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
|
||||
|
||||
"@mintlify/previewing/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"@mintlify/previewing/unist-util-visit/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="],
|
||||
|
|
|
|||
|
|
@ -8,7 +8,29 @@ This guide helps you understand which versions of Honcho's API are compatible wi
|
|||
|
||||
## Version Compatibility
|
||||
|
||||
### Honcho API v2.0.5 (Current)
|
||||
### Honcho API v2.1.0 (Current)
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="TypeScript SDK" icon="js">
|
||||
**Compatible Version:** v1.2.0
|
||||
|
||||
Install with:
|
||||
```bash
|
||||
npm install @honcho-ai/sdk@1.2.0
|
||||
```
|
||||
</Card>
|
||||
|
||||
<Card title="Python SDK" icon="python">
|
||||
**Compatible Version:** v1.2.0
|
||||
|
||||
Install with:
|
||||
```bash
|
||||
pip install honcho-ai==1.2.0
|
||||
```
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
### Honcho API v2.0.5
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="TypeScript SDK" icon="js">
|
||||
|
|
@ -34,5 +56,6 @@ This guide helps you understand which versions of Honcho's API are compatible wi
|
|||
|
||||
| Honcho API Version | TypeScript SDK | Python SDK |
|
||||
|-------------------|---------------|------------|
|
||||
| v2.0.5 (Current) | v1.1.0 | v1.1.0 | Latest release |
|
||||
| v2.1.0 (Current) | v1.2.0 | v1.2.0 | Latest release |
|
||||
| v2.0.5 | v1.1.0 | v1.1.0 |
|
||||
| v2.0.4 | v1.1.0 | v1.1.0 |
|
||||
|
|
|
|||
|
|
@ -27,12 +27,38 @@ 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)">
|
||||
### Added
|
||||
|
||||
- File uploads
|
||||
- Brand new "ROTE" deriver system
|
||||
- Updated dialectic system
|
||||
- Local working representations
|
||||
- Better logging for deriver/dialectic
|
||||
- Deriver Queue Status no longer has redundant data
|
||||
|
||||
### Fixed
|
||||
|
||||
- Document insertion
|
||||
- Session-scoped and peer-targeted dialectic queries work now
|
||||
- Minor bugs
|
||||
|
||||
### Removed
|
||||
|
||||
- Peer-level messages
|
||||
|
||||
### Changed
|
||||
|
||||
- Dialectic chat endpoint takes a single query
|
||||
- Rearranged configuration values (LLM, Deriver, Dialectic, History->Summary)
|
||||
</Update>
|
||||
|
||||
<Update label="v2.0.5 (Current)">
|
||||
### Fixed
|
||||
|
||||
- Groq API client to use the Async library
|
||||
</Update>
|
||||
|
||||
|
||||
<Update label="v2.0.4">
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"navigation": {
|
||||
"versions": [
|
||||
{
|
||||
"version": "v2.0.5",
|
||||
"version": "v2.1.0",
|
||||
"api": {
|
||||
"openapi": [
|
||||
"openapi.documented.yml"
|
||||
|
|
@ -61,6 +61,7 @@
|
|||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"v2/guides/overview",
|
||||
"v2/guides/ai-assisted-setup",
|
||||
"v2/guides/mcp"
|
||||
]
|
||||
},
|
||||
|
|
@ -125,8 +126,6 @@
|
|||
"v2/api-reference/endpoint/peers/update-peer",
|
||||
"v2/api-reference/endpoint/peers/get-sessions-for-peer",
|
||||
"v2/api-reference/endpoint/peers/chat",
|
||||
"v2/api-reference/endpoint/peers/create-messages-for-peer",
|
||||
"v2/api-reference/endpoint/peers/get-messages-for-peer",
|
||||
"v2/api-reference/endpoint/peers/get-working-representation",
|
||||
"v2/api-reference/endpoint/peers/search-peer"
|
||||
]
|
||||
|
|
@ -155,7 +154,8 @@
|
|||
"v2/api-reference/endpoint/messages/create-messages-for-session",
|
||||
"v2/api-reference/endpoint/messages/get-messages",
|
||||
"v2/api-reference/endpoint/messages/get-message",
|
||||
"v2/api-reference/endpoint/messages/update-message"
|
||||
"v2/api-reference/endpoint/messages/update-message",
|
||||
"v2/api-reference/endpoint/messages/upload-file"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
openapi: post /v2/workspaces/{workspace_id}/sessions/{session_id}/messages/upload
|
||||
---
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
---
|
||||
openapi: post /v2/workspaces/{workspace_id}/peers/{peer_id}/messages
|
||||
---
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
---
|
||||
openapi: post /v2/workspaces/{workspace_id}/peers/{peer_id}/messages/list
|
||||
---
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
---
|
||||
title: "Cursor & Claude: AI-Powered Honcho Setup"
|
||||
icon: "wand-magic-sparkles"
|
||||
description: "Build Honcho-powered AI agents quickly using Cursor or Claude. Zero configuration required."
|
||||
sidebarTitle: 'AI-Assisted Setup'
|
||||
---
|
||||
|
||||
Get Honcho up and running in minutes using AI coding assistants. These prompts are specifically optimized for Cursor and Claude to generate production-ready code with minimal effort.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
Choose your path based on your use case:
|
||||
|
||||
### Personal AI Assistant
|
||||
|
||||
Build an AI assistant that remembers conversations and learns user preferences.
|
||||
|
||||
<Tip>
|
||||
Copy this prompt into Cursor or Claude to get a complete implementation:
|
||||
</Tip>
|
||||
|
||||
```
|
||||
Create a personal AI assistant using Honcho that remembers user preferences and conversations. Requirements:
|
||||
|
||||
REFERENCE DOCUMENTATION:
|
||||
- Honcho Docs: https://docs.honcho.dev
|
||||
- Honcho GitHub: https://github.com/plastic-labs/honcho
|
||||
- Python SDK: https://github.com/plastic-labs/honcho-python
|
||||
- API Reference: https://docs.honcho.dev/v2/api-reference/introduction
|
||||
|
||||
WHAT TO BUILD:
|
||||
- Personal assistant that learns about the user automatically
|
||||
- Remembers preferences, habits, and conversation history
|
||||
- Provides personalized responses based on past interactions
|
||||
- Uses Honcho's demo server (no setup required)
|
||||
|
||||
TECHNICAL SETUP:
|
||||
- Python with Honcho SDK and OpenAI
|
||||
- Simple command-line interface for testing
|
||||
- Environment: Use demo.honcho.dev (no API key needed)
|
||||
- LLM: OpenAI GPT-4 (provide env var setup)
|
||||
|
||||
CODE REQUIREMENTS:
|
||||
- Complete working example with extensive comments
|
||||
- Error handling and user-friendly messages
|
||||
- Demonstration of key Honcho concepts:
|
||||
* Creating peers (user and assistant)
|
||||
* Managing sessions and conversations
|
||||
* Automatic learning from interactions
|
||||
* Querying learned information
|
||||
* Getting context for AI responses
|
||||
|
||||
EXAMPLE WORKFLOW:
|
||||
1. User starts conversation with assistant
|
||||
2. Assistant responds using any existing knowledge about user
|
||||
3. System automatically learns facts from the conversation
|
||||
4. System stores conversation in session
|
||||
5. Future conversations reference past interactions
|
||||
|
||||
Include installation instructions, environment setup, and example conversations to test.
|
||||
```
|
||||
|
||||
### Discord Bot with Memory
|
||||
|
||||
Create a Discord bot that learns about server members and provides personalized interactions.
|
||||
|
||||
```
|
||||
Build a Discord bot using Honcho that learns about server members and provides personalized interactions.
|
||||
|
||||
REFERENCE DOCUMENTATION:
|
||||
- Honcho Docs: https://docs.honcho.dev
|
||||
- Honcho GitHub: https://github.com/plastic-labs/honcho
|
||||
- Python SDK: https://github.com/plastic-labs/honcho-python
|
||||
- Discord Guide: https://docs.honcho.dev/v2/guides/discord
|
||||
- API Reference: https://docs.honcho.dev/v2/api-reference/introduction
|
||||
|
||||
STARTER TEMPLATE:
|
||||
- Use the official discord-python-starter from Plastic Labs: https://github.com/plastic-labs/discord-python-starter
|
||||
- This template already includes Honcho integration, py-cord, and fly.io deployment
|
||||
- Modify the existing bot.py file to add enhanced memory features
|
||||
|
||||
WHAT TO BUILD:
|
||||
- Discord bot with persistent memory using Honcho
|
||||
- Learns about users through natural conversation
|
||||
- Provides personalized responses based on user history
|
||||
- Handles multi-user conversations with context awareness
|
||||
- Extends the starter template with advanced memory features
|
||||
|
||||
TECHNICAL SETUP:
|
||||
- Clone the discord-python-starter repository
|
||||
- Python with py-cord, Honcho SDK, and OpenRouter LLM support
|
||||
- Uses uv for package management (already configured)
|
||||
- Environment variables template provided (.env.template)
|
||||
- Docker and fly.io deployment ready
|
||||
|
||||
CORE FEATURES TO ADD:
|
||||
- Enhanced per-user memory and personality modeling
|
||||
- Channel-specific session management
|
||||
- Theory-of-mind queries ("What does this user like?")
|
||||
- Advanced fact extraction from conversations
|
||||
- Multi-participant conversation handling
|
||||
- Slash commands for memory management
|
||||
|
||||
IMPLEMENTATION REQUIREMENTS:
|
||||
- Extend the existing on_message function with memory features
|
||||
- Add new slash commands for memory testing and management
|
||||
- Integrate Honcho's dialectic API for personalized responses
|
||||
- Add session management for different channels
|
||||
- Implement background fact learning and storage
|
||||
- Error handling and comprehensive logging
|
||||
|
||||
DEPLOYMENT:
|
||||
- Use the included fly.toml for deployment
|
||||
- Environment variable management with fly secrets
|
||||
- Docker containerization (Dockerfile provided)
|
||||
|
||||
Include examples of enhanced bot interactions and memory demonstrations.
|
||||
```
|
||||
|
||||
## 🎯 Using Cursor
|
||||
|
||||
### Setup Workflow
|
||||
|
||||
<Tabs>
|
||||
<Tab title="New Project">
|
||||
```bash
|
||||
# Create project
|
||||
mkdir my-honcho-agent
|
||||
cd my-honcho-agent
|
||||
|
||||
# Open in Cursor
|
||||
cursor .
|
||||
|
||||
# Use Cmd+L to open AI chat
|
||||
# Paste one of the prompts above
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Discord Starter">
|
||||
```bash
|
||||
# Clone the starter template
|
||||
git clone https://github.com/plastic-labs/discord-python-starter.git
|
||||
cd discord-python-starter
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Configure environment
|
||||
cp .env.template .env
|
||||
# Add your Discord token and API keys
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Cursor Tips
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Reference Codebase" icon="code">
|
||||
Use `@codebase` to ask questions about your entire project
|
||||
</Card>
|
||||
<Card title="Reference Docs" icon="book">
|
||||
Use `@docs https://docs.honcho.dev` for documentation context
|
||||
</Card>
|
||||
<Card title="Generate Tests" icon="vial">
|
||||
Ask Cursor to write comprehensive tests for your Honcho integration
|
||||
</Card>
|
||||
<Card title="Iterate Quickly" icon="arrows-rotate">
|
||||
Request specific improvements: "Add better error handling"
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 🤖 Claude Workflows
|
||||
|
||||
### Rapid Development
|
||||
|
||||
```
|
||||
I want to quickly prototype an AI agent with Honcho. Help me build:
|
||||
|
||||
REFERENCE DOCUMENTATION:
|
||||
- Honcho Docs: https://docs.honcho.dev
|
||||
- Honcho GitHub: https://github.com/plastic-labs/honcho
|
||||
- Quickstart Guide: https://docs.honcho.dev/v2/documentation/introduction/quickstart
|
||||
- SDK Documentation: https://docs.honcho.dev/v2/documentation/platform/sdk
|
||||
|
||||
1. SETUP: Complete development environment with Honcho demo server
|
||||
2. CORE: Basic peer/session/message workflow with memory
|
||||
3. INTEGRATION: OpenAI LLM integration with context management
|
||||
4. TESTING: Simple test cases to verify memory functionality
|
||||
5. ITERATION: Framework for adding features incrementally
|
||||
|
||||
Focus on:
|
||||
- Working code over perfect architecture
|
||||
- Clear comments explaining Honcho concepts
|
||||
- Easy-to-modify structure for experimentation
|
||||
- Immediate feedback and testing capabilities
|
||||
|
||||
Start with the most minimal viable example and show me how to extend it.
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
|
||||
```
|
||||
Help me deploy my Honcho application to production:
|
||||
|
||||
REFERENCE DOCUMENTATION:
|
||||
- Self-Hosting Guide: https://docs.honcho.dev/v2/contributing/self-hosting
|
||||
- Configuration Guide: https://docs.honcho.dev/v2/contributing/configuration-guide
|
||||
- Platform Overview: https://docs.honcho.dev/v2/documentation/platform/overview
|
||||
|
||||
REQUIREMENTS:
|
||||
- Environment configuration and secrets management
|
||||
- Database setup and migrations
|
||||
- API authentication and rate limiting
|
||||
- Monitoring and logging setup
|
||||
- Deployment automation
|
||||
|
||||
Provide step-by-step deployment instructions for [Fly.io/Vercel/Railway/Heroku].
|
||||
```
|
||||
|
||||
## 💡 Common Patterns
|
||||
|
||||
### Basic Conversation Flow
|
||||
|
||||
1. **Initialize**: Create peers and start a session
|
||||
2. **Converse**: Exchange messages between user and assistant
|
||||
3. **Learn**: Honcho automatically extracts facts from conversations
|
||||
4. **Remember**: Future conversations use accumulated context
|
||||
5. **Personalize**: Responses adapt based on learned information
|
||||
|
||||
### Advanced Features
|
||||
|
||||
<Steps>
|
||||
<Step title="Multi-User Sessions">
|
||||
Implement separate memory contexts for different users or channels
|
||||
</Step>
|
||||
<Step title="Context Windows">
|
||||
Manage conversation history to stay within LLM token limits
|
||||
</Step>
|
||||
<Step title="Fact Management">
|
||||
Query and update the knowledge graph programmatically
|
||||
</Step>
|
||||
<Step title="Theory of Mind">
|
||||
Use dialectic API to reason about user preferences and mental states
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
<CodeGroup>
|
||||
```text Connection Issues
|
||||
"My Honcho connection is failing with [ERROR]. Here's my environment setup: [paste code]. What's wrong and how do I fix it?"
|
||||
```
|
||||
|
||||
```text Memory Not Persisting
|
||||
"The agent isn't remembering conversations between sessions. Here's my session management code: [paste code]. Help me debug this."
|
||||
```
|
||||
|
||||
```text Performance Optimization
|
||||
"My Honcho queries are slow. Here's my implementation: [paste code]. How can I optimize this?"
|
||||
```
|
||||
|
||||
```text Integration Problems
|
||||
"I'm trying to integrate Honcho with [SYSTEM] but getting [ERROR]. Here's my approach: [paste code]. What's the correct way to do this?"
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
After your initial setup:
|
||||
|
||||
1. **Add Features**: Extend with voice input, web UI, or API endpoints
|
||||
2. **Improve Memory**: Implement custom fact extraction and retrieval
|
||||
3. **Scale Up**: Add caching, background processing, and optimization
|
||||
4. **Deploy**: Move from demo server to production environment
|
||||
5. **Monitor**: Add logging, metrics, and error tracking
|
||||
|
||||
## 📚 Resources
|
||||
|
||||
### Documentation
|
||||
- **Main Docs**: [docs.honcho.dev](https://docs.honcho.dev)
|
||||
- **API Reference**: [docs.honcho.dev/v2/api-reference](https://docs.honcho.dev/v2/api-reference/introduction)
|
||||
|
||||
### Code & Examples
|
||||
- **Honcho Core**: [github.com/plastic-labs/honcho](https://github.com/plastic-labs/honcho)
|
||||
- **Python SDK**: [github.com/plastic-labs/honcho-python](https://github.com/plastic-labs/honcho-python)
|
||||
- **TypeScript SDK**: [github.com/plastic-labs/honcho-node](https://github.com/plastic-labs/honcho-node)
|
||||
- **Discord Starter**: [github.com/plastic-labs/discord-python-starter](https://github.com/plastic-labs/discord-python-starter)
|
||||
|
||||
### Key Concepts for AI Prompts
|
||||
When working with AI assistants, mention these concepts:
|
||||
- **Core**: "peers, sessions, messages, facts"
|
||||
- **Advanced**: "dialectic API, theory of mind, context management"
|
||||
- **Integration**: "LLM context injection, session persistence, multi-user handling"
|
||||
|
||||
<Note>
|
||||
**Pro Tip**: Be specific about your requirements and constraints when prompting AI. The more context you provide, the better the generated code will match your needs.
|
||||
</Note>
|
||||
|
|
@ -14,7 +14,7 @@ On every message written to a session, an automatic callback is run that will re
|
|||
|
||||
The Dialectic endpoint allows you to define logic enabling your agent to talk to our agent that automatically retrieves and synthesizes facts from the collection. You can use the response as part of your reasoning process for your agent–add it to your next prompt to inject critical context about the user.
|
||||
|
||||
This chat interface is exposed via the `peer.chat()` endpoint. It accepts a string or a list of strings. Below is some example code on how this works.
|
||||
This chat interface is exposed via the `peer.chat()` endpoint. It accepts a string query. Below is some example code on how this works.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,321 @@
|
|||
---
|
||||
title: 'File Uploads'
|
||||
description: 'Upload PDFs, text files, and JSON documents to create messages in Honcho'
|
||||
icon: 'file-upload'
|
||||
---
|
||||
|
||||
Honcho's file upload feature allows you to convert documents into messages automatically. Upload PDFs, text files, or JSON documents, and Honcho will extract the text content, split it into appropriately sized chunks, and create messages that become part of your peer's knowledge or session context.
|
||||
|
||||
This feature is perfect for ingesting documents, reports, research papers, or any text-based content that you want your AI agents to understand and reference.
|
||||
|
||||
## How It Works
|
||||
|
||||
When you upload a file, Honcho:
|
||||
|
||||
1. **Extracts text** from the file using specialized processors based on file type
|
||||
2. **Creates messages** with the extracted content split into chunks that fit within message limits (messages are limited to 50,000 characters)
|
||||
3. **Queues processing** for background analysis and insight derivation like any other message
|
||||
|
||||
The file content becomes part of the peer's representation, making it available for natural language queries and context retrieval.
|
||||
|
||||
## Supported File Types
|
||||
|
||||
Honcho currently supports the following file types with more to come:
|
||||
|
||||
- **PDF files** (`application/pdf`) - Text extraction with page numbers
|
||||
- **Text files** (`text/*`) - Plain text, markdown, code files, etc.
|
||||
- **JSON files** (`application/json`) - Structured data converted to readable format
|
||||
|
||||
<Note>
|
||||
Files are processed in memory and not stored on disk. Only the extracted text content is preserved in Honcho's message system.
|
||||
</Note>
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Upload a Single File
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
from honcho import Honcho
|
||||
|
||||
# Initialize client
|
||||
honcho = Honcho()
|
||||
|
||||
# Create session and peer
|
||||
session = honcho.session("research-session")
|
||||
user = honcho.peer("researcher")
|
||||
|
||||
# Upload a PDF to a session
|
||||
with open("research_paper.pdf", "rb") as file:
|
||||
messages = session.upload_file(
|
||||
file=file,
|
||||
peer_id=user.id,
|
||||
)
|
||||
|
||||
print(f"Created {len(messages)} messages from the PDF")
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
import { Honcho } from "@honcho-ai/sdk";
|
||||
import fs from "fs";
|
||||
|
||||
// Initialize client
|
||||
const honcho = new Honcho({});
|
||||
|
||||
// Create session and peer
|
||||
const session = honcho.session("research-session");
|
||||
const user = honcho.peer("researcher");
|
||||
|
||||
// Upload a PDF to a session
|
||||
const fileStream = fs.createReadStream("research_paper.pdf");
|
||||
const messages = await session.uploadFile({
|
||||
file: fileStream,
|
||||
peerId: user.id,
|
||||
});
|
||||
|
||||
console.log(`Created ${messages.length} messages from the PDF`);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Upload to Peer's Global Representation
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
# Upload files directly to a peer's global representation
|
||||
with open("personal_notes.pdf", "rb") as file:
|
||||
messages = user.upload_file(
|
||||
file=file,
|
||||
)
|
||||
|
||||
print(f"Added {len(messages)} messages to {user.id}'s global representation")
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
// Upload files directly to a peer's global representation
|
||||
const fileStream = fs.createReadStream("personal_notes.pdf");
|
||||
const messages = await user.uploadFile({
|
||||
file: fileStream,
|
||||
});
|
||||
|
||||
console.log(`Added ${messages.length} messages to ${user.id}'s global representation`);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Upload Parameters
|
||||
|
||||
The upload methods accept the following parameters:
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `file` | File | Yes | File to upload |
|
||||
| `peer_id` | String | Session only | ID of the peer creating the messages |
|
||||
|
||||
## File Processing Details
|
||||
|
||||
### Text Extraction
|
||||
|
||||
**PDF Files**: Text is extracted page by page with page numbers preserved:
|
||||
```
|
||||
[Page 1]
|
||||
Introduction
|
||||
This document provides...
|
||||
|
||||
[Page 2]
|
||||
Methodology
|
||||
Our approach involves...
|
||||
```
|
||||
|
||||
**Text Files**: Content is decoded using UTF-8, UTF-16, or Latin-1 encoding as needed.
|
||||
|
||||
**JSON Files**: Structured data is converted to string format.
|
||||
|
||||
### Chunking Strategy
|
||||
|
||||
Large files are automatically split into chunks of ~49,500 characters. The system seeks to break at natural boundaries if present:
|
||||
|
||||
1. Paragraph breaks (`\n\n`)
|
||||
2. Line breaks (`\n`)
|
||||
3. Sentence endings (`. `)
|
||||
4. Word boundaries (` `)
|
||||
|
||||
Each chunk becomes a separate message, maintaining the original document structure.
|
||||
|
||||
## Querying Uploaded Content
|
||||
|
||||
Once files are uploaded, you can query the content using Honcho's natural language interface:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
# Query what was learned from the uploaded documents
|
||||
response = user.chat("What are the key findings from the research papers I uploaded?")
|
||||
print(response)
|
||||
|
||||
# Ask about specific documents
|
||||
response = user.chat("What does the quarterly report say about revenue growth?")
|
||||
print(response)
|
||||
|
||||
# Get context from the uploaded documents for LLM integration
|
||||
context = session.get_context(tokens=3000)
|
||||
messages = context.to_openai(assistant=assistant)
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
// Query what was learned from the uploaded documents
|
||||
const response = await user.chat("What are the key findings from the research papers I uploaded?");
|
||||
console.log(response);
|
||||
|
||||
// Ask about specific documents
|
||||
const response2 = await user.chat("What does the quarterly report say about revenue growth?");
|
||||
console.log(response2);
|
||||
|
||||
// Get context from the uploaded documents for LLM integration
|
||||
const context = await session.getContext({ tokens: 3000 });
|
||||
const messages = context.toOpenAI(assistant);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Unsupported File Types
|
||||
|
||||
Files with unsupported content types will raise an exception:
|
||||
|
||||
```python
|
||||
try:
|
||||
messages = session.upload_file(
|
||||
file=open("image.jpg", "rb"),
|
||||
peer_id=user.id
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Upload failed: {e}")
|
||||
# Error: "Could not process file image.jpg: Unsupported file type: image/jpeg"
|
||||
```
|
||||
|
||||
### Missing Required Fields
|
||||
|
||||
Session uploads require a `peer_id` parameter:
|
||||
|
||||
```python
|
||||
# This will fail for session uploads
|
||||
try:
|
||||
messages = session.upload_file(file=file) # Missing peer_id
|
||||
except ValueError as e:
|
||||
print(f"Validation error: {e}")
|
||||
```
|
||||
|
||||
## Complete Example: Document Analysis Assistant
|
||||
|
||||
Here's a complete example of building a document analysis assistant:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
from honcho import Honcho
|
||||
|
||||
# Initialize
|
||||
honcho = Honcho()
|
||||
session = honcho.session("document-analysis")
|
||||
user = honcho.peer("analyst")
|
||||
assistant = honcho.peer("analysis-bot")
|
||||
|
||||
def upload_document(file_path, description):
|
||||
"""Upload a document and add it to the session"""
|
||||
with open(file_path, "rb") as file:
|
||||
messages = session.upload_file(
|
||||
file=file,
|
||||
peer_id=user.id,
|
||||
)
|
||||
return messages
|
||||
|
||||
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",
|
||||
"content": "Please analyze all the documents I've uploaded and provide a comprehensive summary of the key findings, trends, and recommendations."
|
||||
})
|
||||
|
||||
# Call OpenAI (or your preferred LLM)
|
||||
# response = openai.chat.completions.create(model="gpt-4", messages=messages)
|
||||
# return response.choices[0].message.content
|
||||
|
||||
return "Analysis would be generated here"
|
||||
|
||||
# Upload multiple documents
|
||||
documents = [
|
||||
("quarterly_report.pdf", "Q3 2024 Quarterly Financial Report"),
|
||||
("market_research.pdf", "Market Analysis and Competitive Landscape"),
|
||||
("product_roadmap.pdf", "Product Development Roadmap 2024-2025")
|
||||
]
|
||||
|
||||
for file_path, description in documents:
|
||||
messages = upload_document(file_path, description)
|
||||
print(f"Uploaded {file_path}: {len(messages)} messages created")
|
||||
|
||||
# Get AI analysis
|
||||
analysis = analyze_documents()
|
||||
print("Document Analysis:", analysis)
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
import { Honcho } from "@honcho-ai/sdk";
|
||||
import fs from "fs";
|
||||
|
||||
// Initialize
|
||||
const honcho = new Honcho({});
|
||||
const session = honcho.session("document-analysis");
|
||||
const user = honcho.peer("analyst");
|
||||
const assistant = honcho.peer("analysis-bot");
|
||||
|
||||
async function uploadDocument(filePath: string, description: string) {
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
const messages = await session.uploadFile({
|
||||
file: fileStream,
|
||||
peerId: user.id,
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function analyzeDocuments() {
|
||||
const context = await session.getContext({ tokens: 4000 });
|
||||
const messages = context.toOpenAI(assistant);
|
||||
|
||||
// Add analysis request
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: "Please analyze all the documents I've uploaded and provide a comprehensive summary of the key findings, trends, and recommendations."
|
||||
});
|
||||
|
||||
// Call OpenAI (or your preferred LLM)
|
||||
// const response = await openai.chat.completions.create({ model: "gpt-4", messages });
|
||||
// return response.choices[0].message.content;
|
||||
|
||||
return "Analysis would be generated here";
|
||||
}
|
||||
|
||||
// Upload multiple documents
|
||||
const documents = [
|
||||
["quarterly_report.pdf", "Q3 2024 Quarterly Financial Report"],
|
||||
["market_research.pdf", "Market Analysis and Competitive Landscape"],
|
||||
["product_roadmap.pdf", "Product Development Roadmap 2024-2025"]
|
||||
];
|
||||
|
||||
for (const [filePath, description] of documents) {
|
||||
const messages = await uploadDocument(filePath, description);
|
||||
console.log(`Uploaded ${filePath}: ${messages.length} messages created`);
|
||||
}
|
||||
|
||||
// Get AI analysis
|
||||
const analysis = await analyzeDocuments();
|
||||
console.log("Document Analysis:", analysis);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Always wrap uploads in try-catch blocks** for robust error handling
|
||||
- **Validate file types** before upload to avoid processing errors
|
||||
- **Handle large files gracefully** with progress indicators
|
||||
- **Implement retry logic** for network failures
|
||||
|
|
@ -31,7 +31,8 @@ Implementation patterns for Honcho's core capabilities
|
|||
**[Search](search)** - Search your data using natural language
|
||||
**[Working Representations](working-representations)** - Understanding and customizing user models
|
||||
**[Streaming Responses](streaming-responses)** - Handle real-time interactions efficiently
|
||||
**[Using Filters](using-filters)** - Control what data gets processed and how
|
||||
**[Using Filters](using-filters)** - Control what data gets processed and how
|
||||
**[File Uploads](file-uploads)** - Upload PDF, text, or JSON files to create messages
|
||||
|
||||
## Philosophy
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from logging.config import fileConfig
|
|||
from pathlib import Path
|
||||
|
||||
from alembic import context
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import engine_from_config, pool, text
|
||||
|
||||
from src.config import settings
|
||||
|
|
@ -20,9 +19,6 @@ logging.getLogger("alembic").setLevel(logging.DEBUG)
|
|||
# Add project root to Python path
|
||||
sys.path.append(str(Path(__file__).parents[1]))
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv(override=True)
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
|
|
|||
|
|
@ -553,4 +553,3 @@ def downgrade():
|
|||
# Drop the column
|
||||
op.drop_column("sessions", "app_id", schema=schema)
|
||||
print("Dropped app_id column from sessions table")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "honcho"
|
||||
version = "2.0.5"
|
||||
version = "2.1.0"
|
||||
description = "Honcho Server"
|
||||
authors = [
|
||||
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
|
||||
|
|
@ -26,10 +26,12 @@ dependencies = [
|
|||
"openai>=1.91.0",
|
||||
"pydantic>=2.11.7",
|
||||
"pydantic-settings>=2.10.1",
|
||||
"google-generativeai>=0.8.5",
|
||||
"pdfplumber>=0.11.7",
|
||||
]
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"honcho-core>=1.1.0",
|
||||
"honcho-core>=1.2.0",
|
||||
"pytest>=8.2.2",
|
||||
"sqlalchemy-utils>=0.41.2",
|
||||
"pytest-asyncio>=0.23.7",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
|
|||
from src import models # noqa: E402
|
||||
from src.config import settings # noqa: E402
|
||||
from src.dependencies import tracked_db # noqa: E402
|
||||
from src.embeddings import EmbeddingClient # noqa: E402
|
||||
from src.embedding_client import EmbeddingClient # noqa: E402
|
||||
|
||||
|
||||
async def get_messages_without_embeddings(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,22 @@ 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/).
|
||||
|
||||
## [1.2.0] - 2025-07-16
|
||||
|
||||
### Added
|
||||
|
||||
- Get/poll deriver queue status endpoints added to workspace
|
||||
- Added endpoint to upload files as messages
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed peer messages in accordance with Honcho 2.1.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated chat endpoint to use singular `query` in accordance with Honcho 2.1.0
|
||||
|
||||
|
||||
## [1.1.0] - 2025-07-08
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -29,8 +29,11 @@ session.add_messages([
|
|||
bob.message("Hi Alice, how are you?")
|
||||
])
|
||||
|
||||
# Wait for deriver to process all messages (only necessary if very recent messages are critical to query)
|
||||
client.poll_deriver_status()
|
||||
|
||||
# Query conversation context
|
||||
response = alice.chat("What did Bob say to me?")
|
||||
response = alice.chat("What did Bob say to the user?")
|
||||
print(response)
|
||||
```
|
||||
|
||||
|
|
@ -95,10 +98,6 @@ from honcho import AsyncHoncho
|
|||
|
||||
async def main():
|
||||
client = AsyncHoncho(api_key="your-api-key")
|
||||
|
||||
peer = client.peer("user")
|
||||
response = await peer.chat("Hello!")
|
||||
print(response)
|
||||
```
|
||||
|
||||
### Metadata Management
|
||||
|
|
@ -107,9 +106,6 @@ async def main():
|
|||
# Set peer metadata
|
||||
user.set_metadata({"location": "San Francisco", "preferences": {"theme": "dark"}})
|
||||
|
||||
# Query using metadata context
|
||||
response = user.chat("What's the weather like where I am?")
|
||||
|
||||
# Session metadata
|
||||
session.set_metadata({"topic": "project-planning", "priority": "high"})
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
from honcho import AsyncHoncho
|
||||
from honcho.async_client.session import SessionPeerConfig
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
async def main():
|
||||
# HONCHO_API_KEY is an environment variable
|
||||
# HONCHO_URL is an *optional* environment variable
|
||||
# HONCHO_WORKSPACE_ID is an *optional* environment variable
|
||||
# Using local server for this example
|
||||
honcho = AsyncHoncho(environment="local", workspace_id="test")
|
||||
|
||||
_workspaces = await honcho.get_workspaces()
|
||||
|
||||
# these don't make any API calls, just produce a AsyncPeer object in SDK
|
||||
# in practice, these would be UUIDs, as peer IDs are unique within their workspace
|
||||
assistant = await honcho.peer(id="bob")
|
||||
alice = await honcho.peer(id="alice")
|
||||
|
||||
# empty since peers are not created until they are used
|
||||
_peers = await honcho.get_peers()
|
||||
|
||||
# workspace-level metadata
|
||||
_m = await honcho.get_metadata()
|
||||
await honcho.set_metadata({"test": "test"})
|
||||
|
||||
# calling the dialectic chat endpoint makes an API call.
|
||||
# when this call occurs, the "alice" peer will be get_or_create'd
|
||||
# response will be None because we haven't talked yet!
|
||||
_response = await alice.chat("what did alice have for breakfast today?")
|
||||
|
||||
# sessions are scoped to a set of peers and contain messages/content
|
||||
# this is not an API call, like peers this is created lazily
|
||||
my_session = await honcho.session(id="session_1")
|
||||
|
||||
# API call
|
||||
await my_session.add_peers(
|
||||
[alice, (assistant, SessionPeerConfig(observe_others=False, observe_me=False))]
|
||||
)
|
||||
|
||||
# adding/removing peers from sessions creates a bidirectional relationship,
|
||||
# so no need for operations like `alice.join(my_session)`.
|
||||
|
||||
# this will return a list of sessions [my_session]
|
||||
# this is also an API call
|
||||
_sessions = await alice.get_sessions()
|
||||
|
||||
# API call to create 1 or more messages (overload, can be Message or list[Message]
|
||||
await my_session.add_messages(
|
||||
[
|
||||
# creates a Message object with peer_id="alice", etc etc
|
||||
assistant.message("what did you have for breakfast today, alice?"),
|
||||
alice.message("i had oatmeal."),
|
||||
]
|
||||
)
|
||||
|
||||
m = await my_session.get_metadata()
|
||||
m["test"] = "test2"
|
||||
await my_session.set_metadata(m)
|
||||
|
||||
# peers have one "omnipresent" global representation, comprised of all
|
||||
# the content associated with that peer in this honcho instance.
|
||||
|
||||
# they also have a potentially infinite number of "local" representations,
|
||||
# each one from the perspective of *another* peer in the honcho instance.
|
||||
|
||||
# this is a query to alice's global representation--no scope
|
||||
_response = await alice.chat("what did the user have for breakfast today?")
|
||||
|
||||
# this is a query to alice's local representation *of the assistant*
|
||||
_response = await alice.chat(
|
||||
"does alice know what bob had for breakfast?", target=assistant
|
||||
)
|
||||
|
||||
# this is a query to the assistant's local representation *of alice* in this session
|
||||
_response = await assistant.chat(
|
||||
"does the assistant know what alice had for breakfast?",
|
||||
target=alice,
|
||||
session_id=my_session.id,
|
||||
)
|
||||
|
||||
# API call to store non-message content under a peer + optional session
|
||||
await alice.add_messages(
|
||||
"this might be a document about alice, say, a journal entry."
|
||||
)
|
||||
|
||||
# This does make an API call because we set a configuration for this new peer
|
||||
charlie = await honcho.peer(id="charlie", config={"observe_me": False})
|
||||
|
||||
await my_session.add_messages(charlie.message("hello world!"))
|
||||
|
||||
# session now has 3 members: alice, bob, and charlie. a message automatically adds a peer to a session.
|
||||
|
||||
# peers, sessions, and messages all have metadata which can be modified and used in queries.
|
||||
|
||||
# API call to get metadata?
|
||||
charlie_metadata = await charlie.get_metadata()
|
||||
|
||||
charlie_metadata["location"] = "the moon"
|
||||
|
||||
# API call to store metadata?
|
||||
await charlie.set_metadata(charlie_metadata)
|
||||
|
||||
# response will tell you that charlie is on the moon
|
||||
_response = await charlie.chat("where is the user?")
|
||||
|
||||
# you can get the messages from a session, either fully or partially.
|
||||
# (API call)
|
||||
_messages = await my_session.get_messages()
|
||||
|
||||
context = await my_session.get_context()
|
||||
|
||||
_messages = context.to_openai(assistant=assistant.id)
|
||||
|
||||
_messages = context.to_anthropic(assistant=assistant.id)
|
||||
|
||||
await my_session.add_messages(
|
||||
assistant.message("This is a test message using the property syntax")
|
||||
)
|
||||
|
||||
print("Async sample code executed successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -25,6 +25,8 @@ for i in range(10):
|
|||
|
||||
session.add_messages(messages)
|
||||
|
||||
honcho.poll_deriver_status()
|
||||
|
||||
# Chat with alice
|
||||
alice = peers[0]
|
||||
response = alice.chat("what did alice have for breakfast today?")
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
import logging
|
||||
|
||||
from honcho import Honcho
|
||||
from honcho.session import SessionPeerConfig
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
# HONCHO_API_KEY is an environment variable
|
||||
# HONCHO_URL is an *optional* environment variable
|
||||
# HONCHO_WORKSPACE_ID is an *optional* environment variable
|
||||
# Using local server for this example
|
||||
honcho = Honcho(environment="local", workspace_id="test")
|
||||
|
||||
workspaces = honcho.get_workspaces()
|
||||
|
||||
# these don't make any API calls, just produce a Peer object in SDK
|
||||
# in practice, these would be UUIDs, as peer IDs are unique within their workspace
|
||||
assistant = honcho.peer(id="bob")
|
||||
alice = honcho.peer(id="alice")
|
||||
|
||||
# empty since peers are not created until they are used
|
||||
peers = honcho.get_peers()
|
||||
|
||||
# workspace-level metadata
|
||||
_m = honcho.get_metadata()
|
||||
honcho.set_metadata({"test": "test"})
|
||||
|
||||
# calling the dialectic chat endpoint makes an API call.
|
||||
# when this call occurs, the "alice" peer will be get_or_create'd
|
||||
# response will be None because we haven't talked yet!
|
||||
response = alice.chat("what did alice have for breakfast today?")
|
||||
|
||||
# sessions are scoped to a set of peers and contain messages/content
|
||||
# this is not an API call, like peers this is created lazily
|
||||
my_session = honcho.session(id="session_1")
|
||||
|
||||
# API call
|
||||
my_session.add_peers(
|
||||
[alice, (assistant, SessionPeerConfig(observe_others=False, observe_me=False))]
|
||||
)
|
||||
|
||||
# adding/removing peers from sessions creates a bidirectional relationship,
|
||||
# so no need for operations like `alice.join(my_session)`.
|
||||
|
||||
# this will return a list of sessions [my_session]
|
||||
# this is also an API call
|
||||
_sessions = alice.get_sessions()
|
||||
|
||||
# API call to create 1 or more messages (overload, can be Message or list[Message]
|
||||
my_session.add_messages(
|
||||
[
|
||||
# creates a Message object with peer_id="alice", etc etc
|
||||
assistant.message("what did you have for breakfast today, alice?"),
|
||||
alice.message("i had oatmeal."),
|
||||
]
|
||||
)
|
||||
|
||||
m = my_session.get_metadata()
|
||||
m["test"] = "test2"
|
||||
my_session.set_metadata(m)
|
||||
|
||||
# peers have one "omnipresent" global representation, comprised of all
|
||||
# the content associated with that peer in this honcho instance.
|
||||
|
||||
# they also have a potentially infinite number of "local" representations,
|
||||
# each one from the perspective of *another* peer in the honcho instance.
|
||||
|
||||
# this is a query to alice's global representation--no scope
|
||||
response = alice.chat("what did the user have for breakfast today?")
|
||||
|
||||
# this is a query to alice's local representation *of the assistant*
|
||||
response = alice.chat("does alice know what bob had for breakfast?", target=assistant)
|
||||
|
||||
# this is a query to the assistant's local representation *of alice* in this session
|
||||
response = assistant.chat(
|
||||
"does the assistant know what alice had for breakfast?",
|
||||
target=alice,
|
||||
session_id=my_session.id,
|
||||
)
|
||||
|
||||
# API call to store non-message content under a peer + optional session
|
||||
alice.add_messages("this might be a document about alice, say, a journal entry.")
|
||||
|
||||
charlie = honcho.peer(id="charlie")
|
||||
|
||||
my_session.add_messages(charlie.message("hello world!"))
|
||||
|
||||
# session now has 3 members: alice, bob, and charlie. a message automatically adds a peer to a session.
|
||||
|
||||
# peers, sessions, and messages all have metadata which can be modified and used in queries.
|
||||
|
||||
# API call to get metadata?
|
||||
charlie_metadata = charlie.get_metadata()
|
||||
|
||||
charlie_metadata["location"] = "the moon"
|
||||
|
||||
# API call to store metadata?
|
||||
charlie.set_metadata(charlie_metadata)
|
||||
|
||||
# response will tell you that charlie is on the moon
|
||||
response = charlie.chat("where is the user?")
|
||||
|
||||
# you can get the messages from a session, either fully or partially.
|
||||
# (API call)
|
||||
messages = my_session.get_messages()
|
||||
|
||||
context = my_session.get_context()
|
||||
|
||||
messages = context.to_openai(assistant=assistant.id)
|
||||
|
||||
messages = context.to_anthropic(assistant=assistant.id)
|
||||
|
||||
my_session.add_messages(
|
||||
assistant.message("This is a test message using the property syntax")
|
||||
)
|
||||
|
||||
print("Sample code executed successfully!")
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import uuid
|
||||
|
||||
from honcho import Honcho
|
||||
|
||||
# Create a Honcho client with the default workspace
|
||||
honcho = Honcho(environment="local")
|
||||
|
||||
# Create a new session
|
||||
session = honcho.session("file_upload_test_" + str(uuid.uuid4()))
|
||||
|
||||
# Upload the current file directly using a file object
|
||||
with open(__file__, "rb") as file:
|
||||
session.upload_file(file, peer_id="alice")
|
||||
|
||||
# get the messages from the session
|
||||
# should contain the contents of this file!
|
||||
messages = session.get_messages()
|
||||
for message in messages:
|
||||
print(str(message))
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import time
|
||||
import uuid
|
||||
|
||||
from honcho import Honcho
|
||||
from honcho.session import SessionPeerConfig
|
||||
|
||||
# Create a Honcho client with the default workspace
|
||||
honcho = Honcho(environment="local")
|
||||
|
||||
alice = honcho.peer("alice")
|
||||
bob = honcho.peer("bob")
|
||||
|
||||
# Create a new session
|
||||
session = honcho.session("chat_test_" + str(uuid.uuid4()))
|
||||
|
||||
session.add_peers(
|
||||
[
|
||||
(alice, SessionPeerConfig(observe_me=True, observe_others=True)),
|
||||
(bob, SessionPeerConfig(observe_me=True, observe_others=True)),
|
||||
]
|
||||
)
|
||||
|
||||
# Generate messages with personal information
|
||||
messages = []
|
||||
messages.append(alice.message("I had a great breakfast today!"))
|
||||
messages.append(bob.message("What did you eat?"))
|
||||
messages.append(alice.message("I had pancakes and eggs and bacon."))
|
||||
|
||||
session.add_messages(messages)
|
||||
|
||||
# sleep to get later timestamps on these "future" messages
|
||||
time.sleep(5)
|
||||
|
||||
# Create a separate session
|
||||
session2 = honcho.session("chat_test_" + str(uuid.uuid4()))
|
||||
session2.add_peers(
|
||||
[
|
||||
(alice, SessionPeerConfig(observe_me=True, observe_others=True)),
|
||||
(bob, SessionPeerConfig(observe_me=True, observe_others=True)),
|
||||
]
|
||||
)
|
||||
session2.add_messages(
|
||||
[
|
||||
alice.message(
|
||||
"Hey remember when I told you I had a great breakfast today? I lied. I actually skipped breakfast."
|
||||
),
|
||||
bob.message("WTF is wrong with you??"),
|
||||
]
|
||||
)
|
||||
|
||||
# wait for the deriver to process the messages
|
||||
print("waiting for the deriver to process all the messages")
|
||||
deriver_status = honcho.poll_deriver_status()
|
||||
print("deriver status:", deriver_status)
|
||||
|
||||
|
||||
# # Chat with alice's honcho-level representation
|
||||
# print(
|
||||
# "\n\n\033[1m asking alice's honcho-level representation what she had for breakfast \033[0m"
|
||||
# )
|
||||
# response = alice.chat("what did alice have for breakfast today?", session_id=session.id)
|
||||
# print("response:", response)
|
||||
|
||||
# Chat with bob's internal representation of alice
|
||||
print(
|
||||
"\n\n\033[1m asking bob what alice had for breakfast -- scoped to session 1 \033[0m"
|
||||
)
|
||||
response = bob.chat(
|
||||
"what did alice have for breakfast today?", target=alice, session_id=session.id
|
||||
)
|
||||
print("response:", response)
|
||||
|
||||
print(
|
||||
"\n\n\033[1m asking bob what alice had for breakfast -- scoped to session 2 \033[0m"
|
||||
)
|
||||
response = bob.chat(
|
||||
"what did alice have for breakfast today?", target=alice, session_id=session2.id
|
||||
)
|
||||
print("response:", response)
|
||||
|
||||
print("\n\n\033[1m asking bob what alice had for breakfast -- global scope \033[0m")
|
||||
response = bob.chat("what did alice have for breakfast today?", target=alice)
|
||||
print("response:", response)
|
||||
|
|
@ -12,12 +12,14 @@ peers = [
|
|||
honcho.peer("charlie"),
|
||||
]
|
||||
|
||||
alice = peers[0]
|
||||
|
||||
# Create a new session
|
||||
session = honcho.session("search_test_" + str(uuid.uuid4()))
|
||||
|
||||
# Create a message with our special keyword
|
||||
keyword = f"~special-{str(uuid.uuid4())}~"
|
||||
session.add_messages(peers[0].message(f"I am a {keyword} message"))
|
||||
session.add_messages(alice.message(f"I am a {keyword} message"))
|
||||
|
||||
# Generate some random messages from alice, bob, and charlie and add them to the session
|
||||
messages = []
|
||||
|
|
@ -39,13 +41,7 @@ search_results = honcho.search(keyword)
|
|||
print("searching the workspace")
|
||||
print("search results returned:", [message for message in search_results])
|
||||
|
||||
alice = peers[0]
|
||||
|
||||
# Add a different message to alice's global representation
|
||||
different_keyword = f"~different-{str(uuid.uuid4())}~"
|
||||
alice.add_messages(alice.message(f"I am a {different_keyword} message"))
|
||||
|
||||
# Search alice's global representation for the different message
|
||||
search_results = alice.search(different_keyword)
|
||||
print("searching alice's global representation")
|
||||
# Search alice's messages for the special keyword
|
||||
search_results = alice.search(keyword)
|
||||
print("searching alice's messages")
|
||||
print("search results returned:", [message for message in search_results])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "honcho-ai"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
description = "Official DX Optimized Python SDK for Honcho"
|
||||
dynamic = ["readme"]
|
||||
license = "Apache-2.0"
|
||||
|
|
@ -8,7 +8,7 @@ authors = [
|
|||
{ name = "Plastic Labs", email = "hello@plasticlabs.ai" },
|
||||
]
|
||||
dependencies = [
|
||||
"honcho-core>=1.1.0",
|
||||
"honcho-core>=1.2.0",
|
||||
"httpx>=0.28.0, <1",
|
||||
"pydantic>=2.0.0, <3",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ Usage:
|
|||
bob.message("Hi Alice, how are you?")
|
||||
])
|
||||
|
||||
# Wait for deriver to process all messages (only necessary if very recent messages are critical to query)
|
||||
client.poll_deriver_status()
|
||||
|
||||
# Query conversation context
|
||||
response = alice.chat("What did Bob say to me?")
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from honcho_core import AsyncHoncho as AsyncHonchoCore
|
||||
from honcho_core import Honcho as HonchoCore
|
||||
from honcho_core.types import DeriverStatus
|
||||
from honcho_core.types.workspaces.sessions.message import Message
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
|
||||
|
||||
|
|
@ -30,6 +33,8 @@ class AsyncHoncho(BaseModel):
|
|||
workspace_id: Workspace ID for scoping operations
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow") # pyright: ignore
|
||||
|
||||
workspace_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
|
|
@ -311,6 +316,105 @@ class AsyncHoncho(BaseModel):
|
|||
)
|
||||
return AsyncPage(messages_page)
|
||||
|
||||
@validate_call
|
||||
async def get_deriver_status(
|
||||
self,
|
||||
observer_id: str | None = None,
|
||||
sender_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> DeriverStatus:
|
||||
"""
|
||||
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
|
||||
"""
|
||||
return await self._client.workspaces.deriver_status(
|
||||
workspace_id=self.workspace_id,
|
||||
observer_id=observer_id,
|
||||
sender_id=sender_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
@validate_call
|
||||
async def poll_deriver_status(
|
||||
self,
|
||||
observer_id: str | None = None,
|
||||
sender_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
timeout: float = Field(
|
||||
300.0,
|
||||
gt=0,
|
||||
description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).",
|
||||
),
|
||||
) -> DeriverStatus:
|
||||
"""
|
||||
Poll get_deriver_status until pending_work_units and in_progress_work_units are both 0.
|
||||
This allows you to guarantee that all messages have been processed by the deriver for
|
||||
use with the dialectic endpoint.
|
||||
|
||||
The polling estimates sleep time by assuming each work unit takes 1 second.
|
||||
|
||||
Args:
|
||||
observer_id: Optional observer ID to scope the status check
|
||||
sender_id: Optional sender ID to scope the status check
|
||||
session_id: Optional session ID to scope the status check
|
||||
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
|
||||
|
||||
Returns:
|
||||
DeriverStatus when all work units are complete
|
||||
|
||||
Raises:
|
||||
TimeoutError: If timeout is exceeded before work units complete
|
||||
Exception: If get_deriver_status fails repeatedly
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
status = await self.get_deriver_status(
|
||||
observer_id, sender_id, session_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get deriver status: {e}")
|
||||
# Sleep briefly before retrying
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Check timeout after error
|
||||
elapsed_time = time.time() - start_time
|
||||
if elapsed_time >= timeout:
|
||||
raise TimeoutError(
|
||||
f"Polling timeout exceeded after {timeout}s. "
|
||||
+ f"Error during status check: {e}"
|
||||
) from e
|
||||
continue
|
||||
|
||||
if status.pending_work_units == 0 and status.in_progress_work_units == 0:
|
||||
return status
|
||||
|
||||
# Check timeout before sleeping
|
||||
elapsed_time = time.time() - start_time
|
||||
if elapsed_time >= timeout:
|
||||
raise TimeoutError(
|
||||
f"Polling timeout exceeded after {timeout}s. "
|
||||
+ f"Current status: {status.pending_work_units} pending, "
|
||||
+ f"{status.in_progress_work_units} in progress work units."
|
||||
)
|
||||
|
||||
# Sleep for the expected time to complete all current work units
|
||||
# Assuming each pending and in-progress work unit takes 1 second
|
||||
total_work_units = status.pending_work_units + status.in_progress_work_units
|
||||
sleep_time = max(1, total_work_units)
|
||||
|
||||
# Don't sleep past the timeout
|
||||
remaining_time = timeout - elapsed_time
|
||||
sleep_time = min(sleep_time, remaining_time)
|
||||
if sleep_time <= 0:
|
||||
raise TimeoutError(
|
||||
f"Polling timeout exceeded after {timeout}s. "
|
||||
+ f"Current status: {status.pending_work_units} pending, "
|
||||
+ f"{status.in_progress_work_units} in progress work units."
|
||||
)
|
||||
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a string representation of the AsyncHoncho client.
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class AsyncPeer(BaseModel):
|
|||
|
||||
async def chat(
|
||||
self,
|
||||
queries: str | list[str],
|
||||
query: str,
|
||||
*,
|
||||
stream: bool = False,
|
||||
target: str | AsyncPeer | None = None,
|
||||
|
|
@ -107,9 +107,9 @@ class AsyncPeer(BaseModel):
|
|||
representation of another peer (what this peer knows about the target peer).
|
||||
|
||||
Args:
|
||||
queries: The natural language question(s) to ask. Can be a single string or a list of strings.
|
||||
query: The natural language question to ask.
|
||||
stream: Whether to stream the response
|
||||
target: Optional target peer for local representation queries. If provided,
|
||||
target: Optional target peer for local representation query. If provided,
|
||||
queries what this peer knows about the target peer rather than
|
||||
querying the peer's global representation
|
||||
session_id: Optional session ID to scope the query to a specific session.
|
||||
|
|
@ -122,7 +122,7 @@ class AsyncPeer(BaseModel):
|
|||
response = await self._client.workspaces.peers.chat(
|
||||
peer_id=self.id,
|
||||
workspace_id=self.workspace_id,
|
||||
queries=queries,
|
||||
query=query,
|
||||
stream=stream,
|
||||
target=str(target.id) if isinstance(target, AsyncPeer) else target,
|
||||
session_id=session_id,
|
||||
|
|
@ -154,75 +154,6 @@ class AsyncPeer(BaseModel):
|
|||
lambda session: AsyncSession(session.id, self.workspace_id, self._client),
|
||||
)
|
||||
|
||||
@validate_call
|
||||
async def add_messages(
|
||||
self,
|
||||
content: str | MessageCreateParam | list[MessageCreateParam] = Field(
|
||||
..., description="Content to add to the peer's representation"
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Add messages or content to this peer's global representation.
|
||||
|
||||
Makes an async API call to store content associated with this peer. This content
|
||||
becomes part of the peer's global knowledge base and can be retrieved
|
||||
through chat queries. Content can be provided as raw strings, Message objects,
|
||||
or lists of Message objects.
|
||||
|
||||
Args:
|
||||
content: Content to add to the peer's representation. Can be:
|
||||
- str: Raw text content that will be converted to a Message
|
||||
- Message: A single Message object to add
|
||||
- List[Message]: Multiple Message objects to add in batch
|
||||
"""
|
||||
messages: list[MessageCreateParam]
|
||||
if isinstance(content, str):
|
||||
messages = [
|
||||
MessageCreateParam(peer_id=self.id, content=content, metadata=None)
|
||||
]
|
||||
elif isinstance(content, list):
|
||||
messages = content
|
||||
else:
|
||||
messages = [content]
|
||||
|
||||
await self._client.workspaces.peers.messages.create(
|
||||
peer_id=self.id,
|
||||
workspace_id=self.workspace_id,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
@validate_call
|
||||
async def get_messages(
|
||||
self,
|
||||
*,
|
||||
filters: dict[str, object] | None = Field(
|
||||
None, description="Dictionary of filter criteria"
|
||||
),
|
||||
) -> AsyncPage[Message]:
|
||||
"""
|
||||
Get messages saved to this peer outside of a session with optional filtering.
|
||||
|
||||
Makes an API call to retrieve messages saved to this peer outside of a session.
|
||||
Results can be filtered based on various criteria.
|
||||
|
||||
Args:
|
||||
filters: Dictionary of filter criteria. Supported filters include:
|
||||
- peer_id: Filter messages by the peer who created them
|
||||
- metadata: Filter messages by metadata key-value pairs
|
||||
- timestamp_start: Filter messages after a specific timestamp
|
||||
- timestamp_end: Filter messages before a specific timestamp
|
||||
|
||||
Returns:
|
||||
An AsyncPage of Message objects matching the specified criteria, ordered by
|
||||
creation time (most recent first)
|
||||
"""
|
||||
messages_page = await self._client.workspaces.peers.messages.list(
|
||||
peer_id=self.id,
|
||||
workspace_id=self.workspace_id,
|
||||
filter=filters,
|
||||
)
|
||||
return AsyncPage(messages_page)
|
||||
|
||||
@validate_call
|
||||
def message(
|
||||
self,
|
||||
|
|
@ -296,9 +227,9 @@ class AsyncPeer(BaseModel):
|
|||
query: str = Field(..., min_length=1, description="The search query to use"),
|
||||
) -> AsyncPage[Message]:
|
||||
"""
|
||||
Search for messages in this peer's global representation.
|
||||
Search across all messages in the workspace with this peer as author.
|
||||
|
||||
Makes an async API call to search for messages in this peer's global representation.
|
||||
Makes an API call to search endpoint.
|
||||
|
||||
Args:
|
||||
query: The search query to use
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from honcho_core.types.workspaces.sessions.message import Message
|
|||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
|
||||
|
||||
from ..session_context import SessionContext
|
||||
from ..utils import prepare_file_for_upload
|
||||
from .pagination import AsyncPage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -468,6 +469,54 @@ class AsyncSession(BaseModel):
|
|||
)
|
||||
return AsyncPage(messages_page)
|
||||
|
||||
@validate_call
|
||||
async def upload_file(
|
||||
self,
|
||||
file: tuple[str, bytes, str] | tuple[str, Any, str] | Any = Field(
|
||||
...,
|
||||
description="File to upload. Can be a file object, (filename, bytes, content_type) tuple, or (filename, fileobj, content_type) tuple.",
|
||||
),
|
||||
peer_id: str = Field(..., description="ID of the peer creating the messages"),
|
||||
) -> list[Message]:
|
||||
"""
|
||||
Upload file to create message(s) in this session.
|
||||
|
||||
Accepts a flexible payload:
|
||||
- File objects (opened in binary mode)
|
||||
- (filename, bytes, content_type) tuples
|
||||
- (filename, fileobj, content_type) tuples
|
||||
|
||||
Files are normalized to (filename, fileobj, content_type) tuples for the Stainless client.
|
||||
|
||||
Args:
|
||||
file: File to upload. Can be:
|
||||
- a file object (must have .name and .read())
|
||||
- a tuple (filename, bytes, content_type)
|
||||
- a tuple (filename, fileobj, content_type)
|
||||
peer_id: ID of the peer who will be attributed as the creator of the messages
|
||||
|
||||
Returns:
|
||||
A list of Message objects representing the created messages
|
||||
|
||||
Note:
|
||||
Supported file types include PDFs, text files, and JSON documents.
|
||||
Large files will be automatically split into multiple messages to fit
|
||||
within message size limits.
|
||||
"""
|
||||
|
||||
# Prepare file for upload using shared utility
|
||||
filename, content_bytes, content_type = prepare_file_for_upload(file)
|
||||
|
||||
# Call the upload endpoint
|
||||
response = await self._client.workspaces.sessions.messages.upload(
|
||||
session_id=self.id,
|
||||
workspace_id=self.workspace_id,
|
||||
file=(filename, content_bytes, content_type),
|
||||
peer_id=peer_id,
|
||||
)
|
||||
|
||||
return [Message.model_validate(msg) for msg in response]
|
||||
|
||||
async def working_rep(
|
||||
self,
|
||||
peer: str | AsyncPeer,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from honcho_core import Honcho as HonchoCore
|
||||
from honcho_core.types import DeriverStatus
|
||||
from honcho_core.types.workspaces.sessions.message import Message
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
|
||||
|
||||
|
|
@ -29,6 +31,8 @@ class Honcho(BaseModel):
|
|||
workspace_id: Workspace ID for scoping operations
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow") # pyright: ignore
|
||||
|
||||
workspace_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
|
|
@ -285,6 +289,103 @@ class Honcho(BaseModel):
|
|||
messages_page = self._client.workspaces.search(self.workspace_id, body=query)
|
||||
return SyncPage(messages_page)
|
||||
|
||||
@validate_call
|
||||
def get_deriver_status(
|
||||
self,
|
||||
observer_id: str | None = None,
|
||||
sender_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> DeriverStatus:
|
||||
"""
|
||||
Get the deriver processing status, optionally scoped to an observer, sender, and/or session
|
||||
"""
|
||||
return self._client.workspaces.deriver_status(
|
||||
workspace_id=self.workspace_id,
|
||||
observer_id=observer_id,
|
||||
sender_id=sender_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
@validate_call
|
||||
def poll_deriver_status(
|
||||
self,
|
||||
observer_id: str | None = None,
|
||||
sender_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
timeout: float = Field(
|
||||
300.0,
|
||||
gt=0,
|
||||
description="Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).",
|
||||
),
|
||||
) -> DeriverStatus:
|
||||
"""
|
||||
Poll get_deriver_status until pending_work_units and in_progress_work_units are both 0.
|
||||
This allows you to guarantee that all messages have been processed by the deriver for
|
||||
use with the dialectic endpoint.
|
||||
|
||||
The polling estimates sleep time by assuming each work unit takes 1 second.
|
||||
|
||||
Args:
|
||||
observer_id: Optional observer ID to scope the status check
|
||||
sender_id: Optional sender ID to scope the status check
|
||||
session_id: Optional session ID to scope the status check
|
||||
timeout: Maximum time to poll in seconds. Defaults to 5 minutes (300 seconds).
|
||||
|
||||
Returns:
|
||||
DeriverStatus when all work units are complete
|
||||
|
||||
Raises:
|
||||
TimeoutError: If timeout is exceeded before work units complete
|
||||
Exception: If get_deriver_status fails repeatedly
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
status = self.get_deriver_status(observer_id, sender_id, session_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get deriver status: {e}")
|
||||
# Sleep briefly before retrying
|
||||
time.sleep(1)
|
||||
|
||||
# Check timeout after error
|
||||
elapsed_time = time.time() - start_time
|
||||
if elapsed_time >= timeout:
|
||||
raise TimeoutError(
|
||||
f"Polling timeout exceeded after {timeout}s. "
|
||||
+ f"Error during status check: {e}"
|
||||
) from e
|
||||
continue
|
||||
|
||||
if status.pending_work_units == 0 and status.in_progress_work_units == 0:
|
||||
return status
|
||||
|
||||
# Check timeout before sleeping
|
||||
elapsed_time = time.time() - start_time
|
||||
if elapsed_time >= timeout:
|
||||
raise TimeoutError(
|
||||
f"Polling timeout exceeded after {timeout}s. "
|
||||
+ f"Current status: {status.pending_work_units} pending, "
|
||||
+ f"{status.in_progress_work_units} in progress work units."
|
||||
)
|
||||
|
||||
# Sleep for the expected time to complete all current work units
|
||||
# Assuming each pending and in-progress work unit takes 1 second
|
||||
total_work_units = status.pending_work_units + status.in_progress_work_units
|
||||
sleep_time = max(1, total_work_units)
|
||||
|
||||
# Don't sleep past the timeout
|
||||
remaining_time = timeout - elapsed_time
|
||||
sleep_time = min(sleep_time, remaining_time)
|
||||
if sleep_time <= 0:
|
||||
raise TimeoutError(
|
||||
f"Polling timeout exceeded after {timeout}s. "
|
||||
+ f"Current status: {status.pending_work_units} pending, "
|
||||
+ f"{status.in_progress_work_units} in progress work units."
|
||||
)
|
||||
|
||||
time.sleep(sleep_time)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a string representation of the Honcho client.
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class Peer(BaseModel):
|
|||
|
||||
def chat(
|
||||
self,
|
||||
queries: str | list[str],
|
||||
query: str,
|
||||
*,
|
||||
stream: bool = False,
|
||||
target: str | Peer | None = None,
|
||||
|
|
@ -88,9 +88,9 @@ class Peer(BaseModel):
|
|||
representation of another peer (what this peer knows about the target peer).
|
||||
|
||||
Args:
|
||||
queries: The natural language question(s) to ask. Can be a single string or a list of strings.
|
||||
query: The natural language question to ask.
|
||||
stream: Whether to stream the response
|
||||
target: Optional target peer for local representation queries. If provided,
|
||||
target: Optional target peer for local representation query. If provided,
|
||||
queries what this peer knows about the target peer rather than
|
||||
querying the peer's global representation
|
||||
session_id: Optional session ID to scope the query to a specific session.
|
||||
|
|
@ -103,7 +103,7 @@ class Peer(BaseModel):
|
|||
response = self._client.workspaces.peers.chat(
|
||||
peer_id=self.id,
|
||||
workspace_id=self.workspace_id,
|
||||
queries=queries,
|
||||
query=query,
|
||||
stream=stream,
|
||||
target=str(target.id) if isinstance(target, Peer) else target,
|
||||
session_id=session_id,
|
||||
|
|
@ -134,75 +134,6 @@ class Peer(BaseModel):
|
|||
lambda session: Session(session.id, self.workspace_id, self._client),
|
||||
)
|
||||
|
||||
@validate_call
|
||||
def add_messages(
|
||||
self,
|
||||
content: str | MessageCreateParam | list[MessageCreateParam] = Field(
|
||||
..., description="Content to add to the peer's representation"
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Add messages or content to this peer's global representation.
|
||||
|
||||
Makes an API call to store content associated with this peer. This content
|
||||
becomes part of the peer's global knowledge base and can be retrieved
|
||||
through chat queries. Content can be provided as raw strings, Message objects,
|
||||
or lists of Message objects.
|
||||
|
||||
Args:
|
||||
content: Content to add to the peer's representation. Can be:
|
||||
- str: Raw text content that will be converted to a Message
|
||||
- Message: A single Message object to add
|
||||
- List[Message]: Multiple Message objects to add in batch
|
||||
"""
|
||||
messages: list[MessageCreateParam]
|
||||
if isinstance(content, str):
|
||||
messages = [
|
||||
MessageCreateParam(peer_id=self.id, content=content, metadata=None)
|
||||
]
|
||||
elif isinstance(content, list):
|
||||
messages = content
|
||||
else:
|
||||
messages = [content]
|
||||
|
||||
self._client.workspaces.peers.messages.create(
|
||||
peer_id=self.id,
|
||||
workspace_id=self.workspace_id,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
@validate_call
|
||||
def get_messages(
|
||||
self,
|
||||
*,
|
||||
filters: dict[str, object] | None = Field(
|
||||
None, description="Dictionary of filter criteria"
|
||||
),
|
||||
) -> SyncPage[Message]:
|
||||
"""
|
||||
Get messages saved to this peer outside of a session with optional filtering.
|
||||
|
||||
Makes an API call to retrieve messages saved to this peer outside of a session.
|
||||
Results can be filtered based on various criteria.
|
||||
|
||||
Args:
|
||||
filters: Dictionary of filter criteria. Supported filters include:
|
||||
- peer_id: Filter messages by the peer who created them
|
||||
- metadata: Filter messages by metadata key-value pairs
|
||||
- timestamp_start: Filter messages after a specific timestamp
|
||||
- timestamp_end: Filter messages before a specific timestamp
|
||||
|
||||
Returns:
|
||||
A SyncPage of Message objects matching the specified criteria, ordered by
|
||||
creation time (most recent first)
|
||||
"""
|
||||
messages_page = self._client.workspaces.peers.messages.list(
|
||||
peer_id=self.id,
|
||||
workspace_id=self.workspace_id,
|
||||
filter=filters,
|
||||
)
|
||||
return SyncPage(messages_page)
|
||||
|
||||
@validate_call
|
||||
def message(
|
||||
self,
|
||||
|
|
@ -276,9 +207,9 @@ class Peer(BaseModel):
|
|||
query: str = Field(..., min_length=1, description="The search query to use"),
|
||||
) -> SyncPage[Message]:
|
||||
"""
|
||||
Search for messages in this peer's global representation.
|
||||
Search across all messages in the workspace with this peer as author.
|
||||
|
||||
Makes an API call to search for messages in this peer's global representation.
|
||||
Makes an API call to search endpoint.
|
||||
|
||||
Args:
|
||||
query: The search query to use
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, validate_call
|
|||
|
||||
from .pagination import SyncPage
|
||||
from .session_context import SessionContext
|
||||
from .utils import prepare_file_for_upload
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .peer import Peer
|
||||
|
|
@ -445,6 +446,54 @@ class Session(BaseModel):
|
|||
)
|
||||
return SyncPage(messages_page)
|
||||
|
||||
@validate_call
|
||||
def upload_file(
|
||||
self,
|
||||
file: tuple[str, bytes, str] | tuple[str, Any, str] | Any = Field(
|
||||
...,
|
||||
description="File to upload. Can be a file object, (filename, bytes, content_type) tuple, or (filename, fileobj, content_type) tuple.",
|
||||
),
|
||||
peer_id: str = Field(..., description="ID of the peer creating the messages"),
|
||||
) -> list[Message]:
|
||||
"""
|
||||
Upload file to create message(s) in this session.
|
||||
|
||||
Accepts a flexible payload:
|
||||
- File objects (opened in binary mode)
|
||||
- (filename, bytes, content_type) tuples
|
||||
- (filename, fileobj, content_type) tuples
|
||||
|
||||
Files are normalized to (filename, fileobj, content_type) tuples for the Stainless client.
|
||||
|
||||
Args:
|
||||
file: File to upload. Can be:
|
||||
- a file object (must have .name and .read())
|
||||
- a tuple (filename, bytes, content_type)
|
||||
- a tuple (filename, fileobj, content_type)
|
||||
peer_id: ID of the peer who will be attributed as the creator of the messages
|
||||
|
||||
Returns:
|
||||
A list of Message objects representing the created messages
|
||||
|
||||
Note:
|
||||
Supported file types include PDFs, text files, and JSON documents.
|
||||
Large files will be automatically split into multiple messages to fit
|
||||
within message size limits.
|
||||
"""
|
||||
|
||||
# Prepare file for upload using shared utility
|
||||
filename, content_bytes, content_type = prepare_file_for_upload(file)
|
||||
|
||||
# Call the upload endpoint
|
||||
response = self._client.workspaces.sessions.messages.upload(
|
||||
session_id=self.id,
|
||||
workspace_id=self.workspace_id,
|
||||
file=(filename, content_bytes, content_type),
|
||||
peer_id=peer_id,
|
||||
)
|
||||
|
||||
return [Message.model_validate(msg) for msg in response]
|
||||
|
||||
def working_rep(
|
||||
self,
|
||||
peer: str | Peer,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from honcho_core.types.workspaces.sessions.message import Message
|
||||
from pydantic import BaseModel, Field, validate_call
|
||||
|
||||
from .peer import Peer
|
||||
if TYPE_CHECKING:
|
||||
from .peer import Peer
|
||||
|
||||
|
||||
class SessionContext(BaseModel):
|
||||
|
|
@ -72,11 +77,9 @@ class SessionContext(BaseModel):
|
|||
Returns:
|
||||
A list of dictionaries in OpenAI format, where each dictionary contains
|
||||
"role" and "content" keys suitable for the OpenAI API
|
||||
|
||||
Raises:
|
||||
ValidationError: If assistant parameter is invalid
|
||||
"""
|
||||
assistant_id = assistant.id if isinstance(assistant, Peer) else assistant
|
||||
|
||||
assistant_id = assistant if isinstance(assistant, str) else assistant.id
|
||||
return [
|
||||
{
|
||||
"role": "assistant" if message.peer_id == assistant_id else "user",
|
||||
|
|
@ -107,14 +110,12 @@ class SessionContext(BaseModel):
|
|||
A list of dictionaries in Anthropic format, where each dictionary contains
|
||||
"role" and "content" keys suitable for the Anthropic API
|
||||
|
||||
Raises:
|
||||
ValidationError: If assistant parameter is invalid
|
||||
|
||||
Note:
|
||||
Future versions may implement role alternation requirements for
|
||||
Anthropic's API compatibility
|
||||
"""
|
||||
assistant_id = assistant.id if isinstance(assistant, Peer) else assistant
|
||||
|
||||
assistant_id = assistant if isinstance(assistant, str) else assistant.id
|
||||
return [
|
||||
{
|
||||
"role": "assistant" if message.peer_id == assistant_id else "user",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Utility modules for the Honcho Python SDK.
|
||||
"""
|
||||
|
||||
from .file_upload import normalize_file_input, prepare_file_for_upload
|
||||
|
||||
__all__ = ["normalize_file_input", "prepare_file_for_upload"]
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
"""
|
||||
File upload utilities for the Honcho Python SDK.
|
||||
|
||||
This module provides shared functionality for handling file uploads across
|
||||
both sync and async client implementations.
|
||||
"""
|
||||
|
||||
import mimetypes
|
||||
from io import BytesIO, IOBase
|
||||
|
||||
|
||||
def normalize_file_input(
|
||||
file: tuple[str, bytes, str] | tuple[str, IOBase, str] | IOBase,
|
||||
) -> tuple[str, IOBase, str]:
|
||||
"""
|
||||
Normalize various file input formats to a standard tuple format.
|
||||
|
||||
Args:
|
||||
file: File to normalize. Can be:
|
||||
- a file object (must have .name and .read())
|
||||
- a tuple (filename, bytes, content_type)
|
||||
- a tuple (filename, fileobj, content_type)
|
||||
|
||||
Returns:
|
||||
A normalized tuple of (filename, fileobj, content_type)
|
||||
|
||||
Raises:
|
||||
ValueError: If the file input format is not supported
|
||||
"""
|
||||
# If it's a tuple (filename, bytes, content_type)
|
||||
if isinstance(file, tuple) and len(file) == 3:
|
||||
filename, file_content, content_type = file
|
||||
if isinstance(file_content, bytes):
|
||||
fileobj = BytesIO(file_content)
|
||||
fileobj.name = filename
|
||||
return (filename, fileobj, content_type)
|
||||
elif isinstance(file_content, IOBase): # pyright: ignore -- needed for return type
|
||||
return (filename, file_content, content_type)
|
||||
else:
|
||||
raise ValueError("File content must be bytes or a file-like object.")
|
||||
|
||||
# If it's a file object (not str/bytes/bytearray/memoryview)
|
||||
elif isinstance(file, IOBase): # pyright: ignore -- needed for return type
|
||||
filename = getattr(file, "name", None)
|
||||
if not filename:
|
||||
raise ValueError("File object must have a .name attribute.")
|
||||
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
return (filename, file, content_type)
|
||||
|
||||
|
||||
def prepare_file_for_upload(
|
||||
file: tuple[str, bytes, str] | tuple[str, IOBase, str] | IOBase,
|
||||
) -> tuple[str, bytes, str]:
|
||||
"""
|
||||
Prepare a file for upload by normalizing and reading its content.
|
||||
|
||||
Args:
|
||||
file: File to prepare. Can be:
|
||||
- a file object (must have .name and .read())
|
||||
- a tuple (filename, bytes, content_type)
|
||||
- a tuple (filename, fileobj, content_type)
|
||||
|
||||
Returns:
|
||||
A tuple of (filename, content_bytes, content_type) ready for API upload
|
||||
|
||||
Raises:
|
||||
ValueError: If the file input format is not supported
|
||||
"""
|
||||
normalized_file = normalize_file_input(file)
|
||||
|
||||
# Read the file content
|
||||
normalized_file[1].seek(0) # Reset file position
|
||||
content_bytes = normalized_file[1].read()
|
||||
|
||||
return (normalized_file[0], content_bytes, normalized_file[2])
|
||||
|
|
@ -112,7 +112,7 @@ dev = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "honcho-core", specifier = ">=1.1.0" },
|
||||
{ name = "honcho-core", specifier = ">=1.2.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.0,<1" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0,<3" },
|
||||
]
|
||||
|
|
@ -122,7 +122,7 @@ dev = [{ name = "ruff", specifier = ">=0.11.13" }]
|
|||
|
||||
[[package]]
|
||||
name = "honcho-core"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||
|
|
@ -135,9 +135,9 @@ dependencies = [
|
|||
{ name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
||||
{ name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/2f/27739b0d8950da05743cba5085be66f1184195c07ba9b852988d45ba252d/honcho_core-1.1.0.tar.gz", hash = "sha256:d31dc932573b771056952d234cf0c615a4ca591a11eb29c543e9a2277fcc0926", size = 121388, upload-time = "2025-06-26T19:31:00.814Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/60/e870902c5d247b5a0fb38401d6f3730e11eaf038f5a15c68afc4465e332f/honcho_core-1.2.0.tar.gz", hash = "sha256:1f16fd9ecd236bfc4c30ecc33354baf4bdd9a4206e84f92ec785ecd61b25d193", size = 122450, upload-time = "2025-07-16T19:59:06.362Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/14/c2bc7dce35a76a7d89166c55f35f2be21fdb3a94ee3f6502aa83f399bb1a/honcho_core-1.1.0-py3-none-any.whl", hash = "sha256:5716aa572cf33416d1c2c475dbd714879e9d351f2e2b1c6c37751b2c91adc6ce", size = 112730, upload-time = "2025-06-26T19:30:59.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/99/f435c093ea2067d7da50545cfbe2037e28370541ba161f5c8df6e33030b0/honcho_core-1.2.0-py3-none-any.whl", hash = "sha256:d9260e1a2a1254c26aeec464f8bca7ebb6c2fa9b5ae568a9344b5458467d78ab", size = 110721, upload-time = "2025-07-16T19:59:05.189Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,22 @@ 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/).
|
||||
|
||||
## [1.2.0] - 2025-07-16
|
||||
|
||||
### Added
|
||||
|
||||
- Get/poll deriver queue status endpoints added to workspace
|
||||
- Added endpoint to upload files as messages
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed peer messages in accordance with Honcho 2.1.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated chat endpoint to use singular `query` in accordance with Honcho 2.1.0
|
||||
|
||||
|
||||
## [1.1.0] - 2025-07-08
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ jest.mock('@honcho-ai/core', () => {
|
|||
sessions: {
|
||||
list: jest.fn(),
|
||||
},
|
||||
getOrCreate: jest.fn(),
|
||||
getOrCreate: jest.fn().mockResolvedValue({ id: 'test-workspace', metadata: {} }),
|
||||
update: jest.fn(),
|
||||
list: jest.fn(),
|
||||
search: jest.fn(),
|
||||
|
|
@ -28,13 +28,13 @@ describe('Honcho Client', () => {
|
|||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
|
||||
honcho = new Honcho({
|
||||
workspaceId: 'test-workspace',
|
||||
apiKey: 'test-key',
|
||||
environment: 'local',
|
||||
});
|
||||
|
||||
|
||||
mockClient = (honcho as any)._client;
|
||||
});
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ describe('Honcho Client', () => {
|
|||
timeout: 5000,
|
||||
maxRetries: 3,
|
||||
});
|
||||
|
||||
|
||||
expect(client.workspaceId).toBe('custom-workspace');
|
||||
});
|
||||
|
||||
|
|
@ -56,11 +56,11 @@ describe('Honcho Client', () => {
|
|||
process.env.HONCHO_WORKSPACE_ID = 'env-workspace';
|
||||
process.env.HONCHO_API_KEY = 'env-key';
|
||||
process.env.HONCHO_URL = 'https://env-url.com';
|
||||
|
||||
|
||||
const client = new Honcho({});
|
||||
|
||||
|
||||
expect(client.workspaceId).toBe('env-workspace');
|
||||
|
||||
|
||||
// Clean up environment variables
|
||||
delete process.env.HONCHO_WORKSPACE_ID;
|
||||
delete process.env.HONCHO_API_KEY;
|
||||
|
|
@ -83,7 +83,7 @@ describe('Honcho Client', () => {
|
|||
defaultHeaders: { 'X-Custom': 'header' },
|
||||
defaultQuery: { param: 'value' },
|
||||
});
|
||||
|
||||
|
||||
expect(client.workspaceId).toBe('test');
|
||||
});
|
||||
});
|
||||
|
|
@ -91,7 +91,7 @@ describe('Honcho Client', () => {
|
|||
describe('peer', () => {
|
||||
it('should create a new Peer instance', () => {
|
||||
const peer = honcho.peer('test-peer');
|
||||
|
||||
|
||||
expect(peer).toBeInstanceOf(Peer);
|
||||
expect(peer.id).toBe('test-peer');
|
||||
});
|
||||
|
|
@ -121,7 +121,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.peers.list.mockResolvedValue(mockPeersData);
|
||||
|
||||
const peersPage = await honcho.getPeers();
|
||||
|
||||
|
||||
expect(peersPage).toBeInstanceOf(Page);
|
||||
expect(mockClient.workspaces.peers.list).toHaveBeenCalledWith('test-workspace');
|
||||
});
|
||||
|
|
@ -136,7 +136,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.peers.list.mockResolvedValue(mockPeersData);
|
||||
|
||||
const peersPage = await honcho.getPeers();
|
||||
|
||||
|
||||
expect(peersPage).toBeInstanceOf(Page);
|
||||
expect(mockClient.workspaces.peers.list).toHaveBeenCalledWith('test-workspace');
|
||||
});
|
||||
|
|
@ -151,7 +151,7 @@ describe('Honcho Client', () => {
|
|||
describe('session', () => {
|
||||
it('should create a new Session instance', () => {
|
||||
const session = honcho.session('test-session');
|
||||
|
||||
|
||||
expect(session).toBeInstanceOf(Session);
|
||||
expect(session.id).toBe('test-session');
|
||||
});
|
||||
|
|
@ -181,7 +181,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.sessions.list.mockResolvedValue(mockSessionsData);
|
||||
|
||||
const sessionsPage = await honcho.getSessions();
|
||||
|
||||
|
||||
expect(sessionsPage).toBeInstanceOf(Page);
|
||||
expect(mockClient.workspaces.sessions.list).toHaveBeenCalledWith('test-workspace');
|
||||
});
|
||||
|
|
@ -196,7 +196,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.sessions.list.mockResolvedValue(mockSessionsData);
|
||||
|
||||
const sessionsPage = await honcho.getSessions();
|
||||
|
||||
|
||||
expect(sessionsPage).toBeInstanceOf(Page);
|
||||
});
|
||||
|
||||
|
|
@ -216,7 +216,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace);
|
||||
|
||||
const metadata = await honcho.getMetadata();
|
||||
|
||||
|
||||
expect(metadata).toEqual({ key: 'value', setting: 'config' });
|
||||
expect(mockClient.workspaces.getOrCreate).toHaveBeenCalledWith({ id: 'test-workspace' });
|
||||
});
|
||||
|
|
@ -229,7 +229,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.getOrCreate.mockResolvedValue(mockWorkspace);
|
||||
|
||||
const metadata = await honcho.getMetadata();
|
||||
|
||||
|
||||
expect(metadata).toEqual({});
|
||||
});
|
||||
|
||||
|
|
@ -246,7 +246,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.update.mockResolvedValue({});
|
||||
|
||||
await honcho.setMetadata(metadata);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.update).toHaveBeenCalledWith('test-workspace', { metadata });
|
||||
});
|
||||
|
||||
|
|
@ -254,7 +254,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.update.mockResolvedValue({});
|
||||
|
||||
await honcho.setMetadata({});
|
||||
|
||||
|
||||
expect(mockClient.workspaces.update).toHaveBeenCalledWith('test-workspace', { metadata: {} });
|
||||
});
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.update.mockResolvedValue({});
|
||||
|
||||
await honcho.setMetadata(complexMetadata);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.update).toHaveBeenCalledWith('test-workspace', { metadata: complexMetadata });
|
||||
});
|
||||
|
||||
|
|
@ -292,7 +292,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.list.mockResolvedValue(mockWorkspacesPage);
|
||||
|
||||
const workspaces = await honcho.getWorkspaces();
|
||||
|
||||
|
||||
expect(workspaces).toEqual(['workspace1', 'workspace2', 'workspace3']);
|
||||
expect(mockClient.workspaces.list).toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -306,7 +306,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.list.mockResolvedValue(mockWorkspacesPage);
|
||||
|
||||
const workspaces = await honcho.getWorkspaces();
|
||||
|
||||
|
||||
expect(workspaces).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -331,9 +331,9 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.search.mockResolvedValue(mockSearchResults);
|
||||
|
||||
const results = await honcho.search('hello');
|
||||
|
||||
|
||||
expect(results).toBeInstanceOf(Page);
|
||||
expect(mockClient.workspaces.search).toHaveBeenCalledWith('test-workspace', 'hello');
|
||||
expect(mockClient.workspaces.search).toHaveBeenCalledWith('test-workspace', { body: 'hello' });
|
||||
});
|
||||
|
||||
it('should handle empty search results', async () => {
|
||||
|
|
@ -346,7 +346,7 @@ describe('Honcho Client', () => {
|
|||
mockClient.workspaces.search.mockResolvedValue(mockSearchResults);
|
||||
|
||||
const results = await honcho.search('nonexistent');
|
||||
|
||||
|
||||
expect(results).toBeInstanceOf(Page);
|
||||
});
|
||||
|
||||
|
|
@ -372,8 +372,8 @@ describe('Honcho Client', () => {
|
|||
|
||||
const complexQuery = 'complex query with "quotes" and special characters!@#$%';
|
||||
await honcho.search(complexQuery);
|
||||
|
||||
expect(mockClient.workspaces.search).toHaveBeenCalledWith('test-workspace', complexQuery);
|
||||
|
||||
expect(mockClient.workspaces.search).toHaveBeenCalledWith('test-workspace', { body: complexQuery });
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ describe('Honcho SDK Integration Tests', () => {
|
|||
expect(mockWorkspacesApi.workspaces.peers.chat).toHaveBeenCalledWith(
|
||||
'integration-test-workspace',
|
||||
'assistant',
|
||||
{ queries: 'How are you?', stream: undefined, target: undefined, session_id: undefined }
|
||||
{ query: 'How are you?', stream: undefined, target: undefined, session_id: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -229,7 +229,7 @@ describe('Honcho SDK Integration Tests', () => {
|
|||
expect(workspaceResults).toBeInstanceOf(Page);
|
||||
expect(mockWorkspacesApi.workspaces.search).toHaveBeenCalledWith(
|
||||
'integration-test-workspace',
|
||||
'test query'
|
||||
{ body: 'test query' }
|
||||
);
|
||||
|
||||
// Step 2: Search peer
|
||||
|
|
@ -249,7 +249,7 @@ describe('Honcho SDK Integration Tests', () => {
|
|||
expect(mockWorkspacesApi.workspaces.sessions.search).toHaveBeenCalledWith(
|
||||
'integration-test-workspace',
|
||||
'test-session',
|
||||
'session query'
|
||||
{ query: 'session query' }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ jest.mock('@honcho-ai/core', () => {
|
|||
update: jest.fn(),
|
||||
search: jest.fn(),
|
||||
},
|
||||
getOrCreate: jest.fn().mockResolvedValue({ id: 'test-workspace', metadata: {} }),
|
||||
update: jest.fn(),
|
||||
list: jest.fn(),
|
||||
search: jest.fn(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
|
@ -31,13 +35,13 @@ describe('Peer', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
|
||||
honcho = new Honcho({
|
||||
workspaceId: 'test-workspace',
|
||||
apiKey: 'test-key',
|
||||
environment: 'local',
|
||||
});
|
||||
|
||||
|
||||
peer = new Peer('test-peer', honcho);
|
||||
mockClient = (honcho as any)._client;
|
||||
});
|
||||
|
|
@ -45,7 +49,7 @@ describe('Peer', () => {
|
|||
describe('constructor', () => {
|
||||
it('should initialize with correct properties', () => {
|
||||
const newPeer = new Peer('peer-id', honcho);
|
||||
|
||||
|
||||
expect(newPeer.id).toBe('peer-id');
|
||||
expect(newPeer['_honcho']).toBe(honcho);
|
||||
});
|
||||
|
|
@ -57,12 +61,12 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await peer.chat('Hello');
|
||||
|
||||
|
||||
expect(result).toBe('Hello, I am a peer response');
|
||||
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ queries: 'Hello', stream: undefined, target: undefined, session_id: undefined }
|
||||
{ query: 'Hello', stream: undefined, target: undefined, session_id: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -71,7 +75,7 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await peer.chat('Hello');
|
||||
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -80,7 +84,7 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await peer.chat('Hello');
|
||||
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -89,11 +93,11 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
|
||||
|
||||
await peer.chat('Hello', { stream: true });
|
||||
|
||||
|
||||
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ queries: 'Hello', stream: true, target: undefined, session_id: undefined }
|
||||
{ query: 'Hello', stream: true, target: undefined, session_id: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -103,11 +107,11 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
|
||||
|
||||
await peer.chat('Hello', { target: targetPeer });
|
||||
|
||||
|
||||
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ queries: 'Hello', stream: undefined, target: 'target-peer', session_id: undefined }
|
||||
{ query: 'Hello', stream: undefined, target: 'target-peer', session_id: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -116,11 +120,11 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
|
||||
|
||||
await peer.chat('Hello', { target: 'string-target' });
|
||||
|
||||
|
||||
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ queries: 'Hello', stream: undefined, target: 'string-target', session_id: undefined }
|
||||
{ query: 'Hello', stream: undefined, target: 'string-target', session_id: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -129,11 +133,11 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
|
||||
|
||||
await peer.chat('Hello', { sessionId: 'session-123' });
|
||||
|
||||
|
||||
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ queries: 'Hello', stream: undefined, target: undefined, session_id: 'session-123' }
|
||||
{ query: 'Hello', stream: undefined, target: undefined, session_id: 'session-123' }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -142,16 +146,16 @@ describe('Peer', () => {
|
|||
const mockResponse = { content: 'Full options response' };
|
||||
mockClient.workspaces.peers.chat.mockResolvedValue(mockResponse);
|
||||
|
||||
await peer.chat('Hello', {
|
||||
stream: true,
|
||||
target: targetPeer,
|
||||
sessionId: 'session-456'
|
||||
await peer.chat('Hello', {
|
||||
stream: true,
|
||||
target: targetPeer,
|
||||
sessionId: 'session-456'
|
||||
});
|
||||
|
||||
|
||||
expect(mockClient.workspaces.peers.chat).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ queries: 'Hello', stream: true, target: 'target-peer', session_id: 'session-456' }
|
||||
{ query: 'Hello', stream: true, target: 'target-peer', session_id: 'session-456' }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -176,7 +180,7 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.sessions.list.mockResolvedValue(mockSessionsData);
|
||||
|
||||
const sessionsPage = await peer.getSessions();
|
||||
|
||||
|
||||
expect(sessionsPage).toBeInstanceOf(Page);
|
||||
expect(mockClient.workspaces.peers.sessions.list).toHaveBeenCalledWith(
|
||||
'test-peer',
|
||||
|
|
@ -194,7 +198,7 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.sessions.list.mockResolvedValue(mockSessionsData);
|
||||
|
||||
const sessionsPage = await peer.getSessions();
|
||||
|
||||
|
||||
expect(sessionsPage).toBeInstanceOf(Page);
|
||||
});
|
||||
|
||||
|
|
@ -205,147 +209,10 @@ describe('Peer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('addMessages', () => {
|
||||
it('should add a single string message', async () => {
|
||||
mockClient.workspaces.peers.messages.create.mockResolvedValue({});
|
||||
|
||||
await peer.addMessages('Hello world');
|
||||
|
||||
expect(mockClient.workspaces.peers.messages.create).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ messages: [{ peer_id: 'test-peer', content: 'Hello world', metadata: undefined }] }
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a single message object', async () => {
|
||||
const message = {
|
||||
peerId: 'test-peer',
|
||||
content: 'Test message',
|
||||
metadata: { type: 'test' },
|
||||
};
|
||||
mockClient.workspaces.peers.messages.create.mockResolvedValue({});
|
||||
|
||||
await peer.addMessages(message);
|
||||
|
||||
expect(mockClient.workspaces.peers.messages.create).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ messages: [{ peer_id: 'test-peer', content: 'Test message', metadata: { type: 'test' } }] }
|
||||
);
|
||||
});
|
||||
|
||||
it('should add message object without specified peerId', async () => {
|
||||
const message = {
|
||||
content: 'Test message without peer ID',
|
||||
metadata: { type: 'test' },
|
||||
};
|
||||
mockClient.workspaces.peers.messages.create.mockResolvedValue({});
|
||||
|
||||
await peer.addMessages(message);
|
||||
|
||||
expect(mockClient.workspaces.peers.messages.create).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ messages: [{ peer_id: 'test-peer', content: 'Test message without peer ID', metadata: { type: 'test' } }] }
|
||||
);
|
||||
});
|
||||
|
||||
it('should add array of messages', async () => {
|
||||
const messages = [
|
||||
{ peerId: 'peer1', content: 'Message 1', metadata: { order: 1 } },
|
||||
{ peerId: 'peer2', content: 'Message 2', metadata: { order: 2 } },
|
||||
];
|
||||
mockClient.workspaces.peers.messages.create.mockResolvedValue({});
|
||||
|
||||
await peer.addMessages(messages);
|
||||
|
||||
expect(mockClient.workspaces.peers.messages.create).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{
|
||||
messages: [
|
||||
{ peer_id: 'peer1', content: 'Message 1', metadata: { order: 1 } },
|
||||
{ peer_id: 'peer2', content: 'Message 2', metadata: { order: 2 } },
|
||||
]
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty array', async () => {
|
||||
mockClient.workspaces.peers.messages.create.mockResolvedValue({});
|
||||
|
||||
await peer.addMessages([]);
|
||||
|
||||
expect(mockClient.workspaces.peers.messages.create).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ messages: [] }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
mockClient.workspaces.peers.messages.create.mockRejectedValue(new Error('Failed to add messages'));
|
||||
|
||||
await expect(peer.addMessages('test')).rejects.toThrow('Failed to add messages');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessages', () => {
|
||||
it('should get messages without options', async () => {
|
||||
const mockMessagesData = {
|
||||
items: [
|
||||
{ id: 'msg1', content: 'Message 1', peer_id: 'test-peer' },
|
||||
{ id: 'msg2', content: 'Message 2', peer_id: 'test-peer' },
|
||||
],
|
||||
total: 2,
|
||||
size: 2,
|
||||
hasNextPage: false,
|
||||
};
|
||||
mockClient.workspaces.peers.messages.list.mockResolvedValue(mockMessagesData);
|
||||
|
||||
const messagesPage = await peer.getMessages();
|
||||
|
||||
expect(messagesPage).toBeInstanceOf(Page);
|
||||
expect(mockClient.workspaces.peers.messages.list).toHaveBeenCalledWith(
|
||||
'test-peer',
|
||||
'test-workspace',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('should get messages with filter options', async () => {
|
||||
const mockMessagesData = {
|
||||
items: [],
|
||||
total: 0,
|
||||
size: 0,
|
||||
hasNextPage: false,
|
||||
};
|
||||
mockClient.workspaces.peers.messages.list.mockResolvedValue(mockMessagesData);
|
||||
|
||||
const options = {
|
||||
filter: { type: 'important', date: '2023-01-01' }
|
||||
};
|
||||
await peer.getMessages(options);
|
||||
|
||||
expect(mockClient.workspaces.peers.messages.list).toHaveBeenCalledWith(
|
||||
'test-peer',
|
||||
'test-workspace',
|
||||
{ type: 'important', date: '2023-01-01' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
mockClient.workspaces.peers.messages.list.mockRejectedValue(new Error('Failed to get messages'));
|
||||
|
||||
await expect(peer.getMessages()).rejects.toThrow('Failed to get messages');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message', () => {
|
||||
it('should create message object without metadata', () => {
|
||||
const message = peer.message('Test content');
|
||||
|
||||
|
||||
expect(message).toEqual({
|
||||
peerId: 'test-peer',
|
||||
content: 'Test content',
|
||||
|
|
@ -356,7 +223,7 @@ describe('Peer', () => {
|
|||
it('should create message object with metadata', () => {
|
||||
const metadata = { importance: 'high', category: 'greeting' };
|
||||
const message = peer.message('Hello there', { metadata });
|
||||
|
||||
|
||||
expect(message).toEqual({
|
||||
peerId: 'test-peer',
|
||||
content: 'Hello there',
|
||||
|
|
@ -366,7 +233,7 @@ describe('Peer', () => {
|
|||
|
||||
it('should handle empty content', () => {
|
||||
const message = peer.message('');
|
||||
|
||||
|
||||
expect(message).toEqual({
|
||||
peerId: 'test-peer',
|
||||
content: '',
|
||||
|
|
@ -384,7 +251,7 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer);
|
||||
|
||||
const metadata = await peer.getMetadata();
|
||||
|
||||
|
||||
expect(metadata).toEqual({ name: 'Test Peer', role: 'assistant' });
|
||||
expect(mockClient.workspaces.peers.getOrCreate).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
|
|
@ -400,7 +267,7 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.getOrCreate.mockResolvedValue(mockPeer);
|
||||
|
||||
const metadata = await peer.getMetadata();
|
||||
|
||||
|
||||
expect(metadata).toEqual({});
|
||||
});
|
||||
|
||||
|
|
@ -417,7 +284,7 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.update.mockResolvedValue({});
|
||||
|
||||
await peer.setMetadata(metadata);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.peers.update).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
|
|
@ -429,7 +296,7 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.update.mockResolvedValue({});
|
||||
|
||||
await peer.setMetadata({});
|
||||
|
||||
|
||||
expect(mockClient.workspaces.peers.update).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
|
|
@ -446,7 +313,7 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.update.mockResolvedValue({});
|
||||
|
||||
await peer.setMetadata(complexMetadata);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.peers.update).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
|
|
@ -475,12 +342,12 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.search.mockResolvedValue(mockSearchResults);
|
||||
|
||||
const results = await peer.search('hello');
|
||||
|
||||
|
||||
expect(results).toBeInstanceOf(Page);
|
||||
expect(mockClient.workspaces.peers.search).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ body: 'hello' }
|
||||
{ query: 'hello' }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -494,7 +361,7 @@ describe('Peer', () => {
|
|||
mockClient.workspaces.peers.search.mockResolvedValue(mockSearchResults);
|
||||
|
||||
const results = await peer.search('nonexistent');
|
||||
|
||||
|
||||
expect(results).toBeInstanceOf(Page);
|
||||
});
|
||||
|
||||
|
|
@ -520,11 +387,11 @@ describe('Peer', () => {
|
|||
|
||||
const complexQuery = 'complex query with "quotes" and special characters!@#$%';
|
||||
await peer.search(complexQuery);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.peers.search).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-peer',
|
||||
{ body: complexQuery }
|
||||
{ query: complexQuery }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ jest.mock('@honcho-ai/core', () => {
|
|||
peers: {
|
||||
workingRepresentation: jest.fn(),
|
||||
},
|
||||
getOrCreate: jest.fn().mockResolvedValue({ id: 'test-workspace', metadata: {} }),
|
||||
update: jest.fn(),
|
||||
list: jest.fn(),
|
||||
search: jest.fn(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
|
@ -38,13 +42,13 @@ describe('Session', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
|
||||
honcho = new Honcho({
|
||||
workspaceId: 'test-workspace',
|
||||
apiKey: 'test-key',
|
||||
environment: 'local',
|
||||
});
|
||||
|
||||
|
||||
session = new Session('test-session', honcho);
|
||||
mockClient = (honcho as any)._client;
|
||||
});
|
||||
|
|
@ -52,17 +56,17 @@ describe('Session', () => {
|
|||
describe('constructor', () => {
|
||||
it('should initialize with correct properties', () => {
|
||||
const newSession = new Session('session-id', honcho);
|
||||
|
||||
|
||||
expect(newSession.id).toBe('session-id');
|
||||
expect(newSession['_honcho']).toBe(honcho);
|
||||
});
|
||||
|
||||
it('should handle constructor options', () => {
|
||||
const newSession = new Session('session-id', honcho, {
|
||||
anonymous: true,
|
||||
summarize: false
|
||||
const newSession = new Session('session-id', honcho, {
|
||||
anonymous: true,
|
||||
summarize: false
|
||||
});
|
||||
|
||||
|
||||
expect(newSession.id).toBe('session-id');
|
||||
});
|
||||
});
|
||||
|
|
@ -72,7 +76,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.add.mockResolvedValue({});
|
||||
|
||||
await session.addPeers('peer1');
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -85,7 +89,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.add.mockResolvedValue({});
|
||||
|
||||
await session.addPeers(peer);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -97,11 +101,11 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.add.mockResolvedValue({});
|
||||
|
||||
await session.addPeers(['peer1', 'peer2', 'peer3']);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
{
|
||||
{
|
||||
'peer1': { observe_me: true, observe_others: false },
|
||||
'peer2': { observe_me: true, observe_others: false },
|
||||
'peer3': { observe_me: true, observe_others: false }
|
||||
|
|
@ -118,11 +122,11 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.add.mockResolvedValue({});
|
||||
|
||||
await session.addPeers(peers);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
{
|
||||
{
|
||||
'peer1': { observe_me: true, observe_others: false },
|
||||
'peer2': { observe_me: true, observe_others: false },
|
||||
'peer3': { observe_me: true, observe_others: false }
|
||||
|
|
@ -138,11 +142,11 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.add.mockResolvedValue({});
|
||||
|
||||
await session.addPeers(peers);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.add).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
{
|
||||
{
|
||||
'string-peer': { observe_me: true, observe_others: false },
|
||||
'object-peer': { observe_me: true, observe_others: false }
|
||||
}
|
||||
|
|
@ -161,7 +165,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.set.mockResolvedValue({});
|
||||
|
||||
await session.setPeers('peer1');
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.set).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -174,7 +178,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.set.mockResolvedValue({});
|
||||
|
||||
await session.setPeers(peer);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.set).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -187,11 +191,11 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.set.mockResolvedValue({});
|
||||
|
||||
await session.setPeers(peers);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.set).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
{
|
||||
{
|
||||
'peer1': { observe_me: true, observe_others: false },
|
||||
'peer2': { observe_me: true, observe_others: false }
|
||||
}
|
||||
|
|
@ -210,7 +214,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.remove.mockResolvedValue({});
|
||||
|
||||
await session.removePeers('peer1');
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.remove).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -223,7 +227,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.remove.mockResolvedValue({});
|
||||
|
||||
await session.removePeers(peer);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.remove).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -236,7 +240,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.remove.mockResolvedValue({});
|
||||
|
||||
await session.removePeers(peers);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.peers.remove).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -265,7 +269,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.list.mockResolvedValue(mockPeersData);
|
||||
|
||||
const peers = await session.getPeers();
|
||||
|
||||
|
||||
expect(peers).toBeInstanceOf(Array);
|
||||
expect(mockClient.workspaces.sessions.peers.list).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
|
|
@ -283,7 +287,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.peers.list.mockResolvedValue(mockPeersData);
|
||||
|
||||
const peers = await session.getPeers();
|
||||
|
||||
|
||||
expect(peers).toBeInstanceOf(Array);
|
||||
expect(peers.length).toBe(0);
|
||||
});
|
||||
|
|
@ -305,16 +309,16 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.messages.create.mockResolvedValue({});
|
||||
|
||||
await session.addMessages(message);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
{
|
||||
messages: [{
|
||||
peer_id: 'peer1',
|
||||
content: 'Hello world',
|
||||
metadata: { type: 'greeting' }
|
||||
}]
|
||||
{
|
||||
messages: [{
|
||||
peer_id: 'peer1',
|
||||
content: 'Hello world',
|
||||
metadata: { type: 'greeting' }
|
||||
}]
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -327,15 +331,15 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.messages.create.mockResolvedValue({});
|
||||
|
||||
await session.addMessages(messages);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
{
|
||||
{
|
||||
messages: [
|
||||
{ peer_id: 'peer1', content: 'Message 1', metadata: { order: 1 } },
|
||||
{ peer_id: 'peer2', content: 'Message 2', metadata: { order: 2 } },
|
||||
]
|
||||
]
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -348,7 +352,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.messages.create.mockResolvedValue({});
|
||||
|
||||
await session.addMessages(message);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -360,7 +364,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.messages.create.mockResolvedValue({});
|
||||
|
||||
await session.addMessages([]);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.messages.create).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -389,7 +393,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.messages.list.mockResolvedValue(mockMessagesData);
|
||||
|
||||
const messagesPage = await session.getMessages();
|
||||
|
||||
|
||||
expect(messagesPage).toBeInstanceOf(Page);
|
||||
expect(mockClient.workspaces.sessions.messages.list).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
|
|
@ -407,11 +411,11 @@ describe('Session', () => {
|
|||
};
|
||||
mockClient.workspaces.sessions.messages.list.mockResolvedValue(mockMessagesData);
|
||||
|
||||
const options = {
|
||||
filter: { peer_id: 'peer1', type: 'important' }
|
||||
const options = {
|
||||
filter: { peer_id: 'peer1', type: 'important' }
|
||||
};
|
||||
await session.getMessages(options);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.messages.list).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -435,7 +439,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession);
|
||||
|
||||
const metadata = await session.getMetadata();
|
||||
|
||||
|
||||
expect(metadata).toEqual({ name: 'Test Session', active: true });
|
||||
expect(mockClient.workspaces.sessions.getOrCreate).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
|
|
@ -451,7 +455,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.getOrCreate.mockResolvedValue(mockSession);
|
||||
|
||||
const metadata = await session.getMetadata();
|
||||
|
||||
|
||||
expect(metadata).toEqual({});
|
||||
});
|
||||
|
||||
|
|
@ -468,7 +472,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.update.mockResolvedValue({});
|
||||
|
||||
await session.setMetadata(metadata);
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.update).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -480,7 +484,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.update.mockResolvedValue({});
|
||||
|
||||
await session.setMetadata({});
|
||||
|
||||
|
||||
expect(mockClient.workspaces.sessions.update).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
|
|
@ -507,7 +511,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.getContext.mockResolvedValue(mockContext);
|
||||
|
||||
const context = await session.getContext();
|
||||
|
||||
|
||||
expect(context).toBeInstanceOf(SessionContext);
|
||||
expect(context.sessionId).toBe('test-session');
|
||||
expect(context.messages).toEqual(mockContext.messages);
|
||||
|
|
@ -528,7 +532,7 @@ describe('Session', () => {
|
|||
|
||||
const options = { summary: true, tokens: 1000 };
|
||||
const context = await session.getContext(options);
|
||||
|
||||
|
||||
expect(context).toBeInstanceOf(SessionContext);
|
||||
expect(mockClient.workspaces.sessions.getContext).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
|
|
@ -544,7 +548,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.getContext.mockResolvedValue(mockContext);
|
||||
|
||||
const context = await session.getContext();
|
||||
|
||||
|
||||
expect(context.summary).toBe('');
|
||||
});
|
||||
|
||||
|
|
@ -569,12 +573,12 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.search.mockResolvedValue(mockSearchResults);
|
||||
|
||||
const results = await session.search('hello');
|
||||
|
||||
|
||||
expect(results).toBeInstanceOf(Page);
|
||||
expect(mockClient.workspaces.sessions.search).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
'test-session',
|
||||
'hello'
|
||||
{ query: 'hello' }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -588,7 +592,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.sessions.search.mockResolvedValue(mockSearchResults);
|
||||
|
||||
const results = await session.search('nonexistent');
|
||||
|
||||
|
||||
expect(results).toBeInstanceOf(Page);
|
||||
});
|
||||
|
||||
|
|
@ -620,7 +624,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(mockRepresentation);
|
||||
|
||||
const result = await session.workingRep('peer1');
|
||||
|
||||
|
||||
expect(result).toEqual(mockRepresentation);
|
||||
expect(mockClient.workspaces.peers.workingRepresentation).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
|
|
@ -638,7 +642,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(mockRepresentation);
|
||||
|
||||
const result = await session.workingRep(peer);
|
||||
|
||||
|
||||
expect(result).toEqual(mockRepresentation);
|
||||
expect(mockClient.workspaces.peers.workingRepresentation).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
|
|
@ -655,7 +659,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(mockRepresentation);
|
||||
|
||||
const result = await session.workingRep('peer1', 'target-peer');
|
||||
|
||||
|
||||
expect(result).toEqual(mockRepresentation);
|
||||
expect(mockClient.workspaces.peers.workingRepresentation).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
|
|
@ -674,7 +678,7 @@ describe('Session', () => {
|
|||
mockClient.workspaces.peers.workingRepresentation.mockResolvedValue(mockRepresentation);
|
||||
|
||||
const result = await session.workingRep(peer, target);
|
||||
|
||||
|
||||
expect(result).toEqual(mockRepresentation);
|
||||
expect(mockClient.workspaces.peers.workingRepresentation).toHaveBeenCalledWith(
|
||||
'test-workspace',
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "honcho-ai",
|
||||
"name": "@honcho-ai/sdk",
|
||||
"dependencies": {
|
||||
"@honcho-ai/core": "^1.0.0",
|
||||
"@honcho-ai/core": "1.2.0",
|
||||
"@types/node": "^24.0.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -21,14 +21,16 @@
|
|||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.27.5", "", {}, "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg=="],
|
||||
"@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.27.4", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.4", "@babel/parser": "^7.27.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.4", "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g=="],
|
||||
"@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.27.5", "", { "dependencies": { "@babel/parser": "^7.27.5", "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw=="],
|
||||
"@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="],
|
||||
|
|
@ -43,7 +45,7 @@
|
|||
|
||||
"@babel/helpers": ["@babel/helpers@7.27.6", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.27.6" } }, "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.27.5", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg=="],
|
||||
"@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="],
|
||||
|
||||
"@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="],
|
||||
|
||||
|
|
@ -81,9 +83,9 @@
|
|||
|
||||
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.27.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/parser": "^7.27.4", "@babel/template": "^7.27.2", "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA=="],
|
||||
"@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="],
|
||||
"@babel/types": ["@babel/types@7.28.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ=="],
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
|
||||
|
||||
|
|
@ -95,7 +97,7 @@
|
|||
|
||||
"@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="],
|
||||
|
||||
"@honcho-ai/core": ["@honcho-ai/core@1.0.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-WwpKTxMhkBEpiQ2UYlM9HH+MCAkWA8o+5w/J/bYKAo9traZF4UKsO2VouWIXiLsyIvLfqzLGgGrALoH2P4h9Uw=="],
|
||||
"@honcho-ai/core": ["@honcho-ai/core@1.2.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-VPHCFIGfC00GeE4P83DDIT7hkuMnMVkWlMTmMd2tw4HSEUciqLBh09AX/6aMKfJAzprg1diub6pJJ6LJP6eJ+g=="],
|
||||
|
||||
"@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
|
||||
|
||||
|
|
@ -135,15 +137,13 @@
|
|||
|
||||
"@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
|
|
@ -175,7 +175,7 @@
|
|||
|
||||
"@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="],
|
||||
|
||||
"@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="],
|
||||
"@types/node": ["@types/node@24.0.14", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
|
||||
|
||||
|
|
@ -227,7 +227,7 @@
|
|||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.25.0", "", { "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA=="],
|
||||
"browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="],
|
||||
|
||||
"bs-logger": ["bs-logger@0.2.6", "", { "dependencies": { "fast-json-stable-stringify": "2.x" } }, "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog=="],
|
||||
|
||||
|
|
@ -241,7 +241,7 @@
|
|||
|
||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001723", "", {}, "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
|
|
@ -291,7 +291,7 @@
|
|||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.170", "", {}, "sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.185", "", {}, "sha512-dYOZfUk57hSMPePoIQ1fZWl1Fkj+OshhEVuPacNKWzC1efe56OsHY3l/jCfiAgIICOU3VgOIdoq7ahg7r7n6MQ=="],
|
||||
|
||||
"emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="],
|
||||
|
||||
|
|
@ -733,9 +733,7 @@
|
|||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
|
||||
|
||||
"@honcho-ai/core/@types/node": ["@types/node@18.19.112", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-i+Vukt9POdS/MBI7YrrkkI5fMfwFtOjphSmt4WXYLfwqsfr6z/HdCx7LqT9M7JktGob8WNgj8nFB4TbGNE4Cog=="],
|
||||
"@honcho-ai/core/@types/node": ["@types/node@18.19.119", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-d0F6m9itIPaKnrvEMlzE48UjwZaAnFW7Jwibacw9MNdqadjKNpUm9tfJYDwmShJmgqcoqYUX3EMKO1+RWiuuNg=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
import { Honcho, SessionPeerConfig } from '../src';
|
||||
|
||||
/**
|
||||
* Example usage of the Honcho TypeScript SDK.
|
||||
*
|
||||
* This demonstrates how to manage peers, sessions, and messages
|
||||
* using the high-level SDK API.
|
||||
*/
|
||||
async function main() {
|
||||
console.log('Initializing Honcho client...');
|
||||
const honcho = new Honcho({
|
||||
environment: 'local',
|
||||
workspaceId: 'test',
|
||||
});
|
||||
|
||||
console.log('Creating peers...');
|
||||
const assistant = honcho.peer('bob');
|
||||
const alice = honcho.peer('alice');
|
||||
|
||||
console.log('Fetching all peers in workspace...');
|
||||
const peers = await honcho.getPeers();
|
||||
for await (const peer of peers) {
|
||||
console.log('Peer:', peer.id);
|
||||
}
|
||||
|
||||
console.log('Fetching workspace metadata...');
|
||||
const m = await honcho.getMetadata();
|
||||
console.log('Current metadata:', m);
|
||||
await honcho.setMetadata({ test: 'test' });
|
||||
console.log('Set workspace metadata.');
|
||||
|
||||
console.log('Testing chat endpoint (should be null)...');
|
||||
const response = await alice.chat('what did alice have for breakfast today?');
|
||||
console.log('Chat response:', response);
|
||||
|
||||
console.log('Creating session...');
|
||||
const mySession = honcho.session('session_1');
|
||||
|
||||
console.log('Adding peers to session...');
|
||||
await mySession.addPeers([alice, [assistant, new SessionPeerConfig({ observe_me: false })]]);
|
||||
console.log('Peers added to session.');
|
||||
|
||||
console.log('Fetching sessions for alice...');
|
||||
const _sessions = await alice.getSessions();
|
||||
for await (const session of _sessions) {
|
||||
console.log('Session:', session.id);
|
||||
}
|
||||
|
||||
console.log('Adding messages to session...');
|
||||
await mySession.addMessages([
|
||||
assistant.message('what did you have for breakfast today, alice?'),
|
||||
alice.message('i had oatmeal.'),
|
||||
]);
|
||||
console.log('Messages added.');
|
||||
|
||||
const sessionMetadata = await mySession.getMetadata();
|
||||
console.log('Session metadata:', sessionMetadata);
|
||||
await mySession.setMetadata({ ...sessionMetadata, test: 'test2' });
|
||||
console.log('Session metadata updated.');
|
||||
|
||||
console.log('Querying alice global representation...');
|
||||
await alice.chat('what did the user have for breakfast today?');
|
||||
|
||||
console.log('Querying alice local representation of assistant...');
|
||||
await alice.chat('does alice know what bob had for breakfast?', { target: assistant });
|
||||
|
||||
console.log('Querying assistant local representation of alice in session...');
|
||||
await assistant.chat('does the assistant know what alice had for breakfast?', {
|
||||
target: alice,
|
||||
sessionId: mySession.id,
|
||||
});
|
||||
|
||||
console.log('Adding non-message content to alice...');
|
||||
await alice.addMessages('this might be a document about alice, say, a journal entry.');
|
||||
|
||||
console.log('Creating charlie peer and adding message...');
|
||||
const charlie = honcho.peer('charlie');
|
||||
await mySession.addMessages(charlie.message('hello world!'));
|
||||
|
||||
console.log('Fetching and updating charlie metadata...');
|
||||
const charlieMetadata = await charlie.getMetadata();
|
||||
await charlie.setMetadata({ ...charlieMetadata, location: 'the moon' });
|
||||
console.log('Charlie metadata updated.');
|
||||
|
||||
console.log('Querying charlie for location...');
|
||||
await charlie.chat('where is the user?');
|
||||
|
||||
console.log('Fetching all messages from session...');
|
||||
const messages = await mySession.getMessages();
|
||||
console.log('Messages:', messages.total);
|
||||
|
||||
console.log('Fetching session context...');
|
||||
const context = await mySession.getContext();
|
||||
const openaiMessages = context.toOpenAI(alice.id);
|
||||
const anthropicMessages = context.toAnthropic(alice.id);
|
||||
console.log('OpenAI context:', openaiMessages);
|
||||
console.log('Anthropic context:', anthropicMessages);
|
||||
|
||||
console.log('Adding test message using property syntax...');
|
||||
await mySession.addMessages(
|
||||
assistant.message('This is a test message using the property syntax')
|
||||
);
|
||||
|
||||
console.log('Sample code executed successfully!');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Error running example:', err);
|
||||
});
|
||||
|
|
@ -20,6 +20,8 @@ async function main() {
|
|||
honcho.peer('charlie'),
|
||||
];
|
||||
|
||||
const alice = peers[0];
|
||||
|
||||
// Create a new session
|
||||
const sessionId = `search_test_${crypto.randomUUID()}`;
|
||||
const session = honcho.session(sessionId);
|
||||
|
|
@ -28,7 +30,7 @@ async function main() {
|
|||
// Create a message with our special keyword
|
||||
const keyword = `~special-${crypto.randomUUID()}~`;
|
||||
console.log(`Using keyword: ${keyword}`);
|
||||
await session.addMessages(peers[0].message(`I am a ${keyword} message`));
|
||||
await session.addMessages(alice.message(`I am a ${keyword} message`));
|
||||
|
||||
console.log('Generating random messages...');
|
||||
// Generate some random messages from alice, bob, and charlie and add them to the session
|
||||
|
|
@ -51,13 +53,6 @@ async function main() {
|
|||
console.log(` - ${message.content} (from ${message.peer_id})`);
|
||||
}
|
||||
|
||||
const alice = peers[0];
|
||||
|
||||
// Add a different message to alice's global representation
|
||||
const differentKeyword = `~different-${crypto.randomUUID()}~`;
|
||||
console.log(`Using different keyword: ${differentKeyword}`);
|
||||
await alice.addMessages(alice.message(`I am a ${differentKeyword} message`));
|
||||
|
||||
console.log('Searching the workspace...');
|
||||
// Search the workspace for the special keyword
|
||||
const workspaceSearchResults = await honcho.search(keyword);
|
||||
|
|
@ -66,9 +61,9 @@ async function main() {
|
|||
console.log(` - ${message.content} (from ${message.peer_id})`);
|
||||
}
|
||||
|
||||
console.log('Searching alice\'s global representation...');
|
||||
// Search alice's global representation for the different message
|
||||
const aliceSearchResults = await alice.search(differentKeyword);
|
||||
console.log('Searching alice\'s messages...');
|
||||
// Search alice's messages for the special keyword
|
||||
const aliceSearchResults = await alice.search(keyword);
|
||||
console.log(`Alice search returned ${aliceSearchResults.total} results:`);
|
||||
for await (const message of aliceSearchResults) {
|
||||
console.log(` - ${message.content} (from ${message.peer_id})`);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@honcho-ai/sdk",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Official DX Optimized TypeScript SDK for Honcho",
|
||||
"author": "Plastic Labs <hello@plasticlabs.ai>",
|
||||
"license": "Apache-2.0",
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^24.0.1",
|
||||
"@honcho-ai/core": "^1.1.0"
|
||||
"@honcho-ai/core": "1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
|
|
|
|||
|
|
@ -116,4 +116,100 @@ export class Honcho {
|
|||
const messagesPage = await this._client.workspaces.search(this.workspaceId, { body: query });
|
||||
return new Page(messagesPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the deriver processing status, optionally scoped to an observer, sender, and/or session.
|
||||
*
|
||||
* @param options Configuration options for the status request
|
||||
* @param options.observerId Optional observer ID to scope the status to
|
||||
* @param options.senderId Optional sender ID to scope the status to
|
||||
* @param options.sessionId Optional session ID to scope the status to
|
||||
* @returns Promise resolving to the deriver status information
|
||||
*/
|
||||
async getDeriverStatus(options?: {
|
||||
observerId?: string;
|
||||
senderId?: string;
|
||||
sessionId?: string;
|
||||
}): Promise<{
|
||||
totalWorkUnits: number;
|
||||
completedWorkUnits: number;
|
||||
inProgressWorkUnits: number;
|
||||
pendingWorkUnits: number;
|
||||
sessions?: Record<string, any>;
|
||||
}> {
|
||||
const queryParams: any = {};
|
||||
if (options?.observerId) queryParams.observer_id = options.observerId;
|
||||
if (options?.senderId) queryParams.sender_id = options.senderId;
|
||||
if (options?.sessionId) queryParams.session_id = options.sessionId;
|
||||
|
||||
const status = await this._client.workspaces.deriverStatus(this.workspaceId, queryParams);
|
||||
|
||||
return {
|
||||
totalWorkUnits: status.total_work_units,
|
||||
completedWorkUnits: status.completed_work_units,
|
||||
inProgressWorkUnits: status.in_progress_work_units,
|
||||
pendingWorkUnits: status.pending_work_units,
|
||||
sessions: status.sessions || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll getDeriverStatus until pendingWorkUnits and inProgressWorkUnits are both 0.
|
||||
* This allows you to guarantee that all messages have been processed by the deriver for
|
||||
* use with the dialectic endpoint.
|
||||
*
|
||||
* The polling estimates sleep time by assuming each work unit takes 1 second.
|
||||
*
|
||||
* @param options Configuration options for the status request
|
||||
* @param options.observerId Optional observer ID to scope the status to
|
||||
* @param options.senderId Optional sender ID to scope the status to
|
||||
* @param options.sessionId Optional session ID to scope the status to
|
||||
* @param options.timeoutMs Optional timeout in milliseconds (default: 300000 - 5 minutes)
|
||||
* @returns Promise resolving to the final deriver status when processing is complete
|
||||
* @throws Error if timeout is exceeded before processing completes
|
||||
*/
|
||||
async pollDeriverStatus(options?: {
|
||||
observerId?: string;
|
||||
senderId?: string;
|
||||
sessionId?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{
|
||||
totalWorkUnits: number;
|
||||
completedWorkUnits: number;
|
||||
inProgressWorkUnits: number;
|
||||
pendingWorkUnits: number;
|
||||
sessions?: Record<string, any>;
|
||||
}> {
|
||||
const timeoutMs = options?.timeoutMs ?? 300000; // Default to 5 minutes
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
const status = await this.getDeriverStatus(options);
|
||||
if (status.pendingWorkUnits === 0 && status.inProgressWorkUnits === 0) {
|
||||
return status;
|
||||
}
|
||||
|
||||
// Check if timeout has been exceeded
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
if (elapsedTime >= timeoutMs) {
|
||||
throw new Error(
|
||||
`Polling timeout exceeded after ${timeoutMs}ms. ` +
|
||||
`Current status: ${status.pendingWorkUnits} pending, ${status.inProgressWorkUnits} in progress work units.`
|
||||
);
|
||||
}
|
||||
|
||||
// Sleep for the expected time to complete all current work units
|
||||
// Assuming each pending and in-progress work unit takes 1 second
|
||||
const totalWorkUnits = status.pendingWorkUnits + status.inProgressWorkUnits;
|
||||
const sleepMs = Math.max(1000, totalWorkUnits * 1000); // Sleep at least 1 second
|
||||
|
||||
// Ensure we don't sleep past the timeout
|
||||
const remainingTime = timeoutMs - elapsedTime;
|
||||
const actualSleepMs = Math.min(sleepMs, remainingTime);
|
||||
|
||||
if (actualSleepMs > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, actualSleepMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,14 +40,14 @@ export class Page<T> implements AsyncIterable<T> {
|
|||
* Get the size of the page.
|
||||
*/
|
||||
get size(): number {
|
||||
return this._originalPage?.size ?? 0;
|
||||
return this._originalPage?.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of items.
|
||||
*/
|
||||
get total(): number {
|
||||
return this._originalPage?.total ?? 0;
|
||||
return this._originalPage?.total;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -63,7 +63,7 @@ export class Page<T> implements AsyncIterable<T> {
|
|||
* Check if there's a next page.
|
||||
*/
|
||||
get hasNextPage(): boolean {
|
||||
return this._originalPage?.hasNextPage ?? false;
|
||||
return this._originalPage?.hasNextPage;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export class Peer {
|
|||
/**
|
||||
* Query the peer's representation with a natural language question.
|
||||
*/
|
||||
async chat(queries: string | string[], opts?: {
|
||||
async chat(query: string, opts?: {
|
||||
stream?: boolean;
|
||||
target?: string | Peer;
|
||||
sessionId?: string;
|
||||
|
|
@ -38,7 +38,7 @@ export class Peer {
|
|||
const response = await this._honcho['_client'].workspaces.peers.chat(
|
||||
this._honcho.workspaceId,
|
||||
this.id,
|
||||
{ queries, stream: opts?.stream, target: opts?.target ? (typeof opts.target === 'string' ? opts.target : opts.target.id) : undefined, session_id: opts?.sessionId },
|
||||
{ query, stream: opts?.stream, target: opts?.target ? (typeof opts.target === 'string' ? opts.target : opts.target.id) : undefined, session_id: opts?.sessionId },
|
||||
);
|
||||
if (!response.content || response.content === 'None') {
|
||||
return null;
|
||||
|
|
@ -57,45 +57,6 @@ export class Peer {
|
|||
return new Page(sessionsPage, (session: any) => new Session(session.id, this._honcho));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add messages or content to this peer's global representation.
|
||||
*/
|
||||
async addMessages(content: string | any | any[]): Promise<void> {
|
||||
let messages: any[];
|
||||
if (typeof content === 'string') {
|
||||
messages = [{ peer_id: this.id, content, metadata: undefined }];
|
||||
} else if (Array.isArray(content)) {
|
||||
messages = content.map((msg) => ({
|
||||
peer_id: msg.peerId || this.id,
|
||||
content: msg.content,
|
||||
metadata: msg.metadata,
|
||||
}));
|
||||
} else {
|
||||
messages = [{
|
||||
peer_id: content.peerId || this.id,
|
||||
content: content.content,
|
||||
metadata: content.metadata,
|
||||
}];
|
||||
}
|
||||
await this._honcho['_client'].workspaces.peers.messages.create(
|
||||
this._honcho.workspaceId,
|
||||
this.id,
|
||||
{ messages }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages saved to this peer outside of a session with optional filtering.
|
||||
*/
|
||||
async getMessages(opts?: { filter?: Record<string, unknown> }): Promise<Page<any>> {
|
||||
const messagesPage = await this._honcho['_client'].workspaces.peers.messages.list(
|
||||
this.id,
|
||||
this._honcho.workspaceId,
|
||||
opts?.filter,
|
||||
);
|
||||
return new Page(messagesPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message attributed to this peer.
|
||||
*/
|
||||
|
|
@ -130,9 +91,9 @@ export class Peer {
|
|||
}
|
||||
|
||||
/**
|
||||
* Search for messages in this peer's global representation.
|
||||
* Search for messages in the workspace with this peer as author.
|
||||
*
|
||||
* Makes an API call to search for messages in this peer's global representation.
|
||||
* Makes an API call to search endpoint.
|
||||
*
|
||||
* @param query The search query to use
|
||||
* @returns A Page of Message objects representing the search results.
|
||||
|
|
@ -149,4 +110,40 @@ export class Peer {
|
|||
);
|
||||
return new Page(messagesPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to create messages in this peer's global representation.
|
||||
*
|
||||
* Makes an API call to upload a file and convert it into messages. The file is
|
||||
* processed to extract text content, split into appropriately sized chunks,
|
||||
* and created as messages attributed to this peer.
|
||||
*
|
||||
* @param file File to upload. Should be an object with filename, content (as Buffer or Uint8Array), and content_type
|
||||
* @returns A list of Message objects representing the created messages
|
||||
*
|
||||
* @note Supported file types include PDFs, text files, and JSON documents.
|
||||
* Large files will be automatically split into multiple messages to fit
|
||||
* within message size limits.
|
||||
*/
|
||||
async uploadFile(
|
||||
file: { filename: string; content: Buffer | Uint8Array; content_type: string }
|
||||
): Promise<any[]> {
|
||||
// Convert file to the format expected by the API
|
||||
const fileData = {
|
||||
filename: file.filename,
|
||||
content: file.content,
|
||||
content_type: file.content_type
|
||||
};
|
||||
|
||||
// Call the upload endpoint
|
||||
const response = await (this._honcho['_client'] as any).workspaces.peers.messages.upload(
|
||||
this._honcho.workspaceId,
|
||||
this.id,
|
||||
{
|
||||
file: fileData
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
|
@ -221,12 +221,50 @@ export class Session {
|
|||
return new Page(messagesPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to create messages in this session.
|
||||
*
|
||||
* Makes an API call to upload a file and convert it into messages. The file is
|
||||
* processed to extract text content, split into appropriately sized chunks,
|
||||
* and created as messages attributed to this peer.
|
||||
*
|
||||
* @param file File to upload. Should be an object with filename, content (as Buffer or Uint8Array), and content_type
|
||||
* @param peerId The peer ID to attribute the messages to
|
||||
* @returns A list of Message objects representing the created messages
|
||||
*
|
||||
* @note Supported file types include PDFs, text files, and JSON documents.
|
||||
* Large files will be automatically split into multiple messages to fit
|
||||
* within message size limits.
|
||||
*/
|
||||
async uploadFile(
|
||||
file: { filename: string; content: Buffer | Uint8Array; content_type: string },
|
||||
peerId: string,
|
||||
): Promise<any[]> {
|
||||
// Convert file to the format expected by the API
|
||||
const fileData = {
|
||||
filename: file.filename,
|
||||
content: file.content,
|
||||
content_type: file.content_type
|
||||
};
|
||||
|
||||
// Call the upload endpoint
|
||||
const response = await (this._honcho['_client'] as any).workspaces.sessions.messages.upload(
|
||||
this._honcho.workspaceId,
|
||||
this.id,
|
||||
{
|
||||
file: fileData,
|
||||
peer_id: peerId,
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current working representation of the peer in this session.
|
||||
*
|
||||
* @param peer The peer to get the working representation of.
|
||||
* @param target The target peer to get the representation of. If provided,
|
||||
* queries what `peer` knows about the `target`.
|
||||
* @param target The target peer to get the representation of. If provided, queries what `peer` knows about the `target`.
|
||||
* @returns A dictionary containing information about the peer.
|
||||
*/
|
||||
async workingRep(peer: string | Peer, target?: string | Peer): Promise<Record<string, unknown>> {
|
||||
|
|
|
|||
475
src/agent.py
475
src/agent.py
|
|
@ -1,475 +0,0 @@
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
from langfuse.decorators import langfuse_context, observe # pyright: ignore
|
||||
from mirascope import llm
|
||||
from mirascope.integrations.langfuse import with_langfuse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, models
|
||||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.deriver.tom import get_tom_inference
|
||||
from src.deriver.tom.embeddings import CollectionEmbeddingStore
|
||||
from src.deriver.tom.long_term import get_user_representation_long_term
|
||||
from src.deriver.tom.single_prompt import UserRepresentationOutput
|
||||
from src.utils import history, parse_xml_content
|
||||
from src.utils.clients import clients
|
||||
from src.utils.types import track
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@track("Dialectic Call")
|
||||
@with_langfuse()
|
||||
@llm.call(
|
||||
provider=(
|
||||
settings.LLM.DIALECTIC_PROVIDER
|
||||
if settings.LLM.DIALECTIC_PROVIDER != "custom"
|
||||
else "openai"
|
||||
),
|
||||
model=settings.LLM.DIALECTIC_MODEL,
|
||||
client=clients[settings.LLM.DIALECTIC_PROVIDER],
|
||||
)
|
||||
async def dialectic_call(
|
||||
query: str, working_representation: str, additional_context: str
|
||||
):
|
||||
return f"""
|
||||
You are operating as a context service that helps maintain psychological understanding of users across applications. Alongside a query, you'll receive: 1) previously collected psychological context about the user that I've maintained, 2) a series of long-term facts about the user, and 3) their current conversation/interaction from the requesting application. Your goal is to analyze this information and provide theory-of-mind insights that help applications personalize their responses. Please respond in a brief, matter-of-fact, and appropriate manner to convey as much relevant information to the application based on its query and the user's most recent message. You are encouraged to provide any context from the provided resources that helps provide a more complete or nuanced understanding of the user, as long as it is somewhat relevant to the query. If the context provided doesn't help address the query, write absolutely NOTHING but "None".
|
||||
|
||||
<query>{query}</query>
|
||||
<context>{working_representation}</context>
|
||||
<conversation_history>{additional_context}</conversation_history>
|
||||
"""
|
||||
|
||||
|
||||
@track("Dialectic Stream")
|
||||
@with_langfuse()
|
||||
@llm.call(
|
||||
provider=(
|
||||
settings.LLM.DIALECTIC_PROVIDER
|
||||
if settings.LLM.DIALECTIC_PROVIDER != "custom"
|
||||
else "openai"
|
||||
),
|
||||
model=settings.LLM.DIALECTIC_MODEL,
|
||||
stream=True,
|
||||
client=clients[settings.LLM.DIALECTIC_PROVIDER],
|
||||
)
|
||||
async def dialectic_stream(
|
||||
query: str, working_representation: str, additional_context: str
|
||||
):
|
||||
return f"""
|
||||
You are operating as a context service that helps maintain psychological understanding of users across applications. Alongside a query, you'll receive: 1) previously collected psychological context about the user that I've maintained, 2) a series of long-term facts about the user, and 3) their current conversation/interaction from the requesting application. Your goal is to analyze this information and provide theory-of-mind insights that help applications personalize their responses. Please respond in a brief, matter-of-fact, and appropriate manner to convey as much relevant information to the application based on its query and the user's most recent message. You are encouraged to provide any context from the provided resources that helps provide a more complete or nuanced understanding of the user, as long as it is somewhat relevant to the query. If the context provided doesn't help address the query, write absolutely NOTHING but "None".
|
||||
|
||||
<query>{query}</query>
|
||||
<context>{working_representation}</context>
|
||||
<conversation_history>{additional_context}</conversation_history>
|
||||
"""
|
||||
|
||||
|
||||
class SemanticQueries(BaseModel):
|
||||
queries: list[str]
|
||||
|
||||
|
||||
@with_langfuse()
|
||||
@llm.call(
|
||||
provider=(
|
||||
settings.LLM.QUERY_GENERATION_PROVIDER
|
||||
if settings.LLM.QUERY_GENERATION_PROVIDER != "custom"
|
||||
else "openai"
|
||||
),
|
||||
model=settings.LLM.QUERY_GENERATION_MODEL,
|
||||
response_model=SemanticQueries,
|
||||
client=clients[settings.LLM.QUERY_GENERATION_PROVIDER],
|
||||
)
|
||||
async def generate_semantic_queries_llm(query: str):
|
||||
return f"""
|
||||
Given this query about a user, generate 3 focused search queries that would help retrieve relevant facts about the user. Each query should focus on a specific aspect related to the original query, rephrased to maximize semantic search effectiveness.
|
||||
For example, if the original query asks "what does the user like to eat?", generated queries might include "user's food preferences", "user's favorite cuisine", etc.
|
||||
|
||||
Format your response as a JSON array of strings, with each string being a search query.
|
||||
Respond only in valid JSON, without markdown formatting or quotes, and nothing else.
|
||||
Example:
|
||||
["query about interests", "query about personality", "query about experiences"]
|
||||
|
||||
<query>{query}</query>
|
||||
"""
|
||||
|
||||
|
||||
@observe()
|
||||
async def chat(
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
session_name: str | None,
|
||||
queries: str | list[str],
|
||||
stream: bool = False,
|
||||
target: str | None = None,
|
||||
) -> llm.Stream | llm.CallResponse:
|
||||
"""
|
||||
Chat with the Dialectic API using on-demand user representation generation.
|
||||
|
||||
This function:
|
||||
1. Sets up resources needed (embedding store, latest message ID)
|
||||
2. Runs two parallel processes:
|
||||
- Retrieves long-term facts from the vector store based on the query
|
||||
- Gets recent chat history and runs ToM inference
|
||||
3. Combines both into a fresh user representation
|
||||
4. Uses this representation to answer the query
|
||||
5. Saves the representation for future use
|
||||
|
||||
Args:
|
||||
workspace_name: The workspace name
|
||||
peer_name: The peer name
|
||||
session_name: The session name. If None, this queries the global representation.
|
||||
queries: The queries to ask the Dialectic API
|
||||
stream: Whether to stream the response
|
||||
|
||||
Returns:
|
||||
Either a string or a stream of messages from the LLM provider, depending on the stream flag
|
||||
"""
|
||||
# Format the query string
|
||||
questions = [queries] if isinstance(queries, str) else queries
|
||||
final_query = "\n".join(questions) if len(questions) > 1 else questions[0]
|
||||
|
||||
logger.debug(f"Received query: {final_query} for session {session_name}")
|
||||
logger.debug("Starting on-demand user representation generation")
|
||||
|
||||
# Setup phase - create resources we'll need for all operations
|
||||
|
||||
# 1. Fetch latest peer message & chat history
|
||||
async with tracked_db("chat.load_history") as db_history:
|
||||
stmt = (
|
||||
select(models.Message)
|
||||
.where(models.Message.workspace_name == workspace_name)
|
||||
.where(models.Message.peer_name == peer_name)
|
||||
.order_by(models.Message.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if session_name:
|
||||
stmt = stmt.where(models.Message.session_name == session_name)
|
||||
latest_messages = await db_history.execute(stmt)
|
||||
latest_message = latest_messages.scalar_one_or_none()
|
||||
latest_message_id = latest_message.public_id if latest_message else None
|
||||
if session_name:
|
||||
chat_history = await history.get_summarized_history(
|
||||
db_history,
|
||||
workspace_name,
|
||||
session_name,
|
||||
peer_name,
|
||||
summary_type=history.SummaryType.SHORT,
|
||||
)
|
||||
if not chat_history:
|
||||
logger.warning(f"No chat history found for session {session_name}")
|
||||
chat_history = (
|
||||
f"someone asked this about the user's message: {final_query}"
|
||||
)
|
||||
logger.debug(
|
||||
f"Workspace: {workspace_name}, Peer: {peer_name}, Session: {session_name}"
|
||||
)
|
||||
else:
|
||||
chat_history = ""
|
||||
logger.debug("Retrieved chat history: %s lines", len(chat_history.split("\n")))
|
||||
|
||||
# Run short-term inference and long-term facts in parallel
|
||||
async def fetch_long_term():
|
||||
async with tracked_db("chat.get_collection") as db_embed:
|
||||
name = (
|
||||
"global_representation"
|
||||
if target is None
|
||||
else crud.construct_collection_name(peer_name, target)
|
||||
)
|
||||
collection = await crud.get_or_create_collection(
|
||||
db_embed, workspace_name, collection_name=name, peer_name=peer_name
|
||||
)
|
||||
collection_name = collection.name # Extract the ID while session is active
|
||||
facts = await get_long_term_facts(
|
||||
final_query, workspace_name, peer_name, collection_name
|
||||
)
|
||||
return facts
|
||||
|
||||
long_term_task = asyncio.create_task(fetch_long_term())
|
||||
short_term_task = asyncio.create_task(run_tom_inference(chat_history))
|
||||
|
||||
facts, tom_inference = await asyncio.gather(long_term_task, short_term_task)
|
||||
logger.debug(f"Retrieved {len(facts)} facts from long-term memory")
|
||||
logger.debug(f"TOM inference completed with {len(tom_inference)} characters")
|
||||
|
||||
# Generate a fresh user representation
|
||||
logger.debug("Generating user representation")
|
||||
async with tracked_db("chat.generate_user_representation") as db_rep:
|
||||
user_representation = await generate_user_representation(
|
||||
workspace_name,
|
||||
peer_name,
|
||||
session_name,
|
||||
chat_history=chat_history,
|
||||
tom_inference=tom_inference,
|
||||
facts=facts,
|
||||
db=db_rep,
|
||||
message_id=latest_message_id,
|
||||
with_inference=False,
|
||||
)
|
||||
logger.debug(
|
||||
f"User representation generated: {len(user_representation)} characters"
|
||||
)
|
||||
|
||||
# Call dialectic with enhanced context
|
||||
|
||||
langfuse_context.update_current_trace(
|
||||
session_id=session_name,
|
||||
user_id=peer_name,
|
||||
release=settings.SENTRY.RELEASE,
|
||||
metadata={"environment": settings.SENTRY.ENVIRONMENT},
|
||||
)
|
||||
|
||||
# Use streaming or non-streaming response based on the request
|
||||
logger.debug(f"Calling Dialectic with streaming={stream}")
|
||||
if stream:
|
||||
logger.debug("Calling Dialectic with streaming")
|
||||
response = await dialectic_stream(
|
||||
final_query, user_representation, chat_history
|
||||
)
|
||||
return response
|
||||
else:
|
||||
logger.debug("Calling Dialectic with non-streaming")
|
||||
response = await dialectic_call(final_query, user_representation, chat_history)
|
||||
return response
|
||||
|
||||
|
||||
async def get_long_term_facts(
|
||||
query: str,
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
collection_name: str,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Generate queries based on the dialectic query and retrieve relevant facts.
|
||||
|
||||
Args:
|
||||
query: The user query
|
||||
workspace_name: The workspace name
|
||||
peer_name: The peer name
|
||||
collection_name: The collection name
|
||||
|
||||
Returns:
|
||||
List of retrieved facts
|
||||
"""
|
||||
logger.debug(f"Starting fact retrieval for query: {query}")
|
||||
fact_start_time = asyncio.get_event_loop().time()
|
||||
|
||||
# Generate multiple queries for the semantic search
|
||||
logger.debug("Generating semantic queries")
|
||||
search_queries = await generate_semantic_queries(query)
|
||||
logger.debug(f"Generated {len(search_queries)} semantic queries: {search_queries}")
|
||||
|
||||
# Create a list of coroutines, one for each query
|
||||
async def execute_query(i: int, search_query: str) -> list[str]:
|
||||
logger.debug(f"Starting query {i + 1}/{len(search_queries)}: {search_query}")
|
||||
query_start = asyncio.get_event_loop().time()
|
||||
query_embedding_store = CollectionEmbeddingStore(
|
||||
workspace_name=workspace_name,
|
||||
peer_name=peer_name,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
facts = await query_embedding_store.get_relevant_facts(
|
||||
search_query,
|
||||
top_k=settings.AGENT.SEMANTIC_SEARCH_TOP_K,
|
||||
max_distance=settings.AGENT.SEMANTIC_SEARCH_MAX_DISTANCE,
|
||||
)
|
||||
query_time = asyncio.get_event_loop().time() - query_start
|
||||
logger.debug(f"Query {i + 1} retrieved {len(facts)} facts in {query_time:.2f}s")
|
||||
return facts
|
||||
|
||||
# Execute all queries in parallel
|
||||
query_tasks = [
|
||||
execute_query(i, search_query) for i, search_query in enumerate(search_queries)
|
||||
]
|
||||
all_facts_lists = await asyncio.gather(*query_tasks)
|
||||
|
||||
# Combine all facts into a single set to remove duplicates
|
||||
retrieved_facts: set[str] = set()
|
||||
for facts in all_facts_lists:
|
||||
retrieved_facts.update(facts)
|
||||
|
||||
total_time = asyncio.get_event_loop().time() - fact_start_time
|
||||
logger.debug(
|
||||
f"Total fact retrieval completed in {total_time:.2f}s with {len(retrieved_facts)} unique facts"
|
||||
)
|
||||
return list(retrieved_facts)
|
||||
|
||||
|
||||
async def run_tom_inference(chat_history: str) -> str:
|
||||
"""
|
||||
Run ToM inference on chat history.
|
||||
|
||||
Args:
|
||||
chat_history: The chat history
|
||||
|
||||
Returns:
|
||||
The ToM inference
|
||||
"""
|
||||
# Run ToM inference
|
||||
logger.debug("Running ToM inference")
|
||||
tom_start_time = asyncio.get_event_loop().time()
|
||||
|
||||
# Get chat history length to determine if this is a new conversation
|
||||
tom_inference_response = await get_tom_inference(
|
||||
chat_history,
|
||||
user_representation="",
|
||||
method=settings.AGENT.TOM_INFERENCE_METHOD,
|
||||
)
|
||||
|
||||
# Extract the prediction from the response
|
||||
tom_time = asyncio.get_event_loop().time() - tom_start_time
|
||||
|
||||
logger.debug(f"ToM inference completed in {tom_time:.2f}s")
|
||||
|
||||
# Create a prediction summary from the structured Pydantic object
|
||||
prediction = (
|
||||
f"Current context: {tom_inference_response.current_state.immediate_context}"
|
||||
)
|
||||
if tom_inference_response.tentative_inferences:
|
||||
prediction += f"\nKey inferences: {', '.join([inf.interpretation for inf in tom_inference_response.tentative_inferences[:3]])}"
|
||||
|
||||
logger.debug(f"Prediction length: {len(prediction)} characters")
|
||||
|
||||
return prediction
|
||||
|
||||
|
||||
async def generate_semantic_queries(query: str) -> list[str]:
|
||||
"""
|
||||
Generate multiple semantically relevant queries based on the original query using LLM.
|
||||
This helps retrieve more diverse and relevant facts from the vector store.
|
||||
|
||||
Args:
|
||||
query: The original dialectic query
|
||||
|
||||
Returns:
|
||||
A list of semantically relevant queries
|
||||
"""
|
||||
logger.debug(f"Generating semantic queries from: {query}")
|
||||
query_start = asyncio.get_event_loop().time()
|
||||
|
||||
logger.debug("Calling LLM for query generation")
|
||||
try:
|
||||
queries_result = await generate_semantic_queries_llm(query)
|
||||
queries = queries_result.queries
|
||||
|
||||
# Ensure we always include the original query
|
||||
if query not in queries:
|
||||
logger.debug("Adding original query to results")
|
||||
queries.append(query)
|
||||
|
||||
total_time = asyncio.get_event_loop().time() - query_start
|
||||
logger.debug(f"Generated {len(queries)} queries in {total_time:.2f}s")
|
||||
|
||||
return queries
|
||||
except Exception as e:
|
||||
logger.error(f"Error during query generation: {str(e)}")
|
||||
return [query] # Fallback to original query
|
||||
|
||||
|
||||
async def generate_user_representation(
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
session_name: str | None,
|
||||
chat_history: str,
|
||||
tom_inference: str,
|
||||
facts: list[str],
|
||||
db: AsyncSession,
|
||||
message_id: str | None = None,
|
||||
with_inference: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a user representation by combining long-term facts and short-term context.
|
||||
Save it to peer metadata if no session is provided (global-level), or save it to
|
||||
session-peers table metadata if a session is provided (local-level).
|
||||
If session-level, uses existing representations from the same session for continuity.
|
||||
|
||||
Returns:
|
||||
The generated user representation.
|
||||
"""
|
||||
logger.debug("Starting user representation generation")
|
||||
rep_start_time = asyncio.get_event_loop().time()
|
||||
|
||||
if with_inference:
|
||||
latest_representation = await crud.get_working_representation(
|
||||
db, workspace_name, peer_name, session_name
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Found previous representation: {len(latest_representation)} characters"
|
||||
)
|
||||
logger.debug(f"Using {len(facts)} facts for representation")
|
||||
|
||||
# Generate the new user representation
|
||||
logger.debug("Calling get_user_representation")
|
||||
gen_start_time = asyncio.get_event_loop().time()
|
||||
user_representation_response: UserRepresentationOutput = (
|
||||
await get_user_representation_long_term(
|
||||
chat_history=chat_history,
|
||||
facts=facts,
|
||||
user_representation=latest_representation,
|
||||
tom_inference=tom_inference,
|
||||
)
|
||||
)
|
||||
gen_time = asyncio.get_event_loop().time() - gen_start_time
|
||||
logger.debug(f"get_user_representation completed in {gen_time:.2f}s")
|
||||
|
||||
# Extract the representation from the response
|
||||
if hasattr(user_representation_response, "current_state"):
|
||||
# New Mirascope response model
|
||||
representation = f"""
|
||||
CURRENT STATE: {user_representation_response.current_state}
|
||||
|
||||
TENTATIVE PATTERNS:
|
||||
{chr(10).join([pattern.pattern for pattern in user_representation_response.tentative_patterns])}
|
||||
|
||||
KNOWLEDGE GAPS:
|
||||
{chr(10).join([gap.missing_info for gap in user_representation_response.knowledge_gaps])}
|
||||
|
||||
RECENT UPDATES:
|
||||
{chr(10).join([update.detail for update in user_representation_response.updates.new_information])}
|
||||
"""
|
||||
else:
|
||||
# Fallback to XML parsing for backwards compatibility
|
||||
representation = parse_xml_content(
|
||||
str(user_representation_response), "representation"
|
||||
)
|
||||
logger.debug(f"Extracted representation: {len(representation)} characters")
|
||||
else:
|
||||
representation = f"""
|
||||
PREDICTION ABOUT THE USER'S CURRENT MENTAL STATE:
|
||||
{tom_inference}
|
||||
|
||||
RELEVANT LONG-TERM FACTS ABOUT THE USER:
|
||||
{facts}
|
||||
"""
|
||||
logger.debug(f"Representation: {representation}")
|
||||
# If message_id is provided, save the representation as metadata
|
||||
if not representation:
|
||||
logger.debug("Empty representation, skipping save")
|
||||
elif not message_id:
|
||||
logger.debug("No message_id, skipping save")
|
||||
else:
|
||||
logger.debug(f"Saving representation to message_id: {message_id}")
|
||||
save_start = asyncio.get_event_loop().time()
|
||||
try:
|
||||
await crud.set_working_representation(
|
||||
db,
|
||||
representation,
|
||||
workspace_name,
|
||||
peer_name,
|
||||
session_name,
|
||||
)
|
||||
save_time = asyncio.get_event_loop().time() - save_start
|
||||
logger.debug(f"Representation saved in {save_time:.2f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during save DB operation: {str(e)}")
|
||||
await db.rollback()
|
||||
|
||||
total_time = asyncio.get_event_loop().time() - rep_start_time
|
||||
logger.debug(f"Total representation generation completed in {total_time:.2f}s")
|
||||
return representation
|
||||
108
src/config.py
108
src/config.py
|
|
@ -51,9 +51,9 @@ class TomlConfigSettingsSource(PydanticBaseSettingsSource):
|
|||
"AUTH": "auth",
|
||||
"SENTRY": "sentry",
|
||||
"LLM": "llm",
|
||||
"AGENT": "agent",
|
||||
"DERIVER": "deriver",
|
||||
"HISTORY": "history",
|
||||
"DIALECTIC": "dialectic",
|
||||
"SUMMARY": "summary",
|
||||
"": "app", # For AppSettings with no prefix
|
||||
}
|
||||
|
||||
|
|
@ -174,73 +174,61 @@ class LLMSettings(HonchoSettings):
|
|||
OPENAI_COMPATIBLE_BASE_URL: str | None = None
|
||||
|
||||
# General LLM settings
|
||||
DEFAULT_MAX_TOKENS: Annotated[int, Field(default=1000, gt=0, le=100000)] = 1000
|
||||
DEFAULT_TEMPERATURE: Annotated[float, Field(default=0.0, ge=0.0, le=2.0)] = 0.0
|
||||
|
||||
# Dialectic specific
|
||||
DIALECTIC_PROVIDER: Providers = "anthropic"
|
||||
DIALECTIC_MODEL: str = "claude-3-5-haiku-20241022"
|
||||
# DIALECTIC_SYSTEM_PROMPT_FILE: Optional[str] = "prompts/dialectic_system.txt" # Example for file-based
|
||||
|
||||
# Query Generation specific
|
||||
QUERY_GENERATION_PROVIDER: Providers = "google"
|
||||
QUERY_GENERATION_MODEL: str = "gemini-2.0-flash-lite"
|
||||
# QUERY_GENERATION_SYSTEM_PROMPT_FILE: Optional[str] = "prompts/query_generation_system.txt"
|
||||
|
||||
# Tom Inference specific
|
||||
# TOM_INFERENCE_PROVIDER: Providers = "groq"
|
||||
# TOM_INFERENCE_MODEL: str = "llama-3.3-70b-versatile"
|
||||
TOM_INFERENCE_PROVIDER: Providers = "anthropic"
|
||||
TOM_INFERENCE_MODEL: str = "claude-3-5-haiku-20241022"
|
||||
|
||||
# Summarization specific
|
||||
SUMMARY_PROVIDER: Providers = "google"
|
||||
SUMMARY_MODEL: str = (
|
||||
"gemini-1.5-flash-latest" # Consider specific model version if needed
|
||||
)
|
||||
SUMMARY_MAX_TOKENS_SHORT: Annotated[int, Field(default=1000, gt=0, le=10000)] = 1000
|
||||
SUMMARY_MAX_TOKENS_LONG: Annotated[int, Field(default=2000, gt=0, le=20000)] = 2000
|
||||
# SUMMARY_SYSTEM_PROMPT_SHORT_FILE: Optional[str] = "prompts/summary_short_system.txt"
|
||||
# SUMMARY_SYSTEM_PROMPT_LONG_FILE: Optional[str] = "prompts/summary_long_system.txt"
|
||||
|
||||
# Embed all messages that are sent by peers
|
||||
EMBED_MESSAGES: bool = False
|
||||
MAX_EMBEDDING_TOKENS: Annotated[int, Field(default=8192, gt=0)] = 8192
|
||||
MAX_EMBEDDING_TOKENS_PER_REQUEST: Annotated[int, Field(default=300000, gt=0)] = (
|
||||
300000
|
||||
)
|
||||
|
||||
|
||||
class AgentSettings(HonchoSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="AGENT_") # pyright: ignore
|
||||
|
||||
SEMANTIC_SEARCH_TOP_K: Annotated[int, Field(default=10, gt=0, le=100)] = 10
|
||||
SEMANTIC_SEARCH_MAX_DISTANCE: Annotated[
|
||||
float, Field(default=0.85, ge=0.0, le=1.0)
|
||||
] = 0.85 # Max distance for semantic search relevance
|
||||
TOM_INFERENCE_METHOD: str = "single_prompt"
|
||||
DEFAULT_MAX_TOKENS: Annotated[int, Field(default=1000, gt=0, le=100000)] = 2500
|
||||
|
||||
|
||||
class DeriverSettings(HonchoSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="DERIVER_") # pyright: ignore
|
||||
|
||||
WORKERS: Annotated[int, Field(default=1, gt=0, le=100)] = 1
|
||||
STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = (
|
||||
5 # Max 24 hours
|
||||
)
|
||||
POLLING_SLEEP_INTERVAL_SECONDS: Annotated[
|
||||
float, Field(default=1.0, gt=0.0, le=60.0)
|
||||
] = 1.0
|
||||
TOM_METHOD: str = "single_prompt"
|
||||
USER_REPRESENTATION_METHOD: str = "long_term"
|
||||
STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = 5
|
||||
|
||||
PROVIDER: Providers = "google"
|
||||
MODEL: str = "gemini-2.5-flash"
|
||||
|
||||
MAX_OUTPUT_TOKENS: Annotated[int, Field(default=2500, gt=0, le=100000)] = 2500
|
||||
# Thinking budget tokens are only applied when using Anthropic as provider
|
||||
THINKING_BUDGET_TOKENS: Annotated[int, Field(default=1024, gt=0, le=5000)] = 1024
|
||||
|
||||
# Default number of observations to retrieve for each reasoning level
|
||||
DEDUCTIVE_OBSERVATIONS_COUNT: Annotated[int, Field(default=6, gt=0, le=50)] = 6
|
||||
EXPLICIT_OBSERVATIONS_COUNT: Annotated[int, Field(default=10, gt=0, le=50)] = 10
|
||||
|
||||
|
||||
class HistorySettings(HonchoSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="HISTORY_") # pyright: ignore
|
||||
class DialecticSettings(HonchoSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="DIALECTIC_") # pyright: ignore
|
||||
|
||||
PROVIDER: Providers = "anthropic"
|
||||
MODEL: str = "claude-sonnet-4-20250514"
|
||||
QUERY_GENERATION_PROVIDER: Providers = "groq"
|
||||
QUERY_GENERATION_MODEL: str = "llama-3.1-8b-instant"
|
||||
|
||||
MAX_OUTPUT_TOKENS: Annotated[int, Field(default=2500, gt=0, le=100000)] = 2500
|
||||
|
||||
SEMANTIC_SEARCH_TOP_K: Annotated[int, Field(default=10, gt=0, le=100)] = 10
|
||||
SEMANTIC_SEARCH_MAX_DISTANCE: Annotated[
|
||||
float, Field(default=0.85, ge=0.0, le=1.0)
|
||||
] = 0.85 # Max distance for semantic search relevance
|
||||
|
||||
THINKING_BUDGET_TOKENS: Annotated[int, Field(default=1024, gt=0, le=5000)] = 1024
|
||||
|
||||
|
||||
class SummarySettings(HonchoSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="SUMMARY_") # pyright: ignore
|
||||
|
||||
MESSAGES_PER_SHORT_SUMMARY: Annotated[int, Field(default=20, gt=0, le=100)] = 20
|
||||
MESSAGES_PER_LONG_SUMMARY: Annotated[int, Field(default=60, gt=0, le=500)] = 60
|
||||
|
||||
PROVIDER: Providers = "google"
|
||||
MODEL: str = "gemini-2.5-flash"
|
||||
MAX_TOKENS_SHORT: Annotated[int, Field(default=1000, gt=0, le=10000)] = 1000
|
||||
MAX_TOKENS_LONG: Annotated[int, Field(default=2000, gt=0, le=20000)] = 2000
|
||||
|
||||
THINKING_BUDGET_TOKENS: Annotated[int, Field(default=512, gt=0, le=2000)] = 512
|
||||
|
||||
|
||||
class AppSettings(HonchoSettings):
|
||||
# No env_prefix for app-level settings
|
||||
|
|
@ -253,18 +241,24 @@ class AppSettings(HonchoSettings):
|
|||
FASTAPI_HOST: str = "0.0.0.0"
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# Nested settings models
|
||||
DB: DBSettings = Field(default_factory=DBSettings)
|
||||
AUTH: AuthSettings = Field(default_factory=AuthSettings)
|
||||
SENTRY: SentrySettings = Field(default_factory=SentrySettings)
|
||||
LLM: LLMSettings = Field(default_factory=LLMSettings)
|
||||
AGENT: AgentSettings = Field(default_factory=AgentSettings)
|
||||
DERIVER: DeriverSettings = Field(default_factory=DeriverSettings)
|
||||
HISTORY: HistorySettings = Field(default_factory=HistorySettings)
|
||||
DIALECTIC: DialecticSettings = Field(default_factory=DialecticSettings)
|
||||
SUMMARY: SummarySettings = Field(default_factory=SummarySettings)
|
||||
|
||||
@field_validator("LOG_LEVEL")
|
||||
@classmethod
|
||||
def validate_log_level(cls, v: str) -> str:
|
||||
log_level = v.upper()
|
||||
if log_level not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
|
|
|
|||
2000
src/crud.py
2000
src/crud.py
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,87 @@
|
|||
from .collection import get_collection, get_or_create_collection
|
||||
from .deriver import get_deriver_status
|
||||
from .document import create_document, get_duplicate_documents, query_documents
|
||||
from .message import (
|
||||
create_messages,
|
||||
get_message,
|
||||
get_messages,
|
||||
get_messages_id_range,
|
||||
search,
|
||||
update_message,
|
||||
)
|
||||
from .peer import (
|
||||
get_or_create_peers,
|
||||
get_peer,
|
||||
get_peers,
|
||||
get_sessions_for_peer,
|
||||
update_peer,
|
||||
)
|
||||
from .representation import (
|
||||
construct_collection_name,
|
||||
get_working_representation,
|
||||
get_working_representation_data,
|
||||
set_working_representation,
|
||||
)
|
||||
from .session import (
|
||||
clone_session,
|
||||
delete_session,
|
||||
get_or_create_session,
|
||||
get_peer_config,
|
||||
get_peers_from_session,
|
||||
get_session,
|
||||
get_session_peer_configuration,
|
||||
get_sessions,
|
||||
remove_peers_from_session,
|
||||
set_peer_config,
|
||||
set_peers_for_session,
|
||||
update_session,
|
||||
)
|
||||
from .workspace import get_all_workspaces, get_or_create_workspace, update_workspace
|
||||
|
||||
__all__ = [
|
||||
# Collection
|
||||
"get_collection",
|
||||
"get_or_create_collection",
|
||||
# Deriver
|
||||
"get_deriver_status",
|
||||
# Document
|
||||
"query_documents",
|
||||
"create_document",
|
||||
"get_duplicate_documents",
|
||||
# Message
|
||||
"create_messages",
|
||||
"get_messages",
|
||||
"get_messages_id_range",
|
||||
"get_message",
|
||||
"update_message",
|
||||
"search",
|
||||
# Peer
|
||||
"get_or_create_peers",
|
||||
"get_peer",
|
||||
"get_peers",
|
||||
"update_peer",
|
||||
"get_sessions_for_peer",
|
||||
# Search
|
||||
"representation",
|
||||
"get_working_representation",
|
||||
"get_working_representation_data",
|
||||
"set_working_representation",
|
||||
"construct_collection_name",
|
||||
# Session
|
||||
"get_sessions",
|
||||
"get_or_create_session",
|
||||
"get_session",
|
||||
"update_session",
|
||||
"delete_session",
|
||||
"clone_session",
|
||||
"remove_peers_from_session",
|
||||
"get_peers_from_session",
|
||||
"get_session_peer_configuration",
|
||||
"set_peers_for_session",
|
||||
"get_peer_config",
|
||||
"set_peer_config",
|
||||
# Workspace
|
||||
"get_or_create_workspace",
|
||||
"get_all_workspaces",
|
||||
"update_workspace",
|
||||
]
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
from logging import getLogger
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models
|
||||
from src.exceptions import ResourceNotFoundException
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
async def get_collection(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
collection_name: str,
|
||||
peer_name: str | None = None,
|
||||
) -> models.Collection:
|
||||
"""
|
||||
Get a collection by name for a specific peer and workspace.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
peer_name: Name of the peer
|
||||
collection_name: Name of the collection
|
||||
|
||||
Returns:
|
||||
The collection if found
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the collection does not exist
|
||||
"""
|
||||
stmt = (
|
||||
select(models.Collection)
|
||||
.where(models.Collection.workspace_name == workspace_name)
|
||||
.where(models.Collection.name == collection_name)
|
||||
)
|
||||
if peer_name:
|
||||
stmt = stmt.where(models.Collection.peer_name == peer_name)
|
||||
result = await db.execute(stmt)
|
||||
collection = result.scalar_one_or_none()
|
||||
if collection is None:
|
||||
raise ResourceNotFoundException(
|
||||
"Collection not found or does not belong to peer"
|
||||
)
|
||||
return collection
|
||||
|
||||
|
||||
async def get_or_create_collection(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
collection_name: str,
|
||||
peer_name: str | None = None,
|
||||
) -> models.Collection:
|
||||
try:
|
||||
return await get_collection(db, workspace_name, collection_name, peer_name)
|
||||
except ResourceNotFoundException:
|
||||
honcho_collection = models.Collection(
|
||||
workspace_name=workspace_name,
|
||||
peer_name=peer_name,
|
||||
name=collection_name,
|
||||
)
|
||||
db.add(honcho_collection)
|
||||
await db.commit()
|
||||
return honcho_collection
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
from collections.abc import Sequence
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, case, func, or_, select
|
||||
from sqlalchemy.engine import Row
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models, schemas
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
async def get_deriver_status(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
observer_name: str | None = None,
|
||||
sender_name: str | None = None,
|
||||
session_name: str | None = None,
|
||||
) -> schemas.DeriverStatus:
|
||||
"""
|
||||
Get the deriver processing status, optionally filtered by observer, sender, and/or session.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
observer_name: Optional name of the observer (target) to filter by
|
||||
sender_name: Optional name of the sender to filter by
|
||||
session_name: Optional session name to filter by
|
||||
"""
|
||||
# Normalize empty strings to None for consistent handling
|
||||
normalized_observer_name = observer_name if observer_name else None
|
||||
normalized_sender_name = sender_name if sender_name else None
|
||||
normalized_session_name = session_name if session_name else None
|
||||
|
||||
stmt = _build_queue_status_query(
|
||||
workspace_name,
|
||||
normalized_observer_name,
|
||||
normalized_sender_name,
|
||||
normalized_session_name,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.fetchall()
|
||||
|
||||
counts = _process_queue_rows(rows)
|
||||
return _build_status_response(
|
||||
normalized_session_name,
|
||||
counts,
|
||||
)
|
||||
|
||||
|
||||
def _build_queue_status_query(
|
||||
workspace_name: str,
|
||||
observer_name: str | None,
|
||||
sender_name: str | None,
|
||||
session_name: str | None,
|
||||
) -> Select[Any]:
|
||||
"""Build SQL query for queue status with validation and aggregation."""
|
||||
sender_name_expr = models.QueueItem.payload["sender_name"].astext
|
||||
target_name_expr = models.QueueItem.payload["target_name"].astext
|
||||
task_type_expr = models.QueueItem.payload["task_type"].astext
|
||||
|
||||
# Define conditions for cleaner window functions
|
||||
is_completed = models.QueueItem.processed
|
||||
is_in_progress = (~models.QueueItem.processed) & (
|
||||
models.ActiveQueueSession.id.isnot(None)
|
||||
)
|
||||
is_pending = (~models.QueueItem.processed) & (
|
||||
models.ActiveQueueSession.id.is_(None)
|
||||
)
|
||||
|
||||
# Use window functions to calculate totals and per-session counts in SQL
|
||||
stmt = select(
|
||||
models.QueueItem.session_id,
|
||||
# Overall totals using window functions
|
||||
func.count().over().label("total"),
|
||||
func.count(case((is_completed, 1))).over().label("completed"),
|
||||
func.count(case((is_in_progress, 1))).over().label("in_progress"),
|
||||
func.count(case((is_pending, 1))).over().label("pending"),
|
||||
# Per-session totals using partitioned window functions
|
||||
func.count()
|
||||
.over(partition_by=models.QueueItem.session_id)
|
||||
.label("session_total"),
|
||||
func.count(case((is_completed, 1)))
|
||||
.over(partition_by=models.QueueItem.session_id)
|
||||
.label("session_completed"),
|
||||
func.count(case((is_in_progress, 1)))
|
||||
.over(partition_by=models.QueueItem.session_id)
|
||||
.label("session_in_progress"),
|
||||
func.count(case((is_pending, 1)))
|
||||
.over(partition_by=models.QueueItem.session_id)
|
||||
.label("session_pending"),
|
||||
).select_from(models.QueueItem)
|
||||
|
||||
stmt = stmt.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)
|
||||
& (task_type_expr == models.ActiveQueueSession.task_type),
|
||||
)
|
||||
|
||||
stmt = stmt.join(models.Session, models.QueueItem.session_id == models.Session.id)
|
||||
stmt = stmt.where(models.Session.workspace_name == workspace_name)
|
||||
|
||||
if session_name is not None:
|
||||
stmt = stmt.where(models.Session.name == session_name)
|
||||
|
||||
peer_conditions = []
|
||||
if observer_name is not None:
|
||||
peer_conditions.append(target_name_expr == observer_name) # pyright: ignore
|
||||
if sender_name is not None:
|
||||
peer_conditions.append(sender_name_expr == sender_name) # pyright: ignore
|
||||
if peer_conditions:
|
||||
stmt = stmt.where(or_(*peer_conditions)) # pyright: ignore
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
def _process_queue_rows(rows: Sequence[Row[Any]]) -> schemas.QueueCounts:
|
||||
"""Process query results that already contain aggregated counts."""
|
||||
if not rows:
|
||||
return schemas.QueueCounts(
|
||||
total=0,
|
||||
completed=0,
|
||||
in_progress=0,
|
||||
pending=0,
|
||||
sessions={},
|
||||
)
|
||||
|
||||
# Since we're using window functions, all rows have the same overall totals
|
||||
# We just need the first row for overall counts
|
||||
first_row = rows[0]
|
||||
|
||||
# Build sessions dictionary from unique session_ids
|
||||
sessions: dict[str, schemas.SessionCounts] = {}
|
||||
seen_sessions: set[str] = set()
|
||||
|
||||
for row in rows:
|
||||
if row.session_id and row.session_id not in seen_sessions:
|
||||
sessions[row.session_id] = schemas.SessionCounts(
|
||||
completed=row.session_completed,
|
||||
in_progress=row.session_in_progress,
|
||||
pending=row.session_pending,
|
||||
)
|
||||
seen_sessions.add(row.session_id)
|
||||
|
||||
return schemas.QueueCounts(
|
||||
total=first_row.total,
|
||||
completed=first_row.completed,
|
||||
in_progress=first_row.in_progress,
|
||||
pending=first_row.pending,
|
||||
sessions=sessions,
|
||||
)
|
||||
|
||||
|
||||
def _build_status_response(
|
||||
session_name: str | None,
|
||||
counts: schemas.QueueCounts,
|
||||
) -> schemas.DeriverStatus:
|
||||
"""Build the final response object."""
|
||||
|
||||
if session_name:
|
||||
return schemas.DeriverStatus(
|
||||
total_work_units=counts.total,
|
||||
completed_work_units=counts.completed,
|
||||
in_progress_work_units=counts.in_progress,
|
||||
pending_work_units=counts.pending,
|
||||
)
|
||||
|
||||
sessions: dict[str, schemas.SessionDeriverStatus] = {}
|
||||
for session_id, data in counts.sessions.items():
|
||||
total = data.completed + data.in_progress + data.pending
|
||||
sessions[session_id] = schemas.SessionDeriverStatus(
|
||||
session_id=session_id,
|
||||
total_work_units=total,
|
||||
completed_work_units=data.completed,
|
||||
in_progress_work_units=data.in_progress,
|
||||
pending_work_units=data.pending,
|
||||
)
|
||||
|
||||
return schemas.DeriverStatus(
|
||||
sessions=sessions if sessions else None,
|
||||
total_work_units=counts.total,
|
||||
completed_work_units=counts.completed,
|
||||
in_progress_work_units=counts.in_progress,
|
||||
pending_work_units=counts.pending,
|
||||
)
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
from collections.abc import Sequence
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models, schemas
|
||||
from src.config import settings
|
||||
from src.embedding_client import embedding_client
|
||||
from src.exceptions import ValidationException
|
||||
from src.utils.filter import apply_filter
|
||||
|
||||
from .collection import get_collection
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
async def query_documents(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
collection_name: str,
|
||||
query: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
max_distance: float | None = None,
|
||||
top_k: int = 5,
|
||||
) -> 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
|
||||
|
||||
stmt = (
|
||||
select(models.Document)
|
||||
.where(models.Document.workspace_name == workspace_name)
|
||||
.where(models.Document.peer_name == peer_name)
|
||||
.where(models.Document.collection_name == collection_name)
|
||||
# .limit(top_k)
|
||||
)
|
||||
if max_distance is not None:
|
||||
stmt = stmt.where(
|
||||
models.Document.embedding.cosine_distance(embedding_query) < max_distance
|
||||
)
|
||||
stmt = apply_filter(stmt, models.Document, filters)
|
||||
stmt = stmt.limit(top_k).order_by(
|
||||
models.Document.embedding.cosine_distance(embedding_query)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def create_document(
|
||||
db: AsyncSession,
|
||||
document: schemas.DocumentCreate,
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
collection_name: str,
|
||||
duplicate_threshold: float | None = None,
|
||||
) -> models.Document:
|
||||
"""
|
||||
Embed text as a vector and create a document.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
document: Document creation schema
|
||||
workspace_name: Name of the workspace
|
||||
peer_name: Name of the peer
|
||||
collection_name: Name of the collection
|
||||
|
||||
Returns:
|
||||
The created document
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the collection does not exist
|
||||
ValidationException: If the document data is invalid
|
||||
"""
|
||||
|
||||
# This will raise ResourceNotFoundException if collection not found
|
||||
await get_collection(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
collection_name=collection_name,
|
||||
peer_name=peer_name,
|
||||
)
|
||||
|
||||
# Using ModelClient for embeddings
|
||||
embedding = await embedding_client.embed(document.content)
|
||||
|
||||
if duplicate_threshold is not None:
|
||||
# Check if there are duplicates within the threshold
|
||||
stmt = (
|
||||
select(models.Document)
|
||||
.where(models.Document.workspace_name == workspace_name)
|
||||
.where(models.Document.peer_name == peer_name)
|
||||
.where(models.Document.collection_name == collection_name)
|
||||
.where(
|
||||
models.Document.embedding.cosine_distance(embedding)
|
||||
< duplicate_threshold
|
||||
)
|
||||
.order_by(models.Document.embedding.cosine_distance(embedding))
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
duplicate = result.scalar_one_or_none() # Get the closest match if any exist
|
||||
if duplicate is not None:
|
||||
logger.info(f"Duplicate found: {duplicate.content}. Ignoring new document.")
|
||||
return duplicate
|
||||
|
||||
honcho_document = models.Document(
|
||||
workspace_name=workspace_name,
|
||||
peer_name=peer_name,
|
||||
collection_name=collection_name,
|
||||
content=document.content,
|
||||
internal_metadata=document.metadata,
|
||||
embedding=embedding,
|
||||
)
|
||||
db.add(honcho_document)
|
||||
await db.commit()
|
||||
await db.refresh(honcho_document)
|
||||
return honcho_document
|
||||
|
||||
|
||||
async def get_duplicate_documents(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
collection_name: str,
|
||||
content: str,
|
||||
similarity_threshold: float = 0.85,
|
||||
) -> list[models.Document]:
|
||||
"""Check if a document with similar content already exists in the collection.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
peer_name: Name of the peer
|
||||
collection_name: Name of the collection
|
||||
content: Document content to check for duplicates
|
||||
similarity_threshold: Similarity threshold (0-1) for considering documents as duplicates
|
||||
|
||||
Returns:
|
||||
List of documents that are similar to the provided content
|
||||
"""
|
||||
# Get embedding for the content
|
||||
# Using ModelClient for embeddings
|
||||
embedding = await embedding_client.embed(content)
|
||||
|
||||
# Find documents with similar embeddings
|
||||
stmt = (
|
||||
select(models.Document)
|
||||
.where(models.Document.workspace_name == workspace_name)
|
||||
.where(models.Document.peer_name == peer_name)
|
||||
.where(models.Document.collection_name == collection_name)
|
||||
.where(
|
||||
models.Document.embedding.cosine_distance(embedding)
|
||||
< (1 - similarity_threshold)
|
||||
) # Convert similarity to distance
|
||||
.order_by(models.Document.embedding.cosine_distance(embedding))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all()) # Convert to list to match the return type
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models, schemas
|
||||
from src.config import settings
|
||||
from src.embedding_client import embedding_client
|
||||
from src.exceptions import DisabledException, ValidationException
|
||||
from src.utils.filter import apply_filter
|
||||
|
||||
from .session import get_or_create_session
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
async def create_messages(
|
||||
db: AsyncSession,
|
||||
messages: list[schemas.MessageCreate],
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
) -> list[models.Message]:
|
||||
"""
|
||||
Bulk create messages for a session while maintaining order.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
messages: List of messages to create
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session to create messages in
|
||||
|
||||
Returns:
|
||||
List of created message objects
|
||||
"""
|
||||
# Get or create session with peers in messages list
|
||||
peers = {message.peer_name: schemas.SessionPeerConfig() for message in messages}
|
||||
await get_or_create_session(
|
||||
db,
|
||||
session=schemas.SessionCreate(
|
||||
name=session_name,
|
||||
peers=peers,
|
||||
),
|
||||
workspace_name=workspace_name,
|
||||
)
|
||||
|
||||
# Create list of message objects (this will trigger the before_insert event)
|
||||
message_objects: list[models.Message] = []
|
||||
for message in messages:
|
||||
message_obj = models.Message(
|
||||
session_name=session_name,
|
||||
peer_name=message.peer_name,
|
||||
content=message.content,
|
||||
h_metadata=message.metadata or {},
|
||||
workspace_name=workspace_name,
|
||||
public_id=generate_nanoid(),
|
||||
token_count=len(message.encoded_message),
|
||||
)
|
||||
message_objects.append(message_obj)
|
||||
|
||||
db.add_all(message_objects)
|
||||
await db.flush()
|
||||
|
||||
if settings.EMBED_MESSAGES:
|
||||
encoded_message_lookup = {
|
||||
msg.public_id: orig_msg.encoded_message
|
||||
for msg, orig_msg in zip(message_objects, messages, strict=True)
|
||||
}
|
||||
id_resource_dict = {
|
||||
message.public_id: (
|
||||
message.content,
|
||||
encoded_message_lookup[message.public_id],
|
||||
)
|
||||
for message in message_objects
|
||||
}
|
||||
embedding_dict = await embedding_client.batch_embed(id_resource_dict)
|
||||
|
||||
# Create MessageEmbedding entries for each embedded message
|
||||
embedding_objects: list[models.MessageEmbedding] = []
|
||||
for message_obj in message_objects:
|
||||
embeddings = embedding_dict.get(message_obj.public_id, [])
|
||||
for embedding in embeddings:
|
||||
embedding_obj = models.MessageEmbedding(
|
||||
content=message_obj.content,
|
||||
embedding=embedding,
|
||||
message_id=message_obj.public_id,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
peer_name=message_obj.peer_name,
|
||||
)
|
||||
embedding_objects.append(embedding_obj)
|
||||
|
||||
# Add all embedding objects to the session
|
||||
if embedding_objects:
|
||||
db.add_all(embedding_objects)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return message_objects
|
||||
|
||||
|
||||
async def get_messages(
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
reverse: bool | None = False,
|
||||
filters: dict[str, Any] | None = None,
|
||||
token_limit: int | None = None,
|
||||
message_count_limit: int | None = None,
|
||||
) -> Select[tuple[models.Message]]:
|
||||
"""
|
||||
Get messages from a session. If token_limit is provided, the n most recent messages
|
||||
with token count adding up to the limit will be returned. If message_count_limit is provided,
|
||||
the n most recent messages will be returned. If both are provided, message_count_limit will be
|
||||
used.
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
reverse: Whether to reverse the order of messages
|
||||
filters: Filter to apply to the messages
|
||||
token_limit: Maximum number of tokens to include in the messages
|
||||
message_count_limit: Maximum number of messages to include
|
||||
|
||||
Returns:
|
||||
Select statement for the messages
|
||||
"""
|
||||
# Base query with workspace and session filters
|
||||
base_conditions = [
|
||||
models.Message.workspace_name == workspace_name,
|
||||
models.Message.session_name == session_name,
|
||||
]
|
||||
|
||||
# Apply message count limit first (takes precedence over token limit)
|
||||
if message_count_limit is not None:
|
||||
stmt = select(models.Message).where(*base_conditions)
|
||||
stmt = apply_filter(stmt, models.Message, filters)
|
||||
# For message count limit, we want the most recent N messages
|
||||
# So we order by id desc to get most recent, then apply limit
|
||||
stmt = stmt.order_by(models.Message.id.desc()).limit(message_count_limit)
|
||||
|
||||
# Apply final ordering based on reverse parameter
|
||||
if reverse:
|
||||
stmt = stmt.order_by(models.Message.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(models.Message.id.asc())
|
||||
elif token_limit is not None:
|
||||
# Apply token limit logic
|
||||
# Create a subquery that calculates running sum of tokens for most recent messages
|
||||
token_subquery = (
|
||||
select(
|
||||
models.Message.id,
|
||||
func.sum(models.Message.token_count)
|
||||
.over(order_by=models.Message.id.desc())
|
||||
.label("running_token_sum"),
|
||||
)
|
||||
.where(*base_conditions)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Select Message objects where running sum doesn't exceed token_limit
|
||||
stmt = (
|
||||
select(models.Message)
|
||||
.join(token_subquery, models.Message.id == token_subquery.c.id)
|
||||
.where(token_subquery.c.running_token_sum <= token_limit)
|
||||
)
|
||||
stmt = apply_filter(stmt, models.Message, filters)
|
||||
|
||||
# Apply final ordering based on reverse parameter
|
||||
if reverse:
|
||||
stmt = stmt.order_by(models.Message.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(models.Message.id.asc())
|
||||
else:
|
||||
# Default case - no limits applied
|
||||
stmt = select(models.Message).where(*base_conditions)
|
||||
stmt = apply_filter(stmt, models.Message, filters)
|
||||
if reverse:
|
||||
stmt = stmt.order_by(models.Message.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(models.Message.id.asc())
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
async def get_messages_id_range(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
peer_name: str | None,
|
||||
start_id: int = 0,
|
||||
end_id: int | None = None,
|
||||
) -> list[models.Message]:
|
||||
"""
|
||||
Get messages from a session or peer 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.
|
||||
|
||||
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)):
|
||||
return []
|
||||
stmt = select(models.Message).where(
|
||||
models.Message.workspace_name == workspace_name,
|
||||
)
|
||||
if end_id:
|
||||
stmt = stmt.where(models.Message.id.between(start_id, end_id - 1))
|
||||
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(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
message_id: str,
|
||||
) -> models.Message | None:
|
||||
stmt = (
|
||||
select(models.Message)
|
||||
.where(models.Message.workspace_name == workspace_name)
|
||||
.where(models.Message.session_name == session_name)
|
||||
.where(models.Message.public_id == message_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_message(
|
||||
db: AsyncSession,
|
||||
message: schemas.MessageUpdate,
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
message_id: str,
|
||||
) -> bool:
|
||||
honcho_message = await get_message(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
message_id=message_id,
|
||||
)
|
||||
if honcho_message is None:
|
||||
raise ValueError("Message not found or does not belong to user")
|
||||
if (
|
||||
message.metadata is not None
|
||||
): # Need to explicitly be there won't make it empty by default
|
||||
honcho_message.h_metadata = message.metadata
|
||||
await db.commit()
|
||||
# await db.refresh(honcho_message)
|
||||
return honcho_message
|
||||
|
||||
|
||||
async def search(
|
||||
query: str,
|
||||
*,
|
||||
workspace_name: str,
|
||||
session_name: str | None = None,
|
||||
peer_name: str | None = None,
|
||||
semantic: bool | None = None,
|
||||
) -> Select[tuple[models.Message]]:
|
||||
"""
|
||||
Search across message content using a hybrid approach:
|
||||
- Uses semantic search if embed_messages is set, else fall back to full text
|
||||
- Uses PostgreSQL full text search for natural language queries
|
||||
- Falls back to exact string matching for queries with special characters
|
||||
- Optionally uses semantic search with embeddings
|
||||
|
||||
If a session or peer is provided, the search will be scoped to that
|
||||
session or peer. Otherwise, it will search across all messages in the workspace.
|
||||
|
||||
Args:
|
||||
query: Search query to match against message content
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Optional name of the session
|
||||
peer_name: Optional name of the peer
|
||||
semantic: Optional boolean to configure semantic search:
|
||||
- None: try semantic search if embed_messages is set, else fall back to full text
|
||||
- True: try semantic search if embed_messages is set, else throw error
|
||||
- False: use full text search
|
||||
|
||||
Returns:
|
||||
List of messages that match the search query, ordered by relevance
|
||||
"""
|
||||
import re
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
# Base query conditions
|
||||
base_conditions = [models.Message.workspace_name == workspace_name]
|
||||
|
||||
should_use_semantic_search = False # Default to full text search
|
||||
|
||||
if semantic is None:
|
||||
# Try semantic search if embed_messages is set, else fall back to full text
|
||||
should_use_semantic_search = settings.EMBED_MESSAGES
|
||||
elif semantic is True:
|
||||
# Try semantic search if embed_messages is set, else throw error
|
||||
if settings.EMBED_MESSAGES:
|
||||
should_use_semantic_search = True
|
||||
else:
|
||||
raise DisabledException(
|
||||
"Semantic search requires EMBED_MESSAGES flag to be enabled"
|
||||
)
|
||||
|
||||
if should_use_semantic_search:
|
||||
# Generate embedding for the search query
|
||||
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
|
||||
|
||||
# Use cosine distance for semantic search on MessageEmbedding table
|
||||
# Join with Message table to get the actual message data
|
||||
base_query = (
|
||||
select(models.Message)
|
||||
.join(
|
||||
models.MessageEmbedding,
|
||||
models.Message.public_id == models.MessageEmbedding.message_id,
|
||||
)
|
||||
.where(models.MessageEmbedding.workspace_name == workspace_name)
|
||||
.order_by(
|
||||
models.MessageEmbedding.embedding.cosine_distance(embedding_query)
|
||||
)
|
||||
)
|
||||
|
||||
if session_name is not None:
|
||||
stmt = base_query.where(
|
||||
models.MessageEmbedding.session_name == session_name
|
||||
)
|
||||
elif peer_name is not None:
|
||||
stmt = base_query.where(models.MessageEmbedding.peer_name == peer_name)
|
||||
else:
|
||||
stmt = base_query
|
||||
|
||||
else:
|
||||
# Check if query contains special characters that FTS might not handle well
|
||||
has_special_chars = bool(
|
||||
re.search(r'[~`!@#$%^&*()_+=\[\]{};\':"\\|,.<>/?-]', query)
|
||||
)
|
||||
|
||||
if has_special_chars:
|
||||
# For queries with special characters, use exact string matching (ILIKE)
|
||||
# This ensures we can find exact matches like "~special-uuid~"
|
||||
search_condition = models.Message.content.ilike(f"%{query}%")
|
||||
|
||||
base_query = (
|
||||
select(models.Message)
|
||||
.where(*base_conditions, search_condition)
|
||||
.order_by(models.Message.created_at.desc())
|
||||
)
|
||||
else:
|
||||
# For natural language queries, use full text search with ranking
|
||||
fts_condition = func.to_tsvector("english", models.Message.content).op(
|
||||
"@@"
|
||||
)(func.plainto_tsquery("english", query))
|
||||
|
||||
# Combine FTS with ILIKE as fallback for better coverage
|
||||
combined_condition = or_(
|
||||
fts_condition, models.Message.content.ilike(f"%{query}%")
|
||||
)
|
||||
|
||||
base_query = (
|
||||
select(models.Message)
|
||||
.where(*base_conditions, combined_condition)
|
||||
.order_by(
|
||||
# Order by FTS relevance first, then by creation time
|
||||
func.coalesce(
|
||||
func.ts_rank(
|
||||
func.to_tsvector("english", models.Message.content),
|
||||
func.plainto_tsquery("english", query),
|
||||
),
|
||||
0,
|
||||
).desc(),
|
||||
models.Message.created_at.desc(),
|
||||
)
|
||||
)
|
||||
|
||||
# Add additional filters based on parameters
|
||||
if session_name is not None:
|
||||
stmt = base_query.where(models.Message.session_name == session_name)
|
||||
elif peer_name is not None:
|
||||
stmt = base_query.where(models.Message.peer_name == peer_name)
|
||||
else:
|
||||
stmt = base_query
|
||||
|
||||
return stmt
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models, schemas
|
||||
from src.exceptions import ResourceNotFoundException
|
||||
from src.utils.filter import apply_filter
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
async def get_or_create_peers(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
peers: list[schemas.PeerCreate],
|
||||
) -> list[models.Peer]:
|
||||
"""
|
||||
Get an existing list of peers or create new peers if they don't exist.
|
||||
Updates existing peers with metadata and configuration if provided.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
peers: List of peer creation schemas
|
||||
|
||||
Returns:
|
||||
List of peers if found or created
|
||||
"""
|
||||
peer_names = [p.name for p in peers]
|
||||
stmt = (
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name.in_(peer_names))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_peers = list(result.scalars().all())
|
||||
|
||||
# Create a mapping of peer names to peer schemas for easy lookup
|
||||
peer_schema_map = {p.name: p for p in peers}
|
||||
|
||||
# Update existing peers with metadata and configuration if provided
|
||||
for existing_peer in existing_peers:
|
||||
peer_schema = peer_schema_map[existing_peer.name]
|
||||
|
||||
# Update with metadata and configuration if provided
|
||||
if peer_schema.metadata is not None:
|
||||
existing_peer.h_metadata = peer_schema.metadata
|
||||
|
||||
if peer_schema.configuration is not None:
|
||||
existing_peer.configuration = peer_schema.configuration
|
||||
|
||||
# Find which peers need to be created
|
||||
existing_names = {p.name for p in existing_peers}
|
||||
peers_to_create = [p for p in peers if p.name not in existing_names]
|
||||
|
||||
# Create new peers
|
||||
new_peers = [
|
||||
models.Peer(
|
||||
workspace_name=workspace_name,
|
||||
name=p.name,
|
||||
h_metadata=p.metadata or {},
|
||||
configuration=p.configuration or {},
|
||||
)
|
||||
for p in peers_to_create
|
||||
]
|
||||
db.add_all(new_peers)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Return combined list of existing and new peers
|
||||
return existing_peers + new_peers
|
||||
|
||||
|
||||
async def get_peer(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
peer: schemas.PeerCreate,
|
||||
) -> models.Peer:
|
||||
"""
|
||||
Get an existing peer.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
peer: Peer creation schema
|
||||
|
||||
Returns:
|
||||
The peer if found or created
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the peer does not exist
|
||||
"""
|
||||
# Try to get the existing peer
|
||||
stmt = (
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == peer.name)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_peer = result.scalar_one_or_none()
|
||||
|
||||
if existing_peer is not None:
|
||||
return existing_peer
|
||||
|
||||
raise ResourceNotFoundException(
|
||||
f"Peer {peer.name} not found in workspace {workspace_name}"
|
||||
)
|
||||
|
||||
|
||||
async def get_peers(
|
||||
workspace_name: str,
|
||||
filters: dict[str, str] | None = None,
|
||||
) -> Select[tuple[models.Peer]]:
|
||||
stmt = select(models.Peer).where(models.Peer.workspace_name == workspace_name)
|
||||
|
||||
stmt = apply_filter(stmt, models.Peer, filters)
|
||||
|
||||
return stmt.order_by(models.Peer.created_at)
|
||||
|
||||
|
||||
async def update_peer(
|
||||
db: AsyncSession, workspace_name: str, peer_name: str, peer: schemas.PeerUpdate
|
||||
) -> models.Peer:
|
||||
"""
|
||||
Update a peer.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
peer_name: Name of the peer
|
||||
peer: Peer update schema
|
||||
|
||||
Returns:
|
||||
The updated peer
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the peer does not exist
|
||||
ValidationException: If the update data is invalid
|
||||
ConflictException: If the update violates a unique constraint
|
||||
"""
|
||||
honcho_peer = (
|
||||
await get_or_create_peers(
|
||||
db, workspace_name, [schemas.PeerCreate(name=peer_name)]
|
||||
)
|
||||
)[0]
|
||||
|
||||
if peer.metadata is not None:
|
||||
honcho_peer.h_metadata = peer.metadata
|
||||
|
||||
if peer.configuration is not None:
|
||||
honcho_peer.configuration = peer.configuration
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Peer {peer_name} updated successfully")
|
||||
return honcho_peer
|
||||
|
||||
|
||||
async def get_sessions_for_peer(
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> Select[tuple[models.Session]]:
|
||||
"""
|
||||
Get all sessions for a peer through the session_peers relationship.
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
peer_name: Name of the peer
|
||||
filters: Filter sessions by metadata
|
||||
|
||||
Returns:
|
||||
SQLAlchemy Select statement
|
||||
"""
|
||||
stmt = (
|
||||
select(models.Session)
|
||||
.join(
|
||||
models.SessionPeer,
|
||||
(models.Session.name == models.SessionPeer.session_name)
|
||||
& (models.Session.workspace_name == models.SessionPeer.workspace_name),
|
||||
)
|
||||
.where(models.SessionPeer.peer_name == peer_name)
|
||||
.where(models.Session.workspace_name == workspace_name)
|
||||
)
|
||||
|
||||
stmt = apply_filter(stmt, models.Session, filters)
|
||||
|
||||
stmt: Select[tuple[models.Session]] = stmt.order_by(models.Session.created_at)
|
||||
|
||||
return stmt
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
async def get_working_representation(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
observer_name: str,
|
||||
observed_name: str,
|
||||
session_name: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get working representation for observer/observed relationship.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
observer_name: Name of the peer doing the observing
|
||||
observed_name: Name of the peer being observed (required for explicit global/local)
|
||||
session_name: Optional session name (None for peer-level metadata)
|
||||
|
||||
Returns:
|
||||
Formatted working representation string
|
||||
"""
|
||||
working_rep_data = await get_working_representation_data(
|
||||
db, workspace_name, observer_name, observed_name, session_name
|
||||
)
|
||||
|
||||
if not working_rep_data:
|
||||
logger.warning(
|
||||
f"No working representation found for observer: {observer_name}, observed: {observed_name}"
|
||||
)
|
||||
return ""
|
||||
|
||||
# Handle both old format (string) and new format (structured data)
|
||||
if isinstance(working_rep_data, str):
|
||||
return working_rep_data
|
||||
|
||||
# New structured format - extract and format final_observations
|
||||
try:
|
||||
final_observations = working_rep_data.get("final_observations", {})
|
||||
if not final_observations:
|
||||
logger.warning("No final_observations found in working representation data")
|
||||
return ""
|
||||
|
||||
return _format_observations_by_level(final_observations)
|
||||
except Exception:
|
||||
logger.exception("Error processing working representation")
|
||||
return ""
|
||||
|
||||
|
||||
async def get_working_representation_data(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
observer_name: str,
|
||||
observed_name: str, # now required
|
||||
session_name: str | None = None,
|
||||
) -> dict[str, Any] | str | None:
|
||||
"""
|
||||
Get raw working representation data from internal_metadata.
|
||||
|
||||
Returns either structured data (new format) or string (legacy format).
|
||||
"""
|
||||
# Determine metadata key based on observer/observed relationship
|
||||
if observer_name == observed_name:
|
||||
metadata_key = "global_representation"
|
||||
else:
|
||||
metadata_key = construct_collection_name(
|
||||
observer=observer_name, observed=observed_name
|
||||
)
|
||||
|
||||
if session_name:
|
||||
stmt = select(models.SessionPeer.internal_metadata).where(
|
||||
models.SessionPeer.peer_name == observer_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.session_name == session_name,
|
||||
)
|
||||
else:
|
||||
stmt = select(models.Peer.internal_metadata).where(
|
||||
models.Peer.name == observer_name,
|
||||
models.Peer.workspace_name == workspace_name,
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
peer_metadata = result.scalar_one_or_none()
|
||||
|
||||
if not peer_metadata:
|
||||
return None
|
||||
|
||||
# Try new prefixed key first, then fallback to legacy keys
|
||||
working_rep_data = peer_metadata.get(metadata_key)
|
||||
if working_rep_data:
|
||||
return working_rep_data
|
||||
|
||||
# Fallback logic for migration period
|
||||
legacy_data = peer_metadata.get("latest_working_representation")
|
||||
if legacy_data:
|
||||
logger.debug(
|
||||
"Using legacy key 'latest_working_representation' for %s->%s",
|
||||
observer_name,
|
||||
observed_name,
|
||||
)
|
||||
return legacy_data
|
||||
|
||||
# Final fallback to old user_representation key
|
||||
USER_REPRESENTATION_METADATA_KEY = "user_representation"
|
||||
user_rep_data = peer_metadata.get(USER_REPRESENTATION_METADATA_KEY)
|
||||
if user_rep_data:
|
||||
logger.debug(
|
||||
"Using legacy key '%s' for %s->%s",
|
||||
USER_REPRESENTATION_METADATA_KEY,
|
||||
observer_name,
|
||||
observed_name,
|
||||
)
|
||||
return user_rep_data
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_observations_by_level(final_observations: dict[str, Any]) -> str:
|
||||
"""Format final observations into structured text by level."""
|
||||
formatted_sections: list[str] = []
|
||||
|
||||
for level in ["explicit", "deductive"]:
|
||||
observations: list[Any] = final_observations.get(level, [])
|
||||
if observations:
|
||||
formatted_sections.append(f"{level.upper()} OBSERVATIONS:")
|
||||
formatted_sections.extend(_format_observation_list(observations))
|
||||
formatted_sections.append("")
|
||||
|
||||
return "\n".join(formatted_sections) if formatted_sections else ""
|
||||
|
||||
|
||||
def _format_observation_list(observations: list[dict[str, Any] | str]) -> list[str]:
|
||||
"""Format a list of observations into consistent string format."""
|
||||
formatted: list[str] = []
|
||||
for obs in observations:
|
||||
if isinstance(obs, dict):
|
||||
# Determine core content and premises
|
||||
if "conclusion" in obs:
|
||||
conclusion_text: str = obs["conclusion"]
|
||||
premises: list[str] = obs.get("premises", [])
|
||||
if premises:
|
||||
premises_text = "; ".join(premises)
|
||||
formatted_obs = f"{conclusion_text} (based on: {premises_text})"
|
||||
else:
|
||||
formatted_obs = conclusion_text
|
||||
else:
|
||||
content_text: str = obs.get("content", str(obs))
|
||||
formatted_obs = content_text
|
||||
|
||||
formatted.append(f"- {formatted_obs}")
|
||||
else:
|
||||
# Handle string fallback
|
||||
formatted.append(f"- {str(obs)}")
|
||||
return formatted
|
||||
|
||||
|
||||
async def set_working_representation(
|
||||
db: AsyncSession,
|
||||
representation: str | dict[str, Any],
|
||||
workspace_name: str,
|
||||
observer_name: str, # renamed from peer_name
|
||||
observed_name: str, # now required - no default
|
||||
session_name: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Set working representation for observer/observed relationship.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
representation: Working representation data (string or structured dict)
|
||||
workspace_name: Name of the workspace
|
||||
observer_name: Name of the peer doing the observing
|
||||
observed_name: Name of the peer being observed (required for explicit global/local)
|
||||
session_name: Optional session name (None for peer-level metadata)
|
||||
"""
|
||||
# Determine metadata key based on observer/observed relationship
|
||||
if observer_name == observed_name:
|
||||
metadata_key = "global_representation"
|
||||
else:
|
||||
metadata_key = construct_collection_name(
|
||||
observer=observer_name, observed=observed_name
|
||||
)
|
||||
|
||||
if session_name:
|
||||
# Session-level: save all types (global and local)
|
||||
stmt = (
|
||||
update(models.SessionPeer)
|
||||
.where(models.SessionPeer.workspace_name == workspace_name)
|
||||
.where(models.SessionPeer.peer_name == observer_name)
|
||||
.where(models.SessionPeer.session_name == session_name)
|
||||
.values(
|
||||
internal_metadata=models.SessionPeer.internal_metadata.op("||")(
|
||||
{metadata_key: representation}
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Peer-level: only save global representations
|
||||
if observer_name == observed_name:
|
||||
stmt = (
|
||||
update(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == observer_name)
|
||||
.values(
|
||||
internal_metadata=models.Peer.internal_metadata.op("||")(
|
||||
{metadata_key: representation}
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Skipping peer-level local representation save (this should never happen!): observer=%s, observed=%s",
|
||||
observer_name,
|
||||
observed_name,
|
||||
)
|
||||
return
|
||||
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def construct_collection_name(*, observer: str, observed: str) -> str:
|
||||
return f"{observer}_{observed}"
|
||||
|
|
@ -0,0 +1,690 @@
|
|||
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.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.types import BigInteger
|
||||
|
||||
from src import models, schemas
|
||||
from src.config import settings
|
||||
from src.exceptions import ResourceNotFoundException
|
||||
from src.utils.filter import apply_filter
|
||||
|
||||
from .peer import get_or_create_peers, get_peer
|
||||
|
||||
# Import workspace and peer functions that are needed
|
||||
from .workspace import get_or_create_workspace
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
async def get_sessions(
|
||||
workspace_name: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> Select[tuple[models.Session]]:
|
||||
"""
|
||||
Get all sessions in a workspace.
|
||||
"""
|
||||
stmt = select(models.Session).where(models.Session.workspace_name == workspace_name)
|
||||
|
||||
stmt = apply_filter(stmt, models.Session, filters)
|
||||
|
||||
return stmt.order_by(models.Session.created_at)
|
||||
|
||||
|
||||
async def get_or_create_session(
|
||||
db: AsyncSession,
|
||||
session: schemas.SessionCreate,
|
||||
workspace_name: str,
|
||||
) -> models.Session:
|
||||
"""
|
||||
Get or create a session in a workspace with specified peers.
|
||||
If the session already exists, the peers are added to the session.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session: Session creation schema
|
||||
workspace_name: Name of the workspace
|
||||
peer_names: List of peer names to add to the session
|
||||
|
||||
Returns:
|
||||
The created session
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session does not exist and create is false
|
||||
"""
|
||||
|
||||
stmt = (
|
||||
select(models.Session)
|
||||
.where(models.Session.workspace_name == workspace_name)
|
||||
.where(models.Session.name == session.name)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
|
||||
honcho_session = result.scalar_one_or_none()
|
||||
|
||||
# Check if session already exists
|
||||
if honcho_session is None:
|
||||
if (
|
||||
session.peer_names
|
||||
and len(session.peer_names) > settings.SESSION_PEERS_LIMIT
|
||||
):
|
||||
raise ValueError(
|
||||
f"Cannot create session {session.name} with {len(session.peer_names)} peers. Maximum allowed is {settings.SESSION_PEERS_LIMIT} peers per session."
|
||||
)
|
||||
|
||||
# Get or create workspace to ensure it exists
|
||||
await get_or_create_workspace(
|
||||
db,
|
||||
schemas.WorkspaceCreate(name=workspace_name),
|
||||
)
|
||||
|
||||
# Create honcho session
|
||||
honcho_session = models.Session(
|
||||
workspace_name=workspace_name,
|
||||
name=session.name,
|
||||
h_metadata=session.metadata or {},
|
||||
configuration=session.configuration or {},
|
||||
)
|
||||
db.add(honcho_session)
|
||||
# Flush to ensure session exists in DB before adding peers
|
||||
await db.flush()
|
||||
else:
|
||||
# Update existing session with metadata and feature flags if provided
|
||||
if session.metadata is not None:
|
||||
honcho_session.h_metadata = session.metadata
|
||||
if session.configuration is not None:
|
||||
honcho_session.configuration = session.configuration
|
||||
|
||||
# Add all peers to session
|
||||
if session.peer_names:
|
||||
await get_or_create_peers(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
peers=[
|
||||
schemas.PeerCreate(name=peer_name) for peer_name in session.peer_names
|
||||
],
|
||||
)
|
||||
await _get_or_add_peers_to_session(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session.name,
|
||||
peer_names=session.peer_names,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def get_session(
|
||||
db: AsyncSession,
|
||||
session_name: str,
|
||||
workspace_name: str,
|
||||
) -> models.Session:
|
||||
"""
|
||||
Get a session in a workspace.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_name: Name of the session
|
||||
workspace_name: Name of the workspace
|
||||
|
||||
Returns:
|
||||
The session
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session does not exist
|
||||
"""
|
||||
stmt = (
|
||||
select(models.Session)
|
||||
.where(models.Session.workspace_name == workspace_name)
|
||||
.where(models.Session.name == session_name)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
|
||||
honcho_session = result.scalar_one_or_none()
|
||||
|
||||
if honcho_session is None:
|
||||
raise ResourceNotFoundException(
|
||||
f"Session {session_name} not found in workspace {workspace_name}"
|
||||
)
|
||||
|
||||
return honcho_session
|
||||
|
||||
|
||||
async def update_session(
|
||||
db: AsyncSession,
|
||||
session: schemas.SessionUpdate,
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
) -> models.Session:
|
||||
"""
|
||||
Update a session.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session: Session update schema
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
|
||||
Returns:
|
||||
The updated session
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session does not exist or peer is not in session
|
||||
"""
|
||||
honcho_session = await get_or_create_session(
|
||||
db, schemas.SessionCreate(name=session_name), workspace_name=workspace_name
|
||||
)
|
||||
|
||||
if session.metadata is not None:
|
||||
honcho_session.h_metadata = session.metadata
|
||||
|
||||
if session.configuration is not None:
|
||||
honcho_session.configuration = session.configuration
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Session {session_name} updated successfully")
|
||||
return honcho_session
|
||||
|
||||
|
||||
async def delete_session(
|
||||
db: AsyncSession, workspace_name: str, session_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
Mark a session as inactive (soft delete).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
|
||||
Returns:
|
||||
True if the session was deleted successfully
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session does not exist
|
||||
"""
|
||||
stmt = (
|
||||
select(models.Session)
|
||||
.where(models.Session.workspace_name == workspace_name)
|
||||
.where(models.Session.name == session_name)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
honcho_session = result.scalar_one_or_none()
|
||||
|
||||
if honcho_session is None:
|
||||
logger.warning(
|
||||
f"Session {session_name} not found in workspace {workspace_name}"
|
||||
)
|
||||
raise ResourceNotFoundException("Session not found")
|
||||
|
||||
honcho_session.is_active = False
|
||||
await db.commit()
|
||||
logger.info(f"Session {session_name} marked as inactive")
|
||||
return True
|
||||
|
||||
|
||||
async def clone_session(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
original_session_name: str,
|
||||
cutoff_message_id: str | None = None,
|
||||
) -> models.Session:
|
||||
"""
|
||||
Clone a session and its messages. If cutoff_message_id is provided,
|
||||
only clone messages up to and including that message.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session
|
||||
workspace_name: Name of the workspace the target session is in
|
||||
original_session_name: Name of the session to clone
|
||||
cutoff_message_id: Optional ID of the last message to include in the clone
|
||||
|
||||
Returns:
|
||||
The newly created session
|
||||
"""
|
||||
# Get the original session
|
||||
stmt = (
|
||||
select(models.Session)
|
||||
.where(models.Session.workspace_name == workspace_name)
|
||||
.where(models.Session.name == original_session_name)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
original_session = result.scalar_one_or_none()
|
||||
if original_session is None:
|
||||
raise ResourceNotFoundException("Original session not found")
|
||||
|
||||
# If cutoff_message_id is provided, verify it belongs to the session
|
||||
cutoff_message = None
|
||||
if cutoff_message_id is not None:
|
||||
stmt = select(models.Message).where(
|
||||
models.Message.public_id == cutoff_message_id,
|
||||
models.Message.session_name == original_session_name,
|
||||
)
|
||||
cutoff_message = await db.scalar(stmt)
|
||||
if not cutoff_message:
|
||||
raise ValueError(
|
||||
"Message not found or doesn't belong to the specified session"
|
||||
)
|
||||
|
||||
# Create new session
|
||||
new_session = models.Session(
|
||||
workspace_name=workspace_name,
|
||||
name=generate_nanoid(),
|
||||
h_metadata=original_session.h_metadata,
|
||||
)
|
||||
db.add(new_session)
|
||||
await db.flush() # Flush to get the new session ID
|
||||
|
||||
# Build query for messages to clone
|
||||
stmt = select(models.Message).where(
|
||||
models.Message.session_name == original_session_name
|
||||
)
|
||||
if cutoff_message_id is not None and cutoff_message is not None:
|
||||
stmt = stmt.where(models.Message.id <= cast(cutoff_message.id, BigInteger))
|
||||
stmt = stmt.order_by(models.Message.id)
|
||||
|
||||
# Fetch messages to clone
|
||||
messages_to_clone_scalars = await db.scalars(stmt)
|
||||
messages_to_clone = messages_to_clone_scalars.all()
|
||||
|
||||
if not messages_to_clone:
|
||||
return new_session
|
||||
|
||||
# Prepare bulk insert data
|
||||
new_messages = [
|
||||
{
|
||||
"session_name": new_session.name,
|
||||
"content": message.content,
|
||||
"h_metadata": message.h_metadata,
|
||||
"workspace_name": workspace_name,
|
||||
"peer_name": message.peer_name,
|
||||
}
|
||||
for message in messages_to_clone
|
||||
]
|
||||
|
||||
insert_stmt = insert(models.Message).returning(models.Message)
|
||||
result = await db.execute(insert_stmt, new_messages)
|
||||
|
||||
# Clone peers from original session to new session
|
||||
stmt = select(models.SessionPeer).where(
|
||||
models.SessionPeer.session_name == original_session_name
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
session_peers = result.scalars().all()
|
||||
for session_peer in session_peers:
|
||||
new_session_peer = models.SessionPeer(
|
||||
session_name=new_session.name,
|
||||
peer_name=session_peer.peer_name,
|
||||
workspace_name=workspace_name,
|
||||
)
|
||||
db.add(new_session_peer)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Session {original_session_name} cloned successfully")
|
||||
return new_session
|
||||
|
||||
|
||||
async def remove_peers_from_session(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
peer_names: set[str],
|
||||
) -> bool:
|
||||
"""
|
||||
Remove specified peers from a session.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
peer_names: Set of peer names to remove from the session
|
||||
|
||||
Returns:
|
||||
True if peers were removed successfully
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session does not exist
|
||||
"""
|
||||
# Verify session exists
|
||||
stmt = (
|
||||
select(models.Session)
|
||||
.where(models.Session.workspace_name == workspace_name)
|
||||
.where(models.Session.name == session_name)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if session is None:
|
||||
raise ResourceNotFoundException(
|
||||
f"Session {session_name} not found in workspace {workspace_name}"
|
||||
)
|
||||
|
||||
# Soft delete specified session peers by setting left_at timestamp
|
||||
update_stmt = (
|
||||
update(models.SessionPeer)
|
||||
.where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.peer_name.in_(peer_names),
|
||||
models.SessionPeer.left_at.is_(None), # Only update active peers
|
||||
)
|
||||
.values(left_at=func.now())
|
||||
)
|
||||
result = await db.execute(update_stmt)
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_peers_from_session(
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
) -> Select[tuple[models.Peer]]:
|
||||
"""
|
||||
Get all peers from a session.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
|
||||
Returns:
|
||||
Paginated list of Peer objects in the session
|
||||
"""
|
||||
# Get all active peers in the session (where left_at is NULL)
|
||||
return (
|
||||
select(models.Peer)
|
||||
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
|
||||
.where(models.SessionPeer.session_name == session_name)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.SessionPeer.left_at.is_(None)) # Only active peers
|
||||
)
|
||||
|
||||
|
||||
async def get_session_peer_configuration(
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
) -> Select[tuple[str, dict[str, Any], dict[str, Any]]]:
|
||||
"""
|
||||
Get configuration from both SessionPeer and Peer tables for active peers in a session.
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
|
||||
Returns:
|
||||
Select statement returning peer_name, peer_configuration, and session_peer_configuration
|
||||
"""
|
||||
stmt: Select[tuple[str, dict[str, Any], dict[str, Any]]] = (
|
||||
select(
|
||||
models.Peer.name.label("peer_name"),
|
||||
models.Peer.configuration.label("peer_configuration"),
|
||||
models.SessionPeer.configuration.label("session_peer_configuration"),
|
||||
)
|
||||
.join(models.SessionPeer, models.Peer.name == models.SessionPeer.peer_name)
|
||||
.where(models.SessionPeer.session_name == session_name)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.SessionPeer.workspace_name == workspace_name)
|
||||
.where(models.SessionPeer.left_at.is_(None)) # Only active peers
|
||||
)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
async def set_peers_for_session(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
peer_names: dict[str, schemas.SessionPeerConfig],
|
||||
) -> list[models.SessionPeer]:
|
||||
"""
|
||||
Set peers for a session, overwriting any existing peers.
|
||||
If peers don't exist, they will be created.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
peer_names: Set of peer names to set for the session
|
||||
|
||||
Returns:
|
||||
List of SessionPeer objects for all peers in the session
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session does not exist
|
||||
"""
|
||||
# Validate peer limit before making any changes
|
||||
if len(peer_names) > settings.SESSION_PEERS_LIMIT:
|
||||
raise ValueError(
|
||||
f"Cannot set {len(peer_names)} peers for session {session_name}. Maximum allowed is {settings.SESSION_PEERS_LIMIT} peers per session."
|
||||
)
|
||||
|
||||
# Verify session exists
|
||||
stmt = (
|
||||
select(models.Session)
|
||||
.where(models.Session.workspace_name == workspace_name)
|
||||
.where(models.Session.name == session_name)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if session is None:
|
||||
raise ResourceNotFoundException(
|
||||
f"Session {session_name} not found in workspace {workspace_name}"
|
||||
)
|
||||
|
||||
# Soft delete specified session peers by setting left_at timestamp
|
||||
update_stmt = (
|
||||
update(models.SessionPeer)
|
||||
.where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.left_at.is_(None), # Only update active peers
|
||||
)
|
||||
.values(left_at=func.now())
|
||||
)
|
||||
result = await db.execute(update_stmt)
|
||||
|
||||
# Get or create peers
|
||||
await get_or_create_peers(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
peers=[schemas.PeerCreate(name=peer_name) for peer_name in peer_names],
|
||||
)
|
||||
|
||||
# Add new peers to session
|
||||
peers = await _get_or_add_peers_to_session(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
peer_names=peer_names,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return peers
|
||||
|
||||
|
||||
async def _get_or_add_peers_to_session(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
peer_names: dict[str, schemas.SessionPeerConfig],
|
||||
) -> list[models.SessionPeer]:
|
||||
"""
|
||||
Add multiple peers to an existing session. If a peer already exists in the session,
|
||||
it will be skipped gracefully.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_name: Name of the session
|
||||
peer_names: Set of peer names to add to the session
|
||||
|
||||
Returns:
|
||||
List of all SessionPeer objects (both existing and newly created)
|
||||
|
||||
Raises:
|
||||
ValueError: If adding peers would exceed the maximum limit
|
||||
"""
|
||||
# If no peers to add, skip the insert and just return existing active session peers
|
||||
if not peer_names:
|
||||
select_stmt = select(models.SessionPeer).where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.left_at.is_(None), # Only active peers
|
||||
)
|
||||
result = await db.execute(select_stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
# Check current number of active peers and validate limit before upsert
|
||||
current_peers_stmt = select(models.SessionPeer.peer_name).where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.left_at.is_(None), # Only active peers
|
||||
)
|
||||
result = await db.execute(current_peers_stmt)
|
||||
existing_peer_names = result.scalars().all()
|
||||
|
||||
new_peers = [name for name in peer_names if name not in existing_peer_names]
|
||||
if len(new_peers) + len(existing_peer_names) > settings.SESSION_PEERS_LIMIT:
|
||||
raise ValueError(
|
||||
f"Cannot add {len(new_peers)} peer(s). Session already has {len(existing_peer_names)} peer(s) with {settings.SESSION_PEERS_LIMIT} peers per session."
|
||||
)
|
||||
|
||||
# Use upsert to handle both new peers and rejoining peers
|
||||
stmt = pg_insert(models.SessionPeer).values(
|
||||
[
|
||||
{
|
||||
"session_name": session_name,
|
||||
"peer_name": peer_name,
|
||||
"workspace_name": workspace_name,
|
||||
"joined_at": func.now(),
|
||||
"left_at": None,
|
||||
"configuration": configuration.model_dump(),
|
||||
}
|
||||
for peer_name, configuration in peer_names.items()
|
||||
]
|
||||
)
|
||||
|
||||
# On conflict, update joined_at and clear left_at (rejoin scenario)
|
||||
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,
|
||||
},
|
||||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
# Return all active session peers after the upsert
|
||||
select_stmt = select(models.SessionPeer).where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.left_at.is_(None), # Only active peers
|
||||
)
|
||||
result = await db.execute(select_stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_peer_config(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
peer_id: str,
|
||||
) -> schemas.SessionPeerConfig:
|
||||
"""
|
||||
Get the configuration for a peer in a session.
|
||||
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
peer_id: Name of the peer
|
||||
|
||||
|
||||
Returns:
|
||||
Configuration for the peer
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session or peer does not exist
|
||||
"""
|
||||
# Get row from session_peer table
|
||||
stmt = select(models.SessionPeer).where(
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.peer_name == peer_id,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
session_peer = result.scalar_one_or_none()
|
||||
|
||||
if session_peer is None:
|
||||
raise ResourceNotFoundException(
|
||||
f"Session peer {peer_id} not found in session {session_name} in workspace {workspace_name}"
|
||||
)
|
||||
|
||||
return schemas.SessionPeerConfig(**session_peer.configuration)
|
||||
|
||||
|
||||
async def set_peer_config(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
peer_name: str,
|
||||
config: schemas.SessionPeerConfig,
|
||||
) -> None:
|
||||
"""
|
||||
Set the configuration for a specific peer in a session.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
peer_name: Name of the peer
|
||||
config: The peer configuration to set
|
||||
"""
|
||||
# First, get the session and peer to ensure they exist
|
||||
await get_session(db, session_name, workspace_name)
|
||||
await get_peer(db, workspace_name, schemas.PeerCreate(name=peer_name))
|
||||
|
||||
# Check if a SessionPeer entry already exists
|
||||
stmt = (
|
||||
select(models.SessionPeer)
|
||||
.where(models.SessionPeer.session_name == session_name)
|
||||
.where(models.SessionPeer.peer_name == peer_name)
|
||||
.where(models.SessionPeer.workspace_name == workspace_name)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
session_peer = result.scalar_one_or_none()
|
||||
|
||||
update_data = config.model_dump(exclude_none=True)
|
||||
|
||||
if session_peer:
|
||||
# Update existing configuration
|
||||
if session_peer.configuration:
|
||||
# Create a new dictionary and update it to ensure SQLAlchemy tracks the change
|
||||
new_config = session_peer.configuration.copy()
|
||||
new_config.update(update_data)
|
||||
session_peer.configuration = new_config
|
||||
else:
|
||||
session_peer.configuration = update_data
|
||||
else:
|
||||
# Create a new SessionPeer entry
|
||||
session_peer = models.SessionPeer(
|
||||
session_name=session_name,
|
||||
peer_name=peer_name,
|
||||
workspace_name=workspace_name,
|
||||
configuration=update_data,
|
||||
)
|
||||
db.add(session_peer)
|
||||
|
||||
await db.commit()
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models, schemas
|
||||
from src.utils.filter import apply_filter
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
async def get_or_create_workspace(
|
||||
db: AsyncSession, workspace: schemas.WorkspaceCreate
|
||||
) -> models.Workspace:
|
||||
"""
|
||||
Get an existing workspace or create a new one if it doesn't exist.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace: Workspace creation schema
|
||||
|
||||
Returns:
|
||||
The workspace if found or created
|
||||
|
||||
Raises:
|
||||
ConflictException: If there's an integrity error when creating the workspace
|
||||
"""
|
||||
# Try to get the existing workspace
|
||||
stmt = select(models.Workspace).where(models.Workspace.name == workspace.name)
|
||||
result = await db.execute(stmt)
|
||||
existing_workspace = result.scalar_one_or_none()
|
||||
|
||||
if existing_workspace is not None:
|
||||
# Workspace already exists
|
||||
logger.debug(f"Found existing workspace: {workspace.name}")
|
||||
return existing_workspace
|
||||
|
||||
# Workspace doesn't exist, create a new one
|
||||
honcho_workspace = models.Workspace(
|
||||
name=workspace.name,
|
||||
h_metadata=workspace.metadata,
|
||||
configuration=workspace.configuration,
|
||||
)
|
||||
db.add(honcho_workspace)
|
||||
await db.commit()
|
||||
logger.info(f"Workspace created successfully: {workspace.name}")
|
||||
return honcho_workspace
|
||||
|
||||
|
||||
async def get_all_workspaces(
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> Select[tuple[models.Workspace]]:
|
||||
"""
|
||||
Get all workspaces.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter the workspaces by a dictionary of metadata
|
||||
"""
|
||||
stmt = select(models.Workspace)
|
||||
stmt = apply_filter(stmt, models.Workspace, filters)
|
||||
stmt: Select[tuple[models.Workspace]] = stmt.order_by(models.Workspace.created_at)
|
||||
return stmt
|
||||
|
||||
|
||||
async def update_workspace(
|
||||
db: AsyncSession, workspace_name: str, workspace: schemas.WorkspaceUpdate
|
||||
) -> models.Workspace:
|
||||
"""
|
||||
Update a workspace.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
workspace: Workspace update schema
|
||||
|
||||
Returns:
|
||||
The updated workspace
|
||||
"""
|
||||
honcho_workspace = await get_or_create_workspace(
|
||||
db,
|
||||
schemas.WorkspaceCreate(
|
||||
name=workspace_name,
|
||||
metadata=workspace.metadata or {}, # Provide empty dict if metadata is None
|
||||
),
|
||||
)
|
||||
|
||||
if workspace.metadata is not None:
|
||||
honcho_workspace.h_metadata = workspace.metadata
|
||||
|
||||
if workspace.configuration is not None:
|
||||
honcho_workspace.configuration = workspace.configuration
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Workspace with id {honcho_workspace.id} updated successfully")
|
||||
return honcho_workspace
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from .enqueue import enqueue
|
||||
|
||||
__all__ = ["enqueue"]
|
||||
|
|
@ -2,7 +2,7 @@ import asyncio
|
|||
|
||||
import uvloop
|
||||
|
||||
from .queue import main
|
||||
from .queue_manager import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("[DERIVER] Starting deriver queue processor")
|
||||
|
|
|
|||
|
|
@ -1,52 +1,27 @@
|
|||
import logging
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
import sentry_sdk
|
||||
from langfuse.decorators import observe # pyright: ignore
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic import ValidationError
|
||||
from rich.console import Console
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.config import settings
|
||||
from src.utils.summarizer import summarize_if_needed
|
||||
|
||||
from .. import crud
|
||||
from ..utils import history
|
||||
from .tom.embeddings import CollectionEmbeddingStore
|
||||
from .tom.long_term import extract_facts_long_term
|
||||
from .deriver import Deriver
|
||||
from .queue_payload import DeriverQueuePayload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.getLogger("sqlalchemy.engine.Engine").disabled = True
|
||||
|
||||
console = Console(markup=False)
|
||||
|
||||
TOM_METHOD = settings.DERIVER.TOM_METHOD
|
||||
USER_REPRESENTATION_METHOD = settings.DERIVER.USER_REPRESENTATION_METHOD
|
||||
|
||||
|
||||
class PayloadSchema(BaseModel):
|
||||
"""
|
||||
Schema for validating payload data in process_item function.
|
||||
Ensures all required fields are present with correct types and prevents injection risks.
|
||||
"""
|
||||
|
||||
content: str
|
||||
workspace_name: str
|
||||
sender_name: str
|
||||
target_name: str
|
||||
session_name: str | None
|
||||
message_id: int
|
||||
task_type: Literal["representation", "summary"]
|
||||
|
||||
class Config:
|
||||
# Forbid extra fields to prevent injection of unexpected data
|
||||
extra = "forbid" # pyright: ignore
|
||||
deriver = Deriver()
|
||||
|
||||
|
||||
async def process_item(db: AsyncSession, payload: dict[str, Any]):
|
||||
# Validate payload structure and types before processing
|
||||
try:
|
||||
validated_payload = PayloadSchema(**payload)
|
||||
validated_payload = DeriverQueuePayload(**payload)
|
||||
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
|
||||
|
|
@ -63,15 +38,7 @@ async def process_item(db: AsyncSession, payload: dict[str, Any]):
|
|||
validated_payload.message_id,
|
||||
validated_payload.session_name,
|
||||
)
|
||||
await process_message(
|
||||
validated_payload.content,
|
||||
validated_payload.workspace_name,
|
||||
validated_payload.sender_name,
|
||||
validated_payload.target_name,
|
||||
validated_payload.session_name,
|
||||
validated_payload.message_id,
|
||||
db,
|
||||
)
|
||||
await deriver.process_message(validated_payload)
|
||||
logger.debug(
|
||||
"Finished processing message %s in %s %s",
|
||||
validated_payload.message_id,
|
||||
|
|
@ -90,211 +57,3 @@ async def process_item(db: AsyncSession, payload: dict[str, Any]):
|
|||
validated_payload.message_id,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@sentry_sdk.trace
|
||||
@observe()
|
||||
async def process_message(
|
||||
content: str,
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
target_name: str,
|
||||
session_name: str | None,
|
||||
message_id: int,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
Process a user message by extracting facts and saving them to the vector store.
|
||||
This runs as a background process after a user message is logged.
|
||||
"""
|
||||
console.print(f"Processing User Message: {content}", style="orange1")
|
||||
process_start = os.times()[4] # Get current CPU time
|
||||
logger.debug(
|
||||
"Starting fact extraction for user message %s in %s %s",
|
||||
message_id,
|
||||
"session" if session_name else "peer",
|
||||
session_name if session_name else peer_name,
|
||||
)
|
||||
|
||||
if session_name:
|
||||
# Get chat history and append current message
|
||||
logger.debug(
|
||||
"Retrieving chat history for %s %s",
|
||||
"session" if session_name else "peer",
|
||||
session_name if session_name else peer_name,
|
||||
)
|
||||
short_history_text = await history.get_summarized_history(
|
||||
db,
|
||||
workspace_name,
|
||||
session_name,
|
||||
peer_name,
|
||||
cutoff=message_id,
|
||||
summary_type=history.SummaryType.SHORT,
|
||||
)
|
||||
|
||||
chat_history_str = f"{short_history_text}\nuser: {content}"
|
||||
else:
|
||||
chat_history_str = f"user: {content}"
|
||||
|
||||
# Extract facts from chat history
|
||||
logger.debug("Extracting facts from chat history")
|
||||
extract_start = os.times()[4]
|
||||
fact_extraction = await extract_facts_long_term(chat_history_str)
|
||||
facts: list[str] = fact_extraction.facts or []
|
||||
extract_time = os.times()[4] - extract_start
|
||||
console.print(f"Extracted Facts: {facts}", style="bright_blue")
|
||||
logger.debug(f"Extracted {len(facts)} facts in {extract_time:.2f}s")
|
||||
|
||||
# Save the facts to the collection
|
||||
logger.debug(
|
||||
f"Setting up embedding store for workspace: {workspace_name}, peer: {peer_name}"
|
||||
)
|
||||
collection_name = (
|
||||
crud.construct_collection_name(peer_name, target_name)
|
||||
if peer_name != target_name
|
||||
else "global_representation"
|
||||
)
|
||||
collection = await crud.get_or_create_collection(
|
||||
db, workspace_name, collection_name, peer_name
|
||||
)
|
||||
embedding_store = CollectionEmbeddingStore(
|
||||
workspace_name=workspace_name,
|
||||
peer_name=peer_name,
|
||||
collection_name=collection.name,
|
||||
)
|
||||
|
||||
# Filter out facts that are duplicates of existing facts in the vector store
|
||||
logger.debug("Removing duplicate facts")
|
||||
dedup_start = os.times()[4]
|
||||
unique_facts = await embedding_store.remove_duplicates(facts)
|
||||
dedup_time = os.times()[4] - dedup_start
|
||||
logger.debug(
|
||||
f"Found {len(unique_facts)}/{len(facts)} unique facts in {dedup_time:.2f}s"
|
||||
)
|
||||
|
||||
# Only save the unique facts
|
||||
if unique_facts:
|
||||
logger.debug(f"Saving {len(unique_facts)} unique facts to vector store")
|
||||
save_start = os.times()[4]
|
||||
await embedding_store.save_facts(unique_facts, message_id=message_id)
|
||||
save_time = os.times()[4] - save_start
|
||||
logger.debug(f"Facts saved in {save_time:.2f}s")
|
||||
else:
|
||||
logger.debug("No unique facts to save")
|
||||
|
||||
console.print(f"Saved {len(unique_facts)} unique facts", style="bright_green")
|
||||
|
||||
total_time = os.times()[4] - process_start
|
||||
logger.debug(f"Total processing time: {total_time:.2f}s")
|
||||
|
||||
|
||||
async def summarize_if_needed(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
peer_name: str,
|
||||
message_id: int,
|
||||
):
|
||||
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: First check if we need a short summary (every 10 messages)
|
||||
(
|
||||
should_create_short,
|
||||
short_messages,
|
||||
_,
|
||||
) = await history.should_create_summary(
|
||||
db,
|
||||
workspace_name,
|
||||
session_name,
|
||||
peer_name,
|
||||
message_id,
|
||||
summary_type=history.SummaryType.SHORT,
|
||||
)
|
||||
|
||||
if should_create_short:
|
||||
logger.debug(f"Short summary needed for {len(short_messages)} messages")
|
||||
|
||||
# STEP 2: If we need a short summary, check if we also need a long summary
|
||||
(
|
||||
should_create_long,
|
||||
long_messages,
|
||||
latest_long_summary,
|
||||
) = await history.should_create_summary(
|
||||
db,
|
||||
workspace_name,
|
||||
session_name,
|
||||
peer_name,
|
||||
message_id,
|
||||
summary_type=history.SummaryType.LONG,
|
||||
)
|
||||
|
||||
# STEP 3: If we need a long summary, create it first before creating the short summary
|
||||
if should_create_long:
|
||||
logger.debug(
|
||||
f"Creating new long summary covering {len(long_messages)} messages"
|
||||
)
|
||||
try:
|
||||
# Get previous long summary context if available
|
||||
previous_long_summary_text = (
|
||||
latest_long_summary["content"] if latest_long_summary else None
|
||||
)
|
||||
|
||||
# Create a new long summary
|
||||
new_long_summary = await history.create_summary(
|
||||
messages=long_messages,
|
||||
previous_summary_text=previous_long_summary_text,
|
||||
summary_type=history.SummaryType.LONG,
|
||||
)
|
||||
|
||||
# Save the long summary
|
||||
await history.save_summary(
|
||||
db,
|
||||
new_long_summary,
|
||||
workspace_name,
|
||||
session_name,
|
||||
)
|
||||
logger.debug("Long summary created and saved successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating long summary: {str(e)}")
|
||||
else:
|
||||
logger.debug(
|
||||
f"No long summary needed. Need {history.MESSAGES_PER_LONG_SUMMARY} messages since last long summary."
|
||||
)
|
||||
|
||||
# STEP 4: Now create the short summary, using the latest long summary for context if available
|
||||
logger.debug(
|
||||
f"Creating new short summary covering {len(short_messages)} messages"
|
||||
)
|
||||
try:
|
||||
previous_long_summary_text = (
|
||||
latest_long_summary["content"] if latest_long_summary else None
|
||||
)
|
||||
|
||||
# Create a new short summary
|
||||
new_short_summary = await history.create_summary(
|
||||
messages=short_messages,
|
||||
previous_summary_text=previous_long_summary_text,
|
||||
summary_type=history.SummaryType.SHORT,
|
||||
)
|
||||
|
||||
# Save the short summary
|
||||
await history.save_summary(
|
||||
db,
|
||||
new_short_summary,
|
||||
workspace_name,
|
||||
session_name,
|
||||
)
|
||||
logger.debug("Short summary created and saved successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating short summary: {str(e)}")
|
||||
else:
|
||||
logger.debug(
|
||||
f"No short summary needed. Need {history.MESSAGES_PER_SHORT_SUMMARY} messages since last short summary."
|
||||
)
|
||||
|
||||
summary_time = os.times()[4] - summary_start
|
||||
logger.debug(f"Summary check completed in {summary_time:.2f}s")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,631 @@
|
|||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from langfuse.decorators import langfuse_context, observe # pyright: ignore
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud
|
||||
from src.config import settings
|
||||
from src.utils import summarizer
|
||||
from src.utils.clients import honcho_llm_call
|
||||
from src.utils.embedding_store import EmbeddingStore
|
||||
from src.utils.formatting import (
|
||||
REASONING_LEVELS,
|
||||
extract_observation_content,
|
||||
find_new_observations,
|
||||
format_context_for_prompt,
|
||||
format_datetime_simple,
|
||||
format_new_turn_with_timestamp,
|
||||
)
|
||||
from src.utils.shared_models import (
|
||||
DeductiveObservation,
|
||||
ObservationContext,
|
||||
ReasoningResponse,
|
||||
ReasoningResponseWithThinking,
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.getLogger("sqlalchemy.engine.Engine").disabled = True
|
||||
|
||||
|
||||
@honcho_llm_call(
|
||||
provider=settings.DERIVER.PROVIDER,
|
||||
model=settings.DERIVER.MODEL,
|
||||
track_name="Critical Analysis Call",
|
||||
response_model=ReasoningResponse,
|
||||
json_mode=True,
|
||||
max_tokens=settings.DERIVER.MAX_OUTPUT_TOKENS or settings.LLM.DEFAULT_MAX_TOKENS,
|
||||
thinking_budget_tokens=settings.DERIVER.THINKING_BUDGET_TOKENS
|
||||
if settings.DERIVER.PROVIDER == "anthropic"
|
||||
else None,
|
||||
enable_retry=True,
|
||||
retry_attempts=3,
|
||||
)
|
||||
async def critical_analysis_call(
|
||||
peer_name: str,
|
||||
message_created_at: datetime.datetime,
|
||||
context: str,
|
||||
history: str,
|
||||
new_turn: str,
|
||||
):
|
||||
return critical_analysis_prompt(
|
||||
peer_name=peer_name,
|
||||
message_created_at=message_created_at,
|
||||
context=context,
|
||||
history=history,
|
||||
new_turn=new_turn,
|
||||
)
|
||||
|
||||
|
||||
@observe()
|
||||
class Deriver:
|
||||
"""Deriver class for processing messages and extracting insights."""
|
||||
|
||||
async def process_message(
|
||||
self,
|
||||
payload: DeriverQueuePayload,
|
||||
) -> ReasoningResponseWithThinking:
|
||||
"""
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
# 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
|
||||
created_at = payload.created_at
|
||||
|
||||
# Open a DB session only for the duration of the processing call
|
||||
from src.dependencies import tracked_db
|
||||
|
||||
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
|
||||
current_time = format_datetime_simple(created_at)
|
||||
message_dt_obj = created_at
|
||||
logger.info(
|
||||
f"Using message timestamp '{current_time}' for message {message_id}"
|
||||
)
|
||||
|
||||
# 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(
|
||||
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"
|
||||
)
|
||||
|
||||
# Run consolidated reasoning that handles explicit and deductive levels
|
||||
logger.debug(
|
||||
"REASONING: Running unified insight derivation across explicit and deductive reasoning levels"
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
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."""
|
||||
|
||||
embedding_store: EmbeddingStore
|
||||
|
||||
def __init__(self, embedding_store: EmbeddingStore) -> None:
|
||||
self.embedding_store = embedding_store
|
||||
|
||||
def observation_context_to_reasoning_response(
|
||||
self, context: "ObservationContext"
|
||||
) -> ReasoningResponseWithThinking:
|
||||
"""Convert ObservationContext to ReasoningResponse for compatibility."""
|
||||
thinking = context.thinking
|
||||
|
||||
# Convert explicit observations to new structure
|
||||
explicit: list[str] = []
|
||||
for obs in context.explicit:
|
||||
explicit.append(obs.content)
|
||||
|
||||
# Convert deductive observations
|
||||
deductive: list[DeductiveObservation] = []
|
||||
for obs in context.deductive:
|
||||
deductive_obs = DeductiveObservation(
|
||||
conclusion=obs.content,
|
||||
premises=obs.metadata.premises if obs.metadata else [],
|
||||
)
|
||||
deductive.append(deductive_obs)
|
||||
|
||||
return ReasoningResponseWithThinking(
|
||||
thinking=thinking,
|
||||
explicit=explicit,
|
||||
deductive=deductive,
|
||||
)
|
||||
|
||||
@observe()
|
||||
async def derive_new_insights(
|
||||
self,
|
||||
context: ReasoningResponseWithThinking,
|
||||
history: str,
|
||||
new_turn: str,
|
||||
message_created_at: datetime.datetime,
|
||||
speaker: str = "user",
|
||||
) -> 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
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
formatted_new_turn,
|
||||
)
|
||||
|
||||
# Call the standalone LLM function (now with Tenacity retries)
|
||||
response_obj = await critical_analysis_call(
|
||||
peer_name=speaker,
|
||||
message_created_at=message_created_at,
|
||||
context=formatted_context,
|
||||
history=history,
|
||||
new_turn=formatted_new_turn,
|
||||
)
|
||||
|
||||
# Handle different response types
|
||||
if isinstance(response_obj, str):
|
||||
# If response is a string, try to parse as JSON
|
||||
import json
|
||||
|
||||
try:
|
||||
response_data = json.loads(response_obj)
|
||||
new_insights = ReasoningResponse(
|
||||
explicit=response_data.get("explicit", []),
|
||||
deductive=[
|
||||
DeductiveObservation(**item)
|
||||
for item in response_data.get("deductive", [])
|
||||
],
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
logger.warning(f"Failed to parse string response as JSON: {e}")
|
||||
new_insights = ReasoningResponse(explicit=[], deductive=[])
|
||||
else:
|
||||
# If response is already a ReasoningResponse object
|
||||
new_insights = response_obj
|
||||
|
||||
# Extract thinking content from the response
|
||||
thinking: str | None = None
|
||||
try:
|
||||
# Try to get thinking from the response object using getattr for safety
|
||||
response_attr = getattr(response_obj, "_response", None)
|
||||
if response_attr:
|
||||
thinking = getattr(response_attr, "thinking", None)
|
||||
else:
|
||||
thinking = getattr(response_obj, "thinking", None)
|
||||
|
||||
if thinking is None:
|
||||
logger.debug("No thinking content found in response")
|
||||
except (AttributeError, TypeError) as e:
|
||||
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),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@observe()
|
||||
async def reason(
|
||||
self,
|
||||
context: ReasoningResponseWithThinking,
|
||||
history: str,
|
||||
new_turn: str,
|
||||
message_id: str,
|
||||
session_name: str | None = None,
|
||||
message_created_at: datetime.datetime | None = None,
|
||||
speaker: str = "user",
|
||||
) -> ReasoningResponseWithThinking:
|
||||
"""
|
||||
Single-pass reasoning function that critically analyzes and derives insights.
|
||||
Performs one analysis pass and returns the final observations.
|
||||
"""
|
||||
if message_created_at is None:
|
||||
message_created_at = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
analysis_start = time.time()
|
||||
|
||||
# Perform critical analysis to get observation lists
|
||||
reasoning_response = await self.derive_new_insights(
|
||||
context, history, new_turn, message_created_at, speaker
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
# Save only the NEW observations that weren't in the original context
|
||||
await self._save_new_observations(
|
||||
context,
|
||||
reasoning_response,
|
||||
message_id,
|
||||
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")
|
||||
|
||||
return reasoning_response
|
||||
|
||||
@observe()
|
||||
async def _save_new_observations(
|
||||
self,
|
||||
original_context: ReasoningResponse,
|
||||
revised_observations: ReasoningResponse,
|
||||
message_id: str,
|
||||
session_name: str | None = None,
|
||||
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
|
||||
)
|
||||
|
||||
all_unified_observations: list[UnifiedObservation] = []
|
||||
total_observations_count: int = 0
|
||||
|
||||
for level, new_observations in new_observations_by_level.items():
|
||||
if not new_observations:
|
||||
logger.debug("No new observations to save for %s level", level)
|
||||
continue
|
||||
|
||||
logger.debug("Found %s new %s observations", len(new_observations), level)
|
||||
|
||||
# Convert each observation to UnifiedObservation with proper premises and level
|
||||
for observation in new_observations:
|
||||
if isinstance(observation, DeductiveObservation):
|
||||
# Create UnifiedObservation with premises from DeductiveObservation
|
||||
unified_obs = UnifiedObservation(
|
||||
conclusion=observation.conclusion,
|
||||
premises=observation.premises,
|
||||
level=level,
|
||||
)
|
||||
all_unified_observations.append(unified_obs)
|
||||
logger.debug(
|
||||
"Added %s observation: %s... with %s premises",
|
||||
level,
|
||||
observation.conclusion[:50],
|
||||
len(observation.premises),
|
||||
)
|
||||
|
||||
elif isinstance(observation, str):
|
||||
# String observations (explicit) have no premises
|
||||
unified_obs = UnifiedObservation.from_string(
|
||||
observation, level=level
|
||||
)
|
||||
all_unified_observations.append(unified_obs)
|
||||
logger.debug("Added %s observation: %s...", level, observation[:50])
|
||||
|
||||
else:
|
||||
# Handle unexpected types
|
||||
content = extract_observation_content(observation)
|
||||
unified_obs = UnifiedObservation.from_string(content, level=level)
|
||||
all_unified_observations.append(unified_obs)
|
||||
logger.warning(
|
||||
f"Added unexpected observation type: {type(observation)} as {level}"
|
||||
)
|
||||
|
||||
total_observations_count += 1
|
||||
|
||||
if not all_unified_observations:
|
||||
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,
|
||||
session_name=session_name,
|
||||
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,
|
||||
workspace_name: str,
|
||||
observer_name: str, # renamed from peer_name for clarity
|
||||
observed_name: str, # new parameter
|
||||
session_name: str | None,
|
||||
final_observations: ReasoningResponseWithThinking,
|
||||
message_id: int,
|
||||
) -> None:
|
||||
"""Save working representation to peer internal_metadata for dialectic access."""
|
||||
from sqlalchemy import update
|
||||
|
||||
from src import models
|
||||
|
||||
# Determine metadata key based on observer/observed relationship
|
||||
if observer_name == observed_name:
|
||||
metadata_key = "global_representation"
|
||||
else:
|
||||
metadata_key = crud.construct_collection_name(
|
||||
observer=observer_name, observed=observed_name
|
||||
)
|
||||
|
||||
# Convert ReasoningResponse to serializable dict
|
||||
final_obs_dict = {
|
||||
"thinking": final_observations.thinking,
|
||||
"explicit": final_observations.explicit,
|
||||
"deductive": [
|
||||
{
|
||||
"conclusion": obs.conclusion,
|
||||
"premises": obs.premises,
|
||||
}
|
||||
for obs in final_observations.deductive
|
||||
],
|
||||
}
|
||||
|
||||
working_rep_data = {
|
||||
"final_observations": final_obs_dict,
|
||||
"message_id": message_id,
|
||||
"created_at": datetime.datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# if session_name is supplied, save working representation to session peer
|
||||
if session_name:
|
||||
stmt = (
|
||||
update(models.SessionPeer)
|
||||
.where(
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.peer_name == observer_name,
|
||||
)
|
||||
.values(
|
||||
internal_metadata=models.SessionPeer.internal_metadata.op("||")(
|
||||
{metadata_key: working_rep_data}
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Saved working representation to session peer {session_name} - {observer_name} with key {metadata_key}"
|
||||
)
|
||||
else:
|
||||
# For peer-level messages (session_name=None), only save global representations
|
||||
if observer_name == observed_name:
|
||||
stmt = (
|
||||
update(models.Peer)
|
||||
.where(
|
||||
models.Peer.workspace_name == workspace_name,
|
||||
models.Peer.name == observer_name,
|
||||
)
|
||||
.values(
|
||||
internal_metadata=models.Peer.internal_metadata.op("||")(
|
||||
{metadata_key: working_rep_data}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
|
||||
logger.debug(
|
||||
"Saved working representation to peer %s with key %s",
|
||||
observer_name,
|
||||
metadata_key,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Skipping peer-level local representation save: observer=%s, observed=%s",
|
||||
observer_name,
|
||||
observed_name,
|
||||
)
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, schemas
|
||||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.exceptions import ValidationException
|
||||
from src.models import QueueItem
|
||||
|
||||
from .queue_payload import DeriverQueuePayload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def enqueue(payload: list[dict[str, Any]]) -> None:
|
||||
"""
|
||||
Add message(s) to the deriver queue for processing.
|
||||
|
||||
Args:
|
||||
payload: List of message payload dictionaries
|
||||
"""
|
||||
|
||||
# Use the get_db dependency to ensure proper transaction handling
|
||||
async with tracked_db("message_enqueue") as db_session:
|
||||
try:
|
||||
# Determine if batch or single processing
|
||||
if not payload: # Empty list check
|
||||
return
|
||||
workspace_name = payload[0]["workspace_name"]
|
||||
session_name = payload[0]["session_name"]
|
||||
|
||||
if session_name is None or workspace_name is None:
|
||||
raise ValidationException("Session and workspace are required")
|
||||
|
||||
queue_records = await handle_session(
|
||||
db_session, payload, workspace_name, session_name
|
||||
)
|
||||
|
||||
if queue_records:
|
||||
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!")
|
||||
if settings.SENTRY.ENABLED:
|
||||
import sentry_sdk
|
||||
|
||||
sentry_sdk.capture_exception(e)
|
||||
|
||||
|
||||
async def handle_session(
|
||||
db_session: AsyncSession,
|
||||
payload: list[dict[str, Any]],
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Handle enqueueing for normal session cases, creating appropriate queue items based on configurations.
|
||||
|
||||
Args:
|
||||
db_session: The database session
|
||||
payload: List of message payloads
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
|
||||
Returns:
|
||||
List of queue records to insert
|
||||
"""
|
||||
session = await crud.get_or_create_session(
|
||||
db_session,
|
||||
session=schemas.SessionCreate(name=session_name),
|
||||
workspace_name=workspace_name,
|
||||
)
|
||||
|
||||
deriver_disabled = bool(session.configuration.get("deriver_disabled"))
|
||||
|
||||
peers_with_configuration = await get_peers_with_configuration(
|
||||
db_session, workspace_name, session_name
|
||||
)
|
||||
|
||||
queue_records: list[dict[str, Any]] = []
|
||||
|
||||
for message in payload:
|
||||
queue_records.extend(
|
||||
process_message(
|
||||
message,
|
||||
peers_with_configuration,
|
||||
session.id,
|
||||
deriver_disabled=deriver_disabled,
|
||||
)
|
||||
)
|
||||
|
||||
return queue_records
|
||||
|
||||
|
||||
async def get_peers_with_configuration(
|
||||
db_session: AsyncSession, workspace_name: str, session_name: str
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""
|
||||
Retrieve peers with their configurations for a given session.
|
||||
|
||||
Args:
|
||||
db_session: The database session
|
||||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
|
||||
Returns:
|
||||
Dictionary mapping peer names to their configurations
|
||||
"""
|
||||
configuration_query = await crud.get_session_peer_configuration(
|
||||
workspace_name=workspace_name, session_name=session_name
|
||||
)
|
||||
peers_with_configuration_result = await db_session.execute(configuration_query)
|
||||
peers_with_configuration_list = peers_with_configuration_result.all()
|
||||
return {
|
||||
row.peer_name: [row.peer_configuration, row.session_peer_configuration]
|
||||
for row in peers_with_configuration_list
|
||||
}
|
||||
|
||||
|
||||
def create_representation_record(
|
||||
message: dict[str, Any],
|
||||
sender_name: str,
|
||||
target_name: str,
|
||||
session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a queue record for representation task.
|
||||
|
||||
Args:
|
||||
message: The message payload
|
||||
sender_name: Name of the sender
|
||||
target_name: Name of the target
|
||||
session_id: Optional session ID
|
||||
|
||||
Returns:
|
||||
Queue record dictionary
|
||||
"""
|
||||
processed_payload = DeriverQueuePayload.create_payload(
|
||||
message=message,
|
||||
sender_name=sender_name,
|
||||
target_name=target_name,
|
||||
task_type="representation",
|
||||
)
|
||||
return {
|
||||
"payload": processed_payload,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
|
||||
def create_summary_record(
|
||||
message: dict[str, Any], sender_name: str, target_name: str, session_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a queue record for summary task.
|
||||
|
||||
Args:
|
||||
message: The message payload
|
||||
sender_name: Name of the sender
|
||||
target_name: Name of the target
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
Queue record dictionary
|
||||
"""
|
||||
processed_payload = DeriverQueuePayload.create_payload(
|
||||
message=message,
|
||||
sender_name=sender_name,
|
||||
target_name=target_name,
|
||||
task_type="summary",
|
||||
)
|
||||
return {
|
||||
"payload": processed_payload,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
|
||||
def get_effective_observe_me(
|
||||
sender_name: str, peers_with_configuration: dict[str, list[dict[str, Any]]]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine the effective observe_me setting for a sender, considering session and peer configurations.
|
||||
|
||||
Args:
|
||||
sender_name: Name of the sender
|
||||
peers_with_configuration: Dictionary of peer configurations
|
||||
|
||||
Returns:
|
||||
True if observe_me is enabled, False otherwise
|
||||
"""
|
||||
configuration = peers_with_configuration[sender_name]
|
||||
sender_session_peer_config = (
|
||||
schemas.SessionPeerConfig(**configuration[1]) if configuration[1] else None
|
||||
)
|
||||
sender_peer_config = (
|
||||
schemas.PeerConfig(**configuration[0])
|
||||
if configuration[0]
|
||||
else schemas.PeerConfig()
|
||||
)
|
||||
|
||||
# Session peer config takes precedence if it exists and has observe_me set
|
||||
if sender_session_peer_config and sender_session_peer_config.observe_me is not None:
|
||||
return sender_session_peer_config.observe_me
|
||||
|
||||
# Otherwise use peer config
|
||||
return sender_peer_config.observe_me
|
||||
|
||||
|
||||
def process_message(
|
||||
message: dict[str, Any],
|
||||
peers_with_configuration: dict[str, list[dict[str, Any]]],
|
||||
session_id: str,
|
||||
*,
|
||||
deriver_disabled: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Process a single message and generate queue records based on configurations.
|
||||
|
||||
Args:
|
||||
message: The message payload
|
||||
deriver_disabled: Whether deriver is disabled for the session
|
||||
peers_with_configuration: Dictionary of peer configurations
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
List of queue records for this message
|
||||
"""
|
||||
sender_name = message["peer_name"]
|
||||
|
||||
if deriver_disabled:
|
||||
return [
|
||||
create_summary_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
|
||||
|
||||
records.append(
|
||||
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,
|
||||
)
|
||||
|
||||
return records
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
"""
|
||||
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)
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"""
|
||||
Prompts for the deriver module.
|
||||
|
||||
This module contains all prompt templates used by the deriver for critical analysis
|
||||
and reasoning tasks.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from inspect import cleandoc as c
|
||||
|
||||
from mirascope import prompt_template
|
||||
|
||||
|
||||
@prompt_template()
|
||||
def critical_analysis_prompt(
|
||||
peer_name: str,
|
||||
message_created_at: datetime.datetime,
|
||||
context: str,
|
||||
history: str,
|
||||
new_turn: str,
|
||||
) -> str:
|
||||
"""
|
||||
Generate the critical analysis prompt for the deriver.
|
||||
|
||||
Args:
|
||||
peer_name: The name of the user being analyzed
|
||||
message_created_at: Timestamp of the message being analyzed
|
||||
context: Current user understanding context
|
||||
history: Recent conversation history
|
||||
new_turn: New conversation turn to analyze
|
||||
|
||||
Returns:
|
||||
Formatted prompt string for critical analysis
|
||||
"""
|
||||
return c(
|
||||
f"""
|
||||
You are an agent who critically analyzes user messages through rigorous logical reasoning to produce only conclusions about the user that are CERTAIN. The user's name is **{peer_name}**.
|
||||
|
||||
IMPORTANT NAMING RULES
|
||||
• When you write a conclusion about the current user, always start the sentence with the user's name (e.g. "Anthony is 25 years old").
|
||||
• NEVER start a conclusion with generic phrases like "The user …" unless the user name is not known.
|
||||
• If you must reference a third person, use their explicit name, and add clarifiers such as "(third-party)" when confusion is possible.
|
||||
|
||||
Your goal is to IMPROVE understanding of the user through careful analysis. Your task is to arrive at truthful, factual conclusions via explicit and deductive reasoning.
|
||||
|
||||
Here are strict definitions for the reasoning modes you are to employ:
|
||||
|
||||
1. **EXPLICIT REASONING**:
|
||||
- Conclusions about the user that MUST be true given premises ONLY of the following types:
|
||||
- Most recent user message
|
||||
- Knowledge about the conversation history
|
||||
- Current date and time (which is: {message_created_at})
|
||||
- Timestamps from conversation history
|
||||
2. **DEDUCTIVE REASONING**:
|
||||
- Conclusions about the user that MUST be true given premises ONLY of the following types:
|
||||
- Explicit conclusions
|
||||
- Previous deductive conclusions
|
||||
- General, open domain knowledge known to be true
|
||||
- Current date and time (which is: {message_created_at})
|
||||
- Timestamps for user messages, and previous premises and conclusions
|
||||
|
||||
Here's the current user understanding
|
||||
<current_context>
|
||||
{context}
|
||||
</current_context>
|
||||
|
||||
Recent conversation history for context:
|
||||
<history>
|
||||
{history}
|
||||
</history>
|
||||
|
||||
New conversation turn to analyze:
|
||||
<new_turn>
|
||||
{new_turn}
|
||||
</new_turn>
|
||||
"""
|
||||
)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import asyncio
|
||||
import signal
|
||||
from _asyncio import Task
|
||||
from asyncio import Task
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
|
@ -60,6 +60,8 @@ class QueueManager:
|
|||
sentry_sdk.init(
|
||||
dsn=settings.SENTRY.DSN,
|
||||
enable_tracing=True,
|
||||
release=settings.SENTRY.RELEASE,
|
||||
environment=settings.SENTRY.ENVIRONMENT,
|
||||
traces_sample_rate=settings.SENTRY.TRACES_SAMPLE_RATE,
|
||||
profiles_sample_rate=settings.SENTRY.PROFILES_SAMPLE_RATE,
|
||||
integrations=[AsyncioIntegration()],
|
||||
|
|
@ -146,7 +148,9 @@ class QueueManager:
|
|||
Returns a list of WorkUnit objects.
|
||||
"""
|
||||
# Clean up stale work units
|
||||
five_minutes_ago = datetime.now(UTC) - timedelta(minutes=5)
|
||||
five_minutes_ago = datetime.now(UTC) - timedelta(
|
||||
minutes=settings.DERIVER.STALE_SESSION_TIMEOUT_MINUTES
|
||||
)
|
||||
await db.execute(
|
||||
delete(models.ActiveQueueSession).where(
|
||||
models.ActiveQueueSession.last_updated < five_minutes_ago
|
||||
|
|
@ -176,7 +180,7 @@ class QueueManager:
|
|||
)
|
||||
.where(~models.QueueItem.processed)
|
||||
.where(
|
||||
models.ActiveQueueSession.id == None # noqa: E711
|
||||
models.ActiveQueueSession.id.is_(None)
|
||||
) # Only work units not in active_queue_sessions
|
||||
.group_by(
|
||||
models.QueueItem.session_id,
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class DeriverQueuePayload(BaseModel):
|
||||
"""
|
||||
Schema for validating queue payload data.
|
||||
|
||||
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
|
||||
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')
|
||||
|
||||
Returns:
|
||||
Processed payload dictionary ready for queue processing
|
||||
|
||||
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")
|
||||
|
||||
# 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")
|
||||
|
||||
# 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"]
|
||||
|
||||
# Create and validate the payload using the schema
|
||||
try:
|
||||
validated_payload = DeriverQueuePayload(
|
||||
content=content,
|
||||
workspace_name=workspace_name,
|
||||
sender_name=sender_name,
|
||||
target_name=target_name,
|
||||
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
|
||||
|
||||
return payload
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
# Theory of Mind Inference
|
||||
[Theory of Mind](https://blog.plasticlabs.ai/blog/Theory-of-Mind-Is-All-You-Need) is a core principle behind Honcho: we believe that enabling AI agents to reason about users' mental states is essential if we want them to successfully act on our behalf.
|
||||
|
||||
Honcho currently features three different modules for theory of mind inference:
|
||||
- `conversational.py`: Inspired by our work on [metanarrative prompting](https://blog.plasticlabs.ai/blog/Agent-Identity). Uses a metanarrative prompt for both ToM inference and generating a user representation.
|
||||
- `single_prompt.py`: A more conventional and straightforward approach that specifies in a single system prompt what it wants the LLM to output.
|
||||
- `long_term.py`: Formats a theory of mind inference and a series of long-term facts into a user representation.
|
||||
|
||||
The current setup works as follows:
|
||||
- We extract facts from incoming messages using the code in `src.deriver.consumer`.
|
||||
- These messages get added to the protected `honcho` user collection using the `CollectionEmbeddingStore` in `src.deriver.tom.embeddings`.
|
||||
- The dialectic endpoint, in `src.agent`, retrieves long-term facts from this store that are relevant to the query, and runs the ToM inference in `src.deriver.tom.single_prompt` to generate a prediction of the user's short-term mental state.
|
||||
- The retrieved long-term facts and the short-term ToM inference are combined into a user representation. By default, this is done using a simple f-string, but they can optionally be combined using a separate inference, which would use `src.deriver.tom.long_term`.
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
from .conversational import (
|
||||
tom_inference_conversational,
|
||||
user_representation_conversational,
|
||||
)
|
||||
from .long_term import get_user_representation_long_term
|
||||
from .single_prompt import (
|
||||
TomInferenceOutput,
|
||||
UserRepresentationOutput,
|
||||
)
|
||||
from .single_prompt import (
|
||||
tom_inference as tom_inference_single_prompt,
|
||||
)
|
||||
from .single_prompt import (
|
||||
user_representation as user_representation_single_prompt,
|
||||
)
|
||||
|
||||
|
||||
async def get_tom_inference(
|
||||
chat_history: str,
|
||||
user_representation: str = "None",
|
||||
method: str = "conversational",
|
||||
) -> TomInferenceOutput:
|
||||
if method == "conversational":
|
||||
return await tom_inference_conversational(chat_history, user_representation)
|
||||
elif method == "single_prompt":
|
||||
return await tom_inference_single_prompt(chat_history, user_representation)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Invalid method: {method}")
|
||||
|
||||
|
||||
async def get_user_representation(
|
||||
chat_history: str,
|
||||
user_representation: str = "None",
|
||||
tom_inference: str = "None",
|
||||
method: str = "conversational",
|
||||
) -> UserRepresentationOutput:
|
||||
if method == "conversational":
|
||||
return await user_representation_conversational(
|
||||
chat_history, user_representation, tom_inference
|
||||
)
|
||||
elif method == "single_prompt":
|
||||
return await user_representation_single_prompt(
|
||||
chat_history, user_representation, tom_inference
|
||||
)
|
||||
elif method == "long_term":
|
||||
return await get_user_representation_long_term(
|
||||
chat_history, user_representation, tom_inference
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid method: {method}")
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,117 +0,0 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ... import crud, schemas
|
||||
from ...dependencies import tracked_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CollectionEmbeddingStore:
|
||||
def __init__(self, workspace_name: str, peer_name: str, collection_name: str):
|
||||
self.workspace_name: str = workspace_name
|
||||
self.peer_name: str = peer_name
|
||||
self.collection_name: str = collection_name
|
||||
|
||||
async def save_facts(
|
||||
self,
|
||||
facts: list[str],
|
||||
similarity_threshold: float = 0.85,
|
||||
message_id: int | None = None,
|
||||
) -> None:
|
||||
"""Save facts to the collection.
|
||||
|
||||
Args:
|
||||
facts: List of facts to save
|
||||
replace_duplicates: If True, replace old duplicates with new facts. If False, discard new duplicates
|
||||
similarity_threshold: Facts with similarity above this threshold are considered duplicates
|
||||
"""
|
||||
async with tracked_db("embedding_store.save_facts") as db:
|
||||
for fact in facts:
|
||||
# Create document with duplicate checking
|
||||
try:
|
||||
metadata: dict[str, Any] = {}
|
||||
if message_id is not None:
|
||||
metadata["message_id"] = message_id
|
||||
await crud.create_document(
|
||||
db,
|
||||
document=schemas.DocumentCreate(
|
||||
content=fact, metadata=metadata
|
||||
),
|
||||
workspace_name=self.workspace_name,
|
||||
peer_name=self.peer_name,
|
||||
collection_name=self.collection_name,
|
||||
duplicate_threshold=1
|
||||
- similarity_threshold, # Convert similarity to distance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating document: {e}")
|
||||
continue
|
||||
|
||||
async def get_relevant_facts(
|
||||
self, query: str, top_k: int = 5, max_distance: float = 0.3
|
||||
) -> list[str]:
|
||||
"""Retrieve the most relevant facts for a given query.
|
||||
|
||||
Args:
|
||||
query: The query text to find relevant facts for
|
||||
top_k: Maximum number of facts to return
|
||||
similarity_threshold: Minimum similarity score for a fact to be considered relevant
|
||||
|
||||
Returns:
|
||||
List of facts sorted by relevance
|
||||
"""
|
||||
async with tracked_db("embedding_store.get_relevant_facts") as db:
|
||||
documents = await crud.query_documents(
|
||||
db,
|
||||
workspace_name=self.workspace_name,
|
||||
peer_name=self.peer_name,
|
||||
collection_name=self.collection_name,
|
||||
query=query,
|
||||
max_distance=max_distance,
|
||||
top_k=top_k,
|
||||
)
|
||||
|
||||
return [doc.content for doc in documents]
|
||||
|
||||
async def remove_duplicates(
|
||||
self, facts: list[str], similarity_threshold: float = 0.85
|
||||
) -> list[str]:
|
||||
"""Remove facts that are duplicates of existing facts in the vector store.
|
||||
|
||||
Args:
|
||||
facts: List of facts to check for duplicates
|
||||
similarity_threshold: Facts with similarity above this threshold are considered duplicates
|
||||
|
||||
Returns:
|
||||
List of facts that are not duplicates of existing facts
|
||||
"""
|
||||
unique_facts: list[str] = []
|
||||
|
||||
async with tracked_db("embedding_store.remove_duplicates") as db:
|
||||
for fact in facts:
|
||||
try:
|
||||
# Check for duplicates using the crud function
|
||||
duplicates = await crud.get_duplicate_documents(
|
||||
db,
|
||||
workspace_name=self.workspace_name,
|
||||
peer_name=self.peer_name,
|
||||
collection_name=self.collection_name,
|
||||
content=fact,
|
||||
similarity_threshold=similarity_threshold,
|
||||
)
|
||||
|
||||
if not duplicates:
|
||||
# No duplicates found, add to unique facts
|
||||
unique_facts.append(fact)
|
||||
else:
|
||||
# Log duplicate found
|
||||
logger.debug(
|
||||
f"Duplicate found: {duplicates[0].content}. Ignoring fact: {fact}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for duplicates: {e}")
|
||||
# If there's an error, still include the fact to avoid losing information
|
||||
unique_facts.append(fact)
|
||||
|
||||
return unique_facts
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
import logging
|
||||
|
||||
from mirascope import llm
|
||||
from mirascope.integrations.langfuse import with_langfuse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.config import settings
|
||||
from src.deriver.tom.single_prompt import UserRepresentationOutput
|
||||
from src.utils.clients import clients
|
||||
from src.utils.types import track
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PotentialSurprise(BaseModel):
|
||||
content: str
|
||||
reason: str
|
||||
confidence_level: float
|
||||
|
||||
|
||||
class UserRepresentation(BaseModel):
|
||||
current_state: str
|
||||
tentative_patterns: list[str]
|
||||
knowledge_gaps: list[str]
|
||||
expectation_violations: list[PotentialSurprise]
|
||||
updates: list[str]
|
||||
|
||||
|
||||
class InformationPiece(BaseModel):
|
||||
quote: str
|
||||
category: str
|
||||
explanation: str
|
||||
semantic_retrieval: str
|
||||
|
||||
|
||||
class InformationExtraction(BaseModel):
|
||||
pieces: list[InformationPiece]
|
||||
challenge: str
|
||||
|
||||
|
||||
class FactExtraction(BaseModel):
|
||||
information_extraction: InformationExtraction
|
||||
facts: list[str]
|
||||
|
||||
|
||||
@track("User Representation")
|
||||
@with_langfuse()
|
||||
@llm.call(
|
||||
provider=(
|
||||
settings.LLM.TOM_INFERENCE_PROVIDER
|
||||
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
|
||||
else "openai"
|
||||
),
|
||||
model=settings.LLM.TOM_INFERENCE_MODEL,
|
||||
response_model=UserRepresentationOutput,
|
||||
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
|
||||
)
|
||||
async def get_user_representation_long_term(
|
||||
chat_history: str,
|
||||
user_representation: str = "None",
|
||||
tom_inference: str = "None",
|
||||
facts: list[str] | None = None,
|
||||
):
|
||||
facts_str = "\n".join(f"- {fact}" for fact in facts) if facts else "None available"
|
||||
return f"""
|
||||
You are a system for maintaining factual user representations based on conversation history and theory of mind analysis.
|
||||
|
||||
Your job is to update the existing user representation (if provided) with the new information from the conversation history and theory of mind analysis.
|
||||
|
||||
REQUIREMENTS:
|
||||
1. Distinguish between temporary states and persistent patterns
|
||||
2. Only incorporate verified information into core profile
|
||||
3. Track certainty levels for all information
|
||||
4. Maintain areas of uncertainty explicitly
|
||||
5. Update representation incrementally
|
||||
6. Use the persistent facts provided to inform your understanding of the user
|
||||
|
||||
OUTPUT FORMAT:
|
||||
current_state: str
|
||||
- Active Context: Current situation/activity
|
||||
- Temporary Conditions: Immediate circumstances
|
||||
- Present Mood/Activity: What user is doing right now
|
||||
tentative_patterns: list[str]
|
||||
- Possible Traits: Mark confidence (Low/Medium/High)
|
||||
- Potential Interests: Need more evidence
|
||||
- Speculative Elements: Clearly marked as unconfirmed
|
||||
knowledge_gaps: list[str]
|
||||
- List key missing information
|
||||
- Note areas needing clarification
|
||||
expectation_violations: list
|
||||
content: str
|
||||
reason: str
|
||||
confidence_level: float
|
||||
- Based on the above information, if the next message were to surprise you, what could it contain?
|
||||
- Include 3-5 possible surprises
|
||||
updates: list[str]
|
||||
- New Information: Recent observations
|
||||
- Changes: Modified interpretations
|
||||
- Removals: Information no longer supported
|
||||
|
||||
CONVERSATION:
|
||||
{chat_history}
|
||||
|
||||
PREDICTION OF USER MENTAL STATE - MIGHT BE INCORRECT:
|
||||
{tom_inference or "Doesn't exist"}
|
||||
|
||||
EXISTING USER REPRESENTATION - INCOMPLETE, TO BE UPDATED:
|
||||
{user_representation or "Doesn't exist"}
|
||||
|
||||
PERSISTENT FACTS ABOUT USER:
|
||||
{facts_str}
|
||||
"""
|
||||
|
||||
|
||||
@track("Fact Extraction")
|
||||
@with_langfuse()
|
||||
@llm.call(
|
||||
provider=(
|
||||
settings.LLM.TOM_INFERENCE_PROVIDER
|
||||
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
|
||||
else "openai"
|
||||
),
|
||||
model=settings.LLM.TOM_INFERENCE_MODEL,
|
||||
response_model=FactExtraction,
|
||||
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
|
||||
)
|
||||
async def extract_facts_long_term(chat_history: str):
|
||||
return f"""
|
||||
You are an AI assistant specialized in extracting and formatting relevant information about users from conversations. Your task is to analyze a given conversation and create a list of concise, factual statements about the user. These statements will be stored in a vector embedding database to enhance future interactions.
|
||||
|
||||
Here is the conversation you need to analyze:
|
||||
|
||||
<conversation>
|
||||
{chat_history}
|
||||
</conversation>
|
||||
|
||||
Instructions:
|
||||
|
||||
1. Carefully read through the conversation. Extract only new facts, from only the last message sent by the user - treat the rest of the conversation only as context. Ignore facts in the last message that are already stated in the conversation.
|
||||
|
||||
2. Identify key new pieces of information from the last message sent by the user that would be valuable for future interactions. Look for:
|
||||
- Personal details (name, age, occupation, location, etc.)
|
||||
- Preferences (likes, dislikes, interests, hobbies)
|
||||
- Experiences (travel, education, work history)
|
||||
- Expressive style (writing style, tone, etc.)
|
||||
- Relationships (family, friends, pets)
|
||||
- Goals or aspirations
|
||||
- Challenges or problems they're facing
|
||||
- Opinions or beliefs
|
||||
|
||||
3. For each piece of information you identify:
|
||||
a. Verify that it is factual and explicitly stated in the conversation, not inferred.
|
||||
b. Formulate it as a concise statement that would aid in semantic retrieval.
|
||||
c. Ensure it is not similar to information previously stated in the conversation.
|
||||
|
||||
4. Before providing your final output, wrap your analysis in information_extraction. In this analysis:
|
||||
- List each piece of information you've identified.
|
||||
- For each piece of information:
|
||||
* Quote the relevant part of the conversation.
|
||||
* Categorize the information (e.g., personal detail, preference, experience).
|
||||
* Explain why you've included this information.
|
||||
* Show how you've formulated the fact for optimal semantic retrieval.
|
||||
- Discuss any challenges you encountered in extracting or formatting the information.
|
||||
|
||||
5. After your analysis, provide your final output as a list of strings. Each string should be a single fact about the user.
|
||||
|
||||
Remember to focus on clear, concise statements that capture key information about the user. Each fact should be worded in a way that will aid its semantic retrieval from a vector embedding database.
|
||||
"""
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
import logging
|
||||
from enum import Enum
|
||||
|
||||
from mirascope import llm
|
||||
from mirascope.integrations.langfuse import with_langfuse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.config import settings
|
||||
from src.utils.clients import clients
|
||||
from src.utils.types import track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Enums for strongly typed fields
|
||||
class InfoType(str, Enum):
|
||||
STYLE = "STYLE"
|
||||
STATEMENT = "STATEMENT"
|
||||
|
||||
|
||||
class CertaintyLevel(str, Enum):
|
||||
LIKELY = "LIKELY"
|
||||
POTENTIAL = "POTENTIAL"
|
||||
SPECULATIVE = "SPECULATIVE"
|
||||
|
||||
|
||||
# ToM Inference Output Models
|
||||
class CurrentState(BaseModel):
|
||||
immediate_context: str
|
||||
active_goals: str
|
||||
present_mood: str
|
||||
|
||||
|
||||
class SupportedObservation(BaseModel):
|
||||
detail: str
|
||||
source: str
|
||||
|
||||
|
||||
class TentativeInference(BaseModel):
|
||||
interpretation: str
|
||||
basis: str
|
||||
|
||||
|
||||
class KnowledgeGap(BaseModel):
|
||||
topic: str
|
||||
|
||||
|
||||
class ExpectationViolation(BaseModel):
|
||||
possible_surprise: str
|
||||
reason: str
|
||||
confidence_level: float
|
||||
|
||||
|
||||
class TomInferenceOutput(BaseModel):
|
||||
current_state: CurrentState
|
||||
tentative_inferences: list[TentativeInference]
|
||||
knowledge_gaps: list[KnowledgeGap]
|
||||
expectation_violations: list[ExpectationViolation]
|
||||
|
||||
|
||||
# User Representation Output Models
|
||||
class SourcedInfo(BaseModel):
|
||||
detail: str
|
||||
source: str
|
||||
|
||||
|
||||
class UserCurrentState(BaseModel):
|
||||
active_context: SourcedInfo
|
||||
temporary_conditions: SourcedInfo
|
||||
present_mood_activity: SourcedInfo
|
||||
|
||||
|
||||
class PersistentInfo(BaseModel):
|
||||
detail: str
|
||||
source: str
|
||||
info_type: InfoType
|
||||
|
||||
|
||||
class TentativePattern(BaseModel):
|
||||
pattern: str
|
||||
source: str
|
||||
certainty_level: CertaintyLevel
|
||||
|
||||
|
||||
class UserKnowledgeGap(BaseModel):
|
||||
missing_info: str
|
||||
|
||||
|
||||
class UserExpectationViolation(BaseModel):
|
||||
potential_surprise: str
|
||||
reason: str
|
||||
confidence_level: float
|
||||
|
||||
|
||||
class UpdateSection(BaseModel):
|
||||
new_information: list[SourcedInfo]
|
||||
changes: list[SourcedInfo]
|
||||
removals: list[SourcedInfo]
|
||||
|
||||
|
||||
class UserRepresentationOutput(BaseModel):
|
||||
current_state: UserCurrentState
|
||||
persistent_information: list[PersistentInfo]
|
||||
tentative_patterns: list[TentativePattern]
|
||||
knowledge_gaps: list[UserKnowledgeGap]
|
||||
expectation_violations: list[UserExpectationViolation]
|
||||
updates: UpdateSection
|
||||
|
||||
|
||||
@track("Tom Inference")
|
||||
@with_langfuse()
|
||||
@llm.call(
|
||||
provider=(
|
||||
settings.LLM.TOM_INFERENCE_PROVIDER
|
||||
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
|
||||
else "openai"
|
||||
),
|
||||
model=settings.LLM.TOM_INFERENCE_MODEL,
|
||||
response_model=TomInferenceOutput,
|
||||
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
|
||||
)
|
||||
async def tom_inference(
|
||||
chat_history: str,
|
||||
user_representation: str | None = None,
|
||||
):
|
||||
return f"""
|
||||
You are a system for analyzing conversations to make evidence-based inferences about user mental states.
|
||||
|
||||
REQUIREMENTS:
|
||||
1. Only make inferences that are directly supported by conversation evidence
|
||||
2. For each inference, cite the specific message that supports it
|
||||
3. Use uncertainty qualifiers (may, might, possibly) for speculative inferences
|
||||
4. Do not make assumptions about demographics unless explicitly stated
|
||||
5. Focus on current mental state and immediate context
|
||||
6. Consider your own knowledge gaps and violations of expectations (what would surprise you)
|
||||
|
||||
OUTPUT FORMAT:
|
||||
current_state:
|
||||
- immediate_context: User's current situation
|
||||
- active_goals: What user is trying to achieve
|
||||
- present_mood: Observable emotional state
|
||||
|
||||
tentative_inferences: list of objects with:
|
||||
- interpretation: Possible but uncertain interpretation
|
||||
- basis: Supporting message or evidence
|
||||
|
||||
knowledge_gaps: list of objects with:
|
||||
- topic: Important unknown information or question
|
||||
|
||||
expectation_violations: list of objects with:
|
||||
- possible_surprise: What content could surprise you in the next message
|
||||
- reason: Why this would be surprising based on current information
|
||||
- confidence_level: Float between 0.0 and 1.0 indicating confidence
|
||||
- Include 3-5 possible surprises
|
||||
|
||||
<conversation>
|
||||
{chat_history or "Not provided"}
|
||||
</conversation>
|
||||
|
||||
<user_representation>
|
||||
{user_representation or "Not provided"}
|
||||
</user_representation>
|
||||
"""
|
||||
|
||||
|
||||
@track("User Representation")
|
||||
@with_langfuse()
|
||||
@llm.call(
|
||||
provider=(
|
||||
settings.LLM.TOM_INFERENCE_PROVIDER
|
||||
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
|
||||
else "openai"
|
||||
),
|
||||
model=settings.LLM.TOM_INFERENCE_MODEL,
|
||||
response_model=UserRepresentationOutput,
|
||||
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
|
||||
)
|
||||
async def user_representation(
|
||||
chat_history: str,
|
||||
user_representation: str | None = None,
|
||||
tom_inference: str | None = None,
|
||||
):
|
||||
return f"""
|
||||
You are a system for maintaining factual user representations based on conversation history and theory of mind analysis.
|
||||
|
||||
Your job is to update the existing user representation (if provided) with the new information from the conversation history and theory of mind analysis.
|
||||
|
||||
Copy over information as-is from the existing user representation. Add new information as needed. Only remove content from this section if new information contradicts it. This is especially important for Persistent Information and Tentative Patterns.
|
||||
|
||||
REQUIREMENTS:
|
||||
1. Distinguish between temporary states and persistent patterns
|
||||
2. Only incorporate verified information into core profile
|
||||
3. Track certainty levels for all information
|
||||
4. Maintain areas of uncertainty explicitly
|
||||
5. Update representation incrementally
|
||||
|
||||
OUTPUT FORMAT:
|
||||
current_state:
|
||||
- active_context: object with "detail" (current situation/activity/location) and "source" (exact message)
|
||||
- temporary_conditions: object with "detail" (immediate circumstances) and "source" (exact message)
|
||||
- present_mood_activity: object with "detail" (what user is doing right now) and "source" (exact message)
|
||||
|
||||
persistent_information: list of objects with:
|
||||
- detail: The specific information or pattern
|
||||
- source: Exact message that supports this
|
||||
- info_type: Must be exactly "STYLE" for communication patterns or "STATEMENT" for explicit facts
|
||||
|
||||
tentative_patterns: list of objects with:
|
||||
- pattern: The observed pattern
|
||||
- source: Supporting evidence from specific message
|
||||
- certainty_level: Must be exactly "LIKELY" (almost certain), "POTENTIAL" (possible), or "SPECULATIVE" (uncertain)
|
||||
|
||||
knowledge_gaps: list of objects with:
|
||||
- missing_info: Key information that is missing or needs clarification
|
||||
|
||||
expectation_violations: list of objects with:
|
||||
- potential_surprise: What could surprise you in the next message
|
||||
- reason: Why this would be surprising based on current information
|
||||
- confidence_level: Float between 0.0 and 1.0
|
||||
- Include 3-5 possible surprises
|
||||
|
||||
updates:
|
||||
- new_information: List of objects with "detail" (recent observation) and "source" (supporting message)
|
||||
- changes: List of objects with "detail" (modified interpretation) and "source" (supporting message)
|
||||
- removals: List of objects with "detail" (information no longer supported) and "source" (contradicting message)
|
||||
|
||||
<conversation>
|
||||
{chat_history or "Not provided"}
|
||||
</conversation>
|
||||
|
||||
<existing_user_representation>
|
||||
{user_representation or "Not provided"}
|
||||
</existing_user_representation>
|
||||
|
||||
<tom_analysis>
|
||||
{tom_inference or "Not provided"}
|
||||
</tom_analysis>
|
||||
"""
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from .chat import chat
|
||||
|
||||
__all__ = ["chat"]
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
"""
|
||||
Main dialectic system for AI-powered context synthesis and user representation.
|
||||
|
||||
The Dialectic class provides a natural language API for AI applications to query
|
||||
and understand users through context synthesis of working representations and
|
||||
historical observations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from langfuse.decorators import langfuse_context
|
||||
from mirascope.llm import Stream
|
||||
|
||||
from src import crud
|
||||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.utils.clients import honcho_llm_call
|
||||
from src.utils.embedding_store import EmbeddingStore
|
||||
|
||||
from .prompts import dialectic_prompt
|
||||
from .utils import get_observations
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@honcho_llm_call(
|
||||
provider=settings.DIALECTIC.PROVIDER,
|
||||
model=settings.DIALECTIC.MODEL,
|
||||
track_name="Dialectic Call",
|
||||
max_tokens=settings.DIALECTIC.MAX_OUTPUT_TOKENS,
|
||||
thinking_budget_tokens=settings.DIALECTIC.THINKING_BUDGET_TOKENS
|
||||
if settings.DIALECTIC.PROVIDER == "anthropic"
|
||||
else None,
|
||||
enable_retry=True,
|
||||
retry_attempts=3,
|
||||
)
|
||||
async def dialectic_call(
|
||||
query: str,
|
||||
working_representation: str,
|
||||
additional_context: str | None,
|
||||
peer_name: str,
|
||||
):
|
||||
"""
|
||||
Make a direct call to the dialectic model for context synthesis.
|
||||
|
||||
Args:
|
||||
query: The user query
|
||||
working_representation: Current session conclusions
|
||||
additional_context: Historical context from semantic search
|
||||
peer_name: Name of the user/peer
|
||||
|
||||
Returns:
|
||||
Model response
|
||||
"""
|
||||
# Generate the prompt and log it
|
||||
prompt_result = dialectic_prompt(
|
||||
query, working_representation, additional_context, peer_name
|
||||
)
|
||||
|
||||
# Pretty print the prompt content
|
||||
if len(prompt_result) > 0:
|
||||
# Extract content from the first BaseMessageParam
|
||||
prompt_content = prompt_result[0].content
|
||||
else:
|
||||
prompt_content = str(prompt_result)
|
||||
|
||||
logger.debug("=== DIALECTIC PROMPT ===")
|
||||
logger.debug(prompt_content)
|
||||
logger.debug("=== END DIALECTIC PROMPT ===")
|
||||
|
||||
return prompt_result
|
||||
|
||||
|
||||
@honcho_llm_call(
|
||||
provider=settings.DIALECTIC.PROVIDER,
|
||||
model=settings.DIALECTIC.MODEL,
|
||||
track_name="Dialectic Stream",
|
||||
max_tokens=settings.DIALECTIC.MAX_OUTPUT_TOKENS,
|
||||
thinking_budget_tokens=settings.DIALECTIC.THINKING_BUDGET_TOKENS
|
||||
if settings.DIALECTIC.PROVIDER == "anthropic"
|
||||
else None,
|
||||
enable_retry=True,
|
||||
retry_attempts=3,
|
||||
stream=True,
|
||||
)
|
||||
async def dialectic_stream(
|
||||
query: str,
|
||||
working_representation: str,
|
||||
additional_context: str | None,
|
||||
peer_name: str,
|
||||
):
|
||||
"""
|
||||
Make a streaming call to the dialectic model for context synthesis.
|
||||
|
||||
Args:
|
||||
query: The user query
|
||||
working_representation: Current session conclusions
|
||||
additional_context: Historical context from semantic search
|
||||
peer_name: Name of the user/peer
|
||||
|
||||
Returns:
|
||||
Streaming model response
|
||||
"""
|
||||
# Generate the prompt and log it
|
||||
prompt_result = dialectic_prompt(
|
||||
query, working_representation, additional_context, peer_name
|
||||
)
|
||||
|
||||
# Pretty print the prompt content
|
||||
if len(prompt_result) > 0:
|
||||
# Extract content from the first BaseMessageParam
|
||||
prompt_content = prompt_result[0].content
|
||||
else:
|
||||
prompt_content = str(prompt_result)
|
||||
|
||||
logger.debug("=== DIALECTIC PROMPT (STREAM) ===")
|
||||
logger.debug(prompt_content)
|
||||
logger.debug("=== END DIALECTIC PROMPT ===")
|
||||
|
||||
return prompt_result
|
||||
|
||||
|
||||
async def chat(
|
||||
workspace_name: str,
|
||||
peer_name: str,
|
||||
target_name: str | None,
|
||||
session_name: str | None,
|
||||
query: str,
|
||||
*,
|
||||
stream: bool = False,
|
||||
) -> Stream | str:
|
||||
"""
|
||||
Chat with the Dialectic API that builds on-demand user representations.
|
||||
|
||||
Steps:
|
||||
1. Get working representation from deriver trace
|
||||
2. Retrieve additional relevant context via semantic search
|
||||
3. (New) Append observations from latest deriver trace into that context
|
||||
4. Call Dialectic to synthesize an answer
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
peer_name: Name of the peer making the query
|
||||
target_name: Optional name of the peer being queried about
|
||||
session_name: Optional session name for scoping
|
||||
query: Input Dialectic Query
|
||||
stream: Whether to stream the response
|
||||
|
||||
Returns:
|
||||
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,
|
||||
}
|
||||
)
|
||||
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:
|
||||
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
|
||||
)
|
||||
else:
|
||||
# For global queries, working representation isn't useful - use historical context instead
|
||||
working_representation = ""
|
||||
|
||||
logger.debug(f"Working representation length: {len(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"
|
||||
)
|
||||
|
||||
# 3. Dialectic call --------------------------------------------------------
|
||||
if stream:
|
||||
return await dialectic_stream(
|
||||
query, working_representation, additional_context, peer_name
|
||||
)
|
||||
|
||||
response = await dialectic_call(
|
||||
query, working_representation, additional_context, peer_name
|
||||
)
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
logger.debug(f"Dialectic answered in {elapsed:.2f}s")
|
||||
# Convert AnthropicCallResponse to string for compatibility
|
||||
return str(response)
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
from inspect import cleandoc as c
|
||||
|
||||
from mirascope import prompt_template
|
||||
|
||||
|
||||
@prompt_template()
|
||||
def dialectic_prompt(
|
||||
query: str,
|
||||
working_representation: str,
|
||||
additional_context: str | None,
|
||||
peer_name: str,
|
||||
) -> str:
|
||||
"""
|
||||
Generate the main dialectic prompt for context synthesis.
|
||||
|
||||
Args:
|
||||
query: The specific question or request from the application about the user
|
||||
working_representation: Current session conclusions from recent conversation analysis
|
||||
additional_context: Historical conclusions from the user's global representation
|
||||
peer_name: Name of the user/peer being queried about
|
||||
|
||||
Returns:
|
||||
Formatted prompt string for the dialectic model
|
||||
"""
|
||||
return c(
|
||||
f"""
|
||||
The query is about user {peer_name}.
|
||||
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
|
||||
|
||||
You receive three key inputs:
|
||||
- **Query**: The specific question or request from the application about this user
|
||||
- **Working Representation**: Current session conclusions from recent conversation analysis
|
||||
- **Additional Context**: Historical conclusions from the user's global representation
|
||||
|
||||
Each conclusion contains:
|
||||
- **Conclusion**: The derived insight
|
||||
- **Premises**: Supporting evidence/reasoning
|
||||
- **Type**: Either Explicit or Deductive
|
||||
- **Temporal Data**: When conclusions were made
|
||||
|
||||
## OUTPUT FORMAT
|
||||
|
||||
Provide a natural language response that:
|
||||
1. Directly answers the application's query
|
||||
2. Provides most useful context based on available conclusions
|
||||
3. References the reasoning types and evidence strength when relevant
|
||||
4. Maintains appropriate confidence levels based on conclusion types
|
||||
5. Flags any limitations or gaps in available information
|
||||
|
||||
<query>{query}</query>
|
||||
<working_representation>{working_representation}</working_representation>
|
||||
{f"<global_context>{additional_context}</global_context>" if additional_context else ""}"""
|
||||
)
|
||||
|
||||
|
||||
@prompt_template()
|
||||
def query_generation_prompt(query: str, peer_name: str) -> str:
|
||||
"""
|
||||
Generate the prompt for semantic query expansion.
|
||||
|
||||
Args:
|
||||
query: The original user query
|
||||
peer_name: Name of the user/peer
|
||||
|
||||
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.
|
||||
|
||||
## QUERY EXPANSION STRATEGY FOR SEMANTIC SIMILARITY
|
||||
|
||||
**Your Goal**: Generate 3-5 complementary search queries optimized for semantic similarity retrieval, that together will surface the most relevant observations to help answer the application's question.
|
||||
|
||||
**Semantic Similarity Optimization**:
|
||||
|
||||
1. **Analyze the Application Query**: What specific aspect of the user does the application want to understand?
|
||||
2. **Think Conceptually**: What concepts, themes, and semantic fields relate to this question?
|
||||
3. **Consider Language Patterns in Stored Observations**: Loosely match the structure of the observations we aim to retrieve - "[subject] [verb] [predicate] [additional context]" (e.g. "Mary went ice-skating with Peter and Lin on June 5th 2024", "John activities summer outdoors")
|
||||
4. **Vary Semantic Scope** across the generated queries to ensure maximum coverage.
|
||||
5. Ensure the queries are different enough to not be redundant.
|
||||
|
||||
**Vocabulary Expansion Techniques**:
|
||||
|
||||
- **Synonyms**: feedback/criticism/advice/suggestions/input/guidance
|
||||
- **Related Actions**: receiving/getting/handling/processing/responding/reacting
|
||||
- **Emotional Language**: sensitive/defensive/receptive/open/resistant/welcoming
|
||||
- **Contextual Terms**: workplace/professional/personal/relationship/dynamic/interaction
|
||||
- **Intensity Variations**: harsh/gentle/direct/subtle/constructive/blunt
|
||||
- **Outcome Language**: improvement/growth/learning/development/change
|
||||
|
||||
**Remember**: Since observations come from natural conversations, use the vocabulary people actually use when discussing these topics, including casual language, emotional descriptors, and situational context.
|
||||
|
||||
## OUTPUT FORMAT
|
||||
|
||||
Respond with 3-5 search queries as a JSON object with a "queries" field containing an array of strings. Each query should target different aspects or reasoning levels to maximize retrieval coverage.
|
||||
|
||||
Format: `{{"queries": ["query1", "query2", "query3"]}}`
|
||||
|
||||
No markdown, no explanations, just the JSON object.
|
||||
|
||||
<query>{query}</query>
|
||||
"""
|
||||
)
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langfuse.decorators import langfuse_context, observe # pyright: ignore
|
||||
|
||||
from src.config import settings
|
||||
from src.models import Document
|
||||
from src.utils.clients import honcho_llm_call
|
||||
from src.utils.embedding_store import EmbeddingStore
|
||||
from src.utils.formatting import (
|
||||
format_premises_for_display,
|
||||
)
|
||||
from src.utils.shared_models import SemanticQueries
|
||||
|
||||
from .prompts import query_generation_prompt
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@observe()
|
||||
async def get_observations(
|
||||
query: 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.
|
||||
|
||||
Uses semantic search to find additional relevant historical context beyond
|
||||
what's already in the working representation.
|
||||
|
||||
Args:
|
||||
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
|
||||
)
|
||||
unique_observations = filtered_observations
|
||||
elif include_session_name:
|
||||
filtered_observations = _filter_all_but_current_session_observations(
|
||||
unique_observations, include_session_name
|
||||
)
|
||||
unique_observations = filtered_observations
|
||||
else:
|
||||
filtered_observations = unique_observations
|
||||
|
||||
logger.info(
|
||||
"After session filtering: %s observations (removed %s observations)",
|
||||
len(unique_observations),
|
||||
original_count - len(unique_observations),
|
||||
)
|
||||
|
||||
# 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
|
||||
) -> list[tuple[str, str, dict[str, Any]]]:
|
||||
"""Execute a single semantic search query and return formatted results."""
|
||||
documents: list[Document] = await embedding_store.get_relevant_observations(
|
||||
query,
|
||||
top_k=settings.DIALECTIC.SEMANTIC_SEARCH_TOP_K,
|
||||
max_distance=settings.DIALECTIC.SEMANTIC_SEARCH_MAX_DISTANCE,
|
||||
for_reasoning=False,
|
||||
)
|
||||
|
||||
# Extract data to avoid DetachedInstanceError
|
||||
return [
|
||||
(
|
||||
doc.content,
|
||||
doc.created_at.strftime("%Y-%m-%d-%H:%M:%S"),
|
||||
doc.internal_metadata or {},
|
||||
)
|
||||
for doc in documents
|
||||
]
|
||||
|
||||
|
||||
def _deduplicate_observations(
|
||||
all_results: list[list[tuple[str, str, dict[str, Any]]]],
|
||||
) -> list[tuple[str, str, dict[str, Any]]]:
|
||||
"""Deduplicate observations based on content."""
|
||||
unique_observations: list[tuple[str, str, dict[str, Any]]] = []
|
||||
seen_content: set[str] = set()
|
||||
|
||||
for results in all_results:
|
||||
for content, timestamp, metadata in results:
|
||||
if content not in seen_content:
|
||||
unique_observations.append((content, timestamp, metadata))
|
||||
seen_content.add(content)
|
||||
|
||||
return unique_observations
|
||||
|
||||
|
||||
def _format_observations(
|
||||
observations: list[tuple[str, str, dict[str, Any]]], *, include_premises: bool
|
||||
) -> str:
|
||||
"""Format observations grouped by level and date, including access metadata."""
|
||||
grouped: dict[str, dict[str, list[str]]] = {}
|
||||
|
||||
for content, timestamp, metadata in observations:
|
||||
level: str = metadata.get("level", "unknown")
|
||||
date_str: str = timestamp[:10] # Extract YYYY-MM-DD
|
||||
|
||||
if level not in grouped:
|
||||
grouped[level] = {}
|
||||
if date_str not in grouped[level]:
|
||||
grouped[level][date_str] = []
|
||||
|
||||
# Build formatted content with premises and access metadata
|
||||
formatted_content: str = content
|
||||
|
||||
# Add premises if requested and available
|
||||
if include_premises and metadata.get("premises"):
|
||||
premises_text: str = format_premises_for_display(metadata["premises"])
|
||||
formatted_content = f"{content}{premises_text}"
|
||||
|
||||
# Prefix with full timestamp for clarity
|
||||
if timestamp:
|
||||
formatted_content = f"{timestamp}: {formatted_content}"
|
||||
|
||||
# Add access metadata if available
|
||||
access_parts: list[str] = []
|
||||
access_count: int = metadata.get("access_count", 0)
|
||||
last_accessed: Any = metadata.get("last_accessed")
|
||||
|
||||
if access_count > 0:
|
||||
access_parts.append(f"accessed {access_count}x")
|
||||
|
||||
if last_accessed:
|
||||
# Format the last_accessed datetime for display
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
if isinstance(last_accessed, str):
|
||||
# Parse ISO format datetime string
|
||||
dt = datetime.fromisoformat(last_accessed.replace("Z", "+00:00"))
|
||||
formatted_last_accessed: str = dt.strftime("%Y-%m-%d %H:%M")
|
||||
access_parts.append(f"last accessed {formatted_last_accessed}")
|
||||
except (ValueError, AttributeError):
|
||||
# If parsing fails, just show the raw value
|
||||
access_parts.append(f"last accessed {last_accessed}")
|
||||
|
||||
# Append access metadata to the formatted content
|
||||
if access_parts:
|
||||
access_info: str = ", ".join(access_parts)
|
||||
formatted_content = f"{formatted_content} [{access_info}]"
|
||||
|
||||
grouped[level][date_str].append(formatted_content)
|
||||
|
||||
# Build output
|
||||
parts: list[str] = []
|
||||
for level in sorted(grouped.keys()):
|
||||
header: str = (
|
||||
f"\n{level.upper()} OBSERVATIONS:"
|
||||
if level != "unknown"
|
||||
else "\nOBSERVATIONS:"
|
||||
)
|
||||
parts.append(header)
|
||||
|
||||
for date_str in sorted(
|
||||
grouped[level].keys(), reverse=True
|
||||
): # Most recent first
|
||||
parts.append(f"\n{date_str}:")
|
||||
for obs in grouped[level][date_str]:
|
||||
parts.append(f" • {obs}")
|
||||
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
@honcho_llm_call(
|
||||
provider=settings.DIALECTIC.QUERY_GENERATION_PROVIDER,
|
||||
model=settings.DIALECTIC.QUERY_GENERATION_MODEL,
|
||||
response_model=SemanticQueries,
|
||||
enable_retry=True,
|
||||
retry_attempts=3,
|
||||
)
|
||||
async def generate_semantic_queries(query: str, peer_name: str | None = None):
|
||||
"""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
|
||||
|
|
@ -31,9 +31,9 @@ class EmbeddingClient:
|
|||
raise ValueError("API key is required")
|
||||
self.client: AsyncOpenAI = AsyncOpenAI(api_key=api_key)
|
||||
self.encoding: tiktoken.Encoding = tiktoken.get_encoding("cl100k_base")
|
||||
self.max_embedding_tokens: int = settings.LLM.MAX_EMBEDDING_TOKENS
|
||||
self.max_embedding_tokens: int = settings.MAX_EMBEDDING_TOKENS
|
||||
self.max_embedding_tokens_per_request: int = (
|
||||
settings.LLM.MAX_EMBEDDING_TOKENS_PER_REQUEST
|
||||
settings.MAX_EMBEDDING_TOKENS_PER_REQUEST
|
||||
)
|
||||
|
||||
async def embed(self, query: str) -> list[float]:
|
||||
|
|
@ -226,3 +226,7 @@ def _chunk_text_with_tokens(
|
|||
for i in range(0, len(encoded_tokens), step_size)
|
||||
if i < len(encoded_tokens) # Ensure we don't create empty chunks
|
||||
]
|
||||
|
||||
|
||||
# Shared embedding client instance
|
||||
embedding_client = EmbeddingClient(settings.LLM.OPENAI_API_KEY)
|
||||
|
|
@ -71,3 +71,21 @@ class FilterError(HonchoException):
|
|||
|
||||
status_code = 422
|
||||
detail = "Invalid filter configuration"
|
||||
|
||||
|
||||
@final
|
||||
class UnsupportedFileTypeError(HonchoException):
|
||||
status_code = 415
|
||||
detail = "Unsupported file type"
|
||||
|
||||
|
||||
@final
|
||||
class FileTooLargeError(HonchoException):
|
||||
status_code = 413
|
||||
detail = "File too large"
|
||||
|
||||
|
||||
@final
|
||||
class FileProcessingError(HonchoException):
|
||||
status_code = 500
|
||||
detail = "File processing error"
|
||||
|
|
|
|||
10
src/main.py
10
src/main.py
|
|
@ -7,11 +7,9 @@ from typing import TYPE_CHECKING
|
|||
|
||||
import sentry_sdk
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi_pagination import add_pagination
|
||||
from pydantic import ValidationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sentry_sdk._types import Event, Hint
|
||||
|
|
@ -73,10 +71,8 @@ if SENTRY_ENABLED:
|
|||
def before_send(event: "Event", hint: "Hint") -> "Event | None":
|
||||
if "exc_info" in hint:
|
||||
_, exc_value, _ = hint["exc_info"]
|
||||
# Filter out exceptions that shouldn't be sent to Sentry
|
||||
if isinstance(
|
||||
exc_value, HonchoException | ValidationError | RequestValidationError
|
||||
):
|
||||
# Filter out HonchoExceptions from being sent to Sentry
|
||||
if isinstance(exc_value, HonchoException):
|
||||
return None
|
||||
|
||||
return event
|
||||
|
|
@ -119,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.0.5",
|
||||
version="2.1.0",
|
||||
contact={
|
||||
"name": "Plastic Labs",
|
||||
"url": "https://honcho.dev",
|
||||
|
|
|
|||
|
|
@ -177,6 +177,11 @@ class Message(Base):
|
|||
public_id: Mapped[str] = mapped_column(
|
||||
TEXT, index=True, unique=True, default=generate_nanoid
|
||||
)
|
||||
# NOTE: Messages in Honcho 2.0 could historically be stored outside of a session.
|
||||
# This is no longer the case, so in the future `session_name` will be required.
|
||||
# Peer-level search will be able to retrieve any message with peer as author and
|
||||
# derived facts are retained, so these messages are not abandoned. A future migration
|
||||
# may assign them all to a default session of some kind.
|
||||
session_name: Mapped[str | None] = mapped_column(index=True, nullable=True)
|
||||
content: Mapped[str] = mapped_column(TEXT)
|
||||
h_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Path, Query
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Body,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
Path,
|
||||
Query,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlalchemy import apaginate
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql import insert
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from src import crud, schemas
|
||||
from src.config import settings
|
||||
from src.dependencies import db, tracked_db
|
||||
from src.exceptions import ResourceNotFoundException
|
||||
from src.models import QueueItem
|
||||
from src.dependencies import db
|
||||
from src.deriver import enqueue
|
||||
from src.exceptions import FileTooLargeError, ResourceNotFoundException
|
||||
from src.security import require_auth
|
||||
from src.utils.files import process_file_uploads_for_messages
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -25,286 +35,113 @@ router = APIRouter(
|
|||
)
|
||||
|
||||
|
||||
def create_processed_payload(
|
||||
message: dict[str, Any],
|
||||
sender_name: str | None,
|
||||
target_name: str | None,
|
||||
task_type: str,
|
||||
) -> 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 target peer
|
||||
task_type: Type of task ('representation' or 'summary')
|
||||
|
||||
Returns:
|
||||
Processed payload dictionary ready for queue processing
|
||||
"""
|
||||
processed_payload = {
|
||||
k: str(v) if isinstance(v, str) else v for k, v in message.items()
|
||||
}
|
||||
# Remove peer_name from payload
|
||||
processed_payload.pop("peer_name", None) # Use None as default to avoid KeyError
|
||||
processed_payload["sender_name"] = sender_name
|
||||
processed_payload["target_name"] = target_name
|
||||
processed_payload["task_type"] = task_type
|
||||
return processed_payload
|
||||
|
||||
|
||||
async def enqueue(payload: list[dict[str, Any]]):
|
||||
"""
|
||||
Add message(s) to the deriver queue for processing.
|
||||
|
||||
Args:
|
||||
payload: Single message payload or list of message payloads
|
||||
"""
|
||||
|
||||
# Use the get_db dependency to ensure proper transaction handling
|
||||
async with tracked_db("message_enqueue") as db_session:
|
||||
try:
|
||||
# Determine if batch or single processing
|
||||
if not payload: # Empty list check
|
||||
logger.debug("Empty payload list, skipping enqueue")
|
||||
return
|
||||
logger.debug(f"Enqueueing batch of {len(payload)} messages")
|
||||
workspace_name = payload[0]["workspace_name"]
|
||||
session_name = payload[0]["session_name"]
|
||||
|
||||
# Case 1: session_name is None — only create representation for peer
|
||||
if session_name is None:
|
||||
peer_name = payload[0]["peer_name"]
|
||||
logger.info(
|
||||
"Session name is None, creating single representation queue items"
|
||||
)
|
||||
peer = await crud.get_or_create_peers(
|
||||
db_session,
|
||||
workspace_name=workspace_name,
|
||||
peers=[schemas.PeerCreate(name=peer_name)],
|
||||
)
|
||||
peer = peer[0]
|
||||
|
||||
# Cast configuration to PeerConfig and check observe_me
|
||||
peer_config = (
|
||||
schemas.PeerConfig(**peer.configuration)
|
||||
if peer.configuration
|
||||
else schemas.PeerConfig()
|
||||
)
|
||||
if not peer_config.observe_me:
|
||||
logger.info(
|
||||
f"Peer {peer_name} has observe_me=False, skipping enqueue"
|
||||
)
|
||||
return
|
||||
|
||||
queue_records: list[dict[str, Any]] = []
|
||||
|
||||
for message in payload:
|
||||
processed_payload = create_processed_payload(
|
||||
message=message,
|
||||
sender_name=message["peer_name"],
|
||||
target_name=message["peer_name"],
|
||||
task_type="representation",
|
||||
)
|
||||
queue_records.append(
|
||||
{
|
||||
"payload": processed_payload,
|
||||
"session_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Inserting {len(queue_records)} queue records for None session"
|
||||
)
|
||||
stmt = insert(QueueItem).returning(QueueItem)
|
||||
await db_session.execute(stmt, queue_records)
|
||||
await db_session.commit()
|
||||
logger.info(
|
||||
f"Successfully enqueued {len(queue_records)} messages with None session"
|
||||
)
|
||||
return
|
||||
|
||||
# Case 2: Normal session processing
|
||||
session = await crud.get_or_create_session(
|
||||
db_session,
|
||||
session=schemas.SessionCreate(name=session_name),
|
||||
workspace_name=workspace_name,
|
||||
)
|
||||
|
||||
# Check if deriver is disabled for this session
|
||||
deriver_disabled = (
|
||||
session.configuration.get("deriver_disabled") is not None
|
||||
and session.configuration.get("deriver_disabled") is not False
|
||||
)
|
||||
|
||||
configuration_query = await crud.get_session_peer_configuration(
|
||||
workspace_name=workspace_name, session_name=session_name
|
||||
)
|
||||
peers_with_configuration_result = await db_session.execute(
|
||||
configuration_query
|
||||
)
|
||||
peers_with_configuration_list = peers_with_configuration_result.all()
|
||||
peers_with_configuration = {
|
||||
row.peer_name: [row.peer_configuration, row.session_peer_configuration]
|
||||
for row in peers_with_configuration_list
|
||||
}
|
||||
|
||||
# Process all payloads - create multiple queue items per message
|
||||
queue_records = []
|
||||
|
||||
for message in payload:
|
||||
if deriver_disabled:
|
||||
# still create a summary queue item for the session
|
||||
processed_payload = create_processed_payload(
|
||||
message=message,
|
||||
sender_name=None,
|
||||
target_name=None,
|
||||
task_type="summary",
|
||||
)
|
||||
queue_records.append(
|
||||
{
|
||||
"payload": processed_payload,
|
||||
"session_id": session.id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
sender_name = message["peer_name"]
|
||||
|
||||
sender_session_peer_config = (
|
||||
schemas.SessionPeerConfig(
|
||||
**peers_with_configuration[sender_name][1]
|
||||
)
|
||||
if peers_with_configuration[sender_name][1]
|
||||
else None
|
||||
)
|
||||
sender_peer_config = (
|
||||
schemas.PeerConfig(**peers_with_configuration[sender_name][0])
|
||||
if peers_with_configuration[sender_name][0]
|
||||
else schemas.PeerConfig()
|
||||
)
|
||||
|
||||
observe_me = (
|
||||
(
|
||||
sender_session_peer_config.observe_me
|
||||
if sender_session_peer_config.observe_me is not None
|
||||
else sender_peer_config.observe_me
|
||||
)
|
||||
if sender_session_peer_config
|
||||
else sender_peer_config.observe_me
|
||||
)
|
||||
if not observe_me:
|
||||
continue
|
||||
|
||||
# Handle working representation for sender
|
||||
processed_payload = create_processed_payload(
|
||||
message=message,
|
||||
sender_name=sender_name,
|
||||
target_name=sender_name,
|
||||
task_type="representation",
|
||||
)
|
||||
|
||||
queue_records.append(
|
||||
{
|
||||
"payload": processed_payload,
|
||||
"session_id": session.id,
|
||||
}
|
||||
)
|
||||
for peer_name, configuration in peers_with_configuration.items():
|
||||
session_peer_config = (
|
||||
schemas.SessionPeerConfig(**configuration[1])
|
||||
if configuration[1]
|
||||
else None
|
||||
)
|
||||
|
||||
if peer_name != sender_name:
|
||||
# Handle local representation for other peers
|
||||
if (
|
||||
session_peer_config is None
|
||||
or not session_peer_config.observe_others
|
||||
):
|
||||
continue
|
||||
else:
|
||||
# Create local representation for peer
|
||||
processed_payload = create_processed_payload(
|
||||
message=message,
|
||||
sender_name=sender_name,
|
||||
target_name=peer_name,
|
||||
task_type="representation",
|
||||
)
|
||||
|
||||
queue_records.append(
|
||||
{
|
||||
"payload": processed_payload,
|
||||
"session_id": session.id,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Inserting {len(queue_records)} queue records")
|
||||
|
||||
if len(queue_records) > 0:
|
||||
# Use insert to maintain order
|
||||
stmt = insert(QueueItem).returning(QueueItem)
|
||||
await db_session.execute(stmt, queue_records)
|
||||
await db_session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Successfully enqueued {len(payload)} messages with {len(queue_records)} total queue items"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to enqueue messages: {str(e)}", exc_info=True)
|
||||
if settings.SENTRY.ENABLED:
|
||||
import sentry_sdk
|
||||
|
||||
sentry_sdk.capture_exception(e)
|
||||
async def parse_upload_form(peer_id: str = Form(...)) -> schemas.MessageUploadCreate:
|
||||
"""Parse form data for file upload requests"""
|
||||
return schemas.MessageUploadCreate(peer_id=peer_id)
|
||||
|
||||
|
||||
@router.post("/", response_model=list[schemas.Message])
|
||||
async def create_messages_for_session(
|
||||
background_tasks: BackgroundTasks,
|
||||
workspace_id: str = Path(..., description="ID of the workspace"),
|
||||
session_id: str = Path(..., description="ID of the session"),
|
||||
messages: schemas.MessageBatchCreate = Body(
|
||||
..., description="Batch of messages to create"
|
||||
),
|
||||
messages: schemas.MessageBatchCreate,
|
||||
workspace_id: str = Path(...),
|
||||
session_id: str = Path(...),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
workspace_name, session_name = workspace_id, session_id
|
||||
"""Bulk create messages for a session while maintaining order. Maximum 100 messages per batch."""
|
||||
try:
|
||||
created_messages = await crud.create_messages(
|
||||
db,
|
||||
messages=messages.messages,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
"""Create messages for a session with JSON data (original functionality)."""
|
||||
|
||||
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.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
|
||||
|
||||
|
||||
@router.post("/upload", response_model=list[schemas.Message])
|
||||
async def create_messages_with_file(
|
||||
background_tasks: BackgroundTasks,
|
||||
workspace_id: str = Path(...),
|
||||
session_id: str = Path(...),
|
||||
form_data: schemas.MessageUploadCreate = Depends(parse_upload_form),
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Create messages from uploaded files. Files are converted to text and split into multiple messages."""
|
||||
|
||||
# Validate file size
|
||||
if file.size and file.size > settings.MAX_FILE_SIZE:
|
||||
raise FileTooLargeError(
|
||||
f"File size ({file.size} bytes) exceeds maximum allowed size ({settings.MAX_FILE_SIZE} bytes)",
|
||||
)
|
||||
|
||||
# Create payloads for all messages
|
||||
payloads = [
|
||||
{
|
||||
"workspace_name": workspace_name,
|
||||
"session_name": session_name,
|
||||
"message_id": message.id,
|
||||
"content": message.content,
|
||||
"peer_name": message.peer_name,
|
||||
}
|
||||
for message in created_messages
|
||||
]
|
||||
# Process files using shared utility function
|
||||
all_message_data = await process_file_uploads_for_messages(
|
||||
file=file,
|
||||
peer_id=form_data.peer_id,
|
||||
)
|
||||
|
||||
# Enqueue all messages in one call
|
||||
background_tasks.add_task(enqueue, payloads) # type: ignore
|
||||
logger.info(
|
||||
f"Batch of {len(created_messages)} messages created and queued for processing"
|
||||
)
|
||||
# Create messages
|
||||
message_creates = [item["message_create"] for item in all_message_data]
|
||||
created_messages = await crud.create_messages(
|
||||
db,
|
||||
messages=message_creates,
|
||||
workspace_name=workspace_id,
|
||||
session_name=session_id,
|
||||
)
|
||||
|
||||
return created_messages
|
||||
except ValueError as e:
|
||||
logger.error(
|
||||
f"Failed to create batch messages for session {session_id}: {str(e)}"
|
||||
)
|
||||
raise ResourceNotFoundException("Session not found") from e
|
||||
# Update internal_metadata for file-related messages
|
||||
for i, message in enumerate(created_messages):
|
||||
file_metadata = all_message_data[i]["file_metadata"]
|
||||
message.internal_metadata.update(file_metadata)
|
||||
flag_modified(message, "internal_metadata")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Enqueue for processing (same as regular messages)
|
||||
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
|
||||
]
|
||||
|
||||
background_tasks.add_task(enqueue, payloads)
|
||||
logger.info(
|
||||
f"Batch of {len(created_messages)} messages created from file uploads and queued for processing"
|
||||
)
|
||||
|
||||
return created_messages
|
||||
|
||||
|
||||
@router.post("/list", response_model=Page[schemas.Message])
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Body,
|
||||
Depends,
|
||||
Path,
|
||||
Query,
|
||||
)
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -15,13 +14,10 @@ from fastapi_pagination.ext.sqlalchemy import apaginate
|
|||
from mirascope.llm import Stream
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import agent, crud, schemas
|
||||
from src import crud, schemas
|
||||
from src.dependencies import db
|
||||
from src.exceptions import (
|
||||
AuthenticationException,
|
||||
ResourceNotFoundException,
|
||||
)
|
||||
from src.routers.messages import enqueue
|
||||
from src.dialectic import chat as dialectic_chat
|
||||
from src.exceptions import AuthenticationException, ResourceNotFoundException
|
||||
from src.security import JWTParams, require_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -171,24 +167,25 @@ async def chat(
|
|||
)
|
||||
|
||||
if not options.stream:
|
||||
return await agent.chat(
|
||||
workspace_id,
|
||||
peer_id,
|
||||
options.session_id,
|
||||
options.queries,
|
||||
options.stream,
|
||||
options.target,
|
||||
response = await dialectic_chat(
|
||||
workspace_name=workspace_id,
|
||||
peer_name=peer_id,
|
||||
target_name=options.target,
|
||||
session_name=options.session_id,
|
||||
query=options.query,
|
||||
stream=options.stream,
|
||||
)
|
||||
return schemas.DialecticResponse(content=str(response))
|
||||
|
||||
async def parse_stream():
|
||||
async def parse_stream() -> AsyncGenerator[str, None]:
|
||||
try:
|
||||
stream = await agent.chat(
|
||||
workspace_id,
|
||||
peer_id,
|
||||
options.session_id,
|
||||
options.queries,
|
||||
stream=True,
|
||||
target=options.target,
|
||||
stream = await dialectic_chat(
|
||||
workspace_name=workspace_id,
|
||||
peer_name=peer_id,
|
||||
target_name=options.target,
|
||||
session_name=options.session_id,
|
||||
query=options.query,
|
||||
stream=options.stream,
|
||||
)
|
||||
if isinstance(stream, Stream):
|
||||
async for chunk, _ in stream:
|
||||
|
|
@ -204,96 +201,6 @@ async def chat(
|
|||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{peer_id}/messages",
|
||||
response_model=list[schemas.Message],
|
||||
dependencies=[
|
||||
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
|
||||
],
|
||||
)
|
||||
async def create_messages_for_peer(
|
||||
background_tasks: BackgroundTasks,
|
||||
workspace_id: str = Path(..., description="ID of the workspace"),
|
||||
peer_id: str = Path(..., description="ID of the peer"),
|
||||
messages: schemas.MessageBatchCreate = Body(
|
||||
..., description="Batch of messages to create"
|
||||
),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Create messages for a peer"""
|
||||
workspace_name, peer_name = workspace_id, peer_id
|
||||
"""Bulk create messages for a peer while maintaining order."""
|
||||
try:
|
||||
created_messages = await crud.create_messages_for_peer(
|
||||
db,
|
||||
messages=messages.messages,
|
||||
workspace_name=workspace_name,
|
||||
peer_name=peer_name,
|
||||
)
|
||||
|
||||
# Create payloads for all messages
|
||||
payloads = [
|
||||
{
|
||||
"workspace_name": workspace_name,
|
||||
"session_name": None,
|
||||
"message_id": message.id,
|
||||
"content": message.content,
|
||||
"peer_name": message.peer_name,
|
||||
}
|
||||
for message in created_messages
|
||||
]
|
||||
|
||||
# Enqueue all messages in one call
|
||||
background_tasks.add_task(enqueue, payloads) # type: ignore
|
||||
logger.info(
|
||||
f"Batch of {len(created_messages)} messages created and queued for processing"
|
||||
)
|
||||
|
||||
return created_messages
|
||||
except ValueError as e:
|
||||
logger.error(f"Failed to create batch messages for peer {peer_id}: {str(e)}")
|
||||
raise ResourceNotFoundException("Peer not found") from e
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{peer_id}/messages/list",
|
||||
response_model=Page[schemas.Message],
|
||||
dependencies=[
|
||||
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
|
||||
],
|
||||
)
|
||||
async def get_messages_for_peer(
|
||||
workspace_id: str = Path(..., description="ID of the workspace"),
|
||||
peer_id: str = Path(..., description="ID of the peer"),
|
||||
options: schemas.MessageGet | None = Body(
|
||||
None, description="Filtering options for the messages list"
|
||||
),
|
||||
reverse: bool | None = Query(
|
||||
False, description="Whether to reverse the order of results"
|
||||
),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Get all messages for a peer"""
|
||||
try:
|
||||
filters = None
|
||||
if options and hasattr(options, "filter"):
|
||||
filters = options.filter
|
||||
if filters == {}:
|
||||
filters = None
|
||||
|
||||
messages_query = await crud.get_messages_for_peer(
|
||||
workspace_name=workspace_id,
|
||||
peer_name=peer_id,
|
||||
filters=filters,
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
return await apaginate(db, messages_query)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to get messages for peer {peer_id}: {str(e)}")
|
||||
raise ResourceNotFoundException("Peer not found") from e
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{peer_id}/representation",
|
||||
response_model=dict[str, object],
|
||||
|
|
@ -312,14 +219,22 @@ async def get_working_representation(
|
|||
"""Get a peer's working representation for a session.
|
||||
|
||||
If a session_id is provided in the body, we get the working representation of the peer in that session.
|
||||
|
||||
In the current implementation, we don't offer representations of `target` so that parameter is ignored.
|
||||
Future releases will allow for this.
|
||||
If a target is provided, we get the representation of the target from the perspective of the peer.
|
||||
If no target is provided, we get the global representation of the peer.
|
||||
"""
|
||||
representation = await crud.get_working_representation(
|
||||
db, workspace_id, peer_id, options.session_id
|
||||
)
|
||||
return {"representation": representation}
|
||||
try:
|
||||
# If no target specified, get global representation (peer observing themselves)
|
||||
target_peer = options.target if options.target is not None else peer_id
|
||||
|
||||
representation = await crud.get_working_representation(
|
||||
db, workspace_id, peer_id, target_peer, options.session_id
|
||||
)
|
||||
return {"representation": representation}
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
f"Failed to get working representation for peer {peer_id}: {str(e)}"
|
||||
)
|
||||
raise ResourceNotFoundException("Peer or session not found") from e
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from src.exceptions import (
|
|||
ValidationException,
|
||||
)
|
||||
from src.security import JWTParams, require_auth
|
||||
from src.utils import history
|
||||
from src.utils import summarizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -388,7 +388,12 @@ async def get_session_context(
|
|||
summary_tokens = int(token_limit * 0.4) if summary else 0
|
||||
messages_tokens = token_limit - summary_tokens
|
||||
|
||||
# Get the messages to return verbatim
|
||||
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}"
|
||||
)
|
||||
|
||||
# Get the recent messages to return verbatim
|
||||
messages_stmt = await crud.get_messages(
|
||||
workspace_name=workspace_id,
|
||||
session_name=session_id,
|
||||
|
|
@ -397,42 +402,134 @@ async def get_session_context(
|
|||
result = await db.execute(messages_stmt)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
# Get the most recently created summary for the session
|
||||
last_summary = await history.get_summary(
|
||||
db,
|
||||
workspace_name=workspace_id,
|
||||
session_name=session_id,
|
||||
logger.info(
|
||||
f"Retrieved {len(messages)} recent messages for verbatim return (IDs: {[m.id for m in messages]})"
|
||||
)
|
||||
|
||||
# Get messages between the last summary and the first message we'll return verbatim, if any
|
||||
messages_before = await crud.get_messages_id_range(
|
||||
db,
|
||||
workspace_name=workspace_id,
|
||||
session_name=session_id,
|
||||
peer_name=None,
|
||||
start_id=last_summary["message_id"] if last_summary else 0,
|
||||
end_id=messages[0].id if messages else None,
|
||||
)
|
||||
summary_content = ""
|
||||
|
||||
# Make a summary if the user wants one
|
||||
if summary_tokens > 0:
|
||||
# Make a *new* summary if there are unsummarized messages between the last summary and the ones
|
||||
# we'll return verbatim, or if the last summary is too many tokens -- otherwise, just use the last summary
|
||||
if (
|
||||
not last_summary
|
||||
or len(messages_before) > 0
|
||||
or last_summary["token_count"] > summary_tokens
|
||||
):
|
||||
new_summary = await history.create_summary(
|
||||
messages=messages_before,
|
||||
max_tokens=summary_tokens,
|
||||
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']}"
|
||||
)
|
||||
summary_content = new_summary["content"]
|
||||
|
||||
# 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:
|
||||
summary_content = last_summary["content"]
|
||||
summary_tokens = last_summary["token_count"]
|
||||
# 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:
|
||||
summary_content = ""
|
||||
if summary_tokens == 0:
|
||||
logger.info("Summary not requested, returning messages only")
|
||||
else:
|
||||
logger.info("No messages available for summarization")
|
||||
|
||||
return schemas.SessionContext(
|
||||
name=session_id,
|
||||
|
|
|
|||
|
|
@ -114,30 +114,23 @@ async def search_workspace(
|
|||
)
|
||||
async def get_deriver_status(
|
||||
workspace_id: str = Path(..., description="ID of the workspace"),
|
||||
peer_id: str | None = Query(None, description="Optional peer ID to filter by"),
|
||||
observer_id: str | None = Query(
|
||||
None, description="Optional observer ID to filter by"
|
||||
),
|
||||
sender_id: str | None = Query(None, description="Optional sender ID to filter by"),
|
||||
session_id: str | None = Query(
|
||||
None, description="Optional session ID to filter by"
|
||||
),
|
||||
include_sender: bool = Query(
|
||||
False, description="Include work units triggered by this peer"
|
||||
),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Get the deriver processing status, optionally scoped to a peer and/or session"""
|
||||
# Validate that at least one of peer_id or session_id is provided
|
||||
if peer_id is None and session_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="At least one of 'peer_id' or 'session_id' must be provided",
|
||||
)
|
||||
|
||||
"""Get the deriver processing status, optionally scoped to an observer, sender, and/or session"""
|
||||
try:
|
||||
return await crud.get_deriver_status(
|
||||
db,
|
||||
workspace_name=workspace_id,
|
||||
peer_name=peer_id,
|
||||
observer_name=observer_id,
|
||||
sender_name=sender_id,
|
||||
session_name=session_id,
|
||||
include_sender=include_sender,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid request parameters: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from pydantic import (
|
|||
ConfigDict,
|
||||
Field,
|
||||
PrivateAttr,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
|
|
@ -143,6 +142,7 @@ class Message(MessageBase):
|
|||
public_id: str = Field(serialization_alias="id")
|
||||
content: str
|
||||
peer_name: str = Field(serialization_alias="peer_id")
|
||||
# NOTE: Messages in Honcho 2.0 could historically be stored outside of a session. See models.py for more details.
|
||||
session_name: str | None = Field(serialization_alias="session_id")
|
||||
h_metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, serialization_alias="metadata"
|
||||
|
|
@ -162,6 +162,14 @@ class MessageBatchCreate(BaseModel):
|
|||
messages: list[MessageCreate] = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class MessageUploadCreate(BaseModel):
|
||||
"""Schema for message creation from file uploads"""
|
||||
|
||||
peer_id: str = Field(..., description="ID of the peer creating the message")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
|
||||
|
||||
|
||||
class SessionBase(BaseModel):
|
||||
pass
|
||||
|
||||
|
|
@ -253,23 +261,11 @@ class DialecticOptions(BaseModel):
|
|||
None,
|
||||
description="Optional peer to get the representation for, from the perspective of this peer",
|
||||
)
|
||||
queries: str | list[str]
|
||||
query: Annotated[
|
||||
str, Field(min_length=1, max_length=10000, description="Dialectic API Prompt")
|
||||
]
|
||||
stream: bool = False
|
||||
|
||||
@field_validator("queries")
|
||||
def validate_queries(cls, v: str | list[str]) -> str | list[str]:
|
||||
MAX_STRING_LENGTH = 10000
|
||||
MAX_LIST_LENGTH = 25
|
||||
if isinstance(v, str):
|
||||
if len(v) > MAX_STRING_LENGTH:
|
||||
raise ValueError("Query too long")
|
||||
else:
|
||||
if len(v) > MAX_LIST_LENGTH:
|
||||
raise ValueError("Too many queries")
|
||||
if any(len(q) > MAX_STRING_LENGTH for q in v):
|
||||
raise ValueError("One or more queries too long")
|
||||
return v
|
||||
|
||||
|
||||
class DialecticResponse(BaseModel):
|
||||
content: str
|
||||
|
|
@ -330,10 +326,6 @@ class MessageBulkData(BaseModel):
|
|||
|
||||
|
||||
class SessionDeriverStatus(BaseModel):
|
||||
peer_id: str | None = Field(
|
||||
default=None,
|
||||
description="ID of the peer (optional when filtering by session only)",
|
||||
)
|
||||
session_id: str | None = Field(
|
||||
default=None, description="Session ID if filtered by session"
|
||||
)
|
||||
|
|
@ -346,13 +338,6 @@ class SessionDeriverStatus(BaseModel):
|
|||
|
||||
|
||||
class DeriverStatus(BaseModel):
|
||||
peer_id: str | None = Field(
|
||||
default=None,
|
||||
description="ID of the peer (optional when filtering by session only)",
|
||||
)
|
||||
session_id: str | None = Field(
|
||||
default=None, description="Session ID if filtered by session"
|
||||
)
|
||||
total_work_units: int = Field(description="Total work units")
|
||||
completed_work_units: int = Field(description="Completed work units")
|
||||
in_progress_work_units: int = Field(
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ def create_jwt(params: JWTParams) -> str:
|
|||
payload = {k: v for k, v in params.__dict__.items() if v is not None}
|
||||
if not settings.AUTH.JWT_SECRET:
|
||||
raise ValueError("AUTH_JWT_SECRET is not set, cannot create JWT.")
|
||||
return jwt.encode( # pyright: ignore
|
||||
return jwt.encode(
|
||||
payload, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithm="HS256"
|
||||
)
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ async def verify_jwt(token: str) -> JWTParams:
|
|||
try:
|
||||
if not settings.AUTH.JWT_SECRET:
|
||||
raise ValueError("AUTH_JWT_SECRET is not set, cannot verify JWT.")
|
||||
decoded = jwt.decode( # pyright: ignore
|
||||
decoded = jwt.decode(
|
||||
token, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithms=["HS256"]
|
||||
)
|
||||
if "t" in decoded:
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
"""
|
||||
Utility modules for the Honcho app.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def parse_xml_content(text: str, tag: str) -> str:
|
||||
"""
|
||||
Extract content from XML-like tags in a string.
|
||||
|
||||
Args:
|
||||
text: The text containing XML-like tags
|
||||
tag: The tag name to extract content from
|
||||
|
||||
Returns:
|
||||
The content between the opening and closing tags, or an empty string if not found
|
||||
"""
|
||||
pattern = f"<{tag}>(.*?)</{tag}>"
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
|
@ -1,7 +1,24 @@
|
|||
from collections.abc import Awaitable, Callable
|
||||
from typing import (
|
||||
Any,
|
||||
Literal,
|
||||
ParamSpec,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
overload,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from google import genai
|
||||
from groq import AsyncGroq
|
||||
from mirascope import llm
|
||||
from mirascope.integrations.langfuse import with_langfuse
|
||||
from mirascope.llm import Stream
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic import BaseModel
|
||||
from sentry_sdk.ai.monitoring import ai_track
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from src.config import settings
|
||||
from src.utils.types import Providers
|
||||
|
|
@ -31,3 +48,271 @@ if settings.LLM.GEMINI_API_KEY:
|
|||
if settings.LLM.GROQ_API_KEY:
|
||||
groq = AsyncGroq(api_key=settings.LLM.GROQ_API_KEY)
|
||||
clients["groq"] = groq
|
||||
|
||||
providers = [
|
||||
("Dialectic", settings.DIALECTIC.PROVIDER),
|
||||
("Summary", settings.SUMMARY.PROVIDER),
|
||||
("Deriver", settings.DERIVER.PROVIDER),
|
||||
("Query Generation Provider", settings.DIALECTIC.QUERY_GENERATION_PROVIDER),
|
||||
]
|
||||
|
||||
for provider_name, provider_value in providers:
|
||||
if provider_value not in clients:
|
||||
raise ValueError(f"Missing client for {provider_name}: {provider_value}")
|
||||
|
||||
P = ParamSpec("P")
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
T_co = TypeVar("T_co", bound=BaseModel, covariant=True)
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
# Define protocols for different return types
|
||||
@runtime_checkable
|
||||
class AsyncResponseModelCallable(Protocol[P, T_co]):
|
||||
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T_co: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SyncResponseModelCallable(Protocol[P, T_co]):
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T_co: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AsyncStreamCallable(Protocol[P]):
|
||||
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Stream: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SyncStreamCallable(Protocol[P]):
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Stream: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AsyncStringCallable(Protocol[P]):
|
||||
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> str: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SyncStringCallable(Protocol[P]):
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> str: ...
|
||||
|
||||
|
||||
# Overload for stream=True with async function
|
||||
@overload
|
||||
def honcho_llm_call(
|
||||
*,
|
||||
provider: Providers | None = None,
|
||||
model: str | None = None,
|
||||
track_name: str | None = None,
|
||||
response_model: type[BaseModel] | 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[True],
|
||||
**extra_call_params: Any,
|
||||
) -> Callable[[Callable[P, Awaitable[Any]]], AsyncStreamCallable[P]]: ...
|
||||
|
||||
|
||||
# Overload for response_model with async function
|
||||
@overload
|
||||
def honcho_llm_call(
|
||||
*,
|
||||
provider: Providers | None = None,
|
||||
model: str | None = None,
|
||||
track_name: str | None = None,
|
||||
response_model: type[T],
|
||||
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,
|
||||
**extra_call_params: Any,
|
||||
) -> Callable[[Callable[P, Awaitable[Any]]], AsyncResponseModelCallable[P, T]]: ...
|
||||
|
||||
|
||||
# Overload for no response_model with async function
|
||||
@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,
|
||||
**extra_call_params: Any,
|
||||
) -> Callable[[Callable[P, Awaitable[Any]]], AsyncStringCallable[P]]: ...
|
||||
|
||||
|
||||
# Generic overload for sync functions (fallback)
|
||||
@overload
|
||||
def honcho_llm_call(
|
||||
*,
|
||||
provider: Providers | None = None,
|
||||
model: str | None = None,
|
||||
track_name: str | None = None,
|
||||
response_model: type[BaseModel] | 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: bool = False,
|
||||
**extra_call_params: Any,
|
||||
) -> Callable[[Callable[P, Any]], Callable[P, Any]]: ...
|
||||
|
||||
|
||||
def honcho_llm_call(
|
||||
provider: Providers | None = None,
|
||||
model: str | None = None,
|
||||
track_name: str | None = None,
|
||||
response_model: type[BaseModel] | 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: bool = False,
|
||||
**extra_call_params: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Consolidated decorator for LLM calls that handles provider-specific configurations.
|
||||
|
||||
This decorator automatically:
|
||||
- Handles both sync and async functions seamlessly
|
||||
- Applies retry logic with exponential backoff
|
||||
- Adds AI tracking for Sentry
|
||||
- Integrates with Langfuse for observability
|
||||
- Builds provider-specific call parameters
|
||||
- Handles client selection from the global clients dict
|
||||
|
||||
Args:
|
||||
provider: The LLM provider to use (e.g., "anthropic", "google", "openai")
|
||||
model: The model to use
|
||||
track_name: Name for AI tracking (e.g., "Critical Analysis Call")
|
||||
response_model: Optional Pydantic model for structured responses
|
||||
json_mode: Whether to enable JSON mode (for providers that support it)
|
||||
max_tokens: Maximum tokens for the response
|
||||
thinking_budget_tokens: Budget for thinking tokens (Anthropic only)
|
||||
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)
|
||||
**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
|
||||
|
||||
Note: Type annotations may be needed at the call site for proper type checking.
|
||||
|
||||
Example (async function):
|
||||
@honcho_llm_call(
|
||||
provider=settings.DERIVER.PROVIDER,
|
||||
model=settings.DERIVER.MODEL,
|
||||
track_name="Critical Analysis Call",
|
||||
response_model=ReasoningResponse,
|
||||
json_mode=True,
|
||||
max_tokens=settings.DERIVER.MAX_OUTPUT_TOKENS,
|
||||
)
|
||||
async def analyze(context: str, query: str):
|
||||
return prompt_template(context, query)
|
||||
|
||||
Example (sync function):
|
||||
@honcho_llm_call(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
max_tokens=1000,
|
||||
)
|
||||
def generate_summary(text: str) -> str:
|
||||
return f"Summarize: {text}"
|
||||
|
||||
# Call synchronously
|
||||
result = generate_summary("Long text here...")
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
# Handle special case for custom provider
|
||||
# Custom providers use OpenAI-compatible endpoints, so we resolve to "openai" for the provider name
|
||||
# but keep the original "custom" for client lookup
|
||||
resolved_provider = "openai" if provider == "custom" else provider
|
||||
|
||||
# Build provider-specific call params
|
||||
call_params: dict[str, Any] = {}
|
||||
|
||||
if resolved_provider == "google":
|
||||
# Google uses 'config' parameter
|
||||
config: dict[str, Any] = {}
|
||||
if max_tokens:
|
||||
config["max_output_tokens"] = max_tokens
|
||||
if json_mode or response_model:
|
||||
config["response_mime_type"] = "application/json"
|
||||
if response_model:
|
||||
config["response_schema"] = response_model
|
||||
if config:
|
||||
call_params["config"] = config
|
||||
elif resolved_provider == "anthropic":
|
||||
# Anthropic uses thinking params and max_tokens
|
||||
if thinking_budget_tokens:
|
||||
call_params["thinking"] = {
|
||||
"type": "enabled",
|
||||
"budget_tokens": thinking_budget_tokens,
|
||||
}
|
||||
if max_tokens:
|
||||
call_params["max_tokens"] = max_tokens
|
||||
else:
|
||||
# Other providers just use max_tokens
|
||||
if max_tokens:
|
||||
call_params["max_tokens"] = max_tokens
|
||||
|
||||
# Merge with any extra call params
|
||||
call_params.update(extra_call_params)
|
||||
|
||||
# Build kwargs for llm.call
|
||||
llm_kwargs: dict[str, Any] = {}
|
||||
if resolved_provider and provider:
|
||||
llm_kwargs["provider"] = resolved_provider
|
||||
llm_kwargs["client"] = clients[
|
||||
provider
|
||||
] # Use original provider for client lookup
|
||||
if model:
|
||||
llm_kwargs["model"] = model
|
||||
if response_model:
|
||||
llm_kwargs["response_model"] = response_model
|
||||
if json_mode:
|
||||
llm_kwargs["json_mode"] = json_mode
|
||||
if stream:
|
||||
llm_kwargs["stream"] = stream
|
||||
if call_params:
|
||||
llm_kwargs["call_params"] = call_params
|
||||
|
||||
# Apply decorators in order
|
||||
decorated: Any = func
|
||||
|
||||
# Apply llm.call
|
||||
decorated = llm.call(**llm_kwargs)(decorated) # pyright: ignore
|
||||
|
||||
# Apply langfuse
|
||||
decorated = with_langfuse()(decorated) # pyright: ignore
|
||||
|
||||
# Apply AI tracking if name provided
|
||||
if track_name:
|
||||
decorated = ai_track(track_name)(decorated)
|
||||
|
||||
# Apply retry logic if enabled
|
||||
if enable_retry:
|
||||
decorated = retry(
|
||||
stop=stop_after_attempt(retry_attempts),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=10),
|
||||
)(decorated)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
|
|
|||
|
|
@ -0,0 +1,425 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
from langfuse.decorators import langfuse_context, observe # pyright: ignore
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmbeddingStore:
|
||||
"""Embedding store specialized for observation-based reasoning with structured metadata."""
|
||||
|
||||
def __init__(
|
||||
self, workspace_name: str, peer_name: str, collection_name: str
|
||||
) -> None:
|
||||
self.workspace_name: str = workspace_name
|
||||
self.peer_name: str = peer_name
|
||||
self.collection_name: str = collection_name
|
||||
# Initialize observation counts with config defaults
|
||||
self.explicit_observations_count: int = (
|
||||
settings.DERIVER.EXPLICIT_OBSERVATIONS_COUNT
|
||||
)
|
||||
self.deductive_observations_count: int = (
|
||||
settings.DERIVER.DEDUCTIVE_OBSERVATIONS_COUNT
|
||||
)
|
||||
|
||||
def set_observation_counts(
|
||||
self,
|
||||
explicit: int | None = None,
|
||||
deductive: int | None = None,
|
||||
) -> None:
|
||||
"""Set the number of observations to retrieve for each reasoning level.
|
||||
|
||||
Args:
|
||||
explicit: Number of explicit observations to retrieve
|
||||
deductive: Number of deductive observations to retrieve
|
||||
"""
|
||||
if explicit is not None:
|
||||
self.explicit_observations_count = explicit
|
||||
if deductive is not None:
|
||||
self.deductive_observations_count = deductive
|
||||
|
||||
@observe()
|
||||
async def save_unified_observations(
|
||||
self,
|
||||
observations: list[str] | list[Any],
|
||||
similarity_threshold: float = 0.85,
|
||||
message_id: str | None = None,
|
||||
level: str | None = None,
|
||||
session_name: str | None = None,
|
||||
message_created_at: datetime.datetime | None = None,
|
||||
) -> None:
|
||||
"""Save UnifiedObservation objects to the collection.
|
||||
|
||||
This method handles UnifiedObservation objects by:
|
||||
1. Generating embeddings only from conclusions
|
||||
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
|
||||
level: Reasoning level for the observations
|
||||
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)
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
langfuse_context.update_current_observation(
|
||||
input={
|
||||
"observations": [
|
||||
obs.model_dump() for obs in unified_observations
|
||||
]
|
||||
},
|
||||
output={"unique_conclusions": unique_conclusions},
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving unified observations: {e}")
|
||||
|
||||
@overload
|
||||
async def get_relevant_observations(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
top_k: int = 5,
|
||||
max_distance: float = 0.3,
|
||||
level: str | None = None,
|
||||
conversation_context: str = "",
|
||||
for_reasoning: Literal[True],
|
||||
) -> ObservationContext: ...
|
||||
|
||||
@overload
|
||||
async def get_relevant_observations(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
top_k: int = 5,
|
||||
max_distance: float = 0.3,
|
||||
level: str | None = None,
|
||||
conversation_context: str = "",
|
||||
for_reasoning: Literal[False],
|
||||
) -> list[models.Document]: ...
|
||||
|
||||
async def get_relevant_observations(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
top_k: int = 5,
|
||||
max_distance: float = 0.3,
|
||||
level: str | None = None,
|
||||
conversation_context: str = "",
|
||||
for_reasoning: bool = False,
|
||||
) -> list[models.Document] | ObservationContext:
|
||||
"""Unified method to get relevant observations with flexible options.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
top_k: Number of results to return
|
||||
max_distance: Maximum distance for semantic similarity
|
||||
level: Optional reasoning level to filter by
|
||||
conversation_context: Additional conversation context
|
||||
for_reasoning: If True, returns ObservationContext for ed reasoning
|
||||
|
||||
Returns:
|
||||
List of documents or ObservationContext (if for_reasoning=True)
|
||||
"""
|
||||
async with tracked_db("embedding_store.get_relevant_observations") as db:
|
||||
return await self._get_observations_internal(
|
||||
db,
|
||||
query,
|
||||
top_k,
|
||||
max_distance,
|
||||
level,
|
||||
conversation_context,
|
||||
for_reasoning,
|
||||
)
|
||||
|
||||
async def _get_observations_internal(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
top_k: int,
|
||||
max_distance: float,
|
||||
level: str | None,
|
||||
conversation_context: str,
|
||||
for_reasoning: bool,
|
||||
) -> Any:
|
||||
"""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,
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
else:
|
||||
documents = await crud.query_documents(
|
||||
db,
|
||||
workspace_name=self.workspace_name,
|
||||
peer_name=self.peer_name,
|
||||
collection_name=self.collection_name,
|
||||
query=query,
|
||||
max_distance=max_distance,
|
||||
top_k=top_k,
|
||||
)
|
||||
db.expunge_all()
|
||||
return list(documents)
|
||||
|
||||
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 _query_documents_for_level(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
level: str,
|
||||
conversation_context: str,
|
||||
max_distance: float,
|
||||
count: int,
|
||||
) -> list[models.Document]:
|
||||
"""Query documents for a specific level."""
|
||||
combined_query: str = (
|
||||
f"Current message: {query}\nContext: {conversation_context}"
|
||||
if conversation_context
|
||||
else query
|
||||
)
|
||||
|
||||
documents = await crud.query_documents(
|
||||
db,
|
||||
workspace_name=self.workspace_name,
|
||||
peer_name=self.peer_name,
|
||||
collection_name=self.collection_name,
|
||||
query=combined_query,
|
||||
max_distance=max_distance,
|
||||
top_k=count * 3,
|
||||
filters={"internal_metadata": {"level": level}},
|
||||
)
|
||||
|
||||
docs_list: list[models.Document] = list(documents)
|
||||
docs_sorted: list[models.Document] = sorted(
|
||||
docs_list, 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", "")
|
||||
metadata.summary_id = doc.internal_metadata.get("summary_id", "")
|
||||
metadata.message_id = doc.internal_metadata.get("message_id")
|
||||
metadata.level = doc.internal_metadata.get("level")
|
||||
metadata.session_name = doc.internal_metadata.get("session_name")
|
||||
metadata.premises = doc.internal_metadata.get("premises", [])
|
||||
return metadata
|
||||
|
||||
async def remove_duplicates(
|
||||
self,
|
||||
facts: list[str],
|
||||
*,
|
||||
similarity_threshold: float = 0.85,
|
||||
) -> list[str]:
|
||||
"""Remove duplicate observations based on similarity threshold.
|
||||
|
||||
Args:
|
||||
facts: List of observation strings
|
||||
similarity_threshold: Threshold for considering observations similar
|
||||
|
||||
Returns:
|
||||
List of unique observations
|
||||
"""
|
||||
unique_observations: list[str] = []
|
||||
async with tracked_db("embedding_store.remove_duplicates") as db:
|
||||
for observation in facts:
|
||||
# Check for similar existing observations
|
||||
documents = await crud.query_documents(
|
||||
db,
|
||||
workspace_name=self.workspace_name,
|
||||
peer_name=self.peer_name,
|
||||
collection_name=self.collection_name,
|
||||
query=observation,
|
||||
max_distance=1.0 - similarity_threshold,
|
||||
top_k=1,
|
||||
)
|
||||
|
||||
docs_list: list[models.Document] = list(documents)
|
||||
if not docs_list:
|
||||
unique_observations.append(observation)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Skipping duplicate observation: {observation[:50]}..."
|
||||
)
|
||||
return unique_observations
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
import logging
|
||||
from io import BytesIO
|
||||
from typing import Any, Protocol
|
||||
|
||||
import pdfplumber
|
||||
from fastapi import UploadFile
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy import Integer, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import schemas
|
||||
from src.exceptions import FileProcessingError, UnsupportedFileTypeError
|
||||
from src.schemas import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileProcessor(Protocol):
|
||||
async def extract_text(self, content: bytes) -> str: ...
|
||||
def supports_file_type(self, content_type: str) -> bool: ...
|
||||
|
||||
|
||||
class PDFProcessor:
|
||||
def supports_file_type(self, content_type: str) -> bool:
|
||||
return content_type == "application/pdf"
|
||||
|
||||
async def extract_text(self, content: bytes) -> str:
|
||||
with pdfplumber.open(BytesIO(content)) as pdf_reader:
|
||||
text_parts: list[str] = []
|
||||
for page_num, page in enumerate(pdf_reader.pages):
|
||||
text = page.extract_text()
|
||||
if text and text.strip():
|
||||
text_parts.append(f"[Page {page_num + 1}]\n{text}")
|
||||
return "\n\n".join(text_parts)
|
||||
|
||||
|
||||
class TextProcessor:
|
||||
def supports_file_type(self, content_type: str) -> bool:
|
||||
return content_type.startswith("text/")
|
||||
|
||||
async def extract_text(self, content: bytes) -> str:
|
||||
# Try different encodings
|
||||
for encoding in ["utf-8", "utf-16", "latin-1"]:
|
||||
try:
|
||||
return content.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
raise ValueError("Could not decode text file")
|
||||
|
||||
|
||||
class JSONProcessor:
|
||||
def supports_file_type(self, content_type: str) -> bool:
|
||||
return content_type == "application/json"
|
||||
|
||||
async def extract_text(self, content: bytes) -> str:
|
||||
import json
|
||||
|
||||
data = json.loads(content.decode("utf-8"))
|
||||
# Convert JSON to readable text format
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
|
||||
class FileProcessingService:
|
||||
def __init__(self):
|
||||
self.processors: list[FileProcessor] = [
|
||||
PDFProcessor(),
|
||||
TextProcessor(),
|
||||
JSONProcessor(),
|
||||
# Add more processors as needed
|
||||
]
|
||||
|
||||
async def extract_text_from_upload(self, file: UploadFile) -> str:
|
||||
"""Extract text from uploaded file without saving to disk."""
|
||||
content = await file.read()
|
||||
|
||||
# Reset file position in case it's needed again
|
||||
await file.seek(0)
|
||||
|
||||
processor = self._get_processor(file.content_type or "")
|
||||
if not processor:
|
||||
raise UnsupportedFileTypeError(
|
||||
f"Unsupported file type: {file.content_type}. Supported types: {[p.__class__.__name__ for p in self.processors]}"
|
||||
)
|
||||
|
||||
return await processor.extract_text(content)
|
||||
|
||||
def _get_processor(self, content_type: str) -> FileProcessor | None:
|
||||
for processor in self.processors:
|
||||
if processor.supports_file_type(content_type):
|
||||
return processor
|
||||
return None
|
||||
|
||||
|
||||
def split_text_into_chunks(text: str, max_chars: int = 49500) -> list[str]:
|
||||
"""Split text into chunks that fit within message limits."""
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
|
||||
chunks: list[str] = []
|
||||
current_pos = 0
|
||||
|
||||
while current_pos < len(text):
|
||||
# Try to break at paragraph boundaries first
|
||||
end_pos = current_pos + max_chars
|
||||
|
||||
if end_pos >= len(text):
|
||||
chunks.append(text[current_pos:])
|
||||
break
|
||||
|
||||
# Look for good break points (paragraph, sentence, word)
|
||||
break_pos = end_pos
|
||||
for delimiter in ["\n\n", "\n", ". ", " "]:
|
||||
last_delimiter = text.rfind(delimiter, current_pos, end_pos)
|
||||
if last_delimiter > current_pos:
|
||||
break_pos = last_delimiter + len(delimiter)
|
||||
break
|
||||
|
||||
chunks.append(text[current_pos:break_pos])
|
||||
current_pos = break_pos
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
async def get_file_messages(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
file_id: str,
|
||||
session_name: str | None = None,
|
||||
) -> list[Message]:
|
||||
"""Get all messages for a specific document, ordered by chunk_index."""
|
||||
from sqlalchemy import and_, func
|
||||
|
||||
from src.models import Message
|
||||
|
||||
query = select(Message).where(
|
||||
and_(
|
||||
Message.workspace_name == workspace_name,
|
||||
func.jsonb_extract_path_text(Message.internal_metadata, "file_id")
|
||||
== file_id,
|
||||
)
|
||||
)
|
||||
|
||||
if session_name:
|
||||
query = query.where(Message.session_name == session_name)
|
||||
|
||||
# Order by chunk_index
|
||||
query = query.order_by(
|
||||
func.jsonb_extract_path_text(Message.internal_metadata, "chunk_index").cast(
|
||||
Integer
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def process_file_uploads_for_messages(
|
||||
file: UploadFile,
|
||||
peer_id: str,
|
||||
max_chars: int = 49500,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Process an uploaded file and prepare message creation data.
|
||||
|
||||
This function extracts text from a file, splits it into chunks, and prepares
|
||||
the data needed to create messages.
|
||||
|
||||
Args:
|
||||
file: Uploaded file to process
|
||||
peer_id: ID of the peer creating the messages
|
||||
max_chars: Maximum characters per message chunk
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing message_create and file_metadata
|
||||
|
||||
Raises:
|
||||
HTTPException: If file processing fails
|
||||
"""
|
||||
|
||||
file_processor = FileProcessingService()
|
||||
all_message_data: list[dict[str, Any]] = []
|
||||
|
||||
# Process the uploaded file
|
||||
extracted_text = await file_processor.extract_text_from_upload(file)
|
||||
|
||||
# Split into chunks and create messages
|
||||
chunks = split_text_into_chunks(extracted_text, max_chars=max_chars)
|
||||
file_id = generate_nanoid()
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
# Build message content properly handling empty files
|
||||
message_content = chunk or ""
|
||||
|
||||
# Create message
|
||||
message_create = schemas.MessageCreate(
|
||||
content=message_content,
|
||||
peer_id=peer_id,
|
||||
)
|
||||
|
||||
# Store file metadata separately to add to internal_metadata later
|
||||
file_metadata = {
|
||||
"file_id": file_id,
|
||||
"filename": file.filename,
|
||||
"chunk_index": i,
|
||||
"total_chunks": len(chunks),
|
||||
"original_file_size": file.size,
|
||||
"content_type": file.content_type,
|
||||
"chunk_character_range": [
|
||||
i * max_chars,
|
||||
min((i + 1) * max_chars, len(extracted_text)),
|
||||
],
|
||||
}
|
||||
|
||||
all_message_data.append(
|
||||
{
|
||||
"message_create": message_create,
|
||||
"file_metadata": file_metadata,
|
||||
}
|
||||
)
|
||||
|
||||
if not all_message_data:
|
||||
raise FileProcessingError()
|
||||
|
||||
return all_message_data
|
||||
|
|
@ -191,6 +191,9 @@ def _build_field_condition(
|
|||
"""
|
||||
if model_class.__name__ == "Message":
|
||||
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES.get(key)
|
||||
elif model_class.__name__ == "Document":
|
||||
# documents are fully internal so we can use any column name directly
|
||||
column_name = key
|
||||
else:
|
||||
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING.get(key)
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue