@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.
- package/dist/agent-os-rd.d.ts +100 -0
- package/dist/agent-os-rd.js +258 -0
- package/dist/audit-store.d.ts +14 -0
- package/dist/audit-store.js +107 -0
- package/dist/beta-runner.d.ts +95 -0
- package/dist/beta-runner.js +251 -0
- package/dist/beta.d.ts +102 -0
- package/dist/beta.js +180 -0
- package/dist/browser-agent.d.ts +90 -0
- package/dist/browser-agent.js +223 -0
- package/dist/channel-gateway.d.ts +74 -0
- package/dist/channel-gateway.js +270 -0
- package/dist/channels.d.ts +120 -0
- package/dist/channels.js +432 -0
- package/dist/chat-store.d.ts +29 -0
- package/dist/chat-store.js +120 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +607 -0
- package/dist/command-screen.d.ts +12 -0
- package/dist/command-screen.js +44 -0
- package/dist/commit-gate.d.ts +98 -0
- package/dist/commit-gate.js +258 -0
- package/dist/companion-api.d.ts +37 -0
- package/dist/companion-api.js +101 -0
- package/dist/conversation-graph.d.ts +39 -0
- package/dist/conversation-graph.js +92 -0
- package/dist/cost-estimator.d.ts +27 -0
- package/dist/cost-estimator.js +42 -0
- package/dist/cron-runner.d.ts +31 -0
- package/dist/cron-runner.js +46 -0
- package/dist/dashboard/chat.html +326 -0
- package/dist/dashboard/dental.html +58 -0
- package/dist/dashboard/freebie.png +0 -0
- package/dist/dashboard/icon-192.png +0 -0
- package/dist/dashboard/index.html +892 -0
- package/dist/dashboard/manifest.json +15 -0
- package/dist/dashboard/service-worker.js +28 -0
- package/dist/dashboard-server.d.ts +37 -0
- package/dist/dashboard-server.js +457 -0
- package/dist/dental-intake-service.d.ts +37 -0
- package/dist/dental-intake-service.js +61 -0
- package/dist/dental-metrics.d.ts +25 -0
- package/dist/dental-metrics.js +37 -0
- package/dist/docking.d.ts +36 -0
- package/dist/docking.js +73 -0
- package/dist/finance-mcts-candidate.d.ts +37 -0
- package/dist/finance-mcts-candidate.js +106 -0
- package/dist/finance-regulation-kr.d.ts +33 -0
- package/dist/finance-regulation-kr.js +104 -0
- package/dist/finance-workflow.d.ts +135 -0
- package/dist/finance-workflow.js +242 -0
- package/dist/gateway.d.ts +18 -0
- package/dist/gateway.js +123 -0
- package/dist/governance.d.ts +39 -0
- package/dist/governance.js +48 -0
- package/dist/governed-executor.d.ts +31 -0
- package/dist/governed-executor.js +63 -0
- package/dist/governed-llm.d.ts +41 -0
- package/dist/governed-llm.js +83 -0
- package/dist/gpu-bridge.d.ts +16 -0
- package/dist/gpu-bridge.js +53 -0
- package/dist/health.d.ts +47 -0
- package/dist/health.js +66 -0
- package/dist/identity-link.d.ts +32 -0
- package/dist/identity-link.js +98 -0
- package/dist/index.d.ts +184 -0
- package/dist/index.js +417 -0
- package/dist/integrations/emr-adapter.d.ts +41 -0
- package/dist/integrations/emr-adapter.js +63 -0
- package/dist/kakao-oauth.d.ts +16 -0
- package/dist/kakao-oauth.js +87 -0
- package/dist/knowledge-graph.d.ts +53 -0
- package/dist/knowledge-graph.js +156 -0
- package/dist/llm.d.ts +65 -0
- package/dist/llm.js +357 -0
- package/dist/mcp-client-guard.d.ts +32 -0
- package/dist/mcp-client-guard.js +179 -0
- package/dist/mcp-macaroon.d.ts +75 -0
- package/dist/mcp-macaroon.js +161 -0
- package/dist/mcts-kernel-bridge.d.ts +36 -0
- package/dist/mcts-kernel-bridge.js +99 -0
- package/dist/mcts-prior.d.ts +79 -0
- package/dist/mcts-prior.js +170 -0
- package/dist/model-router.d.ts +51 -0
- package/dist/model-router.js +75 -0
- package/dist/multi-axis-lift.d.ts +43 -0
- package/dist/multi-axis-lift.js +141 -0
- package/dist/net-guard.d.ts +39 -0
- package/dist/net-guard.js +141 -0
- package/dist/onboarding.d.ts +38 -0
- package/dist/onboarding.js +94 -0
- package/dist/oracle-anchored-search.d.ts +25 -0
- package/dist/oracle-anchored-search.js +50 -0
- package/dist/oracle.d.ts +22 -0
- package/dist/oracle.js +116 -0
- package/dist/p6-governance.d.ts +150 -0
- package/dist/p6-governance.js +252 -0
- package/dist/pairing.d.ts +22 -0
- package/dist/pairing.js +81 -0
- package/dist/personalization.d.ts +35 -0
- package/dist/personalization.js +73 -0
- package/dist/pglite-hnsw-bridge.d.ts +118 -0
- package/dist/pglite-hnsw-bridge.js +311 -0
- package/dist/pglite-store.d.ts +59 -0
- package/dist/pglite-store.js +180 -0
- package/dist/playbook.d.ts +79 -0
- package/dist/playbook.js +83 -0
- package/dist/playbooks/dental-intake.d.ts +20 -0
- package/dist/playbooks/dental-intake.js +112 -0
- package/dist/predictive-agent.d.ts +157 -0
- package/dist/predictive-agent.js +535 -0
- package/dist/prompt-optimizer.d.ts +18 -0
- package/dist/prompt-optimizer.js +104 -0
- package/dist/rate-limiter.d.ts +25 -0
- package/dist/rate-limiter.js +75 -0
- package/dist/safety-anneal.d.ts +83 -0
- package/dist/safety-anneal.js +153 -0
- package/dist/sandbox-controller.d.ts +12 -0
- package/dist/sandbox-controller.js +95 -0
- package/dist/satisfaction-metrics.d.ts +26 -0
- package/dist/satisfaction-metrics.js +61 -0
- package/dist/sensor-bridge.d.ts +53 -0
- package/dist/sensor-bridge.js +133 -0
- package/dist/session-repair.d.ts +27 -0
- package/dist/session-repair.js +66 -0
- package/dist/slack-finance-intake.d.ts +42 -0
- package/dist/slack-finance-intake.js +122 -0
- package/dist/symbolic-dynamics.d.ts +113 -0
- package/dist/symbolic-dynamics.js +420 -0
- package/dist/telemetry.d.ts +19 -0
- package/dist/telemetry.js +68 -0
- package/dist/text-embedding.d.ts +6 -0
- package/dist/text-embedding.js +42 -0
- package/dist/tier-classifier.d.ts +20 -0
- package/dist/tier-classifier.js +58 -0
- package/dist/tier-guard.d.ts +36 -0
- package/dist/tier-guard.js +56 -0
- package/dist/tui.d.ts +9 -0
- package/dist/tui.js +214 -0
- package/dist/update-security.d.ts +31 -0
- package/dist/update-security.js +112 -0
- package/dist/v-calibration.d.ts +16 -0
- package/dist/v-calibration.js +42 -0
- package/dist/value-calibration.d.ts +41 -0
- package/dist/value-calibration.js +133 -0
- package/dist/value-head.d.ts +20 -0
- package/dist/value-head.js +91 -0
- package/dist/wal-buffer.d.ts +23 -0
- package/dist/wal-buffer.js +144 -0
- package/dist/wiki-synthesizer.d.ts +80 -0
- package/dist/wiki-synthesizer.js +0 -0
- package/dist/worker-agent.d.ts +10 -0
- package/dist/worker-agent.js +19 -0
- 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,'&').replace(/</g,'<').replace(/>/g,'>').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
|