feat(multimodal): 视觉分层路由(多模态检测→主/视觉/OCR 工具)+ 语音 STT→TTS 降级链(异步)

This commit is contained in:
Aska committed 2026-09-06 05:12:36 +08:00
1 parent 02aae2bbb4
commit 0a936a3202
19 files changed
+2121

No files matched your search

@@ -0,0 +1,217 @@
package vision
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// visionMaxTokens caps the vision/OCR response length.
const visionMaxTokens = 512
// VisionAdapter understands an image and returns a description of it. It is the
// backend behind the vision tool; real providers implement it without exposing
// the image to the main-model context.
type VisionAdapter interface {
// Describe returns a description of img. prompt is an optional instruction
// overriding the default "describe this image" ask.
Describe(ctx context.Context, img ImageRef, prompt string) (string, error)
}
// OCRAdapter extracts text from an image. It is the backend behind the OCR tool.
type OCRAdapter interface {
// Extract returns the text found in img.
Extract(ctx context.Context, img ImageRef) (string, error)
}
// OpenAICompatibleAdapter implements both VisionAdapter and OCRAdapter by
// calling an OpenAI-compatible /chat/completions endpoint with the image
// embedded as a content part. It is the ready default implementation; real
// cloud vision/OCR providers expose the same protocol.
type OpenAICompatibleAdapter struct {
cfg config.LLMConfig
model string
client *http.Client
log *slog.Logger
}
// OpenAIOption customizes an OpenAICompatibleAdapter.
type OpenAIOption func(*OpenAICompatibleAdapter)
// WithModel overrides the adapter model (defaults to cfg.Model).
func WithModel(m string) OpenAIOption {
return func(a *OpenAICompatibleAdapter) {
if m != "" {
a.model = m
}
}
}
// WithHTTPClient sets the *http.Client used for the request; nil keeps default.
func WithHTTPClient(c *http.Client) OpenAIOption {
return func(a *OpenAICompatibleAdapter) {
if c != nil {
a.client = c
}
}
}
// WithLogger sets the logger used on failures; nil keeps a discard logger.
func WithLogger(l *slog.Logger) OpenAIOption {
return func(a *OpenAICompatibleAdapter) {
if l != nil {
a.log = l
}
}
}
// NewOpenAICompatible builds an adapter from cfg, using cfg.Model unless
// overridden.
func NewOpenAICompatible(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAICompatibleAdapter {
a := &OpenAICompatibleAdapter{
cfg: cfg,
model: cfg.Model,
client: defaultHTTPClient(),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
for _, o := range opts {
o(a)
}
return a
}
// Describe returns an image description from the underlying chat endpoint.
func (a *OpenAICompatibleAdapter) Describe(ctx context.Context, img ImageRef, prompt string) (string, error) {
if prompt == "" {
prompt = "Describe this image in detail."
}
return a.ask(ctx, img, prompt)
}
// Extract returns text found in the image via the same endpoint.
func (a *OpenAICompatibleAdapter) Extract(ctx context.Context, img ImageRef) (string, error) {
return a.ask(ctx, img, "Extract all text visible in this image. Return only the text.")
}
// ask resolves the image, builds a chat request and returns the assistant text.
func (a *OpenAICompatibleAdapter) ask(ctx context.Context, img ImageRef, prompt string) (string, error) {
if strings.TrimSpace(a.cfg.BaseURL) == "" {
return "", ErrNotConfigured
}
if strings.TrimSpace(a.model) == "" {
return "", ErrNotConfigured
}
if err := ctx.Err(); err != nil {
return "", err
}
imageURL, err := a.imageURL(ctx, img)
if err != nil {
return "", err
}
payload, err := json.Marshal(a.request(imageURL, prompt))
if err != nil {
return "", fmt.Errorf("vision: %w: marshal request: %v", ErrUpstream, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("vision: %w: build request: %v", ErrUpstream, err)
}
req.Header.Set("Content-Type", "application/json")
if a.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+a.cfg.APIKey)
}
resp, err := a.client.Do(req)
if err != nil {
return "", fmt.Errorf("vision: %w: %v", ErrUpstream, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxFetchedBody))
return "", fmt.Errorf("vision: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
return a.decode(resp.Body)
}
// imageURL converts an ImageRef to the image_url value the wire expects: an
// http(s) URL passes through, anything else becomes a base64 data: URI.
func (a *OpenAICompatibleAdapter) imageURL(ctx context.Context, img ImageRef) (string, error) {
if img.Source == SourceURL {
return img.Data, nil
}
mime, data, err := Resolve(ctx, img, a.client)
if err != nil {
return "", err
}
return DataURI(mime, data), nil
}
// openAIVisionRequest is the JSON body sent to /chat/completions.
type openAIVisionRequest struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
}
type openAIMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
type openAIPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *openAIImageURL `json:"image_url,omitempty"`
}
type openAIImageURL struct {
URL string `json:"url"`
}
type openAIVisionResponse struct {
Choices []openAIChoice `json:"choices"`
}
type openAIChoice struct {
Message openAIResponseMessage `json:"message"`
}
type openAIResponseMessage struct {
Content string `json:"content"`
}
// request builds the wire body for an image+prompt.
func (a *OpenAICompatibleAdapter) request(imageURL, prompt string) openAIVisionRequest {
return openAIVisionRequest{
Model: a.model,
Messages: []openAIMessage{
{Role: "user", Content: []openAIPart{
{Type: "text", Text: prompt},
{Type: "image_url", ImageURL: &openAIImageURL{URL: imageURL}},
}},
},
MaxTokens: visionMaxTokens,
}
}
// decode extracts the first choice's text from a response body.
func (a *OpenAICompatibleAdapter) decode(r io.Reader) (string, error) {
var wire openAIVisionResponse
if err := json.NewDecoder(r).Decode(&wire); err != nil {
return "", fmt.Errorf("vision: %w: decode response: %v", ErrUpstream, err)
}
if len(wire.Choices) == 0 {
return "", fmt.Errorf("vision: %w: empty choices", ErrUpstream)
}
return wire.Choices[0].Message.Content, nil
}
// interface assertion: one concrete adapter serves both directions.
var _ VisionAdapter = (*OpenAICompatibleAdapter)(nil)
var _ OCRAdapter = (*OpenAICompatibleAdapter)(nil)
@@ -0,0 +1,53 @@
package vision
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
func TestOpenAICompatibleAdapter_Describe(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Errorf("path = %s, want /chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
msgs, _ := body["messages"].([]any)
if len(msgs) != 1 {
t.Fatalf("messages = %d, want 1", len(msgs))
}
content, _ := msgs[0].(map[string]any)["content"].([]any)
if len(content) != 2 {
t.Fatalf("content parts = %d, want 2", len(content))
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"choices":[{"message":{"content":"a red car"}}]}`)
}))
defer srv.Close()
ad := NewOpenAICompatible(config.LLMConfig{BaseURL: srv.URL, Model: "vision-"})
text, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "")
if err != nil {
t.Fatalf("Describe error: %v", err)
}
if text != "a red car" {
t.Errorf("text = %q, want a red car", text)
}
}
func TestOpenAICompatibleAdapter_NotConfigured(t *testing.T) {
t.Parallel()
ad := NewOpenAICompatible(config.LLMConfig{})
if _, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, ""); err == nil {
t.Fatal("want error for empty base url, got nil")
}
}
@@ -0,0 +1,22 @@
package vision
import "errors"
// Sentinel errors returned by the vision package. Each is wrapped with %w on
// the way out so callers can test for a category while keeping the cause.
var (
// ErrNoImage is returned when an image source string is empty.
ErrNoImage = errors.New("vision: empty image source")
// ErrNotConfigured is returned by adapters when no endpoint/model is set.
ErrNotConfigured = errors.New("vision: adapter not configured")
// ErrUnsupportedSource is returned when an image source cannot be handled.
ErrUnsupportedSource = errors.New("vision: unsupported image source")
// ErrFetch is returned when a URL image cannot be downloaded.
ErrFetch = errors.New("vision: cannot fetch image")
// ErrUpstream is returned when the vision/OCR provider request fails.
ErrUpstream = errors.New("vision: upstream request failed")
// ErrBadImage is returned when an image cannot be read or decoded.
ErrBadImage = errors.New("vision: cannot read image")
// ErrBadArgs is returned by a tool when its arguments are malformed.
ErrBadArgs = errors.New("vision: bad tool arguments")
)
+178
View File
@@ -0,0 +1,178 @@
package vision
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"strings"
)
// Size caps for reading image bytes and response bodies, so a hostile or
// malformed source cannot exhaust memory. The 50MB cap matches the file-upload
// limit in DevDocs/02-development-constraints.md §11.
const (
maxImageBytes = 50 << 20
maxFetchedBody = 50 << 20
)
// DataURI builds a base64 data: image URI from mime and raw bytes.
func DataURI(mime string, data []byte) string {
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
}
// Resolve loads the bytes for an ImageRef. Paths and base64 payloads are read
// locally; URLs are fetched with an http.Client (the provided client, or a
// default one when nil). For base64 input the Data field is returned verbatim as
// the payload and the MIME type is guessed from a data: prefix.
func Resolve(ctx context.Context, ref ImageRef, client *http.Client) (mime string, data []byte, err error) {
switch ref.Source {
case SourcePath:
return readFile(ref.Data)
case SourceBase64:
return decodeBase64(ref.Data)
case SourceURL:
return fetchURL(ctx, ref.Data, client)
default:
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, ref.Source)
}
}
// readFile reads an image file and guesses its MIME type from the extension.
func readFile(path string) (string, []byte, error) {
f, err := os.Open(path)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(io.LimitReader(f, maxImageBytes))
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
}
return mimeByExt(path), data, nil
}
// decodeBase64 returns the decoded bytes of a base64/data: payload and its MIME.
func decodeBase64(src string) (string, []byte, error) {
mime, encoded, isDataURI := splitDataURI(src)
if isDataURI {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
}
return mime, decoded, nil
}
decoded, err := base64.StdEncoding.DecodeString(src)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
}
return "image/png", decoded, nil
}
// fetchURL downloads an image over http(s); only http/https schemes are allowed.
func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) {
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL)
}
if client == nil {
client = defaultHTTPClient()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrFetch, err)
}
resp, err := client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrFetch, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("vision: %w: status %d", ErrFetch, resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxFetchedBody))
if err != nil {
return "", nil, fmt.Errorf("vision: %w: %v", ErrFetch, err)
}
return mimeByContentType(resp.Header.Get("Content-Type")), data, nil
}
// splitDataURI parses a "data:<mime>;base64,<payload>" prefix. The second return
// is false when src is not a data: URI.
func splitDataURI(src string) (mime, payload string, ok bool) {
if !strings.HasPrefix(src, "data:") {
return "", "", false
}
rest := strings.TrimPrefix(src, "data:")
comma := strings.IndexByte(rest, ',')
if comma < 0 {
return "", "", false
}
meta := rest[:comma]
payload = rest[comma+1:]
mime = "image/png"
if strings.HasPrefix(meta, ";base64") {
return mime, payload, true
}
if idx := strings.Index(meta, ";base64"); idx >= 0 {
if t := strings.TrimSuffix(meta[:idx], ";base64"); t != "" {
mime = t
}
return mime, payload, true
}
return mime, payload, true
}
// mimeByExt guesses a MIME type from a file path extension.
func mimeByExt(path string) string {
switch strings.ToLower(pathExtension(path)) {
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
default:
return "image/png"
}
}
// mimeByContentType returns a MIME type from an HTTP Content-Type header,
// defaulting to image/png when absent or non-image.
func mimeByContentType(ct string) string {
if ct == "" || !strings.HasPrefix(ct, "image/") {
return "image/png"
}
return strings.TrimSpace(strings.Split(ct, ";")[0])
}
// pathExtension returns the file extension with its dot, or empty when none.
func pathExtension(path string) string {
base := path
if idx := strings.LastIndexByte(path, '/'); idx >= 0 {
base = path[idx+1:]
} else if idx := strings.LastIndexByte(path, '\\'); idx >= 0 {
base = path[idx+1:]
}
dot := strings.LastIndexByte(base, '.')
if dot < 0 {
return ""
}
return base[dot:]
}
// fileExists reports whether path exists on disk and is a regular file.
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}
// defaultHTTPClient builds the HTTP client described in
// DevDocs/02-development-constraints.md §2.3. A fresh client is returned per
// call rather than a package global, which the constraints forbid.
func defaultHTTPClient() *http.Client {
return &http.Client{Transport: http.DefaultTransport}
}
@@ -0,0 +1,128 @@
package vision
// Assessment is the model's own confidence in reading an image.
type Assessment string
const (
// AssessmentUnknown means the model reported no self-assessment.
AssessmentUnknown Assessment = "unknown"
// AssessmentGood means the model is confident in its read.
AssessmentGood Assessment = "good"
// AssessmentPoor means the model judged its recognition as poor.
AssessmentPoor Assessment = "poor"
)
// ModelInfo describes the main model's vision capability.
type ModelInfo struct {
// Name identifies the model.
Name string
// SupportsVision reports whether the model accepts image input.
SupportsVision bool
// SelfAssessment is the model's own confidence for the current image.
// It drives the layer-2 tool route when SupportsVision is true.
SelfAssessment Assessment
}
// DecisionKind is the chosen layered route.
type DecisionKind string
const (
// KindMain routes the image to the main model with the image in context.
KindMain DecisionKind = "main"
// KindVisionTool routes to a dedicated vision tool, returning only the tool
// result so the image never occupies main-model context.
KindVisionTool DecisionKind = "vision_tool"
// KindOCRTool routes to an OCR tool, returning only the extracted text.
KindOCRTool DecisionKind = "ocr_tool"
// KindUnsupported means no route can process the image.
KindUnsupported DecisionKind = "unsupported"
)
// Decision is the outcome of a routing call.
type Decision struct {
// Kind is the chosen route.
Kind DecisionKind
// Tool names the vision or OCR tool to invoke, set when Kind is a tool route.
Tool string
// Reason is a human-readable explanation for observability and logs.
Reason string
}
// RouteRequest carries routing hints from the caller on top of the model info.
type RouteRequest struct {
// TextPrimary reports that the user indicated the image is mainly text, so
// an OCR tool is preferred over a general vision model.
TextPrimary bool
// MainSelfAssessedPoor is set when the main model has already judged its own
// read of the image as poor, requesting a vision/OCR tool route.
MainSelfAssessedPoor bool
}
// RouterOption configures a Router.
type RouterOption func(*Router)
// WithVisionTool binds the name of the registered vision-understanding tool.
func WithVisionTool(name string) RouterOption {
return func(r *Router) {
if name != "" {
r.visionTool = name
}
}
}
// WithOCRTool binds the name of the registered OCR tool.
func WithOCRTool(name string) RouterOption {
return func(r *Router) {
if name != "" {
r.ocrTool = name
}
}
}
// Router implements the layered vision routing policy. It does no I/O; the
// caller uses the returned Decision to dispatch the request.
type Router struct {
visionTool string
ocrTool string
}
// NewRouter builds a Router. Unknown (empty) tool names leave the corresponding
// route unavailable, so the router degrades to KindMain or KindUnsupported.
func NewRouter(opts ...RouterOption) *Router {
r := &Router{}
for _, o := range opts {
o(r)
}
return r
}
// Route picks a layered route for an image request given the target model info
// and caller hints. The order is: the main model directly when it can see and
// has not self-assessed poorly (layer 1); otherwise a vision or OCR tool whose
// result replaces the image in context (layers 2 and 3); otherwise
// KindUnsupported when nothing can process the image.
func (r *Router) Route(target ModelInfo, req RouteRequest) Decision {
poor := target.SelfAssessment == AssessmentPoor || req.MainSelfAssessedPoor
// Layer 1: the main model accepts images and is confident, so use it first.
if target.SupportsVision && !poor {
return Decision{
Kind: KindMain,
Reason: "main model supports vision and is confident; use it directly",
}
}
// OCR is preferred when the user says the image is mostly text.
if req.TextPrimary && r.ocrTool != "" {
return Decision{Kind: KindOCRTool, Tool: r.ocrTool, Reason: "image is mainly text; route to OCR"}
}
if r.visionTool != "" {
return Decision{Kind: KindVisionTool, Tool: r.visionTool, Reason: "route to dedicated vision tool"}
}
if r.ocrTool != "" {
return Decision{Kind: KindOCRTool, Tool: r.ocrTool, Reason: "route to OCR tool"}
}
// No tool is bound. A sighted main model remains the only option.
if target.SupportsVision {
return Decision{Kind: KindMain, Reason: "no vision/OCR tool bound; keep image with main model"}
}
return Decision{Kind: KindUnsupported, Reason: "main model cannot see and no vision/OCR tool is bound"}
}
@@ -0,0 +1,178 @@
package vision
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
)
func TestRouter_Route(t *testing.T) {
t.Parallel()
cases := []struct {
name string
router *Router
target ModelInfo
req RouteRequest
want DecisionKind
tool string
}{
{
name: "sighted confident main -> main",
router: NewRouter(WithVisionTool("v"), WithOCRTool("o")),
target: ModelInfo{Name: "m", SupportsVision: true, SelfAssessment: AssessmentGood},
want: KindMain,
},
{
name: "poor self-assessment sighted main -> vision tool",
router: NewRouter(WithVisionTool("v"), WithOCRTool("o")),
target: ModelInfo{Name: "m", SupportsVision: true, SelfAssessment: AssessmentPoor},
req: RouteRequest{MainSelfAssessedPoor: true},
want: KindVisionTool,
tool: "v",
},
{
name: "poor self-assessment only OCR -> OCR tool",
router: NewRouter(WithOCRTool("o")),
target: ModelInfo{Name: "m", SupportsVision: true, SelfAssessment: AssessmentPoor},
want: KindOCRTool,
tool: "o",
},
{
name: "blind main with vision tool -> vision tool",
router: NewRouter(WithVisionTool("v")),
target: ModelInfo{Name: "m", SupportsVision: false},
want: KindVisionTool,
tool: "v",
},
{
name: "blind main text primary -> OCR tool",
router: NewRouter(WithVisionTool("v"), WithOCRTool("o")),
target: ModelInfo{Name: "m", SupportsVision: false},
req: RouteRequest{TextPrimary: true},
want: KindOCRTool,
tool: "o",
},
{
name: "blind main only OCR non-text -> OCR fallback",
router: NewRouter(WithOCRTool("o")),
target: ModelInfo{Name: "m", SupportsVision: false},
want: KindOCRTool,
tool: "o",
},
{
name: "blind main no tools -> unsupported",
router: NewRouter(),
target: ModelInfo{Name: "m", SupportsVision: false},
want: KindUnsupported,
},
{
name: "sighted main no tools -> main",
router: NewRouter(),
target: ModelInfo{Name: "m", SupportsVision: true, SelfAssessment: AssessmentGood},
want: KindMain,
},
{
name: "sighted main poor no tools -> main fallback",
router: NewRouter(),
target: ModelInfo{Name: "m", SupportsVision: true, SelfAssessment: AssessmentPoor},
want: KindMain,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := tc.router.Route(tc.target, tc.req)
if got.Kind != tc.want {
t.Fatalf("Kind = %q, want %q (reason: %s)", got.Kind, tc.want, got.Reason)
}
if tc.tool != "" && got.Tool != tc.tool {
t.Errorf("Tool = %q, want %q", got.Tool, tc.tool)
}
if got.Reason == "" {
t.Error("Reason must be non-empty")
}
})
}
}
// fakeVision implements VisionAdapter for tool tests.
type fakeVision struct{ desc string }
func (f *fakeVision) Describe(_ context.Context, _ ImageRef, _ string) (string, error) {
return f.desc, nil
}
// fakeOCR implements OCRAdapter for tool tests.
type fakeOCR struct{ text string }
func (f *fakeOCR) Extract(_ context.Context, _ ImageRef) (string, error) {
return f.text, nil
}
func TestRegisterTools(t *testing.T) {
t.Parallel()
reg := registry.New()
v := NewVisionTool("vision_understand", &fakeVision{desc: "a cat"})
o := NewOCRTool("ocr_extract", &fakeOCR{text: "HELLO"})
if err := RegisterTools(reg, v, o); err != nil {
t.Fatalf("RegisterTools error: %v", err)
}
if capv, err := reg.Get("vision_understand"); err != nil || capv.Kind != registry.KindTool {
t.Fatalf("Get(vision_understand) = %+v, %v", capv, err)
}
if capo, err := reg.Get("ocr_extract"); err != nil || capo.Kind != registry.KindTool {
t.Fatalf("Get(ocr_extract) = %+v, %v", capo, err)
}
if len(reg.List(registry.KindTool)) != 2 {
t.Errorf("List(tool) = %d, want 2", len(reg.List(registry.KindTool)))
}
}
func TestRegisterTools_NilRegistrar(t *testing.T) {
t.Parallel()
if err := RegisterTools(nil, nil, nil); err == nil {
t.Fatal("want error for nil registrar, got nil")
}
}
func TestVisionTool_Execute(t *testing.T) {
t.Parallel()
v := NewVisionTool("vision_understand", &fakeVision{desc: "a cat"})
res, err := v.Execute(context.Background(), map[string]any{
"image": "data:image/png;base64,aGVsbG8=",
"prompt": "what is this",
})
if err != nil {
t.Fatalf("Execute error: %v", err)
}
m, ok := res.(map[string]any)
if !ok || m["description"] != "a cat" {
t.Errorf("result = %v, want description=a cat", res)
}
}
func TestVisionTool_Execute_BadArgs(t *testing.T) {
t.Parallel()
v := NewVisionTool("vision_understand", &fakeVision{desc: ""})
if _, err := v.Execute(context.Background(), map[string]any{"prompt": "x"}); err == nil {
t.Fatal("want error for missing image, got nil")
}
if _, err := v.Execute(context.Background(), "not-a-map"); err == nil {
t.Fatal("want error for non-map args, got nil")
}
}
func TestOCRTool_Execute(t *testing.T) {
t.Parallel()
o := NewOCRTool("ocr_extract", &fakeOCR{text: "HELLO"})
res, err := o.Execute(context.Background(), map[string]any{"image": "aGVsbG8="})
if err != nil {
t.Fatalf("Execute error: %v", err)
}
m, ok := res.(map[string]any)
if !ok || m["text"] != "HELLO" {
t.Errorf("result = %v, want text=HELLO", res)
}
}
+148
View File
@@ -0,0 +1,148 @@
package vision
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// toolParameters returns the shared input JSON Schema for the vision and OCR
// tools. It builds a fresh map each call rather than sharing a package-level
// variable.
func toolParameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"image": map[string]any{
"type": "string",
"description": "The image: a local path, an http(s) URL, or a base64/data: URI.",
},
"prompt": map[string]any{
"type": "string",
"description": "Optional instruction for the vision model (vision tool only).",
},
},
"required": []any{"image"},
}
}
// VisionTool wraps a VisionAdapter as an sdk.Tool so the M2 tool loop can invoke
// it and return its description as a tool result, keeping the image out of the
// main-model context. It is safe for concurrent use.
type VisionTool struct {
name string
adapter VisionAdapter
}
// NewVisionTool builds a VisionTool with the given name and adapter.
func NewVisionTool(name string, adapter VisionAdapter) *VisionTool {
return &VisionTool{name: name, adapter: adapter}
}
// Name returns the tool identifier the agent uses to invoke it.
func (t *VisionTool) Name() string { return t.name }
// Description explains what the tool does.
func (t *VisionTool) Description() string {
return "Understand an image and return a description of its contents."
}
// Parameters returns the input JSON Schema.
func (t *VisionTool) Parameters() (map[string]any, error) {
return toolParameters(), nil
}
// Execute runs the adapter on the image from args and returns the description.
func (t *VisionTool) Execute(ctx context.Context, args any) (any, error) {
image, prompt, err := toolArgs(args)
if err != nil {
return nil, err
}
ref, err := ParseImageRef(image)
if err != nil {
return nil, err
}
desc, err := t.adapter.Describe(ctx, ref, prompt)
if err != nil {
return nil, err
}
return map[string]any{"description": desc}, nil
}
// OCRTool wraps an OCRAdapter as an sdk.Tool. It returns the extracted text as a
// tool result. It is safe for concurrent use.
type OCRTool struct {
name string
adapter OCRAdapter
}
// NewOCRTool builds an OCRTool with the given name and adapter.
func NewOCRTool(name string, adapter OCRAdapter) *OCRTool {
return &OCRTool{name: name, adapter: adapter}
}
// Name returns the tool identifier.
func (t *OCRTool) Name() string { return t.name }
// Description explains what the tool does.
func (t *OCRTool) Description() string {
return "Extract text from an image (OCR) and return the recognized text."
}
// Parameters returns the input JSON Schema.
func (t *OCRTool) Parameters() (map[string]any, error) {
return toolParameters(), nil
}
// Execute runs the OCR adapter on the image from args and returns the text.
func (t *OCRTool) Execute(ctx context.Context, args any) (any, error) {
image, _, err := toolArgs(args)
if err != nil {
return nil, err
}
ref, err := ParseImageRef(image)
if err != nil {
return nil, err
}
text, err := t.adapter.Extract(ctx, ref)
if err != nil {
return nil, err
}
return map[string]any{"text": text}, nil
}
// RegisterTools registers the vision and OCR tools with r via the SDK helper.
// Each tool is only registered when its pointer is non-nil. It returns the
// first registration error, or nil when every tool registered.
func RegisterTools(r sdk.Registrar, tool *VisionTool, ocr *OCRTool) error {
if r == nil {
return sdk.ErrNilRegistrar
}
if tool != nil {
if err := sdk.RegisterTool(r, tool); err != nil {
return fmt.Errorf("vision: register tool %q: %w", tool.Name(), err)
}
}
if ocr != nil {
if err := sdk.RegisterTool(r, ocr); err != nil {
return fmt.Errorf("vision: register tool %q: %w", ocr.Name(), err)
}
}
return nil
}
// toolArgs decodes sdk.Tool.Execute args into the image string and an optional
// prompt. Args must be a map[string]any with a non-empty "image" string.
func toolArgs(args any) (image, prompt string, err error) {
m, ok := args.(map[string]any)
if !ok {
return "", "", fmt.Errorf("vision: %w: args must be an object", ErrBadArgs)
}
image, _ = m["image"].(string)
if image == "" {
return "", "", fmt.Errorf("vision: %w: missing image", ErrBadArgs)
}
prompt, _ = m["prompt"].(string)
return image, prompt, nil
}
@@ -0,0 +1,79 @@
// Package vision implements the layered image routing described in
// DevDocs/design/06-多模态.md §0.
//
// A request that carries image content is first checked by DetectMultimodal.
// The VisionRouter then picks one of the layered routes: the main model
// directly (when it accepts images), a tool-shaped vision/OCR adapter (when the
// main model self-assesses poorly or cannot see), or the OCR tool (when the
// image is mainly text). Vision/OCR adapters are registered as sdk.Tool
// capabilities through the capability registry, and their output is a tool
// result, so the image itself never occupies main-model context.
//
// Images may come from a local path, an http(s) URL, or a base64/data: URI.
package vision
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// DetectMultimodal reports whether msgs contains any non-text content part,
// i.e. whether multimodal handling is required. Today model only distinguishes
// text from image content, so this is true exactly when a message carries an
// image part; the check is written against "not text" so future content types
// (audio) extend it without rework.
func DetectMultimodal(msgs []model.Message) bool {
for _, m := range msgs {
for _, part := range m.Content {
if part.Type != model.ContentTypeText {
return true
}
}
}
return false
}
// ImageSource classifies where an image's bytes come from.
type ImageSource string
const (
// SourcePath is a local filesystem path.
SourcePath ImageSource = "path"
// SourceURL is an http(s):// URL.
SourceURL ImageSource = "url"
// SourceBase64 is a base64 payload, optionally wrapped in a data: URI.
SourceBase64 ImageSource = "base64"
)
// ImageRef is a single image reference plus its resolved source kind.
type ImageRef struct {
// Source is the classified source kind.
Source ImageSource
// Data is the local path, URL, or base64/data: payload.
Data string
}
// ParseImageRef classifies a raw image source string into an ImageRef. Empty
// input is rejected. Local paths that exist on disk are classified as paths;
// http(s) URLs as URLs; every other value, including a data: URI, is treated as
// a base64 payload. This keeps routing deterministic without guessing MIME.
func ParseImageRef(src string) (ImageRef, error) {
if src == "" {
return ImageRef{}, ErrNoImage
}
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
return ImageRef{Source: SourceURL, Data: src}, nil
}
if strings.HasPrefix(src, "file://") {
return ImageRef{Source: SourcePath, Data: strings.TrimPrefix(src, "file://")}, nil
}
// A data: URI is base64 already; treat as base64, not path.
if strings.HasPrefix(src, "data:") {
return ImageRef{Source: SourceBase64, Data: src}, nil
}
if fileExists(src) {
return ImageRef{Source: SourcePath, Data: src}, nil
}
return ImageRef{Source: SourceBase64, Data: src}, nil
}
@@ -0,0 +1,95 @@
package vision
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
func TestDetectMultimodal(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msgs []model.Message
want bool
}{
{name: "text only", msgs: []model.Message{model.TextMessage(model.RoleUser, "hi")}, want: false},
{name: "empty", msgs: nil, want: false},
{name: "single image", msgs: []model.Message{model.ImageMessage(model.RoleUser, "what", "data:image/png;base64,abc")}, want: true},
{name: "image no text", msgs: []model.Message{model.ImageMessage(model.RoleUser, "", "https://x/y.png")}, want: true},
{name: "image in later turn", msgs: []model.Message{
model.TextMessage(model.RoleUser, "hi"),
model.ImageMessage(model.RoleAssistant, "", "http://x/z.png"),
}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := DetectMultimodal(tt.msgs); got != tt.want {
t.Errorf("DetectMultimodal() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseImageRef(t *testing.T) {
t.Parallel()
tests := []struct {
name string
src string
wantSource ImageSource
}{
{name: "http url", src: "https://a/b.png", wantSource: SourceURL},
{name: "https url", src: "https://a/b.png", wantSource: SourceURL},
{name: "file uri", src: "file:///tmp/a.png", wantSource: SourcePath},
{name: "data uri", src: "data:image/png;base64,abc", wantSource: SourceBase64},
{name: "raw base64", src: "aGVsbG8=", wantSource: SourceBase64},
{name: "empty rejected", src: "", wantSource: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ref, err := ParseImageRef(tt.src)
if tt.src == "" {
if err == nil {
t.Fatal("want error for empty source, got nil")
}
return
}
if err != nil {
t.Fatalf("ParseImageRef error: %v", err)
}
if ref.Source != tt.wantSource {
t.Errorf("Source = %q, want %q", ref.Source, tt.wantSource)
}
})
}
}
func TestResolve_Base64(t *testing.T) {
t.Parallel()
ref, err := ParseImageRef("data:image/png;base64,aGVsbG8=")
if err != nil {
t.Fatalf("ParseImageRef error: %v", err)
}
mime, data, err := Resolve(context.Background(), ref, nil)
if err != nil {
t.Fatalf("Resolve error: %v", err)
}
if mime != "image/png" {
t.Errorf("mime = %q, want image/png", mime)
}
if string(data) != "hello" {
t.Errorf("data = %q, want hello", data)
}
}
func TestResolve_DataURI(t *testing.T) {
t.Parallel()
got := DataURI("image/webp", []byte{1, 2, 3})
if !strings.HasPrefix(got, "data:image/webp;base64,") {
t.Errorf("DataURI = %q, want data:image/webp;base64,", got)
}
}