
Программное обеспечение для управления договорами на базе ИИ автоматизирует весь жизненный цикл договора — от составления проекта до проверки, согласования, утверждения, финализации и отслеживания обязательств после подписания — объединяя языковое понимание ИИ с детерминированной обработкой документов внутри приложения .NET. Его отличие от автоматизации отдельных задач по работе с договорами заключается в охвате жизненного цикла: инструмент проверки договоров отмечает рискованные условия, а система управления договорами проводит соглашение через каждый этап, ведёт журнал аудита по всем версиям и отслеживает обязательства после подписания.
Spire.Agent.Office — это SDK ИИ-агента для работы с документами, который предоставляет одновременно слой языкового понимания и слой обработки документов. В этой статье показано, как построить систему управления жизненным циклом договоров на C#, объединив пять этапов в один конвейер, с изоляцией ошибок на уровне этапов, диспетчеризацией форматов для PDF и Word и корректным отслеживанием статусов — подходы, которые не рассматриваются в примерах для отдельных задач.
В этой статье представлена референсная реализация конвейера жизненного цикла договоров с поддержкой ИИ на платформе .NET. Промышленная система CLM дополнительно потребовала бы сохранения состояния рабочего процесса, управления идентификацией и доступом, интеграции с электронной подписью, хранения документов и их версий, уведомлений и инфраструктуры аудита.
1. Жизненный цикл договора: пять этапов, на которых действует ИИ
Жизненный цикл договора — это не одна операция с документом. Это последовательность этапов, каждый из которых имеет свои входные и выходные данные и свои сценарии сбоя. Понимание того, где на каждом этапе ИИ приносит пользу, а где детерминированный код должен держать оборону, — основа системы, способной выдержать эксплуатацию.
| Этап | Что происходит | Роль ИИ | Роль детерминированного кода |
|---|---|---|---|
| Составление проекта | Создание договора на основе шаблона и структурированных данных | Интерпретация запроса, выбор и заполнение полей шаблона | Загрузка шаблона, сохранение форматирования, сохранение в формате .docx или .pdf
|
| Проверка | Чтение договора, выявление рискованных условий, извлечение ключевых условий | Семантический анализ формулировок условий, оценка риска | Формирование структурированного отчёта о проверке, соблюдение чек-листа проверки |
| Согласование | Сравнение версий, отслеживание правок, объединение изменений | Резюмирование различий, различение содержательных изменений и изменений оформления | Включение и отключение отслеживания изменений, сравнение документов, принятие и отклонение правок |
| Утверждение | Маршрутизация заинтересованным сторонам, сбор согласований | Предложение утверждающих лиц на основе типа и стоимости договора | Применение правил маршрутизации, ведение журнала аудита, создание финальной копии |
| После подписания | Отслеживание обязательств, сроков, продлений | Извлечение обязательств и ключевых дат из финального договора | Хранение структурированных метаданных, запуск напоминаний, формирование отчётов |
На практике жизненный цикл не является линейным — согласование возвращает к проверке, а поправки перезапускают составление проекта, — но архитектура конвейера справляется с этим за счёт маршрутизации этапов, а не жёсткой последовательности.
Чем это отличается от одной лишь проверки договоров
Автоматизация проверки договоров — тема статьи Проверка договоров с помощью ИИ на C# — охватывает один этап: чтение соглашения и выявление проблем. Система управления договорами должна:
- Связывать этапы в цепочку, передавая данные от одного к другому (результат составления проекта становится входными данными для проверки).
-
Поддерживать несколько форматов документов — шаблоны поступают в формате
.docx, контрагенты могут присылать.pdf, и этапы с диспетчеризацией форматов обрабатывают оба варианта без исключений. - Сохранять состояние между этапами — статус проверки договора, число версий при согласовании и цепочка утверждения должны сохраняться между запусками конвейера.
- Изолировать сбои по каждому договору — один повреждённый файл в пакете из 200 не должен останавливать весь конвейер.
Эти требования определяют архитектуру, описанную ниже.
2. Архитектура системы для автоматизации жизненного цикла договоров
Система управления жизненным циклом договоров состоит из трёх уровней. Каждый уровень отвечает за свою область, и именно на границах между ними либо выявляются, либо пропускаются сбои, возникающие в эксплуатации.

