Cómo crear software de gestión de contratos con IA en C#

2026-09-17 06:29:49 Allen Yang
AI Summarize:
ChatGPT
ChatGPT ✓
Claude ✓
Grok ✓
Perplexity ✓
Quick
Quick
Concise overview
Highlights
Key takeaways
Detailed
Structured explanation
Brief
One sentence summary
Summarize |

Software de gestión de contratos con IA: automatiza la redacción, revisión, negociación, aprobación, finalización y seguimiento de obligaciones posteriores a la firma en todo el ciclo de vida del contrato en .NET

El software de gestión de contratos con IA automatiza todo el ciclo de vida del contrato —desde la redacción hasta la revisión, negociación, aprobación, finalización y seguimiento de obligaciones posteriores a la firma— combinando la comprensión del lenguaje de la IA con el procesamiento determinista de documentos dentro de una aplicación .NET. La distinción con la automatización de contratos de una sola tarea es el alcance del ciclo de vida: una herramienta de revisión de contratos señala cláusulas riesgosas; un sistema de gestión de contratos mueve un acuerdo por cada etapa, mantiene una pista de auditoría entre versiones y rastrea obligaciones después de la firma.

Spire.Agent.Office es un SDK de agente de IA para documentos que proporciona tanto la capa de comprensión del lenguaje como la de procesamiento de documentos. Este artículo muestra cómo construir un sistema de gestión del ciclo de vida de contratos en C# que encadena cinco etapas en un solo pipeline, con aislamiento de errores por etapa, despacho de formato para PDF y Word, y seguimiento de estado adecuado: patrones que los ejemplos de una sola tarea no abordan.

Este artículo presenta una implementación de referencia de un pipeline de ciclo de vida de contratos asistido por IA en .NET. Un sistema CLM de producción necesitaría además persistencia de flujos de trabajo, control de identidad y acceso, integración de firma electrónica, almacenamiento de documentos y retención de versiones, notificaciones e infraestructura de auditoría.


1. El ciclo de vida del contrato: cinco etapas donde actúa la IA

El ciclo de vida de un contrato no es una única operación documental. Es una secuencia de etapas, cada una con entradas, salidas y modos de fallo distintos. Comprender dónde la IA aporta valor en cada etapa —y dónde el código determinista debe mantenerse firme— es la base de un sistema que resiste en producción.

Etapa Qué ocurre Rol de la IA Rol del código determinista
Redacción Generar un contrato a partir de una plantilla más datos estructurados Interpretar la solicitud, seleccionar y completar campos de la plantilla Cargar la plantilla, preservar el formato, guardar como .docx o .pdf
Revisión Leer el contrato, señalar cláusulas riesgosas, extraer términos clave Análisis semántico del lenguaje de las cláusulas, puntuación de riesgo Escribir un informe de revisión estructurado, aplicar la lista de verificación de revisión
Negociación Comparar versiones, rastrear cambios, fusionar modificaciones Resumir diferencias, señalar cambios sustantivos vs. de formato Activar/desactivar control de cambios, comparar documentos, aceptar/rechazar revisiones
Aprobación Dirigir a las partes interesadas, recopilar aprobaciones Sugerir aprobadores según el tipo y valor del contrato Aplicar reglas de enrutamiento, registrar pista de auditoría, producir copia finalizada
Posterior a la firma Rastrear obligaciones, plazos, renovaciones Extraer obligaciones y fechas clave del contrato finalizado Almacenar metadatos estructurados, activar recordatorios, generar informes

El ciclo de vida no es lineal en la práctica —la negociación vuelve a la revisión, las enmiendas reinician la redacción— pero la arquitectura del pipeline maneja esto mediante el enrutamiento por etapas en lugar de una secuencia fija.

Qué lo diferencia de la revisión de contratos por sí sola

La automatización de la revisión de contratos —tema de Revisión de contratos con IA en C#— cubre una etapa: leer un acuerdo y señalar problemas. Un sistema de gestión de contratos debe:

  • Encadenar etapas con datos que fluyen de una a la siguiente (la salida de la redacción se convierte en la entrada de la revisión).
  • Manejar múltiples formatos de documento —las plantillas llegan como .docx, las contrapartes pueden enviar .pdf, y las etapas con despacho de formato manejan ambos sin lanzar errores.
  • Mantener el estado entre etapas —el estado de revisión de un contrato, el recuento de versiones de negociación y la cadena de aprobación deben persistir entre ejecuciones del pipeline.
  • Aislar fallos por contrato —un archivo corrupto en un lote de 200 no debe detener todo el pipeline.

Estos requisitos dan forma a la arquitectura que se describe a continuación.


2. Arquitectura del sistema para la automatización del ciclo de vida del contrato

Un sistema de gestión del ciclo de vida de contratos tiene tres capas. Cada capa tiene una responsabilidad específica, y los límites entre ellas son donde los fallos de producción se detectan o se escapan.

Arquitectura del ciclo de vida del contrato: una capa de instrucciones alimenta a un orquestador de etapas que impulsa cinco etapas de procesamiento de documentos, cada una respaldada por Spire.Agent.Office

Capa 1: Interfaz de instrucciones

El punto de entrada es una instrucción en lenguaje natural que describe el resultado deseado, no los pasos mecánicos. «Redacta un acuerdo de proveedor para Acme Corp usando la plantilla estándar, revísalo en busca de términos de pago no estándar y envíalo a Legal si el límite de responsabilidad supera los $500,000.» La capa de instrucciones analiza esto en un plan de pipeline: qué etapas ejecutar, en qué orden y qué parámetros necesita cada etapa.

Capa 2: Orquestador de etapas

El orquestador gestiona el flujo entre etapas. Mantiene un objeto compartido ContractContext que transporta metadatos del contrato, el documento actual y los resultados de cada etapa de una a la siguiente. Cada etapa recibe el contexto, realiza su operación y devuelve un contexto actualizado más un resultado de etapa. El orquestador decide si continuar, reintentar o derivar a un manejador de excepciones según el resultado.

Capa 3: Procesamiento de documentos

Cada etapa llama a Spire.Agent.Office para realizar la operación de documento real. El agente maneja el razonamiento de IA (entender la instrucción, extraer información) y la capa de documentos maneja las operaciones de archivo (cargar, modificar, guardar). El despacho de formato se aplica en etapas donde se esperan múltiples formatos de documento: una entrada .pdf usa PdfDocument, mientras que una entrada .docx usa Document.

El contexto del contrato

El objeto de estado compartido que fluye a través del pipeline:

/// <summary>
/// Shared state that flows through every lifecycle stage.
/// Each stage reads from and writes to this context.
/// </summary>
public class ContractContext
{
    // Identity
    public string ContractId { get; set; } = string.Empty;
    public string ContractType { get; set; } = string.Empty;  // "NDA", "MSA", "Vendor", etc.

    // Document state
    public string CurrentFilePath { get; set; } = string.Empty;
    public string WorkDir { get; set; } = string.Empty;
    public int VersionNumber { get; set; } = 1;

    // Stage results
    public DraftResult? Draft { get; set; }
    public ReviewResult? Review { get; set; }
    public NegotiationResult? Negotiation { get; set; }
    public ApprovalResult? Approval { get; set; }
    public ObligationResult? Obligations { get; set; }

    // Pipeline metadata
    public string Status { get; set; } = PipelineStages.Pending;
    public List<string> StageLog { get; set; } = new();
    public string? ErrorMessage { get; set; }
}

