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