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

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)
}