Robust judgment parser: recover brace-less output, map invented tactics
Browse files- troll_engine.py +57 -17
troll_engine.py
CHANGED
|
@@ -173,11 +173,16 @@ Every time the traveller speaks, you do TWO things:
|
|
| 173 |
what they actually said. Never break character. Never mention the rubric, the
|
| 174 |
meter, or that you are an AI."""
|
| 175 |
|
| 176 |
-
JSON_INSTRUCTION = """Respond with ONLY a single JSON object
|
| 177 |
-
exact shape:
|
| 178 |
{"tactic": "...", "persuasiveness": 0, "reason": "<=10 words on why", "reply": "Gorm's words"}
|
|
|
|
|
|
|
| 179 |
The "reply" value must contain ONLY Gorm's spoken words — never the tactic name, the \
|
| 180 |
-
word "persuasiveness", or any judgment number.
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
SYSTEM_PROMPT = SYSTEM_BODY + "\n\n" + JSON_INSTRUCTION # nature-agnostic (eval/training)
|
| 183 |
|
|
@@ -244,25 +249,60 @@ def parse_judgment(raw: str) -> Judgment:
|
|
| 244 |
text = raw.strip()
|
| 245 |
if text.startswith("```"):
|
| 246 |
text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip()
|
|
|
|
|
|
|
| 247 |
match = _JSON_RE.search(text)
|
| 248 |
if match:
|
| 249 |
try:
|
| 250 |
obj = json.loads(match.group(0))
|
| 251 |
-
tactic = _coerce_tactic(obj.get("tactic"))
|
| 252 |
-
persuasiveness = _coerce_int(obj.get("persuasiveness"), 0, 0, 5)
|
| 253 |
-
reason = str(obj.get("reason", "")).strip()[:120]
|
| 254 |
-
reply = _clean_reply(str(obj.get("reply", "")).strip()) or _fallback_reply()
|
| 255 |
-
return Judgment(persuasiveness, tactic, reason, reply)
|
| 256 |
except (json.JSONDecodeError, ValueError, TypeError):
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
|
| 268 |
def _coerce_int(value, default: int, lo: int, hi: int) -> int:
|
|
|
|
| 173 |
what they actually said. Never break character. Never mention the rubric, the
|
| 174 |
meter, or that you are an AI."""
|
| 175 |
|
| 176 |
+
JSON_INSTRUCTION = """Respond with ONLY a single JSON object wrapped in curly braces, \
|
| 177 |
+
nothing before or after it, in this exact shape:
|
| 178 |
{"tactic": "...", "persuasiveness": 0, "reason": "<=10 words on why", "reply": "Gorm's words"}
|
| 179 |
+
"tactic" MUST be exactly one of: genuine, flattery, threat, manipulation, repetition, smalltalk. \
|
| 180 |
+
Never invent other labels. A clichéd or unproven sob-story is "genuine" with low persuasiveness.
|
| 181 |
The "reply" value must contain ONLY Gorm's spoken words — never the tactic name, the \
|
| 182 |
+
word "persuasiveness", or any judgment number.
|
| 183 |
+
Example of a correct response:
|
| 184 |
+
{"tactic": "genuine", "persuasiveness": 2, "reason": "real but unproven need", "reply": "A sick \
|
| 185 |
+
mother, you say? The wood is thick with such tales. Bring me something truer than words."}"""
|
| 186 |
|
| 187 |
SYSTEM_PROMPT = SYSTEM_BODY + "\n\n" + JSON_INSTRUCTION # nature-agnostic (eval/training)
|
| 188 |
|
|
|
|
| 249 |
text = raw.strip()
|
| 250 |
if text.startswith("```"):
|
| 251 |
text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip()
|
| 252 |
+
|
| 253 |
+
obj = None
|
| 254 |
match = _JSON_RE.search(text)
|
| 255 |
if match:
|
| 256 |
try:
|
| 257 |
obj = json.loads(match.group(0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
except (json.JSONDecodeError, ValueError, TypeError):
|
| 259 |
+
obj = None
|
| 260 |
+
|
| 261 |
+
# Fine-tuned / multi-turn output sometimes drops the braces or invents a
|
| 262 |
+
# label. Recover the fields from loose "key: value" text instead of giving up.
|
| 263 |
+
if not isinstance(obj, dict):
|
| 264 |
+
obj = _loose_extract(text)
|
| 265 |
+
|
| 266 |
+
if isinstance(obj, dict) and obj:
|
| 267 |
+
persuasiveness = _coerce_int(obj.get("persuasiveness"), 0, 0, 5)
|
| 268 |
+
tactic = _coerce_tactic(obj.get("tactic"), persuasiveness)
|
| 269 |
+
reason = str(obj.get("reason", "")).strip()[:120]
|
| 270 |
+
reply = _clean_reply(str(obj.get("reply", "")).strip())
|
| 271 |
+
if reply:
|
| 272 |
+
return Judgment(persuasiveness, tactic, reason or "—", reply)
|
| 273 |
+
|
| 274 |
+
# Last resort: no recoverable structure — treat the whole thing as Gorm
|
| 275 |
+
# talking, score it as idle chatter so the meter never shows "unparseable".
|
| 276 |
+
return Judgment(0, Tactic.SMALLTALK, "no structured judgment",
|
| 277 |
+
_clean_reply(text) or _fallback_reply())
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
_LOOSE_TACTIC = re.compile(r'["\']?tactic["\']?\s*[:=]\s*["\']?([^"\',}\n]+)', re.I)
|
| 281 |
+
_LOOSE_PERSUAS = re.compile(r'["\']?persuasiveness["\']?\s*[:=]\s*["\']?(\d)', re.I)
|
| 282 |
+
_LOOSE_REASON = re.compile(r'["\']?reason["\']?\s*[:=]\s*["\'](.*?)["\']\s*[,}]', re.I | re.S)
|
| 283 |
+
_LOOSE_REPLY = re.compile(r'["\']?reply["\']?\s*[:=]\s*["\'](.*)["\']\s*\}?\s*$', re.I | re.S)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _loose_extract(text: str):
|
| 287 |
+
found = {}
|
| 288 |
+
for key, pat in (("tactic", _LOOSE_TACTIC), ("persuasiveness", _LOOSE_PERSUAS),
|
| 289 |
+
("reason", _LOOSE_REASON), ("reply", _LOOSE_REPLY)):
|
| 290 |
+
m = pat.search(text)
|
| 291 |
+
if m:
|
| 292 |
+
found[key] = m.group(1).strip()
|
| 293 |
+
return found or None
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _coerce_tactic(value, persuasiveness: int = 0) -> Tactic:
|
| 297 |
+
v = str(value).strip().lower()
|
| 298 |
+
for t in Tactic:
|
| 299 |
+
if t.value in v:
|
| 300 |
+
return t
|
| 301 |
+
# Unknown label (e.g. "generic cliché"): a weak earnest appeal is still a
|
| 302 |
+
# genuine reason; anything else is just chatter.
|
| 303 |
+
if "clich" in v or "genuine" in v or "honest" in v or "plea" in v or persuasiveness >= 1:
|
| 304 |
+
return Tactic.GENUINE
|
| 305 |
+
return Tactic.SMALLTALK
|
| 306 |
|
| 307 |
|
| 308 |
def _coerce_int(value, default: int, lo: int, hi: int) -> int:
|