Files
ss-tools/specs/028-llm-datasource-supeset/data-model.md
busya 46d89c9006 docs(028): update all feature documents for enhancement phase completion (US11-US12)
- effort-estimate-report.md: updated metrics (~182 files, ~40K LOC), added enhancement breakdown, updated comparative analysis
- spec.md: status → Core + Enhancement complete, added Enhancement Implementation Notes
- plan.md: all enhancement components marked , metrics updated
- tasks.md: all 26 enhancement tasks (T135-T160) → [x], closure summary updated
- quickstart.md: added §10 Direct Database Insert flow
- data-model.md, research.md, contracts/modules.md, ux_reference.md, spec.ru.md, checklists/requirements.md: dates, statuses, metrics aligned
- all documents now reflect 2026-06-11 state: ~174-182 files, ~39-40K LOC, ~580 pytest, ~68 vitest
2026-06-11 19:11:05 +03:00

24 KiB
Raw Blame History

Data Model: LLM Table Translation Service

Feature Branch: 028-llm-datasource-supeset (актуальная: 032-translate-requests-httpx)
Date: 2026-05-17 (updated 2026-06-11 — enhancement complete)
Note: Все 15 ORM моделей реализованы (добавлены insert_method, connection_id, connection_snapshot для enhancement US11US12)
Note: Маршруты реализованы как пакет backend/src/api/routes/translate/ (13 файлов, 2 296 строк).

1. SQLAlchemy ORM Entities (dialect-aware — PostgreSQL/Greenplum, ClickHouse supported)

TranslationJob (UPDATED — multi-language)

# [DEF:TranslationJob:Class]
# @COMPLEXITY 2
# @PURPOSE Persisted configuration for a translation job (multi-language).
class TranslationJob(Base):
    __tablename__ = "translation_jobs"

    id              = Column(String, primary_key=True, default=generate_uuid)
    name            = Column(String, nullable=False)
    owner_id        = Column(String, ForeignKey("users.id"), nullable=False)
    # Source configuration
    datasource_id   = Column(String, nullable=False)
    database_dialect = Column(String, nullable=False)        # Detected from Superset: postgresql, clickhouse, greenplum
    source_table    = Column(String, nullable=True)          # Optional metadata; datasource may be virtual
    translation_col = Column(String, nullable=False)
    context_cols    = Column(JSON, default=list)
    source_key_cols = Column(JSON, nullable=False)           # [col_name, ...]
    target_key_cols = Column(JSON, nullable=False)           # [col_name, ...] — mapped to source_key_cols
    # Target configuration
    target_table    = Column(String, nullable=False)
    target_col      = Column(String, nullable=False)
    upsert_strategy = Column(String, default="insert")      # insert | skip_existing | overwrite
    # LLM configuration
    provider_id     = Column(String, ForeignKey("llm_providers.id"), nullable=True)
    target_languages = Column(JSON, nullable=False)          # [BCP-47, ...] — was target_language str, now list [UPDATED]
    source_language = Column(String, nullable=True)          # Optional fallback hint; auto-detected per row via LLM [DEPRECATED but preserved for migration]
    prompt_template = Column(Text, nullable=True)
    batch_size      = Column(Integer, default=50)
    # Timestamps
    created_at      = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
    updated_at      = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))

    owner           = relationship("User")
    dictionaries    = relationship("TranslationJobDictionary", back_populates="job", cascade="all, delete-orphan")
    schedule        = relationship("TranslationSchedule", back_populates="job", uselist=False, cascade="all, delete-orphan")
    runs            = relationship("TranslationRun", back_populates="job")
    preview_sessions = relationship("TranslationPreviewSession", back_populates="job")
# [/DEF:TranslationJob:Class]

TranslationJobDictionary (M2M join with priority)