/// <summary>
/// Stage status constants. Using named constants instead of raw strings
/// prevents the "Status never set" bug where results fall through all
/// reporting buckets and silently disappear from batch summaries.
/// </summary>
public static class PipelineStages
{
    public const string Pending    = "Pending";
    public const string Drafted    = "Drafted";
    public const string Reviewed   = "Reviewed";
    public const string Negotiated = "Negotiated";
    public const string Approved   = "Approved";
    public const string Executed   = "Executed"; // In this sample: finalized post-approval PDF, not e-signature
    public const string Monitored  = "Monitored";
    public const string Failed     = "Failed";
    public const string NeedsReview = "NeedsReview";
}

El campo Status usa constantes con nombre en lugar de cadenas sin formato. Esto evita un modo de fallo común en el que el estado nunca se establece explícitamente, lo que hace que los documentos caigan en todos los grupos de informes y desaparezcan de los resúmenes de lotes.


3. Etapa 1: Redacción a partir de plantilla y datos

La etapa de redacción toma una plantilla (.docx con marcadores {{Placeholder}}) y una fuente de datos (hoja de cálculo de Excel o entrada estructurada), y luego produce un contrato completado. El agente de IA lee la estructura de la plantilla y completa los marcadores con datos de la fuente: una instrucción reemplaza el código de mapeo de campos que requiere un SDK tradicional.

using Spire.Agent.Office.AI;
using Spire.Agent.Office.Extensions;
using Spire.Doc;

public class DraftResult
{
    public string OutputPath { get; set; } = string.Empty;
    public int ContractsGenerated { get; set; }
    public List<string> PlaceholdersFilled { get; set; } = new();
}

public class DraftingStage
{
    private readonly AIOptions _options;

    public DraftingStage(AIOptions options) => _options = options;

    public DraftResult Execute(ContractContext context, string templatePath, string dataSourcePath)
    {
        // Format dispatch: verify the template is .docx before loading
        if (!templatePath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase))
            throw new NotSupportedException(
                $"Drafting requires a .docx template. Received: {templatePath}");

        string[] attachments = { dataSourcePath };

        using (Document template = new Document())
        {
            template.LoadFromFile(templatePath);

            string draftPath = Path.Combine(context.WorkDir, $"{context.ContractId}-v1-draft.docx");

            string draftInstruction =
                "Read the data source and fill every {{Placeholder}} field in this template " +
                "with the corresponding data. Preserve the template's layout, styling, and " +
                "clause numbering. Save the completed contract to: " +
                $"{draftPath}. Contract type: {context.ContractType}.";

            AIResult result = template.AI(_options).ExecuteInstruction(
                template, draftInstruction, draftPath, attachments);

            if (result == null || !result.Success)
                throw new InvalidOperationException(
                    $"Drafting failed: {result?.ErrorMessage ?? "Unknown error"}");

            // Collect the generated file: prefer the explicit output path,
            // fall back to AIResult.OutputFiles (unreliable for some product types).
            string? generated = File.Exists(draftPath)
                ? draftPath
                : result.OutputFiles?.FirstOrDefault(p => File.Exists(p));

            if (generated == null)
                throw new InvalidOperationException(
                    "Drafting completed but no output file was found (neither at the requested " +
                    $"path '{draftPath}' nor in AIResult.OutputFiles).");

            context.CurrentFilePath = generated;
            context.VersionNumber = 1;
            context.Status = PipelineStages.Drafted;
            context.StageLog.Add($"Draft: generated contract at {generated}");

            return new DraftResult
            {
                OutputPath = generated,
                ContractsGenerated = 1,
                PlaceholdersFilled = ExtractPlaceholderNames(templatePath)
            };
        }
    }

    private static List<string> ExtractPlaceholderNames(string templatePath)
    {
        // Quick scan of the template for {{...}} markers to report what was filled
        var placeholders = new List<string>();
        using (Document doc = new Document())
        {
            doc.LoadFromFile(templatePath);
            string text = doc.GetText();
            var matches = System.Text.RegularExpressions.Regex.Matches(
                text, @"\{\{(\w+)\}\}");
            foreach (System.Text.RegularExpressions.Match m in matches)
                if (!placeholders.Contains(m.Groups[1].Value))
                    placeholders.Add(m.Groups[1].Value);
        }
        return placeholders;
    }
}

Llamadas clave a la API

  • Document.LoadFromFile() — carga la plantilla .docx con marcadores
  • template.AI(_options) — adjunta el procesador de documentos de IA
  • ExecuteInstruction(doc, instruction, outputPath, attachments) — completa los marcadores desde la fuente de datos; pase una ruta absoluta explícita para que el agente escriba el producto en una ubicación conocida
  • Recolección de productos: verifique primero File.Exists(outputPath); AIResult.OutputFiles no es confiable para algunos tipos de producto y solo debe usarse como respaldo

Despacho de formato

La etapa valida el formato de entrada antes de procesar. Una plantilla .pdf o un formato no compatible se rechaza explícitamente con un mensaje claro, en lugar de fallar dentro del agente con una excepción opaca. Este patrón evita un modo de fallo común en el que la falta de despacho de formato hace que las entradas PDF lancen excepciones no controladas en lo profundo del pipeline de procesamiento.

Redactar un contrato es el caso simple. Cuando la misma plantilla debe completarse para docenas de registros —nuevas contrataciones, proveedores, renovaciones— el patrón de instrucciones escala a salida por lotes sin cambios; Generación de contratos por lotes con Spire.Agent.Office cubre tanto la ruta de combinación de correspondencia como la de reemplazo de marcadores, y compara dónde encaja cada una.

Nota del SDK: Cuando se pasa un outputPath explícito a ExecuteInstruction, el SDK también puede escribir una copia output-<filename> en el mismo directorio. Este duplicado tiene contenido idéntico y puede eliminarse de forma segura después del procesamiento. Todas las etapas que especifican rutas de salida se ven afectadas.

El producto de esta etapa es el documento completado en sí: la plantilla del acuerdo de proveedor con cada marcador reemplazado desde el libro maestro de proveedores.

Salida de ejemplo: CTR-2026-001-v1-draft.docx generado a partir de la plantilla de acuerdo de proveedor con cada campo de marcador completado desde el libro maestro de proveedores


4. Etapa 2: Revisión y análisis de riesgos

La etapa de revisión lee el contrato redactado, identifica cláusulas riesgosas o no estándar y produce un informe de revisión estructurado. A diferencia de la etapa de redacción, la entrada puede ser .docx (borrador interno) o .pdf (documento de la contraparte), por lo que la etapa debe despachar al tipo de documento correcto.

using Spire.Agent.Office.AI;
using Spire.Agent.Office.Extensions;
using Spire.Doc;
using Spire.Pdf;

public class ReviewResult
{
    public string ReportPath { get; set; } = string.Empty;
    public int ClausesAnalyzed { get; set; }
    public int RiskFlags { get; set; }
    public double RiskScore { get; set; }     // ratio of flagged clauses to total (0.0 = none flagged, 1.0 = all flagged)
    public List<string> FlaggedClauses { get; set; } = new();
}

public class ReviewStage
{
    private readonly AIOptions _options;

    // Risk threshold: contracts above this score require manual review.
    // Using a named constant prevents the "threshold always passes" bug
    // where a miscalculated score boundary lets everything auto-approve.
    public const double AutoApproveThreshold = 0.3;
    public const double ManualReviewThreshold = 0.6;

    public ReviewStage(AIOptions options) => _options = options;

