Files
dify/api/services/dify_builder/serde.py
T
Charles YaoandClaude Opus 4.8 ad06a30961 fix(dify-builder): restore reply_language persistence, share translation cache, harden localize contract
Final-review fix wave for the "reply in the user's input language" feature:

- serde.py: context_from_dict omitted reply_language from the explicit field
  allowlist, so it round-tripped to "" on every read despite context_to_dict
  (asdict) serializing it -- the localization feature silently no-op'd after
  the first persisted write. Added it to the constructor call; TDD'd with a
  failing-then-passing round-trip test in test_serde.py.
- localize.py: Localizer._cache was per-instance, but a fresh Localizer is
  built per advance-task invocation, so the static string catalog was
  re-translated every turn instead of once per language. Moved the cache to a
  module-level _TRANSLATION_CACHE dict (safe to share: keys are
  tenant/session-independent catalog-string+language pairs); added an autouse
  fixture in test_localize.py to clear it between tests for isolation.
- localize.py: detect_language and _fill_cache called self._model_provider()
  outside their try/except, violating the Env callback contract that these
  must never raise. Moved both calls inside their try/except so a raising
  provider degrades to the existing safe fallback instead of propagating into
  advance/_commit.
- strings.py: documented that Template.translate_fields is advisory only --
  the Localizer re-inserts all captured groups verbatim and does not consult
  it. No behavior change.

uv run --project api pytest tests/unit_tests/core/dify_builder tests/unit_tests/services/dify_builder -q
-> 722 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 16:17:54 -07:00

81 lines
3.4 KiB
Python

"""Pure JSON (de)serialization for the domain ``DifyBuilderContext``.
``DifyBuilderContext`` (see ``core.dify_builder.models``) is the per-session
working state persisted, one row per version, as the ``context`` JSONB
column on ``dify_builder_session_commits``. This module has no I/O and
no SQLAlchemy dependency -- it only converts between the dataclass and a
JSON-safe ``dict``, so the SQL repository can hand the dict straight to an
``AdjustedJSON`` column and get it back unchanged. The on-disk key names are
unchanged from the pre-rename dataclass form so existing rows deserialize
as-is.
"""
from dataclasses import asdict
from typing import Any
from core.dify_builder.models import (
ChangeSet,
ChecklistError,
Diagnosis,
DifyBuilderContext,
MutationIntent,
Risk,
)
def context_to_dict(fc: DifyBuilderContext) -> dict[str, Any]:
"""Serialize a ``DifyBuilderContext`` to a JSON-safe dict.
``dataclasses.asdict`` recurses into the nested dataclasses
(``Diagnosis``, ``MutationIntent``, ``Risk``, ``ChangeSet``,
``ChecklistError``), turning each into a plain dict; every field is a
str/int/bool/list/dict/None -- so the result is JSON-safe as-is.
"""
return asdict(fc)
def context_from_dict(d: dict[str, Any]) -> DifyBuilderContext:
"""Rebuild a ``DifyBuilderContext`` from a dict produced by ``context_to_dict``.
Reconstructs the nested dataclasses explicitly (they are plain dicts
after a JSON round-trip). Every lookup is defensive (``.get`` with a
default matching the field default) so an older or partial dict still
yields a valid ``DifyBuilderContext``.
"""
diagnosis = Diagnosis(**d["diagnosis"]) if d.get("diagnosis") else None
staged_repair = [MutationIntent(**m) for m in d.get("staged_repair", [])]
risk = Risk(**d["risk"]) if d.get("risk") else None
change_set = ChangeSet(**d["change_set"]) if d.get("change_set") else None
checklist_errors = [ChecklistError(**e) for e in d.get("checklist_errors", [])]
return DifyBuilderContext(
failed_run_id=d.get("failed_run_id", ""),
diagnosis=diagnosis,
staged_repair=staged_repair,
risk=risk,
change_set=change_set,
checkpoint_id=d.get("checkpoint_id", ""),
verify_run_id=d.get("verify_run_id", ""),
test_input_ref=d.get("test_input_ref", ""),
last_snapshot_hash=d.get("last_snapshot_hash", ""),
next_seq=d.get("next_seq", 0),
source=d.get("source", ""),
checklist_errors=checklist_errors,
goal_text=d.get("goal_text", ""),
requirements=dict(d.get("requirements") or {}),
plan_items=list(d.get("plan_items") or []),
plan_version_tag=d.get("plan_version_tag", ""),
resource_selection=dict(d.get("resource_selection") or {}),
built_node_ids=list(d.get("built_node_ids") or []),
form_fields=list(d.get("form_fields") or []),
edit_rules=dict(d.get("edit_rules") or {}),
edit_target_node_ids=list(d.get("edit_target_node_ids") or []),
paused=bool(d.get("paused", False)),
checkpoint_seq=int(d.get("checkpoint_seq", 0)),
last_structure_fingerprint=d.get("last_structure_fingerprint", ""),
recovery_class=d.get("recovery_class", ""),
skill_learning_policy=d.get("skill_learning_policy", "ask"),
model_config=dict(d.get("model_config") or {}),
reply_language=d.get("reply_language", ""),
)