- 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
269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""
|
|
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)
|
|
}
|