Skip to content

WIP: 🎨 Is6673/auto user friendly messages #7345

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions packages/common-library/src/common_library/errors_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,47 @@ def error_context(self) -> dict[str, Any]:
def error_code(self) -> str:
assert isinstance(self, Exception), "subclass must be exception" # nosec
return create_error_code(self)


class BaseOsparcError(OsparcErrorMixin, Exception): ...


class NotFoundError(BaseOsparcError):
msg_template = "{resource} not found: id='{resource_id}'"


class ForbiddenError(BaseOsparcError):
msg_template = "Access to {resource} is forbidden: id='{resource_id}'"


def make_resource_error(
resource: str,
error_cls: type[BaseOsparcError],
base_exception: type[Exception] = Exception,
) -> type[BaseOsparcError]:
"""
Factory function to create a custom error class for a specific resource.

This function dynamically generates an error class that inherits from the provided
`error_cls` and optionally a `base_exception`. The generated error class automatically
includes the resource name and resource ID in its context and message.

See usage examples in test_errors_classes.py

LIMITATIONS: for the moment, exceptions produces with this factory cannot be serialized with pickle.
And therefore it cannot be used as exception of RabbitMQ-RPC interface
"""

class _ResourceError(error_cls, base_exception):
def __init__(self, **ctx: Any):
ctx.setdefault("resource", resource)

# guesses identifer e.g. project_id, user_id
if resource_id := ctx.get(f"{resource.lower()}_id"):
ctx.setdefault("resource_id", resource_id)

super().__init__(**ctx)

resource_class_name = "".join(word.capitalize() for word in resource.split("_"))
_ResourceError.__name__ = f"{resource_class_name}{error_cls.__name__}"
return _ResourceError
51 changes: 50 additions & 1 deletion packages/common-library/tests/test_errors_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
from typing import Any

import pytest
from common_library.errors_classes import OsparcErrorMixin
from common_library.errors_classes import (
ForbiddenError,
NotFoundError,
OsparcErrorMixin,
make_resource_error,
)


def test_get_full_class_name():
Expand Down Expand Up @@ -154,3 +159,47 @@ class MyError(OsparcErrorMixin, ValueError):
"message": "42 and 'missing=?'",
"value": 42,
}


def test_resource_error_factory():
ProjectNotFoundError = make_resource_error("project", NotFoundError)

error_1 = ProjectNotFoundError(resource_id="abc123")
assert "resource_id" in error_1.error_context()
assert error_1.resource_id in error_1.message # type: ignore


def test_resource_error_factory_auto_detect_resource_id():
ProjectForbiddenError = make_resource_error("project", ForbiddenError)
error_2 = ProjectForbiddenError(project_id="abc123", other_id="foo")
assert (
error_2.resource_id == error_2.project_id # type: ignore
), "auto-detects project ids as resourceid"
assert error_2.other_id # type: ignore
assert error_2.code == "BaseOsparcError.ForbiddenError.ProjectForbiddenError"

assert error_2.error_context() == {
"project_id": "abc123",
"other_id": "foo",
"resource": "project",
"resource_id": "abc123",
"message": "Access to project is forbidden: id='abc123'",
"code": "BaseOsparcError.ForbiddenError.ProjectForbiddenError",
}


def test_resource_error_factory_different_base_exception():

class MyServiceError(Exception): ...

OtherProjectForbiddenError = make_resource_error(
"other_project", ForbiddenError, MyServiceError
)

assert issubclass(OtherProjectForbiddenError, MyServiceError)

error_3 = OtherProjectForbiddenError(project_id="abc123")
assert (
error_3.code
== "MyServiceError.BaseOsparcError.ForbiddenError.OtherProjectForbiddenError"
)
127 changes: 127 additions & 0 deletions scripts/rewrite_errors_llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "aiohttp",
# "dotenv",
# "iofiles",
# "json",
# "openai",
# ]
# ///


import asyncio
import getpass
import json
import os
from pathlib import Path

import aiofiles
import aiohttp
from dotenv import load_dotenv
from openai import AsyncOpenAI


def get_openai_api_key() -> str:
# Try to get the API key from the environment variable
if api_key := os.getenv("OPENAI_API_KEY"):
return api_key

# Load environment variables from a .env file if not already loaded
load_dotenv()
if api_key := os.getenv("OPENAI_API_KEY"):
return api_key

# Prompt the user for the API key as a last resort
return getpass.getpass("Enter your OPENAI_API_KEY: ")


# --- Config ---
GUIDELINES_URL = "https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/docs/messages-guidelines.md"
INPUT_FILE = "errors.txt" # Supports either .txt (one per line) or .json (list)
MODEL = "gpt-4"
API_KEY = get_openai_api_key()

client = AsyncOpenAI(api_key=API_KEY)

# --- Functions ---


async def fetch_guidelines() -> str:
async with aiohttp.ClientSession() as session, session.get(GUIDELINES_URL) as resp:
return await resp.text()


async def load_messages(filepath: str) -> list[str]:
path = Path(filepath)
async with aiofiles.open(path) as f:
content = await f.read()
try:
return json.loads(content)
except json.JSONDecodeError:
return [line.strip() for line in content.splitlines() if line.strip()]


