openai call noe possible

This commit is contained in:
Alastair Grant 2025-02-12 11:39:45 +01:00
parent 5e7e4532bf
commit 8ebee35c75
11 changed files with 518 additions and 92 deletions

3
.gitignore vendored
View file

@ -20,3 +20,6 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
.compile-context/
.env

81
TestView.ts Normal file
View file

@ -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<void> {
this.render();
}
/**
* Called when the view is closed.
* @returns A promise that resolves when the view is closed.
*/
async onClose(): Promise<void> {
// 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}` });
});
}
}

213
main.ts
View file

@ -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,125 +31,157 @@ 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 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 command will only show up in Command Palette when the check function returns true
return 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);
console.log('Document clicked', 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));
this.registerInterval(window.setInterval(() => console.log('Interval log'), 5 * 60 * 1000));
}
/**
* 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() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
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() {
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();
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.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();
}));

View file

@ -1,10 +1,10 @@
{
"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

15
package-lock.json generated
View file

@ -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",

View file

@ -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"
}
}

21
src/formatter.ts Normal file
View file

@ -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;
}

41
src/indexer.ts Normal file
View file

@ -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<IndexedNote[]> {
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;
}

62
src/llm.ts Normal file
View file

@ -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<string[]>} - The generated test questions.
*/
export async function generateTestQuestions(indexedNotes: IndexedNote[]): Promise<string[]> {
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);
}

60
src/types.ts Normal file
View file

@ -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;
};
}

84
test_index_formatter.ts Normal file
View file

@ -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();