Inicio / Artículos

Crear un agente de documentación autocorregible con LangGraph y Ollama.

Un pipeline de reflexión local en el que un modelo elabora los documentos de la API en Python, un segundo los critica y un tercero armoniza el resultado, conectado como un bucle LangGraph en Ollama.

Editor: REACTAPP.TOP20 min de lectura4602 palabras

Fuentes: Build a Self-Correcting AI Agent with LangGraph and Ollama

El patrón se denomina reflexión. Su premisa es que los LLM detectan errores en el texto mucho mejor de lo que pueden escribir un texto impecable de inmediato, por lo que el trabajo se divide en roles. Hay un detalle muy importante: cada rol utiliza un modelo local diferente. Un modelo que tanto escribe como evalúa tiende a pasar por alto sus propios resultados, mientras que un crítico separado evita eso.

Anteriormente, los agentes creados desde cero sin un marco de trabajo argumentaban que herramientas como LangGraph resultan útiles una vez que se ha construido la versión básica a mano. Esta vez, LangGraph es una elección deliberada, ya que un bucle de revisión con salidas condicionales es exactamente el flujo de trabajo cíclico para el que fue diseñado. Ollama sirve a los modelos. Los valores predeterminados son qwen3.5:9b como generador, deepseek-coder:6.7b como crítico y llama3:8b como adjudicador, pero un pequeño archivo JSON asigna los roles, por lo que cualquier modelo descargado localmente puede ocupar esos puestos.

El resultado es una única orden de la consola, reflexion. Esta lee un archivo de origen, ejecuta el bucle, imprime un informe de puntuación para cada ronda y escribe el Markdown procesado en el disco. Todo el proceso cabe en un único archivo de Python de aproximadamente 360 líneas.

La estructura consta de siete componentes: el estado del flujo de trabajo y el esquema de evaluación del crítico, el nodo Generador, el nodo Crítico, el enrutador que decide entre continuar con el bucle o finalizarlo, el nodo Adjudicador, la conexión entre los nodos del grafo, y finalmente la fase de ejecución con configuración y limpieza de resultados.

Configuración

El código se encuentra en un repositorio llamado local-reflexion-agent, como un único paquete, src/reflexion/main.py, expuesto como script de consola a través de pyproject.toml. Clónelo e instálelo en modo editable:

git clone https://github.com/jfjensen/local-reflexion-agent.git
cd local-reflexion-agent
pip install -e .

Eso incluye a langgraph, langchain-core, langchain-community, langchain-ollama y pydantic, y registra un script de consola llamado reflexion. Luego se confirma que Ollama está ejecutándose localmente y se descargan los tres modelos mencionados en config.json:

ollama pull qwen3.5:9b
ollama pull deepseek-coder:6.7b
ollama pull llama3:8b

Están bien los diferentes modelos. Los roles se asignan en config.json en la raíz del repositorio, y se tratan nuevamente en la sección de ejecución a continuación. El repositorio incluye tres pequeños archivos de ejemplo en input/, por lo que el pipeline puede ejecutarse de inmediato:

reflexion --input input/source_function_a.py --output output/result.md --config config.json

Parte 1: El estado y el esquema del crítico

Primero vienen dos estructuras de datos. Una define qué debe devolver el crítico y la otra define la memoria que se transmite a través del grafo.

La salida del crítico es la más interesante. Si se le pide a un modelo local de 6.7B puntos una calificación, responderá amablemente con un párrafo de prosa. Lo que se necesita son números más una solución práctica, por lo que un esquema Pydantic obliga al modelo a completar los campos fijos:

class DocEvaluationSchema(BaseModel):
    """Pydantic schema used to force a structured JSON schema constraint out of our local critic model.
    Deliberately contains only judgment fields: the overall average and the pass/fail decision are computed in code."""

    technical_accuracy: int = Field(description="Score from 0-100 on accuracy.")
    completeness: int = Field(description="Score from 0-100 covering parameter definitions.")
    single_critical_fix: str = Field(description="The single absolute highest priority correction required.")

