Спинтакс, который знает про данные: отчёт об одном эксперименте с ИИ Агентом

  • Автор темы Автор темы Divisor
  • Дата начала Дата начала

Divisor

Client
Регистрация
26.02.2024
Сообщения
69
Реакции
78
Баллы
18
Спинтакс, который знает про данные: отчёт об одном эксперименте с ИИ Агентом

Спинтакс — старая технология дешёвого текста. В конце нулевых {a|b} крутили многие, кому нужно было много страниц почти даром: одна заготовка, тысяча вариантов, ноль рублей за штуку. Потом его забыли — не потому, что он перестал работать, а потому, что тексты из него читались как тексты из него: слова перемешаны, смысл на месте, а согласование и цифры — как повезёт. Ремесло ушло к копирайтерам.

Потом пришли модели и за пару лет закрыли копирайтинг как профессию: текст на любую тему за секунды. Но одну вещь модель победить не смогла — цену тысячной копии. Каждая карточка через модель стоит запрос, каждый запрос — деньги, время и очередь на проверку глазами; пять тысяч карточек — пять тысяч раз. И здесь старый спинтакс ожил, потому что задача разложилась на две половины: придумать текст — работа модели, размножить его без брака — работа шаблона. Модель пишет один шаблон, движок проверяет его и рендерит остальное без сети.

Условие одно — шаблонизация должна быть правильной. Плоский {a|b} делает сегодня ту же ошибку, что и в 2009-м: он не знает про данные. Ниже — что я получил на ZennoPoster Pro 7.9.1.0, когда провёл через кубик «ИИ Агент» самый обычный сценарий, пять тысяч карточек товара для витрины, и рендерил их языком шаблонов, который семья открытых движков spintax довела до пяти сред. Всё нужное — dll, оба проекта, промпты, таблица — в архиве у этого поста, начинать с README.txt; движок открыт: https://github.com/investblog/spintax-dotnet, NuGet Spintax.Core, лицензия MIT. Все числа сняты с прогонов и тестов, не с потолка.

1. Вот что получается

Шаблон карточки товара (см. архив в атаче), обычный спин, каким его пишут все:

{Наша платформа|Наши инструменты} {создана|созданы} для {магазинов|витрин}. На складе %in_stock% единиц.

Четвёртый и пятый прогоны из шести (in_stock = 1):

Наша платформа созданы для витрин. На складе 1 единиц.
Наши инструменты созданы для витрин. На складе 1 единиц.

Сказуемое не согласовано с подлежащим, число — с существительным. Это не опечатка в шаблоне: каждая из шести строк допустима по синтаксису, и на витрине окажутся все шесть.

2. Почему так

{a|b} выбирает вариант случайно и ничего не знает ни о соседней скобке, ни о значении in_stock. Согласование — это связь между двумя местами в тексте, а число единиц — связь между текстом и данными. У {a|b} нет ни того, ни другого. Отсюда два типа брака: текст, который спорит сам с собой, и текст, который спорит с таблицей.

Родной спинтакс ZennoPoster — это {a|b}, вложенность и перестановка [+sep+a|b|c]. Он делает ровно то, для чего сделан; претензия не к нему, а к самому {a|b}: в этой записи нет способа сказать «эти два слова согласованы» или «эта форма зависит от числа». Для случайной подписи к посту такой способ и не нужен. Для пяти тысяч карточек с ценой и остатком — нужен, и дальше речь о конструкциях, которые его дают.

Различие между карточками должно нести данные — цену, остаток, скидку, — а не одни синонимы: уберите подставленные значения, и если тексты схлопнулись в один, различались только данные; если остались разными — различие держится на обёртке. Карточке нужно и то и другое, и ниже — конструкции для обоих.

Откуда взялся язык, который это умеет

Честно скажу сразу: ничего особенного здесь не придумано. Синтаксис, о котором пойдёт речь, задала настольная программа Generating The Web — GTW, Windows-приложение, первый выпуск около 2009 года, писалось для вебмастеров, которым нужно было много текста из структурированных шаблонов. Уже у GTW были перечисления в фигурных скобках, перестановки в квадратных с настраиваемыми разделителями, переменные через #set, сборка шаблона из кусков через #include и многоступенчатая постобработка текста после рендера. Программа дожила до версии 2.7 в русской редакции, сайт generating-the-web.com больше не работает, а синтаксис остался: его история и оригинальные установщики сохранены на https://ru.spintax.net/spintax-editor/.

