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
+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")
)