    public ReviewResult Execute(ContractContext context)
    {
        string filePath = context.CurrentFilePath;
        string reportPath = Path.Combine(context.WorkDir, $"{context.ContractId}-review.md");

        string reviewInstruction =
            "Review this contract and write a Markdown report with two sections:\n" +
            "1. A table listing each major clause, its type, and a risk score (0-1).\n" +
            "2. A bullet list of clauses that deviate from standard practice for a " +
            $"standard {context.ContractType}. " +
            "Flag any of: uncapped liability, automatic renewal without notice, " +
            "broad indemnification, unilateral termination, or payment terms exceeding 60 days. " +
            "Prefix each flagged clause bullet with 'FLAG:' so the parser can identify it. " +
            $"Reference the source document as '{context.ContractId}', not as 'input.docx'.\n" +
            $"Save the report to: {reportPath}";

        AIResult result = DispatchByFormat(filePath, reviewInstruction, reportPath);

        if (result == null || !result.Success)
            throw new InvalidOperationException(
                $"Review failed: {result?.ErrorMessage ?? "Unknown error"}");

        // Parse the review report to extract structured data
        string reportContent = File.ReadAllText(reportPath);
        var review = ParseReviewReport(reportContent, reportPath);

        // Route based on risk score (three-tier classification)
        if (review.RiskScore >= ManualReviewThreshold)
            context.Status = PipelineStages.NeedsReview;
        else if (review.RiskScore >= AutoApproveThreshold)
        {
            // Middle zone: not clean enough to auto-approve, not risky enough to block
            context.Status = PipelineStages.Reviewed;
            context.StageLog.Add(
                $"Review: risk score {review.RiskScore:F2} in warning zone " +
                $"({AutoApproveThreshold}-{ManualReviewThreshold}); reviewed with warning");
        }
        else
            context.Status = PipelineStages.Reviewed;

        context.StageLog.Add(
            $"Review: {review.ClausesAnalyzed} clauses, {review.RiskFlags} flags, " +
            $"score {review.RiskScore:F2}, status={context.Status}");

        return review;
    }

    /// <summary>
    /// Dispatch to the correct document type based on file extension.
    /// This prevents the "PDF throws exception" bug where a stage
    /// only handles .docx and fails on counterparty PDFs.
    /// </summary>
    private AIResult DispatchByFormat(string filePath, string instruction, string outputPath)
    {
        string ext = Path.GetExtension(filePath).ToLowerInvariant();

        return ext switch
        {
            ".docx" or ".doc" => ProcessWord(filePath, instruction, outputPath),
            ".pdf"            => ProcessPdf(filePath, instruction, outputPath),
            _ => throw new NotSupportedException(
                $"Review stage does not support format: {ext}")
        };
    }

    private AIResult ProcessWord(string filePath, string instruction, string outputPath)
    {
        using (Document doc = new Document())
        {
            doc.LoadFromFile(filePath);
            return doc.AI(_options).ExecuteInstruction(
                doc, instruction, outputPath, Array.Empty<string>());
        }
    }

    private AIResult ProcessPdf(string filePath, string instruction, string outputPath)
    {
        using (PdfDocument pdf = new PdfDocument())
        {
            pdf.LoadFromFile(filePath);
            return pdf.AI(_options).ExecuteInstruction(
                pdf, instruction, outputPath, Array.Empty<string>());
        }
    }

    private static ReviewResult ParseReviewReport(string markdown, string reportPath)
    {
        var result = new ReviewResult { ReportPath = reportPath };

        // Count table rows as clauses analyzed
        var tableLines = markdown.Split('\n')
            .Where(l => l.StartsWith("|") && !l.StartsWith("|---") && !l.StartsWith("| --"))
            .Skip(1); // skip header
        result.ClausesAnalyzed = tableLines.Count();

        // Count bullet points prefixed with "FLAG:" as risk flags
        var flagLines = markdown.Split('\n')
            .Select(l => l.TrimStart())
            .Where(l => l.StartsWith("-") || l.StartsWith("*"))
            .Where(l => l.Substring(1).TrimStart()
                .StartsWith("FLAG:", StringComparison.OrdinalIgnoreCase));
        result.FlaggedClauses = flagLines.Select(l => l.Trim()).ToList();
        result.RiskFlags = result.FlaggedClauses.Count;

        // Risk score: ratio of flagged clauses to total, clamped to [0, 1]
        result.RiskScore = result.ClausesAnalyzed > 0
            ? Math.Min(1.0, (double)result.RiskFlags / result.ClausesAnalyzed)
            : 0.0;

        return result;
    }
}

El método DispatchByFormat es la adición crítica. En implementaciones anteriores del pipeline, una etapa que solo manejaba .docx lanzaba una InvalidOperationException cuando una contraparte enviaba un .pdf. La expresión switch despacha al tipo de documento correcto antes de que se ejecute el agente, por lo que ambos formatos son entradas de primera clase.

Los umbrales de riesgo (AutoApproveThreshold, ManualReviewThreshold) son constantes con nombre con semántica documentada. La brecha entre 0.3 y 0.6 crea una zona explícita de «necesita revisión»: contratos que no son lo suficientemente limpios para aprobar automáticamente ni lo suficientemente riesgosos para bloquear. Esto evita el error de lógica muerta del umbral donde cada contrato pasa la misma verificación.

Los hallazgos de la revisión se conservan como un informe en lugar de devolverse en memoria, por lo que la auditoría a nivel de cláusula sobrevive independientemente de la ejecución que la produjo.

Salida de ejemplo: CTR-2026-001-review.md de la Etapa 2, que muestra la tabla de riesgos cláusula por cláusula y las cláusulas marcadas para revisión humana


5. Etapa 3: Negociación y control de versiones

La negociación es donde los contratos cambian de manos. La contraparte marca el documento —editando cláusulas, ajustando términos, añadiendo condiciones—. El sistema debe comparar versiones, rastrear cambios y ayudar al equipo a decidir qué aceptar.

Esta etapa utiliza dos capacidades de la capa de documentos Spire.Doc que van más allá del ExecuteInstruction del agente de IA:

  • Control de cambios —habilita el seguimiento de revisiones para que cada edición sea visible y atribuible
  • Comparación de documentos —compara dos versiones y genera un documento de diferencias
using Spire.Agent.Office.AI;
using Spire.Agent.Office.Extensions;
using Spire.Doc;
using Spire.Doc.Documents;
using System.Text.RegularExpressions;

public class NegotiationResult
{
    public string ComparisonPath { get; set; } = string.Empty;
    public int ChangesDetected { get; set; }
    public int SubstantiveChanges { get; set; }  // changes to clause text, not formatting
    public int FormattingChanges { get; set; }
    public string NegotiatedFilePath { get; set; } = string.Empty;
}

public class NegotiationStage
{
    private readonly AIOptions _options;

    public NegotiationStage(AIOptions options) => _options = options;

