Dama12 commited on
Commit
5fad32f
·
1 Parent(s): d55c3ba

fix: make K2 Think JSON extraction and repair extremely robust

Browse files
Files changed (1) hide show
  1. app/services/k2_think_engine.py +137 -99
app/services/k2_think_engine.py CHANGED
@@ -181,135 +181,173 @@ DO NOT USE <think> TAGS. DO NOT CONVERSE.
181
  elif "<think>" in processed_content:
182
  processed_content = re.sub(r'<think>.*', '', processed_content, flags=re.DOTALL)
183
 
184
- candidates = []
185
-
186
- # Stratégie 0 : Merge ALL Markdown blocks (Highest Priority)
187
- # If the LLM splits its JSON into multiple blocks, we want all of them.
188
- json_blocks = re.findall(r'```(?:json)?\s*(\{.*?\})\s*```', processed_content, re.DOTALL | re.IGNORECASE)
189
- if json_blocks:
190
- # We will try to parse and merge them later
191
- pass
192
-
193
- # Stratégie Prioritaire : Le plus grand bloc d'accolades (souvent le plus fiable si </think> est bien géré)
194
- start_idx = processed_content.find('{')
195
- end_idx = processed_content.rfind('}')
196
- if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
197
- candidates.append(processed_content[start_idx:end_idx + 1])
198
-
199
- # Stratégie 1 : Chercher le vrai début du JSON (via comparative_analysis) - FIRST occurrence
200
- first_idx = processed_content.find('"comparative_analysis"')
201
- if first_idx != -1:
202
- start_idx = processed_content.rfind('{', 0, first_idx)
203
- if start_idx != -1:
204
- cand = processed_content[start_idx:]
205
- end_idx = cand.rfind('}')
206
- if end_idx != -1:
207
- candidates.append(cand[:end_idx + 1])
208
- else:
209
- candidates.append(cand) # Truncated
210
-
211
- # Stratégie 2 : Blocs Markdown existants
212
- if json_blocks:
213
- candidates.append(json_blocks[-1].strip())
214
- if len(json_blocks) > 1:
215
- candidates.append(json_blocks[0].strip())
216
-
217
- # Stratégie 3 : Balise [RESULT]
218
- result_match = re.search(r'\[RESULT\]\s*(.*)', processed_content, re.DOTALL | re.IGNORECASE)
219
- if result_match:
220
- cand = result_match.group(1).strip()
221
- json_inner = re.search(r'(\{.*\})', cand, re.DOTALL)
222
- if json_inner:
223
- candidates.append(json_inner.group(1).strip())
224
-
225
  # Fonction utilitaire pour réparer le JSON
226
  def repair_json(text):
227
  repaired = text.strip()
228
 
229
- # Heuristic: if odd number of quotes, we are inside an unclosed string. Remove it.
230
- if repaired.count('"') % 2 != 0:
231
- last_quote = repaired.rfind('"')
232
- if last_quote != -1:
233
- repaired = repaired[:last_quote].strip()
234
 
235
- # Strip trailing commas or colons before closing
236
- repaired = repaired.rstrip(',:').strip()
 
 
 
 
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  open_braces = repaired.count('{')
239
  close_braces = repaired.count('}')
240
  open_brackets = repaired.count('[')
241
  close_brackets = repaired.count(']')
 
242
  while open_brackets > close_brackets:
243
  repaired += ']'
244
  close_brackets += 1
245
  while open_braces > close_braces:
246
  repaired += '}'
247
  close_braces += 1
 
248
  return repaired
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  # 5. Parsing
251
  import pathlib
252
  debug_dir = pathlib.Path(__file__).parent.parent.parent
253
  with open(debug_dir / "k2_debug_raw.txt", "w", encoding="utf-8") as f:
254
  f.write(raw_content)
 
 
 
 
 
 
 
255
 
256
- # FIRST: Try merging all markdown blocks if multiple exist
257
- if json_blocks:
258
- merged_dict = {}
259
- for block in json_blocks:
260
- try:
261
- parsed = json.loads(block.strip())
262
- if isinstance(parsed, dict):
263
- merged_dict.update(parsed)
264
- except:
265
  try:
266
- parsed = json.loads(repair_json(block))
267
- if isinstance(parsed, dict):
268
- merged_dict.update(parsed)
269
- except:
270
  pass
