feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。 - ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线) - ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3) - ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId - Docs/api-client 版本 +1 - 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
This commit is contained in:
1 parent
3732c538cc
commit
5969bba3bd
22 files changed
+1396
-23
No files matched your search
@@ -0,0 +1,108 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// This file covers the admin half of the channel-level role binding (design/22 §2
|
||||
// 方案 b / §4.4): PUT /admin/channels/{platform}/config accepts personaId through the
|
||||
// existing read-modify-write + bridge.Reload path, an unknown role is refused
|
||||
// instead of binding a role that cannot answer, and the list endpoint reports the
|
||||
// binding back.
|
||||
|
||||
// TestAdminSaveChannelConfigPersonaID saves a channel role, asserts it reached
|
||||
// channels.json and the list wire shape, then clears it.
|
||||
func TestAdminSaveChannelConfigPersonaID(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
|
||||
map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "yuki"}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
accounts, warn := runtimeconfig.LoadChannels(path, nil)
|
||||
if warn != nil {
|
||||
t.Fatalf("load channels warn = %v", warn)
|
||||
}
|
||||
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "yuki" {
|
||||
t.Fatalf("channels.json telegram = %+v, want personaId yuki", accounts["telegram"])
|
||||
}
|
||||
if len(store.reloaded) != 1 || store.reloaded[0] != "telegram" {
|
||||
t.Fatalf("reloaded = %v, want [telegram] (the panel's hot-reload path)", store.reloaded)
|
||||
}
|
||||
|
||||
// The list endpoint reports the binding so the panel can render it.
|
||||
var listed []struct {
|
||||
Name string `json:"name"`
|
||||
Accounts []struct {
|
||||
ID string `json:"id"`
|
||||
PersonaID string `json:"personaId"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
_, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
|
||||
if err := json.Unmarshal(decodeBody(t, gBody)["data"], &listed); err != nil {
|
||||
t.Fatalf("decode channels list: %v (body=%s)", err, gBody)
|
||||
}
|
||||
var accts []struct {
|
||||
ID string `json:"id"`
|
||||
PersonaID string `json:"personaId"`
|
||||
}
|
||||
for _, entry := range listed {
|
||||
if entry.Name == "telegram" {
|
||||
accts = entry.Accounts
|
||||
}
|
||||
}
|
||||
if len(accts) != 1 || accts[0].PersonaID != "yuki" {
|
||||
t.Fatalf("GET /admin/channels telegram accounts = %+v, want personaId yuki (body=%s)", accts, gBody)
|
||||
}
|
||||
|
||||
// Saving the same account without a personaId unbinds it.
|
||||
resp, body = doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
|
||||
map[string]any{"id": "conf0", "token": "bot-tok"}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("unbind status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
accounts, _ = runtimeconfig.LoadChannels(path, nil)
|
||||
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "" {
|
||||
t.Fatalf("after unbind = %+v, want personaId cleared", accounts["telegram"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminSaveChannelConfigRejectsUnknownPersonaID keeps the save fail-closed: a
|
||||
// channel bound to a role the library does not know would silently answer as the
|
||||
// default role, so it is refused and nothing is written or reloaded.
|
||||
func TestAdminSaveChannelConfigRejectsUnknownPersonaID(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
|
||||
map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "nobody"}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "personaId") {
|
||||
t.Fatalf("rejection body = %s, want it to name personaId", body)
|
||||
}
|
||||
if len(store.reloaded) != 0 {
|
||||
t.Fatalf("reloaded = %v, want none for a refused save", store.reloaded)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("a refused save must not write channels.json (stat err = %v)", err)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ type AdminChannelAccount struct {
|
||||
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
|
||||
MsgPerMin int `json:"msgPerMin,omitempty"`
|
||||
MaxMessages int `json:"maxMessages,omitempty"`
|
||||
// PersonaID is the role this account answers as (design/22 §2 方案 b), omitted
|
||||
// when the account binds none. It is a persona id, not a secret.
|
||||
PersonaID string `json:"personaId,omitempty"`
|
||||
// BotID/BotUsername/BotNickname are the connected bot's resolved identity
|
||||
// (kook fetches it from /api/v3/user/me on connect). They come from the live
|
||||
// adapter, not the persisted channels.json config, and are non-sensitive (the
|
||||
@@ -72,6 +75,11 @@ type channelConfigRequest struct {
|
||||
GroupMsgPerMin int `json:"groupMsgPerMin"`
|
||||
MsgPerMin int `json:"msgPerMin"`
|
||||
MaxMessages int `json:"maxMessages"`
|
||||
// PersonaID is the role this account answers as (design/22 §2 方案 b). An empty
|
||||
// value leaves the account unbound (the session binding or the global default
|
||||
// applies); an unknown id is refused with 400 so a save can never bind a role
|
||||
// that cannot answer.
|
||||
PersonaID string `json:"personaId"`
|
||||
}
|
||||
|
||||
// adminSaveChannelConfig persists one account's config for a platform into
|
||||
@@ -95,6 +103,13 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
|
||||
return
|
||||
}
|
||||
// A channel-level role must exist in the persona library, or the account would
|
||||
// be bound to a role that cannot answer (design/22 §2 方案 b). An omitted/empty
|
||||
// personaId is always accepted: it means "no channel binding".
|
||||
if !s.requireKnownPersona(w, req.PersonaID) {
|
||||
s.auditClaims(r, "channel.save", "error")
|
||||
return
|
||||
}
|
||||
cfg := channelConfigFromRequest(req)
|
||||
// weixin-ilink may be saved with an empty config: the panel creates the account
|
||||
// first, then the user scans the QR (get_bot_qrcode) which fills bot_token/bot_id/
|
||||
@@ -116,6 +131,7 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
|
||||
GroupMsgPerMin: req.GroupMsgPerMin,
|
||||
MsgPerMin: req.MsgPerMin,
|
||||
MaxMessages: req.MaxMessages,
|
||||
PersonaID: strings.TrimSpace(req.PersonaID),
|
||||
})
|
||||
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
|
||||
s.auditClaims(r, "channel.save", "error")
|
||||
@@ -253,6 +269,7 @@ func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount {
|
||||
GroupMsgPerMin: acct.GroupMsgPerMin,
|
||||
MsgPerMin: acct.MsgPerMin,
|
||||
MaxMessages: acct.MaxMessages,
|
||||
PersonaID: acct.PersonaID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file covers the end-to-end half of design/22 §7-5: a session that switched
|
||||
// roles must keep the conversation it had, and the model must be able to tell
|
||||
// which utterances came from the earlier role. The chain asserted here is:
|
||||
// chat_messages (per-message persona_id) → the orchestrator's seeded turn context
|
||||
// → the loop's speaker attribution → the model.Request the adapter receives.
|
||||
|
||||
// requestTexts returns the text of every message of req, in order.
|
||||
func requestTexts(req model.Request) []string {
|
||||
out := make([]string, 0, len(req.Messages))
|
||||
for _, m := range req.Messages {
|
||||
out = append(out, messageTextOf(m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// messageTextOf concatenates the text parts of m.
|
||||
func messageTextOf(m model.Message) string {
|
||||
var b strings.Builder
|
||||
for _, c := range m.Content {
|
||||
if c.Type == model.ContentTypeText {
|
||||
b.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestChatTurnCarriesSpeakerAttributionFromTranscript is design/22 §7-5 accepted
|
||||
// from the process boundary: the session already holds turns spoken by role X, it
|
||||
// is now bound to role Y, and one more chat turn arrives. The request must carry
|
||||
// X's earlier utterance with its speaker attributed to X (not to Y), keep every
|
||||
// stored body verbatim, and still open with Y's own instructions.
|
||||
func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st := personaTestStore(t)
|
||||
reg := personaTestRegistry(t)
|
||||
sub, err := st.NewSubSessionFor(ctx, "owner", "yuki")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSubSessionFor: %v", err)
|
||||
}
|
||||
// The conversation as it stood while the session spoke as X, with per-message
|
||||
// persona ids exactly as the orchestrator persists them (design/22 §7-5).
|
||||
for _, m := range []history.Message{
|
||||
{SessionID: sub, Role: history.RoleUser, Content: "你还记得吗", PersonaID: "xilian", CreatedAt: 1000},
|
||||
{SessionID: sub, Role: history.RoleAssistant, Content: "我记住了,你喜欢冰美式", PersonaID: "xilian", CreatedAt: 1001},
|
||||
} {
|
||||
if err := st.Append(ctx, m); err != nil {
|
||||
t.Fatalf("seed transcript: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ad := &personaAdapter{}
|
||||
xilian := gatewayAgentFor(t, "你是昔涟。", ad)
|
||||
yuki := gatewayAgentFor(t, "你是由纪。", ad)
|
||||
names := map[string]string{"xilian": "昔涟", "yuki": "由纪"}
|
||||
l := loop.New(loop.Options{
|
||||
Agent: xilian,
|
||||
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
|
||||
PersonaID: "xilian",
|
||||
PersonaName: func(id string) string { return names[id] },
|
||||
})
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: st, Personas: reg, Loop: l})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{"sessionId": sub, "text": "继续"}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if len(ad.reqs) == 0 {
|
||||
t.Fatal("the model adapter saw no request")
|
||||
}
|
||||
req := ad.reqs[len(ad.reqs)-1]
|
||||
|
||||
// The answering role still owns the head of the prompt.
|
||||
if got := firstSystemText(req); got != "你是由纪。" {
|
||||
t.Fatalf("first system = %q, want role Y's prompt", got)
|
||||
}
|
||||
|
||||
// X's earlier utterance reached the model, verbatim (the turn is not amnesiac
|
||||
// across the role switch).
|
||||
texts := requestTexts(req)
|
||||
xIdx := -1
|
||||
for i, txt := range texts {
|
||||
if txt == "我记住了,你喜欢冰美式" {
|
||||
xIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if xIdx < 0 {
|
||||
t.Fatalf("X's earlier utterance missing from the request: %q", texts)
|
||||
}
|
||||
if xIdx == 0 || req.Messages[xIdx].Role == model.RoleSystem {
|
||||
t.Fatalf("X's utterance must stay a plain assistant message, got %+v", req.Messages[xIdx])
|
||||
}
|
||||
if req.Messages[xIdx].SenderName != "xilian" {
|
||||
t.Fatalf("speaker label = %q, want xilian (the role that actually spoke it)", req.Messages[xIdx].SenderName)
|
||||
}
|
||||
|
||||
// The speaker boundary names X and marks the stretch as not Y's own words.
|
||||
note := ""
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == model.RoleSystem && strings.Contains(messageTextOf(m), "「昔涟」(xilian)") {
|
||||
note = messageTextOf(m)
|
||||
break
|
||||
}
|
||||
}
|
||||
if note == "" || !strings.Contains(note, "不是你说的") {
|
||||
t.Fatalf("no speaker boundary for X found: %q", texts)
|
||||
}
|
||||
|
||||
// No stored body was rewritten, and the newly sent user text carries no system
|
||||
// metadata (the user lane stays free text, design/11 §10).
|
||||
for _, m := range req.Messages {
|
||||
if m.Role != model.RoleUser {
|
||||
continue
|
||||
}
|
||||
if txt := messageTextOf(m); txt != "继续" && txt != "你还记得吗" {
|
||||
t.Fatalf("user body = %q, want a stored/verbatim user text", txt)
|
||||
}
|
||||
}
|
||||
if xIdx+1 <= len(req.Messages) && strings.Contains(req.Messages[xIdx].Content[0].Text, "[") {
|
||||
t.Fatalf("attribution leaked into X's body: %q", req.Messages[xIdx].Content[0].Text)
|
||||
}
|
||||
}
|
||||
@@ -243,16 +243,27 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
st.gen++
|
||||
gen := st.gen
|
||||
st.mode = ModeThinking
|
||||
st.tail = o.boundedTail(messages)
|
||||
// A fresh turn continues the session's conversation: the persisted transcript
|
||||
// is seeded in front of the just-sent messages (design/15 §2.2/§4, design/22
|
||||
// §7-5), and its assistant utterances keep the role that spoke them. The read
|
||||
// happens here, before persistUserMessages below writes the new message, so the
|
||||
// message just sent is never duplicated in the context. A store without history
|
||||
// (or an empty session) leaves the turn on the caller's messages alone. It is a
|
||||
// bounded indexed read (transcriptContextCount rows) under the state lock, which
|
||||
// no running turn ever holds, so it cannot block a Submit behind a turn.
|
||||
turn := o.startTurnMessages(ctx, sessionID, messages)
|
||||
st.tail = o.boundedTail(turn)
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
st.cancel = cancel
|
||||
o.mu.Unlock()
|
||||
|
||||
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
|
||||
// Only the messages that arrived now are persisted: the seeded transcript is
|
||||
// already durable, and re-appending it would duplicate the whole conversation.
|
||||
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
|
||||
|
||||
if sync {
|
||||
res, err := o.run(runCtx, sessionID, messages, env)
|
||||
res, err := o.run(runCtx, sessionID, turn, env)
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
|
||||
}
|
||||
@@ -260,7 +271,7 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
|
||||
}
|
||||
|
||||
go o.goTurn(sessionID, gen, messages, env, runCtx, cancel, deviceID, msgMode)
|
||||
go o.goTurn(sessionID, gen, turn, env, runCtx, cancel, deviceID, msgMode)
|
||||
return SubmitResult{Accepted: true, Immediate: true}, nil
|
||||
}
|
||||
|
||||
@@ -297,7 +308,7 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
|
||||
return nil
|
||||
}
|
||||
if res != nil && res.Text != "" {
|
||||
st.tail = o.boundedTail(concatMessages(st.tail, model.TextMessage(model.RoleAssistant, res.Text)))
|
||||
st.tail = o.boundedTail(concatMessages(st.tail, assistantTailMessage(res.Text, personaID)))
|
||||
}
|
||||
if len(st.pending) > 0 {
|
||||
next := st.pending[0]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file owns the reconstruction of a turn's conversation context from the
|
||||
// persisted transcript (design/15 §2.2/§4 "上下文有界:只保留最近 N 条上下文", design/02
|
||||
// §1 主会话承载完整上下文, design/22 §7-5). A turn used to start from the newly sent
|
||||
// message alone, which left the model with no sight of the conversation it was
|
||||
// continuing; the assistant utterances of earlier turns also carried no record of
|
||||
// which role had spoken them, so a role switched into a session could read another
|
||||
// role's words as its own.
|
||||
//
|
||||
// The seeding fixes both halves: a fresh turn carries the session's most recent
|
||||
// persisted messages, and every assistant message is labelled with the role that
|
||||
// spoke it (model.Message.SenderName) so the loop's context assembly can attribute
|
||||
// it (loop/attribution.go). Only labels travel on the messages — no body is
|
||||
// rewritten.
|
||||
//
|
||||
// Scope note: the seeded window is the conversation, so the loop's per-step
|
||||
// background memory extraction now reads those recent messages instead of the
|
||||
// single newly sent one. Extracted items are still filed under the turn's role
|
||||
// (loop.persistMemories, design/22 §4.2) and re-extraction of the same content is
|
||||
// deduplicated by the extractor, but a fact first stated while another role was
|
||||
// active can now be picked up by the current role if it was never extracted at the
|
||||
// time. Narrowing the extraction input to the current turn's messages is a
|
||||
// separate decision and is left as a follow-up.
|
||||
|
||||
// transcriptContextCount bounds how many persisted messages a fresh turn carries
|
||||
// into the model context. It mirrors the orchestrator's continuation tail bound
|
||||
// (defaultMaxTail), so a seeded turn and a resumed turn stay the same size; the
|
||||
// loop's own context budget still governs compaction beyond that.
|
||||
const transcriptContextCount = 20
|
||||
|
||||
// transcriptReader is the narrow history view the seeding needs. *history.Store
|
||||
// satisfies it; a store that does not (a test fake, or a gateway built without
|
||||
// persistence) simply yields no seeded context, so the orchestrator keeps running
|
||||
// exactly as before.
|
||||
type transcriptReader interface {
|
||||
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
||||
}
|
||||
|
||||
// startTurnMessages returns the conversation a fresh turn runs on: the session's
|
||||
// seeded transcript followed by the messages the client just sent. When the
|
||||
// transcript is unavailable the caller's messages are returned untouched, which is
|
||||
// the pre-existing behavior.
|
||||
func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string, messages []model.Message) []model.Message {
|
||||
seed := o.sessionTranscript(ctx, sessionID)
|
||||
if len(seed) == 0 {
|
||||
return messages
|
||||
}
|
||||
return concatMessages(seed, messages...)
|
||||
}
|
||||
|
||||
// sessionTranscript reads the session's most recent persisted messages and maps
|
||||
// them to model messages (oldest first). A nil/unreadable store or a read error
|
||||
// yields nil so a chat is never blocked by a metadata lookup, and a session with
|
||||
// no history yields nothing — both degrade to the pre-existing "new message only"
|
||||
// context.
|
||||
func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string) []model.Message {
|
||||
if o.history == nil || sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
reader, ok := o.history.(transcriptReader)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
recent, err := reader.Recent(ctx, sessionID, transcriptContextCount)
|
||||
if err != nil {
|
||||
o.log.Debug("turn transcript read", "err", err, "session", sessionID)
|
||||
return nil
|
||||
}
|
||||
out := make([]model.Message, 0, len(recent))
|
||||
for _, h := range recent {
|
||||
out = append(out, transcriptMessage(h))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// transcriptMessage maps one persisted chat message onto the model message a turn
|
||||
// continues from. An assistant message carries the role that spoke it (the stored
|
||||
// persona_id of the turn) as its speaker label, so the context can attribute it to
|
||||
// that role even after the session was switched; a non-assistant message keeps its
|
||||
// stored sender label (the third-party display name of design/16 §4.6) and is never
|
||||
// relabelled as a speaking role.
|
||||
func transcriptMessage(h history.Message) model.Message {
|
||||
role := historyRoleToModel(h.Role)
|
||||
msg := model.Message{
|
||||
Role: role,
|
||||
Content: []model.Content{{Type: model.ContentTypeText, Text: h.Content}},
|
||||
}
|
||||
if h.Image != "" {
|
||||
msg.Content = append(msg.Content, model.Content{Type: model.ContentTypeImage, ImageURL: h.Image})
|
||||
}
|
||||
if role == model.RoleAssistant {
|
||||
msg.SenderName = h.PersonaID
|
||||
return msg
|
||||
}
|
||||
msg.SenderName = h.SenderName
|
||||
return msg
|
||||
}
|
||||
|
||||
// historyRoleToModel maps a persisted chat role to the model's role vocabulary,
|
||||
// defaulting an unknown role to the user lane.
|
||||
func historyRoleToModel(role string) model.Role {
|
||||
switch role {
|
||||
case history.RoleAssistant:
|
||||
return model.RoleAssistant
|
||||
case history.RoleSystem:
|
||||
return model.RoleSystem
|
||||
default:
|
||||
return model.RoleUser
|
||||
}
|
||||
}
|
||||
|
||||
// assistantTailMessage builds the continuation-tail entry for one settled turn's
|
||||
// reply, labelled with the role that spoke it (design/22 §7-5): the tail is the
|
||||
// in-memory twin of the seeded transcript, so a role switched in later must be able
|
||||
// to tell an earlier role's reply apart from its own words on the very next queued
|
||||
// turn.
|
||||
func assistantTailMessage(text, personaID string) model.Message {
|
||||
msg := model.TextMessage(model.RoleAssistant, text)
|
||||
msg.SenderName = personaID
|
||||
return msg
|
||||
}
|
||||
Reference in new issue
Block a user