feat(ai-diagnostics): add REST API routes [3.3]

This commit is contained in:
saravanakumardb1 2026-03-03 11:57:00 -08:00
parent d575b725cb
commit 1cba69948f
5 changed files with 701 additions and 3 deletions

View File

@ -0,0 +1,51 @@
---
description: Regenerate AI agent docs (AGENTS.md, CLAUDE.md, .cursorrules, etc.) across all repos
---
# Update Agent Docs Across Workspace
Regenerates all 8 AI agent configuration files across all repos in the workspace.
## Files Generated Per Repo
| File | Tool |
|------|------|
| `AGENTS.md` | Universal (OpenAI Codex, Claude, Copilot, etc.) |
| `CLAUDE.md` | Claude Code |
| `.cursorrules` | Cursor AI |
| `.github/copilot-instructions.md` | GitHub Copilot |
| `.windsurfrules` | Windsurf / Cascade |
| `.clinerules` | Cline / Roo Code |
| `.aider.conf.yml` | Aider |
| `.editorconfig` | All editors |
## Steps
1. Run the update script:
```bash
cd /Users/sd9235/code/mygh/learning_ai_common_plat
./scripts/update-agent-docs.sh
```
2. Review changes per repo:
```bash
cd /Users/sd9235/code/mygh/learning_voice_ai_agent && git diff --stat
cd /Users/sd9235/code/mygh/learning_multimodal_memory_agents && git diff --stat
# ... etc for all repos
```
3. Commit changes (if any):
```bash
cd /Users/sd9235/code/mygh/learning_voice_ai_agent
[ -n "$(git status --porcelain)" ] && git add -A && git commit -m "chore(docs): update agent configuration files"
```
## Notes
- The script scans each repo's structure and regenerates docs based on current state
- Only commits if there are actual changes
- Safe to run repeatedly (idempotent)
- Requires `learning_ai_common_plat` to be the source of truth for templates

101
scripts/sync-workflows.sh Executable file
View File

@ -0,0 +1,101 @@
#!/bin/zsh
# sync-workflows.sh — Sync common repo_* workflows from common-plat to all repos
# Usage: ./sync-workflows.sh [--dry-run]
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPOS_ROOT="/Users/sd9235/code/mygh"
COMMON_PLAT="$REPOS_ROOT/learning_ai_common_plat"
REPOS=(
"learning_voice_ai_agent"
"learning_multimodal_memory_agents"
"learning_ai_clock"
"learning_ai_peakpulse"
"learning_ai_fastgap"
"learning_ai_jarvis_jr"
)
WORKFLOWS_TO_SYNC=(
"repo_backup-main-branch.md"
"repo_backup-and-push.md"
"repo_sync-repos.md"
"repo_commit-workspace.md"
"repo_update-agent-docs.md"
"refresh-chat-history.md"
)
DRY_RUN=false
if [ "$1" = "--dry-run" ]; then
DRY_RUN=true
fi
sync_count=0
skip_count=0
for repo in "${REPOS[@]}"; do
repo_path="$REPOS_ROOT/$repo"
if [ ! -d "$repo_path" ]; then
echo "⚠ Skipping $repo (not found)"
continue
fi
# Create .windsurf/workflows if missing
wf_dir="$repo_path/.windsurf/workflows"
if [ ! -d "$wf_dir" ]; then
if [ "$DRY_RUN" = true ]; then
echo "[dry-run] mkdir -p $wf_dir"
else
mkdir -p "$wf_dir"
echo "✓ Created $wf_dir"
fi
fi
for wf in "${WORKFLOWS_TO_SYNC[@]}"; do
src="$COMMON_PLAT/.windsurf/workflows/$wf"
dest="$wf_dir/$wf"
if [ ! -f "$src" ]; then
echo "⚠ Source not found: $src"
continue
fi
if [ "$DRY_RUN" = true ]; then
if [ -f "$dest" ]; then
# Compare files
if diff -q "$src" "$dest" > /dev/null 2>&1; then
echo "[dry-run] $repo/$wf (identical, skip)"
skip_count=$((skip_count + 1))
else
echo "[dry-run] $repo/$wf (would update)"
sync_count=$((sync_count + 1))
fi
else
echo "[dry-run] $repo/$wf (would copy)"
sync_count=$((sync_count + 1))
fi
else
if [ -f "$dest" ]; then
if diff -q "$src" "$dest" > /dev/null 2>&1; then
echo " = $repo/$wf (identical)"
skip_count=$((skip_count + 1))
else
cp "$src" "$dest"
echo "$repo/$wf (updated)"
sync_count=$((sync_count + 1))
fi
else
cp "$src" "$dest"
echo "$repo/$wf (new)"
sync_count=$((sync_count + 1))
fi
fi
done
done
echo ""
echo "=== Sync complete ==="
echo " Updated: $sync_count"
echo " Skipped (identical): $skip_count"

