Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

221 lines
8.3 KiB
Python

from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from products.surveys.backend.llm import generate_structured_output
DEFAULT_TRANSLATION_MODEL = "gemini-3.1-flash-lite-preview"
DRAFT_TRANSLATION_QUESTION_ID_PREFIX = "__draft_question_"
class SurveyRootTranslation(BaseModel):
model_config = ConfigDict(extra="ignore")
name: str | None = None
thankYouMessageHeader: str | None = None
thankYouMessageDescription: str | None = None
thankYouMessageCloseButtonText: str | None = None
submitButtonText: str | None = None
backButtonText: str | None = None
class SurveyQuestionTranslation(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str
question: str | None = None
description: str | None = None
buttonText: str | None = None
choices: list[str] | None = None
lowerBoundLabel: str | None = None
upperBoundLabel: str | None = None
link: str | None = None
class SurveyTranslationResponse(BaseModel):
root: SurveyRootTranslation = Field(default_factory=SurveyRootTranslation)
questions: list[SurveyQuestionTranslation] = Field(default_factory=list)
SYSTEM_PROMPT = """You translate Insights survey copy.
Return JSON that matches the requested schema.
Rules:
- Translate only user-facing copy.
- Preserve question ids exactly.
- Preserve choice order and choice count exactly.
- Keep empty source strings empty or omitted.
- Do not invent missing fields.
- Keep URLs unchanged unless the source URL itself is localized.
- Preserve placeholders and product names."""
def _root_source(survey: dict[str, Any]) -> dict[str, Any]:
appearance = survey.get("appearance") or {}
return {
"name": survey.get("name"),
"description_or_goal": survey.get("description"),
"type": survey.get("type"),
"thankYouMessageHeader": appearance.get("thankYouMessageHeader"),
"thankYouMessageDescription": appearance.get("thankYouMessageDescription"),
"thankYouMessageCloseButtonText": appearance.get("thankYouMessageCloseButtonText"),
"submitButtonText": appearance.get("submitButtonText"),
"backButtonText": appearance.get("backButtonText"),
"translations": survey.get("translations") or {},
}
def _questions_source(survey: dict[str, Any]) -> list[dict[str, Any]]:
questions = survey.get("questions")
if not isinstance(questions, list):
return []
source_questions: list[dict[str, Any]] = []
for index, question in enumerate(questions):
if not isinstance(question, dict):
continue
source_questions.append(
{
**question,
"id": question.get("id") or f"{DRAFT_TRANSLATION_QUESTION_ID_PREFIX}{index}",
}
)
return source_questions
def _is_missing(value: Any) -> bool:
return value is None or (isinstance(value, str) and value.strip() == "")
def _should_replace(existing_value: Any, source_value: Any, overwrite: bool) -> bool:
return overwrite or _is_missing(existing_value) or existing_value == source_value
def _filter_existing_fields(
*,
target_language: str,
source: dict[str, Any],
result: SurveyTranslationResponse,
overwrite: bool,
) -> tuple[dict[str, dict[str, str]], list[dict[str, Any]], list[str]]:
root_source = _root_source(source)
root_existing = root_source.get("translations", {}).get(target_language, {})
root_result = result.root.model_dump(exclude_none=True)
root_translation: dict[str, str] = {}
generated_paths: list[str] = []
for field, value in root_result.items():
if value != "" and _should_replace(root_existing.get(field), root_source.get(field), overwrite):
root_translation[field] = value
generated_paths.append(f"translations.{target_language}.{field}")
questions_by_id = {
str(question.get("id")): (index, question) for index, question in enumerate(_questions_source(source))
}
question_patches: list[dict[str, Any]] = []
for translated_question in result.questions:
match = questions_by_id.get(translated_question.id)
if not match:
continue
question_index, question = match
existing_translation = (question.get("translations") or {}).get(target_language, {})
question_translation: dict[str, Any] = {}
translated_values = translated_question.model_dump(exclude={"id"}, exclude_none=True)
for field, value in translated_values.items():
if field == "choices":
source_choices = question.get("choices")
if (
not isinstance(source_choices, list)
or not isinstance(value, list)
or len(value) != len(source_choices)
):
continue
existing_choices = existing_translation.get("choices")
if overwrite or not isinstance(existing_choices, list):
question_translation[field] = value
generated_paths.extend(
f"questions.{question_index}.translations.{target_language}.choices.{choice_index}"
for choice_index in range(len(value))
)
continue
choices = list(value) if len(existing_choices) != len(source_choices) else list(existing_choices)
choice_paths = []
for choice_index, translated_choice in enumerate(value):
if choice_index >= len(existing_choices):
choice_paths.append(
f"questions.{question_index}.translations.{target_language}.choices.{choice_index}"
)
continue
if _should_replace(existing_choices[choice_index], source_choices[choice_index], overwrite):
choices[choice_index] = translated_choice
choice_paths.append(
f"questions.{question_index}.translations.{target_language}.choices.{choice_index}"
)
else:
choices[choice_index] = existing_choices[choice_index]
if choice_paths:
question_translation[field] = choices
generated_paths.extend(choice_paths)
elif len(existing_choices) != len(choices):
question_translation[field] = choices
continue
if value != "" and _should_replace(existing_translation.get(field), question.get(field), overwrite):
question_translation[field] = value
generated_paths.append(f"questions.{question_index}.translations.{target_language}.{field}")
if question_translation:
question_patches.append(
{"id": translated_question.id, "translations": {target_language: question_translation}}
)
return ({target_language: root_translation} if root_translation else {}, question_patches, generated_paths)
def generate_survey_translation(
*,
survey: dict[str, Any],
target_language: str,
source_language: str = "en",
overwrite: bool = False,
model: str = DEFAULT_TRANSLATION_MODEL,
distinct_id: str | None = None,
team_id: int | None = None,
) -> tuple[dict[str, dict[str, str]], list[dict[str, Any]], list[str], str]:
root = _root_source(survey)
questions = _questions_source(survey)
result, trace_id = generate_structured_output(
model=model,
system_prompt=SYSTEM_PROMPT,
user_prompt=(
f"Translate this survey from {source_language} to {target_language}.\n"
f"Survey root: {root}\n"
f"Questions: {questions}"
),
response_schema=SurveyTranslationResponse,
insights_properties={
"ai_product": "surveys",
"ai_feature": "survey_translation",
"target_language": target_language,
},
team_id=team_id,
distinct_id=distinct_id,
billable=True,
)
translations, question_patches, generated_paths = _filter_existing_fields(
target_language=target_language,
source=survey,
result=result,
overwrite=overwrite,
)
return translations, question_patches, generated_paths, trace_id