def build_system_prompt(guidelines: str) -> str:
return f"""
You are a technical writing assistant specialized in crafting professional error and warning messages.
Your task is to rewrite the given error message to strictly adhere to the oSparc Simcore message guidelines.

Here are the guidelines:
{guidelines}

Instructions:
- Follow all the principles from the message guidelines.
- Ensure the message is concise, user-focused, actionable, and avoids developer jargon.
- If there is not enough context, ask a clarifying question.

Use this format only:
If enough context:
REWRITTEN: <rewritten message>
If not enough context:
NEED MORE INFO: <your clarifying question(s)>
""".strip()


async def rewrite_message(message: str, system_prompt: str, index: int) -> dict:
try:
response = await client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"ERROR: {message}"},
],
temperature=0,
)
return {
"index": index,
"original": message,
"output": response.choices[0].message.content.strip(),
}
except Exception as e:
return {"index": index, "original": message, "error": str(e)}


# --- Main Flow ---


async def main():
guidelines = await fetch_guidelines()
system_prompt = build_system_prompt(guidelines)
messages = await load_messages(INPUT_FILE)

tasks = [
rewrite_message(msg, system_prompt, i) for i, msg in enumerate(messages, 1)
]
results = await asyncio.gather(*tasks)

for result in results:
print(f"\n[{result['index']}] Original: {result['original']}")
if "output" in result:
print(result["output"])
else:
print(f"ERROR: {result['error']}")


if __name__ == "__main__":
asyncio.run(main())
59 changes: 59 additions & 0 deletions scripts/user-message-reviewer/message_extraction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import ast
import json
from pathlib import Path

"""
def mark_for_review(message):
return message

# Example error messages
error_message = mark_for_review("Invalid input: '{input_value}'")
not_found_message = mark_for_review("File '{filename}' was not found")
"""


def extract_unreviewed_messages(code_file, output_file, condition=None):
"""
Extracts messages from mark_for_review calls that match a condition on the version tag.

Parameters:
code_file (str): Path to the Python source code file.
output_file (str): Path to save the extracted messages as a JSON file.
condition (callable, optional): A function to evaluate the version tag. If None, extracts messages with no version tag.
"""
with Path.open(code_file) as f:
tree = ast.parse(f.read())

messages = {}

# Walk through the AST to find calls to `mark_for_review`
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and getattr(node.func, "id", None) == "mark_for_review"
):
if len(node.args) >= 1 and isinstance(node.args[0], ast.Constant):
original_message = node.args[0].value
version = None

# Check if a second argument (version) exists
if len(node.args) > 1 and isinstance(node.args[1], ast.Constant):
version = node.args[1].value

# Apply the filter condition
if condition is None or condition(version):
key = f"{node.lineno}_{node.col_offset}" # Unique key based on location
messages[key] = {"message": original_message, "version": version}

# Save extracted messages to a JSON file
with Path.open(output_file, "w") as f:
json.dump(messages, f, indent=4)


# Example usage
# Condition: Extract messages with no version or explicitly unreviewed (e.g., version is None or "unreviewed")
def is_unreviewed(version):
return version is None or version == "unreviewed"


# extract_unreviewed_messages("your_script.py", "unreviewed_messages.json", condition=is_unreviewed)
90 changes: 90 additions & 0 deletions scripts/user-message-reviewer/message_refinement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import json
import os
from pathlib import Path

import openai

# Set your OpenAI API key
openai.api_key = os.environ["OPENAPI_API_KEY"]


def load_rules(rules_file):
"""Load rules for message refinement from a JSON file."""
with Path.open(rules_file) as f:
return json.load(f)


def load_messages(messages_file):
"""Load messages from the messages.json file."""
with Path.open(messages_file) as f:
return json.load(f)


def send_to_chatgpt(messages, rules):
"""Send messages and rules to ChatGPT and request alternatives."""
refined_messages = {}
for key, details in messages.items():
message = details["message"]
version = details.get("version", "unknown")

# Prepare the prompt
prompt = f"""
You are a language expert. Apply the following rules to suggest alternatives for the given message:

Rules:
- Tone: {rules['tone']}
- Style: {rules['style']}
- Length: {rules['length']}
- Placeholders: {rules['placeholders']}

Original Message: "{message}"

Provide three alternatives based on the above rules. Ensure placeholders remain intact.
"""

# Send the request to ChatGPT
response = openai.Completion.create(
engine="text-davinci-003", prompt=prompt, max_tokens=150, temperature=0.7
)

# Parse the response
alternatives = response.choices[0].text.strip()
refined_messages[key] = {
"original": message,
"version": version,
"alternatives": alternatives.split(
"\n"
), # Split into list if alternatives are on separate lines
}

return refined_messages


def save_refined_messages(output_file, refined_messages):
"""Save the refined messages to a JSON file."""
with Path.open(output_file, "w") as f:
json.dump(refined_messages, f, indent=4)


# Example usage
# update_messages_in_code("your_script.py", "messages.json")


# Main script
if __name__ == "__main__":
# File paths
messages_file = "messages.json"
rules_file = "rules.json"
output_file = "refined_messages.json"

# Load inputs
rules = load_rules(rules_file)
messages = load_messages(messages_file)

# Process messages with ChatGPT
refined_messages = send_to_chatgpt(messages, rules)

# Save the refined messages
save_refined_messages(output_file, refined_messages)

print(f"Refined messages saved to {output_file}.")
Loading
Loading