2026 03 23 Pvi V2 Intelligence Learning
Documentation for Plans
PVI V2: Intelligence + Learning — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make Vitto proactively analyze generated platforms, suggest improvements via chat, learn user preferences, and persist snapshot history to Hetzner git.
Architecture: V2 builds on V1's core loop (click → toolbar → apply). Adds 4 new capabilities: (1) post-gen proactive scan with parallel Haiku LLM analysis, (2) ambient dot overlay surviving recompiles, (3) learning layer recording user preferences to personalize suggestions, (4) Hetzner-backed snapshot timeline. Frontend services in vitto-studio, LLM routing via vitto-api, preference storage in MongoDB.
Tech Stack: Angular 20+ (signals, standalone, OnPush), RxJS (forkJoin for parallel LLM), NestJS (vitto-api), MongoDB (vitto-mind), Hetzner git (UniverseGitService)
Spec: docs/superpowers/specs/2026-03-23-proactive-visual-intelligence-design.md (sections 7, 9, 10, 12)
Depends on: PVI V1 (S263) — all V1 deliverables complete
IMPORTANT: Do NOT use git actions (commit, push, branch) — another session may be running concurrently.
File Structure
| File | Action | Responsibility |
|---|---|---|
libs/libs-structural/vitto-studio/src/lib/services/pvi-proactive-analysis.service.ts |
Create | Post-gen scan orchestration (rule scan + parallel LLM + merge) |
libs/libs-structural/vitto-studio/src/lib/services/pvi-enrichment.service.ts |
Create | On-click LLM enrichment with debounce, cache, cancel |
libs/libs-structural/vitto-studio/src/lib/services/pvi.service.ts |
Modify | Add ambient dot state, Apply All, wire enrichment |
libs/libs-structural/vitto-studio/src/lib/services/pvi-apply.service.ts |
Modify | Add applyAll() with conflict detection |
libs/libs-structural/vitto-studio/src/lib/services/pvi-snapshot.service.ts |
Modify | Add Hetzner push integration |
libs/libs-structural/vitto-studio/src/lib/services/studio-vitto-bridge.service.ts |
Modify | Add pviEnrich() and pviScan() methods for LLM calls |
libs/libs-structural/vitto-studio/src/lib/components/pvi-chat-summary/pvi-chat-summary.component.ts |
Create | Ranked top-3 suggestion card in Vitto chat |
libs/libs-structural/vitto-studio/src/lib/components/pvi-chat-summary/pvi-chat-summary.component.html |
Create | Template |
libs/libs-structural/vitto-studio/src/lib/components/pvi-chat-summary/pvi-chat-summary.component.scss |
Create | Styles |
libs/libs-structural/vitto-studio/src/lib/components/pvi-timeline/pvi-timeline.component.ts |
Create | Horizontal snapshot history strip |
libs/libs-structural/vitto-studio/src/lib/components/pvi-timeline/pvi-timeline.component.html |
Create | Template |
libs/libs-structural/vitto-studio/src/lib/components/pvi-timeline/pvi-timeline.component.scss |
Create | Styles |
libs/libs-structural/vitto-studio/src/lib/components/studio-dock-shell/studio-dock-shell.component.ts |
Modify | Wire proactive analysis, chat summary, timeline |
libs/libs-structural/vitto-studio/src/lib/components/studio-dock-shell/studio-dock-shell.component.html |
Modify | Add chat summary + timeline components |
libs/libs-structural/platform-preview/src/lib/services/preview-bridge.service.ts |
Modify | Add sendPviMessage() for outbound iframe messages |
APIs/backend-vitto-api/src/app/engine/pvi/pvi.module.ts |
Create | NestJS module for PVI backend |
APIs/backend-vitto-api/src/app/engine/pvi/pvi-model-router.service.ts |
Create | Smart model selection (Haiku/Sonnet/Opus) for PVI tasks |
APIs/backend-vitto-api/src/app/engine/pvi/pvi.controller.ts |
Create | REST endpoints: POST /pvi/scan, POST /pvi/enrich |
APIs/backend-vitto-api/src/app/engine/pvi/user-suggestion-tracker.service.ts |
Create | MongoDB event recording for learning |
APIs/backend-vitto-api/src/app/engine/pvi/suggestion-weight.service.ts |
Create | Weight aggregation + personalization |
libs/libs-ai/proactive-visual-intelligence/src/lib/models/pvi.types.ts |
Modify | Add UserSuggestionEvent, PviScanRequest, PviEnrichRequest types |
Task 1: Add outbound PVI messaging to PreviewBridgeService
Zero dependencies. Needed by ambient dots (Task 2) and proactive analysis (Task 3).
Files:
Modify:
libs/libs-structural/platform-preview/src/lib/services/preview-bridge.service.tsStep 1: Add sendPviMessage method
In preview-bridge.service.ts, add a public method to send messages TO the iframe:
/**
* [S263-V2] Send a PVI message to the preview iframe.
*/
sendPviMessage(type: string, payload: unknown): void {
const iframe = this.currentIframe;
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type, payload }, '*');
}
}
Check if the service already stores a reference to the iframe (from initialize(iframe)). If it stores this.currentIframe, use it. If not, add:
private currentIframe: HTMLIFrameElement | null = null;
// In initialize():
this.currentIframe = iframe;
- Step 2: Add re-inject dots on iframe reload
The preview iframe reloads on every recompile. Dots are lost. Add a method that re-injects dots from stored state:
/**
* [S263-V2] Re-inject ambient dots after iframe reload.
* Called by PviService when iframe load event fires.
*/
reinjectDots(dots: Array<{ studioId: string; dotType: 'high-impact' | 'enhancement'; count: number }>): void {
if (dots.length === 0) return;
// Delay slightly to ensure DOM is ready after iframe load
setTimeout(() => {
this.sendPviMessage('PVI_INJECT_DOTS', dots);
}, 200);
}
Task 2: Ambient dots state management (V2-2)
Files:
Modify:
libs/libs-structural/vitto-studio/src/lib/services/pvi.service.tsStep 1: Add ambient dot state to PviService
/** Ambient dot map — persists across recompiles */
readonly ambientDots = signal<Array<{ studioId: string; dotType: 'high-impact' | 'enhancement'; count: number }>>([]);
- Step 2: Add methods to manage dots
/**
* [S263-V2] Update ambient dots from analysis results.
*/
updateAmbientDots(analysisResult: PviAnalysisResult): void {
const dots = analysisResult.components.map(c => ({
studioId: c.studioId,
dotType: c.dotType,
count: c.suggestions.length,
}));
this.ambientDots.set(dots);
}
/**
* [S263-V2] Re-inject dots after iframe reload.
*/
reinjectDotsAfterRecompile(): void {
const dots = this.ambientDots();
if (dots.length > 0) {
this.previewBridge.reinjectDots(dots);
}
}
Inject PreviewBridgeService into PviService.
- Step 3: Wire iframe load event to dot re-injection
In StudioDockShellComponent (or wherever the preview iframe's load event is observed), subscribe to iframe load and call pvi.reinjectDotsAfterRecompile(). Search for how the existing preview handles iframe load — likely via PreviewBridgeService or PreviewServerService.
Task 3: Post-generation proactive analysis (V2-1)
Depends on Task 1 (sendPviMessage) and Task 2 (ambient dot state).
Files:
Create:
libs/libs-structural/vitto-studio/src/lib/services/pvi-proactive-analysis.service.tsModify:
libs/libs-structural/vitto-studio/src/lib/services/studio-vitto-bridge.service.tsStep 1: Add pviScan method to StudioVittoBridgeService
/**
* [S263-V2] Request PVI component scan from vitto-api.
* Returns LLM-generated suggestions for a single component.
*/
pviScan(components: Array<{ componentName: string; template: string; styles: string; manifest: unknown }>): Observable<{
components: Array<{
componentName: string;
suggestions: Array<{ label: string; description: string; category: string; impact: string; applyType: string; codeHint?: string }>;
}>;
}> {
return this.http.post<any>(`${this.vittoApiUrl}/pvi/scan`, { components }).pipe(
timeout(10000),
catchError(() => of({ components: [] })),
);
}
- Step 2: Create ProactiveAnalysisService
import { Injectable, inject } from '@angular/core';
import { forkJoin, of, type Observable } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import type { PviAnalysisResult, Suggestion, SuggestionCategory } from '@libs-ai/proactive-visual-intelligence';
import { runSuggestionRules, mergeSuggestions } from '@libs-ai/proactive-visual-intelligence';
import { PviService } from './pvi.service';
import { StudioVittoBridgeService } from './studio-vitto-bridge.service';
import sdkManifest from '@libs-ai/proactive-visual-intelligence/lib/data/sdk-manifest.json';
@Injectable({ providedIn: 'root' })
export class PviProactiveAnalysisService {
private readonly pvi = inject(PviService);
private readonly vittoBridge = inject(StudioVittoBridgeService);
/**
* [S263-V2] Run post-generation proactive analysis.
* Phase A: Instant rule scan on all components.
* Phase B: Parallel Haiku LLM deep scan (2-4s).
* Merges results, updates ambient dots, returns PviAnalysisResult.
*/
analyzeGeneration(vfsFiles: Record<string, string>): Observable<PviAnalysisResult> {
// Phase A: Rule scan — instant, <100ms
const componentFiles = this.extractComponents(vfsFiles);
const ruleResults = this.runRuleScanAll(componentFiles);
// Inject preliminary dots immediately
this.pvi.updateAmbientDots(ruleResults);
// Phase B: LLM deep scan — parallel Haiku calls
const scanPayloads = componentFiles.map(c => ({
componentName: c.name,
template: c.template,
styles: c.styles,
manifest: (sdkManifest as any).components || {},
}));
if (scanPayloads.length === 0) {
return of(ruleResults);
}
return this.vittoBridge.pviScan(scanPayloads).pipe(
map(llmResults => {
// Merge LLM results with rule results per component
const merged = this.mergeResults(ruleResults, llmResults);
// Update dots with merged results (some may upgrade to high-impact)
this.pvi.updateAmbientDots(merged);
return merged;
}),
catchError(() => of(ruleResults)), // Graceful degradation
);
}
private extractComponents(vfsFiles: Record<string, string>): Array<{
name: string;
studioId: string;
template: string;
styles: string;
classSource: string;
}> {
const components: Array<{ name: string; studioId: string; template: string; styles: string; classSource: string }> = [];
for (const [path, content] of Object.entries(vfsFiles)) {
if (!path.endsWith('.component.html')) continue;
const name = path.split('/').pop()?.replace('.component.html', '') || '';
const basePath = path.replace('.component.html', '');
const styles = vfsFiles[basePath + '.component.scss'] || '';
const classSource = vfsFiles[basePath + '.component.ts'] || '';
const studioId = `component:${this.toPascalCase(name)}:file:${path}:line:1`;
components.push({ name, studioId, template: content, styles, classSource });
}
return components;
}
private runRuleScanAll(components: Array<{ name: string; studioId: string; template: string; styles: string }>): PviAnalysisResult {
const resultComponents: PviAnalysisResult['components'] = [];
for (const comp of components) {
// Build a lightweight ElementSelectionPayload from static analysis
const payload = this.buildPayloadFromTemplate(comp);
const suggestions = runSuggestionRules(payload, sdkManifest as any);
if (suggestions.length > 0) {
const hasHigh = suggestions.some(s => s.impact === 'high');
resultComponents.push({
componentName: comp.name,
studioId: comp.studioId,
suggestions,
dotType: hasHigh ? 'high-impact' : 'enhancement',
});
}
}
// Build summary
const allSuggestions = resultComponents.flatMap(c => c.suggestions);
const byCategory: Record<string, number> = {};
for (const s of allSuggestions) {
byCategory[s.category] = (byCategory[s.category] || 0) + 1;
}
return {
components: resultComponents,
summary: {
total: allSuggestions.length,
byCategory: byCategory as Record<SuggestionCategory, number>,
topThree: allSuggestions.slice(0, 3),
},
};
}
private buildPayloadFromTemplate(comp: { name: string; studioId: string; template: string; styles: string }): import('@libs-ai/proactive-visual-intelligence').ElementSelectionPayload {
const html = comp.template.toLowerCase();
const childGgs = (html.match(/ggs-[a-z-]+/g) || []).filter((v, i, a) => a.indexOf(v) === i);
const missingGgs: string[] = [];
if (/<button[^>]*>/i.test(comp.template)) missingGgs.push('<button>');
if (/<table[^>]*>/i.test(comp.template)) missingGgs.push('<table>');
return {
studioId: comp.studioId,
componentName: comp.name,
tagName: 'div',
childGgsComponents: childGgs,
missingGgsComponents: missingGgs,
hasNgFor: /\*ngfor|@for/i.test(html),
hasFormControls: /<input|<select|<textarea|ggs-input|ggs-select/i.test(html),
computedStyles: {},
boundingRect: { x: 0, y: 0, width: 0, height: 0 },
};
}
private mergeResults(ruleResults: PviAnalysisResult, llmResults: { components: Array<{ componentName: string; suggestions: Array<any> }> }): PviAnalysisResult {
const merged = { ...ruleResults };
for (const llmComp of llmResults.components) {
const ruleComp = merged.components.find(c => c.componentName === llmComp.componentName);
if (!ruleComp) continue;
const llmSuggestions: Suggestion[] = llmComp.suggestions.map((s: any, i: number) => ({
id: `pvi-llm-${llmComp.componentName}-${i}`,
category: (s.category || 'ux') as SuggestionCategory,
label: s.label || 'LLM suggestion',
description: s.description || '',
impact: (s.impact || 'medium') as 'high' | 'medium' | 'low',
applyType: (s.applyType || 'structural') as 'style' | 'structural',
source: 'llm' as const,
conflicts: [],
structuralChange: s.codeHint ? { prompt: s.codeHint, affectedFiles: [] } : undefined,
}));
ruleComp.suggestions = mergeSuggestions(ruleComp.suggestions, llmSuggestions);
ruleComp.dotType = ruleComp.suggestions.some(s => s.impact === 'high') ? 'high-impact' : 'enhancement';
}
// Rebuild summary
const allSuggestions = merged.components.flatMap(c => c.suggestions);
const byCategory: Record<string, number> = {};
for (const s of allSuggestions) {
byCategory[s.category] = (byCategory[s.category] || 0) + 1;
}
merged.summary = {
total: allSuggestions.length,
byCategory: byCategory as Record<SuggestionCategory, number>,
topThree: allSuggestions.slice(0, 3),
};
return merged;
}
private toPascalCase(str: string): string {
return str.replace(/(^|-)([a-z])/g, (_, _p, c) => c.toUpperCase());
}
}
- Step 3: Wire proactive analysis into generation completion
In StudioDockShellComponent, subscribe to generationListener.filesReadyForReview$. After files are loaded:
this.generationListener.filesReadyForReview$.pipe(
takeUntilDestroyed(this.destroyRef),
).subscribe(() => {
// Trigger proactive PVI analysis after generation
const vfsFiles = this.vfs.serialize(); // Get all VFS file contents
this.proactiveAnalysis.analyzeGeneration(vfsFiles).subscribe(result => {
this.lastAnalysisResult.set(result);
});
});
Inject PviProactiveAnalysisService and VfsService into the component. Check VfsService for a serialize() or equivalent method that returns Record<string, string>.
Task 4: Chat summary component (V2-3)
Depends on Task 3 (analysis result).
Files:
Create:
libs/libs-structural/vitto-studio/src/lib/components/pvi-chat-summary/pvi-chat-summary.component.tsCreate:
libs/libs-structural/vitto-studio/src/lib/components/pvi-chat-summary/pvi-chat-summary.component.htmlCreate:
libs/libs-structural/vitto-studio/src/lib/components/pvi-chat-summary/pvi-chat-summary.component.scssStep 1: Create PviChatSummaryComponent
import { Component, ChangeDetectionStrategy, inject, input, output } from '@angular/core';
import type { PviAnalysisResult, Suggestion } from '@libs-ai/proactive-visual-intelligence';
@Component({
selector: 'lib-pvi-chat-summary',
standalone: true,
templateUrl: './pvi-chat-summary.component.html',
styleUrl: './pvi-chat-summary.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PviChatSummaryComponent {
readonly analysisResult = input.required<PviAnalysisResult>();
readonly applySuggestion = output<Suggestion>();
readonly applyAllTop3 = output<void>();
getCategoryIcon(category: string): string {
const icons: Record<string, string> = {
style: 'palette', layout: 'dashboard', component: 'extension',
ux: 'auto_awesome', data: 'analytics', accessibility: 'accessibility_new',
};
return icons[category] || 'lightbulb';
}
}
- Step 2: Create template
<div class="pvi-chat-summary">
<div class="pvi-summary-header">
<span class="pvi-summary-icon">auto_awesome</span>
<span>I found {{ analysisResult().summary.total }} enhancements for your platform:</span>
</div>
<div class="pvi-summary-list">
@for (suggestion of analysisResult().summary.topThree; track suggestion.id) {
<div class="pvi-summary-item">
<span class="pvi-category-icon material-symbols-outlined">{{ getCategoryIcon(suggestion.category) }}</span>
<div class="pvi-summary-content">
<span class="pvi-summary-label">{{ suggestion.label }}</span>
<span class="pvi-summary-impact impact-{{ suggestion.impact }}">{{ suggestion.impact }}</span>
</div>
<button class="pvi-summary-apply" (click)="applySuggestion.emit(suggestion)">Apply</button>
</div>
}
</div>
@if (analysisResult().summary.topThree.length > 0) {
<button class="pvi-apply-all-btn" (click)="applyAllTop3.emit()">
Apply All Top {{ analysisResult().summary.topThree.length }}
</button>
}
@if (analysisResult().summary.total > 3) {
<div class="pvi-more-hint">
+ {{ analysisResult().summary.total - 3 }} more — click the dots in the preview to explore.
</div>
}
</div>
- Step 3: Create styles
Dark theme, card-like appearance, matching vitto chat message aesthetics. Use var(--ux-*) tokens. Entry animation. Impact badges color-coded.
- Step 4: Wire into studio-dock-shell
Add PviChatSummaryComponent to imports. Show it when lastAnalysisResult() is populated. Wire (applySuggestion) to onPviApply() and (applyAllTop3) to a new onPviApplyAll() method.
Task 5: Apply All with conflict detection (V2-5)
Independent. Extends existing PviApplyService.
Files:
Modify:
libs/libs-structural/vitto-studio/src/lib/services/pvi-apply.service.tsStep 1: Add applyAll method
/**
* [S263-V2] Apply multiple suggestions in order with conflict detection.
* Style changes batched into single CSS injection. Structural changes sequential.
*/
applyAll(suggestions: Suggestion[]): { applied: string[]; skipped: Array<{ label: string; reason: string }> } {
const sorted = [...suggestions].sort((a, b) => {
const impact = { high: 3, medium: 2, low: 1 };
return impact[b.impact] - impact[a.impact];
});
const applied: string[] = [];
const skipped: Array<{ label: string; reason: string }> = [];
const appliedIds = new Set<string>();
// Create single batch snapshot
const editContext = this.visualEditor.editingContext();
if (editContext) {
const files: Record<string, string> = {};
if (editContext.paths.stylesPath) files[editContext.paths.stylesPath] = editContext.stylesSource || '';
if (editContext.paths.templatePath) files[editContext.paths.templatePath] = editContext.templateSource || '';
if (editContext.paths.classPath) files[editContext.paths.classPath] = editContext.classSource || '';
this.snapshots.createSnapshot('Apply All', Object.keys(files), files);
}
for (const suggestion of sorted) {
// Check conflicts
const hasConflict = suggestion.conflicts.some(cid => appliedIds.has(cid));
if (hasConflict) {
skipped.push({ label: suggestion.label, reason: 'Conflicts with an already-applied suggestion' });
continue;
}
const result = suggestion.applyType === 'style'
? this.applyStyle(suggestion)
: this.applyStructural(suggestion);
if (result.success) {
applied.push(suggestion.label);
appliedIds.add(suggestion.id);
} else {
skipped.push({ label: suggestion.label, reason: result.error || 'Failed to apply' });
}
}
return { applied, skipped };
}
Task 6: On-click LLM enrichment (V2-4)
Independent. Extends PVI with LLM-powered suggestions on element click.
Files:
Create:
libs/libs-structural/vitto-studio/src/lib/services/pvi-enrichment.service.tsModify:
libs/libs-structural/vitto-studio/src/lib/services/studio-vitto-bridge.service.tsStep 1: Add pviEnrich method to StudioVittoBridgeService
/**
* [S263-V2] Request LLM enrichment for a specific component + category.
*/
pviEnrich(request: {
componentName: string;
category: string;
template: string;
styles: string;
manifest: unknown;
}): Observable<{
suggestions: Array<{ label: string; description: string; impact: string; applyType: string; codeHint?: string }>;
}> {
return this.http.post<any>(`${this.vittoApiUrl}/pvi/enrich`, request).pipe(
timeout(5000),
catchError(() => of({ suggestions: [] })),
);
}
- Step 2: Create PviEnrichmentService
import { Injectable, inject } from '@angular/core';
import { Subject, switchMap, debounceTime, tap, catchError, of, EMPTY } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import type { Suggestion, SuggestionCategory, ElementSelectionPayload } from '@libs-ai/proactive-visual-intelligence';
import { mergeSuggestions } from '@libs-ai/proactive-visual-intelligence';
import { PviService } from './pvi.service';
import { VisualEditorService } from './visual-editor.service';
import { StudioVittoBridgeService } from './studio-vitto-bridge.service';
import sdkManifest from '@libs-ai/proactive-visual-intelligence/lib/data/sdk-manifest.json';
@Injectable({ providedIn: 'root' })
export class PviEnrichmentService {
private readonly pvi = inject(PviService);
private readonly visualEditor = inject(VisualEditorService);
private readonly vittoBridge = inject(StudioVittoBridgeService);
/** Cache: componentName:category → Suggestion[] */
private readonly cache = new Map<string, Suggestion[]>();
/** Enrichment request trigger */
private readonly enrichTrigger$ = new Subject<{ element: ElementSelectionPayload; category: SuggestionCategory }>();
constructor() {
// 500ms debounce, cancel previous on new click
this.enrichTrigger$.pipe(
debounceTime(500),
switchMap(({ element, category }) => {
const cacheKey = `${element.componentName}:${category}`;
if (this.cache.has(cacheKey)) {
return of({ cached: true, suggestions: this.cache.get(cacheKey)! });
}
const editContext = this.visualEditor.editingContext();
if (!editContext) return EMPTY;
return this.vittoBridge.pviEnrich({
componentName: element.componentName,
category,
template: editContext.templateSource || '',
styles: editContext.stylesSource || '',
manifest: (sdkManifest as any).components || {},
}).pipe(
map(result => ({
cached: false,
suggestions: result.suggestions.map((s, i) => ({
id: `pvi-enrich-${element.componentName}-${i}`,
category,
label: s.label,
description: s.description,
impact: (s.impact || 'medium') as 'high' | 'medium' | 'low',
applyType: (s.applyType || 'structural') as 'style' | 'structural',
source: 'llm' as const,
conflicts: [],
structuralChange: s.codeHint ? { prompt: s.codeHint, affectedFiles: [] } : undefined,
}) as Suggestion),
})),
catchError(() => of({ cached: false, suggestions: [] as Suggestion[] })),
);
}),
takeUntilDestroyed(),
).subscribe(({ suggestions }) => {
if (suggestions.length > 0) {
// Merge with existing rule suggestions
const current = this.pvi.suggestions();
const merged = mergeSuggestions(current, suggestions);
this.pvi.suggestions.set(merged);
}
});
}
/**
* Request enrichment for the current element + category.
* Debounced, cached, cancels previous in-flight request.
*/
requestEnrichment(element: ElementSelectionPayload, category: SuggestionCategory): void {
this.enrichTrigger$.next({ element, category });
}
/** Invalidate cache for a component (after VFS change) */
invalidateCache(componentName: string): void {
for (const key of this.cache.keys()) {
if (key.startsWith(componentName + ':')) {
this.cache.delete(key);
}
}
}
}
Add missing map import from rxjs/operators.
- Step 3: Wire enrichment into PviService.selectCategory
In pvi.service.ts, inject PviEnrichmentService and trigger enrichment when user selects a category:
selectCategory(category: SuggestionCategory): void {
const newCat = this.activeCategory() === category ? null : category;
this.activeCategory.set(newCat);
// [S263-V2] Trigger LLM enrichment if <3 suggestions for this category
if (newCat) {
const current = this.suggestions().filter(s => s.category === newCat);
if (current.length < 3) {
const element = this.elementContext();
if (element) {
this.enrichment.requestEnrichment(element, newCat);
}
}
}
}
Task 7: PVI backend — model router + endpoints (V2-1, V2-4 backend)
Independent. Backend services for LLM routing.
Files:
Create:
APIs/backend-vitto-api/src/app/engine/pvi/pvi.module.tsCreate:
APIs/backend-vitto-api/src/app/engine/pvi/pvi-model-router.service.tsCreate:
APIs/backend-vitto-api/src/app/engine/pvi/pvi.controller.tsStep 1: Create PviModelRouterService
import { Injectable } from '@nestjs/common';
interface PviModelRoute {
model: 'haiku' | 'sonnet' | 'opus';
maxTokens: number;
temperature: number;
}
const PVI_ROUTES: Record<string, PviModelRoute> = {
'pvi:component-scan': { model: 'haiku', maxTokens: 2048, temperature: 0.2 },
'pvi:enrich-simple': { model: 'haiku', maxTokens: 2048, temperature: 0.3 },
'pvi:enrich-complex': { model: 'sonnet', maxTokens: 4096, temperature: 0.3 },
'pvi:structural-apply': { model: 'sonnet', maxTokens: 8192, temperature: 0.1 },
};
@Injectable()
export class PviModelRouterService {
getRoute(task: string, componentComplexity?: 'simple' | 'complex'): PviModelRoute {
if (task === 'pvi:enrich' && componentComplexity) {
return PVI_ROUTES[`pvi:enrich-${componentComplexity}`] || PVI_ROUTES['pvi:enrich-simple'];
}
return PVI_ROUTES[task] || PVI_ROUTES['pvi:component-scan'];
}
classifyComplexity(componentName: string): 'simple' | 'complex' {
const complexPatterns = ['table', 'form', 'dashboard', 'shell', 'list', 'grid'];
return complexPatterns.some(p => componentName.toLowerCase().includes(p)) ? 'complex' : 'simple';
}
}
- Step 2: Create PviController
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { PviModelRouterService } from './pvi-model-router.service';
@Controller('pvi')
export class PviController {
constructor(private readonly modelRouter: PviModelRouterService) {}
@Post('scan')
@HttpCode(HttpStatus.OK)
async scan(@Body() dto: {
components: Array<{ componentName: string; template: string; styles: string; manifest: unknown }>;
}): Promise<{ components: Array<{ componentName: string; suggestions: Array<unknown> }> }> {
// TODO: Wire to BaseLLMProvider with PviModelRouterService for actual LLM calls
// For now, return empty suggestions (rule engine handles V1 suggestions client-side)
return {
components: dto.components.map(c => ({
componentName: c.componentName,
suggestions: [],
})),
};
}
@Post('enrich')
@HttpCode(HttpStatus.OK)
async enrich(@Body() dto: {
componentName: string;
category: string;
template: string;
styles: string;
manifest: unknown;
}): Promise<{ suggestions: Array<unknown> }> {
// TODO: Wire to BaseLLMProvider
return { suggestions: [] };
}
}
- Step 3: Create PviModule and register in EngineModule
import { Module } from '@nestjs/common';
import { PviController } from './pvi.controller';
import { PviModelRouterService } from './pvi-model-router.service';
@Module({
controllers: [PviController],
providers: [PviModelRouterService],
exports: [PviModelRouterService],
})
export class PviModule {}
Register PviModule in the parent EngineModule imports. Find engine.module.ts and add PviModule to imports.
Task 8: Learning layer — event tracking + weight aggregation (V2-6)
Independent. Backend services for preference learning.
Files:
Create:
APIs/backend-vitto-api/src/app/engine/pvi/user-suggestion-tracker.service.tsCreate:
APIs/backend-vitto-api/src/app/engine/pvi/suggestion-weight.service.tsModify:
APIs/backend-vitto-api/src/app/engine/pvi/pvi.controller.tsModify:
libs/libs-ai/proactive-visual-intelligence/src/lib/models/pvi.types.tsStep 1: Add UserSuggestionEvent type to pvi.types.ts
export interface UserSuggestionEvent {
userId: string;
spaceId: string;
universeId: string;
componentType: string;
category: SuggestionCategory;
suggestionLabel: string;
action: 'viewed' | 'applied' | 'dismissed' | 'undone';
source: 'rule' | 'llm' | 'merged';
timestamp: Date;
}
- Step 2: Create UserSuggestionTrackerService
import { Injectable, Logger } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';
@Injectable()
export class UserSuggestionTrackerService {
private readonly logger = new Logger('PviTracker');
constructor(
@InjectConnection('vitto-mind')
private readonly connection: Connection,
) {}
async record(event: {
userId: string;
spaceId: string;
universeId: string;
componentType: string;
category: string;
suggestionLabel: string;
action: 'viewed' | 'applied' | 'dismissed' | 'undone';
source: 'rule' | 'llm' | 'merged';
}): Promise<void> {
try {
await this.connection.collection('pvi_user_events').insertOne({
...event,
timestamp: new Date(),
});
} catch (err) {
this.logger.warn(`Failed to record PVI event: ${(err as Error).message}`);
}
}
}
- Step 3: Create SuggestionWeightService
import { Injectable, Logger } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';
import { Cron } from '@nestjs/schedule';
const DEFAULT_WEIGHTS = { style: 0.30, layout: 0.25, component: 0.20, ux: 0.15, data: 0.05, accessibility: 0.05 };
@Injectable()
export class SuggestionWeightService {
private readonly logger = new Logger('PviWeights');
constructor(
@InjectConnection('vitto-mind')
private readonly connection: Connection,
) {}
async getWeights(userId: string): Promise<Record<string, number>> {
const doc = await this.connection.collection('pvi_user_preferences').findOne({ userId });
return doc?.weights || DEFAULT_WEIGHTS;
}
/** Daily aggregation — compute weights from event history */
@Cron('0 7 * * *')
async aggregateWeights(): Promise<void> {
try {
const pipeline = [
{ $match: { action: 'applied' } },
{ $group: { _id: { userId: '$userId', category: '$category' }, count: { $sum: 1 } } },
{ $group: { _id: '$_id.userId', categories: { $push: { category: '$_id.category', count: '$count' } } } },
];
const results = await this.connection.collection('pvi_user_events').aggregate(pipeline).toArray();
for (const user of results) {
const total = user.categories.reduce((sum: number, c: any) => sum + c.count, 0);
if (total < 10) continue; // Not enough data
const weights = { ...DEFAULT_WEIGHTS };
for (const c of user.categories) {
weights[c.category as string] = c.count / total;
}
await this.connection.collection('pvi_user_preferences').updateOne(
{ userId: user._id },
{ $set: { weights, updatedAt: new Date() } },
{ upsert: true },
);
}
this.logger.log(`PVI weight aggregation complete for ${results.length} users`);
} catch (err) {
this.logger.warn(`PVI weight aggregation failed: ${(err as Error).message}`);
}
}
}
- Step 4: Add tracking endpoint to PviController
@Post('track')
@HttpCode(HttpStatus.OK)
async track(@Body() dto: {
userId: string;
spaceId: string;
universeId: string;
componentType: string;
category: string;
suggestionLabel: string;
action: 'viewed' | 'applied' | 'dismissed' | 'undone';
source: 'rule' | 'llm' | 'merged';
}): Promise<{ tracked: boolean }> {
await this.tracker.record(dto);
return { tracked: true };
}
@Get('weights/:userId')
async getWeights(@Param('userId') userId: string): Promise<{ weights: Record<string, number> }> {
const weights = await this.weightService.getWeights(userId);
return { weights };
}
Add UserSuggestionTrackerService and SuggestionWeightService to PviModule providers and inject into controller.
Task 9: Hetzner snapshot persistence + Timeline UI (V2-7)
Independent. Extends PviSnapshotService with async Hetzner pushes.
Files:
Modify:
libs/libs-structural/vitto-studio/src/lib/services/pvi-snapshot.service.tsCreate:
libs/libs-structural/vitto-studio/src/lib/components/pvi-timeline/pvi-timeline.component.tsCreate:
libs/libs-structural/vitto-studio/src/lib/components/pvi-timeline/pvi-timeline.component.htmlCreate:
libs/libs-structural/vitto-studio/src/lib/components/pvi-timeline/pvi-timeline.component.scssStep 1: Add Hetzner push to PviSnapshotService
Inject StudioVittoBridgeService (which can call the git service). Add async push after each snapshot:
/**
* [S263-V2] Async push snapshot to Hetzner git. Non-blocking, fire-and-forget.
* 3x retry with exponential backoff. Failure is non-blocking.
*/
pushToHetzner(snapshot: VfsSnapshot, repoPath: string): void {
const files = Object.entries(snapshot.fileContents).map(([path, content]) => ({
path,
content,
}));
this.vittoBridge.commitToHetzner(repoPath, files, `[PVI] ${snapshot.label}`).pipe(
retry({ count: 3, delay: (err, retryCount) => timer(1000 * Math.pow(2, retryCount)) }),
).subscribe({
next: (result) => {
this.updateSnapshotStatus(snapshot.id, 'pushed', result.sha);
},
error: () => {
this.updateSnapshotStatus(snapshot.id, 'failed');
},
});
}
private updateSnapshotStatus(snapshotId: string, status: 'pushed' | 'failed', sha?: string): void {
this.snapshots.update(list =>
list.map(s => s.id === snapshotId ? { ...s, hetznerPushStatus: status, hetznerCommitSha: sha } : s),
);
}
- Step 2: Create PviTimelineComponent
Horizontal strip below the preview. Each dot = a snapshot. Current position highlighted. Labels on hover. Click to jump.
@Component({
selector: 'lib-pvi-timeline',
standalone: true,
templateUrl: './pvi-timeline.component.html',
styleUrl: './pvi-timeline.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PviTimelineComponent {
readonly snapshots = inject(PviSnapshotService);
readonly restoreToSnapshot = output<number>();
onDotClick(index: number): void {
this.restoreToSnapshot.emit(index);
}
formatTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
Template: horizontal flex strip with dots, tooltip on hover, active state highlight.
- Step 3: Wire timeline into studio-dock-shell template
Add <lib-pvi-timeline (restoreToSnapshot)="onTimelineRestore($event)" /> below the preview area. Handle the restore event by popping/restoring to the target snapshot index.
Summary
| Task | V2 Deliverable | Risk | Dependencies |
|---|---|---|---|
| 1 | Outbound PVI messaging | Low | None |
| 2 | Ambient dots (V2-2) | Low | Task 1 |
| 3 | Proactive analysis (V2-1) | Medium — touches gen pipeline | Tasks 1, 2 |
| 4 | Chat summary (V2-3) | Low | Task 3 |
| 5 | Apply All (V2-5) | Low | None |
| 6 | LLM enrichment (V2-4) | Medium — debounce/cache | None |
| 7 | Backend PVI module (V2-1, V2-4) | Low — stub endpoints | None |
| 8 | Learning layer (V2-6) | Low — additive | Task 7 |
| 9 | Timeline + Hetzner (V2-7) | Medium — async git | None |
Tasks 1→2→3→4 form the main chain. Tasks 5, 6, 7, 8, 9 are independent and can be parallelized.
V2-8 (complete .intelligence.ts coverage) is mechanical and not included — it's 37 more component intelligence files following the same pattern as the 10 in sdk-manifest.json.