Compare commits

...
6 Commits
Author SHA1 Message Date
mathias2784 f67b7c37a6 logging
Tests / test_and_lint (push) Failing after 12s
2026-10-03 09:29:37 +02:00
mathias2784 0081dac985 too hot
Tests / test_and_lint (push) Successful in 13s
2026-10-02 23:30:23 +02:00
mathias2784 ba0256cda8 improvement of validation with llm validation loops 2026-10-02 23:29:41 +02:00
mathias2784 79655633d0 black 2026-10-02 22:37:27 +02:00
mathias2784 3754b3e4a9 LLM response models for prompting and response validation 2026-10-02 22:36:19 +02:00
mathias2784 3b93c5988b quality bot
Tests / test_and_lint (push) Failing after 10s
2026-10-02 20:56:02 +02:00
9 changed files with 233 additions and 75 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
LLM_API_BASE_URL = "http://localhost:1234/v1" LLM_API_BASE_URL = "http://localhost:1234/v1"
LLM_API_KEY = "not-relevant" LLM_API_KEY = "not-relevant"
LLM_DEFAULT_TEMPERATURE = 0.8 LLM_DEFAULT_TEMPERATURE = 1.0
+1
View File
@@ -10,3 +10,4 @@ wheels/
.venv .venv
.env .env
logs/
+32 -32
View File
@@ -1,13 +1,19 @@
import logging
from pathlib import Path from pathlib import Path
from typing import Annotated, Any from typing import Annotated
import typer import typer
from utils.logging_config import LoggingSettings
from photools3.llm_session import LlmSessionCaptionsAndKeywords from photools3.llm_session import (
LlmSessionCaptionsAndKeywords,
LlmSessionQuality,
)
from photools3.photo import Photo from photools3.photo import Photo
app = typer.Typer(help="PhotoTools3: A CLI for image processing.") app = typer.Typer(help="PhotoTools3: A CLI for image processing.")
LoggingSettings()
logger = logging.getLogger(__name__)
@app.callback(invoke_without_command=True) @app.callback(invoke_without_command=True)
def main( def main(
@@ -30,38 +36,32 @@ def main(
def keywords( def keywords(
file_path: Annotated[Path, typer.Argument(..., help="Path to the image file")], file_path: Annotated[Path, typer.Argument(..., help="Path to the image file")],
): ):
"""Extract keywords from a single image."""
if not file_path.exists():
typer.echo(f"Error: File '{file_path}' does not exist.", err=True)
raise typer.Exit(code=1)
try:
typer.echo(f"Processing: {file_path}")
result = _run_keywords_extraction(file_path)
_print_results(result)
except Exception as e: # noqa: BLE001
typer.echo(f"Error processing image: {e}", err=True)
raise typer.Exit(code=1)
def _run_keywords_extraction(file_path: Path) -> Any:
"""Handles the core logic of loading the photo and calling the LLM."""
session = LlmSessionCaptionsAndKeywords() session = LlmSessionCaptionsAndKeywords()
photo = Photo(file_path) _run_llm_task(file_path, session)
return session.call([photo])
def _print_results(result: Any) -> None: @app.command("quality")
"""Formats and prints the extraction results to the console.""" def quality(
typer.echo("\n--- Results ---") file_path: Annotated[Path, typer.Argument(..., help="Path to the image file")],
if isinstance(result, dict): ):
caption = result.get("caption", "No caption found.") session = LlmSessionQuality()
tags = result.get("tags", []) _run_llm_task(file_path, session)
typer.echo(f"Caption: {caption}")
typer.echo(f"Tags: {', '.join(tags)}")
else: def _run_llm_task(
typer.echo(str(result)) file_path: Path, session: LlmSessionCaptionsAndKeywords | LlmSessionQuality
typer.echo("---------------") ):
try:
logger.info(f"Processing: {file_path}")
photo = Photo(file_path)
result = session.call([photo])
logger.info(result)
except FileNotFoundError:
logger.error(f"Error: File '{file_path}' does not exist.")
raise typer.Exit(code=1)
except Exception as e: # noqa: BLE001
raise typer.Exit(code=1)
logger.error(f"Error processing image: {e}")
if __name__ == "__main__": if __name__ == "__main__":
+68 -23
View File
@@ -1,9 +1,10 @@
import json import json
import logging
import re import re
import openai import openai
from openai import OpenAI from openai import OpenAI
from utils.misc import readlines from pydantic import ValidationError
from photools3.models import ( from photools3.models import (
LlmMessageContent, LlmMessageContent,
@@ -11,9 +12,14 @@ from photools3.models import (
LlmMessageType, LlmMessageType,
LlmRequest, LlmRequest,
LlmRequestMessage, LlmRequestMessage,
LlmResponseCaptionsAndKeywords,
LlmResponseGeneral,
LlmResponseQuality,
) )
from photools3.photo import Photo from photools3.photo import Photo
from photools3.settings import KEYWORDS_LIST_PATH, Settings from photools3.settings import Settings
logger = logging.getLogger(__name__)
settings = Settings() # type: ignore[reportCallIssue] settings = Settings() # type: ignore[reportCallIssue]
@@ -21,6 +27,7 @@ settings = Settings() # type: ignore[reportCallIssue]
class LlmSession: class LlmSession:
base_url: str = str(settings.llm_api_base_url) base_url: str = str(settings.llm_api_base_url)
api_key: str = settings.llm_api_key api_key: str = settings.llm_api_key
response_model = LlmResponseGeneral
def __init__( def __init__(
self, system_prompt: str, temperature: float = settings.llm_default_temperature self, system_prompt: str, temperature: float = settings.llm_default_temperature
@@ -57,24 +64,49 @@ class LlmSession:
prompt: str, prompt: str,
photos: list[Photo], photos: list[Photo],
model_id=None, model_id=None,
max_retries: int = 2,
): ):
if not model_id: if not model_id:
model_id = self.models[0].id model_id = self.models[0].id
request = self._build_request(prompt, photos, model_id) request = self._build_request(prompt, photos, model_id)
result_raw = self.client_chat_completion_create(request)
try: last_error = None
result = json.loads(result_raw) for attempt in range(max_retries + 1):
except json.JSONDecodeError as e:
try: try:
result_raw = self.client_chat_completion_create(request)
# Clean the response (strip markdown code blocks if present)
json_str = re.sub( json_str = re.sub(
r"^```json(.*)```$", r"\1", result_raw, flags=re.DOTALL r"^```json(.*)```$", r"\1", result_raw, flags=re.DOTALL
).strip() ).strip()
result = json.loads(json_str)
except json.JSONDecodeError: # Parse and validate against the class-level response_model
raise e return self.response_model.model_validate_json(json_str)
return result
except (json.JSONDecodeError, ValidationError) as e:
last_error = e
if attempt < max_retries:
logger.info("Chatbot response is invalid; promting to correct ...")
# Prepare a correction prompt
correction_prompt = (
f"The previous response was invalid due to: {e}. "
f"Please provide the corrected JSON based on this content: "
f"{json_str if 'json_str' in locals() else 'the original input'}"
)
# Append correction prompt as a new user message for the next attempt
request.messages.append(
LlmRequestMessage(
role=LlmMessageRole.user,
content=correction_prompt,
)
)
continue
else:
raise ValueError(
f"Failed to parse or validate LLM response after {max_retries} retries: {e}"
)
except Exception:
raise
def _build_request( def _build_request(
self, self,
@@ -116,24 +148,15 @@ class LlmSession:
class LlmSessionCaptionsAndKeywords(LlmSession): class LlmSessionCaptionsAndKeywords(LlmSession):
keywords_list = ",".join([s.strip() for s in readlines(str(KEYWORDS_LIST_PATH))]) response_model = LlmResponseCaptionsAndKeywords
def __init__(self, temperature: float = settings.llm_default_temperature): def __init__(self, temperature: float = settings.llm_default_temperature):
super().__init__( super().__init__(
system_prompt=( system_prompt=(
"# System prompt\n"
"\n"
"You are a helpful photographer's assistant that helps to write **captions** and **keywords** to photos:\n" "You are a helpful photographer's assistant that helps to write **captions** and **keywords** to photos:\n"
"- Answer in JSON format so it can easily be integrated in automatic workflows.\n" "Answer in German.\n"
" - JSON format is `{'caption': '...', 'tags': ['tag1', 'tag2', ...]}`.\n" "Output must be a JSON of the following schema:\n"
" - Do not add any other text outside of this JSON format!\n" f"{self.response_model.model_json_schema()}"
"- When writing captions, ONLY describe what is visible on the photo. Do not interpret!\n"
"- Only use keywords from the keyword list, appended below.\n"
"- Answer in German.\n"
"\n"
"## Keyword list"
"\n"
f"{self.keywords_list}"
), ),
temperature=temperature, temperature=temperature,
) )
@@ -144,3 +167,25 @@ class LlmSessionCaptionsAndKeywords(LlmSession):
photos=photos, photos=photos,
model_id=model_id, model_id=model_id,
) )
class LlmSessionQuality(LlmSession):
response_model = LlmResponseQuality
def __init__(self, temperature: float = settings.llm_default_temperature):
super().__init__(
system_prompt=(
"You are a helpful photographer's assistant that helps to identify "
"photos that can be sorted out due to qulity issues:\n"
"Output must be a JSON of the following schema:\n"
f"{self.response_model.model_json_schema()}"
),
temperature=temperature,
)
def call(self, photos: list[Photo], model_id=None):
return super().call(
prompt="Assess the quality of the following photos to help me decide which ones to sort out",
photos=photos,
model_id=model_id,
)
+109 -2
View File
@@ -1,7 +1,41 @@
from enum import StrEnum from enum import StrEnum
from typing import Annotated, Any from functools import lru_cache
from typing import Annotated, Any, Literal
from pydantic import BaseModel, BeforeValidator, model_validator from pydantic import (
BaseModel,
BeforeValidator,
Field,
field_validator,
model_validator,
)
from .settings import KEYWORDS_LIST_PATH
@lru_cache
def load_valid_keywords() -> set[str]:
"""Loads all valid keywords from the configuration file."""
if not KEYWORDS_LIST_PATH.exists():
return set()
keywords = set()
with open(KEYWORDS_LIST_PATH, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
# Each line can contain hierarchical tags separated by '|'
# e.g., 'architektur|gebäude' means both 'architektur' and 'gebäude' are valid.
parts = line.split("|")
current_path = ""
for part in parts:
if current_path:
current_path += "|" + part
else:
current_path = part
keywords.add(current_path)
return keywords
class LlmRequest(BaseModel): class LlmRequest(BaseModel):
@@ -46,3 +80,76 @@ class LlmMessageContent(BaseModel):
if self.type == "image_url" and self.image_url is None: if self.type == "image_url" and self.image_url is None:
raise ValueError("image_url must be set when type is 'image_url'") raise ValueError("image_url must be set when type is 'image_url'")
return self return self
class LlmResponseGeneral(BaseModel):
text: str
class LlmResponseCaptionsAndKeywords(BaseModel):
caption: Annotated[
str,
Field(
description=(
"A brief but precise description of what the picture shows. "
"No interpretation; just what is visible"
)
),
]
tags: list[str] = Field(
description=(
"A list of keywords that properly tag the photo. "
"Allowed keywords: "
+ str(load_valid_keywords())
.replace("'", '"')
.replace("{", "[")
.replace("}", "]")
)
)
@field_validator("tags")
@classmethod
def validate_tags(cls, v: list[str]) -> list[str]:
valid_keywords = load_valid_keywords()
for tag in v:
if tag not in valid_keywords:
raise ValueError(
f"'{tag}' is not a valid keyword. Must be one from the configuration."
)
return v
class LlmResponseQuality(BaseModel):
rating: Annotated[
Literal["ok", "review", "not_ok"],
Field(
description=(
"General quality assessment of the photo. "
'Helping to decide whether to keep ("ok"), '
'to consider sorting out ("review"), '
'or to almost certainly sort out ("not_ok")'
)
),
]
reason: (
None
| Annotated[
Literal["blur", "exposure", "style"],
Field(
description=(
'Possible reasons for a lower-than-"ok" assessment. '
'"blur": Obviously mis-focussed or motion-blurred. '
'"exposure": severly under- or over-exposed. '
'"style": photo has severe style issues (beyond personal taste).'
)
),
]
)
@model_validator(mode="after")
def _not_ok_needs_reason(self):
if self.rating != "ok" and self.reason is None:
raise ValueError("lower-than-ok rating needs a reason")
elif self.rating == "ok" and self.reason is not None:
raise ValueError("ok rating needs no reason")
return self
+4
View File
@@ -7,6 +7,10 @@ from PIL import Image
from photools3.models import transform_to_image_dict from photools3.models import transform_to_image_dict
def _exist_guard(path: Path):
pass
class Photo: class Photo:
def __init__(self, path: Path): def __init__(self, path: Path):
+5 -2
View File
@@ -58,8 +58,11 @@ def test_keywords_command_success(mock_photo_logic, dummy_file):
assert result.exit_code == 0 assert result.exit_code == 0
assert "Processing: " in result.stdout assert "Processing: " in result.stdout
assert "Caption: A beautiful test image" in result.stdout # The result is printed as a raw dictionary string
assert "Tags: test, mock, image" in result.stdout assert (
"{'caption': 'A beautiful test image', 'tags': ['test', 'mock', 'image']}"
in result.stdout
)
def test_keywords_command_file_not_found(): def test_keywords_command_file_not_found():
+5 -5
View File
@@ -7,7 +7,7 @@ from photools3.llm_session import LlmSession
not LlmSession(system_prompt="").is_api_reachable(), reason="API not available" not LlmSession(system_prompt="").is_api_reachable(), reason="API not available"
) )
def test_llm_session(llm_session, test_photo): def test_llm_session(llm_session, test_photo):
llm_session.system_prompt = "Return only JSON format: {'keywords': ['...']}" llm_session.system_prompt = "Return only JSON format: {'text': '...'}"
result = llm_session.call( result = llm_session.call(
prompt="keywords for the attached picture", photos=[test_photo] prompt="keywords for the attached picture", photos=[test_photo]
) )
@@ -21,7 +21,7 @@ def test_llm_session_captions_and_keywords(
llm_session_captions_and_keywords, test_photo llm_session_captions_and_keywords, test_photo
): ):
result = llm_session_captions_and_keywords.call(photos=[test_photo]) result = llm_session_captions_and_keywords.call(photos=[test_photo])
assert "caption" in result assert result.caption
assert "tags" in result assert result.tags
assert isinstance(result["caption"], str) assert isinstance(result.caption, str)
assert isinstance(result["tags"], list) assert isinstance(result.tags, list)
+8 -10
View File
@@ -21,9 +21,7 @@ def test_llm_session_mocked(test_photo):
mock_choice = MagicMock() mock_choice = MagicMock()
mock_message = MagicMock() mock_message = MagicMock()
mock_message.content = ( mock_message.content = '{"text": "some text"}'
'{"caption": "A beautiful sunset", "tags": ["sunset", "nature"]}'
)
mock_choice.message = mock_message mock_choice.message = mock_message
mock_response.choices = [mock_choice] mock_response.choices = [mock_choice]
@@ -39,11 +37,9 @@ def test_llm_session_mocked(test_photo):
) )
# Verify the results # Verify the results
assert result["caption"] == "A beautiful sunset" assert result.text == "some text"
assert "sunset" in result["tags"]
assert "nature" in result["tags"]
# Verify that the mock was called # Verify that the mock was enough
assert mock_client.chat.completions.create.called assert mock_client.chat.completions.create.called
@@ -65,7 +61,9 @@ def test_llm_session_captions_and_keywords_mocked(test_photo):
mock_choice = MagicMock() mock_choice = MagicMock()
mock_message = MagicMock() mock_message = MagicMock()
mock_message.content = '{"caption": "A mocked caption", "tags": ["mocked_tag"]}' mock_message.content = (
'{"caption": "A mocked caption", "tags": ["architektur"]}'
)
mock_choice.message = mock_message mock_choice.message = mock_message
mock_response.choices = [mock_choice] mock_response.choices = [mock_choice]
@@ -81,8 +79,8 @@ def test_llm_session_captions_and_keywords_mocked(test_photo):
result = mocked_session.call(photos=[test_photo]) result = mocked_session.call(photos=[test_photo])
# Verify the results # Verify the results
assert result["caption"] == "A mocked caption" assert result.caption == "A mocked caption"
assert "mocked_tag" in result["tags"] assert "architektur" in result.tags
# Verify that the mock was called # Verify that the mock was called
assert mock_client.chat.completions.create.called assert mock_client.chat.completions.create.called