@fcg-labs/cx-agent-hook 0.2.3 → 0.4.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 CHANGED
@@ -37,6 +37,8 @@ const PATHS = {
37
37
  ingress: (d) => `/v1/domains/${d}/ingress`,
38
38
  // 조사 접점 (E-8) — 트리거는 큐 적재만, 상태는 경량 메타만 (허브 계약)
39
39
  investigation: (d) => `/v1/domains/${d}/investigations`,
40
+ // 처리 액션 (actions/1) — 실행 요청·상태·재조정. 정본: docs/contracts/action-contract-v1.md
41
+ actions: (d) => `/v1/domains/${d}/actions`,
40
42
  },
41
43
  // platform 은 인그레스 API 미제공 (공장 큐레이션 파이프라인이 담당)
42
44
  };
@@ -113,7 +115,7 @@ export class CxAgentClient {
113
115
  */
114
116
  constructor({ baseUrl, token, domain, api = "platform", retries = 2,
115
117
  timeoutMs = 90000, onError, fetchImpl,
116
- answer, ingress, feedback } = {}) {
118
+ answer, ingress, feedback, source = "cms" } = {}) {
117
119
  if (!baseUrl || !token || !domain) {
118
120
  throw new Error("@fcg-labs/cx-agent-hook: baseUrl·token·domain 은 필수입니다");
119
121
  }
@@ -140,6 +142,9 @@ export class CxAgentClient {
140
142
  }
141
143
  this.retries = retries;
142
144
  this.timeoutMs = timeoutMs;
145
+ // 인그레스 body.source — 문의가 어느 채널에서 왔는지의 테넌트 선언.
146
+ // "cms" 하드코딩이던 것을 0.3.0 에서 파라미터화 (기본값은 하위호환).
147
+ this.source = String(source || "cms");
143
148
  this.onError = onError || (() => {});
144
149
  this.fetchImpl = fetchImpl || globalThis.fetch.bind(globalThis);
145
150
  }
@@ -485,7 +490,7 @@ export class CxAgentClient {
485
490
  return this._inqFail(new Error("inquiry 필수"), "invalid_inquiry");
486
491
  }
487
492
  const body = {
488
- source: "cms",
493
+ source: this.source,
489
494
  external_id: String(externalId),
490
495
  inquiry: String(inquiry),
491
496
  ...(reply ? { reply: String(reply) } : {}),
@@ -588,6 +593,123 @@ export class CxAgentClient {
588
593
  clearTimeout(timer);
589
594
  }
590
595
  }
