Course EN
← back to chapter

Traducción en streaming

Pega texto, elige un idioma destino, observa cómo llega la traducción en

Traducción en streaming

Pega texto, elige un idioma destino, observa cómo llega la traducción en streaming. El streaming vale la pena siempre que la salida es lo bastante larga como para que esperar el blob completo se sintiera lento.

Córrelo

bash bootstrap-secrets.sh              # reads ../../../../.env, writes secrets/
docker compose up --build              # default: PROVIDER=openai

Abre http://localhost:3000.

Para correrlo contra Gemini en su lugar:

PROVIDER=gemini docker compose up --build

Qué hay aquí

  • backend/ai_openai.py — parsea target: <lang>\n---\n<text>, emite la traducción en streaming
  • backend/ai_gemini.py — la misma forma, otro SDK
  • backend/main.py — FastAPI: /api/ai (final) + /api/ai/stream (SSE)
  • frontend/app/page.tsx — selector de idioma + textarea, escribe la salida conforme llega

Detenlo

docker compose down

Ejecútalo en tu máquina

Descarga el proyecto como ZIP y córrelo con Docker. Levanta un backend FastAPI y un frontend Next.js en localhost:3000.

Descargar stream-translate.zip

unzip stream-translate.zip
cd stream-translate
bash bootstrap-secrets.sh   # one-time: pulls API keys into ./secrets
docker compose up --build   # default provider: openai
# or:  PROVIDER=gemini docker compose up --build

Escribe algo, elige un proveedor y ejecuta el mismo código de Código contra la API real. Requiere iniciar sesión.


  

Los mismos módulos que ejecuta el botón Run. El proyecto completo (frontend, Dockerfile, compose) está en el ZIP, pestaña README.

backend/ai_openai.py

"""Week 3 - Showcase 2 (OpenAI): stream-translate.

Translates pasted text into a target language and streams the
translation back. The input carries the target language in a small
header:

    target: French
    ---
    The text to translate.

`run_stream` yields tokens; `run` accumulates them for the contract.
"""
from typing import Iterator

from openai import OpenAI

_client = OpenAI()


def _parse(payload: str) -> tuple[str, str]:
    """Split the input into (target language, text).

    The header `target: <language>` ends at a `---` separator. Anything
    that doesn't match the header defaults to English so the showcase
    never fails closed on bad input — it just translates to English.
    """
    parts = payload.split("---", 1)
    if len(parts) != 2:
        return ("English", payload.strip())
    header, text = parts
    target = "English"
    for line in header.splitlines():
        s = line.strip()
        if s.lower().startswith("target:"):
            target = s.split(":", 1)[1].strip() or target
            break
    return (target, text.strip())


def _prompt(target: str, text: str) -> str:
    return (
        f"Translate the following text into {target}. Preserve meaning, "
        "tone, and paragraph structure. Return only the translation; no "
        "commentary, no explanations."
        f"\n\n---\n{text}\n---"
    )


def run_stream(payload: str) -> Iterator[str]:
    target, text = _parse(payload)
    # Cap output length to prevent runaway responses. 2048 tokens
    # (~6k chars) is enough headroom for translations of inputs at
    # the showcase's 8k-char ceiling.
    stream = _client.responses.create(
        model="gpt-5.4-nano", input=_prompt(target, text), stream=True,
        max_output_tokens=2048,
    )
    for event in stream:
        if event.type == "response.output_text.delta":
            yield event.delta


def run(payload: str) -> str:
    return "".join(run_stream(payload))

backend/ai_gemini.py

"""Week 3 - Showcase 2 (Gemini): stream-translate.

Same input shape and contract as ai_openai.py — see that file for the
input format. Differences are mechanical: Gemini's streaming method and
chunk shape.
"""
import os
from typing import Iterator

from google import genai
from google.genai import types

_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])


def _parse(payload: str) -> tuple[str, str]:
    parts = payload.split("---", 1)
    if len(parts) != 2:
        return ("English", payload.strip())
    header, text = parts
    target = "English"
    for line in header.splitlines():
        s = line.strip()
        if s.lower().startswith("target:"):
            target = s.split(":", 1)[1].strip() or target
            break
    return (target, text.strip())


def _prompt(target: str, text: str) -> str:
    return (
        f"Translate the following text into {target}. Preserve meaning, "
        "tone, and paragraph structure. Return only the translation; no "
        "commentary, no explanations."
        f"\n\n---\n{text}\n---"
    )


def run_stream(payload: str) -> Iterator[str]:
    target, text = _parse(payload)
    # Cap output length to prevent runaway responses. 2048 tokens
    # (~6k chars) is enough headroom for translations of inputs at
    # the showcase's 8k-char ceiling.
    stream = _client.models.generate_content_stream(
        model="gemini-3.1-flash-lite", contents=_prompt(target, text),
        config=types.GenerateContentConfig(max_output_tokens=2048),
    )
    for chunk in stream:
        if chunk.text:
            yield chunk.text


def run(payload: str) -> str:
    return "".join(run_stream(payload))

Archivos del proyecto

  • .gitignore
  • README.es.md
  • README.md
  • backend/Dockerfile
  • backend/ai_gemini.py
  • backend/ai_openai.py
  • backend/main.py
  • backend/requirements.txt
  • bootstrap-secrets.sh
  • docker-compose.yml
  • frontend/Dockerfile
  • frontend/app/layout.tsx
  • frontend/app/page.tsx
  • frontend/next.config.ts
  • frontend/package.json
  • frontend/tsconfig.json