271
- # Check if merge yielded a decent result
272
- if merged_dict and ("comparative_analysis" in merged_dict or "research_gaps" in merged_dict):
273
- k2_analysis = merged_dict
274
- clean_json = json.dumps(merged_dict)
275
- logger.info("JSON successfully merged from multiple Markdown blocks.")
276
-
277
- if not k2_analysis:
278
- for i, cand in enumerate(candidates):
279
- if not cand.strip():
280
- continue
281
- with open(debug_dir / f"k2_debug_cand_{i}.txt", "w", encoding="utf-8") as f:
282
- f.write(cand)
283
-
284
- # Tentative directe
285
- try:
286
- k2_analysis = json.loads(cand)
287
- clean_json = cand
288
- break
289
- except json.JSONDecodeError as e:
290
- # Troncature si "Extra data"
291
- if "Extra data" in str(e) and hasattr(e, "pos"):
292
- try:
293
- k2_analysis = json.loads(cand[:e.pos].strip())
294
- clean_json = cand[:e.pos].strip()
295
- break
296
- except Exception:
297
- pass
298
-
299
- # Tentative avec réparation
300
- repaired_cand = repair_json(cand)
301
  try:
302
- k2_analysis = json.loads(repaired_cand)
303
  clean_json = repaired_cand
304
  break
305
  except Exception:
306
- # Tentative avec ast.literal_eval pour Python dicts
307
- try:
308
- k2_analysis = ast.literal_eval(repaired_cand)
309
- clean_json = repaired_cand
310
- break
311
- except Exception:
312
- pass
313
 
314
  if k2_analysis:
315
  logger.info("JSON successfully extracted and parsed.")
 
181
  elif "<think>" in processed_content:
182
  processed_content = re.sub(r'<think>.*', '', processed_content, flags=re.DOTALL)
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  # Fonction utilitaire pour réparer le JSON
185
  def repair_json(text):
186
  repaired = text.strip()
187
 
188
+ # If it doesn't start with '{', find the first '{'
189
+ first_brace = repaired.find('{')
190
+ if first_brace != -1:
191
+ repaired = repaired[first_brace:]
 
192
 
193
+ if not repaired:
194
+ return repaired
195
+
196
+ # Fix unclosed quotes
197
+ in_string = False
198
+ escape = False
199
+ last_good_pos = len(repaired)
200
 
201
+ for i, char in enumerate(repaired):
202
+ if escape:
203
+ escape = False
204
+ continue
205
+ if char == '\\':
206
+ escape = True
207
+ continue
208
+ if char == '"':
209
+ in_string = not in_string
210
+ if not in_string:
211
+ last_good_pos = i + 1
212
+
213
+ if in_string:
214
+ repaired = repaired[:last_good_pos].strip()
215
+
216
+ # Remove trailing incomplete keys
217
+ # e.g., ',"key"' or ',"key":'
218
+ for suffix in ['', '}', ']']:
219
+ escaped_suffix = re.escape(suffix)
220
+ repaired = re.sub(r',\s*"[^"]*"\s*:\s*' + escaped_suffix + r'$', suffix, repaired)
221
+ repaired = re.sub(r',\s*"[^"]*"\s*' + escaped_suffix + r'$', suffix, repaired)
222
+ repaired = re.sub(r'\{\s*"[^"]*"\s*:\s*' + escaped_suffix + r'$', '{' + suffix, repaired)
223
+ repaired = re.sub(r'\{\s*"[^"]*"\s*' + escaped_suffix + r'$', '{' + suffix, repaired)
224
+
225
+ # Strip trailing punctuation/spaces
226
+ repaired = re.sub(r'[\s,:+]+$', '', repaired)
227
+
228
+ # Balance braces and brackets
229
  open_braces = repaired.count('{')
230
  close_braces = repaired.count('}')
231
  open_brackets = repaired.count('[')
232
  close_brackets = repaired.count(']')
233
+
234
  while open_brackets > close_brackets:
235
  repaired += ']'
236
  close_brackets += 1
237
  while open_braces > close_braces:
238
  repaired += '}'
239
  close_braces += 1
240
+
241
  return repaired
242
 
