S SmartDocs
Série: Ricky python 452 lignes · Mis à jour 2026-04-19

match.py

Ricky/cat_project/backend/app/routes/match.py

import json
import logging
from flask import Blueprint, jsonify, request, current_app

from ..services.storage import Storage
from ..services.poe_analyze import (
    analyze_with_poe,
    generate_kitten_image
)
from ..utils.auth import require_jwt
from ..utils.media_url import resolve_stored_media_url

logger = logging.getLogger(__name__)


def _cat_for_api(cat: dict) -> dict:
    if not cat:
        return cat
    out = dict(cat)
    out["photo_path"] = resolve_stored_media_url(current_app.config, out.get("photo_path"))
    return out


def _match_item_for_api(item: dict) -> dict:
    out = dict(item)
    c = out.get("cat")
    if isinstance(c, dict):
        out["cat"] = _cat_for_api(c)
    return out

bp = Blueprint("match", __name__, url_prefix="/api")


# ----------------------------
# Matching score logic
# ----------------------------
def _different_gender(my_gender: str, other_gender: str) -> bool:
    """Return True only if both genders are set and different (case-insensitive)."""
    a = (my_gender or "").strip().lower()
    b = (other_gender or "").strip().lower()
    if not a or not b:
        return False
    return a != b


def _score(my_cat: dict, other: dict) -> dict:
    score = 0

    # 1. Same breed (mild preference only; cross-breed matching is allowed)
    if (my_cat.get("breed") or "") == (other.get("breed") or ""):
        score += 8

    # 2. Age similarity
    try:
        age_diff = abs(int(my_cat.get("age", 0)) - int(other.get("age", 0)))
        score += max(0, 25 - min(25, age_diff * 5))
    except Exception:
        pass

    # 3. Vaccination overlap
    my_v = set(my_cat.get("vaccinations") or [])
    ot_v = set(other.get("vaccinations") or [])
    score += min(10, len(my_v.intersection(ot_v)) * 2)

    # 4. Health similarity
    if (my_cat.get("health") or "").lower() == (other.get("health") or "").lower():
        score += 10

    return {
        "cat": other,
        "score": int(score),
    }


def _norm(v: str) -> str:
    return (v or "").strip().lower()


def _select_diverse_matches(scored: list, my_breed: str, limit: int = 5) -> list:
    """
    Ensure cross-breed candidates are included when available.
    Strategy:
    - keep score ranking as baseline
    - reserve up to 2 slots for cross-breed cats (if available)
    """
    if not scored:
        return []

    mine = _norm(my_breed)
    cross = [x for x in scored if _norm((x.get("cat") or {}).get("breed")) != mine]
    same = [x for x in scored if _norm((x.get("cat") or {}).get("breed")) == mine]

    reserve_cross = min(2, len(cross), limit)
    chosen = cross[:reserve_cross]

    used_ids = {
        (item.get("cat") or {}).get("id")
        for item in chosen
        if (item.get("cat") or {}).get("id") is not None
    }

    for item in scored:
        cat_id = (item.get("cat") or {}).get("id")
        if cat_id is not None and cat_id in used_ids:
            continue
        chosen.append(item)
        if len(chosen) >= limit:
            break

    return chosen[:limit]


# ----------------------------
# Prompt builder (Poe analyze)
# ----------------------------
def _build_prompt(my_cat: dict, top_matches: list) -> str:
    my_profile = my_cat.get("profile") or {}

    my_summary = {
        "name": my_cat.get("name"),
        "breed": my_cat.get("breed"),
        "age": my_cat.get("age"),
        "sex_status": my_cat.get("gender"),
        "health": my_cat.get("health"),
        "vaccinations": my_cat.get("vaccinations"),
        "source": my_profile.get("source"),
        "owner_experience_years": my_profile.get("experience_years"),
        "origin_knowledge": my_profile.get("origin_knowledge"),
    }

    candidates = []
    for item in top_matches:
        c = item.get("cat") or {}
        candidates.append({
            "name": c.get("name"),
            "breed": c.get("breed"),
            "age": c.get("age"),
            "sex_status": c.get("gender"),
            "health": c.get("health"),
            "vaccinations": c.get("vaccinations"),
            "score": item.get("score"),
        })

    return f"""
# Role:
Professional AI Feline Health & Breed Consultant.

# Context:
You are an AI consultant for cat breeding & care.

# User's Cat:
{json.dumps(my_summary, ensure_ascii=False, indent=2)}

# Potential Match Cats:
{json.dumps(candidates, ensure_ascii=False, indent=2)}

# Output Format (JSON ONLY, no markdown block, no extra text):
# Write summary_md in English only.

{{
  "summary_md": "≤100 English words, markdown allowed. Brief compatibility summary in English.",
  "image_prompt": "≤100 English words, for image generation"
}}
""".strip()


