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:
qyyfw committed 2026-07-06 17:18:05 +08:00
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 {}
}}