voyageai-cli 1.20.6 → 1.22.0

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +142 -26
  2. package/README.md +130 -2
  3. package/package.json +3 -2
  4. package/src/cli.js +10 -0
  5. package/src/commands/bug.js +249 -0
  6. package/src/commands/eval.js +420 -10
  7. package/src/commands/generate.js +220 -0
  8. package/src/commands/playground.js +93 -0
  9. package/src/commands/purge.js +271 -0
  10. package/src/commands/refresh.js +322 -0
  11. package/src/commands/scaffold.js +217 -0
  12. package/src/lib/codegen.js +339 -0
  13. package/src/lib/explanations.js +155 -0
  14. package/src/lib/scaffold-structure.js +114 -0
  15. package/src/lib/templates/nextjs/README.md.tpl +106 -0
  16. package/src/lib/templates/nextjs/env.example.tpl +8 -0
  17. package/src/lib/templates/nextjs/layout.jsx.tpl +29 -0
  18. package/src/lib/templates/nextjs/lib-mongo.js.tpl +111 -0
  19. package/src/lib/templates/nextjs/lib-voyage.js.tpl +103 -0
  20. package/src/lib/templates/nextjs/package.json.tpl +33 -0
  21. package/src/lib/templates/nextjs/page-search.jsx.tpl +147 -0
  22. package/src/lib/templates/nextjs/route-ingest.js.tpl +114 -0
  23. package/src/lib/templates/nextjs/route-search.js.tpl +97 -0
  24. package/src/lib/templates/nextjs/theme.js.tpl +84 -0
  25. package/src/lib/templates/python/README.md.tpl +145 -0
  26. package/src/lib/templates/python/app.py.tpl +221 -0
  27. package/src/lib/templates/python/chunker.py.tpl +127 -0
  28. package/src/lib/templates/python/env.example.tpl +12 -0
  29. package/src/lib/templates/python/mongo_client.py.tpl +125 -0
  30. package/src/lib/templates/python/requirements.txt.tpl +10 -0
  31. package/src/lib/templates/python/voyage_client.py.tpl +124 -0
  32. package/src/lib/templates/vanilla/README.md.tpl +156 -0
  33. package/src/lib/templates/vanilla/client.js.tpl +103 -0
  34. package/src/lib/templates/vanilla/connection.js.tpl +126 -0
  35. package/src/lib/templates/vanilla/env.example.tpl +11 -0
  36. package/src/lib/templates/vanilla/ingest.js.tpl +231 -0
  37. package/src/lib/templates/vanilla/package.json.tpl +31 -0
  38. package/src/lib/templates/vanilla/retrieval.js.tpl +100 -0
  39. package/src/lib/templates/vanilla/search-api.js.tpl +175 -0
  40. package/src/lib/templates/vanilla/server.js.tpl +81 -0
  41. package/src/lib/zip.js +130 -0
  42. package/src/playground/index.html +708 -3
