zhiyi commited on
Commit
b2e433d
·
1 Parent(s): eef685d

Recover from malformed structured model output

Browse files
Files changed (2) hide show
  1. app.py +38 -14
  2. tests/test_app.py +11 -1
app.py CHANGED
@@ -71,25 +71,25 @@ SectionLayout = Literal[
71
 
72
  class SectionPlan(BaseModel):
73
  id: str = Field(min_length=2)
74
- title: str = Field(min_length=2)
75
- purpose: str = Field(min_length=10)
76
  layout: SectionLayout
77
  source_ids: list[str] = Field(default_factory=list)
78
 
79
 
80
  class ReportOutline(BaseModel):
81
- title: str = Field(min_length=2)
82
- subtitle: str = Field(min_length=2)
83
- research_type: str = Field(min_length=3)
84
  executive_summary: list[str] = Field(min_length=1, max_length=6)
85
  section_plan: list[SectionPlan] = Field(min_length=3, max_length=12)
86
 
87
 
88
  class ReportSection(BaseModel):
89
  id: str = Field(min_length=2)
90
- title: str = Field(min_length=2)
91
- purpose: str = Field(min_length=10)
92
- takeaway: str = Field(min_length=10)
93
  layout: SectionLayout
94
  content: dict[str, Any]
95
  source_ids: list[str] = Field(default_factory=list)
@@ -125,9 +125,9 @@ class SectionsEnvelope(BaseModel):
125
 
126
 
127
  class ResearchReport(BaseModel):
128
- title: str = Field(min_length=2)
129
- subtitle: str = Field(min_length=2)
130
- research_type: str = Field(min_length=3)
131
  executive_summary: list[str] = Field(min_length=1, max_length=6)
132
  sections: list[ReportSection] = Field(min_length=3, max_length=12)
133
  references: list[str] = Field(default_factory=list)
@@ -362,12 +362,14 @@ def generate_text(system: str, prompt: str, max_tokens: int, structured: bool =
362
  return str(content).strip()
363
 
364
 
365
- def chat_json(system: str, prompt: str, max_tokens: int = 6000) -> dict[str, Any]:
366
- content = generate_text(system, prompt, max_tokens)
367
  candidates = [content]
368
  fenced = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.IGNORECASE)
369
  if fenced != content:
370
  candidates.append(fenced)
 
 
 
371
  match = re.search(r"\{[\s\S]*\}", content)
372
  if match:
373
  candidates.append(match.group(0))
@@ -378,7 +380,29 @@ def chat_json(system: str, prompt: str, max_tokens: int = 6000) -> dict[str, Any
378
  return parsed
379
  except (json.JSONDecodeError, TypeError):
380
  continue
381
- preview = content[:240].replace("\n", " ") if content else "<empty response>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  raise RuntimeError(
383
  f"{MODEL_ID} did not return a JSON object. Provider={HF_PROVIDER}. Response preview: {preview}"
384
  )
 
71
 
72
  class SectionPlan(BaseModel):
73
  id: str = Field(min_length=2)
74
+ title: str = Field(min_length=2, max_length=120)
75
+ purpose: str = Field(min_length=10, max_length=500)
76
  layout: SectionLayout
77
  source_ids: list[str] = Field(default_factory=list)
78
 
79
 
80
  class ReportOutline(BaseModel):
81
+ title: str = Field(min_length=2, max_length=160)
82
+ subtitle: str = Field(min_length=2, max_length=240)
83
+ research_type: str = Field(min_length=3, max_length=80)
84
  executive_summary: list[str] = Field(min_length=1, max_length=6)
85
  section_plan: list[SectionPlan] = Field(min_length=3, max_length=12)
86
 
87
 
88
  class ReportSection(BaseModel):
89
  id: str = Field(min_length=2)
90
+ title: str = Field(min_length=2, max_length=120)
91
+ purpose: str = Field(min_length=10, max_length=500)
92
+ takeaway: str = Field(min_length=10, max_length=800)
93
  layout: SectionLayout
94
  content: dict[str, Any]
95
  source_ids: list[str] = Field(default_factory=list)
 
125
 
126
 
127
  class ResearchReport(BaseModel):
128
+ title: str = Field(min_length=2, max_length=160)
129
+ subtitle: str = Field(min_length=2, max_length=240)
130
+ research_type: str = Field(min_length=3, max_length=80)
131
  executive_summary: list[str] = Field(min_length=1, max_length=6)
132
  sections: list[ReportSection] = Field(min_length=3, max_length=12)
133
  references: list[str] = Field(default_factory=list)
 
362
  return str(content).strip()
363
 
364
 
365
+ def parse_json_object(content: str) -> dict[str, Any] | None:
 
366
  candidates = [content]
367
  fenced = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.IGNORECASE)
