""" 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"] }