milan1 commited on
Commit
9a1338b
·
verified ·
1 Parent(s): 98332ab

Deploy 2b3655ac from GitHub Actions

Browse files
app/agents/base.py CHANGED
@@ -39,7 +39,43 @@ _MAX_RETRY_DELAY = 120.0 # seconds — cap for retry_delay parsed from API resp
39
 
40
 
41
  class QuotaExceededError(RuntimeError):
42
- """Raised when the Gemini API quota / rate-limit is exhausted."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
 
45
  def _parse_retry_delay(err_str: str) -> Optional[float]:
@@ -210,12 +246,7 @@ class BaseAgent(ABC):
210
  last_error = e
211
  err_str = str(e)
212
 
213
- is_quota = (
214
- "429" in err_str
215
- or "rate_limit" in err_str.lower()
216
- or "quota" in err_str.lower()
217
- or "RESOURCE_EXHAUSTED" in err_str
218
- )
219
 
220
  if is_quota:
221
  # Try fallback provider if available and not already using it
@@ -252,8 +283,10 @@ class BaseAgent(ABC):
252
 
253
  logger.error(f"{self.name}: All providers exhausted — {e}")
254
  raise QuotaExceededError(
255
- f"Rate limit exceeded for {self.name}. "
256
- "Please try again later."
 
 
257
  ) from e
258
 
259
  # Transient non-quota error — exponential backoff
 
39
 
40
 
41
  class QuotaExceededError(RuntimeError):
42
+ """
43
+ Raised when a provider refuses the request for quota reasons.
44
+
45
+ Covers rate limits (429), exhausted free-tier grants (RESOURCE_EXHAUSTED)
46
+ and billing exhaustion (402 payment_required) alike: from the caller's
47
+ point of view all three mean "this provider will not serve us right now,
48
+ and retrying the same call immediately will not help".
49
+ """
50
+
51
+ def __init__(self, message: str, provider: str = "", agent: str = ""):
52
+ super().__init__(message)
53
+ self.provider = provider
54
+ self.agent = agent
55
+
56
+
57
+ # Substrings that identify a quota/billing refusal in a provider error message.
58
+ # 402 matters as much as 429 here: Cerebras answers an exhausted account with
59
+ # `Error code: 402 ... payment_required_error`, which the old 429-only check
60
+ # classified as a transient error and then slept through three pointless
61
+ # retries before returning an empty dict.
62
+ _QUOTA_MARKERS = (
63
+ "429",
64
+ "rate_limit",
65
+ "rate limit",
66
+ "quota",
67
+ "resource_exhausted",
68
+ "error code: 402",
69
+ "payment_required",
70
+ "insufficient_quota",
71
+ "billing",
72
+ )
73
+
74
+
75
+ def _is_quota_error(err_str: str) -> bool:
76
+ """True when a provider error message describes a quota/billing refusal."""
77
+ lowered = err_str.lower()
78
+ return any(marker in lowered for marker in _QUOTA_MARKERS)
79
 
80
 
81
  def _parse_retry_delay(err_str: str) -> Optional[float]:
 
246
  last_error = e
247
  err_str = str(e)
248
 
249
+ is_quota = _is_quota_error(err_str)
 
 
 
 
 
250
 
251
  if is_quota:
252
  # Try fallback provider if available and not already using it
 
283
 
284
  logger.error(f"{self.name}: All providers exhausted — {e}")
285
  raise QuotaExceededError(
286
+ f"{self.provider} quota exhausted for {self.name} "
287
+ f"({err_str[:200]})",
288
+ provider=self.provider,
289
+ agent=self.name,
290
  ) from e
291
 
292
  # Transient non-quota error — exponential backoff
app/agents/compliance_auditor.py CHANGED
@@ -3,7 +3,7 @@ Compliance Auditor Agent
3
  Cross-references a regulation clause against operational document evidence
4
  to determine compliance status (compliant / gap / missing).
5
 
6
- Uses Gemini for the nuanced cross-referencing task that requires reasoning
7
  across multiple evidence chunks.
8
  """
9
 
