11/**
2- * Sync content markdown files to an OpenAI vector store for RAG-based chat.
2+ * Sync content markdown files and PDFs to an OpenAI vector store for RAG-based chat.
33 *
44 * Usage:
55 * OPENAI_API_KEY=sk-... OPENAI_VECTOR_STORE_ID=vs_... npx tsx scripts/sync-docs.ts
66 *
77 * If OPENAI_VECTOR_STORE_ID is not set, a new vector store will be created
88 * and its ID will be printed so you can add it to your .env.local.
9+ *
10+ * Sources synced:
11+ * - src/content/**\/*.md — all content markdown files
12+ * - src/content/research/greenpill-books/*.pdf — Greenpill book PDFs
13+ * - public/content-images/research/**\/*.pdf — research article PDFs
914 */
1015
1116import fs from "node:fs" ;
1217import path from "node:path" ;
1318import crypto from "node:crypto" ;
1419import { glob } from "node:fs/promises" ;
1520import OpenAI from "openai" ;
21+ import matter from "gray-matter" ;
1622
1723const CONTENT_DIR = path . resolve ( process . cwd ( ) , "src/content" ) ;
24+ const GREENPILL_DIR = path . resolve ( process . cwd ( ) , "src/content/research/greenpill-books" ) ;
25+ const PUBLIC_RESEARCH_DIR = path . resolve ( process . cwd ( ) , "public/content-images/research" ) ;
1826const MANIFEST_PATH = path . resolve ( process . cwd ( ) , "scripts/.vector-manifest.json" ) ;
1927const VECTOR_STORE_NAME = "gitcoin-funding-directory" ;
2028
@@ -25,6 +33,10 @@ interface ManifestEntry {
2533
2634type Manifest = Record < string , ManifestEntry > ;
2735
36+ function sha256Buffer ( content : Buffer ) : string {
37+ return crypto . createHash ( "sha256" ) . update ( content ) . digest ( "hex" ) ;
38+ }
39+
2840function sha256 ( content : string ) : string {
2941 return crypto . createHash ( "sha256" ) . update ( content , "utf8" ) . digest ( "hex" ) ;
3042}
@@ -46,6 +58,56 @@ function contentTypeFromPath(filePath: string): string {
4658 return relative . split ( path . sep ) [ 0 ] ; // e.g. "apps", "mechanisms"
4759}
4860
61+ type SyncResult = "uploaded" | "unchanged" ;
62+
63+ async function syncFile (
64+ client : OpenAI ,
65+ vectorStoreId : string ,
66+ manifestKey : string ,
67+ fileName : string ,
68+ fileContent : Buffer | string ,
69+ mimeType : string ,
70+ oldManifest : Manifest ,
71+ newManifest : Manifest ,
72+ oldPaths : Set < string > ,
73+ ) : Promise < SyncResult > {
74+ const hash =
75+ typeof fileContent === "string"
76+ ? sha256 ( fileContent )
77+ : sha256Buffer ( fileContent ) ;
78+
79+ oldPaths . delete ( manifestKey ) ;
80+
81+ const existing = oldManifest [ manifestKey ] ;
82+ if ( existing && existing . hash === hash ) {
83+ newManifest [ manifestKey ] = existing ;
84+ return "unchanged" ;
85+ }
86+
87+ if ( existing ) {
88+ try {
89+ await client . vectorStores . files . delete ( existing . fileId , { vector_store_id : vectorStoreId } ) ;
90+ await client . files . delete ( existing . fileId ) ;
91+ } catch {
92+ // File may already be deleted, continue
93+ }
94+ }
95+
96+ const blobPart = typeof fileContent === "string" ? fileContent : new Uint8Array ( fileContent ) ;
97+ const file = await client . files . create ( {
98+ file : new File ( [ blobPart ] , fileName , { type : mimeType } ) ,
99+ purpose : "assistants" ,
100+ } ) ;
101+
102+ await client . vectorStores . files . create ( vectorStoreId , {
103+ file_id : file . id ,
104+ } ) ;
105+
106+ newManifest [ manifestKey ] = { fileId : file . id , hash } ;
107+ console . log ( ` ↑ ${ manifestKey } ` ) ;
108+ return "uploaded" ;
109+ }
110+
49111async function main ( ) {
50112 const apiKey = process . env . OPENAI_API_KEY ;
51113 if ( ! apiKey ) {
@@ -65,67 +127,120 @@ async function main() {
65127 console . log ( `Add this to your .env.local:\n OPENAI_VECTOR_STORE_ID=${ vectorStoreId } \n` ) ;
66128 }
67129
68- // Collect all markdown files
130+ // Collect files
69131 const mdFiles : string [ ] = [ ] ;
70132 for await ( const entry of glob ( path . join ( CONTENT_DIR , "**/*.md" ) ) ) {
71133 mdFiles . push ( entry ) ;
72134 }
73135 mdFiles . sort ( ) ;
74- console . log ( `Found ${ mdFiles . length } markdown files.\n` ) ;
136+
137+ const greenpillPdfs : string [ ] = [ ] ;
138+ for await ( const entry of glob ( path . join ( GREENPILL_DIR , "*.pdf" ) ) ) {
139+ greenpillPdfs . push ( entry ) ;
140+ }
141+ greenpillPdfs . sort ( ) ;
142+
143+ const researchPdfs : string [ ] = [ ] ;
144+ for await ( const entry of glob ( path . join ( PUBLIC_RESEARCH_DIR , "**/*.pdf" ) ) ) {
145+ researchPdfs . push ( entry ) ;
146+ }
147+ researchPdfs . sort ( ) ;
148+
149+ console . log ( `Found ${ mdFiles . length } markdown files, ${ greenpillPdfs . length } greenpill PDFs, ${ researchPdfs . length } research PDFs.\n` ) ;
75150
76151 const oldManifest = loadManifest ( ) ;
77152 const newManifest : Manifest = { } ;
78153 const oldPaths = new Set ( Object . keys ( oldManifest ) ) ;
154+ const md = { uploaded : 0 , unchanged : 0 , deleted : 0 } ;
155+ const books = { uploaded : 0 , unchanged : 0 , deleted : 0 } ;
79156
80- let uploaded = 0 ;
81- let unchanged = 0 ;
82- let deleted = 0 ;
83-
157+ // Sync markdown files
84158 for ( const filePath of mdFiles ) {
85159 const relativePath = path . relative ( CONTENT_DIR , filePath ) ;
86160 const content = fs . readFileSync ( filePath , "utf8" ) ;
87161 const contentType = contentTypeFromPath ( filePath ) ;
88162 const slug = path . basename ( filePath , ".md" ) ;
89163
90- // Prepend metadata header for better retrieval context
91164 const pageUrl = `/${ contentType } /${ slug } ` ;
92- const enrichedContent = `[Content Type: ${ contentType } ] [Slug: ${ slug } ] [Page URL: ${ pageUrl } ]\n\n${ content } ` ;
93- const hash = sha256 ( enrichedContent ) ;
165+ const { data : fm } = matter ( content ) ;
166+ const authors = Array . isArray ( fm . authors ) && fm . authors . length
167+ ? ` [Authors: ${ fm . authors . join ( ", " ) } ]`
168+ : "" ;
169+ const enrichedContent = `[Content Type: ${ contentType } ] [Slug: ${ slug } ] [Page URL: ${ pageUrl } ]${ authors } \n\n${ content } ` ;
94170
95- oldPaths . delete ( relativePath ) ;
171+ const result = await syncFile (
172+ client , vectorStoreId ,
173+ `md:${ relativePath } ` ,
174+ `${ contentType } --${ slug } .md` ,
175+ enrichedContent , "text/markdown" ,
176+ oldManifest , newManifest , oldPaths ,
177+ ) ;
178+ result === "uploaded" ? md . uploaded ++ : md . unchanged ++ ;
179+ }
96180
97- // Check if unchanged
98- const existing = oldManifest [ relativePath ] ;
99- if ( existing && existing . hash === hash ) {
100- newManifest [ relativePath ] = existing ;
101- unchanged ++ ;
102- continue ;
103- }
181+ // Sync greenpill PDFs — upload PDF + a metadata companion so the bot knows what it is
182+ for ( const filePath of greenpillPdfs ) {
183+ const fileName = path . basename ( filePath ) ;
184+ const baseName = path . basename ( filePath , ".pdf" ) ;
185+ const content = fs . readFileSync ( filePath ) ;
104186
105- // If changed, delete old file first
106- if ( existing ) {
107- try {
108- await client . vectorStores . files . delete ( existing . fileId , { vector_store_id : vectorStoreId } ) ;
109- await client . files . delete ( existing . fileId ) ;
110- } catch {
111- // File may already be deleted, continue
112- }
113- }
187+ // PDF binary
188+ const r1 = await syncFile (
189+ client , vectorStoreId ,
190+ `greenpill:${ fileName } ` ,
191+ `greenpill--${ fileName } ` ,
192+ content , "application/pdf" ,
193+ oldManifest , newManifest , oldPaths ,
194+ ) ;
195+ r1 === "uploaded" ? books . uploaded ++ : books . unchanged ++ ;
196+
197+ // Metadata companion so the bot can identify and describe the book
198+ const meta = `[Content Type: greenpill-books] [Slug: ${ baseName } ] [Book: ${ baseName } ]\n\nThis is a book from the Greenpill library. File: greenpill--${ fileName } . The Greenpill series covers regenerative finance, public goods, and web3 ecosystem building.` ;
199+ const r2 = await syncFile (
200+ client , vectorStoreId ,
201+ `greenpill-meta:${ baseName } ` ,
202+ `greenpill--${ baseName } --meta.md` ,
203+ meta , "text/markdown" ,
204+ oldManifest , newManifest , oldPaths ,
205+ ) ;
206+ r2 === "uploaded" ? books . uploaded ++ : books . unchanged ++ ;
207+ }
114208
115- // Upload new file
116- const fileName = `${ contentType } --${ slug } .md` ;
117- const file = await client . files . create ( {
118- file : new File ( [ enrichedContent ] , fileName , { type : "text/markdown" } ) ,
119- purpose : "assistants" ,
120- } ) ;
209+ // Sync research PDFs — upload PDF + metadata companion derived from the markdown file
210+ for ( const filePath of researchPdfs ) {
211+ const slug = path . basename ( path . dirname ( filePath ) ) ;
212+ const fileName = path . basename ( filePath ) ;
213+ const content = fs . readFileSync ( filePath ) ;
121214
122- await client . vectorStores . files . create ( vectorStoreId , {
123- file_id : file . id ,
124- } ) ;
215+ // PDF binary
216+ const r1 = await syncFile (
217+ client , vectorStoreId ,
218+ `research-pdf:${ slug } /${ fileName } ` ,
219+ `research--${ slug } --${ fileName } ` ,
220+ content , "application/pdf" ,
221+ oldManifest , newManifest , oldPaths ,
222+ ) ;
223+ r1 === "uploaded" ? books . uploaded ++ : books . unchanged ++ ;
125224
126- newManifest [ relativePath ] = { fileId : file . id , hash } ;
127- uploaded ++ ;
128- console . log ( ` ↑ ${ relativePath } ` ) ;
225+ // Metadata companion — read ctaUrl and name from the corresponding markdown file
226+ const mdPath = path . join ( CONTENT_DIR , "research" , `${ slug } .md` ) ;
227+ if ( fs . existsSync ( mdPath ) ) {
228+ const mdContent = fs . readFileSync ( mdPath , "utf8" ) ;
229+ const nameMatch = mdContent . match ( / ^ n a m e : \s * [ " ' ] ? ( .+ ?) [ " ' ] ? \s * $ / m) ;
230+ const ctaMatch = mdContent . match ( / ^ c t a U r l : \s * [ " ' ] ? ( .+ ?) [ " ' ] ? \s * $ / m) ;
231+ const name = nameMatch ?. [ 1 ] ?? slug ;
232+ const ctaUrl = ctaMatch ?. [ 1 ] ?? `/content-images/research/${ slug } /${ fileName } ` ;
233+
234+ const meta = `[Content Type: research] [Slug: ${ slug } ] [Page URL: /research/${ slug } ] [CTA URL: ${ ctaUrl } ]\n\nThis is the full book content for "${ name } ". Read it at: ${ ctaUrl } . More information at: /research/${ slug } .` ;
235+ const r2 = await syncFile (
236+ client , vectorStoreId ,
237+ `research-pdf-meta:${ slug } ` ,
238+ `research--${ slug } --meta.md` ,
239+ meta , "text/markdown" ,
240+ oldManifest , newManifest , oldPaths ,
241+ ) ;
242+ r2 === "uploaded" ? books . uploaded ++ : books . unchanged ++ ;
243+ }
129244 }
130245
131246 // Delete files that no longer exist locally
@@ -137,13 +252,19 @@ async function main() {
137252 } catch {
138253 // Already gone
139254 }
140- deleted ++ ;
255+ if ( removedPath . startsWith ( "md:" ) ) {
256+ md . deleted ++ ;
257+ } else {
258+ books . deleted ++ ;
259+ }
141260 console . log ( ` ✕ ${ removedPath } (removed)` ) ;
142261 }
143262
144263 saveManifest ( newManifest ) ;
145264
146- console . log ( `\nDone. Uploaded: ${ uploaded } , Unchanged: ${ unchanged } , Deleted: ${ deleted } ` ) ;
265+ console . log ( `\nDone.` ) ;
266+ console . log ( ` Markdown — Uploaded: ${ md . uploaded } , Unchanged: ${ md . unchanged } , Deleted: ${ md . deleted } ` ) ;
267+ console . log ( ` Books — Uploaded: ${ books . uploaded } , Unchanged: ${ books . unchanged } , Deleted: ${ books . deleted } ` ) ;
147268 console . log ( `Vector store: ${ vectorStoreId } ` ) ;
148269}
149270
0 commit comments