diff --git a/.gitignore b/.gitignore index bf3e0e1..a2bb201 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ profiles/ /hermes-agent/ *.exe + +# Кэш Python: попадал в бандл после локальных прогонов +__pycache__/ +*.pyc diff --git a/INSTALL.md b/INSTALL.md index 0504994..589ed52 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -369,17 +369,27 @@ ### Каталог моделей по цене В той же строке управления композером, **сразу за чипом расхода**, стоит чип -**`Модели от 1,63`**. Наведение открывает список чат-моделей Polza.ai от дешёвых -к дорогим: цена входа, выхода и чтения из кэша в рублях за миллион токенов. +**`Модели от 50,54`**. Наведение открывает список моделей Polza.ai в двух секциях, +и это разделение — главное в нём. -Разброс цен в одном каталоге — три порядка (1,63 ₽ и 17 713 ₽ за миллион токенов -входа), поэтому выбирать модель по названию нельзя: для черновой работы и для -проверки нужны разные. Показаны 40 самых дешёвых, сортировка по цене входа; клик — -обновить, дальше список обновляется сам раз в 30 минут. +**По факту списания.** Модели, которыми уже пользовались. Ставка посчитана из +`costLines` каждой генерации — это списанные рубли, а не прайс. Рядом число +запросов в выборке и сумма за них. + +**Прайс каталога — не факт.** Остальные модели с ценой из каталога Polza.ai. +Это ориентир для выбора, а не цена. + +Почему нельзя обойтись каталогом: он расходится с фактическим списанием +**по-разному** для разных моделей — от 1,01 до 8,76 раза. У `openai/gpt-6.1-sol` +ставка входа 282,18 ₽ при каталоге 118,09, у `deepseek/deepseek-v4.1-flash` — +50,54 при каталоге 5,78, а у `anthropic/claude-sonnet-5.5` каталог совпал. +Поэтому порядок моделей по каталогу не равен порядку по деньгам: по каталогу +самый дорогой — Claude Sonnet 5.5, по факту — GPT-6.1 Sol. Чип живёт плагином (`desktop-plugins/polza-models`), данные берёт локальный скрипт (`%LOCALAPPDATA%\hermes\.polza-models\polza-models.cmd`). Ключ остаётся в `.env` -и в окно не попадает. Отключить — `Настройки → Плагины`, плагин `polza-models`. +и в окно не попадает. Список обновляется сам раз в 30 минут, клик — обновить сразу. +Отключить — `Настройки → Плагины`, плагин `polza-models`. ### ГОСТ-ассистент (MCP-сервер) diff --git a/README.md b/README.md index 50bd625..036783b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ | `distribution.yaml` | манифест: имя, версия, автор, требуемые переменные окружения | | `SOUL.md` | персона агента: AILab SCRUME Agent, личный помощник | | `config.yaml` | модель, провайдер (Polza.ai), инструменты, язык — **без ключей** | -| `skills/` | 63 скилла: 58 штатных Hermes + 5 наших (`skills/ailab/`, раздел ниже) | +| `skills/` | 62 скилла: 58 штатных Hermes + 4 наших (`skills/ailab/`) | | `INSTALL.md` | пошаговая инструкция для получателя | | `update-check.ps1` | ночная проверка обновлений; ставится задачей Планировщика `AILabAgentUpdate` | | `desktop-plugins/` | шесть плагинов окна: `version-badge`, `slogan`, `hide-messaging`, `session-time`, `polza-limit`, `polza-models` | @@ -85,7 +85,7 @@ | Номер сборки | виден в заголовке окна чипом `AILab v<версия>` (плагин `version-badge`) | | Девиз | справа от номера сборки, в заголовке окна (плагин `slogan`); на узком окне скрывается сам | | Время сессии | счётчик в композере слева от чипа Polza.ai (плагин `session-time`) | -| Цены моделей | чип `Модели` рядом с расходом: модели Polza.ai от дешёвых к дорогим, ₽ за 1 млн токенов (плагин `polza-models`) | +| Цены моделей | чип `Модели` рядом с расходом: две секции — **по факту списания** (свои модели, ставка из `costLines`) и **прайс каталога — не факт** (плагин `polza-models`) | | ГОСТ-ассистент | MCP-сервер `gostassistent` в конфиге; включается своим ключом в `.env` | | Обновления | наши ставятся сами раз в сутки ночью (задача `AILabAgentUpdate`); сообщения об обновлениях самого Hermes выключены (`updates.check: false`) | | Навигация | строка «Сообщения» убрана из боковой панели (плагин `hide-messaging`) | diff --git a/desktop-plugins/polza-models/plugin.js b/desktop-plugins/polza-models/plugin.js index 8497737..6cba22a 100644 --- a/desktop-plugins/polza-models/plugin.js +++ b/desktop-plugins/polza-models/plugin.js @@ -1,24 +1,28 @@ /** - * polza-models — ранжирование моделей Polza.ai по цене в строке управления - * композера (composer.actions), сразу за чипом расхода (order 15 против 10). + * polza-models — модели Polza.ai в строке управления композером + * (composer.actions), сразу за чипом расхода (order 15 против 10). * - * Зачем: разброс цен в каталоге — три порядка. В одном и том же каталоге - * 1,63 ₽ и 17 713 ₽ за миллион токенов входа. При таком разбросе выбирать - * модель по названию нельзя: для черновой работы и для аудита нужны разные, - * и «дороже» не значит «лучше для этой задачи». + * ДВЕ СЕКЦИИ, и это главное в плагине. * - * Цены — рублёвые и уже посчитанные: каталог отдаёт их в top_provider.pricing - * с полем currency = RUB, пересчитывать нечего. + * Прайс каталога — не то, что списывают. Проверено на своих генерациях: + * ставка входа у openai/gpt-6.1-sol 282,18 ₽ при каталоге 118,09 (×2,39), + * у deepseek/deepseek-v4.1-flash — 50,67 при каталоге 5,78 (×8,76), а у + * anthropic/claude-sonnet-5.5 совпала (×1,01). Множитель гуляет от 1,01 до + * 8,76, поэтому порядок по каталогу НЕ равен порядку по деньгам: по каталогу + * самый дорогой Claude Sonnet 5.5, по факту — GPT-6.1 Sol. + * + * Отсюда разделение: + * * «по факту списания» — модели, которыми уже пользовались; ставка + * посчитана из costLines каждой генерации (clientRub / quantity); + * * «прайс каталога — не факт» — остальные; это ориентир для выбора, + * а не цена. * * Данные достаёт локальный скрипт (scripts/polza-models/polza-models.cmd → - * polza_models.py): читает /v1/models, фильтрует чат-модели с ценой и печатает - * их от дешёвых к дорогим. Ключ остаётся в .env, в рендерер не попадает: - * плагин вызывает готовый .cmd через gateway RPC shell.exec. + * polza_models.py). Ключ остаётся в .env, в рендерер не попадает. * - * ОГРАНИЧЕНИЯ shell.exec: только ХВОСТ stdout (4000 символов) и 30 с. Скрипт - * печатает от самого дешёвого и сам ужимает список под бюджет — иначе обрезка - * съела бы именно дешёвые модели, самое ценное в этом списке. Опрос — раз в - * 30 минут: каталог меняется редко, а запрос тянет 600 КБ. + * ОГРАНИЧЕНИЯ shell.exec: только ХВОСТ stdout (4000 символов) и 30 с. + * Скрипт печатает от дешёвого и ужимается под бюджет сам; опрос — раз в + * 30 минут (скрипт тянет каталог 600 КБ плюс историю генераций, ~21 с). */ import { COMPOSER_AREAS, cn, haptic, host, queryClient, Tip, useQuery, useValue } from '@hermes/plugin-sdk' @@ -27,8 +31,7 @@ const ID = 'polza-models' // Путь через %LOCALAPPDATA%: shell.exec выполняет команду через cmd.exe, а он -// раскрывает переменную у каждого пользователя. Хардкод C:\\Users\\Admin на чужой -// машине не нашёлся бы. Пробелов в пути нет — кавычки не нужны. +// раскрывает переменную у каждого пользователя. const CMD = '%LOCALAPPDATA%\\hermes\\.polza-models\\polza-models.cmd' @@ -79,66 +82,49 @@ return parsePayload(res.stdout) } -/** «id \x1f имя \x1f вход \x1f выход \x1f кэш» → объект. +/** «источник \x1f id \x1f имя \x1f вход \x1f выход \x1f кэш \x1f запросов \x1f всего ₽». * - * Хвост добавлен позже, чем первые поля: при ручной подмене одного файла из - * двух развёртывание может разойтись, поэтому отсутствующее обязано давать - * нули, а не NaN. + * Источник: «ф» — фактическая ставка из costLines, «п» — прайс каталога. + * Хвост (запросы, всего) добавлен позже, поэтому отсутствующее даёт нули, + * а не NaN: при ручной подмене одного файла из двух развёртывание разойдётся. */ function parseRows(rows) { return (rows || []).map(row => { - const [id, name, pin, pout, pcache] = String(row).split('\u001f') + const [src, id, name, pin, pout, pcache, gen, total] = String(row).split('\u001f') return { + fact: src === 'ф', id: id || '?', name: name || id || '?', pin: Number(pin) || 0, pout: Number(pout) || 0, - pcache: Number(pcache) || 0 + pcache: Number(pcache) || 0, + gen: Number(gen) || 0, + total: Number(total) || 0 } }) } -/** «1.63» → «1,63»: каталог отдаёт точку, читается это как опечатка. */ -function rub(v, digits = 2) { - return (Number(v) || 0).toFixed(digits).replace('.', ',') +/** «52.61» → «52,61»: каталог отдаёт точку, в русском тексте это опечатка. */ +function rub(v) { + return (Number(v) || 0).toFixed(2).replace('.', ',') } -/** Три порядка разброса: 17713.2 → «17,7 тыс.», 1771.32 → «1 771». */ -function rubShort(v) { - const n = Number(v) || 0 - - if (n >= 10_000) { - return `${(n / 1000).toFixed(1).replace('.', ',')} тыс.` - } - - if (n >= 1_000) { - return `${Math.round(n).toLocaleString('ru-RU')}` - } - - return rub(n) -} - -function LegendHead() { +function LegendHead({ fact }) { return jsxs('div', { - style: { - display: 'flex', - alignItems: 'center', - gap: '6px', - color: 'var(--ui-text-quaternary)' - }, + style: { display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--ui-text-quaternary)' }, children: [ jsx('span', { style: { flex: '1 1 auto' }, children: 'модель' }), - jsx('span', { style: { minWidth: '52px', textAlign: 'right' }, children: 'вход' }), - jsx('span', { style: { minWidth: '52px', textAlign: 'right' }, children: 'выход' }), - jsx('span', { style: { minWidth: '44px', textAlign: 'right' }, children: 'кэш' }) + jsx('span', { style: { minWidth: '50px', textAlign: 'right' }, children: 'вход' }), + jsx('span', { style: { minWidth: '50px', textAlign: 'right' }, children: 'выход' }), + jsx('span', { style: { minWidth: '42px', textAlign: 'right' }, children: 'кэш' }), + fact ? jsx('span', { style: { minWidth: '40px', textAlign: 'right' }, children: 'запр' }) : null, + fact ? jsx('span', { style: { minWidth: '58px', textAlign: 'right' }, children: '₽' }) : null ] }) } function ModelRow({ m, index }) { - // Зебра вместо цвета модели: список читается сверху вниз, и полоски - // помогают не потерять строку. Палитра тут была бы шумом. const bg = index % 2 ? 'transparent' : 'var(--ui-bg-secondary, transparent)' return jsxs('div', { @@ -167,14 +153,19 @@ ] }), jsx('span', { - style: { fontVariantNumeric: 'tabular-nums', minWidth: '52px', textAlign: 'right' }, + style: { + fontVariantNumeric: 'tabular-nums', + minWidth: '50px', + textAlign: 'right', + color: m.fact ? 'var(--ui-text-primary, inherit)' : 'var(--ui-text-tertiary)' + }, children: rub(m.pin) }), jsx('span', { style: { color: 'var(--ui-text-tertiary)', fontVariantNumeric: 'tabular-nums', - minWidth: '52px', + minWidth: '50px', textAlign: 'right' }, children: rub(m.pout) @@ -183,39 +174,98 @@ style: { color: 'var(--ui-text-quaternary)', fontVariantNumeric: 'tabular-nums', - minWidth: '44px', + minWidth: '42px', textAlign: 'right' }, children: m.pcache > 0 ? rub(m.pcache) : '—' - }) + }), + m.fact + ? jsx('span', { + style: { + color: 'var(--ui-text-quaternary)', + fontVariantNumeric: 'tabular-nums', + minWidth: '40px', + textAlign: 'right' + }, + children: `${m.gen}` + }) + : null, + m.fact + ? jsx('span', { + style: { + color: 'var(--ui-text-tertiary)', + fontVariantNumeric: 'tabular-nums', + minWidth: '58px', + textAlign: 'right' + }, + children: rub(m.total) + }) + : null ] }, `row-${index}-${m.id}`) } -function CatalogCard({ data }) { - const models = parseRows(data.m) +function Section({ title, note, rows, fact }) { + if (!rows.length) { + return null + } return jsxs('div', { - style: { width: '380px', display: 'flex', flexDirection: 'column', gap: '8px' }, + style: { display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px' }, + children: [ + jsxs('div', { + style: { color: 'var(--ui-text-tertiary)', fontSize: '10px', textTransform: 'uppercase' }, + children: [ + jsx('span', { children: title }), + note + ? jsx('span', { style: { textTransform: 'none', marginLeft: '6px' }, children: note }) + : null + ] + }), + jsx(LegendHead, { fact }), + ...rows.map((m, i) => jsx(ModelRow, { m, index: i }, `m-${i}-${m.id}`)) + ] + }) +} + +function CatalogCard({ data }) { + const all = parseRows(data.m) + const fact = all.filter(m => m.fact) + const list = all.filter(m => !m.fact) + // Выборка истории ограничена дедлайном shell.exec (30 с), поэтому «всего ₽» — + // это сумма по СОБРАННЫМ генерациям, а не полный итог за окно. Ставка от + // размера выборки почти не зависит, а сумма — зависит, и об этом надо сказать. + const sample = fact.reduce((acc, m) => acc + m.gen, 0) + + return jsxs('div', { + style: { width: '400px', display: 'flex', flexDirection: 'column', gap: '8px' }, children: [ jsxs('div', { style: { display: 'flex', justifyContent: 'space-between', fontSize: '11px' }, children: [ jsx('span', { style: { color: 'var(--ui-text-tertiary)' }, children: 'Polza.ai · модели по цене' }), - jsx('span', { style: { color: 'var(--ui-text-quaternary)' }, children: data.cur || 'RUB' }) + jsx('span', { style: { color: 'var(--ui-text-quaternary)' }, children: `${data.cur || 'RUB'} за 1 млн токенов` }) ] }), jsx('div', { - style: { color: 'var(--ui-text-quaternary)', fontSize: '11px' }, - children: `${data.n} чат-моделей с ценой · ₽ за 1 млн токенов · от ${rub(data.min)} до ${rubShort(data.max)}` + style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' }, + children: `${data.nf} своих моделей по факту списания · ${data.nc} по прайсу каталога` }), - jsxs('div', { - style: { display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px' }, - children: [jsx(LegendHead, {}), ...models.map((m, i) => jsx(ModelRow, { m, index: i }, `m-${i}-${m.id}`))] + jsx(Section, { + title: 'по факту списания', + note: `— costLines, списанные рубли; выборка ${sample} генераций`, + rows: fact, + fact: true + }), + jsx(Section, { + title: 'прайс каталога — не факт', + note: `— отличается от списания до 9 раз, показаны ${list.length} самых дешёвых`, + rows: list, + fact: false }), jsx('div', { style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' }, - children: `показаны ${models.length} самых дешёвых из ${data.n} · сортировка по входу · клик — обновить` + children: 'клик — обновить · сортировка по цене входа' }) ] }) @@ -245,8 +295,6 @@ return jsx(Tip, { label: tip, placement: 'control', - // Тема приложения вместо инверсного фона по умолчанию: таблице цен нужен - // обычный surface. className: 'border border-(--ui-stroke-secondary) bg-(--ui-bg-elevated) text-foreground backdrop-blur-md', children: jsxs('button', { @@ -283,9 +331,8 @@ name: 'Polza models', defaultEnabled: true, register(ctx) { - // composer.actions рендерится внутри строки контролов, до ModelPill — - // то есть визуально левее выбора модели. Order 15 ставит чип сразу за - // расходом (10), чтобы цены и трата читались рядом. + // composer.actions рендерится внутри строки контролов, до ModelPill. + // Order 15 ставит чип сразу за расходом (10): цены и трата рядом. ctx.register({ id: 'chip', area: COMPOSER_AREAS.actions, diff --git a/distribution.yaml b/distribution.yaml index 24025d3..36c17bd 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,5 +1,5 @@ name: ailab_scrume_agent -version: 1.6.0 +version: 1.6.1 description: "Личный помощник: документы, письма, договоры, расчёты, поиск, задачи." hermes_requires: ">=0.21.6" author: "AI laboratory SCRUM company" diff --git a/scripts/polza-models/polza_models.py b/scripts/polza-models/polza_models.py index 7da943b..0a280c4 100644 --- a/scripts/polza-models/polza_models.py +++ b/scripts/polza-models/polza_models.py @@ -1,40 +1,63 @@ #!/usr/bin/env python3 -"""Каталог моделей Polza.ai, отсортированный от дешёвых к дорогим. +"""Модели Polza.ai: фактические ставки по своим генерациям + прайс каталога. -Зачем: выбирать модель по цене, а не по названию. Один и тот же класс задач -различается в цене на три порядка (1,63 ₽ против 17 713 ₽ за миллион токенов), -поэтому «дорогая модель» — не синоним «лучшая»: для черновой работы и для -аудита нужны разные. +Зачем два источника. Прайс каталога (`top_provider.pricing`) — это НЕ то, что +списывают. Проверено на своих генерациях за 7 суток, ставка входа: -Источник: GET /api/v1/models, поле top_provider.pricing. - prompt_per_million — вход, ₽ за 1 млн токенов - completion_per_million — выход, ₽ за 1 млн токенов - input_cache_read_per_million — чтение из кэша, ₽ за 1 млн (в 10–15 раз дешевле входа) - currency — в каталоге указан RUB; значения уже в рублях + openai/gpt-6.1-sol 282,18 ₽/1M каталог 118,09 ×2,39 + anthropic/claude-sonnet-5.5 238,70 каталог 236,18 ×1,01 + google/gemini-3-flash-preview 61,37 каталог 29,52 ×2,08 + deepseek/deepseek-v4.1-flash 50,67 каталог 5,78 ×8,76 -ВАЖНО про бюджет вывода: плагин получает от shell.exec только ХВОСТ stdout -(4000 символов). Поэтому список печатается от самого дешёвого, длина ограничена, -а в конце идёт строка-сводка. Иначе обрезка съела бы именно дешёвые модели — -самое ценное в этом списке. +Множитель гуляет от 1,01 до 8,76: каталог не просто занижен, он занижен +ПО-РАЗНОМУ, поэтому порядок моделей по нему не совпадает с порядком по деньгам. +У DeepSeek в каталоге цена кэша вообще 0,00, а фактически кэш-чтение стоит +0,72 ₽/1M и даёт 280 ₽ из 2 298 ₽ за неделю. + +Поэтому: + * модели, которыми уже пользовались, идут со СВОЕЙ фактической ставкой — + она считается из costLines каждой генерации (`clientRub` / `quantity`); + * остальные — с прайсом каталога и пометкой «не факт». + +Источник факта: GET /history/generations, поле costLines — по каждому виду +токенов своя строка с quantity и clientRub. Это и есть списанные рубли; из +поля cost ставку не вывести, там нет разбивки по видам. Формат: одна строка JSON - {"ok":true,"n":308,"min":1.63,"max":17713.20,"cur":"RUB","m":[ - "qwen/qwen3.5-9b\\x1fQwen: Qwen3.5 9B\\x1f1.63\\x1f4.89\\x1f0.33", ...]} + {"ok":true,"nf":4,"nc":40,"cur":"RUB","min":50.67,"shown":44, + "m":["ф\\x1fdeepseek/deepseek-v4.1-flash\\x1fDeepSeek: DeepSeek V4.1 Flash\\x1f50.67\\x1f105.79\\x1f0.72\\x1f1921\\x1f2298.47", ...]} + +Поля строки: источник («ф» факт / «п» прайс), id, имя, вход, выход, кэш, запросов, +всего рублей. Источник — первое поле, потому что по нему плагин раскладывает +список на две секции. + +ВАЖНО про бюджет вывода: плагин получает от shell.exec только ХВОСТ stdout +(4000 символов). Список печатается от дешёвого и ужимается под бюджет, иначе +обрезка съела бы именно дешёвые модели — самое ценное здесь. """ import argparse +import collections +import datetime import json import os import re import sys -import urllib.error +import time +import urllib.parse import urllib.request API = "https://polza.ai/api/v1" ENV_PATHS = [r"%LOCALAPPDATA%\hermes\.env"] ENV_KEY = "HERMES_CUSTOM_POLZA_AI_API_KEY" -# Хвост stdout у плагина — 4000 символов. Оставляем запас на служебные поля. -MAX_ROWS = 40 +MAX_ROWS = 40 # строк каталога (строки факта печатаются все) +CATALOG_DEADLINE_S = 12 +HISTORY_DEADLINE_S = 20 + +# Виды строк в costLines, раскладываемые по колонкам карточки. +KIND_PROMPT = "prompt" +KIND_COMPLETION = "completion" +KIND_CACHE = "cached_read" def read_key() -> str: @@ -53,88 +76,161 @@ sys.exit("Не найден ключ Polza.ai в .env") -def fetch(key: str, timeout: int = 30) -> list: - req = urllib.request.Request(API + "/models", headers={"Authorization": f"Bearer {key}"}) +def api_get(path: str, key: str, timeout: int = 30) -> dict: + req = urllib.request.Request(API + path, headers={"Authorization": f"Bearer {key}"}) with urllib.request.urlopen(req, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8")).get("data") or [] + return json.loads(resp.read().decode("utf-8")) -def num(p: dict, field: str) -> float: +def num(v) -> float: try: - return float(p.get(field) or 0) + return float(v or 0) except (TypeError, ValueError): return 0.0 -def collect(items: list) -> tuple[list, str]: - """Только чат-модели с ценой входа. Сортировка по входу, затем по выходу.""" +def catalog(key: str) -> tuple: + """Чат-модели с прайсом каталога, по возрастанию цены входа.""" rows, currency = [], "RUB" - for x in items: + deadline = time.monotonic() + CATALOG_DEADLINE_S + for x in (api_get("/models", key).get("data") or []): + if time.monotonic() > deadline: + break if x.get("type") != "chat": continue p = ((x.get("top_provider") or {}).get("pricing") or {}) - pin = num(p, "prompt_per_million") + pin = num(p.get("prompt_per_million")) if pin <= 0: continue currency = p.get("currency") or currency - rows.append(( - pin, - num(p, "completion_per_million"), - num(p, "input_cache_read_per_million"), - x.get("id") or "?", - x.get("name") or x.get("id") or "?", - )) + rows.append((pin, num(p.get("completion_per_million")), + num(p.get("input_cache_read_per_million")), + x.get("id") or "?", x.get("name") or x.get("id") or "?")) rows.sort(key=lambda r: (r[0], r[1], r[3])) return rows, currency -def fmt(rows: list, currency: str, limit: int) -> str: - head = rows[:limit] - out = [ - f"{r[3]}\x1f{r[4]}\x1f{r[0]:.2f}\x1f{r[1]:.2f}\x1f{r[2]:.2f}" - for r in head - ] - payload = { - "ok": True, - "n": len(rows), - "shown": len(head), - "min": round(rows[0][0], 2) if rows else 0.0, - "max": round(rows[-1][0], 2) if rows else 0.0, +def history(key: str, days: int) -> list: + """Генерации за окно с жёстким дедлайном: у плагина всего 30 с.""" + end = datetime.datetime.now() + start = end - datetime.timedelta(days=days) + items, page = [], 1 + deadline = time.monotonic() + HISTORY_DEADLINE_S + while page <= 25: + if time.monotonic() > deadline: + break + query = urllib.parse.urlencode({ + "limit": 100, "page": page, + "dateFrom": start.isoformat(), "dateTo": end.isoformat(), + "sortBy": "createdAt", "sortOrder": "desc", + }) + try: + data = api_get("/history/generations?" + query, key) + except Exception: + break + batch = data.get("items") or [] + items.extend(batch) + if page >= int((data.get("meta") or {}).get("totalPages") or 1) or not batch: + break + page += 1 + return items + + +def actual(items: list) -> dict: + """Фактические ставки: модель → вид → ставка ₽ за 1 млн. + + Считаем costLines, а не cost: costLines дают количество и списанные рубли + отдельно по каждому виду, из них ставка получается делением. + """ + per = collections.defaultdict(lambda: collections.defaultdict(lambda: [0, 0.0])) + gen = collections.Counter() + for it in items: + mid = it.get("model") or "?" + gen[mid] += 1 + for ln in (it.get("costLines") or []): + kind = ln.get("kind") or "?" + per[mid][kind][0] += int(num(ln.get("quantity"))) + per[mid][kind][1] += num(ln.get("clientRub")) + out = {} + for mid, kinds in per.items(): + out[mid] = {k: (rub / qty * 1e6 if qty else 0.0) for k, (qty, rub) in kinds.items()} + out[mid]["_total"] = sum(v[1] for v in kinds.values()) + out[mid]["_gen"] = gen[mid] + return out + + +def build(key: str, days: int): + cat, currency = catalog(key) + fact = actual(history(key, days)) + names = {r[3]: r[4] for r in cat} + + fact_rows = [] + for mid, r in fact.items(): + pin = r.get(KIND_PROMPT, 0.0) + if pin <= 0: + continue + fact_rows.append((pin, r.get(KIND_COMPLETION, 0.0), r.get(KIND_CACHE, 0.0), mid, + names.get(mid, mid), r["_gen"], r["_total"])) + fact_rows.sort(key=lambda x: (x[0], x[3])) + + used = {r[3] for r in fact_rows} + cat_rows = [r for r in cat if r[3] not in used] + + meta = { "cur": currency, - "m": out, + "nf": len(fact_rows), + "nc": len(cat_rows), + "min": round(fact_rows[0][0] if fact_rows else (cat_rows[0][0] if cat_rows else 0.0), 2), } + return fact_rows, cat_rows, meta + + +def fmt(fact_rows, cat_rows, meta, limit: int) -> str: + rows = [f"ф\x1f{r[3]}\x1f{r[4]}\x1f{r[0]:.2f}\x1f{r[1]:.2f}\x1f{r[2]:.2f}\x1f{r[5]}\x1f{r[6]:.2f}" + for r in fact_rows] + rows += [f"п\x1f{r[3]}\x1f{r[4]}\x1f{r[0]:.2f}\x1f{r[1]:.2f}\x1f{r[2]:.2f}\x1f0\x1f0.00" + for r in cat_rows[:limit]] + payload = dict(meta) + payload.update({"ok": True, "shown": len(rows), "m": rows}) return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) def main() -> int: - ap = argparse.ArgumentParser(description="Модели Polza.ai по возрастанию цены") - ap.add_argument("--limit", type=int, default=MAX_ROWS, help="сколько моделей показать") - ap.add_argument("--pretty", action="store_true", help="таблица для чтения глазами") + ap = argparse.ArgumentParser(description="Модели Polza.ai: факт и прайс каталога") + ap.add_argument("--days", type=int, default=30, help="окно истории для фактических ставок") + ap.add_argument("--limit", type=int, default=MAX_ROWS, help="сколько строк каталога показать") + ap.add_argument("--pretty", action="store_true", help="таблицы для чтения глазами") ap.add_argument("--budget", type=int, default=3900, help="предел длины вывода") args = ap.parse_args() - rows, currency = collect(fetch(read_key())) - if not rows: - print(json.dumps({"ok": False, "err": "каталог пуст"}, ensure_ascii=False)) + fact_rows, cat_rows, meta = build(read_key(), args.days) + if not fact_rows and not cat_rows: + print(json.dumps({"ok": False, "err": "нет данных ни по факту, ни по каталогу"}, + ensure_ascii=False)) return 1 if not args.pretty: limit = args.limit - line = fmt(rows, currency, limit) + line = fmt(fact_rows, cat_rows, meta, limit) # ужимаем, пока не влезет: плагин видит только хвост stdout while len(line) > args.budget and limit > 5: limit -= 5 - line = fmt(rows, currency, limit) + line = fmt(fact_rows, cat_rows, meta, limit) print(line) return 0 - print(f"чат-моделей с ценой: {len(rows)} · валюта {currency} · " - f"от {rows[0][0]:.2f} до {rows[-1][0]:.2f} ₽ за 1 млн токенов\n") - print(f"{'модель':<44}{'вход':>10}{'выход':>10}{'кэш':>9} id") - for r in rows[:args.limit]: - print(f"{r[4][:44]:<44}{r[0]:>10.2f}{r[1]:>10.2f}{r[2]:>9.2f} {r[3]}") - if len(rows) > args.limit: - print(f"... ещё {len(rows) - args.limit} моделей дороже") + print(f"факт по {meta['nf']} своим моделям (окно {args.days} сут) · " + f"каталог {meta['nc']} моделей · {meta['cur']} за 1 млн токенов\n") + print("ПО ФАКТУ СПИСАНИЯ (costLines)") + print(f" {'модель':<38}{'вход':>10}{'выход':>10}{'кэш':>9}{'запр':>7}{'всего руб':>12}") + for r in fact_rows: + print(f" {r[4][:38]:<38}{r[0]:>10.2f}{r[1]:>10.2f}{r[2]:>9.2f}{r[5]:>7}{r[6]:>12.2f}") + print("\nПРАЙС КАТАЛОГА — НЕ ФАКТ") + print(f" {'модель':<38}{'вход':>10}{'выход':>10}{'кэш':>9} id") + for r in cat_rows[:args.limit]: + print(f" {r[4][:38]:<38}{r[0]:>10.2f}{r[1]:>10.2f}{r[2]:>9.2f} {r[3]}") + if len(cat_rows) > args.limit: + print(f" ... ещё {len(cat_rows) - args.limit} моделей дороже") return 0 diff --git a/skills/ailab/ailab-docx/SKILL.md b/skills/ailab/ailab-docx/SKILL.md deleted file mode 100644 index 2708fed..0000000 --- a/skills/ailab/ailab-docx/SKILL.md +++ /dev/null @@ -1,194 +0,0 @@ ---- -name: ailab-docx -description: "Use when создаёшь документ Word в фирменном стиле. Типографика, цвета и структура корпоративного документа AI_Lab_SCRUM Co." -version: 1.0.0 -author: AI_Lab_SCRUM Co -license: MIT -platforms: [linux, macos, windows] -metadata: - hermes: - tags: [word, docx, documents, style, ailab] - category: ailab - related_skills: [docx, ailab-pdf] ---- - -Правила фирменного стиля **AI_Lab_SCRUM Co** для документов Word. Механика работы -с `.docx` (создание, правка, шаблоны, ревизии) — в штатном скилле `docx`. - -# AILAB DOCX — корпоративные документы - -Создание документов Word в фирменном стиле проектной организации AI_Lab_SCRUM Co. - -**Когда не применять:** правка присланного документа (оформление берётся из -него) и документы по форме заказчика — там бланк задан не нами. - -## Инструменты - -**python-docx** — основная библиотека. На рабочей машине её может не быть: - -```bash -python -c "import docx" || python -m pip install python-docx -python your_script.py -``` - -## Типовой шаблон документа - -```python -from docx import Document -from docx.shared import Inches, Pt, RGBColor, Cm -from docx.enum.text import WD_ALIGN_PARAGRAPH -from docx.enum.section import WD_ORIENT -from docx.oxml.ns import qn - -doc = Document() - -# ── Page setup ── -section = doc.sections[0] -section.top_margin = Inches(PAGE_MARGIN_TOP) -section.bottom_margin = Inches(PAGE_MARGIN_BOTTOM) -section.left_margin = Inches(PAGE_MARGIN_LEFT) -section.right_margin = Inches(PAGE_MARGIN_RIGHT) - -# ── Default style ── -style = doc.styles['Normal'] -font = style.font -font.name = FONT -font.size = Pt(BODY_SIZE) -font.color.rgb = RGBColor(*INK) -style.paragraph_format.space_after = Pt(SP_2) -style.paragraph_format.line_spacing = LH_RELAXED - -# ── Helper: add AILAB-styled heading ── -def ailab_heading(doc, text, level=1): - """Heading in AILAB style: UPPERCASE, Ink, Medium weight.""" - h = doc.add_heading(text, level=level) - for run in h.runs: - run.font.name = FONT - run.font.color.rgb = RGBColor(*INK) - if level == 1: - run.font.size = Pt(H2_SIZE) - run.font.bold = False # Medium, not Bold - elif level == 2: - run.font.size = Pt(H3_SIZE) - run.font.bold = False - return h - -def ailab_body(doc, text): - """Body paragraph — Regular 13pt, relaxed line-height.""" - p = doc.add_paragraph(text) - for run in p.runs: - run.font.name = FONT - run.font.size = Pt(BODY_SIZE) - run.font.color.rgb = RGBColor(*INK) - p.paragraph_format.line_spacing = LH_RELAXED - return p - -def ailab_caption(doc, text): - """Caption / source — small, grey.""" - p = doc.add_paragraph(text) - for run in p.runs: - run.font.name = FONT - run.font.size = Pt(CAPTION_SIZE) - run.font.color.rgb = RGBColor(*GREY_500) - return p - -def ailab_tag(doc, text): - """Silver tag — inline rectangular label.""" - p = doc.add_paragraph() - run = p.add_run(f" {text} ") - run.font.name = FONT - run.font.size = Pt(TAG_SIZE) - run.font.color.rgb = RGBColor(*INK) - run.font.bold = True - # Silver background via shading - from docx.oxml import OxmlElement - shd = OxmlElement('w:shd') - shd.set(qn('w:fill'), 'C1C5D0') - shd.set(qn('w:val'), 'clear') - run._r.get_or_add_rPr().append(shd) - return p - -def ailab_hairline(doc): - """Horizontal hairline rule.""" - p = doc.add_paragraph() - p.paragraph_format.space_before = Pt(SP_3) - p.paragraph_format.space_after = Pt(SP_3) - # Bottom border on paragraph - from docx.oxml import OxmlElement - pPr = p._p.get_or_add_pPr() - pBdr = OxmlElement('w:pBdr') - bottom = OxmlElement('w:bottom') - bottom.set(qn('w:val'), 'single') - bottom.set(qn('w:sz'), '2') # ~0.5pt = hairline - bottom.set(qn('w:space'), '1') - bottom.set(qn('w:color'), 'D8DBE1') - pBdr.append(bottom) - pPr.append(pBdr) - return p - -# ── Document structure example ── - -# Title page block -p = doc.add_paragraph() -run = p.add_run("НАЗВАНИЕ ДОКУМЕНТА") -run.font.name = FONT; run.font.size = Pt(H1_SIZE) -run.font.color.rgb = RGBColor(*INK) -p.alignment = WD_ALIGN_PARAGRAPH.LEFT - -# Subtitle -p = doc.add_paragraph() -run = p.add_run("Тип документа · Проект · Дата") -run.font.name = FONT; run.font.size = Pt(BODY_SIZE) -run.font.color.rgb = RGBColor(*FG_SECONDARY) - -ailab_hairline(doc) - -# Section -ailab_heading(doc, "1. ОБЩИЕ ПОЛОЖЕНИЯ", level=1) -ailab_body(doc, "Настоящий документ определяет порядок...") - -ailab_heading(doc, "2. ТЕХНИЧЕСКИЕ РЕШЕНИЯ", level=1) -ailab_heading(doc, "2.1. Конструктивные решения", level=2) -ailab_body(doc, "Основные несущие конструкции запроектированы...") - -# Source / reference -ailab_hairline(doc) -ailab_caption(doc, "Источник: ГОСТ 34758-2021, СП 118.13330.2022") - -doc.save("document.docx") -``` - -## Правила оформления документов - -### Типографика -- Заголовки: UPPERCASE, Medium (не Bold), Ink Navy -- Тело: Regular 13pt, Ink, межстрочный 1.35–1.5 -- Подписи/сноски: 10pt, Grey 500 -- Выравнивание: ВСЕГДА по левому краю (не по ширине) -- Абзацный отступ: отсутствует (отбивка пустой строкой) - -### Цвета -- Текст: ТОЛЬКО Ink (`#131A2A`) или Grey 500 (`#888888`) -- Акцент: Champagne ТОЛЬКО для ключевых цифр и декоративных заголовков -- Никаких цветных заголовков (синих, красных и т.д.) - -### Структура -- Разделители между секциями — hairline (0.5pt) -- Нумерация разделов: `1. ЗАГОЛОВОК` (цифра + два пробела + UPPERCASE) -- Между разделами — двойной отступ - -### Стиль текста -- Русский язык, деловой стиль -- От третьего лица или безличные конструкции -- Без восклицательных знаков -- Без маркетинговых эпитетов -- Числа с неразрывными пробелами: `6 183 640 792,00 ₽` - -## Антипаттерны - -- 🚫 Times New Roman (только Arial) -- 🚫 Выравнивание по ширине -- 🚫 Цветной текст (кроме ink, grey-500, champagne) -- 🚫 Подчёркивания (кроме ссылок) -- 🚫 Курсив для русского текста -- 🚫 Более 3 уровней заголовков diff --git a/skills/ailab/ailab-pdf/SKILL.md b/skills/ailab/ailab-pdf/SKILL.md index 81d2d3f..9efe1b4 100644 --- a/skills/ailab/ailab-pdf/SKILL.md +++ b/skills/ailab/ailab-pdf/SKILL.md @@ -9,12 +9,16 @@ hermes: tags: [pdf, reportlab, style, cyrillic, ailab] category: ailab - related_skills: [pdf, ailab-docx] + related_skills: [pdf] --- Правила фирменного стиля **AI_Lab_SCRUM Co** для PDF. Общая механика PDF (создание, чтение, формы, объединение) — в штатном скилле `pdf`. +В примерах ниже встречаются имена стилевых констант (`INK`, `BODY_SIZE`, +`CHAMPAGNE`, `HERO_SIZE`, `HAIRLINE`) — **их значения задаёт вызывающий код**: в скилл +они не входят, потому что принадлежат шаблону документа, а не тексту примера. + # AILAB PDF — корпоративные PDF Создание PDF-документов в фирменном стиле проектной организации AI_Lab_SCRUM Co.