@fcg-labs/cx-agent-hook 0.2.2 → 0.2.3

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.
Files changed (4) hide show
  1. package/client.js +63 -0
  2. package/index.d.ts +15 -0
  3. package/index.js +106 -5
  4. package/package.json +1 -1
package/client.js CHANGED
@@ -35,6 +35,8 @@ const PATHS = {
35
35
  answerStream: (d) => `/v1/domains/${d}/answer/stream`,
36
36
  feedback: (d) => `/v1/domains/${d}/feedback`,
37
37
  ingress: (d) => `/v1/domains/${d}/ingress`,
38
+ // 조사 접점 (E-8) — 트리거는 큐 적재만, 상태는 경량 메타만 (허브 계약)
39
+ investigation: (d) => `/v1/domains/${d}/investigations`,
38
40
  },
39
41
  // platform 은 인그레스 API 미제공 (공장 큐레이션 파이프라인이 담당)
40
42
  };
@@ -525,6 +527,67 @@ export class CxAgentClient {
525
527
  this.onError(err, { op: "logInquiry", code });
526
528
  return { ok: false, inquiryId: null, duplicate: false, error: code };
527
529
  }
530
+
531
+ /** 조사 요청 (E-8) — 허브 큐 적재만. 실행·판정은 공장 몫이라 즉시 돌아온다.
532
+ * 활성 요청 재제출은 허브가 멱등 처리한다 (중복 큐잉 없음). */
533
+ async requestInvestigation(externalId, { userId, subUserId } = {}) {
534
+ if (this.api !== "hub" || !externalId) {
535
+ return { ok: false, status: "", error: "unsupported" };
536
+ }
537
+ try {
538
+ const { status, data } = await this._post(
539
+ PATHS.hub.investigation(this.domain),
540
+ {
541
+ external_id: String(externalId),
542
+ user_id: userId != null ? String(userId) : "",
543
+ sub_user_id: subUserId != null ? String(subUserId) : "",
544
+ },
545
+ );
546
+ if (status === 200 || status === 201) {
547
+ return { ok: true, status: data.status || "queued", error: null };
548
+ }
549
+ this.onError(new Error(`HTTP ${status}`),
550
+ { op: "requestInvestigation", code: data.error || "http_error" });
551
+ return { ok: false, status: "", error: data.error || "http_error" };
552
+ } catch (err) {
553
+ this.onError(err, { op: "requestInvestigation", code: "network_error" });
554
+ return { ok: false, status: "", error: "network_error" };
555
+ }
556
+ }
557
+
558
+ /** 조사 상태 (E-8) — 경량 메타만 온다 {status, job_id}. 리포트 본문은
559
+ * 공장 밖 불반출 계약이라 이 표면에 존재하지 않는다. */
560
+ async investigationStatus(externalId) {
561
+ if (this.api !== "hub" || !externalId) {
562
+ return { ok: false, status: "", error: "unsupported" };
563
+ }
564
+ const controller = new AbortController();
565
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
566
+ try {
567
+ const res = await this.fetchImpl(
568
+ this.baseUrl + PATHS.hub.investigation(this.domain)
569
+ + `/${encodeURIComponent(String(externalId))}/status`,
570
+ {
571
+ headers: { "Authorization": `Bearer ${this.token}` },
572
+ signal: controller.signal,
573
+ },
574
+ );
575
+ const data = await res.json().catch(() => ({}));
576
+ if (res.status === 200) {
577
+ return { ok: true, status: data.status || "",
578
+ jobId: data.job_id || 0, error: null };
579
+ }
580
+ if (res.status === 404) {
581
+ return { ok: true, status: "none", jobId: 0, error: null };
582
+ }
583
+ return { ok: false, status: "", error: data.error || "http_error" };
584
+ } catch (err) {
585
+ this.onError(err, { op: "investigationStatus", code: "network_error" });
586
+ return { ok: false, status: "", error: "network_error" };
587
+ } finally {
588
+ clearTimeout(timer);
589
+ }
590
+ }
528
591
  }
529
592
 
