Phase G5: Local Agent / Privacy Mode — 4 tables, 19 API endpoints, platform_bridge MCP, PrivacySettings.tsx (740 lines). 25/25 tests pass.
This commit is contained in:
1 parent
0486210133
commit
891d1ce527
7 files changed
+1681
-2
No files matched your search
@@ -0,0 +1,799 @@
|
||||
"""
|
||||
Phase G5: Privacy & Local Agent Administration API
|
||||
- Privacy config management (local/cloud/hybrid modes)
|
||||
- Offline license generation & validation (JWT-based)
|
||||
- Knowledge base sync state tracking
|
||||
- Anonymized usage reporting
|
||||
"""
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ...database import SessionLocal
|
||||
from ...auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PRIVACY_SECRET = "g5-privacy-secret-key-2026"
|
||||
LICENSE_SECRET = "g5-license-hmac-secret-2026"
|
||||
|
||||
# ========================================================================
|
||||
# Helpers
|
||||
# ========================================================================
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _execute_raw(db: Session, sql_text, params=None):
|
||||
"""Execute raw SQL and return results"""
|
||||
result = db.execute(sql_text, params or {})
|
||||
if result.returns_rows:
|
||||
return result.fetchall()
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
def _row_to_dict(row, keys):
|
||||
"""Convert raw SQL row to dict"""
|
||||
if row is None:
|
||||
return None
|
||||
d = {}
|
||||
for i, k in enumerate(keys):
|
||||
val = row[i]
|
||||
if isinstance(val, datetime):
|
||||
val = val.strftime("%Y-%m-%d %H:%M:%S")
|
||||
d[k] = val
|
||||
return d
|
||||
|
||||
def _rows_to_list(rows, keys):
|
||||
return [_row_to_dict(r, keys) for r in rows]
|
||||
|
||||
def _anonymize_user_id(user_id: str) -> str:
|
||||
"""SHA256 hash for anonymized usage reporting"""
|
||||
return hashlib.sha256(f"{user_id}:{PRIVACY_SECRET}".encode()).hexdigest()
|
||||
|
||||
def _generate_offline_license(user_id: str, agent_id: str, max_days: int = 7) -> dict:
|
||||
"""Generate JWT-like offline license with HMAC signature"""
|
||||
now = int(time.time())
|
||||
expires_at = now + max_days * 86400
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"issued_at": now,
|
||||
"expires_at": expires_at,
|
||||
"max_offline_days": max_days,
|
||||
"jti": uuid.uuid4().hex[:16],
|
||||
"type": "offline_license"
|
||||
}
|
||||
payload_bytes = json.dumps(payload, sort_keys=True).encode()
|
||||
signature = hmac.new(
|
||||
LICENSE_SECRET.encode(),
|
||||
payload_bytes,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
license_key = f"{payload_bytes.decode()}.{signature}"
|
||||
return {
|
||||
"license_key": license_key,
|
||||
"payload": payload,
|
||||
"expires_at": datetime.fromtimestamp(expires_at),
|
||||
}
|
||||
|
||||
def _validate_offline_license(license_key: str) -> dict:
|
||||
"""Validate offline license signature and expiry"""
|
||||
if "." not in license_key:
|
||||
raise ValueError("Invalid license format")
|
||||
payload_str, signature = license_key.rsplit(".", 1)
|
||||
expected_sig = hmac.new(
|
||||
LICENSE_SECRET.encode(),
|
||||
payload_str.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected_sig):
|
||||
raise ValueError("Invalid license signature")
|
||||
payload = json.loads(payload_str)
|
||||
now = int(time.time())
|
||||
if now > payload["expires_at"]:
|
||||
raise ValueError("License expired")
|
||||
return payload
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 1. Privacy Config Management
|
||||
# ========================================================================
|
||||
@router.get("/config")
|
||||
def get_privacy_config(user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
|
||||
"""Get user's privacy configuration"""
|
||||
result = _execute_raw(db, text(
|
||||
"SELECT * FROM privacy_configs WHERE user_id = :uid ORDER BY updated_at DESC LIMIT 1"
|
||||
), {"uid": user_id})
|
||||
keys = ["id", "user_id", "agent_id", "privacy_mode", "local_llm_provider",
|
||||
"local_llm_model", "local_llm_endpoint", "data_retention_days",
|
||||
"anonymize_usage", "sync_knowledge_bases", "sync_frequency",
|
||||
"last_sync_at", "status", "created_at", "updated_at"]
|
||||
if result:
|
||||
row = result[0]
|
||||
config = _row_to_dict(row, keys)
|
||||
if config.get("sync_knowledge_bases"):
|
||||
try:
|
||||
config["sync_knowledge_bases"] = json.loads(config["sync_knowledge_bases"])
|
||||
except Exception:
|
||||
pass
|
||||
return {"code": 0, "data": config}
|
||||
# Return default config
|
||||
return {"code": 0, "data": {
|
||||
"privacy_mode": "cloud",
|
||||
"local_llm_provider": None,
|
||||
"local_llm_model": None,
|
||||
"local_llm_endpoint": None,
|
||||
"data_retention_days": 30,
|
||||
"anonymize_usage": True,
|
||||
"sync_knowledge_bases": [],
|
||||
"sync_frequency": "daily",
|
||||
"status": "active"
|
||||
}}
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
def update_privacy_config(
|
||||
privacy_mode: str = Query(None),
|
||||
local_llm_provider: str = Query(None),
|
||||
local_llm_model: str = Query(None),
|
||||
local_llm_endpoint: str = Query(None),
|
||||
data_retention_days: int = Query(None),
|
||||
anonymize_usage: bool = Query(None),
|
||||
sync_knowledge_bases: str = Query(None),
|
||||
sync_frequency: str = Query(None),
|
||||
user_id: str = Query("admin-001"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update privacy configuration"""
|
||||
valid_modes = ["local", "cloud", "hybrid"]
|
||||
valid_freqs = ["realtime", "hourly", "daily", "manual"]
|
||||
|
||||
if privacy_mode and privacy_mode not in valid_modes:
|
||||
raise HTTPException(400, f"Invalid mode: {privacy_mode}")
|
||||
if sync_frequency and sync_frequency not in valid_freqs:
|
||||
raise HTTPException(400, f"Invalid frequency: {sync_frequency}")
|
||||
|
||||
# Check if config exists
|
||||
existing = _execute_raw(db, text(
|
||||
"SELECT id FROM privacy_configs WHERE user_id = :uid"
|
||||
), {"uid": user_id})
|
||||
|
||||
sets = []
|
||||
params = {"uid": user_id}
|
||||
if privacy_mode:
|
||||
sets.append("privacy_mode = :mode")
|
||||
params["mode"] = privacy_mode
|
||||
if local_llm_provider is not None:
|
||||
sets.append("local_llm_provider = :provider")
|
||||
params["provider"] = local_llm_provider
|
||||
if local_llm_model is not None:
|
||||
sets.append("local_llm_model = :model")
|
||||
params["model"] = local_llm_model
|
||||
if local_llm_endpoint is not None:
|
||||
sets.append("local_llm_endpoint = :endpoint")
|
||||
params["endpoint"] = local_llm_endpoint
|
||||
if data_retention_days is not None:
|
||||
sets.append("data_retention_days = :retention")
|
||||
params["retention"] = data_retention_days
|
||||
if anonymize_usage is not None:
|
||||
sets.append("anonymize_usage = :anon")
|
||||
params["anon"] = 1 if anonymize_usage else 0
|
||||
if sync_knowledge_bases is not None:
|
||||
sets.append("sync_knowledge_bases = :kb_ids")
|
||||
params["kb_ids"] = sync_knowledge_bases
|
||||
if sync_frequency:
|
||||
sets.append("sync_frequency = :freq")
|
||||
params["freq"] = sync_frequency
|
||||
|
||||
if not existing:
|
||||
sets.append("user_id = :uid")
|
||||
sets.append("created_at = NOW()")
|
||||
sets.append("updated_at = NOW()")
|
||||
sql = f"INSERT INTO privacy_configs SET {', '.join(sets)}"
|
||||
else:
|
||||
sets.append("updated_at = NOW()")
|
||||
sql = f"UPDATE privacy_configs SET {', '.join(sets)} WHERE user_id = :uid"
|
||||
|
||||
_execute_raw(db, text(sql), params)
|
||||
return {"code": 0, "message": "Config updated"}
|
||||
|
||||
|
||||
@router.get("/modes")
|
||||
def list_privacy_modes():
|
||||
"""List available privacy modes with descriptions"""
|
||||
return {"code": 0, "data": [
|
||||
{
|
||||
"mode": "cloud",
|
||||
"name": "云端模式",
|
||||
"description": "所有数据和计算在云端处理,适用于一般场景",
|
||||
"pros": ["无需本地资源", "完整的平台功能", "自动更新"],
|
||||
"cons": ["数据离开本地", "需要持续网络连接"],
|
||||
"recommended_for": ["一般用户", "非敏感数据"]
|
||||
},
|
||||
{
|
||||
"mode": "local",
|
||||
"name": "本地模式",
|
||||
"description": "数据完全本地处理,仅通过Platform Bridge同步配置",
|
||||
"pros": ["数据不离开本地", "完全隐私保护", "离线可用"],
|
||||
"cons": ["需要本地GPU/NPU", "功能有限", "需手动同步"],
|
||||
"recommended_for": ["政务", "金融", "医疗"]
|
||||
},
|
||||
{
|
||||
"mode": "hybrid",
|
||||
"name": "混合模式",
|
||||
"description": "敏感数据本地处理,通用能力云端调用",
|
||||
"pros": ["灵活配置", "平衡隐私与功能"],
|
||||
"cons": ["配置复杂", "需要网络连接"],
|
||||
"recommended_for": ["企业级用户", "合规需求"]
|
||||
}
|
||||
]}
|
||||
|
||||
|
||||
@router.post("/switch-mode")
|
||||
def switch_privacy_mode(
|
||||
mode: str = Query(...),
|
||||
user_id: str = Query("admin-001"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Switch privacy mode with validation"""
|
||||
valid_modes = ["local", "cloud", "hybrid"]
|
||||
if mode not in valid_modes:
|
||||
raise HTTPException(400, f"Invalid mode. Must be one of: {valid_modes}")
|
||||
|
||||
existing = _execute_raw(db, text(
|
||||
"SELECT id FROM privacy_configs WHERE user_id = :uid"
|
||||
), {"uid": user_id})
|
||||
|
||||
if existing:
|
||||
_execute_raw(db, text(
|
||||
"UPDATE privacy_configs SET privacy_mode = :mode, updated_at = NOW() WHERE user_id = :uid"
|
||||
), {"mode": mode, "uid": user_id})
|
||||
else:
|
||||
_execute_raw(db, text(
|
||||
"INSERT INTO privacy_configs (user_id, privacy_mode, created_at, updated_at) VALUES (:uid, :mode, NOW(), NOW())"
|
||||
), {"uid": user_id, "mode": mode})
|
||||
|
||||
return {"code": 0, "message": f"Switched to {mode} mode", "data": {"mode": mode}}
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 2. Offline License Management
|
||||
# ========================================================================
|
||||
@router.post("/licenses/generate")
|
||||
def generate_offline_license(
|
||||
agent_id: str = Query(None),
|
||||
max_offline_days: int = Query(7),
|
||||
device_fingerprint: str = Query(None),
|
||||
user_id: str = Query("admin-001"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Generate a new offline license"""
|
||||
if max_offline_days < 1 or max_offline_days > 90:
|
||||
raise HTTPException(400, "max_offline_days must be between 1 and 90")
|
||||
|
||||
license_info = _generate_offline_license(
|
||||
user_id, agent_id or "default", max_offline_days
|
||||
)
|
||||
license_key = license_info["license_key"]
|
||||
payload = license_info["payload"]
|
||||
|
||||
_execute_raw(db, text("""
|
||||
INSERT INTO offline_licenses (license_key, user_id, agent_id, device_fingerprint,
|
||||
issued_at, expires_at, max_offline_days, status)
|
||||
VALUES (:lk, :uid, :aid, :fp, NOW(), :exp, :max_days, 'active')
|
||||
"""), {
|
||||
"lk": license_key,
|
||||
"uid": user_id,
|
||||
"aid": agent_id,
|
||||
"fp": device_fingerprint,
|
||||
"exp": datetime.fromtimestamp(payload["expires_at"]),
|
||||
"max_days": max_offline_days,
|
||||
})
|
||||
|
||||
return {"code": 0, "data": {
|
||||
"license_key": license_key,
|
||||
"issued_at": payload["issued_at"],
|
||||
"expires_at": payload["expires_at"],
|
||||
"max_offline_days": max_offline_days,
|
||||
"status": "active"
|
||||
}}
|
||||
|
||||
|
||||
@router.get("/licenses")
|
||||
def list_offline_licenses(
|
||||
user_id: str = Query("admin-001"),
|
||||
status: str = Query(None),
|
||||
page: int = Query(1),
|
||||
page_size: int = Query(20),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List user's offline licenses"""
|
||||
where = "WHERE user_id = :uid"
|
||||
params = {"uid": user_id}
|
||||
if status:
|
||||
where += " AND status = :st"
|
||||
params["st"] = status
|
||||
|
||||
count_row = _execute_raw(db, text(
|
||||
f"SELECT COUNT(*) FROM offline_licenses {where}"
|
||||
), params)
|
||||
total = count_row[0][0] if count_row else 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
rows = _execute_raw(db, text(
|
||||
f"SELECT * FROM offline_licenses {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
||||
), {**params, "limit": page_size, "offset": offset})
|
||||
keys = ["id", "license_key", "user_id", "agent_id", "device_fingerprint",
|
||||
"issued_at", "expires_at", "last_validated_at", "max_offline_days",
|
||||
"is_active", "revoked_reason", "status", "created_at", "updated_at"]
|
||||
|
||||
return {"code": 0, "data": {
|
||||
"items": _rows_to_list(rows, keys),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}}
|
||||
|
||||
|
||||
@router.get("/licenses/{license_id}")
|
||||
def get_license_detail(license_id: int, db: Session = Depends(get_db)):
|
||||
"""Get offline license detail"""
|
||||
result = _execute_raw(db, text(
|
||||
"SELECT * FROM offline_licenses WHERE id = :lid"
|
||||
), {"lid": license_id})
|
||||
keys = ["id", "license_key", "user_id", "agent_id", "device_fingerprint",
|
||||
"issued_at", "expires_at", "last_validated_at", "max_offline_days",
|
||||
"is_active", "revoked_reason", "status", "created_at", "updated_at"]
|
||||
if not result:
|
||||
raise HTTPException(404, "License not found")
|
||||
return {"code": 0, "data": _row_to_dict(result[0], keys)}
|
||||
|
||||
|
||||
@router.post("/licenses/{license_id}/refresh")
|
||||
def refresh_license(
|
||||
license_id: int,
|
||||
extend_days: int = Query(7),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Refresh an existing license (extend expiry)"""
|
||||
result = _execute_raw(db, text(
|
||||
"SELECT * FROM offline_licenses WHERE id = :lid"
|
||||
), {"lid": license_id})
|
||||
if not result:
|
||||
raise HTTPException(404, "License not found")
|
||||
|
||||
row = result[0]
|
||||
status = row[11] # status column index
|
||||
if status != "active":
|
||||
raise HTTPException(400, f"Cannot refresh license with status: {status}")
|
||||
|
||||
current_expires = row[5] # expires_at
|
||||
if isinstance(current_expires, str):
|
||||
current_expires = datetime.strptime(current_expires, "%Y-%m-%d %H:%M:%S")
|
||||
new_expires = current_expires + timedelta(days=extend_days)
|
||||
|
||||
_execute_raw(db, text(
|
||||
"UPDATE offline_licenses SET expires_at = :exp, last_validated_at = NOW() WHERE id = :lid"
|
||||
), {"exp": new_expires, "lid": license_id})
|
||||
|
||||
return {"code": 0, "message": f"License extended by {extend_days} days",
|
||||
"data": {"new_expires_at": new_expires.strftime("%Y-%m-%d %H:%M:%S")}}
|
||||
|
||||
|
||||
@router.post("/licenses/validate")
|
||||
def validate_license(
|
||||
license_key: str = Query(...),
|
||||
device_fingerprint: str = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Validate an offline license (called by local agent)"""
|
||||
try:
|
||||
payload = _validate_offline_license(license_key)
|
||||
except ValueError as e:
|
||||
return {"code": 1, "message": str(e), "data": {"valid": False}}
|
||||
|
||||
# Check DB record
|
||||
result = _execute_raw(db, text(
|
||||
"SELECT id, status, expires_at, device_fingerprint FROM offline_licenses WHERE license_key = :lk"
|
||||
), {"lk": license_key})
|
||||
|
||||
if not result:
|
||||
return {"code": 0, "data": {"valid": True, "warning": "License not in DB (may be pre-generated)"}}
|
||||
|
||||
db_status = result[0][1]
|
||||
if db_status != "active":
|
||||
return {"code": 1, "message": f"License status: {db_status}", "data": {"valid": False}}
|
||||
|
||||
# Update last_validated_at
|
||||
_execute_raw(db, text(
|
||||
"UPDATE offline_licenses SET last_validated_at = NOW() WHERE id = :lid"
|
||||
), {"lid": result[0][0]})
|
||||
|
||||
return {"code": 0, "data": {
|
||||
"valid": True,
|
||||
"user_id": payload["user_id"],
|
||||
"agent_id": payload["agent_id"],
|
||||
"expires_at": payload["expires_at"],
|
||||
"max_offline_days": payload["max_offline_days"]
|
||||
}}
|
||||
|
||||
|
||||
@router.post("/licenses/{license_id}/revoke")
|
||||
def revoke_license(
|
||||
license_id: int,
|
||||
reason: str = Query("Manually revoked"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Revoke an offline license"""
|
||||
result = _execute_raw(db, text(
|
||||
"SELECT id, status FROM offline_licenses WHERE id = :lid"
|
||||
), {"lid": license_id})
|
||||
if not result:
|
||||
raise HTTPException(404, "License not found")
|
||||
|
||||
_execute_raw(db, text(
|
||||
"UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = 0 WHERE id = :lid"
|
||||
), {"reason": reason, "lid": license_id})
|
||||
|
||||
return {"code": 0, "message": "License revoked"}
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 3. Knowledge Base Sync
|
||||
# ========================================================================
|
||||
@router.get("/kb-sync")
|
||||
def list_kb_sync_states(
|
||||
user_id: str = Query("admin-001"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List knowledge base sync states"""
|
||||
rows = _execute_raw(db, text(
|
||||
"SELECT * FROM kb_sync_states WHERE user_id = :uid ORDER BY updated_at DESC"
|
||||
), {"uid": user_id})
|
||||
keys = ["id", "user_id", "knowledge_base_id", "local_version", "remote_version",
|
||||
"last_sync_at", "sync_status", "total_docs", "synced_docs",
|
||||
"sync_direction", "error_message", "created_at", "updated_at"]
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
d = _row_to_dict(row, keys)
|
||||
# Get KB name
|
||||
kb_row = _execute_raw(db, text(
|
||||
"SELECT name FROM knowledge_bases WHERE id = :kbid"
|
||||
), {"kbid": row[2]})
|
||||
d["kb_name"] = kb_row[0][0] if kb_row else f"KB-{row[2]}"
|
||||
result.append(d)
|
||||
|
||||
return {"code": 0, "data": result}
|
||||
|
||||
|
||||
@router.post("/kb-sync/trigger")
|
||||
def trigger_kb_sync(
|
||||
knowledge_base_id: int = Query(...),
|
||||
sync_direction: str = Query("pull"),
|
||||
user_id: str = Query("admin-001"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Trigger knowledge base sync"""
|
||||
valid_dirs = ["push", "pull", "bidirectional"]
|
||||
if sync_direction not in valid_dirs:
|
||||
raise HTTPException(400, f"Invalid direction: {sync_direction}")
|
||||
|
||||
# Check or create sync state
|
||||
existing = _execute_raw(db, text(
|
||||
"SELECT id FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid"
|
||||
), {"uid": user_id, "kbid": knowledge_base_id})
|
||||
|
||||
if existing:
|
||||
_execute_raw(db, text("""
|
||||
UPDATE kb_sync_states SET sync_status = 'syncing', sync_direction = :dir,
|
||||
last_sync_at = NOW(), error_message = NULL
|
||||
WHERE id = :sid
|
||||
"""), {"dir": sync_direction, "sid": existing[0][0]})
|
||||
else:
|
||||
_execute_raw(db, text("""
|
||||
INSERT INTO kb_sync_states (user_id, knowledge_base_id, sync_status,
|
||||
sync_direction, last_sync_at)
|
||||
VALUES (:uid, :kbid, 'syncing', :dir, NOW())
|
||||
"""), {"uid": user_id, "kbid": knowledge_base_id, "dir": sync_direction})
|
||||
|
||||
# Simulate sync: count KB docs as "synced"
|
||||
doc_count = _execute_raw(db, text(
|
||||
"SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid"
|
||||
), {"kbid": knowledge_base_id})
|
||||
total = doc_count[0][0] if doc_count else 0
|
||||
|
||||
_execute_raw(db, text("""
|
||||
UPDATE kb_sync_states SET sync_status = 'synced', total_docs = :total,
|
||||
synced_docs = :synced, remote_version = remote_version + 1,
|
||||
error_message = NULL
|
||||
WHERE user_id = :uid AND knowledge_base_id = :kbid
|
||||
"""), {"total": total, "synced": total, "uid": user_id, "kbid": knowledge_base_id})
|
||||
|
||||
return {"code": 0, "message": f"Sync triggered: {sync_direction}",
|
||||
"data": {"total_docs": total, "synced_docs": total, "status": "synced"}}
|
||||
|
||||
|
||||
@router.get("/kb-sync/{kb_id}/status")
|
||||
def get_kb_sync_status(kb_id: int, user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
|
||||
"""Get sync status for a specific KB"""
|
||||
result = _execute_raw(db, text(
|
||||
"SELECT * FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid"
|
||||
), {"uid": user_id, "kbid": kb_id})
|
||||
keys = ["id", "user_id", "knowledge_base_id", "local_version", "remote_version",
|
||||
"last_sync_at", "sync_status", "total_docs", "synced_docs",
|
||||
"sync_direction", "error_message", "created_at", "updated_at"]
|
||||
if not result:
|
||||
return {"code": 0, "data": {"sync_status": "pending", "knowledge_base_id": kb_id}}
|
||||
return {"code": 0, "data": _row_to_dict(result[0], keys)}
|
||||
|
||||
|
||||
@router.get("/kb-sync/{kb_id}/diff")
|
||||
def get_kb_sync_diff(kb_id: int, user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
|
||||
"""Get document diff since last sync"""
|
||||
result = _execute_raw(db, text(
|
||||
"SELECT last_sync_at FROM kb_sync_states WHERE user_id = :uid AND knowledge_base_id = :kbid"
|
||||
), {"uid": user_id, "kbid": kb_id})
|
||||
|
||||
last_sync = result[0][0] if result else None
|
||||
|
||||
if last_sync:
|
||||
new_docs = _execute_raw(db, text(
|
||||
"SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid AND created_at > :since"
|
||||
), {"kbid": kb_id, "since": last_sync})
|
||||
updated_docs = _execute_raw(db, text(
|
||||
"SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid AND updated_at > :since AND created_at <= :since"
|
||||
), {"kbid": kb_id, "since": last_sync})
|
||||
deleted_count = 0 # Soft delete not tracked in this version
|
||||
else:
|
||||
total = _execute_raw(db, text(
|
||||
"SELECT COUNT(*) FROM knowledge_documents WHERE knowledge_base_id = :kbid"
|
||||
), {"kbid": kb_id})
|
||||
total = total[0][0] if total else 0
|
||||
new_docs = [(total,)]
|
||||
updated_docs = [(0,)]
|
||||
deleted_count = 0
|
||||
|
||||
return {"code": 0, "data": {
|
||||
"knowledge_base_id": kb_id,
|
||||
"last_sync_at": last_sync.strftime("%Y-%m-%d %H:%M:%S") if last_sync and not isinstance(last_sync, str) else str(last_sync) if last_sync else None,
|
||||
"new_docs": new_docs[0][0] if new_docs else 0,
|
||||
"updated_docs": updated_docs[0][0] if updated_docs else 0,
|
||||
"deleted_docs": deleted_count,
|
||||
}}
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 4. Anonymized Usage Reporting
|
||||
# ========================================================================
|
||||
@router.post("/usage/report")
|
||||
def submit_usage_report(
|
||||
metrics: str = Query(...),
|
||||
report_type: str = Query("daily"),
|
||||
period_start: str = Query(None),
|
||||
period_end: str = Query(None),
|
||||
user_id: str = Query("admin-001"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Submit anonymized usage report from local agent"""
|
||||
user_hash = _anonymize_user_id(user_id)
|
||||
|
||||
try:
|
||||
metrics_obj = json.loads(metrics)
|
||||
except json.JSONDecodeError:
|
||||
metrics_obj = {"raw": metrics}
|
||||
|
||||
if not period_start:
|
||||
period_start = datetime.now().strftime("%Y-%m-%d 00:00:00")
|
||||
if not period_end:
|
||||
period_end = datetime.now().strftime("%Y-%m-%d 23:59:59")
|
||||
|
||||
_execute_raw(db, text("""
|
||||
INSERT INTO usage_reports (user_hash, report_type, metrics, period_start,
|
||||
period_end, is_anonymized, submitted_at)
|
||||
VALUES (:hash, :rtype, :metrics, :pstart, :pend, 1, NOW())
|
||||
"""), {
|
||||
"hash": user_hash,
|
||||
"rtype": report_type,
|
||||
"metrics": json.dumps(metrics_obj),
|
||||
"pstart": period_start,
|
||||
"pend": period_end,
|
||||
})
|
||||
|
||||
return {"code": 0, "message": "Usage report submitted"}
|
||||
|
||||
|
||||
@router.get("/usage/stats")
|
||||
def get_usage_stats(db: Session = Depends(get_db)):
|
||||
"""Get aggregated usage statistics (admin view, anonymized)"""
|
||||
total_reports = _execute_raw(db, text(
|
||||
"SELECT COUNT(*) FROM usage_reports"
|
||||
))
|
||||
unique_users = _execute_raw(db, text(
|
||||
"SELECT COUNT(DISTINCT user_hash) FROM usage_reports"
|
||||
))
|
||||
today_reports = _execute_raw(db, text(
|
||||
"SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURDATE()"
|
||||
))
|
||||
by_type = _execute_raw(db, text(
|
||||
"SELECT report_type, COUNT(*) as cnt FROM usage_reports GROUP BY report_type"
|
||||
))
|
||||
|
||||
return {"code": 0, "data": {
|
||||
"total_reports": total_reports[0][0] if total_reports else 0,
|
||||
"unique_users": unique_users[0][0] if unique_users else 0,
|
||||
"today_reports": today_reports[0][0] if today_reports else 0,
|
||||
"by_type": [{"type": r[0], "count": r[1]} for r in by_type] if by_type else []
|
||||
}}
|
||||
|
||||
|
||||
@router.get("/usage/my-stats")
|
||||
def get_my_usage_stats(user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
|
||||
"""Get current user's usage stats"""
|
||||
user_hash = _anonymize_user_id(user_id)
|
||||
result = _execute_raw(db, text(
|
||||
"SELECT COUNT(*), MAX(submitted_at) FROM usage_reports WHERE user_hash = :hash"
|
||||
), {"hash": user_hash})
|
||||
|
||||
return {"code": 0, "data": {
|
||||
"total_reports": result[0][0] if result else 0,
|
||||
"last_submitted": _row_to_dict(result[0], ["count", "last"])["last"] if result else None
|
||||
}}
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 5. Local Agent Status
|
||||
# ========================================================================
|
||||
@router.get("/local-agents")
|
||||
def list_local_agents(user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
|
||||
"""List local agents with sync status"""
|
||||
# Query agents with privacy configs
|
||||
rows = _execute_raw(db, text("""
|
||||
SELECT DISTINCT a.id, a.name, a.description, a.status, a.type,
|
||||
COALESCE(pc.privacy_mode, 'cloud') as privacy_mode,
|
||||
COALESCE(pc.local_llm_provider, '') as llm_provider,
|
||||
COALESCE(pc.local_llm_model, '') as llm_model,
|
||||
COALESCE(pc.last_sync_at, NULL) as last_sync
|
||||
FROM market_apps a
|
||||
LEFT JOIN privacy_configs pc ON pc.agent_id = a.id AND pc.user_id = :uid
|
||||
WHERE a.type = 'agent' AND a.status = 'published'
|
||||
ORDER BY a.name
|
||||
"""), {"uid": user_id})
|
||||
keys = ["id", "name", "description", "status", "type", "privacy_mode",
|
||||
"llm_provider", "llm_model", "last_sync"]
|
||||
|
||||
agents = []
|
||||
for row in rows:
|
||||
d = _row_to_dict(row, keys)
|
||||
d["can_run_local"] = d["privacy_mode"] in ["local", "hybrid"]
|
||||
agents.append(d)
|
||||
|
||||
return {"code": 0, "data": agents}
|
||||
|
||||
|
||||
@router.get("/local-agents/{agent_id}/status")
|
||||
def get_local_agent_status(
|
||||
agent_id: str,
|
||||
user_id: str = Query("admin-001"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get detailed local agent status"""
|
||||
config = _execute_raw(db, text(
|
||||
"SELECT * FROM privacy_configs WHERE user_id = :uid AND agent_id = :aid"
|
||||
), {"uid": user_id, "aid": agent_id})
|
||||
keys = ["id", "user_id", "agent_id", "privacy_mode", "local_llm_provider",
|
||||
"local_llm_model", "local_llm_endpoint", "data_retention_days",
|
||||
"anonymize_usage", "sync_knowledge_bases", "sync_frequency",
|
||||
"last_sync_at", "status", "created_at", "updated_at"]
|
||||
|
||||
if config:
|
||||
cfg = _row_to_dict(config[0], keys)
|
||||
if cfg.get("sync_knowledge_bases"):
|
||||
try:
|
||||
cfg["sync_knowledge_bases"] = json.loads(cfg["sync_knowledge_bases"])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
cfg = {"privacy_mode": "cloud", "status": "not_configured"}
|
||||
|
||||
# Check active license
|
||||
license_info = _execute_raw(db, text(
|
||||
"SELECT license_key, expires_at, status FROM offline_licenses WHERE user_id = :uid AND status = 'active' ORDER BY expires_at DESC LIMIT 1"
|
||||
), {"uid": user_id})
|
||||
if license_info:
|
||||
cfg["has_active_license"] = True
|
||||
cfg["license_expires"] = license_info[0][1].strftime("%Y-%m-%d %H:%M:%S") if hasattr(license_info[0][1], 'strftime') else str(license_info[0][1])
|
||||
else:
|
||||
cfg["has_active_license"] = False
|
||||
cfg["license_expires"] = None
|
||||
|
||||
return {"code": 0, "data": cfg}
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 6. Platform Bridge Config Export
|
||||
# ========================================================================
|
||||
@router.get("/bridge-config")
|
||||
def get_platform_bridge_config(user_id: str = Query("admin-001"), db: Session = Depends(get_db)):
|
||||
"""Export Platform Bridge configuration for local Goose setup"""
|
||||
config = _execute_raw(db, text(
|
||||
"SELECT * FROM privacy_configs WHERE user_id = :uid ORDER BY updated_at DESC LIMIT 1"
|
||||
), {"uid": user_id})
|
||||
|
||||
platform_api = "http://210.16.161.218:18080"
|
||||
bridge_config = {
|
||||
"mcp_server": {
|
||||
"name": "platform-bridge",
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"args": ["-m", "platform_bridge.mcp_server"],
|
||||
"env": {
|
||||
"PLATFORM_API_URL": platform_api,
|
||||
"PLATFORM_USER_ID": user_id,
|
||||
"PLATFORM_API_KEY": "YOUR_API_KEY_HERE"
|
||||
},
|
||||
"timeout": 60
|
||||
},
|
||||
"privacy_mode": "cloud",
|
||||
"sync_config": {
|
||||
"frequency": "daily",
|
||||
"knowledge_bases": [],
|
||||
"auto_sync": True,
|
||||
"conflict_resolution": "last_write_wins"
|
||||
},
|
||||
"local_llm": {
|
||||
"provider": "ollama",
|
||||
"model": "qwen2.5:7b",
|
||||
"endpoint": "http://localhost:11434"
|
||||
}
|
||||
}
|
||||
|
||||
if config:
|
||||
row = config[0]
|
||||
bridge_config["privacy_mode"] = row[3] # privacy_mode column
|
||||
bridge_config["sync_config"]["frequency"] = row[10] # sync_frequency column
|
||||
if row[5]: # local_llm_provider
|
||||
bridge_config["local_llm"]["provider"] = row[5]
|
||||
if row[6]: # local_llm_model
|
||||
bridge_config["local_llm"]["model"] = row[6]
|
||||
if row[7]: # local_llm_endpoint
|
||||
bridge_config["local_llm"]["endpoint"] = row[7]
|
||||
if row[9]: # sync_knowledge_bases
|
||||
try:
|
||||
bridge_config["sync_config"]["knowledge_bases"] = json.loads(row[9])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"code": 0, "data": bridge_config}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_privacy_stats(db: Session = Depends(get_db)):
|
||||
"""Get privacy module statistics"""
|
||||
total_configs = _execute_raw(db, text("SELECT COUNT(*) FROM privacy_configs"))
|
||||
active_licenses = _execute_raw(db, text(
|
||||
"SELECT COUNT(*) FROM offline_licenses WHERE status = 'active' AND expires_at > NOW()"
|
||||
))
|
||||
synced_kbs = _execute_raw(db, text(
|
||||
"SELECT COUNT(*) FROM kb_sync_states WHERE sync_status = 'synced'"
|
||||
))
|
||||
mode_breakdown = _execute_raw(db, text(
|
||||
"SELECT privacy_mode, COUNT(*) FROM privacy_configs GROUP BY privacy_mode"
|
||||
))
|
||||
|
||||
return {"code": 0, "data": {
|
||||
"total_configs": total_configs[0][0] if total_configs else 0,
|
||||
"active_licenses": active_licenses[0][0] if active_licenses else 0,
|
||||
"synced_knowledge_bases": synced_kbs[0][0] if synced_kbs else 0,
|
||||
"mode_breakdown": {r[0]: r[1] for r in mode_breakdown} if mode_breakdown else {}
|
||||
}}
|
||||
@@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin
|
||||
from app.api.admin.developer_tools_admin import router as developer_tools_router
|
||||
from app.api.admin.mcp_market_admin import router as mcp_market_router
|
||||
from app.api.admin import asset_admin
|
||||
@@ -433,6 +433,7 @@ app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin
|
||||
app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
|
||||
app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
|
||||
app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
|
||||
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user