@@ -0,0 +1,221 @@
1
+ """
2
+ Voyage AI RAG API (Flask)
3
+ Generated by vai v{{vaiVersion}} on {{generatedAt}}
4
+
5
+ Endpoints:
6
+ - POST /api/search - Semantic search with optional reranking
7
+ - POST /api/ingest - Document ingestion
8
+ - GET /api/health - Health check
9
+ """
10
+
11
+ import os
12
+ import time
13
+ from flask import Flask, request, jsonify
14
+ from dotenv import load_dotenv
15
+
16
+ load_dotenv()
17
+
18
+ from voyage_client import embed_query{{#if rerank}}, rerank{{/if}}
19
+ from mongo_client import connect, vector_search, insert_documents, close
20
+ from chunker import chunk_text
21
+
22
+ app = Flask(__name__)
23
+
24
+
25
+ @app.route("/")
26
+ def index():
27
+ """API information."""
28
+ return jsonify({
29
+ "name": "{{projectName}}",
30
+ "description": "Voyage AI RAG API",
31
+ "model": "{{model}}",
32
+ "database": "{{db}}",
33
+ "collection": "{{collection}}",
34
+ "endpoints": {
35
+ "search": "POST /api/search",
36
+ "ingest": "POST /api/ingest",
37
+ "health": "GET /api/health",
38
+ },
39
+ })
40
+
41
+
42
+ @app.route("/api/search", methods=["POST"])
43
+ def search():
44
+ """
45
+ Semantic search endpoint.
46
+
47
+ Request body:
48
+ {
49
+ "query": "What is vector search?",
50
+ "limit": 5,
51
+ "include_context": true
52
+ }
53
+ """
54
+ start = time.time()
55
+ data = request.get_json()
56
+
57
+ query = data.get("query")
58
+ if not query:
59
+ return jsonify({"error": "Query is required"}), 400
60
+
61
+ limit = data.get("limit", 5)
62
+ {{#if rerank}}
63
+ candidates = data.get("candidates", 20)
64
+ {{/if}}
65
+ include_context = data.get("include_context", False)
66
+ filter_query = data.get("filter")
67
+
68
+ # Step 1: Embed the query
69
+ query_embedding = embed_query(query)
70
+
71
+ # Step 2: Vector search
72
+ {{#if rerank}}
73
+ search_results = vector_search(query_embedding, limit=candidates, filter_query=filter_query)
74
+ {{else}}
75
+ search_results = vector_search(query_embedding, limit=limit, filter_query=filter_query)
76
+ {{/if}}
77
+
78
+ if not search_results:
79
+ return jsonify({
80
+ "results": [],
81
+ "meta": {"model": "{{model}}", "took": int((time.time() - start) * 1000)},
82
+ })
83
+
84
+ {{#if rerank}}
85
+ # Step 3: Rerank for better relevance
86
+ documents = [r["document"]["text"] for r in search_results]
87
+ rerank_result = rerank(query, documents, top_k=limit)
88
+
89
+ results = [
90
+ {
91
+ "text": search_results[r["index"]]["document"]["text"],
92
+ "score": r["relevance_score"],
93
+ "metadata": search_results[r["index"]]["document"].get("metadata", {}),
94
+ }
95
+ for r in rerank_result["results"]
96
+ ]
97
+ {{else}}
98
+ results = [
99
+ {
100
+ "text": r["document"]["text"],
101
+ "score": r["score"],
102
+ "metadata": r["document"].get("metadata", {}),
103
+ }
104
+ for r in search_results
105
+ ]
106
+ {{/if}}
107
+
108
+ response = {
109
+ "results": results,
110
+ "meta": {
111
+ "model": "{{model}}",
112
+ {{#if rerank}}
113
+ "rerank_model": "{{rerankModel}}",
114
+ {{/if}}
115
+ "took": int((time.time() - start) * 1000),
116
+ },
117
+ }
118
+
119
+ if include_context:
120
+ response["context"] = "\n\n---\n\n".join(r["text"] for r in results)
121
+
122
+ return jsonify(response)
123
+
124
+
125
+ @app.route("/api/ingest", methods=["POST"])
126
+ def ingest():
127
+ """
128
+ Document ingestion endpoint.
129
+
130
+ Request body:
131
+ {
132
+ "text": "Document content...",
133
+ "metadata": {"source": "api"}
134
+ }
135
+ """
136
+ start = time.time()
137
+ data = request.get_json()
138
+
139
+ text = data.get("text")
140
+ if not text:
141
+ return jsonify({"error": "Text is required"}), 400
142
+
143
+ metadata = data.get("metadata", {})
144
+ metadata["source"] = metadata.get("source", "api")
145
+
146
+ # Chunk the text
147
+ chunks = chunk_text(text)
148
+
149
+ # Embed all chunks
150
+ from voyage_client import embed
151
+ result = embed(chunks, input_type="document")
152
+ embeddings = result["embeddings"]
153
+
154
+ # Prepare documents
155
+ documents = [
156
+ {
157
+ "text": chunk,
158
+ "embedding": embeddings[i],
159
+ "metadata": {
160
+ **metadata,
161
+ "chunk_index": i,
162
+ "total_chunks": len(chunks),
163
+ },
164
+ }
165
+ for i, chunk in enumerate(chunks)
166
+ ]
167
+
168
+ # Insert into MongoDB
169
+ insert_result = insert_documents(documents)
170
+
171
+ return jsonify({
172
+ "success": True,
173
+ "chunks": len(chunks),
174
+ "tokens": result["usage"]["total_tokens"],
175
+ "took": int((time.time() - start) * 1000),
176
+ })
177
+
178
+
179
+ @app.route("/api/health", methods=["GET"])
180
+ def health():
181
+ """Health check endpoint."""
182
+ try:
183
+ connect()
184
+ return jsonify({
185
+ "status": "healthy",
186
+ "model": "{{model}}",
187
+ "database": "{{db}}",
188
+ "collection": "{{collection}}",
189
+ })
190
+ except Exception as e:
191
+ return jsonify({
192
+ "status": "unhealthy",
193
+ "error": str(e),
194
+ }), 503
195
+
196
+
197
+ @app.teardown_appcontext
198
+ def shutdown(exception=None):
199
+ """Clean up on shutdown."""
200
+ pass # Connection pooling handles this
201
+
202
+
203
+ if __name__ == "__main__":
204
+ port = int(os.getenv("PORT", 3000))
205
+ print(f"""
206
+ 🚀 Voyage AI RAG Server (Flask)
207
+
208
+ Model: {{model}}
209
+ Database: {{db}}.{{collection}}
210
+ {{#if rerank}}
211
+ Reranking: {{rerankModel}}
212
+ {{/if}}
213
+
214
+ Endpoints:
215
+ POST /api/search - Semantic search
216
+ POST /api/ingest - Document ingestion
217
+ GET /api/health - Health check
218
+
219
+ Running on http://localhost:{port}
220
+ """)
221
+ app.run(host="0.0.0.0", port=port, debug=os.getenv("FLASK_DEBUG", "0") == "1")
@@ -0,0 +1,127 @@
1
+ """
2
+ Text Chunker
3
+ Generated by vai v{{vaiVersion}} on {{generatedAt}}
4
+
5
+ Strategy: {{chunkStrategy}}
6
+ Size: {{chunkSize}} characters
7
+ Overlap: {{chunkOverlap}} characters
8
+ """
9
+
10
+ from typing import List
11
+
12
+
13
+ def chunk_text(
14
+ text: str,
15
+ size: int = {{chunkSize}},
16
+ overlap: int = {{chunkOverlap}},
17
+ ) -> List[str]:
18
+ """
19
+ Chunk text using recursive splitting with smart boundaries.
20
+
21
+ Args:
22
+ text: Text to chunk
23
+ size: Maximum chunk size in characters
24
+ overlap: Overlap between chunks
25
+
26
+ Returns:
27
+ List of text chunks
28
+ """
29
+ separators = ["\n\n", "\n", ". ", " "]
30
+
31
+ def split_recursive(text: str, sep_index: int = 0) -> List[str]:
32
+ if len(text) <= size:
33
+ stripped = text.strip()
34
+ return [stripped] if stripped else []
35
+
36
+ if sep_index >= len(separators):
37
+ # Fall back to fixed-size split
38
+ chunks = []
39
+ start = 0
40
+ while start < len(text):
41
+ chunk = text[start:start + size].strip()
42
+ if chunk:
43
+ chunks.append(chunk)
44
+ start += size - overlap
45
+ return chunks
46
+
47
+ separator = separators[sep_index]
48
+ parts = text.split(separator)
49
+ chunks = []
50
+ current = ""
51
+
52
+ for part in parts:
53
+ potential = f"{current}{separator}{part}" if current else part
54
+
55
+ if len(potential) <= size:
56
+ current = potential
57
+ else:
58
+ if current:
59
+ chunks.extend(split_recursive(current, sep_index + 1))
60
+ current = part
61
+
62
+ if current:
63
+ chunks.extend(split_recursive(current, sep_index + 1))
64
+
65
+ return chunks
66
+
67
+ return split_recursive(text)
68
+
69
+
70
+ def chunk_markdown(text: str, size: int = {{chunkSize}}, overlap: int = {{chunkOverlap}}) -> List[str]:
71
+ """
72
+ Chunk markdown with header awareness.
73
+ Tries to keep sections together when possible.
74
+ """
75
+ import re
76
+
77
+ # Split by headers (## or ###)
78
+ header_pattern = r"(^#{1,3}\s+.+$)"
79
+ parts = re.split(header_pattern, text, flags=re.MULTILINE)
80
+
81
+ chunks = []
82
+ current = ""
83
+
84
+ for part in parts:
85
+ if not part.strip():
86
+ continue
87
+
88
+ potential = f"{current}\n\n{part}" if current else part
89
+
90
+ if len(potential) <= size:
91
+ current = potential
92
+ else:
93
+ if current:
94
+ # Chunk the current section
95
+ chunks.extend(chunk_text(current, size, overlap))
96
+ current = part
97
+
98
+ if current:
99
+ chunks.extend(chunk_text(current, size, overlap))
100
+
101
+ return chunks
102
+
103
+
104
+ if __name__ == "__main__":
105
+ # Test chunking
106
+ sample = """
107
+ # Introduction
108
+
109
+ This is a sample document for testing the chunker.
110
+ It contains multiple paragraphs and sections.
111
+
112
+ ## Section 1
113
+
114
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
115
+ Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
116
+
117
+ ## Section 2
118
+
119
+ Ut enim ad minim veniam, quis nostrud exercitation ullamco.
120
+ Duis aute irure dolor in reprehenderit in voluptate velit.
121
+ """
122
+
123
+ chunks = chunk_text(sample)
124
+ print(f"Created {len(chunks)} chunks:")
125
+ for i, chunk in enumerate(chunks):
126
+ print(f"\n--- Chunk {i + 1} ({len(chunk)} chars) ---")
127
+ print(chunk[:200] + "..." if len(chunk) > 200 else chunk)
@@ -0,0 +1,12 @@
1
+ # Voyage AI API
2
+ VOYAGE_API_KEY=your_voyage_api_key_here
3
+
4
+ # MongoDB Atlas
5
+ MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/{{db}}?retryWrites=true&w=majority
6
+
7
+ # Server
8
+ PORT=3000
9
+ FLASK_DEBUG=0
10
+
11
+ # Optional: Override defaults
12
+ # VOYAGE_API_URL=https://api.voyageai.com/v1
@@ -0,0 +1,125 @@
1
+ """
2
+ MongoDB Connection Helper
3
+ Generated by vai v{{vaiVersion}} on {{generatedAt}}
4
+
5
+ Database: {{db}}
6
+ Collection: {{collection}}
7
+ """
8
+
9
+ import os
10
+ from datetime import datetime
11
+ from typing import List, Dict, Any, Optional
12
+ from pymongo import MongoClient
13
+
14
+ MONGODB_URI = os.getenv("MONGODB_URI")
15
+
16
+ if not MONGODB_URI:
17
+ raise ValueError("MONGODB_URI environment variable is required")
18
+
19
+ # Module-level connection (singleton pattern)
20
+ _client: Optional[MongoClient] = None
21
+ _db = None
22
+
23
+
24
+ def connect():
25
+ """Connect to MongoDB and return the database instance."""
26
+ global _client, _db
27
+
28
+ if _db is not None:
29
+ return _db
30
+
31
+ _client = MongoClient(MONGODB_URI)
32
+ _db = _client["{{db}}"]
33
+ print(f"Connected to MongoDB: {{db}}")
34
+ return _db
35
+
36
+
37
+ def get_collection():
38
+ """Get the documents collection."""
39
+ db = connect()
40
+ return db["{{collection}}"]
41
+
42
+
43
+ def close():
44
+ """Close the MongoDB connection."""
45
+ global _client, _db
46
+ if _client:
47
+ _client.close()
48
+ _client = None
49
+ _db = None
50
+
51
+
52
+ def vector_search(
53
+ embedding: List[float],
54
+ limit: int = 10,
55
+ num_candidates: Optional[int] = None,
56
+ filter_query: Optional[Dict] = None,
57
+ ) -> List[Dict[str, Any]]:
58
+ """
59
+ Perform a vector search on the collection.
60
+
61
+ Args:
62
+ embedding: Query embedding vector
63
+ limit: Number of results to return
64
+ num_candidates: Candidates to consider (default: limit * 10)
65
+ filter_query: Optional MongoDB filter
66
+
67
+ Returns:
68
+ List of documents with scores
69
+ """
70
+ collection = get_collection()
71
+ num_candidates = num_candidates or limit * 10
72
+
73
+ pipeline = [
74
+ {
75
+ "$vectorSearch": {
76
+ "index": "{{index}}",
77
+ "path": "{{field}}",
78
+ "queryVector": embedding,
79
+ "numCandidates": num_candidates,
80
+ "limit": limit,
81
+ }
82
+ },
83
+ {
84
+ "$project": {
85
+ "_id": 1,
86
+ "text": 1,
87
+ "metadata": 1,
88
+ "score": {"$meta": "vectorSearchScore"},
89
+ }
90
+ },
91
+ ]
92
+
93
+ # Add filter if provided
94
+ if filter_query:
95
+ pipeline[0]["$vectorSearch"]["filter"] = filter_query
96
+
97
+ results = list(collection.aggregate(pipeline))
98
+ return [{"document": doc, "score": doc.pop("score")} for doc in results]
99
+
100
+
101
+ def insert_documents(docs: List[Dict[str, Any]]) -> Dict[str, int]:
102
+ """
103
+ Insert documents with embeddings.
104
+
105
+ Args:
106
+ docs: List of dicts with 'text', 'embedding', and optional 'metadata'
107
+
108
+ Returns:
109
+ Dict with 'inserted_count'
110
+ """
111
+ collection = get_collection()
112
+
113
+ documents = [
114
+ {
115
+ "text": doc["text"],
116
+ "{{field}}": doc["embedding"],
117
+ "metadata": doc.get("metadata", {}),
118
+ "_embeddedAt": datetime.utcnow(),
119
+ "_model": "{{model}}",
120
+ }
121
+ for doc in docs
122
+ ]
123
+
124
+ result = collection.insert_many(documents)
125
+ return {"inserted_count": len(result.inserted_ids)}
@@ -0,0 +1,10 @@
1
+ # Voyage AI RAG Application
2
+ # Generated by vai v{{vaiVersion}}
3
+
4
+ flask>=2.3.0
5
+ pymongo>=4.6.0
6
+ requests>=2.31.0
7
+ python-dotenv>=1.0.0
8
+
9
+ # Optional: for production
10
+ # gunicorn>=21.0.0
@@ -0,0 +1,124 @@
1
+ """
2
+ Voyage AI API Client
3
+ Generated by vai v{{vaiVersion}} on {{generatedAt}}
4
+
5
+ Model: {{model}}
6
+ Dimensions: {{dimensions}}
7
+ """
8
+
9
+ import os
10
+ import requests
11
+ from typing import List, Optional, Dict, Any
12
+
13
+ VOYAGE_API_URL = os.getenv("VOYAGE_API_URL", "https://api.voyageai.com/v1")
14
+ VOYAGE_API_KEY = os.getenv("VOYAGE_API_KEY")
15
+
16
+ if not VOYAGE_API_KEY:
17
+ raise ValueError("VOYAGE_API_KEY environment variable is required")
18
+
19
+
20
+ def embed(
21
+ texts: List[str],
22
+ model: str = "{{model}}",
23
+ input_type: str = "{{inputType}}",
24
+ output_dimension: int = {{dimensions}},
25
+ ) -> Dict[str, Any]:
26
+ """
27
+ Generate embeddings for a list of texts using Voyage AI.
28
+
29
+ Args:
30
+ texts: List of texts to embed
31
+ model: Embedding model name
32
+ input_type: 'document' or 'query'
33
+ output_dimension: Output vector dimensions
34
+
35
+ Returns:
36
+ Dict with 'embeddings' (list of vectors) and 'usage' (token counts)
37
+ """
38
+ response = requests.post(
39
+ f"{VOYAGE_API_URL}/embeddings",
40
+ headers={
41
+ "Content-Type": "application/json",
42
+ "Authorization": f"Bearer {VOYAGE_API_KEY}",
43
+ },
44
+ json={
45
+ "model": model,
46
+ "input": texts,
47
+ "input_type": input_type,
48
+ "output_dimension": output_dimension,
49
+ },
50
+ )
51
+
52
+ response.raise_for_status()
53
+ data = response.json()
54
+
55
+ return {
56
+ "embeddings": [d["embedding"] for d in data["data"]],
57
+ "usage": data["usage"],
58
+ }
59
+
60
+
61
+ def embed_query(query: str, **kwargs) -> List[float]:
62
+ """Embed a single query, returning the embedding vector."""
63
+ result = embed([query], input_type="query", **kwargs)
64
+ return result["embeddings"][0]
65
+
66
+
67
+ def embed_documents(documents: List[str], **kwargs) -> List[List[float]]:
68
+ """Embed multiple documents, returning list of embedding vectors."""
69
+ result = embed(documents, input_type="document", **kwargs)
70
+ return result["embeddings"]
71
+
72
+
73
+ {{#if rerank}}
74
+ def rerank(
75
+ query: str,
76
+ documents: List[str],
77
+ model: str = "{{rerankModel}}",
78
+ top_k: Optional[int] = None,
79
+ ) -> Dict[str, Any]:
80
+ """
81
+ Rerank documents by relevance to a query.
82
+
83
+ Args:
84
+ query: The query to rank against
85
+ documents: List of documents to rerank
86
+ model: Rerank model name
87
+ top_k: Number of top results to return
88
+
89
+ Returns:
90
+ Dict with 'results' containing index, relevance_score, and document
91
+ """
92
+ payload = {
93
+ "model": model,
94
+ "query": query,
95
+ "documents": documents,
96
+ "return_documents": True,
97
+ }
98
+ if top_k:
99
+ payload["top_k"] = top_k
100
+
101
+ response = requests.post(
102
+ f"{VOYAGE_API_URL}/rerank",
103
+ headers={
104
+ "Content-Type": "application/json",
105
+ "Authorization": f"Bearer {VOYAGE_API_KEY}",
106
+ },
107
+ json=payload,
108
+ )
109
+
110
+ response.raise_for_status()
111
+ data = response.json()
112
+
113
+ return {
114
+ "results": [
115
+ {
116
+ "index": d["index"],
117
+ "relevance_score": d["relevance_score"],
118
+ "document": d.get("document"),
119
+ }
120
+ for d in data["data"]
121
+ ],
122
+ "usage": data.get("usage"),
123
+ }
124
+ {{/if}}