ltcai 0.3.0 → 0.3.1

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/server.py CHANGED
@@ -72,6 +72,24 @@ from latticeai.core.audit import (
72
72
  )
73
73
  from latticeai.api.auth import create_auth_router
74
74
  from latticeai.api.admin import create_admin_router
75
+ from latticeai.api.security_dashboard import create_security_router as _create_security_router
76
+ from latticeai.core.model_compat import (
77
+ ensure_profile as _ensure_compat_profile,
78
+ record_smoke_result as _record_smoke_result,
79
+ fast_postprocess as _compat_fast_postprocess,
80
+ validate_smoke_response as _validate_smoke_response,
81
+ list_cached_profiles as _list_compat_profiles,
82
+ SMOKE_PROMPT as _SMOKE_PROMPT,
83
+ )
84
+ from latticeai.core.model_resolution import (
85
+ ModelResolution as _ModelResolution,
86
+ PrepareState as _PrepareState,
87
+ PrepareReport as _PrepareReport,
88
+ )
89
+ from latticeai.core.graph_curator import (
90
+ auto_build_graph_overlay as _auto_build_graph_overlay,
91
+ mask_secrets as _curator_mask_secrets,
92
+ )
75
93
  import mcp_registry
76
94
  from mcp_registry import (
77
95
  MCP_REGISTRY, _THIRD_PARTY_SKILL_SOURCES, _KNOWN_REPO_LICENSES,
@@ -1107,7 +1125,7 @@ async def lifespan(app: FastAPI):
1107
1125
  except Exception:
1108
1126
  pass
1109
1127
 
1110
- app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.2.2", lifespan=lifespan)
1128
+ app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.3.0", lifespan=lifespan)
1111
1129
 
1112
1130
  CORS_ALLOWED_ORIGINS = [
1113
1131
  f"http://localhost:{DEFAULT_PORT}",
@@ -1175,19 +1193,64 @@ app.include_router(create_admin_router(
1175
1193
  default_port=DEFAULT_PORT,
1176
1194
  ))
1177
1195
 
1196
+ # ── Security & Audit Command Center (피드백 #5) ──────────────────────────────
1197
+ def _security_audit_events_safe() -> List[Dict]:
1198
+ try:
1199
+ return _get_audit_log(AUDIT_FILE)
1200
+ except Exception as e:
1201
+ logging.warning("security audit events load failed: %s", e)
1202
+ return []
1203
+
1204
+ def _security_list_uploaded_files() -> List[Dict]:
1205
+ """Audit log에서 document_upload 이벤트를 가공해서 file 목록으로 노출."""
1206
+ files: List[Dict] = []
1207
+ for idx, e in enumerate(_security_audit_events_safe()):
1208
+ if e.get("event_type") != "document_upload":
1209
+ continue
1210
+ files.append({
1211
+ "file_id": str(e.get("filename") or idx),
1212
+ "filename": e.get("filename"),
1213
+ "user_email": e.get("user_email"),
1214
+ "user_nickname": e.get("user_nickname"),
1215
+ "uploaded_at": e.get("timestamp"),
1216
+ "ext": e.get("ext"),
1217
+ "bytes": e.get("bytes"),
1218
+ "sensitivity": e.get("sensitivity") or "none",
1219
+ "sensitive_labels": e.get("sensitive_labels") or [],
1220
+ "content_preview": e.get("content_preview"),
1221
+ })
1222
+ return files
1223
+
1224
+ app.include_router(_create_security_router(
1225
+ require_admin=require_admin,
1226
+ get_history=get_history,
1227
+ get_audit_events=_security_audit_events_safe,
1228
+ classify_sensitive_message=classify_sensitive_message,
1229
+ build_sensitivity_report=build_sensitivity_report,
1230
+ list_uploaded_files=_security_list_uploaded_files,
1231
+ append_audit_event=append_audit_event,
1232
+ ))
1233
+
1234
+ def ui_file_response(path: Path) -> FileResponse:
1235
+ response = FileResponse(path)
1236
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
1237
+ response.headers["Pragma"] = "no-cache"
1238
+ response.headers["Expires"] = "0"
1239
+ return response
1240
+
1178
1241
  @app.get("/")
1179
1242
  async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
1180
1243
  """로그인/회원가입 페이지. 초대 게이트 활성화 시 코드 검증 후 진입."""
1181
1244
  if not INVITE_GATE_ENABLED:
1182
- return FileResponse(STATIC_DIR / "account.html")
1245
+ return ui_file_response(STATIC_DIR / "account.html")
1183
1246
 
1184
1247
  # 1. 이미 쿠키로 인증된 경우
1185
1248
  if authorized == "true":
1186
- return FileResponse(STATIC_DIR / "account.html")
1249
+ return ui_file_response(STATIC_DIR / "account.html")
1187
1250
 
1188
1251
  # 2. 초대 코드가 일치하는 경우 (최초 진입)
1189
1252
  if code == INVITE_CODE:
1190
- response = FileResponse(STATIC_DIR / "account.html")
1253
+ response = ui_file_response(STATIC_DIR / "account.html")
1191
1254
  response.set_cookie(key="authorized", value="true", httponly=True, samesite="lax", max_age=60*60*24*7)
1192
1255
  return response
1193
1256
 
@@ -1207,7 +1270,7 @@ async def root(request: Request, code: Optional[str] = None, authorized: Optiona
1207
1270
  @app.get("/account")
1208
1271
  async def account_page():
1209
1272
  """Direct login/register page route used by logout and manual navigation."""
1210
- return FileResponse(STATIC_DIR / "account.html")
1273
+ return ui_file_response(STATIC_DIR / "account.html")
1211
1274
 
1212
1275
 
1213
1276
  @app.get("/manifest.json")
@@ -1230,7 +1293,7 @@ async def service_worker():
1230
1293
 
1231
1294
  @app.get("/chat")
1232
1295
  async def chat_page(request: Request):
1233
- return FileResponse(STATIC_DIR / "chat.html")
1296
+ return ui_file_response(STATIC_DIR / "chat.html")
1234
1297
 
1235
1298
 
1236
1299
  @app.get("/admin")
@@ -1963,15 +2026,11 @@ def get_lmstudio_models(*, force: bool = False) -> List[Dict[str, object]]:
1963
2026
  global _LMSTUDIO_MODELS_CACHE, _LMSTUDIO_MODELS_CACHE_TS
1964
2027
  if not force and time.monotonic() - _LMSTUDIO_MODELS_CACHE_TS < _LMSTUDIO_MODELS_CACHE_TTL:
1965
2028
  return _LMSTUDIO_MODELS_CACHE
1966
- try:
1967
- ensure_lmstudio_server()
1968
- except HTTPException:
1969
- return _LMSTUDIO_MODELS_CACHE
1970
2029
  try:
1971
2030
  payload = _json_request(
1972
2031
  f"{lmstudio_native_api_base()}/api/v1/models",
1973
2032
  headers={"Authorization": f"Bearer {os.getenv('LMSTUDIO_API_KEY') or 'lmstudio'}"},
1974
- timeout=5,
2033
+ timeout=2.5,
1975
2034
  )
1976
2035
  except Exception:
1977
2036
  return _LMSTUDIO_MODELS_CACHE
@@ -2939,6 +2998,82 @@ def ensure_engine_ready(engine: str) -> Dict[str, object]:
2939
2998
  return {"engine": engine, "installed": True, "installed_now": True, "install": result}
2940
2999
 
2941
3000
 
3001
+ def build_model_resolution(
3002
+ input_id: str,
3003
+ engine: Optional[str],
3004
+ *,
3005
+ user_email: Optional[str] = None,
3006
+ display_name: Optional[str] = None,
3007
+ ) -> _ModelResolution:
3008
+ """피드백 #1/#2 공용 ModelResolution 생성기.
3009
+
3010
+ 사용자가 클릭한 input_id + engine 힌트를 받아 모든 단계가 공유할
3011
+ canonical identity를 만든다.
3012
+ """
3013
+ normalized = normalize_local_model_request(input_id, engine)
3014
+ return _ModelResolution.from_request(
3015
+ normalized,
3016
+ engine=engine,
3017
+ user_email=user_email,
3018
+ display_name=display_name or input_id,
3019
+ engine_aliases=MODEL_ENGINE_ALIASES,
3020
+ )
3021
+
3022
+
3023
+ _LOCAL_SMOKE_ENGINES = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
3024
+
3025
+
3026
+ async def _smoke_test_loaded_model(
3027
+ resolution: _ModelResolution,
3028
+ *,
3029
+ api_key_override: Optional[str] = None,
3030
+ ) -> Dict[str, object]:
3031
+ """로드 직후 짧은 채팅 테스트를 돌려 ready_to_chat 여부를 판정한다.
3032
+
3033
+ Cloud(OpenAI/Anthropic/OpenRouter 등) 모델은 사용자 비용 발생 가능성 때문에 skip.
3034
+ 실패해도 예외를 던지지 않는다. 결과는 compat_cache에도 기록된다.
3035
+ """
3036
+ if (resolution.engine or "").lower() not in _LOCAL_SMOKE_ENGINES:
3037
+ profile = _ensure_compat_profile(resolution.load_id, resolution.engine)
3038
+ return {
3039
+ "ok": True,
3040
+ "reason": "skipped (cloud model — smoke test would incur cost)",
3041
+ "answer": None,
3042
+ "profile": profile.to_dict(),
3043
+ "skipped": True,
3044
+ }
3045
+ try:
3046
+ text = await asyncio.wait_for(
3047
+ router.generate(
3048
+ _SMOKE_PROMPT,
3049
+ context=None,
3050
+ max_tokens=128,
3051
+ temperature=0.1,
3052
+ ),
3053
+ timeout=30,
3054
+ )
3055
+ except Exception as exc: # pragma: no cover - generator may not exist on all engines
3056
+ reason = str(exc)[:200] or "generation_failed"
3057
+ profile = _record_smoke_result(resolution.load_id, resolution.engine, False, reason)
3058
+ return {
3059
+ "ok": False,
3060
+ "reason": reason,
3061
+ "answer": None,
3062
+ "profile": profile.to_dict(),
3063
+ }
3064
+
3065
+ profile = _ensure_compat_profile(resolution.load_id, resolution.engine)
3066
+ cleaned = _compat_fast_postprocess(str(text or ""), profile.to_dict())
3067
+ ok, reason = _validate_smoke_response(cleaned)
3068
+ profile = _record_smoke_result(resolution.load_id, resolution.engine, ok, reason)
3069
+ return {
3070
+ "ok": ok,
3071
+ "reason": reason,
3072
+ "answer": cleaned,
3073
+ "profile": profile.to_dict(),
3074
+ }
3075
+
3076
+
2942
3077
  async def prepare_and_load_model(
2943
3078
  model_id: str,
2944
3079
  request: Request,
@@ -2951,6 +3086,14 @@ async def prepare_and_load_model(
2951
3086
  if not model_id:
2952
3087
  raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
2953
3088
 
3089
+ # 피드백 #1: ModelResolution을 모든 단계가 공유한다.
3090
+ resolution = _ModelResolution.from_request(
3091
+ model_id,
3092
+ engine=engine,
3093
+ user_email=user_email or get_current_user(request),
3094
+ engine_aliases=MODEL_ENGINE_ALIASES,
3095
+ )
3096
+
2954
3097
  parsed_provider, parsed_model = parse_model_ref(model_id)
2955
3098
  if parsed_provider == "mlx":
2956
3099
  parsed_provider = "local_mlx"
@@ -3008,6 +3151,18 @@ async def prepare_and_load_model(
3008
3151
  api_key_override=user_api_key,
3009
3152
  owner=effective_email or None,
3010
3153
  )
3154
+ # 피드백 #1/#2: 로드 직후 ModelResolution을 실제 current로 동기화하고 smoke test 수행.
3155
+ resolution.update_after_load(actual_current=router.current_model_id)
3156
+ smoke_result: Dict[str, object] = {}
3157
+ ready_to_chat = True
3158
+ compat_status = "ok"
3159
+ try:
3160
+ smoke_result = await _smoke_test_loaded_model(resolution, api_key_override=user_api_key)
3161
+ ready_to_chat = bool(smoke_result.get("ok"))
3162
+ compat_status = "ok" if ready_to_chat else "degraded"
3163
+ except Exception as exc: # never break load on smoke test failures
3164
+ logging.warning("smoke test failed for %s: %s", resolution.load_id, exc)
3165
+ compat_status = "unknown"
3011
3166
  return {
3012
3167
  "status": "ok",
3013
3168
  "message": msg,
@@ -3016,6 +3171,12 @@ async def prepare_and_load_model(
3016
3171
  "engine": parsed_provider,
3017
3172
  "installed_now": bool(install_result.get("installed_now")),
3018
3173
  "download": download_result,
3174
+ "resolution": resolution.to_dict(),
3175
+ "downloaded": True,
3176
+ "loaded": True,
3177
+ "ready_to_chat": ready_to_chat,
3178
+ "compatibility_status": compat_status,
3179
+ "smoke_test": smoke_result,
3019
3180
  }
3020
3181
 
3021
3182
 
@@ -3221,6 +3382,30 @@ async def prepare_and_load_model_stream(
3221
3382
  api_key_override=user_api_key,
3222
3383
  owner=effective_email or None,
3223
3384
  )
3385
+ # 피드백 #1/#2: SSE에도 ModelResolution과 smoke test 결과를 같이 내려준다.
3386
+ resolution_stream = _ModelResolution.from_request(
3387
+ prepared_model_id,
3388
+ engine=prepared_provider,
3389
+ user_email=effective_email or None,
3390
+ engine_aliases=MODEL_ENGINE_ALIASES,
3391
+ )
3392
+ resolution_stream.update_after_load(actual_current=router.current_model_id)
3393
+ yield sse_event("progress", model_download_progress_payload(
3394
+ "smoke_test",
3395
+ "채팅 호환성 테스트 중입니다.",
3396
+ percent=98,
3397
+ indeterminate=True,
3398
+ ))
3399
+ smoke_result: Dict[str, object] = {}
3400
+ ready_to_chat = True
3401
+ compat_status = "ok"
3402
+ try:
3403
+ smoke_result = await _smoke_test_loaded_model(resolution_stream, api_key_override=user_api_key)
3404
+ ready_to_chat = bool(smoke_result.get("ok"))
3405
+ compat_status = "ok" if ready_to_chat else "degraded"
3406
+ except Exception as exc:
3407
+ logging.warning("smoke test (stream) failed for %s: %s", resolution_stream.load_id, exc)
3408
+ compat_status = "unknown"
3224
3409
  result = {
3225
3410
  "status": "ok",
3226
3411
  "message": msg,
@@ -3229,6 +3414,12 @@ async def prepare_and_load_model_stream(
3229
3414
  "engine": prepared_provider,
3230
3415
  "installed_now": bool(isinstance(install_result, dict) and install_result.get("installed_now")),
3231
3416
  "download": download_result,
3417
+ "resolution": resolution_stream.to_dict(),
3418
+ "downloaded": True,
3419
+ "loaded": True,
3420
+ "ready_to_chat": ready_to_chat,
3421
+ "compatibility_status": compat_status,
3422
+ "smoke_test": smoke_result,
3232
3423
  }
3233
3424
  yield sse_event("progress", model_download_progress_payload(
3234
3425
  "done",
@@ -3300,7 +3491,7 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
3300
3491
 
3301
3492
  @app.get("/health")
3302
3493
  async def health(request: Request):
3303
- base = {"status": "ok", "version": "0.2.2", "mode": APP_MODE}
3494
+ base = {"status": "ok", "version": "0.3.0", "mode": APP_MODE}
3304
3495
  if not get_current_user(request) and REQUIRE_AUTH:
3305
3496
  return base
3306
3497
  engines = await asyncio.to_thread(engine_status)
@@ -3455,22 +3646,69 @@ async def set_api_key(req: SetApiKeyRequest, request: Request):
3455
3646
  return {"ok": True, "provider": req.provider, "user_email": target_email, "scope": "user"}
3456
3647
 
3457
3648
 
3649
+ def _recommended_with_engine_options(items: List[Dict[str, object]]) -> List[Dict[str, object]]:
3650
+ """피드백 #1: 추천 모델에 엔진별 선택지(engine_options)를 붙여 내려준다.
3651
+
3652
+ 프론트에서 추천 카드를 누르는 순간 어느 엔진/실제 모델로 다운로드/로드할지가
3653
+ 이미 확정되도록 한다.
3654
+ """
3655
+ out: List[Dict[str, object]] = []
3656
+ for item in items:
3657
+ base = {
3658
+ "id": item["id"],
3659
+ "name": item["name"],
3660
+ "tag": item["tag"],
3661
+ "size": item["size"],
3662
+ "display_name": item.get("name") or item.get("id"),
3663
+ }
3664
+ short_id = str(item["id"]).lower()
3665
+ aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
3666
+ options: List[Dict[str, str]] = []
3667
+ for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
3668
+ real = aliases.get(engine_name)
3669
+ if not real:
3670
+ continue
3671
+ options.append({
3672
+ "engine": engine_name,
3673
+ "model_id": real,
3674
+ "load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
3675
+ })
3676
+ # 어느 엔진도 alias가 없으면 local_mlx 카탈로그 자체를 사용한다.
3677
+ if not options:
3678
+ options.append({
3679
+ "engine": "local_mlx",
3680
+ "model_id": item["id"],
3681
+ "load_id": item["id"],
3682
+ })
3683
+ base["engine_options"] = options
3684
+ base["recommended_engine"] = options[0]["engine"]
3685
+ out.append(base)
3686
+ return out
3687
+
3688
+
3458
3689
  @app.get("/models")
3459
3690
  async def list_models():
3460
3691
  """HuggingFace 추천 모델 목록 및 로드 상태 반환"""
3461
- recommended = [
3462
- {"id": item["id"], "name": item["name"], "tag": item["tag"], "size": item["size"]}
3463
- for item in filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", []))
3464
- ]
3692
+ recommended = _recommended_with_engine_options(
3693
+ list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", [])))
3694
+ )
3465
3695
  return {
3466
3696
  "recommended": recommended,
3467
3697
  "cloud": router.detected_cloud_models(),
3468
3698
  "engines": await asyncio.to_thread(engine_status),
3469
3699
  "loaded": router.loaded_model_ids,
3470
3700
  "current": router.current_model_id,
3701
+ "compat_profiles": _list_compat_profiles(),
3471
3702
  }
3472
3703
 
3473
3704
 
3705
+ @app.get("/models/compat-profiles")
3706
+ async def list_model_compat_profiles(request: Request):
3707
+ """피드백 #3: Model Compatibility Layer 캐시 상태를 조회한다."""
3708
+ require_user(request)
3709
+ return {"profiles": _list_compat_profiles()}
3710
+
3711
+
3474
3712
  # ── Model Management ───────────────────────────────────────────────────────────
3475
3713
 
3476
3714
  @app.post("/models/load")
@@ -13,7 +13,7 @@
13
13
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
14
14
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap">
15
15
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
16
- <link rel="stylesheet" href="/static/lattice-reference.css">
16
+ <link rel="stylesheet" href="/static/lattice-reference.css?v=0.3.3">
17
17
  </head>
18
18
  <body class="lattice-ref-auth">
19
19
  <div class="orb orb-1"></div>
@@ -103,6 +103,6 @@
103
103
  <a href="#" onclick="return false;" id="privacy-link">개인정보 처리방침</a>
104
104
  </footer>
105
105
 
106
- <script src="/static/scripts/account.js"></script>
106
+ <script src="/static/scripts/account.js?v=0.3.3"></script>
107
107
  </body>
108
108
  </html>
package/static/admin.html CHANGED
@@ -14,7 +14,7 @@
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
15
15
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap">
16
16
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
17
- <link rel="stylesheet" href="/static/lattice-reference.css">
17
+ <link rel="stylesheet" href="/static/lattice-reference.css?v=0.3.3">
18
18
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
19
19
  </head>
20
20
 
@@ -256,6 +256,80 @@
256
256
  </section>
257
257
 
258
258
  <section class="admin-view" id="admin-view-security" data-admin-view="security">
259
+ <!-- Security & Audit Command Center (피드백 #5) -->
260
+ <section class="panel" id="security-overview-panel">
261
+ <div class="panel-header">
262
+ <div>
263
+ <h3>AI 보안 감사 콘솔</h3>
264
+ <p>사용자별 위험/준수 채팅 및 파일, 민감정보 유형 분포, 원문 조회 현황을 한눈에 확인합니다.</p>
265
+ </div>
266
+ <div class="panel-tools">
267
+ <div class="export-control">
268
+ <button class="btn" id="security-cc-export-toggle" type="button">
269
+ <i class="ti ti-download"></i>
270
+ <span>보안 리포트 추출</span>
271
+ </button>
272
+ <div class="export-options" id="security-cc-export-options">
273
+ <button class="table-btn" type="button" data-cc-scope="overview" data-cc-format="json">Overview JSON</button>
274
+ <button class="table-btn" type="button" data-cc-scope="users" data-cc-format="csv">User Risk CSV</button>
275
+ <button class="table-btn" type="button" data-cc-scope="users" data-cc-format="xlsx">User Risk Excel</button>
276
+ <button class="table-btn" type="button" data-cc-scope="events" data-cc-format="csv">Events CSV</button>
277
+ <button class="table-btn" type="button" data-cc-scope="events" data-cc-format="json">Events JSON</button>
278
+ <button class="table-btn" type="button" data-cc-scope="overview" data-cc-format="pdf">Security PDF</button>
279
+ </div>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ <div class="panel-body">
284
+ <div class="audit-grid" id="security-cc-cards"></div>
285
+ <div class="two-col" style="margin-top:18px">
286
+ <div class="subpanel">
287
+ <h4><i class="ti ti-users"></i> 사용자별 위험/준수 (채팅 + 파일)</h4>
288
+ <div class="table-wrap" id="security-cc-users">
289
+ <div class="preview" style="padding:14px">불러오는 중...</div>
290
+ </div>
291
+ <canvas id="security-cc-user-chart" height="180" style="margin-top:12px"></canvas>
292
+ </div>
293
+ <div class="subpanel">
294
+ <h4><i class="ti ti-chart-donut"></i> 민감정보 유형 분포</h4>
295
+ <canvas id="security-cc-field-chart" height="200"></canvas>
296
+ <div id="security-cc-field-legend" style="margin-top:10px;font-size:12px;color:var(--muted-text)"></div>
297
+ </div>
298
+ </div>
299
+ <div class="two-col" style="margin-top:18px">
300
+ <div class="subpanel">
301
+ <h4><i class="ti ti-message-2"></i> 민감 채팅 모니터</h4>
302
+ <div class="table-wrap" id="security-cc-chats">
303
+ <div class="preview" style="padding:14px">불러오는 중...</div>
304
+ </div>
305
+ </div>
306
+ <div class="subpanel">
307
+ <h4><i class="ti ti-file-shield"></i> 위험 파일 모니터</h4>
308
+ <div class="table-wrap" id="security-cc-files">
309
+ <div class="preview" style="padding:14px">불러오는 중...</div>
310
+ </div>
311
+ </div>
312
+ </div>
313
+ <div class="two-col" style="margin-top:18px">
314
+ <div class="subpanel">
315
+ <h4><i class="ti ti-timeline"></i> 감사 타임라인</h4>
316
+ <div class="table-wrap" id="security-cc-timeline">
317
+ <div class="preview" style="padding:14px">불러오는 중...</div>
318
+ </div>
319
+ </div>
320
+ <div class="subpanel">
321
+ <h4><i class="ti ti-code"></i> Raw Data Explorer</h4>
322
+ <div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap">
323
+ <button class="table-btn" type="button" data-cc-raw="audit">감사 로그</button>
324
+ <button class="table-btn" type="button" data-cc-raw="history">대화 원문</button>
325
+ <button class="table-btn" type="button" data-cc-raw="files">파일 인덱스</button>
326
+ </div>
327
+ <pre id="security-cc-raw" style="max-height:280px;overflow:auto;background:rgba(0,0,0,.04);padding:12px;border-radius:8px;font-size:12px;white-space:pre-wrap;">선택한 scope의 raw JSON이 여기에 표시됩니다. (관리자 원문 조회는 별도로 감사로그에 기록됩니다.)</pre>
328
+ </div>
329
+ </div>
330
+ </div>
331
+ </section>
332
+
259
333
  <section class="panel">
260
334
  <div class="panel-header">
261
335
  <div>
package/static/chat.html CHANGED
@@ -23,7 +23,7 @@
23
23
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
24
24
 
25
25
  <!-- ── Setup Wizard Styles ──────────────────────────────────────────── -->
26
- <link rel="stylesheet" href="/static/lattice-reference.css">
26
+ <link rel="stylesheet" href="/static/lattice-reference.css?v=0.3.3">
27
27
  </head>
28
28
 
29
29
  <body class="lattice-ref-chat">
@@ -831,7 +831,7 @@
831
831
  </div>
832
832
 
833
833
 
834
- <script src="/static/scripts/chat.js"></script>
834
+ <script src="/static/scripts/chat.js?v=0.3.4"></script>
835
835
  </body>
836
836
 
837
837
  </html>
package/static/graph.html CHANGED
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap">
10
10
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
11
- <link rel="stylesheet" href="/static/lattice-reference.css">
11
+ <link rel="stylesheet" href="/static/lattice-reference.css?v=0.3.3">
12
12
  </head>
13
13
  <body class="lattice-ref-graph">
14
14
  <aside class="reference-rail graph-rail">
@@ -97,6 +97,6 @@
97
97
 
98
98
  <div id="tooltip"></div>
99
99
 
100
- <script src="/static/scripts/graph.js"></script>
100
+ <script src="/static/scripts/graph.js?v=0.3.3"></script>
101
101
  </body>
102
102
  </html>