Раскладка файлов после распознавния
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Recognition — строка таблицы recognition (попытка распознавания).
|
||||
type Recognition struct {
|
||||
ID int64 `db:"id"`
|
||||
DownloadID int64 `db:"download_id"`
|
||||
AttemptNo int `db:"attempt_no"`
|
||||
IsCurrent bool `db:"is_current"`
|
||||
MediaType sql.NullString `db:"media_type"`
|
||||
Title sql.NullString `db:"title"`
|
||||
OriginalTitle sql.NullString `db:"original_title"`
|
||||
Year sql.NullInt64 `db:"year"`
|
||||
Provider sql.NullString `db:"provider"`
|
||||
ProviderID sql.NullString `db:"provider_id"`
|
||||
Confidence sql.NullFloat64 `db:"confidence"`
|
||||
Reasons string `db:"reasons"` // JSON-массив строк
|
||||
RawLLM sql.NullString `db:"raw_llm"`
|
||||
Plan sql.NullString `db:"plan"` // JSON recognize.Plan
|
||||
CreatedAt string `db:"created_at"`
|
||||
}
|
||||
|
||||
// ReasonList разбирает JSON-поле reasons в срез строк.
|
||||
func (r Recognition) ReasonList() []string {
|
||||
if r.Reasons == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
_ = json.Unmarshal([]byte(r.Reasons), &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// CreateRecognition вставляет новую попытку распознавания, помечая прежние
|
||||
// как неактуальные (is_current = 0) и проставляя следующий attempt_no.
|
||||
// Возвращает id новой записи. reasons сериализуется в JSON.
|
||||
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (int64, error) {
|
||||
reasonsJSON, err := json.Marshal(reasons)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal reasons: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE recognition SET is_current = 0 WHERE download_id = ?`, r.DownloadID); err != nil {
|
||||
return 0, fmt.Errorf("clear current recognitions: %w", err)
|
||||
}
|
||||
|
||||
var nextAttempt int
|
||||
if err := tx.GetContext(ctx, &nextAttempt,
|
||||
`SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM recognition WHERE download_id = ?`,
|
||||
r.DownloadID); err != nil {
|
||||
return 0, fmt.Errorf("next attempt_no: %w", err)
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO recognition
|
||||
(download_id, attempt_no, is_current, media_type, title, original_title,
|
||||
year, provider, provider_id, confidence, reasons, raw_llm, plan)
|
||||
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
res, err := tx.ExecContext(ctx, q,
|
||||
r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
||||
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert recognition: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("recognition last insert id: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit recognition: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetCurrentRecognition возвращает актуальную попытку распознавания загрузки
|
||||
// либо (nil, nil), если её ещё нет.
|
||||
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*Recognition, error) {
|
||||
var r Recognition
|
||||
err := s.DB.GetContext(ctx, &r,
|
||||
`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
|
||||
ORDER BY attempt_no DESC LIMIT 1`, downloadID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get current recognition: %w", err)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// --- Подсказки (hint) ---
|
||||
|
||||
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
|
||||
func (s *Store) AddHint(ctx context.Context, downloadID int64, text string) error {
|
||||
if _, err := s.DB.ExecContext(ctx,
|
||||
`INSERT INTO hint (download_id, text) VALUES (?, ?)`, downloadID, text); err != nil {
|
||||
return fmt.Errorf("add hint: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListHints возвращает подсказки загрузки в хронологическом порядке.
|
||||
func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]string, error) {
|
||||
var out []string
|
||||
if err := s.DB.SelectContext(ctx, &out,
|
||||
`SELECT text FROM hint WHERE download_id = ? ORDER BY id`, downloadID); err != nil {
|
||||
return nil, fmt.Errorf("list hints: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- Ручные правки (override) ---
|
||||
|
||||
// SetOverride пиннит значение поля (upsert по (download_id, field)).
|
||||
func (s *Store) SetOverride(ctx context.Context, downloadID int64, field, value string) error {
|
||||
const q = `
|
||||
INSERT INTO override (download_id, field, value) VALUES (?, ?, ?)
|
||||
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
|
||||
if _, err := s.DB.ExecContext(ctx, q, downloadID, field, value); err != nil {
|
||||
return fmt.Errorf("set override %q: %w", field, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListOverrides возвращает запиненные правки загрузки как map[field]value.
|
||||
func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error) {
|
||||
rows, err := s.DB.QueryxContext(ctx,
|
||||
`SELECT field, value FROM override WHERE download_id = ?`, downloadID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list overrides: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var field, value string
|
||||
if err := rows.Scan(&field, &value); err != nil {
|
||||
return nil, fmt.Errorf("scan override: %w", err)
|
||||
}
|
||||
out[field] = value
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- Ссылки файлов (file_link) ---
|
||||
|
||||
// FileLink — строка таблицы file_link (одна созданная/планируемая ссылка).
|
||||
type FileLink struct {
|
||||
ID int64 `db:"id"`
|
||||
DownloadID int64 `db:"download_id"`
|
||||
ApplyBatchID string `db:"apply_batch_id"`
|
||||
SrcPath string `db:"src_path"`
|
||||
DstPath string `db:"dst_path"`
|
||||
Kind string `db:"kind"`
|
||||
Status string `db:"status"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
}
|
||||
|
||||
// CreateFileLinks вставляет батч ссылок одной транзакцией.
|
||||
func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
|
||||
if len(links) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
const q = `
|
||||
INSERT INTO file_link (download_id, apply_batch_id, src_path, dst_path, kind, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
for _, l := range links {
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil {
|
||||
return fmt.Errorf("insert file_link: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit file_links: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LatestBatchID возвращает apply_batch_id последнего применённого батча
|
||||
// загрузки (для undo) либо пустую строку, если ссылок нет.
|
||||
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) {
|
||||
var batch string
|
||||
err := s.DB.GetContext(ctx, &batch,
|
||||
`SELECT apply_batch_id FROM file_link WHERE download_id = ?
|
||||
ORDER BY id DESC LIMIT 1`, downloadID)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("latest batch id: %w", err)
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
// ListFileLinksByBatch возвращает ссылки батча.
|
||||
func (s *Store) ListFileLinksByBatch(ctx context.Context, batchID string) ([]FileLink, error) {
|
||||
var out []FileLink
|
||||
if err := s.DB.SelectContext(ctx, &out,
|
||||
`SELECT * FROM file_link WHERE apply_batch_id = ? ORDER BY id`, batchID); err != nil {
|
||||
return nil, fmt.Errorf("list file_links by batch: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС).
|
||||
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
|
||||
if _, err := s.DB.ExecContext(ctx,
|
||||
`DELETE FROM file_link WHERE apply_batch_id = ?`, batchID); err != nil {
|
||||
return fmt.Errorf("delete file_links by batch: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user