# ----------------------------
# API: GET /api/match-result
# ----------------------------
@bp.get("/match-result")
def match_result():
    user = require_jwt()
    user_id = int(user["user_id"])
    email = user["email"]

    store = Storage(current_app.config["SQLITE_PATH"])
    my_cat = store.get_cat_by_user_id(user_id)

    if not my_cat:
        return jsonify({
            "status": "error",
            "message": "No cat profile"
        }), 404

    # -------- 配對計分 --------
    others = store.list_other_cats(user_id=user_id, limit=200)
    # Only consider cats with a different gender than the user's feline
    others = [c for c in others if _different_gender(my_cat.get("gender"), c.get("gender"))]
    scored = sorted(
        [_score(my_cat, c) for c in others],
        key=lambda x: x["score"],
        reverse=True
    )
    top_matches = _select_diverse_matches(scored, my_cat.get("breed"), limit=5)

    # -------- Poe 文字分析 --------
    review_md = "AI analysis is temporarily unavailable"
    image_prompt = None

    try:
        logger.info("Building prompt for Poe analysis...")
        prompt = _build_prompt(my_cat, top_matches)
        logger.info(f"Prompt built successfully, length: {len(prompt)}")
        
        logger.info("Calling analyze_with_poe...")
        ai_result = analyze_with_poe(prompt)
        logger.info(f"Poe analyze result: {ai_result}")

        if isinstance(ai_result, dict):
            review_md = ai_result.get("summary_md", review_md)
            image_prompt = ai_result.get("image_prompt")
            logger.info(f"Successfully extracted review_md and image_prompt")
        else:
            logger.warning(f"AI result is not a dict, got: {type(ai_result)}")

    except Exception as e:
        logger.exception(f"Poe analyze failed: {str(e)}")
        current_app.logger.exception("Poe analyze failed")

    # -------- OpenRouter 產圖 --------
    kitten_image_url = None
    generation_id = None
    image_result = None

    if image_prompt:
        image_prompt = image_prompt.strip()

        # 強制 ≤100 字
        if len(image_prompt) > 100:
            image_prompt = image_prompt[:100]

        try:
            logger.info(f"Generating image with prompt: {image_prompt[:50]}...")
            image_result = generate_kitten_image(
                image_prompt,
                logger=current_app.logger
            )
            logger.info(f"Image generation result: {image_result}")
        except Exception as e:
            logger.exception(f"Image generation crashed: {str(e)}")
            current_app.logger.exception("Image generation crashed")
    else:
        logger.info("No image prompt provided, skipping image generation")

    if image_result and image_result.get("success"):
        s3_uri = image_result.get("s3_uri")
        if s3_uri:
            store.update_cat_ai_match_photo(user_id, s3_uri)
        kitten_image_url = (
            resolve_stored_media_url(current_app.config, s3_uri)
            if s3_uri
            else image_result.get("image_url")
        )
        generation_id = image_result.get("generation_id")
    else:
        if image_result:
            current_app.logger.warning(
                f"Image generation failed: {image_result.get('error')}"
            )

    # -------- Response --------
    return jsonify({
        "status": "ok",
        "user": {
            "email": email,
            "user_id": user_id
        },
        "your_feline": _cat_for_api(my_cat),
        "match_review_md": review_md,
        "kitten_image_url": kitten_image_url,
        "potential_matches": [_match_item_for_api(x) for x in top_matches],
    }), 200


# ----------------------------
# Prompt builder for offspring generation
# ----------------------------
def _build_offspring_prompt(my_cat: dict, match_cat: dict) -> str:
    """Build a prompt for generating offspring review and image for a specific cat pair."""
    my_profile = my_cat.get("profile") or {}
    match_profile = match_cat.get("profile") or {}

    my_summary = {
        "name": my_cat.get("name"),
        "breed": my_cat.get("breed"),
        "age": my_cat.get("age"),
        "sex_status": my_cat.get("gender"),
        "health": my_cat.get("health"),
        "vaccinations": my_cat.get("vaccinations"),
    }

    match_summary = {
        "name": match_cat.get("name"),
        "breed": match_cat.get("breed"),
        "age": match_cat.get("age"),
        "sex_status": match_cat.get("gender"),
        "health": match_cat.get("health"),
        "vaccinations": match_cat.get("vaccinations"),
    }

    return f"""
# Role:
Professional AI Feline Genetics & Breeding Consultant.

# Context:
You are analyzing the potential offspring of two specific cats. Provide insights about what their kitten might look like, inherit traits, and health considerations.

# Parent Cat 1 (User's Cat):
{json.dumps(my_summary, ensure_ascii=False, indent=2)}

# Parent Cat 2 (Selected Match):
{json.dumps(match_summary, ensure_ascii=False, indent=2)}

# Task:
Analyze the potential offspring of these two cats. Consider:
1. Likely breed characteristics and appearance
2. Potential health traits inherited from parents
3. Expected temperament based on parent breeds

# Output Format (JSON ONLY, no markdown block, no extra text):
# Write summary_md in English only.

{{
  "summary_md": "≤200 English words, markdown allowed. Analysis of the potential offspring in English: appearance, health predictions, and care recommendations.",
  "image_prompt": "≤200 English words describing a realistic cute kitten that would result from breeding a {my_cat.get('breed', 'cat')} with a {match_cat.get('breed', 'cat')}, include specific appearance details like fur color, pattern, eye color, and body type"
}}
""".strip()


