From 8ebee35c75e18416c7ffae35d73efc04bd61cd76 Mon Sep 17 00:00:00 2001 From: Alastair Grant <85125580+aldo-g@users.noreply.github.com> Date: Wed, 12 Feb 2025 11:39:45 +0100 Subject: [PATCH] openai call noe possible --- .gitignore | 3 + TestView.ts | 81 ++++++++++++++ main.ts | 229 +++++++++++++++++++++++++--------------- manifest.json | 10 +- package-lock.json | 15 +++ package.json | 4 + src/formatter.ts | 21 ++++ src/indexer.ts | 41 +++++++ src/llm.ts | 62 +++++++++++ src/types.ts | 60 +++++++++++ test_index_formatter.ts | 84 +++++++++++++++ 11 files changed, 518 insertions(+), 92 deletions(-) create mode 100644 TestView.ts create mode 100644 src/formatter.ts create mode 100644 src/indexer.ts create mode 100644 src/llm.ts create mode 100644 src/types.ts create mode 100644 test_index_formatter.ts diff --git a/.gitignore b/.gitignore index e09a007..b353082 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ data.json # Exclude macOS Finder (System Explorer) View States .DS_Store + +.compile-context/ +.env \ No newline at end of file diff --git a/TestView.ts b/TestView.ts new file mode 100644 index 0000000..2ced7cc --- /dev/null +++ b/TestView.ts @@ -0,0 +1,81 @@ +/* +This file implements a custom sidebar view for the Obsidian RAG Test Plugin. +It displays a header with a "Prepare Tests" button that, when clicked, will trigger the indexing of test notes and prepare them to be sent to an LLM for test generation. +Below the header, a file tree of test notes with status icons and test result counts is rendered. +*/ + +import { App, ItemView, WorkspaceLeaf } from 'obsidian'; +import type { IndexedNote } from './main'; + +export const VIEW_TYPE = "rag-test-view"; + +/** + * Custom sidebar view for displaying test notes and preparing tests. + */ +export default class TestView extends ItemView { + pluginData: IndexedNote[]; + + /** + * Constructs the TestView. + * @param leaf The workspace leaf to attach the view. + * @param app The Obsidian application instance. + * @param pluginData The indexed notes data. + */ + constructor(leaf: WorkspaceLeaf, app: App, pluginData: IndexedNote[]) { + super(leaf); + this.pluginData = pluginData; + } + + /** + * Returns the view type. + * @returns The view type identifier. + */ + getViewType(): string { + return VIEW_TYPE; + } + + /** + * Returns the display text for the view. + * @returns The display text. + */ + getDisplayText(): string { + return "Test Dashboard"; + } + + /** + * Called when the view is opened; triggers rendering. + */ + async onOpen(): Promise { + this.render(); + } + + /** + * Called when the view is closed. + * @returns A promise that resolves when the view is closed. + */ + async onClose(): Promise { + // No additional cleanup required. + } + + /** + * Renders the sidebar with a header containing a "Prepare Tests" button, + * followed by a file tree of test notes with status icons and test result counts. + */ + render(): void { + const container = this.containerEl; + container.empty(); + const headerEl = container.createEl('div', { cls: 'test-view-header' }); + const prepareButton = headerEl.createEl('button', { text: 'Prepare Tests' }); + prepareButton.addEventListener('click', () => { + console.log('Prepare Tests clicked'); + // Here you would call a function to index files and prepare data for the LLM. + }); + const listEl = container.createEl('ul'); + this.pluginData.forEach((note) => { + const itemEl = listEl.createEl('li'); + const statusIcon = note.testStatus.testsReady ? '🟢' : '🟔'; + const resultText = note.testStatus.testsReady ? ` (${note.testStatus.passed}/${note.testStatus.total})` : ''; + itemEl.createEl('span', { text: `${statusIcon} ${note.filePath}${resultText}` }); + }); + } +} \ No newline at end of file diff --git a/main.ts b/main.ts index 2d07212..bea380b 100644 --- a/main.ts +++ b/main.ts @@ -1,6 +1,29 @@ -import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; +/* +This file is the main plugin file for the Obsidian RAG Test Plugin. +It registers a ribbon icon that, when clicked, opens the custom sidebar view (Test Dashboard). +It also handles indexing of test notes in the "Test" folder and persisting the index. +*/ -// Remember to rename these classes and interfaces! +import { App, Notice, Plugin, PluginSettingTab, Setting, TFile, WorkspaceLeaf } from 'obsidian'; +import TestView, { VIEW_TYPE } from './TestView'; + +/** + * Interface for test status. + */ +export interface TestStatus { + testsReady: boolean; + passed: number; + total: number; +} + +/** + * Interface for an indexed note. + */ +export interface IndexedNote { + filePath: string; + content: string; + testStatus: TestStatus; +} interface MyPluginSettings { mySetting: string; @@ -8,127 +31,159 @@ interface MyPluginSettings { const DEFAULT_SETTINGS: MyPluginSettings = { mySetting: 'default' +}; + +/** + * Interface for storing plugin data, including settings and the persisted index. + */ +interface MyPluginData { + settings: MyPluginSettings; + persistedIndex: IndexedNote[]; } +/** + * Main plugin class for the Obsidian RAG Test Plugin. + */ export default class MyPlugin extends Plugin { settings: MyPluginSettings; + indexedNotes: IndexedNote[] = []; + testViewLeaf: WorkspaceLeaf | null = null; + /** + * Called when the plugin is loaded. + */ async onload() { await this.loadSettings(); - - // This creates an icon in the left ribbon. - const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { - // Called when the user clicks the icon. - new Notice('This is a notice!'); + this.registerView(VIEW_TYPE, (leaf: WorkspaceLeaf) => new TestView(leaf, this.app, this.indexedNotes)); + this.addRibbonIcon('dice', 'Test Dashboard', (evt: MouseEvent) => { + this.openTestDashboard(); }); - // Perform additional things with the ribbon - ribbonIconEl.addClass('my-plugin-ribbon-class'); - - // This adds a status bar item to the bottom of the app. Does not work on mobile apps. - const statusBarItemEl = this.addStatusBarItem(); - statusBarItemEl.setText('Status Bar Text'); - - // This adds a simple command that can be triggered anywhere + this.addStatusBarItem().setText('RAG Test Plugin Active'); this.addCommand({ - id: 'open-sample-modal-simple', - name: 'Open sample modal (simple)', + id: 'open-test-dashboard', + name: 'Open Test Dashboard', callback: () => { - new SampleModal(this.app).open(); + this.openTestDashboard(); } }); - // This adds an editor command that can perform some operation on the current editor instance - this.addCommand({ - id: 'sample-editor-command', - name: 'Sample editor command', - editorCallback: (editor: Editor, view: MarkdownView) => { - console.log(editor.getSelection()); - editor.replaceSelection('Sample Editor Command'); - } + this.addSettingTab(new SampleSettingTab(this.app, this)); + this.registerDomEvent(document, 'click', (evt: MouseEvent) => { + console.log('Document clicked', evt); }); - // This adds a complex command that can check whether the current state of the app allows execution of the command - this.addCommand({ - id: 'open-sample-modal-complex', - name: 'Open sample modal (complex)', - checkCallback: (checking: boolean) => { - // Conditions to check - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) { - // If checking is true, we're simply "checking" if the command can be run. - // If checking is false, then we want to actually perform the operation. - if (!checking) { - new SampleModal(this.app).open(); - } + this.registerInterval(window.setInterval(() => console.log('Interval log'), 5 * 60 * 1000)); + } - // This command will only show up in Command Palette when the check function returns true - return true; + /** + * Called when the plugin is unloaded. + */ + onunload() { + if (this.testViewLeaf) { + this.app.workspace.detachLeavesOfType(VIEW_TYPE); + } + } + + /** + * Loads plugin settings and the persisted index from disk. + */ + async loadSettings() { + const data = (await this.loadData()) as MyPluginData | null; + if (data) { + this.settings = Object.assign({}, DEFAULT_SETTINGS, data.settings); + this.indexedNotes = data.persistedIndex || []; + } else { + this.settings = DEFAULT_SETTINGS; + this.indexedNotes = []; + } + } + + /** + * Saves plugin settings and the current index to disk. + */ + async saveSettings() { + const data: MyPluginData = { + settings: this.settings, + persistedIndex: this.indexedNotes + }; + await this.saveData(data); + } + + /** + * Indexes Markdown files in the "Test" folder, extracts test readiness data, and persists the index. + */ + async indexTestNotes() { + this.indexedNotes = []; + const markdownFiles = this.app.vault.getMarkdownFiles().filter((file: TFile) => file.path.startsWith("Test/")); + for (const file of markdownFiles) { + const content = await this.app.vault.read(file); + const testsReady = content.includes("## Test"); + let total = 0; + let passed = 0; + if (testsReady) { + const regex = /- \[( |x)\]/g; + let match; + while ((match = regex.exec(content)) !== null) { + total++; + if (match[1] === "x") passed++; } } + const testStatus = { testsReady, passed, total }; + this.indexedNotes.push({ filePath: file.path, content, testStatus }); + console.log(`Indexed: ${file.path}`); + } + await this.saveSettings(); + new Notice(`Indexed ${this.indexedNotes.length} test notes`); + } + + /** + * Opens the test dashboard view in the left sidebar. + */ + openTestDashboard() { + this.app.workspace.detachLeavesOfType(VIEW_TYPE); + const leaf = this.app.workspace.getLeftLeaf(false); + if (!leaf) { + new Notice('Could not obtain workspace leaf.'); + return; + } + leaf.setViewState({ + type: VIEW_TYPE, + active: true }); - - // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new SampleSettingTab(this.app, this)); - - // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) - // Using this function will automatically remove the event listener when this plugin is disabled. - this.registerDomEvent(document, 'click', (evt: MouseEvent) => { - console.log('click', evt); - }); - - // When registering intervals, this function will automatically clear the interval when the plugin is disabled. - this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000)); - } - - onunload() { - - } - - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - - async saveSettings() { - await this.saveData(this.settings); - } -} - -class SampleModal extends Modal { - constructor(app: App) { - super(app); - } - - onOpen() { - const {contentEl} = this; - contentEl.setText('Woah!'); - } - - onClose() { - const {contentEl} = this; - contentEl.empty(); + this.app.workspace.revealLeaf(leaf); + this.testViewLeaf = leaf; } } +/** + * Settings tab for the RAG Test Plugin. + */ class SampleSettingTab extends PluginSettingTab { plugin: MyPlugin; + /** + * Constructs the settings tab. + * @param app The Obsidian application instance. + * @param plugin The plugin instance. + */ constructor(app: App, plugin: MyPlugin) { super(app, plugin); this.plugin = plugin; } + /** + * Renders the settings tab. + */ display(): void { - const {containerEl} = this; - + const { containerEl } = this; containerEl.empty(); - new Setting(containerEl) .setName('Setting #1') - .setDesc('It\'s a secret') + .setDesc("It's a secret") .addText(text => text .setPlaceholder('Enter your secret') .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { + .onChange(async (value: string) => { this.plugin.settings.mySetting = value; await this.plugin.saveSettings(); })); } -} +} \ No newline at end of file diff --git a/manifest.json b/manifest.json index dfa940e..16b807a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ { - "id": "sample-plugin", - "name": "Sample Plugin", + "id": "obsidian-rag-test-plugin", + "name": "Obsidian RAG Test Plugin", "version": "1.0.0", "minAppVersion": "0.15.0", - "description": "Demonstrates some of the capabilities of the Obsidian API.", - "author": "Obsidian", + "description": "A plugin to index test notes and display a dashboard for self-testing based on your notes.", + "author": "Aldo E George", "authorUrl": "https://obsidian.md", "fundingUrl": "https://obsidian.md/pricing", "isDesktopOnly": false -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7a62d92..0bafa22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "obsidian-sample-plugin", "version": "1.0.0", "license": "MIT", + "dependencies": { + "dotenv": "^16.4.7" + }, "devDependencies": { "@types/node": "^16.11.6", "@typescript-eslint/eslint-plugin": "5.29.0", @@ -1075,6 +1078,18 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/esbuild": { "version": "0.17.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz", diff --git a/package.json b/package.json index 6a00766..52050ed 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "keywords": [], "author": "", "license": "MIT", + "type": "commonjs", "devDependencies": { "@types/node": "^16.11.6", "@typescript-eslint/eslint-plugin": "5.29.0", @@ -20,5 +21,8 @@ "obsidian": "latest", "tslib": "2.4.0", "typescript": "4.7.4" + }, + "dependencies": { + "dotenv": "^16.4.7" } } diff --git a/src/formatter.ts b/src/formatter.ts new file mode 100644 index 0000000..2d0373f --- /dev/null +++ b/src/formatter.ts @@ -0,0 +1,21 @@ +/* +This file contains functions to format indexed notes into a prompt for an LLM. +It exports a function formatNotesForLLM that takes an array of IndexedNote and returns a formatted prompt string. +*/ + +import type { IndexedNote } from "./types"; + +/** + * Formats an array of IndexedNote objects into a structured prompt string for the LLM. + * @param notes - An array of IndexedNote objects. + * @returns A formatted prompt string containing the full content of each note. + */ +export function formatNotesForLLM(notes: IndexedNote[]): string { + let prompt = "Generate test questions based on the following notes.\n\n"; + notes.forEach(note => { + prompt += `### Note: ${note.filePath}\n`; + prompt += `Content:\n${note.content}\n\n`; + }); + prompt += "Ensure that the test questions are relevant to the content and test key concepts."; + return prompt; +} \ No newline at end of file diff --git a/src/indexer.ts b/src/indexer.ts new file mode 100644 index 0000000..21e29a0 --- /dev/null +++ b/src/indexer.ts @@ -0,0 +1,41 @@ +/* +This file contains functions to scan the vault and extract test data from all Markdown files. +It exports a function indexTestNotes that reads each Markdown file, +extracts test-related markers (if any), and returns an array of IndexedNote objects. +*/ + +import { App } from "obsidian"; +import type { IndexedNote, TestStatus } from "./types"; + +/** + * Scans all Markdown files in the vault and extracts test data. + * @param app - The Obsidian application instance. + * @returns A promise that resolves to an array of IndexedNote objects. + */ +export async function indexTestNotes(app: App): Promise { + const notes: IndexedNote[] = []; + // Process all Markdown files without filtering by folder. + const markdownFiles = app.vault.getMarkdownFiles(); + for (const file of markdownFiles) { + const content = await app.vault.read(file); + // Check for a test marker (e.g., "## Test"). This may be present in some notes. + const testsReady = content.includes("## Test"); + let total = 0; + let passed = 0; + if (testsReady) { + const regex = /- \[( |x)\]/g; + let match; + while ((match = regex.exec(content)) !== null) { + total++; + if (match[1] === "x") passed++; + } + } + const testStatus: TestStatus = { testsReady, passed, total }; + notes.push({ + filePath: file.path, + content, + testStatus + }); + } + return notes; +} \ No newline at end of file diff --git a/src/llm.ts b/src/llm.ts new file mode 100644 index 0000000..3b876ed --- /dev/null +++ b/src/llm.ts @@ -0,0 +1,62 @@ +/** + * Handles communication with OpenAI to generate test questions. + */ + +import { formatNotesForLLM } from "./formatter"; +import type { IndexedNote, LLMResponse } from "./types"; + +const API_URL = "https://api.openai.com/v1/chat/completions"; + +// Load API key from environment variables (DO NOT hardcode!) +import { config } from "dotenv"; +config(); // Load .env variables + +const API_KEY = process.env.OPENAI_API_KEY; + +if (!API_KEY) { + throw new Error("Missing OpenAI API key! Add it to your .env file."); +} + +console.log("āœ… OpenAI API Key Loaded Successfully"); + +/** + * Sends formatted note data to OpenAI and retrieves test questions. + * @param {IndexedNote[]} indexedNotes - The indexed notes to generate questions from. + * @returns {Promise} - The generated test questions. + */ +export async function generateTestQuestions(indexedNotes: IndexedNote[]): Promise { + const prompt = formatNotesForLLM(indexedNotes); + + const requestBody = { + model: "gpt-4-turbo", // You can change this model + messages: [ + { role: "system", content: "You are a helpful AI that generates test questions from study notes." }, + { role: "user", content: prompt } + ], + temperature: 0.7, // Adjust for more or less creativity + max_tokens: 500 + }; + + const response = await fetch(API_URL, { + method: "POST", + headers: { + "Authorization": `Bearer ${API_KEY}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + throw new Error(`OpenAI API Error: ${response.statusText}`); + } + + const responseData: LLMResponse = await response.json(); + const output = responseData.choices?.[0]?.message?.content; + + if (!output) { + throw new Error("Invalid response from OpenAI"); + } + + // Split response into separate questions (assuming they are newline-separated) + return output.split("\n").filter(q => q.trim().length > 0); +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..aee93af --- /dev/null +++ b/src/types.ts @@ -0,0 +1,60 @@ +/* +This file contains shared type definitions for the Obsidian RAG Test Plugin, +including types for LLM responses and generated tests. +*/ + +/** + * Represents the response structure from the LLM API. + */ +export interface LLMResponse { + data: any; +} + +/** + * Represents a generated test question (and an optional answer) from the LLM. + */ +export interface GeneratedTest { + question: string; + answer?: string; +} + +/** + * Represents the test status for a note. + */ +export interface TestStatus { + testsReady: boolean; + passed: number; + total: number; +} + +/** + * Represents an indexed note with its file path, content, and test status. + */ +export interface IndexedNote { + filePath: string; + content: string; + testStatus: TestStatus; +} + +/** + * Shared type definitions for indexed notes and LLM responses. + */ + +export interface LLMResponse { + id: string; + object: string; + created: number; + model: string; + choices: { + index: number; + message: { + role: string; + content: string; + }; + }[]; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} \ No newline at end of file diff --git a/test_index_formatter.ts b/test_index_formatter.ts new file mode 100644 index 0000000..5643085 --- /dev/null +++ b/test_index_formatter.ts @@ -0,0 +1,84 @@ +/** + * Test script to verify the indexing, formatting, and LLM response generation. + * It mocks an Obsidian vault, runs the indexer and formatter, calls the LLM, and prints the output. + */ + +import { indexTestNotes } from "./src/indexer"; +import { formatNotesForLLM } from "./src/formatter"; +import { generateTestQuestions } from "./src/llm"; +import type { IndexedNote } from "./src/types"; + +// Mock Vault class to simulate Obsidian vault behavior +class MockVault { + files: { path: string; content: string }[]; + + constructor(files: { path: string; content: string }[]) { + this.files = files; + } + + // Simulates fetching Markdown files + getMarkdownFiles() { + return this.files.map(file => ({ + path: file.path, + })); + } + + // Simulates reading a file's content + async read(file: { path: string }) { + const found = this.files.find(f => f.path === file.path); + if (found) { + return found.content; + } + throw new Error(`File not found: ${file.path}`); + } +} + +// Mock App object +class MockApp { + vault: MockVault; + + constructor(vault: MockVault) { + this.vault = vault; + } +} + +// Sample test data (simulating Obsidian Markdown files) +const mockFiles = [ + { + path: "Test/1.1 - The Art of Learning.md", + content: "## Key Takeaways\n- Learning is an iterative process.\n- Experimentation is crucial.\n\n## Test\n- [ ] What is the key principle of learning?", + }, + { + path: "Test/1.2 - The Pyramid Principle.md", + content: "## Summary\nThe Pyramid Principle helps structure arguments.\n\n## Test\n- [x] What does the Pyramid Principle help with?", + }, + { + path: "General Notes/1.3 - Random Thoughts.md", + content: "## Notes\nSome random reflections on life.", + }, +]; + +async function runTest() { + // Initialize mock app and vault + const mockApp = new MockApp(new MockVault(mockFiles)); + + // Step 1: Run indexer + const indexedNotes: IndexedNote[] = await indexTestNotes(mockApp as any); + console.log("\nšŸ“‚ Indexed Notes:\n", indexedNotes); + + // Step 2: Run formatter + const formattedPrompt = formatNotesForLLM(indexedNotes); + console.log("\nšŸ“ Formatted Prompt for LLM:\n", formattedPrompt); + + // Step 3: Call LLM to generate test questions + try { + console.log("\nšŸš€ Sending request to OpenAI...\n"); + const testQuestions = await generateTestQuestions(indexedNotes); + console.log("\nāœ… Generated Test Questions:\n", testQuestions); + } catch (error) { + console.error("\nāŒ Error in LLM call:", error); + } +} + +// Execute the test +runTest(); \ No newline at end of file