добавлен словарь категориальных значений HAE → коды HealthKit

- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
This commit is contained in:
av
2026-08-04 07:32:19 +03:00
parent eb3fca77ee
commit 1b649ba3d5
40 changed files with 4317 additions and 42 deletions
+86 -3
View File
@@ -9,6 +9,7 @@ package fold
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -18,6 +19,7 @@ import (
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/healthkit"
"git.vakhrushev.me/av/healthlog/internal/store"
)
@@ -90,6 +92,16 @@ type Stats struct {
Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка.
UncoveredDropped int
// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
// CategoricalUnknown — сколько из них словарь не знает;
// CategoricalDropped — сколько отброшено границами разбора.
//
// Только числа: сами строки — данные о здоровье, контекст пульса и фаза сна
// описывают человека не меньше, чем число.
Categoricals int
CategoricalUnknown int
CategoricalDropped int
}
// ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и
@@ -144,6 +156,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
parsed, err := hae.Parse(body, hae.Meta{
Aggregation: d.Aggregation,
FallbackLayer: hae.Layer(fallback),
Locale: localeOf(d.Headers),
})
if err != nil {
// Список непокрытых секций переживает отказ: доставка, у которой не
@@ -169,6 +182,9 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed
stats.Layer = string(parsed.Layer)
stats.LayerMismatch = parsed.LayerMismatch
stats.Categoricals = len(parsed.Categoricals)
stats.CategoricalUnknown = parsed.CategoricalUnknown
stats.CategoricalDropped = parsed.CategoricalDropped
merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{
ID: d.ID,
@@ -256,6 +272,12 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// построчный разбор логов. Содержимого секций здесь нет.
"uncovered", st.Uncovered,
"uncovered_dropped", st.UncoveredDropped,
// Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов:
// «Сидячий образ жизни» — это контекст пульса, то есть данные о
// здоровье. Какие именно строки ждут словаря, отвечает реестр в базе.
"categoricals", st.Categoricals,
"categoricals_unknown", st.CategoricalUnknown,
"categoricals_dropped", st.CategoricalDropped,
}
if len(st.Collisions) > 0 {
attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
@@ -298,6 +320,19 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
// а границу выбило больше тридцати двух.
s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...)
case st.CategoricalDropped > 0:
// Тот же класс события и та же причина эскалации, что у списка секций:
// границы подобраны по измерению (на живом потоке около одиннадцати
// различных значений, самое длинное 36 байт), поэтому попадание в них —
// уже аномалия, а не режим. В INFO оно утонуло бы: поток идёт раз в пять
// минут, а `/stats` ещё нет. Соответствие «доставка → отброшено» живёт
// только здесь: лог ротируется, и к следующей сверке отпечатков причину
// уже не восстановить.
//
// `CategoricalUnknown` намеренно НЕ эскалируется: он штатно ненулевой —
// словарь покрывает только фазы сна, — и постоянный WARN обесценил бы
// уровень.
s.log.WarnContext(ctx, "delivery folded, categorical values dropped by limit", attrs...)
case st.Incomparable > 0:
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
@@ -479,12 +514,60 @@ func toIncoming(parsed hae.Result) store.Incoming {
})
}
return store.Incoming{
Points: points,
Workouts: toEntities(parsed.Workouts),
Records: toEntities(parsed.Records),
Points: points,
Workouts: toEntities(parsed.Workouts),
Records: toEntities(parsed.Records),
Categories: toCategories(parsed.Categoricals),
}
}
func toCategories(in []hae.Categorical) []store.CategoryValue {
if len(in) == 0 {
return nil
}
out := make([]store.CategoryValue, 0, len(in))
for _, c := range in {
out = append(out, store.CategoryValue{
Metric: c.Metric,
Field: c.Field,
Value: c.Value,
Code: c.Code,
})
}
return out
}
// localeOf достаёт язык доставки из сохранённых заголовков запроса.
//
// Отдельной колонки под язык нет намеренно: заголовки уже лежат целиком, и
// вторая копия того же факта разошлась бы с первой при первой же правке приёма.
//
// Отсутствие заголовка, неразбираемый JSON и любая другая неожиданность дают
// пустую локаль, а не отказ. Пустая локаль законна и вывода кода не отменяет:
// заголовков в сыром архиве нет вовсе, поэтому доставка, восстановленная из
// осиротевшего тела, приезжает без них — и обязана дать то же состояние.
//
// Имя заголовка ищется без учёта регистра: `http.Header` канонизирует его при
// приёме, но в базе лежит то, что было записано, и правило регистра не наше.
func localeOf(headers string) string {
if headers == "" {
return ""
}
var h map[string][]string
if err := json.Unmarshal([]byte(headers), &h); err != nil {
return ""
}
for name, values := range h {
if !strings.EqualFold(name, acceptLanguageHeader) || len(values) == 0 {
continue
}
return healthkit.Locale(values[0])
}
return ""
}
const acceptLanguageHeader = "Accept-Language"
func toEntities(in []hae.Entity) []store.IncomingEntity {
if len(in) == 0 {
return nil