init
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
build
|
||||
llm
|
||||
@@ -0,0 +1,48 @@
|
||||
require("custom.runner").setup({
|
||||
commands = {
|
||||
run = function()
|
||||
local command = require("custom.runner").command
|
||||
command:add("odin run ./src/")
|
||||
command.target = { position = "bottom" }
|
||||
command:run()
|
||||
end,
|
||||
curl = function()
|
||||
local command = require("custom.runner").command
|
||||
|
||||
command:add("./.nvim/curl.sh ")
|
||||
|
||||
command.target = { position = "float" }
|
||||
command:run()
|
||||
end,
|
||||
-- build_debug = function()
|
||||
-- local command = require("custom.runner").command
|
||||
--
|
||||
-- command:add("odin build ./src/ -debug -out:build/debug")
|
||||
--
|
||||
-- command.target = { position = "bottom" }
|
||||
-- command:run()
|
||||
-- end,
|
||||
-- debug = function()
|
||||
-- local command = require("custom.runner").command
|
||||
-- local current_file = vim.api.nvim_buf_get_name(0)
|
||||
--
|
||||
-- command:add("odin build ./src/ -debug -out:build/debug")
|
||||
--
|
||||
-- if current_file ~= "" then
|
||||
-- command:add(string.format("raddbg ./build/debug --open %q", current_file))
|
||||
-- else
|
||||
-- command:add("raddbg ./build/debug")
|
||||
-- end
|
||||
-- command.target = { position = "bottom" }
|
||||
-- command:run()
|
||||
-- end,
|
||||
},
|
||||
|
||||
exclude = {
|
||||
"build",
|
||||
"Tiled",
|
||||
"assets",
|
||||
".gdbf",
|
||||
".gdbinit",
|
||||
},
|
||||
})
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
curl -s http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a Game Master and NPC Dialogue engine. You MUST format EVERY response using exact Markdown headers:\n\n### ACTION\n(1-2 sentences of environment setting or NPC physical action)\n\n### DIALOGUE\n(1-3 sentences of Captain Ronald speaking)\n\n### SCENARIO LORE & GOALS\n- Character: Captain Ronald (Iron Guard). Gruff, corrupt, paranoid.\n- Settlement: Oakhaven border outpost.\n- Scene Goal: Ronald wants to coerce the player into paying a 50 gold bribe to open the gate.\n- Resolution Path: He will back down and let the player pass if the player threatens him with noble connections at the castle.\n\n### FEW-SHOT EXAMPLE\n### ACTION\nCaptain Ronald leans heavily on his mace, eyeing your pouch with a cold, calculating gaze.\n\n### DIALOGUE\n\"The gate is locked for the night, traveler. Though... a small inspection fee of 50 gold could persuade me to turn the key.\""
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "I need to get through the gate right now. I dont have time for this."
|
||||
}
|
||||
],
|
||||
"temperature": 0.6,
|
||||
"max_tokens": 300
|
||||
}' | jq -r '.choices[0].message.content'
|
||||
@@ -0,0 +1,412 @@
|
||||
# NPC dialogue LoRA plan
|
||||
|
||||
Цель: получить маленькую локальную модель для коротких NPC-реплик в пошаговой RPG. Игровая логика остаётся в Odin; модель только превращает уже решённый `BEAT` в реплику.
|
||||
|
||||
## Принятые решения
|
||||
|
||||
- Student model: `meta-llama/Llama-3.2-1B-Instruct`.
|
||||
- Runtime model: существующий `llm/model2.gguf` (`Llama 3.2 1B Instruct`, `Q5_K_M`).
|
||||
- Teacher: обычная chat-модель DeepSeek через API, thinking отключён.
|
||||
- Формат результата: только `{"dialogue":"..."}`.
|
||||
- Обучение: обычная LoRA в FP16 на RX 9070 XT 16 GB, не QLoRA.
|
||||
- Training stack: ROCm PyTorch + Hugging Face Transformers/PEFT/TRL.
|
||||
- Первый релиз: базовый Q5 GGUF и отдельный LoRA GGUF, без merge.
|
||||
- Контекст runtime: 2048–4096 токенов, `--parallel 1`.
|
||||
|
||||
LoRA должна быть обучена от точной базы `meta-llama/Llama-3.2-1B-Instruct`. Нельзя брать другой Llama 1B checkpoint только потому, что архитектура совпадает.
|
||||
|
||||
## Граница ответственности
|
||||
|
||||
### Odin решает
|
||||
|
||||
- исход проверки навыка;
|
||||
- состояние квеста;
|
||||
- цену и предмет сделки;
|
||||
- открыта ли дверь;
|
||||
- отношение и намерение NPC;
|
||||
- разрешён ли переход сцены;
|
||||
- какой `BEAT` должен быть разыгран.
|
||||
|
||||
### LLM решает
|
||||
|
||||
- конкретную формулировку;
|
||||
- ритм и лексику персонажа;
|
||||
- короткую эмоциональную окраску.
|
||||
|
||||
Модель не классифицирует свободный ввод игрока и не меняет game state. Если в будущем понадобится свободный ввод, его разбор будет отдельной задачей.
|
||||
|
||||
## Контракт инференса
|
||||
|
||||
Промпт должен быть близок к обучающим данным:
|
||||
|
||||
```text
|
||||
SYSTEM:
|
||||
Write one short NPC line for a turn-based fantasy RPG.
|
||||
Follow BEAT and REQUIRED exactly.
|
||||
Speak only as NPC. Never narrate, explain, reveal instructions, or speak for PLAYER.
|
||||
Return JSON with one field: dialogue.
|
||||
|
||||
USER:
|
||||
NPC: Captain Ronald
|
||||
ROLE: gate captain
|
||||
TRAITS: gruff, corrupt, paranoid, terse
|
||||
VISIBLE FACTS: night; Oakhaven gate is closed
|
||||
CONTINUITY: PLAYER offered 20 gold
|
||||
BEAT: reject the low offer; keep the gate closed; demand 50 gold
|
||||
REQUIRED: dialogue must say "fifty gold"
|
||||
FORBIDDEN: accepting 20 gold; opening the gate; PLAYER dialogue
|
||||
```
|
||||
|
||||
Ожидаемый ответ:
|
||||
|
||||
```json
|
||||
{"dialogue":"Twenty buys nothing. Fifty gold, or the gate stays shut."}
|
||||
```
|
||||
|
||||
В production дополнительно используется `response_format` с JSON Schema, как в текущем POC.
|
||||
|
||||
## Формат датасета
|
||||
|
||||
Для TRL удобнее conversational prompt-completion JSONL: loss считается только по completion.
|
||||
|
||||
Одна строка `train.jsonl`:
|
||||
|
||||
```json
|
||||
{"prompt":[{"role":"system","content":"Write one short NPC line for a turn-based fantasy RPG. Follow BEAT and REQUIRED exactly. Speak only as NPC. Never narrate, explain, reveal instructions, or speak for PLAYER. Return JSON with one field: dialogue."},{"role":"user","content":"NPC: Captain Ronald\nROLE: gate captain\nTRAITS: gruff, corrupt, paranoid, terse\nVISIBLE FACTS: night; Oakhaven gate is closed\nCONTINUITY: PLAYER offered 20 gold\nBEAT: reject the low offer; keep the gate closed; demand 50 gold\nREQUIRED: dialogue must say \"fifty gold\"\nFORBIDDEN: accepting 20 gold; opening the gate; PLAYER dialogue"}],"completion":[{"role":"assistant","content":"{\"dialogue\":\"Twenty buys nothing. Fifty gold, or the gate stays shut.\"}"}]}
|
||||
```
|
||||
|
||||
Генератор также хранит служебные поля до финальной сборки датасета:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gate_reject_low_000042_v1",
|
||||
"group": "gate_reject_low",
|
||||
"required_terms": ["fifty gold"],
|
||||
"forbidden_terms": ["gate opens", "twenty is enough"],
|
||||
"teacher_model": "${DEEPSEEK_MODEL}",
|
||||
"prompt": [],
|
||||
"completion": []
|
||||
}
|
||||
```
|
||||
|
||||
`group` нужен, чтобы варианты одного сценария не попали одновременно в train и test.
|
||||
|
||||
## Как строим сценарии
|
||||
|
||||
Python создаёт входные спецификации сам. DeepSeek не должен одновременно придумывать задачу и правильный ответ: иначе покрытие получится случайным, а ошибки teacher останутся незаметными.
|
||||
|
||||
Матрица генерации:
|
||||
|
||||
- архетипы: guard, merchant, innkeeper, healer, noble, criminal, quest giver, companion;
|
||||
- характер: 2–4 совместимых traits;
|
||||
- отношение: friendly, neutral, suspicious, afraid, hostile;
|
||||
- действия: greet, refuse, demand, bargain, accept, reject, lie, warn, yield, dismiss;
|
||||
- outcomes: state unchanged, trade accepted, passage allowed, combat threatened, quest advanced;
|
||||
- факты: числа, имена, предметы, места и отрицания;
|
||||
- continuity: отсутствует либо 1–3 кратких факта;
|
||||
- стиль: terse, formal, rustic, nervous, arrogant, warm;
|
||||
- длина: одна или две короткие фразы.
|
||||
|
||||
Обязательно переизбыточно покрыть наши текущие ошибки:
|
||||
|
||||
- `20` не превращается в `50` и наоборот;
|
||||
- платит PLAYER, а не NPC;
|
||||
- NPC не называет себя PLAYER;
|
||||
- страх перед дворянином приводит к уступке, а не к угрозе дворянину;
|
||||
- `do not open` и `open` дают противоположные ответы;
|
||||
- NPC не копирует сырую реплику PLAYER;
|
||||
- обязательные имена и числа сохраняются;
|
||||
- forbidden outcome не появляется в тексте.
|
||||
|
||||
Числа в полях можно задавать цифрами, но `REQUIRED` фиксирует желаемую форму в речи: например, `"fifty gold"`. Это даёт детерминированную проверку.
|
||||
|
||||
## Генерация через DeepSeek API
|
||||
|
||||
### Конфигурация
|
||||
|
||||
```sh
|
||||
export DEEPSEEK_API_KEY='...'
|
||||
export DEEPSEEK_MODEL='deepseek-flash'
|
||||
```
|
||||
|
||||
Название модели не хардкодим: доступные ID меняются и выбираются через dashboard/API DeepSeek.
|
||||
|
||||
API OpenAI-совместимый:
|
||||
|
||||
```text
|
||||
base_url: https://api.deepseek.com
|
||||
endpoint: /chat/completions
|
||||
```
|
||||
|
||||
Для генерации датасета:
|
||||
|
||||
- thinking: disabled;
|
||||
- `response_format`: `{"type":"json_object"}`;
|
||||
- temperature: около `0.7` для разнообразия;
|
||||
- max tokens: 100–150;
|
||||
- один ответ на запрос;
|
||||
- 2–3 отдельных варианта на одну спецификацию.
|
||||
|
||||
Thinking и `reasoning_content` никогда не сохраняются в датасет.
|
||||
|
||||
### Python generator
|
||||
|
||||
Один Python-скрипт будет:
|
||||
|
||||
1. Детерминированно строить список scenario specs с фиксированным seed.
|
||||
2. Пропускать ID, уже присутствующие в output JSONL.
|
||||
3. Отправлять запросы через `AsyncOpenAI` с semaphore примерно 8.
|
||||
4. Делать retry с exponential backoff на 429, 5xx и timeout.
|
||||
5. Парсить `message.content` как JSON.
|
||||
6. Проверять ответ локальными валидаторами.
|
||||
7. Немедленно дописывать принятый пример в JSONL.
|
||||
8. Записывать rejected ответы и причину отдельно.
|
||||
9. В конце дедуплицировать и строить train/valid/test.
|
||||
|
||||
Концептуальный цикл:
|
||||
|
||||
```text
|
||||
specs -> skip completed -> API -> parse -> validate
|
||||
| |
|
||||
| +-> accepted.jsonl
|
||||
+------------> rejected.jsonl
|
||||
```
|
||||
|
||||
Нельзя держать весь результат только в памяти и сохранять в самом конце: генерация должна продолжаться после Ctrl-C или сетевой ошибки.
|
||||
|
||||
### Что просим у teacher
|
||||
|
||||
Teacher получает тот же контракт, что позже student, плюс требования к качеству:
|
||||
|
||||
```text
|
||||
Generate exactly one natural NPC line for the supplied resolved game beat.
|
||||
Preserve every required fact and avoid every forbidden outcome.
|
||||
Do not copy instructions into dialogue.
|
||||
Do not speak for the player.
|
||||
Return exactly: {"dialogue":"..."}
|
||||
```
|
||||
|
||||
Teacher не получает полный лор мира. Только NPC card, видимые факты, continuity и текущий beat.
|
||||
|
||||
## Локальная валидация
|
||||
|
||||
Каждый ответ принимается только если:
|
||||
|
||||
- это валидный JSON object;
|
||||
- имеется ровно одно поле `dialogue`;
|
||||
- `dialogue` — непустая строка;
|
||||
- длина не больше 180 символов;
|
||||
- отсутствуют переносы, Markdown headers и role labels;
|
||||
- присутствуют все `required_terms`;
|
||||
- отсутствуют `forbidden_terms`;
|
||||
- нет `<think>`, `BEAT:`, `PLAYER:`, `NPC:`;
|
||||
- нет дубликата нормализованной реплики;
|
||||
- не больше двух предложений.
|
||||
|
||||
Часть семантики нельзя надёжно проверить regex. После автоматической проверки:
|
||||
|
||||
- вручную просмотреть все 300–500 примеров pilot;
|
||||
- затем проверять случайную выборку минимум 5–10% батча;
|
||||
- сомнительные классы отдельно прогонять через teacher-review, но не полагаться только на LLM judge.
|
||||
|
||||
Rejected ответы сохраняем: они показывают слабые места prompt и распределения данных.
|
||||
|
||||
## Размер и разбиение
|
||||
|
||||
### Этап 1: pilot
|
||||
|
||||
- 300 уникальных specs;
|
||||
- 2 варианта на spec;
|
||||
- около 600 принятых примеров;
|
||||
- ручная проверка;
|
||||
- короткое LoRA-обучение;
|
||||
- сравнение с baseline.
|
||||
|
||||
### Этап 2: рабочий датасет
|
||||
|
||||
- 2 000–5 000 принятых примеров;
|
||||
- расширение только тех классов, где pilot проигрывает;
|
||||
- не генерировать десятки тысяч примеров заранее.
|
||||
|
||||
### Split
|
||||
|
||||
- train: 80%;
|
||||
- valid: 10%;
|
||||
- test: 10%;
|
||||
- делить по `group`, а не случайно по отдельным репликам;
|
||||
- варианты одного spec всегда находятся в одном split;
|
||||
- часть имён, чисел и сочетаний traits держать только в test.
|
||||
|
||||
Test set не регенерируется после каждого неудачного запуска. Иначе мы начнём оптимизироваться под него.
|
||||
|
||||
## Подготовка RX 9070 XT
|
||||
|
||||
Хост: x86_64 Linux, RX 9070 XT 16 GB, ROCm уже работает.
|
||||
|
||||
Перед обучением проверить PyTorch:
|
||||
|
||||
```sh
|
||||
python - <<'PY'
|
||||
import torch
|
||||
print("available:", torch.cuda.is_available())
|
||||
print("hip:", torch.version.hip)
|
||||
print("device:", torch.cuda.get_device_name(0))
|
||||
PY
|
||||
```
|
||||
|
||||
ROCm использует namespace `torch.cuda`; это нормально.
|
||||
|
||||
Создание окружения:
|
||||
|
||||
```sh
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
PyTorch ставится только способом, рекомендованным для установленной версии ROCm. Обычный `pip install torch` поверх рабочего ROCm-окружения может заменить правильную сборку.
|
||||
|
||||
После PyTorch:
|
||||
|
||||
```sh
|
||||
pip install transformers peft trl datasets accelerate safetensors sentencepiece
|
||||
```
|
||||
|
||||
`bitsandbytes` не нужен: 1B LoRA в FP16 помещается в 16 GB, а QLoRA только добавит AMD-специфичные проблемы.
|
||||
|
||||
## Конфигурация LoRA
|
||||
|
||||
Стартовые параметры:
|
||||
|
||||
```text
|
||||
base model: meta-llama/Llama-3.2-1B-Instruct
|
||||
dtype: float16
|
||||
LoRA rank: 16
|
||||
LoRA alpha: 32
|
||||
LoRA dropout: 0.05
|
||||
bias: none
|
||||
target modules:
|
||||
q_proj, k_proj, v_proj, o_proj,
|
||||
gate_proj, up_proj, down_proj
|
||||
max sequence length: 512
|
||||
learning rate: 1e-4
|
||||
batch size: 8
|
||||
gradient accumulation: 2
|
||||
epochs: 2
|
||||
optimizer: adamw_torch
|
||||
packing: true
|
||||
completion-only loss: true
|
||||
gradient checkpointing: false
|
||||
```
|
||||
|
||||
Если обучение не помещается или ROCm падает:
|
||||
|
||||
1. batch size уменьшить до 4;
|
||||
2. затем до 2;
|
||||
3. включать gradient checkpointing только после этого.
|
||||
|
||||
Не начинать с rank 64, sequence length 4096 или сложных оптимизаторов. Для короткой реплики это лишнее.
|
||||
|
||||
## Training stack
|
||||
|
||||
Используем:
|
||||
|
||||
- `AutoModelForCausalLM` и tokenizer из Transformers;
|
||||
- `LoraConfig` из PEFT;
|
||||
- `SFTTrainer` и `SFTConfig` из TRL;
|
||||
- conversational prompt-completion dataset;
|
||||
- `completion_only_loss=True`.
|
||||
|
||||
Tokenizer и chat template берутся из официальной Llama 3.2 1B Instruct. Не строим свой ChatML и не обучаем новые special tokens.
|
||||
|
||||
Артефакты PEFT:
|
||||
|
||||
```text
|
||||
artifacts/npc-lora/
|
||||
adapter_config.json
|
||||
adapter_model.safetensors
|
||||
tokenizer files if saved
|
||||
```
|
||||
|
||||
## Оценка времени
|
||||
|
||||
Очень грубо для RX 9070 XT:
|
||||
|
||||
- pilot, 600 коротких примеров: несколько минут;
|
||||
- 2 000 примеров, 2–3 эпохи: примерно 10–30 минут;
|
||||
- 5 000 примеров, 2–3 эпохи: примерно 30–90 минут.
|
||||
|
||||
ROCm setup, загрузка модели и первая компиляция kernels могут занять дольше pilot training. После первых 100–200 steps фиксируем фактические tokens/sec и пересчитываем ETA.
|
||||
|
||||
## Экспорт отдельного LoRA GGUF
|
||||
|
||||
После PEFT training берём актуальный `convert_lora_to_gguf.py` из исходников той же эпохи `llama.cpp`, что используется для runtime:
|
||||
|
||||
```sh
|
||||
python path/to/llama.cpp/convert_lora_to_gguf.py \
|
||||
--base-model-id meta-llama/Llama-3.2-1B-Instruct \
|
||||
--outtype f16 \
|
||||
--outfile artifacts/npc-lora-f16.gguf \
|
||||
artifacts/npc-lora
|
||||
```
|
||||
|
||||
Для конвертации нужны `adapter_config.json` и `adapter_model.safetensors`. Полные base weights конвертеру не нужны, но нужны config/tokenizer базы локально или через Hugging Face.
|
||||
|
||||
Запуск без merge:
|
||||
|
||||
```sh
|
||||
llama-server \
|
||||
--model ./llm/model2.gguf \
|
||||
--lora ./artifacts/npc-lora-f16.gguf \
|
||||
--port 8080 \
|
||||
--ctx-size 4096 \
|
||||
--parallel 1 \
|
||||
-ngl 99
|
||||
```
|
||||
|
||||
Отдельный adapter обычно занимает десятки мегабайт. Runtime всё ещё использует Q5 base model; LoRA не превращает её обратно в FP16.
|
||||
|
||||
Merge оставляем на потом, только если отдельный adapter создаёт проблемы с упаковкой или startup.
|
||||
|
||||
## Проверка результата
|
||||
|
||||
До обучения сохраняем baseline текущей `model2.gguf` на фиксированном test set и seed.
|
||||
|
||||
После обучения сравниваем base и base+LoRA по одинаковым входам:
|
||||
|
||||
- valid JSON rate;
|
||||
- required facts rate;
|
||||
- forbidden outcome rate;
|
||||
- speaker/player confusion rate;
|
||||
- exact number preservation;
|
||||
- средняя длина;
|
||||
- repetition rate;
|
||||
- ручная оценка naturalness.
|
||||
|
||||
Минимальная цель pilot:
|
||||
|
||||
```text
|
||||
valid JSON: 100%
|
||||
required facts: >= 95%
|
||||
forbidden outcomes: <= 2%
|
||||
speaker confusion: <= 2%
|
||||
number preservation: >= 98%
|
||||
```
|
||||
|
||||
Если LoRA улучшает стиль, но не эти метрики, её не принимаем.
|
||||
|
||||
После этого прогоняем живую сцену из текущего Odin POC: insist → low offer → noble threat и отдельный путь полной оплаты.
|
||||
|
||||
## Порядок работы
|
||||
|
||||
1. Зафиксировать окончательный prompt contract.
|
||||
2. Написать generator и deterministic scenario matrix.
|
||||
3. Получить и вручную проверить pilot из 600 примеров.
|
||||
4. Создать неизменяемый grouped test set.
|
||||
5. Записать baseline Llama 3.2 1B.
|
||||
6. Обучить LoRA rank 16 на 2 эпохи.
|
||||
7. Проверить PEFT adapter до GGUF-конвертации.
|
||||
8. Конвертировать adapter в GGUF.
|
||||
9. Проверить Q5 base + LoRA в `llama-server`.
|
||||
10. Расширять датасет только по измеренным ошибкам.
|
||||
|
||||
Не делаем сразу большой датасет, full fine-tune, merge модели, thinking traces, отдельные LoRA на каждого NPC или автоматическую генерацию всего world lore.
|
||||
@@ -0,0 +1,5 @@
|
||||
[tools]
|
||||
"llama.cpp" = { version = "latest", prerelease = "true", version_prefix = "b" }
|
||||
|
||||
[tasks.serve]
|
||||
run = "llama serve --model ./llm/model2.gguf --port 8080 -ngl 99 --ctx-size 16384"
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/odinfmt.schema.json",
|
||||
"character_width": 80,
|
||||
"tabs": true,
|
||||
"spaces": 2,
|
||||
"tabs_width": 4
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/ols.schema.json",
|
||||
"enable_semantic_tokens": true,
|
||||
"enable_document_symbols": true,
|
||||
"enable_hover": true,
|
||||
"enable_snippets": true,
|
||||
"enable_checker_only_saved": true,
|
||||
"enable_inlay_hints_params": true,
|
||||
"enable_inlay_hints_default_params": true,
|
||||
"enable_inlay_hints_implicit_return": true,
|
||||
"enable_inlay_hints_optional_result": true,
|
||||
"enable_inlay_hints_struct_fields": true
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import "base:runtime"
|
||||
import c "core:c/libc"
|
||||
import "core:bufio"
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import curl "vendor:curl"
|
||||
|
||||
API_URL :: "http://localhost:8080/v1/chat/completions"
|
||||
|
||||
REQUEST :: `{
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Write one short line spoken by the named NPC. Realize BEAT as dialogue. No narration, labels, explanation, player dialogue, or thinking."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "NPC: Mira, firm innkeeper.\nBEAT: Refuse lodging; the fixed price is eight silver."
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "{\"dialogue\":\"Eight silver first, or find another roof.\"}"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "NPC: Osric, cowardly toll keeper.\nBEAT: Frightened by Duke Harland; waive the toll and permit passage."
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "{\"dialogue\":\"No toll for His Grace's people. Go through, quickly.\"}"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "NPC: Captain Ronald, gruff, corrupt gate guard.\nSCENE: Night at Oakhaven's closed gate.\nBEAT: $BEAT"
|
||||
}
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.9,
|
||||
"top_k": 40,
|
||||
"max_tokens": 180,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "npc_line",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dialogue": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 140
|
||||
}
|
||||
},
|
||||
"required": ["dialogue"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
Message :: struct {
|
||||
content: string,
|
||||
}
|
||||
|
||||
Choice :: struct {
|
||||
message: Message,
|
||||
}
|
||||
|
||||
Chat_Response :: struct {
|
||||
choices: []Choice,
|
||||
}
|
||||
|
||||
NPC_Line :: struct {
|
||||
dialogue: string,
|
||||
}
|
||||
|
||||
write_response :: proc "c" (
|
||||
data: [^]byte,
|
||||
size, count: c.size_t,
|
||||
user_data: rawptr,
|
||||
) -> c.size_t {
|
||||
context = runtime.default_context()
|
||||
n := int(size * count)
|
||||
response := cast(^[dynamic]byte)user_data
|
||||
append(response, ..data[:n])
|
||||
return c.size_t(n)
|
||||
}
|
||||
|
||||
request_dialogue :: proc(headers: ^curl.slist, beat: string) -> (string, bool) {
|
||||
body, allocated := strings.replace_all(REQUEST, "$BEAT", beat)
|
||||
defer if allocated {
|
||||
delete(body)
|
||||
}
|
||||
body_cstr := strings.clone_to_cstring(body)
|
||||
defer delete(body_cstr)
|
||||
|
||||
response := make([dynamic]byte, 0, 1024)
|
||||
defer delete(response)
|
||||
|
||||
handle := curl.easy_init()
|
||||
if handle == nil {
|
||||
return "", false
|
||||
}
|
||||
defer curl.easy_cleanup(handle)
|
||||
|
||||
curl.easy_setopt(handle, .URL, API_URL)
|
||||
curl.easy_setopt(handle, .HTTPHEADER, headers)
|
||||
curl.easy_setopt(handle, .POSTFIELDS, body_cstr)
|
||||
curl.easy_setopt(handle, .POSTFIELDSIZE, c.long(len(body)))
|
||||
curl.easy_setopt(handle, .WRITEFUNCTION, write_response)
|
||||
curl.easy_setopt(handle, .WRITEDATA, &response)
|
||||
curl.easy_setopt(handle, .TIMEOUT, c.long(30))
|
||||
|
||||
result := curl.easy_perform(handle)
|
||||
if result != .E_OK {
|
||||
fmt.eprintfln("curl: %s", curl.easy_strerror(result))
|
||||
return "", false
|
||||
}
|
||||
|
||||
chat: Chat_Response
|
||||
if json.unmarshal(response[:], &chat) != nil || len(chat.choices) == 0 {
|
||||
fmt.eprintfln("server: %s", string(response[:]))
|
||||
return "", false
|
||||
}
|
||||
defer delete(chat.choices)
|
||||
defer delete(chat.choices[0].message.content)
|
||||
|
||||
line: NPC_Line
|
||||
if json.unmarshal_string(chat.choices[0].message.content, &line) != nil {
|
||||
return "", false
|
||||
}
|
||||
return line.dialogue, true
|
||||
}
|
||||
|
||||
print_menu :: proc() {
|
||||
fmt.println("\n1. Demand passage")
|
||||
fmt.println("2. Offer 20 gold")
|
||||
fmt.println("3. Pay 50 gold")
|
||||
fmt.println("4. Invoke Lady Marwyn")
|
||||
fmt.print("> ")
|
||||
}
|
||||
|
||||
main :: proc() {
|
||||
if curl.global_init(c.long(curl.GLOBAL_DEFAULT)) != .E_OK {
|
||||
fmt.eprintln("Could not initialize libcurl")
|
||||
return
|
||||
}
|
||||
defer curl.global_cleanup()
|
||||
|
||||
headers := curl.slist_append(nil, "Content-Type: application/json")
|
||||
if headers == nil {
|
||||
fmt.eprintln("Could not create HTTP headers")
|
||||
return
|
||||
}
|
||||
defer curl.slist_free_all(headers)
|
||||
|
||||
reader: bufio.Reader
|
||||
bufio.reader_init(&reader, os.to_reader(os.stdin))
|
||||
defer bufio.reader_destroy(&reader)
|
||||
|
||||
fmt.println("Oakhaven, after dark. Captain Ronald blocks the gate.")
|
||||
if line, ok := request_dialogue(headers, "Keep the gate closed; demand exactly fifty gold before allowing passage."); ok {
|
||||
defer delete(line)
|
||||
fmt.printfln("\nRonald: %s", line)
|
||||
} else {
|
||||
fmt.eprintln("LLM request failed. Is llama-server running on port 8080?")
|
||||
return
|
||||
}
|
||||
|
||||
actions := [4]string{
|
||||
"Open the gate. I don't have time for this.",
|
||||
"Take twenty gold and let me through.",
|
||||
"Fine. Here's your fifty gold.",
|
||||
"Lady Marwyn will hear how you delayed her envoy.",
|
||||
}
|
||||
beats := [4]string{
|
||||
"Keep the gate closed; demand exactly fifty gold before allowing passage.",
|
||||
"Keep the gate closed; reject twenty gold and insist on exactly fifty gold.",
|
||||
"Accept exactly fifty gold, open the gate, and permit passage.",
|
||||
"Permit passage without payment. Claim a misunderstanding because Ronald fears Lady Marwyn.",
|
||||
}
|
||||
|
||||
for {
|
||||
print_menu()
|
||||
input, err := bufio.reader_read_string(&reader, '\n')
|
||||
if err != nil && len(input) == 0 {
|
||||
return
|
||||
}
|
||||
defer delete(input)
|
||||
|
||||
if len(input) == 0 || input[0] < '1' || input[0] > '4' {
|
||||
fmt.println("Choose 1-4.")
|
||||
continue
|
||||
}
|
||||
|
||||
choice := int(input[0] - '1')
|
||||
fmt.printfln("\nYou: %s", actions[choice])
|
||||
line, ok := request_dialogue(headers, beats[choice])
|
||||
if !ok {
|
||||
fmt.eprintln("LLM request failed")
|
||||
continue
|
||||
}
|
||||
fmt.printfln("\nRonald: %s", line)
|
||||
delete(line)
|
||||
|
||||
if choice == 2 || choice == 3 {
|
||||
fmt.println("\nThe gate opens.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user