본문으로 건너뛰기

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-networkSSE 클라이언트 (간접)

관련 문서: