Skip to content

Technical details

Inhaltsverzeichnis

  1. Projektübersicht
  2. Architektur
  3. Voraussetzungen
  4. Konfiguration (.env)
  5. Dateien im Detail
  6. skill_generator_v2.py
  7. proxy_v2.py
  8. diagnose.py
  9. Dockerfile
  10. docker-compose.yml
  11. Installation & Deployment
  12. OpenWebUI konfigurieren
  13. Verfügbare Tools & Verwendung
  14. Wartung & Troubleshooting

1. Projektübersicht

Dieses Projekt verbindet eine SharePoint-Produktdatenbank (Microsoft Graph API) mit einem KI-Chatbot (OpenWebUI + externes Sprachmodell). Nutzer können in natürlicher Sprache Fragen zu verfügbaren Softwareprodukten stellen — der Assistent holt sich die nötigen Informationen live aus SharePoint.

Anwendungsfall: Die TU Wien betreibt eine Software-Verteilungsplattform (TUsoftware) mit ~80 Produkten als SharePoint-Subsites. Statt manuell durch SharePoint-Seiten zu navigieren, kann man einfach fragen:

  • „Gibt es MATLAB für macOS?"
  • „Welche CAD-Programme benötigen einen Lizenzserver?"
  • „Zeig mir den englischen Inhalt der ANSYS-Seite."

2. Architektur

┌─────────────────────────────────────────────────────────────────┐
│  Entwicklungsrechner (lokal)                                    │
│                                                                 │
│  skill_generator_v2.py                                          │
│       │                                                         │
│       │  Microsoft Graph API (Azure AD OAuth2)                  │
│       ▼                                                         │
│  SharePoint (80 Produkt-Subsites)                               │
│       │                                                         │
│       ▼  generiert                                              │
│  ┌──────────────┐   ┌──────────────────────┐                    │
│  │  SKILL.md    │   │ produkte_cache.json  │                    │
│  │ (Wissensbasis│   │ (Produktindex+Metad.)│                    │
│  └──────┬───────┘   └──────────┬───────────┘                    │
│         └──────────────────────┘                                │
└─────────────────────┬───────────────────────────────────────────┘
                      │  docker-compose up
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│  Docker (Produktionsserver)                                     │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │  sharepoint-proxy (Port 8765)                           │    │
│  │  proxy_v2.py — FastAPI                                  │    │
│  │                                                         │    │
│  │  • Injiziert SKILL.md als System-Prompt                 │    │
│  │  • Tool-Calling → Microsoft Graph API (live)            │    │
│  │  • Agentic Loop (max. 3 Runden)                         │    │
│  └───────────────────┬─────────────────────────────────────┘    │
│                      │  /v1/chat/completions                    │
│                      ▼                                          │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │  open-webui (Port 3003)                                 │    │
│  │  Chat-Interface für Endnutzer                           │    │
│  └───────────────────┬─────────────────────────────────────┘    │
│                      │                                          │
└──────────────────────┼──────────────────────────────────────────┘
                       │  OpenAI-kompatibler API-Aufruf
                       ▼
          Externes Sprachmodell
          (z.B. Aqueduct / Gemma / GPT)

Datenfluss bei einer Nutzeranfrage:

Nutzer tippt Frage
      │
      ▼
OpenWebUI → POST /v1/chat/completions an proxy_v2.py
      │
      ▼
proxy_v2.py injiziert SKILL.md als System-Prompt
      │
      ▼
Erster LLM-Aufruf → Modell entscheidet: Tool nötig?
      │
      ├── Nein → Antwort direkt aus SKILL.md
      │
      └── Ja → Tool ausführen (Graph API)
                │
                ▼
           SharePoint-Daten holen
                │
                ▼
           Zweiter LLM-Aufruf mit Tool-Ergebnis
                │
                ▼
           Finale Antwort an OpenWebUI → Nutzer

3. Voraussetzungen

3.1 Azure AD App-Registrierung

Das Projekt nutzt den OAuth2 Client Credentials Flow — kein Benutzer-Login, sondern eine App-zu-App-Verbindung.

Schritte im Azure Portal (portal.azure.com):

  1. App registrieren: Azure Active Directory → App-Registrierungen → Neue Registrierung
  2. Client Secret erstellen: Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel
  3. API-Berechtigungen vergeben:
  4. Sites.Read.All (Microsoft Graph, Anwendungsberechtigung)
  5. Files.Read.All (Microsoft Graph, Anwendungsberechtigung)
  6. Admin-Zustimmung erteilen
  7. IDs notieren: Tenant-ID, Client-ID, Client-Secret

3.2 Python-Abhängigkeiten (lokal, für Generator & Diagnose)

# Erstellen
python -m venv venv
source venv/bin/activate
# Installation
pip install requests python-dotenv

3.3 Docker & Docker Compose

# Installation prüfen
docker --version
docker compose version

Docker Compose V2 (als Plugin) wird empfohlen. Der Befehl lautet dann docker compose (ohne Bindestrich).

sudo apt update
sudo apt install ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$UBUNTU_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

4. Konfiguration (.env)

Die .env-Datei liegt im Projektverzeichnis und wird nicht ins Docker-Image kopiert — sie wird zur Laufzeit als Umgebungsvariablen injiziert.

# Externes Sprachmodell (OpenAI-kompatibler Endpunkt)
BASE_URL=https://ihr-llm-server.example.com/v1
API_KEY=sk-ihr-api-key-hier

# Modellname (muss am BASE_URL verfügbar sein)
MODEL=gemma-4-26b

# Azure AD — App-Registrierung
TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
CLIENT_SECRET=ihr~client~secret~hier

# SharePoint Parent-Site ID
# Format: hostname,site-collection-id,web-id
# Zu finden via: GET https://graph.microsoft.com/v1.0/sites/{hostname}:/sites/{sitename}
PARENT_SITE_ID=contoso.sharepoint.com,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Variable Beschreibung
BASE_URL URL des LLM-Servers, muss OpenAI-API-kompatibel sein (endet auf /v1)
API_KEY API-Key für den LLM-Server
MODEL Modellbezeichnung, wie sie der Server erwartet
TENANT_ID Azure AD Verzeichnis-ID (Tenant)
CLIENT_ID App-ID der registrierten Azure-Anwendung
CLIENT_SECRET Geheimer Schlüssel der Azure-App
PARENT_SITE_ID SharePoint Site-ID der übergeordneten Site mit allen Produkt-Subsites

⚠️ Sicherheitshinweis: Die .env-Datei enthält sensible Credentials. Niemals in ein öffentliches Repository einchecken. Für Entwicklungszwecke eine separate Kopie ohne echte Keys bereithalten.


5. Dateien im Detail

5.1 skill_generator_v2.py

Zweck: Einmaliges (oder regelmäßiges) Skript, das alle SharePoint-Produktseiten liest und zwei Ausgabedateien erzeugt: - SKILL.md — Wissensbasis als Markdown, wird als System-Prompt ins LLM injiziert - produkte_cache.json — Produktindex mit Site-IDs für schnelle Lookups

Wann ausführen: Immer wenn SharePoint-Daten aktualisiert wurden oder neue Produkte hinzugekommen sind. Anschließend Docker-Container neu starten.

Ablauf:

1. Azure AD Token holen
2. Alle Subsites der Parent-Site laden (mit Pagination)
3. Für jede Subsite:
   a) Hauptseite lesen (Canvas Layout via Graph Beta API)
   b) Plattformen aus Ordnerstruktur erkennen (Windows/macOS/Linux)
   c) Seiten-Flags lesen (Kommentare aktiviert? Englische Version?)
   d) Produktinfos extrahieren (Shop-URL, Lizenzserver, Version, etc.)
4. SKILL.md generieren
5. produkte_cache.json speichern

Wichtige Funktionen:

Funktion Beschreibung
get_access_token() OAuth2 Client-Credentials-Token von Azure AD holen
get_all_subsites(token) Alle Produkt-Subsites laden, inkl. Pagination via @odata.nextLink. Überspringt Sites mit Name "RIP" (archiviert)
get_site_page_content(token, site_id) Hauptseite lesen: sucht zuerst Home.aspx, dann erste Seite. Nutzt Canvas Layout via Beta API
extract_text_from_canvas(page_detail) Extrahiert lesbaren Text aus dem SharePoint Canvas Layout — bereinigt HTML-Tags und dekodiert HTML-Entities
extract_product_info(text, site) Parst Seitentext: Shop-URL per Regex, Lizenzserver per Keyword-Suche, Version per "Aktuelle Version:"-Muster
get_site_drives_platforms(token, site_id) Erkennt Plattformen (Windows/macOS/Linux) anhand von Ordnernamen in der Document Library
get_site_page_flags(token, site_id) Liest Beta API: Kommentare aktiviert? Englische Seite vorhanden? Wählt jeweils die Seite mit der höchsten versionId (da SharePoint pro Sprache eine Vorlage-Seite v1.0 und die echte Seite anlegt)
generate_skill_md(products) Erzeugt die strukturierte Markdown-Wissensbasis mit Tabelle, Sektionen und Hinweisen
"""
SKILL.md Generator v2 — Subsite-Struktur
==========================================
Liest alle Produkt-Subsites der TU Wien Software-Verteilungsplattform
und extrahiert Seiteninhalte (Lizenzserver, Shop-URL, Plattformen, etc.)

Verwendung:
    python3 skill_generator_v2.py

Voraussetzungen:
    pip install requests python-dotenv
"""

