ResultParser.java

package com.shengzhiai.yugu.internal;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.shengzhiai.yugu.errors.ErrorTable;
import com.shengzhiai.yugu.errors.WarningCode;
import com.shengzhiai.yugu.errors.YuguErrors;
import com.shengzhiai.yugu.errors.YuguException;
import com.shengzhiai.yugu.model.AsrAlignment;
import com.shengzhiai.yugu.model.ConnectedScores;
import com.shengzhiai.yugu.model.Dimensions;
import com.shengzhiai.yugu.model.EvalResult;
import com.shengzhiai.yugu.model.OpenScores;
import com.shengzhiai.yugu.model.SentenceScore;
import com.shengzhiai.yugu.model.StandardAudio;
import com.shengzhiai.yugu.model.TtsResult;
import com.shengzhiai.yugu.model.Warning;
import com.shengzhiai.yugu.model.WordScore;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Parses native {@code SpeechEvaluationResult} responses and compat {@code {recordId, eof, result}}
 * responses into {@link EvalResult}. Neither has an envelope.
 */
public final class ResultParser {
    private ResultParser() {
    }

    /**
     * @param root response or final frame
     * @return result
     * @throws YuguException {@code ProtocolViolationException} 90005 when the JSON is not a result
     */
    public static EvalResult parseEvaluation(JsonNode root) throws YuguException {
        if (root == null || !root.isObject()) {
            throw protocol("evaluation response is not a JSON object", root);
        }
        JsonNode result = root.get("result");
        if (result == null || !result.isObject()) {
            throw protocol("evaluation response has no result object", root);
        }
        try {
            EvalResult out = new EvalResult();
            out.setRaw(root);
            out.setRecordId(text(root, "recordId"));
            if (root.hasNonNull("eof")) {
                out.setEof(root.get("eof").asInt());
            }
            out.setDims(Json.MAPPER.treeToValue(result, Dimensions.class));
            out.setWords(list(result.get("words"), WordScore.class));
            out.setSentences(list(result.get("sentences"), SentenceScore.class));
            out.setLanguage(text(result, "_language"));
            out.setCoreType(text(result, "_coreType"));
            out.setYuguScores(objectOrNull(result.get("yuguScores")));
            out.setCompositeReport(objectOrNull(result.get("compositeReport")));
            String coreType = out.getCoreType();
            if ("connected".equals(coreType) || result.has("connected_overall")) {
                out.setConnected(Json.MAPPER.treeToValue(result, ConnectedScores.class));
            }
            if ("open".equals(coreType) || (isObject(result.get("content")) && isObject(result.get("delivery")))) {
                out.setOpen(Json.MAPPER.treeToValue(result, OpenScores.class));
            }
            JsonNode asr = root.get("asrText");
            if (isObject(asr) && asr.size() > 0) {
                AsrAlignment a = Json.MAPPER.treeToValue(asr, AsrAlignment.class);
                a.setRaw(asr);
                out.setAsrText(a);
            }
            JsonNode report = root.get("report");
            if (report != null && !report.isNull()) {
                out.setReport(report);
            }
            JsonNode std = root.get("standardAudio");
            if (isObject(std) && std.size() > 0) {
                out.setStandardAudio(Json.MAPPER.treeToValue(std, StandardAudio.class));
            }
            out.setWarnings(warnings(result.get("warning"), root.get("warnings")));
            out.setReplayed(root.path("replayed").asBoolean(false));
            return out;
        } catch (JsonProcessingException | IllegalArgumentException e) {
            throw protocol("evaluation response does not match the result schema: " + e.getMessage(), root);
        }
    }

    /**
     * Parses TTS {@code data}.
     *
     * @param data    data object
     * @param baseUrl REST base used to resolve relative audio URLs
     * @return result
     */
    public static TtsResult parseTts(JsonNode data, String baseUrl) {
        TtsResult r = new TtsResult();
        r.setAudioUrl(text(data, "audioUrl"));
        r.setDuration(text(data, "duration"));
        r.setFormat(text(data, "format"));
        r.setWarnings(warnings(data.get("warnings")));
        String url = r.getAudioUrl();
        if (url != null) {
            if (url.startsWith("http://") || url.startsWith("https://")) {
                r.setAbsoluteUrl(url);
            } else if (url.startsWith("/audio/")) {
                // nginx serves synthesised audio under /tts/ (CONTRACT section 2.1)
                r.setAbsoluteUrl(baseUrl + "/tts" + url);
            } else {
                r.setAbsoluteUrl(baseUrl + (url.startsWith("/") ? "" : "/") + url);
            }
        }
        return r;
    }

    /**
     * Merges warning arrays, keeping the first occurrence of each code. Elements may be numbers,
     * numeric strings or {@code {code, message}} objects.
     *
     * @param arrays arrays, null entries are skipped
     * @return warnings
     */
    public static List<Warning> warnings(JsonNode... arrays) {
        Map<Integer, Warning> byCode = new LinkedHashMap<>();
        for (JsonNode arr : arrays) {
            if (arr == null || !arr.isArray()) {
                continue;
            }
            for (JsonNode n : arr) {
                int code;
                String message = null;
                if (n.isObject()) {
                    code = n.path("code").asInt(0);
                    message = text(n, "message");
                } else if (n.isNumber()) {
                    code = n.asInt();
                } else if (n.isTextual() && n.asText().trim().matches("\\d+")) {
                    code = Integer.parseInt(n.asText().trim());
                } else {
                    code = 0;
                    message = n.isTextual() ? n.asText() : n.toString();
                }
                if (message == null) {
                    WarningCode wc = WarningCode.fromCode(code);
                    message = wc != null ? wc.getMessage() : "warning " + code;
                }
                if (!byCode.containsKey(code)) {
                    byCode.put(code, new Warning(code, message, Warning.Source.SERVER));
                }
            }
        }
        return new ArrayList<>(byCode.values());
    }

    private static <T> List<T> list(JsonNode arr, Class<T> type) throws JsonProcessingException {
        List<T> out = new ArrayList<>();
        if (arr != null && arr.isArray()) {
            for (JsonNode n : arr) {
                out.add(Json.MAPPER.treeToValue(n, type));
            }
        }
        return out;
    }

    private static boolean isObject(JsonNode n) {
        return n != null && n.isObject();
    }

    private static JsonNode objectOrNull(JsonNode n) {
        return n != null && n.isObject() ? n : null;
    }

    static String text(JsonNode n, String field) {
        JsonNode v = n == null ? null : n.get(field);
        return v == null || v.isNull() ? null : v.isValueNode() ? v.asText() : v.toString();
    }

    private static YuguException protocol(String message, JsonNode root) {
        YuguException e = YuguErrors.local(ErrorTable.PROTOCOL_ERROR, message, null);
        if (root == null) {
            return e;
        }
        return YuguErrors.create(e.toDetails().toBuilder().rawBody(root.toString()).build());
    }
}