Семья движков spintax взяла этот синтаксис как стандарт и расширила: добавила то, чего в GTW не было, — условие по данным и согласование с числом, — и убрала то, что жило только внутри настольного приложения. Сегодня один и тот же язык читают пять независимых реализаций — JavaScript, PHP, Python, Object Pascal и .NET, — и держит их вместе не общий код, а общий золотой корпус из 258 проверочных случаев: шаблон, который прошёл в одном движке, обязан дать то же в остальных. Что это за язык и зачем он нужен, коротко: https://ru.spintax.net/what-is-spintax/; все пять движков и как их ставить: https://ru.spintax.net/spintax-engines/.

Пятый движок, под .NET, появился ради ZennoPoster. Ещё до этой статьи я открыл на этом форуме тему «Предложение: поддержать открытый расширенный синтаксис spintax» и предложил команде ZennoLab поддержать его нативно: плоский {a|b} с вложенностью движок читает как есть, а всё остальное добавляет сверху; у перестановки, правда, другая запись границ и разделителя — об этом в п. 3.
Пока это предложение, синтаксис доступен как плагин — о нём и статья.

3. Семь конструкций, которых нет

Справочник по всему языку — https://ru.spintax.net/docs/syntax/. Ниже — по одному примеру на конструкцию и ровно то, что ломается без неё; каждый пример — реальный рендер.

%VAR% — переменная внутри шаблона. Без неё значение из таблицы приходится склеивать со спином в коде, до рендера — и тогда значение само становится частью спина (см. п. 7).

{Купите|Закажите} %product% {сегодня|сейчас}.

Как переменные живут в шаблоне, откуда берутся значения и что перебивает что — https://ru.spintax.net/docs/variables/.

#def — один бросок на документ. #set бросает кубик при каждом упоминании, #def — один раз. Шаблон:

#set %adj% = {надёжный|лёгкий|тихий}
%adj% %product%. Почему %adj%? Потому что %adj%.

Второй прогон: Надёжный Ferra Nova. Почему тихий? Потому что лёгкий. С #def вместо #set все три прогона дают одно прилагательное во всех трёх местах. Тем же способом связывается согласование из первого экрана — фраза целиком в одной альтернативе:

#def %who% = {Наша платформа создана|Наши инструменты созданы}
%who% для {магазинов|витрин}.

Четыре прогона, ни одного рассогласования: Наши инструменты созданы для магазинов., Наши инструменты созданы для витрин.

{plural} — форма по числу.

На складе {plural %in_stock%: %in_stock% единица|%in_stock% единицы|%in_stock% единиц}.

Прогоны по значениям 1, 2, 5, 11, 21: 1 единица, 2 единицы, 5 единиц, 11 единиц, 21 единица. Без конструкции — На складе 1 единиц., На складе 21 единиц., как в первом экране. Правило трёх форм — для ru, uk, be, sr, hr, bs; остальные локали — две формы. Справочник конструкции — https://ru.spintax.net/docs/plural-spintax/; на какие обходные пути идут авторы, когда у движка плюрала нет, — https://ru.spintax.net/russian-spintax-plurals/.

{?VAR?then|else} — условие по данным. В демо-шаблоне:

{На складе|В наличии|Сейчас есть} %in_stock% {plural %in_stock%: штука|штуки|штук}{?has_discount?, {сегодня|на этой неделе} со скидкой|}.

has_discount = yes → На складе 21 штука, на этой неделе со скидкой.; пусто → На складе 3 штуки. Без условия фраза про скидку либо есть у всех, либо ни у кого — или шаблонов становится два. Три формы условия и таблица, что считается «пусто»: https://ru.spintax.net/docs/conditional-spintax/.

[<minsize=…;maxsize=…>a|b|c|d] — перестановка с границами.

Доставим [<minsize=2;maxsize=3;sep=", ";lastsep=" и ">за два дня|с проверкой|в удобное время|до двери].