import requests
import os
import re
import html
import json
from datetime import datetime
from dotenv import load_dotenv

load_dotenv()

# ─────────────────────────────────────────────
# KONFIGURATION
# ─────────────────────────────────────────────

TENANT_ID     = os.environ.get("TENANT_ID", "")
CLIENT_ID     = os.environ.get("CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "")

# Parent Site ID — enthält alle Produkt-Subsites
PARENT_SITE_ID = os.environ.get("PARENT_SITE_ID","")

OUTPUT_FILE   = "SKILL.md"
# Produkte-Cache für den Proxy (JSON mit allen Site-IDs)
CACHE_FILE    = "produkte_cache.json"

# ─────────────────────────────────────────────
# AUTH
# ─────────────────────────────────────────────

def get_access_token():
    url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
    r = requests.post(url, data={
        "client_id": CLIENT_ID,
        "scope": "https://graph.microsoft.com/.default",
        "client_secret": CLIENT_SECRET,
        "grant_type": "client_credentials"
    })
    r.raise_for_status()
    return r.json()["access_token"]


def graph_get(token, endpoint, base="https://graph.microsoft.com/v1.0"):
    r = requests.get(
        f"{base}{endpoint}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}
    )
    r.raise_for_status()
    return r.json()


# ─────────────────────────────────────────────
# SUBSITES LADEN
# ─────────────────────────────────────────────

def get_all_subsites(token):
    """Lädt alle Produkt-Subsites mit Pagination."""
    subsites = []
    url = f"/sites/{PARENT_SITE_ID}/sites"

    while url:
        data = graph_get(token, url)
        for site in data.get("value", []):
            # RIP-Seiten überspringen (archivierte Produkte)
            if site.get("displayName", "").upper() != "RIP":
                subsites.append({
                    "site_id":     site["id"],
                    "name":        site["name"],
                    "displayName": site.get("displayName", site["name"]),
                    "webUrl":      site.get("webUrl", ""),
                    "description": site.get("description", ""),
                    "lastModified": site.get("lastModifiedDateTime", "")[:10]
                })
        # Pagination
        next_link = data.get("@odata.nextLink", "")
        url = next_link.replace("https://graph.microsoft.com/v1.0", "") if next_link else None

    return sorted(subsites, key=lambda x: x["displayName"].lower())


# ─────────────────────────────────────────────
# SEITENINHALT LESEN
# ─────────────────────────────────────────────

def get_site_page_content(token, site_id):
    """Liest den Textinhalt der Hauptseite einer Produkt-Subsite."""
    try:
        # Seiten der Subsite auflisten
        pages = graph_get(token, f"/sites/{site_id}/pages")
        page_list = pages.get("value", [])
        if not page_list:
            return ""

        # Erste/Hauptseite nehmen (meistens Home.aspx oder der Produktname)
        main_page = None
        for p in page_list:
            name = p.get("name", "").lower()
            if "home" in name or name.endswith(".aspx"):
                main_page = p
                break
        if not main_page:
            main_page = page_list[0]

        page_id = main_page.get("id", "")
        if not page_id:
            return ""

        # Seiteninhalt mit canvasLayout holen (Graph Beta)
        try:
            detail = graph_get(
                token,
                f"/sites/{site_id}/pages/{page_id}/microsoft.graph.sitePage?$expand=canvasLayout",
                base="https://graph.microsoft.com/beta"
            )
            return extract_text_from_canvas(detail)
        except Exception:
            # Fallback: Nur Titel und Beschreibung
            return (main_page.get("title") or "") + " " + (main_page.get("description") or "")

    except Exception as e:
        return ""


def extract_text_from_canvas(page_detail):
    """Extrahiert lesbaren Text aus dem canvasLayout einer SharePoint-Seite."""
    texts = []

    canvas = page_detail.get("canvasLayout", {})
    sections = canvas.get("horizontalSections", []) or canvas.get("sections", [])

    for section in sections:
        columns = section.get("columns", [])
        for column in columns:
            webparts = column.get("webparts", [])
            for wp in webparts:
                # Text-Webparts
                inner = wp.get("innerHtml", "") or wp.get("data", {}).get("innerHTML", "")
                if inner:
                    # HTML bereinigen und Entities dekodieren
                    clean = re.sub(r'<[^>]+>', ' ', inner)
                    clean = html.unescape(re.sub(r'\s+', ' ', clean).strip())
                    if clean:
                        texts.append(clean)

                # Properties (z.B. bei Quick Links)
                props = wp.get("webPartData", {}).get("properties", {})
                if isinstance(props, dict):
                    for val in props.values():
                        if isinstance(val, str) and len(val) > 10:
                            texts.append(val)

    return " ".join(texts)[:4000]


# ─────────────────────────────────────────────
# RELEVANTE INFORMATIONEN EXTRAHIEREN
# ─────────────────────────────────────────────

def extract_product_info(text, site):
    """Extrahiert strukturierte Produktinfos aus dem Seitentext."""
    info = {
        "shop_url":          "",
        "lizenzserver":      False,
        "lizenzserver_name": "",
        "tu_net_required":   False,
        "plattformen":       [],
        "kurzbeschreibung":  "",
        "last_modified":     site.get("lastModified", "")
    }

    if not text:
        return info

    # Shop-URL extrahieren
    shop_match = re.search(r'https://shop\.tusoftware\.tuwien\.ac\.at[^\s"\'<>]+', text)
    if shop_match:
        info["shop_url"] = shop_match.group(0)

    # Lizenzserver erkennen
    liz_keywords = ["lizenzserver", "license server", "netzwerk lizenz", "network license", "lizenz manager", "lizenzmanager"]
    info["lizenzserver"] = any(k in text.lower() for k in liz_keywords)

    # Lizenzserver-Name wird NICHT in SKILL.md gespeichert — kommt live via get_product_details
    info["lizenzserver_name"] = ""

    # TU-Net/VPN Pflicht
    tu_net_keywords = ["tu-net", "tu-vpn", "tu net", "tu vpn", "innerhalb des tu"]
    info["tu_net_required"] = any(k in text.lower() for k in tu_net_keywords)

    # Produktversion extrahieren
    # Unterstützte Formate: 2024.1, R2025b, 1.9.2, 2026_Q3, V25, etc.
    version_match = re.search(
        r'(?:Aktuelle Version|Latest Version):\s*([A-Za-z0-9][A-Za-z0-9._\-]*)',
        text, re.IGNORECASE
    )
    info["produkt_version"] = version_match.group(1).strip() if version_match else ""

    # Kurzbeschreibung: erster sinnvoller Satz (nicht URL, nicht zu kurz)
    sentences = re.split(r'[.!?]\s+', text)
    for s in sentences:
        s = s.strip()
        if 20 < len(s) < 300 and "http" not in s and "©" not in s:
            info["kurzbeschreibung"] = s
            break
    return info


def get_site_drives_platforms(token, site_id):
    """Erkennt verfügbare Plattformen anhand der Ordner in der Document Library."""
    try:
        drives = graph_get(token, f"/sites/{site_id}/drives")
        drive_id = None
        for d in drives.get("value", []):
            if d.get("name") in ("Documents", "Dokumente", "Freigegebene Dokumente"):
                drive_id = d["id"]
                break
        if not drive_id and drives.get("value"):
            drive_id = drives["value"][0]["id"]
        if not drive_id:
            return []

        children = graph_get(token, f"/sites/{site_id}/drives/{drive_id}/root/children")
        platforms = []
        platform_map = {
            "windows": "Windows",
            "win":     "Windows",
            "mac":     "macOS",
            "macos":   "macOS",
            "linux":   "Linux",
            "lnx":     "Linux",
            "android": "Android",
            "ios":     "iOS"
        }
        for item in children.get("value", []):
            name_lower = item["name"].lower()
            for key, label in platform_map.items():
                if key in name_lower and label not in platforms:
                    platforms.append(label)

        return sorted(platforms)
    except Exception:
        return []

def get_site_page_flags(token, site_id):
    """
    Liest alle Seiten via Beta API.
    Wichtig: Pro Sprachversion gibt es ZWEI Seiten:
    - Home.aspx (Version 1.0)  = automatische Vorlage, showComments immer True
    - Home(1).aspx (Version N) = echte bearbeitete Seite
    Wir nehmen NUR die Seite mit der höchsten versionId pro Sprache.
    """
    result = {
        "kommentare_de": False,
        "kommentare_en": False,
        "hat_englisch":  False,
        "en_page_id":  "",
        "en_version":  0,
        "erstellt_von":  "",
        "erstellt_am":   "",
        "geaendert_von": "",
        "geaendert_am":  "",
        "status":  ""
    }
    en_pages = []
    de_pages = []
    try:
        pages = graph_get(token, f"/sites/{site_id}/pages", "https://graph.microsoft.com/beta")
        for page in pages.get("value", []):
            web_url       = page.get("webUrl", "")
            pid           = page.get("id", "")
            show_comments = page.get("showComments", False)
            version_str   = page.get("publishingState", {}).get("versionId", "0")
            try:
                version = float(version_str)
            except Exception:
                version = 0

            if "/en/" in web_url:
                en_pages.append({"id": pid, "version": version, "showComments": show_comments})
            else:
                de_pages.append({
                    "id":           pid,
                    "version":      version,
                    "showComments": show_comments,
                    "createdBy":    page.get("createdBy", {}).get("user", {}).get("displayName", ""),
                    "createdAt":    page.get("createdDateTime", "")[:10],
                    "modifiedBy":   page.get("lastModifiedBy", {}).get("user", {}).get("displayName", ""),
                    "modifiedAt":   page.get("lastModifiedDateTime", "")[:10],
                    "status":       page.get("publishingState", {}).get("level", "")
                })

        # Beste DE-Seite (höchste Version)
        if de_pages:
            best_de = max(de_pages, key=lambda x: x["version"])
            result["kommentare_de"] = best_de["showComments"]
            result["erstellt_von"]  = best_de["createdBy"]
            result["erstellt_am"]   = best_de["createdAt"]
            result["geaendert_von"] = best_de["modifiedBy"]
            result["geaendert_am"]  = best_de["modifiedAt"]
            result["status"]        = best_de["status"]

        # Beste EN-Seite (höchste Version)
        if en_pages:
            result["hat_englisch"] = True
            best_en = max(en_pages, key=lambda x: x["version"])
            result["en_page_id"]    = best_en["id"]
            result["en_version"]    = best_en["version"]
            result["kommentare_en"] = best_en["showComments"]

    except Exception as e:
        print(f"  ⚠️  Page flags nicht lesbar für {site_id}: {e}")
    return result

# ─────────────────────────────────────────────
# SKILL.md GENERIEREN
# ─────────────────────────────────────────────

def generate_skill_md(products):
    now = datetime.now().strftime("%d.%m.%Y %H:%M")
    total = len(products)

    lines = [
        "# TU Wien — Software-Verteilungsplattform (TUsoftware)",
        f"**Stand:** {now}  ",
        f"**Produkte gesamt:** {total}  ",
        "",
        "## Deine Aufgabe",
        "Du bist der KI-Assistent für die TU Wien Software-Verteilungsplattform.",
        "Du beantwortest Fragen zu verfügbaren Softwareprodukten auf Basis dieser Wissensbasis.",
        "Für detaillierte Live-Daten zu einem Produkt nutze das Tool `get_product_details(produktname)`.",
        "Antworte immer auf Deutsch, außer der Benutzer schreibt auf Englisch.",
        "",
        "## Produkt-Übersicht",
        "",
        "| Produkt | Version | Plattformen | Lizenzserver | TU-Net | Shop | EN | Komm. DE | Komm. EN | Letzte Änderung | Geändert von |",
        "|---|---|---|:---:|:---:|:---:|:---:|:---:|:---:|---|---|"
    ]

    for p in products:
        info = p.get("info", {})
        plattformen = ", ".join(info.get("plattformen", [])) or "—"
        liz   = "⚠️ Ja"  if info.get("lizenzserver")    else "—"
        tunet = "✅ Ja"  if info.get("tu_net_required")  else "—"
        shop  = "✅"     if info.get("shop_url")         else "—"
        en      = "✅"     if info.get("hat_englisch")     else "❌"
        kom_de  = "⚠️"    if info.get("kommentare_de")    else "—"
        kom_en  = "⚠️"    if info.get("kommentare_en")    else "—"
        geaendert_am  = info.get("geaendert_am", "—") or "—"
        geaendert_von = info.get("geaendert_von", "—") or "—"
        prod_version  = info.get("produkt_version", "") or "—"
        lines.append(f"| {p['displayName']} | {prod_version} | {plattformen} | {liz} | {tunet} | {shop} | {en} | {kom_de} | {kom_en} | {geaendert_am} | {geaendert_von} |")

    # Lizenzserver-Produkte
    liz_produkte = [p for p in products if p.get("info", {}).get("lizenzserver")]
    lines += ["", "## Produkte mit Lizenzserver (benötigen TU-Net/VPN)"]
    if liz_produkte:
        for p in liz_produkte:
            lines.append(f"- **{p['displayName']}** ⚠️ Lizenzserver erforderlich — Details via get_product_details")
    else:
        lines.append("- (keine erkannt — bei Bedarf `get_product_details` verwenden)")


    # Produkte mit aktiven Kommentaren DE
    kom_de = [p for p in products if p.get("info", {}).get("kommentare_de")]
    lines += ["", "## Produkte mit aktiven Kommentaren — Deutsche Seite"]
    if kom_de:
        for p in kom_de:
            lines.append(f"- **{p['displayName']}** ⚠️ Kommentare auf DE-Seite aktiv")
    else:
        lines.append("- ✅ Alle deutschen Seiten haben Kommentare deaktiviert")

    # Produkte mit aktiven Kommentaren EN
    kom_en = [p for p in products if p.get("info", {}).get("kommentare_en")]
    lines += ["", "## Produkte mit aktiven Kommentaren — Englische Seite"]
    if kom_en:
        for p in kom_en:
            lines.append(f"- **{p['displayName']}** ⚠️ Kommentare auf EN-Seite aktiv")
    else:
        lines.append("- ✅ Alle englischen Seiten haben Kommentare deaktiviert")

    # Produkte ohne englische Version
    no_en_produkte = [p for p in products if not p.get("info", {}).get("hat_englisch")]
    lines += ["", "## Produkte ohne englische Version"]
    for p in no_en_produkte:
        lines.append(f"- {p['displayName']}")

    # Kurzbeschreibungen
    lines += ["", "## Produktbeschreibungen"]
    for p in products:
        info = p.get("info", {})
        beschreibung = info.get("kurzbeschreibung", "")
        if beschreibung:
            lines.append(f"**{p['displayName']}:** {beschreibung}")
            if info.get("shop_url"):
                lines.append(f"  Shop: {info['shop_url']}")
            lines.append("")

    # Veraltete Produkte (> 180 Tage nicht geändert)
    from datetime import date
    heute = date.today()
    veraltete = []
    for p in products:
        geaendert_am = p.get("info", {}).get("geaendert_am", "")
        if geaendert_am:
            try:
                delta = (heute - date.fromisoformat(geaendert_am)).days
                if delta > 180:
                    veraltete.append((p["displayName"], geaendert_am, delta))
            except Exception:
                pass
    veraltete.sort(key=lambda x: x[2], reverse=True)
    lines += ["", "## Produkte die lange nicht aktualisiert wurden (> 180 Tage)"]
    if veraltete:
        for name, datum, tage in veraltete:
            lines.append(f"- **{name}** — letzte Änderung: {datum} ({tage} Tage)")
    else:
        lines.append("- ✅ Alle Produkte wurden in den letzten 180 Tagen aktualisiert")

    # Hinweise
    lines += [
        "## Hinweise für den Assistenten",
        "- Produktversion steht in der Tabelle (Spalte Version) — basiert auf 'Aktuelle Version:' im Seitentext",
        "- Lizenzserver-Erkennung basiert auf Seitentext — für Details `get_product_details` nutzen",
        "- Alle Produkte die einen Lizenzserver benötigen, können nur im TU-Netz oder per VPN benutzt werden",
        "- Shop-URLs zeigen auf `shop.tusoftware.tuwien.ac.at` — nur im TU-Netz erreichbar",
        "- Produkte ohne Plattformangabe: bitte `get_product_details` aufrufen",
        "- Datumsangaben bitte immer in der Form (DD.MM.YYYY) ausgeben.",
        f"- Datenstand: {now} — bei Aktualitätsfragen live abfragen",
    ]

    return "\n".join(lines)


# ─────────────────────────────────────────────
# MAIN
# ─────────────────────────────────────────────

def main():
    print("📋 SharePoint SKILL.md Generator v2 — Subsite-Struktur")
    print("=" * 55)

    print("🔑 Authentifizierung...")
    token = get_access_token()
    print("✅ Token erhalten")

    print("📦 Produkt-Subsites laden...")
    subsites = get_all_subsites(token)
    print(f"✅ {len(subsites)} Produkte gefunden")

    print("\n🔍 Seiteninhalte & Metadaten lesen...")
    products = []
    errors   = []

    for i, site in enumerate(subsites, 1):
        print(f"  [{i:3}/{len(subsites)}] {site['displayName'][:45]:<45}", end="\r")

        try:
            page_text = get_site_page_content(token, site["site_id"])
            platforms = get_site_drives_platforms(token, site["site_id"])
            flags     = get_site_page_flags(token, site["site_id"])
            info      = extract_product_info(page_text, site)
            info["plattformen"]       = platforms
            info["kommentare_de"]     = flags["kommentare_de"]
            info["kommentare_en"]     = flags["kommentare_en"]
            info["hat_englisch"]      = flags["hat_englisch"]
            info["erstellt_von"]      = flags["erstellt_von"]
            info["erstellt_am"]       = flags["erstellt_am"]
            info["geaendert_von"]     = flags["geaendert_von"]
            info["geaendert_am"]      = flags["geaendert_am"]
            info["status"]            = flags["status"]

            products.append({**site, "info": info})
        except Exception as e:
            errors.append({"name": site["displayName"], "fehler": str(e)})
            products.append({**site, "info": {}})

    print(f"\n{len(products)} Produkte verarbeitet, {len(errors)} Fehler")

    # Produkte-Cache speichern (für Proxy)
    cache = {
        "generiert": datetime.now().isoformat(),
        "parent_site_id": PARENT_SITE_ID,
        "produkte": [
            {
                "name":        p["name"],
                "displayName": p["displayName"],
                "site_id":     p["site_id"],
                "webUrl":      p["webUrl"],
                "shop_url":    p.get("info", {}).get("shop_url", ""),
                "lizenzserver":      p.get("info", {}).get("lizenzserver", False),
                "kommentare_de":     p.get("info", {}).get("kommentare_de", False),
                "kommentare_en":     p.get("info", {}).get("kommentare_en", False),
                "hat_englisch":      p.get("info", {}).get("hat_englisch", False),
                "geaendert_am":      p.get("info", {}).get("geaendert_am", ""),
                "geaendert_von":     p.get("info", {}).get("geaendert_von", ""),
                "status":            p.get("info", {}).get("status", ""),
                "produkt_version":   p.get("info", {}).get("produkt_version", "")
            }
            for p in products
        ]
    }
    with open(CACHE_FILE, "w", encoding="utf-8") as f:
        json.dump(cache, f, ensure_ascii=False, indent=2)
    print(f"✅ Produkte-Cache gespeichert: {CACHE_FILE}")

    # SKILL.md generieren
    print("📝 SKILL.md generieren...")
    content = generate_skill_md(products)
    with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
        f.write(content)
    print(f"✅ SKILL.md gespeichert ({len(content)} Zeichen, {content.count(chr(10))} Zeilen)")

    if errors:
        print(f"\n⚠️  Fehler bei {len(errors)} Produkten:")
        for e in errors[:5]:
            print(f"   • {e['name']}: {e['fehler']}")

    print(f"\n🎉 Fertig! Proxy neu starten: docker-compose restart sharepoint-proxy")

if __name__ == "__main__":
    main()

5.2 proxy_v2.py

Zweck: FastAPI-Server der als Middleware zwischen OpenWebUI und dem externen LLM sitzt. Er: - injiziert SKILL.md als System-Prompt - stellt 5 Tools für SharePoint-Abfragen bereit - führt einen Agentic Loop durch (max. 3 Runden Tool-Calling) - leitet die finale Antwort an OpenWebUI weiter

Endpunkte:

Endpunkt Methode Beschreibung
/v1/chat/completions POST Haupt-Proxy-Endpunkt, OpenAI-kompatibel
/v1/models GET Gibt verfügbare Modelle vom Backend weiter
/health GET Health-Check: Status, Cache-Größe, Modellname

Wichtige Funktionen:

Funktion Beschreibung
load_skill_md() Lädt SKILL.md beim Start — Fallback auf Minimalbeschreibung
load_product_cache() Lädt produkte_cache.json — ermöglicht schnelle Suche ohne API-Call
get_graph_token() OAuth2-Token mit Caching (erneuert 60s vor Ablauf)
graph_get(endpoint, beta) Graph API GET-Wrapper, unterstützt v1.0 und Beta
find_product_in_cache(name) Zweistufige Suche: erst exakt, dann Teilstring
find_product_live(name) Fallback: direkte SharePoint-Suche ohne Cache
get_page_text(site_id) Liest Hauptseite via Canvas Layout (Beta API)
get_page_flags_and_pages(site_id) Kommentare-Status + EN-Seite, höchste Version pro Sprache
get_page_text_by_id(site_id, page_id) Liest spezifische Seite (für englischen Inhalt)
call_glm(messages, tools) LLM-Aufruf mit 120s Timeout, optionalem Tool-Schema
chat_completions(request) Haupt-Endpunkt: SKILL.md injizieren → LLM → Agentic Loop

Der Agentic Loop im Detail:

POST /v1/chat/completions
        │
        ▼
SKILL.md in System-Prompt injizieren
        │
        ▼
1. LLM-Aufruf (mit Tool-Definitionen)
        │
        ├── finish_reason = "stop" → Antwort zurückgeben
        │
        └── finish_reason = "tool_calls"
                │
                ▼
          Tool(s) ausführen (max. parallel)
                │
                ▼
          Tool-Ergebnis(se) als "tool"-Messages anhängen
                │
                ▼
          2. LLM-Aufruf
                │
                └── (max. 3 Runden, dann Antwort erzwingen)
"""
SharePoint GLM Proxy v2 — Subsite-Struktur
==========================================
FastAPI Proxy zwischen WebUI und GLM API.
- Injiziert SKILL.md als System Prompt
- Tool Calling für SharePoint Subsite-Abfragen
- Nutzt produkte_cache.json für schnelle Produktsuche
"""

import os
import json
import re
import html
import requests
import logging
from datetime import datetime
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import uvicorn

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)

