Документы и диагностика: имя детали, честное сохранение, причина ошибки

set_part_info задаёт наименование и обозначение детали: без него в дереве
стоит безликая «Деталь», и она же уходит в штамп чертежа и в спецификацию.
describe_model показывает наименование в шапке, а безымянную деталь помечает
явно. Имя файла этого не заменяет — это разные свойства.

document_save рапортовал «Сохранено», ничего не записав: SaveAs возвращает
void и молча отказывает, если файл с этим именем уже открыт в КОМПАС другим
документом. Теперь путь и наличие файла проверяются после записи, а Save
сверяет флаг Changed. Освободить занятое имя нечем не было — document_close
получил режим all.

Текст исключения доходил до клиента только у McpException, всё остальное
подменялось на «An error occurred invoking». То есть подсказки в наших
сообщениях агент не видел вовсе. Фильтр CallTool в Program.cs возвращает
IsError с реальной причиной (ToolErrorText разворачивает AggregateException
и склеивает вложенные причины). Там же — регистрация PrimitiveService.
This commit is contained in:
2026-07-31 14:51:12 +03:00
parent a4cc755fb4
commit 4f4dd082d9
11 changed files with 310 additions and 6 deletions
@@ -53,11 +53,60 @@ public sealed class DocumentService
/// <summary>Сохранить активный документ (для уже сохранённого — без диалога).</summary>
public Task SaveAsync(CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => WithActive(d => d.Save()), ct);
=> _dispatcher.InvokeAsync(() => WithActive(d =>
{
d.Save();
if (d.Changed)
throw new InvalidOperationException(
"КОМПАС не сохранил документ: в нём остались несохранённые изменения. " +
"Для нового документа передайте path (Save без пути работает только для ранее сохранённого).");
return true;
}), ct);
/// <summary>Сохранить активный документ по указанному пути (безопасно для новых документов).</summary>
public Task SaveAsAsync(string path, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() => WithActive(d => d.SaveAs(path)), ct);
=> _dispatcher.InvokeAsync(() => WithActive(d =>
{
d.SaveAs(path);
// SaveAs ничего не возвращает, а КОМПАС молча не сохраняет, если файл с этим именем уже
// открыт другим документом. Без проверки по факту агент получает ложный «Сохранено».
var actual = d.PathName ?? string.Empty;
if (!string.Equals(actual, path, StringComparison.OrdinalIgnoreCase) || !File.Exists(path))
throw new InvalidOperationException(
$"КОМПАС не сохранил документ по пути «{path}»" +
(actual.Length == 0
? " (документ так и остался несохранённым)"
: $" (документ остался по пути «{actual}»)") +
". Обычная причина — файл с этим именем уже открыт в КОМПАС другим документом.");
return true;
}), ct);
/// <summary>
/// Задать наименование и/или обозначение детали активного 3D-документа. Наименование — это имя
/// вершинного компонента: оно стоит в дереве вместо безликой «Детали» и попадает в штамп чертежа
/// и в спецификацию. Пустой аргумент оставляет свойство как есть. Возвращает значения после записи.
/// </summary>
public Task<PartIdentity> SetPartIdentityAsync(string? name, string? marking, CancellationToken ct = default)
=> _dispatcher.InvokeAsync(() =>
{
if (_session.Application.ActiveDocument is not IKompasDocument3D doc3d)
throw new InvalidOperationException("Активный документ не является 3D-документом.");
if (doc3d.TopPart is not IPart7 top)
throw new InvalidOperationException("Не удалось получить вершинный компонент (IPart7).");
try
{
if (name is not null) top.Name = name;
if (marking is not null) top.Marking = marking;
if (!top.Update())
throw new InvalidOperationException("IPart7.Update() вернул FALSE — свойства детали не применены.");
// Read-back: КОМПАС может не принять значение (например, у компонента-заготовки),
// а присваивание свойства об этом не сообщает.
return new PartIdentity { Name = top.Name ?? string.Empty, Marking = top.Marking ?? string.Empty };
}
finally { ComHelper.Release(top); ComHelper.Release(doc3d); }
}, ct);
/// <summary>Закрыть активный документ. <paramref name="save"/> — сохранять ли изменения.</summary>
public Task<bool> CloseAsync(bool save = false, CancellationToken ct = default)
@@ -0,0 +1,11 @@
namespace Kompas.Mcp.Core.Documents;
/// <summary>Наименование и обозначение детали — то, что видно в дереве, штампе и спецификации.</summary>
public sealed record PartIdentity
{
/// <summary>Наименование (имя вершинного компонента; в дереве вместо «Деталь»).</summary>
public required string Name { get; init; }
/// <summary>Обозначение по ЕСКД (может быть пустым).</summary>
public required string Marking { get; init; }
}
+18 -1
View File
@@ -97,11 +97,28 @@ public static class InspectionText
.Select(x => $"{x.Count} {x.Key}"));
}
/// <summary>
/// Наименование и обозначение детали в заголовке паспорта. Пустое наименование — это «Деталь»
/// в дереве: подсказываем, чем его задать, пока модель не ушла в чертёж или спецификацию.
/// </summary>
public static string PartIdentityText(ModelDescription d)
{
var name = (d.PartName ?? "").Trim();
var marking = (d.PartMarking ?? "").Trim();
if (name.Length == 0 && marking.Length == 0)
return " — деталь без наименования (задайте через set_part_info)";
var parts = new List<string>(2);
if (name.Length > 0) parts.Add($"наименование «{name}»");
if (marking.Length > 0) parts.Add($"обозначение «{marking}»");
return " — " + string.Join(", ", parts);
}
/// <summary>Отрендерить «паспорт» модели в читаемый текст (по умолчанию — все разделы).</summary>
public static string Render(ModelDescription d, ModelSections sections = ModelSections.All)
{
var sb = new StringBuilder();
sb.AppendLine($"Документ: «{d.Name}» ({d.DocumentTypeName})");
sb.AppendLine($"Документ: «{d.Name}» ({d.DocumentTypeName}){PartIdentityText(d)}");
if (d.IsEmpty)
{
@@ -9,6 +9,12 @@ public sealed record ModelDescription
/// <summary>Имя документа/детали.</summary>
public required string Name { get; init; }
/// <summary>Наименование детали (имя вершинного компонента; в дереве вместо «Деталь»).</summary>
public string PartName { get; init; } = "";
/// <summary>Обозначение детали по ЕСКД.</summary>
public string PartMarking { get; init; } = "";
/// <summary>Модель пуста (нет тел; габарит-«пустышка»).</summary>
public required bool IsEmpty { get; init; }
@@ -40,6 +40,8 @@ public sealed class ModelInspectionService
try
{
var (name, typeName) = ActiveDocInfo();
var partName = ComHelper.SafeStr(() => part.name);
var partMarking = ComHelper.SafeStr(() => part.marking);
bool hasGab = part.GetGabarit(false, false,
out double x1, out double y1, out double z1, out double x2, out double y2, out double z2);
@@ -49,7 +51,7 @@ public sealed class ModelInspectionService
if (empty)
return new ModelDescription
{
Name = name, IsEmpty = true,
Name = name, PartName = partName, PartMarking = partMarking, IsEmpty = true,
Bodies = Array.Empty<BodyInfo>(), FaceTypes = Array.Empty<string>(),
EdgeTypes = Array.Empty<string>(), Features = Array.Empty<FeatureInfo>(),
Imported = false, Variables = Array.Empty<VariableInfo>(),
@@ -73,6 +75,8 @@ public sealed class ModelInspectionService
return new ModelDescription
{
Name = name,
PartName = partName,
PartMarking = partMarking,
IsEmpty = false,
Box = sections.HasFlag(InspectionText.ModelSections.Box) ? box : null,
Mass = sections.HasFlag(InspectionText.ModelSections.Mass) ? ReadMass(part) : null,
@@ -0,0 +1,36 @@
namespace Kompas.Mcp.Core.Startup;
/// <summary>
/// Текст ошибки инструмента для клиента. MCP-SDK показывает сообщение исключения только у
/// <c>McpException</c>, всё остальное подменяет на «An error occurred invoking '&lt;tool&gt;'» —
/// а для агента ценна как раз причина: какого параметра не хватило и что делать дальше.
/// Форматирование живёт в Core, чтобы покрываться unit-тестами без MCP-хоста.
/// </summary>
public static class ToolErrorText
{
/// <summary>Собрать сообщение: имя инструмента, причина и вложенные причины (без стека).</summary>
public static string Describe(string? toolName, Exception exception)
{
ArgumentNullException.ThrowIfNull(exception);
var reasons = new List<string>(3);
for (var e = Unwrap(exception); e is not null && reasons.Count < 3; e = e.InnerException)
{
var message = (e.Message ?? "").Trim();
// Служебный текст обёрток («One or more errors occurred») агенту ничего не говорит.
if (message.Length == 0 || e is AggregateException) continue;
if (!reasons.Contains(message)) reasons.Add(message);
}
if (reasons.Count == 0) reasons.Add(exception.GetType().Name);
var where = string.IsNullOrWhiteSpace(toolName) ? "Инструмент" : $"Инструмент «{toolName}»";
return $"{where} не выполнен: {string.Join(" ", reasons)}";
}
/// <summary>Развернуть агрегаты диспетчера: настоящая причина лежит внутри.</summary>
private static Exception Unwrap(Exception exception)
=> exception is AggregateException aggregate && aggregate.InnerExceptions.Count == 1
? Unwrap(aggregate.InnerExceptions[0])
: exception;
}
+17
View File
@@ -14,6 +14,7 @@ using Kompas.Mcp.Core.Vision;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
// Interop-сборки КОМПАС в поставку сервера не входят (принадлежат АСКОН) — они берутся из
// установленного КОМПАС-3D. Резолвер вешается до любых обращений к COM-типам; сам поиск ленивый,
@@ -40,6 +41,7 @@ builder.Services.AddSingleton<KompasSession>();
builder.Services.AddSingleton<DocumentService>();
builder.Services.AddSingleton<PartModeler>();
builder.Services.AddSingleton<HoleService>();
builder.Services.AddSingleton<PrimitiveService>();
builder.Services.AddSingleton<AssemblyService>();
builder.Services.AddSingleton<DrawingService>();
builder.Services.AddSingleton<VariableService>();
@@ -57,6 +59,21 @@ builder.Services.AddSingleton<AutoValidation>();
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
// Без фильтра клиент получает на любую ошибку безликое «An error occurred invoking '<tool>'»:
// SDK показывает текст исключения, только если оно McpException. А вся польза наших сообщений
// именно в тексте («какого параметра не хватает», «почему операция не построилась»).
.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (context, ct) =>
{
try { return await next(context, ct); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
return new CallToolResult
{
Content = [new TextContentBlock { Text = ToolErrorText.Describe(context.Params?.Name, ex) }],
IsError = true,
};
}
}))
.WithToolsFromAssembly();
var app = builder.Build();
+31 -2
View File
@@ -52,15 +52,44 @@ public sealed class DocumentTools(KompasSession session, DocumentService docs, P
}
[McpServerTool(Name = "document_close")]
[Description("Закрыть активный документ. save — сохранять ли изменения при закрытии.")]
public async Task<string> Close([Description("Сохранять изменения")] bool save = false)
[Description("Закрыть активный документ. save — сохранять ли изменения при закрытии. " +
"all=true закрывает ВСЕ открытые документы БЕЗ сохранения — этим освобождают имя файла, " +
"занятое другим открытым документом (иначе КОМПАС молча не перезапишет его).")]
public async Task<string> Close(
[Description("Сохранять изменения")] bool save = false,
[Description("Закрыть все открытые документы без сохранения")] bool all = false)
{
await session.ConnectAsync();
if (all)
{
var closed = await docs.CloseAllAsync();
await modeler.ResetAsync();
return $"Закрыто документов: {closed} (без сохранения).";
}
var ok = await docs.CloseAsync(save);
await modeler.ResetAsync();
return ok ? "Документ закрыт." : "Не удалось закрыть документ.";
}
[McpServerTool(Name = "set_part_info")]
[Description("Задать наименование и/или обозначение детали (сборки) активного 3D-документа. " +
"Наименование заменяет безликую «Деталь» в дереве построения и подставляется в штамп " +
"чертежа и в спецификацию, поэтому давайте его КАЖДОЙ детали, а не только перед выдачей. " +
"Не переданный параметр остаётся без изменений. Возвращает значения после записи.")]
public async Task<string> SetPartInfo(
[Description("Наименование детали, например «Значок декоративный»")] string? name = null,
[Description("Обозначение по ЕСКД, например «ОМЕ.741124.001»")] string? marking = null)
{
await session.ConnectAsync();
if (name is null && marking is null)
throw new ArgumentException("Передайте name и/или marking — иначе менять нечего.");
var identity = await docs.SetPartIdentityAsync(name, marking);
var markingPart = identity.Marking.Length == 0 ? "" : $", обозначение «{identity.Marking}»";
return $"Деталь: наименование «{identity.Name}»{markingPart}.";
}
[McpServerTool(Name = "document_active")]
[Description("Сведения об активном документе.")]
public async Task<string> Active()
@@ -31,6 +31,41 @@ public sealed class InspectionTextTests
public void GroupTypes_empty_returns_dash()
=> Assert.Equal("—", InspectionText.GroupTypes(Array.Empty<string>()));
// ── Наименование/обозначение детали в заголовке ──────────────────────
private static ModelDescription Desc(string partName, string partMarking) => new()
{
Name = "part.m3d", PartName = partName, PartMarking = partMarking,
IsEmpty = true, DocumentTypeName = "деталь",
Bodies = [], FaceTypes = [], EdgeTypes = [], Features = [], Variables = [], Imported = false,
};
[Fact]
public void Part_without_a_name_is_flagged_with_the_way_to_set_it()
{
var s = InspectionText.PartIdentityText(Desc("", ""));
Assert.Contains("без наименования", s, StringComparison.Ordinal);
Assert.Contains("set_part_info", s, StringComparison.Ordinal);
}
[Fact]
public void Part_identity_shows_both_name_and_marking()
{
var s = InspectionText.PartIdentityText(Desc("Значок декоративный", "ОМЕ.741124.001"));
Assert.Contains("«Значок декоративный»", s, StringComparison.Ordinal);
Assert.Contains("«ОМЕ.741124.001»", s, StringComparison.Ordinal);
}
[Fact]
public void Part_identity_omits_the_empty_marking()
{
var s = InspectionText.PartIdentityText(Desc("Значок декоративный", " "));
Assert.Contains("наименование", s, StringComparison.Ordinal);
Assert.DoesNotContain("обозначение", s, StringComparison.Ordinal);
}
// ── Детект импорта без истории ───────────────────────────────────────
private static FeatureInfo Feat(int i, string name, string kind) => new() { Index = i, Name = name, Kind = kind };
@@ -30,6 +30,46 @@ public sealed class DocumentTests : IntegrationTestBase
Assert.True(File.Exists(path));
}
[Fact]
public async Task SaveAs_over_a_name_held_by_another_open_document_fails_loudly()
{
// КОМПАС не перезаписывает файл, открытый другим документом, и НИЧЕГО об этом не сообщает:
// SaveAs возвращает void. Без проверки по факту агент получает ложное «Сохранено».
var path = TestPaths.NewFile(".m3d");
await _docs.CreateAsync(KompasDocumentType.Part);
await _docs.SaveAsAsync(path); // первый документ занял имя и остался открытым
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _docs.SaveAsAsync(path));
Assert.Contains(path, ex.Message, StringComparison.OrdinalIgnoreCase);
}
finally
{
await _docs.CloseAsync(save: false);
await _docs.CloseAsync(save: false);
}
}
[Fact]
public async Task Part_name_and_marking_survive_the_write()
{
await _docs.CreateAsync(KompasDocumentType.Part);
try
{
var written = await _docs.SetPartIdentityAsync("Значок декоративный", "ОМЕ.741124.001");
Assert.Equal("Значок декоративный", written.Name);
Assert.Equal("ОМЕ.741124.001", written.Marking);
// Незаданный параметр не затирает уже записанное.
var partial = await _docs.SetPartIdentityAsync(name: null, marking: "ОМЕ.741124.002");
Assert.Equal("Значок декоративный", partial.Name);
Assert.Equal("ОМЕ.741124.002", partial.Marking);
}
finally { await _docs.CloseAsync(save: false); }
}
[Fact]
public async Task Create_fragment_reports_type()
{
@@ -0,0 +1,60 @@
using Kompas.Mcp.Core.Startup;
namespace Kompas.Mcp.Tests;
/// <summary>
/// Текст ошибки инструмента. SDK показывает клиенту сообщение только у McpException, поэтому
/// причину собирает фильтр — и она обязана называть инструмент и суть, а не тип исключения.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ToolErrorTextTests
{
[Fact]
public void Message_names_the_tool_and_the_reason()
{
var text = ToolErrorText.Describe("extrude", new InvalidOperationException("Эскиз id=7 уже закрыт."));
Assert.Contains("extrude", text, StringComparison.Ordinal);
Assert.Contains("Эскиз id=7 уже закрыт.", text, StringComparison.Ordinal);
}
[Fact]
public void Inner_exception_is_appended_as_the_underlying_cause()
{
var inner = new InvalidOperationException("ksTextEx вернул 0.");
var outer = new InvalidOperationException("Примитив [0] (text) не построен.", inner);
var text = ToolErrorText.Describe("sketch_add", outer);
Assert.Contains("Примитив [0] (text) не построен.", text, StringComparison.Ordinal);
Assert.Contains("ksTextEx вернул 0.", text, StringComparison.Ordinal);
}
[Fact]
public void Single_wrapped_aggregate_is_unwrapped_to_the_real_cause()
{
var aggregate = new AggregateException(new KeyNotFoundException("Эскиз с id=42 не найден."));
var text = ToolErrorText.Describe("extrude", aggregate);
Assert.Contains("Эскиз с id=42 не найден.", text, StringComparison.Ordinal);
Assert.DoesNotContain("One or more errors", text, StringComparison.Ordinal);
}
[Fact]
public void Empty_message_falls_back_to_the_exception_type()
{
var text = ToolErrorText.Describe("hole", new TimeoutException(""));
Assert.Contains("TimeoutException", text, StringComparison.Ordinal);
}
[Fact]
public void Missing_tool_name_still_yields_a_readable_message()
{
var text = ToolErrorText.Describe(null, new ArgumentException("Радиус должен быть > 0."));
Assert.Contains("Радиус должен быть > 0.", text, StringComparison.Ordinal);
Assert.DoesNotContain("«»", text, StringComparison.Ordinal);
}
}