This commit is contained in:
shuler7 2026-03-22 23:53:50 +03:00
commit 55b4f4360a
5 changed files with 301 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@

BIN
ege.skill Normal file

Binary file not shown.

300
ege_tester.html Normal file
View file

@ -0,0 +1,300 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-sans); }
.wrap { padding: 1rem 0; }
.section { background: var(--color-background-primary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); padding: 1rem 1.25rem; margin-bottom: 12px; }
.label { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
.row { display: flex; gap: 8px; align-items: center; }
input[type=text], input[type=password] { flex: 1; font-size: 13px; }
.drop-zone { border: 1px dashed var(--color-border-secondary); border-radius: var(--border-radius-md); padding: 2rem; text-align: center; cursor: pointer; color: var(--color-text-secondary); font-size: 13px; transition: background 0.15s; position: relative; }
.drop-zone:hover { background: var(--color-background-secondary); }
.drop-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.preview-grid { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.preview-img { width: 80px; height: 80px; object-fit: cover; border-radius: var(--border-radius-md); border: 0.5px solid var(--color-border-tertiary); }
.tab-row { display: flex; gap: 4px; margin-bottom: 12px; }
.tab { padding: 6px 14px; font-size: 13px; border-radius: var(--border-radius-md); cursor: pointer; border: 0.5px solid var(--color-border-tertiary); background: transparent; color: var(--color-text-secondary); }
.tab.active { background: var(--color-background-secondary); color: var(--color-text-primary); border-color: var(--color-border-secondary); }
.result-box { background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 1rem; font-size: 13px; line-height: 1.7; color: var(--color-text-primary); white-space: pre-wrap; min-height: 80px; max-height: 420px; overflow-y: auto; }
.badge { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: var(--border-radius-md); }
.badge-ok { background: var(--color-background-success); color: var(--color-text-success); }
.badge-err { background: var(--color-background-danger); color: var(--color-text-danger); }
.badge-wait { background: var(--color-background-warning); color: var(--color-text-warning); }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--color-border-secondary); border-top-color: var(--color-text-secondary); border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }
.score-table { width: 100%; font-size: 13px; border-collapse: collapse; }
.score-table td, .score-table th { padding: 4px 8px; border-bottom: 0.5px solid var(--color-border-tertiary); text-align: left; }
.score-table th { font-size: 11px; color: var(--color-text-secondary); font-weight: 400; }
.total-row td { font-weight: 500; border-top: 1px solid var(--color-border-secondary); }
</style>
<div class="wrap">
<div class="section">
<div class="label">Эндпоинт и модель</div>
<div class="row" style="margin-bottom:8px">
<input type="text" id="endpoint" value="https://llm.lambda.coredump.ru/v1" placeholder="https://...">
</div>
<div class="row" style="margin-bottom:8px">
<input type="text" id="model" value="openai/Qwen3.5-122B-A10B" placeholder="имя модели">
</div>
<div class="row">
<input type="password" id="apikey" placeholder="API key">
</div>
</div>
<div class="section">
<div class="label">Режим</div>
<div class="tab-row">
<button class="tab active" onclick="setMode('ocr',this)">OCR рукописи</button>
<button class="tab" onclick="setMode('grade',this)">Проверка сочинения</button>
<button class="tab" onclick="setMode('full',this)">Полный цикл</button>
</div>
<div id="pane-ocr">
<div class="label">Загрузи фото бланка (можно несколько)</div>
<div class="drop-zone" id="drop">
Нажми или перетащи изображения сюда
<input type="file" id="file-input" accept="image/*" multiple onchange="handleFiles(this.files)">
</div>
<div class="preview-grid" id="previews"></div>
</div>
<div id="pane-grade" style="display:none">
<div class="label">Текст сочинения</div>
<textarea id="essay-text" style="width:100%;height:160px;font-size:13px;padding:8px;border-radius:var(--border-radius-md);border:0.5px solid var(--color-border-tertiary);background:var(--color-background-primary);color:var(--color-text-primary);resize:vertical" placeholder="Вставьте текст сочинения..."></textarea>
</div>
<div id="pane-full" style="display:none">
<div class="label">Загрузи фото + автоматически распознаёт и проверит по критериям</div>
<div class="drop-zone">
Нажми или перетащи изображения сюда
<input type="file" id="file-input-full" accept="image/*" multiple onchange="handleFilesFull(this.files)">
</div>
<div class="preview-grid" id="previews-full"></div>
</div>
</div>
<div style="margin-bottom:12px">
<button onclick="run()" style="width:100%;padding:10px;font-size:14px">Запустить ↗</button>
</div>
<div class="section" id="status-section" style="display:none">
<div class="row" style="justify-content:space-between;margin-bottom:8px">
<span style="font-size:13px;font-weight:500">Результат</span>
<span id="status-badge" class="badge badge-wait">ожидание</span>
</div>
<div id="result-content"></div>
</div>
</div>
<script>
let mode = 'ocr';
let images = [];
let imagesFull = [];
function setMode(m, el) {
mode = m;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
el.classList.add('active');
document.getElementById('pane-ocr').style.display = m === 'ocr' ? '' : 'none';
document.getElementById('pane-grade').style.display = m === 'grade' ? '' : 'none';
document.getElementById('pane-full').style.display = m === 'full' ? '' : 'none';
}
function handleFiles(files) {
images = [];
const grid = document.getElementById('previews');
grid.innerHTML = '';
Array.from(files).forEach(f => {
const reader = new FileReader();
reader.onload = e => {
images.push({data: e.target.result.split(',')[1], type: f.type});
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'preview-img';
grid.appendChild(img);
};
reader.readAsDataURL(f);
});
}
function handleFilesFull(files) {
imagesFull = [];
const grid = document.getElementById('previews-full');
grid.innerHTML = '';
Array.from(files).forEach(f => {
const reader = new FileReader();
reader.onload = e => {
imagesFull.push({data: e.target.result.split(',')[1], type: f.type});
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'preview-img';
grid.appendChild(img);
};
reader.readAsDataURL(f);
});
}
function showStatus(text, type) {
const s = document.getElementById('status-section');
s.style.display = '';
const badge = document.getElementById('status-badge');
badge.className = 'badge badge-' + type;
badge.textContent = type === 'wait' ? 'загрузка...' : type === 'ok' ? 'готово' : 'ошибка';
document.getElementById('result-content').innerHTML = text;
}
async function callAPI(messages, maxTokens) {
const endpoint = document.getElementById('endpoint').value.replace(/\/$/, '');
const model = document.getElementById('model').value;
const apikey = document.getElementById('apikey').value;
const headers = {'Content-Type': 'application/json'};
if (apikey) headers['Authorization'] = 'Bearer ' + apikey;
const resp = await fetch(endpoint + '/chat/completions', {
method: 'POST',
headers,
body: JSON.stringify({model, max_tokens: maxTokens || 2000, messages})
});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + await resp.text());
const data = await resp.json();
return data.choices?.[0]?.message?.content || '';
}
function buildImageContent(imgList, textPrompt) {
const content = [];
imgList.forEach(img => {
content.push({type: 'image_url', image_url: {url: `data:${img.type};base64,${img.data}`}});
});
content.push({type: 'text', text: textPrompt});
return content;
}
const CRITERIA = `К1 (0-1): Формулировка проблемы
К2 (0-6): Комментарий (2 примера + связь)
К3 (0-1): Позиция автора
К4 (0-1): Отношение к позиции автора
К5 (0-2): Смысловая цельность и связность
К6 (0-2): Точность и выразительность речи
К7 (0-3): Орфография (3=0ош, 2=1ош, 1=2-3ош, 0=4+)
К8 (0-3): Пунктуация (3=0ош, 2=1-2ош, 1=3-4ош, 0=5+)
К9 (0-2): Языковые нормы (грамматика)
К10 (0-2): Речевые нормы
К11 (0-1): Этические нормы
К12 (0-1): Фактическая точность
Итого: 25 баллов
Правила: если К1=0 → К2,К3,К4 автоматически=0. К6 не может быть выше К10.`;
async function run() {
showStatus('<span class="spinner"></span>Отправляю запрос...', 'wait');
try {
if (mode === 'ocr') {
if (images.length === 0) throw new Error('Загрузи хотя бы одно изображение');
const content = buildImageContent(images,
'Ты эксперт по распознаванию рукописного текста. Внимательно прочитай рукописное сочинение ЕГЭ на изображении и выведи его текст максимально точно. Сохраняй абзацное деление. Зачёркнутые слова отмечай в скобках [зачёркнуто]. Не добавляй никаких комментариев — только текст.');
const text = await callAPI([{role:'user', content}], 2000);
showStatus(`<div class="result-box">${escHtml(text)}</div>`, 'ok');
} else if (mode === 'grade') {
const essay = document.getElementById('essay-text').value.trim();
if (!essay) throw new Error('Вставьте текст сочинения');
const prompt = `Ты эксперт-проверяющий ЕГЭ по русскому языку. Проверь сочинение по критериям ФИПИ и выставь баллы.
КРИТЕРИИ:
${CRITERIA}
СОЧИНЕНИЕ:
${essay}
Ответь строго в формате JSON без markdown-обёрток:
{"k1":0,"k2":0,"k3":0,"k4":0,"k5":0,"k6":0,"k7":0,"k8":0,"k9":0,"k10":0,"k11":0,"k12":0,"total":0,"comments":{"k1":"...","k2":"...","k3":"...","k4":"...","k5":"...","k6":"...","k7":"...","k8":"...","k9":"...","k10":"...","k11":"...","k12":"..."},"recommendations":["...","..."]}`;
const raw = await callAPI([{role:'user',content:prompt}], 2000);
renderGrading(raw);
} else if (mode === 'full') {
if (imagesFull.length === 0) throw new Error('Загрузи хотя бы одно изображение');
showStatus('<span class="spinner"></span>Шаг 1/2: распознаю рукопись...', 'wait');
const content = buildImageContent(imagesFull,
'Ты эксперт по распознаванию рукописного текста. Внимательно прочитай рукописное сочинение ЕГЭ на изображении и выведи его текст максимально точно. Сохраняй абзацное деление. Не добавляй никаких комментариев — только текст сочинения.');
const essayText = await callAPI([{role:'user', content}], 2000);
showStatus('<span class="spinner"></span>Шаг 2/2: проверяю по критериям...', 'wait');
const prompt = `Ты эксперт-проверяющий ЕГЭ по русскому языку. Проверь сочинение по критериям ФИПИ и выставь баллы.
КРИТЕРИИ:
${CRITERIA}
СОЧИНЕНИЕ:
${essayText}
Ответь строго в формате JSON без markdown-обёрток:
{"recognized_text":"...","k1":0,"k2":0,"k3":0,"k4":0,"k5":0,"k6":0,"k7":0,"k8":0,"k9":0,"k10":0,"k11":0,"k12":0,"total":0,"comments":{"k1":"...","k2":"...","k3":"...","k4":"...","k5":"...","k6":"...","k7":"...","k8":"...","k9":"...","k10":"...","k11":"...","k12":"..."},"recommendations":["...","..."]}`;
const raw = await callAPI([{role:'user',content:prompt}], 3000);
renderGrading(raw, essayText);
}
} catch(e) {
showStatus(`<div style="font-size:13px;color:var(--color-text-danger)">${escHtml(e.message)}</div>`, 'err');
}
}
function renderGrading(raw, ocrText) {
let data;
try {
const clean = raw.replace(/```json|```/g, '').trim();
data = JSON.parse(clean);
} catch(e) {
showStatus(`<div class="result-box">${escHtml(raw)}</div>`, 'ok');
return;
}
const keys = ['k1','k2','k3','k4','k5','k6','k7','k8','k9','k10','k11','k12'];
const names = {k1:'Формулировка проблемы',k2:'Комментарий',k3:'Позиция автора',k4:'Отношение к позиции',k5:'Связность',k6:'Точность речи',k7:'Орфография',k8:'Пунктуация',k9:'Языковые нормы',k10:'Речевые нормы',k11:'Этика',k12:'Фактика'};
const maxes = {k1:1,k2:6,k3:1,k4:1,k5:2,k6:2,k7:3,k8:3,k9:2,k10:2,k11:1,k12:1};
let rows = keys.map(k => {
const score = data[k] ?? '?';
const max = maxes[k];
const pct = typeof score === 'number' ? score/max : 0;
const color = pct === 1 ? 'var(--color-text-success)' : pct >= 0.5 ? 'var(--color-text-warning)' : 'var(--color-text-danger)';
return `<tr><td style="color:var(--color-text-secondary)">${k.toUpperCase()}</td><td>${names[k]}</td><td style="text-align:right;font-weight:500;color:${color}">${score}/${max}</td></tr>`;
}).join('');
const total = data.total ?? keys.reduce((s,k) => s + (data[k]||0), 0);
const totalColor = total >= 20 ? 'var(--color-text-success)' : total >= 13 ? 'var(--color-text-warning)' : 'var(--color-text-danger)';
let comments = '';
if (data.comments) {
comments = keys.map(k => data.comments[k] ? `<div style="margin-bottom:10px"><span style="font-size:12px;font-weight:500;color:var(--color-text-secondary)">${k.toUpperCase()} — </span><span style="font-size:13px">${escHtml(data.comments[k])}</span></div>` : '').join('');
}
let recs = '';
if (data.recommendations?.length) {
recs = data.recommendations.map((r,i) => `<div style="font-size:13px;margin-bottom:6px">${i+1}. ${escHtml(r)}</div>`).join('');
}
let ocrBlock = '';
if (ocrText || data.recognized_text) {
const t = data.recognized_text || ocrText;
ocrBlock = `<div style="margin-bottom:12px"><div class="label" style="margin-bottom:4px">Распознанный текст</div><div class="result-box" style="max-height:160px">${escHtml(t)}</div></div>`;
}
showStatus(`
${ocrBlock}
<table class="score-table">
<thead><tr><th>Кр.</th><th>Название</th><th style="text-align:right">Балл</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr class="total-row"><td colspan="2">Итого</td><td style="text-align:right;color:${totalColor}">${total}/25</td></tr></tfoot>
</table>
${comments ? `<div style="margin-top:14px"><div class="label" style="margin-bottom:8px">Комментарии</div>${comments}</div>` : ''}
${recs ? `<div style="margin-top:12px"><div class="label" style="margin-bottom:8px">Рекомендации</div>${recs}</div>` : ''}
`, 'ok');
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>');
}
</script>

BIN
tests/test-1/skan-1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

BIN
tests/test-1/skan-2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB