Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
+281
@@ -0,0 +1,281 @@
|
||||
//! 真实重水合源(design 2026-06-23):会话库 `messages` 表 = 内容唯一事实源。
|
||||
//!
|
||||
//! 给定 wire `conversation_id`(= `conversations.id` 的十进制字符串),按 [`TranscriptAnchor`]
|
||||
//! 框出窗口,把消息转成**脱敏**转录喂给 drafter。装配见 `service::attach_companion`(会话服务
|
||||
//! 晚于伴随服务构建,故晚装配)。走仓储层 `get_messages`(user 无关,绕开 list_messages 的
|
||||
//! 鉴权与 type 过滤)。会话不存在/为空 → `None`(drafter 降级回工具名步骤)。
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_common::AppError;
|
||||
use nomifun_db::{IConversationRepository, SortOrder};
|
||||
|
||||
use crate::evolution::transcript::{TranscriptAnchor, TranscriptSource, TranscriptTurn};
|
||||
|
||||
/// 单条文本/参数/结果脱敏后的字符上限(控转录体量)。
|
||||
const FIELD_CHARS: usize = 600;
|
||||
/// 单会话最多取多少条消息来框窗口(防超大会话拖垮起草)。
|
||||
const MAX_FETCH: u32 = 1000;
|
||||
|
||||
pub struct ConversationTranscriptSource {
|
||||
repo: Arc<dyn IConversationRepository>,
|
||||
}
|
||||
|
||||
impl ConversationTranscriptSource {
|
||||
pub fn new(repo: Arc<dyn IConversationRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
}
|
||||
|
||||
/// 一行消息解析后的最小投影。
|
||||
struct Parsed {
|
||||
ty: String,
|
||||
position: String,
|
||||
content: serde_json::Value,
|
||||
/// tool_call/acp_tool_call 的 call_id(用于按锚精确框窗)。
|
||||
call_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 脱敏 + 截断(secrets 永不入转录;过长字段裁断带省略号)。
|
||||
fn redact_clip(s: &str) -> String {
|
||||
let red = nomi_redact::redact_secrets_owned(s.to_owned());
|
||||
if red.chars().count() <= FIELD_CHARS {
|
||||
red
|
||||
} else {
|
||||
let head: String = red.chars().take(FIELD_CHARS).collect();
|
||||
format!("{head}…")
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 tool_call/acp_tool_call 的 content 提取 (name, args, result)。
|
||||
fn extract_tool(ty: &str, content: &serde_json::Value) -> Option<(String, Option<String>, Option<String>)> {
|
||||
match ty {
|
||||
"tool_call" => {
|
||||
let name = content.get("name").and_then(|v| v.as_str())?.to_owned();
|
||||
let args = content
|
||||
.get("args")
|
||||
.or_else(|| content.get("input"))
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| v.to_string());
|
||||
let result = content.get("output").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
Some((name, args, result))
|
||||
}
|
||||
"acp_tool_call" => {
|
||||
let upd = content.get("update")?;
|
||||
let name = upd.get("title").and_then(|v| v.as_str()).unwrap_or("tool").to_owned();
|
||||
let args = upd.get("raw_input").filter(|v| !v.is_null()).map(|v| v.to_string());
|
||||
let result = upd.get("raw_output").filter(|v| !v.is_null()).map(|v| v.to_string());
|
||||
Some((name, args, result))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptSource for ConversationTranscriptSource {
|
||||
async fn window(&self, anchor: &TranscriptAnchor) -> Result<Option<Vec<TranscriptTurn>>, AppError> {
|
||||
// wire id = conversations.id 的十进制串(无独立公开 id 列);非数字 → 无法定位。
|
||||
let Ok(conv_id) = anchor.conversation_id.parse::<i64>() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let page = self
|
||||
.repo
|
||||
.get_messages(conv_id, 1, MAX_FETCH, SortOrder::Asc)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("rehydrate get_messages: {e}")))?;
|
||||
if page.items.is_empty() {
|
||||
return Ok(None); // 会话已删/为空
|
||||
}
|
||||
|
||||
let parsed: Vec<Parsed> = page
|
||||
.items
|
||||
.iter()
|
||||
.filter(|r| !r.hidden)
|
||||
.map(|r| {
|
||||
let content: serde_json::Value = serde_json::from_str(&r.content).unwrap_or(serde_json::Value::Null);
|
||||
let call_id = match r.r#type.as_str() {
|
||||
"tool_call" => content.get("call_id").and_then(|v| v.as_str()).map(|s| s.to_owned()),
|
||||
"acp_tool_call" => content
|
||||
.get("update")
|
||||
.and_then(|u| u.get("tool_call_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_owned()),
|
||||
_ => None,
|
||||
};
|
||||
Parsed { ty: r.r#type.clone(), position: r.position.clone().unwrap_or_default(), content, call_id }
|
||||
})
|
||||
.collect();
|
||||
if parsed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 框窗口:call_id 命中优先(挖矿/示范都带 call_ids);命中为空则退回整段(capped)。
|
||||
let want: HashSet<&str> = anchor.call_ids.iter().map(|s| s.as_str()).collect();
|
||||
let hits: Vec<usize> = if want.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
parsed
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.call_id.as_deref().map(|c| want.contains(c)).unwrap_or(false))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
};
|
||||
let (lo, hi) = if hits.is_empty() {
|
||||
(0, parsed.len() - 1) // 无精确命中 → 整段(已 capped 到 MAX_FETCH)
|
||||
} else {
|
||||
let lo = hits.iter().min().copied().unwrap().saturating_sub(anchor.pad_turns);
|
||||
let hi = (hits.iter().max().copied().unwrap() + anchor.pad_turns).min(parsed.len() - 1);
|
||||
(lo, hi)
|
||||
};
|
||||
|
||||
let mut turns = Vec::new();
|
||||
for p in &parsed[lo..=hi] {
|
||||
match p.ty.as_str() {
|
||||
"text" if p.position == "right" => {
|
||||
if let Some(t) = p.content.get("content").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()) {
|
||||
turns.push(TranscriptTurn::user(redact_clip(t)));
|
||||
}
|
||||
}
|
||||
"text" => {
|
||||
if let Some(t) = p.content.get("content").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()) {
|
||||
turns.push(TranscriptTurn::assistant(redact_clip(t)));
|
||||
}
|
||||
}
|
||||
"tool_call" | "acp_tool_call" => {
|
||||
if let Some((name, args, result)) = extract_tool(&p.ty, &p.content) {
|
||||
turns.push(TranscriptTurn::tool(
|
||||
name,
|
||||
args.map(|a| redact_clip(&a)),
|
||||
result.map(|r| redact_clip(&r)),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {} // thinking/tips 跳过(对起草是噪声)
|
||||
}
|
||||
}
|
||||
Ok(Some(turns))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_common::now_ms;
|
||||
use nomifun_db::models::{ConversationRow, MessageRow};
|
||||
use nomifun_db::{init_database_memory, SqliteConversationRepository};
|
||||
|
||||
fn conv_row() -> ConversationRow {
|
||||
ConversationRow {
|
||||
id: 0, // ignored on insert (autoincrement)
|
||||
user_id: "system_default_user".into(), // seeded by init_database_memory (FK)
|
||||
name: "t".into(),
|
||||
r#type: "gemini".into(),
|
||||
extra: "{}".into(),
|
||||
model: None,
|
||||
status: Some("finished".into()),
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
pinned: false,
|
||||
pinned_at: None,
|
||||
cron_job_id: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_msg(conv: i64, content: &str, position: &str, ts: i64) -> MessageRow {
|
||||
MessageRow {
|
||||
id: format!("msg-{position}-{ts}"),
|
||||
conversation_id: conv,
|
||||
msg_id: None,
|
||||
r#type: "text".into(),
|
||||
content: serde_json::json!({ "content": content }).to_string(),
|
||||
position: Some(position.into()),
|
||||
status: Some("finish".into()),
|
||||
hidden: false,
|
||||
created_at: ts,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_msg(conv: i64, call_id: &str, args: serde_json::Value, output: &str, ts: i64) -> MessageRow {
|
||||
MessageRow {
|
||||
id: format!("msg-{call_id}"),
|
||||
conversation_id: conv,
|
||||
msg_id: None,
|
||||
r#type: "tool_call".into(),
|
||||
content: serde_json::json!({
|
||||
"call_id": call_id, "name": "grep", "args": args, "status": "completed", "output": output
|
||||
})
|
||||
.to_string(),
|
||||
position: Some("left".into()),
|
||||
status: Some("finish".into()),
|
||||
hidden: false,
|
||||
created_at: ts,
|
||||
}
|
||||
}
|
||||
|
||||
async fn repo_with_conv() -> (Arc<SqliteConversationRepository>, i64) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
let id = repo.create(&conv_row()).await.unwrap();
|
||||
(repo, id)
|
||||
}
|
||||
|
||||
/// 守门:重水合命中 → 真实 user/tool/assistant 内容入转录;secret 脱敏;thinking/hidden 排除。
|
||||
#[tokio::test]
|
||||
async fn rehydrates_window_and_redacts_secrets() {
|
||||
let (repo, conv) = repo_with_conv().await;
|
||||
repo.insert_message(&text_msg(conv, "把日志里的错误改掉", "right", 1)).await.unwrap();
|
||||
repo.insert_message(&tool_msg(
|
||||
conv,
|
||||
"tc-1",
|
||||
serde_json::json!({ "pattern": "ERROR", "key": "sk-ABCDEFGHIJ0123456789xyz" }),
|
||||
"命中 3 处",
|
||||
2,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_message(&text_msg(conv, "改好了", "left", 3)).await.unwrap();
|
||||
let mut hidden = text_msg(conv, "隐藏内容", "left", 4);
|
||||
hidden.hidden = true;
|
||||
repo.insert_message(&hidden).await.unwrap();
|
||||
let mut thinking = text_msg(conv, "内心独白", "left", 5);
|
||||
thinking.r#type = "thinking".into();
|
||||
repo.insert_message(&thinking).await.unwrap();
|
||||
|
||||
let src = ConversationTranscriptSource::new(repo.clone());
|
||||
let anchor = TranscriptAnchor {
|
||||
conversation_id: conv.to_string(),
|
||||
start_ts: 0,
|
||||
end_ts: 0,
|
||||
pad_turns: 2,
|
||||
call_ids: vec!["tc-1".into()],
|
||||
};
|
||||
let turns = src.window(&anchor).await.unwrap().expect("conversation present");
|
||||
let rendered = crate::evolution::render_transcript(&turns, 600).join("\n");
|
||||
assert!(rendered.contains("把日志里的错误改掉"), "user content: {rendered}");
|
||||
assert!(rendered.contains("grep"), "tool name: {rendered}");
|
||||
assert!(rendered.contains("命中 3 处"), "tool result: {rendered}");
|
||||
assert!(rendered.contains("改好了"), "assistant content: {rendered}");
|
||||
// 脱敏:secret 永不入转录(纵深防御要害)。
|
||||
assert!(!rendered.contains("sk-ABCDEFGHIJ"), "secret leaked: {rendered}");
|
||||
assert!(rendered.contains("[REDACTED_SECRET]"), "redaction marker missing: {rendered}");
|
||||
// thinking/hidden 是噪声,不入转录。
|
||||
assert!(!rendered.contains("内心独白"), "thinking leaked: {rendered}");
|
||||
assert!(!rendered.contains("隐藏内容"), "hidden leaked: {rendered}");
|
||||
}
|
||||
|
||||
/// 守门:非数字 id / 不存在的会话 → None(drafter 降级,不报错)。
|
||||
#[tokio::test]
|
||||
async fn missing_or_nonnumeric_conversation_returns_none() {
|
||||
let (repo, _conv) = repo_with_conv().await;
|
||||
let src = ConversationTranscriptSource::new(repo);
|
||||
let nonnumeric = TranscriptAnchor { conversation_id: "conv_abc".into(), ..Default::default() };
|
||||
assert!(src.window(&nonnumeric).await.unwrap().is_none(), "non-numeric id → None");
|
||||
let gone = TranscriptAnchor { conversation_id: "999999".into(), ..Default::default() };
|
||||
assert!(src.window(&gone).await.unwrap().is_none(), "missing conversation → None");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user