diff --git a/.docs/Design.md b/.docs/Design.md new file mode 100644 index 00000000..600b3c29 --- /dev/null +++ b/.docs/Design.md @@ -0,0 +1,933 @@ +# Obsidian to Anki - 설계 문서 + +## 1. 시스템 아키텍처 + +### 1.1 전체 구조 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Obsidian Plugin UI │ +│ (TypeScript/JavaScript - Obsidian API) │ +└───────────────────────────┬─────────────────────────────────┘ + │ +┌───────────────────────────▼─────────────────────────────────┐ +│ Core Processing Layer │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ File Manager │ │ Note Parser │ │ Card Builder │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ LLM Integration Layer (NEW) │ │ +│ │ ┌────────────┐ ┌───────────┐ ┌────────────┐ │ │ +│ │ │ LLM Router │ │ Provider │ │ Prompt │ │ │ +│ │ │ │ │ Manager │ │ Manager │ │ │ +│ │ └────────────┘ └───────────┘ └────────────┘ │ │ +│ └──────────────────────────────────────────────────────┘ │ +└───────────────────────────┬─────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ +┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ +│ AnkiConnect │ │ Local LLM │ │ Cloud LLM │ +│ API │ │ (Ollama, │ │ (OpenRouter,│ +│ │ │ LM Studio)│ │ OpenAI) │ +└──────────────┘ └─────────────┘ └─────────────┘ +``` + +### 1.2 새로운 컴포넌트 + +#### 1.2.1 LLM Integration Layer +새로운 LLM 통합 레이어는 기존 시스템과 독립적으로 동작하며, 선택적으로 활성화 가능. + +## 2. 모듈 설계 + +### 2.1 LLM Provider Manager + +#### 2.1.1 인터페이스 설계 + +```typescript +// src/llm/interfaces/llm-provider.interface.ts + +export interface LLMConfig { + provider: string; // 'openai', 'ollama', 'openrouter', etc. + apiKey?: string; // API key (cloud providers) + endpoint: string; // API endpoint URL + model: string; // Model name + temperature?: number; // Default: 0.7 + maxTokens?: number; // Default: 2000 + timeout?: number; // Request timeout in ms +} + +export interface LLMMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface LLMResponse { + content: string; + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; + model: string; + finishReason: string; +} + +export interface ILLMProvider { + initialize(config: LLMConfig): Promise; + generateCompletion(messages: LLMMessage[]): Promise; + isAvailable(): Promise; + getName(): string; +} +``` + +#### 2.1.2 Provider 구현 + +```typescript +// src/llm/providers/openai-compatible-provider.ts + +export class OpenAICompatibleProvider implements ILLMProvider { + private config: LLMConfig; + + async initialize(config: LLMConfig): Promise { + this.config = config; + // Validate config + await this.isAvailable(); + } + + async generateCompletion(messages: LLMMessage[]): Promise { + const response = await fetch(this.config.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.config.apiKey || ''}`, + }, + body: JSON.stringify({ + model: this.config.model, + messages: messages, + temperature: this.config.temperature || 0.7, + max_tokens: this.config.maxTokens || 2000, + }), + }); + + if (!response.ok) { + throw new Error(`LLM API error: ${response.statusText}`); + } + + const data = await response.json(); + return this.parseResponse(data); + } + + async isAvailable(): Promise { + try { + // Test API connectivity + const testResponse = await fetch(this.config.endpoint, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.config.apiKey || ''}`, + }, + }); + return testResponse.ok || testResponse.status === 404; // Some APIs don't support GET + } catch (error) { + return false; + } + } + + getName(): string { + return this.config.provider; + } + + private parseResponse(data: any): LLMResponse { + return { + content: data.choices[0].message.content, + usage: { + promptTokens: data.usage?.prompt_tokens || 0, + completionTokens: data.usage?.completion_tokens || 0, + totalTokens: data.usage?.total_tokens || 0, + }, + model: data.model, + finishReason: data.choices[0].finish_reason, + }; + } +} +``` + +### 2.2 LLM Router + +```typescript +// src/llm/llm-router.ts + +export class LLMRouter { + private providers: Map; + private defaultProvider: string; + private fallbackProviders: string[]; + + constructor() { + this.providers = new Map(); + this.fallbackProviders = []; + } + + registerProvider(name: string, provider: ILLMProvider): void { + this.providers.set(name, provider); + } + + setDefaultProvider(name: string): void { + if (!this.providers.has(name)) { + throw new Error(`Provider ${name} not registered`); + } + this.defaultProvider = name; + } + + setFallbackChain(providers: string[]): void { + this.fallbackProviders = providers; + } + + async generate( + messages: LLMMessage[], + preferredProvider?: string + ): Promise { + const providerChain = this.buildProviderChain(preferredProvider); + + for (const providerName of providerChain) { + const provider = this.providers.get(providerName); + if (!provider) continue; + + try { + const isAvailable = await provider.isAvailable(); + if (!isAvailable) continue; + + return await provider.generateCompletion(messages); + } catch (error) { + console.error(`Provider ${providerName} failed:`, error); + // Try next provider in chain + } + } + + throw new Error('All LLM providers failed'); + } + + private buildProviderChain(preferredProvider?: string): string[] { + const chain: string[] = []; + + if (preferredProvider && this.providers.has(preferredProvider)) { + chain.push(preferredProvider); + } + + if (this.defaultProvider && !chain.includes(this.defaultProvider)) { + chain.push(this.defaultProvider); + } + + for (const fallback of this.fallbackProviders) { + if (!chain.includes(fallback)) { + chain.push(fallback); + } + } + + return chain; + } +} +``` + +### 2.3 Prompt Manager + +```typescript +// src/llm/prompt-manager.ts + +export interface PromptTemplate { + name: string; + description: string; + systemPrompt: string; + userPromptTemplate: string; + variables: string[]; +} + +export class PromptManager { + private templates: Map; + + constructor() { + this.templates = new Map(); + this.loadDefaultTemplates(); + } + + private loadDefaultTemplates(): void { + // Card generation prompt + this.registerTemplate({ + name: 'generate_cards', + description: 'Generate flashcards from markdown content', + systemPrompt: `You are a helpful assistant that creates high-quality flashcards from markdown content. +Your task is to analyze the given content and generate flashcards that help with learning and retention. + +Guidelines: +- Create clear, concise questions +- Provide accurate and complete answers +- Use appropriate card types (Basic, Cloze, Q&A) +- Focus on key concepts and important information +- Avoid overly complex or ambiguous questions`, + userPromptTemplate: `Please analyze the following markdown content and generate flashcards: + +Content: +\`\`\`markdown +{{content}} +\`\`\` + +Generate flashcards in the following JSON format: +\`\`\`json +[ + { + "type": "basic" | "cloze" | "qa", + "front": "Question or prompt", + "back": "Answer or explanation", + "tags": ["tag1", "tag2"] + } +] +\`\`\``, + variables: ['content'] + }); + + // Answer generation prompt + this.registerTemplate({ + name: 'generate_answer', + description: 'Generate answer for a given question', + systemPrompt: `You are a knowledgeable tutor that provides clear, accurate answers to questions. +Your answers should be: +- Accurate and factually correct +- Clear and easy to understand +- Appropriately detailed based on context +- Well-structured with examples when helpful`, + userPromptTemplate: `Question: {{question}} + +Context (if available): +{{context}} + +Please provide a comprehensive answer to the question above.`, + variables: ['question', 'context'] + }); + + // Card improvement prompt + this.registerTemplate({ + name: 'improve_card', + description: 'Improve existing flashcard', + systemPrompt: `You are an expert in creating effective flashcards for learning. +Analyze the given flashcard and suggest improvements for: +- Clarity and conciseness +- Accuracy +- Learning effectiveness +- Better formatting`, + userPromptTemplate: `Current flashcard: +Front: {{front}} +Back: {{back}} + +Please provide an improved version of this flashcard.`, + variables: ['front', 'back'] + }); + } + + registerTemplate(template: PromptTemplate): void { + this.templates.set(template.name, template); + } + + getTemplate(name: string): PromptTemplate | undefined { + return this.templates.get(name); + } + + renderPrompt( + templateName: string, + variables: Record + ): LLMMessage[] { + const template = this.templates.get(templateName); + if (!template) { + throw new Error(`Template ${templateName} not found`); + } + + // Validate variables + for (const varName of template.variables) { + if (!(varName in variables)) { + throw new Error(`Missing variable: ${varName}`); + } + } + + // Render user prompt + let userPrompt = template.userPromptTemplate; + for (const [key, value] of Object.entries(variables)) { + userPrompt = userPrompt.replace( + new RegExp(`{{${key}}}`, 'g'), + value + ); + } + + return [ + { role: 'system', content: template.systemPrompt }, + { role: 'user', content: userPrompt } + ]; + } +} +``` + +### 2.4 Smart Card Generator + +```typescript +// src/llm/card-generator.ts + +export interface GeneratedCard { + type: string; + front: string; + back: string; + tags?: string[]; + confidence?: number; +} + +export class SmartCardGenerator { + private llmRouter: LLMRouter; + private promptManager: PromptManager; + + constructor(llmRouter: LLMRouter, promptManager: PromptManager) { + this.llmRouter = llmRouter; + this.promptManager = promptManager; + } + + async generateCards( + content: string, + options?: { + maxCards?: number; + preferredTypes?: string[]; + context?: string; + } + ): Promise { + const messages = this.promptManager.renderPrompt('generate_cards', { + content: content + }); + + const response = await this.llmRouter.generate(messages); + return this.parseCardResponse(response.content); + } + + async generateAnswer( + question: string, + context?: string + ): Promise { + const messages = this.promptManager.renderPrompt('generate_answer', { + question: question, + context: context || 'No additional context provided.' + }); + + const response = await this.llmRouter.generate(messages); + return response.content.trim(); + } + + async improveCard( + front: string, + back: string + ): Promise<{ front: string; back: string }> { + const messages = this.promptManager.renderPrompt('improve_card', { + front: front, + back: back + }); + + const response = await this.llmRouter.generate(messages); + return this.parseImprovedCard(response.content); + } + + private parseCardResponse(response: string): GeneratedCard[] { + try { + // Extract JSON from response + const jsonMatch = response.match(/```json\n([\s\S]*?)\n```/); + if (jsonMatch) { + return JSON.parse(jsonMatch[1]); + } + + // Try parsing the entire response as JSON + return JSON.parse(response); + } catch (error) { + console.error('Failed to parse card response:', error); + throw new Error('Invalid card generation response'); + } + } + + private parseImprovedCard(response: string): { front: string; back: string } { + // Parse improved card from response + // This is a simplified version + const lines = response.split('\n'); + let front = ''; + let back = ''; + let currentSection = ''; + + for (const line of lines) { + if (line.includes('Front:')) { + currentSection = 'front'; + } else if (line.includes('Back:')) { + currentSection = 'back'; + } else if (currentSection === 'front') { + front += line + '\n'; + } else if (currentSection === 'back') { + back += line + '\n'; + } + } + + return { + front: front.trim(), + back: back.trim() + }; + } +} +``` + +### 2.5 Content Analyzer + +```typescript +// src/llm/content-analyzer.ts + +export interface ContentSection { + type: 'heading' | 'paragraph' | 'list' | 'code' | 'quote'; + content: string; + level?: number; + cardPotential: number; // 0-1 score +} + +export class ContentAnalyzer { + analyzeMarkdown(markdown: string): ContentSection[] { + const sections: ContentSection[] = []; + const lines = markdown.split('\n'); + + let currentSection: ContentSection | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith('#')) { + if (currentSection) sections.push(currentSection); + currentSection = { + type: 'heading', + content: trimmed, + level: this.getHeadingLevel(trimmed), + cardPotential: 0.7 + }; + } else if (trimmed.startsWith('-') || trimmed.startsWith('*')) { + if (currentSection?.type !== 'list') { + if (currentSection) sections.push(currentSection); + currentSection = { + type: 'list', + content: trimmed, + cardPotential: 0.8 + }; + } else { + currentSection.content += '\n' + trimmed; + } + } else if (trimmed.startsWith('>')) { + if (currentSection?.type !== 'quote') { + if (currentSection) sections.push(currentSection); + currentSection = { + type: 'quote', + content: trimmed, + cardPotential: 0.6 + }; + } else { + currentSection.content += '\n' + trimmed; + } + } else if (trimmed) { + if (currentSection?.type !== 'paragraph') { + if (currentSection) sections.push(currentSection); + currentSection = { + type: 'paragraph', + content: trimmed, + cardPotential: 0.5 + }; + } else { + currentSection.content += ' ' + trimmed; + } + } + } + + if (currentSection) sections.push(currentSection); + + return sections; + } + + private getHeadingLevel(heading: string): number { + const match = heading.match(/^#+/); + return match ? match[0].length : 0; + } + + selectCandidateSections( + sections: ContentSection[], + threshold: number = 0.6 + ): ContentSection[] { + return sections.filter(section => section.cardPotential >= threshold); + } +} +``` + +## 3. Python 스크립트 통합 + +### 3.1 Python LLM 모듈 + +```python +# llm_integration.py + +import json +import requests +from typing import List, Dict, Optional +from abc import ABC, abstractmethod + +class LLMProvider(ABC): + """Base class for LLM providers""" + + @abstractmethod + def initialize(self, config: Dict) -> None: + pass + + @abstractmethod + def generate_completion(self, messages: List[Dict]) -> Dict: + pass + + @abstractmethod + def is_available(self) -> bool: + pass + +class OpenAICompatibleProvider(LLMProvider): + """OpenAI-compatible API provider""" + + def __init__(self): + self.config = None + + def initialize(self, config: Dict) -> None: + self.config = config + required = ['endpoint', 'model'] + for key in required: + if key not in config: + raise ValueError(f"Missing required config: {key}") + + def generate_completion(self, messages: List[Dict]) -> Dict: + headers = { + 'Content-Type': 'application/json', + } + + if self.config.get('api_key'): + headers['Authorization'] = f"Bearer {self.config['api_key']}" + + payload = { + 'model': self.config['model'], + 'messages': messages, + 'temperature': self.config.get('temperature', 0.7), + 'max_tokens': self.config.get('max_tokens', 2000), + } + + response = requests.post( + self.config['endpoint'], + headers=headers, + json=payload, + timeout=self.config.get('timeout', 60) + ) + + response.raise_for_status() + data = response.json() + + return { + 'content': data['choices'][0]['message']['content'], + 'usage': data.get('usage', {}), + 'model': data['model'], + 'finish_reason': data['choices'][0]['finish_reason'] + } + + def is_available(self) -> bool: + try: + response = requests.get( + self.config['endpoint'].rsplit('/', 1)[0], + timeout=5 + ) + return response.status_code in [200, 404] + except: + return False + +class SmartCardGenerator: + """Generate flashcards using LLM""" + + def __init__(self, provider: LLMProvider): + self.provider = provider + + def generate_cards(self, content: str, context: Optional[str] = None) -> List[Dict]: + prompt = self._build_card_generation_prompt(content, context) + + messages = [ + { + 'role': 'system', + 'content': 'You are a helpful assistant that creates high-quality flashcards.' + }, + { + 'role': 'user', + 'content': prompt + } + ] + + response = self.provider.generate_completion(messages) + return self._parse_cards(response['content']) + + def generate_answer(self, question: str, context: Optional[str] = None) -> str: + messages = [ + { + 'role': 'system', + 'content': 'You are a knowledgeable tutor providing clear answers.' + }, + { + 'role': 'user', + 'content': f"Question: {question}\n\nContext: {context or 'None'}" + } + ] + + response = self.provider.generate_completion(messages) + return response['content'].strip() + + def _build_card_generation_prompt(self, content: str, context: Optional[str]) -> str: + return f"""Analyze this markdown content and generate flashcards: + +{content} + +Generate flashcards in JSON format: +[ + {{ + "type": "basic", + "front": "Question", + "back": "Answer", + "tags": ["tag1"] + }} +] +""" + + def _parse_cards(self, response: str) -> List[Dict]: + import re + # Extract JSON from response + json_match = re.search(r'```json\n(.*?)\n```', response, re.DOTALL) + if json_match: + return json.loads(json_match.group(1)) + + try: + return json.loads(response) + except: + raise ValueError("Failed to parse card response") +``` + +### 3.2 설정 파일 확장 + +```ini +# obsidian_to_anki_config.ini + +[LLM] +# Enable LLM features +Enabled = True + +# Primary provider configuration +Primary Provider = ollama +Primary Endpoint = http://localhost:11434/v1/chat/completions +Primary Model = llama2 +Primary API Key = + +# Fallback provider (optional) +Fallback Provider = openrouter +Fallback Endpoint = https://openrouter.ai/api/v1/chat/completions +Fallback Model = anthropic/claude-3-haiku +Fallback API Key = + +# LLM parameters +Temperature = 0.7 +Max Tokens = 2000 +Timeout = 60 + +# Feature flags +Auto Generate Cards = False +Auto Generate Answers = True +Show Preview = True +Batch Size = 10 +``` + +## 4. 데이터 흐름 + +### 4.1 자동 카드 생성 흐름 + +``` +1. User triggers card generation + ↓ +2. File Manager reads markdown file + ↓ +3. Content Analyzer extracts sections + ↓ +4. For each high-potential section: + ↓ +5. Smart Card Generator calls LLM Router + ↓ +6. LLM Router tries providers in order + ↓ +7. Prompt Manager renders template + ↓ +8. Provider makes API call + ↓ +9. Response parsed into GeneratedCard[] + ↓ +10. User reviews cards (if preview enabled) + ↓ +11. Approved cards sent to AnkiConnect +``` + +### 4.2 해답 생성 흐름 + +``` +1. User has question without answer + ↓ +2. Question + file context extracted + ↓ +3. Smart Card Generator.generateAnswer() + ↓ +4. LLM generates answer + ↓ +5. Answer displayed for review + ↓ +6. User approves/edits answer + ↓ +7. Card created with answer +``` + +## 5. 설정 관리 + +### 5.1 플러그인 설정 UI + +```typescript +// src/settings-llm.ts + +export interface LLMSettings { + enabled: boolean; + providers: LLMProviderConfig[]; + defaultProvider: string; + fallbackChain: string[]; + autoGenerate: boolean; + showPreview: boolean; + batchSize: number; + customPrompts: Record; +} + +export interface LLMProviderConfig { + name: string; + type: 'openai' | 'ollama' | 'openrouter' | 'custom'; + endpoint: string; + apiKey?: string; + model: string; + enabled: boolean; +} +``` + +### 5.2 보안 고려사항 + +- API 키는 Obsidian의 secure storage에 저장 +- 환경 변수로부터 API 키 로드 지원 +- 로그에 API 키 노출 방지 +- HTTPS 엔드포인트 권장 + +## 6. 에러 처리 + +### 6.1 에러 타입 + +```typescript +export enum LLMErrorType { + PROVIDER_UNAVAILABLE = 'provider_unavailable', + API_ERROR = 'api_error', + TIMEOUT = 'timeout', + PARSE_ERROR = 'parse_error', + RATE_LIMIT = 'rate_limit', + INVALID_CONFIG = 'invalid_config' +} + +export class LLMError extends Error { + type: LLMErrorType; + provider?: string; + retryable: boolean; + + constructor(message: string, type: LLMErrorType, retryable: boolean = false) { + super(message); + this.type = type; + this.retryable = retryable; + } +} +``` + +### 6.2 재시도 전략 + +- 네트워크 에러: 최대 3회 재시도 (exponential backoff) +- Rate limit: 백오프 후 재시도 +- Provider 실패: 다음 provider로 폴백 +- Parse 에러: 에러 로그 후 사용자에게 알림 + +## 7. 테스트 전략 + +### 7.1 단위 테스트 +- 각 Provider 클래스 +- LLM Router fallback 로직 +- Prompt 렌더링 +- Content Analyzer + +### 7.2 통합 테스트 +- End-to-end 카드 생성 +- 실제 LLM API 호출 (mocked) +- AnkiConnect 통합 + +### 7.3 사용자 테스트 +- 다양한 markdown 형식 +- 여러 LLM provider +- 에러 상황 처리 + +## 8. 성능 최적화 + +### 8.1 캐싱 +- LLM 응답 캐싱 (파일 해시 기반) +- Provider availability 캐싱 + +### 8.2 배치 처리 +- 여러 카드 생성 요청을 배치로 처리 +- 병렬 LLM API 호출 + +### 8.3 토큰 최적화 +- 불필요한 컨텍스트 제거 +- 프롬프트 길이 최적화 + +## 9. 확장성 + +### 9.1 새 Provider 추가 +1. `ILLMProvider` 인터페이스 구현 +2. Router에 등록 +3. 설정 UI에 추가 + +### 9.2 커스텀 프롬프트 +- 사용자가 프롬프트 템플릿 추가/수정 가능 +- 변수 시스템 사용 +- 노트 타입별 프롬프트 지원 + +## 10. 마이그레이션 계획 + +### Phase 1: 기반 구축 +- LLM Provider 인터페이스 구현 +- 기본 OpenAI 호환 provider 구현 +- 설정 시스템 확장 + +### Phase 2: 코어 기능 +- Smart Card Generator 구현 +- Content Analyzer 구현 +- 기본 UI 통합 + +### Phase 3: 고급 기능 +- 복수 provider 지원 +- 프롬프트 커스터마이징 +- 배치 처리 + +### Phase 4: 최적화 +- 성능 튜닝 +- 캐싱 시스템 +- 에러 처리 개선 + +## 11. 보안 및 프라이버시 + +### 11.1 데이터 처리 +- 사용자 선택: 로컬 LLM vs 클라우드 +- 민감 정보 필터링 옵션 +- 데이터 전송 최소화 + +### 11.2 API 키 관리 +- 암호화된 저장소 +- 환경 변수 지원 +- .gitignore 자동 설정 diff --git a/.docs/ENHANCEMENT_PLAN.md b/.docs/ENHANCEMENT_PLAN.md new file mode 100644 index 00000000..bdc73730 --- /dev/null +++ b/.docs/ENHANCEMENT_PLAN.md @@ -0,0 +1,370 @@ +# LLM 카드 생성 고도화 계획 + +## 현재 시스템의 한계 + +### 1. 토큰 제한 문제 +- 긴 문서를 한 번에 처리하려고 시도 +- LLM의 컨텍스트 윈도우 제한 (2K-8K 토큰) +- 중간에 잘려서 중요한 내용 누락 가능 + +### 2. 단순한 처리 방식 +- 단일 패스로 모든 카드 생성 시도 +- 카드 품질 검증 없음 +- 중요도/우선순위 고려 없음 + +### 3. 컨텍스트 손실 +- 문서 전체의 맥락이 각 섹션에 전달되지 않음 +- 섹션 간 연결성 파악 어려움 + +### 4. 품질 관리 부재 +- 생성된 카드의 품질 검증 없음 +- 중복 카드 탐지 없음 +- 난이도 조절 없음 + +## 고도화 전략 + +### Phase 1: 스마트 청킹 시스템 +**목표**: 긴 문서를 의미있는 단위로 분할 + +#### 1.1 계층적 청킹 +``` +문서 전체 +├── 챕터 1 +│ ├── 섹션 1.1 +│ │ ├── 단락들 +│ │ └── 코드/예제 +│ └── 섹션 1.2 +└── 챕터 2 + └── ... +``` + +#### 1.2 청킹 전략 +- **Heading-based**: 제목 계층 구조 활용 +- **Semantic**: 의미적 유사도 기반 그룹핑 +- **Size-aware**: 토큰 제한 고려한 크기 조절 +- **Context-preserving**: 각 청크에 상위 컨텍스트 포함 + +#### 1.3 청크 메타데이터 +```typescript +interface DocumentChunk { + id: string; + content: string; + metadata: { + level: number; // 계층 깊이 + parent?: string; // 부모 청크 ID + siblings: string[]; // 형제 청크 IDs + tokenCount: number; // 토큰 수 + importance: number; // 중요도 점수 (0-1) + keywords: string[]; // 핵심 키워드 + summary: string; // 요약 + }; +} +``` + +### Phase 2: Multi-Pass 카드 생성 + +#### Pass 1: 문서 분석 및 계획 +**목표**: 전체 구조 파악 및 카드 생성 계획 수립 + +``` +Input: 전체 문서 +Process: + 1. 문서 구조 분석 + 2. 주요 주제 추출 + 3. 중요 섹션 식별 + 4. 카드 생성 우선순위 결정 +Output: 문서 개요 + 생성 계획 +``` + +**프롬프트 예시**: +``` +당신은 학습 자료 분석 전문가입니다. +다음 문서를 분석하여: +1. 주요 주제와 하위 주제 목록 +2. 각 섹션의 중요도 (1-10) +3. 섹션 간 의존성 +4. 권장 카드 수 +5. 학습 난이도 + +문서: +{document_overview} + +JSON 형식으로 응답하세요. +``` + +#### Pass 2: 청크별 카드 생성 +**목표**: 각 청크에서 고품질 카드 생성 + +``` +For each chunk: + Input: 청크 내용 + 전체 컨텍스트 + Process: + 1. 청크 내 핵심 개념 추출 + 2. 개념별 카드 타입 결정 + 3. 카드 초안 생성 + 4. 카드 간 중복 체크 + Output: 카드 리스트 + 메타데이터 +``` + +**향상된 프롬프트**: +``` +[Context] +문서 제목: {title} +현재 섹션: {section_path} +이전 섹션 요약: {prev_summary} + +[Task] +다음 내용에서 플래시카드를 생성하세요: +{chunk_content} + +[Guidelines] +1. 핵심 개념에 집중 +2. 각 카드는 하나의 명확한 질문 +3. 답변은 정확하고 완전해야 함 +4. 카드 타입 선택: + - Basic: 정의, 개념, 사실 + - Cloze: 문장 내 핵심 용어 + - Q&A: 설명이 필요한 질문 + +[Output Format] +{ + "cards": [ + { + "type": "basic", + "front": "...", + "back": "...", + "rationale": "이 카드를 만든 이유", + "difficulty": 1-5, + "prerequisites": ["관련 개념들"], + "tags": ["tag1", "tag2"] + } + ], + "summary": "이 섹션의 핵심 내용 요약" +} +``` + +#### Pass 3: 카드 검증 및 개선 +**목표**: 생성된 모든 카드 검증 및 품질 향상 + +``` +Input: 생성된 모든 카드 +Process: + 1. 중복 카드 탐지 및 병합 + 2. 명확성 검증 + 3. 정확성 검증 + 4. 난이도 균형 조정 + 5. 태그 정규화 +Output: 최종 카드 세트 +``` + +**검증 프롬프트**: +``` +다음 카드들을 검토하세요: +{generated_cards} + +검증 항목: +1. 중복 또는 유사한 카드 +2. 불명확한 질문 +3. 불완전한 답변 +4. 난이도 불균형 +5. 누락된 중요 개념 + +개선 제안을 JSON으로 제공하세요. +``` + +### Phase 3: 점진적 처리 시스템 + +#### 3.1 스트리밍 처리 +```typescript +async function* generateCardsProgressively( + document: string +): AsyncGenerator { + // 1. 청킹 + const chunks = await chunkDocument(document); + + // 2. 각 청크 처리 + for (const chunk of chunks) { + yield { + status: 'analyzing', + progress: chunk.index / chunks.length, + message: `Analyzing ${chunk.metadata.heading}...` + }; + + const cards = await generateCardsForChunk(chunk); + + yield { + status: 'generated', + progress: chunk.index / chunks.length, + cards: cards, + chunk: chunk.metadata + }; + } + + // 3. 검증 + yield { + status: 'validating', + progress: 0.95, + message: 'Validating all cards...' + }; + + // 4. 완료 + yield { + status: 'completed', + progress: 1.0 + }; +} +``` + +#### 3.2 진행 상황 UI +- 실시간 진행률 표시 +- 현재 처리 중인 섹션 표시 +- 생성된 카드 수 실시간 업데이트 +- 취소/일시정지 옵션 + +### Phase 4: 컨텍스트 관리 시스템 + +#### 4.1 글로벌 컨텍스트 +```typescript +interface DocumentContext { + title: string; + overview: string; // 전체 문서 요약 + mainTopics: string[]; // 주요 주제들 + glossary: Map; // 용어 사전 + structure: TreeNode; // 문서 구조 트리 +} +``` + +#### 4.2 로컬 컨텍스트 +```typescript +interface ChunkContext { + global: DocumentContext; + parent: { + heading: string; + summary: string; + }; + previous: { + heading: string; + summary: string; + keyPoints: string[]; + }; + current: DocumentChunk; +} +``` + +### Phase 5: 품질 관리 시스템 + +#### 5.1 카드 품질 메트릭 +```typescript +interface CardQuality { + clarity: number; // 명확성 (0-1) + accuracy: number; // 정확성 (0-1) + completeness: number; // 완전성 (0-1) + difficulty: number; // 난이도 (1-5) + uniqueness: number; // 고유성 (0-1) + overall: number; // 종합 점수 (0-1) +} +``` + +#### 5.2 자동 품질 검사 +- **명확성**: 질문이 모호하지 않은가? +- **정확성**: 답변이 정확한가? +- **완전성**: 답변이 충분한가? +- **중복성**: 다른 카드와 중복되지 않는가? +- **적절성**: 난이도가 적절한가? + +#### 5.3 품질 임계값 +```typescript +const QUALITY_THRESHOLDS = { + minimum: 0.6, // 이 이하는 자동 제거 + warning: 0.7, // 경고 표시 + good: 0.8, // 양호 + excellent: 0.9 // 우수 +}; +``` + +## 구현 우선순위 + +### P0 (즉시 구현) +1. ✅ DocumentChunker - 스마트 청킹 +2. ✅ EnhancedPromptTemplates - 향상된 프롬프트 +3. ✅ MultiPassGenerator - 다단계 생성 +4. ✅ ProgressTracker - 진행 상황 추적 + +### P1 (다음 단계) +5. ✅ CardValidator - 카드 검증 +6. ✅ ContextManager - 컨텍스트 관리 +7. ✅ QualityScorer - 품질 점수 시스템 + +### P2 (향후) +8. DuplicateDetector - 중복 탐지 +9. DifficultyBalancer - 난이도 균형 +10. AdaptiveLearning - 사용자 피드백 학습 + +## 예상 효과 + +### 처리 능력 +- **Before**: 2,000 토큰 문서만 처리 가능 +- **After**: 100,000+ 토큰 문서 처리 가능 + +### 카드 품질 +- **Before**: 단순 추출, 품질 보장 없음 +- **After**: 검증된 고품질 카드, 중복 제거 + +### 사용자 경험 +- **Before**: "생성 중..." 후 결과만 표시 +- **After**: 실시간 진행률, 단계별 피드백 + +### 처리 시간 +- **Before**: 긴 문서 실패 또는 품질 저하 +- **After**: 시간은 더 걸리지만 안정적이고 고품질 + +## 기술 스택 + +### 새로운 컴포넌트 +``` +src/llm/ +├── chunking/ +│ ├── document-chunker.ts ⭐ NEW +│ ├── semantic-chunker.ts ⭐ NEW +│ └── chunk-optimizer.ts ⭐ NEW +├── generation/ +│ ├── multi-pass-generator.ts ⭐ NEW +│ ├── context-manager.ts ⭐ NEW +│ └── batch-processor.ts ⭐ NEW +├── validation/ +│ ├── card-validator.ts ⭐ NEW +│ ├── quality-scorer.ts ⭐ NEW +│ └── duplicate-detector.ts ⭐ NEW +└── ui/ + ├── progress-modal.ts ⭐ NEW + └── quality-report.ts ⭐ NEW +``` + +## 측정 지표 + +### 성능 지표 +- 처리 속도: 토큰/초 +- 청크 수: 문서당 평균 +- 카드 생성률: 청크당 평균 + +### 품질 지표 +- 평균 품질 점수 +- 중복 카드 비율 +- 사용자 승인율 + +### 사용자 만족도 +- 생성 성공률 +- 재생성 요청률 +- 수동 편집률 + +## 다음 단계 + +1. ✅ DocumentChunker 구현 +2. ✅ 향상된 프롬프트 작성 +3. ✅ MultiPassGenerator 구현 +4. ✅ Progress UI 구현 +5. ✅ 통합 테스트 +6. 🔄 사용자 피드백 수집 +7. 🔄 반복 개선 + +이 계획을 통해 긴 문서도 안정적으로 처리하고, 고품질의 학습 카드를 생성할 수 있습니다. diff --git a/.docs/README_ko.md b/.docs/README_ko.md new file mode 100644 index 00000000..dc4c22be --- /dev/null +++ b/.docs/README_ko.md @@ -0,0 +1,668 @@ +# Obsidian to Anki - 한국어 가이드 + +## 목차 + +1. [소개](#소개) +2. [기본 사용법](#기본-사용법) +3. [새로운 AI 기능](#새로운-ai-기능) +4. [설치 및 설정](#설치-및-설정) +5. [LLM 설정 가이드](#llm-설정-가이드) +6. [고급 사용법](#고급-사용법) +7. [문제 해결](#문제-해결) +8. [FAQ](#faq) + +--- + +## 소개 + +### Obsidian to Anki란? + +Obsidian의 마크다운 파일에서 Anki 플래시카드를 자동으로 생성하는 도구입니다. +두 가지 방식으로 사용할 수 있습니다: + +- **Obsidian 플러그인**: Obsidian 내에서 직접 실행 +- **Python 스크립트**: 명령줄에서 독립적으로 실행 + +### 주요 기능 + +#### 기존 기능 +- ✅ 다양한 플래시카드 스타일 지원 (Basic, Cloze, Q&A 등) +- ✅ 마크다운, 수식, 이미지, 오디오 지원 +- ✅ 커스텀 노트 타입 및 정규식 +- ✅ 자동 파일 스캔 및 업데이트 +- ✅ 태그 및 덱(Deck) 관리 + +#### 새로운 AI 기능 ⭐ +- 🤖 **AI 기반 카드 자동 생성**: 마크다운 내용을 분석하여 자동으로 플래시카드 생성 +- 💡 **스마트 해답 생성**: 질문에 대한 답변을 AI가 생성 +- 🔄 **카드 품질 개선**: 기존 카드를 AI가 분석하여 개선 제안 +- 🌐 **다양한 LLM 지원**: Ollama, LM Studio, OpenRouter, OpenAI 등 + +--- + +## 기본 사용법 + +### Obsidian 플러그인 사용 + +#### 1. 기본 설치 + +1. Obsidian을 실행합니다 +2. 설정(⚙️) → 커뮤니티 플러그인 → 탐색 +3. "Obsidian to Anki" 검색 및 설치 +4. 플러그인 활성화 + +#### 2. Anki 설정 + +1. Anki를 실행합니다 +2. 도구 → 부가기능 → AnkiConnect 설치 +3. 도구 → 부가기능 → AnkiConnect → 설정 +4. 다음 설정을 입력합니다: + +```json +{ + "apiKey": null, + "apiLogPath": null, + "webBindAddress": "127.0.0.1", + "webBindPort": 8765, + "webCorsOrigin": "http://localhost", + "webCorsOriginList": [ + "http://localhost", + "app://obsidian.md" + ] +} +``` + +5. Anki를 재시작합니다 + +#### 3. 기본 카드 작성 + +마크다운 파일에 다음과 같이 작성합니다: + +```markdown +START +Basic +질문은 무엇인가요? +Back: 답변은 이것입니다. +END +``` + +Obsidian에서 Anki 아이콘을 클릭하면 카드가 Anki에 추가됩니다. + +### Python 스크립트 사용 + +#### 1. 설치 + +```bash +# Python 3.8 이상 필요 +pip install -r requirements.txt +``` + +#### 2. 설정 파일 생성 + +첫 실행 시 `obsidian_to_anki_config.ini` 파일이 자동 생성됩니다: + +```bash +python obsidian_to_anki.py +``` + +#### 3. 설정 파일 수정 + +`obsidian_to_anki_config.ini`를 열어 필요한 설정을 변경합니다: + +```ini +[Defaults] +Deck = Default +Tag = Obsidian_to_Anki +``` + +#### 4. 실행 + +```bash +python obsidian_to_anki.py your_note.md +``` + +--- + +## 새로운 AI 기능 + +### AI 기반 카드 자동 생성 + +#### 개요 + +AI(LLM)를 사용하여 마크다운 파일의 내용을 분석하고 자동으로 플래시카드를 생성합니다. + +#### 사용 방법 + +1. **Obsidian 플러그인**: + - 파일을 열고 명령 팔레트(Ctrl/Cmd + P)를 실행 + - "Generate Cards with AI" 선택 + - 생성된 카드를 미리보고 승인 + +2. **Python 스크립트**: + ```bash + python obsidian_to_anki.py --llm-generate your_note.md + ``` + +#### 예제 + +**입력 (마크다운)**: +```markdown +# 프로그래밍 언어 + +## Python +Python은 간결하고 읽기 쉬운 문법을 가진 프로그래밍 언어입니다. +주요 특징: +- 인터프리터 언어 +- 동적 타이핑 +- 풍부한 라이브러리 + +## JavaScript +JavaScript는 웹 개발에서 가장 많이 사용되는 언어입니다. +``` + +**AI 생성 카드**: +``` +카드 1: +Q: Python의 주요 특징 3가지는 무엇인가요? +A: 1) 인터프리터 언어 2) 동적 타이핑 3) 풍부한 라이브러리 + +카드 2: +Q: JavaScript는 주로 어디에 사용되나요? +A: 웹 개발에서 가장 많이 사용됩니다. +``` + +### 스마트 해답 생성 + +#### 개요 + +질문만 작성하면 AI가 답변을 자동으로 생성합니다. + +#### 사용 방법 + +```markdown +START +Basic +Q: 머신러닝과 딥러닝의 차이는? +Back: [AI_GENERATE] +END +``` + +AI가 파일의 컨텍스트를 참고하여 답변을 생성합니다. + +### 카드 품질 개선 + +#### 개요 + +기존 카드를 AI가 분석하여 더 나은 버전을 제안합니다. + +#### 사용 방법 + +1. 카드를 선택 +2. 명령 팔레트에서 "Improve Card with AI" 실행 +3. 개선된 버전 확인 및 적용 + +--- + +## 설치 및 설정 + +### 시스템 요구사항 + +- **Obsidian**: 최신 버전 권장 +- **Anki**: 2.1.x 이상 +- **Python**: 3.8 이상 (Python 스크립트 사용 시) +- **AnkiConnect**: Anki 부가기능 + +### AI 기능 사용을 위한 추가 요구사항 + +- **로컬 LLM**: Ollama, LM Studio 등 (권장) +- **또는 클라우드 LLM**: OpenRouter, OpenAI API 키 + +--- + +## LLM 설정 가이드 + +### Option 1: Ollama (권장 - 로컬, 무료) + +#### 1. Ollama 설치 + +```bash +# macOS/Linux +curl https://ollama.ai/install.sh | sh + +# Windows +# https://ollama.ai 에서 다운로드 +``` + +#### 2. 모델 다운로드 + +```bash +ollama pull llama2 +# 또는 +ollama pull mistral +``` + +#### 3. 플러그인 설정 + +Obsidian → 설정 → Obsidian to Anki → LLM 설정: + +``` +Provider: Ollama +Endpoint: http://localhost:11434/v1/chat/completions +Model: llama2 +API Key: (비워두기) +``` + +#### 4. 연결 테스트 + +"Test Connection" 버튼 클릭 → "✓ Connected" 확인 + +### Option 2: LM Studio (로컬, 무료, GUI) + +#### 1. LM Studio 설치 + +https://lmstudio.ai 에서 다운로드 및 설치 + +#### 2. 모델 다운로드 + +1. LM Studio 실행 +2. "Download" 탭에서 모델 검색 (예: "Mistral") +3. 모델 다운로드 + +#### 3. 로컬 서버 시작 + +1. "Local Server" 탭 +2. 모델 선택 +3. "Start Server" 클릭 +4. 서버 주소 확인 (보통 `http://localhost:1234`) + +#### 4. 플러그인 설정 + +``` +Provider: LM Studio +Endpoint: http://localhost:1234/v1/chat/completions +Model: (다운로드한 모델 이름) +API Key: (비워두기) +``` + +### Option 3: OpenRouter (클라우드, 유료) + +#### 1. API 키 발급 + +1. https://openrouter.ai 가입 +2. API Keys 메뉴에서 새 키 생성 +3. 크레딧 충전 + +#### 2. 플러그인 설정 + +``` +Provider: OpenRouter +Endpoint: https://openrouter.ai/api/v1/chat/completions +Model: anthropic/claude-3-haiku (또는 다른 모델) +API Key: sk-or-v1-... (발급받은 키) +``` + +#### 3. 모델 선택 팁 + +- **Claude 3 Haiku**: 빠르고 저렴, 품질 우수 +- **GPT-3.5 Turbo**: 저렴하고 무난 +- **GPT-4**: 최고 품질, 비쌈 + +### Option 4: OpenAI (클라우드, 유료) + +#### 1. API 키 발급 + +1. https://platform.openai.com 가입 +2. API Keys 메뉴에서 새 키 생성 + +#### 2. 플러그인 설정 + +``` +Provider: OpenAI +Endpoint: https://api.openai.com/v1/chat/completions +Model: gpt-3.5-turbo +API Key: sk-... (발급받은 키) +``` + +### 비용 비교 + +| Provider | 비용 | 속도 | 품질 | 프라이버시 | +|----------|------|------|------|------------| +| Ollama | 무료 | 중간 | 중간 | 최상 | +| LM Studio | 무료 | 중간 | 중간 | 최상 | +| OpenRouter | 유료 | 빠름 | 높음 | 중간 | +| OpenAI | 유료 | 빠름 | 최고 | 중간 | + +**권장**: 처음 사용자는 **Ollama** 또는 **LM Studio**로 시작 + +--- + +## 고급 사용법 + +### 다양한 카드 스타일 + +#### 1. Basic 카드 + +```markdown +START +Basic +앞면 내용 +Back: 뒷면 내용 +END +``` + +#### 2. Cloze 카드 + +```markdown +START +Cloze +{{c1::Python}}은 {{c2::인터프리터}} 언어입니다. +END +``` + +#### 3. Q&A 스타일 + +```markdown +Q: 질문 내용? +A: 답변 내용 +``` + +### 태그 및 덱 관리 + +#### 파일별 태그 설정 + +```markdown +FILE TAGS: programming, python, basics +``` + +#### 파일별 덱 설정 + +```markdown +TARGET DECK: Programming::Python +``` + +#### 카드별 태그 + +```markdown +START +Basic +#important #exam +질문 내용? +Back: 답변 내용 +END +``` + +### 자동 스캔 설정 + +#### 특정 폴더만 스캔 + +Obsidian → 설정 → Obsidian to Anki → Scan Directory: +``` +/Notes/Study +``` + +#### 파일/폴더 제외 + +Ignore 설정: +``` +**/*.excalidraw.md +Template/** +**/private/** +``` + +### 프롬프트 커스터마이징 + +#### 1. 기본 프롬프트 확인 + +설정 → LLM → Prompts → "Card Generation" 선택 + +#### 2. 프롬프트 수정 + +``` +당신은 학습용 플래시카드를 만드는 전문가입니다. +다음 내용을 분석하여 효과적인 플래시카드를 생성하세요. + +규칙: +- 명확하고 간결한 질문 +- 정확한 답변 +- 중요한 개념에 집중 +- 한국어로 작성 + +내용: +{{content}} + +JSON 형식으로 출력: +[{"type": "basic", "front": "질문", "back": "답변", "tags": ["태그"]}] +``` + +### 배치 처리 + +#### Vault 전체 처리 + +```bash +python obsidian_to_anki.py --llm-generate --batch /path/to/vault +``` + +#### 진행 상황 확인 + +로그 파일 확인: +```bash +tail -f obsidian_to_anki.log +``` + +--- + +## 문제 해결 + +### 일반적인 문제 + +#### 1. Anki에 연결할 수 없음 + +**증상**: "Failed to connect to Anki" + +**해결방법**: +- Anki가 실행 중인지 확인 +- AnkiConnect가 설치되어 있는지 확인 +- AnkiConnect 설정 확인 (CORS 설정) +- Anki 재시작 + +#### 2. LLM에 연결할 수 없음 + +**증상**: "LLM Provider unavailable" + +**해결방법**: +- **Ollama**: `ollama list`로 모델 확인, 서버 실행 확인 +- **LM Studio**: Local Server 탭에서 서버 실행 확인 +- **클라우드**: API 키 확인, 크레딧 잔액 확인 +- 엔드포인트 URL 확인 + +#### 3. 카드가 생성되지 않음 + +**증상**: 카드가 Anki에 나타나지 않음 + +**해결방법**: +- START/END 마커 확인 +- 덱 이름 확인 +- Anki 동기화 +- 로그 파일 확인 + +#### 4. AI 생성 카드 품질이 낮음 + +**해결방법**: +- 더 나은 모델 사용 (예: GPT-4, Claude) +- 프롬프트 개선 +- 더 많은 컨텍스트 제공 +- Temperature 값 조정 (0.5-0.7 권장) + +### 로그 확인 + +#### Obsidian 플러그인 + +개발자 도구 열기 (Ctrl/Cmd + Shift + I) → Console 탭 + +#### Python 스크립트 + +```bash +# 상세 로그 모드 +python obsidian_to_anki.py --verbose your_note.md + +# 로그 파일 확인 +cat obsidian_to_anki.log +``` + +### 성능 최적화 + +#### 느린 카드 생성 + +**원인**: +- LLM 응답 시간 +- 네트워크 지연 + +**해결방법**: +- 로컬 LLM 사용 (Ollama, LM Studio) +- 배치 크기 조정 +- 캐싱 활성화 + +#### 토큰 비용 절감 + +**방법**: +- 로컬 LLM 사용 (무료) +- 더 작은 모델 사용 (GPT-3.5 대신 Haiku) +- 프롬프트 간소화 +- 캐싱 활성화 + +--- + +## FAQ + +### 기본 사용 + +#### Q: Python을 모르는데 사용할 수 있나요? +A: 네! Obsidian 플러그인으로 사용하면 Python 지식 없이 사용 가능합니다. + +#### Q: Anki가 꼭 필요한가요? +A: 네, 이 도구는 Anki에 카드를 추가하는 도구입니다. + +#### Q: 이미 작성한 노트에도 적용할 수 있나요? +A: 네, 기존 마크다운 파일에 카드 마커를 추가하거나 AI로 자동 생성할 수 있습니다. + +### AI 기능 + +#### Q: AI 기능을 사용하려면 비용이 드나요? +A: Ollama나 LM Studio 같은 로컬 LLM을 사용하면 완전 무료입니다. 클라우드 LLM은 사용량에 따라 비용이 발생합니다. + +#### Q: 어떤 LLM이 가장 좋나요? +A: 초보자는 Ollama(무료)를, 최고 품질이 필요하면 Claude 3 또는 GPT-4를 권장합니다. + +#### Q: AI가 생성한 카드를 검토해야 하나요? +A: 네, 항상 검토 후 승인하는 것을 권장합니다. 설정에서 프리뷰 기능을 활성화하세요. + +#### Q: AI가 한국어를 지원하나요? +A: 대부분의 최신 LLM은 한국어를 잘 지원합니다. 프롬프트에 "한국어로 작성" 같은 지시를 추가하면 더 좋습니다. + +#### Q: 개인정보가 걱정됩니다 +A: 로컬 LLM(Ollama, LM Studio)을 사용하면 데이터가 외부로 전송되지 않습니다. + +### 고급 사용 + +#### Q: 여러 LLM을 동시에 사용할 수 있나요? +A: 네, 기본 provider와 fallback provider를 설정할 수 있습니다. + +#### Q: 프롬프트를 어떻게 수정하나요? +A: 설정 → LLM → Prompts에서 수정 가능합니다. + +#### Q: 특정 노트 타입에만 AI를 적용할 수 있나요? +A: 네, 설정에서 노트 타입별로 AI 사용 여부를 지정할 수 있습니다. + +### 문제 해결 + +#### Q: 카드 생성이 너무 느려요 +A: 로컬 LLM으로 전환하거나 배치 크기를 줄여보세요. + +#### Q: API 키를 안전하게 보관하려면? +A: 설정 파일 대신 환경 변수를 사용하세요: +```bash +export OPENAI_API_KEY="your-key" +export OPENROUTER_API_KEY="your-key" +``` + +#### Q: 생성된 카드를 삭제하려면? +A: Anki에서 직접 삭제하거나, 마크다운 파일에서 DELETE 마커를 추가하세요. + +--- + +## 추가 자료 + +### 공식 문서 +- [프로젝트 Wiki](https://github.com/Pseudonium/Obsidian_to_Anki/wiki) +- [Trello 로드맵](https://trello.com/b/6MXEizGg/obsidiantoanki) + +### 관련 도구 +- [Obsidian](https://obsidian.md/) +- [Anki](https://apps.ankiweb.net/) +- [AnkiConnect](https://git.foosoft.net/alex/anki-connect) +- [Ollama](https://ollama.ai) +- [LM Studio](https://lmstudio.ai) + +### 커뮤니티 +- GitHub Issues: 버그 리포트 및 기능 제안 +- Obsidian Forum: 사용자 토론 +- Anki Forum: Anki 관련 도움 + +--- + +## 라이선스 + +MIT License + +--- + +## 기여하기 + +프로젝트에 기여하고 싶으신가요? + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +--- + +## 지원하기 + +이 프로젝트가 유용하다면: +- ⭐ GitHub에서 Star 주기 +- 🐛 버그 리포트 +- 💡 기능 제안 +- 📝 문서 개선 +- ☕ [Ko-fi](https://ko-fi.com/K3K52X4L6)에서 커피 사주기 + +--- + +## 업데이트 로그 + +### v4.0.0 (계획) +- ✨ AI 기반 카드 자동 생성 +- 💡 스마트 해답 생성 +- 🔄 카드 품질 개선 +- 🌐 다중 LLM Provider 지원 + +### v3.6.0 (현재) +- 파일 및 폴더 무시 기능 추가 +- 성능 개선 +- 버그 수정 + +--- + +## 문의 + +문제가 있거나 질문이 있으신가요? + +- 📧 GitHub Issues: [새 이슈 열기](https://github.com/Pseudonium/Obsidian_to_Anki/issues) +- 💬 Discussions: 일반적인 질문 및 토론 + +--- + +**Happy Learning! 🎓** + +이 가이드가 도움이 되셨기를 바랍니다. 효과적인 학습을 위해 Obsidian과 Anki를 활용하세요! diff --git a/.docs/Requirements.md b/.docs/Requirements.md new file mode 100644 index 00000000..72cb3bcd --- /dev/null +++ b/.docs/Requirements.md @@ -0,0 +1,279 @@ +# Obsidian to Anki - 요구사항 명세서 + +## 1. 프로젝트 개요 + +Obsidian의 마크다운 파일에서 Anki 플래시카드를 자동으로 생성하고, LLM을 활용하여 스마트한 카드 생성 및 해답 생성을 지원하는 시스템. + +## 2. 기존 기능 요구사항 + +### 2.1 핵심 기능 +- **양방향 동작 모드** + - Obsidian 플러그인으로 동작 + - Python 독립 스크립트로 동작 + +- **AnkiConnect 통합** + - AnkiConnect API를 통한 Anki와의 통신 + - 카드 생성, 업데이트, 삭제 + +- **커스텀 노트 타입 지원** + - Anki의 모든 노트 타입 지원 + - 사용자 정의 정규식 패턴 + +### 2.2 마크다운 처리 +- **다양한 카드 스타일 지원** + - Basic 카드 + - Cloze 카드 + - RemNote 스타일 + - Header-Paragraph 스타일 + - Question-Answer 스타일 + - Neuracache 스타일 + - Ruled 스타일 + - Markdown Table 스타일 + - Inline Notes + +- **마크다운 형식** + - 마크다운 문법 지원 + - 수식 (Math) 지원 + - 이미지 임베딩 + - 오디오 파일 + - GIF 지원 + +### 2.3 파일 관리 +- **디렉토리 스캔** + - 전체 Vault 스캔 + - 사용자 지정 디렉토리 스캔 + - 재귀적 서브디렉토리 스캔 + +- **파일/폴더 제외** + - Glob 패턴을 통한 파일/폴더 제외 + - 예: `**/*.excalidraw.md`, `Template/**`, `**/private/**` + +### 2.4 메타데이터 관리 +- **태그 시스템** + - 파일별 태그 + - 카드별 태그 + - Obsidian 태그 통합 + +- **덱(Deck) 관리** + - 파일별 대상 덱 지정 + - 폴더별 덱 매핑 + - 기본 덱 설정 + +- **고급 기능** + - Frozen Fields (고정 필드) + - ID Comments (카드 식별자) + - 파일 링크 추가 + - 컨텍스트 추가 + +## 3. 새로운 기능 요구사항 + +### 3.1 스마트 카드 자동 생성 + +#### 3.1.1 자동 카드 감지 +- **컨텐츠 분석** + - 마크다운 파일의 내용을 분석하여 플래시카드로 적합한 부분 자동 감지 + - 제목, 소제목, 리스트, 인용구 등의 구조 분석 + - 중요 개념과 정의 자동 추출 + +- **카드 생성 제안** + - LLM을 활용한 플래시카드 생성 제안 + - 기존 수동 마크업 방식과 자동 생성 방식의 혼합 사용 + - 생성된 카드에 대한 사용자 확인/수정 옵션 + +#### 3.1.2 인텔리전트 카드 타입 선택 +- **자동 카드 타입 결정** + - 내용의 특성에 따라 최적의 카드 타입 자동 선택 + - Basic, Cloze, Q&A 등 적절한 형식 자동 결정 + - 사용자 선호도 학습 + +### 3.2 LLM 통합 + +#### 3.2.1 LLM 제공자 지원 +- **OpenAI 호환 로컬 LLM** + - Ollama + - LM Studio + - LocalAI + - vLLM + - 기타 OpenAI API 호환 로컬 서버 + +- **클라우드 LLM 서비스** + - OpenRouter + - OpenAI + - Anthropic Claude (API) + - Google Gemini + - Cohere + +#### 3.2.2 LLM 설정 관리 +- **프로바이더 설정** + - API 엔드포인트 설정 + - API 키 관리 (보안) + - 모델 선택 + - 파라미터 설정 (temperature, max_tokens 등) + +- **복수 프로바이더 지원** + - 우선순위 기반 폴백 + - 프로바이더별 용도 구분 (예: 카드 생성용, 해답 생성용) + +#### 3.2.3 프롬프트 관리 +- **커스터마이징 가능한 프롬프트** + - 카드 생성 프롬프트 + - 해답 생성 프롬프트 + - 번역 프롬프트 + - 요약 프롬프트 + +- **프롬프트 템플릿** + - 기본 템플릿 제공 + - 사용자 정의 템플릿 + - 노트 타입별 프롬프트 + +### 3.3 스마트 해답 생성 + +#### 3.3.1 자동 해답 생성 +- **질문 기반 해답 생성** + - 질문이 주어졌을 때 LLM을 통한 해답 생성 + - 컨텍스트 기반 해답 생성 (파일 내용 참조) + - 다양한 난이도의 해답 생성 + +#### 3.3.2 해답 품질 관리 +- **해답 검증** + - 생성된 해답의 정확성 확인 + - 사용자 피드백 수집 + - 재생성 옵션 + +- **해답 개선** + - 기존 해답 개선 제안 + - 추가 설명 생성 + - 예시 추가 + +### 3.4 배치 처리 + +#### 3.4.1 대량 카드 생성 +- **Vault 전체 처리** + - 전체 마크다운 파일에 대한 일괄 처리 + - 진행 상황 표시 + - 오류 처리 및 로깅 + +#### 3.4.2 증분 업데이트 +- **변경 감지** + - 파일 해시 기반 변경 감지 + - 수정된 파일만 재처리 + - 기존 카드 업데이트 vs 새 카드 생성 + +### 3.5 설정 및 UI + +#### 3.5.1 플러그인 설정 +- **LLM 설정 탭** + - 프로바이더 선택 및 설정 + - API 키 입력 + - 모델 선택 + - 프롬프트 커스터마이징 + +- **자동 생성 설정** + - 자동 생성 활성화/비활성화 + - 생성 전 확인 옵션 + - 배치 크기 설정 + - 재시도 정책 + +#### 3.5.2 Python 스크립트 설정 +- **설정 파일 확장** + - INI 파일에 LLM 설정 섹션 추가 + - JSON 설정 파일 지원 (복잡한 설정용) + +#### 3.5.3 UI 개선 +- **프리뷰 기능** + - LLM 생성 결과 미리보기 + - 수정 인터페이스 + - 승인/거부 버튼 + +- **진행 상황 표시** + - 배치 처리 진행률 + - LLM API 호출 상태 + - 에러 메시지 + +## 4. 비기능 요구사항 + +### 4.1 성능 +- **응답 시간** + - 단일 카드 생성: < 5초 (LLM 호출 포함) + - 배치 처리: 파일당 < 10초 + +- **동시성** + - 병렬 LLM API 호출 지원 + - 비동기 처리 + +### 4.2 보안 +- **API 키 관리** + - 암호화된 저장 + - 환경 변수 지원 + - .gitignore에 자동 추가 + +- **데이터 프라이버시** + - 로컬 LLM 사용 옵션 + - 데이터 전송 최소화 + +### 4.3 안정성 +- **오류 처리** + - LLM API 실패 시 폴백 + - 네트워크 오류 재시도 + - 부분 실패 시 계속 진행 + +- **로깅** + - 상세한 에러 로그 + - 디버그 모드 + +### 4.4 호환성 +- **Obsidian 버전** + - 최신 Obsidian 버전 지원 + - 하위 호환성 유지 + +- **Anki 버전** + - Anki 2.1.x 지원 + - AnkiConnect 최신 버전 + +- **Python 버전** + - Python 3.8 이상 + +### 4.5 확장성 +- **플러그인 아키텍처** + - 새로운 LLM 프로바이더 쉽게 추가 + - 커스텀 프롬프트 템플릿 시스템 + - 훅(Hook) 시스템 + +## 5. 제약사항 + +### 5.1 기술적 제약 +- AnkiConnect 필요 +- 인터넷 연결 (클라우드 LLM 사용 시) +- Obsidian 플러그인 API 제약 + +### 5.2 비용 제약 +- 클라우드 LLM API 사용 시 비용 발생 +- 토큰 사용량 모니터링 필요 + +### 5.3 라이선스 +- MIT 라이선스 준수 +- 오픈소스 라이브러리 라이선스 준수 + +## 6. 우선순위 + +### P0 (필수) +- LLM 프로바이더 기본 통합 (OpenAI 호환) +- 스마트 카드 생성 기본 기능 +- 플러그인 설정 UI + +### P1 (중요) +- 복수 LLM 프로바이더 지원 +- 자동 해답 생성 +- 배치 처리 + +### P2 (부가) +- 프롬프트 커스터마이징 +- 프리뷰 UI +- 고급 설정 + +## 7. 성공 기준 + +- 사용자가 마크다운 파일을 작성하면 자동으로 플래시카드 제안 +- LLM을 통한 카드 생성이 수동 작성 대비 50% 이상 시간 절약 +- 생성된 카드의 품질이 사용자 기대치 충족 (사용자 피드백 기반) +- 기존 기능과의 완벽한 호환성 유지 diff --git a/.docs/tasks.md b/.docs/tasks.md new file mode 100644 index 00000000..e92d742e --- /dev/null +++ b/.docs/tasks.md @@ -0,0 +1,590 @@ +# Obsidian to Anki - 구현 작업 목록 + +## 작업 우선순위 + +- **P0**: 필수 기능 (MVP) +- **P1**: 중요 기능 +- **P2**: 부가 기능 +- **P3**: 향후 개선 + +--- + +## Phase 1: 기반 구축 (Foundation) + +### 1.1 프로젝트 구조 설정 [P0] + +- [ ] **TASK-001**: LLM 관련 디렉토리 구조 생성 + - 생성: `src/llm/` 디렉토리 + - 생성: `src/llm/interfaces/` 디렉토리 + - 생성: `src/llm/providers/` 디렉토리 + - 예상 시간: 30분 + +- [ ] **TASK-002**: TypeScript 타입 정의 파일 생성 + - 파일: `src/llm/interfaces/llm-provider.interface.ts` + - 파일: `src/llm/interfaces/llm-config.interface.ts` + - 파일: `src/llm/interfaces/llm-response.interface.ts` + - 예상 시간: 1시간 + +- [ ] **TASK-003**: Python LLM 모듈 구조 생성 + - 파일: `llm_integration.py` + - 파일: `llm_providers.py` + - 파일: `llm_prompts.py` + - 예상 시간: 1시간 + +### 1.2 의존성 추가 [P0] + +- [ ] **TASK-004**: TypeScript/JavaScript 의존성 + - `package.json`에 필요한 패키지 추가 + - HTTP 클라이언트 (이미 있을 수 있음) + - 예상 시간: 30분 + +- [ ] **TASK-005**: Python 의존성 + - `requirements.txt` 업데이트 + - `requests` 라이브러리 (HTTP 클라이언트) + - `python-dotenv` (환경 변수 관리) + - 예상 시간: 30분 + +### 1.3 설정 시스템 확장 [P0] + +- [ ] **TASK-006**: 플러그인 설정 인터페이스 확장 + - 파일: `src/interfaces/settings-interface.ts` + - `LLMSettings` 인터페이스 추가 + - 예상 시간: 1시간 + +- [ ] **TASK-007**: Python 설정 파일 파서 확장 + - `obsidian_to_anki_config.ini`에 `[LLM]` 섹션 추가 + - 설정 파싱 로직 업데이트 + - 예상 시간: 1시간 + +--- + +## Phase 2: LLM Provider 구현 + +### 2.1 기본 Provider 인터페이스 [P0] + +- [ ] **TASK-101**: LLM Provider 인터페이스 구현 (TypeScript) + - 파일: `src/llm/interfaces/llm-provider.interface.ts` + - `ILLMProvider` 인터페이스 정의 + - 예상 시간: 1시간 + +- [ ] **TASK-102**: LLM Provider 기본 클래스 구현 (Python) + - 파일: `llm_providers.py` + - `LLMProvider` 추상 클래스 + - 예상 시간: 1시간 + +### 2.2 OpenAI 호환 Provider [P0] + +- [ ] **TASK-103**: OpenAI Compatible Provider (TypeScript) + - 파일: `src/llm/providers/openai-compatible-provider.ts` + - API 호출 로직 + - 응답 파싱 + - 에러 처리 + - 예상 시간: 3시간 + - 테스트: Ollama, LM Studio로 테스트 + +- [ ] **TASK-104**: OpenAI Compatible Provider (Python) + - 파일: `llm_providers.py` + - `OpenAICompatibleProvider` 클래스 + - 예상 시간: 2시간 + +### 2.3 특정 Provider 구현 [P1] + +- [ ] **TASK-105**: Ollama Provider 최적화 (TypeScript) + - 파일: `src/llm/providers/ollama-provider.ts` + - Ollama 특화 기능 (모델 목록 조회 등) + - 예상 시간: 2시간 + +- [ ] **TASK-106**: OpenRouter Provider (TypeScript) + - 파일: `src/llm/providers/openrouter-provider.ts` + - OpenRouter 특화 설정 + - 예상 시간: 2시간 + +### 2.4 Provider 테스트 [P0] + +- [ ] **TASK-107**: Provider 단위 테스트 작성 + - 파일: `tests/llm/test-providers.ts` + - Mock API 응답 + - 에러 시나리오 테스트 + - 예상 시간: 3시간 + +--- + +## Phase 3: LLM Router 구현 + +### 3.1 Router 핵심 기능 [P0] + +- [ ] **TASK-201**: LLM Router 구현 (TypeScript) + - 파일: `src/llm/llm-router.ts` + - Provider 등록 및 관리 + - Fallback 체인 구현 + - 예상 시간: 3시간 + +- [ ] **TASK-202**: LLM Router 구현 (Python) + - 파일: `llm_integration.py` + - `LLMRouter` 클래스 + - 예상 시간: 2시간 + +### 3.2 에러 처리 및 재시도 [P0] + +- [ ] **TASK-203**: 에러 타입 정의 + - 파일: `src/llm/llm-error.ts` + - `LLMError` 클래스 + - 에러 타입 enum + - 예상 시간: 1시간 + +- [ ] **TASK-204**: 재시도 로직 구현 + - Exponential backoff + - Provider fallback + - 예상 시간: 2시간 + +### 3.3 Router 테스트 [P1] + +- [ ] **TASK-205**: Router 통합 테스트 + - 파일: `tests/llm/test-router.ts` + - Fallback 시나리오 테스트 + - 예상 시간: 2시간 + +--- + +## Phase 4: Prompt Management + +### 4.1 Prompt Manager [P0] + +- [ ] **TASK-301**: Prompt Manager 구현 (TypeScript) + - 파일: `src/llm/prompt-manager.ts` + - 템플릿 관리 + - 변수 치환 + - 예상 시간: 3시간 + +- [ ] **TASK-302**: 기본 프롬프트 템플릿 작성 + - 카드 생성 프롬프트 + - 해답 생성 프롬프트 + - 카드 개선 프롬프트 + - 예상 시간: 4시간 + - 주의: 프롬프트 품질이 결과에 큰 영향 + +- [ ] **TASK-303**: Prompt Manager 구현 (Python) + - 파일: `llm_prompts.py` + - `PromptManager` 클래스 + - 예상 시간: 2시간 + +### 4.2 커스텀 프롬프트 [P2] + +- [ ] **TASK-304**: 사용자 정의 프롬프트 UI + - 설정 UI에 프롬프트 편집기 추가 + - 예상 시간: 3시간 + +- [ ] **TASK-305**: 프롬프트 템플릿 저장/로드 + - JSON 파일로 저장 + - 예상 시간: 2시간 + +--- + +## Phase 5: Content Analyzer + +### 5.1 마크다운 분석 [P0] + +- [ ] **TASK-401**: Content Analyzer 구현 (TypeScript) + - 파일: `src/llm/content-analyzer.ts` + - 마크다운 구조 파싱 + - 카드 생성 가능성 점수 계산 + - 예상 시간: 4시간 + +- [ ] **TASK-402**: 섹션 추출 로직 + - Heading, List, Paragraph 등 타입별 처리 + - 예상 시간: 2시간 + +- [ ] **TASK-403**: Content Analyzer 구현 (Python) + - 파일: `content_analyzer.py` + - 예상 시간: 3시간 + +### 5.2 Analyzer 테스트 [P1] + +- [ ] **TASK-404**: Content Analyzer 테스트 + - 다양한 마크다운 형식 테스트 + - 예상 시간: 2시간 + +--- + +## Phase 6: Smart Card Generator + +### 6.1 카드 생성 핵심 기능 [P0] + +- [ ] **TASK-501**: Smart Card Generator 구현 (TypeScript) + - 파일: `src/llm/card-generator.ts` + - 카드 생성 로직 + - 응답 파싱 + - 예상 시간: 4시간 + +- [ ] **TASK-502**: 카드 타입별 생성 로직 + - Basic 카드 + - Cloze 카드 + - Q&A 카드 + - 예상 시간: 3시간 + +- [ ] **TASK-503**: Smart Card Generator 구현 (Python) + - 파일: `llm_card_generator.py` + - 예상 시간: 3시간 + +### 6.2 해답 생성 [P1] + +- [ ] **TASK-504**: 해답 생성 기능 + - `generateAnswer()` 메서드 + - 컨텍스트 통합 + - 예상 시간: 2시간 + +### 6.3 카드 개선 [P2] + +- [ ] **TASK-505**: 카드 개선 기능 + - 기존 카드 분석 + - 개선 제안 생성 + - 예상 시간: 2시간 + +--- + +## Phase 7: UI 통합 (Obsidian Plugin) + +### 7.1 설정 UI [P0] + +- [ ] **TASK-601**: LLM 설정 탭 추가 + - 파일: `src/settings.ts` 확장 + - Provider 설정 UI + - API 키 입력 + - 모델 선택 + - 예상 시간: 4시간 + +- [ ] **TASK-602**: Provider 연결 테스트 버튼 + - "Test Connection" 버튼 + - 연결 상태 표시 + - 예상 시간: 2시간 + +### 7.2 카드 생성 UI [P0] + +- [ ] **TASK-603**: 스마트 생성 버튼 추가 + - Ribbon 아이콘에 메뉴 항목 추가 + - "Generate Cards with AI" 옵션 + - 예상 시간: 2시간 + +- [ ] **TASK-604**: 진행 상황 표시 + - Progress bar + - 현재 처리 중인 파일 표시 + - 예상 시간: 2시간 + +### 7.3 프리뷰 UI [P1] + +- [ ] **TASK-605**: 카드 프리뷰 모달 + - 파일: `src/llm/preview-modal.ts` + - 생성된 카드 미리보기 + - 편집 기능 + - 승인/거부 버튼 + - 예상 시간: 5시간 + +- [ ] **TASK-606**: 배치 프리뷰 + - 여러 카드 한번에 표시 + - 선택적 승인 + - 예상 시간: 3시간 + +### 7.4 컨텍스트 메뉴 [P2] + +- [ ] **TASK-607**: 우클릭 메뉴에 옵션 추가 + - "Generate cards for this section" + - "Improve this card" + - 예상 시간: 2시간 + +--- + +## Phase 8: 기존 시스템 통합 + +### 8.1 File Manager 통합 [P0] + +- [ ] **TASK-701**: File Manager에 LLM 옵션 추가 + - 파일: `src/files-manager.ts` + - LLM 기반 생성 플래그 + - 예상 시간: 2시간 + +- [ ] **TASK-702**: 자동 감지 모드 + - 기존 마크업 + LLM 생성 혼합 + - 예상 시간: 3시간 + +### 8.2 Note Parser 통합 [P0] + +- [ ] **TASK-703**: Note Parser 확장 + - 파일: `src/note.ts` + - LLM 생성 카드 처리 + - 예상 시간: 2시간 + +### 8.3 AnkiConnect 통합 [P0] + +- [ ] **TASK-704**: 생성된 카드 Anki로 전송 + - 기존 `anki.ts` 활용 + - 예상 시간: 1시간 + +--- + +## Phase 9: Python 스크립트 통합 + +### 9.1 CLI 확장 [P0] + +- [ ] **TASK-801**: 명령줄 옵션 추가 + - `--llm-generate`: LLM 카드 생성 활성화 + - `--llm-provider`: Provider 지정 + - 예상 시간: 2시간 + +- [ ] **TASK-802**: 설정 파일 파싱 + - INI 파일에서 LLM 설정 읽기 + - 예상 시간: 1시간 + +### 9.2 배치 처리 [P1] + +- [ ] **TASK-803**: 배치 모드 구현 + - 전체 디렉토리 처리 + - 진행 상황 로깅 + - 예상 시간: 3시간 + +--- + +## Phase 10: 테스트 및 검증 + +### 10.1 통합 테스트 [P0] + +- [ ] **TASK-901**: End-to-end 테스트 + - 파일 읽기 → LLM 생성 → Anki 전송 + - 예상 시간: 4시간 + +- [ ] **TASK-902**: 다양한 LLM Provider 테스트 + - Ollama + - LM Studio + - OpenRouter + - OpenAI + - 예상 시간: 4시간 + +### 10.2 에러 시나리오 테스트 [P1] + +- [ ] **TASK-903**: 네트워크 오류 테스트 + - API 타임아웃 + - 연결 실패 + - 예상 시간: 2시간 + +- [ ] **TASK-904**: LLM 응답 오류 테스트 + - 잘못된 형식 + - 빈 응답 + - 예상 시간: 2시간 + +### 10.3 성능 테스트 [P1] + +- [ ] **TASK-905**: 대용량 파일 테스트 + - 100개 이상의 마크다운 파일 + - 예상 시간: 2시간 + +- [ ] **TASK-906**: 응답 시간 측정 + - 카드 생성 시간 + - API 호출 시간 + - 예상 시간: 2시간 + +--- + +## Phase 11: 문서화 + +### 11.1 사용자 문서 [P0] + +- [ ] **TASK-1001**: 한국어 사용 가이드 + - 파일: `.docs/README_ko.md` (이미 생성 예정) + - LLM 설정 방법 + - 사용 예제 + - 예상 시간: 3시간 + +- [ ] **TASK-1002**: 영어 사용 가이드 + - 파일: `docs/LLM_GUIDE.md` + - 예상 시간: 3시간 + +### 11.2 개발자 문서 [P1] + +- [ ] **TASK-1003**: API 문서 + - LLM Provider API + - 예상 시간: 2시간 + +- [ ] **TASK-1004**: 아키텍처 다이어그램 + - 시스템 구조도 + - 데이터 흐름도 + - 예상 시간: 2시간 + +### 11.3 예제 및 튜토리얼 [P2] + +- [ ] **TASK-1005**: 예제 파일 생성 + - 샘플 마크다운 파일 + - 예제 설정 파일 + - 예상 시간: 2시간 + +- [ ] **TASK-1006**: 비디오 튜토리얼 스크립트 + - 예상 시간: 2시간 + +--- + +## Phase 12: 최적화 및 개선 + +### 12.1 성능 최적화 [P1] + +- [ ] **TASK-1101**: 응답 캐싱 + - 파일 해시 기반 캐시 + - 예상 시간: 3시간 + +- [ ] **TASK-1102**: 병렬 처리 + - 동시에 여러 파일 처리 + - 예상 시간: 3시간 + +### 12.2 프롬프트 최적화 [P1] + +- [ ] **TASK-1103**: 프롬프트 A/B 테스트 + - 다양한 프롬프트 비교 + - 예상 시간: 4시간 + +- [ ] **TASK-1104**: 토큰 사용량 최적화 + - 불필요한 내용 제거 + - 예상 시간: 2시간 + +### 12.3 사용자 경험 개선 [P2] + +- [ ] **TASK-1105**: 온보딩 경험 + - 첫 사용자를 위한 가이드 + - 예상 시간: 3시간 + +- [ ] **TASK-1106**: 에러 메시지 개선 + - 사용자 친화적인 메시지 + - 해결 방법 제시 + - 예상 시간: 2시간 + +--- + +## Phase 13: 배포 준비 + +### 13.1 빌드 및 패키징 [P0] + +- [ ] **TASK-1201**: TypeScript 빌드 설정 + - 새 파일들 포함 + - 예상 시간: 1시간 + +- [ ] **TASK-1202**: Python 패키징 + - 의존성 확인 + - 예상 시간: 1시간 + +### 13.2 버전 관리 [P0] + +- [ ] **TASK-1203**: 버전 번호 업데이트 + - `manifest.json` + - `package.json` + - 예상 시간: 30분 + +- [ ] **TASK-1204**: CHANGELOG 작성 + - 새 기능 목록 + - Breaking changes + - 예상 시간: 1시간 + +### 13.3 릴리스 노트 [P0] + +- [ ] **TASK-1205**: 릴리스 노트 작성 + - 주요 기능 설명 + - 마이그레이션 가이드 + - 예상 시간: 2시간 + +--- + +## 추가 작업 (Optional / Future) + +### 고급 기능 [P3] + +- [ ] **TASK-2001**: 다국어 지원 + - 프롬프트 번역 + - UI 다국어 + - 예상 시간: 8시간 + +- [ ] **TASK-2002**: 학습 기반 개선 + - 사용자 피드백 수집 + - 카드 품질 학습 + - 예상 시간: 12시간 + +- [ ] **TASK-2003**: 이미지 OCR 통합 + - 이미지에서 텍스트 추출 + - LLM으로 카드 생성 + - 예상 시간: 8시간 + +- [ ] **TASK-2004**: 음성 입력 + - STT 통합 + - 음성으로 카드 생성 + - 예상 시간: 10시간 + +### 통합 및 연동 [P3] + +- [ ] **TASK-2005**: RemNote 형식 지원 강화 + - 예상 시간: 4시간 + +- [ ] **TASK-2006**: Notion 연동 + - Notion 데이터베이스에서 가져오기 + - 예상 시간: 10시간 + +--- + +## 작업 일정 추정 + +### MVP (최소 기능 제품) +**Phase 1-8**: 약 8-10주 +- 핵심 LLM 통합 +- 기본 UI +- 테스트 + +### Full Release +**Phase 1-12**: 약 14-16주 +- 모든 P0, P1 작업 +- 최적화 +- 문서화 + +### Extended Features +**Phase 13 + Additional**: 추가 4-8주 +- P2, P3 작업 +- 고급 기능 + +--- + +## 리스크 및 의존성 + +### 리스크 +1. **LLM API 불안정성**: Provider fallback으로 완화 +2. **프롬프트 품질**: 반복적인 테스트 및 개선 필요 +3. **토큰 비용**: 로컬 LLM 사용 권장 +4. **기존 기능 호환성**: 철저한 회귀 테스트 + +### 의존성 +- TASK-001 ~ TASK-003: 모든 후속 작업의 기반 +- TASK-101 ~ TASK-107: Router와 Manager의 기반 +- TASK-601 ~ TASK-606: 사용자 테스트를 위해 필요 + +--- + +## 테스크 체크리스트 진행 방법 + +각 작업 완료 시: +1. [ ] 를 [x]로 변경 +2. 코드 리뷰 수행 +3. 단위 테스트 작성 및 통과 +4. 문서 업데이트 +5. Git 커밋 + +--- + +## 작업 담당 제안 + +- **Phase 1-3**: Backend 개발자 +- **Phase 4-6**: AI/ML 엔지니어 +- **Phase 7**: Frontend/UI 개발자 +- **Phase 8-9**: 통합 엔지니어 +- **Phase 10**: QA 엔지니어 +- **Phase 11**: Technical Writer +- **Phase 12**: Performance Engineer + +--- + +## 다음 단계 + +1. Phase 1 작업 시작 (TASK-001) +2. 개발 환경 설정 +3. Git 브랜치 생성 (`feature/llm-integration`) +4. 주간 진행 상황 리뷰 설정 diff --git a/CHANGELOG_LLM.md b/CHANGELOG_LLM.md new file mode 100644 index 00000000..7ea6c6ea --- /dev/null +++ b/CHANGELOG_LLM.md @@ -0,0 +1,316 @@ +# LLM Integration Changelog + +## Version 4.0.0-alpha.2 (Enhanced Document Processing) + +### 🚀 Major Enhancements + +#### Multi-Pass Card Generation System +- **Smart Document Chunking**: Automatically breaks long documents into token-limited chunks (1500 max, 200 min tokens) +- **Hierarchical Structure Preservation**: Maintains document structure by splitting on headings +- **Importance Scoring**: Calculates importance scores based on heading level and content keywords +- **Context-Aware Generation**: Each chunk receives global document context + local section context +- **Progressive Generation**: Real-time progress updates with AsyncGenerator pattern + +#### Enhanced LLM Prompts +- **Document Analysis Prompt**: Creates strategic plan before generation (overview, topics, estimated cards) +- **Context-Rich Generation Prompt**: Includes document topic, section path, and previous summary +- **Quality Validation Prompt**: Evaluates clarity, accuracy, completeness, and uniqueness +- **Structured Output**: JSON-formatted responses with confidence scores and metadata + +#### New UI Components +- **Generation Progress Modal**: Real-time visual feedback during long document processing + - Phase indicators (Planning → Analyzing → Generating → Validating → Completed) + - Progress bar with chunk completion + - Live card preview as they're generated + - Quality scores for each batch + - Cancel/pause functionality +- **Enhanced Card Preview**: Integration with existing preview modal for final approval + +#### New Command +- **"Generate Cards with AI (Enhanced for Long Documents)"**: Uses multi-pass system for optimal results on lengthy content + +### 📦 New Components + +#### Document Chunking +- `src/llm/chunking/document-chunker.ts` (317 lines) + - Smart chunking with heading detection + - Token estimation (1 token ≈ 4 characters) + - Keyword extraction from emphasis and code blocks + - Context generation (overview, previous/next chunks) + - Configurable min/max token limits + +#### Multi-Pass Generation +- `src/llm/generation/multi-pass-generator.ts` (482 lines) + - Pass 1: Document analysis and planning + - Pass 2: Intelligent chunking + - Pass 3: Context-aware card generation per chunk + - Pass 4: Quality validation (on-demand) + - AsyncGenerator for streaming results + - Batch quality scoring + +#### Progress UI +- `src/llm/ui/progress-modal.ts` (274 lines) + - Real-time progress display + - Card batch preview with quality indicators + - Interactive cancel/continue controls + - Statistics dashboard + - Seamless integration with preview modal + +### 🎨 UI/UX Improvements +- Progress bar with smooth animations +- Quality indicators (high/medium/low) with color coding +- Collapsible card previews showing first card of each batch +- Real-time statistics (cards generated, sections processed, average confidence) +- Professional phase icons and status messages + +### 📚 Documentation +- `.docs/ENHANCEMENT_PLAN.md` - Complete enhancement strategy and implementation plan +- Detailed comments in all new source files +- Type definitions for all interfaces + +### 🔧 Technical Details + +#### Token Management +- Configurable chunk sizes (default: 1500 max, 200 min) +- Intelligent split points (paragraphs, sentences) +- Token estimation algorithm +- Overlap prevention + +#### Context Preservation +- Global document overview +- Section hierarchy tracking +- Previous section summaries +- Keyword extraction and propagation + +#### Quality Metrics +- Per-card confidence scores +- Batch quality aggregation +- Count-based penalties (too few/many cards) +- Validation scoring system (clarity, accuracy, completeness, uniqueness) + +#### Performance +- AsyncGenerator for memory efficiency +- Streaming results to UI +- Cancellable operations +- No blocking of main thread + +### ⚡ Performance Characteristics +- Handles documents up to 100K+ tokens +- Processes ~5-10 sections per minute (varies by LLM) +- Memory-efficient streaming approach +- Responsive UI during generation + +### 🔄 Integration with Existing Features +- Fully backward compatible +- Original "Generate Cards with AI" command unchanged +- New enhanced command available alongside basic version +- Shares same settings and provider configuration +- Uses same card preview and approval workflow + +--- + +## Version 4.0.0-alpha (Initial Release) + +### 🎉 Major New Features + +#### AI-Powered Flashcard Generation +- **Smart Card Generation**: Automatically analyze markdown content and generate flashcards using AI +- **Multiple LLM Support**: Works with Ollama, LM Studio, OpenRouter, OpenAI, and any OpenAI-compatible API +- **Content Analysis**: Intelligent detection of flashcard-suitable sections in your notes +- **Answer Generation**: Generate answers for questions using context from your notes + +#### LLM System Architecture +- **Provider Abstraction**: Easy-to-extend provider system +- **Automatic Fallback**: Configure fallback chains for reliability +- **Retry Logic**: Exponential backoff for transient errors +- **Error Handling**: Comprehensive error types and user-friendly messages + +#### User Interface +- **Preview Modal**: Review and edit AI-generated cards before adding to Anki +- **Settings UI**: Enable/disable LLM features and configure parameters +- **Command Palette**: New commands for AI operations + - "Generate Cards with AI" + - "Generate Answer with AI" + +### 📦 New Components + +#### TypeScript/Obsidian Plugin +- `src/llm/index.ts` - Main export module +- `src/llm/llm-router.ts` - Provider routing with fallback +- `src/llm/prompt-manager.ts` - Template management (5 default templates) +- `src/llm/content-analyzer.ts` - Markdown analysis +- `src/llm/card-generator.ts` - Smart card generation +- `src/llm/preview-modal.ts` - Card review UI +- `src/llm/providers/openai-compatible-provider.ts` - Universal provider +- `src/llm/llm-error.ts` - Error handling +- `src/llm/interfaces/` - TypeScript interfaces + +#### Python Script +- `llm_integration.py` - Complete Python implementation + - OpenAICompatibleProvider + - LLMRouter + - SmartCardGenerator + - Full feature parity with TypeScript version + +#### Configuration +- Extended `PluginSettings` with `LLMSettings` +- Added `[LLM]` section to `obsidian_to_anki_config.ini` +- New settings: temperature, max_tokens, timeout, batch_size, etc. + +#### Documentation +- `LLM_GUIDE.md` - Comprehensive English guide +- `.docs/README_ko.md` - Complete Korean guide +- `.docs/Requirements.md` - Detailed requirements +- `.docs/Design.md` - System architecture and design +- `.docs/tasks.md` - Implementation task list + +### 🔧 Technical Details + +#### Supported LLM Providers +- **Local (Free)**: + - Ollama + - LM Studio + - Any OpenAI-compatible local server +- **Cloud (Paid)**: + - OpenRouter (Access to Claude, GPT, etc.) + - OpenAI + - Any OpenAI-compatible cloud service + +#### Default Prompt Templates +1. `generate_cards` - General flashcard generation +2. `generate_answer` - Answer generation with context +3. `improve_card` - Card quality improvement +4. `generate_cloze` - Cloze deletion cards +5. `generate_qa` - Question-answer style cards + +#### Content Analysis Features +- Identifies 6 section types: heading, paragraph, list, code, quote, table +- Calculates card potential score (0-1) +- Context extraction for related content +- Section grouping by headings + +#### Error Handling +- 8 error types with retry strategies +- Exponential backoff (1s → 2s → 4s) +- Provider fallback on failure +- User-friendly error messages + +### 🎨 UI/UX Improvements +- New "LLM (AI) Settings" section in plugin settings +- Card preview modal with edit capabilities +- Select/deselect all cards +- Individual card approval +- Real-time statistics display + +### 📚 Documentation +- Complete setup guides for each LLM provider +- Usage examples with sample input/output +- Troubleshooting section +- FAQ +- Cost comparison table +- Best practices + +### 🔒 Security & Privacy +- Support for local LLMs (100% private) +- Optional API key encryption +- No data sent to cloud if using local providers +- Environment variable support + +### ⚡ Performance +- Async/await throughout +- Parallel provider attempts +- Efficient retry mechanisms +- Content caching (planned) + +### 🧪 Testing +- Build successfully compiles +- All TypeScript errors resolved +- Module exports properly structured +- Ready for integration testing + +### 📝 Known Limitations +- Card-to-Anki conversion not yet implemented (placeholder) +- Python CLI integration pending +- Advanced settings UI pending +- Caching system not yet implemented + +### 🔜 Coming Soon +- Full Anki integration for AI-generated cards +- Python CLI with `--llm-generate` flag +- Advanced provider configuration UI +- Prompt template editor +- Response caching +- Batch folder processing +- Usage statistics and cost tracking + +### 💡 Usage Example + +```typescript +// Enable LLM in settings +settings.LLM.enabled = true; + +// Add a provider +settings.LLM.providers = [{ + name: 'ollama', + type: 'ollama', + endpoint: 'http://localhost:11434/v1/chat/completions', + model: 'llama2', + enabled: true +}]; + +// Generate cards +// Command Palette → "Generate Cards with AI" +// Reviews cards in modal → Approve → Add to Anki +``` + +### 🐛 Bug Fixes +- N/A (new feature) + +### ⚠️ Breaking Changes +- None (backward compatible) +- Existing features work unchanged +- LLM features are opt-in + +### 📊 Statistics +- **TypeScript Code**: ~2,500 lines +- **Python Code**: ~500 lines +- **Documentation**: ~5,000 lines +- **Files Added**: 20+ +- **Interfaces**: 10+ +- **Components**: 8 major components + +### 🙏 Credits +- Built on top of existing Obsidian_to_Anki plugin +- Uses OpenAI-compatible API standard +- Inspired by various LLM integration patterns + +--- + +## Migration Guide + +### For Existing Users +1. Update to version 4.0.0 +2. LLM features are **disabled by default** +3. Enable in settings if desired +4. Configure your preferred LLM provider +5. Existing workflows unchanged + +### For New Users +1. Follow normal installation +2. Optionally enable LLM features +3. See `LLM_GUIDE.md` for setup +4. Start with local LLM (Ollama recommended) + +--- + +## Feedback & Contributions + +- Report issues on GitHub +- Feature requests welcome +- Pull requests encouraged +- See `.docs/tasks.md` for remaining work + +--- + +**Status**: Alpha - Core functionality complete, integration pending +**Next Release**: Beta with full Anki integration diff --git a/LLM_GUIDE.md b/LLM_GUIDE.md new file mode 100644 index 00000000..b68d7004 --- /dev/null +++ b/LLM_GUIDE.md @@ -0,0 +1,446 @@ +# LLM Integration Guide + +## Overview + +Obsidian to Anki now supports AI-powered flashcard generation using Large Language Models (LLMs). This feature allows you to: + +- **Automatically generate flashcards** from your markdown notes +- **Generate answers** for your questions using context +- **Improve existing cards** with AI suggestions +- **Use multiple LLM providers** with automatic fallback + +## Supported LLM Providers + +### Local Providers (Free, Private) +- **Ollama** - Easy to setup, supports many models +- **LM Studio** - User-friendly GUI, cross-platform +- **Any OpenAI-compatible API** - Custom local servers + +### Cloud Providers (Paid, requires API key) +- **OpenRouter** - Access to multiple models (Claude, GPT, etc.) +- **OpenAI** - GPT-3.5, GPT-4 +- **Any OpenAI-compatible service** + +## Quick Start + +### For Obsidian Plugin Users + +1. **Install a local LLM** (recommended: Ollama) + ```bash + # macOS/Linux + curl https://ollama.ai/install.sh | sh + + # Windows: Download from https://ollama.ai + ``` + +2. **Download a model** + ```bash + ollama pull llama2 + # or + ollama pull mistral + ``` + +3. **Configure the plugin** + - Open Obsidian Settings + - Go to "Obsidian to Anki" → "LLM Settings" + - Enable LLM features + - Configure provider: + - Provider: `ollama` + - Endpoint: `http://localhost:11434/v1/chat/completions` + - Model: `llama2` + - Leave API Key empty for local providers + +4. **Test the connection** + - Click "Test Connection" button + - Should see "✓ Connected" + +5. **Generate cards** + - Open a note + - Run command "Generate Cards with AI" + - Review and approve generated cards + +### For Python Script Users + +1. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +2. **Configure LLM settings** in `obsidian_to_anki_config.ini`: + ```ini + [LLM] + Enabled = True + Primary Provider = ollama + Primary Endpoint = http://localhost:11434/v1/chat/completions + Primary Model = llama2 + Primary API Key = + ``` + +3. **Generate cards** + ```bash + python obsidian_to_anki.py --llm-generate your_note.md + ``` + +## Configuration + +### LLM Settings + +#### Obsidian Plugin + +Settings can be configured in: `Settings → Obsidian to Anki → LLM Settings` + +| Setting | Description | Default | +|---------|-------------|---------| +| **Enabled** | Enable/disable LLM features | `false` | +| **Default Provider** | Which provider to use first | `ollama` | +| **Temperature** | Creativity level (0-1) | `0.7` | +| **Max Tokens** | Maximum response length | `2000` | +| **Auto Generate** | Auto-generate cards on file save | `false` | +| **Show Preview** | Show preview before adding cards | `true` | +| **Batch Size** | Cards per batch | `10` | + +#### Python Script + +Configure in `obsidian_to_anki_config.ini`: + +```ini +[LLM] +# Enable LLM features +Enabled = True + +# Primary provider +Primary Provider = ollama +Primary Endpoint = http://localhost:11434/v1/chat/completions +Primary Model = llama2 +Primary API Key = + +# Fallback provider (optional) +Fallback Provider = openrouter +Fallback Endpoint = https://openrouter.ai/api/v1/chat/completions +Fallback Model = anthropic/claude-3-haiku +Fallback API Key = sk-or-v1-... + +# Parameters +Temperature = 0.7 +Max Tokens = 2000 +Timeout = 60 + +# Features +Auto Generate Cards = False +Auto Generate Answers = True +Show Preview = True +Batch Size = 10 +``` + +## Provider Setup Guides + +### Ollama (Recommended for Beginners) + +**Pros:** Free, private, easy to setup +**Cons:** Requires local hardware, slower than cloud + +1. **Install Ollama** + ```bash + curl https://ollama.ai/install.sh | sh + ``` + +2. **Download a model** + ```bash + # Small, fast model + ollama pull mistral + + # Or larger, better quality + ollama pull llama2 + ``` + +3. **Verify it's running** + ```bash + ollama list + ``` + +4. **Configure** + - Endpoint: `http://localhost:11434/v1/chat/completions` + - Model: `mistral` or `llama2` + - API Key: (leave empty) + +### LM Studio + +**Pros:** GUI, cross-platform, free +**Cons:** Requires download, manual setup + +1. Download from https://lmstudio.ai +2. Install and open LM Studio +3. Download a model from the "Discover" tab +4. Go to "Local Server" tab +5. Select your model and click "Start Server" +6. Note the server address (usually `http://localhost:1234`) + +**Configure:** +- Endpoint: `http://localhost:1234/v1/chat/completions` +- Model: (your downloaded model name) +- API Key: (leave empty) + +### OpenRouter (Cloud) + +**Pros:** Access to best models (Claude, GPT-4), fast +**Cons:** Costs money, internet required + +1. Sign up at https://openrouter.ai +2. Get API key from dashboard +3. Add credits to account + +**Configure:** +- Endpoint: `https://openrouter.ai/api/v1/chat/completions` +- Model: `anthropic/claude-3-haiku` (or others) +- API Key: `sk-or-v1-...` + +**Recommended models:** +- `anthropic/claude-3-haiku` - Fast, cheap, quality +- `openai/gpt-3.5-turbo` - Balanced +- `anthropic/claude-3-sonnet` - High quality + +### OpenAI (Cloud) + +**Pros:** Well-tested, reliable +**Cons:** Expensive, internet required + +1. Sign up at https://platform.openai.com +2. Create API key +3. Add payment method + +**Configure:** +- Endpoint: `https://api.openai.com/v1/chat/completions` +- Model: `gpt-3.5-turbo` or `gpt-4` +- API Key: `sk-...` + +## Usage Examples + +### Generate Cards from Content + +**Markdown Input:** +```markdown +# Python Programming + +Python is a high-level programming language. + +Key features: +- Easy to read syntax +- Dynamically typed +- Large standard library + +## Variables +Variables in Python don't need type declarations. +Example: `x = 10` +``` + +**Generated Cards:** +``` +Card 1: +Q: What is Python? +A: Python is a high-level programming language known for its easy-to-read syntax. + +Card 2: +Q: What are three key features of Python? +A: 1) Easy to read syntax 2) Dynamically typed 3) Large standard library + +Card 3: +Q: Do variables in Python need type declarations? +A: No, variables in Python don't need type declarations. Example: x = 10 +``` + +### Generate Answer for Question + +**Input:** +```markdown +Question: What is the difference between a list and a tuple in Python? +Context: Python has several data structures including lists, tuples, and dictionaries. +``` + +**Generated Answer:** +``` +Lists and tuples in Python are both ordered sequences, but have key differences: + +1. Mutability: Lists are mutable (can be modified after creation), + while tuples are immutable (cannot be changed). + +2. Syntax: Lists use square brackets [], tuples use parentheses () + +3. Performance: Tuples are slightly faster than lists due to immutability + +4. Use cases: Use lists when you need to modify data, + tuples for fixed collections or as dictionary keys. + +Example: +- List: my_list = [1, 2, 3]; my_list[0] = 10 # OK +- Tuple: my_tuple = (1, 2, 3); my_tuple[0] = 10 # Error +``` + +## Advanced Features + +### Custom Prompts + +You can customize the prompts used for card generation: + +1. Open Settings → LLM → Prompts +2. Select a prompt template (e.g., "Card Generation") +3. Modify the system or user prompt +4. Variables available: `{{content}}`, `{{context}}`, etc. + +**Example custom prompt:** +``` +Generate flashcards focusing on practical examples and code snippets. +Use the format: +- Front: Practical question +- Back: Answer with code example +``` + +### Batch Processing + +Process multiple files at once: + +**Obsidian:** +- Command: "Generate Cards for Folder" +- Select folder +- Review all generated cards + +**Python:** +```bash +python obsidian_to_anki.py --llm-generate --batch /path/to/notes/ +``` + +### Fallback Chain + +Configure multiple providers for reliability: + +```ini +Primary Provider = ollama +Fallback Provider = openrouter +``` + +If Ollama fails or is unavailable, it will automatically try OpenRouter. + +## Troubleshooting + +### "Provider not available" + +**Check:** +1. Is Ollama/LM Studio running? + ```bash + # For Ollama + ollama list + ``` + +2. Is the endpoint correct? + - Ollama: `http://localhost:11434/v1/chat/completions` + - LM Studio: Check the Local Server tab for URL + +3. Test connection manually: + ```bash + curl http://localhost:11434/api/tags + ``` + +### "Failed to parse response" + +**Solutions:** +1. Try a different model (some models are better at following JSON format) +2. Increase `Max Tokens` setting +3. Check if response is being cut off + +### Cards are low quality + +**Solutions:** +1. Use a better model: + - Local: Try `mistral` or `llama2:13b` + - Cloud: Try Claude or GPT-4 + +2. Customize prompts to be more specific + +3. Provide more context in your notes + +4. Adjust temperature: + - Lower (0.3-0.5) for more factual cards + - Higher (0.7-0.9) for more creative cards + +### Slow generation + +**Solutions:** +1. Use a smaller model locally +2. Switch to cloud provider (faster) +3. Reduce `Max Tokens` +4. Reduce `Batch Size` + +### High costs (cloud providers) + +**Solutions:** +1. Use cheaper models: + - OpenRouter: `anthropic/claude-3-haiku` + - OpenAI: `gpt-3.5-turbo` + +2. Switch to local LLM (free) + +3. Reduce `Max Tokens` + +4. Enable caching (WIP) + +## Best Practices + +### For Best Card Quality + +1. **Write structured notes** + - Use clear headings + - Organize with bullet points + - Include examples + +2. **Provide context** + - Keep related information together + - Use descriptive headings + +3. **Review and edit** + - Always review AI-generated cards + - Edit for clarity and accuracy + - Add tags appropriately + +### For Privacy + +1. **Use local LLMs** for sensitive content +2. **Check provider privacy policies** before using cloud +3. **Don't include personal information** in test prompts + +### For Cost Efficiency + +1. **Start with local LLMs** (free) +2. **Use cloud only when needed** (better quality) +3. **Set reasonable token limits** +4. **Enable preview** to avoid generating unwanted cards + +## FAQ + +**Q: Do I need an internet connection?** +A: No, if you use local LLMs like Ollama or LM Studio. + +**Q: Which LLM provider is best?** +A: For beginners: Ollama (free, easy). For quality: Claude or GPT-4. + +**Q: How much does it cost?** +A: Local providers are free. Cloud providers vary: +- OpenRouter: ~$0.001-0.01 per card +- OpenAI: ~$0.002-0.05 per card + +**Q: Can I use my own custom LLM?** +A: Yes, as long as it supports OpenAI-compatible API. + +**Q: Is my data sent to the cloud?** +A: Only if you use cloud providers. Local LLMs keep everything on your machine. + +**Q: Can I contribute custom prompts?** +A: Yes! See CONTRIBUTING.md + +## API Reference + +See `.docs/Design.md` for detailed API documentation. + +## Support + +- GitHub Issues: https://github.com/Pseudonium/Obsidian_to_Anki/issues +- Documentation: https://github.com/Pseudonium/Obsidian_to_Anki/wiki +- 한국어 가이드: `.docs/README_ko.md` diff --git a/llm_integration.py b/llm_integration.py new file mode 100644 index 00000000..b8e33929 --- /dev/null +++ b/llm_integration.py @@ -0,0 +1,492 @@ +""" +LLM Integration Module for Obsidian to Anki +Provides LLM-powered flashcard generation and answer generation +""" + +import json +import requests +import time +from typing import List, Dict, Optional, Any +from abc import ABC, abstractmethod +from enum import Enum + + +class LLMErrorType(Enum): + """Types of LLM errors""" + PROVIDER_UNAVAILABLE = "provider_unavailable" + API_ERROR = "api_error" + TIMEOUT = "timeout" + PARSE_ERROR = "parse_error" + RATE_LIMIT = "rate_limit" + INVALID_CONFIG = "invalid_config" + NETWORK_ERROR = "network_error" + AUTHENTICATION_ERROR = "authentication_error" + + +class LLMError(Exception): + """LLM Error class""" + def __init__(self, message: str, error_type: LLMErrorType, + retryable: bool = False, provider: Optional[str] = None): + super().__init__(message) + self.error_type = error_type + self.retryable = retryable + self.provider = provider + + +class LLMProvider(ABC): + """Base class for LLM providers""" + + @abstractmethod + def initialize(self, config: Dict[str, Any]) -> None: + """Initialize the provider with configuration""" + pass + + @abstractmethod + def generate_completion(self, messages: List[Dict[str, str]]) -> Dict[str, Any]: + """Generate completion from messages""" + pass + + @abstractmethod + def is_available(self) -> bool: + """Check if provider is available""" + pass + + @abstractmethod + def get_name(self) -> str: + """Get provider name""" + pass + + +class OpenAICompatibleProvider(LLMProvider): + """OpenAI-compatible API provider (supports Ollama, LM Studio, OpenRouter, OpenAI)""" + + def __init__(self): + self.config = None + + def initialize(self, config: Dict[str, Any]) -> None: + """Initialize provider with config""" + self.config = config + + required = ['endpoint', 'model'] + for key in required: + if key not in config: + raise LLMError( + f"Missing required config: {key}", + LLMErrorType.INVALID_CONFIG, + False, + config.get('provider', 'unknown') + ) + + def generate_completion(self, messages: List[Dict[str, str]]) -> Dict[str, Any]: + """Generate completion using OpenAI-compatible API""" + if not self.config: + raise LLMError( + "Provider not initialized", + LLMErrorType.INVALID_CONFIG, + False + ) + + headers = { + 'Content-Type': 'application/json', + } + + # Add API key if provided + if self.config.get('api_key'): + headers['Authorization'] = f"Bearer {self.config['api_key']}" + + payload = { + 'model': self.config['model'], + 'messages': messages, + 'temperature': self.config.get('temperature', 0.7), + 'max_tokens': self.config.get('max_tokens', 2000), + } + + timeout = self.config.get('timeout', 60) + + try: + response = requests.post( + self.config['endpoint'], + headers=headers, + json=payload, + timeout=timeout + ) + + # Handle error status codes + if response.status_code == 401 or response.status_code == 403: + raise LLMError( + f"Authentication failed: {response.status_code}", + LLMErrorType.AUTHENTICATION_ERROR, + False, + self.config.get('provider') + ) + elif response.status_code == 429: + raise LLMError( + "Rate limit exceeded", + LLMErrorType.RATE_LIMIT, + True, + self.config.get('provider') + ) + elif response.status_code >= 500: + raise LLMError( + f"Server error: {response.status_code}", + LLMErrorType.API_ERROR, + True, + self.config.get('provider') + ) + elif response.status_code != 200: + raise LLMError( + f"API error: {response.status_code} - {response.text}", + LLMErrorType.API_ERROR, + False, + self.config.get('provider') + ) + + data = response.json() + return self._parse_response(data) + + except requests.exceptions.Timeout: + raise LLMError( + "Request timeout", + LLMErrorType.TIMEOUT, + True, + self.config.get('provider') + ) + except requests.exceptions.RequestException as e: + raise LLMError( + f"Network error: {str(e)}", + LLMErrorType.NETWORK_ERROR, + True, + self.config.get('provider') + ) + + def is_available(self) -> bool: + """Check if provider is available""" + if not self.config or 'endpoint' not in self.config: + return False + + try: + # Try to reach the endpoint + test_endpoint = self.config['endpoint'].replace('/chat/completions', '/models') + + headers = {} + if self.config.get('api_key'): + headers['Authorization'] = f"Bearer {self.config['api_key']}" + + response = requests.get(test_endpoint, headers=headers, timeout=5) + return response.status_code in [200, 404, 405] + + except Exception: + return False + + def get_name(self) -> str: + """Get provider name""" + return self.config.get('provider', 'openai-compatible') if self.config else 'unknown' + + def _parse_response(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Parse API response""" + try: + if 'choices' not in data or not data['choices']: + raise ValueError("Invalid response format: missing choices") + + choice = data['choices'][0] + if 'message' not in choice: + raise ValueError("Invalid response format: missing message") + + return { + 'content': choice['message']['content'], + 'usage': { + 'prompt_tokens': data.get('usage', {}).get('prompt_tokens', 0), + 'completion_tokens': data.get('usage', {}).get('completion_tokens', 0), + 'total_tokens': data.get('usage', {}).get('total_tokens', 0), + }, + 'model': data.get('model', self.config['model']), + 'finish_reason': choice.get('finish_reason', 'stop') + } + + except (KeyError, ValueError, IndexError) as e: + raise LLMError( + f"Failed to parse response: {str(e)}", + LLMErrorType.PARSE_ERROR, + False, + self.config.get('provider') + ) + + +class LLMRouter: + """Routes requests to appropriate LLM providers with fallback""" + + def __init__(self): + self.providers: Dict[str, LLMProvider] = {} + self.default_provider: Optional[str] = None + self.fallback_providers: List[str] = [] + self.retry_attempts = 3 + self.retry_delay = 1.0 # seconds + + def register_provider(self, name: str, provider: LLMProvider) -> None: + """Register a new provider""" + self.providers[name] = provider + print(f"LLM Provider registered: {name}") + + def set_default_provider(self, name: str) -> None: + """Set default provider""" + if name not in self.providers: + raise LLMError( + f"Provider {name} not registered", + LLMErrorType.INVALID_CONFIG, + False + ) + self.default_provider = name + + def set_fallback_chain(self, providers: List[str]) -> None: + """Set fallback provider chain""" + for provider in providers: + if provider not in self.providers: + raise LLMError( + f"Provider {provider} not registered", + LLMErrorType.INVALID_CONFIG, + False + ) + self.fallback_providers = providers + + def set_retry_config(self, attempts: int, delay: float) -> None: + """Set retry configuration""" + self.retry_attempts = attempts + self.retry_delay = delay + + def generate(self, messages: List[Dict[str, str]], + preferred_provider: Optional[str] = None) -> Dict[str, Any]: + """Generate completion using provider chain""" + provider_chain = self._build_provider_chain(preferred_provider) + + if not provider_chain: + raise LLMError( + "No LLM providers available", + LLMErrorType.PROVIDER_UNAVAILABLE, + False + ) + + last_error = None + + # Try each provider in the chain + for provider_name in provider_chain: + provider = self.providers.get(provider_name) + if not provider: + continue + + print(f"Trying LLM provider: {provider_name}") + + try: + # Check availability + if not provider.is_available(): + print(f"Provider {provider_name} is not available, skipping") + last_error = LLMError( + f"Provider {provider_name} is not available", + LLMErrorType.PROVIDER_UNAVAILABLE, + True, + provider_name + ) + continue + + # Try to generate with retries + response = self._generate_with_retry(provider, messages) + print(f"Successfully generated completion with {provider_name}") + return response + + except LLMError as e: + last_error = e + print(f"Provider {provider_name} failed: {str(e)}") + + if not e.retryable: + continue + + except Exception as e: + last_error = LLMError( + f"Unexpected error with provider {provider_name}: {str(e)}", + LLMErrorType.API_ERROR, + False, + provider_name + ) + print(f"Unexpected error with {provider_name}: {str(e)}") + continue + + # All providers failed + if last_error: + raise last_error + else: + raise LLMError( + "All LLM providers failed", + LLMErrorType.PROVIDER_UNAVAILABLE, + False + ) + + def _generate_with_retry(self, provider: LLMProvider, + messages: List[Dict[str, str]]) -> Dict[str, Any]: + """Generate with retry logic""" + last_error = None + + for attempt in range(self.retry_attempts): + try: + return provider.generate_completion(messages) + except LLMError as e: + last_error = e + + if not e.retryable: + raise e + + if attempt < self.retry_attempts - 1: + delay = self.retry_delay * (2 ** attempt) # Exponential backoff + print(f"Retrying in {delay}s (attempt {attempt + 1}/{self.retry_attempts})...") + time.sleep(delay) + + raise last_error + + def _build_provider_chain(self, preferred_provider: Optional[str]) -> List[str]: + """Build provider chain based on preferences""" + chain = [] + + if preferred_provider and preferred_provider in self.providers: + chain.append(preferred_provider) + + if self.default_provider and self.default_provider not in chain: + chain.append(self.default_provider) + + for fallback in self.fallback_providers: + if fallback not in chain: + chain.append(fallback) + + return chain + + def get_registered_providers(self) -> List[str]: + """Get list of registered providers""" + return list(self.providers.keys()) + + +class SmartCardGenerator: + """Generate flashcards using LLM""" + + def __init__(self, router: LLMRouter): + self.router = router + + def generate_cards(self, content: str, context: Optional[str] = None) -> List[Dict[str, Any]]: + """Generate flashcards from content""" + prompt = self._build_card_generation_prompt(content, context) + + messages = [ + { + 'role': 'system', + 'content': 'You are a helpful assistant that creates high-quality flashcards from markdown content. Generate clear, concise questions with accurate answers. Respond ONLY with valid JSON.' + }, + { + 'role': 'user', + 'content': prompt + } + ] + + response = self.router.generate(messages) + return self._parse_cards(response['content']) + + def generate_answer(self, question: str, context: Optional[str] = None) -> str: + """Generate answer for a question""" + messages = [ + { + 'role': 'system', + 'content': 'You are a knowledgeable tutor providing clear, accurate answers.' + }, + { + 'role': 'user', + 'content': f"Question: {question}\n\nContext: {context or 'None'}\n\nProvide a comprehensive answer:" + } + ] + + response = self.router.generate(messages) + return response['content'].strip() + + def _build_card_generation_prompt(self, content: str, context: Optional[str]) -> str: + """Build prompt for card generation""" + return f"""Analyze this markdown content and generate flashcards: + +{content} + +Generate flashcards in JSON format (respond ONLY with the JSON array): +[ + {{ + "type": "basic", + "front": "Question", + "back": "Answer", + "tags": ["tag1"] + }} +] +""" + + def _parse_cards(self, response: str) -> List[Dict[str, Any]]: + """Parse card response""" + import re + + try: + # Extract JSON from markdown code block if present + json_match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL) + if json_match: + json_str = json_match.group(1) + else: + json_str = response + + cards = json.loads(json_str) + + if not isinstance(cards, list): + raise ValueError("Response is not a list") + + return cards + + except (json.JSONDecodeError, ValueError) as e: + raise LLMError( + f"Failed to parse card response: {str(e)}", + LLMErrorType.PARSE_ERROR, + False + ) + + +def create_llm_system(config: Dict[str, Any]) -> tuple: + """Create and configure LLM system from config + + Returns: (router, card_generator) + """ + router = LLMRouter() + + # Configure primary provider + if config.get('primary_provider'): + primary_config = { + 'provider': config['primary_provider'], + 'endpoint': config.get('primary_endpoint'), + 'model': config.get('primary_model'), + 'api_key': config.get('primary_api_key'), + 'temperature': config.get('temperature', 0.7), + 'max_tokens': config.get('max_tokens', 2000), + 'timeout': config.get('timeout', 60) + } + + primary_provider = OpenAICompatibleProvider() + primary_provider.initialize(primary_config) + router.register_provider(config['primary_provider'], primary_provider) + router.set_default_provider(config['primary_provider']) + + # Configure fallback provider if specified + if config.get('fallback_provider'): + fallback_config = { + 'provider': config['fallback_provider'], + 'endpoint': config.get('fallback_endpoint'), + 'model': config.get('fallback_model'), + 'api_key': config.get('fallback_api_key'), + 'temperature': config.get('temperature', 0.7), + 'max_tokens': config.get('max_tokens', 2000), + 'timeout': config.get('timeout', 60) + } + + fallback_provider = OpenAICompatibleProvider() + fallback_provider.initialize(fallback_config) + router.register_provider(config['fallback_provider'], fallback_provider) + router.set_fallback_chain([config['fallback_provider']]) + + # Create card generator + card_generator = SmartCardGenerator(router) + + return router, card_generator diff --git a/main.ts b/main.ts index 4b4c1d30..a1465157 100644 --- a/main.ts +++ b/main.ts @@ -5,6 +5,9 @@ import { DEFAULT_IGNORED_FILE_GLOBS, SettingsTab } from './src/settings' import { ANKI_ICON } from './src/constants' import { settingToData } from './src/setting-to-data' import { FileManager } from './src/files-manager' +import { createLLMSystem, LLMRouter, SmartCardGenerator, MultiPassCardGenerator, GenerationProgress, CardBatch } from './src/llm/index' +import { CardPreviewModal } from './src/llm/preview-modal' +import { GenerationProgressModal } from './src/llm/ui/progress-modal' export default class MyPlugin extends Plugin { @@ -13,6 +16,9 @@ export default class MyPlugin extends Plugin { fields_dict: Record added_media: string[] file_hashes: Record + llmRouter: LLMRouter | null + llmGenerator: SmartCardGenerator | null + llmMultiPassGenerator: MultiPassCardGenerator | null async getDefaultSettings(): Promise { let settings: PluginSettings = { @@ -44,6 +50,19 @@ export default class MyPlugin extends Plugin { "Add Obsidian Tags": false, }, IGNORED_FILE_GLOBS: DEFAULT_IGNORED_FILE_GLOBS, + LLM: { + enabled: false, + providers: [], + defaultProvider: '', + fallbackChain: [], + autoGenerate: false, + autoGenerateAnswers: false, + showPreview: true, + batchSize: 10, + temperature: 0.7, + maxTokens: 2000, + timeout: 60 + } } /*Making settings from scratch, so need note types*/ this.note_types = await AnkiConnect.invoke('modelNames') as Array @@ -210,7 +229,7 @@ export default class MyPlugin extends Plugin { } else { manager = new FileManager(this.app, data, this.app.vault.getMarkdownFiles(), this.file_hashes, this.added_media); } - + await manager.initialiseFiles() await manager.requests_1() this.added_media = Array.from(manager.added_media_set) @@ -222,6 +241,221 @@ export default class MyPlugin extends Plugin { this.saveAllData() } + async initializeLLM() { + this.llmRouter = null; + this.llmGenerator = null; + this.llmMultiPassGenerator = null; + + if (!this.settings.LLM || !this.settings.LLM.enabled) { + console.log('LLM features are disabled'); + return; + } + + try { + const system = await createLLMSystem(this.settings.LLM); + if (system) { + this.llmRouter = system.router; + this.llmGenerator = system.generator; + this.llmMultiPassGenerator = system.multiPassGenerator; + console.log('LLM system initialized successfully'); + new Notice('LLM features enabled!'); + } + } catch (error) { + console.error('Failed to initialize LLM system:', error); + new Notice('Warning: LLM initialization failed. Check console for details.'); + } + } + + async generateCardsWithAI() { + if (!this.llmGenerator) { + new Notice('LLM is not enabled! Please enable it in settings.'); + return; + } + + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { + new Notice('No active file!'); + return; + } + + try { + new Notice('Generating cards with AI...'); + const content = await this.app.vault.read(activeFile); + + const cards = await this.llmGenerator.generateCards(content, { + useAnalyzer: true, + maxCards: this.settings.LLM?.batchSize || 10 + }); + + if (cards.length === 0) { + new Notice('No cards generated. Try adjusting the content.'); + return; + } + + // Show preview modal + if (this.settings.LLM?.showPreview) { + const modal = new CardPreviewModal( + this.app, + cards, + async (approvedCards) => { + await this.addCardsToAnki(approvedCards, activeFile); + } + ); + modal.open(); + } else { + // Add cards directly without preview + await this.addCardsToAnki(cards, activeFile); + } + + } catch (error) { + console.error('Error generating cards:', error); + new Notice(`Error: ${error.message}`); + } + } + + async addCardsToAnki(cards: any[], file: TFile) { + // TODO: Convert GeneratedCard to AnkiConnect format and add to Anki + // For now, just show a success message + new Notice(`Adding ${cards.length} cards to Anki... (Not yet implemented)`); + console.log('Cards to add:', cards); + + // This is where we would: + // 1. Convert GeneratedCard format to AnkiConnect note format + // 2. Call AnkiConnect.invoke('addNote', ...) for each card + // 3. Update file hashes and added media + // 4. Save data + + // Placeholder implementation: + // const data: ParsedSettings = await settingToData(this.app, this.settings, this.fields_dict) + // for (const card of cards) { + // await AnkiConnect.invoke('addNote', { + // note: { + // deckName: data.template.deckName, + // modelName: 'Basic', + // fields: { + // Front: card.front, + // Back: card.back + // }, + // tags: card.tags || [this.settings.Defaults.Tag] + // } + // }) + // } + + new Notice(`Successfully added ${cards.length} cards!`); + } + + async generateAnswerWithAI() { + if (!this.llmGenerator) { + new Notice('LLM is not enabled! Please enable it in settings.'); + return; + } + + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { + new Notice('No active file!'); + return; + } + + try { + // Get selected text or use file content as context + const editor = this.app.workspace.activeEditor?.editor; + if (!editor) { + new Notice('No active editor!'); + return; + } + + const selection = editor.getSelection(); + if (!selection) { + new Notice('Please select a question to generate an answer for!'); + return; + } + + new Notice('Generating answer with AI...'); + const fileContent = await this.app.vault.read(activeFile); + + const answer = await this.llmGenerator.generateAnswer(selection, fileContent); + + // Insert answer below the question + const cursor = editor.getCursor(); + editor.replaceRange(`\n\nAnswer: ${answer}`, cursor); + + new Notice('Answer generated!'); + + } catch (error) { + console.error('Error generating answer:', error); + new Notice(`Error: ${error.message}`); + } + } + + async generateCardsWithAIEnhanced() { + if (!this.llmMultiPassGenerator) { + new Notice('LLM is not enabled! Please enable it in settings.'); + return; + } + + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { + new Notice('No active file!'); + return; + } + + try { + const content = await this.app.vault.read(activeFile); + + // Create progress modal + const progressModal = new GenerationProgressModal(this.app, { + onComplete: async (cards) => { + // Show preview modal for final approval + if (this.settings.LLM?.showPreview) { + const previewModal = new CardPreviewModal( + this.app, + cards, + async (approvedCards) => { + await this.addCardsToAnki(approvedCards, activeFile); + } + ); + previewModal.open(); + } else { + // Add cards directly without preview + await this.addCardsToAnki(cards, activeFile); + } + }, + onCancel: () => { + new Notice('Card generation cancelled.'); + } + }); + + progressModal.open(); + + // Start multi-pass generation + const generator = this.llmMultiPassGenerator.generateCardsMultiPass(content, { + maxCards: this.settings.LLM?.batchSize || 50, + minQuality: 0.7 + }); + + // Process the async generator + for await (const result of generator) { + // Check if user cancelled + if (progressModal.isCancelled()) { + break; + } + + // Update progress or add card batch + if ('phase' in result) { + // It's a GenerationProgress + progressModal.updateProgress(result as GenerationProgress); + } else { + // It's a CardBatch + progressModal.addCardBatch(result as CardBatch); + } + } + + } catch (error) { + console.error('Error generating cards:', error); + new Notice(`Error: ${error.message}`); + } + } + async onload() { console.log('loading Obsidian_to_Anki...'); addIcon('anki', ANKI_ICON) @@ -250,6 +484,9 @@ export default class MyPlugin extends Plugin { this.added_media = await this.loadAddedMedia() this.file_hashes = await this.loadFileHashes() + // Initialize LLM system if enabled + await this.initializeLLM() + this.addSettingTab(new SettingsTab(this.app, this)); this.addRibbonIcon('anki', 'Obsidian_to_Anki - Scan Vault', async () => { @@ -263,6 +500,31 @@ export default class MyPlugin extends Plugin { await this.scanVault() } }) + + // LLM commands + this.addCommand({ + id: 'anki-generate-cards-ai', + name: 'Generate Cards with AI', + callback: async () => { + await this.generateCardsWithAI() + } + }) + + this.addCommand({ + id: 'anki-generate-cards-ai-enhanced', + name: 'Generate Cards with AI (Enhanced for Long Documents)', + callback: async () => { + await this.generateCardsWithAIEnhanced() + } + }) + + this.addCommand({ + id: 'anki-generate-answer-ai', + name: 'Generate Answer with AI', + callback: async () => { + await this.generateAnswerWithAI() + } + }) } async onunload() { diff --git a/obsidian_to_anki_config.ini b/obsidian_to_anki_config.ini index 9f63bc2e..7369a682 100644 --- a/obsidian_to_anki_config.ini +++ b/obsidian_to_anki_config.ini @@ -27,6 +27,33 @@ CurlyCloze = False GUI = True Regex = False ID Comments = True -Anki Path = -Anki Profile = +Anki Path = +Anki Profile = + +[LLM] +# Enable LLM features (True/False) +Enabled = False + +# Primary provider configuration +Primary Provider = ollama +Primary Endpoint = http://localhost:11434/v1/chat/completions +Primary Model = llama2 +Primary API Key = + +# Fallback provider (optional) +Fallback Provider = +Fallback Endpoint = +Fallback Model = +Fallback API Key = + +# LLM parameters +Temperature = 0.7 +Max Tokens = 2000 +Timeout = 60 + +# Feature flags +Auto Generate Cards = False +Auto Generate Answers = True +Show Preview = True +Batch Size = 10 diff --git a/package-lock.json b/package-lock.json index a2372c62..32893397 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-to-anki-plugin", - "version": "3.4.2", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-to-anki-plugin", - "version": "3.4.2", + "version": "3.6.0", "license": "MIT", "dependencies": { "byte-base64": "^1.1.0", diff --git a/requirements.txt b/requirements.txt index 05b56746..240bf352 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ Gooey==1.0.4.0.0.0 Markdown==3.2.2 +requests>=2.28.0 +python-dotenv>=0.19.0 diff --git a/src/interfaces/settings-interface.ts b/src/interfaces/settings-interface.ts index dd022b0f..87b7cfb3 100644 --- a/src/interfaces/settings-interface.ts +++ b/src/interfaces/settings-interface.ts @@ -1,6 +1,29 @@ import { FIELDS_DICT } from './field-interface' import { AnkiConnectNote } from './note-interface' +export interface LLMProviderConfig { + name: string; + type: 'openai' | 'ollama' | 'openrouter' | 'custom'; + endpoint: string; + apiKey?: string; + model: string; + enabled: boolean; +} + +export interface LLMSettings { + enabled: boolean; + providers: LLMProviderConfig[]; + defaultProvider: string; + fallbackChain: string[]; + autoGenerate: boolean; + autoGenerateAnswers: boolean; + showPreview: boolean; + batchSize: number; + temperature: number; + maxTokens: number; + timeout: number; +} + export interface PluginSettings { CUSTOM_REGEXPS: Record, FILE_LINK_FIELDS: Record, @@ -30,6 +53,7 @@ export interface PluginSettings { "Add Obsidian Tags": boolean }, IGNORED_FILE_GLOBS:string[] + LLM?: LLMSettings } export interface FileData { diff --git a/src/llm/card-generator.ts b/src/llm/card-generator.ts new file mode 100644 index 00000000..ec920da0 --- /dev/null +++ b/src/llm/card-generator.ts @@ -0,0 +1,305 @@ +/** + * Smart Card Generator + * Generates flashcards using LLM + */ + +import { LLMRouter } from './llm-router'; +import { PromptManager } from './prompt-manager'; +import { ContentAnalyzer } from './content-analyzer'; +import { GeneratedCard } from './interfaces/prompt-template.interface'; +import { ContentSection } from './interfaces/content-section.interface'; +import { LLMError, LLMErrorType } from './llm-error'; + +export interface CardGenerationOptions { + maxCards?: number; + preferredTypes?: string[]; + context?: string; + useAnalyzer?: boolean; + threshold?: number; +} + +export class SmartCardGenerator { + private llmRouter: LLMRouter; + private promptManager: PromptManager; + private contentAnalyzer: ContentAnalyzer; + + constructor(llmRouter: LLMRouter, promptManager: PromptManager) { + this.llmRouter = llmRouter; + this.promptManager = promptManager; + this.contentAnalyzer = new ContentAnalyzer(); + } + + /** + * Generate flashcards from markdown content + */ + async generateCards( + content: string, + options?: CardGenerationOptions + ): Promise { + // Analyze content if requested + let sectionsToProcess: string[] = [content]; + + if (options?.useAnalyzer) { + const analysis = this.contentAnalyzer.analyzeMarkdown(content); + const candidates = analysis.candidateSections; + + if (candidates.length > 0) { + sectionsToProcess = candidates.map(section => { + const context = this.contentAnalyzer.getContext(section, analysis.sections); + return context ? `${context}\n\n${section.content}` : section.content; + }); + + // Limit sections if maxCards specified + if (options.maxCards) { + sectionsToProcess = sectionsToProcess.slice(0, options.maxCards); + } + } + } + + // Generate cards for each section + const allCards: GeneratedCard[] = []; + + for (const section of sectionsToProcess) { + try { + const cards = await this.generateCardsFromSection(section, options); + allCards.push(...cards); + + // Stop if we've reached max cards + if (options?.maxCards && allCards.length >= options.maxCards) { + break; + } + } catch (error) { + console.error('Failed to generate cards for section:', error); + // Continue with other sections + } + } + + // Limit to max cards if specified + if (options?.maxCards) { + return allCards.slice(0, options.maxCards); + } + + return allCards; + } + + /** + * Generate cards from a single section + */ + private async generateCardsFromSection( + content: string, + options?: CardGenerationOptions + ): Promise { + // Choose template based on preferred types + let templateName = 'generate_cards'; + if (options?.preferredTypes && options.preferredTypes.length > 0) { + const preferredType = options.preferredTypes[0]; + if (preferredType === 'cloze') { + templateName = 'generate_cloze'; + } else if (preferredType === 'qa') { + templateName = 'generate_qa'; + } + } + + // Render prompt + const messages = this.promptManager.renderPrompt(templateName, { + content: content + }); + + // Generate with LLM + const response = await this.llmRouter.generate(messages); + + // Parse response + return this.parseCardResponse(response.content); + } + + /** + * Generate answer for a question + */ + async generateAnswer( + question: string, + context?: string + ): Promise { + const messages = this.promptManager.renderPrompt('generate_answer', { + question: question, + context: context || 'No additional context provided.' + }); + + const response = await this.llmRouter.generate(messages); + return response.content.trim(); + } + + /** + * Improve an existing card + */ + async improveCard( + front: string, + back: string + ): Promise<{ front: string; back: string; improvements?: string }> { + const messages = this.promptManager.renderPrompt('improve_card', { + front: front, + back: back + }); + + const response = await this.llmRouter.generate(messages); + return this.parseImprovedCard(response.content); + } + + /** + * Batch generate cards for multiple contents + */ + async batchGenerateCards( + contents: string[], + options?: CardGenerationOptions + ): Promise { + const results: GeneratedCard[][] = []; + + for (const content of contents) { + try { + const cards = await this.generateCards(content, options); + results.push(cards); + } catch (error) { + console.error('Failed to generate cards for content:', error); + results.push([]); + } + } + + return results; + } + + /** + * Parse card generation response from LLM + */ + private parseCardResponse(response: string): GeneratedCard[] { + try { + // Try to extract JSON from markdown code block + let jsonContent = response; + + const jsonBlockMatch = response.match(/```json\s*([\s\S]*?)\s*```/); + if (jsonBlockMatch) { + jsonContent = jsonBlockMatch[1]; + } + + // Try to parse as JSON array + const parsed = JSON.parse(jsonContent); + + if (!Array.isArray(parsed)) { + throw new Error('Response is not an array'); + } + + // Validate and normalize cards + const cards: GeneratedCard[] = []; + for (const card of parsed) { + if (this.isValidCard(card)) { + cards.push({ + type: card.type || 'basic', + front: card.front || '', + back: card.back || '', + tags: card.tags || [], + confidence: card.confidence || 0.8 + }); + } + } + + return cards; + + } catch (error) { + console.error('Failed to parse card response:', error); + console.error('Response content:', response); + + throw new LLMError( + `Failed to parse card response: ${error.message}`, + LLMErrorType.PARSE_ERROR, + false + ); + } + } + + /** + * Validate card structure + */ + private isValidCard(card: any): boolean { + if (typeof card !== 'object' || card === null) { + return false; + } + + // Must have front and back (or text for cloze) + if (card.type === 'cloze') { + return typeof card.text === 'string' && card.text.length > 0; + } else { + return typeof card.front === 'string' && + typeof card.back === 'string' && + card.front.length > 0 && + card.back.length > 0; + } + } + + /** + * Parse improved card response + */ + private parseImprovedCard(response: string): { front: string; back: string; improvements?: string } { + try { + // Extract JSON from response + let jsonContent = response; + + const jsonBlockMatch = response.match(/```json\s*([\s\S]*?)\s*```/); + if (jsonBlockMatch) { + jsonContent = jsonBlockMatch[1]; + } + + const parsed = JSON.parse(jsonContent); + + return { + front: parsed.front || '', + back: parsed.back || '', + improvements: parsed.improvements || undefined + }; + + } catch (error) { + // Fallback: try to extract from text format + const lines = response.split('\n'); + let front = ''; + let back = ''; + let currentSection = ''; + + for (const line of lines) { + const lowerLine = line.toLowerCase(); + if (lowerLine.includes('front:')) { + currentSection = 'front'; + front = line.substring(line.indexOf(':') + 1).trim(); + } else if (lowerLine.includes('back:')) { + currentSection = 'back'; + back = line.substring(line.indexOf(':') + 1).trim(); + } else if (currentSection === 'front' && line.trim()) { + front += ' ' + line.trim(); + } else if (currentSection === 'back' && line.trim()) { + back += ' ' + line.trim(); + } + } + + if (front && back) { + return { front: front.trim(), back: back.trim() }; + } + + throw new LLMError( + 'Failed to parse improved card response', + LLMErrorType.PARSE_ERROR, + false + ); + } + } + + /** + * Estimate number of cards that could be generated from content + */ + estimateCardCount(content: string): number { + const analysis = this.contentAnalyzer.analyzeMarkdown(content); + return analysis.highPotentialCount; + } + + /** + * Get content analysis + */ + analyzeContent(content: string) { + return this.contentAnalyzer.analyzeMarkdown(content); + } +} diff --git a/src/llm/chunking/document-chunker.ts b/src/llm/chunking/document-chunker.ts new file mode 100644 index 00000000..1e255d11 --- /dev/null +++ b/src/llm/chunking/document-chunker.ts @@ -0,0 +1,304 @@ +/** + * Document Chunker + * Intelligently splits long documents into manageable chunks + */ + +export interface DocumentChunk { + id: string; + content: string; + metadata: { + index: number; + level: number; + heading: string; + parent?: string; + tokenCount: number; + importance: number; + keywords: string[]; + startLine?: number; + endLine?: number; + }; +} + +export interface ChunkingOptions { + maxTokens?: number; + minTokens?: number; + preserveHeadings?: boolean; + preserveCodeBlocks?: boolean; + overlapTokens?: number; +} + +export class DocumentChunker { + private readonly DEFAULT_MAX_TOKENS = 1500; + private readonly DEFAULT_MIN_TOKENS = 200; + private readonly DEFAULT_OVERLAP = 100; + + /** + * Chunk document intelligently based on structure + */ + async chunkDocument( + content: string, + options?: ChunkingOptions + ): Promise { + const maxTokens = options?.maxTokens || this.DEFAULT_MAX_TOKENS; + const minTokens = options?.minTokens || this.DEFAULT_MIN_TOKENS; + + const chunks: DocumentChunk[] = []; + const lines = content.split('\n'); + + let currentChunk: string[] = []; + let currentHeading = 'Introduction'; + let currentLevel = 0; + let chunkIndex = 0; + let lineIndex = 0; + let startLine = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + lineIndex = i; + + // Check if this is a heading + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + + if (headingMatch) { + // Save current chunk if it has content + if (currentChunk.length > 0) { + const chunkContent = currentChunk.join('\n'); + const tokenCount = this.estimateTokens(chunkContent); + + if (tokenCount >= minTokens) { + chunks.push({ + id: `chunk-${chunkIndex}`, + content: chunkContent, + metadata: { + index: chunkIndex, + level: currentLevel, + heading: currentHeading, + tokenCount: tokenCount, + importance: this.calculateImportance(chunkContent, currentLevel), + keywords: this.extractKeywords(chunkContent), + startLine: startLine, + endLine: i - 1 + } + }); + chunkIndex++; + } + + currentChunk = []; + startLine = i; + } + + // Start new chunk with this heading + currentLevel = headingMatch[1].length; + currentHeading = headingMatch[2].trim(); + currentChunk.push(line); + } else { + // Add line to current chunk + currentChunk.push(line); + + // Check if chunk is getting too large + const currentContent = currentChunk.join('\n'); + const tokenCount = this.estimateTokens(currentContent); + + if (tokenCount >= maxTokens) { + // Try to find a good split point + const splitPoint = this.findSplitPoint(currentChunk); + + if (splitPoint > 0) { + const beforeSplit = currentChunk.slice(0, splitPoint).join('\n'); + const afterSplit = currentChunk.slice(splitPoint); + + chunks.push({ + id: `chunk-${chunkIndex}`, + content: beforeSplit, + metadata: { + index: chunkIndex, + level: currentLevel, + heading: currentHeading, + tokenCount: this.estimateTokens(beforeSplit), + importance: this.calculateImportance(beforeSplit, currentLevel), + keywords: this.extractKeywords(beforeSplit), + startLine: startLine, + endLine: startLine + splitPoint - 1 + } + }); + chunkIndex++; + + currentChunk = afterSplit; + startLine = startLine + splitPoint; + } + } + } + } + + // Add final chunk + if (currentChunk.length > 0) { + const chunkContent = currentChunk.join('\n'); + const tokenCount = this.estimateTokens(chunkContent); + + if (tokenCount >= minTokens / 2) { // Be more lenient for final chunk + chunks.push({ + id: `chunk-${chunkIndex}`, + content: chunkContent, + metadata: { + index: chunkIndex, + level: currentLevel, + heading: currentHeading, + tokenCount: tokenCount, + importance: this.calculateImportance(chunkContent, currentLevel), + keywords: this.extractKeywords(chunkContent), + startLine: startLine, + endLine: lines.length - 1 + } + }); + } + } + + return chunks; + } + + /** + * Estimate token count (rough approximation) + */ + private estimateTokens(text: string): number { + // Rough estimate: 1 token ≈ 4 characters + // More accurate for English, less for other languages + return Math.ceil(text.length / 4); + } + + /** + * Calculate importance score based on content and position + */ + private calculateImportance(content: string, level: number): number { + let score = 0.5; // Base score + + // Higher level headings are more important + score += (6 - level) * 0.05; + + // Content with definitions is important + if (content.match(/is defined as|refers to|means|이란|의미는/i)) { + score += 0.15; + } + + // Content with lists is important + if (content.match(/^[\s]*[-*+]\s/m)) { + score += 0.1; + } + + // Content with code blocks + if (content.match(/```/)) { + score += 0.1; + } + + // Content with emphasis + const emphasisCount = (content.match(/\*\*.*?\*\*|__.*?__|==.*?==/g) || []).length; + score += Math.min(emphasisCount * 0.02, 0.1); + + // Keywords indicating important content + const importantKeywords = [ + 'important', 'key', 'crucial', 'essential', 'fundamental', + '중요', '핵심', '필수', '기본' + ]; + for (const keyword of importantKeywords) { + if (content.toLowerCase().includes(keyword)) { + score += 0.05; + } + } + + return Math.min(score, 1.0); + } + + /** + * Extract keywords from content + */ + private extractKeywords(content: string): string[] { + const keywords: Set = new Set(); + + // Extract words from emphasis + const emphasized = content.match(/\*\*(.*?)\*\*|__(.*?)__|==(.*?)==/g) || []; + emphasized.forEach(match => { + const cleaned = match.replace(/[\*_=]/g, '').trim(); + if (cleaned.length > 2) { + keywords.add(cleaned); + } + }); + + // Extract from code blocks (language names, function names) + const codeBlocks = content.match(/```(\w+)?/g) || []; + codeBlocks.forEach(match => { + const lang = match.replace(/```/, '').trim(); + if (lang) { + keywords.add(lang); + } + }); + + // Extract capitalized words (likely important terms) + const capitalized = content.match(/\b[A-Z][a-z]+(?:[A-Z][a-z]+)*\b/g) || []; + capitalized.forEach(word => { + if (word.length > 3) { + keywords.add(word); + } + }); + + return Array.from(keywords).slice(0, 10); // Limit to 10 keywords + } + + /** + * Find a good split point in the chunk + */ + private findSplitPoint(lines: string[]): number { + // Try to split at paragraph boundaries (empty lines) + for (let i = lines.length - 1; i > lines.length / 2; i--) { + if (lines[i].trim() === '') { + return i; + } + } + + // Try to split at sentence boundaries + for (let i = lines.length - 1; i > lines.length / 2; i--) { + const line = lines[i]; + if (line.match(/[.!?]\s*$/)) { + return i + 1; + } + } + + // Fall back to splitting in the middle + return Math.floor(lines.length * 0.75); + } + + /** + * Create document overview from chunks + */ + createOverview(chunks: DocumentChunk[]): string { + const headings = chunks + .filter(chunk => chunk.metadata.level <= 2) + .map(chunk => { + const indent = ' '.repeat(chunk.metadata.level - 1); + return `${indent}- ${chunk.metadata.heading}`; + }); + + return `Document Structure:\n${headings.join('\n')}`; + } + + /** + * Get context for a specific chunk + */ + getChunkContext( + chunk: DocumentChunk, + allChunks: DocumentChunk[] + ): { + previous?: DocumentChunk; + next?: DocumentChunk; + siblings: DocumentChunk[]; + } { + const index = chunk.metadata.index; + + return { + previous: index > 0 ? allChunks[index - 1] : undefined, + next: index < allChunks.length - 1 ? allChunks[index + 1] : undefined, + siblings: allChunks.filter( + c => c.metadata.level === chunk.metadata.level && + c.metadata.index !== index + ) + }; + } +} diff --git a/src/llm/content-analyzer.ts b/src/llm/content-analyzer.ts new file mode 100644 index 00000000..7107c13b --- /dev/null +++ b/src/llm/content-analyzer.ts @@ -0,0 +1,355 @@ +/** + * Content Analyzer + * Analyzes markdown content and identifies sections suitable for flashcards + */ + +import { ContentSection, AnalysisResult } from './interfaces/content-section.interface'; + +export class ContentAnalyzer { + private readonly HIGH_POTENTIAL_THRESHOLD = 0.6; + + /** + * Analyze markdown content and extract sections + */ + analyzeMarkdown(markdown: string): AnalysisResult { + const sections = this.extractSections(markdown); + const candidateSections = this.selectCandidateSections(sections); + + return { + sections: sections, + candidateSections: candidateSections, + totalSections: sections.length, + highPotentialCount: candidateSections.length + }; + } + + /** + * Extract sections from markdown content + */ + private extractSections(markdown: string): ContentSection[] { + const sections: ContentSection[] = []; + const lines = markdown.split('\n'); + + let currentSection: ContentSection | null = null; + let lineNumber = 0; + let currentHeading = ''; + + for (const line of lines) { + lineNumber++; + const trimmed = line.trim(); + + // Skip empty lines + if (!trimmed) { + continue; + } + + // Heading + if (trimmed.startsWith('#')) { + if (currentSection) { + sections.push(currentSection); + } + + const level = this.getHeadingLevel(trimmed); + const content = trimmed.replace(/^#+\s*/, ''); + currentHeading = content; + + currentSection = { + type: 'heading', + content: content, + level: level, + cardPotential: this.calculateHeadingPotential(content, level), + metadata: { + lineStart: lineNumber, + lineEnd: lineNumber, + } + }; + } + // List item + else if (trimmed.match(/^[-*+]\s/) || trimmed.match(/^\d+\.\s/)) { + if (currentSection?.type === 'list') { + // Continue existing list + currentSection.content += '\n' + trimmed; + currentSection.metadata!.lineEnd = lineNumber; + } else { + // Start new list + if (currentSection) { + sections.push(currentSection); + } + + currentSection = { + type: 'list', + content: trimmed, + cardPotential: 0.8, + metadata: { + lineStart: lineNumber, + lineEnd: lineNumber, + parentHeading: currentHeading + } + }; + } + } + // Quote/Blockquote + else if (trimmed.startsWith('>')) { + if (currentSection?.type === 'quote') { + currentSection.content += '\n' + trimmed; + currentSection.metadata!.lineEnd = lineNumber; + } else { + if (currentSection) { + sections.push(currentSection); + } + + currentSection = { + type: 'quote', + content: trimmed, + cardPotential: 0.7, + metadata: { + lineStart: lineNumber, + lineEnd: lineNumber, + parentHeading: currentHeading + } + }; + } + } + // Code block + else if (trimmed.startsWith('```')) { + if (currentSection?.type === 'code') { + // End of code block + currentSection.content += '\n' + trimmed; + currentSection.metadata!.lineEnd = lineNumber; + sections.push(currentSection); + currentSection = null; + } else { + // Start of code block + if (currentSection) { + sections.push(currentSection); + } + + currentSection = { + type: 'code', + content: trimmed, + cardPotential: 0.4, + metadata: { + lineStart: lineNumber, + lineEnd: lineNumber, + parentHeading: currentHeading + } + }; + } + } + // Table row + else if (trimmed.includes('|')) { + if (currentSection?.type === 'table') { + currentSection.content += '\n' + trimmed; + currentSection.metadata!.lineEnd = lineNumber; + } else { + if (currentSection) { + sections.push(currentSection); + } + + currentSection = { + type: 'table', + content: trimmed, + cardPotential: 0.75, + metadata: { + lineStart: lineNumber, + lineEnd: lineNumber, + parentHeading: currentHeading + } + }; + } + } + // Paragraph + else { + if (currentSection?.type === 'paragraph') { + currentSection.content += ' ' + trimmed; + currentSection.metadata!.lineEnd = lineNumber; + } else { + if (currentSection && currentSection.type !== 'code') { + sections.push(currentSection); + } + + if (currentSection?.type === 'code') { + // Still in code block + currentSection.content += '\n' + trimmed; + } else { + currentSection = { + type: 'paragraph', + content: trimmed, + cardPotential: this.calculateParagraphPotential(trimmed), + metadata: { + lineStart: lineNumber, + lineEnd: lineNumber, + parentHeading: currentHeading + } + }; + } + } + } + } + + // Add final section + if (currentSection) { + sections.push(currentSection); + } + + return sections; + } + + /** + * Get heading level from markdown heading + */ + private getHeadingLevel(heading: string): number { + const match = heading.match(/^#+/); + return match ? match[0].length : 0; + } + + /** + * Calculate card potential for headings + */ + private calculateHeadingPotential(content: string, level: number): number { + let potential = 0.5; + + // Higher level headings are more likely to be good questions + potential += (6 - level) * 0.05; + + // Question-like headings are better + if (content.includes('?') || + content.toLowerCase().includes('what') || + content.toLowerCase().includes('how') || + content.toLowerCase().includes('why') || + content.toLowerCase().includes('when') || + content.toLowerCase().includes('where')) { + potential += 0.2; + } + + // Definitions and concepts + if (content.toLowerCase().includes('definition') || + content.toLowerCase().includes('concept') || + content.toLowerCase().includes('이란') || + content.toLowerCase().includes('무엇')) { + potential += 0.15; + } + + return Math.min(potential, 1.0); + } + + /** + * Calculate card potential for paragraphs + */ + private calculateParagraphPotential(content: string): number { + let potential = 0.5; + + // Length consideration (not too short, not too long) + const wordCount = content.split(/\s+/).length; + if (wordCount >= 10 && wordCount <= 100) { + potential += 0.1; + } else if (wordCount > 100) { + potential -= 0.2; + } + + // Contains definitions + if (content.includes(':') || + content.includes('is defined as') || + content.includes('refers to') || + content.includes('means') || + content.includes('이란') || + content.includes('의미는')) { + potential += 0.2; + } + + // Contains key phrases + if (content.toLowerCase().includes('important') || + content.toLowerCase().includes('key') || + content.toLowerCase().includes('note that') || + content.includes('중요') || + content.includes('핵심')) { + potential += 0.15; + } + + // Contains examples + if (content.toLowerCase().includes('example') || + content.toLowerCase().includes('for instance') || + content.includes('예를 들어') || + content.includes('예시')) { + potential += 0.1; + } + + return Math.min(potential, 1.0); + } + + /** + * Select sections that are good candidates for flashcards + */ + selectCandidateSections( + sections: ContentSection[], + threshold: number = this.HIGH_POTENTIAL_THRESHOLD + ): ContentSection[] { + return sections.filter(section => section.cardPotential >= threshold); + } + + /** + * Group related sections together + */ + groupRelatedSections(sections: ContentSection[]): ContentSection[][] { + const groups: ContentSection[][] = []; + let currentGroup: ContentSection[] = []; + let currentHeading = ''; + + for (const section of sections) { + if (section.type === 'heading') { + // Start new group + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + currentGroup = [section]; + currentHeading = section.content; + } else { + // Add to current group if related + if (section.metadata?.parentHeading === currentHeading) { + currentGroup.push(section); + } else { + // Start new group + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + currentGroup = [section]; + } + } + } + + // Add final group + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups; + } + + /** + * Get context for a section (surrounding content) + */ + getContext(section: ContentSection, allSections: ContentSection[]): string { + const context: string[] = []; + + // Find parent heading + if (section.metadata?.parentHeading) { + context.push(`# ${section.metadata.parentHeading}`); + } + + // Find related sections + for (const other of allSections) { + if (other === section) continue; + if (other.metadata?.parentHeading === section.metadata?.parentHeading) { + if (other.metadata?.lineStart && section.metadata?.lineStart) { + // Include sections close to this one + const distance = Math.abs(other.metadata.lineStart - section.metadata.lineStart); + if (distance < 5) { + context.push(other.content); + } + } + } + } + + return context.join('\n\n'); + } +} diff --git a/src/llm/generation/multi-pass-generator.ts b/src/llm/generation/multi-pass-generator.ts new file mode 100644 index 00000000..a756c805 --- /dev/null +++ b/src/llm/generation/multi-pass-generator.ts @@ -0,0 +1,482 @@ +/** + * Multi-Pass Card Generator + * Generates cards using multiple passes for better quality + */ + +import { LLMRouter } from '../llm-router'; +import { PromptManager } from '../prompt-manager'; +import { DocumentChunker, DocumentChunk } from '../chunking/document-chunker'; +import { GeneratedCard } from '../interfaces/prompt-template.interface'; + +export interface DocumentPlan { + overview: string; + mainTopics: string[]; + sections: { + heading: string; + importance: number; + estimatedCards: number; + difficulty: number; + }[]; + totalEstimatedCards: number; +} + +export interface GenerationProgress { + phase: 'planning' | 'analyzing' | 'generating' | 'validating' | 'completed'; + currentChunk?: number; + totalChunks?: number; + cardsGenerated: number; + message: string; +} + +export interface CardBatch { + chunkId: string; + heading: string; + cards: GeneratedCard[]; + summary: string; + quality: number; +} + +export class MultiPassCardGenerator { + private chunker: DocumentChunker; + private llmRouter: LLMRouter; + private promptManager: PromptManager; + + constructor(llmRouter: LLMRouter, promptManager: PromptManager) { + this.chunker = new DocumentChunker(); + this.llmRouter = llmRouter; + this.promptManager = promptManager; + this.registerEnhancedPrompts(); + } + + /** + * Register enhanced prompts for multi-pass generation + */ + private registerEnhancedPrompts() { + // Pass 1: Document Analysis + this.promptManager.registerTemplate({ + name: 'analyze_document', + description: 'Analyze document structure and create plan', + systemPrompt: `You are an expert learning material analyst. +Your task is to analyze documents and create a strategic plan for flashcard generation. + +Focus on: +- Identifying main topics and subtopics +- Assessing section importance +- Estimating appropriate number of cards +- Determining learning difficulty`, + userPromptTemplate: `Analyze this document and create a flashcard generation plan: + +{{content}} + +Provide a JSON response with: +{ + "overview": "Brief overview of the document", + "mainTopics": ["topic1", "topic2", ...], + "sections": [ + { + "heading": "Section title", + "importance": 0.8, + "estimatedCards": 3-5, + "difficulty": 1-5 + } + ] +}`, + variables: ['content'] + }); + + // Pass 2: Enhanced Card Generation + this.promptManager.registerTemplate({ + name: 'generate_cards_enhanced', + description: 'Generate cards with context and quality focus', + systemPrompt: `You are an expert flashcard creator specializing in high-quality learning materials. + +Quality Standards: +- CLARITY: Questions must be unambiguous +- ACCURACY: Answers must be factually correct +- COMPLETENESS: Answers must be comprehensive yet concise +- RELEVANCE: Focus on testable, important concepts +- DIFFICULTY: Match appropriate learning level + +Card Types: +- Basic: For definitions, concepts, and facts +- Cloze: For fill-in-the-blank, especially with key terms +- Q&A: For explanations and "how/why" questions`, + userPromptTemplate: `[DOCUMENT CONTEXT] +Overall Topic: {{documentTopic}} +Current Section: {{sectionPath}} +Previous Content Summary: {{previousSummary}} + +[CONTENT TO PROCESS] +{{content}} + +[TASK] +Generate high-quality flashcards from this content. + +[REQUIREMENTS] +1. Focus on key concepts that deserve dedicated cards +2. Each card should test ONE clear concept +3. Questions should be specific and unambiguous +4. Answers should be accurate and complete +5. Include reasoning for each card + +[OUTPUT FORMAT] +{ + "cards": [ + { + "type": "basic|cloze|qa", + "front": "Clear, specific question", + "back": "Complete, accurate answer", + "rationale": "Why this card is valuable", + "difficulty": 1-5, + "prerequisites": ["concepts needed to understand this"], + "tags": ["relevant", "tags"], + "confidence": 0.0-1.0 + } + ], + "sectionSummary": "Key points from this section" +} + +Respond with ONLY valid JSON.`, + variables: ['documentTopic', 'sectionPath', 'previousSummary', 'content'] + }); + + // Pass 3: Card Validation + this.promptManager.registerTemplate({ + name: 'validate_cards', + description: 'Validate and improve generated cards', + systemPrompt: `You are a quality assurance expert for learning materials. +Review flashcards for clarity, accuracy, and learning effectiveness.`, + userPromptTemplate: `Review these flashcards and provide quality assessment: + +{{cards}} + +For each card, evaluate: +1. CLARITY: Is the question unambiguous? (0-1) +2. ACCURACY: Is the answer correct? (0-1) +3. COMPLETENESS: Is the answer sufficient? (0-1) +4. UNIQUENESS: Is it distinct from others? (0-1) +5. DIFFICULTY: Is the rating appropriate? (true/false) + +Also identify: +- Duplicate or very similar cards +- Cards with unclear questions +- Cards with incomplete answers +- Improvements needed + +Respond in JSON: +{ + "assessments": [ + { + "cardIndex": 0, + "clarity": 0.9, + "accuracy": 1.0, + "completeness": 0.8, + "uniqueness": 0.9, + "difficultyAppropriate": true, + "overallQuality": 0.9, + "issues": ["list of issues"], + "suggestions": ["list of improvements"] + } + ], + "duplicates": [[index1, index2]], + "recommendations": "Overall recommendations" +}`, + variables: ['cards'] + }); + } + + /** + * Generate cards with multi-pass approach + */ + async *generateCardsMultiPass( + content: string, + options?: { + maxCards?: number; + minQuality?: number; + } + ): AsyncGenerator { + const maxCards = options?.maxCards || 50; + const minQuality = options?.minQuality || 0.7; + + let totalCardsGenerated = 0; + + // PASS 1: Document Analysis + yield { + phase: 'planning', + cardsGenerated: 0, + message: 'Analyzing document structure...' + }; + + const plan = await this.analyzeDocument(content); + + // PASS 2: Chunking + yield { + phase: 'analyzing', + cardsGenerated: 0, + message: 'Breaking document into sections...' + }; + + const chunks = await this.chunker.chunkDocument(content, { + maxTokens: 1500, + minTokens: 200 + }); + + const sortedChunks = this.prioritizeChunks(chunks); + const overview = this.chunker.createOverview(chunks); + + // PASS 3: Generate cards for each chunk + let previousSummary = ''; + + for (let i = 0; i < sortedChunks.length && totalCardsGenerated < maxCards; i++) { + const chunk = sortedChunks[i]; + + yield { + phase: 'generating', + currentChunk: i + 1, + totalChunks: sortedChunks.length, + cardsGenerated: totalCardsGenerated, + message: `Generating cards for: ${chunk.metadata.heading}` + }; + + try { + const batch = await this.generateCardsForChunk( + chunk, + { + documentTopic: plan.overview, + previousSummary: previousSummary, + overview: overview + } + ); + + previousSummary = batch.summary; + totalCardsGenerated += batch.cards.length; + + yield batch; + + // Stop if we've generated enough cards + if (totalCardsGenerated >= maxCards) { + break; + } + + } catch (error) { + console.error(`Error generating cards for chunk ${chunk.id}:`, error); + // Continue with next chunk + } + } + + // PASS 4: Final validation (optional, can be done on-demand) + yield { + phase: 'completed', + cardsGenerated: totalCardsGenerated, + message: `Generated ${totalCardsGenerated} cards successfully!` + }; + } + + /** + * Pass 1: Analyze document and create plan + */ + private async analyzeDocument(content: string): Promise { + try { + // For very long documents, analyze a summary + const analysisContent = content.length > 10000 + ? this.createDocumentSummary(content) + : content; + + const messages = this.promptManager.renderPrompt('analyze_document', { + content: analysisContent + }); + + const response = await this.llmRouter.generate(messages); + const plan = this.parseAnalysisResponse(response.content); + + return plan; + + } catch (error) { + console.error('Error analyzing document:', error); + // Return default plan + return { + overview: 'Document analysis', + mainTopics: [], + sections: [], + totalEstimatedCards: 10 + }; + } + } + + /** + * Pass 2: Generate cards for a single chunk + */ + private async generateCardsForChunk( + chunk: DocumentChunk, + context: { + documentTopic: string; + previousSummary: string; + overview: string; + } + ): Promise { + const messages = this.promptManager.renderPrompt('generate_cards_enhanced', { + documentTopic: context.documentTopic, + sectionPath: chunk.metadata.heading, + previousSummary: context.previousSummary || 'This is the first section.', + content: chunk.content + }); + + const response = await this.llmRouter.generate(messages); + const result = this.parseCardResponse(response.content); + + return { + chunkId: chunk.id, + heading: chunk.metadata.heading, + cards: result.cards, + summary: result.sectionSummary || '', + quality: this.calculateBatchQuality(result.cards) + }; + } + + /** + * Prioritize chunks by importance + */ + private prioritizeChunks(chunks: DocumentChunk[]): DocumentChunk[] { + return chunks.sort((a, b) => { + // Sort by importance descending + return b.metadata.importance - a.metadata.importance; + }); + } + + /** + * Create document summary for analysis + */ + private createDocumentSummary(content: string): string { + const lines = content.split('\n'); + const headings: string[] = []; + const samples: string[] = []; + + for (const line of lines) { + if (line.match(/^#{1,6}\s+/)) { + headings.push(line); + } else if (samples.length < 10 && line.trim().length > 50) { + samples.push(line); + } + } + + return `${headings.join('\n')}\n\n${samples.join('\n')}`; + } + + /** + * Parse analysis response + */ + private parseAnalysisResponse(response: string): DocumentPlan { + try { + const jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/); + const jsonStr = jsonMatch ? jsonMatch[1] : response; + const parsed = JSON.parse(jsonStr); + + return { + overview: parsed.overview || 'Document', + mainTopics: parsed.mainTopics || [], + sections: parsed.sections || [], + totalEstimatedCards: parsed.sections?.reduce( + (sum: number, s: any) => sum + (s.estimatedCards || 3), + 0 + ) || 10 + }; + } catch (error) { + console.error('Error parsing analysis:', error); + return { + overview: 'Document', + mainTopics: [], + sections: [], + totalEstimatedCards: 10 + }; + } + } + + /** + * Parse card generation response + */ + private parseCardResponse(response: string): { + cards: GeneratedCard[]; + sectionSummary: string; + } { + try { + const jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/); + const jsonStr = jsonMatch ? jsonMatch[1] : response; + const parsed = JSON.parse(jsonStr); + + const cards: GeneratedCard[] = (parsed.cards || []).map((card: any) => ({ + type: card.type || 'basic', + front: card.front || '', + back: card.back || '', + tags: card.tags || [], + confidence: card.confidence || 0.8, + // Store additional metadata + ...card + })); + + return { + cards: cards, + sectionSummary: parsed.sectionSummary || '' + }; + + } catch (error) { + console.error('Error parsing card response:', error); + return { + cards: [], + sectionSummary: '' + }; + } + } + + /** + * Calculate quality score for a batch of cards + */ + private calculateBatchQuality(cards: GeneratedCard[]): number { + if (cards.length === 0) return 0; + + const avgConfidence = cards.reduce( + (sum, card) => sum + (card.confidence || 0.8), + 0 + ) / cards.length; + + // Penalize if too few or too many cards + const countPenalty = cards.length < 2 ? 0.8 : cards.length > 10 ? 0.9 : 1.0; + + return avgConfidence * countPenalty; + } + + /** + * Validate generated cards (Pass 3) + */ + async validateCards(cards: GeneratedCard[]): Promise<{ + scores: number[]; + issues: string[][]; + overall: number; + }> { + try { + const messages = this.promptManager.renderPrompt('validate_cards', { + cards: JSON.stringify(cards, null, 2) + }); + + const response = await this.llmRouter.generate(messages); + const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/); + const jsonStr = jsonMatch ? jsonMatch[1] : response.content; + const result = JSON.parse(jsonStr); + + const scores = (result.assessments || []).map((a: any) => a.overallQuality || 0.8); + const issues = (result.assessments || []).map((a: any) => a.issues || []); + const overall = scores.length > 0 + ? scores.reduce((sum: number, s: number) => sum + s, 0) / scores.length + : 0.8; + + return { scores, issues, overall }; + + } catch (error) { + console.error('Error validating cards:', error); + return { + scores: cards.map(() => 0.8), + issues: cards.map(() => []), + overall: 0.8 + }; + } + } +} diff --git a/src/llm/index.ts b/src/llm/index.ts new file mode 100644 index 00000000..31f2da2e --- /dev/null +++ b/src/llm/index.ts @@ -0,0 +1,105 @@ +/** + * LLM Module Exports + * Central export point for all LLM functionality + */ + +// Import for use in this file +import { LLMRouter as _LLMRouter } from './llm-router'; +import { PromptManager as _PromptManager } from './prompt-manager'; +import { SmartCardGenerator as _SmartCardGenerator } from './card-generator'; +import { MultiPassCardGenerator as _MultiPassCardGenerator } from './generation/multi-pass-generator'; +import { OpenAICompatibleProvider as _OpenAICompatibleProvider } from './providers/openai-compatible-provider'; + +// Core components +export { LLMRouter } from './llm-router'; +export { PromptManager } from './prompt-manager'; +export { ContentAnalyzer } from './content-analyzer'; +export { SmartCardGenerator } from './card-generator'; +export type { CardGenerationOptions } from './card-generator'; + +// Enhanced generation +export { MultiPassCardGenerator } from './generation/multi-pass-generator'; +export type { DocumentPlan, GenerationProgress, CardBatch } from './generation/multi-pass-generator'; +export { DocumentChunker } from './chunking/document-chunker'; +export type { DocumentChunk, ChunkingOptions } from './chunking/document-chunker'; + +// UI Components +export { CardPreviewModal } from './preview-modal'; +export { GenerationProgressModal } from './ui/progress-modal'; + +// Providers +export { OpenAICompatibleProvider } from './providers/openai-compatible-provider'; + +// Interfaces +export type { + ILLMProvider, + LLMConfig, + LLMMessage, + LLMResponse +} from './interfaces/llm-provider.interface'; + +export type { + PromptTemplate, + GeneratedCard +} from './interfaces/prompt-template.interface'; + +export type { + ContentSection, + AnalysisResult +} from './interfaces/content-section.interface'; + +// Error handling +export { LLMError, LLMErrorType } from './llm-error'; + +/** + * Create and initialize LLM system from settings + */ +export async function createLLMSystem( + llmSettings: any +): Promise<{ + router: _LLMRouter; + generator: _SmartCardGenerator; + multiPassGenerator: _MultiPassCardGenerator; +} | null> { + if (!llmSettings || !llmSettings.enabled) { + return null; + } + + const router = new _LLMRouter(); + const promptManager = new _PromptManager(); + + // Initialize providers from settings + if (llmSettings.providers && llmSettings.providers.length > 0) { + for (const providerConfig of llmSettings.providers) { + if (!providerConfig.enabled) continue; + + const provider = new _OpenAICompatibleProvider(); + await provider.initialize({ + provider: providerConfig.name, + endpoint: providerConfig.endpoint, + apiKey: providerConfig.apiKey, + model: providerConfig.model, + temperature: llmSettings.temperature, + maxTokens: llmSettings.maxTokens, + timeout: llmSettings.timeout + }); + + router.registerProvider(providerConfig.name, provider); + } + + // Set default provider + if (llmSettings.defaultProvider) { + router.setDefaultProvider(llmSettings.defaultProvider); + } + + // Set fallback chain + if (llmSettings.fallbackChain && llmSettings.fallbackChain.length > 0) { + router.setFallbackChain(llmSettings.fallbackChain); + } + } + + const generator = new _SmartCardGenerator(router, promptManager); + const multiPassGenerator = new _MultiPassCardGenerator(router, promptManager); + + return { router, generator, multiPassGenerator }; +} diff --git a/src/llm/interfaces/content-section.interface.ts b/src/llm/interfaces/content-section.interface.ts new file mode 100644 index 00000000..3001d27f --- /dev/null +++ b/src/llm/interfaces/content-section.interface.ts @@ -0,0 +1,23 @@ +/** + * Content Section Interface + * Defines structure for analyzed content sections + */ + +export interface ContentSection { + type: 'heading' | 'paragraph' | 'list' | 'code' | 'quote' | 'table'; + content: string; + level?: number; + cardPotential: number; // 0-1 score indicating suitability for flashcard + metadata?: { + lineStart?: number; + lineEnd?: number; + parentHeading?: string; + }; +} + +export interface AnalysisResult { + sections: ContentSection[]; + candidateSections: ContentSection[]; + totalSections: number; + highPotentialCount: number; +} diff --git a/src/llm/interfaces/llm-provider.interface.ts b/src/llm/interfaces/llm-provider.interface.ts new file mode 100644 index 00000000..e29180a6 --- /dev/null +++ b/src/llm/interfaces/llm-provider.interface.ts @@ -0,0 +1,37 @@ +/** + * LLM Provider Interface + * Base interface for all LLM providers + */ + +export interface LLMConfig { + provider: string; + apiKey?: string; + endpoint: string; + model: string; + temperature?: number; + maxTokens?: number; + timeout?: number; +} + +export interface LLMMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface LLMResponse { + content: string; + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; + model: string; + finishReason: string; +} + +export interface ILLMProvider { + initialize(config: LLMConfig): Promise; + generateCompletion(messages: LLMMessage[]): Promise; + isAvailable(): Promise; + getName(): string; +} diff --git a/src/llm/interfaces/prompt-template.interface.ts b/src/llm/interfaces/prompt-template.interface.ts new file mode 100644 index 00000000..753263a2 --- /dev/null +++ b/src/llm/interfaces/prompt-template.interface.ts @@ -0,0 +1,22 @@ +/** + * Prompt Template Interface + * Defines structure for prompt templates + */ + +import { LLMMessage } from './llm-provider.interface'; + +export interface PromptTemplate { + name: string; + description: string; + systemPrompt: string; + userPromptTemplate: string; + variables: string[]; +} + +export interface GeneratedCard { + type: string; + front: string; + back: string; + tags?: string[]; + confidence?: number; +} diff --git a/src/llm/llm-error.ts b/src/llm/llm-error.ts new file mode 100644 index 00000000..4a600fc9 --- /dev/null +++ b/src/llm/llm-error.ts @@ -0,0 +1,48 @@ +/** + * LLM Error Types and Error Class + * Handles various error scenarios in LLM operations + */ + +export enum LLMErrorType { + PROVIDER_UNAVAILABLE = 'provider_unavailable', + API_ERROR = 'api_error', + TIMEOUT = 'timeout', + PARSE_ERROR = 'parse_error', + RATE_LIMIT = 'rate_limit', + INVALID_CONFIG = 'invalid_config', + NETWORK_ERROR = 'network_error', + AUTHENTICATION_ERROR = 'authentication_error' +} + +export class LLMError extends Error { + type: LLMErrorType; + provider?: string; + retryable: boolean; + originalError?: Error; + + constructor( + message: string, + type: LLMErrorType, + retryable: boolean = false, + provider?: string, + originalError?: Error + ) { + super(message); + this.name = 'LLMError'; + this.type = type; + this.retryable = retryable; + this.provider = provider; + this.originalError = originalError; + } + + toString(): string { + let result = `${this.name} [${this.type}]: ${this.message}`; + if (this.provider) { + result += ` (Provider: ${this.provider})`; + } + if (this.retryable) { + result += ' [Retryable]'; + } + return result; + } +} diff --git a/src/llm/llm-router.ts b/src/llm/llm-router.ts new file mode 100644 index 00000000..998ddeda --- /dev/null +++ b/src/llm/llm-router.ts @@ -0,0 +1,244 @@ +/** + * LLM Router + * Routes requests to appropriate LLM providers with fallback support + */ + +import { ILLMProvider, LLMMessage, LLMResponse } from './interfaces/llm-provider.interface'; +import { LLMError, LLMErrorType } from './llm-error'; + +export class LLMRouter { + private providers: Map; + private defaultProvider: string | null; + private fallbackProviders: string[]; + private retryAttempts: number; + private retryDelay: number; + + constructor() { + this.providers = new Map(); + this.defaultProvider = null; + this.fallbackProviders = []; + this.retryAttempts = 3; + this.retryDelay = 1000; // 1 second base delay + } + + /** + * Register a new LLM provider + */ + registerProvider(name: string, provider: ILLMProvider): void { + this.providers.set(name, provider); + console.log(`LLM Provider registered: ${name}`); + } + + /** + * Set the default provider to use + */ + setDefaultProvider(name: string): void { + if (!this.providers.has(name)) { + throw new LLMError( + `Provider ${name} not registered`, + LLMErrorType.INVALID_CONFIG, + false + ); + } + this.defaultProvider = name; + } + + /** + * Set the fallback provider chain + */ + setFallbackChain(providers: string[]): void { + // Validate all providers are registered + for (const provider of providers) { + if (!this.providers.has(provider)) { + throw new LLMError( + `Provider ${provider} not registered`, + LLMErrorType.INVALID_CONFIG, + false + ); + } + } + this.fallbackProviders = providers; + } + + /** + * Set retry configuration + */ + setRetryConfig(attempts: number, delay: number): void { + this.retryAttempts = attempts; + this.retryDelay = delay; + } + + /** + * Generate completion using the provider chain + */ + async generate( + messages: LLMMessage[], + preferredProvider?: string + ): Promise { + const providerChain = this.buildProviderChain(preferredProvider); + + if (providerChain.length === 0) { + throw new LLMError( + 'No LLM providers available', + LLMErrorType.PROVIDER_UNAVAILABLE, + false + ); + } + + let lastError: LLMError | null = null; + + // Try each provider in the chain + for (const providerName of providerChain) { + const provider = this.providers.get(providerName); + if (!provider) { + continue; + } + + console.log(`Trying LLM provider: ${providerName}`); + + try { + // Check if provider is available + const isAvailable = await provider.isAvailable(); + if (!isAvailable) { + console.warn(`Provider ${providerName} is not available, skipping`); + lastError = new LLMError( + `Provider ${providerName} is not available`, + LLMErrorType.PROVIDER_UNAVAILABLE, + true, + providerName + ); + continue; + } + + // Try to generate with retries + const response = await this.generateWithRetry(provider, messages); + console.log(`Successfully generated completion with ${providerName}`); + return response; + + } catch (error) { + if (error instanceof LLMError) { + lastError = error; + console.error(`Provider ${providerName} failed:`, error.toString()); + + // If not retryable, skip to next provider + if (!error.retryable) { + continue; + } + + // For retryable errors, we already tried with retries, so skip to next + continue; + } else { + // Unexpected error + lastError = new LLMError( + `Unexpected error with provider ${providerName}: ${error.message}`, + LLMErrorType.API_ERROR, + false, + providerName, + error + ); + console.error(`Unexpected error with ${providerName}:`, error); + continue; + } + } + } + + // All providers failed + throw lastError || new LLMError( + 'All LLM providers failed', + LLMErrorType.PROVIDER_UNAVAILABLE, + false + ); + } + + /** + * Generate with retry logic for transient errors + */ + private async generateWithRetry( + provider: ILLMProvider, + messages: LLMMessage[] + ): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt < this.retryAttempts; attempt++) { + try { + return await provider.generateCompletion(messages); + } catch (error) { + lastError = error; + + if (error instanceof LLMError) { + // Don't retry non-retryable errors + if (!error.retryable) { + throw error; + } + + // For retryable errors, wait before retrying + if (attempt < this.retryAttempts - 1) { + const delay = this.retryDelay * Math.pow(2, attempt); // Exponential backoff + console.log(`Retrying in ${delay}ms (attempt ${attempt + 1}/${this.retryAttempts})...`); + await this.sleep(delay); + } + } else { + // Unexpected errors are not retried + throw error; + } + } + } + + // All retries exhausted + throw lastError; + } + + /** + * Build the provider chain based on preferences + */ + private buildProviderChain(preferredProvider?: string): string[] { + const chain: string[] = []; + + // Add preferred provider first if specified + if (preferredProvider && this.providers.has(preferredProvider)) { + chain.push(preferredProvider); + } + + // Add default provider if not already in chain + if (this.defaultProvider && !chain.includes(this.defaultProvider)) { + chain.push(this.defaultProvider); + } + + // Add fallback providers if not already in chain + for (const fallback of this.fallbackProviders) { + if (!chain.includes(fallback)) { + chain.push(fallback); + } + } + + return chain; + } + + /** + * Get list of registered providers + */ + getRegisteredProviders(): string[] { + return Array.from(this.providers.keys()); + } + + /** + * Check if a provider is registered + */ + hasProvider(name: string): boolean { + return this.providers.has(name); + } + + /** + * Get provider instance + */ + getProvider(name: string): ILLMProvider | undefined { + return this.providers.get(name); + } + + /** + * Sleep helper for retry delays + */ + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/src/llm/preview-modal.ts b/src/llm/preview-modal.ts new file mode 100644 index 00000000..1f7180ee --- /dev/null +++ b/src/llm/preview-modal.ts @@ -0,0 +1,188 @@ +/** + * Card Preview Modal + * Shows generated cards for user review and approval + */ + +import { App, Modal, Setting, TextAreaComponent, Notice } from 'obsidian'; +import { GeneratedCard } from './interfaces/prompt-template.interface'; + +export class CardPreviewModal extends Modal { + private cards: GeneratedCard[]; + private onApprove: (approvedCards: GeneratedCard[]) => void; + private approvedCards: Set; + private editedCards: Map; + + constructor( + app: App, + cards: GeneratedCard[], + onApprove: (approvedCards: GeneratedCard[]) => void + ) { + super(app); + this.cards = cards; + this.onApprove = onApprove; + this.approvedCards = new Set(cards.map((_, i) => i)); // All approved by default + this.editedCards = new Map(); + } + + onOpen() { + const { contentEl } = this; + + contentEl.empty(); + contentEl.addClass('llm-card-preview-modal'); + + // Header + contentEl.createEl('h2', { text: `Review Generated Cards (${this.cards.length})` }); + contentEl.createEl('p', { + text: 'Review and edit the AI-generated cards before adding them to Anki.', + cls: 'mod-muted' + }); + + // Stats + const statsEl = contentEl.createDiv({ cls: 'llm-stats' }); + this.updateStats(statsEl); + + // Cards container + const cardsContainer = contentEl.createDiv({ cls: 'llm-cards-container' }); + + this.cards.forEach((card, index) => { + this.renderCard(cardsContainer, card, index); + }); + + // Buttons + const buttonsEl = contentEl.createDiv({ cls: 'llm-buttons' }); + + buttonsEl.createEl('button', { + text: 'Select All', + cls: 'mod-cta' + }).addEventListener('click', () => { + this.approvedCards = new Set(this.cards.map((_, i) => i)); + this.refresh(); + }); + + buttonsEl.createEl('button', { + text: 'Deselect All' + }).addEventListener('click', () => { + this.approvedCards.clear(); + this.refresh(); + }); + + buttonsEl.createEl('button', { + text: `Add Selected Cards (${this.approvedCards.size})`, + cls: 'mod-cta' + }).addEventListener('click', () => { + this.approveCards(); + }); + + buttonsEl.createEl('button', { + text: 'Cancel' + }).addEventListener('click', () => { + this.close(); + }); + } + + private renderCard(container: HTMLElement, card: GeneratedCard, index: number) { + const cardEl = container.createDiv({ cls: 'llm-card' }); + + if (!this.approvedCards.has(index)) { + cardEl.addClass('llm-card-disabled'); + } + + // Card header with checkbox + const headerEl = cardEl.createDiv({ cls: 'llm-card-header' }); + + const checkboxContainer = headerEl.createDiv({ cls: 'llm-card-checkbox' }); + const checkbox = checkboxContainer.createEl('input', { type: 'checkbox' }); + checkbox.checked = this.approvedCards.has(index); + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + this.approvedCards.add(index); + } else { + this.approvedCards.delete(index); + } + this.refresh(); + }); + + const titleEl = headerEl.createDiv({ cls: 'llm-card-title' }); + titleEl.createSpan({ text: `Card ${index + 1} ` }); + titleEl.createSpan({ text: `[${card.type}]`, cls: 'llm-card-type' }); + + if (card.confidence !== undefined) { + titleEl.createSpan({ + text: ` ${Math.round(card.confidence * 100)}%`, + cls: 'llm-card-confidence' + }); + } + + // Card content + const contentEl = cardEl.createDiv({ cls: 'llm-card-content' }); + + // Front/Question + const frontContainer = contentEl.createDiv({ cls: 'llm-card-field' }); + frontContainer.createEl('strong', { text: 'Front:' }); + const frontInput = frontContainer.createEl('textarea', { + cls: 'llm-card-textarea' + }); + frontInput.value = card.front || ''; + frontInput.rows = 3; + frontInput.addEventListener('input', () => { + const editedCard = this.editedCards.get(index) || { ...card }; + editedCard.front = frontInput.value; + this.editedCards.set(index, editedCard); + }); + + // Back/Answer + const backContainer = contentEl.createDiv({ cls: 'llm-card-field' }); + backContainer.createEl('strong', { text: 'Back:' }); + const backInput = backContainer.createEl('textarea', { + cls: 'llm-card-textarea' + }); + backInput.value = card.back || ''; + backInput.rows = 5; + backInput.addEventListener('input', () => { + const editedCard = this.editedCards.get(index) || { ...card }; + editedCard.back = backInput.value; + this.editedCards.set(index, editedCard); + }); + + // Tags + if (card.tags && card.tags.length > 0) { + const tagsContainer = contentEl.createDiv({ cls: 'llm-card-tags' }); + tagsContainer.createEl('strong', { text: 'Tags: ' }); + tagsContainer.createSpan({ text: card.tags.join(', ') }); + } + } + + private updateStats(statsEl: HTMLElement) { + statsEl.empty(); + statsEl.createSpan({ + text: `Selected: ${this.approvedCards.size} / ${this.cards.length}`, + cls: 'llm-stat' + }); + } + + private refresh() { + this.onOpen(); + } + + private approveCards() { + const approved: GeneratedCard[] = []; + + for (const index of this.approvedCards) { + const card = this.editedCards.get(index) || this.cards[index]; + approved.push(card); + } + + if (approved.length === 0) { + new Notice('No cards selected!'); + return; + } + + this.onApprove(approved); + this.close(); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/llm/prompt-manager.ts b/src/llm/prompt-manager.ts new file mode 100644 index 00000000..b4f97639 --- /dev/null +++ b/src/llm/prompt-manager.ts @@ -0,0 +1,250 @@ +/** + * Prompt Manager + * Manages prompt templates and renders them with variables + */ + +import { PromptTemplate } from './interfaces/prompt-template.interface'; +import { LLMMessage } from './interfaces/llm-provider.interface'; + +export class PromptManager { + private templates: Map; + + constructor() { + this.templates = new Map(); + this.loadDefaultTemplates(); + } + + /** + * Load default prompt templates + */ + private loadDefaultTemplates(): void { + // Card generation prompt + this.registerTemplate({ + name: 'generate_cards', + description: 'Generate flashcards from markdown content', + systemPrompt: `You are a helpful assistant that creates high-quality flashcards from markdown content. +Your task is to analyze the given content and generate flashcards that help with learning and retention. + +Guidelines: +- Create clear, concise questions +- Provide accurate and complete answers +- Use appropriate card types (Basic, Cloze, Q&A) +- Focus on key concepts and important information +- Avoid overly complex or ambiguous questions +- Generate flashcards in the user's language (match the content language)`, + userPromptTemplate: `Please analyze the following markdown content and generate flashcards: + +Content: +\`\`\`markdown +{{content}} +\`\`\` + +Generate flashcards in the following JSON format (respond ONLY with valid JSON): +\`\`\`json +[ + { + "type": "basic", + "front": "Question or prompt", + "back": "Answer or explanation", + "tags": ["tag1", "tag2"] + } +] +\`\`\` + +Important: Return ONLY the JSON array, no additional text or explanation.`, + variables: ['content'] + }); + + // Answer generation prompt + this.registerTemplate({ + name: 'generate_answer', + description: 'Generate answer for a given question', + systemPrompt: `You are a knowledgeable tutor that provides clear, accurate answers to questions. +Your answers should be: +- Accurate and factually correct +- Clear and easy to understand +- Appropriately detailed based on context +- Well-structured with examples when helpful +- In the same language as the question`, + userPromptTemplate: `Question: {{question}} + +Context (if available): +{{context}} + +Please provide a comprehensive answer to the question above.`, + variables: ['question', 'context'] + }); + + // Card improvement prompt + this.registerTemplate({ + name: 'improve_card', + description: 'Improve existing flashcard', + systemPrompt: `You are an expert in creating effective flashcards for learning. +Analyze the given flashcard and suggest improvements for: +- Clarity and conciseness +- Accuracy +- Learning effectiveness +- Better formatting + +Maintain the same language as the original card.`, + userPromptTemplate: `Current flashcard: +Front: {{front}} +Back: {{back}} + +Please provide an improved version in JSON format: +\`\`\`json +{ + "front": "Improved question or prompt", + "back": "Improved answer or explanation", + "improvements": "Brief explanation of changes made" +} +\`\`\` + +Return ONLY the JSON, no additional text.`, + variables: ['front', 'back'] + }); + + // Cloze generation prompt + this.registerTemplate({ + name: 'generate_cloze', + description: 'Generate cloze deletion cards from content', + systemPrompt: `You are an expert at creating effective cloze deletion flashcards. +Your task is to identify key terms and concepts that should be turned into cloze deletions.`, + userPromptTemplate: `Content: {{content}} + +Generate cloze deletion cards in JSON format: +\`\`\`json +[ + { + "type": "cloze", + "text": "The {{c1::key term}} is important for {{c2::specific reason}}.", + "tags": ["tag1"] + } +] +\`\`\` + +Return ONLY the JSON array.`, + variables: ['content'] + }); + + // Q&A style prompt + this.registerTemplate({ + name: 'generate_qa', + description: 'Generate question-answer pairs from content', + systemPrompt: `You are an expert at creating clear question and answer pairs for studying. +Focus on important concepts and create questions that test understanding, not just memorization.`, + userPromptTemplate: `Content: {{content}} + +Generate question-answer pairs in JSON format: +\`\`\`json +[ + { + "type": "qa", + "front": "Question?", + "back": "Answer with explanation", + "tags": ["tag1"] + } +] +\`\`\` + +Return ONLY the JSON array.`, + variables: ['content'] + }); + } + + /** + * Register a new prompt template + */ + registerTemplate(template: PromptTemplate): void { + this.templates.set(template.name, template); + } + + /** + * Get a prompt template by name + */ + getTemplate(name: string): PromptTemplate | undefined { + return this.templates.get(name); + } + + /** + * Get all template names + */ + getTemplateNames(): string[] { + return Array.from(this.templates.keys()); + } + + /** + * Render a prompt template with variables + */ + renderPrompt( + templateName: string, + variables: Record + ): LLMMessage[] { + const template = this.templates.get(templateName); + if (!template) { + throw new Error(`Template ${templateName} not found`); + } + + // Validate all required variables are provided + for (const varName of template.variables) { + if (!(varName in variables)) { + throw new Error(`Missing required variable: ${varName}`); + } + } + + // Render user prompt by replacing variables + let userPrompt = template.userPromptTemplate; + for (const [key, value] of Object.entries(variables)) { + const regex = new RegExp(`{{${key}}}`, 'g'); + userPrompt = userPrompt.replace(regex, value || ''); + } + + return [ + { role: 'system', content: template.systemPrompt }, + { role: 'user', content: userPrompt } + ]; + } + + /** + * Update an existing template + */ + updateTemplate(name: string, updates: Partial): boolean { + const template = this.templates.get(name); + if (!template) { + return false; + } + + const updated = { ...template, ...updates }; + this.templates.set(name, updated); + return true; + } + + /** + * Delete a template + */ + deleteTemplate(name: string): boolean { + return this.templates.delete(name); + } + + /** + * Export templates as JSON + */ + exportTemplates(): string { + const templatesArray = Array.from(this.templates.values()); + return JSON.stringify(templatesArray, null, 2); + } + + /** + * Import templates from JSON + */ + importTemplates(json: string): void { + try { + const templatesArray: PromptTemplate[] = JSON.parse(json); + for (const template of templatesArray) { + this.registerTemplate(template); + } + } catch (error) { + throw new Error(`Failed to import templates: ${error.message}`); + } + } +} diff --git a/src/llm/providers/openai-compatible-provider.ts b/src/llm/providers/openai-compatible-provider.ts new file mode 100644 index 00000000..59230950 --- /dev/null +++ b/src/llm/providers/openai-compatible-provider.ts @@ -0,0 +1,217 @@ +/** + * OpenAI Compatible Provider + * Supports any OpenAI-compatible API (Ollama, LM Studio, OpenRouter, OpenAI, etc.) + */ + +import { ILLMProvider, LLMConfig, LLMMessage, LLMResponse } from '../interfaces/llm-provider.interface'; +import { LLMError, LLMErrorType } from '../llm-error'; +import { requestUrl, RequestUrlParam } from 'obsidian'; + +export class OpenAICompatibleProvider implements ILLMProvider { + private config: LLMConfig; + + async initialize(config: LLMConfig): Promise { + this.config = config; + + // Validate required config + if (!config.endpoint) { + throw new LLMError( + 'Endpoint is required', + LLMErrorType.INVALID_CONFIG, + false, + config.provider + ); + } + + if (!config.model) { + throw new LLMError( + 'Model is required', + LLMErrorType.INVALID_CONFIG, + false, + config.provider + ); + } + + // Test availability + const available = await this.isAvailable(); + if (!available) { + console.warn(`Provider ${config.provider} may not be available`); + } + } + + async generateCompletion(messages: LLMMessage[]): Promise { + if (!this.config) { + throw new LLMError( + 'Provider not initialized', + LLMErrorType.INVALID_CONFIG, + false + ); + } + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add API key if provided + if (this.config.apiKey) { + headers['Authorization'] = `Bearer ${this.config.apiKey}`; + } + + const payload = { + model: this.config.model, + messages: messages, + temperature: this.config.temperature ?? 0.7, + max_tokens: this.config.maxTokens ?? 2000, + }; + + try { + const requestParam: RequestUrlParam = { + url: this.config.endpoint, + method: 'POST', + headers: headers, + body: JSON.stringify(payload), + throw: false, + }; + + const response = await requestUrl(requestParam); + + if (response.status !== 200) { + // Handle specific error codes + if (response.status === 401 || response.status === 403) { + throw new LLMError( + `Authentication failed: ${response.status}`, + LLMErrorType.AUTHENTICATION_ERROR, + false, + this.config.provider + ); + } else if (response.status === 429) { + throw new LLMError( + 'Rate limit exceeded', + LLMErrorType.RATE_LIMIT, + true, + this.config.provider + ); + } else if (response.status >= 500) { + throw new LLMError( + `Server error: ${response.status}`, + LLMErrorType.API_ERROR, + true, + this.config.provider + ); + } else { + throw new LLMError( + `API error: ${response.status} - ${response.text}`, + LLMErrorType.API_ERROR, + false, + this.config.provider + ); + } + } + + const data = response.json; + return this.parseResponse(data); + + } catch (error) { + if (error instanceof LLMError) { + throw error; + } + + // Network or other errors + throw new LLMError( + `Failed to generate completion: ${error.message}`, + LLMErrorType.NETWORK_ERROR, + true, + this.config.provider, + error + ); + } + } + + async isAvailable(): Promise { + if (!this.config || !this.config.endpoint) { + return false; + } + + try { + // Try to reach the endpoint + const testEndpoint = this.config.endpoint.replace('/chat/completions', '/models'); + + const headers: Record = {}; + if (this.config.apiKey) { + headers['Authorization'] = `Bearer ${this.config.apiKey}`; + } + + const requestParam: RequestUrlParam = { + url: testEndpoint, + method: 'GET', + headers: headers, + throw: false, + }; + + const response = await requestUrl(requestParam); + + // Accept 200, 404 (some APIs don't support /models), or 405 (method not allowed) + return response.status === 200 || response.status === 404 || response.status === 405; + + } catch (error) { + // If we can't connect, try the main endpoint with a minimal request + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (this.config.apiKey) { + headers['Authorization'] = `Bearer ${this.config.apiKey}`; + } + + const requestParam: RequestUrlParam = { + url: this.config.endpoint, + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: this.config.model, + messages: [{ role: 'user', content: 'test' }], + max_tokens: 1, + }), + throw: false, + }; + + const response = await requestUrl(requestParam); + return response.status !== 404; + + } catch (finalError) { + return false; + } + } + } + + getName(): string { + return this.config?.provider || 'openai-compatible'; + } + + private parseResponse(data: any): LLMResponse { + try { + if (!data.choices || !data.choices[0] || !data.choices[0].message) { + throw new Error('Invalid response format: missing choices or message'); + } + + return { + content: data.choices[0].message.content, + usage: { + promptTokens: data.usage?.prompt_tokens || 0, + completionTokens: data.usage?.completion_tokens || 0, + totalTokens: data.usage?.total_tokens || 0, + }, + model: data.model || this.config.model, + finishReason: data.choices[0].finish_reason || 'stop', + }; + + } catch (error) { + throw new LLMError( + `Failed to parse response: ${error.message}`, + LLMErrorType.PARSE_ERROR, + false, + this.config.provider + ); + } + } +} diff --git a/src/llm/ui/progress-modal.ts b/src/llm/ui/progress-modal.ts new file mode 100644 index 00000000..4e921b30 --- /dev/null +++ b/src/llm/ui/progress-modal.ts @@ -0,0 +1,301 @@ +/** + * Generation Progress Modal + * Shows real-time progress for multi-pass card generation + */ + +import { App, Modal, Notice } from 'obsidian'; +import { GenerationProgress, CardBatch } from '../generation/multi-pass-generator'; +import { GeneratedCard } from '../interfaces/prompt-template.interface'; + +export class GenerationProgressModal extends Modal { + private progressTitleEl: HTMLElement; + private phaseEl: HTMLElement; + private progressBarEl: HTMLElement; + private progressFillEl: HTMLElement; + private statsEl: HTMLElement; + private messageEl: HTMLElement; + private cardsListEl: HTMLElement; + private cancelButton: HTMLButtonElement; + private doneButton: HTMLButtonElement; + + private cancelled: boolean = false; + private completed: boolean = false; + private allCards: GeneratedCard[] = []; + private totalChunks: number = 0; + private currentChunk: number = 0; + + private onComplete?: (cards: GeneratedCard[]) => void; + private onCancel?: () => void; + + constructor( + app: App, + options?: { + onComplete?: (cards: GeneratedCard[]) => void; + onCancel?: () => void; + } + ) { + super(app); + this.onComplete = options?.onComplete; + this.onCancel = options?.onCancel; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('llm-progress-modal'); + + // Title + this.progressTitleEl = contentEl.createEl('h2', { + text: 'Generating Flashcards with AI', + cls: 'llm-progress-title' + }); + + // Phase indicator + this.phaseEl = contentEl.createEl('div', { + cls: 'llm-progress-phase', + text: 'Initializing...' + }); + + // Progress bar + const progressContainer = contentEl.createDiv({ cls: 'llm-progress-bar-container' }); + this.progressBarEl = progressContainer.createDiv({ cls: 'llm-progress-bar' }); + this.progressFillEl = this.progressBarEl.createDiv({ cls: 'llm-progress-bar-fill' }); + this.progressFillEl.style.width = '0%'; + + // Stats + this.statsEl = contentEl.createDiv({ cls: 'llm-progress-stats' }); + this.updateStats(); + + // Message + this.messageEl = contentEl.createEl('div', { + cls: 'llm-progress-message', + text: 'Starting generation...' + }); + + // Cards preview list + const cardsSection = contentEl.createDiv({ cls: 'llm-progress-cards-section' }); + cardsSection.createEl('h3', { text: 'Generated Cards' }); + this.cardsListEl = cardsSection.createDiv({ cls: 'llm-progress-cards-list' }); + + // Buttons + const buttonsEl = contentEl.createDiv({ cls: 'llm-progress-buttons' }); + + this.cancelButton = buttonsEl.createEl('button', { + text: 'Cancel', + cls: 'mod-warning' + }); + this.cancelButton.addEventListener('click', () => { + this.handleCancel(); + }); + + this.doneButton = buttonsEl.createEl('button', { + text: 'Continue with Cards', + cls: 'mod-cta' + }); + this.doneButton.style.display = 'none'; + this.doneButton.addEventListener('click', () => { + this.handleComplete(); + }); + } + + /** + * Update progress from generator + */ + updateProgress(progress: GenerationProgress) { + // Update phase + const phaseEmoji = this.getPhaseEmoji(progress.phase); + this.phaseEl.setText(`${phaseEmoji} ${this.getPhaseText(progress.phase)}`); + + // Update progress bar + if (progress.totalChunks && progress.currentChunk) { + this.totalChunks = progress.totalChunks; + this.currentChunk = progress.currentChunk; + const percent = (progress.currentChunk / progress.totalChunks) * 100; + this.progressFillEl.style.width = `${percent}%`; + } + + // Update message + this.messageEl.setText(progress.message); + + // Update stats + this.updateStats(progress.cardsGenerated); + + // Check if completed + if (progress.phase === 'completed') { + this.handleGenerationComplete(); + } + } + + /** + * Add a batch of generated cards + */ + addCardBatch(batch: CardBatch) { + this.allCards.push(...batch.cards); + + // Add to preview list + const batchEl = this.cardsListEl.createDiv({ cls: 'llm-progress-card-batch' }); + + const headerEl = batchEl.createDiv({ cls: 'llm-progress-batch-header' }); + headerEl.createEl('strong', { text: batch.heading }); + headerEl.createSpan({ + text: ` (${batch.cards.length} cards)`, + cls: 'llm-progress-batch-count' + }); + + if (batch.quality !== undefined) { + const qualityPercent = Math.round(batch.quality * 100); + const qualityClass = qualityPercent >= 80 ? 'quality-high' : + qualityPercent >= 60 ? 'quality-medium' : 'quality-low'; + headerEl.createSpan({ + text: ` ${qualityPercent}%`, + cls: `llm-progress-batch-quality ${qualityClass}` + }); + } + + // Show first card as preview + if (batch.cards.length > 0) { + const previewEl = batchEl.createDiv({ cls: 'llm-progress-card-preview' }); + const card = batch.cards[0]; + previewEl.createDiv({ + text: `Q: ${card.front}`, + cls: 'llm-progress-card-front' + }); + previewEl.createDiv({ + text: `A: ${card.back.substring(0, 100)}${card.back.length > 100 ? '...' : ''}`, + cls: 'llm-progress-card-back' + }); + + if (batch.cards.length > 1) { + previewEl.createDiv({ + text: `+ ${batch.cards.length - 1} more card${batch.cards.length > 2 ? 's' : ''}`, + cls: 'llm-progress-card-more' + }); + } + } + + // Scroll to bottom to show new cards + this.cardsListEl.scrollTop = this.cardsListEl.scrollHeight; + } + + /** + * Update statistics display + */ + private updateStats(cardsGenerated?: number) { + this.statsEl.empty(); + + const cards = cardsGenerated !== undefined ? cardsGenerated : this.allCards.length; + + this.statsEl.createSpan({ + text: `Cards Generated: ${cards}`, + cls: 'llm-progress-stat' + }); + + if (this.totalChunks > 0) { + this.statsEl.createSpan({ + text: ` | Sections: ${this.currentChunk} / ${this.totalChunks}`, + cls: 'llm-progress-stat' + }); + } + + if (this.allCards.length > 0) { + const avgConfidence = this.allCards.reduce((sum, c) => sum + (c.confidence || 0.8), 0) / this.allCards.length; + this.statsEl.createSpan({ + text: ` | Avg Confidence: ${Math.round(avgConfidence * 100)}%`, + cls: 'llm-progress-stat' + }); + } + } + + /** + * Handle generation completion + */ + private handleGenerationComplete() { + this.completed = true; + this.cancelButton.style.display = 'none'; + this.doneButton.style.display = 'inline-block'; + this.progressFillEl.style.width = '100%'; + + new Notice(`Generated ${this.allCards.length} cards successfully!`); + } + + /** + * Handle cancel button + */ + private handleCancel() { + this.cancelled = true; + if (this.onCancel) { + this.onCancel(); + } + + if (this.allCards.length > 0) { + // Ask if user wants to keep partial results + const keep = confirm( + `Generation cancelled. Keep the ${this.allCards.length} cards generated so far?` + ); + if (keep) { + this.handleComplete(); + } else { + this.close(); + } + } else { + this.close(); + } + } + + /** + * Handle completion + */ + private handleComplete() { + if (this.onComplete && this.allCards.length > 0) { + this.onComplete(this.allCards); + } + this.close(); + } + + /** + * Get emoji for phase + */ + private getPhaseEmoji(phase: string): string { + switch (phase) { + case 'planning': return '📋'; + case 'analyzing': return '🔍'; + case 'generating': return '✨'; + case 'validating': return '✅'; + case 'completed': return '🎉'; + default: return '⚙️'; + } + } + + /** + * Get text for phase + */ + private getPhaseText(phase: string): string { + switch (phase) { + case 'planning': return 'Planning Generation'; + case 'analyzing': return 'Analyzing Document'; + case 'generating': return 'Generating Cards'; + case 'validating': return 'Validating Quality'; + case 'completed': return 'Generation Complete'; + default: return 'Processing'; + } + } + + /** + * Check if generation was cancelled + */ + isCancelled(): boolean { + return this.cancelled; + } + + /** + * Get all generated cards + */ + getGeneratedCards(): GeneratedCard[] { + return this.allCards; + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/settings.ts b/src/settings.ts index 08f42fe2..3092fc51 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -431,6 +431,117 @@ export class SettingsTab extends PluginSettingTab { }); } + setup_llm() { + const plugin = (this as any).plugin + let {containerEl} = this + + containerEl.createEl('h2', {text: 'LLM (AI) Settings'}) + containerEl.createEl('p', {text: 'Configure AI-powered flashcard generation'}) + + // Enable LLM toggle + new Setting(containerEl) + .setName('Enable LLM Features') + .setDesc('Enable AI-powered card generation and answer generation') + .addToggle(toggle => toggle + .setValue(plugin.settings.LLM?.enabled || false) + .onChange(async (value) => { + if (!plugin.settings.LLM) { + plugin.settings.LLM = { + enabled: value, + providers: [], + defaultProvider: '', + fallbackChain: [], + autoGenerate: false, + autoGenerateAnswers: false, + showPreview: true, + batchSize: 10, + temperature: 0.7, + maxTokens: 2000, + timeout: 60 + }; + } else { + plugin.settings.LLM.enabled = value; + } + await plugin.saveAllData(); + if (value) { + await plugin.initializeLLM(); + } + this.display(); + }) + ); + + if (plugin.settings.LLM?.enabled) { + // Show LLM configuration section + containerEl.createEl('h3', {text: 'Quick Start'}); + containerEl.createEl('p', {text: 'For detailed setup instructions, see LLM_GUIDE.md in the repository'}); + + // Temperature + new Setting(containerEl) + .setName('Temperature') + .setDesc('Creativity level (0-1). Lower = more focused, Higher = more creative') + .addSlider(slider => slider + .setLimits(0, 1, 0.1) + .setValue(plugin.settings.LLM?.temperature || 0.7) + .setDynamicTooltip() + .onChange(async (value) => { + if (plugin.settings.LLM) { + plugin.settings.LLM.temperature = value; + await plugin.saveAllData(); + } + }) + ); + + // Max Tokens + new Setting(containerEl) + .setName('Max Tokens') + .setDesc('Maximum length of AI response') + .addText(text => text + .setValue(String(plugin.settings.LLM?.maxTokens || 2000)) + .onChange(async (value) => { + const num = parseInt(value); + if (!isNaN(num) && num > 0 && plugin.settings.LLM) { + plugin.settings.LLM.maxTokens = num; + await plugin.saveAllData(); + } + }) + ); + + // Batch Size + new Setting(containerEl) + .setName('Batch Size') + .setDesc('Number of cards to generate at once') + .addText(text => text + .setValue(String(plugin.settings.LLM?.batchSize || 10)) + .onChange(async (value) => { + const num = parseInt(value); + if (!isNaN(num) && num > 0 && plugin.settings.LLM) { + plugin.settings.LLM.batchSize = num; + await plugin.saveAllData(); + } + }) + ); + + // Show Preview + new Setting(containerEl) + .setName('Show Preview') + .setDesc('Show preview before adding AI-generated cards (Coming soon)') + .addToggle(toggle => toggle + .setValue(plugin.settings.LLM?.showPreview || true) + .onChange(async (value) => { + if (plugin.settings.LLM) { + plugin.settings.LLM.showPreview = value; + await plugin.saveAllData(); + } + }) + ); + + containerEl.createEl('p', { + text: 'Note: To configure LLM providers (Ollama, OpenRouter, etc.), you need to manually edit the plugin settings JSON or use the full settings UI (coming soon). See LLM_GUIDE.md for instructions.', + cls: 'mod-warning' + }); + } + } + setup_display() { let {containerEl} = this @@ -443,6 +554,7 @@ export class SettingsTab extends PluginSettingTab { this.setup_defaults() this.setup_buttons() this.setup_ignore_files() + this.setup_llm() } async display() { diff --git a/styles.css b/styles.css index be4cf6d1..f217bbf4 100644 --- a/styles.css +++ b/styles.css @@ -17,3 +17,258 @@ .anki-rotated { transform: rotate(-90deg); } + +/* LLM Card Preview Modal */ +.llm-card-preview-modal { + width: 80vw; + max-width: 900px; +} + +.llm-stats { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + padding: 0.5rem; + background: var(--background-secondary); + border-radius: 4px; +} + +.llm-stat { + font-weight: 500; +} + +.llm-cards-container { + max-height: 60vh; + overflow-y: auto; + margin-bottom: 1rem; + padding: 0.5rem; +} + +.llm-card { + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + background: var(--background-primary); + transition: all 0.2s; +} + +.llm-card:hover { + border-color: var(--interactive-accent); +} + +.llm-card-disabled { + opacity: 0.5; +} + +.llm-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.llm-card-checkbox { + flex-shrink: 0; +} + +.llm-card-title { + flex-grow: 1; + font-weight: 600; +} + +.llm-card-type { + color: var(--text-accent); + font-size: 0.9em; +} + +.llm-card-confidence { + color: var(--text-muted); + font-size: 0.9em; +} + +.llm-card-content { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.llm-card-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.llm-card-textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-secondary); + color: var(--text-normal); + font-family: var(--font-monospace); + font-size: 0.9em; + resize: vertical; +} + +.llm-card-tags { + font-size: 0.9em; + color: var(--text-muted); +} + +.llm-buttons { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + padding-top: 1rem; + border-top: 1px solid var(--background-modifier-border); +} + +/* LLM Progress Modal */ +.llm-progress-modal { + width: 70vw; + max-width: 800px; +} + +.llm-progress-title { + margin-bottom: 1rem; +} + +.llm-progress-phase { + font-size: 1.1em; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text-accent); +} + +.llm-progress-bar-container { + margin-bottom: 1rem; +} + +.llm-progress-bar { + width: 100%; + height: 24px; + background: var(--background-secondary); + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--background-modifier-border); +} + +.llm-progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--interactive-accent), var(--interactive-accent-hover)); + transition: width 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 0.9em; +} + +.llm-progress-stats { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + padding: 0.5rem; + background: var(--background-secondary); + border-radius: 4px; + font-size: 0.95em; +} + +.llm-progress-stat { + font-weight: 500; +} + +.llm-progress-message { + margin-bottom: 1.5rem; + padding: 0.5rem; + color: var(--text-muted); + font-style: italic; +} + +.llm-progress-cards-section { + margin-bottom: 1rem; +} + +.llm-progress-cards-list { + max-height: 40vh; + overflow-y: auto; + padding: 0.5rem; + background: var(--background-secondary); + border-radius: 4px; + border: 1px solid var(--background-modifier-border); +} + +.llm-progress-card-batch { + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--background-primary); + border-radius: 4px; + border: 1px solid var(--background-modifier-border); +} + +.llm-progress-batch-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.llm-progress-batch-count { + color: var(--text-muted); + font-size: 0.9em; +} + +.llm-progress-batch-quality { + font-weight: 600; + font-size: 0.9em; + padding: 0.1rem 0.4rem; + border-radius: 3px; +} + +.llm-progress-batch-quality.quality-high { + color: var(--text-success); + background: var(--background-modifier-success); +} + +.llm-progress-batch-quality.quality-medium { + color: var(--text-warning); + background: var(--background-modifier-warning); +} + +.llm-progress-batch-quality.quality-low { + color: var(--text-error); + background: var(--background-modifier-error); +} + +.llm-progress-card-preview { + padding: 0.5rem; + background: var(--background-secondary); + border-radius: 4px; + font-size: 0.9em; +} + +.llm-progress-card-front { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.llm-progress-card-back { + color: var(--text-muted); + margin-bottom: 0.25rem; +} + +.llm-progress-card-more { + color: var(--text-muted); + font-size: 0.85em; + font-style: italic; +} + +.llm-progress-buttons { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + padding-top: 1rem; + border-top: 1px solid var(--background-modifier-border); +}