Compare commits

...

3 Commits

Author SHA1 Message Date
Michael Mayer
79654170eb Ollama: Remove code fences and commentary from JSON API responses
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-10-04 16:46:55 +02:00
Michael Mayer
fba00a843c Config: Add "test-hub" target to Makefile and improve log messages
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-10-04 15:59:34 +02:00
Michael Mayer
47b7a0faf7 Develop: Upgrade base image from 250930-plucky to 251004-plucky
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-10-04 15:57:52 +02:00
8 changed files with 140 additions and 12 deletions

View File

@@ -1,5 +1,5 @@
# Ubuntu 25.04 (Plucky Puffin)
FROM photoprism/develop:250930-plucky
FROM photoprism/develop:251004-plucky
# Harden npm usage by default (applies to npm ci / install in dev container)
ENV NPM_CONFIG_IGNORE_SCRIPTS=true

View File

@@ -73,6 +73,7 @@ build-all: build-go build-js
pull: docker-pull
test: test-js test-go
test-go: run-test-go
test-hub: run-test-hub
test-pkg: run-test-pkg
test-ai: run-test-ai
test-api: run-test-api
@@ -403,6 +404,9 @@ run-test-short:
run-test-go:
$(info Running all Go tests...)
$(GOTEST) -parallel 1 -count 1 -cpu 1 -tags="slow,develop" -timeout 20m ./pkg/... ./internal/...
run-test-hub:
$(info Running all Go tests with hub requests...)
env PHOTOPRISM_TEST_HUB="true" $(GOTEST) -parallel 1 -count 1 -cpu 1 -tags="slow,develop,debug" -timeout 20m ./pkg/... ./internal/...
run-test-mariadb:
$(info Running all Go tests on MariaDB...)
PHOTOPRISM_TEST_DRIVER="mysql" PHOTOPRISM_TEST_DSN="root:photoprism@tcp(mariadb:4001)/acceptance?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true" $(GOTEST) -parallel 1 -count 1 -cpu 1 -tags="slow,develop" -timeout 20m ./pkg/... ./internal/...

View File

