feat(ai-diagnostics): add REST API routes [3.3]
This commit is contained in:
parent
d575b725cb
commit
1cba69948f
51
.windsurf/workflows/repo_update-agent-docs.md
Normal file
51
.windsurf/workflows/repo_update-agent-docs.md
Normal 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
101
scripts/sync-workflows.sh
Executable 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"
|
||||||
@ -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;
|
||||||
|
|||||||
544
services/platform-service/src/modules/ai-diagnostics/routes.ts
Normal file
544
services/platform-service/src/modules/ai-diagnostics/routes.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user