Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc162d4e3 | ||
|
|
43cd5d189a | ||
|
|
5f24bfbe78 | ||
|
|
672ce5aaa6 | ||
|
|
bee0778b48 | ||
|
|
5317323fdd |
@@ -50,6 +50,7 @@ jobs:
|
||||
integrations-smolagents: ${{ steps.filter.outputs.integrations-smolagents }}
|
||||
integrations-dify: ${{ steps.filter.outputs.integrations-dify }}
|
||||
integrations-gemini-spark: ${{ steps.filter.outputs.integrations-gemini-spark }}
|
||||
integrations-vapi: ${{ steps.filter.outputs.integrations-vapi }}
|
||||
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
|
||||
dev: ${{ steps.filter.outputs.dev }}
|
||||
ci: ${{ steps.filter.outputs.ci }}
|
||||
@@ -146,6 +147,8 @@ jobs:
|
||||
- 'hindsight-integrations/dify/**'
|
||||
integrations-gemini-spark:
|
||||
- 'hindsight-integrations/gemini-spark/**'
|
||||
integrations-vapi:
|
||||
- 'hindsight-integrations/vapi/**'
|
||||
tools-agent-sdk:
|
||||
- 'hindsight-tools/hindsight-agent-sdk/**'
|
||||
dev:
|
||||
@@ -2768,6 +2771,43 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/crewai
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-vapi-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-vapi == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build vapi integration
|
||||
working-directory: ./hindsight-integrations/vapi
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/vapi
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/vapi
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-litellm-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -3774,6 +3814,7 @@ jobs:
|
||||
- test-paperclip-integration
|
||||
- test-pipecat-integration
|
||||
- test-gemini-spark-integration
|
||||
- test-vapi-integration
|
||||
- build-control-plane
|
||||
- build-docs
|
||||
- test-rust-cli
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
sidebar_position: 21
|
||||
title: "Vapi Persistent Memory with Hindsight | Integration"
|
||||
description: "Add persistent long-term memory to Vapi voice AI calls via Hindsight webhooks. Auto-recalls caller context at call start and retains the transcript when the call ends."
|
||||
---
|
||||
|
||||
# Vapi
|
||||
|
||||
Persistent long-term memory for [Vapi](https://vapi.ai) voice AI calls via [Hindsight](https://vectorize.io/hindsight). A single webhook handler recalls relevant memories at call start (injected as `assistantOverrides`) and retains the full transcript when the call ends.
|
||||
|
||||
## Quick Start
|
||||
|
||||
:::tip Hindsight Cloud (recommended)
|
||||
[Sign up free](https://ui.hindsight.vectorize.io/signup) — get an API key instantly, no infrastructure to run.
|
||||
:::
|
||||
|
||||
```bash
|
||||
pip install hindsight-vapi
|
||||
```
|
||||
|
||||
Wire it into any HTTP server. FastAPI example with Hindsight Cloud:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, Request
|
||||
from hindsight_vapi import HindsightVapiWebhook
|
||||
|
||||
app = FastAPI()
|
||||
memory = HindsightVapiWebhook(
|
||||
bank_id="user-123",
|
||||
hindsight_api_url="https://api.hindsight.vectorize.io",
|
||||
api_key="hsk_your_token_here",
|
||||
)
|
||||
|
||||
@app.post("/webhook")
|
||||
async def vapi_webhook(request: Request):
|
||||
event = await request.json()
|
||||
response = await memory.handle(event)
|
||||
return response or {}
|
||||
```
|
||||
|
||||
Point Vapi's **Server URL** at your webhook endpoint and memory is active.
|
||||
|
||||
**Self-hosting alternative:** [install Hindsight locally](/developer/installation) and use `hindsight_api_url="http://localhost:8888"` (omit `api_key`).
|
||||
|
||||
## How It Works
|
||||
|
||||
Unlike the Pipecat integration (per-turn `FrameProcessor`), Vapi doesn't expose a per-turn hook, so memory is injected **once per call** at call start:
|
||||
|
||||
```
|
||||
Incoming call
|
||||
└─ Vapi fires "assistant-request" webhook
|
||||
└─ Recall memories (query = caller's phone number)
|
||||
└─ Return as assistantOverrides with <hindsight_memories> system message
|
||||
└─ Vapi merges into assistant config before the call begins
|
||||
|
||||
Call ends
|
||||
└─ Vapi fires "end-of-call-report" webhook
|
||||
└─ Retain full transcript (fire-and-forget — webhook responds immediately)
|
||||
```
|
||||
|
||||
Memory accumulates across calls. By the second or third call with the same caller, Hindsight surfaces relevant history automatically — previous decisions, account details, stated preferences.
|
||||
|
||||
## Vapi Server URL Setup
|
||||
|
||||
In the Vapi dashboard:
|
||||
|
||||
1. Go to **Settings → Server URL**
|
||||
2. Point it at your webhook endpoint (e.g., `https://your-domain.com/webhook`)
|
||||
3. Enable the `assistant-request` and `end-of-call-report` event types
|
||||
|
||||
See [Vapi's server events docs](https://docs.vapi.ai/server-url) for details.
|
||||
|
||||
## Outbound Calls
|
||||
|
||||
There is no `assistant-request` webhook for outbound calls. Use `build_assistant_overrides()` at call-creation time:
|
||||
|
||||
```python
|
||||
overrides = await memory.build_assistant_overrides("Ben from Vectorize")
|
||||
vapi.calls.create(
|
||||
assistant_id="...",
|
||||
assistant_overrides=overrides,
|
||||
customer={"number": "+15555550100"},
|
||||
)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```python
|
||||
HindsightVapiWebhook(
|
||||
bank_id="user-123", # Required: memory bank to use
|
||||
hindsight_api_url="...", # Hindsight API URL
|
||||
api_key="hsk_...", # API key (Hindsight Cloud)
|
||||
recall_budget="mid", # "low", "mid", or "high"
|
||||
recall_max_tokens=4096, # Max tokens for recall results
|
||||
enable_recall=True, # Inject memories at call start
|
||||
enable_retain=True, # Store transcript at call end
|
||||
memory_prefix="Relevant memories from past conversations:\n",
|
||||
)
|
||||
```
|
||||
|
||||
### Global Configuration
|
||||
|
||||
```python
|
||||
from hindsight_vapi import configure
|
||||
|
||||
configure(
|
||||
hindsight_api_url="http://localhost:8888",
|
||||
api_key="hsk_...",
|
||||
recall_budget="mid",
|
||||
)
|
||||
|
||||
# Now create webhooks without repeating connection details
|
||||
memory = HindsightVapiWebhook(bank_id="user-123")
|
||||
```
|
||||
|
||||
## Bank Scoping
|
||||
|
||||
Typical patterns for the `bank_id`:
|
||||
|
||||
- **One bank per user** — scope by phone number (`user-+15551234567`) or your own account ID
|
||||
- **Shared bank** — one bank for all callers (useful for small teams or shared memory)
|
||||
- **Per-assistant** — if you have multiple Vapi assistants with different personalities or scopes
|
||||
|
||||
## Prerequisites
|
||||
|
||||
A running Hindsight instance:
|
||||
|
||||
**Self-hosted:**
|
||||
```bash
|
||||
pip install hindsight-all
|
||||
export HINDSIGHT_API_LLM_API_KEY=your-api-key
|
||||
hindsight-api # starts on http://localhost:8888
|
||||
```
|
||||
|
||||
**Hindsight Cloud:** [Sign up](https://ui.hindsight.vectorize.io/signup) — no self-hosting required.
|
||||
@@ -290,6 +290,16 @@
|
||||
"link": "/sdks/integrations/dify",
|
||||
"icon": "/img/icons/dify.png"
|
||||
},
|
||||
{
|
||||
"id": "vapi",
|
||||
"name": "Vapi",
|
||||
"description": "Persistent memory for Vapi voice AI calls via Hindsight webhooks. Recalls caller context at call start and retains the transcript when the call ends.",
|
||||
"type": "official",
|
||||
"by": "hindsight",
|
||||
"category": "tool",
|
||||
"link": "/sdks/integrations/vapi",
|
||||
"icon": "/img/icons/vapi.png"
|
||||
},
|
||||
{
|
||||
"id": "hindclaw",
|
||||
"name": "HindClaw",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0 (2026-04-24)
|
||||
|
||||
- Initial release: `HindsightVapiWebhook` for adding persistent memory to Vapi voice calls
|
||||
- `assistant-request` handler recalls memories by caller phone number and returns `assistantOverrides` with a system message
|
||||
- `end-of-call-report` handler retains the full transcript to Hindsight (fire-and-forget)
|
||||
- `build_assistant_overrides()` helper for outbound calls where there is no `assistant-request` webhook
|
||||
- Configurable recall budget (`low`, `mid`, `high`) and token limit
|
||||
- Global `configure()` helper for shared connection settings
|
||||
- Framework-agnostic: wires into any HTTP server (FastAPI, Flask, aiohttp, etc.) in two lines
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Vectorize AI, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Hindsight Vapi Integration
|
||||
|
||||
Persistent long-term memory for [Vapi](https://vapi.ai) voice AI calls via [Hindsight](https://vectorize.io/hindsight). A single webhook handler recalls relevant memories at call start (injected as `assistantOverrides`) and retains the full transcript when the call ends.
|
||||
|
||||
## Quick Start
|
||||
|
||||
> ✨ **Recommended:** [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) — sign up free, get an API key, and skip the self-hosting setup entirely.
|
||||
|
||||
```bash
|
||||
pip install hindsight-vapi
|
||||
```
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, Request
|
||||
from hindsight_vapi import HindsightVapiWebhook
|
||||
|
||||
app = FastAPI()
|
||||
memory = HindsightVapiWebhook(
|
||||
bank_id="user-123",
|
||||
hindsight_api_url="https://api.hindsight.vectorize.io",
|
||||
api_key="hsk_your_token_here",
|
||||
)
|
||||
|
||||
@app.post("/webhook")
|
||||
async def vapi_webhook(request: Request):
|
||||
event = await request.json()
|
||||
response = await memory.handle(event)
|
||||
return response or {}
|
||||
```
|
||||
|
||||
Point Vapi's Server URL at this endpoint and memory is active.
|
||||
|
||||
**Self-hosting alternative:** replace the URL with `http://localhost:8888` and omit the `api_key`.
|
||||
|
||||
## How It Works
|
||||
|
||||
Unlike Pipecat (per-turn FrameProcessor), Vapi doesn't expose a per-turn hook, so memory is injected **once per call** at call start:
|
||||
|
||||
```
|
||||
Incoming call
|
||||
└─ Vapi fires "assistant-request" webhook
|
||||
└─ Recall memories (query = caller's phone number)
|
||||
└─ Return as assistantOverrides with <hindsight_memories> system message
|
||||
└─ Vapi merges into assistant config before the call begins
|
||||
|
||||
Call ends
|
||||
└─ Vapi fires "end-of-call-report" webhook
|
||||
└─ Retain full transcript (fire-and-forget — webhook responds immediately)
|
||||
```
|
||||
|
||||
Memory accumulates across calls. By the second or third call with the same caller, Hindsight surfaces relevant history automatically.
|
||||
|
||||
## Outbound Calls
|
||||
|
||||
There is no `assistant-request` webhook for outbound calls. Use `build_assistant_overrides()` at call-creation time:
|
||||
|
||||
```python
|
||||
overrides = await memory.build_assistant_overrides("Ben from Vectorize")
|
||||
vapi.calls.create(
|
||||
assistant_id="...",
|
||||
assistant_overrides=overrides,
|
||||
customer={"number": "+15555550100"},
|
||||
)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
A running Hindsight instance:
|
||||
|
||||
**Self-hosted:**
|
||||
```bash
|
||||
pip install hindsight-all
|
||||
export HINDSIGHT_API_LLM_API_KEY=your-api-key
|
||||
hindsight-api # starts on http://localhost:8888
|
||||
```
|
||||
|
||||
**Hindsight Cloud:** [Sign up](https://ui.hindsight.vectorize.io/signup) — no self-hosting required.
|
||||
|
||||
## Configuration
|
||||
|
||||
```python
|
||||
HindsightVapiWebhook(
|
||||
bank_id="user-123", # Required: memory bank to use
|
||||
hindsight_api_url="...", # Hindsight API URL
|
||||
api_key="hsk_...", # API key (Hindsight Cloud)
|
||||
recall_budget="mid", # "low", "mid", or "high"
|
||||
recall_max_tokens=4096, # Max tokens for recall results
|
||||
enable_recall=True, # Inject memories at call start
|
||||
enable_retain=True, # Store transcript at call end
|
||||
memory_prefix="Relevant memories from past conversations:\n",
|
||||
)
|
||||
```
|
||||
|
||||
### Global Configuration
|
||||
|
||||
```python
|
||||
from hindsight_vapi import configure
|
||||
|
||||
configure(
|
||||
hindsight_api_url="http://localhost:8888",
|
||||
api_key="hsk_...",
|
||||
recall_budget="mid",
|
||||
)
|
||||
|
||||
# Now create webhooks without repeating connection details
|
||||
memory = HindsightVapiWebhook(bank_id="user-123")
|
||||
```
|
||||
|
||||
## Vapi Setup
|
||||
|
||||
1. In the Vapi dashboard, set your **Server URL** to your webhook endpoint
|
||||
2. Enable the `assistant-request` and `end-of-call-report` event types
|
||||
3. For inbound calls, memory is recalled automatically when Vapi fires `assistant-request`
|
||||
|
||||
See [Vapi's server events docs](https://docs.vapi.ai/server-url) for details.
|
||||
|
||||
## Manual Testing
|
||||
|
||||
The `examples/` directory includes an interactive webhook simulator for testing without a real Vapi account:
|
||||
|
||||
```bash
|
||||
python examples/interactive_webhook.py --bank demo-user
|
||||
```
|
||||
|
||||
Commands: `:script` (guided demo), `:end <transcript>`, `:call <number>`, `:memories`, `:quit`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Interactive Vapi webhook simulator — test HindsightVapiWebhook by hand.
|
||||
|
||||
Simulates Vapi's webhook events (assistant-request + end-of-call-report) so you
|
||||
can watch memory retain/recall happen in real time without a real Vapi account
|
||||
or phone number.
|
||||
|
||||
Usage:
|
||||
python examples/interactive_webhook.py --bank vapi-demo
|
||||
python examples/interactive_webhook.py --bank vapi-demo --hindsight-url http://localhost:8888
|
||||
|
||||
Commands:
|
||||
:call <caller-number> Simulate assistant-request (incoming call)
|
||||
:end <transcript> Simulate end-of-call-report (call ends with transcript)
|
||||
:script Run a scripted demo: 1 call ends → wait → next call recalls
|
||||
:memories Dump all memories in the bank
|
||||
:bank Show current bank id
|
||||
:quit / :q Exit
|
||||
|
||||
Example:
|
||||
vapi> :end User: My name is Alex. Assistant: Hi Alex! User: I prefer email. Assistant: Got it.
|
||||
vapi> :memories
|
||||
vapi> :call +15551234567
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from hindsight_vapi import HindsightVapiWebhook
|
||||
|
||||
BLUE = "\033[94m"
|
||||
GREEN = "\033[92m"
|
||||
YELLOW = "\033[93m"
|
||||
CYAN = "\033[96m"
|
||||
DIM = "\033[2m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def banner(label: str, color: str = CYAN) -> None:
|
||||
print(f"{color}[{label}]{RESET}", end=" ")
|
||||
|
||||
|
||||
def dump_memories(url: str, bank: str, api_key: str | None) -> None:
|
||||
req = urllib.request.Request(f"{url}/v1/default/banks/{bank}/memories/list")
|
||||
if api_key:
|
||||
req.add_header("Authorization", f"Bearer {api_key}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
data = json.loads(r.read())
|
||||
items = data.get("items", [])
|
||||
print(f"\n{YELLOW}=== Bank '{bank}' — {len(items)} memories ==={RESET}")
|
||||
for i, m in enumerate(items, 1):
|
||||
print(f"{i}. {m.get('text', '')[:200]}")
|
||||
if not items:
|
||||
print("(empty)")
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f"{YELLOW}Could not list memories: {e}{RESET}")
|
||||
|
||||
|
||||
def make_assistant_request(caller_number: str | None) -> dict:
|
||||
"""Build a fake Vapi assistant-request webhook payload."""
|
||||
return {
|
||||
"message": {
|
||||
"type": "assistant-request",
|
||||
"call": {
|
||||
"customer": {"number": caller_number} if caller_number else {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def make_end_of_call(transcript: str) -> dict:
|
||||
"""Build a fake Vapi end-of-call-report webhook payload."""
|
||||
return {
|
||||
"message": {
|
||||
"type": "end-of-call-report",
|
||||
"artifact": {"transcript": transcript},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def cmd_end_call(webhook: HindsightVapiWebhook, transcript: str, wait_seconds: int = 8) -> None:
|
||||
banner("WEBHOOK: end-of-call-report", CYAN)
|
||||
print(f"transcript length: {len(transcript)} chars")
|
||||
event = make_end_of_call(transcript)
|
||||
response = await webhook.handle(event)
|
||||
banner("RESPONSE", GREEN)
|
||||
print(response if response else "None (HTTP 200, no body)")
|
||||
banner("RETAIN", GREEN)
|
||||
print(f"{DIM}fire-and-forget task scheduled{RESET}")
|
||||
|
||||
# Give the async retain task a chance to actually fire and let Hindsight
|
||||
# extract facts. Without this wait the input() prompt blocks the event
|
||||
# loop before the task can make progress.
|
||||
print(f"{DIM}waiting {wait_seconds}s for retain + fact extraction...{RESET}")
|
||||
for _ in range(wait_seconds):
|
||||
await asyncio.sleep(1)
|
||||
print(f"{DIM}done — try :memories to see what was extracted{RESET}")
|
||||
|
||||
|
||||
async def cmd_assistant_request(webhook: HindsightVapiWebhook, caller: str | None) -> None:
|
||||
banner("WEBHOOK: assistant-request", CYAN)
|
||||
print(f"caller: {caller or '(none)'}")
|
||||
event = make_assistant_request(caller)
|
||||
response = await webhook.handle(event)
|
||||
banner("RESPONSE", GREEN)
|
||||
if not response:
|
||||
print(f"{DIM}empty {{}} — no memories matched the recall query{RESET}")
|
||||
return
|
||||
|
||||
# Pretty-print the assistantOverrides structure
|
||||
print(json.dumps(response, indent=2)[:800])
|
||||
overrides = response.get("assistantOverrides", {})
|
||||
msgs = overrides.get("model", {}).get("messages", [])
|
||||
if msgs:
|
||||
banner("INJECTED SYSTEM PROMPT", CYAN)
|
||||
content = msgs[0].get("content", "")
|
||||
print(content[:500] + ("..." if len(content) > 500 else ""))
|
||||
|
||||
|
||||
async def cmd_script(webhook: HindsightVapiWebhook, url: str, bank: str, api_key: str | None) -> None:
|
||||
"""Run a scripted demo that proves the full retain → recall cycle."""
|
||||
print(f"\n{GREEN}=== Scripted demo: Alex's first + second call ==={RESET}\n")
|
||||
|
||||
# Call 1 ends with a transcript
|
||||
print(f"{DIM}>>> Step 1: Call 1 ends (end-of-call-report){RESET}")
|
||||
transcript = (
|
||||
"User: Hi, my name is Alex and I'm calling from New York. "
|
||||
"Assistant: Nice to meet you Alex! How can I help? "
|
||||
"User: I prefer email updates over phone calls, and my account number is A-12345. "
|
||||
"Assistant: Got it — email updates, account A-12345. Anything else? "
|
||||
"User: No that's all, thanks. "
|
||||
"Assistant: Have a great day!"
|
||||
)
|
||||
await cmd_end_call(webhook, transcript)
|
||||
|
||||
# Wait for async extraction
|
||||
print(f"\n{DIM}>>> Step 2: Waiting 8s for async retain + fact extraction...{RESET}")
|
||||
await asyncio.sleep(8)
|
||||
dump_memories(url, bank, api_key)
|
||||
|
||||
# Call 2 starts — should recall Alex's prefs
|
||||
print(f"{DIM}>>> Step 3: Call 2 starts (assistant-request) — should recall Alex{RESET}")
|
||||
await cmd_assistant_request(webhook, "+15551234567")
|
||||
print(f"\n{GREEN}=== Demo complete ==={RESET}\n")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bank", default=f"vapi-demo-{os.environ.get('USER', 'anon')}")
|
||||
parser.add_argument("--hindsight-url", default=os.environ.get("HINDSIGHT_API_URL", "http://localhost:8888"))
|
||||
parser.add_argument("--hindsight-api-key", default=os.environ.get("HINDSIGHT_API_KEY"))
|
||||
args = parser.parse_args()
|
||||
|
||||
webhook = HindsightVapiWebhook(
|
||||
bank_id=args.bank,
|
||||
hindsight_api_url=args.hindsight_url,
|
||||
api_key=args.hindsight_api_key,
|
||||
)
|
||||
|
||||
print(f"\n{GREEN}=== Vapi + Hindsight Interactive Webhook Simulator ==={RESET}")
|
||||
print(f"Bank: {args.bank}")
|
||||
print(f"Hindsight: {args.hindsight_url}")
|
||||
cmds = ":call <number> :end <transcript> :script :memories :bank :quit"
|
||||
print(f"\nCommands: {DIM}{cmds}{RESET}\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = input(f"{BLUE}vapi> {RESET}").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
|
||||
if not line:
|
||||
continue
|
||||
if line in (":quit", ":q", ":exit"):
|
||||
break
|
||||
if line == ":memories":
|
||||
dump_memories(args.hindsight_url, args.bank, args.hindsight_api_key)
|
||||
continue
|
||||
if line == ":bank":
|
||||
print(f"{YELLOW}bank: {args.bank}{RESET}")
|
||||
continue
|
||||
if line == ":script":
|
||||
await cmd_script(webhook, args.hindsight_url, args.bank, args.hindsight_api_key)
|
||||
continue
|
||||
if line.startswith(":call"):
|
||||
parts = line.split(maxsplit=1)
|
||||
caller = parts[1].strip() if len(parts) > 1 else None
|
||||
await cmd_assistant_request(webhook, caller)
|
||||
continue
|
||||
if line.startswith(":end"):
|
||||
parts = line.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
print(f"{YELLOW}Usage: :end <transcript text>{RESET}")
|
||||
continue
|
||||
await cmd_end_call(webhook, parts[1])
|
||||
continue
|
||||
|
||||
print(f"{YELLOW}Unknown command. Try :script for a guided demo, or :call/:end.{RESET}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Hindsight-Vapi: Persistent memory for Vapi voice AI calls.
|
||||
|
||||
Provides a webhook handler that adds Hindsight long-term memory to Vapi
|
||||
voice calls — recalling relevant context at call start and retaining the
|
||||
transcript when the call ends.
|
||||
|
||||
Basic usage::
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from hindsight_vapi import HindsightVapiWebhook
|
||||
|
||||
app = FastAPI()
|
||||
memory = HindsightVapiWebhook(
|
||||
bank_id="user-123",
|
||||
hindsight_api_url="http://localhost:8888",
|
||||
)
|
||||
|
||||
@app.post("/webhook")
|
||||
async def vapi_webhook(request: Request):
|
||||
event = await request.json()
|
||||
response = await memory.handle(event)
|
||||
return response or {}
|
||||
"""
|
||||
|
||||
from .config import (
|
||||
HindsightVapiConfig,
|
||||
configure,
|
||||
get_config,
|
||||
reset_config,
|
||||
)
|
||||
from .errors import HindsightVapiError
|
||||
from .webhook import HindsightVapiWebhook
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"configure",
|
||||
"get_config",
|
||||
"reset_config",
|
||||
"HindsightVapiConfig",
|
||||
"HindsightVapiError",
|
||||
"HindsightVapiWebhook",
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Global configuration for the Hindsight-Vapi integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io"
|
||||
HINDSIGHT_API_KEY_ENV = "HINDSIGHT_API_KEY"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HindsightVapiConfig:
|
||||
"""Connection and default settings for the Vapi integration.
|
||||
|
||||
Attributes:
|
||||
hindsight_api_url: URL of the Hindsight API server.
|
||||
api_key: API key for Hindsight authentication.
|
||||
recall_budget: Default recall budget level (low/mid/high).
|
||||
recall_max_tokens: Default maximum tokens for recall results.
|
||||
"""
|
||||
|
||||
hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
|
||||
api_key: str | None = None
|
||||
recall_budget: str = "mid"
|
||||
recall_max_tokens: int = 4096
|
||||
|
||||
|
||||
_global_config: HindsightVapiConfig | None = None
|
||||
|
||||
|
||||
def configure(
|
||||
hindsight_api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
recall_budget: str = "mid",
|
||||
recall_max_tokens: int = 4096,
|
||||
) -> HindsightVapiConfig:
|
||||
"""Configure Hindsight connection and default settings.
|
||||
|
||||
Args:
|
||||
hindsight_api_url: Hindsight API URL (default: production).
|
||||
api_key: API key. Falls back to HINDSIGHT_API_KEY env var.
|
||||
recall_budget: Default recall budget (low/mid/high).
|
||||
recall_max_tokens: Default max tokens for recall.
|
||||
|
||||
Returns:
|
||||
The configured HindsightVapiConfig.
|
||||
"""
|
||||
global _global_config
|
||||
|
||||
resolved_url = hindsight_api_url or DEFAULT_HINDSIGHT_API_URL
|
||||
resolved_key = api_key or os.environ.get(HINDSIGHT_API_KEY_ENV)
|
||||
|
||||
_global_config = HindsightVapiConfig(
|
||||
hindsight_api_url=resolved_url,
|
||||
api_key=resolved_key,
|
||||
recall_budget=recall_budget,
|
||||
recall_max_tokens=recall_max_tokens,
|
||||
)
|
||||
|
||||
return _global_config
|
||||
|
||||
|
||||
def get_config() -> HindsightVapiConfig | None:
|
||||
"""Get the current global configuration."""
|
||||
return _global_config
|
||||
|
||||
|
||||
def reset_config() -> None:
|
||||
"""Reset global configuration to None."""
|
||||
global _global_config
|
||||
_global_config = None
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Hindsight-Vapi error types."""
|
||||
|
||||
|
||||
class HindsightVapiError(Exception):
|
||||
"""Exception raised when a Hindsight memory operation fails."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Hindsight memory webhook handler for Vapi voice AI.
|
||||
|
||||
Processes Vapi server events to add persistent memory to voice calls:
|
||||
|
||||
- ``assistant-request``: recalled memories are injected into the assistant's
|
||||
system prompt via ``assistantOverrides`` returned in the webhook response.
|
||||
- ``end-of-call-report``: the full call transcript is retained to Hindsight
|
||||
asynchronously (fire-and-forget) so it never blocks the webhook response.
|
||||
|
||||
Unlike Pipecat (per-turn injection), Vapi memory is injected **once per call**
|
||||
at call start — there is no per-turn hook in Vapi's architecture.
|
||||
|
||||
Basic usage with FastAPI::
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from hindsight_vapi import HindsightVapiWebhook
|
||||
|
||||
app = FastAPI()
|
||||
memory = HindsightVapiWebhook(
|
||||
bank_id="user-123",
|
||||
hindsight_api_url="http://localhost:8888",
|
||||
)
|
||||
|
||||
@app.post("/webhook")
|
||||
async def vapi_webhook(request: Request):
|
||||
event = await request.json()
|
||||
response = await memory.handle(event)
|
||||
return response or {}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
from .config import get_config
|
||||
from .errors import HindsightVapiError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MEMORY_MARKER = "<hindsight_memories>"
|
||||
|
||||
|
||||
def _resolve_client(
|
||||
client: Hindsight | None,
|
||||
hindsight_api_url: str | None,
|
||||
api_key: str | None,
|
||||
) -> Hindsight:
|
||||
"""Resolve a Hindsight client from explicit args or global config."""
|
||||
if client is not None:
|
||||
return client
|
||||
|
||||
config = get_config()
|
||||
url = hindsight_api_url or (config.hindsight_api_url if config else None)
|
||||
key = api_key or (config.api_key if config else None)
|
||||
|
||||
if url is None:
|
||||
raise HindsightVapiError(
|
||||
"No Hindsight API URL configured. Pass client= or hindsight_api_url=, or call configure() first."
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {"base_url": url, "timeout": 30.0}
|
||||
if key:
|
||||
kwargs["api_key"] = key
|
||||
return Hindsight(**kwargs)
|
||||
|
||||
|
||||
class HindsightVapiWebhook:
|
||||
"""Webhook handler that adds Hindsight persistent memory to Vapi voice calls.
|
||||
|
||||
Handles two Vapi server events:
|
||||
|
||||
- ``assistant-request``: Recalls relevant memories for the caller and
|
||||
returns ``assistantOverrides`` containing a system message with those
|
||||
memories. Vapi merges the overrides into the active assistant config.
|
||||
|
||||
- ``end-of-call-report``: Retains the full call transcript to Hindsight
|
||||
asynchronously (fire-and-forget). The webhook response is not delayed.
|
||||
|
||||
All other event types are ignored (returns ``None``, Vapi expects HTTP 200).
|
||||
|
||||
Args:
|
||||
bank_id: Hindsight memory bank to read from and write to.
|
||||
client: Pre-configured Hindsight client (preferred).
|
||||
hindsight_api_url: API URL (used if no client provided).
|
||||
api_key: API key for Hindsight Cloud.
|
||||
recall_budget: Recall budget level — ``"low"``, ``"mid"``, ``"high"``.
|
||||
recall_max_tokens: Maximum tokens for recall results.
|
||||
enable_recall: Inject recalled memories into the assistant system prompt.
|
||||
enable_retain: Store call transcripts after each call ends.
|
||||
memory_prefix: Text prepended inside the recalled memory block.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bank_id: str,
|
||||
*,
|
||||
client: Hindsight | None = None,
|
||||
hindsight_api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
recall_budget: str = "mid",
|
||||
recall_max_tokens: int = 4096,
|
||||
enable_recall: bool = True,
|
||||
enable_retain: bool = True,
|
||||
memory_prefix: str = "Relevant memories from past conversations:\n",
|
||||
) -> None:
|
||||
self._bank_id = bank_id
|
||||
self._client = _resolve_client(client, hindsight_api_url, api_key)
|
||||
config = get_config()
|
||||
self._recall_budget = recall_budget or (config.recall_budget if config else "mid")
|
||||
self._recall_max_tokens = recall_max_tokens or (config.recall_max_tokens if config else 4096)
|
||||
self._enable_recall = enable_recall
|
||||
self._enable_retain = enable_retain
|
||||
self._memory_prefix = memory_prefix
|
||||
|
||||
async def handle(self, event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Process a Vapi server event.
|
||||
|
||||
Args:
|
||||
event: The parsed JSON body of the Vapi webhook POST request.
|
||||
|
||||
Returns:
|
||||
A response dict for Vapi (must be returned as the HTTP response body),
|
||||
or ``None`` for events that require no response body (HTTP 200 OK).
|
||||
"""
|
||||
msg = event.get("message", {})
|
||||
event_type = msg.get("type")
|
||||
|
||||
if event_type == "assistant-request":
|
||||
return await self._handle_assistant_request(msg)
|
||||
elif event_type == "end-of-call-report":
|
||||
await self._handle_end_of_call(msg)
|
||||
|
||||
return None
|
||||
|
||||
async def build_assistant_overrides(self, query: str) -> dict[str, Any]:
|
||||
"""Build ``assistantOverrides`` for an outbound call.
|
||||
|
||||
Use this when creating outbound calls via the Vapi API — there is no
|
||||
``assistant-request`` webhook for outbound calls, so memories must be
|
||||
injected at call-creation time::
|
||||
|
||||
overrides = await memory.build_assistant_overrides("user preferences")
|
||||
vapi.calls.create(
|
||||
assistant_id="...",
|
||||
assistant_overrides=overrides,
|
||||
...
|
||||
)
|
||||
|
||||
Args:
|
||||
query: Query string for memory recall (e.g. the caller's name or
|
||||
a description of the call topic).
|
||||
|
||||
Returns:
|
||||
A dict suitable for passing as ``assistantOverrides``, or ``{}``
|
||||
if recall is disabled or returns no results.
|
||||
"""
|
||||
if not self._enable_recall:
|
||||
return {}
|
||||
|
||||
memories = await self._recall(query)
|
||||
if not memories:
|
||||
return {}
|
||||
|
||||
return self._build_overrides(memories)
|
||||
|
||||
async def _handle_assistant_request(self, msg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle ``assistant-request``: recall and return assistantOverrides."""
|
||||
if not self._enable_recall:
|
||||
return {}
|
||||
|
||||
# Use the caller's phone number as the recall query when available.
|
||||
caller_number: str | None = msg.get("call", {}).get("customer", {}).get("number")
|
||||
query = caller_number or "returning caller"
|
||||
|
||||
memories = await self._recall(query)
|
||||
if not memories:
|
||||
return {}
|
||||
|
||||
return self._build_overrides(memories)
|
||||
|
||||
async def _handle_end_of_call(self, msg: dict[str, Any]) -> None:
|
||||
"""Handle ``end-of-call-report``: retain transcript (fire-and-forget)."""
|
||||
if not self._enable_retain:
|
||||
return
|
||||
|
||||
transcript: str = msg.get("artifact", {}).get("transcript", "")
|
||||
if transcript:
|
||||
asyncio.create_task(self._retain(transcript))
|
||||
|
||||
def _build_overrides(self, memories: str) -> dict[str, Any]:
|
||||
"""Build the assistantOverrides dict with a memory system message."""
|
||||
memory_block = f"{_MEMORY_MARKER}\n{memories}\n</hindsight_memories>"
|
||||
return {
|
||||
"assistantOverrides": {
|
||||
"model": {
|
||||
"messages": [{"role": "system", "content": memory_block}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async def _recall(self, query: str) -> str | None:
|
||||
"""Call Hindsight recall and return a formatted string, or None."""
|
||||
try:
|
||||
response = await self._client.arecall(
|
||||
bank_id=self._bank_id,
|
||||
query=query,
|
||||
budget=self._recall_budget,
|
||||
max_tokens=self._recall_max_tokens,
|
||||
)
|
||||
if not response.results:
|
||||
return None
|
||||
lines = [self._memory_prefix]
|
||||
for i, result in enumerate(response.results, 1):
|
||||
lines.append(f"{i}. {result.text}")
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.warning(f"Hindsight recall failed (continuing without memories): {e}")
|
||||
return None
|
||||
|
||||
async def _retain(self, content: str) -> None:
|
||||
"""Call Hindsight retain (fire-and-forget — errors are logged and swallowed)."""
|
||||
try:
|
||||
await self._client.aretain(
|
||||
bank_id=self._bank_id,
|
||||
content=content,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Hindsight retain failed: {e}")
|
||||
@@ -0,0 +1,60 @@
|
||||
[project]
|
||||
name = "hindsight-vapi"
|
||||
version = "0.1.0"
|
||||
description = "Hindsight persistent memory integration for Vapi voice AI"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "Vectorize", email = "[email protected]" }
|
||||
]
|
||||
keywords = [
|
||||
"ai",
|
||||
"memory",
|
||||
"vapi",
|
||||
"voice",
|
||||
"agents",
|
||||
"hindsight",
|
||||
"webhook",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
dependencies = [
|
||||
"hindsight-client>=0.4.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://vectorize.io/hindsight"
|
||||
Repository = "https://github.com/vectorize-io/hindsight"
|
||||
Documentation = "https://hindsight.vectorize.io/sdks/integrations/vapi"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.8.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hindsight_vapi"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP"]
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Unit tests for HindsightVapiWebhook."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_vapi import HindsightVapiWebhook, configure, reset_config
|
||||
from hindsight_vapi.errors import HindsightVapiError
|
||||
from hindsight_vapi.webhook import _resolve_client
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_client(results: list[str] | None = None) -> MagicMock:
|
||||
"""Return a mock Hindsight client with preset recall results."""
|
||||
client = MagicMock()
|
||||
if results:
|
||||
mock_results = [MagicMock(text=r) for r in results]
|
||||
client.arecall = AsyncMock(return_value=MagicMock(results=mock_results))
|
||||
else:
|
||||
client.arecall = AsyncMock(return_value=MagicMock(results=[]))
|
||||
client.aretain = AsyncMock(return_value=None)
|
||||
return client
|
||||
|
||||
|
||||
def _make_assistant_request(caller_number: str | None = "+15555550100") -> dict[str, Any]:
|
||||
call: dict[str, Any] = {}
|
||||
if caller_number is not None:
|
||||
call["customer"] = {"number": caller_number}
|
||||
return {"message": {"type": "assistant-request", "call": call}}
|
||||
|
||||
|
||||
def _make_end_of_call(transcript: str = "User: hi\nAssistant: hello") -> dict[str, Any]:
|
||||
return {
|
||||
"message": {
|
||||
"type": "end-of-call-report",
|
||||
"artifact": {"transcript": transcript},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestResolveClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveClient:
|
||||
def setup_method(self) -> None:
|
||||
reset_config()
|
||||
|
||||
def test_returns_explicit_client(self) -> None:
|
||||
client = MagicMock()
|
||||
result = _resolve_client(client, None, None)
|
||||
assert result is client
|
||||
|
||||
def test_creates_client_from_url(self) -> None:
|
||||
with patch("hindsight_vapi.webhook.Hindsight") as mock_cls:
|
||||
_resolve_client(None, "http://localhost:8888", None)
|
||||
mock_cls.assert_called_once()
|
||||
|
||||
def test_raises_when_no_url(self) -> None:
|
||||
with pytest.raises(HindsightVapiError, match="No Hindsight API URL"):
|
||||
_resolve_client(None, None, None)
|
||||
|
||||
def test_uses_global_config_url(self) -> None:
|
||||
configure(hindsight_api_url="http://configured:8888")
|
||||
with patch("hindsight_vapi.webhook.Hindsight") as mock_cls:
|
||||
_resolve_client(None, None, None)
|
||||
mock_cls.assert_called_once()
|
||||
reset_config()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAssistantRequest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAssistantRequest:
|
||||
def _svc(self, results: list[str] | None = None, **kwargs: Any) -> HindsightVapiWebhook:
|
||||
client = _make_client(results)
|
||||
return HindsightVapiWebhook(bank_id="test-bank", client=client, **kwargs)
|
||||
|
||||
async def test_memories_injected_into_assistant_overrides(self) -> None:
|
||||
svc = self._svc(["Caller is Jordan", "Prefers metric units"])
|
||||
event = _make_assistant_request()
|
||||
result = await svc.handle(event)
|
||||
assert result is not None
|
||||
assert "assistantOverrides" in result
|
||||
messages = result["assistantOverrides"]["model"]["messages"]
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "system"
|
||||
assert "<hindsight_memories>" in messages[0]["content"]
|
||||
assert "Jordan" in messages[0]["content"]
|
||||
|
||||
async def test_empty_recall_returns_empty_dict(self) -> None:
|
||||
svc = self._svc(results=None)
|
||||
event = _make_assistant_request()
|
||||
result = await svc.handle(event)
|
||||
assert result == {}
|
||||
|
||||
async def test_recall_error_swallowed_empty_dict_returned(self) -> None:
|
||||
client = _make_client()
|
||||
client.arecall = AsyncMock(side_effect=RuntimeError("network error"))
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
|
||||
event = _make_assistant_request()
|
||||
result = await svc.handle(event)
|
||||
assert result == {}
|
||||
|
||||
async def test_caller_number_used_as_recall_query(self) -> None:
|
||||
client = _make_client(["fact"])
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
|
||||
event = _make_assistant_request(caller_number="+15555550199")
|
||||
await svc.handle(event)
|
||||
client.arecall.assert_called_once()
|
||||
call_kwargs = client.arecall.call_args.kwargs
|
||||
assert call_kwargs["query"] == "+15555550199"
|
||||
|
||||
async def test_no_caller_number_uses_fallback_query(self) -> None:
|
||||
client = _make_client()
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
|
||||
event = _make_assistant_request(caller_number=None)
|
||||
await svc.handle(event)
|
||||
call_kwargs = client.arecall.call_args.kwargs
|
||||
assert call_kwargs["query"] == "returning caller"
|
||||
|
||||
async def test_enable_recall_false_skips_recall(self) -> None:
|
||||
client = _make_client(["fact"])
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client, enable_recall=False)
|
||||
event = _make_assistant_request()
|
||||
result = await svc.handle(event)
|
||||
client.arecall.assert_not_called()
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestEndOfCall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEndOfCall:
|
||||
def _svc(self, **kwargs: Any) -> HindsightVapiWebhook:
|
||||
client = _make_client()
|
||||
return HindsightVapiWebhook(bank_id="test-bank", client=client, **kwargs)
|
||||
|
||||
async def test_transcript_retained_fire_and_forget(self) -> None:
|
||||
svc = self._svc()
|
||||
event = _make_end_of_call("User: hello\nAssistant: hi there")
|
||||
with patch("hindsight_vapi.webhook.asyncio.create_task") as mock_task:
|
||||
result = await svc.handle(event)
|
||||
assert result is None
|
||||
mock_task.assert_called_once()
|
||||
|
||||
async def test_retain_called_with_transcript(self) -> None:
|
||||
client = _make_client()
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
|
||||
transcript = "User: hello\nAssistant: hi there"
|
||||
|
||||
retained: list[str] = []
|
||||
|
||||
async def _fake_retain(content: str) -> None:
|
||||
retained.append(content)
|
||||
|
||||
with patch.object(svc, "_retain", new_callable=AsyncMock, side_effect=_fake_retain):
|
||||
await svc.handle(_make_end_of_call(transcript))
|
||||
# Let the scheduled task run
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert retained == [transcript]
|
||||
|
||||
async def test_retain_error_swallowed(self) -> None:
|
||||
client = _make_client()
|
||||
client.aretain = AsyncMock(side_effect=RuntimeError("retain failed"))
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
|
||||
# Should not raise
|
||||
await svc._retain("some transcript")
|
||||
|
||||
async def test_empty_transcript_is_noop(self) -> None:
|
||||
svc = self._svc()
|
||||
event = _make_end_of_call(transcript="")
|
||||
with patch("hindsight_vapi.webhook.asyncio.create_task") as mock_task:
|
||||
await svc.handle(event)
|
||||
mock_task.assert_not_called()
|
||||
|
||||
async def test_enable_retain_false_skips_retain(self) -> None:
|
||||
svc = self._svc(enable_retain=False)
|
||||
with patch("hindsight_vapi.webhook.asyncio.create_task") as mock_task:
|
||||
await svc.handle(_make_end_of_call())
|
||||
mock_task.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestBuildOverrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildOverrides:
|
||||
async def test_returns_overrides_with_memories(self) -> None:
|
||||
client = _make_client(["User is Jordan", "Prefers metric"])
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
|
||||
result = await svc.build_assistant_overrides("what do I know about this caller?")
|
||||
assert "assistantOverrides" in result
|
||||
messages = result["assistantOverrides"]["model"]["messages"]
|
||||
assert messages[0]["role"] == "system"
|
||||
assert "Jordan" in messages[0]["content"]
|
||||
|
||||
async def test_empty_recall_returns_empty_dict(self) -> None:
|
||||
client = _make_client(results=None)
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
|
||||
result = await svc.build_assistant_overrides("query")
|
||||
assert result == {}
|
||||
|
||||
async def test_enable_recall_false_returns_empty_dict(self) -> None:
|
||||
client = _make_client(["fact"])
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client, enable_recall=False)
|
||||
result = await svc.build_assistant_overrides("query")
|
||||
client.arecall.assert_not_called()
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestUnknownEvent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnknownEvent:
|
||||
async def test_unknown_event_type_returns_none(self) -> None:
|
||||
client = _make_client()
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
|
||||
for event_type in ["call-started", "call-ended", "speech-update", "transcript"]:
|
||||
result = await svc.handle({"message": {"type": event_type}})
|
||||
assert result is None, f"Expected None for event type {event_type!r}"
|
||||
client.arecall.assert_not_called()
|
||||
client.aretain.assert_not_called()
|
||||
|
||||
async def test_empty_event_returns_none(self) -> None:
|
||||
client = _make_client()
|
||||
svc = HindsightVapiWebhook(bank_id="test-bank", client=client)
|
||||
result = await svc.handle({})
|
||||
assert result is None
|
||||
Generated
+1109
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "hermes" "autogen" "paperclip" "opencode" "cloudflare-oauth-proxy" "openai-agents" "pipecat" "agentcore" "smolagents" "n8n" "dify" "gemini-spark")
|
||||
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "hermes" "autogen" "paperclip" "opencode" "cloudflare-oauth-proxy" "openai-agents" "pipecat" "agentcore" "smolagents" "n8n" "dify" "gemini-spark" "vapi")
|
||||
|
||||
usage() {
|
||||
print_error "Usage: $0 <integration> <version>"
|
||||
|
||||
Reference in New Issue
Block a user