P0 — CRITICAL (CWE-798): JWT_SECRET crash-early
Replace hardcoded super-secret-key fallback with os.environ["JWT_SECRET"]
and ${JWT_SECRET:?} syntax in app.py + docker-compose files
P1 — HIGH: Frontend dependency CVEs
Upgrade svelte 5.43.8 → 5.56.4 — resolves devalue DoS (GHSA-g2pg-6438-jwpf)
and svelte XSS (GHSA-crpf-4hrx-3jrp, GHSA-m56q-vw4c-c2cp, GHSA-rcqx-6q8c-2c42)
P2 — MEDIUM: Logging hygiene + contract gaps + tool resolver refactor
Apply _redact_sensitive_fields() in middleware + event streaming
Truncate LLM error body to 100 chars
Add @RATIONALE/@REJECTED to HandleResume + SaveConversation
Refactor deterministic intent matching → LLM-driven tool resolution
P3 — LOW: Translate logging hardening
Move _sanitize_url() to _utils.py (shared, no circular imports)
Sanitize base_url before logging in _llm_call.py and _llm_async_http.py
Emit EXPLORE warning when LLM_SSL_VERIFY=false disables TLS
superset_client module: passed clean — no changes needed
246 lines
9.0 KiB
Python
246 lines
9.0 KiB
Python
# #region TranslationUtils [C:3] [TYPE Module] [SEMANTICS translate, utils, hash, dictionary, cache]
|
|
# @defgroup Translate Module group.
|
|
# @BRIEF Shared utility functions for the translation plugin — dictionary enforcement,
|
|
# source hashing, cache lookup. Extracted from executor.py to break circular imports.
|
|
# @LAYER Domain
|
|
# @RELATION DEPENDS_ON -> [TranslationRecord]
|
|
# @RELATION DEPENDS_ON -> [TranslationLanguage]
|
|
# @RATIONALE Extracted from TranslationExecutor to avoid circular imports when sub-services
|
|
# (batch_proc, llm_call) need these helpers. The original monolithic executor.py
|
|
# violated INV_7 at 1974 lines.
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from typing import Any
|
|
import unicodedata
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
from sqlalchemy.orm import Session, joinedload
|
|
|
|
from ...core.logger import logger
|
|
from ...models.translate import TranslationRecord
|
|
|
|
|
|
# #region _sanitize_url [C:1] [TYPE Function] [SEMANTICS translate, url, sanitize]
|
|
# @BRIEF Strip embedded credentials from URL for safe logging.
|
|
# @POST Returns URL with user:pass@ portion removed, preserving host:port.
|
|
def _sanitize_url(url: str) -> str:
|
|
"""Strip embedded credentials from URL for safe logging."""
|
|
if not url:
|
|
return url
|
|
parsed = urlsplit(url)
|
|
if parsed.username or parsed.password:
|
|
safe_netloc = parsed.hostname
|
|
if parsed.port:
|
|
safe_netloc += f":{parsed.port}"
|
|
parsed = parsed._replace(netloc=safe_netloc)
|
|
return urlunsplit(parsed)
|
|
# #endregion _sanitize_url
|
|
|
|
|
|
# #region _normalize_term [TYPE Function]
|
|
# @BRIEF Normalize a term for case-insensitive unique constraint lookup.
|
|
# @RATIONALE NFC normalization is applied before lowercasing to ensure consistent
|
|
# comparison of Unicode characters (e.g. precomposed vs decomposed forms).
|
|
# @REJECTED Lowercasing without NFC normalization — would cause duplicate entries
|
|
# for semantically identical Unicode strings in different normalization forms.
|
|
def _normalize_term(term: str) -> str:
|
|
"""Normalize a term by NFC, lowercasing, and removing extra whitespace."""
|
|
if not term:
|
|
return ""
|
|
term = unicodedata.normalize('NFC', term)
|
|
return " ".join(term.lower().split())
|
|
# #endregion _normalize_term
|
|
|
|
|
|
# #region _detect_delimiter [TYPE Function]
|
|
# @BRIEF Detect the delimiter used in a CSV/TSV header line.
|
|
def _detect_delimiter(header_line: str) -> str:
|
|
"""Detect delimiter by counting tabs vs commas in the first line."""
|
|
if not header_line:
|
|
return ","
|
|
tab_count = header_line.count("\t")
|
|
comma_count = header_line.count(",")
|
|
return "\t" if tab_count > comma_count else ","
|
|
# #endregion _detect_delimiter
|
|
|
|
|
|
# #region _enforce_dictionary [C:2] [TYPE Function] [SEMANTICS translate, dictionary, post-processing]
|
|
# @BRIEF Post-process LLM output: enforce dictionary term replacements.
|
|
# @PRE dict_matches is a list of dict entries with source_term/target_term.
|
|
# @POST per_lang_values may be mutated to include forced dictionary replacements.
|
|
# @SIDE_EFFECT Logs when enforcement is applied.
|
|
def _enforce_dictionary(
|
|
source_text: str,
|
|
per_lang_values: dict[str, str],
|
|
dict_matches: list[dict[str, Any]],
|
|
batch_id: str,
|
|
row_id: str,
|
|
) -> None:
|
|
"""Post-process LLM output: enforce dictionary term replacements.
|
|
|
|
For each dictionary entry whose source_term appears in the source_text,
|
|
verify that the target_term is present in each language's translation.
|
|
If missing, replace any occurrence of the source term (which the LLM
|
|
may have left untranslated) with the dictionary target term.
|
|
"""
|
|
if not dict_matches or not source_text:
|
|
return
|
|
|
|
text_lower = source_text.lower()
|
|
|
|
for dm in dict_matches:
|
|
src_term = dm.get("source_term", "")
|
|
tgt_term = dm.get("target_term", "")
|
|
is_regex = dm.get("is_regex", False)
|
|
if not src_term or not tgt_term:
|
|
continue
|
|
|
|
if is_regex:
|
|
try:
|
|
src_check = re.compile(src_term, re.IGNORECASE)
|
|
source_matched = src_check.search(source_text) is not None
|
|
except re.error:
|
|
continue
|
|
else:
|
|
if src_term.lower() not in text_lower:
|
|
continue
|
|
source_matched = True
|
|
|
|
if not source_matched:
|
|
continue
|
|
|
|
for lang_code in list(per_lang_values.keys()):
|
|
val = per_lang_values[lang_code]
|
|
if not val:
|
|
continue
|
|
|
|
if tgt_term.lower() in val.lower():
|
|
continue
|
|
|
|
if is_regex:
|
|
try:
|
|
src_pattern = re.compile(src_term, re.IGNORECASE)
|
|
except re.error:
|
|
continue
|
|
else:
|
|
src_pattern = re.compile(re.escape(src_term), re.IGNORECASE)
|
|
|
|
if src_pattern.search(val):
|
|
new_val = src_pattern.sub(lambda _: tgt_term, val)
|
|
if new_val != val:
|
|
logger.reason("Dictionary enforcement applied", {
|
|
"batch_id": batch_id,
|
|
"row_id": row_id,
|
|
"language_code": lang_code,
|
|
"source_term": src_term,
|
|
"target_term": tgt_term,
|
|
"before": val[:200],
|
|
"after": new_val[:200],
|
|
})
|
|
per_lang_values[lang_code] = new_val
|
|
# #endregion _enforce_dictionary
|
|
|
|
|
|
# #region _compute_source_hash [C:2] [TYPE Function] [SEMANTICS translate, hash, cache-key]
|
|
# @BRIEF Compute deterministic cache key for a source row.
|
|
# SHA256 of (source_text + context_fields + dict_snapshot_hash + config_hash).
|
|
def _compute_source_hash(
|
|
source_text: str,
|
|
source_data: dict | None,
|
|
dict_snapshot_hash: str | None,
|
|
config_hash: str | None,
|
|
context_keys: list[str] | None = None,
|
|
) -> str:
|
|
"""Deterministic cache key for a translation source row.
|
|
|
|
Only includes source_text and context-relevant fields from source_data.
|
|
Key/identifier columns are excluded so identical text in different rows hits cache.
|
|
"""
|
|
context_data: dict[str, str] = {}
|
|
if source_data and context_keys:
|
|
for key in context_keys:
|
|
val = source_data.get(key)
|
|
if val is not None:
|
|
context_data[key] = str(val)
|
|
|
|
payload = json.dumps({
|
|
"text": source_text,
|
|
"ctx": context_data,
|
|
"dict_hash": dict_snapshot_hash or "",
|
|
"config_hash": config_hash or "",
|
|
}, sort_keys=True, ensure_ascii=False)
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
# #endregion _compute_source_hash
|
|
|
|
|
|
# #region _check_translation_cache [C:2] [TYPE Function] [SEMANTICS translate, cache, lookup]
|
|
# @BRIEF Look up a previously successful translation by source_hash.
|
|
# Returns per-language dict {lang_code: final_value} or None.
|
|
def _check_translation_cache(
|
|
db: Session,
|
|
source_hash: str,
|
|
) -> dict[str, str] | None:
|
|
"""Check if this source_hash was already translated successfully."""
|
|
cached = (
|
|
db.query(TranslationRecord)
|
|
.options(joinedload(TranslationRecord.languages))
|
|
.filter(
|
|
TranslationRecord.source_hash == source_hash,
|
|
TranslationRecord.status == "SUCCESS",
|
|
)
|
|
.order_by(TranslationRecord.created_at.desc())
|
|
.first()
|
|
)
|
|
if not cached:
|
|
return None
|
|
|
|
lang_values: dict[str, str] = {}
|
|
for lang in cached.languages:
|
|
if lang.status == "translated" and lang.final_value:
|
|
lang_values[lang.language_code] = lang.final_value
|
|
|
|
return lang_values if lang_values else None
|
|
# #endregion _check_translation_cache
|
|
|
|
|
|
# #region _compute_key_hash [C:1] [TYPE Function]
|
|
# @BRIEF Compute a stable hash from source_data dict for matching preview edits.
|
|
def _compute_key_hash(source_data: dict) -> str:
|
|
"""Compute a stable hash from source_data dict for matching preview edits."""
|
|
import hashlib
|
|
stable = json.dumps(source_data, sort_keys=True)
|
|
return hashlib.sha256(stable.encode()).hexdigest()[:16]
|
|
# #endregion _compute_key_hash
|
|
|
|
|
|
# #region estimate_row_tokens [C:2] [TYPE Function] [SEMANTICS translate, token, estimation]
|
|
# @ingroup Translate
|
|
# @BRIEF Estimate token count for a single source row including context fields.
|
|
# @PRE source_text is a string.
|
|
# @POST Returns estimated token count >= 1.
|
|
def estimate_row_tokens(
|
|
source_text: str,
|
|
source_data: dict | None,
|
|
job,
|
|
) -> int:
|
|
"""Estimate token count for a single source row including context fields.
|
|
|
|
Uses CJK-aware heuristics via _token_budget._estimate_tokens_for_text.
|
|
Context fields from job.context_columns are included in the estimate.
|
|
"""
|
|
from ._token_budget import _estimate_tokens_for_text
|
|
|
|
text_tokens = _estimate_tokens_for_text(source_text or "")
|
|
|
|
context_keys = job.context_columns or []
|
|
ctx_text = ""
|
|
if source_data and context_keys:
|
|
ctx_text = " ".join(str(source_data.get(k, "")) for k in context_keys)
|
|
ctx_tokens = _estimate_tokens_for_text(ctx_text)
|
|
|
|
return text_tokens + ctx_tokens
|
|
# #endregion estimate_row_tokens
|
|
# #endregion TranslationUtils
|