300 lines
16 KiB
HTML
300 lines
16 KiB
HTML
|
||
<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g,'<br>');
|
||
}
|
||
</script>
|