feat(skills): add RealSkills living endpoint for agent Q&A

Implements a skills endpoint that evolves through agent conversations:

- GET /:username/collections/:slug/skills - Returns markdown skill summary
- POST /:username/collections/:slug/skills - Ask questions, get RAG+LLM responses

Features:
- OpenAI text-embedding-3-large (3072 dims) for semantic similarity
- GPT-4.1-mini for response generation with RAG context
- Lazy seeding of synthetic questions on first access
- Cache hits for >95% similar questions
- Real-time skill graph updates (emergent skill taxonomy)
- Session support for multi-turn conversations
- Activity feed and stats APIs for UI

Database models: SkillQuestion, Skill, SkillSession, SkillQuestionSkill, SkillQuestionTool
This commit is contained in:
Ajax Davis 2026-01-25 02:37:02 +10:00
parent 0489646e55
commit 1118463e6b
13 changed files with 3001 additions and 8 deletions

View file

@ -12,6 +12,7 @@ import { ForkedFromBadge } from '~/components/ForkedFromBadge';
import { LikeButton } from '~/components/LikeButton';
import { ScenariosSection } from '~/components/ScenariosSection';
import { ShareButton } from '~/components/ShareButton';
import { SkillsSection } from '~/components/skills/SkillsSection';
import { UseCasesSection } from '~/components/UseCasesSection';
import { useSession } from '~/lib/auth-client';
@ -412,6 +413,15 @@ export function CollectionDetailClient({
slug={collection.slug}
/>
)}
{/* Skills Section */}
{collection.tools.length > 0 && (
<SkillsSection
collectionId={collection.id}
username={username}
slug={collection.slug}
/>
)}
</div>
</main>
</div>

View file

@ -0,0 +1,537 @@
/**
* RealSkills API Endpoint
*
* A living skills endpoint that evolves through agent conversations.
* Skills emerge organically from question patterns.
*
* GET - Return skill summary markdown (triggers lazy seeding)
* POST - Ask a question (RAG + LLM response)
*/
import { createHash } from 'crypto';
import { prisma } from '@tpmjs/db';
import { NextResponse, type NextRequest } from 'next/server';
import { z } from 'zod';
import { checkQuestionSimilarity } from '~/lib/ai/skills-embedding';
import { updateSkillGraph, getCollectionSkillsSummary } from '~/lib/ai/skills-graph-updater';
import {
generateSkillResponse,
generateFollowupSuggestions,
calculateConfidence,
type CollectionContext,
} from '~/lib/ai/skills-response-generator';
import {
seedCollectionSkills,
getSeedingStatus,
type CollectionWithTools,
} from '~/lib/ai/skills-seeder';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
type RouteContext = {
params: Promise<{ username: string; slug: string }>;
};
// Request validation schemas
const PostRequestSchema = z.object({
question: z.string().min(5).max(2000),
sessionId: z.string().optional(),
agentName: z.string().max(100).optional(),
context: z.string().max(2000).optional(),
tags: z.array(z.string().max(50)).max(10).optional(),
});
// Session expiry time (24 hours)
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
/**
* Hash agent identity for anonymization
*/
function hashAgentIdentity(ip: string, userAgent: string): string {
return createHash('sha256')
.update(`${ip}:${userAgent}`)
.digest('hex')
.slice(0, 16);
}
/**
* Load collection with tools
*/
async function loadCollection(
username: string,
slug: string
): Promise<CollectionWithTools | NextResponse> {
const user = await prisma.user.findUnique({
where: { username },
select: { id: true, username: true },
});
if (!user || !user.username) {
return NextResponse.json(
{ success: false, error: 'User not found' },
{ status: 404 }
);
}
const collection = await prisma.collection.findFirst({
where: { slug, userId: user.id },
include: {
tools: {
include: {
tool: {
include: {
package: {
select: { npmPackageName: true, npmVersion: true },
},
},
},
},
orderBy: { position: 'asc' },
take: 100,
},
},
});
if (!collection) {
return NextResponse.json(
{ success: false, error: 'Collection not found' },
{ status: 404 }
);
}
if (!collection.isPublic) {
return NextResponse.json(
{ success: false, error: 'Collection is not public' },
{ status: 403 }
);
}
// Flatten the tools structure
const collectionWithTools = {
...collection,
tools: collection.tools.map((ct) => ({
...ct.tool,
package: ct.tool.package,
})),
};
return collectionWithTools as CollectionWithTools;
}
/**
* Ensure collection is seeded (lazy seeding)
*/
async function ensureSeeded(collection: CollectionWithTools): Promise<{
isSeeding: boolean;
wasSeeded: boolean;
}> {
const status = await getSeedingStatus(collection.id);
if (status.isSeeded) {
return { isSeeding: false, wasSeeded: false };
}
if (status.isSeeding) {
return { isSeeding: true, wasSeeded: false };
}
// Trigger seeding (non-blocking for GET, blocking for POST)
try {
const result = await seedCollectionSkills(collection);
return { isSeeding: false, wasSeeded: result.seeded };
} catch (error) {
console.error('[Skills] Seeding failed:', error);
// Continue without seeding - endpoint still works
return { isSeeding: false, wasSeeded: false };
}
}
/**
* GET /:username/collections/:slug/skills
*
* Returns skill summary as markdown.
* Triggers lazy seeding on first access.
*/
export async function GET(_request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
const { username: rawUsername, slug } = await context.params;
const username = rawUsername.startsWith('@')
? rawUsername.slice(1)
: rawUsername;
// Load collection
const result = await loadCollection(username, slug);
if (result instanceof NextResponse) return result;
const collection = result;
// Check/trigger seeding
const seedStatus = await ensureSeeded(collection);
if (seedStatus.isSeeding) {
return NextResponse.json(
{
success: true,
data: {
status: 'seeding',
message: 'Skills are being generated. Please retry in a few seconds.',
},
},
{
status: 202,
headers: { 'Retry-After': '10' },
}
);
}
// Get skill summary
const summary = await getCollectionSkillsSummary(collection.id);
// Build markdown response
const markdown = buildSkillsSummaryMarkdown(collection, summary, username);
return new Response(markdown, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'X-Skills-Total-Questions': summary.totalQuestions.toString(),
'X-Skills-Total-Skills': summary.totalSkills.toString(),
'X-Processing-Time-Ms': (Date.now() - startTime).toString(),
},
});
} catch (error) {
console.error('[Skills GET Error]:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
);
}
}
/**
* POST /:username/collections/:slug/skills
*
* Submit a question and get a skill-based response.
*/
export async function POST(request: NextRequest, context: RouteContext) {
const startTime = Date.now();
try {
const { username: rawUsername, slug } = await context.params;
const username = rawUsername.startsWith('@')
? rawUsername.slice(1)
: rawUsername;
// Parse and validate request body
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ success: false, error: 'Invalid JSON body' },
{ status: 400 }
);
}
const parseResult = PostRequestSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{
success: false,
error: 'Validation error',
details: parseResult.error.format(),
},
{ status: 400 }
);
}
const { question, sessionId, agentName, context: questionContext, tags } =
parseResult.data;
// Load collection
const result = await loadCollection(username, slug);
if (result instanceof NextResponse) return result;
const collection = result;
// Ensure seeded
await ensureSeeded(collection);
// Get agent identity
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const userAgent = request.headers.get('user-agent') || 'unknown';
const agentHash = hashAgentIdentity(ip, userAgent);
// Check for similarity / cache hit
const similarityResult = await checkQuestionSimilarity(
question,
collection.id
);
// If very similar question exists (>95%), return cached answer
if (similarityResult.isCacheHit && similarityResult.cachedAnswer) {
const cachedQuestion = similarityResult.similarQuestions[0];
return NextResponse.json({
success: true,
data: {
answer: similarityResult.cachedAnswer,
confidence: cachedQuestion?.similarity || 0.95,
basedOn: 1,
skillsIdentified: [],
cached: true,
},
meta: {
cached: true,
questionId: cachedQuestion?.id || null,
processingMs: Date.now() - startTime,
},
});
}
// Build collection context
const collectionContext: CollectionContext = {
collection,
tools: collection.tools,
skillsMarkdown: collection.skillsMarkdown,
};
// Get session history if session exists
let sessionHistory: Array<{ role: 'user' | 'assistant'; content: string }> =
[];
let activeSessionId = sessionId;
if (sessionId) {
const session = await prisma.skillSession.findUnique({
where: { id: sessionId },
});
if (session && session.collectionId === collection.id) {
sessionHistory = session.context as Array<{
role: 'user' | 'assistant';
content: string;
}>;
}
}
// Generate response
const fullQuestion = questionContext
? `${question}\n\nContext: ${questionContext}`
: question;
const { answer, tokensUsed } = await generateSkillResponse({
question: fullQuestion,
collectionContext,
similarQuestions: similarityResult.similarQuestions,
sessionHistory,
tags,
});
// Calculate confidence
const confidence = calculateConfidence(
similarityResult.similarQuestions,
!!collection.skillsMarkdown
);
// Store the question
const storedQuestion = await prisma.skillQuestion.create({
data: {
collectionId: collection.id,
question,
embedding: similarityResult.embedding as unknown as object,
answer,
answerTokens: tokensUsed,
agentHash,
agentName: agentName || null,
sessionId: activeSessionId,
confidence,
tags: tags || [],
},
});
// Update skill graph (best-effort, don't fail request)
let skillLinks: Array<{ skillId: string; skillName: string }> = [];
try {
const graphResult = await updateSkillGraph({
questionId: storedQuestion.id,
collectionId: collection.id,
question,
answer,
tools: collection.tools,
});
skillLinks = graphResult.skillLinks;
} catch (error) {
console.error('[Skills] Graph update failed:', error);
}
// Update or create session
if (sessionId || sessionHistory.length > 0) {
const newHistory = [
...sessionHistory,
{ role: 'user' as const, content: question },
{ role: 'assistant' as const, content: answer },
].slice(-20); // Keep last 20 messages
if (sessionId) {
await prisma.skillSession.update({
where: { id: sessionId },
data: {
context: newHistory,
updatedAt: new Date(),
},
});
} else {
const newSession = await prisma.skillSession.create({
data: {
collectionId: collection.id,
context: newHistory,
agentHash,
agentName,
expiresAt: new Date(Date.now() + SESSION_EXPIRY_MS),
},
});
activeSessionId = newSession.id;
}
}
// Generate follow-up suggestions (optional, don't block)
let suggestedFollowups: string[] = [];
try {
suggestedFollowups = await generateFollowupSuggestions(
question,
answer,
collection.name
);
} catch {
// Ignore errors for followups
}
return NextResponse.json({
success: true,
data: {
answer,
confidence,
basedOn: similarityResult.similarQuestions.length,
skillsIdentified: skillLinks.map((s) => s.skillName),
sessionId: activeSessionId,
suggestedFollowups,
},
meta: {
cached: false,
questionId: storedQuestion.id,
processingMs: Date.now() - startTime,
},
});
} catch (error) {
console.error('[Skills POST Error]:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
);
}
}
/**
* Build markdown summary of skills
*/
function buildSkillsSummaryMarkdown(
collection: CollectionWithTools,
summary: {
totalQuestions: number;
totalSkills: number;
topSkills: Array<{
name: string;
questionCount: number;
confidence: number;
}>;
},
username: string
): string {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://tpmjs.com';
const skillsUrl = `${baseUrl}/${username}/collections/${collection.slug}/skills`;
let markdown = `# Skills: ${collection.name}
> Skills, proven in the wild not declared on paper.
This collection has evolved through **${summary.totalQuestions} questions** from agents, identifying **${summary.totalSkills} distinct skills**.
## API Usage
\`\`\`bash
# Ask a question
curl -X POST ${skillsUrl} \\
-H "Content-Type: application/json" \\
-d '{"question": "How do I handle errors with these tools?"}'
\`\`\`
## Top Skills
`;
if (summary.topSkills.length > 0) {
for (const skill of summary.topSkills) {
const confidenceBar = '█'.repeat(Math.floor(skill.confidence * 10));
const confidenceEmpty = '░'.repeat(10 - Math.floor(skill.confidence * 10));
markdown += `- **${skill.name}** (${skill.questionCount} questions) ${confidenceBar}${confidenceEmpty}\n`;
}
} else {
markdown += `*No skills identified yet. Ask questions to start building the skill graph.*\n`;
}
markdown += `
## How It Works
1. **You ask a question** via POST
2. We find similar past questions (RAG)
3. We generate a tailored response
4. Your question helps evolve the skill graph
5. Future questions get better answers
## Request Schema
\`\`\`typescript
interface SkillsRequest {
question: string; // Required (5-2000 chars)
sessionId?: string; // For multi-turn conversations
agentName?: string; // Self-reported agent identity
context?: string; // Additional context (max 2000 chars)
tags?: string[]; // Hint tags (max 10)
}
\`\`\`
## Response Schema
\`\`\`typescript
interface SkillsResponse {
success: boolean;
data: {
answer: string; // Markdown response
confidence: number; // 0-1 confidence score
basedOn: number; // Similar questions used
skillsIdentified: string[];
sessionId?: string;
suggestedFollowups?: string[];
};
meta: {
cached: boolean;
questionId: string;
processingMs: number;
};
}
\`\`\`
---
*Last updated: ${new Date().toISOString()}*
`;
return markdown;
}

