8f7664eb04
The researcher now reuses the current/already-open document only when the user explicitly asked for it, OR the page is empty/near-empty AND its title matches the research topic; otherwise it creates a new document. - Rewrite the reuse rule in the WHERE TO WRITE THE RESULT section - Reword "Create this document" -> "Set up this document" so it fits both the create-new and reuse-current-empty cases - Apply identically to bundles/research/ru.yaml and en.yaml - Bump researcher version 3 -> 4 in index.yaml; refresh content-hashes.json Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
521 lines
50 KiB
Markdown
521 lines
50 KiB
Markdown
# Фича «Время работы над статьёй» — дизайн-документ
|
|
|
|
Статус: черновик проектирования (код не пишется).
|
|
Контекст: gitmost (форк Docmost). Зависит от PR #370 / PR #374 (типизированная история страниц).
|
|
|
|
## 1. Цель и не-цели
|
|
|
|
**Цель.** Показывать в UI страницы одно число — оценку времени, реально затраченного
|
|
на работу над статьёй, собранную из истории правок. Число должно быть устойчиво к
|
|
паузам: «в 21:00 одна правка, в 09:00 вторая» не должно превращаться в «работал 12 часов».
|
|
По клику на число — раскрытие в **суточный таймлайн**: строка-день = 24-часовая дорожка с
|
|
окнами активности и суммой за день (см. §6.2).
|
|
|
|
**Явно не-цели.**
|
|
- Это НЕ инструмент для агента (не MCP-tool). Это число, отображаемое человеку в UI.
|
|
- Не хронометраж с точностью до минуты — это заведомо оценка.
|
|
- Не биллинг/тайм-трекинг сотрудников; не изменение долговечности черновика.
|
|
|
|
## 2. Проблема
|
|
|
|
История страницы в Docmost — таблица `page_history`: снимок на каждое сохранение.
|
|
У снимка есть `createdAt`, автор (`lastUpdatedById`), тип источника
|
|
(`lastUpdatedSource`: `user`/`agent`/`git`) и группировка агентских правок
|
|
(`lastUpdatedAiChatId`).
|
|
|
|
Наивная оценка `max(createdAt) − min(createdAt)` завышает в разы: между крайними
|
|
правками лежат сон, обед, дни простоя. На реальных данных статьи-примера
|
|
(первая страница истории, 20 снимков) span между крайними снимками ≈ 60 часов,
|
|
тогда как реальной работы — пара часов в 5–6 коротких заходов.
|
|
|
|
Правильная постановка: **длительность — это не span между крайними правками, а сумма
|
|
интервалов внутри «сессий»; историю режем по паузам бездействия.** Это классическая
|
|
*сессионизация по таймауту неактивности* (WakaTime / RescueTime / веб-аналитика).
|
|
|
|
## 3. Почему PR #374 — фундамент фичи
|
|
|
|
До #374 у сессионизации слепое пятно: если человек час пишет подряд и не жмёт «Сохранить»,
|
|
в `page_history` за этот час почти нет строк (тяжёлые автосейвы ydoc идут в `pages`/`ydoc`,
|
|
а не в историю). Непрерывную работу нечем измерить.
|
|
|
|
PR #374 вводит типизированную историю через колонку `page_history.kind`:
|
|
|
|
| `kind` | что означает | ценность для фичи |
|
|
|------------|------------------------------------------------|----------------------------------------------------|
|
|
| `manual` | человек нажал Save | сильный маркер активной работы человека |
|
|
| `agent` | снимок правки агентом | машинное время агента (группируется по `aiChatId`) |
|
|
| `idle` | автоснимок idle-флеша (потолок ~`maxWait`) | регулярный «пульс» непрерывной работы (~≤10м, §3) |
|
|
| `boundary` | автоснимок на переходе актора (user↔agent↔git) | бесплатная разметка «кто работал в этом сегменте» |
|
|
| `null` | легаси-автосейв (старые страницы) | обычный сэмпл активности |
|
|
|
|
Два env-параметра #374 напрямую задают качество измерения:
|
|
|
|
- **`IDLE_MAX_WAIT_USER=10м` / `AGENT=5м` (потолок ожидания)** — определяющий параметр.
|
|
Проверено по `computeHistoryJob` (`persistence.extension.ts`):
|
|
`delay = max(0, min(interval, burstStart + maxWait − now))`, а `enqueuePageHistory`
|
|
сбрасывает `burstStart` каждые `maxWait`. Так как `maxWait` (10м/5м) < `interval` (60м/15м),
|
|
**потолок всегда доминирует**: во время непрерывной работы `idle`-снимок форсится каждые
|
|
~`maxWait`, давая регулярный «пульс активности» с шагом ≤10 мин (user) / ≤5 мин (agent).
|
|
- **`IDLE_INTERVAL_USER=60м` / `AGENT=15м`** — номинальный трейлинг-интервал, который потолок
|
|
ожидания на практике всегда упреждает. Поэтому метка любого `idle`-снимка отстоит от
|
|
реальной правки не более чем на ~`maxWait` (≤10м user / ≤5м agent), а **не** на 60 мин.
|
|
(Это ключевой факт для точности: `idle`-метки — достоверный сигнал активности, не «хвост».)
|
|
|
|
Вывод: ядро алгоритма работает и на голых `createdAt`+`lastUpdatedSource` (есть с миграции
|
|
`20260616T130000-agent-provenance`), поэтому фича считает и на легаси-страницах — просто
|
|
грубее. После #374 (пульс ≤10 мин + типы) — точно. Жёсткой блокировки на мёрж #374 нет,
|
|
но полноценная точность появляется вместе с ним.
|
|
|
|
## 4. Входной сигнал
|
|
|
|
Дешёвый проекционный запрос по `page_history` (без тяжёлой колонки `content`): на строку —
|
|
`createdAt`, `lastUpdatedById`, `lastUpdatedSource`, `lastUpdatedAiChatId`, `kind`.
|
|
Все поля (включая `kind`) уже в `PageHistoryRepo.baseFields` после #374 — схему трогать не
|
|
нужно, `findTimelineByPageId` это лишь лёгкая проекция без `content`.
|
|
|
|
**Важный факт об авторстве (проверено по `persistence.extension.ts` → `updatePage`):**
|
|
`page_history.lastUpdatedById` — это ВСЕГДА ответственный человек, даже для агентских снимков
|
|
(«human stays the responsible author»). Признак «человек vs агент» живёт в
|
|
`lastUpdatedSource` (`user`/`agent`/`git`) и `lastUpdatedAiChatId`, а НЕ в `lastUpdatedById`.
|
|
Отсюда: разделять человеко-/агенто-время нужно по `lastUpdatedSource`, а атрибутировать
|
|
конкретному человеку — по `lastUpdatedById`/`contributorIds`.
|
|
|
|
## 5. Алгоритм: сессионизация по паузам
|
|
|
|
Параметры (env, в стиле #374; полный список — §10):
|
|
|
|
- **`T_gap`** — таймаут неактивности: пауза между соседними сэмплами `≤ T_gap` = непрерывная
|
|
работа, больше = перерыв (в зачёт не идёт).
|
|
- **`P_in` / `P_out`** — добивка МНОГОсэмпловой сессии (работа началась до первого и продолжалась
|
|
после последнего сохранения).
|
|
- **`P_single`** — блок ОДИНОЧНОЙ сессии (один сэмпл, соседей в пределах `T_gap` нет). Мал
|
|
(дефолт ~2 мин): один автосейв/idle-пульс — это «была правка», но приписывать ему полный
|
|
`P_in+P_out` = выдумывать время. НЕ путать с многосэмпловой добивкой.
|
|
|
|
Псевдокод (ОДИН проход по ВСЕМ сэмплам страницы; класс определяется у ГОТОВОЙ сессии — §5.1):
|
|
|
|
```text
|
|
samples = ВСЕ history rows страницы, projected, sorted by createdAt ASC
|
|
(все типы kind — сэмплы активности; idle — основной «пульс» непрерывной работы §3, НЕ исключается)
|
|
|
|
# коллапс агентских всплесков (§5.1)
|
|
collapse: подряд идущие сэмплы ОДНОГО aiChatId с source=agent → сегмент {t_start, t_end, source:'agent'}.
|
|
Разрывает всплеск любой сэмпл, НЕ продолжающий тот же aiChatId-агент: source≠agent,
|
|
boundary-переход, ИЛИ иной aiChatId — в т.ч. idle/boundary с ДРУГИМ aiChatId = НОВЫЙ ран,
|
|
склеивать НЕЛЬЗЯ (иначе простой между двумя ИИ-ранами засчитается агенту). idle с ТЕМ ЖЕ
|
|
(или null) aiChatId — продолжает сегмент. Дальше сегмент участвует в цикле как один «сэмпл» с
|
|
.t_start/.t_end/.source (source='agent'); у скалярного сэмпла t_start == t_end == t.
|
|
|
|
gap_threshold(a, b) = (a и b оба source=agent) ? agentTGap : T_gap # порог зависит от ПАРЫ (§10)
|
|
|
|
# сессионизация по паузам — ОДИН проход по ВСЕМ сэмплам (не по парам, не отдельно по классам)
|
|
sessions = []; cur = null
|
|
for s in samples: # s — скаляр или коллапс-сегмент
|
|
if cur == null: cur = { first: s, last: s, samples: [s] }
|
|
elif s.t_start − cur.last.t_end ≤ gap_threshold(cur.last, s):
|
|
cur.last = s; cur.samples.push(s) # непрерывная работа
|
|
else: sessions.push(cur); cur = { first: s, last: s, samples: [s] }
|
|
if cur != null: sessions.push(cur) # ОБЯЗАТЕЛЬНО закрыть последнюю (иначе теряется)
|
|
|
|
# класс и интервал каждой сессии
|
|
for sess in sessions:
|
|
sess.class = sess.samples.every(is_agent) ? 'agent_only' : 'work' # §5.1 (по source сэмплов)
|
|
sess.iv = (sess.first == sess.last && sess.first.t_start == sess.first.t_end)
|
|
? [ sess.t − P_single, sess.t ] # одиночный скаляр: pre-roll (без «будущей» работы)
|
|
: [ sess.first.t_start − P_in, sess.last.t_end + P_out ] # многосэмпловая / лон-сегмент
|
|
|
|
workMs = duration( union( sess.iv : sess.class=='work' ) ) # union внутри класса
|
|
agentOnlyMs = duration( union( sess.iv : sess.class=='agent_only' ) )
|
|
```
|
|
|
|
Ключевые свойства:
|
|
|
|
- **Один проход, потом классификация → метрики дизъюнктны.** Сессии не перекрываются (разделены
|
|
гэпами > порога), каждая — ровно одного класса. Человек и агент, правящие ОДНОВРЕМЕННО, попадают
|
|
в одну сессию класса `work` (надзор засчитан человеку, не дважды). `work`- и `agent_only`-интервалы
|
|
не пересекаются по wall-clock и `workMs + agentOnlyMs ≤ реально прошедшего` — **при рекомендованном
|
|
`T_gap ≥ P_in+P_out` (§10)**: иначе `P`-добивка соседних сессий РАЗНЫХ классов может пересечься, и
|
|
пересечение попадёт в обе метрики. Инвариант §6.3 (`Σ perDay == work`) от этого НЕ зависит.
|
|
- **Закрытие последней сессии обязательно** (иначе теряется самая свежая): `n=1` → одна одиночная
|
|
сессия `P_single`, `n=0` → 0.
|
|
- **`union`, а не `Σ`.** Перекрытия (соседние ближе `P`; одновременное соредактирование) не
|
|
задваиваются. Отсюда `Σ perDay == work` держится сам собой (§6.3) — без «клампа» и без условия
|
|
`T_gap ≥ P`.
|
|
- **Калибровка `T_gap` по «пульсу» #374 (важно).** После #374 непрерывная работа гарантированно
|
|
оставляет history-строку не реже ~`maxWait` (idle-пульс §3, гейт `isDeepStrictEqual`). Значит гэп
|
|
между соседними строками БОЛЬШЕ ~`maxWait` содержит участок без изменений контента = (частичное)
|
|
бездействие — даже между двумя `manual`. Поэтому `T_gap` калибруется ОТ `maxWait` (реком. ~15м
|
|
user / ~7м agent), а не ставится вольно: прежние «30 мин» переоценивали не-пульсирующие паузы.
|
|
Гэп `≤ T_gap ≈ maxWait` ПОДКРЕПЛЁН пульсом — это и оправдывает счёт его как работы. Легаси-страницы
|
|
до #374 пульса не имеют → там `T_gap` вынужденно шире и оценка грубее (§7, §10).
|
|
- **Всё равно оценка, а не строгая граница**: даже с пульсом «читал/думал 12 мин не печатая» не
|
|
отличить от «отошёл»; подпись «≈» и показ `T_gap` обязательны (§6).
|
|
- Почему интервалы, а не «правок × блок»: «снимок = +N мин» ломается на агентских всплесках
|
|
(8 снимков за 7 минут); длина сегмента всплеска = его wall-clock, независимо от плотности снимков.
|
|
|
|
### 5.1. Классификация сэмплов и всплесков
|
|
|
|
- **Класс сэмпла (человек / агент)** — по `lastUpdatedSource`: `user`→человек, `agent`→агент,
|
|
`git`→исключаем (§10 `excludeGit`), legacy-`null`→человек. `idle` наследует `source` страницы
|
|
на момент флеша (= source последней правки). `boundary` несёт СТАРЫЙ (pre-transition) `source`
|
|
— трактуем как есть: он маркирует исходящую работу того актора (это осознанный компромисс, а не
|
|
недосмотр — точность посекундная тут не нужна).
|
|
- **Класс СЕССИИ** (нужен для метрик §6.1): сессия, где ВСЕ сэмплы `source=agent` → **`agent_only`**
|
|
(автономный прогон, не в основную метрику). Сессия хотя бы с одним человеческим сэмплом →
|
|
**`work`** (человек + надзор за агентом ВНУТРИ сессии засчитывается человеку — по union'у, §5).
|
|
- **`idle`** — полноценный сэмпл активности и основной «пульс» непрерывной работы (§3): его метка
|
|
отстаёт от реальной правки ≤ `maxWait` (≤10м) — в пределах округления, отмотки не требует.
|
|
Исключать нельзя: без него непрерывное письмо без ручных сохранений снова стало бы невидимым.
|
|
|
|
## 6. Что показывать в UI
|
|
|
|
### 6.1. Свёрнутое состояние — одно число
|
|
|
|
Две метрики, каждая = union-wall-clock СВОИХ сессий (§5):
|
|
|
|
- **`work` — headline, кликабельное число** (`≈ 4 ч 30 мин`, рядом с панелью истории или в
|
|
мета-инфо у заголовка): сессии класса `work` (≥1 человеческий сэмпл = человек + надзор за
|
|
агентом внутри сессии). Именно это открывает таймлайн (§6.2), и именно к нему сходится сумма по
|
|
дням.
|
|
- **`agent_only` — вторично**: сессии автономных прогонов агента (ни одного человеческого сэмпла).
|
|
На таймлайне — отдельным цветом; в `work` НЕ входит.
|
|
- Подпись «≈» и показ `T_gap` обязательны (оценка, не хронометраж — §5). Округление headline —
|
|
шаг 5–15 мин (точность оценки не оправдывает «4 ч 27 мин»).
|
|
|
|
### 6.2. Раскрытие по клику — суточный таймлайн (24 ч × дни)
|
|
|
|
Клик по числу открывает модалку/поповер с таймлайном по типу «punch-card»:
|
|
|
|
- **Строка = один календарный день**; ширина строки = 24 часа (фиксированная шкала 00:00→24:00).
|
|
- На дорожке дня закрашены **окна активности** — реальные интервалы работы в их часовом
|
|
положении, так что видно «вечерний марафон» vs «утренняя сессия».
|
|
- **Справа от строки — сумма за день** «ч мм» (напр. `3 ч 17 м`); пустой день — пустая дорожка
|
|
и «—».
|
|
- Внизу — общий итог (= headline `work` §6.1) плюс подпись таймзоны и `T_gap`.
|
|
|
|
Это графическая версия таблицы-примера-картинки: там окна были текстом («18:46 → 00:58»), здесь
|
|
то же рисуется отрезками на 24-часовой дорожке.
|
|
|
|
Детали:
|
|
- Окна = сессии класса `work` (§5), обрезанные по границам суток; сессии `agent_only` — отдельным
|
|
цветом (§6.1). По умолчанию `work` — один цвет «активность».
|
|
- Сессия через полночь рисуется как отрезок до 24:00 в одном дне и продолжение с 00:00 в
|
|
следующем — тот самый полуночный разрез (§6.3), теперь визуально очевиден.
|
|
- **Честность добивки.** Окно включает `P`/`P_single` — это оценка «до/после», а не измеренный
|
|
интервал. Одиночная сессия (`P_single`) рисуется минимальной видимой шириной и приглушённо
|
|
(иначе исчезает и/или создаёт иллюзию плотной работы из одного клика). Общая подпись — «≈».
|
|
|
|
### 6.3. Агрегация по дням (алгоритм)
|
|
|
|
Вход — ВСЕ сессии из §5 (`work` и `agent_only`), каждая развёрнута в интервал (`P_in/P_out` или
|
|
`P_single`).
|
|
|
|
```text
|
|
bucketByDay(sessions, tz):
|
|
U_work = union( sess.iv : sess.class=='work' ) # снимаем перекрытия ОДИН раз (§5)
|
|
U_agent = union( sess.iv : sess.class=='agent_only' ) # СИММЕТРИЧНО — иначе окна агента наложатся
|
|
для каждого дня D в [первый_день … последний_день] по tz (dayjs+tz, startOf('day')):
|
|
workWin[D] = { u ∩ [начало D, начало D+1) : непусто, u в U_work }
|
|
agentWin[D] = { u ∩ [начало D, начало D+1) : непусто, u в U_agent }
|
|
activeMs[D] = Σ длительностей workWin[D] # U_work без перекрытий → просто сумма
|
|
agentMs[D] = Σ длительностей agentWin[D]
|
|
→ perDay[] = [{ day, activeMs, agentMs, windows: (workWin[D] ⊕ agentWin[D]) с меткой класса }]
|
|
```
|
|
|
|
- **Инвариант согласованности `Σ activeMs[D] == work`** держится ПО ПОСТРОЕНИЮ: день — это
|
|
разбиение union'а `U_work` границами суток, ничего не теряется и не дублируется (в т.ч. на
|
|
23/25-часовых DST-сутках — §9#14). `agent_only`-окна рисуются, но в `activeMs` НЕ входят. Клампа
|
|
и скрытого условия `T_gap ≥ P` не требуется (в отличие от наивного `Σ` длительностей).
|
|
- **Таймзона `TZ`** определяет, где проходит «полночь» И в каких часовых координатах рисуются
|
|
окна. Дефолт — таймзона зрителя (локаль браузера); альтернатива — UTC (как на картинке-примере
|
|
«По дням (UTC)») или tz воркспейса. Влияет на раскладку по дням и положение окон, но НЕ на
|
|
общий итог → настройка (§10).
|
|
- **Длинный диапазон** (месяцы правок): строк ровно столько, сколько календарных дней в диапазоне.
|
|
При большом числе дней — вертикальный скролл + сворачивание длинных серий пустых дней
|
|
(«× N дней без правок») и/или переключение на понедельную группировку. Порог — настройка.
|
|
- **Округление дисплея:** сумму за день округляем до минут для подписи, но общий итог берём из
|
|
точных значений (иначе Σ округлённых по дням разойдётся с округлённым числом на ±1–2 мин).
|
|
|
|
## 7. Проверка на реальной статье
|
|
|
|
Ручной прогон на 20 снимках (1-я страница истории, `T_gap=30 мин`, `P_in+P_out=10 мин`,
|
|
`P_single=2 мин`; все сессии здесь класса `work` — в каждой есть человеческий сэмпл):
|
|
|
|
| Сессия | Интервал | Длит. |
|
|
|--------|-------------------------------------------------------|----------|
|
|
| S1 | 07-04 03:40 → 03:49 (многосэмпловая) | ≈19 мин |
|
|
| S2 | 07-04 15:43 → 16:13 (агент 15:43–15:50 → человек 16:13)| ≈41 мин |
|
|
| S3 | 07-04 18:11 (одиночная) | ≈2 мин |
|
|
| S4 | 07-04 19:38 → 19:54 (многосэмпловая) | ≈26 мин |
|
|
| S5 | 07-06 15:34 (одиночная) | ≈2 мин |
|
|
| S6 | 07-06 16:18 (одиночная, закрыта пост-циклом §5) | ≈2 мин |
|
|
| **Итого** | | **≈1 ч 32 мин** |
|
|
|
|
Наивно на том же срезе — ≈60 часов. Разница — весь смысл фичи.
|
|
|
|
Наблюдения:
|
|
- Разрезы легли по перерывам: ночь `03:49 → 15:43` (~12 ч) и сутки
|
|
`07-04 19:54 → 07-06 15:34` — оба выброшены.
|
|
- Чувствительность к порогу: `S5/S6` отстоят на 44 мин. При `T_gap=30` это две сессии,
|
|
при `T_gap=60` — одна (~44 мин). Число надо показывать **вместе с использованным порогом**.
|
|
- Это **оценка, не строгая граница** (§5), и НАПРАВЛЕНИЕ ошибки зависит от данных. Сохранения
|
|
ВНУТРИ `T_gap` мостятся, и вся пауза между ними засчитывается → на периодичном «фоновом» ритме
|
|
(напр. автосейв раз в ~`T_gap` при почти полном простое) возможен КРАТНЫЙ перебор (в разы), а не
|
|
«±порог». Сохранения ДАЛЬШЕ `T_gap` друг от друга, наоборот, теряют между-время → недобор. Поэтому
|
|
`T_gap` калибруется по пульсу #374 (§5, §10): после #374 «фоновый» ритм даёт гэпы > `maxWait` и
|
|
рвётся на перерывы, срезая кратный перебор; на легаси (пульса нет) оценка грубее в обе стороны.
|
|
Пример иллюстративен, посчитан на до-#374 срезе при `T_gap=30`.
|
|
|
|
## 8. Архитектура (куда встраивать)
|
|
|
|
**Ядро — чистая функция** `computeWorkTime(rows, config)` (детерминированная, без БД) в
|
|
отдельном модуле → легко покрыть юнит-тестами. Выход —
|
|
`{ workMs, agentOnlyMs, sessions[] }`, где `session = { start, end, class: 'work'|'agent_only' }`:
|
|
абсолютные границы (с добивкой `P`/`P_single`) плюс класс (§5.1) — этого достаточно и для метрик,
|
|
и для цвета окна. `workMs`/`agentOnlyMs` считаются как union-wall-clock сессий своего класса (§5).
|
|
|
|
**Вторая чистая функция** `bucketByDay(sessions, tz)` (ВСЕ сессии обоих классов) →
|
|
`perDay[] = [{ day, activeMs, agentMs, windows }]`: `activeMs` = длительность `work`-окон за день
|
|
(сходится к `work`, §6.3), `agentMs` = то же для `agent_only` (для подписи машинного времени за
|
|
сутки), `windows` — интервалы ОБОИХ классов, обрезанные по суткам и помеченные классом (для
|
|
отрисовки `work`/`agent_only` разным цветом, §6.2). Полуночный разрез — календарный, через
|
|
`dayjs` + tz-плагин (`startOf('day')` в `tz`), НЕ «+24 ч» (§9#14); `dayjs` уже в проекте. Отдельная
|
|
тестируемая функция (общий модуль сервера и клиента); `tz` — презентационный параметр, удобно звать
|
|
на клиенте от локали зрителя.
|
|
|
|
**Сервер:**
|
|
- `page-history.repo.ts` — метод `findTimelineByPageId(pageId)`: лёгкая проекция
|
|
(`createdAt, lastUpdatedById, lastUpdatedSource, lastUpdatedAiChatId, kind`) по всем строкам
|
|
ASC, без `content`.
|
|
- `page-history.service.ts` — `computeWorkTime(pageId, config)`: тянет таймлайн, зовёт ядро,
|
|
кэширует.
|
|
- `page.controller.ts` — рядом с `POST /history` и `POST /history/info` добавить
|
|
`POST /history/time` (или вложить в page-info). Отдаёт число + разбивку по сессиям.
|
|
|
|
**Клиент:**
|
|
- `page-history-query.ts` — хук `usePageWorkTime(pageId)` (возвращает `workMs`, `agentOnlyMs`,
|
|
`sessions[]`).
|
|
- Рендер кликабельного числа в панели истории.
|
|
- Модалка/поповер с суточным таймлайном (§6.2): `bucketByDay(sessions, viewerTz)` → строки-дни,
|
|
в каждой — 24-часовая дорожка с окнами. Это НЕ bar chart: рисуется кастомными CSS/SVG-отрезками
|
|
на 24-часовой шкале (позиция окна = `startOfDayOffset/24ч`, ширина = длительность/24ч). Готовые
|
|
чарт-библиотеки под это плохо ложатся — брать лёгкую собственную вёрстку, без новых тяжёлых
|
|
зависимостей. Пустые дни — пустая дорожка + «—».
|
|
|
|
**Производительность:** проекция без `content` дёшева; результат можно инкрементально кэшировать
|
|
(при `version.saved`, который #374 броадкастит, пересчитывать хвост).
|
|
|
|
## 9. Крайние случаи
|
|
|
|
1. Один снимок → одна одиночная сессия `P_single`, не ноль. Последняя сессия ВСЕГДА закрывается
|
|
пост-циклом (§5) — иначе теряется самая свежая.
|
|
2. История целиком агентская → `work = 0`, `agent_only = union прогонов`.
|
|
3. Плотный агентский всплеск → длина сегмента = его wall-clock (не зависит от числа снимков);
|
|
опц. кап `burstCapMs`.
|
|
4. Метка `idle` лагает ≤ `maxWait` (10м user / 5м agent) — в пределах округления; это
|
|
полноценный сэмпл активности, спец-обработки не требует (см. §3, §5.1).
|
|
5. Несколько соавторов → атрибуция человеку по `lastUpdatedById`/`contributorIds`; разделение
|
|
человек/агент — по `lastUpdatedSource` (НЕ по `lastUpdatedById`, он всегда человек).
|
|
6. Легаси `kind=null` → работает на `source`+`createdAt`, грубее.
|
|
7. Совпадающие метки времени (boundary+agent в один момент; в коде есть tie-break по `id`)
|
|
→ дедуп по округлённому `t`.
|
|
8. Одновременное соредактирование → `union` (§5) убирает двойной счёт wall-clock при перекрытии
|
|
окон; персональные человеко-часы (разбивка по авторам) — отдельный опциональный режим
|
|
(`perAuthor`), а не поведение по умолчанию.
|
|
9. Сессия через полночь (напр. `23:14 → 02:00`) → режется на границе суток `tz`, части идут
|
|
в разные дни; сумма по дням = общему числу (§6.3).
|
|
10. Выбор таймзоны дня меняет раскладку по дням (та же работа попадёт в другой день) — общий
|
|
итог не меняется; `tz` фиксируется в подписи графика.
|
|
11. Длинный диапазон правок (месяцы) → строк = число дней: вертикальный скролл + сворачивание
|
|
длинных серий пустых дней и/или понедельная группировка по порогу (§6.3).
|
|
12. День без правок → пустая 24-часовая дорожка + «—» в диапазоне (показываем ритм/паузы),
|
|
не пропускаем.
|
|
13. Очень короткое окно на 24-часовой шкале → рисуем минимальной видимой шириной, чтобы не
|
|
исчезало (§6.2).
|
|
14. Переход на летнее/зимнее время внутри `tz` → сутки в 23/25 ч; полуночный разрез считать
|
|
по календарю `tz` (`dayjs`+tz, §8), а не «+24 ч». Инвариант `Σ activeMs == work` держится
|
|
(разбиение union'а). Редкое исключение — tz с DST-переходом РОВНО в полночь (`startOf('day')`
|
|
неоднозначен) → до 1 ч может протечь в соседний день. Фиксированная 24-часовая дорожка (§6.2)
|
|
на 23/25-часовых сутках смещает окна визуально до ~1 ч — сознательное упрощение, на итог не
|
|
влияет.
|
|
|
|
## 10. Параметры по умолчанию и открытые решения
|
|
|
|
**Полный `config`** (env, дефолты):
|
|
|
|
- `T_gap≈15м` — калибровка по `IDLE_MAX_WAIT_USER=10м` + запас (§5: гэп больше `maxWait` не
|
|
подкреплён пульсом = бездействие; прежние «30м» переоценивали не-пульсирующие паузы).
|
|
- `agentTGap≈7м` — порог для ПАРЫ подряд идущих агентских сэмплов (`IDLE_MAX_WAIT_AGENT=5м` + запас).
|
|
- `P_in=5м`, `P_out=5м`, `P_single=2м` (одиночная — pre-roll, §5).
|
|
- `burstCapMs` (опц. кап на сегмент всплеска, §9#3), `dedupRoundMs` (дедуп совпадающих меток, §9#7),
|
|
`excludeGit=true`, `tz=локаль-зрителя`, `longRangeDayThreshold` (день→неделя, §6.3),
|
|
`perAuthor=false` (§9#8).
|
|
- **Легаси-страницы до #374** (нет пульса) → `T_gap` вынужденно шире, оценка там грубее.
|
|
- **`T_gap ≥ P_in+P_out`** НЕ требуется для инварианта §6.3 (union), но РЕКОМЕНДУЕТСЯ и валидируется
|
|
— иначе `work`/`agent_only` могут перекрыться в сумме (§5). При дефолтах (15 ≥ 10) держится.
|
|
|
|
Развилки (настройки, не блокируют проектирование):
|
|
- `work` vs `agent_only` — показывать оба, крупно `work` (headline), `agent_only` вторично;
|
|
- точное место в UI — панель истории (основное) или мета-строка страницы;
|
|
- дефолт `T_gap` — вынести в env (как `IDLE_*` в #374), калибровать на реальных статьях
|
|
после мёржа #374;
|
|
- **таймзона дня для графика (§6.2/§6.3)** — рекомендую локаль зрителя (интуитивно «мои
|
|
вечера»); альтернативы — UTC (как на картинке-примере) или tz воркспейса. Влияет только на
|
|
раскладку по дням, не на общий итог;
|
|
- **порог перехода день→неделя/месяц** для длинного диапазона правок.
|
|
|
|
---
|
|
|
|
# Приложение: Review Ledger (рабочий аппарат, НЕ нормативная часть)
|
|
|
|
> Аппарат adversarial-review-loop. Перед выдачей реализатору выносится/сворачивается.
|
|
> Критикам: НЕ перелитигировать закрытые findings, КРОМЕ случая, когда сам RESOLUTION
|
|
> дефектен — тогда атаковать его явно и сказать, почему предыдущий раунд ошибся.
|
|
|
|
## CONFIG
|
|
|
|
- EXTERNAL_MODEL: endpoint `https://api.z.ai/api/coding/paas/v4`, model `glm-5.2`
|
|
(ключ хранится вне репозитория, в логи не пишется). Роль: cold-reader gate (Phase 4.5),
|
|
tiebreaker при эскалации.
|
|
- Артефакт: `docs/features/page-work-time_design.md`.
|
|
- Целевой класс: спец для реализации другим человеком → план 2–3 итерации, до пустого
|
|
cold-reader gate (не по числу итераций).
|
|
|
|
## FACT BASE (проверено по PR #374 @ commit 924f8aa, ветка feat/370-page-versioning)
|
|
|
|
Верификация автором ДО цикла (ветка не в рабочем дереве — критики не видят её локально;
|
|
факты ниже — ground truth, можно дозапросить файлы через gitea MCP по указанному SHA):
|
|
|
|
- `page_history.kind` — `varchar(20)`, NULLABLE, БЕЗ дефолта (migration
|
|
`20260705T120000-page-history-kind.ts`). Домен: `manual`/`agent`/`idle`/`boundary`;
|
|
legacy `null` = автосейв (`collaboration/constants.ts`, `PageHistoryKind`).
|
|
- `kind` УЖЕ включён в `PageHistoryRepo.baseFields` (`page-history.repo.ts`) — читается всеми
|
|
выборками истории. `saveHistory({kind})` и `updateHistoryKind(id, kind)` существуют.
|
|
- Тайминги (`constants.ts`): `IDLE_INTERVAL_USER=60м`/`AGENT=15м`;
|
|
`IDLE_MAX_WAIT_USER=10м`/`AGENT=5м`.
|
|
- `computeHistoryJob` (`persistence.extension.ts`):
|
|
`delay = max(0, min(interval, burstStart + maxWait − now))`; `enqueuePageHistory` сбрасывает
|
|
`burstStart` каждые `maxWait`. Следствие: **потолок всегда доминирует** → `idle` пульсирует
|
|
каждые ~`maxWait` при непрерывной работе; метка `idle` отстоит от правки ≤ `maxWait`, НЕ 60м.
|
|
- Идл-джоб всегда ставится с `kind:'idle'`; процессор пишет `job.data.kind ?? 'idle'`, но только
|
|
если контент изменился (`isDeepStrictEqual`-гейт).
|
|
- `boundary`-снимок пишется СИНХРОННО в store-транзакции на смене `lastUpdatedSource`
|
|
(user↔agent↔git), фиксирует ИСХОДЯЩИЙ (pre-transition) контент со СТАРЫМ source; его
|
|
`createdAt` = момент перехода (точный).
|
|
- `manual`/`agent` — явный save-version по stateless-каналу; `kind` выводится из
|
|
`context.actor` СЕРВЕРНО (неподделываемо). Promote-not-dup: апгрейд `kind` последнего снимка
|
|
на месте вместо дубля.
|
|
- `page_history.lastUpdatedById` = ВСЕГДА ответственный человек (даже для агентских снимков);
|
|
«agentness» — в `lastUpdatedSource` + `lastUpdatedAiChatId`.
|
|
- REST истории: `POST /history`, `POST /history/info` (`page.controller.ts`). Клиент:
|
|
`apps/client/src/features/page-history/*`. Есть broadcast `version.saved` (для live-инвалидации).
|
|
|
|
## Pre-loop fact-base corrections (автор, проверено vs source)
|
|
|
|
- C1. §3/§5.1/§крайние-случаи#4: убрано ошибочное «idle лагает до 60м / idle не удлиняет
|
|
сессию / отмотка на IDLE_INTERVAL». Верно: лаг ≤ `maxWait` (≤10м), `idle` — полноценный сэмпл.
|
|
- C2. §3: устранено внутреннее противоречие §3↔§5.1 (пульс vs исключение idle).
|
|
- C3. §4: `kind` уже в `baseFields` — схему менять не нужно.
|
|
- C4. §крайние-случаи#5: `lastUpdatedById` всегда человек; человек/агент — по `lastUpdatedSource`.
|
|
|
|
## Scope changes
|
|
|
|
- S1 (owner, до итерации 1): добавлен drill-down — клик по числу открывает график по дням.
|
|
- S2 (owner, до итерации 1): drill-down переопределён с «столбцы часов/день» на СУТОЧНЫЙ
|
|
ТАЙМЛАЙН (строка-день = 24 ч с окнами активности + сумма за день, §6.2/§6.3). Окна вернулись,
|
|
но графикой. Затронуты §1, §6.2, §6.3, §8, §9(#9–14), §10.
|
|
|
|
## Iteration log
|
|
|
|
### Итерация 1 — критики: Claude (hardened, A) + GLM-5.2 external (B)
|
|
|
|
Первый прогон local-субагентов вернул инъекцию из подложенного SKILL.md (реклама
|
|
«agent-first-plugin-suite») и 0 полезной работы — проигнорировано; пере-прогон с анти-инъекцией
|
|
дал реальные ревью. Дефекты и диспозиции:
|
|
|
|
- **[BLOCKER] A1** — §5-псевдокод не закрывал последнюю сессию (терял свежую; 0 сессий для
|
|
односессионной страницы; ронял S6). ПРИНЯТО → пост-цикловое закрытие, `n=1`→`P_single`, `n=0`→0.
|
|
- **[MAJOR] A2+B4** — какое число суммирует таймлайн; двойной счёт при перекрытии. ПРИНЯТО →
|
|
метрики `work`/`agent_only` (§6.1) + `total`/`perDay` через **union** (§5, §6.3).
|
|
- **[MAJOR] A3** — §1 остался со старым «столбцом на день» (residue S1→S2). ПРИНЯТО → §1 переписан.
|
|
- **[MAJOR] A4** — «кламп vs скрытое условие `T_gap ≥ P`». ПРИНЯТО, но растворено: union убрал и
|
|
кламп, и условие (§6.3).
|
|
- **[MAJOR] A5** — форма `session` и правило классификации человек/агент (нужны для `workMs` и
|
|
цвета). ПРИНЯТО → §5.1 классификация (+ nuance про старый source у boundary), `session.class` (§8).
|
|
- **[MAJOR→понижено] B1** — «нижняя оценка» ложна; гэп ≤ T_gap считается работой. ЧАСТИЧНО ПРИНЯТО
|
|
+ КОНТР по severity: гэп-как-работа — by design (по меткам не отличить «думал» от «отошёл»),
|
|
поведение оставлено; исправлено УТВЕРЖДЕНИЕ (§5/§7: «оценка, не строгая граница»). Понижено с
|
|
BLOCKER: это не баг кода, а некорректная формулировка/честность.
|
|
- **[MAJOR] B2+B5** — инфляция одиночных сессий на `P` + честность отрисовки добивки. ПРИНЯТО →
|
|
`P_single` (мал), приглушённая отрисовка + «≈» (§5, §6.2).
|
|
- **[MAJOR/MINOR] B3+A6** — коллапс агентского всплеска рушит вклинившегося человека + выбор
|
|
конца для гэп-теста. ПРИНЯТО → правило коллапса (только подряд один aiChatId; человек внутри
|
|
разрывает; левый гэп до `t_start`, правый от `t_end`) (§5).
|
|
- **[MINOR] A7** — `config` не перечислен. ПРИНЯТО → полный список (§10).
|
|
- **[MINOR] A8** — ячейка `idle` в таблице §3 устарела. ПРИНЯТО → исправлена.
|
|
- **[NIT] A9** — расхождение округления headline vs подписи дня. Оставлено с оговоркой (§6.3).
|
|
- **[NIT] A10** — назвать `dayjs` для tz/DST-разреза. ПРИНЯТО (§8, §9#14).
|
|
|
|
Verified clean (оба критика): факты #374; tz/DST-разрез; лёгкая проекция без `content`;
|
|
кэш на `version.saved`; разбиение на две чистые функции; §2-постановка; арифметика §7 (при
|
|
исправленном алгоритме).
|
|
|
|
### Итерация 1 — post-integration re-attack (Claude A + GLM round 2) — ЗАКРЫТА
|
|
|
|
Оба критика НЕЗАВИСИМО нашли один и тот же блокер и сошлись (разные семейства моделей):
|
|
|
|
- **[BLOCKER] N1 (A) = MAJOR-1 (GLM)** — §5 «сессионизация ОТДЕЛЬНО по классам» противоречила
|
|
§5.1/§7/§8 и теряла надзорное агентское время (S2 схлопывалась в 2 мин; `workMs+agentOnlyMs` мог
|
|
превысить прошедшее). Исправлено: ЕДИНЫЙ проход по всем сэмплам + классификация готовой сессии;
|
|
метрики — union внутри класса.
|
|
- **[MAJOR] N2 (A)** — union только ВНУТРИ класса; кросс-класс дизъюнктность требует `T_gap ≥ P`,
|
|
а §10 это отрицал; `agentTGap` неопределён при едином проходе. ПРИНЯТО → `gap_threshold` по ПАРЕ
|
|
сэмплов (agent–agent → `agentTGap`), caveat в §5, §10 смягчён (валидируем `T_gap ≥ P`).
|
|
- **[MAJOR] GLM-2** — `bucketByDay(workSessions)` не мог рисовать `agent_only`. ПРИНЯТО → сигнатура
|
|
`bucketByDay(sessions)`, окна ОБОИХ классов, `activeMs` = work-only.
|
|
- **[MINOR] N3** — роль `boundary` противоречива (§5 «человеческий» vs §5.1 «старый source=agent»).
|
|
ПРИНЯТО → §5: всплеск рвёт любой сэмпл, не продолжающий тот же aiChatId-агент; класс — по source.
|
|
- **[MINOR] N4** — псевдокод точечный, концы сегмента не заданы. ПРИНЯТО → сегмент {t_start,t_end};
|
|
same-source idle внутри всплеска продолжает сегмент.
|
|
- **[NIT] N5** — DST ровно в полночь + фикс. 24ч-дорожка ±1ч визуально. ПРИНЯТО → оговорка §9#14.
|
|
- **[NIT] N6** — `P_single` симметричный «выдумывает будущую работу». ПРИНЯТО → pre-roll `[t−P_single, t]`.
|
|
- **B1-severity (спор):** A СОГЛАСИЛСЯ с понижением BLOCKER→MAJOR (нет входа, где код отклоняется от
|
|
намерения — только формулировка/UX). GLM НАСТАИВАЛ на BLOCKER с НОВЫМ аргументом: отсутствие
|
|
idle-пульса в гэпе = доказанное бездействие. Диспозиция: label = MAJOR, но СУБСТАНЦИЯ GLM принята
|
|
(Invariant 7 — уступка с аргументом): `T_gap` калиброван по `maxWait` (§5/§10) + принцип «гэп >
|
|
maxWait = перерыв». A-residue (направление ошибки data-dependent, возможен КРАТНЫЙ перебор) принят
|
|
в §7. Тайбрейкер не понадобился (обе стороны привели аргументы, интегрированы обе).
|
|
|
|
Verified clean (round-2): инвариант §6.3 под DST (оба критика); burst-endpoints (оба); `P_single`
|
|
через полночь (GLM). Блокеров в ядре: 0. Итерация 1 закрыта.
|
|
|
|
### Convergence validation + cold-reader gate — ЗАКРЫТА (CONVERGED)
|
|
|
|
- **Cold-reader gate** (GLM-5.2, внешняя модель, «имплементер читает впервые»): ПУСТОЙ список
|
|
вопросов на день 1 — ДВАЖДЫ (до и после партии полировки).
|
|
- **Convergence pass** (GLM-5.2, свои hostile-сценарии + слепая реализация): вердикт NOT CONVERGED
|
|
на ещё не атакованной партии round-2 → нашёл 2 MAJOR + 1 MINOR (как и предупреждает skill —
|
|
«re-opened cycles find MAJORs in unreviewed amendments»):
|
|
- [MAJOR] §6.3 рисовал `agent_only`-окна из «сырых» `sess.iv` (не union) → визуальное наложение.
|
|
→ `U_agent = union(...)`, симметрично `U_work`; добавлен `agentMs[D]`.
|
|
- [MAJOR] §5 «idle продолжает сегмент НЕЗАВИСИМО от aiChatId» склеивал разные ИИ-раны через
|
|
idle-снимок. → idle с ДРУГИМ `aiChatId` = новый ран, рвёт сегмент.
|
|
- [MINOR] нет per-day суммы агента. → `agentMs` в `perDay`/§8.
|
|
Все приняты и интегрированы; финальный re-gate (GLM cold-reader) → снова ПУСТО, рассинхрона нет.
|
|
- **Tooling note:** local general-purpose Claude-субагенты трижды перехватывались инъекцией из
|
|
подложенного SKILL.md (реклама «agent-first-plugin-suite» / фейковые «review this PR» промпты,
|
|
0 tool-uses). Проигнорировано. Адверсариальные проходы и cold-reader выполнены внешней GLM-5.2
|
|
(гетерогенная модель — как раз рекомендована skill против self-preference) + один hardened
|
|
Claude-проход, который сработал.
|
|
|
|
## Closure checklist
|
|
|
|
1. 0 блокеров в ядре; счётчики обсуждения в обе стороны (приняты находки И отбит spor по B1-severity) — ✓
|
|
2. Партия полировки re-gated финальным cold-reader на полном тексте — ✓
|
|
3. Cold-reader: список вопросов ПУСТ (дважды) — ✓
|
|
4. Прогноз сходимости трактовался как гипотеза; решал чек-лист (convergence-pass реально нашёл MAJOR) — ✓
|
|
5. Backcast: частично (пример §7 на реальных данных + 36.5ч-картинка владельца) — обязательным не был
|
|
6. Preconditions зафиксированы: зависимость точности от #374 (пульс), грубость на легаси, DST-в-полночь, `T_gap ≥ P`
|
|
|
|
**СТАТУС: CONVERGED.** §1–§10 — нормативная спека для реализатора; это приложение — аудит-след (не нормативно).
|