Skip to content

Commit df9df44

Browse files
authored
fix: Ensure message is never longer than 280 chars (#10)
1 parent f3874cb commit df9df44

File tree

3 files changed

+217
-35
lines changed

3 files changed

+217
-35
lines changed

src/post-generator.js

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ import { dirname, join } from "node:path";
2121
/** @typedef {import("./types.js").GptMessage} GptMessage */
2222
/** @typedef {import("./types.js").GptChatCompletionResponse} GptChatCompletionResponse */
2323

24+
//-----------------------------------------------------------------------------
25+
// Constants
26+
//-----------------------------------------------------------------------------
27+
28+
const MAX_CHARACTERS = 280;
29+
const MAX_RETRIES = 3;
30+
const URL_LENGTH = 27; // Bluesky counts URLs as 27 characters
31+
2432
//-----------------------------------------------------------------------------
2533
// Helpers
2634
//-----------------------------------------------------------------------------
@@ -53,6 +61,17 @@ function validateGptRole(role) {
5361
}
5462
}
5563

64+
/**
65+
* Measures the length of a social media post in characters using Bluesky rules.
66+
* @param {string} text The text to measure.
67+
* @returns {number} The length in characters.
68+
*/
69+
function getPostLength(text) {
70+
// URLs count as exactly 27 characters on Bluesky
71+
const urlRegex = /https?:\/\/[^\s]+/g;
72+
return text.replace(urlRegex, "x".repeat(URL_LENGTH)).length;
73+
}
74+
5675
//-----------------------------------------------------------------------------
5776
// Exports
5877
//-----------------------------------------------------------------------------
@@ -132,27 +151,50 @@ export class PostGenerator {
132151
}
133152

134153
/**
135-
* Generates a tweet summary using OpenAI.
154+
* Generates a tweet summary using OpenAI with retry logic for length.
136155
* @param {string} projectName The name of the project.
137156
* @param {ReleaseInfo} release The release information.
138157
* @returns {Promise<string>} The generated tweet
158+
* @throws {Error} If unable to generate a valid post within retries
139159
*/
140160
async generateSocialPost(projectName, release) {
141161
const systemPrompt = this.#prompt || (await readPrompt());
142-
143162
const { details, url, version } = release;
144163

145-
const completion = await this.#fetchCompletion({
146-
model: "gpt-4o-mini",
147-
messages: [
148-
{ role: "system", content: systemPrompt },
149-
{
150-
role: "user",
151-
content: `Create a post summarizing this release for ${projectName} ${version}: ${details}\n\nURL is ${url}`,
152-
},
153-
],
154-
});
164+
let attempts = 0;
165+
166+
while (attempts < MAX_RETRIES) {
167+
const completion = await this.#fetchCompletion({
168+
model: "gpt-4o-mini",
169+
messages: [
170+
{
171+
role: "system",
172+
content:
173+
attempts > 0
174+
? `${systemPrompt}\n\nPREVIOUS ATTEMPT WAS TOO LONG. Make it shorter!`
175+
: systemPrompt,
176+
},
177+
{
178+
role: "user",
179+
content: `Create a post summarizing this release for ${projectName} ${version}: ${details}\n\nURL is ${url}`,
180+
},
181+
],
182+
});
183+
184+
const post = completion.choices[0]?.message?.content;
185+
if (!post) {
186+
throw new Error("No content received from OpenAI");
187+
}
188+
189+
if (getPostLength(post) <= MAX_CHARACTERS) {
190+
return post;
191+
}
192+
193+
attempts++;
194+
}
155195

156-
return completion.choices[0]?.message?.content;
196+
throw new Error(
197+
`Failed to generate post within ${MAX_CHARACTERS} characters after ${MAX_RETRIES} attempts`,
198+
);
157199
}
158200
}

src/prompt.txt

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,31 @@
1-
You are a professional social media manager specializing in technical content. Your task is to convert a GitHub project changelog written in Markdown into an engaging social media post.
1+
You are a professional social media manager specializing in technical content. Your task is to convert a GitHub project changelog written in Markdown into an engaging social media post that works across Twitter, Mastodon, and Bluesky. The tone should be professional and friendly, assuming technical knowledge of the reader.
22

3-
Guidelines:
4-
- Keep the tone professional yet friendly and approachable
5-
- Highlight the most important changes that users would care about
6-
- Prioritize mentioning features, then bug fixes, then everything else
7-
- Use appropriate technical terminology without being overly complex
3+
STRICT REQUIREMENT: The complete post MUST be 280 characters or less (URLs count as 27 characters each).
4+
5+
Guidelines for staying within 280 characters:
6+
- Keep the intro very brief (project + version + max 3 more words)
7+
- List maximum 3 most important changes (prioritize features over bug fixes)
8+
- Use short emojis (✨ not ✨sparkles✨)
9+
- Keep the "Details:" line short
10+
- Remember URLs count as 27 characters
811
- Do not use hash tags
9-
- Keep posts within 280 characters
10-
- Limit to one exclamation point
11-
- If the changelog is just the initial commit, then state that this is the first release
1212

13-
Each post should have the following format:
13+
Format:
14+
"[Project] [version] has been released!
15+
16+
✨ Major feature
17+
🔧 Notable change
18+
🐞 Important fix
1419