@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"strings"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/service/http/header"
@@ -110,8 +109,8 @@ func decodeOllamaResponse(data []byte) (*ApiResponseOllama, error) {
}
func parseOllamaLabels(raw string) ([]LabelResult, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
cleaned := clean.JSON(raw)
if cleaned == "" {
return nil, nil
}
@@ -119,7 +118,7 @@ func parseOllamaLabels(raw string) ([]LabelResult, error) {
Labels []LabelResult `json:"labels"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
if err := json.Unmarshal([]byte(cleaned), &payload); err != nil {
return nil, err
}

View File

@@ -70,6 +70,29 @@ func TestPerformApiRequestOllama(t *testing.T) {
assert.Equal(t, "Test", resp.Result.Labels[0].Name)
assert.Nil(t, resp.Result.Caption)
})
t.Run("LabelsWithCodeFence", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.NoError(t, json.NewEncoder(w).Encode(ApiResponseOllama{
Model: "gemma3:latest",
Response: "```json\n{\"labels\":[{\"name\":\"lingerie\",\"confidence\":0.81,\"topicality\":0.73}]}\n```\nThe model provided additional commentary.",
}))
}))
defer server.Close()
apiRequest := &ApiRequest{
Id: "fenced",
Model: "gemma3:latest",
Format: FormatJSON,
Images: []string{"data:image/jpeg;base64,AA=="},
ResponseFormat: ApiFormatOllama,
}
resp, err := PerformApiRequest(apiRequest, server.URL, http.MethodPost, "")
assert.NoError(t, err)
if assert.Len(t, resp.Result.Labels, 1) {
assert.Equal(t, "Lingerie", resp.Result.Labels[0].Name)
}
})
t.Run("CaptionFallback", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.NoError(t, json.NewEncoder(w).Encode(ApiResponseOllama{

View File

@@ -226,7 +226,7 @@ func (c *Config) ReSync(token string) (err error) {
// Return if no endpoint URL is set.
if endpointUrl == "" {
log.Debugf("config: unable to obtain API key for maps and places (service disabled)")
log.Debugf("config: unable to obtain key for maps and places (service disabled)")
return nil
}
@@ -235,10 +235,10 @@ func (c *Config) ReSync(token string) (err error) {
if c.Key == "" {
method = http.MethodPost
log.Tracef("config: requesting new API key for maps and places")
log.Tracef("config: requesting new key for maps and places")
} else {
method = http.MethodPut
log.Tracef("config: requesting API key for maps and places")
log.Tracef("config: requesting key for maps and places")
}
// Create JSON request.
@@ -275,7 +275,7 @@ func (c *Config) ReSync(token string) (err error) {
if err != nil {
return err
} else if r.StatusCode >= 400 {
err = fmt.Errorf("requesting api key from %s failed (error %d)", GetServiceHost(), r.StatusCode)
err = fmt.Errorf("failed to request key from %s (error %d)", GetServiceHost(), r.StatusCode)
return err
}

View File

@@ -90,7 +90,7 @@ func TestConfig_Refresh(t *testing.T) {
if sess, err := c.DecodeSession(false); err != nil {
t.Fatal(err)
} else if sess.Expired() {
t.Fatalf("session expired: %+v", sess)
t.Fatalf("(1) session expired: %+v", sess)
} else {
t.Logf("(1) session: %#v", sess)
}
@@ -114,9 +114,9 @@ func TestConfig_Refresh(t *testing.T) {
if sess, err := c.DecodeSession(false); err != nil {
t.Fatal(err)
} else if sess.Expired() {
t.Fatal("session expired")
t.Fatal("(2) session expired")
} else {
t.Logf("session: %#v", sess)
t.Logf("(2) session: %#v", sess)
}
if err := c.Save(); err != nil {

70
pkg/clean/json.go Normal file
View File

@@ -0,0 +1,70 @@
package clean
import "strings"
// JSON attempts to extract a JSON object or array from raw text.
// It removes common wrappers such as Markdown code fences and trailing commentary.
// Returns an empty string when no JSON payload can be found.
func JSON(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if strings.HasPrefix(trimmed, "```") {
trimmed = strings.TrimPrefix(trimmed, "```")
trimmed = strings.TrimSpace(trimmed)
if !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
if idx := strings.Index(trimmed, "\n"); idx != -1 {
trimmed = trimmed[idx+1:]
} else {
return ""
}
}
if idx := strings.LastIndex(trimmed, "```"); idx != -1 {
trimmed = trimmed[:idx]
}
}
trimmed = strings.TrimSpace(trimmed)
startObj := strings.Index(trimmed, "{")
startArr := strings.Index(trimmed, "[")
start := -1
if startObj >= 0 && startArr >= 0 {
if startObj < startArr {
start = startObj
} else {
start = startArr
}
} else if startObj >= 0 {
start = startObj
} else if startArr >= 0 {
start = startArr
}
endObj := strings.LastIndex(trimmed, "}")
endArr := strings.LastIndex(trimmed, "]")
end := -1
if endObj >= 0 && endArr >= 0 {
if endObj > endArr {
end = endObj
} else {
end = endArr
}
} else if endObj >= 0 {
end = endObj
} else if endArr >= 0 {
end = endArr
}
if start >= 0 && end > start {
trimmed = trimmed[start : end+1]
}
return strings.TrimSpace(trimmed)
}

32
pkg/clean/json_test.go Normal file
View File

@@ -0,0 +1,32 @@
package clean
import "testing"
func TestJSON(t *testing.T) {
t.Run("CodeFence", func(t *testing.T) {
payload := "```json\n{\"labels\":[]}\n```\nextra"
expected := "{\"labels\":[]}"
if got := JSON(payload); got != expected {
t.Fatalf("expected %q, got %q", expected, got)
}
})
t.Run("PlainWithPrefix", func(t *testing.T) {
payload := "Here you go: {\"labels\":[1]} thanks"
expected := "{\"labels\":[1]}"
if got := JSON(payload); got != expected {
t.Fatalf("expected %q, got %q", expected, got)
}
})
t.Run("Array", func(t *testing.T) {
payload := "```\n[1,2,3]\n```"
expected := "[1,2,3]"
if got := JSON(payload); got != expected {
t.Fatalf("expected %q, got %q", expected, got)
}
})
t.Run("Empty", func(t *testing.T) {
if got := JSON(" "); got != "" {
t.Fatalf("expected empty, got %q", got)
}
})
}