import yaml
import json
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse, HTMLResponse, Response
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Dict, Any, List, Optional, Union
from .linter.engine import lint_flow
from .linter.schemas import FlowInput, MCPResponse
from .widget.renderer import render_widget_html
app = FastAPI(title="IVR Flow Linter")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# --- Schemas ---
class JSONRPCRequest(BaseModel):
jsonrpc: str = "2.0"
method: str
params: Optional[Dict[str, Any]] = None
id: Optional[Union[str, int]] = None
class JSONRPCResponse(BaseModel):
jsonrpc: str = "2.0"
result: Optional[Any] = None
error: Optional[Dict[str, Any]] = None
id: Optional[Union[str, int]] = None
# --- Endpoints ---
@app.get("/health")
async def health_check():
return {"status": "ok"}
@app.post("/mcp")
async def handle_mcp(request: JSONRPCRequest):
if request.method == "tools/list":
return JSONRPCResponse(
id=request.id,
result={
"tools": [{
"name": "lint_flow",
"description": "Lint an IVR flow JSON for errors, warnings, and best practices.",
"inputSchema": FlowInput.model_json_schema()
}]
}
)
elif request.method == "tools/call":
if not request.params or "arguments" not in request.params:
return JSONRPCResponse(
id=request.id,
error={"code": -32602, "message": "Missing arguments"}
)
args = request.params["arguments"]
try:
# Pydantic validation
flow_input = FlowInput(**args)
result = lint_flow(flow_input)
# Generate Widget HTML
html = render_widget_html(result)
# Construct Response
# The MCP tool response expects a specific content structure typically,
# but for OpenAI Apps SDK custom actions, we often return JSON.
# The requirement is: "lint_flow debe devolver DATA + UI"
response_data = MCPResponse(
data=result,
ui={
"type": "text/html+skybridge",
"html": html
}
)
return JSONRPCResponse(
id=request.id,
result=response_data.model_dump()
)
except Exception as e:
return JSONRPCResponse(
id=request.id,
error={"code": -32603, "message": str(e)}
)
else:
return JSONRPCResponse(
id=request.id,
error={"code": -32601, "message": "Method not found"}
)
# --- OpenAI / Plugin Metadata ---
@app.get("/.well-known/ai-plugin.json")
async def plugin_manifest():
return {
"schema_version": "v1",
"name_for_human": "IVR Flow Linter",
"name_for_model": "ivr_flow_linter",
"description_for_human": "Lint and validate IVR flows.",
"description_for_model": "Analyze IVR flow JSON structures for errors and best practices.",
"auth": {"type": "none"},
"api": {
"type": "openapi",
"url": "https://ivr-flow-linter.onrender.com/openapi.yaml"
# Note: The above URL will need to be dynamically determined or hardcoded for Prod
},
"logo_url": "https://via.placeholder.com/150",
"contact_email": "support@example.com",
"legal_info_url": "http://example.com/legal"
}
@app.get("/openapi.yaml", include_in_schema=False)
async def openapi_yaml(request: Request):
# Dynamically generate OpenAPI spec based on the FastAPI app
# Or serve a static one. For now, generate basic wrapper.
openapi_schema = app.openapi()
# Customize for OpenAI Actions if needed
openapi_schema["servers"] = [{"url": str(request.base_url)}]
return Response(content=yaml.dump(openapi_schema), media_type="text/yaml")
# --- Widget Test Endpoint ---
@app.get("/widget")
async def widget_test():
"""Returns the widget with a mock empty result for testing HTML rendering"""
from .linter.schemas import LintResult, FlowMeta
mock_result = LintResult(
flow_hash="mock",
score=100,
errors=[],
warnings=[],
quick_fixes=[],
flow_meta=FlowMeta(
nodes=0,
edges=0,
unreachable_count=0,
dead_end_count=0,
created_at='now'
)
)
html = render_widget_html(mock_result)
return HTMLResponse(content=html)
from fastapi.staticfiles import StaticFiles
# Mount examples directory to serve JSON files directly
app.mount("/examples", StaticFiles(directory="examples"), name="examples")
@app.get("/demo")
async def demo_page():
"""Hardened Interactive Demo Page"""
html = """
<!DOCTYPE html>
<html>
<head>
<title>IVR Linter Demo (Hardened)</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; display: flex; height: 100vh; margin: 0; background: #f0f2f5; }
.sidebar { width: 400px; padding: 20px; border-right: 1px solid #ccc; display: flex; flex-direction: column; background: white; overflow-y: auto; }
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.preview-container { flex: 1; background: white; }
textarea { width: 100%; height: 200px; font-family: monospace; padding: 10px; border: 1px solid #ddd; margin-bottom: 10px; font-size: 12px; }
button { padding: 8px 16px; background: #007aff; color: white; border: none; cursor: pointer; border-radius: 4px; font-weight: 500; }
button:hover { background: #0056b3; }
button.secondary { background: #e0e0e0; color: #333; margin-right: 5px; }
button.secondary:hover { background: #d0d0d0; }
h3 { margin-top: 0; font-size: 16px; margin-bottom: 10px; }
.controls { margin-bottom: 10px; }
.status-bar { padding: 10px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 20px; font-size: 13px; }
iframe { width: 100%; height: 100%; border: none; }
.debug-panel { height: 200px; border-top: 1px solid #ccc; background: #fafafa; display: flex; flex-direction: column; }
.tabs { display: flex; border-bottom: 1px solid #ddd; background: #eee; }
.tab { padding: 8px 16px; cursor: pointer; font-size: 12px; border-right: 1px solid #ddd; }
.tab.active { background: white; font-weight: bold; border-bottom: 2px solid #007aff; }
.tab-content { flex: 1; padding: 10px; overflow: auto; font-family: monospace; font-size: 11px; white-space: pre-wrap; display: none; }
.tab-content.active { display: block; }
.error-banner { background: #fee; color: #c00; padding: 10px; border: 1px solid #fcc; margin-bottom: 10px; display: none; }
</style>
</head>
<body>
<div class="sidebar">
<h3>IVR Flow JSON</h3>
<div class="controls">
<select id="exampleSelect" onchange="loadExample(this.value)" style="width:100%; padding:5px; margin-bottom:10px;">
<option value="">-- Select Example --</option>
<option value="demo_bad_flow.json">Demo Bad Flow</option>
<option value="demo_fixed_flow.json">Demo Fixed Flow</option>
<option value="invalid_dead_end.json">Invalid: Dead End</option>
<option value="invalid_unreachable.json">Invalid: Unreachable</option>
<option value="valid_basic.json">Valid: Basic</option>
</select>
<div style="display:flex; justify-content:space-between">
<button class="secondary" onclick="loadExample('demo_bad_flow.json')">Load Bad</button>
<button onclick="lint()">Lint & Render</button>
</div>
</div>
<div id="errorBanner" class="error-banner"></div>
<textarea id="jsonInput" placeholder="Paste flow JSON here..."></textarea>
</div>
<div class="main">
<div class="status-bar">
<span>Status: <strong id="statusText">Ready</strong></span>
<span>Score: <strong id="scoreText">-</strong></span>
<span>Errors: <strong id="errText">-</strong></span>
</div>
<div class="preview-container">
<iframe id="resultFrame"></iframe>
</div>
<div class="debug-panel">
<div class="tabs">
<div class="tab active" onclick="switchTab('parsed')">Parsed Result</div>
<div class="tab" onclick="switchTab('raw')">Raw JSON-RPC</div>
</div>
<div id="parsed" class="tab-content active">Waiting for analysis...</div>
<div id="raw" class="tab-content">Waiting for request...</div>
</div>
</div>
<script>
async function loadExample(filename) {
if(!filename) return;
try {
const res = await fetch('/examples/' + filename);
if(!res.ok) throw new Error('Failed to load ' + filename);
const json = await res.json();
document.getElementById('jsonInput').value = JSON.stringify(json, null, 2);
document.getElementById('statusText').innerText = 'Loaded ' + filename;
} catch(e) {
alert(e.message);
}
}
function switchTab(id) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`.tab[onclick="switchTab('${id}')"]`).classList.add('active');
document.getElementById(id).classList.add('active');
}
function showError(msg) {
const banner = document.getElementById('errorBanner');
banner.innerText = msg;
banner.style.display = 'block';
document.getElementById('statusText').innerText = 'Error';
document.getElementById('statusText').style.color = 'red';
}
async function lint() {
const inputVal = document.getElementById('jsonInput').value;
if(!inputVal.trim()) {
showError("Please enter JSON");
return;
}
document.getElementById('errorBanner').style.display = 'none';
document.getElementById('statusText').innerText = 'Linting...';
document.getElementById('statusText').style.color = '#333';
let json;
try {
json = JSON.parse(inputVal);
} catch(e) {
showError("Invalid JSON: " + e.message);
return;
}
try {
const res = await fetch('/mcp', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
params: { arguments: json },
id: Date.now()
})
});
const data = await res.json();
// Show Raw Response
document.getElementById('raw').innerText = JSON.stringify(data, null, 2);
if(data.error) {
showError(`JSON-RPC Error ${data.error.code}: ${data.error.message}`);
return;
}
if(data.result && data.result.data) {
const r = data.result.data;
document.getElementById('parsed').innerText =
`Score: ${r.score}\nErrors: ${r.errors.length}\nWarnings: ${r.warnings.length}\nHash: ${r.flow_hash}\n\nErrors List:\n${JSON.stringify(r.errors, null, 2)}`;
document.getElementById('scoreText').innerText = r.score;
document.getElementById('errText').innerText = r.errors.length;
document.getElementById('statusText').innerText = 'Success';
document.getElementById('statusText').style.color = 'green';
// Render Widget
const html = data.result.ui.html;
const frame = document.getElementById('resultFrame');
frame.contentWindow.document.open();
frame.contentWindow.document.write(html);
frame.contentWindow.document.close();
} else {
showError("Unexpected response format");
}
} catch(e) {
showError("Network/Server Error: " + e.message);
}
}
// Load default
loadExample('demo_bad_flow.json');
</script>
</body>
</html>
"""
return HTMLResponse(content=html)