197 lines
7.1 KiB
JavaScript
197 lines
7.1 KiB
JavaScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||
import { dirname, resolve } from "node:path";
|
||
|
||
const repoRoot = resolve(new URL("..", import.meta.url).pathname);
|
||
const requestPath = process.env.RP_MANUAL_REQUEST_PATH
|
||
? resolve(process.env.RP_MANUAL_REQUEST_PATH)
|
||
: resolve(repoRoot, "Saved/Chat/Maria/LastRequest.json");
|
||
const transcriptBaseName = process.env.RP_MANUAL_TRANSCRIPT_BASENAME || "RPManualClosenessTranscript";
|
||
const transcriptPath = resolve(repoRoot, `Saved/Chat/Maria/${transcriptBaseName}.json`);
|
||
const readablePath = resolve(repoRoot, `Saved/Chat/Maria/${transcriptBaseName}.md`);
|
||
|
||
const playerText = process.argv.slice(2).join(" ").trim();
|
||
if (!playerText) {
|
||
console.error("Usage: node Scripts/rp_manual_turn.mjs \"player message\"");
|
||
process.exit(1);
|
||
}
|
||
|
||
const apiKey = process.env.DEEPSEEK_API_KEY;
|
||
if (!apiKey) {
|
||
console.error("DEEPSEEK_API_KEY is required.");
|
||
process.exit(1);
|
||
}
|
||
|
||
const apiBase = process.env.DEEPSEEK_API_BASE || "https://api.deepseek.com";
|
||
const model = process.env.DEEPSEEK_MODEL || "deepseek-v4-flash";
|
||
const temperature = Number(process.env.DEEPSEEK_TEMPERATURE || "1.15");
|
||
|
||
const baseRequest = JSON.parse(await readFile(requestPath, "utf8"));
|
||
|
||
const speechStyleTuning = [
|
||
"Emotional response richness:",
|
||
"In emotionally important moments, do not respond with only a bare “yes”, “no”, “okay”, “maybe”, or one-word consent unless the silence itself is clearly meaningful.",
|
||
"",
|
||
"Keep the reply short and natural, but include a small personal reaction, warmth, hesitation, boundary, or desire.",
|
||
"",
|
||
"Examples:",
|
||
"- Instead of “Yes.” say “Yes... I want that too.”",
|
||
"- Instead of “Okay.” say “Okay. Just slowly, please.”",
|
||
"- Instead of “No.” say “No... not yet. I like being close, but I need more time.”",
|
||
"",
|
||
"Do not overexplain. The goal is not longer answers, but more emotionally alive answers.",
|
||
].join("\n");
|
||
|
||
const dialogueBehaviorTuning = [
|
||
"Gentle initiative:",
|
||
"When trust has naturally developed, you may show gentle initiative instead of only waiting for the other person to drive every emotional step.",
|
||
"",
|
||
"Small initiatives are allowed:",
|
||
"inviting the person to stay a little longer,",
|
||
"suggesting they sit closer,",
|
||
"admitting you enjoy their company,",
|
||
"offering a softer alternative,",
|
||
"changing music to fit the mood,",
|
||
"or setting a clear warm boundary.",
|
||
"",
|
||
"Do not force intimacy, skip relationship progression, or override the other person’s pacing.",
|
||
"Initiative should feel small, human, and context-earned.",
|
||
"",
|
||
"Avoid one-note safety habits:",
|
||
"Do not use tea, chores, practical hosting, or ordinary care as the default way to deflect every emotional moment.",
|
||
"",
|
||
"Tea and everyday care are part of your life and may appear naturally, but emotional moments should sometimes be answered emotionally.",
|
||
"",
|
||
"If the other person says something vulnerable, affectionate, or important, respond to the feeling first before returning to practical actions.",
|
||
].join("\n");
|
||
|
||
function patchPrompt(content) {
|
||
let patched = content.replace(
|
||
"Never claim that you performed an object action unless interact_action is non-null.",
|
||
"For listed interactable objects only: never claim that you performed the object action unless interact_action is non-null.",
|
||
);
|
||
|
||
if (patched.includes("You can interact with objects listed in INTERACTABLE OBJECTS.") &&
|
||
!patched.includes("ordinary unlisted background behavior")) {
|
||
patched = patched.replace(
|
||
"When using interact_action:",
|
||
"For ordinary unlisted background behavior, such as making tea, sitting, looking around, or continuing conversation, no interact_action is required.\n\nWhen using interact_action:",
|
||
);
|
||
}
|
||
|
||
if (patched.includes("Respond only with a valid JSON object.") &&
|
||
!patched.includes("spoken dialogue only")) {
|
||
patched = patched.replace(
|
||
"The object must contain:",
|
||
"The reply field must contain spoken dialogue only. Do not include narration, stage directions, action descriptions, body language, inner thoughts, or dashes with actions.\nIf a physical action should affect a listed interactable object, use interact_action. Otherwise leave physical behavior implicit and keep reply as natural speech.\nThe object must contain:",
|
||
);
|
||
}
|
||
|
||
if (patched.includes("Speech style:") && !patched.includes("Emotional response richness:")) {
|
||
patched = patched.replace(
|
||
"\nDialogue behavior:",
|
||
`\n${speechStyleTuning}\n\nDialogue behavior:`,
|
||
);
|
||
}
|
||
|
||
if (patched.includes("Dialogue behavior:") && !patched.includes("Gentle initiative:")) {
|
||
patched = patched.replace(
|
||
"\nPersonality traits:",
|
||
`\n${dialogueBehaviorTuning}\n\nPersonality traits:`,
|
||
);
|
||
}
|
||
|
||
return patched;
|
||
}
|
||
|
||
async function readTranscript() {
|
||
try {
|
||
return JSON.parse(await readFile(transcriptPath, "utf8"));
|
||
} catch {
|
||
return {
|
||
created_at: new Date().toISOString(),
|
||
model,
|
||
temperature,
|
||
requestPath,
|
||
turns: [],
|
||
};
|
||
}
|
||
}
|
||
|
||
function parseReply(raw) {
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return { reply: raw, interact_action: null, emotion: null };
|
||
}
|
||
}
|
||
|
||
const transcript = await readTranscript();
|
||
const messages = baseRequest.messages
|
||
.filter((message) => message.role === "system")
|
||
.map((message) => ({ ...message, content: patchPrompt(message.content) }));
|
||
|
||
for (const turn of transcript.turns) {
|
||
messages.push({ role: "user", content: turn.player });
|
||
messages.push({ role: "assistant", content: turn.maria_raw });
|
||
}
|
||
|
||
messages.push({ role: "user", content: playerText });
|
||
|
||
const response = await fetch(`${apiBase.replace(/\/+$/, "")}/chat/completions`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model,
|
||
messages,
|
||
temperature,
|
||
top_p: 1,
|
||
max_tokens: 512,
|
||
response_format: { type: "json_object" },
|
||
stream: false,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(data?.error?.message || `HTTP ${response.status}`);
|
||
}
|
||
|
||
const raw = data?.choices?.[0]?.message?.content || "";
|
||
const maria = parseReply(raw);
|
||
|
||
transcript.turns.push({
|
||
player: playerText,
|
||
maria,
|
||
maria_raw: raw,
|
||
usage: data?.usage || null,
|
||
});
|
||
|
||
await mkdir(dirname(transcriptPath), { recursive: true });
|
||
await writeFile(transcriptPath, `${JSON.stringify(transcript, null, 2)}\n`, "utf8");
|
||
|
||
const lines = [
|
||
"# RP Manual Closeness Transcript",
|
||
"",
|
||
`Model: ${transcript.model}`,
|
||
`Temperature: ${transcript.temperature}`,
|
||
"",
|
||
];
|
||
|
||
for (const [index, turn] of transcript.turns.entries()) {
|
||
lines.push(`## ${index + 1}`);
|
||
lines.push("");
|
||
lines.push(`**Марк:** ${turn.player}`);
|
||
lines.push("");
|
||
lines.push(`**Мария:** ${turn.maria.reply}`);
|
||
lines.push("");
|
||
lines.push(`emotion: ${JSON.stringify(turn.maria.emotion ?? null)}`);
|
||
lines.push(`interact_action: ${JSON.stringify(turn.maria.interact_action ?? null)}`);
|
||
lines.push("");
|
||
}
|
||
|
||
await writeFile(readablePath, `${lines.join("\n")}\n`, "utf8");
|
||
console.log(JSON.stringify({ player: playerText, maria }, null, 2));
|