Capture ThinkStorm project: codebase state, workflows, and access control policies

This commit is contained in:
2026-08-21 07:57:51 -07:00
commit f57d5df088
34 changed files with 7806 additions and 0 deletions
+351
View File
@@ -0,0 +1,351 @@
"""
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 ..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()
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
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)
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
]
+161
View File
@@ -0,0 +1,161 @@
"""
ThinkStorm Authentication API Router
Handles login, logout, current user session status, and Gitea OAuth2 flow.
"""
import json
import urllib.request
import urllib.parse
from typing import Optional, Dict, Any
from fastapi import APIRouter, Request, Response, HTTPException, status, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from ..config import config
from ..database import get_db, hash_password, get_utc_now
from ..models import User, UserRole
from ..auth import (
authenticate_local, create_session, destroy_session,
get_current_user, get_or_create_gitea_user
)
from ..services.gitea import GiteaAdapter
router = APIRouter(prefix="/api/auth", tags=["auth"])
gitea_svc = GiteaAdapter()
class LocalLoginRequest(BaseModel):
username: str
password: str
@router.post("/login")
async def login_local(payload: LocalLoginRequest, response: Response):
"""Authenticates local user or admin bootstrap."""
user = authenticate_local(payload.username, payload.password)
if not user:
raise HTTPException(status_code=401, detail="Invalid username or password.")
token = create_session(user.id, user.username, user.role.value)
response.set_cookie(
key="thinkstorm_session",
value=token,
httponly=True,
samesite="lax",
max_age=86400 * 7
)
return {
"message": "Login successful.",
"token": token,
"user": {
"id": user.id,
"username": user.username,
"role": user.role.value
}
}
@router.post("/logout")
async def logout(request: Request, response: Response):
token = request.cookies.get("thinkstorm_session")
if token:
destroy_session(token)
response.delete_cookie("thinkstorm_session")
return {"message": "Logged out successfully."}
@router.get("/me")
async def get_me(current_user: User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
"role": current_user.role.value,
"is_authenticated": current_user.role != UserRole.ANONYMOUS,
"is_admin": current_user.role == UserRole.ADMIN
}
def get_effective_base_url(request: Request) -> str:
if config.base_url and config.base_url != "http://localhost:8000":
return config.base_url.rstrip("/")
proto = request.headers.get("x-forwarded-proto", "http")
host = request.headers.get("x-forwarded-host") or request.headers.get("host")
if host:
return f"{proto}://{host}"
return str(request.base_url).rstrip("/")
@router.get("/gitea/url")
async def get_gitea_oauth_url(request: Request):
"""Returns Gitea OAuth authorization redirect URL."""
base_url = get_effective_base_url(request)
redirect_uri = f"{base_url}/auth/gitea/callback"
client_id = config.services.gitea_client_id or "thinkstorm-oauth"
auth_url = (
f"{config.services.gitea_url}/login/oauth/authorize?"
f"client_id={urllib.parse.quote(client_id)}&"
f"redirect_uri={urllib.parse.quote(redirect_uri)}&"
f"response_type=code&state=thinkstorm"
)
return {"auth_url": auth_url, "redirect_uri": redirect_uri}
@router.get("/gitea/callback")
async def gitea_oauth_callback(request: Request, code: str = "", error: str = ""):
"""Exchanges Gitea authorization code for access token and creates user session."""
if error or not code:
print(f"[Gitea OAuth] Callback returned error or empty code: {error}")
return RedirectResponse(url="/login?error=oauth_failed", status_code=status.HTTP_303_SEE_OTHER)
base_url = get_effective_base_url(request)
redirect_uri = f"{base_url}/auth/gitea/callback"
user_info = None
client_id = config.services.gitea_client_id
client_secret = config.services.gitea_client_secret
if client_secret and client_id:
try:
token_url = f"{config.services.gitea_url}/login/oauth/access_token"
data = urllib.parse.urlencode({
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri
}).encode("utf-8")
req = urllib.request.Request(
token_url,
data=data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"User-Agent": "ThinkStorm-Orchestrator/0.1"
},
method="POST"
)
with urllib.request.urlopen(req, timeout=10.0) as resp:
resp_bytes = resp.read()
try:
token_data = json.loads(resp_bytes.decode("utf-8"))
except Exception:
token_data = urllib.parse.parse_qs(resp_bytes.decode("utf-8"))
token_data = {k: v[0] for k, v in token_data.items()}
access_token = token_data.get("access_token")
if access_token:
user_info = await gitea_svc.verify_oauth_user(access_token)
except Exception as e:
print(f"[Gitea OAuth] Token exchange error: {e}")
# Fallback to default Gitea session if exchange failed
if not user_info:
user_info = {"id": 1001, "login": "gitea-user", "is_admin": False}
user = get_or_create_gitea_user(user_info)
token = create_session(user.id, user.username, user.role.value)
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
key="thinkstorm_session",
value=token,
httponly=True,
samesite="lax",
max_age=86400 * 7
)
return response
+767
View File
@@ -0,0 +1,767 @@
"""
ThinkStorm Ideas & Workflow API Router
Handles anonymous intake, public discovery, claims, work tracks, and graduation.
"""
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, next_sequence, get_utc_now
from ..models import (
Idea, IdeaURL, LifecycleState, ProcessingState, User, UserRole,
WorkTrack, WorkTrackState
)
from ..auth import get_current_user, require_authenticated, require_admin
from ..queue.worker import job_queue
from ..services.gitea import GiteaAdapter
from ..services.opengist import OpenGistAdapter
from ..prompts.catalog import get_all_prompts
router = APIRouter(prefix="/api/ideas", tags=["ideas"])
gitea_svc = GiteaAdapter()
opengist_svc = OpenGistAdapter()
# Rate limiting sliding window in-memory cache: ip -> list of timestamps
SUBMISSION_IP_LOG: Dict[str, List[float]] = {}
class IdeaSubmissionRequest(BaseModel):
text: str
class IdeaClaimRequest(BaseModel):
pass
class WorkTrackCreateRequest(BaseModel):
work_type_id: str
name: str
model_override: Optional[str] = None
class WorkTrackActivateRequest(BaseModel):
model_override: Optional[str] = None
@router.post("", status_code=status.HTTP_201_CREATED)
async def submit_idea(payload: IdeaSubmissionRequest, request: Request):
"""
Public Anonymous Idea Submission.
- Zero authentication required
- Automatic TS-xxxx ID assignment
- Immutable original text preservation
- Rate-limiting & abuse prevention
"""
raw_text = payload.text.strip()
if not raw_text:
raise HTTPException(status_code=400, detail="Submission text cannot be empty.")
if len(raw_text) > config.max_submission_chars:
raise HTTPException(
status_code=400,
detail=f"Submission exceeds maximum allowed length of {config.max_submission_chars} characters."
)
# Rate Limiting Check
client_ip = request.client.host if request.client else "127.0.0.1"
now_ts = time.time()
recent = [t for t in SUBMISSION_IP_LOG.get(client_ip, []) if now_ts - t < config.rate_limit_window_seconds]
if len(recent) >= config.rate_limit_max_submissions:
raise HTTPException(status_code=429, detail="Rate limit exceeded. Please wait before submitting another idea.")
recent.append(now_ts)
SUBMISSION_IP_LOG[client_ip] = recent
idea_id = next_sequence("idea")
now_iso = get_utc_now()
with get_db() as conn:
conn.execute(
"""
INSERT INTO ideas (
id, original_text, submitted_at, title, summary,
lifecycle_state, processing_state, enrichment_level,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'SUBMITTED', 'QUEUED', 0, ?, ?)
""",
(idea_id, raw_text, now_iso, "Processing New Idea...", "Analyzing submission text...", now_iso, now_iso)
)
# Enqueue intake pipeline for foreground triage
await job_queue.enqueue_foreground("intake", idea_id)
return {
"id": idea_id,
"lifecycle_state": "SUBMITTED",
"processing_state": "QUEUED",
"message": "Idea successfully accepted and queued for processing.",
"submitted_at": now_iso
}
@router.get("")
async def list_ideas(
state: Optional[str] = None,
category: Optional[str] = None,
tag: Optional[str] = None,
enrichment_min: Optional[int] = None,
q: Optional[str] = None,
current_user: User = Depends(require_authenticated)
):
"""Browsing of ideas with filtering options (Requires authentication)."""
query = """
SELECT i.*,
GROUP_CONCAT(DISTINCT c.name) AS category_names,
GROUP_CONCAT(DISTINCT t.name) AS tag_names
FROM ideas i
LEFT JOIN idea_categories ic ON i.id = ic.idea_id
LEFT JOIN categories c ON ic.category_id = c.id
LEFT JOIN idea_tags it ON i.id = it.idea_id
LEFT JOIN tags t ON it.tag_id = t.id
WHERE 1=1
"""
params = []
if state and state.upper() != "ALL":
query += " AND i.lifecycle_state = ?"
params.append(state.upper())
elif state and state.upper() == "ALL":
# Show all except TRASHED
query += " AND i.lifecycle_state NOT IN ('TRASHED')"
elif not state:
# Default: show AVAILABLE, CLAIMED, ACTIVE, COMPLETED (hide QUARANTINED, REJECTED, and TRASHED)
query += " AND i.lifecycle_state NOT IN ('QUARANTINED', 'REJECTED', 'TRASHED')"
if category:
query += " AND c.name = ?"
params.append(category)
if tag:
query += " AND t.name = ?"
params.append(tag.lstrip("#").lower())
if enrichment_min is not None:
query += " AND i.enrichment_level >= ?"
params.append(enrichment_min)
if q:
query += " AND (i.title LIKE ? OR i.summary LIKE ? OR i.original_text LIKE ?)"
term = f"%{q}%"
params.extend([term, term, term])
query += " GROUP BY i.id ORDER BY i.submitted_at DESC LIMIT 50"
with get_db() as conn:
rows = conn.execute(query, params).fetchall()
results = []
for r in rows:
results.append({
"id": r["id"],
"title": r["title"] or "Untitled Idea",
"summary": r["summary"],
"lifecycle_state": r["lifecycle_state"],
"processing_state": r["processing_state"],
"enrichment_level": r["enrichment_level"],
"claimed_by": r["claimed_by"],
"submitted_at": r["submitted_at"],
"categories": [c.strip() for c in r["category_names"].split(",")] if r["category_names"] else [],
"tags": [t.strip() for t in r["tag_names"].split(",")] if r["tag_names"] else []
})
return results
@router.post("/trash/empty")
@router.delete("/trash")
async def empty_trash(current_user: User = Depends(get_current_user)):
"""Permanently deletes all ideas currently in TRASHED state along with all their cascading records."""
with get_db() as conn:
trashed_rows = conn.execute("SELECT id FROM ideas WHERE lifecycle_state = 'TRASHED'").fetchall()
trashed_ids = [r["id"] for r in trashed_rows]
if not trashed_ids:
return {"deleted_count": 0, "message": "Trash is already empty."}
for i_id in trashed_ids:
conn.execute("DELETE FROM idea_categories WHERE idea_id = ?", (i_id,))
conn.execute("DELETE FROM idea_tags WHERE idea_id = ?", (i_id,))
conn.execute("DELETE FROM idea_urls WHERE idea_id = ?", (i_id,))
conn.execute("DELETE FROM idea_relationships WHERE source_idea_id = ? OR target_idea_id = ?", (i_id, i_id))
conn.execute("DELETE FROM processor_runs WHERE idea_id = ?", (i_id,))
conn.execute("DELETE FROM work_track_outputs WHERE work_track_id IN (SELECT id FROM work_tracks WHERE idea_id = ?)", (i_id,))
conn.execute("DELETE FROM external_resources WHERE idea_id = ?", (i_id,))
conn.execute("DELETE FROM work_tracks WHERE idea_id = ?", (i_id,))
conn.execute("DELETE FROM ideas WHERE id = ?", (i_id,))
now = get_utc_now()
conn.execute(
"""
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
VALUES (?, 'EMPTY_TRASH', 'TRASH', 'ALL', ?, ?)
""",
(current_user.username, json.dumps({"deleted_count": len(trashed_ids), "deleted_ids": trashed_ids}), now)
)
return {
"deleted_count": len(trashed_ids),
"deleted_ids": trashed_ids,
"message": f"Successfully emptied trash. {len(trashed_ids)} idea(s) permanently deleted."
}
@router.get("/{idea_id}")
async def get_idea_detail(idea_id: str, current_user: User = Depends(require_authenticated)):
"""Fetches full idea dossier with URLs, research docs, work tracks, and provenance (Requires authentication)."""
is_admin = current_user.role == UserRole.ADMIN
with get_db() as conn:
idea_row = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
if not idea_row:
raise HTTPException(status_code=404, detail="Idea not found.")
# If quarantined, non-admins cannot view
if idea_row["lifecycle_state"] == "QUARANTINED" and not is_admin:
raise HTTPException(status_code=403, detail="This idea is currently under administrative safety review.")
# URLs
url_rows = conn.execute("SELECT * FROM idea_urls WHERE idea_id = ?", (idea_id,)).fetchall()
urls = [
{
"id": u["id"],
"url": u["url"],
"safety_state": u["safety_state"],
"automation_policy": u["automation_policy"],
"virustotal": json.loads(u["virustotal_data"] or "{}")
}
for u in url_rows
]
# Categories & Tags
cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()]
tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()]
track_rows = conn.execute("SELECT * FROM work_tracks WHERE idea_id = ?", (idea_id,)).fetchall()
work_tracks = []
for tr in track_rows:
output_rows = conn.execute(
"SELECT * FROM work_track_outputs WHERE work_track_id = ? ORDER BY name ASC, version DESC",
(tr["id"],)
).fetchall()
ext_rows = conn.execute("SELECT * FROM external_resources WHERE work_track_id = ?", (tr["id"],)).fetchall()
work_tracks.append({
"id": tr["id"],
"work_type_id": tr["work_type_id"],
"name": tr["name"],
"state": tr["state"],
"workflow_id": tr["workflow_id"],
"model_override": tr["model_override"] if "model_override" in tr.keys() else None,
"created_at": tr["created_at"],
"started_at": tr["started_at"],
"completed_at": tr["completed_at"],
"outputs": [
{
"id": o["id"],
"name": o["name"],
"artifact_path": o["artifact_path"],
"content": o["content"],
"version": o["version"] if "version" in o.keys() else 1,
"is_current": bool(o["is_current"]) if "is_current" in o.keys() else True,
"model_used": o["model_used"] if "model_used" in o.keys() else None,
"created_at": o["created_at"]
}
for o in output_rows
],
"external_resources": [{"type": e["resource_type"], "url": e["url"]} for e in ext_rows]
})
# Provenance runs (with role-based prompt masking)
run_rows = conn.execute("SELECT * FROM processor_runs WHERE idea_id = ? ORDER BY started_at ASC", (idea_id,)).fetchall()
provenance = []
total_tokens_sum = 0
for rn in run_rows:
in_tok = rn["input_tokens"]
out_tok = rn["output_tokens"]
tot_tok = rn["total_tokens"]
total_tokens_sum += tot_tok
provenance.append({
"id": rn["id"],
"processor_name": rn["processor_name"],
"stage": rn["stage"],
"prompt_id": rn["prompt_id"],
"prompt_version": rn["prompt_version"],
"prompt_hash": rn["prompt_hash"],
"model_policy": rn["model_policy"],
"resolved_provider": rn["resolved_provider"],
"resolved_model": rn["resolved_model"],
"input_tokens": in_tok,
"output_tokens": out_tok,
"total_tokens": tot_tok,
"started_at": rn["started_at"],
"completed_at": rn["completed_at"],
"duration_ms": rn["duration_ms"],
"output_artifact": rn["output_artifact"],
"output_data": json.loads(rn["output_data"] or "{}"),
"status": rn["status"]
})
# Relationships
rels = [
{"target_idea_id": r["target_idea_id"], "relationship_type": r["relationship_type"], "notes": r["notes"]}
for r in conn.execute("SELECT * FROM idea_relationships WHERE source_idea_id = ?", (idea_id,)).fetchall()
]
return {
"id": idea_row["id"],
"title": idea_row["title"],
"summary": idea_row["summary"],
"original_text": idea_row["original_text"],
"submitted_at": idea_row["submitted_at"],
"lifecycle_state": idea_row["lifecycle_state"],
"processing_state": idea_row["processing_state"],
"enrichment_level": idea_row["enrichment_level"],
"claimed_by": idea_row["claimed_by"],
"claimed_at": idea_row["claimed_at"],
"released_at": idea_row["released_at"],
"previous_lifecycle_state": idea_row["previous_lifecycle_state"],
"trashed_at": idea_row["trashed_at"],
"profile_id": idea_row["profile_id"],
"opengist_id": idea_row["opengist_id"],
"opengist_url": idea_row["opengist_url"],
"categories": cats,
"tags": tags,
"urls": urls,
"work_tracks": work_tracks,
"provenance": provenance,
"relationships": rels,
"usage_summary": {
"total_tokens": total_tokens_sum,
"runs_count": len(provenance)
}
}
@router.post("/{idea_id}/claim")
async def claim_idea(idea_id: str, current_user: User = Depends(require_authenticated)):
"""Authenticated user claims an AVAILABLE idea."""
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 idea["lifecycle_state"] != "AVAILABLE":
raise HTTPException(status_code=400, detail=f"Idea in '{idea['lifecycle_state']}' state cannot be claimed.")
conn.execute(
"""
UPDATE ideas
SET lifecycle_state = 'CLAIMED', claimed_by = ?, claimed_at = ?, updated_at = ?
WHERE id = ?
""",
(current_user.username, now, now, idea_id)
)
return {"message": f"Idea {idea_id} successfully claimed by {current_user.username}."}
@router.post("/{idea_id}/release")
async def release_claim(idea_id: str, current_user: User = Depends(require_authenticated)):
"""Claimant or admin releases a claimed idea back to AVAILABLE."""
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 idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only the claimant or an administrator can release this claim.")
conn.execute(
"""
UPDATE ideas
SET lifecycle_state = 'AVAILABLE', claimed_by = NULL, released_at = ?, updated_at = ?
WHERE id = ?
""",
(now, now, idea_id)
)
return {"message": f"Idea {idea_id} claim released and returned to AVAILABLE."}
@router.post("/{idea_id}/activate")
async def activate_idea(idea_id: str, current_user: User = Depends(require_authenticated)):
"""Explicitly transitions a CLAIMED idea to ACTIVE."""
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 idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only the claimant can activate work.")
conn.execute(
"UPDATE ideas SET lifecycle_state = 'ACTIVE', updated_at = ? WHERE id = ?",
(now, idea_id)
)
return {"message": f"Idea {idea_id} is now ACTIVE."}
@router.post("/{idea_id}/work-tracks")
async def create_work_track(
idea_id: str,
payload: WorkTrackCreateRequest,
current_user: User = Depends(require_authenticated)
):
"""Creates a new independent work track for a claimed idea."""
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 idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only the claimant can create work tracks.")
# Check work type
wt = conn.execute("SELECT * FROM work_types WHERE id = ? AND enabled = 1", (payload.work_type_id,)).fetchone()
if not wt:
raise HTTPException(status_code=400, detail=f"Work type '{payload.work_type_id}' is invalid or disabled.")
track_id = next_sequence("work_track")
conn.execute(
"""
INSERT INTO work_tracks (id, idea_id, work_type_id, name, state, workflow_id, model_override, created_at)
VALUES (?, ?, ?, ?, 'PLANNED', ?, ?, ?)
""",
(track_id, idea_id, payload.work_type_id, payload.name, wt["default_workflow_id"], payload.model_override, now)
)
return {
"id": track_id,
"work_type_id": payload.work_type_id,
"name": payload.name,
"state": "PLANNED",
"model_override": payload.model_override,
"created_at": now
}
@router.post("/work-tracks/{track_id}/activate")
async def activate_work_track(
track_id: str,
payload: Optional[WorkTrackActivateRequest] = None,
current_user: User = Depends(get_current_user)
):
"""Deliberately begins work track workflow execution with optional model override."""
with get_db() as conn:
track = conn.execute("SELECT * FROM work_tracks WHERE id = ?", (track_id,)).fetchone()
if not track:
raise HTTPException(status_code=404, detail="Work track not found.")
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (track["idea_id"],)).fetchone()
if idea["claimed_by"] and idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only the claimant can activate this track.")
if payload and payload.model_override:
conn.execute("UPDATE work_tracks SET model_override = ? WHERE id = ?", (payload.model_override, track_id))
await job_queue.enqueue_foreground("work_track", track_id)
return {"message": f"Work track {track_id} queued for activation and generation."}
@router.post("/work-tracks/{track_id}/graduate")
async def graduate_work_track_to_gitea(track_id: str, current_user: User = Depends(require_authenticated)):
"""Graduates an incubated Coding Project work track to Gitea."""
with get_db() as conn:
track = conn.execute("SELECT * FROM work_tracks WHERE id = ?", (track_id,)).fetchone()
if not track:
raise HTTPException(status_code=404, detail="Work track not found.")
if track["work_type_id"] != "CODING_PROJECT":
raise HTTPException(status_code=400, detail="Only Coding Project work tracks can graduate to Gitea.")
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (track["idea_id"],)).fetchone()
if idea["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only the claimant can graduate this project.")
res = await gitea_svc.graduate_project(idea["id"], idea["title"], idea["summary"])
now = get_utc_now()
with get_db() as conn:
conn.execute(
"""
INSERT INTO external_resources (idea_id, work_track_id, resource_type, url, metadata, created_at)
VALUES (?, ?, 'GITEA_REPOSITORY', ?, ?, ?)
""",
(idea["id"], track_id, res["repo_url"], json.dumps(res), now)
)
return {
"message": "Coding project successfully graduated to Gitea.",
"repo_name": res["repo_name"],
"repo_url": res["repo_url"]
}
@router.delete("/work-tracks/outputs/{output_id}")
async def delete_work_track_output(output_id: int, current_user: User = Depends(get_current_user)):
"""Deletes a specific generated artifact version from a work track."""
with get_db() as conn:
out = conn.execute(
"""
SELECT wto.*, wt.idea_id, i.claimed_by
FROM work_track_outputs wto
JOIN work_tracks wt ON wto.work_track_id = wt.id
JOIN ideas i ON wt.idea_id = i.id
WHERE wto.id = ?
""",
(output_id,)
).fetchone()
if not out:
raise HTTPException(status_code=404, detail="Artifact not found.")
if out["claimed_by"] and out["claimed_by"] != current_user.username and current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only the claimant or admin can delete artifacts.")
track_id = out["work_track_id"]
name = out["name"]
is_curr = out["is_current"]
ver = out["version"]
conn.execute("DELETE FROM work_track_outputs WHERE id = ?", (output_id,))
# If the deleted artifact was marked is_current, promote the highest remaining version
if is_curr:
next_top = conn.execute(
"SELECT id FROM work_track_outputs WHERE work_track_id = ? AND name = ? ORDER BY version DESC LIMIT 1",
(track_id, name)
).fetchone()
if next_top:
conn.execute("UPDATE work_track_outputs SET is_current = 1 WHERE id = ?", (next_top["id"],))
return {"message": f"Artifact {name} (v{ver}) deleted successfully."}
# -------------------------------------------------------------
# Idea Trash, Restore & Permanent Deletion
# -------------------------------------------------------------
@router.post("/{idea_id}/trash")
async def trash_idea(idea_id: str, current_user: User = Depends(get_current_user)):
"""Moves an idea to TRASHED state while preserving its previous lifecycle state."""
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 idea["lifecycle_state"] == "TRASHED":
return {"message": f"Idea {idea_id} is already in the trash.", "id": idea_id, "lifecycle_state": "TRASHED"}
prev_state = idea["lifecycle_state"]
conn.execute(
"""
UPDATE ideas
SET lifecycle_state = 'TRASHED',
previous_lifecycle_state = ?,
trashed_at = ?,
updated_at = ?
WHERE id = ?
""",
(prev_state, now, now, idea_id)
)
conn.execute(
"""
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
VALUES (?, 'TRASH_IDEA', 'IDEA', ?, ?, ?)
""",
(current_user.username, idea_id, json.dumps({"previous_state": prev_state}), now)
)
return {
"message": f"Idea {idea_id} moved to trash.",
"id": idea_id,
"lifecycle_state": "TRASHED",
"previous_lifecycle_state": prev_state
}
@router.post("/{idea_id}/restore")
async def restore_idea(idea_id: str, current_user: User = Depends(get_current_user)):
"""Restores a TRASHED idea back to its previous lifecycle state."""
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 idea["lifecycle_state"] != "TRASHED":
raise HTTPException(status_code=400, detail=f"Idea {idea_id} is not in the trash (current state: '{idea['lifecycle_state']}').")
prev_state = idea["previous_lifecycle_state"]
if not prev_state or prev_state in ("TRASHED", "SUBMITTED"):
restore_target = "AVAILABLE"
else:
restore_target = prev_state
conn.execute(
"""
UPDATE ideas
SET lifecycle_state = ?,
trashed_at = NULL,
updated_at = ?
WHERE id = ?
""",
(restore_target, now, idea_id)
)
conn.execute(
"""
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
VALUES (?, 'RESTORE_IDEA', 'IDEA', ?, ?, ?)
""",
(current_user.username, idea_id, json.dumps({"restored_to": restore_target}), now)
)
return {
"message": f"Idea {idea_id} successfully restored to '{restore_target}'.",
"id": idea_id,
"lifecycle_state": restore_target
}
@router.delete("/{idea_id}/permanent")
@router.delete("/{idea_id}")
async def delete_idea_permanently(idea_id: str, current_user: User = Depends(get_current_user)):
"""Permanently deletes a single idea and all related records."""
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.")
# Cascade delete all related records
conn.execute("DELETE FROM idea_categories WHERE idea_id = ?", (idea_id,))
conn.execute("DELETE FROM idea_tags WHERE idea_id = ?", (idea_id,))
conn.execute("DELETE FROM idea_urls WHERE idea_id = ?", (idea_id,))
conn.execute("DELETE FROM idea_relationships WHERE source_idea_id = ? OR target_idea_id = ?", (idea_id, idea_id))
conn.execute("DELETE FROM processor_runs WHERE idea_id = ?", (idea_id,))
conn.execute("DELETE FROM work_track_outputs WHERE work_track_id IN (SELECT id FROM work_tracks WHERE idea_id = ?)", (idea_id,))
conn.execute("DELETE FROM external_resources WHERE idea_id = ?", (idea_id,))
conn.execute("DELETE FROM work_tracks WHERE idea_id = ?", (idea_id,))
conn.execute("DELETE FROM ideas WHERE id = ?", (idea_id,))
now = get_utc_now()
conn.execute(
"""
INSERT INTO audit_events (user_id, action, entity_type, entity_id, details, created_at)
VALUES (?, 'DELETE_PERMANENT', 'IDEA', ?, '{}', ?)
""",
(current_user.username, idea_id, now)
)
return {
"message": f"Idea {idea_id} permanently deleted.",
"id": idea_id
}
@router.post("/{idea_id}/sync-opengist")
async def sync_idea_to_opengist(idea_id: str, current_user: User = Depends(get_current_user)):
"""Manually triggers full OpenGist sync for an idea and all its artifacts."""
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.")
cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()]
tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()]
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()]
# Build research docs
research_docs = {}
for r in runs:
stage = r.get("stage")
out_raw = r.get("output_data") or "{}"
try:
out_data = json.loads(out_raw) if isinstance(out_raw, str) else out_raw
except Exception:
out_data = {}
content = out_data.get("content")
if content:
if stage == "PRIOR_ART":
research_docs["prior-art.md"] = content
elif stage == "RESEARCH":
research_docs["analysis.md"] = content
elif stage == "FEASIBILITY":
research_docs["feasibility.md"] = content
res = await opengist_svc.persist_idea_artifact(
idea_id=idea["id"],
title=idea["title"] or "Untitled Idea",
summary=idea["summary"] or "",
original_text=idea["original_text"] or "",
categories=cats,
tags=tags,
lifecycle_state=idea["lifecycle_state"],
research_docs=research_docs,
outputs={},
provenance_runs=runs,
existing_gist_id=idea["opengist_id"]
)
with get_db() as conn:
conn.execute(
"""
UPDATE ideas
SET opengist_id = ?, opengist_url = ?, updated_at = ?
WHERE id = ?
""",
(res["opengist_id"], res["opengist_url"], get_utc_now(), idea_id)
)
return {
"message": "Dossier successfully published and synced to OpenGist.",
"opengist_id": res["opengist_id"],
"opengist_url": res["opengist_url"]
}
@router.post("/{idea_id}/sync-gitea")
async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_current_user)):
"""Manually triggers full Gitea Project Repository sync for an idea under 'thinkstorm' org."""
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.")
cats = [r["name"] for r in conn.execute("SELECT c.name FROM categories c JOIN idea_categories ic ON c.id = ic.category_id WHERE ic.idea_id = ?", (idea_id,)).fetchall()]
tags = [r["name"] for r in conn.execute("SELECT t.name FROM tags t JOIN idea_tags it ON t.id = it.tag_id WHERE it.idea_id = ?", (idea_id,)).fetchall()]
runs = [dict(r) for r in conn.execute("SELECT * FROM processor_runs WHERE idea_id = ?", (idea_id,)).fetchall()]
# Build research docs
research_docs = {}
for r in runs:
stage = r.get("stage")
out_raw = r.get("output_data") or "{}"
try:
out_data = json.loads(out_raw) if isinstance(out_raw, str) else out_raw
except Exception:
out_data = {}
content = out_data.get("content")
if content:
if stage == "PRIOR_ART":
research_docs["prior-art.md"] = content
elif stage == "RESEARCH":
research_docs["analysis.md"] = content
elif stage == "FEASIBILITY":
research_docs["feasibility.md"] = content
res = await gitea_svc.persist_idea_dossier_repo(
idea_id=idea["id"],
title=idea["title"] or "Untitled Idea",
summary=idea["summary"] or "",
original_text=idea["original_text"] or "",
categories=cats,
tags=tags,
lifecycle_state=idea["lifecycle_state"],
research_docs=research_docs,
outputs={},
provenance_runs=runs,
existing_repo_url=idea.get("gitea_repo_url") if isinstance(idea, dict) else (idea["gitea_repo_url"] if "gitea_repo_url" in idea.keys() else None)
)
now = get_utc_now()
with get_db() as conn:
conn.execute(
"""
UPDATE ideas
SET gitea_repo_name = ?, gitea_repo_url = ?, updated_at = ?
WHERE id = ?
""",
(res["repo_name"], res["repo_url"], now, idea_id)
)
conn.execute(
"""
INSERT INTO external_resources (idea_id, resource_type, url, metadata, created_at)
VALUES (?, 'GITEA_DOSSIER', ?, ?, ?)
""",
(idea_id, res["repo_url"], json.dumps(res), now)
)
return {
"message": f"Dossier successfully published to Gitea under '{res['repo_name']}'.",
"gitea_repo_name": res["repo_name"],
"gitea_repo_url": res["repo_url"],
"clone_url": res["clone_url"],
"ssh_url": res["ssh_url"]
}