app = FastAPI(title="SharePoint MCP Proxy v2")

# ─────────────────────────────────────────────
# KONFIGURATION
# ─────────────────────────────────────────────

BASE_URL   = os.environ.get("BASE_URL", "")
API_KEY    = os.environ.get("API_KEY", "")
MODEL      = os.environ.get("MODEL", "")
SKILL_FILE     = os.environ.get("SKILL_FILE", "/app/SKILL.md")
CACHE_FILE     = os.environ.get("CACHE_FILE", "/app/produkte_cache.json")

TENANT_ID      = os.environ.get("TENANT_ID", "")
CLIENT_ID      = os.environ.get("CLIENT_ID", "")
CLIENT_SECRET  = os.environ.get("CLIENT_SECRET", "")
PARENT_SITE_ID = os.environ.get("PARENT_SITE_ID", "")

# ─────────────────────────────────────────────
# SKILL.md + CACHE LADEN
# ─────────────────────────────────────────────

def load_skill_md():
    if os.path.exists(SKILL_FILE):
        with open(SKILL_FILE, "r", encoding="utf-8") as f:
            content = f.read()
        log.info(f"SKILL.md geladen ({len(content)} Zeichen)")
        return content
    log.warning("SKILL.md nicht gefunden!")
    return "Du bist ein Assistent für die TU Wien Software-Verteilungsplattform."