    /// <summary>
    /// Compare the current version with a counterparty's revised version.
    /// Produces a diff document with all changes marked.
    /// </summary>
    public NegotiationResult CompareVersions(
        ContractContext context, string counterpartyFilePath)
    {
        string comparisonPath = Path.Combine(
            context.WorkDir, $"{context.ContractId}-v{context.VersionNumber}-comparison.docx");

        // Load both versions
        using (Document ourVersion = new Document())
        using (Document theirVersion = new Document())
        {
            ourVersion.LoadFromFile(context.CurrentFilePath);
            theirVersion.LoadFromFile(counterpartyFilePath);

            // Compare: marks every difference in ourVersion as a tracked change
            ourVersion.Compare(theirVersion, "Contract Management System");

            // Save the comparison document
            ourVersion.SaveToFile(comparisonPath, FileFormat.Docx2013);
        }

        // Use the AI agent to analyze the comparison and categorize changes
        string analysisPath = Path.Combine(
            context.WorkDir, $"{context.ContractId}-negotiation-analysis.md");

        using (Document comparison = new Document())
        {
            comparison.LoadFromFile(comparisonPath);

            string analyzeInstruction =
                "Analyze this document comparison and write a Markdown report:\n" +
                "1. On the first line, write 'Total changes: <N>' where N is the total count " +
                "of categorized changes (substantive + formatting).\n" +
                "2. List each change as a bullet line prefixed with 'SUBSTANTIVE:' or 'FORMATTING:' " +
                "followed by the clause affected and the nature of the change.\n" +
                "3. SUBSTANTIVE changes: clause text, numbers, dates. " +
                "FORMATTING changes: style, spacing, font.\n" +
                $"Save to: {analysisPath}";

            AIResult result = comparison.AI(_options).ExecuteInstruction(
                comparison, analyzeInstruction, analysisPath, Array.Empty<string>());

            if (result == null || !result.Success)
                throw new InvalidOperationException(
                    $"Negotiation analysis failed: {result?.ErrorMessage}");
        }

        var negotiation = ParseNegotiationReport(analysisPath);
        negotiation.ComparisonPath = comparisonPath;
        negotiation.NegotiatedFilePath = counterpartyFilePath;

        context.VersionNumber++;
        context.CurrentFilePath = counterpartyFilePath;
        context.Status = PipelineStages.Negotiated;
        context.StageLog.Add(
            $"Negotiation: {negotiation.ChangesDetected} changes " +
            $"({negotiation.SubstantiveChanges} substantive, " +
            $"{negotiation.FormattingChanges} formatting), v{context.VersionNumber}");

        return negotiation;
    }

    /// <summary>
    /// Enable track changes on a contract before sending it for negotiation.
    /// This ensures every edit by the counterparty is visible and attributable.
    /// </summary>
    public string PrepareForRedlining(ContractContext context)
    {
        string redlineReadyPath = Path.Combine(
            context.WorkDir, $"{context.ContractId}-v{context.VersionNumber}-redline.docx");

        using (Document doc = new Document())
        {
            doc.LoadFromFile(context.CurrentFilePath);

            // Enable track changes so all edits are recorded as revisions
            doc.TrackChanges = true;

            doc.SaveToFile(redlineReadyPath, FileFormat.Docx2013);
        }

        context.StageLog.Add("Negotiation: track changes enabled for redlining");
        return redlineReadyPath;
    }

    /// <summary>
    /// Accept all tracked changes to produce a clean final version.
    /// Optional post-review operation: not automatically invoked by the pipeline
    /// because acceptance should occur only after human review of the changes.
    /// </summary>
    public string AcceptAllChanges(ContractContext context)
    {
        string cleanPath = Path.Combine(
            context.WorkDir, $"{context.ContractId}-v{context.VersionNumber}-final.docx");

        using (Document doc = new Document())
        {
            doc.LoadFromFile(context.CurrentFilePath);
            doc.AcceptChanges();
            doc.SaveToFile(cleanPath, FileFormat.Docx2013);
        }

        context.CurrentFilePath = cleanPath;
        context.StageLog.Add("Negotiation: all changes accepted, clean version produced");
        return cleanPath;
    }

    private static NegotiationResult ParseNegotiationReport(string reportPath)
    {
        string content = File.ReadAllText(reportPath);
        var result = new NegotiationResult();

        // Parse total changes from the report (try multiple patterns for robustness)
        var totalMatch = Regex.Match(content, @"Total changes:\s*(\d+)", RegexOptions.IgnoreCase);
        if (!totalMatch.Success)
            totalMatch = Regex.Match(content, @"(\d+)\s+(?:tracked\s+)?changes", RegexOptions.IgnoreCase);
        if (totalMatch.Success)
            result.ChangesDetected = int.Parse(totalMatch.Groups[1].Value);

        // Count changes by prefix: SUBSTANTIVE: or FORMATTING:
        result.SubstantiveChanges = CountByPrefix(content, "SUBSTANTIVE:");
        result.FormattingChanges = CountByPrefix(content, "FORMATTING:");

        return result;
    }

    private static int CountByPrefix(string content, string prefix)
    {
        return content.Split('\n')
            .Select(l => l.TrimStart())
            .Where(l => l.StartsWith("-") || l.StartsWith("*"))
            .Count(l => l.Substring(1).TrimStart()
                .StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
    }
}

Llamadas clave a la API

  • Document.Compare(otherDoc, authorName) — genera un documento de diferencias con todos los cambios marcados como revisiones controladas (del ejemplo de comparación de Spire.Doc)
  • Document.TrackChanges = true — habilita el seguimiento de revisiones para que cada edición sea visible y atribuible
  • Document.AcceptChanges() — acepta todos los cambios controlados para producir una versión final limpia
  • doc.AI(_options).ExecuteInstruction(...) — analiza la comparación y categoriza los cambios

La etapa de negociación combina operaciones deterministas de documentos (comparar, controlar cambios, aceptar) con análisis de IA (categorizar cambios como sustantivos o de formato). Las operaciones deterministas provienen de la capa Spire.Doc —las mismas API disponibles en el directorio de referencia de código— mientras que el agente de IA se encarga de la categorización semántica que de otro modo requeriría revisión manual.

El ejemplo compara versiones y rastrea la ruta del archivo actual; un sistema de producción normalmente persistiría cada versión por separado y registraría explícitamente qué revisión se aceptó. AcceptAllChanges() se proporciona como una operación de finalización opcional después de la negociación, pero el pipeline de ejemplo no la invoca automáticamente porque la aceptación debe ocurrir solo después de la revisión humana.

Lo que Document.Compare devuelve es un documento, no un resumen: las revisiones controladas a continuación son las marcas rojas que un revisor tendría que ensamblar a mano.

Salida de ejemplo: CTR-2026-001-v1-comparison.docx abierto en Word con marcas de revisión en texto insertado y eliminado entre versiones del contrato


6. Etapa 4: Aprobación y finalización

La etapa de aprobación dirige el contrato a las partes interesadas correctas según el tipo y el valor del contrato, registra sus aprobaciones y produce una copia PDF finalizada después de que se registran todas las aprobaciones requeridas. La lógica de enrutamiento es determinista —usa reglas de negocio, no IA— pero el agente ayuda sugiriendo aprobadores y generando el resumen de aprobación.

using Spire.Agent.Office.AI;
using Spire.Agent.Office.Extensions;
using Spire.Doc;

public class ApprovalResult
{
    public List<ApprovalRecord> Approvals { get; set; } = new();
    public bool AllApproved { get; set; }
    public string ExecutedFilePath { get; set; } = string.Empty; // Finalized PDF path, not a signed contract
    public DateTime? ExecutionDate { get; set; }                // Finalization timestamp, not a signing date
}

public class ApprovalRecord
{
    public string Approver { get; set; } = string.Empty;
    public string Role { get; set; } = string.Empty;
    public bool Approved { get; set; }
    public DateTime Timestamp { get; set; }
    public string? Comments { get; set; }
}

public class ApprovalStage
{
    private readonly AIOptions _options;

    public ApprovalStage(AIOptions options) => _options = options;

