Files
ss-tools/backend/src/plugins/translate/dictionary_correction.py
busya 1e6ec34c2b feat(backend): drop dictionary dialect columns, add allowed_languages
- Removes source_dialect and target_dialect from TerminologyDictionary model,
  schemas, routes, helper serialization, and all tests
- Adds allowed_languages config field (BCP-47 language codes) to GlobalSettings
  with validation, consolidated settings response, and API endpoint
- Adds alembic migration f1a2b3c4d5e6 to drop the two columns
2026-06-03 11:48:03 +03:00

193 lines
9.7 KiB
Python

# #region DictionaryCorrectionService [C:3] [TYPE Class] [SEMANTICS dictionary, correction, bulk]
# @BRIEF Submit term corrections and bulk corrections to dictionaries with conflict detection.
# @RELATION DEPENDS_ON -> [DictionaryEntry]
# @RELATION DEPENDS_ON -> [TerminologyDictionary]
from typing import Any
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ...core.logger import belief_scope, logger
from ...models.translate import DictionaryEntry, TerminologyDictionary
from ._utils import _normalize_term
class DictionaryCorrectionService:
"""Submit term corrections to dictionaries with conflict detection and bulk support."""
# region submit_correction [TYPE Function]
# @PURPOSE: Submit a term correction from a run result.
@staticmethod
def submit_correction(
db: Session,
dict_id: str,
source_term: str,
incorrect_target_term: str,
corrected_target_term: str,
origin_run_id: str | None = None,
origin_row_key: str | None = None,
origin_user_id: str | None = None,
on_conflict: str = "overwrite",
context_data: dict[str, Any] | None = None,
usage_notes: str | None = None,
keep_context: bool = True,
) -> dict[str, Any]:
with belief_scope("DictionaryCorrectionService.submit_correction"):
dictionary = db.query(TerminologyDictionary).filter(TerminologyDictionary.id == dict_id).first()
if not dictionary:
raise ValueError(f"Dictionary '{dict_id}' not found")
effective_context = context_data
effective_context_source = "auto"
if not keep_context:
effective_context = None
effective_context_source = "manual"
normalized = _normalize_term(source_term)
entry_src_lang = "und"
entry_tgt_lang = "und"
existing = (
db.query(DictionaryEntry)
.filter(
DictionaryEntry.dictionary_id == dict_id,
DictionaryEntry.source_term_normalized == normalized,
or_(
(DictionaryEntry.source_language == entry_src_lang) & (DictionaryEntry.target_language == entry_tgt_lang),
(DictionaryEntry.source_language == "und") & (DictionaryEntry.target_language == "und"),
),
)
.first()
)
result: dict[str, Any] = {"source_term": source_term, "target_term": corrected_target_term, "action": "created", "entry_id": None, "conflict": None, "message": None}
if existing:
if on_conflict == "keep_existing":
result["action"] = "conflict_detected"
result["conflict"] = {"source_term": source_term, "existing_target_term": existing.target_term, "submitted_target_term": corrected_target_term, "action": "keep_existing"}
result["message"] = f"Existing entry kept: '{existing.target_term}'"
return result
elif on_conflict == "overwrite":
existing.target_term = corrected_target_term.strip()
if origin_run_id:
existing.origin_run_id = origin_run_id
if origin_row_key:
existing.origin_row_key = origin_row_key
if origin_user_id:
existing.origin_user_id = origin_user_id
if context_data is not None or not keep_context:
existing.context_data = effective_context
existing.has_context = bool(effective_context)
existing.context_source = effective_context_source
if usage_notes is not None:
existing.usage_notes = usage_notes
db.flush()
result["action"] = "updated"
result["entry_id"] = existing.id
result["message"] = f"Entry updated from '{existing.target_term}' to '{corrected_target_term}'"
else:
result["action"] = "skipped"
result["conflict"] = {"source_term": source_term, "existing_target_term": existing.target_term, "submitted_target_term": corrected_target_term, "action": "cancel"}
result["message"] = "Correction cancelled by conflict mode"
return result
else:
entry = DictionaryEntry(
dictionary_id=dict_id, source_term=source_term.strip(),
source_term_normalized=normalized, target_term=corrected_target_term.strip(),
source_language=entry_src_lang, target_language=entry_tgt_lang,
context_data=effective_context, usage_notes=usage_notes,
has_context=bool(effective_context), context_source=effective_context_source if effective_context else None,
origin_run_id=origin_run_id, origin_row_key=origin_row_key, origin_user_id=origin_user_id,
)
db.add(entry)
db.flush()
result["entry_id"] = entry.id
result["message"] = f"Entry created for '{source_term}' -> '{corrected_target_term}'"
db.commit()
logger.reflect("Correction processed", result)
return result
# endregion submit_correction
# region submit_bulk_corrections [TYPE Function]
# @PURPOSE: Submit multiple term corrections atomically.
@staticmethod
def submit_bulk_corrections(
db: Session,
dict_id: str,
corrections: list[dict[str, Any]],
origin_user_id: str | None = None,
) -> dict[str, Any]:
with belief_scope("DictionaryCorrectionService.submit_bulk_corrections"):
dictionary = db.query(TerminologyDictionary).filter(TerminologyDictionary.id == dict_id).first()
if not dictionary:
raise ValueError(f"Dictionary '{dict_id}' not found")
results: list[dict[str, Any]] = []
conflicts: list[dict[str, Any]] = []
all_ok = True
for corr in corrections:
source_term = corr.get("source_term", "").strip()
corrected_target = corr.get("corrected_target_term", "").strip()
if not source_term or not corrected_target:
results.append({"source_term": source_term, "action": "error", "message": "source_term and corrected_target_term are required"})
all_ok = False
continue
normalized = _normalize_term(source_term)
bulk_src_lang = "und"
bulk_tgt_lang = "und"
existing = (
db.query(DictionaryEntry)
.filter(
DictionaryEntry.dictionary_id == dict_id,
DictionaryEntry.source_term_normalized == normalized,
or_(
(DictionaryEntry.source_language == bulk_src_lang) & (DictionaryEntry.target_language == bulk_tgt_lang),
(DictionaryEntry.source_language == "und") & (DictionaryEntry.target_language == "und"),
),
)
.first()
)
if existing:
on_conflict = corr.get("on_conflict", "overwrite")
if on_conflict == "keep_existing":
conflicts.append({"source_term": source_term, "existing_target_term": existing.target_term, "submitted_target_term": corrected_target, "action": "keep_existing"})
results.append({"source_term": source_term, "action": "conflict_detected", "message": f"Existing entry kept: '{existing.target_term}'"})
continue
elif on_conflict == "overwrite":
existing.target_term = corrected_target
existing.origin_user_id = origin_user_id
results.append({"source_term": source_term, "action": "updated", "entry_id": existing.id, "message": f"Updated to '{corrected_target}'"})
else:
conflicts.append({"source_term": source_term, "existing_target_term": existing.target_term, "submitted_target_term": corrected_target, "action": "cancel"})
results.append({"source_term": source_term, "action": "skipped", "message": "Cancelled by conflict mode"})
else:
entry = DictionaryEntry(
dictionary_id=dict_id, source_term=source_term, source_term_normalized=normalized,
target_term=corrected_target, source_language=bulk_src_lang, target_language=bulk_tgt_lang,
origin_run_id=corr.get("origin_run_id"), origin_row_key=corr.get("origin_row_key"),
origin_user_id=origin_user_id,
)
db.add(entry)
results.append({"source_term": source_term, "action": "created", "message": f"Entry created for '{source_term}' -> '{corrected_target}'"})
if conflicts and not all_ok:
db.rollback()
return {"status": "conflicts", "results": results, "conflicts": conflicts, "message": "Conflicts detected. Resolve before retrying."}
db.commit()
return {
"status": "completed", "results": results, "conflicts": conflicts,
"total": len(corrections), "applied": sum(1 for r in results if r["action"] in ("created", "updated")),
}
# endregion submit_bulk_corrections
# #endregion DictionaryCorrectionService