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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"api:start": "node api/server.js"
},
"dependencies": {
"@axe-core/react": "^4.11.3",
"@sentry/browser": "8.54.0",
"@sentry/react": "8.54.0",
"@tensorflow/tfjs-node": "^4.22.0",
Expand All @@ -62,6 +63,8 @@
"d3-shape": "^3.2.0",
"d3-time-format": "^4.1.0",
"d3-zoom": "^3.0.0",
"date-fns": "^3.6.0",
"express": "^4.18.2",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"idb": "^8.0.3",
Expand Down
153 changes: 153 additions & 0 deletions src/components/templates/TemplateRecommender.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
recommendTemplates,
requirementSignature,
} from '../../lib/templateRecommendation'
import { templateFeedbackStore } from '../../lib/templateFeedbackStore'
import TemplateCustomizer from './TemplateCustomizer'

/**
* TemplateRecommender — Issue #563
*
* Lets a user describe what they want to build, shows ranked contract-template
* suggestions with the reasons each was picked, and hands the chosen template
* off to the existing TemplateCustomizer. Choosing a template records feedback
* so future suggestions for similar needs improve.
*/
export default function TemplateRecommender() {
const [description, setDescription] = useState('')
const [category, setCategory] = useState('')
const [tagsInput, setTagsInput] = useState('')
const [results, setResults] = useState([])
const [chosen, setChosen] = useState(null)
const [searched, setSearched] = useState(false)

// Warm the feedback store once so getBoost has data to read.
useEffect(() => {
templateFeedbackStore.initialize().catch(() => {
// Non-fatal: recommender still works without persisted feedback.
})
}, [])

const requirement = useMemo(() => {
const tags = tagsInput
.split(',')
.map((t) => t.trim())
.filter(Boolean)
return {
description: description.trim() || undefined,
category: category || undefined,
tags: tags.length ? tags : undefined,
}
}, [description, category, tagsInput])

const handleRecommend = () => {
const ranked = recommendTemplates(requirement, {
boost: templateFeedbackStore.getBoost,
limit: 5,
})
setResults(ranked)
setSearched(true)
setChosen(null)
}

const handleChoose = (template) => {
const signature = requirementSignature(requirement)
templateFeedbackStore.recordChoice(signature, template.id).catch(() => {
// Non-fatal: choosing still proceeds even if persistence fails.
})
setChosen(template)
}

if (chosen) {
return (
<div className="template-recommender">
<button type="button" onClick={() => setChosen(null)}>
← Back to suggestions
</button>
<TemplateCustomizer template={chosen} />
</div>
)
}

return (
<div className="template-recommender">
<h3>Find the right contract template</h3>
<p>Describe what you want to build and we&apos;ll suggest a starting point.</p>

<label htmlFor="recommender-description">What are you building?</label>
<textarea
id="recommender-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. hold buyer funds until delivery, with an arbiter for disputes"
rows={3}
/>

<div className="recommender-filters">
<label htmlFor="recommender-category">Category (optional)</label>
<select
id="recommender-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">Any</option>
<option value="token">Token</option>
<option value="escrow">Escrow</option>
<option value="governance">Governance</option>
<option value="nft">NFT</option>
<option value="utility">Utility</option>
</select>

<label htmlFor="recommender-tags">Tags (optional, comma-separated)</label>
<input
id="recommender-tags"
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="e.g. staking, rewards"
/>
</div>

<button type="button" onClick={handleRecommend}>
Suggest templates
</button>

{searched && results.length === 0 && (
<p className="recommender-empty">
No matching templates yet. Try describing your goal differently or add tags.
</p>
)}

{results.length > 0 && (
<ul className="recommender-results">
{results.map((r) => (
<li key={r.template.id} className="recommender-result">
<div className="recommender-result-header">
<strong>{r.template.name}</strong>
{r.template.status === 'wip' && (
<span className="recommender-wip-badge"> (work in progress)</span>
)}
</div>
<p>{r.template.description}</p>
{r.reasons.length > 0 && (
<ul className="recommender-reasons">
{r.reasons.map((reason, i) => (
<li key={i}>{reason}</li>
))}
</ul>
)}
<button
type="button"
onClick={() => handleChoose(r.template)}
disabled={r.template.status === 'wip'}
>
{r.template.status === 'wip' ? 'Coming soon' : 'Use this template'}
</button>
</li>
))}
</ul>
)}
</div>
)
}
134 changes: 134 additions & 0 deletions src/lib/templateFeedbackStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* templateFeedbackStore.ts — Issue #563
*
* Records which template a user actually chose for a given requirement, so the
* recommendation engine can boost templates that proved useful for similar
* needs. This is the "recommendations improve with usage" mechanism.
*
* Persistence uses IndexedDB, mirroring the pattern in templateLibrary.ts, with
* an in-memory fallback so the store works in tests and SSR without IndexedDB.
* The boost lookup exposed here is synchronous by design: the engine's scoring
* is pure and synchronous, so the store keeps a warmed in-memory index that the
* engine reads, and persists changes to IndexedDB in the background.
*/