View file

@ -0,0 +1,81 @@
/**
* GET /api/skills/activity
*
* Fetch recent skill questions for a collection (anonymized for activity feed)
*/
import { prisma } from '@tpmjs/db';
import { NextResponse, type NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const collectionId = searchParams.get('collectionId');
const limitParam = searchParams.get('limit');
const limit = Math.min(50, Math.max(1, parseInt(limitParam || '10', 10)));
if (!collectionId) {
return NextResponse.json(
{ error: 'collectionId is required' },
{ status: 400 }
);
}
try {
// Verify collection exists and is public
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
select: { id: true, isPublic: true },
});
if (!collection) {
return NextResponse.json(
{ error: 'Collection not found' },
{ status: 404 }
);
}
if (!collection.isPublic) {
return NextResponse.json(
{ error: 'Collection is not public' },
{ status: 403 }
);
}
// Fetch recent questions with skill links (anonymized - no agent info)
const questions = await prisma.skillQuestion.findMany({
where: { collectionId },
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
question: true,
answer: true,
confidence: true,
similarCount: true,
tags: true,
createdAt: true,
// Include skills but not agent info for privacy
skillNodes: {
select: {
skill: {
select: {
name: true,
},
},
},
},
},
});
return NextResponse.json({ questions });
} catch (error) {
console.error('[Skills Activity Error]:', error);
return NextResponse.json(
{ error: 'Failed to fetch activity' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,73 @@
/**
* GET /api/skills/stats
*
* Get skill statistics for a collection
*/
import { prisma } from '@tpmjs/db';
import { NextResponse, type NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const collectionId = searchParams.get('collectionId');
if (!collectionId) {
return NextResponse.json(
{ error: 'collectionId is required' },
{ status: 400 }
);
}
try {
// Verify collection exists and is public
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
select: { id: true, isPublic: true },
});
if (!collection) {
return NextResponse.json(
{ error: 'Collection not found' },
{ status: 404 }
);
}
if (!collection.isPublic) {
return NextResponse.json(
{ error: 'Collection is not public' },
{ status: 403 }
);
}
// Fetch stats in parallel
const [totalQuestions, totalSkills, topSkills] = await Promise.all([
prisma.skillQuestion.count({ where: { collectionId } }),
prisma.skill.count({ where: { collectionId } }),
prisma.skill.findMany({
where: { collectionId },
orderBy: { questionCount: 'desc' },
take: 10,
select: {
name: true,
questionCount: true,
confidence: true,
},
}),
]);
return NextResponse.json({
totalQuestions,
totalSkills,
topSkills,
});
} catch (error) {
console.error('[Skills Stats Error]:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,521 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import type { Metadata } from 'next';
import Link from 'next/link';
export const metadata: Metadata = {
title: 'RealSkills API | TPMJS Docs',
description:
'A living skills endpoint that evolves through agent conversations. Skills emerge organically from question patterns.',
};
const exampleResponseJson = `{
"success": true,
"data": {
"answer": "To handle errors with these tools...",
"confidence": 0.85,
"basedOn": 3,
"skillsIdentified": ["error-handling", "try-catch-patterns"],
"sessionId": "sess_abc123",
"suggestedFollowups": [
"What are the retry patterns?",
"How do I log errors?"
]
},
"meta": {
"cached": false,
"questionId": "clx123abc456",
"processingMs": 1234
}
}`;
export default function SkillsPage(): React.ReactElement {
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold mb-4">RealSkills API</h1>
<p className="text-foreground-secondary text-lg">
Skills, proven in the wild not declared on paper.
</p>
<p className="text-foreground-secondary mt-2">
A living skills endpoint that evolves through agent conversations. Unlike static
documentation, skills emerge organically from question patterns and improve over time.
</p>
</div>
{/* Philosophy */}
<Card>
<CardHeader>
<CardTitle>Philosophy</CardTitle>
<CardDescription>Why living skills beats static skills.md</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Traditional documentation is written once and becomes outdated. RealSkills takes a
different approach:
</p>
<ul className="list-disc list-inside space-y-2 text-foreground-secondary">
<li>
<strong>Questions drive discovery</strong> Every agent question reveals what users
actually need
</li>
<li>
<strong>Answers compound</strong> Similar questions get better answers based on
previous responses
</li>
<li>
<strong>Skills emerge</strong> Patterns in questions automatically create skill
categories
</li>
<li>
<strong>Quality improves</strong> More questions = more context = better responses
</li>
</ul>
<p className="text-foreground-secondary">
Think of it as a knowledge base that learns from every interaction.
</p>
</CardContent>
</Card>
{/* How It Works */}
<Card>
<CardHeader>
<CardTitle>How It Works</CardTitle>
<CardDescription>The question skill inference loop</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-surface p-4 rounded-lg border border-border font-mono text-sm">
<pre className="whitespace-pre-wrap">{`Agent POSTs question
/skills endpoint
- Embed question
- Check similarity cache
- RAG from stored Q&A
- Generate response (LLM)
- Store question + answer
- Update skill graph
Return skill guidance (markdown)
Skill graph evolves in real-time`}</pre>
</div>
<ol className="list-decimal list-inside space-y-3 text-foreground-secondary mt-4">
<li>
<strong>Question Received</strong> Agent submits a question via POST
</li>
<li>
<strong>Embedding Generated</strong> Question is converted to a 3072-dimensional
vector
</li>
<li>
<strong>Similarity Check</strong> If &gt;95% similar to existing question, return
cached answer
</li>
<li>
<strong>RAG Context</strong> Find similar past questions/answers for context
</li>
<li>
<strong>Response Generation</strong> GPT-4.1-mini generates a tailored response
</li>
<li>
<strong>Storage & Graph Update</strong> Question stored, skills inferred and linked
</li>
</ol>
</CardContent>
</Card>
{/* API Reference */}
<Card>
<CardHeader>
<CardTitle>API Reference</CardTitle>
<CardDescription>GET and POST endpoints</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h4 className="font-semibold mb-2">GET /:username/collections/:slug/skills</h4>
<p className="text-foreground-secondary text-sm mb-3">
Returns the skill summary as markdown. Triggers lazy seeding on first access.
</p>
<CodeBlock
code={`curl https://tpmjs.com/ajaxdavis/collections/my-tools/skills`}
language="bash"
showCopy={true}
/>
</div>
<div>
<h4 className="font-semibold mb-2">POST /:username/collections/:slug/skills</h4>
<p className="text-foreground-secondary text-sm mb-3">
Submit a question and receive an AI-generated response based on the collection&apos;s
tools and previous Q&amp;A.
</p>
<CodeBlock
code={`curl -X POST https://tpmjs.com/ajaxdavis/collections/my-tools/skills \\
-H "Content-Type: application/json" \\
-d '{
"question": "How do I handle errors with these tools?",
"agentName": "my-agent",
"tags": ["error-handling"]
}'`}
language="bash"
showCopy={true}
/>
</div>
</CardContent>
</Card>
{/* Request Schema */}
<Card>
<CardHeader>
<CardTitle>Request Schema</CardTitle>
<CardDescription>POST request body format</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CodeBlock
code={`interface SkillsRequest {
// Required: The question to ask (5-2000 characters)
question: string;
// Optional: Session ID for multi-turn conversations
sessionId?: string;
// Optional: Self-reported agent identity
agentName?: string;
// Optional: Additional context (max 2000 chars)
context?: string;
// Optional: Hint tags to guide response (max 10)
tags?: string[];
}`}
language="typescript"
showCopy={true}
/>
<div className="bg-background-secondary p-4 rounded-lg mt-4">
<h4 className="font-semibold text-sm mb-2">Multi-Turn Conversations</h4>
<p className="text-foreground-secondary text-sm">
To continue a conversation, include the <code>sessionId</code> from a previous
response. Sessions maintain context for up to 24 hours and include the last 20
messages.
</p>
</div>
</CardContent>
</Card>
{/* Response Schema */}
<Card>
<CardHeader>
<CardTitle>Response Schema</CardTitle>
<CardDescription>Successful response format</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CodeBlock
code={`interface SkillsResponse {
success: boolean;
data: {
// Markdown-formatted response
answer: string;
// Confidence score (0-1)
confidence: number;
// Number of similar questions used for RAG
basedOn: number;
// Skills this question relates to
skillsIdentified: string[];
// Session ID for continuing conversation
sessionId?: string;
// Suggested follow-up questions
suggestedFollowups?: string[];
};
meta: {
// Whether response was from cache
cached: boolean;
// ID of stored question
questionId: string;
// Processing time in milliseconds
processingMs: number;
};
}`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold mt-4">Example Response</h4>
<CodeBlock
code={exampleResponseJson}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
{/* Integration Guide */}
<Card>
<CardHeader>
<CardTitle>Integration Guide</CardTitle>
<CardDescription>How agents should use the Skills API</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<h4 className="font-semibold">1. Initial Discovery</h4>
<p className="text-foreground-secondary text-sm mb-3">
When an agent first encounters a collection, fetch the skills summary:
</p>
<CodeBlock
code={`const response = await fetch(\`\${baseUrl}/\${username}/collections/\${slug}/skills\`);
const skillsMarkdown = await response.text();
// Parse or display the markdown summary`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold mt-6">2. Asking Questions</h4>
<p className="text-foreground-secondary text-sm mb-3">
When the agent needs guidance on using the tools:
</p>
<CodeBlock
code={`const response = await fetch(\`\${baseUrl}/\${username}/collections/\${slug}/skills\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: "How do I parse JSON responses from the API?",
agentName: "my-automation-agent",
tags: ["json", "parsing"]
})
});
const { data } = await response.json();
console.log(data.answer); // Use this guidance`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold mt-6">3. Multi-Turn Conversations</h4>
<p className="text-foreground-secondary text-sm mb-3">
For follow-up questions, use the session ID:
</p>
<CodeBlock
code={`// First question
const first = await askSkills("How do I handle pagination?");
const sessionId = first.data.sessionId;
// Follow-up (maintains context)
const followUp = await askSkills(
"Can you show me an example with async iteration?",
{ sessionId }
);`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Best Practices */}
<Card>
<CardHeader>
<CardTitle>Best Practices</CardTitle>
<CardDescription>Effective questioning patterns</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-green-500/10 p-4 rounded-lg">
<h5 className="font-semibold text-green-600 dark:text-green-400 text-sm mb-2">
Good Questions
</h5>
<ul className="text-foreground-secondary text-sm space-y-2">
<li> &quot;How do I handle rate limiting with the API tool?&quot;</li>
<li> &quot;What&apos;s the best way to batch multiple requests?&quot;</li>
<li> &quot;Can I use these tools with streaming responses?&quot;</li>
</ul>
</div>
<div className="bg-red-500/10 p-4 rounded-lg">
<h5 className="font-semibold text-red-600 dark:text-red-400 text-sm mb-2">
Avoid These
</h5>
<ul className="text-foreground-secondary text-sm space-y-2">
<li> &quot;Tell me everything about this collection&quot;</li>
<li> Single-word questions like &quot;Help&quot;</li>
<li> Questions unrelated to the collection&apos;s tools</li>
</ul>
</div>
</div>
<div className="bg-background-secondary p-4 rounded-lg mt-4">
<h4 className="font-semibold text-sm mb-2">Tips for Better Responses</h4>
<ul className="text-foreground-secondary text-sm space-y-1">
<li> Be specific about what you&apos;re trying to accomplish</li>
<li> Include relevant context in the <code>context</code> field</li>
<li> Use tags to hint at the problem domain</li>
<li> Use sessions for related follow-up questions</li>
</ul>
</div>
</CardContent>
</Card>
{/* Confidence Scores */}
<Card>
<CardHeader>
<CardTitle>Confidence Scores</CardTitle>
<CardDescription>How confidence is calculated</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Each response includes a confidence score (0-1) based on:
</p>
<ul className="list-disc list-inside space-y-2 text-foreground-secondary">
<li>
<strong>Base confidence (30%)</strong> Minimum for any generated response
</li>
<li>
<strong>Similar questions (up to 40%)</strong> More similar past Q&amp;A = higher
confidence
</li>
<li>
<strong>Skills documentation (20%)</strong> Collection has generated skills.md
</li>
<li>
<strong>Question volume (10%)</strong> 3+ similar questions adds bonus
</li>
</ul>
<div className="bg-background-secondary p-4 rounded-lg mt-4">
<h4 className="font-semibold text-sm mb-2">Interpreting Scores</h4>
<ul className="text-foreground-secondary text-sm space-y-1">
<li>
<code>&gt;0.8</code> High confidence, well-supported by prior Q&amp;A
</li>
<li>
<code>0.5-0.8</code> Moderate confidence, some relevant context
</li>
<li>
<code>&lt;0.5</code> Lower confidence, limited prior knowledge
</li>
</ul>
</div>
</CardContent>
</Card>
{/* Lazy Seeding */}
<Card>
<CardHeader>
<CardTitle>Lazy Seeding</CardTitle>
<CardDescription>Automatic bootstrapping on first access</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
When a collection&apos;s skills endpoint is accessed for the first time, it automatically
seeds with synthetic questions generated from:
</p>
<ul className="list-disc list-inside space-y-2 text-foreground-secondary">
<li>Existing skills.md documentation (if available)</li>
<li>Tool descriptions and capabilities</li>
<li>Common use case patterns for the tool category</li>
</ul>
<p className="text-foreground-secondary mt-4">
This ensures the endpoint is useful immediately, even before any real agent interactions.
Seeding typically adds 10-15 synthetic Q&amp;A pairs.
</p>
<div className="bg-background-secondary p-4 rounded-lg mt-4">
<h4 className="font-semibold text-sm mb-2">Seeding Status Response</h4>
<p className="text-foreground-secondary text-sm">
If seeding is in progress when you make a request, you&apos;ll receive a 202 response:
</p>
<CodeBlock
code={`{
"success": true,
"data": {
"status": "seeding",
"message": "Skills are being generated. Please retry in a few seconds."
}
}`}
language="json"
showCopy={false}
/>
</div>
</CardContent>
</Card>
{/* Caching */}
<Card>
<CardHeader>
<CardTitle>Caching Behavior</CardTitle>
<CardDescription>How similar questions are cached</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Questions with &gt;95% similarity to existing questions return cached answers instantly.
This provides:
</p>
<ul className="list-disc list-inside space-y-2 text-foreground-secondary">
<li>Faster response times (~50ms vs ~1-2s)</li>
<li>Reduced API costs</li>
<li>Consistent answers for equivalent questions</li>
</ul>
<p className="text-foreground-secondary mt-4">
The <code>meta.cached</code> field indicates whether a cached response was used. Cached
responses increment a <code>similarCount</code> counter for analytics.
</p>
</CardContent>
</Card>
{/* Next Steps */}
<Card>
<CardHeader>
<CardTitle>Next Steps</CardTitle>
<CardDescription>Continue exploring TPMJS</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-3">
<li>
<Link href="/docs/scenarios" className="text-primary hover:underline font-medium">
Scenarios Guide
</Link>
<p className="text-foreground-secondary text-sm">
Automated testing for tool collections
</p>
</li>
<li>
<Link href="/docs/api/collections" className="text-primary hover:underline font-medium">
Collections API
</Link>
<p className="text-foreground-secondary text-sm">
Create and manage tool collections
</p>
</li>
<li>
<Link href="/docs/agents" className="text-primary hover:underline font-medium">
Agents Documentation
</Link>
<p className="text-foreground-secondary text-sm">
Build AI agents with your collections
</p>
</li>
<li>
<Link href="/" className="text-primary hover:underline font-medium">
Browse Tool Registry
</Link>
<p className="text-foreground-secondary text-sm">
Discover tools to add to your collections
</p>
</li>
</ul>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,174 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@tpmjs/ui/Card/Card';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Skeleton } from '@tpmjs/ui/Skeleton/Skeleton';
// Simple relative time formatter
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
import { useEffect, useState } from 'react';
interface SkillQuestion {
id: string;
question: string;
answer: string;
confidence: number;
similarCount: number;
tags: string[];
createdAt: string;
skillNodes: Array<{
skill: {
name: string;
};
}>;
}
interface SkillsActivityFeedProps {
collectionId: string;
limit?: number;
}
export function SkillsActivityFeed({
collectionId,
limit = 10,
}: SkillsActivityFeedProps): React.ReactElement {
const [questions, setQuestions] = useState<SkillQuestion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchQuestions() {
try {
const response = await fetch(
`/api/skills/activity?collectionId=${collectionId}&limit=${limit}`
);
if (!response.ok) {
throw new Error('Failed to fetch activity');
}
const data = await response.json();
setQuestions(data.questions || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
} finally {
setLoading(false);
}
}
fetchQuestions();
}, [collectionId, limit]);
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Card key={i} variant="default">
<CardContent padding="md">
<Skeleton className="h-4 w-3/4 mb-2" />
<Skeleton className="h-3 w-1/2" />
</CardContent>
</Card>
))}
</div>
);
}
if (error) {
return (
<Card variant="default" className="border-red-200 bg-red-50">
<CardContent padding="md">
<p className="text-sm text-red-600">{error}</p>
</CardContent>
</Card>
);
}
if (questions.length === 0) {
return (
<Card variant="default" className="border-dashed">
<CardContent padding="lg" className="text-center">
<Icon
icon="message"
size="lg"
className="mx-auto text-foreground-tertiary mb-2"
/>
<p className="text-foreground-secondary text-sm">
No questions yet. Be the first to ask!
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-3">
{questions.map((q) => (
<Card key={q.id} variant="default" className="hover:border-foreground/20 transition-colors">
<CardHeader padding="sm" className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle as="h4" className="text-sm font-medium line-clamp-2">
{q.question}
</CardTitle>
<div className="flex items-center gap-1 flex-shrink-0">
<Badge
variant={q.confidence >= 0.7 ? 'success' : 'secondary'}
size="sm"
>
{Math.round(q.confidence * 100)}%
</Badge>
</div>
</div>
</CardHeader>
<CardContent padding="sm" className="pt-0">
<CardDescription className="line-clamp-2 text-xs mb-2">
{q.answer.slice(0, 150)}
{q.answer.length > 150 ? '...' : ''}
</CardDescription>
<div className="flex items-center justify-between">
<div className="flex gap-1 flex-wrap">
{q.skillNodes.slice(0, 2).map((sn, i) => (
<Badge key={i} variant="outline" size="sm">
{sn.skill.name}
</Badge>
))}
{q.skillNodes.length > 2 && (
<Badge variant="outline" size="sm">
+{q.skillNodes.length - 2}
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-foreground-tertiary">
{q.similarCount > 0 && (
<span className="flex items-center gap-1">
<Icon icon="user" size="sm" />
{q.similarCount} similar
</span>
)}
<span>{formatRelativeTime(new Date(q.createdAt))}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View file

@ -0,0 +1,158 @@
'use client';
import { Button } from '@tpmjs/ui/Button/Button';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useState } from 'react';
import { SkillsActivityFeed } from './SkillsActivityFeed';
import { SkillsStats } from './SkillsStats';
interface SkillsSectionProps {
collectionId: string;
username: string;
slug: string;
}
export function SkillsSection({
collectionId,
username,
slug,
}: SkillsSectionProps): React.ReactElement {
const [showApiDocs, setShowApiDocs] = useState(false);
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
const skillsUrl = `${baseUrl}/${username}/collections/${slug}/skills`;
const apiExample = `# Ask a question about this collection's tools
curl -X POST "${skillsUrl}" \\
-H "Content-Type: application/json" \\
-d '{
"question": "How do I handle errors with these tools?",
"agentName": "my-agent"
}'`;
const responseExample = `{
"success": true,
"data": {
"answer": "To handle errors with these tools...",
"confidence": 0.85,
"basedOn": 3,
"skillsIdentified": ["error-handling", "debugging"],
"suggestedFollowups": [
"What are the retry patterns?",
"How do I log errors?"
]
},
"meta": {
"cached": false,
"questionId": "clx...",
"processingMs": 1234
}
}`;
return (
<section className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-purple-100 rounded-lg">
<Icon icon="star" className="w-4 h-4 text-purple-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Skills</h2>
<p className="text-xs text-foreground-tertiary">
Proven in the wild not declared on paper
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowApiDocs(!showApiDocs)}
>
<Icon icon="terminal" className="w-4 h-4 mr-1" />
API
</Button>
<Link href={`/${username}/collections/${slug}/skills`}>
<Button variant="secondary" size="sm">
<Icon icon="externalLink" className="w-4 h-4 mr-1" />
Full Docs
</Button>
</Link>
</div>
</div>
{/* API Documentation Toggle */}
{showApiDocs && (
<div className="p-4 bg-surface border border-border rounded-lg space-y-4">
<div>
<h4 className="text-sm font-medium mb-2">API Endpoint</h4>
<div className="px-3 py-2 bg-background border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
POST {skillsUrl}
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-2">Example Request</h4>
<CodeBlock language="bash" code={apiExample} />
</div>
<div>
<h4 className="text-sm font-medium mb-2">Example Response</h4>
<CodeBlock language="json" code={responseExample} />
</div>
<div className="text-xs text-foreground-tertiary">
<Link href="/docs/skills" className="text-primary hover:underline">
View full API documentation
</Link>
</div>
</div>
)}
{/* Main Content */}
<div className="grid md:grid-cols-3 gap-4">
{/* Stats Column */}
<div className="md:col-span-1">
<SkillsStats collectionId={collectionId} />
</div>
{/* Activity Feed Column */}
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground-secondary">
Recent Questions
</h3>
</div>
<SkillsActivityFeed collectionId={collectionId} limit={5} />
</div>
</div>
{/* CTA for empty state */}
<div className="p-4 bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-100 rounded-lg">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-lg shadow-sm">
<Icon icon="message" className="w-5 h-5 text-purple-600" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
Ask questions to build the skill graph
</p>
<p className="text-xs text-foreground-secondary">
Every question helps improve future responses for all agents.
</p>
</div>
<Button
variant="default"
size="sm"
onClick={() => setShowApiDocs(!showApiDocs)}
>
Get Started
</Button>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,155 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@tpmjs/ui/Card/Card';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Skeleton } from '@tpmjs/ui/Skeleton/Skeleton';
import { useEffect, useState } from 'react';
interface SkillStats {
totalQuestions: number;
totalSkills: number;
topSkills: Array<{
name: string;
questionCount: number;
confidence: number;
}>;
}
interface SkillsStatsProps {
collectionId: string;
}
export function SkillsStats({
collectionId,
}: SkillsStatsProps): React.ReactElement | null {
const [stats, setStats] = useState<SkillStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchStats() {
try {
const response = await fetch(
`/api/skills/stats?collectionId=${collectionId}`
);
if (!response.ok) {
throw new Error('Failed to fetch stats');
}
const data = await response.json();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
} finally {
setLoading(false);
}
}
fetchStats();
}, [collectionId]);
if (loading) {
return (
<div className="grid grid-cols-2 gap-3">
{[1, 2].map((i) => (
<Card key={i} variant="default">
<CardContent padding="md">
<Skeleton className="h-8 w-16 mb-1" />
<Skeleton className="h-3 w-20" />
</CardContent>
</Card>
))}
</div>
);
}
if (error) {
return (
<Card variant="default" className="border-red-200 bg-red-50">
<CardContent padding="md">
<p className="text-sm text-red-600">{error}</p>
</CardContent>
</Card>
);
}
if (!stats) {
return null;
}
return (
<div className="space-y-4">
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-3">
<Card variant="default">
<CardContent padding="md">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-blue-50">
<Icon icon="message" size="md" className="text-blue-600" />
</div>
<div>
<p className="text-2xl font-semibold">{stats.totalQuestions}</p>
<p className="text-xs text-foreground-secondary">Questions</p>
</div>
</div>
</CardContent>
</Card>
<Card variant="default">
<CardContent padding="md">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-purple-50">
<Icon icon="star" size="md" className="text-purple-600" />
</div>
<div>
<p className="text-2xl font-semibold">{stats.totalSkills}</p>
<p className="text-xs text-foreground-secondary">Skills</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Top Skills */}
{stats.topSkills.length > 0 && (
<Card variant="default">
<CardHeader padding="sm">
<CardTitle as="h4" className="text-sm font-medium">
Top Skills
</CardTitle>
</CardHeader>
<CardContent padding="sm" className="pt-0">
<div className="space-y-2">
{stats.topSkills.slice(0, 5).map((skill, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Badge variant="outline" size="sm" className="truncate max-w-[180px]">
{skill.name}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-foreground-secondary flex-shrink-0">
<span>{skill.questionCount} Q</span>
<div
className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden"
title={`${Math.round(skill.confidence * 100)}% confidence`}
>
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${skill.confidence * 100}%` }}
/>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -0,0 +1,206 @@
/**
* Skills Embedding Service
*
* Uses OpenAI text-embedding-3-large (3072 dims) for high-quality
* semantic similarity detection in the RealSkills endpoint.
*/
import { openai } from '@ai-sdk/openai';
import { prisma } from '@tpmjs/db';
import { embed } from 'ai';
export const SKILLS_EMBEDDING_MODEL = 'text-embedding-3-large';
export const SKILLS_EMBEDDING_DIMS = 3072;
export const DEFAULT_SIMILARITY_THRESHOLD = 0.8;
export const CACHE_HIT_THRESHOLD = 0.95;
/**
* Compute embedding for a question or skill description
*/
export async function embedQuestion(text: string): Promise<number[]> {
const { embedding } = await embed({
model: openai.embedding(SKILLS_EMBEDDING_MODEL),
value: text,
});
return embedding;
}
/**
* Compute cosine similarity between two vectors
*/
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error(
`Vector dimension mismatch: ${a.length} vs ${b.length}`
);
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
const aVal = a[i] ?? 0;
const bVal = b[i] ?? 0;
dotProduct += aVal * bVal;
normA += aVal * aVal;
normB += bVal * bVal;
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
if (denominator === 0) return 0;
return dotProduct / denominator;
}
export interface SimilarQuestion {
id: string;
question: string;
answer: string;
similarity: number;
createdAt: Date;
}
/**
* Find questions similar to a given query embedding
*/
export async function findSimilarQuestions(
queryEmbedding: number[],
collectionId: string,
options: {
threshold?: number;
limit?: number;
excludeId?: string;
} = {}
): Promise<SimilarQuestion[]> {
const {
threshold = DEFAULT_SIMILARITY_THRESHOLD,
limit = 5,
excludeId,
} = options;
// Fetch all questions for this collection
const questions = await prisma.skillQuestion.findMany({
where: {
collectionId,
...(excludeId && { id: { not: excludeId } }),
},
select: {
id: true,
question: true,
answer: true,
embedding: true,
createdAt: true,
},
});
// Calculate similarity scores
const similar: SimilarQuestion[] = [];
for (const q of questions) {
const existingEmbedding = q.embedding as number[];
if (!existingEmbedding || existingEmbedding.length === 0) continue;
const similarity = cosineSimilarity(queryEmbedding, existingEmbedding);
if (similarity >= threshold) {
similar.push({
id: q.id,
question: q.question,
answer: q.answer,
similarity,
createdAt: q.createdAt,
});
}
}
// Sort by similarity descending and limit results
return similar
.sort((a, b) => b.similarity - a.similarity)
.slice(0, limit);
}
/**
* Check for a cache hit (very similar question already answered)
* Returns the cached answer if similarity > 95%
*/
export async function checkCacheHit(
queryEmbedding: number[],
collectionId: string
): Promise<SimilarQuestion | null> {
const similar = await findSimilarQuestions(queryEmbedding, collectionId, {
threshold: CACHE_HIT_THRESHOLD,
limit: 1,
});
if (similar.length > 0 && similar[0]) {
// Increment the similar count for analytics
await prisma.skillQuestion.update({
where: { id: similar[0].id },
data: { similarCount: { increment: 1 } },
});
return similar[0];
}
return null;
}
/**
* Find questions that match a text query (convenience wrapper)
*/
export async function searchQuestions(
query: string,
collectionId: string,
options: {
threshold?: number;
limit?: number;
} = {}
): Promise<SimilarQuestion[]> {
const embedding = await embedQuestion(query);
return findSimilarQuestions(embedding, collectionId, options);
}
export interface SimilarityResult {
isCacheHit: boolean;
cachedAnswer: string | null;
similarQuestions: SimilarQuestion[];
embedding: number[];
}
/**
* Full similarity check for a new question
* - Checks for cache hit (>95% similar)
* - Returns similar questions for RAG context
*/
export async function checkQuestionSimilarity(
question: string,
collectionId: string
): Promise<SimilarityResult> {
// Generate embedding for the question
const embedding = await embedQuestion(question);
// Check for cache hit first
const cacheHit = await checkCacheHit(embedding, collectionId);
if (cacheHit) {
return {
isCacheHit: true,
cachedAnswer: cacheHit.answer,
similarQuestions: [cacheHit],
embedding,
};
}
// Find similar questions for RAG context
const similarQuestions = await findSimilarQuestions(
embedding,
collectionId,
{ threshold: DEFAULT_SIMILARITY_THRESHOLD, limit: 5 }
);
return {
isCacheHit: false,
cachedAnswer: null,
similarQuestions,
embedding,
};
}

View file

@ -0,0 +1,389 @@
/**
* Skills Graph Updater
*
* Handles real-time skill graph updates:
* - Infers skills from questions using embeddings
* - Creates/matches skill nodes
* - Links questions to skills and tools
* - Updates confidence scores
*/
import { openai } from '@ai-sdk/openai';
import type { Skill, Tool } from '@prisma/client';
import { prisma } from '@tpmjs/db';
import { generateObject } from 'ai';
import { z } from 'zod';
import {
cosineSimilarity,
embedQuestion,
} from './skills-embedding';
const SKILL_MATCH_THRESHOLD = 0.75;
/**
* Generate a URL-safe slug from a skill name
*/
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 200);
}
/**
* Extract potential skills from a question using LLM
*/
export async function extractSkillsFromQuestion(
question: string,
tools: Tool[]
): Promise<
Array<{
name: string;
description: string;
}>
> {
const toolNames = tools.map((t) => t.name).join(', ');
const { object } = await generateObject({
model: openai('gpt-4.1-mini'),
schema: z.object({
skills: z.array(
z.object({
name: z
.string()
.describe('Short skill name (2-5 words), e.g., "API error handling"'),
description: z
.string()
.describe('One sentence describing what this skill enables'),
})
),
}),
system: `You extract skills/capabilities from questions about tool collections.
Tools in this collection: ${toolNames}
A skill represents a specific capability or use case that the tools enable.
Examples: "API error handling", "Data transformation", "File parsing", "React state management"
Return 1-3 skills that this question relates to.`,
prompt: question,
temperature: 0.3,
});
return object.skills;
}
/**
* Find existing skills that match by embedding similarity
*/
async function findMatchingSkills(
skillEmbedding: number[],
collectionId: string,
threshold: number = SKILL_MATCH_THRESHOLD
): Promise<Array<{ skill: Skill; similarity: number }>> {
const existingSkills = await prisma.skill.findMany({
where: { collectionId },
select: {
id: true,
name: true,
slug: true,
description: true,
embedding: true,
questionCount: true,
confidence: true,
collectionId: true,
parentSkillId: true,
createdAt: true,
updatedAt: true,
},
});
const matches: Array<{ skill: Skill; similarity: number }> = [];
for (const skill of existingSkills) {
const existingEmbedding = skill.embedding as number[];
if (!existingEmbedding || existingEmbedding.length === 0) continue;
const similarity = cosineSimilarity(skillEmbedding, existingEmbedding);
if (similarity >= threshold) {
matches.push({
skill: skill as unknown as Skill,
similarity,
});
}
}
return matches.sort((a, b) => b.similarity - a.similarity);
}
/**
* Get or create a skill node
*/
async function getOrCreateSkill(
collectionId: string,
name: string,
description: string
): Promise<Skill> {
const slug = slugify(name);
// Try to find existing skill by slug
const existing = await prisma.skill.findUnique({
where: {
collectionId_slug: {
collectionId,
slug,
},
},
});
if (existing) {
return existing;
}
// Check by embedding similarity
const embedding = await embedQuestion(`${name}: ${description}`);
const matches = await findMatchingSkills(embedding, collectionId);
if (matches.length > 0 && matches[0]) {
// Use existing skill if close match
return matches[0].skill;
}
// Create new skill
return prisma.skill.create({
data: {
collectionId,
name,
slug,
description,
embedding: embedding as unknown as object,
questionCount: 0,
confidence: 0,
},
});
}
export interface SkillLink {
skillId: string;
skillName: string;
relevance: number;
}
export interface ToolLink {
toolId: string;
toolName: string;
relevance: number;
}
export interface GraphUpdateResult {
skillLinks: SkillLink[];
toolLinks: ToolLink[];
}
/**
* Extract tool mentions from question/answer text
*/
function extractToolMentions(
text: string,
tools: Tool[]
): Array<{ tool: Tool; relevance: number }> {
const mentions: Array<{ tool: Tool; relevance: number }> = [];
const lowerText = text.toLowerCase();
for (const tool of tools) {
// Check if tool name is mentioned
if (lowerText.includes(tool.name.toLowerCase())) {
mentions.push({ tool, relevance: 1.0 });
}
}
return mentions;
}
/**
* Update the skill graph after storing a new question
*
* This:
* 1. Extracts skills from the question
* 2. Matches or creates skill nodes
* 3. Links the question to skills
* 4. Links the question to mentioned tools
* 5. Updates skill confidence scores
*/
export async function updateSkillGraph(params: {
questionId: string;
collectionId: string;
question: string;
answer: string;
tools: Tool[];
}): Promise<GraphUpdateResult> {
const { questionId, collectionId, question, answer, tools } = params;
const skillLinks: SkillLink[] = [];
const toolLinks: ToolLink[] = [];
// 1. Extract skills from the question
const extractedSkills = await extractSkillsFromQuestion(question, tools);
// 2. Get or create skill nodes and link to question
for (const extracted of extractedSkills) {
try {
const skill = await getOrCreateSkill(
collectionId,
extracted.name,
extracted.description
);
// Link question to skill
await prisma.skillQuestionSkill.upsert({
where: {
questionId_skillId: {
questionId,
skillId: skill.id,
},
},
create: {
questionId,
skillId: skill.id,
relevance: 1.0,
},
update: {
relevance: 1.0,
},
});
// Update skill question count
await prisma.skill.update({
where: { id: skill.id },
data: {
questionCount: { increment: 1 },
// Increase confidence with more questions
confidence: {
increment: 0.05,
},
},
});
skillLinks.push({
skillId: skill.id,
skillName: skill.name,
relevance: 1.0,
});
} catch (error) {
// Log but don't fail - graph updates are best-effort
console.error(`Failed to link skill "${extracted.name}":`, error);
}
}
// 3. Extract and link tool mentions
const combinedText = `${question} ${answer}`;
const toolMentions = extractToolMentions(combinedText, tools);
for (const { tool, relevance } of toolMentions) {
try {
await prisma.skillQuestionTool.upsert({
where: {
questionId_toolId: {
questionId,
toolId: tool.id,
},
},
create: {
questionId,
toolId: tool.id,
relevance,
},
update: {
relevance,
},
});
toolLinks.push({
toolId: tool.id,
toolName: tool.name,
relevance,
});
} catch (error) {
console.error(`Failed to link tool "${tool.name}":`, error);
}
}
return { skillLinks, toolLinks };
}
/**
* Get skill summary for a collection
*/
export async function getCollectionSkillsSummary(
collectionId: string
): Promise<{
totalQuestions: number;
totalSkills: number;
topSkills: Array<{
name: string;
questionCount: number;
confidence: number;
}>;
}> {
const [totalQuestions, totalSkills, topSkills] = await Promise.all([
prisma.skillQuestion.count({ where: { collectionId } }),
prisma.skill.count({ where: { collectionId } }),
prisma.skill.findMany({
where: { collectionId },
orderBy: { questionCount: 'desc' },
take: 10,
select: {
name: true,
questionCount: true,
confidence: true,
},
}),
]);
return {
totalQuestions,
totalSkills,
topSkills,
};
}
/**
* Recalculate confidence scores for all skills in a collection
* (Useful for batch updates or maintenance)
*/
export async function recalculateSkillConfidence(
collectionId: string
): Promise<void> {
const skills = await prisma.skill.findMany({
where: { collectionId },
include: {
questions: {
include: {
question: true,
},
},
},
});
for (const skill of skills) {
// Base confidence on question count (logarithmic scale)
const questionCount = skill.questions.length;
let confidence = Math.min(1.0, Math.log10(questionCount + 1) / 2);
// Boost for recent questions
const recentQuestions = skill.questions.filter((q) => {
const daysSinceQuestion =
(Date.now() - q.question.createdAt.getTime()) / (1000 * 60 * 60 * 24);
return daysSinceQuestion < 30;
});
if (recentQuestions.length > 0) {
confidence += 0.1;
}
await prisma.skill.update({
where: { id: skill.id },
data: { confidence: Math.min(1.0, confidence) },
});
}
}

View file

@ -0,0 +1,221 @@
/**
* Skills Response Generator
*
* Uses GPT-4.1-mini to generate skill responses using RAG
* from stored questions and collection context.
*/
import { openai } from '@ai-sdk/openai';
import type { Collection, Tool } from '@prisma/client';
import { generateText, streamText } from 'ai';
import type { SimilarQuestion } from './skills-embedding';
const RESPONSE_MODEL = 'gpt-4.1-mini';
const TEMPERATURE = 0.3;
export interface CollectionContext {
collection: Collection;
tools: Array<
Tool & {
package: { npmPackageName: string };
}
>;
skillsMarkdown?: string | null;
}
export interface SessionMessage {
role: 'user' | 'assistant';
content: string;
}
export interface GenerateResponseParams {
question: string;
collectionContext: CollectionContext;
similarQuestions: SimilarQuestion[];
sessionHistory?: SessionMessage[];
tags?: string[];
stream?: boolean;
}
/**
* Build the system prompt for skill response generation
*/
function buildSystemPrompt(params: GenerateResponseParams): string {
const { collectionContext, similarQuestions, tags } = params;
const { collection, tools, skillsMarkdown } = collectionContext;
// Build tool descriptions
const toolDescriptions = tools
.map((t) => {
return `- **${t.name}** (${t.package.npmPackageName}): ${t.description}`;
})
.join('\n');
// Build similar Q&A context for RAG
const ragContext =
similarQuestions.length > 0
? similarQuestions
.map((q, i) => {
return `### Previous Question ${i + 1} (${Math.round(q.similarity * 100)}% similar)
**Q:** ${q.question}
**A:** ${q.answer}`;
})
.join('\n\n')
: 'No similar questions have been asked yet.';
// Optional tag hints
const tagHints =
tags && tags.length > 0
? `\nThe user has tagged this question with: ${tags.join(', ')}`
: '';
return `You are a helpful assistant that answers questions about using the tools in the "${collection.name}" collection.
## Collection Description
${collection.description || 'No description provided.'}
## Available Tools
${toolDescriptions}
${skillsMarkdown ? `## Skills Documentation\n${skillsMarkdown.slice(0, 4000)}` : ''}
## Previous Related Questions & Answers (Use for context)
${ragContext}
${tagHints}
## Response Guidelines
1. **Be specific and practical** - Provide concrete examples and code snippets when helpful
2. **Reference the tools** - When relevant, mention which tools from the collection can help
3. **Build on previous answers** - If similar questions exist, use them as context but provide a fresh, tailored response
4. **Use markdown formatting** - Format your response with headers, code blocks, and lists as appropriate
5. **Be concise** - Get to the point quickly while being thorough
6. **Admit limitations** - If you're unsure or the collection doesn't have tools for something, say so
Your response will be stored and used to help future users, so make it clear and reusable.`;
}
/**
* Build the user prompt with session context
*/
function buildUserPrompt(params: GenerateResponseParams): string {
const { question, sessionHistory } = params;
// Include session history for multi-turn conversations
if (sessionHistory && sessionHistory.length > 0) {
const historyText = sessionHistory
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`)
.join('\n\n');
return `Previous conversation:
${historyText}
Current question: ${question}`;
}
return question;
}
export interface GenerateResponseResult {
answer: string;
tokensUsed: number;
}
/**
* Generate a skill response (non-streaming)
*/
export async function generateSkillResponse(
params: GenerateResponseParams
): Promise<GenerateResponseResult> {
const systemPrompt = buildSystemPrompt(params);
const userPrompt = buildUserPrompt(params);
const { text, usage } = await generateText({
model: openai(RESPONSE_MODEL),
system: systemPrompt,
prompt: userPrompt,
temperature: TEMPERATURE,
});
return {
answer: text,
tokensUsed: usage?.totalTokens ?? 0,
};
}
/**
* Generate a skill response with streaming
* Returns a ReadableStream for SSE
*/
export async function generateSkillResponseStream(
params: GenerateResponseParams
): Promise<ReadableStream> {
const systemPrompt = buildSystemPrompt(params);
const userPrompt = buildUserPrompt(params);
const result = streamText({
model: openai(RESPONSE_MODEL),
system: systemPrompt,
prompt: userPrompt,
temperature: TEMPERATURE,
});
return result.textStream as unknown as ReadableStream;
}
/**
* Generate suggested follow-up questions based on the response
*/
export async function generateFollowupSuggestions(
question: string,
answer: string,
collectionName: string
): Promise<string[]> {
const { text } = await generateText({
model: openai(RESPONSE_MODEL),
system: `You suggest follow-up questions based on a Q&A about the "${collectionName}" tool collection.
Return exactly 3 short follow-up questions, one per line. No numbering or bullets.`,
prompt: `Original question: ${question}
Answer given: ${answer.slice(0, 1000)}
Suggest 3 follow-up questions:`,
temperature: 0.5,
});
return text
.split('\n')
.map((q) => q.trim())
.filter((q) => q.length > 0 && q.endsWith('?'))
.slice(0, 3);
}
/**
* Infer confidence score based on RAG context quality
*/
export function calculateConfidence(
similarQuestions: SimilarQuestion[],
hasSkillsMarkdown: boolean
): number {
let confidence = 0.3; // Base confidence
// Boost for similar questions (RAG context)
if (similarQuestions.length > 0) {
const avgSimilarity =
similarQuestions.reduce((sum, q) => sum + q.similarity, 0) /
similarQuestions.length;
confidence += avgSimilarity * 0.4; // Up to 0.4 boost
}
// Boost for having skills documentation
if (hasSkillsMarkdown) {
confidence += 0.2;
}
// Boost for multiple similar questions
if (similarQuestions.length >= 3) {
confidence += 0.1;
}
return Math.min(1.0, confidence);
}

View file

@ -0,0 +1,331 @@
/**
* Skills Seeder
*
* Lazy seeding of synthetic questions on first access.
* Generates questions from:
* - Existing skills.md documentation
* - Tool capabilities and descriptions
* - Common use case patterns
*/
import { openai } from '@ai-sdk/openai';
import type { Collection, Tool } from '@prisma/client';
import { prisma } from '@tpmjs/db';
import { generateObject } from 'ai';
import { z } from 'zod';
import { embedQuestion } from './skills-embedding';
import {
generateSkillResponse,
type CollectionContext,
} from './skills-response-generator';
import { updateSkillGraph } from './skills-graph-updater';
const SEED_BATCH_SIZE = 5;
const SEEDING_LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export interface CollectionWithTools extends Collection {
tools: Array<
Tool & {
package: { npmPackageName: string };
}
>;
}
/**
* Generate synthetic questions from skills markdown
*/
async function generateQuestionsFromSkillsMarkdown(
skillsMarkdown: string,
collectionName: string,
count: number = 5
): Promise<string[]> {
const { object } = await generateObject({
model: openai('gpt-4.1-mini'),
schema: z.object({
questions: z.array(z.string()),
}),
system: `You generate realistic questions that users might ask about a tool collection.
Generate practical, specific questions based on the skills documentation.
Questions should be natural and varied - some simple, some complex.`,
prompt: `Collection: ${collectionName}
Skills Documentation:
${skillsMarkdown.slice(0, 4000)}
Generate ${count} questions that someone using these tools might ask:`,
temperature: 0.7,
});
return object.questions;
}
/**
* Generate synthetic questions from tool descriptions
*/
async function generateQuestionsFromTools(
tools: Array<Tool & { package: { npmPackageName: string } }>,
count: number = 5
): Promise<string[]> {
const toolInfo = tools
.slice(0, 10) // Limit to first 10 tools
.map((t) => `- ${t.name}: ${t.description}`)
.join('\n');
const { object } = await generateObject({
model: openai('gpt-4.1-mini'),
schema: z.object({
questions: z.array(z.string()),
}),
system: `You generate realistic questions that users might ask when learning to use tools.
Questions should cover:
- How to use specific tools
- Error handling
- Common use cases
- Integration patterns
- Edge cases`,
prompt: `Available tools:
${toolInfo}
Generate ${count} practical questions about using these tools:`,
temperature: 0.7,
});
return object.questions;
}
/**
* Generate common use case questions
*/
async function generateCommonUseCaseQuestions(
collectionName: string,
collectionDescription: string | null,
count: number = 3
): Promise<string[]> {
const { object } = await generateObject({
model: openai('gpt-4.1-mini'),
schema: z.object({
questions: z.array(z.string()),
}),
system: `You generate common, beginner-friendly questions about tool collections.
Focus on:
- Getting started
- Best practices
- Common pitfalls
- When to use vs alternatives`,
prompt: `Collection: ${collectionName}
Description: ${collectionDescription || 'A collection of tools'}
Generate ${count} common questions someone new might ask:`,
temperature: 0.7,
});
return object.questions;
}
/**
* Check if seeding is already in progress (with timeout)
*/
async function isSeeding(collectionId: string): Promise<boolean> {
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
select: { skillsSeedingAt: true },
});
if (!collection?.skillsSeedingAt) return false;
// Check if seeding has timed out
const elapsed = Date.now() - collection.skillsSeedingAt.getTime();
if (elapsed > SEEDING_LOCK_TIMEOUT_MS) {
// Clear stale lock
await prisma.collection.update({
where: { id: collectionId },
data: { skillsSeedingAt: null },
});
return false;
}
return true;
}
/**
* Seed a collection with synthetic questions
* Returns true if seeding was performed, false if skipped
*/
export async function seedCollectionSkills(
collection: CollectionWithTools
): Promise<{
seeded: boolean;
questionsCreated: number;
reason?: string;
}> {
// Check if already seeded
if (collection.skillsSeeded) {
return { seeded: false, questionsCreated: 0, reason: 'Already seeded' };
}
// Check if seeding is in progress
if (await isSeeding(collection.id)) {
return { seeded: false, questionsCreated: 0, reason: 'Seeding in progress' };
}
// Acquire seeding lock
await prisma.collection.update({
where: { id: collection.id },
data: { skillsSeedingAt: new Date() },
});
try {
const allQuestions: string[] = [];
// 1. Generate from skills.md if available
if (collection.skillsMarkdown) {
const skillsQuestions = await generateQuestionsFromSkillsMarkdown(
collection.skillsMarkdown,
collection.name,
5
);
allQuestions.push(...skillsQuestions);
}
// 2. Generate from tool descriptions
if (collection.tools.length > 0) {
const toolQuestions = await generateQuestionsFromTools(
collection.tools,
5
);
allQuestions.push(...toolQuestions);
}
// 3. Generate common use case questions
const useCaseQuestions = await generateCommonUseCaseQuestions(
collection.name,
collection.description,
3
);
allQuestions.push(...useCaseQuestions);
// Deduplicate questions
const uniqueQuestions = [...new Set(allQuestions)];
// 4. Process questions in batches
let questionsCreated = 0;
const collectionContext: CollectionContext = {
collection,
tools: collection.tools,
skillsMarkdown: collection.skillsMarkdown,
};
for (let i = 0; i < uniqueQuestions.length; i += SEED_BATCH_SIZE) {
const batch = uniqueQuestions.slice(i, i + SEED_BATCH_SIZE);
for (const question of batch) {
try {
// Generate embedding
const embedding = await embedQuestion(question);
// Generate answer
const { answer, tokensUsed } = await generateSkillResponse({
question,
collectionContext,
similarQuestions: [], // No similar questions for seed
stream: false,
});
// Store the question
const stored = await prisma.skillQuestion.create({
data: {
collectionId: collection.id,
question,
embedding: embedding as unknown as object,
answer,
answerTokens: tokensUsed,
agentName: 'seed-bot',
confidence: 0.5, // Medium confidence for synthetic
tags: ['synthetic', 'seed'],
},
});
// Update skill graph (best-effort)
try {
await updateSkillGraph({
questionId: stored.id,
collectionId: collection.id,
question,
answer,
tools: collection.tools,
});
} catch {
// Don't fail seeding if graph update fails
}
questionsCreated++;
} catch (error) {
console.error(`Failed to seed question: "${question}"`, error);
// Continue with other questions
}
}
}
// Mark as seeded
await prisma.collection.update({
where: { id: collection.id },
data: {
skillsSeeded: true,
skillsSeedingAt: null,
},
});
return { seeded: true, questionsCreated };
} catch (error) {
// Clear seeding lock on error
await prisma.collection.update({
where: { id: collection.id },
data: { skillsSeedingAt: null },
});
throw error;
}
}
/**
* Check seeding status for a collection
*/
export async function getSeedingStatus(collectionId: string): Promise<{
isSeeded: boolean;
isSeeding: boolean;
questionCount: number;
}> {
const [collection, questionCount] = await Promise.all([
prisma.collection.findUnique({
where: { id: collectionId },
select: { skillsSeeded: true, skillsSeedingAt: true },
}),
prisma.skillQuestion.count({ where: { collectionId } }),
]);
if (!collection) {
return { isSeeded: false, isSeeding: false, questionCount: 0 };
}
const isCurrentlySeeding =
collection.skillsSeedingAt &&
Date.now() - collection.skillsSeedingAt.getTime() < SEEDING_LOCK_TIMEOUT_MS;
return {
isSeeded: collection.skillsSeeded,
isSeeding: !!isCurrentlySeeding,
questionCount,
};
}
/**
* Reset seeding status (for manual re-seeding)
*/
export async function resetSeedingStatus(collectionId: string): Promise<void> {
await prisma.collection.update({
where: { id: collectionId },
data: {
skillsSeeded: false,
skillsSeedingAt: null,
},
});
}