ethnmcl commited on
Commit
73b845e
·
verified ·
1 Parent(s): 8db9436

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +48 -72
main.py CHANGED
@@ -12,8 +12,22 @@ from pydantic import BaseModel, Field
12
  from sentence_transformers import SentenceTransformer
13
  from dateutil.relativedelta import relativedelta
14
 
15
- # === Environment ===
16
- API_KEY = os.getenv("API_KEY") # shared secret for this API (set as a Space secret/variable)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  SUPABASE_URL = os.getenv("SUPABASE_URL")
18
  SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
19
  MODEL_NAME = os.getenv("MODEL_NAME", "BAAI/bge-small-en-v1.5")
@@ -41,7 +55,7 @@ TIME_PATTERNS = [
41
  r"\b(january|february|march|april|may|june|july|august|september|october|november|december)(?:\s+\d{4})?\b",
42
  ]
43
 
44
- app = FastAPI(title="CIC Check-ins API", version="1.3.0")
45
 
46
  # === Auth guard ===
47
  def require_key(authorization: Optional[str] = Header(None)):
@@ -56,9 +70,15 @@ def require_key(authorization: Optional[str] = Header(None)):
56
  # === Startup / Shutdown ===
57
  @app.on_event("startup")
58
  async def on_startup():
59
- # Load embedding model once
60
- app.state.model = SentenceTransformer(MODEL_NAME)
61
- # Supabase REST client (uses service role for RPCs)
 
 
 
 
 
 
62
  app.state.http = httpx.AsyncClient(
63
  base_url=f"{SUPABASE_URL}/rest/v1",
64
  headers={
@@ -98,12 +118,10 @@ def to_utc_iso(local_iso: str) -> str:
98
 
99
  def extract_time_subphrase(text: str, tz: pytz.BaseTzInfo) -> Optional[str]:
100
  s = (text or "").lower()
101
- # 1) Regex heuristics
102
  for pat in TIME_PATTERNS:
103
  m = re.search(pat, s)
104
  if m:
105
  return m.group(0)
106
- # 2) Fallback: search any date in text
107
  settings = {
108
  "TIMEZONE": str(tz),
109
  "RETURN_AS_TIMEZONE_AWARE": True,
@@ -121,7 +139,6 @@ def parse_phrase_to_range(
121
  tz: Optional[pytz.BaseTzInfo] = None,
122
  week_start: Optional[str] = None
123
  ) -> Dict[str, str]:
124
- """Parse human phrase into [start, end) in tz. Returns {start, end, source}."""
125
  tz = tz or LOCAL_TZ
126
  week_start = (week_start or DEFAULT_WEEK_START).strip().lower()
127
  s_in = (phrase or "").strip()
@@ -131,7 +148,6 @@ def parse_phrase_to_range(
131
 
132
  now = datetime.now(tz)
133
 
134
- # last <weekday>
135
  m = re.fullmatch(r"last\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)", s)
136
  if m:
137
  target = WEEKDAYS[m.group(1)]
@@ -140,7 +156,6 @@ def parse_phrase_to_range(
140
  day = _day_start(now - timedelta(days=delta))
141
  return {"start": day.isoformat(), "end": (day + timedelta(days=1)).isoformat(), "source": "weekday"}
142
 
143
- # today / yesterday
144
  if s == "today":
145
  start = _day_start(now)
146
  return {"start": start.isoformat(), "end": (start + timedelta(days=1)).isoformat(), "source": "day"}
@@ -149,7 +164,6 @@ def parse_phrase_to_range(
149
  start = end - timedelta(days=1)
150
  return {"start": start.isoformat(), "end": end.isoformat(), "source": "day"}
151
 
152
- # this/last week
153
  if s == "this week":
154
  start = _week_start(now, week_start)
155
  return {"start": start.isoformat(), "end": (start + timedelta(days=7)).isoformat(), "source": "week"}
@@ -158,7 +172,6 @@ def parse_phrase_to_range(
158
  start = this_start - timedelta(days=7)
159
  return {"start": start.isoformat(), "end": (start + timedelta(days=7)).isoformat(), "source": "week"}
160
 
161
- # this/last month
162
  if s == "this month":
163
  start = _localize(tz, datetime(now.year, now.month, 1))
164
  end = _localize(tz, datetime(now.year + (1 if now.month == 12 else 0),
@@ -170,7 +183,6 @@ def parse_phrase_to_range(
170
  end = first_this
171
  return {"start": start.isoformat(), "end": end.isoformat(), "source": "month"}
172
 
173
- # <month> [year]?
174
  m = re.fullmatch(rf"({'|'.join(MONTHS)})(?:\s+(\d{{4}}))?", s)
175
  if m:
176
  month_name, year_str = m.group(1), m.group(2)
@@ -180,12 +192,11 @@ def parse_phrase_to_range(
180
  end = _localize(tz, datetime(year + 1, 1, 1)) if month_idx == 12 else _localize(tz, datetime(year, month_idx + 1, 1))
181
  return {"start": start.isoformat(), "end": end.isoformat(), "source": "month"}
182
 
183
- # (past|last) <N> (days|weeks|months)
184
  m = re.fullmatch(r"(past|last)\s+(\d+)\s*(day|days|week|weeks|month|months)", s)
185
  if m:
186
  n = int(m.group(2))
187
  unit = m.group(3)
188
- end = _day_start(now) + timedelta(days=1) # through end of today
189
  if unit.startswith("day"):
190
  start = end - timedelta(days=n)
191
  elif unit.startswith("week"):
@@ -194,7 +205,6 @@ def parse_phrase_to_range(
194
  start = end - relativedelta(months=n)
195
  return {"start": start.isoformat(), "end": end.isoformat(), "source": "relative"}
196
 
197
- # quarters: Q1..Q4 [year]?
198
  m = re.fullmatch(r"q([1-4])(?:\s+(\d{4}))?", s)
199
  if m:
200
  q = int(m.group(1))
@@ -204,13 +214,7 @@ def parse_phrase_to_range(
204
  end = start + relativedelta(months=3)
205
  return {"start": start.isoformat(), "end": end.isoformat(), "source": "quarter"}
206
 
207
- # fallback: dateparser -> day range
208
- settings = {
209
- "TIMEZONE": str(tz),
210
- "RETURN_AS_TIMEZONE_AWARE": True,
211
- "PREFER_DATES_FROM": "past",
212
- "RELATIVE_BASE": now
213
- }
214
  dt = dateparser.parse(s, settings=settings, languages=["en"])
215
  if not dt:
216
  raise HTTPException(400, detail=f"Could not parse phrase: {phrase}\n")
@@ -242,7 +246,6 @@ class SearchBody(BaseModel):
242
  filters: Optional[SearchFilters] = None
243
  return_fields: List[str] = ["id","ts","sender","username","msg","score"]
244
 
245
- # /interpret request schema
246
  class InterpretDefaults(BaseModel):
247
  timezone: Optional[str] = None
248
  week_start: Optional[str] = None
@@ -263,15 +266,12 @@ class InterpretBody(BaseModel):
263
  # === Routes ===
264
  @app.get("/")
265
  async def root():
266
- return {
267
- "ok": True,
268
- "hint": "Use /healthz, /ingest, /search, /phrases/resolve, /interpret, /stats",
269
- "week_start": DEFAULT_WEEK_START
270
- }
271
 
272
  @app.get("/healthz")
273
  async def health():
274
- return {"ok": True, "model": MODEL_NAME}
 
275
 
276
  @app.get("/phrases/resolve")
277
  async def resolve_phrase(phrase: str = Query(..., min_length=1), _: None = Depends(require_key)):
@@ -280,20 +280,12 @@ async def resolve_phrase(phrase: str = Query(..., min_length=1), _: None = Depen
280
 
281
  @app.post("/ingest")
282
  async def ingest(body: IngestBody, _: None = Depends(require_key)):
283
- ts_utc = (
284
- datetime.fromisoformat(body.timestamp).astimezone(pytz.UTC).isoformat()
285
- if body.timestamp else datetime.now(pytz.UTC).isoformat()
286
- )
287
  vec = embed_text([body.msg])[0]
288
  payload = {
289
- "_id": body.id,
290
- "_sender": body.sender,
291
- "_username": body.username,
292
- "_slack_id": body.slack_id,
293
- "_msg": body.msg,
294
- "_ts": ts_utc,
295
- "_tags": body.tags or [],
296
- "_valid": True if body.valid_checkin is not False else False,
297
  "_embedding": vec,
298
  }
299
  r = await app.state.http.post("/rpc/upsert_checkin", json=payload)
@@ -314,10 +306,7 @@ async def search(body: SearchBody, _: None = Depends(require_key)):
314
  if body.filters.end:
315
  end_utc = to_utc_iso(body.filters.end) if "T" in body.filters.end else to_utc_iso(LOCAL_TZ.localize(datetime.fromisoformat(body.filters.end)).isoformat())
316
  rpc_payload = {
317
- "q_embedding": q_vec,
318
- "k": max(1, min(body.k, 100)),
319
- "start_ts": start_utc,
320
- "end_ts": end_utc,
321
  "sender_eq": body.filters.sender if body.filters and body.filters.sender else None,
322
  "valid_only": body.filters.valid_only if body.filters else None
323
  }
@@ -334,11 +323,7 @@ async def search(body: SearchBody, _: None = Depends(require_key)):
334
  return {"results": out, "used": {"semantic": True}}
335
 
336
  @app.get("/stats")
337
- async def stats(
338
- phrase: Optional[str] = None,
339
- bucket: Literal["weekly","monthly"] = "weekly",
340
- _: None = Depends(require_key)
341
- ):
342
  if phrase:
343
  rng = parse_phrase_to_range(phrase)
344
  start_utc, end_utc = to_utc_iso(rng["start"]), to_utc_iso(rng["end"])
@@ -352,15 +337,8 @@ async def stats(
352
  raise HTTPException(r.status_code, detail=f"Supabase RPC error: {r.text[:300]}")
353
  return {"bucket": bucket, "range": {"start": start_utc, "end": end_utc}, **r.json()}
354
 
355
- # === /interpret ===
356
- class InterpretResponse(BaseModel):
357
- ok: bool
358
-
359
  @app.post("/interpret")
360
  async def interpret(body: InterpretBody, _: None = Depends(require_key)):
361
- """
362
- Free-form input -> (query, time window) + (optionally) return matching rows.
363
- """
364
  text = (body.text or "").strip()
365
  if not text:
366
  raise HTTPException(400, detail="Missing 'text'")
@@ -369,8 +347,10 @@ async def interpret(body: InterpretBody, _: None = Depends(require_key)):
369
  week_start = DEFAULT_WEEK_START
370
  if body.defaults:
371
  if body.defaults.timezone:
372
- try: tz = pytz.timezone(body.defaults.timezone)
373
- except Exception: pass
 
 
374
  if body.defaults.week_start and body.defaults.week_start.lower() in ("monday","sunday"):
375
  week_start = body.defaults.week_start.lower()
376
 
@@ -407,7 +387,7 @@ async def interpret(body: InterpretBody, _: None = Depends(require_key)):
407
  else:
408
  return {
409
  "ok": False,
410
- "error": { "code": "NO_TIME_FOUND", "message": "No time phrase detected and no fallback_range provided." },
411
  "hints": ["Add 'last week', 'August', 'past 30 days'", "Or pass defaults.fallback_range"],
412
  "query_guess": query or text
413
  }
@@ -416,7 +396,7 @@ async def interpret(body: InterpretBody, _: None = Depends(require_key)):
416
  search_payload = {
417
  "query": query or text,
418
  "k": max(1, min(opt.k, 100)),
419
- "filters": { "start": rng["start"], "end": rng["end"], "sender": opt.infer_sender, "valid_only": None },
420
  "return_fields": opt.return_fields
421
  }
422
 
@@ -426,10 +406,8 @@ async def interpret(body: InterpretBody, _: None = Depends(require_key)):
426
  start_utc = to_utc_iso(search_payload["filters"]["start"])
427
  end_utc = to_utc_iso(search_payload["filters"]["end"])
428
  rpc_payload = {
429
- "q_embedding": q_vec,
430
- "k": search_payload["k"],
431
- "start_ts": start_utc,
432
- "end_ts": end_utc,
433
  "sender_eq": search_payload["filters"]["sender"],
434
  "valid_only": search_payload["filters"].get("valid_only")
435
  }
@@ -446,12 +424,10 @@ async def interpret(body: InterpretBody, _: None = Depends(require_key)):
446
 
447
  resp: Dict[str, Any] = {
448
  "ok": True,
449
- "input": { "text": body.text, "timezone": str(tz), "week_start": week_start },
450
  "query": query or text,
451
- "time": {
452
- "phrase_raw": body.text, "phrase_extracted": extracted, "source": time_source,
453
- "start": rng["start"], "end": rng["end"], "tz": rng["tz"]
454
- },
455
  "search_payload": search_payload
456
  }
457
  if suggestions and (not used_fallback):
 
12
  from sentence_transformers import SentenceTransformer
13
  from dateutil.relativedelta import relativedelta
14
 
15
+ # ==== Cache & Env setup (important on HF Spaces) ====
16
+ # Put all model caches under /data (persistent & writable in Spaces)
17
+ CACHE_ROOT = os.getenv("MODEL_CACHE_DIR", "/data/.cache")
18
+ HF_HOME = os.getenv("HF_HOME", os.path.join(CACHE_ROOT, "huggingface"))
19
+ TRANSFORMERS_CACHE = os.getenv("TRANSFORMERS_CACHE", os.path.join(HF_HOME, "transformers"))
20
+ ST_HOME = os.getenv("SENTENCE_TRANSFORMERS_HOME", os.path.join(CACHE_ROOT, "sentence-transformers"))
21
+
22
+ os.makedirs(TRANSFORMERS_CACHE, exist_ok=True)
23
+ os.makedirs(ST_HOME, exist_ok=True)
24
+
25
+ os.environ["HF_HOME"] = HF_HOME
26
+ os.environ["TRANSFORMERS_CACHE"] = TRANSFORMERS_CACHE
27
+ os.environ["SENTENCE_TRANSFORMERS_HOME"] = ST_HOME
28
+
29
+ # ==== App config ====
30
+ API_KEY = os.getenv("API_KEY") # shared secret for this API
31
  SUPABASE_URL = os.getenv("SUPABASE_URL")
32
  SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
33
  MODEL_NAME = os.getenv("MODEL_NAME", "BAAI/bge-small-en-v1.5")
 
55
  r"\b(january|february|march|april|may|june|july|august|september|october|november|december)(?:\s+\d{4})?\b",
56
  ]
57
 
58
+ app = FastAPI(title="CIC Check-ins API", version="1.3.1")
59
 
60
  # === Auth guard ===
61
  def require_key(authorization: Optional[str] = Header(None)):
 
70
  # === Startup / Shutdown ===
71
  @app.on_event("startup")
72
  async def on_startup():
73
+ # Load embedding model with explicit cache folder (fixes /.cache permission issue)
74
+ try:
75
+ app.state.model = SentenceTransformer(MODEL_NAME, cache_folder=ST_HOME)
76
+ except Exception as e:
77
+ # Optional fallback to a tiny model if the specified one fails (keeps API available)
78
+ fallback = "sentence-transformers/all-MiniLM-L6-v2"
79
+ app.state.model = SentenceTransformer(fallback, cache_folder=ST_HOME)
80
+ app.state.model_name_fallback = fallback
81
+ # Supabase REST client
82
  app.state.http = httpx.AsyncClient(
83
  base_url=f"{SUPABASE_URL}/rest/v1",
84
  headers={
 
118
 
119
  def extract_time_subphrase(text: str, tz: pytz.BaseTzInfo) -> Optional[str]:
120
  s = (text or "").lower()
 
121
  for pat in TIME_PATTERNS:
122
  m = re.search(pat, s)
123
  if m:
124
  return m.group(0)
 
125
  settings = {
126
  "TIMEZONE": str(tz),
127
  "RETURN_AS_TIMEZONE_AWARE": True,
 
139
  tz: Optional[pytz.BaseTzInfo] = None,
140
  week_start: Optional[str] = None
141
  ) -> Dict[str, str]:
 
142
  tz = tz or LOCAL_TZ
143
  week_start = (week_start or DEFAULT_WEEK_START).strip().lower()
144
  s_in = (phrase or "").strip()
 
148
 
149
  now = datetime.now(tz)
150
 
 
151
  m = re.fullmatch(r"last\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)", s)
152
  if m:
153
  target = WEEKDAYS[m.group(1)]
 
156
  day = _day_start(now - timedelta(days=delta))
157
  return {"start": day.isoformat(), "end": (day + timedelta(days=1)).isoformat(), "source": "weekday"}
158
 
 
159
  if s == "today":
160
  start = _day_start(now)
161
  return {"start": start.isoformat(), "end": (start + timedelta(days=1)).isoformat(), "source": "day"}
 
164
  start = end - timedelta(days=1)
165
  return {"start": start.isoformat(), "end": end.isoformat(), "source": "day"}
166
 
 
167
  if s == "this week":
168
  start = _week_start(now, week_start)
169
  return {"start": start.isoformat(), "end": (start + timedelta(days=7)).isoformat(), "source": "week"}
 
172
  start = this_start - timedelta(days=7)
173
  return {"start": start.isoformat(), "end": (start + timedelta(days=7)).isoformat(), "source": "week"}
174
 
 
175
  if s == "this month":
176
  start = _localize(tz, datetime(now.year, now.month, 1))
177
  end = _localize(tz, datetime(now.year + (1 if now.month == 12 else 0),
 
183
  end = first_this
184
  return {"start": start.isoformat(), "end": end.isoformat(), "source": "month"}
185
 
 
186
  m = re.fullmatch(rf"({'|'.join(MONTHS)})(?:\s+(\d{{4}}))?", s)
187
  if m:
188
  month_name, year_str = m.group(1), m.group(2)
 
192
  end = _localize(tz, datetime(year + 1, 1, 1)) if month_idx == 12 else _localize(tz, datetime(year, month_idx + 1, 1))
193
  return {"start": start.isoformat(), "end": end.isoformat(), "source": "month"}
194
 
 
195
  m = re.fullmatch(r"(past|last)\s+(\d+)\s*(day|days|week|weeks|month|months)", s)
196
  if m:
197
  n = int(m.group(2))
198
  unit = m.group(3)
199
+ end = _day_start(now) + timedelta(days=1)
200
  if unit.startswith("day"):
201
  start = end - timedelta(days=n)
202
  elif unit.startswith("week"):
 
205
  start = end - relativedelta(months=n)
206
  return {"start": start.isoformat(), "end": end.isoformat(), "source": "relative"}
207
 
 
208
  m = re.fullmatch(r"q([1-4])(?:\s+(\d{4}))?", s)
209
  if m:
210
  q = int(m.group(1))
 
214
  end = start + relativedelta(months=3)
215
  return {"start": start.isoformat(), "end": end.isoformat(), "source": "quarter"}
216
 
217
+ settings = {"TIMEZONE": str(tz), "RETURN_AS_TIMEZONE_AWARE": True, "PREFER_DATES_FROM": "past", "RELATIVE_BASE": now}
 
 
 
 
 
 
218
  dt = dateparser.parse(s, settings=settings, languages=["en"])
219
  if not dt:
220
  raise HTTPException(400, detail=f"Could not parse phrase: {phrase}\n")
 
246
  filters: Optional[SearchFilters] = None
247
  return_fields: List[str] = ["id","ts","sender","username","msg","score"]
248
 
 
249
  class InterpretDefaults(BaseModel):
250
  timezone: Optional[str] = None
251
  week_start: Optional[str] = None
 
266
  # === Routes ===
267
  @app.get("/")
268
  async def root():
269
+ return {"ok": True, "hint": "Use /healthz, /ingest, /search, /phrases/resolve, /interpret, /stats", "week_start": DEFAULT_WEEK_START}
 
 
 
 
270
 
271
  @app.get("/healthz")
272
  async def health():
273
+ model_name = getattr(app.state, "model_name_fallback", MODEL_NAME)
274
+ return {"ok": True, "model": model_name}
275
 
276
  @app.get("/phrases/resolve")
277
  async def resolve_phrase(phrase: str = Query(..., min_length=1), _: None = Depends(require_key)):
 
280
 
281
  @app.post("/ingest")
282
  async def ingest(body: IngestBody, _: None = Depends(require_key)):
283
+ ts_utc = (datetime.fromisoformat(body.timestamp).astimezone(pytz.UTC).isoformat()
284
+ if body.timestamp else datetime.now(pytz.UTC).isoformat())
 
 
285
  vec = embed_text([body.msg])[0]
286
  payload = {
287
+ "_id": body.id, "_sender": body.sender, "_username": body.username, "_slack_id": body.slack_id,
288
+ "_msg": body.msg, "_ts": ts_utc, "_tags": body.tags or [], "_valid": True if body.valid_checkin is not False else False,
 
 
 
 
 
 
289
  "_embedding": vec,
290
  }
291
  r = await app.state.http.post("/rpc/upsert_checkin", json=payload)
 
306
  if body.filters.end:
307
  end_utc = to_utc_iso(body.filters.end) if "T" in body.filters.end else to_utc_iso(LOCAL_TZ.localize(datetime.fromisoformat(body.filters.end)).isoformat())
308
  rpc_payload = {
309
+ "q_embedding": q_vec, "k": max(1, min(body.k, 100)), "start_ts": start_utc, "end_ts": end_utc,
 
 
 
310
  "sender_eq": body.filters.sender if body.filters and body.filters.sender else None,
311
  "valid_only": body.filters.valid_only if body.filters else None
312
  }
 
323
  return {"results": out, "used": {"semantic": True}}
324
 
325
  @app.get("/stats")
326
+ async def stats(phrase: Optional[str] = None, bucket: Literal["weekly","monthly"] = "weekly", _: None = Depends(require_key)):
 
 
 
 
327
  if phrase:
328
  rng = parse_phrase_to_range(phrase)
329
  start_utc, end_utc = to_utc_iso(rng["start"]), to_utc_iso(rng["end"])
 
337
  raise HTTPException(r.status_code, detail=f"Supabase RPC error: {r.text[:300]}")
338
  return {"bucket": bucket, "range": {"start": start_utc, "end": end_utc}, **r.json()}
339
 
 
 
 
 
340
  @app.post("/interpret")
341
  async def interpret(body: InterpretBody, _: None = Depends(require_key)):
 
 
 
342
  text = (body.text or "").strip()
343
  if not text:
344
  raise HTTPException(400, detail="Missing 'text'")
 
347
  week_start = DEFAULT_WEEK_START
348
  if body.defaults:
349
  if body.defaults.timezone:
350
+ try:
351
+ tz = pytz.timezone(body.defaults.timezone)
352
+ except Exception:
353
+ pass
354
  if body.defaults.week_start and body.defaults.week_start.lower() in ("monday","sunday"):
355
  week_start = body.defaults.week_start.lower()
356
 
 
387
  else:
388
  return {
389
  "ok": False,
390
+ "error": {"code": "NO_TIME_FOUND", "message": "No time phrase detected and no fallback_range provided."},
391
  "hints": ["Add 'last week', 'August', 'past 30 days'", "Or pass defaults.fallback_range"],
392
  "query_guess": query or text
393
  }
 
396
  search_payload = {
397
  "query": query or text,
398
  "k": max(1, min(opt.k, 100)),
399
+ "filters": {"start": rng["start"], "end": rng["end"], "sender": opt.infer_sender, "valid_only": None},
400
  "return_fields": opt.return_fields
401
  }
402
 
 
406
  start_utc = to_utc_iso(search_payload["filters"]["start"])
407
  end_utc = to_utc_iso(search_payload["filters"]["end"])
408
  rpc_payload = {
409
+ "q_embedding": q_vec, "k": search_payload["k"],
410
+ "start_ts": start_utc, "end_ts": end_utc,
 
 
411
  "sender_eq": search_payload["filters"]["sender"],
412
  "valid_only": search_payload["filters"].get("valid_only")
413
  }
 
424
 
425
  resp: Dict[str, Any] = {
426
  "ok": True,
427
+ "input": {"text": body.text, "timezone": str(tz), "week_start": week_start},
428
  "query": query or text,
429
+ "time": {"phrase_raw": body.text, "phrase_extracted": extracted, "source": time_source,
430
+ "start": rng["start"], "end": rng["end"], "tz": rng["tz"]},
 
 
431
  "search_payload": search_payload
432
  }
433
  if suggestions and (not used_fallback):