feat(astra): Project Astra 이메일 자산화 Phase 1+2 (v2.2.206)

- Gmail 읽기전용 수집(/email-sync) — gmail.readonly 스코프(공유 토큰),
  본문/메타/스레드를 로컬 인덱스에 저장. 본문 로컬 only(프라이버시).
- RAG 'email' 소스 — 검색 파이프라인 자동 합류 + 원문 메일 링크 출처.
- 하이브리드(TF-IDF+임베딩) 검색, brain 과 동일 공식.
- /email-status — 미회신/놓친 요청 추적(스레드 SENT 라벨 휴리스틱).
- 백그라운드 자동 동기화(g1nation.email.autoSync) — 슬래시와 동일 코어 공유.

신규 features/email/{gmailApi,emailStore,emailSync,autoSync,handlers}.ts
+ retrieval 'email' 소스 통합. 타입체크·407 테스트 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 18:34:42 +09:00
parent 6b017b0d31
commit 7e96e56381
12 changed files with 719 additions and 6 deletions
+2 -1
View File
@@ -119,7 +119,8 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
'procedural-memory': '📋 Procedural Memory (반복 절차)',
'episodic-memory': '📖 Episodic Memory (과거 대화 흐름)',
'project-scan': '🔍 Project Scan',
'recent-knowledge': '📄 Recent Project Knowledge'
'recent-knowledge': '📄 Recent Project Knowledge',
'email': '📧 이메일 (수집된 메일 근거 — 원문 링크 포함)'
};
// Group by source
+96 -1
View File
@@ -26,6 +26,7 @@ import { extractLessonEssence } from './lessonHelpers';
import { cosineSimilarity } from './embeddings';
import { applyActionabilityBoost, WorkStateSignals, ActionabilityWeights } from './actionabilityScoring';
import { applyHierarchicalReweight, classifyQueryLevel, AbstractionLevel, HierarchicalWeights } from './hierarchicalLevel';
import { loadEmailRecords } from '../features/email/emailStore';
export { tokenize, expandQuery, scoreTfIdf, scoreTfIdfPreTokenized, extractBestExcerpt } from './scoring';
export { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
@@ -83,6 +84,8 @@ interface RetrievalOptions {
embeddingModel?: string;
/** Blend weight: 0 = TF-IDF only, 1 = cosine only. Default 0.5. */
embeddingBlendAlpha?: number;
/** Project Astra — email 소스에서 가져올 최대 메일 수. 기본 6. 0 이면 스킵. */
emailLimit?: number;
/**
* Actionability — "현재 작업 상태" 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과 재가중.
* undefined 면 actionability re-rank 안 함 (legacy 동작).
@@ -151,6 +154,19 @@ export class RetrievalOrchestrator {
allChunks.push(...memoryChunks);
fusionLog.push(`Memory search: ${memoryChunks.length} chunks found`);
// ── ①-b Email Search (Project Astra) — 수집된 메일에서 근거 검색 ──
// 인덱스가 비어 있으면(미수집) 즉시 [] 반환하므로 비용 0.
const emailChunks = this.searchEmailIndex(
expandedTokens,
options.brain,
options.emailLimit ?? 6,
options.queryEmbedding,
options.embeddingModel,
options.embeddingBlendAlpha,
);
allChunks.push(...emailChunks);
if (emailChunks.length > 0) fusionLog.push(`Email search: ${emailChunks.length} chunks found`);
// ── ②-b Medium-Term Memory (recent sessions) ──
const mediumChunks = this.scoreRecentSessions(
expandedTokens,
@@ -343,6 +359,84 @@ export class RetrievalOrchestrator {
}
}
// ─── Email Search (Project Astra) ───
/**
* 수집된 이메일 인덱스({brainPath}/memory/email_index.json)에서 TF-IDF 로 근거 메일을
* 찾는다. 브레인 파일 검색과 *동일한* scoreTfIdfPreTokenized 를 써서 일관성 유지.
* 각 청크는 messageId/permalink 메타를 실어 답변이 원문 메일로 점프할 수 있게 한다.
* Phase 1 은 키워드(TF-IDF)만 — 임베딩 블렌드는 Phase 2.
*/
private searchEmailIndex(
expandedTokens: string[],
brain: BrainProfile,
limit: number,
queryEmbedding?: number[],
embeddingModel?: string,
embeddingBlendAlpha?: number,
): RetrievalChunk[] {
if (limit <= 0) return [];
try {
const records = loadEmailRecords(brain.localBrainPath);
if (records.length === 0) return [];
const scored = scoreTfIdfPreTokenized(
expandedTokens,
records.map((r) => ({
tokens: r.tokens,
titleTokens: r.subjectTokens,
lastModified: r.date,
conflictCount: 0,
})),
);
// (Phase 2) 하이브리드 블렌드 — 브레인 검색과 동일 공식. 같은 모델로 임베딩된
// 레코드만 코사인 가산, 없으면 순수 TF-IDF 유지(graceful fallback).
if (queryEmbedding && embeddingModel && (embeddingBlendAlpha ?? 0) > 0) {
const alpha = Math.max(0, Math.min(1, embeddingBlendAlpha!));
const maxTfidf = scored.reduce((m, s) => s.score > m ? s.score : m, 0) || 1;
for (const s of scored) {
const rec = records[s.index];
if (!rec.embedding || rec.embeddingModel !== embeddingModel) continue;
const cos = cosineSimilarity(queryEmbedding, rec.embedding);
const tfidfNorm = s.score / maxTfidf;
s.score = (1 - alpha) * tfidfNorm + alpha * Math.max(0, cos);
}
}
const ranked = scored.filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
const out: RetrievalChunk[] = [];
for (const s of ranked) {
const r = records[s.index];
const dateStr = new Date(r.date).toISOString().slice(0, 10);
const title = `메일: "${r.subject || '(제목 없음)'}" (${dateStr}, from ${r.from})`;
const body = summarizeText(r.bodyText || r.snippet || '', 700);
const content = `${body}${r.permalink ? `\n[원문 링크] ${r.permalink}` : ''}`;
out.push({
id: `email-${r.messageId}`,
source: 'email' as const,
title,
content,
score: s.score,
tokenEstimate: estimateTokens(content),
metadata: {
category: 'email',
lastUpdated: r.date,
queryCoverage: s.queryCoverage,
emailMessageId: r.messageId,
emailThreadId: r.threadId,
emailFrom: r.from,
emailSubject: r.subject,
emailDate: r.date,
emailPermalink: r.permalink,
},
});
}
return out;
} catch {
return [];
}
}
// ─── Memory Layer Search ───
private searchMemoryLayers(
@@ -513,7 +607,8 @@ export class RetrievalOrchestrator {
'medium-term-memory': 0.78, // recent sessions: useful when the user references "last time / yesterday"
'episodic-memory': 0.7,
'project-scan': 0.6,
'recent-knowledge': 0.75
'recent-knowledge': 0.75,
'email': 0.9 // 메일은 직접 근거 — 브레인 노트와 동급으로 취급
};
for (const chunk of chunks) {
+11 -1
View File
@@ -16,7 +16,8 @@ export type RetrievalSource =
| 'procedural-memory' // Procedural Memory
| 'episodic-memory' // Episodic Memory
| 'project-scan' // Local Project Path scan
| 'recent-knowledge'; // Recent Project Knowledge record
| 'recent-knowledge' // Recent Project Knowledge record
| 'email'; // Project Astra — 수집된 Gmail/이메일
export type ConflictSeverity = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH';
@@ -43,6 +44,15 @@ export interface RetrievalChunk {
isLesson?: boolean;
/** 'lesson' | 'playbook' | 'qa-finding' when isLesson is true. */
lessonKind?: string;
// --- Email (Project Astra) — 출처 추적: 원문 메일로 점프 ---
emailMessageId?: string;
emailThreadId?: string;
emailFrom?: string;
emailSubject?: string;
emailDate?: number;
/** 원문 메일 딥링크. */
emailPermalink?: string;
};
}