@fcg-labs/cx-agent-hook 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.d.ts CHANGED
@@ -54,12 +54,31 @@ export interface FeedbackInput {
54
54
  note?: string;
55
55
  }
56
56
 
57
+ export interface AnswerStreamHandlers {
58
+ /** 텍스트 조각 — 누적(append) */
59
+ onDelta?(text: string): void;
60
+ /** 교정 재생성 시작 — 누적 전부 폐기 */
61
+ onRestart?(reasons: string[]): void;
62
+ onStage?(phase: string): void;
63
+ onFinal?(result: AnswerResult): void;
64
+ /** 외부 중단 (문의 전환 등) */
65
+ signal?: AbortSignal;
66
+ idleTimeoutMs?: number;
67
+ overallTimeoutMs?: number;
68
+ }
69
+
57
70
  export declare class CxAgentClient {
58
71
  constructor(config: CxAgentConfig);
59
72
  getAnswer(
60
73
  inquiry: string,
61
74
  context?: Record<string, string>,
62
75
  ): Promise<AnswerResult>;
76
+ /** SSE 스트리밍 — 델타 콜백 + getAnswer 동형 결과. 404/405 는 비스트림 폴백. */
77
+ getAnswerStream(
78
+ inquiry: string,
79
+ context?: Record<string, string>,
80
+ handlers?: AnswerStreamHandlers,
81
+ ): Promise<AnswerResult>;
63
82
  sendFeedback(fb: FeedbackInput): Promise<FeedbackResult>;
64
83
  sent(answerId: number | string, finalText: string, agent?: string): Promise<FeedbackResult>;
65
84
  scored(answerId: number | string, score: number, agent?: string): Promise<FeedbackResult>;
package/client.js CHANGED
@@ -21,6 +21,7 @@ const FEEDBACK_ACTIONS = new Set(["scored", "edited", "sent", "discarded"]);
21
21
  const PATHS = {
22
22
  platform: {
23
23
  answer: (d) => `/api/domains/${d}/answer`,
24
+ answerStream: (d) => `/api/domains/${d}/answer/stream`,
24
25
  feedback: (d) => `/api/domains/${d}/feedback`,
25
26
  },
26
27
  hub: {
@@ -31,12 +32,32 @@ const PATHS = {
31
32
  // 허브에 라우트가 생긴 뒤에도 SDK 가 그대로여서 hub 대상 getAnswer 가
32
33
  // HTTP 를 타보지도 못하고 unsupported_api 로 단락됐다 (2026-07-31 감사).
33
34
  answer: (d) => `/v1/domains/${d}/answer`,
35
+ answerStream: (d) => `/v1/domains/${d}/answer/stream`,
34
36
  feedback: (d) => `/v1/domains/${d}/feedback`,
35
37
  ingress: (d) => `/v1/domains/${d}/ingress`,
36
38
  },
37
39
  // platform 은 인그레스 API 미제공 (공장 큐레이션 파이프라인이 담당)
38
40
  };
39
41
 
42
+ /**
43
+ * SSE 블록 1개 파싱 — "event: 이름" + "data: JSON 한 줄" 규약.
44
+ * 규약 밖 블록(주석·미지 이벤트·깨진 JSON)은 null — 스트림을 죽이지 않는다.
45
+ */
46
+ function parseSSEBlock(block) {
47
+ let name = "";
48
+ let data = "";
49
+ for (const line of block.split("\n")) {
50
+ if (line.startsWith("event: ")) name = line.slice(7).trim();
51
+ else if (line.startsWith("data: ")) data = line.slice(6);
52
+ }
53
+ if (!name || !data) return null;
54
+ try {
55
+ return { name, data: JSON.parse(data) };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
40
61
  function sleep(ms) {
41
62
  return new Promise((resolve) => setTimeout(resolve, ms));
42
63
  }
@@ -220,6 +241,132 @@ export class CxAgentClient {
220
241
  }
221
242
  }
222
243
 
244
+ /**
245
+ * 서빙 답변 스트리밍 — 델타를 콜백으로 흘리고 getAnswer 와 동형의 결과로
246
+ * resolve 한다. throw 하지 않는다.
247
+ *
248
+ * 타임아웃 계단(정본): 서버 LLM 60s×2 < 허브 180s < 여기 overall 190s.
249
+ * idle 90s 는 침묵 구간(콜드 검색+첫 토큰, 교정 재시작 후 첫 토큰)을 덮는다 —
250
+ * 더 짧으면 정상 생성을 오탐으로 자른다 (30s 시절 사고의 재판 금지).
251
+ *
252
+ * @param {string} inquiry
253
+ * @param {Record<string,string>} [context]
254
+ * @param {object} [handlers]
255
+ * @param {(text:string)=>void} [handlers.onDelta] 텍스트 조각 (append)
256
+ * @param {(reasons:string[])=>void} [handlers.onRestart] 교정 재생성 시작 —
257
+ * 지금까지의 누적을 전부 버릴 것
258
+ * @param {(phase:string)=>void} [handlers.onStage] retrieving|generating|correcting
259
+ * @param {(result:object)=>void} [handlers.onFinal]
260
+ * @param {AbortSignal} [handlers.signal] 외부 중단 (문의 전환 등)
261
+ * @param {number} [handlers.idleTimeoutMs=90000]
262
+ * @param {number} [handlers.overallTimeoutMs=190000]
263
+ */
264
+ async getAnswerStream(inquiry, context, {
265
+ onDelta, onRestart, onStage, onFinal, signal,
266
+ idleTimeoutMs = 90000, overallTimeoutMs = 190000,
267
+ } = {}) {
268
+ const fail = (reason, raw = {}) => ({
269
+ ok: false, answered: false, answer: "", answerId: null,
270
+ evidence: [], declinedReason: reason, raw,
271
+ });
272
+ const target = this._target("answer");
273
+ const streamPath = PATHS[target.api] && PATHS[target.api].answerStream;
274
+ if (!streamPath) {
275
+ this.onError(
276
+ new Error("답변 생성 대상이 설정되지 않았습니다 (answer.baseUrl 확인)"),
277
+ { op: "getAnswerStream" },
278
+ );
279
+ return fail("unsupported_api");
280
+ }
281
+ const body = { inquiry };
282
+ const ctx = normalizeContext(context);
283
+ if (ctx) body.context = ctx;
284
+
285
+ const controller = new AbortController();
286
+ const abort = () => controller.abort();
287
+ if (signal) {
288
+ if (signal.aborted) return fail("network_error");
289
+ signal.addEventListener("abort", abort, { once: true });
290
+ }
291
+ const overallTimer = setTimeout(abort, overallTimeoutMs);
292
+ let idleTimer = setTimeout(abort, idleTimeoutMs);
293
+ const bumpIdle = () => {
294
+ clearTimeout(idleTimer);
295
+ idleTimer = setTimeout(abort, idleTimeoutMs);
296
+ };
297
+ try {
298
+ const res = await this.fetchImpl(target.baseUrl + streamPath(this.domain), {
299
+ method: "POST",
300
+ headers: {
301
+ "Authorization": `Bearer ${target.token}`,
302
+ "Content-Type": "application/json",
303
+ },
304
+ body: JSON.stringify(body),
305
+ signal: controller.signal,
306
+ });
307
+ if (res.status === 404 || res.status === 405) {
308
+ // 구 서버(스트림 라우트 이전) 혼재 배포 — 비스트림으로 폴백.
309
+ // 델타 없이 완성본이 한 번에 오지만 기능은 산다 (배포 순서 안전판).
310
+ return await this.getAnswer(inquiry, context);
311
+ }
312
+ if (res.status !== 200) {
313
+ const data = await res.json().catch(() => ({}));
314
+ return fail((data && data.error) || `http_${res.status}`, data);
315
+ }
316
+ const reader = res.body.getReader();
317
+ // {stream:true} 필수 — 한글 3바이트 문자가 청크 경계에서 잘리면
318
+ // 이것 없이는 U+FFFD 로 깨진다 (한국어 스트림의 최다 빈도 버그).
319
+ const decoder = new TextDecoder("utf-8");
320
+ let buffer = "";
321
+ let final = null;
322
+ for (;;) {
323
+ const { done, value } = await reader.read();
324
+ if (done) break;
325
+ bumpIdle();
326
+ buffer += decoder.decode(value, { stream: true });
327
+ let sep;
328
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
329
+ const event = parseSSEBlock(buffer.slice(0, sep));
330
+ buffer = buffer.slice(sep + 2);
331
+ if (!event) continue;
332
+ if (event.name === "delta") {
333
+ if (onDelta) onDelta(String(event.data.text || ""));
334
+ } else if (event.name === "restart") {
335
+ if (onRestart) onRestart(event.data.reasons || []);
336
+ } else if (event.name === "stage") {
337
+ if (onStage) onStage(String(event.data.phase || ""));
338
+ } else if (event.name === "final") {
339
+ final = event.data;
340
+ }
341
+ }
342
+ }
343
+ if (!final) {
344
+ // final 없이 끊김 = 절단 (허브 상한·네트워크) — 부분 텍스트는 무효다.
345
+ this.onError(new Error("스트림이 final 없이 종료됐습니다"),
346
+ { op: "getAnswerStream" });
347
+ return fail("network_error");
348
+ }
349
+ const result = {
350
+ ok: true,
351
+ answered: Boolean(final.answered),
352
+ answer: final.answer || "",
353
+ answerId: final.answer_id ?? null,
354
+ evidence: final.evidence || [],
355
+ declinedReason: final.declined_reason || "",
356
+ raw: final,
357
+ };
358
+ if (onFinal) onFinal(result);
359
+ return result;
360
+ } catch (err) {
361
+ this.onError(err, { op: "getAnswerStream" });
362
+ return fail("network_error");
363
+ } finally {
364
+ clearTimeout(overallTimer);
365
+ clearTimeout(idleTimer);
366
+ if (signal) signal.removeEventListener("abort", abort);
367
+ }
368
+ }
369
+
223
370
  /**
224
371
  * 교정 후킹 — CS팀 업무를 막지 않는다: throw 없이 재시도 후 결과 보고.
225
372
  * @param {object} fb
package/index.d.ts CHANGED
@@ -42,6 +42,17 @@ export interface CxHook {
42
42
  context?: Record<string, string>,
43
43
  ): Promise<AnswerResult>;
44
44
 
45
+ /** 초안 직주입 스트리밍 — 에디터 접근자만 배선하면 나머지 판단은 훅 소유.
46
+ * 성공 시 자동 채택(noteAdopted) — answerSent 귀속이 그대로 성립한다. */
47
+ composeDraft(opts: {
48
+ inquiry: string;
49
+ context?: Record<string, string>;
50
+ getDraft?: () => string;
51
+ setDraft: (text: string) => void;
52
+ setStatus?: (text: string) => void;
53
+ confirmOverwrite?: () => boolean;
54
+ }): { promise: Promise<AnswerResult>; abort: () => void };
55
+
45
56
  /** 제안을 에디터에 넣었다 (패널이 부른다) */
46
57
  noteAdopted(answerId: number | string | null): void;
47
58
  /** 채택 기록 폐기 — 문의 전환 시 */
package/index.js CHANGED
@@ -105,6 +105,87 @@ export function createCxHook(config = {}) {
105
105
  return client.getAnswer(inquiry, context);
106
106
  },
107
107
 
108
+ /** 초안 직주입 스트리밍 — 답변 에디터에 AI 초안을 직접 흘려 쓴다.
109
+ *
110
+ * 패널·채택 버튼 없는 흐름의 정본이다: 소비처는 에디터 접근자(getDraft·
111
+ * setDraft)와 상태 표시(setStatus)만 배선하고, 나머지 제품 판단 —
112
+ * 덮어쓰기 확인, 교정 재시작 처리, 실패 시 원복, 문구, **자동 채택 귀속**
113
+ * (직주입 = 채택이므로 성공 시 noteAdopted 를 훅이 스스로 부른다) — 은
114
+ * 전부 여기 있다. answerSent 의 consume 의미론은 그대로다.
115
+ *
116
+ * @param {object} opts
117
+ * @param {string} opts.inquiry
118
+ * @param {Record<string,string>} [opts.context]
119
+ * @param {()=>string} [opts.getDraft] 현재 초안 (덮어쓰기 가드용)
120
+ * @param {(text:string)=>void} opts.setDraft 초안 전체 치환 (누적 스냅샷)
121
+ * @param {(text:string)=>void} [opts.setStatus] 한 줄 상태 ("" = 지움)
122
+ * @param {()=>boolean} [opts.confirmOverwrite] 초안이 비어있지 않을 때 확인
123
+ * @returns {{promise: Promise<object>, abort: ()=>void}}
124
+ */
125
+ composeDraft({ inquiry, context, getDraft, setDraft, setStatus,
126
+ confirmOverwrite } = {}) {
127
+ const status = (text) => { if (setStatus) setStatus(text || ""); };
128
+ const noop = { promise: Promise.resolve(NOT_CONFIGURED), abort: () => {} };
129
+ if (!client || !inquiry || typeof setDraft !== "function") {
130
+ status(textOf(messages, "not_configured"));
131
+ return noop;
132
+ }
133
+ const existing = typeof getDraft === "function"
134
+ ? String(getDraft() || "") : "";
135
+ if (existing.trim() && !(confirmOverwrite && confirmOverwrite())) {
136
+ // 상담사가 쓰던 초안이 우선한다 — 조용히 덮지 않는다.
137
+ return { promise: Promise.resolve({ ...NOT_CONFIGURED, declinedReason: "" }),
138
+ abort: () => {} };
139
+ }
140
+ // 새 스트림 = 이전 채택 무효 (문의가 같아도 초안이 바뀐다)
141
+ adoptedAnswerId = null;
142
+
143
+ const controller = new AbortController();
144
+ let accumulated = "";
145
+ let pending = null;
146
+ const flushDraft = () => { pending = null; setDraft(accumulated); };
147
+ const queueDraft = () => {
148
+ // trailing 스로틀 — 청크마다 리렌더하면 큰 화면이 버벅인다
149
+ if (pending === null) pending = setTimeout(flushDraft, 80);
150
+ };
151
+ const clearPending = () => {
152
+ if (pending !== null) { clearTimeout(pending); pending = null; }
153
+ };
154
+
155
+ status(messages.ui_requesting);
156
+ setDraft("");
157
+ const promise = client.getAnswerStream(inquiry, context, {
158
+ signal: controller.signal,
159
+ onDelta(text) { accumulated += text; queueDraft(); },
160
+ onRestart() {
161
+ // 계약 위반 교정 — 지금까지 보인 초안은 폐기본이다
162
+ clearPending();
163
+ accumulated = "";
164
+ setDraft("");
165
+ status(messages.ui_correcting);
166
+ },
167
+ onStage(phase) {
168
+ if (phase === "generating") status(messages.ui_requesting);
169
+ },
170
+ }).then((result) => {
171
+ clearPending();
172
+ if (result.answered) {
173
+ setDraft(result.answer); // 정본으로 확정 (strip 반영)
174
+ adoptedAnswerId = result.answerId ?? null;
175
+ status("");
176
+ } else {
177
+ // 실패·거절 — 미완성 텍스트를 에디터에 남기지 않고 원래 초안 복원
178
+ setDraft(existing);
179
+ status(textOf(messages, result.declinedReason || "unknown"));
180
+ }
181
+ return result;
182
+ });
183
+ return {
184
+ promise,
185
+ abort: () => { clearPending(); controller.abort(); },
186
+ };
187
+ },
188
+
108
189
  /** 제안을 에디터에 넣었다 (패널이 부른다) */
109
190
  noteAdopted(answerId) {
110
191
  adoptedAnswerId = answerId || null;
package/locales.js CHANGED
@@ -30,6 +30,8 @@ const en = {
30
30
  ui_requesting: "Generating…",
31
31
  ui_adopt: "Insert into editor",
32
32
  ui_evidence: "Sources",
33
+ ui_correcting: "Fixing phrasing — rewriting…",
34
+ ui_overwrite_confirm: "Replace your current draft with the AI draft?",
33
35
  // 거절·오류 사유
34
36
  not_configured: "AI reply suggestions are not connected yet.",
35
37
  unsupported_api: "AI reply suggestions are not connected yet.",
@@ -54,6 +56,8 @@ const ko = {
54
56
  ui_requesting: "제안 생성 중...",
55
57
  ui_adopt: "에디터에 넣기",
56
58
  ui_evidence: "근거",
59
+ ui_correcting: "표현 교정 중 — 다시 쓰는 중...",
60
+ ui_overwrite_confirm: "작성 중인 답변을 지우고 AI 초안으로 바꿀까요?",
57
61
  not_configured: "AI 답변 제안이 아직 연결되지 않았습니다.",
58
62
  unsupported_api: "AI 답변 제안이 아직 연결되지 않았습니다.",
59
63
  answer_disabled: "AI 답변 제안은 아직 켜지지 않았습니다. 문의·답변 수집만 진행 중입니다.",
@@ -73,6 +77,8 @@ const ja = {
73
77
  ui_requesting: "生成中...",
74
78
  ui_adopt: "エディタに挿入",
75
79
  ui_evidence: "根拠",
80
+ ui_correcting: "表現を修正中 — 書き直しています…",
81
+ ui_overwrite_confirm: "作成中の回答を消してAI下書きに置き換えますか?",
76
82
  not_configured: "AI 返信案はまだ接続されていません。",
77
83
  unsupported_api: "AI 返信案はまだ接続されていません。",
78
84
  answer_disabled: "AI 返信案はまだ有効になっていません。問い合わせの収集のみ実行中です。",
@@ -92,6 +98,8 @@ const zhTW = {
92
98
  ui_requesting: "產生中...",
93
99
  ui_adopt: "插入編輯器",
94
100
  ui_evidence: "依據",
101
+ ui_correcting: "正在修正表述 — 重新撰寫中…",
102
+ ui_overwrite_confirm: "要清除目前草稿並以 AI 草稿取代嗎?",
95
103
  not_configured: "AI 回覆建議尚未連接。",
96
104
  unsupported_api: "AI 回覆建議尚未連接。",
97
105
  answer_disabled: "AI 回覆建議尚未啟用,目前僅進行問題收集。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcg-labs/cx-agent-hook",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
5
5
  "type": "module",
6
6
  "main": "index.js",