memoair-vapi 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memoair_vapi-0.1.0/.gitignore +198 -0
- memoair_vapi-0.1.0/PKG-INFO +77 -0
- memoair_vapi-0.1.0/README.md +43 -0
- memoair_vapi-0.1.0/memoair_vapi/__init__.py +25 -0
- memoair_vapi-0.1.0/memoair_vapi/_search.py +114 -0
- memoair_vapi-0.1.0/memoair_vapi/_signature.py +27 -0
- memoair_vapi-0.1.0/memoair_vapi/_types.py +38 -0
- memoair_vapi-0.1.0/memoair_vapi/_webhook.py +81 -0
- memoair_vapi-0.1.0/memoair_vapi/py.typed +0 -0
- memoair_vapi-0.1.0/pyproject.toml +73 -0
- memoair_vapi-0.1.0/tests/__init__.py +0 -0
- memoair_vapi-0.1.0/tests/test_search.py +102 -0
- memoair_vapi-0.1.0/tests/test_signature.py +33 -0
- memoair_vapi-0.1.0/tests/test_webhook.py +95 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# ============================================
|
|
2
|
+
# External dependencies (vendored)
|
|
3
|
+
# ============================================
|
|
4
|
+
/mem0/
|
|
5
|
+
/graphiti/
|
|
6
|
+
/HippoRAG/
|
|
7
|
+
/memvid/
|
|
8
|
+
/PageIndex/
|
|
9
|
+
/NornicDB/
|
|
10
|
+
/cognee/
|
|
11
|
+
|
|
12
|
+
# ============================================
|
|
13
|
+
# Legacy directories (removed, ignore local copies)
|
|
14
|
+
# ============================================
|
|
15
|
+
/memory-service/
|
|
16
|
+
/home-page/
|
|
17
|
+
/extensionV2/
|
|
18
|
+
|
|
19
|
+
# ============================================
|
|
20
|
+
# Python virtual environments
|
|
21
|
+
# ============================================
|
|
22
|
+
memory-service-v2/.venv/
|
|
23
|
+
doc-parser/.venv/
|
|
24
|
+
.venv/
|
|
25
|
+
venv/
|
|
26
|
+
env/
|
|
27
|
+
ENV/
|
|
28
|
+
|
|
29
|
+
# ============================================
|
|
30
|
+
# Environment files (contain secrets)
|
|
31
|
+
# ============================================
|
|
32
|
+
# Ignore all .env files except .env.example
|
|
33
|
+
.env
|
|
34
|
+
.env.voice.local
|
|
35
|
+
.env.local
|
|
36
|
+
.env.*.local
|
|
37
|
+
!.env.example
|
|
38
|
+
!**/.env.example
|
|
39
|
+
|
|
40
|
+
# ============================================
|
|
41
|
+
# Node.js
|
|
42
|
+
# ============================================
|
|
43
|
+
node_modules/
|
|
44
|
+
extension/node_modules/
|
|
45
|
+
dashboard/node_modules/
|
|
46
|
+
homePage/node_modules/
|
|
47
|
+
home-page/node_modules/
|
|
48
|
+
mcp-server/node_modules/
|
|
49
|
+
|
|
50
|
+
# ============================================
|
|
51
|
+
# Backend (Go)
|
|
52
|
+
# ============================================
|
|
53
|
+
backend/uploads/
|
|
54
|
+
backend/vendor/
|
|
55
|
+
backend/backend
|
|
56
|
+
backend/server
|
|
57
|
+
backend/*.test
|
|
58
|
+
backend/notetaker.db
|
|
59
|
+
backend/notetaker.db-shm
|
|
60
|
+
backend/notetaker.db-wal
|
|
61
|
+
backend/tmp/
|
|
62
|
+
backend/build-errors.log
|
|
63
|
+
|
|
64
|
+
# ============================================
|
|
65
|
+
# Next.js / React build output
|
|
66
|
+
# ============================================
|
|
67
|
+
.next/
|
|
68
|
+
out/
|
|
69
|
+
dist/
|
|
70
|
+
build/
|
|
71
|
+
|
|
72
|
+
# TypeScript incremental build info (regenerated on every tsc run)
|
|
73
|
+
*.tsbuildinfo
|
|
74
|
+
|
|
75
|
+
# ============================================
|
|
76
|
+
# Extension build output
|
|
77
|
+
# ============================================
|
|
78
|
+
extension/dist/
|
|
79
|
+
extension/dist-extension/
|
|
80
|
+
extension/.next/
|
|
81
|
+
|
|
82
|
+
# ============================================
|
|
83
|
+
# MCP server build output
|
|
84
|
+
# ============================================
|
|
85
|
+
mcp-server/dist/
|
|
86
|
+
|
|
87
|
+
# ============================================
|
|
88
|
+
# OS files
|
|
89
|
+
# ============================================
|
|
90
|
+
.DS_Store
|
|
91
|
+
Thumbs.db
|
|
92
|
+
|
|
93
|
+
# ============================================
|
|
94
|
+
# IDE files
|
|
95
|
+
# ============================================
|
|
96
|
+
.vscode/
|
|
97
|
+
.idea/
|
|
98
|
+
*.swp
|
|
99
|
+
*~
|
|
100
|
+
|
|
101
|
+
# ============================================
|
|
102
|
+
# Python
|
|
103
|
+
# ============================================
|
|
104
|
+
__pycache__/
|
|
105
|
+
*.py[cod]
|
|
106
|
+
*$py.class
|
|
107
|
+
*.so
|
|
108
|
+
.Python
|
|
109
|
+
*.egg-info/
|
|
110
|
+
.eggs/
|
|
111
|
+
|
|
112
|
+
# ============================================
|
|
113
|
+
# Logs
|
|
114
|
+
# ============================================
|
|
115
|
+
*.log
|
|
116
|
+
npm-debug.log*
|
|
117
|
+
yarn-debug.log*
|
|
118
|
+
yarn-error.log*
|
|
119
|
+
pnpm-debug.log*
|
|
120
|
+
|
|
121
|
+
# ============================================
|
|
122
|
+
# Temp files
|
|
123
|
+
# ============================================
|
|
124
|
+
*.tmp
|
|
125
|
+
*.temp
|
|
126
|
+
.cache/
|
|
127
|
+
|
|
128
|
+
# ============================================
|
|
129
|
+
# Database files (local dev)
|
|
130
|
+
# ============================================
|
|
131
|
+
*.db
|
|
132
|
+
*.db-shm
|
|
133
|
+
*.db-wal
|
|
134
|
+
!memory-service-v2/.gitkeep
|
|
135
|
+
|
|
136
|
+
# ============================================
|
|
137
|
+
# Benchmark data (downloaded, large files)
|
|
138
|
+
# ============================================
|
|
139
|
+
memory-service-v2/benchmarks/data/memorybench_large.json
|
|
140
|
+
memory-service-v2/benchmarks/data/*.json
|
|
141
|
+
!memory-service-v2/benchmarks/data/notes.json
|
|
142
|
+
memory-service-v2/benchmarks/results/*.json
|
|
143
|
+
!memory-service-v2/benchmarks/results/baseline.json
|
|
144
|
+
memory-service-v2/.env
|
|
145
|
+
|
|
146
|
+
# ============================================
|
|
147
|
+
# Rust / memvid-service local config
|
|
148
|
+
# ============================================
|
|
149
|
+
memvid-service/.config/
|
|
150
|
+
|
|
151
|
+
# Generated benchmark data (large / reproducible)
|
|
152
|
+
memorybench/data/benchmarks/locomo/locomo10-entities.json
|
|
153
|
+
memorybench/data/benchmarks/longmemeval/
|
|
154
|
+
|
|
155
|
+
# Go test binaries
|
|
156
|
+
backend/service.test
|
|
157
|
+
|
|
158
|
+
# Debug logs (runtime instrumentation)
|
|
159
|
+
backend/debug_logs/
|
|
160
|
+
|
|
161
|
+
# OpenMemory - IDE/Assistant specific rules
|
|
162
|
+
.cursor/rules/openmemory.mdc
|
|
163
|
+
|
|
164
|
+
# MemoAir agent scratch pad (ephemeral, never commit)
|
|
165
|
+
.memoair/
|
|
166
|
+
|
|
167
|
+
# Spec Kit (project-local config, regenerable via `specify init`)
|
|
168
|
+
.specify/
|
|
169
|
+
|
|
170
|
+
# Git worktrees (isolated workspaces for parallel feature work)
|
|
171
|
+
.worktrees/
|
|
172
|
+
CLAUDE.md
|
|
173
|
+
AGENTS.md
|
|
174
|
+
|
|
175
|
+
# ============================================
|
|
176
|
+
# Generated caches (code-review-graph tool output; fully regenerable)
|
|
177
|
+
# ============================================
|
|
178
|
+
graphify-out/
|
|
179
|
+
.graphify-scopes/
|
|
180
|
+
|
|
181
|
+
# ============================================
|
|
182
|
+
# Runtime / notebook artifacts (never commit)
|
|
183
|
+
# ============================================
|
|
184
|
+
dump.rdb
|
|
185
|
+
v3_store.json
|
|
186
|
+
v3_artifacts/
|
|
187
|
+
|
|
188
|
+
# voice-runtime build artifacts
|
|
189
|
+
voice-runtime/target/
|
|
190
|
+
.claude/worktrees/
|
|
191
|
+
memory-service-v2/data/permanent_store/
|
|
192
|
+
voice-runtime/dist/
|
|
193
|
+
|
|
194
|
+
# Secrets / account recovery codes — NEVER commit these
|
|
195
|
+
PyPI-Recovery-Codes*.txt
|
|
196
|
+
*recovery-codes*.txt
|
|
197
|
+
*.pem
|
|
198
|
+
*.key
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memoair-vapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: VAPI Custom Knowledge Base integration for MemoAir voice memory
|
|
5
|
+
Project-URL: Homepage, https://memoair.dev
|
|
6
|
+
Project-URL: Documentation, https://docs.memoair.dev/voice/integrations/vapi
|
|
7
|
+
Project-URL: Repository, https://github.com/memoair/memoair-python
|
|
8
|
+
Author-email: MemoAir <hello@memoair.dev>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agents,ai,memoair,memory,vapi,voice,voice-agents
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: memoair-voice>=0.3.2
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: black>=23.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# memoair-vapi
|
|
36
|
+
|
|
37
|
+
Serve [VAPI's Custom Knowledge Base webhook](https://docs.vapi.ai/knowledge-base/custom-knowledge-base)
|
|
38
|
+
from MemoAir voice memory. Phase 1 retrieves from the shared org index.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install memoair-vapi
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import json
|
|
50
|
+
|
|
51
|
+
from memoair_vapi import MemoAirVapiSearch, verify_vapi_signature
|
|
52
|
+
|
|
53
|
+
search = MemoAirVapiSearch(
|
|
54
|
+
api_key="memoair_pk_...",
|
|
55
|
+
project_id="proj_xxx",
|
|
56
|
+
agent_id="agent_xxx",
|
|
57
|
+
top_k=5,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# In your webhook handler (raw_body = exact request bytes):
|
|
61
|
+
if not verify_vapi_signature(raw_body, signature_header, secret):
|
|
62
|
+
... # return 401
|
|
63
|
+
|
|
64
|
+
payload = json.loads(raw_body)
|
|
65
|
+
documents = await search.handle_request(payload) # -> {"documents": [...]}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`handle_request` dispatches on `message.type`: `knowledge-base-request` returns
|
|
69
|
+
documents; any other type is acked with `{}`. A malformed knowledge-base request
|
|
70
|
+
raises `VapiWebhookError` (map it to HTTP 400). Retrieval failures degrade to
|
|
71
|
+
`{"documents": []}` so a live call never breaks.
|
|
72
|
+
|
|
73
|
+
A runnable FastAPI server is in [`examples/vapi/`](../../examples/vapi/).
|
|
74
|
+
|
|
75
|
+
## Docs
|
|
76
|
+
|
|
77
|
+
https://docs.memoair.dev/voice/integrations/vapi
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# memoair-vapi
|
|
2
|
+
|
|
3
|
+
Serve [VAPI's Custom Knowledge Base webhook](https://docs.vapi.ai/knowledge-base/custom-knowledge-base)
|
|
4
|
+
from MemoAir voice memory. Phase 1 retrieves from the shared org index.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install memoair-vapi
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
from memoair_vapi import MemoAirVapiSearch, verify_vapi_signature
|
|
18
|
+
|
|
19
|
+
search = MemoAirVapiSearch(
|
|
20
|
+
api_key="memoair_pk_...",
|
|
21
|
+
project_id="proj_xxx",
|
|
22
|
+
agent_id="agent_xxx",
|
|
23
|
+
top_k=5,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# In your webhook handler (raw_body = exact request bytes):
|
|
27
|
+
if not verify_vapi_signature(raw_body, signature_header, secret):
|
|
28
|
+
... # return 401
|
|
29
|
+
|
|
30
|
+
payload = json.loads(raw_body)
|
|
31
|
+
documents = await search.handle_request(payload) # -> {"documents": [...]}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`handle_request` dispatches on `message.type`: `knowledge-base-request` returns
|
|
35
|
+
documents; any other type is acked with `{}`. A malformed knowledge-base request
|
|
36
|
+
raises `VapiWebhookError` (map it to HTTP 400). Retrieval failures degrade to
|
|
37
|
+
`{"documents": []}` so a live call never breaks.
|
|
38
|
+
|
|
39
|
+
A runnable FastAPI server is in [`examples/vapi/`](../../examples/vapi/).
|
|
40
|
+
|
|
41
|
+
## Docs
|
|
42
|
+
|
|
43
|
+
https://docs.memoair.dev/voice/integrations/vapi
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from memoair_vapi._search import MemoAirVapiSearch
|
|
2
|
+
from memoair_vapi._signature import verify_vapi_signature
|
|
3
|
+
from memoair_vapi._types import (
|
|
4
|
+
VapiDocument,
|
|
5
|
+
VapiKnowledgeBaseResponse,
|
|
6
|
+
VapiWebhookError,
|
|
7
|
+
)
|
|
8
|
+
from memoair_vapi._webhook import (
|
|
9
|
+
KNOWLEDGE_BASE_REQUEST,
|
|
10
|
+
documents_from_search_result,
|
|
11
|
+
extract_query,
|
|
12
|
+
message_type,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MemoAirVapiSearch",
|
|
17
|
+
"verify_vapi_signature",
|
|
18
|
+
"VapiDocument",
|
|
19
|
+
"VapiKnowledgeBaseResponse",
|
|
20
|
+
"VapiWebhookError",
|
|
21
|
+
"KNOWLEDGE_BASE_REQUEST",
|
|
22
|
+
"documents_from_search_result",
|
|
23
|
+
"extract_query",
|
|
24
|
+
"message_type",
|
|
25
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""MemoAirVapiSearch — serve VAPI Custom Knowledge Base from MemoAir org memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from memoair_voice import MemoAirVoiceClient
|
|
10
|
+
|
|
11
|
+
from memoair_vapi._types import VapiKnowledgeBaseResponse, VapiWebhookError
|
|
12
|
+
from memoair_vapi._webhook import (
|
|
13
|
+
KNOWLEDGE_BASE_REQUEST,
|
|
14
|
+
documents_from_search_result,
|
|
15
|
+
extract_query,
|
|
16
|
+
message_type,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_logger = logging.getLogger("memoair_vapi")
|
|
20
|
+
|
|
21
|
+
DEFAULT_TOP_K = 5
|
|
22
|
+
DEFAULT_SEARCH_TIMEOUT_MS = 250
|
|
23
|
+
DEFAULT_CLOUD_BASE_URL = "https://backend.memoair.space"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MemoAirVapiSearch:
|
|
27
|
+
"""Wraps a MemoAirVoiceClient to answer VAPI knowledge-base-request webhooks.
|
|
28
|
+
|
|
29
|
+
Phase 1 is org-lane-only. ``user_id`` / ``lanes`` are accepted now as inert
|
|
30
|
+
seams: when ``org_only`` is True they are ignored and the org lane is forced.
|
|
31
|
+
Phase 2 will route a supplied ``user_id`` to per-user lanes.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
api_key: str,
|
|
38
|
+
project_id: str,
|
|
39
|
+
agent_id: str,
|
|
40
|
+
top_k: int = DEFAULT_TOP_K,
|
|
41
|
+
org_only: bool = True,
|
|
42
|
+
cloud_base_url: str = DEFAULT_CLOUD_BASE_URL,
|
|
43
|
+
search_timeout_ms: int = DEFAULT_SEARCH_TIMEOUT_MS,
|
|
44
|
+
max_concurrent_users: int = 4,
|
|
45
|
+
client: Any | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
if not api_key:
|
|
48
|
+
raise ValueError("api_key is required")
|
|
49
|
+
if not project_id:
|
|
50
|
+
raise ValueError("project_id is required")
|
|
51
|
+
if not agent_id:
|
|
52
|
+
raise ValueError("agent_id is required")
|
|
53
|
+
|
|
54
|
+
self._project_id = project_id
|
|
55
|
+
self._org_only = org_only
|
|
56
|
+
self._top_k = int(top_k)
|
|
57
|
+
self._search_timeout_ms = int(search_timeout_ms)
|
|
58
|
+
self._client = client or MemoAirVoiceClient(
|
|
59
|
+
api_key=api_key,
|
|
60
|
+
project_id=project_id,
|
|
61
|
+
agent_id=agent_id,
|
|
62
|
+
cloud_base_url=cloud_base_url,
|
|
63
|
+
org_only=org_only,
|
|
64
|
+
max_concurrent_users=max_concurrent_users,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
async def search(
|
|
68
|
+
self,
|
|
69
|
+
query: str,
|
|
70
|
+
*,
|
|
71
|
+
user_id: str | None = None,
|
|
72
|
+
lanes: list[str] | None = None,
|
|
73
|
+
) -> VapiKnowledgeBaseResponse:
|
|
74
|
+
effective_lanes = ["org"] if self._org_only else (lanes or ["org"])
|
|
75
|
+
result = await self._client.search_memory(
|
|
76
|
+
query,
|
|
77
|
+
lanes=effective_lanes,
|
|
78
|
+
timeout_ms=self._search_timeout_ms,
|
|
79
|
+
)
|
|
80
|
+
docs = documents_from_search_result(result, top_k=self._top_k)
|
|
81
|
+
return VapiKnowledgeBaseResponse(documents=docs)
|
|
82
|
+
|
|
83
|
+
async def handle_request(
|
|
84
|
+
self,
|
|
85
|
+
payload: Mapping[str, Any],
|
|
86
|
+
*,
|
|
87
|
+
user_id: str | None = None,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Parse a VAPI webhook payload, dispatch on type, return a JSON-able dict.
|
|
90
|
+
|
|
91
|
+
Non-``knowledge-base-request`` types are acked with ``{}``. Malformed
|
|
92
|
+
KB payloads raise VapiWebhookError (server maps to 400). Search failures
|
|
93
|
+
degrade to ``{"documents": []}`` so the live call keeps flowing.
|
|
94
|
+
|
|
95
|
+
A non-object payload (valid JSON array/scalar reaching a public
|
|
96
|
+
endpoint) is treated as malformed rather than allowed to raise an
|
|
97
|
+
uncaught AttributeError — preserves the never-500 contract.
|
|
98
|
+
"""
|
|
99
|
+
if not isinstance(payload, Mapping):
|
|
100
|
+
raise VapiWebhookError("payload is not a JSON object")
|
|
101
|
+
if message_type(payload) != KNOWLEDGE_BASE_REQUEST:
|
|
102
|
+
return {}
|
|
103
|
+
query = extract_query(payload)
|
|
104
|
+
try:
|
|
105
|
+
response = await self.search(query, user_id=user_id)
|
|
106
|
+
except Exception as exc: # noqa: BLE001 — degrade, never 500 mid-call
|
|
107
|
+
_logger.warning(
|
|
108
|
+
"vapi search failed (project=%s): %s", self._project_id, exc
|
|
109
|
+
)
|
|
110
|
+
return {"documents": []}
|
|
111
|
+
return response.to_dict()
|
|
112
|
+
|
|
113
|
+
async def aclose(self) -> None:
|
|
114
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""HMAC-SHA256 verification for VAPI webhook requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def verify_vapi_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
|
|
10
|
+
"""Return True iff ``signature_header`` is a valid HMAC-SHA256 of
|
|
11
|
+
``raw_body`` under ``secret``.
|
|
12
|
+
|
|
13
|
+
VAPI's docs are inconsistent about whether the header carries a bare hex
|
|
14
|
+
digest or a ``sha256=<hex>`` form, so both are accepted. Comparison is
|
|
15
|
+
timing-safe. Any missing input (empty body, header, or secret) returns
|
|
16
|
+
False rather than raising.
|
|
17
|
+
"""
|
|
18
|
+
if not raw_body or not signature_header or not secret:
|
|
19
|
+
return False
|
|
20
|
+
candidate = signature_header.strip()
|
|
21
|
+
if candidate.lower().startswith("sha256="):
|
|
22
|
+
candidate = candidate[len("sha256="):]
|
|
23
|
+
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
|
|
24
|
+
try:
|
|
25
|
+
return hmac.compare_digest(candidate, expected)
|
|
26
|
+
except (TypeError, ValueError):
|
|
27
|
+
return False
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Public types for the memoair-vapi package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class VapiWebhookError(Exception):
|
|
10
|
+
"""Raised when a VAPI webhook payload is malformed (missing message,
|
|
11
|
+
missing/empty messages, or no user turn with content)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class VapiDocument:
|
|
16
|
+
"""A single document returned to VAPI's Custom Knowledge Base webhook."""
|
|
17
|
+
|
|
18
|
+
content: str
|
|
19
|
+
similarity: float | None = None
|
|
20
|
+
uuid: str | None = None
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict[str, Any]:
|
|
23
|
+
out: dict[str, Any] = {"content": self.content}
|
|
24
|
+
if self.similarity is not None:
|
|
25
|
+
out["similarity"] = self.similarity
|
|
26
|
+
if self.uuid is not None:
|
|
27
|
+
out["uuid"] = self.uuid
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class VapiKnowledgeBaseResponse:
|
|
33
|
+
"""The `{"documents": [...]}` response body VAPI expects."""
|
|
34
|
+
|
|
35
|
+
documents: list[VapiDocument] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
return {"documents": [d.to_dict() for d in self.documents]}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Parse VAPI Custom Knowledge Base requests and map MemoAir results to docs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from memoair_voice import SearchResult
|
|
9
|
+
|
|
10
|
+
from memoair_vapi._types import VapiDocument, VapiWebhookError
|
|
11
|
+
|
|
12
|
+
KNOWLEDGE_BASE_REQUEST = "knowledge-base-request"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def message_type(payload: Mapping[str, Any]) -> str | None:
|
|
16
|
+
"""Return ``message.type`` from a VAPI webhook payload, or None."""
|
|
17
|
+
message = payload.get("message")
|
|
18
|
+
if isinstance(message, Mapping):
|
|
19
|
+
mtype = message.get("type")
|
|
20
|
+
if isinstance(mtype, str):
|
|
21
|
+
return mtype
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_query(payload: Mapping[str, Any]) -> str:
|
|
26
|
+
"""Return the latest user message content from a knowledge-base-request.
|
|
27
|
+
|
|
28
|
+
Raises VapiWebhookError if the payload is malformed or has no user turn.
|
|
29
|
+
"""
|
|
30
|
+
message = payload.get("message")
|
|
31
|
+
if not isinstance(message, Mapping):
|
|
32
|
+
raise VapiWebhookError("payload missing 'message' object")
|
|
33
|
+
messages = message.get("messages")
|
|
34
|
+
if not isinstance(messages, list) or not messages:
|
|
35
|
+
raise VapiWebhookError("payload 'message.messages' missing or empty")
|
|
36
|
+
for item in reversed(messages):
|
|
37
|
+
if isinstance(item, Mapping) and item.get("role") == "user":
|
|
38
|
+
content = item.get("content")
|
|
39
|
+
if isinstance(content, str) and content.strip():
|
|
40
|
+
return content
|
|
41
|
+
raise VapiWebhookError("no user message with content found")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def documents_from_search_result(result: SearchResult, *, top_k: int) -> list[VapiDocument]:
|
|
45
|
+
"""Map a SearchResult's org lane to VAPI documents.
|
|
46
|
+
|
|
47
|
+
Org items follow the runtime contract {docId, chunkId, text, score} but we
|
|
48
|
+
read defensively across alternative keys. Falls back to a single document
|
|
49
|
+
carrying contextText when the org lane is empty.
|
|
50
|
+
"""
|
|
51
|
+
docs: list[VapiDocument] = []
|
|
52
|
+
for item in result.org or []:
|
|
53
|
+
if not isinstance(item, Mapping):
|
|
54
|
+
continue
|
|
55
|
+
content = item.get("text") or item.get("content") or item.get("chunk")
|
|
56
|
+
if not isinstance(content, str) or not content.strip():
|
|
57
|
+
continue
|
|
58
|
+
raw_score = item.get("score")
|
|
59
|
+
if raw_score is None:
|
|
60
|
+
raw_score = item.get("similarity")
|
|
61
|
+
similarity = float(raw_score) if isinstance(raw_score, (int, float)) else None
|
|
62
|
+
raw_uuid = (
|
|
63
|
+
item.get("chunkId")
|
|
64
|
+
or item.get("docId")
|
|
65
|
+
or item.get("id")
|
|
66
|
+
or item.get("uuid")
|
|
67
|
+
)
|
|
68
|
+
docs.append(
|
|
69
|
+
VapiDocument(
|
|
70
|
+
content=content,
|
|
71
|
+
similarity=similarity,
|
|
72
|
+
uuid=str(raw_uuid) if raw_uuid is not None else None,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if not docs and result.contextText and result.contextText.strip():
|
|
77
|
+
docs.append(VapiDocument(content=result.contextText))
|
|
78
|
+
|
|
79
|
+
if top_k and top_k > 0:
|
|
80
|
+
return docs[:top_k]
|
|
81
|
+
return docs
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "memoair-vapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "VAPI Custom Knowledge Base integration for MemoAir voice memory"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "MemoAir", email = "hello@memoair.dev" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
27
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
28
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
keywords = ["ai", "agents", "vapi", "memory", "memoair", "voice", "voice-agents"]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"memoair-voice>=0.3.2",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=7.0.0",
|
|
39
|
+
"pytest-asyncio>=0.21.0",
|
|
40
|
+
"mypy>=1.0.0",
|
|
41
|
+
"ruff>=0.1.0",
|
|
42
|
+
"black>=23.0.0",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://memoair.dev"
|
|
47
|
+
Documentation = "https://docs.memoair.dev/voice/integrations/vapi"
|
|
48
|
+
Repository = "https://github.com/memoair/memoair-python"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["memoair_vapi"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 100
|
|
55
|
+
target-version = "py39"
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "W", "F", "I", "B", "C4", "UP"]
|
|
59
|
+
ignore = ["E501", "B008"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint.isort]
|
|
62
|
+
known-first-party = ["memoair_vapi"]
|
|
63
|
+
|
|
64
|
+
[tool.mypy]
|
|
65
|
+
python_version = "3.9"
|
|
66
|
+
strict = true
|
|
67
|
+
warn_return_any = true
|
|
68
|
+
warn_unused_configs = true
|
|
69
|
+
disallow_untyped_defs = true
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
asyncio_mode = "auto"
|
|
73
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from memoair_voice import MemoAirVoiceMemoryError, SearchResult
|
|
5
|
+
|
|
6
|
+
from memoair_vapi._search import MemoAirVapiSearch
|
|
7
|
+
from memoair_vapi._types import VapiWebhookError
|
|
8
|
+
from memoair_vapi._webhook import KNOWLEDGE_BASE_REQUEST
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeVoiceClient:
|
|
12
|
+
"""In-memory stand-in for MemoAirVoiceClient (org_only)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, result: "SearchResult | None" = None, raises: "Exception | None" = None) -> None:
|
|
15
|
+
self._result = result
|
|
16
|
+
self._raises = raises
|
|
17
|
+
self.closed = False
|
|
18
|
+
self.calls: list = []
|
|
19
|
+
|
|
20
|
+
async def search_memory(self, query, *, lanes=None, timeout_ms=None, **kwargs):
|
|
21
|
+
self.calls.append({"query": query, "lanes": lanes, "timeout_ms": timeout_ms})
|
|
22
|
+
if self._raises is not None:
|
|
23
|
+
raise self._raises
|
|
24
|
+
return self._result
|
|
25
|
+
|
|
26
|
+
async def aclose(self) -> None:
|
|
27
|
+
self.closed = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _result(org: list, context_text: str = "") -> SearchResult:
|
|
31
|
+
return SearchResult(
|
|
32
|
+
contextText=context_text, profile=None, working=[],
|
|
33
|
+
permanent=[], org=org, sources=[], trace={},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _kb_payload(messages: list) -> dict:
|
|
38
|
+
return {"message": {"type": KNOWLEDGE_BASE_REQUEST, "messages": messages}}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _search(client: "FakeVoiceClient", **overrides) -> MemoAirVapiSearch:
|
|
42
|
+
kwargs = {"api_key": "memoair_pk_t", "project_id": "proj_t", "agent_id": "agent_t", "client": client}
|
|
43
|
+
kwargs.update(overrides)
|
|
44
|
+
return MemoAirVapiSearch(**kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_constructor_requires_api_key() -> None:
|
|
48
|
+
with pytest.raises(ValueError, match="api_key"):
|
|
49
|
+
MemoAirVapiSearch(api_key="", project_id="p", agent_id="a")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def test_search_queries_org_lane() -> None:
|
|
53
|
+
client = FakeVoiceClient(_result([
|
|
54
|
+
{"chunkId": "c1", "text": "hello", "score": 0.9},
|
|
55
|
+
]))
|
|
56
|
+
search = _search(client, search_timeout_ms=300)
|
|
57
|
+
resp = await search.search("hi there")
|
|
58
|
+
assert client.calls == [{"query": "hi there", "lanes": ["org"], "timeout_ms": 300}]
|
|
59
|
+
assert resp.to_dict() == {"documents": [{"content": "hello", "similarity": 0.9, "uuid": "c1"}]}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def test_handle_request_happy_path() -> None:
|
|
63
|
+
client = FakeVoiceClient(_result([{"chunkId": "c1", "text": "answer", "score": 0.8}]))
|
|
64
|
+
search = _search(client)
|
|
65
|
+
out = await search.handle_request(_kb_payload([{"role": "user", "content": "q?"}]))
|
|
66
|
+
assert out == {"documents": [{"content": "answer", "similarity": 0.8, "uuid": "c1"}]}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def test_handle_request_non_kb_type_acks() -> None:
|
|
70
|
+
client = FakeVoiceClient(_result([]))
|
|
71
|
+
search = _search(client)
|
|
72
|
+
out = await search.handle_request({"message": {"type": "end-of-call-report"}})
|
|
73
|
+
assert out == {}
|
|
74
|
+
assert client.calls == []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def test_handle_request_malformed_raises() -> None:
|
|
78
|
+
search = _search(FakeVoiceClient(_result([])))
|
|
79
|
+
with pytest.raises(VapiWebhookError):
|
|
80
|
+
await search.handle_request(_kb_payload([{"role": "assistant", "content": "x"}]))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def test_handle_request_non_object_payload_raises() -> None:
|
|
84
|
+
search = _search(FakeVoiceClient(_result([])))
|
|
85
|
+
with pytest.raises(VapiWebhookError):
|
|
86
|
+
await search.handle_request([]) # type: ignore[arg-type]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def test_handle_request_search_failure_degrades(caplog: pytest.LogCaptureFixture) -> None:
|
|
90
|
+
client = FakeVoiceClient(raises=MemoAirVoiceMemoryError(code="runtime.timeout", message="boom"))
|
|
91
|
+
search = _search(client)
|
|
92
|
+
with caplog.at_level(logging.WARNING, logger="memoair_vapi"):
|
|
93
|
+
out = await search.handle_request(_kb_payload([{"role": "user", "content": "q?"}]))
|
|
94
|
+
assert out == {"documents": []}
|
|
95
|
+
assert "vapi search failed" in caplog.text
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def test_aclose_closes_client() -> None:
|
|
99
|
+
client = FakeVoiceClient(_result([]))
|
|
100
|
+
search = _search(client)
|
|
101
|
+
await search.aclose()
|
|
102
|
+
assert client.closed is True
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
|
|
4
|
+
from memoair_vapi._signature import verify_vapi_signature
|
|
5
|
+
|
|
6
|
+
SECRET = "shh-secret"
|
|
7
|
+
BODY = b'{"message":{"type":"knowledge-base-request"}}'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _sig(body: bytes, secret: str) -> str:
|
|
11
|
+
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_valid_raw_hex_signature() -> None:
|
|
15
|
+
assert verify_vapi_signature(BODY, _sig(BODY, SECRET), SECRET) is True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_valid_sha256_prefixed_signature() -> None:
|
|
19
|
+
assert verify_vapi_signature(BODY, f"sha256={_sig(BODY, SECRET)}", SECRET) is True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_invalid_signature() -> None:
|
|
23
|
+
assert verify_vapi_signature(BODY, _sig(BODY, "wrong"), SECRET) is False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_tampered_body() -> None:
|
|
27
|
+
assert verify_vapi_signature(b"tampered", _sig(BODY, SECRET), SECRET) is False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_missing_secret_or_header() -> None:
|
|
31
|
+
assert verify_vapi_signature(BODY, _sig(BODY, SECRET), "") is False
|
|
32
|
+
assert verify_vapi_signature(BODY, "", SECRET) is False
|
|
33
|
+
assert verify_vapi_signature(b"", _sig(BODY, SECRET), SECRET) is False
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from memoair_voice import SearchResult
|
|
3
|
+
|
|
4
|
+
from memoair_vapi._types import VapiWebhookError
|
|
5
|
+
from memoair_vapi._webhook import (
|
|
6
|
+
KNOWLEDGE_BASE_REQUEST,
|
|
7
|
+
documents_from_search_result,
|
|
8
|
+
extract_query,
|
|
9
|
+
message_type,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _kb_payload(messages: list) -> dict:
|
|
14
|
+
return {"message": {"type": KNOWLEDGE_BASE_REQUEST, "messages": messages}}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_extract_query_returns_latest_user_message() -> None:
|
|
18
|
+
payload = _kb_payload([
|
|
19
|
+
{"role": "user", "content": "first"},
|
|
20
|
+
{"role": "assistant", "content": "ok"},
|
|
21
|
+
{"role": "user", "content": "latest question"},
|
|
22
|
+
])
|
|
23
|
+
assert extract_query(payload) == "latest question"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_extract_query_missing_message_raises() -> None:
|
|
27
|
+
with pytest.raises(VapiWebhookError):
|
|
28
|
+
extract_query({})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_extract_query_empty_messages_raises() -> None:
|
|
32
|
+
with pytest.raises(VapiWebhookError):
|
|
33
|
+
extract_query({"message": {"type": KNOWLEDGE_BASE_REQUEST, "messages": []}})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_extract_query_no_user_turn_raises() -> None:
|
|
37
|
+
with pytest.raises(VapiWebhookError):
|
|
38
|
+
extract_query(_kb_payload([{"role": "assistant", "content": "hi"}]))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_message_type() -> None:
|
|
42
|
+
assert message_type(_kb_payload([])) == KNOWLEDGE_BASE_REQUEST
|
|
43
|
+
assert message_type({"message": {"type": "end-of-call-report"}}) == "end-of-call-report"
|
|
44
|
+
assert message_type({}) is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _result(org: list, context_text: str = "") -> SearchResult:
|
|
48
|
+
return SearchResult(
|
|
49
|
+
contextText=context_text, profile=None, working=[],
|
|
50
|
+
permanent=[], org=org, sources=[], trace={},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_documents_from_org_items() -> None:
|
|
55
|
+
result = _result([
|
|
56
|
+
{"docId": "doc_42", "chunkId": "doc_42:7", "text": "Standup is 10:30 IST.", "score": 0.71},
|
|
57
|
+
{"docId": "doc_9", "chunkId": "doc_9:1", "text": "Returns within 30 days.", "score": 0.6},
|
|
58
|
+
])
|
|
59
|
+
docs = documents_from_search_result(result, top_k=5)
|
|
60
|
+
assert [d.to_dict() for d in docs] == [
|
|
61
|
+
{"content": "Standup is 10:30 IST.", "similarity": 0.71, "uuid": "doc_42:7"},
|
|
62
|
+
{"content": "Returns within 30 days.", "similarity": 0.6, "uuid": "doc_9:1"},
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_documents_top_k_truncates() -> None:
|
|
67
|
+
result = _result([
|
|
68
|
+
{"chunkId": f"c{i}", "text": f"t{i}", "score": 0.5} for i in range(10)
|
|
69
|
+
])
|
|
70
|
+
assert len(documents_from_search_result(result, top_k=3)) == 3
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_documents_fallback_to_context_text() -> None:
|
|
74
|
+
result = _result([], context_text="some recalled context")
|
|
75
|
+
docs = documents_from_search_result(result, top_k=5)
|
|
76
|
+
assert [d.to_dict() for d in docs] == [{"content": "some recalled context"}]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_documents_empty_when_no_org_and_no_context() -> None:
|
|
80
|
+
assert documents_from_search_result(_result([]), top_k=5) == []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_documents_defensive_keys_and_zero_score() -> None:
|
|
84
|
+
result = _result([
|
|
85
|
+
{"docId": "d1", "text": "zero score kept", "score": 0.0},
|
|
86
|
+
{"content": "alt content key", "similarity": 0.4, "docId": "d2"},
|
|
87
|
+
"not-a-mapping",
|
|
88
|
+
{"chunk": "alt chunk key", "id": "d3"},
|
|
89
|
+
])
|
|
90
|
+
docs = documents_from_search_result(result, top_k=10)
|
|
91
|
+
assert [d.to_dict() for d in docs] == [
|
|
92
|
+
{"content": "zero score kept", "similarity": 0.0, "uuid": "d1"},
|
|
93
|
+
{"content": "alt content key", "similarity": 0.4, "uuid": "d2"},
|
|
94
|
+
{"content": "alt chunk key", "uuid": "d3"},
|
|
95
|
+
]
|