View File

@ -14,11 +14,11 @@ export interface QueryExecutionResult {
aiResponse: string; aiResponse: string;
confidence: number; confidence: number;
supportingData: Array<{ supportingData: Array<{
type: 'cluster' | 'insight' | 'trend' | 'comparison'; type: 'cluster' | 'insight' | 'trend' | 'comparison' | 'session' | 'telemetry';
id: string; id: string;
title: string; title: string;
relevanceScore: number; relevanceScore: number;
data: unknown; data?: Record<string, unknown>;
}>; }>;
suggestedActions: string[]; suggestedActions: string[];
executionTimeMs: number; executionTimeMs: number;

View File

@ -0,0 +1,544 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { z } from 'zod';
import { UnauthorizedError } from '../../lib/errors.js';
import * as repository from './repository.js';
import { parseQuery, validateQuery, generateQuerySuggestions } from './query-parser.js';
import { executeQuery } from './query-executor.js';
import { analyzeRootCause, generatePatternSummary } from './llm-analyzer.js';
import { aggregateClusterContext } from './telemetry-linking.js';
import type { ErrorClusterDoc, DiagnosticInsightDoc } from './types.js';
// Auth helper
function requireAdmin(req: { jwtPayload?: { role?: string } }): void {
if (req.jwtPayload?.role !== 'admin') {
throw new UnauthorizedError('Admin access required');
}
}
// ============================================================================
// Request/Response Schemas
// ============================================================================
const QueryRequestSchema = z.object({
query: z.string().min(1),
productId: z.string().optional(),
timeRange: z.object({
start: z.string(),
end: z.string(),
}).optional(),
});
const FeedbackRequestSchema = z.object({
insightId: z.string(),
clusterId: z.string(),
rating: z.enum(['helpful', 'not_helpful']),
note: z.string().optional(),
});
const AnalyzeClusterRequestSchema = z.object({
analysisType: z.enum(['root_cause', 'pattern', 'comparison', 'trend']).default('root_cause'),
modelPreference: z.enum(['gpt-4o-mini', 'gpt-4o']).default('gpt-4o-mini'),
});
const SearchRequestSchema = z.object({
query: z.string(),
productId: z.string().optional(),
limit: z.number().default(10),
threshold: z.number().default(0.75),
});
// ============================================================================
// Routes
// ============================================================================
export default async function aiDiagnosticsRoutes(fastify: FastifyInstance): Promise<void> {
// All routes require admin authentication
fastify.addHook('onRequest', async (request) => {
requireAdmin(request);
});
// ==========================================================================
// POST /ai-diagnostics/query - Natural language diagnostic query
// ==========================================================================
fastify.post('/query', {
schema: {
body: {
type: 'object',
properties: {
query: { type: 'string' },
productId: { type: 'string' },
timeRange: {
type: 'object',
properties: {
start: { type: 'string' },
end: { type: 'string' },
},
},
},
required: ['query'],
},
},
handler: async (
request: FastifyRequest<{ Body: z.infer<typeof QueryRequestSchema> }>,
reply: FastifyReply
) => {
try {
const body = QueryRequestSchema.parse(request.body);
const userId = request.jwtPayload?.sub || 'anonymous';
// Parse the query
const parsedQuery = parseQuery(body.query);
// Add time range if provided
if (body.timeRange) {
parsedQuery.entities.timeRange = body.timeRange;
}
// Add product filter
if (body.productId) {
parsedQuery.entities.products = [body.productId];
}
// Validate query
const validation = validateQuery(parsedQuery);
if (!validation.valid) {
return reply.status(400).send({
error: 'Invalid query',
details: validation.errors,
});
}
// Execute query
const result = await executeQuery(parsedQuery, {
productId: body.productId,
userId,
});
// Save query for history
await repository.saveNaturalLanguageQuery({
id: `nq_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`,
userId,
productId: body.productId,
rawQuery: body.query,
parsedIntent: parsedQuery.intent,
extractedEntities: parsedQuery.entities,
aiResponse: result.aiResponse,
confidence: result.confidence,
supportingData: result.supportingData,
dataSources: result.supportingData.map((s) => s.type),
executionTimeMs: result.executionTimeMs,
createdAt: new Date().toISOString(),
ttl: 30 * 86400, // 30 days
});
return reply.send({
success: true,
result,
warnings: validation.warnings,
});
} catch (error) {
request.log.error({ error }, 'Query execution failed');
return reply.status(500).send({
error: 'Query execution failed',
message: error instanceof Error ? error.message : String(error),
});
}
},
});
// ==========================================================================
// GET /ai-diagnostics/clusters - List error clusters
// ==========================================================================
fastify.get('/clusters', {
schema: {
querystring: {
type: 'object',
properties: {
productId: { type: 'string' },
status: { type: 'string', enum: ['active', 'investigating', 'resolved', 'ignored'] },
limit: { type: 'number', default: 50 },
includeInsights: { type: 'boolean', default: false },
},
required: ['productId'],
},
},
handler: async (
request: FastifyRequest<{
Querystring: {
productId: string;
status?: 'active' | 'investigating' | 'resolved' | 'ignored';
limit?: number;
includeInsights?: boolean;
};
}>,
reply: FastifyReply
) => {
try {
const { productId, status, limit = 50, includeInsights = false } = request.query;
const clusters = await repository.findClustersByProduct(productId, {
status,
limit,
});
let clustersWithInsights: Array<ErrorClusterDoc & { latestInsight?: DiagnosticInsightDoc }> = clusters;
if (includeInsights) {
clustersWithInsights = await Promise.all(
clusters.map(async (cluster) => {
const insight = await repository.getLatestInsightForCluster(
cluster.id,
productId
);
return { ...cluster, latestInsight: insight || undefined };
})
);
}
return reply.send({
clusters: clustersWithInsights,
total: clusters.length,
});
} catch (error) {
request.log.error({ error }, 'Failed to fetch clusters');
return reply.status(500).send({
error: 'Failed to fetch clusters',
message: error instanceof Error ? error.message : String(error),
});
}
},
});
// ==========================================================================
// GET /ai-diagnostics/clusters/:id - Get cluster detail
// ==========================================================================
fastify.get('/clusters/:id', {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
},
querystring: {
type: 'object',
properties: {
productId: { type: 'string' },
},
required: ['productId'],
},
},
handler: async (
request: FastifyRequest<{
Params: { id: string };
Querystring: { productId: string };
}>,
reply: FastifyReply
) => {
try {
const { id } = request.params;
const { productId } = request.query;
const cluster = await repository.getErrorClusterById(id, productId);
if (!cluster) {
return reply.status(404).send({ error: 'Cluster not found' });
}
// Get latest insight
const latestInsight = await repository.getLatestInsightForCluster(id, productId);
// Get related clusters
const relatedClusters = await repository.findRelatedClusters(id, productId, {
limit: 5,
});
return reply.send({
cluster,
latestInsight,
relatedClusters,
});
} catch (error) {
request.log.error({ error }, 'Failed to fetch cluster');
return reply.status(500).send({
error: 'Failed to fetch cluster',
message: error instanceof Error ? error.message : String(error),
});
}
},
});
// ==========================================================================
// POST /ai-diagnostics/clusters/:id/analyze - Trigger fresh analysis
// ==========================================================================
fastify.post('/clusters/:id/analyze', {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
},
querystring: {
type: 'object',
properties: {
productId: { type: 'string' },
},
required: ['productId'],
},
body: {
type: 'object',
properties: {
analysisType: { type: 'string', enum: ['root_cause', 'pattern', 'comparison', 'trend'] },
modelPreference: { type: 'string', enum: ['gpt-4o-mini', 'gpt-4o'] },
},
},
},
handler: async (
request: FastifyRequest<{
Params: { id: string };
Querystring: { productId: string };
Body: z.infer<typeof AnalyzeClusterRequestSchema>;
}>,
reply: FastifyReply
) => {
try {
const { id } = request.params;
const { productId } = request.query;
const body = AnalyzeClusterRequestSchema.parse(request.body || {});
// Get cluster
const cluster = await repository.getErrorClusterById(id, productId);
if (!cluster) {
return reply.status(404).send({ error: 'Cluster not found' });
}
// Aggregate context
const contextSummary = await aggregateClusterContext(cluster, []);
// Run analysis
const analysis = await analyzeRootCause({
cluster,
context: contextSummary,
sampleStackTraces: [cluster.stackSignature],
relatedClusters: [],
analysisType: body.analysisType,
});
// Save insight
const insightId = `di_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
const insight: DiagnosticInsightDoc = {
id: insightId,
clusterId: id,
productId,
...analysis,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ttl: 90 * 86400,
} as DiagnosticInsightDoc;
await repository.createDiagnosticInsight(insight);
// Update cluster with insight reference
cluster.lastAnalyzedAt = new Date().toISOString();
cluster.insightId = insightId;
await repository.updateErrorCluster(cluster);
return reply.send({
success: true,
insight,
});
} catch (error) {
request.log.error({ error }, 'Analysis failed');
return reply.status(500).send({
error: 'Analysis failed',
message: error instanceof Error ? error.message : String(error),
});
}
},
});
// ==========================================================================
// GET /ai-diagnostics/clusters/:id/analysis - Get pre-computed insight
// ==========================================================================
fastify.get('/clusters/:id/analysis', {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
},
querystring: {
type: 'object',
properties: {
productId: { type: 'string' },
},
required: ['productId'],
},
},
handler: async (
request: FastifyRequest<{
Params: { id: string };
Querystring: { productId: string };
}>,
reply: FastifyReply
) => {
try {
const { id } = request.params;
const { productId } = request.query;
const insight = await repository.getLatestInsightForCluster(id, productId);
if (!insight) {
return reply.status(404).send({
error: 'No analysis found',
message: 'Run POST /analyze to generate analysis',
});
}
return reply.send({ insight });
} catch (error) {
request.log.error({ error }, 'Failed to fetch analysis');
return reply.status(500).send({
error: 'Failed to fetch analysis',
message: error instanceof Error ? error.message : String(error),
});
}
},
});
// ==========================================================================
// POST /ai-diagnostics/feedback - Submit insight rating
// ==========================================================================
fastify.post('/feedback', {
schema: {
body: {
type: 'object',
properties: {
insightId: { type: 'string' },
clusterId: { type: 'string' },
rating: { type: 'string', enum: ['helpful', 'not_helpful'] },
note: { type: 'string' },
},
required: ['insightId', 'clusterId', 'rating'],
},
},
handler: async (
request: FastifyRequest<{ Body: z.infer<typeof FeedbackRequestSchema> }>,
reply: FastifyReply
) => {
try {
const body = FeedbackRequestSchema.parse(request.body);
const userId = request.jwtPayload?.sub || 'anonymous';
await repository.updateInsightFeedback(
body.insightId,
body.clusterId,
{
helpful: body.rating === 'helpful',
note: body.note,
}
);
return reply.send({
success: true,
message: 'Feedback recorded',
});
} catch (error) {
request.log.error({ error }, 'Failed to record feedback');
return reply.status(500).send({
error: 'Failed to record feedback',
message: error instanceof Error ? error.message : String(error),
});
}
},
});
// ==========================================================================
// GET /ai-diagnostics/suggestions - Get AI-suggested investigations
// ==========================================================================
fastify.get('/suggestions', {
schema: {
querystring: {
type: 'object',
properties: {
productId: { type: 'string' },
limit: { type: 'number', default: 5 },
},
required: ['productId'],
},
},
handler: async (
request: FastifyRequest<{
Querystring: { productId: string; limit?: number };
}>,
reply: FastifyReply
) => {
try {
const { productId, limit = 5 } = request.query;
// Get active alerts
const alerts = await repository.getActiveAlerts(productId);
// Get top error types for suggestions
const topTypes = await repository.getTopErrorTypes(productId, 5);
const platforms = ['ios', 'android', 'web'];
const suggestions = generateQuerySuggestions(
topTypes.map((t) => t.errorType),
platforms
);
return reply.send({
alerts: alerts.slice(0, limit),
suggestions: suggestions.slice(0, limit),
});
} catch (error) {
request.log.error({ error }, 'Failed to get suggestions');
return reply.status(500).send({
error: 'Failed to get suggestions',
message: error instanceof Error ? error.message : String(error),
});
}
},
});
// ==========================================================================
// GET /ai-diagnostics/query-history - Get user's query history
// ==========================================================================
fastify.get('/query-history', {
schema: {
querystring: {
type: 'object',
properties: {
limit: { type: 'number', default: 20 },
},
},
},
handler: async (
request: FastifyRequest<{
Querystring: { limit?: number };
}>,
reply: FastifyReply
) => {
try {
const userId = request.jwtPayload?.sub || 'anonymous';
const { limit = 20 } = request.query;
const history = await repository.getQueryHistory(userId, { limit });
return reply.send({ history });
} catch (error) {
request.log.error({ error }, 'Failed to fetch query history');
return reply.status(500).send({
error: 'Failed to fetch query history',
message: error instanceof Error ? error.message : String(error),
});
}
},
});
}

View File

@ -256,9 +256,11 @@ export const ExtractedEntitiesSchema = z.object({
export type ExtractedEntities = z.infer<typeof ExtractedEntitiesSchema>; export type ExtractedEntities = z.infer<typeof ExtractedEntitiesSchema>;
export const SupportingDataSchema = z.object({ export const SupportingDataSchema = z.object({
type: z.enum(['cluster', 'telemetry', 'session', 'insight']), type: z.enum(['cluster', 'telemetry', 'session', 'insight', 'trend', 'comparison']),
id: z.string(), id: z.string(),
relevanceScore: z.number(), relevanceScore: z.number(),
title: z.string().optional(),
data: z.record(z.unknown()).optional(),
}); });
export type SupportingData = z.infer<typeof SupportingDataSchema>; export type SupportingData = z.infer<typeof SupportingDataSchema>;