Преглед изворни кода

增加与ai大模型对话

wwh пре 3 недеља
родитељ
комит
4364d83dce

+ 4 - 3
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/ConversationController.java

@@ -112,9 +112,10 @@ public class ConversationController {
112 112
     @PostMapping(value = "/{id}/chat", consumes = MediaType.APPLICATION_JSON_VALUE)
113 113
     @Operation(summary = "会话内 AI 对话(SSE 流式 + 落库)",
114 114
             description = "需先 POST /conversations 创建会话。请求体仅需 message 字段;"
115
-                    + "服务端组装上游 console/chat 请求,流式开始前写入 user 消息,"
116
-                    + "SSE complete 后由服务端根据 ready 草稿写入 content_type/card_payload/next_action(不依赖智能体返回卡片),"
117
-                    + "可选请求头 X-Agent-Id。")
115
+                    + "服务端组装上游 console/chat 请求,流式开始前写入 user 消息;"
116
+                    + "SSE 最后一条消息由服务端注入 content_type/next_action/card_payload"
117
+                    + "(会话有 pending 草稿且 missingFields 为空时,不依赖智能体),"
118
+                    + "并同步落库。可选请求头 X-Agent-Id。")
118 119
     public void chat(@PathVariable("id") Long conversationId,
119 120
                      @Validated @RequestBody ConversationChatRequest request,
120 121
                      HttpServletResponse response,

+ 73 - 41
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/ConsoleChatSseAggregator.java

@@ -9,23 +9,19 @@ import java.util.HashSet;
9 9
 import java.util.Set;
10 10
 
11 11
 /**
12
- * 从 {@code /api/console/chat} SSE 字节流中增量提取 assistant 文本,并识别流结束 complete 事件。
13
- * <p>
14
- * 兼容:legacy {@code output[]}、{@code object=content} delta、
15
- * {@code object=response/status=completed}、{@code object=message} 完成态,以及重复 {@code data:} 前缀。
16
- * </p>
12
+ * 从 {@code /api/console/chat} SSE 字节流中增量提取 assistant 文本,
13
+ * 识别流结束事件,并暂存「最后一条终端消息」供业务层追加卡片字段后再转发。
17 14
  */
18 15
 public class ConsoleChatSseAggregator {
19 16
 
20 17
     private final ObjectMapper objectMapper;
21 18
     private final StringBuilder lineBuffer = new StringBuilder();
22
-    /** delta 格式拼接(不含 reasoning) */
23 19
     private final StringBuilder streamingText = new StringBuilder();
24
-    /** 完成态 assistant 回复(优先返回) */
25 20
     private final StringBuilder completedReply = new StringBuilder();
26 21
     private final Set<String> reasoningMessageIds = new HashSet<>();
27
-    /** 是否已收到上游 complete / DONE 结束信号 */
28 22
     private boolean completed;
23
+    /** 暂存的最后一条终端 SSE 行(未转发,待业务层注入卡片字段) */
24
+    private String heldTerminalLine;
29 25
 
30 26
     public ConsoleChatSseAggregator(ObjectMapper objectMapper) {
31 27
         this.objectMapper = objectMapper;
@@ -39,17 +35,22 @@ public class ConsoleChatSseAggregator {
39 35
         drainCompleteLines();
40 36
     }
41 37
 
42
-    /** 流结束时刷新缓冲区中未换行的尾部行。 */
43 38
     public void finish() {
44 39
         drainCompleteLines();
45 40
         if (lineBuffer.length() > 0) {
46 41
             consumeSseLine(lineBuffer.toString().trim());
47 42
             lineBuffer.setLength(0);
48 43
         }
49
-        // 流关闭本身也视为一轮对话结束
50 44
         completed = true;
51 45
     }
52 46
 
47
+    public void acceptLine(String line) {
48
+        if (!StringUtils.hasText(line)) {
49
+            return;
50
+        }
51
+        consumeSseLine(line.trim());
52
+    }
53
+
53 54
     public boolean isCompleted() {
54 55
         return completed;
55 56
     }
@@ -61,6 +62,39 @@ public class ConsoleChatSseAggregator {
61 62
         return streamingText.toString();
62 63
     }
63 64
 
65
+    /**
66
+     * 取出暂存的最后一条终端消息行(取出后清空)。
67
+     */
68
+    public String takeHeldTerminalLine() {
69
+        String line = heldTerminalLine;
70
+        heldTerminalLine = null;
71
+        return line;
72
+    }
73
+
74
+    /**
75
+     * 判断 JSON 载荷是否为对话结束/最后一条消息(需 hold 以便注入卡片)。
76
+     */
77
+    public boolean isTerminalPayload(String payload) {
78
+        if (!StringUtils.hasText(payload) || "[DONE]".equals(payload)) {
79
+            return false;
80
+        }
81
+        try {
82
+            JsonNode data = objectMapper.readTree(payload);
83
+            return isTerminalEvent(data);
84
+        } catch (Exception ex) {
85
+            return false;
86
+        }
87
+    }
88
+
89
+    /**
90
+     * hold 一条终端行;若已有 hold,返回旧行供立即转发。
91
+     */
92
+    public String holdTerminalLine(String line) {
93
+        String previous = heldTerminalLine;
94
+        heldTerminalLine = line;
95
+        return previous;
96
+    }
97
+
64 98
     private void drainCompleteLines() {
65 99
         int newlineIndex;
66 100
         while ((newlineIndex = indexOfLineBreak(lineBuffer)) >= 0) {
@@ -83,7 +117,7 @@ public class ConsoleChatSseAggregator {
83 117
             JsonNode data = objectMapper.readTree(payload);
84 118
             consumeJsonEvent(data);
85 119
         } catch (Exception ignored) {
86
-            // 跳过无法解析的 SSE 片段,不影响流式转发
120
+            // skip
87 121
         }
88 122
     }
89 123
 
@@ -102,9 +136,7 @@ public class ConsoleChatSseAggregator {
102 136
             return;
103 137
         }
104 138
 
105
-        // 最后一条 complete:response 完成态
106
-        if (("response".equals(objectType) || "chat.completion".equals(objectType))
107
-                && ("completed".equals(status) || "complete".equals(status))) {
139
+        if (isTerminalEvent(data)) {
108 140
             completed = true;
109 141
             if (data.has("output")) {
110 142
                 String reply = extractAssistantReplyFromOutput(data.get("output"));
@@ -112,17 +144,8 @@ public class ConsoleChatSseAggregator {
112 144
                     completedReply.setLength(0);
113 145
                     completedReply.append(reply);
114 146
                 }
115
-            }
116
-            return;
117
-        }
118
-
119
-        // 兼容 object=complete / type=complete
120
-        if ("complete".equals(objectType)
121
-                || "complete".equals(data.path("type").asText())
122
-                || "completed".equals(data.path("type").asText())) {
123
-            completed = true;
124
-            if (data.has("output")) {
125
-                String reply = extractAssistantReplyFromOutput(data.get("output"));
147
+            } else if ("message".equals(objectType) && "assistant".equals(data.path("role").asText())) {
148
+                String reply = extractTextFromContentNodes(data.get("content"));
126 149
                 if (StringUtils.hasText(reply)) {
127 150
                     completedReply.setLength(0);
128 151
                     completedReply.append(reply);
@@ -131,20 +154,6 @@ public class ConsoleChatSseAggregator {
131 154
             return;
132 155
         }
133 156
 
134
-        if ("message".equals(objectType)
135
-                && "message".equals(data.path("type").asText())
136
-                && "assistant".equals(data.path("role").asText())) {
137
-            String reply = extractTextFromContentNodes(data.get("content"));
138
-            if (StringUtils.hasText(reply)) {
139
-                completedReply.setLength(0);
140
-                completedReply.append(reply);
141
-            }
142
-            if ("completed".equals(status) || "complete".equals(status)) {
143
-                completed = true;
144
-            }
145
-            return;
146
-        }
147
-
148 157
         if ("content".equals(objectType) && "text".equals(data.path("type").asText())) {
149 158
             if (data.path("delta").asBoolean(false) && data.has("text")) {
150 159
                 String msgId = data.path("msg_id").asText(null);
@@ -165,6 +174,30 @@ public class ConsoleChatSseAggregator {
165 174
         }
166 175
     }
167 176
 
177
+    private static boolean isTerminalEvent(JsonNode data) {
178
+        if (data == null || !data.isObject()) {
179
+            return false;
180
+        }
181
+        String objectType = data.path("object").asText("");
182
+        String status = data.path("status").asText("");
183
+        String type = data.path("type").asText("");
184
+
185
+        if (("response".equals(objectType) || "chat.completion".equals(objectType))
186
+                && ("completed".equals(status) || "complete".equals(status))) {
187
+            return true;
188
+        }
189
+        if ("complete".equals(objectType) || "complete".equals(type) || "completed".equals(type)) {
190
+            return true;
191
+        }
192
+        // 完整 assistant 消息(非 reasoning、非纯 delta)
193
+        if ("message".equals(objectType)
194
+                && "message".equals(data.path("type").asText("message"))
195
+                && "assistant".equals(data.path("role").asText())) {
196
+            return true;
197
+        }
198
+        return false;
199
+    }
200
+
168 201
     private static String extractAssistantReplyFromOutput(JsonNode output) {
169 202
         if (output == null || !output.isArray()) {
170 203
             return "";
@@ -212,8 +245,7 @@ public class ConsoleChatSseAggregator {
212 245
         }
213 246
     }
214 247
 
215
-    /** 去掉一层或多层 {@code data:} 前缀,得到 JSON 载荷。 */
216
-    static String normalizeDataPayload(String line) {
248
+    public static String normalizeDataPayload(String line) {
217 249
         if (!StringUtils.hasText(line)) {
218 250
             return "";
219 251
         }

+ 8 - 2
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbOpenAiProxyServiceImpl.java

@@ -67,7 +67,10 @@ public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
67 67
         ConsoleChatSseAggregator aggregator = new ConsoleChatSseAggregator(objectMapper);
68 68
         streamConsoleChatInternal(requestBody, agentId, output, aggregator);
69 69
         aggregator.finish();
70
-        return new ConsoleChatCollectResult(aggregator.getAssistantText(), aggregator.isCompleted());
70
+        return new ConsoleChatCollectResult(
71
+                aggregator.getAssistantText(),
72
+                aggregator.isCompleted(),
73
+                aggregator.takeHeldTerminalLine());
71 74
     }
72 75
 
73 76
     private void streamConsoleChatInternal(byte[] requestBody, String agentId, OutputStream output,
@@ -90,8 +93,11 @@ public class KbOpenAiProxyServiceImpl implements KbOpenAiProxyService {
90 93
             if (input != null) {
91 94
                 if (status >= 400) {
92 95
                     writeUpstreamError(output, input);
96
+                } else if (aggregator != null) {
97
+                    // 会话落库:暂缓 [DONE],便于业务层把卡片追加到最后消息后再结束
98
+                    SseStreamSupport.pipeCollectingDeferDone(input, output, aggregator);
93 99
                 } else {
94
-                    SseStreamSupport.pipeWithFlush(input, output, aggregator);
100
+                    SseStreamSupport.pipeWithFlush(input, output, null);
95 101
                 }
96 102
             }
97 103
         } finally {

+ 91 - 3
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/SseStreamSupport.java

@@ -34,9 +34,6 @@ public final class SseStreamSupport {
34 34
         pipeWithFlush(input, output, null);
35 35
     }
36 36
 
37
-    /**
38
-     * 转发 SSE 并在转发过程中收集字节(供 assistant 文本解析)。
39
-     */
40 37
     public static void pipeWithFlush(InputStream input, OutputStream output, ConsoleChatSseAggregator aggregator)
41 38
             throws IOException {
42 39
         if (input == null) {
@@ -53,6 +50,81 @@ public final class SseStreamSupport {
53 50
         }
54 51
     }
55 52
 
53
+    /**
54
+     * 会话落库:按行转发;暂缓 [DONE] 与「最后一条终端消息」,便于注入卡片字段后再写出。
55
+     */
56
+    public static void pipeCollectingDeferDone(InputStream input,
57
+                                               OutputStream output,
58
+                                               ConsoleChatSseAggregator aggregator) throws IOException {
59
+        if (input == null) {
60
+            return;
61
+        }
62
+        StringBuilder lineBuffer = new StringBuilder();
63
+        byte[] buffer = new byte[PIPE_BUFFER_SIZE];
64
+        int read;
65
+        while ((read = input.read(buffer)) != -1) {
66
+            lineBuffer.append(new String(buffer, 0, read, StandardCharsets.UTF_8));
67
+            drainAndForward(lineBuffer, output, aggregator);
68
+        }
69
+        if (lineBuffer.length() > 0) {
70
+            String leftover = lineBuffer.toString();
71
+            lineBuffer.setLength(0);
72
+            if (leftover.endsWith("\r")) {
73
+                leftover = leftover.substring(0, leftover.length() - 1);
74
+            }
75
+            forwardOrHoldLine(leftover, output, aggregator);
76
+        }
77
+    }
78
+
79
+    private static void drainAndForward(StringBuilder lineBuffer,
80
+                                        OutputStream output,
81
+                                        ConsoleChatSseAggregator aggregator) throws IOException {
82
+        int newlineIndex;
83
+        while ((newlineIndex = indexOfLineBreak(lineBuffer)) >= 0) {
84
+            String line = lineBuffer.substring(0, newlineIndex);
85
+            if (line.endsWith("\r")) {
86
+                line = line.substring(0, line.length() - 1);
87
+            }
88
+            lineBuffer.delete(0, newlineIndex + 1);
89
+            forwardOrHoldLine(line, output, aggregator);
90
+        }
91
+    }
92
+
93
+    private static void forwardOrHoldLine(String line,
94
+                                          OutputStream output,
95
+                                          ConsoleChatSseAggregator aggregator) throws IOException {
96
+        if (aggregator != null) {
97
+            aggregator.acceptLine(line.trim());
98
+        }
99
+        String payload = ConsoleChatSseAggregator.normalizeDataPayload(line.trim());
100
+        if ("[DONE]".equals(payload)) {
101
+            return;
102
+        }
103
+        // 终端消息:hold,旧 hold 先发出(保持流顺序)
104
+        if (aggregator != null && aggregator.isTerminalPayload(payload)) {
105
+            String previous = aggregator.holdTerminalLine(line);
106
+            if (previous != null) {
107
+                writeLine(output, previous);
108
+            }
109
+            return;
110
+        }
111
+        writeLine(output, line);
112
+    }
113
+
114
+    private static void writeLine(OutputStream output, String line) throws IOException {
115
+        output.write((line + "\n").getBytes(StandardCharsets.UTF_8));
116
+        output.flush();
117
+    }
118
+
119
+    private static int indexOfLineBreak(StringBuilder sb) {
120
+        for (int i = 0; i < sb.length(); i++) {
121
+            if (sb.charAt(i) == '\n') {
122
+                return i;
123
+            }
124
+        }
125
+        return -1;
126
+    }
127
+
56 128
     public static void writeDataEvent(OutputStream output, String dataPayload) throws IOException {
57 129
         if (dataPayload == null) {
58 130
             return;
@@ -65,4 +137,20 @@ public final class SseStreamSupport {
65 137
     public static void writeDoneEvent(OutputStream output) throws IOException {
66 138
         writeDataEvent(output, "[DONE]");
67 139
     }
140
+
141
+    /** 原样写出 hold 的 SSE 行(补齐事件分隔)。 */
142
+    public static void writeRawSseLine(OutputStream output, String line) throws IOException {
143
+        if (output == null || line == null) {
144
+            return;
145
+        }
146
+        String trimmed = line.trim();
147
+        if (trimmed.isEmpty()) {
148
+            return;
149
+        }
150
+        if (!trimmed.startsWith("data:")) {
151
+            trimmed = "data: " + trimmed;
152
+        }
153
+        output.write((trimmed + "\n\n").getBytes(StandardCharsets.UTF_8));
154
+        output.flush();
155
+    }
68 156
 }

+ 81 - 13
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationChatService.java

@@ -1,11 +1,14 @@
1 1
 package com.huimv.employment.service.conversation;
2 2
 
3
+import com.fasterxml.jackson.databind.JsonNode;
3 4
 import com.fasterxml.jackson.databind.ObjectMapper;
4 5
 import com.fasterxml.jackson.databind.node.ArrayNode;
5 6
 import com.fasterxml.jackson.databind.node.ObjectNode;
6 7
 import com.huimv.employment.dao.entity.FeConversation;
7 8
 import com.huimv.employment.integration.kb.ConsoleChatCollectResult;
9
+import com.huimv.employment.integration.kb.ConsoleChatSseAggregator;
8 10
 import com.huimv.employment.integration.kb.KbOpenAiProxyService;
11
+import com.huimv.employment.integration.kb.SseStreamSupport;
9 12
 import com.huimv.employment.service.conversation.dto.ConversationChatRequest;
10 13
 import com.huimv.employment.service.conversation.dto.ParsedAssistantReply;
11 14
 import org.slf4j.Logger;
@@ -17,10 +20,7 @@ import java.io.IOException;
17 20
 import java.io.OutputStream;
18 21
 
19 22
 /**
20
- * 会话内 AI 对话:组装上游请求、流式转发并在结束后落库。
21
- * <p>
22
- * 卡片字段由服务端在 SSE complete 后根据草稿状态写入,不依赖智能体返回 card JSON。
23
- * </p>
23
+ * 会话内 AI 对话:流式转发;pending 且 missingFields 为空时在 SSE 最后一条消息注入卡片字段后落库。
24 24
  */
25 25
 @Service
26 26
 public class ConversationChatService {
@@ -48,9 +48,6 @@ public class ConversationChatService {
48 48
         this.objectMapper = objectMapper;
49 49
     }
50 50
 
51
-    /**
52
-     * 在指定会话内发起一轮 AI 对话,SSE 流结束后写入 user/assistant 消息。
53
-     */
54 51
     public void chat(Long userId, Long conversationId, ConversationChatRequest request, String agentId,
55 52
                      OutputStream output) throws IOException {
56 53
         FeConversation conversation = conversationService.requireOwnedActive(userId, conversationId);
@@ -66,21 +63,92 @@ public class ConversationChatService {
66 63
 
67 64
         ConsoleChatCollectResult collectResult = kbOpenAiProxyService.streamConsoleChatCollecting(
68 65
                 upstreamBody, agentId, output, objectMapper);
69
-        // SSE complete(或流正常关闭)后:服务端按 ready 草稿写入卡片,忽略智能体卡片 JSON
66
+
70 67
         ParsedAssistantReply parsed = conversationDraftCardEnricher.attachCardOnComplete(
71 68
                 conversation.getId(), collectResult.getAssistantText());
72 69
 
70
+        // 把卡片字段写入「最后一条」SSE 消息本体后再发 [DONE]
71
+        writeLastSseMessageWithCard(output, collectResult.getHeldTerminalLine(), parsed);
72
+        SseStreamSupport.writeDoneEvent(output);
73
+
73 74
         if (!StringUtils.hasText(parsed.getDisplayText()) && !parsed.hasCard()) {
74
-            log.warn("assistant 回复为空,未写入 fe_conversation_message conversationId={} conversationNo={}",
75
-                    conversation.getId(), conversation.getConversationNo());
75
+            log.warn("assistant 回复为空,未写入 fe_conversation_message conversationId={}",
76
+                    conversation.getId());
76 77
         } else if (parsed.hasCard()) {
77
-            log.info("SSE complete 落库卡片 conversationId={} completed={} contentType={} relatedDraftId={}",
78
-                    conversation.getId(), collectResult.isCompleted(),
79
-                    parsed.getContentType(), parsed.getRelatedDraftId());
78
+            log.info("SSE 最后消息已注入卡片 conversationId={} relatedDraftId={}",
79
+                    conversation.getId(), parsed.getRelatedDraftId());
80 80
         }
81 81
         conversationMessageService.saveAssistantMessage(conversation.getId(), parsed);
82 82
     }
83 83
 
84
+    /**
85
+     * 在最后一条 SSE 消息 JSON 上追加 content_type / next_action / card_payload。
86
+     */
87
+    private void writeLastSseMessageWithCard(OutputStream output,
88
+                                             String heldTerminalLine,
89
+                                             ParsedAssistantReply parsed) throws IOException {
90
+        if (output == null) {
91
+            return;
92
+        }
93
+        if (!parsed.hasCard()) {
94
+            // 无卡片也要把 hold 的最后消息发出,避免客户端永远收不到完结消息
95
+            if (StringUtils.hasText(heldTerminalLine)) {
96
+                SseStreamSupport.writeRawSseLine(output, heldTerminalLine);
97
+            }
98
+            return;
99
+        }
100
+
101
+        ObjectNode msg = buildEnrichedLastMessage(heldTerminalLine, parsed);
102
+        String json = objectMapper.writeValueAsString(msg);
103
+        SseStreamSupport.writeDataEvent(output, json);
104
+        log.info("SSE 最后消息已写出 next_action={} content_type={} draftId={}",
105
+                msg.path("next_action").asText(),
106
+                msg.path("content_type").asText(),
107
+                parsed.getRelatedDraftId());
108
+    }
109
+
110
+    private ObjectNode buildEnrichedLastMessage(String heldTerminalLine, ParsedAssistantReply parsed)
111
+            throws IOException {
112
+        ObjectNode msg;
113
+        if (StringUtils.hasText(heldTerminalLine)) {
114
+            String payload = ConsoleChatSseAggregator.normalizeDataPayload(heldTerminalLine.trim());
115
+            JsonNode node = objectMapper.readTree(payload);
116
+            if (node != null && node.isObject()) {
117
+                msg = (ObjectNode) node;
118
+            } else {
119
+                msg = objectMapper.createObjectNode();
120
+                msg.put("object", "message");
121
+                msg.put("type", "message");
122
+                msg.put("role", "assistant");
123
+                msg.put("status", "completed");
124
+            }
125
+        } else {
126
+            msg = objectMapper.createObjectNode();
127
+            msg.put("object", "message");
128
+            msg.put("type", "message");
129
+            msg.put("role", "assistant");
130
+            msg.put("status", "completed");
131
+            ArrayNode content = msg.putArray("content");
132
+            ObjectNode textPart = content.addObject();
133
+            textPart.put("type", "text");
134
+            textPart.put("text", parsed.getDisplayText() != null ? parsed.getDisplayText() : "");
135
+        }
136
+
137
+        // 强制覆盖/追加卡片字段(不用智能体返回)
138
+        msg.put("content_type", MessageCardConstants.CONTENT_TYPE_CARD_DRAFT);
139
+        msg.put("next_action", MessageCardConstants.NEXT_ACTION_SHOW_EMPLOYMENT_DRAFT);
140
+        if (parsed.getRelatedDraftId() != null) {
141
+            msg.put("related_draft_id", parsed.getRelatedDraftId());
142
+        }
143
+        if (StringUtils.hasText(parsed.getCardPayload())) {
144
+            msg.set("card_payload", objectMapper.readTree(parsed.getCardPayload()));
145
+        }
146
+        if (!msg.has("status") || msg.get("status").asText("").isEmpty()) {
147
+            msg.put("status", "completed");
148
+        }
149
+        return msg;
150
+    }
151
+
84 152
     private byte[] buildUpstreamBody(FeConversation conversation, Long userId, String userText) throws IOException {
85 153
         ObjectNode root = objectMapper.createObjectNode();
86 154
         ArrayNode input = objectMapper.createArrayNode();

+ 13 - 14
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationDraftCardEnricher.java

@@ -18,7 +18,7 @@ import java.util.Map;
18 18
 import java.util.regex.Pattern;
19 19
 
20 20
 /**
21
- * SSE complete 后,由服务端根据会话 pending+ready 草稿写入卡片字段(不依赖智能体返回 card JSON)。
21
+ * SSE 最后消息时,由服务端根据会话 pending 且 missingFields 为空的草稿注入卡片字段(不依赖智能体)。
22 22
  */
23 23
 @Component
24 24
 public class ConversationDraftCardEnricher {
@@ -27,7 +27,6 @@ public class ConversationDraftCardEnricher {
27 27
 
28 28
     private static final String DB_STATUS_PENDING = "pending";
29 29
 
30
-    /** 剥除智能体可能附带的 fe-card / json 代码块,仅保留展示文案 */
31 30
     private static final Pattern FENCED_CARD_BLOCK = Pattern.compile(
32 31
             "```(?:fe-card|json)?\\s*[\\s\\S]*?```",
33 32
             Pattern.CASE_INSENSITIVE);
@@ -42,35 +41,37 @@ public class ConversationDraftCardEnricher {
42 41
     }
43 42
 
44 43
     /**
45
-     * SSE 流结束(complete)后调用:若草稿已 ready,强制写入 card_draft 字段
44
+     * 会话有 pending 草稿且 missingFields 为空时追加 card_draft
46 45
      */
47 46
     public ParsedAssistantReply attachCardOnComplete(Long conversationId, String assistantText) {
48 47
         String displayText = sanitizeDisplayText(assistantText);
49 48
         ParsedAssistantReply parsed = ParsedAssistantReply.textOnly(displayText);
50 49
         if (conversationId == null) {
50
+            log.warn("SSE 注入卡片跳过:conversationId 为空");
51 51
             return parsed;
52 52
         }
53 53
         FeEmploymentDraft draft = findPendingDraft(conversationId);
54 54
         if (draft == null) {
55
-            log.debug("会话 {} SSE complete 后无 pending 草稿,按纯文本落库", conversationId);
55
+            log.info("SSE 注入卡片跳过:会话 {} 无 pending 草稿", conversationId);
56 56
             return parsed;
57 57
         }
58
-        if (!isReady(draft)) {
59
-            log.debug("会话 {} 草稿 {} 尚未 ready(missing_fields={}),按纯文本落库",
60
-                    conversationId, draft.getId(), draft.getMissingFields());
58
+        List<String> missing = parseMissingFields(draft.getMissingFields());
59
+        if (!missing.isEmpty()) {
60
+            log.info("SSE 注入卡片跳过:会话 {} 草稿 {} missingFields 非空 {}",
61
+                    conversationId, draft.getId(), missing);
61 62
             return parsed;
62 63
         }
63 64
         try {
64 65
             Map<String, Object> payload = buildDraftCardPayload(draft);
65 66
             String cardPayload = objectMapper.writeValueAsString(payload);
66
-            log.info("会话 {} SSE complete,服务端写入 card_draft draftId={}", conversationId, draft.getId());
67
+            log.info("SSE 注入卡片 conversationId={} draftId={}", conversationId, draft.getId());
67 68
             return parsed.withCard(
68 69
                     MessageCardConstants.CONTENT_TYPE_CARD_DRAFT,
69 70
                     MessageCardConstants.NEXT_ACTION_SHOW_EMPLOYMENT_DRAFT,
70 71
                     cardPayload,
71 72
                     draft.getId());
72 73
         } catch (Exception ex) {
73
-            log.warn("会话 {} 草稿卡片写入失败: {}", conversationId, ex.getMessage());
74
+            log.warn("会话 {} 草稿卡片注入失败: {}", conversationId, ex.getMessage(), ex);
74 75
             return parsed;
75 76
         }
76 77
     }
@@ -91,10 +92,6 @@ public class ConversationDraftCardEnricher {
91 92
                 .last("LIMIT 1"));
92 93
     }
93 94
 
94
-    private boolean isReady(FeEmploymentDraft draft) {
95
-        return parseMissingFields(draft.getMissingFields()).isEmpty();
96
-    }
97
-
98 95
     private List<String> parseMissingFields(String json) {
99 96
         if (!StringUtils.hasText(json)) {
100 97
             return Collections.emptyList();
@@ -104,7 +101,9 @@ public class ConversationDraftCardEnricher {
104 101
             });
105 102
             return parsed != null ? parsed : Collections.emptyList();
106 103
         } catch (Exception ex) {
107
-            return Collections.emptyList();
104
+            log.warn("解析 missing_fields 失败,视为非空,跳过注入: {}", ex.getMessage());
105
+            // 解析失败时保守处理:不注入,避免误推未就绪草稿
106
+            return Collections.singletonList("_parse_error");
108 107
         }
109 108
     }
110 109