ltcai 0.1.4 → 0.1.8
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/README.md +92 -0
- package/docs/OPERATIONS.md +149 -0
- package/knowledge_graph.py +802 -0
- package/ltcai_cli.py +45 -1
- package/package.json +15 -3
- package/requirements.txt +1 -0
- package/server.py +665 -28
- package/skills/SKILL_TEMPLATE.md +57 -0
- package/skills/code_review/SKILL.md +76 -0
- package/skills/data_analysis/SKILL.md +79 -0
- package/skills/file_edit/SKILL.md +68 -0
- package/skills/web_search/SKILL.md +74 -0
- package/static/account.html +14 -2
- package/static/admin.html +225 -6
- package/static/chat.html +644 -140
- package/static/graph.html +612 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/manifest.json +35 -0
- package/static/sw.js +51 -0
- package/telegram_bot.py +631 -217
- package/tests/__init__.py +0 -0
- package/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/test_api.py +94 -0
- package/tests/unit/__init__.py +0 -0
- package/tests/unit/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_tools.py +127 -0
- package/tools.py +169 -13
package/static/admin.html
CHANGED
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
7
7
|
<title>Lattice AI Admin</title>
|
|
8
|
+
<link rel="manifest" href="/manifest.json">
|
|
9
|
+
<meta name="theme-color" content="#2d5a3d">
|
|
10
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
11
|
+
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
|
12
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
|
|
8
13
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
|
9
14
|
<style>
|
|
10
15
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
|
@@ -381,6 +386,43 @@
|
|
|
381
386
|
gap: 12px;
|
|
382
387
|
}
|
|
383
388
|
|
|
389
|
+
.audit-grid {
|
|
390
|
+
display: grid;
|
|
391
|
+
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
392
|
+
gap: 10px;
|
|
393
|
+
margin-bottom: 14px;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.audit-metric {
|
|
397
|
+
border: 1px solid var(--border);
|
|
398
|
+
border-radius: 12px;
|
|
399
|
+
background: rgba(255,255,255,0.025);
|
|
400
|
+
padding: 12px;
|
|
401
|
+
min-width: 0;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.audit-metric .label {
|
|
405
|
+
color: var(--faint);
|
|
406
|
+
font-size: 11px;
|
|
407
|
+
font-weight: 800;
|
|
408
|
+
letter-spacing: 0.06em;
|
|
409
|
+
text-transform: uppercase;
|
|
410
|
+
margin-bottom: 8px;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.audit-metric .value {
|
|
414
|
+
font-size: 24px;
|
|
415
|
+
font-weight: 800;
|
|
416
|
+
line-height: 1;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.audit-metric .meta {
|
|
420
|
+
color: var(--muted);
|
|
421
|
+
font-size: 12px;
|
|
422
|
+
margin-top: 7px;
|
|
423
|
+
line-height: 1.4;
|
|
424
|
+
}
|
|
425
|
+
|
|
384
426
|
.subpanel {
|
|
385
427
|
border: 1px solid var(--border);
|
|
386
428
|
border-radius: 12px;
|
|
@@ -505,6 +547,7 @@
|
|
|
505
547
|
|
|
506
548
|
@media (max-width: 1100px) {
|
|
507
549
|
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
550
|
+
.audit-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
508
551
|
.panel-grid { grid-template-columns: 1fr; }
|
|
509
552
|
}
|
|
510
553
|
|
|
@@ -518,6 +561,7 @@
|
|
|
518
561
|
.content { padding: 16px; gap: 14px; }
|
|
519
562
|
.hero h2 { font-size: 22px; }
|
|
520
563
|
.summary-grid { grid-template-columns: 1fr; }
|
|
564
|
+
.audit-grid { grid-template-columns: 1fr; }
|
|
521
565
|
.two-col, .form-grid { grid-template-columns: 1fr; }
|
|
522
566
|
.field.full { grid-column: auto; }
|
|
523
567
|
}
|
|
@@ -623,6 +667,33 @@
|
|
|
623
667
|
</div>
|
|
624
668
|
</section>
|
|
625
669
|
|
|
670
|
+
<section class="panel">
|
|
671
|
+
<div class="panel-header">
|
|
672
|
+
<div>
|
|
673
|
+
<h3>Audit & Data Governance</h3>
|
|
674
|
+
<p>사용자별 AI 사용량, 문서 업로드, 민감정보 감지, 삭제/정리 이벤트를 보존합니다.</p>
|
|
675
|
+
</div>
|
|
676
|
+
<div class="tag-row" id="audit-summary-tags"></div>
|
|
677
|
+
</div>
|
|
678
|
+
<div class="panel-body">
|
|
679
|
+
<div class="audit-grid" id="audit-metrics"></div>
|
|
680
|
+
<div class="two-col">
|
|
681
|
+
<div class="subpanel">
|
|
682
|
+
<h4><i class="ti ti-users"></i> User Usage & Risk</h4>
|
|
683
|
+
<div class="table-wrap" id="audit-user-table">
|
|
684
|
+
<div class="preview" style="padding:14px">불러오는 중...</div>
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
<div class="subpanel">
|
|
688
|
+
<h4><i class="ti ti-history"></i> Audit Trail</h4>
|
|
689
|
+
<div class="table-wrap" id="audit-event-table">
|
|
690
|
+
<div class="preview" style="padding:14px">불러오는 중...</div>
|
|
691
|
+
</div>
|
|
692
|
+
</div>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
</section>
|
|
696
|
+
|
|
626
697
|
<section class="panel-grid">
|
|
627
698
|
<article class="panel">
|
|
628
699
|
<div class="panel-header">
|
|
@@ -753,7 +824,10 @@
|
|
|
753
824
|
const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
|
|
754
825
|
|
|
755
826
|
function apiFetch(path, options = {}) {
|
|
756
|
-
|
|
827
|
+
const headers = { ...(options.headers || {}) };
|
|
828
|
+
const token = localStorage.getItem('ltcai_session_token') || '';
|
|
829
|
+
if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
|
|
830
|
+
return fetch(`${API_BASE}${path}`, { credentials: 'include', ...options, headers });
|
|
757
831
|
}
|
|
758
832
|
|
|
759
833
|
function currentUserEmail() {
|
|
@@ -768,10 +842,26 @@
|
|
|
768
842
|
return localStorage.getItem('ltcai_is_admin') === 'true';
|
|
769
843
|
}
|
|
770
844
|
|
|
845
|
+
function restoreSessionFromQuery() {
|
|
846
|
+
const raw = sessionStorage.getItem('ltcai_admin_handoff');
|
|
847
|
+
if (!raw) return;
|
|
848
|
+
sessionStorage.removeItem('ltcai_admin_handoff');
|
|
849
|
+
let data;
|
|
850
|
+
try { data = JSON.parse(raw); } catch { return; }
|
|
851
|
+
const { email, nickname, is_admin, token } = data;
|
|
852
|
+
if (!email) return;
|
|
853
|
+
localStorage.setItem('ltcai_user_email', email);
|
|
854
|
+
if (nickname) localStorage.setItem('ltcai_user_nickname', nickname);
|
|
855
|
+
if (is_admin === 'true' || is_admin === 'false') localStorage.setItem('ltcai_is_admin', is_admin);
|
|
856
|
+
if (token) localStorage.setItem('ltcai_session_token', token);
|
|
857
|
+
}
|
|
858
|
+
|
|
771
859
|
function adminHeaders() {
|
|
860
|
+
const token = localStorage.getItem('ltcai_session_token') || '';
|
|
772
861
|
return {
|
|
773
862
|
'Content-Type': 'application/json',
|
|
774
|
-
'X-Admin-Email': currentUserEmail()
|
|
863
|
+
'X-Admin-Email': currentUserEmail(),
|
|
864
|
+
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
775
865
|
};
|
|
776
866
|
}
|
|
777
867
|
|
|
@@ -854,6 +944,7 @@
|
|
|
854
944
|
|
|
855
945
|
function applyI18n() {
|
|
856
946
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
947
|
+
if (el.id === 'session-help') return;
|
|
857
948
|
const val = t(el.dataset.i18n);
|
|
858
949
|
if (val) el.textContent = val;
|
|
859
950
|
});
|
|
@@ -1056,6 +1147,128 @@
|
|
|
1056
1147
|
document.getElementById('sensitivity-summary').insertAdjacentHTML('beforeend', fieldTags.join('') + userTags.join(''));
|
|
1057
1148
|
}
|
|
1058
1149
|
|
|
1150
|
+
function formatAuditTime(value) {
|
|
1151
|
+
if (!value) return '-';
|
|
1152
|
+
const date = new Date(value);
|
|
1153
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
1154
|
+
return date.toLocaleString();
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function auditEventLabel(event) {
|
|
1158
|
+
const type = event?.event_type || '-';
|
|
1159
|
+
const labels = {
|
|
1160
|
+
chat_message: event?.role === 'assistant' ? 'AI response' : 'User message',
|
|
1161
|
+
document_upload: 'Document upload',
|
|
1162
|
+
clear_command: 'Chat clear',
|
|
1163
|
+
conversation_delete: 'Conversation delete',
|
|
1164
|
+
history_delete: 'History delete',
|
|
1165
|
+
user_delete: 'User delete'
|
|
1166
|
+
};
|
|
1167
|
+
return labels[type] || type;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function auditTarget(event) {
|
|
1171
|
+
if (!event) return '-';
|
|
1172
|
+
if (event.filename) return event.filename;
|
|
1173
|
+
if (event.target_email) return `target: ${event.target_email}`;
|
|
1174
|
+
if (event.command) return `${event.command} · ${event.scope || '-'} · removed ${event.removed || 0}`;
|
|
1175
|
+
if (event.event_type === 'history_delete') return `history · removed ${event.removed || 0} · kept ${event.kept || 0}`;
|
|
1176
|
+
if (event.conversation_id) return `conversation ${String(event.conversation_id).slice(0, 18)}`;
|
|
1177
|
+
return event.content_preview || '-';
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function renderAudit(audit) {
|
|
1181
|
+
const summary = audit?.summary || {};
|
|
1182
|
+
const graph = audit?.graph || {};
|
|
1183
|
+
const metrics = [
|
|
1184
|
+
['Total Events', summary.total_events || 0, `${summary.chat_events || 0} chat events`],
|
|
1185
|
+
['AI Usage', `${summary.user_messages || 0}/${summary.assistant_messages || 0}`, 'user / assistant'],
|
|
1186
|
+
['Uploads', summary.document_uploads || 0, `${graph.nodes || 0} graph nodes`],
|
|
1187
|
+
['Clear Events', summary.clear_events || 0, 'screen cleanup only'],
|
|
1188
|
+
['Sensitive', summary.sensitive_events || 0, `${summary.high_sensitive_events || 0} high risk`],
|
|
1189
|
+
];
|
|
1190
|
+
document.getElementById('audit-metrics').innerHTML = metrics.map(([label, value, meta]) => `
|
|
1191
|
+
<div class="audit-metric">
|
|
1192
|
+
<div class="label">${esc(label)}</div>
|
|
1193
|
+
<div class="value">${esc(value)}</div>
|
|
1194
|
+
<div class="meta">${esc(meta)}</div>
|
|
1195
|
+
</div>
|
|
1196
|
+
`).join('');
|
|
1197
|
+
|
|
1198
|
+
const tags = [
|
|
1199
|
+
['low', `Graph nodes ${graph.nodes || 0}`],
|
|
1200
|
+
['low', `Edges ${graph.edges || 0}`],
|
|
1201
|
+
['medium', `Deletes ${summary.delete_events || 0}`],
|
|
1202
|
+
[summary.high_sensitive_events ? 'high' : 'low', `High risk ${summary.high_sensitive_events || 0}`]
|
|
1203
|
+
];
|
|
1204
|
+
document.getElementById('audit-summary-tags').innerHTML = tags.map(([tone, label]) => `<span class="tag ${tone}">${esc(label)}</span>`).join('');
|
|
1205
|
+
|
|
1206
|
+
const users = audit?.per_user || [];
|
|
1207
|
+
document.getElementById('audit-user-table').innerHTML = users.length ? `
|
|
1208
|
+
<table>
|
|
1209
|
+
<thead>
|
|
1210
|
+
<tr>
|
|
1211
|
+
<th>User</th>
|
|
1212
|
+
<th>AI Use</th>
|
|
1213
|
+
<th>Uploads</th>
|
|
1214
|
+
<th>Sensitive</th>
|
|
1215
|
+
<th>Clear/Delete</th>
|
|
1216
|
+
<th>Last Active</th>
|
|
1217
|
+
</tr>
|
|
1218
|
+
</thead>
|
|
1219
|
+
<tbody>
|
|
1220
|
+
${users.map(user => `
|
|
1221
|
+
<tr>
|
|
1222
|
+
<td>
|
|
1223
|
+
<strong>${esc(user.nickname || user.email || 'Unknown')}</strong>
|
|
1224
|
+
<div class="preview">${esc(user.email || '')}</div>
|
|
1225
|
+
</td>
|
|
1226
|
+
<td>${esc(user.user_messages || 0)} / ${esc(user.assistant_messages || 0)}</td>
|
|
1227
|
+
<td>${esc(user.document_uploads || 0)}</td>
|
|
1228
|
+
<td>
|
|
1229
|
+
<span class="tag ${(user.high_sensitive_events || 0) ? 'high' : ((user.sensitive_events || 0) ? 'medium' : 'low')}">
|
|
1230
|
+
${esc(user.sensitive_events || 0)}
|
|
1231
|
+
</span>
|
|
1232
|
+
</td>
|
|
1233
|
+
<td>${esc(user.clear_events || 0)} / ${esc(user.delete_events || 0)}</td>
|
|
1234
|
+
<td>${esc(formatAuditTime(user.last_activity_at))}</td>
|
|
1235
|
+
</tr>
|
|
1236
|
+
`).join('')}
|
|
1237
|
+
</tbody>
|
|
1238
|
+
</table>
|
|
1239
|
+
` : '<div class="preview" style="padding:14px">감사 데이터가 아직 없습니다.</div>';
|
|
1240
|
+
|
|
1241
|
+
const events = audit?.recent_events || [];
|
|
1242
|
+
document.getElementById('audit-event-table').innerHTML = events.length ? `
|
|
1243
|
+
<table>
|
|
1244
|
+
<thead>
|
|
1245
|
+
<tr>
|
|
1246
|
+
<th>Time</th>
|
|
1247
|
+
<th>Event</th>
|
|
1248
|
+
<th>User</th>
|
|
1249
|
+
<th>Target/Data</th>
|
|
1250
|
+
<th>Risk</th>
|
|
1251
|
+
</tr>
|
|
1252
|
+
</thead>
|
|
1253
|
+
<tbody>
|
|
1254
|
+
${events.map(event => `
|
|
1255
|
+
<tr>
|
|
1256
|
+
<td>${esc(formatAuditTime(event.timestamp))}</td>
|
|
1257
|
+
<td>${esc(auditEventLabel(event))}</td>
|
|
1258
|
+
<td>${esc(event.user_nickname || event.user_email || 'Unknown')}</td>
|
|
1259
|
+
<td>${esc(auditTarget(event))}</td>
|
|
1260
|
+
<td>
|
|
1261
|
+
<span class="tag ${event.sensitivity === 'high' ? 'high' : (event.sensitivity && event.sensitivity !== 'none' ? 'medium' : 'low')}">
|
|
1262
|
+
${esc(event.sensitivity || 'none')}
|
|
1263
|
+
</span>
|
|
1264
|
+
</td>
|
|
1265
|
+
</tr>
|
|
1266
|
+
`).join('')}
|
|
1267
|
+
</tbody>
|
|
1268
|
+
</table>
|
|
1269
|
+
` : '<div class="preview" style="padding:14px">최근 감사 이벤트가 없습니다.</div>';
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1059
1272
|
function renderUsers(users) {
|
|
1060
1273
|
const wrap = document.getElementById('user-table-wrap');
|
|
1061
1274
|
if (!Array.isArray(users) || !users.length) {
|
|
@@ -1137,21 +1350,22 @@
|
|
|
1137
1350
|
});
|
|
1138
1351
|
|
|
1139
1352
|
async function loadDashboard() {
|
|
1140
|
-
setSessionInfo();
|
|
1141
1353
|
applyI18n();
|
|
1354
|
+
setSessionInfo();
|
|
1142
1355
|
|
|
1143
1356
|
const access = document.getElementById('access-notice');
|
|
1144
1357
|
access.style.display = 'none';
|
|
1145
1358
|
|
|
1146
1359
|
try {
|
|
1147
|
-
const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes] = await Promise.all([
|
|
1360
|
+
const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes, auditRes] = await Promise.all([
|
|
1148
1361
|
apiFetch('/health'),
|
|
1149
1362
|
apiFetch('/vpc/status'),
|
|
1150
1363
|
apiFetch('/admin/summary', { headers: adminHeaders() }),
|
|
1151
1364
|
apiFetch('/admin/users', { headers: adminHeaders() }),
|
|
1152
1365
|
apiFetch('/admin/sensitivity', { headers: adminHeaders() }),
|
|
1153
1366
|
apiFetch('/admin/invite-link', { headers: adminHeaders() }),
|
|
1154
|
-
apiFetch('/admin/stats', { headers: adminHeaders() })
|
|
1367
|
+
apiFetch('/admin/stats', { headers: adminHeaders() }),
|
|
1368
|
+
apiFetch('/admin/audit', { headers: adminHeaders() })
|
|
1155
1369
|
]);
|
|
1156
1370
|
|
|
1157
1371
|
const health = healthRes.ok ? await healthRes.json() : null;
|
|
@@ -1161,11 +1375,13 @@
|
|
|
1161
1375
|
const sensitivity = sensitivityRes.ok ? await sensitivityRes.json() : null;
|
|
1162
1376
|
const invite = inviteRes.ok ? await inviteRes.json() : null;
|
|
1163
1377
|
const stats = statsRes.ok ? await statsRes.json() : null;
|
|
1378
|
+
const audit = auditRes.ok ? await auditRes.json() : null;
|
|
1164
1379
|
|
|
1165
1380
|
renderSummary(health, summary, vpc);
|
|
1166
1381
|
fillVpcForm(vpc);
|
|
1167
1382
|
renderUsers(users);
|
|
1168
1383
|
renderSensitivity(sensitivity);
|
|
1384
|
+
renderAudit(audit);
|
|
1169
1385
|
if (invite) {
|
|
1170
1386
|
document.getElementById('invite-link-input').value = invite.invite_url;
|
|
1171
1387
|
document.getElementById('invite-gate-info').textContent = invite.gate_enabled
|
|
@@ -1178,6 +1394,7 @@
|
|
|
1178
1394
|
if (!summaryRes.ok) failedSections.push(t('section_summary'));
|
|
1179
1395
|
if (!usersRes.ok) failedSections.push(t('section_users'));
|
|
1180
1396
|
if (!sensitivityRes.ok) failedSections.push(t('section_sensitivity'));
|
|
1397
|
+
if (!auditRes.ok) failedSections.push('audit');
|
|
1181
1398
|
|
|
1182
1399
|
if (failedSections.length) {
|
|
1183
1400
|
access.style.display = 'block';
|
|
@@ -1229,6 +1446,7 @@
|
|
|
1229
1446
|
localStorage.removeItem('ltcai_user_email');
|
|
1230
1447
|
localStorage.removeItem('ltcai_user_nickname');
|
|
1231
1448
|
localStorage.removeItem('ltcai_is_admin');
|
|
1449
|
+
localStorage.removeItem('ltcai_session_token');
|
|
1232
1450
|
window.location.href = '/';
|
|
1233
1451
|
}
|
|
1234
1452
|
|
|
@@ -1236,6 +1454,7 @@
|
|
|
1236
1454
|
document.getElementById('save-vpc-btn').addEventListener('click', saveVpc);
|
|
1237
1455
|
document.getElementById('logout-btn').addEventListener('click', logout);
|
|
1238
1456
|
applyI18n();
|
|
1457
|
+
restoreSessionFromQuery();
|
|
1239
1458
|
loadDashboard();
|
|
1240
1459
|
</script>
|
|
1241
1460
|
</body>
|