mmday-firebase/src/services/chatAnalyticsService.ts
윤정민 f79cf6d0a0 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 단위 테스트 추가
2026-06-24 14:08:34 +09:00

117 lines
4.4 KiB
TypeScript

import { logger } from "firebase-functions";
import type { CrisisType } from "./chatFilterService";
import type { KnowledgeLevel, TeamCode } from "../types/panit";
/**
* 채팅 관측성(#3) — BigQuery 집계용 이벤트 emit.
*
* 전송 경로: 구조화 로그(여기) → Cloud Logging 싱크 → BigQuery 데이터셋. 함수 요청 경로에
* 네트워크 호출을 더하지 않도록 "로그만" 남기고(지연 0·새 의존성 0), BQ 적재는 로그 싱크가
* 비동기로 처리한다. 싱크 설정·집계 쿼리는 docs/chat-analytics-bigquery.md 참고.
*
* 프라이버시: 메시지·응답 "본문"은 어떤 필드에도 담지 않는다 — 길이(msgLen)·메타만 남긴다.
*/
/** 싱크 필터가 채팅 이벤트 행을 고르는 마커(jsonPayload.event). 바꾸면 싱크 필터도 갱신. */
export const CHAT_EVENT_MARKER = "chat_exchange";
/** 스키마 버전 — 필드 추가/의미 변경 시 올린다(BQ 쪽 호환 추적용). */
export const CHAT_EVENT_SCHEMA_VERSION = 1;
export type ChatOutcome =
| "ok" // 모델 응답 정상
| "filtered" // 출력 필터 교체
| "crisis_input" // 입력 위기 감지 → 고정 응답(모델 미호출)
| "crisis_output" // 출력 위기 마커/문구 → 고정 응답
| "input_blocked" // 입력 필터/모더레이션 차단(422)
| "error" // provider 등 처리 실패
| "replay" // 멱등 재반환
| "rejected"; // 전역 한도 등 사전 거부
export interface ChatEventInput {
outcome: ChatOutcome;
uid: string;
teamCode?: TeamCode | null;
threadId?: string | null;
stylePack?: string;
knowledgeLevel?: KnowledgeLevel | null;
promptVersion?: string;
provider?: string;
model?: string;
finishReason?: string;
crisis?: boolean;
crisisType?: CrisisType;
filtered?: boolean;
/** checkOutput 사유(canary/prompt-leak/hate/...) — 본문 아님. */
filterReason?: string;
/** 입력 차단 카테고리(hate/sexual/minor/insult) 또는 "moderation". */
blockedCategory?: string;
/** 모델이 실제 호출한 도구 이름들(표시용 첨부 여부와 무관). */
toolNames?: string[];
/** 응답에 첨부된 화면 이동 라우트들(예: prediction, schedule). */
navRoutes?: string[];
tokensIn?: number;
tokensOut?: number;
tokensCached?: number;
latencyMs?: number;
/** 사용자 메시지 "길이"만(본문 X). */
msgLen?: number;
/** HttpError code/에러 name(본문 X). */
errorCode?: string;
}
/**
* 평탄한 이벤트 객체 생성(순수 함수, 테스트 대상). undefined/null 필드는 빼서 BQ 스키마를
* 깔끔히 유지하고, 메시지·응답 본문은 절대 포함하지 않는다.
*/
export function buildChatEvent(input: ChatEventInput): Record<string, unknown> {
const ev: Record<string, unknown> = {
event: CHAT_EVENT_MARKER,
schema: CHAT_EVENT_SCHEMA_VERSION,
outcome: input.outcome,
uid: input.uid,
};
const put = (k: string, v: unknown): void => {
if (v !== undefined && v !== null) ev[k] = v;
};
put("teamCode", input.teamCode);
put("threadId", input.threadId);
put("stylePack", input.stylePack);
put("knowledgeLevel", input.knowledgeLevel);
put("promptVersion", input.promptVersion);
put("provider", input.provider);
put("model", input.model);
put("finishReason", input.finishReason);
put("crisis", input.crisis);
put("crisisType", input.crisisType);
put("filtered", input.filtered);
put("filterReason", input.filterReason);
put("blockedCategory", input.blockedCategory);
if (input.toolNames) {
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);
if (typeof input.tokensIn === "number" && typeof input.tokensOut === "number") {
ev.tokensTotal = input.tokensIn + input.tokensOut;
}
put("latencyMs", input.latencyMs);
put("msgLen", input.msgLen);
put("errorCode", input.errorCode);
return ev;
}
/** 이벤트를 구조화 로그로 emit. best-effort — 절대 throw하지 않는다(응답 경로 보호). */
export function logChatEvent(input: ChatEventInput): void {
try {
logger.write({ severity: "INFO", message: CHAT_EVENT_MARKER, ...buildChatEvent(input) });
} catch {
// 분석 로깅 실패가 사용자 응답을 막지 않는다.
}
}