package vip.mate.goal.model; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * Shared (de)serialization and merge helpers for a goal's checklist stored * as JSON text in {@code mate_agent_goal.criteria}. * *

Centralizes the String JSON ↔ {@code List} boundary so the * evaluator, service and node never reimplement parsing. Parse failures fail * soft to an empty list (logged) rather than throwing — a corrupt column must * never break a chat turn or an API response. */ public final class GoalCriteriaCodec { private static final Logger log = LoggerFactory.getLogger(GoalCriteriaCodec.class); private static final TypeReference> LIST_TYPE = new TypeReference<>() { }; private GoalCriteriaCodec() { } /** Parse the JSON column into a mutable list; empty list on null/blank/corrupt. */ public static List parse(String json, ObjectMapper mapper) { if (json == null || json.isBlank()) { return new ArrayList<>(); } try { List parsed = mapper.readValue(json, LIST_TYPE); return parsed != null ? parsed : new ArrayList<>(); } catch (Exception e) { log.warn("[GoalCriteria] failed to parse criteria JSON, treating as empty: {}", e.getMessage()); return new ArrayList<>(); } } /** Serialize a checklist to JSON text; {@code null} for a null list. */ public static String serialize(List criteria, ObjectMapper mapper) { if (criteria == null) { return null; } try { return mapper.writeValueAsString(criteria); } catch (JsonProcessingException e) { log.warn("[GoalCriteria] failed to serialize criteria, storing null: {}", e.getMessage()); return null; } } /** * Merge a per-round verdict delta into the full checklist by id. Criteria * absent from the delta retain their state unless their pass lacks evidence. * The criterion text is always kept from the existing item (the verdict never carries text). */ public static List merge(List existing, List verdicts) { if (existing == null || existing.isEmpty()) { return existing == null ? new ArrayList<>() : existing; } Map byId = new LinkedHashMap<>(); if (verdicts != null) { for (GoalChecklistVerdict.CriterionVerdict v : verdicts) { if (v != null && v.id() != null) { byId.put(v.id(), v); } } } List merged = new ArrayList<>(existing.size()); for (GoalCriterion c : existing) { GoalChecklistVerdict.CriterionVerdict v = byId.get(c.id()); GoalCriterion candidate = v == null ? c : new GoalCriterion(c.id(), c.text(), v.passed(), v.evidence() != null ? v.evidence() : ""); // A model boolean alone cannot satisfy even the legacy semantic // checklist. This checks presence, not truth or execution provenance. merged.add(candidate.passed() && !hasEvidence(candidate) ? new GoalCriterion(candidate.id(), candidate.text(), false, candidate.evidence()) : candidate); } return merged; } /** True only when the list is non-empty and every criterion is passed with nonblank evidence. */ public static boolean allPassed(List criteria) { return criteria != null && !criteria.isEmpty() && criteria.stream().allMatch(c -> c != null && c.passed() && hasEvidence(c)); } /** Criteria not yet passed (used for the continuation prompt + gap text). */ public static List remaining(List criteria) { if (criteria == null) { return List.of(); } return criteria.stream().filter(c -> !c.passed() || !hasEvidence(c)).toList(); } private static boolean hasEvidence(GoalCriterion criterion) { return criterion.evidence() != null && !criterion.evidence().isBlank(); } /** Reassign stable ids {@code C1..Cn} in list order. */ public static List reindex(List criteria) { List out = new ArrayList<>(criteria.size()); int n = 1; for (GoalCriterion c : criteria) { out.add(new GoalCriterion("C" + n, c.text(), c.passed(), c.evidence() == null ? "" : c.evidence())); n++; } return out; } }