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/README.md +24 -0
- package/docs/CHANGELOG.md +82 -0
- package/knowledge_graph_api.py +10 -2
- package/latticeai/api/security_dashboard.py +580 -0
- package/latticeai/core/graph_curator.py +417 -0
- package/latticeai/core/model_compat.py +407 -0
- package/latticeai/core/model_resolution.py +227 -0
- package/package.json +1 -1
- package/server.py +254 -16
- package/static/account.html +2 -2
- package/static/admin.html +75 -1
- package/static/chat.html +2 -2
- package/static/graph.html +2 -2
- package/static/lattice-reference.css +82 -50
- package/static/scripts/account.js +10 -2
- package/static/scripts/admin.js +296 -0
- package/static/scripts/chat.js +82 -9
- package/static/scripts/graph.js +6 -2
- package/static/sw.js +1 -1
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.
|
|
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
|
|
1245
|
+
return ui_file_response(STATIC_DIR / "account.html")
|
|
1183
1246
|
|
|
1184
1247
|
# 1. 이미 쿠키로 인증된 경우
|
|
1185
1248
|
if authorized == "true":
|
|
1186
|
-
return
|
|
1249
|
+
return ui_file_response(STATIC_DIR / "account.html")
|
|
1187
1250
|
|
|
1188
1251
|
# 2. 초대 코드가 일치하는 경우 (최초 진입)
|
|
1189
1252
|
if code == INVITE_CODE:
|
|
1190
|
-
response =
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
3463
|
-
|
|
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")
|
package/static/account.html
CHANGED
|
@@ -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>
|