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:
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user