GitHub Actions commited on
Commit ·
cdddc93
1
Parent(s): 3624fdd
Deploy backend from GitHub 2d3455a8d65c9fd8cd30c3146d96419d0e627d6f
Browse files- backend/app/api/models.py +10 -0
- backend/app/api/routes.py +62 -0
- backend/app/db/firestore.py +7 -0
- backend/tests/test_api.py +102 -1
backend/app/api/models.py
CHANGED
|
@@ -43,3 +43,13 @@ class AnalyzeResponse(BaseModel):
|
|
| 43 |
class HealthResponse(BaseModel):
|
| 44 |
status: str = "ok"
|
| 45 |
version: str = "1.0.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
class HealthResponse(BaseModel):
|
| 44 |
status: str = "ok"
|
| 45 |
version: str = "1.0.0"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class AssistRequest(BaseModel):
|
| 49 |
+
text: str = Field(..., min_length=10, max_length=50000, description="Text to rewrite/fix")
|
| 50 |
+
threat_score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Threat score from analysis")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class AssistResponse(BaseModel):
|
| 54 |
+
fixed_text: str
|
| 55 |
+
request_logs: List[str]
|
backend/app/api/routes.py
CHANGED
|
@@ -10,9 +10,13 @@ from datetime import datetime, timezone
|
|
| 10 |
|
| 11 |
from fastapi import APIRouter, HTTPException
|
| 12 |
|
|
|
|
|
|
|
| 13 |
from backend.app.api.models import (
|
| 14 |
AnalyzeRequest,
|
| 15 |
AnalyzeResponse,
|
|
|
|
|
|
|
| 16 |
BulkAnalyzeRequest,
|
| 17 |
ExplainabilityItem,
|
| 18 |
SignalScores,
|
|
@@ -184,3 +188,61 @@ async def get_result(result_id: str):
|
|
| 184 |
explainability=[ExplainabilityItem(**e) for e in (data.get("explainability") or [])],
|
| 185 |
processing_time_ms=data.get("processing_time_ms"),
|
| 186 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
from fastapi import APIRouter, HTTPException
|
| 12 |
|
| 13 |
+
import httpx
|
| 14 |
+
|
| 15 |
from backend.app.api.models import (
|
| 16 |
AnalyzeRequest,
|
| 17 |
AnalyzeResponse,
|
| 18 |
+
AssistRequest,
|
| 19 |
+
AssistResponse,
|
| 20 |
BulkAnalyzeRequest,
|
| 21 |
ExplainabilityItem,
|
| 22 |
SignalScores,
|
|
|
|
| 188 |
explainability=[ExplainabilityItem(**e) for e in (data.get("explainability") or [])],
|
| 189 |
processing_time_ms=data.get("processing_time_ms"),
|
| 190 |
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@router.post("/assist", response_model=AssistResponse)
|
| 194 |
+
async def assist_text(request: AssistRequest):
|
| 195 |
+
"""Call Groq API to propose a rewrite of flagged text to reduce AI threat indicators."""
|
| 196 |
+
if not settings.GROQ_API_KEY:
|
| 197 |
+
raise HTTPException(
|
| 198 |
+
status_code=503,
|
| 199 |
+
detail="AI Fixer is not configured: GROQ_API_KEY is missing",
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
rate_ok = await check_rate_limit("assist:global", limit=10)
|
| 203 |
+
if not rate_ok:
|
| 204 |
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
| 205 |
+
|
| 206 |
+
logs: list[str] = []
|
| 207 |
+
logs.append(f"Preparing rewrite request for Groq model: {settings.GROQ_MODEL}")
|
| 208 |
+
|
| 209 |
+
prompt = (
|
| 210 |
+
"You are a text editor. Rewrite the following text to sound more natural and human-authored "
|
| 211 |
+
"while preserving the original meaning and factual content. "
|
| 212 |
+
"Return only the rewritten text without any explanation or commentary.\n\n"
|
| 213 |
+
f"Original text:\n{request.text}"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
try:
|
| 217 |
+
logs.append("Sending request to Groq API…")
|
| 218 |
+
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
| 219 |
+
response = await http_client.post(
|
| 220 |
+
f"{settings.GROQ_BASE_URL}/chat/completions",
|
| 221 |
+
headers={
|
| 222 |
+
"Authorization": f"Bearer {settings.GROQ_API_KEY}",
|
| 223 |
+
"Content-Type": "application/json",
|
| 224 |
+
},
|
| 225 |
+
json={
|
| 226 |
+
"model": settings.GROQ_MODEL,
|
| 227 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 228 |
+
"max_tokens": 8192,
|
| 229 |
+
"temperature": 0.7,
|
| 230 |
+
},
|
| 231 |
+
)
|
| 232 |
+
response.raise_for_status()
|
| 233 |
+
data = response.json()
|
| 234 |
+
fixed_text = data["choices"][0]["message"]["content"].strip()
|
| 235 |
+
logs.append("Groq model returned rewritten text successfully.")
|
| 236 |
+
except httpx.TimeoutException:
|
| 237 |
+
logger.warning("Groq API timeout in /api/assist")
|
| 238 |
+
raise HTTPException(status_code=504, detail="AI Fixer request timed out")
|
| 239 |
+
except httpx.HTTPStatusError as e:
|
| 240 |
+
logger.warning("Groq API error in /api/assist", status_code=e.response.status_code)
|
| 241 |
+
raise HTTPException(
|
| 242 |
+
status_code=502, detail=f"AI Fixer upstream error: {e.response.status_code}"
|
| 243 |
+
)
|
| 244 |
+
except Exception as e:
|
| 245 |
+
logger.warning("Unexpected error in /api/assist", error=str(e))
|
| 246 |
+
raise HTTPException(status_code=500, detail="AI Fixer failed unexpectedly")
|
| 247 |
+
|
| 248 |
+
return AssistResponse(fixed_text=fixed_text, request_logs=logs)
|
backend/app/db/firestore.py
CHANGED
|
@@ -21,6 +21,7 @@ from typing import Any
|
|
| 21 |
|
| 22 |
import firebase_admin
|
| 23 |
from firebase_admin import credentials, firestore as fb_firestore
|
|
|
|
| 24 |
|
| 25 |
from backend.app.core.config import settings
|
| 26 |
from backend.app.core.logging import get_logger
|
|
@@ -74,6 +75,9 @@ async def save_document(collection: str, doc_id: str, data: dict) -> bool:
|
|
| 74 |
try:
|
| 75 |
_db.collection(collection).document(doc_id).set(data)
|
| 76 |
return True
|
|
|
|
|
|
|
|
|
|
| 77 |
except Exception as e:
|
| 78 |
logger.warning("Firestore save_document failed", collection=collection, doc_id=doc_id, error=str(e))
|
| 79 |
return False
|
|
@@ -86,6 +90,9 @@ async def get_document(collection: str, doc_id: str) -> dict | None:
|
|
| 86 |
try:
|
| 87 |
doc = _db.collection(collection).document(doc_id).get()
|
| 88 |
return doc.to_dict() if doc.exists else None
|
|
|
|
|
|
|
|
|
|
| 89 |
except Exception as e:
|
| 90 |
logger.warning("Firestore get_document failed", collection=collection, doc_id=doc_id, error=str(e))
|
| 91 |
return None
|
|
|
|
| 21 |
|
| 22 |
import firebase_admin
|
| 23 |
from firebase_admin import credentials, firestore as fb_firestore
|
| 24 |
+
import google.auth.exceptions
|
| 25 |
|
| 26 |
from backend.app.core.config import settings
|
| 27 |
from backend.app.core.logging import get_logger
|
|
|
|
| 75 |
try:
|
| 76 |
_db.collection(collection).document(doc_id).set(data)
|
| 77 |
return True
|
| 78 |
+
except google.auth.exceptions.RefreshError as e:
|
| 79 |
+
logger.debug("Firestore save_document skipped – credential refresh failed", collection=collection, doc_id=doc_id, error=str(e))
|
| 80 |
+
return False
|
| 81 |
except Exception as e:
|
| 82 |
logger.warning("Firestore save_document failed", collection=collection, doc_id=doc_id, error=str(e))
|
| 83 |
return False
|
|
|
|
| 90 |
try:
|
| 91 |
doc = _db.collection(collection).document(doc_id).get()
|
| 92 |
return doc.to_dict() if doc.exists else None
|
| 93 |
+
except google.auth.exceptions.RefreshError as e:
|
| 94 |
+
logger.debug("Firestore get_document skipped – credential refresh failed", collection=collection, doc_id=doc_id, error=str(e))
|
| 95 |
+
return None
|
| 96 |
except Exception as e:
|
| 97 |
logger.warning("Firestore get_document failed", collection=collection, doc_id=doc_id, error=str(e))
|
| 98 |
return None
|
backend/tests/test_api.py
CHANGED
|
@@ -118,8 +118,109 @@ class TestAnalyzeEndpoint:
|
|
| 118 |
assert response.status_code == 422
|
| 119 |
|
| 120 |
|
| 121 |
-
class
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
@patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=True)
|
| 124 |
@patch("backend.app.api.routes.detect_ai_text", new_callable=AsyncMock, return_value=0.95)
|
| 125 |
@patch("backend.app.api.routes.compute_perplexity", new_callable=AsyncMock, return_value=0.8)
|
|
|
|
| 118 |
assert response.status_code == 422
|
| 119 |
|
| 120 |
|
| 121 |
+
class TestAssistEndpoint:
|
| 122 |
+
def test_assist_no_api_key_returns_503(self, client):
|
| 123 |
+
"""When GROQ_API_KEY is not set, /api/assist should return 503."""
|
| 124 |
+
from backend.app.core import config
|
| 125 |
+
original = config.settings.GROQ_API_KEY
|
| 126 |
+
try:
|
| 127 |
+
config.settings.GROQ_API_KEY = ""
|
| 128 |
+
response = client.post(
|
| 129 |
+
"/api/assist",
|
| 130 |
+
json={"text": "This is a test text that needs to be fixed by the AI assistant."},
|
| 131 |
+
)
|
| 132 |
+
assert response.status_code == 503
|
| 133 |
+
assert "GROQ_API_KEY" in response.json()["detail"]
|
| 134 |
+
finally:
|
| 135 |
+
config.settings.GROQ_API_KEY = original
|
| 136 |
+
|
| 137 |
+
@patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=True)
|
| 138 |
+
@patch("backend.app.api.routes.httpx.AsyncClient")
|
| 139 |
+
def test_assist_returns_fixed_text(self, mock_http_cls, mock_rate, client):
|
| 140 |
+
"""When Groq API responds, /api/assist should return fixed_text and logs."""
|
| 141 |
+
from backend.app.core import config
|
| 142 |
+
original = config.settings.GROQ_API_KEY
|
| 143 |
+
try:
|
| 144 |
+
config.settings.GROQ_API_KEY = "test-groq-key"
|
| 145 |
+
# Set up mock response
|
| 146 |
+
mock_response = MagicMock()
|
| 147 |
+
mock_response.json.return_value = {
|
| 148 |
+
"choices": [{"message": {"content": "This is the improved text."}}]
|
| 149 |
+
}
|
| 150 |
+
mock_response.raise_for_status = MagicMock()
|
| 151 |
+
mock_http_instance = MagicMock()
|
| 152 |
+
mock_http_instance.__aenter__ = AsyncMock(return_value=mock_http_instance)
|
| 153 |
+
mock_http_instance.__aexit__ = AsyncMock(return_value=False)
|
| 154 |
+
mock_http_instance.post = AsyncMock(return_value=mock_response)
|
| 155 |
+
mock_http_cls.return_value = mock_http_instance
|
| 156 |
+
|
| 157 |
+
response = client.post(
|
| 158 |
+
"/api/assist",
|
| 159 |
+
json={"text": "This is a test text that needs to be fixed by the AI assistant."},
|
| 160 |
+
)
|
| 161 |
+
assert response.status_code == 200
|
| 162 |
+
data = response.json()
|
| 163 |
+
assert data["fixed_text"] == "This is the improved text."
|
| 164 |
+
assert isinstance(data["request_logs"], list)
|
| 165 |
+
assert len(data["request_logs"]) > 0
|
| 166 |
+
finally:
|
| 167 |
+
config.settings.GROQ_API_KEY = original
|
| 168 |
|
| 169 |
+
@patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=False)
|
| 170 |
+
def test_assist_rate_limited(self, mock_rate, client):
|
| 171 |
+
"""When rate limit is exceeded, /api/assist should return 429."""
|
| 172 |
+
from backend.app.core import config
|
| 173 |
+
original = config.settings.GROQ_API_KEY
|
| 174 |
+
try:
|
| 175 |
+
config.settings.GROQ_API_KEY = "test-groq-key"
|
| 176 |
+
response = client.post(
|
| 177 |
+
"/api/assist",
|
| 178 |
+
json={"text": "This is a test text that needs to be fixed."},
|
| 179 |
+
)
|
| 180 |
+
assert response.status_code == 429
|
| 181 |
+
finally:
|
| 182 |
+
config.settings.GROQ_API_KEY = original
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class TestFirestoreRefreshError:
|
| 186 |
+
"""Verify RefreshError is logged at DEBUG, not WARNING."""
|
| 187 |
+
|
| 188 |
+
@pytest.mark.asyncio
|
| 189 |
+
async def test_save_document_refresh_error_returns_false(self):
|
| 190 |
+
"""RefreshError in save_document should return False without raising."""
|
| 191 |
+
import google.auth.exceptions
|
| 192 |
+
from unittest.mock import MagicMock, patch
|
| 193 |
+
|
| 194 |
+
mock_db = MagicMock()
|
| 195 |
+
mock_db.collection.return_value.document.return_value.set.side_effect = (
|
| 196 |
+
google.auth.exceptions.RefreshError("invalid_grant: Invalid JWT Signature")
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
with patch("backend.app.db.firestore._enabled", True), \
|
| 200 |
+
patch("backend.app.db.firestore._db", mock_db):
|
| 201 |
+
from backend.app.db.firestore import save_document
|
| 202 |
+
result = await save_document("test_col", "test_doc", {"key": "value"})
|
| 203 |
+
assert result is False
|
| 204 |
+
|
| 205 |
+
@pytest.mark.asyncio
|
| 206 |
+
async def test_get_document_refresh_error_returns_none(self):
|
| 207 |
+
"""RefreshError in get_document should return None without raising."""
|
| 208 |
+
import google.auth.exceptions
|
| 209 |
+
from unittest.mock import MagicMock, patch
|
| 210 |
+
|
| 211 |
+
mock_db = MagicMock()
|
| 212 |
+
mock_db.collection.return_value.document.return_value.get.side_effect = (
|
| 213 |
+
google.auth.exceptions.RefreshError("invalid_grant: Invalid JWT Signature")
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
with patch("backend.app.db.firestore._enabled", True), \
|
| 217 |
+
patch("backend.app.db.firestore._db", mock_db):
|
| 218 |
+
from backend.app.db.firestore import get_document
|
| 219 |
+
result = await get_document("test_col", "test_doc")
|
| 220 |
+
assert result is None
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
class TestAttackSimulations:
|
| 224 |
@patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=True)
|
| 225 |
@patch("backend.app.api.routes.detect_ai_text", new_callable=AsyncMock, return_value=0.95)
|
| 226 |
@patch("backend.app.api.routes.compute_perplexity", new_callable=AsyncMock, return_value=0.8)
|