| 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)) |
| } |
|
|