Spaces:
Runtime error
Fix JSON coercion: every LLM call returned empty (ported prefill broke)
Browse filesThe cloud app prefills the assistant turn with '{' and lets Claude continue
it; the Anthropic API honors that natively. llama.cpp's chat endpoint does
not continue a trailing assistant turn — it generates a fresh complete object
starting with '{', so prepending '{' produced a doubled brace ('{{ ... }').
_extract_json never reached depth 0 and returned {}, so parse/extract/score
all yielded zero items: "Nothing actionable found" on every input, and a
silent no-op on "Re-suggest with AI".
- Drop the prefill; use llama.cpp response_format json_object, which forces a
single valid JSON object as a sampling grammar (no prose, no fences, no
unbalanced output) — the reliable equivalent for a local 3B model.
- Make _extract_json recover complete items from a truncated array so one
cut-off task can't void the whole batch (also defends against the old
double-brace shape). Verified against clean/double-brace/truncated/prose.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@@ -42,13 +42,19 @@ def is_ready() -> bool:
|
|
| 42 |
|
| 43 |
|
| 44 |
def _chat(system: str, user_content, max_tokens: int) -> str:
|
| 45 |
-
"""One chat completion
|
| 46 |
-
OpenAI content-parts list (for images).
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
messages = [
|
| 49 |
{"role": "system", "content": system},
|
| 50 |
{"role": "user", "content": user_content},
|
| 51 |
-
{"role": "assistant", "content": "{"}, # prefill: coerce a JSON object
|
| 52 |
]
|
| 53 |
payload = {
|
| 54 |
"messages": messages,
|
|
@@ -56,23 +62,17 @@ def _chat(system: str, user_content, max_tokens: int) -> str:
|
|
| 56 |
"temperature": 0.2,
|
| 57 |
"top_p": 0.9,
|
| 58 |
"stream": False,
|
|
|
|
| 59 |
}
|
| 60 |
r = requests.post(CHAT_URL, json=payload, timeout=REQUEST_TIMEOUT)
|
| 61 |
r.raise_for_status()
|
| 62 |
-
|
| 63 |
-
return "{" + text
|
| 64 |
|
| 65 |
|
| 66 |
-
def
|
| 67 |
-
"""
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
if text.startswith("```"):
|
| 71 |
-
text = text.split("```", 2)[1] if "```" in text[3:] else text
|
| 72 |
-
text = text.lstrip("json").lstrip()
|
| 73 |
-
start = text.find("{")
|
| 74 |
-
if start == -1:
|
| 75 |
-
return {}
|
| 76 |
depth, in_str, esc = 0, False, False
|
| 77 |
for i in range(start, len(text)):
|
| 78 |
ch = text[i]
|
|
@@ -91,11 +91,61 @@ def _extract_json(text: str) -> dict:
|
|
| 91 |
elif ch == "}":
|
| 92 |
depth -= 1
|
| 93 |
if depth == 0:
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
# -- validation ---------------------------------------------------------------
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
def _chat(system: str, user_content, max_tokens: int) -> str:
|
| 45 |
+
"""One chat completion that is constrained to return a single JSON object.
|
| 46 |
+
`user_content` is a string or an OpenAI content-parts list (for images).
|
| 47 |
+
|
| 48 |
+
Note on JSON coercion: the cloud app (what-first.com) prefills the assistant
|
| 49 |
+
turn with '{' and lets Claude continue it. llama.cpp's chat endpoint does NOT
|
| 50 |
+
continue a trailing assistant turn — it generates a fresh, complete object —
|
| 51 |
+
so that prefill produced a doubled leading brace and every parse failed.
|
| 52 |
+
Instead we use llama.cpp's `response_format: json_object`, which applies a
|
| 53 |
+
grammar at sampling time: the model literally cannot emit prose, markdown
|
| 54 |
+
fences, or an unbalanced object."""
|
| 55 |
messages = [
|
| 56 |
{"role": "system", "content": system},
|
| 57 |
{"role": "user", "content": user_content},
|
|
|
|
| 58 |
]
|
| 59 |
payload = {
|
| 60 |
"messages": messages,
|
|
|
|
| 62 |
"temperature": 0.2,
|
| 63 |
"top_p": 0.9,
|
| 64 |
"stream": False,
|
| 65 |
+
"response_format": {"type": "json_object"},
|
| 66 |
}
|
| 67 |
r = requests.post(CHAT_URL, json=payload, timeout=REQUEST_TIMEOUT)
|
| 68 |
r.raise_for_status()
|
| 69 |
+
return r.json()["choices"][0]["message"]["content"]
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
+
def _read_balanced(text: str, start: int) -> tuple[str | None, int]:
|
| 73 |
+
"""Read one string-aware balanced {...} starting at text[start] == '{'.
|
| 74 |
+
Returns (substring, end_index_exclusive), or (None, len) if it never closes
|
| 75 |
+
(the object was truncated, e.g. the model hit max_tokens mid-write)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
depth, in_str, esc = 0, False, False
|
| 77 |
for i in range(start, len(text)):
|
| 78 |
ch = text[i]
|
|
|
|
| 91 |
elif ch == "}":
|
| 92 |
depth -= 1
|
| 93 |
if depth == 0:
|
| 94 |
+
return text[start:i + 1], i + 1
|
| 95 |
+
return None, len(text)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _recover_items(text: str) -> list:
|
| 99 |
+
"""Salvage every complete object inside an `"items": [ ... ]` array when the
|
| 100 |
+
whole response can't be parsed (truncated final item, stray trailing token).
|
| 101 |
+
A single cut-off task shouldn't void an otherwise-good batch."""
|
| 102 |
+
key = text.find('"items"')
|
| 103 |
+
if key == -1:
|
| 104 |
+
return []
|
| 105 |
+
i = text.find("[", key)
|
| 106 |
+
if i == -1:
|
| 107 |
+
return []
|
| 108 |
+
items: list = []
|
| 109 |
+
n = len(text)
|
| 110 |
+
while i < n:
|
| 111 |
+
if text[i] == "]":
|
| 112 |
+
break
|
| 113 |
+
if text[i] == "{":
|
| 114 |
+
obj, end = _read_balanced(text, i)
|
| 115 |
+
if obj is None:
|
| 116 |
+
break # truncated final object — stop, keep what we have
|
| 117 |
+
try:
|
| 118 |
+
items.append(json.loads(obj))
|
| 119 |
+
except json.JSONDecodeError:
|
| 120 |
+
pass
|
| 121 |
+
i = end
|
| 122 |
+
else:
|
| 123 |
+
i += 1
|
| 124 |
+
return items
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _extract_json(text: str) -> dict:
|
| 128 |
+
"""Parse the model's JSON object. Output is grammar-constrained to a clean
|
| 129 |
+
object, so the fast path almost always wins; the fallbacks only matter if a
|
| 130 |
+
response is truncated at max_tokens."""
|
| 131 |
+
text = text.strip()
|
| 132 |
+
try:
|
| 133 |
+
obj = json.loads(text)
|
| 134 |
+
return obj if isinstance(obj, dict) else {}
|
| 135 |
+
except json.JSONDecodeError:
|
| 136 |
+
pass
|
| 137 |
+
start = text.find("{")
|
| 138 |
+
if start != -1:
|
| 139 |
+
obj_str, _ = _read_balanced(text, start)
|
| 140 |
+
if obj_str is not None:
|
| 141 |
+
try:
|
| 142 |
+
parsed = json.loads(obj_str)
|
| 143 |
+
if isinstance(parsed, dict):
|
| 144 |
+
return parsed
|
| 145 |
+
except json.JSONDecodeError:
|
| 146 |
+
pass
|
| 147 |
+
# Truncated mid-array: recover whatever complete items survived.
|
| 148 |
+
return {"items": _recover_items(text)}
|
| 149 |
|
| 150 |
|
| 151 |
# -- validation ---------------------------------------------------------------
|