package channelbridge import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "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" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript" ) // 本文件让 IM 回合带上**该会话自己**最近的对话历史,使她看得懂一段连贯的对话,而不是只 // 听得到当次那一条消息(主人 2026-10-05 反馈:「只有 @ 才有反应、不 @ 就不理解意图」—— // 此前群里的回合只有「来源系统行 + 当次消息」,前几句她根本没看见)。 // // 会话环境在 loop.SessionEnv 上早已按车道解析(ActorEnv/MemWritable/PersonaID, // design/22 §7-3/§7-4),缺的一直是**历史转录**:Web 主会话回合由编排器播种该会话最近 N 条 // 转录(gateway/session_transcript.go),而 IM 回合此前没有。 // // - **主人私聊**:IM 回合镜像进主会话,因此播种**主会话**(`web`)的转录,与 Web 主会话 // 看到的历史逐条一致 —— 她不会像第一次见到主人(主人 2026-10-05 要求两条车道环境一致)。 // - **群聊 / 陌生人(受限车道)**:播种**该隔离会话自己**(`sess-*`)的最近转录,因此群里 // 前几句、以及被最小打扰静默掉而只登记不回的那几条(light.go 的 registerSilenced 已把它们 // 落进同一会话)都在她的上下文里,她据此才能理解一段连贯对话的意图。**顺序为 // frame 先行**:来源行 + 第三方身份边界在最前、转录居中、当次消息在最后 // (主人 2026-10-05 裁定,见 seedTurnTranscript)。 // // 两条车道读的会话不同,重建逻辑与条数上限却是同一份:internal/runtime/transcript // (ContextCount / Read / Message)—— 与网关同一份实现、同一个常量,绝不另写第二套 // (两份映射必然漂移)。**受限车道结构上够不到主会话**:目标会话由 inboundTargetFor 结构化 // 决定,受限车道拿到的是 `sess-*` 隔离会话,主人的主会话历史一个字都不外泄 // (design/16 §4.6 §3、design/02 §3)。 // turnEnv 返回一条入站消息本轮运行所用的 loop 会话环境。 // // - 主人私聊(inboundTarget.private)与 Web 主会话是**同一条车道**,因此经主人车道的 // 唯一构造入口 loop.OwnerEnvFor 派生:两条车道逐字段同源,SessionEnv 将来新增字段时 // 会一起拿到,而不是各自漂移出「同一个她、两套环境」。 // - 受限会话(群聊/陌生人)由结构化解析出的隔离边界决定:记忆只读(MemWritable=false)、 // 并强制默认角色(design/22 §7-4 —— resolvePersona 对受限会话恒返回 "")。身份与权限 // 始终来自 actor 元数据,绝不来自这里的任何字符串(项目红线)。 func turnEnv(sess session.Session, target inboundTarget, personaID string) loop.SessionEnv { if target.private { return loop.OwnerEnvFor(personaID) } return loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable, PersonaID: personaID} } // seedTurnTranscript 在本回合模型可见消息之前前置**该会话自己**最近的转录 // (与网关编排器的 startTurnMessages 同序:转录在前、本次消息在后): // // - 主人私聊 → 主会话(`web`)的转录,与 Web 主会话逐条一致; // - 群聊 / 陌生人 → 该隔离会话(`sess-*`)的转录,含被静默登记的群成员发言。 // // **受限车道的顺序是「frame 先行」**(主人 2026-10-05 裁定):来源系统行 + // 第三方身份边界必须排在**最前**,转录居中,当次用户消息在最后——即 // `[边界/来源系统行, …转录…, 当次用户消息]`。理由是提示词注入面:若把转录 // 前置在边界之前,群友的历史发言会先于「这是群、这些人是第三方」进入她的 // 上下文。主人私聊车道**顺序逐字不变**(转录最前、来源行、当次消息),因为 // 那条车道的上下文隔离本就不同。 // // 时刻与网关一致:调用点在本回合用户消息落库**之前**(runInboundTurn 里装配先于 // persistToMain / persistIsolated),因此当次消息绝不会在上下文里出现两次。 // // 转录读不出来(未接历史存储、读失败、空会话)时原样返回,这正是既有行为:回合退化为 // 「来源行 + 当次消息」,而不是让回合失败。 // // 受限车道另设一道显式守卫:目标会话是主会话 id 时宁可不播种。这在生产里结构上不可能 // (inboundTargetFor 只在主人私聊时给出 MainSessionID),但上下文隔离是安全红线 —— 万一 // 将来多出别的构造点写错,也绝不能把主人的主会话历史灌进群聊/陌生人上下文 // (design/02 §3、design/16 §4.6 §3)。 func (b *Bridge) seedTurnTranscript(ctx context.Context, target inboundTarget, msgs []model.Message) []model.Message { if !target.private && target.sessionID == history.MainSessionID { return msgs } seed := transcript.Read(ctx, b.history, target.sessionID) if len(seed) == 0 { return msgs } // 主人私聊:转录前置在最前(既有顺序,逐字不变)。 if target.private { out := make([]model.Message, 0, len(seed)+len(msgs)) out = append(out, seed...) return append(out, msgs...) } return insertSeedAfterFrame(msgs, seed) } // insertSeedAfterFrame 把受限车道的转录插在**最前面的系统框架行之后** // (来源行 + 第三方身份边界仍在最前,frame 先行),当次用户消息保持在最后: // `[系统框架行, …转录…, 当次用户消息]`。受限车道的本回合消息恰好是 // 「框架系统行 + 当次用户消息」,因此这一插入位置等价于「边界最前、历史居中、 // 当次最后」,且**当次消息绝不重复**。 func insertSeedAfterFrame(msgs, seed []model.Message) []model.Message { frame := 0 for frame < len(msgs) && msgs[frame].Role == model.RoleSystem { frame++ } out := make([]model.Message, 0, len(msgs)+len(seed)) out = append(out, msgs[:frame]...) out = append(out, seed...) return append(out, msgs[frame:]...) }