Rasheed commited on
Commit
418ece4
·
verified ·
1 Parent(s): db5bf08

Upload 3 files

Browse files
Files changed (3) hide show
  1. groqai.py +79 -25
  2. humanizer.py +32 -12
  3. meaning.py +3 -0
groqai.py CHANGED
@@ -12,6 +12,8 @@ If Groq is unreachable or out of quota, the local checks alone are used (as befo
12
 
13
  Settings (Space → Settings → Variables and secrets):
14
  GROQ_API_KEY secret — from https://console.groq.com/keys
 
 
15
  GROQ_MODELS preference list, first one available on your key is used
16
  (default: openai/gpt-oss-120b, llama-3.3-70b-versatile, qwen/qwen3.8-27b, openai/gpt-oss-20b)
17
  GROQ_BATCH sentence pairs per request (default 10)
@@ -25,8 +27,19 @@ import time
25
 
26
  import textparse as TP
27
 
28
- KEY = os.getenv("GROQ_API_KEY", "").strip()
29
- ENABLED = bool(KEY) and os.getenv("GROQ_ENABLED", "1") != "0"
 
 
 
 
 
 
 
 
 
 
 
30
  URL = "https://api.groq.com/openai/v1"
31
  PREF = [m.strip() for m in os.getenv(
32
  "GROQ_MODELS", "openai/gpt-oss-120b,llama-3.3-70b-versatile,qwen/qwen3.8-27b,openai/gpt-oss-20b").split(",")
@@ -34,7 +47,7 @@ PREF = [m.strip() for m in os.getenv(
34
  BATCH = max(1, int(os.getenv("GROQ_BATCH", "12")))
35
  MAX_WAIT = float(os.getenv("GROQ_MAX_WAIT", "60"))
36
 
37
- _lock = threading.Lock()
38
  _models = None # models available on this key (ordered by preference)
39
  _down_until = 0.0 # daily quota exhausted -> skip Groq until then
40
  STATS = {"calls": 0, "checked": 0, "ok": 0, "fixed": 0, "rejected": 0, "errors": 0, "tokens": 0,
@@ -45,12 +58,37 @@ class Unavailable(Exception):
45
  """Groq cannot be used right now (quota, network, bad key) — caller falls back to the local model."""
46
 
47
 
48
- def _headers():
49
- return {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
 
52
  def available():
53
- return ENABLED and time.time() >= _down_until
54
 
55
 
56
  def _model_list():
@@ -59,7 +97,7 @@ def _model_list():
59
  if _models is None:
60
  import httpx
61
  try:
62
- r = httpx.get(URL + "/models", headers=_headers(), timeout=20)
63
  if r.status_code == 401:
64
  STATS["last_error"] = "Groq key rejected (401) — check GROQ_API_KEY."
65
  raise Unavailable(STATS["last_error"])
@@ -81,7 +119,7 @@ def _drop_model(model):
81
 
82
 
83
  def status():
84
- if not KEY:
85
  return "Groq check is OFF — add the GROQ_API_KEY secret (Parrot output is checked by the local rules only)."
86
  if not ENABLED:
87
  return "Groq is disabled (GROQ_ENABLED=0)."
@@ -89,14 +127,20 @@ def status():
89
  m = (_models or PREF)[0]
90
  down = f" · quota pause until {time.strftime('%H:%M UTC', time.gmtime(_down_until))}" \
91
  if time.time() < _down_until else ""
92
- return (f"Groq quality check is ON ({m}){down}. Since restart: {s['checked']} Parrot sentences checked — "
 
 
 
 
93
  f"{s['ok']} fine as written, {s['fixed']} repaired, {s['rejected']} rejected (original kept); "
94
  f"~{s['tokens']:,} tokens."
95
  + (f" Last error: {s['last_error']}" if s["last_error"] else ""))
96
 
97
 
98
  def _chat(messages, temperature, max_tokens):
99
- """One chat completion (JSON mode). Waits on short rate limits, raises Unavailable on long ones."""
 
 
100
  global _down_until
101
  import httpx
102
  if not available():
@@ -108,9 +152,18 @@ def _chat(messages, temperature, max_tokens):
108
  "response_format": {"type": "json_object"}}
109
  if "gpt-oss" in model:
110
  body["reasoning_effort"] = "low"
111
- for attempt in range(8):
 
 
 
 
 
 
 
 
 
112
  try:
113
- r = httpx.post(URL + "/chat/completions", json=body, headers=_headers(), timeout=120)
114
  except Exception as err:
115
  STATS["errors"] += 1
116
  STATS["last_error"] = f"network: {err}"
@@ -118,26 +171,27 @@ def _chat(messages, temperature, max_tokens):
118
  raise Unavailable(str(err))
119
  time.sleep(2 * (attempt + 1))
120
  continue
 
 
121
  txt = r.text[:400]
122
  if r.status_code == 429:
123
  try:
124
  ra = float(r.headers.get("retry-after") or 5)
125
  except ValueError:
126
  ra = 5.0
127
- if "per day" in txt.lower() or ra > MAX_WAIT or waited + ra > MAX_WAIT:
128
- if "per day" in txt.lower() or ra > MAX_WAIT:
129
- _down_until = time.time() + min(max(ra, 60), 6 * 3600)
130
- STATS["last_error"] = f"rate limit: {txt[:160]}"
131
- raise Unavailable("Groq rate limit")
132
- time.sleep(ra)
133
- waited += ra
134
- continue
135
  if r.status_code == 401:
136
- STATS["last_error"] = "Groq key rejected (401) — check GROQ_API_KEY."
137
- _down_until = time.time() + 600
138
- raise Unavailable(STATS["last_error"])
 
 
 
139
  if r.status_code in (400, 404) and "model" in txt.lower() and any(
140
- k in txt.lower() for k in ("decommission", "not found", "does not exist", "not available",
141
  "no access", "deprecated")):
142
  _drop_model(model)
143
  nxt = _model_list()[0]
@@ -240,7 +294,7 @@ def _check_batch(pairs, glossary):
240
  return _parse(content, len(pairs))
241
 
242
 
243
- PARALLEL = max(1, int(os.getenv("GROQ_PARALLEL", "5")))
244
 
245
 
246
  def review(pairs, glossary, progress=None, lo=0.85, hi=0.9):
 
12
 
13
  Settings (Space → Settings → Variables and secrets):
14
  GROQ_API_KEY secret — from https://console.groq.com/keys
15
+ GROQ_API_KEY_2, GROQ_API_KEY_3 ... (up to _9) or GROQ_API_KEYS="k1,k2,k3": more keys. Requests are spread over
16
+ all keys (round robin); a key that hits its rate limit is paused and the next key is used.
17
  GROQ_MODELS preference list, first one available on your key is used
18
  (default: openai/gpt-oss-120b, llama-3.3-70b-versatile, qwen/qwen3.8-27b, openai/gpt-oss-20b)
19
  GROQ_BATCH sentence pairs per request (default 10)
 
27
 
28
  import textparse as TP
29
 
30
+ def _read_keys():
31
+ """GROQ_API_KEY, GROQ_API_KEY_2 ... GROQ_API_KEY_9 and/or GROQ_API_KEYS (comma separated), de-duplicated."""
32
+ keys = [k.strip() for k in os.getenv("GROQ_API_KEYS", "").split(",")]
33
+ keys += [os.getenv("GROQ_API_KEY", "")] + [os.getenv(f"GROQ_API_KEY_{i}", "") for i in range(2, 10)]
34
+ return list(dict.fromkeys(k.strip() for k in keys if k and k.strip()))
35
+
36
+
37
+ KEYS = _read_keys()
38
+ KEY = KEYS[0] if KEYS else ""
39
+ ENABLED = bool(KEYS) and os.getenv("GROQ_ENABLED", "1") != "0"
40
+ # per key: paused until (rate limit / daily quota / rejected key), requests sent, last error
41
+ _KEY_STATE = [{"until": 0.0, "calls": 0, "err": ""} for _ in KEYS]
42
+ _rr = [0] # round-robin position
43
  URL = "https://api.groq.com/openai/v1"
44
  PREF = [m.strip() for m in os.getenv(
45
  "GROQ_MODELS", "openai/gpt-oss-120b,llama-3.3-70b-versatile,qwen/qwen3.8-27b,openai/gpt-oss-20b").split(",")
 
47
  BATCH = max(1, int(os.getenv("GROQ_BATCH", "12")))
48
  MAX_WAIT = float(os.getenv("GROQ_MAX_WAIT", "60"))
49
 
50
+ _lock = threading.RLock()
51
  _models = None # models available on this key (ordered by preference)
52
  _down_until = 0.0 # daily quota exhausted -> skip Groq until then
53
  STATS = {"calls": 0, "checked": 0, "ok": 0, "fixed": 0, "rejected": 0, "errors": 0, "tokens": 0,
 
58
  """Groq cannot be used right now (quota, network, bad key) — caller falls back to the local model."""
59
 
60
 
61
+ def _headers(k=0):
62
+ return {"Authorization": f"Bearer {KEYS[k] if KEYS else ''}", "Content-Type": "application/json"}
63
+
64
+
65
+ def _pick_key():
66
+ """Next usable key (round robin, so the load is spread over all keys); None if all are paused."""
67
+ now = time.time()
68
+ with _lock:
69
+ n = len(KEYS)
70
+ for step in range(n):
71
+ k = (_rr[0] + step) % n
72
+ if _KEY_STATE[k]["until"] <= now:
73
+ _rr[0] = (k + 1) % n
74
+ return k
75
+ return None
76
+
77
+
78
+ def _pause_key(k, seconds, why):
79
+ with _lock:
80
+ _KEY_STATE[k]["until"] = max(_KEY_STATE[k]["until"], time.time() + seconds)
81
+ _KEY_STATE[k]["err"] = why
82
+
83
+
84
+ def _next_free_in():
85
+ """Seconds until the first paused key is usable again."""
86
+ now = time.time()
87
+ return max(0.0, min((st["until"] for st in _KEY_STATE), default=0.0) - now)
88
 
89
 
90
  def available():
91
+ return ENABLED and time.time() >= _down_until and _next_free_in() <= MAX_WAIT
92
 
93
 
94
  def _model_list():
 
97
  if _models is None:
98
  import httpx
99
  try:
100
+ r = httpx.get(URL + "/models", headers=_headers(_pick_key() or 0), timeout=20)
101
  if r.status_code == 401:
102
  STATS["last_error"] = "Groq key rejected (401) — check GROQ_API_KEY."
103
  raise Unavailable(STATS["last_error"])
 
119
 
120
 
121
  def status():
122
+ if not KEYS:
123
  return "Groq check is OFF — add the GROQ_API_KEY secret (Parrot output is checked by the local rules only)."
124
  if not ENABLED:
125
  return "Groq is disabled (GROQ_ENABLED=0)."
 
127
  m = (_models or PREF)[0]
128
  down = f" · quota pause until {time.strftime('%H:%M UTC', time.gmtime(_down_until))}" \
129
  if time.time() < _down_until else ""
130
+ now = time.time()
131
+ keys = " · ".join(f"key {i + 1}: {st['calls']} calls" + (f", paused {int(st['until'] - now)}s ({st['err']})"
132
+ if st["until"] > now else "")
133
+ for i, st in enumerate(_KEY_STATE))
134
+ return (f"Groq quality check is ON ({m}), {len(KEYS)} key(s) [{keys}]{down}. Since restart: {s['checked']} Parrot sentences checked �� "
135
  f"{s['ok']} fine as written, {s['fixed']} repaired, {s['rejected']} rejected (original kept); "
136
  f"~{s['tokens']:,} tokens."
137
  + (f" Last error: {s['last_error']}" if s["last_error"] else ""))
138
 
139
 
140
  def _chat(messages, temperature, max_tokens):
141
+ """One chat completion (JSON mode). With several keys, a rate-limited key is paused and the request moves to
142
+ the next key at once; it waits only when every key is paused (short limits), and raises Unavailable when all
143
+ keys are out for longer than GROQ_MAX_WAIT."""
144
  global _down_until
145
  import httpx
146
  if not available():
 
152
  "response_format": {"type": "json_object"}}
153
  if "gpt-oss" in model:
154
  body["reasoning_effort"] = "low"
155
+ for attempt in range(8 + 2 * len(KEYS)):
156
+ k = _pick_key()
157
+ if k is None: # every key is paused: wait for the first one, if short
158
+ wait = _next_free_in()
159
+ if wait > MAX_WAIT or waited + wait > MAX_WAIT:
160
+ STATS["last_error"] = "rate limit on all Groq keys"
161
+ raise Unavailable("Groq rate limit (all keys)")
162
+ time.sleep(wait + 0.2)
163
+ waited += wait
164
+ continue
165
  try:
166
+ r = httpx.post(URL + "/chat/completions", json=body, headers=_headers(k), timeout=120)
167
  except Exception as err:
168
  STATS["errors"] += 1
169
  STATS["last_error"] = f"network: {err}"
 
171
  raise Unavailable(str(err))
172
  time.sleep(2 * (attempt + 1))
173
  continue
174
+ with _lock:
175
+ _KEY_STATE[k]["calls"] += 1
176
  txt = r.text[:400]
177
  if r.status_code == 429:
178
  try:
179
  ra = float(r.headers.get("retry-after") or 5)
180
  except ValueError:
181
  ra = 5.0
182
+ daily = "per day" in txt.lower()
183
+ _pause_key(k, min(max(ra, 60), 6 * 3600) if daily else ra, "daily quota" if daily else "rate limit")
184
+ STATS["last_error"] = f"rate limit (key {k + 1}): {txt[:160]}"
185
+ continue # next key at once
 
 
 
 
186
  if r.status_code == 401:
187
+ _pause_key(k, 3600, "key rejected (401)")
188
+ STATS["last_error"] = f"Groq key {k + 1} rejected (401) — check that secret."
189
+ if all(st["err"].startswith("key rejected") for st in _KEY_STATE):
190
+ _down_until = time.time() + 600
191
+ raise Unavailable(STATS["last_error"])
192
+ continue
193
  if r.status_code in (400, 404) and "model" in txt.lower() and any(
194
+ w in txt.lower() for w in ("decommission", "not found", "does not exist", "not available",
195
  "no access", "deprecated")):
196
  _drop_model(model)
197
  nxt = _model_list()[0]
 
294
  return _parse(content, len(pairs))
295
 
296
 
297
+ PARALLEL = max(1, int(os.getenv("GROQ_PARALLEL", str(min(12, 4 * max(1, len(KEYS)))))))
298
 
299
 
300
  def review(pairs, glossary, progress=None, lo=0.85, hi=0.9):
humanizer.py CHANGED
@@ -509,6 +509,7 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
509
  todo = list(range(len(sent_map)))
510
  options, first_raw = [[] for _ in sent_map], [s for s, _, _ in sent_map]
511
  extra_rounds = meaning_dropped = 0
 
512
  for r in range(rounds):
513
  if not todo:
514
  break
@@ -520,17 +521,30 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
520
  extra_rounds += r > 0
521
  for si in todo:
522
  options[si], first_raw[si] = build(si)
523
- # meaning check: drop versions whose meaning drifted from the original sentence
524
  pairs = [(si, o) for si in todo for o in options[si]]
525
- keep = MEANING.keeps_meaning([(sent_map[si][0], o) for si, o in pairs])
526
- drift = {(si, o) for (si, o), k in zip(pairs, keep) if not k}
527
- # fluency check: drop versions with missing / doubled words or broken structure
528
- ok_fl = FL.acceptable([(sent_map[si][0], o) for si, o in pairs])
529
- drift |= {(si, o) for (si, o), k in zip(pairs, ok_fl) if not k}
530
- if drift:
531
- meaning_dropped += len(drift)
 
 
 
532
  for si in todo:
533
- options[si] = [o for o in options[si] if (si, o) not in drift]
 
 
 
 
 
 
 
 
 
 
534
  if not target:
535
  break
536
  todo = [si for si in todo
@@ -638,12 +652,18 @@ def _paraphrase_local(texts, temperature, progress=None, lo=0.15, hi=0.85, mode=
638
 
639
  # final meaning check of what will actually be used (Groq rescues / repairs included)
640
  final = [si for si in range(len(sent_map)) if chosen[si] and chosen[si].strip() != sent_map[si][0].strip()]
641
- for si, k_ in zip(final, MEANING.keeps_meaning([(sent_map[si][0], chosen[si]) for si in final])):
 
642
  if not k_:
643
  meaning_dropped += 1
644
  chosen[si] = next((o for o in ranked[si] if o != chosen[si]), None) # already meaning-checked
645
- if meaning_dropped:
646
- print(f"[humanizer] meaning check rejected {meaning_dropped} version(s)", flush=True)
 
 
 
 
 
647
 
648
  sentences = [chosen[si] or s for si, (s, _, _) in enumerate(sent_map)]
649
  changed = sum(1 for si, (s, _, _) in enumerate(sent_map) if sentences[si].strip() != s.strip())
 
509
  todo = list(range(len(sent_map)))
510
  options, first_raw = [[] for _ in sent_map], [s for s, _, _ in sent_map]
511
  extra_rounds = meaning_dropped = 0
512
+ qstat = dict(n=0, meaning=0, fluency=0, second=0, sims=[])
513
  for r in range(rounds):
514
  if not todo:
515
  break
 
521
  extra_rounds += r > 0
522
  for si in todo:
523
  options[si], first_raw[si] = build(si)
524
+ # quality checks of every version: meaning (vs the original sentence) and fluency (well-formed English)
525
  pairs = [(si, o) for si in todo for o in options[si]]
526
+ if pairs:
527
+ sims = MEANING.similarities([(sent_map[si][0], o) for si, o in pairs])
528
+ flu = FL.acceptable([(sent_map[si][0], o) for si, o in pairs])
529
+ keep_ = {}
530
+ for (si, o), sm, fl in zip(pairs, sims, flu):
531
+ keep_[(si, o)] = (sm >= MEANING.MIN_SIM and fl, sm, fl)
532
+ qstat["n"] += 1
533
+ qstat["meaning"] += sm < MEANING.MIN_SIM
534
+ qstat["fluency"] += not fl
535
+ qstat["sims"].append(sm)
536
  for si in todo:
537
+ good = [o for o in options[si] if keep_[(si, o)][0]]
538
+ if not good and options[si]:
539
+ # second chance: no version passed both checks -> keep the closest-meaning version that is
540
+ # fluent English and only slightly below the meaning threshold
541
+ soft = [(keep_[(si, o)][1], o) for o in options[si]
542
+ if keep_[(si, o)][2] and keep_[(si, o)][1] >= MEANING.SOFT_SIM]
543
+ if soft:
544
+ good = [max(soft)[1]]
545
+ qstat["second"] += 1
546
+ meaning_dropped += len(options[si]) - len(good)
547
+ options[si] = good
548
  if not target:
549
  break
550
  todo = [si for si in todo
 
652
 
653
  # final meaning check of what will actually be used (Groq rescues / repairs included)
654
  final = [si for si in range(len(sent_map)) if chosen[si] and chosen[si].strip() != sent_map[si][0].strip()]
655
+ for si, k_ in zip(final, MEANING.keeps_meaning([(sent_map[si][0], chosen[si]) for si in final],
656
+ min_sim=MEANING.SOFT_SIM)):
657
  if not k_:
658
  meaning_dropped += 1
659
  chosen[si] = next((o for o in ranked[si] if o != chosen[si]), None) # already meaning-checked
660
+ if qstat["n"]:
661
+ import statistics as _st
662
+ med = _st.median(qstat["sims"]) if qstat["sims"] else 0
663
+ print(f"[humanizer] quality checks: {qstat['n']} versions | below meaning {MEANING.MIN_SIM:.2f}: "
664
+ f"{qstat['meaning']} (median similarity {med:.2f}) | not fluent: {qstat['fluency']} | "
665
+ f"rejected in total: {meaning_dropped} | sentences saved by second chance: {qstat['second']}",
666
+ flush=True)
667
 
668
  sentences = [chosen[si] or s for si, (s, _, _) in enumerate(sent_map)]
669
  changed = sum(1 for si, (s, _, _) in enumerate(sent_map) if sentences[si].strip() != s.strip())
meaning.py CHANGED
@@ -12,6 +12,8 @@ message is printed once, so the app keeps working.
12
  Space variables:
13
  MEANING_MODEL default sentence-transformers/all-MiniLM-L6-v2
14
  MEANING_MIN default 0.80 (raise to be stricter, lower if too many good rewrites are rejected)
 
 
15
  MEANING_CHECK default 1 (0 turns the check off)
16
  """
17
  import os
@@ -19,6 +21,7 @@ import threading
19
 
20
  MODEL_ID = os.environ.get("MEANING_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
21
  MIN_SIM = float(os.environ.get("MEANING_MIN", "0.80"))
 
22
  ENABLED = os.environ.get("MEANING_CHECK", "1") != "0"
23
  BATCH = 64
24
 
 
12
  Space variables:
13
  MEANING_MODEL default sentence-transformers/all-MiniLM-L6-v2
14
  MEANING_MIN default 0.80 (raise to be stricter, lower if too many good rewrites are rejected)
15
+ MEANING_SOFT default 0.72 a sentence with no version above MEANING_MIN may keep its closest fluent version
16
+ if it reaches this similarity
17
  MEANING_CHECK default 1 (0 turns the check off)
18
  """
19
  import os
 
21
 
22
  MODEL_ID = os.environ.get("MEANING_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
23
  MIN_SIM = float(os.environ.get("MEANING_MIN", "0.80"))
24
+ SOFT_SIM = float(os.environ.get("MEANING_SOFT", "0.72")) # second chance when no version reaches MIN_SIM
25
  ENABLED = os.environ.get("MEANING_CHECK", "1") != "0"
26
  BATCH = 64
27