@fcg-labs/cx-agent-hook 0.2.1 → 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.
- package/client.js +63 -0
- package/index.d.ts +15 -0
- package/index.js +116 -9
- package/package.json +2 -2
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,13 +210,24 @@ export function createCxHook(config = {}) {
|
|
|
137
210
|
return { promise: Promise.resolve({ ...NOT_CONFIGURED, declinedReason: "" }),
|
|
138
211
|
abort: () => {} };
|
|
139
212
|
}
|
|
140
|
-
// 새 스트림 = 이전
|
|
141
|
-
|
|
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
|
-
// 호칭 개인화 —
|
|
144
|
-
//
|
|
145
|
-
//
|
|
221
|
+
// 호칭 개인화 — 0.2.2: 이름을 context 로도 보낸다. 서버가 사설
|
|
222
|
+
// 서빙(AI_CS_LLM_PRIVATE)이면 인사말을 처음부터 이름으로 굽고,
|
|
223
|
+
// **저장은 하지 않는다**(로그에는 name_provided 마커만 — 서버 계약).
|
|
224
|
+
// 아래 클라 치환은 안전망으로 유지: 구 서버·클라우드 폴백처럼 이름
|
|
225
|
+
// 없이 "안녕하세요 고객님"으로 온 초안에만 작동하고, 서버가 이미
|
|
226
|
+
// 이름을 넣었으면 패턴이 안 맞아 no-op 이다.
|
|
146
227
|
const name = String(customerName || "").trim();
|
|
228
|
+
const contextWithName = name
|
|
229
|
+
? { ...(context || {}), customerName: name }
|
|
230
|
+
: context;
|
|
147
231
|
const personalize = (text) =>
|
|
148
232
|
name
|
|
149
233
|
? String(text).replace(
|
|
@@ -153,6 +237,7 @@ export function createCxHook(config = {}) {
|
|
|
153
237
|
: text;
|
|
154
238
|
|
|
155
239
|
const controller = new AbortController();
|
|
240
|
+
let aborted = false;
|
|
156
241
|
let accumulated = "";
|
|
157
242
|
let pending = null;
|
|
158
243
|
const flushDraft = () => { pending = null; setDraft(personalize(accumulated)); };
|
|
@@ -166,7 +251,7 @@ export function createCxHook(config = {}) {
|
|
|
166
251
|
|
|
167
252
|
status(messages.ui_requesting);
|
|
168
253
|
setDraft("");
|
|
169
|
-
const promise = client.getAnswerStream(inquiry,
|
|
254
|
+
const promise = client.getAnswerStream(inquiry, contextWithName, {
|
|
170
255
|
signal: controller.signal,
|
|
171
256
|
onDelta(text) { accumulated += text; queueDraft(); },
|
|
172
257
|
onRestart() {
|
|
@@ -181,20 +266,42 @@ export function createCxHook(config = {}) {
|
|
|
181
266
|
},
|
|
182
267
|
}).then((result) => {
|
|
183
268
|
clearPending();
|
|
269
|
+
if (aborted) {
|
|
270
|
+
// 소비자 주도 중단 (문의 전환 등) — 화면은 소비자가 이미 재구성했다.
|
|
271
|
+
// 여기서 원복하면 **이전 문의의 초안이 새 문의 에디터에 주입**된다
|
|
272
|
+
// (0.2.3 — A 답변이 B 로 발송될 수 있던 오염 경로). 아무것도 안 만진다.
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
184
275
|
if (result.answered) {
|
|
185
276
|
setDraft(personalize(result.answer)); // 정본 확정 (strip+호칭 반영)
|
|
186
|
-
|
|
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;
|
|
187
284
|
status("");
|
|
188
285
|
} else {
|
|
189
|
-
// 실패·거절 — 미완성 텍스트를 에디터에 남기지 않고 원래 초안
|
|
286
|
+
// 실패·거절 — 미완성 텍스트를 에디터에 남기지 않고 원래 초안 복원.
|
|
287
|
+
// 채택 기록도 함께 원복 — 텍스트만 되돌리면 복원된 이전 초안의
|
|
288
|
+
// verbatim 발송이 원장에서 빠진다 (0.2.3 비대칭 수정).
|
|
190
289
|
setDraft(existing);
|
|
290
|
+
adoptedAnswerId = supersededId;
|
|
191
291
|
status(textOf(messages, result.declinedReason || "unknown"));
|
|
192
292
|
}
|
|
193
293
|
return result;
|
|
194
294
|
});
|
|
195
295
|
return {
|
|
196
296
|
promise,
|
|
197
|
-
abort: () => {
|
|
297
|
+
abort: () => {
|
|
298
|
+
// 중단 = 화면 상태는 소비자 소유, 원장 상태는 요청 전으로 복귀 —
|
|
299
|
+
// 직후 소비자의 폐기 훅(discarded)이 이전 채택 id 로 발화할 수 있다.
|
|
300
|
+
aborted = true;
|
|
301
|
+
adoptedAnswerId = supersededId;
|
|
302
|
+
clearPending();
|
|
303
|
+
controller.abort();
|
|
304
|
+
},
|
|
198
305
|
};
|
|
199
306
|
},
|
|
200
307
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fcg-labs/cx-agent-hook",
|
|
3
|
-
"version": "0.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",
|
|
@@ -100,4 +100,4 @@
|
|
|
100
100
|
"access": "public"
|
|
101
101
|
},
|
|
102
102
|
"//name": "스코프는 @fcg-labs — org 소유이고 @fcg-labs/tlm 과 같은 자리다. @fcg 는 우리 것이 아니다(발행 불가)."
|
|
103
|
-
}
|
|
103
|
+
}
|