Прогоны: Доставим в удобное время, до двери и за два дня., Доставим за два дня и с проверкой. Из четырёх пунктов это 36 разных хвостов (12 пар и 24 тройки — движок считает точно, не выборкой). Родная перестановка [+sep+a|b|c|d] перечисляет все четыре пункта всегда и в одном из 24 порядков; выбрать два из четырёх она не умеет. Приёмы с перестановками — заголовки и перечни: https://ru.spintax.net/docs/permutations/.

#include — общий фрагмент в нескольких шаблонах. Подвал, юридическая строка, блок доставки — один файл, подключённый в десять шаблонов. Без него фрагмент копируется в десять мест и правится в девяти. В сборке для ZennoPoster резолвер пока выключен: строка #include остаётся в выводе как есть, а шаблон живёт одним файлом — это записано в ограничениях.

Экранирование значений (neutralize). Всё, что приходит из таблицы или со страницы, движок по умолчанию считает текстом, не спином. Значение product = Aurix {Mini|Maxi} 50% off:

Закажите Aurix {Mini|Maxi} 50% off сегодня.

Если то же значение подклеить в шаблон до рендера, спин его прочитает: Закажите Aurix Mini 50% off сегодня. — и часть карточек будет про другой товар.

Всё это можно потрогать без установки: https://ru.spintax.net/play/ рендерит шаблон в браузере на эталонном движке семьи, и демо-шаблон из архива (demo/card-ru.spintax) туда вставляется как есть.

4. Арифметика

Теперь о выгоде. Пять тысяч карточек через кубик «ИИ Агент» — пять тысяч запросов к модели, по одному на карточку, каждый со своим ответом и своей очередью на проверку глазами. Через шаблон — один запрос: модель пишет шаблон, движок его проверяет и рендерит пять тысяч раз без сети и без случайностей — один seed, один текст.

Сколько весит этот один запрос — измерено на демо-брифе («карточка товара для витрины: название, цена, остаток, скидка»): кубик 10 из шаблона 1 (template-1-author/10-build-authoring-prompt.cs) собирает промпт на 7 320 символов, из них 6 939 — системная часть с правилами синтаксиса (она одна на все брифы, лежит в template-1-author/prompts/authoring.system.ru.txt) и 379 — сам бриф с перечнем разрешённых переменных. Ответ модели — шаблон размером с demo/card-ru.spintax: 422 символа. Тарифы у пяти встроенных провайдеров ZennoPoster (OpenAI, Anthropic, DeepSeek, Gemini, Perplexity) разные и меняются, поэтому в рублях считайте сами: один запрос на ~7 тысяч символов против пяти тысяч запросов на бриф каждый. Порядок величин не зависит от провайдера.

Сколько разных карточек даёт демо-шаблон — посчитано движком точным обходом дерева, не выборкой: 124 416 разных текстов для одного товара со скидкой, 62 208 — без скидки, 1 866 240 — в сумме по двадцати строкам демо-таблицы (у шаблона все альтернативы разные, так что пути выбора и разные тексты — одно число). Откуда каждое из них берётся, по множителям, — в demo/README.md.

Ёмкость не отвечает на вопрос, который задаёт себе владелец витрины: сколько из пяти тысяч карточек одного товара совпадут. Это измерено прогоном, не выведено: тот же шаблон, та же строка со скидкой, seed row-0…row-(N−1), совпадение — побайтовое. На 500 карточках повторов нет. На 5 000 — 4 882 различных, и у 236 есть хотя бы один двойник. На 20 000 — 18 401 различных, с двойником 3 103. Для 124 416 равновероятных текстов прикидка N·(1−e^(−N/C)) дала бы 197 из 5 000; на деле больше — в том числе потому, что пути выбора не равновероятны — перестановка minsize=2;maxsize=3 сначала бросает размер, и 12 пар делят вероятность с 24 тройками. Поэтому близнецов считают рендером, а не по ёмкости; три числа выше прибиты тестом в репозитории.

И один замер про совместимость, чтобы не было иллюзий: шаблон в расширенном синтаксисе нельзя отдать родному макросу.
На ZennoPoster Pro 7.9.1.0 кубик из архива (demo/native-spin-count.cs) прогнал демо-шаблон через Macros.TextProcessing.Spintax 10 000 раз в каждом из двух его режимов: родной спин честно перемешал {a|b} — 4 287 различных строк в обычном режиме, 9 568 в расширенном (extendedSyntax), — а #def, %переменная%, plural и {? напечатал как есть, во всех 10 000 строках.

