feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+27
View File
@@ -0,0 +1,27 @@
package workflowbridge
import (
"data-terminal-cloud/internal/extraction"
"data-terminal-cloud/internal/memoir"
"time"
)
func NewArkService(secret, base, key string) (*Service, error) {
extractor, err := extraction.NewArkJSONGenerator(base, key, DefaultTextModel, 90*time.Second, 8192)
if err != nil {
return nil, err
}
editor, err := extraction.NewArkJSONGenerator(base, key, DefaultBookModel, 480*time.Second, 65536)
if err != nil {
return nil, err
}
s := New(secret, extractor, editor, memoir.NewIllustrator(base, key, DefaultImageModel))
s.EditorForModel = func(step, model string) (Editor, error) {
if step == "write-book" {
return extraction.NewArkJSONGenerator(base, key, model, 480*time.Second, 65536)
}
return extraction.NewArkJSONGenerator(base, key, model, 90*time.Second, 8192)
}
s.ImageForModel = func(model string) (ImageGenerator, error) { return memoir.NewIllustrator(base, key, model), nil }
return s, nil
}
+58
View File
@@ -0,0 +1,58 @@
package workflowbridge
import (
"errors"
"regexp"
"strings"
)
const DefaultTextModel = "deepseek-v4-1-flash-260910"
const DefaultBookModel = "doubao-seed-evolving"
const DefaultImageModel = "doubao-seedream-5-0-pro-260628"
var modelID = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`)
var errInvalidModel = errors.New("invalid model selection")
// Nodes carry only an Ark model/endpoint identifier. URL and credentials remain
// server configuration; choosing a model never silently falls back to another.
func selectedModel(step, model, custom string) (string, error) {
var fallback string
switch step {
case "classify", "daily":
fallback = DefaultTextModel
case "write-book":
fallback = DefaultBookModel
case "illustrate":
fallback = DefaultImageModel
default:
return "", nil
}
model = strings.TrimSpace(model)
if model == "__custom__" {
model = strings.TrimSpace(custom)
if model == "" {
return "", errInvalidModel
}
}
if model == "" {
if inCustom := strings.TrimSpace(custom); inCustom != "" {
return "", errInvalidModel
}
model = fallback
}
if !modelID.MatchString(model) || model == "__custom__" {
return "", errInvalidModel
}
if step == "illustrate" {
if !strings.HasPrefix(model, "doubao-seedream-") && !strings.HasPrefix(model, "ep-") {
return "", errInvalidModel
}
} else {
for _, modality := range []string{"seedream", "seedance", "embedding", "seed3d"} {
if strings.Contains(strings.ToLower(model), modality) {
return "", errInvalidModel
}
}
}
return model, nil
}
+58
View File
@@ -0,0 +1,58 @@
package workflowbridge
import (
"context"
"data-terminal-cloud/internal/dify"
"data-terminal-cloud/internal/extraction"
"net/http/httptest"
"testing"
)
func TestModelSelectionValidation(t *testing.T) {
for _, tc := range []struct {
step, model, custom, want string
invalid bool
}{
{"classify", "", "", DefaultTextModel, false},
{"write-book", "doubao-seed-2-1-pro-260915", "", "doubao-seed-2-1-pro-260915", false},
{"illustrate", "__custom__", "ep-test123", "ep-test123", false},
{"daily", "__custom__", "", "", true},
{"daily", "https://example.org", "", "", true},
{"write-book", DefaultImageModel, "", "", true},
{"illustrate", DefaultTextModel, "", "", true},
} {
got, err := selectedModel(tc.step, tc.model, tc.custom)
if got != tc.want || (err != nil) != tc.invalid {
t.Errorf("%+v: %q %v", tc, got, err)
}
}
}
func TestIndependentModelsAndRetryRetainsOriginalSelection(t *testing.T) {
e := &fixtureEditor{}
s := New("secret", e, e, overdueImages{})
selected := map[string]string{}
s.EditorForModel = func(step, model string) (Editor, error) { selected[step] = model; return e, nil }
srv := httptest.NewServer(s)
defer srv.Close()
b := dify.NewBridge(srv.URL, "secret")
ticket, err := b.Register(context.Background(), "extract", extraction.GenerationInput{Content: "[turn-1] 长者:1970年进厂。"})
if err != nil {
t.Fatal(err)
}
defer b.Forget(ticket)
c := s.find(ticket.ID)
out := s.step(c, "classify", stepInput{Model: "doubao-seed-2-1-lite-260915"})
if out["success"] != true {
t.Fatal(out)
}
s.step(c, "daily", stepInput{Model: DefaultTextModel})
retry := s.step(c, "classify", stepInput{Model: DefaultBookModel})
if retry["model"] != out["model"] || e.calls.Load() != 2 || selected["classify"] != "doubao-seed-2-1-lite-260915" || selected["daily"] != DefaultTextModel {
t.Fatalf("wrong selection: %v %v", selected, retry)
}
merged := s.step(c, "dedup", stepInput{})
if merged["success"] != true || c.Result.ModelRef != "dify:doubao-seed-2-1-lite-260915" {
t.Fatalf("actual model attribution lost: %v %q", merged, c.Result.ModelRef)
}
}
+506
View File
@@ -0,0 +1,506 @@
// Package workflowbridge executes private model steps for the Dify canvas.
// Elder speech and generated media live only in expiring memory, never in
// Dify's durable execution logs. The platform remains the authorized writer.
package workflowbridge
import (
"context"
"crypto/hmac"
"crypto/rand"
"data-terminal-cloud/internal/extraction"
"data-terminal-cloud/internal/memoir"
"encoding/hex"
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
type Editor interface {
GenerateJSON(context.Context, string, string) (json.RawMessage, error)
GenerateJSONSchema(context.Context, string, string, json.RawMessage) (json.RawMessage, error)
}
type ImageGenerator interface {
Generate(context.Context, string) ([]byte, error)
}
type Service struct {
Secret string
Extractor, BookEditor Editor
Illustrator ImageGenerator
EditorForModel func(step, model string) (Editor, error)
ImageForModel func(model string) (ImageGenerator, error)
mu sync.Mutex
contexts map[string]*job
imageBytes int
}
type job struct {
ID, Token, Kind string
Expires time.Time
ctx context.Context
cancel context.CancelFunc
Input extraction.GenerationInput
Sources []memoir.Source
mu sync.Mutex
Result extraction.GenerationResult
Document memoir.Document
Pictures map[int][]byte
imageBytes int
ErrorCode string
Diagnostic string
Overdue bool
completed map[string]map[string]any
flights map[string]chan struct{}
models map[string]string
}
type stepInput struct {
ID string `json:"context_id"`
Token string `json:"step_token"`
Kind string `json:"kind"`
Prompt string `json:"prompt"`
Index int `json:"chapter_index"`
Model string `json:"model"`
CustomModel string `json:"custom_model"`
}
func token() string { b := make([]byte, 24); _, _ = rand.Read(b); return hex.EncodeToString(b) }
func New(secret string, extractor, editor Editor, image ImageGenerator) *Service {
return &Service{Secret: secret, Extractor: extractor, BookEditor: editor, Illustrator: image, contexts: map[string]*job{}}
}
func (s *Service) authorized(r *http.Request) bool {
return s.Secret != "" && hmac.Equal([]byte(r.Header.Get("Authorization")), []byte("Bearer "+s.Secret))
}
func reply(w http.ResponseWriter, status int, out any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(out)
}
func (s *Service) create(kind string, raw json.RawMessage) (*job, error) {
if kind != "extract" && kind != "memoir" {
return nil, errors.New("invalid workflow kind")
}
c := &job{ID: token(), Token: token(), Kind: kind, Expires: time.Now().Add(20 * time.Minute), Pictures: map[int][]byte{}, completed: map[string]map[string]any{}, flights: map[string]chan struct{}{}}
if kind == "extract" {
if json.Unmarshal(raw, &c.Input) != nil || c.Input.Content == "" {
return nil, errors.New("invalid extraction input")
}
} else {
var in struct {
Stories []memoir.Source `json:"stories"`
}
if json.Unmarshal(raw, &in) != nil || len(in.Stories) == 0 {
return nil, errors.New("invalid book input")
}
c.Sources = in.Stories
}
c.ctx, c.cancel = context.WithTimeout(context.Background(), 20*time.Minute)
s.mu.Lock()
defer s.mu.Unlock()
for id, old := range s.contexts {
if time.Now().After(old.Expires) {
old.cancel()
s.imageBytes -= old.imageBytes
delete(s.contexts, id)
}
}
if len(s.contexts) >= 8 {
c.cancel()
return nil, errors.New("workflow capacity reached")
}
s.contexts[c.ID] = c
time.AfterFunc(time.Until(c.Expires), func() { s.forget(c.ID) })
return c, nil
}
func (s *Service) find(id string) *job {
s.mu.Lock()
defer s.mu.Unlock()
c := s.contexts[id]
if c != nil && time.Now().After(c.Expires) {
c.cancel()
delete(s.contexts, id)
s.imageBytes -= c.imageBytes
return nil
}
return c
}
func (s *Service) forget(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if c := s.contexts[id]; c != nil {
c.cancel()
delete(s.contexts, id)
s.imageBytes -= c.imageBytes
}
}
func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
reply(w, 200, map[string]bool{"ready": true})
return
}
if r.Method == "POST" && r.URL.Path == "/contexts" {
if !s.authorized(r) {
reply(w, 403, map[string]string{"error": "FORBIDDEN"})
return
}
var in struct {
Kind string `json:"kind"`
Input json.RawMessage `json:"input"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&in) != nil {
reply(w, 400, map[string]string{"error": "INVALID_INPUT"})
return
}
c, err := s.create(in.Kind, in.Input)
if err != nil {
reply(w, 400, map[string]string{"error": "INVALID_INPUT"})
return
}
reply(w, 200, map[string]string{"context_id": c.ID, "step_token": c.Token})
return
}
if strings.HasPrefix(r.URL.Path, "/contexts/") {
if !s.authorized(r) {
reply(w, 403, map[string]string{"error": "FORBIDDEN"})
return
}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
c := s.find(parts[1])
if c == nil {
reply(w, 404, map[string]string{"error": "CONTEXT_EXPIRED"})
return
}
if r.Method == "DELETE" && len(parts) == 2 {
s.forget(c.ID)
reply(w, 200, map[string]bool{"deleted": true})
return
}
c.mu.Lock()
defer c.mu.Unlock()
if r.Method == "GET" && len(parts) == 4 && parts[2] == "images" {
i, err := strconv.Atoi(parts[3])
b := c.Pictures[i]
if err != nil || len(b) == 0 {
w.WriteHeader(404)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(b)
return
}
if r.Method == "GET" && len(parts) == 2 {
doc := c.Document
// Empty event arrays are meaningful: a today-only conversation has no
// historical stories, and must survive the private JSON round trip.
raw, _ := json.Marshal(c.Result)
var result map[string]any
_ = json.Unmarshal(raw, &result)
result["stories"] = append([]extraction.EventStory{}, c.Result.Stories...)
result["daily_events"] = append([]extraction.DailyEvent{}, c.Result.DailyEvents...)
reply(w, 200, map[string]any{"result": result, "document": doc, "error_code": c.ErrorCode})
return
}
if r.Method == "GET" && len(parts) == 3 && parts[2] == "diagnostic" {
reply(w, 200, map[string]string{"model_json": c.Diagnostic})
return
}
}
if r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/steps/") {
var in stepInput
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&in) != nil {
reply(w, 400, map[string]string{"error": "INVALID_STEP"})
return
}
step := strings.TrimPrefix(r.URL.Path, "/steps/")
if step == "prepare" && in.ID == "" {
var raw json.RawMessage
if in.Kind == "extract" {
raw, _ = json.Marshal(extraction.GenerationInput{TenantID: "workflow-demo", ChatDate: time.Now().Format("2006-01-02"), Content: "[demo-turn] 长者:今天吃了早饭。1970年9月我进厂学做手套,师傅先教我剪线头。", PreviousStories: []extraction.PreviousStory{}})
} else {
raw, _ = json.Marshal(map[string]any{"stories": []memoir.Source{{ID: "demo-story", Version: 1, Title: "进厂第一天", Content: "1970年9月我进厂学做手套,师傅先教我剪线头。", EventTime: "1970年9月"}}})
}
c, err := s.create(in.Kind, raw)
if err != nil {
reply(w, 429, map[string]string{"error": "CAPACITY_REACHED"})
return
}
in.ID = c.ID
in.Token = c.Token
}
c := s.find(in.ID)
if c == nil || !hmac.Equal([]byte(in.Token), []byte(c.Token)) {
reply(w, 403, map[string]string{"error": "STEP_CONTEXT_UNAVAILABLE"})
return
}
if step == "prepare" {
reply(w, 200, map[string]any{"success": true, "context_id": c.ID, "step_token": c.Token, "kind": c.Kind})
return
}
out := s.step(c, step, in)
reply(w, 200, out)
return
}
reply(w, 404, map[string]string{"error": "NOT_FOUND"})
}
func (s *Service) step(c *job, step string, in stepInput) map[string]any {
key := step
if step == "illustrate" {
key += ":" + strconv.Itoa(in.Index)
}
for {
c.mu.Lock()
if out, ok := c.completed[key]; ok {
c.mu.Unlock()
return out
}
if flight := c.flights[key]; flight != nil {
c.mu.Unlock()
select {
case <-flight:
continue
case <-c.ctx.Done():
return map[string]any{"success": false, "error_code": "WORKFLOW_CANCELLED"}
}
}
flight := make(chan struct{})
c.flights[key] = flight
overdue := c.Overdue
c.mu.Unlock()
var out map[string]any
var err error
if overdue && (step == "classify" || step == "daily" || step == "write-book" || step == "illustrate") {
err = extraction.ErrModelAccountOverdue
} else {
out, err = s.execute(c, step, in)
}
c.mu.Lock()
if err != nil {
code := "MODEL_STEP_FAILED"
if errors.Is(err, errInvalidModel) {
code = "INVALID_MODEL_SELECTION"
}
if errors.Is(err, extraction.ErrModelAccountOverdue) {
code = "MODEL_ACCOUNT_OVERDUE"
c.Overdue = true
}
c.ErrorCode = code
out = map[string]any{"success": false, "error_code": code, "step": step}
if step == "illustrate" {
c.Document.IllustrationErrors++
out["chapter_index"] = in.Index
}
}
c.completed[key] = out
delete(c.flights, key)
close(flight)
c.mu.Unlock()
return out
}
}
func (s *Service) execute(c *job, step string, in stepInput) (map[string]any, error) {
model, err := selectedModel(step, in.Model, in.CustomModel)
if err != nil {
return nil, err
}
extractor, editor, image := s.Extractor, s.BookEditor, s.Illustrator
if model != "" {
if step == "illustrate" && s.ImageForModel != nil {
image, err = s.ImageForModel(model)
} else if s.EditorForModel != nil {
var selected Editor
selected, err = s.EditorForModel(step, model)
if step == "write-book" {
editor = selected
} else {
extractor = selected
}
}
if err != nil {
return nil, err
}
c.mu.Lock()
if c.models == nil {
c.models = map[string]string{}
}
c.models[step] = model
c.mu.Unlock()
}
classified, daily := extraction.WorkflowPrompts()
switch step {
case "classify":
if c.Kind != "extract" {
break
}
prompt := in.Prompt
if prompt == "" {
prompt = classified
}
raw, err := extractor.GenerateJSON(c.ctx, extraction.WorkflowContent(c.Input), prompt)
if err != nil {
return nil, err
}
var result extraction.GenerationResult
if json.Unmarshal(raw, &result) != nil || result.Stories == nil {
return nil, errors.New("invalid extraction result")
}
c.mu.Lock()
c.Result = result
c.mu.Unlock()
return map[string]any{"success": true, "model": model, "story_count": len(result.Stories), "daily_candidate_count": len(result.DailyEvents)}, nil
case "daily":
if c.Kind != "extract" {
break
}
prompt := in.Prompt
if prompt == "" {
prompt = daily
}
input, _ := json.Marshal(map[string]any{"chat_date": c.Input.ChatDate, "transcript": c.Input.Content})
raw, err := extractor.GenerateJSON(c.ctx, string(input), prompt)
if err != nil {
return nil, err
}
var result struct {
Events []extraction.DailyEvent `json:"daily_events"`
}
if json.Unmarshal(raw, &result) != nil || result.Events == nil {
return nil, errors.New("invalid daily result")
}
c.mu.Lock()
c.Result.DailyEvents = result.Events
c.mu.Unlock()
return map[string]any{"success": true, "model": model, "today_actual_count": len(result.Events)}, nil
case "dedup":
if c.Kind != "extract" {
break
}
c.mu.Lock()
defer c.mu.Unlock()
r, err := extraction.ValidateWorkflowResult(c.Input, c.Result, "dify:"+c.models["classify"])
if err != nil {
return nil, err
}
c.Result = r
created, updated, repeated := 0, 0, 0
for _, story := range r.Stories {
if story.MatchID == "" {
created++
} else if len(story.NewDetails) > 0 {
updated++
} else {
repeated++
}
}
return map[string]any{"success": true, "story_count": len(r.Stories), "daily_count": len(r.DailyEvents), "new_stories": created, "updated_stories": updated, "repeated_stories": repeated}, nil
case "write-book":
if c.Kind != "memoir" {
break
}
prompt := in.Prompt
if prompt == "" {
prompt = memoir.MemoirPrompt
}
input, _ := json.Marshal(map[string]any{"stories": c.Sources})
var doc memoir.Document
var err error
// Bound all attempts inside the HTTP node's 660-second read budget.
// Whole-book prose can take longer than a short chat classification.
stepCtx, cancel := context.WithTimeout(c.ctx, 600*time.Second)
defer cancel()
for attempt := 0; attempt < 3; attempt++ {
var raw json.RawMessage
raw, err = editor.GenerateJSONSchema(stepCtx, string(input), prompt, json.RawMessage(memoir.MemoirSchema))
if err != nil && len(raw) > 0 {
c.mu.Lock()
c.Diagnostic = string(raw)
c.mu.Unlock()
}
if errors.Is(err, extraction.ErrModelAccountOverdue) {
return nil, err
}
if err == nil {
err = json.Unmarshal(raw, &doc)
if err == nil {
err = memoir.ValidateDocument(doc, c.Sources)
}
if err == nil {
break
}
}
// Errors contain fixed categories or byte counts, never model prose.
log.Printf("workflow book attempt=%d error=%v", attempt+1, err)
if stepCtx.Err() != nil {
break
}
}
if err != nil {
return nil, err
}
ordered := make([]memoir.Chapter, 0, len(c.Sources))
for _, source := range c.Sources {
for _, chapter := range doc.Chapters {
if chapter.StoryID == source.ID {
ordered = append(ordered, chapter)
break
}
}
}
doc.Chapters = ordered
c.mu.Lock()
c.Document = doc
c.mu.Unlock()
indexes := make([]int, len(doc.Chapters))
for i := range indexes {
indexes[i] = i
}
return map[string]any{"success": true, "model": model, "chapter_count": len(indexes), "chapter_indexes": indexes}, nil
case "validate-book":
if c.Kind != "memoir" {
break
}
c.mu.Lock()
defer c.mu.Unlock()
if err := memoir.ValidateDocument(c.Document, c.Sources); err != nil {
return nil, err
}
return map[string]any{"success": true, "chapter_count": len(c.Document.Chapters), "all_stories_covered": true}, nil
case "illustrate":
if c.Kind != "memoir" {
break
}
c.mu.Lock()
if in.Index < 0 || in.Index >= len(c.Document.Chapters) {
c.mu.Unlock()
break
}
prompt := c.Document.Chapters[in.Index].IllustrationPrompt
c.mu.Unlock()
b, err := image.Generate(c.ctx, prompt)
if err != nil {
return nil, err
}
s.mu.Lock()
if s.contexts[c.ID] != c || s.imageBytes+len(b) > 192<<20 {
s.mu.Unlock()
return nil, errors.New("image memory limit")
}
s.imageBytes += len(b)
c.imageBytes += len(b)
s.mu.Unlock()
c.mu.Lock()
c.Pictures[in.Index] = b
c.mu.Unlock()
return map[string]any{"success": true, "model": model, "chapter_index": in.Index, "illustration_ready": true}, nil
case "finish":
c.mu.Lock()
defer c.mu.Unlock()
return map[string]any{"success": true, "story_count": len(c.Result.Stories), "chapter_count": len(c.Document.Chapters), "illustrations_ready": len(c.Pictures), "illustration_errors": c.Document.IllustrationErrors}, nil
}
return nil, errors.New("invalid step for workflow context")
}
+162
View File
@@ -0,0 +1,162 @@
package workflowbridge
import (
"bytes"
"context"
"data-terminal-cloud/internal/dify"
"data-terminal-cloud/internal/extraction"
"data-terminal-cloud/internal/memoir"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
)
type fixtureEditor struct {
calls atomic.Int32
input string
mu sync.Mutex
}
func (e *fixtureEditor) GenerateJSON(_ context.Context, input, prompt string) (json.RawMessage, error) {
e.calls.Add(1)
raw, _ := json.Marshal(extraction.GenerationResult{Meaningful: true, StoryWorthy: true, DecisionReason: "有原话支持的历史事件", Summary: "进厂往事", Keywords: []string{"进厂"}, Confidence: .9, Stories: []extraction.EventStory{{Title: "进厂", Content: "1970年进厂。", FactContent: "1970年进厂", EventTime: "1970年", SourceIDs: []string{"turn-1"}}}})
var out map[string]any
_ = json.Unmarshal(raw, &out)
out["daily_events"] = []any{}
return json.Marshal(out)
}
func (e *fixtureEditor) GenerateJSONSchema(_ context.Context, input, prompt string, schema json.RawMessage) (json.RawMessage, error) {
e.calls.Add(1)
e.mu.Lock()
e.input = input
e.mu.Unlock()
var in struct {
Stories []memoir.Source `json:"stories"`
}
_ = json.Unmarshal([]byte(input), &in)
d := memoir.Document{Title: "测试回忆录"}
for _, s := range in.Stories {
d.Chapters = append(d.Chapters, memoir.Chapter{StoryID: s.ID, EventTime: s.EventTime, Title: s.Title, Paragraphs: []string{s.Content}, IllustrationPrompt: "水彩情景插画"})
}
return json.Marshal(d)
}
type overdueImages struct{}
func (overdueImages) Generate(context.Context, string) ([]byte, error) {
return nil, extraction.ErrModelAccountOverdue
}
func postStep(t *testing.T, base, step string, ticket dify.Ticket, index int) map[string]any {
t.Helper()
raw, _ := json.Marshal(stepInput{ID: ticket.ID, Token: ticket.Token, Index: index})
r, err := http.Post(base+"/steps/"+step, "application/json", bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
defer r.Body.Close()
var out map[string]any
if json.NewDecoder(r.Body).Decode(&out) != nil {
t.Fatal("invalid step response")
}
return out
}
func TestStepsAreCachedAndNeverExposePrivateContent(t *testing.T) {
editor := &fixtureEditor{}
s := New("backend-secret", editor, editor, overdueImages{})
srv := httptest.NewServer(s)
defer srv.Close()
b := dify.NewBridge(srv.URL, "backend-secret")
ticket, err := b.Register(context.Background(), "extract", extraction.GenerationInput{Content: "[turn-1] 长者:1970年进厂。私密原话标记", PreviousStories: []extraction.PreviousStory{}})
if err != nil {
t.Fatal(err)
}
defer b.Forget(ticket)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out := postStep(t, srv.URL, "classify", ticket, 0)
raw, _ := json.Marshal(out)
if out["success"] != true || strings.Contains(string(raw), "私密原话") || strings.Contains(string(raw), "1970") {
t.Errorf("private content in metadata %s", raw)
}
}()
}
wg.Wait()
if editor.calls.Load() != 1 {
t.Fatalf("duplicate model calls %d", editor.calls.Load())
}
req, _ := http.NewRequest("GET", srv.URL+"/contexts/"+ticket.ID, nil)
req.Header.Set("Authorization", "Bearer "+ticket.Token)
resp, _ := http.DefaultClient.Do(req)
resp.Body.Close()
if resp.StatusCode != 403 {
t.Fatal("step token could read private results")
}
b.Forget(ticket)
if s.find(ticket.ID) != nil {
t.Fatal("deleted context survives")
}
}
func TestWholeCatalogAndBillingDuringImagesKeepText(t *testing.T) {
editor := &fixtureEditor{}
s := New("backend-secret", editor, editor, overdueImages{})
srv := httptest.NewServer(s)
defer srv.Close()
b := dify.NewBridge(srv.URL, "backend-secret")
sources := []memoir.Source{{ID: "one", Title: "进厂", Content: "1970年进厂。", EventTime: "1970年"}, {ID: "two", Title: "学徒", Content: "1971年学做手套。", EventTime: "1971年"}}
ticket, err := b.Register(context.Background(), "memoir", map[string]any{"stories": sources})
if err != nil {
t.Fatal(err)
}
defer b.Forget(ticket)
if postStep(t, srv.URL, "write-book", ticket, 0)["success"] != true {
t.Fatal("whole book failed")
}
if postStep(t, srv.URL, "validate-book", ticket, 0)["success"] != true {
t.Fatal("validation failed")
}
for i := range sources {
if postStep(t, srv.URL, "illustrate", ticket, i)["error_code"] != "MODEL_ACCOUNT_OVERDUE" {
t.Fatal("billing error not identified")
}
}
if postStep(t, srv.URL, "finish", ticket, 0)["success"] != true {
t.Fatal("image billing failure discarded text")
}
var result struct {
Document memoir.Document `json:"document"`
}
if b.Result(context.Background(), ticket, &result) != nil || len(result.Document.Chapters) != 2 || result.Document.IllustrationErrors != 2 {
t.Fatal("whole text unavailable")
}
if editor.calls.Load() != 1 || !strings.Contains(editor.input, "one") || !strings.Contains(editor.input, "two") {
t.Fatal("model did not receive whole catalog once")
}
}
func TestPrivateResultPreservesEmptyEventArrays(t *testing.T) {
s := New("backend-secret", nil, nil, nil)
srv := httptest.NewServer(s)
defer srv.Close()
b := dify.NewBridge(srv.URL, "backend-secret")
ticket, err := b.Register(context.Background(), "extract", extraction.GenerationInput{Content: "今天吃早饭。"})
if err != nil {
t.Fatal(err)
}
defer b.Forget(ticket)
var out struct {
Result extraction.GenerationResult `json:"result"`
}
if err = b.Result(context.Background(), ticket, &out); err != nil || out.Result.Stories == nil || out.Result.DailyEvents == nil {
t.Fatalf("empty arrays lost in private transport: %v", err)
}
}