Files
Cyrene_v2/internal/runtime/model/anthropic_encode.go
T
AskaEth 6b2b07cbef
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime): document 内容类型与持久化(design/24 S2)
- model 新增 ContentTypeDocument + Content 的四字段 + DocumentMessage 构造器 +
  ValidateDocumentParts(文档部件只允许 RoleUser,非 user ⇒ ErrDocumentOnNonUser,fail-loud)。
- 两个编码器入口(buildMessages / buildAnthropicMessages)对文档部件报 ErrBadRequest:
  本期 document 永不抵达编码器,且绝不静默丢块。
- history:拆出 types.go(Message 增 Document 列),新增 document.go(DocumentRef 同时是
  落库 JSON 与 DTO 形状,6 字段含 status ⇒ §4.4 的「读不到要可见」;坏列回 unreadable 不静默)。
- chat_messages 加 document 列(迁移 0015 + NewStore 幂等前滚);historyMessage.Document DTO;
  session_persist 的 messageTextImage → messageTextImageDocument(保留窄视图以免牵连 steer 配对)。
- 只落「引用 + 统计」,绝不落抽取正文(TestPersistedDocumentHoldsNoBody 把全部列拼起来扫正文)。

验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、
全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、18 条 Document* 测试 PASS。
变异独立复现:ValidateDocumentParts 开头 return nil ⇒ 非 user 负例红。
2026-10-04 17:07:24 +08:00

187 lines
6.0 KiB
Go

