H-337: add local Captain acceptance fixture (#63)
* H-337: add local Captain acceptance fixture * fix(H-337): close acceptance fixture review gaps * fix(H-337): reject non-string skill metadata --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/captain-fixture
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.25.13-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod main.go ./
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /captain-fixture .
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN addgroup -S fixture && adduser -S fixture -G fixture
|
||||
COPY --from=build /captain-fixture /usr/local/bin/captain-fixture
|
||||
USER fixture
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["captain-fixture"]
|
||||
@@ -0,0 +1,3 @@
|
||||
module gochat.local/captain-fixture
|
||||
|
||||
go 1.24.0
|
||||
@@ -0,0 +1,291 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
listenAddr = ":8080"
|
||||
maxRequestBody = 1 << 20
|
||||
knowledgeHTML = `<!doctype html><html><head><title>GoChat Local Acceptance Knowledge</title></head><body><main><h1>Local acceptance policy</h1><p id="fixture-fact">The local fixture warranty window is 37 days. Reference marker: KBASE-LOCAL-2026.</p></main></body></html>`
|
||||
)
|
||||
|
||||
type fixtureServer struct {
|
||||
mu sync.Mutex
|
||||
attempts map[[32]byte]int
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Tools []toolDefinition `json:"tools"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Name string `json:"name"`
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
ToolCalls []toolCall `json:"tool_calls"`
|
||||
}
|
||||
|
||||
type toolDefinition struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
type toolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
type embeddingRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
logger := log.New(os.Stdout, "captain-fixture ", log.LstdFlags)
|
||||
server := &http.Server{Addr: listenAddr, Handler: newFixtureServer(logger), ReadHeaderTimeout: 5 * time.Second}
|
||||
logger.Printf("listening addr=%s", listenAddr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func newFixtureServer(logger *log.Logger) http.Handler {
|
||||
s := &fixtureServer{attempts: make(map[[32]byte]int)}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("GET /knowledge", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, knowledgeHTML)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/chat/completions", s.chat)
|
||||
mux.HandleFunc("POST /v1/embeddings", s.embeddings)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
status := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
mux.ServeHTTP(status, r)
|
||||
logger.Printf("method=%s path=%s status=%d", r.Method, r.URL.Path, status.status)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fixtureServer) chat(w http.ResponseWriter, r *http.Request) {
|
||||
body, ok := readBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req chatRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil || len(req.Messages) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request")
|
||||
return
|
||||
}
|
||||
latest := latestUserContent(req.Messages)
|
||||
if strings.Contains(latest, "[fixture:fail]") {
|
||||
writeError(w, http.StatusServiceUnavailable, "fixture_failure")
|
||||
return
|
||||
}
|
||||
if strings.Contains(latest, "[fixture:retry]") && s.firstAttempt(body) {
|
||||
writeError(w, http.StatusServiceUnavailable, "fixture_retry_once")
|
||||
return
|
||||
}
|
||||
|
||||
message, finishReason := fixtureChatMessage(req)
|
||||
if req.Stream {
|
||||
writeStream(w, req.Model, message.Content)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": "chatcmpl-fixture", "object": "chat.completion", "created": 1, "model": req.Model,
|
||||
"choices": []map[string]any{{"index": 0, "message": message, "finish_reason": finishReason}},
|
||||
"usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fixtureServer) embeddings(w http.ResponseWriter, r *http.Request) {
|
||||
body, ok := readBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req embeddingRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil || len(req.Input) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request")
|
||||
return
|
||||
}
|
||||
content := strings.Join(req.Input, "\n")
|
||||
if strings.Contains(content, "[fixture:embedding-fail]") {
|
||||
writeError(w, http.StatusServiceUnavailable, "fixture_embedding_failure")
|
||||
return
|
||||
}
|
||||
if strings.Contains(content, "[fixture:embedding-retry]") && s.firstAttempt(body) {
|
||||
writeError(w, http.StatusServiceUnavailable, "fixture_embedding_retry_once")
|
||||
return
|
||||
}
|
||||
vector := make([]float64, 1536)
|
||||
for i := range vector {
|
||||
vector[i] = 0.01
|
||||
}
|
||||
data := make([]map[string]any, len(req.Input))
|
||||
for i := range req.Input {
|
||||
data[i] = map[string]any{"object": "embedding", "index": i, "embedding": vector}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"object": "list", "data": data, "model": req.Model,
|
||||
"usage": map[string]int{"prompt_tokens": len(req.Input), "total_tokens": len(req.Input)},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fixtureServer) firstAttempt(body []byte) bool {
|
||||
key := sha256.Sum256(body)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.attempts[key]++
|
||||
return s.attempts[key] == 1
|
||||
}
|
||||
|
||||
func fixtureChatMessage(req chatRequest) (chatMessage, string) {
|
||||
all := allMessageContent(req.Messages)
|
||||
if strings.Contains(all, "Extract factual, self-contained FAQs") {
|
||||
return chatMessage{Role: "assistant", Content: `{"faqs":[{"question":"What is the local fixture warranty window?","answer":"The local fixture warranty window is 37 days."}]}`}, "stop"
|
||||
}
|
||||
if last := req.Messages[len(req.Messages)-1]; last.Role == "tool" {
|
||||
if last.Name == "activate_skill" {
|
||||
if key := firstJSONListValue(last.Content, "reference_keys"); key != "" {
|
||||
return toolMessage("fixture-read", "read_skill_reference", map[string]string{"skill_name": firstCatalogSkill(all), "reference_key": key}), "tool_calls"
|
||||
}
|
||||
}
|
||||
return chatMessage{Role: "assistant", Content: "Fixture skill path completed."}, "stop"
|
||||
}
|
||||
if strings.Contains(latestUserContent(req.Messages), "[fixture:skill]") && hasTool(req.Tools, "activate_skill") {
|
||||
if name := firstCatalogSkill(all); name != "" {
|
||||
return toolMessage("fixture-activate", "activate_skill", map[string]string{"skill_name": name}), "tool_calls"
|
||||
}
|
||||
}
|
||||
if strings.Contains(all, "Knowledge Base Context:") {
|
||||
return chatMessage{Role: "assistant", Content: "The local fixture warranty window is 37 days [FAQ 1]."}, "stop"
|
||||
}
|
||||
return chatMessage{Role: "assistant", Content: "Fixture chat response."}, "stop"
|
||||
}
|
||||
|
||||
func toolMessage(id, name string, args map[string]string) chatMessage {
|
||||
raw, _ := json.Marshal(args)
|
||||
call := toolCall{ID: id, Type: "function"}
|
||||
call.Function.Name, call.Function.Arguments = name, string(raw)
|
||||
return chatMessage{Role: "assistant", ToolCalls: []toolCall{call}}
|
||||
}
|
||||
|
||||
func firstCatalogSkill(content string) string {
|
||||
const start, end = "<available_skills_json>", "</available_skills_json>"
|
||||
i := strings.Index(content, start)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
j := strings.Index(content[i+len(start):], end)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
var catalog []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal([]byte(content[i+len(start):i+len(start)+j]), &catalog) != nil || len(catalog) == 0 {
|
||||
return ""
|
||||
}
|
||||
return catalog[0].Name
|
||||
}
|
||||
|
||||
func firstJSONListValue(content, key string) string {
|
||||
var payload map[string]any
|
||||
start, end := strings.Index(content, "{"), strings.LastIndex(content, "}")
|
||||
if start < 0 || end < start || json.Unmarshal([]byte(content[start:end+1]), &payload) != nil {
|
||||
return ""
|
||||
}
|
||||
values, _ := payload[key].([]any)
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
value, _ := values[0].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func hasTool(tools []toolDefinition, name string) bool {
|
||||
for _, tool := range tools {
|
||||
if tool.Function.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func allMessageContent(messages []chatMessage) string {
|
||||
var b strings.Builder
|
||||
for _, message := range messages {
|
||||
b.WriteString(message.Content)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func latestUserContent(messages []chatMessage) string {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == "user" {
|
||||
return messages[i].Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBody+1))
|
||||
if err != nil || len(body) == 0 || len(body) > maxRequestBody {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request")
|
||||
return nil, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func writeStream(w http.ResponseWriter, model, content string) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
chunk := map[string]any{
|
||||
"id": "chatcmpl-fixture", "object": "chat.completion.chunk", "created": 1, "model": model,
|
||||
"choices": []map[string]any{{"index": 0, "delta": map[string]string{"role": "assistant", "content": content}, "finish_reason": "stop"}},
|
||||
}
|
||||
raw, _ := json.Marshal(chunk)
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", raw)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code string) {
|
||||
writeJSON(w, status, map[string]any{"error": map[string]string{"message": "local acceptance fixture error", "type": "fixture_error", "code": code}})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(status int) {
|
||||
w.status = status
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAcceptanceFixtureContract(t *testing.T) {
|
||||
server := httptest.NewServer(newFixtureServer(log.New(io.Discard, "", 0)))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
t.Run("knowledge", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/knowledge")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK || !bytes.Contains(body, []byte("KBASE-LOCAL-2026")) {
|
||||
t.Fatalf("knowledge status=%d marker=%t", resp.StatusCode, bytes.Contains(body, []byte("KBASE-LOCAL-2026")))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chat retry and faq", func(t *testing.T) {
|
||||
fail := `{"model":"gpt-5.6-luna","messages":[{"role":"user","content":"[fixture:fail]"}]}`
|
||||
if status := post(t, server.URL+"/v1/chat/completions", fail, nil); status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("failure status=%d", status)
|
||||
}
|
||||
recovered := `{"model":"gpt-5.6-luna","messages":[{"role":"user","content":"[fixture:fail]"},{"role":"user","content":"recovered"}]}`
|
||||
if status := post(t, server.URL+"/v1/chat/completions", recovered, nil); status != http.StatusOK {
|
||||
t.Fatalf("recovery status=%d", status)
|
||||
}
|
||||
retry := `{"model":"gpt-5.6-luna","messages":[{"role":"user","content":"[fixture:retry] one"}]}`
|
||||
if status := post(t, server.URL+"/v1/chat/completions", retry, nil); status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("first retry status=%d", status)
|
||||
}
|
||||
if status := post(t, server.URL+"/v1/chat/completions", retry, nil); status != http.StatusOK {
|
||||
t.Fatalf("second retry status=%d", status)
|
||||
}
|
||||
faq := `{"model":"gpt-5.6-luna","messages":[{"role":"system","content":"Extract factual, self-contained FAQs"}]}`
|
||||
var response struct {
|
||||
Choices []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if status := post(t, server.URL+"/v1/chat/completions", faq, &response); status != http.StatusOK || len(response.Choices) != 1 || !strings.Contains(response.Choices[0].Message.Content, `"faqs"`) {
|
||||
t.Fatalf("faq status=%d choices=%d", status, len(response.Choices))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skill activation", func(t *testing.T) {
|
||||
request := `{"model":"gpt-5.6-luna","messages":[{"role":"system","content":"<available_skills_json>[{\"name\":\"local-skill\"}]</available_skills_json>"},{"role":"user","content":"[fixture:skill]"}],"tools":[{"type":"function","function":{"name":"activate_skill"}}]}`
|
||||
var response struct {
|
||||
Choices []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
status := post(t, server.URL+"/v1/chat/completions", request, &response)
|
||||
if status != http.StatusOK || len(response.Choices) != 1 || len(response.Choices[0].Message.ToolCalls) != 1 || response.Choices[0].Message.ToolCalls[0].Function.Name != "activate_skill" {
|
||||
t.Fatalf("status=%d choices=%d", status, len(response.Choices))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("embedding dimensions", func(t *testing.T) {
|
||||
var response struct {
|
||||
Data []struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
} `json:"data"`
|
||||
}
|
||||
status := post(t, server.URL+"/v1/embeddings", `{"model":"fixture-embedding","input":["hello"]}`, &response)
|
||||
if status != http.StatusOK || len(response.Data) != 1 || len(response.Data[0].Embedding) != 1536 {
|
||||
t.Fatalf("status=%d vectors=%d dimensions=%d", status, len(response.Data), len(response.Data[0].Embedding))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func post(t *testing.T, url, body string, dst any) int {
|
||||
t.Helper()
|
||||
resp, err := http.Post(url, "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if dst != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
Reference in New Issue
Block a user