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
+5 -1
View File
@@ -22,6 +22,7 @@ from ..services.perplexica import PerplexicaAdapter
from ..services.opengist import OpenGistAdapter
from ..services.gitea import GiteaAdapter
from ..services.virustotal import VirusTotalAdapter
from ..services.signal_gateway import SignalGatewayAdapter
from ..queue.worker import job_queue
router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(require_admin)])
@@ -32,6 +33,7 @@ perplexica_svc = PerplexicaAdapter()
opengist_svc = OpenGistAdapter()
gitea_svc = GiteaAdapter()
virustotal_svc = VirusTotalAdapter()
signal_gateway_svc = SignalGatewayAdapter()
class PromptUpdateRequest(BaseModel):
name: Optional[str] = None
@@ -151,6 +153,8 @@ async def test_service_connection(service_id: str):
adapter = perplexica_svc
elif service_id == "virustotal":
adapter = virustotal_svc
elif service_id == "signal_gateway":
adapter = signal_gateway_svc
else:
raise HTTPException(status_code=404, detail=f"Service '{service_id}' not found.")
@@ -247,7 +251,7 @@ async def list_jobs():
async def retry_idea_job(idea_id: str):
with get_db() as conn:
conn.execute("UPDATE ideas SET processing_state = 'QUEUED' WHERE id = ?", (idea_id,))
await job_queue.enqueue_foreground("intake", idea_id)
await job_queue.enqueue_foreground("intake", idea_id, {"bypass_duplicate_check": True})
return {"message": f"Idea {idea_id} enqueued for processing retry."}
# ----------------- Moderation & Quarantine -----------------
+15 -19
View File
@@ -4,6 +4,7 @@ Handles login, logout, current user session status, and Gitea OAuth2 flow.
"""
import json
import requests
import urllib.request
import urllib.parse
from typing import Optional, Dict, Any
@@ -111,41 +112,36 @@ async def gitea_oauth_callback(request: Request, code: str = "", error: str = ""
if client_secret and client_id:
try:
token_url = f"{config.services.gitea_url}/login/oauth/access_token"
data = urllib.parse.urlencode({
payload = {
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri
}).encode("utf-8")
req = urllib.request.Request(
token_url,
data=data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"User-Agent": "ThinkStorm-Orchestrator/0.1"
},
method="POST"
)
with urllib.request.urlopen(req, timeout=10.0) as resp:
resp_bytes = resp.read()
}
headers = {
"Accept": "application/json",
"User-Agent": "ThinkStorm-Orchestrator/0.1"
}
resp = requests.post(token_url, data=payload, headers=headers, timeout=10.0)
if resp.status_code == 200:
try:
token_data = json.loads(resp_bytes.decode("utf-8"))
token_data = resp.json()
except Exception:
token_data = urllib.parse.parse_qs(resp_bytes.decode("utf-8"))
token_data = urllib.parse.parse_qs(resp.text)
token_data = {k: v[0] for k, v in token_data.items()}
access_token = token_data.get("access_token")
if access_token:
user_info = await gitea_svc.verify_oauth_user(access_token)
else:
print(f"[Gitea OAuth] Token exchange returned status {resp.status_code}: {resp.text[:150]}")
except Exception as e:
print(f"[Gitea OAuth] Token exchange error: {e}")
# Fallback to default Gitea session if exchange failed
if not user_info:
user_info = {"id": 1001, "login": "gitea-user", "is_admin": False}
print("[Gitea OAuth] Could not fetch valid user profile from Gitea OAuth access token.")
return RedirectResponse(url="/login?error=oauth_verify_failed", status_code=status.HTTP_303_SEE_OTHER)
user = get_or_create_gitea_user(user_info)
token = create_session(user.id, user.username, user.role.value)
+147 -8
View File
@@ -7,6 +7,7 @@ import json
import time
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Request, HTTPException, status, Depends
from fastapi.responses import FileResponse
from pydantic import BaseModel
from ..config import config
@@ -19,6 +20,12 @@ from ..auth import get_current_user, require_authenticated, require_admin
from ..queue.worker import job_queue
from ..services.gitea import GiteaAdapter
from ..services.opengist import OpenGistAdapter
from ..services.image_handler import (
validate_and_sanitize_image,
save_image_artifact,
get_image_artifact_path,
ImageValidationError
)
from ..prompts.catalog import get_all_prompts
router = APIRouter(prefix="/api/ideas", tags=["ideas"])
@@ -43,15 +50,48 @@ class WorkTrackActivateRequest(BaseModel):
model_override: Optional[str] = None
@router.post("", status_code=status.HTTP_201_CREATED)
async def submit_idea(payload: IdeaSubmissionRequest, request: Request):
async def submit_idea(request: Request):
"""
Public Anonymous Idea Submission.
- Zero authentication required
- Supports JSON body ({"text": "..."}) and multipart/form-data (text + optional image)
- Single image accepted, sanitized (EXIF/GPS stripped), and preserved as artifact
- Automatic TS-xxxx ID assignment
- Immutable original text preservation
- Rate-limiting & abuse prevention
"""
raw_text = payload.text.strip()
content_type = request.headers.get("content-type", "")
raw_text = ""
image_bytes = None
image_filename = ""
if "application/json" in content_type:
try:
body = await request.json()
if isinstance(body, dict):
raw_text = str(body.get("text") or "")
except Exception:
raise HTTPException(status_code=400, detail="Malformed JSON body.")
elif "multipart/form-data" in content_type or "application/x-www-form-urlencoded" in content_type:
try:
form = await request.form()
raw_text = str(form.get("text") or "")
img_item = form.get("image") or form.get("file")
if img_item and hasattr(img_item, "read"):
image_bytes = await img_item.read()
image_filename = getattr(img_item, "filename", "")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid form data: {str(e)}")
else:
# Fallback attempt JSON
try:
body = await request.json()
if isinstance(body, dict):
raw_text = str(body.get("text") or "")
except Exception:
raise HTTPException(status_code=400, detail="Invalid submission request.")
raw_text = raw_text.strip()
if not raw_text:
raise HTTPException(status_code=400, detail="Submission text cannot be empty.")
if len(raw_text) > config.max_submission_chars:
@@ -72,16 +112,33 @@ async def submit_idea(payload: IdeaSubmissionRequest, request: Request):
idea_id = next_sequence("idea")
now_iso = get_utc_now()
submission_image_meta = None
if image_bytes and len(image_bytes) > 0:
try:
sanitized_bytes, submission_image_meta = validate_and_sanitize_image(
raw_bytes=image_bytes,
original_filename=image_filename,
source="web",
idea_id=idea_id
)
save_image_artifact(idea_id, sanitized_bytes, submission_image_meta)
except ImageValidationError as e:
raise HTTPException(status_code=400, detail=f"Image validation failed: {str(e)}")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not process image upload: {str(e)}")
img_json = json.dumps(submission_image_meta) if submission_image_meta else None
with get_db() as conn:
conn.execute(
"""
INSERT INTO ideas (
id, original_text, submitted_at, title, summary,
lifecycle_state, processing_state, enrichment_level,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'SUBMITTED', 'QUEUED', 0, ?, ?)
submission_image, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'SUBMITTED', 'QUEUED', 0, ?, ?, ?)
""",
(idea_id, raw_text, now_iso, "Processing New Idea...", "Analyzing submission text...", now_iso, now_iso)
(idea_id, raw_text, now_iso, "Processing New Idea...", "Analyzing submission text...", img_json, now_iso, now_iso)
)
# Enqueue intake pipeline for foreground triage
@@ -92,7 +149,8 @@ async def submit_idea(payload: IdeaSubmissionRequest, request: Request):
"lifecycle_state": "SUBMITTED",
"processing_state": "QUEUED",
"message": "Idea successfully accepted and queued for processing.",
"submitted_at": now_iso
"submitted_at": now_iso,
"submission_image": submission_image_meta
}
@router.get("")
@@ -151,6 +209,13 @@ async def list_ideas(
rows = conn.execute(query, params).fetchall()
results = []
for r in rows:
sub_img = None
if "submission_image" in r.keys() and r["submission_image"]:
try:
sub_img = json.loads(r["submission_image"]) if isinstance(r["submission_image"], str) else r["submission_image"]
except Exception:
sub_img = None
results.append({
"id": r["id"],
"title": r["title"] or "Untitled Idea",
@@ -161,7 +226,8 @@ async def list_ideas(
"claimed_by": r["claimed_by"],
"submitted_at": r["submitted_at"],
"categories": [c.strip() for c in r["category_names"].split(",")] if r["category_names"] else [],
"tags": [t.strip() for t in r["tag_names"].split(",")] if r["tag_names"] else []
"tags": [t.strip() for t in r["tag_names"].split(",")] if r["tag_names"] else [],
"submission_image": sub_img
})
return results
@@ -304,6 +370,14 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut
for r in conn.execute("SELECT * FROM idea_relationships WHERE source_idea_id = ?", (idea_id,)).fetchall()
]
# Image metadata
sub_img = None
if "submission_image" in idea_row.keys() and idea_row["submission_image"]:
try:
sub_img = json.loads(idea_row["submission_image"]) if isinstance(idea_row["submission_image"], str) else idea_row["submission_image"]
except Exception:
sub_img = None
return {
"id": idea_row["id"],
"title": idea_row["title"],
@@ -321,6 +395,7 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut
"profile_id": idea_row["profile_id"],
"opengist_id": idea_row["opengist_id"],
"opengist_url": idea_row["opengist_url"],
"submission_image": sub_img,
"categories": cats,
"tags": tags,
"urls": urls,
@@ -726,6 +801,18 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
elif stage == "FEASIBILITY":
research_docs["feasibility.md"] = content
# Collect work track outputs
outputs = {}
track_rows = conn.execute("SELECT id, name FROM work_tracks WHERE idea_id = ?", (idea_id,)).fetchall()
for tr in track_rows:
tr_outputs = conn.execute(
"SELECT name, content, version FROM work_track_outputs WHERE work_track_id = ? AND is_current = 1",
(tr["id"],)
).fetchall()
for out in tr_outputs:
track_slug = tr["name"].lower().replace(" ", "-")
outputs[f"{track_slug}/{out['name']}"] = out["content"]
res = await gitea_svc.persist_idea_dossier_repo(
idea_id=idea["id"],
title=idea["title"] or "Untitled Idea",
@@ -735,7 +822,7 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
tags=tags,
lifecycle_state=idea["lifecycle_state"],
research_docs=research_docs,
outputs={},
outputs=outputs,
provenance_runs=runs,
existing_repo_url=idea.get("gitea_repo_url") if isinstance(idea, dict) else (idea["gitea_repo_url"] if "gitea_repo_url" in idea.keys() else None)
)
@@ -765,3 +852,55 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
"clone_url": res["clone_url"],
"ssh_url": res["ssh_url"]
}
@router.post("/{idea_id}/reprocess")
async def reprocess_idea(
idea_id: str,
bypass_duplicate_check: bool = True,
current_user: User = Depends(get_current_user)
):
"""Triggers end-to-end reprocessing of an idea (Prior Art, Deep Research, Feasibility, and Gitea Dossier)."""
with get_db() as conn:
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
if not idea:
raise HTTPException(status_code=404, detail="Idea not found.")
now = get_utc_now()
conn.execute(
"UPDATE ideas SET processing_state = 'QUEUED', updated_at = ? WHERE id = ?",
(now, idea_id)
)
await job_queue.enqueue_foreground("intake", idea_id, {"bypass_duplicate_check": bypass_duplicate_check})
return {
"message": f"Idea {idea_id} enqueued for processing.",
"idea_id": idea_id,
"processing_state": "QUEUED"
}
@router.get("/{idea_id}/image")
async def get_idea_image(idea_id: str):
"""Serves the canonical reference image attached to an idea."""
with get_db() as conn:
row = conn.execute("SELECT submission_image FROM ideas WHERE id = ?", (idea_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Idea not found.")
raw_img = row["submission_image"] if "submission_image" in row.keys() else None
if not raw_img:
raise HTTPException(status_code=404, detail="No reference image associated with this idea.")
meta = json.loads(raw_img) if isinstance(raw_img, str) else raw_img
if not meta or not meta.get("present"):
raise HTTPException(status_code=404, detail="No reference image associated with this idea.")
img_path = get_image_artifact_path(idea_id, meta)
if not img_path or not img_path.exists():
raise HTTPException(status_code=404, detail="Image artifact file not found on disk.")
mime = meta.get("mime_type", "image/jpeg")
return FileResponse(
path=str(img_path),
media_type=mime,
filename=meta.get("original_filename", f"{idea_id}_reference_image")
)
+338
View File
@@ -0,0 +1,338 @@
"""
Signal Gateway Integration API Router
Receives, authenticates, and durably processes inbound webhooks and events from Signal Gateway.
"""
import os
import json
import secrets
import base64
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from fastapi import APIRouter, Request, HTTPException, status, Header
from pydantic import BaseModel, Field
from ..config import config
from ..database import get_db, next_sequence, get_utc_now
from ..queue.worker import job_queue
from ..services.image_handler import (
validate_and_sanitize_image,
save_image_artifact,
ImageValidationError
)
router = APIRouter(prefix="/api/integrations/signal", tags=["signal_integration"])
def extract_signal_image_candidate(attachment: Any) -> Optional[Tuple[bytes, str]]:
"""
Extracts raw bytes and filename from a Signal attachment item.
Returns (raw_bytes, filename) or None.
"""
if isinstance(attachment, dict):
# 1. Base64 data payload
b64_data = attachment.get("data") or attachment.get("base64") or attachment.get("bytes")
filename = attachment.get("filename") or attachment.get("name") or "signal_attachment"
if b64_data and isinstance(b64_data, str):
try:
if "," in b64_data:
b64_data = b64_data.split(",", 1)[1]
return base64.b64decode(b64_data), filename
except Exception:
pass
# 2. Filesystem path
file_path_str = attachment.get("path") or attachment.get("file") or attachment.get("stored_filename")
if file_path_str and isinstance(file_path_str, str):
p = Path(file_path_str)
if p.exists() and p.is_file():
try:
return p.read_bytes(), p.name
except Exception:
pass
elif isinstance(attachment, str):
# Could be path or base64
p = Path(attachment)
if p.exists() and p.is_file():
try:
return p.read_bytes(), p.name
except Exception:
pass
else:
try:
raw_str = attachment
if "," in raw_str:
raw_str = raw_str.split(",", 1)[1]
return base64.b64decode(raw_str), "signal_attachment"
except Exception:
pass
elif isinstance(attachment, (bytes, bytearray)):
return bytes(attachment), "signal_attachment"
return None
def get_configured_callback_secret() -> str:
"""Retrieves configured callback bearer secret dynamically."""
secret = config.services.signal_gateway_callback_secret or os.getenv("SIGNAL_GATEWAY_CALLBACK_SECRET", "")
if secret:
return secret
# Try reloading .env in case it was updated after server startup
from ..config import _load_env_file, BASE_DIR
from pathlib import Path
_load_env_file(Path("/root/.env"))
_load_env_file(BASE_DIR / ".env")
_load_env_file(BASE_DIR / "thinkstorm" / ".env")
secret = os.getenv("SIGNAL_GATEWAY_CALLBACK_SECRET", "")
if secret:
config.services.signal_gateway_callback_secret = secret
return secret
try:
with get_db() as conn:
row = conn.execute(
"SELECT api_key_raw, config_json FROM service_configurations WHERE id = 'signal_gateway'"
).fetchone()
if row:
conf = json.loads(row["config_json"] or "{}")
if "callback_secret" in conf and conf["callback_secret"]:
return conf["callback_secret"]
except Exception:
pass
return ""
def verify_callback_auth(request: Request):
"""
Enforces constant-time authentication comparison on the Bearer credential.
Returns 401 on missing or invalid authentication without leaking details.
"""
auth_header = request.headers.get("Authorization", "")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized"
)
provided_token = auth_header[7:].strip()
expected_secret = get_configured_callback_secret()
if not expected_secret or not provided_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized"
)
# Constant-time comparison to prevent timing attacks
if not secrets.compare_digest(provided_token, expected_secret):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized"
)
@router.post("/events", status_code=status.HTTP_200_OK)
async def handle_signal_events(
request: Request,
idempotency_key_header: Optional[str] = Header(None, alias="Idempotency-Key")
):
"""
Inbound Signal Gateway Webhook Callback.
Handles both test probes and actual normalized Signal messages.
"""
# 1. Authenticate Request
verify_callback_auth(request)
# 2. Parse Raw JSON Body
try:
body = await request.json()
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Malformed JSON body."
)
if not isinstance(body, dict):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON structure."
)
# Check schema_version
schema_version = body.get("schema_version")
if schema_version != 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported schema_version: {schema_version}. Expected 1."
)
# 3. Handle Callback Test Request
event_type = body.get("event_type")
if event_type == "signal_gateway.callback_test":
# Quick 2xx response without triggering message processing or database side effects
return {
"accepted": True,
"event_type": "signal_gateway.callback_test"
}
# 4. Handle Actual Signal Event
event_id = body.get("event_id")
if not event_id or not isinstance(event_id, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing or invalid event_id."
)
# Validate Idempotency-Key header against JSON event_id
if not idempotency_key_header or idempotency_key_header.strip() != event_id.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing or mismatched Idempotency-Key header."
)
channel = body.get("channel", "signal")
if channel != "signal":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported channel: {channel}."
)
sender_uuid = body.get("sender_uuid")
if not sender_uuid or not isinstance(sender_uuid, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing or invalid sender_uuid."
)
text = body.get("text")
if text is None or not isinstance(text, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing or invalid text field."
)
raw_text = text.strip()
if not raw_text:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Message text cannot be empty."
)
sender_number = body.get("sender_number")
sender_name = body.get("sender_name")
group_id = body.get("group_id")
message_id = body.get("message_id") or str(int(request.state.time() if hasattr(request.state, "time") else 0))
reply_to = body.get("reply_to")
received_at = body.get("received_at") or get_utc_now()
now_iso = get_utc_now()
# 5. Durable Idempotency & Persistence
is_duplicate = False
idea_id = None
with get_db() as conn:
# Check if event already exists
existing = conn.execute(
"SELECT event_id, processing_status, idea_id FROM signal_inbound_events WHERE event_id = ?",
(event_id,)
).fetchone()
if existing:
is_duplicate = True
idea_id = existing["idea_id"]
else:
# Generate new Idea ID
idea_id = next_sequence("idea")
# Process optional Signal attachments (One-Image Rule)
accepted_image_meta = None
attachments = body.get("attachments") or body.get("attachment") or []
if not isinstance(attachments, list):
attachments = [attachments]
for att in attachments:
if not att:
continue
candidate = extract_signal_image_candidate(att)
if not candidate:
# Non-image or unreadable attachment -> ignore
continue
raw_img_bytes, filename = candidate
if not raw_img_bytes:
continue
try:
sanitized_bytes, img_meta = validate_and_sanitize_image(
raw_bytes=raw_img_bytes,
original_filename=filename,
source="signal",
idea_id=idea_id
)
save_image_artifact(idea_id, sanitized_bytes, img_meta)
accepted_image_meta = img_meta
# Once first valid image is accepted, ignore all subsequent attachments!
break
except ImageValidationError:
# Non-image or corrupted candidate -> ignore and check next attachment
continue
except Exception:
continue
img_json = json.dumps(accepted_image_meta) if accepted_image_meta else None
# Insert idea into ThinkStorm ideas table first (so foreign key in signal_inbound_events is satisfied)
conn.execute(
"""
INSERT INTO ideas (
id, original_text, submitted_at, title, summary,
lifecycle_state, processing_state, enrichment_level,
source_channel, source_sender_uuid, source_group_id, source_event_id,
submission_image, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'SUBMITTED', 'QUEUED', 0, 'signal', ?, ?, ?, ?, ?, ?)
""",
(
idea_id,
raw_text,
received_at,
"Processing New Idea (Signal)...",
"Analyzing inbound Signal submission text...",
sender_uuid,
group_id,
event_id,
img_json,
now_iso,
now_iso
)
)
# Persist inbound event record referencing the new idea
conn.execute(
"""
INSERT INTO signal_inbound_events (
event_id, channel, sender_uuid, sender_number, sender_name,
group_id, message_id, reply_to, text, received_at,
processing_status, idea_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?)
""",
(
event_id, channel, sender_uuid, sender_number, sender_name,
group_id, str(message_id), reply_to, raw_text, received_at,
idea_id, now_iso
)
)
if is_duplicate:
return {
"accepted": True,
"event_id": event_id,
"idea_id": idea_id,
"duplicate": True,
"status": "already_processed"
}
# 6. Enqueue Background Intake Pipeline
await job_queue.enqueue_foreground("intake", idea_id)
return {
"accepted": True,
"event_id": event_id,
"idea_id": idea_id,
"status": "queued"
}