Peterase commited on
Commit
583c3c6
Β·
1 Parent(s): ebdd2fb

feat(intent): add 4-provider fallback chain for intent classification

Browse files

Fallback chain (in order):
1. Groq llama-3.1-8b-instant - 14,400 free RPD, ~50ms (PRIMARY)
2. Gemini Flash - 1,500 free RPD, ~200ms (FALLBACK 1)
3. OpenRouter auto router - free model pool, ~300ms (FALLBACK 2)
4. HuggingFace Inference API - ~300 RPH, ~2s (FALLBACK 3)
5. Default NEWS_GENERAL - always works, 0ms (SAFETY NET)

All providers use same classification prompt and parse logic.
OpenRouter uses openrouter/auto which selects best available free model.
HuggingFace uses Llama-3.2-3B-Instruct (fast, small, good for classification).
Added OPENROUTER_API_KEY to config.py and .env template.

.env CHANGED
@@ -108,3 +108,8 @@ SEARXNG_ENABLED=true
108
  SEARXNG_BASE_URL=http://searxng:8080
109
  SEARXNG_TIMEOUT=5.0
110
  SEARXNG_MAX_RESULTS=10
 
 
 
 
 
 
108
  SEARXNG_BASE_URL=http://searxng:8080
109
  SEARXNG_TIMEOUT=5.0
110
  SEARXNG_MAX_RESULTS=10
111
+
112
+ # --- OpenRouter (FREE model pool β€” fallback for intent classification) ---
113
+ # Get free key: https://openrouter.ai/keys (no credit card required)
114
+ # Free models: Llama 4, Qwen 3, DeepSeek, Gemma 3 and more
115
+ OPENROUTER_API_KEY=your-openrouter-api-key-here
src/core/config.py CHANGED
@@ -61,6 +61,9 @@ class Settings(BaseSettings):
61
  HF_TOKEN: str = os.getenv("HF_TOKEN", "")
62
  HF_MODEL: str = os.getenv("HF_MODEL", "meta-llama/Llama-3.1-8B-Instruct")
63
 
 
 
 
64
  # Ollama β€” local inference
65
  OLLAMA_HOST: str = os.getenv("OLLAMA_HOST", "http://localhost:11434")
66
  OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "llama3.2")
 
61
  HF_TOKEN: str = os.getenv("HF_TOKEN", "")
62
  HF_MODEL: str = os.getenv("HF_MODEL", "meta-llama/Llama-3.1-8B-Instruct")
63
 
64
+ # OpenRouter β€” free model pool | https://openrouter.ai/keys
65
+ OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
66
+
67
  # Ollama β€” local inference
68
  OLLAMA_HOST: str = os.getenv("OLLAMA_HOST", "http://localhost:11434")
69
  OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "llama3.2")
src/infrastructure/adapters/intent_classifier_v2.py CHANGED
@@ -1,24 +1,27 @@
1
  """
2
- Intent Classifier v4 β€” LLM-Powered (Hybrid)
3
 
4
  Architecture:
5
- Layer 1: Instant safety net (0ms) β€” 6 exact strings only
6
- Layer 2: LLM classification (50ms) β€” llama-3.1-8b-instant via Groq
7
- Layer 3: Safe default (0ms) β€” NEWS_GENERAL if LLM fails
 
 
 
8
 
9
  Why LLM instead of hard-coded rules:
10
  - 99%+ accuracy vs ~75% for keyword matching
11
- - Handles any language naturally (Amharic, Arabic, Somali...)
12
  - Handles any topic (new conflicts, new places, new events)
13
- - Zero maintenance β€” no keyword lists to update
14
  - Understands context ("Abiy's latest move" β†’ NEWS_TEMPORAL)
15
 
16
- Model choice: llama-3.1-8b-instant on Groq
17
- - 14,400 free requests/day (vs 1,000 for 70B)
18
- - Intent is a simple 4-choice task β€” 8B is more than enough
19
- - ~50ms latency
20
- - Preserves 70B quota for actual RAG answer generation
21
- - Fallback: Gemini Flash β†’ default NEWS_GENERAL
22
  """
23
 
24
  import logging
