Root cause: ensure_structured_column() executed a synchronous blocking engine.connect().execute(SELECT information_schema.columns ...) inside the async request path (_build_chat_history / _maybe_compress_history), freezing the uvicorn event loop and hanging all endpoints (~200s, recovered only by docker restart). Fix: - chat_context.py: remove per-request ensure_structured_column() calls; run once idempotently at app startup (main.py lifespan). Use CURRENT_DATABASE() for PostgreSQL compatibility. - main.py: add startup-only ensure_structured_column() hook (try/except). - llm_client.py: trust_env=False + bounded httpx.Timeout/wait_for so broken endpoints fail fast instead of hanging the event loop. - chat_service.py: run media generation in a background daemon thread with its own event loop (_launch_media_task_bg) so SSE end does not cancel image/video tasks (#811); remove TEMP faulthandler diagnostic. Safe-align: persist accumulated live container changes (admin/*, knowledge, market, models, config, observability, mcp_client, platform_knowledge, im_bot_platform, chat_media, capability_registry, requirements) and add workflow_templates. Verified: live failover test (model-006 -> conn-refused) returns in ~1.9s with concurrent login 0.83s/200; event-loop responsiveness restored (sub-second).
800 lines
30 KiB
Python
800 lines
30 KiB
Python
"""
|
|
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 = false 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) = CURRENT_DATE"
|
|
))
|
|
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 {}
|
|
}}
|