Это не недостаток родного спина — он и не обещал их понимать. Это правило: шаблон пишется либо под родной спин, либо под движок, и во втором случае рендерит его движок. В обратную сторону граница проходима: плоский {a|b} с вложенностью движок читает как есть, так что старые шаблоны переезжают без правок.

5. Петля

Так выглядит эксперимент целиком. Шаблон 1 из архива (template-1-author/template-1-author.zp) — семь кубиков, схема 10 → 20 → 30 → 50 → 60 → 70, с ответвлением 30 → 40 → 20 на один круг. Каждый файл NN-*.cs в той же папке — тело одного кубика «C# код» целиком, вставляется как есть. Бриф, язык, разрешённые переменные и канал клиент задаёт в форме «Входные настройки» проекта — той, что открывается двойным кликом по проекту в ZennoPoster.

t1-inputs-form.png



  1. C# код (10-build-authoring-prompt.cs) собирает промпт: системная часть из файла + бриф + список разрешённых %переменных% + канал.
  2. ИИ Агент пишет шаблон («Текст запроса» = {-Variable.prompt-}, температура 0.2–0.3, лимит токенов поднять с 400 — по умолчанию он режет карточку).
  3. C# код (30-clean-and-validate.cs) чистит ответ от ограждений кода, кавычек и префиксов вроде «Шаблон:» и зовёт Validate. Валиден — зелёная ветка. Нет — return null, красная ветка.
  4. На красной ветке C# код (40-build-repair-prompt.cs) собирает промпт починки: шаблон + диагностики со строкой и колонкой (error 1:1 [bracket.unclosed] Unclosed '{'., error 1:56 [plural.arity] expected 3 forms, got 2) — и возвращает управление ИИ Агенту. Один круг, не цикл: не починил со второго раза — стоп и лог.
  5. RenderMany (50-render-many.cs) — до 20 разных вариантов, у каждого свой seed вида имя:i (тот, что вернул AttemptSeed: повторы отбрасываются, номера идут с пропусками); по seed любой из них воспроизводится побайтово.
  6. Lint (60-lint.cs) — 50 документов, доля чистых, худшие находки первыми. На демо-шаблоне линт поймал {руб.|рублей}. → руб.. в 24 документах из 50 (строка A-001, seed A-001; прибито тестом) до того, как это увидел человек. Почему дефекты живут в сочетании веток и как их чинить по рецепту, а не переписыванием: https://ru.spintax.net/docs/repairing-spintax/.
  7. Файл .spintax (70-save-template.cs) — в папку проекта.

Так это прошло на ZennoPoster Pro 7.9.1.0 с claude-opus-4-5-20251101 22–23 августа — пять чистых прогонов, и в каждом шаблон валиден с первой попытки: #def, {plural} по %in_stock%, условие по %has_discount%, все четыре переменные на месте и ни одной выдуманной; 20 из 20 вариантов; линт на пятидесяти документах — 1.0, 1.0, 0.98, 1.0 и 1.0. Последний шаблон, его 20 вариантов с seed каждого и мета-файл лежат в архиве — template-1-author/out/card-ru.spintax, card-ru.variants.txt, card-ru.meta.txt; тест в репозитории перерендеривает каждый вариант по его seed и сверяет побайтово.

Где эта петля кончается. Всё выше измерено на карточке: короткий бриф, две-три фразы, четыре переменные. На таком объёме модель пишет шаблон с нуля и попадает с первой попытки — это и видно по пяти прогонам. Длинные структурные тексты я так не гонял и цифр по ним не привожу; порядок, с которого стоит начинать там, обратный: сначала один чистый текст — написанный или готовый, — а модели поручить только разметку: заменить слова на {а|б}, повторяющееся вынести в #def, счётное — в {plural}. Придумывать при этом нечего, а проверка та же — Validate, RenderMany, Lint. Этот подход в семье называют обратным авторингом, и с него начинается вся серия руководств: https://ru.spintax.net/docs/authoring-mindset/. Шаблон 1 сделан под карточку.