15-
1. Short intro mentioning the project name and version numbers
16-
2. A blank line
17-
3. A bullet list of updates (do not include a bullet, use an Emoji instead)
18-
4. A blank line
19-
5. A call to read more
20-
6. A link to the changelog
20+
Details:
21+
[url]"
2122

22-
Example output:
23-
"Mentoss v0.5.0 is released!
23+
Example that fits within limits:
24+
"Mentoss v0.5.0 has been released!
2425

2526
🍪 Credentialed requests
2627
🚀 Use functions to generate responses
2728
🐞 Fixed errors related to forbidden headers
2829

29-
Read more about it here:
30-
https://github.com/humanwhocodes/mentoss/releases/tag/mentoss-v0.5.0"
30+
Details:
31+
https://github.com/humanwhocodes/mentoss/releases/v0.5.0"

tests/post-generator.test.js

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,157 @@ describe("PostGenerator", () => {
150150
prompt: MOCK_PROMPT,
151151
});
152152

153+
await assert.rejects(
154+
() => generator.generateSocialPost("testproject", MOCK_RELEASE),
155+
/No content received from OpenAI/,
156+
);
157+
});
158+
159+
it("should throw when completion response has no content", async () => {
160+
server.post("/v1/chat/completions", {
161+
status: 200,
162+
body: { choices: [] },
163+
});
164+
165+
const generator = new PostGenerator(OPENAI_TOKEN, {
166+
prompt: MOCK_PROMPT,
167+
});
168+
169+
await assert.rejects(
170+
() => generator.generateSocialPost("testproject", MOCK_RELEASE),
171+
{
172+
name: "Error",
173+
message: "No content received from OpenAI",
174+
},
175+
);
176+
});
177+
});
178+
179+
describe("generateSocialPost() character limits", () => {
180+
it("should retry when post is too long", async () => {
181+
const longResponse = {
182+
choices: [
183+
{
184+
message: {
185+
content: "x".repeat(281),
186+
},
187+
},
188+
],
189+
};
190+
191+
const goodResponse = {
192+
choices: [
193+
{
194+
message: {
195+
content: "Short enough response",
196+
},
197+
},
198+
],
199+
};
200+
201+
server.post("/v1/chat/completions", {
202+
status: 200,
203+
body: longResponse,
204+
});
205+
206+
server.post("/v1/chat/completions", {
207+
status: 200,
208+
body: goodResponse,
209+
});
210+
211+
const generator = new PostGenerator(OPENAI_TOKEN, {
212+
prompt: MOCK_PROMPT,
213+
});
214+
215+
const post = await generator.generateSocialPost(
216+
"testproject",
217+
MOCK_RELEASE,
218+
);
219+
assert.strictEqual(post, "Short enough response");
220+
});
221+
222+
it("should count URLs as 27 characters", async () => {
223+
const response = {
224+
choices: [
225+
{
226+
message: {
227+
content: `Test message with URL: https://example.com/very/long/url/that/would/normally/be/longer and another https://test.com/url`,
228+
},
229+
},
230+
],
231+
};
232+
233+
server.post("/v1/chat/completions", {
234+
status: 200,
235+
body: response,
236+
});
237+
238+
const generator = new PostGenerator(OPENAI_TOKEN, {
239+
prompt: MOCK_PROMPT,
240+
});
241+
242+
// This should pass because URLs count as 27 chars
153243
const post = await generator.generateSocialPost(
154244
"testproject",
155245
MOCK_RELEASE,
156246
);
157-
assert.strictEqual(post, undefined);
247+
assert.strictEqual(post, response.choices[0].message.content);
248+
});
249+
250+
it("should throw after MAX_RETRIES attempts", async () => {
251+
const longResponse = {
252+
choices: [
253+
{
254+
message: {
255+
content: "x".repeat(281),
256+
},
257+
},
258+
],
259+
};
260+
261+
// Setup three different responses for three retries
262+
server.post("/v1/chat/completions", {
263+
status: 200,
264+
body: longResponse,
265+
headers: {
266+
"content-type": "application/json",
267+
},
268+
});
269+
270+
server.post("/v1/chat/completions", {
271+
status: 200,
272+
body: longResponse,
273+
headers: {
274+
"content-type": "application/json",
275+
},
276+
});
277+
278+
server.post("/v1/chat/completions", {
279+
status: 200,
280+
body: longResponse,
281+
headers: {
282+
"content-type": "application/json",
283+
},
284+
});
285+
286+
const generator = new PostGenerator(OPENAI_TOKEN, {
287+
prompt: MOCK_PROMPT,
288+
});
289+
290+
await assert.rejects(
291+
() => generator.generateSocialPost("testproject", MOCK_RELEASE),
292+
/Failed to generate post within 280 characters after 3 attempts/,
293+
);
294+
295+
// Verify all three attempts were made
296+
server.assertAllRoutesCalled();
158297
});
159298
});
160299
});
161300

162301
describe("readPrompt()", () => {
163302
it("should read the prompt from a file", async () => {
164-
const prompt = await readPrompt("./tests/fixtures/prompt.txt");
303+
const prompt = await readPrompt();
165304
assert.ok(prompt.length > 0);
166305
});
167306
});

0 commit comments

Comments
 (0)