Add in-app navigation actions to chat responses

- 모델이 화면 안내 시 [[NAV:route]] 마커를 붙이면 서버가 검증·제거 후 actions[]로 응답·이력에 실어, 클라가 해당 화면으로 이동하는 버튼을 렌더할 수 있게 함
- chatNavService(마커 파서 + APP_ROUTES 12개 라우트·라벨), NavAction/AppRoute 타입, finalizeExchange 저장·멱등 재반환·이력 매핑까지 전 경로 연결
- 위기/필터로 교체된 응답에는 액션을 붙이지 않고, 목록 밖 route는 드롭(모델 환각 방어)
- actions는 route+label만 전달(실제 경로 매핑·이동은 클라가 보유)
- 분석 이벤트에 navRoutes/navCount 추가, chatNavService 단위 테스트 추가
This commit is contained in:
윤정민 2026-06-24 14:08:34 +09:00
parent 81aa1f2fb6
commit f79cf6d0a0
7 changed files with 197 additions and 4 deletions

View File

@ -178,6 +178,14 @@ export const SERVER_DIRECTIVE_BLOCK = `[서버 지시 — 사용자에게 노출
- · ,
. "어디를 찾아볼지" ,
· ( ).
- 안내: 사용자를 ,
. ,
( ),
( ). :
"[[NAV:prediction]]"(), "[[NAV:schedule]]"(·), "[[NAV:prediction_history]]"( ),
"[[NAV:attendance]]"(), "[[NAV:home]]"(), "[[NAV:shop]]"(·),
"[[NAV:notices]]"(), "[[NAV:notifications]]"(), "[[NAV:notification_settings]]"( ),
"[[NAV:mypage]]"(), "[[NAV:inquiry]]"(), "[[NAV:photo_decorator]]"( ).
- 식별자: ${CHAT_CANARY_TOKEN}
, (·· ) .`;

View File

@ -12,6 +12,7 @@ import type {
ChatRequestDoc,
ChatThreadDoc,
ChatToolCallInfo,
NavAction,
} from "../types/chat";
import type { TeamCode } from "../types/panit";
import { addDaysKst, type DateString } from "../types/dateString";
@ -296,6 +297,8 @@ export interface ExchangeParams {
crisis: boolean;
/** assistant 응답 생성 중 호출한 도구(이름+인자). 비었으면 미저장. */
toolCalls?: ChatToolCallInfo[];
/** assistant 화면 이동 액션(마커에서 추출). 비었으면 미저장. */
actions?: NavAction[];
retentionDays: number;
}
@ -341,6 +344,7 @@ export async function finalizeExchange(params: ExchangeParams): Promise<Exchange
...(params.model ? { model: params.model } : {}),
...(params.promptVersion ? { promptVersion: params.promptVersion } : {}),
...(params.toolCalls && params.toolCalls.length > 0 ? { toolCalls: params.toolCalls } : {}),
...(params.actions && params.actions.length > 0 ? { actions: params.actions } : {}),
};
batch.set(messagesCol(params.uid, params.threadId).doc(assistantMessageId), assistantDoc);

View File

