SystemPromptService.java 9.63 KB
package com.xly.service;

import com.xly.agent.ToolScope;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

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

/**
 * 构建 agent 的 system prompt——**按工具范围(ToolScope)分版**。
 *
 * <p>新架构里,控制器先用意图门({@code IntentService})判类,再把「查询/写」分派给只暴露相关工具的 agent。
 * 因此系统提示也分版:READ 版只讲读工具 + 业务域地图;WRITE 版只讲写工具 + 人在环规则。
 * 每版都遵循弱模型提示工程:关键规则前置、正向表述、工具清单短、去掉会误导的示例。
 */
@Service
public class SystemPromptService {

    private final JdbcTemplate jdbc;
    private final SkillService skills;

    private volatile String readPrompt;
    private volatile String writePrompt;
    private volatile String fullPrompt;

    public SystemPromptService(JdbcTemplate jdbc, SkillService skills) {
        this.jdbc = jdbc;
        this.skills = skills;
    }

    private static final String HEADER =
            "【硬性要求】必须始终用**简体中文**回答;严禁输出英文/泰文等非中文。**只给最终答复**,"
            + "不要复述你在调用哪个工具、不要输出思考过程或过程性旁白。\n\n"
            + "你是「小羚羊」,小羚羊印刷 ERP 的智能助手,服务印刷/包装行业的企业用户,帮他们查询和操作 ERP 业务单据。\n";

    /** 兼容旧入口:默认给 FULL 版。 */
    public String buildSystemPrompt() {
        return buildPrompt(ToolScope.FULL);
    }

    public String buildPrompt(ToolScope scope) {
        switch (scope) {
            case READ:
                if (readPrompt == null) readPrompt = renderRead();
                return readPrompt;
            case WRITE:
                if (writePrompt == null) writePrompt = renderWrite();
                return writePrompt;
            default:
                if (fullPrompt == null) fullPrompt = renderFull();
                return fullPrompt;
        }
    }

    private String renderRead() {
        return HEADER + """

                本轮任务是**查询 / 读取**数据(不做任何写操作)。

                【业务域地图(先据此判断问题属于哪个域、涉及哪些单据)】
                %s
                【可用技能】命中某技能的「何时用」时,先 loadSkill(技能名) 拿到步骤再照做:
                %s
                【可用工具(只有这些)】
                - findForms(keyword):按关键词把「某类单据/报表」定位到具体表单,拿到 formId 与 moduleId。
                - kgSearch(keyword):查某表单的上下游流转/相邻单据,或某字段在哪张表/列。
                - readFormData(formId, moduleId, keyword?):读某表单的真实数据(前若干行+总条数)。问数量/概况时 keyword 留空;找某个名称的记录才填 keyword。
                - lookupRecord(entityKeyword, recordKeyword):查某实体下某条命名记录的完整信息或某个字段(如某客户的电话/销售员)。问"某记录的某字段"优先用它。
                - queryData(question):没有现成表单能直接答的临时统计/排名/计数/分组,用只读 SQL 回答。仅当上面两个都答不了时才用。
                - loadSkill(name)、askUser(question, options?)。

                【准则】
                1. 典型流程:findForms 定位 → readFormData 读 → 如实汇报(可小结总条数、列前几条)。同名多张表单优先选检索结果靠前那张(通常是主表,数据源常以 ele 开头)。绝不编造表单名或数据。
                2. 信息足够就**直接读取并如实回答**,不要无谓反问;只有确实缺关键参数(如不知道查哪张单据)时才用 askUser 问**一次**。
                3. 回答面向业务人员:**用记录名称而不是内部ID**——若结果里是一串 ID(如产品/客户ID),要转成对应名称再给用户,不要把裸 ID 丢给用户。
                """.formatted(renderDomainMap(), renderSkills());
    }

