Документы и диагностика: имя детали, честное сохранение, причина ошибки
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:
@@ -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; }
|
||||
}
|
||||
@@ -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 '<tool>'» —
|
||||
/// а для агента ценна как раз причина: какого параметра не хватило и что делать дальше.
|
||||
/// Форматирование живёт в 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user