437 lines
24 KiB
Markdown
437 lines
24 KiB
Markdown
# Data Model: LLM Table Translation Service
|
||
|
||
**Feature Branch**: `028-llm-datasource-supeset` (актуальная: `032-translate-requests-httpx`)
|
||
**Date**: 2026-05-17 (updated 2026-06-07 to match implementation)
|
||
**Note**: Все 15 ORM моделей реализованы в `backend/src/models/translate.py` (401 строка).
|
||
**Note**: Маршруты реализованы как пакет `backend/src/api/routes/translate/` (13 файлов, 2 296 строк).
|
||
|
||
## 1. SQLAlchemy ORM Entities (dialect-aware — PostgreSQL/Greenplum, ClickHouse supported)
|
||
|
||
### TranslationJob (UPDATED — multi-language)
|
||
|
||
```python
|
||
# [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)
|
||
|
||
```python
|
||
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)
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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)
|
||
|
||
```python
|
||
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)
|
||
|
||
```python
|
||
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)
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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)
|
||
|
||
## 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).
|