This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Staging Review Workflow – 2026-07-29
|
||||||
|
|
||||||
|
- Editor now has **Produktiv / Staging** scopes.
|
||||||
|
- Staging drafts can be searched, filtered, opened and edited with the existing form/Raw JSON editor.
|
||||||
|
- Added single and bulk **Freigeben → Produktiv**.
|
||||||
|
- Added single and bulk delete with safe archive under `staging/.trash`.
|
||||||
|
- Promoted source drafts are retained under `staging/.approved` for audit purposes.
|
||||||
|
- Promotion refuses duplicate production IDs or target files.
|
||||||
|
- Dual Compose now mounts the same staging directory read/write into the editor container.
|
||||||
|
- Google/viewer mode remains read-only for all review actions.
|
||||||
+43
-20
@@ -1,23 +1,46 @@
|
|||||||
KB Helpdesk Editor / Google Mode / Ollama Fallback
|
KB Helpdesk Editor / Search / Ollama / Staging Review Workflow
|
||||||
Quality report
|
Quality report
|
||||||
|
Date: 2026-07-29
|
||||||
|
|
||||||
Checks completed successfully:
|
Implemented review workflow
|
||||||
- go test ./...
|
---------------------------
|
||||||
- go test -race ./...
|
- Editor scope switch: Production / Staging
|
||||||
- go vet ./...
|
- Staging count in UI and health endpoint
|
||||||
- go build ./cmd/server
|
- Staging search/filter with existing q, auto_reply, language, style and source filters
|
||||||
- node --check cmd/server/web/app.js
|
- Staging article editing in form view and raw JSON view
|
||||||
- node --check cmd/server/viewer/app.js
|
- Single promote: staging -> productive knowledge directory
|
||||||
- docker-compose.yml YAML parse
|
- Single delete: staging -> staging/.trash
|
||||||
- docker-compose.dual.yml YAML parse
|
- Bulk promote/delete for selected staging articles
|
||||||
- End-to-end mock Ollama flow:
|
- Promoted originals archived below staging/.approved for audit
|
||||||
0 search results -> POST /api/ai/fallback -> structured /api/chat response
|
- Production import refuses duplicate IDs and existing target filenames
|
||||||
-> atomic staging JSON write -> GET /api/staging/{key}
|
- Unknown JSON fields are preserved on staging edits and promotion
|
||||||
- Server-side guard blocks AI fallback when normal KB search has results
|
- Google/viewer mode blocks staging list/write/delete/promote APIs server-side
|
||||||
- Staging default auto_reply=false
|
- Ollama fallback continues to save only into staging
|
||||||
- Main knowledge directory remains separate from writable staging directory
|
|
||||||
|
|
||||||
Note:
|
Validation
|
||||||
No live request was made against the user's Ollama instance. The integration test used
|
----------
|
||||||
an HTTP mock that validates the expected Ollama /api/chat request shape and returns a
|
PASS go test ./...
|
||||||
structured response compatible with the official Ollama API documentation.
|
PASS go test -race ./...
|
||||||
|
PASS go vet ./...
|
||||||
|
PASS go build ./cmd/server
|
||||||
|
PASS node --check cmd/server/web/app.js
|
||||||
|
PASS node --check cmd/server/viewer/app.js
|
||||||
|
PASS docker-compose.yml YAML parse
|
||||||
|
PASS docker-compose.dual.yml YAML parse
|
||||||
|
PASS editor DOM selector/ID consistency check
|
||||||
|
PASS binary E2E: staging search -> update -> promote -> production -> .approved
|
||||||
|
|
||||||
|
E2E assertions
|
||||||
|
--------------
|
||||||
|
- active staging count before promotion: 1
|
||||||
|
- production count before promotion: 0
|
||||||
|
- matching staging search results: 1
|
||||||
|
- production files after promotion: 1
|
||||||
|
- active staging files after promotion: 0
|
||||||
|
- approved audit files after promotion: 1
|
||||||
|
|
||||||
|
Docker note
|
||||||
|
-----------
|
||||||
|
The Docker CLI/daemon is not available in this execution environment, so an
|
||||||
|
actual `docker build` was not executed. Docker Compose files were parsed as YAML,
|
||||||
|
and the Go binary itself was exercised end-to-end.
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ Der bekannte Administrationsmodus:
|
|||||||
- automatische Backups
|
- automatische Backups
|
||||||
- atomisches Schreiben per Temp-Datei + Rename
|
- atomisches Schreiben per Temp-Datei + Rename
|
||||||
- Schutz vor extern veränderten Dateien
|
- Schutz vor extern veränderten Dateien
|
||||||
|
- integrierter **Produktiv/Staging-Umschalter** mit Staging-Zähler
|
||||||
|
- KI-Entwürfe im selben Formular oder Raw-JSON-Editor prüfen und korrigieren
|
||||||
|
- einzelne oder mehrere Staging-Entwürfe **Freigeben → Produktiv**
|
||||||
|
- einzelne oder mehrere Staging-Entwürfe sicher löschen (`staging/.trash`)
|
||||||
|
- Freigaben überschreiben niemals bestehende Produktiv-IDs oder Zieldateien
|
||||||
|
|
||||||
### `APP_MODE=google`
|
### `APP_MODE=google`
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ APP_SUBTITLE=JSON · Massenbearbeitung · Docker
|
|||||||
KB_DATA_PATH=../glpi-ai-agent-kb-microsoft-errorcodes-kompendium/knowledge
|
KB_DATA_PATH=../glpi-ai-agent-kb-microsoft-errorcodes-kompendium/knowledge
|
||||||
KB_DATA_MOUNT_MODE=rw
|
KB_DATA_MOUNT_MODE=rw
|
||||||
KB_BACKUP_PATH=./backups
|
KB_BACKUP_PATH=./backups
|
||||||
|
KB_STAGING_PATH=./staging
|
||||||
KB_EDITOR_PORT=8080
|
KB_EDITOR_PORT=8080
|
||||||
|
|
||||||
BASIC_AUTH_USER=admin
|
BASIC_AUTH_USER=admin
|
||||||
@@ -193,6 +199,35 @@ Ein Staging-Artikel verwendet dasselbe JSON-Format wie die restliche Wissensbasi
|
|||||||
|
|
||||||
`auto_reply` ist im Staging standardmäßig bewusst `false`. Das kann über `OLLAMA_STAGING_AUTO_REPLY=true` geändert werden, wird für ungeprüfte KI-Inhalte aber nicht empfohlen.
|
`auto_reply` ist im Staging standardmäßig bewusst `false`. Das kann über `OLLAMA_STAGING_AUTO_REPLY=true` geändert werden, wird für ungeprüfte KI-Inhalte aber nicht empfohlen.
|
||||||
|
|
||||||
|
## Staging-Review und Freigabe im Editor
|
||||||
|
|
||||||
|
Der Editor bindet `STAGING_DIR` unabhängig davon ein, ob auf dieser Instanz der Ollama-Fallback aktiv ist. In einem Dual-Deployment teilen sich Search- und Editor-Container daher denselben Staging-Mount:
|
||||||
|
|
||||||
|
```text
|
||||||
|
kb-search
|
||||||
|
knowledge :ro
|
||||||
|
staging :rw <- KI erzeugt Entwürfe
|
||||||
|
|
||||||
|
kb-editor
|
||||||
|
knowledge :rw <- Freigaben landen hier
|
||||||
|
staging :rw <- Helpdesk prüft Entwürfe
|
||||||
|
backups :rw
|
||||||
|
```
|
||||||
|
|
||||||
|
In der Editor-Oberfläche steht links oberhalb der Suche ein Umschalter **Produktiv / Staging** zur Verfügung. Die bestehenden Filter für Suchtext, `auto_reply`, Sprache, Stil und Quelle funktionieren auch auf den Staging-Dateien.
|
||||||
|
|
||||||
|
Ein Staging-Artikel kann ganz normal im Formular oder als Raw JSON bearbeitet und gespeichert werden. Im Staging-Modus erscheinen zusätzlich:
|
||||||
|
|
||||||
|
- **Freigeben → Produktiv** – legt eine neue JSON-Datei in `DATA_DIR` an und archiviert den geprüften Originalentwurf danach unter `STAGING_DIR/.approved`.
|
||||||
|
- **Löschen** – verschiebt den verworfenen Entwurf nach `STAGING_DIR/.trash`, statt ihn sofort unwiederbringlich zu löschen.
|
||||||
|
- **Staging-Aktionen** – Freigeben oder Löschen für eine Mehrfachauswahl.
|
||||||
|
|
||||||
|
Bei einer Freigabe wird der **aktuelle JSON-Inhalt unverändert** übernommen. Insbesondere bleibt `auto_reply` so gesetzt, wie der Reviewer ihn im Entwurf eingestellt hat. Dadurch kann ein KI-Entwurf zunächst mit `auto_reply: false` geprüft und erst bewusst auf `true` gesetzt werden.
|
||||||
|
|
||||||
|
Die Freigabe überschreibt niemals eine vorhandene Produktivdatei. Existiert bereits dieselbe `id` oder derselbe abgeleitete Dateiname, bricht die Operation mit einem Konflikt ab und der Staging-Entwurf bleibt erhalten.
|
||||||
|
|
||||||
|
Im Google-/Viewer-Modus bleiben alle Staging-Schreib-, Lösch- und Freigabe-Endpunkte serverseitig gesperrt.
|
||||||
|
|
||||||
### 10-Minuten-Timeout
|
### 10-Minuten-Timeout
|
||||||
|
|
||||||
`OLLAMA_TIMEOUT=10m` ist der Standard. Der Timeout wird im Request-Kontext und im Go-HTTP-Client durchgesetzt. Zusätzlich passt der Server seinen HTTP-`WriteTimeout` an, damit eine erlaubte 10-Minuten-Generierung nicht bereits nach dem normalen 60-Sekunden-Timeout abgebrochen wird.
|
`OLLAMA_TIMEOUT=10m` ist der Standard. Der Timeout wird im Request-Kontext und im Go-HTTP-Client durchgesetzt. Zusätzlich passt der Server seinen HTTP-`WriteTimeout` an, damit eine erlaubte 10-Minuten-Generierung nicht bereits nach dem normalen 60-Sekunden-Timeout abgebrochen wird.
|
||||||
@@ -236,6 +271,7 @@ docker run -d \
|
|||||||
-e APP_TITLE="KB Administration" \
|
-e APP_TITLE="KB Administration" \
|
||||||
-v /srv/kb/knowledge:/data/knowledge:rw \
|
-v /srv/kb/knowledge:/data/knowledge:rw \
|
||||||
-v /srv/kb/backups:/data/backups:rw \
|
-v /srv/kb/backups:/data/backups:rw \
|
||||||
|
-v /srv/kb/staging:/data/staging:rw \
|
||||||
kb-helpdesk:local
|
kb-helpdesk:local
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -249,6 +285,7 @@ docker run -d \
|
|||||||
-e APP_TITLE="IT Helpdesk Wissen" \
|
-e APP_TITLE="IT Helpdesk Wissen" \
|
||||||
-e APP_SUBTITLE="Interne Lösungsdatenbank" \
|
-e APP_SUBTITLE="Interne Lösungsdatenbank" \
|
||||||
-v /srv/kb/knowledge:/data/knowledge:ro \
|
-v /srv/kb/knowledge:/data/knowledge:ro \
|
||||||
|
-v /srv/kb/staging:/data/staging:rw \
|
||||||
kb-helpdesk:local
|
kb-helpdesk:local
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -363,18 +400,27 @@ Lesend in beiden Modi:
|
|||||||
- `GET /api/facets?limit=10`
|
- `GET /api/facets?limit=10`
|
||||||
- `GET /api/items/{key}`
|
- `GET /api/items/{key}`
|
||||||
|
|
||||||
Optional bei aktiviertem Ollama-Fallback:
|
Optional bei aktiviertem Ollama-Fallback im Google-Modus:
|
||||||
|
|
||||||
- `POST /api/ai/fallback` mit `{"query":"..."}` – nur zulässig, wenn die normale KB 0 Treffer liefert
|
- `POST /api/ai/fallback` mit `{"query":"..."}` – nur zulässig, wenn die normale KB 0 Treffer liefert
|
||||||
- `GET /api/staging/{key}` – gespeicherten Staging-Entwurf laden
|
- `GET /api/staging/{key}` – den gerade erzeugten Staging-Entwurf im Viewer laden
|
||||||
|
|
||||||
Nur im Editor-Modus:
|
Staging-Review im Editor-Modus:
|
||||||
|
|
||||||
|
- `GET /api/staging?...` – Staging-Dateien suchen und filtern
|
||||||
|
- `GET /api/staging/{key}` – Staging-Entwurf laden
|
||||||
|
- `PUT /api/staging/{key}` – Staging-Entwurf bearbeiten
|
||||||
|
- `DELETE /api/staging/{key}` – sicher nach `staging/.trash` verschieben
|
||||||
|
- `POST /api/staging/{key}/promote` – Entwurf nach Produktiv freigeben und Original unter `.approved` archivieren
|
||||||
|
- `POST /api/staging/bulk` – mehrere Entwürfe mit `action=promote|delete` bearbeiten
|
||||||
|
|
||||||
|
Weitere Schreibendpunkte nur im Editor-Modus:
|
||||||
|
|
||||||
- `PUT /api/items/{key}`
|
- `PUT /api/items/{key}`
|
||||||
- `POST /api/bulk`
|
- `POST /api/bulk`
|
||||||
- `POST /api/reload`
|
- `POST /api/reload`
|
||||||
|
|
||||||
Im Google-Modus antworten diese drei Endpunkte mit HTTP `403 Forbidden`.
|
Im Google-Modus sind Staging-Liste und sämtliche Staging-Schreib-/Freigabeaktionen sowie die produktiven Schreibendpunkte serverseitig gesperrt.
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
@@ -447,7 +493,7 @@ go build -o kb-helpdesk ./cmd/server
|
|||||||
│ ├── app.js
|
│ ├── app.js
|
||||||
│ └── style.css
|
│ └── style.css
|
||||||
├── internal/aifallback/ # Ollama-Client + Structured Output
|
├── internal/aifallback/ # Ollama-Client + Structured Output
|
||||||
├── internal/staging/ # atomisches Speichern/Laden ungeprüfter Entwürfe
|
├── internal/staging/ # Staging-Suche, Bearbeitung, Soft-Delete und AI-Entwürfe
|
||||||
├── internal/store/
|
├── internal/store/
|
||||||
│ ├── store.go
|
│ ├── store.go
|
||||||
│ └── store_test.go
|
│ └── store_test.go
|
||||||
|
|||||||
+195
-8
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"kb-editor/internal/aifallback"
|
"kb-editor/internal/aifallback"
|
||||||
|
"kb-editor/internal/staging"
|
||||||
"kb-editor/internal/store"
|
"kb-editor/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,13 +25,15 @@ type appConfig struct {
|
|||||||
AIFallbackEnabled bool `json:"ai_fallback_enabled"`
|
AIFallbackEnabled bool `json:"ai_fallback_enabled"`
|
||||||
AIFallbackTimeoutSeconds int `json:"ai_fallback_timeout_seconds,omitempty"`
|
AIFallbackTimeoutSeconds int `json:"ai_fallback_timeout_seconds,omitempty"`
|
||||||
AIFallbackModel string `json:"ai_fallback_model,omitempty"`
|
AIFallbackModel string `json:"ai_fallback_model,omitempty"`
|
||||||
|
StagingEnabled bool `json:"staging_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
web fs.FS
|
web fs.FS
|
||||||
config appConfig
|
config appConfig
|
||||||
ai *aifallback.Service
|
ai *aifallback.Service
|
||||||
|
staging *staging.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApp(s *store.Store, web fs.FS, configs ...appConfig) *app {
|
func newApp(s *store.Store, web fs.FS, configs ...appConfig) *app {
|
||||||
@@ -45,6 +49,12 @@ func (a *app) withAI(service *aifallback.Service) *app {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *app) withStaging(st *staging.Store) *app {
|
||||||
|
a.staging = st
|
||||||
|
a.config.StagingEnabled = st != nil
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) routes() http.Handler {
|
func (a *app) routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /api/health", a.handleHealth)
|
mux.HandleFunc("GET /api/health", a.handleHealth)
|
||||||
@@ -54,16 +64,25 @@ func (a *app) routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/facets", a.handleFacets)
|
mux.HandleFunc("GET /api/facets", a.handleFacets)
|
||||||
mux.HandleFunc("GET /api/items/{key}", a.handleGet)
|
mux.HandleFunc("GET /api/items/{key}", a.handleGet)
|
||||||
mux.HandleFunc("POST /api/ai/fallback", a.handleAIFallback)
|
mux.HandleFunc("POST /api/ai/fallback", a.handleAIFallback)
|
||||||
|
mux.HandleFunc("GET /api/staging", a.handleStagingList)
|
||||||
mux.HandleFunc("GET /api/staging/{key}", a.handleStagingGet)
|
mux.HandleFunc("GET /api/staging/{key}", a.handleStagingGet)
|
||||||
|
|
||||||
if a.config.Writable {
|
if a.config.Writable {
|
||||||
mux.HandleFunc("PUT /api/items/{key}", a.handlePut)
|
mux.HandleFunc("PUT /api/items/{key}", a.handlePut)
|
||||||
mux.HandleFunc("POST /api/bulk", a.handleBulk)
|
mux.HandleFunc("POST /api/bulk", a.handleBulk)
|
||||||
mux.HandleFunc("POST /api/reload", a.handleReload)
|
mux.HandleFunc("POST /api/reload", a.handleReload)
|
||||||
|
mux.HandleFunc("PUT /api/staging/{key}", a.handleStagingPut)
|
||||||
|
mux.HandleFunc("DELETE /api/staging/{key}", a.handleStagingDelete)
|
||||||
|
mux.HandleFunc("POST /api/staging/{key}/promote", a.handleStagingPromote)
|
||||||
|
mux.HandleFunc("POST /api/staging/bulk", a.handleStagingBulk)
|
||||||
} else {
|
} else {
|
||||||
mux.HandleFunc("PUT /api/items/{key}", a.handleReadOnly)
|
mux.HandleFunc("PUT /api/items/{key}", a.handleReadOnly)
|
||||||
mux.HandleFunc("POST /api/bulk", a.handleReadOnly)
|
mux.HandleFunc("POST /api/bulk", a.handleReadOnly)
|
||||||
mux.HandleFunc("POST /api/reload", a.handleReadOnly)
|
mux.HandleFunc("POST /api/reload", a.handleReadOnly)
|
||||||
|
mux.HandleFunc("PUT /api/staging/{key}", a.handleReadOnly)
|
||||||
|
mux.HandleFunc("DELETE /api/staging/{key}", a.handleReadOnly)
|
||||||
|
mux.HandleFunc("POST /api/staging/{key}/promote", a.handleReadOnly)
|
||||||
|
mux.HandleFunc("POST /api/staging/bulk", a.handleReadOnly)
|
||||||
}
|
}
|
||||||
|
|
||||||
static := http.FileServer(http.FS(a.web))
|
static := http.FileServer(http.FS(a.web))
|
||||||
@@ -90,6 +109,11 @@ func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
"mode": a.config.Mode,
|
"mode": a.config.Mode,
|
||||||
"writable": a.config.Writable,
|
"writable": a.config.Writable,
|
||||||
"ai_fallback_enabled": a.config.AIFallbackEnabled && a.ai != nil,
|
"ai_fallback_enabled": a.config.AIFallbackEnabled && a.ai != nil,
|
||||||
|
"staging_enabled": a.staging != nil,
|
||||||
|
}
|
||||||
|
if a.staging != nil {
|
||||||
|
payload["staging_count"] = a.staging.Count()
|
||||||
|
payload["staging_dir"] = a.staging.Dir()
|
||||||
}
|
}
|
||||||
if a.config.Writable {
|
if a.config.Writable {
|
||||||
payload["backup_dir"] = a.store.BackupDir()
|
payload["backup_dir"] = a.store.BackupDir()
|
||||||
@@ -187,12 +211,40 @@ func (a *app) handleAIFallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, result)
|
writeJSON(w, http.StatusCreated, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) handleStagingGet(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleStagingList(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.config.AIFallbackEnabled || a.ai == nil {
|
if !a.config.Writable {
|
||||||
writeError(w, http.StatusNotFound, "Staging-Viewer ist auf dieser Instanz deaktiviert")
|
writeError(w, http.StatusForbidden, "Die Staging-Liste ist nur im Editor-Modus verfügbar")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := a.ai.GetStaging(r.PathValue("key"))
|
if a.staging == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := r.URL.Query()
|
||||||
|
page, _ := strconv.Atoi(v.Get("page"))
|
||||||
|
pageSize, _ := strconv.Atoi(v.Get("page_size"))
|
||||||
|
result, err := a.staging.List(staging.Query{
|
||||||
|
Q: v.Get("q"),
|
||||||
|
AutoReply: v.Get("auto_reply"),
|
||||||
|
Language: v.Get("language"),
|
||||||
|
CommunicationStyle: v.Get("communication_style"),
|
||||||
|
Source: v.Get("source"),
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleStagingGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.staging == nil || (!a.config.Writable && (!a.config.AIFallbackEnabled || a.ai == nil)) {
|
||||||
|
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := a.staging.Get(r.PathValue("key"))
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden")
|
writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden")
|
||||||
return
|
return
|
||||||
@@ -204,6 +256,141 @@ func (a *app) handleStagingGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, result)
|
writeJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stagingBulkRequest struct {
|
||||||
|
Keys []string `json:"keys"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleStagingPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.staging == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !mustJSONContentType(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var doc map[string]any
|
||||||
|
if err := decodeJSON(r, &doc); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := a.staging.Update(r.PathValue("key"), doc)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": result.Document, "meta": result.Meta})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleStagingDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.staging == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trash, err := a.staging.Delete(r.PathValue("key"))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "trash": trash})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleStagingPromote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.staging == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := a.promoteStaging(r.PathValue("key"))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleStagingBulk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.staging == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !mustJSONContentType(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req stagingBulkRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Keys) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "Keine Staging-Dateien ausgewählt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Keys) > 500 {
|
||||||
|
writeError(w, http.StatusBadRequest, "Maximal 500 Staging-Dateien pro Vorgang")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := strings.ToLower(strings.TrimSpace(req.Action))
|
||||||
|
if action != "promote" && action != "delete" {
|
||||||
|
writeError(w, http.StatusBadRequest, "action muss promote oder delete sein")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type itemResult struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
items := make([]itemResult, 0, len(req.Keys))
|
||||||
|
succeeded := 0
|
||||||
|
for _, key := range req.Keys {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
var err error
|
||||||
|
if action == "promote" {
|
||||||
|
_, err = a.promoteStaging(key)
|
||||||
|
} else {
|
||||||
|
_, err = a.staging.Delete(key)
|
||||||
|
}
|
||||||
|
item := itemResult{Key: key, OK: err == nil}
|
||||||
|
if err != nil {
|
||||||
|
item.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
succeeded++
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"action": action, "targeted": len(req.Keys), "succeeded": succeeded,
|
||||||
|
"failed": len(req.Keys) - succeeded, "items": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) promoteStaging(key string) (map[string]any, error) {
|
||||||
|
staged, err := a.staging.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
summary, err := a.store.ImportDocument(staged.Document, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
archive, err := a.staging.ArchiveApproved(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Produktivdatei wurde erstellt (%s), aber Staging konnte nicht als freigegeben archiviert werden: %w", summary.RelPath, err)
|
||||||
|
}
|
||||||
|
return map[string]any{"ok": true, "production": summary, "staging_key": key, "staging_archive": archive}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) handleReadOnly(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleReadOnly(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusForbidden, "Diese Instanz läuft im Google-/Viewer-Modus und ist schreibgeschützt")
|
writeError(w, http.StatusForbidden, "Diese Instanz läuft im Google-/Viewer-Modus und ist schreibgeschützt")
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-1
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -165,7 +166,7 @@ func TestAIFallbackOnlyRunsForZeroResultsAndReturnsStagingArticle(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false, AIFallbackEnabled: true}).withAI(ai).routes()
|
h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false, AIFallbackEnabled: true}).withStaging(st).withAI(ai).routes()
|
||||||
|
|
||||||
// Existing results must block the AI path before Ollama is called.
|
// Existing results must block the AI path before Ollama is called.
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/ai/fallback", bytes.NewBufferString(`{"query":"Bekannter Fehler"}`))
|
req := httptest.NewRequest(http.MethodPost, "/api/ai/fallback", bytes.NewBufferString(`{"query":"Bekannter Fehler"}`))
|
||||||
@@ -202,3 +203,133 @@ func TestAIFallbackOnlyRunsForZeroResultsAndReturnsStagingArticle(t *testing.T)
|
|||||||
t.Fatalf("staging get status=%d body=%s", getRR.Code, getRR.Body.String())
|
t.Fatalf("staging get status=%d body=%s", getRR.Code, getRR.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEditorCanReviewPromoteAndDeleteStaging(t *testing.T) {
|
||||||
|
knowledge := t.TempDir()
|
||||||
|
stagingDir := t.TempDir()
|
||||||
|
t.Setenv("BACKUP_DIR", filepath.Join(t.TempDir(), "backups"))
|
||||||
|
s, err := store.New(knowledge)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := staging.New(stagingDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := st.Save("unbekannt 0xAABBCCDD", "test-model", staging.Draft{
|
||||||
|
Title: "Zu prüfender Entwurf", Text: "Symptom", Answer: "Lösung", Keywords: []string{"0xAABBCCDD"},
|
||||||
|
}, false, 0.78)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := st.Save("anderer Entwurf", "test-model", staging.Draft{
|
||||||
|
Title: "Zu löschender Entwurf", Text: "Symptom", Answer: "Lösung",
|
||||||
|
}, false, 0.78)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
web, err := fs.Sub(webFS, "web")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := newApp(s, web, appConfig{Mode: "editor", Title: "Editor", Writable: true}).withStaging(st).routes()
|
||||||
|
|
||||||
|
listReq := httptest.NewRequest(http.MethodGet, "/api/staging?q=AABBCCDD&page=1&page_size=20", nil)
|
||||||
|
listRR := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(listRR, listReq)
|
||||||
|
if listRR.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list status=%d body=%s", listRR.Code, listRR.Body.String())
|
||||||
|
}
|
||||||
|
var list staging.ListResult
|
||||||
|
if err := json.Unmarshal(listRR.Body.Bytes(), &list); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if list.Total != 1 || list.Items[0].Key != first.Key {
|
||||||
|
t.Fatalf("unexpected staging list: %+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := first.Document
|
||||||
|
updated["title"] = "Geprüfter Entwurf"
|
||||||
|
updated["auto_reply"] = true
|
||||||
|
body, _ := json.Marshal(updated)
|
||||||
|
putReq := httptest.NewRequest(http.MethodPut, "/api/staging/"+first.Key, bytes.NewReader(body))
|
||||||
|
putReq.Header.Set("Content-Type", "application/json")
|
||||||
|
putRR := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(putRR, putReq)
|
||||||
|
if putRR.Code != http.StatusOK {
|
||||||
|
t.Fatalf("put status=%d body=%s", putRR.Code, putRR.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
promoteReq := httptest.NewRequest(http.MethodPost, "/api/staging/"+first.Key+"/promote", bytes.NewBufferString(`{}`))
|
||||||
|
promoteReq.Header.Set("Content-Type", "application/json")
|
||||||
|
promoteRR := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(promoteRR, promoteReq)
|
||||||
|
if promoteRR.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("promote status=%d body=%s", promoteRR.Code, promoteRR.Body.String())
|
||||||
|
}
|
||||||
|
if s.Count() != 1 || st.Count() != 1 {
|
||||||
|
t.Fatalf("counts after promote: production=%d staging=%d", s.Count(), st.Count())
|
||||||
|
}
|
||||||
|
prod := s.List(store.Query{Page: 1, PageSize: 10})
|
||||||
|
if prod.Items[0].Title != "Geprüfter Entwurf" || prod.Items[0].AutoReply == nil || !*prod.Items[0].AutoReply {
|
||||||
|
t.Fatalf("promoted item not preserved: %+v", prod.Items[0])
|
||||||
|
}
|
||||||
|
if _, err := st.Get(first.Key); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("promoted staging file should be gone, err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/staging/"+second.Key, nil)
|
||||||
|
deleteRR := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(deleteRR, deleteReq)
|
||||||
|
if deleteRR.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete status=%d body=%s", deleteRR.Code, deleteRR.Body.String())
|
||||||
|
}
|
||||||
|
if st.Count() != 0 {
|
||||||
|
t.Fatalf("staging should be empty, count=%d", st.Count())
|
||||||
|
}
|
||||||
|
approved, err := filepath.Glob(filepath.Join(stagingDir, ".approved", "*.json"))
|
||||||
|
if err != nil || len(approved) != 1 {
|
||||||
|
t.Fatalf("expected one promoted draft in .approved, files=%v err=%v", approved, err)
|
||||||
|
}
|
||||||
|
trash, err := filepath.Glob(filepath.Join(stagingDir, ".trash", "*.json"))
|
||||||
|
if err != nil || len(trash) != 1 {
|
||||||
|
t.Fatalf("expected one deleted draft in .trash, files=%v err=%v", trash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditorBulkStagingPromote(t *testing.T) {
|
||||||
|
knowledge := t.TempDir()
|
||||||
|
s, err := store.New(knowledge)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := staging.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, err := st.Save("a", "model", staging.Draft{Title: "A", Answer: "Lösung A"}, false, .78)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := st.Save("b", "model", staging.Draft{Title: "B", Answer: "Lösung B"}, false, .78)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
web, _ := fs.Sub(webFS, "web")
|
||||||
|
h := newApp(s, web, appConfig{Mode: "editor", Writable: true}).withStaging(st).routes()
|
||||||
|
payload, _ := json.Marshal(map[string]any{"keys": []string{a.Key, b.Key}, "action": "promote"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/staging/bulk", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var result struct{ Succeeded, Failed int }
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Succeeded != 2 || result.Failed != 0 || s.Count() != 2 || st.Count() != 0 {
|
||||||
|
t.Fatalf("unexpected bulk result=%+v prod=%d staging=%d", result, s.Count(), st.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+29
-23
@@ -39,7 +39,11 @@ func main() {
|
|||||||
log.Fatalf("initialize store: %v", err)
|
log.Fatalf("initialize store: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
aiService, aiTimeout, err := aiServiceFromEnv(cfg.Mode, s.DataDir())
|
stagingStore, err := stagingStoreFromEnv(s.DataDir())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
aiService, aiTimeout, err := aiServiceFromEnv(cfg.Mode, stagingStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -62,7 +66,7 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app := newApp(s, sub, cfg).withAI(aiService)
|
app := newApp(s, sub, cfg).withStaging(stagingStore).withAI(aiService)
|
||||||
handler := requestLogger(optionalBasicAuth(app.routes()))
|
handler := requestLogger(optionalBasicAuth(app.routes()))
|
||||||
|
|
||||||
writeTimeout := 60 * time.Second
|
writeTimeout := 60 * time.Second
|
||||||
@@ -81,6 +85,7 @@ func main() {
|
|||||||
log.Printf("KB service listening on %s", listen)
|
log.Printf("KB service listening on %s", listen)
|
||||||
log.Printf("Mode: %s (writable=%t)", cfg.Mode, cfg.Writable)
|
log.Printf("Mode: %s (writable=%t)", cfg.Mode, cfg.Writable)
|
||||||
log.Printf("Data directory: %s (%d JSON files indexed)", s.DataDir(), s.Count())
|
log.Printf("Data directory: %s (%d JSON files indexed)", s.DataDir(), s.Count())
|
||||||
|
log.Printf("Staging directory: %s (%d JSON files)", stagingStore.Dir(), stagingStore.Count())
|
||||||
if reloadInterval > 0 {
|
if reloadInterval > 0 {
|
||||||
log.Printf("Automatic index reload: %s", reloadInterval)
|
log.Printf("Automatic index reload: %s", reloadInterval)
|
||||||
}
|
}
|
||||||
@@ -148,7 +153,26 @@ func startAutoReload(s *store.Store, interval time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func aiServiceFromEnv(mode, dataDir string) (*aifallback.Service, time.Duration, error) {
|
func stagingStoreFromEnv(dataDir string) (*staging.Store, error) {
|
||||||
|
stagingDir := strings.TrimSpace(os.Getenv("STAGING_DIR"))
|
||||||
|
if stagingDir == "" {
|
||||||
|
stagingDir = filepath.Join(filepath.Dir(dataDir), "staging")
|
||||||
|
}
|
||||||
|
stagingAbs, err := filepath.Abs(stagingDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dataAbs, err := filepath.Abs(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pathContains(dataAbs, stagingAbs) || pathContains(stagingAbs, dataAbs) {
|
||||||
|
return nil, fmt.Errorf("STAGING_DIR (%s) must be separate from DATA_DIR (%s)", stagingAbs, dataAbs)
|
||||||
|
}
|
||||||
|
return staging.New(stagingAbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiServiceFromEnv(mode string, st *staging.Store) (*aifallback.Service, time.Duration, error) {
|
||||||
enabled, err := envBool("AI_FALLBACK_ENABLED", false)
|
enabled, err := envBool("AI_FALLBACK_ENABLED", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -176,26 +200,8 @@ func aiServiceFromEnv(mode, dataDir string) (*aifallback.Service, time.Duration,
|
|||||||
if err != nil || minScore < 0 || minScore > 1 {
|
if err != nil || minScore < 0 || minScore > 1 {
|
||||||
return nil, 0, fmt.Errorf("OLLAMA_STAGING_MIN_SCORE must be between 0 and 1")
|
return nil, 0, fmt.Errorf("OLLAMA_STAGING_MIN_SCORE must be between 0 and 1")
|
||||||
}
|
}
|
||||||
|
if st == nil {
|
||||||
stagingDir := strings.TrimSpace(os.Getenv("STAGING_DIR"))
|
return nil, 0, fmt.Errorf("staging store is required for AI fallback")
|
||||||
if stagingDir == "" {
|
|
||||||
stagingDir = filepath.Join(filepath.Dir(dataDir), "staging")
|
|
||||||
}
|
|
||||||
stagingAbs, err := filepath.Abs(stagingDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
dataAbs, err := filepath.Abs(dataDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
if pathContains(dataAbs, stagingAbs) || pathContains(stagingAbs, dataAbs) {
|
|
||||||
return nil, 0, fmt.Errorf("STAGING_DIR (%s) must be separate from DATA_DIR (%s)", stagingAbs, dataAbs)
|
|
||||||
}
|
|
||||||
|
|
||||||
st, err := staging.New(stagingAbs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
}
|
||||||
svc, err := aifallback.New(aifallback.Config{
|
svc, err := aifallback.New(aifallback.Config{
|
||||||
BaseURL: envOr("OLLAMA_BASE_URL", "http://ollama:11434"),
|
BaseURL: envOr("OLLAMA_BASE_URL", "http://ollama:11434"),
|
||||||
|
|||||||
+137
-13
@@ -5,6 +5,7 @@
|
|||||||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
|
scope: 'production',
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 60,
|
pageSize: 60,
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -21,15 +22,19 @@
|
|||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), healthPill: $('#healthPill'), reloadBtn: $('#reloadBtn'), bulkBtn: $('#bulkBtn'),
|
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), healthPill: $('#healthPill'), reloadBtn: $('#reloadBtn'), bulkBtn: $('#bulkBtn'),
|
||||||
|
scopeProduction: $('#scopeProduction'), scopeStaging: $('#scopeStaging'), stagingCountBadge: $('#stagingCountBadge'),
|
||||||
searchInput: $('#searchInput'), autoReplyFilter: $('#autoReplyFilter'), languageFilter: $('#languageFilter'),
|
searchInput: $('#searchInput'), autoReplyFilter: $('#autoReplyFilter'), languageFilter: $('#languageFilter'),
|
||||||
sourceFilter: $('#sourceFilter'), styleFilter: $('#styleFilter'), selectPage: $('#selectPage'),
|
sourceFilter: $('#sourceFilter'), styleFilter: $('#styleFilter'), selectPage: $('#selectPage'),
|
||||||
selectionCount: $('#selectionCount'), resultList: $('#resultList'), prevPage: $('#prevPage'), nextPage: $('#nextPage'),
|
selectionCount: $('#selectionCount'), resultList: $('#resultList'), prevPage: $('#prevPage'), nextPage: $('#nextPage'),
|
||||||
pageLabel: $('#pageLabel'), totalLabel: $('#totalLabel'), emptyState: $('#emptyState'), editor: $('#editor'),
|
pageLabel: $('#pageLabel'), totalLabel: $('#totalLabel'), emptyState: $('#emptyState'), editor: $('#editor'),
|
||||||
filePath: $('#filePath'), dirtyBadge: $('#dirtyBadge'), saveBtn: $('#saveBtn'), formatJsonBtn: $('#formatJsonBtn'),
|
filePath: $('#filePath'), dirtyBadge: $('#dirtyBadge'), stagingBadge: $('#stagingBadge'), saveBtn: $('#saveBtn'), formatJsonBtn: $('#formatJsonBtn'),
|
||||||
|
deleteStagingBtn: $('#deleteStagingBtn'), promoteStagingBtn: $('#promoteStagingBtn'),
|
||||||
formTab: $('#formTab'), rawTab: $('#rawTab'), rawEditor: $('#rawEditor'), rawError: $('#rawError'),
|
formTab: $('#formTab'), rawTab: $('#rawTab'), rawEditor: $('#rawEditor'), rawError: $('#rawError'),
|
||||||
bulkDialog: $('#bulkDialog'), bulkTargetText: $('#bulkTargetText'), bulkAllMatching: $('#bulkAllMatching'),
|
bulkDialog: $('#bulkDialog'), bulkTargetText: $('#bulkTargetText'), bulkAllMatching: $('#bulkAllMatching'),
|
||||||
allMatchingHint: $('#allMatchingHint'), bulkPreview: $('#bulkPreview'), previewBulkBtn: $('#previewBulkBtn'),
|
allMatchingHint: $('#allMatchingHint'), bulkPreview: $('#bulkPreview'), previewBulkBtn: $('#previewBulkBtn'),
|
||||||
applyBulkBtn: $('#applyBulkBtn'), toastHost: $('#toastHost')
|
applyBulkBtn: $('#applyBulkBtn'), stagingBulkDialog: $('#stagingBulkDialog'), stagingBulkTargetText: $('#stagingBulkTargetText'),
|
||||||
|
stagingBulkResult: $('#stagingBulkResult'), bulkDeleteStagingBtn: $('#bulkDeleteStagingBtn'), bulkPromoteStagingBtn: $('#bulkPromoteStagingBtn'),
|
||||||
|
toastHost: $('#toastHost')
|
||||||
};
|
};
|
||||||
|
|
||||||
let searchTimer;
|
let searchTimer;
|
||||||
@@ -73,9 +78,11 @@
|
|||||||
document.title = config.title;
|
document.title = config.title;
|
||||||
}
|
}
|
||||||
if (config.subtitle) els.brandSubtitle.textContent = config.subtitle;
|
if (config.subtitle) els.brandSubtitle.textContent = config.subtitle;
|
||||||
els.healthPill.textContent = `${h.count.toLocaleString('de-DE')} Dateien`;
|
const stagingCount = Number(h.staging_count || 0);
|
||||||
|
els.stagingCountBadge.textContent = stagingCount.toLocaleString('de-DE');
|
||||||
|
els.healthPill.textContent = `${h.count.toLocaleString('de-DE')} produktiv · ${stagingCount.toLocaleString('de-DE')} Staging`;
|
||||||
els.healthPill.className = 'pill ok';
|
els.healthPill.className = 'pill ok';
|
||||||
els.healthPill.title = `Daten: ${h.data_dir}\nBackups: ${h.backup_dir}`;
|
els.healthPill.title = `Daten: ${h.data_dir}\nStaging: ${h.staging_dir || '–'}\nBackups: ${h.backup_dir || '–'}`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
els.healthPill.textContent = 'Offline';
|
els.healthPill.textContent = 'Offline';
|
||||||
els.healthPill.className = 'pill';
|
els.healthPill.className = 'pill';
|
||||||
@@ -87,7 +94,8 @@
|
|||||||
if (resetPage) state.page = 1;
|
if (resetPage) state.page = 1;
|
||||||
els.resultList.innerHTML = '<div class="muted-text" style="padding:16px">Lade …</div>';
|
els.resultList.innerHTML = '<div class="muted-text" style="padding:16px">Lade …</div>';
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/items?${queryString(currentQuery())}`);
|
const endpoint = state.scope === 'staging' ? '/api/staging' : '/api/items';
|
||||||
|
const data = await api(`${endpoint}?${queryString(currentQuery())}`);
|
||||||
state.page = data.page || 1;
|
state.page = data.page || 1;
|
||||||
state.total = data.total;
|
state.total = data.total;
|
||||||
state.totalPages = data.total_pages;
|
state.totalPages = data.total_pages;
|
||||||
@@ -105,14 +113,14 @@
|
|||||||
}
|
}
|
||||||
for (const item of state.items) {
|
for (const item of state.items) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = `result-item${item.key === state.currentKey ? ' active' : ''}`;
|
row.className = `result-item${item.key === state.currentKey ? ' active' : ''}${state.scope === 'staging' ? ' staging-item' : ''}`;
|
||||||
row.dataset.key = item.key;
|
row.dataset.key = item.key;
|
||||||
const checked = state.selected.has(item.key) ? 'checked' : '';
|
const checked = state.selected.has(item.key) ? 'checked' : '';
|
||||||
const auto = item.auto_reply === true;
|
const auto = item.auto_reply === true;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input class="result-check" type="checkbox" ${checked} aria-label="Auswählen">
|
<input class="result-check" type="checkbox" ${checked} aria-label="Auswählen">
|
||||||
<div>
|
<div>
|
||||||
<div class="result-id">${escapeHTML(item.id || item.rel_path)}</div>
|
<div class="result-id">${state.scope === 'staging' ? '<span class="mini-staging">STAGING</span> ' : ''}${escapeHTML(item.id || item.rel_path)}</div>
|
||||||
<div class="result-title">${escapeHTML(item.title || '(ohne Titel)')}</div>
|
<div class="result-title">${escapeHTML(item.title || '(ohne Titel)')}</div>
|
||||||
<div class="result-meta">
|
<div class="result-meta">
|
||||||
<span><i class="bool-dot ${auto ? 'true' : ''}"></i>auto ${String(item.auto_reply ?? '–')}</span>
|
<span><i class="bool-dot ${auto ? 'true' : ''}"></i>auto ${String(item.auto_reply ?? '–')}</span>
|
||||||
@@ -145,7 +153,8 @@
|
|||||||
|
|
||||||
function renderSelectionOnly() {
|
function renderSelectionOnly() {
|
||||||
els.selectionCount.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählt`;
|
els.selectionCount.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählt`;
|
||||||
els.bulkBtn.disabled = state.selected.size === 0 && state.total === 0;
|
els.bulkBtn.disabled = state.scope === 'staging' ? state.selected.size === 0 : (state.selected.size === 0 && state.total === 0);
|
||||||
|
els.bulkBtn.textContent = state.scope === 'staging' ? '✦ Staging-Aktionen' : '✦ Massenbearbeitung';
|
||||||
els.selectPage.checked = state.items.length > 0 && state.items.every(i => state.selected.has(i.key));
|
els.selectPage.checked = state.items.length > 0 && state.items.every(i => state.selected.has(i.key));
|
||||||
els.selectPage.indeterminate = !els.selectPage.checked && state.items.some(i => state.selected.has(i.key));
|
els.selectPage.indeterminate = !els.selectPage.checked && state.items.some(i => state.selected.has(i.key));
|
||||||
}
|
}
|
||||||
@@ -156,7 +165,8 @@
|
|||||||
if (key === state.currentKey) return;
|
if (key === state.currentKey) return;
|
||||||
if (state.dirty && !confirm('Es gibt ungespeicherte Änderungen. Wirklich einen anderen Eintrag öffnen?')) return;
|
if (state.dirty && !confirm('Es gibt ungespeicherte Änderungen. Wirklich einen anderen Eintrag öffnen?')) return;
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/items/${encodeURIComponent(key)}`);
|
const endpoint = state.scope === 'staging' ? '/api/staging' : '/api/items';
|
||||||
|
const data = await api(`${endpoint}/${encodeURIComponent(key)}`);
|
||||||
state.currentKey = key;
|
state.currentKey = key;
|
||||||
state.currentDoc = data.document;
|
state.currentDoc = data.document;
|
||||||
state.currentMeta = data.meta;
|
state.currentMeta = data.meta;
|
||||||
@@ -174,6 +184,10 @@
|
|||||||
els.emptyState.classList.add('hidden');
|
els.emptyState.classList.add('hidden');
|
||||||
els.editor.classList.remove('hidden');
|
els.editor.classList.remove('hidden');
|
||||||
els.filePath.textContent = state.currentMeta?.rel_path || '–';
|
els.filePath.textContent = state.currentMeta?.rel_path || '–';
|
||||||
|
const isStaging = state.scope === 'staging';
|
||||||
|
els.stagingBadge.classList.toggle('hidden', !isStaging);
|
||||||
|
els.promoteStagingBtn.classList.toggle('hidden', !isStaging);
|
||||||
|
els.deleteStagingBtn.classList.toggle('hidden', !isStaging);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,28 +262,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveCurrent() {
|
async function saveCurrent() {
|
||||||
if (!state.currentKey || !state.currentDoc) return;
|
if (!state.currentKey || !state.currentDoc) return false;
|
||||||
if (state.activeTab === 'raw') {
|
if (state.activeTab === 'raw') {
|
||||||
if (!syncRawToDoc()) return;
|
if (!syncRawToDoc()) return false;
|
||||||
} else syncFormToDoc();
|
} else syncFormToDoc();
|
||||||
|
|
||||||
els.saveBtn.disabled = true;
|
els.saveBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const result = await api(`/api/items/${encodeURIComponent(state.currentKey)}`, {
|
const endpoint = state.scope === 'staging' ? '/api/staging' : '/api/items';
|
||||||
|
const result = await api(`${endpoint}/${encodeURIComponent(state.currentKey)}`, {
|
||||||
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(state.currentDoc)
|
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(state.currentDoc)
|
||||||
});
|
});
|
||||||
state.currentMeta = result.meta;
|
state.currentMeta = result.meta;
|
||||||
|
if (result.document) state.currentDoc = result.document;
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
toast(`Gespeichert. Backup: ${shortPath(result.backup)}`, 'success');
|
if (state.scope === 'staging') toast('Staging-Entwurf gespeichert.', 'success');
|
||||||
|
else toast(`Gespeichert. Backup: ${shortPath(result.backup)}`, 'success');
|
||||||
await loadList(false);
|
await loadList(false);
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, 'error');
|
toast(err.message, 'error');
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
els.saveBtn.disabled = false;
|
els.saveBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBulk() {
|
function openBulk() {
|
||||||
|
if (state.scope === 'staging') {
|
||||||
|
if (state.selected.size === 0) return;
|
||||||
|
els.stagingBulkTargetText.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählte Staging-Entwürfe`;
|
||||||
|
els.stagingBulkResult.className = 'preview-box hidden';
|
||||||
|
els.stagingBulkResult.textContent = '';
|
||||||
|
els.stagingBulkDialog.showModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (state.selected.size === 0 && state.total === 0) return;
|
if (state.selected.size === 0 && state.total === 0) return;
|
||||||
resetBulkPreview();
|
resetBulkPreview();
|
||||||
els.bulkAllMatching.checked = state.selected.size === 0;
|
els.bulkAllMatching.checked = state.selected.size === 0;
|
||||||
@@ -383,6 +410,96 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function promoteCurrentStaging() {
|
||||||
|
if (state.scope !== 'staging' || !state.currentKey) return;
|
||||||
|
if (state.dirty) {
|
||||||
|
if (!confirm('Der Entwurf enthält ungespeicherte Änderungen. Vor der Freigabe speichern?')) return;
|
||||||
|
if (!await saveCurrent()) return;
|
||||||
|
}
|
||||||
|
if (!confirm('Diesen Staging-Entwurf jetzt unverändert in die produktive Wissensbasis freigeben?')) return;
|
||||||
|
els.promoteStagingBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await api(`/api/staging/${encodeURIComponent(state.currentKey)}/promote`, {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}'
|
||||||
|
});
|
||||||
|
const productionKey = result.production?.key;
|
||||||
|
toast(`Freigegeben: ${result.production?.rel_path || result.production?.id || 'Produktivartikel'}`, 'success');
|
||||||
|
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false; state.selected.clear();
|
||||||
|
await loadHealth();
|
||||||
|
await setScope('production');
|
||||||
|
if (productionKey) await openItem(productionKey);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
els.promoteStagingBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentStaging() {
|
||||||
|
if (state.scope !== 'staging' || !state.currentKey) return;
|
||||||
|
if (!confirm('Diesen Staging-Entwurf löschen? Er wird zur Sicherheit nach staging/.trash verschoben.')) return;
|
||||||
|
els.deleteStagingBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await api(`/api/staging/${encodeURIComponent(state.currentKey)}`, {method: 'DELETE'});
|
||||||
|
toast('Staging-Entwurf gelöscht und in .trash archiviert.', 'success');
|
||||||
|
state.selected.delete(state.currentKey);
|
||||||
|
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false;
|
||||||
|
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
|
||||||
|
await Promise.all([loadList(true), loadHealth()]);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
els.deleteStagingBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stagingBulkAction(action) {
|
||||||
|
if (state.scope !== 'staging' || state.selected.size === 0) return;
|
||||||
|
const verb = action === 'promote' ? 'freigeben' : 'löschen';
|
||||||
|
if (!confirm(`${state.selected.size.toLocaleString('de-DE')} Staging-Entwürfe wirklich ${verb}?`)) return;
|
||||||
|
els.bulkPromoteStagingBtn.disabled = true;
|
||||||
|
els.bulkDeleteStagingBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await api('/api/staging/bulk', {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({keys: Array.from(state.selected), action})
|
||||||
|
});
|
||||||
|
els.stagingBulkResult.className = `preview-box ${result.failed ? 'warn' : 'ok'}`;
|
||||||
|
els.stagingBulkResult.innerHTML = `<strong>${result.succeeded.toLocaleString('de-DE')} erfolgreich</strong> · ${result.failed.toLocaleString('de-DE')} fehlgeschlagen` +
|
||||||
|
(result.failed ? `<div class="preview-samples">${result.items.filter(x => !x.ok).slice(0,20).map(x => `<code>${escapeHTML(x.key)} · ${escapeHTML(x.error)}</code>`).join('')}</div>` : '');
|
||||||
|
state.selected.clear();
|
||||||
|
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false;
|
||||||
|
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
|
||||||
|
await Promise.all([loadList(true), loadHealth()]);
|
||||||
|
if (!result.failed) setTimeout(() => els.stagingBulkDialog.close(), 650);
|
||||||
|
} catch (err) {
|
||||||
|
els.stagingBulkResult.className = 'preview-box warn';
|
||||||
|
els.stagingBulkResult.textContent = err.message;
|
||||||
|
} finally {
|
||||||
|
els.bulkPromoteStagingBtn.disabled = false;
|
||||||
|
els.bulkDeleteStagingBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setScope(scope) {
|
||||||
|
if (scope !== 'production' && scope !== 'staging') return;
|
||||||
|
if (scope === state.scope) return;
|
||||||
|
if (state.dirty && !confirm('Ungespeicherte Änderungen verwerfen und Bereich wechseln?')) return;
|
||||||
|
state.scope = scope;
|
||||||
|
state.page = 1;
|
||||||
|
state.selected.clear();
|
||||||
|
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false;
|
||||||
|
els.scopeProduction.classList.toggle('active', scope === 'production');
|
||||||
|
els.scopeStaging.classList.toggle('active', scope === 'staging');
|
||||||
|
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
|
||||||
|
els.emptyState.querySelector('h1').textContent = scope === 'staging' ? 'Staging-Entwürfe prüfen' : 'JSON-Wissensbasis bearbeiten';
|
||||||
|
els.emptyState.querySelector('p').textContent = scope === 'staging'
|
||||||
|
? 'KI-generierte Entwürfe prüfen, bearbeiten und anschließend gezielt freigeben oder verwerfen.'
|
||||||
|
: 'Wähle links einen Eintrag aus oder markiere mehrere Dateien für eine Massenänderung.';
|
||||||
|
renderSelectionOnly();
|
||||||
|
await loadList(true);
|
||||||
|
}
|
||||||
|
|
||||||
function resetBulkPreview() {
|
function resetBulkPreview() {
|
||||||
state.lastBulkPreviewSignature = '';
|
state.lastBulkPreviewSignature = '';
|
||||||
els.bulkPreview.className = 'preview-box hidden';
|
els.bulkPreview.className = 'preview-box hidden';
|
||||||
@@ -408,6 +525,13 @@
|
|||||||
searchTimer = setTimeout(() => loadList(true), 240);
|
searchTimer = setTimeout(() => loadList(true), 240);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
els.scopeProduction.addEventListener('click', () => setScope('production'));
|
||||||
|
els.scopeStaging.addEventListener('click', () => setScope('staging'));
|
||||||
|
els.promoteStagingBtn.addEventListener('click', promoteCurrentStaging);
|
||||||
|
els.deleteStagingBtn.addEventListener('click', deleteCurrentStaging);
|
||||||
|
els.bulkPromoteStagingBtn.addEventListener('click', () => stagingBulkAction('promote'));
|
||||||
|
els.bulkDeleteStagingBtn.addEventListener('click', () => stagingBulkAction('delete'));
|
||||||
|
|
||||||
// Filters and navigation.
|
// Filters and navigation.
|
||||||
[els.searchInput, els.languageFilter, els.sourceFilter, els.styleFilter].forEach(el => el.addEventListener('input', debounceReload));
|
[els.searchInput, els.languageFilter, els.sourceFilter, els.styleFilter].forEach(el => el.addEventListener('input', debounceReload));
|
||||||
els.autoReplyFilter.addEventListener('change', () => loadList(true));
|
els.autoReplyFilter.addEventListener('change', () => loadList(true));
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<section class="filters">
|
<section class="filters">
|
||||||
|
<div class="scope-switch" role="group" aria-label="Datenbereich">
|
||||||
|
<button id="scopeProduction" type="button" class="scope-btn active" data-scope="production">Produktiv</button>
|
||||||
|
<button id="scopeStaging" type="button" class="scope-btn" data-scope="staging">Staging <span id="stagingCountBadge">0</span></button>
|
||||||
|
</div>
|
||||||
<label class="search-wrap">
|
<label class="search-wrap">
|
||||||
<span>⌕</span>
|
<span>⌕</span>
|
||||||
<input id="searchInput" type="search" placeholder="Code, Titel, Text, Keyword …" autocomplete="off">
|
<input id="searchInput" type="search" placeholder="Code, Titel, Text, Keyword …" autocomplete="off">
|
||||||
@@ -77,9 +81,12 @@
|
|||||||
<div class="editor-head">
|
<div class="editor-head">
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<span id="filePath">–</span>
|
<span id="filePath">–</span>
|
||||||
|
<span id="stagingBadge" class="badge staging hidden">AI-STAGING · UNGEPRÜFT</span>
|
||||||
<span id="dirtyBadge" class="badge warn hidden">Ungespeichert</span>
|
<span id="dirtyBadge" class="badge warn hidden">Ungespeichert</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
|
<button id="deleteStagingBtn" class="btn danger hidden">Löschen</button>
|
||||||
|
<button id="promoteStagingBtn" class="btn primary hidden">✓ Freigeben → Produktiv</button>
|
||||||
<button id="formatJsonBtn" class="btn ghost hidden">JSON formatieren</button>
|
<button id="formatJsonBtn" class="btn ghost hidden">JSON formatieren</button>
|
||||||
<button id="saveBtn" class="btn success">Speichern <span class="shortcut">Ctrl S</span></button>
|
<button id="saveBtn" class="btn success">Speichern <span class="shortcut">Ctrl S</span></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,6 +252,34 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|
||||||
|
<dialog id="stagingBulkDialog" class="modal staging-modal">
|
||||||
|
<form method="dialog" class="modal-card">
|
||||||
|
<header class="modal-head">
|
||||||
|
<div>
|
||||||
|
<h2>Staging-Aktionen</h2>
|
||||||
|
<p id="stagingBulkTargetText">–</p>
|
||||||
|
</div>
|
||||||
|
<button value="cancel" class="icon-btn" aria-label="Schließen">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="staging-info-card">
|
||||||
|
<strong>Review-Workflow</strong>
|
||||||
|
<p>Freigeben kopiert den aktuellen JSON-Inhalt unverändert in die produktive Wissensbasis und archiviert den geprüften Originalentwurf anschließend unter <code>staging/.approved</code>. <code>auto_reply</code> bleibt genau wie im Entwurf gesetzt.</p>
|
||||||
|
</div>
|
||||||
|
<div id="stagingBulkResult" class="preview-box hidden"></div>
|
||||||
|
</div>
|
||||||
|
<footer class="modal-foot">
|
||||||
|
<span class="muted-text">Löschen verschiebt Entwürfe sicher nach <code>staging/.trash</code>.</span>
|
||||||
|
<div>
|
||||||
|
<button value="cancel" class="btn ghost">Abbrechen</button>
|
||||||
|
<button id="bulkDeleteStagingBtn" type="button" class="btn danger">Auswahl löschen</button>
|
||||||
|
<button id="bulkPromoteStagingBtn" type="button" class="btn primary">Auswahl freigeben</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<div id="toastHost" class="toast-host" aria-live="polite"></div>
|
<div id="toastHost" class="toast-host" aria-live="polite"></div>
|
||||||
<script src="/app.js" defer></script>
|
<script src="/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -197,3 +197,62 @@ textarea { resize: vertical; line-height: 1.45; }
|
|||||||
.bulk-grid { grid-template-columns: 1fr; }
|
.bulk-grid { grid-template-columns: 1fr; }
|
||||||
.top-actions .pill { display: none; }
|
.top-actions .pill { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Review workflow: production vs. AI staging */
|
||||||
|
.scope-switch {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 11px;
|
||||||
|
background: #0b1323;
|
||||||
|
}
|
||||||
|
.scope-btn {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: .16s ease;
|
||||||
|
}
|
||||||
|
.scope-btn:hover { color: var(--text); background: rgba(255,255,255,.035); }
|
||||||
|
.scope-btn.active { color: var(--text); background: var(--panel-3); box-shadow: inset 0 0 0 1px rgba(122,162,255,.22); }
|
||||||
|
.scope-btn span {
|
||||||
|
display: inline-grid;
|
||||||
|
min-width: 20px;
|
||||||
|
place-items: center;
|
||||||
|
margin-left: 5px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #f7d58f;
|
||||||
|
background: rgba(242,190,97,.12);
|
||||||
|
border: 1px solid rgba(242,190,97,.22);
|
||||||
|
}
|
||||||
|
.result-item.staging-item { background-image: linear-gradient(90deg, rgba(242,190,97,.035), transparent 45%); }
|
||||||
|
.result-item.staging-item.active { background: rgba(242,190,97,.075); box-shadow: inset 3px 0 0 var(--warning); }
|
||||||
|
.mini-staging {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #f6d99e;
|
||||||
|
background: rgba(242,190,97,.10);
|
||||||
|
border: 1px solid rgba(242,190,97,.2);
|
||||||
|
font: 800 8px/1.4 ui-sans-serif, system-ui, sans-serif;
|
||||||
|
letter-spacing: .07em;
|
||||||
|
}
|
||||||
|
.badge.staging { color: #f6d99e; border-color: rgba(242,190,97,.28); background: rgba(242,190,97,.07); }
|
||||||
|
.staging-modal { width: min(720px, calc(100vw - 40px)); }
|
||||||
|
.staging-info-card {
|
||||||
|
border: 1px solid rgba(242,190,97,.22);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 15px;
|
||||||
|
background: rgba(242,190,97,.055);
|
||||||
|
}
|
||||||
|
.staging-info-card strong { display: block; margin-bottom: 6px; color: #f7dca8; }
|
||||||
|
.staging-info-card p { margin: 0; color: #c7cede; font-size: 12px; line-height: 1.6; }
|
||||||
|
.staging-info-card code, .modal-foot code { color: #f6d99e; }
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ services:
|
|||||||
APP_SUBTITLE: "${EDITOR_SUBTITLE:-Wissensbasis verwalten}"
|
APP_SUBTITLE: "${EDITOR_SUBTITLE:-Wissensbasis verwalten}"
|
||||||
DATA_DIR: /data/knowledge
|
DATA_DIR: /data/knowledge
|
||||||
BACKUP_DIR: /data/backups
|
BACKUP_DIR: /data/backups
|
||||||
|
STAGING_DIR: /data/staging
|
||||||
LISTEN_ADDR: :8080
|
LISTEN_ADDR: :8080
|
||||||
BASIC_AUTH_USER: "${EDITOR_AUTH_USER:-}"
|
BASIC_AUTH_USER: "${EDITOR_AUTH_USER:-}"
|
||||||
BASIC_AUTH_PASSWORD: "${EDITOR_AUTH_PASSWORD:-}"
|
BASIC_AUTH_PASSWORD: "${EDITOR_AUTH_PASSWORD:-}"
|
||||||
volumes:
|
volumes:
|
||||||
- "${KB_DATA_PATH:-./knowledge}:/data/knowledge:rw"
|
- "${KB_DATA_PATH:-./knowledge}:/data/knowledge:rw"
|
||||||
- "${KB_BACKUP_PATH:-./backups}:/data/backups:rw"
|
- "${KB_BACKUP_PATH:-./backups}:/data/backups:rw"
|
||||||
|
- "${KB_STAGING_PATH:-./staging}:/data/staging:rw"
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:size=32m
|
- /tmp:size=32m
|
||||||
|
|||||||
+369
-52
@@ -1,6 +1,7 @@
|
|||||||
package staging
|
package staging
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -9,11 +10,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var keyPattern = regexp.MustCompile(`^KB-AI-STAGING-[0-9]{8}-[0-9]{6}-[A-F0-9]{8}$`)
|
var safeKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,179}$`)
|
||||||
|
|
||||||
type Draft struct {
|
type Draft struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -29,7 +33,44 @@ type Result struct {
|
|||||||
Meta map[string]any `json:"meta"`
|
Meta map[string]any `json:"meta"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Summary struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
AutoReply *bool `json:"auto_reply,omitempty"`
|
||||||
|
MinScore *float64 `json:"min_score,omitempty"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
CommunicationStyle string `json:"communication_style"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Keywords []string `json:"keywords"`
|
||||||
|
Categories []string `json:"categories"`
|
||||||
|
RelPath string `json:"rel_path"`
|
||||||
|
ModifiedAt string `json:"modified_at"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
Staging bool `json:"staging"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query struct {
|
||||||
|
Q string `json:"q"`
|
||||||
|
AutoReply string `json:"auto_reply"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
CommunicationStyle string `json:"communication_style"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListResult struct {
|
||||||
|
Items []Summary `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
|
mu sync.Mutex
|
||||||
dir string
|
dir string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +90,20 @@ func New(dir string) (*Store, error) {
|
|||||||
|
|
||||||
func (s *Store) Dir() string { return s.dir }
|
func (s *Store) Dir() string { return s.dir }
|
||||||
|
|
||||||
|
func (s *Store) Count() int {
|
||||||
|
entries, err := os.ReadDir(s.dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) Save(query, model string, draft Draft, autoReply bool, minScore float64) (Result, error) {
|
func (s *Store) Save(query, model string, draft Draft, autoReply bool, minScore float64) (Result, error) {
|
||||||
draft.Title = clampString(draft.Title, 320)
|
draft.Title = clampString(draft.Title, 320)
|
||||||
draft.Text = clampString(draft.Text, 16000)
|
draft.Text = clampString(draft.Text, 16000)
|
||||||
@@ -65,8 +120,6 @@ func (s *Store) Save(query, model string, draft Draft, autoReply bool, minScore
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(query)) + "\x00" + now.Format(time.RFC3339Nano)))
|
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(query)) + "\x00" + now.Format(time.RFC3339Nano)))
|
||||||
id := fmt.Sprintf("KB-AI-STAGING-%s-%s-%s", now.Format("20060102"), now.Format("150405"), strings.ToUpper(hex.EncodeToString(sum[:4])))
|
id := fmt.Sprintf("KB-AI-STAGING-%s-%s-%s", now.Format("20060102"), now.Format("150405"), strings.ToUpper(hex.EncodeToString(sum[:4])))
|
||||||
filename := id + ".json"
|
|
||||||
path := filepath.Join(s.dir, filename)
|
|
||||||
|
|
||||||
categories := uniqueStrings(append([]string{"AI-Staging"}, draft.Categories...))
|
categories := uniqueStrings(append([]string{"AI-Staging"}, draft.Categories...))
|
||||||
keywords := uniqueStrings(draft.Keywords)
|
keywords := uniqueStrings(draft.Keywords)
|
||||||
@@ -88,79 +141,343 @@ func (s *Store) Save(query, model string, draft Draft, autoReply bool, minScore
|
|||||||
"language": "de-DE",
|
"language": "de-DE",
|
||||||
"communication_style": "formal",
|
"communication_style": "formal",
|
||||||
}
|
}
|
||||||
|
if err := s.writeNew(id, doc); err != nil {
|
||||||
payload, err := json.MarshalIndent(doc, "", " ")
|
return Result{}, err
|
||||||
|
}
|
||||||
|
result, err := s.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
payload = append(payload, '\n')
|
result.Meta["generated_at"] = now.Format(time.RFC3339)
|
||||||
|
return result, nil
|
||||||
tmp, err := os.CreateTemp(s.dir, ".staging-*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, fmt.Errorf("create staging temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
defer os.Remove(tmpName)
|
|
||||||
if err := tmp.Chmod(0o644); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
if _, err := tmp.Write(payload); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
if err := tmp.Sync(); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(path); err == nil {
|
|
||||||
return Result{}, fmt.Errorf("staging target already exists: %s", filename)
|
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
|
||||||
return Result{}, fmt.Errorf("commit staging file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result{
|
|
||||||
Key: id,
|
|
||||||
Document: doc,
|
|
||||||
Meta: map[string]any{
|
|
||||||
"rel_path": filepath.ToSlash(filepath.Join("staging", filename)),
|
|
||||||
"staging": true,
|
|
||||||
"generated_at": now.Format(time.RFC3339),
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Get(key string) (Result, error) {
|
func (s *Store) Get(key string) (Result, error) {
|
||||||
key = strings.TrimSpace(key)
|
key = strings.TrimSpace(key)
|
||||||
if !keyPattern.MatchString(key) {
|
path, err := s.pathForKey(key)
|
||||||
|
if err != nil {
|
||||||
return Result{}, os.ErrNotExist
|
return Result{}, os.ErrNotExist
|
||||||
}
|
}
|
||||||
filename := key + ".json"
|
|
||||||
path := filepath.Join(s.dir, filename)
|
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
var doc map[string]any
|
var doc map[string]any
|
||||||
if err := json.Unmarshal(b, &doc); err != nil {
|
dec := json.NewDecoder(bytes.NewReader(b))
|
||||||
|
dec.UseNumber()
|
||||||
|
if err := dec.Decode(&doc); err != nil {
|
||||||
return Result{}, fmt.Errorf("invalid staging JSON: %w", err)
|
return Result{}, fmt.Errorf("invalid staging JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
st, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
return Result{
|
return Result{
|
||||||
Key: key,
|
Key: key,
|
||||||
Document: doc,
|
Document: doc,
|
||||||
Meta: map[string]any{
|
Meta: map[string]any{
|
||||||
"rel_path": filepath.ToSlash(filepath.Join("staging", filename)),
|
"rel_path": filepath.ToSlash(filepath.Join("staging", filepath.Base(path))),
|
||||||
"staging": true,
|
"staging": true,
|
||||||
|
"modified_at": st.ModTime().Format(time.RFC3339),
|
||||||
|
"size": st.Size(),
|
||||||
|
"checksum": fmt.Sprintf("%x", sum[:8]),
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) List(q Query) (ListResult, error) {
|
||||||
|
if q.Page < 1 {
|
||||||
|
q.Page = 1
|
||||||
|
}
|
||||||
|
if q.PageSize < 1 {
|
||||||
|
q.PageSize = 50
|
||||||
|
}
|
||||||
|
if q.PageSize > 500 {
|
||||||
|
q.PageSize = 500
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(s.dir)
|
||||||
|
if err != nil {
|
||||||
|
return ListResult{}, err
|
||||||
|
}
|
||||||
|
items := make([]Summary, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
|
||||||
|
if !safeKeyPattern.MatchString(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := s.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return ListResult{}, fmt.Errorf("load staging %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
summary := summarize(result)
|
||||||
|
if matches(summary, result.Document, q) {
|
||||||
|
items = append(items, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].ModifiedAt != items[j].ModifiedAt {
|
||||||
|
return items[i].ModifiedAt > items[j].ModifiedAt
|
||||||
|
}
|
||||||
|
return strings.ToLower(items[i].Title) < strings.ToLower(items[j].Title)
|
||||||
|
})
|
||||||
|
total := len(items)
|
||||||
|
totalPages := 0
|
||||||
|
if total > 0 {
|
||||||
|
totalPages = (total + q.PageSize - 1) / q.PageSize
|
||||||
|
if q.Page > totalPages {
|
||||||
|
q.Page = totalPages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start := (q.Page - 1) * q.PageSize
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := start + q.PageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
return ListResult{Items: items[start:end], Total: total, Page: q.Page, PageSize: q.PageSize, TotalPages: totalPages}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Update(key string, doc map[string]any) (Result, error) {
|
||||||
|
if doc == nil {
|
||||||
|
return Result{}, errors.New("JSON root must be an object")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
path, err := s.pathForKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
st, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
payload, err := json.MarshalIndent(doc, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
payload = append(payload, '\n')
|
||||||
|
if err := atomicWrite(path, payload, st.Mode().Perm()); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
return s.Get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete moves a staging file into .trash instead of irreversibly removing it.
|
||||||
|
func (s *Store) Delete(key string) (string, error) {
|
||||||
|
return s.archive(key, ".trash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveApproved removes a reviewed item from active staging while keeping the original
|
||||||
|
// draft for audit purposes below .approved.
|
||||||
|
func (s *Store) ArchiveApproved(key string) (string, error) {
|
||||||
|
return s.archive(key, ".approved")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) archive(key, bucket string) (string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
path, err := s.pathForKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
archiveDir := filepath.Join(s.dir, bucket)
|
||||||
|
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("%s-%s.json", time.Now().UTC().Format("20060102-150405.000000000"), key)
|
||||||
|
dst := filepath.Join(archiveDir, name)
|
||||||
|
if err := os.Rename(path, dst); err != nil {
|
||||||
|
return "", fmt.Errorf("move staging file to %s: %w", bucket, err)
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) writeNew(key string, doc map[string]any) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
path, err := s.pathForKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return fmt.Errorf("staging target already exists: %s", filepath.Base(path))
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload, err := json.MarshalIndent(doc, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload = append(payload, '\n')
|
||||||
|
return atomicWrite(path, payload, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) pathForKey(key string) (string, error) {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if !safeKeyPattern.MatchString(key) {
|
||||||
|
return "", errors.New("invalid staging key")
|
||||||
|
}
|
||||||
|
return filepath.Join(s.dir, key+".json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func atomicWrite(path string, payload []byte, mode os.FileMode) error {
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".staging-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName)
|
||||||
|
if err := tmp.Chmod(mode); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tmp.Write(payload); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarize(result Result) Summary {
|
||||||
|
doc := result.Document
|
||||||
|
meta := result.Meta
|
||||||
|
var autoReply *bool
|
||||||
|
if v, ok := doc["auto_reply"].(bool); ok {
|
||||||
|
vv := v
|
||||||
|
autoReply = &vv
|
||||||
|
}
|
||||||
|
var minScore *float64
|
||||||
|
if v, ok := number(doc["min_score"]); ok {
|
||||||
|
vv := v
|
||||||
|
minScore = &vv
|
||||||
|
}
|
||||||
|
return Summary{
|
||||||
|
Key: result.Key,
|
||||||
|
ID: str(doc["id"]),
|
||||||
|
Title: str(doc["title"]),
|
||||||
|
AutoReply: autoReply,
|
||||||
|
MinScore: minScore,
|
||||||
|
Language: str(doc["language"]),
|
||||||
|
CommunicationStyle: str(doc["communication_style"]),
|
||||||
|
Source: str(doc["source"]),
|
||||||
|
Keywords: toStrings(doc["keywords"]),
|
||||||
|
Categories: toStrings(doc["categories"]),
|
||||||
|
RelPath: str(meta["rel_path"]),
|
||||||
|
ModifiedAt: str(meta["modified_at"]),
|
||||||
|
Size: int64Number(meta["size"]),
|
||||||
|
Checksum: str(meta["checksum"]),
|
||||||
|
Staging: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matches(summary Summary, doc map[string]any, q Query) bool {
|
||||||
|
if text := strings.ToLower(strings.TrimSpace(q.Q)); text != "" {
|
||||||
|
search := strings.ToLower(strings.Join([]string{
|
||||||
|
summary.ID, summary.Title, str(doc["text"]), str(doc["answer"]), summary.Source,
|
||||||
|
strings.Join(summary.Keywords, " "), strings.Join(summary.Categories, " "),
|
||||||
|
}, "\n"))
|
||||||
|
for _, term := range strings.Fields(text) {
|
||||||
|
if !strings.Contains(search, term) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(q.AutoReply); v != "" && v != "any" {
|
||||||
|
expected, err := strconv.ParseBool(v)
|
||||||
|
if err != nil || summary.AutoReply == nil || *summary.AutoReply != expected {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.Language != "" && !strings.EqualFold(summary.Language, q.Language) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if q.CommunicationStyle != "" && !strings.EqualFold(summary.CommunicationStyle, q.CommunicationStyle) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if q.Source != "" && !strings.Contains(strings.ToLower(summary.Source), strings.ToLower(q.Source)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(v any) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func number(v any) (float64, bool) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return x, true
|
||||||
|
case float32:
|
||||||
|
return float64(x), true
|
||||||
|
case int:
|
||||||
|
return float64(x), true
|
||||||
|
case json.Number:
|
||||||
|
f, err := x.Float64()
|
||||||
|
return f, err == nil
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Number(v any) int64 {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case int64:
|
||||||
|
return x
|
||||||
|
case int:
|
||||||
|
return int64(x)
|
||||||
|
case float64:
|
||||||
|
return int64(x)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStrings(v any) []string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case []string:
|
||||||
|
return append([]string(nil), x...)
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(x))
|
||||||
|
for _, item := range x {
|
||||||
|
if value, ok := item.(string); ok {
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func uniqueStrings(values []string) []string {
|
func uniqueStrings(values []string) []string {
|
||||||
seen := make(map[string]struct{}, len(values))
|
seen := make(map[string]struct{}, len(values))
|
||||||
out := make([]string, 0, len(values))
|
out := make([]string, 0, len(values))
|
||||||
|
|||||||
@@ -42,3 +42,43 @@ func TestSaveAndGet(t *testing.T) {
|
|||||||
t.Fatalf("loaded=%+v", loaded)
|
t.Fatalf("loaded=%+v", loaded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListUpdateAndSoftDelete(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s, err := New(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
one, err := s.Save("0xFEEDFACE Netzwerk", "model", Draft{Title: "Netzwerk", Answer: "Prüfen"}, false, .78)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.Save("anderes", "model", Draft{Title: "Drucker", Answer: "Prüfen"}, false, .78); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := s.List(Query{Q: "FEEDFACE", Page: 1, PageSize: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if list.Total != 1 || list.Items[0].Key != one.Key {
|
||||||
|
t.Fatalf("unexpected list: %+v", list)
|
||||||
|
}
|
||||||
|
one.Document["title"] = "Geprüftes Netzwerk"
|
||||||
|
updated, err := s.Update(one.Key, one.Document)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if updated.Document["title"] != "Geprüftes Netzwerk" {
|
||||||
|
t.Fatalf("update failed: %+v", updated.Document)
|
||||||
|
}
|
||||||
|
trash, err := s.Delete(one.Key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(trash); err != nil {
|
||||||
|
t.Fatalf("trash file missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Get(one.Key); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("deleted staging file should be gone, err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -619,6 +619,110 @@ func (s *Store) Save(key string, doc map[string]any) (Summary, string, error) {
|
|||||||
return summarize(newRec), backupBatch, nil
|
return summarize(newRec), backupBatch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImportDocument creates a new production JSON file without overwriting an existing entry.
|
||||||
|
// It is used when a reviewed staging article is promoted into the productive knowledge base.
|
||||||
|
func (s *Store) ImportDocument(doc map[string]any, preferredBase string) (Summary, error) {
|
||||||
|
if doc == nil {
|
||||||
|
return Summary{}, errors.New("JSON root must be an object")
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(str(doc["id"]))
|
||||||
|
if id == "" {
|
||||||
|
id = strings.TrimSpace(preferredBase)
|
||||||
|
doc = cloneMap(doc)
|
||||||
|
doc["id"] = id
|
||||||
|
}
|
||||||
|
base := safeFilenameBase(id)
|
||||||
|
if base == "" {
|
||||||
|
base = safeFilenameBase(preferredBase)
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
return Summary{}, errors.New("cannot derive a safe production filename from document id")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, rec := range s.records {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(str(rec.Doc["id"])), id) {
|
||||||
|
return Summary{}, fmt.Errorf("knowledge entry with id %q already exists", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rel := base + ".json"
|
||||||
|
path := filepath.Join(s.dataDir, rel)
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return Summary{}, fmt.Errorf("production target already exists: %s", rel)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
payload, err := marshalDocument(doc)
|
||||||
|
if err != nil {
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(s.dataDir, ".kb-import-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
cleanup := func() {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
}
|
||||||
|
if err := tmp.Chmod(0o644); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
if _, err := tmp.Write(payload); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
rec, err := s.readRecord(path)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
s.records[rec.Key] = rec
|
||||||
|
s.order = append(s.order, rec.Key)
|
||||||
|
s.resortLocked()
|
||||||
|
return summarize(rec), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeFilenameBase(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
lastDash := false
|
||||||
|
for _, r := range value {
|
||||||
|
valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.'
|
||||||
|
if valid {
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastDash = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastDash {
|
||||||
|
b.WriteByte('-')
|
||||||
|
lastDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), ".-_ ")
|
||||||
|
if len(out) > 180 {
|
||||||
|
out = out[:180]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ApplyBulk(keys []string, patch BulkPatch, dryRun bool) (BulkResult, error) {
|
func (s *Store) ApplyBulk(keys []string, patch BulkPatch, dryRun bool) (BulkResult, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -161,3 +161,28 @@ func TestSearchRanksExactIdentifiersAndBuildsExcerpt(t *testing.T) {
|
|||||||
t.Fatalf("unexpected excerpt: %q", result.Items[0].Excerpt)
|
t.Fatalf("unexpected excerpt: %q", result.Items[0].Excerpt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportDocumentCreatesNewFileAndRejectsDuplicateID(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s, err := New(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doc := map[string]any{
|
||||||
|
"id": "KB-AI-STAGING-TEST-001", "title": "Reviewed", "answer": "Lösung",
|
||||||
|
"auto_reply": false, "categories": []any{"AI-Staging"},
|
||||||
|
}
|
||||||
|
created, err := s.ImportDocument(doc, "fallback")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if created.ID != "KB-AI-STAGING-TEST-001" || s.Count() != 1 {
|
||||||
|
t.Fatalf("unexpected created item: %+v count=%d", created, s.Count())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "KB-AI-STAGING-TEST-001.json")); err != nil {
|
||||||
|
t.Fatalf("production file missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.ImportDocument(doc, "fallback"); err == nil {
|
||||||
|
t.Fatal("expected duplicate ID to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user