Как устроены задачи

Техническая сторона задач: одна таблица, ключи, отношения, бронь и планируемая проверка на дубликаты.

Разберитесь, как хранится задача, откуда берётся её ключ и как инструмент будет находить задачи, которые могут быть одной. Это пригодится разработчикам и авторам агентов, которые читают локальное хранилище или строят что-то поверх задач. Сначала — данные, потом планируемое: ключи по типам, бронь и проверка на дубликаты с разобранным примером. Для повседневной работы начните со страницы Задачи.

Устройство описано в утверждённом плане задач в cli-core (WireCatLabs/cli-core#1216). В каждом разделе сказано, что уже есть, а что планируется.

Данные

Сейчас. Одна таблица, tasks, хранит все задачи локального хранилища. Полный список столбцов — в модели данных.

ЧтоКак хранится
Типquestion, request, mention, promise для задач из сообщений; bug, feature, chore для задач проекта
Планназвание, описание, приоритет от 0 (срочно) до 4 (низкий), срок, время следующей проверки, родительская задача
Состояниеopen, done или dismissed, с тем, кто закрыл и почему
Источникодин указатель на сообщение или заметку, никогда не текст сообщения, поэтому удалённое сообщение ничего не оставляет
Оценкаuseful или not_useful — как вы оценили задачу, поднятую агентом
Людиназначения с ролью: исполнитель, проверяющий, наблюдатель
Историяtask_events: создана, статус, назначена, срок, приоритет, перенесена, привязка, отвязка
Привязкистроки links к сообщениям, чатам, людям, документам, заметкам и адресам, с видом

Каждая задача принадлежит проекту. Задача из сообщения попадает во входящий проект своего аккаунта; задача проекта — в проект, который создали вы. Привязка к сообщению имеет вид related-to, evidence или created-from. Отношение между двумя задачами — тоже строка links, с видом blocked-by, follow-up-of или related-to. Отношения работают только между задачами ваших собственных проектов, и устанавливаете их только вы. Перед записью blocked-by или follow-up-of рекурсивный запрос проверяет, что новая строка не замыкает цикл.

Планируется. Новые поля: category, claimed_by, claimed_until. Задача становится ещё одним типом в существующей таблице taggings, поэтому команды и фильтры tags работают и на задачах. Новые виды отношений: duplicate-of и discovered-from. Две новые полнотекстовые таблицы, task_words и task_stems, устроенные как таблицы заметок. Каждое изменение категории становится строкой task_events вида category.

Идентификаторы и ключи

Сейчас. У задачи проекта есть ключ из ключа проекта и номера: HRB-12. Номер берётся из счётчика в строке проекта, в той же записи, что и вставка задачи:

UPDATE projects SET tasks_count = tasks_count + 1 WHERE id = ? RETURNING key, tasks_count

У задачи из сообщения есть внутренний id (UUID) и ключ из входящего проекта её аккаунта: IN, 8 шестнадцатеричных символов и номер, например IN1A2B3C4D-17. tasks list показывает начало id; tasks show и tasks agenda показывают ключ.

Планируется. Новая задача из сообщения получает вместо ключа входящего проекта читаемый ключ по типу: Q-15 вопрос, REQ-22 просьба, M-7 упоминание, PR-4 обещание. Старые задачи сохраняют ключ IN…. У каждого типа своя строка-счётчик, которая берётся в той же записи, что и вставка, как у проектов. UUID и оба вида ключей находятся как task:<…>. Категория — поле, а не часть ключа, поэтому исправленная категория не ломает ключ, уже процитированный в чате или заметке.

Почему не хеш-идентификаторы. beads использует хеш-идентификаторы вроде bd-a3f8, которые удлиняются с ростом базы (internal/idgen/hash.go). Хеши решают задачу многих писателей на многих копиях, которые потом сливаются. Здесь одно хранилище на компьютер и один писатель за раз, поэтому счётчик даёт короткие ключи, которые человек может прочитать. Если хранилища когда-нибудь начнут синхронизироваться между устройствами, ключи получат префикс устройства; это решение на потом.

Бронь (планируется)

Два агента или агент и вы не должны одновременно работать над одной задачей. tasks claim выполняет один запрос внутри BEGIN IMMEDIATE:

UPDATE tasks SET claimed_by = ?, claimed_until = ?
WHERE id = ? AND status = 'open' AND (claimed_by IS NULL OR claimed_until < ?)
RETURNING id

Если строка не вернулась, задачу держит кто-то другой; ошибка называет кто и до какого времени. SQLite пускает только одного писателя за раз во всех процессах, поэтому бронь атомарна между tg, max, memo и агентами на одном компьютере. Бронь — это аренда: она заканчивается сама (по умолчанию через 30 минут), поэтому упавший агент не держит задачу вечно. tasks release снимает бронь раньше; закрытие задачи снимает её тоже. tasks agenda скрывает задачи, забронированные другими, если не попросить их через --claimed.

Проверка на дубликаты (планируется)

Проверка запускается для каждой новой задачи и для каждого нового сообщения, привязанного к задаче. Она работает в cli-core, на SQLite, без эмбеддингов и без обращения к провайдеру ИИ.

Как новая задача проверяется на дубликаты

Словами: новая задача нормализуется; точное совпадение сразу привязывает её; иначе собираются кандидаты, каждый сравнивается по полям, веса складываются, и сумма решает: создать или спросить. Счёт никогда не объединяет две задачи; решает агент или человек. Каждый ответ сохраняется, чтобы потом подстроить веса.

1. Нормализация

  • Название и описание проходят через нормализатор поиска: нижний регистр, без диакритики, ё становится е, группы цифр склеиваются (15 000 становится 15000).
  • Стеммер Snowball для каждой письменности (русский, английский, испанский) сводит каждое слово к основе; стоп-слова убираются. Получается набор основ.
  • Нормализованное название также режется на символьные триграммы — все окна по три буквы. Они ловят опечатки и формы, которые стеммер пропускает.
  • Шаблоны извлекают подпись: сумму с валютой (число рядом с ₽, руб, €, $, usd), срок (даты и фразы вроде by Friday или до пятницы) и человека (person:<id> автора исходного сообщения, через identities). Агент может передать их сам (--amount, --due, --person).

Основы и нормализованный текст попадают в task_words и task_stems, которые обновляются теми же записями.

2. Точные совпадения

Без подсчёта баллов привязывает только одно совпадение: то же правило на том же сообщении, его держит уникальный индекс по источнику и правилу. Новая задача не создаётся, а в ответе сказано, к какой задаче и почему.

В первом варианте точными считались ещё ответ и то же название. На тестовом наборе они ошиблись 6 раз из 11: ответ с новой просьбой, чужое «+1», то же название для другого дела. Теперь ответ — поле с весом (шаг 4), а то же название — просто высокий балл текста.

3. Кандидаты

Кандидаты — объединение дешёвых выборок:

SELECT task_id FROM task_stems
WHERE task_stems MATCH '<stems joined by OR>'
ORDER BY bm25(task_stems) LIMIT 20

плюс задачи того же человека, той же категории и той же цепочки ответов — обычные запросы по индексам. Учитываются только открытые задачи и задачи, закрытые за последние 14 дней. В объединении не больше примерно 40 задач. bm25() — функция ранжирования SQLite FTS5.

Два правила не дают потерять хороших кандидатов:

  • Один только общий чат никогда не делает кандидата. В чате много несвязанных задач, а одна просьба может прийти и в группу, и в личный чат.
  • Одна выборка никогда не решает в одиночку. Graphiti терял точные дубликаты, потому что брал кандидатов из одного поиска с отсечкой (getzep/graphiti#1734).

4. Сравнение по полям

ПолеСравнениеУровни
ТекстЖаккар (общие элементы, делённые на все различные) по наборам основ названия и описания и по триграммам названия; берётся большее0,6 и выше · от 0,3 до 0,6 · ниже 0,3
Человекperson:<id>тот же · другой · неизвестен
Суммазначение и валютаравны · разные · нет
Категорияназваниета же · другая
Срокразница в днях2 и меньше · больше · нет
Цепочка ответовисходные сообщения отвечают друг на другада · нет
Времядней между задачами3 и меньше · от 4 до 14 · больше

5. Сумма весов

Счёт следует модели Феллеги — Сантера для связывания записей. У каждого уровня есть вес log2(m / u). Здесь m — как часто этот уровень встречается у настоящих дубликатов, а u — у несвязанных задач. Уровень, который у дубликатов обычен, поднимает сумму; уровень, который у них редок, опускает её. Метод объясняет руководство Splink по модели Феллеги — Сантера.

Первые веса заданы вручную, затем подстроены на синтетическом тестовом наборе:

УровеньВес
Текст: 0,6 и выше / от 0,3 до 0,6 / ниже 0,3+5 / +5 / −2
Человек: тот же / другой / неизвестен+1 / −3 / 0
Сумма: равны / разные / нет+6 / −4 / 0
Категория: та же / другая+1 / −1
Срок: 2 дня и меньше / больше / нет+4 / −1 / 0
Цепочка ответов: да / нет+6 / 0
Время: 3 дня и меньше / от 4 до 14 дней / больше+2 / −2 / −3

Ни у одного дубликата в наборе не было другого человека, суммы или категории, поэтому подсчёт дал бы этим уровням −5 или −6 без единого наблюдения. Они сохраняют веса, заданные вручную.

Полосы: 10 и больше — «вероятно», от 5 до 9 — «возможно», меньше 5 — «другая». «Вероятно» только ставит кандидата первым в списке; по счёту ничего не объединяется. Веса и полосы живут в одном модуле, с причиной для каждого числа; больше нигде они не зашиты.

Что показал тестовый набор

В наборе 251 синтетическая задача на русском и английском и 80 пар дубликатов. Используются настоящий bm25() из FTS5 и стеммер cli-core; веса подстроены на одной половине и проверены на другой (WireCatLabs/cli-testing#60).

  • «Возможно» и «вероятно» вместе находят 90% дубликатов. 58% показанных пар — настоящие дубликаты, а 21% новых задач отклоняется до выбора.
  • Одна полоса «вероятно» права в 85% случаев и находит 43% дубликатов.
  • Ни один счёт не безопасен для автоматического объединения. Первый счёт без ошибочных объединений — 16, и он ловит 4% дубликатов. Оставшиеся похожие пары, например подарок маме и подарок папе на ту же сумму с разницей в два дня, отличаются смыслом одного слова, а его не видит ни одно поле.

Ограничения: данные синтетические, данные и оценку писал один автор, дубликаты на разных языках находятся плохо (2 из 5 получили «другая»). Измерений на настоящих чатах пока нет.

6. Решение

ПолосаЗадачу добавляет агентЗадачу открывает правило
Точное (шаг 2)не применяетсяничего нового
Вероятнопоказана первой; агент обязан выбратьв список на проверку
Возможноtasks add отказывает и показывает до 5 кандидатов со счётом и причинами; агент повторяет с --attach <task> или --newв список на проверку
Другаясоздаётсясоздаётся

Инструмент отказывает, а не предупреждает: предупреждение агент может пропустить, код выхода — нет. Спросить агента, который создаёт задачу, не стоит лишнего обращения к провайдеру ИИ: этот агент и так судья. Mem0 устроен так же: агент, который записывает воспоминание, выбирает — добавить его, обновить существующее, удалить или ничего не делать (Mem0, arXiv 2504.19413).

Список на проверку. tasks duplicates показывает пары, которые правило оставило в полосе «вероятно» или «возможно», сначала новые, с причинами. tasks merge <dup> --into <task> привязывает все сообщения дубликата к оставшейся задаче, закрывает дубликат как dismissed с отношением duplicate-of и записывает решение. tasks duplicates reject <dup> <task> записывает «не одно и то же», и пара больше не предлагается.

Объединение можно отменить. tasks unmerge <dup> снова открывает дубликат и убирает отношение. Привязки остаются на месте, пока их не перенесут. Объединение никогда ничего не удаляет.

7. Учёт ответов

Каждое объединение, отказ, --attach и --new после списка кандидатов — размеченная пара. Когда пар достаточно, m и u для каждого уровня пересчитываются по ним — та же оценка подсчётом, что в Splink, без шага обучения, — и полосы сдвигаются. Счёт никогда не объединяет сам. Если настоящие ответы покажут, что пары «вероятно» вы почти всегда принимаете, объединять ли их — решать вам.

Разобранный пример

Иван Пример пишет в личном чате: «Пришлёшь счёт на 15 000 ₽ до пятницы?» Правило открывает просьбу. До её создания проверка находит двух кандидатов от того же человека:

  • REQ-22, создана вчера: «Send the invoice for 15000 ₽» (отправить счёт на 15000 ₽), категория money, без срока.
  • REQ-19, создана 10 дней назад: «Send the contract invoice, 40 000 ₽» (отправить счёт по договору), категория money, без срока.

Названия задач записаны по-английски, поэтому работает английский стеммер. Новая задача названа «Send the invoice for 15 000 ₽ by Friday»; её основы: send, invoic, 15000, friday. В этом примере триграммы дают меньше, чем основы, для обеих пар, поэтому считаются основы.

ПолеREQ-22БаллыREQ-19Баллы
Текстосновы send, invoic, 15000: 3 общих из 4 различных = 0,75+5основы send, contract, invoic, 40000: 2 общих из 6 различных = 0,33+5
Человектот же+1тот же+1
Сумма15000 ₽ = 15000 ₽+615000 ₽ ≠ 40000 ₽−4
Категорияmoney = money+1money = money+1
Срокнет у REQ-220нет у REQ-190
Цепочка ответовнет0нет0
Время1 день+210 дней−2
Итого15: вероятно1: другая

Просьбу нашло правило, поэтому инструмент создаёт её как REQ-23 и кладёт пару REQ-23 и REQ-22 в список на проверку. По счёту ничего не объединяется: агент или вы проверяете пару и запускаете tasks merge REQ-23 --into REQ-22. Если бы задачу добавлял агент, tasks add отказал бы, показал REQ-22 первой с 15 баллами и причиной «тот же человек, та же сумма, общих основ 3 из 4», и агент повторил бы команду с --attach REQ-22. У REQ-19 тот же человек и те же слова «отправить счёт», но сумма и давность тянут её вниз: это другое дело.

Сравнение с beads

beads — трекер задач для агентов-программистов. Часть его идей взята сюда, часть — нет.

beadsЗдесь
Хеш-идентификаторы bd-a3f8Читаемые ключи из счётчика: одно хранилище, один писатель за раз
Метки для всего, что не полеОдна категория из фиксированного списка плюс существующие теги
Виды связей, например duplicates и discovered-fromduplicate-of и discovered-from добавляются к видам отношений
bd ready: открытая работа без блокировокtasks agenda; фильтры по категории и тегу планируются
bd update --claim: атомарный захватtasks claim — аренда, которая заканчивается сама
external_ref на один внешний объектКаждая задача привязана ко всем своим сообщениям
bd duplicates объединяет точные копии; bd find-duplicates считает совпадение слов и только сообщаетПроверка до создания, веса полей и выбор агента или список на проверку

Текст в чатах никогда не бывает точной копией, и ничто в beads не проверяет задачу до создания. Поэтому проверка здесь работает по полям и спрашивает, когда не уверена.

Похожие работы

Дубликаты отчётов об ошибках — старая задача. REP сравнивает два отчёта по тексту и по полям вроде продукта и компонента, с весами, подобранными на известных дубликатах (Sun et al., ASE 2011). Проверка здесь использует ту же идею — текст плюс поля, — но веса считаются, а не обучаются.

Чего нет

  • Ни эмбеддингов, ни обращения к провайдеру ИИ для сравнения задач. Если тестовый набор покажет, что текстовое поле слишком слабое, следующий шаг — локальный эмбеддинг, измеренный против этой проверки.
  • Никаких удалений. Дубликат закрывается и привязывается; его никогда не удаляют.
  • Никакого ограничения «одна задача на чат или на человека».
  • Никаких хеш-идентификаторов, пока хранилища не синхронизируются между устройствами.
  • Открытые вопросы: список категорий по умолчанию и что делать с сообщением, похожим на задачу, закрытую за последние 14 дней: снова открыть её или открыть новую со ссылкой follow-up-of.