Уровень 1: Интерфейс инструкций
Точкой входа служит инструкция на естественном языке, описывающая желаемый результат, а не механические шаги. "Составь договор с поставщиком для Acme Corp по стандартному шаблону, проверь его на наличие нестандартных условий оплаты и направь в юридический отдел, если предел ответственности превышает $500 000." Уровень инструкций преобразует её в план конвейера: какие этапы выполнять, в каком порядке и какие параметры нужны каждому этапу.
Уровень 2: Оркестратор этапов
Оркестратор управляет потоком между этапами. Он хранит общий объект ContractContext, который переносит метаданные договора, текущий документ и результаты этапов от одного этапа к следующему. Каждый этап получает контекст, выполняет свою операцию и возвращает обновлённый контекст вместе с результатом этапа. На основе результата оркестратор решает, продолжить работу, повторить попытку или передать управление обработчику исключений.
Уровень 3: Обработка документов
Каждый этап вызывает Spire.Agent.Office для выполнения собственно операции с документом. Агент отвечает за рассуждения ИИ (понимание инструкции, извлечение информации), а уровень документов — за файловые операции (загрузку, изменение, сохранение). Диспетчеризация форматов применяется на этапах, где ожидается несколько форматов документов: для входного файла .pdf используется PdfDocument, а для входного файла .docx — Document.
Контекст договора
Общий объект состояния, проходящий через конвейер:
/// <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";
}
Поле Status использует именованные константы, а не строковые литералы. Это предотвращает распространённый сценарий сбоя, когда статус никогда не задаётся явно, из-за чего документы выпадают из всех отчётных категорий и исчезают из сводок по пакету.
3. Этап 1: Составление проекта на основе шаблона и данных
Этап составления проекта берёт шаблон (.docx с маркерами {{Placeholder}}) и источник данных (таблицу Excel или структурированные входные данные), а затем создаёт заполненный договор. ИИ-агент читает структуру шаблона и заполняет маркеры данными из источника — одна инструкция заменяет код сопоставления полей, который требуется традиционному SDK.
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;
}
}
Ключевые вызовы API
-
Document.LoadFromFile()— загружает шаблон.docxс маркерами -
template.AI(_options)— подключает ИИ-обработчик документов -
ExecuteInstruction(doc, instruction, outputPath, attachments)— заполняет маркеры данными из источника; передавайте явный абсолютный путь, чтобы агент записал результат в известное место - Сбор результатов: сначала проверяйте
File.Exists(outputPath);AIResult.OutputFilesненадёжен для некоторых типов продуктов и должен использоваться только как запасной вариант
Диспетчеризация форматов
Этап проверяет формат входных данных перед обработкой. Шаблон .pdf или неподдерживаемый формат отклоняется явно с понятным сообщением, вместо того чтобы привести к сбою внутри агента с непрозрачным исключением. Этот подход предотвращает распространённый сценарий сбоя, когда отсутствие диспетчеризации форматов приводит к необработанным исключениям при обработке PDF-файлов глубоко в конвейере.
Составление одного договора — простой случай. Когда один и тот же шаблон нужно заполнить для десятков записей — новых сотрудников, поставщиков, продлений, — шаблон инструкции масштабируется на пакетный вывод без изменений; в статье Пакетное создание договоров с помощью Spire.Agent.Office рассматриваются как маршрут слияния, так и маршрут замены маркеров, а также сравниваются области их применения.
Примечание к SDK: когда в
ExecuteInstructionпередаётся явныйoutputPath, SDK может также записать копиюoutput-<filename>в тот же каталог. Эта копия имеет идентичное содержимое и может быть безопасно удалена после обработки. Это затрагивает все этапы, в которых указаны пути вывода.
Результатом этого этапа является сам заполненный документ — шаблон договора с поставщиком, в котором все маркеры заменены данными из реестра поставщиков.

