GitHub Actions commited on
Commit
cdddc93
·
1 Parent(s): 3624fdd

Deploy backend from GitHub 2d3455a8d65c9fd8cd30c3146d96419d0e627d6f

Browse files
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 TestAttackSimulations:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)