File size: 2,765 Bytes
107df28
 
810f920
 
107df28
 
 
c3434e7
107df28
 
 
 
810f920
107df28
 
810f920
 
 
 
 
 
 
 
 
107df28
 
 
810f920
 
 
107df28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3434e7
107df28
 
 
 
 
 
 
 
810f920
107df28
 
 
c3434e7
107df28
 
 
 
 
 
 
810f920
107df28
 
 
 
4221b2e
107df28
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import base64
import re
import io
from PIL import Image

from huggingface_hub import InferenceClient

from config import EVALUATION_HEADING, REMOTE_MODEL, REMOTE_PROVIDER, remote_prompt, REMOTE_MAX_TOKENS
from critique import Critique

SCORE_PATTERN = re.compile(r"##\s*Score:\s*(\d+)\s*/\s*100")
HEADING_PATTERN = re.compile(rf"###\s*{re.escape(EVALUATION_HEADING)}\s*", re.IGNORECASE)
MAX_IMAGE_SIDE = 768

def image_as_data_url(image_path: str) -> str:
    # downscale before sending to model to reduce tokens
    with Image.open(image_path) as source:
        image = source.convert("RGB")
        if max(image.size) > MAX_IMAGE_SIDE:
            image.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE))
        buffer = io.BytesIO()
        image.save(buffer, format="JPEG", quality=88)
        encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
        return f"data:image/jpeg;base64,{encoded}"


def parse_response(text: str) -> tuple[int | None, str]:
    # split the model markdown into (score, evaluation body)
    # notice the Qwen3 output can be duplicated sometime
    # so we only take the first part of the output
    headings = list(SCORE_PATTERN.finditer(text))
    if len(headings) > 1:
        text = text[: headings[1].start()]

    score = int(headings[0].group(1)) if headings else None

    body = HEADING_PATTERN.split(text, maxsplit=1)
    evaluation = body[1].strip() if len(body) > 1 else text.strip()

    repeat = HEADING_PATTERN.search(evaluation)
    if repeat:
        evaluation = evaluation[: repeat.start()].strip()

    return score, evaluation


def remote_critique(image_path, aspect, temperature, top_p, hf_token) -> Critique:
    client = InferenceClient(provider=REMOTE_PROVIDER, token=hf_token)
    response = client.chat.completions.create(
        model=REMOTE_MODEL,
        messages=[
            {
                "role": "user",
                "content": [
                    {"type": "image_url", "image_url": {"url": image_as_data_url(image_path)}},
                    {"type": "text", "text": remote_prompt(aspect)},
                ],
            }
        ],
        max_tokens=REMOTE_MAX_TOKENS,
        temperature=temperature,
        top_p=top_p,
    )

    choice = response.choices[0]
    content = (choice.message.content or "").strip()

    # catch the case where the reasoning exhausts the token budget
    if not content:
        raise RuntimeError(
            f"Hosted model returned no visible content (finish_reason="
            f"{choice.finish_reason}); the token budget was likely exhausted by "
            "internal reasoning."
        )

    score, evaluation = parse_response(content)
    return Critique(score=score, evaluation=evaluation, model_name=REMOTE_MODEL, route="Hosted")