4. Этап 2: Проверка и анализ рисков
Этап проверки читает составленный договор, выявляет рискованные или нестандартные условия и формирует структурированный отчёт о проверке. В отличие от этапа составления проекта, входные данные могут быть в формате .docx (внутренний черновик) или .pdf (документ контрагента), поэтому этап должен направлять обработку к правильному типу документа.
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;
}
}
Метод DispatchByFormat — это ключевое дополнение. В более ранних реализациях конвейера этап, обрабатывавший только .docx, выбрасывал InvalidOperationException, когда контрагент присылал .pdf. Выражение switch направляет обработку к правильному типу документа до запуска агента, поэтому оба формата являются полноценными входными данными.
Пороговые значения риска (AutoApproveThreshold, ManualReviewThreshold) — это именованные константы с документированной семантикой. Промежуток между 0,3 и 0,6 образует явную зону "требуется проверка" — договоры, которые недостаточно чисты для автоматического утверждения и недостаточно рискованны для блокировки. Это предотвращает ошибку "мёртвой логики порога", когда все договоры проходят одну и ту же проверку.
Результаты проверки сохраняются в виде отчёта, а не возвращаются в память, поэтому аудит на уровне отдельных условий сохраняется независимо от запуска, который его создал.

5. Этап 3: Согласование и контроль версий
Согласование — это этап, на котором договоры переходят из рук в руки. Контрагент вносит правки в документ — отмечает условия, корректирует формулировки, добавляет условия. Система должна сравнивать версии, отслеживать изменения и помогать команде решить, что принять.
На этом этапе используются две возможности уровня документов Spire.Doc, выходящие за рамки метода ExecuteInstruction ИИ-агента:
- Отслеживание изменений — включение режима правок, чтобы каждое изменение было видимым и атрибутируемым
- Сравнение документов — сравнение двух версий и создание документа с различиями
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));
}
}
Ключевые вызовы API
-
Document.Compare(otherDoc, authorName)— создаёт документ с различиями, в котором все изменения помечены как отслеживаемые правки (из примера сравнения документов Spire.Doc) -
Document.TrackChanges = true— включает отслеживание правок, чтобы каждое изменение было видимым и атрибутируемым -
Document.AcceptChanges()— принимает все отслеживаемые изменения для получения чистой финальной версии -
doc.AI(_options).ExecuteInstruction(...)— анализирует сравнение и классифицирует изменения
Этап согласования объединяет детерминированные операции с документами (сравнение, отслеживание изменений, принятие) с анализом ИИ (классификация изменений как содержательных или оформительских). Детерминированные операции предоставляются уровнем Spire.Doc — теми же API, которые доступны в справочном каталоге кода, — а ИИ-агент выполняет семантическую классификацию, которая иначе потребовала бы ручной проверки.
В примере выполняется сравнение версий и отслеживается путь к текущему файлу; промышленная система обычно сохраняла бы каждую версию отдельно и явно фиксировала, какая правка была принята. Метод AcceptAllChanges() предоставлен как необязательная операция финализации после согласования, но он не вызывается автоматически в примере конвейера, поскольку принятие должно происходить только после проверки изменений человеком.
Метод Document.Compare возвращает документ, а не сводку: отслеживаемые правки, показанные ниже, — это те самые правки, которые проверяющий иначе собирал бы вручную.

