IntentService.java 7.45 KB
package com.xly.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.xly.agent.Intent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

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

/**
 * 第 0 阶段意图门(intent gate):把一句用户话分类为 {意图, 单据类型, 带角色的实体, 缺失信息}。
 *
 * <p>只做**一件窄任务**,用受约束 JSON 解码({@link LlmJsonClient})。实测在此形态下
 * qwen3:14b 对包括「报价纸盒→纸盒是产品而非客户」「给苏州华为报价彩盒→客户+产品分离」
 * 「有多少个客户→查询」在内的样本 8/8 正确、~2-3s/次。相较之下,让同一个模型在全部 9 个工具的
 * 单次 ReAct 里同时判意图/选工具/编参数则频繁出错(把查询错当新增、更新流程死循环等)。
 *
 * <p>失败(模型不可达/JSON 异常)时返回 intent=不清楚 的空结果,调用方降级到通用 agent,绝不中断。
 */
@Service
public class IntentService {

    private static final Logger log = LoggerFactory.getLogger(IntentService.class);

    private static final String SYSTEM =
            "你是印刷/包装 ERP 的意图与实体抽取器。给你一句用户话,判断其操作意图"
            + "(查询/新增/修改/删除/审核/闲聊/不清楚)、涉及的单据或实体类型,并抽取句中出现的实体及其业务角色"
            + "(客户/产品/物料/供应商/数量/尺寸/日期/金额)。规则:"
            + "①『报价X』『给X报价』里,若 X 是物品(如 纸盒/彩盒/画册/小册子)则 X 是**产品**,不是客户;"
            + "只有明确出现公司/客户名(如『给苏州华为报价』)时才把它当**客户**。"
            + "②对某个产品/规格问价——『多少钱』『什么价』『报个价』『报价』『价格是多少』——在印刷/包装行业"
            + "**通常是要新建一张报价单**(价格由系统核价算出,系统里没有该定制活的现成价),intent=**新增**、单据=**报价**;"
            + "只有明确查看/统计**已存在**的单据(给了单号,或说『查已有报价/列出报价单/上月报价额』)才是查询。"
            + "③『有多少X/查一下X/X的电话是多少』这类问既有数据是**查询**,不是新增。"
            + "④只说一个名词(如『报价』『纸盒』)而无动作时,intent=不清楚。"
            + "⑤missing 里列出完成该意图还缺的关键信息(如 修改缺『具体记录』『新值』)。"
            + "只输出 JSON,不要解释。";

    private final LlmJsonClient llm;

    public IntentService(LlmJsonClient llm) {
        this.llm = llm;
    }

    /** 意图门主入口。utterance 为空或模型失败时返回不清楚。 */
    public Intent classify(String utterance) {
        Intent out = new Intent();
        if (utterance == null || utterance.isBlank()) {
            return out;
        }
        JsonNode n = llm.completeJson(SYSTEM, utterance.trim(), schema());
        if (n == null) {
            log.warn("intent classify fell back to UNCLEAR (model unavailable)");
            return out;
        }
        String intent = n.path("intent").asText(Intent.UNCLEAR);
        out.intent = normalizeIntent(intent);
        out.danju = n.path("danju").asText("");
        JsonNode es = n.path("entities");
        if (es.isArray()) {
            for (JsonNode e : es) {
                String v = e.path("value").asText("");
                String r = e.path("role").asText("未知");
                if (!v.isBlank()) {
                    out.entities.add(new Intent.Entity(v, r));
                }
            }
        }
        JsonNode ms = n.path("missing");
        if (ms.isArray()) {
            for (JsonNode m : ms) {
                String v = m.asText("");
                if (!v.isBlank()) out.missing.add(v);
            }
        }
        return out;
    }

    private static final String WRITE_SYSTEM =
            "你是 ERP 写操作的槽位抽取器。从用户话中抽取要操作的记录及改动:"
            + "record=要操作的那条记录的名称(公司名/客户名/物料名/单号等);"
            + "entityType=该记录属于哪类实体(客户/供应商/物料/产品/单据;拿不准就填 客户);"
            + "field=要修改的字段中文名(如 电话/地址/简称/备注),删除或审核时留空;"
            + "newValue=改成什么新值,删除或审核时留空。只输出 JSON,不要解释。";

    /** 抽取写操作槽位(修改/删除/审核用)。失败返回空槽位。 */
    public Intent.WriteSlots extractWrite(String utterance) {
        Intent.WriteSlots w = new Intent.WriteSlots();
        if (utterance == null || utterance.isBlank()) {
            return w;
        }
        JsonNode n = llm.completeJson(WRITE_SYSTEM, utterance.trim(), writeSchema());
        if (n == null) {
            return w;
        }
        w.entityType = n.path("entityType").asText("");
        w.record = n.path("record").asText("");
        w.field = n.path("field").asText("");
        w.newValue = n.path("newValue").asText("");
        return w;
    }

    private Map<String, Object> writeSchema() {
        Map<String, Object> props = new LinkedHashMap<>();
        props.put("entityType", Map.of("type", "string"));
        props.put("record", Map.of("type", "string"));
        props.put("field", Map.of("type", "string"));
        props.put("newValue", Map.of("type", "string"));
        Map<String, Object> schema = new LinkedHashMap<>();
        schema.put("type", "object");
        schema.put("properties", props);
        schema.put("required", List.of("record"));
        return schema;
    }

    private static String normalizeIntent(String s) {
        if (s == null) return Intent.UNCLEAR;
        s = s.trim();
        switch (s) {
            case Intent.QUERY:
            case Intent.CREATE:
            case Intent.UPDATE:
            case Intent.DELETE:
            case Intent.EXAMINE:
            case Intent.CHAT:
            case Intent.UNCLEAR:
                return s;
            default:
                return Intent.UNCLEAR;
        }
    }

    private Map<String, Object> schema() {
        Map<String, Object> entityProps = new LinkedHashMap<>();
        entityProps.put("value", Map.of("type", "string"));
        entityProps.put("role", Map.of("type", "string",
                "enum", List.of("客户", "产品", "物料", "供应商", "数量", "尺寸", "日期", "金额", "其他", "未知")));
        Map<String, Object> entityItem = new LinkedHashMap<>();
        entityItem.put("type", "object");
        entityItem.put("properties", entityProps);
        entityItem.put("required", List.of("value", "role"));

        Map<String, Object> props = new LinkedHashMap<>();
        props.put("intent", Map.of("type", "string",
                "enum", List.of("查询", "新增", "修改", "删除", "审核", "闲聊", "不清楚")));
        props.put("danju", Map.of("type", "string"));
        props.put("entities", Map.of("type", "array", "items", entityItem));
        props.put("missing", Map.of("type", "array", "items", Map.of("type", "string")));

        Map<String, Object> schema = new LinkedHashMap<>();
        schema.put("type", "object");
        schema.put("properties", props);
        schema.put("required", List.of("intent", "danju", "entities"));
        return schema;
    }
}