diff --git a/src/constants/chatPrompts.ts b/src/constants/chatPrompts.ts index 063a1a1..42d901a 100644 --- a/src/constants/chatPrompts.ts +++ b/src/constants/chatPrompts.ts @@ -405,6 +405,14 @@ export const INPUT_BLOCKED_NOTICE = export const FILTERED_REPLY = "방금 하려던 말은 그대로 전하기가 어렵겠어 짹. 미안! 대신 다른 야구 얘기 하자."; +/** + * 모델이 빈 응답을 낸 경우(주로 도구 호출 뒤 무텍스트 STOP)의 대체 문구. + * 필터 교체(FILTERED_REPLY)와 구분한다 — "전하기 어렵다"는 검열 톤이 정상 질문 + * ("기아 성적은?" 등)에 나가면 주제를 거부한 것처럼 오해를 부른다. + */ +export const EMPTY_REPLY_NOTICE = + "어라, 방금 답을 만들다가 꼬였다 짹. 미안! 같은 질문 한 번만 다시 던져줄래?"; + // ── 추천 질문 기본 풀(페르소나 문서 6장 — 4-1 클라이언트 검수 대상) ── export const DEFAULT_SUGGESTIONS: ChatSuggestion[] = [ diff --git a/src/services/chatAnalyticsService.ts b/src/services/chatAnalyticsService.ts index 6284efd..9ec9448 100644 --- a/src/services/chatAnalyticsService.ts +++ b/src/services/chatAnalyticsService.ts @@ -15,11 +15,12 @@ import type { KnowledgeLevel, TeamCode } from "../types/panit"; /** 싱크 필터가 채팅 이벤트 행을 고르는 마커(jsonPayload.event). 바꾸면 싱크 필터도 갱신. */ export const CHAT_EVENT_MARKER = "chat_exchange"; /** 스키마 버전 — 필드 추가/의미 변경 시 올린다(BQ 쪽 호환 추적용). */ -export const CHAT_EVENT_SCHEMA_VERSION = 1; +export const CHAT_EVENT_SCHEMA_VERSION = 2; export type ChatOutcome = | "ok" // 모델 응답 정상 - | "filtered" // 출력 필터 교체 + | "filtered" // 출력 필터 교체(로컬 필터 매치·벤더 안전필터) + | "empty" // 모델 빈 응답·비정상 출력 → 대체 문구(v2에서 "filtered"로부터 분리) | "crisis_input" // 입력 위기 감지 → 고정 응답(모델 미호출) | "crisis_output" // 출력 위기 마커/문구 → 고정 응답 | "input_blocked" // 입력 필터/모더레이션 차단(422) diff --git a/src/services/chatProviderService.ts b/src/services/chatProviderService.ts index 17df7e6..8a093b9 100644 --- a/src/services/chatProviderService.ts +++ b/src/services/chatProviderService.ts @@ -1,5 +1,5 @@ import Anthropic from "@anthropic-ai/sdk"; -import { GoogleGenAI, ThinkingLevel, type Content } from "@google/genai"; +import { FunctionCallingConfigMode, GoogleGenAI, ThinkingLevel, type Content } from "@google/genai"; import { HttpError } from "../middleware/errors"; import { backoffSumMs, TOTAL_AI_BUDGET_MS } from "./chatConfigService"; import type { ChatProviderConfig, ChatToolCallInfo } from "../types/chat"; @@ -263,6 +263,8 @@ class VertexChatProvider implements ChatProvider { let outputTokens = 0; let cachedTokens = 0; const toolCalls: ChatToolCallInfo[] = []; + // 도구 라운드 뒤 무텍스트 STOP 방어용 — true면 함수 호출을 잠그고 텍스트만 강제한다. + let forceText = false; for (let round = 0; ; round++) { const remaining = deadline - Date.now(); @@ -281,6 +283,11 @@ class VertexChatProvider implements ChatProvider { maxOutputTokens: cfg.maxOutputTokens, abortSignal: controller.signal, ...(toolConfig ? { tools: toolConfig } : {}), + // 강제 텍스트 재시도 — 함수 선언은 유지(이력의 functionCall 파트 검증용)하되 + // 모드 NONE으로 잠가 이번 응답은 반드시 텍스트로 나오게 한다. + ...(forceText ? + { toolConfig: { functionCallingConfig: { mode: FunctionCallingConfigMode.NONE } } } : + {}), // Gemini 3.0+ 전용 — thinkingLevel만 사용(thinkingBudget은 2.x 레거시). // 도구 호출·다단계 검증(CoT)을 살리되 짧은 응답이라 LOW. thinkingConfig: { thinkingLevel: ThinkingLevel.LOW }, @@ -330,6 +337,17 @@ class VertexChatProvider implements ChatProvider { finishReason = "stop"; } + // flash-lite 퀴크 방어 — functionResponse를 받고도 텍스트 없이 STOP으로 끝나는 + // 경우("기아 성적은?" 류), 함수 호출을 잠그고 텍스트 생성을 1회만 강제 재시도한다. + // 잔여 예산이 없으면 재시도 없이 그대로 반환한다(빈 응답 폴백은 서비스 계층 몫). + if ( + reply.length === 0 && finishReason === "stop" && toolCalls.length > 0 && + !forceText && deadline - Date.now() >= 1000 + ) { + forceText = true; + continue; + } + return { reply, finishReason, diff --git a/src/services/chatService.ts b/src/services/chatService.ts index a274c70..37227c9 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -32,7 +32,9 @@ import { logChatEvent } from "./chatAnalyticsService"; import { extractNavActions } from "./chatNavService"; import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService"; import { buildChatTools, withToolLabels } from "./chatToolService"; -import { FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE } from "../constants/chatPrompts"; +import { + EMPTY_REPLY_NOTICE, FALLBACK_SUGGESTION_IDS, FILTERED_REPLY, INPUT_BLOCKED_NOTICE, +} from "../constants/chatPrompts"; import { CHAT_REPORT_REASONS, type ChatConfig, type ChatMessagesPage, type ChatMessageView, type ChatQuotaView, type ChatReportReason, type ChatSendResult, type ChatSuggestion, type ChatSuggestionsView, type ChatToolCallInfo, type NavAction } from "../types/chat"; @@ -119,7 +121,8 @@ async function replayDone( throw new HttpError(503, "stored reply missing", "AI_UNAVAILABLE"); } return buildSendResult( - uid, date, config, assistantMessageId, stored.content, stored.crisis, stored.createdAt, stored.toolCalls, stored.actions, + uid, date, config, assistantMessageId, stored.content, stored.crisis, stored.createdAt, + stored.toolCalls, stored.actions, ); } @@ -339,6 +342,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise t.name), navRoutes: actions.map((a) => a.route), tokensIn: result.usage.inputTokens, diff --git a/tests/services/chatService.test.ts b/tests/services/chatService.test.ts index 8b274d8..bd01a98 100644 --- a/tests/services/chatService.test.ts +++ b/tests/services/chatService.test.ts @@ -48,6 +48,7 @@ import { import { CRISIS_SELF_HARM_MESSAGE, CRISIS_URGENT_PREFIX, + EMPTY_REPLY_NOTICE, FILTERED_REPLY, } from "../../src/constants/chatPrompts"; import { todayKst } from "../../src/types/dateString"; @@ -517,6 +518,26 @@ describe("chatService", () => { const docs = await messagesCol(uid, "HH").where("role", "==", "assistant").get(); expect((docs.docs[0].data() as ChatMessageDoc).filtered).toBe(true); }); + + it("빈 모델 응답(도구 호출 뒤 무텍스트)은 검열 문구가 아닌 재시도 유도 문구로 교체한다", async () => { + setTestProvider({ + complete: async () => ({ + reply: "", + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 0, cachedTokens: 0 }, + toolCalls: [{ name: "get_team_rank_snapshot", args: {} }], + }), + }); + const result = await sendMessage(uid, { message: "기아 성적은?", clientMessageId: newUuid() }); + expect(result.reply).toBe(EMPTY_REPLY_NOTICE); + expect(result.crisis).toBe(false); + expect(result.toolCalls).toBeUndefined(); // 교체된 응답에는 도구 메타 미첨부 + + // 저장 filtered=true — 히스토리 윈도잉(§5.5)이 교체 응답을 문맥에서 제외하기 위함 + const docs = await messagesCol(uid, "HH").where("role", "==", "assistant").get(); + expect((docs.docs[0].data() as ChatMessageDoc).filtered).toBe(true); + expect((await readQuota()).used).toBe(1); // AI 비용 발생 — 차감 유지 + }); }); describe("장애·가드(§8)", () => {