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:
+147
-8
@@ -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")
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user