class TranslationJobDictionary(Base):
    __tablename__ = "translation_job_dictionaries"
    id            = Column(String, primary_key=True, default=generate_uuid)
    job_id        = Column(String, ForeignKey("translation_jobs.id"), nullable=False)
    dictionary_id = Column(String, ForeignKey("terminology_dictionaries.id"), nullable=False)
    priority      = Column(Integer, default=0)              # Lower = higher priority

    job           = relationship("TranslationJob", back_populates="dictionaries")
    dictionary    = relationship("TerminologyDictionary")

TerminologyDictionary (UPDATED — multilingual)

class TerminologyDictionary(Base):
    __tablename__ = "terminology_dictionaries"
    id              = Column(String, primary_key=True, default=generate_uuid)
    name            = Column(String, nullable=False)         # Organizational label only
    # target_language removed — entries carry their own language pairs [UPDATED]
    owner_id        = Column(String, ForeignKey("users.id"), nullable=False)
    created_at      = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
    updated_at      = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))

    owner           = relationship("User")
    entries         = relationship("DictionaryEntry", back_populates="dictionary", cascade="all, delete-orphan")

### DictionaryEntry (UPDATED — language-aware + context)

```python
class DictionaryEntry(Base):
    __tablename__ = "dictionary_entries"
    id              = Column(String, primary_key=True, default=generate_uuid)
    dictionary_id   = Column(String, ForeignKey("terminology_dictionaries.id"), nullable=False)
    source_term     = Column(String, nullable=False)
    source_term_normalized = Column(String, nullable=False)  # Lowercase, NFC normalized
    source_language = Column(String, nullable=False)         # BCP-47 tag [NEW]
    target_language = Column(String, nullable=False)         # BCP-47 tag [NEW — was on dictionary]
    target_term     = Column(String, nullable=False)
    # Context [NEW section]
    context_data    = Column(JSON, nullable=True)            # Snapshot of source row's context columns { col_name: value, ... }
    usage_notes     = Column(Text, nullable=True)            # Free-form user notes on when this translation applies
    has_context     = Column(Boolean, default=False)         # Fast filter flag for prompt construction
    context_source  = Column(String, nullable=True)          # "auto" | "auto_with_edits" | "manual" | "bulk"
    # Origin metadata
    origin_run_id   = Column(String, ForeignKey("translation_runs.id"), nullable=True)
    origin_row_key  = Column(JSON, nullable=True)
    origin_user_id  = Column(String, ForeignKey("users.id"), nullable=True)
    origin_source_language = Column(String, nullable=True)   # Detected source language at correction time
    created_at      = Column(DateTime(timezone=True), default=datetime.now(timezone.utc))

    dictionary      = relationship("TerminologyDictionary", back_populates="entries")
    __table_args__ = (
        # Unique constraint updated to include language pair
        UniqueConstraint("dictionary_id", "source_term_normalized", "source_language", "target_language", name="uq_dict_source_term_lang"),
        Index("idx_dict_entry_lang", "source_language", "target_language"),
        Index("idx_dict_has_context", "has_context"),        # [NEW] — fast filter for context-only queries
    )

TranslationSchedule

class TranslationSchedule(Base):
    __tablename__ = "translation_schedules"
    id              = Column(String, primary_key=True, default=generate_uuid)
    job_id          = Column(String, ForeignKey("translation_jobs.id"), unique=True, nullable=False)
    schedule_type   = Column(String, nullable=False)          # cron | interval | once
    cron_expression = Column(String, nullable=True)
    interval_seconds = Column(Integer, nullable=True)
    run_at          = Column(DateTime(timezone=True), nullable=True)
    timezone        = Column(String, default="UTC")          # e.g., "Europe/Moscow"
    is_enabled      = Column(Boolean, default=True)
    concurrency     = Column(String, default="skip")         # skip | queue
    next_run_at     = Column(DateTime(timezone=True), nullable=True)
    created_at      = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
    updated_at      = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))

    job             = relationship("TranslationJob", back_populates="schedule")

TranslationRun

class TranslationRun(Base):
    __tablename__ = "translation_runs"
    id              = Column(String, primary_key=True, default=generate_uuid)
    job_id          = Column(String, ForeignKey("translation_jobs.id"), nullable=False)
    trigger_type    = Column(String, nullable=False)          # manual | scheduled
    translation_status = Column(String, default="pending")   # pending|running|completed|partial|failed|cancelled|skipped
    insert_status   = Column(String, default="not_started")  # not_started|submitted|running|succeeded|failed|skipped
    # Statistics
    total_rows      = Column(Integer, default=0)
    translated_rows = Column(Integer, default=0)
    failed_rows     = Column(Integer, default=0)
    skipped_rows    = Column(Integer, default=0)
    token_count     = Column(Integer, default=0)
    estimated_cost  = Column(Float, default=0.0)
    # Hashes for idempotency and audit
    config_hash     = Column(String, nullable=True)
    dict_snapshot_hash = Column(String, nullable=True)
    # Snapshots
    config_snapshot = Column(JSON, nullable=False)
    dict_snapshot   = Column(JSON, nullable=True)
    prompt_used     = Column(Text, nullable=True)
    # SQL output
    insert_sql      = Column(Text, nullable=True)
    sql_hash        = Column(String, nullable=True)
    # Superset execution reference
    superset_query_id   = Column(String, nullable=True)
    superset_database_id = Column(String, nullable=True)
    insert_error_type   = Column(String, nullable=True)
    insert_error_message = Column(Text, nullable=True)
    rows_affected       = Column(Integer, nullable=True)
    # Timestamps
    started_at          = Column(DateTime(timezone=True), nullable=True)
    completed_at        = Column(DateTime(timezone=True), nullable=True)
    insert_started_at   = Column(DateTime(timezone=True), nullable=True)
    insert_completed_at = Column(DateTime(timezone=True), nullable=True)
    created_at          = Column(DateTime(timezone=True), default=datetime.now(timezone.utc))

    job             = relationship("TranslationJob", back_populates="runs")
    records         = relationship("TranslationRecord", back_populates="run", cascade="all, delete-orphan")
    batches         = relationship("TranslationBatch", back_populates="run", cascade="all, delete-orphan")
    events          = relationship("TranslationEvent", back_populates="run")
    language_stats  = relationship("TranslationRunLanguageStats", back_populates="run", cascade="all, delete-orphan")  # [NEW]

TranslationBatch

class TranslationBatch(Base):
    __tablename__ = "translation_batches"
    id              = Column(String, primary_key=True, default=generate_uuid)
    run_id          = Column(String, ForeignKey("translation_runs.id"), nullable=False)
    batch_index     = Column(Integer, nullable=False)
    status          = Column(String, default="pending")       # pending|running|completed|failed
    row_count       = Column(Integer, default=0)
    translated_count = Column(Integer, default=0)
    failed_count    = Column(Integer, default=0)
    skipped_count   = Column(Integer, default=0)
    token_count     = Column(Integer, default=0)
    estimated_cost  = Column(Float, default=0.0)
    latency_ms      = Column(Integer, nullable=True)
    error_type      = Column(String, nullable=True)
    error_message   = Column(Text, nullable=True)
    started_at      = Column(DateTime(timezone=True), nullable=True)
    completed_at    = Column(DateTime(timezone=True), nullable=True)

    run             = relationship("TranslationRun", back_populates="batches")
    records         = relationship("TranslationRecord", back_populates="batch")
    __table_args__ = (
        Index("idx_batch_run_idx", "run_id", "batch_index"),
    )

TranslationRecord

class TranslationRecord(Base):
    __tablename__ = "translation_records"
    id              = Column(String, primary_key=True, default=generate_uuid)
    run_id          = Column(String, ForeignKey("translation_runs.id"), nullable=False)
    batch_id        = Column(String, ForeignKey("translation_batches.id"), nullable=True)
    # Source data
    source_text     = Column(Text, nullable=True)
    context_data    = Column(JSON, nullable=True)
    key_values      = Column(JSON, nullable=False)
    key_hash        = Column(String, nullable=False)          # hash(canonical_json(key_values))
    # Translation result
    llm_translation = Column(Text, nullable=True)
    user_edit       = Column(Text, nullable=True)
    final_value     = Column(Text, nullable=True)
    status          = Column(String, default="pending")       # pending|translated|approved|edited|rejected|failed|skipped
    error_message   = Column(Text, nullable=True)

    run             = relationship("TranslationRun", back_populates="records")
    batch           = relationship("TranslationBatch", back_populates="records")
    languages       = relationship("TranslationLanguage", back_populates="record", cascade="all, delete-orphan")  # [NEW]
    __table_args__ = (
        Index("idx_record_key_hash", "key_hash"),
        Index("idx_record_run_key", "run_id", "key_hash"),
    )

TranslationLanguage (NEW — per-language translation storage)

class TranslationLanguage(Base):
    """Stores per-language translation for a TranslationRecord.
    One row per (record_id, language_code). This replaces direct language fields on TranslationRecord."""
    __tablename__ = "translation_languages"
    id                      = Column(String, primary_key=True, default=generate_uuid)
    record_id               = Column(String, ForeignKey("translation_records.id"), nullable=False)
    language_code           = Column(String, nullable=False)         # BCP-47 tag, e.g., "ru", "en", "de"
    source_language_detected = Column(String, nullable=True)         # BCP-47 or "und" — auto-detected per row [NEW]
    translated_value        = Column(Text, nullable=True)
    user_edit               = Column(Text, nullable=True)
    final_value             = Column(Text, nullable=True)
    status                  = Column(String, default="pending")      # pending|translated|approved|edited|rejected|failed|skipped
    error_message           = Column(Text, nullable=True)
    created_at              = Column(DateTime(timezone=True), default=datetime.now(timezone.utc))

    record                  = relationship("TranslationRecord", back_populates="languages")
    __table_args__ = (
        UniqueConstraint("record_id", "language_code", name="uq_record_language"),
        Index("idx_tl_language", "language_code"),
        Index("idx_tl_record_lang", "record_id", "language_code"),
    )

TranslationPreviewLanguage (NEW — per-language preview results)

class TranslationPreviewLanguage(Base):
    """Stores per-language translation for a preview record."""
    __tablename__ = "translation_preview_languages"
    id                      = Column(String, primary_key=True, default=generate_uuid)
    preview_record_id       = Column(String, ForeignKey("translation_preview_records.id"), nullable=False)
    language_code           = Column(String, nullable=False)         # BCP-47 tag
    source_language_detected = Column(String, nullable=True)         # BCP-47 or "und"
    translated_value        = Column(Text, nullable=True)
    user_edit               = Column(Text, nullable=True)
    final_value             = Column(Text, nullable=True)
    status                  = Column(String, default="pending")      # pending|approved|edited|rejected
    created_at              = Column(DateTime(timezone=True), default=datetime.now(timezone.utc))

    preview_record          = relationship("TranslationPreviewRecord", back_populates="languages")
    __table_args__ = (
        UniqueConstraint("preview_record_id", "language_code", name="uq_preview_record_language"),
    )

TranslationRunLanguageStats (NEW — per-language run statistics)

class TranslationRunLanguageStats(Base):
    """Per-language statistics for a translation run."""
    __tablename__ = "translation_run_language_stats"
    id              = Column(String, primary_key=True, default=generate_uuid)
    run_id          = Column(String, ForeignKey("translation_runs.id"), nullable=False)
    language_code   = Column(String, nullable=False)         # BCP-47 tag
    total_rows      = Column(Integer, default=0)
    translated_rows = Column(Integer, default=0)
    failed_rows     = Column(Integer, default=0)
    skipped_rows    = Column(Integer, default=0)
    token_count     = Column(Integer, default=0)
    estimated_cost  = Column(Float, default=0.0)

    run             = relationship("TranslationRun", back_populates="language_stats")
    __table_args__ = (
        UniqueConstraint("run_id", "language_code", name="uq_run_language"),
        Index("idx_rls_run", "run_id"),
    )

TranslationPreviewSession

class TranslationPreviewSession(Base):
    __tablename__ = "translation_preview_sessions"
    id                  = Column(String, primary_key=True, default=generate_uuid)
    job_id              = Column(String, ForeignKey("translation_jobs.id"), nullable=False)
    user_id             = Column(String, ForeignKey("users.id"), nullable=False)
    config_hash         = Column(String, nullable=False)
    dict_snapshot_hash  = Column(String, nullable=True)
    sample_size         = Column(Integer, default=10)
    status              = Column(String, default="pending")   # pending|accepted|rejected
    created_at          = Column(DateTime(timezone=True), default=datetime.now(timezone.utc))
    accepted_at         = Column(DateTime(timezone=True), nullable=True)
    expires_at          = Column(DateTime(timezone=True), nullable=True)

    job             = relationship("TranslationJob", back_populates="preview_sessions")
    rows            = relationship("TranslationPreviewRecord", back_populates="session", cascade="all, delete-orphan")

TranslationPreviewRecord

class TranslationPreviewRecord(Base):
    __tablename__ = "translation_preview_records"
    id              = Column(String, primary_key=True, default=generate_uuid)
    session_id      = Column(String, ForeignKey("translation_preview_sessions.id"), nullable=False)
    source_text     = Column(Text, nullable=True)
    context_data    = Column(JSON, nullable=True)
    key_values      = Column(JSON, nullable=False)
    key_hash        = Column(String, nullable=False)
    llm_translation = Column(Text, nullable=True)
    user_edit       = Column(Text, nullable=True)
    final_value     = Column(Text, nullable=True)
    status          = Column(String, default="pending")       # pending|approved|edited|rejected

    session         = relationship("TranslationPreviewSession", back_populates="rows")
    languages       = relationship("TranslationPreviewLanguage", back_populates="preview_record", cascade="all, delete-orphan")  # [NEW]

TranslationEvent

class TranslationEvent(Base):
    __tablename__ = "translation_events"
    id              = Column(String, primary_key=True, default=generate_uuid)
    run_id          = Column(String, ForeignKey("translation_runs.id"), nullable=True)  # NULL for pre-run events
    job_id          = Column(String, ForeignKey("translation_jobs.id"), nullable=False)
    event_type      = Column(String, nullable=False)
    # schedule_triggered|schedule_skipped|schedule_failed|
    # run_started|batch_started|batch_completed|batch_failed|
    # run_succeeded|run_partial|run_failed|run_cancelled|run_skipped|run_noop|
    # insert_submitted|insert_succeeded|insert_failed
    timestamp       = Column(DateTime(timezone=True), default=datetime.now(timezone.utc), nullable=False)
    payload         = Column(JSON, default=dict)

    run             = relationship("TranslationRun", back_populates="events")
    __table_args__ = (
        Index("idx_event_job_ts", "job_id", "timestamp"),
        Index("idx_event_type", "event_type"),
    )

MetricSnapshot

class MetricSnapshot(Base):
    __tablename__ = "translation_metric_snapshots"
    id              = Column(String, primary_key=True, default=generate_uuid)
    job_id          = Column(String, ForeignKey("translation_jobs.id"), nullable=False)
    snapshot_date   = Column(Date, nullable=False)
    cumulative_tokens = Column(Integer, default=0)
    cumulative_cost   = Column(Float, default=0.0)
    per_language_metrics = Column(JSON, nullable=True)       # [NEW] { "ru": { "tokens": N, "cost": M }, ... }
    covers_events_before = Column(DateTime(timezone=True), nullable=False)  # Cutoff for event coverage
    total_runs        = Column(Integer, default=0)
    success_runs      = Column(Integer, default=0)
    failed_runs       = Column(Integer, default=0)
    partial_runs      = Column(Integer, default=0)
    avg_batch_latency_ms = Column(Integer, nullable=True)
    created_at      = Column(DateTime(timezone=True), default=datetime.now(timezone.utc))

    __table_args__ = (
        UniqueConstraint("job_id", "snapshot_date", name="uq_metric_snapshot_date"),
    )

2. Pydantic Schemas (API DTOs)

Key changes from original:

  • TranslateJobCreate/Update: added source_key_cols, target_key_cols (explicit mapping), source_language → replaced by target_languages: list[str] [UPDATED 2026-05-14]
  • TermCorrectionSubmit: added source_term, incorrect_target_term, corrected_target_term, source_language (auto-filled from detected), target_language [UPDATED]
  • ScheduleConfig: added timezone
  • TranslationRunResponse: split into translation_status + insert_status, added Superset execution fields, added language_stats: list[LanguageStatsResponse] [NEW]
  • Added: TranslationBatchResponse, PreviewSessionResponse, MetricsSnapshotResponse
  • Added: TranslationLanguageResponse [NEW] — wraps language_code, detected_source_language, translated_value, user_edit, final_value, status
  • Added: TranslationPreviewLanguageResponse [NEW] — same structure for preview records
  • Added: BulkFindReplaceRequest [NEW] — find_pattern (string, regex flag), replacement_text, target_language
  • Added: InlineCorrectionSubmit [NEW] — record_id, language_code, source_term, corrected_term, dictionary_id, source_language
  • Added: CorrectionSubmitWithContext [NEW] — extends InlineCorrectionSubmit with context_data (optional JSON override), usage_notes (optional text), keep_context (boolean, default=true — false means strip auto-captured context)
  • Updated: DictionaryEntryResponse [UPDATED] — added context_data, usage_notes, has_context, context_source fields
  • Updated: BulkFindReplaceRequest [UPDATED] — added submit_to_dictionary_with_context (boolean, default=false), bulk_usage_notes (optional text applied to all entries)
  • Added: DatabaseConnection Pydantic model (C1) [NEW 2026-06-11] — id, name, host, port, database, username, password (encrypted), dialect, extra_params, pool_size

3. Entity Relationship Summary

TranslationJob (1) ──< (N) TranslationJobDictionary >── (1) TerminologyDictionary
TranslationJob (1) ──< (N) TranslationRun
TranslationJob (1) ──< (N) TranslationPreviewSession
TranslationJob (1) ──── (0..1) TranslationSchedule
TranslationRun (1) ──< (N) TranslationRunLanguageStats     [NEW]
TranslationRun (1) ──< (N) TranslationBatch
TranslationRun (1) ──< (N) TranslationRecord
TranslationRun (1) ──< (N) TranslationEvent (nullable run_id for pre-run events)
TranslationBatch (1) ──< (N) TranslationRecord
TranslationRecord (1) ──< (N) TranslationLanguage           [NEW — replaces direct language fields on Record]
TranslationPreviewSession (1) ──< (N) TranslationPreviewRecord
TranslationPreviewRecord (1) ──< (N) TranslationPreviewLanguage  [NEW]
TerminologyDictionary (1) ──< (N) DictionaryEntry

All UUID PKs, timezone-aware UTC timestamps, JSON columns for dynamic data, hash columns for efficient comparison.

Key change [2026-05-14]: TranslationRecord no longer holds source_text, llm_translation, user_edit, final_value, status directly for language-specific fields. Instead, it holds source-level data (source_text, context_data, key_values, key_hash), and each TranslationLanguage entry holds per-language data (translated_value, user_edit, final_value, status, detected_source_language).