diff --git a/README.md b/README.md index 25858dd..99b754e 100644 --- a/README.md +++ b/README.md @@ -1,306 +1,133 @@
-
-
-
+
+ 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/