368
  if fenced != content:
369
  candidates.append(fenced)
370
+ line_continuations_removed = re.sub(r"\\\r?\n", "", fenced)
371
+ if line_continuations_removed != fenced:
372
+ candidates.append(line_continuations_removed)
373
  match = re.search(r"\{[\s\S]*\}", content)
374
  if match:
375
  candidates.append(match.group(0))
 
380
  return parsed
381
  except (json.JSONDecodeError, TypeError):
382
  continue
383
+ return None
384
+
385
+
386
+ def chat_json(system: str, prompt: str, max_tokens: int = 6000) -> dict[str, Any]:
387
+ last_content = ""
388
+ for attempt in range(2):
389
+ retry_rules = ""
390
+ retry_prompt = prompt
391
+ if attempt:
392
+ retry_rules = (
393
+ " The previous response was malformed or degenerated. Regenerate from scratch as compact valid JSON."
394
+ " Never put literal or escaped line breaks in string values. Do not repeat whitespace or characters."
395
+ " Keep titles under 120 characters and every other string under 500 characters. Close every array and object."
396
+ )
397
+ retry_prompt = (
398
+ f"{prompt}\n\nThe previous response could not be parsed. Its beginning was: "
399
+ f"{last_content[:320]!r}\nRegenerate the requested object from scratch."
400
+ )
401
+ last_content = generate_text(system + retry_rules, retry_prompt, max_tokens)
402
+ parsed = parse_json_object(last_content)
403
+ if parsed is not None:
404
+ return parsed
405
+ preview = last_content[:240].replace("\n", " ") if last_content else "<empty response>"
406
  raise RuntimeError(
407
  f"{MODEL_ID} did not return a JSON object. Provider={HF_PROVIDER}. Response preview: {preview}"
408
  )
tests/test_app.py CHANGED
@@ -1,8 +1,9 @@
1
  import unittest
 
2
 
3
  from pydantic import ValidationError
4
 
5
- from app import ResearchReport, deduplicate, normalize_comparison, prepare_evidence_inputs, render_presentation, render_section_content, render_value, safe_slug
6
 
7
 
8
  def base_report(section):
@@ -30,6 +31,15 @@ class AppTests(unittest.TestCase):
30
  self.assertEqual([item["id"] for item in result[:2]], ["8", "9"])
31
  self.assertIn("selected_text_extract", result[0])
32
 
 
 
 
 
 
 
 
 
 
33
  def test_render_is_downloadable_document(self):
34
  section = {"title": "Scope", "purpose": "Define the technical decision boundary.", "takeaway": "The boundary determines the useful evidence.", "layout": "text", "content": {"points": ["A meaningful point", "Another meaningful point"]}, "source_ids": []}
35
  output = render_presentation(base_report(section), [], "2026-01-01")
 
1
  import unittest
2
+ from unittest.mock import patch
3
 
4
  from pydantic import ValidationError
5
 
6
+ from app import ResearchReport, chat_json, deduplicate, normalize_comparison, parse_json_object, prepare_evidence_inputs, render_presentation, render_section_content, render_value, safe_slug
7
 
8
 
9
  def base_report(section):
 
31
  self.assertEqual([item["id"] for item in result[:2]], ["8", "9"])
32
  self.assertIn("selected_text_extract", result[0])
33
 
34
+ def test_parser_removes_invalid_string_line_continuation(self):
35
+ self.assertEqual(parse_json_object('{"title":"A\\\nB","value":1}'), {"title": "AB", "value": 1})
36
+
37
+ @patch("app.generate_text", side_effect=['{"title":"broken\\\n', '{"title":"valid"}'])
38
+ def test_json_generation_retries_malformed_response(self, mocked_generate):
39
+ self.assertEqual(chat_json("system", "prompt", 2048), {"title": "valid"})
40
+ self.assertEqual(mocked_generate.call_count, 2)
41
+ self.assertIn("Regenerate from scratch", mocked_generate.call_args.args[0])
42
+
43
  def test_render_is_downloadable_document(self):
44
  section = {"title": "Scope", "purpose": "Define the technical decision boundary.", "takeaway": "The boundary determines the useful evidence.", "layout": "text", "content": {"points": ["A meaningful point", "Another meaningful point"]}, "source_ids": []}
45
  output = render_presentation(base_report(section), [], "2026-01-01")