""" OpenGist Service Adapter Syncs canonical idea dossiers, research synthesis, provenance logs, and work-track output artifacts to OpenGist. """ import json import time import urllib.request import urllib.error import asyncio from pathlib import Path from typing import Dict, Any, List, Optional from .base import BaseServiceAdapter, ServiceHealth from ..config import BASE_DIR, config ARTIFACTS_DIR = BASE_DIR / "data" / "artifacts" ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) class OpenGistAdapter(BaseServiceAdapter): def __init__(self, endpoint: str = "https://gist.labyricorn.com", api_token: str = ""): super().__init__(service_id="opengist", endpoint=endpoint, api_key=api_token or config.services.opengist_api_token) self.internal_endpoint = getattr(config.services, "opengist_internal_url", "http://10.138.2.48:6157") or "http://10.138.2.48:6157" async def check_health(self) -> ServiceHealth: start = time.time() for test_url in [f"{self.internal_endpoint}/api/gists/public", f"{self.endpoint}/"]: try: req = urllib.request.Request(test_url, headers={"User-Agent": "ThinkStorm-Orchestrator/0.1"}) loop = asyncio.get_running_loop() def fetch(): with urllib.request.urlopen(req, timeout=5.0) as resp: return resp.status status = await loop.run_in_executor(None, fetch) elapsed = int((time.time() - start) * 1000) return ServiceHealth( service_id=self.service_id, healthy=True, endpoint=self.endpoint, message=f"OpenGist online (HTTP {status})", response_time_ms=elapsed ) except Exception as e: continue elapsed = int((time.time() - start) * 1000) return ServiceHealth( service_id=self.service_id, healthy=False, endpoint=self.endpoint, message="OpenGist connection error", response_time_ms=elapsed ) def get_effective_token(self) -> str: """Retrieves configured API token from instance, DB configuration, or environment.""" if self.api_key: return self.api_key try: from ..database import get_db with get_db() as conn: row = conn.execute("SELECT api_key_raw FROM service_configurations WHERE id = 'opengist'").fetchone() if row and row["api_key_raw"]: return row["api_key_raw"] except Exception: pass return config.services.opengist_api_token or "" async def persist_work_track_outputs( self, idea_id: str, track_name: str, outputs: Dict[str, str] ) -> Dict[str, Any]: """Persists Work Track deliverables to local dossier and OpenGist.""" idea_dir = ARTIFACTS_DIR / idea_id out_subdir = idea_dir / "outputs" / track_name.lower().replace(" ", "-") out_subdir.mkdir(parents=True, exist_ok=True) for filename, content in outputs.items(): file_path = out_subdir / filename file_path.write_text(content, encoding="utf-8") token = self.get_effective_token() gist_url = None if token: loop = asyncio.get_running_loop() def sync_track(): try: files_payload = { f"{track_name.lower().replace(' ', '-')}_{k}": {"content": v} for k, v in outputs.items() } data = { "description": f"ThinkStorm Deliverables - {idea_id} - {track_name}", "public": True, "visibility": "public", "files": files_payload } req_data = json.dumps(data).encode("utf-8") api_target = f"{self.internal_endpoint}/api/gists" req = urllib.request.Request( api_target, data=req_data, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", "User-Agent": "ThinkStorm-Orchestrator/0.1" }, method="POST" ) with urllib.request.urlopen(req, timeout=8.0) as resp: res = json.loads(resp.read().decode("utf-8")) return res.get("html_url") or f"{self.endpoint}/{res.get('id')}" except Exception as e: print(f"[OpenGist] Track sync notice: {e}") return None try: gist_url = await loop.run_in_executor(None, sync_track) except Exception: pass return {"status": "SUCCESS", "path": str(out_subdir), "gist_url": gist_url} async def persist_idea_artifact( self, idea_id: str, title: str, summary: str, original_text: str, categories: List[str], tags: List[str], lifecycle_state: str, research_docs: Dict[str, str], outputs: Dict[str, str], provenance_runs: List[Dict[str, Any]], existing_gist_id: Optional[str] = None, submission_image: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Persists structured idea artifacts to local durable store and syncs with OpenGist.""" # 1. Prepare local disk artifact tree idea_dir = ARTIFACTS_DIR / idea_id idea_dir.mkdir(parents=True, exist_ok=True) (idea_dir / "research").mkdir(parents=True, exist_ok=True) (idea_dir / "provenance").mkdir(parents=True, exist_ok=True) (idea_dir / "outputs").mkdir(parents=True, exist_ok=True) # 2. Write idea.md cat_str = ", ".join(categories) if categories else "General" tag_str = ", ".join(f"#{t}" for t in tags) if tags else "None" img_section = "" if submission_image and submission_image.get("present"): art_id = submission_image.get("artifact_id", "IMG-TS") mime = submission_image.get("mime_type", "image/jpeg") w = submission_image.get("width", 0) h = submission_image.get("height", 0) size_kb = round(submission_image.get("size_bytes", 0) / 1024, 1) sha = submission_image.get("sha256", "") img_section = ( f"## Reference Image\n\n" f"- **Artifact ID:** `{art_id}`\n" f"- **MIME Type:** `{mime}`\n" f"- **Dimensions:** `{w}x{h}`\n" f"- **File Size:** `{size_kb} KB`\n" f"- **SHA-256:** `{sha}`\n\n" ) ctx_section = "" if "image-context.md" in research_docs: ctx_section = f"## Image Context\n\n{research_docs['image-context.md']}\n\n" idea_md = ( f"# {idea_id}: {title}\n\n" f"**Lifecycle State:** `{lifecycle_state}` \n" f"**Categories:** {cat_str} \n" f"**Tags:** {tag_str} \n\n" f"## Summary\n{summary}\n\n" f"## Original Submission\n> {original_text.strip()}\n\n" f"{img_section}" f"{ctx_section}" ) (idea_dir / "idea.md").write_text(idea_md, encoding="utf-8") # 3. Write metadata.json meta = { "id": idea_id, "title": title, "summary": summary, "categories": categories, "tags": tags, "lifecycle_state": lifecycle_state, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) } (idea_dir / "metadata.json").write_text(json.dumps(meta, indent=2), encoding="utf-8") # 4. Write research docs for filename, content in research_docs.items(): safe_name = filename.replace("/", "_") (idea_dir / "research" / safe_name).write_text(content, encoding="utf-8") # 5. Write outputs for filename, content in outputs.items(): out_path = idea_dir / "outputs" / filename out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(content, encoding="utf-8") # 6. Write provenance runs for run in provenance_runs: run_id = run.get("id", f"run-{int(time.time())}") (idea_dir / "provenance" / f"{run_id}.json").write_text(json.dumps(run, indent=2), encoding="utf-8") # 7. Attempt OpenGist API sync if token configured gist_id = existing_gist_id or idea_id.lower() gist_url = f"{self.endpoint}/{gist_id}" token = self.get_effective_token() if token: loop = asyncio.get_running_loop() def sync_remote(): try: files_payload = { "idea.md": {"content": idea_md}, "metadata.json": {"content": json.dumps(meta, indent=2)} } for k, v in research_docs.items(): safe_k = k.replace("/", "_") files_payload[safe_k] = {"content": v} data = { "description": f"ThinkStorm Dossier - {idea_id}: {title}", "public": True, "visibility": "public", "files": files_payload } req_data = json.dumps(data).encode("utf-8") api_target = f"{self.internal_endpoint}/api/gists" req = urllib.request.Request( api_target, data=req_data, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {token}", "User-Agent": "ThinkStorm-Orchestrator/0.1" }, method="POST" ) with urllib.request.urlopen(req, timeout=8.0) as resp: res = json.loads(resp.read().decode("utf-8")) remote_id = res.get("id") or gist_id remote_url = res.get("html_url") or f"{self.endpoint}/{remote_id}" return remote_id, remote_url except Exception as e: print(f"[OpenGist] Remote sync notice: {e}") return gist_id, f"{self.endpoint}/{gist_id}" try: remote_id, remote_url = await loop.run_in_executor(None, sync_remote) gist_id = remote_id or gist_id gist_url = remote_url or f"{self.endpoint}/{gist_id}" except Exception as e: print(f"[OpenGist] Async executor exception: {e}") return { "opengist_id": gist_id, "opengist_url": gist_url, "local_path": str(idea_dir) }