const FEEDBACK_DB_NAME = 'stellar-dev-dashboard-template-feedback';
const FEEDBACK_DB_VERSION = 1;
const STORE = 'choices';

/** One recorded choice: for requirement `signature`, the user picked `templateId`. */
export interface FeedbackRecord {
/** `${signature}::${templateId}` — the primary key. */
key: string;
signature: string;
templateId: string;
count: number;
updatedAt: string;
}

function hasIndexedDb(): boolean {
return typeof indexedDB !== 'undefined';
}

export class TemplateFeedbackStore {
private db: IDBDatabase | null = null;
/** Warmed in-memory index: key -> count. Read synchronously by the engine. */
private readonly counts = new Map<string, number>();
private ready = false;

/** Open IndexedDB (if available) and warm the in-memory index. Idempotent. */
async initialize(): Promise<void> {
if (this.ready) return;
if (hasIndexedDb()) {
await this.openDb();
await this.warmFromDb();
}
this.ready = true;
}

private openDb(): Promise<void> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(FEEDBACK_DB_NAME, FEEDBACK_DB_VERSION);
req.onerror = () => reject(req.error);
req.onsuccess = () => {
this.db = req.result;
resolve();
};
req.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: 'key' });
store.createIndex('signature', 'signature', { unique: false });
}
};
});
}

private warmFromDb(): Promise<void> {
if (!this.db) return Promise.resolve();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction([STORE], 'readonly');
const req = tx.objectStore(STORE).getAll();
req.onsuccess = () => {
for (const rec of req.result as FeedbackRecord[]) {
this.counts.set(rec.key, rec.count);
}
resolve();
};
req.onerror = () => reject(req.error);
});
}

private static composeKey(signature: string, templateId: string): string {
return `${signature}::${templateId}`;
}

/**
* Record that `templateId` was chosen for the requirement `signature`.
* Updates the in-memory index immediately and persists in the background.
*/
async recordChoice(signature: string, templateId: string): Promise<void> {
if (!this.ready) await this.initialize();
const key = TemplateFeedbackStore.composeKey(signature, templateId);
const next = (this.counts.get(key) ?? 0) + 1;
this.counts.set(key, next);

if (!this.db) return;
const record: FeedbackRecord = {
key,
signature,
templateId,
count: next,
updatedAt: new Date().toISOString(),
};
return new Promise((resolve, reject) => {
const tx = this.db!.transaction([STORE], 'readwrite');
const req = tx.objectStore(STORE).put(record);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}

/**
* Synchronous boost lookup for the recommendation engine: how many times
* `templateId` was chosen for this exact requirement signature.
*/
getBoost = (signature: string, templateId: string): number => {
return this.counts.get(TemplateFeedbackStore.composeKey(signature, templateId)) ?? 0;
};

/** Clear all feedback (both memory and IndexedDB). Mainly for tests/settings. */
async clear(): Promise<void> {
this.counts.clear();
if (!this.db) return;
return new Promise((resolve, reject) => {
const tx = this.db!.transaction([STORE], 'readwrite');
const req = tx.objectStore(STORE).clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
}

/** Shared singleton used by the UI. Tests can construct their own instance. */
export const templateFeedbackStore = new TemplateFeedbackStore();
Loading
Loading