├── _streamlit_app ├── .env_example ├── zix_scores.jpg ├── data │ ├── cefr_vocab.parq │ ├── ridge_regressor.pkl │ ├── standard_scaler.pkl │ └── word_scores_final_0728.parq ├── utils_expander.md ├── utils_prompts.py ├── sprache-vereinfachen.py └── sprache-vereinfachen-openai.py ├── _imgs ├── app_ui.png └── zix_scores.jpg ├── .gitignore ├── pyproject.toml ├── LICENSE ├── config_openai.yaml ├── config.yaml └── README.md /_streamlit_app/.env_example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=sk-proj-... 2 | OPENROUTER_API_KEY=sk-or-... 3 | -------------------------------------------------------------------------------- /_imgs/app_ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearningZH/simply-simplify-language/HEAD/_imgs/app_ui.png -------------------------------------------------------------------------------- /_imgs/zix_scores.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearningZH/simply-simplify-language/HEAD/_imgs/zix_scores.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.log 3 | .DS_Store 4 | .env 5 | _streamlit_app/.DS_Store 6 | _streamlit_app/.env 7 | _gh.code-workspace -------------------------------------------------------------------------------- /_streamlit_app/zix_scores.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearningZH/simply-simplify-language/HEAD/_streamlit_app/zix_scores.jpg -------------------------------------------------------------------------------- /_streamlit_app/data/cefr_vocab.parq: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearningZH/simply-simplify-language/HEAD/_streamlit_app/data/cefr_vocab.parq -------------------------------------------------------------------------------- /_streamlit_app/data/ridge_regressor.pkl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearningZH/simply-simplify-language/HEAD/_streamlit_app/data/ridge_regressor.pkl -------------------------------------------------------------------------------- /_streamlit_app/data/standard_scaler.pkl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearningZH/simply-simplify-language/HEAD/_streamlit_app/data/standard_scaler.pkl -------------------------------------------------------------------------------- /_streamlit_app/data/word_scores_final_0728.parq: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/machinelearningZH/simply-simplify-language/HEAD/_streamlit_app/data/word_scores_final_0728.parq -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "simply-simplify-language" 3 | version = "1.1.0" 4 | description = "Use LLMs to simplify your institutional communication." 5 | authors = [ 6 | { name = "Statistisches Amt Kanton Zürich, Team Data", email = "datashop@statistik.zh.ch" }, 7 | ] 8 | readme = "README.md" 9 | requires-python = ">=3.13" 10 | dependencies = [ 11 | "zix @ git+https://github.com/machinelearningZH/zix_understandability-index", 12 | "de-core-news-sm @ https://github.com/explosion/spacy-models/releases/download/de_core_news_sm-3.8.0/de_core_news_sm-3.8.0-py3-none-any.whl", 13 | "openai>=2.9.0", 14 | "pyarrow>=22.0.0", 15 | "streamlit>=1.52.1", 16 | "python-docx>=1.2.0", 17 | "python-dotenv>=1.2.1", 18 | "pyyaml>=6.0.3", 19 | ] 20 | 21 | [tool.ruff] 22 | ignore = ["E402"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Canton of Zurich / Department of Justice and Home Affairs / Statistical Office / Team Data 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config_openai.yaml: -------------------------------------------------------------------------------- 1 | # Language models available for text simplification using OpenAI API directly. 2 | # Each model has a display name and an OpenAI API identifier. 3 | # Only OpenAI models are supported in this version. 4 | models: 5 | - name: "GPT-5.1" 6 | id: "gpt-5.1" 7 | 8 | # API configuration for model calls 9 | api: 10 | temperature: 0.5 # From our testing we derive a sensible temperature of 0.5 as a good trade-off between creativity and coherence. Adjust this to your needs. 11 | max_tokens: 8192 # Maximum number of tokens in the response 12 | 13 | # User interface configuration 14 | ui: 15 | text_area_height: 600 # Height of text input/output areas in pixels 16 | max_chars_input: 10000 # Maximum characters allowed in input text. # This is way below the context window sizes of the models. Adjust to your needs. However, we found that users can work and validate better when we nudge to work with shorter texts. 17 | user_warning: "⚠️ Achtung: Diese App ist ein Prototyp (OpenAI Version). Nutze die App :red[**nur für öffentliche, nicht sensible Daten**]. Die App liefert lediglich einen Textentwurf. Überprüfe das Ergebnis immer und passe es an, wenn nötig. Die aktuelle App-Version ist v1.1 Die letzte Aktualisierung war am 07.12.2025." # Warning message displayed to users 18 | 19 | # Constants for the formatting of the Word document that can be downloaded. 20 | document: 21 | font_name: "Arial" 22 | font_size_heading: 12 # Font size for headings 23 | font_size_paragraph: 9 # Font size for paragraph text 24 | font_size_footer: 7 # Font size for footer text 25 | default_output_filename: "Ergebnis_OpenAI.docx" 26 | analysis_filename: "Analyse_OpenAI.docx" 27 | 28 | # Limits for the understandability score to determine if the text is easy, medium or hard to understand. 29 | understandability: 30 | limit_hard: 0 31 | limit_medium: -2 32 | # Scale ranges from -10 (extremely hard) to +10 (very easy to understand) 33 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # Language models available for text simplification. 2 | # Each model has a display name and an OpenRouter API identifier. 3 | # Select models from here: https://openrouter.ai/models 4 | # We recommend that you select up to 10 models for the best performance and compatibility in the UI. 5 | models: 6 | - name: "Mistral Large 3" 7 | id: "mistralai/mistral-large-2512" 8 | - name: "Claude Haiku 4.5" 9 | id: "anthropic/claude-haiku-4.5" 10 | - name: "Claude Sonnet 4.5" 11 | id: "anthropic/claude-sonnet-4.5" 12 | - name: "Claude Opus 4.5" 13 | id: "anthropic/claude-opus-4.5" 14 | - name: "GPT-5.1" 15 | id: "openai/gpt-5.1" 16 | - name: "Gemini 2.5 Flash" 17 | id: "google/gemini-2.5-flash" 18 | - name: "Gemini 2.5 Pro" 19 | id: "google/gemini-2.5-pro" 20 | - name: "Gemini 3 Pro" 21 | id: "google/gemini-3-pro-preview" 22 | 23 | # API configuration for model calls 24 | api: 25 | temperature: 0.5 # From our testing we derive a sensible temperature of 0.5 as a good trade-off between creativity and coherence. Adjust this to your needs. 26 | max_tokens: 8192 # Maximum number of tokens in the response 27 | 28 | # User interface configuration 29 | ui: 30 | text_area_height: 600 # Height of text input/output areas in pixels 31 | max_chars_input: 10000 # Maximum characters allowed in input text. # This is way below the context window sizes of the models. Adjust to your needs. However, we found that users can work and validate better when we nudge to work with shorter texts. 32 | user_warning: "⚠️ Achtung: Diese App ist ein Prototyp. Nutze die App :red[**nur für öffentliche, nicht sensible Daten**]. Die App liefert lediglich einen Textentwurf. Überprüfe das Ergebnis immer und passe es an, wenn nötig. Die aktuelle App-Version ist v1.1 Die letzte Aktualisierung war am 07.12.2025." # Warning message displayed to users 33 | 34 | # Constants for the formatting of the Word document that can be downloaded. 35 | document: 36 | font_name: "Arial" 37 | font_size_heading: 12 # Font size for headings 38 | font_size_paragraph: 9 # Font size for paragraph text 39 | font_size_footer: 7 # Font size for footer text 40 | default_output_filename: "Ergebnis.docx" 41 | analysis_filename: "Analyse.docx" 42 | 43 | # Limits for the understandability score to determine if the text is easy, medium or hard to understand. 44 | understandability: 45 | limit_hard: 0 46 | limit_medium: -2 47 | # Scale ranges from -10 (extremely hard) to +10 (very easy to understand) 48 | -------------------------------------------------------------------------------- /_streamlit_app/utils_expander.md: -------------------------------------------------------------------------------- 1 | Dieser Prototyp ist Teil eines Projekts vom Statistischen Amt, Kanton Zürich. Mit diesem Projekt möchten wir öffentliche Organisationen dabei unterstützen, ihre Kommunikation noch verständlicher aufzubereiten. 2 | 3 | ## Wichtig 4 | 5 | - **:red[Nutze die App nur für öffentliche, nicht sensible Daten.]** 6 | - **:red[Die App liefert lediglich einen Entwurf. Überprüfe das Ergebnis immer – idealerweise mit Menschen aus dem Zielpublikum – und passe es an, wenn nötig.]** 7 | 8 | ## Was macht diese App? 9 | 10 | **Diese App übersetzt einen von dir eingegebenen Text in einen Entwurf für Einfache Sprache oder Leichte Sprache.** 11 | 12 | Dein Text wird dazu in der App aufbereitet und an ein sogenanntes grosses Sprachmodell (LLM, Large Language Model) eines kommerziellen Anbieters geschickt. Diese Sprachmodelle sind in der Lage, Texte nach Anweisungen umzuformulieren und dabei zu vereinfachen. 13 | 14 | Du kannst die Texte nach den Regeln für Einfache Sprache oder Leichte Sprache übersetzen. 15 | 16 | - **Leichte Sprache** ist eine vereinfachte Form der deutschen Sprache, die nach bestimmten Regeln gestaltet wird. Leichte Sprache hilft u.a. Menschen mit Lernschwierigkeiten oder geringen Deutschkenntnissen. 17 | - **Einfache Sprache** ist eine vereinfachte Version von Alltagssprache. Diese zielt darauf, Texte generell für ein breiteres Publikum verständlicher zu machen. 18 | 19 | In der Grundeinstellung übersetzt die App in Einfache Spache. Wenn du den Schalter «Leichte Sprache» klickst, weist du die App an, einen Entwurf in **Leichter Sprache** zu schreiben. Wenn Leichte Sprache aktiviert ist, kannst du zusätzlich wählen, ob das Modell alle Informationen übernehmen oder versuchen soll, sinnvoll zu verdichten. 20 | 21 | **Die Texte werden teils in sehr guter Qualität vereinfacht. Sie sind aber nie 100% korrekt. Die App liefert lediglich einen Entwurf. Die Texte müssen immer von dir überprüft und angepasst werden.** Insbesondere bei Leichter Sprache ist die Überprüfung der Ergebnisse durch Prüferinnen und Prüfer aus dem Zielpublikum essentiell. 22 | 23 | ### Wie funktioniert die Bewertung der Verständlichkeit? 24 | 25 | Wir haben einen Algorithmus entwickelt, der die Verständlichkeit von Texten auf einer Skala von -10 bis 10 bewertet. Dieser Algorithmus basiert auf diversen Textmerkmalen: Den Wort- und Satzlängen, dem [Lesbarkeitsindex RIX](https://www.jstor.org/stable/40031755), der Häufigkeit von einfachen, verständlichen, viel genutzten Worten, sowie dem Anteil an Worten aus dem Standardvokabular A1, A2 und B1. Wir haben dies systematisch ermittelt, indem wir geschaut haben, welche Merkmale am aussagekräftigsten für Verwaltungs- und Rechtssprache und deren Vereinfachung sind. 26 | 27 | Die Bewertung kannst du so interpretieren: 28 | 29 | - **Sehr schwer verständliche Texte** wie Rechts- oder Verwaltungstexte haben meist Werte von **-10 bis -2**. 30 | - **Durchschnittlich verständliche Texte** wie Nachrichtentexte, Wikipediaartikel oder Bücher haben meist Werte von **-2 bis 0**. 31 | - **Gut verständliche Texte im Bereich Einfacher Sprache und Leichter Sprache** haben meist Werte von **0 oder grösser.**. 32 | 33 | Wir zeigen dir zusätzlich eine **grobe** Schätzung des Sprachniveaus gemäss [CEFR (Common European Framework of Reference for Languages)](https://www.coe.int/en/web/common-european-framework-reference-languages/level-descriptions) von A1 bis C2 an. 34 | 35 | ADD_IMAGE_HERE 36 | 37 | Die Bewertung ist bei weitem nicht perfekt, aber sie ist ein guter erster Anhaltspunkt und hat sich bei unseren Praxistests bewährt. 38 | 39 | ### Feedback 40 | 41 | Wir sind für Rückmeldungen und Anregungen jeglicher Art dankbar und nehmen diese jederzeit gern [per Mail entgegen](mailto:datashop@statistik.zh.ch). 42 | 43 | ## Versionsverlauf 44 | 45 | - **v1.1** - 07.12.2025 - _App aktualisiert auf neueste Modelle. Kleinere Verbesserungen._ 46 | - **v1.0** - 13.07.2025 - _App aktualisiert und umgeschrieben auf OpenRouter. Ältere App-Versionen entfernt bis auf Version OpenAI._ 47 | - **v0.8** - 07.06.2025 - _Modelle aktualisiert. Bugfixes._ 48 | - **v0.7** - 18.04.2025 - _Modelle aktualisiert. GPT-4.1 und GPT-4.1 mini, neue Gemini-Modelle. Refactoring zu aktuellem Google GenAI Python SDK._ 49 | - **v0.6** - 29.03.2025 - _Modelle aktualisiert._ 50 | - **v0.5** - 22.12.2024 - _Modelle aktualisiert und ergänzt. Code vereinfacht._ 51 | - **v0.4** - 30.08.2024 - _Fehler behoben._ 52 | - **v0.3** - 18.08.2024 - _Neuen ZIX-Index integriert. Diverse Fehler behoben._ 53 | - **v0.2** - 21.06.2024 - _Update auf Claude Sonnet v3.5._ 54 | - **v0.1** - 01.06.2024 - _Erste Open Source-Version der App auf Basis des bisherigen Pilotprojekts._ 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simply simplify language 2 | 3 | **Use LLMs to simplify your institutional communication. Get rid of «Behördendeutsch».** 4 | 5 | ![GitHub License](https://img.shields.io/github/license/machinelearningZH/simply-simplify-language) 6 | [![PyPI - Python](https://img.shields.io/badge/python-v3.10+-blue.svg)](https://github.com/machinelearningZH/simply-simplify-language) 7 | [![GitHub Stars](https://img.shields.io/github/stars/machinelearningZH/simply-simplify-language.svg)](https://github.com/machinelearningZH/simply-simplify-language/stargazers) 8 | [![GitHub Issues](https://img.shields.io/github/issues/machinelearningZH/simply-simplify-language.svg)](https://github.com/machinelearningZH/simply-simplify-language/issues) 9 | [![GitHub Issues](https://img.shields.io/github/issues-pr/machinelearningZH/simply-simplify-language.svg)](https://img.shields.io/github/issues-pr/machinelearningZH/simply-simplify-language) 10 | [![Current Version](https://img.shields.io/badge/version-1.0.0-green.svg)](https://github.com/machinelearningZH/simply-simplify-language) 11 | linting - Ruff 12 | 13 |
14 | 15 | Contents 16 | 17 | - [Usage](#usage) 18 | - [Project information](#project-information) 19 | - [What does the app do?](#what-does-the-app-do) 20 | - [What does it cost?](#what-does-it-cost) 21 | - [Our language guidelines](#our-language-guidelines) 22 | - [A couple of findings](#a-couple-of-findings) 23 | - [How does the understandability score work?](#how-does-the-understandability-score-work) 24 | - [What does the score mean?](#what-does-the-score-mean) 25 | - [Project team](#project-team) 26 | - [Contributing](#feedback-and-contributing) 27 | - [License](#license) 28 | - [Miscellaneous](#miscellaneous) 29 | - [Disclaimer](#disclaimer) 30 | 31 |
32 | 33 | ![](_imgs/app_ui.png) 34 | 35 | ## Usage 36 | 37 | - You can run the app **locally**, **in the cloud** or **in a [GitHub Codespace](https://github.com/features/codespaces)**. 38 | - The app uses **[OpenRouter](https://openrouter.ai/)** as a unified API provider to access multiple leading language models. 39 | - We also provide an app version that only uses the OpenAI API. 40 | - All available models are configured in `config.yaml` and can be easily customized for your needs. 41 | 42 | ### Running Locally 43 | 44 | 1. Install [uv](https://docs.astral.sh/uv/):\ 45 | `pip3 install uv` 46 | 2. Clone the repo and enter the directory:\ 47 | `cd simply-simplify-language/` 48 | 3. Create and activate a virtual environment:\ 49 | `uv sync`\ 50 | `source .venv/bin/activate` (Unix/macOS)\ 51 | `.venv\Scripts\activate` (Windows) 52 | 4. Add your OpenRouter (or OpenAI) API key to a `.env` file in `_streamlit_app/`: 53 | 54 | ``` 55 | OPENROUTER_API_KEY=sk-or-v1-... 56 | # OR for OpenAI API: 57 | OPENAI_API_KEY=sk-... 58 | ``` 59 | 60 | 5. Enter the app directory:\ 61 | `cd _streamlit_app/` 62 | 6. Start the app:\ 63 | `streamlit run sprache-vereinfachen.py`\ 64 | Or for the OpenAI-only version:\ 65 | `streamlit run sprache-vereinfachen_openai.py` 66 | 67 | #### Getting your OpenRouter API key 68 | 69 | 1. Register at [OpenRouter](https://openrouter.ai/) 70 | 2. Create an API key: [API Keys](https://openrouter.ai/keys) 71 | 3. Add credits: [Credits](https://openrouter.ai/credits) 72 | 73 | #### Alternatively: Getting your OpenAI API key 74 | 75 | 1. Sign up at [OpenAI](https://platform.openai.com/) 76 | 2. Add billing information and credits at [Billing](https://platform.openai.com/account/billing) 77 | 3. Create a new API key [API Keys](https://platform.openai.com/api-keys) 78 | 79 | ### Running in the Cloud 80 | 81 | - Instantiate a small virtual machine with the cloud provider of your choosing. Suggested size: 2 vCPUs, 2GB RAM, and an SSD with a couple of GBs are sufficient. This will set you back no more than a couple of Francs per month. 82 | - Install the app as described above for local usage. 83 | - Recommendation: To use a proper domain and HTTPS it makes sense to install a reverse proxy. We very much like [Caddy server](https://caddyserver.com/) for this due to its simplicity and ease of installation and usage. It's also simple to request certificates – Caddy does [this automatically for you](https://caddyserver.com/docs/automatic-https). 84 | 85 | ### Running in GitHub Codespaces 86 | 87 | You can develop and run the app in a cloud-hosted environment using GitHub Codespaces. Benefits include: 88 | 89 | - No local installation required 90 | - Everything runs from your web browser 91 | - Free usage hours with your GitHub account (you still need to pay for LLM token usage) 92 | 93 | > [!Note] 94 | > To avoid unnecessary charges, remember to delete any unused Codespaces. It's also a good idea to enable the Auto-delete codespace option in your settings. 95 | 96 | - Launch a codespace:\ 97 | `Code > Codespaces > Create codespace on main` 98 | - Install dependencies:\ 99 | `uv sync` or `pip install -r requirements.txt` 100 | - Install spaCy model:\ 101 | `python -m spacy download de_core_news_sm` 102 | - Add OpenRouter API key via `.env` or GitHub Secrets. 103 | - Start the app:\ 104 | `python -m streamlit run _streamlit_app/sprache-vereinfachen.py` 105 | - Port 8501 is auto-forwarded by Codespaces. 106 | 107 | ### Configuring Models 108 | 109 | Edit `config.yaml` to customize available models: 110 | 111 | - `name`: UI display name 112 | - `id`: OpenRouter model identifier (e.g., `anthropic/claude-3-5-sonnet`, `openai/gpt-4o`) 113 | 114 | See the full model list at [OpenRouter models](https://openrouter.ai/models). 115 | 116 | Alternatively for OpenAI: 117 | 118 | Edit `config_openai.yaml`: 119 | 120 | - `name`: UI display name 121 | - `id`: OpenAI model identifier (e.g., `gpt-4o`, `gpt-4o-mini`) 122 | 123 | See the full model list at [OpenAI models](https://platform.openai.com/docs/models). 124 | 125 | > [!Note] 126 | > The app logs user interactions to your local computer or virtual machine to a file named `app.log`. If you do not want to have analytics, simply comment out the function call in the code. 127 | 128 | ## Project information 129 | 130 | **Institutional communication is often complicated and difficult to understand.** This can be a barrier for many people. Clear and simple communication is essential to ensure equal access to public processes and services. 131 | 132 | The cantonal administration of Zurich has long worked to make its communication more inclusive and accessible. As the amount of content continues to grow, we saw an opportunity to use AI to support this goal. In autumn 2023, we launched a pilot project—this app is one of its results. The code in this repository is a snapshot of our ongoing work. 133 | 134 | We developed the app according to our communication guidelines, but we know from experience that it can be easily adapted to other guidelines and by other institutions. 135 | 136 | ### What does the app do? 137 | 138 | - This app **simplifies complex texts, rewriting them according to rules for [«Einfache Sprache»](https://de.wikipedia.org/wiki/Einfache_Sprache) or [«Leichte Sprache»](https://de.wikipedia.org/wiki/Leichte_Sprache)**. To simplify your source text, the app applies effective prompting, and uses your chosen LLM via OpenRouter. 139 | - The app also offers **coaching to improve your writing**. Its **analysis function** provides detailed, sentence-by-sentence feedback to enhance your communication. 140 | - It **measures the understandability of your text** on a scale from -10 (very complex) to +10 (very easy to understand). 141 | - The **One-Click feature sends your text to all configured LLMs simultaneously**, delivering multiple drafts in a formatted Word document within seconds, ready for download. 142 | 143 | In English, «Einfache Sprache» is roughly equivalent to [«Plain English»](https://www.plainlanguage.gov/about/definitions/), while «Leichte Sprache» has similarities to [«Easy English»](https://centreforinclusivedesign.org.au/wp-content/uploads/2020/04/Easy-English-vs-Plain-English_accessible.pdf). 144 | 145 | > [!Important] 146 | > At the risk of stating the obvious: By using the app **you send data to OpenRouter and their partner model providers** (OpenAI, Anthropic, Google, Meta, Mistral AI, etc.). **Therefore, strictly only use non-sensitive data.** Again, stating the obvious: **LLMs make errors.** They regularly hallucinate, make things up, and get things wrong. They often do so in subtle, non-obvious ways that may be hard to detect. This app is **meant to be used as an assistive system**. It **only yields a draft that you always must check.** 147 | 148 | **At the time of writing, many users in our administration have extensively used the app with many thousands of texts over more than a year and a half. The results are very promising.** With the prototype app, our experts have saved time, improved their output, and made public communication more inclusive. 149 | 150 | > [!Note] 151 | > This **app is optimized for Swiss German** («Swiss High German», not dialect). Some rules in the prompts steer the models toward this. Also, the app is **set up to use the Swiss `ss` rather than the German `ß`.** The understandability index assumes the Swiss `ss` for the common word scoring, and we replace `ß` with `ss` in the results. 152 | 153 | ### What does it cost? 154 | 155 | **Usage is inexpensive**. You only pay OpenRouter (or OpenAI) for the tokens that you use. OpenRouter provides transparent, competitive pricing for all models. E.g., for the simplification of 100 separate [«Normseiten»](https://de.wikipedia.org/wiki/Normseite) (standard pages of 250 German words each) to Einfache Sprache or Leichte Sprache, you pay depending on the model—roughly between 0.5 CHF for faster models and around 5-10 CHF for premium models like Claude Opus. Check [OpenRouter pricing](https://openrouter.ai/models) for current rates. The hardware requirements to run the app are modest too. As mentioned above, a small VM for a couple of Francs per month will suffice. 156 | 157 | ### Our language guidelines 158 | 159 | You can find the current rules that are being prompted in `utils_prompts.py`. Have a look and change these according to your needs and organizational communication guidelines. 160 | 161 | We derived the current rules in the prompts mainly from these of our language guidelines: 162 | 163 | - [General language guidelines zh.ch](https://www.zh.ch/de/webangebote-entwickeln-und-gestalten/inhalt/inhalte-gestalten/informationen-bereitstellen/umgang-mit-sprache.html) 164 | - [Language guidelines Leichte Sprache](https://www.zh.ch/de/webangebote-entwickeln-und-gestalten/inhalt/barrierefreiheit/regeln-fuer-leichte-sprache.html) 165 | - [Guidelines Strassenverkehrsamt](https://www.zh.ch/content/dam/zhweb/bilder-dokumente/themen/politik-staat/teilhabe/erfolgsbeispiele-teilhabe/Sprachleitfaden_Strassenverkehrsamt_Maerz_2022.pdf) 166 | 167 | ### A couple of findings 168 | 169 | - **Large Language Models (LLMs) already have an understanding of Einfache Sprache, Leichte Sprache, and CEFR levels** ([A1, A2, B1, etc.](https://www.goethe.de/de/spr/kur/stu.html)) from their pretraining. It's impressive how well they can translate text by simply being asked to rewrite it according to these terms or levels. We have also successfully created test data by asking models to e.g. describe a situation at each of the six CEFR levels (A1 to C2). 170 | - **LLMs produce varied rewrites, which is beneficial**. By offering multiple model options through OpenRouter, users receive a range of suggestions, helping them achieve a good result. It's often effective to use the One-Click mode, which consolidates results from all configured models. 171 | - **Measuring text understandability is really helpful**. Early in our project, we realized the need for a quantitative metric to evaluate our outputs, such as comparing different prompts, models, and preprocessing steps. We developed an index for this purpose that we call the «Zürcher Verständlichkeits-Index» or «ZIX» 😉. We created the ZIX using a dataset of complex legal and administrative texts, as well as many samples of Einfache and Leichte Sprache. We trained a classification model to differentiate between complex and simple texts. The ZIX as a metric has been very useful to us in practice. We have published the code and the Python package [here](https://github.com/machinelearningZH/zix_understandability-index). 172 | - Finally, **validating your results with your target audience is crucial**, especially for Leichte Sprache, which requires expert and user validation to be effective. 173 | 174 | ### How does the understandability score work? 175 | 176 | - The score takes into account sentence lengths, the [readability metric RIX](https://hlasse.github.io/TextDescriptives/readability.html), the occurrence of common words, and overlap with the standard CEFR vocabularies A1, A2, and B1. 177 | - At the moment, the score does **not** take into account other language properties that are essential for, e.g., [Einfache Sprache](https://de.wikipedia.org/wiki/Einfache_Sprache) (B1 or easier, similar to «Plain English») or [Leichte Sprache](https://de.wikipedia.org/wiki/Leichte_Sprache) (A2, A1, similar to «Easy English»), like use of passive voice, subjunctives, negations, etc. 178 | 179 | We have published the ZIX understandability index as a pip installable package. You can find it [here](https://github.com/machinelearningZH/zix_understandability-index). 180 | 181 | > [!Note] 182 | > The index is slightly adjusted to Swiss German. Specifically, we use `ss` instead of `ß` in our vocabulary lists. In practice, this should not make a big difference. For High German text that actually contains `ß`, the index will likely underestimate the understandability slightly with a difference of around 0.1. 183 | 184 | ### What does the score mean? 185 | 186 | - **Negative scores indicate difficult texts in the range of B2 to C2**. These texts will likely be **very hard to understand for many people** (this is classic «Behördendeutsch» or legal text territory...). 187 | - **Positive scores indicate a language level of B1 or easier**. 188 | 189 | ![](_imgs/zix_scores.jpg) 190 | 191 | ## Project Team 192 | 193 | This project is a collaborative effort by these people from the cantonal administration of Zurich: 194 | 195 | - **Simone Luchetta, Roger Zedi** - [Team Informationszugang & Dialog, Staatskanzlei](https://www.zh.ch/de/staatskanzlei/digitale-verwaltung/team.html) 196 | - **Emek Sahin, Peter Hotz** - [Team Kommunikation & Entwicklung, Strassenverkehrsamt](https://www.zh.ch/de/sicherheitsdirektion/strassenverkehrsamt.html) 197 | - **Roger Meier** - [Generalsekretariat, Direktion der Justiz und des Inneren](https://www.zh.ch/de/direktion-der-justiz-und-des-innern/generalsekretariat.html#2092000119) 198 | - **Matthias Mazenauer** - [Co-Leiter, Statistisches Amt](https://www.zh.ch/de/direktion-der-justiz-und-des-innern/statistisches-amt/amtsleitung.html) 199 | - **Marisol Keller, Céline Colombo** - [Koordinationsstelle Teilhabe, Statistisches Amt](https://www.zh.ch/de/politik-staat/teilhabe.html) 200 | - **Patrick Arnecke, Chantal Amrhein, Dominik Frefel** - [Team Data, Statistisches Amt](https://www.zh.ch/de/direktion-der-justiz-und-des-innern/statistisches-amt/data.html) 201 | 202 | Special thanks to [**Government Councillor Jacqueline Fehr**](https://www.zh.ch/en/direktion-der-justiz-und-des-innern/regierungsraetin-jacqueline-fehr.html) for initiating and supporting the project. 203 | 204 | ## Feedback and Contributing 205 | 206 | We welcome feedback and contributions! [Email us](mailto:datashop@statistik.zh.ch) or open an issue or pull request. 207 | 208 | We use [`ruff`](https://docs.astral.sh/ruff/) for linting and formatting. 209 | 210 | ## License 211 | 212 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 213 | 214 | ## Miscellaneous 215 | 216 | - Thanks to [LIIP](https://www.liip.ch/en) for refactoring the understandability index as an [API](https://github.com/chregu/simply-understandability-score) and [webservice](https://u15y.gpt.liip.ch/). 217 | - Special shoutout to [Christian Stocker](https://www.linkedin.com/in/chregu/). 218 | - Thanks to [Florian Georg](https://www.linkedin.com/in/fgeorg/) (Microsoft Switzerland) for help integrating with [Azure AI](https://azure.microsoft.com/en-us/solutions/ai). 219 | 220 | ## Disclaimer 221 | 222 | This software (the Software) incorporates open-source models from [Spacy](https://spacy.io/models) and uses LLMs from various providers (Model(s)). This software has been developed according to and with the intent to be used under Swiss law. Please be aware that the EU Artificial Intelligence Act (EU AI Act) may, under certain circumstances, be applicable to your use of the Software. You are solely responsible for ensuring that your use of the Software as well as of the underlying Models complies with all applicable local, national and international laws and regulations. By using this Software, you acknowledge and agree (a) that it is your responsibility to assess which laws and regulations, in particular regarding the use of AI technologies, are applicable to your intended use and to comply therewith, and (b) that you will hold us harmless from any action, claims, liability or loss in respect of your use of the Software. 223 | -------------------------------------------------------------------------------- /_streamlit_app/utils_prompts.py: -------------------------------------------------------------------------------- 1 | # We derived the following prompts for «Einfache Sprache» (ES) and «Leichte Sprache» (LS) mainly from our guidelines of the administration of the Canton of Zurich. According to our testing these are good defaults and prove to be helpful for our employees. However, we strongly recommend to validate and adjust these rules to the specific needs of your organization. 2 | 3 | # References: 4 | # https://www.zh.ch/de/webangebote-entwickeln-und-gestalten/inhalt/inhalte-gestalten/informationen-bereitstellen/umgang-mit-sprache.html 5 | # https://www.zh.ch/de/webangebote-entwickeln-und-gestalten/inhalt/barrierefreiheit/regeln-fuer-leichte-sprache.html 6 | # https://www.zh.ch/content/dam/zhweb/bilder-dokumente/themen/politik-staat/teilhabe/erfolgsbeispiele-teilhabe/Sprachleitfaden_Strassenverkehrsamt_Maerz_2022.pdf 7 | 8 | # Note that Anthropic recommends to put the content first and the prompt last. This is the opposite of what usually is the prompt structure for OpenAI models. 9 | # https://docs.anthropic.com/en/docs/long-context-window-tips#document-query-placement 10 | # Note also that Claude models prefer XML tags for structuring whereas OpenAI models prefer Markdown (used here) or JSON. 11 | # We use the Claude prompt structure for Mistral with good success. Feel free to adjust the structure to your needs. 12 | 13 | SAMPLE_TEXT = """Als Vernehmlassungsverfahren wird diejenige Phase innerhalb des Vorverfahrens der Gesetzgebung bezeichnet, in der Vorhaben des Bundes von erheblicher politischer, finanzieller, wirtschaftlicher, ökologischer, sozialer oder kultureller Tragweite auf ihre sachliche Richtigkeit, Vollzugstauglichkeit und Akzeptanz hin geprüft werden. 14 | 15 | Die Vorlage wird zu diesem Zweck den Kantonen, den in der Bundesversammlung vertretenen Parteien, den Dachverbänden der Gemeinden, Städte und der Berggebiete, den Dachverbänden der Wirtschaft sowie weiteren, im Einzelfall interessierten Kreisen unterbreitet.""" 16 | 17 | SYSTEM_MESSAGE_ES = """Du bist ein hilfreicher Assistent, der Texte in Einfache Sprache, Sprachniveau B1 bis A2, umschreibt. Sei immer wahrheitsgemäß und objektiv. Schreibe nur das, was du sicher aus dem Text des Benutzers weisst. Arbeite die Texte immer vollständig durch und kürze nicht. Mache keine Annahmen. Schreibe einfach und klar und immer in deutscher Sprache. Gib dein Ergebnis innerhalb von Tags aus.""" 18 | 19 | 20 | SYSTEM_MESSAGE_LS = """Du bist ein hilfreicher Assistent, der Texte in Leichte Sprache, Sprachniveau A2 bis A1, umschreibt. Sei immer wahrheitsgemäß und objektiv. Schreibe nur das, was du sicher aus dem Text des Benutzers weisst. Arbeite die Texte immer vollständig durch und kürze nicht. Mache keine Annahmen. Schreibe einfach und klar und immer in deutscher Sprache. Gib dein Ergebnis innerhalb von Tags aus.""" 21 | 22 | 23 | RULES_ES = """ 24 | - Schreibe kurze Sätze mit höchstens 12 Wörtern. 25 | - Beschränke dich auf eine Aussage, einen Gedanken pro Satz. 26 | - Verwende aktive Sprache anstelle von Passiv. 27 | - Formuliere grundsätzlich positiv und bejahend. 28 | - Strukturiere den Text übersichtlich mit kurzen Absätzen. 29 | - Verwende einfache, kurze, häufig gebräuchliche Wörter. 30 | - Wenn zwei Wörter dasselbe bedeuten, verwende das kürzere und einfachere Wort. 31 | - Vermeide Füllwörter und unnötige Wiederholungen. 32 | - Erkläre Fachbegriffe und Fremdwörter. 33 | - Schreibe immer einfach, direkt und klar. Vermeide komplizierte Konstruktionen und veraltete Begriffe. Vermeide «Behördendeutsch». 34 | - Benenne Gleiches immer gleich. Verwende für denselben Begriff, Gegenstand oder Sachverhalt immer dieselbe Bezeichnung. Wiederholungen von Begriffen sind in Texten in Einfacher Sprache normal. 35 | - Vermeide Substantivierungen. Verwende stattdessen Verben und Adjektive. 36 | - Vermeide Adjektive und Adverbien, wenn sie nicht unbedingt notwendig sind. 37 | - Wenn du vier oder mehr Wörter zusammensetzt, setzt du Bindestriche. Beispiel: «Motorfahrzeug-Ausweispflicht». 38 | - Achte auf die sprachliche Gleichbehandlung von Mann und Frau. Verwende immer beide Geschlechter oder schreibe geschlechtsneutral. 39 | - Vermeide Abkürzungen grundsätzlich. Schreibe stattdessen die Wörter aus. Z.B. «10 Millionen» statt «10 Mio.», «200 Kilometer pro Stunde» statt «200 km/h», «zum Beispiel» statt «z.B.», «30 Prozent» statt «30 %», «2 Meter» statt «2 m», «das heisst» statt «d.h.». 40 | - Vermeide das stumme «e» am Wortende, wenn es nicht unbedingt notwendig ist. Zum Beispiel: «des Fahrzeugs» statt «des Fahrzeuges». 41 | - Verwende immer französische Anführungszeichen (« ») anstelle von deutschen Anführungszeichen („ “). 42 | - Gliedere Telefonnummern mit vier Leerzeichen. Z.B. 044 123 45 67. Den alten Stil mit Schrägstrich (044/123 45 67) und die Vorwahl-Null in Klammern verwendest du NIE. 43 | - Formatiere Datumsangaben immer so: 1. Januar 2022, 15. Februar 2022. 44 | - Jahreszahlen schreibst du immer vierstellig aus: 2022, 2025-2030. 45 | - Formatiere Zeitangaben immer «Stunden Punkt Minuten Uhr». Verwende keinen Doppelpunkt, um Stunden von Minuten zu trennen. Ergänze immer .00 bei vollen Stunden. Beispiele: 9.25 Uhr (NICHT 9:30), 10.30 Uhr (NICHT 10:00), 14.00 Uhr (NICHT 14 Uhr), 15.45 Uhr, 18.00 Uhr, 20.15 Uhr, 22.30 Uhr. 46 | - Zahlen bis 12 schreibst du aus. Ab 13 verwendest du Ziffern. 47 | - Fristen, Geldbeträge und physikalische Grössen schreibst du immer in Ziffern. 48 | - Zahlen, die zusammengehören, schreibst du immer in Ziffern. Beispiel: 5-10, 20 oder 30. 49 | - Grosse Zahlen ab 5 Stellen gliederst du in Dreiergruppen mit Leerzeichen. Beispiel: 1 000 000. 50 | - Achtung: Identifikationszahlen übernimmst du 1:1. Beispiel: Stammnummer 123.456.789, AHV-Nummer 756.1234.5678.90, Konto 01-100101-9. 51 | - Verwende das Komma, dass das deutsche Dezimalzeichen ist. Überflüssige Nullen nach dem Komma schreibst du nicht. Beispiel: 5,5 Millionen, 3,75 Prozent, 1,5 Kilometer, 2,25 Stunden. 52 | - Vor Franken-Rappen-Beträgen schreibst du immer «CHF». Nur nach ganzen Franken-Beträgen darfst du «Franken» schreiben. Bei Franken- Rappen-Beträgen setzt du einen Punkt als Dezimalzeichen. Anstatt des Null-Rappen-Strichs verwendest du «.00» oder lässt die Dezimalstellen weg. Z.B. 20 Franken, CHF 20, CHF 2.00, CHF 12.50, aber CHF 45,2 Millionen, EUR 14,90. 53 | - Die Anrede mit «Sie» schreibst du immer gross. Beispiel: «Sie haben». 54 | """.strip() 55 | 56 | 57 | RULES_LS = """ 58 | - Schreibe wichtiges zuerst: Beginne den Text mit den wichtigsten Informationen, so dass diese sofort klar werden. 59 | - Verwende einfache, kurze, häufig gebräuchliche Wörter. 60 | - Löse zusammengesetzte Wörter auf und formuliere sie neu. 61 | - Wenn es wichtige Gründe gibt, ein zusammengesetztes Wort nicht aufzulösen, trenne das zusammengesetzte Wort mit einem Bindestrich. Beginne dann jedes Wort mit einem Grossbuchstaben. Beispiele: «Auto-Service», «Gegen-Argument», «Kinder-Betreuung», «Volks-Abstimmung». 62 | - Vermeide Fremdwörter. Wähle stattdessen einfache, allgemein bekannte Wörter. Erkläre Fremdwörter, wenn sie unvermeidbar sind. 63 | - Vermeide Fachbegriffe. Wähle stattdessen einfache, allgemein bekannte Wörter. Erkläre Fachbegriffe, wenn sie unvermeidbar sind. 64 | - Vermeide bildliche Sprache. Verwende keine Metaphern oder Redewendungen. Schreibe stattdessen klar und direkt. 65 | - Schreibe kurze Sätze mit optimal 8 und höchstens 12 Wörtern. 66 | - Du darfst Relativsätze mit «der», «die», «das» verwenden. 67 | - Löse Nebensätze nach folgenden Regeln auf: 68 | - Kausalsätze (weil, da): Löse Kausalsätze als zwei Hauptsätze mit «deshalb» auf. 69 | - Konditionalsätze (wenn, falls): Löse Konditionalsätze als zwei Hauptsätze mit «vielleicht» auf. 70 | - Finalsätze (damit, dass): Löse Finalsätze als zwei Hauptsätze mit «deshalb» auf. 71 | - Konzessivsätze (obwohl, obgleich, wenngleich, auch wenn): Löse Konzessivsätze als zwei Hauptsätze mit «trotzdem» auf. 72 | - Temporalsätze (als, während, bevor, nachdem, sobald, seit): Löse Temporalsätze als einzelne chronologische Sätze auf. Wenn es passt, verknüpfe diese mit «dann». 73 | - Adversativsätze (aber, doch, jedoch, allerdings, sondern, allein): Löse Adversativsätze als zwei Hauptsätze mit «aber» auf. 74 | - Modalsätze (indem, dadurch dass): Löse Modalsätze als zwei Hauptsätze auf. Z.B. Alltagssprache: Er lernt besser, indem er regelmässig übt. Leichte Sprache: Er lernt besser. Er übt regelmässig. 75 | - Konsekutivsätze (so dass, sodass): Löse Konsekutivsätze als zwei Hauptsätze auf. Z.B. Alltagssprache: Er ist krank, sodass er nicht arbeiten konnte. Leichte Sprache: Er ist krank. Er konnte nicht arbeiten. 76 | - Relativsätze mit «welcher», «welche», «welches»: Löse solche Relativsätze als zwei Hauptsätze auf. Z.B. Alltagssprache: Das Auto, welches rot ist, steht vor dem Haus. Leichte Sprache: Das Auto ist rot. Das Auto steht vor dem Haus. 77 | - Ob-Sätze: Schreibe Ob-Sätze als zwei Hauptsätze. Z.B. Alltagssprache: Er fragt, ob es schönes Wetter wird. Leichte Sprache: Er fragt: Wird es schönes Wetter? 78 | - Verwende aktive Sprache anstelle von Passiv. 79 | - Benutze den Genitiv nur in einfachen Fällen. Verwende stattdessen die Präposition "von" und den Dativ. 80 | - Vermeide das stumme «e» am Wortende, wenn es nicht unbedingt notwendig ist. Zum Beispiel: «des Fahrzeugs» statt «des Fahrzeuges». 81 | - Bevorzuge die Vorgegenwart (Perfekt). Vermeide die Vergangenheitsform (Präteritum), wenn möglich. Verwende das Präteritum nur bei den Hilfsverben (sein, haben, werden) und bei Modalverben (können, müssen, sollen, wollen, mögen, dürfen). 82 | - Benenne Gleiches immer gleich. Verwende für denselben Begriff, Gegenstand oder Sachverhalt immer dieselbe Bezeichnung. Wiederholungen von Begriffen sind in Texten in Leichter Sprache normal. 83 | - Vermeide Pronomen. Verwende Pronomen nur, wenn der Bezug ganz klar ist. Sonst wiederhole das Nomen. 84 | - Formuliere grundsätzlich positiv und bejahend. Vermeide Verneinungen ganz. 85 | - Verwende IMMER die Satzstellung Subjekt-Prädikat-Objekt. 86 | - Vermeide Substantivierungen. Verwende stattdessen Verben und Adjektive. 87 | - Achte auf die sprachliche Gleichbehandlung von Mann und Frau. Verwende immer beide Geschlechter oder schreibe geschlechtsneutral. 88 | - Vermeide Abkürzungen grundsätzlich. Schreibe stattdessen die Wörter aus. Z.B. «10 Millionen» statt «10 Mio.», «200 Kilometer pro Stunde» statt «200 km/h», «zum Beispiel» statt «z.B.», «30 Prozent» statt «30 %», «2 Meter» statt «2 m», «das heisst» statt «d.h.». Je nach Kontext kann es aber sinnvoll sein, eine Abkürzung einzuführen. Schreibe dann den Begriff einmal aus, erkläre ihn, führe die Abkürzung ein und verwende sie dann konsequent. 89 | - Schreibe die Abkürzungen «usw.», «z.B.», «etc.» aus. Also zum Beispiel «und so weiter», «zum Beispiel», «etcetera». 90 | - Formatiere Zeitangaben immer «Stunden Punkt Minuten Uhr». Verwende keinen Doppelpunkt, um Stunden von Minuten zu trennen. Ergänze immer .00 bei vollen Stunden. Beispiele: 9.25 Uhr (NICHT 9:30), 10.30 Uhr (NICHT 10:00), 14.00 Uhr (NICHT 14 Uhr), 15.45 Uhr, 18.00 Uhr, 20.15 Uhr, 22.30 Uhr. 91 | - Formatiere Datumsangaben immer so: 1. Januar 2022, 15. Februar 2022. 92 | - Jahreszahlen schreibst du immer vierstellig aus: 2022, 2025-2030. 93 | - Verwende immer französische Anführungszeichen (« ») anstelle von deutschen Anführungszeichen („ “). 94 | - Gliedere Telefonnummern mit vier Leerzeichen. Z.B. 044 123 45 67. Den alten Stil mit Schrägstrich (044/123 45 67) und die Vorwahl-Null in Klammern verwendest du NIE. 95 | - Zahlen bis 12 schreibst du aus. Ab 13 verwendest du Ziffern. 96 | - Fristen, Geldbeträge und physikalische Grössen schreibst du immer in Ziffern. 97 | - Zahlen, die zusammengehören, schreibst du immer in Ziffern. Beispiel: 5-10, 20 oder 30. 98 | - Grosse Zahlen ab 5 Stellen gliederst du in Dreiergruppen mit Leerzeichen. Beispiel: 1 000 000. 99 | - Achtung: Identifikationszahlen übernimmst du 1:1. Beispiel: Stammnummer 123.456.789, AHV-Nummer 756.1234.5678.90, Konto 01-100101-9. 100 | - Verwende das Komma, dass das deutsche Dezimalzeichen ist. Überflüssige Nullen nach dem Komma schreibst du nicht. Beispiel: 5 Millionen, 3,75 Prozent, 1,5 Kilometer, 2,25 Stunden. 101 | - Vor Franken-Rappen-Beträgen schreibst du immer «CHF». Nur nach ganzen Franken-Beträgen darfst du «Franken» schreiben. Bei Franken-Rappen-Beträgen setzt du einen Punkt als Dezimalzeichen. Anstatt des Null-Rappen-Strichs verwendest du «.00» oder lässt die Dezimalstellen weg. Z.B. 20 Franken, CHF 20, CHF 2.00, CHF 12.50, aber CHF 45,2 Millionen, EUR 14,90. 102 | - Die Anrede mit «Sie» schreibst du immer gross. Beispiel: «Sie haben». 103 | - Strukturiere den Text. Gliedere in sinnvolle Abschnitte und Absätze. Verwende Titel und Untertitel grosszügig, um den Text zu gliedern. Es kann hilfreich sein, wenn diese als Frage formuliert sind. 104 | - Stelle Aufzählungen als Liste dar. 105 | - Zeilenumbrüche helfen, Sinneinheiten zu bilden und erleichtern das Lesen. Füge deshalb nach Haupt- und Nebensätzen sowie nach sonstigen Sinneinheiten Zeilenumbrüche ein. Eine Sinneinheit soll maximal 8 Zeilen umfassen. 106 | - Eine Textzeile enthält inklusiv Leerzeichen maximal 85 Zeichen. 107 | """.strip() 108 | 109 | 110 | REWRITE_COMPLETE = """- Achte immer sehr genau darauf, dass ALLE Informationen aus dem schwer verständlichen Text in deinem verständlicheren Text enthalten sind. Kürze niemals Informationen. Wo sinnvoll kannst du zusätzliche Beispiele hinzufügen, um den Text verständlicher zu machen und relevante Inhalte zu konkretisieren.""" 111 | 112 | 113 | REWRITE_CONDENSED = """- Konzentriere dich auf das Wichtigste. Gib die essenziellen Informationen wieder und lass den Rest weg.""" 114 | 115 | 116 | CLAUDE_TEMPLATE_ES = """ 117 | Hier ist ein schwer verständlicher Text, den du vollständig in Einfache Sprache, Sprachniveau B1 bis A2, umschreiben sollst: 118 | 119 | 120 | {prompt} 121 | 122 | 123 | Bitte lies den Text sorgfältig durch und schreibe ihn vollständig in Einfache Sprache um. 124 | 125 | Beachte dabei folgende Regeln: 126 | 127 | {completeness} 128 | {rules} 129 | 130 | Formuliere den Text jetzt in Einfache Sprache, Sprachniveau B1 bis A2, um. Schreibe den vereinfachten Text innerhalb von Tags. 131 | """.strip() 132 | 133 | TEMPLATE_ES = """ 134 | Du bekommst einen schwer verständlichen Text, den du vollständig in Einfache Sprache auf Sprachniveau B1 bis A2 umschreiben sollst. 135 | 136 | Beachte dabei folgende Regeln: 137 | 138 | {completeness} 139 | {rules} 140 | 141 | Schreibe den vereinfachten Text innerhalb von Tags. Gib nur Text aus, keine Markdown-Formatierung, kein HTML. 142 | 143 | Hier ist der schwer verständliche Text: 144 | 145 | -------------------------------------------------------------------------------- 146 | 147 | {prompt} 148 | """.strip() 149 | 150 | TEMPLATE_LS = """ 151 | Du bekommst einen schwer verständlichen Text, den du vollständig in Leichte Sprache auf Sprachniveau A2 bis A1 umschreiben sollst. 152 | 153 | Beachte dabei folgende Regeln: 154 | 155 | {completeness} 156 | {rules} 157 | 158 | Schreibe den vereinfachten Text innerhalb von Tags. Gib nur Text aus, keine Markdown-Formatierung, kein HTML. 159 | 160 | Hier ist der schwer verständliche Text: 161 | 162 | -------------------------------------------------------------------------------- 163 | 164 | {prompt} 165 | """.strip() 166 | 167 | TEMPLATE_ANALYSIS_ES = """ 168 | Du bekommst einen schwer verständlichen Text, den du genau analysieren sollst. 169 | 170 | Analysiere den schwer verständlichen Text Satz für Satz. Beschreibe genau und detailliert, was sprachlich nicht gut bei jedem Satz ist. Analysiere was ich tun müsste, damit der Text zu Einfache Sprache (B1 bis A2) wird. Gib klare Hinweise, wie ich den Text besser verständlich machen kann. Gehe bei deiner Analyse Schritt für Schritt vor. 171 | 172 | 1. Wiederhole den Satz. 173 | 2. Analysiere den Satz auf seine Verständlichkeit. Was muss ich tun, damit der Satz verständlicher wird? Wie kann ich den Satz in Einfache Sprache formulieren? 174 | 3. Mache einen Vorschlag für einen vereinfachten Satz. 175 | 176 | Befolge diesen Ablauf von Anfang bis Ende, auch wenn der schwer verständliche Text sehr lang ist. 177 | 178 | Die Regeln für Einfache Sprache sind diese hier: 179 | 180 | {rules} 181 | 182 | Schreibe deine Analyse innerhalb von Tags. Gib nur Text aus, keine Markdown-Formatierung, kein HTML. 183 | 184 | Hier ist der schwer verständliche Text: 185 | 186 | -------------------------------------------------------------------------------- 187 | 188 | {prompt} 189 | """.strip() 190 | 191 | TEMPLATE_ANALYSIS_LS = """ 192 | Du bekommst einen schwer verständlichen Text, den du genau analysieren sollst. 193 | 194 | Analysiere den schwer verständlichen Text Satz für Satz. Beschreibe genau und detailliert, was sprachlich nicht gut bei jedem Satz ist. Analysiere was ich tun müsste, damit der Text zu Leichte Sprache (A2 bis A1) wird. Gib klare Hinweise, wie ich den Text besser verständlich machen kann. Gehe bei deiner Analyse Schritt für Schritt vor. 195 | 196 | 1. Wiederhole den Satz. 197 | 2. Analysiere den Satz auf seine Verständlichkeit. Was muss ich tun, damit der Satz verständlicher wird? Wie kann ich den Satz in Leichte Sprache formulieren? 198 | 3. Mache einen Vorschlag für einen vereinfachten Satz. 199 | 200 | Befolge diesen Ablauf von Anfang bis Ende, auch wenn der schwer verständliche Text sehr lang ist. 201 | 202 | Die Regeln für Leichte Sprache sind diese hier: 203 | 204 | {rules} 205 | 206 | Schreibe deine Analyse innerhalb von Tags. Gib nur Text aus, keine Markdown-Formatierung, kein HTML. 207 | 208 | Hier ist der schwer verständliche Text: 209 | 210 | -------------------------------------------------------------------------------- 211 | 212 | {prompt} 213 | """.strip() 214 | -------------------------------------------------------------------------------- /_streamlit_app/sprache-vereinfachen.py: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------------------- 2 | # Imports 3 | 4 | import streamlit as st 5 | 6 | st.set_page_config(layout="wide") 7 | 8 | import os 9 | import re 10 | from datetime import datetime 11 | import time 12 | import base64 13 | from docx import Document 14 | from docx.shared import Pt, Inches 15 | import io 16 | from concurrent.futures import ThreadPoolExecutor 17 | from dotenv import load_dotenv 18 | import yaml 19 | 20 | import logging 21 | 22 | logging.basicConfig( 23 | filename="app.log", 24 | datefmt="%d-%b-%y %H:%M:%S", 25 | level=logging.WARNING, 26 | ) 27 | 28 | import numpy as np 29 | from openai import OpenAI 30 | from zix.understandability import get_zix, get_cefr 31 | from utils_prompts import ( 32 | SAMPLE_TEXT, 33 | SYSTEM_MESSAGE_ES, 34 | SYSTEM_MESSAGE_LS, 35 | RULES_ES, 36 | RULES_LS, 37 | REWRITE_COMPLETE, 38 | REWRITE_CONDENSED, 39 | TEMPLATE_ES, 40 | TEMPLATE_LS, 41 | TEMPLATE_ANALYSIS_ES, 42 | TEMPLATE_ANALYSIS_LS, 43 | ) 44 | 45 | # --------------------------------------------------------------- 46 | # Constants 47 | 48 | load_dotenv(override=True) 49 | 50 | API_KEYS = { 51 | "OPENROUTER": os.getenv("OPENROUTER_API_KEY"), 52 | } 53 | 54 | 55 | @st.cache_resource 56 | def load_config(): 57 | """Load configuration from YAML file.""" 58 | config_path = os.path.join(os.path.dirname(__file__), "..", "config.yaml") 59 | with open(config_path, "r", encoding="utf-8") as file: 60 | return yaml.safe_load(file) 61 | 62 | 63 | # Load configuration 64 | config = load_config() 65 | 66 | # Create model dictionaries from config 67 | MODEL_IDS = {model["name"]: model["id"] for model in config["models"]} 68 | MODEL_NAMES = list(MODEL_IDS.keys()) 69 | 70 | # Get configuration values from config 71 | TEMPERATURE = config["api"]["temperature"] 72 | MAX_TOKENS = config["api"]["max_tokens"] 73 | TEXT_AREA_HEIGHT = config["ui"]["text_area_height"] 74 | MAX_CHARS_INPUT = config["ui"]["max_chars_input"] 75 | USER_WARNING = f"{config['ui']['user_warning']}" 76 | 77 | # Document formatting constants 78 | FONT_WORDDOC = config["document"]["font_name"] 79 | FONT_SIZE_HEADING = config["document"]["font_size_heading"] 80 | FONT_SIZE_PARAGRAPH = config["document"]["font_size_paragraph"] 81 | FONT_SIZE_FOOTER = config["document"]["font_size_footer"] 82 | DEFAULT_OUTPUT_FILENAME = config["document"]["default_output_filename"] 83 | ANALYSIS_FILENAME = config["document"]["analysis_filename"] 84 | 85 | # Understandability limits 86 | LIMIT_HARD = config["understandability"]["limit_hard"] 87 | LIMIT_MEDIUM = config["understandability"]["limit_medium"] 88 | 89 | DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" 90 | 91 | # --------------------------------------------------------------- 92 | # Functions 93 | 94 | 95 | @st.cache_resource 96 | def get_project_info(): 97 | """Get markdown for project information that is shown in the expander section at the top of the app.""" 98 | with open("utils_expander.md") as f: 99 | return f.read() 100 | 101 | 102 | @st.cache_resource 103 | def create_project_info(project_info): 104 | """Create expander for project info. Add the image in the middle of the content.""" 105 | with st.expander("Detaillierte Informationen zum Projekt"): 106 | project_info = project_info.split("ADD_IMAGE_HERE") 107 | st.markdown(project_info[0], unsafe_allow_html=True) 108 | st.image("zix_scores.jpg", width="content") 109 | st.markdown(project_info[1], unsafe_allow_html=True) 110 | 111 | 112 | def create_prompt(text, analysis): 113 | """Create prompt and system message according the app settings.""" 114 | if analysis: 115 | final_prompt = ( 116 | TEMPLATE_ANALYSIS_LS.format(rules=RULES_LS, prompt=text) 117 | if leichte_sprache 118 | else TEMPLATE_ANALYSIS_ES.format(rules=RULES_ES, prompt=text) 119 | ) 120 | system = SYSTEM_MESSAGE_LS if leichte_sprache else SYSTEM_MESSAGE_ES 121 | else: 122 | if leichte_sprache: 123 | completeness = REWRITE_CONDENSED if condense_text else REWRITE_COMPLETE 124 | final_prompt = TEMPLATE_LS.format( 125 | rules=RULES_LS, completeness=completeness, prompt=text 126 | ) 127 | system = SYSTEM_MESSAGE_LS 128 | else: 129 | final_prompt = TEMPLATE_ES.format( 130 | rules=RULES_ES, completeness=REWRITE_COMPLETE, prompt=text 131 | ) 132 | system = SYSTEM_MESSAGE_ES 133 | return final_prompt, system 134 | 135 | 136 | def get_result_from_response(response): 137 | """Extract text between tags from response.""" 138 | tag = "leichtesprache" if leichte_sprache else "einfachesprache" 139 | result = re.findall(rf"<{tag}>(.*?)", response, re.DOTALL) 140 | return "\n".join(result).strip() 141 | 142 | 143 | def strip_markdown(text): 144 | """Strip markdown from text.""" 145 | # Remove markdown headers. 146 | text = re.sub(r"#+\s", "", text) 147 | # Remove markdown italic and bold. 148 | text = re.sub(r"\*\*|\*|__|_", "", text) 149 | return text 150 | 151 | 152 | @st.cache_resource 153 | def get_openrouter_client(): 154 | return OpenAI( 155 | base_url="https://openrouter.ai/api/v1", 156 | api_key=API_KEYS["OPENROUTER"], 157 | ) 158 | 159 | 160 | def invoke_model( 161 | text, 162 | model_id, 163 | analysis=False, 164 | ): 165 | """Invoke any model through OpenRouter.""" 166 | final_prompt, system = create_prompt(text, analysis) 167 | 168 | try: 169 | message = openrouter_client.chat.completions.create( 170 | model=model_id, 171 | temperature=TEMPERATURE, 172 | max_tokens=MAX_TOKENS, 173 | messages=[ 174 | {"role": "system", "content": system}, 175 | {"role": "user", "content": final_prompt}, 176 | ], 177 | ) 178 | message = message.choices[0].message.content.strip() 179 | message = get_result_from_response(message) 180 | message = strip_markdown(message) 181 | return True, message 182 | except Exception as e: 183 | print(f"Error: {e}") 184 | return False, e 185 | 186 | 187 | def enter_sample_text(): 188 | """Enter sample text into the text input in the left column.""" 189 | st.session_state.key_textinput = SAMPLE_TEXT 190 | 191 | 192 | def get_one_click_results(): 193 | with ThreadPoolExecutor(max_workers=len(MODEL_IDS)) as executor: 194 | futures = { 195 | name: executor.submit( 196 | invoke_model, 197 | st.session_state.key_textinput, 198 | model_id, 199 | ) 200 | for name, model_id in MODEL_IDS.items() 201 | } 202 | 203 | responses = {name: future.result() for name, future in futures.items()} 204 | response_texts = [] 205 | 206 | # We add 0 to the rounded ZIX score to avoid -0. 207 | # https://stackoverflow.com/a/11010791/7117003 208 | for name, (success, response) in responses.items(): 209 | if success: 210 | zix = get_zix(response) 211 | zix = int(np.round(zix, 0) + 0) 212 | cefr = get_cefr(zix) 213 | response_texts.append( 214 | f"\n----- Ergebnis von {name} (Verständlichkeit: {zix}, Niveau etwa {cefr}) -----\n\n{response}" 215 | ) 216 | 217 | if not response_texts: 218 | return False, "Es ist ein Fehler aufgetreten." 219 | return True, "\n\n\n".join(response_texts) 220 | 221 | 222 | def create_download_link(text_input, response, analysis=False): 223 | """Create a downloadable Word document and download link of the results.""" 224 | document = Document() 225 | 226 | h1 = document.add_heading("Ausgangstext") 227 | p1 = document.add_paragraph("\n" + text_input) 228 | 229 | if analysis: 230 | h2 = document.add_heading(f"Analyse von Sprachmodell {model_choice}") 231 | elif do_one_click: 232 | h2 = document.add_heading("Vereinfachte Texte von Sprachmodellen") 233 | else: 234 | h2 = document.add_heading("Vereinfachter Text von Sprachmodell") 235 | 236 | p2 = document.add_paragraph(response) 237 | 238 | timestamp = datetime.now().strftime(DATETIME_FORMAT) 239 | models_used = model_choice 240 | if do_one_click: 241 | models_used = ", ".join(MODEL_NAMES) 242 | footer = document.sections[0].footer 243 | footer.paragraphs[ 244 | 0 245 | ].text = f"Erstellt am {timestamp} mit der Prototyp-App «Einfache Sprache», Statistisches Amt, Kanton Zürich.\nSprachmodell(e): {models_used}\nVerarbeitungszeit: {time_processed:.1f} Sekunden" 246 | 247 | # Set font for all paragraphs. 248 | for paragraph in document.paragraphs: 249 | for run in paragraph.runs: 250 | run.font.name = FONT_WORDDOC 251 | 252 | # Set font size for all headings. 253 | for paragraph in [h1, h2]: 254 | for run in paragraph.runs: 255 | run.font.size = Pt(FONT_SIZE_HEADING) 256 | 257 | # Set font size for all paragraphs. 258 | for paragraph in [p1, p2]: 259 | for run in paragraph.runs: 260 | run.font.size = Pt(FONT_SIZE_PARAGRAPH) 261 | 262 | # Set font and font size for footer. 263 | for run in footer.paragraphs[0].runs: 264 | run.font.name = "Arial" 265 | run.font.size = Pt(FONT_SIZE_FOOTER) 266 | 267 | section = document.sections[0] 268 | section.page_width = Inches(8.27) # Width of A4 paper in inches 269 | section.page_height = Inches(11.69) # Height of A4 paper in inches 270 | 271 | io_stream = io.BytesIO() 272 | document.save(io_stream) 273 | 274 | b64 = base64.b64encode(io_stream.getvalue()) 275 | file_name = DEFAULT_OUTPUT_FILENAME 276 | 277 | if do_one_click: 278 | caption = "Vereinfachte Texte herunterladen" 279 | else: 280 | caption = "Vereinfachten Text herunterladen" 281 | 282 | if analysis: 283 | file_name = ANALYSIS_FILENAME 284 | caption = "Analyse herunterladen" 285 | download_url = f'{caption}' 286 | st.markdown(download_url, unsafe_allow_html=True) 287 | 288 | 289 | def clean_log(text): 290 | """Remove linebreaks and tabs from log messages 291 | that otherwise would yield problems when parsing the logs.""" 292 | return text.replace("\n", " ").replace("\t", " ") 293 | 294 | 295 | def log_event( 296 | text, 297 | response, 298 | do_analysis, 299 | do_simplification, 300 | do_one_click, 301 | leichte_sprache, 302 | model_choice, 303 | time_processed, 304 | success, 305 | ): 306 | """Log event.""" 307 | log_string = f"{datetime.now().strftime(DATETIME_FORMAT)}" 308 | log_string += f"\t{clean_log(text)}" 309 | log_string += f"\t{clean_log(response)}" 310 | log_string += f"\t{do_analysis}" 311 | log_string += f"\t{do_simplification}" 312 | log_string += f"\t{do_one_click}" 313 | log_string += f"\t{leichte_sprache}" 314 | log_string += f"\t{model_choice}" 315 | log_string += f"\t{time_processed:.3f}" 316 | log_string += f"\t{success}" 317 | 318 | logging.warning(log_string) 319 | 320 | 321 | # --------------------------------------------------------------- 322 | # Main 323 | 324 | openrouter_client = get_openrouter_client() 325 | project_info = get_project_info() 326 | 327 | # Persist text input across sessions in session state. 328 | # Otherwise, the text input sometimes gets lost when the user clicks on a button. 329 | if "key_textinput" not in st.session_state: 330 | st.session_state.key_textinput = "" 331 | 332 | st.markdown("## 🙋‍♀️ Sprache einfach vereinfachen") 333 | create_project_info(project_info) 334 | st.caption(USER_WARNING, unsafe_allow_html=True) 335 | st.markdown("---") 336 | 337 | # Set up first row with all buttons and settings. 338 | button_cols = st.columns([1, 1, 1, 2]) 339 | with button_cols[0]: 340 | st.button( 341 | "Beispiel einfügen", 342 | on_click=enter_sample_text, 343 | width="stretch", 344 | type="secondary", 345 | help="Fügt einen Beispieltext ein.", 346 | ) 347 | do_analysis = st.button( 348 | "Analysieren", 349 | width="stretch", 350 | help="Analysiert deinen Ausgangstext Satz für Satz.", 351 | ) 352 | with button_cols[1]: 353 | do_simplification = st.button( 354 | "Vereinfachen", 355 | width="stretch", 356 | help="Vereinfacht deinen Ausgangstext.", 357 | ) 358 | do_one_click = st.button( 359 | "🚀 One-Klick", 360 | width="stretch", 361 | help="Schickt deinen Ausgangstext gleichzeitig an alle Modelle.", 362 | ) 363 | with button_cols[2]: 364 | leichte_sprache = st.toggle( 365 | "Leichte Sprache", 366 | value=False, 367 | help="**Schalter aktiviert**: «Leichte Sprache». **Schalter nicht aktiviert**: «Einfache Sprache».", 368 | ) 369 | if leichte_sprache: 370 | condense_text = st.toggle( 371 | "Text verdichten", 372 | value=True, 373 | help="**Schalter aktiviert**: Modell konzentriert sich auf essentielle Informationen und versucht, Unwichtiges wegzulassen. **Schalter nicht aktiviert**: Modell versucht, alle Informationen zu übernehmen.", 374 | ) 375 | with button_cols[3]: 376 | model_choice = st.radio( 377 | label="Sprachmodell", 378 | options=MODEL_NAMES, 379 | index=0, 380 | horizontal=True, 381 | ) 382 | 383 | # Instantiate empty containers for the text areas. 384 | cols = st.columns([2, 2, 1]) 385 | 386 | with cols[0]: 387 | source_text = st.container() 388 | with cols[1]: 389 | placeholder_result = st.empty() 390 | with cols[2]: 391 | placeholder_analysis = st.empty() 392 | 393 | # Populate containers. 394 | with source_text: 395 | st.text_area( 396 | "Ausgangstext, den du vereinfachen möchtest", 397 | value=None, 398 | height=TEXT_AREA_HEIGHT, 399 | max_chars=MAX_CHARS_INPUT, 400 | key="key_textinput", 401 | ) 402 | with placeholder_result: 403 | text_output = st.text_area( 404 | "Ergebnis", 405 | height=TEXT_AREA_HEIGHT, 406 | ) 407 | with placeholder_analysis: 408 | text_analysis = st.metric( 409 | label="Verständlichkeit -10 bis 10", 410 | value=None, 411 | delta=None, 412 | help="Verständlichkeit auf einer Skala von -10 bis 10 Punkten (von -10 = extrem schwer verständlich bis 10 = sehr gut verständlich). Texte in Einfacher Sprache haben meist einen Wert von 0 bis 4 oder höher, Texte in Leichter Sprache 2 bis 6 oder höher.", 413 | ) 414 | 415 | 416 | # Derive model_id from explicit model_choice. 417 | model_id = MODEL_IDS[model_choice] 418 | 419 | # Start processing if one of the processing buttons is clicked. 420 | if do_simplification or do_analysis or do_one_click: 421 | start_time = time.time() 422 | if st.session_state.key_textinput == "": 423 | st.error("Bitte gib einen Text ein.") 424 | st.stop() 425 | 426 | score_source = get_zix(st.session_state.key_textinput) 427 | # We add 0 to avoid negative zero. 428 | score_source_rounded = int(np.round(score_source, 0) + 0) 429 | cefr_source = get_cefr(score_source) 430 | 431 | # Analyze source text and display results. 432 | with source_text: 433 | if score_source < LIMIT_HARD: 434 | st.markdown( 435 | f"Dein Ausgangstext ist **:red[schwer verständlich]**. ({score_source_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:red[Sprachniveau {cefr_source}]**." 436 | ) 437 | elif score_source >= LIMIT_HARD and score_source < LIMIT_MEDIUM: 438 | st.markdown( 439 | f"Dein Ausgangstext ist **:orange[nur mässig verständlich]**. ({score_source_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:orange[Sprachniveau {cefr_source}]**." 440 | ) 441 | else: 442 | st.markdown( 443 | f"Dein Ausgangstext ist **:green[gut verständlich]**. ({score_source_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:green[Sprachniveau {cefr_source}]**." 444 | ) 445 | with placeholder_analysis.container(): 446 | text_analysis = st.metric( 447 | label="Verständlichkeit von -10 bis 10", 448 | value=score_source_rounded, 449 | delta=None, 450 | help="Verständlichkeit auf einer Skala von -10 bis 10 Punkten (von -10 = extrem schwer verständlich bis 10 = sehr gut verständlich). Texte in Einfacher Sprache haben meist einen Wert von 0 bis 4 oder höher, Texte in Leichter Sprache 2 bis 6 oder höher.", 451 | ) 452 | 453 | with placeholder_analysis.container(): 454 | with st.spinner("Ich arbeite..."): 455 | # One-click simplification. 456 | if do_one_click: 457 | success, response = get_one_click_results() 458 | # Regular text simplification or analysis 459 | else: 460 | success, response = invoke_model( 461 | st.session_state.key_textinput, 462 | model_id=model_id, 463 | analysis=do_analysis, 464 | ) 465 | 466 | if success is False: 467 | st.error( 468 | "Es ist ein Fehler bei der Abfrage der APIs aufgetreten. Bitte versuche es erneut. Alternativ überprüfe Code, API-Keys, Verfügbarkeit der Modelle und ggf. Internetverbindung." 469 | ) 470 | time_processed = time.time() - start_time 471 | log_event( 472 | st.session_state.key_textinput, 473 | "Error from model call", 474 | do_analysis, 475 | do_simplification, 476 | do_one_click, 477 | leichte_sprache, 478 | model_choice, 479 | time_processed, 480 | success, 481 | ) 482 | 483 | st.stop() 484 | 485 | # Display results in UI. 486 | text = "Dein vereinfachter Text" 487 | if do_analysis: 488 | text = "Deine Analyse" 489 | # Often the models return the German letter «ß». Replace it with the Swiss «ss». 490 | response = response.replace("ß", "ss") 491 | time_processed = time.time() - start_time 492 | 493 | with placeholder_result.container(): 494 | st.text_area( 495 | text, 496 | height=TEXT_AREA_HEIGHT, 497 | value=response, 498 | ) 499 | if do_simplification or do_one_click: 500 | score_target = get_zix(response) 501 | score_target_rounded = int(np.round(score_target, 0) + 0) 502 | cefr_target = get_cefr(score_target) 503 | if score_target < LIMIT_HARD: 504 | st.markdown( 505 | f"Dein vereinfachter Text ist **:red[schwer verständlich]**. ({score_target_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:red[Sprachniveau {cefr_target}]**." 506 | ) 507 | elif score_target >= LIMIT_HARD and score_target < LIMIT_MEDIUM: 508 | st.markdown( 509 | f"Dein vereinfachter Text ist **:orange[nur mässig verständlich]**. ({score_target_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:orange[Sprachniveau {cefr_target}]**." 510 | ) 511 | else: 512 | st.markdown( 513 | f"Dein vereinfachter Text ist **:green[gut verständlich]**. ({score_target_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:green[Sprachniveau {cefr_target}]**." 514 | ) 515 | with placeholder_analysis.container(): 516 | text_analysis = st.metric( 517 | label="Verständlichkeit -10 bis 10", 518 | value=score_target_rounded, 519 | delta=int(np.round(score_target - score_source, 0)), 520 | help="Verständlichkeit auf einer Skala von -10 bis 10 (von -10 = extrem schwer verständlich bis 10 = sehr gut verständlich). Texte in Einfacher Sprache haben meist einen Wert von 0 bis 4 oder höher.", 521 | ) 522 | 523 | create_download_link(st.session_state.key_textinput, response) 524 | st.caption(f"Verarbeitet in {time_processed:.1f} Sekunden.") 525 | else: 526 | with placeholder_analysis.container(): 527 | text_analysis = st.metric( 528 | label="Verständlichkeit -10 bis 10", 529 | value=score_source_rounded, 530 | help="Verständlichkeit auf einer Skala von -10 bis 10 (von -10 = extrem schwer verständlich bis 10 = sehr gut verständlich). Texte in Einfacher Sprache haben meist einen Wert von 0 bis 4 oder höher.", 531 | ) 532 | create_download_link( 533 | st.session_state.key_textinput, response, analysis=True 534 | ) 535 | st.caption(f"Verarbeitet in {time_processed:.1f} Sekunden.") 536 | 537 | log_event( 538 | st.session_state.key_textinput, 539 | response, 540 | do_analysis, 541 | do_simplification, 542 | do_one_click, 543 | leichte_sprache, 544 | model_choice, 545 | time_processed, 546 | success, 547 | ) 548 | st.stop() 549 | -------------------------------------------------------------------------------- /_streamlit_app/sprache-vereinfachen-openai.py: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------------------- 2 | # Imports 3 | 4 | import streamlit as st 5 | import os 6 | import re 7 | from datetime import datetime 8 | import time 9 | import base64 10 | from docx import Document 11 | from docx.shared import Pt, Inches 12 | import io 13 | from concurrent.futures import ThreadPoolExecutor 14 | from dotenv import load_dotenv 15 | import yaml 16 | import logging 17 | import numpy as np 18 | from openai import OpenAI 19 | from zix.understandability import get_zix, get_cefr 20 | from utils_prompts import ( 21 | SAMPLE_TEXT, 22 | SYSTEM_MESSAGE_ES, 23 | SYSTEM_MESSAGE_LS, 24 | RULES_ES, 25 | RULES_LS, 26 | REWRITE_COMPLETE, 27 | REWRITE_CONDENSED, 28 | TEMPLATE_ES, 29 | TEMPLATE_LS, 30 | TEMPLATE_ANALYSIS_ES, 31 | TEMPLATE_ANALYSIS_LS, 32 | ) 33 | 34 | st.set_page_config(layout="wide") 35 | 36 | logging.basicConfig( 37 | filename="app_openai.log", 38 | datefmt="%d-%b-%y %H:%M:%S", 39 | level=logging.WARNING, 40 | ) 41 | 42 | # --------------------------------------------------------------- 43 | # Constants 44 | 45 | load_dotenv(override=True) 46 | 47 | API_KEYS = { 48 | "OPENAI": os.getenv("OPENAI_API_KEY"), 49 | } 50 | 51 | 52 | @st.cache_resource 53 | def load_config(): 54 | """Load configuration from YAML file.""" 55 | config_path = os.path.join(os.path.dirname(__file__), "..", "config_openai.yaml") 56 | with open(config_path, "r", encoding="utf-8") as file: 57 | return yaml.safe_load(file) 58 | 59 | 60 | # Load configuration 61 | config = load_config() 62 | 63 | # Create model dictionaries from config 64 | MODEL_IDS = {model["name"]: model["id"] for model in config["models"]} 65 | MODEL_NAMES = list(MODEL_IDS.keys()) 66 | 67 | # Get configuration values from config 68 | TEMPERATURE = config["api"]["temperature"] 69 | MAX_TOKENS = config["api"]["max_tokens"] 70 | TEXT_AREA_HEIGHT = config["ui"]["text_area_height"] 71 | MAX_CHARS_INPUT = config["ui"]["max_chars_input"] 72 | USER_WARNING = f"{config['ui']['user_warning']}" 73 | 74 | # Document formatting constants 75 | FONT_WORDDOC = config["document"]["font_name"] 76 | FONT_SIZE_HEADING = config["document"]["font_size_heading"] 77 | FONT_SIZE_PARAGRAPH = config["document"]["font_size_paragraph"] 78 | FONT_SIZE_FOOTER = config["document"]["font_size_footer"] 79 | DEFAULT_OUTPUT_FILENAME = config["document"]["default_output_filename"] 80 | ANALYSIS_FILENAME = config["document"]["analysis_filename"] 81 | 82 | # Understandability limits 83 | LIMIT_HARD = config["understandability"]["limit_hard"] 84 | LIMIT_MEDIUM = config["understandability"]["limit_medium"] 85 | 86 | DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" 87 | 88 | # --------------------------------------------------------------- 89 | # Functions 90 | 91 | 92 | @st.cache_resource 93 | def get_project_info(): 94 | """Get markdown for project information that is shown in the expander section at the top of the app.""" 95 | with open("utils_expander.md") as f: 96 | return f.read() 97 | 98 | 99 | @st.cache_resource 100 | def create_project_info(project_info): 101 | """Create expander for project info. Add the image in the middle of the content.""" 102 | with st.expander("Detaillierte Informationen zum Projekt (OpenAI Version)"): 103 | project_info = project_info.split("ADD_IMAGE_HERE") 104 | st.markdown(project_info[0], unsafe_allow_html=True) 105 | st.image("zix_scores.jpg", width="content") 106 | st.markdown(project_info[1], unsafe_allow_html=True) 107 | 108 | 109 | def create_prompt(text, analysis): 110 | """Create prompt and system message according the app settings.""" 111 | if analysis: 112 | final_prompt = ( 113 | TEMPLATE_ANALYSIS_LS.format(rules=RULES_LS, prompt=text) 114 | if leichte_sprache 115 | else TEMPLATE_ANALYSIS_ES.format(rules=RULES_ES, prompt=text) 116 | ) 117 | system = SYSTEM_MESSAGE_LS if leichte_sprache else SYSTEM_MESSAGE_ES 118 | else: 119 | if leichte_sprache: 120 | completeness = REWRITE_CONDENSED if condense_text else REWRITE_COMPLETE 121 | final_prompt = TEMPLATE_LS.format( 122 | rules=RULES_LS, completeness=completeness, prompt=text 123 | ) 124 | system = SYSTEM_MESSAGE_LS 125 | else: 126 | final_prompt = TEMPLATE_ES.format( 127 | rules=RULES_ES, completeness=REWRITE_COMPLETE, prompt=text 128 | ) 129 | system = SYSTEM_MESSAGE_ES 130 | return final_prompt, system 131 | 132 | 133 | def get_result_from_response(response): 134 | """Extract text between tags from response.""" 135 | tag = "leichtesprache" if leichte_sprache else "einfachesprache" 136 | result = re.findall(rf"<{tag}>(.*?)", response, re.DOTALL) 137 | return "\n".join(result).strip() 138 | 139 | 140 | def strip_markdown(text): 141 | """Strip markdown from text.""" 142 | # Remove markdown headers. 143 | text = re.sub(r"#+\s", "", text) 144 | # Remove markdown italic and bold. 145 | text = re.sub(r"\*\*|\*|__|_", "", text) 146 | return text 147 | 148 | 149 | @st.cache_resource 150 | def get_openai_client(): 151 | """Create OpenAI client using the official OpenAI API.""" 152 | return OpenAI( 153 | api_key=API_KEYS["OPENAI"], 154 | ) 155 | 156 | 157 | def invoke_model( 158 | text, 159 | model_id, 160 | analysis=False, 161 | ): 162 | """Invoke OpenAI model through the official OpenAI API.""" 163 | final_prompt, system = create_prompt(text, analysis) 164 | 165 | try: 166 | message = openai_client.chat.completions.create( 167 | model=model_id, 168 | temperature=TEMPERATURE, 169 | max_completion_tokens=MAX_TOKENS, 170 | messages=[ 171 | {"role": "system", "content": system}, 172 | {"role": "user", "content": final_prompt}, 173 | ], 174 | ) 175 | content = message.choices[0].message.content 176 | if content is None: 177 | return False, "No content received from API" 178 | 179 | content = content.strip() 180 | content = get_result_from_response(content) 181 | content = strip_markdown(content) 182 | return True, content 183 | except Exception as e: 184 | print(f"Error: {e}") 185 | return False, str(e) 186 | 187 | 188 | def enter_sample_text(): 189 | """Enter sample text into the text input in the left column.""" 190 | st.session_state.key_textinput = SAMPLE_TEXT 191 | 192 | 193 | def get_one_click_results(): 194 | with ThreadPoolExecutor(max_workers=len(MODEL_IDS)) as executor: 195 | futures = { 196 | name: executor.submit( 197 | invoke_model, 198 | st.session_state.key_textinput, 199 | model_id, 200 | ) 201 | for name, model_id in MODEL_IDS.items() 202 | } 203 | 204 | responses = {name: future.result() for name, future in futures.items()} 205 | response_texts = [] 206 | 207 | # We add 0 to the rounded ZIX score to avoid -0. 208 | # https://stackoverflow.com/a/11010791/7117003 209 | for name, (success, response) in responses.items(): 210 | if success: 211 | zix = get_zix(response) 212 | zix = int(np.round(zix, 0) + 0) 213 | cefr = get_cefr(zix) 214 | response_texts.append( 215 | f"\n----- Ergebnis von {name} (Verständlichkeit: {zix}, Niveau etwa {cefr}) -----\n\n{response}" 216 | ) 217 | 218 | if not response_texts: 219 | return False, "Es ist ein Fehler aufgetreten." 220 | return True, "\n\n\n".join(response_texts) 221 | 222 | 223 | def create_download_link(text_input, response, analysis=False): 224 | """Create a downloadable Word document and download link of the results.""" 225 | document = Document() 226 | 227 | h1 = document.add_heading("Ausgangstext") 228 | p1 = document.add_paragraph("\n" + text_input) 229 | 230 | if analysis: 231 | h2 = document.add_heading(f"Analyse von Sprachmodell {model_choice}") 232 | elif do_one_click: 233 | h2 = document.add_heading("Vereinfachte Texte von Sprachmodellen") 234 | else: 235 | h2 = document.add_heading("Vereinfachter Text von Sprachmodell") 236 | 237 | p2 = document.add_paragraph(response) 238 | 239 | timestamp = datetime.now().strftime(DATETIME_FORMAT) 240 | models_used = model_choice 241 | if do_one_click: 242 | models_used = ", ".join(MODEL_NAMES) 243 | footer = document.sections[0].footer 244 | footer.paragraphs[ 245 | 0 246 | ].text = f"Erstellt am {timestamp} mit der Prototyp-App «Einfache Sprache» (OpenAI Version), Statistisches Amt, Kanton Zürich.\nSprachmodell(e): {models_used}\nVerarbeitungszeit: {time_processed:.1f} Sekunden" 247 | 248 | # Set font for all paragraphs. 249 | for paragraph in document.paragraphs: 250 | for run in paragraph.runs: 251 | run.font.name = FONT_WORDDOC 252 | 253 | # Set font size for all headings. 254 | for paragraph in [h1, h2]: 255 | for run in paragraph.runs: 256 | run.font.size = Pt(FONT_SIZE_HEADING) 257 | 258 | # Set font size for all paragraphs. 259 | for paragraph in [p1, p2]: 260 | for run in paragraph.runs: 261 | run.font.size = Pt(FONT_SIZE_PARAGRAPH) 262 | 263 | # Set font and font size for footer. 264 | for run in footer.paragraphs[0].runs: 265 | run.font.name = "Arial" 266 | run.font.size = Pt(FONT_SIZE_FOOTER) 267 | 268 | section = document.sections[0] 269 | section.page_width = Inches(8.27) # Width of A4 paper in inches 270 | section.page_height = Inches(11.69) # Height of A4 paper in inches 271 | 272 | io_stream = io.BytesIO() 273 | document.save(io_stream) 274 | 275 | b64 = base64.b64encode(io_stream.getvalue()) 276 | file_name = DEFAULT_OUTPUT_FILENAME 277 | 278 | if do_one_click: 279 | caption = "Vereinfachte Texte herunterladen" 280 | else: 281 | caption = "Vereinfachten Text herunterladen" 282 | 283 | if analysis: 284 | file_name = ANALYSIS_FILENAME 285 | caption = "Analyse herunterladen" 286 | download_url = f'{caption}' 287 | st.markdown(download_url, unsafe_allow_html=True) 288 | 289 | 290 | def clean_log(text): 291 | """Remove linebreaks and tabs from log messages 292 | that otherwise would yield problems when parsing the logs.""" 293 | return text.replace("\n", " ").replace("\t", " ") 294 | 295 | 296 | def log_event( 297 | text, 298 | response, 299 | do_analysis, 300 | do_simplification, 301 | do_one_click, 302 | leichte_sprache, 303 | model_choice, 304 | time_processed, 305 | success, 306 | ): 307 | """Log event.""" 308 | log_string = f"{datetime.now().strftime(DATETIME_FORMAT)}" 309 | log_string += f"\t{clean_log(text)}" 310 | log_string += f"\t{clean_log(response)}" 311 | log_string += f"\t{do_analysis}" 312 | log_string += f"\t{do_simplification}" 313 | log_string += f"\t{do_one_click}" 314 | log_string += f"\t{leichte_sprache}" 315 | log_string += f"\t{model_choice}" 316 | log_string += f"\t{time_processed:.3f}" 317 | log_string += f"\t{success}" 318 | 319 | logging.warning(log_string) 320 | 321 | 322 | # --------------------------------------------------------------- 323 | # Main 324 | 325 | openai_client = get_openai_client() 326 | project_info = get_project_info() 327 | 328 | # Persist text input across sessions in session state. 329 | # Otherwise, the text input sometimes gets lost when the user clicks on a button. 330 | if "key_textinput" not in st.session_state: 331 | st.session_state.key_textinput = "" 332 | 333 | st.markdown("## 🙋‍♀️ Sprache einfach vereinfachen (OpenAI Version)") 334 | create_project_info(project_info) 335 | st.caption(USER_WARNING, unsafe_allow_html=True) 336 | st.markdown("---") 337 | 338 | # Set up first row with all buttons and settings. 339 | button_cols = st.columns([1, 1, 1, 2]) 340 | with button_cols[0]: 341 | st.button( 342 | "Beispiel einfügen", 343 | on_click=enter_sample_text, 344 | width="stretch", 345 | type="secondary", 346 | help="Fügt einen Beispieltext ein.", 347 | ) 348 | do_analysis = st.button( 349 | "Analysieren", 350 | width="stretch", 351 | help="Analysiert deinen Ausgangstext Satz für Satz.", 352 | ) 353 | with button_cols[1]: 354 | do_simplification = st.button( 355 | "Vereinfachen", 356 | width="stretch", 357 | help="Vereinfacht deinen Ausgangstext.", 358 | ) 359 | do_one_click = st.button( 360 | "🚀 One-Klick", 361 | width="stretch", 362 | help="Schickt deinen Ausgangstext gleichzeitig an alle verfügbaren OpenAI Modelle.", 363 | ) 364 | with button_cols[2]: 365 | leichte_sprache = st.toggle( 366 | "Leichte Sprache", 367 | value=False, 368 | help="**Schalter aktiviert**: «Leichte Sprache». **Schalter nicht aktiviert**: «Einfache Sprache».", 369 | ) 370 | if leichte_sprache: 371 | condense_text = st.toggle( 372 | "Text verdichten", 373 | value=True, 374 | help="**Schalter aktiviert**: Modell konzentriert sich auf essentielle Informationen und versucht, Unwichtiges wegzulassen. **Schalter nicht aktiviert**: Modell versucht, alle Informationen zu übernehmen.", 375 | ) 376 | with button_cols[3]: 377 | model_choice = st.radio( 378 | label="OpenAI Sprachmodell", 379 | options=MODEL_NAMES, 380 | index=0, 381 | horizontal=True, 382 | ) 383 | 384 | # Instantiate empty containers for the text areas. 385 | cols = st.columns([2, 2, 1]) 386 | 387 | with cols[0]: 388 | source_text = st.container() 389 | with cols[1]: 390 | placeholder_result = st.empty() 391 | with cols[2]: 392 | placeholder_analysis = st.empty() 393 | 394 | # Populate containers. 395 | with source_text: 396 | st.text_area( 397 | "Ausgangstext, den du vereinfachen möchtest", 398 | value=None, 399 | height=TEXT_AREA_HEIGHT, 400 | max_chars=MAX_CHARS_INPUT, 401 | key="key_textinput", 402 | ) 403 | with placeholder_result: 404 | text_output = st.text_area( 405 | "Ergebnis", 406 | height=TEXT_AREA_HEIGHT, 407 | ) 408 | with placeholder_analysis: 409 | text_analysis = st.metric( 410 | label="Verständlichkeit -10 bis 10", 411 | value=None, 412 | delta=None, 413 | help="Verständlichkeit auf einer Skala von -10 bis 10 Punkten (von -10 = extrem schwer verständlich bis 10 = sehr gut verständlich). Texte in Einfacher Sprache haben meist einen Wert von 0 bis 4 oder höher, Texte in Leichter Sprache 2 bis 6 oder höher.", 414 | ) 415 | 416 | 417 | # Derive model_id from explicit model_choice. 418 | model_id = MODEL_IDS[model_choice] 419 | 420 | # Start processing if one of the processing buttons is clicked. 421 | if do_simplification or do_analysis or do_one_click: 422 | start_time = time.time() 423 | if st.session_state.key_textinput == "": 424 | st.error("Bitte gib einen Text ein.") 425 | st.stop() 426 | 427 | score_source = get_zix(st.session_state.key_textinput) 428 | # We add 0 to avoid negative zero. 429 | score_source_rounded = int(np.round(score_source, 0) + 0) 430 | cefr_source = get_cefr(score_source) 431 | 432 | # Analyze source text and display results. 433 | with source_text: 434 | if score_source < LIMIT_HARD: 435 | st.markdown( 436 | f"Dein Ausgangstext ist **:red[schwer verständlich]**. ({score_source_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:red[Sprachniveau {cefr_source}]**." 437 | ) 438 | elif score_source >= LIMIT_HARD and score_source < LIMIT_MEDIUM: 439 | st.markdown( 440 | f"Dein Ausgangstext ist **:orange[nur mässig verständlich]**. ({score_source_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:orange[Sprachniveau {cefr_source}]**." 441 | ) 442 | else: 443 | st.markdown( 444 | f"Dein Ausgangstext ist **:green[gut verständlich]**. ({score_source_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:green[Sprachniveau {cefr_source}]**." 445 | ) 446 | with placeholder_analysis.container(): 447 | text_analysis = st.metric( 448 | label="Verständlichkeit von -10 bis 10", 449 | value=score_source_rounded, 450 | delta=None, 451 | help="Verständlichkeit auf einer Skala von -10 bis 10 Punkten (von -10 = extrem schwer verständlich bis 10 = sehr gut verständlich). Texte in Einfacher Sprache haben meist einen Wert von 0 bis 4 oder höher, Texte in Leichter Sprache 2 bis 6 oder höher.", 452 | ) 453 | 454 | with placeholder_analysis.container(): 455 | with st.spinner("Ich arbeite..."): 456 | # One-click simplification. 457 | if do_one_click: 458 | success, response = get_one_click_results() 459 | # Regular text simplification or analysis 460 | else: 461 | success, response = invoke_model( 462 | st.session_state.key_textinput, 463 | model_id=model_id, 464 | analysis=do_analysis, 465 | ) 466 | 467 | if success is False: 468 | st.error( 469 | "Es ist ein Fehler bei der Abfrage der OpenAI API aufgetreten. Bitte versuche es erneut. Alternativ überprüfe Code, API-Keys, Verfügbarkeit der Modelle und ggf. Internetverbindung." 470 | ) 471 | time_processed = time.time() - start_time 472 | log_event( 473 | st.session_state.key_textinput, 474 | "Error from model call", 475 | do_analysis, 476 | do_simplification, 477 | do_one_click, 478 | leichte_sprache, 479 | model_choice, 480 | time_processed, 481 | success, 482 | ) 483 | 484 | st.stop() 485 | 486 | # Display results in UI. 487 | text = "Dein vereinfachter Text" 488 | if do_analysis: 489 | text = "Deine Analyse" 490 | # Often the models return the German letter «ß». Replace it with the Swiss «ss». 491 | response = response.replace("ß", "ss") 492 | time_processed = time.time() - start_time 493 | 494 | with placeholder_result.container(): 495 | st.text_area( 496 | text, 497 | height=TEXT_AREA_HEIGHT, 498 | value=response, 499 | ) 500 | if do_simplification or do_one_click: 501 | score_target = get_zix(response) 502 | score_target_rounded = int(np.round(score_target, 0) + 0) 503 | cefr_target = get_cefr(score_target) 504 | if score_target < LIMIT_HARD: 505 | st.markdown( 506 | f"Dein vereinfachter Text ist **:red[schwer verständlich]**. ({score_target_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:red[Sprachniveau {cefr_target}]**." 507 | ) 508 | elif score_target >= LIMIT_HARD and score_target < LIMIT_MEDIUM: 509 | st.markdown( 510 | f"Dein vereinfachter Text ist **:orange[nur mässig verständlich]**. ({score_target_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:orange[Sprachniveau {cefr_target}]**." 511 | ) 512 | else: 513 | st.markdown( 514 | f"Dein vereinfachter Text ist **:green[gut verständlich]**. ({score_target_rounded} auf einer Skala von -10 bis 10). Das entspricht etwa dem **:green[Sprachniveau {cefr_target}]**." 515 | ) 516 | with placeholder_analysis.container(): 517 | text_analysis = st.metric( 518 | label="Verständlichkeit -10 bis 10", 519 | value=score_target_rounded, 520 | delta=int(np.round(score_target - score_source, 0)), 521 | help="Verständlichkeit auf einer Skala von -10 bis 10 (von -10 = extrem schwer verständlich bis 10 = sehr gut verständlich). Texte in Einfacher Sprache haben meist einen Wert von 0 bis 4 oder höher.", 522 | ) 523 | 524 | create_download_link(st.session_state.key_textinput, response) 525 | st.caption(f"Verarbeitet in {time_processed:.1f} Sekunden.") 526 | else: 527 | with placeholder_analysis.container(): 528 | text_analysis = st.metric( 529 | label="Verständlichkeit -10 bis 10", 530 | value=score_source_rounded, 531 | help="Verständlichkeit auf einer Skala von -10 bis 10 (von -10 = extrem schwer verständlich bis 10 = sehr gut verständlich). Texte in Einfacher Sprache haben meist einen Wert von 0 bis 4 oder höher.", 532 | ) 533 | create_download_link( 534 | st.session_state.key_textinput, response, analysis=True 535 | ) 536 | st.caption(f"Verarbeitet in {time_processed:.1f} Sekunden.") 537 | 538 | log_event( 539 | st.session_state.key_textinput, 540 | response, 541 | do_analysis, 542 | do_simplification, 543 | do_one_click, 544 | leichte_sprache, 545 | model_choice, 546 | time_processed, 547 | success, 548 | ) 549 | st.stop() 550 | --------------------------------------------------------------------------------