- 모델이 화면 안내 시 [[NAV:route]] 마커를 붙이면 서버가 검증·제거 후 actions[]로 응답·이력에 실어, 클라가 해당 화면으로 이동하는 버튼을 렌더할 수 있게 함 - chatNavService(마커 파서 + APP_ROUTES 12개 라우트·라벨), NavAction/AppRoute 타입, finalizeExchange 저장·멱등 재반환·이력 매핑까지 전 경로 연결 - 위기/필터로 교체된 응답에는 액션을 붙이지 않고, 목록 밖 route는 드롭(모델 환각 방어) - actions는 route+label만 전달(실제 경로 매핑·이동은 클라가 보유) - 분석 이벤트에 navRoutes/navCount 추가, chatNavService 단위 테스트 추가
117 lines
4.4 KiB
TypeScript
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 {
|
|
// 분석 로깅 실패가 사용자 응답을 막지 않는다.
|
|
}
|
|
}
|