243
+ candidates = []
244
+
245
+ # Stratégie 0 : Extraction par blocs markdown
246
+ for block in re.findall(r'```(?:json)?\s*(.*?)\s*```', processed_content, re.DOTALL | re.IGNORECASE):
247
+ b_start = block.find('{')
248
+ b_end = block.rfind('}')
249
+ if b_start != -1 and b_end != -1 and b_end > b_start:
250
+ candidates.append(block[b_start:b_end + 1])
251
+ else:
252
+ candidates.append(block)
253
+
254
+ # Stratégie 1 : Recherche par comptage de parenthèses (très robuste)
255
+ positions = [m.start() for m in re.finditer(r'\{', processed_content)]
256
+ for start_idx in positions:
257
+ brace_count = 0
258
+ in_string = False
259
+ escape = False
260
+ end_idx = -1
261
+ for i in range(start_idx, len(processed_content)):
262
+ char = processed_content[i]
263
+ if escape:
264
+ escape = False
265
+ continue
266
+ if char == '\\':
267
+ escape = True
268
+ continue
269
+ if char == '"':
270
+ in_string = not in_string
271
+ continue
272
+ if not in_string:
273
+ if char == '{':
274
+ brace_count += 1
275
+ elif char == '}':
276
+ brace_count -= 1
277
+ if brace_count == 0:
278
+ end_idx = i
279
+ break
280
+ if end_idx != -1:
281
+ candidates.append(processed_content[start_idx:end_idx + 1])
282
+ else:
283
+ candidates.append(processed_content[start_idx:]) # Truncated fallback
284
+
285
+ # Stratégie 2 : Blocs d'accolades globaux
286
+ start_idx = processed_content.find('{')
287
+ end_idx = processed_content.rfind('}')
288
+ if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
289
+ candidates.append(processed_content[start_idx:end_idx + 1])
290
+
291
+ # Stratégie 3 : Recherche de "comparative_analysis"
292
+ first_idx = processed_content.find('"comparative_analysis"')
293
+ if first_idx != -1:
294
+ start_idx = processed_content.rfind('{', 0, first_idx)
295
+ if start_idx != -1:
296
+ candidates.append(processed_content[start_idx:])
297
+
298
+ # Prioritize candidates containing key schema terms
299
+ schema_keywords = ["comparative_analysis", "proposed_protocol", "reasoning_summary", "research_gaps"]
300
+
301
+ # Sort candidates by:
302
+ # 1. Matches at least one keyword (Boolean)
303
+ # 2. Length (longer is better for completeness)
304
+ def candidate_key(c):
305
+ has_keyword = any(kw in c for kw in schema_keywords)
306
+ return (1 if has_keyword else 0, len(c))
307
+
308
+ candidates.sort(key=candidate_key, reverse=True)
309
+
310
  # 5. Parsing
311
  import pathlib
312
  debug_dir = pathlib.Path(__file__).parent.parent.parent
313
  with open(debug_dir / "k2_debug_raw.txt", "w", encoding="utf-8") as f:
314
  f.write(raw_content)
315
+
316
+ # Try parsing each candidate
317
+ for i, cand in enumerate(candidates):
318
+ if not cand.strip():
319
+ continue
320
+ with open(debug_dir / f"k2_debug_cand_{i}.txt", "w", encoding="utf-8") as f:
321
+ f.write(cand)
322
 
323
+ # 5.1 Direct parse
324
+ try:
325
+ k2_analysis = json.loads(cand)
326
+ clean_json = cand
327
+ break
328
+ except json.JSONDecodeError as e:
329
+ if "Extra data" in str(e) and hasattr(e, "pos"):
 
 
330
  try:
331
+ k2_analysis = json.loads(cand[:e.pos].strip())
332
+ clean_json = cand[:e.pos].strip()
333
+ break
334
+ except Exception:
335
  pass
336
+
337
+ # 5.2 Parse with repair
338
+ repaired_cand = repair_json(cand)
339
+ try:
340
+ k2_analysis = json.loads(repaired_cand)
341
+ clean_json = repaired_cand
342
+ break
343
+ except Exception:
344
+ # 5.3 Parse python literal dict fallback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  try:
346
+ k2_analysis = ast.literal_eval(repaired_cand)
347
  clean_json = repaired_cand
348
  break
349
  except Exception:
350
+ pass
 
 
 
 
 
 
351
 
352
  if k2_analysis:
353
  logger.info("JSON successfully extracted and parsed.")