Files
xAdmin 41e08611c9 Implement optional single-image intake, Signal ingestion, and multimodal vision analysis
- Add image intake service with format validation (JPEG, PNG, WebP) and EXIF/GPS stripping
- Enforce strict single-image rule across Web and Signal attachment channels
- Implement token-optimized vision downscaling and JPEG compression
- Add IMAGE_CONTEXT pipeline stage with OmniRoute vision routing and resilient failover
- Seed and manage versioned idea-image-interpreter prompt in catalog
- Update Web UI with responsive image picker, preview chip, and Visual Context tab
- Add comprehensive automated test suite in test_image_intake.py
- Update README and Labyricorn devlog
2026-08-23 01:40:22 -07:00

356 lines
14 KiB
Python

"""
ThinkStorm Admin API Router
Handles Prompts Management, Versioning, Profiles, Services Config, Job Observability, Moderation, and Token Accounting.
"""
import json
import time
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Request, HTTPException, status, Depends
from pydantic import BaseModel
from ..config import config
from ..database import get_db, get_utc_now
from ..models import User, UserRole, ProcessorStatus
from ..auth import require_admin
from ..prompts.catalog import (
get_all_prompts, get_prompt_version, update_prompt, duplicate_prompt, get_all_profiles
)
from ..services.omniroute import OmniRouteAdapter
from ..services.searxng import SearXNGAdapter
from ..services.perplexica import PerplexicaAdapter
from ..services.opengist import OpenGistAdapter
from ..services.gitea import GiteaAdapter
from ..services.virustotal import VirusTotalAdapter
from ..services.signal_gateway import SignalGatewayAdapter
from ..queue.worker import job_queue
router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(require_admin)])
omniroute_svc = OmniRouteAdapter()
searxng_svc = SearXNGAdapter()
perplexica_svc = PerplexicaAdapter()
opengist_svc = OpenGistAdapter()
gitea_svc = GiteaAdapter()
virustotal_svc = VirusTotalAdapter()
signal_gateway_svc = SignalGatewayAdapter()
class PromptUpdateRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
system_prompt: str
user_prompt_template: str
model_policy: str = "reasoning"
expected_outputs: Optional[List[str]] = None
class PromptDuplicateRequest(BaseModel):
new_id: str
new_name: str
class ServiceUpdateRequest(BaseModel):
endpoint: str
api_key_raw: Optional[str] = None
enabled: bool = True
config_json: Optional[Dict[str, Any]] = None
class QuarantineDecisionRequest(BaseModel):
decision: str # APPROVE or REJECT
notes: Optional[str] = ""
# ----------------- Prompts & Profiles -----------------
@router.get("/prompts")
async def list_admin_prompts():
"""Returns full prompt definitions with templates (Admin only)."""
return get_all_prompts(is_admin=True)
@router.get("/prompts/{prompt_id}")
async def get_admin_prompt(prompt_id: str, version: Optional[int] = None):
p = get_prompt_version(prompt_id, version, is_admin=True)
if not p:
raise HTTPException(status_code=404, detail="Prompt not found.")
return p
@router.put("/prompts/{prompt_id}")
async def update_admin_prompt(prompt_id: str, payload: PromptUpdateRequest, current_user: User = Depends(require_admin)):
"""
Creates a new immutable prompt version.
Historical execution records remain linked to their original version.
"""
new_version = update_prompt(
prompt_id=prompt_id,
system_prompt=payload.system_prompt,
user_prompt_template=payload.user_prompt_template,
model_policy=payload.model_policy,
expected_outputs=payload.expected_outputs,
updated_by=current_user.username,
name=payload.name,
description=payload.description
)
# Log Audit Event
with get_db() as conn:
conn.execute(
"""
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
VALUES (?, 'UPDATE_PROMPT_VERSION', 'PROMPT', ?, ?, ?)
""",
(current_user.username, prompt_id, json.dumps({"new_version": new_version}), get_utc_now())
)
return {"message": f"Created new version {new_version} for prompt {prompt_id}.", "version": new_version}
@router.post("/prompts/{prompt_id}/duplicate")
async def duplicate_admin_prompt(prompt_id: str, payload: PromptDuplicateRequest, current_user: User = Depends(require_admin)):
duplicate_prompt(prompt_id, payload.new_id, payload.new_name, created_by=current_user.username)
return {"message": f"Prompt {prompt_id} duplicated to {payload.new_id}."}
@router.post("/prompts/{prompt_id}/toggle")
async def toggle_admin_prompt(prompt_id: str):
with get_db() as conn:
p = conn.execute("SELECT enabled FROM prompt_definitions WHERE id = ?", (prompt_id,)).fetchone()
if not p:
raise HTTPException(status_code=404, detail="Prompt not found.")
new_state = 0 if p["enabled"] else 1
conn.execute("UPDATE prompt_definitions SET enabled = ? WHERE id = ?", (new_state, prompt_id))
return {"message": f"Prompt {prompt_id} enabled set to {bool(new_state)}."}
@router.get("/profiles")
async def list_admin_profiles():
return get_all_profiles()
# ----------------- Services Configuration -----------------
@router.get("/services")
async def list_services():
with get_db() as conn:
rows = conn.execute("SELECT id, name, endpoint, api_key_masked, enabled, config_json, last_tested_at, health_status, last_error FROM service_configurations").fetchall()
return [
{
"id": r["id"],
"name": r["name"],
"endpoint": r["endpoint"],
"api_key_masked": r["api_key_masked"],
"enabled": bool(r["enabled"]),
"config": json.loads(r["config_json"] or "{}"),
"last_tested_at": r["last_tested_at"],
"health_status": r["health_status"],
"last_error": r["last_error"]
}
for r in rows
]
@router.post("/services/{service_id}/test")
async def test_service_connection(service_id: str):
"""Performs live health probe to external service."""
now = get_utc_now()
adapter = None
if service_id == "searxng":
adapter = searxng_svc
elif service_id == "omniroute":
adapter = omniroute_svc
elif service_id == "opengist":
adapter = opengist_svc
elif service_id == "gitea":
adapter = gitea_svc
elif service_id == "perplexica":
adapter = perplexica_svc
elif service_id == "virustotal":
adapter = virustotal_svc
elif service_id == "signal_gateway":
adapter = signal_gateway_svc
else:
raise HTTPException(status_code=404, detail=f"Service '{service_id}' not found.")
health = await adapter.check_health()
status_str = "HEALTHY" if health.healthy else "UNHEALTHY"
with get_db() as conn:
conn.execute(
"""
UPDATE service_configurations
SET last_tested_at = ?, health_status = ?, last_error = ?
WHERE id = ?
""",
(now, status_str, None if health.healthy else health.message, service_id)
)
return {
"service_id": service_id,
"healthy": health.healthy,
"status": status_str,
"message": health.message,
"response_time_ms": health.response_time_ms
}
@router.put("/services/{service_id}")
async def update_service(service_id: str, payload: ServiceUpdateRequest, current_user: User = Depends(require_admin)):
now = get_utc_now()
with get_db() as conn:
s = conn.execute("SELECT * FROM service_configurations WHERE id = ?", (service_id,)).fetchone()
if not s:
raise HTTPException(status_code=404, detail="Service not found.")
masked = s["api_key_masked"]
raw = s["api_key_raw"]
if payload.api_key_raw:
raw = payload.api_key_raw
masked = f"{raw[:4]}...{raw[-4:]}" if len(raw) > 8 else "****"
conf_json = json.dumps(payload.config_json or json.loads(s["config_json"] or "{}"))
conn.execute(
"""
UPDATE service_configurations
SET endpoint = ?, api_key_masked = ?, api_key_raw = ?, enabled = ?, config_json = ?
WHERE id = ?
""",
(payload.endpoint, masked, raw, 1 if payload.enabled else 0, conf_json, service_id)
)
conn.execute(
"""
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
VALUES (?, 'UPDATE_SERVICE_CONFIG', 'SERVICE', ?, ?, ?)
""",
(current_user.username, service_id, json.dumps({"endpoint": payload.endpoint, "enabled": payload.enabled}), now)
)
return {"message": f"Service '{service_id}' updated successfully."}
# ----------------- Observability & Jobs -----------------
@router.get("/jobs")
async def list_jobs():
queue_status = job_queue.get_status()
with get_db() as conn:
recent_runs = conn.execute(
"""
SELECT * FROM processor_runs ORDER BY started_at DESC LIMIT 30
"""
).fetchall()
runs = []
for r in recent_runs:
runs.append({
"id": r["id"],
"idea_id": r["idea_id"],
"work_track_id": r["work_track_id"],
"processor_name": r["processor_name"],
"stage": r["stage"],
"prompt_id": r["prompt_id"],
"prompt_version": r["prompt_version"],
"resolved_model": r["resolved_model"],
"total_tokens": r["total_tokens"],
"duration_ms": r["duration_ms"],
"started_at": r["started_at"],
"status": r["status"],
"error_message": r["error_message"]
})
return {
"queue": queue_status,
"recent_runs": runs
}
@router.post("/jobs/retry/{idea_id}")
async def retry_idea_job(idea_id: str):
with get_db() as conn:
conn.execute("UPDATE ideas SET processing_state = 'QUEUED' WHERE id = ?", (idea_id,))
await job_queue.enqueue_foreground("intake", idea_id, {"bypass_duplicate_check": True})
return {"message": f"Idea {idea_id} enqueued for processing retry."}
# ----------------- Moderation & Quarantine -----------------
@router.get("/quarantine")
async def list_quarantined_ideas():
with get_db() as conn:
ideas = conn.execute(
"""
SELECT i.*,
(SELECT COUNT(*) FROM idea_urls u WHERE u.idea_id = i.id AND u.safety_state = 'MALICIOUS') AS malicious_url_count,
(SELECT COUNT(*) FROM idea_urls u WHERE u.idea_id = i.id AND u.safety_state = 'SUSPICIOUS') AS suspicious_url_count
FROM ideas i
WHERE i.lifecycle_state = 'QUARANTINED'
ORDER BY i.submitted_at DESC
"""
).fetchall()
results = []
for r in ideas:
urls = conn.execute("SELECT * FROM idea_urls WHERE idea_id = ?", (r["id"],)).fetchall()
results.append({
"id": r["id"],
"title": r["title"],
"original_text": r["original_text"],
"submitted_at": r["submitted_at"],
"malicious_urls": r["malicious_url_count"],
"suspicious_urls": r["suspicious_url_count"],
"urls": [
{
"url": u["url"],
"safety_state": u["safety_state"],
"automation_policy": u["automation_policy"],
"virustotal": json.loads(u["virustotal_data"] or "{}")
}
for u in urls
]
})
return results
@router.post("/quarantine/{idea_id}/decision")
async def quarantine_decision(idea_id: str, payload: QuarantineDecisionRequest, current_user: User = Depends(require_admin)):
now = get_utc_now()
with get_db() as conn:
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
if not idea:
raise HTTPException(status_code=404, detail="Idea not found.")
if payload.decision.upper() == "APPROVE":
conn.execute("UPDATE ideas SET lifecycle_state = 'AVAILABLE', updated_at = ? WHERE id = ?", (now, idea_id))
conn.execute("UPDATE idea_urls SET automation_policy = 'APPROVED', decision = 'APPROVED_BY_ADMIN', reviewed_by = ?, reviewed_at = ? WHERE idea_id = ?", (current_user.username, now, idea_id))
else:
conn.execute("UPDATE ideas SET lifecycle_state = 'REJECTED', updated_at = ? WHERE id = ?", (now, idea_id))
conn.execute("UPDATE idea_urls SET automation_policy = 'BLOCKED', decision = 'REJECTED_BY_ADMIN', reviewed_by = ?, reviewed_at = ? WHERE idea_id = ?", (current_user.username, now, idea_id))
conn.execute(
"""
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
VALUES (?, 'QUARANTINE_DECISION', 'IDEA', ?, ?, ?)
""",
(current_user.username, idea_id, json.dumps({"decision": payload.decision, "notes": payload.notes}), now)
)
return {"message": f"Idea {idea_id} marked as {payload.decision.upper()}."}
# ----------------- Token Accounting & Metrics -----------------
@router.get("/token-metrics")
async def get_token_metrics():
with get_db() as conn:
totals = conn.execute("SELECT SUM(input_tokens) AS in_tok, SUM(output_tokens) AS out_tok, SUM(total_tokens) AS tot_tok, COUNT(*) AS run_count FROM processor_runs").fetchone()
by_stage = conn.execute("SELECT stage, SUM(total_tokens) as tokens, COUNT(*) as count FROM processor_runs GROUP BY stage").fetchall()
by_model = conn.execute("SELECT resolved_model, SUM(total_tokens) as tokens, COUNT(*) as count FROM processor_runs GROUP BY resolved_model").fetchall()
by_idea = conn.execute("SELECT idea_id, SUM(total_tokens) as tokens, COUNT(*) as count FROM processor_runs GROUP BY idea_id ORDER BY tokens DESC LIMIT 10").fetchall()
return {
"overall": {
"input_tokens": totals["in_tok"] or 0,
"output_tokens": totals["out_tok"] or 0,
"total_tokens": totals["tot_tok"] or 0,
"runs_count": totals["run_count"] or 0
},
"by_stage": [dict(r) for r in by_stage],
"by_model": [dict(r) for r in by_model],
"top_ideas": [dict(r) for r in by_idea]
}
@router.get("/audit-logs")
async def get_audit_logs():
with get_db() as conn:
rows = conn.execute("SELECT * FROM audit_events ORDER BY created_at DESC LIMIT 50").fetchall()
return [
{
"id": r["id"],
"user_id": r["user_id"],
"action": r["action"],
"entity_type": r["entity_type"],
"entity_id": r["entity_id"],
"details": json.loads(r["details"] or "{}"),
"created_at": r["created_at"]
}
for r in rows
]