# ----------------------------
# API: POST /api/generate-offspring
# ----------------------------
@bp.post("/generate-offspring")
def generate_offspring():
    """Generate AI review and kitten image for a specific cat pair."""
    user = require_jwt()
    user_id = int(user["user_id"])

    data = request.get_json(silent=True) or {}
    match_cat_id = data.get("match_cat_id")

    if not match_cat_id:
        return jsonify({
            "status": "error",
            "message": "match_cat_id is required"
        }), 400

    store = Storage(current_app.config["SQLITE_PATH"])
    
    # Get user's cat
    my_cat = store.get_cat_by_user_id(user_id)
    if not my_cat:
        return jsonify({
            "status": "error",
            "message": "No cat profile found"
        }), 404

    # Get the matched cat by ID
    match_cat = None
    others = store.list_other_cats(user_id=user_id, limit=200)
    for cat in others:
        if cat.get("id") == match_cat_id:
            match_cat = cat
            break

    if not match_cat:
        return jsonify({
            "status": "error",
            "message": "Match cat not found"
        }), 404

    if not _different_gender(my_cat.get("gender"), match_cat.get("gender")):
        return jsonify({
            "status": "error",
            "message": "Your Feline and Potential Match must have different genders"
        }), 400

    # -------- Poe AI Analysis for this pair --------
    review_md = "AI analysis is temporarily unavailable"
    image_prompt = None

    try:
        logger.info(f"Building offspring prompt for cats: {my_cat.get('name')} + {match_cat.get('name')}")
        prompt = _build_offspring_prompt(my_cat, match_cat)
        logger.info(f"Offspring prompt built, length: {len(prompt)}")
        
        logger.info("Calling analyze_with_poe for offspring...")
        ai_result = analyze_with_poe(prompt)
        logger.info(f"Offspring Poe result: {ai_result}")

        if isinstance(ai_result, dict):
            review_md = ai_result.get("summary_md", review_md)
            image_prompt = ai_result.get("image_prompt")
            logger.info(f"Extracted offspring review and image_prompt")
        else:
            logger.warning(f"AI result is not a dict, got: {type(ai_result)}")

    except Exception as e:
        logger.exception(f"Offspring Poe analyze failed: {str(e)}")
        current_app.logger.exception("Offspring Poe analyze failed")

    # -------- OpenRouter Image Generation --------
    kitten_image_url = None
    generation_id = None
    image_result = None

    if image_prompt:
        image_prompt = image_prompt.strip()

        # Limit to 100 words
        words = image_prompt.split()
        if len(words) > 100:
            image_prompt = " ".join(words[:100])

        try:
            logger.info(f"Generating offspring image with prompt: {image_prompt[:80]}...")
            image_result = generate_kitten_image(
                image_prompt,
                logger=current_app.logger
            )
            logger.info(f"Offspring image generation result: {image_result}")
        except Exception as e:
            logger.exception(f"Offspring image generation crashed: {str(e)}")
            current_app.logger.exception("Offspring image generation crashed")
    else:
        logger.info("No image prompt for offspring, skipping image generation")

    if image_result and image_result.get("success"):
        s3_uri = image_result.get("s3_uri")
        if s3_uri:
            store.upsert_offspring_ai_photo(user_id, int(match_cat_id), s3_uri)
        kitten_image_url = (
            resolve_stored_media_url(current_app.config, s3_uri)
            if s3_uri
            else image_result.get("image_url")
        )
        generation_id = image_result.get("generation_id")
    else:
        if image_result:
            current_app.logger.warning(
                f"Offspring image generation failed: {image_result.get('error')}"
            )

    # -------- Response --------
    return jsonify({
        "status": "ok",
        "match_cat_id": match_cat_id,
        "match_cat_name": match_cat.get("name"),
        "review_md": review_md,
        "kitten_image_url": kitten_image_url,
        "generation_id": generation_id,
    }), 200

Articles liés