Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 62 additions & 43 deletions backend/app/services/code_assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ def chat_fallback_reply(
code: str | None,
history: list[str],
level: str,
error_context: dict | None = None,
) -> str:
"""Return a simple fallback chat response when the LLM is unavailable."""
message_text = (message or "").strip()
Expand Down Expand Up @@ -242,6 +243,17 @@ def chat_fallback_reply(
f"Recent chat context: {recent_history}."
)

if error_context:
code_val = error_context.get("code", "")
message_val = error_context.get("message", "An upstream service error occurred.")
metadata_val = error_context.get("metadata", {})
error_detail = f"Error context — code: {code_val}, message: {message_val}"
if metadata_val:
meta_parts = [f"{k}: {v}" for k, v in metadata_val.items()]
if meta_parts:
error_detail += f", details: {'; '.join(meta_parts)}"
response_parts.append(error_detail)

return " ".join(response_parts)


Expand Down Expand Up @@ -862,14 +874,11 @@ def run_bug_detection(code: str, language: str) -> list[dict]:
)

if language == "Python":
try:
for issue in ast_analyze(code):
key = f"{issue['type']}:{issue['line']}"
if key not in seen:
seen.add(key)
found.append(issue)
except SyntaxError:
pass
for issue in ast_analyze(code):
key = f"{issue['type']}:{issue['line']}"
if key not in seen:
seen.add(key)
found.append(issue)

return found

Expand Down Expand Up @@ -1370,42 +1379,52 @@ def full_analysis(code: str, language_hint: str | None = None) -> dict:
language_hint: Optional language override hint.

