optiquant Claude Opus 4.8 (1M context) commited on
Commit
f92e318
·
1 Parent(s): 5740dad

Fix JSON coercion: every LLM call returned empty (ported prefill broke)

Browse files

The 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>

Files changed (1) hide show
  1. llm.py +71 -21
llm.py CHANGED
@@ -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 with a JSON prefill. `user_content` is a string or an
46
- OpenAI content-parts list (for images). Returns the raw assistant text with
47
- the prefilled '{' restored."""
 
 
 
 
 
 
 
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
- text = r.json()["choices"][0]["message"]["content"]
63
- return "{" + text
64
 
65
 
66
- def _extract_json(text: str) -> dict:
67
- """Tolerantly pull the first balanced JSON object out of a model response,
68
- ignoring markdown fences and any trailing prose."""
69
- text = text.strip()
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
- try:
95
- return json.loads(text[start:i + 1])
96
- except json.JSONDecodeError:
97
- return {}
98
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ---------------------------------------------------------------