Для длинного текста есть отдельный проект и вторая статья — руководство без кода, только практика, по-русски и по-английски: https://zenno.club/discussion/threa...adok-obratnyi-avtoring-na-zennoposter.134262/.

Круг починки пришлось проверять, сломав шаблон руками — модель сама не ошибалась. Лишняя { перед Validate: кубик 30 ушёл по красной ветке с bracket.unclosed, кубик 40 собрал промпт починки (его системная часть — template-1-author/prompts/repair.system.ru.txt), модель вернула исправленный шаблон за четыре секунды, дальше всё зелёное.

Единственный сбой за день был наш, не модели: значения, набранные в панели переменных, ProjectMaker сбросил при запуске «С начала», и модель получила промпт без брифа — написала общий лендинг про «рутину». Отсюда форма «Входные настройки»: её значения читаются при каждом старте.

Два родных кубика — «ИИ Агент» и «C# код» — и одна dll. Шаблон 2 (template-2-production/) — прод: таблица товаров (products.csv, 20 строк вымышленных брендов), на каждую строку Protect → Render(seed = артикул:витрина) → Restore и файл; весь проход — один кубик 10-render-table.cs, цикл из кубиков не нужен. Без модели вообще. Двадцать файлов, которые этот кубик записал на ZennoPoster Pro 7.9.1.0, лежат в архиве — template-2-production/out/site-a/; тест рендерит те же строки движком и сравнивает побайтово; повторный прогон на ZennoPoster записал те же байты — git не увидел изменений. Это и значит «у вас получится то же самое»: прогоните шаблон 2 у себя с той же таблицей, тем же шаблоном, той же dll и тем же именем витрины (site-a) — файлы совпадут с моими до байта.

t2-output.png


6. Грабли

Четыре грабли, на которые я наступил, — и по ним видно, что это запускалось, а не описано по документации.

  1. {-Variable.x-} съедается парсером. Для движка {-Variable.sku-} — перечисление из одного варианта -Variable.sku-. Шаблон Артикул {-Variable.sku-}: {наличие|остаток} на складе — {-Variable.in_stock-} единиц. валиден и рендерится в Артикул -Variable.sku-: наличие на складе — -Variable. In_stock- единиц. — типограф ещё и заглавную поставил после точки. Поэтому в кубиках стоит Protect/Restore: макросы заменяются маркерами на время рендера и возвращаются побайтово; если рендер маркер повредил — исключение, а не испорченный файл. Шаблон с макросом внутри, чтобы повторить, — demo/card-macro.spintax.
  2. Шаблон из файла макросы получает — и это хуже, чем если бы не получал. Я ждал обратного и проверил: в файл .spintax вписана строка {-Variable.sku-}, кубик «Чтение файла» → переменная → кубик «C# код» возвращает её содержимое. Последняя строка вывода — A-001: макрос раскрылся по дороге, в тексте шаблона его уже нет. Значит, значение переменной попало в шаблон как текст шаблона, без щита — и если в нём есть | или {, парсер прочитает его как спин (п. 3). Поэтому значения в шаблон идут только через %переменные% и словарь в Render (движок их экранирует), а {-Variable.x-} в файле шаблона — не способ подставить данные. Читает файл сам кубик «C# код» (File.ReadAllText) — макросы остаются текстом, и тогда их прячет Protect (п. 1).
  3. Спарсенное значение с | — это выбор для любого спина, в который его подклеили. Macros.TextProcessing.Spintax("Закажите " + product + " сегодня.", false) при product = Aurix {Mini|Maxi} 50% off даёт Закажите Aurix Maxi 50% off сегодня.; с extendedSyntax: true — Закажите Aurix Mini 50% off сегодня. Название товара стало выбором в обоих режимах — и так поступил бы любой спин, потому что склейка строк не отличает данные от разметки. У движка то же значение печатается как есть не потому, что он умнее, а потому, что значения идут отдельным словарём, а не в тексте шаблона (п. 3, экранирование). Строка A-007 в products.csv несёт {, | и % в названии ровно для этой проверки.
  4. Дефект живёт на стыке, и ни в одном блоке его не видно. {руб.|рублей}. — оба варианта верны, точка после скобки верна, а рендер даёт руб.. в 24 документах из 50: брак возникает только в сочетании. То же с двумя соседними группами, которые выпадают на один корень. Поэтому Lint гоняется по рендерам, а не по шаблону: повтор корня в окне шести слов, «который» не в том роде, мусор пунктуации — всё это существует только в готовом тексте.

И одна грабля не про спин, а про сборку: dll под netstandard2.0 в ProjectMaker загружается, Spintax.Net.Version отвечает, а кубик с Dictionary<,> из её API не компилируется — CS0012: add a reference to assembly 'netstandard'. У компилятора кубика нет ссылки на netstandard.dll. В ZennoPoster уходит сборка под net472 — она и лежит в lib/ архива; netstandard2.0 остаётся для других хостов.

7. Перед боевым запуском

  • Проверено на ZennoPoster Pro 7.9.1.0 — это и есть поддерживаемая база. Шаблону 1 нужен кубик «ИИ Агент»: в релизах 7.8.x он не упомянут, есть ли он там — не проверял; рассчитывайте на 7.9 и новее. Шаблону 2 хватит «C# код», таблицы и входных настроек.
  • Две dll из lib/ лежат в Progs\ExternalAssemblies, в проекте одна ссылка из GAC — на Spintax.Zenno.dll; движок подтянулся из ExternalAssemblies (без копии там — не проверено); в каждом проекте своя.
  • Бриф, язык, переменные и папки заданы в форме «Входные настройки», не в панели переменных — панель сбрасывается при запуске.
  • Validate зелёный, и список колонок таблицы передан как известные переменные — иначе каждая %колонка% даст предупреждение variable.undefined.
  • Lint на 50 документах посмотрен глазами, худшие находки — сверху. Глаза ищут то, чего линт не видит по построению: смысловую несогласованность между слотами, когда каждый вариант верен сам по себе, а сочетание — нет; для этого читают несколько рендеров, не первый.
  • Число вариантов от RenderMany сверено с запрошенным: бедный шаблон честно возвращает меньше, а не крутится до упора.
  • Шаблон содержит макросы ZennoPoster → рендер обёрнут в Protect/Restore.
  • Seed = ключ строки + имя витрины: один и тот же текст на любой машине и в любом потоке; другая витрина — другой независимый розыгрыш (совпасть может, как любые два броска).
  • Лимит токенов в «ИИ Агент» поднят; температура 0.2–0.3.
  • Перед заменой dll закрыты ProjectMaker и ZennoPoster — сборка кешируется; Spintax.Net.Build до и после замены различается.


Шаблон 2 не привязан к шаблону 1: .spintax можно написать и без ИИ Агента — руками или в Spintax Studio (редактор для Windows из Microsoft Store, офлайн, тот же язык и движок семьи, с валидацией и предпросмотром) — положить файл рядом с проектом и указать его в форме. И это, пожалуй, главная выгода, которую я вынес из эксперимента: шаблон, написанный для ZennoPoster, — тот же файл, который читают узел n8n, плагин для WordPress и пакет для JavaScript. Переезд процесса на другую платформу — файл едет с вами без изменений.

Движок: https://github.com/investblog/spintax-dotnet (NuGet Spintax.Core; корпус семьи, тесты, CI). Приложение к статье — архив у этого поста: lib/ с двумя dll, оба проекта .zp с формами, тела кубиков, промпты по локалям, демо-таблица на 20 товаров и README.txt, с которого стоит начать. Предложение команде ZennoLab поддержать синтаксис нативно остаётся в силе — тема открыта, движок MIT, корпус общий.

Ключи ИИ-сервисов лежат в настройках программы (Редактирование → Настройки → ИИ, %AppData%\ZennoLab\Configs\), а не в проекте: скачанный шаблон ключей не содержит и запускается на ваших. Поле «Дополнительные параметры» там же — это адрес приёма запросов, а значит, свой прокси или локальная модель без выхода в интернет подключаются тем же окном.
 

Вложения

Последнее редактирование:
  • Оценить
Реакции: Алексей Козлов

Кто просматривает тему: (Всего: 1, Пользователи: 1, Гости: 0)