From c7ee9dbcb74263caf32e00df6e40e9c7e096478d Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 00:13:19 +0900 Subject: [PATCH 01/21] =?UTF-8?q?=F0=9F=93=9D=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK.md | 363 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) diff --git a/TASK.md b/TASK.md index e69de29..674dc3d 100644 --- a/TASK.md +++ b/TASK.md @@ -0,0 +1,363 @@ +# ASK 세션 관리 및 대화 이력 저장 계획 + +## 1. 목표와 배경 +- `/ai/v2/ask` 흐름에 “세션” 개념을 도입해 질문·응답 히스토리를 영속화하고, LLM 호출 시 과거 맥락을 재활용한다. +- 하이브리드 검색/플래너 구조는 유지하되, 세션별 메타데이터와 메시지를 저장해 대화형 UX를 지원한다. +- `/ai/v1/ask` 경로도 동일한 세션/캐시 메커니즘을 공유하도록 범위를 확장하며, v1과 v2 모두 동일한 저장소/히스토리 API를 사용한다. `session_id` 없이 호출된 경우 서버가 즉시 신규 세션을 생성해 클라이언트에 식별자를 돌려준다. + +## 2. 요구사항 & 고려 사항 +- **세션 수명**: 클라이언트가 `session_id`를 생략하거나 `null`로 보낼 때만 서버가 신규 세션을 생성하고 식별자를 반환한다. 값이 있는 `session_id`는 항상 기존 세션을 재사용한다. +- **사용자 식별**: 세션/히스토리 소유권은 반드시 `authMiddleware`가 디코딩한 JWT의 `user_id` 클레임으로 판별한다. `/ai/(v1|v2)/ask` 요청 본문의 `user_id`는 “어떤 블로그의 챗봇을 질의하느냐”를 뜻하는 `owner_user_id`로 DB에 별도 저장한다. 세션/메시지 레코드는 항상 `(requester_user_id, owner_user_id)` 쌍을 유지해 접근 제어(요청자 기준)와 캐시/무효화(블로그 주인 기준)를 동시에 지원한다. +- **세션 생성 전제**: 새 세션을 만들려면 반드시 목표 챗봇(= `owner_user_id`)을 명시해야 한다. `/ai/(v1|v2)/ask` 요청에서 `session_id`가 없거나 null일 때만 서버가 새 세션을 자동 생성하며, 이후 세션이 속한 모든 메시지·임베딩에는 동일한 `owner_user_id`가 저장된다. 별도의 세션 생성 API는 제공하지 않는다. +- **Owner 일관성 검증**: 클라이언트가 `/ai/(v1|v2)/ask`에 `session_id`와 `user_id`를 동시에 보낼 때 두 값이 가리키는 owner가 다르면 요청을 즉시 400/409로 거부한다(프런트에서도 다른 owner로 세션 다시 쓰기를 금지). 서버는 DB에 저장된 세션의 `owner_user_id`를 단일 진실 소스로 신뢰하며, Body의 값과 일치할 때만 진행한다. +- **히스토리 저장**: 사용자 질문, 검색 계획 요약, 모델 답변 요약본 등 최소 정보는 DB에 보관. 토큰 비용을 고려해 원문 전체 저장 여부 판단. +- **프롬프트 구성**: 세션의 최근 N개 대화를 불러와 RAG 컨텍스트 뒤에 배치하되, 총 토큰 한도를 넘지 않도록 절단 로직 필요. +- **하이브리드/플래너 영향**: 검색 계획은 여전히 질문 단위로 생성하지만, 과거 대화에서 follow-up intent 판단에 활용될 수 있도록 프롬프트 확장 검토. +- **SSE 계약**: 세션 생성/갱신 이벤트를 추가해 클라이언트가 새 세션을 추적할 수 있게 한다. +- **보안·정합성**: 사용자별 세션 접근 제어 필요. 세션 삭제/만료 정책과 개인정보(민감 답변) 취급 주의. +- **v1/v2 공통 처리**: `/ai/v1/ask`는 검색 계획이나 하이브리드 메타가 없을 수 있으므로 저장 시 `search_plan`/`retrieval_meta`를 `NULL`로 허용하고, 히스토리 API에서 두 경로가 동일한 스키마를 사용한다. + +## 3. 작업 단계 +1. **DB 설계 & 마이그레이션** + - `ask_session` 테이블 추가 (PostgreSQL) + ```sql + CREATE TABLE ask_session ( + id BIGSERIAL PRIMARY KEY, + requester_user_id TEXT NOT NULL, -- JWT에서 파생된 실제 질문자 + owner_user_id TEXT NOT NULL, -- 챗봇/블로그 주인 + title TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + last_question_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX idx_ask_session_requester_created_at + ON ask_session (requester_user_id, created_at DESC); + CREATE INDEX idx_ask_session_owner_created_at + ON ask_session (owner_user_id, created_at DESC); + CREATE INDEX idx_ask_session_last_question_at + ON ask_session (last_question_at DESC NULLS LAST); + ``` + - `ask_message` 테이블 추가 (PostgreSQL) + ```sql + CREATE TABLE ask_message ( + id BIGSERIAL PRIMARY KEY, + session_id BIGINT NOT NULL REFERENCES ask_session(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), + content TEXT NOT NULL, + search_plan JSONB, + retrieval_meta JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX idx_ask_message_session_created_at + ON ask_message (session_id, created_at DESC, id DESC); + ``` + - 각 메시지의 소유권은 `ask_session`을 통해 추적되므로 `ask_message`에는 별도의 `requester_user_id`/`owner_user_id` 컬럼이 없다. + - `ask_message_embedding` 테이블 추가 (PostgreSQL, `pgvector` 필요) + ```sql + CREATE EXTENSION IF NOT EXISTS vector; + + CREATE TABLE ask_message_embedding ( + message_id BIGINT PRIMARY KEY REFERENCES ask_message(id) ON DELETE CASCADE, + owner_user_id TEXT NOT NULL, + requester_user_id TEXT NOT NULL, + category_id BIGINT, + post_id BIGINT, + answer_message_id BIGINT REFERENCES ask_message(id), + embedding vector(1536) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX idx_ask_message_embedding_owner ON ask_message_embedding (owner_user_id); + CREATE INDEX idx_ask_message_embedding_owner_category ON ask_message_embedding (owner_user_id, category_id); + CREATE INDEX idx_ask_message_embedding_owner_post ON ask_message_embedding (owner_user_id, post_id); + CREATE INDEX idx_ask_message_embedding_requester ON ask_message_embedding (requester_user_id); + CREATE INDEX idx_ask_message_embedding_vec ON ask_message_embedding USING ivfflat (embedding vector_cosine_ops); + ``` + - 벡터 차원(1536)은 현재 `text-embedding-3-small` 모델에 맞추며, 당장은 코드/마이그레이션 모두 하드코딩 값으로 유지한다. + - `owner_user_id`는 챗봇 주인 ID로, 블로그 콘텐츠 재임베딩 시 이 값을 기준으로 `ask_message_embedding` 레코드를 일괄 삭제한다. `requester_user_id`는 동일 사용자가 반복 질문할 때 캐시를 재사용하기 위한 용도로 유지한다. + - `category_id`와 `post_id`는 모두 NULL 허용 값이며, `/ai/ask` 요청 파라미터에 따라 한 쪽만 채워질 수도 있다. 이 값을 그대로 저장해 KNN 검색 시 SQL where 조건으로 필터링한다. + - 인덱스 설계 + - `ask_session`: `(requester_user_id, created_at DESC)`, `(owner_user_id, created_at DESC)`, `last_question_at DESC` (최근 세션/대상별 리스트용). + - `ask_message`: `(session_id, created_at DESC, id DESC)` 커버링 인덱스만 유지해 세션 단위 페이지네이션을 지원. + - 마이그레이션/롤백 스크립트 작성 후 `docs/migrations`에 설명 추가. + +2. **Repository/Service 계층 추가** + - `src/repositories/ask-session.repository.ts`(세션 CRUD, 최신 메시지 조회). + - `src/repositories/ask-message.repository.ts`(메시지 insert/조회). + - 트랜잭션 지원이 필요하면 `db.ts` 활용해 래퍼 제공. + +3. **세션 식별/제목 로직** + - `askV2Handler`에서 `session_id` 파라미터 읽기. 없으면 세션을 생성하고 첫 사용자 질문을 제목으로 삼아 저장 → SSE `session` 이벤트로 ID 및 제목 전달. + - `PATCH /ai/v2/sessions/:id`에서 제목, metadata를 수정할 수 있도록 repository/서비스 레이어 포함(보관/복구 없이 항상 실제 삭제). + - 사용자 검증: 전달된 세션이 JWT에서 파생된 `requester_user_id`와 일치하는지 검사 후 진행. + +4. **대화 히스토리 로드** + - `answerStreamV2` 시작 시 최근 메시지 N개 조회 (정책: 최신 2개 turn만 사용). + - LLM 메시지 배열 구성 시 `qaPrompts`에 히스토리를 prepend(역순 정렬 주의). 별도 토큰 계산 없이 2개 turn을 그대로 포함하고, 보다 엄격한 한도가 필요해지면 `utils/tokenizer` 기반 토큰 검사 추가. + +5. **검색 계획과 히스토리 연결** + - Follow-up 질문 식별을 위해 `buildSearchPlanPrompt`에 “이전 대화” 섹션 옵션 추가. + - 하이브리드 검색은 현재 질문과 재작성으로 수행하되, 필요 시 세션의 마지막 답변 제목 등을 참고하도록 확장 가능. + +6. **SSE 전송 구조 업데이트** + - 세션 생성 시 `event: session` → `{ session_id, owner_user_id, requester_user_id }`를 한 번 송신하고 응답 헤더(`session-id`)도 함께 세팅한다. + - 스트림 완료 시 `event: session_saved` → `{ session_id, owner_user_id, cached: boolean }`, 실패 시 `event: session_error` → `{ session_id, owner_user_id, reason }`를 전송한다. + - 나머지 이벤트(`search_plan`, `search_result`, `context`, `answer`, `rewrite`, `keywords`, ...)의 payload 구조는 기존과 동일하며 문서만 보강한다. + +7. **메시지 & 임베딩 저장 파이프라인** + - 질문이 유입되면 즉시 `createEmbeddings([question])`로 질문 벡터를 생성하고, 중복 질문 KNN 검사/검색 플로우/최종 저장까지 같은 벡터를 재사용한다(v1 RAG, v1 단일 포스트, v2 하이브리드 공통). + - SSE가 정상 종료될 때까지 사용자 질문, 검색 계획 요약, 요청 범위(`category_id`/`post_id`), 세션 ID, 생성된 임베딩, 최종 답변 텍스트를 메모리(또는 임시 버퍼)에 보관한다. 구현은 스트리밍을 지연시키지 않도록 하고, `answerStream`/`answerStreamV2`에서 LLM 청크를 즉시 클라이언트로 흘리되 동시에 `bufferedAnswer += chunkString` 형태로 누적 문자열을 유지하는 간단한 메모리 버퍼를 둔다. + - 스트림 종료 직전에 단일 트랜잭션을 열어 `ask_message`(user) → `ask_message`(assistant) → `ask_message_embedding` 순으로 한 번에 INSERT/UPDATE를 수행해 트랜잭션 시간을 최소화한다. v1 플로우에서는 검색 계획 관련 필드가 비어 있을 수 있으므로 NULL 허용/기본값 로직을 통일한다. + - 검색/하이브리드 파이프라인에서 이미 생성한 “질문 원문” 임베딩을 재사용해 `ask_message_embedding`을 저장하고, 같은 트랜잭션에서 `answer_message_id`를 세팅한다. `/ai/ask`에서 `post_id`가 지정된 단일 포스트 질문도 동일한 세션/캐시 흐름에 참여시키기 위해 질문 원문을 별도로 임베딩해 저장한다(내부 `createEmbeddings` 호출 재사용). + - 사용자 블로그 콘텐츠가 재임베딩되면 이전 답변의 근거가 달라질 수 있으므로, 임베딩 워커에서 특정 블로그 주인(=owner)의 포스트 임베딩을 재계산한 직후 `ask_message_embedding` 테이블에서 해당 `owner_user_id`의 모든 레코드를 일괄 삭제한다(트랜잭션 고려, 삭제 실패 시 로그만 남기고 본 작업은 계속). `queue-consumer`는 `findPostById`로 이미 포스트와 소유자 정보를 조회하므로 이를 활용하고, 삭제 실패는 try/catch로 감싼다. + - 스트림 도중 에러가 발생하거나 클라이언트가 연결을 끊으면 해당 질문/답변/임베딩을 모두 버리고 트랜잭션을 열지 않는다(불완전 대화는 저장하지 않음). 저장 중 오류 발생 시에도 트랜잭션을 롤백하고 스트림에는 영향을 주지 않으며, 경고 로그와 메트릭을 남겨 재시도/모니터링한다. + +8. **중복 질문 선별 & 재사용** + - 새 질문 수신 시 `ask_message_embedding`에서 동일 사용자 범위로 KNN 검색을 수행하고, `category_id`/`post_id` 컬럼을 이용해 현재 요청과 동일한 범위만 후보로 제한한다(예: `post_id`가 존재하면 해당 값 동일 조건, 없으면 `category_id` 비교). + ```sql + SELECT message_id, answer_message_id, 1 - (embedding <=> $1) AS similarity + FROM ask_message_embedding ame + WHERE ame.owner_user_id = $2 + AND ame.requester_user_id = $3 + AND ( + ($4::bigint IS NOT NULL AND ame.post_id = $4) + OR + ($4::bigint IS NULL AND ame.post_id IS NULL AND ame.category_id IS NOT DISTINCT FROM $5::bigint) + ) + ORDER BY ame.embedding <-> $1 + LIMIT 3; + ``` + - 상위 후보의 similarity(코사인 기준)를 계산하여 0.92~0.95 이상이고 필터 조건이 완전히 일치할 때 동일 질문으로 간주하고, 이미 저장된 `search_plan`/`retrieval_meta`/`answer` 스냅샷을 이용해 최초 질의와 동일한 이벤트 시퀀스(`search_plan` → `search_result`/`context` → `answer` → `session_saved`)를 그대로 재생한다. + - `/ai/ask`와 `/ai/v2/ask` 모두 요청 본문의 `post_id`가 있으면 임베딩 레코드의 `post_id`를 채우고, 없으면 `category_id`를 채운다(두 값이 모두 없으면 NULL). 이 설계를 기반으로 KNN 검색 시 `WHERE owner_user_id = $owner AND requester_user_id = $requester AND post_id IS NOT DISTINCT FROM $postId AND category_id IS NOT DISTINCT FROM $categoryId` 조건을 적용한다. + - 임계값 미달 또는 저장된 응답이 없을 경우 기존 검색/LLM 플로우로 진행. + +9. **테스트 & 관측성** + - 리포지토리 단위 테스트: 세션 생성, 메시지 삽입/조회, 임베딩 저장/업데이트. + - 서비스 통합 테스트: 세션 없는 요청 → 생성 확인, 기존 세션 요청 → 히스토리 포함 프롬프트 검증, 유사 질문 반복 시 캐시된 답변 반환 확인. + - 디버그 로그에 `session_id`와 재사용 여부(`qa_cached: true/false`) 추가해 추적성 확보. + +10. **문서 & 마이그레이션 가이드** + - `docs/history-tasks`에 세션 도입 배경/사용법 기록. + - 운영 배포 시 주의사항(마이그레이션 순서, 롤백 절차, `pgvector` 설치) 설명. + +## 4. 무한 스크롤 메시지 API 상세 +- **엔드포인트**: `GET /ai/v2/sessions/:sessionId/messages` +- **쿼리 파라미터** + - `cursor?: string` → `created_at` ISO 문자열과 `id` 조합을 Base64로 인코딩 (`${created_at}|${id}`)해 전달. + - `direction?: 'backward' | 'forward'` → 무한 스크롤 UX에 맞춰 과거(`backward`, default) 또는 이후(`forward`) 로딩 지원. + - `limit?: number` → 기본 20, 최대 50. +- **응답** + ```json + { + "session_id": "123", + "messages": [ + { + "id": "456", + "role": "user", + "content": "...", + "created_at": "2025-01-19T10:05:12.123Z", + "search_plan": {...}, + "retrieval_meta": {...} + } + ], + "paging": { + "direction": "backward", + "has_more": true, + "next_cursor": "MjAyNS0wMS0xOVQxMDowNToxMi4xMjNa|456" + } + } + ``` +- **PostgreSQL 조회 예시** + ```sql + WITH cursor_values AS ( + SELECT + (split_part($1, '|', 1))::timestamptz AS cursor_created_at, + (split_part($1, '|', 2))::bigint AS cursor_id + ) + SELECT * + FROM ask_message am + WHERE am.session_id = $2 + AND ( + $3 = 'forward' AND ( + am.created_at > (SELECT cursor_created_at FROM cursor_values) OR + (am.created_at = (SELECT cursor_created_at FROM cursor_values) AND am.id > (SELECT cursor_id FROM cursor_values)) + ) + OR + $3 <> 'forward' AND ( + am.created_at < (SELECT cursor_created_at FROM cursor_values) OR + (am.created_at = (SELECT cursor_created_at FROM cursor_values) AND am.id < (SELECT cursor_id FROM cursor_values)) + ) + OR $1 IS NULL + ) + ORDER BY + CASE WHEN $3 = 'forward' THEN am.created_at END ASC, + CASE WHEN $3 <> 'forward' THEN am.created_at END DESC, + am.id DESC + LIMIT $4; + ``` +- **서버 로직** + 1. `authMiddleware`에서 파생된 사용자 ID와 세션 소유권을 비교. + 2. `cursor` 미전달 시 최신 메시지를 기준으로 `backward` 모드 페이징. + 3. 응답 `messages`는 API 레이어에서 시간순 정렬(무한 스크롤 라이브러리 요구사항에 맞춰 전/후 정렬 선택). + 4. `has_more`는 조회 개수가 `limit`와 같을 때 true, `next_cursor`는 목록의 마지막 항목에서 생성. + 5. 대화가 비어 있으면 빈 배열과 `has_more: false` 반환. + +## 5. API 추가/변경 사항 + +### 5.1 `/ai/v1/ask`, `/ai/v2/ask` +- **Request Body (공통)** + ```json + { + "question": "string", + "user_id": "owner_user_id", // 챗봇/블로그 주인 (새 세션 생성 시 필수) + "session_id": "optional string", // 기존 세션 ID, 없으면 서버가 새로 생성 + "category_id": 123, // optional, follow-up 필터 + "post_id": 456, // optional, 단일 포스트 질문 + "speech_tone": -1, + "llm": { ... } // existing override 구조 + } + ``` +- **동작** + - 컨트롤러는 JWT의 `user_id` 클레임을 `requester_user_id`로 사용하고 Body의 `user_id`를 `owner_user_id`로 매핑한다. + - `session_id`가 없으면 `owner_user_id`가 반드시 포함되어야 하며 서버가 즉시 새로운 세션을 생성한다. `session_id`가 전달되면 `owner_user_id`는 optional이지만, 전달된 경우 세션이 가리키는 owner와 일치해야 한다. + - 신규 세션 생성 시 SSE 첫 이벤트(`event: session`)와 응답 헤더(`session-id`)에 ID를 반환하고, 캐시 저장 성공/실패 여부는 별도 이벤트로 통지한다. +- **Response / SSE 계약** + - 기존 `search_plan`/`search_result`/`answer` 등 이벤트 payload 형식은 변경하지 않는다. + - 신규 세션이 만들어졌을 때만 `event: session` → `{ session_id, owner_user_id, requester_user_id }`를 전송한다. + - 스트림 종료 시 히스토리 영속화/캐시 여부를 알려주는 `event: session_saved` 또는 오류 시 `event: session_error`를 송신한다. + +### 5.2 REST 세션 API + +| Endpoint | Method | 목적 | +|----------|--------|------| +| `/ai/v2/sessions` | GET | 요청자 기준 세션 목록 조회 | +| `/ai/v2/sessions/:id` | GET | 단일 세션 메타 조회(옵션) | +| `/ai/v2/sessions/:id/messages` | GET | 세션 메시지 히스토리 페이지네이션 | +| `/ai/v2/sessions/:id` | PATCH | 세션 제목/메타데이터 수정 | +| `/ai/v2/sessions/:id` | DELETE | 세션 및 메시지 삭제 | + +> 세션 생성은 `/ai/(v1|v2)/ask` 호출에서 `session_id`가 없거나 null일 때만 허용되므로 별도의 POST 엔드포인트는 제공하지 않는다. + +#### GET `/ai/v2/sessions` +- **Query Params** + - `limit` (default 20, max 50) + - `cursor` (optional, Base64 `${created_at}|${id}`) + - `owner_user_id` (optional) → 특정 챗봇만 필터링 +- **Response 200** + ```json + { + "sessions": [ + { + "session_id": "123", + "owner_user_id": "user-abc", + "requester_user_id": "req-xyz", + "title": "first question", + "metadata": {}, + "last_question_at": "2025-01-19T10:05:12.123Z", + "message_count": 4 + } + ], + "paging": { + "cursor": "base64", + "has_more": true + } + } + ``` +- **Behaviour**: 항상 requester 기준으로만 조회, owner 필터가 있으면 `owner_user_id = $owner` 조건 추가. + +#### GET `/ai/v2/sessions/:id` +- **Purpose**: 단일 세션 메타데이터를 조회해 클라이언트가 세션 헤더를 갱신할 때 사용. +- **Response 200** + ```json + { + "session_id": "123", + "owner_user_id": "user-abc", + "requester_user_id": "req-xyz", + "title": "first question", + "metadata": {}, + "created_at": "...", + "updated_at": "...", + "last_question_at": "...", + "message_count": 4 + } + ``` +- 세션이 requester에게 속하지 않으면 404 (존재 은닉). + +#### GET `/ai/v2/sessions/:id/messages` +- 이미 4장에서 상세히 정의된 무한 스크롤 스펙 사용. +- **Response**: `session_id`, `owner_user_id`, `messages`, `paging`. 각 메시지는 `{ id, role, content, search_plan, retrieval_meta, created_at }`. + +#### PATCH `/ai/v2/sessions/:id` +- **Body** + ```json + { + "title": "optional string", + "metadata": { ... } // optional JSON object + } + ``` +- **Validation**: `owner_user_id`는 수정 불가. Metadata는 JSON object만 허용(primitive/array 거부). 빈 요청이면 400. +- **Response 200** + ```json + { + "session_id": "123", + "owner_user_id": "user-abc", + "title": "updated title", + "metadata": { "topic": "infra" }, + "updated_at": "..." + } + ``` + +#### DELETE `/ai/v2/sessions/:id` +- **Response 200** + ```json + { + "session_id": "123", + "deleted": true + } + ``` +- 삭제 시 `ask_message`/`ask_message_embedding`가 ON DELETE CASCADE로 정리되므로, 워커나 캐시와의 동기화는 별도 훅 없이 로그로만 남긴다. + +### 5.3 추가 고려 사항 +- API 응답 스키마를 `docs/history-tasks` 혹은 OpenAPI 스펙에 반영. +- 모든 세션 관련 REST 엔드포인트는 `authMiddleware`로 `requester_user_id`를 추출하며, 본문에서 이 값을 받지 않는다. `owner_user_id`는 세션 생성 시 결정되며 이후 PATCH에서도 수정할 수 없다(필요 시 세션 삭제 후 재생성). +- 모바일/웹 클라이언트가 SSE 없이 REST만으로도 히스토리를 로드할 수 있도록 설계하고, SSE가 종료된 뒤 REST 히스토리와 일관성 있게 동작하도록 `session_saved`/`session_error` 이벤트를 표준화한다. + +## 6. 추후 확장 아이디어 +- 세션 제목 자동 생성(첫 질문 혹은 LLM 요약 활용). +- 메시지 요약/압축 작업을 위한 비동기 워커 도입. +- 세션 검색 UI 제공을 위한 인덱싱(예: pg_trgm) 적용. +- 멀티 디바이스 동기화를 위한 마지막 읽은 위치(last_seen_at) 관리. + +## 7. 커밋 단위 구현 계획 +1. **chore: add ask session/message schemas** + - `ask_session`, `ask_message`, `ask_message_embedding` 테이블과 관련 인덱스/확장(pgvector, GIN, IVFFlat) 마이그레이션 추가. + - `docs/migrations/README.md`에 적용/롤백 방법과 의존성(pgvector 설치 등) 문서화. +2. **chore: db helpers & config prep** + - `db.ts`에 트랜잭션 헬퍼/유틸 추가, 공통 PG 타입 정의. + - Lint/tsconfig가 신규 파일을 해석하도록 배치하고, 최소 테스트 러너(예: Jest 스켈레톤) 추가. +3. **feat: session repositories** + - `ask-session.repository.ts`, `ask-message.repository.ts`, `ask-message-embedding.repository.ts` 작성. + - 커서 기반 조회, 세션 소유권 검사, 임베딩 insert/upsert 로직 포함. +4. **feat: session REST APIs** + - `/ai/v2/sessions` 목록/단일 조회, `/ai/v2/sessions/:id/messages` 무한 스크롤, `/ai/v2/sessions/:id` PATCH/DELETE 라우터/컨트롤러 + 요청 스키마 추가. + - JWT 파생 사용자 ID를 전제로 하고, 응답/문서 업데이트 포함. (POST 엔드포인트는 없음) +5. **feat: ask endpoints auth & session plumbing** + - `/ai/ask`, `/ai/v2/ask`에서 바디 `user_id`와 JWT `requester_id`를 명시적으로 구분. + - `session_id`가 없거나 null일 때만 새 세션을 만들고, 존재하면 해당 세션/owner 일관성을 검증한다. + - `session_id` 파라미터 처리, SSE `session` 이벤트/헤더 전송, 질문 범위(`category_id`/`post_id`)와 `owner_user_id` 파생 로직 도입. +6. **feat: history hydration & persistence** + - `answerStream`/`answerStreamV2`에서 최신 2턴 히스토리를 로드해 프롬프트에 prepend. + - SSE 청크를 즉시 전송하면서 메모리 버퍼에 누적하고, 스트림 종료 시 질문/답변/임베딩을 단일 트랜잭션으로 저장. +7. **feat: duplicate question cache & embeddings** + - 질문 벡터 생성·재사용, `ask_message_embedding` KNN 조회, `category_id`/`post_id` 동일 시 기존 답변 재생산. + - 캐시 적중 시 저장된 `search_plan`/`retrieval_meta`/`answer`를 사용해 기존 이벤트 시퀀스를 그대로 재생하고, 미적중 시 새 임베딩/답변을 저장해 캐시 여부를 로그로 노출. +8. **feat: embedding worker invalidation** + - `queue-consumer.ts`에서 동일 사용자 포스트 재임베딩 시 해당 `ask_message_embedding` 레코드 일괄 삭제. + - 실패 시 재시도/로그 처리, 단위 테스트 포함. +9. **docs/test: usage and coverage** + - `docs/history-tasks` 및 README에 세션 흐름/SSE 이벤트 기록. + - 통합 테스트: 세션 생성→질문→히스토리 페이징, 캐시 적중, 롤백 경로 등을 검증. From 04f3301f43adf3339f675a36af3368df788a3a30 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 00:14:08 +0900 Subject: [PATCH 02/21] =?UTF-8?q?=F0=9F=93=9D=20=EB=94=94=EB=B9=84=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2025-02-ask-session-history.sql | 71 +++++++++++++++++++ docs/migrations/README.md | 17 +++++ 2 files changed, 88 insertions(+) create mode 100644 docs/migrations/2025-02-ask-session-history.sql diff --git a/docs/migrations/2025-02-ask-session-history.sql b/docs/migrations/2025-02-ask-session-history.sql new file mode 100644 index 0000000..2f64a7a --- /dev/null +++ b/docs/migrations/2025-02-ask-session-history.sql @@ -0,0 +1,71 @@ +BEGIN; + +-- Ensure pgvector extension is available for embedding storage +CREATE EXTENSION IF NOT EXISTS vector; + +-- Persist ASK session metadata +CREATE TABLE IF NOT EXISTS ask_session ( + id BIGSERIAL PRIMARY KEY, + requester_user_id TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + title TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + last_question_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_ask_session_requester_created_at + ON ask_session (requester_user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_ask_session_owner_created_at + ON ask_session (owner_user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_ask_session_last_question_at + ON ask_session (last_question_at DESC NULLS LAST); + +-- Persist individual ASK messages (user + assistant turns) +CREATE TABLE IF NOT EXISTS ask_message ( + id BIGSERIAL PRIMARY KEY, + session_id BIGINT NOT NULL REFERENCES ask_session(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), + content TEXT NOT NULL, + search_plan JSONB, + retrieval_meta JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_ask_message_session_created_at + ON ask_message (session_id, created_at DESC, id DESC); + +-- Store embeddings for dedupe/cache checks +CREATE TABLE IF NOT EXISTS ask_message_embedding ( + message_id BIGINT PRIMARY KEY REFERENCES ask_message(id) ON DELETE CASCADE, + owner_user_id TEXT NOT NULL, + requester_user_id TEXT NOT NULL, + category_id BIGINT, + post_id BIGINT, + answer_message_id BIGINT REFERENCES ask_message(id), + embedding VECTOR(1536) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_ask_message_embedding_owner + ON ask_message_embedding (owner_user_id); + +CREATE INDEX IF NOT EXISTS idx_ask_message_embedding_owner_category + ON ask_message_embedding (owner_user_id, category_id); + +CREATE INDEX IF NOT EXISTS idx_ask_message_embedding_owner_post + ON ask_message_embedding (owner_user_id, post_id); + +CREATE INDEX IF NOT EXISTS idx_ask_message_embedding_requester + ON ask_message_embedding (requester_user_id); + +-- IVF FLAT index for similarity search (requires ANALYZE after large inserts) +CREATE INDEX IF NOT EXISTS idx_ask_message_embedding_vec + ON ask_message_embedding + USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +COMMIT; diff --git a/docs/migrations/README.md b/docs/migrations/README.md index 3f4de87..edf66fb 100644 --- a/docs/migrations/README.md +++ b/docs/migrations/README.md @@ -25,3 +25,20 @@ Notes: - Indexes increase disk usage and write overhead; create only on columns used for text search. - The extension must be installed once per database. +## Create ASK session/message tables + +File: `2025-02-ask-session-history.sql` + +Purpose: +- Create `ask_session`, `ask_message`, and `ask_message_embedding` tables with the indexes needed for session history APIs. +- Ensure the `vector` extension is enabled so question embeddings can be stored for duplicate-detection. + +Run: + +```bash +psql "$DATABASE_URL" -f docs/migrations/2025-02-ask-session-history.sql +``` + +Notes: +- The IVFFlat index requires a populated table before it becomes efficient; run `ANALYZE ask_message_embedding;` after bulk loading data. +- `ask_message_embedding` references `ask_message`, so dropping the session tables will cascade to embeddings automatically. From 9f5144b1139d9a7db96c46d1d752b8892a48b4be Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 00:42:30 +0900 Subject: [PATCH 03/21] =?UTF-8?q?=F0=9F=94=A7=20db=20=ED=97=AC=ED=8D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/db.ts | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/utils/db.ts b/src/utils/db.ts index cc73577..fab4fdf 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -1,11 +1,42 @@ -import { Pool } from 'pg'; +import { Pool, PoolClient, QueryResult } from 'pg'; import config from '../config'; const pool = new Pool({ connectionString: config.DATABASE_URL, }); -// 재사용 가능한 PG 풀 인스턴스를 반환 -export const getDb = () => { - return pool; +export type DbPool = Pool; +export type DbClient = PoolClient; +export type QueryExecutor = Pick | Pick; + +export const getDb = (): DbPool => pool; + +/** + * Runs a parametrized query using either the shared pool or a provided client. + */ +export const runQuery = async ( + sql: string, + params: unknown[] = [], + executor?: QueryExecutor +): Promise> => { + const target = executor ?? pool; + return target.query(sql, params); +}; + +/** + * Wraps a callback in a BEGIN/COMMIT transaction. + */ +export const withTransaction = async (callback: (client: PoolClient) => Promise): Promise => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } }; From 561108ace1137a25c1f3ffcaa567ba17e01c6763 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 00:46:54 +0900 Subject: [PATCH 04/21] =?UTF-8?q?=E2=9C=A8=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ask-message-embedding.repository.ts | 159 +++++++++++++ src/repositories/ask-message.repository.ts | 148 ++++++++++++ src/repositories/ask-session.repository.ts | 215 ++++++++++++++++++ src/utils/db.ts | 4 +- 4 files changed, 524 insertions(+), 2 deletions(-) create mode 100644 src/repositories/ask-message-embedding.repository.ts create mode 100644 src/repositories/ask-message.repository.ts create mode 100644 src/repositories/ask-session.repository.ts diff --git a/src/repositories/ask-message-embedding.repository.ts b/src/repositories/ask-message-embedding.repository.ts new file mode 100644 index 0000000..0ac512c --- /dev/null +++ b/src/repositories/ask-message-embedding.repository.ts @@ -0,0 +1,159 @@ +import pgvector from 'pgvector/pg'; +import type { QueryExecutor } from '../utils/db'; +import { runQuery } from '../utils/db'; + +export interface MessageEmbedding { + messageId: number; + ownerUserId: string; + requesterUserId: string; + categoryId: number | null; + postId: number | null; + answerMessageId: number | null; + createdAt: Date; + updatedAt: Date; +} + +type EmbeddingRow = { + messageId: number; + ownerUserId: string; + requesterUserId: string; + categoryId: number | null; + postId: number | null; + answerMessageId: number | null; + createdAt: Date; + updatedAt: Date; +}; + +const baseSelect = ` + SELECT + message_id AS "messageId", + owner_user_id AS "ownerUserId", + requester_user_id AS "requesterUserId", + category_id AS "categoryId", + post_id AS "postId", + answer_message_id AS "answerMessageId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM ask_message_embedding +`; + +const mapRow = (row: EmbeddingRow): MessageEmbedding => ({ ...row }); + +export const upsertEmbedding = async ( + params: { + messageId: number; + ownerUserId: string; + requesterUserId: string; + embedding: number[]; + categoryId?: number | null; + postId?: number | null; + answerMessageId?: number | null; + }, + executor?: QueryExecutor +): Promise => { + const result = await runQuery( + ` + INSERT INTO ask_message_embedding ( + message_id, + owner_user_id, + requester_user_id, + category_id, + post_id, + answer_message_id, + embedding + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (message_id) DO UPDATE + SET + category_id = EXCLUDED.category_id, + post_id = EXCLUDED.post_id, + answer_message_id = EXCLUDED.answer_message_id, + embedding = EXCLUDED.embedding, + updated_at = now() + RETURNING + message_id AS "messageId", + owner_user_id AS "ownerUserId", + requester_user_id AS "requesterUserId", + category_id AS "categoryId", + post_id AS "postId", + answer_message_id AS "answerMessageId", + created_at AS "createdAt", + updated_at AS "updatedAt" + `, + [ + params.messageId, + params.ownerUserId, + params.requesterUserId, + params.categoryId ?? null, + params.postId ?? null, + params.answerMessageId ?? null, + pgvector.toSql(params.embedding), + ], + executor + ); + + return mapRow(result.rows[0]); +}; + +export interface SimilarMessage { + messageId: number; + answerMessageId: number | null; + similarity: number; +} + +export interface SimilarSearchParams { + ownerUserId: string; + requesterUserId: string; + embedding: number[]; + postId?: number | null; + categoryId?: number | null; + limit?: number; +} + +export const findSimilarEmbeddings = async ({ + ownerUserId, + requesterUserId, + embedding, + postId, + categoryId, + limit = 3, +}: SimilarSearchParams): Promise => { + const filters = ['owner_user_id = $2', 'requester_user_id = $3']; + const values: unknown[] = [pgvector.toSql(embedding), ownerUserId, requesterUserId]; + + if (postId != null) { + values.push(postId); + filters.push('post_id = $' + values.length); + } else { + filters.push('post_id IS NULL'); + } + + if (postId == null) { + values.push(categoryId ?? null); + filters.push('category_id IS NOT DISTINCT FROM $' + values.length); + } + + values.push(limit); + const limitIdx = values.length; + + const result = await runQuery( + ` + SELECT + message_id AS "messageId", + answer_message_id AS "answerMessageId", + 1 - (embedding <=> $1) AS similarity + FROM ask_message_embedding + WHERE ${filters.join(' AND ')} + ORDER BY embedding <-> $1 + LIMIT $${limitIdx} + `, + values + ); + + return result.rows; +}; + +export const deleteEmbeddingsByOwner = async (ownerUserId: string): Promise => { + const result = await runQuery('DELETE FROM ask_message_embedding WHERE owner_user_id = $1', [ownerUserId]); + return result.rowCount ?? 0; +}; diff --git a/src/repositories/ask-message.repository.ts b/src/repositories/ask-message.repository.ts new file mode 100644 index 0000000..1dcfb26 --- /dev/null +++ b/src/repositories/ask-message.repository.ts @@ -0,0 +1,148 @@ +import type { QueryExecutor } from '../utils/db'; +import { runQuery } from '../utils/db'; + +export type MessageRole = 'user' | 'assistant'; + +export interface AskMessage { + id: number; + sessionId: number; + role: MessageRole; + content: string; + searchPlan: Record | null; + retrievalMeta: Record | null; + createdAt: Date; +} + +type MessageRow = { + id: number; + sessionId: number; + role: MessageRole; + content: string; + searchPlan: Record | null; + retrievalMeta: Record | null; + createdAt: Date; +}; + +const baseSelect = ` + SELECT + id, + session_id AS "sessionId", + role, + content, + search_plan AS "searchPlan", + retrieval_meta AS "retrievalMeta", + created_at AS "createdAt" + FROM ask_message +`; + +const mapMessage = (row: MessageRow): AskMessage => ({ + ...row, + searchPlan: row.searchPlan ?? null, + retrievalMeta: row.retrievalMeta ?? null, +}); + +export const insertMessage = async ( + params: { + sessionId: number; + role: MessageRole; + content: string; + searchPlan?: Record | null; + retrievalMeta?: Record | null; + }, + executor?: QueryExecutor +): Promise => { + const result = await runQuery( + ` + INSERT INTO ask_message (session_id, role, content, search_plan, retrieval_meta) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id, + session_id AS "sessionId", + role, + content, + search_plan AS "searchPlan", + retrieval_meta AS "retrievalMeta", + created_at AS "createdAt" + `, + [params.sessionId, params.role, params.content, params.searchPlan ?? null, params.retrievalMeta ?? null], + executor + ); + + return mapMessage(result.rows[0]); +}; + +export const getMessageById = async (messageId: number): Promise => { + const result = await runQuery(`${baseSelect} WHERE id = $1`, [messageId]); + if (!result.rowCount) return null; + return mapMessage(result.rows[0]); +}; + +export const getLatestMessages = async ( + sessionId: number, + limit = 4, + executor?: QueryExecutor +): Promise => { + const result = await runQuery( + `${baseSelect} WHERE session_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2`, + [sessionId, limit], + executor + ); + return result.rows.map(mapMessage).reverse(); +}; + +export type MessageDirection = 'forward' | 'backward'; + +export interface FetchMessagesParams { + sessionId: number; + limit: number; + direction?: MessageDirection; + cursor?: { createdAt: Date; id: number }; +} + +export const getMessagesBySession = async ({ + sessionId, + limit, + direction = 'backward', + cursor, +}: FetchMessagesParams): Promise => { + const predicates = ['session_id = $1']; + const values: unknown[] = [sessionId]; + + if (cursor) { + values.push(cursor.createdAt, cursor.id); + const cursorCreatedIdx = values.length - 1; + const cursorIdIdx = values.length; + + if (direction === 'forward') { + predicates.push( + `(created_at > $${cursorCreatedIdx} OR (created_at = $${cursorCreatedIdx} AND id > $${cursorIdIdx}))` + ); + } else { + predicates.push( + `(created_at < $${cursorCreatedIdx} OR (created_at = $${cursorCreatedIdx} AND id < $${cursorIdIdx}))` + ); + } + } + + values.push(limit); + const limitIdx = values.length; + + const orderClause = + direction === 'forward' + ? 'ORDER BY created_at ASC, id ASC' + : 'ORDER BY created_at DESC, id DESC'; + + const result = await runQuery( + `${baseSelect} WHERE ${predicates.join(' AND ')} ${orderClause} LIMIT $${limitIdx}`, + values + ); + + return result.rows.map(mapMessage); +}; + +export const countMessagesForSession = async (sessionId: number): Promise => { + const result = await runQuery<{ count: string }>('SELECT COUNT(*)::text AS count FROM ask_message WHERE session_id = $1', [ + sessionId, + ]); + return Number(result.rows[0]?.count ?? 0); +}; diff --git a/src/repositories/ask-session.repository.ts b/src/repositories/ask-session.repository.ts new file mode 100644 index 0000000..f742090 --- /dev/null +++ b/src/repositories/ask-session.repository.ts @@ -0,0 +1,215 @@ +import { runQuery } from '../utils/db'; + +export type JsonMap = Record; + +export interface AskSession { + id: number; + requesterUserId: string; + ownerUserId: string; + title: string | null; + metadata: JsonMap; + lastQuestionAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface AskSessionSummary extends AskSession { + messageCount: number; +} + +type AskSessionRow = { + id: number; + requesterUserId: string; + ownerUserId: string; + title: string | null; + metadata: JsonMap | null; + lastQuestionAt: Date | null; + createdAt: Date; + updatedAt: Date; + messageCount?: number; +}; + +const baseSelect = ` + SELECT + s.id, + s.requester_user_id AS "requesterUserId", + s.owner_user_id AS "ownerUserId", + s.title, + s.metadata, + s.last_question_at AS "lastQuestionAt", + s.created_at AS "createdAt", + s.updated_at AS "updatedAt" + FROM ask_session s +`; + +const mapRow = (row: T): T & { metadata: JsonMap } => ({ + ...row, + metadata: row.metadata ?? {}, +}); + +export const createSession = async (params: { + requesterUserId: string; + ownerUserId: string; + title?: string | null; + metadata?: JsonMap; +}): Promise => { + const result = await runQuery( + ` + INSERT INTO ask_session (requester_user_id, owner_user_id, title, metadata) + VALUES ($1, $2, $3, COALESCE($4::jsonb, '{}'::jsonb)) + RETURNING + id, + requester_user_id AS "requesterUserId", + owner_user_id AS "ownerUserId", + title, + metadata, + last_question_at AS "lastQuestionAt", + created_at AS "createdAt", + updated_at AS "updatedAt" + `, + [params.requesterUserId, params.ownerUserId, params.title ?? null, JSON.stringify(params.metadata ?? {})] + ); + + return mapRow(result.rows[0]); +}; + +export const findSessionById = async (sessionId: number): Promise => { + const result = await runQuery(`${baseSelect} WHERE s.id = $1`, [sessionId]); + if (result.rows.length === 0) return null; + return mapRow(result.rows[0]); +}; + +export const findSessionForRequester = async ( + sessionId: number, + requesterUserId: string +): Promise => { + const result = await runQuery(`${baseSelect} WHERE s.id = $1 AND s.requester_user_id = $2`, [ + sessionId, + requesterUserId, + ]); + if (result.rows.length === 0) return null; + return mapRow(result.rows[0]); +}; + +export interface ListSessionsParams { + requesterUserId: string; + ownerUserId?: string; + cursorCreatedAt?: Date; + cursorId?: number; + limit?: number; +} + +export const listSessionsForRequester = async ({ + requesterUserId, + ownerUserId, + cursorCreatedAt, + cursorId, + limit = 20, +}: ListSessionsParams): Promise => { + const conditions = ['s.requester_user_id = $1']; + const values: unknown[] = [requesterUserId]; + let paramIndex = values.length; + + if (ownerUserId) { + values.push(ownerUserId); + paramIndex += 1; + conditions.push(`s.owner_user_id = $${paramIndex}`); + } + + if (cursorCreatedAt && cursorId) { + values.push(cursorCreatedAt, cursorId); + const cursorCreatedIdx = values.length - 1; + const cursorIdIdx = values.length; + conditions.push( + `(s.created_at < $${cursorCreatedIdx} OR (s.created_at = $${cursorCreatedIdx} AND s.id < $${cursorIdIdx}))` + ); + } + + values.push(limit); + const limitIdx = values.length; + + const sql = ` + SELECT + s.id, + s.requester_user_id AS "requesterUserId", + s.owner_user_id AS "ownerUserId", + s.title, + s.metadata, + s.last_question_at AS "lastQuestionAt", + s.created_at AS "createdAt", + s.updated_at AS "updatedAt", + COALESCE(stats.message_count, 0)::int AS "messageCount" + FROM ask_session s + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS message_count + FROM ask_message m + WHERE m.session_id = s.id + ) AS stats ON true + WHERE ${conditions.join(' AND ')} + ORDER BY s.created_at DESC, s.id DESC + LIMIT $${limitIdx} + `; + + const result = await runQuery(sql, values); + return result.rows.map((row) => mapRow(row)); +}; + +export const updateSessionMeta = async ( + sessionId: number, + requesterUserId: string, + updates: { title?: string | null; metadata?: JsonMap } +): Promise => { + const sets: string[] = ['updated_at = now()']; + const values: unknown[] = []; + + if (updates.title !== undefined) { + values.push(updates.title ?? null); + sets.push(`title = $${values.length}`); + } + + if (updates.metadata !== undefined) { + values.push(JSON.stringify(updates.metadata ?? {})); + sets.push(`metadata = COALESCE($${values.length}::jsonb, '{}'::jsonb)`); + } + + if (sets.length === 1) { + return findSessionForRequester(sessionId, requesterUserId); + } + + values.push(sessionId, requesterUserId); + const sessionIdx = values.length - 1; + const requesterIdx = values.length; + + const result = await runQuery( + ` + UPDATE ask_session + SET ${sets.join(', ')} + WHERE id = $${sessionIdx} AND requester_user_id = $${requesterIdx} + RETURNING + id, + requester_user_id AS "requesterUserId", + owner_user_id AS "ownerUserId", + title, + metadata, + last_question_at AS "lastQuestionAt", + created_at AS "createdAt", + updated_at AS "updatedAt" + `, + values + ); + + if (result.rows.length === 0) return null; + return mapRow(result.rows[0]); +}; + +export const touchSessionLastQuestion = async (sessionId: number): Promise => { + await runQuery('UPDATE ask_session SET last_question_at = now(), updated_at = now() WHERE id = $1', [sessionId]); +}; + +export const deleteSession = async (sessionId: number, requesterUserId: string): Promise => { + const result = await runQuery('DELETE FROM ask_session WHERE id = $1 AND requester_user_id = $2', [ + sessionId, + requesterUserId, + ]); + return (result.rowCount ?? 0) > 0; +}; diff --git a/src/utils/db.ts b/src/utils/db.ts index fab4fdf..292548a 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -1,4 +1,4 @@ -import { Pool, PoolClient, QueryResult } from 'pg'; +import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg'; import config from '../config'; const pool = new Pool({ @@ -14,7 +14,7 @@ export const getDb = (): DbPool => pool; /** * Runs a parametrized query using either the shared pool or a provided client. */ -export const runQuery = async ( +export const runQuery = async ( sql: string, params: unknown[] = [], executor?: QueryExecutor From bd6e98ec3abbb18f041c3276aa23a8eb88c857f2 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 00:59:50 +0900 Subject: [PATCH 05/21] =?UTF-8?q?=E2=9C=A8=20=EC=84=B8=EC=85=98=20api=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/session.controller.ts | 180 +++++++++++++++++++++ src/repositories/ask-message.repository.ts | 8 +- src/routes/ai.v2.routes.ts | 2 + src/routes/session.routes.ts | 21 +++ src/types/session.types.ts | 28 ++++ 5 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 src/controllers/session.controller.ts create mode 100644 src/routes/session.routes.ts create mode 100644 src/types/session.types.ts diff --git a/src/controllers/session.controller.ts b/src/controllers/session.controller.ts new file mode 100644 index 0000000..78c6f96 --- /dev/null +++ b/src/controllers/session.controller.ts @@ -0,0 +1,180 @@ +import { Request, Response } from 'express'; +import { AuthRequest } from '../middlewares/auth.middleware'; +import * as sessionRepository from '../repositories/ask-session.repository'; +import * as messageRepository from '../repositories/ask-message.repository'; +import { sessionListQuerySchema, sessionMessagesQuerySchema, sessionPatchSchema } from '../types/session.types'; + +const encodeCursor = (createdAt: Date, id: number): string => + Buffer.from(`${createdAt.toISOString()}|${id}`).toString('base64'); + +const decodeCursor = (cursor: string): { createdAt: Date; id: number } | null => { + try { + const decoded = Buffer.from(cursor, 'base64').toString('utf-8'); + const [iso, idStr] = decoded.split('|'); + if (!iso || !idStr) return null; + const createdAt = new Date(iso); + const id = Number(idStr); + if (Number.isNaN(createdAt.getTime()) || !Number.isFinite(id)) return null; + return { createdAt, id }; + } catch { + return null; + } +}; + +const resolveRequesterId = (req: AuthRequest): string | null => { + const user = req.user; + if (!user || typeof user !== 'object') return null; + const candidate = (user as Record).user_id ?? (user as Record).sub; + return typeof candidate === 'string' ? candidate : null; +}; + +const toSessionResponse = ( + session: sessionRepository.AskSession | sessionRepository.AskSessionSummary, + messageCountOverride?: number +) => ({ + session_id: session.id, + owner_user_id: session.ownerUserId, + requester_user_id: session.requesterUserId, + title: session.title, + metadata: session.metadata ?? {}, + last_question_at: session.lastQuestionAt ? session.lastQuestionAt.toISOString() : null, + created_at: session.createdAt.toISOString(), + updated_at: session.updatedAt.toISOString(), + message_count: + messageCountOverride ?? ('messageCount' in session ? session.messageCount : undefined), +}); + +const toMessageResponse = (message: messageRepository.AskMessage) => ({ + id: message.id, + role: message.role, + content: message.content, + search_plan: message.searchPlan, + retrieval_meta: message.retrievalMeta, + created_at: message.createdAt.toISOString(), +}); + +export const listSessionsHandler = async (req: AuthRequest, res: Response) => { + const requesterId = resolveRequesterId(req); + if (!requesterId) return res.status(401).json({ message: 'Unauthorized' }); + + const parse = sessionListQuerySchema.safeParse(req.query); + if (!parse.success) return res.status(400).json({ message: 'Invalid query', issues: parse.error.format() }); + const { limit, cursor, owner_user_id: ownerUserId } = parse.data; + + let cursorPayload: { createdAt: Date; id: number } | undefined; + if (cursor) { + const decoded = decodeCursor(cursor); + if (!decoded) return res.status(400).json({ message: 'Invalid cursor' }); + cursorPayload = decoded; + } + + const sessions = await sessionRepository.listSessionsForRequester({ + requesterUserId: requesterId, + ownerUserId, + cursorCreatedAt: cursorPayload?.createdAt, + cursorId: cursorPayload?.id, + limit, + }); + + const hasMore = sessions.length === limit; + const nextCursor = + hasMore && sessions.length + ? encodeCursor(sessions[sessions.length - 1].createdAt, sessions[sessions.length - 1].id) + : null; + + res.json({ + sessions: sessions.map((session) => toSessionResponse(session)), + paging: { cursor: nextCursor, has_more: hasMore }, + }); +}; + +export const getSessionHandler = async (req: AuthRequest, res: Response) => { + const requesterId = resolveRequesterId(req); + if (!requesterId) return res.status(401).json({ message: 'Unauthorized' }); + + const sessionId = Number(req.params.id); + if (!Number.isFinite(sessionId)) return res.status(400).json({ message: 'Invalid session id' }); + + const session = await sessionRepository.findSessionForRequester(sessionId, requesterId); + if (!session) return res.status(404).json({ message: 'Session not found' }); + + const messageCount = await messageRepository.countMessagesForSession(sessionId); + + res.json(toSessionResponse(session, messageCount)); +}; + +export const getSessionMessagesHandler = async (req: AuthRequest, res: Response) => { + const requesterId = resolveRequesterId(req); + if (!requesterId) return res.status(401).json({ message: 'Unauthorized' }); + + const sessionId = Number(req.params.id); + if (!Number.isFinite(sessionId)) return res.status(400).json({ message: 'Invalid session id' }); + + const session = await sessionRepository.findSessionForRequester(sessionId, requesterId); + if (!session) return res.status(404).json({ message: 'Session not found' }); + + const parse = sessionMessagesQuerySchema.safeParse(req.query); + if (!parse.success) return res.status(400).json({ message: 'Invalid query', issues: parse.error.format() }); + + const { limit, cursor, direction } = parse.data; + let cursorPayload: { createdAt: Date; id: number } | undefined; + if (cursor) { + const decoded = decodeCursor(cursor); + if (!decoded) return res.status(400).json({ message: 'Invalid cursor' }); + cursorPayload = decoded; + } + + const messages = await messageRepository.getMessagesBySession({ + sessionId, + limit, + direction, + cursor: cursorPayload ? { createdAt: cursorPayload.createdAt, id: cursorPayload.id } : undefined, + }); + + const hasMore = messages.length === limit; + const nextCursor = + hasMore && messages.length + ? encodeCursor(messages[messages.length - 1].createdAt, messages[messages.length - 1].id) + : null; + + res.json({ + session_id: session.id, + owner_user_id: session.ownerUserId, + requester_user_id: session.requesterUserId, + messages: messages.map(toMessageResponse), + paging: { direction, has_more: hasMore, next_cursor: nextCursor }, + }); +}; + +export const patchSessionHandler = async (req: AuthRequest, res: Response) => { + const requesterId = resolveRequesterId(req); + if (!requesterId) return res.status(401).json({ message: 'Unauthorized' }); + + const sessionId = Number(req.params.id); + if (!Number.isFinite(sessionId)) return res.status(400).json({ message: 'Invalid session id' }); + + const parse = sessionPatchSchema.safeParse(req.body); + if (!parse.success) return res.status(400).json({ message: 'Invalid body', issues: parse.error.format() }); + + const updates = parse.data; + if (!('title' in updates) && !('metadata' in updates)) + return res.status(400).json({ message: 'No fields to update' }); + + const updated = await sessionRepository.updateSessionMeta(sessionId, requesterId, updates); + if (!updated) return res.status(404).json({ message: 'Session not found' }); + + res.json(toSessionResponse(updated)); +}; + +export const deleteSessionHandler = async (req: AuthRequest, res: Response) => { + const requesterId = resolveRequesterId(req); + if (!requesterId) return res.status(401).json({ message: 'Unauthorized' }); + + const sessionId = Number(req.params.id); + if (!Number.isFinite(sessionId)) return res.status(400).json({ message: 'Invalid session id' }); + + const deleted = await sessionRepository.deleteSession(sessionId, requesterId); + if (!deleted) return res.status(404).json({ message: 'Session not found' }); + + res.json({ session_id: sessionId, deleted: true }); +}; diff --git a/src/repositories/ask-message.repository.ts b/src/repositories/ask-message.repository.ts index 1dcfb26..e86cbc8 100644 --- a/src/repositories/ask-message.repository.ts +++ b/src/repositories/ask-message.repository.ts @@ -127,17 +127,15 @@ export const getMessagesBySession = async ({ values.push(limit); const limitIdx = values.length; - const orderClause = - direction === 'forward' - ? 'ORDER BY created_at ASC, id ASC' - : 'ORDER BY created_at DESC, id DESC'; + const orderClause = direction === 'forward' ? 'ORDER BY created_at ASC, id ASC' : 'ORDER BY created_at DESC, id DESC'; const result = await runQuery( `${baseSelect} WHERE ${predicates.join(' AND ')} ${orderClause} LIMIT $${limitIdx}`, values ); - return result.rows.map(mapMessage); + const mapped = result.rows.map(mapMessage); + return direction === 'forward' ? mapped : mapped.reverse(); }; export const countMessagesForSession = async (sessionId: number): Promise => { diff --git a/src/routes/ai.v2.routes.ts b/src/routes/ai.v2.routes.ts index 08bc65e..eb2aabf 100644 --- a/src/routes/ai.v2.routes.ts +++ b/src/routes/ai.v2.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { askV2Handler } from '../controllers/ai.v2.controller'; import { authMiddleware } from '../middlewares/auth.middleware'; +import sessionRouter from './session.routes'; // 검색 계획을 사용하는 v2 ASK 엔드포인트 라우터 const aiV2Router = Router(); @@ -10,5 +11,6 @@ aiV2Router.get('/health', (req, res) => { }); aiV2Router.post('/ask', authMiddleware, askV2Handler); +aiV2Router.use('/sessions', sessionRouter); export default aiV2Router; diff --git a/src/routes/session.routes.ts b/src/routes/session.routes.ts new file mode 100644 index 0000000..a3eadbe --- /dev/null +++ b/src/routes/session.routes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import { authMiddleware } from '../middlewares/auth.middleware'; +import { + deleteSessionHandler, + getSessionHandler, + getSessionMessagesHandler, + listSessionsHandler, + patchSessionHandler, +} from '../controllers/session.controller'; + +const sessionRouter = Router(); + +sessionRouter.use(authMiddleware); + +sessionRouter.get('/', listSessionsHandler); +sessionRouter.get('/:id', getSessionHandler); +sessionRouter.get('/:id/messages', getSessionMessagesHandler); +sessionRouter.patch('/:id', patchSessionHandler); +sessionRouter.delete('/:id', deleteSessionHandler); + +export default sessionRouter; diff --git a/src/types/session.types.ts b/src/types/session.types.ts new file mode 100644 index 0000000..49b7a64 --- /dev/null +++ b/src/types/session.types.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const sessionListQuerySchema = z.object({ + limit: z + .preprocess((value) => (value === undefined ? undefined : Number(value)), z.number().int().min(1).max(50)) + .optional() + .default(20), + cursor: z.string().optional(), + owner_user_id: z.string().min(1).optional(), +}); + +export const sessionMessagesQuerySchema = z.object({ + limit: z + .preprocess((value) => (value === undefined ? undefined : Number(value)), z.number().int().min(1).max(50)) + .optional() + .default(20), + cursor: z.string().optional(), + direction: z.enum(['forward', 'backward']).optional().default('backward'), +}); + +export const sessionPatchSchema = z.object({ + title: z.string().trim().min(1).max(200).optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export type SessionListQuery = z.infer; +export type SessionMessagesQuery = z.infer; +export type SessionPatchBody = z.infer; From 945d382ad63184692dcf248a53b692ea89cda3bd Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 01:09:29 +0900 Subject: [PATCH 06/21] =?UTF-8?q?=E2=9C=A8=20ask=20api=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/ai.controller.ts | 43 +++++++++++++++-- src/controllers/ai.v2.controller.ts | 67 ++++++++++++++++++++++++--- src/controllers/session.controller.ts | 18 +++---- src/types/ai.types.ts | 3 +- src/types/ai.v2.types.ts | 3 +- src/utils/auth.ts | 9 ++++ src/utils/session.ts | 54 +++++++++++++++++++++ 7 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 src/utils/auth.ts create mode 100644 src/utils/session.ts diff --git a/src/controllers/ai.controller.ts b/src/controllers/ai.controller.ts index 6bfcec1..6c1595f 100644 --- a/src/controllers/ai.controller.ts +++ b/src/controllers/ai.controller.ts @@ -8,6 +8,9 @@ import { import { answerStream } from '../services/qa.service'; import { EmbedTitleRequest, EmbedContentRequest, AskRequest } from '../types/ai.types'; import { DebugLogger } from '../utils/debug-logger'; +import { AuthRequest } from '../middlewares/auth.middleware'; +import { extractRequesterId } from '../utils/auth'; +import { resolveSessionContext, SessionContextError } from '../utils/session'; export const embedTitleHandler = async ( req: Request<{}, {}, EmbedTitleRequest>, @@ -47,13 +50,36 @@ export const embedContentHandler = async ( }; export const askHandler = async ( - req: Request<{}, {}, AskRequest>, + req: AuthRequest & Request<{}, {}, AskRequest>, res: Response, next: NextFunction ) => { // RAG 기반 QA 결과를 SSE 스트림으로 클라이언트에 전달 try { - const { question, user_id, category_id, speech_tone, post_id, llm } = req.body as any; + const requesterUserId = extractRequesterId(req); + if (!requesterUserId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const { question, user_id, session_id, category_id, speech_tone, post_id, llm } = req.body; + + let sessionResult; + try { + sessionResult = await resolveSessionContext({ + requesterUserId, + sessionId: session_id, + ownerUserId: user_id, + titleHint: question, + }); + } catch (error) { + if (error instanceof SessionContextError) { + return res.status(error.status).json({ message: error.message }); + } + throw error; + } + + const { session, created } = sessionResult; + const ownerUserId = session.ownerUserId; // SSE를 위한 헤더 설정과 버퍼링 완화 옵션 res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); @@ -61,6 +87,7 @@ export const askHandler = async ( res.setHeader('Connection', 'keep-alive'); // Nginx 버퍼링 비활성화 res.setHeader('X-Accel-Buffering', 'no'); + res.setHeader('session-id', String(session.id)); // 헤더를 먼저 전송해 클라이언트 처리를 즉시 시작 (res as any).flushHeaders?.(); // 소켓의 네이글 알고리즘 버퍼링을 줄여 전송 지연 완화 @@ -68,7 +95,17 @@ export const askHandler = async ( // 프록시 버퍼링 임계값을 넘기기 위한 초기 keep-alive 전송 res.write(':ok\n\n'); - const stream = await answerStream(question, user_id, category_id, speech_tone, post_id, llm); + if (created) { + const payload = { + session_id: String(session.id), + owner_user_id: ownerUserId, + requester_user_id: requesterUserId, + }; + res.write(`event: session\n`); + res.write(`data: ${JSON.stringify(payload)}\n\n`); + } + + const stream = await answerStream(question, ownerUserId, category_id, speech_tone, post_id, llm); // SSE 델타가 즉시 전송되도록 수동 브리징 stream.on('data', (chunk) => { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); diff --git a/src/controllers/ai.v2.controller.ts b/src/controllers/ai.v2.controller.ts index 1fdf5be..a41fbb8 100644 --- a/src/controllers/ai.v2.controller.ts +++ b/src/controllers/ai.v2.controller.ts @@ -1,28 +1,83 @@ import { Request, Response, NextFunction } from 'express'; import { AskV2Request } from '../types/ai.v2.types'; import { answerStreamV2 } from '../services/qa.v2.service'; +import { AuthRequest } from '../middlewares/auth.middleware'; +import { extractRequesterId } from '../utils/auth'; +import { resolveSessionContext, SessionContextError } from '../utils/session'; export const askV2Handler = async ( - req: Request<{}, {}, AskV2Request>, + req: AuthRequest & Request<{}, {}, AskV2Request>, res: Response, next: NextFunction ) => { // 검색 계획 기반 v2 QA를 SSE로 중계 try { - const { question, user_id, category_id, speech_tone, post_id, llm } = req.body as any; - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); + const requesterUserId = extractRequesterId(req); + if (!requesterUserId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const { question, user_id, session_id, category_id, speech_tone, post_id, llm } = req.body; + + let sessionResult; + try { + sessionResult = await resolveSessionContext({ + requesterUserId, + sessionId: session_id, + ownerUserId: user_id, + titleHint: question, + }); + } catch (error) { + if (error instanceof SessionContextError) { + return res.status(error.status).json({ message: error.message }); + } + throw error; + } + + const { session, created } = sessionResult; + const ownerUserId = session.ownerUserId; + + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.setHeader('session-id', String(session.id)); + (res as any).flushHeaders?.(); + (res.socket as any)?.setNoDelay?.(true); + res.write(':ok\n\n'); + + if (created) { + const payload = { + session_id: String(session.id), + owner_user_id: ownerUserId, + requester_user_id: requesterUserId, + }; + res.write(`event: session\n`); + res.write(`data: ${JSON.stringify(payload)}\n\n`); + } const stream = await answerStreamV2( question, - user_id, + ownerUserId, category_id, speech_tone, post_id, llm ); - stream.pipe(res); + + stream.on('data', (chunk) => { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + res.write(buf); + (res as any).flush?.(); + }); + stream.on('end', () => res.end()); + stream.on('error', () => res.end()); + + req.on('close', () => { + try { + stream.destroy(); + } catch {} + }); } catch (error) { next(error); } diff --git a/src/controllers/session.controller.ts b/src/controllers/session.controller.ts index 78c6f96..1b8b0d7 100644 --- a/src/controllers/session.controller.ts +++ b/src/controllers/session.controller.ts @@ -3,6 +3,7 @@ import { AuthRequest } from '../middlewares/auth.middleware'; import * as sessionRepository from '../repositories/ask-session.repository'; import * as messageRepository from '../repositories/ask-message.repository'; import { sessionListQuerySchema, sessionMessagesQuerySchema, sessionPatchSchema } from '../types/session.types'; +import { extractRequesterId } from '../utils/auth'; const encodeCursor = (createdAt: Date, id: number): string => Buffer.from(`${createdAt.toISOString()}|${id}`).toString('base64'); @@ -21,13 +22,6 @@ const decodeCursor = (cursor: string): { createdAt: Date; id: number } | null => } }; -const resolveRequesterId = (req: AuthRequest): string | null => { - const user = req.user; - if (!user || typeof user !== 'object') return null; - const candidate = (user as Record).user_id ?? (user as Record).sub; - return typeof candidate === 'string' ? candidate : null; -}; - const toSessionResponse = ( session: sessionRepository.AskSession | sessionRepository.AskSessionSummary, messageCountOverride?: number @@ -54,7 +48,7 @@ const toMessageResponse = (message: messageRepository.AskMessage) => ({ }); export const listSessionsHandler = async (req: AuthRequest, res: Response) => { - const requesterId = resolveRequesterId(req); + const requesterId = extractRequesterId(req); if (!requesterId) return res.status(401).json({ message: 'Unauthorized' }); const parse = sessionListQuerySchema.safeParse(req.query); @@ -89,7 +83,7 @@ export const listSessionsHandler = async (req: AuthRequest, res: Response) => { }; export const getSessionHandler = async (req: AuthRequest, res: Response) => { - const requesterId = resolveRequesterId(req); + const requesterId = extractRequesterId(req); if (!requesterId) return res.status(401).json({ message: 'Unauthorized' }); const sessionId = Number(req.params.id); @@ -104,7 +98,7 @@ export const getSessionHandler = async (req: AuthRequest, res: Response) => { }; export const getSessionMessagesHandler = async (req: AuthRequest, res: Response) => { - const requesterId = resolveRequesterId(req); + const requesterId = extractRequesterId(req); if (!requesterId) return res.status(401).json({ message: 'Unauthorized' }); const sessionId = Number(req.params.id); @@ -147,7 +141,7 @@ export const getSessionMessagesHandler = async (req: AuthRequest, res: Response) }; export const patchSessionHandler = async (req: AuthRequest, res: Response) => { - const requesterId = resolveRequesterId(req); + const requesterId = extractRequesterId(req); if (!requesterId) return res.status(401).json({ message: 'Unauthorized' }); const sessionId = Number(req.params.id); @@ -167,7 +161,7 @@ export const patchSessionHandler = async (req: AuthRequest, res: Response) => { }; export const deleteSessionHandler = async (req: AuthRequest, res: Response) => { - const requesterId = resolveRequesterId(req); + const requesterId = extractRequesterId(req); if (!requesterId) return res.status(401).json({ message: 'Unauthorized' }); const sessionId = Number(req.params.id); diff --git a/src/types/ai.types.ts b/src/types/ai.types.ts index bffc72a..a70fe3e 100644 --- a/src/types/ai.types.ts +++ b/src/types/ai.types.ts @@ -24,7 +24,8 @@ export type EmbedContentRequest = z.infer['body']; export const askSchema = z.object({ body: z.object({ question: z.string(), - user_id: z.string(), + user_id: z.string().optional(), + session_id: z.string().optional(), category_id: z.number().optional(), post_id: z.number().optional(), speech_tone: z.number().optional(), diff --git a/src/types/ai.v2.types.ts b/src/types/ai.v2.types.ts index c806262..b58378d 100644 --- a/src/types/ai.v2.types.ts +++ b/src/types/ai.v2.types.ts @@ -83,7 +83,8 @@ export type SearchPlan = z.infer; export const askV2Schema = z.object({ body: z.object({ question: z.string(), - user_id: z.string(), + user_id: z.string().optional(), + session_id: z.string().optional(), category_id: z.number().optional(), post_id: z.number().optional(), speech_tone: z.number().optional(), diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..792ae39 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,9 @@ +import { AuthRequest } from '../middlewares/auth.middleware'; + +export const extractRequesterId = (req: AuthRequest): string | null => { + const user = req.user; + if (!user || typeof user !== 'object') return null; + + const candidate = (user as Record).user_id ?? (user as Record).sub; + return typeof candidate === 'string' ? candidate : null; +}; diff --git a/src/utils/session.ts b/src/utils/session.ts new file mode 100644 index 0000000..303036c --- /dev/null +++ b/src/utils/session.ts @@ -0,0 +1,54 @@ +import * as sessionRepository from '../repositories/ask-session.repository'; + +export class SessionContextError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +export interface ResolveSessionOptions { + requesterUserId: string; + sessionId?: string | null; + ownerUserId?: string | null; + titleHint?: string; +} + +export const resolveSessionContext = async ({ + requesterUserId, + sessionId, + ownerUserId, + titleHint, +}: ResolveSessionOptions): Promise<{ session: sessionRepository.AskSession; created: boolean }> => { + if (sessionId) { + const numericId = Number(sessionId); + if (!Number.isFinite(numericId) || numericId <= 0) { + throw new SessionContextError(400, 'Invalid session_id'); + } + const session = await sessionRepository.findSessionForRequester(numericId, requesterUserId); + if (!session) { + throw new SessionContextError(404, 'Session not found'); + } + if (ownerUserId && ownerUserId !== session.ownerUserId) { + throw new SessionContextError(409, 'Session owner mismatch'); + } + return { session, created: false }; + } + + const trimmedOwner = ownerUserId?.trim(); + if (!trimmedOwner) { + throw new SessionContextError(400, 'user_id is required when session_id is missing'); + } + + const normalizedTitle = titleHint?.trim(); + const title = normalizedTitle ? normalizedTitle.slice(0, 120) : null; + + const session = await sessionRepository.createSession({ + requesterUserId, + ownerUserId: trimmedOwner, + title, + }); + + return { session, created: true }; +}; From b379e46d50a74640c3cd62090fcfbfd14a37ab45 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 01:17:58 +0900 Subject: [PATCH 07/21] =?UTF-8?q?=E2=9C=A8=20=EB=A7=A5=EB=9D=BD=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20=EA=B5=AC=ED=98=84(2=ED=84=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/ai.controller.ts | 22 ++- src/controllers/ai.v2.controller.ts | 24 ++- src/repositories/ask-session.repository.ts | 6 +- src/services/qa.service.ts | 185 ++++++++++++++++----- src/services/qa.v2.service.ts | 185 ++++++++++++++++----- src/services/session-history.service.ts | 79 +++++++++ 6 files changed, 414 insertions(+), 87 deletions(-) create mode 100644 src/services/session-history.service.ts diff --git a/src/controllers/ai.controller.ts b/src/controllers/ai.controller.ts index 6c1595f..9982fda 100644 --- a/src/controllers/ai.controller.ts +++ b/src/controllers/ai.controller.ts @@ -105,7 +105,26 @@ export const askHandler = async ( res.write(`data: ${JSON.stringify(payload)}\n\n`); } - const stream = await answerStream(question, ownerUserId, category_id, speech_tone, post_id, llm); + const stream = await answerStream({ + question, + session, + requesterUserId, + ownerUserId, + categoryId: category_id, + speechTone: speech_tone, + postId: post_id, + llm, + }); + + stream.on('session_saved', (payload) => { + res.write(`event: session_saved\n`); + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }); + stream.on('session_error', (payload) => { + res.write(`event: session_error\n`); + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }); + // SSE 델타가 즉시 전송되도록 수동 브리징 stream.on('data', (chunk) => { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); @@ -125,6 +144,7 @@ export const askHandler = async ( // 클라이언트 연결이 끊기면 스트림 자원 해제 req.on('close', () => { try { + stream.emit('client_disconnect'); stream.destroy(); } catch {} }); diff --git a/src/controllers/ai.v2.controller.ts b/src/controllers/ai.v2.controller.ts index a41fbb8..ea79aa4 100644 --- a/src/controllers/ai.v2.controller.ts +++ b/src/controllers/ai.v2.controller.ts @@ -56,14 +56,25 @@ export const askV2Handler = async ( res.write(`data: ${JSON.stringify(payload)}\n\n`); } - const stream = await answerStreamV2( + const stream = await answerStreamV2({ question, + session, + requesterUserId, ownerUserId, - category_id, - speech_tone, - post_id, - llm - ); + categoryId: category_id, + speechTone: speech_tone, + postId: post_id, + llm, + }); + + stream.on('session_saved', (payload) => { + res.write(`event: session_saved\n`); + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }); + stream.on('session_error', (payload) => { + res.write(`event: session_error\n`); + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }); stream.on('data', (chunk) => { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); @@ -75,6 +86,7 @@ export const askV2Handler = async ( req.on('close', () => { try { + stream.emit('client_disconnect'); stream.destroy(); } catch {} }); diff --git a/src/repositories/ask-session.repository.ts b/src/repositories/ask-session.repository.ts index f742090..25cb791 100644 --- a/src/repositories/ask-session.repository.ts +++ b/src/repositories/ask-session.repository.ts @@ -1,4 +1,4 @@ -import { runQuery } from '../utils/db'; +import { runQuery, QueryExecutor } from '../utils/db'; export type JsonMap = Record; @@ -202,8 +202,8 @@ export const updateSessionMeta = async ( return mapRow(result.rows[0]); }; -export const touchSessionLastQuestion = async (sessionId: number): Promise => { - await runQuery('UPDATE ask_session SET last_question_at = now(), updated_at = now() WHERE id = $1', [sessionId]); +export const touchSessionLastQuestion = async (sessionId: number, executor?: QueryExecutor): Promise => { + await runQuery('UPDATE ask_session SET last_question_at = now(), updated_at = now() WHERE id = $1', [sessionId], executor); }; export const deleteSession = async (sessionId: number, requesterUserId: string): Promise => { diff --git a/src/services/qa.service.ts b/src/services/qa.service.ts index a8966d0..edbcc01 100644 --- a/src/services/qa.service.ts +++ b/src/services/qa.service.ts @@ -1,4 +1,3 @@ -import { createEmbeddings } from './embedding.service'; import { PassThrough } from 'stream'; import config from '../config'; import * as postRepository from '../repositories/post.repository'; @@ -7,6 +6,9 @@ import * as qaPrompts from '../prompts/qa.prompts'; import { generate } from '../llm'; import { DebugLogger } from '../utils/debug-logger'; import * as userRepository from '../repositories/user.repository'; +import { createEmbeddings } from './embedding.service'; +import * as sessionHistoryService from './session-history.service'; +import { AskSession } from '../repositories/ask-session.repository'; // HTML 태그를 제거하고 길이를 제한하여 LLM 컨텍스트를 정제 const preprocessContent = (content: string): string => { @@ -16,16 +18,16 @@ const preprocessContent = (content: string): string => { // 사용자 말투 ID에 따라 프롬프트 지시문을 반환 const getSpeechTonePrompt = async (speechTone: number, userId: string): Promise => { - if (speechTone === -1) return "간결하고 명확한 말투로 답변해"; - if (speechTone === -2) return "아래의 블로그 본문 컨텍스트를 참고하여 본문의 말투를 파악해 최대한 비슷한 말투로 답변해"; + if (speechTone === -1) return '간결하고 명확한 말투로 답변해'; + if (speechTone === -2) return '아래의 블로그 본문 컨텍스트를 참고하여 본문의 말투를 파악해 최대한 비슷한 말투로 답변해'; const persona = await personaRepository.findPersonaById(speechTone, userId); if (persona) { return `${persona.name}: ${persona.description}`; } - return "간결하고 명확한 말투로 답변해"; // 기본 말투 -} + return '간결하고 명확한 말투로 답변해'; // 기본 말투 +}; type LlmOverride = { provider?: 'openai' | 'gemini'; @@ -33,24 +35,64 @@ type LlmOverride = { options?: { temperature?: number; top_p?: number; max_output_tokens?: number }; }; +export interface AnswerStreamOptions { + question: string; + session: AskSession; + requesterUserId: string; + ownerUserId: string; + categoryId?: number; + speechTone?: number; + postId?: number; + llm?: LlmOverride; +} + +const prependHistory = ( + base: { role: 'system' | 'user' | 'assistant' | 'tool' | 'function'; content: string }[], + history: { role: 'user' | 'assistant'; content: string }[] +) => { + if (!history.length) return base; + if (base.length === 0 || base[0].role !== 'system') { + return [...history, ...base]; + } + const [systemMessage, ...rest] = base; + return [systemMessage, ...history, ...rest]; +}; + +const sessionSavedPayload = (session: AskSession, cached = false) => ({ + session_id: String(session.id), + owner_user_id: session.ownerUserId, + requester_user_id: session.requesterUserId, + cached, +}); + +const sessionErrorPayload = (session: AskSession, reason: string) => ({ + session_id: String(session.id), + owner_user_id: session.ownerUserId, + requester_user_id: session.requesterUserId, + reason, +}); + // 질문에 대한 RAG 답변을 SSE 스트림으로 생성 -export const answerStream = async ( - question: string, - userId: string, - categoryId?: number, - speechTone: number = -1, - postId?: number, - llm?: LlmOverride -): Promise => { +export const answerStream = async ({ + question, + session, + requesterUserId, + ownerUserId, + categoryId, + speechTone = -1, + postId, + llm, +}: AnswerStreamOptions): Promise => { const stream = new PassThrough(); DebugLogger.log('qa', { type: 'debug.qa.start', questionLen: question?.length || 0, - userId, + ownerUserId, categoryId, postId, speechTone, llm, + sessionId: session.id, }); let messages: { role: 'system' | 'user' | 'assistant' | 'tool' | 'function'; content: string }[] = []; @@ -61,11 +103,23 @@ export const answerStream = async ( }[] | undefined = undefined; + let bufferedAnswer = ''; + let questionEmbedding: number[] | null = null; + let searchPlanPayload: Record | null = null; + let retrievalMetaPayload: Record | null = null; + let clientDisconnected = false; + + stream.once('client_disconnect', () => { + clientDisconnected = true; + }); + (async () => { - const [speechTonePrompt, blogMeta] = await Promise.all([ - getSpeechTonePrompt(speechTone, userId), - userRepository.findUserBlogMetadata(userId), + const [speechTonePrompt, blogMeta, historyMessages] = await Promise.all([ + getSpeechTonePrompt(speechTone, ownerUserId), + userRepository.findUserBlogMetadata(ownerUserId), + sessionHistoryService.loadRecentMessages(session.id), ]); + const toSimpleMessages = ( raw: any[] ): { role: 'system' | 'user' | 'assistant' | 'tool' | 'function'; content: string }[] => { @@ -80,15 +134,16 @@ export const answerStream = async ( if (!post) { stream.write(`event: error\ndata: ${JSON.stringify({ code: 404, message: 'Post not found' })}\n\n`); + stream.emit('session_error', sessionErrorPayload(session, 'post_not_found')); stream.end(); DebugLogger.warn('qa', { type: 'debug.qa.post', status: 'not_found', postId }); return; } - - // 비공개 글이면 소유자만 접근하도록 검증 - if (!post.is_public && post.user_id !== userId) { + + if (!post.is_public && post.user_id !== ownerUserId) { stream.write(`event: error\n`); stream.write(`data: ${JSON.stringify({ code: 403, message: 'Forbidden' })}\n\n`); + stream.emit('session_error', sessionErrorPayload(session, 'forbidden_post')); stream.end(); DebugLogger.warn('qa', { type: 'debug.qa.post', status: 'forbidden', postId }); return; @@ -108,14 +163,25 @@ export const answerStream = async ( qaPrompts.createPostContextPrompt(post, processedContent, question, speechTonePrompt, blogMeta ?? undefined) ); + if (!questionEmbedding) { + [questionEmbedding] = await createEmbeddings([question]); + } + + searchPlanPayload = { mode: 'post', post_id: postId }; + retrievalMetaPayload = { + strategy: '단일 포스트 컨텍스트', + post_id: postId, + }; } else { - const [questionEmbedding] = await createEmbeddings([question]); - const similarChunks = await postRepository.findSimilarChunks(userId, questionEmbedding, categoryId); - + if (!questionEmbedding) { + [questionEmbedding] = await createEmbeddings([question]); + } + const similarChunks = await postRepository.findSimilarChunks(ownerUserId, questionEmbedding, categoryId); + const existInPost = similarChunks.length > 0; stream.write(`event: exist_in_post_status\ndata: ${JSON.stringify(existInPost)}\n\n`); - const context = similarChunks.map(chunk => ({ postId: chunk.postId, postTitle: chunk.postTitle })); + const context = similarChunks.map((chunk) => ({ postId: chunk.postId, postTitle: chunk.postTitle })); stream.write(`event: context\ndata: ${JSON.stringify(context)}\n\n`); DebugLogger.log('qa', { type: 'debug.qa.path', @@ -130,26 +196,34 @@ export const answerStream = async ( postChunk: chunk.postChunk, createdAt: (chunk as any).postCreatedAt ?? null, })); + const retrievalMeta = { + strategy: categoryId ? `임베딩 기반 RAG (카테고리 ${categoryId})` : '임베딩 기반 RAG', + resultCount: similarChunks.length, + }; messages = toSimpleMessages( qaPrompts.createRagPrompt(question, ragChunks, speechTonePrompt, { - retrievalMeta: { - strategy: categoryId - ? `임베딩 기반 RAG (카테고리 ${categoryId})` - : '임베딩 기반 RAG', - resultCount: similarChunks.length, - }, + retrievalMeta, blogMeta: blogMeta ?? undefined, }) ); + + searchPlanPayload = { mode: 'rag', category_id: categoryId ?? null }; + retrievalMetaPayload = retrievalMeta; } + const historyForPrompt = historyMessages.map((message) => ({ + role: message.role, + content: message.content, + })) as { role: 'user' | 'assistant'; content: string }[]; + messages = prependHistory(messages, historyForPrompt); + const llmStream = await generate({ provider: llm?.provider || 'openai', model: llm?.model || config.CHAT_MODEL, messages, tools, options: llm?.options, - meta: { userId, categoryId, postId }, + meta: { userId: ownerUserId, categoryId, postId }, }); DebugLogger.log('qa', { type: 'debug.qa.call', @@ -162,6 +236,7 @@ export const answerStream = async ( llmStream.on('data', (chunk) => { const str = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + bufferedAnswer += str; DebugLogger.log('qa', { type: 'debug.qa.chunk', at: Date.now(), @@ -170,17 +245,53 @@ export const answerStream = async ( }); stream.write(chunk); }); - llmStream.on('end', () => { + + llmStream.on('end', async () => { + if (clientDisconnected) { + stream.end(); + return; + } + try { + if (questionEmbedding) { + await sessionHistoryService.persistConversation({ + sessionId: session.id, + requesterUserId, + ownerUserId, + question, + answer: bufferedAnswer.trim(), + searchPlan: searchPlanPayload ?? undefined, + retrievalMeta: retrievalMetaPayload ?? undefined, + categoryId, + postId, + questionEmbedding, + }); + stream.emit('session_saved', sessionSavedPayload(session)); + } else { + stream.emit('session_error', sessionErrorPayload(session, 'missing_question_embedding')); + } + } catch (error) { + DebugLogger.error('qa', { + type: 'debug.qa.persistence_error', + message: (error as Error)?.message ?? 'unknown', + sessionId: session.id, + }); + stream.emit('session_error', sessionErrorPayload(session, 'persistence_failed')); + } stream.end(); }); + llmStream.on('error', (e) => { DebugLogger.error('qa', { type: 'debug.qa.llmError', message: (e as any)?.message || 'error' }); - }); - - })().catch(err => { - console.error('Stream process error:', err); - stream.write(`event: error\ndata: ${JSON.stringify({ message: 'Internal server error' })}\n\n`); + stream.write(`event: error\n`); + stream.write(`data: ${JSON.stringify({ message: 'Internal server error' })}\n\n`); + stream.emit('session_error', sessionErrorPayload(session, 'llm_error')); stream.end(); + }); + })().catch((err) => { + console.error('Stream process error:', err); + stream.write(`event: error\ndata: ${JSON.stringify({ message: 'Internal server error' })}\n\n`); + stream.emit('session_error', sessionErrorPayload(session, 'stream_error')); + stream.end(); }); return stream; diff --git a/src/services/qa.v2.service.ts b/src/services/qa.v2.service.ts index 209b5d4..cd2a8e1 100644 --- a/src/services/qa.v2.service.ts +++ b/src/services/qa.v2.service.ts @@ -10,6 +10,8 @@ import { runSemanticSearch } from './semantic-search.service'; import { runHybridSearch } from './hybrid-search.service'; import { createEmbeddings } from './embedding.service'; import { DebugLogger } from '../utils/debug-logger'; +import * as sessionHistoryService from './session-history.service'; +import { AskSession } from '../repositories/ask-session.repository'; // HTML을 제거하고 길이를 제한해 LLM 컨텍스트를 정리 const preprocessContent = (content: string): string => { @@ -34,22 +36,74 @@ type LlmOverride = { options?: { temperature?: number; top_p?: number; max_output_tokens?: number }; }; +export interface AnswerStreamV2Options { + question: string; + session: AskSession; + requesterUserId: string; + ownerUserId: string; + categoryId?: number; + speechTone?: number; + postId?: number; + llm?: LlmOverride; +} + +const prependHistory = ( + base: { role: 'system' | 'user' | 'assistant' | 'tool' | 'function'; content: string }[], + history: { role: 'user' | 'assistant'; content: string }[] +) => { + if (!history.length) return base; + if (base.length === 0 || base[0].role !== 'system') { + return [...history, ...base]; + } + const [systemMessage, ...rest] = base; + return [systemMessage, ...history, ...rest]; +}; + +const sessionSavedPayload = (session: AskSession, cached = false) => ({ + session_id: String(session.id), + owner_user_id: session.ownerUserId, + requester_user_id: session.requesterUserId, + cached, +}); + +const sessionErrorPayload = (session: AskSession, reason: string) => ({ + session_id: String(session.id), + owner_user_id: session.ownerUserId, + requester_user_id: session.requesterUserId, + reason, +}); + // 검색 계획을 활용한 v2 QA 스트림을 생성 -export const answerStreamV2 = async ( - question: string, - userId: string, - categoryId?: number, - speechTone: number = -1, - postId?: number, - llm?: LlmOverride -): Promise => { +export const answerStreamV2 = async ({ + question, + session, + requesterUserId, + ownerUserId, + categoryId, + speechTone = -1, + postId, + llm, +}: AnswerStreamV2Options): Promise => { const stream = new PassThrough(); + let bufferedAnswer = ''; + let searchPlanPayload: Record | null = null; + let retrievalMetaPayload: Record | null = null; + let questionEmbedding: number[] | null = null; + let clientDisconnected = false; + + stream.once('client_disconnect', () => { + clientDisconnected = true; + }); + (async () => { - const [speechTonePrompt, blogMeta] = await Promise.all([ - getSpeechTonePrompt(speechTone, userId), - userRepository.findUserBlogMetadata(userId), + const [speechTonePrompt, blogMeta, historyMessages, embeddingVector] = await Promise.all([ + getSpeechTonePrompt(speechTone, ownerUserId), + userRepository.findUserBlogMetadata(ownerUserId), + sessionHistoryService.loadRecentMessages(session.id), + createEmbeddings([question]), ]); + questionEmbedding = embeddingVector[0]; let messages: { role: 'system' | 'user' | 'assistant' | 'tool' | 'function'; content: string }[] = []; let tools: @@ -74,20 +128,21 @@ export const answerStreamV2 = async ( if (!post) { stream.write(`event: error\n`); stream.write(`data: ${JSON.stringify({ code: 404, message: 'Post not found' })}\n\n`); + stream.emit('session_error', sessionErrorPayload(session, 'post_not_found')); stream.end(); return; } - if (!post.is_public && post.user_id !== userId) { + if (!post.is_public && post.user_id !== ownerUserId) { stream.write(`event: error\n`); stream.write(`data: ${JSON.stringify({ code: 403, message: 'Forbidden' })}\n\n`); + stream.emit('session_error', sessionErrorPayload(session, 'forbidden_post')); stream.end(); return; } // 검색 계획 정보를 스트림으로 먼저 공지 + const postPlan = { mode: 'post', filters: { post_id: postId, user_id: ownerUserId } }; stream.write(`event: search_plan\n`); - stream.write( - `data: ${JSON.stringify({ mode: 'post', filters: { post_id: postId, user_id: userId } })}\n\n` - ); + stream.write(`data: ${JSON.stringify(postPlan)}\n\n`); const processed = preprocessContent(post.content); const ctx = [{ postId: post.id, postTitle: post.title }]; @@ -101,16 +156,19 @@ export const answerStreamV2 = async ( messages = toSimpleMessages( qaPrompts.createPostContextPrompt(post, processed, question, speechTonePrompt, blogMeta ?? undefined) ); + + searchPlanPayload = postPlan; + retrievalMetaPayload = { strategy: '단일 포스트 컨텍스트', post_id: postId }; } else { // 질문 기반 검색 계획 생성 경로 - const planPair = await generateSearchPlan(question, { user_id: userId, category_id: categoryId }); + const planPair = await generateSearchPlan(question, { user_id: ownerUserId, category_id: categoryId }); if (!planPair) { // 계획 생성 실패 시 v1 RAG로 조용히 폴백 - const [questionEmbedding] = await createEmbeddings([question]); - const similarChunks = await postRepository.findSimilarChunks(userId, questionEmbedding, categoryId); + const similarChunks = await postRepository.findSimilarChunks(ownerUserId, questionEmbedding, categoryId); const context = similarChunks.map((c) => ({ postId: c.postId, postTitle: c.postTitle })); + const fallbackPlan = { mode: 'rag', fallback: true }; stream.write(`event: search_plan\n`); - stream.write(`data: ${JSON.stringify({ mode: 'rag', fallback: true })}\n\n`); + stream.write(`data: ${JSON.stringify(fallbackPlan)}\n\n`); stream.write(`event: search_result\n`); stream.write(`data: ${JSON.stringify(context)}\n\n`); stream.write(`event: exist_in_post_status\n`); @@ -124,24 +182,27 @@ export const answerStreamV2 = async ( postChunk: c.postChunk, createdAt: (c as any).postCreatedAt ?? null, })); + const retrievalMeta = { + strategy: '임베딩 기반 RAG (검색 계획 폴백)', + resultCount: similarChunks.length, + }; messages = toSimpleMessages( qaPrompts.createRagPrompt(question, ragChunks, speechTonePrompt, { - retrievalMeta: { - strategy: '임베딩 기반 RAG (검색 계획 생성 실패 폴백)', - resultCount: similarChunks.length, - notes: ['검색 계획 생성 실패로 기본 임베딩 검색을 사용했습니다.'], - }, + retrievalMeta, blogMeta: blogMeta ?? undefined, }) ); + searchPlanPayload = fallbackPlan; + retrievalMetaPayload = retrievalMeta; } else { const plan: any = planPair.normalized; + searchPlanPayload = plan; stream.write(`event: search_plan\n`); stream.write(`data: ${JSON.stringify(plan)}\n\n`); // 전송된 검색 계획을 디버그 로그로 남김 DebugLogger.log('sse', { type: 'debug.sse.search_plan', - userId, + userId: ownerUserId, categoryId, plan_summary: { mode: plan.mode, @@ -176,14 +237,13 @@ export const answerStreamV2 = async ( } rows = await runHybridSearch( question, - userId, + ownerUserId, plan, { categoryId: categoryId ?? undefined, limit: plan.limit } ); const hybridContext = rows.map((r) => ({ postId: r.postId, postTitle: r.postTitle })); stream.write(`event: hybrid_result\n`); stream.write(`data: ${JSON.stringify(hybridContext)}\n\n`); - // 메타데이터를 선택적으로 구독하는 클라이언트를 위한 추가 이벤트 try { const hybridMeta = rows.map((r) => ({ postId: r.postId, @@ -196,16 +256,15 @@ export const answerStreamV2 = async ( } catch {} if (!rows.length) { - rows = await runSemanticSearch(question, userId, plan, { categoryId: categoryId ?? undefined }); + rows = await runSemanticSearch(question, ownerUserId, plan, { categoryId: categoryId ?? undefined }); } } else { - rows = await runSemanticSearch(question, userId, plan, { categoryId: categoryId ?? undefined }); + rows = await runSemanticSearch(question, ownerUserId, plan, { categoryId: categoryId ?? undefined }); } const context = rows.map((r) => ({ postId: r.postId, postTitle: r.postTitle })); stream.write(`event: search_result\n`); stream.write(`data: ${JSON.stringify(context)}\n\n`); - // 메타데이터를 선택적으로 요청하는 클라이언트를 위한 추가 이벤트 try { const resultMeta = rows.map((r) => ({ postId: r.postId, @@ -227,35 +286,80 @@ export const answerStreamV2 = async ( postChunk: r.postChunk, createdAt: r.postCreatedAt ?? null, })); + const retrievalMeta = { + strategy: plan.hybrid?.enabled + ? `검색 계획 기반 하이브리드 (${plan.hybrid.retrieval_bias || 'balanced'})` + : '검색 계획 기반 임베딩', + plan, + resultCount: rows.length, + }; messages = toSimpleMessages( qaPrompts.createRagPrompt(question, planChunks, speechTonePrompt, { - retrievalMeta: { - strategy: plan.hybrid?.enabled - ? `검색 계획 기반 하이브리드 (${plan.hybrid.retrieval_bias || 'balanced'})` - : '검색 계획 기반 임베딩', - plan, - resultCount: rows.length, - }, + retrievalMeta, blogMeta: blogMeta ?? undefined, }) ); + retrievalMetaPayload = retrievalMeta; } } + const historyForPrompt = historyMessages.map((message) => ({ + role: message.role, + content: message.content, + })) as { role: 'user' | 'assistant'; content: string }[]; + messages = prependHistory(messages, historyForPrompt); + const llmStream = await generate({ provider: llm?.provider || 'openai', model: llm?.model || config.CHAT_MODEL, messages, tools, options: llm?.options, - meta: { userId, categoryId, postId }, + meta: { userId: ownerUserId, categoryId, postId }, }); - llmStream.on('data', (chunk) => stream.write(chunk)); - llmStream.on('end', () => stream.end()); + llmStream.on('data', (chunk) => { + const str = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + bufferedAnswer += str; + stream.write(chunk); + }); + llmStream.on('end', async () => { + if (clientDisconnected) { + stream.end(); + return; + } + try { + if (questionEmbedding) { + await sessionHistoryService.persistConversation({ + sessionId: session.id, + requesterUserId, + ownerUserId, + question, + answer: bufferedAnswer.trim(), + searchPlan: searchPlanPayload ?? undefined, + retrievalMeta: retrievalMetaPayload ?? undefined, + categoryId, + postId, + questionEmbedding, + }); + stream.emit('session_saved', sessionSavedPayload(session)); + } else { + stream.emit('session_error', sessionErrorPayload(session, 'missing_question_embedding')); + } + } catch (error) { + DebugLogger.error('qa', { + type: 'debug.qa.v2.persistence_error', + message: (error as Error)?.message ?? 'unknown', + sessionId: session.id, + }); + stream.emit('session_error', sessionErrorPayload(session, 'persistence_failed')); + } + stream.end(); + }); llmStream.on('error', () => { stream.write(`event: error\n`); stream.write(`data: ${JSON.stringify({ message: 'Internal server error' })}\n\n`); + stream.emit('session_error', sessionErrorPayload(session, 'llm_error')); stream.end(); }); })().catch((err) => { @@ -264,6 +368,7 @@ export const answerStreamV2 = async ( } catch {} stream.write(`event: error\n`); stream.write(`data: ${JSON.stringify({ message: 'Internal server error' })}\n\n`); + stream.emit('session_error', sessionErrorPayload(session, 'stream_error')); stream.end(); }); diff --git a/src/services/session-history.service.ts b/src/services/session-history.service.ts new file mode 100644 index 0000000..f68d9e5 --- /dev/null +++ b/src/services/session-history.service.ts @@ -0,0 +1,79 @@ +import * as messageRepository from '../repositories/ask-message.repository'; +import * as embeddingRepository from '../repositories/ask-message-embedding.repository'; +import * as sessionRepository from '../repositories/ask-session.repository'; +import { withTransaction } from '../utils/db'; + +export const HISTORY_MESSAGE_LIMIT = 4; + +export const loadRecentMessages = async (sessionId: number, limit = HISTORY_MESSAGE_LIMIT) => { + const boundedLimit = Math.max(0, Math.min(limit, HISTORY_MESSAGE_LIMIT)); + if (boundedLimit === 0) return []; + return messageRepository.getLatestMessages(sessionId, boundedLimit); +}; + +export interface PersistConversationInput { + sessionId: number; + requesterUserId: string; + ownerUserId: string; + question: string; + answer: string; + searchPlan?: Record | null; + retrievalMeta?: Record | null; + categoryId?: number; + postId?: number; + questionEmbedding: number[]; +} + +export const persistConversation = async ({ + sessionId, + requesterUserId, + ownerUserId, + question, + answer, + searchPlan, + retrievalMeta, + categoryId, + postId, + questionEmbedding, +}: PersistConversationInput): Promise => { + if (!answer || questionEmbedding.length === 0) return; + + await withTransaction(async (client) => { + const userMessage = await messageRepository.insertMessage( + { + sessionId, + role: 'user', + content: question, + searchPlan: searchPlan ?? null, + retrievalMeta: null, + }, + client + ); + + const assistantMessage = await messageRepository.insertMessage( + { + sessionId, + role: 'assistant', + content: answer, + searchPlan: null, + retrievalMeta: retrievalMeta ?? null, + }, + client + ); + + await embeddingRepository.upsertEmbedding( + { + messageId: userMessage.id, + ownerUserId, + requesterUserId, + categoryId: categoryId ?? null, + postId: postId ?? null, + answerMessageId: assistantMessage.id, + embedding: questionEmbedding, + }, + client + ); + + await sessionRepository.touchSessionLastQuestion(sessionId, client); + }); +}; From 6bf928d9470c5631d7b0f3d2e7108347fd0f3314 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 01:27:14 +0900 Subject: [PATCH 08/21] =?UTF-8?q?=E2=9C=A8=20=EC=9C=A0=EC=82=AC=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=ED=9E=88=ED=8A=B8=EC=8B=9C=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EB=8B=B5=EB=B3=80=20=EC=9E=AC=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/qa.service.ts | 83 ++++++++++++++++++++++--- src/services/qa.v2.service.ts | 79 ++++++++++++++++++++++- src/services/session-history.service.ts | 54 ++++++++++++++++ 3 files changed, 207 insertions(+), 9 deletions(-) diff --git a/src/services/qa.service.ts b/src/services/qa.service.ts index edbcc01..56a209f 100644 --- a/src/services/qa.service.ts +++ b/src/services/qa.service.ts @@ -109,6 +109,54 @@ export const answerStream = async ({ let retrievalMetaPayload: Record | null = null; let clientDisconnected = false; + const replayCachedAnswer = async (cached: sessionHistoryService.CachedAnswerResult) => { + if (cached.searchPlan) { + stream.write(`event: search_plan\n`); + stream.write(`data: ${JSON.stringify(cached.searchPlan)}\n\n`); + } + const context = Array.isArray((cached.retrievalMeta as any)?.context) + ? (cached.retrievalMeta as any).context + : null; + if (context) { + stream.write(`event: search_result\n`); + stream.write(`data: ${JSON.stringify(context)}\n\n`); + stream.write(`event: context\n`); + stream.write(`data: ${JSON.stringify(context)}\n\n`); + } + const existFlag = (cached.retrievalMeta as any)?.exist_in_post_status; + if (typeof existFlag === 'boolean') { + stream.write(`event: exist_in_post_status\n`); + stream.write(`data: ${JSON.stringify(existFlag)}\n\n`); + } + stream.write(`event: answer\n`); + stream.write(`data: ${JSON.stringify(cached.answer)}\n\n`); + + try { + if (!questionEmbedding) throw new Error('Missing question embedding for cache replay'); + await sessionHistoryService.persistConversation({ + sessionId: session.id, + requesterUserId, + ownerUserId, + question, + answer: cached.answer, + searchPlan: cached.searchPlan ?? undefined, + retrievalMeta: cached.retrievalMeta ?? undefined, + categoryId, + postId, + questionEmbedding, + }); + stream.emit('session_saved', sessionSavedPayload(session, true)); + } catch (error) { + DebugLogger.error('qa', { + type: 'debug.qa.cache_persistence_error', + sessionId: session.id, + message: (error as Error)?.message ?? 'unknown', + }); + stream.emit('session_error', sessionErrorPayload(session, 'persistence_failed')); + } + stream.end(); + }; + stream.once('client_disconnect', () => { clientDisconnected = true; }); @@ -119,6 +167,28 @@ export const answerStream = async ({ userRepository.findUserBlogMetadata(ownerUserId), sessionHistoryService.loadRecentMessages(session.id), ]); + const embeddingVector = await createEmbeddings([question]); + questionEmbedding = embeddingVector[0]; + + const cachedAnswer = + questionEmbedding && + (await sessionHistoryService.findCachedAnswer({ + ownerUserId, + requesterUserId, + embedding: questionEmbedding, + postId: postId ?? undefined, + categoryId: categoryId ?? undefined, + })); + + if (cachedAnswer) { + DebugLogger.log('qa', { + type: 'debug.qa.cache_hit', + sessionId: session.id, + similarity: cachedAnswer.similarity, + }); + await replayCachedAnswer(cachedAnswer); + return; + } const toSimpleMessages = ( raw: any[] @@ -163,20 +233,15 @@ export const answerStream = async ({ qaPrompts.createPostContextPrompt(post, processedContent, question, speechTonePrompt, blogMeta ?? undefined) ); - if (!questionEmbedding) { - [questionEmbedding] = await createEmbeddings([question]); - } - searchPlanPayload = { mode: 'post', post_id: postId }; retrievalMetaPayload = { strategy: '단일 포스트 컨텍스트', post_id: postId, + context: [{ postId: post.id, postTitle: post.title }], + exist_in_post_status: true, }; } else { - if (!questionEmbedding) { - [questionEmbedding] = await createEmbeddings([question]); - } - const similarChunks = await postRepository.findSimilarChunks(ownerUserId, questionEmbedding, categoryId); + const similarChunks = await postRepository.findSimilarChunks(ownerUserId, questionEmbedding!, categoryId); const existInPost = similarChunks.length > 0; stream.write(`event: exist_in_post_status\ndata: ${JSON.stringify(existInPost)}\n\n`); @@ -199,6 +264,8 @@ export const answerStream = async ({ const retrievalMeta = { strategy: categoryId ? `임베딩 기반 RAG (카테고리 ${categoryId})` : '임베딩 기반 RAG', resultCount: similarChunks.length, + context, + exist_in_post_status: existInPost, }; messages = toSimpleMessages( qaPrompts.createRagPrompt(question, ragChunks, speechTonePrompt, { diff --git a/src/services/qa.v2.service.ts b/src/services/qa.v2.service.ts index cd2a8e1..0107531 100644 --- a/src/services/qa.v2.service.ts +++ b/src/services/qa.v2.service.ts @@ -92,6 +92,54 @@ export const answerStreamV2 = async ({ let questionEmbedding: number[] | null = null; let clientDisconnected = false; + const replayCachedAnswer = async (cached: sessionHistoryService.CachedAnswerResult) => { + if (cached.searchPlan) { + stream.write(`event: search_plan\n`); + stream.write(`data: ${JSON.stringify(cached.searchPlan)}\n\n`); + } + const context = Array.isArray((cached.retrievalMeta as any)?.context) + ? (cached.retrievalMeta as any).context + : null; + if (context) { + stream.write(`event: search_result\n`); + stream.write(`data: ${JSON.stringify(context)}\n\n`); + stream.write(`event: context\n`); + stream.write(`data: ${JSON.stringify(context)}\n\n`); + } + const existFlag = (cached.retrievalMeta as any)?.exist_in_post_status; + if (typeof existFlag === 'boolean') { + stream.write(`event: exist_in_post_status\n`); + stream.write(`data: ${JSON.stringify(existFlag)}\n\n`); + } + stream.write(`event: answer\n`); + stream.write(`data: ${JSON.stringify(cached.answer)}\n\n`); + + try { + if (!questionEmbedding) throw new Error('Missing question embedding for cache replay'); + await sessionHistoryService.persistConversation({ + sessionId: session.id, + requesterUserId, + ownerUserId, + question, + answer: cached.answer, + searchPlan: cached.searchPlan ?? undefined, + retrievalMeta: cached.retrievalMeta ?? undefined, + categoryId, + postId, + questionEmbedding, + }); + stream.emit('session_saved', sessionSavedPayload(session, true)); + } catch (error) { + DebugLogger.error('qa', { + type: 'debug.qa.v2.cache_persistence_error', + sessionId: session.id, + message: (error as Error)?.message ?? 'unknown', + }); + stream.emit('session_error', sessionErrorPayload(session, 'persistence_failed')); + } + stream.end(); + }; + stream.once('client_disconnect', () => { clientDisconnected = true; }); @@ -105,6 +153,26 @@ export const answerStreamV2 = async ({ ]); questionEmbedding = embeddingVector[0]; + const cachedAnswer = + questionEmbedding && + (await sessionHistoryService.findCachedAnswer({ + ownerUserId, + requesterUserId, + embedding: questionEmbedding, + postId: postId ?? undefined, + categoryId: categoryId ?? undefined, + })); + + if (cachedAnswer) { + DebugLogger.log('qa', { + type: 'debug.qa.v2.cache_hit', + sessionId: session.id, + similarity: cachedAnswer.similarity, + }); + await replayCachedAnswer(cachedAnswer); + return; + } + let messages: { role: 'system' | 'user' | 'assistant' | 'tool' | 'function'; content: string }[] = []; let tools: | { @@ -158,7 +226,12 @@ export const answerStreamV2 = async ({ ); searchPlanPayload = postPlan; - retrievalMetaPayload = { strategy: '단일 포스트 컨텍스트', post_id: postId }; + retrievalMetaPayload = { + strategy: '단일 포스트 컨텍스트', + post_id: postId, + context: ctx, + exist_in_post_status: true, + }; } else { // 질문 기반 검색 계획 생성 경로 const planPair = await generateSearchPlan(question, { user_id: ownerUserId, category_id: categoryId }); @@ -185,6 +258,8 @@ export const answerStreamV2 = async ({ const retrievalMeta = { strategy: '임베딩 기반 RAG (검색 계획 폴백)', resultCount: similarChunks.length, + context, + exist_in_post_status: similarChunks.length > 0, }; messages = toSimpleMessages( qaPrompts.createRagPrompt(question, ragChunks, speechTonePrompt, { @@ -292,6 +367,8 @@ export const answerStreamV2 = async ({ : '검색 계획 기반 임베딩', plan, resultCount: rows.length, + context, + exist_in_post_status: rows.length > 0, }; messages = toSimpleMessages( qaPrompts.createRagPrompt(question, planChunks, speechTonePrompt, { diff --git a/src/services/session-history.service.ts b/src/services/session-history.service.ts index f68d9e5..3ccb2eb 100644 --- a/src/services/session-history.service.ts +++ b/src/services/session-history.service.ts @@ -4,6 +4,7 @@ import * as sessionRepository from '../repositories/ask-session.repository'; import { withTransaction } from '../utils/db'; export const HISTORY_MESSAGE_LIMIT = 4; +export const DUPLICATE_SIMILARITY_THRESHOLD = 0.93; export const loadRecentMessages = async (sessionId: number, limit = HISTORY_MESSAGE_LIMIT) => { const boundedLimit = Math.max(0, Math.min(limit, HISTORY_MESSAGE_LIMIT)); @@ -77,3 +78,56 @@ export const persistConversation = async ({ await sessionRepository.touchSessionLastQuestion(sessionId, client); }); }; + +export interface CachedAnswerResult { + answer: string; + searchPlan: Record | null; + retrievalMeta: Record | null; + similarity: number; +} + +export interface FindCachedAnswerParams { + ownerUserId: string; + requesterUserId: string; + embedding: number[]; + postId?: number; + categoryId?: number; + threshold?: number; +} + +export const findCachedAnswer = async ({ + ownerUserId, + requesterUserId, + embedding, + postId, + categoryId, + threshold = DUPLICATE_SIMILARITY_THRESHOLD, +}: FindCachedAnswerParams): Promise => { + const candidates = await embeddingRepository.findSimilarEmbeddings({ + ownerUserId, + requesterUserId, + embedding, + postId: postId ?? null, + categoryId: categoryId ?? null, + limit: 3, + }); + + for (const candidate of candidates) { + if (candidate.similarity < threshold) continue; + if (!candidate.answerMessageId) continue; + + const userMessage = await messageRepository.getMessageById(candidate.messageId); + if (!userMessage) continue; + const assistantMessage = await messageRepository.getMessageById(candidate.answerMessageId); + if (!assistantMessage) continue; + + return { + answer: assistantMessage.content, + searchPlan: userMessage.searchPlan ?? null, + retrievalMeta: assistantMessage.retrievalMeta ?? null, + similarity: candidate.similarity, + }; + } + + return null; +}; From 8bfe222730cb81bdb18f4177b74feec8fc11a2c0 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 01:31:19 +0900 Subject: [PATCH 09/21] =?UTF-8?q?=E2=9C=A8=20=EC=9E=84=EB=B2=A0=EB=94=A9?= =?UTF-8?q?=EC=8B=9C=20=ED=95=B4=EB=8B=B9=20=EC=9C=A0=EC=A0=80=EC=9D=98=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=9E=AC=EC=82=AC=EC=9A=A9=EC=9A=A9=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/worker/queue-consumer.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/worker/queue-consumer.ts b/src/worker/queue-consumer.ts index 196c3d9..bffbbbb 100644 --- a/src/worker/queue-consumer.ts +++ b/src/worker/queue-consumer.ts @@ -7,6 +7,7 @@ import { storeTitleEmbedding, } from '../services/embedding.service'; import { findPostById } from '../repositories/post.repository'; +import { deleteEmbeddingsByOwner } from '../repositories/ask-message-embedding.repository'; type EmbeddingJob = { postId: number; @@ -86,6 +87,8 @@ const processJob = async (job: EmbeddingJob) => { const title = typeof post.title === 'string' ? post.title.trim() : ''; const content = typeof post.content === 'string' ? post.content.trim() : ''; + let invalidateCachedAnswers = false; + if (shouldProcessTitle) { if (!title) { console.warn('[embedding-worker]', { @@ -96,6 +99,7 @@ const processJob = async (job: EmbeddingJob) => { } else { await storeTitleEmbedding(postId, title); console.log(`[embedding-worker] stored title embedding for post ${postId}`); + invalidateCachedAnswers = true; } } @@ -125,6 +129,24 @@ const processJob = async (job: EmbeddingJob) => { console.log( `[embedding-worker] stored content embeddings for post ${postId} (chunks=${chunks.length})` ); + invalidateCachedAnswers = true; + } + + if (invalidateCachedAnswers) { + try { + const removed = await deleteEmbeddingsByOwner(post.user_id); + console.log('[embedding-worker]', { + type: 'worker.ask_cache.invalidate', + ownerUserId: post.user_id, + removed, + }); + } catch (error) { + console.error('[embedding-worker]', { + type: 'worker.ask_cache.invalidate_failed', + ownerUserId: post.user_id, + message: (error as Error)?.message ?? 'unknown', + }); + } } }; From 79e470cf6079b11f81006aecfe9948e3968de9c8 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 11:50:09 +0900 Subject: [PATCH 10/21] =?UTF-8?q?=F0=9F=90=9B=20open=20ai=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/llm/providers/openai-responses.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/llm/providers/openai-responses.ts b/src/llm/providers/openai-responses.ts index 82e808b..598ff28 100644 --- a/src/llm/providers/openai-responses.ts +++ b/src/llm/providers/openai-responses.ts @@ -8,10 +8,9 @@ const openai = new OpenAI({ apiKey: config.OPENAI_API_KEY }); const toResponsesInput = (messages: OpenAIStyleMessage[] = []) => { // 단순 채팅 메시지를 Responses API 입력 구조로 변환 - // Responses API는 content 타입으로 'text'가 아닌 'input_text'를 요구함 return messages.map((m) => ({ role: m.role, - content: [{ type: 'input_text', text: m.content }], + content: m.content, })); }; From 6fdff1da384ac0934c074444b781077571f6e72c Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 11:50:28 +0900 Subject: [PATCH 11/21] =?UTF-8?q?=F0=9F=90=9B=20db=EC=97=90=20=EC=B2=AD?= =?UTF-8?q?=ED=81=AC=20=EB=8B=A8=EC=9C=84=EB=A1=9C=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=ED=95=B4=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/qa.service.ts | 12 +++++++++++- src/services/qa.v2.service.ts | 12 +++++++++++- src/utils/sse.ts | 37 +++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/utils/sse.ts diff --git a/src/services/qa.service.ts b/src/services/qa.service.ts index 56a209f..d271c2f 100644 --- a/src/services/qa.service.ts +++ b/src/services/qa.service.ts @@ -9,6 +9,7 @@ import * as userRepository from '../repositories/user.repository'; import { createEmbeddings } from './embedding.service'; import * as sessionHistoryService from './session-history.service'; import { AskSession } from '../repositories/ask-session.repository'; +import { extractAnswerText } from '../utils/sse'; // HTML 태그를 제거하고 길이를 제한하여 LLM 컨텍스트를 정제 const preprocessContent = (content: string): string => { @@ -303,7 +304,10 @@ export const answerStream = async ({ llmStream.on('data', (chunk) => { const str = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); - bufferedAnswer += str; + const answerTexts = extractAnswerText(str); + if (answerTexts.length) { + bufferedAnswer += answerTexts.join(''); + } DebugLogger.log('qa', { type: 'debug.qa.chunk', at: Date.now(), @@ -318,6 +322,12 @@ export const answerStream = async ({ stream.end(); return; } + DebugLogger.log('qa', { + type: 'debug.qa.buffered_answer', + sessionId: session.id, + length: bufferedAnswer.length, + preview: bufferedAnswer.slice(0, 80), + }); try { if (questionEmbedding) { await sessionHistoryService.persistConversation({ diff --git a/src/services/qa.v2.service.ts b/src/services/qa.v2.service.ts index 0107531..67816c2 100644 --- a/src/services/qa.v2.service.ts +++ b/src/services/qa.v2.service.ts @@ -12,6 +12,7 @@ import { createEmbeddings } from './embedding.service'; import { DebugLogger } from '../utils/debug-logger'; import * as sessionHistoryService from './session-history.service'; import { AskSession } from '../repositories/ask-session.repository'; +import { extractAnswerText } from '../utils/sse'; // HTML을 제거하고 길이를 제한해 LLM 컨텍스트를 정리 const preprocessContent = (content: string): string => { @@ -397,7 +398,10 @@ export const answerStreamV2 = async ({ llmStream.on('data', (chunk) => { const str = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); - bufferedAnswer += str; + const answerTexts = extractAnswerText(str); + if (answerTexts.length) { + bufferedAnswer += answerTexts.join(''); + } stream.write(chunk); }); llmStream.on('end', async () => { @@ -405,6 +409,12 @@ export const answerStreamV2 = async ({ stream.end(); return; } + DebugLogger.log('qa', { + type: 'debug.qa.v2.buffered_answer', + sessionId: session.id, + length: bufferedAnswer.length, + preview: bufferedAnswer.slice(0, 80), + }); try { if (questionEmbedding) { await sessionHistoryService.persistConversation({ diff --git a/src/utils/sse.ts b/src/utils/sse.ts new file mode 100644 index 0000000..dc4b217 --- /dev/null +++ b/src/utils/sse.ts @@ -0,0 +1,37 @@ +export const extractAnswerText = (sseChunk: string): string[] => { + if (!sseChunk) return []; + const blocks = sseChunk.split('\n\n'); + const results: string[] = []; + + for (const block of blocks) { + if (!block.trim()) continue; + const lines = block.split('\n'); + let eventName: string | null = null; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice(5)); + } + } + + if (eventName === 'answer' && dataLines.length > 0) { + const raw = dataLines.join('\n').trim(); + if (!raw) continue; + try { + const parsed = JSON.parse(raw); + if (typeof parsed === 'string') { + results.push(parsed); + } else { + results.push(JSON.stringify(parsed)); + } + } catch { + results.push(raw); + } + } + } + + return results; +}; From 351eaa64c7da6ddcadefc8ce0c99f0ba3e923507 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sat, 15 Nov 2025 13:58:10 +0900 Subject: [PATCH 12/21] =?UTF-8?q?=F0=9F=93=9D=20api=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/history-tasks/ASK_SESSION_INTEGRATION.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/history-tasks/ASK_SESSION_INTEGRATION.md diff --git a/docs/history-tasks/ASK_SESSION_INTEGRATION.md b/docs/history-tasks/ASK_SESSION_INTEGRATION.md new file mode 100644 index 0000000..085b1a2 --- /dev/null +++ b/docs/history-tasks/ASK_SESSION_INTEGRATION.md @@ -0,0 +1,253 @@ +# ASK Session Integration Guide + +이 문서는 프론트엔드가 새 ASK 세션/히스토리 기능을 활용하기 위해 필요한 API 계약과 구현 예시를 정리한 자료다. `/ai/ask`, `/ai/v2/ask` 스트림 요청부터 세션 REST 엔드포인트, 무한 스크롤 메시지 페이징까지 한 흐름으로 설명한다. + +--- + +## 1. ASK 요청 흐름 + +### 1.1 세션 ID 확보/생성 +1. 기존 세션을 재사용할 때는 세션 목록 API(`GET /ai/v2/sessions`)로 ID를 조회한 뒤 선택한다. +2. 새 세션을 만들려면 ASK 요청의 `session_id`를 `null`이거나 생략하고, `user_id`(챗봇 주인 ID)를 반드시 포함한다. + - 서버가 자동으로 세션을 생성하고 다음을 반환한다. + - HTTP 헤더 `session-id: ` + - SSE `event: session` → `{ session_id, owner_user_id, requester_user_id }` +3. 새 ID를 받으면 프론트에서 상태에 저장하고 이후 요청에서는 `session_id`만 전달하면 된다. 이때 `user_id`는 optional이며, 보낸 경우 DB owner와 일치해야 한다. + +### 1.2 `/ai/ask` / `/ai/v2/ask` SSE 요청 샘플 + +```http +POST /ai/v2/ask HTTP/1.1 +Authorization: Bearer +Content-Type: application/json + +{ + "question": "최근 인프라 포스트 요약해줘", + "user_id": "blog-owner-123", // 새 세션일 때 필수 + "session_id": null, // null 또는 생략 → 세션 자동 생성 + "category_id": 42, + "speech_tone": -2 +} +``` + +스트림 이벤트 순서(상황에 따라 일부 생략): +1. `event: session` *(신규 세션인 경우)* +2. `event: search_plan` +3. `event: rewrite` / `event: keywords` *(하이브리드 일 때)* +4. `event: search_result`, `event: search_result_meta`, `event: exist_in_post_status`, `event: context` +5. `event: hybrid_result`, `event: hybrid_result_meta` *(필요 시)* +6. `event: answer` (LLM 토큰이 들어있는 SSE) +7. `event: session_saved` → `{ session_id, owner_user_id, requester_user_id, cached }` + - `cached: true`는 질문이 캐시 적중되어 기존 답변을 재사용했음을 의미 +8. 오류 시 `event: session_error`(reason 포함) + 기존 `event: error` + +프론트에서는 `session_saved`/`session_error`를 기준으로 UI 상태(“보관 완료” 배지 등)를 갱신할 수 있다. + +### 1.3 캐시 히트 처리 +동일한 질문(같은 사용자와 필터)일 경우 서버가 자동으로 캐시를 재생한다. +- SSE로 `search_plan`/`search_result`/`answer`가 즉시 도착하고, `session_saved` 이벤트의 `cached`가 `true`. +- **프론트 액션은 일반 답변과 동일**: SSE 순서/값이 동일하게 재생되므로 별도 분기를 둘 필요는 없지만, `cached`를 활용해 “이전 답변을 재사용했습니다” 같은 안내를 띄울 수 있다. + +--- + +## 2. 세션 REST API + +### 2.1 목록 조회 `GET /ai/v2/sessions` +- 쿼리 파라미터 + - `limit`: 기본 20, 최대 50 + - `cursor`: Base64(`created_at|id`) 문자열 + - `owner_user_id`: 특정 블로그/챗봇만 필터링할 때 사용 +- 응답 +```json +{ + "sessions": [ + { + "session_id": 123, + "owner_user_id": "blog-owner-123", + "requester_user_id": "viewer-999", + "title": "인프라 정리 질문", + "metadata": {}, + "last_question_at": "2025-01-19T10:05:12.123Z", + "created_at": "2025-01-19T09:55:00.000Z", + "updated_at": "2025-01-19T10:05:12.123Z", + "message_count": 4 + } + ], + "paging": { + "cursor": "MjAyNS0wMS0xOVQxMDowNToxMi4xMjNa|123", + "has_more": true + } +} +``` +- 페이징 구현 예시 + 1. 최초 호출: `GET /ai/v2/sessions?limit=20` + 2. 응답 `paging.cursor`가 존재하면 “더 보기” 클릭 시 `GET ...?cursor=` + 3. `has_more=false`일 때까지 반복 + +### 2.2 단일 세션 메타 `GET /ai/v2/sessions/:id` +- 자신이 만든 세션이 아니면 404. +- `message_count`를 추가로 주므로 목록에서 선택한 뒤 최신 상태를 다시 확인할 수 있다. + +### 2.3 메시지 페이지네이션 `GET /ai/v2/sessions/:id/messages` +- 쿼리 + - `limit` (default 20, max 50) + - `cursor` + - `direction`: `'backward'`(기본) 또는 `'forward'` +- 응답 +```json +{ + "session_id": 123, + "owner_user_id": "blog-owner-123", + "requester_user_id": "viewer-999", + "messages": [ + { + "id": 456, + "role": "user", + "content": "최근 인프라 글을 알려줘", + "search_plan": {...}, + "retrieval_meta": null, + "created_at": "2025-01-19T10:05:12.123Z" + }, + { + "id": 457, + "role": "assistant", + "content": "인프라 관련 최신 글은 ...", + "search_plan": null, + "retrieval_meta": {...}, + "created_at": "2025-01-19T10:05:20.000Z" + } + ], + "paging": { + "direction": "backward", + "has_more": true, + "next_cursor": "MjAyNS0wMS0xOVQxMDowNToxMi4xMjNa|456" + } +} +``` +- **무한 스크롤 구현 팁** + 1. 최신 메시지를 불러오려면 `direction=backward`, `cursor` 생략으로 시작. UI에서는 리스트 끝에 붙인다. + 2. 위로 스크롤하여 과거 메시지를 계속 불러오고 싶다면 응답의 `next_cursor`를 사용해 `GET ...?cursor=&direction=backward`. + 3. 대화 중간으로 점프해 이후 메시지를 로드하려면 동일 cursor를 `direction=forward`로 호출하면 된다. + 4. 응답 메시지는 API에서 시간순으로 이미 정렬되어 있으므로 바로 렌더링하면 된다. + +**무한 스크롤 의사 코드** +```ts +type PagingState = { + prevCursor: string | null; + nextCursor: string | null; + hasMorePrev: boolean; +}; + +const state: PagingState = { prevCursor: null, nextCursor: null, hasMorePrev: true }; + +// 최신(아래쪽) 메시지 로드 +const loadLatest = async () => { + const params = new URLSearchParams({ limit: '20', direction: 'backward' }); + if (state.prevCursor) params.set('cursor', state.prevCursor); + const res = await fetch(`/ai/v2/sessions/${sessionId}/messages?${params}`, { headers }); + const body = await res.json(); + renderPrepend(body.messages); // 위쪽에 추가 + state.prevCursor = body.paging?.next_cursor ?? null; + state.hasMorePrev = Boolean(body.paging?.has_more); +}; + +// 사용자가 아래로 내려간 뒤 이후 메시지를 보고 싶을 때 +const loadForward = async () => { + if (!state.nextCursor) return; + const params = new URLSearchParams({ limit: '20', direction: 'forward', cursor: state.nextCursor }); + const res = await fetch(`/ai/v2/sessions/${sessionId}/messages?${params}`, { headers }); + const body = await res.json(); + renderAppend(body.messages); // 아래쪽에 추가 + state.nextCursor = body.paging?.next_cursor ?? null; +}; +``` + +### 2.4 PATCH / DELETE +- `PATCH /ai/v2/sessions/:id` + - Body: `{ "title": "...", "metadata": { ... } }` (둘 중 하나 이상 필수) + - 성공 시 최신 메타를 반환. +- `DELETE /ai/v2/sessions/:id` + - `{ "session_id": 123, "deleted": true }` + - 세션/메시지/임베딩이 모두 cascade로 제거되므로 프론트에서 제거 후 새로고침 필요 없음. + +--- + +## 3. 프론트엔드 구현 참고 + +### 3.1 ASK 스트림 핸들러 의사 코드 +```ts +const sse = new EventSourcePolyfill('/ai/v2/ask', { headers: { Authorization: `Bearer ${token}` }, payload }); +const state = { sessionId: null, chunks: [] }; + +sse.addEventListener('session', (evt) => { + const data = JSON.parse(evt.data); + state.sessionId = data.session_id; + // 새 세션 ID를 저장해 다음 질문에 사용 +}); + +sse.addEventListener('search_plan', (evt) => { ... }); +sse.addEventListener('context', (evt) => { ... }); +sse.addEventListener('answer', (evt) => { + state.chunks.push(JSON.parse(evt.data)); + renderStreamingAnswer(state.chunks.join('')); +}); + +sse.addEventListener('session_saved', (evt) => { + const data = JSON.parse(evt.data); + showToast(data.cached ? '기존 답변을 재사용했어요.' : '대화가 저장되었습니다.'); +}); + +sse.addEventListener('session_error', (evt) => { + console.warn('세션 저장 실패', evt.data); +}); + +sse.onerror = () => { + sse.close(); +}; +``` + +### 3.2 대화 목록/상세 UI 시나리오 +1. **좌측 패널**: `/ai/v2/sessions?limit=20`으로 최근 대화 조회 → 커서 기반 “더 보기” 버튼. +2. **메시지 영역**: 세션을 선택하면 `GET /ai/v2/sessions/:id/messages`로 최신 메시지 불러오기 → `direction=backward`. +3. **무한 스크롤**: 맨 위로 스크롤되면 `cursor=previous.next_cursor`로 과거 메시지 로드. +4. **실시간 갱신**: SSE에서 받은 user/assistant 메시지를 메모리에 쌓고, 스트림 종료 후 `session_saved` 이벤트가 오면 REST API 결과와 동기화 가능. + +### 3.3 세션 ID 전파 +- 새 ASK 요청 → 응답 헤더 `session-id`와 `event: session`을 받으면, 프론트의 현재 대화 객체에 그 ID를 기록한다. +- 이후 폼 전송 시 `session_id`만 바디에 넣어서 이어서 질문할 수 있다. +- 다른 블로그로 이동하면 기존 세션 ID를 버리고 `user_id`를 새 값으로 넣어 다시 질문하면 된다(서버가 다른 owner와 세션을 매칭하지 않도록 검증함). + +--- + +## 4. 오류 및 예외 처리 + +### 4.1 주요 SSE 이벤트 & UI 매핑 + +| 이벤트 | 예시 payload | 권장 UI 처리 | +|--------|--------------|--------------| +| `session` | `{ session_id, owner_user_id, requester_user_id }` | 새 세션 카드 추가, 현재 대화 헤더 업데이트 | +| `search_plan` | `{ mode: 'rag', ... }` | 디버그 패널, “검색 계획 준비 중…” 표시 | +| `rewrite` / `keywords` | `["재작성1", ...]` / `["키워드1", ...]` | 검색 과정 시각화(선택 사항) | +| `search_result` / `hybrid_result` | `[ { postId, postTitle }, ... ]` | 참고 컨텍스트 목록 표시 | +| `search_result_meta` / `hybrid_result_meta` | 추가 메타 정보 | 고급 모드 또는 디버그 뷰 | +| `exist_in_post_status` | `true/false` | “관련 글을 찾음/찾지 못함” 안내 뱃지 | +| `context` | `[ { postId, postTitle }, ... ]` | UI 우측 “참조 글 목록” 섹션 | +| `answer` | `"…LLM 청크…"` | 채팅 말풍선 실시간 갱신 | +| `session_saved` | `{ session_id, cached }` | 저장 완료/캐시 재사용 토스트, 상태 뱃지 | +| `session_error` | `{ reason }` | 오류 토스트, 재시도 버튼 노출 | +| `error` | `{ message }` | 스트림 종료 + 에러 메시지 | + +### 4.2 오류 대응 요약 + +| 상황 | 응답/이벤트 | 대응 방법 | +|------|-------------|-----------| +| `session_id`가 유효하지 않음 | 400 + `{ message: 'Invalid session_id' }` | 프론트 세션 상태 초기화, 새 세션 생성 | +| 세션 owner 불일치 | 409 + `{ message: 'Session owner mismatch' }` | 다른 블로그로 전환 후 새 세션 시작 | +| 세션 접근 권한 없음 | 404 (`존재하지 않는다고 응답`) | 리스트를 다시 로드해 실제로 존재하는지 확인 | +| 포스트가 삭제/비공개 | SSE `event: error` + `session_error(reason=post_not_found/forbidden_post)` | 사용자에게 안내 후 대화 중단 | +| 저장 실패 | SSE `event: session_error` + reason | 로그/토스트로 사용자에게 “대화 저장에 실패했습니다” 알림 | +| LLM 오류/스트림 예외 | SSE `event: error` + `session_error(reason=llm_error/stream_error)` | 스트림 종료 후 재시도 UI | + +--- + +이 가이드를 토대로 세션 기반 ASK UX를 구현하면, 신규 세션 생성에서 히스토리 로딩까지 백엔드와 일관된 동작을 보장할 수 있다. 추가 질문은 `docs/history-tasks/ASK_SESSION_MANAGEMENT` 시리즈나 최근 커밋을 참고한다. From 6f028b7ec22475497ce3c47db32bcd2390869672 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 16 Nov 2025 16:07:55 +0900 Subject: [PATCH 13/21] =?UTF-8?q?=F0=9F=93=9D=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK.md | 465 +++++++++++++------------------------------------------- 1 file changed, 102 insertions(+), 363 deletions(-) diff --git a/TASK.md b/TASK.md index 674dc3d..4dde083 100644 --- a/TASK.md +++ b/TASK.md @@ -1,363 +1,102 @@ -# ASK 세션 관리 및 대화 이력 저장 계획 - -## 1. 목표와 배경 -- `/ai/v2/ask` 흐름에 “세션” 개념을 도입해 질문·응답 히스토리를 영속화하고, LLM 호출 시 과거 맥락을 재활용한다. -- 하이브리드 검색/플래너 구조는 유지하되, 세션별 메타데이터와 메시지를 저장해 대화형 UX를 지원한다. -- `/ai/v1/ask` 경로도 동일한 세션/캐시 메커니즘을 공유하도록 범위를 확장하며, v1과 v2 모두 동일한 저장소/히스토리 API를 사용한다. `session_id` 없이 호출된 경우 서버가 즉시 신규 세션을 생성해 클라이언트에 식별자를 돌려준다. - -## 2. 요구사항 & 고려 사항 -- **세션 수명**: 클라이언트가 `session_id`를 생략하거나 `null`로 보낼 때만 서버가 신규 세션을 생성하고 식별자를 반환한다. 값이 있는 `session_id`는 항상 기존 세션을 재사용한다. -- **사용자 식별**: 세션/히스토리 소유권은 반드시 `authMiddleware`가 디코딩한 JWT의 `user_id` 클레임으로 판별한다. `/ai/(v1|v2)/ask` 요청 본문의 `user_id`는 “어떤 블로그의 챗봇을 질의하느냐”를 뜻하는 `owner_user_id`로 DB에 별도 저장한다. 세션/메시지 레코드는 항상 `(requester_user_id, owner_user_id)` 쌍을 유지해 접근 제어(요청자 기준)와 캐시/무효화(블로그 주인 기준)를 동시에 지원한다. -- **세션 생성 전제**: 새 세션을 만들려면 반드시 목표 챗봇(= `owner_user_id`)을 명시해야 한다. `/ai/(v1|v2)/ask` 요청에서 `session_id`가 없거나 null일 때만 서버가 새 세션을 자동 생성하며, 이후 세션이 속한 모든 메시지·임베딩에는 동일한 `owner_user_id`가 저장된다. 별도의 세션 생성 API는 제공하지 않는다. -- **Owner 일관성 검증**: 클라이언트가 `/ai/(v1|v2)/ask`에 `session_id`와 `user_id`를 동시에 보낼 때 두 값이 가리키는 owner가 다르면 요청을 즉시 400/409로 거부한다(프런트에서도 다른 owner로 세션 다시 쓰기를 금지). 서버는 DB에 저장된 세션의 `owner_user_id`를 단일 진실 소스로 신뢰하며, Body의 값과 일치할 때만 진행한다. -- **히스토리 저장**: 사용자 질문, 검색 계획 요약, 모델 답변 요약본 등 최소 정보는 DB에 보관. 토큰 비용을 고려해 원문 전체 저장 여부 판단. -- **프롬프트 구성**: 세션의 최근 N개 대화를 불러와 RAG 컨텍스트 뒤에 배치하되, 총 토큰 한도를 넘지 않도록 절단 로직 필요. -- **하이브리드/플래너 영향**: 검색 계획은 여전히 질문 단위로 생성하지만, 과거 대화에서 follow-up intent 판단에 활용될 수 있도록 프롬프트 확장 검토. -- **SSE 계약**: 세션 생성/갱신 이벤트를 추가해 클라이언트가 새 세션을 추적할 수 있게 한다. -- **보안·정합성**: 사용자별 세션 접근 제어 필요. 세션 삭제/만료 정책과 개인정보(민감 답변) 취급 주의. -- **v1/v2 공통 처리**: `/ai/v1/ask`는 검색 계획이나 하이브리드 메타가 없을 수 있으므로 저장 시 `search_plan`/`retrieval_meta`를 `NULL`로 허용하고, 히스토리 API에서 두 경로가 동일한 스키마를 사용한다. - -## 3. 작업 단계 -1. **DB 설계 & 마이그레이션** - - `ask_session` 테이블 추가 (PostgreSQL) - ```sql - CREATE TABLE ask_session ( - id BIGSERIAL PRIMARY KEY, - requester_user_id TEXT NOT NULL, -- JWT에서 파생된 실제 질문자 - owner_user_id TEXT NOT NULL, -- 챗봇/블로그 주인 - title TEXT, - metadata JSONB NOT NULL DEFAULT '{}'::jsonb, - last_question_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ); - - CREATE INDEX idx_ask_session_requester_created_at - ON ask_session (requester_user_id, created_at DESC); - CREATE INDEX idx_ask_session_owner_created_at - ON ask_session (owner_user_id, created_at DESC); - CREATE INDEX idx_ask_session_last_question_at - ON ask_session (last_question_at DESC NULLS LAST); - ``` - - `ask_message` 테이블 추가 (PostgreSQL) - ```sql - CREATE TABLE ask_message ( - id BIGSERIAL PRIMARY KEY, - session_id BIGINT NOT NULL REFERENCES ask_session(id) ON DELETE CASCADE, - role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), - content TEXT NOT NULL, - search_plan JSONB, - retrieval_meta JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - ); - - CREATE INDEX idx_ask_message_session_created_at - ON ask_message (session_id, created_at DESC, id DESC); - ``` - - 각 메시지의 소유권은 `ask_session`을 통해 추적되므로 `ask_message`에는 별도의 `requester_user_id`/`owner_user_id` 컬럼이 없다. - - `ask_message_embedding` 테이블 추가 (PostgreSQL, `pgvector` 필요) - ```sql - CREATE EXTENSION IF NOT EXISTS vector; - - CREATE TABLE ask_message_embedding ( - message_id BIGINT PRIMARY KEY REFERENCES ask_message(id) ON DELETE CASCADE, - owner_user_id TEXT NOT NULL, - requester_user_id TEXT NOT NULL, - category_id BIGINT, - post_id BIGINT, - answer_message_id BIGINT REFERENCES ask_message(id), - embedding vector(1536) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ); - - CREATE INDEX idx_ask_message_embedding_owner ON ask_message_embedding (owner_user_id); - CREATE INDEX idx_ask_message_embedding_owner_category ON ask_message_embedding (owner_user_id, category_id); - CREATE INDEX idx_ask_message_embedding_owner_post ON ask_message_embedding (owner_user_id, post_id); - CREATE INDEX idx_ask_message_embedding_requester ON ask_message_embedding (requester_user_id); - CREATE INDEX idx_ask_message_embedding_vec ON ask_message_embedding USING ivfflat (embedding vector_cosine_ops); - ``` - - 벡터 차원(1536)은 현재 `text-embedding-3-small` 모델에 맞추며, 당장은 코드/마이그레이션 모두 하드코딩 값으로 유지한다. - - `owner_user_id`는 챗봇 주인 ID로, 블로그 콘텐츠 재임베딩 시 이 값을 기준으로 `ask_message_embedding` 레코드를 일괄 삭제한다. `requester_user_id`는 동일 사용자가 반복 질문할 때 캐시를 재사용하기 위한 용도로 유지한다. - - `category_id`와 `post_id`는 모두 NULL 허용 값이며, `/ai/ask` 요청 파라미터에 따라 한 쪽만 채워질 수도 있다. 이 값을 그대로 저장해 KNN 검색 시 SQL where 조건으로 필터링한다. - - 인덱스 설계 - - `ask_session`: `(requester_user_id, created_at DESC)`, `(owner_user_id, created_at DESC)`, `last_question_at DESC` (최근 세션/대상별 리스트용). - - `ask_message`: `(session_id, created_at DESC, id DESC)` 커버링 인덱스만 유지해 세션 단위 페이지네이션을 지원. - - 마이그레이션/롤백 스크립트 작성 후 `docs/migrations`에 설명 추가. - -2. **Repository/Service 계층 추가** - - `src/repositories/ask-session.repository.ts`(세션 CRUD, 최신 메시지 조회). - - `src/repositories/ask-message.repository.ts`(메시지 insert/조회). - - 트랜잭션 지원이 필요하면 `db.ts` 활용해 래퍼 제공. - -3. **세션 식별/제목 로직** - - `askV2Handler`에서 `session_id` 파라미터 읽기. 없으면 세션을 생성하고 첫 사용자 질문을 제목으로 삼아 저장 → SSE `session` 이벤트로 ID 및 제목 전달. - - `PATCH /ai/v2/sessions/:id`에서 제목, metadata를 수정할 수 있도록 repository/서비스 레이어 포함(보관/복구 없이 항상 실제 삭제). - - 사용자 검증: 전달된 세션이 JWT에서 파생된 `requester_user_id`와 일치하는지 검사 후 진행. - -4. **대화 히스토리 로드** - - `answerStreamV2` 시작 시 최근 메시지 N개 조회 (정책: 최신 2개 turn만 사용). - - LLM 메시지 배열 구성 시 `qaPrompts`에 히스토리를 prepend(역순 정렬 주의). 별도 토큰 계산 없이 2개 turn을 그대로 포함하고, 보다 엄격한 한도가 필요해지면 `utils/tokenizer` 기반 토큰 검사 추가. - -5. **검색 계획과 히스토리 연결** - - Follow-up 질문 식별을 위해 `buildSearchPlanPrompt`에 “이전 대화” 섹션 옵션 추가. - - 하이브리드 검색은 현재 질문과 재작성으로 수행하되, 필요 시 세션의 마지막 답변 제목 등을 참고하도록 확장 가능. - -6. **SSE 전송 구조 업데이트** - - 세션 생성 시 `event: session` → `{ session_id, owner_user_id, requester_user_id }`를 한 번 송신하고 응답 헤더(`session-id`)도 함께 세팅한다. - - 스트림 완료 시 `event: session_saved` → `{ session_id, owner_user_id, cached: boolean }`, 실패 시 `event: session_error` → `{ session_id, owner_user_id, reason }`를 전송한다. - - 나머지 이벤트(`search_plan`, `search_result`, `context`, `answer`, `rewrite`, `keywords`, ...)의 payload 구조는 기존과 동일하며 문서만 보강한다. - -7. **메시지 & 임베딩 저장 파이프라인** - - 질문이 유입되면 즉시 `createEmbeddings([question])`로 질문 벡터를 생성하고, 중복 질문 KNN 검사/검색 플로우/최종 저장까지 같은 벡터를 재사용한다(v1 RAG, v1 단일 포스트, v2 하이브리드 공통). - - SSE가 정상 종료될 때까지 사용자 질문, 검색 계획 요약, 요청 범위(`category_id`/`post_id`), 세션 ID, 생성된 임베딩, 최종 답변 텍스트를 메모리(또는 임시 버퍼)에 보관한다. 구현은 스트리밍을 지연시키지 않도록 하고, `answerStream`/`answerStreamV2`에서 LLM 청크를 즉시 클라이언트로 흘리되 동시에 `bufferedAnswer += chunkString` 형태로 누적 문자열을 유지하는 간단한 메모리 버퍼를 둔다. - - 스트림 종료 직전에 단일 트랜잭션을 열어 `ask_message`(user) → `ask_message`(assistant) → `ask_message_embedding` 순으로 한 번에 INSERT/UPDATE를 수행해 트랜잭션 시간을 최소화한다. v1 플로우에서는 검색 계획 관련 필드가 비어 있을 수 있으므로 NULL 허용/기본값 로직을 통일한다. - - 검색/하이브리드 파이프라인에서 이미 생성한 “질문 원문” 임베딩을 재사용해 `ask_message_embedding`을 저장하고, 같은 트랜잭션에서 `answer_message_id`를 세팅한다. `/ai/ask`에서 `post_id`가 지정된 단일 포스트 질문도 동일한 세션/캐시 흐름에 참여시키기 위해 질문 원문을 별도로 임베딩해 저장한다(내부 `createEmbeddings` 호출 재사용). - - 사용자 블로그 콘텐츠가 재임베딩되면 이전 답변의 근거가 달라질 수 있으므로, 임베딩 워커에서 특정 블로그 주인(=owner)의 포스트 임베딩을 재계산한 직후 `ask_message_embedding` 테이블에서 해당 `owner_user_id`의 모든 레코드를 일괄 삭제한다(트랜잭션 고려, 삭제 실패 시 로그만 남기고 본 작업은 계속). `queue-consumer`는 `findPostById`로 이미 포스트와 소유자 정보를 조회하므로 이를 활용하고, 삭제 실패는 try/catch로 감싼다. - - 스트림 도중 에러가 발생하거나 클라이언트가 연결을 끊으면 해당 질문/답변/임베딩을 모두 버리고 트랜잭션을 열지 않는다(불완전 대화는 저장하지 않음). 저장 중 오류 발생 시에도 트랜잭션을 롤백하고 스트림에는 영향을 주지 않으며, 경고 로그와 메트릭을 남겨 재시도/모니터링한다. - -8. **중복 질문 선별 & 재사용** - - 새 질문 수신 시 `ask_message_embedding`에서 동일 사용자 범위로 KNN 검색을 수행하고, `category_id`/`post_id` 컬럼을 이용해 현재 요청과 동일한 범위만 후보로 제한한다(예: `post_id`가 존재하면 해당 값 동일 조건, 없으면 `category_id` 비교). - ```sql - SELECT message_id, answer_message_id, 1 - (embedding <=> $1) AS similarity - FROM ask_message_embedding ame - WHERE ame.owner_user_id = $2 - AND ame.requester_user_id = $3 - AND ( - ($4::bigint IS NOT NULL AND ame.post_id = $4) - OR - ($4::bigint IS NULL AND ame.post_id IS NULL AND ame.category_id IS NOT DISTINCT FROM $5::bigint) - ) - ORDER BY ame.embedding <-> $1 - LIMIT 3; - ``` - - 상위 후보의 similarity(코사인 기준)를 계산하여 0.92~0.95 이상이고 필터 조건이 완전히 일치할 때 동일 질문으로 간주하고, 이미 저장된 `search_plan`/`retrieval_meta`/`answer` 스냅샷을 이용해 최초 질의와 동일한 이벤트 시퀀스(`search_plan` → `search_result`/`context` → `answer` → `session_saved`)를 그대로 재생한다. - - `/ai/ask`와 `/ai/v2/ask` 모두 요청 본문의 `post_id`가 있으면 임베딩 레코드의 `post_id`를 채우고, 없으면 `category_id`를 채운다(두 값이 모두 없으면 NULL). 이 설계를 기반으로 KNN 검색 시 `WHERE owner_user_id = $owner AND requester_user_id = $requester AND post_id IS NOT DISTINCT FROM $postId AND category_id IS NOT DISTINCT FROM $categoryId` 조건을 적용한다. - - 임계값 미달 또는 저장된 응답이 없을 경우 기존 검색/LLM 플로우로 진행. - -9. **테스트 & 관측성** - - 리포지토리 단위 테스트: 세션 생성, 메시지 삽입/조회, 임베딩 저장/업데이트. - - 서비스 통합 테스트: 세션 없는 요청 → 생성 확인, 기존 세션 요청 → 히스토리 포함 프롬프트 검증, 유사 질문 반복 시 캐시된 답변 반환 확인. - - 디버그 로그에 `session_id`와 재사용 여부(`qa_cached: true/false`) 추가해 추적성 확보. - -10. **문서 & 마이그레이션 가이드** - - `docs/history-tasks`에 세션 도입 배경/사용법 기록. - - 운영 배포 시 주의사항(마이그레이션 순서, 롤백 절차, `pgvector` 설치) 설명. - -## 4. 무한 스크롤 메시지 API 상세 -- **엔드포인트**: `GET /ai/v2/sessions/:sessionId/messages` -- **쿼리 파라미터** - - `cursor?: string` → `created_at` ISO 문자열과 `id` 조합을 Base64로 인코딩 (`${created_at}|${id}`)해 전달. - - `direction?: 'backward' | 'forward'` → 무한 스크롤 UX에 맞춰 과거(`backward`, default) 또는 이후(`forward`) 로딩 지원. - - `limit?: number` → 기본 20, 최대 50. -- **응답** - ```json - { - "session_id": "123", - "messages": [ - { - "id": "456", - "role": "user", - "content": "...", - "created_at": "2025-01-19T10:05:12.123Z", - "search_plan": {...}, - "retrieval_meta": {...} - } - ], - "paging": { - "direction": "backward", - "has_more": true, - "next_cursor": "MjAyNS0wMS0xOVQxMDowNToxMi4xMjNa|456" - } - } - ``` -- **PostgreSQL 조회 예시** - ```sql - WITH cursor_values AS ( - SELECT - (split_part($1, '|', 1))::timestamptz AS cursor_created_at, - (split_part($1, '|', 2))::bigint AS cursor_id - ) - SELECT * - FROM ask_message am - WHERE am.session_id = $2 - AND ( - $3 = 'forward' AND ( - am.created_at > (SELECT cursor_created_at FROM cursor_values) OR - (am.created_at = (SELECT cursor_created_at FROM cursor_values) AND am.id > (SELECT cursor_id FROM cursor_values)) - ) - OR - $3 <> 'forward' AND ( - am.created_at < (SELECT cursor_created_at FROM cursor_values) OR - (am.created_at = (SELECT cursor_created_at FROM cursor_values) AND am.id < (SELECT cursor_id FROM cursor_values)) - ) - OR $1 IS NULL - ) - ORDER BY - CASE WHEN $3 = 'forward' THEN am.created_at END ASC, - CASE WHEN $3 <> 'forward' THEN am.created_at END DESC, - am.id DESC - LIMIT $4; - ``` -- **서버 로직** - 1. `authMiddleware`에서 파생된 사용자 ID와 세션 소유권을 비교. - 2. `cursor` 미전달 시 최신 메시지를 기준으로 `backward` 모드 페이징. - 3. 응답 `messages`는 API 레이어에서 시간순 정렬(무한 스크롤 라이브러리 요구사항에 맞춰 전/후 정렬 선택). - 4. `has_more`는 조회 개수가 `limit`와 같을 때 true, `next_cursor`는 목록의 마지막 항목에서 생성. - 5. 대화가 비어 있으면 빈 배열과 `has_more: false` 반환. - -## 5. API 추가/변경 사항 - -### 5.1 `/ai/v1/ask`, `/ai/v2/ask` -- **Request Body (공통)** - ```json - { - "question": "string", - "user_id": "owner_user_id", // 챗봇/블로그 주인 (새 세션 생성 시 필수) - "session_id": "optional string", // 기존 세션 ID, 없으면 서버가 새로 생성 - "category_id": 123, // optional, follow-up 필터 - "post_id": 456, // optional, 단일 포스트 질문 - "speech_tone": -1, - "llm": { ... } // existing override 구조 - } - ``` -- **동작** - - 컨트롤러는 JWT의 `user_id` 클레임을 `requester_user_id`로 사용하고 Body의 `user_id`를 `owner_user_id`로 매핑한다. - - `session_id`가 없으면 `owner_user_id`가 반드시 포함되어야 하며 서버가 즉시 새로운 세션을 생성한다. `session_id`가 전달되면 `owner_user_id`는 optional이지만, 전달된 경우 세션이 가리키는 owner와 일치해야 한다. - - 신규 세션 생성 시 SSE 첫 이벤트(`event: session`)와 응답 헤더(`session-id`)에 ID를 반환하고, 캐시 저장 성공/실패 여부는 별도 이벤트로 통지한다. -- **Response / SSE 계약** - - 기존 `search_plan`/`search_result`/`answer` 등 이벤트 payload 형식은 변경하지 않는다. - - 신규 세션이 만들어졌을 때만 `event: session` → `{ session_id, owner_user_id, requester_user_id }`를 전송한다. - - 스트림 종료 시 히스토리 영속화/캐시 여부를 알려주는 `event: session_saved` 또는 오류 시 `event: session_error`를 송신한다. - -### 5.2 REST 세션 API - -| Endpoint | Method | 목적 | -|----------|--------|------| -| `/ai/v2/sessions` | GET | 요청자 기준 세션 목록 조회 | -| `/ai/v2/sessions/:id` | GET | 단일 세션 메타 조회(옵션) | -| `/ai/v2/sessions/:id/messages` | GET | 세션 메시지 히스토리 페이지네이션 | -| `/ai/v2/sessions/:id` | PATCH | 세션 제목/메타데이터 수정 | -| `/ai/v2/sessions/:id` | DELETE | 세션 및 메시지 삭제 | - -> 세션 생성은 `/ai/(v1|v2)/ask` 호출에서 `session_id`가 없거나 null일 때만 허용되므로 별도의 POST 엔드포인트는 제공하지 않는다. - -#### GET `/ai/v2/sessions` -- **Query Params** - - `limit` (default 20, max 50) - - `cursor` (optional, Base64 `${created_at}|${id}`) - - `owner_user_id` (optional) → 특정 챗봇만 필터링 -- **Response 200** - ```json - { - "sessions": [ - { - "session_id": "123", - "owner_user_id": "user-abc", - "requester_user_id": "req-xyz", - "title": "first question", - "metadata": {}, - "last_question_at": "2025-01-19T10:05:12.123Z", - "message_count": 4 - } - ], - "paging": { - "cursor": "base64", - "has_more": true - } - } - ``` -- **Behaviour**: 항상 requester 기준으로만 조회, owner 필터가 있으면 `owner_user_id = $owner` 조건 추가. - -#### GET `/ai/v2/sessions/:id` -- **Purpose**: 단일 세션 메타데이터를 조회해 클라이언트가 세션 헤더를 갱신할 때 사용. -- **Response 200** - ```json - { - "session_id": "123", - "owner_user_id": "user-abc", - "requester_user_id": "req-xyz", - "title": "first question", - "metadata": {}, - "created_at": "...", - "updated_at": "...", - "last_question_at": "...", - "message_count": 4 - } - ``` -- 세션이 requester에게 속하지 않으면 404 (존재 은닉). - -#### GET `/ai/v2/sessions/:id/messages` -- 이미 4장에서 상세히 정의된 무한 스크롤 스펙 사용. -- **Response**: `session_id`, `owner_user_id`, `messages`, `paging`. 각 메시지는 `{ id, role, content, search_plan, retrieval_meta, created_at }`. - -#### PATCH `/ai/v2/sessions/:id` -- **Body** - ```json - { - "title": "optional string", - "metadata": { ... } // optional JSON object - } - ``` -- **Validation**: `owner_user_id`는 수정 불가. Metadata는 JSON object만 허용(primitive/array 거부). 빈 요청이면 400. -- **Response 200** - ```json - { - "session_id": "123", - "owner_user_id": "user-abc", - "title": "updated title", - "metadata": { "topic": "infra" }, - "updated_at": "..." - } - ``` - -#### DELETE `/ai/v2/sessions/:id` -- **Response 200** - ```json - { - "session_id": "123", - "deleted": true - } - ``` -- 삭제 시 `ask_message`/`ask_message_embedding`가 ON DELETE CASCADE로 정리되므로, 워커나 캐시와의 동기화는 별도 훅 없이 로그로만 남긴다. - -### 5.3 추가 고려 사항 -- API 응답 스키마를 `docs/history-tasks` 혹은 OpenAPI 스펙에 반영. -- 모든 세션 관련 REST 엔드포인트는 `authMiddleware`로 `requester_user_id`를 추출하며, 본문에서 이 값을 받지 않는다. `owner_user_id`는 세션 생성 시 결정되며 이후 PATCH에서도 수정할 수 없다(필요 시 세션 삭제 후 재생성). -- 모바일/웹 클라이언트가 SSE 없이 REST만으로도 히스토리를 로드할 수 있도록 설계하고, SSE가 종료된 뒤 REST 히스토리와 일관성 있게 동작하도록 `session_saved`/`session_error` 이벤트를 표준화한다. - -## 6. 추후 확장 아이디어 -- 세션 제목 자동 생성(첫 질문 혹은 LLM 요약 활용). -- 메시지 요약/압축 작업을 위한 비동기 워커 도입. -- 세션 검색 UI 제공을 위한 인덱싱(예: pg_trgm) 적용. -- 멀티 디바이스 동기화를 위한 마지막 읽은 위치(last_seen_at) 관리. - -## 7. 커밋 단위 구현 계획 -1. **chore: add ask session/message schemas** - - `ask_session`, `ask_message`, `ask_message_embedding` 테이블과 관련 인덱스/확장(pgvector, GIN, IVFFlat) 마이그레이션 추가. - - `docs/migrations/README.md`에 적용/롤백 방법과 의존성(pgvector 설치 등) 문서화. -2. **chore: db helpers & config prep** - - `db.ts`에 트랜잭션 헬퍼/유틸 추가, 공통 PG 타입 정의. - - Lint/tsconfig가 신규 파일을 해석하도록 배치하고, 최소 테스트 러너(예: Jest 스켈레톤) 추가. -3. **feat: session repositories** - - `ask-session.repository.ts`, `ask-message.repository.ts`, `ask-message-embedding.repository.ts` 작성. - - 커서 기반 조회, 세션 소유권 검사, 임베딩 insert/upsert 로직 포함. -4. **feat: session REST APIs** - - `/ai/v2/sessions` 목록/단일 조회, `/ai/v2/sessions/:id/messages` 무한 스크롤, `/ai/v2/sessions/:id` PATCH/DELETE 라우터/컨트롤러 + 요청 스키마 추가. - - JWT 파생 사용자 ID를 전제로 하고, 응답/문서 업데이트 포함. (POST 엔드포인트는 없음) -5. **feat: ask endpoints auth & session plumbing** - - `/ai/ask`, `/ai/v2/ask`에서 바디 `user_id`와 JWT `requester_id`를 명시적으로 구분. - - `session_id`가 없거나 null일 때만 새 세션을 만들고, 존재하면 해당 세션/owner 일관성을 검증한다. - - `session_id` 파라미터 처리, SSE `session` 이벤트/헤더 전송, 질문 범위(`category_id`/`post_id`)와 `owner_user_id` 파생 로직 도입. -6. **feat: history hydration & persistence** - - `answerStream`/`answerStreamV2`에서 최신 2턴 히스토리를 로드해 프롬프트에 prepend. - - SSE 청크를 즉시 전송하면서 메모리 버퍼에 누적하고, 스트림 종료 시 질문/답변/임베딩을 단일 트랜잭션으로 저장. -7. **feat: duplicate question cache & embeddings** - - 질문 벡터 생성·재사용, `ask_message_embedding` KNN 조회, `category_id`/`post_id` 동일 시 기존 답변 재생산. - - 캐시 적중 시 저장된 `search_plan`/`retrieval_meta`/`answer`를 사용해 기존 이벤트 시퀀스를 그대로 재생하고, 미적중 시 새 임베딩/답변을 저장해 캐시 여부를 로그로 노출. -8. **feat: embedding worker invalidation** - - `queue-consumer.ts`에서 동일 사용자 포스트 재임베딩 시 해당 `ask_message_embedding` 레코드 일괄 삭제. - - 실패 시 재시도/로그 처리, 단위 테스트 포함. -9. **docs/test: usage and coverage** - - `docs/history-tasks` 및 README에 세션 흐름/SSE 이벤트 기록. - - 통합 테스트: 세션 생성→질문→히스토리 페이징, 캐시 적중, 롤백 경로 등을 검증. +## 중복 질문 판별 개선 계획 +- 기존 ask_message_embedding은 단지 이본 질문의 벡터 값만 저장하므로 현재 프로젝트에서 작동중인 맥락 주입 부분에서 불일치가 가능함 +- 최근 2턴(사용자 질문)을 다 불러와 이번 질문과 합쳐 하나의 텍스트 블록으로 만든다. 예: `[Q-2]\n[Q-1]\n[Q-now]`. +- 없을 시 현재 질문만 저장 +- 프롬프트에 주입하는 히스토리도 2턴이므로 캐시 비교 기준과 완전히 일치해 follow-up 질문 반복 시 캐시 정확도가 올라간다. +- 임베딩은 질문당 1회만 추가로 수행되므로 비용 증가는 미미하며, 길이가 길 경우 앞 턴을 줄이는 로직을 헬퍼에서 처리한다. + +### 말투 ID 독립 컬럼 및 마이그레이션 계획 +1. **DB 스키마 변경**: 중복 질문 판별 전용임을 명시하기 위해 `ask_message_embedding`을 `ask_question_cache`(또는 `ask_duplicate_embedding`)으로 리네임한다. 리네임 후 `speech_tone_id integer NOT NULL DEFAULT -1` 컬럼을 추가하고, 기존 레코드는 tone 정보가 없어 재사용 가치가 낮으므로 컬럼 추가 직후 `TRUNCATE` 또는 `DELETE`로 전량 삭제한다. +2. **엔티티/레포지토리 업데이트**: `ask-message-embedding.repository.ts`에서 `MessageEmbedding` 타입과 `upsertEmbedding`/`findSimilarEmbeddings` 결과에 `speechToneId` 필드를 노출한다. +3. **persistConversation 수정**: `session-history.service.ts`에서 `persistConversation` 호출 시 `speechTone` 파라미터를 새로 받아, 메시지 레포지토리에는 아무 변화 없이 `embeddingRepository.upsertEmbedding`에만 전달한다. +4. **캐시 비교 및 재작성 로직**: `findCachedAnswer`가 `speechToneId`를 반환하도록 수정하고, `qa.service.ts`/`qa.v2.service.ts`에서 tone ID 비교 결과에 따라 캐시 재생 또는 tone 재작성 분기를 처리한다. +5. **백필 전략(옵션)**: 추가 데이터를 이관하고 싶다면 별도 배치를 설계해 `speech_tone_id`를 채울 수 있지만, 초기에는 기본값 `-1`을 tone 불명 값으로 삼고 rewrite 플로우를 따른다. +6. **명명 개선 검토**: 테이블 리네임과 컬럼 추가는 같은 마이그레이션에서 처리하고, 관련 코드/SQL 명칭도 일괄 업데이트한다. + +## 캐시 응답 말투 정합성 계획 +1. **목표**: 캐시에서 꺼낸 답변의 말투가 API 요청 값과 일치하면 즉시 재사용하고, 불일치하면 동일 답변을 tone 전용 LLM으로 재작성한 뒤 전달한다. +2. **tone 검증 순서** + - (a) `findCachedAnswer`가 반환한 후보 배열(유사도 기준 정렬)을 순회하며 `speechToneId === 요청 값`인 항목을 찾는다. + - (b) 같은 ID가 있는 경우 해당 후보를 즉시 재생하고, tone 재작성은 생략한다. + - (c) 같은 ID가 하나도 없으면 유사도 1순위 후보를 선택해 `replace-tone.service.ts`에 전달하고, tone만 바꾼 결과를 사용자에게 전송한다. (threshold 미달이면 기존처럼 새 LLM 답변 생성) +3. **replace-tone.service.ts** + - 시그니처: `rewriteTone(answer: string, opts: { speechToneId: number; speechTonePrompt: string; llm?: LlmOverride })`. + - 프롬프트 구성 + - System: "너는 편집자다. 아래 콘텐츠의 의미, 사실, 구조를 훼손하지 말고, 요청된 말투 지시만 반영해 다시 작성해." + - User: ```tone 지시: ${speechTonePrompt} +원문: ${answer}``` + - 모델/프로바이더는 운영 편의를 위해 기존 QA 파이프라인과 동일한 `generate` 래퍼를 그대로 사용한다(즉, Ask 요청에서 선택된 LLM 설정을 재사용). temperature는 0~0.2, max_tokens는 원문 길이와 비슷하게 맞춘다. + - tone 재작성 결과가 비어 있거나 원문과 지나치게 다르면 실패로 간주하고 캐시를 포기한 뒤 RAG/LLM 경로로 폴백한다. +4. **서비스 연동** (`qa.service.ts`, `qa.v2.service.ts`) + - `findCachedAnswer`가 tone ID와 함께 후보 배열을 돌려줄 수 있도록 확장하거나, tone별 우선순위를 반환한다. + - tone 동일 후보가 있으면 기존 `replayCachedAnswer`를 실행한다. + - tone 불일치만 있는 경우엔 `rewriteTone` 호출 후, SSE `answer` 이벤트와 `persistConversation`에 재작성된 텍스트를 사용하고 `speech_tone_id`를 목표 값으로 저장한다. +5. **운영 고려사항**: tone ID의 기본값을 `-1`(unknown)으로 두고, 이 값은 tone 동일 후보 검색에서 매칭되지 않도록 처리한다. 즉, 모든 후보가 `-1`이면 top-1 rewrite 대상으로만 사용된다. + +### 구현 우선순위 및 단계 +1. **중복 질문 판별 개선**: 히스토리 2턴을 합친 텍스트 블록 기반으로 임베딩을 저장하고 캐시 비교에 활용한다. (상단 계획을 먼저 적용) +2. **말투 ID 컬럼 추가**: 위 마이그레이션 계획대로 DB 및 레포지토리를 확장해 tone 정보가 영속되도록 한다. +3. **말투 조정 기능 도입**: `replace-tone.service.ts` 구현과 `replayCachedAnswer` 통합으로 캐시 히트 시 tone 검증/재작성 플로우를 완성한다. +4. **후속 최적화**: tone 컬럼이 채워진 이후에는 tone 일치 여부를 먼저 확인해 tone 분석/재작성 호출을 최소화한다. + +## 상세 구현 설계 +1. **마이그레이션** + - 새 SQL 파일(예: `docs/migrations/2025-XX-ask-question-cache-tone.sql`)을 작성하여 테이블 리네임(`ALTER TABLE ask_message_embedding RENAME TO ask_question_cache;`) → 컬럼 추가(`ADD COLUMN speech_tone_id integer NOT NULL DEFAULT -1;`) → 기존 데이터 삭제(`TRUNCATE ask_question_cache;`)를 순차 진행한다. + - 필요한 경우 `speech_tone_id`에 인덱스(`CREATE INDEX ... ON ask_question_cache(owner_user_id, requester_user_id, speech_tone_id)`)를 추가해 tone 별 검색을 빠르게 한다. +2. **레포지토리 계층** + - `ask-message-embedding.repository.ts` (리네임 후 `ask-question-cache.repository.ts` 고려) + - `MessageEmbedding`/`SimilarMessage` 인터페이스에 `speechToneId: number` 추가. 기본값 `-1`은 별도 상수로 관리한다. + - `upsertEmbedding` INSERT/UPDATE 문에 `speech_tone_id` 컬럼을 포함하고, 매개변수로 tone ID를 받는다. + - `findSimilarEmbeddings` SELECT에 `speech_tone_id AS "speechToneId"`를 추가하고, 반환 타입에 포함. + - `session-history.service.ts` + - `persistConversation` 파라미터에 `speechTone?: number`를 추가하여 assistant 톤을 전달. + - `embeddingRepository.upsertEmbedding` 호출 시 새 tone 값을 전달. + - `findCachedAnswer`가 `speechToneId`를 함께 포함한 후보 배열을 리턴하도록 수정 (ex: `{ answer, searchPlan, retrievalMeta, similarity, speechToneId }`). +3. **서비스 계층 (QA)** + - `qa.service.ts` / `qa.v2.service.ts` + - 캐시 조회 결과를 tone별로 분류: `const matched = candidates.find(c => c.speechToneId === speechTone)`. + - `matched`가 있으면 기존 `replayCachedAnswer(matched)` 실행. + - 없고 후보 배열이 존재하면 `const primary = candidates[0];` 로 선정 후 `replaceTone.rewriteTone(primary.answer, speechTonePrompt)` 호출. + - 재작성 결과 텍스트를 SSE `answer` 이벤트로 흘려보내고, `persistConversation`에 `speechTone` 값을 명시해 저장. + - 재작성 여부를 `DebugLogger`에 남기고, 실패 시 기존 RAG/LLM 경로로 폴백한다. +4. **replace-tone.service.ts** + - 최종 시그니처: `export const rewriteTone = async ( + answer: string, + opts: { speechToneId: number; speechTonePrompt: string; llm?: LlmOverride } + ): Promise`. + - 내부에서 `generate`를 호출하며, 시스템 프롬프트에 "다음 답변의 내용은 유지하고 tone만 아래 지시에 맞춰라" 구조를 사용한다. + - 응답이 비거나 너무 짧으면 실패로 간주하고 오류를 throw. +5. **SSE / 이벤트** + - tone 재작성 시에도 `search_plan`, `context` 이벤트는 캐시된 값 그대로 재생하고 `answer` 이벤트에만 수정된 텍스트를 전송. + - `session_saved` 이벤트에 `cached: true`와 `tone_rewritten: true` (추가 속성) 등을 포함해 프론트에서 구분할 수 있도록 한다. +6. **테스트 전략** + - `session-history.service` 단위 테스트: tone ID가 upsert 및 조회되는지 검증. + - QA 서비스 통합 테스트: (1) 동일 tone 캐시 재생, (2) tone 불일치로 rewrite, (3) rewrite 실패 시 LLM 재호출 폴백. + - 마이그레이션 테스트: 로컬 DB에서 `speech_tone_id integer NOT NULL DEFAULT -1`가 정확히 적용되는지 확인. + +## 커밋 단위 구현 계획 +1. **마이그레이션 + DB 명명 정리** + - `docs/migrations/2025-XX-ask-question-cache-tone.sql` 추가: 테이블 리네임 → 컬럼 추가 → TRUNCATE → 인덱스 생성. + - `README.md` 등 마이그레이션 가이드에 새 스크립트 실행 방법 추가. + +2. **레포지토리 계층 업데이트** + - (선택) `ask-message-embedding.repository.ts` 파일명을 `ask-question-cache.repository.ts`로 변경하고 import 경로 수정. + - 인터페이스/쿼리에 `speechToneId` 반영, upsert 파라미터에 tone ID 추가. + +3. **세션 히스토리 서비스 수정** + - `persistConversation` 시그니처에 `speechTone?: number` 추가. + - `embeddingRepository.upsertEmbedding` 호출부에 tone 전달. + - `findCachedAnswer` 반환 타입을 tone 정보 포함 배열로 변경. + +4. **QA 서비스 캐시 로직 개편** + - `qa.service.ts`/`qa.v2.service.ts`에서 tone 일치 후보 우선 사용, 불일치 시 `replaceTone` 경로로 분기. + - SSE 이벤트/`persistConversation`에 재작성 결과 및 tone ID 반영. + - DebugLogger 로깅 추가. + +5. **replace-tone.service.ts 신규 추가** + - `rewriteTone` 함수 구현, 프롬프트 템플릿/에러 처리를 포함. + - 필요 시 `qa.prompts.ts`에 tone 전용 프롬프트 자산 추가. + +6. **테스트/검증** + - `session-history` 단위 테스트, QA 통합 테스트 보강. + - 마이그레이션 스크립트 dry-run 결과 공유 및 README 업데이트. From a7da28015a16505e87dafa63290892fe1639b336 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 16 Nov 2025 16:09:02 +0900 Subject: [PATCH 14/21] =?UTF-8?q?=F0=9F=94=A8=20=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2025-03-ask-question-cache-tone.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/migrations/2025-03-ask-question-cache-tone.sql diff --git a/docs/migrations/2025-03-ask-question-cache-tone.sql b/docs/migrations/2025-03-ask-question-cache-tone.sql new file mode 100644 index 0000000..161da8e --- /dev/null +++ b/docs/migrations/2025-03-ask-question-cache-tone.sql @@ -0,0 +1,16 @@ +BEGIN; + +-- Rename the duplicate-detection table to clarify its purpose. +ALTER TABLE IF EXISTS ask_message_embedding RENAME TO ask_question_cache; + +-- Keep naming consistent for the IVFFlat index if it already exists. +ALTER INDEX IF EXISTS idx_ask_message_embedding_vec RENAME TO idx_ask_question_cache_vec; + +-- Store speech tone IDs with cached answers; -1 indicates unknown tone. +ALTER TABLE IF EXISTS ask_question_cache + ADD COLUMN IF NOT EXISTS speech_tone_id integer NOT NULL DEFAULT -1; + +-- Existing cache entries lack tone metadata, so drop them after the schema change. +TRUNCATE ask_question_cache; + +COMMIT; From 8138e00161edaadd02f3c22531b1f38e78c97283 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 16 Nov 2025 18:00:17 +0900 Subject: [PATCH 15/21] =?UTF-8?q?=E2=9C=A8=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ry.ts => ask-question-cache.repository.ts} | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) rename src/repositories/{ask-message-embedding.repository.ts => ask-question-cache.repository.ts} (86%) diff --git a/src/repositories/ask-message-embedding.repository.ts b/src/repositories/ask-question-cache.repository.ts similarity index 86% rename from src/repositories/ask-message-embedding.repository.ts rename to src/repositories/ask-question-cache.repository.ts index 0ac512c..310c4b5 100644 --- a/src/repositories/ask-message-embedding.repository.ts +++ b/src/repositories/ask-question-cache.repository.ts @@ -9,6 +9,7 @@ export interface MessageEmbedding { categoryId: number | null; postId: number | null; answerMessageId: number | null; + speechToneId: number; createdAt: Date; updatedAt: Date; } @@ -20,6 +21,7 @@ type EmbeddingRow = { categoryId: number | null; postId: number | null; answerMessageId: number | null; + speechToneId: number; createdAt: Date; updatedAt: Date; }; @@ -32,9 +34,10 @@ const baseSelect = ` category_id AS "categoryId", post_id AS "postId", answer_message_id AS "answerMessageId", + speech_tone_id AS "speechToneId", created_at AS "createdAt", updated_at AS "updatedAt" - FROM ask_message_embedding + FROM ask_question_cache `; const mapRow = (row: EmbeddingRow): MessageEmbedding => ({ ...row }); @@ -48,26 +51,29 @@ export const upsertEmbedding = async ( categoryId?: number | null; postId?: number | null; answerMessageId?: number | null; + speechToneId?: number; }, executor?: QueryExecutor ): Promise => { const result = await runQuery( ` - INSERT INTO ask_message_embedding ( + INSERT INTO ask_question_cache ( message_id, owner_user_id, requester_user_id, category_id, post_id, answer_message_id, + speech_tone_id, embedding ) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (message_id) DO UPDATE SET category_id = EXCLUDED.category_id, post_id = EXCLUDED.post_id, answer_message_id = EXCLUDED.answer_message_id, + speech_tone_id = EXCLUDED.speech_tone_id, embedding = EXCLUDED.embedding, updated_at = now() RETURNING @@ -77,6 +83,7 @@ export const upsertEmbedding = async ( category_id AS "categoryId", post_id AS "postId", answer_message_id AS "answerMessageId", + speech_tone_id AS "speechToneId", created_at AS "createdAt", updated_at AS "updatedAt" `, @@ -87,6 +94,7 @@ export const upsertEmbedding = async ( params.categoryId ?? null, params.postId ?? null, params.answerMessageId ?? null, + params.speechToneId ?? -1, pgvector.toSql(params.embedding), ], executor @@ -98,6 +106,7 @@ export const upsertEmbedding = async ( export interface SimilarMessage { messageId: number; answerMessageId: number | null; + speechToneId: number; similarity: number; } @@ -141,8 +150,9 @@ export const findSimilarEmbeddings = async ({ SELECT message_id AS "messageId", answer_message_id AS "answerMessageId", + speech_tone_id AS "speechToneId", 1 - (embedding <=> $1) AS similarity - FROM ask_message_embedding + FROM ask_question_cache WHERE ${filters.join(' AND ')} ORDER BY embedding <-> $1 LIMIT $${limitIdx} @@ -154,6 +164,6 @@ export const findSimilarEmbeddings = async ({ }; export const deleteEmbeddingsByOwner = async (ownerUserId: string): Promise => { - const result = await runQuery('DELETE FROM ask_message_embedding WHERE owner_user_id = $1', [ownerUserId]); + const result = await runQuery('DELETE FROM ask_question_cache WHERE owner_user_id = $1', [ownerUserId]); return result.rowCount ?? 0; }; From 183f46a689e97c26f1dd804a138827ae61085185 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 16 Nov 2025 18:01:22 +0900 Subject: [PATCH 16/21] =?UTF-8?q?=F0=9F=9A=9A=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/worker/queue-consumer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/worker/queue-consumer.ts b/src/worker/queue-consumer.ts index bffbbbb..43a4eb8 100644 --- a/src/worker/queue-consumer.ts +++ b/src/worker/queue-consumer.ts @@ -7,7 +7,7 @@ import { storeTitleEmbedding, } from '../services/embedding.service'; import { findPostById } from '../repositories/post.repository'; -import { deleteEmbeddingsByOwner } from '../repositories/ask-message-embedding.repository'; +import { deleteEmbeddingsByOwner } from '../repositories/ask-question-cache.repository'; type EmbeddingJob = { postId: number; From dd1b63442b6b5fcdfa7a62956eb600cf20a79057 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 16 Nov 2025 18:01:38 +0900 Subject: [PATCH 17/21] =?UTF-8?q?=E2=9C=A8=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/session-history.service.ts | 69 +++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/src/services/session-history.service.ts b/src/services/session-history.service.ts index 3ccb2eb..7621a96 100644 --- a/src/services/session-history.service.ts +++ b/src/services/session-history.service.ts @@ -1,10 +1,52 @@ +import type { AskMessage } from '../repositories/ask-message.repository'; import * as messageRepository from '../repositories/ask-message.repository'; -import * as embeddingRepository from '../repositories/ask-message-embedding.repository'; +import * as questionCacheRepository from '../repositories/ask-question-cache.repository'; import * as sessionRepository from '../repositories/ask-session.repository'; import { withTransaction } from '../utils/db'; export const HISTORY_MESSAGE_LIMIT = 4; export const DUPLICATE_SIMILARITY_THRESHOLD = 0.93; +const DUPLICATE_USER_HISTORY_LIMIT = 2; +const DUPLICATE_TURN_LIMITS = { + previousFar: 400, + previousNear: 600, + current: 800, +}; + +const normalizeQuestionText = (input: string, limit: number) => { + const cleaned = (input ?? '').replace(/\s+/g, ' ').trim(); + if (!limit || cleaned.length <= limit) return cleaned; + return cleaned.slice(0, limit); +}; + +export const buildDuplicateQuestionBlock = (question: string, history: AskMessage[]): string => { + const userQuestions = (history || []).filter((msg) => msg.role === 'user').map((msg) => msg.content); + const recent = userQuestions.slice(-DUPLICATE_USER_HISTORY_LIMIT); + const sections: string[] = []; + if (recent.length === 2) { + sections.push(`[Q-2] ${normalizeQuestionText(recent[0], DUPLICATE_TURN_LIMITS.previousFar)}`); + sections.push(`[Q-1] ${normalizeQuestionText(recent[1], DUPLICATE_TURN_LIMITS.previousNear)}`); + } else if (recent.length === 1) { + sections.push(`[Q-1] ${normalizeQuestionText(recent[0], DUPLICATE_TURN_LIMITS.previousNear)}`); + } + sections.push(`[Q-now] ${normalizeQuestionText(question, DUPLICATE_TURN_LIMITS.current)}`); + return sections.join('\n'); +}; + +export const selectToneAwareCacheCandidate = ( + candidates: CachedAnswerResult[], + requestedSpeechTone: number +): { + matchingCandidate: CachedAnswerResult | null; + rewriteCandidate: CachedAnswerResult | null; +} => { + const normalizedTone = typeof requestedSpeechTone === 'number' ? requestedSpeechTone : -1; + const matchingCandidate = candidates.find((candidate) => candidate.speechToneId === normalizedTone) ?? null; + if (matchingCandidate) { + return { matchingCandidate, rewriteCandidate: null }; + } + return { matchingCandidate: null, rewriteCandidate: candidates[0] ?? null }; +}; export const loadRecentMessages = async (sessionId: number, limit = HISTORY_MESSAGE_LIMIT) => { const boundedLimit = Math.max(0, Math.min(limit, HISTORY_MESSAGE_LIMIT)); @@ -23,6 +65,8 @@ export interface PersistConversationInput { categoryId?: number; postId?: number; questionEmbedding: number[]; + duplicateQuestionEmbedding: number[]; + speechTone?: number; } export const persistConversation = async ({ @@ -36,8 +80,10 @@ export const persistConversation = async ({ categoryId, postId, questionEmbedding, + duplicateQuestionEmbedding, + speechTone, }: PersistConversationInput): Promise => { - if (!answer || questionEmbedding.length === 0) return; + if (!answer || questionEmbedding.length === 0 || duplicateQuestionEmbedding.length === 0) return; await withTransaction(async (client) => { const userMessage = await messageRepository.insertMessage( @@ -62,7 +108,7 @@ export const persistConversation = async ({ client ); - await embeddingRepository.upsertEmbedding( + await questionCacheRepository.upsertEmbedding( { messageId: userMessage.id, ownerUserId, @@ -70,7 +116,8 @@ export const persistConversation = async ({ categoryId: categoryId ?? null, postId: postId ?? null, answerMessageId: assistantMessage.id, - embedding: questionEmbedding, + speechToneId: speechTone ?? -1, + embedding: duplicateQuestionEmbedding, }, client ); @@ -84,6 +131,7 @@ export interface CachedAnswerResult { searchPlan: Record | null; retrievalMeta: Record | null; similarity: number; + speechToneId: number; } export interface FindCachedAnswerParams { @@ -102,8 +150,8 @@ export const findCachedAnswer = async ({ postId, categoryId, threshold = DUPLICATE_SIMILARITY_THRESHOLD, -}: FindCachedAnswerParams): Promise => { - const candidates = await embeddingRepository.findSimilarEmbeddings({ +}: FindCachedAnswerParams): Promise => { + const candidates = await questionCacheRepository.findSimilarEmbeddings({ ownerUserId, requesterUserId, embedding, @@ -112,6 +160,8 @@ export const findCachedAnswer = async ({ limit: 3, }); + const hydratedCandidates: CachedAnswerResult[] = []; + for (const candidate of candidates) { if (candidate.similarity < threshold) continue; if (!candidate.answerMessageId) continue; @@ -121,13 +171,14 @@ export const findCachedAnswer = async ({ const assistantMessage = await messageRepository.getMessageById(candidate.answerMessageId); if (!assistantMessage) continue; - return { + hydratedCandidates.push({ answer: assistantMessage.content, searchPlan: userMessage.searchPlan ?? null, retrievalMeta: assistantMessage.retrievalMeta ?? null, similarity: candidate.similarity, - }; + speechToneId: candidate.speechToneId ?? -1, + }); } - return null; + return hydratedCandidates; }; From 1876a1c16e77752d19165fd80e084a2d3e51567e Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 16 Nov 2025 18:02:10 +0900 Subject: [PATCH 18/21] =?UTF-8?q?=E2=9C=A8=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=EC=97=90=20=EC=9D=B4=EC=A0=84=20=EB=A7=A5?= =?UTF-8?q?=EB=9D=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/qa.service.ts | 97 +++++++++++++++++++++++++--------- src/services/qa.v2.service.ts | 99 ++++++++++++++++++++++++++--------- 2 files changed, 147 insertions(+), 49 deletions(-) diff --git a/src/services/qa.service.ts b/src/services/qa.service.ts index d271c2f..52ab7e9 100644 --- a/src/services/qa.service.ts +++ b/src/services/qa.service.ts @@ -10,6 +10,8 @@ import { createEmbeddings } from './embedding.service'; import * as sessionHistoryService from './session-history.service'; import { AskSession } from '../repositories/ask-session.repository'; import { extractAnswerText } from '../utils/sse'; +import { rewriteTone } from './replace-tone.service'; +import type { LlmOverride } from '../types/llm.types'; // HTML 태그를 제거하고 길이를 제한하여 LLM 컨텍스트를 정제 const preprocessContent = (content: string): string => { @@ -30,12 +32,6 @@ const getSpeechTonePrompt = async (speechTone: number, userId: string): Promise< return '간결하고 명확한 말투로 답변해'; // 기본 말투 }; -type LlmOverride = { - provider?: 'openai' | 'gemini'; - model?: string; - options?: { temperature?: number; top_p?: number; max_output_tokens?: number }; -}; - export interface AnswerStreamOptions { question: string; session: AskSession; @@ -106,11 +102,18 @@ export const answerStream = async ({ let bufferedAnswer = ''; let questionEmbedding: number[] | null = null; + let duplicateQuestionEmbedding: number[] | null = null; let searchPlanPayload: Record | null = null; let retrievalMetaPayload: Record | null = null; let clientDisconnected = false; - const replayCachedAnswer = async (cached: sessionHistoryService.CachedAnswerResult) => { + const replayCachedAnswer = async ( + cached: sessionHistoryService.CachedAnswerResult, + options?: { answerOverride?: string; speechToneIdOverride?: number } + ) => { + const finalAnswer = options?.answerOverride ?? cached.answer; + const speechToneForPersistence = + typeof options?.speechToneIdOverride === 'number' ? options?.speechToneIdOverride : cached.speechToneId; if (cached.searchPlan) { stream.write(`event: search_plan\n`); stream.write(`data: ${JSON.stringify(cached.searchPlan)}\n\n`); @@ -130,21 +133,24 @@ export const answerStream = async ({ stream.write(`data: ${JSON.stringify(existFlag)}\n\n`); } stream.write(`event: answer\n`); - stream.write(`data: ${JSON.stringify(cached.answer)}\n\n`); + stream.write(`data: ${JSON.stringify(finalAnswer)}\n\n`); try { - if (!questionEmbedding) throw new Error('Missing question embedding for cache replay'); + if (!questionEmbedding || !duplicateQuestionEmbedding) + throw new Error('Missing embeddings for cache replay'); await sessionHistoryService.persistConversation({ sessionId: session.id, requesterUserId, ownerUserId, question, - answer: cached.answer, + answer: finalAnswer, searchPlan: cached.searchPlan ?? undefined, retrievalMeta: cached.retrievalMeta ?? undefined, categoryId, postId, questionEmbedding, + duplicateQuestionEmbedding, + speechTone: speechToneForPersistence, }); stream.emit('session_saved', sessionSavedPayload(session, true)); } catch (error) { @@ -168,29 +174,70 @@ export const answerStream = async ({ userRepository.findUserBlogMetadata(ownerUserId), sessionHistoryService.loadRecentMessages(session.id), ]); - const embeddingVector = await createEmbeddings([question]); + const duplicateQuestionBlock = sessionHistoryService.buildDuplicateQuestionBlock(question, historyMessages); + const embeddingVector = await createEmbeddings([question, duplicateQuestionBlock]); questionEmbedding = embeddingVector[0]; + duplicateQuestionEmbedding = embeddingVector[1]; + + const cachedAnswerList = duplicateQuestionEmbedding + ? await sessionHistoryService.findCachedAnswer({ + ownerUserId, + requesterUserId, + embedding: duplicateQuestionEmbedding, + postId: postId ?? undefined, + categoryId: categoryId ?? undefined, + }) + : []; - const cachedAnswer = - questionEmbedding && - (await sessionHistoryService.findCachedAnswer({ - ownerUserId, - requesterUserId, - embedding: questionEmbedding, - postId: postId ?? undefined, - categoryId: categoryId ?? undefined, - })); + const requestedSpeechTone = typeof speechTone === 'number' ? speechTone : -1; + const { matchingCandidate: matchingCachedAnswer, rewriteCandidate } = + sessionHistoryService.selectToneAwareCacheCandidate(cachedAnswerList, requestedSpeechTone); + DebugLogger.log('qa', { + type: 'debug.qa.cache_candidates', + requestedSpeechTone, + candidateCount: cachedAnswerList.length, + candidateTones: cachedAnswerList.map((candidate) => candidate.speechToneId), + }); - if (cachedAnswer) { + if (matchingCachedAnswer) { DebugLogger.log('qa', { type: 'debug.qa.cache_hit', sessionId: session.id, - similarity: cachedAnswer.similarity, + similarity: matchingCachedAnswer.similarity, + speechTone: requestedSpeechTone, }); - await replayCachedAnswer(cachedAnswer); + await replayCachedAnswer(matchingCachedAnswer); return; } + if (rewriteCandidate) { + DebugLogger.log('qa', { + type: 'debug.qa.cache_hit_tone_mismatch', + sessionId: session.id, + similarity: rewriteCandidate.similarity, + requestedSpeechTone, + cachedSpeechTone: rewriteCandidate.speechToneId, + }); + try { + const rewrittenAnswer = await rewriteTone(rewriteCandidate.answer, { + speechToneId: requestedSpeechTone, + speechTonePrompt, + llm, + }); + await replayCachedAnswer(rewriteCandidate, { + answerOverride: rewrittenAnswer, + speechToneIdOverride: requestedSpeechTone, + }); + return; + } catch (error) { + DebugLogger.warn('qa', { + type: 'debug.qa.cache_tone_rewrite_failed', + sessionId: session.id, + message: (error as Error)?.message ?? 'tone_rewrite_failed', + }); + } + } + const toSimpleMessages = ( raw: any[] ): { role: 'system' | 'user' | 'assistant' | 'tool' | 'function'; content: string }[] => { @@ -329,7 +376,7 @@ export const answerStream = async ({ preview: bufferedAnswer.slice(0, 80), }); try { - if (questionEmbedding) { + if (questionEmbedding && duplicateQuestionEmbedding) { await sessionHistoryService.persistConversation({ sessionId: session.id, requesterUserId, @@ -341,6 +388,8 @@ export const answerStream = async ({ categoryId, postId, questionEmbedding, + duplicateQuestionEmbedding, + speechTone, }); stream.emit('session_saved', sessionSavedPayload(session)); } else { diff --git a/src/services/qa.v2.service.ts b/src/services/qa.v2.service.ts index 67816c2..dd1c8f2 100644 --- a/src/services/qa.v2.service.ts +++ b/src/services/qa.v2.service.ts @@ -13,6 +13,8 @@ import { DebugLogger } from '../utils/debug-logger'; import * as sessionHistoryService from './session-history.service'; import { AskSession } from '../repositories/ask-session.repository'; import { extractAnswerText } from '../utils/sse'; +import { rewriteTone } from './replace-tone.service'; +import type { LlmOverride } from '../types/llm.types'; // HTML을 제거하고 길이를 제한해 LLM 컨텍스트를 정리 const preprocessContent = (content: string): string => { @@ -31,12 +33,6 @@ const getSpeechTonePrompt = async (speechTone: number, userId: string): Promise< return '간결하고 명확한 말투로 답변해'; }; -type LlmOverride = { - provider?: 'openai' | 'gemini'; - model?: string; - options?: { temperature?: number; top_p?: number; max_output_tokens?: number }; -}; - export interface AnswerStreamV2Options { question: string; session: AskSession; @@ -91,9 +87,16 @@ export const answerStreamV2 = async ({ let searchPlanPayload: Record | null = null; let retrievalMetaPayload: Record | null = null; let questionEmbedding: number[] | null = null; + let duplicateQuestionEmbedding: number[] | null = null; let clientDisconnected = false; - const replayCachedAnswer = async (cached: sessionHistoryService.CachedAnswerResult) => { + const replayCachedAnswer = async ( + cached: sessionHistoryService.CachedAnswerResult, + options?: { answerOverride?: string; speechToneIdOverride?: number } + ) => { + const finalAnswer = options?.answerOverride ?? cached.answer; + const speechToneForPersistence = + typeof options?.speechToneIdOverride === 'number' ? options.speechToneIdOverride : cached.speechToneId; if (cached.searchPlan) { stream.write(`event: search_plan\n`); stream.write(`data: ${JSON.stringify(cached.searchPlan)}\n\n`); @@ -113,21 +116,24 @@ export const answerStreamV2 = async ({ stream.write(`data: ${JSON.stringify(existFlag)}\n\n`); } stream.write(`event: answer\n`); - stream.write(`data: ${JSON.stringify(cached.answer)}\n\n`); + stream.write(`data: ${JSON.stringify(finalAnswer)}\n\n`); try { - if (!questionEmbedding) throw new Error('Missing question embedding for cache replay'); + if (!questionEmbedding || !duplicateQuestionEmbedding) + throw new Error('Missing embeddings for cache replay'); await sessionHistoryService.persistConversation({ sessionId: session.id, requesterUserId, ownerUserId, question, - answer: cached.answer, + answer: finalAnswer, searchPlan: cached.searchPlan ?? undefined, retrievalMeta: cached.retrievalMeta ?? undefined, categoryId, postId, questionEmbedding, + duplicateQuestionEmbedding, + speechTone: speechToneForPersistence, }); stream.emit('session_saved', sessionSavedPayload(session, true)); } catch (error) { @@ -146,34 +152,75 @@ export const answerStreamV2 = async ({ }); (async () => { - const [speechTonePrompt, blogMeta, historyMessages, embeddingVector] = await Promise.all([ + const [speechTonePrompt, blogMeta, historyMessages] = await Promise.all([ getSpeechTonePrompt(speechTone, ownerUserId), userRepository.findUserBlogMetadata(ownerUserId), sessionHistoryService.loadRecentMessages(session.id), - createEmbeddings([question]), ]); + const duplicateQuestionBlock = sessionHistoryService.buildDuplicateQuestionBlock(question, historyMessages); + const embeddingVector = await createEmbeddings([question, duplicateQuestionBlock]); questionEmbedding = embeddingVector[0]; + duplicateQuestionEmbedding = embeddingVector[1]; - const cachedAnswer = - questionEmbedding && - (await sessionHistoryService.findCachedAnswer({ - ownerUserId, - requesterUserId, - embedding: questionEmbedding, - postId: postId ?? undefined, - categoryId: categoryId ?? undefined, - })); + const cachedAnswerList = duplicateQuestionEmbedding + ? await sessionHistoryService.findCachedAnswer({ + ownerUserId, + requesterUserId, + embedding: duplicateQuestionEmbedding, + postId: postId ?? undefined, + categoryId: categoryId ?? undefined, + }) + : []; - if (cachedAnswer) { + const requestedSpeechTone = typeof speechTone === 'number' ? speechTone : -1; + const { matchingCandidate: matchingCachedAnswer, rewriteCandidate } = + sessionHistoryService.selectToneAwareCacheCandidate(cachedAnswerList, requestedSpeechTone); + DebugLogger.log('qa', { + type: 'debug.qa.v2.cache_candidates', + requestedSpeechTone, + candidateCount: cachedAnswerList.length, + candidateTones: cachedAnswerList.map((candidate) => candidate.speechToneId), + }); + + if (matchingCachedAnswer) { DebugLogger.log('qa', { type: 'debug.qa.v2.cache_hit', sessionId: session.id, - similarity: cachedAnswer.similarity, + similarity: matchingCachedAnswer.similarity, + speechTone: requestedSpeechTone, }); - await replayCachedAnswer(cachedAnswer); + await replayCachedAnswer(matchingCachedAnswer); return; } + if (rewriteCandidate) { + DebugLogger.log('qa', { + type: 'debug.qa.v2.cache_hit_tone_mismatch', + sessionId: session.id, + similarity: rewriteCandidate.similarity, + requestedSpeechTone, + cachedSpeechTone: rewriteCandidate.speechToneId, + }); + try { + const rewrittenAnswer = await rewriteTone(rewriteCandidate.answer, { + speechToneId: requestedSpeechTone, + speechTonePrompt, + llm, + }); + await replayCachedAnswer(rewriteCandidate, { + answerOverride: rewrittenAnswer, + speechToneIdOverride: requestedSpeechTone, + }); + return; + } catch (error) { + DebugLogger.warn('qa', { + type: 'debug.qa.v2.cache_tone_rewrite_failed', + sessionId: session.id, + message: (error as Error)?.message ?? 'tone_rewrite_failed', + }); + } + } + let messages: { role: 'system' | 'user' | 'assistant' | 'tool' | 'function'; content: string }[] = []; let tools: | { @@ -416,7 +463,7 @@ export const answerStreamV2 = async ({ preview: bufferedAnswer.slice(0, 80), }); try { - if (questionEmbedding) { + if (questionEmbedding && duplicateQuestionEmbedding) { await sessionHistoryService.persistConversation({ sessionId: session.id, requesterUserId, @@ -428,6 +475,8 @@ export const answerStreamV2 = async ({ categoryId, postId, questionEmbedding, + duplicateQuestionEmbedding, + speechTone, }); stream.emit('session_saved', sessionSavedPayload(session)); } else { From a82a8fce9a89c0b007d95069dbf798ca861909c3 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 16 Nov 2025 18:02:41 +0900 Subject: [PATCH 19/21] =?UTF-8?q?=E2=9C=A8=EB=A7=90=ED=88=AC=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASK.md | 9 ---- docs/migrations/README.md | 8 ++-- src/services/replace-tone.service.ts | 70 ++++++++++++++++++++++++++++ src/types/llm.types.ts | 5 ++ 4 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 src/services/replace-tone.service.ts create mode 100644 src/types/llm.types.ts diff --git a/TASK.md b/TASK.md index 4dde083..105bb96 100644 --- a/TASK.md +++ b/TASK.md @@ -69,11 +69,6 @@ 5. **SSE / 이벤트** - tone 재작성 시에도 `search_plan`, `context` 이벤트는 캐시된 값 그대로 재생하고 `answer` 이벤트에만 수정된 텍스트를 전송. - `session_saved` 이벤트에 `cached: true`와 `tone_rewritten: true` (추가 속성) 등을 포함해 프론트에서 구분할 수 있도록 한다. -6. **테스트 전략** - - `session-history.service` 단위 테스트: tone ID가 upsert 및 조회되는지 검증. - - QA 서비스 통합 테스트: (1) 동일 tone 캐시 재생, (2) tone 불일치로 rewrite, (3) rewrite 실패 시 LLM 재호출 폴백. - - 마이그레이션 테스트: 로컬 DB에서 `speech_tone_id integer NOT NULL DEFAULT -1`가 정확히 적용되는지 확인. - ## 커밋 단위 구현 계획 1. **마이그레이션 + DB 명명 정리** - `docs/migrations/2025-XX-ask-question-cache-tone.sql` 추가: 테이블 리네임 → 컬럼 추가 → TRUNCATE → 인덱스 생성. @@ -96,7 +91,3 @@ 5. **replace-tone.service.ts 신규 추가** - `rewriteTone` 함수 구현, 프롬프트 템플릿/에러 처리를 포함. - 필요 시 `qa.prompts.ts`에 tone 전용 프롬프트 자산 추가. - -6. **테스트/검증** - - `session-history` 단위 테스트, QA 통합 테스트 보강. - - 마이그레이션 스크립트 dry-run 결과 공유 및 README 업데이트. diff --git a/docs/migrations/README.md b/docs/migrations/README.md index edf66fb..5f994be 100644 --- a/docs/migrations/README.md +++ b/docs/migrations/README.md @@ -30,8 +30,8 @@ Notes: File: `2025-02-ask-session-history.sql` Purpose: -- Create `ask_session`, `ask_message`, and `ask_message_embedding` tables with the indexes needed for session history APIs. -- Ensure the `vector` extension is enabled so question embeddings can be stored for duplicate-detection. +- Create `ask_session`, `ask_message`, and `ask_question_cache` tables with the indexes needed for session history APIs. The cache table keeps duplicate-detection embeddings and speech tone metadata. +- Ensure the `vector` extension is enabled so question embeddings can be stored for duplicate-detection. `ask_question_cache.speech_tone_id` must default to `-1` to indicate unknown tone. Run: @@ -40,5 +40,5 @@ psql "$DATABASE_URL" -f docs/migrations/2025-02-ask-session-history.sql ``` Notes: -- The IVFFlat index requires a populated table before it becomes efficient; run `ANALYZE ask_message_embedding;` after bulk loading data. -- `ask_message_embedding` references `ask_message`, so dropping the session tables will cascade to embeddings automatically. +- The IVFFlat index requires a populated table before it becomes efficient; run `ANALYZE ask_question_cache;` after bulk loading data. +- `ask_question_cache` references `ask_message`, so dropping the session tables will cascade to embeddings automatically. diff --git a/src/services/replace-tone.service.ts b/src/services/replace-tone.service.ts new file mode 100644 index 0000000..698874a --- /dev/null +++ b/src/services/replace-tone.service.ts @@ -0,0 +1,70 @@ +import config from '../config'; +import { generate } from '../llm'; +import { extractAnswerText } from '../utils/sse'; +import type { LlmOverride } from '../types/llm.types'; + +const SYSTEM_PROMPT = + '주어진 원문을 말투에 맞게 변경해라. 아래 콘텐츠 원문의 의미, 사실, 구조를 훼손하지 말고, 요청된 tone 지시만 반영해 다시 작성해.'; +const MIN_LENGTH_RATIO = 0.5; +const MAX_LENGTH_RATIO = 1.5; + +const collectAnswerFromStream = (stream: NodeJS.ReadableStream): Promise => { + return new Promise((resolve, reject) => { + let buffer = ''; + stream.on('data', (chunk) => { + const str = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + const texts = extractAnswerText(str); + if (texts.length) buffer += texts.join(''); + }); + stream.on('end', () => resolve(buffer.trim())); + stream.on('error', (err) => reject(err)); + }); +}; + +export interface RewriteToneOptions { + speechToneId: number; + speechTonePrompt: string; + llm?: LlmOverride; +} + +export const rewriteTone = async (answer: string, opts: RewriteToneOptions): Promise => { + const original = (answer ?? '').trim(); + if (!original) throw new Error('tone_rewrite_original_empty'); + + const provider = opts.llm?.provider || 'openai'; + const model = opts.llm?.model || config.CHAT_MODEL; + const options = { ...(opts.llm?.options || {}) }; + if (typeof options.temperature !== 'number') { + options.temperature = 0.2; + } + if (typeof options.top_p !== 'number') { + options.top_p = 0.9; + } + const approxTokens = Math.max(120, Math.min(4096, Math.ceil(original.length * 1.2))); + if (typeof options.max_output_tokens !== 'number') { + options.max_output_tokens = approxTokens; + } + + const stream = await generate({ + provider, + model, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { + role: 'user', + content: `tone 지시: ${opts.speechTonePrompt}\n원문: ${original}`, + }, + ], + options, + }); + + const rewritten = await collectAnswerFromStream(stream); + if (!rewritten) throw new Error('tone_rewrite_empty'); + + const ratio = rewritten.length / Math.max(1, original.length); + if (ratio < MIN_LENGTH_RATIO || ratio > MAX_LENGTH_RATIO) { + throw new Error('tone_rewrite_ratio_out_of_bounds'); + } + + return rewritten; +}; diff --git a/src/types/llm.types.ts b/src/types/llm.types.ts new file mode 100644 index 0000000..86966b9 --- /dev/null +++ b/src/types/llm.types.ts @@ -0,0 +1,5 @@ +export type LlmOverride = { + provider?: 'openai' | 'gemini'; + model?: string; + options?: { temperature?: number; top_p?: number; max_output_tokens?: number }; +}; From 1af1b5a1ded3b8dac26f9c50d7402b2535c1e273 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Sun, 16 Nov 2025 18:02:55 +0900 Subject: [PATCH 20/21] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=EB=94=94=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=9C=A0=ED=8B=B8=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.ts | 1 + src/utils/debug-logger.ts | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/config.ts b/src/config.ts index a2ff720..4ea7bb3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ const configSchema = z.object({ LLM_COST_ROUND: z.coerce.number().default(4), DEBUG_ALL: z.string().default('false'), DEBUG_CHANNELS: z.string().default(''), + DEBUG_EXCLUDE_TYPES: z.string().default(''), REDIS_URL: z.string().optional(), REDIS_HOST: z.string().default('127.0.0.1'), REDIS_PORT: z.coerce.number().default(6379), diff --git a/src/utils/debug-logger.ts b/src/utils/debug-logger.ts index c81bbc6..dc4f159 100644 --- a/src/utils/debug-logger.ts +++ b/src/utils/debug-logger.ts @@ -12,6 +12,14 @@ export type DebugChannel = type DebugLevel = 'log' | 'info' | 'warn' | 'error'; const ALL_CHANNELS: DebugChannel[] = ['plan', 'hybrid', 'qa', 'sse', 'llm', 'openai', 'server']; +const DEFAULT_EXCLUDED_TYPES = new Set([ + 'debug.qa.chunk', + 'llm.request', + 'llm.response', + 'llm.error', + 'debug.llm.start', + 'debug.llm.end', +]); const parseEnabledChannels = (): Set => { const set = new Set(); @@ -29,6 +37,47 @@ const parseEnabledChannels = (): Set => { const enabledAll = (config.DEBUG_ALL || '').toString().toLowerCase() === 'true'; const enabledChannels = parseEnabledChannels(); +const parseExcludedTypes = (): { + global: Set; + byChannel: Map>; +} => { + const global = new Set(DEFAULT_EXCLUDED_TYPES); + const byChannel = new Map>(); + const raw = (config.DEBUG_EXCLUDE_TYPES || '').toString(); + if (!raw) return { global, byChannel }; + for (const token of raw.split(',')) { + const item = token.trim(); + if (!item) continue; + const [rawChannel, rawType] = item.includes(':') ? item.split(':', 2) : [null, item]; + const type = (rawType ?? '').trim(); + if (!type) continue; + if (!rawChannel) { + global.add(type); + continue; + } + const channelKey = ALL_CHANNELS.find((ch) => ch === rawChannel.trim()); + if (!channelKey) { + global.add(type); + continue; + } + const set = byChannel.get(channelKey) ?? new Set(); + set.add(type); + byChannel.set(channelKey, set); + } + return { global, byChannel }; +}; + +const excludedTypes = parseExcludedTypes(); + +const shouldSkipPayload = (channel: DebugChannel, payload: unknown): boolean => { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false; + const type = (payload as Record).type; + if (typeof type !== 'string' || !type) return false; + if (excludedTypes.global.has('*') || excludedTypes.global.has(type)) return true; + const channelSet = excludedTypes.byChannel.get(channel); + if (!channelSet) return false; + return channelSet.has('*') || channelSet.has(type); +}; const pickWriter = (level: DebugLevel) => { switch (level) { @@ -95,6 +144,7 @@ export const DebugLogger = { }, write(channel: DebugChannel, payload: unknown, level: DebugLevel = 'log'): void { if (!this.isEnabled(channel)) return; + if (shouldSkipPayload(channel, payload)) return; const writer = pickWriter(level); writer(formatPayload(channel, payload)); }, From 8deef8e5ec280fe1991a33d8bb968f97fec64067 Mon Sep 17 00:00:00 2001 From: chan000518 Date: Mon, 17 Nov 2025 00:44:14 +0900 Subject: [PATCH 21/21] =?UTF-8?q?=F0=9F=93=9D=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/history-tasks/ASK_DUPLICATE_CACHE.md | 46 +++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/history-tasks/ASK_DUPLICATE_CACHE.md diff --git a/docs/history-tasks/ASK_DUPLICATE_CACHE.md b/docs/history-tasks/ASK_DUPLICATE_CACHE.md new file mode 100644 index 0000000..e6ac36f --- /dev/null +++ b/docs/history-tasks/ASK_DUPLICATE_CACHE.md @@ -0,0 +1,46 @@ +# ASK 중복 질문 캐시 & Tone Rewrite 회고 + +이번 작업은 “중복 질문 캐시를 더 똑똑하게 만들고, tone 정합성을 지키면서도 비용을 줄이자”라는 목표로 진행했다. 아래 정리는 과장 없이 우리가 실제로 마주친 문제와 해결 과정, 그리고 그 결과다. + +## 왜 손봤나 +- **Follow-up 질문 품질**: 프롬프트에는 항상 직전 2턴이 붙지만, 캐시는 단일 질문 벡터만 저장했다. follow-up 질문이 들어오면 캐시가 엇나가거나, 유사도 비교 자체가 어렵다. +- **Tone 불일치**: 캐시에서 꺼낸 답변의 말투가 요청 값과 다르면 사용자 경험이 깨진다. tone 정보를 캐시에 같이 저장하지 않으면, 결국 새 LLM 호출을 해야 했다. +- **콘텐츠 정합성**: 사용자가 글을 수정/삭제해 임베딩이 새로 생성될 때, 예전 중복 질문 캐시가 남아 있으면 최신 본문과 어긋난 답변이 튀어나온다. + +## 어떻게 풀었나 + +### 1. 캐시 스키마 + tone 메타 +- `ask_message_embedding`을 `ask_question_cache`로 리네임하고, `speech_tone_id integer NOT NULL DEFAULT -1`을 추가했다. (파일: `docs/migrations/2025-03-ask-question-cache-tone.sql`) +- 마이그레이션 직후 `TRUNCATE`로 tone 정보가 없는 캐시를 비웠다. 덕분에 tone-aware 로직과 충돌하는 레코드가 남지 않았다. + +### 2. 히스토리 2턴을 합쳐 임베딩 +- `buildDuplicateQuestionBlock`(`session-history.service.ts:22`)이 `[Q-2]`, `[Q-1]`, `[Q-now]` 블록을 만들어준다. 길이가 길면 앞선 턴부터 자른다. +- `qa.service.ts:172`, `qa.v2.service.ts:154`에서 `[현재 질문, 중복 질문 블록]`을 동시에 임베딩한다. + - 첫 번째 벡터 → RAG 검색용 + - 두 번째 벡터 → 캐시 저장/조회용 +- `persistConversation`(`session-history.service.ts:68`)이 answer tone과 중복 질문 벡터를 함께 `ask_question_cache`에 upsert한다. + +### 3. tone-aware 캐시 조회 +- `findCachedAnswer`(`session-history.service.ts:146`)가 owner, requester, post, category 조건에 맞는 후보를 tone ID와 함께 돌려준다. +- `selectToneAwareCacheCandidate`(`session-history.service.ts:36`)가 요청 tone과 동일한 후보를 고르고, 없으면 top-1 후보를 rewrite 대상으로 지정한다. +- `qa.service.ts:192`, `qa.v2.service.ts:175`에서 tone이 맞는 캐시는 그대로 재생하고, tone이 다르면 rewrite 경로로 분기한다. + +### 4. `replace-tone.service.ts` 디테일 +- 기존 `generate` 래퍼를 그대로 사용하면서 system/user 프롬프트를 tone 교체에 맞춰 고정했다. +- **Ask v2**: 캐시 → tone mismatch → `rewriteTone`만 호출하므로 LLM 호출 수가 2 → 1로 줄어든다. + **Ask v1**: LLM 호출 수는 동일하지만 tone 재작성은 원문만 넣으니 입력 토큰이 줄어 비용 절감이 된다. +- tone 재작성 실패 시에는 로그를 남기고 RAG 경로로 폴백한다. 성공하면 SSE `answer` 이벤트와 `persistConversation`에 tone ID를 저장한다. + +### 5. 임베딩 워커에서 정합성 보장 +- 포스트 임베딩 작업(수정/삭제 포함)이 끝나면 `queue-consumer.ts`가 `deleteEmbeddingsByOwner`(`ask-question-cache.repository.ts:166`)를 호출한다. +- 그 사용자에 대한 중복 질문 캐시가 전부 지워져서, 새로운 임베딩과 캐시가 항상 같은 시점을 바라보게 된다. 이 정합성 덕분에 “본문은 최신인데 캐시는 옛날 기록” 같은 상황을 확실하게 막았다. + +## 운영 & 디버깅 팁 +- `speech_tone_id = -1`은 tone 미확인 상태로 간주한다. rewrite 한 번만 성공하면 tone이 채워져 이후에는 재작성 없이 캐시를 재생할 수 있다. +- `DEBUG_CHANNELS=qa` + `DEBUG_EXCLUDE_TYPES` 조합으로 `debug.qa.cache_candidates` / `debug.qa.v2.cache_candidates` 로그만 추려보면 tone 매칭 상태를 바로 확인할 수 있다. +- 특정 사용자의 중복 질문 캐시를 비우고 싶으면 `deleteEmbeddingsByOwner`를 실행하면 된다. 워커에서 이미 자동으로 호출하지만, 필요 시 수동으로도 가능하다. + +## 결과 +- follow-up 질문에서도 동일한 히스토리를 기준으로 캐시가 비교되니, 중복 질문 탐지가 더 정확해졌다. +- tone mismatch 상황에서도 `rewriteTone`만 호출하면 되기 때문에, Ask v2에서는 LLM 호출을 1번으로 줄였고 v1에서도 입력 토큰이 줄어 비용이 내려갔다. +- 임베딩 워커 단계에서 캐시를 정리하니, 콘텐츠 정합성을 걱정할 일이 없어졌다. “최신 글과 tone까지 맞춘 캐시”라는 목표를 과장 없이 달성했다.