    /// <summary>
    /// Route the contract for approval based on type and value.
    /// Routing rules are deterministic business logic, not AI.
    /// </summary>
    public ApprovalResult Execute(
        ContractContext context, double contractValue,
        Dictionary<string, bool>? approvalDecisions = null)
    {
        var routingPlan = DetermineApprovers(context.ContractType, contractValue);
        var result = new ApprovalResult();

        // Generate an approval summary for each approver
        string summaryPath = Path.Combine(
            context.WorkDir, $"{context.ContractId}-approval-summary.md");

        using (Document doc = new Document())
        {
            doc.LoadFromFile(context.CurrentFilePath);

            string summaryInstruction =
                "Write a one-page approval summary for this contract: parties, value, " +
                "key terms, risk flags from review, and changes from negotiation. " +
                $"Save to: {summaryPath}";

            AIResult aiResult = doc.AI(_options).ExecuteInstruction(
                doc, summaryInstruction, summaryPath, Array.Empty<string>());

            if (aiResult == null || !aiResult.Success)
                throw new InvalidOperationException(
                    $"Approval summary generation failed: {aiResult?.ErrorMessage}");
        }

        // In a real system, this would integrate with an approval workflow API.
        // approvalDecisions maps approver name → approved/rejected. A missing key
        // means no decision was recorded — which is not the same as a rejection.
        int approvedCount = 0, rejectedCount = 0, pendingCount = 0;

        foreach (var approver in routingPlan)
        {
            bool decision = false;
            bool hasDecision = approvalDecisions != null
                && approvalDecisions.TryGetValue(approver.Name, out decision);
            bool approved = hasDecision && decision;

            // Three states, all distinguishable in the audit record
            string comment = !hasDecision ? "Pending approval"
                : approved ? "Approved by approver"
                : "Rejected by approver";

            if (!hasDecision) pendingCount++;
            else if (approved) approvedCount++;
            else rejectedCount++;

            result.Approvals.Add(new ApprovalRecord
            {
                Approver = approver.Name,
                Role = approver.Role,
                Approved = approved,
                Timestamp = DateTime.UtcNow,
                Comments = comment
            });
        }

        result.AllApproved = result.Approvals.All(a => a.Approved);

        if (result.AllApproved)
        {
            // Produce the finalized copy as PDF
            string executedPath = Path.Combine(
                context.WorkDir, $"{context.ContractId}-executed.pdf");

            using (Document doc = new Document())
            {
                doc.LoadFromFile(context.CurrentFilePath);
                doc.SaveToFile(executedPath, FileFormat.PDF);
                result.ExecutedFilePath = executedPath;
                result.ExecutionDate = DateTime.UtcNow;
            }

            context.CurrentFilePath = result.ExecutedFilePath;
            context.Status = PipelineStages.Executed;
        }
        else
        {
            context.Status = PipelineStages.NeedsReview;
        }

        context.StageLog.Add(
            $"Approval: {result.Approvals.Count} approvers, " +
            $"approved={approvedCount}, rejected={rejectedCount}, " +
            $"pending={pendingCount}, all approved={result.AllApproved}");

        return result;
    }

    /// <summary>
    /// Deterministic routing rules. These are business logic, not AI.
    /// </summary>
    private static List<(string Name, string Role)> DetermineApprovers(
        string contractType, double value)
    {
        var approvers = new List<(string, string)>();

        // All contracts need Legal sign-off (example business rule)
        approvers.Add(("Legal Team", "Legal Counsel"));

        // Contracts over $100K need VP approval (example business rule)
        if (value > 100_000)
            approvers.Add(("VP Operations", "VP"));

        // Contracts over $500K need CFO approval (example business rule)
        if (value > 500_000)
            approvers.Add(("CFO", "CFO"));

        // Vendor contracts need Procurement sign-off
        if (contractType.Equals("Vendor", StringComparison.OrdinalIgnoreCase))
            approvers.Add(("Procurement", "Procurement Manager"));

        return approvers;
    }
}

Las reglas de enrutamiento en DetermineApprovers son intencionalmente deterministas. La IA sugiere y resume; no decide quién firma un contrato. La cadena de aprobación es una regla de negocio que debe ser auditable y consistente —exactamente el tipo de decisión que pertenece al código, no a un modelo de lenguaje.

Nota: El estado Executed en este ejemplo significa el PDF final producido después de la aprobación, no un contrato firmado. La firma electrónica real —sobres de firma, verificación de identidad del firmante, incrustación de campos de firma— debe manejarse mediante un flujo de trabajo de firma electrónica dedicado integrado por separado.

Una vez que la decisión de cada aprobador está registrada, la etapa escribe el contrato finalizado como PDF, y ese PDF, no el borrador fuente, es lo que consume la Etapa 5.

Salida de ejemplo: CTR-2026-001-executed.pdf, el contrato finalizado producido por la Etapa 4 después de que todos los aprobadores aprobaron


7. Etapa 5: Monitoreo posterior a la firma

En un flujo de trabajo de producción, el monitoreo posterior a la firma comienza después de que el contrato ha sido firmado electrónicamente o de otra forma formalmente. En esta implementación de referencia, el PDF finalizado producido por la Etapa 4 se utiliza como entrada para el monitoreo. El monitoreo posterior a la firma extrae obligaciones, fechas clave y términos de renovación del contrato, y luego los almacena como metadatos estructurados que los sistemas posteriores usan para recordatorios e informes.

using Spire.Agent.Office.AI;
using Spire.Agent.Office.Extensions;
using Spire.Doc;
using Spire.Pdf;

public class ObligationResult
{
    public List<Obligation> Obligations { get; set; } = new();
    public List<KeyDate> KeyDates { get; set; } = new();
    public bool AutoRenewal { get; set; }
    public DateTime? RenewalDate { get; set; }
    public string MetadataPath { get; set; } = string.Empty;
}

public class Obligation
{
    public string Description { get; set; } = string.Empty;
    public string Party { get; set; } = string.Empty;  // who must fulfill
    public string Frequency { get; set; } = string.Empty;  // "monthly", "annual", "one-time"
    public DateTime? DueDate { get; set; }
    public string DueDateRaw { get; set; } = string.Empty;  // original deadline text from the contract
}

public class KeyDate
{
    public string Description { get; set; } = string.Empty;
    public DateTime Date { get; set; }
    public string Type { get; set; } = string.Empty;  // "renewal", "termination", "milestone"
}

public class MonitoringStage
{
    private readonly AIOptions _options;

    public MonitoringStage(AIOptions options) => _options = options;

    public ObligationResult Execute(ContractContext context)
    {
        string metadataPath = Path.Combine(
            context.WorkDir, $"{context.ContractId}-obligations.json");

        string extractInstruction =
            "Extract all post-signature obligations and key dates from this contract. " +
            "Write a JSON file with:\n" +
            "1. \"obligations\": array of {description, party, frequency, dueDate, dueDateRaw} " +
            "where dueDate is an ISO-8601 date (YYYY-MM-DD). If the obligation has a relative " +
            "deadline (e.g., 'Within 90 days of receipt'), convert it to an absolute date based " +
            "on the contract effective date. Use null only for ongoing obligations with no " +
            "fixed deadline. Always put the original deadline text in dueDateRaw.\n" +
            "2. \"keyDates\": array of {description, date, type} where date is ISO-8601 " +
            "(YYYY-MM-DD) and type is 'renewal', 'termination', or 'milestone'\n" +
            "3. \"autoRenewal\": boolean\n" +
            "4. \"renewalDate\": ISO-8601 date (YYYY-MM-DD) or null\n" +
            $"Save to: {metadataPath}";

        // Dispatch by format — executed contracts may be PDF or DOCX
        AIResult result = DispatchExtraction(
            context.CurrentFilePath, extractInstruction, metadataPath, _options);

        if (result == null || !result.Success)
            throw new InvalidOperationException(
                $"Obligation extraction failed: {result?.ErrorMessage}");

        var obligations = ParseObligationMetadata(metadataPath);
        obligations.MetadataPath = metadataPath;

        context.Obligations = obligations;
        context.Status = PipelineStages.Monitored;

        // Diagnostics: bucket every obligation by how its deadline came out, so that a
        // field the agent writes but the parser fails to read cannot pass unnoticed.
        int datesParsed = obligations.Obligations.Count(o => o.DueDate != null);
        int datesUnparsed = obligations.Obligations.Count(
            o => o.DueDate == null && !string.IsNullOrEmpty(o.DueDateRaw));
        int datesOngoing = obligations.Obligations.Count(
            o => o.DueDate == null && string.IsNullOrEmpty(o.DueDateRaw));
        int datesNoSourceText = obligations.Obligations.Count(
            o => o.DueDate != null && string.IsNullOrEmpty(o.DueDateRaw));
        context.StageLog.Add(
            $"Monitoring: {obligations.Obligations.Count} obligations, " +
            $"{obligations.KeyDates.Count} key dates, " +
            $"auto-renewal={obligations.AutoRenewal}, " +
            $"dates parsed={datesParsed}, unparsed={datesUnparsed}, ongoing={datesOngoing}, " +
            $"parsed-without-source-text={datesNoSourceText}");

        return obligations;
    }

