openapi: 3.1.0
info:
  title: Initiative 18 — Brand Suitability API
  version: 1.0.0
  description: |
    Kontextuelle Brand Safety API der Initiative 18. Klassifiziert Artikel gegen ein
    3-Schichten-Modell: Universal Legal Safety, branchenspezifische Basis-Cluster und
    optionale Premium-Profile.

    ## Authentifizierung

    Alle POST-Requests werden per HMAC-SHA256 signiert. GET-Requests nutzen den
    Publisher Key als Query-Parameter.

    ### HMAC-Signierung (POST)

    ```
    Payload = "{METHOD}:{PATH}:{timestamp}.{request_body}"
    Signature = HMAC-SHA256(api_secret, payload)
    ```

    Beispiel für `POST /api/v1/classify`:
    ```
    Payload = "POST:/api/v1/classify:1713600000.{\"url\":\"...\",\"title\":\"...\"}"
    ```

    **Required Headers:**
    - `X-Publisher-Key`: Ihr Publisher API Key (z.B. `pub_a1b2c3d4`)
    - `X-Request-Timestamp`: Unix-Timestamp in Sekunden
    - `X-Request-Signature`: HMAC-SHA256 Hex-Digest

    Der Timestamp darf maximal 5 Minuten von der Serverzeit abweichen (Replay-Schutz).
    Method und Path sind Teil der Signatur, damit eine gültige Signatur nicht
    zwischen Endpoints replay-bar ist.

    ### API Key (GET)

    Score-Lookup und Status-Abfragen nutzen den Publisher Key als `pk` Query-Parameter:
    ```
    GET /scores/{hash}?pk=pub_a1b2c3d4
    ```

    ## 3-Schichten-Modell

    | Schicht | Beschreibung | Score-Key |
    |---------|-------------|-----------|
    | **Legal Safety** | Universelle Blockierregeln (Gewalt, Terror, etc.) | `legal_safety.verdict` |
    | **Branchen-Basis** | Pro Ini18-Branche (INI_LEH, INI_AUTO, etc.) | `industry_scores.INI_*` |
    | **Premium** | Individuelle Werbekunden-Profile (opake IDs) | `premium_scores.p_*` |
    | **Personas** (opt-in) | Audience-Affinität pro Zielgruppen-Persona (opake IDs) | `persona_scores.ps_*` |

    Branchen-Scores werden zentral berechnet und geteilt. Premium-Scores nur bei
    Publisher-Agentur-Zuordnung.

    ## Personas (Audience-Targeting, opt-in)

    Persona-Scores sind ein opt-in-Feature pro Publisher. Ist es NICHT aktiviert,
    fehlen die Felder `persona_scores` und `audience_segments` vollständig —
    bestehende Integrationen bleiben unverändert.

    Semantik (dreistufig, wichtig für die Auswertung):
    - **Felder fehlen komplett** → Feature nicht aktiviert.
    - **Persona-ID fehlt im Block** → in diesem Durchlauf nicht bewertet
      (z.B. KI-Budget erschöpft) — NICHT als "kein Match" interpretieren.
    - **`affinity: 0, match: false`** → bewertet, keine Affinität.

    Anders als Suitability-Scores sind Persona-Scores POSITIVE Signale:
    `match: true` heißt "dieses Umfeld passt zur Zielgruppe". Matches erscheinen
    als `aud_{id}` im separaten Feld `audience_segments` — das bestehende
    `segments`-Feld bleibt ausschließlich Brand-Safety (`safe_*`). Die IDs sind
    opak; ihre Bedeutung wird ausschließlich über den Deal mit der Agentur
    vereinbart.

    ## Rate Limits

    - 100 Requests/Minute
    - 10.000 Requests/Tag
    - Bei Uberschreitung: HTTP 429

    ## Lockout

    Nach 10 fehlgeschlagenen Auth-Versuchen innerhalb von 10 Minuten wird der
    Publisher fur 15 Minuten gesperrt.

  contact:
    name: Initiative 18
    url: https://initiative18.org
    email: norman.wagner@initiative18.org

servers:
  - url: https://bsm.initiative18.org/api/v1
    description: Produktion
  - url: http://localhost:3000/api/v1
    description: Lokale Entwicklung