package model
import (
"fmt"
"strings"
)
// buildAnthropicMessages 把归一化消息列表拆成顶层的 system
// 字符串与其余按 Anthropic 内容块形式表示的消息。所有
// RoleSystem 消息拼接成单个 system 提示词(Anthropic
// 把它放在请求根而非 messages 里);没有内容片段的消息
// 会被丢弃,因为 Anthropic 拒绝空内容。挂在非用户回合上的
// 文档部件在这里就被拒绝(design/24 §2.3-③):文档本期永不
// 抵达编码器,漏网即编程错误,绝不静默丢块。
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
if err := ValidateDocumentParts(msgs); err != nil {
return "", nil, err
}
var sys strings.Builder
out := make([]anthropicMessage, 0, len(msgs))
for _, m := range msgs {
if m.Role == RoleSystem {
if t := messageText(m); t != "" {
if sys.Len() > 0 {
sys.WriteString("\n")
}
sys.WriteString(t)
}
continue
}
blocks, err := encodeAnthropicMessage(m)
if err != nil {
return "", nil, err
}
if len(blocks) == 0 {
continue
}
out = append(out, anthropicMessage{Role: anthropicRole(m.Role), Content: blocks})
}
return sys.String(), out, nil
}
// messageText 拼接消息中带文本的片段(system 提示词
// 与工具结果正文)。工具结果片段把正文放在 Text 里,
// 因此 RoleTool 回合的内容能正确序列化为 OpenAI 的 tool message。
func messageText(m Message) string {
var b strings.Builder
for _, part := range m.Content {
if part.Type == ContentTypeText || part.Type == ContentTypeToolResult {
b.WriteString(part.Text)
}
}
return b.String()
}
// anthropicRole 把归一化的 Role 映射到 Anthropic 的角色词汇。
// system 消息已被抽取到顶层 system 字段,不会走到这里。
func anthropicRole(r Role) string {
switch r {
case RoleAssistant:
return "assistant"
default:
// user、tool 以及未来任何角色都落在 user 回合上;
// tool_result 块(M2)在 Anthropic 里属于 user 角色。
return "user"
}
}
// encodeAnthropicMessage 把归一化消息转成 Anthropic 内容块。
// RoleTool 回合永远渲染为原生 tool_result 块
// (绝不是 user 纯文本回合),因为 Anthropic 要求工具结果以
// 携带 tool_use_id 与 is_error 标志的 tool_result 块形式到达。
func encodeAnthropicMessage(m Message) ([]any, error) {
if m.Role == RoleTool {
return encodeAnthropicToolResult(m)
}
return encodeAnthropicContent(m.Content)
}
// encodeAnthropicToolResult 把 RoleTool 回合渲染为 tool_result 块。
// 每个 ContentTypeToolResult 片段变成一个块;遗留的文本片段也会
// 被包成 tool_result 块,使该回合永远不会退化成普通 user 消息。
// 当片段自身没有携带 tool_use_id 时,回退到消息级的
// ToolCallID。
func encodeAnthropicToolResult(m Message) ([]any, error) {
blocks := make([]any, 0, len(m.Content))
for _, p := range m.Content {
switch p.Type {
case ContentTypeToolResult, ContentTypeText:
id := p.ToolCallID
if id == "" {
id = m.ToolCallID
}
blocks = append(blocks, AnthropicToolResult(id, p.Text, p.IsError))
}
}
if len(blocks) == 0 {
blocks = append(blocks, AnthropicToolResult(m.ToolCallID, "", false))
}
return blocks, nil
}
// encodeAnthropicContent 把归一化内容片段转成 Anthropic 内容块。
// 文本 / 图片 / tool_use(助手)片段在此处理;tool_result
// 片段由 encodeAnthropicToolResult 在消息级渲染。文档片段不走
// 这里:本期 DeepSeek 的 anthropic 兼容端点明确不支持 document
// 块,所以文档部件落到 default 分支被拒绝(design/24 §2.3-②,
// fail-loud 绝不静默丢块)。
func encodeAnthropicContent(parts []Content) ([]any, error) {
blocks := make([]any, 0, len(parts))
for _, p := range parts {
switch p.Type {
case ContentTypeText:
blocks = append(blocks, map[string]any{"type": "text", "text": p.Text})
case ContentTypeImage:
mediaType, data, err := parseAnthropicImageData(p.ImageURL)
if err != nil {
return nil, err
}
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": mediaType,
"data": data,
},
})
case ContentTypeToolCall:
args := p.ToolArgs
if args == nil {
args = map[string]any{}
}
blocks = append(blocks, map[string]any{
"type": "tool_use",
"id": p.ToolCallID,
"name": p.ToolCallName,
"input": args,
})
case ContentTypeToolResult:
blocks = append(blocks, AnthropicToolResult(p.ToolCallID, p.Text, p.IsError))
default:
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
}
}
return blocks, nil
}
// parseAnthropicImageData 从 data: URI 中提取媒体类型与 base64
// 负载。Anthropic 要求 base64 图片源、无法引用远程
// URL,因此其它任何 URI 形式都按错误请求拒绝。
func parseAnthropicImageData(uri string) (string, string, error) {
if !strings.HasPrefix(uri, anthropicDataPrefix) {
return "", "", fmt.Errorf("model: %w: anthropic image requires a data: URI, got %q", ErrBadRequest, uri)
}
rest := strings.TrimPrefix(uri, anthropicDataPrefix)
comma := strings.Index(rest, ",")
if comma < 0 {
return "", "", fmt.Errorf("model: %w: malformed data: URI %q", ErrBadRequest, uri)
}
meta := rest[:comma]
data := rest[comma+1:]
mediaType := meta
if idx := strings.Index(meta, ";"); idx >= 0 {
mediaType = meta[:idx]
}
if mediaType == "" {
return "", "", fmt.Errorf("model: %w: data: URI missing media type in %q", ErrBadRequest, uri)
}
return mediaType, data, nil
}
// buildAnthropicTools 把 ToolSpec schema 转换为 Anthropic 工具。
func buildAnthropicTools(tools []ToolSpec) []anthropicTool {
if len(tools) == 0 {
return nil
}
out := make([]anthropicTool, 0, len(tools))
for _, t := range tools {
out = append(out, anthropicTool{
Name: t.Name,
Description: t.Description,
InputSchema: t.Parameters,
})
}
return out
}