    private static AIResult DispatchExtraction(
        string filePath, string instruction, string outputPath, AIOptions options)
    {
        string ext = Path.GetExtension(filePath).ToLowerInvariant();

        return ext switch
        {
            ".docx" or ".doc" => ProcessWordExtraction(filePath, instruction, outputPath, options),
            ".pdf"            => ProcessPdfExtraction(filePath, instruction, outputPath, options),
            _ => throw new NotSupportedException(
                $"Obligation extraction does not support format: {ext}")
        };
    }

    private static AIResult ProcessWordExtraction(
        string filePath, string instruction, string outputPath, AIOptions options)
    {
        using (Document doc = new Document())
        {
            doc.LoadFromFile(filePath);
            return doc.AI(options).ExecuteInstruction(
                doc, instruction, outputPath, Array.Empty<string>());
        }
    }

    private static AIResult ProcessPdfExtraction(
        string filePath, string instruction, string outputPath, AIOptions options)
    {
        using (PdfDocument pdf = new PdfDocument())
        {
            pdf.LoadFromFile(filePath);
            return pdf.AI(options).ExecuteInstruction(
                pdf, instruction, outputPath, Array.Empty<string>());
        }
    }

    private static ObligationResult ParseObligationMetadata(string jsonPath)
    {
        string json = File.ReadAllText(jsonPath);
        using var doc = System.Text.Json.JsonDocument.Parse(json);
        var root = doc.RootElement;

        var result = new ObligationResult();

        if (TryGetPropertyIgnoreCase(root, "obligations", out var obs))
            foreach (var ob in obs.EnumerateArray())
            {
                string dueDateRaw = TryGetPropertyIgnoreCase(ob, "dueDateRaw", out var dr)
                    && dr.ValueKind == System.Text.Json.JsonValueKind.String
                    ? dr.GetString() ?? "" : "";

                result.Obligations.Add(new Obligation
                {
                    Description = TryGetPropertyIgnoreCase(ob, "description", out var d) ? d.GetString() ?? "" : "",
                    Party = TryGetPropertyIgnoreCase(ob, "party", out var p) ? p.GetString() ?? "" : "",
                    Frequency = TryGetPropertyIgnoreCase(ob, "frequency", out var f) ? f.GetString() ?? "" : "",
                    DueDate = TryGetDate(TryGetPropertyIgnoreCase(ob, "dueDate", out var dd2), dd2),
                    DueDateRaw = dueDateRaw
                });
            }

        if (TryGetPropertyIgnoreCase(root, "keyDates", out var kds))
            foreach (var kd in kds.EnumerateArray())
                result.KeyDates.Add(new KeyDate
                {
                    Description = TryGetPropertyIgnoreCase(kd, "description", out var d) ? d.GetString() ?? "" : "",
                    Date = TryGetDate(TryGetPropertyIgnoreCase(kd, "date", out var dt), dt) ?? default,
                    Type = TryGetPropertyIgnoreCase(kd, "type", out var t) ? t.GetString() ?? "" : ""
                });

        result.AutoRenewal = TryGetBool(TryGetPropertyIgnoreCase(root, "autoRenewal", out var ar), ar);
        result.RenewalDate = TryGetDate(TryGetPropertyIgnoreCase(root, "renewalDate", out var rd), rd);

        return result;
    }

    /// <summary>
    /// Case-insensitive property lookup. Prevents silent data loss when
    /// the AI agent outputs PascalCase or snake_case instead of camelCase.
    /// </summary>
    private static bool TryGetPropertyIgnoreCase(
        System.Text.Json.JsonElement element, string name,
        out System.Text.Json.JsonElement value)
    {
        foreach (var prop in element.EnumerateObject())
        {
            if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
            {
                value = prop.Value;
                return true;
            }
        }
        value = default;
        return false;
    }

    /// <summary>
    /// Safe date parsing: handles null, non-string values, and invalid formats
    /// without throwing. Returns null on any parse failure.
    /// </summary>
    private static DateTime? TryGetDate(bool exists, System.Text.Json.JsonElement element)
    {
        if (!exists || element.ValueKind != System.Text.Json.JsonValueKind.String)
            return null;
        string? s = element.GetString();
        return DateTime.TryParse(s, out var date) ? date : null;
    }

    /// <summary>
    /// Safe boolean parsing: handles string "true"/"false" and actual booleans
    /// without throwing on type mismatch.
    /// </summary>
    private static bool TryGetBool(bool exists, System.Text.Json.JsonElement element)
    {
        if (!exists) return false;
        if (element.ValueKind == System.Text.Json.JsonValueKind.True) return true;
        if (element.ValueKind == System.Text.Json.JsonValueKind.False) return false;
        if (element.ValueKind == System.Text.Json.JsonValueKind.String)
            return bool.TryParse(element.GetString(), out var b) && b;
        return false;
    }
}

Los metadatos de obligaciones se almacenan como JSON, no como un documento. Esto los hace consultables por sistemas posteriores: un panel de renovaciones puede escanear todos los contratos en busca de autoRenewal == true y renewalDate < DateTime.Now.AddDays(90) sin abrir un solo documento. Las obligaciones con valores dueDate estructurados se pueden rastrear automáticamente; aquellas donde la fecha límite no se pudo convertir a una fecha absoluta conservan el texto original en dueDateRaw para revisión manual. El agente de IA extrae la información; la capa determinista la almacena en un formato que los sistemas de negocio pueden consumir.

El patrón de extracción es el mismo que se usa para facturas en Automatizar el procesamiento de facturas con un agente de IA en .NET: extraer campos estructurados de un documento entrante, validarlos contra reglas deterministas y enrutar los valores inciertos para su revisión. Los contratos difieren principalmente en lo que ocurre después: las obligaciones se convierten en registros de larga vida que sobreviven a la transacción, mientras que los campos de factura se consumen una vez.

Los datos extraídos se almacenan como JSON en lugar de prosa, por lo que cada obligación y fecha clave lleva la redacción original del contrato junto con el valor que el analizador derivó de ella.

Salida de ejemplo: CTR-2026-001-obligations.json de la Etapa 5, que enumera cada obligación extraída y fecha clave junto con el texto original del contrato


8. Orquestación del pipeline completo

El orquestador encadena las etapas, pasando el ContractContext de una a la siguiente. Sus dos responsabilidades críticas son el aislamiento de errores por contrato (un contrato fallido no debe detener el lote) y la conservación de estado (cada contrato debe terminar en exactamente un grupo de informes).

using Spire.Agent.Office.AI;

public class PipelineResult
{
    public string ContractId { get; set; } = string.Empty;
    public string Status { get; set; } = PipelineStages.Pending;
    public List<string> StageLog { get; set; } = new();
    public string? ErrorMessage { get; set; }
}

public class BatchResult
{
    public int Total { get; set; }
    public int Successful { get; set; }
    public int Flagged { get; set; }    // needs review
    public int Errored { get; set; }
    public List<PipelineResult> Results { get; set; } = new();
}

public class ContractLifecyclePipeline
{
    private readonly DraftingStage _drafting;
    private readonly ReviewStage _review;
    private readonly NegotiationStage _negotiation;
    private readonly ApprovalStage _approval;
    private readonly MonitoringStage _monitoring;

