constantinSch commited on
Commit
2cdd0e3
·
1 Parent(s): e25dfc1

Add annotation app

Browse files
Files changed (7) hide show
  1. .python-version +1 -0
  2. Dockerfile +27 -0
  3. app.py +252 -0
  4. evaluation_dataset.jsonl +0 -0
  5. index.html +613 -0
  6. pyproject.toml +9 -0
  7. uv.lock +161 -0
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Based on https://github.com/astral-sh/uv-docker-example/blob/main/README.md
2
+ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
3
+
4
+ RUN useradd -m -u 1000 user
5
+
6
+ WORKDIR /app
7
+
8
+ ENV PATH="/app/.venv/bin:$PATH"
9
+ ENV DATASET_PATH="/app/evaluation_dataset.jsonl"
10
+ ENV DB_PATH="/data/annotations.db"
11
+
12
+ # Copy dependency files first (better layer caching)
13
+ COPY --chown=user pyproject.toml .python-version uv.lock ./
14
+ RUN uv sync --locked
15
+
16
+ COPY --chown=user app.py .
17
+ COPY --chown=user index.html .
18
+ COPY --chown=user evaluation_dataset.jsonl .
19
+
20
+ # Persistent storage for annotations (enable in HF Space settings)
21
+ RUN mkdir -p /data && chown user:user /data
22
+
23
+ USER user
24
+
25
+ EXPOSE 7860
26
+
27
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flask annotation app for blind summary evaluation.
2
+
3
+ Serves a web UI where annotators evaluate AI-generated summaries.
4
+ No accounts required -- annotators identify themselves by name.
5
+ Annotations are persisted in a SQLite database.
6
+
7
+ Optional password protection: set APP_PASSWORD as an environment variable.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import sqlite3
13
+ from functools import cache
14
+ from pathlib import Path
15
+
16
+ from flask import Flask, Response, jsonify, redirect, request, send_file, session
17
+
18
+ DATASET_PATH = Path(os.environ.get("DATASET_PATH", "evaluation_dataset.jsonl"))
19
+ DB_PATH = Path(os.environ.get("DB_PATH", "annotations.db"))
20
+
21
+ ANNOTATION_FIELDS = (
22
+ "bewertung", "korrekt", "relevant", "vollstaendig", "kohaerenz", "anmerkungen",
23
+ )
24
+
25
+ APP_PASSWORD = os.environ.get("APP_PASSWORD", "")
26
+
27
+ app = Flask(__name__)
28
+ app.secret_key = os.environ.get("SECRET_KEY", os.urandom(24).hex())
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Database
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def get_db() -> sqlite3.Connection:
36
+ """Open a SQLite connection with row factory and WAL mode."""
37
+ db = sqlite3.connect(str(DB_PATH))
38
+ db.row_factory = sqlite3.Row
39
+ db.execute("PRAGMA journal_mode=WAL")
40
+ db.execute("""
41
+ CREATE TABLE IF NOT EXISTS annotations (
42
+ annotator TEXT NOT NULL,
43
+ eval_id TEXT NOT NULL,
44
+ bewertung TEXT,
45
+ korrekt TEXT,
46
+ relevant TEXT,
47
+ vollstaendig TEXT,
48
+ kohaerenz TEXT,
49
+ anmerkungen TEXT,
50
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
51
+ PRIMARY KEY (annotator, eval_id)
52
+ )
53
+ """)
54
+ return db
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Dataset (immutable, cached)
59
+ # ---------------------------------------------------------------------------
60
+
61
+ @cache
62
+ def load_dataset() -> tuple[dict, ...]:
63
+ """Load evaluation items from JSONL. Cached because the dataset never changes."""
64
+ items = []
65
+ with open(DATASET_PATH, encoding="utf-8") as f:
66
+ for line in f:
67
+ if line.strip():
68
+ row = json.loads(line)
69
+ has_prior = bool(row.get("bewertung"))
70
+ row["has_prior_judgement"] = has_prior
71
+ if has_prior:
72
+ for field in ANNOTATION_FIELDS:
73
+ row[f"prior_{field}"] = row.get(field)
74
+ items.append(row)
75
+ items.sort(key=lambda x: x["eval_id"])
76
+ return tuple(items)
77
+
78
+
79
+ def fetch_annotations(db: sqlite3.Connection, annotator: str) -> dict[str, dict]:
80
+ """Fetch all annotations for a given annotator, keyed by eval_id."""
81
+ rows = db.execute(
82
+ "SELECT * FROM annotations WHERE annotator = ?", (annotator,),
83
+ ).fetchall()
84
+ return {row["eval_id"]: dict(row) for row in rows}
85
+
86
+
87
+ def merge_items_with_annotations(
88
+ items: tuple[dict, ...],
89
+ annotations: dict[str, dict],
90
+ ) -> list[dict]:
91
+ """Return items with annotation values merged in (does not mutate originals)."""
92
+ merged = []
93
+ for item in items:
94
+ entry = {**item}
95
+ ann = annotations.get(item["eval_id"])
96
+ if ann:
97
+ for field in ANNOTATION_FIELDS:
98
+ entry[field] = ann.get(field)
99
+ merged.append(entry)
100
+ return merged
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Optional password protection
105
+ # ---------------------------------------------------------------------------
106
+
107
+ LOGIN_HTML = """<!DOCTYPE html>
108
+ <html lang="de"><head><meta charset="UTF-8">
109
+ <meta name="viewport" content="width=device-width,initial-scale=1">
110
+ <title>Anmeldung</title>
111
+ <style>
112
+ body { font-family: system-ui, sans-serif; background: #f5f5f5; }
113
+ .box { max-width: 320px; margin: 120px auto; background: #fff;
114
+ padding: 32px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.1);
115
+ text-align: center; }
116
+ h2 { margin-bottom: 16px; font-size: 20px; }
117
+ input { padding: 10px; width: 100%%; border: 1px solid #ccc; border-radius: 4px;
118
+ font-size: 15px; margin-bottom: 12px; box-sizing: border-box; }
119
+ button { padding: 10px 24px; border: none; border-radius: 4px;
120
+ background: #2563eb; color: #fff; font-size: 15px; cursor: pointer; }
121
+ button:hover { background: #1d4ed8; }
122
+ .err { color: #dc2626; font-size: 13px; margin-bottom: 8px; }
123
+ </style></head><body>
124
+ <div class="box">
125
+ <h2>Zusammenfassungs-Evaluation</h2>
126
+ %(error)s
127
+ <form method="post">
128
+ <input type="password" name="password" placeholder="Passwort" autofocus>
129
+ <button type="submit">Weiter</button>
130
+ </form>
131
+ </div></body></html>"""
132
+
133
+
134
+ @app.before_request
135
+ def check_auth():
136
+ if not APP_PASSWORD:
137
+ return None
138
+ if request.path == "/login":
139
+ return None
140
+ if session.get("authenticated"):
141
+ return None
142
+ if request.path.startswith("/api/"):
143
+ return jsonify({"error": "unauthorized"}), 401
144
+ return redirect("/login")
145
+
146
+
147
+ @app.route("/login", methods=["GET", "POST"])
148
+ def login():
149
+ if request.method == "POST":
150
+ if request.form.get("password") == APP_PASSWORD:
151
+ session["authenticated"] = True
152
+ return redirect("/")
153
+ return LOGIN_HTML % {"error": '<p class="err">Falsches Passwort.</p>'}, 401
154
+ return LOGIN_HTML % {"error": ""}
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Routes
159
+ # ---------------------------------------------------------------------------
160
+
161
+ @app.route("/")
162
+ def index():
163
+ return send_file("index.html")
164
+
165
+
166
+ @app.route("/api/entries")
167
+ def get_entries():
168
+ """Return all evaluation items, optionally with annotations merged for an annotator."""
169
+ annotator = request.args.get("annotator", "").strip()
170
+ items = load_dataset()
171
+ if annotator:
172
+ db = get_db()
173
+ annotations = fetch_annotations(db, annotator)
174
+ db.close()
175
+ return jsonify(merge_items_with_annotations(items, annotations))
176
+ return jsonify(list(items))
177
+
178
+
179
+ @app.route("/api/annotate", methods=["POST"])
180
+ def annotate():
181
+ """Save or update an annotation."""
182
+ data = request.get_json()
183
+ db = get_db()
184
+ db.execute(
185
+ """INSERT OR REPLACE INTO annotations
186
+ (annotator, eval_id, bewertung, korrekt, relevant, vollstaendig, kohaerenz, anmerkungen)
187
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
188
+ (
189
+ data["annotator"].strip(),
190
+ data["eval_id"],
191
+ data.get("bewertung"),
192
+ data.get("korrekt"),
193
+ data.get("relevant"),
194
+ data.get("vollstaendig"),
195
+ data.get("kohaerenz"),
196
+ data.get("anmerkungen"),
197
+ ),
198
+ )
199
+ db.commit()
200
+ db.close()
201
+ return jsonify({"status": "ok"})
202
+
203
+
204
+ @app.route("/api/progress")
205
+ def progress():
206
+ """Return annotation progress for an annotator."""
207
+ annotator = request.args.get("annotator", "").strip()
208
+ total = len(load_dataset())
209
+ if not annotator:
210
+ return jsonify({"total": total, "annotated": 0})
211
+ db = get_db()
212
+ count = db.execute(
213
+ "SELECT COUNT(*) FROM annotations WHERE annotator = ?", (annotator,),
214
+ ).fetchone()[0]
215
+ db.close()
216
+ return jsonify({"total": total, "annotated": count})
217
+
218
+
219
+ @app.route("/api/export")
220
+ def export_annotations():
221
+ """Export all annotations as downloadable JSONL."""
222
+ db = get_db()
223
+ rows = db.execute(
224
+ "SELECT * FROM annotations ORDER BY annotator, eval_id",
225
+ ).fetchall()
226
+ db.close()
227
+ lines = [json.dumps(dict(row), ensure_ascii=False) for row in rows]
228
+ return Response(
229
+ "\n".join(lines) + "\n",
230
+ mimetype="application/jsonl",
231
+ headers={"Content-Disposition": "attachment; filename=annotations.jsonl"},
232
+ )
233
+
234
+
235
+ @app.route("/api/stats")
236
+ def stats():
237
+ """Return overall annotation statistics (for the admin)."""
238
+ total = len(load_dataset())
239
+ db = get_db()
240
+ annotators = db.execute(
241
+ "SELECT annotator, COUNT(*) as count FROM annotations GROUP BY annotator ORDER BY annotator",
242
+ ).fetchall()
243
+ db.close()
244
+ return jsonify({
245
+ "total": total,
246
+ "annotators": [{"name": r["annotator"], "count": r["count"]} for r in annotators],
247
+ })
248
+
249
+
250
+ if __name__ == "__main__":
251
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
252
+ app.run(host="0.0.0.0", port=7860)
evaluation_dataset.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
index.html ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="de">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Zusammenfassungs-Evaluation</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #222; }
10
+
11
+ header {
12
+ background: #1a1a2e; color: #fff; padding: 12px 24px;
13
+ display: flex; align-items: center; gap: 24px; flex-wrap: wrap;
14
+ position: sticky; top: 0; z-index: 10;
15
+ }
16
+ header h1 { font-size: 17px; font-weight: 600; white-space: nowrap; }
17
+ .progress-box { margin-left: auto; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 10px; }
18
+ .progress-bar { width: 120px; height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
19
+ .progress-fill { height: 100%; background: #22c55e; transition: width .3s; }
20
+
21
+ .nav {
22
+ display: flex; align-items: center; gap: 12px;
23
+ padding: 10px 24px; background: #fff; border-bottom: 1px solid #ddd;
24
+ position: sticky; top: 52px; z-index: 9; flex-wrap: wrap;
25
+ }
26
+ .nav button {
27
+ padding: 6px 16px; border: 1px solid #ccc; border-radius: 4px;
28
+ background: #fff; cursor: pointer; font-size: 14px;
29
+ }
30
+ .nav button:hover { background: #eee; }
31
+ .nav button:disabled { opacity: .4; cursor: default; }
32
+ .nav .counter { font-size: 14px; font-weight: 600; min-width: 80px; text-align: center; }
33
+ .filters { display: flex; align-items: center; gap: 8px; margin-left: auto; }
34
+ .filters label { font-size: 12px; font-weight: 600; color: #666; }
35
+ .filters select { padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
36
+
37
+ .card {
38
+ max-width: 960px; margin: 20px auto; background: #fff;
39
+ border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.1); overflow: hidden;
40
+ }
41
+ .card-header {
42
+ padding: 14px 20px; border-bottom: 1px solid #eee;
43
+ display: flex; justify-content: space-between; align-items: center; gap: 12px;
44
+ }
45
+ .card-header h2 { font-size: 15px; font-weight: 600; }
46
+ .card-header .meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
47
+ .card-header .id { font-size: 12px; color: #888; font-family: monospace; }
48
+
49
+ .badge {
50
+ padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600;
51
+ }
52
+ .badge-DRA { background: #ecfdf5; color: #065f46; }
53
+ .badge-DW { background: #eff6ff; color: #1e40af; }
54
+
55
+ .section { padding: 14px 20px; border-bottom: 1px solid #f0f0f0; }
56
+ .section-label {
57
+ font-size: 11px; font-weight: 700; color: #666;
58
+ text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px;
59
+ }
60
+ .section-content { font-size: 14px; line-height: 1.65; white-space: pre-wrap; }
61
+
62
+ .summary-section {
63
+ background: #eff6ff; border-left: 4px solid #2563eb;
64
+ }
65
+ .summary-section .section-content { font-size: 15px; font-weight: 500; }
66
+
67
+ .transcript-section .section-content {
68
+ max-height: 260px; overflow-y: auto; font-size: 13px; color: #444;
69
+ }
70
+
71
+ .original-toggle {
72
+ padding: 8px 20px; background: #f8f8f8; border-bottom: 1px solid #f0f0f0;
73
+ cursor: pointer; display: flex; align-items: center; gap: 8px;
74
+ font-size: 12px; font-weight: 600; color: #666; user-select: none;
75
+ }
76
+ .original-toggle:hover { background: #f0f0f0; }
77
+ .original-toggle .arrow { transition: transform .2s; display: inline-block; }
78
+ .original-toggle .arrow.open { transform: rotate(90deg); }
79
+ .original-transcript {
80
+ display: none; padding: 14px 20px; border-bottom: 1px solid #f0f0f0;
81
+ }
82
+ .original-transcript.open { display: block; }
83
+ .original-transcript .section-content {
84
+ max-height: 260px; overflow-y: auto; font-size: 13px; color: #444;
85
+ white-space: pre-wrap; line-height: 1.65;
86
+ }
87
+
88
+ /* --- Evaluation controls --- */
89
+ .eval-section {
90
+ padding: 20px; border-bottom: 1px solid #f0f0f0;
91
+ border: 2px solid #2563eb; border-radius: 0 0 0 0;
92
+ margin: 0; background: #fafbff;
93
+ }
94
+ .eval-section .section-label { font-size: 13px; margin-bottom: 12px; color: #1e40af; }
95
+
96
+ .rating-row { margin-bottom: 16px; }
97
+ .rating-row label { font-size: 13px; font-weight: 600; color: #444; display: block; margin-bottom: 4px; }
98
+ .rating-row select { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
99
+
100
+ .criteria { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 16px; }
101
+ .criterion { display: flex; flex-direction: column; gap: 4px; min-width: 120px; }
102
+ .criterion label { font-size: 12px; font-weight: 700; color: #555; text-transform: uppercase; }
103
+ .criterion select {
104
+ padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;
105
+ }
106
+ .criterion select.val-ja { border-color: #22c55e; background: #f0fdf4; }
107
+ .criterion select.val-aus { border-color: #f59e0b; background: #fffbeb; }
108
+ .criterion select.val-nein { border-color: #ef4444; background: #fef2f2; }
109
+ .criterion select.val-none { border-color: #ccc; background: #f9fafb; }
110
+
111
+ .criterion-desc {
112
+ font-size: 11px; color: #777; font-weight: 400; line-height: 1.4; margin-bottom: 2px;
113
+ }
114
+
115
+ /* Collapsible guidelines */
116
+ .guidelines-toggle {
117
+ padding: 10px 20px; background: #f0f4ff; border-bottom: 1px solid #d0d8f0;
118
+ cursor: pointer; display: flex; align-items: center; gap: 8px;
119
+ font-size: 13px; font-weight: 600; color: #1e40af; user-select: none;
120
+ }
121
+ .guidelines-toggle:hover { background: #e0e8ff; }
122
+ .guidelines-toggle .arrow { transition: transform .2s; display: inline-block; }
123
+ .guidelines-toggle .arrow.open { transform: rotate(90deg); }
124
+ .guidelines-body {
125
+ display: none; padding: 14px 20px; background: #f8f9ff; border-bottom: 1px solid #d0d8f0;
126
+ font-size: 13px; line-height: 1.7; color: #444;
127
+ }
128
+ .guidelines-body.open { display: block; }
129
+ .guidelines-body h3 { font-size: 13px; font-weight: 700; color: #1e40af; margin: 12px 0 4px 0; }
130
+ .guidelines-body h3:first-child { margin-top: 0; }
131
+ .guidelines-body ul { margin: 4px 0 8px 18px; }
132
+ .guidelines-body li { margin-bottom: 2px; }
133
+ .guidelines-body .scale-label { font-weight: 600; }
134
+
135
+ .comments-row { margin-top: 4px; }
136
+ .comments-row label { font-size: 12px; font-weight: 700; color: #555; text-transform: uppercase; display: block; margin-bottom: 4px; }
137
+ .comments-row textarea {
138
+ width: 100%; padding: 8px 10px; border: 1px solid #ccc; border-radius: 4px;
139
+ font-family: inherit; font-size: 14px; line-height: 1.5; resize: vertical;
140
+ }
141
+ .comments-row textarea:focus { outline: 2px solid #2563eb; border-color: #2563eb; }
142
+
143
+ .actions { padding: 14px 20px; display: flex; justify-content: flex-end; gap: 12px; align-items: center; }
144
+ .save-hint { font-size: 12px; color: #888; margin-right: auto; }
145
+ .btn-save {
146
+ padding: 8px 24px; border: none; border-radius: 4px;
147
+ background: #2563eb; color: #fff; font-size: 14px; cursor: pointer; font-weight: 500;
148
+ }
149
+ .btn-save:hover { background: #1d4ed8; }
150
+ .btn-save:disabled { opacity: .4; cursor: default; }
151
+
152
+ .toast {
153
+ position: fixed; bottom: 24px; right: 24px; padding: 12px 20px;
154
+ border-radius: 6px; color: #fff; font-size: 14px;
155
+ opacity: 0; transition: opacity .3s; pointer-events: none;
156
+ }
157
+ .toast.show { opacity: 1; }
158
+ .toast.success { background: #22c55e; }
159
+ .toast.error { background: #ef4444; }
160
+
161
+ .empty-state {
162
+ text-align: center; padding: 60px 20px; color: #666; font-size: 15px;
163
+ }
164
+
165
+ .badge-promptd {
166
+ background: #fef3c7; color: #92400e; padding: 3px 10px;
167
+ border-radius: 12px; font-size: 12px; font-weight: 600;
168
+ }
169
+ .reference-banner {
170
+ padding: 10px 20px; background: #fef9c3; border-bottom: 1px solid #fde68a;
171
+ font-size: 13px; color: #92400e; font-weight: 500;
172
+ }
173
+ .reference-banner strong { font-weight: 700; }
174
+ .prior-judgement { padding: 14px 20px; border-bottom: 1px solid #f0f0f0; background: #fffbeb; }
175
+ .prior-judgement .section-label { color: #92400e; }
176
+ .prior-judgement .pj-grid {
177
+ display: flex; gap: 16px; flex-wrap: wrap; font-size: 13px; margin-top: 6px;
178
+ }
179
+ .prior-judgement .pj-item { display: flex; flex-direction: column; gap: 2px; }
180
+ .prior-judgement .pj-label { font-size: 11px; font-weight: 700; color: #92400e; text-transform: uppercase; }
181
+ .prior-judgement .pj-value { font-weight: 600; }
182
+ </style>
183
+ </head>
184
+ <body>
185
+
186
+ <header>
187
+ <h1>Zusammenfassungs-Evaluation</h1>
188
+ <div class="progress-box">
189
+ <span id="progress-text">-</span>
190
+ <div class="progress-bar"><div class="progress-fill" id="progress-fill" style="width:0"></div></div>
191
+ </div>
192
+ </header>
193
+
194
+ <div class="nav">
195
+ <button id="btn-prev" onclick="navigate(-1)">Prev</button>
196
+ <span class="counter" id="counter">-</span>
197
+ <button id="btn-next" onclick="navigate(1)">Next</button>
198
+ <div class="filters">
199
+ <label>Bearbeitungsstatus:</label>
200
+ <select id="filter-status" onchange="applyFilters()">
201
+ <option value="">alle</option>
202
+ <option value="pending">offen</option>
203
+ <option value="done">bewertet</option>
204
+ </select>
205
+ <label>LRA:</label>
206
+ <select id="filter-rfa" onchange="applyFilters()">
207
+ <option value="">alle</option>
208
+ <option value="DW">DW</option>
209
+ <option value="DRA">DRA</option>
210
+ </select>
211
+ <label>Bewertungsrunde:</label>
212
+ <select id="filter-type" onchange="applyFilters()">
213
+ <option value="">alle</option>
214
+ <option value="new" selected>Neu (zu bewerten)</option>
215
+ <option value="promptd">Alter Prompt (bereits evaluiert)</option>
216
+ </select>
217
+ </div>
218
+ </div>
219
+
220
+ <div class="card" id="card">
221
+ <div class="card-header">
222
+ <h2 id="title">-</h2>
223
+ <div class="meta">
224
+ <span class="badge" id="badge-rfa"></span>
225
+ <span class="badge-promptd" id="badge-promptd" style="display:none">Alter Prompt</span>
226
+ <span class="id" id="eval-id">-</span>
227
+ </div>
228
+ </div>
229
+
230
+ <div class="reference-banner" id="reference-banner" style="display:none">
231
+ <strong>Alter Prompt:</strong> Dieser Eintrag wurde bereits in einer frueheren Evaluation bewertet. Die bestehende Bewertung wird unten angezeigt.
232
+ </div>
233
+
234
+ <div class="prior-judgement" id="prior-judgement" style="display:none">
235
+ <div class="section-label">Bestehende Bewertung (Alter Prompt)</div>
236
+ <div class="pj-grid" id="pj-grid"></div>
237
+ <div id="pj-anmerkungen" style="margin-top:8px; font-size:13px; color:#444;"></div>
238
+ </div>
239
+
240
+ <div class="section summary-section">
241
+ <div class="section-label">Zusammenfassung</div>
242
+ <div class="section-content" id="summary">-</div>
243
+ </div>
244
+
245
+ <div class="section">
246
+ <div class="section-label">Referenz</div>
247
+ <div class="section-content" id="referenz">-</div>
248
+ </div>
249
+
250
+ <div class="section transcript-section">
251
+ <div class="section-label" id="transcript-label">Transkript</div>
252
+ <div class="section-content" id="transcript">-</div>
253
+ </div>
254
+
255
+ <div class="original-toggle" id="original-toggle" style="display:none" onclick="toggleOriginal()">
256
+ <span class="arrow" id="original-arrow">&#9654;</span>
257
+ Original-Transkript (Fremdsprache) anzeigen
258
+ </div>
259
+ <div class="original-transcript" id="original-transcript">
260
+ <div class="section-label">Original-Transkript</div>
261
+ <div class="section-content" id="original-text">-</div>
262
+ </div>
263
+
264
+ <div class="guidelines-toggle" onclick="toggleGuidelines()">
265
+ <span class="arrow" id="guidelines-arrow">&#9654;</span>
266
+ Bewertungsrichtlinien anzeigen
267
+ </div>
268
+ <div class="guidelines-body" id="guidelines-body">
269
+ <h3>Vorgehensweise</h3>
270
+ <p>Lies das Transkript vollstaendig (oder ueberfliege es gruendlich). Lies dann die Zusammenfassung und waehle fuer jedes Kriterium eine Stufe (ja / ausreichend / nein). Vergib abschliessend eine Gesamtbewertung als Schulnote.</p>
271
+
272
+ <h3>Stufen</h3>
273
+ <ul>
274
+ <li><span class="scale-label">Ja</span> -- Perfekt oder nahezu perfekt, keine nennenswerten Schwaechen</li>
275
+ <li><span class="scale-label">Ausreichend</span> -- Mit Schwaechen, aber noch brauchbar</li>
276
+ <li><span class="scale-label">Nein</span> -- Inakzeptabel, voellig unbrauchbar</li>
277
+ </ul>
278
+
279
+ <h3>1. Korrekt</h3>
280
+ <p>Faktische Korrektheit: Werden Fakten, Namen und Orte korrekt wiedergegeben? Gibt es Halluzinationen oder Informationen, die nicht im Transkript vorkommen?</p>
281
+ <ul>
282
+ <li><span class="scale-label">Ja:</span> Alle Aussagen faktisch korrekt und durch das Transkript gestuetzt. Keine Halluzinationen.</li>
283
+ <li><span class="scale-label">Ausreichend:</span> Einige Ungenauigkeiten, Kernaussagen stimmen groesstenteils.</li>
284
+ <li><span class="scale-label">Nein:</span> Faktische Fehler und Halluzinationen. Ueberwiegend falsche Informationen.</li>
285
+ </ul>
286
+
287
+ <h3>2. Relevant</h3>
288
+ <p>Auswahl der bedeutsamen Inhalte: Enthaelt die Zusammenfassung nur wichtige Informationen? Gibt es unnoetige Wiederholungen?</p>
289
+ <ul>
290
+ <li><span class="scale-label">Ja:</span> Konzentriert sich ausschliesslich auf die wichtigsten Informationen. Keine Redundanzen.</li>
291
+ <li><span class="scale-label">Ausreichend:</span> Enthaelt auch weniger relevante Informationen. Einige unwichtige Details.</li>
292
+ <li><span class="scale-label">Nein:</span> Konzentriert sich auf nebensaechliche Aspekte statt Kernthemen.</li>
293
+ </ul>
294
+
295
+ <h3>3. Vollstaendig</h3>
296
+ <p>Sind alle wesentlichen Fakten, Kernaussagen und relevanten Entitaeten enthalten?</p>
297
+ <ul>
298
+ <li><span class="scale-label">Ja:</span> Alle wesentlichen Informationen und Kernaussagen enthalten. Wichtige Entitaeten vollstaendig erfasst.</li>
299
+ <li><span class="scale-label">Ausreichend:</span> Grundlegender Ueberblick, aber einzelne bedeutsame Aspekte fehlen.</li>
300
+ <li><span class="scale-label">Nein:</span> Wichtige Kernaussagen oder zentrale Entitaeten nicht erfasst. Zu lueckenhaft.</li>
301
+ </ul>
302
+
303
+ <h3>4. Kohaerenz</h3>
304
+ <p>Ist die Zusammenfassung gut strukturiert und logisch nachvollziehbar? Bilden die Saetze einen zusammenhaengenden Text?</p>
305
+ <ul>
306
+ <li><span class="scale-label">Ja:</span> Strukturiert und organisiert. Saetze bauen logisch aufeinander auf.</li>
307
+ <li><span class="scale-label">Ausreichend:</span> Grundsaetzlich verstaendlich, aber stellenweise sprunghaft.</li>
308
+ <li><span class="scale-label">Nein:</span> Unstrukturiert, Zusammenhang nur schwer nachvollziehbar.</li>
309
+ </ul>
310
+
311
+ <h3>Gesamtbewertung</h3>
312
+ <p>Schulnote 1 (sehr gut) bis 6 (ungenuegend). Bezieht sich auf den generellen Eindruck, wie gut das Transkript zusammengefasst wurde. Muss kein Durchschnitt der Kriterien sein -- z.B. kann fehlende Korrektheit staerker wiegen als fehlende Kohaerenz.</p>
313
+
314
+ <h3>Anmerkungen</h3>
315
+ <p>Optional: Besondere Fehler, Staerken, Halluzinationen, Begruendungen oder konkrete Verbesserungsvorschlaege fuer den Prompt.</p>
316
+ </div>
317
+
318
+ <div class="eval-section">
319
+ <div class="section-label">Bewertung</div>
320
+
321
+ <div class="rating-row">
322
+ <label>Gesamtbewertung (1 = sehr gut, 6 = ungenuegend)</label>
323
+ <select id="bewertung" onchange="markDirty()">
324
+ <option value="">(bitte waehlen)</option>
325
+ <option value="1">1 - sehr gut</option>
326
+ <option value="2">2 - gut</option>
327
+ <option value="3">3 - befriedigend</option>
328
+ <option value="4">4 - ausreichend</option>
329
+ <option value="5">5 - mangelhaft</option>
330
+ <option value="6">6 - ungenuegend</option>
331
+ </select>
332
+ </div>
333
+
334
+ <div class="criteria" id="criteria"></div>
335
+
336
+ <div class="comments-row">
337
+ <label for="anmerkungen">Anmerkungen</label>
338
+ <textarea id="anmerkungen" rows="3" placeholder="Optionale Anmerkungen..." oninput="markDirty()"></textarea>
339
+ </div>
340
+ </div>
341
+
342
+ <div class="actions">
343
+ <span class="save-hint" id="save-hint"></span>
344
+ <button class="btn-save" id="btn-save" onclick="saveAnnotation()" disabled>Speichern</button>
345
+ </div>
346
+ </div>
347
+
348
+ <div class="toast" id="toast"></div>
349
+
350
+ <script>
351
+ const CRITERIA = ["korrekt", "relevant", "vollstaendig", "kohaerenz"];
352
+ const CRITERIA_DESC = {
353
+ korrekt: "Faktisch korrekt? Keine Halluzinationen?",
354
+ relevant: "Nur wichtige Informationen? Keine Redundanzen?",
355
+ vollstaendig: "Alle wesentlichen Fakten und Entitaeten enthalten?",
356
+ kohaerenz: "Gut strukturiert und logisch nachvollziehbar?",
357
+ };
358
+
359
+ let allRows = [];
360
+ let filteredRows = [];
361
+ let currentIdx = 0;
362
+ let dirty = false;
363
+
364
+ /* ---------- Silent session ID (one per browser, prevents overwrites) ---------- */
365
+
366
+ function getSessionId() {
367
+ let id = localStorage.getItem("eval_session_id");
368
+ if (!id) {
369
+ id = crypto.randomUUID();
370
+ localStorage.setItem("eval_session_id", id);
371
+ }
372
+ return id;
373
+ }
374
+ const SESSION_ID = getSessionId();
375
+
376
+ /* ---------- Data loading ---------- */
377
+
378
+ async function loadEntries() {
379
+ const res = await fetch("/api/entries?annotator=" + encodeURIComponent(SESSION_ID));
380
+ if (!res.ok) { allRows = []; applyFilters(); return; }
381
+ allRows = await res.json();
382
+ applyFilters();
383
+ refreshProgress();
384
+ }
385
+
386
+ function applyFilters() {
387
+ if (dirty && !confirm("Ungespeicherte Aenderungen verwerfen?")) return;
388
+ dirty = false;
389
+ const status = document.getElementById("filter-status").value;
390
+ const rfa = document.getElementById("filter-rfa").value;
391
+ filteredRows = [...allRows];
392
+ if (status === "pending") {
393
+ filteredRows = filteredRows.filter(r => !r.bewertung);
394
+ } else if (status === "done") {
395
+ filteredRows = filteredRows.filter(r => r.bewertung);
396
+ }
397
+ if (rfa) {
398
+ filteredRows = filteredRows.filter(r => r.rfa === rfa);
399
+ }
400
+ const typeFilter = document.getElementById("filter-type").value;
401
+ if (typeFilter === "promptd") {
402
+ filteredRows = filteredRows.filter(r => r.has_prior_judgement);
403
+ } else if (typeFilter === "new") {
404
+ filteredRows = filteredRows.filter(r => !r.has_prior_judgement);
405
+ }
406
+ currentIdx = 0;
407
+ render();
408
+ }
409
+
410
+ async function refreshProgress() {
411
+ const res = await fetch("/api/progress?annotator=" + encodeURIComponent(SESSION_ID));
412
+ if (!res.ok) return;
413
+ const p = await res.json();
414
+ document.getElementById("progress-text").textContent = p.annotated + " / " + p.total;
415
+ const pct = p.total ? Math.round(100 * p.annotated / p.total) : 0;
416
+ document.getElementById("progress-fill").style.width = pct + "%";
417
+ }
418
+
419
+ /* ---------- Rendering ---------- */
420
+
421
+ function render() {
422
+ const card = document.getElementById("card");
423
+ if (filteredRows.length === 0) {
424
+ card.innerHTML = '<div class="empty-state">Keine Eintraege gefunden.</div>';
425
+ document.getElementById("counter").textContent = "0 / 0";
426
+ return;
427
+ }
428
+ // Ensure card structure is present (may have been replaced by empty state)
429
+ if (!document.getElementById("title")) { location.reload(); return; }
430
+
431
+ const row = filteredRows[currentIdx];
432
+ document.getElementById("counter").textContent = (currentIdx + 1) + " / " + filteredRows.length;
433
+ document.getElementById("btn-prev").disabled = currentIdx === 0;
434
+ document.getElementById("btn-next").disabled = currentIdx === filteredRows.length - 1;
435
+
436
+ document.getElementById("eval-id").textContent = row.eval_id;
437
+ document.getElementById("title").textContent =
438
+ (row.sende_haupttitel || "") + " \u2013 " + (row.beitragstitel || "");
439
+
440
+ const badge = document.getElementById("badge-rfa");
441
+ badge.textContent = row.rfa;
442
+ badge.className = "badge badge-" + row.rfa;
443
+
444
+ const isPromptD = row.has_prior_judgement;
445
+ document.getElementById("badge-promptd").style.display = isPromptD ? "" : "none";
446
+ document.getElementById("reference-banner").style.display = isPromptD ? "" : "none";
447
+
448
+ const pjEl = document.getElementById("prior-judgement");
449
+ if (isPromptD) {
450
+ pjEl.style.display = "";
451
+ const grid = document.getElementById("pj-grid");
452
+ grid.innerHTML =
453
+ '<div class="pj-item"><span class="pj-label">Gesamt</span><span class="pj-value">' + (row.prior_bewertung || "-") + '</span></div>' +
454
+ CRITERIA.map(c =>
455
+ '<div class="pj-item"><span class="pj-label">' + c.charAt(0).toUpperCase() + c.slice(1) +
456
+ '</span><span class="pj-value">' + (row["prior_" + c] || "-") + '</span></div>'
457
+ ).join("");
458
+ const pjAnm = document.getElementById("pj-anmerkungen");
459
+ pjAnm.textContent = row.prior_anmerkungen ? "Anmerkungen: " + row.prior_anmerkungen : "";
460
+ } else {
461
+ pjEl.style.display = "none";
462
+ }
463
+
464
+ document.getElementById("summary").textContent = row.summary || "-";
465
+ document.getElementById("referenz").textContent = row.referenz || "-";
466
+
467
+ const hasTranslation = row.rfa === "DW" && row.translation;
468
+ if (hasTranslation) {
469
+ document.getElementById("transcript-label").textContent = "Transkript (Uebersetzung)";
470
+ document.getElementById("transcript").textContent = row.translation;
471
+ document.getElementById("original-toggle").style.display = "";
472
+ document.getElementById("original-text").textContent = row.transkript || "-";
473
+ document.getElementById("original-transcript").classList.remove("open");
474
+ document.getElementById("original-arrow").classList.remove("open");
475
+ } else {
476
+ document.getElementById("transcript-label").textContent = "Transkript";
477
+ document.getElementById("transcript").textContent = row.transkript || "-";
478
+ document.getElementById("original-toggle").style.display = "none";
479
+ document.getElementById("original-transcript").classList.remove("open");
480
+ }
481
+
482
+ // Bewertung dropdown
483
+ document.getElementById("bewertung").value = row.bewertung || "";
484
+
485
+ // Criteria
486
+ const criteriaEl = document.getElementById("criteria");
487
+ criteriaEl.innerHTML = "";
488
+ for (const c of CRITERIA) {
489
+ const val = (row[c] || "").toLowerCase();
490
+ const div = document.createElement("div");
491
+ div.className = "criterion";
492
+ div.innerHTML =
493
+ '<label>' + c.charAt(0).toUpperCase() + c.slice(1) + '</label>' +
494
+ '<span class="criterion-desc">' + CRITERIA_DESC[c] + '</span>' +
495
+ '<select data-criterion="' + c + '" onchange="onCriterionChange(this)">' +
496
+ '<option value=""' + (!val ? ' selected' : '') + '>(bitte waehlen)</option>' +
497
+ '<option value="ja"' + (val === 'ja' ? ' selected' : '') + '>ja</option>' +
498
+ '<option value="ausreichend"' + (val === 'ausreichend' ? ' selected' : '') + '>ausreichend</option>' +
499
+ '<option value="nein"' + (val === 'nein' ? ' selected' : '') + '>nein</option>' +
500
+ '</select>';
501
+ const sel = div.querySelector("select");
502
+ applyCriterionStyle(sel);
503
+ criteriaEl.appendChild(div);
504
+ }
505
+
506
+ // Anmerkungen
507
+ document.getElementById("anmerkungen").value = row.anmerkungen || "";
508
+
509
+ // Save hint
510
+ document.getElementById("save-hint").textContent = row.bewertung ? "Bereits bewertet" : "";
511
+
512
+ dirty = false;
513
+ document.getElementById("btn-save").disabled = true;
514
+ }
515
+
516
+ function applyCriterionStyle(sel) {
517
+ const v = sel.value;
518
+ sel.className = v === "ja" ? "val-ja" : v === "ausreichend" ? "val-aus" : v === "nein" ? "val-nein" : "val-none";
519
+ }
520
+
521
+ /* ---------- Interaction ---------- */
522
+
523
+ function markDirty() {
524
+ dirty = true;
525
+ document.getElementById("btn-save").disabled = false;
526
+ document.getElementById("save-hint").textContent = "";
527
+ }
528
+
529
+ function onCriterionChange(sel) {
530
+ applyCriterionStyle(sel);
531
+ markDirty();
532
+ }
533
+
534
+ function navigate(dir) {
535
+ if (dirty && !confirm("Ungespeicherte Aenderungen verwerfen?")) return;
536
+ dirty = false;
537
+ currentIdx = Math.max(0, Math.min(filteredRows.length - 1, currentIdx + dir));
538
+ render();
539
+ }
540
+
541
+ async function saveAnnotation() {
542
+ const row = filteredRows[currentIdx];
543
+ const data = {
544
+ annotator: SESSION_ID,
545
+ eval_id: row.eval_id,
546
+ bewertung: document.getElementById("bewertung").value || null,
547
+ anmerkungen: document.getElementById("anmerkungen").value || null,
548
+ };
549
+ document.querySelectorAll("#criteria select").forEach(sel => {
550
+ data[sel.dataset.criterion] = sel.value || null;
551
+ });
552
+
553
+ const res = await fetch("/api/annotate", {
554
+ method: "POST",
555
+ headers: { "Content-Type": "application/json" },
556
+ body: JSON.stringify(data),
557
+ });
558
+
559
+ if (res.ok) {
560
+ // Update local row data
561
+ for (const key of ["bewertung", "korrekt", "relevant", "vollstaendig", "kohaerenz", "anmerkungen"]) {
562
+ row[key] = data[key];
563
+ }
564
+ // Also update in allRows
565
+ const src = allRows.find(r => r.eval_id === row.eval_id);
566
+ if (src) Object.assign(src, data);
567
+
568
+ dirty = false;
569
+ document.getElementById("btn-save").disabled = true;
570
+ document.getElementById("save-hint").textContent = "Gespeichert";
571
+ showToast("Gespeichert", "success");
572
+ refreshProgress();
573
+ } else {
574
+ showToast("Fehler beim Speichern", "error");
575
+ }
576
+ }
577
+
578
+ /* ---------- Utilities ---------- */
579
+
580
+ function showToast(msg, type) {
581
+ const el = document.getElementById("toast");
582
+ el.textContent = msg;
583
+ el.className = "toast " + type + " show";
584
+ setTimeout(() => el.classList.remove("show"), 2000);
585
+ }
586
+
587
+ document.addEventListener("keydown", e => {
588
+ if (e.target.tagName === "SELECT" || e.target.tagName === "TEXTAREA") return;
589
+ if (e.key === "ArrowLeft") navigate(-1);
590
+ if (e.key === "ArrowRight") navigate(1);
591
+ });
592
+
593
+ /* ---------- Guidelines toggle ---------- */
594
+
595
+ function toggleGuidelines() {
596
+ const body = document.getElementById("guidelines-body");
597
+ const arrow = document.getElementById("guidelines-arrow");
598
+ body.classList.toggle("open");
599
+ arrow.classList.toggle("open");
600
+ }
601
+
602
+ function toggleOriginal() {
603
+ const body = document.getElementById("original-transcript");
604
+ const arrow = document.getElementById("original-arrow");
605
+ body.classList.toggle("open");
606
+ arrow.classList.toggle("open");
607
+ }
608
+
609
+ /* ---------- Init ---------- */
610
+ loadEntries();
611
+ </script>
612
+ </body>
613
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "annotation-app"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "flask==3.1.3",
9
+ ]
uv.lock ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [options]
6
+ exclude-newer = "2026-04-09T08:47:56.7359646Z"
7
+ exclude-newer-span = "P7D"
8
+
9
+ [[package]]
10
+ name = "annotation-app"
11
+ version = "0.1.0"
12
+ source = { virtual = "." }
13
+ dependencies = [
14
+ { name = "flask" },
15
+ ]
16
+
17
+ [package.metadata]
18
+ requires-dist = [{ name = "flask", specifier = "==3.1.3" }]
19
+
20
+ [[package]]
21
+ name = "blinker"
22
+ version = "1.9.0"
23
+ source = { registry = "https://pypi.org/simple" }
24
+ sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
25
+ wheels = [
26
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
27
+ ]
28
+
29
+ [[package]]
30
+ name = "click"
31
+ version = "8.3.2"
32
+ source = { registry = "https://pypi.org/simple" }
33
+ dependencies = [
34
+ { name = "colorama", marker = "sys_platform == 'win32'" },
35
+ ]
36
+ sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
37
+ wheels = [
38
+ { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
39
+ ]
40
+
41
+ [[package]]
42
+ name = "colorama"
43
+ version = "0.4.6"
44
+ source = { registry = "https://pypi.org/simple" }
45
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
46
+ wheels = [
47
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
48
+ ]
49
+
50
+ [[package]]
51
+ name = "flask"
52
+ version = "3.1.3"
53
+ source = { registry = "https://pypi.org/simple" }
54
+ dependencies = [
55
+ { name = "blinker" },
56
+ { name = "click" },
57
+ { name = "itsdangerous" },
58
+ { name = "jinja2" },
59
+ { name = "markupsafe" },
60
+ { name = "werkzeug" },
61
+ ]
62
+ sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
63
+ wheels = [
64
+ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
65
+ ]
66
+
67
+ [[package]]
68
+ name = "itsdangerous"
69
+ version = "2.2.0"
70
+ source = { registry = "https://pypi.org/simple" }
71
+ sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
72
+ wheels = [
73
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
74
+ ]
75
+
76
+ [[package]]
77
+ name = "jinja2"
78
+ version = "3.1.6"
79
+ source = { registry = "https://pypi.org/simple" }
80
+ dependencies = [
81
+ { name = "markupsafe" },
82
+ ]
83
+ sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
84
+ wheels = [
85
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
86
+ ]
87
+
88
+ [[package]]
89
+ name = "markupsafe"
90
+ version = "3.0.3"
91
+ source = { registry = "https://pypi.org/simple" }
92
+ sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
93
+ wheels = [
94
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
95
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
96
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
97
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
98
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
99
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
100
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
101
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
102
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
103
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
104
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
105
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
106
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
107
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
108
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
109
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
110
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
111
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
112
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
113
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
114
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
115
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
116
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
117
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
118
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
119
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
120
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
121
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
122
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
123
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
124
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
125
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
126
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
127
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
128
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
129
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
130
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
131
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
132
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
133
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
134
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
135
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
136
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
137
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
138
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
139
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
140
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
141
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
142
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
143
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
144
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
145
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
146
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
147
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
148
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
149
+ ]
150
+
151
+ [[package]]
152
+ name = "werkzeug"
153
+ version = "3.1.8"
154
+ source = { registry = "https://pypi.org/simple" }
155
+ dependencies = [
156
+ { name = "markupsafe" },
157
+ ]
158
+ sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
159
+ wheels = [
160
+ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
161
+ ]