Spaces:
Sleeping
Sleeping
Commit ·
2cf0f7b
1
Parent(s): 758a047
fix: Guard against LLM returning JSON array instead of object
Browse files- services/utils.py +35 -32
services/utils.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import json
|
| 2 |
import re
|
| 3 |
|
|
|
|
| 4 |
def clean_json_string(text: str) -> str:
|
| 5 |
if "```json" in text:
|
| 6 |
text = text.split("```json")[1].split("```")[0]
|
|
@@ -12,22 +13,24 @@ def clean_json_string(text: str) -> str:
|
|
| 12 |
text = parts[1]
|
| 13 |
return text.strip()
|
| 14 |
|
|
|
|
| 15 |
def repair_json(json_str: str) -> str:
|
| 16 |
json_str = json_str.strip()
|
| 17 |
json_str = json_str.rstrip(", ")
|
| 18 |
-
|
| 19 |
open_braces = json_str.count("{")
|
| 20 |
close_braces = json_str.count("}")
|
| 21 |
open_brackets = json_str.count("[")
|
| 22 |
close_brackets = json_str.count("]")
|
| 23 |
-
|
| 24 |
if open_braces > close_braces:
|
| 25 |
json_str += "}" * (open_braces - close_braces)
|
| 26 |
if open_brackets > close_brackets:
|
| 27 |
json_str += "]" * (open_brackets - close_brackets)
|
| 28 |
-
|
| 29 |
return json_str
|
| 30 |
|
|
|
|
| 31 |
def parse_and_repair(raw_text: str, max_preview=300):
|
| 32 |
cleaned = clean_json_string(raw_text)
|
| 33 |
try:
|
|
@@ -44,16 +47,17 @@ def parse_and_repair(raw_text: str, max_preview=300):
|
|
| 44 |
except json.JSONDecodeError as e:
|
| 45 |
return None, {"error": str(e), "raw_preview": raw_text[:max_preview]}
|
| 46 |
|
|
|
|
| 47 |
def repair_json_v2(json_str: str) -> str:
|
| 48 |
json_str = json_str.strip()
|
| 49 |
json_str = json_str.rstrip(", ")
|
| 50 |
-
|
| 51 |
in_string = False
|
| 52 |
escape = False
|
| 53 |
brace_depth = 0
|
| 54 |
bracket_depth = 0
|
| 55 |
last_good_pos = 0
|
| 56 |
-
|
| 57 |
for i, ch in enumerate(json_str):
|
| 58 |
if escape:
|
| 59 |
escape = False
|
|
@@ -66,7 +70,7 @@ def repair_json_v2(json_str: str) -> str:
|
|
| 66 |
continue
|
| 67 |
if in_string:
|
| 68 |
continue
|
| 69 |
-
|
| 70 |
if ch == '{':
|
| 71 |
brace_depth += 1
|
| 72 |
elif ch == '}':
|
|
@@ -75,52 +79,50 @@ def repair_json_v2(json_str: str) -> str:
|
|
| 75 |
bracket_depth += 1
|
| 76 |
elif ch == ']':
|
| 77 |
bracket_depth -= 1
|
| 78 |
-
|
| 79 |
if brace_depth >= 0 and bracket_depth >= 0:
|
| 80 |
last_good_pos = i + 1
|
| 81 |
-
|
| 82 |
end = last_good_pos
|
| 83 |
trimmed = json_str[:end].rstrip(", ")
|
| 84 |
-
|
| 85 |
cb = trimmed.count("{") - trimmed.count("}")
|
| 86 |
sb = trimmed.count("[") - trimmed.count("]")
|
| 87 |
-
|
| 88 |
if cb > 0:
|
| 89 |
trimmed += "}" * cb
|
| 90 |
if sb > 0:
|
| 91 |
trimmed += "]" * sb
|
| 92 |
-
|
| 93 |
return trimmed
|
| 94 |
|
|
|
|
| 95 |
def normalize_field(items, expected_type):
|
| 96 |
"""Convert model output to match Pydantic expectations."""
|
| 97 |
if not isinstance(items, list):
|
| 98 |
return items
|
| 99 |
|
| 100 |
if expected_type == "object_list":
|
| 101 |
-
# Items should be [{key: val, ...}, ...] but model gave ["str1", "str2"]
|
| 102 |
if items and isinstance(items[0], str):
|
| 103 |
return [{"title": s, "expected_ctr": "Medium"} for s in items]
|
| 104 |
return items
|
| 105 |
|
| 106 |
if expected_type == "string_list":
|
| 107 |
-
# Items should be ["str1", "str2"] but model gave [{...}, {...}]
|
| 108 |
if items and isinstance(items[0], dict):
|
| 109 |
extracted = []
|
| 110 |
for obj in items:
|
| 111 |
-
val = obj.get("caption") or obj.get("description") or obj.get("title") or obj.get("name") or obj.get(
|
|
|
|
| 112 |
extracted.append(val)
|
| 113 |
return extracted
|
| 114 |
return items
|
| 115 |
|
| 116 |
if expected_type == "keyword_list":
|
| 117 |
-
# Items should be [{keyword, search_volume, competition, relevance}]
|
| 118 |
if items and isinstance(items[0], str):
|
| 119 |
return [{"keyword": s, "search_volume": "Medium", "competition": "Medium", "relevance": 80} for s in items]
|
| 120 |
return items
|
| 121 |
|
| 122 |
if expected_type == "hashtag_list":
|
| 123 |
-
# Items should be [{tag, post_count}] but model gave ["#tag1", "#tag2"]
|
| 124 |
if items and isinstance(items[0], str):
|
| 125 |
return [{"tag": s, "post_count": "N/A"} for s in items]
|
| 126 |
return items
|
|
@@ -137,40 +139,37 @@ def normalize_field(items, expected_type):
|
|
| 137 |
|
| 138 |
return items
|
| 139 |
|
|
|
|
| 140 |
def normalize_response(data: dict) -> dict:
|
| 141 |
"""Post-process model output to match expected Pydantic schemas."""
|
| 142 |
-
# YouTube: video_titles expects list of {title, expected_ctr}
|
| 143 |
if "video_titles" in data:
|
| 144 |
data["video_titles"] = normalize_field(data["video_titles"], "object_list")
|
| 145 |
if "thumbnail_ideas" in data:
|
| 146 |
data["thumbnail_ideas"] = normalize_field(data["thumbnail_ideas"], "string_list")
|
| 147 |
|
| 148 |
-
# Instagram
|
| 149 |
if "captions" in data:
|
| 150 |
data["captions"] = normalize_field(data["captions"], "caption_list")
|
| 151 |
if "hashtag_sets" in data and isinstance(data["hashtag_sets"], dict):
|
| 152 |
for tier in ["small", "medium", "large"]:
|
| 153 |
if tier in data["hashtag_sets"] and isinstance(data["hashtag_sets"][tier], list):
|
| 154 |
if data["hashtag_sets"][tier] and isinstance(data["hashtag_sets"][tier][0], dict):
|
| 155 |
-
data["hashtag_sets"][tier] = [obj.get("tag", obj.get("name", str(obj))) for obj in
|
|
|
|
| 156 |
if "content_ideas" in data and isinstance(data["content_ideas"], dict):
|
| 157 |
for key in ["reels", "carousels", "stories"]:
|
| 158 |
if key in data["content_ideas"]:
|
| 159 |
data["content_ideas"][key] = normalize_field(data["content_ideas"][key], "string_list")
|
| 160 |
|
| 161 |
-
# Generic SEO
|
| 162 |
if "core_keywords" in data:
|
| 163 |
data["core_keywords"] = normalize_field(data["core_keywords"], "keyword_list")
|
| 164 |
if "viral_hashtags" in data:
|
| 165 |
data["viral_hashtags"] = normalize_field(data["viral_hashtags"], "hashtag_list")
|
| 166 |
|
| 167 |
-
# Pinterest
|
| 168 |
if "pin_ideas" in data:
|
| 169 |
data["pin_ideas"] = normalize_field(data["pin_ideas"], "pin_list")
|
| 170 |
if "seo_keywords" in data:
|
| 171 |
data["seo_keywords"] = normalize_field(data["seo_keywords"], "string_list")
|
| 172 |
|
| 173 |
-
# Generic string lists that might have gotten objects
|
| 174 |
for field in ["related_phrases", "strategy_tips", "content_titles",
|
| 175 |
"tags", "engagement_strategies", "growth_strategies",
|
| 176 |
"article_topics", "thought_leadership_angles",
|
|
@@ -182,31 +181,29 @@ def normalize_response(data: dict) -> dict:
|
|
| 182 |
if field in data:
|
| 183 |
data[field] = normalize_field(data[field], "string_list")
|
| 184 |
|
| 185 |
-
# LinkedIn post_drafts: should be [{headline, body, hook}]
|
| 186 |
if "post_drafts" in data:
|
| 187 |
if data["post_drafts"] and isinstance(data["post_drafts"][0], str):
|
| 188 |
data["post_drafts"] = [{"headline": s, "body": s, "hook": s} for s in data["post_drafts"]]
|
| 189 |
|
| 190 |
-
# Post ideas: should be [{type, content}]
|
| 191 |
if "post_ideas" in data:
|
| 192 |
if data["post_ideas"] and isinstance(data["post_ideas"][0], str):
|
| 193 |
data["post_ideas"] = [{"type": "text", "content": s} for s in data["post_ideas"]]
|
| 194 |
|
| 195 |
-
# Tweet threads: should be [{tweets: [str], theme: str}]
|
| 196 |
if "tweet_threads" in data:
|
| 197 |
if data["tweet_threads"] and isinstance(data["tweet_threads"][0], str):
|
| 198 |
data["tweet_threads"] = [{"tweets": [data["tweet_threads"][0]], "theme": "topic"}]
|
| 199 |
|
| 200 |
-
# Video concepts: should be [{hook, script_snippet, sound_suggestion}]
|
| 201 |
if "video_concepts" in data:
|
| 202 |
if data["video_concepts"] and isinstance(data["video_concepts"][0], str):
|
| 203 |
-
data["video_concepts"] = [{"hook": s, "script_snippet": s, "sound_suggestion": "Trending"} for s in
|
|
|
|
| 204 |
|
| 205 |
-
# Grammar: corrections should be objects [{original, corrected, error_type, explanation}]
|
| 206 |
if "corrections" in data:
|
| 207 |
if isinstance(data["corrections"], list) and data["corrections"]:
|
| 208 |
if isinstance(data["corrections"][0], str):
|
| 209 |
-
data["corrections"] = [
|
|
|
|
|
|
|
| 210 |
elif isinstance(data["corrections"][0], dict):
|
| 211 |
for c in data["corrections"]:
|
| 212 |
c.setdefault("error_type", "grammar")
|
|
@@ -214,7 +211,8 @@ def normalize_response(data: dict) -> dict:
|
|
| 214 |
if "issues" in data:
|
| 215 |
if isinstance(data["issues"], list) and data["issues"]:
|
| 216 |
if isinstance(data["issues"][0], str):
|
| 217 |
-
data["issues"] = [{"issue_type": s, "location": "text", "suggestion": "Review this section."} for s in
|
|
|
|
| 218 |
elif isinstance(data["issues"][0], dict):
|
| 219 |
for iss in data["issues"]:
|
| 220 |
iss.setdefault("issue_type", "style")
|
|
@@ -238,22 +236,22 @@ def normalize_response(data: dict) -> dict:
|
|
| 238 |
except (ValueError, TypeError):
|
| 239 |
data["sentence_count"] = 0
|
| 240 |
|
| 241 |
-
# Ensure descriptions are strings, not objects
|
| 242 |
if "description_template" in data and isinstance(data["description_template"], dict):
|
| 243 |
data["description_template"] = str(data["description_template"])
|
| 244 |
|
| 245 |
-
# Target audience should be string
|
| 246 |
if "target_audience" in data and isinstance(data["target_audience"], dict):
|
| 247 |
data["target_audience"] = str(data["target_audience"])
|
| 248 |
|
| 249 |
return data
|
| 250 |
|
|
|
|
| 251 |
def validate_and_fill_data_defaults(data: dict, defaults: dict) -> dict:
|
| 252 |
for key, default_val in defaults.items():
|
| 253 |
if key not in data or data[key] is None:
|
| 254 |
data[key] = default_val
|
| 255 |
return data
|
| 256 |
|
|
|
|
| 257 |
def run_analysis(messages, defaults=None, temperature=0.3, max_new_tokens=2000):
|
| 258 |
try:
|
| 259 |
from services.model_loader import generate_text
|
|
@@ -274,6 +272,11 @@ def run_analysis(messages, defaults=None, temperature=0.3, max_new_tokens=2000):
|
|
| 274 |
base["error"] = "The AI response was malformed. Please try again."
|
| 275 |
return base
|
| 276 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
try:
|
| 278 |
data = normalize_response(data)
|
| 279 |
except Exception as e:
|
|
|
|
| 1 |
import json
|
| 2 |
import re
|
| 3 |
|
| 4 |
+
|
| 5 |
def clean_json_string(text: str) -> str:
|
| 6 |
if "```json" in text:
|
| 7 |
text = text.split("```json")[1].split("```")[0]
|
|
|
|
| 13 |
text = parts[1]
|
| 14 |
return text.strip()
|
| 15 |
|
| 16 |
+
|
| 17 |
def repair_json(json_str: str) -> str:
|
| 18 |
json_str = json_str.strip()
|
| 19 |
json_str = json_str.rstrip(", ")
|
| 20 |
+
|
| 21 |
open_braces = json_str.count("{")
|
| 22 |
close_braces = json_str.count("}")
|
| 23 |
open_brackets = json_str.count("[")
|
| 24 |
close_brackets = json_str.count("]")
|
| 25 |
+
|
| 26 |
if open_braces > close_braces:
|
| 27 |
json_str += "}" * (open_braces - close_braces)
|
| 28 |
if open_brackets > close_brackets:
|
| 29 |
json_str += "]" * (open_brackets - close_brackets)
|
| 30 |
+
|
| 31 |
return json_str
|
| 32 |
|
| 33 |
+
|
| 34 |
def parse_and_repair(raw_text: str, max_preview=300):
|
| 35 |
cleaned = clean_json_string(raw_text)
|
| 36 |
try:
|
|
|
|
| 47 |
except json.JSONDecodeError as e:
|
| 48 |
return None, {"error": str(e), "raw_preview": raw_text[:max_preview]}
|
| 49 |
|
| 50 |
+
|
| 51 |
def repair_json_v2(json_str: str) -> str:
|
| 52 |
json_str = json_str.strip()
|
| 53 |
json_str = json_str.rstrip(", ")
|
| 54 |
+
|
| 55 |
in_string = False
|
| 56 |
escape = False
|
| 57 |
brace_depth = 0
|
| 58 |
bracket_depth = 0
|
| 59 |
last_good_pos = 0
|
| 60 |
+
|
| 61 |
for i, ch in enumerate(json_str):
|
| 62 |
if escape:
|
| 63 |
escape = False
|
|
|
|
| 70 |
continue
|
| 71 |
if in_string:
|
| 72 |
continue
|
| 73 |
+
|
| 74 |
if ch == '{':
|
| 75 |
brace_depth += 1
|
| 76 |
elif ch == '}':
|
|
|
|
| 79 |
bracket_depth += 1
|
| 80 |
elif ch == ']':
|
| 81 |
bracket_depth -= 1
|
| 82 |
+
|
| 83 |
if brace_depth >= 0 and bracket_depth >= 0:
|
| 84 |
last_good_pos = i + 1
|
| 85 |
+
|
| 86 |
end = last_good_pos
|
| 87 |
trimmed = json_str[:end].rstrip(", ")
|
| 88 |
+
|
| 89 |
cb = trimmed.count("{") - trimmed.count("}")
|
| 90 |
sb = trimmed.count("[") - trimmed.count("]")
|
| 91 |
+
|
| 92 |
if cb > 0:
|
| 93 |
trimmed += "}" * cb
|
| 94 |
if sb > 0:
|
| 95 |
trimmed += "]" * sb
|
| 96 |
+
|
| 97 |
return trimmed
|
| 98 |
|
| 99 |
+
|
| 100 |
def normalize_field(items, expected_type):
|
| 101 |
"""Convert model output to match Pydantic expectations."""
|
| 102 |
if not isinstance(items, list):
|
| 103 |
return items
|
| 104 |
|
| 105 |
if expected_type == "object_list":
|
|
|
|
| 106 |
if items and isinstance(items[0], str):
|
| 107 |
return [{"title": s, "expected_ctr": "Medium"} for s in items]
|
| 108 |
return items
|
| 109 |
|
| 110 |
if expected_type == "string_list":
|
|
|
|
| 111 |
if items and isinstance(items[0], dict):
|
| 112 |
extracted = []
|
| 113 |
for obj in items:
|
| 114 |
+
val = obj.get("caption") or obj.get("description") or obj.get("title") or obj.get("name") or obj.get(
|
| 115 |
+
"url") or str(obj)
|
| 116 |
extracted.append(val)
|
| 117 |
return extracted
|
| 118 |
return items
|
| 119 |
|
| 120 |
if expected_type == "keyword_list":
|
|
|
|
| 121 |
if items and isinstance(items[0], str):
|
| 122 |
return [{"keyword": s, "search_volume": "Medium", "competition": "Medium", "relevance": 80} for s in items]
|
| 123 |
return items
|
| 124 |
|
| 125 |
if expected_type == "hashtag_list":
|
|
|
|
| 126 |
if items and isinstance(items[0], str):
|
| 127 |
return [{"tag": s, "post_count": "N/A"} for s in items]
|
| 128 |
return items
|
|
|
|
| 139 |
|
| 140 |
return items
|
| 141 |
|
| 142 |
+
|
| 143 |
def normalize_response(data: dict) -> dict:
|
| 144 |
"""Post-process model output to match expected Pydantic schemas."""
|
|
|
|
| 145 |
if "video_titles" in data:
|
| 146 |
data["video_titles"] = normalize_field(data["video_titles"], "object_list")
|
| 147 |
if "thumbnail_ideas" in data:
|
| 148 |
data["thumbnail_ideas"] = normalize_field(data["thumbnail_ideas"], "string_list")
|
| 149 |
|
|
|
|
| 150 |
if "captions" in data:
|
| 151 |
data["captions"] = normalize_field(data["captions"], "caption_list")
|
| 152 |
if "hashtag_sets" in data and isinstance(data["hashtag_sets"], dict):
|
| 153 |
for tier in ["small", "medium", "large"]:
|
| 154 |
if tier in data["hashtag_sets"] and isinstance(data["hashtag_sets"][tier], list):
|
| 155 |
if data["hashtag_sets"][tier] and isinstance(data["hashtag_sets"][tier][0], dict):
|
| 156 |
+
data["hashtag_sets"][tier] = [obj.get("tag", obj.get("name", str(obj))) for obj in
|
| 157 |
+
data["hashtag_sets"][tier]]
|
| 158 |
if "content_ideas" in data and isinstance(data["content_ideas"], dict):
|
| 159 |
for key in ["reels", "carousels", "stories"]:
|
| 160 |
if key in data["content_ideas"]:
|
| 161 |
data["content_ideas"][key] = normalize_field(data["content_ideas"][key], "string_list")
|
| 162 |
|
|
|
|
| 163 |
if "core_keywords" in data:
|
| 164 |
data["core_keywords"] = normalize_field(data["core_keywords"], "keyword_list")
|
| 165 |
if "viral_hashtags" in data:
|
| 166 |
data["viral_hashtags"] = normalize_field(data["viral_hashtags"], "hashtag_list")
|
| 167 |
|
|
|
|
| 168 |
if "pin_ideas" in data:
|
| 169 |
data["pin_ideas"] = normalize_field(data["pin_ideas"], "pin_list")
|
| 170 |
if "seo_keywords" in data:
|
| 171 |
data["seo_keywords"] = normalize_field(data["seo_keywords"], "string_list")
|
| 172 |
|
|
|
|
| 173 |
for field in ["related_phrases", "strategy_tips", "content_titles",
|
| 174 |
"tags", "engagement_strategies", "growth_strategies",
|
| 175 |
"article_topics", "thought_leadership_angles",
|
|
|
|
| 181 |
if field in data:
|
| 182 |
data[field] = normalize_field(data[field], "string_list")
|
| 183 |
|
|
|
|
| 184 |
if "post_drafts" in data:
|
| 185 |
if data["post_drafts"] and isinstance(data["post_drafts"][0], str):
|
| 186 |
data["post_drafts"] = [{"headline": s, "body": s, "hook": s} for s in data["post_drafts"]]
|
| 187 |
|
|
|
|
| 188 |
if "post_ideas" in data:
|
| 189 |
if data["post_ideas"] and isinstance(data["post_ideas"][0], str):
|
| 190 |
data["post_ideas"] = [{"type": "text", "content": s} for s in data["post_ideas"]]
|
| 191 |
|
|
|
|
| 192 |
if "tweet_threads" in data:
|
| 193 |
if data["tweet_threads"] and isinstance(data["tweet_threads"][0], str):
|
| 194 |
data["tweet_threads"] = [{"tweets": [data["tweet_threads"][0]], "theme": "topic"}]
|
| 195 |
|
|
|
|
| 196 |
if "video_concepts" in data:
|
| 197 |
if data["video_concepts"] and isinstance(data["video_concepts"][0], str):
|
| 198 |
+
data["video_concepts"] = [{"hook": s, "script_snippet": s, "sound_suggestion": "Trending"} for s in
|
| 199 |
+
data["video_concepts"]]
|
| 200 |
|
|
|
|
| 201 |
if "corrections" in data:
|
| 202 |
if isinstance(data["corrections"], list) and data["corrections"]:
|
| 203 |
if isinstance(data["corrections"][0], str):
|
| 204 |
+
data["corrections"] = [
|
| 205 |
+
{"original": s, "corrected": s, "error_type": "grammar", "explanation": "Automatically corrected."}
|
| 206 |
+
for s in data["corrections"]]
|
| 207 |
elif isinstance(data["corrections"][0], dict):
|
| 208 |
for c in data["corrections"]:
|
| 209 |
c.setdefault("error_type", "grammar")
|
|
|
|
| 211 |
if "issues" in data:
|
| 212 |
if isinstance(data["issues"], list) and data["issues"]:
|
| 213 |
if isinstance(data["issues"][0], str):
|
| 214 |
+
data["issues"] = [{"issue_type": s, "location": "text", "suggestion": "Review this section."} for s in
|
| 215 |
+
data["issues"]]
|
| 216 |
elif isinstance(data["issues"][0], dict):
|
| 217 |
for iss in data["issues"]:
|
| 218 |
iss.setdefault("issue_type", "style")
|
|
|
|
| 236 |
except (ValueError, TypeError):
|
| 237 |
data["sentence_count"] = 0
|
| 238 |
|
|
|
|
| 239 |
if "description_template" in data and isinstance(data["description_template"], dict):
|
| 240 |
data["description_template"] = str(data["description_template"])
|
| 241 |
|
|
|
|
| 242 |
if "target_audience" in data and isinstance(data["target_audience"], dict):
|
| 243 |
data["target_audience"] = str(data["target_audience"])
|
| 244 |
|
| 245 |
return data
|
| 246 |
|
| 247 |
+
|
| 248 |
def validate_and_fill_data_defaults(data: dict, defaults: dict) -> dict:
|
| 249 |
for key, default_val in defaults.items():
|
| 250 |
if key not in data or data[key] is None:
|
| 251 |
data[key] = default_val
|
| 252 |
return data
|
| 253 |
|
| 254 |
+
|
| 255 |
def run_analysis(messages, defaults=None, temperature=0.3, max_new_tokens=2000):
|
| 256 |
try:
|
| 257 |
from services.model_loader import generate_text
|
|
|
|
| 272 |
base["error"] = "The AI response was malformed. Please try again."
|
| 273 |
return base
|
| 274 |
|
| 275 |
+
if not isinstance(data, dict):
|
| 276 |
+
print(f"[run_analysis] Expected dict, got {type(data).__name__}")
|
| 277 |
+
base = dict(defaults) if defaults else {}
|
| 278 |
+
return base
|
| 279 |
+
|
| 280 |
try:
|
| 281 |
data = normalize_response(data)
|
| 282 |
except Exception as e:
|