Algunos puntos sobre el esquema:

  • El campo single_critical_fix es el núcleo del bucle. En lugar de una lista de todos los problemas, el crítico indica la corrección de máxima prioridad. Un pequeño generador maneja mucho mejor la tarea de “arreglar esto” que un montón de comentarios mixtos.
  • Lo que se omite es tan importante como lo que se incluye. Una versión anterior también pedía al modelo un porcentaje general y una marca de aprobación/rechazo, lo cual resultó ser un error, descrito bajo la sección del crítico. En resumen: nunca haga que un modelo realice cálculos aritméticos o comparaciones de umbrales que Python puede hacer en una sola línea. El esquema solo contiene los juicios realmente necesarios para el modelo, mientras que el nodo del crítico se encarga de hacer los promedios y tomar la decisión de aprobación/rechazo, en código simple.
  • No descarte las descripciones de Field como algo meramente decorativo. Ellas se transmiten al modelo a través de la restricción del esquema JSON y funcionan como pequeños indicadores.
  • A continuación está el estado. LangGraph transmite un objeto de estado de nodo en nodo, y cada nodo responde con una actualización parcial de dicho estado. Un TypedDict declara su estructura:

    def append_scores(old: List[int], new: List[int]) -> List[int]:
        return old + new
    
    class AgentWorkflowState(TypedDict):
        """Memory tracking state for the heterogeneous local graph layout."""
        source_code: str
        generated_markdown: str
        current_score: int
        score_history: Annotated[List[int], append_scores]
        required_fix: Optional[str]
        iteration_count: int
        is_approved: bool
        final_adjudicated_markdown: Optional[str]
    

    La única línea que no es obvia es score_history. El tipo Annotated[List[int], append_scores] indica a LangGraph que debe fusionar las actualizaciones de ese campo con el reductor append_scores en lugar de sobrescribirlo. Por lo tanto, cada puntuación que devuelve el crítico se agrega a la historia. Al final de una ejecución, está disponible toda la curva de puntuaciones a lo largo de todas las rondas de revisión, lo que demuestra ser la línea más informativa que imprime el pipeline.

    Parte 2: El nodo Generador

    Un nodo de LangGraph no es más que una función: se ingresa el estado y se obtiene un diccionario con las actualizaciones. El Generador tiene dos modos. La primera pasada elabora una versión preliminar de la documentación directamente a partir del código. Cada pasada posterior revisa esa versión preliminar en respuesta a las correcciones del crítico.

    def content_generator_node(state: AgentWorkflowState) -> dict:
        """The Generator node: Focuses entirely on drafting/editing documentation text layout."""
        current_iteration = state.get("iteration_count", 0)
        target_model = MODEL_REGISTRY["GENERATOR"]
    
        # ... console logging trimmed, see the repo for the full version
        llm = ChatOllama(model=target_model, temperature=0.7)
        if not state.get("generated_markdown"):
            system_prompt = "You are an expert technical writer. Convert raw code snippets into clean, comprehensive API markdown documentation."
            user_prompt = f"Generate Markdown documentation for the following source code:\n\n{state['source_code']}"
        else:
            system_prompt = "You are a precise editor rewriting technical documentation to fix a structural flaw."
            user_prompt = (
                f"Your previous documentation draft was scored {state['current_score']}/100.\n"
                f"You must completely rewrite the documentation to fix this specific issue: {state['required_fix']}\n\n"
                f"Original Code Context:\n{state['source_code']}\n\n"
                f"Previous Draft:\n{state['generated_markdown']}"
            )
        messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)]
        response = llm.invoke(messages)
        return {
            "generated_markdown": response.content,
            "iteration_count": current_iteration + 1
        }
    

    Qué hace el código:

    Elegir el modo. Si generated_markdown está vacío, significa que es la primera ronda, por lo que el modelo actúa como un redactor técnico a partir del texto original sin procesar. Si ya existe un borrador, se le pide al modelo que actúe como editor y recibe la puntuación anterior, la única corrección del crítico, el texto original y su propio borrador anterior.

    La instrucción de revisión. Indicarle al modelo su puntuación previa es un pequeño truco que funciona bien. Una indicación concreta como “obtuviste 78/100” lo impulsa a hacer cambios reales en lugar de solo reformular ligeramente el texto. La instrucción solicita una reescritura completa para corregir un problema específico, y instrucciones tan precisas son ideales para modelos locales de tamaño reducido.

    Temperatura. El generador funciona con temperature=0.7, lo que permite cierta variedad en los borradores entre rondas. El crítico, como se verá a continuación, funciona con temperatura cero.

    El valor de retorno. Solo se devuelven los campos que han cambiado. LangGraph los fusiona en el estado, y iteration_count aumenta en uno por cada ronda de generación.

    Parte 3: El nodo Critic

    Se trata de un modelo diferente, deepseek-coder:6.7b por defecto, y su prompt requirió más intentos para lograrse correctamente que cualquier otro en el conjunto.

    def content_critic_node(state: AgentWorkflowState) -> dict:
        """The Critic node: Cross-checks generator output using a different model with strict JSON formatting."""
        target_model = MODEL_REGISTRY.get("CRITIC")
    
        llm = ChatOllama(model=target_model, temperature=0.0)
        structured_llm = llm.with_structured_output(DocEvaluationSchema)
    
        system_prompt = (
            f"You are a meticulous QA inspector evaluating API documentation against its source code. "
            f"You are grading the DOCUMENTATION, not the source code. Never deduct points because the source code "
            f"itself could be designed better, and never ask for the source code to be changed. "
            f"Score by deduction: start from 100 and subtract points only for concrete errors you can verify by "
            f"pointing at specific documentation text that contradicts the source code. "
            f"Check that every 'raise' statement in the source is documented with its exact exception type and its "
            f"exact quoted error message: a missing, denied, or misquoted exception is a major deduction. "
            f"Recompute every numeric value shown in the documentation's examples against what the source code would "
            f"actually return: a wrong number is a major deduction. "
            f"If you find no verifiable errors, score 100. "
            f"The single_critical_fix must point at the documentation text to change, not at the source code."
        )
    
        user_prompt = (
            f"Evaluate the following markdown documentation relative to its raw source code.\n\n"
            f"Raw Source Code:\n{state['source_code']}\n\n"
            f"Generated Markdown Document:\n{state['generated_markdown']}"
        )
    
        evaluation: DocEvaluationSchema = structured_llm.invoke([
            SystemMessage(content=system_prompt),
            HumanMessage(content=user_prompt)
        ])
    
        # The model provides the judgment scores; the arithmetic and the pass/fail decision are done here in code.
        overall_score = round((evaluation.technical_accuracy + evaluation.completeness) / 2)
        is_approved = overall_score >= QUALITY_SCORE_BAR
        required_fix = sanitize_critical_fix(evaluation.single_critical_fix)
    
        return {
            "current_score": overall_score,
            "score_history": [overall_score],
            "required_fix": required_fix,
            "is_approved": is_approved
        }
    

    Salida estructurada. La línea clave es llm.with_structured_output(DocEvaluationSchema). Esta instrucción restringe al modelo para que devuelva un objeto DocEvaluationSchema validado en lugar de texto libre. Eso implica que no hay necesidad de analizar JSON, ni usar expresiones regulares, ni aplicar lógica de intentos repetidos ante salidas mal formadas. El proceso de evaluación se ejecuta con temperature=0.0, ya que un sistema de calificación debe ser determinista o lo más cercano posible a eso en un modelo local.

    Cómo evolucionó la instrucción de inicio. Pasó por tres versiones, y su historial es probablemente la lección más útil en este caso.

    • La primera versión solicitaba amablemente al modelo que analizara la documentación y le diera una puntuación. El resultado fueron puntuaciones respetuosas en los 80 y 90 por ciento para documentos que contenían mensajes de error inventados, por lo que el bucle se detuvo demasiado pronto.
  • La segunda versión tomó un enfoque opuesto, indicando al modelo que actuara como “un inspector de calidad excepcionalmente estricto” y que “redujera la puntuación de forma agresiva” cada vez que detectara un problema. El crítico evaluó entonces un borrador sustancialmente correcto con 0 de 100 en cuatro rondas consecutivas, y su razonamiento en la primera ronda interpretó por completo mal el texto original. Los críticos de menor tamaño no se calibran; simplemente siguen el tono que se les indica. Si se fomenta la generosidad, aprueban todo; si se exige severidad, ponen cero a todo.
  • La última versión se guía por procedimientos en lugar de por el tono. La puntuación se calcula restando puntos de un total inicial de 100, solo por los errores que puedan confirmarse señalando texto específico en la documentación. Si no hay nada verificable como incorrecto, la puntuación es de 100. Con las reglas de puntuación establecidas, al modelo le queda poco margen para expresar algún estado de ánimo.
  • La instrucción también indica claramente que el crítico evalúa la documentación, no la función. En varias ejecuciones, la “corrección crítica” consistió en una crítica al diseño del código fuente, como la falta de validación o una criptografía débil, algo sobre lo cual el generador no puede hacer nada ya que su tarea es describir el código tal como está.
  • Se especifican por nombre dos tipos de verificaciones: los tipos exactos de excepciones junto con sus mensajes entre comillas, y un recálculo de todos los números en los ejemplos. Estas eran las clases de errores que las instrucciones más vagas permitían pasar en las pruebas. Un crítico sencillo responde mucho mejor a una lista nominal de verificaciones que a una solicitud general de precisión.
  • Mantenga la aritmética fuera del modelo. El primer esquema hacía que el modelo generara un porcentaje general y un valor booleano de aprobado/rechazado, y los resultados eran extraños. En una ocasión, el evaluador asignó 100 en precisión técnica y 100 en completitud, pero luego informó de un “promedio neto” de exactamente 95, que era simplemente el umbral de aprobación mencionado en su instrucción, y no un promedio real de nada. Otra vez otorgó una puntuación de 97 en cuatro rondas consecutivas, superando el umbral de 95, pero la marcó como rechazada en cada caso. Los cuatro intentos se perdieron en borradores que ya cumplían los requisitos, porque el router confiaba en el valor booleano y el registro indicaba claramente que 97 era insuficiente frente a 95.

    Hoy el modelo solo proporciona las dos subpuntuaciones. Tres líneas de Python se encargan de todo lo demás: calcular el promedio, compararlo con QUALITY_SCORE_BAR y establecer la bandera que seguirá el enrutador. Comparar un número con un umbral no es cuestión de juicio, y todo lo que no dependa del juicio no debería ser delegado a un modelo.

    La protección sanitize_critical_fix. Durante esa misma ejecución de cuatro rondas, los comentarios del crítico en una de ellas se convirtieron en varios cientos de tokens repetidos ../, los cuales se pegaron palabra por palabra en el siguiente prompt del generador como problema a solucionar. Los modelos pequeños que operan bajo restricciones de salida estructurada a veces se comportan de esta manera. La protección verifica si la solución está vacía o es degenerada (un único token repetido, o texto compuesto solo por unos pocos caracteres distintos) y, de ser así, sustituye el contenido por una solicitud genérica para volver a revisar la documentación en relación con el código fuente. No es algo sofisticado, pero evita que datos irrelevantes afecten toda una ronda. Toda la función es breve:

    def sanitize_critical_fix(fix: Optional[str]) -> str:
        """Guards against degenerate critic feedback (empty strings or runaway token repetition)."""
        stripped = (fix or "").strip()
        words = stripped.split()
        is_degenerate = (
            not stripped
            or len(set(stripped)) <= 4
            or (len(words) > 5 and len(set(words)) <= 2)
        )
        if is_degenerate:
            return "Re-verify the documentation line by line against the source code and correct the weakest section."
        return stripped
    

    Cada ronda, la puntuación vuelve a aparecer como una lista que contiene un único elemento, y el reductor de estado definido anteriormente lo agrega al historial. Así es como se acumula la curva de puntuaciones. El nodo también imprime una hoja de puntuación por ronda que muestra la precisión técnica, la completitud, la puntuación neta en relación con el límite establecido y los comentarios del crítico. Esas impresiones no se incluyen en el extracto, pero sí están en el repositorio; seguir la evolución de las puntuaciones de una ronda a otra es parte del entretenimiento.

    Parte 4: El enrutador

    Ya se han definido el generador y el crítico, por lo que ahora algo debe decidir qué sigue a cada crítica. En LangGraph esa función corresponde al enrutador, el cual lee el estado actual y devuelve el nombre del siguiente nodo.

    def execution_router(state: AgentWorkflowState) -> Literal["content_generator", "content_adjudicator"]:
        """Monitors workflow states and determines whether to repeat, loop, or step out to adjudication."""
    
        if state["is_approved"]:
            return "content_adjudicator"
    
        if state["iteration_count"] >= MAX_ATTEMPTS:
            return "content_adjudicator"
    
        return "content_generator"
    

    Notas sobre el enrutador:

    • El bucle solo tiene dos salidas: el crítico aprueba el borrador o se alcanza el límite de intentos. En ambos casos, el borrador pasa al Adjudicador; en todos los demás casos vuelve al Generador.
    • Ese límite merece más respeto del que parece. Con un umbral de 95 puntos y un crítico estricto, ciertos borradores nunca pasarán, y sin MAX_ATTEMPTS el bucle seguiría llamando a Ollama indefinidamente. Cuatro intentos es una cifra arbitraria pero viable, y se puede cambiar.
    • El tipo de retorno Literal es más que una simple documentación, ya que coincide con el mapa de enrutamiento definido al configurar el grafo.
    • Al igual que con los otros nodos, las impresiones de la consola se eliminan del extracto. En el repositorio, el enrutador registra el recuento de intentos y el historial de puntuaciones antes de cada decisión.

    Parte 5: El nodo Adjudicador

    Cualquier elemento que salga del bucle, ya sea porque superó la prueba o se agotaron los intentos, recibe una última evaluación por parte de un tercer modelo. La tarea del Adjudicador no es añadir contenido, sino resolver las contradicciones que surgieron a lo largo de las rondas de revisión y eliminar todo elemento de carácter conversacional.

    def content_adjudicator_node(state: AgentWorkflowState) -> dict:
        """The Adjudicator node: Runs outside the loop to resolve structural contradictions and finalize text."""
        target_model = MODEL_REGISTRY["ADJUDICATOR"]
    
        llm = ChatOllama(model=target_model, temperature=0.2)
    
        system_prompt = (
            "You are a senior technical documentation editor giving the final word on an API document "
            "that has been through multiple revision rounds. Your job is to read the raw source code, "
            "look at the latest draft, eliminate any conflicting statements, clean up semantic prose, and output the polished final markdown document. "
            "Do not include any preamble, comments, conversational pleasantries, or explanations-return ONLY the markdown documentation."
        )
    
        user_prompt = (
            f"Review and finalize this documentation template.\n\n"
            f"Raw Source Reference Code:\n{state['source_code']}\n\n"
            f"Latest Revision Draft:\n{state['generated_markdown']}\n\n"
            f"Last Remaining Critic Concern:\n\"{state['required_fix']}\""
        )
    
        response = llm.invoke([SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)])
    
        return {"final_adjudicated_markdown": response.content}
    

    Por qué existe el nodo. Después de varias rondas de “reescribir completamente para corregir X”, un borrador puede contener contradicciones internas: en una ronda se añade una afirmación y una reescritura posterior la contradice sin eliminarla. El árbitro recibe el texto original, el borrador más reciente y la última preocupación pendiente del crítico, con la instrucción de eliminar las declaraciones que entren en conflicto. También sirve como salida para los borradores que nunca cumplieron con los requisitos. En lugar de enviar el cuarto intento en su forma bruta, el proceso al menos entrega una versión armonizada.

    Temperatura. Funciona con temperature=0.2. Ese valor es bajo porque la edición es la tarea principal, pero no es cero porque aún debe reescribir el texto.

    Solo Markdown. Los modelos locales suelen comenzar con “¡Por supuesto! Aquí está su documentación:” y terminar con “Espero que esto sea de ayuda.” La instrucción prohíbe ambas cosas. Solo dar la instrucción no es suficiente, como se muestra en la sección de ejecución, por lo que un paso de procesamiento posterior elimina todo lo que pueda pasar desapercibido.

    Parte 6: Conectar el grafo

    Ahora se ensamblan las piezas. Aquí es donde LangGraph demuestra su utilidad, ya que toda la topología, incluidos los ciclos, se describe en unas pocas líneas:

    builder = StateGraph(AgentWorkflowState)
    
    builder.add_node("content_generator", content_generator_node)
    builder.add_node("content_critic", content_critic_node)
    builder.add_node("content_adjudicator", content_adjudicator_node)
    
    builder.set_entry_point("content_generator")
    builder.add_edge("content_generator", "content_critic")
    
    builder.add_conditional_edges(
        "content_critic",
        execution_router,
        {
            "content_generator": "content_generator",
            "content_adjudicator": "content_adjudicator"
        }
    )
    
    builder.add_edge("content_adjudicator", END)
    agent_pipeline = builder.compile()
    

    Nodos y punto de entrada. Las tres funciones de nodo están registradas bajo nombres, siendo el Generador el punto de entrada. El Generador siempre pasa el control al Crítico, y esa conexión es incondicional.

    La conexión condicional. add_conditional_edges conecta el enrutador con el Crítico. Una vez que finaliza la crítica, se ejecuta el enrutador y su respuesta se busca en el mapeo para encontrar el siguiente nodo. Ese mapeo crea un ciclo, ya que content_critic puede remitir nuevamente a content_generator, el cual a su vez suministra datos al content_critic de nuevo.

    Compilación. Al llamar a builder.compile() se obtiene una secuencia de procesos que puede ejecutarse, de modo que todo el bucle se convierte en una sola llamada a agent_pipeline.invoke(initial_state).

    Cualquiera que haya escrito este bucle a mano, con una instrucción while, gestión manual del estado y un contador de intentos, notará cuánta tarea administrativa ha desaparecido. La declaración del gráfico también se documenta por sí misma de una manera que un bucle implementado manualmente nunca logra.

    Parte 7: Tiempo de ejecución, configuración y limpieza de resultados

    Por último está la función run() que se encuentra detrás del script de consola reflexion. Esta función analiza los argumentos, carga la configuración, ejecuta el proceso y limpia los resultados. Gran parte de su funcionamiento relacionado con el análisis de argumentos se explica mejor en el repositorio, pero hay dos aspectos que merecen ser analizados.

    Primero viene la configuración. config.json proporciona los roles del modelo y las opciones del bucle:

    {
      "model_registry": {
        "GENERATOR": "qwen3.5:9b",
        "CRITIC": "deepseek-coder:6.7b",
        "ADJUDICATOR": "llama3:8b"
      },
      "quality_score_bar": 95,
      "max_attempts": 4
    }
    

    Si el archivo está ausente o no es legible, el cargador utiliza estos mismos valores predeterminados, de modo que el proceso comienza sin problemas. Las ubicaciones predeterminadas para la entrada, la salida y la configuración se determinan en relación con el paquete instalado; en el caso de una instalación editable, esto se refiere a la raíz del repositorio. Por lo tanto, al ejecutar reflexion desde cualquier directorio, se encuentra config.json y la carpeta input/ del repositorio, mientras que los parámetros --input, --output y --config sobrescriben cada uno de ellos.

    En segundo lugar, está la limpieza de la salida, algo necesario al trabajar con modelos locales:

    raw_final_text = final_output.get("final_adjudicated_markdown", "")
    clean_final_markdown = raw_final_text
    
    # 1. Clean Preamble (Conversational text at the top)
    if "#" in raw_final_text:
        parts = raw_final_text.split("#", 1)
        if len(parts[0].strip()) > 0 and len(parts[0].strip()) < 200:
            clean_final_markdown = "#" + parts[1]
    
    # 2. Clean Postscript (Conversational and meta review text at the bottom)
    meta_prefixes = (
        "note:", "note that", "i hope", "this concludes", "in conclusion",
        "no formatting errors", "no contradictions", "no errors",
        "let me know", "feel free", "please let me know"
    )
    meta_keywords = ("errors or contradictions", "contradictions were found", "errors were found")
    
    lines = clean_final_markdown.rstrip().split("\n")
    while lines:
        last_line = lines[-1].strip()
        if not last_line:
            lines.pop()
            continue
        lowered = last_line.lower()
        # Real content usually carries Markdown structure; meta chatter does not.
        is_structural = lowered.startswith(("#", "|", "-", "*", ">", "`")) or lowered[0].isdigit()
        is_meta = (not is_structural) and (
            lowered.startswith(meta_prefixes) or any(k in lowered for k in meta_keywords)
        )
        if not is_meta:
            break
        lines.pop()
    clean_final_markdown = "\n".join(lines).rstrip() + "\n"
    

    Cómo se comporta:

    • La verificación del preámbulo. Si hay texto antes del primer encabezado # y su longitud es inferior a 200 caracteres, casi con certeza se trata de una introducción del tipo “Aquí está el documento finalizado:” y se elimina. Si supera los 200 caracteres, se mantiene, ya que podría ser contenido real.
    • El análisis del posdata. Partiendo de la última línea hacia arriba, el código inspecciona línea por línea. Se elimina una línea si parece ser conversación del modelo en lugar de contenido: un veredicto de revisión restante, o una firma que comienza con “Nota:” o “Espero”, como “No se encontraron errores de formato ni contradicciones”, dejada al final de un documento en general decente tras una prueba. Al tratarse de un bucle y no de una sola verificación, varias líneas de firma superpuestas desaparecen en una sola pasada.
  • La protección estructural. Una línea que comienza con un marcador de Markdown (título, fila de tabla, viñeta, cita, marco de código o dígito) nunca se modifica. Por lo tanto, una fila de tabla que diga “sin errores” está a salvo; solo se pueden eliminar las líneas de prosa simples.
  • Se trata de una intervención rudimentaria en las cadenas de texto, no de algo sofisticado, y algunos datos se escaparán de este proceso. Sin embargo, captura todos los patrones que los modelos han generado realmente en las pruebas, y no cuesta nada.
  • Una vez limpiado, el Markdown final se guarda en la ruta de salida. Luego, la consola muestra un resumen de la ejecución, incluyendo todo el historial de puntuaciones y la cantidad de iteraciones.

    Probándolo

    El repositorio incluye tres ejemplos de entrada: una función que filtra métricas, una verificación de firma de API y un calculador de interés compuesto. La documentación del primero se ve así:

    reflexion --input input/source_function_a.py --output output/result.md --config config.json
    

    Desde allí se puede ver cómo funciona el bucle mientras se ejecuta. En cada ronda, el Generador se anuncia a sí mismo, el Crítico imprime una hoja de puntuación y el Enrutador indica si se está en bucle o saliendo.

    Los resultados de las ejecuciones con los tres inputs se encuentran en la carpeta output/, por lo que es posible inspeccionar los productos del pipeline sin ejecutar nada: tablas ordenadas de parámetros, descripciones de los valores devueltos y tablas de excepciones levantadas con sus mensajes exactos entre comillas. Dos de los tres archivos contienen un pequeño error del tipo descrito a continuación: un resultado declarado que difiere de lo que devuelve el código y un ejemplo en el que se levanta una excepción donde debería devolverse un valor. Se han dejado intencionadamente, de modo que encontrarlos constituye un buen ejercicio.

    Cuando algo sale mal

    Sí ocurren errores, y la lista a continuación proviene de fallos detectados durante las pruebas. En uno de ellos, la documentación indicó el tipo de excepción incorrecto y un mensaje de error inventado, aunque el código genera un TypeError con una cadena específica. En otro caso, afirmó que no se generaba ninguna excepción explícita. Una tabla de historial de versiones incluía una fila ficticia de “versión inicial 0.1” con fecha “TBD”, ambas inexistentes en el código fuente. Un ejemplo mostraba un ratio de rendimiento cercano a 0.86, cuando la respuesta correcta es 0.667. Y un documento bastante decente terminaba con el propio veredicto del crítico: “No se encontraron errores de formato ni contradicciones.”

    Luego estuvo esa ronda en la que obtuvo una puntuación de 97 frente a 95 durante cuatro rondas consecutivas, a pesar de insistir en que el borrador aún no estaba listo; además, una sola ronda de retroalimentación se convirtió en cientos de tokens ../. Y cuando el prompt del crítico se volvió excesivamente severo, calificó un borrador en su mayor parte correcto con 0 de 100 durante cuatro rondas seguidas, defendiendo esa puntuación con una afirmación claramente falsa sobre el texto original. El crítico no es ni un juez fijo ni una calculadora, y amplifica cualquier estado de ánimo que le proporcione el prompt.

    Afortunadamente, los fallos se agrupan, y cada grupo cuenta con una solución específica en el código. Los fallos de contenido, como excepciones incorrectas y cálculos erróneos en los documentos, son gestionados por las verificaciones nombradas del prompt crítico. Los fallos de calibración (sellos formales en un extremo y ceros excesivos en el otro) se manejan mediante el procedimiento de puntuación basado en deducciones y al trasladar la decisión relativa al promedio y los umbrales fuera del modelo hacia el código en Python del nodo crítico. Los fallos de salida, las aprobaciones y los veredictos de revisión restantes son procesados por el análisis posterior, mientras que los comentarios degenerados son gestionados por la protección de saneamiento.

    Nada de esto hace que el bucle sea infalible, ya que una instrucción en el prompt es solo una solicitud y no una prueba. Cuando las puntuaciones siguen variando a pesar de todas estas medidas, el factor más importante es el modelo y no el prompt. El crítico tiene la tarea más difícil en el registro, y reemplazar la entrada CRITIC en config.json por un modelo de uso general más potente es un cambio de una sola línea que no requiere código.

    El bucle produce de manera fiable un documento mucho mejor que uno generado con un prompt único para el mismo generador, y el historial de puntuaciones ofrece un registro honesto de cómo se llegó a ese resultado. Si las ejecuciones siguen alcanzando el límite de intentos, hay dos parámetros que vale la pena ajustar: quality_score_bar, cuyo valor puede ser inferior a 95, y max_attempts, cuyo valor puede superar los 4.

    Combinándolo todo

    El resultado es un pipeline de un solo archivo con unas 360 líneas que:

    • Un estado compartido, además de un esquema estructurado de evaluación para el crítico, donde el cálculo de promedios y la decisión de aprobación se realizan en Python y no por el modelo.
    • Un borrador escrito por un modelo local y criticado por otro, de modo que ningún modelo califica su propio trabajo.
    • Un router que devuelve los borradores para revisiones enfocadas hasta que se cumpla el estándar de calidad o se agoten los intentos.
    • Se utiliza un tercer modelo como árbitro final para resolver las contradicciones que quedan tras las rondas de revisión.
    • Se eliminan los preámbulos y posdata de la conversación del Markdown final antes de escribirlo en disco.
    • La configuración se almacena íntegramente en un pequeño archivo config.json, por lo que cambiar de modelo o relajar los parámetros no requiere editar código alguno.

    Hay espacio para ampliarlo. El crítico podría evaluar varias dimensiones de la rúbrica, y el enrutador elegiría la más débil para corregirla, en lugar de una sola corrección crítica. Un cuarto nodo podría ejecutar los ejemplos del código documentado y devolver errores reales al bucle, detectando las afirmaciones erróneas que el crítico pasa por alto. O bien, la misma estructura de Generador, Crítico y Adjudicador podría utilizarse para una tarea completamente diferente, como la traducción o la generación de pruebas, ya que nada en el grafo es específico de la documentación.

    Aquí es donde resultan útiles los frameworks: adquieren su valor cuando un flujo de trabajo cuenta con ciclos reales, ramas y estado compartido, y esta pipeline cumple con esa descripción; LangGraph se encargó de las partes que nadie quiere implementar manualmente, como la fusión de estados, el enrutamiento condicional y el bucle en sí. Construir primeras versiones simplificadas permite identificar claramente qué partes del framework realizan el trabajo real, y ese orden merece ser recomendado.