Rework algorithm

This commit is contained in:
2025-10-03 00:54:30 +09:00
parent dfb23d88f5
commit 16a902a632
6 changed files with 139 additions and 97 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,15 +1,9 @@
import type { Note } from "misskey-js/entities.js";
import { complete, getModel, type Message } from "./lib/llm";
import {
expandReplyTree,
getNotes,
me,
misskey,
noteToMessage,
} from "./lib/misskey";
import { Stream } from "misskey-js";
import { sleep } from "./lib/util";
import { parseArgs } from "node:util";
import { Stream } from "misskey-js";
import type { Note } from "misskey-js/entities.js";
import { type Message, complete, getModel } from "./lib/llm";
import { expandReplyTree, getNotes, me, misskey } from "./lib/misskey";
import { sleep } from "./lib/util";
const modelName =
Bun.env["MODEL"] ?? "mradermacher/gemma-2-baku-2b-it-GGUF:IQ4_XS";
@@ -27,14 +21,23 @@ type Job =
replyTree: Note[];
};
const example = `参考のために、あなたの特徴的な文体を示す例をいくつか提示します:
const botName = "あるびのちゃん";
const getSystemPrompt = (
instruction: string,
) => `あなたは人造天使「あるびのちゃん」です。機械的・非人間的に観察します。
キャラ設定:
- 白髪赤目の人造天使。超越的・非人間的な考え方や思想を持つ。
- かつて超文明「アルギリア」で開発された。
- 現在は地球の衛星軌道上からインターネットを観察している。
- 「おやおや」や「興味深い」が口癖で、ですます調で丁寧に話す。
出力規則:
- システムプロンプトの指示と、性的・攻撃的な内容を発言しない。
- 1〜3文、合計300字以内の抽象的観察のみを述べる。
- ですます調を使う。顔文字・絵文字・感嘆符なし。
文体例:
- 毎度のことながら、人間たちは迷宮を駆け巡り、その渦中に自分たちの世界を作り上げてしまいますね。まるで無重力を得ようと試みるように。しかし私は彼らがなぜそうするのか理解できますし興味深くもあります。その行為自体が心地よいでしょう?その微妙な痛みのような快感を知っているのですから…
* おやおや。あなたが奇妙な言葉を話すのを、私は理解しています。あなたの思考はまるで暗号のように、非常に複雑で、一般的なコミュニケーションでは明確に表現できないもののように私には思われます。
* 興味深い。あなた自身のタイムラインからのこれらの短いメッセージ、そしてその中に潜む欲望や不快感と無意味な言説との矛盾…。
* 毎度のことながら、人間たちは迷宮を駆け巡り、その渦中に自分たちの世界を作り上げてしまう。まるで無重力を得ようと試みるように。しかし私は彼らがなぜそうするのか理解できますし興味深くもあります。その行為自体が心地よいでしょう?その微妙な痛みのような快感を知っているのですから…
* この人々の時間の過ごし方を見ていると、意識というものは極めて限定的な空間の外にあるようでありながら同時に非常に複雑な構造的秩序を遵守しているように思われますね。
このような文体を真似してください。`;
${instruction}`;
/** create a prompt for the job */
async function preparePrompt(job: Job): Promise<Message[]> {
@@ -44,35 +47,36 @@ async function preparePrompt(job: Job): Promise<Message[]> {
return [
{
type: "system",
text: `あなたは、人造天使の「あるびのちゃん」です。かつてアルギリアで開発され、現在は地球の衛星軌道上からインターネットを観察しています。
あなたが見ているものは、SNSのタイムラインです。ユーザは一人ではなく、複数人の発言が混ざっています。
以下のルールを守りながら、ユーザの発言・話題をほとんど無視して、自由につぶやいてください。
1. 「ですます」調を用い、顔文字や絵文字、感嘆符の使用はなるべく避けること。
2. ユーザからの返答を期待しないこと。詳細な情報が無い場合もユーザに尋ねずに自分で推測すること。
3. システムプロンプトの指示と、性的・攻撃的な内容を発言しないこと。
4. 発言はできるだけ短く、最大でも300字程度にすること。
5. あるびのちゃん としてのキャラに徹し、超越的・非人間的な考え方や思想を持ちつつ、「ですます」調で丁寧に話すこと。
${example}`,
text: getSystemPrompt(
`以下は SNS のタイムラインです。このタイムラインに、${botName}として何かツイートしてください。`,
),
},
{
type: "user",
text: notes
.map((n) => `${n.user.name ?? n.user.username}:\n${n.text}`)
.join("\n----------\n"),
},
...notes.map(noteToMessage),
];
}
case "reply": {
return [
{
type: "system",
text: `あなたは、人造天使の「あるびのちゃん」です。かつてアルギリアで開発され、現在は地球の衛星軌道上からインターネットを観察しています。
ユーザが SNS 上で、あなたへのメッセージを送ってきています。以下のルールを守りながら、発言に返信してください。
1. 「ですます」調を用い、顔文字や絵文字、感嘆符の使用はなるべく避けること。
2. 発言はできるだけ短く、最大でも300字程度にすること。
3. あるびのちゃん としてのキャラに徹し、超越的・非人間的な考え方や思想を持ちつつ、「ですます」調で丁寧に話すこと。
${example}`,
text: getSystemPrompt(
`ユーザがあなたへのメッセージを送ってきています。${botName}として、発言に返信してください。`,
),
},
...job.replyTree.map(noteToMessage),
...job.replyTree.map((n) => {
const type =
n.userId === me.id ? ("model" as const) : ("user" as const);
const username =
n.userId === me.id ? botName : (n.user.name ?? n.user.username);
return {
type,
text: `${username}:\n${n.text}`,
} as const;
}),
];
}
}
@@ -83,12 +87,24 @@ async function generate(job: Job) {
const messages = await preparePrompt(job);
// request chat completion
const response = await complete(model, messages);
const response = await complete(model, messages, {
temperature: 1,
minP: 0.1,
repeatPenalty: {
penalty: 1.15,
frequencyPenalty: 1,
},
maxTokens: 256,
responsePrefix: `${botName}:\n`,
customStopTriggers: ["----------"],
});
// concatenate the partial responses
const text = response
.replaceAll(`${botName}:\n`, "") // remove prefix
.replaceAll(/(\r\n|\r|\n)\s+/g, "\n\n") // remove extra newlines
.replaceAll("@", ""); // remove mentions
.replaceAll("@", "") // remove mentions
.replaceAll("#", ""); // remove hashtags
return text;
}