Returns:
Combined explanation, debugging, and suggestion analysis results.
Combined explanation, debugging, and suggestion analysis results,
or a structured error response if an upstream failure occurs.
"""
try:
t0 = time.perf_counter()
language = detect_language(code, language_hint)

explanation = run_explanation(code, language)

raw_issues = run_bug_detection(code, language)
errors = [i for i in raw_issues if i["severity"] == "error"]
warnings = [i for i in raw_issues if i["severity"] == "warning"]
infos = [i for i in raw_issues if i["severity"] == "info"]
issue_summary = (
f"Found {len(raw_issues)} issue(s): {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info."
if raw_issues
else "✅ No issues detected!"
)
debugging = {
"issues": raw_issues,
"summary": issue_summary,
"clean": len(raw_issues) == 0,
"error_count": len(errors),
"warning_count": len(warnings),
"info_count": len(infos),
"code": code,
}

t0 = time.perf_counter()
language = detect_language(code, language_hint)

explanation = run_explanation(code, language)

raw_issues = run_bug_detection(code, language)
errors = [i for i in raw_issues if i["severity"] == "error"]
warnings = [i for i in raw_issues if i["severity"] == "warning"]
infos = [i for i in raw_issues if i["severity"] == "info"]
issue_summary = (
f"Found {len(raw_issues)} issue(s): {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info."
if raw_issues
else "✅ No issues detected!"
)
debugging = {
"issues": raw_issues,
"summary": issue_summary,
"clean": len(raw_issues) == 0,
"error_count": len(errors),
"warning_count": len(warnings),
"info_count": len(infos),
"code": code,
}

sugg = run_suggestions(code, language)
sugg = run_suggestions(code, language)

elapsed_ms = (time.perf_counter() - t0) * 1000
elapsed_ms = (time.perf_counter() - t0) * 1000

return {
"provider": "rule-based",
"model": "qyverix-engine-v3",
"explanation": explanation,
"debugging": debugging,
"suggestions": sugg,
"analysis_time_ms": round(elapsed_ms, 2),
}
return {
"provider": "rule-based",
"model": "qyverix-engine-v3",
"explanation": explanation,
"debugging": debugging,
"suggestions": sugg,
"analysis_time_ms": round(elapsed_ms, 2),
}
except Exception as exc:
return {
"code": "UPSTREAM_FAILURE",
"message": f"Analysis failed: {exc}",
"metadata": {
"error_type": type(exc).__name__,
"language_hint": language_hint,
},
}
68 changes: 67 additions & 1 deletion backend/tests/test_code_assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)

from app.services.code_assistant import chat_fallback_reply
from app.services.code_assistant import (
chat_fallback_reply,
full_analysis,
run_bug_detection,
)


def test_chat_fallback_reply_without_code_returns_retry_prompt() -> None:
Expand Down Expand Up @@ -46,3 +50,65 @@ def test_chat_fallback_reply_for_error_query_suggests_common_issues() -> None:

assert "common issues" in reply
assert "incorrect indentation" in reply or "missing imports" in reply


def test_chat_fallback_reply_appends_error_context() -> None:
error_ctx = {
"code": "UPSTREAM_FAILURE",
"message": "Analysis failed: connection refused",
"metadata": {"error_type": "ConnectionError"},
}
reply = chat_fallback_reply(
"Analyze this code",
"def bar():\n pass\n",
[],
"beginner",
error_context=error_ctx,
)

assert "UPSTREAM_FAILURE" in reply
assert "connection refused" in reply
assert "ConnectionError" in reply


def test_full_analysis_returns_structured_error_on_upstream_failure() -> None:
import app.services.code_assistant as ca_module

original_detect = ca_module.detect_language

def mock_detect(code, hint=None):
raise RuntimeError("simulated upstream failure")

try:
ca_module.detect_language = mock_detect
result = full_analysis("def foo(): pass")
finally:
ca_module.detect_language = original_detect

assert isinstance(result, dict)
assert "code" in result
assert "message" in result
assert "metadata" in result
assert result["code"] == "UPSTREAM_FAILURE"
assert "simulated upstream failure" in result["message"]


def test_run_bug_detection_propagates_exceptions() -> None:
import app.services.code_assistant as ca_module

original_analyze = ca_module.ast_analyze

def mock_analyze(code):
raise ValueError("ast analysis failed")

try:
ca_module.ast_analyze = mock_analyze
caught = False
try:
run_bug_detection("def foo(): pass", "Python")
except ValueError as exc:
caught = True
assert "ast analysis failed" in str(exc)
assert caught, "run_bug_detection should propagate exceptions instead of swallowing them"
finally:
ca_module.ast_analyze = original_analyze
84 changes: 66 additions & 18 deletions frontend/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,11 @@ function getApiUrl() {
}

function getUserFriendlyError(err, responseStatus) {
if (err && typeof err === 'object' && err.code && err.message && err.metadata !== undefined) {
const codeLabel = err.code ? `[${err.code}] ` : '';
return codeLabel + String(err.message);
}

const raw = (err && err.message ? String(err.message) : '').toLowerCase();

if (raw.includes('failed to fetch') || raw.includes('networkerror') || raw.includes('network request failed')) {
Expand All @@ -355,13 +360,70 @@ function getUserFriendlyError(err, responseStatus) {
return 'Server error while analyzing code. Try again in a moment.';
}

if (err && typeof err === 'object' && err.code) {
const codeLabel = err.code ? `[${err.code}] ` : '';
const message = err.message || 'An unexpected error occurred.';
return codeLabel + message;
}

if (err && err.message) {
return err.message;
}

return 'Could not reach the backend. Make sure it is running.';
}

const GRAPHQL_ERROR_MAP = {
'UPSTREAM_FAILURE': 'Upstream service unavailable',
'QUOTA_EXCEEDED': 'Provider quota exceeded',
'RATE_LIMITED': 'Rate limit exceeded',
'VALIDATION_ERROR': 'Invalid request parameters',
};

function formatGraphQLError(err) {
const code = err.code || (GRAPHQL_ERROR_MAP[err.message] ? Object.keys(GRAPHQL_ERROR_MAP).find(k => GRAPHQL_ERROR_MAP[k] === err.message) : null);
const mappedCode = code || err.code || 'GRAPHQL_ERROR';
return {
code: mappedCode,
message: err.message || 'A GraphQL error occurred.',
metadata: err.metadata || {},
};
}

function normalizeGraphQLErrors(errors) {
if (!Array.isArray(errors)) return [];
return errors.map(err => formatGraphQLError(err));
}

async function graphqlQuery(query, variables = {}) {
try {
const resp = await fetch(`${getApiUrl()}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});

const data = await resp.json().catch(() => ({}));

if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
const normalized = normalizeGraphQLErrors(data.errors);
const firstError = normalized[0];
const err = new Error(getUserFriendlyError(firstError, resp.status));
err.status = resp.status;
throw err;
}

return data;
} catch (err) {
if (err instanceof SyntaxError) {
const e = new Error('Failed to parse GraphQL response.');
e.status = resp.status;
throw e;
}
throw err;
}
}

initializeApiUrl();
apiUrlInput.addEventListener('change', () => {
localStorage.setItem(API_URL_STORAGE_KEY, getApiUrl());
Expand All @@ -383,28 +445,14 @@ async function runAnalysis() {
runLabel.textContent = '⟳ Analyzing...';
showLoading();

const url = `${getApiUrl()}/${currentMode === 'analyze' ? 'analyze' : currentMode}/`;

try {
let responseStatus = 0;
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
responseStatus = resp.status;

if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(getUserFriendlyError({ message: err.detail || `HTTP ${resp.status}` }, responseStatus));
}

const data = await resp.json();
const mutation = `mutation RunAnalysis($code: String!) { ${currentMode}(code: $code) }`;
const data = await graphqlQuery(mutation, { code });
renderResult(data, currentMode);
saveHistory(code, currentMode, data);
statusDot.className = 'status-dot online';
} catch (err) {
showError(getUserFriendlyError(err, 0));
showError(getUserFriendlyError(err, err.status ?? 0));
statusDot.className = 'status-dot offline';
setEngineBadge('unknown');
} finally {
Expand Down Expand Up @@ -601,4 +649,4 @@ function showToast(msg) {

// ── Init ──
renderHistory();
renderFavorites();
renderFavorites();