6. Этап 4: Утверждение и финализация
Этап утверждения направляет договор нужным заинтересованным сторонам в зависимости от типа и стоимости договора, фиксирует их согласования и создаёт финальную PDF-копию после того, как все необходимые утверждения зафиксированы. Логика маршрутизации детерминирована — она использует бизнес-правила, а не ИИ, — но агент помогает, предлагая утверждающих лиц и формируя сводку для утверждения.
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;
}
}
Правила маршрутизации в DetermineApprovers намеренно детерминированы. ИИ предлагает и резюмирует; он не решает, кто подписывает договор. Цепочка утверждения — это бизнес-правило, которое должно быть проверяемым и согласованным, — именно такое решение должно находиться в коде, а не в языковой модели.
Примечание. Статус
Executedв этом примере означает финальный PDF, созданный после утверждения, — а не подписанный договор. Собственно электронное подписание — конверты подписей, проверка личности подписанта, встраивание полей подписи — должно выполняться отдельным специализированным процессом электронной подписи, интегрированным независимо.
Как только решение каждого утверждающего лица зафиксировано, этап сохраняет финальный договор в формате PDF — и именно этот PDF, а не исходный черновик, используется на этапе 5.

7. Этап 5: Мониторинг после подписания
В рабочем процессе мониторинг после подписания начинается после того, как договор был официально подписан в электронной или иной форме. В данной референсной реализации в качестве входных данных для мониторинга используется финальный PDF, созданный на этапе 4. Мониторинг после подписания извлекает из договора обязательства, ключевые даты и условия продления, а затем сохраняет их в виде структурированных метаданных, которые нижестоящие системы используют для напоминаний и отчётности.
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;
}
}
Метаданные обязательств хранятся в формате JSON, а не в виде документа. Это делает их доступными для запросов из нижестоящих систем — панель контроля продлений может просканировать все договоры на условие autoRenewal == true и renewalDate < DateTime.Now.AddDays(90), не открывая ни одного документа. Обязательства со структурированными значениями dueDate можно отслеживать автоматически; для тех, где срок не удалось преобразовать в абсолютную дату, в поле dueDateRaw сохраняется исходный текст для ручной проверки. ИИ-агент извлекает информацию; детерминированный уровень сохраняет её в формате, который могут использовать бизнес-системы.
Шаблон извлечения тот же, что используется для счетов в статье Автоматизация обработки счетов с помощью ИИ-агента в .NET: извлечение структурированных полей из входящего документа, их проверка по детерминированным правилам и направление неоднозначных значений на проверку. Договоры отличаются главным образом тем, что происходит дальше: обязательства становятся долгоживущими записями, которые переживают саму сделку, тогда как поля счёта используются один раз.
Извлечённые данные хранятся в формате JSON, а не в виде текста, поэтому каждое обязательство и каждая ключевая дата содержат исходную формулировку договора наряду со значением, которое вывел из неё парсер.