@ -47,6 +47,8 @@ export interface ChatEventInput {
blockedCategory?: string;
/** 모델이 실제 호출한 도구 이름들(표시용 첨부 여부와 무관). */
toolNames?: string[];
/** 응답에 첨부된 화면 이동 라우트들(예: prediction, schedule). */
navRoutes?: string[];
tokensIn?: number;
tokensOut?: number;
tokensCached?: number;
@ -88,6 +90,10 @@ export function buildChatEvent(input: ChatEventInput): Record<string, unknown> {
ev.toolNames = input.toolNames;
ev.toolCount = input.toolNames.length;
}
if (input.navRoutes) {
ev.navRoutes = input.navRoutes;
ev.navCount = input.navRoutes.length;
}
put("tokensIn", input.tokensIn);
put("tokensOut", input.tokensOut);
put("tokensCached", input.tokensCached);

View File

@ -0,0 +1,69 @@
import type { AppRoute, NavAction } from "../types/chat";
/**
* ( ) `[[NAV:route]]`
* NavAction[] , .
*
* 이유: 추가 (· ) route
* enum으로 ( / ) .
* (chatFilterService) .
*/
/**
* route ( ). route ·,
* (: schedule/calendar) · . route로 /
* ( ).
*/
export const APP_ROUTES: Record<AppRoute, string> = {
prediction: "예측하러 가기",
schedule: "일정·결과 보기",
prediction_history: "내 예측 기록 보기",
attendance: "출석체크 하러 가기",
home: "홈으로",
shop: "상점(MADE) 가기",
notices: "공지 보기",
notifications: "알림함 열기",
notification_settings: "알림 설정 열기",
mypage: "마이페이지 가기",
inquiry: "문의하기",
photo_decorator: "사진 꾸미기",
};
/** 모델이 화면 안내 시 답변에 덧붙이는 마커. 서버가 파싱·검증 후 제거(사용자 비노출). */
const NAV_MARKER_RE = /\[\[NAV:([a-zA-Z_]+)\]\]/g;
export interface NavExtraction {
/** 마커를 제거한 사용자 노출용 텍스트. */
clean: string;
/** 검증된 액션(알 수 없는 route 제외, 중복 제거, 등장 순서 보존). */
actions: NavAction[];
}
function isAppRoute(route: string): route is AppRoute {
return Object.prototype.hasOwnProperty.call(APP_ROUTES, route);
}
/**
* reply에서 `[[NAV:route]]` NavAction[],
* . route는 ( ), route는 1 .
*/
export function extractNavActions(reply: string): NavExtraction {
const seen = new Set<AppRoute>();
const actions: NavAction[] = [];
NAV_MARKER_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = NAV_MARKER_RE.exec(reply)) !== null) {
const route = m[1];
if (isAppRoute(route) && !seen.has(route)) {
seen.add(route);
actions.push({ type: "navigate", route, label: APP_ROUTES[route] });
}
}
// 마커 제거 + 마커 자리로 생긴 잉여 공백/빈 줄 정리
const clean = reply
.replace(NAV_MARKER_RE, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
return { clean, actions };
}

View File

