внутренняя модель перестроена вокруг аудиозаписи

- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
av
2026-08-14 20:20:33 +03:00
parent d079f03350
commit 1576d06735
84 changed files with 8973 additions and 2865 deletions
+154 -129
View File
@@ -174,186 +174,211 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
}
}
// Колонки очереди правятся в четырёх местах пакета хранилища плюс шаг схемы, и
// компилятор видит два из них (инвариант CLAUDE.md, «Инварианты», major).
// Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
// нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения
// поле теряется только у задачи, попавшей к воркеру.
// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`,
// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре
// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает
// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения.
//
// Правила ниже закрывают все четыре места плюс шаг схемы: перечень запроса,
// структуру захвата, запись коллекции (`applyToRecord`/`recordToJob`) и перенос
// поля в задачу (`toJob`). Литерал колонки ищется **в телах** нужных функций, а
// не в файле: файл держит и структуру с тегами `db:"…"`, и по ней условие
// выполнялось бы само собой.
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила
// ниже держат оставшуюся пару плюс шаг схемы.
const (
repoPkg = "internal/adapter/repo/pocketbase"
acquireFile = repoPkg + "/transcript_job_repo.go"
mappingFile = repoPkg + "/job_mapping.go"
mappingFile = repoPkg + "/record_mapping.go"
migrationsPath = repoPkg + "/migrations"
stageFile = "internal/entity/stage.go"
stateFile = "internal/entity/audio_record.go"
serviceFile = "internal/service/transcribe.go"
)
// Колонки, которые заводит и заполняет само хранилище: перечня запроса они
// касаются, а нашего кода — нет.
// Колонки, которые заводит и заполняет само хранилище: нашего кода они не
// касаются.
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
func TestПереченьЗахватаСовпадаетСоСтруктурой(t *testing.T) {
query := acquireColumnNames(t)
row := rowColumnNames(t)
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
written := writtenColumns(t)
read := readColumns(t)
for _, col := range query {
if !row[col] {
for col := range written {
if storageOwned[col] {
continue
}
if !read[col] {
t.Errorf(
"колонка %q есть в acquireColumns, но не в acquiredRow: из захвата "+
"она приедет нулевой, и первый Save затрёт сохранённое значение",
"колонку %q пишет отображение записи, но recordToAudioRecord её не "+
"читает: запись приедет из хранилища без этого поля",
col,
)
}
delete(row, col)
}
for col := range row {
t.Errorf(
"колонка %q есть в acquiredRow, но не в acquireColumns: запрос её не "+
"читает, и поле остаётся нулевым",
col,
)
for col := range read {
if storageOwned[col] {
continue
}
if !written[col] {
t.Errorf(
"колонку %q читает recordToAudioRecord, но её не пишет ни "+
"applyOwnedByPipeline, ни applyToRecord: поле не сохранится",
col,
)
}
}
}
func TestКолонкиЗахватаЗаведеныШагомСхемы(t *testing.T) {
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
declared := schemaFieldNames(t)
for _, col := range acquireColumnNames(t) {
if col == "id" {
continue // ключ заводит само хранилище, шаг схемы его не объявляет
for col := range writtenColumns(t) {
if storageOwned[col] {
continue
}
if !declared[col] {
t.Errorf(
"колонка %q читается захватом, но ни один шаг схемы её не заводит: "+
"запрос отвалится на живой базе",
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
"заводит: сохранение отвалится на живой базе",
col,
)
}
}
}
// Четвёртое место — путь через запись коллекции: `applyToRecord` пишет колонку,
// `recordToJob` читает. Ищется литерал **в телах этих функций**, а не в файле:
// в файле лежит и структура захвата со своими тегами `db:"…"`, и по ней условие
// выполнялось бы само собой — правило было бы зелёным всегда.
func TestКолонкиЗахватаЧитаютсяИЧерезЗапись(t *testing.T) {
write := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
// Рубеж объявлен одним дескриптором, но шаг под него пишется в другом месте, и
// связь между ними компилятор не видит. Рубеж, оставшийся без шага, из работы не
// выходит: воркер его захватит, шага не найдёт и остановит запись — а рубеж,
// забытый в дескрипторе, не выдаётся захвату вовсе, и пустой прогон по
// инварианту проекта не пишется в журнал и не считается в метрику.
func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T) {
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
for _, stage := range workingStageIdents(t) {
if !strings.Contains(body, "entity."+stage) {
t.Errorf(
"рубеж entity.%s объявлен рабочим в дескрипторе, но шага под него нет "+
"в таблице stepFor: запись с этим рубежом остановится, не начав работы",
stage,
)
}
}
}
// Обратное направление того же правила: шаг, написанный под рубеж, которого в
// дескрипторе нет, недостижим — захват такую запись не выдаст никогда.
func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) {
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
declared := map[string]bool{}
for _, stage := range stageIdents(t) {
declared[stage] = true
}
re := regexp.MustCompile(`case entity\.(\w+):`)
for _, m := range re.FindAllStringSubmatch(body, -1) {
if !declared[m[1]] {
t.Errorf(
"в таблице stepFor есть ветка для entity.%s, но такого рубежа нет в "+
"дескрипторе: запись с этим рубежом захвату не выдаётся",
m[1],
)
}
}
}
// writtenColumns — колонки, которые пишет отображение записи в хранилище.
func writtenColumns(t *testing.T) map[string]bool {
t.Helper()
body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
funcBody(t, mappingFile, "func applyToRecord(")
read := funcBody(t, mappingFile, "func recordToJob(")
for _, col := range acquireColumnNames(t) {
if storageOwned[col] {
continue // эти колонки заводит и заполняет само хранилище
}
if !strings.Contains(write, `"`+col+`"`) {
t.Errorf(
"колонку %q читает захват, но её не пишет ни applyOwnedByPipeline, "+
"ни applyToRecord: путь через запись коллекции её потеряет",
col,
)
}
if !strings.Contains(read, `"`+col+`"`) {
t.Errorf(
"колонку %q читает захват, но recordToJob её не читает: задача, "+
"прочитанная не захватом, приедет без этого поля",
col,
)
}
}
}
// Пятое условие того же инварианта: колонка, доехавшая до структуры захвата,
// обязана попасть в задачу. `toJob` обращается к **полям**, а не к литералам,
// поэтому сверяются имена полей, а не имена колонок: поле, забытое здесь,
// приезжает из захвата прочитанным и теряется на последнем шаге.
func TestПоляСтруктурыЗахватаДоезжаютДоЗадачи(t *testing.T) {
body := funcBody(t, mappingFile, "func (r *acquiredRow) toJob()")
for _, field := range rowFieldNames(t) {
if !strings.Contains(body, "r."+field) {
t.Errorf(
"поле %s структуры захвата не читается в toJob: колонка приедет из "+
"запроса, но в задачу не попадёт",
field,
)
}
}
}
// --- Чтение исходников ------------------------------------------------------
// acquireColumnNames достаёт имена колонок из константы `acquireColumns`. Она
// склеена из строковых литералов, поэтому берётся текстом, а не разбором типов:
// значение константы известно на месте.
func acquireColumnNames(t *testing.T) []string {
t.Helper()
body := readFile(t, acquireFile)
const marker = "const acquireColumns = "
start := strings.Index(body, marker)
if start < 0 {
t.Fatalf("в %s нет константы acquireColumns: правило потеряло предмет", acquireFile)
}
tail := body[start+len(marker):]
end := strings.Index(tail, "`\n")
if end < 0 {
t.Fatalf("не нашёл конец константы acquireColumns в %s", acquireFile)
}
var cols []string
for _, chunk := range strings.Split(strings.NewReplacer("`", "", "+", "", "\n", "", "\t", "").Replace(tail[:end]), ",") {
if col := strings.TrimSpace(chunk); col != "" {
cols = append(cols, col)
}
}
if len(cols) == 0 {
t.Fatalf("перечень acquireColumns прочитан пустым: правило потеряло предмет")
}
return cols
}
// rowColumnNames достаёт колонки из тегов `db:"…"` структуры `acquiredRow`.
func rowColumnNames(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
for _, m := range regexp.MustCompile("`db:\"([^\"]+)\"`").FindAllStringSubmatch(rowStruct(t), -1) {
for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("у acquiredRow не прочитан ни один тег db: правило потеряло предмет")
t.Fatalf("отображение записи не пишет ни одной колонки: правило потеряло предмет")
}
return out
}
// rowFieldNames достаёт имена полей структуры `acquiredRow` — те, к которым
// обращается `toJob`.
func rowFieldNames(t *testing.T) []string {
// readColumns — колонки, которые читает обратное отображение.
func readColumns(t *testing.T) map[string]bool {
t.Helper()
var out []string
for _, m := range regexp.MustCompile(`(?m)^\t([A-Z]\w*)\s`).FindAllStringSubmatch(rowStruct(t), -1) {
out = append(out, m[1])
body := funcBody(t, mappingFile, "func recordToAudioRecord(")
out := map[string]bool{}
for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("у acquiredRow не прочитано ни одно поле: правило потеряло предмет")
t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет")
}
return out
}
// rowStruct — текст объявления структуры `acquiredRow`.
func rowStruct(t *testing.T) string {
// stageIdents — имена констант рубежей, перечисленных дескриптором.
func stageIdents(t *testing.T) []string {
t.Helper()
body := readFile(t, mappingFile)
start := strings.Index(body, "type acquiredRow struct {")
out, _ := stageDescriptor(t)
return out
}
// workingStageIdents — то же, но без конечного рубежа: из него запись в работу
// не берут.
func workingStageIdents(t *testing.T) []string {
t.Helper()
_, working := stageDescriptor(t)
return working
}
func stageDescriptor(t *testing.T) (all []string, working []string) {
t.Helper()
body := readFile(t, stageFile)
const marker = "var stages = []Stage{"
start := strings.Index(body, marker)
if start < 0 {
t.Fatalf("в %s нет структуры acquiredRow: правило потеряло предмет", mappingFile)
t.Fatalf("в %s нет дескриптора рубежей: правило потеряло предмет", stageFile)
}
end := strings.Index(body[start:], "\n}")
if end < 0 {
t.Fatalf("не нашёл конец структуры acquiredRow в %s", mappingFile)
t.Fatalf("не нашёл конец дескриптора рубежей в %s", stageFile)
}
return body[start : start+end]
declared := declaredStates(t)
re := regexp.MustCompile(`\{Name: (\w+)[^}]*\}`)
for _, m := range re.FindAllStringSubmatch(body[start:start+end], -1) {
if !declared[m[1]] {
t.Errorf(
"дескриптор называет рубеж %s, которого нет среди объявленных состояний "+
"в %s: перечень схемы разошёлся бы с ним молча",
m[1], stateFile,
)
continue
}
all = append(all, m[1])
if !strings.Contains(m[0], "Terminal: true") {
working = append(working, m[1])
}
}
if len(all) == 0 {
t.Fatalf("дескриптор рубежей прочитан пустым: правило потеряло предмет")
}
if len(working) == 0 {
t.Fatalf("в дескрипторе нет ни одного рабочего рубежа: правило потеряло предмет")
}
return all, working
}
// declaredStates — константы рубежей, объявленные доменом.
func declaredStates(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
re := regexp.MustCompile(`(?m)^\t(State\w+)\s*=\s*"`)
for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
}
return out
}
// --- Чтение исходников ------------------------------------------------------
// funcBody — текст тела функции от её заголовка до закрывающей скобки в первой
// позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило,
// потерявшее предмет, обязано краснеть, а не зеленеть.