diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1344e51 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/QUALITY_REPORT.txt b/QUALITY_REPORT.txt index 7c031f1..910c9e6 100644 --- a/QUALITY_REPORT.txt +++ b/QUALITY_REPORT.txt @@ -1,23 +1,46 @@ -KB Helpdesk Editor / Google Mode / Ollama Fallback +KB Helpdesk Editor / Search / Ollama / Staging Review Workflow Quality report +Date: 2026-07-29 -Checks completed successfully: -- go test ./... -- go test -race ./... -- go vet ./... -- go build ./cmd/server -- node --check cmd/server/web/app.js -- node --check cmd/server/viewer/app.js -- docker-compose.yml YAML parse -- docker-compose.dual.yml YAML parse -- End-to-end mock Ollama flow: - 0 search results -> POST /api/ai/fallback -> structured /api/chat response - -> atomic staging JSON write -> GET /api/staging/{key} -- Server-side guard blocks AI fallback when normal KB search has results -- Staging default auto_reply=false -- Main knowledge directory remains separate from writable staging directory +Implemented review workflow +--------------------------- +- Editor scope switch: Production / Staging +- Staging count in UI and health endpoint +- Staging search/filter with existing q, auto_reply, language, style and source filters +- Staging article editing in form view and raw JSON view +- Single promote: staging -> productive knowledge directory +- Single delete: staging -> staging/.trash +- Bulk promote/delete for selected staging articles +- Promoted originals archived below staging/.approved for audit +- Production import refuses duplicate IDs and existing target filenames +- Unknown JSON fields are preserved on staging edits and promotion +- Google/viewer mode blocks staging list/write/delete/promote APIs server-side +- Ollama fallback continues to save only into staging -Note: -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 -structured response compatible with the official Ollama API documentation. +Validation +---------- +PASS go test ./... +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. diff --git a/README.md b/README.md index da52d3c..b561f86 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,11 @@ Der bekannte Administrationsmodus: - automatische Backups - atomisches Schreiben per Temp-Datei + Rename - 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` @@ -78,6 +83,7 @@ APP_SUBTITLE=JSON · Massenbearbeitung · Docker KB_DATA_PATH=../glpi-ai-agent-kb-microsoft-errorcodes-kompendium/knowledge KB_DATA_MOUNT_MODE=rw KB_BACKUP_PATH=./backups +KB_STAGING_PATH=./staging KB_EDITOR_PORT=8080 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. +## 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 `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" \ -v /srv/kb/knowledge:/data/knowledge:rw \ -v /srv/kb/backups:/data/backups:rw \ + -v /srv/kb/staging:/data/staging:rw \ kb-helpdesk:local ``` @@ -249,6 +285,7 @@ docker run -d \ -e APP_TITLE="IT Helpdesk Wissen" \ -e APP_SUBTITLE="Interne Lösungsdatenbank" \ -v /srv/kb/knowledge:/data/knowledge:ro \ + -v /srv/kb/staging:/data/staging:rw \ kb-helpdesk:local ``` @@ -363,18 +400,27 @@ Lesend in beiden Modi: - `GET /api/facets?limit=10` - `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 -- `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}` - `POST /api/bulk` - `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 @@ -447,7 +493,7 @@ go build -o kb-helpdesk ./cmd/server │ ├── app.js │ └── style.css ├── 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/ │ ├── store.go │ └── store_test.go diff --git a/cmd/server/app.go b/cmd/server/app.go index c1f1194..8f3cf6f 100644 --- a/cmd/server/app.go +++ b/cmd/server/app.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "io/fs" "net/http" @@ -12,6 +13,7 @@ import ( "strings" "kb-editor/internal/aifallback" + "kb-editor/internal/staging" "kb-editor/internal/store" ) @@ -23,13 +25,15 @@ type appConfig struct { AIFallbackEnabled bool `json:"ai_fallback_enabled"` AIFallbackTimeoutSeconds int `json:"ai_fallback_timeout_seconds,omitempty"` AIFallbackModel string `json:"ai_fallback_model,omitempty"` + StagingEnabled bool `json:"staging_enabled"` } type app struct { - store *store.Store - web fs.FS - config appConfig - ai *aifallback.Service + store *store.Store + web fs.FS + config appConfig + ai *aifallback.Service + staging *staging.Store } 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 } +func (a *app) withStaging(st *staging.Store) *app { + a.staging = st + a.config.StagingEnabled = st != nil + return a +} + func (a *app) routes() http.Handler { mux := http.NewServeMux() 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/items/{key}", a.handleGet) mux.HandleFunc("POST /api/ai/fallback", a.handleAIFallback) + mux.HandleFunc("GET /api/staging", a.handleStagingList) mux.HandleFunc("GET /api/staging/{key}", a.handleStagingGet) if a.config.Writable { mux.HandleFunc("PUT /api/items/{key}", a.handlePut) mux.HandleFunc("POST /api/bulk", a.handleBulk) 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 { mux.HandleFunc("PUT /api/items/{key}", a.handleReadOnly) mux.HandleFunc("POST /api/bulk", 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)) @@ -90,6 +109,11 @@ func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) { "mode": a.config.Mode, "writable": a.config.Writable, "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 { 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) } -func (a *app) handleStagingGet(w http.ResponseWriter, r *http.Request) { - if !a.config.AIFallbackEnabled || a.ai == nil { - writeError(w, http.StatusNotFound, "Staging-Viewer ist auf dieser Instanz deaktiviert") +func (a *app) handleStagingList(w http.ResponseWriter, r *http.Request) { + if !a.config.Writable { + writeError(w, http.StatusForbidden, "Die Staging-Liste ist nur im Editor-Modus verfügbar") 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) { writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden") return @@ -204,6 +256,141 @@ func (a *app) handleStagingGet(w http.ResponseWriter, r *http.Request) { 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) { writeError(w, http.StatusForbidden, "Diese Instanz läuft im Google-/Viewer-Modus und ist schreibgeschützt") } diff --git a/cmd/server/app_test.go b/cmd/server/app_test.go index 1d9ca61..e9cc79c 100644 --- a/cmd/server/app_test.go +++ b/cmd/server/app_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "errors" "io/fs" "net/http" "net/http/httptest" @@ -165,7 +166,7 @@ func TestAIFallbackOnlyRunsForZeroResultsAndReturnsStagingArticle(t *testing.T) if err != nil { 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. 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()) } } + +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()) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index c9b902a..886350f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -39,7 +39,11 @@ func main() { 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 { log.Fatal(err) } @@ -62,7 +66,7 @@ func main() { log.Fatal(err) } - app := newApp(s, sub, cfg).withAI(aiService) + app := newApp(s, sub, cfg).withStaging(stagingStore).withAI(aiService) handler := requestLogger(optionalBasicAuth(app.routes())) writeTimeout := 60 * time.Second @@ -81,6 +85,7 @@ func main() { log.Printf("KB service listening on %s", listen) 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("Staging directory: %s (%d JSON files)", stagingStore.Dir(), stagingStore.Count()) if reloadInterval > 0 { 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) if err != nil { return nil, 0, err @@ -176,26 +200,8 @@ func aiServiceFromEnv(mode, dataDir string) (*aifallback.Service, time.Duration, if err != nil || minScore < 0 || minScore > 1 { return nil, 0, fmt.Errorf("OLLAMA_STAGING_MIN_SCORE must be between 0 and 1") } - - 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, 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 + if st == nil { + return nil, 0, fmt.Errorf("staging store is required for AI fallback") } svc, err := aifallback.New(aifallback.Config{ BaseURL: envOr("OLLAMA_BASE_URL", "http://ollama:11434"), diff --git a/cmd/server/web/app.js b/cmd/server/web/app.js index abdf6c4..9b8181e 100644 --- a/cmd/server/web/app.js +++ b/cmd/server/web/app.js @@ -5,6 +5,7 @@ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const state = { + scope: 'production', page: 1, pageSize: 60, total: 0, @@ -21,15 +22,19 @@ const els = { brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), healthPill: $('#healthPill'), reloadBtn: $('#reloadBtn'), bulkBtn: $('#bulkBtn'), + scopeProduction: $('#scopeProduction'), scopeStaging: $('#scopeStaging'), stagingCountBadge: $('#stagingCountBadge'), searchInput: $('#searchInput'), autoReplyFilter: $('#autoReplyFilter'), languageFilter: $('#languageFilter'), sourceFilter: $('#sourceFilter'), styleFilter: $('#styleFilter'), selectPage: $('#selectPage'), selectionCount: $('#selectionCount'), resultList: $('#resultList'), prevPage: $('#prevPage'), nextPage: $('#nextPage'), 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'), bulkDialog: $('#bulkDialog'), bulkTargetText: $('#bulkTargetText'), bulkAllMatching: $('#bulkAllMatching'), 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; @@ -73,9 +78,11 @@ document.title = config.title; } 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.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) { els.healthPill.textContent = 'Offline'; els.healthPill.className = 'pill'; @@ -87,7 +94,8 @@ if (resetPage) state.page = 1; els.resultList.innerHTML = '