Журнал операций: история вызовов инструментов в JSONL
Каждый вызов пишется строкой JSON из фильтра в Program.cs: инструмент, аргументы, длительность, ok, полный текст ошибки либо начало ответа. Снимок модели в журнал не идёт — только размер картинки. Включается только явным путём: KOMPAS_MCP_OPLOG_PATH на старте или set_operation_log(enabled, path?) в сессии; KOMPAS_MCP_OPLOG=0 глушит, не стирая путь. Сбой записи операцию не роняет, причина видна в kompas_status. tools/dev/oplog-report.ps1 разбирает накопленное: частота и медианное время по инструментам, доля отказов, топ ошибок, ни разу не вызванные инструменты — материал для решений, что из каталога убрать и что переделать. Заодно поправлены устаревшие счётчики в README: 60 инструментов (было указано 57 при 59 фактических), 567 тестов вместо 427.
This commit is contained in:
@@ -64,6 +64,31 @@ description: Внутренняя методика доработки самог
|
||||
(`Core/Validation`). Переключатель — `set_auto_validate` и `KOMPAS_MCP_AUTOVALIDATE`; проверка
|
||||
никогда не превращает удачную операцию в ошибку (сбой самой проверки уходит в примечание).
|
||||
|
||||
## Журнал операций: чем подтверждать решения о каталоге
|
||||
|
||||
Решения «этот инструмент убрать, тот переделать» опираются на историю вызовов, а не на память о
|
||||
прошлых прогонах. Сервер пишет каждый вызов строкой JSON: `ts`, `tool`, `args`, `ms`, `ok`,
|
||||
`error` (текст целиком) либо `result` (первые 500 символов) + `resultChars`, а также `session` и
|
||||
`client` — по ним история режется на прогоны.
|
||||
|
||||
- **Устройство.** `Core/Diagnostics/OperationLog` (+`OperationLogEntry`, `OperationLogSettings`),
|
||||
запись — из фильтра вызовов в `Program.cs`: единственное место, через которое проходит каждый
|
||||
инструмент, там же формируется и текст ошибки. Перевод MCP-контекста в запись — `Host/ToolCallLog`
|
||||
(Core о протоколе не знает: его тесты гоняются без MCP-хоста). Снимок модели в журнал не
|
||||
попадает — вместо base64 пишется размер картинки.
|
||||
- **Включение.** Только явным путём: `KOMPAS_MCP_OPLOG_PATH` на старте (в `.mcp.json`, секция `env`)
|
||||
или `set_operation_log(enabled, path?)` в сессии; `KOMPAS_MCP_OPLOG=0` глушит, не стирая путь.
|
||||
Состояние показывает `kompas_status`. Сбой записи не имеет права уронить операцию — он оседает в
|
||||
`LastError` и виден в статусе.
|
||||
- **Разбор.** `pwsh -NoProfile -File tools/dev/oplog-report.ps1 [-Path …]` — частота и медианное
|
||||
время по инструментам, доля отказов, топ текстов ошибок и список ни разу не вызванных инструментов
|
||||
(каталог для сверки берётся из атрибутов `McpServerTool` в исходниках, а не из README).
|
||||
- **Как читать.** Инструмент с высокой долей отказов — кандидат на доработку (смотри тексты ошибок:
|
||||
чаще это непонятная агенту схема, а не COM); инструмент, которого нет в журнале после нескольких
|
||||
разных задач, — кандидат на слияние с соседом или удаление; долгие вызовы — кандидаты на пакетный
|
||||
параметр. Прежде чем удалять, проверь, что задача такого рода вообще попадала в прогоны: пустота
|
||||
бывает от того, что сценарий не запускали, а не от ненужности инструмента.
|
||||
|
||||
## Продуктизация приёма
|
||||
|
||||
> Если нужного инструмента ещё нет — не хардкодь обход под кейс. Заведи кейс в `usecases/`,
|
||||
|
||||
@@ -39,7 +39,7 @@ validated live. Also packaged as the Claude Code plugin `kompas` (no release pub
|
||||
The full tool catalog (by group) lives in [`README.md`](README.md) §«Инструменты» — the single
|
||||
source of truth; do not duplicate it here.
|
||||
|
||||
**The catalog is shaped for the agent, not for the SDK** (57 tools, down from 84): a tool names the
|
||||
**The catalog is shaped for the agent, not for the SDK** (60 tools, down from 84): a tool names the
|
||||
*operation*, the variant is a parameter (`extrude(mode=boss|cut)`, `hole(type=…)`, `pattern(kind=…)`);
|
||||
an object is picked by index **or** point through one tool (no `*_index` twins); list parameters
|
||||
(`entities[]`, `edgeIndices[]`) run in a single STA hop; and every mutating op appends its own build-check
|
||||
@@ -105,6 +105,15 @@ asking for a session restart when a rebuilt server has to be picked up.
|
||||
a skill edit needs no rebuild but is only picked up by a *new* session.
|
||||
- **Nothing internal leaks into a published skill** — no local SDK paths, no `usecases/`, no RAG-base
|
||||
references. Internal findings belong in `kompas-mcp-dev`; only reproducible methodology ships.
|
||||
- **The operation log is the fourth channel — evidence instead of recollection.** Every tool call can be
|
||||
appended to a JSONL file (`Core/Diagnostics/OperationLog`, written from the `CallToolFilter` in
|
||||
`Program.cs`): tool, arguments, duration, ok/error text, the first 500 chars of the answer. It writes
|
||||
**only** when a path is given (`KOMPAS_MCP_OPLOG_PATH` at startup or `set_operation_log(enabled, path)`
|
||||
in session) — no path, no file. `tools/dev/oplog-report.ps1` turns the accumulated history into what
|
||||
decides the catalog's shape: call frequency, failure rate per tool, the most common error texts, and
|
||||
the tools nobody has ever called (candidates for removal). A tool that only ever fails or never gets
|
||||
called is a finding about the catalog, not about the task that met it.
|
||||
|
||||
### The rebuild cycle
|
||||
|
||||
A running server holds its own binary: with `.mcp.json` pointing straight at
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**MCP-сервер для управления CAD-системой КОМПАС-3D (АСКОН) языковой моделью.**
|
||||
|
||||
Сервер — COM-клиент КОМПАС-3D: подключается к установленному КОМПАС и даёт LLM 57 инструментов для
|
||||
Сервер — COM-клиент КОМПАС-3D: подключается к установленному КОМПАС и даёт LLM 60 инструментов для
|
||||
создания документов, построения 3D-деталей (эскиз → формообразующая операция), работы со сборками и
|
||||
чертежами, импорта/экспорта STEP, прямого редактирования импортированной B-rep, а также структурного
|
||||
осмотра модели (`describe_model` — дерево операций, тела, топология, переменные, МЦХ одним вызовом)
|
||||
@@ -61,11 +61,11 @@ kompas-mcp.exe --version # версия бинаря, КОМПАС для эт
|
||||
}
|
||||
```
|
||||
|
||||
## Инструменты (58)
|
||||
## Инструменты (60)
|
||||
|
||||
| Группа | Инструменты |
|
||||
|---|---|
|
||||
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible`, `set_auto_validate` |
|
||||
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible`, `set_auto_validate`, `set_operation_log` |
|
||||
| Documents | `document_create`, `document_open`, `document_save`, `document_close`, `document_active`, `set_part_info` |
|
||||
| Sketch | `sketch_create`, `sketch_add`, `sketch_close` |
|
||||
| Features | `extrude`, `revolve`, `primitive`, `fillet_edge`, `chamfer_edge`, `shell`, `rib`, `sweep`, `loft`, `pattern`, `mirror`, `hole`, `draft`, `feature_delete`, `rebuild` |
|
||||
@@ -115,6 +115,30 @@ result=new|union|subtract|intersect)` строит тело по размера
|
||||
> `validate_part` после каждого шага не нужен. Отключается на сессию через `set_auto_validate(false)`
|
||||
> или при старте сервера переменной окружения `KOMPAS_MCP_AUTOVALIDATE=0`.
|
||||
|
||||
### Журнал операций
|
||||
|
||||
Сервер умеет записывать каждый вызов инструмента в файл JSON Lines — по строке на вызов: время,
|
||||
имя инструмента, аргументы, длительность, успех или полный текст ошибки, начало ответа (снимки
|
||||
модели не пишутся, только их размер). Журнал — материал для решений о самом каталоге: что зовут,
|
||||
что стабильно отказывает, что не зовут никогда.
|
||||
|
||||
```powershell
|
||||
$env:KOMPAS_MCP_OPLOG_PATH = "D:\logs\kompas-operations.jsonl" # путь = включение
|
||||
$env:KOMPAS_MCP_OPLOG = "0" # временно заглушить, путь не стирая
|
||||
```
|
||||
|
||||
Без пути журнал выключен: сервер не пишет ничего и никуда. В сессии режим переключается
|
||||
инструментом `set_operation_log(enabled, path?)`, состояние видно в `kompas_status`. Сбой записи
|
||||
никогда не роняет операцию — причина оседает в статусе. Сводка по накопленному:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File tools/dev/oplog-report.ps1 -Path D:\logs\kompas-operations.jsonl
|
||||
```
|
||||
|
||||
Отчёт даёт частоту вызовов и медианное время по инструментам, долю отказов, самые частые тексты
|
||||
ошибок и список инструментов, ни разу не встретившихся в журнале (каталог берётся из атрибутов
|
||||
`McpServerTool` в исходниках).
|
||||
|
||||
> ⚠️ Вызовы зависимых инструментов выполняйте **последовательно**, дождавшись ответа: сессия
|
||||
> построения хранит эскизы по `id`, а сервер обрабатывает запросы конкурентно.
|
||||
|
||||
@@ -124,7 +148,7 @@ result=new|union|subtract|intersect)` строит тело по размера
|
||||
src/Kompas.Mcp.Core/ COM-слой: STA-диспетчер, подключение, документы, эскизы/операции,
|
||||
сборки, чертежи, конвертация, снимок, инспекция модели
|
||||
src/Kompas.Mcp.Host/ MCP-сервер (stdio) + определения инструментов (Tools/ — по группе на файл)
|
||||
tests/Kompas.Mcp.Tests/ 427 тестов: unit (корень) + Integration/ (нужен запущенный КОМПАС)
|
||||
tests/Kompas.Mcp.Tests/ 567 тестов: unit (корень) + Integration/ (нужен запущенный КОМПАС)
|
||||
libs/kompas-interop/ interop-сборки КОМПАС (из SDK Samples/Common) — только для компиляции
|
||||
plugin/ плагин Claude Code `kompas`: манифест, лаунчер, лок версии сервера,
|
||||
навыки kompas-3d и kompas-fdm-design (источник истины)
|
||||
@@ -148,8 +172,8 @@ dotnet test --filter "Category=Integration" # только integration
|
||||
pwsh -NoProfile -File tools/tests/run-ps-tests.ps1 # Pester-тесты скриптов плагина
|
||||
```
|
||||
|
||||
427 тестов: 291 unit + 136 integration. CI (Gitea Actions, `.gitea/workflows/ci.yml`) собирает проект и
|
||||
гоняет `Category=Unit&Requires!=Windows` на Linux-раннере (245 тестов) — 5 тестов STA-диспетчера
|
||||
567 тестов: 406 unit + 161 integration. CI (Gitea Actions, `.gitea/workflows/ci.yml`) собирает проект и
|
||||
гоняет `Category=Unit&Requires!=Windows` на Linux-раннере (401 тест) — 5 тестов STA-диспетчера
|
||||
помечены `Requires=Windows` (`Thread.SetApartmentState` не работает на Linux). Integration-тесты в CI
|
||||
не запускаются никогда: нужен запущенный КОМПАС с GUI и лицензией. Плюс 41 Pester-тест
|
||||
PowerShell-скриптов плагина и раскладки навыков — в счёт .NET-тестов не входят.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
|
||||
namespace Kompas.Mcp.Core.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Журнал операций: дописывает в файл по строке JSON на каждый вызов инструмента. Нужен, чтобы
|
||||
/// накапливать историю работы агента с сервером — по ней потом видно, какие инструменты реально
|
||||
/// зовут, какие падают и какие можно убрать или переделать.
|
||||
/// <para>Включается путём: <see cref="OperationLogSettings.PathVariable"/> на старте или
|
||||
/// <c>set_operation_log</c> в сессии. Без пути журнал молчит.</para>
|
||||
/// <para>Сбой записи никогда не ломает вызов инструмента: причина запоминается в
|
||||
/// <see cref="LastError"/> (её показывают <c>kompas_status</c> и <c>set_operation_log</c>),
|
||||
/// а операция продолжается как ни в чём не бывало — журнал диагностический, а не рабочий.</para>
|
||||
/// </summary>
|
||||
public sealed class OperationLog
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private volatile bool _enabled;
|
||||
private string? _path;
|
||||
private int _written;
|
||||
private string? _lastError;
|
||||
|
||||
public OperationLog()
|
||||
: this(OperationLogSettings.ReadFromEnvironment())
|
||||
{
|
||||
}
|
||||
|
||||
public OperationLog(OperationLogOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (!options.Enabled || options.Path is null) return;
|
||||
|
||||
// Каталог создаём на старте: иначе первая же запись обнаружит проблему уже в фильтре,
|
||||
// где её никто не увидит.
|
||||
try
|
||||
{
|
||||
_path = OutputPath.Prepare(options.Path, OperationLogSettings.PathVariable);
|
||||
_enabled = true;
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
_lastError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Пишется ли журнал сейчас.</summary>
|
||||
public bool Enabled => _enabled;
|
||||
|
||||
/// <summary>Файл журнала (абсолютный путь) или <c>null</c>, если путь ни разу не задавали.</summary>
|
||||
public string? Path
|
||||
{
|
||||
get { lock (_gate) return _path; }
|
||||
}
|
||||
|
||||
/// <summary>Сколько записей ушло в файл с момента старта сервера.</summary>
|
||||
public int WrittenCount
|
||||
{
|
||||
get { lock (_gate) return _written; }
|
||||
}
|
||||
|
||||
/// <summary>Причина последнего сбоя (записи или включения) — или <c>null</c>.</summary>
|
||||
public string? LastError
|
||||
{
|
||||
get { lock (_gate) return _lastError; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Включить журнал. Путь берётся из параметра, иначе — из уже настроенного, иначе — из
|
||||
/// окружения. Каталог назначения создаётся.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Путь не задан ни одним из способов или некорректен.</exception>
|
||||
/// <returns>Файл, в который пойдёт запись.</returns>
|
||||
public string Enable(string? path = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var target = OperationLogSettings.ResolveFile(path)
|
||||
?? _path
|
||||
?? OperationLogSettings.ResolveFile(
|
||||
Environment.GetEnvironmentVariable(OperationLogSettings.PathVariable));
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Путь к файлу журнала не задан: укажите параметр path или переменную окружения " +
|
||||
OperationLogSettings.PathVariable + ".", nameof(path));
|
||||
}
|
||||
|
||||
_path = OutputPath.Prepare(target, nameof(path));
|
||||
_lastError = null;
|
||||
_enabled = true;
|
||||
return _path;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Выключить журнал; путь запоминается, повторное включение обойдётся без него.</summary>
|
||||
public void Disable()
|
||||
{
|
||||
lock (_gate) _enabled = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Дописать запись о вызове. Быстрый выход, когда журнал выключен, — фильтр зовёт этот метод
|
||||
/// на каждом вызове инструмента.
|
||||
/// </summary>
|
||||
public void Write(OperationLogEntry entry)
|
||||
{
|
||||
if (!_enabled || entry is null) return;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || _path is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
File.AppendAllText(_path, entry.ToJson() + Environment.NewLine);
|
||||
_written++;
|
||||
_lastError = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Диагностика не имеет права уронить операцию — ни сбоем файла, ни неожиданным
|
||||
// значением в аргументах.
|
||||
_lastError = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Строка о состоянии журнала для ответов инструментов.</summary>
|
||||
public string Describe()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var problem = _lastError is null ? "" : $" Последняя ошибка записи: {_lastError}.";
|
||||
if (!_enabled)
|
||||
{
|
||||
return _path is null
|
||||
? "Журнал операций выключен."
|
||||
: $"Журнал операций выключен (записано за сессию: {_written}, файл {_path})." + problem;
|
||||
}
|
||||
|
||||
return $"Журнал операций пишется в {_path} (записей за сессию: {_written})." + problem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Kompas.Mcp.Core.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Одна строка журнала операций — вызов инструмента: что звали, с чем, сколько заняло и чем
|
||||
/// кончилось. Формат — JSON Lines (объект на строку), чтобы историю за месяцы можно было читать
|
||||
/// потоком и грепать без парсера.
|
||||
/// <para>Ответ пишется усечённым (<see cref="ResultLimit"/>): цель журнала — понять, какие
|
||||
/// инструменты используются и на чём спотыкаются, а не хранить полный вывод <c>describe_model</c>.
|
||||
/// Текст ошибки, наоборот, сохраняется целиком — ради него журнал и заводится.</para>
|
||||
/// </summary>
|
||||
public sealed record OperationLogEntry
|
||||
{
|
||||
/// <summary>Сколько символов ответа сохраняется.</summary>
|
||||
public const int ResultLimit = 500;
|
||||
|
||||
/// <summary>Предел сериализованных аргументов: длинная надпись или пакет из сотен примитивов
|
||||
/// не должны раздувать строку журнала до мегабайта.</summary>
|
||||
public const int ArgumentsLimit = 8000;
|
||||
|
||||
/// <summary>Момент завершения вызова (UTC).</summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>Имя инструмента.</summary>
|
||||
public required string Tool { get; init; }
|
||||
|
||||
/// <summary>Аргументы вызова как их прислал клиент.</summary>
|
||||
public IReadOnlyDictionary<string, JsonElement>? Arguments { get; init; }
|
||||
|
||||
/// <summary>Идентификатор MCP-сессии — по нему вызовы группируются в один прогон.</summary>
|
||||
public string? Session { get; init; }
|
||||
|
||||
/// <summary>Клиент MCP (имя и версия), если он представился.</summary>
|
||||
public string? Client { get; init; }
|
||||
|
||||
/// <summary>Длительность вызова.</summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>Успешен ли вызов (ошибка инструмента — тоже неуспех, не только исключение).</summary>
|
||||
public required bool Ok { get; init; }
|
||||
|
||||
/// <summary>Текст ответа (будет усечён при записи).</summary>
|
||||
public string? Result { get; init; }
|
||||
|
||||
/// <summary>Текст ошибки — тот же, что ушёл агенту.</summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
private static readonly JsonWriterOptions WriterOptions = new()
|
||||
{
|
||||
// Без этого кириллица уезжает в \u04xx и журнал нечитаем глазами.
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false,
|
||||
};
|
||||
|
||||
/// <summary>Сериализовать запись в одну строку JSON (без перевода строки на конце).</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
using var buffer = new MemoryStream(512);
|
||||
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("ts", Timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
|
||||
writer.WriteString("tool", Tool);
|
||||
writer.WriteNumber("ms", (long)Math.Round(Duration.TotalMilliseconds));
|
||||
writer.WriteBoolean("ok", Ok);
|
||||
|
||||
if (!string.IsNullOrEmpty(Session)) writer.WriteString("session", Session);
|
||||
if (!string.IsNullOrEmpty(Client)) writer.WriteString("client", Client);
|
||||
|
||||
WriteArguments(writer);
|
||||
|
||||
if (Error is { Length: > 0 })
|
||||
{
|
||||
writer.WriteString("error", Error);
|
||||
}
|
||||
else if (Result is not null)
|
||||
{
|
||||
writer.WriteString("result", Truncate(Result, ResultLimit));
|
||||
writer.WriteNumber("resultChars", Result.Length);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.ToArray());
|
||||
}
|
||||
|
||||
private void WriteArguments(Utf8JsonWriter writer)
|
||||
{
|
||||
if (Arguments is null || Arguments.Count == 0) return;
|
||||
|
||||
var json = SerializeArguments(Arguments);
|
||||
writer.WritePropertyName("args");
|
||||
if (json.Length <= ArgumentsLimit)
|
||||
{
|
||||
writer.WriteRawValue(json, skipInputValidation: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Усечённый JSON — уже не JSON, поэтому кладём его строкой: анализ увидит и сам факт
|
||||
// усечения, и начало аргументов.
|
||||
writer.WriteStringValue(Truncate(json, ArgumentsLimit));
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeArguments(IReadOnlyDictionary<string, JsonElement> arguments)
|
||||
{
|
||||
using var buffer = new MemoryStream(256);
|
||||
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var (name, value) in arguments)
|
||||
{
|
||||
writer.WritePropertyName(name);
|
||||
// WriteTo переносит значение как есть — без рефлексии и без потери типа.
|
||||
value.WriteTo(writer);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Обрезать текст до предела, пометив, сколько символов отброшено.</summary>
|
||||
public static string Truncate(string text, int limit)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
if (limit <= 0 || text.Length <= limit) return text;
|
||||
return text[..limit] + $"…[+{text.Length - limit} симв.]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace Kompas.Mcp.Core.Diagnostics;
|
||||
|
||||
/// <summary>Режим журнала операций на старте: включён ли и в какой файл писать.</summary>
|
||||
/// <param name="Enabled">Писать ли вызовы инструментов.</param>
|
||||
/// <param name="Path">Файл журнала (уже с именем файла, каталог мог быть не создан).</param>
|
||||
public sealed record OperationLogOptions(bool Enabled, string? Path);
|
||||
|
||||
/// <summary>
|
||||
/// Разбор настроек журнала операций из окружения — отдельно от файловой работы, чтобы покрыть
|
||||
/// тестами. Журнал никогда не пишется «куда-нибудь сам»: без явного пути
|
||||
/// (<see cref="PathVariable"/> или параметр <c>set_operation_log</c>) он выключен.
|
||||
/// </summary>
|
||||
public static class OperationLogSettings
|
||||
{
|
||||
/// <summary>Переменная окружения с путём к файлу (или каталогу) журнала.</summary>
|
||||
public const string PathVariable = "KOMPAS_MCP_OPLOG_PATH";
|
||||
|
||||
/// <summary>Переменная окружения-выключателя: позволяет заглушить журнал, не стирая путь.</summary>
|
||||
public const string EnabledVariable = "KOMPAS_MCP_OPLOG";
|
||||
|
||||
/// <summary>Имя файла, если в настройке указан каталог.</summary>
|
||||
public const string DefaultFileName = "kompas-operations.jsonl";
|
||||
|
||||
/// <summary>
|
||||
/// Собрать режим из пары значений. Пустой путь — журнал выключен (это умолчание).
|
||||
/// Выключатель понимает 0 | false | off | no | disabled (регистр не важен), всё прочее —
|
||||
/// «включено», как и отсутствие переменной.
|
||||
/// </summary>
|
||||
public static OperationLogOptions Parse(string? enabled, string? path)
|
||||
{
|
||||
var file = ResolveFile(path);
|
||||
return file is null
|
||||
? new OperationLogOptions(false, null)
|
||||
: new OperationLogOptions(ParseEnabled(enabled), file);
|
||||
}
|
||||
|
||||
/// <summary>Прочитать режим из переменных окружения процесса.</summary>
|
||||
public static OperationLogOptions ReadFromEnvironment()
|
||||
=> Parse(Environment.GetEnvironmentVariable(EnabledVariable),
|
||||
Environment.GetEnvironmentVariable(PathVariable));
|
||||
|
||||
/// <summary>Значение выключателя: пусто — включено, явные «нет» — выключено.</summary>
|
||||
public static bool ParseEnabled(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return true;
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"0" or "false" or "off" or "no" or "disabled" => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Привести настройку к пути файла. Путь без расширения или с завершающим разделителем
|
||||
/// считается каталогом — в него кладётся <see cref="DefaultFileName"/>. Проверка чисто
|
||||
/// строковая (без обращения к диску): каталога может ещё не быть, его создаст запись.
|
||||
/// Возвращает <c>null</c>, если путь не задан.
|
||||
/// </summary>
|
||||
public static string? ResolveFile(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
|
||||
var trimmed = path.Trim();
|
||||
var looksLikeDirectory =
|
||||
trimmed.EndsWith(Path.DirectorySeparatorChar) ||
|
||||
trimmed.EndsWith(Path.AltDirectorySeparatorChar) ||
|
||||
Path.GetExtension(trimmed).Length == 0;
|
||||
|
||||
return looksLikeDirectory ? Path.Combine(trimmed, DefaultFileName) : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
using System.Diagnostics;
|
||||
using Kompas.Mcp.Core;
|
||||
using Kompas.Mcp.Core.Assemblies;
|
||||
using Kompas.Mcp.Core.Conversion;
|
||||
using Kompas.Mcp.Core.Diagnostics;
|
||||
using Kompas.Mcp.Core.Documents;
|
||||
using Kompas.Mcp.Core.Drawings;
|
||||
using Kompas.Mcp.Core.Editing;
|
||||
using Kompas.Mcp.Core.Interop;
|
||||
using Kompas.Mcp.Host;
|
||||
using Kompas.Mcp.Core.Modeling;
|
||||
using Kompas.Mcp.Core.Query;
|
||||
using Kompas.Mcp.Core.Startup;
|
||||
@@ -55,6 +58,11 @@ builder.Services.AddSingleton<ValidationService>();
|
||||
// Режим авто-валидации живёт на весь процесс: умолчание из KOMPAS_MCP_AUTOVALIDATE, переключение — set_auto_validate.
|
||||
builder.Services.AddSingleton<AutoValidation>();
|
||||
|
||||
// Журнал операций создаётся до хоста: его же захватывает фильтр вызовов ниже, а инструмент
|
||||
// set_operation_log получает тот же экземпляр через DI.
|
||||
var operationLog = new OperationLog();
|
||||
builder.Services.AddSingleton(operationLog);
|
||||
|
||||
// MCP-сервер поверх stdio; инструменты находятся по атрибутам в этой сборке.
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
@@ -62,14 +70,23 @@ builder.Services
|
||||
// Без фильтра клиент получает на любую ошибку безликое «An error occurred invoking '<tool>'»:
|
||||
// SDK показывает текст исключения, только если оно McpException. А вся польза наших сообщений
|
||||
// именно в тексте («какого параметра не хватает», «почему операция не построилась»).
|
||||
// Здесь же снимается журнал операций: единственная точка, через которую проходит каждый вызов.
|
||||
.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (context, ct) =>
|
||||
{
|
||||
try { return await next(context, ct); }
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
var result = await next(context, ct);
|
||||
operationLog.Write(ToolCallLog.Describe(context, Stopwatch.GetElapsedTime(started), result, error: null));
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
var message = ToolErrorText.Describe(context.Params?.Name, ex);
|
||||
operationLog.Write(ToolCallLog.Describe(context, Stopwatch.GetElapsedTime(started), result: null, message));
|
||||
return new CallToolResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = ToolErrorText.Describe(context.Params?.Name, ex) }],
|
||||
Content = [new TextContentBlock { Text = message }],
|
||||
IsError = true,
|
||||
};
|
||||
}
|
||||
@@ -91,4 +108,11 @@ catch (KompasInteropException ex)
|
||||
interopLog.LogWarning("{Message}", ex.Message);
|
||||
}
|
||||
|
||||
// Состояние журнала операций — в stderr на старте: иначе включённый через окружение журнал
|
||||
// работает молча, а невключившийся (плохой путь) молчит вдвойне.
|
||||
if (operationLog.Enabled || operationLog.LastError is not null)
|
||||
{
|
||||
interopLog.LogInformation("{Message}", operationLog.Describe());
|
||||
}
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Kompas.Mcp.Core.Diagnostics;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Kompas.Mcp.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Перевод вызова инструмента из терминов MCP в запись журнала. Живёт в хосте, потому что
|
||||
/// <c>Kompas.Mcp.Core</c> о протоколе не знает (и не должен: его тесты гоняются без MCP-хоста).
|
||||
/// </summary>
|
||||
internal static class ToolCallLog
|
||||
{
|
||||
/// <summary>Собрать запись о завершившемся вызове.</summary>
|
||||
/// <param name="context">Контекст запроса: имя инструмента, аргументы, сессия, клиент.</param>
|
||||
/// <param name="duration">Сколько занял вызов.</param>
|
||||
/// <param name="result">Ответ инструмента (<c>null</c>, если вылетело исключение).</param>
|
||||
/// <param name="error">Текст ошибки, ушедший агенту, или <c>null</c> при успехе.</param>
|
||||
public static OperationLogEntry Describe(
|
||||
RequestContext<CallToolRequestParams> context,
|
||||
TimeSpan duration,
|
||||
CallToolResult? result,
|
||||
string? error)
|
||||
{
|
||||
var failed = error is not null || result?.IsError == true;
|
||||
var text = ResultText(result);
|
||||
|
||||
return new OperationLogEntry
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Tool = context.Params?.Name ?? "?",
|
||||
Arguments = context.Params?.Arguments as IReadOnlyDictionary<string, System.Text.Json.JsonElement>
|
||||
?? context.Params?.Arguments?.ToDictionary(a => a.Key, a => a.Value),
|
||||
Session = context.Server?.SessionId,
|
||||
Client = ClientName(context.Server?.ClientInfo),
|
||||
Duration = duration,
|
||||
Ok = !failed,
|
||||
// Ошибка инструмента приходит обычным ответом с IsError — её текст ценнее, чем поле result.
|
||||
Error = error ?? (failed ? text : null),
|
||||
Result = failed ? null : text,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ClientName(Implementation? client)
|
||||
=> client is null
|
||||
? null
|
||||
: string.IsNullOrEmpty(client.Version) ? client.Name : $"{client.Name} {client.Version}";
|
||||
|
||||
/// <summary>
|
||||
/// Текст ответа. Снимок модели (base64 в <c>ImageContentBlock</c>) в журнал не попадает
|
||||
/// никогда — вместо картинки пишется её размер.
|
||||
/// </summary>
|
||||
private static string? ResultText(CallToolResult? result)
|
||||
{
|
||||
if (result?.Content is not { Count: > 0 } content) return null;
|
||||
|
||||
var parts = content.Select(block => block switch
|
||||
{
|
||||
TextContentBlock text => text.Text,
|
||||
ImageContentBlock image => $"<изображение {image.MimeType}, {image.Data.Length} байт>",
|
||||
_ => $"<{block.Type}>",
|
||||
});
|
||||
|
||||
return string.Join("\n", parts);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
using System.ComponentModel;
|
||||
using Kompas.Mcp.Core;
|
||||
using Kompas.Mcp.Core.Diagnostics;
|
||||
using Kompas.Mcp.Core.Interop;
|
||||
using Kompas.Mcp.Core.Validation;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Kompas.Mcp.Host.Tools;
|
||||
|
||||
/// <summary>Системные инструменты: подключение, статус, видимость окна.</summary>
|
||||
/// <summary>Системные инструменты: подключение, статус, видимость окна, журнал операций.</summary>
|
||||
[McpServerToolType]
|
||||
public sealed class SystemTools(KompasSession session, AutoValidation autoValidation)
|
||||
public sealed class SystemTools(KompasSession session, AutoValidation autoValidation, OperationLog operationLog)
|
||||
{
|
||||
[McpServerTool(Name = "kompas_connect")]
|
||||
[Description("Подключиться к КОМПАС-3D: присоединиться к запущенному экземпляру или запустить новый, показать окно. Возвращает версию и редакцию.")]
|
||||
@@ -41,7 +42,30 @@ public sealed class SystemTools(KompasSession session, AutoValidation autoValida
|
||||
|
||||
return (session.IsConnected && session.LastInfo is { } i
|
||||
? $"Подключено: КОМПАС {i.Version} ({i.Edition})."
|
||||
: "Не подключено. Вызовите kompas_connect.") + " " + validation;
|
||||
: "Не подключено. Вызовите kompas_connect.") + " " + validation + " " + operationLog.Describe();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "set_operation_log")]
|
||||
[Description("Включить или выключить журнал операций: запись каждого вызова инструмента в файл JSONL " +
|
||||
"(имя инструмента, аргументы, длительность, успех или текст ошибки, начало ответа). " +
|
||||
"Журнал нужен для накопления истории работы — по ней видно, какие инструменты реально " +
|
||||
"используются и на чём спотыкаются. Вызывайте по прямой просьбе пользователя; путь задаётся " +
|
||||
"параметром path или переменной окружения KOMPAS_MCP_OPLOG_PATH (без пути журнал выключен). " +
|
||||
"Снимки модели в журнал не пишутся — только их размер.")]
|
||||
public string SetOperationLog(
|
||||
[Description("true — включить запись, false — выключить")] bool enabled,
|
||||
[Description("Файл журнала (.jsonl) или каталог для него. Пусто — прошлый путь либо KOMPAS_MCP_OPLOG_PATH")]
|
||||
string? path = null)
|
||||
{
|
||||
if (!enabled)
|
||||
{
|
||||
operationLog.Disable();
|
||||
return operationLog.Describe();
|
||||
}
|
||||
|
||||
var file = operationLog.Enable(path);
|
||||
return $"Журнал операций включён: {file}. Пишется по строке JSON на вызов инструмента " +
|
||||
$"(записей за сессию: {operationLog.WrittenCount}).";
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "set_auto_validate")]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Text.Json;
|
||||
using Kompas.Mcp.Core.Diagnostics;
|
||||
|
||||
namespace Kompas.Mcp.Tests;
|
||||
|
||||
/// <summary>Формат строки журнала: одна строка JSON, кириллица не эскейпится, ответ усечён.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OperationLogEntryTests
|
||||
{
|
||||
private static OperationLogEntry Entry(
|
||||
string tool = "extrude",
|
||||
string? arguments = null,
|
||||
bool ok = true,
|
||||
string? result = null,
|
||||
string? error = null)
|
||||
=> new()
|
||||
{
|
||||
Timestamp = new DateTimeOffset(2026, 7, 31, 10, 20, 30, 456, TimeSpan.Zero),
|
||||
Tool = tool,
|
||||
Arguments = arguments is null ? null : Parse(arguments),
|
||||
Duration = TimeSpan.FromMilliseconds(412),
|
||||
Ok = ok,
|
||||
Result = result,
|
||||
Error = error,
|
||||
};
|
||||
|
||||
private static Dictionary<string, JsonElement> Parse(string json)
|
||||
=> JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!;
|
||||
|
||||
private static JsonElement Json(OperationLogEntry entry)
|
||||
=> JsonDocument.Parse(entry.ToJson()).RootElement;
|
||||
|
||||
[Fact]
|
||||
public void Writes_single_line()
|
||||
{
|
||||
var json = Entry(result: "Построено.\nВторая строка.").ToJson();
|
||||
|
||||
Assert.DoesNotContain('\n', json);
|
||||
Assert.DoesNotContain('\r', json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keeps_core_fields()
|
||||
{
|
||||
var root = Json(Entry(result: "Готово."));
|
||||
|
||||
Assert.Equal("2026-07-31T10:20:30.456Z", root.GetProperty("ts").GetString());
|
||||
Assert.Equal("extrude", root.GetProperty("tool").GetString());
|
||||
Assert.Equal(412, root.GetProperty("ms").GetInt32());
|
||||
Assert.True(root.GetProperty("ok").GetBoolean());
|
||||
Assert.Equal("Готово.", root.GetProperty("result").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cyrillic_is_not_escaped()
|
||||
=> Assert.Contains("Готово", Entry(result: "Готово.").ToJson());
|
||||
|
||||
[Fact]
|
||||
public void Arguments_are_kept_as_json()
|
||||
{
|
||||
var root = Json(Entry(arguments: """{"depth":20,"mode":"boss","draft":false}"""));
|
||||
var args = root.GetProperty("args");
|
||||
|
||||
Assert.Equal(20, args.GetProperty("depth").GetInt32());
|
||||
Assert.Equal("boss", args.GetProperty("mode").GetString());
|
||||
Assert.False(args.GetProperty("draft").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_arguments_are_omitted()
|
||||
=> Assert.False(Json(Entry(arguments: "{}")).TryGetProperty("args", out _));
|
||||
|
||||
[Fact]
|
||||
public void Huge_arguments_are_truncated_to_string()
|
||||
{
|
||||
var text = new string('ш', OperationLogEntry.ArgumentsLimit + 100);
|
||||
var arguments = JsonSerializer.Serialize(new Dictionary<string, string> { ["text"] = text });
|
||||
|
||||
var args = Json(Entry(arguments: arguments)).GetProperty("args");
|
||||
|
||||
Assert.Equal(JsonValueKind.String, args.ValueKind);
|
||||
Assert.Contains("симв.]", args.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_result_is_truncated_but_length_is_kept()
|
||||
{
|
||||
var text = new string('x', OperationLogEntry.ResultLimit + 250);
|
||||
|
||||
var root = Json(Entry(result: text));
|
||||
|
||||
Assert.StartsWith(new string('x', OperationLogEntry.ResultLimit), root.GetProperty("result").GetString());
|
||||
Assert.Contains("[+250 симв.]", root.GetProperty("result").GetString());
|
||||
Assert.Equal(text.Length, root.GetProperty("resultChars").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_is_kept_whole_and_replaces_result()
|
||||
{
|
||||
var message = "Инструмент «extrude» не выполнен: " + new string('п', OperationLogEntry.ResultLimit + 50);
|
||||
|
||||
var root = Json(Entry(ok: false, result: "не должно попасть", error: message));
|
||||
|
||||
Assert.False(root.GetProperty("ok").GetBoolean());
|
||||
Assert.Equal(message, root.GetProperty("error").GetString());
|
||||
Assert.False(root.TryGetProperty("result", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_and_client_are_optional()
|
||||
{
|
||||
var root = Json(Entry());
|
||||
|
||||
Assert.False(root.TryGetProperty("session", out _));
|
||||
Assert.False(root.TryGetProperty("client", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncate_leaves_short_text_alone()
|
||||
=> Assert.Equal("коротко", OperationLogEntry.Truncate("коротко", 100));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Kompas.Mcp.Core.Diagnostics;
|
||||
|
||||
namespace Kompas.Mcp.Tests;
|
||||
|
||||
/// <summary>Настройки журнала операций: без пути — выключен, путь-каталог получает имя файла.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OperationLogSettingsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Disabled_without_path(string? path)
|
||||
{
|
||||
var options = OperationLogSettings.Parse(enabled: "1", path: path);
|
||||
|
||||
Assert.False(options.Enabled);
|
||||
Assert.Null(options.Path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
[InlineData("что угодно")]
|
||||
public void Enabled_when_path_set(string? enabled)
|
||||
=> Assert.True(OperationLogSettings.Parse(enabled, Path.Combine("C:", "logs", "ops.jsonl")).Enabled);
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("false")]
|
||||
[InlineData("OFF")]
|
||||
[InlineData("no")]
|
||||
[InlineData("disabled")]
|
||||
[InlineData(" 0 ")]
|
||||
public void Switch_off_keeps_path_but_disables(string enabled)
|
||||
{
|
||||
var file = Path.Combine("C:", "logs", "ops.jsonl");
|
||||
var options = OperationLogSettings.Parse(enabled, file);
|
||||
|
||||
Assert.False(options.Enabled);
|
||||
Assert.Equal(file, options.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_path_is_kept_as_is()
|
||||
{
|
||||
var file = Path.Combine("C:", "logs", "ops.jsonl");
|
||||
|
||||
Assert.Equal(file, OperationLogSettings.ResolveFile(file));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Directory_path_gets_default_file_name()
|
||||
{
|
||||
var directory = Path.Combine("C:", "logs");
|
||||
|
||||
Assert.Equal(Path.Combine(directory, OperationLogSettings.DefaultFileName),
|
||||
OperationLogSettings.ResolveFile(directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trailing_separator_means_directory()
|
||||
{
|
||||
var directory = Path.Combine("C:", "logs") + Path.DirectorySeparatorChar;
|
||||
|
||||
Assert.Equal(Path.Combine(directory, OperationLogSettings.DefaultFileName),
|
||||
OperationLogSettings.ResolveFile(directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Path_is_trimmed()
|
||||
=> Assert.Equal("ops.jsonl", OperationLogSettings.ResolveFile(" ops.jsonl "));
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Text.Json;
|
||||
using Kompas.Mcp.Core.Diagnostics;
|
||||
|
||||
namespace Kompas.Mcp.Tests;
|
||||
|
||||
/// <summary>Журнал операций: пишет JSONL только при включённом режиме и не роняет вызовы при сбоях.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OperationLogTests : IDisposable
|
||||
{
|
||||
private readonly string _directory =
|
||||
Path.Combine(Path.GetTempPath(), "kompas-mcp-oplog-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
private string File(string name = "ops.jsonl") => Path.Combine(_directory, name);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
private static OperationLogEntry Entry(string tool = "extrude", bool ok = true, string? result = "Готово.")
|
||||
=> new()
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Tool = tool,
|
||||
Duration = TimeSpan.FromMilliseconds(10),
|
||||
Ok = ok,
|
||||
Result = result,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Disabled_by_default()
|
||||
{
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: false, Path: File()));
|
||||
|
||||
log.Write(Entry());
|
||||
|
||||
Assert.False(log.Enabled);
|
||||
Assert.False(System.IO.File.Exists(File()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writes_one_line_per_call()
|
||||
{
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
|
||||
|
||||
log.Write(Entry("extrude"));
|
||||
log.Write(Entry("fillet_edge"));
|
||||
|
||||
var lines = System.IO.File.ReadAllLines(File());
|
||||
Assert.Equal(2, lines.Length);
|
||||
Assert.Equal("extrude", JsonDocument.Parse(lines[0]).RootElement.GetProperty("tool").GetString());
|
||||
Assert.Equal("fillet_edge", JsonDocument.Parse(lines[1]).RootElement.GetProperty("tool").GetString());
|
||||
Assert.Equal(2, log.WrittenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Creates_missing_directory()
|
||||
{
|
||||
var nested = Path.Combine(_directory, "deep", "ops.jsonl");
|
||||
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: nested));
|
||||
log.Write(Entry());
|
||||
|
||||
Assert.True(System.IO.File.Exists(nested));
|
||||
Assert.Equal(Path.GetFullPath(nested), log.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Appends_to_existing_file()
|
||||
{
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
|
||||
log.Write(Entry("extrude"));
|
||||
|
||||
var second = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
|
||||
second.Write(Entry("hole"));
|
||||
|
||||
Assert.Equal(2, System.IO.File.ReadAllLines(File()).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disable_stops_writing_and_keeps_path()
|
||||
{
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
|
||||
log.Write(Entry());
|
||||
|
||||
log.Disable();
|
||||
log.Write(Entry());
|
||||
|
||||
Assert.Single(System.IO.File.ReadAllLines(File()));
|
||||
Assert.False(log.Enabled);
|
||||
Assert.Equal(Path.GetFullPath(File()), log.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enable_reuses_remembered_path()
|
||||
{
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
|
||||
log.Disable();
|
||||
|
||||
var target = log.Enable();
|
||||
|
||||
log.Write(Entry());
|
||||
Assert.Equal(Path.GetFullPath(File()), target);
|
||||
Assert.Single(System.IO.File.ReadAllLines(File()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enable_switches_file()
|
||||
{
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
|
||||
log.Write(Entry());
|
||||
|
||||
log.Enable(File("other.jsonl"));
|
||||
log.Write(Entry());
|
||||
|
||||
Assert.Single(System.IO.File.ReadAllLines(File()));
|
||||
Assert.Single(System.IO.File.ReadAllLines(File("other.jsonl")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enable_without_any_path_explains_itself()
|
||||
{
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: false, Path: null));
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => log.Enable());
|
||||
|
||||
Assert.Contains(OperationLogSettings.PathVariable, ex.Message);
|
||||
Assert.False(log.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bad_path_disables_log_instead_of_throwing()
|
||||
{
|
||||
// Каталог на месте файла: запись невозможна, но конструктор обязан отдать выключенный журнал.
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: Path.Combine(_directory, "x") + Path.DirectorySeparatorChar));
|
||||
|
||||
Assert.False(log.Enabled);
|
||||
Assert.NotNull(log.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_failure_is_swallowed_and_remembered()
|
||||
{
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
|
||||
System.IO.File.Delete(File());
|
||||
Directory.CreateDirectory(File()); // файл занят каталогом — AppendAllText упадёт
|
||||
|
||||
log.Write(Entry());
|
||||
|
||||
Assert.NotNull(log.LastError);
|
||||
Assert.Equal(0, log.WrittenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_mentions_state()
|
||||
{
|
||||
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
|
||||
|
||||
Assert.Contains(File(), log.Describe());
|
||||
|
||||
log.Disable();
|
||||
Assert.Contains("выключен", log.Describe());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Сводка по журналу операций MCP-сервера (JSONL): чем пользуются, что падает, что тормозит.
|
||||
|
||||
.DESCRIPTION
|
||||
Журнал накапливает вызовы инструментов (см. set_operation_log и KOMPAS_MCP_OPLOG_PATH). Этот
|
||||
отчёт — то, ради чего он ведётся: по нему видно, какие инструменты каталога реально зовут,
|
||||
какие стабильно отказывают (кандидаты на доработку) и какие не зовут никогда (кандидаты на
|
||||
удаление). Инструменты, ни разу не встретившиеся в журнале, перечисляются отдельно — их список
|
||||
берётся из README (каталог инструментов там источник истины).
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -NoProfile -File tools/dev/oplog-report.ps1
|
||||
.EXAMPLE
|
||||
pwsh -NoProfile -File tools/dev/oplog-report.ps1 -Path D:\logs\kompas-operations.jsonl -Errors 5
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Файл журнала или каталог с ним. По умолчанию — KOMPAS_MCP_OPLOG_PATH.
|
||||
[string] $Path = $env:KOMPAS_MCP_OPLOG_PATH,
|
||||
|
||||
# Сколько текстов ошибок показать в разделе «частые отказы».
|
||||
[int] $Errors = 10
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||
throw "Путь к журналу не задан: укажите -Path или переменную окружения KOMPAS_MCP_OPLOG_PATH."
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $Path -PathType Container) {
|
||||
$Path = Join-Path $Path 'kompas-operations.jsonl'
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
throw "Журнал не найден: $Path"
|
||||
}
|
||||
|
||||
# Битая строка (сервер убит посреди записи) не должна ронять отчёт.
|
||||
$broken = 0
|
||||
$records = foreach ($line in (Get-Content -LiteralPath $Path -Encoding utf8)) {
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
try { $line | ConvertFrom-Json } catch { $broken++ }
|
||||
}
|
||||
|
||||
$records = @($records)
|
||||
if ($records.Count -eq 0) {
|
||||
Write-Host "Журнал пуст: $Path"
|
||||
return
|
||||
}
|
||||
|
||||
$sessions = @($records | Where-Object { $_.PSObject.Properties.Name -contains 'session' } |
|
||||
Select-Object -ExpandProperty session -Unique)
|
||||
$failed = @($records | Where-Object { -not $_.ok })
|
||||
|
||||
Write-Host "Журнал: $Path"
|
||||
Write-Host ("Вызовов: {0} отказов: {1} ({2:P1}) период: {3} … {4}" -f
|
||||
$records.Count, $failed.Count, ($failed.Count / $records.Count),
|
||||
$records[0].ts, $records[-1].ts)
|
||||
if ($sessions.Count -gt 0) { Write-Host "Сессий: $($sessions.Count)" }
|
||||
if ($broken -gt 0) { Write-Host "Нечитаемых строк: $broken" -ForegroundColor DarkYellow }
|
||||
|
||||
Write-Host "`n=== Инструменты ==="
|
||||
$byTool = $records | Group-Object tool | ForEach-Object {
|
||||
$calls = @($_.Group)
|
||||
$bad = @($calls | Where-Object { -not $_.ok })
|
||||
$ms = @($calls | ForEach-Object { [double] $_.ms } | Sort-Object)
|
||||
[pscustomobject]@{
|
||||
Инструмент = $_.Name
|
||||
Вызовов = $calls.Count
|
||||
Отказов = $bad.Count
|
||||
'Отказы %' = [int] [math]::Round(100 * $bad.Count / $calls.Count)
|
||||
'мс med' = [int] $ms[[math]::Floor($ms.Count / 2)]
|
||||
'мс max' = [int] ($ms | Select-Object -Last 1)
|
||||
}
|
||||
}
|
||||
$byTool | Sort-Object Вызовов -Descending | Format-Table -AutoSize
|
||||
|
||||
$suspects = @($byTool | Where-Object { $_.Отказов -gt 0 } | Sort-Object 'Отказы %' -Descending)
|
||||
if ($suspects.Count -gt 0) {
|
||||
Write-Host "=== Кандидаты на доработку (есть отказы) ==="
|
||||
$suspects | Format-Table -AutoSize
|
||||
}
|
||||
|
||||
if ($failed.Count -gt 0) {
|
||||
Write-Host "=== Частые отказы ==="
|
||||
$failed |
|
||||
Group-Object { "$($_.tool): " + ($_.error -replace '\s+', ' ') } |
|
||||
Sort-Object Count -Descending |
|
||||
Select-Object -First $Errors |
|
||||
ForEach-Object {
|
||||
$text = $_.Name
|
||||
if ($text.Length -gt 160) { $text = $text.Substring(0, 160) + '…' }
|
||||
Write-Host ("{0,4}x {1}" -f $_.Count, $text)
|
||||
}
|
||||
}
|
||||
|
||||
# Не звали ни разу. Каталог берётся из исходников (атрибуты McpServerTool), а не из README:
|
||||
# имя инструмента там объявлено буквально, без риска поймать в шаблон параметр или слово из текста.
|
||||
$toolsDir = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'src\Kompas.Mcp.Host\Tools'
|
||||
if (Test-Path -LiteralPath $toolsDir) {
|
||||
$catalog = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach ($file in (Get-ChildItem -LiteralPath $toolsDir -Filter '*.cs')) {
|
||||
$text = Get-Content -LiteralPath $file.FullName -Raw -Encoding utf8
|
||||
foreach ($m in [regex]::Matches($text, 'McpServerTool\(Name\s*=\s*"([^"]+)"')) {
|
||||
[void] $catalog.Add($m.Groups[1].Value)
|
||||
}
|
||||
}
|
||||
|
||||
$known = $catalog.Count
|
||||
foreach ($tool in ($records | Select-Object -ExpandProperty tool -Unique)) { [void] $catalog.Remove($tool) }
|
||||
|
||||
Write-Host "`n=== Ни разу не встречались в журнале: $($catalog.Count) из $known ==="
|
||||
if ($catalog.Count -gt 0) { Write-Host (($catalog | Sort-Object) -join ', ') }
|
||||
}
|
||||
Reference in New Issue
Block a user