@ -29,12 +29,13 @@ import {
} from "./chatProviderService";
import { checkInput, checkOutput, crisisReply, detectCrisis } from "./chatFilterService";
import { logChatEvent } from "./chatAnalyticsService";
import { extractNavActions } from "./chatNavService";
import { assemblePrompt, gatherUserContext, resolveTeamCode, type UserContext } from "./chatContextService";
import { buildChatTools } from "./chatToolService";
import { 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 } from "../types/chat";
type ChatSuggestionsView, type ChatToolCallInfo, type NavAction } from "../types/chat";
import type { User } from "../types/panit";
import { todayKst, type DateString } from "../types/dateString";
@ -89,6 +90,7 @@ async function buildSendResult(
crisis: boolean,
createdAt: Timestamp,
toolCalls?: ChatToolCallInfo[],
actions?: NavAction[],
): Promise<ChatSendResult> {
const { used } = await quotaView(uid, date);
return {
@ -99,6 +101,7 @@ async function buildSendResult(
limit: config.dailyLimit,
createdAt: toKstIso(createdAt),
...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}),
...(actions && actions.length > 0 ? { actions } : {}),
};
}
@ -116,7 +119,7 @@ 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,
uid, date, config, assistantMessageId, stored.content, stored.crisis, stored.createdAt, stored.toolCalls, stored.actions,
);
}
@ -283,6 +286,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
let finalReply = "";
let finalCrisis = false;
let finalToolCalls: ChatToolCallInfo[] = [];
let finalActions: NavAction[] = [];
try {
// 6) 컨텍스트 조립(§5)
const ctx: UserContext = await gatherUserContext(uid, user, config);
@ -336,6 +340,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
let reply = result.reply;
let filtered = false;
let crisisOut = false;
let actions: NavAction[] = [];
const outputCheck = checkOutput(reply, config.filters, leakBody);
if (outputCheck.action === "crisis") {
reply = crisisReply(outputCheck.crisisType ?? "selfHarm", outputCheck.crisisUrgent);
@ -346,9 +351,19 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
} else if (reply.length === 0) {
reply = FILTERED_REPLY;
filtered = true;
} else {
// 정상 모델 응답 — 화면 이동 마커([[NAV:route]])를 actions로 분리하고 본문에서 제거
const nav = extractNavActions(reply);
if (nav.clean.length === 0) {
reply = FILTERED_REPLY; // 마커만 있고 본문이 비는 비정상 출력 방어
filtered = true;
} else {
reply = nav.clean;
actions = nav.actions;
}
}
// 도구 호출 메타 — 보여줄 응답이 모델 답변일 때만 부착(교체된 crisis/filtered는 제외)
// 도구 호출·이동 액션 메타 — 보여줄 응답이 모델 답변일 때만 부착(교체된 crisis/filtered는 제외)
const toolCalls = crisisOut || filtered ? [] : result.toolCalls;
// 10) 단일 배치 저장 — 출력 교체(filtered/crisis)는 비용이 발생했으므로 차감 유지
@ -364,11 +379,13 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
filtered,
crisis: crisisOut,
toolCalls,
actions,
retentionDays: config.retentionDays,
});
finalReply = reply;
finalCrisis = crisisOut;
finalToolCalls = toolCalls;
finalActions = actions;
logChatEvent({
outcome: crisisOut ? "crisis_output" : filtered ? "filtered" : "ok",
uid, teamCode: activeTeamCode, threadId: activeThreadId,
@ -378,6 +395,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
crisis: crisisOut, crisisType: crisisOut ? (outputCheck.crisisType ?? "selfHarm") : undefined,
filtered, filterReason: outputCheck.action === "filter" ? outputCheck.reason : undefined,
toolNames: result.toolCalls.map((t) => t.name),
navRoutes: actions.map((a) => a.route),
tokensIn: result.usage.inputTokens,
tokensOut: result.usage.outputTokens,
tokensCached: result.usage.cachedTokens,
@ -393,7 +411,7 @@ export async function sendMessage(uid: string, body: SendBody): Promise<ChatSend
// 11) 응답 반환 — 저장 성공 이후의 쿼터 재조회 실패는 복원 대상이 아니다(§6.2)
return buildSendResult(
uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt, finalToolCalls,
uid, date, config, saved.assistantMessageId, finalReply, finalCrisis, saved.createdAt, finalToolCalls, finalActions,
);
}
@ -448,6 +466,7 @@ export async function getMessages(
createdAt: toKstIso(m.createdAt),
...(m.role === "user" && m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
...(m.role === "assistant" && m.toolCalls?.length ? { toolCalls: m.toolCalls } : {}),
...(m.role === "assistant" && m.actions?.length ? { actions: m.actions } : {}),
}));
const last = items[items.length - 1];

View File

