feat: add standards workbench and portable rules profiles

This commit is contained in:
codex
2026-07-30 13:18:59 +03:00
parent 958bad6823
commit 86fb8c89d2
5 changed files with 165 additions and 0 deletions
@@ -0,0 +1,22 @@
using System.Text.Json;
using StandardsWorkbench.Core;
using StandardsWorkbench.Ollama;
using StandardsWorkbench.Pdf;
namespace StandardsWorkbench.App;
public sealed class MainForm : Form
{
private readonly TextBox _pdf = new() { Width = 560 }; private readonly TextBox _code = new() { Width = 240, Text = "ГОСТ Р 21.101-2026" }; private readonly TextBox _title = new() { Width = 560, Text = "Нормативный документ" }; private readonly TextBox _log = new() { Multiline = true, Dock = DockStyle.Fill, ScrollBars = ScrollBars.Vertical }; private readonly ListBox _candidates = new() { Dock = DockStyle.Fill }; private readonly Label _status = new() { AutoSize = true, Text = "Готово" }; private readonly IStandardPdfService _service = new PdfStandardService(); private StandardMetadata? _metadata; private List<CandidateRule> _rules = [];
public MainForm()
{
Text = "Standards Workbench"; Width = 900; Height = 620; var top = new TableLayoutPanel { Dock = DockStyle.Top, Height = 150, ColumnCount = 3, RowCount = 4, Padding = new Padding(8) }; top.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 120)); top.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); top.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 110)); Add(top, 0, "Стандарт PDF", _pdf, Browse); Add(top, 1, "Код", _code, null); Add(top, 2, "Название", _title, null); var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill }; buttons.Controls.Add(Button("Анализировать PDF", async (_, _) => await ImportAsync())); buttons.Controls.Add(Button("Утвердить выбранное", (_, _) => SetSelectedStatus("Approved"))); buttons.Controls.Add(Button("Отклонить выбранное", (_, _) => SetSelectedStatus("Rejected"))); buttons.Controls.Add(Button("Экспортировать профиль", (_, _) => ExportProfile())); top.Controls.Add(buttons, 1, 3); top.SetColumnSpan(buttons, 2); var tabs = new TabControl { Dock = DockStyle.Fill }; foreach (var t in new[] { "Документ", "Страницы", "Пункты", "Кандидаты правил", "Профили", "Сравнение", "Журнал" }) { var p = new TabPage(t); if (t == "Журнал") p.Controls.Add(_log); if (t == "Кандидаты правил") p.Controls.Add(_candidates); tabs.TabPages.Add(p); } Controls.Add(tabs); Controls.Add(top); Controls.Add(_status); _status.Dock = DockStyle.Bottom;
}
private static void Add(TableLayoutPanel p, int row, string label, TextBox box, EventHandler? browse) { p.Controls.Add(new Label { Text = label, AutoSize = true }, 0, row); p.Controls.Add(box, 1, row); if (browse is not null) { var b = new Button { Text = "Обзор..." }; b.Click += browse; p.Controls.Add(b, 2, row); } }
private static Button Button(string text, EventHandler e) { var b = new Button { Text = text, AutoSize = true }; b.Click += e; return b; }
private void Browse(object? s, EventArgs e) { using var d = new OpenFileDialog { Filter = "PDF|*.pdf" }; if (d.ShowDialog() == DialogResult.OK) _pdf.Text = d.FileName; }
private async Task ImportAsync() { try { _status.Text = "Анализ PDF..."; var result = await _service.ImportAsync(_pdf.Text, _code.Text, _title.Text, CancellationToken.None); _metadata = result.Metadata; _rules = result.Clauses.Select(c => new CandidateRule { CandidateId = Guid.NewGuid().ToString("N"), StandardId = c.StandardId, ClauseId = c.ClauseId, SourcePage = c.StartPage, SourceText = c.Text, Title = c.Heading.Length > 0 ? c.Heading : $"Кандидат по пункту {c.ClauseId}", RequiresHumanApproval = true }).ToList(); RefreshCandidates(); _log.AppendText(JsonSerializer.Serialize(new { result.Metadata, result.Pages, result.Clauses }, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine); _status.Text = $"Готово: {result.Pages.Count} страниц, {result.Clauses.Count} пунктов"; } catch (Exception ex) { _log.AppendText(ex + Environment.NewLine); _status.Text = "Ошибка"; MessageBox.Show(ex.Message); } }
private void RefreshCandidates() { _candidates.Items.Clear(); foreach (var rule in _rules) _candidates.Items.Add($"[{rule.Status}] {rule.ClauseId} стр.{rule.SourcePage}: {rule.Title}"); }
private void SetSelectedStatus(string status) { if (_candidates.SelectedIndex < 0 || _candidates.SelectedIndex >= _rules.Count) return; _rules[_candidates.SelectedIndex] = _rules[_candidates.SelectedIndex] with { Status = status }; RefreshCandidates(); }
private void ExportProfile() { if (_metadata is null) { MessageBox.Show("Сначала импортируйте PDF."); return; } using var dialog = new FolderBrowserDialog(); if (dialog.ShowDialog() != DialogResult.OK) return; var profile = new ApprovedRulesProfile { StandardId = _metadata.StandardId, Code = _metadata.Code, Revision = _metadata.Revision, SourceFileSha256 = _metadata.SourceFileSha256, Rules = _rules.Where(r => r.Status == "Approved").ToArray() }; var path = Path.Combine(dialog.SelectedPath, $"{_metadata.StandardId}-docx-profile"); ProfileStore.Export(path, profile); _status.Text = $"Профиль экспортирован: {path}"; }
}
@@ -0,0 +1,16 @@
namespace StandardsWorkbench.App;
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
// To customize application configuration such as set high DPI settings or default font,
// see https://aka.ms/applicationconfiguration.
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}
@@ -0,0 +1,77 @@
using System.Security.Cryptography;
using System.Text.Json;
using DocumentAutomation.Shared;
namespace StandardsWorkbench.Core;
public sealed record StandardMetadata
{
public required string StandardId { get; init; }
public required string Code { get; init; }
public required string Title { get; init; }
public string Revision { get; init; } = "";
public DateOnly? EffectiveFrom { get; init; }
public required string SourceFileSha256 { get; init; }
public int PageCount { get; init; }
public bool TextLayerDetected { get; init; }
public DateTime ImportedAt { get; init; } = DateTime.UtcNow;
public string Status { get; init; } = "Imported";
}
public sealed record PdfPageText(int PageNumber, string Text, bool TextLayerDetected, string Status, IReadOnlyList<string> Blocks);
public sealed record StandardClause(string StandardId, string ClauseId, string Heading, string Text, int StartPage, int EndPage, double ExtractionConfidence);
public sealed record CandidateRule
{
public required string CandidateId { get; init; }
public required string StandardId { get; init; }
public required string ClauseId { get; init; }
public int SourcePage { get; init; }
public required string SourceText { get; init; }
public required string Title { get; init; }
public string[] Scope { get; init; } = ["DOCX"];
public string RuleKind { get; init; } = "ExpertReview";
public string Target { get; init; } = "Document";
public Dictionary<string, string> Parameters { get; init; } = [];
public string Severity { get; init; } = "Warning";
public string AutomationLevel { get; init; } = "Manual";
public double Confidence { get; init; }
public bool RequiresHumanApproval { get; init; } = true;
public string Status { get; init; } = "New";
}
public sealed record ApprovedRulesProfile
{
public required string StandardId { get; init; }
public required string Code { get; init; }
public required string Revision { get; init; }
public required string SourceFileSha256 { get; init; }
public string ProfileVersion { get; init; } = "1.0";
public string MinimumRulesEngineVersion { get; init; } = "1.0";
public DateTime ExportedAt { get; init; } = DateTime.UtcNow;
public IReadOnlyList<CandidateRule> Rules { get; init; } = [];
}
public interface IPdfOcrService { Task<PdfPageText> RecognizePageAsync(string pdfPath, int pageNumber, CancellationToken cancellationToken); }
public interface IStandardPdfService { Task<(StandardMetadata Metadata, IReadOnlyList<PdfPageText> Pages, IReadOnlyList<StandardClause> Clauses)> ImportAsync(string pdfPath, string code, string title, CancellationToken cancellationToken); }
public interface IOllamaService { Task<IReadOnlyList<string>> GetModelsAsync(CancellationToken cancellationToken); Task<CandidateRule?> SuggestCandidateAsync(StandardClause clause, string model, CancellationToken cancellationToken); }
public static class ProfileStore
{
public static string Sha256(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); }
public static void Export(string directory, ApprovedRulesProfile profile)
{
Directory.CreateDirectory(directory); var options = new JsonSerializerOptions { WriteIndented = true }; File.WriteAllText(Path.Combine(directory, "manifest.json"), JsonSerializer.Serialize(profile, options)); File.WriteAllText(Path.Combine(directory, "approved-rules.json"), JsonSerializer.Serialize(profile.Rules, options)); File.WriteAllText(Path.Combine(directory, "references.json"), JsonSerializer.Serialize(profile.Rules.Select(r => new { r.ClauseId, r.SourcePage, r.StandardId }), options)); File.WriteAllText(Path.Combine(directory, "signature.sha256"), Sha256(Path.Combine(directory, "approved-rules.json")));
}
}
public sealed class RulesEngine
{
public IReadOnlyList<RuleEvaluationResult> Evaluate(DocumentInspectionResult document, ApprovedRulesProfile profile)
=> profile.Rules.Where(r => r.Status is "Approved" or "Implemented" or "Tested").Select(r => EvaluateOne(document, r)).ToArray();
private static RuleEvaluationResult EvaluateOne(DocumentInspectionResult d, CandidateRule rule)
{
var status = rule.RuleKind switch { "SectionCount" => d.Sections > 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "ContentControlPresence" => d.ContentControls > 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "BookmarkPresence" => d.Bookmarks > 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "CommentsAbsent" => d.Comments == 0 ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, "TrackedChangesAbsent" => !d.HasTrackedChanges ? RuleEvaluationStatus.Pass : RuleEvaluationStatus.Fail, _ => RuleEvaluationStatus.NeedsReview };
return new RuleEvaluationResult { RuleId = rule.CandidateId, Status = status, Message = status == RuleEvaluationStatus.Pass ? "Проверка пройдена." : status == RuleEvaluationStatus.Fail ? "Проверка не пройдена." : "Требуется экспертная проверка.", ClauseId = rule.ClauseId, SourcePage = rule.SourcePage, DocumentLocation = rule.Target, CanAutoFix = false };
}
}
@@ -0,0 +1,17 @@
using System.Net.Http.Json;
using System.Text.Json;
using StandardsWorkbench.Core;
namespace StandardsWorkbench.Ollama;
public sealed class OllamaClient : IOllamaService
{
private readonly HttpClient _http;
public OllamaClient(HttpClient? httpClient = null) { _http = httpClient ?? new HttpClient(); _http.BaseAddress = new Uri("http://192.168.200.112:11434"); _http.Timeout = TimeSpan.FromSeconds(120); }
public async Task<IReadOnlyList<string>> GetModelsAsync(CancellationToken cancellationToken)
{ using var response = await _http.GetAsync("/api/tags", cancellationToken); response.EnsureSuccessStatusCode(); using var json = await response.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken: cancellationToken); return json?.RootElement.GetProperty("models").EnumerateArray().Select(x => x.GetProperty("name").GetString() ?? "").Where(x => x.Length > 0).ToArray() ?? []; }
public async Task<CandidateRule?> SuggestCandidateAsync(StandardClause clause, string model, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(model)) throw new ArgumentException("Модель Ollama не выбрана.", nameof(model)); var prompt = $"Return JSON only. Extract one candidate DOCX rule from clause {clause.ClauseId}. Never invent source references. Text: {clause.Text}"; using var response = await _http.PostAsJsonAsync("/api/generate", new { model, prompt, stream = false, format = "json" }, cancellationToken); response.EnsureSuccessStatusCode(); using var json = await response.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken: cancellationToken); var raw = json?.RootElement.GetProperty("response").GetString(); if (string.IsNullOrWhiteSpace(raw)) return null; try { return JsonSerializer.Deserialize<CandidateRule>(raw); } catch (JsonException) { return null; }
}
}
@@ -0,0 +1,33 @@
using System.Text.RegularExpressions;
using StandardsWorkbench.Core;
using UglyToad.PdfPig;
namespace StandardsWorkbench.Pdf;
public sealed class PdfStandardService : IStandardPdfService
{
public Task<(StandardMetadata Metadata, IReadOnlyList<PdfPageText> Pages, IReadOnlyList<StandardClause> Clauses)> ImportAsync(string pdfPath, string code, string title, CancellationToken cancellationToken)
{
if (!File.Exists(pdfPath)) throw new FileNotFoundException("PDF ГОСТа не найден.", pdfPath);
return Task.Run(() =>
{
using var pdf = PdfDocument.Open(pdfPath); var pages = new List<PdfPageText>();
foreach (var page in pdf.GetPages()) { cancellationToken.ThrowIfCancellationRequested(); var text = page.Text ?? ""; pages.Add(new PdfPageText(page.Number, text, text.Trim().Length > 0, text.Trim().Length > 0 ? "Extracted" : "OCR_REQUIRED", text.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries))); }
var standardId = Regex.Replace(code.ToUpperInvariant(), "[^A-ZА-Я0-9]+", "-").Trim('-'); var clauses = ExtractClauses(standardId, pages); var meta = new StandardMetadata { StandardId = standardId, Code = code, Title = title, SourceFileSha256 = ProfileStore.Sha256(pdfPath), PageCount = pages.Count, TextLayerDetected = pages.Any(p => p.TextLayerDetected), ImportedAt = DateTime.UtcNow };
return (meta, (IReadOnlyList<PdfPageText>)pages, (IReadOnlyList<StandardClause>)clauses);
}, cancellationToken);
}
private static List<StandardClause> ExtractClauses(string id, IReadOnlyList<PdfPageText> pages)
{
var result = new List<StandardClause>(); string? currentId = null; var currentText = new List<string>(); var start = 1;
void Flush(int end) { if (currentId is not null) result.Add(new StandardClause(id, currentId, "", string.Join(Environment.NewLine, currentText), start, end, 0.85)); }
foreach (var page in pages) foreach (var line in page.Text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var match = Regex.Match(line, "^(\\d+(?:\\.\\d+){0,5})[.)]?\\s+(.+)$"); if (match.Success) { Flush(page.PageNumber - 1); currentId = match.Groups[1].Value; currentText = [line]; start = page.PageNumber; } else if (currentId is not null) currentText.Add(line); }
Flush(pages.Count); return result;
}
}
public sealed class NoOpOcrService : IPdfOcrService
{
public Task<PdfPageText> RecognizePageAsync(string pdfPath, int pageNumber, CancellationToken cancellationToken) => Task.FromResult(new PdfPageText(pageNumber, "", false, "OCR_REQUIRED", []));
}