530
593
  export default CxAgentClient;
package/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export type { AnswerResult, ApiTarget };
13
13
  export type CxHookConfig = Partial<CxAgentConfig> & {
14
14
  /** 화면 언어. 한 번 넘기면 이후 문구는 전부 훅이 낸다. 미지정이면 "en". */
15
15
  locale?: string;
16
+ /** 중앙 설정 부트스트랩 (허브 client-config) — 기본 false. 명시 인자가 항상 이긴다. */
17
+ bootstrap?: boolean;
16
18
  /** 특정 문구만 덮어쓰기 — 나머지는 로케일 값 그대로 */
17
19
  messages?: Record<string, string>;
18
20
  };
@@ -30,6 +32,19 @@ export declare function declineText(reason: string, locale?: string): string;
30
32
  export interface CxHook {
31
33
  /** 주소·토큰·도메인이 다 있으면 true */
32
34
  enabled: boolean;
35
+ /** 부트스트랩 완료 신호 — bootstrap 미사용 시 즉시 resolve (실패도 resolve) */
36
+ ready: Promise<void>;
37
+ /** 중앙 발행 기능 플래그 — 부트스트랩 전/미발행이면 빈 객체 */
38
+ readonly flags: Record<string, unknown>;
39
+ /** 조사 요청 (E-8) — 허브 큐 적재만, 실행·검수는 공장. 활성 중복은 멱등 */
40
+ requestInvestigation(
41
+ externalId: string | number,
42
+ identity?: { userId?: string | number; subUserId?: string | number },
43
+ ): Promise<{ ok: boolean; status: string; error: string | null }>;
44
+ /** 조사 상태 (E-8) — 경량 메타만: none|queued|running|succeeded|failed */
45
+ investigationStatus(
46
+ externalId: string | number,
47
+ ): Promise<{ ok: boolean; status: string; jobId?: number; error: string | null }>;
33
48
  /** setup 에서 정해진 화면 언어 (정규화된 값) */
34
49
  locale: Locale;
35
50
  /** 이 훅의 문구 한 벌 — UI 라벨 포함 */
package/index.js CHANGED
@@ -52,9 +52,15 @@ export function createCxHook(config = {}) {
52
52
  // 화면 언어. 소비처가 이미 아는 값이라 setup 에서 한 번 넘기면 끝이고,
53
53
  // 이후 문구는 전부 훅이 낸다 — 고객사가 사유별 문구를 알 필요가 없다.
54
54
  locale, messages: messageOverrides,
55
+ // 중앙 설정 부트스트랩 (허브 GET client-config) — 기본 꺼짐(후방호환).
56
+ // 켜면 locale·문구·기능 플래그를 허브 발행분으로 병합한다. **명시 인자가
57
+ // 항상 이긴다** — 로컬이 정한 값은 원격이 못 덮는다. 전송 3요소(baseUrl·
58
+ // token·domain)는 부트스트랩의 닭-달걀이라 중앙화 대상이 아니다.
59
+ bootstrap = false,
55
60
  ...rest
56
61
  } = config;
57
62
  const messages = resolveMessages(locale, messageOverrides);
63
+ let flags = {};
58
64
  const client =
59
65
  baseUrl && token && domain
60
66
  ? new CxAgentClient({
@@ -86,9 +92,63 @@ export function createCxHook(config = {}) {
86
92
  send(id);
87
93
  };
88
94
 
95
+ const applyRemoteConfig = (remote) => {
96
+ if (!remote || typeof remote !== "object") return;
97
+ if (remote.locale || remote.messages) {
98
+ // 원격 문구 위에 로컬 override 재적용 — 로컬 우선 규칙을 병합 순서로 강제
99
+ const next = resolveMessages(locale || remote.locale, {
100
+ ...(remote.messages || {}),
101
+ ...(messageOverrides || {}),
102
+ });
103
+ Object.assign(messages, next);
104
+ }
105
+ if (remote.flags && typeof remote.flags === "object") {
106
+ flags = { ...remote.flags };
107
+ }
108
+ };
109
+
110
+ const fetchRemoteConfig = async () => {
111
+ const doFetch = rest.fetchImpl || (typeof fetch !== "undefined" ? fetch : null);
112
+ if (!doFetch) return;
113
+ const cacheKey = `cx-agent-config:${domain}`;
114
+ try {
115
+ if (typeof sessionStorage !== "undefined") {
116
+ const cached = JSON.parse(sessionStorage.getItem(cacheKey) || "null");
117
+ if (cached && Date.now() - cached.at < 5 * 60 * 1000) {
118
+ applyRemoteConfig(cached.config);
119
+ return;
120
+ }
121
+ }
122
+ } catch { /* 캐시 실패는 무시 — 네트워크로 진행 */ }
123
+ const res = await doFetch(
124
+ `${String(baseUrl).replace(/\/$/, "")}/v1/domains/${domain}/client-config`,
125
+ { headers: { Authorization: `Bearer ${token}` } },
126
+ );
127
+ if (!res.ok) return; // 404(미발행) 포함 — 정적 설정으로 동작 (fail-soft)
128
+ const remote = await res.json();
129
+ applyRemoteConfig(remote);
130
+ try {
131
+ if (typeof sessionStorage !== "undefined") {
132
+ sessionStorage.setItem(
133
+ cacheKey, JSON.stringify({ at: Date.now(), config: remote }),
134
+ );
135
+ }
136
+ } catch { /* 저장 실패 무시 */ }
137
+ };
138
+
139
+ // 부트스트랩은 백그라운드 — 화면을 막지 않는다. 기다리고 싶은 소비처만
140
+ // ready 를 await 한다 (실패도 resolve — 훅은 항상 동작 가능 상태).
141
+ const ready = (bootstrap && client && api === "hub")
142
+ ? fetchRemoteConfig().catch(() => {})
143
+ : Promise.resolve();
144
+
89
145
  return {
90
146
  /** 세 값이 다 있으면 true */
91
147
  enabled: Boolean(client),
148
+ /** 부트스트랩 완료 신호 — bootstrap 미사용 시 즉시 resolve */
149
+ ready,
150
+ /** 중앙 발행 기능 플래그 — 부트스트랩 전/미발행이면 빈 객체 */
151
+ get flags() { return flags; },
92
152
  /** setup 에서 정해진 화면 언어 (정규화된 값) */
93
153
  locale: normalizeLocale(locale),
94
154
  /** 이 훅의 문구 한 벌 — 패널이 UI 라벨까지 여기서 가져간다 */
@@ -105,6 +165,19 @@ export function createCxHook(config = {}) {
105
165
  return client.getAnswer(inquiry, context);
106
166
  },
107
167
 
168
+ /** 조사 요청 (E-8) — "이 케이스 깊이 조사해줘" 트리거. 큐 적재만 하고 즉시
169
+ * 돌아온다 — 결과는 investigationStatus 로 폴링, 리포트 검수는 공장 웹. */
170
+ requestInvestigation(externalId, identity) {
171
+ if (!client) return Promise.resolve({ ok: false, status: "", error: "not_configured" });
172
+ return client.requestInvestigation(externalId, identity);
173
+ },
174
+
175
+ /** 조사 상태 (E-8) — {status: none|queued|running|succeeded|failed, jobId} */
176
+ investigationStatus(externalId) {
177
+ if (!client) return Promise.resolve({ ok: false, status: "", error: "not_configured" });
178
+ return client.investigationStatus(externalId);
179
+ },
180
+
108
181
  /** 초안 직주입 스트리밍 — 답변 에디터에 AI 초안을 직접 흘려 쓴다.
109
182
  *
110
183
  * 패널·채택 버튼 없는 흐름의 정본이다: 소비처는 에디터 접근자(getDraft·
@@ -137,8 +210,13 @@ export function createCxHook(config = {}) {
137
210
  return { promise: Promise.resolve({ ...NOT_CONFIGURED, declinedReason: "" }),
138
211
  abort: () => {} };
139
212
  }
140
- // 새 스트림 = 이전 채택 무효 (문의가 같아도 초안이 바뀐다)
141
- adoptedAnswerId = null;
213
+ // 새 스트림 = 이전 채택의 **교체 시도** (0.2.3). 확정(성공) 전까지
214
+ // 폐기하지 않고 보류한다 — 즉시 null 로 지우던 침묵 소거는 ① 실패 시
215
+ // 원복이 텍스트만 되고 채택 id 는 유실돼 verbatim 발송이 원장에서
216
+ // 빠졌고 ② 상담사가 명백히 버린 이전 제안이 기각 신호 없이 영구
217
+ // 미확인으로 남았다 (2026-08-10 적대 검증).
218
+ const supersededId = adoptedAnswerId;
219
+ adoptedAnswerId = null; // 스트림 중 발송이 낡은 id 로 귀속되는 것 방지
142
220
 
143
221
  // 호칭 개인화 — 0.2.2: 이름을 context 로도 보낸다. 서버가 사설
144
222
  // 서빙(AI_CS_LLM_PRIVATE)이면 인사말을 처음부터 이름으로 굽고,
@@ -159,6 +237,7 @@ export function createCxHook(config = {}) {
159
237
  : text;
160
238
 
161
239
  const controller = new AbortController();
240
+ let aborted = false;
162
241
  let accumulated = "";
163
242
  let pending = null;
164
243
  const flushDraft = () => { pending = null; setDraft(personalize(accumulated)); };
@@ -187,20 +266,42 @@ export function createCxHook(config = {}) {
187
266
  },
188
267
  }).then((result) => {
189
268
  clearPending();
269
+ if (aborted) {
270
+ // 소비자 주도 중단 (문의 전환 등) — 화면은 소비자가 이미 재구성했다.
271
+ // 여기서 원복하면 **이전 문의의 초안이 새 문의 에디터에 주입**된다
272
+ // (0.2.3 — A 답변이 B 로 발송될 수 있던 오염 경로). 아무것도 안 만진다.
273
+ return result;
274
+ }
190
275
  if (result.answered) {
191
276
  setDraft(personalize(result.answer)); // 정본 확정 (strip+호칭 반영)
192
- adoptedAnswerId = result.answerId ?? null;
277
+ const newId = result.answerId ?? null;
278
+ if (supersededId && supersededId !== newId) {
279
+ // 교체 확정 — 이전 제안은 기각으로 원장에 남긴다 (침묵 소거 금지).
280
+ // fire-and-forget: 실패해도 발송 UX 무간섭.
281
+ client.discarded(supersededId, "", "redraft");
282
+ }
283
+ adoptedAnswerId = newId;
193
284
  status("");
194
285
  } else {
195
- // 실패·거절 — 미완성 텍스트를 에디터에 남기지 않고 원래 초안 복원
286
+ // 실패·거절 — 미완성 텍스트를 에디터에 남기지 않고 원래 초안 복원.
287
+ // 채택 기록도 함께 원복 — 텍스트만 되돌리면 복원된 이전 초안의
288
+ // verbatim 발송이 원장에서 빠진다 (0.2.3 비대칭 수정).
196
289
  setDraft(existing);
290
+ adoptedAnswerId = supersededId;
197
291
  status(textOf(messages, result.declinedReason || "unknown"));
198
292
  }
199
293
  return result;
200
294
  });
201
295
  return {
202
296
  promise,
203
- abort: () => { clearPending(); controller.abort(); },
297
+ abort: () => {
298
+ // 중단 = 화면 상태는 소비자 소유, 원장 상태는 요청 전으로 복귀 —
299
+ // 직후 소비자의 폐기 훅(discarded)이 이전 채택 id 로 발화할 수 있다.
300
+ aborted = true;
301
+ adoptedAnswerId = supersededId;
302
+ clearPending();
303
+ controller.abort();
304
+ },
204
305
  };
205
306
  },
206
307
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcg-labs/cx-agent-hook",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
5
5
  "type": "module",
6
6
  "main": "index.js",