ltcai 0.3.0 → 0.3.2
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 +47 -7
- package/docs/CHANGELOG.md +117 -0
- package/knowledge_graph_api.py +10 -2
- package/latticeai/api/security_dashboard.py +584 -0
- package/latticeai/core/audit.py +4 -1
- package/latticeai/core/graph_curator.py +473 -0
- package/latticeai/core/model_compat.py +450 -0
- package/latticeai/core/model_resolution.py +227 -0
- package/latticeai/core/timezones.py +80 -0
- package/package.json +2 -2
- package/server.py +265 -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 +173 -11
- package/static/scripts/graph.js +6 -2
- package/static/sw.js +1 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared timezone helper (피드백 #5 / item 7).
|
|
2
|
+
|
|
3
|
+
문제: audit 로그 timestamp는 ``datetime.now()`` (시스템 로컬 시간)으로 기록되는데,
|
|
4
|
+
security_dashboard는 "오늘 이벤트 수"를 ``datetime.utcnow().date()`` (UTC) 기준으로
|
|
5
|
+
계산해서 한국 사용자에게 events_today가 어긋났다.
|
|
6
|
+
|
|
7
|
+
해결: 모든 날짜/시간 기준을 하나의 timezone 헬퍼로 통일한다.
|
|
8
|
+
|
|
9
|
+
- 환경변수 ``LATTICE_TZ`` (또는 호환용 ``LTCAI_TZ``)가 있으면 그 시간대를 쓴다.
|
|
10
|
+
예: ``LATTICE_TZ=Asia/Seoul``.
|
|
11
|
+
- 없으면 시스템 로컬 시간대를 쓴다 (기존 audit timestamp와 동일한 기준).
|
|
12
|
+
|
|
13
|
+
이렇게 하면 "타임스탬프를 쓸 때 쓰는 시간대" === "오늘을 계산할 때 쓰는 시간대" 가
|
|
14
|
+
항상 일치한다.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from datetime import datetime, tzinfo
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
try: # py3.9+
|
|
25
|
+
from zoneinfo import ZoneInfo
|
|
26
|
+
except Exception: # pragma: no cover - 매우 오래된 런타임
|
|
27
|
+
ZoneInfo = None # type: ignore
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_TZ_ENV_VARS = ("LATTICE_TZ", "LTCAI_TZ")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _system_local_tz() -> tzinfo:
|
|
35
|
+
# tz-aware 시스템 로컬 시간대.
|
|
36
|
+
return datetime.now().astimezone().tzinfo # type: ignore[return-value]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_timezone() -> tzinfo:
|
|
40
|
+
"""설정된 시간대를 돌려준다. (환경변수 → 시스템 로컬 순)"""
|
|
41
|
+
for var in _TZ_ENV_VARS:
|
|
42
|
+
name = (os.environ.get(var) or "").strip()
|
|
43
|
+
if not name:
|
|
44
|
+
continue
|
|
45
|
+
if ZoneInfo is None:
|
|
46
|
+
logger.warning("zoneinfo 사용 불가, %s=%s 무시하고 시스템 로컬 사용", var, name)
|
|
47
|
+
break
|
|
48
|
+
try:
|
|
49
|
+
return ZoneInfo(name)
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.warning("알 수 없는 시간대 %s=%s, 시스템 로컬로 대체", var, name)
|
|
52
|
+
break
|
|
53
|
+
return _system_local_tz()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def now(tz: Optional[tzinfo] = None) -> datetime:
|
|
57
|
+
"""설정된 시간대의 현재 시각 (tz-aware)."""
|
|
58
|
+
return datetime.now(tz or get_timezone())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def now_iso(tz: Optional[tzinfo] = None) -> str:
|
|
62
|
+
"""ISO8601 문자열. audit timestamp 기록용."""
|
|
63
|
+
return now(tz).isoformat()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def today_str(tz: Optional[tzinfo] = None) -> str:
|
|
67
|
+
"""오늘 날짜 ``YYYY-MM-DD``. events_today 계산용."""
|
|
68
|
+
return now(tz).date().isoformat()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def tz_name() -> str:
|
|
72
|
+
"""설정된 시간대 이름(있으면 환경변수 값, 없으면 'local')."""
|
|
73
|
+
for var in _TZ_ENV_VARS:
|
|
74
|
+
name = (os.environ.get(var) or "").strip()
|
|
75
|
+
if name:
|
|
76
|
+
return name
|
|
77
|
+
return "local"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["get_timezone", "now", "now_iso", "today_str", "tz_name"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Lattice AI local MLX/cloud LLM workspace server",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test:unit": "python3 -m pytest tests/unit/ -v",
|
|
24
24
|
"test:integration": "python3 -m pytest tests/integration/ -v",
|
|
25
25
|
"publish:npm": "npm publish --access public",
|
|
26
|
-
"publish:pypi": "python3 -m twine upload dist
|
|
26
|
+
"publish:pypi": "python3 -m twine upload --skip-existing dist/*.tar.gz dist/*.whl"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"ltcai",
|
package/server.py
CHANGED
|
@@ -72,6 +72,25 @@ 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
|
+
classify_smoke_response as _classify_smoke_response,
|
|
82
|
+
list_cached_profiles as _list_compat_profiles,
|
|
83
|
+
SMOKE_PROMPT as _SMOKE_PROMPT,
|
|
84
|
+
)
|
|
85
|
+
from latticeai.core.model_resolution import (
|
|
86
|
+
ModelResolution as _ModelResolution,
|
|
87
|
+
PrepareState as _PrepareState,
|
|
88
|
+
PrepareReport as _PrepareReport,
|
|
89
|
+
)
|
|
90
|
+
from latticeai.core.graph_curator import (
|
|
91
|
+
auto_build_graph_overlay as _auto_build_graph_overlay,
|
|
92
|
+
mask_secrets as _curator_mask_secrets,
|
|
93
|
+
)
|
|
75
94
|
import mcp_registry
|
|
76
95
|
from mcp_registry import (
|
|
77
96
|
MCP_REGISTRY, _THIRD_PARTY_SKILL_SOURCES, _KNOWN_REPO_LICENSES,
|
|
@@ -1107,7 +1126,7 @@ async def lifespan(app: FastAPI):
|
|
|
1107
1126
|
except Exception:
|
|
1108
1127
|
pass
|
|
1109
1128
|
|
|
1110
|
-
app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.
|
|
1129
|
+
app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="0.3.2", lifespan=lifespan)
|
|
1111
1130
|
|
|
1112
1131
|
CORS_ALLOWED_ORIGINS = [
|
|
1113
1132
|
f"http://localhost:{DEFAULT_PORT}",
|
|
@@ -1175,19 +1194,64 @@ app.include_router(create_admin_router(
|
|
|
1175
1194
|
default_port=DEFAULT_PORT,
|
|
1176
1195
|
))
|
|
1177
1196
|
|
|
1197
|
+
# ── Security & Audit Command Center (피드백 #5) ──────────────────────────────
|
|
1198
|
+
def _security_audit_events_safe() -> List[Dict]:
|
|
1199
|
+
try:
|
|
1200
|
+
return _get_audit_log(AUDIT_FILE)
|
|
1201
|
+
except Exception as e:
|
|
1202
|
+
logging.warning("security audit events load failed: %s", e)
|
|
1203
|
+
return []
|
|
1204
|
+
|
|
1205
|
+
def _security_list_uploaded_files() -> List[Dict]:
|
|
1206
|
+
"""Audit log에서 document_upload 이벤트를 가공해서 file 목록으로 노출."""
|
|
1207
|
+
files: List[Dict] = []
|
|
1208
|
+
for idx, e in enumerate(_security_audit_events_safe()):
|
|
1209
|
+
if e.get("event_type") != "document_upload":
|
|
1210
|
+
continue
|
|
1211
|
+
files.append({
|
|
1212
|
+
"file_id": str(e.get("filename") or idx),
|
|
1213
|
+
"filename": e.get("filename"),
|
|
1214
|
+
"user_email": e.get("user_email"),
|
|
1215
|
+
"user_nickname": e.get("user_nickname"),
|
|
1216
|
+
"uploaded_at": e.get("timestamp"),
|
|
1217
|
+
"ext": e.get("ext"),
|
|
1218
|
+
"bytes": e.get("bytes"),
|
|
1219
|
+
"sensitivity": e.get("sensitivity") or "none",
|
|
1220
|
+
"sensitive_labels": e.get("sensitive_labels") or [],
|
|
1221
|
+
"content_preview": e.get("content_preview"),
|
|
1222
|
+
})
|
|
1223
|
+
return files
|
|
1224
|
+
|
|
1225
|
+
app.include_router(_create_security_router(
|
|
1226
|
+
require_admin=require_admin,
|
|
1227
|
+
get_history=get_history,
|
|
1228
|
+
get_audit_events=_security_audit_events_safe,
|
|
1229
|
+
classify_sensitive_message=classify_sensitive_message,
|
|
1230
|
+
build_sensitivity_report=build_sensitivity_report,
|
|
1231
|
+
list_uploaded_files=_security_list_uploaded_files,
|
|
1232
|
+
append_audit_event=append_audit_event,
|
|
1233
|
+
))
|
|
1234
|
+
|
|
1235
|
+
def ui_file_response(path: Path) -> FileResponse:
|
|
1236
|
+
response = FileResponse(path)
|
|
1237
|
+
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
1238
|
+
response.headers["Pragma"] = "no-cache"
|
|
1239
|
+
response.headers["Expires"] = "0"
|
|
1240
|
+
return response
|
|
1241
|
+
|
|
1178
1242
|
@app.get("/")
|
|
1179
1243
|
async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
|
|
1180
1244
|
"""로그인/회원가입 페이지. 초대 게이트 활성화 시 코드 검증 후 진입."""
|
|
1181
1245
|
if not INVITE_GATE_ENABLED:
|
|
1182
|
-
return
|
|
1246
|
+
return ui_file_response(STATIC_DIR / "account.html")
|
|
1183
1247
|
|
|
1184
1248
|
# 1. 이미 쿠키로 인증된 경우
|
|
1185
1249
|
if authorized == "true":
|
|
1186
|
-
return
|
|
1250
|
+
return ui_file_response(STATIC_DIR / "account.html")
|
|
1187
1251
|
|
|
1188
1252
|
# 2. 초대 코드가 일치하는 경우 (최초 진입)
|
|
1189
1253
|
if code == INVITE_CODE:
|
|
1190
|
-
response =
|
|
1254
|
+
response = ui_file_response(STATIC_DIR / "account.html")
|
|
1191
1255
|
response.set_cookie(key="authorized", value="true", httponly=True, samesite="lax", max_age=60*60*24*7)
|
|
1192
1256
|
return response
|
|
1193
1257
|
|
|
@@ -1207,7 +1271,7 @@ async def root(request: Request, code: Optional[str] = None, authorized: Optiona
|
|
|
1207
1271
|
@app.get("/account")
|
|
1208
1272
|
async def account_page():
|
|
1209
1273
|
"""Direct login/register page route used by logout and manual navigation."""
|
|
1210
|
-
return
|
|
1274
|
+
return ui_file_response(STATIC_DIR / "account.html")
|
|
1211
1275
|
|
|
1212
1276
|
|
|
1213
1277
|
@app.get("/manifest.json")
|
|
@@ -1230,7 +1294,7 @@ async def service_worker():
|
|
|
1230
1294
|
|
|
1231
1295
|
@app.get("/chat")
|
|
1232
1296
|
async def chat_page(request: Request):
|
|
1233
|
-
return
|
|
1297
|
+
return ui_file_response(STATIC_DIR / "chat.html")
|
|
1234
1298
|
|
|
1235
1299
|
|
|
1236
1300
|
@app.get("/admin")
|
|
@@ -1963,15 +2027,11 @@ def get_lmstudio_models(*, force: bool = False) -> List[Dict[str, object]]:
|
|
|
1963
2027
|
global _LMSTUDIO_MODELS_CACHE, _LMSTUDIO_MODELS_CACHE_TS
|
|
1964
2028
|
if not force and time.monotonic() - _LMSTUDIO_MODELS_CACHE_TS < _LMSTUDIO_MODELS_CACHE_TTL:
|
|
1965
2029
|
return _LMSTUDIO_MODELS_CACHE
|
|
1966
|
-
try:
|
|
1967
|
-
ensure_lmstudio_server()
|
|
1968
|
-
except HTTPException:
|
|
1969
|
-
return _LMSTUDIO_MODELS_CACHE
|
|
1970
2030
|
try:
|
|
1971
2031
|
payload = _json_request(
|
|
1972
2032
|
f"{lmstudio_native_api_base()}/api/v1/models",
|
|
1973
2033
|
headers={"Authorization": f"Bearer {os.getenv('LMSTUDIO_API_KEY') or 'lmstudio'}"},
|
|
1974
|
-
timeout=5,
|
|
2034
|
+
timeout=2.5,
|
|
1975
2035
|
)
|
|
1976
2036
|
except Exception:
|
|
1977
2037
|
return _LMSTUDIO_MODELS_CACHE
|
|
@@ -2939,6 +2999,90 @@ def ensure_engine_ready(engine: str) -> Dict[str, object]:
|
|
|
2939
2999
|
return {"engine": engine, "installed": True, "installed_now": True, "install": result}
|
|
2940
3000
|
|
|
2941
3001
|
|
|
3002
|
+
def build_model_resolution(
|
|
3003
|
+
input_id: str,
|
|
3004
|
+
engine: Optional[str],
|
|
3005
|
+
*,
|
|
3006
|
+
user_email: Optional[str] = None,
|
|
3007
|
+
display_name: Optional[str] = None,
|
|
3008
|
+
) -> _ModelResolution:
|
|
3009
|
+
"""피드백 #1/#2 공용 ModelResolution 생성기.
|
|
3010
|
+
|
|
3011
|
+
사용자가 클릭한 input_id + engine 힌트를 받아 모든 단계가 공유할
|
|
3012
|
+
canonical identity를 만든다.
|
|
3013
|
+
"""
|
|
3014
|
+
normalized = normalize_local_model_request(input_id, engine)
|
|
3015
|
+
return _ModelResolution.from_request(
|
|
3016
|
+
normalized,
|
|
3017
|
+
engine=engine,
|
|
3018
|
+
user_email=user_email,
|
|
3019
|
+
display_name=display_name or input_id,
|
|
3020
|
+
engine_aliases=MODEL_ENGINE_ALIASES,
|
|
3021
|
+
)
|
|
3022
|
+
|
|
3023
|
+
|
|
3024
|
+
_LOCAL_SMOKE_ENGINES = {"local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"}
|
|
3025
|
+
|
|
3026
|
+
|
|
3027
|
+
async def _smoke_test_loaded_model(
|
|
3028
|
+
resolution: _ModelResolution,
|
|
3029
|
+
*,
|
|
3030
|
+
api_key_override: Optional[str] = None,
|
|
3031
|
+
) -> Dict[str, object]:
|
|
3032
|
+
"""로드 직후 짧은 채팅 테스트를 돌려 ready_to_chat 여부를 판정한다.
|
|
3033
|
+
|
|
3034
|
+
Cloud(OpenAI/Anthropic/OpenRouter 등) 모델은 사용자 비용 발생 가능성 때문에 skip.
|
|
3035
|
+
실패해도 예외를 던지지 않는다. 결과는 compat_cache에도 기록된다.
|
|
3036
|
+
"""
|
|
3037
|
+
if (resolution.engine or "").lower() not in _LOCAL_SMOKE_ENGINES:
|
|
3038
|
+
profile = _ensure_compat_profile(resolution.load_id, resolution.engine)
|
|
3039
|
+
return {
|
|
3040
|
+
"ok": True,
|
|
3041
|
+
"reason": "skipped (cloud model — smoke test would incur cost)",
|
|
3042
|
+
"answer": None,
|
|
3043
|
+
"profile": profile.to_dict(),
|
|
3044
|
+
"skipped": True,
|
|
3045
|
+
}
|
|
3046
|
+
try:
|
|
3047
|
+
text = await asyncio.wait_for(
|
|
3048
|
+
router.generate(
|
|
3049
|
+
_SMOKE_PROMPT,
|
|
3050
|
+
context=None,
|
|
3051
|
+
max_tokens=128,
|
|
3052
|
+
temperature=0.1,
|
|
3053
|
+
),
|
|
3054
|
+
timeout=30,
|
|
3055
|
+
)
|
|
3056
|
+
except Exception as exc: # pragma: no cover - generator may not exist on all engines
|
|
3057
|
+
reason = str(exc)[:200] or "generation_failed"
|
|
3058
|
+
profile = _record_smoke_result(
|
|
3059
|
+
resolution.load_id, resolution.engine, False, reason, status="failed"
|
|
3060
|
+
)
|
|
3061
|
+
return {
|
|
3062
|
+
"ok": False,
|
|
3063
|
+
"status": "failed",
|
|
3064
|
+
"reason": reason,
|
|
3065
|
+
"answer": None,
|
|
3066
|
+
"profile": profile.to_dict(),
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
profile = _ensure_compat_profile(resolution.load_id, resolution.engine)
|
|
3070
|
+
cleaned = _compat_fast_postprocess(str(text or ""), profile.to_dict())
|
|
3071
|
+
# item 3-3: ok / degraded / failed 3분류. degraded는 채팅은 가능하다.
|
|
3072
|
+
status, reason = _classify_smoke_response(cleaned)
|
|
3073
|
+
ok = status != "failed"
|
|
3074
|
+
profile = _record_smoke_result(
|
|
3075
|
+
resolution.load_id, resolution.engine, ok, reason, status=status
|
|
3076
|
+
)
|
|
3077
|
+
return {
|
|
3078
|
+
"ok": ok,
|
|
3079
|
+
"status": status,
|
|
3080
|
+
"reason": reason,
|
|
3081
|
+
"answer": cleaned,
|
|
3082
|
+
"profile": profile.to_dict(),
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
|
|
2942
3086
|
async def prepare_and_load_model(
|
|
2943
3087
|
model_id: str,
|
|
2944
3088
|
request: Request,
|
|
@@ -2951,6 +3095,14 @@ async def prepare_and_load_model(
|
|
|
2951
3095
|
if not model_id:
|
|
2952
3096
|
raise HTTPException(status_code=400, detail="모델 식별자가 비어 있습니다.")
|
|
2953
3097
|
|
|
3098
|
+
# 피드백 #1: ModelResolution을 모든 단계가 공유한다.
|
|
3099
|
+
resolution = _ModelResolution.from_request(
|
|
3100
|
+
model_id,
|
|
3101
|
+
engine=engine,
|
|
3102
|
+
user_email=user_email or get_current_user(request),
|
|
3103
|
+
engine_aliases=MODEL_ENGINE_ALIASES,
|
|
3104
|
+
)
|
|
3105
|
+
|
|
2954
3106
|
parsed_provider, parsed_model = parse_model_ref(model_id)
|
|
2955
3107
|
if parsed_provider == "mlx":
|
|
2956
3108
|
parsed_provider = "local_mlx"
|
|
@@ -3008,6 +3160,19 @@ async def prepare_and_load_model(
|
|
|
3008
3160
|
api_key_override=user_api_key,
|
|
3009
3161
|
owner=effective_email or None,
|
|
3010
3162
|
)
|
|
3163
|
+
# 피드백 #1/#2: 로드 직후 ModelResolution을 실제 current로 동기화하고 smoke test 수행.
|
|
3164
|
+
resolution.update_after_load(actual_current=router.current_model_id)
|
|
3165
|
+
smoke_result: Dict[str, object] = {}
|
|
3166
|
+
ready_to_chat = True
|
|
3167
|
+
compat_status = "ok"
|
|
3168
|
+
try:
|
|
3169
|
+
smoke_result = await _smoke_test_loaded_model(resolution, api_key_override=user_api_key)
|
|
3170
|
+
ready_to_chat = bool(smoke_result.get("ok"))
|
|
3171
|
+
# item 3-3: smoke 결과의 3분류(ok/degraded/failed)를 그대로 노출한다.
|
|
3172
|
+
compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
|
|
3173
|
+
except Exception as exc: # never break load on smoke test failures
|
|
3174
|
+
logging.warning("smoke test failed for %s: %s", resolution.load_id, exc)
|
|
3175
|
+
compat_status = "unknown"
|
|
3011
3176
|
return {
|
|
3012
3177
|
"status": "ok",
|
|
3013
3178
|
"message": msg,
|
|
@@ -3016,6 +3181,12 @@ async def prepare_and_load_model(
|
|
|
3016
3181
|
"engine": parsed_provider,
|
|
3017
3182
|
"installed_now": bool(install_result.get("installed_now")),
|
|
3018
3183
|
"download": download_result,
|
|
3184
|
+
"resolution": resolution.to_dict(),
|
|
3185
|
+
"downloaded": True,
|
|
3186
|
+
"loaded": True,
|
|
3187
|
+
"ready_to_chat": ready_to_chat,
|
|
3188
|
+
"compatibility_status": compat_status,
|
|
3189
|
+
"smoke_test": smoke_result,
|
|
3019
3190
|
}
|
|
3020
3191
|
|
|
3021
3192
|
|
|
@@ -3221,6 +3392,31 @@ async def prepare_and_load_model_stream(
|
|
|
3221
3392
|
api_key_override=user_api_key,
|
|
3222
3393
|
owner=effective_email or None,
|
|
3223
3394
|
)
|
|
3395
|
+
# 피드백 #1/#2: SSE에도 ModelResolution과 smoke test 결과를 같이 내려준다.
|
|
3396
|
+
resolution_stream = _ModelResolution.from_request(
|
|
3397
|
+
prepared_model_id,
|
|
3398
|
+
engine=prepared_provider,
|
|
3399
|
+
user_email=effective_email or None,
|
|
3400
|
+
engine_aliases=MODEL_ENGINE_ALIASES,
|
|
3401
|
+
)
|
|
3402
|
+
resolution_stream.update_after_load(actual_current=router.current_model_id)
|
|
3403
|
+
yield sse_event("progress", model_download_progress_payload(
|
|
3404
|
+
"smoke_test",
|
|
3405
|
+
"채팅 호환성 테스트 중입니다.",
|
|
3406
|
+
percent=98,
|
|
3407
|
+
indeterminate=True,
|
|
3408
|
+
))
|
|
3409
|
+
smoke_result: Dict[str, object] = {}
|
|
3410
|
+
ready_to_chat = True
|
|
3411
|
+
compat_status = "ok"
|
|
3412
|
+
try:
|
|
3413
|
+
smoke_result = await _smoke_test_loaded_model(resolution_stream, api_key_override=user_api_key)
|
|
3414
|
+
ready_to_chat = bool(smoke_result.get("ok"))
|
|
3415
|
+
# item 3-3: smoke 결과의 3분류(ok/degraded/failed)를 그대로 노출한다.
|
|
3416
|
+
compat_status = str(smoke_result.get("status") or ("ok" if ready_to_chat else "degraded"))
|
|
3417
|
+
except Exception as exc:
|
|
3418
|
+
logging.warning("smoke test (stream) failed for %s: %s", resolution_stream.load_id, exc)
|
|
3419
|
+
compat_status = "unknown"
|
|
3224
3420
|
result = {
|
|
3225
3421
|
"status": "ok",
|
|
3226
3422
|
"message": msg,
|
|
@@ -3229,6 +3425,12 @@ async def prepare_and_load_model_stream(
|
|
|
3229
3425
|
"engine": prepared_provider,
|
|
3230
3426
|
"installed_now": bool(isinstance(install_result, dict) and install_result.get("installed_now")),
|
|
3231
3427
|
"download": download_result,
|
|
3428
|
+
"resolution": resolution_stream.to_dict(),
|
|
3429
|
+
"downloaded": True,
|
|
3430
|
+
"loaded": True,
|
|
3431
|
+
"ready_to_chat": ready_to_chat,
|
|
3432
|
+
"compatibility_status": compat_status,
|
|
3433
|
+
"smoke_test": smoke_result,
|
|
3232
3434
|
}
|
|
3233
3435
|
yield sse_event("progress", model_download_progress_payload(
|
|
3234
3436
|
"done",
|
|
@@ -3300,7 +3502,7 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
|
|
|
3300
3502
|
|
|
3301
3503
|
@app.get("/health")
|
|
3302
3504
|
async def health(request: Request):
|
|
3303
|
-
base = {"status": "ok", "version": "0.
|
|
3505
|
+
base = {"status": "ok", "version": "0.3.2", "mode": APP_MODE}
|
|
3304
3506
|
if not get_current_user(request) and REQUIRE_AUTH:
|
|
3305
3507
|
return base
|
|
3306
3508
|
engines = await asyncio.to_thread(engine_status)
|
|
@@ -3455,22 +3657,69 @@ async def set_api_key(req: SetApiKeyRequest, request: Request):
|
|
|
3455
3657
|
return {"ok": True, "provider": req.provider, "user_email": target_email, "scope": "user"}
|
|
3456
3658
|
|
|
3457
3659
|
|
|
3660
|
+
def _recommended_with_engine_options(items: List[Dict[str, object]]) -> List[Dict[str, object]]:
|
|
3661
|
+
"""피드백 #1: 추천 모델에 엔진별 선택지(engine_options)를 붙여 내려준다.
|
|
3662
|
+
|
|
3663
|
+
프론트에서 추천 카드를 누르는 순간 어느 엔진/실제 모델로 다운로드/로드할지가
|
|
3664
|
+
이미 확정되도록 한다.
|
|
3665
|
+
"""
|
|
3666
|
+
out: List[Dict[str, object]] = []
|
|
3667
|
+
for item in items:
|
|
3668
|
+
base = {
|
|
3669
|
+
"id": item["id"],
|
|
3670
|
+
"name": item["name"],
|
|
3671
|
+
"tag": item["tag"],
|
|
3672
|
+
"size": item["size"],
|
|
3673
|
+
"display_name": item.get("name") or item.get("id"),
|
|
3674
|
+
}
|
|
3675
|
+
short_id = str(item["id"]).lower()
|
|
3676
|
+
aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
|
|
3677
|
+
options: List[Dict[str, str]] = []
|
|
3678
|
+
for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
|
|
3679
|
+
real = aliases.get(engine_name)
|
|
3680
|
+
if not real:
|
|
3681
|
+
continue
|
|
3682
|
+
options.append({
|
|
3683
|
+
"engine": engine_name,
|
|
3684
|
+
"model_id": real,
|
|
3685
|
+
"load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
|
|
3686
|
+
})
|
|
3687
|
+
# 어느 엔진도 alias가 없으면 local_mlx 카탈로그 자체를 사용한다.
|
|
3688
|
+
if not options:
|
|
3689
|
+
options.append({
|
|
3690
|
+
"engine": "local_mlx",
|
|
3691
|
+
"model_id": item["id"],
|
|
3692
|
+
"load_id": item["id"],
|
|
3693
|
+
})
|
|
3694
|
+
base["engine_options"] = options
|
|
3695
|
+
base["recommended_engine"] = options[0]["engine"]
|
|
3696
|
+
out.append(base)
|
|
3697
|
+
return out
|
|
3698
|
+
|
|
3699
|
+
|
|
3458
3700
|
@app.get("/models")
|
|
3459
3701
|
async def list_models():
|
|
3460
3702
|
"""HuggingFace 추천 모델 목록 및 로드 상태 반환"""
|
|
3461
|
-
recommended =
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
]
|
|
3703
|
+
recommended = _recommended_with_engine_options(
|
|
3704
|
+
list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", [])))
|
|
3705
|
+
)
|
|
3465
3706
|
return {
|
|
3466
3707
|
"recommended": recommended,
|
|
3467
3708
|
"cloud": router.detected_cloud_models(),
|
|
3468
3709
|
"engines": await asyncio.to_thread(engine_status),
|
|
3469
3710
|
"loaded": router.loaded_model_ids,
|
|
3470
3711
|
"current": router.current_model_id,
|
|
3712
|
+
"compat_profiles": _list_compat_profiles(),
|
|
3471
3713
|
}
|
|
3472
3714
|
|
|
3473
3715
|
|
|
3716
|
+
@app.get("/models/compat-profiles")
|
|
3717
|
+
async def list_model_compat_profiles(request: Request):
|
|
3718
|
+
"""피드백 #3: Model Compatibility Layer 캐시 상태를 조회한다."""
|
|
3719
|
+
require_user(request)
|
|
3720
|
+
return {"profiles": _list_compat_profiles()}
|
|
3721
|
+
|
|
3722
|
+
|
|
3474
3723
|
# ── Model Management ───────────────────────────────────────────────────────────
|
|
3475
3724
|
|
|
3476
3725
|
@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>
|