def load_product_cache():
    if os.path.exists(CACHE_FILE):
        with open(CACHE_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
        produkte = data.get("produkte", [])
        log.info(f"Produkte-Cache geladen ({len(produkte)} Produkte)")
        return produkte
    log.warning("Produkte-Cache nicht gefunden — Suche wird langsamer")
    return []


SKILL_CONTENT   = load_skill_md()
PRODUKTE_CACHE  = load_product_cache()


# ─────────────────────────────────────────────
# GRAPH API
# ─────────────────────────────────────────────

_token_cache = {"token": None, "expires": 0}


def get_graph_token():
    import time
    if _token_cache["token"] and time.time() < _token_cache["expires"]:
        return _token_cache["token"]
    r = requests.post(
        f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
        data={
            "client_id": CLIENT_ID,
            "scope": "https://graph.microsoft.com/.default",
            "client_secret": CLIENT_SECRET,
            "grant_type": "client_credentials"
        }
    )
    r.raise_for_status()
    data = r.json()
    _token_cache["token"] = data["access_token"]
    _token_cache["expires"] = time.time() + data.get("expires_in", 3600) - 60
    return _token_cache["token"]


def graph_get(endpoint, beta=False):
    token = get_graph_token()
    base = "https://graph.microsoft.com/beta" if beta else "https://graph.microsoft.com/v1.0"
    r = requests.get(
        f"{base}{endpoint}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}
    )
    r.raise_for_status()
    return r.json()


# ─────────────────────────────────────────────
# PRODUKT SUCHE
# ─────────────────────────────────────────────

def find_product_in_cache(product_name: str):
    """Findet ein Produkt im Cache via exakter oder Teilstring-Suche."""
    name_lower = product_name.lower()

    # Exakte Suche zuerst
    for p in PRODUKTE_CACHE:
        if p["displayName"].lower() == name_lower or p["name"].lower() == name_lower:
            return p

    # Teilstring-Suche
    matches = [
        p for p in PRODUKTE_CACHE
        if name_lower in p["displayName"].lower() or name_lower in p["name"].lower()
    ]
    return matches[0] if len(matches) == 1 else (matches[0] if matches else None)


def find_product_live(product_name: str):
    """Sucht ein Produkt direkt in SharePoint (Fallback wenn nicht im Cache)."""
    data = graph_get(f"/sites/{PARENT_SITE_ID}/sites")
    name_lower = product_name.lower()
    for site in data.get("value", []):
        if (name_lower in site.get("displayName", "").lower() or
                name_lower in site.get("name", "").lower()):
            return {
                "site_id":     site["id"],
                "name":        site["name"],
                "displayName": site.get("displayName", site["name"]),
                "webUrl":      site.get("webUrl", "")
            }
    return None


# ─────────────────────────────────────────────
# SEITENINHALT LESEN
# ─────────────────────────────────────────────

def get_page_text(site_id: str) -> str:
    """Liest den Textinhalt der Hauptseite einer Produkt-Subsite."""
    try:
        pages = graph_get(f"/sites/{site_id}/pages")
        page_list = pages.get("value", [])
        if not page_list:
            return ""

        main_page = next(
            (p for p in page_list if "home" in p.get("name", "").lower()),
            page_list[0]
        )
        page_id = main_page.get("id", "")
        if not page_id:
            return ""

        try:
            detail = graph_get(
                f"/sites/{site_id}/pages/{page_id}/microsoft.graph.sitePage?$expand=canvasLayout",
                beta=True
            )
            texts = []
            canvas = detail.get("canvasLayout", {})
            for section in canvas.get("horizontalSections", []):
                for column in section.get("columns", []):
                    for wp in column.get("webparts", []):
                        raw_html = wp.get("innerHtml", "")
                        if raw_html:
                            clean = re.sub(r'<[^>]+>', ' ', raw_html)
                            clean = html.unescape(re.sub(r'\s+', ' ', clean).strip())
                            if clean:
                                texts.append(clean)
            return " ".join(texts)[:5000]
        except Exception:
            return main_page.get("title", "") + " " + main_page.get("description", "")

    except Exception as e:
        log.warning(f"Seiteninhalt konnte nicht geladen werden: {e}")
        return ""


def get_page_flags_and_pages(site_id: str) -> dict:
    """
    Liest alle Seiten via Beta API.
    Wählt pro Sprache die Seite mit der höchsten versionId
    (Vorlage-Seite v1.0 vs. echte bearbeitete Seite).
    """
    result = {
        "kommentare_de": False, "kommentare_en": False,
        "hat_englisch": False,  "en_page_id": "",
        "en_version": 0,        "de_page_id": "",
        "erstellt_von": "",     "erstellt_am": "",
        "geaendert_von": "",    "geaendert_am": "",
        "status": ""
    }
    en_pages = []
    de_pages = []
    try:
        pages = graph_get(f"/sites/{site_id}/pages", beta=True)
        for page in pages.get("value", []):
            web_url       = page.get("webUrl", "")
            pid           = page.get("id", "")
            show_comments = page.get("showComments", False)
            version_str   = page.get("publishingState", {}).get("versionId", "0")
            try:
                version = float(version_str)
            except Exception:
                version = 0

            if "/en/" in web_url:
                en_pages.append({"id": pid, "version": version, "showComments": show_comments})
            else:
                de_pages.append({
                    "id": pid, "version": version, "showComments": show_comments,
                    "createdBy":  page.get("createdBy", {}).get("user", {}).get("displayName", ""),
                    "createdAt":  page.get("createdDateTime", "")[:10],
                    "modifiedBy": page.get("lastModifiedBy", {}).get("user", {}).get("displayName", ""),
                    "modifiedAt": page.get("lastModifiedDateTime", "")[:10],
                    "status":     page.get("publishingState", {}).get("level", "")
                })

        # Beste DE-Seite (höchste Version)
        if de_pages:
            best_de = max(de_pages, key=lambda x: x["version"])
            result["de_page_id"]    = best_de["id"]
            result["kommentare_de"] = best_de["showComments"]
            result["erstellt_von"]  = best_de["createdBy"]
            result["erstellt_am"]   = best_de["createdAt"]
            result["geaendert_von"] = best_de["modifiedBy"]
            result["geaendert_am"]  = best_de["modifiedAt"]
            result["status"]        = best_de["status"]

        # Beste EN-Seite (höchste Version)
        if en_pages:
            result["hat_englisch"] = True
            best_en = max(en_pages, key=lambda x: x["version"])
            result["en_page_id"]    = best_en["id"]
            result["en_version"]    = best_en["version"]
            result["kommentare_en"] = best_en["showComments"]

    except Exception as e:
        log.warning(f"Page flags nicht lesbar für {site_id}: {e}")

    return result


def get_page_text_by_id(site_id: str, page_id: str) -> str:
    """Liest den Textinhalt einer spezifischen Seite via Canvas Layout."""
    try:
        detail = graph_get(
            f"/sites/{site_id}/pages/{page_id}/microsoft.graph.sitePage?$expand=canvasLayout",
            beta=True
        )
        texts = []
        canvas = detail.get("canvasLayout", {})
        for section in canvas.get("horizontalSections", []):
            for column in section.get("columns", []):
                for wp in column.get("webparts", []):
                    raw_html = wp.get("innerHtml", "")
                    if raw_html:
                        clean = re.sub(r'<[^>]+>', ' ', raw_html)
                        clean = html.unescape(re.sub(r'\s+', ' ', clean).strip())
                        if clean:
                            texts.append(clean)
        return " ".join(texts)[:5000]
    except Exception as e:
        log.warning(f"Seite {page_id} nicht lesbar: {e}")
        return ""


# ─────────────────────────────────────────────
# TOOL IMPLEMENTIERUNGEN
# ─────────────────────────────────────────────

def tool_get_product_details(product_name: str) -> str:
    """Holt detaillierte Live-Informationen zu einem Produkt aus SharePoint."""
    try:
        cached = find_product_in_cache(product_name)
        if not cached:
            cached = find_product_live(product_name)
        if not cached:
            return f"Produkt '{product_name}' nicht gefunden. Verfügbare Produkte via `list_products` abrufen."

        site_id = cached["site_id"]
        log.info(f"Lade Details für: {cached['displayName']} ({site_id})")

        flags     = get_page_flags_and_pages(site_id)
        page_text = get_page_text(site_id)

        files_info = []
        platforms  = []
        try:
            drives = graph_get(f"/sites/{site_id}/drives")
            drive_id = None
            for d in drives.get("value", []):
                if d.get("name") in ("Documents", "Dokumente", "Freigegebene Dokumente"):
                    drive_id = d["id"]
                    break
            if not drive_id and drives.get("value"):
                drive_id = drives["value"][0]["id"]

            if drive_id:
                children = graph_get(f"/sites/{site_id}/drives/{drive_id}/root/children")
                platform_map = {
                    "windows": "Windows", "win": "Windows",
                    "mac": "macOS",       "macos": "macOS",
                    "linux": "Linux",     "lnx": "Linux"
                }
                for item in children.get("value", []):
                    name_lower = item["name"].lower()
                    for key, label in platform_map.items():
                        if key in name_lower and label not in platforms:
                            platforms.append(label)
                    if "file" in item:
                        files_info.append({
                            "name": item["name"],
                            "groesse_kb": round(item.get("size", 0) / 1024, 1),
                            "geaendert": item.get("lastModifiedDateTime", "")[:10]
                        })
                    elif "folder" in item:
                        files_info.append({
                            "name": f"📁 {item['name']}/",
                            "inhalt": item.get("folder", {}).get("childCount", 0)
                        })
        except Exception as e:
            log.warning(f"Drive-Inhalt nicht ladbar: {e}")

        shop_match = re.search(r'https://shop\.tusoftware\.tuwien\.ac\.at[^\s"\'<>]+', page_text)
        shop_url   = shop_match.group(0) if shop_match else cached.get("shop_url", "")

        liz_keywords    = ["lizenzserver", "license server", "netzwerk lizenz", "lizenzmanager"]
        hat_lizenzserver = any(k in page_text.lower() for k in liz_keywords)

        server_match    = re.search(r'[\w\-]+\.(?:it\.)?tuwien\.ac\.at(?::\d+)?', page_text)
        lizenzserver_name = ""
        if server_match and "login" not in server_match.group(0):
            lizenzserver_name = server_match.group(0)

        result = {
            "produkt":             cached["displayName"],
            "webUrl":              cached["webUrl"],
            "shop_url":            shop_url,
            "plattformen":         sorted(platforms),
            "hat_lizenzserver":    hat_lizenzserver,
            "lizenzserver_name":   lizenzserver_name,
            "tu_net_erforderlich": any(k in page_text.lower() for k in ["tu-net", "tu-vpn", "tu net"]),
            "seiteninhalt_de":     page_text[:2000] if page_text else "(kein Seitentext verfügbar)",
            "hat_englisch":        flags["hat_englisch"],
            "kommentare_de":       flags["kommentare_de"],
            "kommentare_en":       flags["kommentare_en"],
            "metadata": {
                "erstellt_von":  flags["erstellt_von"],  "erstellt_am":  flags["erstellt_am"],
                "geaendert_von": flags["geaendert_von"], "geaendert_am": flags["geaendert_am"],
                "status":        flags["status"]
            },
            "dateien": files_info[:20]
        }
        return json.dumps(result, ensure_ascii=False, indent=2)

    except Exception as e:
        log.error(f"Tool-Fehler get_product_details '{product_name}': {e}")
        return f"Fehler beim Abrufen von '{product_name}': {str(e)}"


def tool_search_products(keyword: str) -> str:
    """Sucht nach Produkten anhand eines Stichworts."""
    keyword_lower = keyword.lower()

    # Im Cache suchen
    if PRODUKTE_CACHE:
        matches = [
            p for p in PRODUKTE_CACHE
            if keyword_lower in p["displayName"].lower() or keyword_lower in p["name"].lower()
        ]
        if matches:
            result = f"Gefundene Produkte für '{keyword}':\n"
            for m in sorted(matches, key=lambda x: x["displayName"]):
                liz = " ⚠️ Lizenzserver" if m.get("lizenzserver") else ""
                result += f"- {m['displayName']}{liz}\n"
            return result
        return f"Keine Produkte gefunden für '{keyword}'."

    # Fallback: Live-Suche
    try:
        data = graph_get(f"/sites/{PARENT_SITE_ID}/sites")
        matches = [
            site["displayName"]
            for site in data.get("value", [])
            if keyword_lower in site.get("displayName", "").lower()
        ]
        if matches:
            return f"Gefundene Produkte für '{keyword}':\n" + "\n".join(f"- {m}" for m in sorted(matches))
        return f"Keine Produkte gefunden für '{keyword}'."
    except Exception as e:
        return f"Fehler bei der Suche: {str(e)}"


def tool_get_english_content(product_name: str) -> str:
    """Liest den englischen Seiteninhalt eines Produkts aus SharePoint."""
    try:
        cached = find_product_in_cache(product_name)
        if not cached:
            return f"Produkt '{product_name}' nicht gefunden."
        site_id = cached["site_id"]
        flags   = get_page_flags_and_pages(site_id)
        if not flags["hat_englisch"]:
            return f"'{cached['displayName']}' hat keine englische Seitenversion in SharePoint."
        en_page_id = flags["en_page_id"]
        if not en_page_id:
            return f"Englische Seite für '{cached['displayName']}' gefunden, aber ID nicht lesbar."
        log.info(f"Lese englische Seite für: {cached['displayName']} (page_id: {en_page_id})")
        en_text = get_page_text_by_id(site_id, en_page_id)
        if not en_text:
            return f"Englische Seite für '{cached['displayName']}' ist leer oder nicht lesbar."
        return json.dumps({
            "produkt": cached["displayName"], "sprache": "Englisch",
            "kommentare_aktiv": flags["kommentare_en"], "seiteninhalt_en": en_text[:3000]
        }, ensure_ascii=False, indent=2)
    except Exception as e:
        log.error(f"Tool-Fehler get_english_content '{product_name}': {e}")
        return f"Fehler: {str(e)}"


def tool_list_products(filter_type: str = "all") -> str:
    """Listet alle verfügbaren Produkte, optional gefiltert."""
    if not PRODUKTE_CACHE:
        return "Produkte-Cache nicht verfügbar. Bitte skill_generator_v2.py ausführen."

    from datetime import date
    heute = date.today()

    if filter_type == "lizenzserver":
        produkte = [p for p in PRODUKTE_CACHE if p.get("lizenzserver")]
        title = "Produkte mit Lizenzserver"
    elif filter_type == "shop":
        produkte = [p for p in PRODUKTE_CACHE if p.get("shop_url")]
        title = "Produkte mit Shop-Eintrag"
    elif filter_type == "kommentare_de":
        produkte = [p for p in PRODUKTE_CACHE if p.get("kommentare_de")]
        title = "Produkte mit aktiven Kommentaren (DE)"
    elif filter_type == "kommentare_en":
        produkte = [p for p in PRODUKTE_CACHE if p.get("kommentare_en")]
        title = "Produkte mit aktiven Kommentaren (EN)"
    elif filter_type == "veraltet":
        produkte = []
        for p in PRODUKTE_CACHE:
            geaendert = p.get("geaendert_am", "")
            if geaendert:
                try:
                    tage = (heute - date.fromisoformat(geaendert)).days
                    if tage > 180:
                        p = dict(p)
                        p["_tage"] = tage
                        produkte.append(p)
                except Exception:
                    pass
        produkte.sort(key=lambda x: x.get("_tage", 0), reverse=True)
        title = "Produkte nicht aktualisiert seit > 180 Tagen"
    elif filter_type == "published":
        produkte = [p for p in PRODUKTE_CACHE if p.get("status") == "published"]
        title = "Veröffentlichte Produkte"
    elif filter_type == "draft":
        produkte = [p for p in PRODUKTE_CACHE if p.get("status") == "draft"]
        title = "Produkte im Entwurfsstatus"
    else:
        produkte = PRODUKTE_CACHE
        title = "Alle verfügbaren Produkte"

    if not produkte:
        return f"Keine Produkte gefunden für Filter '{filter_type}'."

    lines = [f"{title} ({len(produkte)} Stück):"]
    for p in sorted(produkte, key=lambda x: x["displayName"]):
        extras = []
        if p.get("lizenzserver"):       extras.append("⚠️ Lizenzserver")
        if p.get("kommentare_de"):      extras.append("💬 Komm.DE")
        if p.get("kommentare_en"):      extras.append("💬 Komm.EN")
        if p.get("_tage"):              extras.append(f"📅 {p['_tage']} Tage")
        geaendert = p.get("geaendert_am", "")
        geaendert_von = p.get("geaendert_von", "")
        suffix = f" | {geaendert} ({geaendert_von})" if geaendert else ""
        extra_str = " — " + ", ".join(extras) if extras else ""
        lines.append(f"- {p['displayName']}{extra_str}{suffix}")
    return "\n".join(lines)


def tool_filter_products(
    plattform: str = "",
    lizenzserver: bool = None,
    kommentare: str = "",
    geaendert_vor_tagen: int = 0,
    geaendert_von: str = ""
) -> str:
    """Filtert Produkte nach mehreren Kriterien gleichzeitig."""
    if not PRODUKTE_CACHE:
        return "Produkte-Cache nicht verfügbar."

    from datetime import date
    heute = date.today()
    result = PRODUKTE_CACHE

    if plattform:
        # Plattform-Filter braucht live-Daten da nicht im Cache
        return f"Plattform-Filter benötigt Live-Abfrage — nutze stattdessen: 'Suche alle Produkte für {plattform}' und ich rufe get_product_details auf."

    if lizenzserver is not None:
        result = [p for p in result if p.get("lizenzserver") == lizenzserver]

    if kommentare == "de":
        result = [p for p in result if p.get("kommentare_de")]
    elif kommentare == "en":
        result = [p for p in result if p.get("kommentare_en")]
    elif kommentare == "beide":
        result = [p for p in result if p.get("kommentare_de") or p.get("kommentare_en")]

    if geaendert_vor_tagen > 0:
        gefiltert = []
        for p in result:
            geaendert = p.get("geaendert_am", "")
            if geaendert:
                try:
                    tage = (heute - date.fromisoformat(geaendert)).days
                    if tage > geaendert_vor_tagen:
                        gefiltert.append(p)
                except Exception:
                    pass
        result = gefiltert

    if geaendert_von:
        result = [p for p in result
                  if geaendert_von.lower() in p.get("geaendert_von", "").lower()]

    if not result:
        return "Keine Produkte gefunden für die angegebenen Filter."

    lines = [f"Gefilterte Produkte ({len(result)} Treffer):"]
    for p in sorted(result, key=lambda x: x["displayName"]):
        geaendert_am  = p.get("geaendert_am", "—")
        geaendert_von_name = p.get("geaendert_von", "—")
        liz = " ⚠️" if p.get("lizenzserver") else ""
        lines.append(f"- {p['displayName']}{liz} | {geaendert_am} | {geaendert_von_name}")
    return "\n".join(lines)


# ─────────────────────────────────────────────
# TOOL DEFINITIONS (OpenAI Function Calling Format)
# ─────────────────────────────────────────────

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_product_details",
            "description": (
                "Holt detaillierte Live-Informationen zu einem spezifischen Softwareprodukt "
                "aus SharePoint: Seitentext, Lizenzserver-Info, Shop-URL, verfügbare Plattformen, Dateien. "
                "Verwende dieses Tool bei Fragen zu einem bestimmten Produkt."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "product_name": {
                        "type": "string",
                        "description": "Name des Produkts, z.B. 'BricsCAD', 'MATLAB', 'ANSYS'"
                    }
                },
                "required": ["product_name"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "search_products",
            "description": "Sucht nach Produkten anhand eines Stichworts im Produktnamen.",
            "parameters": {
                "type": "object",
                "properties": {
                    "keyword": {
                        "type": "string",
                        "description": "Stichwort, z.B. 'Adobe', 'CAD', 'ANSYS'"
                    }
                },
                "required": ["keyword"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_english_content",
            "description": (
                "Liest den englischen Seiteninhalt eines Produkts aus SharePoint. "
                "Verwende dieses Tool wenn der Benutzer die englische Version einer Produktseite sehen möchte, "
                "oder wenn er prüfen will ob die englische Version mit der deutschen übereinstimmt."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "product_name": {
                        "type": "string",
                        "description": "Name des Produkts, z.B. 'BricsCAD', 'MATLAB'"
                    }
                },
                "required": ["product_name"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "list_products",
            "description": (
                "Listet verfügbare Produkte mit Filteroptionen. filter_type: "
                "'all' = alle, 'lizenzserver' = mit Lizenzserver, 'shop' = mit Shop, "
                "'kommentare_de' = Kommentare DE aktiv, 'kommentare_en' = Kommentare EN aktiv, "
                "'veraltet' = nicht aktualisiert seit > 180 Tagen, "
                "'published' = veröffentlicht, 'draft' = Entwurf"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "filter_type": {
                        "type": "string",
                        "enum": ["all", "lizenzserver", "shop", "kommentare_de",
                                 "kommentare_en", "veraltet", "published", "draft"],
                        "description": "Filtertyp"
                    }
                },
                "required": ["filter_type"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "filter_products",
            "description": (
                "Filtert Produkte nach mehreren Kriterien gleichzeitig aus dem Cache. "
                "Verwende dieses Tool für kombinierte Abfragen wie: "
                "'Welche Produkte haben Kommentare aktiviert UND wurden von Person X geändert?' "
                "oder 'Welche Produkte wurden seit X Tagen nicht aktualisiert?'"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "lizenzserver": {
                        "type": "boolean",
                        "description": "true = nur mit Lizenzserver, false = nur ohne"
                    },
                    "kommentare": {
                        "type": "string",
                        "enum": ["", "de", "en", "beide"],
                        "description": "Kommentare aktiv auf: de, en oder beide Seiten"
                    },
                    "geaendert_vor_tagen": {
                        "type": "integer",
                        "description": "Nur Produkte die seit mehr als X Tagen nicht geändert wurden"
                    },
                    "geaendert_von": {
                        "type": "string",
                        "description": "Nur Produkte die von dieser Person geändert wurden (Teilstring)"
                    }
                },
                "required": []
            }
        }
    }
]

TOOL_MAP = {
    "get_product_details": lambda args: tool_get_product_details(args["product_name"]),
    "search_products":     lambda args: tool_search_products(args["keyword"]),
    "list_products":       lambda args: tool_list_products(args.get("filter_type", "all")),
    "get_english_content": lambda args: tool_get_english_content(args["product_name"]),
    "filter_products":    lambda args: tool_filter_products(
        lizenzserver=args.get("lizenzserver"),
        kommentare=args.get("kommentare", ""),
        geaendert_vor_tagen=args.get("geaendert_vor_tagen", 0),
        geaendert_von=args.get("geaendert_von", "")
    ),
}

# ─────────────────────────────────────────────
# GLM API
# ─────────────────────────────────────────────

def call_glm(messages, tools=None, max_tokens=4096):
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json"
    }
    payload = {
        "model": MODEL,
        "messages": messages,
        "max_tokens": max_tokens,
        "temperature": 0.3,
    }
    if tools:
        payload["tools"] = tools
    r = requests.post(f"{BASE_URL}/chat/completions", headers=headers, json=payload, timeout=120)
    r.raise_for_status()
    return r.json()


# ─────────────────────────────────────────────
# PROXY ENDPUNKT
# ─────────────────────────────────────────────

@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Ungültiger JSON Body")

    messages = body.get("messages", [])
    log.info(f"Request: {len(messages)} messages — '{str(messages[-1].get('content',''))[:80]}'")

    # SKILL.md als System Prompt injizieren
    has_system = any(m.get("role") == "system" for m in messages)
    if not has_system:
        messages = [{"role": "system", "content": SKILL_CONTENT}] + messages
    else:
        for m in messages:
            if m.get("role") == "system":
                m["content"] = SKILL_CONTENT + "\n\n---\n\n" + m["content"]
                break

    response     = call_glm(messages, tools=TOOLS)
    choice       = response["choices"][0]
    assistant_msg = choice["message"]

    # Agentic Loop (max. 3 Tool-Runden)
    for _ in range(3):
        if choice.get("finish_reason") != "tool_calls":
            break
        tool_calls = assistant_msg.get("tool_calls", [])
        log.info(f"Tool calls: {[tc['function']['name'] for tc in tool_calls]}")
        messages.append(assistant_msg)

        for tc in tool_calls:
            fn_name = tc["function"]["name"]
            fn_args = json.loads(tc["function"]["arguments"])
            log.info(f"Executing: {fn_name}({fn_args})")
            result = TOOL_MAP.get(fn_name, lambda _: f"Unbekanntes Tool: {fn_name}")(fn_args)
            messages.append({
                "role": "tool",
                "tool_call_id": tc["id"],
                "content": result
            })

        response = call_glm(messages, tools=TOOLS)
        choice = response["choices"][0]
        assistant_msg = choice["message"]

    log.info(f"Antwort: '{str(assistant_msg.get('content',''))[:100]}'")
    return JSONResponse(content=response)


@app.get("/v1/models")
async def list_models():
    r = requests.get(f"{BASE_URL}/models", headers={"Authorization": f"Bearer {API_KEY}"})
    return JSONResponse(content=r.json())


@app.get("/health")
async def health():
    return {
        "status": "ok",
        "skill_md": os.path.exists(SKILL_FILE),
        "cache_geladen": len(PRODUKTE_CACHE),
        "model": MODEL,
        "timestamp": datetime.now().isoformat()
    }


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8765)

