dmozzherin's picture
add Go web tool
dbd6f57
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
)
const ollamaURL = "http://localhost:11434/api/chat"
const model = "ento-label-parser"
type ollamaRequest struct {
Model string `json:"model"`
Stream bool `json:"stream"`
Messages []ollamaMessage `json:"messages"`
}
type ollamaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ollamaResponse struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}
type labelResult struct {
Verbatim string `json:"verbatim"`
Parsed json.RawMessage `json:"parsed,omitempty"`
Error string `json:"error,omitempty"`
}
func parseLabel(label string) labelResult {
result := labelResult{Verbatim: label}
reqBody, _ := json.Marshal(ollamaRequest{
Model: model,
Stream: false,
Messages: []ollamaMessage{
{Role: "user", Content: label},
},
})
resp, err := http.Post(ollamaURL, "application/json", bytes.NewReader(reqBody))
if err != nil {
result.Error = fmt.Sprintf("failed to call ollama: %v", err)
return result
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
result.Error = fmt.Sprintf("failed to read response: %v", err)
return result
}
var ollamaResp ollamaResponse
if err := json.Unmarshal(body, &ollamaResp); err != nil {
result.Error = fmt.Sprintf("failed to parse ollama response: %v", err)
return result
}
content := strings.TrimSpace(ollamaResp.Message.Content)
var parsed json.RawMessage
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
result.Error = fmt.Sprintf("model returned invalid JSON: %v\nraw content: %s", err, content)
return result
}
result.Parsed = parsed
return result
}
func handleParse(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
raw := r.FormValue("labels")
lines := strings.Split(raw, "\n")
var results []labelResult
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
results = append(results, parseLabel(line))
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(results)
}
const indexHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Label Parser</title>
<style>
body { font-family: sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; background: #f5f5f5; }
h1 { color: #333; }
textarea { width: 100%; height: 180px; font-family: monospace; font-size: 14px; padding: 0.5rem; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; }
button { margin-top: 0.75rem; padding: 0.5rem 1.5rem; font-size: 15px; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #1d4ed8; }
button:disabled { background: #93c5fd; cursor: default; }
#progress { margin-top: 0.75rem; font-size: 13px; color: #555; min-height: 1.2em; }
#meta { margin-top: 0.75rem; display: none; background: #fff; border: 1px solid #ddd; border-radius: 6px; padding: 0.6rem 1rem; font-size: 13px; }
#meta table { border-collapse: collapse; }
#meta td { padding: 2px 1.2rem 2px 0; }
#meta td:first-child { color: #888; }
#meta td.ok { color: #16a34a; font-weight: bold; }
#meta td.fail { color: #dc2626; font-weight: bold; }
#output { margin-top: 1rem; background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 6px; font-family: monospace; font-size: 13px; white-space: pre-wrap; word-break: break-all; min-height: 3rem; }
label { font-weight: bold; display: block; margin-bottom: 0.4rem; }
p.hint { color: #666; font-size: 13px; margin-top: 0.25rem; }
</style>
</head>
<body>
<h1>Entomology Label Parser</h1>
<form id="form">
<label for="labels">Labels (one per line):</label>
<textarea id="labels" name="labels" placeholder="Kazakhstan, Akmola Region: Kokshetau Mountains near Terisakkan River, 23.VI-12.VIII.1957, Emeljanov"></textarea>
<p class="hint">Each non-empty line is sent separately to the model.</p>
<button type="submit" id="btn">Parse</button>
</form>
<div id="progress"></div>
<div id="meta"></div>
<div id="output">Results will appear here.</div>
<script>
function fmt(ms) {
if (ms < 1000) return ms.toFixed(0) + ' ms';
return (ms / 1000).toFixed(2) + ' s';
}
function avg(arr) {
return arr.length ? arr.reduce((a,b) => a+b, 0) / arr.length : null;
}
function renderMeta(total, successes, failures, okTimes, failTimes, totalMs, done) {
const meta = document.getElementById('meta');
meta.style.display = 'block';
const avgOk = avg(okTimes);
const avgFail = avg(failTimes);
meta.innerHTML =
'<table>' +
'<tr><td>labels</td><td>' + total + '</td></tr>' +
'<tr><td>successes</td><td class="ok">' + successes + '</td></tr>' +
'<tr><td>failures</td><td class="' + (failures ? 'fail' : 'ok') + '">' + failures + '</td></tr>' +
(avgOk !== null ? '<tr><td>avg time (success)</td><td>' + fmt(avgOk) + '</td></tr>' : '') +
(avgFail !== null ? '<tr><td>avg time (failure)</td><td>' + fmt(avgFail) + '</td></tr>' : '') +
(done ? '<tr><td>total time</td><td>' + fmt(totalMs) + '</td></tr>' : '') +
'</table>';
}
document.getElementById('form').addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('btn');
const out = document.getElementById('output');
const prog = document.getElementById('progress');
const meta = document.getElementById('meta');
btn.disabled = true;
out.textContent = '';
prog.textContent = '';
meta.style.display = 'none';
const lines = document.getElementById('labels').value
.split('\n').map(l => l.trim()).filter(l => l !== '');
const total = lines.length;
if (total === 0) {
prog.textContent = 'No labels entered.';
btn.disabled = false;
return;
}
const results = [];
const okTimes = [], failTimes = [];
let successes = 0, failures = 0;
const globalStart = performance.now();
try {
for (let i = 0; i < total; i++) {
prog.textContent = 'Parsing ' + (i + 1) + ' of ' + total + '\u2026';
const fd = new URLSearchParams();
fd.append('labels', lines[i]);
const t0 = performance.now();
const resp = await fetch('/parse', { method: 'POST', body: fd });
const json = await resp.json();
const elapsed = performance.now() - t0;
for (const r of json) {
results.push(r);
if (r.error) { failures++; failTimes.push(elapsed); }
else { successes++; okTimes.push(elapsed); }
}
out.textContent = JSON.stringify(results, null, 2);
renderMeta(total, successes, failures, okTimes, failTimes, performance.now() - globalStart, false);
}
prog.textContent = 'Done \u2014 ' + total + ' label' + (total !== 1 ? 's' : '') + ' parsed.';
renderMeta(total, successes, failures, okTimes, failTimes, performance.now() - globalStart, true);
} catch (err) {
prog.textContent = 'Error: ' + err;
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>`
func handleIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, indexHTML)
}
func main() {
http.HandleFunc("/", handleIndex)
http.HandleFunc("/parse", handleParse)
log.Println("listening on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}