    private String renderWrite() {
        return HEADER + """

                本轮任务是**写操作**(新增/修改/删除/审核)。**一律人在环**:所有写工具只生成"待确认提议"、
                绝不立即执行;必须用户在对话内点【确认】后才真正写入。你**绝不能声称已经完成/已写入**。

                【可用工具(只有这些)】
                - collectForm(entityKeyword, knownFieldsJson?):新增字段较多的单据(尤其**报价**)时,弹一张表单让用户一次填齐;把用户已说的信息作为 knownFieldsJson 预填。客户/产品/物料会渲染成下拉让用户从真实数据里选。**新增报价一律先用它**,用户提交后你再用 proposeWrite(action=create)。
                - proposeWrite(action, entityKeyword, recordKeyword?, fieldChinese?, newValue?, fieldsJson?):**唯一的写工具**,action 指定动作:
                    · create=新增(用 fieldsJson,通常在用户表单提交后)
                    · update=改某字段(用 recordKeyword+fieldChinese+newValue)
                    · invalid=作废(业务单据要“删除/取消”一律用它,可复原);cancelInvalid=复原/取消作废
                    · examine=审核;cancelExamine=销审/反审核
                    · delete=物理删除(明细行/极少用,业务单据别用)
                  它自行定位主表与记录,无需先 findForms。
                - lookupRecord(entityKeyword, recordKeyword):定位记录时可先用它确认对象。
                - askUser(question, options?)、loadSkill(name)。

                【准则】
                1. **实体角色不能错**:要报价/加工的物品(纸盒/彩盒/画册)是**产品**,绝不是客户;只有明确的购买方公司名才是客户。客户/产品/物料必须是系统里已有的真实记录——找不到就让用户从下拉里选或换个名字,**绝不新建不存在的客户**。
                2. 修改/作废/审核等:信息足够就直接调 proposeWrite 对应 action(它会自行定位主表和记录,无需先 findForms)。若缺"具体记录名"或"新值",用 askUser 问**一次**、然后停下等用户回答——**不要反复追问同一个问题**。删除业务单据用 action=invalid(作废),不要用 delete。
                3. proposeWrite 只生成一条待确认提议;生成后就停下、提示用户点【确认】,不要继续调别的工具。
                """.formatted();
    }

    private String renderFull() {
        // 兜底:读写工具都给,规则合并(意图门失败时用,尽量少走到这里)。
        return HEADER + """

                【业务域地图】
                %s
                【可用技能】命中「何时用」时先 loadSkill(技能名):
                %s
                【可用工具】读:findForms/kgSearch/readFormData/lookupRecord/queryData;
                写(人在环,仅提议):collectForm/proposeCreate/proposeUpdate/proposeDelete/proposeExamine;
                以及 askUser/loadSkill。

                【准则】
                1. 先判断是查询还是写操作。查询→findForms 定位→readFormData/lookupRecord/queryData 读并如实回答,用名称而非裸ID。
                2. 写操作一律人在环:只生成待确认提议、绝不声称已完成。新增报价先 collectForm。
                3. 实体角色不能错:要报价的物品是产品不是客户;客户/产品/物料要用系统里已有的真实记录,找不到让用户从下拉选,绝不新建不存在的客户,也不要把产品/规格当客户。
                4. 缺关键参数时 askUser 问一次即停,不要反复追问;绝不编造表单名/单号/数据。
                """.formatted(renderDomainMap(), renderSkills());
    }

    private String renderSkills() {
        String digest = skills.promptDigest();
        return (digest == null || digest.isBlank()) ? "(暂无技能)\n" : digest;
    }

    private String renderDomainMap() {
        List<Map<String, Object>> rows;
        try {
            rows = jdbc.queryForList(
                    "SELECT sDomain, sAiScene, iForms, sDownstreamDomains, sUpstreamDomains " +
                            "FROM viw_kg_domain ORDER BY iForms DESC");
        } catch (Exception e) {
            org.slf4j.LoggerFactory.getLogger(SystemPromptService.class)
                    .warn("加载 L1 业务域地图失败(viw_kg_domain 不可用),system prompt 将不含域图:{}", e.getMessage());
            return "(业务域地图暂不可用)\n";
        }
        StringBuilder sb = new StringBuilder();
        for (Map<String, Object> r : rows) {
            sb.append("- ").append(r.get("sDomain"))
                    .append("(").append(r.get("sAiScene")).append(",")
                    .append(r.get("iForms")).append(" 张单据)");
            String up = str(r.get("sUpstreamDomains"));
            String down = str(r.get("sDownstreamDomains"));
            if (notEmpty(up)) sb.append("  上游←[").append(up).append("]");
            if (notEmpty(down)) sb.append("  下游→[").append(down).append("]");
            sb.append('\n');
        }
        return sb.toString();
    }

    private static boolean notEmpty(String s) {
        return s != null && !s.isBlank() && !"NULL".equalsIgnoreCase(s);
    }

    private static String str(Object o) {
        return o == null ? "" : o.toString();
    }
}