    public ContractLifecyclePipeline(AIOptions options)
    {
        _drafting = new DraftingStage(options);
        _review = new ReviewStage(options);
        _negotiation = new NegotiationStage(options);
        _approval = new ApprovalStage(options);
        _monitoring = new MonitoringStage(options);
    }

    /// <summary>
    /// Run a single contract through the full lifecycle.
    /// Each stage is wrapped in its own try-catch so that a failure
    /// in one stage produces a clear error without corrupting the
    /// context for subsequent contracts in the batch.
    /// </summary>
    public PipelineResult RunSingle(
        ContractContext context,
        string templatePath,
        string dataSourcePath,
        double contractValue,
        string? counterpartyFilePath = null,
        Dictionary<string, bool>? approvalDecisions = null)
    {
        try
        {
            // Stage 1: Drafting
            try
            {
                context.Draft = _drafting.Execute(context, templatePath, dataSourcePath);
            }
            catch (Exception ex)
            {
                return Fail(context, "Drafting", ex);
            }

            // Stage 2: Review
            try
            {
                context.Review = _review.Execute(context);
            }
            catch (Exception ex)
            {
                return Fail(context, "Review", ex);
            }

            // If review flagged for manual review, stop here
            if (context.Status == PipelineStages.NeedsReview)
                return Flag(context, "Review flagged for manual review");

            // Stage 3: Negotiation (only when a counterparty version exists)
            if (counterpartyFilePath != null)
            {
                try
                {
                    context.Negotiation = _negotiation.CompareVersions(context, counterpartyFilePath);
                }
                catch (Exception ex)
                {
                    return Fail(context, "Negotiation", ex);
                }
            }

            // Stage 4: Approval and Finalization
            try
            {
                context.Approval = _approval.Execute(context, contractValue, approvalDecisions);
            }
            catch (Exception ex)
            {
                return Fail(context, "Approval", ex);
            }

            if (context.Status == PipelineStages.NeedsReview)
                return Flag(context, "Approval incomplete");

            // Stage 5: Post-signature monitoring
            try
            {
                context.Obligations = _monitoring.Execute(context);
            }
            catch (Exception ex)
            {
                return Fail(context, "Monitoring", ex);
            }

            return new PipelineResult
            {
                ContractId = context.ContractId,
                Status = context.Status,
                StageLog = context.StageLog
            };
        }
        catch (Exception ex)
        {
            return Fail(context, "Pipeline", ex);
        }
    }

    /// <summary>
    /// Process a batch of contracts with per-file error isolation.
    /// One corrupt file does not halt the batch.
    /// Approval decisions are keyed by contract id; a contract with no
    /// entry keeps its approvers pending and stops at NeedsReview.
    /// </summary>
    public BatchResult RunBatch(
        List<(ContractContext context, string template, string data, double value)> contracts,
        Dictionary<string, Dictionary<string, bool>>? approvalDecisions = null)
    {
        var results = new List<PipelineResult>();

        foreach (var (context, template, data, value) in contracts)
        {
            // Per-contract try-catch: one failure does not stop the batch
            try
            {
                Dictionary<string, bool>? decisions = null;
                if (approvalDecisions != null &&
                    approvalDecisions.TryGetValue(context.ContractId, out var d))
                    decisions = d;

                results.Add(RunSingle(context, template, data, value, null, decisions));
            }
            catch (Exception ex)
            {
                results.Add(Fail(context, "Batch", ex));
            }
        }

        // Status conservation: every result must land in exactly one bucket.
        // Using the remainder method ensures that any result with an unexpected
        // status lands in "Flagged" (conservative) rather than vanishing.
        int successful = results.Count(r => r.Status == PipelineStages.Monitored
                                         || r.Status == PipelineStages.Executed);
        int errored = results.Count(r => r.Status == PipelineStages.Failed);

        return new BatchResult
        {
            Total = results.Count,
            Successful = successful,
            Flagged = results.Count - successful - errored,  // remainder → conservative
            Errored = errored,
            Results = results
        };
    }

    private static PipelineResult Fail(ContractContext context, string stage, Exception ex)
    {
        context.Status = PipelineStages.Failed;
        context.ErrorMessage = $"{stage}: {ex.Message}";
        context.StageLog.Add($"ERROR at {stage}: {ex.Message}");
        return new PipelineResult
        {
            ContractId = context.ContractId,
            Status = PipelineStages.Failed,
            StageLog = context.StageLog,
            ErrorMessage = context.ErrorMessage
        };
    }

