From 86fb8c89d28290316281a6a80643b043f15a8de6 Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 30 Jul 2026 13:18:59 +0300 Subject: [PATCH] feat: add standards workbench and portable rules profiles --- .../src/StandardsWorkbench.App/MainForm.cs | 22 ++++++ .../src/StandardsWorkbench.App/Program.cs | 16 ++++ .../StandardsModels.cs | 77 +++++++++++++++++++ .../OllamaService.cs | 17 ++++ .../PdfStandardService.cs | 33 ++++++++ 5 files changed, 165 insertions(+) create mode 100644 DocumentAutomation/src/StandardsWorkbench.App/MainForm.cs create mode 100644 DocumentAutomation/src/StandardsWorkbench.App/Program.cs create mode 100644 DocumentAutomation/src/StandardsWorkbench.Core/StandardsModels.cs create mode 100644 DocumentAutomation/src/StandardsWorkbench.Ollama/OllamaService.cs create mode 100644 DocumentAutomation/src/StandardsWorkbench.Pdf/PdfStandardService.cs diff --git a/DocumentAutomation/src/StandardsWorkbench.App/MainForm.cs b/DocumentAutomation/src/StandardsWorkbench.App/MainForm.cs new file mode 100644 index 0000000..82d891d --- /dev/null +++ b/DocumentAutomation/src/StandardsWorkbench.App/MainForm.cs @@ -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 _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}"; } +} diff --git a/DocumentAutomation/src/StandardsWorkbench.App/Program.cs b/DocumentAutomation/src/StandardsWorkbench.App/Program.cs new file mode 100644 index 0000000..00d8a6e --- /dev/null +++ b/DocumentAutomation/src/StandardsWorkbench.App/Program.cs @@ -0,0 +1,16 @@ +namespace StandardsWorkbench.App; + +static class Program +{ + /// + /// The main entry point for the application. + /// + [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()); + } +} diff --git a/DocumentAutomation/src/StandardsWorkbench.Core/StandardsModels.cs b/DocumentAutomation/src/StandardsWorkbench.Core/StandardsModels.cs new file mode 100644 index 0000000..e003e11 --- /dev/null +++ b/DocumentAutomation/src/StandardsWorkbench.Core/StandardsModels.cs @@ -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 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 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 Rules { get; init; } = []; +} + +public interface IPdfOcrService { Task RecognizePageAsync(string pdfPath, int pageNumber, CancellationToken cancellationToken); } +public interface IStandardPdfService { Task<(StandardMetadata Metadata, IReadOnlyList Pages, IReadOnlyList Clauses)> ImportAsync(string pdfPath, string code, string title, CancellationToken cancellationToken); } +public interface IOllamaService { Task> GetModelsAsync(CancellationToken cancellationToken); Task 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 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 }; + } +} diff --git a/DocumentAutomation/src/StandardsWorkbench.Ollama/OllamaService.cs b/DocumentAutomation/src/StandardsWorkbench.Ollama/OllamaService.cs new file mode 100644 index 0000000..f9e015c --- /dev/null +++ b/DocumentAutomation/src/StandardsWorkbench.Ollama/OllamaService.cs @@ -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> GetModelsAsync(CancellationToken cancellationToken) + { using var response = await _http.GetAsync("/api/tags", cancellationToken); response.EnsureSuccessStatusCode(); using var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); return json?.RootElement.GetProperty("models").EnumerateArray().Select(x => x.GetProperty("name").GetString() ?? "").Where(x => x.Length > 0).ToArray() ?? []; } + public async Task 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(cancellationToken: cancellationToken); var raw = json?.RootElement.GetProperty("response").GetString(); if (string.IsNullOrWhiteSpace(raw)) return null; try { return JsonSerializer.Deserialize(raw); } catch (JsonException) { return null; } + } +} diff --git a/DocumentAutomation/src/StandardsWorkbench.Pdf/PdfStandardService.cs b/DocumentAutomation/src/StandardsWorkbench.Pdf/PdfStandardService.cs new file mode 100644 index 0000000..a4a7d9d --- /dev/null +++ b/DocumentAutomation/src/StandardsWorkbench.Pdf/PdfStandardService.cs @@ -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 Pages, IReadOnlyList 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(); + 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)pages, (IReadOnlyList)clauses); + }, cancellationToken); + } + + private static List ExtractClauses(string id, IReadOnlyList pages) + { + var result = new List(); string? currentId = null; var currentText = new List(); 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 RecognizePageAsync(string pdfPath, int pageNumber, CancellationToken cancellationToken) => Task.FromResult(new PdfPageText(pageNumber, "", false, "OCR_REQUIRED", [])); +}