feat(context): /clear 指令截断上下文并按会话过期归档

用户发送 /clear 时主动截断上下文:加载历史 → LLM 生成摘要归档
→ 丢弃在途会话 → 回复确认(带摘要预览)。

- context/types: SummaryReason 新增 Clear 变体
- context/builder: 抽出 archive_session 共享逻辑,
  新增 trigger_clear_summary(返回摘要文本)
- db/models: 新增 latest_session_boundary;list_recent_chat_records
  _for_context 按会话边界过滤(clear/timeout),并排除 source=clear
- daemon: SessionMap 记录 user_id;拦截 /clear 走 handle_clear_command;
  新增 build_clear_reply 纯函数
- migration: session_summaries (user_id, created_at DESC) 索引

会话边界机制统一 clear 与 12h 空闲超时;摘要不注入下一对话,
只存档供 read_summaries 查询。新增 4 个单元测试。
This commit is contained in:
2026-06-23 11:57:07 +08:00
parent 0299329fb2
commit 132089c8eb
6 changed files with 325 additions and 17 deletions
+27
View File
@@ -333,6 +333,33 @@ flowchart TB
I --> J["发送给 LLM"] I --> J["发送给 LLM"]
``` ```
### 5.3.1 `/clear` 清空上下文流程
用户发送 `/clear` 指令时,主动截断上下文并把本次会话归档为摘要(按会话过期处理):
```mermaid
flowchart TB
U(["📱 用户发送 /clear"]) --> K{"is_clear_command() 匹配?"}
K -->|"否"| N(["走正常 LLM 对话流程"])
K -->|"是"| L["加载历史(过滤掉 /clear 本身)"]
L --> M{"历史非空?"}
M -->|"否"| R2["回复: 上下文已是空的"]
M -->|"是"| S["一次性 Conversation + summarizer"]
S --> T["trigger_clear_summary() → LLM 生成摘要"]
T --> DB[("session_summaries\nreason=clear")]
DB --> D["丢弃该用户在途会话 (sessions.retain)"]
D --> R1["回复确认 + 摘要预览"]
R2 --> END(["📱 用户收到回复"])
R1 --> END
```
- **会话边界**:摘要的 `created_at` 作为边界,之后加载历史时通过
`latest_session_boundary()` 过滤掉此前的消息(`/clear` 与 12h 空闲超时
共用同一套边界机制)
- **注入与否**`/clear` 摘要**不会**注入下一次对话的 system prompt
只存档供 `read_summaries` 工具查询(与空闲超时摘要一致)
- **在途会话**`/clear` 会丢弃该用户尚未完成的工具往返,避免用旧上下文继续
--- ---
## 6. 文件依赖矩阵 ## 6. 文件依赖矩阵
@@ -0,0 +1,4 @@
-- 为"按用户查询最近的会话边界(clear/timeout 摘要)"添加索引。
-- list_recent_chat_records_for_context 会据此过滤掉已过期会话的消息。
CREATE INDEX IF NOT EXISTS idx_session_summaries_user_created
ON session_summaries (user_id, created_at DESC);
+77 -3
View File
@@ -260,13 +260,48 @@ pub async fn trigger_overflow_summary(
/// 而是只保存到数据库,供 `read_summaries` 工具查询。 /// 而是只保存到数据库,供 `read_summaries` 工具查询。
/// ///
/// 生成摘要后会清空所有消息,checkpoint 重置为 0。 /// 生成摘要后会清空所有消息,checkpoint 重置为 0。
/// 该摘要的 `created_at` 同时作为"会话边界"——之后加载历史时
/// 不会包含此时间点之前的消息(见 `db::latest_session_boundary`)。
pub async fn trigger_idle_summary( pub async fn trigger_idle_summary(
session: &Arc<Mutex<ChatSession>>, session: &Arc<Mutex<ChatSession>>,
summarizer: Option<&Summarizer>, summarizer: Option<&Summarizer>,
) { ) {
let _ = archive_session(session, summarizer, super::types::SummaryReason::Timeout, "timeout")
.await;
}
/// ## 触发清空上下文摘要(`/clear` 指令)
///
/// 用户发送 `/clear` 时调用。把当前会话消息交给 LLM 生成摘要并存档,
/// 然后清空消息、重置 checkpoint,使会话"过期"。
///
/// 与空闲超时摘要一样:
/// * 摘要**不会**注入到下一次对话,只存档供 `read_summaries` 查询
/// * 摘要的 `created_at` 作为会话边界,之后加载历史不再包含此前的消息
///
/// 返回生成的摘要文本;若会话本就没有消息,返回 `None`。
pub async fn trigger_clear_summary(
session: &Arc<Mutex<ChatSession>>,
summarizer: Option<&Summarizer>,
) -> Option<String> {
archive_session(session, summarizer, super::types::SummaryReason::Clear, "clear").await
}
/// 归档当前会话:生成摘要 → 存库 → 清空消息 → 重置 checkpoint。
///
/// `trigger_idle_summary` 与 `trigger_clear_summary` 共用此逻辑,
/// 仅 `reason` 不同(Timeout / Clear),二者都代表"会话过期"。
///
/// 返回生成的摘要文本;若会话没有消息可归档,返回 `None`。
async fn archive_session(
session: &Arc<Mutex<ChatSession>>,
summarizer: Option<&Summarizer>,
reason: super::types::SummaryReason,
reason_str: &str,
) -> Option<String> {
let mut s = session.lock().await; let mut s = session.lock().await;
if s.messages.is_empty() { if s.messages.is_empty() {
return; return None;
} }
let summary_text = generate_summary(&s.messages, summarizer).await; let summary_text = generate_summary(&s.messages, summarizer).await;
@@ -275,12 +310,12 @@ pub async fn trigger_idle_summary(
s.summaries.push(super::types::SummaryEntry { s.summaries.push(super::types::SummaryEntry {
checkpoint: cp, checkpoint: cp,
text: summary_text.clone(), text: summary_text.clone(),
reason: super::types::SummaryReason::Timeout, reason,
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
}); });
let msg_count = cp as i32; let msg_count = cp as i32;
s.save_summary_to_db(&summary_text, "timeout", msg_count) s.save_summary_to_db(&summary_text, reason_str, msg_count)
.await; .await;
s.checkpoint = s.messages.len(); s.checkpoint = s.messages.len();
@@ -296,6 +331,8 @@ pub async fn trigger_idle_summary(
} }
} }
} }
Some(summary_text)
} }
/// 生成摘要:优先使用 LLM,不可用时回退到简单截断 /// 生成摘要:优先使用 LLM,不可用时回退到简单截断
@@ -431,4 +468,41 @@ mod tests {
assert!(has_anchor); assert!(has_anchor);
assert!(has_preface); assert!(has_preface);
} }
#[tokio::test]
async fn trigger_clear_summary_archives_and_clears() {
use crate::context::types::SummaryReason;
let mut session = ChatSession::new();
session.add_user("今天天气怎么样".to_string());
session.add_assistant("北京今天晴,25度".to_string());
session.add_user("明天呢".to_string());
session.add_assistant("明天多云转小雨".to_string());
let arc = Arc::new(Mutex::new(session));
let summarizer: Summarizer = Arc::new(|_prompt: String| {
Box::pin(async { Ok("摘要:讨论了北京近两天天气".to_string()) })
});
let summary = trigger_clear_summary(&arc, Some(&summarizer)).await;
assert!(summary.is_some());
assert_eq!(summary.unwrap(), "摘要:讨论了北京近两天天气");
let s = arc.lock().await;
assert!(s.messages.is_empty(), "清空后消息应被清空");
assert_eq!(s.checkpoint, 0);
assert!(s
.summaries
.iter()
.any(|x| x.reason == SummaryReason::Clear));
}
#[tokio::test]
async fn trigger_clear_summary_empty_session_returns_none() {
let arc = Arc::new(Mutex::new(ChatSession::new()));
let summarizer: Summarizer =
Arc::new(|_p: String| Box::pin(async { Ok("x".to_string()) }));
let summary = trigger_clear_summary(&arc, Some(&summarizer)).await;
assert!(summary.is_none());
}
} }
+6 -1
View File
@@ -14,6 +14,7 @@
//! 2. AI 回复 → `add_assistant()` → 存入 `messages` //! 2. AI 回复 → `add_assistant()` → 存入 `messages`
//! 3. Token 超 budget → `trigger_overflow_summary()` → 压缩 checkpoint 前的消息 //! 3. Token 超 budget → `trigger_overflow_summary()` → 压缩 checkpoint 前的消息
//! 4. 12h 空闲 → `trigger_idle_summary()` → 生成摘要,清空消息 //! 4. 12h 空闲 → `trigger_idle_summary()` → 生成摘要,清空消息
//! 5. `/clear` 指令 → `trigger_clear_summary()` → 生成摘要归档,会话过期
use crate::llm::types::Message; use crate::llm::types::Message;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@@ -30,15 +31,18 @@ pub struct HistoryEntry {
/// ## 摘要原因 /// ## 摘要原因
/// ///
/// 区分两种触发摘要的场景: /// 区分触发摘要的场景:
/// * `Overflow` — 上下文 token 总数超过预算(token budget),触发压缩 /// * `Overflow` — 上下文 token 总数超过预算(token budget),触发压缩
/// * `Timeout` — 距上条用户消息超过 12 小时空闲,触发会话总结 /// * `Timeout` — 距上条用户消息超过 12 小时空闲,触发会话总结
/// * `Clear` — 用户发送 `/clear` 主动清空上下文,触发会话过期归档
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SummaryReason { pub enum SummaryReason {
/// 上下文 token 超 budget 触发 /// 上下文 token 超 budget 触发
Overflow, Overflow,
/// 距上条用户消息超过 12 小时触发 /// 距上条用户消息超过 12 小时触发
Timeout, Timeout,
/// 用户发送 `/clear` 主动清空上下文触发
Clear,
} }
/// ## 一条摘要记录 /// ## 一条摘要记录
@@ -77,6 +81,7 @@ pub struct SummaryEntry {
/// 2. AI 回复 → `add_assistant()` → 存入 `messages` /// 2. AI 回复 → `add_assistant()` → 存入 `messages`
/// 3. Token 超 budget → `trigger_overflow_summary()` → 压缩 checkpoint 前的消息 /// 3. Token 超 budget → `trigger_overflow_summary()` → 压缩 checkpoint 前的消息
/// 4. 12h 空闲 → `trigger_idle_summary()` → 生成摘要,清空消息 /// 4. 12h 空闲 → `trigger_idle_summary()` → 生成摘要,清空消息
/// 5. `/clear` 指令 → `trigger_clear_summary()` → 生成摘要归档,会话过期
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ChatSession { pub struct ChatSession {
pub user_id: String, pub user_id: String,
+174 -11
View File
@@ -39,6 +39,7 @@
//! 采用统一的单进程 tokio task 架构。旧版 daemon+worker 分离模式(UDS IPC)已在 v2 中移除。 //! 采用统一的单进程 tokio task 架构。旧版 daemon+worker 分离模式(UDS IPC)已在 v2 中移除。
use crate::channel::ChannelId; use crate::channel::ChannelId;
use crate::context::builder;
use crate::context::HistoryEntry; use crate::context::HistoryEntry;
use crate::context::MemoryStore; use crate::context::MemoryStore;
use crate::db::Database; use crate::db::Database;
@@ -59,6 +60,10 @@ use tokio::sync::Mutex;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use uuid::Uuid; use uuid::Uuid;
/// LLM Consumer 的会话缓存:`correlation_id → (Conversation, 创建时间, user_id)`。
/// 额外记录 `user_id` 是为了在 `/clear` 时能按用户丢弃尚未完成的在途会话。
type SessionMap = HashMap<Uuid, (Conversation, Instant, String)>;
/// ## Daemon 上下文 —— 消费者之间共享的全部状态 /// ## Daemon 上下文 —— 消费者之间共享的全部状态
/// ///
/// 这个结构体被所有三个消费者(LLM / Tool / Send)通过 `Arc` 共享访问。 /// 这个结构体被所有三个消费者(LLM / Tool / Send)通过 `Arc` 共享访问。
@@ -194,7 +199,7 @@ pub async fn run(database: &Arc<Database>, memory_store: &Arc<MemoryStore>) {
sessions_for_clean sessions_for_clean
.lock() .lock()
.await .await
.retain(|_, (conv, created)| { .retain(|_, (conv, created, _)| {
let ttl_secs = if conv.has_pending_async() { 1800 } else { 300 }; let ttl_secs = if conv.has_pending_async() { 1800 } else { 300 };
now.duration_since(*created).as_secs() < ttl_secs now.duration_since(*created).as_secs() < ttl_secs
}); });
@@ -270,9 +275,9 @@ fn spawn_consumers(
enqueue: EnqueueHandle, enqueue: EnqueueHandle,
) -> ( ) -> (
Vec<tokio::task::JoinHandle<()>>, Vec<tokio::task::JoinHandle<()>>,
Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>>, Arc<Mutex<SessionMap>>,
) { ) {
let sessions: Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>> = let sessions: Arc<Mutex<SessionMap>> =
Arc::new(Mutex::new(HashMap::new())); Arc::new(Mutex::new(HashMap::new()));
let mut handles = Vec::new(); let mut handles = Vec::new();
@@ -312,7 +317,7 @@ async fn llm_consumer_loop(
rx: &mut tokio::sync::mpsc::Receiver<PipelineMessage>, rx: &mut tokio::sync::mpsc::Receiver<PipelineMessage>,
ctx: &DaemonCtx, ctx: &DaemonCtx,
enqueue: &EnqueueHandle, enqueue: &EnqueueHandle,
sessions: &Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>>, sessions: &Arc<Mutex<SessionMap>>,
) { ) {
while let Some(msg) = rx.recv().await { while let Some(msg) = rx.recv().await {
let user_id = msg.channel.user_id.clone(); let user_id = msg.channel.user_id.clone();
@@ -324,6 +329,19 @@ async fn llm_consumer_loop(
message_id: _, message_id: _,
context_token, context_token,
} => { } => {
// /clear 指令:截断上下文 + 生成摘要 + 按会话过期处理
if is_clear_command(&text) {
handle_clear_command(
ctx,
enqueue,
sessions,
&user_id,
correlation_id,
context_token,
)
.await;
continue;
}
// 加载数据 // 加载数据
ctx.memory_store.load_if_needed(&user_id).await; ctx.memory_store.load_if_needed(&user_id).await;
let history = load_history(&ctx.db, &user_id).await; let history = load_history(&ctx.db, &user_id).await;
@@ -428,7 +446,7 @@ async fn llm_consumer_loop(
sessions sessions
.lock() .lock()
.await .await
.insert(correlation_id, (conv, Instant::now())); .insert(correlation_id, (conv, Instant::now(), user_id.clone()));
// 执行 LLM 对话 // 执行 LLM 对话
run_llm_round( run_llm_round(
@@ -452,7 +470,7 @@ async fn llm_consumer_loop(
// 从缓存恢复会话,注入工具结果并恢复 LLM 循环 // 从缓存恢复会话,注入工具结果并恢复 LLM 循环
let should_resume = { let should_resume = {
let mut map = sessions.lock().await; let mut map = sessions.lock().await;
if let Some((conv, last_access)) = map.get_mut(&correlation_id) { if let Some((conv, last_access, _)) = map.get_mut(&correlation_id) {
conv.session().lock().await.add_tool_result( conv.session().lock().await.add_tool_result(
&tool_call_id, &tool_call_id,
&tool_name, &tool_name,
@@ -502,13 +520,13 @@ async fn run_llm_round(
correlation_id: Uuid, correlation_id: Uuid,
context_token: Option<String>, context_token: Option<String>,
text: &str, text: &str,
sessions: &Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>>, sessions: &Arc<Mutex<SessionMap>>,
enqueue: &EnqueueHandle, enqueue: &EnqueueHandle,
) { ) {
let result = { let result = {
let map = sessions.lock().await; let map = sessions.lock().await;
let conv = match map.get(&correlation_id) { let conv = match map.get(&correlation_id) {
Some((c, _)) => c, Some((c, _, _)) => c,
None => { None => {
error!(target: "ias::err","[llm-session] conv不在缓存"); error!(target: "ias::err","[llm-session] conv不在缓存");
return; return;
@@ -545,13 +563,13 @@ async fn resume_llm_round(
user_id: &str, user_id: &str,
correlation_id: Uuid, correlation_id: Uuid,
context_token: Option<String>, context_token: Option<String>,
sessions: &Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>>, sessions: &Arc<Mutex<SessionMap>>,
enqueue: &EnqueueHandle, enqueue: &EnqueueHandle,
) { ) {
let result = { let result = {
let map = sessions.lock().await; let map = sessions.lock().await;
let conv = match map.get(&correlation_id) { let conv = match map.get(&correlation_id) {
Some((c, _)) => c, Some((c, _, _)) => c,
None => { None => {
error!(target: "ias::err","[llm-resume] conv不在缓存"); error!(target: "ias::err","[llm-resume] conv不在缓存");
return; return;
@@ -589,7 +607,7 @@ async fn handle_chat_result(
correlation_id: Uuid, correlation_id: Uuid,
context_token: Option<String>, context_token: Option<String>,
result: ChatResult, result: ChatResult,
sessions: &Arc<Mutex<HashMap<Uuid, (Conversation, Instant)>>>, sessions: &Arc<Mutex<SessionMap>>,
enqueue: &EnqueueHandle, enqueue: &EnqueueHandle,
) { ) {
if result.has_pending_async { if result.has_pending_async {
@@ -617,6 +635,111 @@ async fn handle_chat_result(
sessions.lock().await.remove(&correlation_id); sessions.lock().await.remove(&correlation_id);
} }
// ─── /clear 指令处理 ───
/// 判断文本是否为清空上下文指令 `/clear`(大小写不敏感,允许前后空白)。
fn is_clear_command(text: &str) -> bool {
text.trim().eq_ignore_ascii_case("/clear")
}
/// ## 处理 `/clear` 指令 —— 截断上下文 + 生成摘要 + 会话过期
///
/// 用户发送 `/clear` 时:
/// 1. 加载当前历史,用 LLM 生成会话摘要并归档(reason = clear
/// 2. 摘要的 `created_at` 成为会话边界,之后加载历史不再包含此前的消息
/// 3. 丢弃该用户尚未完成的在途会话(按会话过期处理)
/// 4. 回复用户清空确认(附带摘要预览)
async fn handle_clear_command(
ctx: &DaemonCtx,
enqueue: &EnqueueHandle,
sessions: &Arc<Mutex<SessionMap>>,
user_id: &str,
correlation_id: Uuid,
context_token: Option<String>,
) {
info!(target: "ias::msg", "[clear] {} 请求清空上下文", user_id);
// 1. 生成摘要:用一次性 Conversation 提供 summarizer + session
// 过滤掉历史里的 /clear 指令本身,避免污染摘要
let history: Vec<HistoryEntry> = load_history(&ctx.db, user_id)
.await
.into_iter()
.filter(|h| !is_clear_command(&h.content))
.collect();
let summary = if history.is_empty() {
None
} else {
let cfg = ConversationConfig {
system_prompt: String::new(),
model: ctx.model.clone(),
..Default::default()
};
match Conversation::new(cfg) {
Ok(conv) => {
{
let s = conv.session();
let mut s = s.lock().await;
s.db_pool = Some(Arc::new(ctx.db.pool().clone()));
s.load_from_history(&history);
s.current_user_id = user_id.to_string();
}
let summarizer = conv.summarizer();
builder::trigger_clear_summary(&conv.session(), Some(&summarizer)).await
}
Err(e) => {
error!(target: "ias::err", "[clear] 初始化摘要器失败: {e}");
None
}
}
};
// 2. 丢弃该用户在途的会话(按会话过期处理)
sessions
.lock()
.await
.retain(|_, (_, _, uid)| uid.as_str() != user_id);
// 3. 回复确认(附带摘要预览)
let reply = build_clear_reply(&summary);
let msg = PipelineMessage::llm_reply_with_source(
ChannelId::wechat(user_id),
correlation_id,
&reply,
"clear",
context_token,
);
enqueue.enqueue(msg).await;
}
/// 根据 `trigger_clear_summary` 的返回值构建 `/clear` 回复文案。
///
/// * `None` — 无历史可清空
/// * `Some("")` — 已清空但 LLM 摘要为空
/// * `Some(text)` — 已清空,附带 300 字预览
fn build_clear_reply(summary: &Option<String>) -> String {
match summary {
None => "🧹 上下文已是空的,无需清空。".to_string(),
Some(text) if text.is_empty() => "🧹 已清空上下文,本次对话已归档。".to_string(),
Some(text) => {
let chars: Vec<char> = text.chars().collect();
let (preview, suffix) = if chars.len() > 300 {
(
chars[..300].iter().collect::<String>(),
"\n…(摘要已截断预览,完整内容已归档)",
)
} else {
(text.clone(), "")
};
format!(
"🧹 已清空上下文,本次对话已归档为摘要。\n\n📝 摘要预览:\n{}{}",
preview, suffix
)
}
}
}
// ─── Tool Consumer ─── // ─── Tool Consumer ───
async fn tool_consumer_loop( async fn tool_consumer_loop(
@@ -964,3 +1087,43 @@ async fn load_summaries(db: &Arc<Database>, user_id: &str) -> Vec<String> {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clear_command_detection() {
assert!(is_clear_command("/clear"));
assert!(is_clear_command(" /clear "));
assert!(is_clear_command("/CLEAR"));
assert!(is_clear_command("/Clear"));
assert!(is_clear_command("\t/clear\n"));
// 非精确匹配不应触发
assert!(!is_clear_command("/clear all"));
assert!(!is_clear_command("请/clear"));
assert!(!is_clear_command("清空"));
assert!(!is_clear_command(""));
assert!(!is_clear_command("/clea"));
}
#[test]
fn clear_reply_branches() {
// 无历史可清空
assert_eq!(build_clear_reply(&None), "🧹 上下文已是空的,无需清空。");
// 已清空但摘要为空(不应误报“已是空的”)
assert_eq!(
build_clear_reply(&Some(String::new())),
"🧹 已清空上下文,本次对话已归档。"
);
// 正常摘要:附带预览
let r = build_clear_reply(&Some("讨论了天气".to_string()));
assert!(r.starts_with("🧹 已清空上下文,本次对话已归档为摘要。"));
assert!(r.contains("讨论了天气"));
// 超长摘要:截断预览
let long = "x".repeat(500);
let r = build_clear_reply(&Some(long));
assert!(r.contains("摘要已截断预览"));
assert!(!r.contains(&"x".repeat(500)));
}
}
+37 -2
View File
@@ -138,22 +138,31 @@ pub async fn insert_chat_record(
} }
/// 查询最近可进入 LLM 上下文的聊天记录(排除工具等待/审批等中间提示)。 /// 查询最近可进入 LLM 上下文的聊天记录(排除工具等待/审批等中间提示)。
///
/// 会自动应用"会话边界":最近一次 `/clear` 或 12h 空闲超时摘要之前的消息
/// 已归档为摘要,不再进入新会话的上下文(见 [`latest_session_boundary`])。
pub async fn list_recent_chat_records_for_context( pub async fn list_recent_chat_records_for_context(
pool: &PgPool, pool: &PgPool,
user_id: &str, user_id: &str,
limit: i64, limit: i64,
) -> Result<Vec<ChatRecord>, String> { ) -> Result<Vec<ChatRecord>, String> {
// 会话边界:最近一次"会话过期"摘要(/clear 或 12h 空闲超时)的时间点。
// 边界之前的消息已归档为摘要,不再进入新会话的上下文。
let boundary = latest_session_boundary(pool, user_id).await;
let records = sqlx::query_as::<_, ChatRecord>( let records = sqlx::query_as::<_, ChatRecord>(
r#" r#"
SELECT id, created_at, direction, user_id, account_id, text, source, context_token, message_id SELECT id, created_at, direction, user_id, account_id, text, source, context_token, message_id
FROM chat_records FROM chat_records
WHERE user_id = $1 WHERE user_id = $1
AND source NOT IN ('llm_pending_tool', 'tool_approval') AND source NOT IN ('llm_pending_tool', 'tool_approval', 'clear')
AND created_at > COALESCE($2, '-infinity'::timestamptz)
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $2 LIMIT $3
"#, "#,
) )
.bind(user_id) .bind(user_id)
.bind(boundary)
.bind(limit) .bind(limit)
.fetch_all(pool) .fetch_all(pool)
.await .await
@@ -162,6 +171,32 @@ pub async fn list_recent_chat_records_for_context(
Ok(records) Ok(records)
} }
/// ## 查询用户最近的"会话边界"时间点
///
/// 返回最近一次会话过期摘要(`/clear` 或 12h 空闲超时)的 `created_at`。
/// 该时间点之前的聊天记录已归档为摘要,不应再进入新会话的上下文。
///
/// `Overflow` 摘要不构成边界(它发生在会话进行中,仅压缩早期消息)。
pub async fn latest_session_boundary(
pool: &PgPool,
user_id: &str,
) -> Option<DateTime<Utc>> {
sqlx::query_as::<_, (DateTime<Utc>,)>(
r#"
SELECT created_at
FROM session_summaries
WHERE user_id = $1 AND reason IN ('clear', 'timeout')
ORDER BY created_at DESC
LIMIT 1
"#,
)
.bind(user_id)
.fetch_optional(pool)
.await
.ok()?
.map(|(t,)| t)
}
// ─── LLM 用量 ─── // ─── LLM 用量 ───
/// LLM 用量聚合统计 /// LLM 用量聚合统计