- 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
397 lines
17 KiB
Python
397 lines
17 KiB
Python
"""
|
|
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 socket
|
|
import asyncio
|
|
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
|
|
|
|
from .base import BaseServiceAdapter, ServiceHealth
|
|
from ..config import config, BASE_DIR
|
|
|
|
ARTIFACTS_DIR = BASE_DIR / "data" / "artifacts"
|
|
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
class GiteaAdapter(BaseServiceAdapter):
|
|
def __init__(self, endpoint: str = "https://git.labyricorn.com", api_token: str = ""):
|
|
super().__init__(service_id="gitea", endpoint=endpoint, api_key=api_token or config.services.gitea_api_token)
|
|
self.org_name = "thinkstorm"
|
|
|
|
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 = 'gitea'").fetchone()
|
|
if row and row["api_key_raw"]:
|
|
return row["api_key_raw"]
|
|
except Exception:
|
|
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"
|
|
def fetch():
|
|
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(
|
|
service_id=self.service_id,
|
|
healthy=False,
|
|
endpoint=self.endpoint,
|
|
message=f"Gitea connection error: {str(e)}",
|
|
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."""
|
|
if not files_dict or not token:
|
|
return
|
|
|
|
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")
|
|
|
|
b64_content = base64.b64encode(content.encode("utf-8")).decode("utf-8")
|
|
payload = {
|
|
"content": b64_content,
|
|
"message": f"Sync {path} into ThinkStorm dossier",
|
|
"branch": "main"
|
|
}
|
|
|
|
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,
|
|
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_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
|
|
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)
|
|
|
|
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 = (
|
|
f"# {idea_id}: {title}\n\n"
|
|
f"**Lifecycle State:** `{lifecycle_state}` \n"
|
|
f"**Categories:** {cat_str} \n"
|
|
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"
|
|
f"- `provenance/`: Execution run telemetry, model audit trails, and token attribution logs.\n"
|
|
f"- `metadata.json`: Machine-readable metadata schema.\n"
|
|
)
|
|
(idea_dir / "README.md").write_text(readme_md, encoding="utf-8")
|
|
(idea_dir / "idea.md").write_text(readme_md, encoding="utf-8")
|
|
|
|
# 3. Build 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()),
|
|
"organization": self.org_name
|
|
}
|
|
meta_json_str = json.dumps(meta, indent=2)
|
|
(idea_dir / "metadata.json").write_text(meta_json_str, encoding="utf-8")
|
|
|
|
# 4. Research docs
|
|
files_to_sync = {
|
|
"README.md": readme_md,
|
|
"idea.md": readme_md,
|
|
"metadata.json": meta_json_str
|
|
}
|
|
|
|
for filename, content in research_docs.items():
|
|
safe_name = filename.replace("/", "_")
|
|
(idea_dir / "research" / safe_name).write_text(content, encoding="utf-8")
|
|
files_to_sync[f"research/{safe_name}"] = content
|
|
|
|
# 5. 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")
|
|
files_to_sync[f"outputs/{filename}"] = content
|
|
|
|
# 6. Provenance
|
|
for run in provenance_runs:
|
|
run_id = run.get("id", f"run-{int(time.time())}")
|
|
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('-')
|
|
repo_name = f"ts-{clean_slug.replace('ts-', '')}"
|
|
repo_url = existing_repo_url or f"{self.endpoint}/{self.org_name}/{repo_name}"
|
|
clone_url = f"{self.endpoint}/{self.org_name}/{repo_name}.git"
|
|
ssh_url = f"ssh://[email protected]:22/{self.org_name}/{repo_name}.git"
|
|
|
|
token = self.get_effective_token()
|
|
if token:
|
|
loop = asyncio.get_running_loop()
|
|
def sync_gitea():
|
|
try:
|
|
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}"
|
|
except Exception as e:
|
|
print(f"[Gitea] Sync error: {e}")
|
|
return repo_url
|
|
|
|
try:
|
|
actual_url = await loop.run_in_executor(None, sync_gitea)
|
|
repo_url = actual_url or repo_url
|
|
except Exception as e:
|
|
print(f"[Gitea] Async sync exception: {e}")
|
|
|
|
return {
|
|
"repo_name": f"{self.org_name}/{repo_name}",
|
|
"repo_url": repo_url,
|
|
"clone_url": clone_url,
|
|
"ssh_url": ssh_url,
|
|
"local_path": str(idea_dir)
|
|
}
|
|
|
|
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 commits to Gitea project."""
|
|
idea_dir = ARTIFACTS_DIR / idea_id
|
|
out_subdir = idea_dir / "outputs" / track_name.lower().replace(" ", "-")
|
|
out_subdir.mkdir(parents=True, exist_ok=True)
|
|
|
|
files_to_sync = {}
|
|
for filename, content in outputs.items():
|
|
file_path = out_subdir / filename
|
|
file_path.write_text(content, encoding="utf-8")
|
|
files_to_sync[f"outputs/{track_name.lower().replace(' ', '-')}/{filename}"] = content
|
|
|
|
clean_slug = re.sub(r'[^a-zA-Z0-9_-]', '-', idea_id.lower()).strip('-')
|
|
repo_name = f"ts-{clean_slug.replace('ts-', '')}"
|
|
repo_url = f"{self.endpoint}/{self.org_name}/{repo_name}"
|
|
|
|
token = self.get_effective_token()
|
|
if token and files_to_sync:
|
|
loop = asyncio.get_running_loop()
|
|
try:
|
|
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}")
|
|
|
|
return {
|
|
"status": "SUCCESS",
|
|
"path": str(out_subdir),
|
|
"repo_url": repo_url
|
|
}
|
|
|
|
async def graduate_project(self, idea_id: str, title: str, summary: str, repo_name: str = "") -> Dict[str, Any]:
|
|
"""Graduates an incubated Coding Project work track to a dedicated repository under 'thinkstorm'."""
|
|
clean_title = re.sub(r'[^a-zA-Z0-9_-]', '-', title.lower()).strip('-')[:30] or "project"
|
|
slug = repo_name or f"ts-{idea_id.lower()}-{clean_title}"
|
|
repo_url = f"{self.endpoint}/{self.org_name}/{slug}"
|
|
|
|
token = self.get_effective_token()
|
|
if token:
|
|
loop = asyncio.get_running_loop()
|
|
def create_remote():
|
|
try:
|
|
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}")
|
|
return repo_url
|
|
actual_url = await loop.run_in_executor(None, create_remote)
|
|
repo_url = actual_url or repo_url
|
|
|
|
return {
|
|
"repo_name": f"{self.org_name}/{slug}",
|
|
"repo_url": repo_url,
|
|
"graduated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
}
|
|
|
|
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"
|
|
loop = asyncio.get_running_loop()
|
|
def fetch():
|
|
return requests.get(url, headers=self._get_headers(access_token), timeout=8.0)
|
|
try:
|
|
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
|