Spaces:
Paused
Paused
feat(iserv): shorter output, finished tasks only with ?alle=1
Browse filesAttachment text is tidied and capped at 3000 characters per file.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- iserv-filter/taskfilter.py +101 -91
- src/api/iserv-filtered.js +5 -3
iserv-filter/taskfilter.py
CHANGED
|
@@ -1,91 +1,101 @@
|
|
| 1 |
-
"""filter_task: the one place that decides what leaves the server. Explicit whitelist, free text always filtered."""
|
| 2 |
-
import io
|
| 3 |
-
import zipfile
|
| 4 |
-
from collections import Counter
|
| 5 |
-
|
| 6 |
-
from iserv import html_text
|
| 7 |
-
from nerfilter import filter_filename, filter_text, split_ext
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
"
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
return {**out, "hinweis":
|
| 75 |
-
if
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
"
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""filter_task: the one place that decides what leaves the server. Explicit whitelist, free text always filtered."""
|
| 2 |
+
import io
|
| 3 |
+
import zipfile
|
| 4 |
+
from collections import Counter
|
| 5 |
+
|
| 6 |
+
from iserv import html_text
|
| 7 |
+
from nerfilter import filter_filename, filter_text, split_ext
|
| 8 |
+
|
| 9 |
+
READ_LIMIT = 20_000 # chars read from a file; bounds CPU time on huge PDFs
|
| 10 |
+
MAX_TEXT = 3_000 # chars of a file that go out: worksheets are long and Muse needs the gist
|
| 11 |
+
NOT_READABLE = "Inhalt nicht maschinell lesbar"
|
| 12 |
+
_MEDIA = {"jpg": "bild", "jpeg": "bild", "png": "bild", "gif": "bild", "webp": "bild", "bmp": "bild", "heic": "bild",
|
| 13 |
+
"svg": "bild", "mp3": "audio", "wav": "audio", "m4a": "audio", "ogg": "audio", "mp4": "video",
|
| 14 |
+
"mov": "video", "avi": "video", "mkv": "video", "webm": "video"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _decode(data):
|
| 18 |
+
try:
|
| 19 |
+
return data.decode("utf-8")
|
| 20 |
+
except UnicodeDecodeError:
|
| 21 |
+
return data.decode("cp1252", errors="replace")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _pdf(data):
|
| 25 |
+
from pypdf import PdfReader
|
| 26 |
+
|
| 27 |
+
reader = PdfReader(io.BytesIO(data))
|
| 28 |
+
if reader.is_encrypted:
|
| 29 |
+
return ""
|
| 30 |
+
text = ""
|
| 31 |
+
for page in reader.pages:
|
| 32 |
+
text += (page.extract_text() or "") + "\n"
|
| 33 |
+
if len(text) > READ_LIMIT:
|
| 34 |
+
break
|
| 35 |
+
return text
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _docx(data):
|
| 39 |
+
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
| 40 |
+
if sum(i.file_size for i in z.infolist()) > 50 * 1024 * 1024: # zip bomb
|
| 41 |
+
return ""
|
| 42 |
+
from docx import Document
|
| 43 |
+
|
| 44 |
+
doc = Document(io.BytesIO(data))
|
| 45 |
+
return "\n".join([p.text for p in doc.paragraphs] + [c.text for t in doc.tables for r in t.rows for c in r.cells])
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _html(data):
|
| 49 |
+
return html_text(_decode(data))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
_EXTRACT = {"pdf": _pdf, "docx": _docx, "txt": _decode, "md": _decode, "html": _html, "htm": _html}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _tidy(text):
|
| 56 |
+
"""Drops what says nothing: blank lines, page numbers, a line identical to the one before (running headers)."""
|
| 57 |
+
out = []
|
| 58 |
+
for line in (l.strip() for l in text.splitlines()):
|
| 59 |
+
if line and not line.isdigit() and (not out or line != out[-1]):
|
| 60 |
+
out.append(line)
|
| 61 |
+
return "\n".join(out)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def describe_attachment(att, stats=None):
|
| 65 |
+
"""{name, typ, groesse, text | hinweis}. The file bytes never leave this function."""
|
| 66 |
+
if att.get("skipped"):
|
| 67 |
+
return {"hinweis": "weiterer Anhang nicht abgerufen"}
|
| 68 |
+
name = att.get("name") or ""
|
| 69 |
+
ext = split_ext(name)[1]
|
| 70 |
+
out = {"name": filter_filename(name, stats), "typ": _MEDIA.get(ext) or ext or "unbekannt", "groesse": att.get("size")}
|
| 71 |
+
if att.get("failed"):
|
| 72 |
+
if stats is not None:
|
| 73 |
+
stats["attachments_failed"] += 1
|
| 74 |
+
return {**out, "hinweis": "Datei nicht abrufbar"}
|
| 75 |
+
if att.get("data") is None:
|
| 76 |
+
return {**out, "hinweis": "Datei größer als 10 MB, nicht abgerufen"}
|
| 77 |
+
if ext not in _EXTRACT:
|
| 78 |
+
return {**out, "hinweis": NOT_READABLE}
|
| 79 |
+
try:
|
| 80 |
+
text = _tidy(_EXTRACT[ext](att["data"]))
|
| 81 |
+
except Exception: # corrupt/encrypted file; the message may quote its content
|
| 82 |
+
text = ""
|
| 83 |
+
if not text:
|
| 84 |
+
return {**out, "hinweis": NOT_READABLE}
|
| 85 |
+
if len(text) > MAX_TEXT: # cut at a space so no name is split in half
|
| 86 |
+
text, out["gekuerzt"] = text[:MAX_TEXT].rsplit(None, 1)[0], True
|
| 87 |
+
return {**out, "text": filter_text(text, stats)}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def filter_task(task, stats: Counter | None = None):
|
| 91 |
+
"""Whitelist: anything not named here (creator, groups, feedback, ...) cannot appear in the output."""
|
| 92 |
+
return {
|
| 93 |
+
"id": int(task["id"]),
|
| 94 |
+
"fach": filter_text(task.get("tags", ""), stats),
|
| 95 |
+
"titel": filter_text(task.get("title", ""), stats),
|
| 96 |
+
"beschreibung": filter_text(task.get("description", ""), stats),
|
| 97 |
+
"start": task.get("start"),
|
| 98 |
+
"abgabe": task.get("due"),
|
| 99 |
+
"status": None if "done" not in task else "erledigt" if task["done"] else "offen", # None: source cannot tell
|
| 100 |
+
"anhaenge": [describe_attachment(a, stats) for a in task.get("attachments", [])],
|
| 101 |
+
}
|
src/api/iserv-filtered.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
// GET /iserv/tasks: the open IServ tasks with person, place and school names removed, for the assistant "Muse".
|
| 2 |
// IServ credentials come from the Space secrets (never from the request). The raw tasks stay in memory: the fetcher
|
| 3 |
// (iserv.js) reads them, the filter service (iserv-filter/, Python, 127.0.0.1 only, own unix user without the IServ
|
| 4 |
// secrets) cleans them, only its answer leaves. Nothing is stored, cached or logged.
|
|
@@ -16,12 +16,13 @@ function tokenOk(req) {
|
|
| 16 |
return offered.some((o) => o && Buffer.byteLength(o) === expected.length && timingSafeEqual(Buffer.from(o), expected));
|
| 17 |
}
|
| 18 |
|
| 19 |
-
async function cleanTasks() {
|
| 20 |
const { ISERV_BASE_URL: url, ISERV_USERNAME: user, ISERV_PASSWORD: pass } = process.env;
|
| 21 |
if (!url || !user || !pass || !url.startsWith('https://')) return { status: 503, body: { error: 'config' } };
|
| 22 |
let raw;
|
| 23 |
try {
|
| 24 |
raw = await fetchIServTasks(url, user, pass);
|
|
|
|
| 25 |
} catch {
|
| 26 |
return { status: 502, body: { error: 'unreachable' } }; // the message may quote the school domain
|
| 27 |
}
|
|
@@ -44,7 +45,8 @@ export function registerIServFiltered(app) {
|
|
| 44 |
res.set('Cache-Control', 'no-store');
|
| 45 |
if ((process.env.API_BEARER_TOKEN || '').length < 16) return res.status(503).json({ error: 'config' }); // fail closed
|
| 46 |
if (!tokenOk(req)) return res.status(401).json({ error: 'unauthorized' });
|
| 47 |
-
const
|
|
|
|
| 48 |
running = job.catch(() => {});
|
| 49 |
const { status, body } = await job;
|
| 50 |
res.status(status).json(body);
|
|
|
|
| 1 |
+
// GET /iserv/tasks (?alle=1 adds finished tasks): the open IServ tasks with person, place and school names removed, for the assistant "Muse".
|
| 2 |
// IServ credentials come from the Space secrets (never from the request). The raw tasks stay in memory: the fetcher
|
| 3 |
// (iserv.js) reads them, the filter service (iserv-filter/, Python, 127.0.0.1 only, own unix user without the IServ
|
| 4 |
// secrets) cleans them, only its answer leaves. Nothing is stored, cached or logged.
|
|
|
|
| 16 |
return offered.some((o) => o && Buffer.byteLength(o) === expected.length && timingSafeEqual(Buffer.from(o), expected));
|
| 17 |
}
|
| 18 |
|
| 19 |
+
async function cleanTasks(includeDone) {
|
| 20 |
const { ISERV_BASE_URL: url, ISERV_USERNAME: user, ISERV_PASSWORD: pass } = process.env;
|
| 21 |
if (!url || !user || !pass || !url.startsWith('https://')) return { status: 503, body: { error: 'config' } };
|
| 22 |
let raw;
|
| 23 |
try {
|
| 24 |
raw = await fetchIServTasks(url, user, pass);
|
| 25 |
+
if (!includeDone) raw = raw.filter((t) => t.done !== true); // finished tasks only with ?alle=1
|
| 26 |
} catch {
|
| 27 |
return { status: 502, body: { error: 'unreachable' } }; // the message may quote the school domain
|
| 28 |
}
|
|
|
|
| 45 |
res.set('Cache-Control', 'no-store');
|
| 46 |
if ((process.env.API_BEARER_TOKEN || '').length < 16) return res.status(503).json({ error: 'config' }); // fail closed
|
| 47 |
if (!tokenOk(req)) return res.status(401).json({ error: 'unauthorized' });
|
| 48 |
+
const run = () => cleanTasks(req.query.alle === '1');
|
| 49 |
+
const job = running.then(run, run);
|
| 50 |
running = job.catch(() => {});
|
| 51 |
const { status, body } = await job;
|
| 52 |
res.status(status).json(body);
|