Files
ss-tools/backend/src/api/routes/translate/_dictionary_routes.py
busya bb0fbfdafd feat(translate): multi-language optimization (Phase 11)
- Auto-detection of source language per row via LLM (US6)
- Multi-target translation — one LLM call for N languages (US1-US3)
- Language-aware storage: TranslationLanguage, per-language stats
- Multilingual dictionaries with language-pair-aware filtering (US7)
- Inline correction on any run result + submit-to-dictionary (US8)
- Context-aware dictionary: auto-capture row context, usage notes,
  Jaccard similarity, priority flagging in LLM prompts (US8b)
- Configurable preview sample size 1-100, cost warning at >30
- Per-language history & metrics with MetricSnapshot preservation
- 36 files, +5022/-373, all specs GRACE-Poly v2.6 compliant
2026-05-14 17:12:41 +03:00

390 lines
17 KiB
Python

# #region TranslateDictionaryRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, translate, api, search]
# @BRIEF Terminology Dictionary CRUD, entries management, and import routes.
# @LAYER: API
from fastapi import Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import belief_scope, logger
from ....dependencies import get_current_user, has_permission
from ....plugins.translate.dictionary import DictionaryManager
from ....schemas.auth import User
from ....schemas.translate import (
DictionaryCreate,
DictionaryEntryCreate,
DictionaryImport,
)
from ._helpers import _dict_to_response, _get_dictionary_entry_counts
from ._router import router
# ============================================================
# Terminology Dictionaries
# ============================================================
# #region list_dictionaries [TYPE Function]
# @BRIEF List all terminology dictionaries.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns list of dictionaries with total count.
@router.get("/dictionaries")
async def list_dictionaries(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "VIEW")),
db: Session = Depends(get_db),
):
"""List all terminology dictionaries."""
with belief_scope("list_dictionaries"):
logger.reason(f"User: {current_user.username}")
dicts, total = DictionaryManager.list_dictionaries(db, page=page, page_size=page_size)
dict_ids = [d.id for d in dicts]
counts = _get_dictionary_entry_counts(db, dict_ids) if dict_ids else {}
items = [_dict_to_response(d, counts.get(d.id, 0)) for d in dicts]
logger.reflect("Dictionaries listed", {"total": total, "returned": len(items)})
return {"items": items, "total": total, "page": page, "page_size": page_size}
# #endregion list_dictionaries
# #region get_dictionary [TYPE Function]
# @BRIEF Get a single terminology dictionary by ID.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns the dictionary with entry_count.
@router.get("/dictionaries/{dictionary_id}")
async def get_dictionary(
dictionary_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "VIEW")),
db: Session = Depends(get_db),
):
"""Get a terminology dictionary by ID."""
with belief_scope("get_dictionary"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
d = DictionaryManager.get_dictionary(db, dictionary_id)
from ....models.translate import DictionaryEntry
entry_count = db.query(DictionaryEntry.id).filter(DictionaryEntry.dictionary_id == dictionary_id).count()
logger.reflect("Dictionary found", {"id": dictionary_id})
return _dict_to_response(d, entry_count)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion get_dictionary
# #region create_dictionary [TYPE Function]
# @BRIEF Create a new terminology dictionary.
# @PRE: User has translate.dictionary.create permission.
# @POST: Returns the created dictionary.
@router.post("/dictionaries", status_code=status.HTTP_201_CREATED)
async def create_dictionary(
payload: DictionaryCreate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "CREATE")),
db: Session = Depends(get_db),
):
"""Create a new terminology dictionary."""
with belief_scope("create_dictionary"):
logger.reason(f"User: {current_user.username}")
d = DictionaryManager.create_dictionary(
db,
name=payload.name,
source_dialect=payload.source_dialect,
target_dialect=payload.target_dialect,
created_by=current_user.username,
description=payload.description,
is_active=payload.is_active,
)
logger.reflect("Dictionary created", {"id": d.id})
return _dict_to_response(d, 0)
# #endregion create_dictionary
# #region update_dictionary [TYPE Function]
# @BRIEF Update an existing terminology dictionary.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Returns the updated dictionary.
@router.put("/dictionaries/{dictionary_id}")
async def update_dictionary(
dictionary_id: str,
payload: DictionaryCreate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Update a terminology dictionary."""
with belief_scope("update_dictionary"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
d = DictionaryManager.update_dictionary(
db,
dict_id=dictionary_id,
name=payload.name,
description=payload.description,
source_dialect=payload.source_dialect,
target_dialect=payload.target_dialect,
is_active=payload.is_active,
)
from ....models.translate import DictionaryEntry
entry_count = db.query(DictionaryEntry.id).filter(DictionaryEntry.dictionary_id == dictionary_id).count()
logger.reflect("Dictionary updated", {"id": dictionary_id})
return _dict_to_response(d, entry_count)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion update_dictionary
# #region delete_dictionary [TYPE Function]
# @BRIEF Delete a terminology dictionary, blocked if attached to active/scheduled jobs.
# @PRE: User has translate.dictionary.delete permission.
# @POST: Dictionary is deleted.
@router.delete("/dictionaries/{dictionary_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dictionary(
dictionary_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "DELETE")),
db: Session = Depends(get_db),
):
"""Delete a terminology dictionary."""
with belief_scope("delete_dictionary"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
DictionaryManager.delete_dictionary(db, dictionary_id)
logger.reflect("Dictionary deleted", {"id": dictionary_id})
return None
except ValueError as e:
if "active/scheduled" in str(e):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion delete_dictionary
# ============================================================
# Dictionary Entries CRUD
# ============================================================
# #region list_dictionary_entries [TYPE Function]
# @BRIEF List entries for a dictionary, optionally filtered by language pair.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns paginated list of entries with language pair fields.
@router.get("/dictionaries/{dictionary_id}/entries")
async def list_dictionary_entries(
dictionary_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=500),
source_language: str | None = Query(None, description="Filter by BCP-47 source language"),
target_language: str | None = Query(None, description="Filter by BCP-47 target language"),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "VIEW")),
db: Session = Depends(get_db),
):
"""List entries for a dictionary."""
with belief_scope("list_dictionary_entries"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
entries, total = DictionaryManager.list_entries(db, dictionary_id, page=page, page_size=page_size)
# Apply language pair filtering
if source_language or target_language:
filtered = []
for e in entries:
if source_language and e.source_language != source_language and e.source_language != "und":
continue
if target_language and e.target_language != target_language:
continue
filtered.append(e)
entries = filtered
total = len(filtered)
items = [
{
"id": e.id,
"dictionary_id": e.dictionary_id,
"source_term": e.source_term,
"source_term_normalized": e.source_term_normalized,
"target_term": e.target_term,
"source_language": e.source_language,
"target_language": e.target_language,
"context_notes": e.context_notes,
"context_data": e.context_data,
"usage_notes": e.usage_notes,
"has_context": e.has_context,
"context_source": e.context_source,
"origin_source_language": e.origin_source_language,
"created_at": e.created_at,
"updated_at": e.updated_at,
}
for e in entries
]
logger.reflect("Entries listed", {"dict_id": dictionary_id, "total": total, "source_language": source_language, "target_language": target_language})
return {"items": items, "total": total, "page": page, "page_size": page_size}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion list_dictionary_entries
# #region add_dictionary_entry [TYPE Function]
# @BRIEF Add a new entry to a dictionary.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is created.
@router.post("/dictionaries/{dictionary_id}/entries", status_code=status.HTTP_201_CREATED)
async def add_dictionary_entry(
dictionary_id: str,
payload: DictionaryEntryCreate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Add a new entry to a dictionary."""
with belief_scope("add_dictionary_entry"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
entry = DictionaryManager.add_entry(
db, dictionary_id,
source_term=payload.source_term,
target_term=payload.target_term,
context_notes=payload.context_notes,
source_language=payload.source_language,
target_language=payload.target_language,
)
logger.reflect("Entry added", {"entry_id": entry.id})
return {
"id": entry.id,
"dictionary_id": entry.dictionary_id,
"source_term": entry.source_term,
"source_term_normalized": entry.source_term_normalized,
"target_term": entry.target_term,
"source_language": entry.source_language,
"target_language": entry.target_language,
"context_notes": entry.context_notes,
"context_data": entry.context_data,
"usage_notes": entry.usage_notes,
"has_context": entry.has_context,
"context_source": entry.context_source,
"origin_source_language": entry.origin_source_language,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
except ValueError as e:
if "already exists" in str(e):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion add_dictionary_entry
# #region edit_dictionary_entry [TYPE Function]
# @BRIEF Update an existing dictionary entry.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is updated.
@router.put("/dictionaries/{dictionary_id}/entries/{entry_id}")
async def edit_dictionary_entry(
dictionary_id: str,
entry_id: str,
payload: DictionaryEntryCreate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Update an existing dictionary entry."""
with belief_scope("edit_dictionary_entry"):
logger.reason(f"Entry: {entry_id}, User: {current_user.username}")
try:
entry = DictionaryManager.edit_entry(
db, entry_id,
source_term=payload.source_term,
target_term=payload.target_term,
context_notes=payload.context_notes,
source_language=payload.source_language,
target_language=payload.target_language,
)
logger.reflect("Entry updated", {"entry_id": entry_id})
return {
"id": entry.id,
"dictionary_id": entry.dictionary_id,
"source_term": entry.source_term,
"source_term_normalized": entry.source_term_normalized,
"target_term": entry.target_term,
"source_language": entry.source_language,
"target_language": entry.target_language,
"context_notes": entry.context_notes,
"context_data": entry.context_data,
"usage_notes": entry.usage_notes,
"has_context": entry.has_context,
"context_source": entry.context_source,
"origin_source_language": entry.origin_source_language,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
except ValueError as e:
if "already exists" in str(e):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion edit_dictionary_entry
# #region delete_dictionary_entry [TYPE Function]
# @BRIEF Delete a dictionary entry.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is deleted.
@router.delete("/dictionaries/{dictionary_id}/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dictionary_entry(
dictionary_id: str,
entry_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Delete a dictionary entry."""
with belief_scope("delete_dictionary_entry"):
logger.reason(f"Entry: {entry_id}, User: {current_user.username}")
try:
DictionaryManager.delete_entry(db, entry_id)
logger.reflect("Entry deleted", {"entry_id": entry_id})
return None
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion delete_dictionary_entry
# #region import_dictionary_entries [TYPE Function]
# @BRIEF Import entries into a terminology dictionary from CSV/TSV content.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entries are imported per conflict mode.
@router.post("/dictionaries/{dictionary_id}/import")
async def import_dictionary_entries(
dictionary_id: str,
payload: DictionaryImport,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Import entries into a terminology dictionary from CSV/TSV content."""
with belief_scope("import_dictionary_entries"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
if payload.on_conflict not in ("overwrite", "keep_existing", "cancel"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="on_conflict must be 'overwrite', 'keep_existing', or 'cancel'",
)
try:
result = DictionaryManager.import_entries(
db,
dict_id=dictionary_id,
content=payload.content,
delimiter=payload.delimiter,
on_conflict=payload.on_conflict,
preview_only=payload.preview_only,
default_source_language=payload.default_source_language,
default_target_language=payload.default_target_language,
)
logger.reflect("Import completed", {
"dict_id": dictionary_id,
"total": result["total"],
"errors": len(result["errors"]),
})
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion import_dictionary_entries
# #endregion TranslateDictionaryRoutesModule