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
|
// Provide insights and suggestions after importing an OpenAPI spec
|
||||||
String openApiInsightsPrompt({
|
String openApiInsightsPrompt({
|
||||||
required String specSummary,
|
required String specSummary,
|
||||||
|
Map<String, dynamic>? specMeta,
|
||||||
}) {
|
}) {
|
||||||
return """
|
return """
|
||||||
<system_prompt>
|
<system_prompt>
|
||||||
@@ -464,13 +465,17 @@ STRICT OFF-TOPIC POLICY
|
|||||||
CONTEXT (OPENAPI SUMMARY)
|
CONTEXT (OPENAPI SUMMARY)
|
||||||
${specSummary.trim()}
|
${specSummary.trim()}
|
||||||
|
|
||||||
|
CONTEXT (OPENAPI META, JSON)
|
||||||
|
${specMeta ?? '{}'}
|
||||||
|
|
||||||
TASK
|
TASK
|
||||||
- Provide practical, user-friendly insights based on the API spec:
|
- Provide practical, user-friendly insights based on the API spec:
|
||||||
- Identify noteworthy endpoints (e.g., CRUD sets, auth/login, health/status) and common patterns.
|
- 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.
|
- 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.
|
- 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).
|
- 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)
|
OUTPUT FORMAT (STRICT)
|
||||||
- Return ONLY a single JSON object.
|
- Return ONLY a single JSON object.
|
||||||
|
|||||||
@@ -4,6 +4,132 @@ import 'package:openapi_spec/openapi_spec.dart';
|
|||||||
/// Service to parse OpenAPI specifications and produce
|
/// Service to parse OpenAPI specifications and produce
|
||||||
/// a standard action message map understood by Dashbot.
|
/// a standard action message map understood by Dashbot.
|
||||||
class OpenApiImportService {
|
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.
|
/// Try to parse a JSON or YAML OpenAPI spec string.
|
||||||
/// Returns null if parsing fails.
|
/// Returns null if parsing fails.
|
||||||
static OpenApi? tryParseSpec(String source) {
|
static OpenApi? tryParseSpec(String source) {
|
||||||
@@ -135,10 +261,8 @@ class OpenApiImportService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a list of operations from the spec, and if multiple are found,
|
static Map<String, dynamic> buildOperationPicker(OpenApi spec,
|
||||||
/// return a JSON with a single "Import Now" style action to open
|
{String? insights}) {
|
||||||
/// a selection dialog in the UI, avoiding rendering dozens of buttons.
|
|
||||||
static Map<String, dynamic> buildOperationPicker(OpenApi spec) {
|
|
||||||
final servers = spec.servers ?? const [];
|
final servers = spec.servers ?? const [];
|
||||||
int endpointsCount = 0;
|
int endpointsCount = 0;
|
||||||
final methods = <String>{};
|
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 title = spec.info.title.isNotEmpty ? spec.info.title : 'Untitled API';
|
||||||
final version = spec.info.version;
|
final version = spec.info.version;
|
||||||
final server = servers.isNotEmpty ? servers.first.url : null;
|
final server = servers.isNotEmpty ? servers.first.url : null;
|
||||||
@@ -178,8 +301,13 @@ class OpenApiImportService {
|
|||||||
..writeln('- Endpoints discovered: $endpointsCount')
|
..writeln('- Endpoints discovered: $endpointsCount')
|
||||||
..writeln('- Methods: ${methods.join(', ')}');
|
..writeln('- Methods: ${methods.join(', ')}');
|
||||||
|
|
||||||
|
final explanation =
|
||||||
|
StringBuffer('OpenAPI parsed. Click Import Now to choose operations.')
|
||||||
|
.toString();
|
||||||
return {
|
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': [
|
'actions': [
|
||||||
{
|
{
|
||||||
'action': 'import_now_openapi',
|
'action': 'import_now_openapi',
|
||||||
@@ -194,6 +322,7 @@ class OpenApiImportService {
|
|||||||
],
|
],
|
||||||
'meta': {
|
'meta': {
|
||||||
'openapi_summary': summary.toString(),
|
'openapi_summary': summary.toString(),
|
||||||
|
'openapi_meta': extractSpecMeta(spec),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -675,7 +675,42 @@ class ChatViewmodel extends StateNotifier<ChatState> {
|
|||||||
ChatMessageType.importOpenApi);
|
ChatMessageType.importOpenApi);
|
||||||
return;
|
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';
|
final rqId = _currentRequest?.id ?? 'global';
|
||||||
_addMessage(
|
_addMessage(
|
||||||
rqId,
|
rqId,
|
||||||
@@ -691,16 +726,7 @@ class ChatViewmodel extends StateNotifier<ChatState> {
|
|||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// If meta summary is present, generate insights via AI
|
// Do not generate a separate insights prompt; summary is inline now.
|
||||||
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 (_) {}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[OpenAPI] Exception: $e');
|
debugPrint('[OpenAPI] Exception: $e');
|
||||||
final safe = e.toString().replaceAll('"', "'");
|
final safe = e.toString().replaceAll('"', "'");
|
||||||
@@ -1035,29 +1061,6 @@ class ChatViewmodel extends StateNotifier<ChatState> {
|
|||||||
final normalized = path.startsWith('/') ? path : '/$path';
|
final normalized = path.startsWith('/') ? path : '/$path';
|
||||||
return '{{$key}}$normalized';
|
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>((
|
final chatViewmodelProvider = StateNotifierProvider<ChatViewmodel, ChatState>((
|
||||||
|
|||||||
Reference in New Issue
Block a user