diff --git a/packages/common-library/src/common_library/errors_classes.py b/packages/common-library/src/common_library/errors_classes.py index dfee557d38c..00cae26d1b2 100644 --- a/packages/common-library/src/common_library/errors_classes.py +++ b/packages/common-library/src/common_library/errors_classes.py @@ -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 diff --git a/packages/common-library/tests/test_errors_classes.py b/packages/common-library/tests/test_errors_classes.py index 808ed09c40d..5a6c4d5f87a 100644 --- a/packages/common-library/tests/test_errors_classes.py +++ b/packages/common-library/tests/test_errors_classes.py @@ -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(): @@ -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" + ) diff --git a/scripts/rewrite_errors_llm.py b/scripts/rewrite_errors_llm.py new file mode 100644 index 00000000000..3d2c9ac826f --- /dev/null +++ b/scripts/rewrite_errors_llm.py @@ -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: +If not enough context: +NEED MORE INFO: +""".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()) diff --git a/scripts/user-message-reviewer/message_extraction.py b/scripts/user-message-reviewer/message_extraction.py new file mode 100644 index 00000000000..26e7aa26129 --- /dev/null +++ b/scripts/user-message-reviewer/message_extraction.py @@ -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) diff --git a/scripts/user-message-reviewer/message_refinement.py b/scripts/user-message-reviewer/message_refinement.py new file mode 100644 index 00000000000..cbfd58fd386 --- /dev/null +++ b/scripts/user-message-reviewer/message_refinement.py @@ -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}.") diff --git a/scripts/user-message-reviewer/message_reintegration.py b/scripts/user-message-reviewer/message_reintegration.py new file mode 100644 index 00000000000..cea9aed8624 --- /dev/null +++ b/scripts/user-message-reviewer/message_reintegration.py @@ -0,0 +1,43 @@ +import ast +import json +from pathlib import Path + +import astor + + +def update_messages_in_code(code_file, messages_file): + # Load corrected messages from the JSON file + with Path.open(messages_file) as f: + corrected_messages = json.load(f) + + # Parse the Python code + with Path.open(code_file) as f: + tree = ast.parse(f.read()) + + # 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" + ): + # Get the original message + if isinstance(node.args[0], ast.Constant): + original_message = node.args[0].value + + # Match it with corrected messages + for key, corrected_message in corrected_messages.items(): + if original_message == corrected_message["original"]: + # Replace the message with the corrected version + node.args[0].value = corrected_message["current"] + + # Add the version as the second argument + version_node = ast.Constant(value=corrected_message["version"]) + if len(node.args) == 1: + node.args.append(version_node) + else: + node.args[1] = version_node + + # Write the updated code back to the file + updated_code = astor.to_source(tree) + with Path.open(code_file, "w") as f: + f.write(updated_code)