596
+
597
+ // ── 처리 액션 (actions/1) ──────────────────────────────────────────────
598
+ //
599
+ // **재시도 0.** sendFeedback 의 5xx 재시도 루프를 여기 쓰면 비멱등 실행이 두 번
600
+ // 간다. 네트워크 오류면 호출자가 actionStatus(request_id) 로 원장을 묻고,
601
+ // 404(미도달 확정)일 때만 같은 request_id 로 1회 재전송한다 (client 밖 규칙).
602
+
603
+ /**
604
+ * 실행 요청. request_id 는 호출자(세션)가 발급·영속한 뒤 넘긴다 — 멱등 키.
605
+ * @returns {Promise<{ok:boolean, state:string, requestId:string, reasonCode:string,
606
+ * message:string, actorAttested:string, auditRef:string, result:object|null,
607
+ * deduplicated:boolean, error:string|null, httpStatus:number}>}
608
+ */
609
+ async requestAction({ requestId, offer, actorAssertion, actorClaimed, inquiryRef } = {}) {
610
+ if (this.api !== "hub" || !requestId || !offer || !offer.offer_id) {
611
+ return { ok: false, state: "", requestId: requestId || "", reasonCode: "",
612
+ message: "", actorAttested: "", auditRef: "", result: null,
613
+ deduplicated: false, error: "unsupported", httpStatus: 0 };
614
+ }
615
+ const body = {
616
+ request_id: String(requestId), offer_id: String(offer.offer_id),
617
+ event_id: String(offer.event_id || ""), action_key: String(offer.action_key || ""),
618
+ params_hash: String(offer.params_hash || ""), params: offer.params_bound || {},
619
+ actor_assertion: String(actorAssertion || ""), actor_claimed: String(actorClaimed || ""),
620
+ inquiry_ref: inquiryRef != null ? String(inquiryRef) : "",
621
+ confirmed_at: new Date().toISOString(),
622
+ };
623
+ try {
624
+ const { status, data } = await this._post(PATHS.hub.actions(this.domain), body);
625
+ const view = (d) => ({
626
+ state: d.state || "", requestId: d.request_id || String(requestId),
627
+ reasonCode: d.reason_code || "", message: d.message || "",
628
+ actorAttested: d.actor_attested || "", auditRef: d.audit_ref || "",
629
+ result: d.result ?? null, deduplicated: Boolean(d.deduplicated),
630
+ });
631
+ if (status === 200 || status === 202) {
632
+ return { ok: true, ...view(data), error: null, httpStatus: status };
633
+ }
634
+ // 409 offer_locked / request_conflict, 422 계약 위반, 503 잠김 — 그대로 전달
635
+ this.onError(new Error(`HTTP ${status}`), { op: "requestAction", code: data.error || "http_error" });
636
+ return { ok: false, ...view(data), error: data.error || `http_${status}`, httpStatus: status };
637
+ } catch (err) {
638
+ this.onError(err, { op: "requestAction", code: "network_error" });
639
+ return { ok: false, state: "", requestId: String(requestId), reasonCode: "", message: "",
640
+ actorAttested: "", auditRef: "", result: null, deduplicated: false,
641
+ error: "network_error", httpStatus: 0 };
642
+ }
643
+ }
644
+
645
+ /** 상태 조회 — 원장 뷰(result 없음). 404 = 허브에 기록 없음(미도달 확정). */
646
+ async actionStatus(requestId) {
647
+ if (this.api !== "hub" || !requestId) return { ok: false, state: "", error: "unsupported" };
648
+ return this._getJson(
649
+ PATHS.hub.actions(this.domain) + `/${encodeURIComponent(String(requestId))}`,
650
+ "actionStatus",
651
+ (status, data) => {
652
+ if (status === 200) return { ok: true, state: data.state || "", reasonCode: data.reason_code || "",
653
+ message: data.message || "", error: null };
654
+ if (status === 404) return { ok: true, state: "none", reasonCode: "", message: "", error: null };
655
+ return { ok: false, state: "", error: data.error || `http_${status}` };
656
+ },
657
+ );
658
+ }
659
+
660
+ /** 재조정 — lookup 전용 (재실행 아님). unknown 만 의미 있고 종단이면 그대로 온다. */
661
+ async actionReconcile(requestId) {
662
+ if (this.api !== "hub" || !requestId) return { ok: false, state: "", error: "unsupported" };
663
+ try {
664
+ const { status, data } = await this._post(
665
+ PATHS.hub.actions(this.domain) + `/${encodeURIComponent(String(requestId))}/reconcile`, {},
666
+ );
667
+ if (status === 200 || status === 202) {
668
+ return { ok: true, state: data.state || "", reasonCode: data.reason_code || "",
669
+ message: data.message || "", actorAttested: data.actor_attested || "",
670
+ auditRef: data.audit_ref || "", error: null };
671
+ }
672
+ return { ok: false, state: "", error: data.error || `http_${status}` };
673
+ } catch (err) {
674
+ this.onError(err, { op: "actionReconcile", code: "network_error" });
675
+ return { ok: false, state: "", error: "network_error" };
676
+ }
677
+ }
678
+
679
+ async _getJson(path, op, interpret) {
680
+ const controller = new AbortController();
681
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
682
+ try {
683
+ const res = await this.fetchImpl(this.baseUrl + path, {
684
+ headers: { "Authorization": `Bearer ${this.token}` }, signal: controller.signal,
685
+ });
686
+ const data = await res.json().catch(() => ({}));
687
+ return interpret(res.status, data);
688
+ } catch (err) {
689
+ this.onError(err, { op, code: "network_error" });
690
+ return { ok: false, state: "", error: "network_error" };
691
+ } finally {
692
+ clearTimeout(timer);
693
+ }
694
+ }
695
+ }
696
+
697
+ /** ULID (Crockford 26자) — request_id 발급. 의존성 0 (공장 event_ids 와 동형). */
698
+ export function newRequestId(now = Date.now()) {
699
+ const A = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
700
+ let ts = BigInt(now) & ((1n << 48n) - 1n);
701
+ const rnd = new Uint8Array(10);
702
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) crypto.getRandomValues(rnd);
703
+ else for (let i = 0; i < 10; i++) rnd[i] = Math.floor(Math.random() * 256);
704
+ // 상위 48bit 시각 → 10자, 하위 80bit 무작위 → 16자 (공장 event_ids 와 동일 배치)
705
+ const out = [];
706
+ let t = ts;
707
+ for (let i = 0; i < 10; i++) { out.unshift(A[Number(t & 31n)]); t >>= 5n; }
708
+ let r = 0n;
709
+ for (const b of rnd) r = (r << 8n) | BigInt(b);
710
+ const tail = [];
711
+ for (let i = 0; i < 16; i++) { tail.unshift(A[Number(r & 31n)]); r >>= 5n; }
712
+ return out.join("") + tail.join("");
591
713
  }
