feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user