Spaces:
Running on Zero
Running on Zero
zhiyi commited on
Commit ·
b2e433d
1
Parent(s): eef685d
Recover from malformed structured model output
Browse files- app.py +38 -14
- 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|