diff --git a/README.md b/README.md index 25858dd..99b754e 100644 --- a/README.md +++ b/README.md @@ -1,306 +1,133 @@

- -Laravel Logo - + codebar Solutions AG

-## About Laravel Template +

www.codebar.ch

-Laravel Template is a project template for all Codebar Solutions AG Laravel applications. -It is a starting point for new Laravel projects. -It includes a basic setup for a Laravel application with some common packages and configurations.: - -- Auth -- Support for Microsoft Office 365 Authentication -- Enums -- [Laravel Nova](https://nova.laravel.com). -- [Flysystem Cloudinary](https://github.com/codebar-ag/laravel-flysystem-cloudinary). -- [Flysystem Cloudinary Nova](https://github.com/codebar-ag/laravel-flysystem-cloudinary-nova). -- [Spatie Activity Log](https://github.com/spatie/laravel-activitylog). -- [Spatie Health](https://github.com/spatie/laravel-health). -- [Spatie Permission](https://github.com/spatie/laravel-permission). - -## Installation - -You can use this template by clicking on the `Use this template` button on the top of this github repository page. - -**INSERT IMAGE HERE* * - -Once you have created a new repository from this template, you can clone it to your local machine and install the dependencies: - -```bash -composer install - -cp .env.example .env -cp vite.config-example.js vite.config.js - -php artisan key:generate +

+ The public marketing website of codebar Solutions AG — a small software company based in the Basel region, Switzerland. Laravel, Blade and Tailwind, bilingual (DE/EN), content managed as version-controlled files rather than through an admin panel. +

-php artisan migrate --seed +## Contents -npm install -npm run build -``` +- [Content architecture](#content-architecture) +- [Localization](#localization) +- [SEO](#seo) +- [LLM usage analytics](#llm-usage-analytics) +- [Local development](#local-development) +- [Testing & code quality](#testing--code-quality) +- [Deployment](#deployment-laravel-cloud) +- [Key packages](#key-packages) -If you want to use Valet to serve your application, you can run the following command: +## Content architecture -```bash -valet link - -valet secure +Almost everything editorial — pages, news articles, team members, services, products, technologies, network partners, the AI model catalogue — lives as YAML or Markdown files under `database/files/`, not as data entered through a CMS. The files are the source of truth; the database is a rebuildable cache of them. -valet open ``` - -If you want to user Herd to serve your application, you can run the following command: - -```bash -herd link - -herd secure - -herd open +database/files/ +├── ai_models/ one YAML file per model +├── networks/ one YAML file per partner +├── news/{locale}/ one Markdown file per article, per language +├── pages/ one YAML file per page (SEO metadata: title, description, robots, image) +├── products/{locale}/ +├── services/{locale}/ +├── team/ one YAML file per person +└── technologies/{locale}/ ``` -You can run the development asset server with the following command: +Each content type has a matching import command that reads its files and upserts the database, and is safe to run repeatedly: -```bash -npm run dev -```` +| Command | Reads | +|---|---| +| `php artisan pages:import` | `database/files/pages/*.yaml` | +| `php artisan news:import` | `database/files/news/{locale}/*.md` | +| `php artisan team:import` | `database/files/team/*.yaml` | +| `php artisan services:import` | `database/files/services/{locale}/*.md` | +| `php artisan products:import` | `database/files/products/{locale}/*.md` | +| `php artisan technologies:import` | `database/files/technologies/{locale}/*.md` | +| `php artisan networks:import` | `database/files/networks/*.yaml` | +| `php artisan ai-models:import` | `database/files/ai_models/*.yaml` | +| `php artisan sync:repositories` | live GitHub repositories (open-source content) | -> Note: You should set `valetTls: 'your-domain.test',` below `refresh: true,` in your `vite.config.js` file if you use `valet secure` or `herd secure`. +`database/seeders/DatabaseSeeder.php` calls every one of these on `db:seed` — including in production. That is intentional: these seeders don't generate test fixtures, they publish the real content the same way running the command by hand would. The one exception is `AiModelDailyUsagesTableSeeder`, which loads a static local-dev fixture rather than real usage data (see [LLM usage analytics](#llm-usage-analytics)). -## Assets +A file that disappears from `database/files/` removes the matching row on the next import — the importers are the single source of truth in both directions. -Assets should be set in the following directories: +## Localization -- `resources/js` for JavaScript files. -- `resources/css` for CSS files. -- `resources/fonts` for Font files. -- `resources/images` for Image files. +The site ships in German (`de_CH`) and English (`en_CH`) under distinct, fully translated URL prefixes rather than a shared `/en/…` segment — e.g. `/dienstleistungen` and `/services`, `/aktuelles` and `/news`, `/ueber-uns` and `/about-us`. Every localized route pair cross-references the other via `hreflang`, including on detail pages where the slug itself is translated (`routes/web.php`, `resources/views/layouts/_partials/_seo.blade.php`). -After you have added your assets, you can run the following command to compile them: -```bash -npm run build -``` +## SEO -To include your assets in your blade files, you can use the following: +- **Structured data** — a single `@graph` JSON-LD payload (`App\Seo\SchemaGraph` / `App\Seo\SchemaNodes`) built from `config/company.php`, the one source of truth for the company's name, addresses, phone and `sameAs` profiles. Every page ships `Organization`, `WebSite`, `WebPage` and `BreadcrumbList` nodes; content pages add `Service`, `Person`, `BlogPosting`, etc. +- **Sitemap** — `App\Sitemap\SitemapBuilder` + `App\Http\Controllers\Sitemap\SitemapController` build `/sitemap.xml` from the same models the site renders, cached 24h via `Cache::remember`. +- **Response cache** — `spatie/laravel-responsecache` caches full HTTP responses for up to 7 days. `App\Observers\SitemapCacheObserver` drops the sitemap's own data cache on every relevant model save, and `responsecache:clear` runs hourly via the scheduler (`routes/console.php`) as a backstop for the full-page cache layer — these are two independent caches and both need clearing after a bulk content change (`php artisan responsecache:clear` after a fresh `db:seed`, for instance). +- **Social images** — article heroes that are local SVG placeholders (no real photography yet) are not usable as `og:image` — social crawlers don't render SVG. `App\Support\NewsImage::ogImage()` falls back to a same-named `.png` rendered from the SVG when one exists, or the site's default share image otherwise. +- **Tests** — `tests/Feature/Seo/` asserts on the actual rendered JSON-LD and meta tags (not just "does it look right"), and `tests/lighthouse/` audits real Lighthouse scores against the built (`npm run build`) output, not the Vite dev server. -```blade -{{ Vite::asset('resources/images/your-image.png') }} -``` +## LLM usage analytics -## Auth +`/ki` (`/ai`) publishes aggregate usage figures for the AI models codebar runs internally via a self-hosted [LiteLLM](https://www.litellm.ai/) proxy. `php artisan llm:fetch-analytics` pulls per-day usage from the proxy's `/spend/logs` endpoint and stores it in `ai_model_daily_usages`; it's scheduled hourly but only backfills the last 3 days by default — use `--full` (syncs from 2026-01-01) or `--from`/`--to` to backfill a specific range. Spend figures are stored but must never be displayed publicly. The dashboard intentionally shows monthly/yearly aggregates only, never a daily breakdown. -Auth is enabled by default. +## Local development -You can configure auth settings in the `config/laravel-auth.php` file. +```bash +composer install +cp .env.example .env +php artisan key:generate -### 🔐 Verify -If you wish to use email verification, you can use the following middleware to protect your routes: +php artisan migrate --seed # imports real content — see "Content architecture" above -```php -Route::middleware(['laravel-auth-middleware'])->group(function () { - ... -}); -``` -To use verification in nova, add the middleware into in your `nova.php` config: - -```php -/* -|-------------------------------------------------------------------------- -| Nova Route Middleware -|-------------------------------------------------------------------------- -| -| These middleware will be assigned to every Nova route, giving you the -| chance to add your own middleware to this stack or override any of -| the existing middleware. Or, you can just stick with this stack. -| -*/ - -'middleware' => [ - 'web', - 'laravel-auth-middleware', - HandleInertiaRequests::class, - DispatchServingNovaEvent::class, - BootTools::class, -], +npm install +npm run build # or `npm run dev` for the Vite dev server ``` -### Microsoft Office 365 Authentication - -⚠️ When using Office 365 you need to provide a publicly accessible URL for the `MICROSOFT_REDIRECT_URI` environment variable. You can use [expose](https://expose.dev/) or [ngrok](https://ngrok.com/) for local development. - -```bash -APP_URL=https://your-expose-or-ngrok-url.com +If you use [Herd](https://herd.laravel.com): -# ✅ This is recommended for production as well: -MICROSOFT_REDIRECT_URI="${APP_URL}/auth/service/microsoft/redirect" +```bash +herd link +herd secure +herd open ``` -## Permissions - -Please refer to the [Spatie Permission](https://github.com/spatie/laravel-permission) documentation for more information on how to use permissions. - -## Enums - -Enums are included in PHP's core functionality but we have some additional functionality to make them easier to use. - -You can create an enum in the `app/Enums` directory: - -```php - __('Production'), - EnvironmentEnum::STAGING => __('Staging'), - EnvironmentEnum::LOCAL => __('Local'), - }; - } -} +```bash +valet link +valet secure +valet open ``` -You should use the `HasLabels` trait to add a `label` method to your enum. -You should also implement the `LabelEnumInterface` interface to ensure that the `label` method is implemented. - -The `label` method should return the label for the enum value. - -You can use the enum in your code like this: - -```php -// Native PHP Enum -$enum = EnvironmentEnum::PRODUCTION; // Enum Object -$name = EnvironmentEnum::PRODUCTION->name; // Enum Name (PRODUCTION) -$value = EnvironmentEnum::PRODUCTION->value; // Enum Value (production) +> Set `valetTls: 'your-domain.test'` below `refresh: true` in `vite.config.js` if you use `valet secure` / `herd secure`. -// Label -$label = EnvironmentEnum::PRODUCTION->label(); // Enum Label using Laravels Translation (Production) +## Testing & code quality -// Labels -$labels = EnvironmentEnum::labels(); // Array of Enum Labels with Enum value as Key (['production' => 'Production', 'staging' => 'Staging', 'local' => 'Local']) +```bash +./vendor/bin/pest # test suite (Pest) +./vendor/bin/phpstan analyse # static analysis (Larastan, see phpstan.neon.dist) +./vendor/bin/pint # code style +./vendor/bin/pint --blade # blade formatting (beta) ``` -## Health - -Please refer to the [Spatie Health](https://github.com/spatie/laravel-health) documentation for more information on how to use health checks. - -We have added two health checks which are located in the `app/Checks` directory: - -- `FailedJobsCheck` -- `JobsCheck` - -## Helpers - -We have added some helper functions which are located in the `app/Helpers` directory - -You should the Facades for the helpers which are located in the `app/Helpers/Facades` directory: - -- `HelperBank` -- `HelperDate` -- `HelperDevice` -- `HelperFile` -- `HelperMarkdown` -- `HelperMoney` -- `HelperNumber` -- `HelperPhone` - -## Feature Policy - -We have added feature policy which is located in the `app/FeaturePolicy` directory. - -It is enabled by default and you can configure it in the `config/feature-policy.php` and `config/default.php` file. - -The Feature Policy Middleware is installed and enabled by default in the `bootstrap/app.php` file. - -## Traits - -We have added some traits which are located in the `app/Traits` directory. - -- `HassUuid` - -We also have some traits which are located in the `app/Traits/Nova` directory which are intended for use only in Laravel Nova. - -- `NovaCustomOrderTrait` -- `NovaIdentificationPanelTrait` -- `NovaLanguageTrait` -- `NovaTimestampsPanelTrait` - -## Blade Components - -We have added some blade components which are located in the `resources/views/components` directory. +## Deployment (Laravel Cloud) -- `fathom.blade.php` -- `favicons.blade.php` - -You should include both the blade components in your blade layout files - -## Laravel Cloud deployment - -When deploying to Laravel Cloud, ensure these environment variables are set for Lighthouse Best Practices (security headers): +The site runs on [Laravel Cloud](https://cloud.laravel.com), behind Cloudflare. Required environment variables for security headers: - `CSP_ENABLED=true` — enables Content-Security-Policy enforcement via Spatie CSP middleware - `FPH_ENABLED=true` — enables Permissions-Policy headers -Security headers (HSTS, COOP, `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`) are applied automatically by `SecurityHeaders` middleware on all web responses. - -**Lighthouse note:** Deprecated API warnings for `/cdn-cgi/challenge-platform/scripts/jsd/main.js` come from Cloudflare bot protection injected by Laravel Cloud, not from application code. Run Lighthouse in incognito without browser extensions for accurate scores. - -## Cloudinary - -Please refer to the respective documentation for the Cloudinary and Cloudinary Nova packages. - -- [Flysystem Cloudinary](https://github.com/codebar-ag/laravel-flysystem-cloudinary). -- [Flysystem Cloudinary Nova](https://github.com/codebar-ag/laravel-flysystem-cloudinary-nova). +HSTS, COOP, `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options` are applied automatically by the `SecurityHeaders` middleware on every web response. -## Notifications +**Lighthouse note:** deprecated-API warnings for `/cdn-cgi/challenge-platform/scripts/jsd/main.js` come from Cloudflare's bot protection, injected at the edge — not from application code. Run Lighthouse in incognito without extensions for an accurate score, and prefer `tests/lighthouse/` (which audits the built output) over ad-hoc runs against the dev server. -We use Filament for notifications. Please refer to the [Filament](https://filamentphp.com/docs/3.x/notifications/sending-notifications) documentation for more information on how to use notifications. - -## Pint - -We use Laravel Pint to format code. - -You can run the following command to format your code: - -```bash -./vendor/bin/pint -``` - -You can run the following command to format your blade files: - -```bash -./vendor/bin/pint --blade -``` - -> The blade formatter is still in beta and may not work as expected. if you wish to not use this update your `composer.json` to use the latest version of `laravel/pint` instead of the dev branch. - - -## Testing - -We use PestPHP for testing. - -You can run the following command to run your tests: - -```bash -./vendor/bin/pest -``` +## Key packages -Please refer to the [PestPHP](https://pestphp.com) documentation for more information on how to use PestPHP. +- **Content & storage** — `spatie/laravel-translatable`, `codebar-ag/laravel-flysystem-cloudinary` (editorial images), `league/flysystem-aws-s3-v3` (DigitalOcean Spaces for other assets), `symfony/yaml` +- **SEO** — `spatie/laravel-sitemap`, `spatie/laravel-responsecache` +- **Security & health** — `spatie/laravel-csp`, `spatie/laravel-honeypot`, `spatie/laravel-permission`, `spatie/laravel-health`, `spatie/security-advisories-health-check`, `mazedlx/laravel-feature-policy` +- **Ops** — `laravel/nightwatch` (observability), `symfony/postmark-mailer` +- **Analytics** — [Fathom](https://usefathom.com) (privacy-friendly, no cookie banner) ## License -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +The application code is proprietary to codebar Solutions AG. The underlying Laravel framework is open-source software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Security/Presets/MyCspPreset.php b/app/Security/Presets/MyCspPreset.php index 9717f3d..369a2bd 100644 --- a/app/Security/Presets/MyCspPreset.php +++ b/app/Security/Presets/MyCspPreset.php @@ -50,6 +50,8 @@ public function configure(Policy $policy): void 'data:', 'res.cloudinary.com', 'www.gravatar.com', + 'cdn.usefathom.com', + 'cdn-eu.usefathom.com', ]); $fontSources = array_filter([ diff --git a/database/files/news/de_CH/2025-04-06-docuware-7-12-is-here.md b/database/files/news/de_CH/2025-04-06-docuware-7-12-is-here.md index 02e6059..a150846 100644 --- a/database/files/news/de_CH/2025-04-06-docuware-7-12-is-here.md +++ b/database/files/news/de_CH/2025-04-06-docuware-7-12-is-here.md @@ -6,7 +6,7 @@ teaser: >- Mehr Automatisierung und Einblick: Das Release verbessert die E-Rechnungsverarbeitung, bringt IDP in die Cloud und öffnet Workflow-Daten für Analytics. published_at: 2025-04-06 author: sebastian.buergin@codebar.ch -hero: images/news/placeholders/docuware-7-12.svg +hero: images/news/placeholders/docuware-7-12-de.svg hero_alt: Platzhaltergrafik zum DocuWare-Release 7.12 tags: [DMS/ECM] --- diff --git a/database/files/news/de_CH/2025-09-15-docuware-7-13-is-here.md b/database/files/news/de_CH/2025-09-15-docuware-7-13-is-here.md index 5a42be0..d666ed1 100644 --- a/database/files/news/de_CH/2025-09-15-docuware-7-13-is-here.md +++ b/database/files/news/de_CH/2025-09-15-docuware-7-13-is-here.md @@ -6,7 +6,7 @@ teaser: >- Workflows entstehen neu im Browser, der Rechnungseingang kommt mit ausländischen E-Rechnungsformaten zurecht, und die Anmeldung schützt ein zweiter Faktor. published_at: 2025-09-15 author: sebastian.buergin@codebar.ch -hero: images/news/placeholders/docuware-7-13.svg +hero: images/news/placeholders/docuware-7-13-de.svg hero_alt: Platzhaltergrafik zum DocuWare-Release 7.13 tags: [DMS/ECM] --- diff --git a/database/files/news/de_CH/2026-05-12-docuware-7-14-is-here.md b/database/files/news/de_CH/2026-05-12-docuware-7-14-is-here.md index becb1f9..0067af4 100644 --- a/database/files/news/de_CH/2026-05-12-docuware-7-14-is-here.md +++ b/database/files/news/de_CH/2026-05-12-docuware-7-14-is-here.md @@ -6,7 +6,7 @@ teaser: >- Die letzten Einstellungen wandern in den Browser, eine neu gebaute App bringt Aufgaben aufs Telefon, und Archive ziehen zwischen Cloud-Organisationen um. published_at: 2026-05-12 author: sebastian.buergin@codebar.ch -hero: images/news/placeholders/docuware-7-14.svg +hero: images/news/placeholders/docuware-7-14-de.svg hero_alt: Platzhaltergrafik zum DocuWare-Release 7.14 tags: [DMS/ECM] --- diff --git a/database/files/news/de_CH/2026-07-29-llm-gateway-open-source.md b/database/files/news/de_CH/2026-07-29-llm-gateway-open-source.md new file mode 100644 index 0000000..32b6b7f --- /dev/null +++ b/database/files/news/de_CH/2026-07-29-llm-gateway-open-source.md @@ -0,0 +1,191 @@ +--- +key: llm-gateway-open-source +slug: llm-gateway-open-source +title: Warteschlange statt Warteschleife — unser LLM Gateway ist Open Source +teaser: >- + Lokale Modelle lösen die Datenschutzfrage, aber nicht die Kapazitätsfrage. Wir haben ein + Gateway gebaut, das Anfragen an lokale Modelle annimmt, speichert und der Reihe nach + abarbeitet — und stellen es unter MIT-Lizenz zur Verfügung. +published_at: 2026-07-29 +published: false +author: sebastian.buergin@codebar.ch +hero: images/templates/cover-template.jpg +hero_alt: Platzhaltergrafik zum LLM Gateway +tags: [Open Source, KI] +featured: false +--- + +Wir arbeiten täglich mit lokalen Sprachmodellen. Der Grund ist unspektakulär: Sobald ein Prompt +Kundendaten, Verträge oder interne Dokumente enthält, ist die Frage nicht mehr, welches Modell am +besten antwortet, sondern wer die Anfrage zu sehen bekommt. Ein Modell, das auf der eigenen +Hardware läuft, beantwortet diese Frage von selbst — nichts verlässt das Haus, es gibt keine +Verarbeitung im Ausland, keine Vertragsanhänge zur Auftragsverarbeitung, keine Diskussion darüber, +ob Eingaben irgendwann in ein Training fliessen. + +Was lokale Modelle nicht mitliefern, ist Kapazität. + +## Das eigentliche Problem ist Gleichzeitigkeit + +Ein lokales Modell ist günstig im Betrieb und teuer in der *Parallelität*. Eine GPU — oder der +gemeinsame Speicher eines Macs — trägt realistisch ein bis zwei gleichzeitige Anfragen, bevor das +Umschalten zwischen Modellen mehr kostet, als es bringt. Ollamas `OLLAMA_NUM_PARALLEL` existiert +genau deshalb: Die Grenze ist hart, nicht weich. + +Darüber hinaus stauen sich Anfragen. Ob man eine Warteschlange gebaut hat oder nicht, ändert daran +nichts — es ändert nur, *wo* sie entsteht. Entweder an einer Stelle, die man sieht, oder in Ollamas +eigenem Scheduler, wo eine Anfrage, die schlicht wartet, exakt gleich aussieht wie eine, die +hängt. + +Für einen einzelnen Entwickler am Notebook ist das kein Thema. Für interne Werkzeuge, die Rechnungen +auslesen, Protokolle zusammenfassen oder Dokumente klassifizieren, wird daraus schnell ein +Betriebsproblem: Ein Batchlauf blockiert alle anderen, ein Timeout im aufrufenden System verwirft +eine Antwort, für die das Modell noch zwei Minuten rechnet, und niemand kann sagen, wie lange etwas +dauern wird. + +## Was wir stattdessen gebaut haben + +Das LLM Gateway ist eine Warteschlange vor den lokalen Modellen — und sonst bewusst nichts. + +Der Ablauf ist einfach: Sie senden Ihre Anfrage genau so, wie Sie sie an das Modell senden würden. +Das Gateway nimmt sie an, schreibt sie in die Datenbank und antwortet sofort mit einer ID. Sobald +Kapazität frei ist, nimmt ein Worker den Eintrag, spielt ihn unverändert ans Modell weiter und legt +die Antwort zurück in denselben Eintrag. Abgeholt wird sie später über die zweite Anfrage. + +```bash +# absenden — antwortet in Millisekunden mit einer ID +ID=$(curl -s https://gateway.example/v1/responses \ + -H "Authorization: Bearer $MY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"qwen3-vl:30b-a3b","input":"Rechnungsnummer und Total als JSON."}' | jq -r .id) + +# abholen — sobald der Status auf «completed» steht +curl -s https://gateway.example/v1/responses/$ID \ + -H "Authorization: Bearer $MY_API_KEY" | jq -r '.output[0].content[0].text' +``` + +Die API ist die von OpenAI definierte: `POST /v1/chat/completions` und `POST /v1/responses` +funktionieren so, wie es jedes SDK erwartet. Der Unterschied liegt in der Antwort — statt auf das +Modell zu warten, kommt ein `202` mit einer ID zurück. Auch eine als synchron gedachte +Chat-Completion wird dabei in einen asynchronen Auftrag umgewandelt. Das ist kein Nebeneffekt, +sondern der Zweck: Eine Warteschlange, die man umgehen kann, ist keine. + +## Alles geht 1:1 durch + +Der Punkt, auf den wir beim Bauen am meisten geachtet haben: Das Gateway interpretiert die Anfrage +nicht. + +- **Der Body** geht unverändert nach oben. Unbekannte Felder werden nicht entfernt, ein fehlendes + `model` ist kein Fehler, es gibt keine eigene Validierung, die irgendwann hinter der API des + Modells zurückbleibt. +- **Die Header** werden aus einer festen Positivliste gespeichert und exakt so wieder gesendet, wie + sie ankamen — inklusive `Authorization` und der `X-Litellm-`-Tags, mit denen wir Kosten pro + Anwendungsfall auswerten. Alles andere wird verworfen statt weitergereicht; ein `Cookie` aus einem + Browser hat beim Modell nichts zu suchen. + +Daraus folgt der zweite Punkt: **Das Gateway besitzt keine Zugangsdaten.** Es gibt keinen +Master-Key und keine Benutzertabelle. Was den Aufruf beim Modell legitimiert, ist der Schlüssel des +Aufrufenden — so wie vorher auch, als noch direkt gerufen wurde. + +Weil der Aufruf nach oben erst stattfindet, wenn die ursprüngliche HTTP-Verbindung längst geschlossen +ist, müssen die Header zwischengelagert werden. Sie liegen verschlüsselt beim Eintrag, werden von der +API nie zurückgegeben, im Dashboard nur mit *Namen* angezeigt und gelöscht, sobald der Eintrag einen +Endzustand erreicht. Zurück bleibt ein SHA-256-Hash des Tokens — genug, um einen Eintrag seinem +Urheber zuzuordnen, zu wenig, um damit etwas anzustellen. + +:::callout{type="info" title="Wem ein Eintrag gehört"} +Lesen, abbrechen und löschen darf nur, wer denselben Schlüssel schickt, mit dem der Eintrag erstellt +wurde. Passt er nicht, antwortet die API mit `404` statt mit `403` — sie bestätigt eine ID nicht, die +sie ohnehin nicht ausliefern würde. +::: + +## Mehrere Modelle, mehrere Gateways + +Wer lokal arbeitet, hat selten genau eine Maschine. Bei uns sind es ein Mac mit Ollama, eine +LiteLLM-Instanz vor mehreren Modellen und, für Aufgaben ohne schützenswerten Inhalt, ein +kommerzieller Anbieter. + +Jedes dieser Ziele wird im Dashboard als «Gateway» erfasst und bekommt eine ID, die vor dem `/v1` +in der URL steht: + +```bash +curl https://gateway.example/v1/chat/completions # das Standard-Gateway +curl https://gateway.example/9f3c8b52-…/v1/chat/completions # ein bestimmtes +``` + +Ein SDK, das mit `base_url = https://gateway.example/{id}/v1` konfiguriert ist, spricht damit ohne +weitere Anpassung genau ein Ziel an — absenden und abholen. Drei Protokolle werden unterstützt: +OpenAI-kompatibel (also LiteLLM, vLLM, Ollamas eigener Shim und alles andere in dieser Form), +Anthropic und Ollama nativ. Bei den letzten beiden übersetzt das Gateway hin und zurück; bei +OpenAI-kompatiblen Zielen bleibt es reiner Durchgang. + +| Protokoll | Endpunkt beim Ziel | Body | +| --- | --- | --- | +| OpenAI-kompatibel | `{base}/v1/chat/completions` | unverändert durchgereicht | +| Anthropic | `{base}/v1/messages` | übersetzt hin und zurück | +| Ollama | `{base}/api/chat` | übersetzt hin und zurück | + +Ein Gateway zu deaktivieren stoppt nur *neue* Anfragen. Bereits eingereihte Einträge sind fest mit +dem Ziel verbunden, das sie bei der Annahme zugewiesen bekommen haben — ein Wechsel des Standards +lenkt also nie eine Antwort um, auf die jemand gerade wartet. + +## Warten mit Anzeige + +Eine Warteschlange, die nicht sagt, wie lange es dauert, ist auch nur eine Blackbox. Jede Antwort zu +einem wartenden Eintrag enthält deshalb einen eigenen Block: + +```json +"queue": { + "position": 3, + "depth": 7, + "workers": 2, + "estimated_seconds": 84 +} +``` + +`position` zählt den Eintrag selbst mit, `depth` ist der gesamte Rückstand, und `estimated_seconds` +rechnet bis zur *eigenen* Antwort, nicht bis zum Beginn der Bearbeitung — das ist die Zahl, die man +einem wartenden Menschen zeigen kann. + +Sie ist eine Hochrechnung, keine Zusage: der Median der letzten tatsächlichen Antwortzeiten für +dasselbe Modell, multipliziert mit der Anzahl Runden, die vor einem liegen. Was sie nicht sehen +kann, ist ein Modellwechsel — eine Anfrage, die Ollama zwingt, ein anderes Modell zu laden, dauert +deutlich länger, und kein Median über vergangene Antworten weiss davon im Voraus. + +## Was das Gateway bewusst nicht tut + +Wir halten die Grenzen für den ehrlicheren Teil einer Ankündigung: + +- **Kein Streaming.** Ein Rückstand und ein Token-Strom widersprechen sich. `stream: true` wird + zwar durchgereicht, die Antwort landet dann aber als Rohstrom im Eintrag. Wer Streaming braucht, + ruft das Modell direkt. +- **Abbrechen nur, solange etwas wartet.** Eine laufende Ollama-Anfrage lässt sich nicht sauber + stoppen — ein Abbruch im Zustand `in_progress` wäre ein Versprechen, das die Anwendung nicht + halten kann. +- **Keine Übersetzung von Tool-Calls.** Die Abbildung zwischen OpenAI-`tools` und Anthropic-`tool_use` + ist ein Thema für sich und ist nicht umgesetzt. +- **Bilder fallen auf den nativen Routen weg.** Auf den Wegen zu Anthropic und Ollama werden + multimodale Inhalte auf ihren Text reduziert, weil beide Bilder unterschiedlich genug + transportieren, dass Raten schlechter wäre als eine sichtbare Lücke. + +:::callout{type="warning" title="Ein Worker pro paralleler Anfrage"} +Mehr Worker zu starten, als das Modell gleichzeitig beantworten kann, verschiebt den Rückstand +lediglich aus der eigenen Datenbank in Ollamas undurchsichtige Warteschlange — also genau dorthin, +wo man ihn nicht mehr sieht. Bei Ollama entspricht die richtige Zahl `OLLAMA_NUM_PARALLEL`, in der +Regel zwei. +::: + +## Verfügbar unter MIT-Lizenz + +Das Gateway ist eine Laravel-Anwendung mit PostgreSQL, getestet mit Pest und statisch geprüft auf +PHPStan-Level 10. Es läuft bei uns auf einem Mac unter Herd, hinter einem Cloudflare Tunnel; die +Konfiguration dafür — launchd-Agents und Ingress-Regeln mit Default-Deny — liegt im Repository, +statt in der Shell-History von jemandem zu verschwinden. + +Wir veröffentlichen es so, wie wir es einsetzen. Es löst kein grosses Problem, sondern ein +konkretes: Es macht lokale Modelle für interne Werkzeuge planbar, ohne dass jedes dieser Werkzeuge +seine eigene Warteschlange erfinden muss. + +- [codebar-ag/llm-gateway.codebar.ai auf GitHub](https://github.com/codebar-ag/llm-gateway.codebar.ai) + +Fehlerberichte und Pull Requests sind willkommen. Sicherheitslücken bitte nicht über ein +öffentliches Issue, sondern auf dem in `SECURITY.md` beschriebenen Weg. diff --git a/database/files/news/en_CH/2025-04-06-docuware-7-12-is-here.md b/database/files/news/en_CH/2025-04-06-docuware-7-12-is-here.md index 13c6ade..397c36c 100644 --- a/database/files/news/en_CH/2025-04-06-docuware-7-12-is-here.md +++ b/database/files/news/en_CH/2025-04-06-docuware-7-12-is-here.md @@ -6,7 +6,7 @@ teaser: >- More automation and insight: this release improves e-invoice processing, brings IDP into the cloud configuration and opens workflow data up to analytics. published_at: 2025-04-06 author: sebastian.buergin@codebar.ch -hero: images/news/placeholders/docuware-7-12.svg +hero: images/news/placeholders/docuware-7-12-en.svg hero_alt: Placeholder graphic for the DocuWare 7.12 release tags: [DMS/ECM] --- diff --git a/database/files/news/en_CH/2025-09-15-docuware-7-13-is-here.md b/database/files/news/en_CH/2025-09-15-docuware-7-13-is-here.md index ef52f92..a49f3de 100644 --- a/database/files/news/en_CH/2025-09-15-docuware-7-13-is-here.md +++ b/database/files/news/en_CH/2025-09-15-docuware-7-13-is-here.md @@ -6,7 +6,7 @@ teaser: >- Workflows are now built in the browser, invoice intake copes with foreign e-invoice formats, and logging in can be secured with a second factor. published_at: 2025-09-15 author: sebastian.buergin@codebar.ch -hero: images/news/placeholders/docuware-7-13.svg +hero: images/news/placeholders/docuware-7-13-en.svg hero_alt: Placeholder graphic for the DocuWare 7.13 release tags: [DMS/ECM] --- diff --git a/database/files/news/en_CH/2026-05-12-docuware-7-14-is-here.md b/database/files/news/en_CH/2026-05-12-docuware-7-14-is-here.md index 9ce57c4..81c6b6b 100644 --- a/database/files/news/en_CH/2026-05-12-docuware-7-14-is-here.md +++ b/database/files/news/en_CH/2026-05-12-docuware-7-14-is-here.md @@ -6,7 +6,7 @@ teaser: >- The last settings move into the browser, a rebuilt app puts tasks on your phone, and file cabinets can be moved between two cloud organisations. published_at: 2026-05-12 author: sebastian.buergin@codebar.ch -hero: images/news/placeholders/docuware-7-14.svg +hero: images/news/placeholders/docuware-7-14-en.svg hero_alt: Placeholder graphic for the DocuWare 7.14 release tags: [DMS/ECM] --- diff --git a/database/files/news/en_CH/2026-07-29-llm-gateway-open-source.md b/database/files/news/en_CH/2026-07-29-llm-gateway-open-source.md new file mode 100644 index 0000000..5242f9e --- /dev/null +++ b/database/files/news/en_CH/2026-07-29-llm-gateway-open-source.md @@ -0,0 +1,182 @@ +--- +key: llm-gateway-open-source +slug: llm-gateway-open-source +title: A queue, not a holding pattern — our LLM Gateway is open source +teaser: >- + Local models answer the privacy question, not the capacity one. We built a gateway that accepts + requests for local models, stores them and works through them in order — and we are releasing it + under the MIT license. +published_at: 2026-07-29 +published: false +author: sebastian.buergin@codebar.ch +hero: images/templates/cover-template.jpg +hero_alt: Placeholder graphic for the LLM Gateway +tags: [Open Source, KI] +featured: false +--- + +We work with local language models every day. The reason is unglamorous: the moment a prompt +contains customer data, contracts or internal documents, the question is no longer which model +answers best, but who gets to see the request. A model running on your own hardware answers that by +itself — nothing leaves the building, there is no processing abroad, no data processing agreement to +negotiate, and no debate about whether inputs eventually end up in someone's training run. + +What local models do not come with is capacity. + +## The real problem is concurrency + +A local model is cheap to run and expensive to run *at the same time*. A single GPU — or a Mac's +unified memory — realistically holds one or two concurrent requests before switching between models +costs more than it saves. Ollama's `OLLAMA_NUM_PARALLEL` exists for exactly that reason: the ceiling +is hard, not soft. + +Past it, requests pile up. Whether you built a queue or not makes no difference to that — it only +changes *where* the queue forms. Either somewhere you can see it, or inside Ollama's own scheduler, +where a request that is merely waiting its turn looks identical to one that is stuck. + +For a single developer on a laptop, none of this matters. For internal tools that read invoices, +summarise minutes or classify documents, it quickly becomes an operational problem: one batch run +blocks everything else, a timeout in the calling system throws away an answer the model is still two +minutes away from producing, and nobody can say how long anything will take. + +## What we built instead + +The LLM Gateway is a queue in front of local models — and deliberately nothing else. + +The flow is simple: you send your request exactly as you would send it to the model. The gateway +accepts it, writes it to the database and answers immediately with an id. As soon as capacity frees +up, a worker takes the entry, replays it upstream unchanged and puts the answer back into the same +entry. You collect it with a second call. + +```bash +# submit — answers in milliseconds with an id +ID=$(curl -s https://gateway.example/v1/responses \ + -H "Authorization: Bearer $MY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"qwen3-vl:30b-a3b","input":"Extract invoice number and total as JSON."}' | jq -r .id) + +# collect — once the status reads "completed" +curl -s https://gateway.example/v1/responses/$ID \ + -H "Authorization: Bearer $MY_API_KEY" | jq -r '.output[0].content[0].text' +``` + +The API is the one OpenAI defined: `POST /v1/chat/completions` and `POST /v1/responses` behave the +way any SDK expects. The difference is in the response — instead of waiting for the model, you get a +`202` with an id. A chat completion meant to be synchronous is turned into an asynchronous job as +well. That is not a side effect but the point: a queue you can bypass is not a queue. + +## Everything passes through unchanged + +The thing we were most careful about while building it: the gateway does not interpret your request. + +- **The body** goes upstream untouched. Unknown fields are not stripped, a missing `model` is not an + error, and there is no validation of our own to eventually fall behind the model's actual API. +- **The headers** are stored from a fixed allowlist and replayed exactly as they arrived — + including `Authorization` and the `X-Litellm-` tags we use to attribute cost per use case. + Everything else is dropped rather than forwarded; a `Cookie` from a browser has no business + reaching a model. + +From which follows the second point: **the gateway holds no credentials.** There is no master key +and no user table. What authenticates the call upstream is the caller's own key — exactly as it was +when the call went direct. + +Because the upstream call happens long after the original HTTP connection is gone, those headers +have to be kept somewhere. They are stored encrypted with the entry, never returned by the API, +shown on the dashboard by *name* only, and cleared the moment the entry reaches a final status. What +remains is a SHA-256 hash of the token — enough to attribute an entry to its owner, not enough to do +anything with. + +:::callout{type="info" title="Who owns an entry"} +Reading, cancelling and deleting require the same key that created the entry. If it does not match, +the API answers `404` rather than `403` — it never confirms an id it would not serve anyway. +::: + +## Several models, several gateways + +Anyone working locally rarely has exactly one machine. In our case it is a Mac running Ollama, a +LiteLLM instance in front of several models, and — for work with nothing worth protecting in it — a +commercial provider. + +Each of those targets is registered in the dashboard as a "gateway" and gets an id that sits in +front of `/v1` in the URL: + +```bash +curl https://gateway.example/v1/chat/completions # the default gateway +curl https://gateway.example/9f3c8b52-…/v1/chat/completions # one specific gateway +``` + +An SDK configured with `base_url = https://gateway.example/{id}/v1` therefore talks to exactly one +target without any further changes — submitting and collecting alike. Three protocols are supported: +OpenAI-compatible (so LiteLLM, vLLM, Ollama's own shim and anything else in that shape), Anthropic, +and Ollama native. For the latter two the gateway translates out and back; for OpenAI-compatible +targets it stays a pure passthrough. + +| Protocol | Endpoint upstream | Body | +| --- | --- | --- | +| OpenAI compatible | `{base}/v1/chat/completions` | passed through untouched | +| Anthropic | `{base}/v1/messages` | translated out and back | +| Ollama | `{base}/api/chat` | translated out and back | + +Deactivating a gateway stops *new* requests only. Entries already queued stay bound to the target +they were assigned when they were accepted — so moving the default never redirects an answer someone +is already waiting for. + +## Waiting, with a number attached + +A queue that will not say how long it takes is just another black box. Every response about a +waiting entry therefore carries a block of its own: + +```json +"queue": { + "position": 3, + "depth": 7, + "workers": 2, + "estimated_seconds": 84 +} +``` + +`position` counts the entry itself, `depth` is the whole backlog, and `estimated_seconds` runs +through to *your* answer rather than to the moment work on it starts — that is the number worth +showing a person who is waiting. + +It is a projection, not a promise: the median of recent actual response times for the same model, +multiplied by the number of rounds sitting in front of you. What it cannot see is a model swap — a +request that forces Ollama to load a different model takes considerably longer, and no median over +past answers knows that in advance. + +## What the gateway deliberately does not do + +We consider the limits the more honest half of an announcement: + +- **No streaming.** A backlog and a token stream contradict each other. `stream: true` is passed + through, but the answer then lands in the entry as a raw stream. If you want streaming, call the + model directly. +- **Cancelling only while an entry is waiting.** A running Ollama request cannot be stopped cleanly + — cancelling something `in_progress` would be a promise the application cannot keep. +- **No translation of tool calls.** Mapping OpenAI `tools` to Anthropic `tool_use` is a subject of + its own and is not implemented. +- **Images are dropped on the native routes.** On the Anthropic and Ollama paths, multimodal content + is flattened to its text, because those two carry images differently enough that guessing would be + worse than a visibly missing image. + +:::callout{type="warning" title="One worker per concurrent request"} +Running more workers than the model can answer at once merely moves the backlog out of your own +database and into Ollama's opaque queue — precisely where you can no longer see it. With Ollama the +right number is `OLLAMA_NUM_PARALLEL`, usually two. +::: + +## Available under the MIT license + +The gateway is a Laravel application on PostgreSQL, tested with Pest and statically analysed at +PHPStan level 10. Ours runs on a Mac under Herd, behind a Cloudflare Tunnel; the configuration for +that — launchd agents and default-deny ingress rules — lives in the repository instead of +disappearing into somebody's shell history. + +We are publishing it the way we run it. It does not solve a large problem, it solves a specific one: +it makes local models predictable for internal tools, without every one of those tools having to +invent its own queue. + +- [codebar-ag/llm-gateway.codebar.ai on GitHub](https://github.com/codebar-ag/llm-gateway.codebar.ai) + +Bug reports and pull requests are welcome. For security vulnerabilities, please do not open a public +issue — use the process described in `SECURITY.md`. diff --git a/prompts/images-news.md b/prompts/images-news.md new file mode 100644 index 0000000..4055517 --- /dev/null +++ b/prompts/images-news.md @@ -0,0 +1,156 @@ +# Generating news hero placeholders + +Follow this file whenever a news article needs a hero image and no real photograph or +screenshot exists. The output is a **pair** of files per article — one SVG for the page, +one PNG for social crawlers. + +You do not hand-write these. `scripts/make-news-hero.py` lays out the type, because the +whole point of the design is that the text block starts at exactly the same place on every +hero regardless of how long the title is — and SVG has no text wrapping, so that only stays +true if something measures the font. Your job is the wording, the tags and the motif. + +## 1. Making one + +```bash +scripts/make-news-hero.py docuware-7-14 \ + --locale de \ + --title "DocuWare 7.14 ist da" \ + --tags DMS/ECM \ + --motif dms-ecm +``` + +Writes `public/images/news/placeholders/docuware-7-14-de.svg` and the matching `.png`, then +prints the front-matter lines to paste. + +- **slug** — lower-kebab, describes the *subject* (`docuware-7-14`), not the article. +- **`--locale`** — appended to the file name. **Every localised article needs its own + hero**, because the title is baked into the graphic: `DocuWare 7.14 ist da` and + `DocuWare 7.14 is here` cannot share one file. Omit `--locale` only for a hero with no + language in it. +- **`--title`** — the article's `title:` verbatim. Do not shorten it to make it fit; the + script steps the type size down instead. +- **`--tags`** — the article's `tags:`, in order, space-separated. Quote any that contain + spaces. Zero tags is allowed; the row just disappears and nothing else moves. +- **`--motif`** — see §4. + +Then wire it into the article's front matter, path relative to `public/`: + +```yaml +hero: images/news/placeholders/docuware-7-14-de.svg +hero_alt: Platzhaltergrafik zum DocuWare-Release 7.14 +``` + +`hero_alt` is real alt text and belongs in that locale's language — the script cannot write +it for you. `App\Support\NewsImage` resolves the `images/…` prefix as a local path, and +`NewsImage::ogImage()` swaps `.svg` for `.png` when emitting `og:image`. + +## 2. The layout + +1600×900, and it hangs off one fixed anchor on the left at x=80: + +| Element | Position | Notes | +|-------------|---------------------------------|------------------------------------------| +| Accent rule | x 80, y 200, 132×8 | fixed — the top of the block, always | +| Tag row | x 80, y 246, pills 50 tall | grows rightwards; absent if no tags | +| Title | x 80, first baseline y 390 | wraps within a 900-wide column | +| Motif | 1040, 180 → 1520, 660 | the one thing that changes per topic | +| Logo | 1256, 758 → 1520, 814 | codebar logo, bottom-right | + +**Nothing above the title moves.** A one-line title and a four-line title both start at +y=390; the long one simply runs further down. That is the property the whole design exists +to protect, so do not "balance" a short title by nudging it lower. + +The title auto-fits down a ladder of `(size, line-height, max lines)`: +`(76, 92, 3) → (64, 78, 4) → (56, 68, 4)`. The first rung whose wrap fits is used, and the +last rung's line count is what keeps the longest title clear of the logo. A title that +still overflows prints a warning — that is a signal to shorten the *article* title, not to +edit the layout. + +Margins are 80 left and right. The bottom is 80 × 1.0714, because the PNG export in §5 +compresses the vertical axis and that factor makes the margins land visually equal. + +**The background is fixed and identical on every hero**, in every language: the rings are +concentric at (150, 880) — bottom-left, so only arcs show — with radii 540 / 400 / 262 / 130, +the dot field is bottom-left behind them, and the band sits at x=900. None of it varies per +article or per locale. Two heroes differ only in their words and their motif; if you find +yourself wanting to vary the background to tell articles apart, change the motif instead. + +## 3. Palette and type + +Only these values. No new hues, ever. + +| Token | Hex | Used for | +|--------------|-----------|---------------------------------------------------| +| brand | `#500472` | rule, tag pills and text, every motif stroke/fill | +| brand-strong | `#3a0354` | the title | +| wash start | `#ffffff` | background gradient stop 0 | +| wash end | `#f4eef8` | background gradient stop 1 | +| paper | `#ffffff` | motif surfaces (cards, documents, panels) | + +The codebar logo is the one exception: it brings its own ink and its own magenta→blue +gradient strip. Those arrive with the asset and are never recoloured to match. + +Depth comes from `opacity` on brand, never from another colour: `0.05` (band) · `0.10–0.22` +(rings, tag pills, slots) · `0.16` (motif body lines) · `0.35–0.55` (motif accents) · `1.0` +(type). + +One typeface, Poppins 600, embedded as base64 in the SVG. That is not decoration: +`rsvg-convert` cannot fetch a webfont and Poppins is not a system font, so without embedding +the PNG silently falls back to Helvetica. The script handles it. + +## 4. Motifs — the only thing that changes per topic + +Pick with `--motif`. Current set, all defined at the bottom of `scripts/make-news-hero.py`: + +| Name | Reads as | Use for | +|-------------|-------------------------------------------------------|----------------------------| +| `dms-ecm` | a queue of stacked documents seen edge-on, each with the stage it has reached as a pill, the front one fully drawn, indexed and approved — the stack *is* the workflow | DMS/ECM, DocuWare, workflow, automation | +| `archive` | documents standing in an open drawer, one pulled up and indexed, magnifier over it — storage rather than processing | archiving, retention, migration | +| `editorial` | a page built from blocks, with a colour row | Styleguide, Redaktion, anything about the site | +| `documents` | a plain stack of documents, signed off | the neutral fallback | + +To add one, write a function that draws inside a **480×480 box at the origin** and register +it in `MOTIFS`. Nothing else in the layout changes. The rules that keep a new motif in the +family: + +- White surfaces with a `#500472` stroke at `stroke-opacity` 0.16–0.35, `stroke-width="2"`, + `rx="14"`–`rx="16"` on the large shapes. +- Content inside a surface is suggested, never literal: rounded bars (`rx` = half the + height) at `opacity="0.16"` for body text, one bar at `0.55` for a heading. +- Depth by stacking two or three offset copies at rising opacity (0.75 / 0.85 / 1.0), and + by putting a darker slot *behind* an element so things read as going into it — not by + shadows or blur. +- At most one flourish that carries meaning — a magnifier, a check, an arrow. +- No gradients inside a motif, no text, no third-party logos or trademarks. +- It must still read at ~300px wide, which is the size in the article card where most + people will see it. + +The whole motif group is tilted `rotate(-4)` about the box centre; leave that to the script. + +## 5. Rendering + +The script renders the PNG itself via `scripts/render-news-og.sh` (pass `--no-png` to skip). +To re-render every hero after changing the layout: + +```bash +scripts/render-news-og.sh +``` + +Requires `rsvg-convert` (`brew install librsvg`) and `fontTools` (`pip install fonttools`). +The PNG is **1200×630**, which is what `config/seo.php` declares in `og:image:width` / +`og:image:height` for every page. That squashes the 16:9 artwork vertically by 6.7 %; it is +an accepted trade-off and the layout is built around it. Do not change the export size +unless `config/seo.php` changes with it. + +## 6. Before you commit + +- [ ] One hero per locale, and the file name ends in the right `-de` / `-en`. +- [ ] Rings bottom-left at (150, 880) — the same on this hero as on every other one. +- [ ] Title in the graphic matches the article's `title:` exactly, in that locale. +- [ ] Tags in the graphic match `tags:`, same order. +- [ ] The script printed no warning about line count or tag-row width. +- [ ] SVG and PNG both exist, same basename, PNG is 1200×630. +- [ ] Open the PNG: the title is Poppins, not Helvetica. +- [ ] `hero:` and `hero_alt:` set in **every** locale file, alt text in that language. +- [ ] Viewed at card size (~300px): the motif still reads and the title is legible. +- [ ] `git status` shows no orphaned placeholder left behind by a rename. diff --git a/prompts/network.md b/prompts/network.md new file mode 100644 index 0000000..d09c142 --- /dev/null +++ b/prompts/network.md @@ -0,0 +1,123 @@ +# Generating network partner drawings + +Follow this file when a partner on `/network` has no usable logo file and needs a flat +drawing instead. Reference implementations: `public/images/network/docuware.svg` (a +partner drawing) and `public/images/placeholders/network-company.svg` (the generic +fallback). Open one of them alongside this document. + +These are a **different family** from the news heroes in `images-news.md` — much smaller, +no type, no gradients, and deliberately naive. Do not carry news-hero conventions over. + +## 1. Where the files go + +``` +public/images/network/.svg ← drawing for one specific partner +public/images/placeholders/network-company.svg ← the generic fallback, already exists +``` + +`` must match the partner's `key` column exactly — `resources/views/app/network/index.blade.php` +resolves `images/network/{$network->key}.svg` by `file_exists()`, with no registration step +anywhere. Get the key wrong and the card silently falls back to the generic placeholder. + +The cascade the view walks, in order: + +1. `cover_url` on the model (a real uploaded logo) — wins if set, +2. `images/network/.svg` — the drawing you are making, +3. `images/placeholders/network-company.svg` — generic, rendered at `opacity-70`. + +So a drawing is only ever a stand-in for a missing logo. When the partner supplies real +artwork, `cover_url` takes over and the SVG can be deleted. + +## 2. Canvas + +```xml +` has an empty `alt`. + +Keep the one-line HTML comment naming the subject — it is how the next person tells the +files apart without rendering them. + +Everything lives between **x 8–152** and **y 14–88**. There is no bleed and no +off-canvas geometry; at this size a clipped edge just looks like a bug. + +## 3. Palette + +Four values. Nothing else, and no gradients — flat fills only. + +| Token | Hex | Used for | +|---------|-----------|---------------------------------------------------| +| brand | `#500472` | one accent per drawing, plus the shrubs | +| mid | `#d1d5db` | the primary mass — the biggest shape | +| light | `#e5e7eb` | secondary masses, ground line | +| white | `#ffffff` | cut-outs: windows, handles, text lines | + +The brand colour is an **accent, not a fill**. One element carries it (a drawer, a roof +band, a magnifier, a door) at full strength or `opacity="0.65"`. If two things are purple, +one of them is wrong. + +## 4. Composition + +Five layers, in this order: + +1. **Ground line** — ``. + Identical in every drawing; it is what makes the set feel like a set. +2. **Primary mass** — the subject, in `#d1d5db`, roughly 40–45 units wide and 60–70 tall, + centred near x=80 and standing *on* the ground line (bottom edge at y=86). +3. **Secondary masses** — one or two supporting shapes in `#e5e7eb`, shorter than the + primary, flanking it. Never taller, or the silhouette goes flat. +4. **Detail** — white cut-outs at `rx="1"`–`rx="2"`: windows on a 10-unit grid, text lines + as `height="3" rx="1.5"` bars, handles as `width="8" height="4" rx="2"` pills. Suggest, + do not describe. +5. **Shrub accents** — always exactly these two, closing the composition: + +```xml + + +``` + +Corner radii: `rx="2"`–`rx="3"` on masses, `rx="1"`–`rx="2"` on details. Strokes only where +a shape is genuinely an outline (a magnifier ring) — `stroke-width="4"`, otherwise fill. + +## 5. Choosing the subject + +Draw what the partner *does*, in one object a person recognises at 64 px tall: + +- DMS/ECM → filing cabinet with a document pulled out (`docuware.svg`) +- generic company → three buildings, tallest in the middle (`network-company.svg`) + +Existing drawings to check before inventing another: +`baselhack` · `docuware` · `iway` · `odoo` · `pst` · `swiss-digital-services` · +`swiss-laravel-association` · `swiss-made-software` · `wieland-business-solutions`. + +Hard limits at this size: no text, no gradients, no shadows, no more than ~30 elements, and +**no third-party logos, wordmarks or trademarked shapes** — the whole point is that we do +not have the partner's mark. + +## 6. No codebar logo here + +Unlike the news heroes, these drawings carry **no codebar logo**. They stand in for +*another company's* logo inside that company's card — stamping our wordmark on it would +misattribute it. The 160×96 canvas has no room for a legible mark anyway. + +## 7. No PNG + +Network drawings are SVG only. They are decorative in-page images, never `og:image` +candidates, so nothing renders a raster copy. Do not run `scripts/render-news-og.sh` +against them. + +## 8. Before you commit + +- [ ] Filename equals the partner's `key` exactly (`Network::where('key', …)`). +- [ ] `viewBox="0 0 160 96"`, `role="img"`, `aria-hidden="true"`, subject named in a comment. +- [ ] Ground line and both shrub circles present, unmodified. +- [ ] Exactly one brand-coloured accent. +- [ ] No hex outside the table in §4; no gradients, no text, no partner trademark. +- [ ] Nothing outside x 8–152 / y 14–88. +- [ ] Checked at 64 px tall — the silhouette still reads. +- [ ] Partner card renders the drawing, not the generic placeholder (`cover_url` is null). diff --git a/public/images/news/placeholders/docuware-7-12-de.png b/public/images/news/placeholders/docuware-7-12-de.png new file mode 100644 index 0000000..5287097 Binary files /dev/null and b/public/images/news/placeholders/docuware-7-12-de.png differ diff --git a/public/images/news/placeholders/docuware-7-14.svg b/public/images/news/placeholders/docuware-7-12-de.svg similarity index 59% rename from public/images/news/placeholders/docuware-7-14.svg rename to public/images/news/placeholders/docuware-7-12-de.svg index dcca212..dd5d698 100644 --- a/public/images/news/placeholders/docuware-7-14.svg +++ b/public/images/news/placeholders/docuware-7-12-de.svg @@ -1,5 +1,6 @@ diff --git a/public/images/news/placeholders/docuware-7-12-en.png b/public/images/news/placeholders/docuware-7-12-en.png new file mode 100644 index 0000000..431e68c Binary files /dev/null and b/public/images/news/placeholders/docuware-7-12-en.png differ diff --git a/public/images/news/placeholders/docuware-7-12-en.svg b/public/images/news/placeholders/docuware-7-12-en.svg new file mode 100644 index 0000000..f8f5738 --- /dev/null +++ b/public/images/news/placeholders/docuware-7-12-en.svg @@ -0,0 +1,121 @@ + diff --git a/public/images/news/placeholders/docuware-7-12.png b/public/images/news/placeholders/docuware-7-12.png deleted file mode 100644 index 1a9a1c4..0000000 Binary files a/public/images/news/placeholders/docuware-7-12.png and /dev/null differ diff --git a/public/images/news/placeholders/docuware-7-13-de.png b/public/images/news/placeholders/docuware-7-13-de.png new file mode 100644 index 0000000..e2bf56f Binary files /dev/null and b/public/images/news/placeholders/docuware-7-13-de.png differ diff --git a/public/images/news/placeholders/docuware-7-12.svg b/public/images/news/placeholders/docuware-7-13-de.svg similarity index 59% rename from public/images/news/placeholders/docuware-7-12.svg rename to public/images/news/placeholders/docuware-7-13-de.svg index 1b2e451..bd888f7 100644 --- a/public/images/news/placeholders/docuware-7-12.svg +++ b/public/images/news/placeholders/docuware-7-13-de.svg @@ -1,5 +1,6 @@ diff --git a/public/images/news/placeholders/docuware-7-13-en.png b/public/images/news/placeholders/docuware-7-13-en.png new file mode 100644 index 0000000..9d94961 Binary files /dev/null and b/public/images/news/placeholders/docuware-7-13-en.png differ diff --git a/public/images/news/placeholders/docuware-7-13-en.svg b/public/images/news/placeholders/docuware-7-13-en.svg new file mode 100644 index 0000000..5031364 --- /dev/null +++ b/public/images/news/placeholders/docuware-7-13-en.svg @@ -0,0 +1,121 @@ + diff --git a/public/images/news/placeholders/docuware-7-13.png b/public/images/news/placeholders/docuware-7-13.png deleted file mode 100644 index a68bcf0..0000000 Binary files a/public/images/news/placeholders/docuware-7-13.png and /dev/null differ diff --git a/public/images/news/placeholders/docuware-7-14-de.png b/public/images/news/placeholders/docuware-7-14-de.png new file mode 100644 index 0000000..471229c Binary files /dev/null and b/public/images/news/placeholders/docuware-7-14-de.png differ diff --git a/public/images/news/placeholders/docuware-7-13.svg b/public/images/news/placeholders/docuware-7-14-de.svg similarity index 59% rename from public/images/news/placeholders/docuware-7-13.svg rename to public/images/news/placeholders/docuware-7-14-de.svg index e7e1504..e535318 100644 --- a/public/images/news/placeholders/docuware-7-13.svg +++ b/public/images/news/placeholders/docuware-7-14-de.svg @@ -1,5 +1,6 @@ diff --git a/public/images/news/placeholders/docuware-7-14-en.png b/public/images/news/placeholders/docuware-7-14-en.png new file mode 100644 index 0000000..c8e6389 Binary files /dev/null and b/public/images/news/placeholders/docuware-7-14-en.png differ diff --git a/public/images/news/placeholders/docuware-7-14-en.svg b/public/images/news/placeholders/docuware-7-14-en.svg new file mode 100644 index 0000000..4f7b981 --- /dev/null +++ b/public/images/news/placeholders/docuware-7-14-en.svg @@ -0,0 +1,121 @@ + diff --git a/public/images/news/placeholders/docuware-7-14.png b/public/images/news/placeholders/docuware-7-14.png deleted file mode 100644 index 3f8f901..0000000 Binary files a/public/images/news/placeholders/docuware-7-14.png and /dev/null differ diff --git a/scripts/__pycache__/make-news-hero.cpython-314.pyc b/scripts/__pycache__/make-news-hero.cpython-314.pyc new file mode 100644 index 0000000..c835723 Binary files /dev/null and b/scripts/__pycache__/make-news-hero.cpython-314.pyc differ diff --git a/scripts/make-news-hero.py b/scripts/make-news-hero.py new file mode 100755 index 0000000..12b5a78 --- /dev/null +++ b/scripts/make-news-hero.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +""" +Generates a news hero placeholder (SVG + og:image PNG) from a title and its tags. + +The point of doing this in code rather than by hand is that the text block must start at +exactly the same place on every hero, whatever the title's length. Poppins is measured with +fontTools, the title is wrapped to the column and the type size steps down if it needs to, +but the accent rule, the tag row and the first baseline never move. + + scripts/make-news-hero.py docuware-7-14 \ + --title "DocuWare 7.14 ist da" \ + --tags DMS/ECM \ + --motif dms-ecm + +See prompts/images-news.md for the layout spec and for how to add a motif. +""" + +from __future__ import annotations + +import argparse +import base64 +import pathlib +import re +import subprocess +import sys + +from fontTools.ttLib import TTFont + +ROOT = pathlib.Path(__file__).resolve().parent.parent +FONT = ROOT / 'public/fonts/poppins/poppins-600-normal-latin.woff2' +LOGO = ROOT / 'public/images/logos/codebar-logo-colored.svg' +OUT = ROOT / 'public/images/news/placeholders' + +# ---------------------------------------------------------------- canvas & grid + +W, H = 1600, 900 + +LEFT = 80 # left margin — where every text block begins +COL_W = 900 # type column width, x 80 → 980 + +RULE_Y = 200 # accent rule, the fixed top anchor of the block +TAG_TOP = 246 # tag row +TAG_H = 50 +TAG_GAP = 16 +TAG_PAD = 26 +TAG_SIZE = 24 +TAG_LS = 2 + +TITLE_TOP = 390 # first baseline — never moves +# (font size, line height, max lines) — first rung whose wrap fits is used. The line counts +# are what keeps the longest title clear of the logo at the bottom. +TITLE_STEPS = [(76, 92, 3), (64, 78, 4), (56, 68, 4)] +TITLE_LS = -1.5 + +MOTIF_X, MOTIF_Y, MOTIF_BOX = 1040, 180, 480 + +LOGO_W = 264 +LOGO_H = LOGO_W * 120 / 565 +LOGO_X = W - LEFT - LOGO_W +# The PNG export squashes y by 0.7 against x's 0.75, so the bottom margin is scaled up to +# land visually equal to the left/right one. +LOGO_Y = H - LEFT * 1.0714 - LOGO_H + +BRAND = '#500472' +BRAND_STRONG = '#3a0354' + +# One fixed centre and one fixed set of radii for every hero, in every language. The +# background is not a place to vary things: only the words and the motif change. +RING_CX, RING_CY = 150, 880 +RING_RADII = ((540, '0.10'), (400, '0.14'), (262, '0.18'), (130, '0.10')) + + +# ---------------------------------------------------------------- text metrics + +class Metrics: + def __init__(self, path: pathlib.Path): + font = TTFont(path) + self.cmap = font.getBestCmap() + self.hmtx = font['hmtx'] + self.upem = font['head'].unitsPerEm + + def width(self, text: str, size: float, letter_spacing: float = 0.0) -> float: + units = 0 + for ch in text: + glyph = self.cmap.get(ord(ch)) + units += self.hmtx[glyph][0] if glyph else 0 + ink = units / self.upem * size + return ink + letter_spacing * max(len(text) - 1, 0) + + +def wrap(metrics: Metrics, text: str, size: float, max_width: float) -> list[str]: + lines: list[str] = [] + current = '' + + for word in text.split(): + candidate = f'{current} {word}'.strip() + if current and metrics.width(candidate, size, TITLE_LS) > max_width: + lines.append(current) + current = word + else: + current = candidate + + if current: + lines.append(current) + + return lines + + +def fit_title(metrics: Metrics, title: str) -> tuple[list[str], int, int]: + for size, line_height, max_lines in TITLE_STEPS: + lines = wrap(metrics, title, size, COL_W) + if len(lines) <= max_lines: + return lines, size, line_height + + size, line_height, max_lines = TITLE_STEPS[-1] + lines = wrap(metrics, title, size, COL_W) + print(f' ! title needs {len(lines)} lines, {max_lines} is the limit — shorten it', + file=sys.stderr) + return lines, size, line_height + + +# ---------------------------------------------------------------- svg fragments + +def escape(text: str) -> str: + return text.replace('&', '&').replace('<', '<').replace('>', '>') + + +def font_face() -> str: + payload = base64.b64encode(FONT.read_bytes()).decode() + return f""" @font-face {{ + font-family: 'Poppins'; + font-style: normal; + font-weight: 600; + src: url(data:font/woff2;base64,{payload}) format('woff2'); + }}""" + + +def background() -> str: + rings = '\n'.join( + f' ' + for r, o in RING_RADII + ) + + return f""" + + + + + + +{rings} + + + + """ + + +def tag_row(metrics: Metrics, tags: list[str]) -> str: + if not tags: + return '' + + parts = [' ', ' '] + x = LEFT + baseline = TAG_TOP + TAG_H / 2 + TAG_SIZE * 0.35 + + for tag in tags: + label = tag.upper() + text_w = metrics.width(label, TAG_SIZE, TAG_LS) + pill_w = text_w + 2 * TAG_PAD + + if x + pill_w > LEFT + COL_W: + print(f' ! tag row wider than the column, dropping «{tag}» and the rest', + file=sys.stderr) + break + + parts.append( + f' ' + ) + parts.append( + f' ' + f'{escape(label)}' + ) + x += pill_w + TAG_GAP + + parts.append(' ') + return '\n'.join(parts) + + +def title_block(lines: list[str], size: int, line_height: int) -> str: + spans = '\n'.join( + f' {escape(line)}' + for i, line in enumerate(lines) + ) + return f""" + +{spans} + """ + + +def logo() -> str: + inner = LOGO.read_text().split('>', 2)[2].rsplit('', 1)[0].strip() + inner = ' ' + inner.replace('\n ', '\n ') + return f""" + +{inner} + """ + + +# ---------------------------------------------------------------- motifs +# +# Each motif draws inside a 480x480 box at the origin. Add one by writing a function and +# registering it in MOTIFS; nothing else in the layout needs to change. + +def motif_dms_ecm() -> str: + """A queue of documents, each carrying the stage it has reached.""" + return f""" + + + + + + + + + + + + + + + + + + + + + + + + + """ + + +def motif_archive() -> str: + """Storage rather than processing: documents standing in an open drawer.""" + return f""" + + + + + + + + + + + + + + + + + + + + + + + """ + + +def motif_editorial() -> str: + """A page made of blocks: styleguide, redaction, anything about the site itself.""" + return f""" + + + + + + + + + + + + + + """ + + +def motif_documents() -> str: + """The neutral fallback: a stack of documents, signed off.""" + return f""" + + + + + + + + + + + """ + + +MOTIFS = { + 'dms-ecm': motif_dms_ecm, + 'archive': motif_archive, + 'editorial': motif_editorial, + 'documents': motif_documents, +} + + +def motif_block(name: str) -> str: + if name not in MOTIFS: + raise SystemExit(f'unknown motif «{name}» — available: {", ".join(sorted(MOTIFS))}') + + half = MOTIF_BOX / 2 + return f""" + +{MOTIFS[name]()} + """ + + +# ---------------------------------------------------------------- assembly + +def build(title: str, tags: list[str], motif: str) -> str: + metrics = Metrics(FONT) + lines, size, line_height = fit_title(metrics, title) + + return f""" +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('slug', help='file name stem, e.g. docuware-7-14') + parser.add_argument('--title', required=True, help='the article title, verbatim') + parser.add_argument('--tags', nargs='*', default=[], help='one or more tags') + parser.add_argument('--motif', default='documents', + help=f'one of: {", ".join(sorted(MOTIFS))}') + parser.add_argument('--locale', help='de, en, … — appended to the file name. The hero ' + 'carries the title, so each locale needs its own.') + parser.add_argument('--no-png', action='store_true', help='skip the og:image render') + args = parser.parse_args() + + if not re.fullmatch(r'[a-z0-9]+(-[a-z0-9]+)*', args.slug): + raise SystemExit(f'slug must be lower-kebab, got «{args.slug}»') + + name = f'{args.slug}-{args.locale}' if args.locale else args.slug + + OUT.mkdir(parents=True, exist_ok=True) + svg = OUT / f'{name}.svg' + svg.write_text(build(args.title, args.tags, args.motif)) + print(f'wrote {svg.relative_to(ROOT)}') + + if not args.no_png: + subprocess.run([str(ROOT / 'scripts/render-news-og.sh'), str(svg)], check=True) + + print(f'\nfront matter:\n hero: images/news/placeholders/{name}.svg' + f'\n hero_alt: ') + + +if __name__ == '__main__': + main() diff --git a/scripts/render-news-og.sh b/scripts/render-news-og.sh new file mode 100755 index 0000000..453e9af --- /dev/null +++ b/scripts/render-news-og.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# +# Renders the og:image PNG counterpart for every news hero SVG. +# +# Social crawlers do not render SVG, so app/Support/NewsImage::ogImage() looks for a +# same-named .png next to the .svg. The size is fixed at 1200x630 because that is what +# config/seo.php declares in og:image:width / og:image:height for every page. +# +# Usage: scripts/render-news-og.sh [file.svg ...] (no args = all news placeholders) + +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! command -v rsvg-convert >/dev/null 2>&1; then + echo "rsvg-convert missing — install with: brew install librsvg" >&2 + exit 1 +fi + +files=("$@") + +if [ ${#files[@]} -eq 0 ]; then + files=(public/images/news/placeholders/*.svg) +fi + +for svg in "${files[@]}"; do + png="${svg%.svg}.png" + rsvg-convert -w 1200 -h 630 "$svg" -o "$png" + echo "rendered $png" +done