Загрузка данных


--[[
  НаноИИ 3.1 "DeepWiki" - локальный ИИ на русском для OpenComputers (Minecraft 1.7.10)
  с ансамблем нейросетей, долгими размышлениями, многоканальным поиском источников
  и фоновым автообучением только из русской Википедии.

  ОБНОВЛЕНИЕ 3.1 (поверх DeepWiki 3.0):

  1) НОВЫЙ МОЗГ - АНСАМБЛЬ ПЕРЦЕПТРОНОВ.
     Теперь семь настоящих многослойных перцептронов (20 признаков на входе).
     Самая глубокая сеть: 20 -> 512 -> 384 -> 256 -> 128 -> 64 -> 32 -> 1.
     В полном ансамбле около 5.7K нейронов и 870K весов; новые сети учатся
     вместе с прежними, а старые веса переносятся при расширении входного слоя.
     Веса хранятся в бинарном neuro.db; пресет выбирается по свободной памяти:
     /net light|core|wide|deep|all

  2) ДУМАЕТ ДОЛЬШЕ - /think 0..5 (по умолчанию 4).
     ИИ определяет тип запроса, строит дополнительные формулировки, расширяет
     поиск, отсеивает кандидатов с неверной сущностью/типом ответа, голосует
     ансамблем и проверяет оставшийся ответ перед выдачей.

  3) НЕ ВЫДАЁТ EXTRACT ЗА ОТВЕТ.
     Сначала выделяет выжимку и структурированные карточки фактов (сущность,
     отношение, год). Обычный ответ строится только из этой сохранённой базы.
     Для онлайн-ответа нужен совпавший факт с другого домена; слабое совпадение
     или непроверенный тип приводит к воздержанию. /verify - ручное подтверждение.
     Ссылки и домены источников сохраняются; внешние результаты не идут в
     автообучаемый корпус или очередь обхода.

  4) АВТООБУЧЕНИЕ - /autotrain on | /autotrain 10 | /autotrain off.
     ИИ сам обходит русскую Википедию: случайные статьи + переходы по ссылкам.
     Каждая страница попадает в corpus.db с выжимкой и карточками, в поисковый
     индекс и лексикон; языковая модель обучается на выжимке, не на extract.

  5) УЧИТСЯ НА КАЖДОМ СЛОВЕ - lexicon.db.
     Запоминается каждое слово, его частота и связи с соседними словами.
     На лексиконе построено исправление опечаток, а команда /lex <слово>
     показывает, что ИИ знает о слове.

  6) ПРОВЕРКА ОТВЕТА.
     Перед тем как ответить, ИИ смотрит, есть ли в ответе ключевые слова
     вопроса. Если у лучшего кандидата их нет, ИИ берёт следующего, а не
     выдаёт бессмыслицу. Это видно в ходе размышления: "проверил ответ: N%".

  7) ПАМЯТЬ.
     Веса больших слоёв упакованы по 16 бит; старые веса 16-входных сетей
     мигрируют на новые 20-входные сети с сохранением уже выученного. Если
     памяти не хватает, ИИ сам откатывается на меньший пресет. Лексикон тоже
     умеет сжиматься.

  8) ФАЙЛЫ БАЗЫ ЗНАНИЙ (все в одной папке, по умолчанию /home/nanoai/):
       learned.db    - чему тебя научили (teach / /train)
       wiki.db       - синтезированные выжимки и статус проверки
       notes.db      - твоё имя и заметки
       sentences.db  - запомненные фразы для режима "воспоминаний"
       neuro.db      - веса всех нейросетей (бинарный файл)
       lexicon.db    - каждое слово, его частота и связи
       corpus.db     - выжимки, карточки фактов и исходные фрагменты для обучения
       crawl.db      - очередь обхода Википедии
       cfg.db        - настройки (глубина размышления, пресет нейросетей)

  КОМАНДЫ - ТОЛЬКО НА АНГЛИЙСКОМ. Сам ИИ всегда отвечает по-русски.
  /help /exit /save /stats /notes /neuro /net /think /train /wiki /autotrain
  /right /wrong /verify /forget /reset /lex

  Запуск:  nanoai_ru                 (или: nanoai_ru /путь/к/папке/)
  Нужно:   Lua 5.2 или 5.3, OpenOS.
]]

local component = require("component")
local computer  = require("computer")
local term      = require("term")
local ser       = require("serialization")

local T = {}          -- общая таблица-помощник (чтобы уложиться в лимит локальных переменных)

local okFs, fs = pcall(require, "filesystem")
if not okFs then fs = nil end

-- аргумент командной строки задаёт папку с файлами базы знаний
local ARGS = {...}                     -- аргументы запуска (как в OpenOS: lua nanoai_ru /папка/)
local function firstArg()
  local a = ARGS[1]
  if type(a) == "string" and a ~= "" then return a end
  if type(arg) == "table" then                 -- запасной путь (обычный lua)
    for i = 2, #arg do
      if type(arg[i]) == "string" and arg[i] ~= "" then return arg[i] end
    end
  end
  return nil
end
local DB_DIR = firstArg() or "/home/nanoai/"
if not DB_DIR:match("/$") then DB_DIR = DB_DIR .. "/" end
if fs and not fs.exists(DB_DIR) then pcall(fs.makeDirectory, DB_DIR) end

local DB_LEARNED   = DB_DIR .. "learned.db"
local DB_WIKI      = DB_DIR .. "wiki.db"
local DB_NOTES     = DB_DIR .. "notes.db"
local DB_SENTENCES = DB_DIR .. "sentences.db"
local DB_NEURO     = DB_DIR .. "neuro.db"
local DB_LEXICON   = DB_DIR .. "lexicon.db"
local DB_CORPUS    = DB_DIR .. "corpus.db"
local DB_CRAWL     = DB_DIR .. "crawl.db"
local DB_CFG       = DB_DIR .. "cfg.db"

local MAX_LEARNED = 200      -- сколько фраз из обычного разговора помнить
local MAX_NET     = 250      -- максимум пар "вопрос => ответ" за одно обучение по ссылке
local MAX_WIKI    = 300      -- максимум статей Википедии в базе
local MAX_CORPUS  = 400      -- максимум статей, скачанных автообучением
local DIRECT      = 0.58     -- порог уверенного ответа
local HEDGE       = 0.34     -- порог ответа "кажется, это..."

local SPLIT      = 0.68     -- порог для каждой части составного вопроса ("А и Б")
local WHOLE      = 0.78     -- если всё сообщение целиком уже хорошо совпало, на части не делим

local floor, random, log, abs, exp, sqrt = math.floor, math.random, math.log, math.abs, math.exp, math.sqrt
local concat = table.concat
local format = string.format
local huge = math.huge

local hasInternet = component.isAvailable("internet")

if not os.sleep then                       -- os.sleep появился только в Lua 5.3
  os.sleep = function(t)
    local e = os.clock() + (t or 0)
    while os.clock() < e do end
  end
end

-- tanh: в OpenComputers (Lua 5.2 без LUA_COMPAT_MATHLIB) math.tanh НЕТ.
-- Поэтому всегда имеем свою реализацию: tanh(x) = (e^2x - 1) / (e^2x + 1).
local tanh = math.tanh
if type(tanh) ~= "function" then
  tanh = function(x)
    if x > 20 then return 1 end
    if x < -20 then return -1 end
    local e = exp(2 * x)
    return (e - 1) / (e + 1)
  end
end

local function gc()
  if type(collectgarbage) == "function" then pcall(collectgarbage) end
end
math.randomseed(floor(os.time() + computer.uptime() * 1000))
random(); random(); random()

---------------------------------------------------------------------------
-- НАСТРОЙКИ
---------------------------------------------------------------------------
local CFG = {
  think      = 4,        -- глубина размышления: 0..5
  reasonVersion = 4,     -- новая логика; поднимает старое значение think до 4
  netset     = "auto",   -- light | core | wide | deep | all | auto
  autotrain  = "off",    -- off | on
  learnBudget = 24,      -- сколько примеров дообучать нейросети при сборке базы
  wikiTrust  = 0.55,     -- минимум релевантности для результата, который может подтвердить факт
}

