From 035922ee98a9f2494a092cf2c412c1815231b384 Mon Sep 17 00:00:00 2001 From: Link <131011403+LinkF2kkk@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:27:18 +0300 Subject: [PATCH 1/2] More AI Settings --- Services/OpenAiService.cs | 135 ++++++++- appsettings.json | 2 +- client/src/App.js | 7 + client/src/components/Editor.jsx | 3 +- client/src/components/EditorToolbar/index.jsx | 21 +- client/src/components/HelpModal.jsx | 286 ++++++++++++++++++ client/src/hooks/useAnalysis.js | 44 ++- 7 files changed, 475 insertions(+), 23 deletions(-) create mode 100644 client/src/components/HelpModal.jsx diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index 1942be5..4947e26 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -20,19 +20,92 @@ public OpenAiService(IConfiguration config, ILogger logger) _httpClient.BaseAddress = new Uri("https://api.groq.com/openai/v1/"); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config["OpenAI:ApiKey"]); - _httpClient.Timeout = TimeSpan.FromSeconds(60); + _httpClient.Timeout = TimeSpan.FromSeconds(120); - // Читаем промпт из файла при старте var promptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "SystemPrompt.txt"); _systemPromptTemplate = File.Exists(promptPath) ? File.ReadAllText(promptPath) - : GetFallbackPrompt(); // если файл не найден — используем запасной + : GetFallbackPrompt(); } public async Task AnalyzeTextAsync( string text, string language = "ru", - string? userPrompt = null) // ← опциональный промпт от пользователя + string? userPrompt = null) + { + const int maxChars = 3000; + + if (text.Length <= maxChars) + { + return await AnalyzeChunkAsync(text, language, userPrompt); + } + + // Разбиваем на части и объединяем результаты + var chunks = SplitIntoChunks(text, maxChars); + var allIssues = new List(); + var totalWords = 0; + var totalSentences = 0; + var readability = 0.0; + var water = 0.0; + + _logger.LogInformation("Текст разбит на {Count} частей", chunks.Count); + + foreach (var chunk in chunks) + { + try + { + var chunkResult = await AnalyzeChunkAsync(chunk, language, userPrompt); + var parsed = JsonDocument.Parse(chunkResult); + + if (parsed.RootElement.TryGetProperty("issues", out var issues)) + { + foreach (var issue in issues.EnumerateArray()) + { + allIssues.Add(new + { + category = issue.TryGetProperty("category", out var c) ? c.GetString() : "", + original = issue.TryGetProperty("original", out var o) ? o.GetString() : "", + suggestion = issue.TryGetProperty("suggestion", out var s) ? s.GetString() : "", + description = issue.TryGetProperty("description", out var d) ? d.GetString() : "" + }); + } + } + + if (parsed.RootElement.TryGetProperty("statistics", out var stats)) + { + totalWords += stats.TryGetProperty("wordCount", out var w) ? w.GetInt32() : 0; + totalSentences += stats.TryGetProperty("sentenceCount", out var sc) ? sc.GetInt32() : 0; + readability += stats.TryGetProperty("readabilityScore", out var r) ? r.GetDouble() : 0; + water += stats.TryGetProperty("waterScore", out var ws) ? ws.GetDouble() : 0; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Ошибка при анализе части текста — пропускаем"); + } + } + + var count = Math.Max(chunks.Count, 1); + + var finalResult = new + { + issues = allIssues, + statistics = new + { + wordCount = totalWords, + sentenceCount = totalSentences, + readabilityScore = Math.Round(readability / count, 1), + waterScore = Math.Round(water / count, 1) + } + }; + + return JsonSerializer.Serialize(finalResult); + } + + private async Task AnalyzeChunkAsync( + string text, + string language, + string? userPrompt) { var langInstruction = language switch { @@ -42,11 +115,9 @@ public async Task AnalyzeTextAsync( _ => "Определи язык текста самостоятельно и применяй соответствующие правила." }; - // Подставляем язык в шаблон var systemPrompt = _systemPromptTemplate .Replace("{LANG_INSTRUCTION}", langInstruction); - // Если пользователь задал свои требования — добавляем их if (!string.IsNullOrWhiteSpace(userPrompt)) { systemPrompt += $""" @@ -65,13 +136,18 @@ Эти требования имеют приоритет над общими п { model = _model, temperature = 0.1, + seed = 42, + max_tokens = 4000, messages = new[] { new { role = "system", content = systemPrompt }, - new { - role = "user", + new + { + role = "user", content = $""" - Проанализируй текст. Помни: category должна быть СТРОГО одним словом из списка: Орфография, Пунктуация, Грамматика, Тавтология, Стиль, Канцеляризм. + Проанализируй текст. Верни ТОЛЬКО валидный JSON без размышлений и пояснений. + category — СТРОГО одно слово из списка: Орфография, Пунктуация, Грамматика, Тавтология, Стиль, Канцеляризм. + НИКОГДА не комбинируй категории. ТЕКСТ: {text} @@ -98,10 +174,20 @@ Эти требования имеют приоритет над общими п .GetProperty("content") .GetString(); - return resultText? + // Очищаем markdown + resultText = resultText? .Replace("```json", "") .Replace("```", "") - .Trim() ?? "{}"; + .Trim(); + + // Извлекаем чистый JSON + var jsonStart = resultText?.IndexOf('{') ?? -1; + var jsonEnd = resultText?.LastIndexOf('}') ?? -1; + + if (jsonStart >= 0 && jsonEnd > jsonStart) + resultText = resultText?.Substring(jsonStart, jsonEnd - jsonStart + 1); + + return resultText ?? "{}"; } catch (Exception ex) { @@ -110,7 +196,32 @@ Эти требования имеют приоритет над общими п } } - // Запасной промпт если файл не найден + private static List SplitIntoChunks(string text, int maxChars) + { + var chunks = new List(); + var sentences = text.Split( + new[] { ". ", "! ", "? ", ".\n", "!\n", "?\n" }, + StringSplitOptions.RemoveEmptyEntries + ); + + var current = new StringBuilder(); + + foreach (var sentence in sentences) + { + if (current.Length + sentence.Length > maxChars && current.Length > 0) + { + chunks.Add(current.ToString().Trim()); + current.Clear(); + } + current.Append(sentence).Append(". "); + } + + if (current.Length > 0) + chunks.Add(current.ToString().Trim()); + + return chunks.Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); + } + private static string GetFallbackPrompt() => """ Ты — профессиональный редактор текста. ЯЗЫК: {LANG_INSTRUCTION} diff --git a/appsettings.json b/appsettings.json index 45d80ca..9cbfd74 100644 --- a/appsettings.json +++ b/appsettings.json @@ -10,6 +10,6 @@ }, "OpenAI": { "ApiKey": "", - "Model": "llama-3.3-70b-versatile" + "Model": "openai/gpt-oss-120b" } } diff --git a/client/src/App.js b/client/src/App.js index 26c3a9b..c82f519 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -4,12 +4,14 @@ import Editor from './components/Editor' import SidePanel from './components/SidePanel' import ReportModal from './components/ReportModal' import { useAnalysis } from './hooks/useAnalysis' +import HelpModal from './components/HelpModal' function AppContent() { const { result, loading, error, analyze, exportReport, reset } = useAnalysis() const [showReport, setShowReport] = useState(false) const [editorText, setEditorText] = useState('') const [detectedLang, setDetectedLang] = useState('ru') + const [showHelp, setShowHelp] = useState(false) // Refs для вызова функций редактора из боковой панели const analyzeRef = useRef(null) @@ -46,9 +48,14 @@ function AppContent() { onDetectLang={setDetectedLang} analyzeRef={analyzeRef} clearRef={clearRef} + onHelp={() => setShowHelp(true)} /> + {showHelp && ( + setShowHelp(false)} /> + )} +
{ @@ -96,6 +96,25 @@ export default function EditorToolbar({ editor, wordCount, onImport }) { {wordCount} слов + + {/* Переключатель темы */} +
+ + {/* Контент */} +
+ + {/* Левая навигация */} +
+ {SECTIONS.map((section, i) => ( + + ))} +
+ + {/* Правая часть - контент */} +
+

+ {SECTIONS[active].icon} + {SECTIONS[active].title} +

+ +
+ {SECTIONS[active].items.map((item, i) => ( +
+
+ + {item.title} +
+

+ {item.description} +

+
+ ))} +
+
+
+ + + ) +} \ No newline at end of file diff --git a/client/src/hooks/useAnalysis.js b/client/src/hooks/useAnalysis.js index 1012677..193cd8f 100644 --- a/client/src/hooks/useAnalysis.js +++ b/client/src/hooks/useAnalysis.js @@ -32,17 +32,45 @@ export function useAnalysis() { const response = await fetch(`${API_URL}/analyze`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text, - language, - userPrompt // ← передаём на бэкенд - }) + body: JSON.stringify({ text, language, userPrompt }) }) - if (!response.ok) throw new Error(`Ошибка сервера: ${response.status}`) + if (!response.ok) { + if (response.status === 413) { + setError('Текст слишком длинный — сократите или разбейте на части') + return null + } + throw new Error(`Ошибка сервера: ${response.status}`) + } + + const raw = await response.json() + let data + + try { + data = typeof raw === 'string' ? JSON.parse(raw) : raw + } catch (parseErr) { + // Пробуем починить обрезанный JSON + try { + let broken = typeof raw === 'string' ? raw : JSON.stringify(raw) + + broken = broken + .replace(/,\s*{[^}]*$/, '') + .replace(/,\s*$/, '') + .trim() + + if (!broken.endsWith(']}')) { + if (!broken.endsWith(']')) broken += ']' + if (!broken.endsWith('}')) broken += '}' + } + + data = JSON.parse(broken) + console.warn('JSON был обрезан — восстановлен частично') + } catch { + setError('Текст слишком сложный — попробуй сократить или разбить на части') + return null + } + } - const raw = await response.json() - const data = typeof raw === 'string' ? JSON.parse(raw) : raw setResult(data) return data From 65d1340afdc9ff35ea8ccc07a7dde166c8a3fe07 Mon Sep 17 00:00:00 2001 From: Link <131011403+LinkF2kkk@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:01:11 +0300 Subject: [PATCH 2/2] Optimization tokens --- Endpoints/AnalysisEndpoints.cs | 54 +++++-- Program.cs | 3 +- README.md | 224 +++++++++++++++++++++++++++++- Services/OpenAiService.cs | 135 +++++++----------- Services/Promts/SystemPrompt.txt | 50 ++----- Services/TextStatisticsService.cs | 80 +++++++++++ appsettings.json | 2 +- 7 files changed, 414 insertions(+), 134 deletions(-) create mode 100644 Services/TextStatisticsService.cs diff --git a/Endpoints/AnalysisEndpoints.cs b/Endpoints/AnalysisEndpoints.cs index d96a08b..bf80eea 100644 --- a/Endpoints/AnalysisEndpoints.cs +++ b/Endpoints/AnalysisEndpoints.cs @@ -1,4 +1,5 @@ -using WebEditor.Controllers.Requests; +using System.Text.Json; +using WebEditor.Controllers.Requests; using WebEditor.Services; namespace WebEditor.Endpoints @@ -7,17 +8,54 @@ public static class AnalysisEndpoints { public static void MapAnalysisEndpoints(this WebApplication app) { - app.MapPost("/analyze", async (TextRequest request, OpenAiService openAi) => + app.MapPost("/analyze", async ( + TextRequest request, + OpenAiService openAi, + TextStatisticsService statsService) => { if (string.IsNullOrWhiteSpace(request.Text)) return Results.BadRequest("Текст не может быть пустым"); - var result = await openAi.AnalyzeTextAsync( - request.Text, - request.Language ?? "ru", - request.UserPrompt // ← новое поле - ); - return Results.Ok(result); + try + { + var aiResult = await openAi.AnalyzeTextAsync( + request.Text, + request.Language ?? "ru", + request.UserPrompt + ); + + var stats = statsService.Calculate(request.Text); + var parsed = JsonDocument.Parse(aiResult); + + // Собираем финальный объект через Dictionary + var finalResult = new + { + issues = parsed.RootElement + .GetProperty("issues") + .EnumerateArray() + .Select(i => new + { + category = i.TryGetProperty("category", out var c) ? c.GetString() : "", + original = i.TryGetProperty("original", out var o) ? o.GetString() : "", + suggestion = i.TryGetProperty("suggestion", out var s) ? s.GetString() : "", + description = i.TryGetProperty("description", out var d) ? d.GetString() : "" + }) + .ToList(), + statistics = new + { + wordCount = stats.WordCount, + sentenceCount = stats.SentenceCount, + readabilityScore = stats.ReadabilityScore, + waterScore = stats.WaterScore + } + }; + + return Results.Ok(finalResult); + } + catch (Exception ex) + { + return Results.Problem(ex.Message); + } }); } } diff --git a/Program.cs b/Program.cs index 0a8504a..f3fbc90 100644 --- a/Program.cs +++ b/Program.cs @@ -17,8 +17,9 @@ public static void Main(string[] args) builder.Configuration.GetConnectionString("Default"))); // SERVICES + builder.Services.AddSingleton(); builder.Services.AddSingleton(); - + // CORS builder.Services.AddCors(options => options.AddDefaultPolicy(policy => diff --git a/README.md b/README.md index 3736624..51b2e4d 100644 --- a/README.md +++ b/README.md @@ -1 +1,223 @@ -# WebEditor \ No newline at end of file +# WebEditor — Система анализа и редактирования текста + +Дипломный проект. Информационная система для анализа и редактирования текста с использованием технологий NLP и генеративного ИИ. + +--- + +## Стек технологий + +| Слой | Технология | +|---|---| +| Backend | ASP.NET Core Minimal API (C#) | +| Frontend | React.js + Tiptap | +| База данных | MS SQL Server + Entity Framework Core | +| ИИ | Groq API (llama / gpt-oss) | +| Экспорт | DocumentFormat.OpenXml + QuestPDF | + +--- + +## Быстрый старт после клонирования + +### 1. Клонируй репозиторий + +```bash +git clone https://github.com/твой_юзер/WebEditor.git +cd WebEditor +``` + +--- + +### 2. Создай файлы которых нет в репозитории + +Эти файлы в `.gitignore` — их нужно создать вручную. + +#### `appsettings.Development.json` + +Создай в корне проекта рядом с `appsettings.json`: + +```json +{ + "ConnectionStrings": { + "Default": "Server=ИМЯ_ТВОЕГО_СЕРВЕРА;Database=ServerWebDataBase;Trusted_Connection=True;TrustServerCertificate=True;" + }, + "OpenAI": { + "ApiKey": "gsk_xxxxxxxxxxxxxxxx", + "Model": "llama-3.3-70b-versatile" + } +} +``` + +> Замени `ИМЯ_ТВОЕГО_СЕРВЕРА` на имя своего SQL Server (видно в SSMS вверху Object Explorer, например `Roman` или `DESKTOP-XXXXX\SQLEXPRESS`). +> Замени `gsk_xxxxxxxxxxxxxxxx` на свой API ключ от [console.groq.com](https://console.groq.com). + +#### `client/.env` + +Создай в папке `client/`: + +``` +REACT_APP_API_URL=https://localhost:7076 +``` + +> Порт `7076` должен совпадать с тем что указан в `Properties/launchSettings.json` в профиле `https`. + +--- + +### 3. Настрой базу данных + +Открой **SSMS** и выполни SQL скрипт для создания базы: + +``` +/docs/database.sql ← если есть в репозитории +``` + +Или создай базу вручную — структура таблиц описана в `docs/database_documentation.md`. + +После создания базы выполни Scaffold в **Package Manager Console**: + +``` +Scaffold-DbContext "Server=ИМЯ_СЕРВЕРА;Database=ServerWebDataBase;Trusted_Connection=True;TrustServerCertificate=True;" Microsoft.EntityFrameworkCore.SqlServer -OutputDir Models -Force +``` + +--- + +### 4. Установи зависимости фронтенда + +```bash +cd client +npm install +``` + +--- + +### 5. Запусти проект + +Открой **два терминала**: + +**Терминал 1 — бэкенд:** +```bash +# В корне проекта (папка с .csproj файлом) +dotnet run +``` + +**Терминал 2 — фронтенд:** +```bash +cd client +npm start +``` + +Приложение откроется на `http://localhost:3000` + +--- + +## Структура проекта + +``` +WebEditor/ +├── Endpoints/ +│ ├── AnalysisEndpoints.cs # POST /analyze +│ ├── DocumentEndpoints.cs # CRUD документов +│ ├── ExportEndpoints.cs # POST /export/docx, /export/pdf +│ └── ImportEndpoints.cs # POST /import/docx +├── Models/ +│ ├── Requests/ +│ │ └── TextRequest.cs +│ └── (EF модели генерируются через Scaffold) +├── Prompts/ +│ └── SystemPrompt.txt # Системный промпт для ИИ +├── Properties/ +│ └── launchSettings.json +├── Services/ +│ ├── OpenAiService.cs # Интеграция с Groq API +│ └── TextStatisticsService.cs # Подсчёт статистики текста +├── client/ # React приложение +│ └── src/ +│ ├── components/ +│ │ ├── EditorToolbar/ +│ │ ├── Editor.jsx +│ │ ├── HelpModal.jsx +│ │ ├── IssuePopup.jsx +│ │ ├── ReportModal.jsx +│ │ ├── Results.jsx +│ │ ├── SidePanel.jsx +│ │ └── Toolbar.jsx +│ ├── constants/ +│ │ └── categories.js +│ ├── contexts/ +│ │ └── ThemeContext.js +│ ├── hooks/ +│ │ └── useAnalysis.js +│ └── utils/ +│ └── highlight.js +├── appsettings.json # Публичные настройки (заглушки) +├── appsettings.Development.json # СОЗДАТЬ ВРУЧНУЮ — секреты +├── Program.cs +└── WebEditor.csproj +``` + +--- + +## Файлы которые нужно создать вручную + +| Файл | Где | Зачем | +|---|---|---| +| `appsettings.Development.json` | Корень проекта | API ключ Groq + строка подключения к БД | +| `client/.env` | Папка `client/` | URL бэкенда для React | + +--- + +## Получить API ключ Groq + +1. Зайди на [console.groq.com](https://console.groq.com) +2. Зарегистрируйся (бесплатно) +3. API Keys → Create API Key +4. Скопируй ключ в `appsettings.Development.json` + +**Доступные модели:** + +| Модель | Описание | +|---|---| +| `llama-3.3-70b-versatile` | Стабильная, бесплатная — рекомендуется | +| `openai/gpt-oss-120b` | Мощнее, но больше токенов | +| `qwen/qwen-3-32b` | Хорошо для русского языка | + +--- + +## Настройка промпта + +Системный промпт находится в `Prompts/SystemPrompt.txt` — можно редактировать без перекомпиляции. При запуске сервис читает его автоматически. + +--- + +## Возможные проблемы + +**`react-scripts не является командой`** +```bash +cd client +npm install +``` +Node modules не хранятся в репозитории — нужно установить после клонирования. + +**`API вернул ошибку: Unauthorized`** +Проверь что `appsettings.Development.json` создан и API ключ правильный. Убедись что переменная `ASPNETCORE_ENVIRONMENT = Development` прописана в `launchSettings.json`. + +**`Не удалось подключиться к БД`** +Проверь строку подключения в `appsettings.Development.json`. Имя сервера видно в SSMS в Object Explorer вверху. + +**`TooManyRequests от Groq`** +Бесплатный план имеет лимиты. Подожди минуту или переключись на модель `llama-3.3-70b-versatile`. + +--- + +## Функционал + +- Текстовый редактор с форматированием (шрифты, размеры, заголовки, списки, выравнивание) +- Импорт DOCX файлов +- ИИ анализ текста — орфография, пунктуация, грамматика, тавтология, стиль, канцеляризмы +- Автоопределение языка (ru / en / mixed) +- Подсветка ошибок прямо в тексте по категориям +- Контекстный popup с описанием ошибки при клике +- Пользовательские инструкции для ИИ +- Статистика текста (слова, предложения, читаемость, водность) +- Экспорт отчёта в DOCX и PDF +- Светлая и тёмная тема +- Руководство пользователя (кнопка ? в тулбаре) diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index 4947e26..6fc3659 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -36,70 +36,60 @@ public async Task AnalyzeTextAsync( const int maxChars = 3000; if (text.Length <= maxChars) - { return await AnalyzeChunkAsync(text, language, userPrompt); - } - // Разбиваем на части и объединяем результаты var chunks = SplitIntoChunks(text, maxChars); var allIssues = new List(); - var totalWords = 0; - var totalSentences = 0; - var readability = 0.0; - var water = 0.0; _logger.LogInformation("Текст разбит на {Count} частей", chunks.Count); - foreach (var chunk in chunks) + for (int i = 0; i < chunks.Count; i++) { - try - { - var chunkResult = await AnalyzeChunkAsync(chunk, language, userPrompt); - var parsed = JsonDocument.Parse(chunkResult); + var success = false; + var attempts = 0; - if (parsed.RootElement.TryGetProperty("issues", out var issues)) + while (!success && attempts < 3) + { + try { - foreach (var issue in issues.EnumerateArray()) + attempts++; + var chunkResult = await AnalyzeChunkAsync(chunks[i], language, userPrompt); + var parsed = JsonDocument.Parse(chunkResult); + + if (parsed.RootElement.TryGetProperty("issues", out var issues)) { - allIssues.Add(new + foreach (var issue in issues.EnumerateArray()) { - category = issue.TryGetProperty("category", out var c) ? c.GetString() : "", - original = issue.TryGetProperty("original", out var o) ? o.GetString() : "", - suggestion = issue.TryGetProperty("suggestion", out var s) ? s.GetString() : "", - description = issue.TryGetProperty("description", out var d) ? d.GetString() : "" - }); + allIssues.Add(new + { + category = issue.TryGetProperty("category", out var c) ? c.GetString() : "", + original = issue.TryGetProperty("original", out var o) ? o.GetString() : "", + suggestion = issue.TryGetProperty("suggestion", out var s) ? s.GetString() : "", + description = issue.TryGetProperty("description", out var d) ? d.GetString() : "" + }); + } } - } - if (parsed.RootElement.TryGetProperty("statistics", out var stats)) + success = true; + + // Пауза между чанками + if (i < chunks.Count - 1) + await Task.Delay(1500); + } + catch (Exception ex) when (ex.Message.Contains("TooManyRequests")) { - totalWords += stats.TryGetProperty("wordCount", out var w) ? w.GetInt32() : 0; - totalSentences += stats.TryGetProperty("sentenceCount", out var sc) ? sc.GetInt32() : 0; - readability += stats.TryGetProperty("readabilityScore", out var r) ? r.GetDouble() : 0; - water += stats.TryGetProperty("waterScore", out var ws) ? ws.GetDouble() : 0; + _logger.LogWarning("Rate limit — ждём {Sec} сек", attempts * 3); + await Task.Delay(attempts * 3000); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Ошибка чанка {Index} — пропускаем", i); + success = true; } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Ошибка при анализе части текста — пропускаем"); } } - var count = Math.Max(chunks.Count, 1); - - var finalResult = new - { - issues = allIssues, - statistics = new - { - wordCount = totalWords, - sentenceCount = totalSentences, - readabilityScore = Math.Round(readability / count, 1), - waterScore = Math.Round(water / count, 1) - } - }; - - return JsonSerializer.Serialize(finalResult); + return JsonSerializer.Serialize(new { issues = allIssues }); } private async Task AnalyzeChunkAsync( @@ -109,10 +99,10 @@ private async Task AnalyzeChunkAsync( { var langInstruction = language switch { - "ru" => "Текст на русском языке. Строго следуй правилам русской орфографии, пунктуации и грамматики.", - "en" => "Текст на английском языке. Строго следуй правилам английской орфографии и грамматики.", - "mixed" => "Текст содержит русский и английский языки. Общепринятые англицизмы допустимы. Проверяй каждый язык по его правилам.", - _ => "Определи язык текста самостоятельно и применяй соответствующие правила." + "ru" => "Текст на русском языке. Следуй правилам русской орфографии, пунктуации и грамматики.", + "en" => "Текст на английском языке. Следуй правилам английской орфографии и грамматики.", + "mixed" => "Текст содержит русский и английский. Общепринятые англицизмы допустимы.", + _ => "Определи язык самостоятельно." }; var systemPrompt = _systemPromptTemplate @@ -120,14 +110,7 @@ private async Task AnalyzeChunkAsync( if (!string.IsNullOrWhiteSpace(userPrompt)) { - systemPrompt += $""" - - - ДОПОЛНИТЕЛЬНЫЕ ТРЕБОВАНИЯ ПОЛЬЗОВАТЕЛЯ: - {userPrompt} - - Эти требования имеют приоритет над общими правилами. - """; + systemPrompt += $"\n\nДОПОЛНИТЕЛЬНЫЕ ТРЕБОВАНИЯ:\n{userPrompt}"; } try @@ -137,21 +120,14 @@ Эти требования имеют приоритет над общими п model = _model, temperature = 0.1, seed = 42, - max_tokens = 4000, + max_tokens = 2000, messages = new[] { new { role = "system", content = systemPrompt }, new { role = "user", - content = $""" - Проанализируй текст. Верни ТОЛЬКО валидный JSON без размышлений и пояснений. - category — СТРОГО одно слово из списка: Орфография, Пунктуация, Грамматика, Тавтология, Стиль, Канцеляризм. - НИКОГДА не комбинируй категории. - - ТЕКСТ: - {text} - """ + content = $"Найди ошибки. category — строго одно из: Орфография, Пунктуация, Грамматика, Тавтология, Стиль, Канцеляризм.\n\nТЕКСТ:\n{text}" } } }; @@ -174,20 +150,18 @@ НИКОГДА не комбинируй категории. .GetProperty("content") .GetString(); - // Очищаем markdown resultText = resultText? .Replace("```json", "") .Replace("```", "") .Trim(); - // Извлекаем чистый JSON var jsonStart = resultText?.IndexOf('{') ?? -1; var jsonEnd = resultText?.LastIndexOf('}') ?? -1; if (jsonStart >= 0 && jsonEnd > jsonStart) resultText = resultText?.Substring(jsonStart, jsonEnd - jsonStart + 1); - return resultText ?? "{}"; + return resultText ?? "{\"issues\":[]}"; } catch (Exception ex) { @@ -199,10 +173,10 @@ НИКОГДА не комбинируй категории. private static List SplitIntoChunks(string text, int maxChars) { var chunks = new List(); - var sentences = text.Split( - new[] { ". ", "! ", "? ", ".\n", "!\n", "?\n" }, - StringSplitOptions.RemoveEmptyEntries - ); + var sentences = System.Text.RegularExpressions.Regex + .Split(text.Trim(), @"(?<=[.!?])\s+") + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToArray(); var current = new StringBuilder(); @@ -213,7 +187,7 @@ private static List SplitIntoChunks(string text, int maxChars) chunks.Add(current.ToString().Trim()); current.Clear(); } - current.Append(sentence).Append(". "); + current.Append(sentence).Append(" "); } if (current.Length > 0) @@ -222,14 +196,9 @@ private static List SplitIntoChunks(string text, int maxChars) return chunks.Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); } - private static string GetFallbackPrompt() => """ - Ты — профессиональный редактор текста. - ЯЗЫК: {LANG_INSTRUCTION} - Найди все ошибки и верни ТОЛЬКО валидный JSON: - { - "issues": [{"category": "...", "original": "...", "suggestion": "...", "description": "..."}], - "statistics": {"wordCount": 0, "sentenceCount": 0, "readabilityScore": 0, "waterScore": 0} - } - """; + private static string GetFallbackPrompt() => + "Ты — редактор текста. {LANG_INSTRUCTION}\n" + + "Найди ошибки категорий: Орфография, Пунктуация, Грамматика, Тавтология, Стиль, Канцеляризм.\n" + + "Верни ТОЛЬКО JSON: {\"issues\":[{\"category\":\"...\",\"original\":\"...\",\"suggestion\":\"...\",\"description\":\"...\"}]}"; } } \ No newline at end of file diff --git a/Services/Promts/SystemPrompt.txt b/Services/Promts/SystemPrompt.txt index 4c4bdb1..32ee48a 100644 --- a/Services/Promts/SystemPrompt.txt +++ b/Services/Promts/SystemPrompt.txt @@ -1,43 +1,13 @@ -Ты — профессиональный редактор текста. Анализируй строго и возвращай ТОЛЬКО валидный JSON. +Ты — профессиональный редактор текста. {LANG_INSTRUCTION} -ЯЗЫК: {LANG_INSTRUCTION} +Найди ошибки строго по категориям: Орфография, Пунктуация, Грамматика, Тавтология, Стиль, Канцеляризм. -КАТЕГОРИИ — используй СТРОГО одну из списка, никаких комбинаций: -- Орфография — опечатки, неверное написание слов -- Пунктуация — запятые, точки, тире, знаки препинания -- Грамматика — согласование слов, падежи, глагольные формы -- Тавтология — повтор одного слова или смысла рядом -- Стиль — нарушение сочетаемости, смешение стилей, длинные предложения -- Канцеляризм — бюрократические штампы, отглагольные существительные +Правила: +- Одна проблема = один объект в массиве +- category = строго одно слово из списка выше, никаких комбинаций +- original = точный фрагмент из текста +- suggestion = исправленный вариант +- description = краткое объяснение, 1 предложение -ПРАВИЛА: -1. Каждая проблема — отдельный объект в массиве -2. Если проблема затрагивает и орфографию и пунктуацию — создай ДВА отдельных объекта -3. "category" — ТОЛЬКО одно слово из списка выше. Никаких "Орфография и пунктуация", "Орфография/Пунктуация" и подобного -4. "original" — точный фрагмент из текста, который надо исправить -5. "suggestion" — исправленный вариант -6. "description" — краткое объяснение на русском, 1 предложение - -СТАТИСТИКА: -- wordCount: количество слов -- sentenceCount: количество предложений -- readabilityScore: от 0 до 100, где 100 = очень легко читать -- waterScore: процент воды и штампов от 0 до 100 - -ФОРМАТ — верни ТОЛЬКО это, без markdown, без пояснений: -{ - "issues": [ - { - "category": "Орфография", - "original": "точный фрагмент", - "suggestion": "исправление", - "description": "объяснение" - } - ], - "statistics": { - "wordCount": 0, - "sentenceCount": 0, - "readabilityScore": 0, - "waterScore": 0 - } -} \ No newline at end of file +Верни ТОЛЬКО валидный JSON без markdown и пояснений: +{"issues":[{"category":"...","original":"...","suggestion":"...","description":"..."}]} \ No newline at end of file diff --git a/Services/TextStatisticsService.cs b/Services/TextStatisticsService.cs new file mode 100644 index 0000000..61fed91 --- /dev/null +++ b/Services/TextStatisticsService.cs @@ -0,0 +1,80 @@ +using System.Text.RegularExpressions; + +namespace WebEditor.Services +{ + public class TextStatisticsService + { + private static readonly HashSet StopWords = new(StringComparer.OrdinalIgnoreCase) + { + "это", "также", "при", "для", "все", "если", "как", "так", + "или", "но", "да", "нет", "ну", "вот", "уже", "ещё", "даже", + "просто", "очень", "более", "менее", "именно", "вообще", + "кстати", "например", "однако", "поэтому", + "осуществляется", "реализуется", "является", "представляет собой", + "в настоящее время", "на сегодняшний день", "в данный момент", + "следует отметить", "необходимо указать", "важно подчеркнуть", + "таким образом", "в связи с", "в целях", "в рамках", "с целью" + }; + + public TextStatistics Calculate(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return new TextStatistics(); + + var words = SplitWords(text); + var sentences = SplitSentences(text); + var syllables = words.Sum(CountSyllables); + + var wordCount = words.Count; + var sentenceCount = Math.Max(sentences.Count, 1); + + var avgWordsPerSentence = (double)wordCount / sentenceCount; + var avgSyllablesPerWord = wordCount > 0 + ? (double)syllables / wordCount + : 0; + + var readability = 206.835 + - 1.3 * avgWordsPerSentence + - 60.1 * avgSyllablesPerWord; + + readability = Math.Max(0, Math.Min(100, readability)); + + var stopWordCount = words.Count(w => StopWords.Contains(w.ToLower())); + var waterScore = wordCount > 0 + ? Math.Round((double)stopWordCount / wordCount * 100, 1) + : 0; + + return new TextStatistics + { + WordCount = wordCount, + SentenceCount = sentenceCount, + ReadabilityScore = Math.Round(readability, 1), + WaterScore = waterScore + }; + } + + private static List SplitWords(string text) => + Regex.Matches(text, @"[а-яёА-ЯЁa-zA-Z]+") + .Select(m => m.Value) + .ToList(); + + private static List SplitSentences(string text) => + Regex.Split(text.Trim(), @"(?<=[.!?])\s+") + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + + private static int CountSyllables(string word) + { + const string vowels = "аеёиоуыэюяaeiou"; + return word.ToLower().Count(c => vowels.Contains(c)); + } + } + + public class TextStatistics + { + public int WordCount { get; set; } + public int SentenceCount { get; set; } + public double ReadabilityScore { get; set; } + public double WaterScore { get; set; } + } +} \ No newline at end of file diff --git a/appsettings.json b/appsettings.json index 9cbfd74..3b9f16f 100644 --- a/appsettings.json +++ b/appsettings.json @@ -10,6 +10,6 @@ }, "OpenAI": { "ApiKey": "", - "Model": "openai/gpt-oss-120b" + "Model": "meta-llama/llama-4-scout-17b-16e-instruct" } }