5.3 diagnose.py

Zweck: Entwicklungs-Hilfsskript zum Erkunden der verfügbaren Microsoft Graph API Metadaten für ein einzelnes Produkt. Testet 10 verschiedene API-Endpunkte und gibt alle verfügbaren Felder aus — nützlich um zu verstehen, welche Daten SharePoint liefert und ob neue Felder in den Proxy aufgenommen werden sollen.

Verwendung:

# Testprodukt in der Datei anpassen (TEST_PRODUKT = "BricsCAD")
python diagnose.py

Getestete Endpunkte:

Nr. Endpunkt Was wird gezeigt
1 /sites/{id} (v1.0 + Beta) Site-Metadaten
2 /sites/{id}/pages (Beta) Seiten mit allen Feldern, Canvas Layout
3 /sites/{id}/analytics/lastSevenDays Seitenaufrufe letzte 7 Tage
4 /sites/{id}/contentTypes Content Types
5 /sites/{id}/permissions Berechtigungen
6 /sites/{id}/lists SharePoint Listen
7 /sites/{id}/drives Dokument-Bibliotheken
8 /sites/{id}/settings (Beta) Site-Einstellungen
9 Sprachen aus Seiten-URLs ableiten Mehrsprachigkeit
10 Versionshistorie der Hauptseite Änderungshistorie
"""
SharePoint Metadaten-Diagnose
==============================
Zeigt ALLE verfügbaren Metadaten einer Produkt-Subsite.
Testet verschiedene API-Endpunkte und gibt alles aus.

Verwendung:
    python3 diagnose.py

Voraussetzungen:
    pip install requests python-dotenv
"""