security:
  - HmacAuth: []

paths:
  /classify:
    post:
      operationId: classifyArticle
      summary: Artikel klassifizieren
      description: |
        Klassifiziert einen einzelnen Artikel gegen alle 3 Schichten.
        Ergebnis wird gecacht (30 Tage, invalidiert bei Content- oder Config-Anderung).
      tags: [Klassifizierung]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClassifyRequest'
            example:
              url: "https://example.de/politik/klimapolitik-debatte"
              title: "Klimapolitik: Neue Gesetze ab 2027"
              content: "Die Bundesregierung hat neue Klimaschutzgesetze beschlossen..."
              language: "de"
      responses:
        '200':
          description: Klassifizierung erfolgreich
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClassifyResponse'
              example:
                url: "https://example.de/politik/klimapolitik-debatte"
                url_hash: "u8f3k2m9x4p1"
                classified_at: "2026-04-13T14:00:12Z"
                analysis_method: "ai_verified"
                legal_safety:
                  verdict: "allow"
                industry_scores:
                  INI_LEH:
                    score: 92
                    verdict: "allow"
                    label: "Lebensmitteleinzelhandel"
                  INI_AUTO:
                    score: 88
                    verdict: "allow"
                    label: "Automobil"
                  INI_ENERGIE:
                    score: 45
                    verdict: "review"
                    label: "Energie & Versorger"
                premium_scores:
                  p_a7f3e2:
                    score: 90
                    verdict: "allow"
                segments:
                  - "safe_INI_LEH"
                  - "safe_INI_AUTO"
                  - "safe_p_a7f3e2"
                config_version: "2026-04-13T12:00:00Z"
                cached: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /classify/batch:
    post:
      operationId: classifyBatch
      summary: Batch-Klassifizierung (bis 50 Artikel)
      description: |
        Klassifiziert mehrere Artikel in einem Request. Artikel werden sequentiell
        verarbeitet. Fehler einzelner Artikel stoppen nicht den gesamten Batch.
      tags: [Klassifizierung]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ClassifyBatchRequest'
      responses:
        '200':
          description: Batch-Ergebnis
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ClassifyBatchResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'

  /scores/{hash}:
    get:
      operationId: getScoresByHash
      summary: Scores per URL-Hash abrufen
      description: |
        Gibt gecachte Scores fur einen bereits klassifizierten Artikel zuruck.
        Optimal fur Prebid RTD Module (Latenz < 50ms).
      tags: [Score-Abfrage]
      security:
        - ApiKeyQuery: []
      parameters:
        - name: hash
          in: path
          required: true
          schema:
            type: string
          description: URL-Hash (12 Zeichen, aus `/classify` Response)
          example: "u8f3k2m9x4p1"
        - name: pk
          in: query
          required: true
          schema:
            type: string
          description: Publisher API Key
          example: "pub_a1b2c3d4"
      responses:
        '200':
          description: Scores gefunden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScoresResponse'
          headers:
            Cache-Control:
              schema:
                type: string
                example: "public, max-age=900, s-maxage=900"
        '404':
          description: Artikel nicht gefunden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              example:
                error: "Artikel nicht gefunden"
                code: "NOT_FOUND"

  /scores/by-external-id/{external_id}:
    get:
      operationId: getScoresByExternalId
      summary: Scores per External ID abrufen
      description: |
        Alternative zum URL-Hash-Lookup fur CMS-Pipelines, die Artikel per
        `external_id` statt URL identifizieren.
      tags: [Score-Abfrage]
      security:
        - ApiKeyQuery: []
      parameters:
        - name: external_id
          in: path
          required: true
          schema:
            type: string
          description: CMS-interne Artikel-ID
          example: "cms-article-98231"
        - name: pk
          in: query
          required: true
          schema:
            type: string
          description: Publisher API Key
      responses:
        '200':
          description: Scores gefunden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScoresResponse'
        '404':
          description: Artikel nicht gefunden

  /status:
    get:
      operationId: getStatus
      summary: API-Status und Publisher-Info
      description: Health Check und Konfigurationsinformationen.
      tags: [System]
      security:
        - ApiKeyQuery: []
      parameters:
        - name: pk
          in: query
          required: true
          schema:
            type: string
          description: Publisher API Key
      responses:
        '200':
          description: API ist erreichbar
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatusResponse'
              example:
                status: "healthy"
                version: "1.0.0"
                publisher:
                  name: "Axel Springer"
                  domain: "axelspringer.de"
                  total_articles: 12450
                  rate_limit:
                    per_minute: 100
                    per_day: 10000