592
714
 
593
715
  export default CxAgentClient;
package/compat.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * createCxHook — 0.2.x 호환 표면 (내부용 모듈, exports 맵에 없다).
3
+ *
4
+ * 0.3.0 에서 구현이 CxAgent + InquirySession 으로 옮겨졌다. 이 파일은 그
5
+ * 위에 0.2.x 문서화 표면을 **비영속 기본 세션** 하나로 재구성한다 — 기존
6
+ * 소비처(와 시험 94건)는 글자 하나 안 바꾸고 그대로 동작한다.
7
+ *
8
+ * 0.2.x 와 0.3.0 의 관계:
9
+ * - hook.noteAdopted/answerSent/... = 기본 세션의 동명 메서드
10
+ * - hook.composeDraft = session.compose + 호출별 UI sink
11
+ * - 문의별 상태·영속이 필요하면 `createCxAgent` + `agent.session()` 으로
12
+ * 올라간다 (MIGRATION.md)
13
+ */
14
+ import { createCxAgent, notConfiguredResult, textOf } from "./agent.js";
15
+
16
+ /** 후킹 한 벌을 만든다 — 미설정이면 전부 무동작. (0.2.x 표면 그대로) */
17
+ export function createCxHook(config = {}) {
18
+ const agent = createCxAgent({
19
+ ...config,
20
+ // 0.2.x 훅은 문의별 세션 개념이 없다 — 영속은 세션 표면의 능력이다.
21
+ storage: { enabled: false, ...(config.storage || {}) },
22
+ });
23
+ // 훅 전체가 공유하는 단일 채택 슬롯 = 비영속 기본 세션 하나
24
+ const session = agent.session();
25
+
26
+ return {
27
+ enabled: agent.enabled,
28
+ ready: agent.ready,
29
+ get flags() { return agent.flags; },
30
+ locale: agent.locale,
31
+ messages: agent.messages,
32
+ declineText(reason) { return agent.declineText(reason); },
33
+
34
+ requestAnswer(inquiry, context) {
35
+ return agent.requestAnswer(inquiry, context);
36
+ },
37
+ requestInvestigation(externalId, identity) {
38
+ return agent.requestInvestigation(externalId, identity);
39
+ },
40
+ investigationStatus(externalId) {
41
+ return agent.investigationStatus(externalId);
42
+ },
43
+
44
+ /** 초안 직주입 스트리밍 — 세션 compose 에 호출별 UI sink 를 붙인다.
45
+ * 의미론(자동 채택·교체 기각·실패 원복·abort 무간섭)은 session.js 소유. */
46
+ composeDraft({ inquiry, context, getDraft, setDraft, setStatus,
47
+ confirmOverwrite, customerName } = {}) {
48
+ if (!agent.enabled || !inquiry || typeof setDraft !== "function") {
49
+ if (setStatus) setStatus(textOf(agent.messages, "not_configured"));
50
+ return { promise: Promise.resolve(notConfiguredResult()), abort: () => {} };
51
+ }
52
+ session.attachUi({ getDraft, setDraft, setStatus });
53
+ return session.compose({ inquiry, context, customerName, confirmOverwrite });
54
+ },
55
+
56
+ noteAdopted(answerId) { session.noteAdopted(answerId); },
57
+ clearAdopted() { session.clearAdopted(); },
58
+ get adoptedAnswerId() { return session.adoptedAnswerId; },
59
+
60
+ answerSent(finalText, agentId) { session.answerSent(finalText, agentId); },
61
+ scored(score, agentId) { session.scored(score, agentId); },
62
+ edited(finalText, agentId) { session.edited(finalText, agentId); },
63
+ discarded(agentId, note) { session.discarded(agentId, note); },
64
+
65
+ inquirySent(payload) { agent.inquirySent(payload); },
66
+ };
67
+ }
package/element.js CHANGED
@@ -24,21 +24,30 @@
24
24
  * 클래스를 모듈 최상위에서 만들지 않는 이유: `HTMLElement` 가 없는 곳(SSR·Node)