    private static PipelineResult Flag(ContractContext context, string reason)
    {
        context.StageLog.Add($"FLAGGED: {reason}");
        return new PipelineResult
        {
            ContractId = context.ContractId,
            Status = context.Status,
            StageLog = context.StageLog
        };
    }
}

Aislamiento de errores por etapa

Cada etapa está envuelta en su propio bloque try-catch. Si la etapa de revisión falla porque el agente de IA no puede analizar una cláusula particularmente compleja, el pipeline registra el fallo y se detiene, pero el ContractContext del siguiente contrato del lote no se ve afectado. Esto evita un modo de fallo común en el que un solo archivo corrupto en un lote de 200 detiene todo el pipeline sin resultados parciales.

Conservación de estado

El resumen del lote usa el método del residuo para calcular el recuento de Flagged: Flagged = Total - Successful - Errored. Esta es una propiedad estructural: cualquier resultado que no sea explícitamente exitoso o erróneo cae en «marcado» (enrutamiento conservador a revisión manual). Esto evita el error en el que resultados con valores de estado inesperados desaparecen silenciosamente de los tres grupos de informes.

Ejecución del pipeline

// Configure the agent
AIOptions options = new AIOptions
{
    WorkDir = @"C:\contract-mgmt\work",
    SpireToken = Environment.GetEnvironmentVariable("SPIRE_TOKEN")!
};

// Create the pipeline
var pipeline = new ContractLifecyclePipeline(options);

// Prepare a batch of contracts
var batch = new List<(ContractContext, string, string, double)>
{
    new(new ContractContext
    {
        ContractId = "CTR-2026-001",
        ContractType = "Vendor",
        WorkDir = @"C:\contract-mgmt\work\CTR-2026-001"
    },
    @"C:\templates\vendor-agreement.docx",
    @"C:\data\vendors-q1.xlsx",
    250_000)
};

// Run the batch. Approval decisions are supplied per contract id —
// a contract with no recorded decision stays in NeedsReview and
// never reaches Stage 5.
BatchResult result = pipeline.RunBatch(batch, new Dictionary<string, Dictionary<string, bool>>
{
    ["CTR-2026-001"] = new Dictionary<string, bool>
    {
        ["Legal Team"] = true,
        ["VP Operations"] = true,
        ["Procurement"] = true
    }
});

Console.WriteLine($"Total: {result.Total}");
Console.WriteLine($"Successful: {result.Successful}");
Console.WriteLine($"Flagged: {result.Flagged}");
Console.WriteLine($"Errored: {result.Errored}");

foreach (var r in result.Results)
{
    Console.WriteLine($"  {r.ContractId}: {r.Status}");
    foreach (var log in r.StageLog)
        Console.WriteLine($"    {log}");
}

Las decisiones de aprobación se suministran a la llamada por lotes en lugar de ser inferidas por el pipeline: un contrato sin decisión registrada mantiene a sus aprobadores pendientes, cae en Flagged y se detiene antes de la Etapa 5: el resultado previsto para un acuerdo que nadie ha aprobado todavía.

Una ejecución sobre un puñado de contratos es el caso simple. Coloque las mismas etapas detrás de una cola de documentos y las preguntas de orquestación cambian: límites de concurrencia, aislamiento de errores por documento, política de reintentos y agregación en un informe por lotes. Procesamiento inteligente de documentos en .NET: construcción de pipelines IDP aborda esos a nivel de pipeline, y la sección de lotes de este artículo es una instancia específica de contratos del mismo patrón.


9. Dónde termina la IA y comienza la gobernanza

La arquitectura anterior asigna responsabilidades específicas al agente de IA y responsabilidades específicas al código determinista. El límite no es arbitrario: sigue un principio: la IA interpreta y sugiere; el código determinista decide y registra.

Las evaluaciones de riesgo generadas por IA y las obligaciones extraídas deben ser validadas por revisores calificados antes de usarse para decisiones legales o comerciales.

Responsabilidad Gestionada por Por qué
Comprender una instrucción en lenguaje natural Agente de IA Es el objetivo mismo de los modelos de lenguaje
Extraer información de texto no estructurado Agente de IA Se requiere comprensión semántica
Categorizar cambios como sustantivos o de formato Agente de IA Requiere juicio sobre el significado
Sugerir aprobadores según el tipo de contrato Reglas de negocio Auditabilidad y consistencia
Determinar quién debe firmar Reglas de negocio Auditabilidad y consistencia
Registrar la cadena de aprobación Código determinista La pista de auditoría legal debe ser a prueba de manipulaciones
Establecer umbrales de riesgo Reglas de negocio La política de riesgo es una decisión de gobernanza
Almacenar metadatos de obligaciones Código determinista Los sistemas posteriores necesitan una estructura confiable
Activar recordatorios de renovación Código determinista Debe dispararse según lo programado, no por inferencia

Los umbrales de riesgo en la etapa de revisión (AutoApproveThreshold = 0.3, ManualReviewThreshold = 0.6) los establece el negocio, no la IA. La aplicación deriva una relación de revisión a partir del número de cláusulas marcadas; el negocio define los umbrales. Esta separación es lo que hace que el sistema sea defendible en una auditoría: cada decisión automatizada se remonta a una regla definida por humanos, no a una inferencia del modelo.

Integración con sistemas existentes

Un sistema de ciclo de vida de contratos no existe de forma aislada. Los metadatos posteriores a la firma (obligaciones, fechas clave, términos de renovación) están diseñados para ser consumidos por sistemas posteriores:

  • ERP / Finanzas: hitos de pago y conciliación de facturas
  • CRM: alertas de renovación para gerentes de cuentas
  • Compras: seguimiento del rendimiento de proveedores frente a los SLA del contrato
  • Legal: monitoreo de cumplimiento y preparación de auditorías

El formato de metadatos JSON de la Etapa 5 facilita esta integración: los sistemas posteriores consultan los datos estructurados sin necesidad de analizar documentos de contrato.


10. Preguntas frecuentes

¿En qué se diferencia esto de la revisión de contratos con IA?

La revisión de contratos es una etapa del ciclo de vida: leer un acuerdo y señalar cláusulas riesgosas. Un sistema de gestión de contratos cubre todo el ciclo de vida: redacción, revisión, negociación, aprobación, finalización y monitoreo posterior a la firma. El artículo existente Revisión de contratos con IA en C# cubre la etapa de revisión en profundidad; este artículo cubre el pipeline completo dentro del cual se sitúa la revisión.

¿Puede el pipeline manejar entradas tanto PDF como Word?

Sí, en las etapas que implementan despacho de formato. Las etapas de revisión y monitoreo incluyen un switch sobre la extensión de archivo que enruta .docx a Document y .pdf a PdfDocument. La etapa de redacción requiere una plantilla .docx, y la etapa de negociación opera sobre documentos de Word (la comparación de documentos usa Spire.Doc.Document). Esto refleja un patrón del mundo real: el despacho de formato se aplica donde se esperan PDF de la contraparte, no de manera uniforme en todas las etapas.

¿Qué sucede si un contrato falla en un lote?

El procesador por lotes envuelve cada contrato en su propio bloque try-catch. Un fallo en un contrato se registra con el mensaje de error y la etapa donde ocurrió, y el lote continúa con el siguiente contrato. El resumen del lote usa el método del residuo para la conservación de estado: Flagged = Total - Successful - Errored, lo que garantiza que cada resultado caiga en exactamente un grupo de informes.

¿El agente de IA decide quién aprueba un contrato?

No. Las reglas de enrutamiento de aprobación son lógica de negocio determinista: en este ejemplo, los contratos superiores a $100K requieren aprobación del VP, los superiores a $500K requieren aprobación del CFO, y los contratos de proveedores necesitan la aprobación de Compras. El agente de IA genera el resumen de aprobación que leen los aprobadores, pero el enrutamiento en sí es código. Esto mantiene la cadena de aprobación auditable y consistente.

¿Cómo funciona la etapa de negociación?

La etapa de negociación utiliza dos capacidades de la capa de documentos Spire.Doc: Document.Compare() para generar un diff entre versiones, y Document.TrackChanges para habilitar el seguimiento de revisiones. Luego, el agente de IA analiza el documento de comparación y categoriza cada cambio como sustantivo (texto de cláusula, números, fechas) o de formato (estilo, espaciado). Esta combinación —comparación determinista más categorización por IA— es lo que hace que la etapa de negociación sea útil para los equipos legales.

¿Qué es el monitoreo posterior a la firma?

Después de que un contrato se finaliza (firmado en producción), el sistema extrae obligaciones (quién debe hacer qué, para cuándo), fechas clave (renovación, terminación, hitos) y términos de renovación (renovación automática, período de aviso) del documento finalizado. Estos metadatos se almacenan como JSON estructurado que los sistemas posteriores —ERP, CRM, compras— pueden consultar sin abrir el documento del contrato. Esta es la etapa que la mayoría de las herramientas de revisión de contratos no abordan, y es donde se pierde más valor en los procesos manuales: obligaciones olvidadas, renovaciones perdidas, plazos vencidos sin aviso.

¿Qué dependencias de .NET se requieren?

El paquete NuGet Spire.Agent.Office, que incluye transitivamente Spire.Doc, Spire.Pdf, Spire.XLS y Spire.Presentation. El ejemplo apunta a .NET 6 o posterior; verifique los marcos de destino compatibles con la versión del paquete antes del despliegue. Se requiere un SpireToken (clave de API) para que el agente de IA se comunique con el servicio de modelo de lenguaje.


¿Listo para automatizar el ciclo de vida de sus contratos?

Si su aplicación redacta, revisa o rastrea acuerdos, un agente de IA para documentos convierte una instrucción en lenguaje natural en un archivo real y formateado en lugar de un pipeline de extracción y reconstrucción que usted tiene que mantener. Siga la guía de introducción para integrar el SDK en un proyecto .NET y ejecutar su primera instrucción, luego reutilice los cinco patrones de etapas anteriores para sus propios tipos de contrato, reglas de aprobación y seguimiento de obligaciones.

Lecturas adicionales