components:
  securitySchemes:
    HmacAuth:
      type: apiKey
      in: header
      name: X-Publisher-Key
      description: |
        HMAC-SHA256 signierte Requests. Erfordert drei Headers:
        `X-Publisher-Key`, `X-Request-Signature`, `X-Request-Timestamp`.
    ApiKeyQuery:
      type: apiKey
      in: query
      name: pk
      description: Publisher API Key als Query-Parameter (fur GET-Requests).

  schemas:
    ClassifyRequest:
      type: object
      required: [title, content]
      properties:
        url:
          type: string
          format: uri
          description: Artikel-URL. Optional wenn `external_id` angegeben.
          example: "https://example.de/politik/artikel-123"
        external_id:
          type: string
          maxLength: 256
          description: CMS-interne Artikel-ID. Optional wenn `url` angegeben.
          example: "cms-article-98231"
        title:
          type: string
          description: Artikel-Titel.
          example: "Klimapolitik: Neue Gesetze ab 2027"
        content:
          type: string
          description: |
            Artikel-Inhalt. Längere Inhalte werden serverseitig auf 20.000 Zeichen
            gekürzt (an Satzgrenze wenn möglich), kein Fehler bei Überlänge.
        published_at:
          type: string
          format: date-time
          description: Veroffentlichungszeitpunkt (ISO 8601).
        language:
          type: string
          default: "de"
          description: Sprache des Artikels.
      anyOf:
        - required: [url]
        - required: [external_id]

    ClassifyBatchRequest:
      type: object
      required: [articles]
      properties:
        articles:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/ClassifyRequest'

    ClassifyResponse:
      type: object
      properties:
        url:
          type: string
          description: Echo der URL (wenn angegeben).
        url_hash:
          type: string
          description: Deterministischer URL-Hash (12 Zeichen).
        external_id:
          type: string
          description: Echo der external_id (wenn angegeben).
        classified_at:
          type: string
          format: date-time
        analysis_method:
          type: string
          enum: [ai_verified, keyword_only, no_match]
          description: |
            - `ai_verified`: KI hat den Kontext gepruft
            - `keyword_only`: Nur Keyword-Match (kein KI-Budget)
            - `no_match`: Keine Keywords gematcht
        legal_safety:
          type: object
          properties:
            verdict:
              type: string
              enum: [allow, block]
          description: Universelles Legal-Safety-Urteil.
        industry_scores:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/IndustryScore'
          description: |
            Scores pro Ini18-Branche. Keys sind Ini18-Codes (z.B. `INI_LEH`, `INI_AUTO`).
            Nur Branchen mit Keyword-Match enthalten.
        premium_scores:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/PremiumScore'
          description: |
            Scores pro Premium-Profil. Keys sind opake IDs (z.B. `p_a7f3e2`).
            Nur bei Publisher-Agentur-Zuordnung.
        persona_scores:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/PersonaScore'
          description: |
            OPT-IN — Feld fehlt komplett, wenn das Feature für den Publisher
            nicht aktiviert ist. Audience-Affinität pro Zielgruppen-Persona.
            Keys sind opake IDs (z.B. `ps_4f2a91`); die Bedeutung wird nur über
            den Agentur-Deal vereinbart. Fehlt eine Persona-ID im Block, wurde
            sie in diesem Durchlauf nicht bewertet — nicht als "kein Match"
            interpretieren.
        audience_segments:
          type: array
          items:
            type: string
          description: |
            OPT-IN — Feld fehlt komplett, wenn das Feature nicht aktiviert ist.
            Positive Audience-Segmente. Format: `aud_{persona_id}` für jede
            Persona mit `match: true`. Bewusst getrennt von `segments`
            (Brand-Safety), damit Suitability- und Targeting-Signale
            unabhängig ausgesteuert werden können.
          example: ["aud_ps_4f2a91"]
        segments:
          type: array
          items:
            type: string
          description: |
            Brand-Safety-Segments fur den Ad-Server. Format: `safe_{code}`.
            Enthalt alle Branchen und Profile mit Verdict "allow".
            Enthaelt KEINE Audience-Segmente (siehe `audience_segments`).
          example: ["safe_INI_LEH", "safe_INI_AUTO", "safe_p_a7f3e2"]
        config_version:
          type: string
          format: date-time
          description: Zeitstempel der letzten Cluster-Anderung.
        cached:
          type: boolean
          description: Ob das Ergebnis aus dem Cache kam.

    IndustryScore:
      type: object
      properties:
        score:
          type: integer
          minimum: 0
          maximum: 100
        verdict:
          type: string
          enum: [block, allow, review, no_match]
        label:
          type: string
          description: Deutscher Branchenname.
          example: "Lebensmitteleinzelhandel"

    PremiumScore:
      type: object
      properties:
        score:
          type: integer
          minimum: 0
          maximum: 100
        verdict:
          type: string
          enum: [block, allow, review, no_match]

    PersonaScore:
      type: object
      properties:
        affinity:
          type: integer
          minimum: 0
          maximum: 100
          description: |
            Affinität des Artikel-Umfelds zur Persona (0 = keine, 100 = maximale
            Resonanz). POSITIVES Signal — kein Risiko-Score.
        match:
          type: boolean
          description: Affinität erreicht den persona-spezifischen Schwellwert.

    ClassifyBatchResponse:
      type: object
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/ClassifyResponse'
        errors:
          type: array
          items:
            type: object
            properties:
              url:
                type: string
              external_id:
                type: string
              error:
                type: string
        total:
          type: integer
        succeeded:
          type: integer
        failed:
          type: integer

    ScoresResponse:
      type: object
      properties:
        url_hash:
          type: string
        external_id:
          type: string
        legal_safety:
          type: object
          properties:
            verdict:
              type: string
              enum: [allow, block]
        industry_scores:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/IndustryScore'
        premium_scores:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/PremiumScore'
        persona_scores:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/PersonaScore'
          description: OPT-IN — fehlt ohne Aktivierung (siehe ClassifyResponse).
        audience_segments:
          type: array
          items:
            type: string
          description: OPT-IN — fehlt ohne Aktivierung (siehe ClassifyResponse).
        segments:
          type: array
          items:
            type: string
        analysis_method:
          type: string
          enum: [ai_verified, keyword_only, no_match]
        classified_at:
          type: string
          format: date-time
        config_version:
          type: string
          format: date-time

    StatusResponse:
      type: object
      properties:
        status:
          type: string
          enum: [healthy, degraded]
        version:
          type: string
        publisher:
          type: object
          properties:
            name:
              type: string
            domain:
              type: string
            total_articles:
              type: integer
            rate_limit:
              type: object
              properties:
                per_minute:
                  type: integer
                per_day:
                  type: integer

    ApiError:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
        code:
          type: string
        details:
          type: string

  responses:
    BadRequest:
      description: Ungultige Anfrage
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            error: "Pflichtfelder fehlen"
            code: "MISSING_FIELDS"
            details: "title und content sind erforderlich."
    Unauthorized:
      description: Authentifizierung fehlgeschlagen
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            error: "Ungultige Signatur"
            code: "AUTH_INVALID_SIGNATURE"
    UnprocessableEntity:
      description: Validierungsfehler
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            error: "Batch zu groß"
            code: "BATCH_TOO_LARGE"
            details: "Maximal 50 Artikel pro Batch."
    RateLimited:
      description: Rate Limit uberschritten
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            error: "Rate Limit uberschritten"
            code: "RATE_LIMIT_MINUTE"
            details: "Maximal 100 Requests pro Minute."
    InternalError:
      description: Interner Serverfehler
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            error: "Interner Serverfehler"
            code: "INTERNAL_ERROR"

tags:
  - name: Klassifizierung
    description: Artikel klassifizieren und Scores berechnen.
  - name: Score-Abfrage
    description: Vorberechnete Scores abrufen.
  - name: System
    description: Health Check und Konfiguration.