25
25
  * 에서 import 만 해도 죽는다. 등록 시점에 만든다.
26
26
  */
27
- import { aiSuggestView, CLS } from "./view.js";
27
+ import { aiSuggestTree, aiSuggestView, CLS } from "./view.js";
28
28
 
29
29
  export { aiSuggestView };
30
30
 
31
31
  /** 기본 태그 이름 */
32
32
  export const TAG = "cx-ai-suggest";
33
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);
34
+ /** 트리 노드 DOM — 구조는 view.aiSuggestTree 가 소유한다. */
35
+ function renderNode(node, actions) {
36
+ const dom = document.createElement(node.tag);
37
+ if (node.cls) dom.className = node.cls;
38
+ if (node.tag === "button") {
39
+ dom.type = node.type || "button";
40
+ dom.disabled = Boolean(node.disabled);
40
41
  }
41
- return node;
42
+ if (node.action && actions[node.action]) {
43
+ dom.addEventListener("click", actions[node.action]);
44
+ }
45
+ if (node.children) {
46
+ for (const c of node.children) dom.append(renderNode(c, actions));
47
+ } else if (node.text !== undefined) {
48
+ dom.textContent = node.text;
49
+ }
50
+ return dom;
42
51
  }
43
52
 
44
53
  function createClass() {
@@ -100,49 +109,22 @@ function createClass() {
100
109
  #render() {
101
110
  this.replaceChildren();
102
111
  if (!this.#hook || !this.#hook.enabled) return;
103
- const v = aiSuggestView({
112
+ const tree = aiSuggestTree({
104
113
  state: this.#state,
105
114
  result: this.#result,
106
115
  declineText: this.#hook.declineText,
107
116
  messages: this.#hook.messages,
108
117
  });
109
-
110
- const button = el("button", CLS.button);
111
- button.type = "button";
112
- button.disabled = v.buttonDisabled;
113
- button.textContent = v.buttonLabel;
114
- button.addEventListener("click", () => this.#request());
115
-
116
- const head = el("div", CLS.head, button);
117
- if (v.showAdopt) {
118
- const adopt = el("button", CLS.adopt);
119
- adopt.type = "button";
120
- adopt.textContent = v.adoptLabel;
121
- adopt.addEventListener("click", () => this.#adopt());
122
- head.append(adopt);
123
- }
124
-
125
- const root = el("div", CLS.root, head);
118
+ const actions = {
119
+ request: () => this.#request(),
120
+ adopt: () => this.#adopt(),
121
+ };
122
+ const root = document.createElement("div");
123
+ root.className = CLS.root;
126
124
  // 이 패널은 보통 "클릭하면 선택되는" 문의 행 안에 놓인다. 버튼을 누르려다
127
125
  // 행 선택이 토글되면 제안이 초기화된다.
128
126
  root.addEventListener("click", (e) => e.stopPropagation());
129
-
130
- if (v.body.kind === "answer") {
131
- const body = el("div", CLS.body);
132
- const answer = el("div", CLS.answer);
133
- answer.textContent = v.body.text;
134
- body.append(answer);
135
- if (v.body.evidence.length > 0) {
136
- const ev = el("div", CLS.evidence);
137
- ev.textContent = `${v.body.evidenceLabel}: ${v.body.evidence.join(" · ")}`;
138
- body.append(ev);
139
- }
140
- root.append(body);
141
- } else if (v.body.kind === "declined") {
142
- const declined = el("div", CLS.declined);
143
- declined.textContent = v.body.text;
144
- root.append(declined);
145
- }
127
+ for (const node of tree.children) root.append(renderNode(node, actions));
146
128
  this.append(root);
147
129
  }
148
130
  };