@fcg-labs/cx-agent-hook 0.1.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.js ADDED
@@ -0,0 +1,359 @@
1
+ /**
2
+ * 전송 계층 — HTTP 왕복, 재시도, 경로·토큰 규약. **패키지 내부용이다.**
3
+ *
4
+ * `exports` 맵에 없다. 소비처가 `@fcg-labs/cx-agent-hook` 로 닿을 수 있는 것은
5
+ * `createCxHook` 이고, 이 클래스는 그 안에서만 만들어진다.
6
+ *
7
+ * 왜 닫아 두나 (2026-08-03): 열어 두면 새 능력을 붙일 때 자연히 여기로 내려가고,
8
+ * 그 순간 `answer_id` 수명 관리 같은 제품 지식이 다시 고객사 코드로 흩어진다.
9
+ * 능력이 모자라면 여기를 노출하는 게 아니라 **훅 표면을 채우는 것**이 답이다.
10
+ *
11
+ * 원칙:
12
+ * - 의존성 0 (내장 fetch) — 브라우저·Node 18+ 어디서나
13
+ * - 후킹은 CS팀 업무를 절대 막지 않는다: sendFeedback 은 throw 하지 않고
14
+ * 재시도 후 { ok:false } 로 보고한다 (발송 UX 무간섭)
15
+ * - 대상 전환 가능: platform(현 공장 API) ↔ hub(자산 허브 v1 경로)
16
+ */
17
+
18
+ // 정본: src/agent_ax_config/store.py FEEDBACK_ACTIONS (언어 경계 사본 — 함께 바꿀 것)
19
+ const FEEDBACK_ACTIONS = new Set(["scored", "edited", "sent", "discarded"]);
20
+
21
+ const PATHS = {
22
+ platform: {
23
+ answer: (d) => `/api/domains/${d}/answer`,
24
+ feedback: (d) => `/api/domains/${d}/feedback`,
25
+ },
26
+ hub: {
27
+ // 허브가 답변을 중계한다 (POST → 내부 서빙 노드로 프록시 + 교정 미러링).
28
+ // 정본: hub/internal/api/server.go 의 answer 라우트.
29
+ //
30
+ // 이 값이 null 이던 시절의 주석은 "허브는 아직 답변 생성 미담당" 이었는데,
31
+ // 허브에 라우트가 생긴 뒤에도 SDK 가 그대로여서 hub 대상 getAnswer 가
32
+ // HTTP 를 타보지도 못하고 unsupported_api 로 단락됐다 (2026-07-31 감사).
33
+ answer: (d) => `/v1/domains/${d}/answer`,
34
+ feedback: (d) => `/v1/domains/${d}/feedback`,
35
+ ingress: (d) => `/v1/domains/${d}/ingress`,
36
+ },
37
+ // platform 은 인그레스 API 미제공 (공장 큐레이션 파이프라인이 담당)
38
+ };
39
+
40
+ function sleep(ms) {
41
+ return new Promise((resolve) => setTimeout(resolve, ms));
42
+ }
43
+
44
+ export class CxAgentClient {
45
+ /**
46
+ * @param {object} config
47
+ * @param {string} config.baseUrl 예: "https://hub.fcg.example"
48
+ * @param {string} config.token Bearer 토큰 (edge 등급)
49
+ * @param {string} config.domain 예: "acme_cs" (수집함 이름)
50
+ * @param {"platform"|"hub"} [config.api="platform"] 기본 대상
51
+ * @param {{baseUrl?:string, token?:string, api?:string}} [config.answer]
52
+ * 답변 생성 전용 대상. 공장(platform)과 허브가 서로 다른 주소·토큰을 쓰므로
53
+ * 하나만으로는 "답변 생성 + 문의 적재"를 동시에 못 한다 — 그 조합이 바로
54
+ * CMS 가 필요로 하는 것이라, 능력별로 대상을 열어 둔다.
55
+ * @param {{baseUrl?:string, token?:string, api?:string}} [config.ingress]
56
+ * 문의 적재 전용 대상 (보통 허브).
57
+ * @param {{baseUrl?:string, token?:string, api?:string}} [config.feedback]
58
+ * 교정 후킹 전용 대상.
59
+ * @param {number} [config.retries=2] 피드백 재시도 횟수
60
+ * @param {number} [config.timeoutMs=90000] 답변 생성 시간제한.
61
+ *
62
+ * 실측(2026-07-31, 운영 허브 직접 호출 n=4): 16.7s · 25.9s · 30.5s · 59.7s.
63
+ * LLM 호출이라 문의마다 크게 흔들린다. 기본값이 30s 이던 시절에는 절반이
64
+ * 시간초과로 죽었고, 화면에는 그냥 패널이 안 뜨는 것으로만 보였다 —
65
+ * 상담사도 개발자도 원인을 알 수 없는 침묵이다.
66
+ *
67
+ * 여유를 크게 두는 편이 맞다. 이 요청은 상담사를 막지 않으므로(그동안
68
+ * 수동 작성 가능) 오래 기다리는 비용은 작고, 일찍 끊는 비용은 크다.
69
+ * n=4 는 분포를 말하기엔 적다 — 운영 로그가 쌓이면 다시 재라.
70
+ * @param {(err: Error, context: object) => void} [config.onError]
71
+ * @param {typeof fetch} [config.fetchImpl] 테스트 주입용
72
+ */
73
+ constructor({ baseUrl, token, domain, api = "platform", retries = 2,
74
+ timeoutMs = 90000, onError, fetchImpl,
75
+ answer, ingress, feedback } = {}) {
76
+ if (!baseUrl || !token || !domain) {
77
+ throw new Error("@fcg-labs/cx-agent-hook: baseUrl·token·domain 은 필수입니다");
78
+ }
79
+ if (!PATHS[api]) {
80
+ throw new Error(`@fcg-labs/cx-agent-hook: api 는 platform|hub (받음: ${api})`);
81
+ }
82
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
83
+ this.token = token;
84
+ this.domain = domain;
85
+ this.api = api;
86
+ // 능력별 대상 override — 미지정이면 기본 대상을 쓴다(하위호환).
87
+ this.targets = {};
88
+ for (const [cap, cfg] of Object.entries({ answer, ingress, feedback })) {
89
+ if (!cfg) continue;
90
+ const capApi = cfg.api || api;
91
+ if (!PATHS[capApi]) {
92
+ throw new Error(`@fcg-labs/cx-agent-hook: ${cap}.api 는 platform|hub (받음: ${capApi})`);
93
+ }
94
+ this.targets[cap] = {
95
+ baseUrl: (cfg.baseUrl || baseUrl).replace(/\/+$/, ""),
96
+ token: cfg.token || token,
97
+ api: capApi,
98
+ };
99
+ }
100
+ this.retries = retries;
101
+ this.timeoutMs = timeoutMs;
102
+ this.onError = onError || (() => {});
103
+ this.fetchImpl = fetchImpl || globalThis.fetch.bind(globalThis);
104
+ }
105
+
106
+ /**
107
+ * 능력(capability) → 실제 대상 해석.
108
+ * @returns {{baseUrl:string, token:string, path:((d:string)=>string)|null}}
109
+ */
110
+ _target(capability) {
111
+ const t = this.targets[capability] || {
112
+ baseUrl: this.baseUrl, token: this.token, api: this.api,
113
+ };
114
+ return {
115
+ baseUrl: t.baseUrl, token: t.token, api: t.api,
116
+ path: PATHS[t.api][capability] || null,
117
+ };
118
+ }
119
+
120
+ async _post(path, body, target) {
121
+ const { baseUrl, token } = target || { baseUrl: this.baseUrl, token: this.token };
122
+ const controller = new AbortController();
123
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
124
+ try {
125
+ const res = await this.fetchImpl(baseUrl + path, {
126
+ method: "POST",
127
+ headers: {
128
+ "Authorization": `Bearer ${token}`,
129
+ "Content-Type": "application/json",
130
+ },
131
+ body: JSON.stringify(body),
132
+ signal: controller.signal,
133
+ });
134
+ const data = await res.json().catch(() => ({}));
135
+ return { status: res.status, data };
136
+ } finally {
137
+ clearTimeout(timer);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * 서빙 답변 요청. throw 하지 않는다 — ok 로 판별.
143
+ * @param {string} inquiry 고객 문의 원문
144
+ * @returns {Promise<{ok:boolean, answered:boolean, answer:string,
145
+ * answerId:number|null, evidence:Array, declinedReason:string, raw:object}>}
146
+ */
147
+ async getAnswer(inquiry) {
148
+ const target = this._target("answer");
149
+ if (!target.path) {
150
+ // throw 하지 않는다 — 이 SDK 의 원칙은 "CS팀 업무를 절대 막지 않는다".
151
+ // 답변 대상이 안 잡혀 있으면 상담사는 수동 작성으로 계속 가면 된다.
152
+ this.onError(
153
+ new Error("답변 생성 대상이 설정되지 않았습니다 (answer.baseUrl 확인)"),
154
+ { op: "getAnswer" },
155
+ );
156
+ return {
157
+ ok: false, answered: false, answer: "", answerId: null,
158
+ evidence: [], declinedReason: "unsupported_api", raw: {},
159
+ };
160
+ }
161
+ try {
162
+ const { status, data } = await this._post(
163
+ target.path(this.domain), { inquiry }, target,
164
+ );
165
+ if (status !== 200) {
166
+ // 서버가 준 사유를 그대로 옮긴다. `http_503` 으로 뭉개면 "아직 안 켬"
167
+ // (answer_disabled) 과 "장애" (answer_unavailable) 가 같은 값이 되고,
168
+ // 화면은 켜지지도 않은 기능을 두고 "잠시 후 다시 시도" 라고 거짓말한다.
169
+ // 진실의 주인은 서버이고 SDK 는 전달만 한다.
170
+ //
171
+ // 필드명 `error` 는 허브·공장 양쪽이 같다:
172
+ // 허브 writeErr → {"error": code, "detail": ...}
173
+ // 공장 JSONResponse({"error": "serving_disabled"}, 503) 등
174
+ return {
175
+ ok: false, answered: false, answer: "", answerId: null,
176
+ evidence: [],
177
+ declinedReason: (data && data.error) || `http_${status}`,
178
+ raw: data,
179
+ };
180
+ }
181
+ return {
182
+ ok: true,
183
+ answered: Boolean(data.answered),
184
+ answer: data.answer || "",
185
+ answerId: data.answer_id ?? null,
186
+ evidence: data.evidence || [],
187
+ declinedReason: data.declined_reason || "",
188
+ raw: data,
189
+ };
190
+ } catch (err) {
191
+ this.onError(err, { op: "getAnswer" });
192
+ return {
193
+ ok: false, answered: false, answer: "", answerId: null,
194
+ evidence: [], declinedReason: "network_error", raw: {},
195
+ };
196
+ }
197
+ }
198
+
199
+ /**
200
+ * 교정 후킹 — CS팀 업무를 막지 않는다: throw 없이 재시도 후 결과 보고.
201
+ * @param {object} fb
202
+ * @param {number|string} fb.answerId
203
+ * @param {"scored"|"edited"|"sent"|"discarded"} fb.action
204
+ * @param {number} [fb.score] scored 시 1~5 필수
205
+ * @param {string} [fb.finalText] 수정/발송된 최종 문구
206
+ * @param {string} [fb.agent] CS 상담사 식별자
207
+ * @param {string} [fb.note]
208
+ * @returns {Promise<{ok:boolean, feedbackId:number|null, error:string}>}
209
+ */
210
+ async sendFeedback({ answerId, action, score, finalText, agent, note } = {}) {
211
+ // 계약 위반은 재시도해도 소용없다 — 즉시 보고
212
+ if (answerId === undefined || answerId === null || answerId === "") {
213
+ return this._fbFail(new Error("answerId 필수"), "invalid_answer_id");
214
+ }
215
+ if (!FEEDBACK_ACTIONS.has(action)) {
216
+ return this._fbFail(new Error(`action 무효: ${action}`), "invalid_action");
217
+ }
218
+ if (action === "scored" && !(Number.isInteger(score) && score >= 1 && score <= 5)) {
219
+ return this._fbFail(new Error("scored 는 1~5 정수 점수 필수"), "invalid_score");
220
+ }
221
+ const target = this._target("feedback");
222
+ if (!target.path) {
223
+ return this._fbFail(
224
+ new Error("교정 후킹 대상이 설정되지 않았습니다"), "unsupported_api",
225
+ );
226
+ }
227
+ const body = {
228
+ answer_id: target.api === "hub" ? String(answerId) : answerId,
229
+ action,
230
+ ...(score !== undefined ? { score } : {}),
231
+ ...(finalText ? { final_text: finalText } : {}),
232
+ ...(agent ? { agent } : {}),
233
+ ...(note ? { note } : {}),
234
+ };
235
+ const path = target.path(this.domain);
236
+ let lastErr = null;
237
+ for (let attempt = 0; attempt <= this.retries; attempt++) {
238
+ try {
239
+ const { status, data } = await this._post(path, body, target);
240
+ if (status === 200) {
241
+ return { ok: true, feedbackId: data.feedback_id ?? data.id ?? null, error: "" };
242
+ }
243
+ // 4xx 는 계약 문제 — 재시도 무의미
244
+ if (status >= 400 && status < 500) {
245
+ return this._fbFail(
246
+ new Error(`http_${status}: ${JSON.stringify(data)}`), `http_${status}`,
247
+ );
248
+ }
249
+ lastErr = new Error(`http_${status}`);
250
+ } catch (err) {
251
+ lastErr = err;
252
+ }
253
+ if (attempt < this.retries) {
254
+ await sleep(300 * (attempt + 1));
255
+ }
256
+ }
257
+ return this._fbFail(lastErr || new Error("unknown"), "network_error");
258
+ }
259
+
260
+ _fbFail(err, code) {
261
+ this.onError(err, { op: "sendFeedback", code });
262
+ return { ok: false, feedbackId: null, error: code };
263
+ }
264
+
265
+ /** 발송 후킹 — CS팀이 실제로 내보낸 최종 문구 (gold 학습 쌍의 원천) */
266
+ sent(answerId, finalText, agent) {
267
+ return this.sendFeedback({ answerId, action: "sent", finalText, agent });
268
+ }
269
+
270
+ /** 점수 후킹 (1~5) */
271
+ scored(answerId, score, agent) {
272
+ return this.sendFeedback({ answerId, action: "scored", score, agent });
273
+ }
274
+
275
+ /** 수정 후킹 — 발송 전 편집 상태 공유 */
276
+ edited(answerId, finalText, agent) {
277
+ return this.sendFeedback({ answerId, action: "edited", finalText, agent });
278
+ }
279
+
280
+ /** 폐기 후킹 — 품질 경보 신호 */
281
+ discarded(answerId, agent, note) {
282
+ return this.sendFeedback({ answerId, action: "discarded", agent, note });
283
+ }
284
+
285
+ /**
286
+ * 문의 적재 후킹 — 신규 문의(+상담사 최종 답변)를 허브로.
287
+ * (external_id 기준 멱등 — 같은 문의를 여러 번 보내도 안전)
288
+ *
289
+ * AI 서빙 없이도 광물이 흐르는 통로: 문의+최종답변 쌍은 그대로
290
+ * 교재(학습) 후보가 된다. CS팀 업무를 막지 않는다 — throw 없음.
291
+ *
292
+ * @param {object} q
293
+ * @param {number|string} q.externalId 원 시스템 문의 id (멱등 키)
294
+ * @param {string} q.inquiry 고객 문의 원문 (필수)
295
+ * @param {string} [q.reply] 상담사 최종 답변 (있으면 Q/A 학습 후보)
296
+ * @param {string} [q.agent] CS 상담사 식별자
297
+ * @param {object} [q.meta] 부가 정보 (문자열 값만)
298
+ * @returns {Promise<{ok:boolean, inquiryId:number|null, duplicate:boolean, error:string}>}
299
+ */
300
+ async logInquiry({ externalId, inquiry, reply, agent, meta } = {}) {
301
+ const target = this._target("ingress");
302
+ if (!target.path) {
303
+ return this._inqFail(
304
+ new Error("문의 적재 대상이 설정되지 않았습니다 (ingress.baseUrl 확인)"),
305
+ "unsupported_api",
306
+ );
307
+ }
308
+ if (externalId === undefined || externalId === null || externalId === "") {
309
+ return this._inqFail(new Error("externalId 필수"), "invalid_external_id");
310
+ }
311
+ if (!inquiry || !String(inquiry).trim()) {
312
+ return this._inqFail(new Error("inquiry 필수"), "invalid_inquiry");
313
+ }
314
+ const body = {
315
+ source: "cms",
316
+ external_id: String(externalId),
317
+ inquiry: String(inquiry),
318
+ ...(reply ? { reply: String(reply) } : {}),
319
+ ...(agent || meta
320
+ ? { meta: { ...(meta || {}), ...(agent ? { agent: String(agent) } : {}) } }
321
+ : {}),
322
+ };
323
+ let lastErr = null;
324
+ for (let attempt = 0; attempt <= this.retries; attempt++) {
325
+ try {
326
+ const { status, data } = await this._post(
327
+ target.path(this.domain), body, target,
328
+ );
329
+ if (status === 200) {
330
+ return {
331
+ ok: true,
332
+ inquiryId: data.id ?? null,
333
+ duplicate: Boolean(data.duplicate),
334
+ error: "",
335
+ };
336
+ }
337
+ if (status >= 400 && status < 500) {
338
+ return this._inqFail(
339
+ new Error(`http_${status}: ${JSON.stringify(data)}`), `http_${status}`,
340
+ );
341
+ }
342
+ lastErr = new Error(`http_${status}`);
343
+ } catch (err) {
344
+ lastErr = err;
345
+ }
346
+ if (attempt < this.retries) {
347
+ await sleep(300 * (attempt + 1));
348
+ }
349
+ }
350
+ return this._inqFail(lastErr || new Error("unknown"), "network_error");
351
+ }
352
+
353
+ _inqFail(err, code) {
354
+ this.onError(err, { op: "logInquiry", code });
355
+ return { ok: false, inquiryId: null, duplicate: false, error: code };
356
+ }
357
+ }
358
+
359
+ export default CxAgentClient;
package/element.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { CxHook } from "./index.js";
2
+
3
+ export { aiSuggestView } from "./view.js";
4
+ export type { AiSuggestView } from "./view.js";
5
+
6
+ /** 기본 태그 이름 */
7
+ export declare const TAG: "cx-ai-suggest";
8
+
9
+ export interface CxAiSuggestElement extends HTMLElement {
10
+ hook: CxHook | null;
11
+ inquiry: string;
12
+ }
13
+
14
+ /** 커스텀 엘리먼트 등록. 두 번 불러도 안전하고, 브라우저가 아니면 무동작(false). */
15
+ export declare function defineCxAiSuggest(tagName?: string): boolean;
16
+
17
+ declare global {
18
+ interface HTMLElementTagNameMap { "cx-ai-suggest": CxAiSuggestElement }
19
+ interface HTMLElementEventMap {
20
+ "cx-adopt": CustomEvent<{ text: string; answerId: number | string | null }>;
21
+ }
22
+ }
package/element.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * AI 답변 제안 패널 — 표준 커스텀 엘리먼트 `<cx-ai-suggest>`.
3
+ *
4
+ * React·Vue 가 아닌 곳(Astro, Angular, 순수 HTML)이 쓰는 형태다. 프레임워크마다
5
+ * 어댑터를 하나씩 더 짜는 대신, **브라우저 표준 하나**로 나머지를 덮는다.
6
+ *
7
+ * ```html
8
+ * <cx-ai-suggest></cx-ai-suggest>
9
+ * <script type="module">
10
+ * import { defineCxAiSuggest } from "@fcg-labs/cx-agent-hook/element";
11
+ * import { createCxHook } from "@fcg-labs/cx-agent-hook";
12
+ * defineCxAiSuggest();
13
+ * const el = document.querySelector("cx-ai-suggest");
14
+ * el.hook = createCxHook({ ... });
15
+ * el.inquiry = "체중계가 안 켜져요";
16
+ * el.addEventListener("cx-adopt", (e) => editor.value = e.detail.text);
17
+ * </script>
18
+ * ```
19
+ *
20
+ * **섀도 DOM 을 쓰지 않는다.** 쓰면 `styles.css` 가 안쪽에 닿지 않아 소비처가
21
+ * 스타일을 다시 넣어야 하고, 테마 변수(`--fcx-*`)도 끊긴다. 캡슐화보다 "한 벌의
22
+ * 스타일이 세 어댑터에 똑같이 적용되는 것"이 이 패널에는 중요하다.
23
+ *
24
+ * 클래스를 모듈 최상위에서 만들지 않는 이유: `HTMLElement` 가 없는 곳(SSR·Node)
25
+ * 에서 import 만 해도 죽는다. 등록 시점에 만든다.
26
+ */
27
+ import { aiSuggestView, CLS } from "./view.js";
28
+
29
+ export { aiSuggestView };
30
+
31
+ /** 기본 태그 이름 */
32
+ export const TAG = "cx-ai-suggest";
33
+
34
+ function el(tag, className, ...children) {
35
+ const node = document.createElement(tag);
36
+ if (className) node.className = className;
37
+ for (const c of children) {
38
+ if (c === null || c === false || c === undefined) continue;
39
+ node.append(c);
40
+ }
41
+ return node;
42
+ }
43
+
44
+ function createClass() {
45
+ return class CxAiSuggestElement extends HTMLElement {
46
+ #hook = null;
47
+ #inquiry = "";
48
+ #state = "idle";
49
+ #result = null;
50
+
51
+ /** createCxHook() 결과 */
52
+ get hook() { return this.#hook; }
53
+ set hook(value) { this.#hook = value; this.#render(); }
54
+
55
+ /** 대상 문의 원문 */
56
+ get inquiry() { return this.#inquiry; }
57
+ set inquiry(value) {
58
+ const next = value == null ? "" : String(value);
59
+ if (next === this.#inquiry) return;
60
+ this.#inquiry = next;
61
+ // 다른 문의로 옮기면 앞 문의의 제안이 남아 있으면 안 된다. 채택 기록도
62
+ // 같이 지운다 — 안 지우면 다음 문의를 발송할 때 엉뚱한 answer_id 로
63
+ // 후킹된다.
64
+ this.#state = "idle";
65
+ this.#result = null;
66
+ if (this.#hook) this.#hook.clearAdopted();
67
+ this.#render();
68
+ }
69
+
70
+ connectedCallback() { this.#render(); }
71
+
72
+ async #request() {
73
+ if (!this.#inquiry || this.#state === "loading") return;
74
+ this.#state = "loading";
75
+ this.#render();
76
+ this.#result = await this.#hook.requestAnswer(this.#inquiry);
77
+ this.#state = "done";
78
+ this.#render();
79
+ }
80
+
81
+ #adopt() {
82
+ const r = this.#result;
83
+ if (!r || !r.answered) return;
84
+ this.#hook.noteAdopted(r.answerId);
85
+ this.dispatchEvent(new CustomEvent("cx-adopt", {
86
+ detail: { text: r.answer, answerId: r.answerId },
87
+ bubbles: true,
88
+ }));
89
+ }
90
+
91
+ #render() {
92
+ this.replaceChildren();
93
+ if (!this.#hook || !this.#hook.enabled) return;
94
+ const v = aiSuggestView({
95
+ state: this.#state,
96
+ result: this.#result,
97
+ declineText: this.#hook.declineText,
98
+ messages: this.#hook.messages,
99
+ });
100
+
101
+ const button = el("button", CLS.button);
102
+ button.type = "button";
103
+ button.disabled = v.buttonDisabled;
104
+ button.textContent = v.buttonLabel;
105
+ button.addEventListener("click", () => this.#request());
106
+
107
+ const head = el("div", CLS.head, button);
108
+ if (v.showAdopt) {
109
+ const adopt = el("button", CLS.adopt);
110
+ adopt.type = "button";
111
+ adopt.textContent = v.adoptLabel;
112
+ adopt.addEventListener("click", () => this.#adopt());
113
+ head.append(adopt);
114
+ }
115
+
116
+ const root = el("div", CLS.root, head);
117
+ // 이 패널은 보통 "클릭하면 선택되는" 문의 행 안에 놓인다. 버튼을 누르려다
118
+ // 행 선택이 토글되면 제안이 초기화된다.
119
+ root.addEventListener("click", (e) => e.stopPropagation());
120
+
121
+ if (v.body.kind === "answer") {
122
+ const body = el("div", CLS.body);
123
+ const answer = el("div", CLS.answer);
124
+ answer.textContent = v.body.text;
125
+ body.append(answer);
126
+ if (v.body.evidence.length > 0) {
127
+ const ev = el("div", CLS.evidence);
128
+ ev.textContent = `${v.body.evidenceLabel}: ${v.body.evidence.join(" · ")}`;
129
+ body.append(ev);
130
+ }
131
+ root.append(body);
132
+ } else if (v.body.kind === "declined") {
133
+ const declined = el("div", CLS.declined);
134
+ declined.textContent = v.body.text;
135
+ root.append(declined);
136
+ }
137
+ this.append(root);
138
+ }
139
+ };
140
+ }
141
+
142
+ /** 커스텀 엘리먼트 등록. 두 번 불러도 안전하고, 브라우저가 아니면 무동작. */
143
+ export function defineCxAiSuggest(tagName = TAG) {
144
+ if (typeof window === "undefined" || !window.customElements) return false;
145
+ if (window.customElements.get(tagName)) return false;
146
+ window.customElements.define(tagName, createClass());
147
+ return true;
148
+ }
package/index.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @fcg-labs/cx-agent-hook 공개 표면.
3
+ *
4
+ * 전송 계층(`CxAgentClient`)의 타입은 `client.d.ts` 에 있고 여기서 다시
5
+ * 내보내지 않는다 — 소비처가 그 타입을 손에 쥘 이유가 없어야 한다.
6
+ * 필요한 조각(설정·결과 모양)만 골라 노출한다.
7
+ */
8
+ import type { AnswerResult, ApiTarget, CxAgentConfig } from "./client.js";
9
+
10
+ export type { AnswerResult, ApiTarget };
11
+
12
+ /** createCxHook 설정 — 주소·토큰·도메인 셋, 나머지는 선택 */
13
+ export type CxHookConfig = Partial<CxAgentConfig> & {
14
+ /** 화면 언어. 한 번 넘기면 이후 문구는 전부 훅이 낸다. 미지정이면 "en". */
15
+ locale?: string;
16
+ /** 특정 문구만 덮어쓰기 — 나머지는 로케일 값 그대로 */
17
+ messages?: Record<string, string>;
18
+ };
19
+
20
+ /** 지원 로케일 (BCP 47 접두와 맞춘 값) */
21
+ export type Locale = "ko" | "en" | "ja" | "zh-TW";
22
+ export declare const LOCALES: Locale[];
23
+ /** 로케일별 문구 한 벌 (제품 지식 — 소비처에 복제 금지) */
24
+ export declare const MESSAGES: Record<Locale, Record<string, string>>;
25
+ /** "ko-KR" 같은 표기를 지원 로케일로 정규화. 모르면 기본값("en"). */
26
+ export declare function normalizeLocale(locale: unknown): Locale;
27
+ /** 훅 없이 문구만 필요할 때 (테스트·미리보기). 보통은 hook.declineText 를 쓴다. */
28
+ export declare function declineText(reason: string, locale?: string): string;
29
+
30
+ export interface CxHook {
31
+ /** 주소·토큰·도메인이 다 있으면 true */
32
+ enabled: boolean;
33
+ /** setup 에서 정해진 화면 언어 (정규화된 값) */
34
+ locale: Locale;
35
+ /** 이 훅의 문구 한 벌 — UI 라벨 포함 */
36
+ messages: Record<string, string>;
37
+ /** 사유 코드 → 이 훅의 언어로 된 평문 */
38
+ declineText(reason: string): string;
39
+
40
+ requestAnswer(inquiry: string): Promise<AnswerResult>;
41
+
42
+ /** 제안을 에디터에 넣었다 (패널이 부른다) */
43
+ noteAdopted(answerId: number | string | null): void;
44
+ /** 채택 기록 폐기 — 문의 전환 시 */
45
+ clearAdopted(): void;
46
+ readonly adoptedAnswerId: number | string | null;
47
+
48
+ /** 발송됨 — 채택본이었을 때만 gold 쌍 후킹. 보낸 뒤 채택 기록을 비운다. */
49
+ answerSent(finalText: string, agent?: string): void;
50
+ /** 제안 품질 1~5 (발송 전 여러 번 가능 — 기록을 비우지 않는다) */
51
+ scored(score: number, agent?: string): void;
52
+ /** 제안을 고쳐 씀 — 초안↔최종본 델타 (기록을 비우지 않는다) */
53
+ edited(finalText: string, agent?: string): void;
54
+ /** 제안을 버림 + 이유. 마지막 판단이라 기록을 비운다. */
55
+ discarded(agent?: string, note?: string): void;
56
+
57
+ /** 문의+최종답변 쌍 적재 — 인입의 유일한 통로 (external_id 멱등) */
58
+ inquirySent(q: {
59
+ externalId: number | string;
60
+ inquiry: string;
61
+ reply?: string;
62
+ agent?: string;
63
+ meta?: Record<string, string>;
64
+ }): void;
65
+ }
66
+
67
+ /** 후킹 한 벌 생성 — 미설정(세 값 중 하나라도 빔)이면 전부 무동작 */
68
+ export declare function createCxHook(config?: CxHookConfig): CxHook;