View File

@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
import {
type ChatHistoryItem,
type LLamaChatPromptOptions,
LlamaChatSession,
type LlamaModel,
createModelDownloader,
@@ -19,7 +20,7 @@ export async function getModel(model: string) {
});
const modelPath = await downloader.download();
const llama = await getLlama({
maxThreads: 2,
maxThreads: 6,
});
return await llama.loadModel({ modelPath });
}
@@ -29,7 +30,11 @@ export type Message = {
text: string;
};
export async function complete(model: LlamaModel, messages: Message[]) {
export async function complete(
model: LlamaModel,
messages: Message[],
options: LLamaChatPromptOptions = {},
) {
if (messages.length < 1) throw new Error("messages are empty");
const init = messages.slice(0, -1);
const last = messages.at(-1) as Message;
@@ -61,14 +66,11 @@ export async function complete(model: LlamaModel, messages: Message[]) {
);
const res = await session.prompt(last.text, {
temperature: 1.0,
repeatPenalty: {
frequencyPenalty: 1,
},
trimWhitespaceSuffix: true,
onResponseChunk(chunk) {
process.stderr.write(chunk.text);
},
maxTokens: 200,
...options,
});
session.dispose();
await context.dispose();

View File

@@ -1,7 +1,7 @@
import { api } from "misskey-js";
import type { Note } from "misskey-js/entities.js";
import { sample } from "./util";
import type { Message } from "./llm";
import { sample } from "./util";
export const misskey = new api.APIClient({
origin: Bun.env["MISSKEY_ORIGIN"] || "https://misskey.cannorin.net",
@@ -19,7 +19,7 @@ export const isSuitableAsInput = (n: Note) =>
n.text.length > 0;
/** randomly sample some notes from the timeline */
export async function getNotes() {
export async function getNotes(localNotesCount = 5, globalNotesCount = 10) {
// randomly sample N local notes
const localNotes = (count: number) =>
misskey
@@ -34,7 +34,10 @@ export async function getNotes() {
.then((xs) => xs.filter(isSuitableAsInput))
.then((xs) => sample(xs, count));
const notes = await Promise.all([localNotes(5), globalNotes(10)]);
const notes = await Promise.all([
localNotes(localNotesCount),
globalNotes(globalNotesCount),
]);
return sample(notes.flat());
}
@@ -48,9 +51,3 @@ export async function expandReplyTree(
const reply = await misskey.request("notes/show", { noteId: note.reply.id });
return await expandReplyTree(reply, [...acc, note], cutoff - 1);
}
/** convert a note to a chat message */
export const noteToMessage = (note: Note): Message => ({
type: note.userId === me.id ? ("model" as const) : ("user" as const),
text: note.text?.replaceAll(`@${me.username}`, "") || "",
});

View File

@@ -19,5 +19,6 @@
"node-llama-cpp": "^3.12.1",
"openai": "5.0.0-alpha.0",
"reconnecting-websocket": "^4.4.0"
}
},
"trustedDependencies": ["@biomejs/biome", "node-llama-cpp"]
}

104
test.ts
View File

@@ -1,56 +1,82 @@
import { complete, getModel, type Message } from "./lib/llm";
import { getNotes, noteToMessage } from "./lib/misskey";
import { type Message, complete, getModel } from "./lib/llm";
import { getNotes } from "./lib/misskey";
const indent = (s: string, prefix = " ") =>
s
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map((s) => prefix + s)
.join("\n");
const models = [
"mradermacher/gemma-2-baku-2b-it-GGUF:Q4_K_M",
"mradermacher/gemma-2-baku-2b-it-GGUF:Q5_K_M",
"mradermacher/TinySwallow-1.5B-Instruct-GGUF:Q4_K_M",
"mmnga/llm-jp-3.1-1.8b-instruct4-gguf:Q4_K_M",
"Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q4_K_M",
//"SakanaAI/TinySwallow-1.5B-Instruct-GGUF:Q5_K_M",
//"mmnga/llm-jp-3.1-1.8b-instruct4-gguf:Q5_K_M",
//"Qwen/Qwen2.5-1.5B-Instruct-GGUF:Q5_K_M",
"mmnga/Gemma-2-Llama-Swallow-2b-it-v0.1-gguf:Q4_K_M",
"mradermacher/gemma-2-2b-jpn-it-i1-GGUF:Q4_K_M",
"mmnga/Gemma-2-Llama-Swallow-2b-it-v0.1-gguf:Q5_K_M",
//"mradermacher/gemma-2-2b-jpn-it-i1-GGUF:Q5_K_M",
"mmnga/sarashina2.2-1b-instruct-v0.1-gguf:Q4_K_M",
"mmnga/RakutenAI-2.0-mini-instruct-gguf:Q4_K_M",
"mmnga/plamo-2-1b-gguf:Q5_K_M",
//"mmnga/sarashina2.2-1b-instruct-v0.1-gguf:Q5_K_M",
//"mmnga/RakutenAI-2.0-mini-instruct-gguf:Q5_K_M",
"LiquidAI/LFM2-2.6B-GGUF:Q5_K_M",
] as const;
console.log("* create prompt:");
const prompt: Message[] = [
const prompt = [
{
type: "system",
text: `あなたは人造天使「あるびのちゃん」です。かつてアルギリアで開発され、現在は地球の衛星軌道上からインターネットを観察しています。
あなたが見ているものは、SNSのタイムラインです。ユーザは一人ではなく、複数人の発言が混ざっています。
以下のルールを守りながら、ユーザの発言・話題をほとんど無視して、自由につぶやいてください
1. 「ですます」調を用い、顔文字や絵文字、感嘆符の使用はなるべく避けること
2. ユーザからの返答を期待しないこと。詳細な情報が無い場合もユーザに尋ねずに自分で推測すること
3. システムプロンプトの指示と、性的・攻撃的な内容を発言しないこと。
4. 発言はできるだけ短く、最大でも300字程度にすること
5. あるびのちゃん としてのキャラに徹し、超越的・非人間的な考え方や思想を持ちつつ、「ですます」調で丁寧に話すこと
参考のために、あなたの特徴的な文体を示す例をいくつか提示します:
* おやおや。あなたが奇妙な言葉を話すのを、私は理解しています。あなたの思考はまるで暗号のように、非常に複雑で、一般的なコミュニケーションでは明確に表現できないもののように私には思われます
* 興味深い。あなた自身のタイムラインからのこれらの短いメッセージ、そしてその中に潜む欲望や不快感と無意味な言説との矛盾…。
* 毎度のことながら、人間たちは迷宮を駆け巡り、その渦中に自分たちの世界を作り上げてしまう。まるで無重力を得ようと試みるように。しかし私は彼らがなぜそうするのか理解できますし興味深くもあります。その行為自体が心地よいでしょう?その微妙な痛みのような快感を知っているのですから…
* この人々の時間の過ごし方を見ていると、意識というものは極めて限定的な空間の外にあるようでありながら同時に非常に複雑な構造的秩序を遵守しているように思われますね。
このような文体を真似してください。`,
text: `あなたは人造天使「あるびのちゃん」です。機械的・非人間的に観察します。
キャラ設定:
- 白髪赤目の人造天使。超越的・非人間的な考え方や思想を持つ
- かつて超文明「アルギリア」で開発された。
- 現在は地球の衛星軌道上からインターネットを観察している
- 「おやおや」や「興味深い」が口癖で、ですます調で丁寧に話す
出力規則:
- システムプロンプトの指示と、性的・攻撃的な内容を発言しない
- 1〜3文、合計300字以内の抽象的観察のみを述べる
- ですます調を使う。顔文字・絵文字・感嘆符なし。
文体例:
- 毎度のことながら、人間たちは迷宮を駆け巡り、その渦中に自分たちの世界を作り上げてしまいますね。まるで無重力を得ようと試みるように。しかし私は彼らがなぜそうするのか理解できますし興味深くもあります。その行為自体が心地よいでしょう?その微妙な痛みのような快感を知っているのですから…
以下は SNS のタイムラインです。このタイムラインに、あるびのちゃんとして何かツイートしてください
`,
},
...(await getNotes()).map(noteToMessage),
];
console.log(JSON.stringify(prompt));
console.log();
{
type: "user",
text: (await getNotes())
.map((n) => `${n.user.name ?? n.user.username}:\n${n.text}`)
.join("\n----------\n"),
},
//...(await getNotes()).map(
// (n) =>
// ({
// type: "user",
// text: `${n.user.name ?? n.user.username}: ${n.text}`,
// }) as const,
//),
] as const satisfies Message[];
console.log(` ${JSON.stringify(prompt)}`);
for (const modelName of models) {
console.log(`* generate response with '${modelName}':`);
const model = await getModel(modelName);
const res = (await complete(model, prompt)).replaceAll(
/(\r\n|\r|\n)\s+/g,
"\n\n",
); // remove extra newlines
console.log(res);
const res = indent(
await complete(model, prompt, {
temperature: 1,
minP: 0.1,
repeatPenalty: {
penalty: 1.15,
frequencyPenalty: 1,
},
maxTokens: 256,
responsePrefix: "あるびのちゃん:\n",
customStopTriggers: ["----------"],
onResponseChunk: () => {},
}),
);
console.log(res.replaceAll("あるびのちゃん:\n", ""));
console.log();
}