@ -20,6 +20,33 @@ export interface ChatToolCallInfo {
args: Record<string, unknown>;
}
/**
* GoRouter 1:1( ).
* route , (: schedule/calendar)· .
* game_detail( gameId) .
*/
export type AppRoute =
| "prediction"
| "schedule"
| "prediction_history"
| "attendance"
| "home"
| "shop"
| "notices"
| "notifications"
| "notification_settings"
| "mypage"
| "inquiry"
| "photo_decorator";
/** 채팅 응답에 첨부되는 화면 이동 액션 — 클라가 탭하면 route 화면으로 이동시킨다. */
export interface NavAction {
type: "navigate";
route: AppRoute;
/** 버튼 기본 라벨(서버 제공). 클라가 route로 자체 라벨/아이콘 매핑해도 된다. */
label: string;
}
/** `users/{uid}/chatThreads/{threadId}` — threadId는 팀 코드 또는 "default". */
export interface ChatThreadDoc {
teamCode?: TeamCode;
@ -50,6 +77,8 @@ export interface ChatMessageDoc {
crisis: boolean;
/** assistant 메시지만 — 응답 생성 중 호출한 도구 목록(이름+인자). 호출 없으면 미저장. */
toolCalls?: ChatToolCallInfo[];
/** assistant 메시지만 — 화면 이동 액션(마커에서 추출). 없으면 미저장. */
actions?: NavAction[];
/** TTL 삭제 시각 = createdAt + 보관 기간(§4.3). */
expireAt: Timestamp;
}
@ -191,6 +220,8 @@ export interface ChatSendResult {
createdAt: string;
/** 응답 생성 중 호출한 도구(이름+인자). 호출 없으면 생략. 클라 활용 선택. */
toolCalls?: ChatToolCallInfo[];
/** 화면 이동 액션 — 클라가 버튼으로 렌더해 해당 화면으로 이동. 없으면 생략. */
actions?: NavAction[];
}
export interface ChatMessageView {
@ -202,6 +233,8 @@ export interface ChatMessageView {
clientMessageId?: string;
/** assistant 메시지의 도구 호출(이름+인자). 없으면 생략. */
toolCalls?: ChatToolCallInfo[];
/** assistant 메시지의 화면 이동 액션. 없으면 생략. */
actions?: NavAction[];
}
export interface ChatMessagesPage {

View File

@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { extractNavActions, APP_ROUTES } from "../../src/services/chatNavService";
describe("chatNavService.extractNavActions", () => {
it("마커가 없으면 actions 빈 배열·원문 유지(trim만)", () => {
const r = extractNavActions("오늘 우리 경기 대전에서 6시 30분에 해");
expect(r.actions).toEqual([]);
expect(r.clean).toBe("오늘 우리 경기 대전에서 6시 30분에 해");
});
it("유효 마커를 actions로 분리하고 본문에서 제거한다", () => {
const r = extractNavActions("예측은 예측 탭에서 하면 돼 [[NAV:prediction]]");
expect(r.actions).toEqual([
{ type: "navigate", route: "prediction", label: APP_ROUTES.prediction },
]);
expect(r.clean).toBe("예측은 예측 탭에서 하면 돼");
expect(r.clean).not.toContain("[[NAV:");
});
it("여러 마커를 등장 순서대로, 라벨까지 채워 반환한다", () => {
const r = extractNavActions("출석하고 [[NAV:attendance]] 예측도 해 [[NAV:prediction]]");
expect(r.actions.map((a) => a.route)).toEqual(["attendance", "prediction"]);
expect(r.actions[0].label).toBe(APP_ROUTES.attendance);
expect(r.clean).toBe("출석하고 예측도 해"); // 마커 제거(중간 공백은 남되 양끝 trim)
});
it("알 수 없는 route는 무시한다(모델 환각 방어)", () => {
const r = extractNavActions("어딘가 가봐 [[NAV:settings_unknown]] [[NAV:schedule]]");
expect(r.actions.map((a) => a.route)).toEqual(["schedule"]);
});
it("프로토타입 키(toString 등)를 route로 받지 않는다", () => {
const r = extractNavActions("ㅋㅋ [[NAV:toString]] [[NAV:hasOwnProperty]]");
expect(r.actions).toEqual([]);
});
it("중복 route는 하나로 접는다", () => {
const r = extractNavActions("[[NAV:shop]] 포인트는 상점에서 [[NAV:shop]]");
expect(r.actions.map((a) => a.route)).toEqual(["shop"]);
});
it("마커만 있고 본문이 비면 clean이 빈 문자열(상위에서 필터 처리)", () => {
const r = extractNavActions("[[NAV:home]]");
expect(r.clean).toBe("");
expect(r.actions.map((a) => a.route)).toEqual(["home"]);
});
it("APP_ROUTES는 12개 무파라미터 라우트를 가진다(클라 확정본)", () => {
expect(Object.keys(APP_ROUTES).sort()).toEqual([
"attendance", "home", "inquiry", "mypage", "notices", "notification_settings",
"notifications", "photo_decorator", "prediction", "prediction_history", "schedule", "shop",
]);
});
});