import requests
import json
import os
from dotenv import load_dotenv

load_dotenv()

TENANT_ID     = os.environ.get("TENANT_ID", "")
CLIENT_ID     = os.environ.get("CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "")
CACHE_FILE    = "produkte_cache.json"

TEST_PRODUKT  = "BricsCAD"   # ← hier anpassen


def get_token():
    r = requests.post(
        f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
        data={"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
              "grant_type": "client_credentials", "scope": "https://graph.microsoft.com/.default"}
    )
    r.raise_for_status()
    return r.json()["access_token"]


def load_cache():
    if not os.path.exists(CACHE_FILE):
        print(f"❌ Cache-Datei nicht gefunden: {CACHE_FILE}")
        print("   Bitte zuerst skill_generator_v2.py ausführen.")
        exit(1)
    with open(CACHE_FILE) as f:
        return json.load(f).get("produkte", [])


def find_product(cache, name):
    name_lower = name.lower()
    for p in cache:
        if name_lower in p["displayName"].lower():
            return p
    return None


def graph_get(token, url, beta=False):
    base     = "https://graph.microsoft.com/beta" if beta else "https://graph.microsoft.com/v1.0"
    full_url = url if url.startswith("http") else f"{base}{url}"
    r = requests.get(full_url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"})
    return r.status_code, r.json() if r.ok else r.text


def main():
    token   = get_token()
    cache   = load_cache()
    product = find_product(cache, TEST_PRODUKT)
    if not product:
        print(f"❌ '{TEST_PRODUKT}' nicht im Cache")
        return

    site_id = product["site_id"]
    print(f"✅ Produkt: {product['displayName']} | Site ID: {site_id}")

    # Seiten mit Beta API laden
    _, pages_data = graph_get(token, f"/sites/{site_id}/pages", beta=True)
    pages    = pages_data.get("value", []) if isinstance(pages_data, dict) else []
    de_pages = [p for p in pages if "/en/" not in p.get("webUrl", "")]

    page_id = None
    if de_pages:
        def _version(p):
            try:
                return float(p.get("publishingState", {}).get("versionId", "0"))
            except (ValueError, TypeError):
                return 0.0
        best_de = max(de_pages, key=_version)
        page_id = best_de["id"]
        print(json.dumps(best_de, ensure_ascii=False, indent=2))

    # Weitere Endpunkte testen...
    # [Analytics, Content Types, Permissions, Lists, Drives, Settings, Versions]

if __name__ == "__main__":
    main()

5.4 Dockerfile

Zweck: Baut das Docker-Image für den sharepoint-proxy Service. Basiert auf Python 3.12 Slim — minimales Image ohne unnötige Pakete.

FROM python:3.12-slim
# Schlankes Python 3.12 Base-Image (~150MB)

WORKDIR /app
# Arbeitsverzeichnis im Container

RUN pip install --no-cache-dir \
    fastapi \
    uvicorn \
    requests \
    python-dotenv
# Abhängigkeiten installieren (kein Cache = kleineres Image)
# fastapi     — Web-Framework für den Proxy-Server
# uvicorn     — ASGI-Server zum Ausführen von FastAPI
# requests    — HTTP-Client für Graph API Aufrufe
# python-dotenv — .env Datei laden (für lokale Nutzung der Skripte)

COPY proxy_v2.py proxy.py
# Proxy-Skript ins Image kopieren (als proxy.py)

EXPOSE 8765
# Port dokumentieren (wird in docker-compose.yml gemappt)

CMD ["python", "proxy.py"]
# Server starten — uvicorn.run() ist im Skript selbst aufgerufen

Hinweis: SKILL.md und produkte_cache.json werden nicht ins Image kopiert, sondern zur Laufzeit als Volume eingebunden (:ro = read-only). So kann der Cache aktualisiert werden, ohne das Image neu zu bauen.


5.5 docker-compose.yml

Zweck: Orchestriert beide Services — SharePoint-Proxy und OpenWebUI — in einem gemeinsamen Docker-Netzwerk.

services:
   sharepoint-proxy:
      build: ..                          # Image aus lokalem Dockerfile bauen
      container_name: sharepoint-proxy
      restart: unless-stopped           # Neustart bei Absturz, nicht bei manuellem Stop

      ports:
         - "8765:8765"                   # Für direkten Zugriff / Debugging
         # Kann weggelassen werden wenn nur OpenWebUI zugreift

      volumes:
         - ./SKILL.md:/app/SKILL.md:ro               # Wissensbasis (read-only)
         - ./produkte_cache.json:/app/produkte_cache.json:ro  # Produktindex (read-only)

      environment:
         - BASE_URL=${BASE_URL}          # Alle Werte kommen aus der .env Datei
         - API_KEY=${API_KEY}
         - MODEL=${MODEL}
         - TENANT_ID=${TENANT_ID}
         - CLIENT_ID=${CLIENT_ID}
         - CLIENT_SECRET=${CLIENT_SECRET}
         - PARENT_SITE_ID=${PARENT_SITE_ID}
         - SKILL_FILE=/app/SKILL.md      # Pfad im Container
         - CACHE_FILE=/app/produkte_cache.json

      healthcheck:
         # Python ist im Container verfügbar — kein curl nötig
         test: [ "CMD", "python", "-c",
                 "import urllib.request; urllib.request.urlopen('http://localhost:8765/health', timeout=5)" ]
         interval: 30s    # Alle 30 Sekunden prüfen
         timeout: 10s     # Nach 10s als unhealthy markieren
         retries: 3       # 3 Fehlversuche bevor unhealthy
      networks:
         - ai-network

   open-webui:
      image: ghcr.io/open-webui/open-webui:main   # Offizielles OpenWebUI Image
      container_name: open-webui
      restart: unless-stopped
      ports:
         - "3003:8080"            # OpenWebUI auf Port 3003 erreichbar
      volumes:
         - ./data:/app/backend/data   # Persistente Daten (DB, Embeddings, etc.)
      depends_on:
         sharepoint-proxy:
            condition: service_healthy  # Startet erst wenn Proxy healthy ist
      networks:
         - ai-network

networks:
   ai-network:   # Internes Docker-Netzwerk — Container kommunizieren über Containernamen

6. Installation & Deployment

Schritt 1 — Projekt klonen / Dateien bereitstellen

projektverzeichnis/
├── .env                    ← Credentials (nie in Git!)
├── Dockerfile
├── docker-compose.yml
├── proxy_v2.py
├── skill_generator_v2.py
├── diagnose.py
├── SKILL.md                ← wird von skill_generator_v2.py erzeugt
└── produkte_cache.json     ← wird von skill_generator_v2.py erzeugt

Schritt 2 — .env Datei befüllen

cp .env.example .env
# .env mit echten Werten befüllen (siehe Abschnitt 4)

Schritt 3 — Cache und SKILL.md generieren

# Abhängigkeiten installieren (einmalig)
pip install requests python-dotenv

# Generator ausführen (~2-5 Minuten je nach Produktanzahl)
python skill_generator_v2.py

Erwartete Ausgabe:

📋 SharePoint SKILL.md Generator v2 — Subsite-Struktur
=======================================================
🔑 Authentifizierung...
✅ Token erhalten
📦 Produkt-Subsites laden...
✅ 80 Produkte gefunden

🔍 Seiteninhalte & Metadaten lesen...
  [ 80/ 80] Zoom                                        
✅ 80 Produkte verarbeitet, 0 Fehler
✅ Produkte-Cache gespeichert: produkte_cache.json
📝 SKILL.md generieren...
✅ SKILL.md gespeichert (45230 Zeichen, 312 Zeilen)

🎉 Fertig! Proxy neu starten: docker compose restart sharepoint-proxy

Schritt 4 — Docker Container starten

# Erster Start (Image bauen + Container starten)
docker compose up -d

# Logs verfolgen
docker compose logs -f

# Status prüfen
docker compose ps

Erwarteter Status nach ~30 Sekunden:

NAME                STATUS
sharepoint-proxy    running (healthy)
open-webui          running

Schritt 5 — Health Check

curl http://localhost:8765/health
{
  "status": "ok",
  "skill_md": true,
  "cache_geladen": 80,
  "model": "gemma-4-26b",
  "timestamp": "2026-04-15T10:30:00.000000"
}

7. OpenWebUI konfigurieren

  1. Browser öffnen: http://localhost:3003
  2. Account erstellen (erster Account wird automatisch Admin)
  3. Einstellungen → Verbindungen (oder Admin Panel → Einstellungen)
  4. OpenAI API konfigurieren:
  5. URL: http://sharepoint-proxy:8765/v1 (Container-interner Name, funktioniert weil beide im ai-network sind)
  6. API Key: beliebiger Wert (z.B. dummy) — der Proxy prüft ihn nicht
  7. Verbindung testen → ✅
  8. Modell auswählen (wird vom Proxy aus der .env MODEL-Variable bestimmt)

Tipp: Wenn OpenWebUI von außerhalb Docker zugreift, statt http://sharepoint-proxy:8765/v1 die Host-IP verwenden: http://HOST_IP:8765/v1


8. Verfügbare Tools & Verwendung

Der Proxy stellt dem LLM 5 Tools zur Verfügung, die automatisch aufgerufen werden:

get_product_details — Detailinfos zu einem Produkt

Holt Live-Daten direkt aus SharePoint: Seitentext, Lizenzserver, Shop-URL, Plattformen, Dateien.

Beispielanfragen:

"Wie installiere ich MATLAB?"
"Gibt es AutoCAD für Mac?"
"Welchen Lizenzserver brauche ich für ANSYS?"

search_products — Produktsuche nach Stichwort

Sucht im Cache nach Produktnamen.

Beispielanfragen:

"Gibt es Adobe-Produkte?"
"Zeig mir alle CAD-Programme."

list_products — Produktliste mit Filtern

Filteroptionen: all, lizenzserver, shop, kommentare_de, kommentare_en, veraltet, published, draft

Beispielanfragen:

"Welche Produkte benötigen einen Lizenzserver?"
"Zeig mir alle Produkte die seit über 6 Monaten nicht aktualisiert wurden."
"Welche Produkte haben einen Shop-Eintrag?"

get_english_content — Englischer Seiteninhalt

Liest die englische Sprachversion einer SharePoint-Seite.

Beispielanfragen:

"Was steht auf der englischen Seite von BricsCAD?"
"Stimmt die englische Übersetzung von MATLAB mit der deutschen überein?"

filter_products — Kombinierter Filter

Mehrere Kriterien gleichzeitig: Lizenzserver, Kommentare, Alter, Bearbeiter.

Beispielanfragen:

"Welche Produkte wurden von Müller geändert und haben Kommentare aktiviert?"
"Zeig mir alle Produkte ohne Lizenzserver die seit 90 Tagen nicht aktualisiert wurden."


9. Wartung & Troubleshooting

Cache aktualisieren

Nach Änderungen in SharePoint (neue Produkte, geänderte Inhalte):

# 1. Generator lokal ausführen
python skill_generator_v2.py

# 2. Proxy-Container neu starten (lädt SKILL.md und Cache neu)
docker compose restart sharepoint-proxy

# 3. Prüfen ob neue Produktanzahl stimmt
curl http://localhost:8765/health

Container neu starten

# Nur Proxy
docker compose restart sharepoint-proxy

# Alles
docker compose restart

# Komplett neu bauen (nach Änderungen an proxy_v2.py oder Dockerfile)
docker compose up -d --build sharepoint-proxy

Logs einsehen

# Live-Logs beider Container
docker compose logs -f

# Nur Proxy-Logs (letzte 100 Zeilen)
docker compose logs --tail=100 sharepoint-proxy

Häufige Probleme

Problem Ursache Lösung
sharepoint-proxy bleibt starting SKILL.md oder produkte_cache.json fehlt skill_generator_v2.py ausführen
Health Check schlägt fehl Proxy noch nicht gestartet docker compose logs sharepoint-proxy prüfen
OpenWebUI findet keine Modelle Falsche URL in OpenWebUI URL auf http://sharepoint-proxy:8765/v1 setzen
401 Unauthorized in Proxy-Logs API_KEY ungültig .env prüfen, Container neu starten
AADSTS Fehler in Logs Azure AD Credentials falsch TENANT_ID, CLIENT_ID, CLIENT_SECRET prüfen
Leere Antworten vom LLM SKILL.md zu groß für Kontext Modell mit größerem Kontextfenster verwenden

Diagnose-Tool verwenden

# TEST_PRODUKT in diagnose.py anpassen, dann:
python diagnose.py > diagnose_output.txt

# Zeigt alle verfügbaren API-Felder für ein Produkt
# Nützlich für Debugging oder Erweiterungen

Dokumentation erstellt am 15.04.2026