8. Оркестрация полного конвейера
Оркестратор связывает этапы в цепочку, передавая ContractContext от одного к другому. Две его ключевые обязанности — изоляция ошибок по каждому договору (один неудачный договор не должен останавливать пакет) и сохранение статусов (каждый договор должен попасть ровно в одну отчётную категорию).
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
};
}
}
Изоляция ошибок на уровне этапов
Каждый этап обёрнут в собственный блок try-catch. Если этап проверки завершается сбоем из-за того, что ИИ-агент не может разобрать особо сложное условие, конвейер фиксирует сбой и останавливается, — но ContractContext следующего договора в пакете остаётся нетронутым. Это предотвращает распространённый сценарий сбоя, когда один повреждённый файл в пакете из 200 останавливает весь конвейер без частичных результатов.
Сохранение статусов
В сводке по пакету для вычисления значения Flagged используется метод остатка: Flagged = Total - Successful - Errored. Это структурное свойство — любой результат, который не является явно успешным или ошибочным, попадает в категорию "отмечено" (консервативная маршрутизация на ручную проверку). Это предотвращает ошибку, при которой результаты с неожиданными значениями статуса незаметно исчезают из всех трёх отчётных категорий.
Запуск конвейера
// 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}");
}
Решения об утверждении передаются в вызов пакетной обработки, а не выводятся конвейером: договор без зафиксированного решения оставляет своих утверждающих лиц в состоянии ожидания, попадает в категорию Flagged и останавливается до этапа 5 — это ожидаемый результат для соглашения, которое ещё никто не утвердил.
Один запуск для нескольких договоров — простой случай. Если поставить те же этапы за очередь документов, вопросы оркестрации меняются: ограничения параллелизма, изоляция ошибок по каждому документу, политика повторных попыток и агрегация в отчёт по пакету. В статье Интеллектуальная обработка документов в .NET: построение конвейеров IDP эти вопросы рассматриваются на уровне конвейера, а раздел о пакетной обработке в этой статье представляет собой специфичный для договоров пример того же подхода.
9. Где заканчивается ИИ и начинается управление
Приведённая выше архитектура закрепляет конкретные обязанности за ИИ-агентом и конкретные обязанности за детерминированным кодом. Эта граница не произвольна — она следует принципу: ИИ интерпретирует и предлагает; детерминированный код решает и фиксирует.
Оценки рисков, сформированные ИИ, и извлечённые обязательства должны проверяться квалифицированными специалистами, прежде чем использоваться для юридических или коммерческих решений.
| Обязанность | Кем выполняется | Почему |
|---|---|---|
| Понимание инструкции на естественном языке | ИИ-агент | В этом и заключается суть языковых моделей |
| Извлечение информации из неструктурированного текста | ИИ-агент | Требуется семантическое понимание |
| Классификация изменений как содержательных или оформительских | ИИ-агент | Требуется оценка смысла |
| Предложение утверждающих лиц на основе типа договора | Бизнес-правила | Проверяемость и согласованность |
| Определение того, кто должен подписать | Бизнес-правила | Проверяемость и согласованность |
| Фиксация цепочки утверждения | Детерминированный код | Юридический журнал аудита должен быть защищён от подделки |
| Установка пороговых значений риска | Бизнес-правила | Политика управления рисками — это управленческое решение |
| Хранение метаданных обязательств | Детерминированный код | Нижестоящим системам нужна надёжная структура |
| Запуск напоминаний о продлении | Детерминированный код | Должно срабатывать по расписанию, а не по догадке |
Пороговые значения риска на этапе проверки (AutoApproveThreshold = 0.3, ManualReviewThreshold = 0.6) устанавливаются бизнесом, а не ИИ. Приложение вычисляет коэффициент проверки исходя из числа отмеченных условий; бизнес определяет пороги. Именно это разделение делает систему защищаемой при аудите — каждое автоматизированное решение восходит к определённому человеком правилу, а не к выводу модели.
Интеграция с существующими системами
Система управления жизненным циклом договоров не существует изолированно. Метаданные, формируемые после подписания (обязательства, ключевые даты, условия продления), рассчитаны на использование нижестоящими системами:
- ERP / финансы: этапы платежей и сверка счетов
- CRM: оповещения о продлении для менеджеров по работе с клиентами
- Закупки: отслеживание эффективности поставщиков в соответствии с SLA договора
- Юридический отдел: мониторинг соответствия и подготовка к аудиту
Формат метаданных JSON с этапа 5 делает такую интеграцию простой — нижестоящие системы запрашивают структурированные данные без необходимости разбирать документы договоров.
10. Часто задаваемые вопросы
Чем это отличается от проверки договоров с помощью ИИ?
Проверка договоров — это один этап жизненного цикла: чтение соглашения и выявление рискованных условий. Система управления договорами охватывает весь жизненный цикл: составление проекта, проверку, согласование, утверждение, финализацию и мониторинг после подписания. Существующая статья Проверка договоров с помощью ИИ на C# подробно рассматривает этап проверки; в этой статье описан полный конвейер, частью которого является проверка.
Может ли конвейер обрабатывать как PDF, так и Word?
Да, на этапах, где реализована диспетчеризация форматов. Этапы проверки и мониторинга включают проверку расширения файла, которая направляет .docx в Document, а .pdf — в PdfDocument. Этап составления проекта требует шаблона .docx, а этап согласования работает с документами Word (сравнение документов использует Spire.Doc.Document). Это отражает реальную практику: диспетчеризация форматов применяется там, где ожидаются PDF от контрагентов, а не единообразно на каждом этапе.
Что произойдёт, если один договор в пакете завершится сбоем?
Пакетный обработчик оборачивает каждый договор в собственный блок try-catch. Сбой одного договора фиксируется вместе с сообщением об ошибке и указанием этапа, на котором он произошёл, а обработка пакета продолжается со следующим договором. В сводке по пакету для сохранения статусов используется метод остатка: Flagged = Total - Successful - Errored, что гарантирует попадание каждого результата ровно в одну отчётную категорию.
Решает ли ИИ-агент, кто утверждает договор?
Нет. Правила маршрутизации утверждения — это детерминированная бизнес-логика: в данном примере договоры на сумму свыше $100 000 требуют утверждения вице-президента, свыше $500 000 — утверждения финансового директора, а договоры с поставщиками требуют согласования отдела закупок. ИИ-агент формирует сводку для утверждения, которую читают утверждающие лица, но сама маршрутизация реализована в коде. Это сохраняет цепочку утверждения проверяемой и согласованной.
Как работает этап согласования?
Этап согласования использует две возможности уровня документов Spire.Doc: Document.Compare() для создания документа с различиями между версиями и Document.TrackChanges для включения отслеживания правок. Затем ИИ-агент анализирует документ сравнения и классифицирует каждое изменение как содержательное (текст условий, числа, даты) или оформительское (стиль, интервалы). Именно это сочетание — детерминированное сравнение плюс классификация ИИ — делает этап согласования полезным для юридических команд.
Что такое мониторинг после подписания?
После финализации договора (в реальной эксплуатации — после подписания) система извлекает из финального документа обязательства (кто что должен сделать и к какому сроку), ключевые даты (продление, расторжение, этапы) и условия продления (автоматическое продление, срок уведомления). Эти метаданные сохраняются в виде структурированного JSON, который нижестоящие системы — ERP, CRM, закупки — могут запрашивать, не открывая документ договора. Это тот этап, который большинство инструментов проверки договоров не охватывают, и именно здесь в ручных процессах теряется больше всего ценности: обязательства забыты, продления пропущены, сроки прошли незамеченными.
Какие зависимости .NET требуются?
Пакет NuGet Spire.Agent.Office, который транзитивно подключает Spire.Doc, Spire.Pdf, Spire.XLS и Spire.Presentation. Пример ориентирован на .NET 6 или более позднюю версию; перед развёртыванием проверьте поддерживаемые целевые платформы для версии пакета. Для взаимодействия ИИ-агента со службой языковой модели требуется SpireToken (ключ API).
Готовы автоматизировать жизненный цикл своих договоров?
Если ваше приложение составляет, проверяет или отслеживает соглашения, ИИ-агент для работы с документами превращает одну инструкцию на естественном языке в реальный отформатированный файл вместо конвейера извлечения и восстановления, который вам приходится поддерживать. Следуйте руководству по началу работы, чтобы подключить SDK к проекту .NET и выполнить первую инструкцию, а затем используйте описанные выше шаблоны пяти этапов для своих типов договоров, правил утверждения и отслеживания обязательств.
Дополнительная литература
- Пакетное создание договоров с помощью Spire.Agent.Office — этап составления проекта подробно: слияние и замена маркеров сразу в десятках шаблонов
- Автоматизация обработки счетов с помощью ИИ-агента в .NET — тот же шаблон "извлечь — проверить — просмотреть", применённый к счетам, где каждый документ обрабатывается один раз
- Интеллектуальная обработка документов в .NET: построение конвейеров IDP — оркестрация на уровне конвейера: параллелизм, изоляция ошибок по каждому документу и отчётность по пакетам
- Обзор продукта Spire.Agent.Office — SDK ИИ-агентов для всех форматов документов Office