oneshim-suggestion
AI 제안 처리를 담당하는 크레이트. SSE 수신, 우선순위 큐, 피드백 전송, 이력 관리를 수행합니다.
역할
- 제안 수신: SSE 스트림에서 제안 이벤트 수신
- 우선순위 관리: 중요도 기반 제안 정렬
- 피드백 전송: 사용자 반응(수락/거절) 서버 전송
- 이력 관리: 제안 히스토리 캐시
- UI 변환: 표시용 데이터 포맷팅
디렉토리 구조
oneshim-suggestion/src/
├── lib.rs # 크레이트 루트
├── receiver.rs # SuggestionReceiver - SSE → 제안 변환
├── queue.rs # PriorityQueue - 우선순위 큐
├── feedback.rs # FeedbackSender - 피드백 전송
├── presenter.rs # SuggestionPresenter - UI 데이터 변환
└── history.rs # SuggestionHistory - 이력 캐시
제안 흐름
주요 컴포넌트
SuggestionReceiver (receiver.rs)
SSE 이벤트를 제안으로 변환:
pub struct SuggestionReceiver {
sse_client: Arc<dyn SseClient>,
queue: Arc<PriorityQueue>,
notifier: Arc<dyn DesktopNotifier>,
history: Arc<SuggestionHistory>,
}
impl SuggestionReceiver {
pub async fn start(&self, session_id: &str) -> Result<(), CoreError> {
let (tx, mut rx) = mpsc::channel::<SseEvent>(100);
// SSE 연결 태스크
let sse = self.sse_client.clone();
let sid = session_id.to_string();
tokio::spawn(async move {
sse.connect(&sid, tx).await
});
// 이벤트 처리 루프
while let Some(event) = rx.recv().await {
match event {
SseEvent::Suggestion(s) => {
// 히스토리 기록
self.history.record(s.clone()).await;
// 큐에 추가
self.queue.push(s.clone()).await;
// 알림 표시
if let Err(e) = self.notifier.notify(&s).await {
tracing::warn!("알림 실패: {}", e);
}
}
SseEvent::Heartbeat { server_time } => {
tracing::debug!("Heartbeat: {}", server_time);
}
SseEvent::Error(msg) => {
tracing::warn!("SSE error: {}", msg);
}
_ => {}
}
}
Ok(())
}
}
PriorityQueue (queue.rs)
BTreeSet 기반 우선순위 큐:
pub struct PriorityQueue {
queue: RwLock<BTreeSet<PrioritizedSuggestion>>,
max_size: usize,
}
#[derive(Eq, PartialEq)]
struct PrioritizedSuggestion {
priority_score: u32,
suggestion: Suggestion,
}
impl Ord for PrioritizedSuggestion {
fn cmp(&self, other: &Self) -> Ordering {
// 점수 내림차순, 같으면 시간 오름차순
other.priority_score.cmp(&self.priority_score)
.then(self.suggestion.created_at.cmp(&other.suggestion.created_at))
}
}
impl PriorityQueue {
pub fn new(max_size: usize) -> Self {
Self {
queue: RwLock::new(BTreeSet::new()),
max_size,
}
}
pub async fn push(&self, suggestion: Suggestion) {
let mut queue = self.queue.write().await;
let score = self.calculate_score(&suggestion);
queue.insert(PrioritizedSuggestion {
priority_score: score,
suggestion,
});
// 최대 크기 초과 시 낮은 우선순위 제거
while queue.len() > self.max_size {
queue.pop_last();
}
}
pub async fn pop(&self) -> Option<Suggestion> {
let mut queue = self.queue.write().await;
queue.pop_first().map(|p| p.suggestion)
}
fn calculate_score(&self, s: &Suggestion) -> u32 {
let priority_weight = match s.priority {
Priority::Critical => 1000,
Priority::High => 750,
Priority::Medium => 500,
Priority::Low => 250,
};
let confidence_bonus = (s.confidence_score * 100.0) as u32;
let relevance_bonus = (s.relevance_score * 100.0) as u32;
let actionable_bonus = if s.is_actionable { 50 } else { 0 };
priority_weight + confidence_bonus + relevance_bonus + actionable_bonus
}
}
우선순위 계산:
| 항목 | 가중치 |
|---|---|
| Critical 우선순위 | +1000 |
| High 우선순위 | +750 |
| Medium 우선순위 | +500 |
| Low 우선순위 | +250 |
| 신뢰도 (0-1) | +0~100 |
| 관련성 (0-1) | +0~100 |
| 실행 가능 여부 | +50 |
FeedbackSender (feedback.rs)
사용자 피드백 전송:
pub struct FeedbackSender {
api_client: Arc<dyn ApiClient>,
history: Arc<SuggestionHistory>,
}
impl FeedbackSender {
pub async fn accept(&self, suggestion_id: &str) -> Result<(), CoreError> {
self.api_client.send_feedback(suggestion_id, true).await?;
self.history.record_feedback(
suggestion_id,
FeedbackType::Accepted
).await;
Ok(())
}
pub async fn reject(
&self,
suggestion_id: &str,
reason: Option<&str>
) -> Result<(), CoreError> {
self.api_client.send_feedback(suggestion_id, false).await?;
self.history.record_feedback(
suggestion_id,
FeedbackType::Rejected(reason.map(String::from))
).await;
Ok(())
}
pub async fn dismiss(&self, suggestion_id: &str) -> Result<(), CoreError> {
// 서버 전송 없이 로컬만 기록
self.history.record_feedback(
suggestion_id,
FeedbackType::Dismissed
).await;
Ok(())
}
}
SuggestionPresenter (presenter.rs)
UI 표시용 데이터 변환:
pub struct SuggestionPresenter;
#[derive(Clone)]
pub struct SuggestionView {
pub suggestion_id: String,
pub title: String,
pub body: String,
pub priority_badge: String,
pub action_buttons: Vec<ActionButton>,
pub created_ago: String,
}
impl SuggestionPresenter {
pub fn to_view(suggestion: &Suggestion) -> SuggestionView {
SuggestionView {
suggestion_id: suggestion.suggestion_id.clone(),
title: Self::extract_title(&suggestion.content),
body: Self::format_body(&suggestion.content),
priority_badge: Self::priority_to_badge(&suggestion.priority),
action_buttons: Self::create_actions(suggestion),
created_ago: Self::format_time_ago(suggestion.created_at),
}
}
fn priority_to_badge(priority: &Priority) -> String {
match priority {
Priority::Critical => "🔴 긴급".to_string(),
Priority::High => "🟠 높음".to_string(),
Priority::Medium => "🟡 보통".to_string(),
Priority::Low => "🟢 낮음".to_string(),
}
}
fn format_time_ago(created_at: DateTime<Utc>) -> String {
let duration = Utc::now() - created_at;
if duration.num_minutes() < 1 {
"방금 전".to_string()
} else if duration.num_hours() < 1 {
format!("{}분 전", duration.num_minutes())
} else if duration.num_days() < 1 {
format!("{}시간 전", duration.num_hours())
} else {
format!("{}일 전", duration.num_days())
}
}
}
SuggestionHistory (history.rs)
제안 이력 캐시:
pub struct SuggestionHistory {
cache: RwLock<VecDeque<HistoryEntry>>,
max_entries: usize,
}
#[derive(Clone)]
pub struct HistoryEntry {
pub suggestion: Suggestion,
pub feedback: Option<FeedbackType>,
pub received_at: DateTime<Utc>,
pub actioned_at: Option<DateTime<Utc>>,
}
#[derive(Clone)]
pub enum FeedbackType {
Accepted,
Rejected(Option<String>), // 거절 사유
Dismissed,
}
impl SuggestionHistory {
pub fn new(max_entries: usize) -> Self {
Self {
cache: RwLock::new(VecDeque::new()),
max_entries,
}
}
pub async fn record(&self, suggestion: Suggestion) {
let mut cache = self.cache.write().await;
if cache.len() >= self.max_entries {
cache.pop_front();
}
cache.push_back(HistoryEntry {
suggestion,
feedback: None,
received_at: Utc::now(),
actioned_at: None,
});
}
pub async fn record_feedback(&self, suggestion_id: &str, feedback: FeedbackType) {
let mut cache = self.cache.write().await;
if let Some(entry) = cache.iter_mut()
.find(|e| e.suggestion.suggestion_id == suggestion_id)
{
entry.feedback = Some(feedback);
entry.actioned_at = Some(Utc::now());
}
}
pub async fn get_recent(&self, count: usize) -> Vec<HistoryEntry> {
let cache = self.cache.read().await;
cache.iter().rev().take(count).cloned().collect()
}
}
제안 타입
pub enum SuggestionType {
WorkGuidance, // 업무 안내
RiskAlert, // 위험 알림
ProductivityTip, // 생산성 팁
ContextAwareness, // 컨텍스트 인식
ScheduleReminder, // 일정 알림
}
| 타입 | 설명 | 예시 |
|---|---|---|
WorkGuidance | 업무 진행 안내 | "이 작업 먼저 완료하세요" |
RiskAlert | 위험 상황 알림 | "마감 기한 임박" |
ProductivityTip | 생산성 팁 | "이 단축키를 사용해보세요" |
ContextAwareness | 컨텍스트 인식 | "관련 문서를 참조하세요" |
ScheduleReminder | 일정 알림 | "30분 후 회의가 있습니다" |
의존성
| 크레이트 | 용도 |
|---|---|
tokio | 비동기 런타임, mpsc 채널 |
chrono | 시간 처리 |
oneshim-core | 모델, 포트 |
oneshim-network | SSE 클라이언트 (간접) |
관련 문서:
- 클라이언트 개요
- oneshim-core - 제안 모델
- oneshim-network - SSE 연결
- oneshim-ui - 제안 표시