- 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
390 lines
17 KiB
Python
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
|