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