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:
+159
-137
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user