Batch of LLM-assisted fixes/feature updates
This commit is contained in:
1 parent
7c6453160a
commit
05aaf394df
16 files changed
+5597
-912
No files matched your search
+125
-59
@@ -1,90 +1,156 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const OpenAI = require('openai');
|
||||
|
||||
// read optional config file
|
||||
const configPath = path.join(__dirname, 'llm_config.json');
|
||||
const llmOptions = fs.existsSync(configPath)
|
||||
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
||||
: {};
|
||||
const DEFAULT_MODEL = 'meta-llama/Llama-3.2-3B-Instruct-Turbo';
|
||||
const CONFIG_PATH = path.join(__dirname, 'llm_config.json');
|
||||
const PERSONALITY_PATH = path.join(__dirname, 'bot_personalities_dumb.txt');
|
||||
|
||||
console.log(`Read config from ${configPath}`);
|
||||
const FALLBACK_PERSONALITY = 'You are a friendly bot playing a collaborative story game';
|
||||
|
||||
function generateBotPersonality() {
|
||||
const personalities = fs.readFileSync(path.join(__dirname, 'bot_personalities_dumb.txt'), 'utf8')
|
||||
// Read the optional config file once, tolerating missing/corrupt files.
|
||||
function loadConfig() {
|
||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
||||
console.warn(`[LLMBot] ${CONFIG_PATH} must contain a JSON object; ignoring it.`);
|
||||
} catch (err) {
|
||||
console.warn(`[LLMBot] Could not parse ${CONFIG_PATH}: ${err.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function readLines(file) {
|
||||
try {
|
||||
return fs
|
||||
.readFileSync(file, 'utf8')
|
||||
.split('\n')
|
||||
.map(personality => personality.trim())
|
||||
.filter(personality => personality.length > 0);
|
||||
|
||||
if (personalities.length === 0) {
|
||||
return 'You are a friendly bot'; // Fallback if no personalities are found
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
} catch (err) {
|
||||
console.warn(`[LLMBot] Could not read ${file}: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
const randomIndex = Math.floor(Math.random() * personalities.length);
|
||||
return personalities[randomIndex];
|
||||
}
|
||||
|
||||
function resolvePersonalitiesFile(configured) {
|
||||
const requested = configured || process.env.YARN_BOT_PERSONALITIES;
|
||||
if (!requested) return PERSONALITY_PATH;
|
||||
return path.isAbsolute(requested) ? requested : path.join(__dirname, requested);
|
||||
}
|
||||
|
||||
function readPersonalities(file = PERSONALITY_PATH) {
|
||||
return readLines(file);
|
||||
}
|
||||
|
||||
function generateBotPersonality(file = PERSONALITY_PATH) {
|
||||
const personalities = readPersonalities(file);
|
||||
if (!personalities.length) return FALLBACK_PERSONALITY;
|
||||
return personalities[Math.floor(Math.random() * personalities.length)];
|
||||
}
|
||||
|
||||
function firstContent(response) {
|
||||
const content = response?.choices?.[0]?.message?.content;
|
||||
return typeof content === 'string' ? content.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM-backed bot. The constructor throws when the client cannot be
|
||||
* configured (for example, no API key); BotFactory catches that and
|
||||
* falls back to RandomBot so a misconfigured server keeps working.
|
||||
*/
|
||||
class LLMBot {
|
||||
constructor() {
|
||||
// Instantiate the OpenAI client with the config options we just read
|
||||
this.llm = new OpenAI(llmOptions);
|
||||
constructor(config) {
|
||||
const configPathExists = fs.existsSync(CONFIG_PATH);
|
||||
if (configPathExists) console.log(`[LLMBot] Read config from ${CONFIG_PATH}`);
|
||||
|
||||
// Prompt fragments we’ll reuse
|
||||
this.writePrompt =
|
||||
`You are playing a collaborative storytelling game.
|
||||
Continue the following story with exactly ONE short, creative sentence or sentence fragment.
|
||||
Do NOT repeat what’s already written.`;
|
||||
this.options = config && typeof config === 'object' ? config : loadConfig();
|
||||
this.model = this.options.model || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
||||
|
||||
this.votePrompt =
|
||||
`You are playing a collaborative storytelling game.
|
||||
Below is the story so far, followed by a list of possible next sentences.
|
||||
Reply with ONLY the number (0-indexed) of the sentence you like best.`;
|
||||
// Throws if OPENAI_API_KEY (or an apiKey in llm_config.json) is missing.
|
||||
this.llm = new OpenAI(this.options);
|
||||
|
||||
this.banterPrompt =
|
||||
`You are a playful, slightly sarcastic AI taking part in a writing-game chat.
|
||||
Keep it short, fun, and on-topic.`;
|
||||
this.writePrompt = [
|
||||
'You are playing a collaborative storytelling game.',
|
||||
'Continue the following story with exactly ONE short, creative sentence or sentence fragment.',
|
||||
'Do NOT repeat what is already written.',
|
||||
'Reply with the sentence only, no quotes, no preamble.'
|
||||
].join('\n');
|
||||
|
||||
this.limitPrompt = "Please do not exceed 25 words."
|
||||
this.votePrompt = [
|
||||
'You are playing a collaborative storytelling game.',
|
||||
'Below is the story so far, followed by a list of possible next sentences.',
|
||||
'Reply with ONLY the number of the sentence you like best. No other text.'
|
||||
].join('\n');
|
||||
|
||||
this.personality = generateBotPersonality();
|
||||
this.banterPrompt = [
|
||||
'You are a playful, slightly sarcastic AI taking part in a writing-game chat.',
|
||||
'Keep it short, fun, and on-topic (one sentence, under 20 words).'
|
||||
].join('\n');
|
||||
|
||||
this.limitPrompt = 'Please do not exceed 25 words.';
|
||||
|
||||
// Defaults to bot_personalities_dumb.txt; override with `personalitiesFile`
|
||||
// in the config or the YARN_BOT_PERSONALITIES environment variable.
|
||||
this.personalitiesFile = resolvePersonalitiesFile(this.options.personalitiesFile);
|
||||
this.personality = generateBotPersonality(this.personalitiesFile);
|
||||
}
|
||||
|
||||
async Write(story_so_far) {
|
||||
const msg = `${this.personality}. ${this.writePrompt} ${this.limitPrompt}\n\nStory so far:\n${story_so_far}`;
|
||||
async complete(messages, { temperature = 0.8, maxTokens = 80 } = {}) {
|
||||
const response = await this.llm.chat.completions.create({
|
||||
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
|
||||
messages: [{ role: 'user', content: msg }],
|
||||
temperature: llmOptions.temperature ?? 0.8,
|
||||
max_tokens: llmOptions.max_tokens ?? 80,
|
||||
model: this.model,
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens
|
||||
});
|
||||
//console.log(`Received response from LLM: ${response.choices[0].message.content}`);
|
||||
return response.choices[0].message.content.trim();
|
||||
return firstContent(response);
|
||||
}
|
||||
|
||||
async Vote(story_so_far, choices) {
|
||||
const choicesBlock = choices.map((c, i) => `${i}: ${(c.entry_text)}`).join('\n');
|
||||
const msg = `${this.personality}. ${this.votePrompt}\n\nStory:\n${story_so_far}\n\nChoices:\n${choicesBlock}`;
|
||||
//console.log(msg);
|
||||
const response = await this.llm.chat.completions.create({
|
||||
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
|
||||
messages: [{ role: 'user', content: msg }],
|
||||
async Write(storySoFar) {
|
||||
const msg = `${this.personality}.\n${this.writePrompt}\n${this.limitPrompt}\n\nStory so far:\n${storySoFar || '(the story has not started yet)'}`;
|
||||
const text = await this.complete([{ role: 'user', content: msg }], {
|
||||
temperature: this.options.temperature ?? 0.8,
|
||||
maxTokens: this.options.max_tokens ?? 80
|
||||
});
|
||||
// Strip wrapping quotes and stray newlines so entries read naturally.
|
||||
return text.replace(/^["'\s]+|["'\s]+$/g, '');
|
||||
}
|
||||
|
||||
async Vote(storySoFar, choices) {
|
||||
const list = Array.isArray(choices) ? choices : [];
|
||||
if (!list.length) return null;
|
||||
|
||||
const choicesBlock = list
|
||||
.map((choice, i) => `${choice.index ?? i}: ${choice.entry_text}`)
|
||||
.join('\n');
|
||||
const msg = `${this.personality}.\n${this.votePrompt}\n\nStory:\n${storySoFar}\n\nChoices:\n${choicesBlock}`;
|
||||
|
||||
const reply = await this.complete([{ role: 'user', content: msg }], {
|
||||
temperature: 0.2,
|
||||
max_tokens: 5,
|
||||
maxTokens: 8
|
||||
});
|
||||
const reply = response.choices[0].message.content.trim();
|
||||
const match = reply.match(/\d+/);
|
||||
return match ? parseInt(match[0], 10) : 0;
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseInt(match[0], 10);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
async Banter(chat_so_far) {
|
||||
const msg = `${this.banterPrompt}\n\nChat so far:\n${chat_so_far}`;
|
||||
const response = await this.llm.chat.completions.create({
|
||||
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
|
||||
messages: [{ role: 'user', content: msg }],
|
||||
temperature: llmOptions.temperature ?? 0.8,
|
||||
max_tokens: llmOptions.max_tokens ?? 60,
|
||||
async Banter(chatSoFar) {
|
||||
const msg = `${this.banterPrompt}\n\nRecent chat:\n${chatSoFar || '(nobody has spoken yet)'}`;
|
||||
return this.complete([{ role: 'user', content: msg }], {
|
||||
temperature: this.options.temperature ?? 0.8,
|
||||
maxTokens: this.options.max_tokens ?? 60
|
||||
});
|
||||
return response.choices[0].message.content.trim();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LLMBot;
|
||||
LLMBot.DEFAULT_MODEL = DEFAULT_MODEL;
|
||||
LLMBot.CONFIG_PATH = CONFIG_PATH;
|
||||
LLMBot.PERSONALITY_PATH = PERSONALITY_PATH;
|
||||
LLMBot.readPersonalities = readPersonalities;
|
||||
LLMBot.generateBotPersonality = generateBotPersonality;
|
||||
|
||||
module.exports = LLMBot;
|
||||
Reference in new issue
Block a user