@n2world/orchestrator 1.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.
Files changed (154) hide show
  1. package/dist/agent-os-rd.d.ts +100 -0
  2. package/dist/agent-os-rd.js +258 -0
  3. package/dist/audit-store.d.ts +14 -0
  4. package/dist/audit-store.js +107 -0
  5. package/dist/beta-runner.d.ts +95 -0
  6. package/dist/beta-runner.js +251 -0
  7. package/dist/beta.d.ts +102 -0
  8. package/dist/beta.js +180 -0
  9. package/dist/browser-agent.d.ts +90 -0
  10. package/dist/browser-agent.js +223 -0
  11. package/dist/channel-gateway.d.ts +74 -0
  12. package/dist/channel-gateway.js +270 -0
  13. package/dist/channels.d.ts +120 -0
  14. package/dist/channels.js +432 -0
  15. package/dist/chat-store.d.ts +29 -0
  16. package/dist/chat-store.js +120 -0
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +607 -0
  19. package/dist/command-screen.d.ts +12 -0
  20. package/dist/command-screen.js +44 -0
  21. package/dist/commit-gate.d.ts +98 -0
  22. package/dist/commit-gate.js +258 -0
  23. package/dist/companion-api.d.ts +37 -0
  24. package/dist/companion-api.js +101 -0
  25. package/dist/conversation-graph.d.ts +39 -0
  26. package/dist/conversation-graph.js +92 -0
  27. package/dist/cost-estimator.d.ts +27 -0
  28. package/dist/cost-estimator.js +42 -0
  29. package/dist/cron-runner.d.ts +31 -0
  30. package/dist/cron-runner.js +46 -0
  31. package/dist/dashboard/chat.html +326 -0
  32. package/dist/dashboard/dental.html +58 -0
  33. package/dist/dashboard/freebie.png +0 -0
  34. package/dist/dashboard/icon-192.png +0 -0
  35. package/dist/dashboard/index.html +892 -0
  36. package/dist/dashboard/manifest.json +15 -0
  37. package/dist/dashboard/service-worker.js +28 -0
  38. package/dist/dashboard-server.d.ts +37 -0
  39. package/dist/dashboard-server.js +457 -0
  40. package/dist/dental-intake-service.d.ts +37 -0
  41. package/dist/dental-intake-service.js +61 -0
  42. package/dist/dental-metrics.d.ts +25 -0
  43. package/dist/dental-metrics.js +37 -0
  44. package/dist/docking.d.ts +36 -0
  45. package/dist/docking.js +73 -0
  46. package/dist/finance-mcts-candidate.d.ts +37 -0
  47. package/dist/finance-mcts-candidate.js +106 -0
  48. package/dist/finance-regulation-kr.d.ts +33 -0
  49. package/dist/finance-regulation-kr.js +104 -0
  50. package/dist/finance-workflow.d.ts +135 -0
  51. package/dist/finance-workflow.js +242 -0
  52. package/dist/gateway.d.ts +18 -0
  53. package/dist/gateway.js +123 -0
  54. package/dist/governance.d.ts +39 -0
  55. package/dist/governance.js +48 -0
  56. package/dist/governed-executor.d.ts +31 -0
  57. package/dist/governed-executor.js +63 -0
  58. package/dist/governed-llm.d.ts +41 -0
  59. package/dist/governed-llm.js +83 -0
  60. package/dist/gpu-bridge.d.ts +16 -0
  61. package/dist/gpu-bridge.js +53 -0
  62. package/dist/health.d.ts +47 -0
  63. package/dist/health.js +66 -0
  64. package/dist/identity-link.d.ts +32 -0
  65. package/dist/identity-link.js +98 -0
  66. package/dist/index.d.ts +184 -0
  67. package/dist/index.js +417 -0
  68. package/dist/integrations/emr-adapter.d.ts +41 -0
  69. package/dist/integrations/emr-adapter.js +63 -0
  70. package/dist/kakao-oauth.d.ts +16 -0
  71. package/dist/kakao-oauth.js +87 -0
  72. package/dist/knowledge-graph.d.ts +53 -0
  73. package/dist/knowledge-graph.js +156 -0
  74. package/dist/llm.d.ts +65 -0
  75. package/dist/llm.js +357 -0
  76. package/dist/mcp-client-guard.d.ts +32 -0
  77. package/dist/mcp-client-guard.js +179 -0
  78. package/dist/mcp-macaroon.d.ts +75 -0
  79. package/dist/mcp-macaroon.js +161 -0
  80. package/dist/mcts-kernel-bridge.d.ts +36 -0
  81. package/dist/mcts-kernel-bridge.js +99 -0
  82. package/dist/mcts-prior.d.ts +79 -0
  83. package/dist/mcts-prior.js +170 -0
  84. package/dist/model-router.d.ts +51 -0
  85. package/dist/model-router.js +75 -0
  86. package/dist/multi-axis-lift.d.ts +43 -0
  87. package/dist/multi-axis-lift.js +141 -0
  88. package/dist/net-guard.d.ts +39 -0
  89. package/dist/net-guard.js +141 -0
  90. package/dist/onboarding.d.ts +38 -0
  91. package/dist/onboarding.js +94 -0
  92. package/dist/oracle-anchored-search.d.ts +25 -0
  93. package/dist/oracle-anchored-search.js +50 -0
  94. package/dist/oracle.d.ts +22 -0
  95. package/dist/oracle.js +116 -0
  96. package/dist/p6-governance.d.ts +150 -0
  97. package/dist/p6-governance.js +252 -0
  98. package/dist/pairing.d.ts +22 -0
  99. package/dist/pairing.js +81 -0
  100. package/dist/personalization.d.ts +35 -0
  101. package/dist/personalization.js +73 -0
  102. package/dist/pglite-hnsw-bridge.d.ts +118 -0
  103. package/dist/pglite-hnsw-bridge.js +311 -0
  104. package/dist/pglite-store.d.ts +59 -0
  105. package/dist/pglite-store.js +180 -0
  106. package/dist/playbook.d.ts +79 -0
  107. package/dist/playbook.js +83 -0
  108. package/dist/playbooks/dental-intake.d.ts +20 -0
  109. package/dist/playbooks/dental-intake.js +112 -0
  110. package/dist/predictive-agent.d.ts +157 -0
  111. package/dist/predictive-agent.js +535 -0
  112. package/dist/prompt-optimizer.d.ts +18 -0
  113. package/dist/prompt-optimizer.js +104 -0
  114. package/dist/rate-limiter.d.ts +25 -0
  115. package/dist/rate-limiter.js +75 -0
  116. package/dist/safety-anneal.d.ts +83 -0
  117. package/dist/safety-anneal.js +153 -0
  118. package/dist/sandbox-controller.d.ts +12 -0
  119. package/dist/sandbox-controller.js +95 -0
  120. package/dist/satisfaction-metrics.d.ts +26 -0
  121. package/dist/satisfaction-metrics.js +61 -0
  122. package/dist/sensor-bridge.d.ts +53 -0
  123. package/dist/sensor-bridge.js +133 -0
  124. package/dist/session-repair.d.ts +27 -0
  125. package/dist/session-repair.js +66 -0
  126. package/dist/slack-finance-intake.d.ts +42 -0
  127. package/dist/slack-finance-intake.js +122 -0
  128. package/dist/symbolic-dynamics.d.ts +113 -0
  129. package/dist/symbolic-dynamics.js +420 -0
  130. package/dist/telemetry.d.ts +19 -0
  131. package/dist/telemetry.js +68 -0
  132. package/dist/text-embedding.d.ts +6 -0
  133. package/dist/text-embedding.js +42 -0
  134. package/dist/tier-classifier.d.ts +20 -0
  135. package/dist/tier-classifier.js +58 -0
  136. package/dist/tier-guard.d.ts +36 -0
  137. package/dist/tier-guard.js +56 -0
  138. package/dist/tui.d.ts +9 -0
  139. package/dist/tui.js +214 -0
  140. package/dist/update-security.d.ts +31 -0
  141. package/dist/update-security.js +112 -0
  142. package/dist/v-calibration.d.ts +16 -0
  143. package/dist/v-calibration.js +42 -0
  144. package/dist/value-calibration.d.ts +41 -0
  145. package/dist/value-calibration.js +133 -0
  146. package/dist/value-head.d.ts +20 -0
  147. package/dist/value-head.js +91 -0
  148. package/dist/wal-buffer.d.ts +23 -0
  149. package/dist/wal-buffer.js +144 -0
  150. package/dist/wiki-synthesizer.d.ts +80 -0
  151. package/dist/wiki-synthesizer.js +0 -0
  152. package/dist/worker-agent.d.ts +10 -0
  153. package/dist/worker-agent.js +19 -0
  154. package/package.json +65 -0
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ // ============================================================================
3
+ // Stage D / D3: 비용 견적·승인 — 실행 전 토큰·비용을 추정하고 승인 게이트를 통과시킨다.
4
+ // 단가는 변동하므로 설정값(개략치)이며, 실제 청구는 제공자 콘솔 기준이다(정직 고지).
5
+ // ============================================================================
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.DEFAULT_PRICING = void 0;
8
+ exports.estimateCost = estimateCost;
9
+ exports.requireCostApproval = requireCostApproval;
10
+ /** 개략 단가표(변동 가능 — 설정으로 덮어쓸 것). 정확한 청구는 제공자 콘솔 기준. */
11
+ exports.DEFAULT_PRICING = {
12
+ 'claude-opus-4-8': { inputPer1M: 15, outputPer1M: 75 },
13
+ 'claude-sonnet-4-6': { inputPer1M: 3, outputPer1M: 15 },
14
+ 'claude-haiku-4-5-20251001': { inputPer1M: 1, outputPer1M: 5 },
15
+ 'gemini-flash-latest': { inputPer1M: 0.075, outputPer1M: 0.3 },
16
+ };
17
+ function estimateCost(model, tokensIn, tokensOut, pricing = exports.DEFAULT_PRICING) {
18
+ const p = pricing[model] ?? { inputPer1M: 1, outputPer1M: 3 };
19
+ const costUsd = (tokensIn / 1e6) * p.inputPer1M + (tokensOut / 1e6) * p.outputPer1M;
20
+ return {
21
+ model,
22
+ tokensIn,
23
+ tokensOut,
24
+ totalTokens: tokensIn + tokensOut,
25
+ costUsd: +costUsd.toFixed(6),
26
+ approximate: true,
27
+ };
28
+ }
29
+ /**
30
+ * 비용 승인 게이트. 승인자 없거나 거부면 실행 금지(보수적 — 예산 보호).
31
+ * 임계($) 이하 비용은 자동 승인하도록 옵션 제공.
32
+ */
33
+ async function requireCostApproval(est, opts = {}) {
34
+ if (opts.autoApproveBelowUsd !== undefined && est.costUsd <= opts.autoApproveBelowUsd) {
35
+ return { approved: true, reason: `auto-approved (<= $${opts.autoApproveBelowUsd})` };
36
+ }
37
+ if (!opts.approve) {
38
+ return { approved: false, reason: 'no approver (비용 보호: 기본 거부)' };
39
+ }
40
+ const ok = await opts.approve(est);
41
+ return { approved: ok, reason: ok ? 'approved' : 'rejected' };
42
+ }
@@ -0,0 +1,31 @@
1
+ export interface CronJob {
2
+ id: string;
3
+ /** 멱등 단위(예: 'weekly-report'). 동일 (jobId:occurrence)는 1회만 발송. */
4
+ occurrence: string;
5
+ channel: string;
6
+ payload: string;
7
+ /** IANA 타임존 라벨(예: 'Asia/Seoul') — 보존·표기. */
8
+ tz?: string;
9
+ }
10
+ export type DeliverFn = (job: CronJob) => Promise<{
11
+ ok: boolean;
12
+ reason?: string;
13
+ }>;
14
+ export type FailureAlert = (job: CronJob, reason: string) => void;
15
+ export interface DeliveryResult {
16
+ jobId: string;
17
+ occurrence: string;
18
+ status: 'delivered' | 'duplicate-skipped' | 'failed';
19
+ reason?: string;
20
+ }
21
+ /**
22
+ * 멱등 Cron 배달기 — (jobId:occurrence) 키로 1회 발송 보장. 실패 시 데드맨 알림.
23
+ */
24
+ export declare class CronDeliveryRunner {
25
+ private deliver;
26
+ private onFailure;
27
+ private done;
28
+ constructor(deliver: DeliverFn, onFailure?: FailureAlert);
29
+ private key;
30
+ run(job: CronJob): Promise<DeliveryResult>;
31
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ // ============================================================================
3
+ // v2.5.2 Phase 1 — 앰비언트 Cron Delivery 신뢰성 (멱등·중복방지·실패알림)
4
+ // ----------------------------------------------------------------------------
5
+ // 계획서 v2.5.2 §3.1: 자연어 크론 자가배달은 앰비언트 에이전트다 → 누락/중복/타임존이
6
+ // 3대 난제. 본 모듈은 (a) 멱등성 키로 중복 발송 방지, (b) 실패 시 데드맨 알림,
7
+ // (c) 타임존 라벨 보존을 제공한다.
8
+ //
9
+ // 정직 고지(제1계명): 발송 성공은 sink 결과로만 판정(가짜 성공 금지). 중복은 실제 차단.
10
+ // ============================================================================
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.CronDeliveryRunner = void 0;
13
+ /**
14
+ * 멱등 Cron 배달기 — (jobId:occurrence) 키로 1회 발송 보장. 실패 시 데드맨 알림.
15
+ */
16
+ class CronDeliveryRunner {
17
+ deliver;
18
+ onFailure;
19
+ done = new Set(); // 발송 완료된 멱등 키
20
+ constructor(deliver, onFailure = () => { }) {
21
+ this.deliver = deliver;
22
+ this.onFailure = onFailure;
23
+ }
24
+ key(j) { return `${j.id}:${j.occurrence}`; }
25
+ async run(job) {
26
+ const k = this.key(job);
27
+ if (this.done.has(k)) {
28
+ return { jobId: job.id, occurrence: job.occurrence, status: 'duplicate-skipped' };
29
+ }
30
+ let res;
31
+ try {
32
+ res = await this.deliver(job);
33
+ }
34
+ catch (e) {
35
+ res = { ok: false, reason: e?.message || String(e) };
36
+ }
37
+ if (!res.ok) {
38
+ // 데드맨 스위치: 실패는 조용히 죽지 않고 반드시 알림(누락 가시화).
39
+ this.onFailure(job, res.reason || 'unknown');
40
+ return { jobId: job.id, occurrence: job.occurrence, status: 'failed', reason: res.reason };
41
+ }
42
+ this.done.add(k); // 성공분만 멱등 기록 → 재시도 시 중복 차단
43
+ return { jobId: job.id, occurrence: job.occurrence, status: 'delivered' };
44
+ }
45
+ }
46
+ exports.CronDeliveryRunner = CronDeliveryRunner;
@@ -0,0 +1,326 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>n2world 워크스페이스</title>
7
+ <link rel="manifest" href="/manifest.json" />
8
+ <style>
9
+ :root {
10
+ --bg: #f4f7fa; --card: #ffffff; --text: #2b3a4a; --muted: #6b7c90;
11
+ --blue: #42a5f5; --green: #66bb6a; --orange: #ffb74d; --purple: #ab8ce0; --red: #ef5350;
12
+ --line: #e6edf3;
13
+ }
14
+ * { box-sizing: border-box; }
15
+ html, body { height: 100%; margin: 0; }
16
+ body {
17
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Malgun Gothic', sans-serif;
18
+ background: var(--bg); color: var(--text);
19
+ display: grid; grid-template-columns: var(--leftw, 36%) 6px 1fr; grid-template-rows: 100vh;
20
+ }
21
+ .left { display: grid; grid-template-rows: var(--toph, 1fr) 6px 1fr; min-width: 0; min-height: 0; }
22
+ /* 분할바(좌우=vert, 상하=horiz) */
23
+ .gutter { background: var(--line); position: relative; }
24
+ .gutter.vert { cursor: col-resize; }
25
+ .gutter.vert::after { content: ''; position: absolute; inset: 0 -3px; } /* 잡기 쉬운 히트영역 */
26
+ .gutter.horiz { cursor: row-resize; }
27
+ .gutter.horiz::after { content: ''; position: absolute; inset: -3px 0; }
28
+ .gutter:hover, .gutter.dragging { background: var(--blue); }
29
+ body.resizing { user-select: none; }
30
+ body.resizing-col { cursor: col-resize; }
31
+ body.resizing-row { cursor: row-resize; }
32
+ .pane { display: flex; flex-direction: column; min-height: 0; }
33
+ .pane.top { border-bottom: 1px solid var(--line); }
34
+ .pane-head {
35
+ flex: 0 0 auto; padding: 8px 12px; background: var(--card); border-bottom: 1px solid var(--line);
36
+ font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 8px;
37
+ }
38
+ .pane-head .sub { font-weight: 400; color: var(--muted); font-size: 11px; }
39
+ .pane-head .spacer { flex: 1; }
40
+ .pane-head button { border: 1px solid var(--line); background: #fff; color: var(--muted); border-radius: 6px; font-size: 11px; padding: 3px 8px; cursor: pointer; }
41
+ .pane-head button:hover { background: var(--bg); color: var(--blue); }
42
+ #graph-wrap { flex: 1; min-height: 0; position: relative; overflow: hidden; background: #fbfdff; }
43
+ #graph { width: 100%; height: 100%; display: block; cursor: grab; }
44
+ .legend { position: absolute; left: 8px; bottom: 8px; font-size: 10px; color: var(--muted); background: rgba(255,255,255,.8); border-radius: 6px; padding: 4px 6px; line-height: 1.6; }
45
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 3px; vertical-align: middle; }
46
+ #detail { flex: 1; min-height: 0; overflow-y: auto; padding: 12px; font-size: 13px; }
47
+ #detail .empty { color: var(--muted); }
48
+ #detail h4 { margin: 0 0 6px; font-size: 13px; }
49
+ #detail .body { white-space: pre-wrap; word-break: break-word; background: var(--bg); border-radius: 8px; padding: 8px 10px; margin: 6px 0; line-height: 1.5; }
50
+ #detail .chip { display: inline-block; background: #fff3e0; color: #e69b2e; border-radius: 10px; padding: 2px 8px; font-size: 11px; margin: 2px 3px 2px 0; cursor: pointer; }
51
+ #detail .rel { padding: 6px 8px; border-left: 3px solid var(--line); margin: 4px 0; cursor: pointer; color: var(--text); }
52
+ #detail .rel:hover { border-left-color: var(--blue); background: var(--bg); }
53
+ #detail .rel .who { font-size: 11px; color: var(--muted); }
54
+ /* 우측 채팅 */
55
+ .chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
56
+ .chat-head { flex: 0 0 auto; padding: 12px 16px; background: var(--card); border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 10px; }
57
+ .chat-head .avatar { width: 34px; height: 34px; border-radius: 50%; background: #e3f2fd; display: grid; place-items: center; font-size: 18px; }
58
+ .chat-head .title { font-weight: 700; } .chat-head .sub { font-size: 11px; color: var(--muted); } .chat-head .spacer { flex: 1; }
59
+ #log { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
60
+ .row { display: flex; } .row.user { justify-content: flex-end; }
61
+ .bubble { max-width: 80%; padding: 9px 13px; border-radius: 14px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; font-size: 14px; box-shadow: 0 1px 2px rgba(0,0,0,.05); }
62
+ .user .bubble { background: var(--blue); color: #fff; border-bottom-right-radius: 4px; }
63
+ .agent .bubble { background: var(--card); color: var(--text); border-bottom-left-radius: 4px; }
64
+ .agent .bubble.empty { color: var(--muted); font-style: italic; }
65
+ .agent .bubble.error { background: #fdecea; color: var(--red); }
66
+ .bubble.hl { outline: 2px solid var(--orange); }
67
+ .bubble b { font-weight: 700; }
68
+ .meta { font-size: 11px; color: var(--muted); margin: 0 4px 2px; } .row.agent .meta { align-self: flex-start; } .row.user .meta { align-self: flex-end; }
69
+ .typing span { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--muted); animation: blink 1.2s infinite both; margin:0 1px; }
70
+ .typing span:nth-child(2){animation-delay:.2s} .typing span:nth-child(3){animation-delay:.4s}
71
+ @keyframes blink {0%,80%,100%{opacity:.2}40%{opacity:1}}
72
+ .chat-foot { flex: 0 0 auto; padding: 10px 12px; background: var(--card); border-top: 1px solid var(--line); display: flex; gap: 8px; align-items: flex-end; }
73
+ #input { flex: 1; resize: none; border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; font: inherit; font-size: 14px; max-height: 120px; outline: none; }
74
+ #input:focus { border-color: var(--blue); }
75
+ #send { border: none; background: var(--blue); color: #fff; border-radius: 12px; padding: 0 18px; height: 42px; font-weight: 700; cursor: pointer; }
76
+ #send:disabled { opacity: .5; }
77
+ text.glabel { font-size: 10px; fill: var(--text); pointer-events: none; user-select: none; }
78
+ </style>
79
+ </head>
80
+ <body>
81
+ <div class="left">
82
+ <div class="pane top">
83
+ <div class="pane-head">🧠 사고 과정 그래프 <span class="sub" id="graph-sub">Graphify · 대화 의미망</span>
84
+ <span class="spacer"></span><button id="g-reload">↻</button></div>
85
+ <div id="graph-wrap">
86
+ <svg id="graph"></svg>
87
+ <div class="legend">
88
+ <span class="dot" style="background:var(--blue)"></span>나(질문)
89
+ <span class="dot" style="background:var(--green)"></span>Ethan(응답)
90
+ <span class="dot" style="background:var(--orange)"></span>개념(키워드)
91
+ </div>
92
+ </div>
93
+ </div>
94
+ <div class="gutter horiz" id="gutter-h" title="드래그하여 위·아래 높이 조절"></div>
95
+ <div class="pane">
96
+ <div class="pane-head">🔎 선택 상세 <span class="sub">노드를 클릭하면 관련 대화·키워드 표시</span></div>
97
+ <div id="detail"><div class="empty">위 그래프에서 노드(발화 또는 개념)를 클릭하세요.</div></div>
98
+ </div>
99
+ </div>
100
+
101
+ <div class="gutter vert" id="gutter" title="드래그하여 좌우 너비 조절"></div>
102
+
103
+ <div class="chat">
104
+ <div class="chat-head">
105
+ <div class="avatar">🤖</div>
106
+ <div><div class="title">n2world 대화</div><div class="sub" id="chat-sub">불러오는 중…</div></div>
107
+ <span class="spacer"></span><button id="c-reload" style="border:1px solid var(--line);background:#fff;color:var(--muted);border-radius:8px;padding:6px 10px;font-size:12px;cursor:pointer">↻ 새로고침</button>
108
+ </div>
109
+ <div id="log"></div>
110
+ <div class="chat-foot">
111
+ <textarea id="input" rows="1" placeholder="메시지를 입력하세요… (Enter 전송, Shift+Enter 줄바꿈)"></textarea>
112
+ <button id="send">전송</button>
113
+ </div>
114
+ </div>
115
+
116
+ <script>
117
+ const $ = (id) => document.getElementById(id);
118
+ const elLog = $('log'), elInput = $('input'), elSend = $('send'), elDetail = $('detail');
119
+ let turnsCache = [];
120
+
121
+ function fmt(t) { return (t||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\*\*(.+?)\*\*/g,'<b>$1</b>'); }
122
+
123
+ // ─────────── 우측 채팅 ───────────
124
+ function addBubble(role, content, opts = {}) {
125
+ const row = document.createElement('div');
126
+ row.className = 'row ' + (role === 'user' ? 'user' : 'agent');
127
+ const wrap = document.createElement('div');
128
+ wrap.style.cssText = 'display:flex;flex-direction:column;max-width:100%';
129
+ const meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = role==='user'?'나':'Ethan';
130
+ const b = document.createElement('div');
131
+ b.className = 'bubble' + (opts.empty?' empty':'') + (opts.error?' error':'');
132
+ if (opts.typing) b.innerHTML = '<span class="typing"><span></span><span></span><span></span></span>';
133
+ else if (opts.empty) b.textContent = '(이 턴은 응답이 없었습니다)';
134
+ else b.innerHTML = fmt(content);
135
+ if (opts.idx != null) b.dataset.idx = opts.idx;
136
+ wrap.appendChild(meta); wrap.appendChild(b); row.appendChild(wrap); elLog.appendChild(row);
137
+ elLog.scrollTop = elLog.scrollHeight;
138
+ return b;
139
+ }
140
+
141
+ async function loadHistory() {
142
+ elLog.innerHTML = '';
143
+ try {
144
+ const d = await (await fetch('/api/chat-history')).json();
145
+ turnsCache = d.turns || [];
146
+ if (!turnsCache.length) { elLog.innerHTML = '<div style="color:var(--muted);text-align:center;padding:12px">대화가 없습니다. 아래에 입력해 시작하세요.</div>'; }
147
+ else turnsCache.forEach((t, i) => addBubble(t.role, t.content, { empty: t.role==='assistant' && !(t.content||'').trim(), idx: i }));
148
+ $('chat-sub').textContent = `${d.count}턴 · ${d.path}`;
149
+ } catch { $('chat-sub').textContent = '서버 연결 확인'; }
150
+ }
151
+
152
+ let busy = false;
153
+ async function send() {
154
+ const text = elInput.value.trim(); if (!text || busy) return;
155
+ busy = true; elSend.disabled = true;
156
+ addBubble('user', text); elInput.value=''; elInput.style.height='auto';
157
+ const typing = addBubble('agent','',{typing:true});
158
+ try {
159
+ const d = await (await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json; charset=utf-8'},body:JSON.stringify({command:text})})).json();
160
+ typing.parentElement.parentElement.remove();
161
+ if (d.error) addBubble('agent','오류: '+d.error,{error:true});
162
+ else { const out=(d.stdout&&d.stdout.trim())?d.stdout:''; out?addBubble('agent',out):addBubble('agent','',{empty:true}); }
163
+ } catch { typing.parentElement.parentElement.remove(); addBubble('agent','네트워크 오류',{error:true}); }
164
+ busy=false; elSend.disabled=false; elInput.focus();
165
+ loadHistory(); loadGraph(); // 대화 후 그래프 갱신
166
+ }
167
+ elSend.onclick = send;
168
+ elInput.addEventListener('keydown', e=>{ if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();} });
169
+ elInput.addEventListener('input', ()=>{ elInput.style.height='auto'; elInput.style.height=Math.min(elInput.scrollHeight,120)+'px'; });
170
+ $('c-reload').onclick = ()=>{ loadHistory(); loadGraph(); };
171
+
172
+ // 우측 채팅에서 특정 턴 강조 + 스크롤.
173
+ function highlightTurn(idx) {
174
+ document.querySelectorAll('.bubble.hl').forEach(b=>b.classList.remove('hl'));
175
+ const b = document.querySelector(`.bubble[data-idx="${idx}"]`);
176
+ if (b) { b.classList.add('hl'); b.scrollIntoView({behavior:'smooth',block:'center'}); }
177
+ }
178
+
179
+ // ─────────── 좌하단 상세 패널 ───────────
180
+ let graphData = { nodes: [], links: [] };
181
+ function neighborsOf(id) {
182
+ const ns = new Set();
183
+ for (const l of graphData.links) { if (l.source===id) ns.add(l.target); if (l.target===id) ns.add(l.source); }
184
+ return ns;
185
+ }
186
+ function nodeById(id){ return graphData.nodes.find(n=>n.id===id); }
187
+
188
+ function showTurnDetail(node) {
189
+ const kws = [...neighborsOf(node.id)].map(nodeById).filter(n=>n&&n.type==='keyword');
190
+ const related = new Set();
191
+ for (const k of kws) for (const id of neighborsOf(k.id)) if (id!==node.id) related.add(id);
192
+ let html = `<h4>${node.role==='user'?'🧑 나':'🤖 Ethan'} · 발화 #${node.turnIndex+1}</h4>`;
193
+ html += `<div class="body">${fmt(node.text||'')}</div>`;
194
+ html += `<div><b>개념 키워드</b><br>` + (kws.length?kws.map(k=>`<span class="chip" data-id="${k.id}">${k.label}</span>`).join(''):'<span class="empty">없음</span>') + `</div>`;
195
+ const rel = [...related].map(nodeById).filter(Boolean);
196
+ html += `<div style="margin-top:8px"><b>관련 대화 ${rel.length}건</b>`;
197
+ for (const r of rel.slice(0,8)) html += `<div class="rel" data-turn="${r.turnIndex}"><span class="who">${r.role==='user'?'나':'Ethan'} #${r.turnIndex+1}</span><br>${fmt(r.snippet||'')}</div>`;
198
+ html += `</div>`;
199
+ elDetail.innerHTML = html;
200
+ wireDetailClicks();
201
+ }
202
+ function showKeywordDetail(node) {
203
+ const turns = [...neighborsOf(node.id)].map(nodeById).filter(n=>n&&n.type==='turn');
204
+ let html = `<h4>🏷️ 개념: ${node.label} <span style="color:var(--muted);font-weight:400">· ${node.count}회 등장</span></h4>`;
205
+ html += `<div style="margin-top:6px"><b>이 개념이 나온 대화 ${turns.length}건</b>`;
206
+ for (const t of turns) html += `<div class="rel" data-turn="${t.turnIndex}"><span class="who">${t.role==='user'?'나':'Ethan'} #${t.turnIndex+1}</span><br>${fmt(t.snippet||'')}</div>`;
207
+ html += `</div>`;
208
+ elDetail.innerHTML = html;
209
+ wireDetailClicks();
210
+ }
211
+ function wireDetailClicks() {
212
+ elDetail.querySelectorAll('.rel[data-turn]').forEach(el=>el.onclick=()=>{ const i=+el.dataset.turn; const n=nodeById('t'+i); if(n){selectNode(n);} highlightTurn(i); });
213
+ elDetail.querySelectorAll('.chip[data-id]').forEach(el=>el.onclick=()=>{ const n=nodeById(el.dataset.id); if(n) selectNode(n); });
214
+ }
215
+ function selectNode(node) {
216
+ selectedId = node.id; draw();
217
+ if (node.type==='turn') { showTurnDetail(node); highlightTurn(node.turnIndex); }
218
+ else showKeywordDetail(node);
219
+ }
220
+
221
+ // ─────────── 좌상단 그래프 (force-directed, 바닐라 SVG) ───────────
222
+ const svg = $('graph'); let W=0,H=0; let nodes=[],links=[]; let selectedId=null; let raf=null;
223
+ function size(){ const r=$('graph-wrap').getBoundingClientRect(); W=r.width; H=r.height; svg.setAttribute('viewBox',`0 0 ${W} ${H}`); }
224
+
225
+ async function loadGraph() {
226
+ try {
227
+ graphData = await (await fetch('/api/graph')).json();
228
+ } catch { graphData = {nodes:[],links:[]}; }
229
+ $('graph-sub').textContent = `Graphify · 노드 ${graphData.nodes.length} · 링크 ${graphData.links.length}`;
230
+ size();
231
+ // 초기 위치: 원형 배치(결정적) + 약간의 분산.
232
+ nodes = graphData.nodes.map((n,i)=>{ const a=i/Math.max(1,graphData.nodes.length)*Math.PI*2; return {...n, x:W/2+Math.cos(a)*Math.min(W,H)*0.3, y:H/2+Math.sin(a)*Math.min(W,H)*0.3, vx:0, vy:0}; });
233
+ const byId = Object.fromEntries(nodes.map(n=>[n.id,n]));
234
+ links = graphData.links.map(l=>({source:byId[l.source], target:byId[l.target]})).filter(l=>l.source&&l.target);
235
+ if (selectedId && !byId[selectedId]) selectedId=null;
236
+ runSim();
237
+ }
238
+
239
+ function runSim(){ let alpha=1; if(raf) cancelAnimationFrame(raf);
240
+ const tick=()=>{
241
+ // 반발력(O(n²) — 노드 수 작음).
242
+ for (let i=0;i<nodes.length;i++){ const a=nodes[i]; for(let j=i+1;j<nodes.length;j++){ const b=nodes[j];
243
+ let dx=a.x-b.x, dy=a.y-b.y; let d2=dx*dx+dy*dy+0.01; let f=2200/d2; let d=Math.sqrt(d2);
244
+ dx/=d; dy/=d; a.vx+=dx*f; a.vy+=dy*f; b.vx-=dx*f; b.vy-=dy*f; } }
245
+ // 링크 스프링.
246
+ for (const l of links){ let dx=l.target.x-l.source.x, dy=l.target.y-l.source.y; let d=Math.sqrt(dx*dx+dy*dy)+0.01; let f=(d-70)*0.02; dx/=d; dy/=d; l.source.vx+=dx*f; l.source.vy+=dy*f; l.target.vx-=dx*f; l.target.vy-=dy*f; }
247
+ // 중심 중력 + 감쇠 + 적분.
248
+ for (const n of nodes){ n.vx+=(W/2-n.x)*0.004; n.vy+=(H/2-n.y)*0.004; if(n===dragNode) continue; n.vx*=0.85; n.vy*=0.85; n.x+=n.vx*alpha; n.y+=n.vy*alpha; n.x=Math.max(12,Math.min(W-12,n.x)); n.y=Math.max(12,Math.min(H-12,n.y)); }
249
+ draw(); alpha*=0.985; if(alpha>0.02) raf=requestAnimationFrame(tick);
250
+ };
251
+ raf=requestAnimationFrame(tick);
252
+ }
253
+
254
+ function draw(){
255
+ const sel = selectedId ? new Set([selectedId, ...neighborsOf(selectedId)]) : null;
256
+ let s='';
257
+ for (const l of links){ const on = !sel || (sel.has(l.source.id)&&sel.has(l.target.id)); s+=`<line x1="${l.source.x}" y1="${l.source.y}" x2="${l.target.x}" y2="${l.target.y}" stroke="${on?'#c4d4e2':'#eef2f6'}" stroke-width="${on?1.2:0.6}"/>`; }
258
+ for (const n of nodes){
259
+ const isKw=n.type==='keyword'; const r=isKw?Math.min(11,5+(n.count||1)):7;
260
+ const color = isKw?'var(--orange)':(n.role==='user'?'var(--blue)':'var(--green)');
261
+ const dim = sel && !sel.has(n.id); const op = dim?0.18:1;
262
+ const stroke = n.id===selectedId?'#1f6fb0':'#fff';
263
+ s+=`<circle cx="${n.x}" cy="${n.y}" r="${r}" fill="${color}" stroke="${stroke}" stroke-width="${n.id===selectedId?2.5:1.2}" opacity="${op}" style="cursor:pointer" data-id="${n.id}"><title>${(n.label||'').replace(/"/g,'')}${n.snippet?(' — '+n.snippet.replace(/"/g,'')):''}</title></circle>`;
264
+ if (isKw && !dim) s+=`<text class="glabel" x="${n.x+r+2}" y="${n.y+3}" opacity="${op}">${(n.label||'').replace(/</g,'')}</text>`;
265
+ }
266
+ svg.innerHTML=s;
267
+ svg.querySelectorAll('circle[data-id]').forEach(c=>{ c.addEventListener('mousedown',startDrag); c.addEventListener('click',()=>{ const n=nodes.find(x=>x.id===c.dataset.id); if(n) selectNode(n); }); });
268
+ }
269
+
270
+ // 드래그.
271
+ let dragNode=null, dragMoved=false;
272
+ function startDrag(e){ const id=e.target.dataset.id; dragNode=nodes.find(n=>n.id===id); dragMoved=false; e.preventDefault(); }
273
+ svg.addEventListener('mousemove',e=>{ if(!dragNode)return; const r=svg.getBoundingClientRect(); dragNode.x=(e.clientX-r.left)/r.width*W; dragNode.y=(e.clientY-r.top)/r.height*H; dragNode.vx=0; dragNode.vy=0; dragMoved=true; draw(); });
274
+ window.addEventListener('mouseup',()=>{ dragNode=null; });
275
+ window.addEventListener('resize', ()=>{ size(); draw(); });
276
+
277
+ $('g-reload').onclick = loadGraph;
278
+
279
+ // ─────────── 분할바(좌우 + 상하) 드래그 조절, 저장 ───────────
280
+ (function initGutters(){
281
+ const body = document.body, left = document.querySelector('.left');
282
+ const gv = $('gutter'), gh = $('gutter-h');
283
+ // 저장된 값 복원.
284
+ const sv = parseFloat(localStorage.getItem('n2w_leftw'));
285
+ if (sv && sv > 200) body.style.setProperty('--leftw', sv + 'px');
286
+ const sh = parseFloat(localStorage.getItem('n2w_toph'));
287
+ if (sh && sh > 100) left.style.setProperty('--toph', sh + 'px');
288
+
289
+ let mode = null; // 'col'(좌우) | 'row'(상하)
290
+ gv.addEventListener('mousedown', (e)=>{ mode='col'; gv.classList.add('dragging'); body.classList.add('resizing','resizing-col'); e.preventDefault(); });
291
+ gh.addEventListener('mousedown', (e)=>{ mode='row'; gh.classList.add('dragging'); body.classList.add('resizing','resizing-row'); e.preventDefault(); });
292
+ window.addEventListener('mousemove', (e)=>{
293
+ if (mode==='col') {
294
+ const w = Math.max(240, Math.min(window.innerWidth - 320, e.clientX));
295
+ body.style.setProperty('--leftw', w + 'px');
296
+ } else if (mode==='row') {
297
+ const r = left.getBoundingClientRect();
298
+ const h = Math.max(120, Math.min(r.height - 160, e.clientY - r.top));
299
+ left.style.setProperty('--toph', h + 'px');
300
+ }
301
+ });
302
+ window.addEventListener('mouseup', ()=>{
303
+ if (!mode) return;
304
+ if (mode==='col') { const v = body.style.getPropertyValue('--leftw').replace('px','').trim(); if (v) localStorage.setItem('n2w_leftw', v); }
305
+ if (mode==='row') { const v = left.style.getPropertyValue('--toph').replace('px','').trim(); if (v) localStorage.setItem('n2w_toph', v); }
306
+ mode = null;
307
+ gv.classList.remove('dragging'); gh.classList.remove('dragging');
308
+ body.classList.remove('resizing','resizing-col','resizing-row');
309
+ });
310
+ // 더블클릭 → 기본 비율 복귀.
311
+ gv.addEventListener('dblclick', ()=>{ body.style.removeProperty('--leftw'); localStorage.removeItem('n2w_leftw'); });
312
+ gh.addEventListener('dblclick', ()=>{ left.style.removeProperty('--toph'); localStorage.removeItem('n2w_toph'); });
313
+ })();
314
+
315
+ // 그래프 패널 크기 변화(창 리사이즈·상하/좌우 분할바) 자동 반영.
316
+ // 드래그 중엔 즉시 size+draw(viewBox 추종), 멈추면 시뮬 재실행으로 새 영역에 재배치.
317
+ if (window.ResizeObserver) {
318
+ let rsz=null;
319
+ new ResizeObserver(()=>{ size(); draw(); clearTimeout(rsz); rsz=setTimeout(()=>{ if(nodes.length) runSim(); }, 160); }).observe($('graph-wrap'));
320
+ }
321
+
322
+ // init
323
+ loadHistory(); loadGraph(); elInput.focus();
324
+ </script>
325
+ </body>
326
+ </html>
@@ -0,0 +1,58 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>N2World — 치과 접수·대조</title>
7
+ <style>
8
+ body{font-family:'Malgun Gothic',system-ui,sans-serif;max-width:680px;margin:24px auto;padding:0 16px;color:#1f2937;background:#fafafa}
9
+ h1{font-size:20px} .sub{color:#6b7280;font-size:13px;margin-top:-6px}
10
+ label{display:block;margin:10px 0 4px;font-weight:600;font-size:13px}
11
+ input{width:100%;padding:9px;border:1px solid #d1d5db;border-radius:8px;font-size:14px}
12
+ .pii::after{content:" 🔒 로컬 처리";color:#059669;font-weight:600;font-size:11px}
13
+ button{margin-top:16px;padding:11px 18px;background:#111827;color:#fff;border:0;border-radius:8px;font-size:15px;cursor:pointer}
14
+ .card{margin-top:14px;padding:12px 14px;border-radius:10px;border:1px solid #e5e7eb;background:#fff}
15
+ .badge{display:inline-block;font-size:11px;padding:2px 8px;border-radius:999px;margin-left:6px}
16
+ .local{background:#d1fae5;color:#065f46}.external{background:#dbeafe;color:#1e40af}.none{background:#fef3c7;color:#92400e}
17
+ .warn{border-left:4px solid #f59e0b;background:#fffbeb}.block{border-left:4px solid #ef4444;background:#fef2f2}
18
+ .clinical{border:2px solid #ef4444;background:#fef2f2;font-weight:600}
19
+ .trust{color:#059669;font-size:12px;margin-top:8px}
20
+ </style>
21
+ </head>
22
+ <body>
23
+ <h1>치과 접수·대조 비서</h1>
24
+ <p class="sub">환자 정보(개인정보)는 이 PC를 떠나지 않습니다(로컬 처리). 임상 판단은 치과의 확인이 필요합니다.</p>
25
+
26
+ <label class="pii">이름</label><input id="name" value="김환자"/>
27
+ <label class="pii">생년월일(YYYY-MM-DD)</label><input id="dob" value="1990-05-05"/>
28
+ <label class="pii">연락처</label><input id="phone" value="010-1234-5678"/>
29
+ <label>보험번호</label><input id="insuranceId" value="INS-1"/>
30
+ <label>예약일</label><input id="appointmentDate" value="2026-07-01"/>
31
+ <label>담당의</label><input id="provider" value="박원장"/>
32
+ <label>주호소(선택)</label><input id="chiefComplaint" placeholder="예: 예약 변경 문의 / 어금니 통증"/>
33
+
34
+ <button onclick="submitIntake()">접수·대조 실행</button>
35
+ <div id="out"></div>
36
+
37
+ <script>
38
+ async function submitIntake(){
39
+ const body={};
40
+ for(const k of ['name','dob','phone','insuranceId','appointmentDate','provider','chiefComplaint']) body[k]=document.getElementById(k).value;
41
+ const out=document.getElementById('out'); out.innerHTML='<div class="card">처리 중…</div>';
42
+ try{
43
+ const r=await fetch('/api/playbook/dental/intake',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
44
+ const d=await r.json();
45
+ if(d.error){out.innerHTML='<div class="card block">오류: '+d.error+'</div>';return;}
46
+ let h='<div class="card"><b>'+d.notice+'</b><div class="trust">🔒 PII 로컬 처리: '+(d.piiKeptLocal?'예(외부 전송 0)':'경고')+' · Tier-0 외부허용='+d.tier0ExternalAllowed+'</div></div>';
47
+ if(d.humanInLoopRequired) h+='<div class="card clinical">⚠ 임상 관련 — 치과의 확인 필요(자동 처리 보류)</div>';
48
+ for(const f of d.flags) h+='<div class="card '+(f.severity==='block'?'block':'warn')+'">['+f.id+'] '+f.flag+'</div>';
49
+ for(const s of d.steps){
50
+ const cls=s.provider; const label=s.id==='staff-summary'?'직원 요약':'환자 안내문';
51
+ h+='<div class="card">'+label+'<span class="badge '+cls+'">'+s.provider+(s.tier===0?' · Tier-0':'')+'</span><div style="margin-top:6px">'+(s.text||'(보류)')+'</div></div>';
52
+ }
53
+ out.innerHTML=h;
54
+ }catch(e){out.innerHTML='<div class="card block">요청 실패: '+e.message+'</div>';}
55
+ }
56
+ </script>
57
+ </body>
58
+ </html>
Binary file
Binary file