diff --git a/backend/app/services/code_assistant.py b/backend/app/services/code_assistant.py index 951853c..c06e3cc 100644 --- a/backend/app/services/code_assistant.py +++ b/backend/app/services/code_assistant.py @@ -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() @@ -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) @@ -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 @@ -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, + }, + } diff --git a/backend/tests/test_code_assistant.py b/backend/tests/test_code_assistant.py index ec84d2e..f8becc3 100644 --- a/backend/tests/test_code_assistant.py +++ b/backend/tests/test_code_assistant.py @@ -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: @@ -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 diff --git a/frontend/script.js b/frontend/script.js index 426f8d7..49bb4c9 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -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')) { @@ -355,6 +360,12 @@ 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; } @@ -362,6 +373,57 @@ function getUserFriendlyError(err, responseStatus) { 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()); @@ -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 { @@ -601,4 +649,4 @@ function showToast(msg) { // ── Init ── renderHistory(); -renderFavorites(); \ No newline at end of file +renderFavorites();