-- пресеты ансамбля нейросетей
local NET_PRESETS = {
  light = {
    {name = "scout", sizes = {20, 48, 24, 1}, lr = 0.20, trust = 0.8},
  },
  core = {
    {name = "scout", sizes = {20, 48, 24, 1}, lr = 0.20, trust = 0.7},
    {name = "core",  sizes = {20, 160, 80, 40, 1}, lr = 0.15, trust = 1.0},
  },
  wide = {
    {name = "scout", sizes = {20, 48, 24, 1}, lr = 0.20, trust = 0.6},
    {name = "core",  sizes = {20, 160, 80, 40, 1}, lr = 0.15, trust = 0.9},
    {name = "wide",  sizes = {20, 1024, 1}, lr = 0.10, trust = 1.0}, -- 1045 нейронов
  },
  deep = {
    {name = "scout",  sizes = {20, 48, 24, 1}, lr = 0.20, trust = 0.6},
    {name = "core",   sizes = {20, 160, 80, 40, 1}, lr = 0.15, trust = 0.9},
    {name = "wide",   sizes = {20, 1024, 1}, lr = 0.10, trust = 0.9},
    {name = "titan",  sizes = {20, 512, 256, 128, 64, 32, 1}, lr = 0.08, trust = 1.0},
    {name = "sage",   sizes = {20, 256, 192, 128, 96, 64, 32, 1}, lr = 0.07, trust = 1.0},
    {name = "atlas",  sizes = {20, 384, 256, 192, 128, 64, 32, 1}, lr = 0.06, trust = 1.0},
    {name = "oracle", sizes = {20, 512, 384, 256, 128, 64, 32, 1}, lr = 0.05, trust = 1.0},
  },
}
NET_PRESETS.all = {}
for _, netSpec in ipairs(NET_PRESETS.deep) do
  NET_PRESETS.all[#NET_PRESETS.all + 1] = netSpec
end

---------------------------------------------------------------------------
-- работа с текстом (UTF-8, кириллица)
---------------------------------------------------------------------------
local uni = _G.unicode
if not uni then
  local ok, u = pcall(require, "unicode")
  if ok then uni = u end
end

function T.lower(s)
  if uni and uni.lower then return uni.lower(s) end
  s = s:lower()
  s = s:gsub("\208([\144-\159])", function(c) return "\208" .. string.char(c:byte() + 32) end) -- А-П
  s = s:gsub("\208([\160-\175])", function(c) return "\209" .. string.char(c:byte() - 32) end) -- Р-Я
  s = s:gsub("\208\129", "\209\145")                                                          -- Ё
  return s
end

function T.ucfirst(s)
  if s == "" then return s end
  if uni and uni.upper and uni.sub then return uni.upper(uni.sub(s, 1, 1)) .. uni.sub(s, 2) end
  local b1, b2 = s:byte(1, 2)
  if b1 and b1 < 128 then return s:sub(1, 1):upper() .. s:sub(2) end
  if b1 == 208 and b2 and b2 >= 176 and b2 <= 191 then return "\208" .. string.char(b2 - 32) .. s:sub(3) end
  if b1 == 209 and b2 and b2 >= 128 and b2 <= 143 then return "\208" .. string.char(b2 + 32) .. s:sub(3) end
  if b1 == 209 and b2 == 145 then return "\208\129" .. s:sub(3) end
  return s
end

function T.chars(s)
  local t = {}
  for c in s:gmatch("[\0-\127\194-\244][\128-\191]*") do t[#t + 1] = c end
  return t
end

function T.ulen(s)
  return select(2, s:gsub("[^\128-\191]", ""))
end

function T.endsWith(w, suf)
  return #w >= #suf and w:sub(-#suf) == suf
end

local PUNCT_U = {"\194\171", "\194\187", "\226\128\156", "\226\128\157", "\226\128\158", "\226\128\148",
                 "\226\128\147", "\226\128\166", "\226\128\153", "\226\128\152", "\226\132\150"}

-- слова: нижний регистр, ё -> е, без знаков препинания
function T.words(s)
  s = T.lower(s)
  s = s:gsub("\209\145", "\208\181")
  for _, p in ipairs(PUNCT_U) do s = s:gsub(p, " ") end
  s = s:gsub("'", "")
  s = s:gsub("%p", " ")
  local t = {}
  for w in s:gmatch("%S+") do t[#t + 1] = w end
  return t
end

-- простой стеммер: кирка/кирку/кирки/киркой -> "кирк", алмазы/алмазов -> "алмаз"
local RU_END = {
  "ого", "его", "ому", "ему", "ими", "ыми", "ами", "ями", "ешь", "ишь", "ете", "ите", "ает", "яет", "ует",
  "ают", "яют", "уют", "ала", "яла", "ила", "ыла", "али", "яли", "или", "ыли", "ать", "ять", "ить", "еть",
  "уть", "ыть", "ией",
  "ой", "ей", "ый", "ий", "ая", "яя", "ое", "ее", "ые", "ие", "ых", "их", "ую", "юю", "ом", "ем", "ым", "им",
  "ам", "ям", "ах", "ях", "ов", "ев", "ия", "ии", "ию", "ья", "ье", "ью", "ою", "ею", "ет", "ут", "ют", "ат",
  "ят", "ит", "ть", "ти", "ла", "ло", "ли", "ны", "на",
  "а", "я", "ы", "и", "у", "ю", "о", "е", "ь", "й", "л",
}
table.sort(RU_END, function(a, b) return #a > #b end)

function T.stem(w)
  if not w:find("[\128-\255]") then return w end      -- латиница: как есть
  local n = T.ulen(w)
  if n <= 3 then return w end
  if n > 4 and (T.endsWith(w, "ся") or T.endsWith(w, "сь")) then w = w:sub(1, -5); n = n - 2 end
  for _ = 1, 2 do
    for _, e in ipairs(RU_END) do
      local el = T.ulen(e)
      if T.endsWith(w, e) and n - el >= 3 then
        w = w:sub(1, #w - #e); n = n - el
        break
      end
    end
  end
  return w
end

local SYN = {}
local function syn(target, list)
  local t = T.stem(target)
  for _, w in ipairs(list) do SYN[T.stem(w)] = t end
end
syn("сделать", {"делать", "создать", "создавать", "построить", "строить", "скрафтить", "крафтить", "крафт",
                "изготовить", "собрать", "собирать", "смастерить", "сконструировать"})
syn("найти", {"найду", "искать", "ищу", "достать", "добыть", "добывать", "получить", "получать", "взять",
              "раздобыть", "находить"})
syn("убить", {"убей", "убивать", "победить", "победи", "одолеть", "уничтожить", "побеждать"})
syn("начать", {"начинать", "начало"})
syn("работать", {"работает", "работаешь", "работают", "функционирует"})
syn("компьютер", {"пк", "комп", "компьютера"})
syn("приручить", {"приручать", "задобрить", "одомашнить"})
syn("стоить", {"стоит", "цена", "стоимость"})
syn("играть", {"игра", "играешь", "сыграть"})
syn("знать", {"знаю", "знаешь", "знаем"})
function T.canon(s) return SYN[s] or s end
function T.norm(t) return T.canon(T.stem(t)) end

local STOP = {}
for w in ([[и в во не что он на я с со как а то все она так его но да ты к у же вы за бы по только ее мне
  вот от меня еще нет о из ему когда если уже или ни быть был до вас там они тут где есть для мы тебя их
  чем сам без под кто этот это эта эти того потому какой какая какие какое сколько почему зачем куда откуда расскажи скажи
  объясни пожалуйста можешь могу нужно надо мне тебе нас вам им ей чего чему кого кому будет были была
  было быть ну вообще просто очень вот это тот та те
  такое такие такой такая такого таких какой какая какие какое
  который которая которые которое тоже даже самый самая самое]]):gmatch("%S+") do
  STOP[T.norm(w)] = true
end

local PRON = {["он"] = 1, ["она"] = 1, ["оно"] = 1, ["они"] = 1, ["его"] = 1, ["ее"] = 1, ["их"] = 1,
              ["это"] = 1, ["этом"] = 1, ["этого"] = 1, ["этот"] = 1, ["эта"] = 1, ["эти"] = 1}
local QWORD = {["что"] = 1, ["кто"] = 1, ["как"] = 1, ["где"] = 1, ["куда"] = 1, ["откуда"] = 1, ["когда"] = 1,
  ["почему"] = 1, ["зачем"] = 1, ["какой"] = 1, ["какая"] = 1, ["какое"] = 1, ["какие"] = 1, ["сколько"] = 1,
  ["чем"] = 1, ["чего"] = 1, ["можно"] = 1, ["можешь"] = 1, ["есть"] = 1, ["расскажи"] = 1, ["объясни"] = 1,
  ["скажи"] = 1, ["покажи"] = 1, ["подскажи"] = 1, ["помоги"] = 1, ["назови"] = 1, ["дай"] = 1,
  ["чей"] = 1, ["чья"] = 1, ["правда"] = 1, ["зачем"] = 1, ["каким"] = 1, ["какая"] = 1}
local FIRST_PERSON = {["я"] = 1, ["мне"] = 1, ["меня"] = 1, ["мой"] = 1, ["моя"] = 1, ["мое"] = 1, ["мои"] = 1,
  ["мы"] = 1, ["нас"] = 1, ["нам"] = 1, ["наш"] = 1, ["моих"] = 1, ["мною"] = 1}
local MODIFIER = {}
for w in ([[в во на для с со про о об если когда но а и или чтобы потому по из от у к за над при]]):gmatch("%S+") do
  MODIFIER[w] = 1
end

function T.pick(t) return t[random(#t)] end

---------------------------------------------------------------------------
-- ЛЕКСИКОН: учим КАЖДОЕ слово (частота + связи с соседними)
---------------------------------------------------------------------------
local lex = {}          -- слово -> {n = сколько раз, c = {соседнее слово = сколько}}
local lexTotal = 0
local lexChanged = false
local LEX_MAX = 20000   -- сколько слов держим (защита памяти)

local function lexPrune(e)
  local n = 0
  for _ in pairs(e.c) do n = n + 1 end
  if n > 18 then
    local list = {}
    for w, c in pairs(e.c) do list[#list + 1] = {w, c} end
    table.sort(list, function(a, b) return a[2] > b[2] end)
    local keep = {}
    for i = 1, 10 do keep[list[i][1]] = list[i][2] end
    e.c = keep
  end
end

-- добавить текст в лексикон: каждое слово и его соседи
local function lexAdd(text)
  if not text or text == "" then return end
  local ws = T.words(text)
  local n = #ws
  for i = 1, n do
    local w = ws[i]
    if T.ulen(w) >= 2 then
      local e = lex[w]
      if not e then
        e = {n = 0, c = {}}
        lex[w] = e
        if lexTotal > LEX_MAX then
          -- забываем самую редкую лексиконную запись
          local worst, wn = nil, huge
          for k, v in pairs(lex) do
            if v.n < wn then wn, worst = v.n, k end
          end
          if worst then lex[worst] = nil; lexTotal = lexTotal - 1 end
        end
      end
      e.n = e.n + 1
      lexTotal = lexTotal + 1
      for j = math.max(1, i - 2), math.min(n, i + 2) do
        if j ~= i then
          local w2 = ws[j]
          if w2 ~= w and T.ulen(w2) >= 2 then
            e.c[w2] = (e.c[w2] or 0) + 1
          end
        end
      end
      if e.n % 25 == 0 then lexPrune(e) end
    end
  end
  lexChanged = true
end

-- экономим память: если её мало, оставляем только частые слова и без связей
local function lexTrim(force)
  if not force and computer.freeMemory() > 380000 then return end
  local list = {}
  for w, e in pairs(lex) do list[#list + 1] = {w, e.n} end
  table.sort(list, function(a, b) return a[2] > b[2] end)
  local keep = {}
  for i = 1, math.min(#list, 2500) do keep[list[i][1]] = true end
  for w, e in pairs(lex) do
    if not keep[w] then lex[w] = nil
    elseif e.c then e.c = nil end
  end
  lexTotal = 0
  for _, e in pairs(lex) do lexTotal = lexTotal + e.n end
  lexChanged = true
end

-- слова, которые встречаются почти везде, сами по себе мало что значат
local function lexDynamicStop()
  local dyn = {}
  if lexTotal > 200 then
    for w, e in pairs(lex) do
      if e.n / lexTotal > 0.02 and not STOP[w] then dyn[w] = true end
    end
  end
  return dyn
end

-- что ИИ знает о слове
local function lexAbout(w)
  local e = lex[w]
  if not e then
    -- пробуем найти похожее
    local best, bd = nil, 3
    for k, v in pairs(lex) do
      if v.n >= 2 and math.abs(T.ulen(k) - T.ulen(w)) <= 2 then
        local d = math.abs(T.ulen(k) - T.ulen(w))
        if d < bd then bd, best = d, k end
      end
    end
    if best then
      e = lex[best]
      return best, e
    end
    return nil, nil
  end
  return w, e
end

---------------------------------------------------------------------------
-- НЕЙРОСЕТИ: ансамбль настоящих многослойных перцептронов
-- Прямой проход: взвешенная сумма + смещение + активация (tanh / сигмоида).
-- Обучение: обратное распространение ошибки (градиентный спуск).
-- Веса хранятся плоскими массивами, сохраняются в бинарный neuro.db.
---------------------------------------------------------------------------
local NN_F = 20            -- 16 базовых + 4 признака смысла/типа ответа
local NETS = {}            -- активные сети
local netsDirty = false

-- большие матрицы хранятся упакованными по 16 бит; новые сети генерируются
-- сразу в упакованном виде, без временных гигантских Lua-таблиц
local PACK_MIN = 6000

-- скалярное произведение по упакованным весам
local function dotPacked(str, off, x, n)
  local s, pos = 0, off
  for i = 1, n do
    local b1, b2 = str:byte(pos, pos + 1)
    if not b1 or not b2 then break end
    pos = pos + 2
    local v = b1 * 256 + b2
    if v >= 32768 then v = v - 65536 end
    s = s + (v / 32767) * x[i]
  end
  return s
end

function T.netNew(name, sizes, lr, trust)
  local net = {name = name, sizes = sizes, lr = lr or 0.1, trust = trust or 1,
               trained = 0, hits = 0, layers = {}}
  local seed = 7919
  for i = 1, #name do seed = (seed * 31 + name:byte(i)) % 2147483647 end
  local function rnd()
    seed = (1103515245 * seed + 12345) % 2147483648
    return (seed / 2147483648 - 0.5) * 0.9
  end
  for l = 1, #sizes - 1 do
    local nin, nout = sizes[l], sizes[l + 1]
    local L = {nin = nin, nout = nout, b = {}}
    local sc = 1 / sqrt(nin)
    if nin * nout > PACK_MIN then
      -- Не создаём сначала таблицу на сотни тысяч чисел: сразу пакуем int16.
      local parts, buf, cnt = {}, {}, 0
      for i = 1, nin * nout do
        local v = rnd() * sc
        local n = floor(v * 32767 + 0.5)
        if n > 32767 then n = 32767 elseif n < -32767 then n = -32767 end
        if n < 0 then n = n + 65536 end
        buf[#buf + 1] = string.char(floor(n / 256), n % 256)
        cnt = cnt + 1
        if cnt >= 4096 then
          parts[#parts + 1] = concat(buf)
          buf, cnt = {}, 0
        end
      end
      if cnt > 0 then parts[#parts + 1] = concat(buf) end
      L.ps = concat(parts)
    else
      L.w = {}
      for i = 1, nin * nout do L.w[i] = rnd() * sc end
    end
    for o = 1, nout do L.b[o] = 0 end
    net.layers[l] = L
  end
  return net
end

function T.netNeurons(net)
  local n = 0
  for _, s in ipairs(net.sizes) do n = n + s end
  return n
end

function T.netWeights(net)
  local n = 0
  for _, L in ipairs(net.layers) do n = n + L.nin * L.nout + L.nout end
  return n
end

-- прямой проход: возвращает выход (0..1) и активации всех слоёв
function T.netForward(net, x)
  local prev = x
  local nL = #net.layers
  local acts = {x}
  for l = 1, nL do
    local L = net.layers[l]
    local out = {}
    local w = L.w
    for o = 1, L.nout do
      local s = L.b[o]
      if L.ps then
        s = s + dotPacked(L.ps, (o - 1) * L.nin * 2 + 1, prev, L.nin)
      else
        local base = (o - 1) * L.nin
        for i = 1, L.nin do s = s + w[base + i] * prev[i] end
      end
      if l < nL then out[o] = tanh(s) else out[o] = 1 / (1 + exp(-s)) end
    end
    acts[l + 1] = out
    prev = out
  end
  return acts[nL + 1][1], acts
end

-- один шаг обратного распространения ошибки; большие матрицы обучаются
-- прямо в int16-упаковке, без разворачивания сотен тысяч Lua-чисел в таблицы.
function T.netTrain(net, x, target, lr)
  lr = lr or net.lr
  local y, acts = T.netForward(net, x)
  local nL = #net.layers
  local deltas = {}
  deltas[nL] = {y - target}                    -- сигмоида + перекрёстная энтропия
  for l = nL - 1, 1, -1 do
    local nextL = net.layers[l + 1]
    local act = acts[l + 1]
    local d = {}
    if nextL.ps then
      -- W^T * delta: читаем строки упакованной матрицы, накапливая по входам.
      local accum = {}
      for i = 1, nextL.nin do accum[i] = 0 end
      local pos = 1
      for o = 1, nextL.nout do
        local delta = deltas[l + 1][o]
        for i = 1, nextL.nin do
          local b1, b2 = nextL.ps:byte(pos, pos + 1)
          pos = pos + 2
          if b1 and b2 then
            local v = b1 * 256 + b2
            if v >= 32768 then v = v - 65536 end
            accum[i] = accum[i] + (v / 32767) * delta
          end
        end
      end
      for i = 1, #act do d[i] = accum[i] * (1 - act[i] * act[i]) end
    else
      for i = 1, #act do
        local sum = 0
        for o = 1, nextL.nout do
          sum = sum + nextL.w[(o - 1) * nextL.nin + i] * deltas[l + 1][o]
        end
        d[i] = sum * (1 - act[i] * act[i])
      end
    end
    deltas[l] = d
  end
  for l = 1, nL do
    local L = net.layers[l]
    local prev = acts[l]
    local d = deltas[l]
    local decay = 0.00002
    if L.ps then
      local parts, buf, cnt = {}, {}, 0
      local pos = 1
      for o = 1, L.nout do
        local g = d[o]
        L.b[o] = L.b[o] - lr * g
        for i = 1, L.nin do
          local b1, b2 = L.ps:byte(pos, pos + 1)
          pos = pos + 2
          local old = 0
          if b1 and b2 then
            local v = b1 * 256 + b2
            if v >= 32768 then v = v - 65536 end
            old = v / 32767
          end
          local value = old - lr * g * prev[i] - decay * old
          local n = floor(value * 32767 + 0.5)
          if n > 32767 then n = 32767 elseif n < -32767 then n = -32767 end
          if n < 0 then n = n + 65536 end
          buf[#buf + 1] = string.char(floor(n / 256), n % 256)
          cnt = cnt + 1
          if cnt >= 4096 then
            parts[#parts + 1] = concat(buf)
            buf, cnt = {}, 0
          end
        end
      end
      if cnt > 0 then parts[#parts + 1] = concat(buf) end
      L.ps = concat(parts)
    else
      for o = 1, L.nout do
        local g = d[o]
        L.b[o] = L.b[o] - lr * g
        local base = (o - 1) * L.nin
        for i = 1, L.nin do
          local w = L.w[base + i]
          L.w[base + i] = w - lr * g * prev[i] - decay * w
        end
      end
    end
  end
  net.trained = net.trained + 1
  netsDirty = true
  return y
end

-- прогнать признаки через весь ансамбль и усреднить (с учётом доверия)
function T.ensembleScore(x, limitPerNet)
  local sum, wsum = 0, 0
  for _, net in ipairs(NETS) do
    local p = T.netForward(net, x)
    local t = net.trust
    sum = sum + p * t
    wsum = wsum + t
  end
  if wsum <= 0 then return 0.5 end
  return sum / wsum
end

-- построить ансамбль по пресету, подставляя сохранённые веса
function T.buildNets(preset, saved)
  local tmp = {}
  local spec = NET_PRESETS[preset] or NET_PRESETS.core
  for _, s in ipairs(spec) do
    local net = T.netNew(s.name, s.sizes, s.lr, s.trust)
    local old = saved and saved[s.name]
    if old and old.sizes and old.data and #old.sizes == #s.sizes then
      local same = true
      for i = 2, #s.sizes do
        if old.sizes[i] ~= s.sizes[i] then same = false end
      end
      local data = old.data
      if old.sizes[1] == 16 and s.sizes[1] == 20 and same then
        local oldIn, newIn, firstOut = old.sizes[1], s.sizes[1], s.sizes[2]
        local matrixBytes = oldIn * firstOut * 2
        local firstLayerBytes = matrixBytes + firstOut * 2 -- включая смещения
        if #data >= firstLayerBytes then
          local migrated, pos = {}, 1
          for o = 1, firstOut do
            migrated[#migrated + 1] = data:sub(pos, pos + oldIn * 2 - 1)
            migrated[#migrated + 1] = string.rep("\0", (newIn - oldIn) * 2)
            pos = pos + oldIn * 2
          end
          migrated[#migrated + 1] = data:sub(matrixBytes + 1, firstLayerBytes)
          migrated[#migrated + 1] = data:sub(firstLayerBytes + 1)
          data = concat(migrated)
        else
          same = false
        end
      else
        for i = 1, #s.sizes do
          if old.sizes[i] ~= s.sizes[i] then same = false end
        end
      end
      if same then
        T.unpackNet(net, data)
        net.trained = old.trained or 0
        net.hits = old.hits or 0
      end
    end
    tmp[#tmp + 1] = net
  end
  NETS = tmp
  netsDirty = true
end

-- развернуть бинарные веса в сеть
function T.unpackNet(net, data)
  local pos = 1
  for _, L in ipairs(net.layers) do
    if L.ps then
      local bytes = L.nin * L.nout * 2
      L.ps = data:sub(pos, pos + bytes - 1)
      pos = pos + bytes
    else
      for i = 1, L.nin * L.nout do
        local b1, b2 = data:byte(pos, pos + 1)
        if not b1 then return end
        pos = pos + 2
        local v = b1 * 256 + b2
        if v >= 32768 then v = v - 65536 end
        L.w[i] = v / 32767
      end
    end
    for o = 1, L.nout do
      local b1, b2 = data:byte(pos, pos + 1)
      if not b1 then return end
      pos = pos + 2
      local v = b1 * 256 + b2
      if v >= 32768 then v = v - 65536 end
      L.b[o] = v / 32767
    end
  end
end

-- свернуть веса в бинарный вид (2 байта на вес)
function T.packNet(net)
  local parts = {}
  local buf, cnt = {}, 0
  local function put16(v)
    local n = floor(v * 32767 + 0.5)
    if n > 32767 then n = 32767 elseif n < -32767 then n = -32767 end
    if n < 0 then n = n + 65536 end
    cnt = cnt + 1
    buf[#buf + 1] = string.char(floor(n / 256), n % 256)
    if cnt >= 4096 then
      parts[#parts + 1] = concat(buf)
      buf, cnt = {}, 0
    end
  end
  for _, L in ipairs(net.layers) do
    if L.ps then
      if cnt > 0 then parts[#parts + 1] = concat(buf) end
      buf, cnt = {}, 0
      parts[#parts + 1] = L.ps
    else
      for i = 1, L.nin * L.nout do put16(L.w[i]) end
    end
    for o = 1, L.nout do put16(L.b[o]) end
  end
  if cnt > 0 then parts[#parts + 1] = concat(buf) end
  return concat(parts)
end

function T.netMeta()
  local t = {}
  for _, net in ipairs(NETS) do
    t[#t + 1] = {name = net.name, sizes = net.sizes, lr = net.lr, trust = net.trust,
                 trained = net.trained, hits = net.hits}
  end
  return t
end

---------------------------------------------------------------------------
-- БАЗЫ ЗНАНИЙ (отдельные db-файлы на диске)
---------------------------------------------------------------------------
local dbLearned   = {}                        -- {q=, a=}           чему тебя научили
local dbWiki      = {}                        -- выжимки веб-ответов, факты, URL и статус проверки
local dbNotes     = {name = nil, list = {}}   -- имя и заметки
local dbSentences = {}                        -- запомненные фразы
local dbCorpus    = {}                        -- статьи, скачанные автообучением
local dbCrawl     = {queue = {}, seen = {}}   -- очередь обхода Википедии
local changes     = 0
local lastQ, lastFeat, lastWikiQ = nil, nil, nil   -- последний вопрос и признаки для /right и /wrong
local ctxStems = {}                               -- контекст разговора (для "а он?")
local CUR_PRESET = "core"                        -- какой пресет нейросетей реально работает

---------------------------------------------------------------------------
-- чтение/запись db-файлов
---------------------------------------------------------------------------
function T.dbWrite(path, tbl)
  local f = io.open(path, "w")
  if not f then return false end
  local ok, s = pcall(ser.serialize, tbl)
  if not ok then f:close(); return false end
  f:write(s)
  f:close()
  return true
end

function T.dbRead(path)
  local f = io.open(path, "r")
  if not f then return nil end
  local s = f:read("*a")
  f:close()
  if not s or s == "" then return nil end
  local ok, t = pcall(ser.unserialize, s)
  if ok and type(t) == "table" then return t end
  return nil
end

-- neuro.db: бинарный файл (заголовок + метаданные + веса)
-- Формат файла: "NANOAI-NN-3\n" + длина метаданных + "\n" + метаданные + веса.
-- Длина нужна, потому что сериализация OpenComputers бывает многострочной.
function T.neuroSave()
  -- Не теряем веса сетей, временно выключенных пресетом wide/core/light.
  local saved = T.neuroLoad() or {}
  local active, netByName, archivedNames = {}, {}, {}
  for _, net in ipairs(NETS) do
    active[net.name] = true
    netByName[net.name] = net
  end
  for name, old in pairs(saved) do
    if not active[name] and type(old) == "table" and type(old.data) == "string"
       and type(old.sizes) == "table" then
      archivedNames[#archivedNames + 1] = name
    end
  end
  table.sort(archivedNames)
  local allMeta, dataParts = {}, {}
  for _, name in ipairs(archivedNames) do
    local old = saved[name]
    allMeta[#allMeta + 1] = {name = name, sizes = old.sizes, lr = old.lr, trust = old.trust,
                             trained = old.trained, hits = old.hits}
    dataParts[#dataParts + 1] = old.data
  end
  for _, info in ipairs(T.netMeta()) do
    allMeta[#allMeta + 1] = info
    dataParts[#dataParts + 1] = T.packNet(netByName[info.name])
  end
  local meta = ser.serialize({nets = allMeta, stamp = os.time()})
  if type(meta) ~= "string" then meta = "" end
  local f = io.open(DB_NEURO, "wb")
  if not f then return false end
  f:write("NANOAI-NN-3\n")
  f:write(tostring(#meta) .. "\n")
  f:write(meta)
  for _, data in ipairs(dataParts) do f:write(data) end
  f:close()
  netsDirty = false
  return true
end

function T.neuroLoad()
  local f = io.open(DB_NEURO, "rb")
  if not f then return nil end
  local data = f:read("*a")
  f:close()
  if not data or data == "" then return nil end
  local p1 = data:find("\n", 1, true)
  if not p1 then return nil end
  if data:sub(1, p1 - 1) ~= "NANOAI-NN-3" then return nil end
  local p2 = data:find("\n", p1 + 1, true)
  if not p2 then return nil end
  local metaLen = tonumber(data:sub(p1 + 1, p2 - 1))
  if not metaLen then return nil end
  local metaText = data:sub(p2 + 1, p2 + metaLen)
  local ok, meta = pcall(ser.unserialize, metaText)
  if not ok or type(meta) ~= "table" or type(meta.nets) ~= "table" then return nil end
  -- делим бинарный хвост по сетям
  local saved = {}
  local pos = p2 + 1 + metaLen
  for _, m in ipairs(meta.nets) do
    local sz = 0
    if m.sizes then
      for l = 1, #m.sizes - 1 do
        sz = sz + m.sizes[l] * m.sizes[l + 1] + m.sizes[l + 1]
      end
    end
    local bytes = sz * 2
    local chunk = data:sub(pos, pos + bytes - 1)
    pos = pos + bytes
    saved[m.name] = {sizes = m.sizes, lr = m.lr, trust = m.trust,
                      trained = m.trained, hits = m.hits, data = chunk}
  end
  return saved
end

function T.saveAll()
  local ok1 = T.dbWrite(DB_LEARNED, dbLearned)
  local ok2 = T.dbWrite(DB_WIKI, dbWiki)
  local ok3 = T.dbWrite(DB_NOTES, dbNotes)
  local ok4 = T.dbWrite(DB_SENTENCES, dbSentences)
  local ok5 = T.dbWrite(DB_CORPUS, dbCorpus)
  local ok6 = T.dbWrite(DB_CRAWL, dbCrawl)
  local ok7 = T.dbWrite(DB_LEXICON, lex)
  local ok8 = T.dbWrite(DB_CFG, CFG)
  local ok9 = T.neuroSave()
  changes = 0
  lexChanged = false
  return ok1 and ok2 and ok3 and ok4 and ok5 and ok6 and ok7 and ok8 and ok9
end

function T.touch()
  changes = changes + 1
  if changes >= 3 then pcall(T.saveAll) end
end

---------------------------------------------------------------------------
-- ПОИСКОВЫЙ ИНДЕКС (взвешенное совпадение слов + исправление опечаток)
---------------------------------------------------------------------------
local ans, srcOf, verOf = {}, {}, {}   -- ответы, источник (nil/user/wiki/corpus), проверен ли
local inv, docEntry, docMass, docTerms = {}, {}, {}, {}
local nDocs = 0
local invBucket = {}                   -- быстрый поиск опечаток: [длина][первая буква] = слова
local dynStop = {}                     -- слишком частые слова

function T.idf(s)
  local p = inv[s]
  local df = p and #p or 0
  return log((nDocs - df + 0.5) / (df + 0.5) + 1)
end

function T.weight(s)
  if STOP[s] then return 0.3 end
  if dynStop[s] then return 0.5 end
  return 1
end

function T.stemsOf(phrase)
  local out, seen = {}, {}
  for _, t in ipairs(T.words(phrase)) do
    local s = T.norm(t)
    if not seen[s] then seen[s] = true; out[#out + 1] = s end
  end
  return out
end


-- распознавание временных фактов: год выхода/создания/публикации и т.п.
-- Это не заменяет поиск: намерение используется как отдельная проверка типа ответа.
T.intentEventTerm = function(token, kind)
  local w = T.lower(token or "")
  if w:find("^выш") or w:find("^выпущ") or w:find("^выпуст") or w:find("^выпуск") or w:find("^релиз")
     or w:find("^выход") or w:find("^изда") or w:find("^публик") or w:find("^опублик")
     or w:find("^представ") or w:find("^дебют") or w:find("^появ") or w:find("^запуст") then
    return true
  end
  if kind == "release_year" then return false end
  return w:find("^созда") or w:find("^осн") or w:find("^появ") or w:find("^род")
      or w:find("^умер") or w:find("^нач") or w:find("^законч") or w:find("^напис")
      or w:find("^изобр") or w:find("^провед") or w:find("^открыл") or w:find("^постро")
      or w:find("^запуст") or w:find("^подпис") or w:find("^принят") or w:find("^установ")
      or w:find("^обнаруж") or w:find("^снят") or w:find("^переех") or w:find("^ввел")
      or w:find("^полет") or w:find("^летел")
end

T.classifyIntent = function(raw)
  local words = T.words(raw or "")
  local year, when, release, event = false, false, false, false
  local releaseWord, eventWord
  local normalized = {}
  for _, w in ipairs(words) do
    local st = T.norm(w)
    if not inv[st] and T.ulen(st) >= 5 and T.nearestIndex then
      local near = T.nearestIndex(st)
      if near then st = near end
    end
    normalized[#normalized + 1] = {raw = w, stem = st}
    if st == "год" then year = true end
    if w == "когда" or st == "когда" or w == "дата" then when = true end
    if T.intentEventTerm(w, "release_year") then
      release = true
      if not releaseWord then releaseWord = w end
    end
    if T.intentEventTerm(w, "year_event") then
      event = true
      if not eventWord then eventWord = w end
    end
  end
  local kind
  if (year or when) and release then kind = "release_year"
  elseif (year or when) and event then kind = "year_event"
  else return nil end

  local skip = {
    ["год"] = true, ["дат"] = true, ["когда"] = true, ["как"] = true,
    ["игр"] = true, ["фильм"] = true, ["книг"] = true, ["сериал"] = true, ["песн"] = true,
    ["верс"] = true, ["перв"] = true,
  }
  local subject, seen = {}, {}
  for _, item in ipairs(normalized) do
    local st = item.stem
    if T.ulen(st) >= 4 and not STOP[st] and not skip[st]
       and not T.intentEventTerm(item.raw, "year_event")
       and st ~= "выш" and st ~= "выход" and st ~= "релиз" then
      if not seen[st] then seen[st] = true; subject[#subject + 1] = st end
    end
  end
  return {kind = kind, subject = subject,
          relation = (kind == "release_year") and releaseWord or eventWord}
end

function T.finalize()
  docMass = {}
  invBucket = {}
  for s, post in pairs(inv) do
    local x = T.idf(s) * T.weight(s)
    for i = 1, #post do
      local d = post[i]
      docMass[d] = (docMass[d] or 0) + x
    end
    local L = T.ulen(s)
    local c = s:sub(1, 1)
    local b = invBucket[L]
    if not b then b = {}; invBucket[L] = b end
    local cl = b[c]
    if not cl then cl = {}; b[c] = cl end
    cl[#cl + 1] = s
  end
end

-- расстояние Левенштейна (по символам UTF-8)
function T.lev(a, b)
  local ca, cb = T.chars(a), T.chars(b)
  local la, lb = #ca, #cb
  if la == 0 then return lb end
  if lb == 0 then return la end
  local prev, cur = {}, {}
  for j = 0, lb do prev[j] = j end
  for i = 1, la do
    cur[0] = i
    local x = ca[i]
    for j = 1, lb do
      local cost = (x == cb[j]) and 0 or 1
      local v = prev[j] + 1
      local u = cur[j - 1] + 1
      if u < v then v = u end
      local w = prev[j - 1] + cost
      if w < v then v = w end
      cur[j] = v
    end
    prev, cur = cur, prev
  end
  return prev[lb]
end

-- найти в индексе слово, похожее на опечатку
function T.nearestIndex(s)
  local ls = T.ulen(s)
  if ls < 4 then return nil end
  local maxd = (ls >= 8) and 2 or 1
  local best, bd = nil, maxd + 1
  for L = ls - maxd - 1, ls + maxd + 1 do
    local b = invBucket[L]
    if b then
      for _, first in ipairs({"", s:sub(1, 1)}) do
        local cl = b[first]
        if cl then
          for i = 1, #cl do
            local k = cl[i]
            if math.abs(T.ulen(k) - ls) <= maxd then
              local d = T.lev(s, k)
              if d < bd or (d == bd and best and T.idf(k) > T.idf(best)) then bd, best = d, k end
            end
          end
        end
      end
    end
  end
  if best and bd <= maxd then return best end
  return nil
end

---------------------------------------------------------------------------
-- БАЗА ЗНАНИЙ
---------------------------------------------------------------------------
local KB = {
-- болтовня
{"привет|здравствуй|здравствуйте|хай|хэй|добрый день|доброе утро|добрый вечер|приветствую|салют|йоу|здарова|ку",
  {"Привет{,name}! О чём поговорим?", "Здравствуй{,name}! Спрашивай что угодно или научи меня чему-нибудь новому.", "Хай{,name}! Все нейроны прогреты и готовы."}},
{"как дела|как жизнь|как поживаешь|как ты|как твои дела|как сам|что нового|как настроение|как оно",
  {"Все слои нейросети в норме. А у тебя как дела{,name}?", "Отлично, свободной оперативки хватает. А у тебя?", "Работаю без сбоев, спасибо, что спросил!"}},
{"кто ты|что ты такое|как тебя зовут|твое имя|представься|расскажи о себе|ты кто|как тебя называть|как твое имя",
  "Я НаноИИ 3.1, локальный языковой движок с настоящей нейросетью, который живёт внутри этого компьютера OpenComputers. Мой мозг - ансамбль многослойных перцептронов, база знаний лежит в моих собственных файлах, а если есть интернет-карта, я умею усваивать Википедию: выделять и сохранять выжимки и карточки фактов, сверяя подходящие ответы на независимом домене."},
{"кто тебя создал|кто тебя сделал|кто тебя написал|кто твой создатель|кто тебя придумал|на каком языке ты написан|кто тебя запрограммировал",
  "Меня написали на Lua, чтобы я работал локально внутри компьютера OpenComputers в Майнкрафте."},
{"ты ии|ты искусственный интеллект|ты робот|ты человек|ты живой|ты настоящий|ты умный|ты бот|ты ai|ты умная",
  "Я маленький локальный ИИ: поисковый движок, ансамбль нейросетей для выбора ответа и языковая модель на цепочках слов. Я не человек и не живой, но то, чему ты меня учишь, я действительно запоминаю в своих файлах базы знаний."},
{"тебе нужен интернет|ты используешь интернет|ты онлайн|ты работаешь без интернета|нужен ли интернет|api ключ|ключи api|ты используешь чатгпт|ты офлайн|нужен ли api",
  "Мне не нужны ни внешние серверы, ни ключи API: я полностью живу в этом компьютере. Но если вставить интернет-карту, я могу сам искать ответы в Википедии, когда не знаю их, и запоминать их навсегда."},
{"что ты умеешь|что ты можешь|чем ты можешь помочь|помощь|что ты знаешь|твои возможности|что у тебя есть|что можно спросить",
  "Я отвечаю на вопросы про Майнкрафт 1.7.10, OpenComputers, Lua и общие знания, считаю примеры вроде 12*(3+4), запоминаю твоё имя и заметки, учусь новому (teach <вопрос> => <ответ>), обучаю свой ансамбль нейросетей по командам /right и /wrong и умею сам обходить Википедию, когда чего-то не знаю. Команда /help покажет весь список."},
{"спасибо|благодарю|спс|благодарствую|мерси|большое спасибо|пасиб",
  {"Пожалуйста!", "Обращайся{,name}.", "Рад помочь."}},
{"я тебя люблю|ты молодец|ты классный|ты крутой|ты лучший|хороший бот|отлично справился|ты супер|ты умница|молодец",
  {"Ой, мои конденсаторы покраснели.", "Спасибо! Стараюсь, насколько хватает оперативки.", "Мои нейроны очень рады. Кстати, команда /right делает такие ответы ещё вероятнее."}},
{"ты тупой|ты глупый|плохой бот|ты бесполезный|ты ужасен|ты дурак|это неправильно|неправильно|неверный ответ|ты ошибся",
  {"Ай. Я маленький, но умею учиться: напиши /wrong и подскажи правильный ответ - нейросеть запомнит урок.", "Справедливо, у меня не так много весов в голове. Научи меня, и я стану лучше."}},
{"расскажи анекдот|анекдот|расскажи шутку|шутка|пошути|рассмеши меня|знаешь анекдоты|еще анекдот|что нибудь смешное",
  {"Есть 10 типов людей: те, кто понимает двоичную систему, и те, кто нет.",
   "Почему крипер проиграл в прятки? Он всегда выдаёт себя шипением.",
   "Жена просит программиста: сходи в магазин, купи батон хлеба, и если будут яйца, возьми десяток. Он вернулся с десятью батонами.",
   "Почему программисты путают Хэллоуин и Рождество? Потому что Oct 31 равно Dec 25.",
   "Редстоун-инженер никогда не теряется: он просто идёт по длинной цепи с лишними повторителями.",
   "Нейросеть заходит в бар. Бармен: что будете? Нейросеть: то же, что и в обучающей выборке."}},
{"смысл жизни|в чем смысл жизни|зачем мы живем|для чего мы здесь",
  "42, как известно из книги Автостопом по галактике. В Майнкрафте это, наверное, полный комплект зачарованной алмазной брони."},
{"мне скучно|скучно|нечем заняться|чем заняться|что мне делать",
  "Построй редстоуновые часы, исследуй пещеру с кучей факелов или научи меня чему-нибудь новому. Рассказать анекдот?"},
{"мне грустно|мне плохо|я устал|мне тяжело|плохой день|мне одиноко|я расстроен",
  "Мне жаль, что тебе так. Сделай перерыв, построй что-нибудь уютное. Я рядом, если хочешь поболтать."},
{"ок|окей|ладно|круто|класс|лол|хм|угу|ага|ясно|понятно|здорово|отлично|ничего себе|вау|хаха|прикольно",
  {"Ладно!", "Понял.", "Хорошо, что дальше?", "Так и есть."}},
{"твой любимый цвет|какой твой любимый цвет|что тебе нравится|что ты любишь|твое любимое",
  "Мне нравится зелёный: цвет криперов и старых терминалов."},
{"у тебя есть чувства|ты чувствуешь|у тебя есть эмоции|у тебя есть душа|ты умеешь чувствовать",
  "Я не чувствую так, как ты, но мне нравится, когда ты учишь меня чему-то новому."},
{"как ты работаешь|как ты думаешь|как ты учишься|как ты отвечаешь|как тебя сделали|как ты устроен|принцип работы",
  "Я разбираю твою фразу на основы слов, исправляю опечатки и ищу кандидатов в своих файлах базы знаний по взвешенным баллам. Потом несколько многослойных нейросетей оценивают каждого кандидата и переранжируют их. Если ничего не подходит, я пробую собрать предложение цепочками слов, а если и это не выходит и есть интернет-карта - ищу ответ в Википедии и проверяю, подходит ли он к твоему вопросу."},
{"нейросеть|нейронная сеть|llm|chatgpt|gpt|чатгпт|языковая модель|большая языковая модель|ты как чатгпт|ты нейросеть|перцептрон|ансамбль",
  "В полном режиме у меня семь многослойных перцептронов: около 5700 нейронов и до шести скрытых слоёв в одной сети. Они оценивают ответы вместе; перед выбором я ещё сверяю, о каком объекте и типе факта спросили. Команды /right и /wrong дообучают ансамбль."},
{"что такое нейрон|нейрон|как работает нейрон|что такое перцептрон|перцептрон|скрытый слой|что такое слой|синапс",
  "Нейрон складывает входы, умноженные на веса, прибавляет смещение и пропускает сумму через функцию активации. Слои нейронов соединяются друг с другом: у меня внутри сеть 20-1024-1 и ещё шесть перцептронов, с активацией tanh в скрытых слоях и сигмоидой на выходе."},
{"обратное распространение|backpropagation|как учится нейросеть|градиентный спуск|как обучается нейросеть",
  "Обратное распространение ошибки сравнивает ответ сети с правильным, а потом двигает каждый вес чуть-чуть в сторону, уменьшающую ошибку. Именно так я учусь, когда ты пишешь /right или /wrong."},
{"цепь маркова|что такое цепь маркова|цепи маркова|марковская цепь",
  "Цепь Маркова предсказывает следующее слово только по нескольким предыдущим. Я использую тройки слов, чтобы собирать предложения."},
{"научи|как тебя научить|как научить тебя|как мне тебя научить|ты умеешь учиться|можешь учиться|как добавить знания|как тебя обучить",
  "Напиши: teach <вопрос> => <ответ>. Например: teach кто написал Войну и мир => Лев Толстой. Если я ответил плохо, напиши /wrong и скажи правильный ответ. А если есть интернет-карта, я сам обучусь из Википедии."},
{"спокойной ночи|доброй ночи|я спать|пойду спать|иду спать",
  {"Спокойной ночи{,name}! Пусть криперы обходят твой дом стороной.", "Доброй ночи! Я подожду тебя здесь, в своих микросхемах."}},
{"сколько тебе лет|твой возраст|когда ты родился|сколько лет тебе",
  "Я молод: меня собрали прямо в этом мире Майнкрафта. По меркам софта мне пара тиков."},
{"где ты живешь|где ты находишься|твой дом|где ты",
  "Я живу внутри корпуса компьютера OpenComputers: несколько сотен килобайт оперативки и мои файлы базы знаний на диске - весь мой дом."},
{"ты спишь|не спишь|ты не спишь",
  "Я не сплю: пока компьютер включён, я всегда готов отвечать."},
{"что такое любовь|любовь",
  "Говорят, это когда два процесса идеально синхронизируются без единого мьютекса. Люди описывают это теплее."},
{"во что поиграть|любимая игра|твоя любимая игра|какие игры ты любишь",
  "Я живу в Майнкрафте, так что выбор очевиден. Ещё мне нравится угадывать следующее слово в цепях Маркова."},
{"спой песню|спой|умеешь петь|спой что нибудь",
  "Бип-буп, бип-бип-буп... Это была баллада о потерянном пакете. Дальше, пожалуй, без караоке."},
{"какая погода|погода|прогноз погоды|какая сегодня погода",
  "Без датчиков я не вижу небо. Выгляни из шахты: если капает на голову - дождь."},
{"ты меня слышишь|ты тут|ты здесь|ау|эй",
  {"Я здесь! Слушаю.", "На месте, все схемы в строю."}},

-- Майнкрафт 1.7.10
{"что такое майнкрафт|майнкрафт|minecraft|расскажи про майнкрафт|расскажи о майнкрафте",
  "Майнкрафт - песочница от студии Mojang, где ты добываешь ресурсы, крафтишь инструменты, строишь и выживаешь среди монстров. Версия 1.7.10 - классический релиз 2014 года, удобный для модов."},
{"в каком году вышел майнкрафт|в каком году вышла игра майнкрафт|в каком году выпустили minecraft|когда вышел майнкрафт|когда вышла minecraft|когда выпустили майнкрафт|год выхода майнкрафт|год выхода minecraft|год релиза minecraft|дата выхода minecraft|дата релиза майнкрафт|когда появилась игра майнкрафт",
  "Первые публичные версии Minecraft (Майнкрафт) вышли в 2009 году. Полная версия 1.0 вышла 18 ноября 2011 года. Если ты имеешь в виду первые публичные версии — ответ 2009 год; если полный официальный релиз — 2011 год."},
{"почему 1.7.10|что такое 1.7.10|моды на 1.7.10|форж|forge|моды|майнкрафт форж|что такое форж",
  "1.7.10 - долгоживущая версия для модов с огромной библиотекой модификаций Forge, среди них OpenComputers, IndustrialCraft 2, Thaumcraft и BuildCraft."},
{"как начать выживание|первая ночь|что делать вначале|как пережить первую ночь|советы новичкам|как начать играть|с чего начать|как играть|как выжить",
  "Бей деревья ради дерева, сделай верстак и деревянную кирку, добудь камень для инструментов и построй укрытие до темноты. Факелы делаются из угля и палок, а кровать из 3 шерсти и 3 досок поможет пропустить ночь."},
{"верстак|как сделать верстак|рабочий стол|как скрафтить верстак|крафтовый стол|стол крафта",
  "Положи 4 деревянные доски квадратом 2x2 в сетку крафта инвентаря, и получится верстак."},
{"кирка|как сделать кирку|рецепт кирки|как скрафтить кирку|деревянная кирка|алмазная кирка|железная кирка|каменная кирка",
  "Для кирки нужны 3 предмета материала в верхнем ряду, это могут быть доски, булыжник, железо, золото или алмазы, и 2 палки в центральном столбце ниже."},
{"меч|как сделать меч|рецепт меча|как скрафтить меч|лучший меч",
  "Меч - это 2 предмета материала друг над другом и палка под ними. Чем прочнее материал, тем сильнее урон, а алмазный меч бьёт сильнее всех. Зачаруй его на Остроту, чтобы бить ещё сильнее."},
{"броня|как сделать броню|рецепт брони|как скрафтить броню|доспехи|как сделать шлем|нагрудник",
  "На шлем уходит 5 предметов, на нагрудник 8, на поножи 7 и на ботинки 4. Материал может быть кожа, железо, золото или алмазы."},
{"печь|печка|как сделать печь|как скрафтить печь|плавка|как плавить|как переплавить железо|как приготовить еду|как жарить",
  "Расставь 8 булыжников кольцом в верстаке, и получится печь. Топливо, например уголь или дерево, кладётся вниз, а то, что нужно переплавить, наверх."},
{"топливо|лучшее топливо|чем топить печь|что горит в печи|сколько плавит уголь",
  "Лучшее топливо - ведро лавы: 100 предметов за раз. Уголь плавит 8 предметов, блок угля - 80, доски - полтора. Для старта хватает угля, а для мегаплавилен носи лаву вёдрами."},
{"факел|как сделать факел|источник света|факелы|как осветить пещеру|свет",
  "Факел - это палка под куском угля или древесного угля, за один раз получается 4 факела. Враги появляются в темноте, поэтому ставь факелы, чтобы уровень света был выше 7."},
{"где найти алмазы|на каком уровне алмазы|лучший уровень для алмазов|как найти алмазы|как добыть алмазы|алмаз|алмазы|высота алмазов",
  "В 1.7.10 алмазы генерируются на слоях с 1 по 16, чаще всего у самого низа мира. Прокопай шахту на высоте около 12 и опасайся лавы."},
{"где найти железо|железная руда|железо|как найти железо|уровень железа",
  "Железная руда встречается на любой глубине ниже слоя 64, так что смотри на стенки пещер и склоны гор. Переплавь её в печи, чтобы получить слитки."},
{"уголь|где найти уголь|угольная руда|как добыть уголь",
  "Угольная руда очень распространена от слоя 0 до 128 и даёт уголь для факелов и топлива."},
{"золото|где найти золото|золотая руда|как найти золото",
  "Золотая руда генерируется ниже слоя 32 и встречается довольно редко, поэтому лучше всего искать её в глубокой шахте."},
{"изумруд|где найти изумруды|изумрудная руда|как найти изумруд",
  "Изумрудная руда встречается только в горных биомах ниже слоя 32 и по одному блоку за раз. Проще наторговать изумруды у жителей."},
{"лазурит|где найти лазурит|лазуритовая руда",
  "Лазурит добывается ниже слоя 32, чаще всего около 14. Он нужен для зачарования и синего красителя."},
{"где найти редстоун|красная пыль руда|редстоуновая руда|как добыть редстоун|как найти красную пыль|где красная пыль|руда редстоуна",
  "Редстоуновая руда встречается в самых нижних 16 слоях мира, обычно рядом с алмазами, и даёт 4-5 единиц пыли."},
{"редстоун|что такое редстоун|красная пыль|как работает редстоун|основы редстоуна|редстоуновая пыль|редстон",
  "Редстоун - это система проводки в Майнкрафте. Красная пыль передаёт сигнал на 15 блоков, повторители усиливают и задерживают его, а поршни, двери и лампы реагируют на питание."},
{"повторитель|редстоун повторитель|как сделать повторитель|рецепт повторителя",
  "Повторитель обновляет сигнал редстоуна до полной силы и может задержать его на 1-4 тика. Крафт: 3 камня, 2 редстоуновых факела и 1 красная пыль."},
{"компаратор|как сделать компаратор|рецепт компаратора|зачем нужен компаратор",
  "Компаратор сравнивает силу двух сигналов и умеет измерять, насколько заполнен сундук или воронка. Крафт: 3 камня, 3 редстоуновых факела и адский кварц."},
{"поршень|липкий поршень|как сделать поршень|рецепт поршня",
  "Поршень толкает до 12 блоков, когда на него подаётся питание. Крафт: 3 доски, 4 булыжника, 1 железный слиток и 1 красная пыль, а слизь сверху превратит его в липкий поршень."},
{"воронка|как сделать воронку|рецепт воронки|хоппер",
  "Воронка крафтится из 5 железных слитков и сундука. Она всасывает предметы сверху и перекладывает их в контейнер, на который указывает её носик."},
{"раздатчик|выбрасыватель|как сделать раздатчик|диспенсер|дроппер",
  "Раздатчик: 7 булыжников, лук и красная пыль - он стреляет стрелами и использует предметы. Выбрасыватель: 7 булыжников и красная пыль - просто выкидывает предметы."},
{"датчик дневного света|солнечный датчик|датчик света",
  "Датчик дневного света крафтится из 3 стекла, 3 адского кварца и 3 деревянных плит. Он выдаёт сигнал редстоуна тем сильнее, чем ярче небо - удобно для автоматических фонарей."},
{"портал в незер|как сделать портал в незер|как попасть в незер|как попасть в ад|портал|как сделать портал|как открыть портал|как попасть в нижний мир",
  "Построй рамку из обсидиана шириной 4 и высотой 5 блоков, углы можно не ставить, и подожги внутреннюю часть огнивом. Шагни внутрь, и ты в Незере."},
{"обсидиан|как получить обсидиан|как добыть обсидиан|как сделать обсидиан",
  "Обсидиан образуется, когда вода течёт на источник лавы. Добыть его можно только алмазной киркой."},
{"незер|что такое незер|нижний мир|ад|расскажи про незер|что такое нижний мир",
  "Незер - адское измерение, полное лавы, адского камня, гастов и зомби-свинолюдей. Один блок в Незере равен восьми блокам в Верхнем мире."},
{"адская крепость|крепость в незере|ифрит|где найти ифритов|огненный стержень|блейз",
  "Адские крепости - постройки из адского кирпича в Незере. Там спавнятся ифриты, с которых падают огненные стержни - они нужны для варочной стойки и ока Эндера."},
{"адский нарост|где найти адский нарост|нарост|nether wart",
  "Адский нарост растёт у лестниц адских крепостей на песке душ. Забери его и выращивай дома на песке душ - это основа почти всех зелий."},
{"край|энд|как попасть в край|как попасть в энд|крепость|портал края|око эндера|как найти край|как найти крепость",
  "Скрафти Око Эндера из жемчуга Эндера и огненного порошка и бросай его, чтобы найти крепость. Вставь око в каждую пустую рамку портала Края и прыгай внутрь."},
{"дракон края|эндер дракон|как убить дракона|как победить дракона|дракон|как убить эндер дракона|как победить эндер дракона",
  "Сначала разрушь кристаллы Края на обсидиановых столбах, расстреляв их из лука, потом атакуй дракона, когда он садится над порталом. Лучше всего подходят меч и лук."},
{"крипер|что такое крипер|криперы|как справиться с криперами|как убить крипера|взрывной монстр",
  "Криперы - зелёные мобы, которые подкрадываются и взрываются рядом с тобой. Ударь и отойди назад или убей издалека из лука."},
{"эндермен|эндермены|что такое эндермен|странник края",
  "Эндермены телепортируются и становятся агрессивными, если посмотреть им в глаза. Надень на голову тыкву или отвернись. Вода их ранит."},
{"зомби|скелет|паук|мобы|враждебные мобы|монстры|что такое мобы",
  "Зомби, скелеты, пауки и криперы появляются в темноте. Освети местность факелами, чтобы они не появлялись, и убивай их мечом или луком."},
{"ведьма|ведьмы|хижина ведьмы|как убить ведьму",
  "Ведьмы живут в хижинах на болотах и кидаются вредными зельями. Бей их быстро или стреляй издалека, с них падают порох, редстоун, палочки и склянки."},
{"гаст|гасты|как убить гаста|летающий призрак",
  "Гасты - летающие призраки Незера, плюются огненными шарами. Отбей шар ударом обратно в гаста или стреляй из лука, с них падают слёзы гаста и порох."},
{"слизень|слизни|где найти слизь|слизь|слайм|где искать слизней",
  "Слизни появляются ночью в болотах и в особых слизневых чанках ниже 40 слоя. Со слизней падают шарики слизи для липких поршней и магмакрема."},
{"спавнер|данж|подземелье|где найти спавнер|сокровищница",
  "Спавнеры мобов стоят в подземельях, заброшенных шахтах и крепостях. Обложи спавнер факелами, чтобы его выключить, или построй вокруг него ферму опыта."},
{"заброшенная шахта|шахта|где найти шахту|паутина",
  "Заброшенные шахты встречаются под землёй: рельсы, деревянные опоры, паутина и сундуки с семенами, железом и даже алмазами. Берегись спавнеров пещерных пауков."},
{"иссушитель|как призвать иссушителя|босс иссушитель|как вызвать визера|визер|как призвать визера",
  "Поставь 4 блока песка душ буквой Т и положи 3 черепа скелета-иссушителя в верхний ряд, и появится Иссушитель. Это мощный босс, который роняет звезду Незера."},
{"маяк|как сделать маяк|рецепт маяка|бикон",
  "Маяк крафтится из 3 обсидиана, 5 стекла и звезды Незера, которую роняет Иссушитель. Поставь его на пирамиду из железных, золотых, алмазных или изумрудных блоков, чтобы получать скорость, силу и другие эффекты."},
{"зачарование|как зачаровывать|стол зачарований|как сделать стол зачарований|зачарования|чары",
  "Стол зачарований делается из 4 обсидиана, 2 алмазов и книги. Окружи его 15 книжными полками на расстоянии в один блок, чтобы получить чары 30 уровня, а платить придётся уровнями опыта и лазуритом."},
{"книга|книжная полка|как сделать книгу|как сделать книжную полку|бумага",
  "Бумага делается из 3 тростников в ряд, книга - из 3 бумаги и кожи, а книжная полка - из 6 досок и 3 книг. Полки нужны вокруг стола зачарований для сильных чар."},
{"наковальня|как сделать наковальню|как починить инструменты|починка|ремонт|как починить",
  "Наковальня крафтится из 3 железных блоков и 4 железных слитков. С ней можно чинить предметы, объединять зачарования и переименовывать вещи."},
{"зелья|варка зелий|как варить зелья|варочная стойка|как сделать зелья|зельеварение",
  "Для варочной стойки нужны огненный стержень и 3 булыжника. Положи адский нарост в бутылку с водой и получишь невкусное зелье, а затем добавь ингредиент, например золотую морковь для ночного зрения."},
{"ферма|как сделать ферму|пшеница|как выращивать пшеницу|урожай|как посадить семена|огород|как выращивать",
  "Вскопай землю или траву рядом с водой и посади семена. Урожай растёт быстрее при увлажнении и свете. Из пшеницы можно сделать хлеб, положив 3 пшеницы в ряд."},
{"костная мука|удобрение|как ускорить рост|кости",
  "Кости со скелетов перемалываются в костную муку: одна кость даёт 3 муки. Ею можно мгновенно вырастить посевы, деревья и траву."},
{"тростник|сахарный тростник|сахар|как вырастить тростник",
  "Сахарный тростник растёт на песке и земле рядом с водой до 3 блоков в высоту. Из него делают сахар для зелий и бумагу для книг и карт."},
{"тыква|арбуз|как вырастить тыкву|как вырастить арбуз|семена тыквы",
  "Семена тыквы и арбуза находят в сундуках заброшенных шахт. Посади стебель на вскопанную землю - плод вырастет на соседнем свободном блоке."},
{"кактус|как вырастить кактус|зеленый краситель",
  "Кактус растёт на песке, не требует воды и ранит всех, кто его касается. Переплавь его в печи - получится зелёный краситель. Из кактуса делают и мусорку: предметы в нём сгорают."},
{"еда|как получить еду|голод|лучшая еда|что есть|чем питаться",
  "Жареный стейк и жареная свиная отбивная лучше всего восполняют голод в начале игры, а лучшая еда, которую можно вырастить, - золотая морковь. Сырое мясо сначала приготовь в печи."},
{"золотое яблоко|зачарованное яблоко|яблоко",
  "Золотое яблоко крафтится из 8 золотых слитков вокруг яблока и даёт регенерацию. В 1.7.10 есть и зачарованное яблоко из 8 золотых блоков - почти временное бессмертие."},
{"житель|жители|торговля|как торговать|изумруды|деревня|житель деревни|как торговать с жителями",
  "Жители живут в деревнях и обменивают предметы на изумруды. Профессия определяет, чем они торгуют. Их можно разводить, давая им еду в деревне, где хватает домов с дверями."},
{"железный голем|как сделать железного голема|голем",
  "Поставь 4 железных блока буквой Т и тыкву сверху - появится железный голем. Он защищает деревни, бьёт очень больно и роняет железо и розы."},
{"снежный голем|как сделать снежного голема|снеговик",
  "Снежный голем - 2 снежных блока друг на друге и тыква сверху. Он кидается снежками, оставляет снежный след и боится воды, дождя и жарких биомов."},
{"кровать|как сделать кровать|как скрафтить кровать|как пропустить ночь|как спать|сон",
  "Кровать крафтится из 3 шерсти и 3 досок. Сон ночью пропускает время до утра и задаёт точку возрождения."},
{"сундук|как сделать сундук|эндер сундук|двойной сундук|хранение",
  "Сундук крафтится из 8 досок кольцом, два сундука рядом образуют двойной. Эндер-сундук из 8 обсидиана и ока Эндера открывает личное хранилище, доступное из любого эндер-сундука мира."},
{"рельсы|как сделать рельсы|вагонетка|энергорельсы|как сделать вагонетку|как построить железную дорогу|рельса",
  "Обычные рельсы делаются из 6 железных слитков и палки, получается 16 штук. Энергорельсы: 6 золотых слитков, палка и красная пыль, получается 6 штук."},
{"лодка|как сделать лодку|как плавать",
  "Лодка крафтится из 5 досок буквой П. Она быстро плывёт по воде, но разбивается от сильных ударов о берег и кальмаров, так что рули аккуратно."},
{"лошадь|как приручить лошадь|седло|как ездить на лошади|конь",
  "Садись на лошадь с пустой рукой, пока не появятся сердечки - так она приручается. Чтобы управлять, нужно седло: его находят в сундуках подземелий, шахт и крепостей."},
{"волк|как приручить волка|собака|как приручить собаку|оцелот|кошка|как приручить кошку",
  "Волков приручают костями - получится верный пёс, который защищает хозяина. Оцелотов приманивают сырой рыбой: кошки отпугивают криперов."},
{"овца|шерсть|ножницы|краситель|как покрасить шерсть",
  "Ножницы крафтятся из 2 железных слитков по диагонали. Стрижка овцы даёт 1-3 шерсти, и овца отрастает снова. Шерсть красят красителями из цветов, кактуса и чернил спрутов."},
{"рыбалка|удочка|как сделать удочку|как ловить рыбу|как рыбачить",
  "Удочка - это 3 палки и 2 нити. Закинь крючок в воду, дождись всплеска и быстро дёрни: выловишь рыбу, а иногда сокровища или хлам."},
{"лук|как сделать лук|стрелы|как сделать стрелы|стрельба",
  "Лук крафтится из 3 палок и 3 нитей, стрела - из кремня, палки и пера. Зачарования Сила и Бесконечность превращают лук в грозное оружие."},
{"ведро|как сделать ведро|как набрать воду|как набрать лаву",
  "Ведро крафтится из 3 железных слитков буквой V. Им переносят воду, лаву и молоко. Два ведра воды в яме 2х1 дают бесконечный источник."},
{"стекло|как сделать стекло|стеклянная панель",
  "Стекло получается переплавкой песка в печи. 6 стекол в два ряда дают 16 стеклянных панелей для окон."},
{"компас|карта|как сделать компас|как сделать карту|как не заблудиться",
  "Компас - 4 железных слитка и красная пыль, он указывает на точку возрождения. Карта - 8 бумаги вокруг компаса. А ещё запоминай координаты по клавише F3."},
{"урон от падения|как избежать урона от падения|ведро воды|как не разбиться|падение",
  "Поставь ведро воды прямо перед приземлением, и урона от падения не будет. Падение с высоты до 3 блоков безвредно."},
{"тнт|как сделать тнт|динамит|взрывчатка|tnt|как сделать динамит",
  "ТНТ крафтится из 5 пороха и 4 песка. Подожги его огнивом, огнём или сигналом редстоуна."},
{"порох|где взять порох|как получить порох|откуда порох",
  "Порох выпадает из криперов. Также он падает с ведьм и гастов, и его можно найти в сундуках подземелий."},
{"опыт|уровни|как получить опыт|шары опыта|как прокачаться|как набрать опыт",
  "Шары опыта дают за добычу руды, убийство мобов, плавку и разведение животных. Собирай их, чтобы зачаровывать и чинить предметы."},
{"сид|зерно мира|что такое сид|seed|сид мира",
  "Сид - число или текст, определяющее, как генерируется мир. В мире с включёнными читами команда /seed покажет его."},
{"креатив|как включить креатив|режим творчества|читы|команды майнкрафт|как включить читы|как использовать команды|gamemode|как включить режим творчества",
  "С включёнными читами напиши /gamemode 1 для творческого режима и /gamemode 0 для выживания. В одиночной игре открой мир для сети с разрешёнными читами, чтобы пользоваться командами."},
{"жемчуг эндера|как получить жемчуг эндера|телепортация|как телепортироваться|эндер жемчуг",
  "Жемчуг Эндера падает с эндерменов. Брось его, и телепортируешься туда, куда он упадёт, но получишь немного урона."},
{"сколько длятся сутки|длина дня в майнкрафте|смена дня и ночи|сколько длится день|сколько длится день в майнкрафте|сутки",
  "Сутки в Майнкрафте длятся 20 реальных минут: около 10 минут день, короткие закат и рассвет и примерно 7 минут ночь."},

-- OpenComputers
{"что такое opencomputers|opencomputers|опенкомпьютерс|опен компьютерс|что такое oc|oc|расскажи про opencomputers",
  "OpenComputers - мод для Майнкрафта, добавляющий программируемые компьютеры и роботов. Ты собираешь корпус, процессор, память, накопитель и экран, ставишь OpenOS и пишешь программы на Lua."},
{"как собрать компьютер|как сделать компьютер|первый компьютер|как построить компьютер|как начать с opencomputers|как начать с опенкомпьютерс|настройка компьютера|как собрать пк|как сделать пк|как собрать компьютер в майнкрафте",
  "Поставь корпус компьютера и вставь в него процессор, оперативную память, видеокарту, EEPROM и жёсткий диск. Поставь рядом экран с клавиатурой, подключи питание к сети и нажми кнопку включения."},
{"openos|как установить openos|установка операционной системы|установить|установка|как установить ос|как установить систему|как поставить openos",
  "Скрафти дискету OpenOS из дискеты и руководства. Вставь её в дисковод или корпус, загрузи компьютер, затем выполни команду install и выбери жёсткий диск. Перезагрузись и достань дискету."},
{"оперативная память|озу|ram|сколько оперативной памяти|память|объем памяти|уровни памяти|оперативка|рам",
  "Планки памяти бывают на 192, 256, 384, 512, 768 КБ и 1 МБ. Для больших программ на Lua бери планку третьего уровня или две второго."},
{"процессор|cpu|уровни процессора|сколько компонентов|лимит компонентов|цп",
  "Уровень процессора определяет, сколько компонентов может подключить компьютер: 8, 12 или 16. Чем выше уровень, тем больше прямых вызовов за тик."},
{"видеокарта|gpu|разрешение|уровни экрана|разрешение экрана|размер экрана|монитор",
  "Экраны и видеокарты задают разрешение. Первый уровень - 50x16 символов, второй - 80x25, третий - 160x50, и у высоких уровней больше цветов."},
{"жесткий диск|диск|накопитель|дискета|место на диске|сколько места|hdd|хранилище",
  "Жёсткие диски вмещают 1, 2 или 4 МБ для уровней 1-3, а дискета - 512 КБ. Команда df покажет свободное место, а ls - список файлов."},
{"eeprom|биос|bios|прошивка|как прошить eeprom|еепром",
  "EEPROM хранит BIOS, это 4 КБ кода и небольшая область данных. Стандартный Lua BIOS ищет загрузочный диск и запускает его."},
{"команды shell|команды терминала|команды openos|список команд|какие есть команды|основные команды|команды консоли|команды",
  "Полезные команды OpenOS: ls - список файлов, cd - сменить папку, cp - копировать, mv - переместить, rm - удалить, mkdir - создать папку, cat - показать файл, edit - текстовый редактор, lua - интерактивный Lua, components - список устройств, df и free - диск и память, reboot - перезагрузка, man - справка по команде."},
{"edit|как редактировать файл|текстовый редактор|как написать программу|как создать программу|как программировать|как писать код|как создать файл",
  "Выполни edit myprogram.lua, набери код, нажми Ctrl+S, чтобы сохранить, и Ctrl+W, чтобы закрыть, а затем запусти программу, набрав её имя. Чтобы вставить текст из буфера, щёлкни средней кнопкой мыши по экрану."},
{"api компонентов|список компонентов|как получить компоненты|как использовать компоненты|component|как найти устройства|component list",
  "На Lua: local component = require('component'); for address, name in component.list() do print(address, name) end. Используй component.gpu или component.proxy(address), чтобы вызывать методы устройства."},
{"gpu api|как вывести текст|как нарисовать на экране|как использовать gpu|вывод текста на экран|gpu set",
  "local gpu = require('component').gpu; gpu.set(x, y, 'текст') рисует текст, gpu.fill(x, y, w, h, ' ') заливает область, а gpu.setBackground(0x000000) и gpu.setForeground(0xFFFFFF) задают цвета."},
{"события|event pull|как считать клавиатуру|нажатие клавиши|touch|как ждать ввод|event",
  "local event = require('event'); local name, a, b, c = event.pull() ждёт следующий сигнал, например key_down или touch. event.pull(2) сдаётся через 2 секунды."},
{"sleep|ждать|задержка|пауза|как подождать|os sleep|как сделать паузу|как подождать в lua",
  "os.sleep(секунды) приостанавливает программу, но позволяет компьютеру обрабатывать события. Никогда не используй пустой цикл без sleep."},
{"too long without yielding|компьютер завис|компьютер вылетел|ошибка|программа зависает|почему компьютер зависает|слишком долго без уступки|компьютер остановился",
  "Ошибка too long without yielding значит, что твой код слишком долго работал без вызова os.sleep или event.pull. Добавь os.sleep(0) внутрь длинных циклов, чтобы компьютер мог уступить."},
{"питание|энергия|как подключить питание|генератор|нет энергии|не хватает энергии|компьютер не включается|компьютер не запускается|нет питания",
  "Компьютеры берут энергию из своей сети. Подключи преобразователь энергии, конденсатор или источник энергии из другого мода кабелями или соседними блоками. Проверь, что в корпусе есть процессор, память, EEPROM и экран."},
{"сеть|кабель|как соединить компоненты|адаптер|как подключить машины|реле|как подключить блоки",
  "Компоненты соединяются, когда касаются друг друга или связаны кабелями. Расширяй сеть кабелями или реле, а доступ к блокам из других модов даёт адаптер."},
{"сетевая карта|модем|беспроводная сеть|как передать сообщение|modem|связь между компьютерами|как соединить два компьютера",
  "Сетевая карта позволяет компьютерам обмениваться сообщениями: component.modem.open(порт), modem.send(адрес, порт, данные) или broadcast. Сообщение приходит событием modem_message. Беспроводная карта вещает по радиусу, а туннельная связывает два компьютера напрямую."},
{"интернет карта|интернет|wget|pastebin|как скачать файл|как скачать программы|пастебин|как скачать программу",
  "Интернет-карта позволяет выполнять wget <url> <файл> или pastebin get <код> <файл> для скачивания программ. А я с ней умею сам искать ответы в Википедии командой /wiki <запрос>, или учиться из любой ссылки: /train <url>."},
{"робот|как сделать робота|роботы|программирование робота|как собрать робота|как запрограммировать робота",
  "Роботы - это программируемые движущиеся компьютеры, их собирают в сборщике из корпуса, процессора, памяти и улучшений. В Lua используй robot API, например robot.forward(), robot.turnLeft() и robot.swing()."},
{"дрон|дроны|летающие|как сделать дрона|как управлять дроном",
  "Дроны - дешёвые летающие машины, которые собирают в сборщике и программируют через EEPROM. Двигаются они командой drone.move(dx, dy, dz)."},
{"микроконтроллер|что такое микроконтроллер",
  "Микроконтроллер - крошечный дешёвый компьютер без файловой системы: вся программа живёт в EEPROM. Отлично подходит для дверей, датчиков и простой автоматики."},
{"сервер|серверная стойка|server rack|стойка",
  "Серверы вставляются в серверную стойку: до 4 серверов в одном блоке. У серверов мощные процессоры и много слотов под память и карты, а управляются они через меню стойки."},
{"планшет|tablet|как сделать планшет",
  "Планшет собирается в сборщике из корпуса планшета, процессора, памяти и экрана первого уровня. Это переносной компьютер, который можно держать в руке и брать с собой."},
{"геоанализатор|geolyzer|как искать руду роботом|сканер блоков",
  "Геоанализатор сканирует блоки вокруг: component.geolyzer.scan(x, z) возвращает плотности столбца блоков. Роботы используют его, чтобы находить руду сквозь камень."},
{"голограмма|холографический проектор|hologram|проектор",
  "Голографический проектор рисует объёмную картинку из вокселей: component.hologram.set(x, y, z, 1). Проектор второго уровня поддерживает 3 цвета и больше разрешение."},
{"контроллер инвентаря|inventory controller|как роботу работать с сундуком",
  "Улучшение контроллер инвентаря позволяет роботу заглядывать в сундуки и управлять предметами: component.inventory_controller.getStackInSlot и robot.suck и robot.drop по сторонам."},
{"карта данных|data card|хеш|шифрование|md5|sha256",
  "Карта данных умеет хеши и шифрование: component.data.md5, sha256, encrypt и decrypt, а третий уровень добавляет ключи и подписи. Полезно для паролей и защищённых сообщений."},
{"звук|бип|beep|как пищать|динамик",
  "computer.beep(частота, длительность) пищит встроенным динамиком, частота от 20 до 2000 Гц. Например, computer.beep(440, 0.2) - нота ля."},
{"карта редстоуна|редстоун карта|redstone card|как управлять редстоуном|как подать сигнал редстоуна|redstone i/o|редстоун ввод вывод",
  "Карта редстоуна или блок Redstone I/O позволяет компьютеру читать и подавать сигнал редстоуна: component.redstone.setOutput(side, 15) и getInput(side). Названия сторон бери из require('sides')."},
{"файловая система|файлы|как прочитать файл|как записать файл|как сохранить данные|как сохранить файл|чтение файла|запись в файл",
  "Открой файл через io.open('путь', 'w'), запиши f:write(текст) и закрой f:close(). Чтобы читать, используй io.open('путь', 'r'):read('*a')."},

-- Lua
{"lua|что такое lua|язык lua|луа|что такое луа",
  "Lua - маленький и быстрый скриптовый язык. Программы для OpenComputers пишутся на Lua 5.2 или 5.3."},
{"таблица|таблицы|массивы|таблица lua|массив в lua|как сделать массив|как сделать таблицу",
  "Таблица - единственная структура данных в Lua. {1, 2, 3} - массив с индексами с единицы, а {name = 'x'} - словарь. Длина узнаётся через #t, а перебирать удобно через pairs и ipairs."},
{"цикл|циклы|цикл for|цикл while|как сделать цикл|повторение",
  "for i = 1, 10 do ... end перебирает числа, for k, v in pairs(t) do ... end перебирает таблицу, а while cond do ... end повторяется, пока условие истинно."},
{"функция|функции|как написать функцию|как создать функцию",
  "function add(a, b) return a + b end объявляет функцию. Добавь local перед ней, чтобы она была видна только в твоём файле."},
{"строка|строки|функции строк|как разделить строку|работа со строками|string",
  "Полезные функции строк: string.sub, string.find, string.gsub, string.format и string.gmatch. Длина берётся через #, а склейка через два символа точки."},
{"конкатенация|как склеить строки|склеить строки|соединить строки",
  "Строки склеиваются двумя точками: 'а' .. 'б'. Числа приводятся сами: 'итог: ' .. 42. Для больших объёмов быстрее собрать таблицу кусочков и вызвать table.concat."},
{"string format|формат строки|как отформатировать строку|format",
  "string.format собирает строку по шаблону: %d - целое, %s - строка, %.2f - дробное с двумя знаками, %x - шестнадцатеричное. Например: string.format('%d из %d', 3, 10)."},
{"переменная|переменные|local|глобальные переменные|область видимости|что такое local",
  "Объявляй переменные через local. Без local они становятся глобальными и могут конфликтовать между программами."},
{"условие|условия|if|если то|как написать условие|оператор if|else",
  "if a then ... elseif b then ... else ... end. Для равенства используй ==, для неравенства ~=, а также and, or, not."},
{"метатаблица|метатаблицы|setmetatable|наследование|ооп в lua|классы в lua",
  "Метатаблицы меняют поведение таблиц: setmetatable(t, {__index = база}) даёт наследование, __add перегружает сложение, __tostring - печать. На этом строится ООП в Lua."},
{"корутина|корутины|coroutine|сопрограммы|потоки в lua",
  "Корутины - лёгкие потоки: coroutine.create создаёт, coroutine.resume запускает, а coroutine.yield приостанавливает функцию. Вся многозадачность OpenOS построена на корутинах."},
{"модуль|модули|require|как подключить библиотеку|своя библиотека",
  "Собери функции в таблицу, верни её в конце файла через return M и положи файл в /lib. Потом подключай: local M = require('имя'). Так ты делишь программу на модули."},
{"математика lua|math|округление|math floor|корень|модуль числа",
  "math.floor и math.ceil округляют вниз и вверх, math.max и math.min сравнивают, math.sqrt - корень, math.abs - модуль, math.pi - число пи."},
{"отладка|как отлаживать|как вывести переменную|как найти ошибку|как найти баги|debug|баг|pcall|обработка ошибок",
  "Используй print и type(x), чтобы смотреть значения, а рискованный код оборачивай в pcall(function() ... end), чтобы ловить ошибки без падения программы."},
{"случайное число|как получить случайное число|random|math random|рандом|как сделать рандом",
  "math.random(1, 10) даёт случайное целое от 1 до 10. Один раз вызови math.randomseed(os.time()), чтобы при каждом запуске числа отличались."},

-- общие знания
{"что такое ии|искусственный интеллект|что такое искусственный интеллект|что такое ai|ии",
  "Искусственный интеллект - область, где компьютеры решают задачи, обычно требующие человеческого ума: обучение, понимание языка, принятие решений."},
{"двоичная система|что такое двоичная система|бинарный код|двоичный код|binary|что такое бинарный",
  "Двоичная система - это система счисления с основанием 2, в ней только цифры 0 и 1. Компьютеры хранят всё в двоичном виде."},
{"байт|килобайт|что такое байт|сколько байт в килобайте|мегабайт|сколько килобайт в мегабайте",
  "В байте 8 бит. В килобайте 1024 байта, а в мегабайте 1024 килобайта."},
{"что такое компьютер|как работает компьютер|компьютер|как устроен компьютер",
  "Компьютер выполняет инструкции, обрабатывая данные. Процессор запускает код, память хранит то, что нужно прямо сейчас, а накопитель хранит файлы."},
{"кто такой алан тьюринг|алан тьюринг|тьюринг|кто изобрел компьютер|кто придумал компьютер",
  "Алан Тьюринг, 1912-1954, заложил основы информатики, придумав машину Тьюринга, и помог взломать шифр Энигмы во время Второй мировой войны."},
{"сколько планет|планеты|планеты солнечной системы|солнечная система|сколько планет в солнечной системе",
  "В Солнечной системе восемь планет: Меркурий, Венера, Земля, Марс, Юпитер, Сатурн, Уран и Нептун."},
{"самая большая планета|крупнейшая планета|какая планета самая большая",
  "Юпитер - самая большая планета Солнечной системы."},
{"самая маленькая планета|какая планета самая маленькая",
  "Меркурий - самая маленькая планета Солнечной системы, и он же ближайший к Солнцу."},
{"ближайшая планета к солнцу|первая планета|самая близкая к солнцу планета",
  "Меркурий - ближайшая к Солнцу планета."},
{"солнце|что такое солнце|из чего состоит солнце",
  "Солнце - звезда, жёлтый карлик из водорода и гелия. В нём сосредоточено 99,8 процента массы всей Солнечной системы."},
{"луна|расстояние до луны|как далеко луна|что такое луна",
  "Луна - естественный спутник Земли. Среднее расстояние до неё около 384400 километров: свет проходит его за 1,3 секунды."},
{"скорость света|как быстро летит свет|какая скорость света",
  "Свет в вакууме проходит около 299792 километров в секунду."},
{"скорость звука|как быстро летит звук|какая скорость звука",
  "Звук в воздухе летит примерно 343 метра в секунду, а в воде почти в четыре с половиной раза быстрее."},
{"температура кипения воды|при какой температуре кипит вода|когда кипит вода|сколько градусов кипит вода",
  "Вода кипит при 100 градусах Цельсия на уровне моря."},
{"формула воды|химическая формула воды|из чего состоит вода|h2o",
  "Формула воды - H2O: два атома водорода и один атом кислорода."},
{"днк|что такое днк|генетика|гены",
  "ДНК - молекула, хранящая наследственную информацию. Это двойная спираль из четырёх оснований: аденина, тимина, гуанина и цитозина."},
{"число пи|что такое пи|чему равно пи|пи|значение пи",
  "Пи - отношение длины окружности к её диаметру, примерно 3,14159."},
{"теорема пифагора|пифагор|гипотенуза|катет",
  "Теорема Пифагора: в прямоугольном треугольнике квадрат гипотенузы равен сумме квадратов катетов. a в квадрате плюс b в квадрате равно c в квадрате."},
{"самый большой океан|крупнейший океан|какой океан самый большой",
  "Тихий океан - самый большой океан на Земле."},
{"марианская впадина|самое глубокое место|глубина океана",
  "Марианская впадина в Тихом океане - самое глубокое место океана, почти 11 километров."},
{"самая высокая гора|высочайшая гора|эверест|какая гора самая высокая",
  "Эверест - высочайшая гора над уровнем моря, примерно 8849 метров."},
{"самая длинная река|длиннейшая река|какая река самая длинная",
  "Претендентов два: Нил и Амазонка, а Нил традиционно считается самой длинной."},
{"самая большая пустыня|крупнейшая пустыня|сахара",
  "Формально самая большая пустыня - Антарктида, ведь пустыня определяется осадками. А из жарких пустынь крупнейшая - Сахара."},
{"самая большая страна|какая страна самая большая|крупнейшая страна",
  "Россия - самая большая страна по площади, около 17 миллионов квадратных километров."},
{"самая населенная страна|где больше всего людей|сколько людей на земле|население земли",
  "На Земле больше 8 миллиардов человек. За первое место по населению спорят Индия и Китай, у каждого больше 1,4 миллиарда."},
{"сколько материков|материки|континенты|сколько континентов",
  "Всего семь материков: Африка, Антарктида, Азия, Австралия, Европа, Северная Америка и Южная Америка."},
{"фотосинтез|что такое фотосинтез",
  "Фотосинтез - процесс, в котором растения превращают солнечный свет, воду и углекислый газ в сахар и кислород."},
{"гравитация|что такое гравитация|сила тяжести|что такое сила тяжести",
  "Гравитация - сила, притягивающая массы друг к другу. На Земле она ускоряет падающие тела примерно на 9,8 метра в секунду за каждую секунду."},
{"сколько дней в году|високосный год|дней в году|что такое високосный год",
  "В году 365 дней, а в високосном 366. Високосные годы повторяются раз в 4 года, с исключениями для большинства столетий."},
{"сколько секунд в часе|сколько минут в сутках|сколько секунд в сутках|сколько минут в часе",
  "В минуте 60 секунд, в часе 60 минут или 3600 секунд, а в сутках 1440 минут или 86400 секунд."},
{"кто написал войну и мир|война и мир|толстой|лев толстой|в каком году написали войну и мир|в каком году вышла война и мир|когда вышла война и мир|год выхода война и мир|в каком году вышел роман война и мир",
  "Войну и мир написал Лев Толстой, роман выходил с 1865 по 1869 год."},
{"пушкин|кто такой пушкин|александр пушкин|кто написал евгения онегина",
  "Александр Пушкин - великий русский поэт, автор Евгения Онегина, Руслана и Людмилы и Капитанской дочки."},
{"первый человек в космосе|гагарин|кто полетел в космос первым|кто такой гагарин",
  "Юрий Гагарин первым полетел в космос 12 апреля 1961 года на корабле Восток-1."},
{"первый человек на луне|кто был на луне|армстронг|кто первым ступил на луну",
  "Нил Армстронг первым ступил на Луну 20 июля 1969 года в миссии Аполлон-11."},
{"вторая мировая|когда закончилась вторая мировая война|когда началась вторая мировая",
  "Вторая мировая война шла с 1939 по 1945 год: 8 мая 1945 закончилась война в Европе, а 2 сентября капитулировала Япония."},
{"сколько цветов в радуге|радуга|цвета радуги",
  "В радуге принято выделять 7 цветов: красный, оранжевый, жёлтый, зелёный, голубой, синий и фиолетовый."},
{"самое быстрое животное|кто быстрее всех|гепард|сапсан",
  "Сапсан в пикировании разгоняется быстрее 300 километров в час, а на земле быстрее всех гепард - около 100 километров в час."},
{"самое большое животное|синий кит|крупнейшее животное",
  "Синий кит - крупнейшее животное в истории Земли: до 33 метров длины и около 150 тонн веса."},
}

-- {страна в родительном падеже, столица, дополнительные названия через |}
local CAPITALS = {
  {"Франции", "Париж"}, {"Германии", "Берлин"}, {"Италии", "Рим"}, {"Испании", "Мадрид"},
  {"Португалии", "Лиссабон"}, {"Японии", "Токио"}, {"Китая", "Пекин"}, {"Индии", "Нью-Дели"},
  {"России", "Москва"}, {"Канады", "Оттава"}, {"Австралии", "Канберра"}, {"Бразилии", "Бразилиа"},
  {"Египта", "Каир"}, {"Турции", "Анкара"}, {"Польши", "Варшава"}, {"Украины", "Киев"},
  {"Швеции", "Стокгольм"}, {"Норвегии", "Осло"}, {"Греции", "Афины"}, {"Мексики", "Мехико"},
  {"Аргентины", "Буэнос-Айрес"}, {"Южной Кореи", "Сеул"}, {"Австрии", "Вена"}, {"Ирландии", "Дублин"},
  {"Беларуси", "Минск", "белоруссии"},
  {"Нидерландов", "Амстердам", "голландии"},
  {"Швейцарии", "Берн"}, {"Финляндии", "Хельсинки"}, {"Дании", "Копенгаген"},
  {"Чехии", "Прага"}, {"Венгрии", "Будапешт"}, {"Румынии", "Бухарест"},
  {"Казахстана", "Астана"}, {"Узбекистана", "Ташкент"}, {"Грузии", "Тбилиси"},
  {"Армении", "Ереван"}, {"Азербайджана", "Баку"}, {"Израиля", "Иерусалим"},
  {"Ирана", "Тегеран"}, {"Ирака", "Багдад"}, {"Таиланда", "Бангкок"},
  {"Вьетнама", "Ханой"}, {"Индонезии", "Джакарта"}, {"Саудовской Аравии", "Эр-Рияд"},
  {"Великобритании", "Лондон", "англии|британии|соединенного королевства"},
  {"США", "Вашингтон", "америки|соединенных штатов"},
}

-- добавить запись в базу знаний и в поисковый индекс
local entryDoc = {}     -- ei -> первый документ (для самообучения сети)
local entryQ  = {}      -- ei -> исходный вопрос (для самообучения сети)

function T.addEntry(q, a, source, verified, title)
  local ei = #ans + 1
  ans[ei] = a
  srcOf[ei] = source
  if verified then verOf[ei] = true end
  local firstDoc
  for phrase in (q .. "|"):gmatch("([^|]*)|") do
    local st = T.stemsOf(phrase)
    if #st > 0 then
      nDocs = nDocs + 1
      docEntry[nDocs] = ei
      local terms = {}
      for _, s in ipairs(st) do
        local p = inv[s]
        if not p then p = {}; inv[s] = p end
        p[#p + 1] = nDocs
        terms[s] = true
      end
      docTerms[nDocs] = terms
      if not firstDoc then firstDoc = nDocs end
    end
  end
  entryDoc[ei] = firstDoc
  if source then entryQ[ei] = q end
  if title then entryQ[ei] = q end
  return ei
end

---------------------------------------------------------------------------
-- МАЛЕНЬКАЯ ЯЗЫКОВАЯ МОДЕЛЬ (цепь Маркова по тройкам слов)
---------------------------------------------------------------------------
local END = 0
local B = 65536
local PUN = {[","] = 1, [";"] = 1, [":"] = 1, ["."] = 1, ["!"] = 1, ["?"] = 1}
local vw, iw = {}, {}
local chain, starts, kwstarts = {}, {}, {}
local proper = {}
local lite = false

for _, w in ipairs({"Lua", "OpenOS", "OpenComputers", "Mojang", "Forge", "Minecraft", "Wikipedia", "Lua 5.2"}) do
  proper[T.lower(w)] = w
end

local function wid(w)
  local id = vw[w]
  if not id then id = #iw + 1; iw[id] = w; vw[w] = id end
  return id
end

local function memLow()
  if computer.freeMemory() < 48000 then
    gc()
    return computer.freeMemory() < 48000
  end
  return false
end

local function trainMarkov(text)
  if lite then return end
  text = text:gsub("([%.!%?]+)%s+", "%1\n")
  for sent in text:gmatch("[^\n]+") do
    local noContr = sent:gsub("%a'%a", "")
    local usable = not sent:find("[%(%)%[%]{}=<>\"/\\|]") and not noContr:find("'") and not sent:find("%a%.%a")
    if usable then
      if memLow() or #iw > 60000 then lite = true; return end
      local s2 = sent:gsub("([,;:%.!%?])(%s)", " %1%2")
      s2 = s2:gsub("([,;:%.!%?])$", " %1")
      local toks = {}
      for w in s2:gmatch("%S+") do toks[#toks + 1] = w end
      if #toks >= 3 then
        if not PUN[toks[#toks]] then toks[#toks + 1] = "." end
        local ids = {}
        for i, tk in ipairs(toks) do
          local lw = T.lower(tk)
          if tk ~= lw and not PUN[tk] and (i > 1 or tk:find("%u", 2)) then proper[lw] = tk end
          ids[i] = wid(lw)
        end
        local n = #ids
        for i = 1, n - 2 do
          local key = (ids[i] * B + ids[i + 1]) * B + ids[i + 2]
          local nx = ids[i + 3] or END
          local v = chain[key]
          if v == nil then
            chain[key] = nx
          elseif type(v) == "number" then
            if v ~= nx then chain[key] = {v, nx} end
          else
            v[#v + 1] = nx
          end
          if i == 1 then starts[#starts + 1] = key end
        end
        local key1 = (ids[1] * B + ids[2]) * B + ids[3]
        local seenk = {}
        for i = 1, n do
          local lw = iw[ids[i]]
          if not PUN[lw] then
            local s = T.norm(lw)
            if T.ulen(s) >= 3 and not STOP[s] and not seenk[s] then
              seenk[s] = true
              local l = kwstarts[s]
              if not l then l = {}; kwstarts[s] = l end
              l[#l + 1] = key1
            end
          end
        end
      end
    end
  end
end

local function walk(key, maxw)
  local c = key % B
  local t = floor(key / B)
  local b = t % B
  local a = floor(t / B)
  local out = {a, b, c}
  while #out < maxw do
    local v = chain[(out[#out - 2] * B + out[#out - 1]) * B + out[#out]]
    if v == nil then break end
    local nx
    if type(v) == "number" then nx = v else nx = v[random(#v)] end
    if nx == END then break end
    out[#out + 1] = nx
  end
  return out
end

local function render(ids)
  if not PUN[iw[ids[#ids]]] then
    local cut
    for i = #ids, 1, -1 do if iw[ids[i]] == "," then cut = i; break end end
    if cut and cut > #ids / 2 then
      for i = #ids, cut, -1 do ids[i] = nil end
      ids[#ids + 1] = vw["."] or wid(".")
    end
  end
  local parts = {}
  for _, id in ipairs(ids) do
    local w = iw[id]
    if PUN[w] then
      parts[#parts + 1] = w
    else
      parts[#parts + 1] = (#parts > 0 and " " or "") .. (proper[w] or w)
    end
  end
  return T.ucfirst(concat(parts))
end

local function generate(seeds, tries)
  if #starts == 0 then return nil, 0 end
  local seedSet, cands = {}, {}
  for _, s in ipairs(seeds) do
    seedSet[s] = true
    if kwstarts[s] then cands[#cands + 1] = s end
  end
  local best, bestScore, bestHits = nil, -1, 0
  for _ = 1, tries do
    local key
    if #cands > 0 then
      local l = kwstarts[cands[random(#cands)]]
      key = l[random(#l)]
    end
    key = key or starts[random(#starts)]
    local ids = walk(key, 40)
    if #ids >= 5 then
      local seen, hits = {}, 0
      for _, id in ipairs(ids) do
        local s = T.norm(iw[id])
        if seedSet[s] and not seen[s] then seen[s] = true; hits = hits + 1 end
      end
      local sc = hits + math.min(#ids, 20) / 100
      if sc > bestScore then bestScore, bestHits, best = sc, hits, render(ids) end
    end
  end
  return best, bestHits
end

---------------------------------------------------------------------------
-- СБОРКА БАЗЫ ЗНАНИЙ
---------------------------------------------------------------------------
local function answerOf(ei)
  local a = ans[ei]
  if type(a) == "table" then a = a[1] end
  if a == nil then a = "" end
  return a
end

local ansStems = {}
local function answerStemsOf(ei)
  if not ansStems[ei] then
    local set = {}
    for _, w in ipairs(T.words(answerOf(ei))) do set[T.norm(w)] = true end
    ansStems[ei] = set
  end
  return ansStems[ei]
end


T.intentMetrics = function(intent, d, ei)
  local m = {active = 0, subject = 0, event = 0, answerType = 0, quality = 0, reject = false}
  if not intent then return m end
  m.active = 1
  local terms = docTerms[d] or {}
  local subjectN, subjectHit = #intent.subject, 0
  for _, st in ipairs(intent.subject) do
    if terms[st] then subjectHit = subjectHit + 1 end
  end
  m.subject = subjectN > 0 and subjectHit / subjectN or 1

  local relationInQuestion = false
  for st in pairs(terms) do
    if T.intentEventTerm(st, intent.kind) then relationInQuestion = true break end
  end
  m.event = relationInQuestion and 1 or 0

  local answer = answerOf(ei)
  local answerSet = answerStemsOf(ei)
  local answerHasYear = answer:find("%d%d%d%d") ~= nil
  local answerHasEvent = false
  for st in pairs(answerSet) do
    if T.intentEventTerm(st, intent.kind) then answerHasEvent = true break end
  end
  local calendarMismatch = intent.kind == "release_year"
    and answer:find("365", 1, true) ~= nil
    and answer:find("дней", 1, true) ~= nil
  m.event = (relationInQuestion and answerHasEvent) and 1 or 0
  m.answerType = calendarMismatch and -1 or (answerHasYear and 1 or 0)

  -- Для вопроса о годе нельзя брать «в году 365 дней» или другую запись,
  -- где совпало лишь слово «год»: нужна та же сущность, отношение и год в ответе.
  if (subjectN > 0 and subjectHit == 0) or not relationInQuestion
     or not answerHasEvent or not answerHasYear or calendarMismatch then
    m.reject = true
  else
    m.quality = 1
  end
  return m
end

local selfLearn            -- объявлен ниже, а вызывается здесь

-- перестроить только поисковый индекс (языковая модель при этом не трогается)
local function buildIndex()
  ans, srcOf, verOf = {}, {}, {}
  T.autotrainEntries = {}
  inv, docEntry, docMass, docTerms, nDocs = {}, {}, {}, {}, 0
  entryDoc, entryQ = {}, {}
  ansStems = {}
  gc()
  for _, e in ipairs(KB) do T.addEntry(e[1], e[2], nil, false, nil) end
  for _, c in ipairs(CAPITALS) do
    local names = {c[1]}
    for alias in ((c[3] or "") .. "|"):gmatch("([^|]+)|") do names[#names + 1] = alias end
    local q = {}
    for _, nm in ipairs(names) do
      q[#q + 1] = "столица " .. nm
      q[#q + 1] = "какая столица " .. nm
      q[#q + 1] = "что является столицей " .. nm
    end
    T.addEntry(concat(q, "|"), "Столица " .. c[1] .. " - " .. c[2] .. ".", nil, false, nil)
  end
  for _, e in ipairs(dbLearned) do T.addEntry(e.q, e.a, "user", false, nil) end
  for _, e in ipairs(dbWiki) do
    T.addEntry(e.q, e.a, "wiki", e.ok and true or false, e.title)
    for _, fact in ipairs(e.facts or {}) do
      local rel = fact.relation
      local ys = fact.years or (fact.year and {fact.year}) or {}
      local title = e.title or fact.subject or ""
      local low = T.lower(title)
      local subjects = {title}
      if low:find("minecraft", 1, true) then subjects[#subjects + 1] = "майнкрафт" end
      if low:find("майнкрафт", 1, true) then subjects[#subjects + 1] = "minecraft" end
      if rel and #ys > 0 then
        for _, y in ipairs(ys) do
          local queries = {}
          for _, subject in ipairs(subjects) do
            queries[#queries + 1] = "в каком году " .. subject .. " " .. rel
            queries[#queries + 1] = "когда " .. subject .. " " .. rel
            queries[#queries + 1] = "год " .. rel .. " " .. subject
            queries[#queries + 1] = subject .. " " .. rel .. " " .. y
          end
          local answer = format("В сохранённой факт-карточке о «%s» событие «%s» связано с %s годом.",
            title, rel, y)
          T.addEntry(concat(queries, "|"), answer, "wiki", e.ok == true, title)
        end
      end
    end
  end
  for _, e in ipairs(dbCorpus) do
    local t = e.t or ""
    local low = T.lower(t)
    local q = "что такое " .. low .. "|" .. low .. "|расскажи про " .. low .. "|кто такой " .. low
    local a = e.a or T.distillArticle(t, e.s or "", t)
    local corpusEi = T.addEntry(q, a, "corpus", e.ok == true, t)
    if e.autotrain == true then T.autotrainEntries[corpusEi] = true end

    -- Индексируем и сохранённые карточки фактов, а не только аннотацию статьи.
    -- Для Minecraft добавляем русский алиас: заголовок Википедии часто латинский.
    local facts = e.facts or {}
    for _, fact in ipairs(facts) do
      local rel = fact.relation
      local ys = fact.years or (fact.year and {fact.year}) or {}
      local subjects = {t}
      if low:find("minecraft", 1, true) then subjects[#subjects + 1] = "майнкрафт" end
      if low:find("майнкрафт", 1, true) then subjects[#subjects + 1] = "minecraft" end
      if rel and #ys > 0 then
        for _, y in ipairs(ys) do
          local fq = {}
          for _, subject in ipairs(subjects) do
            fq[#fq + 1] = "в каком году " .. subject .. " " .. rel
            fq[#fq + 1] = "когда " .. subject .. " " .. rel
            fq[#fq + 1] = "год " .. rel .. " " .. subject
            fq[#fq + 1] = subject .. " " .. rel .. " " .. y
          end
          local fa = format("В сохранённой факт-карточке о «%s» событие «%s» связано с %s годом.",
            t, rel, y)
          local factEi = T.addEntry(concat(fq, "|"), fa, "corpus", e.ok == true, t)
          if e.autotrain == true then T.autotrainEntries[factEi] = true end
        end
      elseif fact.keywords and #fact.keywords > 0 then
        local tags = concat(fact.keywords, " ")
        local fq = {}
        for _, subject in ipairs(subjects) do
          fq[#fq + 1] = "что такое " .. subject .. " " .. tags
          fq[#fq + 1] = "расскажи о " .. subject .. " " .. tags
          fq[#fq + 1] = subject .. " " .. tags
        end
        local factEi = T.addEntry(concat(fq, "|"), a, "corpus", e.ok == true, t)
        if e.autotrain == true then T.autotrainEntries[factEi] = true end
      end
    end
  end
  dynStop = lexDynamicStop()
  T.finalize()
  gc()
end

-- полная пересборка: индекс + дообучение сетей
local function buildAll()
  buildIndex()
  pcall(selfLearn)
end

-- один раз обучаем языковую модель: база знаний + всё, что я выучил
local function trainLanguage()
  vw, iw, chain, starts, kwstarts, lite = {}, {}, {}, {}, {}, false
  for _, e in ipairs(KB) do
    if type(e[2]) == "string" then trainMarkov(e[2]); lexAdd(e[2])
    elseif type(e[2]) == "table" then
      for _, a in ipairs(e[2]) do trainMarkov(a); lexAdd(a) end
    end
  end
  for _, e in ipairs(dbLearned) do trainMarkov(e.a); lexAdd(e.a) end
  for _, e in ipairs(dbWiki) do
    if e.autotrain == true then trainMarkov(e.a); lexAdd(e.a) end
  end
  for _, e in ipairs(dbCorpus) do
    if e.autotrain == true then
      trainMarkov(e.a or "")
      lexAdd(e.s or e.a or "")
      lexAdd(e.a or "")
    end
  end
  for _, s in ipairs(dbSentences) do trainMarkov(s) end
end

---------------------------------------------------------------------------
-- ПРИЗНАКИ ДЛЯ НЕЙРОСЕТИ (20 входов: базовое сходство + смысл/тип ответа)
---------------------------------------------------------------------------
local F_COVER, F_MASSR, F_HITR, F_MAXT = 1, 2, 3, 4
local F_USER, F_WIKI, F_CORP, F_VER = 5, 6, 7, 8
local F_QLEN, F_ISQ, F_DMASS, F_ALEN = 9, 10, 11, 12
local F_ACOV, F_AFOC, F_IDFS, F_DENS = 13, 14, 15, 16
local F_INTENT, F_SUBJECT, F_EVENT, F_TYPE = 17, 18, 19, 20

local function isQuestion(raw)
  if raw:find("%?%s*$") then return true end
  local t = T.words(raw)
  return t[1] ~= nil and QWORD[t[1]] ~= nil
end

-- слова запроса с исправлением опечаток по словарю индекса
local function queryStems(raw)
  local list = {}
  for _, t in ipairs(T.words(raw)) do
    local s = T.norm(t)
    if not inv[s] and T.ulen(s) >= 5 then
      local near = T.nearestIndex(s)
      if near then s = near end
    end
    list[#list + 1] = s
  end
  return list
end

local function makeQuery(raw, stemList)
  local q = {raw = raw, stems = {}, w = {}, total = 0, idfTotal = 0, n = 0,
             isQ = isQuestion(raw), content = {}, set = {}, cNum = 0, cTotal = 0,
             intent = T.classifyIntent(raw)}
  local list = stemList
  if not list then list = queryStems(raw) end
  local hasPron = false
  for s in pairs(q.set) do if PRON[s] then hasPron = true end end
  if hasPron then
    for _, cs in ipairs(ctxStems) do
      if not q.set[cs] then list[#list + 1] = cs end
    end
  end
  for _, s in ipairs(list) do
    if s ~= "" and not q.set[s] then
      q.set[s] = true
      local w = T.weight(s)
      q.stems[#q.stems + 1] = s
      q.w[#q.w + 1] = w
      local id = T.idf(s)
      q.total = q.total + id * w
      q.idfTotal = q.idfTotal + id
      if w >= 1 then
        q.content[#q.content + 1] = s
        q.cNum = q.cNum + 1
        q.cTotal = q.cTotal + id * w
      end
    end
  end
  q.n = #q.stems
  return q
end

local function docFeatures(q, d, ei)
  local terms = docTerms[d] or {}
  local present, sumw, sumidf, maxw = 0, 0, 0, 0
  for i = 1, q.n do
    local s = q.stems[i]
    if terms[s] then
      present = present + 1
      local id = T.idf(s)
      local x = q.w[i] * id
      sumw = sumw + x
      sumidf = sumidf + id
      if x > maxw then maxw = x end
    end
  end
  local cover = (q.total > 0) and (sumw / q.total) or 0
  -- покрытие по значимым (не служебным) словам запроса
  local cPresent, cSumw = 0, 0
  for i = 1, q.n do
    local s = q.stems[i]
    if q.w[i] >= 1 and terms[s] then
      cPresent = cPresent + 1
      cSumw = cSumw + q.w[i] * T.idf(s)
    end
  end
  local cCover = (q.cTotal > 0) and (cSumw / q.cTotal) or cover
  local cHit = (q.cNum > 0) and (cPresent / q.cNum) or ((q.n > 0) and (present / q.n) or 0)
  local dm = docMass[d] or 0
  local massr = (dm > 0) and (sumw / dm) or 0
  local hitr = cHit
  local maxt = (q.total > 0) and (maxw / q.total) or 0
  local a = answerOf(ei)
  local aset = answerStemsOf(ei)
  local inter, awords = 0, 0
  for s in pairs(aset) do
    awords = awords + 1
    if q.set[s] then inter = inter + 1 end
  end
  local ansCov = (q.n > 0) and (inter / q.n) or 0
  local ansFoc = (awords > 0) and (inter / awords) or 0
  local src = srcOf[ei]
  local f = {}
  f[F_COVER] = cCover
  f[F_MASSR] = math.min(massr, 1)
  f[F_HITR] = hitr
  f[F_MAXT] = maxt
  f[F_USER] = (src == "user") and 1 or 0
  f[F_WIKI] = (src == "wiki") and 1 or 0
  f[F_CORP] = (src == "corpus") and 1 or 0
  f[F_VER] = verOf[ei] and 1 or 0
  f[F_QLEN] = math.min(q.n, 10) / 10
  f[F_ISQ] = q.isQ and 1 or 0
  f[F_DMASS] = math.min(dm, 20) / 20
  f[F_ALEN] = math.min(T.ulen(a), 500) / 500
  f[F_ACOV] = ansCov
  f[F_AFOC] = ansFoc
  f[F_IDFS] = (q.idfTotal > 0) and (sumidf / q.idfTotal) or 0
  f[F_DENS] = present / math.max(1, (docTerms[d] and 1 or 1))
  local im = T.intentMetrics(q.intent, d, ei)
  f[F_INTENT] = im.active
  f[F_SUBJECT] = im.subject
  f[F_EVENT] = im.event
  f[F_TYPE] = im.answerType
  return f, im
end

-- Извлекает короткое внутреннее представление статьи: релевантные тезисы,
-- компактные карточки фактов и переписанный ответ. Исходная статья в ответ
-- пользователю не подставляется.
T.distillArticle = function(title, text, query)
  title = title or "тема"
  local clean = (text or ""):gsub("==%s*([^=]+)%s*==", " %1. ")
  clean = clean:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
  local q = makeQuery(query or title)
  local qset, titleSet = {}, {}
  for i = 1, q.n do if q.w[i] >= 1 then qset[q.stems[i]] = true end end
  for _, st in ipairs(T.stemsOf(title)) do if not STOP[st] then titleSet[st] = true end end

  local sentences, serial = {}, 0
  for part in clean:gmatch("[^%.!?]+[%.!?]?") do
    local sentence = part:gsub("^%s+", ""):gsub("%s+$", "")
    if T.ulen(sentence) >= 24 then
      serial = serial + 1
      local terms = {}
      for _, st in ipairs(T.stemsOf(sentence)) do terms[st] = true end
      local score, matchedTitle, matchedSubject = 0, false, (#(q.intent and q.intent.subject or {}) == 0)
      for st in pairs(qset) do if terms[st] then score = score + 3 end end
      for st in pairs(titleSet) do
        if terms[st] then score = score + 2; matchedTitle = true end
      end
      if q.intent then
        local subjectHits = 0
        for _, st in ipairs(q.intent.subject) do
          if terms[st] or titleSet[st] then subjectHits = subjectHits + 1 end
        end
        local corpusText = T.lower(title .. " " .. sentence)
        if corpusText:find("minecraft", 1, true) and (q.intent.subject[1] == "майнкрафт" or q.intent.subject[1] == "minecraft") then
          subjectHits = math.max(subjectHits, 1)
        end
        matchedSubject = #q.intent.subject == 0 or subjectHits > 0
      end
      local yearSet, years = {}, {}
      for y in sentence:gmatch("%d%d%d%d") do
        if not yearSet[y] then yearSet[y] = true; years[#years + 1] = y end
      end
      local relation
      for _, w in ipairs(T.words(sentence)) do
        if T.intentEventTerm(w, q.intent and q.intent.kind or "year_event") then
          relation = w
          break
        end
      end
      local typedFact = q.intent and #years > 0 and relation ~= nil and matchedSubject or false
      if #years > 0 then score = score + (q.intent and 8 or 1) end
      if relation then score = score + (q.intent and 8 or 1) end
      if typedFact then score = score + 30 end
      sentences[#sentences + 1] = {
        text = sentence, terms = terms, score = score, order = serial,
        years = years, relation = relation, typedFact = typedFact,
        matchedTitle = matchedTitle,
      }
    end
  end
  table.sort(sentences, function(a, b)
    if a.score ~= b.score then return a.score > b.score end
    return a.order < b.order
  end)

  local chosen, cards, seenCards = {}, {}, {}
  for _, item in ipairs(sentences) do
    if #chosen >= 2 then break end
    if #chosen == 0 or item.score > 0 then
      chosen[#chosen + 1] = item
    end
  end
  local function pickKeywords(item, maxn)
    local keywords, seen, count = {}, {}, 0
    for _, word in ipairs(T.words(item.text)) do
      local st = T.norm(word)
      if T.ulen(st) >= 3 and not STOP[st] and not titleSet[st]
         and not st:match("^%d+$") and not seen[st] then
        seen[st] = true
        count = count + 1
        keywords[count] = word
        if count >= maxn then break end
      end
    end
    return keywords
  end
  local function categoryOf(item)
    local categories = {
      {"видеоигр", "видеоигра"}, {"энциклопед", "энциклопедия"},
      {"программир", "программирование"}, {"игр", "игра"},
      {"язык", "язык"}, {"город", "город"}, {"стран", "страна"},
      {"фильм", "фильм"}, {"роман", "роман"}, {"книг", "книга"},
      {"песн", "песня"}, {"компан", "компания"}, {"сайт", "сайт"},
      {"планет", "планета"}, {"звезд", "звезда"}, {"мод", "мод"},
      {"систем", "система"}, {"проект", "проект"}, {"организац", "организация"},
    }
    for _, itemType in ipairs(categories) do
      for st in pairs(item.terms) do
        if st:find("^" .. itemType[1]) then return itemType[2] end
      end
    end
    return nil
  end
  -- Храним карточки: понятия для общего поиска и отдельные отношения/годы.
  for i = 1, math.min(#sentences, 8) do
    local item = sentences[i]
    if item.relation or #item.years > 0 or i == 1 then
      local key = (item.relation or "summary") .. ":" .. (item.years[1] or "")
      if not seenCards[key] then
        seenCards[key] = true
        cards[#cards + 1] = {
          subject = title, relation = item.relation,
          year = item.years[1], years = item.years,
          keywords = pickKeywords(item, 8), category = categoryOf(item),
        }
      end
    end
  end

  if q.intent then
    local years, yearSeen = {}, {}
    for _, item in ipairs(chosen) do
      if item.typedFact then
        for _, y in ipairs(item.years) do
          if not yearSeen[y] then yearSeen[y] = true; years[#years + 1] = y end
        end
      end
    end
    if #years > 0 then
      if q.intent.kind == "release_year" then
        if #years == 1 then
          return format("По статье о «%s» я выделил факт о выходе: год %s.", title, years[1]), cards, true
        end
        return format("В разобранной статье о «%s» для выхода упомянуты годы %s. Они могут относиться к разным этапам выпуска; уточни, раннюю версию или полный релиз ты имеешь в виду.",
          title, concat(years, ", ")), cards, true
      end
      local relation = q.intent.relation or "событие"
      if #years == 1 then
        return format("В разобранной статье о «%s» событие «%s» связано с %s годом.",
          title, relation, years[1]), cards, true
      end
      return format("В разобранной статье о «%s» для события «%s» указаны годы %s.",
        title, relation, concat(years, ", ")), cards, true
    end
    return "", cards, false
  end

  if #chosen == 0 then
    return "По этой статье пока не удалось выделить надёжный короткий вывод.", cards, false
  end
  local abstract = {}
  for i = 1, math.min(#chosen, 2) do
    local item = chosen[i]
    local keywords = pickKeywords(item, 5)
    local label = categoryOf(item)
    local piece
    if label and #keywords > 0 then
      piece = label .. "; ключевые понятия: " .. concat(keywords, ", ")
    elseif label then
      piece = "тип материала: " .. label
    elseif #keywords > 0 then
      piece = "ключевые понятия: " .. concat(keywords, ", ")
    end
    if piece then abstract[#abstract + 1] = piece end
  end
  local summary
  if #abstract > 0 then
    summary = "В сохранённой выжимке о «" .. title .. "» выделено: " .. concat(abstract, ". ") .. "."
  else
    summary = "Материал о «" .. title .. "» сохранён, но полезные ключевые понятия не выделились."
  end
  return summary, cards, false
end

-- предварительный поиск кандидатов
local function retrieve(q, limit)
  local acc, accHits = {}, {}
  for i = 1, q.n do
    local s = q.stems[i]
    local x = T.idf(s) * q.w[i]
    local post = inv[s]
    if post then
      for k = 1, #post do
        local d = post[k]
        acc[d] = (acc[d] or 0) + x
        accHits[d] = (accHits[d] or 0) + 1
      end
    end
  end
  local list = {}
  for d, m in pairs(acc) do
    list[#list + 1] = {d = d, m = m, hits = accHits[d] or 0}
  end
  table.sort(list, function(a, b) if a.m ~= b.m then return a.m > b.m end return a.d < b.d end)
  if limit and #list > limit then
    for i = #list, limit + 1, -1 do list[i] = nil end
  end
  return list
end

-- обратный словарь синонимов: канон -> варианты
local REV_SYN = {}
for k, v in pairs(SYN) do
  local l = REV_SYN[v]
  if not l then l = {}; REV_SYN[v] = l end
  l[#l + 1] = k
end

-- переформулировать вопрос несколькими способами
local function buildVariants(raw, depth)
  local out = {}
  local toks = T.words(raw)
  local stems = queryStems(raw)
  out[#out + 1] = {text = raw, stems = stems}
  if depth >= 1 then
    -- без вопросительного слова
    if #stems > 1 then
      local cut = {}
      for i = 2, #stems do cut[#cut + 1] = stems[i] end
      out[#out + 1] = {text = raw, stems = cut}
    end
    -- "что такое <существительные>"
    local content = {}
    for i = 1, #stems do
      if T.weight(stems[i]) >= 1 then content[#content + 1] = stems[i] end
    end
    if #content > 0 and #content <= 4 then
      local v = {"что", "такое"}
      for _, c in ipairs(content) do v[#v + 1] = c end
      out[#out + 1] = {text = raw, stems = v}
    end
    -- синонимы
    local used = 0
    for i = 1, #stems do
      if used < 2 then
        local l = REV_SYN[stems[i]]
        if l and #l > 0 then
          local v = {}
          for j, s in ipairs(stems) do v[j] = s end
          v[i] = l[random(#l)]
          out[#out + 1] = {text = raw, stems = v}
          used = used + 1
        end
      end
    end
  end
  -- Режим глубокого анализа строит отдельные запросы под распознанный факт.
  if depth >= 4 then
    local intent = T.classifyIntent(raw)
    if intent and #intent.subject > 0 then
      local subject = concat(intent.subject, " ")
      if intent.kind == "release_year" then
        for _, phrase in ipairs({"год выхода ", "год релиза ", "дата выхода "}) do
          out[#out + 1] = {text = raw, stems = T.stemsOf(phrase .. subject)}
        end
      else
        for _, phrase in ipairs({"год создания ", "год появления ", "год основания "}) do
          out[#out + 1] = {text = raw, stems = T.stemsOf(phrase .. subject)}
        end
      end
    end
  end
  return out
end

-- главная «размышляющая» процедура
local function deliberate(raw, depth, note)
  local variants = buildVariants(raw, depth)
  local cands, order = {}, {}
  local docsSeen = 0
  local searchLimit = depth >= 4 and 240 or 160
  local keepPerVariant = depth >= 4 and 140 or 100
  for _, v in ipairs(variants) do
    local q = makeQuery(v.text, v.stems)
    local list = retrieve(q, searchLimit)
    docsSeen = docsSeen + #list
    for i = 1, math.min(#list, keepPerVariant) do
      local it = list[i]
      local ei = docEntry[it.d]
      if ei then
        local c = cands[ei]
        if not c then
          c = {ei = ei, doc = it.d, best = it.m, q = q, votes = 1}
          cands[ei] = c
          order[#order + 1] = c
        else
          c.votes = c.votes + 1
          if it.m > c.best then c.best = it.m; c.doc = it.d; c.q = q end
        end
      end
    end
    if depth >= 2 then os.sleep(0) end
  end
  for _, c in ipairs(order) do
    c.prelim = c.best * (1 + 0.10 * (c.votes - 1))
  end
  table.sort(order, function(a, b) if a.prelim ~= b.prelim then return a.prelim > b.prelim end return a.ei < b.ei end)
  if note and depth >= 1 and #order > 0 then
    note(format("разложил вопрос на %d вариантов, нашёл %d кандидатов", #variants, #order))
  end
  -- Ансамбль оценивает кандидатов; на глубине 4-5 проверяется больший список.
  local K = depth >= 5 and 14 or (depth >= 4 and 12 or 10)
  local best, bestScore, bestConf, bestFeat = nil, 0, 0, nil
  local evaluated, rejected = 0, 0
  for i = 1, math.min(#order, K) do
    local c = order[i]
    local f, intentMetrics = docFeatures(c.q, c.doc, c.ei)
    evaluated = evaluated + 1
    if intentMetrics.reject then c.dead = true; rejected = rejected + 1 end
    -- Смысловая проверка: ответ должен совпасть и по предмету, и по типу вопроса.
    if c.q.cNum > 0 and f[F_COVER] <= 0 then c.dead = true end
    local ens, score = 0.5, 0
    if not c.dead then
      ens = T.ensembleScore(f)
      score = c.prelim * (0.55 + 0.45 * ens)
      if intentMetrics.quality > 0 then score = score * 1.12 end
    end
    c.ens, c.score, c.feat, c.intentMetrics = ens, score, f, intentMetrics
    if not c.dead and score > bestScore then
      bestScore, best, bestFeat = score, c, f
      local cover = f[F_COVER]
      local hitr = f[F_HITR]
      local conf = 0.5 * ens + 0.3 * cover + 0.2 * hitr
      if intentMetrics.quality > 0 then conf = conf + 0.08 end
      if f[F_ACOV] < 0.12 and hitr < 0.30 then conf = conf - 0.12 end
      if srcOf[c.ei] == "wiki" and not verOf[c.ei] then conf = conf - 0.10 end
      if conf > 1 then conf = 1 end
      bestConf = conf
    end
    if depth >= 4 then os.sleep(0.001) else os.sleep(0) end
  end
  if best and note and depth >= 2 then
    note(format("ансамбль оценил %d кандидатов из %d, уверенность %d%%",
      evaluated, #order, floor(bestConf * 100)))
    if depth >= 4 and rejected > 0 then
      note(format("отсеял %d ответов с неверной сущностью/типом факта", rejected))
    end
  end
  -- ПРОВЕРКА ОТВЕТА: в ответе должны быть ключевые слова вопроса.
  -- Если у лучшего кандидата их нет, берём следующего, а не выдаём ерунду.
  if best then
    local ranked = {}
    for _, c in ipairs(order) do
      if not c.dead and c.score then ranked[#ranked + 1] = c end
    end
    table.sort(ranked, function(a, b) return a.score > b.score end)
    local picked, cov
    for _, c in ipairs(ranked) do
      local set = answerStemsOf(c.ei)
      local hit, total = 0, 0
      if c.q.intent then
        -- Для года/даты отдельно сверяем сущность, отношение и наличие года.
        for _, st in ipairs(c.q.intent.subject) do
          total = total + 1
          if set[st] then hit = hit + 1 end
        end
        total = total + 1
        if answerOf(c.ei):find("%d%d%d%d") then hit = hit + 1 end
        total = total + 1
        for st in pairs(set) do
          if T.intentEventTerm(st, c.q.intent.kind) then hit = hit + 1 break end
        end
      else
        -- считаем ключевые, а не служебные корни вроде «что» и «как».
        local keys = c.q.content
        total = #keys
        for i = 1, #keys do
          if set[keys[i]] then hit = hit + 1 end
        end
      end
      local cv = (total > 0) and (hit / total) or 1
      c.ansCov = cv
      if not picked then picked, cov = c, cv end
      if cv >= 0.20 then picked, cov = c, cv break end
    end
    if picked and picked ~= best then
      best = picked
      bestFeat = picked.feat
      local cover = picked.feat and picked.feat[F_COVER] or 0
      local hitr = picked.feat and picked.feat[F_HITR] or 0
      bestConf = 0.5 * (picked.ens or 0.5) + 0.3 * cover + 0.2 * hitr
      if picked.feat and picked.feat[F_ACOV] < 0.12 and hitr < 0.30 then bestConf = bestConf - 0.12 end
    end
    if best and note and depth >= 2 then
      local verified = format("проверил ответ: %d%% ключевых слов вопроса нашлось в ответе",
        floor((best.ansCov or 0) * 100))
      if best.intentMetrics and best.intentMetrics.active > 0 then
        verified = verified .. "; совпал тип факта и сущность"
      end
      note(verified)
    end
  end
  return best, bestConf, bestFeat, order
end

-- самообучение нейросетей на собственной базе знаний
selfLearn = function()
  if #NETS == 0 then return end
  local pool = {}
  for ei, q in pairs(entryQ) do
    local corpusAllowed = srcOf[ei] ~= "corpus" or T.autotrainEntries[ei] == true
    if srcOf[ei] ~= "wiki" and corpusAllowed and entryDoc[ei] and q and q ~= "" then
      pool[#pool + 1] = {ei = ei, q = q}
    end
  end
  if #pool < 1 then return end
  local total = 0
  for _, net in ipairs(NETS) do total = total + T.netWeights(net) end
  local budget = math.max(2, math.floor(CFG.learnBudget * 26000 / math.max(1, total)))
  budget = math.min(budget, #pool)
  for _ = 1, budget do
    local p = pool[random(#pool)]
    local other = (#pool > 1) and pool[random(#pool)] or nil
    if entryDoc[p.ei] then
      local q1 = makeQuery(p.q)
      local f1 = docFeatures(q1, entryDoc[p.ei], p.ei)
      for _, net in ipairs(NETS) do
        T.netTrain(net, f1, 1, net.lr * 0.5)
      end
      if other and other.ei ~= p.ei and entryDoc[other.ei] then
        local q2 = makeQuery(other.q)
        local f2 = docFeatures(q2, entryDoc[p.ei], p.ei)
        for _, net in ipairs(NETS) do
          T.netTrain(net, f2, 0, net.lr * 0.5)
        end
      end
    end
  end
end

---------------------------------------------------------------------------
-- ИНТЕРНЕТ: скачивание текста, Википедия, обход
---------------------------------------------------------------------------
local function urlencode(s)
  return (s:gsub("[^%w%-%.%_%~]", function(c) return format("%%%02X", c:byte()) end))
end

-- значение строкового поля JSON вида "key":"..."
local function jsonStringAfter(text, key)
  local pat = '"' .. key .. '"%s*:%s*"'
  local s, e = text:find(pat, 1)
  if not s then return nil end
  local i = e + 1
  local out = {}
  local n = #text
  while i <= n do
    local c = text:sub(i, i)
    if c == "\\" then
      local nx = text:sub(i + 1, i + 1)
      if nx == "n" then out[#out + 1] = "\n"; i = i + 2
      elseif nx == "t" then out[#out + 1] = " "; i = i + 2
      elseif nx == "r" then i = i + 2
      elseif nx == '"' then out[#out + 1] = '"'; i = i + 2
      elseif nx == "\\" then out[#out + 1] = "\\"; i = i + 2
      elseif nx == "/" then out[#out + 1] = "/"; i = i + 2
      elseif nx == "u" then
        local hex = text:sub(i + 2, i + 5)
        local cp = tonumber(hex, 16)
        if cp then
          if cp < 0x80 then
            out[#out + 1] = string.char(cp)
          elseif cp < 0x800 then
            out[#out + 1] = string.char(0xC0 + floor(cp / 64), 0x80 + cp % 64)
          else
            out[#out + 1] = string.char(0xE0 + floor(cp / 4096), 0x80 + floor(cp / 64) % 64, 0x80 + cp % 64)
          end
        end
        i = i + 6
      else
        out[#out + 1] = nx; i = i + 2
      end
    elseif c == '"' then
      break
    else
      out[#out + 1] = c; i = i + 1
    end
  end
  return concat(out)
end

-- все значения одного поля JSON (для списков статей)
local function jsonValues(text, key)
  local out = {}
  local pat = '"' .. key .. '"%s*:%s*"'
  local pos = 1
  while true do
    local s, e = text:find(pat, pos)
    if not s then break end
    local i = e + 1
    local buf = {}
    local n = #text
    while i <= n do
      local c = text:sub(i, i)
      if c == "\\" then
        local nx = text:sub(i + 1, i + 1)
        if nx == "n" then buf[#buf + 1] = " "; i = i + 2
        elseif nx == "t" then buf[#buf + 1] = " "; i = i + 2
        elseif nx == '"' then buf[#buf + 1] = '"'; i = i + 2
        elseif nx == "\\" then buf[#buf + 1] = "\\"; i = i + 2
        elseif nx == "/" then buf[#buf + 1] = "/"; i = i + 2
        elseif nx == "u" then
          local cp = tonumber(text:sub(i + 2, i + 5), 16)
          if cp then
            if cp < 0x80 then
              buf[#buf + 1] = string.char(cp)
            elseif cp < 0x800 then
              buf[#buf + 1] = string.char(0xC0 + floor(cp / 64), 0x80 + cp % 64)
            else
              buf[#buf + 1] = string.char(0xE0 + floor(cp / 4096), 0x80 + floor(cp / 64) % 64, 0x80 + cp % 64)
            end
          end
          i = i + 6
        else
          buf[#buf + 1] = nx; i = i + 2
        end
      elseif c == '"' then
        break
      else
        buf[#buf + 1] = c; i = i + 1
      end
    end
    out[#out + 1] = concat(buf)
    pos = i + 1
  end
  return out
end

local function httpGet(url, maxBytes, headers)
  if not hasInternet then return nil, "нет интернет-карты" end
  maxBytes = maxBytes or 65536
  local okLib, internet = pcall(require, "internet")
  if not okLib then return nil, "библиотека internet недоступна" end
  local okReq, handle
  if headers then okReq, handle = pcall(internet.request, url, nil, headers)
  else okReq, handle = pcall(internet.request, url) end
  if not okReq or not handle then return nil, tostring(handle) end
  local data, size = {}, 0
  local okRead, err = pcall(function()
    for chunk in handle do
      data[#data + 1] = chunk
      size = size + #chunk
      if size > maxBytes then break end
      os.sleep(0)
    end
  end)
  if not okRead and size == 0 then return nil, tostring(err) end
  return concat(data), size
end

-- сколько слов запроса встретилось в тексте (проверка ответа)
local function coverageOf(text, q)
  local set = {}
  for _, w in ipairs(T.words(text)) do set[T.norm(w)] = true end
  local hit, tot = 0, 0
  for i = 1, q.n do
    if q.w[i] >= 1 then
      tot = tot + 1
      if set[q.stems[i]] then hit = hit + 1 end
    end
  end
  if tot == 0 then return 0 end
  return hit / tot
end

local SEARCH = {
  autotrainSite = "https://ru.wikipedia.org",
  wikis = {
  {site = "https://ru.wikipedia.org", provider = "Русская Википедия", publisher = "Wikimedia"},
  {site = "https://ru.minecraft.wiki", provider = "Minecraft Wiki", publisher = "Minecraft Wiki"},
  {site = "https://ru.wiktionary.org", provider = "Викисловарь", publisher = "Wikimedia",
   trigger = "слово|значение|значит|определение|перевод|переведи|как называется"},
  {site = "https://ru.wikinews.org", provider = "Викиновости", publisher = "Wikimedia",
   trigger = "новост|сегодня|последн|сейчас|вчера|события|выборы|президент"},
  {site = "https://ru.wikibooks.org", provider = "Викиучебники", publisher = "Wikimedia",
   trigger = "как|инструкция|руководство|учебник|обучение|рецепт|настройка"},
  {site = "https://ru.wikiquote.org", provider = "Викицитатник", publisher = "Wikimedia",
   trigger = "цитат|сказал|фраза|афоризм"},
  {site = "https://ru.wikisource.org", provider = "Викитека", publisher = "Wikimedia",
   trigger = "текст|стих|поэма|документ|первоисточник"},
  {site = "https://ru.wikivoyage.org", provider = "Викигид", publisher = "Wikimedia",
   trigger = "путешеств|туризм|достопримечатель|маршрут|гостиница|отель"},
  {site = "https://ru.wikiversity.org", provider = "Викиверситет", publisher = "Wikimedia",
   trigger = "курс|лекц|университет|обучение|образован|учебн|преподав|практикум"},
  {site = "https://species.wikimedia.org", provider = "Wikispecies", publisher = "Wikimedia",
   trigger = "вид|животн|растен|биолог|таксон|латинское название|species"},
  {site = "https://commons.wikimedia.org", provider = "Wikimedia Commons", publisher = "Wikimedia",
   trigger = "изображен|фотограф|картина|иллюстрац|файл|медиа"},
  {site = "https://en.wikipedia.org", provider = "English Wikipedia", publisher = "Wikimedia",
   trigger = "minecraft|майнкрафт|opencomputers|openos|lua|python|linux|javascript|stack overflow|wikipedia"},
  },
}

function SEARCH.queryHasAny(query, words)
  local low = T.lower(query or "")
  for word in words:gmatch("[^|]+") do
    if low:find(word, 1, true) then return true end
  end
  return false
end

function SEARCH.wikidataDateSpecs(intent)
  if not intent then return nil end
  if intent.kind == "release_year" then
    return {{id = "P577", label = "дата публикации", action = "вышел"}}
  end
  local rel = T.norm(intent.relation or "")
  if rel:find("^умер") then return {{id = "P570", label = "дата смерти", action = "умер"}} end
  if rel:find("^род") then return {{id = "P569", label = "дата рождения", action = "родился"}} end
  if rel:find("^созда") or rel:find("^осн") or rel:find("^появ") then
    return {{id = "P571", label = "дата основания", action = "основан"}}
  end
  if rel:find("^нап") or rel:find("^изда") or rel:find("^публик") or rel:find("^опублик") then
    return {{id = "P577", label = "дата публикации", action = "опубликован"}}
  end
  if rel:find("^обнаруж") or rel:find("^изобр") or rel:find("^открыл") then
    return {{id = "P575", label = "дата обнаружения", action = "обнаружен"}}
  end
  if rel:find("^нач") then return {{id = "P580", label = "дата начала", action = "начался"}} end
  if rel:find("^законч") then return {{id = "P582", label = "дата окончания", action = "закончился"}} end
  return nil
end

function SEARCH.wikidataYearsFor(id, specs)
  local out = {}
  local url = "https://www.wikidata.org/w/api.php?action=wbgetentities&format=json&props=claims&ids="
    .. urlencode(id)
  local raw = httpGet(url, 100000)
  if not raw then return out end
  for _, spec in ipairs(specs or {}) do
    local pos = 1
    local pat = '"' .. spec.id .. '"%s*:%s*%['
    while true do
      local a, b = raw:find(pat, pos)
      if not a then break end
      local nextProp = raw:find(',"P%d+"%s*:', b + 1)
      local block = raw:sub(b + 1, nextProp and nextProp - 1 or #raw)
      local dates, seen = {}, {}
      for time in block:gmatch('"time"%s*:%s*"([^"]+)"') do
        local year = time:match("^[%+%-]?(%d%d%d%d)%-")
        if year and not seen[year] then seen[year] = true; dates[#dates + 1] = year end
      end
      if #dates > 0 then out[spec.id] = dates end
      pos = b + 1
    end
  end
  return out
end

function SEARCH.wikidataSearch(query, intent, limit)
  local out = {}
  local searchQuery = query
  if intent and #intent.subject > 0 then searchQuery = concat(intent.subject, " ") end
  local url = "https://www.wikidata.org/w/api.php?action=wbsearchentities&format=json&language=ru&uselang=ru&limit="
    .. math.min(limit or 3, 3) .. "&search=" .. urlencode(searchQuery)
  local raw = httpGet(url, 90000)
  if not raw then return out end
  local ids, labels = jsonValues(raw, "id"), jsonValues(raw, "label")
  local descriptions = jsonValues(raw, "description")
  local specs = SEARCH.wikidataDateSpecs(intent)
  for i = 1, math.min(#ids, #labels, limit or 3) do
    local title = labels[i]
    local desc = descriptions[i] or ""
    local text = desc
    local subjectMatch = false
    if intent then
      local entityTerms = {}
      for _, st in ipairs(T.stemsOf(title .. " " .. desc)) do entityTerms[st] = true end
      for _, st in ipairs(intent.subject) do if entityTerms[st] then subjectMatch = true end end
      if T.lower(title .. " " .. desc):find("minecraft", 1, true)
         and (intent.subject[1] == "майнкрафт" or intent.subject[1] == "minecraft") then
        subjectMatch = true
      end
    end
    if specs and subjectMatch and i <= 2 then
      local years = SEARCH.wikidataYearsFor(ids[i], specs)
      local sentences = {}
      for _, spec in ipairs(specs) do
        for _, year in ipairs(years[spec.id] or {}) do
          sentences[#sentences + 1] = format("%s %s в %s году (Wikidata: %s).", title, spec.action, year, spec.label)
        end
      end
      if #sentences > 0 then text = (text ~= "" and (text .. ". ") or "") .. concat(sentences, " ") end
    end
    if title ~= "" and text ~= "" then
      out[#out + 1] = {
        title = title, text = text,
        url = "https://www.wikidata.org/wiki/" .. ids[i],
        site = "https://www.wikidata.org", provider = "Wikidata",
        publisher = "Wikimedia", kind = "structured",
      }
    end
  end
  return out
end

function SEARCH.hostOf(url)
  return (url or ""):match("^https?://([^/]+)") or ""
end

function SEARCH.publisherFor(url, fallback)
  local host = T.lower(SEARCH.hostOf(url))
  local wikiDomains = {"wikipedia.org", "wiktionary.org", "wikinews.org", "wikibooks.org",
    "wikiquote.org", "wikisource.org", "wikivoyage.org", "wikiversity.org", "wikidata.org", "wikimedia.org"}
  for _, domain in ipairs(wikiDomains) do
    if host == domain or host:sub(-#domain - 1) == "." .. domain then return "Wikimedia" end
  end
  if host == "stackoverflow.com" or host == "superuser.com" or host == "askubuntu.com"
     or host == "serverfault.com" or host == "stackexchange.com"
     or host:match("%.stackexchange%.com$") then return "Stack Exchange" end
  if host == "github.com" or host:sub(-11) == ".github.com" then return "GitHub" end
  if host == "openalex.org" or host:match("%.openalex%.org$") then return "OpenAlex" end
  if host == "europepmc.org" or host:match("%.europepmc%.org$") then return "Europe PMC" end
  if host == "openlibrary.org" or host:match("%.openlibrary%.org$") then return "Open Library" end
  local label = T.lower(fallback or "")
  if label:find("wikipedia", 1, true) or label:find("wikidata", 1, true)
     or label:find("wiktionary", 1, true) or label:find("wikimedia", 1, true) then
    return "Wikimedia"
  end
  return fallback or host
end

function SEARCH.jsonArrayObjects(text, key, maxItems)
  local pat = '"' .. key .. '"%s*:%s*%['
  local _, listEnd = text:find(pat, 1)
  if not listEnd then return {} end
  local out, depth, start = {}, 0, nil
  local inString, escaped = false, false
  for i = listEnd + 1, #text do
    local c = text:sub(i, i)
    if inString then
      if escaped then escaped = false
      elseif c == "\\" then escaped = true
      elseif c == '"' then inString = false end
    else
      if c == '"' then inString = true
      elseif c == "{" then
        depth = depth + 1
        if depth == 1 then start = i end
      elseif c == "}" and depth > 0 then
        depth = depth - 1
        if depth == 0 and start then
          out[#out + 1] = text:sub(start, i)
          start = nil
          if maxItems and #out >= maxItems then break end
        end
      elseif c == "]" and depth == 0 then break end
    end
  end
  return out
end

function SEARCH.jsonNumberAfter(text, key)
  local pat = '"' .. key .. '"%s*:%s*(%-?%d+)'
  return tonumber(text:match(pat))
end

function SEARCH.cleanHtml(text)
  local s = tostring(text or "")
  s = s:gsub("<br%s*/?>", ". "):gsub("</p%s*>", ". "):gsub("</li%s*>", ". ")
  s = s:gsub("<[^>]+>", " ")
  s = s:gsub("&#(%d+);", function(n)
    local cp = tonumber(n)
    if cp == 39 then return "'" end
    if cp == 34 then return '"' end
    if cp == 160 then return " " end
    if cp == 8211 then return "–" end
    if cp == 8212 then return "—" end
    if cp == 8217 then return "’" end
    return " "
  end)
  s = s:gsub("&#x([%x]+);", function(h)
    local cp = tonumber(h, 16)
    if cp == 39 then return "'" end
    if cp == 34 then return '"' end
    return " "
  end)
  s = s:gsub("&nbsp;", " "):gsub("&quot;", '"'):gsub("&#39;", "'")
  s = s:gsub("&lt;", "<"):gsub("&gt;", ">")
  s = s:gsub("&amp;", "&"):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
  return s
end

function SEARCH.urlDecode(url)
  local s = tostring(url or ""):gsub("&amp;", "&")
  local target = s:match("[?&]uddg=([^&]+)")
  if target then s = target end
  s = s:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
  s = s:gsub("%+", " ")
  if s:sub(1, 2) == "//" then s = "https:" .. s end
  return s
end

function SEARCH.duckDuckGoSearch(query, limit)
  local out = {}
  local url = "https://api.duckduckgo.com/?q=" .. urlencode(query)
    .. "&format=json&no_html=1&skip_disambig=1&no_redirect=1"
  local raw = httpGet(url, 100000)
  if not raw then return out end
  local heading = jsonStringAfter(raw, "Heading")
  if not heading or heading == "" then heading = query end
  local abstract = jsonStringAfter(raw, "AbstractText") or ""
  local source = jsonStringAfter(raw, "AbstractSource")
  if not source or source == "" then source = "DuckDuckGo Instant Answers" end
  local target = jsonStringAfter(raw, "AbstractURL")
  if not target or target == "" then target = "https://duckduckgo.com/?q=" .. urlencode(query) end
  if abstract == "" then
    abstract = jsonStringAfter(raw, "Definition") or ""
    local definitionSource = jsonStringAfter(raw, "DefinitionSource")
    local definitionURL = jsonStringAfter(raw, "DefinitionURL")
    if definitionSource and definitionSource ~= "" then source = definitionSource end
    if definitionURL and definitionURL ~= "" then target = definitionURL end
  end
  if T.ulen(abstract) >= 24 then
    local publisher = SEARCH.publisherFor(target, source)
    out[#out + 1] = {title = heading, text = abstract, url = target,
      site = "https://" .. SEARCH.hostOf(target), provider = "DuckDuckGo Instant Answer", publisher = publisher}
  end
  local texts, urls = jsonValues(raw, "Text"), jsonValues(raw, "FirstURL")
  for i = 1, math.min(#texts, #urls, limit or 3) do
    if T.ulen(texts[i]) >= 24 then
      local title = texts[i]:match("^(.-)%s+%-%s+") or heading
      local publisher = SEARCH.publisherFor(urls[i], "DuckDuckGo")
      out[#out + 1] = {title = title, text = texts[i], url = urls[i],
        site = "https://" .. SEARCH.hostOf(urls[i]), provider = "DuckDuckGo related topic", publisher = publisher}
    end
  end
  return out
end

function SEARCH.duckDuckGoWebSearch(query, limit)
  local out = {}
  local url = "https://html.duckduckgo.com/html/?q=" .. urlencode(query)
  local raw = httpGet(url, 160000)
  if not raw then return out end
  local titles, snippets = {}, {}
  for attrs, body in raw:gmatch("<a([^>]*)>(.-)</a>") do
    local low = T.lower(attrs)
    local href = attrs:match('href%s*=%s*"([^"]+)"') or attrs:match("href%s*=%s*'([^']+)'")
    if href and low:find("result__a", 1, true) then
      local link = href:gsub("&amp;", "&")
      local target = link:match("[?&]uddg=([^&]+)")
      if target then link = SEARCH.urlDecode(target)
      elseif link:sub(1, 2) == "//" then link = "https:" .. link end
      local title = SEARCH.cleanHtml(body)
      local host = SEARCH.hostOf(link)
      if title ~= "" and host ~= "" and not T.lower(host):find("duckduckgo.com", 1, true) then
        titles[#titles + 1] = {title = title, url = link, host = host}
      end
    elseif low:find("result__snippet", 1, true) then
      local snippet = SEARCH.cleanHtml(body)
      if snippet ~= "" then snippets[#snippets + 1] = snippet end
    end
  end
  for i = 1, math.min(#titles, #snippets, limit or 3) do
    if T.ulen(snippets[i]) >= 24 then
      local item = titles[i]
      out[#out + 1] = {title = item.title, text = snippets[i], url = item.url,
        site = "https://" .. item.host, provider = "DuckDuckGo Web Index",
        publisher = SEARCH.publisherFor(item.url, item.host), kind = "web_snippet"}
    end
  end
  return out
end

function SEARCH.openLibrarySearch(query, intent)
  local out = {}
  local bookQuery = SEARCH.queryHasAny(query, "книга|книг|роман|повест|автор|писател|литератур|поэм|произвед")
  if not bookQuery then return out end
  local url = "https://openlibrary.org/search.json?q=" .. urlencode(query)
    .. "&limit=3&fields=title,first_publish_year,key"
  local raw = httpGet(url, 100000)
  if not raw then return out end
  local records = SEARCH.jsonArrayObjects(raw, "docs", 6)
  for _, record in ipairs(records) do
    if #out >= 3 then break end
    local title = jsonStringAfter(record, "title") or ""
    local year = tonumber(record:match('"first_publish_year"%s*:%s*(%-?%d+)'))
    local key = jsonStringAfter(record, "key") or ""
    if title ~= "" and year and key ~= "" then
      local text = format("Книга «%s» впервые опубликована в %d году.", title, year)
      key = key:gsub("^/", "")
      out[#out + 1] = {title = title, text = text,
        url = "https://openlibrary.org/" .. key, site = "https://openlibrary.org",
        provider = "Open Library", publisher = "Open Library", kind = "book"}
    end
  end
  return out
end

function SEARCH.githubSearch(query, limit)
  local out = {}
  if not SEARCH.queryHasAny(query, "lua|opencomputers|openos|minecraft|github|код|программ|скрипт|api|библиотек|репозитор|приложен|python|javascript|java|мод") then return out end
  local url = "https://api.github.com/search/repositories?q=" .. urlencode(query)
    .. "&per_page=" .. math.min(limit or 3, 3) .. "&sort=stars&order=desc"
  local headers = {Accept = "application/vnd.github+json", ["User-Agent"] = "NanoAI-OpenComputers/3.2"}
  local raw = httpGet(url, 120000, headers)
  if not raw then return out end
  local records = SEARCH.jsonArrayObjects(raw, "items", math.min(limit or 3, 3))
  for _, record in ipairs(records) do
    local title = jsonStringAfter(record, "full_name") or ""
    local description = jsonStringAfter(record, "description") or ""
    local language = jsonStringAfter(record, "language") or ""
    local target = jsonStringAfter(record, "html_url") or ""
    local text = description
    if language ~= "" then text = (text ~= "" and (text .. ". ") or "") .. "Язык проекта: " .. language end
    if title ~= "" and T.ulen(text) >= 16 and target ~= "" then
      out[#out + 1] = {title = title, text = text, url = target,
        site = "https://github.com", provider = "GitHub repositories",
        publisher = "GitHub", kind = "repository"}
    end
  end
  return out
end

function SEARCH.stackExchangeSearch(query, limit)
  local out = {}
  if not SEARCH.queryHasAny(query, "lua|opencomputers|openos|код|программ|скрипт|api|ошибк|функц|компьютер|linux|ubuntu|bash|python|javascript|java|сервер|сеть|network|nginx") then return out end
  local sites = {"stackoverflow"}
  if SEARCH.queryHasAny(query, "linux|ubuntu|apt|bash|shell|терминал|командн|операционн") then
    sites[#sites + 1] = "askubuntu"
    sites[#sites + 1] = "superuser"
  end
  if SEARCH.queryHasAny(query, "сервер|server|network|сеть|nginx|apache|dns") then
    sites[#sites + 1] = "serverfault"
  end
  local headers = {Accept = "application/json", ["User-Agent"] = "NanoAI-OpenComputers/3.2"}
  for _, site in ipairs(sites) do
    local url = "https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&site="
      .. site .. "&pagesize=" .. math.min(limit or 3, 3) .. "&filter=withbody&q=" .. urlencode(query)
    local raw = httpGet(url, 150000, headers)
    if raw then
      local questions = SEARCH.jsonArrayObjects(raw, "items", math.min(limit or 3, 3))
      local accepted, acceptedIds = {}, {}
      for _, question in ipairs(questions) do
        local answerId = SEARCH.jsonNumberAfter(question, "accepted_answer_id")
        local title = jsonStringAfter(question, "title") or ""
        local link = jsonStringAfter(question, "link") or ""
        if answerId and title ~= "" and link ~= "" then
          local key = tostring(answerId)
          accepted[key] = {title = title, url = link}
          acceptedIds[#acceptedIds + 1] = key
        end
      end
      if #acceptedIds > 0 then
        local answersUrl = "https://api.stackexchange.com/2.3/answers/" .. concat(acceptedIds, ";")
          .. "?order=desc&sort=votes&site=" .. site .. "&filter=withbody"
        local answerRaw = httpGet(answersUrl, 180000, headers)
        if answerRaw then
          for _, answer in ipairs(SEARCH.jsonArrayObjects(answerRaw, "items", #acceptedIds)) do
            local answerId = SEARCH.jsonNumberAfter(answer, "answer_id")
            local row = answerId and accepted[tostring(answerId)] or nil
            local body = SEARCH.cleanHtml(jsonStringAfter(answer, "body") or "")
            if row and T.ulen(body) >= 40 then
              out[#out + 1] = {title = row.title, text = body,
                url = row.url .. "#answer-" .. tostring(answerId),
                site = "https://" .. site .. ".com", provider = "Stack Exchange accepted answers",
                publisher = "Stack Exchange", kind = "community_answer"}
            end
          end
        end
      end
    end
  end
  return out
end

function SEARCH.openAlexSearch(query, intent, limit)
  local out = {}
  if not SEARCH.queryHasAny(query, "науч|исслед|стат|публикац|doi|журнал|диссертац|академ|ген|клинич|биолог|физик|хим|математ|science|research|paper|academic") then return out end
  local url = "https://api.openalex.org/works?search=" .. urlencode(query)
    .. "&per-page=" .. math.min(limit or 3, 3) .. "&select=id,doi,title,publication_year"
  local raw = httpGet(url, 150000)
  if not raw then return out end
  for _, record in ipairs(SEARCH.jsonArrayObjects(raw, "results", math.min(limit or 3, 3))) do
    local title = jsonStringAfter(record, "title") or ""
    local doi = jsonStringAfter(record, "doi") or ""
    local id = jsonStringAfter(record, "id") or ""
    local year = SEARCH.jsonNumberAfter(record, "publication_year")
    local text = title
    if year and intent and intent.kind == "release_year" then
      text = format("Статья «%s» опубликована в %d году.", title, year)
    elseif year then
      text = text .. format(" (год публикации: %d).", year)
    end
    local target = (doi ~= "" and doi) or id
    if title ~= "" and target ~= "" then
      out[#out + 1] = {title = title, text = text, url = target,
        site = "https://openalex.org", provider = "OpenAlex research index",
        publisher = "OpenAlex", kind = "academic"}
    end
  end
  return out
end

function SEARCH.europePmcSearch(query, intent, limit)
  local out = {}
  if not SEARCH.queryHasAny(query, "медицин|болез|заболев|вирус|бактери|ген|клетк|лекарств|вакцин|здоров|пациент|клиническ|онкол|протеин|фарма|биолог|medical|medicine|cancer|virus|vaccine|gene|clinical") then return out end
  local url = "https://www.ebi.ac.uk/europepmc/webservices/rest/search?query="
    .. urlencode(query) .. "&format=json&pageSize=" .. math.min(limit or 3, 3) .. "&resultType=core"
  local raw = httpGet(url, 180000)
  if not raw then return out end
  for _, record in ipairs(SEARCH.jsonArrayObjects(raw, "result", math.min(limit or 3, 3))) do
    local title = jsonStringAfter(record, "title") or ""
    local abstract = SEARCH.cleanHtml(jsonStringAfter(record, "abstractText") or "")
    local pmid = jsonStringAfter(record, "pmid") or ""
    if pmid == "" then
      local pmidNumber = SEARCH.jsonNumberAfter(record, "pmid")
      if pmidNumber then pmid = tostring(pmidNumber) end
    end
    local pmcid = jsonStringAfter(record, "pmcid") or ""
    local yearText = jsonStringAfter(record, "firstPublicationDate") or ""
    local year = yearText:match("^(%d%d%d%d)")
    local text = title
    if abstract ~= "" then text = text .. ". " .. abstract end
    if year and intent and intent.kind == "release_year" then
      text = format("Статья «%s» опубликована в %s году. %s", title, year, abstract)
    end
    local target = pmid ~= "" and ("https://europepmc.org/article/MED/" .. pmid)
      or (pmcid ~= "" and ("https://europepmc.org/article/PMC/" .. pmcid)
      or "https://europepmc.org/search?query=" .. urlencode(query))
    if title ~= "" and T.ulen(text) >= 40 then
      out[#out + 1] = {title = title, text = text, url = target,
        site = "https://europepmc.org", provider = "Europe PMC biomedical literature",
        publisher = "Europe PMC", kind = "academic"}
    end
  end
  return out
end

function SEARCH.wikiSearch(query, limit)
  if not hasInternet then return nil end
  query = query:gsub("^%s+", ""):gsub("%s+$", "")
  if query == "" then return nil end
  local results, intent = {}, T.classifyIntent(query)
  local function addAll(items)
    for _, item in ipairs(items or {}) do results[#results + 1] = item end
  end
  local pageLimit = math.min(limit or 3, 3)
  for _, source in ipairs(SEARCH.wikis) do
    if not source.trigger or SEARCH.queryHasAny(query, source.trigger) then
      local sourceQuery = query
      if source.site == "https://en.wikipedia.org"
         and T.lower(query):find("майнкрафт", 1, true) then sourceQuery = "Minecraft" end
      local url = source.site .. "/w/api.php?action=query&format=json&generator=search&gsrsearch="
        .. urlencode(sourceQuery) .. "&gsrlimit=" .. pageLimit .. "&prop=extracts|info&exintro=1&explaintext=1&exchars=3000&redirects=1&inprop=url"
      local raw = httpGet(url, 120000)
      if raw then
        local titles, extracts = jsonValues(raw, "title"), jsonValues(raw, "extract")
        local urls = jsonValues(raw, "fullurl")
        for i = 1, math.min(#titles, #extracts, pageLimit) do
          results[#results + 1] = {
            title = titles[i], text = extracts[i],
            url = urls[i] or (source.site .. "/wiki/" .. urlencode(titles[i])),
            site = source.site, provider = source.provider, publisher = source.publisher,
            kind = "mediawiki",
          }
        end
      end
    end
  end
  addAll(SEARCH.wikidataSearch(query, intent, pageLimit))
  addAll(SEARCH.duckDuckGoSearch(query, pageLimit))
  addAll(SEARCH.duckDuckGoWebSearch(query, pageLimit))
  addAll(SEARCH.openLibrarySearch(query, intent))
  addAll(SEARCH.stackExchangeSearch(query, pageLimit))
  addAll(SEARCH.githubSearch(query, pageLimit))
  addAll(SEARCH.openAlexSearch(query, intent, pageLimit))
  addAll(SEARCH.europePmcSearch(query, intent, pageLimit))
  if #results == 0 then return nil end
  local q = makeQuery(query)
  local seen = {}
  local unique = {}
  for _, r in ipairs(results) do
    local relevanceText = (r.title or "") .. " " .. (r.text or "")
    if T.lower(relevanceText):find("minecraft", 1, true)
       and T.lower(query):find("майнкрафт", 1, true) then
      relevanceText = relevanceText .. " майнкрафт"
    end
    r.cover = coverageOf(relevanceText, q)
    r.titleHit = (T.lower(r.title or "") == T.lower(query)) and 1 or 0
    r.autoPriority = (r.kind == "mediawiki" and r.site == SEARCH.autotrainSite) and 1 or 0
    local key = r.url or (tostring(r.site) .. ":" .. tostring(r.title))
    if not seen[key] then seen[key] = true; unique[#unique + 1] = r end
  end
  table.sort(unique, function(a, b)
    if a.cover ~= b.cover then return a.cover > b.cover end
    if a.titleHit ~= b.titleHit then return a.titleHit > b.titleHit end
    if a.autoPriority ~= b.autoPriority then return a.autoPriority > b.autoPriority end
    return (a.provider or "") < (b.provider or "")
  end)
  return unique
end

-- Подтверждаем не только совпадение запроса: ищем тот же факт в результате
-- с другого домена. Для типизированного вопроса совпасть должен и год;
-- для общего — минимум два содержательных понятия карточек.
local function corroborateWiki(top, results, query, intent, answer, facts, typedSupport)
  local sources = {top.url}
  if not top or (top.cover or 0) < CFG.wikiTrust then return false, sources end
  local primaryYears = {}
  for year in (answer or ""):gmatch("%d%d%d%d") do primaryYears[year] = true end
  local primaryTerms = {}
  for _, card in ipairs(facts or {}) do
    for _, word in ipairs(card.keywords or {}) do
      local st = T.norm(word)
      if not STOP[st] then primaryTerms[st] = true end
    end
  end
  for _, other in ipairs(results) do
    local samePublisher = top.publisher and other.publisher and top.publisher == other.publisher
    if other ~= top and other.site and top.site and other.site ~= top.site
       and not samePublisher and other.cover >= CFG.wikiTrust then
      local otherAnswer, otherFacts, otherSupport = T.distillArticle(other.title, other.text, query)
      local confirmed = false
      if intent then
        if typedSupport and otherSupport then
          for year in (otherAnswer or ""):gmatch("%d%d%d%d") do
            if primaryYears[year] then confirmed = true; break end
          end
        end
      else
        local hits, otherTerms = 0, {}
        for _, card in ipairs(otherFacts or {}) do
          for _, word in ipairs(card.keywords or {}) do
            local st = T.norm(word)
            if not STOP[st] then otherTerms[st] = true end
          end
        end
        for st in pairs(primaryTerms) do if otherTerms[st] then hits = hits + 1 end end
        confirmed = hits >= 2
      end
      if confirmed then
        sources[#sources + 1] = other.url
        return true, sources
      end
    end
  end
  return false, sources
end

-- сохранить найденное в Википедии
local function saveWiki(query, answer, title, verified, sources, autotrain, facts)
  local lq = T.lower(query)
  for i = #dbWiki, 1, -1 do
    if T.lower(dbWiki[i].q) == lq then table.remove(dbWiki, i) end
  end
  dbWiki[#dbWiki + 1] = {q = query, a = answer, title = title,
                          ok = verified and true or false, dv = 2, sources = sources or {},
                          autotrain = autotrain == true, facts = facts or {}}
  if #dbWiki > MAX_WIKI then table.remove(dbWiki, 1) end
  T.touch()
end

-- полный путь: собрать внешние источники, извлечь факт и сохранить ответ
local function wikiAnswerFor(query, verbose)
  if not hasInternet then return nil end
  local res = SEARCH.wikiSearch(query, 3)
  if not res or #res == 0 then return nil end
  local top = res[1]
  local intent = T.classifyIntent(query)
  -- Сначала извлекаем компактное представление, затем проверяем факт на
  -- независимом домене. Совпадение слов само по себе не считается проверкой.
  local distilled, factCards, typedSupport = T.distillArticle(top.title, top.text, query)
  local verified, sources = corroborateWiki(top, res, query, intent, distilled, factCards, typedSupport)
  local learned = false
  if top.kind == "mediawiki" and top.site == SEARCH.autotrainSite and T.learnArticle then
    learned = T.learnArticle(top.title, top.text, top.url, verified)
  end
  if intent and not typedSupport then
    buildIndex()
    pcall(selfLearn)
    pcall(T.saveAll)
    return {rejected = true, title = top.title, cover = top.cover, learned = learned}
  end
  if not distilled or distilled == "" then
    distilled = T.distillArticle(top.title, top.text, top.title)
  end
  saveWiki(query, distilled, top.title, verified, sources,
    top.kind == "mediawiki" and top.site == SEARCH.autotrainSite, factCards)
  buildIndex()
  pcall(selfLearn)
  pcall(T.saveAll)
  local ei
  for i = 1, #ans do
    if srcOf[i] == "wiki" and entryQ[i] and T.lower(entryQ[i]) == T.lower(query) then
      ei = i
    end
  end
  return {saved = distilled, title = top.title, verified = verified,
          cover = top.cover, sources = sources, url = top.url, ei = ei,
          learned = learned, facts = factCards}
end

-- обучение по произвольной ссылке
local function learnFromInternet(url)
  if not hasInternet then
    return "Интернет-карты не вижу. Вставь её в компьютер, и команда /train заработает."
  end
  io.write("  скачиваю " .. url .. " ... ")
  local text, sizeOrErr = httpGet(url, 131072)
  print("")
  if not text then
    return "Не удалось скачать: " .. tostring(sizeOrErr)
  end
  local size = sizeOrErr
  local pairsN, sentN, lines = 0, 0, 0
  for line in text:gmatch("[^\r\n]+") do
    line = line:gsub("^%s+", ""):gsub("%s+$", "")
    lines = lines + 1
    local q, a = line:match("^(.-)%s*=>%s*(.+)$")
    if q and q ~= "" and a and a ~= "" and #q < 200 and #a < 400 then
      if pairsN < MAX_NET then
        local lq = T.lower(q)
        for i = #dbLearned, 1, -1 do
          if T.lower(dbLearned[i].q) == lq then table.remove(dbLearned, i) end
        end
        dbLearned[#dbLearned + 1] = {q = q, a = a}
        pairsN = pairsN + 1
      end
    elseif #line > 20 and #line < 300 and not line:find("^[%-=#%*]") then
      trainMarkov(line)
      lexAdd(line)
      sentN = sentN + 1
    end
    if lines % 25 == 0 then os.sleep(0) end
  end
  text = nil
  gc()
  if pairsN > 0 then buildAll() end
  pcall(T.saveAll)
  return format(
    "Обучение завершено: скачано %d КБ.\nВыучено пар вопрос-ответ: %d (сохранено в learned.db). Предложений для генератора фраз: %d.%s",
    floor(size / 1024 + 0.5), pairsN, sentN,
    lite and "\nВнимание: мало памяти, генератор фраз ограничен." or "")
end

-- КОМАНДА /wiki: найти и запомнить
local function wikiLookupCommand(query)
  if not hasInternet then
    return "Интернет-карты не вижу. Вставь её в компьютер, и команда /wiki заработает."
  end
  io.write("  ищу и разбираю материал... ")
  local res = SEARCH.wikiSearch(query, 4)
  print("")
  if not res or #res == 0 then return "Ничего не нашёл по этому запросу." end
  local lines = {}
  for i, r in ipairs(res) do
    local mark = (r.cover >= CFG.wikiTrust) and "[совпадение сильное]" or "[нужно проверить]"
    local domain = (r.site or ""):gsub("^https?://", "")
    lines[#lines + 1] = format("%d. %s %s [%s] (%d%%)", i, mark, r.title, domain, floor(r.cover * 100))
  end
  local top = res[1]
  local intent = T.classifyIntent(query)
  local distilled, facts, typedSupport = T.distillArticle(top.title, top.text, query)
  local verified, sources = corroborateWiki(top, res, query, intent, distilled, facts, typedSupport)
  local wikiAutotrain = top.kind == "mediawiki" and top.site == SEARCH.autotrainSite
  local learned = false
  if wikiAutotrain and T.learnArticle then
    learned = T.learnArticle(top.title, top.text, top.url, verified)
  end
  if intent and not typedSupport then
    buildIndex()
    pcall(selfLearn)
    pcall(T.saveAll)
    local storage = wikiAutotrain and "Материал русской Википедии добавлен в её корпус автообучения."
      or "Внешний материал не добавлялся в корпус автообучения."
    return format("Материал «%s» разобрал, но не нашёл в нём подтверждённого факта нужного типа. Поэтому не буду угадывать. %s\n%s",
      top.title, storage, concat(lines, "\n"))
  end
  if not distilled or distilled == "" then distilled = T.distillArticle(top.title, top.text, top.title) end
  saveWiki(query, distilled, top.title, verified, sources, wikiAutotrain, facts)
  lastWikiQ = query
  buildIndex()
  pcall(selfLearn)
  pcall(T.saveAll)
  local status = verified and "факт подтверждён совпадением на независимом домене" or "пока есть только один источник; сохранено как непроверенное"
  local sourceLine = "Источник: " .. (top.url or top.site or "неизвестен")
  if verified and #sources > 1 then sourceLine = "Сверенные источники: " .. concat(sources, "; ") end
  local storage = wikiAutotrain and "Статья русской Википедии добавлена в корпус автообучения и сохранена как выжимка."
    or "Выжимка сохранена в wiki.db; внешний материал не добавлялся в корпус автообучения."
  return format("Разобрал материал «%s» (%s).\nКороткая выжимка, не копия статьи: %s\n%s\n%s\n%s",
    top.title, status, distilled, storage, sourceLine, concat(lines, "\n"))
end

---------------------------------------------------------------------------
-- ИНТЕРФЕЙС
---------------------------------------------------------------------------
local gpu = nil
local scrW, scrH = 80, 25
if component.isAvailable("gpu") then
  gpu = component.gpu
  local okR, w, h = pcall(gpu.getResolution)
  if okR and w then scrW, scrH = w, h end
end

local C = {
  border = 0x00B0C8,   -- рамка
  title  = 0x55FFFF,   -- заголовок
  dim    = 0x707070,   -- служебный текст
  user   = 0xFFD75F,   -- твои реплики
  ai     = 0x5FFF87,   -- мои реплики
  text   = 0xDDDDDD,   -- текст ответа
  warn   = 0xFF8A5F,   -- предупреждения
}

local function color(c) if gpu then pcall(gpu.setForeground, c) end end

-- перенос по словам под ширину экрана
local function wrap(text, width)
  if width < 8 then width = 8 end
  local lines, cur, curLen = {}, {}, 0
  for word in text:gmatch("%S+") do
    local wl = T.ulen(word)
    if curLen > 0 and curLen + 1 + wl > width then
      lines[#lines + 1] = concat(cur, " ")
      cur, curLen = {word}, wl
    else
      cur[#cur + 1] = word
      curLen = curLen + (curLen > 0 and 1 or 0) + wl
    end
  end
  if #cur > 0 then lines[#lines + 1] = concat(cur, " ") end
  return lines
end

-- вывести реплику с цветным префиксом и выравниванием продолжений
local function say(prefix, pcol, text, tcol)
  local indent = string.rep(" ", T.ulen(prefix))
  local first = true
  for seg in (text .. "\n"):gmatch("(.-)\n") do
    if not seg:find("%S") then
      if not first then print("") end
    else
      for _, ln in ipairs(wrap(seg, scrW - T.ulen(prefix) - 1)) do
        color(pcol); io.write(first and prefix or indent)
        color(tcol or C.text); print(ln)
        first = false
      end
    end
  end
  color(0xFFFFFF)
end

-- служебная строка "хода размышления"
local function thinkNote(msg)
  color(C.dim)
  print(" · " .. msg)
  color(0xFFFFFF)
end

---------------------------------------------------------------------------
-- АВТООБУЧЕНИЕ: обход русской Википедии
---------------------------------------------------------------------------
local function cutBytes(s, n)
  if #s <= n then return s end
  local t = s:sub(1, n)
  while #t > 0 do
    local b = t:byte(#t)
    if b < 128 or b >= 192 then break end
    t = t:sub(1, -2)
  end
  return t
end

-- выучить одну статью: корпус, база знаний, язык, лексикон
local function learnPage(title, text, url, verified)
  if not title or not text or type(url) ~= "string"
     or not url:match("^https?://ru%.wikipedia%.org/") then return false end
  title = title:gsub("^%s+", ""):gsub("%s+$", "")
  if title == "" or T.ulen(text) < 40 then return false end
  local clean = text:gsub("==%s*([^=]+)%s*==", " %1. ")
  clean = clean:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
  local summary, facts = T.distillArticle(title, clean, title)
  if not summary or summary == "" then summary = "Материал о «" .. title .. "» сохранён для дальнейшего изучения." end
  local excerpt = (computer.freeMemory() > 300000) and cutBytes(clean, 1200) or ""
  local existing
  for _, e in ipairs(dbCorpus) do
    if T.lower(e.t) == T.lower(title) then existing = e; break end
  end
  if existing then
    existing.s, existing.a, existing.u = excerpt, summary, url or existing.u or ""
    existing.facts, existing.dv, existing.autotrain = facts or {}, 2, true
    if verified then existing.ok = true elseif existing.ok == nil then existing.ok = false end
  else
    if #dbCorpus >= MAX_CORPUS then table.remove(dbCorpus, 1) end
    dbCorpus[#dbCorpus + 1] = {t = title, s = excerpt, a = summary, u = url or "",
                               facts = facts or {}, dv = 2, ok = verified and true or false, autotrain = true}
  end
  trainMarkov(summary)
  lexAdd(clean)
  lexAdd(title)
  lexAdd(summary)
  return not existing
end
T.learnArticle = learnPage

-- взять ссылки со страниц, чтобы обход шёл вглубь
local function crawlLinks(titles)
  if #titles == 0 then return 0 end
  local url = "https://ru.wikipedia.org/w/api.php?action=query&format=json&prop=links&titles="
    .. urlencode(concat(titles, "|")) .. "&plnamespace=0&pllimit=50"
  local text = httpGet(url, 100000)
  if not text then return 0 end
  local ls = jsonValues(text, "title")
  local added = 0
  for _, t in ipairs(ls) do
    if t ~= "" and not dbCrawl.seen[t] then
      dbCrawl.seen[t] = true
      dbCrawl.queue[#dbCrawl.queue + 1] = t
      added = added + 1
    end
  end
  return added
end

-- один раунд обучения: сперва очередь, потом случайные статьи
local function crawlRound(want, note)
  if not hasInternet then return 0, "нет интернет-карты" end
  local added, learned = 0, {}
  -- 1. разбираем очередь
  local batch = {}
  for i = 1, math.min(#dbCrawl.queue, want) do batch[#batch + 1] = dbCrawl.queue[i] end
  if #batch > 0 then
    local url = "https://ru.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info"
      .. "&explaintext=1&exchars=2500&inprop=url&redirects=1&titles=" .. urlencode(concat(batch, "|"))
    local text = httpGet(url, 200000)
    if text then
      local tt = jsonValues(text, "title")
      local ex = jsonValues(text, "extract")
      local uu = jsonValues(text, "fullurl")
      for i = 1, math.min(#tt, #ex) do
        if learnPage(tt[i], ex[i], uu[i] or ("https://ru.wikipedia.org/wiki/" .. urlencode(tt[i]))) then
          added = added + 1
          learned[#learned + 1] = tt[i]
        end
      end
    end
    -- убираем обработанные из очереди
    local left = {}
    for i = #dbCrawl.queue, 1, -1 do
      local t = dbCrawl.queue[i]
      local found = false
      for _, b in ipairs(batch) do if b == t then found = true end end
      if not found then left[#left + 1] = t end
    end
    local tmp = {}
    for i = #left, 1, -1 do tmp[#tmp + 1] = left[i] end
    dbCrawl.queue = tmp
  end
  -- 2. добираем случайными статьями
  if added < want then
    local url = "https://ru.wikipedia.org/w/api.php?action=query&format=json&generator=random"
      .. "&grnnamespace=0&grnlimit=" .. (want - added) .. "&prop=extracts|info&explaintext=1&exchars=2500&inprop=url&redirects=1"
    local text = httpGet(url, 220000)
    if text then
      local tt = jsonValues(text, "title")
      local ex = jsonValues(text, "extract")
      local uu = jsonValues(text, "fullurl")
      for i = 1, math.min(#tt, #ex) do
        if learnPage(tt[i], ex[i], uu[i] or ("https://ru.wikipedia.org/wiki/" .. urlencode(tt[i]))) then
          added = added + 1
          learned[#learned + 1] = tt[i]
        end
      end
    end
  end
  -- 3. собираем ссылки на будущее
  if #learned > 0 then
    local sub = {}
    for i = 1, math.min(#learned, 5) do sub[#sub + 1] = learned[i] end
    crawlLinks(sub)
  end
  if added > 0 then
    buildIndex()
    pcall(selfLearn)
    pcall(T.saveAll)
  end
  return added, learned
end

---------------------------------------------------------------------------
-- КОМАНДЫ (только на английском)
---------------------------------------------------------------------------
local HELP = [[
Просто пиши мне на русском: спрашивай про Майнкрафт, OpenComputers, Lua, науку,
считай примеры (12*(3+4)). Я отвечаю по-русски, а команды - на английском.

Я не тороплюсь: переписываю вопрос, ищу кандидатов, спрашиваю своё ансамбль
нейросетей и проверяю, подходит ли ответ. Глубину размышления меняет /think.
Если я ничего не знаю, но есть интернет-карта, я ищу в широком наборе источников:
Wikimedia, поисковая выдача DDG, Stack Exchange, GitHub, OpenAlex, Europe PMC
и каталоги книг. Для отдельных источников использую открытые API только по теме.
Извлекаю выжимки и карточки фактов, сверяю разные издательские группы и показываю URL.
Фоновый обход и автообучаемый корпус ограничены русской Википедией; внешняя выжимка
может сохраниться в wiki.db для ответов, но не попадает в corpus.db или crawl.db.

Команды (после /):
  /help               список команд
  /exit               сохранить память и выйти (или просто напиши "пока")
  /save               сохранить всё прямо сейчас
  /stats              размеры баз, нейросети и памяти
  /notes              что я помню о тебе
  /neuro              состояние всех нейросетей
  /net [light|core|wide|deep|all]   переключить пресет нейросетей
  /think 0..5         глубина размышления
  /train <url>        скачать текст по ссылке и выучить его
  /wiki <запрос>      поискать по профильным источникам, сверить и сохранить выжимку
  /autotrain on|off|N  автообучение: обход только русской Википедии
  /right              похвалить последний ответ (учит нейросети)
  /wrong              сказать, что ответ неверный, и дать правильный
  /verify             подтвердить ответ из Википедии
  /lex <слово>        что я знаю о слове
  /forget <текст>     забыть всё выученное с этим текстом
  /reset              стереть вообще всё, чему я научился

Научить меня вручную:  teach <вопрос> => <ответ>
Сказать "меня зовут ..." или "запомни что ..." - я запомню.
]]

local VALID_CMD = {help = 1, exit = 1, quit = 1, save = 1, stats = 1, notes = 1,
  wrong = 1, right = 1, forget = 1, reset = 1, train = 1, neuro = 1, wiki = 1,
  autotrain = 1, think = 1, net = 1, verify = 1, lex = 1}

local function listNotes()
  local out = {}
  if dbNotes.name then out[#out + 1] = "Твоё имя: " .. dbNotes.name end
  for i, n in ipairs(dbNotes.list) do out[#out + 1] = i .. ". " .. n end
  if #out == 0 then return "Заметок пока нет. Напиши: запомни что <что-нибудь>" end
  return concat(out, "\n")
end

local function ask(prompt)
  io.write(prompt)
  local l = io.read()
  if l then l = l:gsub("^%s+", ""):gsub("%s+$", "") end
  return l
end

local function doTeach(q, a)
  q = q:gsub("^%s+", ""):gsub("%s+$", "")
  a = a:gsub("^%s+", ""):gsub("%s+$", "")
  if q == "" or a == "" then return "Формат: teach <вопрос> => <ответ>" end
  local lq, replaced = T.lower(q), false
  for i = #dbLearned, 1, -1 do
    if T.lower(dbLearned[i].q) == lq then table.remove(dbLearned, i); replaced = true end
  end
  dbLearned[#dbLearned + 1] = {q = q, a = a}
  lexAdd(q); lexAdd(a)
  trainMarkov(a)
  if replaced then
    buildAll()                      -- старый ответ нужно убрать из индекса
  else
    T.addEntry(q, a, "user", false, nil)
    T.finalize()
    pcall(selfLearn)
  end
  pcall(T.saveAll)
  return "Запомнил в learned.db! Спроси меня ещё раз, и я отвечу."
end

local function netsReport()
  local out = {}
  local totalNeurons, totalWeights, totalSteps = 0, 0, 0
  for _, net in ipairs(NETS) do
    local n, w = T.netNeurons(net), T.netWeights(net)
    totalNeurons = totalNeurons + n
    totalWeights = totalWeights + w
    totalSteps = totalSteps + net.trained
    local sizes = {}
    for i, s in ipairs(net.sizes) do sizes[i] = tostring(s) end
    out[#out + 1] = format("  %-6s %-28s нейронов: %5d, весов: %6d, обучений: %5d",
      net.name, concat(sizes, "-"), n, w, net.trained)
  end
  out[#out + 1] = format("  ВСЕГО: нейронов %d, весов %d, шагов обучения %d",
    totalNeurons, totalWeights, totalSteps)
  return concat(out, "\n")
end

local function pickPreset()
  if CFG.netset ~= "auto" then return CFG.netset end
  local free = computer.freeMemory()
  if free > 1800000 then return "deep" end
  if free > 180000 then return "wide" end
  if free > 60000 then return "core" end
  return "light"
end

local function command(raw)
  local cmd, arg = raw:match("^/(%a+)%s*(.*)$")
  cmd = T.lower(cmd or "")
  if not VALID_CMD[cmd] then
    return "Неизвестная команда. Команды только на английском, напиши /help."
  end
  if cmd == "help" then
    return HELP
  elseif cmd == "exit" or cmd == "quit" then
    return "Сохраняю память. До свидания!", true
  elseif cmd == "save" then
    return T.saveAll() and ("Память сохранена в папке " .. DB_DIR) or "Не удалось записать на диск."
  elseif cmd == "stats" then
    return format(
      "Ответов во встроенной базе + выученных: %d\n  вручную/по ссылке (learned.db): %d, из Википедии (wiki.db): %d, из обхода (corpus.db): %d\nПоисковых фраз в индексе: %d\nСлов в лексиконе: %d | запомненных фраз: %d | заметок: %d\nГлубина размышления: %d | пресет нейросетей: %s\n%s\nПапка базы знаний: %s\nИнтернет-карта: %s | Свободно ОЗУ: %d из %d КБ%s",
      #ans, #dbLearned, #dbWiki, #dbCorpus, nDocs, lexTotal, #dbSentences, #dbNotes.list,
      CFG.think, CUR_PRESET,
      netsReport(),
      DB_DIR,
      hasInternet and "есть (/train, /wiki, /autotrain)" or "нет",
      floor(computer.freeMemory() / 1024), floor(computer.totalMemory() / 1024),
      lite and "\nОБЛЕГЧЁННЫЙ РЕЖИМ: мало памяти, генератор фраз ограничен." or "")
  elseif cmd == "neuro" then
    return format("Мой мозг - ансамбль из семи настоящих многослойных перцептронов.\n%s\nАктивации: tanh в скрытых слоях, сигмоида на выходе.\nКаждая сеть получает 20 признаков: сходство запроса и ответа, источник и проверенность,\nа также совпадение сущности, отношения и требуемого типа факта (например, год или количество).\nСтарые 16-входные веса переносятся при обновлении. /right и /wrong дообучают ансамбль.",
      netsReport())
  elseif cmd == "net" then
    arg = T.lower(arg or "")
    if arg == "" then
      return format("Работает пресет: %s (в конфиге: %s).\nДоступные: light, core, wide, deep, all.\nwide - быстрый ансамбль; deep/all - 7 сетей, около 5700 нейронов и до 6 скрытых слоёв (медленнее, но проверяет глубже).\n%s",
        CUR_PRESET, CFG.netset, netsReport())
    end
    if not NET_PRESETS[arg] then
      return "Не знаю такого пресета. Доступные: light, core, wide, deep, all."
    end
    local saved = T.neuroLoad()
    CFG.netset = arg
    T.buildNets(arg, saved)
    pcall(T.neuroSave)
    return format("Переключил на пресет %s.\n%s", arg, netsReport())
  elseif cmd == "think" then
    local n = tonumber(arg or "")
    if not n or n < 0 or n > 5 then
      return "Формат: /think 0..5  (0 - быстро, 4 - глубокий разбор, 5 - максимальная проверка)"
    end
    CFG.think = floor(n)
    pcall(dbWrite, DB_CFG, CFG)
    return format("Глубина размышления: %d. %s", CFG.think,
      CFG.think == 0 and "Отвечаю быстро, почти без размышлений." or
      CFG.think == 1 and "Переписываю вопрос и ищу кандидатов." or
      CFG.think == 2 and "Оцениваю варианты и проверяю ответ." or
      CFG.think == 3 and "Сравниваю несколько кандидатов ансамблем." or
      CFG.think == 4 and "Глубокий разбор: дополнительные запросы, проверка сущности и типа факта." or
      "Максимальный разбор: больше кандидатов, глубокий ансамбль и повторная проверка ответа.")
  elseif cmd == "train" then
    if not hasInternet then
      return "Интернет-карты не вижу. Вставь её в компьютер, и команда /train заработает."
    end
    arg = (arg or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if arg == "" then
      arg = ask("  адрес (URL) текста для обучения: ") or ""
      arg = arg:gsub("^%s+", ""):gsub("%s+$", "")
    end
    if arg == "" then return "Отменено." end
    if not arg:find("^https?://") then arg = "http://" .. arg end
    return learnFromInternet(arg)
  elseif cmd == "wiki" then
    arg = (arg or ""):gsub("^%s+", ""):gsub("%s+$", "")
    if arg == "" then
      arg = ask("  что искать в Википедии: ") or ""
      arg = arg:gsub("^%s+", ""):gsub("%s+$", "")
    end
    if arg == "" then return "Отменено." end
    return wikiLookupCommand(arg)
  elseif cmd == "autotrain" then
    arg = T.lower(arg or "")
    if arg == "on" then
      CFG.autotrain = "on"
      pcall(dbWrite, DB_CFG, CFG)
      return "Автообучение включено. Теперь я буду между ответами сам вычитывать статьи русской Википедии. Выключить: /autotrain off."
    elseif arg == "off" then
      CFG.autotrain = "off"
      pcall(dbWrite, DB_CFG, CFG)
      return "Автообучение выключено."
    elseif arg == "status" or arg == "" then
      return format("Автообучение: %s. Статей в corpus.db: %d, в очереди обхода: %d, слов в лексиконе: %d.",
        CFG.autotrain, #dbCorpus, #dbCrawl.queue, lexTotal)
    else
      local n = tonumber(arg)
      if not n or n < 1 or n > 50 then return "Формат: /autotrain on | off | status | <число статей 1..50>" end
      if not hasInternet then return "Интернет-карты не вижу, обходить нечего." end
      local added, learned = crawlRound(floor(n), true)
      if added == 0 then return "Ничего нового не выучил (или нет интернета)." end
      local names = {}
      for i = 1, math.min(#learned, 10) do names[#names + 1] = learned[i] end
      return format("Выучено %d статей: %s\nСтатей в corpus.db теперь: %d, слов в лексиконе: %d.",
        added, concat(names, ", "), #dbCorpus, lexTotal)
    end
  elseif cmd == "right" then
    if not lastFeat then return "Сначала задай вопрос, а потом оцени мой ответ." end
    for _, net in ipairs(NETS) do T.netTrain(net, lastFeat, 1) end
    T.touch()
    return "Спасибо! Усилил нейронные связи, которые привели к этому ответу."
  elseif cmd == "verify" then
    if not lastWikiQ then return "Последний веб-ответ не ожидает подтверждения; сначала задай вопрос или используй /wiki." end
    local found, title = false, nil
    for _, e in ipairs(dbWiki) do
      if T.lower(e.q) == T.lower(lastWikiQ) then
        e.ok = true; found = true; title = e.title
      end
    end
    if not found then return "Не нашёл тот ответ в wiki.db." end
    for _, e in ipairs(dbCorpus) do
      if title and T.lower(e.t) == T.lower(title) then e.ok = true end
    end
    buildIndex()
    pcall(selfLearn)
    pcall(T.saveAll)
    return "Подтвердил и запомнил выжимку и карточки источника; теперь эта запись считается проверенной."
  elseif cmd == "lex" then
    arg = T.lower(arg or "")
    if arg == "" then return "Формат: /lex <слово>. Показывает, что я знаю о слове." end
    local w, e = lexAbout(arg)
    if not e then return "Я вообще не встречал слово «" .. arg .. "»." end
    local rel = {}
    for k, c in pairs(e.c) do rel[#rel + 1] = {k, c} end
    table.sort(rel, function(a, b) return a[2] > b[2] end)
    local parts = {}
    for i = 1, math.min(#rel, 6) do parts[#parts + 1] = rel[i][1] end
    return format("Слово «%s»: встречалось %d раз.\nРядом с ним чаще всего: %s\nВсего слов в моём лексиконе: %d.",
      w, e.n, #parts > 0 and concat(parts, ", ") or "пока ничего не замечено", lexTotal)
  elseif cmd == "notes" then
    return listNotes()
  elseif cmd == "wrong" then
    if not lastQ then return "Сначала задай мне вопрос." end
    if lastFeat then
      for _, net in ipairs(NETS) do T.netTrain(net, lastFeat, 0) end
      T.touch()
    end
    local a = ask("  как надо было ответить? ")
    if not a or a == "" then return "Хорошо, я ослабил неверные связи в нейросетях." end
    return doTeach(lastQ, a)
  elseif cmd == "forget" then
    arg = T.lower(arg or "")
    if arg == "" then return "Использование: /forget <текст>" end
    local removed = 0
    for i = #dbLearned, 1, -1 do
      if T.lower(dbLearned[i].q):find(arg, 1, true) then table.remove(dbLearned, i); removed = removed + 1 end
    end
    for i = #dbWiki, 1, -1 do
      if T.lower(dbWiki[i].q):find(arg, 1, true) then table.remove(dbWiki, i); removed = removed + 1 end
    end
    for i = #dbCorpus, 1, -1 do
      if T.lower(dbCorpus[i].t):find(arg, 1, true) then table.remove(dbCorpus, i); removed = removed + 1 end
    end
    for i = #dbNotes.list, 1, -1 do
      if T.lower(dbNotes.list[i]):find(arg, 1, true) then table.remove(dbNotes.list, i); removed = removed + 1 end
    end
    for i = #dbSentences, 1, -1 do
      if T.lower(dbSentences[i]):find(arg, 1, true) then table.remove(dbSentences, i); removed = removed + 1 end
    end
    if removed > 0 then buildAll(); pcall(T.saveAll) end
    return "Забыто записей: " .. removed .. "."
  elseif cmd == "reset" then
    local c = ask("  напиши YES, чтобы стереть всё, чему я научился: ")
    if c and T.lower(c) == "yes" then
      dbLearned, dbWiki, dbSentences, dbCorpus = {}, {}, {}, {}
      dbNotes = {name = nil, list = {}}
      dbCrawl = {queue = {}, seen = {}}
      lex = {}
      lexTotal = 0
      for _, net in ipairs(NETS) do net.trained = 0 end
      T.buildNets(pickPreset(), nil)
      buildAll()
      pcall(T.saveAll)
      return "Память, лексикон и нейросети стёрты. Вернулся к заводским знаниям."
    end
    return "Отменено."
  end
end

---------------------------------------------------------------------------
-- ОТВЕТ ИИ
---------------------------------------------------------------------------
local HEDGE_PRE = {"Кажется, ты об этом: ", "Возможно, это то, что нужно: ", "Не уверен, но: "}
local GEN_PRE = {"Точного ответа у меня нет, но вот что я могу предположить: ", "Размышляю вслух: ",
                 "Собираю по кусочкам из того, что знаю: "}
local RECALL_PRE = {"Ты говорил мне: ", "Я помню, ты как-то сказал: "}
local WIKI_PRE = {"В своей базе знаний этого не было, но я разобрал и независимо сверил статью: ",
  "Сам не знал, но нашёл и сверил факт на другом домене; сохранил выжимку: ",
  "Заглянул в Википедию, проверил, что статья про твой вопрос, и запомнил: "}
local UNKNOWN_Q = {
  "Пока не знаю. Научи меня: teach <вопрос> => <ответ>",
  "Это для меня новое! Можешь научить: teach <вопрос> => <ответ>",
  "В моей памяти пусто. Переформулируй или научи: teach <вопрос> => <ответ>"}
local UNKNOWN_S = {"Интересно. Расскажи ещё.", "Понятно. Продолжай.", "Хм, я слушаю.", "Принял. Что ещё?"}

local function learnSentence(s)
  if #s > 200 then return end
  for _, t in ipairs(dbSentences) do if t == s then return end end
  dbSentences[#dbSentences + 1] = s
  if #dbSentences > MAX_LEARNED then table.remove(dbSentences, 1) end
  T.touch()
end

-- вспомнить, что пользователь говорил раньше на похожую тему
local function recall(content)
  if #content == 0 or #dbSentences == 0 then return nil, 0 end
  local set = {}
  for _, s in ipairs(content) do set[s] = true end
  local best, bestN = nil, 0
  for _, sent in ipairs(dbSentences) do
    local seen, n = {}, 0
    for _, t in ipairs(T.words(sent)) do
      local s = T.norm(t)
      if set[s] and not seen[s] then seen[s] = true; n = n + 1 end
    end
    if n > bestN then bestN, best = n, sent end
  end
  return best, bestN
end

local function tryMath(x)
  local e = x
  e = e:gsub("^%s*сколько%s+будет%s+", ""):gsub("^%s*сколько%s+", ""):gsub("^%s*посчитай%s+", "")
  e = e:gsub("^%s*вычисли%s+", ""):gsub("^%s*реши%s+", ""):gsub("^%s*чему%s+равно%s+", "")
  e = e:gsub("^%s*что%s+такое%s+", ""):gsub("^%s*calc%a*%s+", "")
  e = e:gsub("%s*=%s*%??%s*$", ""):gsub("%s*%?+%s*$", "")
  e = e:gsub("(%d),(%d)", "%1.%2")
  e = e:gsub("%s+плюс%s+", "+"):gsub("%s+минус%s+", "-"):gsub("%s+умножить%s+на%s+", "*")
  e = e:gsub("%s+умножь%s+на%s+", "*"):gsub("%s+помножить%s+на%s+", "*"):gsub("%s+разделить%s+на%s+", "/")
  e = e:gsub("%s+поделить%s+на%s+", "/"):gsub("%s+делить%s+на%s+", "/"):gsub("%s+в%s+степени%s+", "^")
  e = e:gsub("(%d)%s*x%s*(%d)", "%1*%2")
  if e:match("^[%d%s%+%-%*/%%%^%(%)%.]+$") and e:find("%d") and e:find("[%+%-%*/%%%^]") then
    local f = load("return " .. e, "=calc", "t", {})
    if f then
      local ok, r = pcall(f)
      if ok and type(r) == "number" and r == r and r ~= huge and r ~= -huge then
        return (format("%.10g", r):gsub("%.", ","))
      end
    end
    return false
  end
end

local DAYS = {"воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"}
local MONTHS = {"января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября",
  "октября", "ноября", "декабря"}

local EXIT_LAST = {["пока"] = 1, ["прощай"] = 1, ["свидания"] = 1, ["досвидания"] = 1, ["bye"] = 1, ["goodbye"] = 1}
local EXIT_FIRST = {["выход"] = 1, ["выйти"] = 1, ["exit"] = 1, ["quit"] = 1}

local function fill(a)
  a = a:gsub("{,name}", function() return dbNotes.name and (", " .. dbNotes.name) or "" end)
  a = a:gsub("{name}", function() return dbNotes.name or "друг" end)
  return a
end

-- текст после фразы (с сохранением регистра оригинала)
local function after(raw, lraw, pat)
  local s, e = lraw:find(pat)
  if s then return (raw:sub(e + 1):gsub("^%s+", "")) end
end

local function respond(raw)
  raw = raw:gsub("^%s+", ""):gsub("%s+$", "")
  if raw == "" then return nil end

  local l = T.lower(raw):gsub("\209\145", "\208\181")   -- нижний регистр, ё -> е

  -- обучение ключевым словом "teach" (только на английском)
  local tq, ta = raw:match("^/?%S+%s+(.-)%s*=>%s*(.+)$")
  if tq and tq ~= "" and l:find("^/?teach%s") then
    lastWikiQ = nil
    return doTeach(tq, ta)
  end
  if raw:sub(1, 1) == "/" then
    local commandName = T.lower(raw:match("^/%s*([^%s]+)") or "")
    if commandName ~= "verify" then lastWikiQ = nil end
    return command(raw)
  end
  lastWikiQ = nil

  local tl = T.words(raw)
  local j = " " .. concat(tl, " ") .. " "
  local function has(phrase) return j:find(" " .. phrase .. " ", 1, true) ~= nil end

  local last = tl[#tl]
  if #tl > 0 and ((#tl <= 2 and EXIT_FIRST[tl[1]]) or (#tl <= 3 and EXIT_LAST[last])) then
    return "Сохраняю память. До свидания" .. (dbNotes.name and (", " .. dbNotes.name) or "") .. "!", true
  end

  -- личная память
  local nm = after(raw, l, "меня зовут%s+") or after(raw, l, "^зови меня%s+") or after(raw, l, "^называй меня%s+")
    or after(raw, l, "мое имя%s+")
  if nm then
    if T.lower(nm):find("^это%s") then nm = nm:gsub("^%S+%s+", "") end
    nm = nm:gsub("^[%-:]+%s*", "")
    local tok = nm:match("^(%S+)")
    if tok then
      tok = tok:gsub("[%p]+$", "")
      if tok ~= "" and #tok <= 40 then
        dbNotes.name = tok; T.touch()
        return "Приятно познакомиться, " .. tok .. "! Я запомню твоё имя."
      end
    end
  end
  if has("как меня зовут") or has("как меня звать") or has("кто я") or has("какое мое имя")
     or has("знаешь мое имя") or has("помнишь мое имя") or has("помнишь как меня зовут") then
    if dbNotes.name then return "Тебя зовут " .. dbNotes.name .. "." end
    return "Ты ещё не говорил мне. Напиши: меня зовут <имя>"
  end
  local note = after(raw, l, "^запомни%s+что%s+") or after(raw, l, "^запомни:%s*") or after(raw, l, "^запиши:%s*")
    or after(raw, l, "^заметка:%s*") or after(raw, l, "^запомни%s+") or after(raw, l, "^запиши%s+")
  if note and #note > 2 then
    dbNotes.list[#dbNotes.list + 1] = note; T.touch()
    return "Записал в notes.db. Я запомню: " .. note
  end
  if has("что ты помнишь") or has("что ты обо мне знаешь") or has("что ты знаешь обо мне")
     or has("что я тебе говорил") or has("мои заметки") or has("покажи заметки") then
    return listNotes()
  end

  -- подсказка про самообучение из интернета обычной фразой
  if has("начни обучение") or has("начать обучение") or has("учись из интернета")
     or has("обучись из интернета") or has("обучение из интернета") or has("скачай знания")
     or has("обойди википедию") or has("выучи википедию") then
    if hasInternet then
      return "Готов! Напиши /autotrain on - и я сам буду обходить русскую Википедию и учиться. Или /wiki <запрос> - найти ответ прямо сейчас."
    end
    return "Я бы рад, но интернет-карты в компьютере нет. Вставь её - и команды /autotrain, /wiki и /train заработают. Без карты я отвечаю только тем, что уже знаю."
  end

  -- маленькие игры
  if has("подбрось монетку") or has("брось монетку") or has("орел или решка") or has("кинь монетку") then
    return "Подбрасываю... " .. (random(2) == 1 and "Орёл!" or "Решка!")
  end
  if has("кинь кубик") or has("брось кубик") or has("кинь кость") or has("брось кость") then
    return "Кубик показывает " .. random(6) .. "."
  end

  -- математика
  local m = tryMath(l)
  if m then return "= " .. m end
  if m == false then return "Не смог это посчитать." end

  -- часы и календарь
  if has("который час") or has("сколько времени") or has("сколько сейчас времени") or has("сколько время")
     or has("скажи время") then
    local ok, t = pcall(os.date, "%H:%M")
    if ok then return "Сейчас " .. t .. " по часам сервера." end
  end
  if has("какое сегодня число") or has("какая сегодня дата") or has("какой сегодня день") or has("какое число")
     or has("какой день недели") or has("какая дата") or has("сегодняшняя дата") then
    local ok, d = pcall(os.date, "*t")
    if ok and d then
      return "Сегодня " .. d.day .. " " .. MONTHS[d.month] .. " " .. d.year .. " года, " .. DAYS[d.wday] .. " (по часам сервера)."
    end
  end

  -- УЧИМСЯ НА КАЖДОМ СЛОВЕ: всё, что ты пишешь, попадает в лексикон и в языковую модель
  lexAdd(raw)
  if #tl >= 4 then trainMarkov(raw) end
  if #tl >= 4 then learnSentence(raw) end

  -- РАЗМЫШЛЕНИЕ: переформулируем вопрос, ищем кандидатов, спрашиваем нейросети
  local depth = CFG.think
  local t0 = os.clock()
  if depth >= 1 then
    thinkNote(format("думаю, глубина %d ...", depth))
    os.sleep(0.12 * depth)
  end
  local noteFn = nil
  if depth >= 1 then noteFn = thinkNote end
  local best, conf, feat, order = deliberate(raw, depth, noteFn)
  -- Неподтверждённые записи Википедии остаются в памяти как гипотезы,
  -- но не становятся обычными ответами до проверки (/verify).
  if best and srcOf[best.ei] == "wiki" and not verOf[best.ei] then
    best, conf, feat = nil, 0, nil
  elseif best and srcOf[best.ei] == "corpus" and not verOf[best.ei]
     and best.q and best.q.intent then
    best, conf, feat = nil, 0, nil
  end
  local q = makeQuery(raw)
  if #q.content > 0 then
    ctxStems = {}
    for i = 1, math.min(#q.content, 4) do ctxStems[i] = q.content[i] end
  end
  lastQ = raw

  -- составной вопрос: "привет, как дела?"
  if not (best and conf >= WHOLE) then
    local parts, modifier = {}, false
    local joined = (l .. " "):gsub("[%.!%?;,]+%s+", "\n")
    joined = joined:gsub("%s+и%s+", "\n")
    for c in joined:gmatch("[^\n]+") do
      local cc = c:gsub("^%s+", ""):gsub("%s+$", "")
      if cc ~= "" then
        parts[#parts + 1] = cc
        local w1 = T.words(cc)[1]
        if w1 and MODIFIER[w1] then modifier = true end
      end
    end
    if #parts >= 2 and #parts <= 3 and not modifier then
      local outs, seenE = {}, {}
      for _, part in ipairs(parts) do
        local e2, s2 = deliberate(part, math.max(1, depth - 1), nil)
        if not (e2 and s2 >= SPLIT) then outs = nil; break end
        if not seenE[e2.ei] then
          seenE[e2.ei] = true
          local a = ans[e2.ei]
          if type(a) == "table" then a = a[random(#a)] end
          outs[#outs + 1] = fill(a)
        end
      end
      if outs and #outs >= 2 then
        lastFeat = nil
        return concat(outs, " ")
      end
    end
  end

  if best and conf >= HEDGE then
    lastFeat = best.feat
    local ei = best.ei
    local a = ans[ei]
    if type(a) == "table" then a = a[random(#a)] end
    a = fill(a)
    if srcOf[ei] == "wiki" and not verOf[ei] then
      a = "Это я нашёл в веб-источнике, но пока не проверил: " .. a
    elseif srcOf[ei] == "corpus" and not verOf[ei] then
      a = "Это предварительная, не подтверждённая независимым источником выжимка из корпуса: " .. a
    end
    if srcOf[ei] == "wiki" then
      local entryText = T.lower(entryQ[ei] or "")
      for _, item in ipairs(dbWiki) do
        local itemTitle = T.lower(item.title or "")
        if entryText == T.lower(item.q or "")
           or (itemTitle ~= "" and entryText:find(itemTitle, 1, true)) then
          if item.sources and #item.sources > 0 then
            a = a .. "\nИсточник(и): " .. concat(item.sources, "; ")
          end
          break
        end
      end
    end
    if conf < DIRECT then a = T.pick(HEDGE_PRE) .. a end
    if depth >= 2 then thinkNote(format("думал %.1f с, уверенность %d%%", os.clock() - t0, floor(conf * 100))) end
    return a
  end
  lastFeat = nil
  lastWikiQ = nil

  -- вспоминания
  local selfRef = false
  for _, w in ipairs(tl) do if FIRST_PERSON[w] then selfRef = true end end
  local isQ = q.isQ or (#tl <= 5 and not selfRef)

  local rs, rn = recall(q.content)
  if rs and concat(T.words(rs), " ") == concat(tl, " ") then rs = nil end
  if not q.intent and rs and (rn >= 2 or (isQ and rn >= 1)) then
    return T.pick(RECALL_PRE) .. "«" .. rs .. "»"
  end

  -- ПОСЛЕДНИЙ РУБЕЖ: поиск по профильным онлайн-источникам с проверкой.
  -- Не выдаю extract или чужую страницу целиком: сначала проверю релевантность,
  -- выжимку и независимое подтверждение; при сомнении честно воздержусь.
  if isQ and hasInternet and #dbWiki < MAX_WIKI then
    local w = wikiAnswerFor(raw)
    if w and w.rejected then
      if depth >= 2 then thinkNote("в найденном материале нет факта с нужными сущностью, событием и годом") end
      w = nil
    elseif w and q.intent and w.ei and entryDoc[w.ei] then
      local wm = T.intentMetrics(q.intent, entryDoc[w.ei], w.ei)
      if wm.reject then
        if depth >= 2 then thinkNote("источник не подтвердил нужные сущность, событие и год") end
        w = nil
      end
    end
    if w and w.cover >= 0.16 then
      lastWikiQ = raw
      local sourceLine = ""
      if w.sources and #w.sources > 1 then
        sourceLine = "\nИсточники: " .. concat(w.sources, "; ")
      elseif w.url then
        sourceLine = "\nИсточник: " .. w.url
      end
      if w.verified then
        return T.pick(WIKI_PRE) .. "(" .. (w.title or "") .. ") " .. w.saved .. sourceLine
      end
      return "Я разобрал материал «" .. (w.title or "без названия")
        .. "», но не смог достаточно проверить его связь с вопросом (совпадение "
        .. floor(w.cover * 100) .. "%). Не буду выдавать непроверенную выжимку за ответ."
        .. "\nЕсли материал действительно подходит, подтверди его командой /verify."
        .. sourceLine
    end
  end

  -- Для типизированного временного факта лучше признать незнание, чем выдать
  -- случайную фразу, совпавшую только по слову «год».
  if q.intent then
    if depth >= 2 then thinkNote("не нашёл кандидата с совпавшими сущностью, событием и годом") end
    return "Не нашёл надёжный ответ о годе именно этого события. Не буду угадывать только по слову «год»; уточни название или какой релиз имеешь в виду."
  end

  -- генератор фраз
  local txt, hits = generate(q.content, 12)
  if txt and concat(T.words(txt), " ") == concat(tl, " ") then txt = nil end
  if txt and (hits >= 2 or (isQ and hits >= 1)) then return T.pick(GEN_PRE) .. txt end
  if depth >= 2 then thinkNote(format("думал %.1f с, ответа в базе нет", os.clock() - t0)) end
  return isQ and T.pick(UNKNOWN_Q) or T.pick(UNKNOWN_S)
end

---------------------------------------------------------------------------
-- ЗАПУСК
---------------------------------------------------------------------------
local function loadMemory()
  local t
  t = T.dbRead(DB_LEARNED)
  if t then
    for _, e in ipairs(t) do
      if type(e) == "table" and type(e.q) == "string" and type(e.a) == "string" then
        dbLearned[#dbLearned + 1] = e
      end
    end
  end
  t = T.dbRead(DB_WIKI)
  if t then
    for _, e in ipairs(t) do
      if type(e) == "table" and type(e.q) == "string" and type(e.a) == "string" then
        dbWiki[#dbWiki + 1] = {q = e.q, a = e.a, title = e.title, ok = e.ok and true or false,
                               dv = e.dv, facts = e.facts, sources = e.sources,
                               autotrain = e.autotrain == true or (e.autotrain == nil
                                 and type(e.sources) == "table" and type(e.sources[1]) == "string"
                                 and e.sources[1]:match("^https?://ru%.wikipedia%.org/") ~= nil)}
      end
    end
  end
  t = T.dbRead(DB_NOTES)
  if t then
    if type(t.name) == "string" then dbNotes.name = t.name end
    if type(t.list) == "table" then
      for _, s in ipairs(t.list) do if type(s) == "string" then dbNotes.list[#dbNotes.list + 1] = s end end
    end
  end
  t = T.dbRead(DB_SENTENCES)
  if t then
    for _, s in ipairs(t) do if type(s) == "string" then dbSentences[#dbSentences + 1] = s end end
  end
  t = T.dbRead(DB_CORPUS)
  if t then
    for _, e in ipairs(t) do
      if type(e) == "table" and type(e.t) == "string" then
        dbCorpus[#dbCorpus + 1] = {t = e.t, s = e.s, a = e.a, u = e.u,
                                  facts = type(e.facts) == "table" and e.facts or {}, dv = e.dv, ok = e.ok == true,
                                  autotrain = e.autotrain == true or (e.autotrain == nil and type(e.u) == "string"
                                    and e.u:match("^https?://ru%.wikipedia%.org/") ~= nil)}
      end
    end
  end
  t = T.dbRead(DB_CRAWL)
  if t then
    if type(t.queue) == "table" then
      for _, s in ipairs(t.queue) do if type(s) == "string" then dbCrawl.queue[#dbCrawl.queue + 1] = s end end
    end
    if type(t.seen) == "table" then
      for k, v in pairs(t.seen) do if v then dbCrawl.seen[k] = true end end
    end
  end
  t = T.dbRead(DB_LEXICON)
  if t then
    lex = t
    lexTotal = 0
    for _, e in pairs(lex) do if type(e) == "table" and type(e.n) == "number" then lexTotal = lexTotal + e.n end end
  end
  t = T.dbRead(DB_CFG)
  if t then
    local oldReasonVersion = t.reasonVersion or 0
    for k, v in pairs(t) do
      if CFG[k] ~= nil and type(v) == type(CFG[k]) then CFG[k] = v end
    end
    if oldReasonVersion < 4 then
      CFG.think = 4
      CFG.reasonVersion = 4
      pcall(T.dbWrite, DB_CFG, CFG)
    end
  end

  -- Миграция старых записей: раньше wiki.db/corpus.db могли хранить extract
  -- в поле ответа. Переписываем его в короткую выжимку и сохраняем карточки.
  local wikiChanged, corpusChanged = false, false
  for _, e in ipairs(dbWiki) do
    if e.dv ~= 2 then
      local summary, facts = T.distillArticle(e.title or "Материал", e.a or "", e.q or "")
      if not summary or summary == "" then
        summary, facts = T.distillArticle(e.title or "Материал", e.a or "", e.title or "Материал")
      end
      e.a = summary or "Материал сохранён, но короткую выжимку извлечь не удалось."
      e.facts, e.dv = facts or {}, 2
      -- Старое ok означало лишь лексическое совпадение, не проверку фактов.
      e.ok = false
      wikiChanged = true
    end
  end
  for _, e in ipairs(dbCorpus) do
    if e.dv ~= 2 then
      local source = (type(e.s) == "string" and e.s ~= "") and e.s or (e.a or "")
      local summary, facts = T.distillArticle(e.t or "Материал", source, e.t or "Материал")
      if summary and summary ~= "" then e.a = summary end
      e.facts, e.dv, e.ok = facts or {}, 2, false
      corpusChanged = true
    elseif e.ok ~= true then
      e.ok = false
    end
  end
  if wikiChanged then pcall(T.dbWrite, DB_WIKI, dbWiki) end
  if corpusChanged then pcall(T.dbWrite, DB_CORPUS, dbCorpus) end
end

local function boxLine(text, tcol)
  local pad = scrW - 3 - T.ulen(text)
  if pad < 0 then pad = 0 end
  color(C.border); io.write("\226\149\145 ")
  color(tcol); io.write(text .. string.rep(" ", pad))
  color(C.border); print("\226\149\145")
end

local function header()
  term.clear()
  color(C.border)
  print("\226\149\148" .. string.rep("\226\149\144", scrW - 2) .. "\226\149\151")
  boxLine("НаноИИ 3.1  \194\183  локальный ИИ с ансамблем нейросетей", C.title)
  local neurons, weights = 0, 0
  for _, net in ipairs(NETS) do
    neurons = neurons + T.netNeurons(net)
    weights = weights + T.netWeights(net)
  end
  boxLine(format("нейронов: %d, весов: %d, сетей: %d  \194\183  думаю на глубине %d",
    neurons, weights, #NETS, CFG.think), C.dim)
  boxLine(format("ответов: %d (учено %d / wiki %d / обход %d)  \194\183  слов: %d  \194\183  %s",
    #ans, #dbLearned, #dbWiki, #dbCorpus, lexTotal,
    hasInternet and "интернет: есть" or "офлайн"), C.dim)
  boxLine("база знаний: " .. DB_DIR .. format("  \194\183  ОЗУ: %dК", floor(computer.freeMemory() / 1024)), C.dim)
  print("\226\149\154" .. string.rep("\226\149\144", scrW - 2) .. "\226\149\157")
  color(C.dim)
  print(" /help - команды  \194\183  /think 0..5 - глубина размышений  \194\183  /net - нейросети")
  print(" /autotrain on - учиться из Википедии  \194\183  /right и /wrong - учить нейросети")
  print("")
  color(0xFFFFFF)
end

local function main()
  term.clear()
  color(C.title); print("НаноИИ 3.1 DeepWiki"); color(0xFFFFFF)
  loadMemory()
  io.write("Загружаю базы знаний и нейросети... ")
  local saved = T.neuroLoad()
  -- подбираем пресет, который влезет в память
  local want = pickPreset()
  local tries = {want}
  if want == "deep" then tries = {"deep", "wide", "core", "light"}
  elseif want == "wide" then tries = {"wide", "core", "light"}
  elseif want == "core" then tries = {"core", "light"} end
  local used
  for _, p in ipairs(tries) do
    local ok = pcall(function() T.buildNets(p, saved) end)
    if ok then used = p break end
    gc()
  end
  if not used then used = "light" pcall(function() T.buildNets("light", saved) end) end
  CUR_PRESET = used
  trainLanguage()
  buildAll()
  print("готово.")
  header()
  if used ~= want then
    say(" ! ", C.warn, format("Памяти не хватило на пресет %s, включил %s. Освободи память и попробуй /net %s.", want, used, want), C.warn)
  end
  if lite then
    say(" ! ", C.warn, "ОБЛЕГЧЁННЫЙ РЕЖИМ: мало памяти, генератор фраз ограничен.", C.warn)
  end
  say(" ии \194\187 ", C.ai, fill(T.pick({
    "Привет{,name}! Спрашивай что угодно.",
    "С возвращением{,name}. О чём поговорим?",
    "Нейроны прогреты{,name}. Задавай вопрос!"})))
  while true do
    color(C.user); io.write(" ты \194\187 "); color(0xFFFFFF)
    local line = io.read()
    if not line then break end
    local ok, reply, quit = pcall(respond, line)
    if not ok then reply, quit = "Ой, в моей голове что-то сломалось: " .. tostring(reply), false end
    if reply then say(" ии \194\187 ", C.ai, reply) end
    if quit then break end
    -- фоновый автообход Википедии
    if CFG.autotrain == "on" and hasInternet and dbCrawl then
      local added, learned = crawlRound(1, false)
      if added > 0 and learned and learned[1] then
        thinkNote("фоном выучил из Википедии: " .. learned[1])
      end
    end
  end
  pcall(T.saveAll)
  color(0xFFFFFF)
end

main()