@@ -107,7 +107,7 @@ Always cite specific evidence in your assessment. If no evidence is provided, ma
107
  prompt = (
108
  "Assess compliance for the following regulation clause against "
109
  "the provided operational document evidence.\n\n"
110
- f"REGULATION CLAUSE{text}:\n{text}\n\n"
111
  f"{section_hint}\n\n"
112
  f"EVIDENCE FROM OPERATIONAL DOCUMENTS:\n{evidence_text}\n\n"
113
  "Respond with a JSON object:\n"
 
3
  Cross-references a regulation clause against operational document evidence
4
  to determine compliance status (compliant / gap / missing).
5
 
6
+ Runs on Groq for the nuanced cross-referencing task that requires reasoning
7
  across multiple evidence chunks.
8
  """
9
 
 
107
  prompt = (
108
  "Assess compliance for the following regulation clause against "
109
  "the provided operational document evidence.\n\n"
110
+ f"REGULATION CLAUSE:\n{text}\n\n"
111
  f"{section_hint}\n\n"
112
  f"EVIDENCE FROM OPERATIONAL DOCUMENTS:\n{evidence_text}\n\n"
113
  "Respond with a JSON object:\n"
app/agents/entity_extractor.py CHANGED
@@ -26,7 +26,18 @@ class EntityExtractorAgent(BaseAgent):
26
 
27
  def __init__(self):
28
  # Cerebras: 1M tokens/day free, 2600+ TPS, 60K TPM — better for high-volume extraction than Groq
29
- super().__init__(model_name="gpt-oss-120b", provider="cerebras")
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  @property
32
  def system_prompt(self) -> str:
@@ -108,6 +119,22 @@ Notes:
108
  """
109
  result = await self._generate_json(prompt)
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  entities = {
112
  "equipment": self._deduplicate(result.get("equipment", [])),
113
  "chemicals": self._deduplicate(result.get("chemicals", [])),
 
26
 
27
  def __init__(self):
28
  # Cerebras: 1M tokens/day free, 2600+ TPS, 60K TPM — better for high-volume extraction than Groq
29
+ super().__init__(
30
+ model_name="gpt-oss-120b",
31
+ provider="cerebras",
32
+ # Cerebras answers an exhausted account with 402
33
+ # payment_required, which took this agent out entirely while
34
+ # Groq was still serving. Groq hosts the same gpt-oss-120b
35
+ # weights, so the fallback is the same model on another host
36
+ # rather than a downgrade. It is only ever used after a quota
37
+ # refusal, so it costs nothing on the happy path.
38
+ fallback_provider="groq",
39
+ fallback_model="openai/gpt-oss-120b",
40
+ )
41
 
42
  @property
43
  def system_prompt(self) -> str:
 
119
  """
120
  result = await self._generate_json(prompt)
121
 
122
+ # An exhausted _generate_json returns {}, which used to be laundered
123
+ # into six empty lists — a document with no entities and a document the
124
+ # extractor never managed to read looked identical. Mark the failure.
125
+ if not result:
126
+ return {
127
+ "equipment": [],
128
+ "chemicals": [],
129
+ "locations": [],
130
+ "personnel": [],
131
+ "dates": [],
132
+ "regulations": [],
133
+ "entity_count": 0,
134
+ "status": "error",
135
+ "error": "entity extractor provider returned no usable response",
136
+ }
137
+
138
  entities = {
139
  "equipment": self._deduplicate(result.get("equipment", [])),
140
  "chemicals": self._deduplicate(result.get("chemicals", [])),
app/agents/orchestrator.py CHANGED
@@ -162,22 +162,47 @@ class AgentOrchestrator:
162
  (datetime.now(timezone.utc) - t1).total_seconds() * 1000
163
  )
164
 
165
- # Handle per-agent exceptions gracefully
 
 
 
 
 
166
  def _quota_error_result(agent_name: str, exc: Exception) -> dict:
167
  is_quota = isinstance(exc, QuotaExceededError)
 
168
  logger.error(f"{agent_name} failed: {exc}")
169
  return {
170
  "error": str(exc),
 
171
  "quota_exceeded": is_quota,
172
  "status": "quota_exceeded" if is_quota else "error",
 
 
 
 
 
173
  }
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  if isinstance(safety, Exception):
176
  safety = {
177
  **_quota_error_result("SafetyAnalyzerAgent", safety),
178
  "score": None,
179
  "hazards": [],
180
- "recommendations": [],
181
  }
182
 
183
  if isinstance(entities, Exception):
@@ -194,10 +219,31 @@ class AgentOrchestrator:
194
  if isinstance(summary, Exception):
195
  summary = {
196
  **_quota_error_result("SummarizerAgent", summary),
197
- "summary": "Analysis failed — the summarizer's provider rate-limited this request. Please try again later.",
198
  "key_points": [],
199
  }
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  total_ms = int(
202
  (datetime.now(timezone.utc) - start_time).total_seconds() * 1000
203
  )
@@ -219,6 +265,7 @@ class AgentOrchestrator:
219
  ],
220
  "analyzed_at": datetime.now(timezone.utc).isoformat(),
221
  "cache_hit": False,
 
222
  },
223
  }
224
 
 
162
  (datetime.now(timezone.utc) - t1).total_seconds() * 1000
163
  )
164
 
165
+ # Handle per-agent exceptions gracefully.
166
+ #
167
+ # gather(return_exceptions=True) means one agent's provider dying
168
+ # never costs the other two: whatever classification, safety and
169
+ # entity work succeeded is still returned and still persisted. The
170
+ # failed section is replaced by a placeholder that says so.
171
  def _quota_error_result(agent_name: str, exc: Exception) -> dict:
172
  is_quota = isinstance(exc, QuotaExceededError)
173
+ provider = getattr(exc, "provider", "") or "the provider"
174
  logger.error(f"{agent_name} failed: {exc}")
175
  return {
176
  "error": str(exc),
177
+ "provider": getattr(exc, "provider", ""),
178
  "quota_exceeded": is_quota,
179
  "status": "quota_exceeded" if is_quota else "error",
180
+ "unavailable_reason": (
181
+ f"{provider} quota exhausted"
182
+ if is_quota
183
+ else f"{provider} error"
184
+ ),
185
  }
186
 
187
+ def _unavailable_text(agent_label: str, exc: Exception) -> str:
188
+ if isinstance(exc, QuotaExceededError):
189
+ provider = getattr(exc, "provider", "") or "the AI provider"
190
+ return (
191
+ f"{agent_label} unavailable — the {provider} account has "
192
+ "no quota left for this request. Click Re-analyze once "
193
+ "quota is restored."
194
+ )
195
+ return (
196
+ f"{agent_label} unavailable — the AI provider failed for this "
197
+ f"request ({str(exc)[:150]}). Click Re-analyze to try again."
198
+ )
199
+
200
  if isinstance(safety, Exception):
201
  safety = {
202
  **_quota_error_result("SafetyAnalyzerAgent", safety),
203
  "score": None,
204
  "hazards": [],
205
+ "recommendations": [_unavailable_text("Safety analysis", safety)],
206
  }
207
 
208
  if isinstance(entities, Exception):
 
219
  if isinstance(summary, Exception):
220
  summary = {
221
  **_quota_error_result("SummarizerAgent", summary),
222
+ "summary": _unavailable_text("Summary", summary),
223
  "key_points": [],
224
  }
225
 
226
+ # Sections that produced a placeholder rather than real analysis.
227
+ # document_service surfaces this to the user through
228
+ # Document.processing_error, because the API response coerces
229
+ # entities to plain lists and would otherwise drop every marker.
230
+ degraded_sections = {
231
+ name: section.get("unavailable_reason")
232
+ or section.get("error")
233
+ or "unavailable"
234
+ for name, section in (
235
+ ("safety", safety),
236
+ ("entities", entities),
237
+ ("summary", summary),
238
+ )
239
+ if isinstance(section, dict)
240
+ and (
241
+ section.get("error")
242
+ or section.get("quota_exceeded")
243
+ or section.get("status") in ("error", "quota_exceeded")
244
+ )
245
+ }
246
+
247
  total_ms = int(
248
  (datetime.now(timezone.utc) - start_time).total_seconds() * 1000
249
  )
 
265
  ],
266
  "analyzed_at": datetime.now(timezone.utc).isoformat(),
267
  "cache_hit": False,
268
+ "degraded_sections": degraded_sections,
269
  },
270
  }
271
 
app/agents/summarizer.py CHANGED
@@ -23,7 +23,18 @@ class SummarizerAgent(BaseAgent):
23
 
24
  def __init__(self):
25
  # Shift to Cerebras (gpt-oss-120b) to bypass Gemini API rate limits and avoid Groq parallel execution rate limits
26
- super().__init__(model_name="gpt-oss-120b", provider="cerebras")
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  @property
29
  def system_prompt(self) -> str:
@@ -89,6 +100,28 @@ Respond with a JSON object:
89
  """
90
  result = await self._generate_json(prompt)
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  summary = result.get("summary") or "Summary not available."
93
  return {
94
  "summary": summary,
 
23
 
24
  def __init__(self):
25
  # Shift to Cerebras (gpt-oss-120b) to bypass Gemini API rate limits and avoid Groq parallel execution rate limits
26
+ super().__init__(
27
+ model_name="gpt-oss-120b",
28
+ provider="cerebras",
29
+ # Cerebras answers an exhausted account with 402
30
+ # payment_required, which took this agent out entirely while
31
+ # Groq was still serving. Groq hosts the same gpt-oss-120b
32
+ # weights, so the fallback is the same model on another host
33
+ # rather than a downgrade. It is only ever used after a quota
34
+ # refusal, so it costs nothing on the happy path.
35
+ fallback_provider="groq",
36
+ fallback_model="openai/gpt-oss-120b",
37
+ )
38
 
39
  @property
40
  def system_prompt(self) -> str:
 
100
  """
101
  result = await self._generate_json(prompt)
102
 
103
+ # _generate_json returns {} once it has exhausted its retries on a
104
+ # non-quota failure. Falling through to "Summary not available." made
105
+ # that indistinguishable from a successful run over an empty document,
106
+ # and it was stored with confidence 0.7. Say plainly that nothing was
107
+ # produced, and carry an error marker so the orchestrator, the analysis
108
+ # cache and document_service can all see it.
109
+ if not result:
110
+ message = (
111
+ "Summary unavailable — the summarization provider returned no "
112
+ "usable response after retries. Re-analyze to try again."
113
+ )
114
+ return {
115
+ "summary": message,
116
+ "key_points": [],
117
+ "action_items": [],
118
+ "document_purpose": "",
119
+ "confidence": 0.0,
120
+ "word_count": 0,
121
+ "status": "error",
122
+ "error": "summarizer provider returned no usable response",
123
+ }
124
+
125
  summary = result.get("summary") or "Summary not available."
126
  return {
127
  "summary": summary,
app/api/v1/chat.py CHANGED
@@ -257,23 +257,28 @@ async def send_message(
257
  db.commit()
258
  db.refresh(session)
259
 
 
 
 
 
 
 
 
 
 
260
  # Save user message (but don't commit yet - wait for successful response)
261
  user_message = ChatMessage(
262
  session_id=session.id, role="user", content=request.content
263
  )
264
  db.add(user_message)
265
 
266
- # Generate AI response with RAG
267
- from app.services.chat_service import CHAT_MODEL, ChatService
268
-
269
- chat_service = ChatService()
270
-
271
  try:
272
  ai_response, sources, tokens_used = await chat_service.generate_response(
273
  query=validated_query,
274
  user_id=user_id,
275
  document_ids=request.document_ids,
276
  db=db,
 
277
  )
278
 
279
  # Calculate response time
 
257
  db.commit()
258
  db.refresh(session)
259
 
260
+ # Generate AI response with RAG
261
+ from app.services.chat_service import CHAT_MODEL, ChatService
262
+
263
+ chat_service = ChatService()
264
+
265
+ # Load prior turns BEFORE staging this one. The add() below is uncommitted,
266
+ # but autoflush would still surface it to the history query.
267
+ history = chat_service.load_session_history(db, session.id)
268
+
269
  # Save user message (but don't commit yet - wait for successful response)
270
  user_message = ChatMessage(
271
  session_id=session.id, role="user", content=request.content
272
  )
273
  db.add(user_message)
274
 
 
 
 
 
 
275
  try:
276
  ai_response, sources, tokens_used = await chat_service.generate_response(
277
  query=validated_query,
278
  user_id=user_id,
279
  document_ids=request.document_ids,
280
  db=db,
281
+ history=history,
282
  )
283
 
284
  # Calculate response time
app/api/v1/chat_stream.py CHANGED
@@ -78,6 +78,12 @@ async def stream_chat(
78
  db.commit()
79
  db.refresh(session)
80
 
 
 
 
 
 
 
81
  # Save user message immediately
82
  user_message = ChatMessage(
83
  session_id=session.id,
@@ -87,8 +93,6 @@ async def stream_chat(
87
  db.add(user_message)
88
  db.commit()
89
 
90
- chat_service = ChatService()
91
-
92
  async def event_generator():
93
  full_response = []
94
  sources = []
@@ -100,6 +104,7 @@ async def stream_chat(
100
  user_id=user_id,
101
  document_ids=request.document_ids,
102
  db=db,
 
103
  ):
104
  # Forward each SSE event to client
105
  yield event
 
78
  db.commit()
79
  db.refresh(session)
80
 
81
+ chat_service = ChatService()
82
+
83
+ # Load prior turns BEFORE persisting this one, or the current question is
84
+ # replayed to the model as its own history.
85
+ history = chat_service.load_session_history(db, session.id)
86
+
87
  # Save user message immediately
88
  user_message = ChatMessage(
89
  session_id=session.id,
 
93
  db.add(user_message)
94
  db.commit()
95
 
 
 
96
  async def event_generator():
97
  full_response = []
98
  sources = []
 
104
  user_id=user_id,
105
  document_ids=request.document_ids,
106
  db=db,
107
+ history=history,
108
  ):
109
  # Forward each SSE event to client
110
  yield event
app/api/v1/health.py CHANGED
@@ -3,9 +3,12 @@ Health Check Endpoints
3
  System health and status monitoring
4
  """
5
 
 
 
6
  from datetime import datetime
 
7
 
8
- from fastapi import APIRouter, Depends
9
  from sqlalchemy.orm import Session
10
 
11
  from app.config import settings
@@ -14,6 +17,136 @@ from app.schemas.common import HealthResponse
14
 
15
  router = APIRouter()
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  @router.get("/", response_model=HealthResponse)
19
  async def root():
@@ -27,19 +160,37 @@ async def root():
27
 
28
 
29
  @router.get("/health", response_model=HealthResponse)
30
- async def health_check(db: Session = Depends(get_db)):
31
  """
32
  Detailed health check with service status.
33
- Checks database connectivity and other services.
 
 
 
 
 
 
 
 
 
 
 
 
34
  """
35
- services = {"database": "unknown", "redis": "unknown", "ai": "unknown"}
 
 
 
 
36
 
37
  # Check database
 
38
  try:
39
  from sqlalchemy import text
40
 
41
  db.execute(text("SELECT 1"))
42
  services["database"] = "healthy"
 
43
  except Exception as e:
44
  services["database"] = f"unhealthy: {str(e)}"
45
 
@@ -57,37 +208,20 @@ async def health_check(db: Session = Depends(get_db)):
57
  if r is not None:
58
  r.close()
59
 
60
- # AI providers.
61
- #
62
- # This is deliberately a configuration check, not a liveness check. The
63
- # previous version called genai.configure() and reported "healthy", which
64
- # proved nothing: configure() makes no network call and validates no key,
65
- # so it reported healthy with a garbage key and never touched Groq,
66
- # Cerebras or Mistral at all. A model this backend could no longer call
67
- # would still have shown green.
68
- #
69
- # It stays shallow because the keepalive workflow pings this endpoint on a
70
- # schedule and Groq's free tier allows only 8K tokens/minute — burning that
71
- # on health checks would starve real traffic. Use /health/providers to
72
- # actually call the providers.
73
- missing = [
74
- name
75
- for name, key in (
76
- ("gemini", settings.GEMINI_API_KEY),
77
- ("groq", settings.GROQ_API_KEY),
78
- ("cerebras", settings.CEREBRAS_API_KEY),
79
- ("mistral", settings.MISTRAL_API_KEY),
80
- )
81
- if not key
82
- ]
83
- services["ai"] = (
84
- "configured" if not missing else f"missing keys: {','.join(missing)}"
85
- )
86
-
87
- # Overall status
88
- overall = "healthy"
89
- if services["database"] != "healthy":
90
  overall = "degraded"
 
 
91
 
92
  return HealthResponse(
93
  status=overall,
@@ -99,52 +233,19 @@ async def health_check(db: Session = Depends(get_db)):
99
 
100
 
101
  @router.get("/health/providers")
102
- async def provider_check():
103
  """
104
  Actually call each LLM provider with a minimal request.
105
 
106
- /health only reports whether keys are present. This endpoint answers the
107
- question that matters after a model migration: does the model id this
108
- backend is configured with still exist and still serve us?
109
 
110
  That gap is not theoretical. Groq decommissioned llama-3.3-70b-versatile
111
  while it was hardcoded on both chat paths, and nothing — not the health
112
  check, not the test suite, which mocks every provider — could tell the
113
  difference between a working model and a retired one.
114
 
115
- Not wired into /health on purpose: this spends real tokens, and the
116
- keepalive workflow polls /health on a schedule. Call it by hand after a
117
- deploy or a model change.
118
  """
119
- from app.services.chat_service import CHAT_MODEL
120
-
121
- results: dict = {}
122
-
123
- async def _probe(name: str, client, model: str) -> None:
124
- try:
125
- resp = await client.chat.completions.create(
126
- model=model,
127
- messages=[{"role": "user", "content": "ping"}],
128
- max_tokens=1,
129
- )
130
- results[name] = {
131
- "model": model,
132
- "ok": True,
133
- "finish_reason": resp.choices[0].finish_reason,
134
- }
135
- except Exception as e:
136
- # The message carries the useful part — an unknown model id reads
137
- # very differently from an auth failure or a rate limit.
138
- results[name] = {"model": model, "ok": False, "error": str(e)[:300]}
139
-
140
- from app.services.llm_provider import get_cerebras_client, get_groq_client
141
-
142
- await _probe("groq", get_groq_client(), CHAT_MODEL)
143
- await _probe("cerebras", get_cerebras_client(), "gpt-oss-120b")
144
-
145
- all_ok = all(r["ok"] for r in results.values())
146
- return {
147
- "status": "ok" if all_ok else "degraded",
148
- "checked_at": datetime.utcnow().isoformat(),
149
- "providers": results,
150
- }
 
3
  System health and status monitoring
4
  """
5
 
6
+ import asyncio
7
+ import time
8
  from datetime import datetime
9
+ from typing import Any, Dict
10
 
11
+ from fastapi import APIRouter, Depends, Response
12
  from sqlalchemy.orm import Session
13
 
14
  from app.config import settings
 
17
 
18
  router = APIRouter()
19
 
20
+ # ── Provider liveness ─────────────────────────────────────────────────────────
21
+ #
22
+ # /health used to report the AI subsystem as "configured" purely because API
23
+ # keys were present. That is not a health check: in production Cerebras was
24
+ # returning 402 payment_required on every call while /health still said
25
+ # "healthy", and only /health/providers — which nothing polls — knew.
26
+ #
27
+ # So both endpoints now share the real ping below. The constraints that kept
28
+ # the pings out of /health are handled rather than avoided:
29
+ #
30
+ # * Cost — the result is memoised in-process for _PROVIDER_CACHE_TTL seconds,
31
+ # so the keepalive cron (every 30 min) and any load-balancer probe cost at
32
+ # most one ping per provider per minute, and bursts of probes cost nothing.
33
+ # * Latency — probes run concurrently under a hard timeout; a hung provider
34
+ # is reported as failed instead of hanging the endpoint.
35
+ #
36
+ # The cheap static probe used by the Dockerfile HEALTHCHECK is the plain
37
+ # /health in app/main.py. It stays free of network calls on purpose.
38
+
39
+ _PROVIDER_PING_TIMEOUT = 4.0 # seconds — per provider, hard ceiling
40
+ _PROVIDER_CACHE_TTL = 60.0 # seconds — memoise the whole result set
41
+
42
+ _provider_cache: Dict[str, Any] = {"checked_at_monotonic": None, "result": None}
43
+ _provider_lock = asyncio.Lock()
44
+
45
+
46
+ async def _probe(name: str, client, model: str, api_key: str) -> Dict[str, Any]:
47
+ """
48
+ Call one provider with a 1-token request and report what happened.
49
+
50
+ Never raises: a probe failure is a result, not an error.
51
+ """
52
+ if not api_key:
53
+ return {"model": model, "ok": False, "error": "API key not configured"}
54
+
55
+ try:
56
+ resp = await asyncio.wait_for(
57
+ client.chat.completions.create(
58
+ model=model,
59
+ messages=[{"role": "user", "content": "ping"}],
60
+ max_tokens=1,
61
+ ),
62
+ timeout=_PROVIDER_PING_TIMEOUT,
63
+ )
64
+ return {
65
+ "model": model,
66
+ "ok": True,
67
+ "finish_reason": resp.choices[0].finish_reason,
68
+ }
69
+ except asyncio.TimeoutError:
70
+ # A provider that cannot answer a 1-token request inside the timeout
71
+ # cannot serve a document analysis either. Treat it as down.
72
+ return {
73
+ "model": model,
74
+ "ok": False,
75
+ "error": f"timed out after {_PROVIDER_PING_TIMEOUT}s",
76
+ }
77
+ except Exception as e:
78
+ # The message carries the useful part — an unknown model id reads
79
+ # very differently from an auth failure, a 402 or a rate limit.
80
+ return {"model": model, "ok": False, "error": str(e)[:300]}
81
+
82
+
83
+ async def check_providers(use_cache: bool = True) -> Dict[str, Any]:
84
+ """
85
+ Liveness of every LLM provider this backend actually calls.
86
+
87
+ Returns ``{"status": "ok"|"degraded", "checked_at": iso8601,
88
+ "providers": {name: {"model", "ok", ...}}, "cached": bool}``.
89
+
90
+ Shared by /health and /health/providers so there is exactly one definition
91
+ of "is the AI working".
92
+ """
93
+ from app.services.chat_service import CHAT_MODEL
94
+ from app.services.llm_provider import get_cerebras_client, get_groq_client
95
+
96
+ now = time.monotonic()
97
+ if use_cache:
98
+ checked_at = _provider_cache["checked_at_monotonic"]
99
+ if checked_at is not None and (now - checked_at) < _PROVIDER_CACHE_TTL:
100
+ return {**_provider_cache["result"], "cached": True}
101
+
102
+ async with _provider_lock:
103
+ # Re-check under the lock: while we waited, a concurrent probe may
104
+ # have filled the cache. Without this, a burst of simultaneous probes
105
+ # would each fire their own round of provider calls.
106
+ now = time.monotonic()
107
+ if use_cache:
108
+ checked_at = _provider_cache["checked_at_monotonic"]
109
+ if checked_at is not None and (now - checked_at) < _PROVIDER_CACHE_TTL:
110
+ return {**_provider_cache["result"], "cached": True}
111
+
112
+ targets = [
113
+ ("groq", get_groq_client(), CHAT_MODEL, settings.GROQ_API_KEY),
114
+ (
115
+ "cerebras",
116
+ get_cerebras_client(),
117
+ "gpt-oss-120b",
118
+ settings.CEREBRAS_API_KEY,
119
+ ),
120
+ ]
121
+
122
+ probed = await asyncio.gather(*(_probe(*t) for t in targets))
123
+ results = {name: outcome for (name, *_), outcome in zip(targets, probed)}
124
+
125
+ result = {
126
+ "status": "ok" if all(r["ok"] for r in results.values()) else "degraded",
127
+ "checked_at": datetime.utcnow().isoformat(),
128
+ "providers": results,
129
+ }
130
+ _provider_cache["result"] = result
131
+ _provider_cache["checked_at_monotonic"] = time.monotonic()
132
+
133
+ return {**result, "cached": False}
134
+
135
+
136
+ def _summarise_providers(provider_result: Dict[str, Any]) -> str:
137
+ """One-line, human-readable rendering of the provider probe for services.ai."""
138
+ failed = [
139
+ f"{name} ({info.get('error', 'unknown error')})"
140
+ for name, info in provider_result["providers"].items()
141
+ if not info["ok"]
142
+ ]
143
+ if not failed:
144
+ return "healthy"
145
+ return "degraded: " + "; ".join(failed)
146
+
147
+
148
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
149
+
150
 
151
  @router.get("/", response_model=HealthResponse)
152
  async def root():
 
160
 
161
 
162
  @router.get("/health", response_model=HealthResponse)
163
+ async def health_check(response: Response, db: Session = Depends(get_db)):
164
  """
165
  Detailed health check with service status.
166
+
167
+ Checks the database, the optional Redis cache, and — for real, not by
168
+ inspecting environment variables — whether the LLM providers answer.
169
+
170
+ Status semantics:
171
+ * ``healthy`` — database up, every provider answering.
172
+ * ``degraded`` — database up, at least one provider failing. Still 200:
173
+ the keepalive cron and load-balancer probes treat non-200 as "restart
174
+ the Space", and a provider quota problem is not fixed by a restart.
175
+ * ``unhealthy`` — database unreachable. Returns 503.
176
+
177
+ Redis being ``not_configured`` never changes the overall status. It is an
178
+ optional cache and is deliberately unset in production.
179
  """
180
+ services: Dict[str, Any] = {
181
+ "database": "unknown",
182
+ "redis": "unknown",
183
+ "ai": "unknown",
184
+ }
185
 
186
  # Check database
187
+ db_ok = False
188
  try:
189
  from sqlalchemy import text
190
 
191
  db.execute(text("SELECT 1"))
192
  services["database"] = "healthy"
193
+ db_ok = True
194
  except Exception as e:
195
  services["database"] = f"unhealthy: {str(e)}"
196
 
 
208
  if r is not None:
209
  r.close()
210
 
211
+ # AI providers — a real 1-token call per provider, memoised for 60s and
212
+ # bounded by a timeout so this endpoint stays fast and cheap.
213
+ provider_result = await check_providers()
214
+ services["ai"] = _summarise_providers(provider_result)
215
+ services["ai_providers"] = provider_result["providers"]
216
+ services["ai_checked_at"] = provider_result["checked_at"]
217
+
218
+ if not db_ok:
219
+ overall = "unhealthy"
220
+ response.status_code = 503
221
+ elif provider_result["status"] != "ok":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  overall = "degraded"
223
+ else:
224
+ overall = "healthy"
225
 
226
  return HealthResponse(
227
  status=overall,
 
233
 
234
 
235
  @router.get("/health/providers")
236
+ async def provider_check(fresh: bool = False):
237
  """
238
  Actually call each LLM provider with a minimal request.
239
 
240
+ This answers the question that matters after a model migration: does the
241
+ model id this backend is configured with still exist and still serve us?
 
242
 
243
  That gap is not theoretical. Groq decommissioned llama-3.3-70b-versatile
244
  while it was hardcoded on both chat paths, and nothing — not the health
245
  check, not the test suite, which mocks every provider — could tell the
246
  difference between a working model and a retired one.
247
 
248
+ Shares its result with /health through a 60s in-process cache. Pass
249
+ ``?fresh=true`` to bypass the cache and force a live round of pings.
 
250
  """
251
+ return await check_providers(use_cache=not fresh)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/v1/search.py CHANGED
@@ -31,13 +31,22 @@ genai.configure(api_key=settings.GEMINI_API_KEY)
31
 
32
 
33
  async def _get_query_embedding(text_input: str) -> List[float]:
34
- """Generate query embedding using Gemini text-embedding-004."""
 
 
 
 
 
 
 
 
35
  try:
36
  result = await asyncio.to_thread(
37
  genai.embed_content,
38
  model=settings.EMBEDDING_MODEL,
39
  content=text_input,
40
  task_type="retrieval_query",
 
41
  )
42
  return result["embedding"]
43
  except Exception as e:
 
31
 
32
 
33
  async def _get_query_embedding(text_input: str) -> List[float]:
34
+ """Generate a query embedding using the configured Gemini embedding model.
35
+
36
+ output_dimensionality=768 is REQUIRED, not optional. The chunk_embedding
37
+ column is Vector(768); gemini-embedding-001 returns 3072 dimensions by
38
+ default. Omitting it produces a vector the pgvector cast rejects, and
39
+ hybrid_search swallows that failure — so the semantic arm silently
40
+ disappears and this endpoint degrades to lexical-only results with no
41
+ error anywhere. Keep this in sync with chat_service and document_service.
42
+ """
43
  try:
44
  result = await asyncio.to_thread(
45
  genai.embed_content,
46
  model=settings.EMBEDDING_MODEL,
47
  content=text_input,
48
  task_type="retrieval_query",
49
+ output_dimensionality=768,
50
  )
51
  return result["embedding"]
52
  except Exception as e:
app/config.py CHANGED
@@ -226,9 +226,23 @@ class Settings(BaseSettings):
226
  "an uncapped 500-page scan would occupy a worker for 20 minutes.",
227
  )
228
 
229
- # Mining AI Settings
230
- SAFETY_SCORE_THRESHOLD: float = Field(default=70.0)
231
- MAX_EMBEDDINGS_PER_QUERY: int = Field(default=5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
  # RAG Pipeline — Production Retrieval
234
  RERANK_MODEL: str = Field(
 
226
  "an uncapped 500-page scan would occupy a worker for 20 minutes.",
227
  )
228
 
229
+ # Conversation Memory
230
+ # Prior turns are replayed to the model so follow-ups ("what about
231
+ # surface mines?") resolve against what was already discussed. Bounded on
232
+ # two axes because either one alone fails: a turn cap alone lets a few
233
+ # long answers blow the context window, and a char budget alone can slice
234
+ # the history mid-conversation in a way that strands a user turn without
235
+ # its answer.
236
+ CHAT_HISTORY_MAX_TURNS: int = Field(
237
+ default=6,
238
+ description="Max prior messages (user+assistant) replayed to the LLM. "
239
+ "Counted in whole user/assistant pairs, most recent first.",
240
+ )
241
+ CHAT_HISTORY_MAX_CHARS: int = Field(
242
+ default=6000,
243
+ description="Approximate char budget for replayed history. Roughly "
244
+ "1.5K tokens, leaving room for retrieved context and the answer.",
245
+ )
246
 
247
  # RAG Pipeline — Production Retrieval
248
  RERANK_MODEL: str = Field(
app/services/analysis_cache.py CHANGED
@@ -73,6 +73,12 @@ def _is_cacheable(results: Dict[str, Any]) -> bool:
73
  if results.get("metadata", {}).get("failed"):
74
  return False
75
 
 
 
 
 
 
 
76
  for section in _REQUIRED_SECTIONS:
77
  value = results.get(section)
78
  if not isinstance(value, dict):
 
73
  if results.get("metadata", {}).get("failed"):
74
  return False
75
 
76
+ # The orchestrator lists every section it had to replace with a
77
+ # placeholder. One entry here means the analysis is partial, whatever the
78
+ # individual sections look like.
79
+ if results.get("metadata", {}).get("degraded_sections"):
80
+ return False
81
+
82
  for section in _REQUIRED_SECTIONS:
83
  value = results.get(section)
84
  if not isinstance(value, dict):
app/services/chat_service.py CHANGED
@@ -108,11 +108,13 @@ class ChatService:
108
  user_id: str,
109
  document_ids: Optional[List[str]] = None,
110
  db: Session = None,
111
- ) -> Tuple[str, List[Dict[str, Any]]]:
 
112
  try:
113
- query_embedding = await self._get_embedding(query)
 
114
  relevant_chunks = await self._retrieve_chunks(
115
- query=query,
116
  query_embedding=query_embedding,
117
  user_id=user_id,
118
  document_ids=document_ids,
@@ -127,13 +129,7 @@ class ChatService:
127
 
128
  response = await client.chat.completions.create(
129
  model=CHAT_MODEL,
130
- messages=[
131
- {"role": "system", "content": _SYSTEM_PROMPT},
132
- {
133
- "role": "user",
134
- "content": self._build_user_message(query, context),
135
- },
136
- ],
137
  )
138
  answer = response.choices[0].message.content
139
 
@@ -164,11 +160,13 @@ class ChatService:
164
  user_id: str,
165
  document_ids: Optional[List[str]] = None,
166
  db: Session = None,
 
167
  ) -> AsyncGenerator[str, None]:
168
  try:
169
- query_embedding = await self._get_embedding(query)
 
170
  relevant_chunks = await self._retrieve_chunks(
171
- query=query,
172
  query_embedding=query_embedding,
173
  user_id=user_id,
174
  document_ids=document_ids,
@@ -186,13 +184,7 @@ class ChatService:
186
 
187
  response_stream = await client.chat.completions.create(
188
  model=CHAT_MODEL,
189
- messages=[
190
- {"role": "system", "content": _SYSTEM_PROMPT},
191
- {
192
- "role": "user",
193
- "content": self._build_user_message(query, context),
194
- },
195
- ],
196
  stream=True,
197
  )
198
 
@@ -207,6 +199,142 @@ class ChatService:
207
  logger.error(f"Stream generation error: {e}", exc_info=True)
208
  yield f"event: error\ndata: {json.dumps({'message': str(e)})}\n\n"
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  def get_mining_suggestions(self) -> List[str]:
211
  """Get suggested mining-related questions."""
212
  return [
 
108
  user_id: str,
109
  document_ids: Optional[List[str]] = None,
110
  db: Session = None,
111
+ history: Optional[List[Dict[str, str]]] = None,
112
+ ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, int]]]:
113
  try:
114
+ retrieval_query = self._build_retrieval_query(query, history or [])
115
+ query_embedding = await self._get_embedding(retrieval_query)
116
  relevant_chunks = await self._retrieve_chunks(
117
+ query=retrieval_query,
118
  query_embedding=query_embedding,
119
  user_id=user_id,
120
  document_ids=document_ids,
 
129
 
130
  response = await client.chat.completions.create(
131
  model=CHAT_MODEL,
132
+ messages=self._build_messages(query, context, history),
 
 
 
 
 
 
133
  )
134
  answer = response.choices[0].message.content
135
 
 
160
  user_id: str,
161
  document_ids: Optional[List[str]] = None,
162
  db: Session = None,
163
+ history: Optional[List[Dict[str, str]]] = None,
164
  ) -> AsyncGenerator[str, None]:
165
  try:
166
+ retrieval_query = self._build_retrieval_query(query, history or [])
167
+ query_embedding = await self._get_embedding(retrieval_query)
168
  relevant_chunks = await self._retrieve_chunks(
169
+ query=retrieval_query,
170
  query_embedding=query_embedding,
171
  user_id=user_id,
172
  document_ids=document_ids,
 
184
 
185
  response_stream = await client.chat.completions.create(
186
  model=CHAT_MODEL,
187
+ messages=self._build_messages(query, context, history),
 
 
 
 
 
 
188
  stream=True,
189
  )
190
 
 
199
  logger.error(f"Stream generation error: {e}", exc_info=True)
200
  yield f"event: error\ndata: {json.dumps({'message': str(e)})}\n\n"
201
 
202
+ # ── Conversation Memory ───────────────────────────────────────────────────
203
+
204
+ @staticmethod
205
+ def load_session_history(
206
+ db: Session,
207
+ session_id: Any,
208
+ max_turns: Optional[int] = None,
209
+ max_chars: Optional[int] = None,
210
+ ) -> List[Dict[str, str]]:
211
+ """Load recent prior turns for a session, oldest-first, ready to replay.
212
+
213
+ Call this BEFORE persisting the incoming user message. Both chat
214
+ endpoints add the new user row to the session early (one commits it
215
+ immediately), and SQLAlchemy autoflush would otherwise pull that row
216
+ into this query — the model would see the current question twice, once
217
+ as history and once as the live turn.
218
+
219
+ Bounded by turn count and an approximate char budget. Trimming walks
220
+ backwards from the newest message and stops on the first message that
221
+ would breach the budget, so the retained window is always a contiguous
222
+ recent slice rather than a sampling of the conversation.
223
+ """
224
+ from app.models.chat import ChatMessage
225
+
226
+ max_turns = max_turns or settings.CHAT_HISTORY_MAX_TURNS
227
+ max_chars = max_chars or settings.CHAT_HISTORY_MAX_CHARS
228
+
229
+ try:
230
+ rows = (
231
+ db.query(ChatMessage)
232
+ .filter(ChatMessage.session_id == session_id)
233
+ .order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc())
234
+ .limit(max_turns)
235
+ .all()
236
+ )
237
+ except Exception as e:
238
+ # History is an enhancement, never a precondition. A failure here
239
+ # must degrade to a stateless answer, not break the chat.
240
+ logger.warning(f"Could not load session history: {e}")
241
+ return []
242
+
243
+ history: List[Dict[str, str]] = []
244
+ budget = max_chars
245
+ for row in rows: # newest → oldest
246
+ content = (row.content or "").strip()
247
+ if not content or row.role not in ("user", "assistant"):
248
+ continue
249
+ if len(content) > budget:
250
+ break
251
+ budget -= len(content)
252
+ history.append({"role": row.role, "content": content})
253
+
254
+ history.reverse() # back to chronological order
255
+
256
+ # Never open the replay on an assistant turn: a dangling answer with no
257
+ # question reads as the model talking to itself and measurably degrades
258
+ # follow-up quality.
259
+ while history and history[0]["role"] == "assistant":
260
+ history.pop(0)
261
+
262
+ return history
263
+
264
+ @staticmethod
265
+ def _build_retrieval_query(query: str, history: List[Dict[str, str]]) -> str:
266
+ """Expand a follow-up into something retrievable on its own.
267
+
268
+ Retrieval sees only the raw question, so "what about surface mines?"
269
+ embeds to almost nothing useful and the reranker has no good candidates
270
+ to choose from — the generation half would have full history while the
271
+ retrieval half stayed blind.
272
+
273
+ This is deliberately a cheap string heuristic rather than an LLM
274
+ condensation call: it costs nothing, adds no latency to every turn, and
275
+ cannot fail. A dependent-looking follow-up is prefixed with the previous
276
+ user turn purely for embedding and lexical matching; the prompt the
277
+ model actually answers is untouched.
278
+ """
279
+ if not history:
280
+ return query
281
+
282
+ stripped = query.strip().lower()
283
+ looks_dependent = len(stripped) < 60 or stripped.startswith(
284
+ (
285
+ "what about",
286
+ "and ",
287
+ "but ",
288
+ "how about",
289
+ "why",
290
+ "what if",
291
+ "does it",
292
+ "do they",
293
+ "is it",
294
+ "are they",
295
+ "which one",
296
+ "that ",
297
+ "those ",
298
+ "it ",
299
+ "they ",
300
+ "then ",
301
+ "also",
302
+ "same",
303
+ )
304
+ )
305
+ if not looks_dependent:
306
+ return query
307
+
308
+ prior_user = next(
309
+ (m["content"] for m in reversed(history) if m["role"] == "user"), None
310
+ )
311
+ if not prior_user:
312
+ return query
313
+
314
+ return f"{prior_user}\n{query}"
315
+
316
+ def _build_messages(
317
+ self,
318
+ query: str,
319
+ context: str,
320
+ history: Optional[List[Dict[str, str]]] = None,
321
+ ) -> List[Dict[str, str]]:
322
+ """Assemble the LLM message list: system, prior turns, then this turn.
323
+
324
+ Retrieved context rides on the final user message only. Restating it on
325
+ every historical turn would multiply the prompt by the history depth and
326
+ let stale context compete with the chunks retrieved for the live
327
+ question — the citation rules in the system prompt must bind against
328
+ the current context, not an older one.
329
+ """
330
+ messages: List[Dict[str, str]] = [{"role": "system", "content": _SYSTEM_PROMPT}]
331
+ if history:
332
+ messages.extend(history)
333
+ messages.append(
334
+ {"role": "user", "content": self._build_user_message(query, context)}
335
+ )
336
+ return messages
337
+
338
  def get_mining_suggestions(self) -> List[str]:
339
  """Get suggested mining-related questions."""
340
  return [
app/services/document_service.py CHANGED
@@ -197,10 +197,40 @@ class DocumentService:
197
 
198
  # Entities & Summary
199
  document.entities = entities if isinstance(entities, dict) else {}
200
- document.summary = summary.get("summary", "Summary not available.")
 
 
 
201
  document.key_points = summary.get("key_points", [])
202
 
203
  # ── Step 7: Mark completed ───────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  document.status = DocumentStatus.COMPLETED
205
  document.processed_at = datetime.now(timezone.utc)
206
  db.commit()
@@ -209,17 +239,30 @@ class DocumentService:
209
  return True
210
 
211
  except QuotaExceededError as qe:
212
- # Quota hit: text extraction + embeddings already succeeded.
213
- # Mark COMPLETED with partial data so re-analyze is available.
 
 
 
 
 
 
 
 
 
214
  logger.error(
215
- f"Gemini quota exceeded during agent analysis for {document_id}: {qe}"
 
216
  )
217
  document.status = DocumentStatus.COMPLETED
218
  document.processing_error = (
219
- "AI analysis incomplete: Gemini API quota exceeded. "
220
  "Click Re-analyze to run the full analysis when quota resets."
221
  )
222
- document.summary = "Summary not available — Gemini quota exceeded. Re-analyze to generate."
 
 
 
223
  document.key_points = []
224
  document.processed_at = datetime.now(timezone.utc)
225
  db.commit()
 
197
 
198
  # Entities & Summary
199
  document.entities = entities if isinstance(entities, dict) else {}
200
+ document.summary = summary.get("summary") or (
201
+ "Summary unavailable — the summarizer produced no result "
202
+ "for this document. Click Re-analyze to try again."
203
+ )
204
  document.key_points = summary.get("key_points", [])
205
 
206
  # ── Step 7: Mark completed ───────────────────────────────────
207
+ #
208
+ # One agent losing its provider (Cerebras answers an exhausted
209
+ # account with 402) must not throw away the work of the other
210
+ # three, so the document is COMPLETED with whatever succeeded.
211
+ # But it must not look like a clean run either: the analysis
212
+ # endpoint coerces entities to plain lists and drops the error
213
+ # markers, so the reason is recorded on processing_error, which
214
+ # DocumentResponse does expose.
215
+ degraded = (results.get("metadata") or {}).get(
216
+ "degraded_sections"
217
+ ) or {}
218
+ if degraded:
219
+ details = "; ".join(
220
+ f"{name}: {reason}" for name, reason in degraded.items()
221
+ )
222
+ document.processing_error = (
223
+ f"Partial AI analysis — {len(degraded)} of 4 sections "
224
+ f"unavailable ({details}). Click Re-analyze to run the "
225
+ "missing sections."
226
+ )
227
+ logger.warning(
228
+ f"Document {document_id} completed with degraded "
229
+ f"sections: {details}"
230
+ )
231
+ else:
232
+ document.processing_error = None
233
+
234
  document.status = DocumentStatus.COMPLETED
235
  document.processed_at = datetime.now(timezone.utc)
236
  db.commit()
 
239
  return True
240
 
241
  except QuotaExceededError as qe:
242
+ # Only the classifier can reach here: it runs before the
243
+ # gather() that absorbs the other three agents' failures. Text
244
+ # extraction and embeddings already succeeded, so the document
245
+ # is searchable — mark it COMPLETED with partial data rather
246
+ # than FAILED, so Re-analyze is offered.
247
+ #
248
+ # The provider is named rather than assumed. This used to say
249
+ # "Gemini" unconditionally, which was wrong for every agent:
250
+ # the classifier runs on Groq and the extractor and summarizer
251
+ # on Cerebras.
252
+ provider = getattr(qe, "provider", "") or "AI"
253
  logger.error(
254
+ f"{provider} quota exceeded during agent analysis for "
255
+ f"{document_id}: {qe}"
256
  )
257
  document.status = DocumentStatus.COMPLETED
258
  document.processing_error = (
259
+ f"AI analysis incomplete: {provider} quota exceeded. "
260
  "Click Re-analyze to run the full analysis when quota resets."
261
  )
262
+ document.summary = (
263
+ f"Summary unavailable — {provider} quota exceeded before "
264
+ "analysis could run. Click Re-analyze to generate it."
265
+ )
266
  document.key_points = []
267
  document.processed_at = datetime.now(timezone.utc)
268
  db.commit()
tests/integration/conftest.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Integration test fixtures — real PostgreSQL, not SQLite.
3
+
4
+ Why this file exists
5
+ --------------------
6
+ The root conftest points every test at SQLite (``test.db``). That is fine for
7
+ unit tests, which never touch a dialect-specific column, but it does not work
8
+ for these API tests and never really did:
9
+
10
+ * Every model uses ``sqlalchemy.dialects.postgresql.UUID`` primary keys. On
11
+ SQLite those bind as ``CHAR(32)`` hex, and any place the app hands the
12
+ driver a ``uuid.UUID`` (or a ``str`` where the other was expected) blows up
13
+ with ``type 'UUID' is not supported`` / ``'str' object has no attribute
14
+ 'hex'``. That is what the five long-standing failures in
15
+ ``test_chat_api.py`` were.
16
+ * SQLite does not enforce the foreign keys to ``users.clerk_user_id`` by
17
+ default, so these tests were silently not exercising them.
18
+ * SQLite has no pgvector and no ``to_tsvector`` full-text search, so the two
19
+ things the retrieval path is actually built on could never be covered.
20
+
21
+ Papering over the UUID binding with a type decorator would have kept the tests
22
+ green on a database the application never runs against. CI already starts a
23
+ ``pgvector/pgvector:pg16`` service container and runs Alembic against it, so
24
+ the real database was sitting there unused — these fixtures point at it.
25
+
26
+ Running locally
27
+ ---------------
28
+ docker compose up -d postgres
29
+ pytest tests/integration -v
30
+
31
+ The default URL matches the ``postgres`` service in ``docker-compose.yml``.
32
+ Override with ``TEST_DATABASE_URL`` to use a different server. When no server
33
+ is reachable the whole package skips with an explanatory message rather than
34
+ failing — same convention as ``tests/eval/test_retrieval_eval.py``.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import os
40
+ from typing import Generator
41
+ from urllib.parse import urlsplit, urlunsplit
42
+
43
+ import pytest
44
+ from sqlalchemy import create_engine, text
45
+ from sqlalchemy.engine import Engine
46
+ from sqlalchemy.orm import Session, sessionmaker
47
+
48
+ from app.models.base import Base
49
+
50
+ # Matches the `postgres` service in docker-compose.yml. `miningniti_test` keeps
51
+ # the suite off the dev database; it is created on demand if missing.
52
+ DEFAULT_TEST_DB_URL = (
53
+ "postgresql+psycopg2://postgres:postgres@localhost:5432/miningniti_test"
54
+ )
55
+
56
+ # connect_timeout keeps an unreachable-but-routable host from stalling the run
57
+ # for the driver's default before the skip fires.
58
+ _CONNECT_KWARGS = {"pool_pre_ping": True, "connect_args": {"connect_timeout": 5}}
59
+
60
+ SKIP_REASON = (
61
+ "Integration tests need PostgreSQL. Start one with "
62
+ "`docker compose up -d postgres`, or point TEST_DATABASE_URL at a server. "
63
+ "Tried: {url}\nConnection error: {err}"
64
+ )
65
+
66
+
67
+ def _resolve_url() -> str:
68
+ """
69
+ Pick the PostgreSQL URL for the integration suite.
70
+
71
+ TEST_DATABASE_URL wins. Otherwise DATABASE_URL is used when it is a
72
+ PostgreSQL URL — that is the case in CI, where the workflow points it at
73
+ the pgvector service container. The root conftest defaults DATABASE_URL to
74
+ SQLite for unit tests, which is why a non-PostgreSQL value is ignored here.
75
+ """
76
+ explicit = os.environ.get("TEST_DATABASE_URL")
77
+ if explicit:
78
+ return explicit
79
+
80
+ configured = os.environ.get("DATABASE_URL", "")
81
+ if configured.startswith("postgresql"):
82
+ return configured
83
+
84
+ return DEFAULT_TEST_DB_URL
85
+
86
+
87
+ def _with_database(url: str, database: str) -> str:
88
+ parts = urlsplit(url)
89
+ return urlunsplit(
90
+ (parts.scheme, parts.netloc, f"/{database}", parts.query, parts.fragment)
91
+ )
92
+
93
+
94
+ def _ensure_database_exists(url: str) -> None:
95
+ """
96
+ CREATE DATABASE the target if it is missing.
97
+
98
+ Only reached for the local default; in CI the service container already
99
+ provisions the database and this connects, finds it, and returns.
100
+ """
101
+ target = urlsplit(url).path.lstrip("/")
102
+ if not target:
103
+ return
104
+
105
+ admin = create_engine(
106
+ _with_database(url, "postgres"), isolation_level="AUTOCOMMIT", **_CONNECT_KWARGS
107
+ )
108
+ try:
109
+ with admin.connect() as conn:
110
+ exists = conn.execute(
111
+ text("SELECT 1 FROM pg_database WHERE datname = :name"),
112
+ {"name": target},
113
+ ).scalar()
114
+ if not exists:
115
+ # Identifier cannot be bound as a parameter; target comes from
116
+ # our own URL, not user input.
117
+ conn.execute(text(f'CREATE DATABASE "{target}"'))
118
+ finally:
119
+ admin.dispose()
120
+
121
+
122
+ @pytest.fixture(scope="session")
123
+ def test_engine() -> Generator[Engine, None, None]:
124
+ """
125
+ Session-scoped engine against real PostgreSQL.
126
+
127
+ Overrides the SQLite engine of the same name in the root conftest for
128
+ everything under tests/integration/. Skips the suite when no server is
129
+ reachable.
130
+ """
131
+ url = _resolve_url()
132
+
133
+ try:
134
+ _ensure_database_exists(url)
135
+ engine = create_engine(url, **_CONNECT_KWARGS)
136
+ with engine.connect() as conn:
137
+ # DocumentEmbedding.embedding is a pgvector column, so the type has
138
+ # to exist before create_all(). In CI the Alembic migrations have
139
+ # already done this and create_all() below is a no-op.
140
+ conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
141
+ conn.commit()
142
+ except Exception as exc: # pragma: no cover - environment dependent
143
+ pytest.skip(SKIP_REASON.format(url=url, err=exc))
144
+
145
+ # checkfirst=True: leaves the Alembic-managed schema in CI untouched, and
146
+ # bootstraps a bare database locally. Deliberately no drop_all() on
147
+ # teardown — later CI steps (the retrieval eval) run against this same
148
+ # database, and every test rolls its own writes back anyway.
149
+ Base.metadata.create_all(bind=engine, checkfirst=True)
150
+
151
+ yield engine
152
+
153
+ engine.dispose()
154
+
155
+
156
+ @pytest.fixture(scope="function")
157
+ def db_session(test_engine: Engine) -> Generator[Session, None, None]:
158
+ """
159
+ Per-test session wrapped in a transaction that is always rolled back.
160
+
161
+ ``join_transaction_mode="create_savepoint"`` is what makes this work
162
+ against PostgreSQL: the API code under test calls ``session.commit()``, and
163
+ without it that commit ends the outer transaction, leaking rows into the
164
+ next test (on SQLite it produced the "transaction already deassociated"
165
+ warnings). With savepoints, the app's commits are real as far as the test
166
+ is concerned but the outer rollback below still undoes all of them.
167
+ """
168
+ connection = test_engine.connect()
169
+ transaction = connection.begin()
170
+
171
+ TestingSessionLocal = sessionmaker(
172
+ bind=connection,
173
+ autocommit=False,
174
+ autoflush=False,
175
+ join_transaction_mode="create_savepoint",
176
+ )
177
+ session = TestingSessionLocal()
178
+
179
+ # The foreign keys from documents/chat_sessions to users.clerk_user_id are
180
+ # enforced here, unlike on SQLite. The `client` fixture overrides
181
+ # get_current_user_id, which is the dependency that would normally
182
+ # provision this row (app/api/deps.py::_ensure_user_row), so the test has
183
+ # to stand it up itself.
184
+ from app.models.user import User
185
+
186
+ session.add(User(clerk_user_id="test_user_001", is_active=True))
187
+ session.commit()
188
+
189
+ try:
190
+ yield session
191
+ finally:
192
+ session.close()
193
+ if transaction.is_active:
194
+ transaction.rollback()
195
+ connection.close()
tests/integration/test_chat_api.py CHANGED
@@ -108,9 +108,15 @@ class TestSendMessage:
108
  "app.services.chat_service.ChatService.generate_response",
109
  new_callable=AsyncMock,
110
  ) as mock_gen:
 
 
 
 
 
111
  mock_gen.return_value = (
112
  "According to [Mining_site.pdf, Page 12], methane limits are below 1%.",
113
  [],
 
114
  )
115
  response = client.post(
116
  "/api/v1/chat/send",
@@ -134,7 +140,11 @@ class TestSendMessage:
134
  "app.services.chat_service.ChatService.generate_response",
135
  new_callable=AsyncMock,
136
  ) as mock_gen:
137
- mock_gen.return_value = ("Test answer with [doc.pdf, Page 5] citation.", [])
 
 
 
 
138
  response = client.post(
139
  "/api/v1/chat/send",
140
  json={
@@ -156,7 +166,11 @@ class TestSendMessage:
156
  "app.services.chat_service.ChatService.generate_response",
157
  new_callable=AsyncMock,
158
  ) as mock_gen:
159
- mock_gen.return_value = ("Response text.", [])
 
 
 
 
160
  response = client.post(
161
  "/api/v1/chat/send",
162
  json={
 
108
  "app.services.chat_service.ChatService.generate_response",
109
  new_callable=AsyncMock,
110
  ) as mock_gen:
111
+ # ChatService.generate_response returns (answer, sources,
112
+ # tokens_used). The two-element mock here used to fail as
113
+ # "not enough values to unpack" the moment the endpoint was
114
+ # actually reached — it never was, because the request died on
115
+ # SQLite UUID binding first.
116
  mock_gen.return_value = (
117
  "According to [Mining_site.pdf, Page 12], methane limits are below 1%.",
118
  [],
119
+ {"input": 128, "output": 42},
120
  )
121
  response = client.post(
122
  "/api/v1/chat/send",
 
140
  "app.services.chat_service.ChatService.generate_response",
141
  new_callable=AsyncMock,
142
  ) as mock_gen:
143
+ mock_gen.return_value = (
144
+ "Test answer with [doc.pdf, Page 5] citation.",
145
+ [],
146
+ {"input": 128, "output": 42},
147
+ )
148
  response = client.post(
149
  "/api/v1/chat/send",
150
  json={
 
166
  "app.services.chat_service.ChatService.generate_response",
167
  new_callable=AsyncMock,
168
  ) as mock_gen:
169
+ mock_gen.return_value = (
170
+ "Response text.",
171
+ [],
172
+ {"input": 128, "output": 42},
173
+ )
174
  response = client.post(
175
  "/api/v1/chat/send",
176
  json={
tests/unit/test_agents.py CHANGED
@@ -71,16 +71,6 @@ MOCK_SUMMARY_JSON = json.dumps(
71
  )
72
 
73
 
74
- def make_mock_model(json_response: str):
75
- """Create a mock Gemini model that returns json_response."""
76
- mock_response = MagicMock()
77
- mock_response.text = json_response
78
-
79
- mock_model = MagicMock()
80
- mock_model.generate_content = MagicMock(return_value=mock_response)
81
- return mock_model
82
-
83
-
84
  def make_mock_client(json_response: str):
85
  """Create a mock OpenAI client that returns json_response."""
86
  mock_choice = MagicMock()
@@ -284,7 +274,11 @@ class TestSummarizerAgent:
284
  from app.agents.summarizer import SummarizerAgent
285
 
286
  agent = SummarizerAgent()
287
- agent.model = make_mock_model(MOCK_SUMMARY_JSON)
 
 
 
 
288
 
289
  result = await agent.analyze(sample_mining_text)
290
 
@@ -300,7 +294,11 @@ class TestSummarizerAgent:
300
  from app.agents.summarizer import SummarizerAgent
301
 
302
  agent = SummarizerAgent()
303
- agent.model = make_mock_model(MOCK_SUMMARY_JSON)
 
 
 
 
304
 
305
  result = await agent.analyze(sample_mining_text)
306
  assert isinstance(result["key_points"], list)
 
71
  )
72
 
73
 
 
 
 
 
 
 
 
 
 
 
74
  def make_mock_client(json_response: str):
75
  """Create a mock OpenAI client that returns json_response."""
76
  mock_choice = MagicMock()
 
274
  from app.agents.summarizer import SummarizerAgent
275
 
276
  agent = SummarizerAgent()
277
+ # SummarizerAgent is provider="cerebras" and goes through
278
+ # BaseAgent._call_openai_compat(self.client, ...). Patching `.model`
279
+ # here was a no-op that let this "unit" test make a live HTTPS call to
280
+ # api.cerebras.ai — patch the attribute the code path actually reads.
281
+ agent.client = make_mock_client(MOCK_SUMMARY_JSON)
282
 
283
  result = await agent.analyze(sample_mining_text)
284
 
 
294
  from app.agents.summarizer import SummarizerAgent
295
 
296
  agent = SummarizerAgent()
297
+ # SummarizerAgent is provider="cerebras" and goes through
298
+ # BaseAgent._call_openai_compat(self.client, ...). Patching `.model`
299
+ # here was a no-op that let this "unit" test make a live HTTPS call to
300
+ # api.cerebras.ai — patch the attribute the code path actually reads.
301
+ agent.client = make_mock_client(MOCK_SUMMARY_JSON)
302
 
303
  result = await agent.analyze(sample_mining_text)
304
  assert isinstance(result["key_points"], list)
tests/unit/test_chat_service.py CHANGED
@@ -195,3 +195,209 @@ class TestGetMiningeSuggestions:
195
  assert len(suggestions) > 0
196
  for s in suggestions:
197
  assert isinstance(s, str)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  assert len(suggestions) > 0
196
  for s in suggestions:
197
  assert isinstance(s, str)
198
+
199
+
200
+ class TestConversationHistory:
201
+ """Tests for multi-turn conversation memory.
202
+
203
+ Prior turns are replayed to the model so follow-ups resolve against what
204
+ was already discussed. These guard the two failure modes that make history
205
+ worse than no history: replaying the live question as its own context, and
206
+ opening the replay on a dangling assistant turn.
207
+ """
208
+
209
+ @staticmethod
210
+ def _rows(*pairs):
211
+ """Build fake ChatMessage rows, newest first (query order)."""
212
+ rows = []
213
+ for role, content in pairs:
214
+ row = MagicMock()
215
+ row.role = role
216
+ row.content = content
217
+ rows.append(row)
218
+ return rows
219
+
220
+ @staticmethod
221
+ def _db_returning(rows):
222
+ db = MagicMock()
223
+ chain = db.query.return_value.filter.return_value.order_by.return_value
224
+ chain.limit.return_value.all.return_value = rows
225
+ return db
226
+
227
+ @pytest.mark.unit
228
+ def test_history_is_chronological(self):
229
+ """Rows arrive newest-first from the DB and must be replayed oldest-first."""
230
+ from app.services.chat_service import ChatService
231
+
232
+ db = self._db_returning(
233
+ self._rows(
234
+ ("assistant", "Methane must stay below 1%."),
235
+ ("user", "What are the methane limits?"),
236
+ )
237
+ )
238
+ history = ChatService.load_session_history(db, "session-1")
239
+
240
+ assert [m["role"] for m in history] == ["user", "assistant"]
241
+ assert history[0]["content"] == "What are the methane limits?"
242
+
243
+ @pytest.mark.unit
244
+ def test_history_never_starts_on_assistant_turn(self):
245
+ """A dangling answer with no question is dropped from the front."""
246
+ from app.services.chat_service import ChatService
247
+
248
+ db = self._db_returning(
249
+ self._rows(
250
+ ("assistant", "Newest answer."),
251
+ ("user", "Newest question."),
252
+ (
253
+ "assistant",
254
+ "Orphaned answer whose question fell outside the window.",
255
+ ),
256
+ )
257
+ )
258
+ history = ChatService.load_session_history(db, "session-1")
259
+
260
+ assert history[0]["role"] == "user"
261
+ assert all("Orphaned" not in m["content"] for m in history)
262
+
263
+ @pytest.mark.unit
264
+ def test_char_budget_trims_oldest_first(self):
265
+ """The budget retains the most recent turns, not the oldest."""
266
+ from app.services.chat_service import ChatService
267
+
268
+ db = self._db_returning(
269
+ self._rows(
270
+ ("user", "recent"),
271
+ ("assistant", "x" * 500),
272
+ ("user", "ancient"),
273
+ )
274
+ )
275
+ history = ChatService.load_session_history(db, "session-1", max_chars=50)
276
+
277
+ contents = [m["content"] for m in history]
278
+ assert "recent" in contents
279
+ assert "ancient" not in contents
280
+
281
+ @pytest.mark.unit
282
+ def test_turn_cap_is_passed_to_query(self):
283
+ """max_turns bounds the DB query rather than being applied after."""
284
+ from app.services.chat_service import ChatService
285
+
286
+ db = self._db_returning([])
287
+ ChatService.load_session_history(db, "session-1", max_turns=4)
288
+
289
+ limit = db.query.return_value.filter.return_value.order_by.return_value.limit
290
+ limit.assert_called_once_with(4)
291
+
292
+ @pytest.mark.unit
293
+ def test_db_failure_degrades_to_stateless(self):
294
+ """History is an enhancement; a DB error must not break the chat."""
295
+ from app.services.chat_service import ChatService
296
+
297
+ db = MagicMock()
298
+ db.query.side_effect = RuntimeError("connection lost")
299
+
300
+ assert ChatService.load_session_history(db, "session-1") == []
301
+
302
+ @pytest.mark.unit
303
+ def test_blank_and_system_rows_are_skipped(self):
304
+ """Empty content and non user/assistant roles never reach the model."""
305
+ from app.services.chat_service import ChatService
306
+
307
+ db = self._db_returning(
308
+ self._rows(
309
+ ("user", "Real question."),
310
+ ("system", "internal note"),
311
+ ("assistant", " "),
312
+ )
313
+ )
314
+ history = ChatService.load_session_history(db, "session-1")
315
+
316
+ assert history == [{"role": "user", "content": "Real question."}]
317
+
318
+ @pytest.mark.unit
319
+ def test_messages_place_history_between_system_and_query(self):
320
+ """System prompt first, prior turns next, live question last."""
321
+ from app.services.chat_service import ChatService
322
+
323
+ history = [
324
+ {"role": "user", "content": "What are the methane limits?"},
325
+ {"role": "assistant", "content": "Below 1%."},
326
+ ]
327
+ messages = ChatService()._build_messages(
328
+ "What about surface mines?", "CTX", history
329
+ )
330
+
331
+ assert messages[0]["role"] == "system"
332
+ assert messages[1:3] == history
333
+ assert messages[-1]["role"] == "user"
334
+ assert "What about surface mines?" in messages[-1]["content"]
335
+
336
+ @pytest.mark.unit
337
+ def test_context_rides_only_on_the_live_turn(self):
338
+ """Retrieved context must not be restated on historical turns."""
339
+ from app.services.chat_service import ChatService
340
+
341
+ history = [{"role": "user", "content": "Earlier question."}]
342
+ messages = ChatService()._build_messages(
343
+ "Now what?", "UNIQUE_CTX_MARKER", history
344
+ )
345
+
346
+ carrying = [m for m in messages if "UNIQUE_CTX_MARKER" in m["content"]]
347
+ assert len(carrying) == 1
348
+ assert carrying[0] is messages[-1]
349
+
350
+ @pytest.mark.unit
351
+ def test_no_history_yields_system_plus_query_only(self):
352
+ """Behaviour with an empty history is unchanged from the stateless path."""
353
+ from app.services.chat_service import ChatService
354
+
355
+ messages = ChatService()._build_messages("A question.", "CTX", None)
356
+
357
+ assert len(messages) == 2
358
+ assert [m["role"] for m in messages] == ["system", "user"]
359
+
360
+
361
+ class TestRetrievalQueryExpansion:
362
+ """Tests for _build_retrieval_query.
363
+
364
+ Generation gets full history, so retrieval must too — otherwise a
365
+ follow-up embeds to almost nothing and the reranker has no good
366
+ candidates to pick from.
367
+ """
368
+
369
+ @pytest.mark.unit
370
+ def test_dependent_followup_is_expanded(self):
371
+ """A short follow-up inherits the previous user turn for retrieval."""
372
+ from app.services.chat_service import ChatService
373
+
374
+ history = [
375
+ {"role": "user", "content": "What are the methane limits underground?"},
376
+ {"role": "assistant", "content": "Below 1%."},
377
+ ]
378
+ expanded = ChatService._build_retrieval_query(
379
+ "What about surface mines?", history
380
+ )
381
+
382
+ assert "methane limits underground" in expanded
383
+ assert "What about surface mines?" in expanded
384
+
385
+ @pytest.mark.unit
386
+ def test_self_contained_question_is_untouched(self):
387
+ """A long, standalone question must not be polluted with prior context."""
388
+ from app.services.chat_service import ChatService
389
+
390
+ history = [{"role": "user", "content": "What are the methane limits?"}]
391
+ query = (
392
+ "Describe in full the statutory ventilation survey obligations that "
393
+ "apply to an underground coal mine operator under 30 CFR 75.323."
394
+ )
395
+
396
+ assert ChatService._build_retrieval_query(query, history) == query
397
+
398
+ @pytest.mark.unit
399
+ def test_first_turn_has_nothing_to_expand_from(self):
400
+ """With no history the query passes through unchanged."""
401
+ from app.services.chat_service import ChatService
402
+
403
+ assert ChatService._build_retrieval_query("Short one?", []) == "Short one?"