@@ -44,7 +47,7 @@ _INSTANT_OTHER = {
44
 
45
 
46
  # ═══════════════════════════════════════════════════════════════════════════════
47
- # CLASSIFICATION PROMPT
48
  # ═══════════════════════════════════════════════════════════════════════════════
49
 
50
  _CLASSIFY_PROMPT = """You are an intent classifier for ARKI AI, a news assistant focused on Ethiopia and Africa.
@@ -78,7 +81,7 @@ Category:"""
78
  class IntentResult:
79
  intent: str # NEWS_TEMPORAL | NEWS_HISTORICAL | NEWS_GENERAL | OTHER
80
  confidence: float # 0.0 – 1.0
81
- method: str # instant | llm_groq | llm_gemini | default
82
  inference_time_ms: float
83
  query_complexity: str # vague | simple | medium | complex
84
  sub_type: str # general | conflict | humanitarian | identity | off_topic
@@ -106,23 +109,31 @@ class IntentResult:
106
 
107
  class IntentClassifierV2:
108
  """
109
- LLM-powered intent classifier.
110
 
111
- Uses llama-3.1-8b-instant (14,400 free RPD on Groq) for classification.
112
- Falls back to Gemini Flash, then defaults to NEWS_GENERAL.
113
  """
114
 
115
- # Groq endpoint β€” uses the fast 8B model, not the 70B used for answers
116
- GROQ_BASE_URL = "https://api.groq.com/openai/v1/chat/completions"
117
- CLASSIFICATION_MODEL = "llama-3.1-8b-instant"
 
 
 
 
 
 
 
118
 
119
  VALID_INTENTS = {"NEWS_TEMPORAL", "NEWS_HISTORICAL", "NEWS_GENERAL", "OTHER"}
120
 
121
  def __init__(self):
122
  self._groq_key: Optional[str] = None
123
  self._gemini_key: Optional[str] = None
 
 
124
  self._client = httpx.Client(timeout=5.0)
125
- self._lock = threading.Lock()
126
  self._metrics = {
127
  "total": 0,
128
  "by_intent": {},
@@ -135,16 +146,37 @@ class IntentClassifierV2:
135
  """Load API keys from settings."""
136
  try:
137
  from src.core.config import settings
 
138
  key = settings.GROQ_API_KEY
139
  if key and key not in ("", "your-groq-api-key-here"):
140
  self._groq_key = key
141
- logger.info("βœ… Intent classifier: Groq key loaded")
142
- else:
143
- logger.warning("Intent classifier: Groq key not set β€” will use fallback")
144
 
145
- gem_key = settings.GEMINI_API_KEY
146
- if gem_key and gem_key not in ("", "your-gemini-api-key-here"):
147
- self._gemini_key = gem_key
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  except Exception as e:
149
  logger.error(f"Intent classifier: failed to load keys: {e}")
150
 
@@ -160,80 +192,137 @@ class IntentClassifierV2:
160
  if ql in _INSTANT_OTHER:
161
  return self._result("OTHER", 1.0, "instant", t0, complexity, "identity")
162
 
163
- # ── Layer 2: LLM classification ───────────────────────────────────────
164
- # Try Groq first (fast 8B model, 14,400 RPD free)
165
  if self._groq_key:
166
- intent = self._classify_with_groq(q)
 
 
 
 
 
 
167
  if intent:
168
  return self._result(intent, 0.97, "llm_groq", t0, complexity,
169
  self._sub_type(q, intent))
170
 
171
- # Try Gemini Flash as fallback
172
  if self._gemini_key:
173
- intent = self._classify_with_gemini(q)
174
  if intent:
175
  return self._result(intent, 0.95, "llm_gemini", t0, complexity,
176
  self._sub_type(q, intent))
177
 
178
- # ── Layer 3: Safe default ─────────────────────────────────────────────
179
- # Better to search and find nothing than to refuse
180
- logger.warning(f"Intent classifier: all LLMs failed for '{q[:50]}' β€” defaulting to NEWS_GENERAL")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  return self._result("NEWS_GENERAL", 0.50, "default", t0, complexity, "general")
182
 
183
- # ── LLM calls ─────────────────────────────────────────────────────────────
184
 
185
- def _classify_with_groq(self, query: str) -> Optional[str]:
186
- """Call Groq llama-3.1-8b-instant for intent classification."""
 
 
 
 
 
 
 
 
 
 
 
 
187
  try:
188
- prompt = _CLASSIFY_PROMPT.format(query=query)
 
 
 
 
 
 
189
  response = self._client.post(
190
- self.GROQ_BASE_URL,
191
- headers={
192
- "Authorization": f"Bearer {self._groq_key}",
193
- "Content-Type": "application/json",
194
- },
195
  json={
196
- "model": self.CLASSIFICATION_MODEL,
197
- "messages": [{"role": "user", "content": prompt}],
198
- "max_tokens": 20, # We only need 1 word
199
- "temperature": 0.0, # Deterministic
200
- "stop": ["\n", " "], # Stop after first word
 
201
  },
202
- timeout=4.0,
203
  )
204
 
205
  if response.status_code == 200:
206
- content = response.json()["choices"][0]["message"]["content"].strip()
 
 
 
 
 
 
207
  intent = self._parse_intent(content)
208
  if intent:
209
- logger.debug(f"Groq classified '{query[:40]}' β†’ {intent}")
210
  return intent
211
- logger.warning(f"Groq returned unexpected intent: '{content}'")
212
 
213
  elif response.status_code == 429:
214
- logger.warning("Intent classifier: Groq rate limit hit")
 
 
215
  else:
216
- logger.warning(f"Intent classifier: Groq returned {response.status_code}")
217
 
218
  except httpx.TimeoutException:
219
- logger.warning("Intent classifier: Groq timeout (4s)")
220
  except Exception as e:
221
- logger.error(f"Intent classifier: Groq error: {e}")
222
 
223
  return None
224
 
225
- def _classify_with_gemini(self, query: str) -> Optional[str]:
226
- """Call Gemini Flash as fallback classifier."""
227
  try:
228
- prompt = _CLASSIFY_PROMPT.format(query=query)
229
- url = (
230
- f"https://generativelanguage.googleapis.com/v1beta/models/"
231
- f"gemini-2.0-flash:generateContent?key={self._gemini_key}"
232
- )
233
  response = self._client.post(
234
  url,
235
  json={
236
- "contents": [{"parts": [{"text": prompt}]}],
 
 
237
  "generationConfig": {
238
  "maxOutputTokens": 20,
239
  "temperature": 0.0,
@@ -253,16 +342,18 @@ class IntentClassifierV2:
253
  )
254
  intent = self._parse_intent(content)
255
  if intent:
256
- logger.debug(f"Gemini classified '{query[:40]}' β†’ {intent}")
257
  return intent
258
 
259
  elif response.status_code == 429:
260
- logger.warning("Intent classifier: Gemini rate limit hit")
 
 
261
 
262
  except httpx.TimeoutException:
263
- logger.warning("Intent classifier: Gemini timeout (4s)")
264
  except Exception as e:
265
- logger.error(f"Intent classifier: Gemini error: {e}")
266
 
267
  return None
268
 
@@ -270,9 +361,8 @@ class IntentClassifierV2:
270
 
271
  def _parse_intent(self, raw: str) -> Optional[str]:
272
  """Parse LLM response to valid intent. Handles partial matches."""
273
- cleaned = raw.strip().upper().replace(".", "").replace(":", "")
274
 
275
- # Exact match
276
  if cleaned in self.VALID_INTENTS:
277
  return cleaned
278
 
@@ -294,22 +384,18 @@ class IntentClassifierV2:
294
  return "off_topic"
295
 
296
  ql = query.lower()
297
- if any(w in ql for w in ("clash", "attack", "killed", "battle", "fano", "tplf", "military", "troops")):
298
  return "conflict"
299
- if any(w in ql for w in ("displaced", "refugee", "aid", "humanitarian", "famine", "drought")):
300
  return "humanitarian"
301
  return "general"
302
 
303
  def _complexity(self, query: str) -> str:
304
  n = len(query.split())
305
- if n == 0:
306
- return "empty"
307
- if n == 1:
308
- return "vague"
309
- if n <= 4:
310
- return "simple"
311
- if n <= 12:
312
- return "medium"
313
  return "complex"
314
 
315
  def _result(
 
1
  """
2
+ Intent Classifier v4 β€” LLM-Powered with 4-Provider Fallback Chain
3
 
4
  Architecture:
5
+ Layer 1: Instant safety net (0ms) β€” 20 exact strings only
6
+ Layer 2: Groq llama-3.1-8b-instant β€” 14,400 free RPD, ~50ms (PRIMARY)
7
+ Layer 3: Gemini Flash fallback β€” 1,500 free RPD, ~200ms (FALLBACK 1)
8
+ Layer 4: OpenRouter free router β€” free models pool, ~300ms (FALLBACK 2)
9
+ Layer 5: HuggingFace Inference API β€” ~300 RPH, ~2s (FALLBACK 3)
10
+ Layer 6: Safe default β€” NEWS_GENERAL, 0ms (ALWAYS WORKS)
11
 
12
  Why LLM instead of hard-coded rules:
13
  - 99%+ accuracy vs ~75% for keyword matching
14
+ - Handles any language naturally (Amharic, Arabic, Somali, French...)
15
  - Handles any topic (new conflicts, new places, new events)
16
+ - Zero maintenance β€” no keyword lists to update ever
17
  - Understands context ("Abiy's latest move" β†’ NEWS_TEMPORAL)
18
 
19
+ Provider selection rationale:
20
+ - Groq 8B: 14,400 RPD free β€” primary, fastest, cheapest
21
+ - Gemini Flash: 1,500 RPD free β€” reliable fallback
22
+ - OpenRouter: free model pool β€” auto-selects best available free model
23
+ - HuggingFace: ~300 RPH free β€” last resort (slower but always available)
24
+ - Default: NEWS_GENERAL β€” never fails, safe for user experience
25
  """
26
 
27
  import logging
 
47
 
48
 
49
  # ═══════════════════════════════════════════════════════════════════════════════
50
+ # CLASSIFICATION PROMPT β€” same prompt used across all providers
51
  # ═══════════════════════════════════════════════════════════════════════════════
52
 
53
  _CLASSIFY_PROMPT = """You are an intent classifier for ARKI AI, a news assistant focused on Ethiopia and Africa.
 
81
  class IntentResult:
82
  intent: str # NEWS_TEMPORAL | NEWS_HISTORICAL | NEWS_GENERAL | OTHER
83
  confidence: float # 0.0 – 1.0
84
+ method: str # instant | llm_groq | llm_gemini | llm_openrouter | llm_hf | default
85
  inference_time_ms: float
86
  query_complexity: str # vague | simple | medium | complex
87
  sub_type: str # general | conflict | humanitarian | identity | off_topic
 
109
 
110
  class IntentClassifierV2:
111
  """
112
+ LLM-powered intent classifier with 4-provider fallback chain.
113
 
114
+ Fallback order:
115
+ Groq 8B β†’ Gemini Flash β†’ OpenRouter Free β†’ HuggingFace β†’ Default
116
  """
117
 
118
+ # Provider endpoints
119
+ GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
120
+ GROQ_MODEL = "llama-3.1-8b-instant" # 14,400 free RPD
121
+
122
+ GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
123
+
124
+ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
125
+ OPENROUTER_MODEL = "openrouter/auto" # Auto-selects best available free model
126
+
127
+ HF_URL = "https://api-inference.huggingface.co/models/meta-llama/Llama-3.2-3B-Instruct/v1/chat/completions"
128
 
129
  VALID_INTENTS = {"NEWS_TEMPORAL", "NEWS_HISTORICAL", "NEWS_GENERAL", "OTHER"}
130
 
131
  def __init__(self):
132
  self._groq_key: Optional[str] = None
133
  self._gemini_key: Optional[str] = None
134
+ self._openrouter_key: Optional[str] = None
135
+ self._hf_token: Optional[str] = None
136
  self._client = httpx.Client(timeout=5.0)
 
137
  self._metrics = {
138
  "total": 0,
139
  "by_intent": {},
 
146
  """Load API keys from settings."""
147
  try:
148
  from src.core.config import settings
149
+
150
  key = settings.GROQ_API_KEY
151
  if key and key not in ("", "your-groq-api-key-here"):
152
  self._groq_key = key
 
 
 
153
 
154
+ gem = settings.GEMINI_API_KEY
155
+ if gem and gem not in ("", "your-gemini-api-key-here"):
156
+ self._gemini_key = gem
157
+
158
+ # OpenRouter key (add OPENROUTER_API_KEY to .env)
159
+ try:
160
+ or_key = getattr(settings, "OPENROUTER_API_KEY", "")
161
+ if or_key and or_key not in ("", "your-openrouter-api-key-here"):
162
+ self._openrouter_key = or_key
163
+ except Exception:
164
+ pass
165
+
166
+ # HuggingFace token
167
+ hf = settings.HF_TOKEN
168
+ if hf and hf not in ("", "your-hf-token-here"):
169
+ self._hf_token = hf
170
+
171
+ providers = []
172
+ if self._groq_key: providers.append("Groq")
173
+ if self._gemini_key: providers.append("Gemini")
174
+ if self._openrouter_key: providers.append("OpenRouter")
175
+ if self._hf_token: providers.append("HuggingFace")
176
+ providers.append("Default")
177
+
178
+ logger.info(f"βœ… Intent classifier providers: {' β†’ '.join(providers)}")
179
+
180
  except Exception as e:
181
  logger.error(f"Intent classifier: failed to load keys: {e}")
182
 
 
192
  if ql in _INSTANT_OTHER:
193
  return self._result("OTHER", 1.0, "instant", t0, complexity, "identity")
194
 
195
+ # ── Layer 2: Groq llama-3.1-8b-instant (PRIMARY) ─────────────────────
 
196
  if self._groq_key:
197
+ intent = self._call_openai_compat(
198
+ url=self.GROQ_URL,
199
+ api_key=self._groq_key,
200
+ model=self.GROQ_MODEL,
201
+ query=q,
202
+ provider="groq",
203
+ )
204
  if intent:
205
  return self._result(intent, 0.97, "llm_groq", t0, complexity,
206
  self._sub_type(q, intent))
207
 
208
+ # ── Layer 3: Gemini Flash (FALLBACK 1) ────────────────────────────────
209
  if self._gemini_key:
210
+ intent = self._call_gemini(q)
211
  if intent:
212
  return self._result(intent, 0.95, "llm_gemini", t0, complexity,
213
  self._sub_type(q, intent))
214
 
215
+ # ── Layer 4: OpenRouter free router (FALLBACK 2) ─────────────────────
216
+ if self._openrouter_key:
217
+ intent = self._call_openai_compat(
218
+ url=self.OPENROUTER_URL,
219
+ api_key=self._openrouter_key,
220
+ model=self.OPENROUTER_MODEL,
221
+ query=q,
222
+ provider="openrouter",
223
+ extra_headers={
224
+ "HTTP-Referer": "https://arki-ai.com",
225
+ "X-Title": "ARKI AI Intent Classifier",
226
+ },
227
+ )
228
+ if intent:
229
+ return self._result(intent, 0.93, "llm_openrouter", t0, complexity,
230
+ self._sub_type(q, intent))
231
+
232
+ # ── Layer 5: HuggingFace Inference API (FALLBACK 3) ───────────────────
233
+ if self._hf_token:
234
+ intent = self._call_openai_compat(
235
+ url=self.HF_URL,
236
+ api_key=self._hf_token,
237
+ model="meta-llama/Llama-3.2-3B-Instruct",
238
+ query=q,
239
+ provider="huggingface",
240
+ timeout=8.0, # HF is slower
241
+ )
242
+ if intent:
243
+ return self._result(intent, 0.90, "llm_hf", t0, complexity,
244
+ self._sub_type(q, intent))
245
+
246
+ # ── Layer 6: Safe default ─────────────────────────────────────────────
247
+ logger.warning(f"Intent: all providers failed for '{q[:50]}' β€” defaulting to NEWS_GENERAL")
248
  return self._result("NEWS_GENERAL", 0.50, "default", t0, complexity, "general")
249
 
250
+ # ── Provider calls ────────────────────────────────────────────────────────
251
 
252
+ def _call_openai_compat(
253
+ self,
254
+ url: str,
255
+ api_key: str,
256
+ model: str,
257
+ query: str,
258
+ provider: str,
259
+ extra_headers: Optional[Dict] = None,
260
+ timeout: float = 4.0,
261
+ ) -> Optional[str]:
262
+ """
263
+ Generic OpenAI-compatible API call.
264
+ Works for: Groq, OpenRouter, HuggingFace (all use same format).
265
+ """
266
  try:
267
+ headers = {
268
+ "Authorization": f"Bearer {api_key}",
269
+ "Content-Type": "application/json",
270
+ }
271
+ if extra_headers:
272
+ headers.update(extra_headers)
273
+
274
  response = self._client.post(
275
+ url,
276
+ headers=headers,
 
 
 
277
  json={
278
+ "model": model,
279
+ "messages": [
280
+ {"role": "user", "content": _CLASSIFY_PROMPT.format(query=query)}
281
+ ],
282
+ "max_tokens": 20,
283
+ "temperature": 0.0,
284
  },
285
+ timeout=timeout,
286
  )
287
 
288
  if response.status_code == 200:
289
+ content = (
290
+ response.json()
291
+ .get("choices", [{}])[0]
292
+ .get("message", {})
293
+ .get("content", "")
294
+ .strip()
295
+ )
296
  intent = self._parse_intent(content)
297
  if intent:
298
+ logger.debug(f"{provider}: '{query[:40]}' β†’ {intent}")
299
  return intent
300
+ logger.warning(f"{provider}: unexpected response: '{content}'")
301
 
302
  elif response.status_code == 429:
303
+ logger.warning(f"Intent: {provider} rate limited")
304
+ elif response.status_code == 503:
305
+ logger.warning(f"Intent: {provider} unavailable (503)")
306
  else:
307
+ logger.warning(f"Intent: {provider} returned {response.status_code}")
308
 
309
  except httpx.TimeoutException:
310
+ logger.warning(f"Intent: {provider} timeout ({timeout}s)")
311
  except Exception as e:
312
+ logger.error(f"Intent: {provider} error: {e}")
313
 
314
  return None
315
 
316
+ def _call_gemini(self, query: str) -> Optional[str]:
317
+ """Gemini has a different API format."""
318
  try:
319
+ url = f"{self.GEMINI_URL}?key={self._gemini_key}"
 
 
 
 
320
  response = self._client.post(
321
  url,
322
  json={
323
+ "contents": [
324
+ {"parts": [{"text": _CLASSIFY_PROMPT.format(query=query)}]}
325
+ ],
326
  "generationConfig": {
327
  "maxOutputTokens": 20,
328
  "temperature": 0.0,
 
342
  )
343
  intent = self._parse_intent(content)
344
  if intent:
345
+ logger.debug(f"gemini: '{query[:40]}' β†’ {intent}")
346
  return intent
347
 
348
  elif response.status_code == 429:
349
+ logger.warning("Intent: Gemini rate limited")
350
+ else:
351
+ logger.warning(f"Intent: Gemini returned {response.status_code}")
352
 
353
  except httpx.TimeoutException:
354
+ logger.warning("Intent: Gemini timeout (4s)")
355
  except Exception as e:
356
+ logger.error(f"Intent: Gemini error: {e}")
357
 
358
  return None
359
 
 
361
 
362
  def _parse_intent(self, raw: str) -> Optional[str]:
363
  """Parse LLM response to valid intent. Handles partial matches."""
364
+ cleaned = raw.strip().upper().replace(".", "").replace(":", "").split()[0] if raw.strip() else ""
365
 
 
366
  if cleaned in self.VALID_INTENTS:
367
  return cleaned
368
 
 
384
  return "off_topic"
385
 
386
  ql = query.lower()
387
+ if any(w in ql for w in ("clash", "attack", "killed", "battle", "fano", "tplf", "military")):
388
  return "conflict"
389
+ if any(w in ql for w in ("displaced", "refugee", "aid", "humanitarian", "famine")):
390
  return "humanitarian"
391
  return "general"
392
 
393
  def _complexity(self, query: str) -> str:
394
  n = len(query.split())
395
+ if n == 0: return "empty"
396
+ if n == 1: return "vague"
397
+ if n <= 4: return "simple"
398
+ if n <= 12: return "medium"
 
 
 
 
399
  return "complex"
400
 
401
  def _result(