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
This commit is contained in:
2026-08-23 01:40:22 -07:00
parent 94ff1e4408
commit 41e08611c9
30 changed files with 3784 additions and 278 deletions
+159 -137
View File
@@ -2,17 +2,23 @@
Gitea Service Adapter
Handles Idea Dossier repository creation, full document tree synchronization,
organization management (under 'thinkstorm' org), and OAuth2 authentication.
Uses direct Gitea REST API v1 over HTTPS for fast, reliable, atomic synchronization.
"""
import json
import time
import re
import base64
import urllib.request
import urllib.parse
import urllib.error
import socket
import asyncio
import subprocess
import urllib3.util.connection as urllib3_cn
try:
urllib3_cn.allowed_gai_family = lambda: socket.AF_INET
except Exception:
pass
import requests
from pathlib import Path
from typing import Dict, Any, Optional, List
@@ -41,27 +47,45 @@ class GiteaAdapter(BaseServiceAdapter):
pass
return config.services.gitea_api_token or ""
def _get_headers(self, token: Optional[str] = None) -> Dict[str, str]:
t = token or self.get_effective_token()
headers = {
"User-Agent": "ThinkStorm-Orchestrator/0.1",
"Content-Type": "application/json"
}
if t:
headers["Authorization"] = f"token {t}"
return headers
async def check_health(self) -> ServiceHealth:
start = time.time()
loop = asyncio.get_running_loop()
try:
url = f"{self.endpoint}/api/v1/version"
req = urllib.request.Request(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.read()
raw = await loop.run_in_executor(None, fetch)
data = json.loads(raw.decode("utf-8"))
version = data.get("version", "unknown")
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
service_id=self.service_id,
healthy=True,
endpoint=self.endpoint,
message=f"Gitea online (v{version})",
response_time_ms=elapsed,
extra={"version": version}
)
return requests.get(url, headers={"User-Agent": "ThinkStorm-Orchestrator/0.1"}, timeout=6.0)
resp = await loop.run_in_executor(None, fetch)
if resp.status_code == 200:
data = resp.json()
version = data.get("version", "unknown")
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
service_id=self.service_id,
healthy=True,
endpoint=self.endpoint,
message=f"Gitea online (v{version})",
response_time_ms=elapsed,
extra={"version": version}
)
else:
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
service_id=self.service_id,
healthy=False,
endpoint=self.endpoint,
message=f"Gitea HTTP error: {resp.status_code}",
response_time_ms=elapsed
)
except Exception as e:
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
@@ -72,66 +96,80 @@ class GiteaAdapter(BaseServiceAdapter):
response_time_ms=elapsed
)
def _ensure_org(self, session: requests.Session, token: str) -> None:
"""Ensures the 'thinkstorm' organization exists."""
headers = self._get_headers(token)
try:
r = session.get(f"{self.endpoint}/api/v1/orgs/{self.org_name}", headers=headers, timeout=8)
if r.status_code == 200:
return
if r.status_code == 404:
payload = {
"username": self.org_name,
"full_name": "ThinkStorm Idea Incubation",
"description": "Canonical dossiers, research, and project incubations generated by ThinkStorm",
"visibility": "public"
}
session.post(f"{self.endpoint}/api/v1/orgs", json=payload, headers=headers, timeout=8)
except Exception as e:
print(f"[Gitea] Org ensure notice: {e}")
def _ensure_repo(self, session: requests.Session, token: str, repo_name: str, description: str) -> bool:
"""Ensures the repository exists under the organization."""
headers = self._get_headers(token)
try:
r = session.get(f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_name}", headers=headers, timeout=8)
if r.status_code == 200:
return True
if r.status_code == 404:
payload = {
"name": repo_name,
"description": description,
"private": False,
"auto_init": True,
"default_branch": "main"
}
cr = session.post(f"{self.endpoint}/api/v1/orgs/{self.org_name}/repos", json=payload, headers=headers, timeout=10)
return cr.status_code in (200, 201)
except Exception as e:
print(f"[Gitea] Repo ensure error for {repo_name}: {e}")
return False
return False
def _sync_files_via_api(self, token: str, repo_slug: str, files_dict: Dict[str, str]) -> None:
"""Commits or updates multiple files directly into the Gitea repository via Contents API."""
script_payload = {
"token": token,
"repo": f"{self.org_name}/{repo_slug}",
"files": files_dict
}
py_code = f"""
import urllib.request, json, base64, sys
if not files_dict or not token:
return
data = json.loads({json.dumps(json.dumps(script_payload))})
token = data['token']
repo = data['repo']
files = data['files']
headers = self._get_headers(token)
with requests.Session() as s:
for path, content in files_dict.items():
try:
# 1. Check if file already exists in repo to get current SHA
get_url = f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_slug}/contents/{path}"
r = s.get(get_url, headers=headers, timeout=8)
sha = None
if r.status_code == 200:
sha = r.json().get("sha")
for path, content in files.items():
get_url = f'https://git.labyricorn.com/api/v1/repos/{{repo}}/contents/{{path}}'
sha = None
try:
req = urllib.request.Request(get_url, headers={{'Authorization': f'token {{token}}', 'User-Agent': 'ThinkStorm/0.1'}})
with urllib.request.urlopen(req, timeout=8) as resp:
d = json.loads(resp.read().decode('utf-8'))
sha = d.get('sha')
except Exception:
pass
b64_content = base64.b64encode(content.encode("utf-8")).decode("utf-8")
payload = {
"content": b64_content,
"message": f"Sync {path} into ThinkStorm dossier",
"branch": "main"
}
payload = {{
'content': base64.b64encode(content.encode('utf-8')).decode('utf-8'),
'message': f'Sync {{path}} into ThinkStorm dossier',
'branch': 'main'
}}
if sha:
payload['sha'] = sha
method = 'PUT'
else:
method = 'POST'
url = f'https://git.labyricorn.com/api/v1/repos/{{repo}}/contents/{{path}}'
req = urllib.request.Request(
url,
data=json.dumps(payload).encode('utf-8'),
headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}', 'User-Agent': 'ThinkStorm/0.1'}},
method=method
)
try:
with urllib.request.urlopen(req, timeout=8) as resp:
pass
except Exception as e:
print(f'File sync notice for {{path}}: {{e}}')
"""
try:
subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "[email protected]", f"python3 -c {json.dumps(py_code)}"],
capture_output=True,
text=True,
timeout=25
)
except Exception as e:
print(f"[Gitea] Remote file commit notice: {e}")
if sha:
payload["sha"] = sha
put_res = s.put(get_url, json=payload, headers=headers, timeout=10)
if put_res.status_code not in (200, 201):
print(f"[Gitea] Update notice for {path}: {put_res.status_code} {put_res.text[:100]}")
else:
post_res = s.post(get_url, json=payload, headers=headers, timeout=10)
if post_res.status_code not in (200, 201):
print(f"[Gitea] Create notice for {path}: {post_res.status_code} {post_res.text[:100]}")
except Exception as e:
print(f"[Gitea] File sync notice for {path}: {e}")
async def persist_idea_dossier_repo(
self,
@@ -145,7 +183,8 @@ for path, content in files.items():
research_docs: Dict[str, str],
outputs: Dict[str, str],
provenance_runs: List[Dict[str, Any]],
existing_repo_url: Optional[str] = None
existing_repo_url: Optional[str] = None,
submission_image: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Creates a dedicated Gitea Project repository and synchronizes all files into it."""
# 1. Local disk directory
@@ -157,6 +196,27 @@ for path, content in files.items():
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"
# 2. Build README.md
readme_md = (
@@ -166,6 +226,8 @@ for path, content in files.items():
f"**Tags:** {tag_str} \n\n"
f"## Executive Summary\n{summary}\n\n"
f"## Original Submission Prompt\n> {original_text.strip()}\n\n"
f"{img_section}"
f"{ctx_section}"
f"## Project Structure\n"
f"- `research/`: Automated competitor analysis, prior art, deep research, and technical feasibility reports.\n"
f"- `outputs/`: Multi-modal work track deliverables (articles, code scaffolds, business models).\n"
@@ -192,6 +254,7 @@ for path, content in files.items():
# 4. Research docs
files_to_sync = {
"README.md": readme_md,
"idea.md": readme_md,
"metadata.json": meta_json_str
}
@@ -210,7 +273,9 @@ for path, content in files.items():
# 6. Provenance
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")
run_json = json.dumps(run, indent=2)
(idea_dir / "provenance" / f"{run_id}.json").write_text(run_json, encoding="utf-8")
files_to_sync[f"provenance/{run_id}.json"] = run_json
# 7. Gitea Repository sync under 'thinkstorm' organization
clean_slug = re.sub(r'[^a-zA-Z0-9_-]', '-', idea_id.lower()).strip('-')
@@ -224,37 +289,10 @@ for path, content in files.items():
loop = asyncio.get_running_loop()
def sync_gitea():
try:
# 1. Create repo under org if missing
create_script = f"""
import urllib.request, json
token = '{token}'
repo_name = '{repo_name}'
org = '{self.org_name}'
# Ensure org
try:
req = urllib.request.Request(f'https://git.labyricorn.com/api/v1/orgs/{{org}}', headers={{'Authorization': f'token {{token}}'}})
with urllib.request.urlopen(req, timeout=5): pass
except Exception:
try:
req = urllib.request.Request('https://git.labyricorn.com/api/v1/orgs', data=json.dumps({{'username': org, 'visibility': 'public'}}).encode(), headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}'}}, method='POST')
with urllib.request.urlopen(req, timeout=5): pass
except Exception: pass
# Create repo
try:
payload = {{'name': repo_name, 'description': f'[{idea_id}] {title}', 'private': False, 'auto_init': True}}
req = urllib.request.Request(f'https://git.labyricorn.com/api/v1/orgs/{{org}}/repos', data=json.dumps(payload).encode(), headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}'}}, method='POST')
with urllib.request.urlopen(req, timeout=6): pass
except Exception: pass
"""
subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "[email protected]", f"python3 -c {json.dumps(create_script)}"],
capture_output=True,
text=True,
timeout=10
)
with requests.Session() as s:
# 1. Ensure org and repo exist
self._ensure_org(s, token)
self._ensure_repo(s, token, repo_name, f"[{idea_id}] {title}")
# 2. Push full file tree to Gitea
self._sync_files_via_api(token, repo_name, files_to_sync)
return f"{self.endpoint}/{self.org_name}/{repo_name}"
@@ -301,7 +339,11 @@ except Exception: pass
if token and files_to_sync:
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(None, lambda: self._sync_files_via_api(token, repo_name, files_to_sync))
def do_sync():
with requests.Session() as s:
self._ensure_repo(s, token, repo_name, f"[{idea_id}] Project Dossier")
self._sync_files_via_api(token, repo_name, files_to_sync)
await loop.run_in_executor(None, do_sync)
except Exception as e:
print(f"[Gitea] Work track sync notice: {e}")
@@ -322,23 +364,9 @@ except Exception: pass
loop = asyncio.get_running_loop()
def create_remote():
try:
create_script = f"""
import urllib.request, json
token = '{token}'
slug = '{slug}'
org = '{self.org_name}'
payload = {{'name': slug, 'description': f'[{idea_id}] {title}', 'private': False, 'auto_init': True}}
req = urllib.request.Request(f'https://git.labyricorn.com/api/v1/orgs/{{org}}/repos', data=json.dumps(payload).encode(), headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}'}}, method='POST')
try:
with urllib.request.urlopen(req, timeout=6): pass
except Exception: pass
"""
subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "[email protected]", f"python3 -c {json.dumps(create_script)}"],
capture_output=True,
text=True,
timeout=10
)
with requests.Session() as s:
self._ensure_org(s, token)
self._ensure_repo(s, token, slug, f"[{idea_id}] {title}")
return f"{self.endpoint}/{self.org_name}/{slug}"
except Exception as e:
print(f"[Gitea] Project graduation notice: {e}")
@@ -355,20 +383,14 @@ except Exception: pass
async def verify_oauth_user(self, access_token: str) -> Optional[Dict[str, Any]]:
"""Fetches user profile details using Gitea OAuth access token."""
url = f"{self.endpoint}/api/v1/user"
req = urllib.request.Request(
url,
headers={
"Authorization": f"token {access_token}",
"User-Agent": "ThinkStorm-Orchestrator/0.1"
}
)
loop = asyncio.get_running_loop()
def fetch():
with urllib.request.urlopen(req, timeout=8.0) as resp:
return resp.read()
return requests.get(url, headers=self._get_headers(access_token), timeout=8.0)
try:
raw = await loop.run_in_executor(None, fetch)
return json.loads(raw.decode("utf-8"))
resp = await loop.run_in_executor(None, fetch)
if resp.status_code == 200:
return resp.json()
return None
except Exception as e:
print(f"[Gitea] OAuth user verify error: {e}")
return None
+249
View File
@@ -0,0 +1,249 @@
"""
ThinkStorm Image Intake & Sanitization Service
Handles safe image validation, EXIF/GPS metadata stripping, token-optimized vision resizing,
durable artifact persistence, and canonical metadata generation.
"""
import io
import os
import hashlib
from pathlib import Path
from typing import Optional, Dict, Any, Tuple
from PIL import Image, ImageOps
from ..config import config, BASE_DIR
ARTIFACTS_DIR = BASE_DIR / "data" / "artifacts"
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
# Map PIL format strings to canonical MIME types and file extensions
FORMAT_TO_MIME = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"WEBP": "image/webp"
}
MIME_TO_EXT = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp"
}
class ImageValidationError(Exception):
"""Raised when an uploaded or received file fails image validation."""
pass
class ImageTooLargeError(ImageValidationError):
"""Raised when an image exceeds configured size limit."""
pass
class ImageFormatError(ImageValidationError):
"""Raised when image format is unsupported or invalid."""
pass
def sanitize_filename(filename: Optional[str], default_ext: str = ".jpg") -> str:
"""Strips path traversal elements and unsafe characters from original filename."""
if not filename:
return f"submission_image{default_ext}"
base = os.path.basename(filename).strip()
# Remove null bytes and non-printable characters
clean = "".join(c for c in base if c.isalnum() or c in "._- ")
if not clean or clean.startswith("."):
return f"submission_image{default_ext}"
return clean[:128]
def validate_and_sanitize_image(
raw_bytes: bytes,
original_filename: str = "",
source: str = "web",
idea_id: Optional[str] = None
) -> Tuple[bytes, Dict[str, Any]]:
"""
Validates, strips metadata, and generates canonical sanitized image bytes and metadata.
Returns:
Tuple[bytes, Dict[str, Any]]: (sanitized_bytes, metadata_dict)
Raises:
ImageTooLargeError: If payload exceeds max size.
ImageFormatError: If data is corrupted or unsupported format.
"""
if not raw_bytes:
raise ImageFormatError("Image payload cannot be empty.")
max_bytes = config.max_image_upload_bytes
if len(raw_bytes) > max_bytes:
raise ImageTooLargeError(
f"Image size ({len(raw_bytes)} bytes) exceeds maximum limit of {max_bytes} bytes ({max_bytes // (1024 * 1024)}MB)."
)
# 1. Open and verify image structure with Pillow
try:
in_stream = io.BytesIO(raw_bytes)
img = Image.open(in_stream)
img_format = img.format
except Exception as e:
raise ImageFormatError(f"Could not decode image header: {str(e)}")
if not img_format or img_format.upper() not in FORMAT_TO_MIME:
allowed_str = ", ".join(config.allowed_image_mime_types)
raise ImageFormatError(
f"Unsupported image format: '{img_format}'. Supported formats are: {allowed_str} (JPEG, PNG, WebP)."
)
canonical_mime = FORMAT_TO_MIME[img_format.upper()]
# 2. Decode pixel data to ensure file is not truncated or corrupted
try:
# Respect EXIF orientation before stripping EXIF metadata
img = ImageOps.exif_transpose(img)
img.load()
except Exception as e:
raise ImageFormatError(f"Malformed or corrupt image pixel stream: {str(e)}")
# 3. Create sanitized copy (stripping EXIF, GPS, camera metadata, comments)
out_format = img_format.upper()
out_stream = io.BytesIO()
try:
if out_format == "JPEG":
# Convert palette/RGBA modes to RGB for JPEG
if img.mode in ("RGBA", "LA", "P"):
rgb_img = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
rgb_img.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
img = rgb_img
elif img.mode != "RGB":
img = img.convert("RGB")
img.save(out_stream, format="JPEG", quality=92, optimize=True)
elif out_format == "PNG":
# Preserve RGB / RGBA transparency
if img.mode not in ("RGB", "RGBA", "L", "LA"):
img = img.convert("RGBA" if "transparency" in img.info else "RGB")
img.save(out_stream, format="PNG", optimize=True)
elif out_format == "WEBP":
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGBA" if "transparency" in img.info else "RGB")
img.save(out_stream, format="WEBP", quality=90, method=6)
else:
raise ImageFormatError(f"Unsupported save format: {out_format}")
except Exception as e:
raise ImageFormatError(f"Failed to encode sanitized image: {str(e)}")
sanitized_bytes = out_stream.getvalue()
width, height = img.size
sha256_hash = hashlib.sha256(sanitized_bytes).hexdigest()
ext = MIME_TO_EXT.get(canonical_mime, ".jpg")
safe_filename = sanitize_filename(original_filename, default_ext=ext)
artifact_id = f"IMG-{idea_id}" if idea_id else f"IMG-{sha256_hash[:12].upper()}"
metadata: Dict[str, Any] = {
"present": True,
"artifact_id": artifact_id,
"original_filename": safe_filename,
"mime_type": canonical_mime,
"size_bytes": len(sanitized_bytes),
"width": width,
"height": height,
"sha256": sha256_hash,
"source": source,
"vision_analysis_run_id": None
}
return sanitized_bytes, metadata
def save_image_artifact(idea_id: str, sanitized_bytes: bytes, metadata: Dict[str, Any]) -> str:
"""
Saves the canonical sanitized image file to the idea's artifact directory.
Returns relative or absolute path to saved artifact.
"""
idea_dir = ARTIFACTS_DIR / idea_id
idea_dir.mkdir(parents=True, exist_ok=True)
artifact_id = metadata.get("artifact_id") or f"IMG-{idea_id}"
mime = metadata.get("mime_type", "image/jpeg")
ext = MIME_TO_EXT.get(mime, ".jpg")
file_path = idea_dir / f"{artifact_id}{ext}"
file_path.write_bytes(sanitized_bytes)
# Also keep a predictable reference file for easy retrieval
canonical_link = idea_dir / f"reference_image{ext}"
if canonical_link != file_path:
canonical_link.write_bytes(sanitized_bytes)
return str(file_path)
def get_image_artifact_path(idea_id: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[Path]:
"""Retrieves file path to an idea's saved reference image."""
idea_dir = ARTIFACTS_DIR / idea_id
if not idea_dir.exists():
return None
if metadata and metadata.get("artifact_id"):
artifact_id = metadata["artifact_id"]
mime = metadata.get("mime_type", "image/jpeg")
ext = MIME_TO_EXT.get(mime, ".jpg")
path = idea_dir / f"{artifact_id}{ext}"
if path.exists():
return path
# Fallback to search any image in idea artifact directory
for candidate_ext in [".jpg", ".jpeg", ".png", ".webp"]:
ref = idea_dir / f"reference_image{candidate_ext}"
if ref.exists():
return ref
img_match = list(idea_dir.glob(f"IMG-*{candidate_ext}"))
if img_match:
return img_match[0]
return None
def create_token_optimized_vision_payload(
sanitized_bytes: bytes,
mime_type: str = "image/jpeg"
) -> Tuple[bytes, str]:
"""
Optimizes and downscales image dimensions to bound vision LLM token consumption.
Bounds maximum dimension to `config.vision_max_dimension` (default 1536px)
and compresses to efficient JPEG to minimize token expenditure across vision grid tiles.
Returns:
Tuple[bytes, str]: (optimized_bytes, optimized_mime_type)
"""
try:
img = Image.open(io.BytesIO(sanitized_bytes))
width, height = img.size
max_dim = config.vision_max_dimension
# Downscale if larger than max_dim while preserving aspect ratio
if width > max_dim or height > max_dim:
img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)
# Convert to RGB if needed for JPEG compression
if img.mode in ("RGBA", "LA", "P"):
rgb_img = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
rgb_img.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
img = rgb_img
elif img.mode != "RGB":
img = img.convert("RGB")
out_buf = io.BytesIO()
img.save(
out_buf,
format="JPEG",
quality=config.vision_jpeg_quality,
optimize=True
)
return out_buf.getvalue(), "image/jpeg"
except Exception:
# Fallback to original sanitized bytes if downscaling fails
return sanitized_bytes, mime_type
+47 -8
View File
@@ -7,6 +7,7 @@ Tracks token usage (input_tokens, output_tokens, total_tokens) and handles model
import json
import time
import re
import base64
import urllib.request
import asyncio
from typing import Dict, Any, Optional, Tuple
@@ -21,7 +22,7 @@ class OmniRouteAdapter(BaseServiceAdapter):
start = time.time()
try:
url = f"{self.endpoint}/models"
headers = {"User-Agent": "ThinkStorm-Orchestrator/0.1"}
headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
req = urllib.request.Request(url, headers=headers)
@@ -57,6 +58,8 @@ class OmniRouteAdapter(BaseServiceAdapter):
return config.services.omniroute_model_fast or "openrouter/openai/gpt-oss-20b:free-low"
elif policy == "coding":
return config.services.omniroute_model_coding or "auto/best-coding"
elif policy == "vision":
return getattr(config, "omniroute_model_vision", "auto/best-vision") or config.services.omniroute_model_reasoning or "auto/best-reasoning"
elif policy == "research" or policy == "reasoning":
return config.services.omniroute_model_reasoning or "auto/best-reasoning"
return config.services.omniroute_model_reasoning or "auto/best-reasoning"
@@ -68,16 +71,35 @@ class OmniRouteAdapter(BaseServiceAdapter):
model_policy: str = "reasoning",
max_tokens: int = 1500,
temperature: float = 0.7,
model_override: Optional[str] = None
model_override: Optional[str] = None,
image_bytes: Optional[bytes] = None,
image_mime_type: Optional[str] = None
) -> Dict[str, Any]:
"""Executes a chat completion via OmniRoute with provenance token tracking."""
model = model_override.strip() if model_override and model_override.strip() else self.resolve_model(model_policy)
"""Executes a chat completion via OmniRoute with provenance token tracking and optional vision input."""
effective_policy = "vision" if image_bytes else model_policy
model = model_override.strip() if model_override and model_override.strip() else self.resolve_model(effective_policy)
url = f"{self.endpoint}/chat/completions"
if image_bytes:
b64_img = base64.b64encode(image_bytes).decode("utf-8")
mime = image_mime_type or "image/jpeg"
user_content: Any = [
{"type": "text", "text": user_prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:{mime};base64,{b64_img}"
}
}
]
else:
user_content = user_prompt
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
{"role": "user", "content": user_content}
],
"max_tokens": max_tokens,
"temperature": temperature
@@ -85,7 +107,7 @@ class OmniRouteAdapter(BaseServiceAdapter):
headers = {
"Content-Type": "application/json",
"User-Agent": "ThinkStorm-Orchestrator/0.1"
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
@@ -217,9 +239,26 @@ class OmniRouteAdapter(BaseServiceAdapter):
return {}
def _generate_fallback(self, system: str, user_prompt: str, policy: str) -> str:
"""Deterministic rich heuristic fallback when remote provider is unreachable."""
"""Deterministic rich heuristic fallback when remote provider is unreachable or declines input."""
# Check if Image Context is requested
if "Visual Context" in system or "Image Context" in system or "reference image" in user_prompt.lower():
lines = [l.strip() for l in user_prompt.splitlines() if l.strip() and not l.startswith("<") and not l.startswith("#") and not l.startswith("-")]
topic = lines[0] if lines else "Submitted Reference Artifact"
return (
f"# Image Context\n\n"
f"## Observed\n"
f"- Reference visual artifact provided as supplementary context for: {topic}.\n"
f"- Visual structure exhibits conceptual layout, functional architecture, or interface blueprint.\n\n"
f"## Relevant to the Idea\n"
f"- Serves as foundational context guiding requirements specification, topology, and workflows.\n\n"
f"## Possible Constraints\n"
f"- Automated high-dimensional visual parsing operating under fallback mode.\n"
f"- Explicit interface and architectural constraints should be confirmed against core text.\n\n"
f"## Uncertain\n"
f"- Fine-grained diagrammatic notations and nested component labels require explicit validation."
)
# Check if JSON is expected
if "JSON" in system or "JSON" in user_prompt:
elif "JSON" in system or "JSON" in user_prompt:
words = user_prompt.replace("\n", " ").split()
title = " ".join(words[:6]).replace("<untrusted_submission>", "").strip() or "Untitled Incubation Idea"
if len(title) > 60:
+27 -2
View File
@@ -134,7 +134,8 @@ class OpenGistAdapter(BaseServiceAdapter):
research_docs: Dict[str, str],
outputs: Dict[str, str],
provenance_runs: List[Dict[str, Any]],
existing_gist_id: Optional[str] = None
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
@@ -147,13 +148,37 @@ class OpenGistAdapter(BaseServiceAdapter):
# 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"
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")
+197
View File
@@ -0,0 +1,197 @@
"""
Signal Gateway Service Adapter
Manages outbound messaging and health checks with the Signal Gateway on the private LAN.
"""
import json
import time
import urllib.request
import urllib.error
import asyncio
from typing import Dict, Any, Optional
from .base import BaseServiceAdapter, ServiceHealth
from ..config import config
class SignalGatewayError(Exception):
"""Base exception for Signal Gateway operations."""
pass
class SignalGatewayAuthError(SignalGatewayError):
"""Raised when authentication fails (HTTP 401)."""
pass
class SignalGatewayClientError(SignalGatewayError):
"""Raised when request payload is invalid (HTTP 400)."""
pass
class SignalGatewayPayloadTooLargeError(SignalGatewayError):
"""Raised when payload exceeds gateway size limit (HTTP 413)."""
pass
class SignalGatewayUnavailableError(SignalGatewayError):
"""Raised when the Signal Gateway service is unavailable (HTTP 503)."""
pass
class SignalGatewayTimeoutError(SignalGatewayError):
"""Raised when requests to the gateway time out."""
pass
def mask_secret(secret: Optional[str]) -> str:
"""Safely masks secret credentials for logs/diagnostics."""
if not secret:
return ""
if len(secret) <= 8:
return "***"
return f"{secret[:4]}...{secret[-4:]}"
class SignalGatewayAdapter(BaseServiceAdapter):
def __init__(self, endpoint: str = "", api_key: str = ""):
ep = endpoint or config.services.signal_gateway_base_url or "http://10.138.4.46:8000"
key = api_key or config.services.signal_gateway_api_key or ""
super().__init__(service_id="signal_gateway", endpoint=ep, api_key=key)
self.timeout = config.services.signal_gateway_timeout_seconds
def get_effective_api_key(self) -> str:
"""Retrieves configured API key from instance, config, or database configuration."""
if self.api_key:
return self.api_key
if config.services.signal_gateway_api_key:
return config.services.signal_gateway_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 = 'signal_gateway'").fetchone()
if row and row["api_key_raw"]:
return row["api_key_raw"]
except Exception:
pass
return ""
async def check_health(self) -> ServiceHealth:
"""Checks connectivity against Gateway /ready and /health endpoints."""
start = time.time()
ready_url = f"{self.endpoint}/ready"
health_url = f"{self.endpoint}/health"
loop = asyncio.get_running_loop()
def probe():
req = urllib.request.Request(ready_url, headers={"User-Agent": "ThinkStorm-Orchestrator/0.1"})
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read().decode("utf-8")
return resp.status, json.loads(raw) if raw else {}
try:
status_code, data = await loop.run_in_executor(None, probe)
elapsed = int((time.time() - start) * 1000)
is_ready = data.get("status") == "ready"
return ServiceHealth(
service_id=self.service_id,
healthy=is_ready,
endpoint=self.endpoint,
message=f"Signal Gateway online (status: {data.get('status', 'ok')})",
response_time_ms=elapsed,
extra=data
)
except urllib.error.HTTPError as e:
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
service_id=self.service_id,
healthy=False,
endpoint=self.endpoint,
message=f"Signal Gateway HTTP error: {e.code}",
response_time_ms=elapsed
)
except Exception as e:
elapsed = int((time.time() - start) * 1000)
return ServiceHealth(
service_id=self.service_id,
healthy=False,
endpoint=self.endpoint,
message=f"Signal Gateway unreachable: {str(e)}",
response_time_ms=elapsed
)
async def send_message(self, recipient: str, text: str, max_retries: int = 2) -> Dict[str, Any]:
"""
Sends an outbound message via Signal Gateway POST /api/v1/messages.
Constraints:
- recipient: nonempty, <= 256 bytes UTF-8
- text: 1 to 16,000 bytes UTF-8
- unknown fields rejected
"""
if not recipient or not recipient.strip():
raise SignalGatewayClientError("Recipient must not be empty.")
recipient_bytes = recipient.encode("utf-8")
if len(recipient_bytes) > 256:
raise SignalGatewayClientError("Recipient exceeds maximum allowed length of 256 bytes.")
if not text or not text.strip():
raise SignalGatewayClientError("Message text must not be empty.")
text_bytes = text.encode("utf-8")
if len(text_bytes) < 1 or len(text_bytes) > 16000:
raise SignalGatewayPayloadTooLargeError("Message text must be between 1 and 16,000 bytes.")
api_key = self.get_effective_api_key()
if not api_key:
raise SignalGatewayAuthError("Signal Gateway application API key is not configured.")
url = f"{self.endpoint}/api/v1/messages"
payload = {
"recipient": recipient,
"text": text
}
payload_data = json.dumps(payload).encode("utf-8")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "ThinkStorm-Orchestrator/0.1"
}
loop = asyncio.get_running_loop()
def make_request():
req = urllib.request.Request(url, data=payload_data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw_resp = resp.read().decode("utf-8")
return resp.status, json.loads(raw_resp) if raw_resp else {}
attempt = 0
while True:
try:
status_code, data = await loop.run_in_executor(None, make_request)
if status_code in (200, 202):
return data
return data
except urllib.error.HTTPError as e:
err_code = e.code
err_body = ""
try:
err_body = e.read().decode("utf-8")
err_json = json.loads(err_body)
err_msg = err_json.get("error", {}).get("message", e.reason)
except Exception:
err_msg = e.reason
if err_code == 400:
raise SignalGatewayClientError(f"Invalid request (400): {err_msg}")
elif err_code == 401:
raise SignalGatewayAuthError("Signal Gateway application key is invalid or revoked (401).")
elif err_code == 413:
raise SignalGatewayPayloadTooLargeError(f"Request payload too large (413): {err_msg}")
elif err_code == 503:
if attempt < max_retries:
attempt += 1
await asyncio.sleep(0.5 * attempt)
continue
raise SignalGatewayUnavailableError(f"Signal Gateway runtime or send queue unavailable (503): {err_msg}")
else:
raise SignalGatewayError(f"Signal Gateway HTTP error {err_code}: {err_msg}")
except (TimeoutError, urllib.error.URLError) as e:
if isinstance(e, urllib.error.URLError) and "timed out" in str(e.reason).lower():
raise SignalGatewayTimeoutError("Signal Gateway outbound request timed out.")
if isinstance(e, TimeoutError):
raise SignalGatewayTimeoutError("Signal Gateway outbound request timed out.")
raise SignalGatewayError(f"Signal Gateway connection error: {str(e)}")
except Exception as e:
raise SignalGatewayError(f"Unexpected error communicating with Signal Gateway: {str(e)}")