Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"private": true,
"packageManager": "npm@11.6.2",
"dependencies": {
"@angular/cdk": "^21.1.4",
"@angular/common": "^21.1.0",
"@angular/compiler": "^21.1.0",
"@angular/core": "^21.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { defaultBlocks, getBlockFields, BLOCK_ICONS, BLOCK_LABELS, BlockType } from './pipeline-block.model';

describe('PipelineBlock Model', () => {
describe('defaultBlocks', () => {
it('should return 5 blocks', () => {
expect(defaultBlocks().length).toBe(5);
});

it('should have correct order: source → chunking → embedding → retrieval → generation', () => {
const types = defaultBlocks().map(b => b.type);
expect(types).toEqual(['source', 'chunking', 'embedding', 'retrieval', 'generation']);
});

it('should have unique IDs', () => {
const ids = defaultBlocks().map(b => b.id);
expect(new Set(ids).size).toBe(5);
});

it('should have default config values for chunking', () => {
const chunking = defaultBlocks().find(b => b.type === 'chunking')!;
expect(chunking.config['chunkSize']).toBe(512);
expect(chunking.config['chunkOverlap']).toBe(50);
expect(chunking.config['strategy']).toBe('recursive');
});

it('should have default config values for embedding', () => {
const embedding = defaultBlocks().find(b => b.type === 'embedding')!;
expect(embedding.config['model']).toBe('text-embedding-3-small');
expect(embedding.config['dimensions']).toBe(1536);
});

it('should have default config values for generation', () => {
const gen = defaultBlocks().find(b => b.type === 'generation')!;
expect(gen.config['model']).toBe('gpt-4o-mini');
expect(gen.config['temperature']).toBe(0.7);
expect(gen.config['maxTokens']).toBe(2048);
});

it('should return independent copies each call', () => {
const a = defaultBlocks();
const b = defaultBlocks();
a[0].config['test'] = 'modified';
expect(b[0].config['test']).toBeUndefined();
});
});

describe('BLOCK_ICONS', () => {
it('should have icons for all 5 block types', () => {
const types: BlockType[] = ['source', 'chunking', 'embedding', 'retrieval', 'generation'];
for (const t of types) {
expect(BLOCK_ICONS[t]).toBeTruthy();
}
});
});

describe('BLOCK_LABELS', () => {
it('should have labels for all block types', () => {
expect(BLOCK_LABELS['source']).toBe('Data Source');
expect(BLOCK_LABELS['generation']).toBe('Generation');
});
});

describe('getBlockFields', () => {
it('should return fields for chunking block', () => {
const fields = getBlockFields('chunking');
expect(fields.length).toBe(3);
expect(fields.map(f => f.key)).toEqual(['strategy', 'chunkSize', 'chunkOverlap']);
});

it('should return fields for embedding block', () => {
const fields = getBlockFields('embedding');
expect(fields.length).toBe(2);
expect(fields[0].key).toBe('model');
});

it('should return fields for retrieval block', () => {
const fields = getBlockFields('retrieval');
expect(fields.find(f => f.key === 'topK')?.max).toBe(20);
});

it('should return fields for generation block', () => {
const fields = getBlockFields('generation');
const modelField = fields.find(f => f.key === 'model')!;
expect(modelField.options!.length).toBeGreaterThanOrEqual(3);
});

it('should return fields for source block', () => {
const fields = getBlockFields('source');
expect(fields.length).toBe(1);
expect(fields[0].key).toBe('sourceType');
});
});
});
189 changes: 189 additions & 0 deletions client/src/app/features/pipeline-builder/pipeline.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { PipelineBlock, ChunkPreview, createDefaultBlocks } from './pipeline-block.model';

export interface PipelineDto {
id: string;
projectId: string;
name: string;
description: string | null;
config: {
chunkSize: number;
chunkOverlap: number;
embeddingModel: string;
retrievalStrategy: string;
topK: number;
scoreThreshold: number;
};
status: string;
createdAt: string;
updatedAt: string | null;
}

const SAMPLE_TEXT =
'Retrieval-Augmented Generation (RAG) is a technique that combines information retrieval with text generation. ' +
'It first retrieves relevant documents from a knowledge base using semantic search, then feeds those documents ' +
'as context to a large language model (LLM) to generate accurate, grounded responses. This approach reduces ' +
'hallucinations and allows the model to access up-to-date information without retraining. RAG pipelines typically ' +
'consist of several stages: document ingestion, text chunking, embedding generation, vector storage, retrieval, ' +
'and finally generation. Each stage can be configured independently to optimize the overall pipeline performance ' +
'for specific use cases.';

@Injectable({ providedIn: 'root' })
export class PipelineService {
private http = inject(HttpClient);

readonly blocks = signal<PipelineBlock[]>(createDefaultBlocks());
readonly selectedBlockId = signal<string | null>(null);
readonly loading = signal(false);
readonly saving = signal(false);
readonly dirty = signal(false);
readonly pipelineId = signal<string | null>(null);

readonly selectedBlock = computed(() => {
const id = this.selectedBlockId();
return id ? this.blocks().find((b: PipelineBlock) => b.id === id) ?? null : null;
});

readonly chunkPreviews = computed<ChunkPreview[]>(() => {
const chunkingBlock = this.blocks().find((b: PipelineBlock) => b.type === 'chunking');
if (!chunkingBlock) return [];
const size: number = chunkingBlock.config['chunkSize'] ?? 512;
const overlap: number = chunkingBlock.config['chunkOverlap'] ?? 50;
return this.generateChunkPreviews(SAMPLE_TEXT, size, overlap);
});

readonly config = computed(() => {
const chunking = this.blocks().find((b: PipelineBlock) => b.type === 'chunking');
const retrieval = this.blocks().find((b: PipelineBlock) => b.type === 'retrieval');
const generation = this.blocks().find((b: PipelineBlock) => b.type === 'generation');
return {
chunkSize: (chunking?.config['chunkSize'] as number) ?? 512,
chunkOverlap: (chunking?.config['chunkOverlap'] as number) ?? 50,
retrievalStrategy: (retrieval?.config['strategy'] as string) ?? 'semantic',
topK: (retrieval?.config['topK'] as number) ?? 5,
scoreThreshold: (retrieval?.config['scoreThreshold'] as number) ?? 0.7,
temperature: (generation?.config['temperature'] as number) ?? 0.7,
};
});

readonly hasUnsavedChanges = this.dirty;

selectBlock(id: string): void {
this.selectedBlockId.set(id);
}

updateBlockConfig(blockId: string, key: string, value: unknown): void {
this.blocks.update((blocks: PipelineBlock[]) =>
blocks.map((b: PipelineBlock) =>
b.id === blockId ? { ...b, config: { ...b.config, [key]: value } } : b,
),
);
this.dirty.set(true);
}

updateConfig(partial: Partial<{ chunkSize: number; chunkOverlap: number; temperature: number }>): void {
if (partial.chunkSize !== undefined || partial.chunkOverlap !== undefined) {
const chunking = this.blocks().find((b: PipelineBlock) => b.type === 'chunking');
if (chunking) {
if (partial.chunkSize !== undefined) this.updateBlockConfig(chunking.id, 'chunkSize', partial.chunkSize);
if (partial.chunkOverlap !== undefined) this.updateBlockConfig(chunking.id, 'chunkOverlap', partial.chunkOverlap);
}
}
if (partial.temperature !== undefined) {
const gen = this.blocks().find((b: PipelineBlock) => b.type === 'generation');
if (gen) this.updateBlockConfig(gen.id, 'temperature', partial.temperature);
}
this.dirty.set(true);
}

resetDefaults(): void {
this.blocks.set(createDefaultBlocks());
this.dirty.set(false);
this.selectedBlockId.set(null);
}

moveBlock(fromIndex: number, toIndex: number): void {
this.blocks.update((blocks: PipelineBlock[]) => {
const arr = [...blocks];
const [removed] = arr.splice(fromIndex, 1);
arr.splice(toIndex, 0, removed);
return arr;
});
this.dirty.set(true);
}

async loadPipeline(projectId: string): Promise<void> {
this.loading.set(true);
try {
const pipelines = await firstValueFrom(
this.http.get<PipelineDto[]>('/api/projects/' + projectId + '/pipelines'),
);
if (pipelines.length > 0) {
const p = pipelines[0];
this.pipelineId.set(p.id);
const blocks = createDefaultBlocks();
const chunking = blocks.find((b: PipelineBlock) => b.type === 'chunking')!;
chunking.config['chunkSize'] = p.config.chunkSize;
chunking.config['chunkOverlap'] = p.config.chunkOverlap;
const embedding = blocks.find((b: PipelineBlock) => b.type === 'embedding')!;
embedding.config['model'] = p.config.embeddingModel;
const retrieval = blocks.find((b: PipelineBlock) => b.type === 'retrieval')!;
retrieval.config['strategy'] = p.config.retrievalStrategy;
retrieval.config['topK'] = p.config.topK;
retrieval.config['scoreThreshold'] = p.config.scoreThreshold;
this.blocks.set(blocks);
}
} finally {
this.loading.set(false);
this.dirty.set(false);
}
}

async savePipeline(projectId: string): Promise<void> {
this.saving.set(true);
try {
const cfg = this.config();
const body = {
name: 'Default Pipeline',
description: 'Visual pipeline configuration',
config: {
chunkSize: cfg.chunkSize,
chunkOverlap: cfg.chunkOverlap,
embeddingModel:
(this.blocks().find((b: PipelineBlock) => b.type === 'embedding')?.config['model'] as string) ??
'text-embedding-3-small',
retrievalStrategy: cfg.retrievalStrategy,
topK: cfg.topK,
scoreThreshold: cfg.scoreThreshold,
},
};

const pid = this.pipelineId();
if (pid) {
await firstValueFrom(this.http.put('/api/projects/' + projectId + '/pipelines/' + pid, body));
} else {
const result = await firstValueFrom(
this.http.post<PipelineDto>('/api/projects/' + projectId + '/pipelines', body),
);
this.pipelineId.set(result.id);
}
this.dirty.set(false);
} finally {
this.saving.set(false);
}
}

private generateChunkPreviews(text: string, size: number, overlap: number): ChunkPreview[] {
const charSize = Math.max(size, 50);
const charOverlap = Math.min(overlap, charSize - 10);
const step = Math.max(charSize - charOverlap, 1);
const chunks: ChunkPreview[] = [];
for (let i = 0; i < text.length && chunks.length < 5; i += step) {
const slice = text.slice(i, i + charSize);
chunks.push({ index: chunks.length, text: slice, tokens: Math.round(slice.length / 4) });
}
return chunks;
}
}
Loading