Skip to content

Commit 2b8525b

Browse files
authored
Merge pull request #278 from gitcoinco/feat/update-chat
feat: improve chat context
2 parents c4a927b + a257e05 commit 2b8525b

5 files changed

Lines changed: 318 additions & 76 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"publish-research": "npx tsx scripts/publish-research.ts",
1414
"publish-campaign": "npx tsx scripts/publish-campaign.ts",
1515
"publish-all": "sh scripts/publish-all-issues.sh",
16-
"sync-docs": "npx tsx --env-file-if-exists=.env.local scripts/sync-docs.ts",
16+
"sync-docs": "npx tsx --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/sync-docs.ts",
1717
"banner:pick": "npx tsx scripts/open-banner.ts",
1818
"banner:auto": "npx tsx scripts/generate-banners.ts"
1919
},

scripts/sync-docs.ts

Lines changed: 162 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
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

1116
import fs from "node:fs";
1217
import path from "node:path";
1318
import crypto from "node:crypto";
1419
import { glob } from "node:fs/promises";
1520
import OpenAI from "openai";
21+
import matter from "gray-matter";
1622

1723
const 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");
1826
const MANIFEST_PATH = path.resolve(process.cwd(), "scripts/.vector-manifest.json");
1927
const VECTOR_STORE_NAME = "gitcoin-funding-directory";
2028

@@ -25,6 +33,10 @@ interface ManifestEntry {
2533

2634
type Manifest = Record<string, ManifestEntry>;
2735

36+
function sha256Buffer(content: Buffer): string {
37+
return crypto.createHash("sha256").update(content).digest("hex");
38+
}
39+
2840
function 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+
49111
async 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(/^name:\s*["']?(.+?)["']?\s*$/m);
230+
const ctaMatch = mdContent.match(/^ctaUrl:\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

Comments
 (0)