mirror of
https://github.com/foss42/apidash.git
synced 2025-12-02 18:57:05 +08:00
feat: enhance OpenAPI insights generation with structured metadata and summary
This commit is contained in:
@@ -453,6 +453,7 @@ RETURN THE JSON ONLY.
|
||||
// Provide insights and suggestions after importing an OpenAPI spec
|
||||
String openApiInsightsPrompt({
|
||||
required String specSummary,
|
||||
Map<String, dynamic>? specMeta,
|
||||
}) {
|
||||
return """
|
||||
<system_prompt>
|
||||
@@ -464,13 +465,17 @@ STRICT OFF-TOPIC POLICY
|
||||
CONTEXT (OPENAPI SUMMARY)
|
||||
${specSummary.trim()}
|
||||
|
||||
CONTEXT (OPENAPI META, JSON)
|
||||
${specMeta ?? '{}'}
|
||||
|
||||
TASK
|
||||
- Provide practical, user-friendly insights based on the API spec:
|
||||
- Identify noteworthy endpoints (e.g., CRUD sets, auth/login, health/status) and common patterns.
|
||||
- Point out authentication/security requirements (e.g., API keys, OAuth scopes) if present.
|
||||
- Suggest a few starter calls (e.g., list/search) and a short onboarding path.
|
||||
- Call out potential pitfalls (rate limits, pagination, required headers, content types).
|
||||
- Keep it concise and actionable: 1–2 line summary → 4–6 bullets → 2–3 next steps.
|
||||
- Use the meta JSON when present to be specific about routes, tags, and content types.
|
||||
- Keep it detailed and actionable: 6–10 line summary → 4–6 bullets → 2–3 next steps.
|
||||
|
||||
OUTPUT FORMAT (STRICT)
|
||||
- Return ONLY a single JSON object.
|
||||
|
||||
@@ -4,6 +4,132 @@ import 'package:openapi_spec/openapi_spec.dart';
|
||||
/// Service to parse OpenAPI specifications and produce
|
||||
/// a standard action message map understood by Dashbot.
|
||||
class OpenApiImportService {
|
||||
/// Produce a concise, human-readable summary for a spec.
|
||||
static String summaryForSpec(OpenApi spec) {
|
||||
final servers = spec.servers ?? const [];
|
||||
int endpointsCount = 0;
|
||||
final methods = <String>{};
|
||||
(spec.paths ?? const {}).forEach((_, item) {
|
||||
final ops = <String, Operation?>{
|
||||
'GET': item.get,
|
||||
'POST': item.post,
|
||||
'PUT': item.put,
|
||||
'DELETE': item.delete,
|
||||
'PATCH': item.patch,
|
||||
'HEAD': item.head,
|
||||
'OPTIONS': item.options,
|
||||
'TRACE': item.trace,
|
||||
};
|
||||
ops.forEach((m, op) {
|
||||
if (op == null) return;
|
||||
endpointsCount += 1;
|
||||
methods.add(m);
|
||||
});
|
||||
});
|
||||
|
||||
final title = spec.info.title.isNotEmpty ? spec.info.title : 'Untitled API';
|
||||
final version = spec.info.version;
|
||||
final server = servers.isNotEmpty ? servers.first.url : null;
|
||||
final summary = StringBuffer()
|
||||
..writeln('- Title: $title (v$version)')
|
||||
..writeln('- Server: ${server ?? '/'}')
|
||||
..writeln('- Endpoints discovered: $endpointsCount')
|
||||
..writeln('- Methods: ${methods.join(', ')}');
|
||||
return summary.toString();
|
||||
}
|
||||
|
||||
/// Extract structured metadata from an OpenAPI spec for analytics/insights.
|
||||
/// Returns a JSON-serializable map capturing key details like title, version,
|
||||
/// servers, total endpoints, methods, tags and a concise list of routes.
|
||||
static Map<String, dynamic> extractSpecMeta(OpenApi spec,
|
||||
{int maxRoutes = 40}) {
|
||||
final servers = (spec.servers ?? const [])
|
||||
.map((s) => s.url)
|
||||
.where((u) => (u ?? '').trim().isNotEmpty)
|
||||
.map((u) => u!)
|
||||
.toList(growable: false);
|
||||
final title = spec.info.title.isNotEmpty ? spec.info.title : 'Untitled API';
|
||||
final version = spec.info.version;
|
||||
|
||||
int endpointsCount = 0;
|
||||
final methods = <String>{};
|
||||
final tags = <String>{};
|
||||
final routes = <Map<String, dynamic>>[];
|
||||
final reqContentTypes = <String>{};
|
||||
final respContentTypes = <String>{};
|
||||
|
||||
(spec.paths ?? const {}).forEach((path, item) {
|
||||
final ops = <String, Operation?>{
|
||||
'GET': item.get,
|
||||
'POST': item.post,
|
||||
'PUT': item.put,
|
||||
'DELETE': item.delete,
|
||||
'PATCH': item.patch,
|
||||
'HEAD': item.head,
|
||||
'OPTIONS': item.options,
|
||||
'TRACE': item.trace,
|
||||
};
|
||||
final presentMethods = <String>[];
|
||||
ops.forEach((method, op) {
|
||||
if (op == null) return;
|
||||
endpointsCount += 1;
|
||||
methods.add(method);
|
||||
presentMethods.add(method);
|
||||
// tags
|
||||
for (final t in op.tags ?? const []) {
|
||||
final tt = t?.trim() ?? '';
|
||||
if (tt.isNotEmpty) tags.add(tt);
|
||||
}
|
||||
// content types
|
||||
final reqCts = op.requestBody?.content?.keys;
|
||||
if (reqCts != null) reqContentTypes.addAll(reqCts);
|
||||
final resps = op.responses ?? const {};
|
||||
for (final r in resps.values) {
|
||||
final ct = r.content?.keys;
|
||||
if (ct != null) respContentTypes.addAll(ct);
|
||||
}
|
||||
});
|
||||
if (presentMethods.isNotEmpty) {
|
||||
routes.add({
|
||||
'path': path,
|
||||
'methods': presentMethods,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Heuristic noteworthy route detection
|
||||
final noteworthy = routes
|
||||
.where((r) {
|
||||
final p = (r['path'] as String).toLowerCase();
|
||||
return p.contains('auth') ||
|
||||
p.contains('login') ||
|
||||
p.contains('status') ||
|
||||
p.contains('health') ||
|
||||
p.contains('user') ||
|
||||
p.contains('search');
|
||||
})
|
||||
.take(10)
|
||||
.toList();
|
||||
|
||||
// Trim routes list to keep payload light
|
||||
final trimmedRoutes =
|
||||
routes.length > maxRoutes ? routes.take(maxRoutes).toList() : routes;
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'version': version,
|
||||
'servers': servers,
|
||||
'baseUrl': servers.isNotEmpty ? servers.first : null,
|
||||
'endpointsCount': endpointsCount,
|
||||
'methods': methods.toList()..sort(),
|
||||
'tags': tags.toList()..sort(),
|
||||
'routes': trimmedRoutes,
|
||||
'noteworthyRoutes': noteworthy,
|
||||
'requestContentTypes': reqContentTypes.toList()..sort(),
|
||||
'responseContentTypes': respContentTypes.toList()..sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Try to parse a JSON or YAML OpenAPI spec string.
|
||||
/// Returns null if parsing fails.
|
||||
static OpenApi? tryParseSpec(String source) {
|
||||
@@ -135,10 +261,8 @@ class OpenApiImportService {
|
||||
};
|
||||
}
|
||||
|
||||
/// Build a list of operations from the spec, and if multiple are found,
|
||||
/// return a JSON with a single "Import Now" style action to open
|
||||
/// a selection dialog in the UI, avoiding rendering dozens of buttons.
|
||||
static Map<String, dynamic> buildOperationPicker(OpenApi spec) {
|
||||
static Map<String, dynamic> buildOperationPicker(OpenApi spec,
|
||||
{String? insights}) {
|
||||
final servers = spec.servers ?? const [];
|
||||
int endpointsCount = 0;
|
||||
final methods = <String>{};
|
||||
@@ -168,7 +292,6 @@ class OpenApiImportService {
|
||||
};
|
||||
}
|
||||
|
||||
// Build a short spec summary for downstream insights prompt
|
||||
final title = spec.info.title.isNotEmpty ? spec.info.title : 'Untitled API';
|
||||
final version = spec.info.version;
|
||||
final server = servers.isNotEmpty ? servers.first.url : null;
|
||||
@@ -178,8 +301,13 @@ class OpenApiImportService {
|
||||
..writeln('- Endpoints discovered: $endpointsCount')
|
||||
..writeln('- Methods: ${methods.join(', ')}');
|
||||
|
||||
final explanation =
|
||||
StringBuffer('OpenAPI parsed. Click Import Now to choose operations.')
|
||||
.toString();
|
||||
return {
|
||||
'explnation': 'OpenAPI parsed. Click Import Now to choose operations.',
|
||||
'explnation': insights == null || insights.isEmpty
|
||||
? '$explanation\n\n${summary.toString()}'
|
||||
: '$explanation\n\n${summary.toString()}\n\\n$insights',
|
||||
'actions': [
|
||||
{
|
||||
'action': 'import_now_openapi',
|
||||
@@ -194,6 +322,7 @@ class OpenApiImportService {
|
||||
],
|
||||
'meta': {
|
||||
'openapi_summary': summary.toString(),
|
||||
'openapi_meta': extractSpecMeta(spec),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -675,7 +675,42 @@ class ChatViewmodel extends StateNotifier<ChatState> {
|
||||
ChatMessageType.importOpenApi);
|
||||
return;
|
||||
}
|
||||
final picker = OpenApiImportService.buildOperationPicker(spec);
|
||||
// Build a short summary + structured meta for the insights prompt
|
||||
final summary = OpenApiImportService.summaryForSpec(spec);
|
||||
|
||||
String? insights;
|
||||
try {
|
||||
final ai = _selectedAIModel;
|
||||
if (ai != null) {
|
||||
final meta = OpenApiImportService.extractSpecMeta(spec);
|
||||
final sys = dash.DashbotPrompts()
|
||||
.openApiInsightsPrompt(specSummary: summary, specMeta: meta);
|
||||
final res = await _repo.sendChat(
|
||||
request: ai.copyWith(
|
||||
systemPrompt: sys,
|
||||
userPrompt:
|
||||
'Provide concise, actionable insights about these endpoints.',
|
||||
stream: false,
|
||||
),
|
||||
);
|
||||
if (res != null && res.isNotEmpty) {
|
||||
// Ensure we only pass the explnation string to embed into explanation
|
||||
try {
|
||||
final map = MessageJson.safeParse(res);
|
||||
if (map['explnation'] is String) insights = map['explnation'];
|
||||
} catch (_) {
|
||||
insights = res; // fallback raw text
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[OpenAPI] insights error: $e');
|
||||
}
|
||||
|
||||
final picker = OpenApiImportService.buildOperationPicker(
|
||||
spec,
|
||||
insights: insights,
|
||||
);
|
||||
final rqId = _currentRequest?.id ?? 'global';
|
||||
_addMessage(
|
||||
rqId,
|
||||
@@ -691,16 +726,7 @@ class ChatViewmodel extends StateNotifier<ChatState> {
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
// If meta summary is present, generate insights via AI
|
||||
try {
|
||||
final meta = picker['meta'];
|
||||
final summary = (meta is Map && meta['openapi_summary'] is String)
|
||||
? meta['openapi_summary'] as String
|
||||
: '';
|
||||
if (summary.isNotEmpty) {
|
||||
await _generateOpenApiInsights(summary);
|
||||
}
|
||||
} catch (_) {}
|
||||
// Do not generate a separate insights prompt; summary is inline now.
|
||||
} catch (e) {
|
||||
debugPrint('[OpenAPI] Exception: $e');
|
||||
final safe = e.toString().replaceAll('"', "'");
|
||||
@@ -1035,29 +1061,6 @@ class ChatViewmodel extends StateNotifier<ChatState> {
|
||||
final normalized = path.startsWith('/') ? path : '/$path';
|
||||
return '{{$key}}$normalized';
|
||||
}
|
||||
|
||||
Future<void> _generateOpenApiInsights(String summary) async {
|
||||
final ai = _selectedAIModel;
|
||||
if (ai == null) return;
|
||||
try {
|
||||
final sys = dash.DashbotPrompts().openApiInsightsPrompt(
|
||||
specSummary: summary,
|
||||
);
|
||||
final res = await _repo.sendChat(
|
||||
request: ai.copyWith(
|
||||
systemPrompt: sys,
|
||||
userPrompt:
|
||||
'Provide concise, actionable insights about these endpoints.',
|
||||
stream: false,
|
||||
),
|
||||
);
|
||||
if (res != null && res.isNotEmpty) {
|
||||
_appendSystem(res, ChatMessageType.importOpenApi);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Chat] Insights error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final chatViewmodelProvider = StateNotifierProvider<ChatViewmodel, ChatState>((
|
||||
|
||||
Reference in New Issue
Block a user