| """
|
| ═══════════════════════════════════════════════════════════
|
| CORA — Unified Entry Point for Hugging Face Spaces
|
| Merges the FastAPI backend + Gradio UI into one process.
|
| ═══════════════════════════════════════════════════════════
|
| """
|
|
|
| import os
|
| import io
|
| import uuid
|
| import threading
|
|
|
| from dotenv import load_dotenv
|
| load_dotenv()
|
|
|
| import gradio as gr
|
| from fastapi import FastAPI, HTTPException, BackgroundTasks
|
| from fastapi.staticfiles import StaticFiles
|
| from pydantic import BaseModel
|
| from PIL import Image
|
|
|
| from cora_engine import CoraEngine
|
| from cora_curator import CoraCurator
|
| from cora_vision import CoraVision
|
| from cora_memory import CoraMemory
|
|
|
|
|
| engine = None
|
| curator = None
|
| vision = None
|
| memory = None
|
|
|
| from contextlib import asynccontextmanager
|
|
|
| @asynccontextmanager
|
| async def lifespan(app: FastAPI):
|
| global engine, curator, vision, memory
|
| print("🚀 Initializing Cora Engines...")
|
| engine = CoraEngine()
|
| curator = CoraCurator()
|
| vision = CoraVision()
|
| memory = CoraMemory()
|
| yield
|
| print("💤 Shutting down...")
|
|
|
|
|
| api = FastAPI(
|
| title="Cora API",
|
| description="Historical Archive Generator",
|
| lifespan=lifespan
|
| )
|
|
|
| ARCHIVE_DIR = os.environ.get("ARCHIVE_DIR", "archive_images")
|
| os.makedirs(ARCHIVE_DIR, exist_ok=True)
|
|
|
|
|
| class AgentPrompt(BaseModel):
|
| prompt: str
|
| use_curator: bool = True
|
|
|
|
|
| class SearchQuery(BaseModel):
|
| query: str
|
| limit: int = 10
|
|
|
|
|
| @api.get("/health")
|
| def health_check():
|
| """Service status with dynamic backend reporting."""
|
| return {
|
| "status": "online",
|
| "engine": {
|
| "status": engine.client is not None,
|
| "backend": "cloud (huggingface)" if engine.client else "none",
|
| "model": engine.MODEL_ID
|
| },
|
| "curator": {
|
| "status": curator.backend != "none",
|
| "backend": curator.backend,
|
| "model": curator.MODEL_ID
|
| },
|
| "vision": "online" if vision.clip_model else "offline",
|
| "memory": "online" if memory.client else "offline"
|
| }
|
|
|
|
|
| def archive_generation(image, prompt):
|
| """Background: save + embed + tag."""
|
| try:
|
| filename = f"{uuid.uuid4()}.png"
|
| filepath = os.path.join(ARCHIVE_DIR, filename)
|
| image.save(filepath)
|
| embedding = vision.embed_image(image)
|
| tags = vision.detect_tags(image)
|
| memory.save(filepath, embedding, prompt, tags)
|
| print(f"✅ Archived: {filepath} | tags: {tags}")
|
| except Exception as e:
|
| print(f"❌ Archive failed: {e}")
|
|
|
|
|
| @api.post("/agent/generate")
|
| async def agent_generate(request: AgentPrompt, background_tasks: BackgroundTasks):
|
| try:
|
| if not request.prompt or not request.prompt.strip():
|
| raise HTTPException(status_code=400, detail="Prompt cannot be empty.")
|
|
|
| final_prompt = request.prompt
|
| if request.use_curator:
|
| try:
|
| final_prompt = curator.refine_prompt(request.prompt)
|
| except Exception:
|
| final_prompt = request.prompt
|
|
|
| result = engine.generate_from_text(final_prompt)
|
|
|
| if "transparent" in request.prompt.lower():
|
| try:
|
| from rembg import remove
|
| result = remove(result)
|
| except Exception as e:
|
| print(f"Failed to remove background: {e}")
|
|
|
| background_tasks.add_task(archive_generation, result, final_prompt)
|
|
|
| img_bytes = io.BytesIO()
|
| result.save(img_bytes, format="PNG")
|
| from fastapi.responses import Response
|
| return Response(content=img_bytes.getvalue(), media_type="image/png")
|
| except HTTPException:
|
| raise
|
| except Exception as e:
|
| raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
| @api.post("/curator/search")
|
| async def curator_search(request: SearchQuery):
|
| try:
|
| emb = vision.embed_text(request.query)
|
| if not emb:
|
| return {"results": []}
|
|
|
| query_lower = request.query.lower()
|
| tag_hints = []
|
| cultural_markers = {
|
| "roman": ["roman", "rome"],
|
| "greek": ["greek", "greece", "hellenic"],
|
| "egyptian": ["egypt", "egyptian"],
|
| "medieval": ["medieval", "middle ages"],
|
| "renaissance": ["renaissance"],
|
| }
|
| for culture, keywords in cultural_markers.items():
|
| if any(kw in query_lower for kw in keywords):
|
| tag_hints.extend(keywords)
|
|
|
| if tag_hints:
|
| results = memory.search_hybrid(emb, k=request.limit, tag_filter=tag_hints)
|
| else:
|
| results = memory.search_by_vector(emb, k=request.limit)
|
|
|
| images = []
|
| if results["ids"]:
|
| for i, uid in enumerate(results["ids"][0]):
|
| meta = results["metadatas"][0][i]
|
| path = meta.get("path")
|
| if path and os.path.exists(path):
|
| filename = os.path.basename(path)
|
| images.append({
|
| "path": f"/archive_images/{filename}",
|
| "tags": meta.get("tags"),
|
| "prompt": meta.get("prompt"),
|
| "score": float(results["distances"][0][i]),
|
| })
|
| return {"results": images}
|
| except Exception as e:
|
| raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
| api.mount("/archive_images", StaticFiles(directory=ARCHIVE_DIR), name="archive_images")
|
|
|
|
|
|
|
|
|
| API_URL = None
|
|
|
|
|
| def ui_generate(user_prompt, is_monochromy, monochromy_color, is_transparent, progress=gr.Progress()):
|
| """Generate an archive illustration — calls engine directly."""
|
| try:
|
| if not user_prompt or not user_prompt.strip():
|
| raise gr.Error("Please enter a subject to generate an archive.")
|
|
|
| progress(0.1, desc="🔍 Curator is analyzing your request...")
|
|
|
| augmented_prompt = user_prompt.strip()
|
| if is_monochromy:
|
| augmented_prompt += f", monochromy in {monochromy_color} color"
|
| if is_transparent:
|
| augmented_prompt += ", transparent background"
|
|
|
| final_prompt = augmented_prompt
|
| try:
|
| final_prompt = curator.refine_prompt(augmented_prompt)
|
| except Exception:
|
| pass
|
|
|
| progress(0.3, desc="🎨 Generating historical archive... (1-2 min)")
|
| result = engine.generate_from_text(final_prompt)
|
|
|
| if "transparent" in augmented_prompt.lower():
|
| try:
|
| progress(0.8, desc="✂️ Removing background...")
|
| from rembg import remove
|
| result = remove(result)
|
| except Exception as e:
|
| print(f"Failed to remove background: {e}")
|
|
|
|
|
| threading.Thread(
|
| target=archive_generation, args=(result, final_prompt), daemon=True
|
| ).start()
|
|
|
| progress(1.0, desc="✅ Generated! Indexing in background...")
|
| return result
|
| except gr.Error:
|
| raise
|
| except Exception as e:
|
| raise gr.Error(f"Generation failed: {str(e)}")
|
|
|
|
|
| def ui_search(query):
|
| """Semantic search — calls memory directly."""
|
| try:
|
| if not query or not query.strip():
|
| raise gr.Error("Please enter search keywords.")
|
|
|
| emb = vision.embed_text(query)
|
| if not emb:
|
| return []
|
|
|
| results = memory.search_by_vector(emb, k=9)
|
| gallery_items = []
|
| if results["ids"]:
|
| for i, uid in enumerate(results["ids"][0]):
|
| meta = results["metadatas"][0][i]
|
| path = meta.get("path")
|
| if path and os.path.exists(path):
|
| gallery_items.append((path, meta.get("tags", "")))
|
|
|
| if not gallery_items:
|
| gr.Info("🔍 No artifacts found. Try different keywords.")
|
| return gallery_items
|
| except gr.Error:
|
| raise
|
| except Exception as e:
|
| raise gr.Error(f"Search error: {str(e)}")
|
|
|
|
|
|
|
|
|
| custom_css = """
|
| button { transition: all 0.2s ease-in-out !important; }
|
| button:hover { transform: scale(1.02); opacity: 0.95; }
|
| button:active { transform: scale(0.98); }
|
| button:focus-visible, textarea:focus-visible, input:focus-visible {
|
| outline: 2px solid #8B5CF6 !important;
|
| outline-offset: 2px !important;
|
| }
|
| @media (max-width: 768px) { .gallery { gap: 0.5rem !important; } }
|
| """
|
|
|
| with gr.Blocks(
|
| title="Cora — Historical Archive Generator",
|
| theme=gr.themes.Monochrome(),
|
| css=custom_css,
|
| ) as demo:
|
| gr.Markdown(
|
| f"""
|
| # 🏛️ Cora — The Archive Curator
|
|
|
| **Generate AI-powered historical illustrations** using SDXL and Visual RAG.
|
| Transform simple prompts into detailed historical scenes, artifacts, and engravings.
|
|
|
| **How it works**: Your prompt is enhanced by an AI curator, then rendered as a
|
| historically-themed illustration and archived for future semantic search.
|
|
|
| *Active Curator Backend:* **{{curator.backend}}** | *Model:* **{{curator.MODEL_ID}}**
|
| """
|
| )
|
|
|
| with gr.Tabs():
|
| with gr.Tab("🎨 Generate"):
|
| with gr.Row():
|
| with gr.Column(scale=1):
|
| txt_input = gr.Textbox(
|
| label="Historical Subject",
|
| placeholder="Describe a historical scene, artifact, or character...",
|
| lines=3,
|
| info="Be specific! Mention time periods, cultures, contexts, or ask for a monochromy.",
|
| )
|
| with gr.Row():
|
| chk_monochromy = gr.Checkbox(label="Monochromy", value=False)
|
| color_picker = gr.ColorPicker(label="Monochromy Color", value="#000000")
|
| chk_transparent = gr.Checkbox(label="Transparent Background", value=False)
|
|
|
| btn_generate = gr.Button(
|
| "🔮 Generate Archive", variant="primary", size="lg"
|
| )
|
| gr.Examples(
|
| examples=[
|
| ["A woman mending fishing nets by the shore of a medieval village"],
|
| ["Baking bread in a communal stone oven, soot and flour on hands"],
|
| ["An apprentice carving a wooden chair in a 17th-century workshop"],
|
| ["A Roman merchant counting copper coins at a busy market stall"],
|
| ["Tending to a small vegetable garden in the courtyard of a Tudor cottage"],
|
| ["Spinning wool into yarn using a simple wooden spindle by a hearth"],
|
| ],
|
| inputs=txt_input,
|
| label="💡 Daily Life Examples (click to use)",
|
| )
|
| with gr.Column(scale=1):
|
| output_image = gr.Image(
|
| label="🖼️ Recovered Artifact", show_label=True
|
| )
|
|
|
| btn_generate.click(
|
| fn=ui_generate,
|
| inputs=[txt_input, chk_monochromy, color_picker, chk_transparent],
|
| outputs=output_image,
|
| show_progress="full",
|
| )
|
|
|
| with gr.Tab("📚 The Archive"):
|
| gr.Markdown(
|
| """
|
| ### Search the Visual Memory
|
| Use semantic search to find archived artifacts by keywords, themes, or cultural markers.
|
| """
|
| )
|
| with gr.Row():
|
| search_box = gr.Textbox(
|
| label="Search Query",
|
| placeholder="Try: 'roman armor', 'medieval', 'warrior'",
|
| scale=4,
|
| )
|
| btn_search = gr.Button("🔍 Search", variant="secondary", scale=1)
|
|
|
| gallery = gr.Gallery(
|
| label="Found Artifacts",
|
| columns=[1, 2, 3],
|
| height="auto",
|
| object_fit="contain",
|
| show_label=True,
|
| )
|
| btn_search.click(fn=ui_search, inputs=[search_box], outputs=gallery)
|
|
|
| gr.Examples(
|
| examples=[
|
| ["roman gladiator"],
|
| ["medieval castle"],
|
| ["viking warrior"],
|
| ["renaissance art"],
|
| ],
|
| inputs=search_box,
|
| label="💡 Quick Search Terms",
|
| )
|
|
|
|
|
| app = gr.mount_gradio_app(api, demo, path="/")
|
|
|
|
|
| if __name__ == "__main__":
|
| import uvicorn
|
| port = int(os.environ.get("PORT", 7860))
|
| uvicorn.run(app, host="0.0.0.0", port=port)
|
|
|